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/vercel | Stateless MCP over Vercel / Web Standard serverless |
@silkweave/typegen | Build-time .d.ts generation from action Zod schemas |
@silkweave/auth | OAuth 2.1 proxy, bearer token validation |
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 vercel() - 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> {
name: string
description: string
input: z.ZodType<I>
output?: z.ZodType<O>
args?: (keyof I)[]
isEnabled?: (context: SilkweaveContext) => boolean
run: (input: I, context: SilkweaveContext) => Promise<O>
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.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.
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 |
vercel() | @silkweave/vercel | 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).
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
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.
Fastify REST
The fastify() adapter registers each action as a POST /{name} endpoint with auto-generated OpenAPI documentation and interactive Swagger UI (powered by Scalar).
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)
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. - 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
Vercel Serverless
The vercel() 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 { vercel } from '@silkweave/vercel'
const { adapter, GET, POST, DELETE } = vercel()
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 - Supports OAuth 2.1 and bearer token auth
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] |
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.
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.
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. The google() helper pre-configures Google as the upstream provider:
import { google, createJsonStore } from '@silkweave/auth'
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: 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.