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 zod | 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' // default MCP result format
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.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 use the defaultsmartToolResultbehavior. Only affects MCP adapters.disposition- Optional default MCP result format -'json'(jsonToolResult) or'smart'(the default,smartToolResult). A simpler alternative to atoolResulthook when you just want compact JSON; a client's_meta.dispositionstill overrides it. MCP adapters only.
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
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
CLI
The cli() adapter exposes each action as a CLI command using Commander with beautiful Clack terminal UI.
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.
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 uses the TypeScript compiler API to produce correct, well-formatted output with zero runtime cost.
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/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()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-side 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() 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.
Smart Tool Results
When an MCP tool returns a large response, the entire payload is ingested into the LLM's context window. This burns tokens, degrades reasoning quality, and can hit context limits. Silkweave addresses this server-side with smart tool results.
All MCP adapters (stdio, HTTP, and Vercel) use smartToolResult() by default to format action return values:
- Small responses (≤ 4096 chars) - returned as inline
TextContentJSON, same as before - 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
Some MCP clients (e.g. VS Code since December 2025) handle large responses client-side, but most don't. smartToolResult ensures good behavior regardless of client capabilities - it's a server-side best practice that works everywhere.
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 default smartToolResult behavior. The hook only affects MCP adapters - CLI and Fastify handle serialization independently.
For the common case of just choosing compact JSON over smart splitting (without a hook), set disposition: 'json' on the action. It is only a default - a client that sends _meta.disposition on the tool call overrides it (resolution order: client _meta.disposition → action disposition → 'smart'). In NestJS this is exposed per-tool as @Mcp({ result: 'json' }), or module-wide via SilkweaveModule.forRoot({ defaultResult: 'json' }) (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) | Default formatter - automatic embedded resource splitting at 4096 chars |
jsonToolResult(data, isError?) | Simple inline TextContent JSON (no splitting) |
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).
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.
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.