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/vercelStateless MCP over Vercel / Web Standard serverless
@silkweave/typegenBuild-time .d.ts generation from action Zod schemas
@silkweave/authOAuth 2.1 proxy, bearer token validation

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

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

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
vercel()@silkweave/vercelStateless 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).

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

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.

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

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)

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

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.

api/mcp/route.ts
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 WebStandardStreamableHTTPServerTransport from 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.

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]

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.

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.

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. The google() helper pre-configures Google as the upstream provider:

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

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.