Sidecar is an opinionated TypeScript framework for building interactive MCP apps once and targeting ChatGPT and Claude.
It gives you a Next.js-style project structure for MCP:
- write tools as normal TypeScript functions
- write widgets as React components
- use typed helpers instead of raw JSON-RPC and metadata strings
- generate MCP apps and Claude plugin packages from the same source tree
- keep platform-specific features in
@sidecar-ai/openaiand@sidecar-ai/anthropic
Sidecar is currently alpha. The core API is usable, but public docs, deployment polish, and larger examples are still evolving.
npm create sidecar-app@latest my-app
cd my-app
npm install
npm run devFor an existing project:
npm install sidecar-aiFor an HTTPS MCP URL that can be added to ChatGPT or Claude:
npm run dev:httpssidecar dev --tunnel starts Sidecar on Streamable HTTP, opens a temporary HTTPS tunnel, and validates the public MCP endpoint before printing the URL. Sidecar tries cloudflared first. If it is missing, the CLI asks whether to install cloudflared or continue with npx wrangler.
The generated tunnel URL is public and unprotected unless your app has auth.ts, proxy.ts, or upstream network controls in place. Treat tunneled dev servers as temporary test endpoints, avoid sensitive data, and stop the process when you are done. Quick tunnels are still best-effort infrastructure; for repeatable team testing, use a configured tunnel/domain or a deployed preview.
my-app/
sidecar.config.ts
style.css
auth.ts # optional
proxy.ts # optional
server/
add-numbers/
tool.ts
widget.tsx # optional React UI for the tool
resources/
company-handbook/
resource.ts
prompts/
review-expense/
prompt.tsFolder names become stable machine ids by default:
server/add-numbers/tool.tsbecomes tool idadd-numbersresources/company-handbook/resource.tsbecomes URIsidecar://resources/company-handbookprompts/review-expense/prompt.tsbecomes prompt namereview-expense
You can override ids and URIs when you need to.
import { defineConfig } from "sidecar-ai";
export default defineConfig({
name: "Expense Review",
version: "0.1.0",
description: "Review expense reports with MCP tools and widgets.",
build: {
target: "mcp",
plugins: true
},
pagination: {
pageSize: 50
}
});Sidecar uses sidecar.config.ts for app identity, generated manifests, plugin metadata, build defaults, and MCP capability settings. CLI flags override config values, so sidecar build --target claude still works for one-off builds.
Tools live in server/<tool-id>/tool.ts.
import { tool, toolResult } from "sidecar-ai";
type Params = {
/** First number to add. */
a: number;
/** Second number to add. */
b: number;
};
type Result = {
/** Sum of the two input numbers. */
sum: number;
};
export default tool({
name: "Add Numbers",
description: "Use this when the user wants to add two numbers.",
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false
},
execute(params: Params) {
const structuredContent: Result = {
sum: params.a + params.b
};
return toolResult({
structuredContent,
content: `The sum is ${structuredContent.sum}.`
});
}
});Every tool returns toolResult(...). Sidecar keeps the MCP result channels explicit:
structuredContent: typed data for widgets and clientscontent: model-visible contentmeta: optional host/widget-only metadata
execute can be sync or async.
If you want runtime validation, attach a Zod schema directly to execute:
import { z } from "zod";
import { tool, toolResult, withParams } from "sidecar-ai";
const Params = z.object({
query: z.string().min(2).describe("Search query."),
limit: z.number().int().min(1).max(20).optional()
});
export default tool({
name: "Search Pages",
description: "Search pages by query.",
execute: withParams(Params, (params) => {
return toolResult({
structuredContent: { results: [] },
content: `Searched for ${params.query}.`
});
})
});For one tool, use one params source of truth. If withParams(...) or params
is present, Sidecar uses that runtime schema for validation and MCP
inputSchema; otherwise it infers inputSchema from the TypeScript type on
execute.
Place widget.tsx next to a tool to give it UI.
import { widget, useToolResult } from "@sidecar-ai/react";
type Result = {
sum: number;
};
function AddNumbersWidget() {
const { structuredContent } = useToolResult<Result>();
return (
<main style={{ padding: 16 }}>
<h1>Sum</h1>
<output>{structuredContent?.sum ?? "--"}</output>
</main>
);
}
export default widget(
{
description: "Shows the computed sum from the Add Numbers tool.",
csp: {
connectDomains: [],
resourceDomains: []
}
},
AddNumbersWidget
);Sidecar bundles widgets into content-hashed ui://... resources and emits the MCP Apps metadata needed for hosts to render them. Cache-busting widget URIs are generated automatically when UI output changes.
Widget code is React. The iframe still supports normal CSS, Tailwind, and any React component library you choose.
If a widget needs bundler support beyond the defaults, extend the esbuild
options from sidecar.config.ts while Sidecar still owns the MCP wrapper,
bridge, native stylesheet, and hashed HTML output:
export default defineConfig({
name: "Expense Review",
version: "0.1.0",
description: "Review expense reports with MCP tools and widgets.",
build: {
widgets: {
configure: "./sidecar.widgets.ts",
esbuild: {
alias: {
"@assets": "./assets"
},
loader: {
".svg": "text",
".mdx": "text"
}
}
}
}
});// sidecar.widgets.ts
import { defineWidgetBundler } from "sidecar-ai";
export default defineWidgetBundler(({ esbuildOptions }) => ({
esbuildOptions: {
define: {
...esbuildOptions.define,
"process.env.WIDGET_MODE": JSON.stringify("preview")
}
}
}));Resources expose readable MCP context.
import { resource, resourceResult } from "sidecar-ai";
export default resource({
name: "Company Handbook",
description: "Reference handbook for expense policy.",
mimeType: "text/markdown",
annotations: {
audience: ["assistant"],
priority: 0.7
},
read() {
return resourceResult({
content: "# Handbook\n\nExpense reports need receipts.",
mimeType: "text/markdown"
});
}
});resourceResult(...) mirrors toolResult(...): it is the required Sidecar envelope that lowers to MCP resources/read.
Prompts expose reusable MCP prompt templates.
import { prompt } from "sidecar-ai";
export default prompt({
title: "Review Expense",
description: "Creates an expense review request.",
args: {
reportId: "Expense report id to review.",
severity: {
description: "How urgent the review is.",
required: false
}
},
run({ reportId, severity }: { reportId: string; severity?: string }) {
return `Review expense report ${reportId}. Urgency: ${severity ?? "normal"}.`;
}
});Returning a string creates one MCP user text message. Advanced prompts can return many MCP prompt messages directly.
Sidecar paginates the MCP list operations that support cursors:
tools/listresources/listresources/templates/listprompts/list
The default page size is 50. Override globally or per operation:
import { defineConfig, offsetPagination } from "sidecar-ai";
export default defineConfig({
name: "Acme",
version: "0.1.0",
description: "Acme MCP app.",
pagination: {
pageSize: 50,
override: {
default({ items, cursor, pageSize }) {
return offsetPagination({ items, cursor, pageSize });
},
toolsList({ items, cursor, pageSize, auth }) {
return offsetPagination({
items: items.filter((tool) => canUseTool(auth, tool)),
cursor,
pageSize
});
}
}
}
});Clients treat cursors as opaque. The server decides page size.
Sidecar imports @sidecar-ai/native/styles.css before your root style.css.
Use style.css for:
- Tailwind entrypoints
- app-wide layout classes
- product tokens
- intentional native token overrides
Use portable native components when you want controls that adapt to the current host:
import { Button, Text, Surface } from "@sidecar-ai/native/components";
export function ReviewPanel() {
return (
<Surface>
<Text>Ready for review.</Text>
<Button color="primary">Approve</Button>
</Surface>
);
}Use platform packages when you intentionally want host-specific APIs or components:
@sidecar-ai/openai@sidecar-ai/openai/components@sidecar-ai/anthropic@sidecar-ai/anthropic/components
Sidecar warns when shared widgets import platform-specific features without an obvious platform boundary.
Use platform-specific files when a tool or widget should differ by host:
server/
report/
tool.ts
widget.tsx
tool.openai.ts
widget.openai.tsx
tool.anthropic.ts
widget.anthropic.tsxBuild targets select the matching files:
sidecar build --target mcp
sidecar build --target chatgpt
sidecar build --target claude --plugins
sidecar build --target mcp --host vercelSet build.target in sidecar.config.ts when one platform is the normal app target, then sidecar build is enough.
mcp uses only standard MCP behavior. chatgpt and claude add platform-specific output where supported.
--host selects the hosting artifact shape. node emits a standalone Node server; vercel emits Vercel Build Output API files at .vercel/output. When VERCEL=1 is present, sidecar build selects the Vercel host automatically.
Code mode exposes a small public tool catalog for hosts that prefer generated code over many individual tools:
search_toolsget_tool_schemaexecute_code
Your authored tools still live in server/<tool-id>/tool.ts, but Sidecar keeps
them internal and gives generated code a typed tools.* API. If internal tools
return widgets, execute_code can render the selected internal widget through a
single dynamic code-mode widget.
// sidecar.config.ts
import { defineConfig } from "sidecar-ai";
export default defineConfig({
name: "Code Mode Demo",
version: "0.1.0",
codeMode: {
render: {
enabled: true,
strategy: "last-renderable"
}
},
remoteExecution: true
});Remote execution is owned by your app through reserved remote.ts. Sidecar
generates a runner, a command, and a short-lived callback token; your executor
writes the files, runs the command, and returns stdout/stderr.
// remote.ts
import { remote } from "sidecar-ai/remote";
export default remote({
async execute(run, ctx) {
ctx.log.info(`Running ${run.id}`);
// Write run.files into your sandbox, run run.command with run.env,
// enforce run.timeoutMs, then return the process result.
return {
exitCode: 0,
stdout: "",
stderr: ""
};
}
});For multi-instance or serverless hosts, set SIDECAR_CODE_MODE_SECRET so remote
tool callbacks use stateless encrypted tokens instead of process-local dev
tokens. For trusted local experiments only, use codeMode: { unsafe: true } to
run generated code inside the MCP server process.
auth.ts owns MCP/OAuth resource-server behavior. Your auth provider still validates tokens.
import { auth, scope, type AuthSession } from "sidecar-ai";
type Session = AuthSession<
{ sub: string; scope: string; org_id: string },
{ orgId: string }
>;
const appAuth = auth({
resource: "https://api.example.com/mcp",
authorizationServers: ["https://auth.example.com"],
scopes: {
expensesRead: scope("expenses.read", "Read expense reports.")
},
async session(request): Promise<Session | null> {
const claims = await verifyWithYourProvider(request.bearerToken(), {
audience: "https://api.example.com/mcp"
});
if (!claims) return null;
return {
userId: claims.sub,
scopes: claims.scope.split(" "),
claims,
orgId: claims.org_id
};
}
});
export const { scopes } = appAuth;
export default appAuth;Tool policy lives with the tool:
import { tool, toolResult } from "sidecar-ai";
import { scopes } from "../../auth.js";
export default tool({
name: "Review Expense Report",
description: "Use this to review one expense report for policy issues.",
auth: {
scopes: [scopes.expensesRead]
},
async execute(params: { reportId: string }, ctx) {
const review = await ctx.services.expenses.review(params.reportId, {
orgId: ctx.auth.orgId
});
return toolResult({
structuredContent: review,
content: `Reviewed expense report ${params.reportId}.`
});
}
});proxy.ts is for HTTP middleware such as origins, request ids, and rate limits:
import { origin, proxy, rateLimit, requestId } from "@sidecar-ai/server/proxy";
export default proxy({
before: [
requestId(),
origin({
allow: ["https://chatgpt.com", "https://claude.ai"],
dev: ["http://localhost:*"]
}),
rateLimit({ windowMs: 60_000, max: 120 })
]
});Claude plugin-specific pieces can be authored as TypeScript and generated into plugin files.
Agents:
agents/
review-writer/
agent.tsimport { agent } from "@sidecar-ai/anthropic/plugin";
export default agent({
name: "review-writer",
description: "Use to draft concise expense review summaries.",
tools: ["Read", "Grep"],
disallowedTools: ["Write"],
prompt: "Draft concise expense review summaries from Sidecar tool results."
});Hooks:
hooks/
protect-writes/
hook.tsimport { commandHook, hook } from "@sidecar-ai/anthropic/hooks";
export default hook({
event: "PreToolUse",
matcher: "Write",
run: [commandHook("echo checking write permissions")]
});Slash commands:
import { command } from "@sidecar-ai/anthropic/plugin";
export default command({
name: "review-summary",
description: "Draft a short expense review summary.",
argumentHint: "[report-id]",
allowedTools: ["expenses.review"],
prompt: "Draft a concise review summary for the current expense report."
});Inside a Sidecar app:
npm run dev # local Streamable HTTP MCP server
npm run dev:https # local server plus HTTPS tunnel
npm run check # diagnostics
npm run inspect # list detected tools
npm run build # build MCP and plugin artifactsDirect CLI usage:
sidecar dev --port 3101
sidecar dev --tunnel
sidecar check --strict
sidecar build
sidecar build --target mcp
sidecar build --target mcp --host vercel
sidecar build --target chatgpt
sidecar build --target claude --pluginssidecar check prints diagnostics as file:line:column messages. Build and dev print the same diagnostics. Use // sidecar-ignore DIAGNOSTIC_CODE when an exception is intentional.
out/
mcp/
package.json
server/index.js
manifest.sidecar.json
public/widgets/...
chatgpt/
package.json
server/index.js
manifest.sidecar.json
public/widgets/...
claude/
package.json
server/index.js
manifest.sidecar.json
claude-plugin/
.claude-plugin/plugin.json
.mcp.json
skills/
commands/
hooks/
agents/
.vercel/
output/
config.json
functions/api/sidecar.func/
.vc-config.json
index.js
server/index.js
manifest.sidecar.jsonBy default, each MCP target includes a hostable Node server. Start it from the target output:
cd out/mcp
SIDECAR_MCP_URL=https://your-host.example.com/mcp npm startThe generated server listens on PORT or SIDECAR_PORT and serves Streamable HTTP at /mcp. Claude plugin packages reference the hosted MCP URL instead of bundling the server. After hosting the MCP server, update the generated claude-plugin/.mcp.json URL from the placeholder to your real HTTPS MCP endpoint before sharing or installing the plugin.
For Vercel, no custom build or output setting is required. Use the normal package build script:
npm run buildVercel sets VERCEL=1, so sidecar build automatically emits .vercel/output using Vercel's Build Output API. In a monorepo, set only the Vercel Root Directory to the Sidecar app folder. Set SIDECAR_MCP_URL to the final public https://.../mcp URL in Vercel.
This repository is a monorepo:
packages/
sidecar-ai/
core/
cli/
compiler/
server/
native/
openai/
anthropic/
examples/
simple/Package releases are cut from GitHub Actions using npm trusted publishing. See RELEASE.md for the maintainer workflow.
Contributor commands:
npm install
npm run typecheck
npm test
npm run build
node dist/cli/index.js build --cwd examples/simple --target chatgpt