Documentation
Silkweave is a TypeScript toolkit for building MCP servers, REST APIs, and CLI tools from a single set of Actions. Define your business logic once, then deploy it via any transport.
Installation
Install the core package and at least one adapter:
pnpm add @silkweave/core @silkweave/mcp zodZod 3 or 4. Every package declares zod@^3.25.0 || ^4.0.0, so Silkweave installs cleanly alongside whichever major your app is already on - no forced migration. Import the v4 API as zod/v4 either way; that subpath ships in both 3.25+ and 4.x. @silkweave/cli and the silkweave CLI require Node.js 22.12+; the other packages require Node 18+.
| Package | Description |
|---|---|
@silkweave/core | Actions, adapters, builder, context, error utilities |
@silkweave/mcp | MCP stdio and streamable HTTP adapters |
@silkweave/fastify | Fastify REST API with auto-generated Swagger UI |
@silkweave/cli | CLI adapter with Commander + Clack prompts |
@silkweave/edge | Stateless MCP over Web Standard edge / serverless (Vercel, Cloudflare, Bun) |
@silkweave/typegen | Build-time .d.ts generation from action Zod schemas |
@silkweave/auth | Resource-server core (bearer validation, protected-resource metadata); OAuth 2.1 proxy behind /oauth |
Quick Start
1. Define an action
import { createAction } from '@silkweave/core'
import z from 'zod/v4'
export const GreetAction = createAction({
name: 'greet',
description: 'Greet a user by name',
input: z.object({
name: z.string().describe('Name to greet')
}),
run: async ({ name }) => {
return { message: `Hello, ${'$'}{name}!` }
}
})2. Wire it up and start
import { silkweave } from '@silkweave/core'
import { stdio } from '@silkweave/mcp'
import { GreetAction } from './actions/greet.js'
await silkweave({ name: 'my-server', version: '1.0.0' })
.adapter(stdio())
.action(GreetAction)
.start()Swap stdio() for http(), fastify(), cli(), or edge() - the action stays identical.
Core Concepts
Actions
An Action is a named operation with a Zod input schema and an async handler. Actions are completely transport-agnostic - they know nothing about HTTP, stdio, or CLI.
interface Action<I, O, Chunk = never> {
name: string
description: string
input: z.ZodType<I>
output?: z.ZodType<O> // buffered actions
chunk?: z.ZodType<Chunk> // streaming actions
kind?: 'query' | 'mutation' // tRPC dispatch
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' // REST verb
path?: string // REST route, e.g. 'spaces/:spaceId/users'
queryParams?: (keyof I)[] // input fields read from the query string
args?: (keyof I)[]
disposition?: 'json' | 'smart' | 'structured' // MCP result format ('json' is the default)
annotations?: ToolAnnotations // MCP behavior hints (readOnlyHint, destructiveHint, ...)
tags?: string[] // grouping labels for per-request tool filtering
isEnabled?: (context: SilkweaveContext) => boolean
run:
| ((input: I, context: SilkweaveContext) => Promise<O>)
| ((input: I, context: SilkweaveContext) => AsyncGenerator<Chunk>)
toolResult?: (response: O, context: SilkweaveContext) => CallToolResult | undefined
}name- Identifier used for routing (kebab-case for CLI, PascalCase for MCP tools, URL path for Fastify).input- Zod object schema. Drives validation, CLI option generation, and OpenAPI docs.output- Optional Zod object schema for the return type. Used by the typegen adapter to generate typed response interfaces and by Fastify for OpenAPI response schemas.chunk- Optional Zod schema for individual chunks yielded by a streamingrun(anasync function*). Presence of this field plus a generatorrunmarks the action as streaming; adapters then switch to per-chunk wire delivery.method/path/queryParams- Optional REST routing for the Fastify and NestJSrestadapters. See REST routing.args- Keys frominputto treat as CLI positional arguments instead of named options. Honored by the local CLI adapter and carried over the wire (via MCP tool_meta) to CLI Proxy clients.isEnabled- Optional guard. Returnfalseto hide this action from a specific adapter or context. See Per-Action Guards.run- Your business logic. Receives validated input and a context bag.toolResult- Optional hook to customize how the return value is formatted as an MCPCallToolResult. Returnundefinedto fall through to the resolveddisposition. Only affects MCP adapters.disposition- MCP result format -'json'(the default,jsonToolResult),'smart'(smartToolResult, sideloads payloads over 4096 chars), or'structured'(declaresoutputas the tool's MCPoutputSchemacontract and ships schema-parsedstructuredContent- see Tool Results). A client's_meta.dispositionoverrides'json'/'smart'but never'structured'. MCP adapters only.annotations- Optional MCP tool annotations (readOnlyHint,destructiveHint,idempotentHint,openWorldHint,title) forwarded totools/list; MCP hosts use them to group and permission-gate tools. Adapters derivereadOnlyHintfromkind('query'⇒ read-only) and merge explicit annotations over the derived base.tags- Optional free-form grouping labels (e.g.['leads', 'write']) for the MCP adapters' per-requestfilterActionsto match on.
Adapters
Adapters translate actions into a specific transport. The pattern is AdapterFactory → AdapterGenerator → Adapter:
- AdapterFactory - Takes transport-specific config (port, host, auth) and returns a generator.
- AdapterGenerator - Takes silkweave options + base context, returns the adapter.
- Adapter - Has
start(actions)andstop()methods.
The fluent builder handles all of this - you just call .adapter(stdio()).
| Adapter | Package | Transport |
|---|---|---|
stdio() | @silkweave/mcp | MCP over stdin/stdout |
http() | @silkweave/mcp | MCP Streamable HTTP (Express) |
fastify() | @silkweave/fastify | REST API + Swagger UI |
cli() | @silkweave/cli | CLI via Commander + Clack |
edge() | @silkweave/edge | Stateless MCP (Web Standard) |
typegen() | @silkweave/typegen | Build-time .d.ts type generation |
Context & Dependency Injection
Silkweave uses a simple key-value context bag for dependency injection. No magic, no decorators - just .set() and .get().
Setting up context
Use the fluent .set(key, value) API to inject singletons (database pools, config, services) into the base context:
import { silkweave, createContext } from '@silkweave/core'
import { http } from '@silkweave/mcp'
import pg from 'pg'
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
await silkweave({ name: 'my-server', version: '1.0.0' })
.set('db', pool)
.adapter(http({ host: 'localhost', port: 8080 }))
.action(GreetAction)
.start()Accessing context in actions
export const ListUsersAction = createAction({
name: 'listUsers',
description: 'List all users',
input: z.object({}),
run: async (_input, context) => {
const db = context.get<pg.Pool>('db')
const { rows } = await db.query('SELECT * FROM users')
return { users: rows }
}
})How context flows
The base context you configure via .set() is shared across all adapters (singleton). Each adapter forks the context per request, adding transport-specific values. The fork creates a shallow copy - your singletons are shared, but per-request values are isolated.
| Adapter | "Request" means | Context keys injected |
|---|---|---|
| Fastify | HTTP request | logger, adapter, request, auth? |
| CLI | Process execution | logger, adapter, command |
| MCP stdio | JSON-RPC message | logger, adapter, extra |
| MCP HTTP | HTTP + JSON-RPC | logger, adapter, extra, auth? |
| Vercel | Serverless invocation | logger, adapter, extra, auth? |
Use context.get<T>(key) when the key is guaranteed to exist (throws if missing). Use context.getOptional<T>(key) for keys that may not be present (returns undefined).
Streaming Actions
A streaming action returns multiple chunks over the lifetime of a single invocation instead of one buffered result. Declare a chunk Zod schema and write run as an async function*:
import { createAction } from '@silkweave/core'
import z from 'zod/v4'
export const GenerateMessagesAction = createAction({
name: 'generate-messages',
description: 'Stream a series of messages about a topic',
input: z.object({
topic: z.string(),
count: z.number().int().min(1).max(50).default(5)
}),
chunk: z.object({
index: z.number().int(),
text: z.string()
}),
run: async function* ({ topic, count }, { logger }) {
logger.info('streaming ' + count + ' messages')
for (let i = 0; i < count; i += 1) {
yield { index: i, text: 'Message ' + (i + 1) + ' about ' + topic }
}
}
})The action itself is unchanged across transports - the same generator runs whether it's invoked over MCP, HTTP, tRPC, or the CLI. Adapters detect streaming actions via isStreamingAction() at registration time and switch to per-chunk wire delivery, with backpressure wired end-to-end (each chunk write awaits the wire-level drain before pulling the next value from your generator).
How each adapter delivers chunks
| Adapter | Wire format | How a client opts in | Buffered fallback |
|---|---|---|---|
MCP (stdio, http, vercel) | MCP notifications/progress - one notification per chunk, JSON-stringified into the message field | Client sends _meta.progressToken with the tool call (the MCP SDK does this automatically when the host registers a progress listener) | Tool resolves with the buffered chunk array as the CallToolResult |
| Fastify REST | text/event-stream (SSE: data: <json>\n\n per chunk, ending with event: done) or application/x-ndjson (one JSON chunk per line) | Accept: text/event-stream or Accept: application/x-ndjson | 200 OK with the chunk array as a JSON body |
tRPC (trpc, trpcFetch) | tRPC .subscription() whose async generator yields chunks directly; consumer iterates with client.foo.subscribe(...) | Streaming action ⇒ always a subscription (regardless of kind) | n/a - the consumer drives chunk delivery |
| CLI | NDJSON on stdout (one JSON chunk per line, pipe-friendly) | Streaming action ⇒ always streamed | n/a |
NestJS (trpc) | Same as tRPC above - an async function* controller method carrying @Trpc() (with a chunk type) is discovered as a streaming action and registered as a .subscription() | Decorated method is an async function* | n/a |
What streaming means for AI/MCP clients
This is the part that most often surprises people, and it's worth being explicit about.
On the wire, MCP notifications/progress is standard protocol - your server emits one notification per chunk, and any compliant MCP client receives them as they're produced.
What the client does with them is up to the client. Most LLM-driven MCP hosts today (Claude Code, Cursor, generic chat UIs) consume progress notifications for UI rendering - spinners, status text, progress bars in the terminal or chat - and not as incremental data fed into the model's context. From the model's perspective, an MCP tool call is still atomic: invoke → wait → single aggregated result.
So the chunks reach the wire correctly; in-flight model visibility depends on host behavior, not on your server. Today, you can rely on chunks being delivered to the host, surfaced as UI affordances (spinner text, progress bars) visible to the human user, and useful for resumability/cancellation. You generally cannot rely on the host feeding chunks into the model's context as they arrive.
If you need per-chunk model visibility today, prefer transports where streaming is part of the consumer contract rather than a side channel:
- Fastify SSE/NDJSON - your consumer (a frontend, a worker, another agent) iterates chunks directly
- tRPC subscriptions - typed async-iterable client-side; trivial to fan out chunk-by-chunk
- CLI NDJSON -
your-tool generate-messages | jqprocesses each line as it arrives
Resource Results (binary)
An action can return a binary or text artifact - a screenshot, PDF, rendered chart, audio clip, or a JSON/markdown artifact meant to travel as a file rather than inline JSON. Declare the output with binary() and return a resource():
import { binary, createAction, resource } from '@silkweave/core'
import z from 'zod/v4'
export const ScreenshotAction = createAction({
name: 'screenshot',
description: 'Capture a screenshot of a URL',
kind: 'query',
input: z.object({ url: z.string().describe('The URL to capture.') }),
output: binary({ mimeType: 'image/png' }),
run: async ({ url }) => resource(await capture(url), {
mimeType: 'image/png',
name: 'screenshot.png',
description: 'Screenshot of ' + url
})
})The run may return a resource(), a Web-Standard File/Blob, or bare Uint8Array/ArrayBuffer bytes - adapters normalize the value, using the binary() metadata as defaults for whatever the value doesn't carry (last resort: application/octet-stream). The optional description travels with the artifact so agents know what it is without decoding it. Everything is Web-Standard (no Buffer), so resource actions run on edge runtimes too.
How each adapter delivers a resource
| Adapter | Delivery |
|---|---|
MCP (stdio, http, edge) | Mime-driven content blocks: the description ships first as a text block, then raster image/* (png/jpeg/gif/webp) becomes an image block - multimodal hosts show it to the model, so an agent can literally see a screenshot - audio/* an audio block, text-based media (JSON, markdown, SVG) an embedded resource with text, and any other binary an embedded resource with a base64 blob. A client's _meta.disposition cannot demote a resource result. |
| Fastify REST | The raw payload: Content-Type from the media type, Content-Disposition: inline; filename= from name, Content-Description from description. OpenAPI documents the response as type: string, format: binary. curl .../screenshot?url=… > shot.png just works. |
tRPC (trpc, trpcFetch) | The SerializedResource JSON envelope: { kind: 'resource', mimeType, name?, description?, text? | base64? } - text media carries text, binary carries base64 (handy as a data URL in a frontend). InferTrpcRouter types the envelope end-to-end. |
| CLI / cliProxy | Terminal-appropriate: piped stdout gets the raw bytes (my-cli screenshot > shot.png, description on stderr); an interactive TTY writes to --output <path> or a file named after the resource - binary is never dumped onto a terminal. |
| NestJS | @Mcp({ resource: { mimeType, name?, description? } }) / @Trpc({ resource }) declare a controller route's result a resource; a reflected non-JSON @Header('Content-Type', …) flips the route automatically, and returned StreamableFiles are collected to bytes. An existing REST screenshot endpoint gets correct MCP behavior with a bare @Mcp(). |
| typegen | binary() outputs emit the SerializedResource interface in the generated .d.ts and AppRouter. |
Resources are embedded-only (no hosted resource links), and are incompatible with disposition: 'structured' and with streaming actions - both rejected at registration. A toolResult hook still wins over the built-in mapping when you need full control.
Skills over MCP
Silkweave MCP servers can serve Agent Skills - the SKILL.md directories Claude Code and other agents use - versioned, digest-verified, and installable with one command. Point the skills option (available on stdio(), http(), mcpTransport(), and edge()) at your skill directories:
import { defineSkill, silkweave } from '@silkweave/core'
import { http } from '@silkweave/mcp/server'
await silkweave({ name: 'team-skills', description: 'Team skills server', version: '1.0.0' })
.adapter(http({
host: 'localhost',
port: 8080,
skills: [
defineSkill({ dir: './skills/commit-message' }),
defineSkill({ dir: './skills/release-checklist', tags: ['internal'] })
]
}))
.start()Each skill is served three ways at once, so every client works today while tracking the MCP Skills working group's direction (SEP-2640):
skill://resources - every skill file is an MCP resource (skill://<name>/<path>), readable via plainresources/list/resources/read- the SEP-2640 baseline.ListSkills/GetSkilltools - ordinary actions returning the digest-carrying manifest and per-file content, so any current MCP client (and agents themselves) can discover and install skills. Filters, auth, and telemetry apply like for any other tool.- Server instructions - the
initializeresponse announces the skills, so hosts that surface instructions activate them with no protocol support needed.
Team members (and CI, and a fleet of machines) install and stay current with the silkweave CLI:
# Install/update everything the server offers into ~/.claude/skills
npx silkweave skills sync --url https://skills.example.com/mcp --token $TOKEN
# See what's available / what changed (outdated exits 1 when updates exist)
npx silkweave skills list --url https://skills.example.com/mcp
npx silkweave skills outdated --url https://skills.example.com/mcp
# Cherry-pick, hold, prune
npx silkweave skills install commit-message --url ...
npx silkweave skills pin commit-message
npx silkweave skills sync --prune --url ...
# Bonus: any MCP server as a CLI, built from tools/list at invocation
npx silkweave proxy http://localhost:8080/mcp hello TobiasInstalls land in ~/.claude/skills (or --target) with a .silkweave-lock.json recording server, versions, and per-file sha256 digests. Every file is re-verified against its digest client-side and names/paths are revalidated, so a server can never write outside the target. pin holds a skill across syncs; sync --prune removes skills the server dropped. Versioning comes from the conventional metadata.version frontmatter entry (or defineSkill({ version })).
Private and mixed sharing: combine with bearer/OAuth auth for private skill servers, or serve public and private skills from one endpoint - when a per-request filterActions drops the skill tools, the skill:// resources and instructions are hidden for that request too, so filtered clients can't read the files through the back door. On filesystem-less runtimes (Cloudflare Workers), use defineSkill({ files }) with inline content. Digests bind approval to content but are not signatures - install skills only from servers you trust.
Public skills: the Claude Code plugin bridge
Public skills can additionally get the native Claude Code /plugin experience - real versioning and /plugin update - without exposing a repo or running a registry. Pack and publish a skill as a skills-only plugin npm package, then serve a marketplace from the same server:
# 1. Pack the skill as a skills-only Claude Code plugin, publish to npm
npx silkweave skills pack ./skills/commit-message
npm publish dist/commit-message-plugin --access public
# 2. Serve the marketplace next to the skills (server.ts)
http({
port: 8080,
skills: [defineSkill({ dir: './skills/commit-message', npmPackage: 'commit-message-skill' })],
skillsMarketplace: { owner: { name: 'Your Team' } }
})
# 3. Consumers add it in Claude Code
/plugin marketplace add https://skills.example.com/.claude-plugin/marketplace.json
/plugin install commit-message@team-skillsskills pack reads the version from the frontmatter metadata.version and refuses a version already on the registry, so changed content always ships under a new version. The skillsMarketplace option (on http() and edge()) answers GET /.claude-plugin/marketplace.json with npm-sourced plugin entries for every served skill carrying an npmPackage (from defineSkill({ npmPackage }) or frontmatter metadata.npmPackage). The document is served unauthenticated, like /.well-known/ - it only points at npm packages that are public anyway; skills without an npmPackage stay private to the MCP surface. Consumers run /plugin marketplace add https://<host>/.claude-plugin/marketplace.json, then /plugin install <skill>@<marketplace>.
Action Linter
The action linter is a dev-time guardrail that flags agent-hostile action definitions - the cheap mistakes that quietly degrade an agent's tool use. It catches a missing or throwaway description and undescribed input params, exactly the metadata an LLM relies on to pick and call the right tool.
silkweave().start() runs the linter automatically, writing warnings to stderr via console.warn (safe for stdio, where stdout is the protocol channel). Disable it with SilkweaveOptions.lint: false:
await silkweave({ name: 'my-server', version: '1.0.0', lint: false })
.adapter(stdio())
.action(GreetAction)
.start()You can also run it directly: lintActions(actions) returns the findings, and reportActionLint(actions) prints them (both from @silkweave/core).
Adapter Reference
MCP (stdio)
The stdio() adapter serves actions as MCP tools over stdin/stdout using StdioServerTransport. This is the standard way to connect to Claude Desktop, VS Code, and other MCP clients.
import { silkweave } from '@silkweave/core'
import { stdio } from '@silkweave/mcp'
await silkweave({ name: 'my-server', version: '1.0.0' })
.adapter(stdio())
.action(GreetAction)
.start()- Action names are converted to PascalCase for MCP tool names
- Logging is forwarded via MCP
notifications/message - Progress is reported via MCP
notifications/progress(and streaming actions deliver one notification per chunk when the client sends_meta.progressToken; see the AI-host caveat in that section)
MCP (HTTP)
The http() adapter serves actions as MCP tools over Streamable HTTP with session management. It uses Express under the hood and supports full OAuth 2.1 integration.
import { http } from '@silkweave/mcp'
await silkweave({ name: 'my-server', version: '1.0.0' })
.adapter(http({ host: 'localhost', port: 8080 }))
.action(GreetAction)
.start()Options: host, port, auth (see Authentication), cors, plus any CreateMcpExpressAppOptions.
CORS: By default, CORS is enabled with origin: '*'. Pass cors: false to disable, or a CorsOptions object to customize. MCP-required headers are always exposed regardless of config.
Streaming: Streaming actions deliver each chunk as a notifications/progress on the session's SSE stream when the client opts in with _meta.progressToken. The tool call still resolves with the buffered chunk array as the final CallToolResult; clients that don't opt in receive only that buffered result.
Fastify REST
The fastify() adapter registers each action as a POST /{name} endpoint (by default) with auto-generated OpenAPI documentation and interactive Swagger UI (powered by Scalar). Use the action's method, path, and queryParams fields for custom verbs and dynamic routes - see REST routing.
import { fastify } from '@silkweave/fastify'
await silkweave({ name: 'my-api', version: '1.0.0' })
.adapter(fastify({ port: 8080 }))
.action(GreetAction)
.start()
// Swagger UI at http://localhost:8080/- Zod schemas are converted to JSON Schema for OpenAPI docs
- Supports
authoption for bearer token / OAuth protection - Supports
corsoption:falseto disable,true/omit for permissive defaults (origin: '*'), or a FastifyCorsOptions object - Errors are mapped to HTTP status codes automatically (see Error Handling)
- Streaming actions: content negotiation on
Acceptpicks SSE (text/event-stream), NDJSON (application/x-ndjson), or buffered JSON
REST routing
The REST-style adapters - Fastify and the NestJS rest adapter - map a single input schema across the request's path, query string, and body using three optional action fields:
method-'GET' | 'POST' | 'PUT' | 'DELETE'. Defaults toPOST, orGETwhenkind: 'query'. An explicitmethodalways wins.path- a route template, optionally with:paramplaceholders. Each placeholder must be a key of the input schema and is resolved from the URL path. When unset, the route is derived fromname.queryParams- input fields read from the URL query string instead of the body. On a bodylessGET, every non-path field is read from the query string automatically.
const ListUsers = createAction({
name: 'list.users',
description: 'List users in a space',
kind: 'query', // ⇒ GET
path: 'spaces/:spaceId/users', // GET /spaces/:spaceId/users
queryParams: ['offset', 'limit'],
input: z.object({
spaceId: z.string(), // ← from the path
offset: z.int().optional().default(0), // ← from ?offset=
limit: z.int().optional().default(10) // ← from ?limit=
}),
output: z.object({ users: z.array(z.object({ id: z.string() })) }),
run: async ({ spaceId, offset, limit }) => ({ users: [/* ... */] })
})
// GET /spaces/acme/users?offset=20&limit=10- The input is merged from all three sources with precedence body → query params → path params, then validated as one schema.
- Path and query values arrive as strings and are coerced to the primitive each field's schema expects (number / boolean / bigint).
- Fastify validates each source with its generated JSON Schema (AJV) and emits per-source OpenAPI parameters; validation failures return
400 { error: 'validation_error', issues: [...] }. The NestJSrestadapter validates via the Zod schema. - A
:paramplaceholder orqueryParamsentry that isn't a key of the input schema throws at startup (validateActionRouting).
tRPC
The trpc() adapter exposes each action as a fully-typed tRPC procedure on a standalone HTTP server. The returned Silkweave<Actions> instance carries action type info through .action() calls, so InferTrpcRouter<typeof server> produces a complete AppRouter type for end-to-end client inference. No code generation, no generated .d.ts files.
import { silkweave } from '@silkweave/core'
import { trpc, type InferTrpcRouter } from '@silkweave/trpc'
const server = silkweave({ name: 'my-api', version: '1.0.0' })
.adapter(trpc({ port: 8080 }))
.action(GreetAction)
.action(ListThingsAction)
export type AppRouter = InferTrpcRouter<typeof server>
await server.start()import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from './server.js'
const client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:8080/trpc' })]
})
const hi = await client.greet.mutate({ name: 'World' })
// ^? { message: string }
const things = await client.listThings.query({ contains: 'saw' })
// ^? { items: string[] }- Action names convert to
camelCaseprocedure keys:list-things→client.listThings - Set
kind: 'query'on an action to expose it as a cacheable.query()(GET); default is.mutation()(POST). The literal kind is preserved throughcreateAction, so calling.mutate()on a query is a compile-time error. - Streaming actions (those with a
chunkschema + async-generatorrun) are registered as tRPC.subscription()procedures whose async generators yield chunks directly - client code consumes them withclient.foo.subscribe(input, { onData, onError, onComplete }) - Supports
authoption for bearer token / OAuth protection (see Authentication) - Supports
corsoption:falseto disable,true/omit for permissive defaults, or a CorsOptions object - Options:
host,port,endpoint(default/trpc/),cors,auth - Thrown
SilkweaveErrormaps to the matchingTRPCErrorcode; Zod validation errors becomeBAD_REQUEST
Serverless / fetch handler (trpcFetch)
For Astro API routes, Vercel Functions, Cloudflare Workers, or any Web Standard runtime, use trpcFetch() instead of trpc(). It returns a fetch-compatible (Request) => Promise<Response> handler instead of binding a port.
import { silkweave } from '@silkweave/core'
import { trpcFetch, type InferTrpcRouter } from '@silkweave/trpc'
import { GreetAction } from './actions/greet.js'
const { adapter, handler } = trpcFetch({ endpoint: '/api/trpc' })
export const server = silkweave({ name: 'my-api', version: '1.0.0' })
.adapter(adapter)
.action(GreetAction)
export type AppRouter = InferTrpcRouter<typeof server>
export { handler }
await server.start() // builds the router; no listen()import type { APIRoute } from 'astro'
import { handler } from '../../../server/silkweave.js'
export const GET: APIRoute = ({ request }) => handler(request)
export const POST: APIRoute = ({ request }) => handler(request)- Options:
endpoint(default/trpc),auth. Nocorsoption. Configure CORS in your host framework (Astro middleware,vercel.json, Worker headers) - Internal
_readypromise gates the handler untilserver.start()completes, guarding against cold-start races on serverless - Returns
{ adapter, handler, GET, POST };GET/POSTare aliases for the primaryhandlerso you can export them directly in Astro-style route files
Mounting on a server you already own (trpcNode)
trpc() binds its own port and trpcFetch() returns a Web Standard handler. The third shape is the one a real Node application has - an HTTP server that already exists, owned by something else, onto which tRPC mounts as one route among many. trpcNode() returns that (req, res) handler.
This is not only a convenience. An app whose browser talks tRPC and whose agent talks MCP usually needs both on one origin, for a reason with no workaround: a browser cannot put an Authorization header on a WebSocket upgrade, so the only credential a tab can present on an attach is a cookie - and a cookie is per-origin. Move the API to another port and the app's own login stops authenticating the agent socket.
import { silkweave } from '@silkweave/core'
import { trpcNode, type InferTrpcRouter } from '@silkweave/trpc'
const api = trpcNode({ endpoint: '/trpc', auth })
const server = silkweave({ name: 'wiki', version: '1.0.0' })
.adapter(api.adapter)
.actions(wikiActions)
export type AppRouter = InferTrpcRouter<typeof server>
await server.start()
// Mount on whatever owns the port.
httpServer.on('request', (req, res) => {
if (req.url?.startsWith('/trpc')) { return api.handler(req, res) }
// ...the MCP transport, the agent gateway, the SPA
})- Returns
{ adapter, handler };handleris a(req: IncomingMessage, res: ServerResponse) => void - The handler does not verify the prefix - the underlying tRPC handler slices
endpointoff the path unconditionally, so route only matching URLs into it - No CORS, like
trpcFetchand unliketrpc(): a mounted handler does not own the response headers of the server it is mounted on - Ready-gated, answering
503if the router failed to build, so a request arriving beforestart()resolves neither hangs nor 500s
Cookie / custom authentication (authenticate)
auth reads exactly one credential: a bearer token in the Authorization header. That is right for MCP and for service-to-service calls, and it is not how a same-origin SPA authenticates - the browser sends a cookie, because that is also what authenticates the WebSocket upgrade. authenticate resolves the caller from the request itself, and is available on all three tRPC adapters:
trpcNode({
endpoint: '/trpc',
auth, // the agent still presents a bearer token
authenticate: (req) => { // the user's tab presents a cookie
const user = sessions.resolve(req)
return user ? { token: user.sessionId, clientId: user.id } : null
}
})The chain: authenticate first; returning null falls through to the auth bearer path, so one endpoint serves both callers. With no auth configured, a decline is a 401 without a WWW-Authenticate challenge - advertising OAuth discovery on a cookie endpoint would mislead. Throw a SilkweaveError to reject with a specific status. The AuthInfo you return lands on the same auth context key the MCP adapters use, so one action's run() serves the browser and the agent unchanged.
Two things to know. authenticate bypasses every check validateToken performs - expiry, issuer, audience, required scopes - because what you are validating is not a token; validation is entirely yours, and this is not a "custom token validator". And once a cookie authenticates the endpoint, it inherits a browser CSRF threat model that bearer endpoints are structurally immune to: kind: 'query' actions execute on GET and SameSite=Lax cookies ride top-level GET navigations. Pair it with SameSite cookies, check Origin or Sec-Fetch-Site (you receive the whole request precisely so you can), and keep side effects out of queries.
CLI
The cli() adapter exposes each action as a CLI command using Commander, with plain zero-dependency console output.
import { cli } from '@silkweave/cli'
await silkweave({ name: 'my-tool', version: '1.0.0' })
.adapter(cli())
.action(GreetAction)
.start()
// $ my-tool greet --name Alice- Action names become kebab-case commands
- Zod types map to CLI options:
z.string()→--name <string>,z.boolean()→--verbose / --no-verbose - Use
args: ['name']to make a field a positional argument instead - Streaming actions write NDJSON to stdout (one JSON chunk per line) - pipe-friendly with
jq,head, etc.
CLI Proxy
The cliProxy() adapter (from @silkweave/mcp/cli-proxy) is the client-side twin of the MCP HTTP adapter: it connects to a running MCP server, lists its tools, and synthesizes a Commander subcommand for each. A remote server's tools become a local CLI with zero per-command code - and the CLI stays in sync as tools are added on the server.
import { silkweave } from '@silkweave/core'
import { cliProxy } from '@silkweave/mcp/cli-proxy'
await silkweave({ name: 'my-cli', description: 'My CLI', version: '1.0.0' })
.adapter(cliProxy({
url: new URL('https://gateway.example.com/mcp'),
headers: () => ({ authorization: `Bearer ${process.env.MY_TOKEN}` })
}))
.start()
// $ my-cli create-identity default- Auth passthrough -
headers(a record, or a lazy sync/async thunk resolved once per invocation before connecting) is merged into every request;requestInit,fetch, andauthProviderare forwarded to the SDK'sStreamableHTTPClientTransportfor full control. - Positional arguments - input fields listed in an action's
argscross the wire via the MCP tool's_meta(silkweave/args, ignored by non-silkweave clients) and render as positionals in declared order: required fields as<arg>, optional ones as[arg]. Remaining fields stay named--flags. - Legible failures - a failed connect prints a short message (
authentication failed for <origin> - check your tokenon a 401/403) instead of an SDK stack trace, and root--help/--versionstill work when the server is unreachable.
Edge / Serverless
The edge() adapter creates a stateless MCP server for serverless environments. It uses Web Standard Request / Response APIs, making it compatible with Vercel, Cloudflare Workers, and Bun.
import { edge } from '@silkweave/edge'
const { adapter, GET, POST, DELETE } = edge()
await silkweave({ name: 'my-server', version: '1.0.0' })
.adapter(adapter)
.action(GreetAction)
.start()
export { GET, POST, DELETE }- Each request creates a fresh MCP server instance (fully stateless)
- Uses
WebStandardStreamableHTTPServerTransportfrom the MCP SDK - Only
POSTcarries JSON-RPC;GET/DELETEreturn405(stateless mode has no SSE stream or session to tear down) - Supports OAuth 2.1 and bearer token auth
- Streaming actions deliver chunks as MCP
notifications/progressjust like the stateful HTTP adapter
The same handler runs unchanged on Cloudflare Workers. The examples/cloudflare example is a full Worker deployment - stateless MCP + Google Workspace OAuth 2.1 with OAuth state in Cloudflare KV (Workers have no filesystem, so it reuses createRedisStore over a tiny KV adapter) - with a from-scratch Cloudflare + Google setup guide.
Express-optional / Web Standard tools
express and cors are optional peer dependencies of @silkweave/mcp - they are only needed for the Express-based http() server. A serverless deployment never installs or bundles them.
The transport-agnostic tool-registration and result helpers are re-exported from the express-free @silkweave/mcp/tools subpath, which the Web Standard adapters (@silkweave/edge and @silkweave/nextjs) import - so serverless bundles and installs never pull Express in.
// Express-free: tool registration + result helpers for Web Standard runtimes
import { registerTools, smartToolResult } from '@silkweave/mcp/tools'- Import
@silkweave/mcp(thehttp()server) only when you want the Express transport - installexpress+corsalongside it - The
edge()and@silkweave/nextjsadapters use@silkweave/mcp/tools, keeping Express out of serverless bundles
Type Generation
The typegen() adapter generates .d.ts interface declarations from your action Zod schemas at build time. It emits the declaration text directly, so it carries no typescript compiler dependency at runtime (nothing to install or link at server boot, even on the native tsgo / TS7 toolchain).
import { silkweave } from '@silkweave/core'
import { typegen } from '@silkweave/typegen'
import { GreetAction } from './actions/greet.js'
await silkweave({ name: 'my-server', version: '1.0.0' })
.adapter(typegen({ path: 'types/actions.d.ts' }))
.action(GreetAction)
.start()This produces a .d.ts file with typed interfaces for each action's input and output:
export interface GreetInput {
name: string;
}
export interface GreetOutput {
message: string;
}path- Output file path for the generated.d.tsfile. Directories are created automatically.- Generates
{PascalName}{Input|Output}interfaces per action (e.g.GreetInput,GreetOutput). - Output interfaces are only generated when the action defines an
outputschema. - Uses
allActions: trueto bypassisEnabledfiltering - types are generated for every registered action regardless of runtime guards.
Combining with runtime adapters
Chain typegen() alongside your runtime adapter. The typegen adapter writes the .d.ts file on start and has no other effect:
import { silkweave } from '@silkweave/core'
import { stdio } from '@silkweave/mcp'
import { typegen } from '@silkweave/typegen'
await silkweave({ name: 'my-server', version: '1.0.0' })
.adapter(stdio())
.adapter(typegen({ path: 'types/actions.d.ts' }))
.action(GreetAction)
.start()Supported Zod types
| Zod type | TypeScript output |
|---|---|
z.string() | string |
z.number() | number |
z.boolean() | boolean |
z.enum(['a', 'b']) | 'a' | 'b' |
z.literal('x') | 'x' |
z.array(z.string()) | string[] |
z.object({...}) | {...} (nested interface) |
z.string().optional() | string | undefined |
z.string().nullable() | string | null |
z.string().default('x') | string (optional field) |
z.union([...]) | A | B |
z.record(z.string()) | {[key: string]: string} |
z.tuple([...]) | [A, B] |
Integrations
Adapters are the low-level primitives - one Action maps to one wire format. Integrations sit one level up: they compose adapters with the conventions of a host framework (DI containers, decorators, file-based routing, lifecycle hooks) so Silkweave feels native to that framework.
Pick the integration that matches the shape of your app:
| Integration | When to use | Status |
|---|---|---|
| Standalone | You own your main.ts. Compose silkweave().adapter(...).action(...).start() directly with the adapters from Adapter Reference. | Stable |
| NestJS | You're building on NestJS modules + DI. Define actions as method decorators on providers; expose them simultaneously over REST, tRPC, and MCP on Nest's running HTTP server. | Stable |
| Next.js | You're building on the Next.js App Router. File-based route helpers for MCP and tRPC, sharing your Next request context. | Coming soon |
| Vercel AI SDK | You want useChat / useObject on the frontend without writing a separate /api/chat route. Streams Anthropic/OpenAI/etc. chunks through a tRPC subscription, fully typed end-to-end. | Stable |
Standalone
The default. Compose the fluent builder yourself, pick whichever adapters fit, run it however you like - Node entrypoint, Docker container, serverless function, anywhere a Node process can boot.
import { silkweave } from '@silkweave/core'
import { http } from '@silkweave/mcp'
import { fastify } from '@silkweave/fastify'
import { GreetAction } from './actions/greet.js'
await silkweave({ name: 'my-server', version: '1.0.0' })
.adapter(http({ host: 'localhost', port: 8080 })) // MCP Streamable HTTP
.adapter(fastify({ port: 8081 })) // REST + Swagger
.action(GreetAction)
.start()- Chain as many adapters as you need; the same actions are exposed on every transport (filterable per-action via
isEnabled). - For serverless runtimes, use the fetch-handler adapters (
trpcFetch,edge) instead of server-binding ones. - Full reference: Adapter Reference ↑
NestJS
The @silkweave/nestjs package exposes your existing NestJS controllers as MCP tools (@Mcp()) and end-to-end-typed tRPC procedures (@Trpc()). Add a decorator to a route handler and its name, description, and input schema are reflected from the route, the @Param/@Query/@Body decorators, and any @nestjs/swagger / class-validator metadata the method already carries. On a call the validated input is split back into the method's positional arguments and the handler is invoked directly, with @UseGuards() guards applied first. It is additive - controllers keep serving HTTP unchanged, and the same method can carry both @Mcp() (for agents) and @Trpc() (for your frontend).
pnpm add @silkweave/core @silkweave/nestjs @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata
# then add only the adapter packages you use (each is an optional peer):
pnpm add @silkweave/mcp # if you use mcp()
pnpm add @silkweave/trpc # if you use trpc()
pnpm add @silkweave/typegen # if you use typegen()Adapters live behind subpath exports (@silkweave/nestjs/mcp, /trpc, /typegen) and their stacks are optional peer dependencies, so an MCP-only app never pulls in @trpc/server and a tRPC-only app never pulls in the MCP SDK. The decorators and module come from the root. @nestjs/swagger and class-validator are optional peers too - install whichever your DTOs already use; the reflector reads both and falls back to TypeScript design:type.
import { Body, Controller, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common'
import { ApiOkResponse, ApiOperation, ApiParam, ApiProperty } from '@nestjs/swagger'
import { Mcp, Trpc } from '@silkweave/nestjs'
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator'
class BanUserDto {
@ApiProperty({ description: 'Reason for the ban' })
@IsString() @MinLength(3)
reason!: string
@ApiProperty({ description: 'Permanent?', required: false })
@IsOptional() @IsBoolean()
permanent?: boolean
}
@Controller('users')
export class UsersController {
constructor(private readonly db: DbService) {}
@Get(':id') // ordinary REST route - unchanged
@ApiOperation({ summary: 'Get a single user by ID' })
@ApiParam({ name: 'id', description: 'User ID' })
@ApiOkResponse({ type: UserDto }) // drives the tRPC output type
@Mcp() // ...also an MCP tool (UsersGet)
@Trpc() // ...also a tRPC query (usersGet)
get(@Param('id') id: string) {
return this.db.getUser(id)
}
@Post(':id/ban')
@ApiParam({ name: 'id', description: 'User ID' })
@UseGuards(AuthGuard) // reads the real request (cookie/header) on tRPC; headers on MCP
@Mcp({ description: 'Ban a user (admin only).' })
@Trpc({ description: 'Ban a user (admin only).' }) // → mutation usersBan
ban(@Param('id') id: string, @Body() body: BanUserDto, @Req() req: AppRequest) {
return this.db.ban(req.user, id, body.reason, body.permanent ?? false)
}
}import { Module } from '@nestjs/common'
import { SilkweaveModule } from '@silkweave/nestjs'
import { mcp } from '@silkweave/nestjs/mcp'
import { trpc } from '@silkweave/nestjs/trpc'
import { typegen } from '@silkweave/nestjs/typegen'
@Module({
imports: [
SilkweaveModule.forRoot({
silkweave: { name: 'my-app', description: 'My App', version: '1.0.0' },
adapters: [
mcp({ basePath: '/mcp' }), // @Mcp methods → MCP tools
trpc({ basePath: '/trpc' }), // @Trpc methods → tRPC procedures
typegen({ path: '../app/src/types/appRouter.ts' })// @Trpc procedures → AppRouter type
]
})
],
controllers: [UsersController],
providers: [AuthGuard]
})
export class AppModule {}The MCP endpoint now exposes UsersGet ({ id: string }, described from @ApiParam) and UsersBan ({ id, reason (minLength 3), permanent? }, flattened from the path param + BanUserDto with @ApiProperty + class-validator merged). UsersBan is gated by the Nest guard, which over MCP reads the inbound tool-call headers via switchToHttp().getRequest().headers.
How reflection works
Each @Mcp method becomes one flat Zod input object, merged per field in increasing precedence: design:type < class-validator < swagger decorators < ingested OpenAPI document < @Mcp({ input }). The input override accepts a raw shape ({ field: z.string() }) or a whole z.object({ ... }), and works with a Zod v3 or v4 schema. Field sources follow the parameter decorators:
| Controller parameter | Tool input |
|---|---|
@Param('id') id | scalar field id |
@Query('limit') limit | scalar field limit |
@Query() dto: ListDto | each ListDto property, flattened |
@Body() dto: CreateDto | each CreateDto property, flattened |
@Req/@Headers/@Ip | not exposed; bound from the MCP request at call time |
@Res() res | not exposed; the real Express response over tRPC (so @Res({ passthrough: true }) can set cookies), undefined over MCP |
- Unreflectable params warn at boot. A whole-DTO
@Body()/@Query()typed as an intersection/union (e.g.CreateDto & Extra) is erased toObjectby TypeScript, so none of its fields reflect - the adapter logs aSilkweavewarning naming the method and param. A@Mcp/@Trpc({ input })override adds fields but does not recover the dropped DTO; fix it with a single DTO class or by declaring every field via({ input }). - Nullable fields.
@ApiProperty({ nullable: true })(and OpenAPInullable) reflect to a.nullable()field (string | null).class-validator's@IsOptional()only yieldsstring | undefined(it has nonullsignal). - Opt-in. Only methods carrying
@Mcp()/@Trpc()are exposed - controllers are never auto-published. @UseGuards()fires before the handler on a tool call. Guards get anExecutionContextwhoseswitchToHttp().getRequest()carries the inbound tool-call headers andurl(from the SDK'sextra.requestInfo), plusparams/query/bodyreconstructed from the validated input per each field's reflected source (@Param→params,@Query→query,@Body→body) - so a scope-enforcing guard readinggetRequest().params['id'],req.query['…'], orreq.body['sessionId']decides the same over MCP as over REST. A denying guard yields a clean MCP tool error, not a 500. For bearer-token/OAuth MCP auth usemcp({ auth })and readctx.get('auth').- Global guards (opt-in). App-global guards (
app.useGlobalGuards()or{ provide: APP_GUARD, useClass }) don't run on tool calls by default. Opt them in by class viaglobalGuards: [ApiKeyGuard]onSilkweaveModule.forRoot()- they run before each method/class@UseGuards. It's an explicit allow-list on purpose: blanket-running every global would also fire unrelated ones (e.g. aThrottlerGuardthat needs a writable response MCP can't provide). - What does not run: the handler is invoked directly, so globally-registered
ValidationPipe/ interceptors / exception filters do not apply (MCP input is validated against the reflected Zod schema instead), and whole-DTO arguments arrive as plain objects (no@Transform). Only guards and parameter-bound pipes run. Express only by default. - Optional OpenAPI ingestion: pass
openapi: documenttoSilkweaveModule.forRoot()to use a pre-built OpenAPI doc as the authoritative schema source, matched to each method by verb + path. - Use
@swc-node/register(node --import @swc-node/register/esm-register) ornest startfor dev - plaintsxdoesn't emit decorator metadata. Because the controllers are ordinary Nest controllers,@nestjs/swaggerdocuments them natively - no Silkweave-specific merging needed.
tRPC procedures (@Trpc)
@Trpc() is the tRPC sibling of @Mcp() - same reflected input, plus the two things tRPC consumers need: precise output types in the generated router and subscriptions for async * routes. Procedure keys are camelCase of {ControllerBase}.{Method} (UsersController.listBySpace → usersListBySpace); kind is inferred (@Get → query, else mutation, async * → subscription) and overridable with @Trpc({ kind }).
- Output types. The generated
AppRoutercarries precise outputs, sourced (in order) from@ApiOkResponse({ type: Dto })reflection or an explicit@Trpc({ output })(a Zod schema, DTO class, or raw shape) that wins over it. Reflection is one level deep - nested DTOs /Dto[]degrade tounknown, and the adapter warns at boot naming the field(s) so the gap is visible; pass@Trpc({ output })a Zod schema for a precise nested shape. - Subscriptions. An
async *method registers as a tRPC subscription over SSE;@Trpc({ chunk })types the stream element. It needs no HTTP verb - a verb-less@Trpc({ kind: 'subscription', chunk })exposes it over tRPC without a public REST route. Guards run before the first chunk. - Guards + cookie auth, no separate config. The
trpc()adapter gives each procedure's@UseGuards()a realExecutionContextwhoseswitchToHttp().getRequest()is the actual Express request (cookies, headers,req.user) - so anAuthGuardreading an HttpOnly session cookie works with no auth config (the client sendscredentials: 'include'). A denying guard surfaces as aTRPCErrorwhosedata.httpStatusis the guard's status (401/403). - No public REST. Omit the HTTP-verb decorator to serve a method over tRPC/MCP only - Nest never maps it as REST, but
@Trpc({ kind })/@Mcp()still pick it up. typegen({ path })writes theAppRoutertype (aTRPCBuiltRouterover every@Trpcprocedure) on boot - the exact contractcreateTRPCClient<AppRouter>()andinferRouterInputs/inferRouterOutputsconsume. It sees@Trpcactions only, so the emitted router matches whattrpc()serves.
Full API reference: @silkweave/nestjs README.
Next.js
The @silkweave/nextjs package projects one action set onto Next.js App Router route handlers - MCP tools for agents and a typed tRPC endpoint for your frontend - from a single source of truth. It's action-first (define a Silkweave action, then mount it) and additive (it only adds route files). Under the hood it wraps edge() (MCP) and trpcFetch(), adding catch-all path normalization and end-to-end tRPC types. App Router only; the package has no next/react dependency.
Install
pnpm add @silkweave/nextjs @silkweave/core
# typed tRPC client on the frontend:
pnpm add @trpc/clientDefine your app once
// silkweave/server.ts
import { defineSilkweave } from '@silkweave/nextjs'
import { banUser, listUsers } from './actions'
export const app = defineSilkweave({
name: 'my-app',
description: 'My app exposed to agents + frontend',
version: '1.0.0',
actions: [listUsers, banUser]
})
// Type-only export for the tRPC client:
export type AppRouter = typeof app.RouterMount the route handlers
The MCP route is a single optional catch-all file - it serves the transport plus any OAuth / well-known sub-paths. basePath must equal the route file's directory.
// app/api/mcp/[[...mcp]]/route.ts
import { app } from '@/silkweave/server'
export const { GET, POST, DELETE, OPTIONS } = app.mcp({ basePath: '/api/mcp' })
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'// app/api/trpc/[trpc]/route.ts
import { app } from '@/silkweave/server'
export const { GET, POST, OPTIONS } = app.trpc({ endpoint: '/api/trpc' })
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'Call it from your frontend, fully typed
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '@/silkweave/server'
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '/api/trpc' })]
})
const { users } = await trpc.listUsers.query({ activeOnly: true })- One source, many surfaces.
app.mcp()andapp.trpc()each build their own internal Silkweave instance, so mounting both from the sameappis safe. The same actions are reachable as MCP tools (ListUsers/BanUser) and tRPC procedures (listUsers/banUser). - Path normalization. The MCP handler matches absolute paths, so the catch-all route strips
basePathfrom each request - one[[...mcp]]file covers the transport, OAuth routes, and protected-resource metadata. - Runtime. Use
runtime = 'nodejs'(the MCP transport needs Node APIs) anddynamic = 'force-dynamic'(these handlers are never statically cached). - CORS. tRPC CORS is opt-in (
app.trpc({ cors: true })) - a same-origin Next.js frontend needs none. The MCP route is permissive by default. - Auth. Pass
authtoapp.mcp()/app.trpc()for bearer-token / OAuth 2.1 (see@silkweave/auth).
Full API reference: @silkweave/nextjs README.
Vercel AI SDK
The @silkweave/ai package wires Vercel AI SDK's useChat hook to a Silkweave streaming action over a tRPC subscription. No /api/chat route, no AI SDK Data Stream Protocol parsing - useChat consumes UIMessageChunk objects directly from your typed tRPC client.
The integration is two pieces:
createChatAction({ model, ... })- server-side helper. Wraps AI SDK'sstreamTextin a Silkweave streaming action whosechunkschema isUIMessageChunk; the action yields chunks fromresult.toUIMessageStream(). The tRPC adapter sees it's streaming and registers it as a.subscription()automatically.silkweaveTransport(subscribe)- client-sideChatTransportfactory. Wraps any subscribe-style function (typicallyclient.chat.subscribe) into theReadableStream<UIMessageChunk>useChatexpects. Abort signals propagate tounsubscribe().
pnpm add @silkweave/core @silkweave/trpc @silkweave/ai ai @ai-sdk/anthropic
pnpm add @ai-sdk/react @trpc/client @trpc/server # client-sideimport { silkweave } from '@silkweave/core'
import { trpc, type InferTrpcRouter } from '@silkweave/trpc'
import { createChatAction } from '@silkweave/ai'
import { anthropic } from '@ai-sdk/anthropic'
const ChatAction = createChatAction({
model: anthropic('claude-haiku-4-5'),
system: 'You are a helpful assistant.'
})
const server = silkweave({ name: 'chat', description: 'AI Chat', version: '1.0.0' })
.adapter(trpc({ host: 'localhost', port: 8081 }))
.action(ChatAction)
export type AppRouter = InferTrpcRouter<typeof server>
await server.start()import { createTRPCClient, httpSubscriptionLink, httpBatchLink, splitLink } from '@trpc/client'
import type { AppRouter } from '../server/server.js'
export const trpc = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: httpSubscriptionLink({ url: '/trpc' }),
false: httpBatchLink({ url: '/trpc' })
})
]
})import { useChat } from '@ai-sdk/react'
import { silkweaveTransport } from '@silkweave/ai'
import { trpc } from './trpc'
const transport = silkweaveTransport(trpc.chat.subscribe)
export function Chat() {
const { messages, sendMessage, status, stop } = useChat({ transport })
// ... your UI (ai-elements works as-is)
}That's the entire integration. Type safety flows from AppRouter through useChat; AI SDK's frontend hook is none the wiser that it isn't talking to its DefaultChatTransport HTTP endpoint. ai-elements components (Conversation, Message, PromptInput) work as drop-in replacements for the hand-rolled UI - the data layer doesn't change.
Why this works
A custom ChatTransport.sendMessages returns Promise<ReadableStream<UIMessageChunk>>. The chunks in that stream are plain JS objects - useChat doesn't care where they came from. tRPC subscriptions yield objects. AI SDK's streamText().toUIMessageStream() produces objects of exactly the right shape. The three pieces compose without any wire-format translation:
streamText() ─► toUIMessageStream() ─► yield chunk
│
[silkweave streaming action]
│
[tRPC subscription]
│
▼
ReadableStream<UIMessageChunk> ◄─ silkweaveTransport ─◄ trpc.chat.subscribe
│
▼
useChat({ transport })Caveats
- No resume after disconnect.
reconnectToStream()returnsnull- if the connection drops mid-stream the in-progress message is lost. Replicating AI SDK's stream resumption would require server-side state we don't manage here. Fine for most cases. httpSubscriptionLinkuses SSE (GET). Long conversations serialized into query strings will hit URL length limits. Switch towsLink(WebSocket) for production or configure a custom transport that POSTs.onDatais typedunknown. Zod'sz.custom<UIMessageChunk>()doesn't preserve the exact union variance through tRPC's subscription inference, so the callback boundary is loose. Runtime is safe becausestreamTextonly yields valid chunks; the cast lives insidesilkweaveTransport.
Full working example: examples/ai/ (Vite + React + Tailwind v4). Full API reference: @silkweave/ai README.
Tool Results
MCP tool results default to compact JSON (jsonToolResult). Two opt-in dispositions cover the other cases: smart results protect the LLM's context window from large payloads, and structured results declare a typed output contract.
Smart results (disposition: 'smart'; the default before 3.2). When an MCP tool returns a large response, the entire payload is ingested into the LLM's context window - burning tokens and degrading reasoning quality. smartToolResult() addresses this server-side:
- Small responses (≤ 4096 chars) - returned as inline
TextContentJSON - Large responses (> 4096 chars) - automatically split into a short text summary + a base64 embedded resource, keeping the LLM's context window lean while preserving full data access
Structured results (disposition: 'structured'). The action's output Zod schema is forwarded to tools/list as the tool's MCP outputSchema - agents see the result shape before calling - and the result ships as structuredContent plus a JSON text mirror. The MCP SDK enforces output schemas on both sides (the server validates before responding; SDK clients validate independently), so silkweave parses the result through the schema before shipping: extra fields are stripped (returning a wider object than the schema is safe by construction) and a genuine mismatch degrades to an agent-legible isError tool result naming the failing fields, never an opaque protocol error. A client's _meta.disposition cannot demote a structured tool, and 'structured' requires a non-streaming action with an output schema (validated at registration).
Custom toolResult Hook
Actions can override the default formatting by defining a toolResult hook. This gives you full control over what the LLM sees vs. what gets stored as a resource:
import { createAction } from '@silkweave/core'
import { smartToolResult } from '@silkweave/mcp'
import z from 'zod/v4'
export const UserListAction = createAction({
name: 'user-list',
description: 'Return a list of users',
input: z.object({
format: z.enum(['full', 'summary']).default('summary')
}),
run: async ({ format }, context) => {
context.set('format', format)
return await fetchUsers()
},
toolResult: (users, context) => {
if (context.get('format') === 'full') {
return smartToolResult(users)
}
// Lean summary for the LLM + full data as embedded resource
const summary = users.map(({ id, name }) => ({ id, name }))
return {
content: [
{ type: 'text', text: JSON.stringify(summary) },
{ type: 'resource', resource: {
uri: 'mcp://my-app/users.json',
mimeType: 'application/json',
blob: Buffer.from(JSON.stringify(users)).toString('base64')
}}
]
}
}
})Return undefined from toolResult to fall through to the resolved disposition. The hook only affects MCP adapters - CLI and Fastify handle serialization independently.
Without a hook, disposition picks the format per action. 'json' and 'smart' are only defaults - a client that sends _meta.disposition on the tool call overrides them (resolution order: client _meta.disposition → action disposition → 'json'; before 3.2 the fallback was 'smart'). In NestJS this is exposed per-tool as @Mcp({ result }), or module-wide via SilkweaveModule.forRoot({ defaultResult }) (per-method result wins over the module default, and a client's _meta.disposition wins over both).
Result Utilities
All result utilities are exported from @silkweave/mcp:
| Function | Description |
|---|---|
smartToolResult(data) | Embedded-resource splitting at 4096 chars (disposition: 'smart') |
jsonToolResult(data, isError?) | Simple inline TextContent JSON (no splitting) - the default formatter |
structuredToolResult(data) | structuredContent + JSON text mirror for disposition: 'structured' (pass output-schema-parsed data) |
errorToolResult(error) | Format a SilkweaveError as an error result |
handleToolError(error) | Catch-all error handler used by all MCP adapters |
Authentication
@silkweave/auth provides authentication for MCP and REST adapters. It supports bearer token validation and a full OAuth 2.1 proxy with PKCE, refresh tokens, and dynamic client registration.
Resource server core vs. opt-in OAuth
@silkweave/auth is split into two layers so a pure resource server never pulls the OAuth issuer machinery into its dependency graph.
The package root is the spec-required resource-server core (jose-only). It validates bearer tokens and serves protected-resource metadata:
- Bearer token validation - expiry + issuer binding (RFC 9207), audience binding (RFC 8707), and step-up
scopechallenges (SEP-2350) - Protected resource metadata - RFC 9728 discovery, including
scopes_supported
The OAuth 2.1 authorization-server proxy (PKCE, refresh tokens, CIMD, dynamic client registration) and the persistence stores (memory / JSON file / Redis) live behind the opt-in @silkweave/auth/oauth subpath. Providers like google() and createRedisStore / createJsonStore are imported from there:
// Resource server only - validate tokens, delegate issuance to an external IdP
import { AuthConfig } from '@silkweave/auth'
// Front your own OAuth 2.1 flow - pulls in the issuer machinery
import { google, createRedisStore, createJsonStore } from '@silkweave/auth/oauth'Use the root when you want to validate bearer tokens and delegate issuance to an external IdP; reach for @silkweave/auth/oauth when you front your own OAuth flow.
Bearer Token
The simplest auth setup - provide a verifyToken callback that validates tokens against your own logic:
import { AuthConfig } from '@silkweave/auth'
import { silkweave } from '@silkweave/core'
import { http } from '@silkweave/mcp'
const auth: AuthConfig = {
verifyToken: async (token) => {
if (token === process.env.API_TOKEN) {
return { token, clientId: 'api', scopes: ['read', 'write'] }
}
return undefined
}
}
await silkweave({ name: 'my-server', version: '1.0.0' })
.adapter(http({ host: 'localhost', port: 8080, auth }))
.action(GreetAction)
.start()When a valid token is provided, the adapter injects an auth key into the context. Access it in your actions:
const auth = context.getOptional<AuthInfo>('auth')
if (auth) {
console.log(auth.clientId, auth.scopes)
}OAuth 2.1
For production use, Silkweave includes a full OAuth 2.1 proxy behind the opt-in @silkweave/auth/oauth subpath (see Resource server core vs. opt-in OAuth). The google() helper pre-configures Google as the upstream provider:
import { google, createJsonStore } from '@silkweave/auth/oauth'
const auth = google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
resourceUrl: 'http://localhost:8080',
redirectUris: ['http://localhost:*'],
requiredScopes: ['openid'],
store: createJsonStore('store.json')
})The OAuth proxy handles:
- PKCE - Proof Key for Code Exchange for public clients
- Refresh tokens - Automatic token renewal
- Dynamic client registration - RFC 7591 support
- Protected Resource Metadata - RFC 9728 discovery
Storage backends (also from @silkweave/auth/oauth): createMemoryStore() (development), createJsonStore(path) (file-based), createRedisStore(redis) (production).
Multi-Resource: One AS, N Protected Resources
A server fronting several tenants needs a distinct URL per tenant - Claude Desktop and claude.ai dedupe MCP connectors by URL, so one URL means one attachable space. Distinct URLs alone are not enough: each must be its own OAuth protected resource with its own token audience, so a token minted for tenant A is rejected when replayed against tenant B. There is still exactly one authorization server.
Set resourceUrl to a resolver instead of a string, and give the authorization server an allow-list of the resource indicators it will mint an aud for:
import { pathResolver } from '@silkweave/auth'
import { google } from '@silkweave/auth/oauth'
const AS = 'https://mcp.example.com'
const TENANT = /^\/([a-z]{8})$/
const auth = google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
resourceUrl: AS, // AS identity: iss + default audience
resolveResource: pathResolver({ origin: AS, match: TENANT }), // one resource per tenant
allowedResources: async (resource) => { // which audiences the AS will mint
const { origin, pathname } = new URL(resource)
const match = TENANT.exec(pathname)
return origin === AS && !!match && await spaceExists(match[1])
},
redirectUris: ['https://claude.ai/*'],
store
})
// Mount the transport at each tenant URL as well as /mcp.
http({ host: 'localhost', port: 8080, auth, transportPaths: ['/:spaceId'] })On the wire: an unauthorized request to https://mcp.example.com/yoexoexl is challenged with resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/yoexoexl" (RFC 9728 path-insertion form, which is what MCP clients probe); that document advertises the tenant as its resource plus the shared authorization_servers; the client sends resource=…/yoexoexl on both the authorize and token legs; the minted token carries that aud; presenting it at another tenant is a 401.
pathResolver({ origin, match }) builds identifiers from your configured origin, never the inbound request's - so a spoofed Host header cannot steer the advertised metadata URL or the expected audience. Adapters mount the wildcard well-known route automatically when resourceUrl is a resolver, and existing string configs are unchanged in every respect.
aud is not membership. The authorization server will mint aud: …/tenantA for any authenticated user who asks, including one with no rights in tenant A. The indicator says where a token may be presented, never what its subject may do there - per-tenant authorization stays your job on every call. validateToken returns the resolved resource so that check needs no URL re-parsing.
Per-Action Guards
Use the isEnabled field to conditionally expose actions based on context (including auth state):
export const AdminAction = createAction({
name: 'admin',
description: 'Admin-only action',
input: z.object({}),
isEnabled: (ctx) => {
const auth = ctx.getOptional<AuthInfo>('auth')
return auth?.scopes?.includes('admin') ?? false
},
run: async (_input, context) => {
return { status: 'ok' }
}
})When isEnabled returns false, the action is not registered with that adapter - it won't appear in MCP tool lists, Swagger docs, or CLI help.
isEnabled is evaluated once at start(). For per-request scoping over MCP, see Per-Request Tool Filtering.
Per-Request Tool Filtering
The stateless MCP transports (http(), edge(), NestJS mcp()) recompute the tool list on every request, so per-API-key permissions, tool groups, or read-only keys are a single filterActions callback:
http({
host: 'localhost', port: 8080,
filterActions: async (actions, request) => {
// request: { headers, url, method, toolName? }
if (request.method === 'initialize' || request.method === 'ping') { return actions }
const key = await lookupApiKey(request.headers.authorization)
if (!key) { throw new SilkweaveError('invalid api key', 'invalid_key', 401) }
return actions.filter((action) => action.tags?.some((tag) => key.allowedTags.includes(tag)))
}
})The filter applies to tools/list and tools/call alike - a client that cached a wider list is still denied - and permission changes apply on the next tools/list (clients refetch on reconnect). request.method is the JSON-RPC method of the POSTed message and request.toolName is params.name on tools/call, so the callback can skip expensive lookups on initialize/ping and double as an observability tap. Error semantics: a thrown SilkweaveError surfaces as its statusCode (e.g. 401) with a JSON-RPC error body, anything else as a 500 - a throw never degrades to an empty tool list.
Telemetry
Every MCP adapter accepts an onToolCall hook - one seam observes every tool call, fire-and-forget (never awaited on the result path; hook errors are logged and swallowed):
http({
host: 'localhost', port: 8080,
onToolCall: (event) => {
// { action, tool, transport, durationMs, ok, errorCode?, errorMessage?, args?, resultBytes?, sideloaded?, context }
console.log(JSON.stringify({ event: 'tool_call', tool: event.tool, ms: event.durationMs, ok: event.ok }))
}
})Events fire after result formatting, so they carry resultBytes (serialized result size) and sideloaded (whether smartToolResult offloaded to an embedded resource); event.context gives access to the resolved auth for per-key/per-user attribution. event.args carries the call's input: the parsed (post-zod) input the action ran with. It is unredacted - like event.context, redact and truncate before persisting. In NestJS, pass a class token instead - SilkweaveModule.forRoot({ telemetry: MyTelemetryService }) resolves a SilkweaveTelemetry provider through DI and covers MCP and tRPC calls (guard denials included), exactly one event per call.
Invalid arguments emit too. A tools/call that fails input-schema validation is rejected by the MCP SDK before the handler runs, so the HTTP transports (http(), mcpTransport(), edge()) pre-validate emit-only - the SDK still produces its native rejection on the wire - and report ok: false with the stable errorCode: 'INVALID_ARGUMENTS', durationMs: 0, and args set to the raw offered input exactly as the client sent it. The NestJS tRPC adapter does the same from tRPC's onError seam. A misbehaving agent hammering wrong schemas becomes visible in your metrics; these events are cheap to trigger at high rate, so batch or sample in the hook rather than writing per event. (stdio() does not emit validation-failure events.)
Error Handling
Silkweave provides standard error classes that automatically map to the correct behavior in each transport.
SilkweaveError
Throw a SilkweaveError from your action to communicate structured errors:
import { SilkweaveError, notFound, badRequest } from '@silkweave/core'
export const GetUserAction = createAction({
name: 'getUser',
description: 'Get a user by ID',
input: z.object({
id: z.string().describe('User ID')
}),
run: async ({ id }, context) => {
const db = context.get<pg.Pool>('db')
const { rows } = await db.query('SELECT * FROM users WHERE id = $1', [id])
if (!rows.length) throw notFound(`User ${'$'}{id} not found`)
return rows[0]
}
})Convenience factories: notFound(), badRequest(), forbidden(), internal(). Or construct directly:
throw new SilkweaveError('Rate limited', 'rate_limited', 429)Adapter Error Behavior
Each adapter maps errors to the appropriate transport response:
| Error Type | Fastify | CLI | MCP |
|---|---|---|---|
SilkweaveError(404) | HTTP 404 + JSON body | exit 1 + [not_found] message | { isError: true, code: 'not_found' } |
ZodError | HTTP 400 + validation issues | exit 1 + field-level errors | { isError: true } |
| Unknown error | HTTP 500 | exit 1 + error message | { isError: true } |
Testing
Unit Testing Actions
Because actions are plain async functions decoupled from any transport, testing them is trivial - no server required:
import { describe, it, expect } from 'vitest'
import { createContext } from '@silkweave/core'
import { GreetAction } from './actions/greet.js'
describe('GreetAction', () => {
it('greets by name', async () => {
const ctx = createContext({ logger: { info: () => {} } })
const result = await GreetAction.run({ name: 'Alice' }, ctx)
expect(result.message).toBe('Hello, Alice!')
})
})Create a minimal context with createContext(), call action.run(), and assert on the return value. That's it.
For actions that access context keys like db or auth, pass mocks in the context store:
const ctx = createContext({
logger: { info: () => {} },
db: { query: async () => ({ rows: [{ id: '1', name: 'Alice' }] }) }
})
const result = await GetUserAction.run({ id: '1' }, ctx)
expect(result.name).toBe('Alice')Escape Hatches
Silkweave provides a clean default path but never hides the underlying transport. When you need transport-specific features, the native objects are right there in context.
Accessing Native Objects
// Fastify adapter: access raw request
const req = context.get<FastifyRequest>('request')
const customHeader = req.headers['x-trace-id']
// CLI adapter: access commander instance
const cmd = context.get<Command>('command')
// MCP adapters: access MCP extra metadata
const extra = context.get<ToolExtra>('extra')This lets you access transport-specific features like custom headers, cookies, or MCP metadata when your action truly needs them - while keeping the common case clean.
Context by Adapter
| Adapter | Key | Type | Description |
|---|---|---|---|
| All | logger | Logger | Adapter-specific logger instance |
| All | adapter | string | Adapter name ('fastify', 'stdio', etc.) |
| Fastify | request | FastifyRequest | Raw Fastify request with headers, URL, body |
| Fastify, MCP HTTP, Vercel | auth | AuthInfo | Authenticated user info (when auth is configured) |
| CLI | command | Command | Commander command instance |
| MCP (all) | extra | ToolExtra | MCP request metadata, session info |
Use context.getOptional<T>(key) when writing actions that run across multiple adapters - it returns undefined instead of throwing when a key isn't present.