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
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'    // 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 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.
  • 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 use the default smartToolResult behavior. Only affects MCP adapters.
  • disposition - Optional default MCP result format - 'json' (jsonToolResult) or 'smart' (the default, smartToolResult). A simpler alternative to a toolResult hook when you just want compact JSON; a client's _meta.disposition still overrides it. MCP adapters only.

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 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 | jq processes 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:

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

CLI

The cli() adapter exposes each action as a CLI command using Commander with beautiful Clack terminal UI.

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.

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 uses the TypeScript compiler API to produce correct, well-formatted output with zero runtime cost.

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

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.

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

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

FunctionDescription
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 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).

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.

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

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.