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:

terminal
pnpm add @silkweave/core @silkweave/mcp zod

Zod 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+.

PackageDescription
@silkweave/coreActions, adapters, builder, context, error utilities
@silkweave/mcpMCP stdio and streamable HTTP adapters
@silkweave/fastifyFastify REST API with auto-generated Swagger UI
@silkweave/cliCLI adapter with Commander + Clack prompts
@silkweave/edgeStateless MCP over Web Standard edge / serverless (Vercel, Cloudflare, Bun)
@silkweave/typegenBuild-time .d.ts generation from action Zod schemas
@silkweave/authResource-server core (bearer validation, protected-resource metadata); OAuth 2.1 proxy behind /oauth

Quick Start

1. Define an action

actions/greet.ts
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

server.ts
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.

Action interface
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 streaming run (an async function*). Presence of this field plus a generator run marks the action as streaming; adapters then switch to per-chunk wire delivery.
  • method / path / queryParams - Optional REST routing for the Fastify and NestJS rest adapters. See REST routing.
  • args - Keys from input to 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. Return false to 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 MCP CallToolResult. Return undefined to fall through to the resolved disposition. Only affects MCP adapters.
  • disposition - MCP result format - 'json' (the default, jsonToolResult), 'smart' (smartToolResult, sideloads payloads over 4096 chars), or 'structured' (declares output as the tool's MCP outputSchema contract and ships schema-parsed structuredContent - see Tool Results). A client's _meta.disposition overrides 'json'/'smart' but never 'structured'. MCP adapters only.
  • annotations - Optional MCP tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title) forwarded to tools/list; MCP hosts use them to group and permission-gate tools. Adapters derive readOnlyHint from kind ('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-request filterActions to match on.

Adapters

Adapters translate actions into a specific transport. The pattern is AdapterFactory → AdapterGenerator → Adapter:

  1. AdapterFactory - Takes transport-specific config (port, host, auth) and returns a generator.
  2. AdapterGenerator - Takes silkweave options + base context, returns the adapter.
  3. Adapter - Has start(actions) and stop() methods.

The fluent builder handles all of this - you just call .adapter(stdio()).

AdapterPackageTransport
stdio()@silkweave/mcpMCP over stdin/stdout
http()@silkweave/mcpMCP Streamable HTTP (Express)
fastify()@silkweave/fastifyREST API + Swagger UI
cli()@silkweave/cliCLI via Commander + Clack
edge()@silkweave/edgeStateless MCP (Web Standard)
typegen()@silkweave/typegenBuild-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:

server.ts
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

actions/listUsers.ts
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" meansContext keys injected
FastifyHTTP requestlogger, adapter, request, auth?
CLIProcess executionlogger, adapter, command
MCP stdioJSON-RPC messagelogger, adapter, extra
MCP HTTPHTTP + JSON-RPClogger, adapter, extra, auth?
VercelServerless invocationlogger, 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*:

actions/generate-messages.ts
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

AdapterWire formatHow a client opts inBuffered fallback
MCP (stdio, http, vercel)MCP notifications/progress - one notification per chunk, JSON-stringified into the message fieldClient 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 RESTtext/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-ndjson200 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
CLINDJSON on stdout (one JSON chunk per line, pipe-friendly)Streaming action ⇒ always streamedn/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 | jq processes 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():

actions/screenshot.ts
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

AdapterDelivery
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 RESTThe 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 / cliProxyTerminal-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().
typegenbinary() 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:

server.ts
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 plain resources/list/resources/read - the SEP-2640 baseline.
  • ListSkills / GetSkill tools - 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 initialize response 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:

terminal
# 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 Tobias

Installs 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:

terminal + server.ts
# 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-skills

skills 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:

server.ts
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.

server.ts
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.

server.ts
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.

server.ts
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 auth option for bearer token / OAuth protection
  • Supports cors option: false to 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 Accept picks 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 to POST, or GET when kind: 'query'. An explicit method always wins.
  • path - a route template, optionally with :param placeholders. Each placeholder must be a key of the input schema and is resolved from the URL path. When unset, the route is derived from name.
  • queryParams - input fields read from the URL query string instead of the body. On a bodyless GET, every non-path field is read from the query string automatically.
list-users.ts
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 NestJS rest adapter validates via the Zod schema.
  • A :param placeholder or queryParams entry 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.

server.ts
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()
client.ts
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 camelCase procedure keys: list-thingsclient.listThings
  • Set kind: 'query' on an action to expose it as a cacheable .query() (GET); default is .mutation() (POST). The literal kind is preserved through createAction, so calling .mutate() on a query is a compile-time error.
  • Streaming actions (those with a chunk schema + async-generator run) are registered as tRPC .subscription() procedures whose async generators yield chunks directly - client code consumes them with client.foo.subscribe(input, { onData, onError, onComplete })
  • Supports auth option for bearer token / OAuth protection (see Authentication)
  • Supports cors option: false to disable, true/omit for permissive defaults, or a CorsOptions object
  • Options: host, port, endpoint (default /trpc/), cors, auth
  • Thrown SilkweaveError maps to the matching TRPCError code; Zod validation errors become BAD_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.

src/server/silkweave.ts
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()
src/pages/api/trpc/[trpc].ts (Astro)
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. No cors option. Configure CORS in your host framework (Astro middleware, vercel.json, Worker headers)
  • Internal _ready promise gates the handler until server.start() completes, guarding against cold-start races on serverless
  • Returns { adapter, handler, GET, POST }; GET/POST are aliases for the primary handler so 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.

server.ts
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 }; handler is a (req: IncomingMessage, res: ServerResponse) => void
  • The handler does not verify the prefix - the underlying tRPC handler slices endpoint off the path unconditionally, so route only matching URLs into it
  • No CORS, like trpcFetch and unlike trpc(): a mounted handler does not own the response headers of the server it is mounted on
  • Ready-gated, answering 503 if the router failed to build, so a request arriving before start() 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:

server.ts
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.

cli.ts
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.

cli.ts
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, and authProvider are forwarded to the SDK's StreamableHTTPClientTransport for full control.
  • Positional arguments - input fields listed in an action's args cross 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 token on a 401/403) instead of an SDK stack trace, and root --help / --version still 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.

api/mcp/route.ts
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 WebStandardStreamableHTTPServerTransport from the MCP SDK
  • Only POST carries JSON-RPC; GET / DELETE return 405 (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/progress just 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 imports
// Express-free: tool registration + result helpers for Web Standard runtimes
import { registerTools, smartToolResult } from '@silkweave/mcp/tools'
  • Import @silkweave/mcp (the http() server) only when you want the Express transport - install express + cors alongside it
  • The edge() and @silkweave/nextjs adapters 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).

typegen.ts
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:

types/actions.d.ts (generated)
export interface GreetInput {
    name: string;
}

export interface GreetOutput {
    message: string;
}
  • path - Output file path for the generated .d.ts file. 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 output schema.
  • Uses allActions: true to bypass isEnabled filtering - 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:

server.ts
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 typeTypeScript 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:

IntegrationWhen to useStatus
StandaloneYou own your main.ts. Compose silkweave().adapter(...).action(...).start() directly with the adapters from Adapter Reference.Stable
NestJSYou'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.jsYou'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 SDKYou 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.

main.ts
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).

install
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.

users.controller.ts
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)
  }
}
app.module.ts
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 parameterTool input
@Param('id') idscalar field id
@Query('limit') limitscalar field limit
@Query() dto: ListDtoeach ListDto property, flattened
@Body() dto: CreateDtoeach CreateDto property, flattened
@Req/@Headers/@Ipnot exposed; bound from the MCP request at call time
@Res() resnot 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 to Object by TypeScript, so none of its fields reflect - the adapter logs a Silkweave warning 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 OpenAPI nullable) reflect to a .nullable() field (string | null). class-validator's @IsOptional() only yields string | undefined (it has no null signal).
  • 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 an ExecutionContext whose switchToHttp().getRequest() carries the inbound tool-call headers and url (from the SDK's extra.requestInfo), plus params/query/body reconstructed from the validated input per each field's reflected source (@Paramparams, @Queryquery, @Bodybody) - so a scope-enforcing guard reading getRequest().params['id'], req.query['…'], or req.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 use mcp({ auth }) and read ctx.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 via globalGuards: [ApiKeyGuard] on SilkweaveModule.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. a ThrottlerGuard that 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: document to SilkweaveModule.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) or nest start for dev - plain tsx doesn't emit decorator metadata. Because the controllers are ordinary Nest controllers, @nestjs/swagger documents 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.listBySpaceusersListBySpace); kind is inferred (@Get → query, else mutation, async * → subscription) and overridable with @Trpc({ kind }).

  • Output types. The generated AppRouter carries 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 to unknown, 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 real ExecutionContext whose switchToHttp().getRequest() is the actual Express request (cookies, headers, req.user) - so an AuthGuard reading an HttpOnly session cookie works with no auth config (the client sends credentials: 'include'). A denying guard surfaces as a TRPCError whose data.httpStatus is 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 the AppRouter type (a TRPCBuiltRouter over every @Trpc procedure) on boot - the exact contract createTRPCClient<AppRouter>() and inferRouterInputs/inferRouterOutputs consume. It sees @Trpc actions only, so the emitted router matches what trpc() 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/client

Define 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.Router

Mount 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() and app.trpc() each build their own internal Silkweave instance, so mounting both from the same app is 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 basePath from each request - one [[...mcp]] file covers the transport, OAuth routes, and protected-resource metadata.
  • Runtime. Use runtime = 'nodejs' (the MCP transport needs Node APIs) and dynamic = '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 auth to app.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's streamText in a Silkweave streaming action whose chunk schema is UIMessageChunk; the action yields chunks from result.toUIMessageStream(). The tRPC adapter sees it's streaming and registers it as a .subscription() automatically.
  • silkweaveTransport(subscribe) - client-side ChatTransport factory. Wraps any subscribe-style function (typically client.chat.subscribe) into the ReadableStream<UIMessageChunk> useChat expects. Abort signals propagate to unsubscribe().
install
pnpm add @silkweave/core @silkweave/trpc @silkweave/ai ai @ai-sdk/anthropic
pnpm add @ai-sdk/react @trpc/client @trpc/server  # client-side
server/server.ts
import { 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()
src/trpc.ts
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' })
    })
  ]
})
src/Chat.tsx
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() returns null - 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.
  • httpSubscriptionLink uses SSE (GET). Long conversations serialized into query strings will hit URL length limits. Switch to wsLink (WebSocket) for production or configure a custom transport that POSTs.
  • onData is typed unknown. Zod's z.custom<UIMessageChunk>() doesn't preserve the exact union variance through tRPC's subscription inference, so the callback boundary is loose. Runtime is safe because streamText only yields valid chunks; the cast lives inside silkweaveTransport.

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 TextContent JSON
  • 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:

actions/userList.ts
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:

FunctionDescription
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 scope challenges (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:

Imports by use case
// 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:

server.ts
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:

Inside an action
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:

server.ts
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:

server.ts
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):

actions/admin.ts
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:

Per-key tool scoping
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):

Tool-call telemetry
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:

actions/getUser.ts
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:

Custom error
throw new SilkweaveError('Rate limited', 'rate_limited', 429)

Adapter Error Behavior

Each adapter maps errors to the appropriate transport response:

Error TypeFastifyCLIMCP
SilkweaveError(404)HTTP 404 + JSON bodyexit 1 + [not_found] message{ isError: true, code: 'not_found' }
ZodErrorHTTP 400 + validation issuesexit 1 + field-level errors{ isError: true }
Unknown errorHTTP 500exit 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:

greet.test.ts
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:

Testing with mocked dependencies
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

Inside an action
// 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

AdapterKeyTypeDescription
AllloggerLoggerAdapter-specific logger instance
AlladapterstringAdapter name ('fastify', 'stdio', etc.)
FastifyrequestFastifyRequestRaw Fastify request with headers, URL, body
Fastify, MCP HTTP, VercelauthAuthInfoAuthenticated user info (when auth is configured)
CLIcommandCommandCommander command instance
MCP (all)extraToolExtraMCP 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.