Silkweave
Write your logic once. Run it everywhere.
Silkweave is a TypeScript toolkit that lets you define application logic as portable Actions and instantly expose them through any combination of transports - MCP servers (stdio, HTTP, and serverless), end-to-end-typed tRPC, REST APIs with auto-generated OpenAPI docs, and fully-featured CLIs. It also meets your framework where it is: expose existing NestJS controllers as MCP tools, project one action set onto Next.js App Router route handlers, or stream to the Vercel AI SDK's useChat. No glue code required.
┌───────────────────────────────────┐
│ ACTIONS │
│ name + zod schema + async run() │
└─────────────────┬─────────────────┘
│
ADAPTER ┌────────────────┬────────┼────────┬────────────────┐
│ │ │ │ │
┌───────▼──────┐ ┌───────▼──────┐ │ ┌──────▼───────┐ ┌──────▼───────┐
│ MCP │ │ API │ │ │ CLI │ │ EDGE │
│ Http/Stdio │ │ tRPC/Fastify │ │ │ Commander │ │ Serverless │
└──────────────┘ └──────────────┘ │ └──────────────┘ └──────────────┘
│
FEATURE ┌───────────────────────┼───────────────────────┐
│ │ │
┌─────────▼─────────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐
│ AUTHENTICATION │ │ CONTEXT BAG │ │ TYPE GENERATION │
│ Oauth2/Bearer/JWT │ │ Typed/Extensible │ │ Input/Output DTS │
└───────────────────┘ └───────────────────┘ └───────────────────┘
Table of Contents
- Why Silkweave
- Packages
- Quick Start
- Core Concepts
- Adapters in Depth
- Framework Integrations
- Agent Skills over MCP
- Authentication
- Logging and Progress
- Advanced Patterns
- MCP Client Configuration
- API Reference
- Development
Why Silkweave
Building an MCP server usually means wiring up transports, registering tools, serializing responses, and handling errors - for every single tool. If you also want a CLI or REST API for the same logic, you're writing it all again.
Silkweave eliminates this duplication. You define an Action - a name, a Zod schema, and an async function - and Silkweave handles the rest:
- MCP adapters register your actions as MCP tools with proper notifications, progress reporting, and error handling - over stdio, streamable HTTP, or stateless serverless (Edge / Web Standard) - plus agent-quality signals: tool annotations, typed output contracts, per-request tool filtering, and call telemetry
- tRPC adapter exposes your actions as end-to-end type-safe procedures (
InferTrpcRouter<typeof server>), as a standalone server or a fetch handler - Fastify adapter generates a REST API with full OpenAPI/Swagger documentation derived from your Zod schemas
- CLI adapter builds a complete command-line interface with argument parsing, option flags, and plain
consoleoutput - Framework integrations meet you where you are - expose existing NestJS controllers as MCP tools via
@Mcp(), project an action set onto Next.js App Router handlers (MCP + tRPC), or bridge a streaming action to the Vercel AI SDK'suseChat
Your action doesn't know or care which transport is running it.
Packages
Silkweave is organized as a monorepo with modular packages. Install only what you need:
| Package | npm | Description |
|---|---|---|
@silkweave/core |
Core library - actions, adapters, builder, context, logger, utilities | |
@silkweave/auth |
Auth - resource-server core (bearer-token validation + protected-resource metadata, RFC 9728); opt-in OAuth 2.1 authorization-server proxy (PKCE, refresh, CIMD, dynamic client registration) via @silkweave/auth/oauth |
|
@silkweave/mcp |
MCP adapters - stdio, streamable HTTP, CLI proxy | |
@silkweave/cli |
CLI adapter - commander + plain console output |
|
@silkweave/fastify |
Fastify REST adapter - auto-generated OpenAPI/Swagger docs | |
@silkweave/trpc |
tRPC adapter - end-to-end type-safe procedures (standalone server, fetch handler, or a handler you mount yourself) | |
@silkweave/edge |
Web-Standard edge/serverless adapter - stateless MCP over Streamable HTTP (Cloudflare Workers, Vercel, Bun, Deno) | |
@silkweave/nestjs |
NestJS adapter - expose existing controllers as MCP tools via @Mcp() (input reflected from route + param decorators + swagger/class-validator) |
|
@silkweave/nextjs |
Next.js App Router adapter - defineSilkweave({ actions }) projects one action set onto MCP + tRPC route handlers |
|
@silkweave/ai |
Vercel AI SDK bridge - wrap streamText as a streaming action and feed useChat over a tRPC subscription |
|
@silkweave/typegen |
Type generator - emit .d.ts interfaces from action Zod schemas |
|
@silkweave/skills |
Serve Agent Skills (SKILL.md) over MCP - versioned, digest-verified, SEP-2640-aligned |
|
silkweave |
The Silkweave CLI - npx silkweave skills sync (install/update skills from a server), skills pack (publish a skill as a Claude Code plugin), and npx silkweave proxy <url> (any MCP server as a CLI). 5.0 breaking: previously an umbrella of re-exports (silkweave/core, ...) - depend on the scoped @silkweave/* packages instead |
@silkweave/core is always required. Then add the adapter packages for the transports you need:
# MCP server (stdio or HTTP)
pnpm add @silkweave/core @silkweave/mcp
# tRPC (end-to-end typed)
pnpm add @silkweave/core @silkweave/trpc
# REST API with Swagger
pnpm add @silkweave/core @silkweave/fastify
# CLI tool
pnpm add @silkweave/core @silkweave/cli
# Edge / Web Standard serverless MCP (Cloudflare Workers, Vercel, Bun, Deno)
pnpm add @silkweave/core @silkweave/edge
# NestJS controllers as MCP tools
pnpm add @silkweave/core @silkweave/nestjs
# Next.js App Router (MCP + tRPC)
pnpm add @silkweave/core @silkweave/nextjs
# Serve Agent Skills from an MCP server (then: npx silkweave skills sync)
pnpm add @silkweave/core @silkweave/mcp @silkweave/skills
Express-Optional MCP
express and cors are optional peerDependencies of @silkweave/mcp - they are only needed for the Express-based http() server. 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, @silkweave/nextjs) import. This means a serverless bundle or install never pulls Express into its graph.
// Express-free: tool-registration + result helpers, no express/cors
import { registerTools, smartToolResult } from '@silkweave/mcp/tools'
If you use the Express http() server, install the peers:
pnpm add @silkweave/core @silkweave/mcp express cors
Quick Start
pnpm add @silkweave/core @silkweave/mcp
Create an action:
// actions/greet.ts
import z from 'zod/v4'
import { createAction } from '@silkweave/core'
export const GreetAction = createAction({
name: 'greet',
description: 'Greet someone by name',
input: z.object({
name: z.string().describe('The name to greet'),
enthusiastic: z.boolean().describe('Add excitement').default(false)
}),
run: async ({ name, enthusiastic }, { logger }) => {
const greeting = enthusiastic ? `HELLO, ${name.toUpperCase()}!!!` : `Hello, ${name}.`
logger.info(greeting)
return { greeting }
}
})
Serve it as an MCP server:
// server.ts
import { silkweave } from '@silkweave/core'
import { stdio } from '@silkweave/mcp'
import { GreetAction } from './actions/greet.js'
await silkweave({ name: 'my-server', description: 'My MCP Server', version: '1.0.0' })
.adapter(stdio())
.action(GreetAction)
.start()
That's it. Your action is now an MCP tool called Greet that any MCP client (Claude Desktop, Cursor, Claude Code, etc.) can discover and invoke.
Core Concepts
Actions
An Action is the fundamental unit of logic in Silkweave. It is completely transport-agnostic.
import z from 'zod/v4'
import { createAction } from '@silkweave/core'
export const SearchAction = createAction({
name: 'search',
description: 'Search documents by query',
input: z.object({
query: z.string().describe('Search query'),
limit: z.number().int().min(1).max(100).describe('Max results').default(10),
includeArchived: z.boolean().describe('Include archived documents').default(false)
}),
run: async ({ query, limit, includeArchived }, { logger }) => {
logger.info(`Searching for: ${query}`)
// ... your logic here
const results = await performSearch(query, { limit, includeArchived })
return { results, count: results.length }
}
})
createAction accepts an object with:
| Field | Type | Description |
|---|---|---|
name |
string |
Unique identifier. Adapters transform this automatically (PascalCase for MCP tools, kebab-case for CLI commands, as-is for REST routes). |
description |
string |
Human-readable description. Shown in MCP tool listings, CLI help, and Swagger docs. |
input |
z.ZodObject |
A Zod object schema defining the input. .describe() on each field provides per-field documentation across all adapters. |
args |
(keyof I)[] |
(Optional) Fields to expose as positional CLI arguments instead of --options. Only relevant for the CLI adapter. |
run |
(input, context) => Promise<O> |
The implementation. Receives validated input and an SilkweaveContext with a logger. Returns any object - adapters handle serialization. |
Adapters
Adapters are the bridge between your actions and the outside world. Each adapter is a factory function that takes configuration and returns a generator compatible with the Silkweave builder.
import { stdio, http } from '@silkweave/mcp'
import { fastify } from '@silkweave/fastify'
import { cli } from '@silkweave/cli'
// No config needed
stdio()
// Host and port required
http({ host: 'localhost', port: 8080 })
// Full Fastify options pass-through
fastify({ host: 'localhost', port: 8080, logger: true })
// No config needed
cli()
The adapter lifecycle:
- Factory -
stdio()/http({ ... })/ etc. captures configuration - Generator - Silkweave calls the factory result with
{ name, description, version }to produce anAdapter - Start -
adapter.start(actions)registers all actions and begins listening - Stop -
adapter.stop()tears down gracefully
The Silkweave Builder
The builder provides a fluent, chainable API:
import { silkweave } from '@silkweave/core'
import { stdio, http } from '@silkweave/mcp'
const app = silkweave({
name: 'my-toolkit',
description: 'A collection of useful tools',
version: '2.1.0'
})
app
.adapter(stdio()) // Add an adapter
.adapter(http({ ... })) // Add another - they run in parallel
.action(SearchAction) // Mount an action
.action(GreetAction) // Mount another
await app.start() // Start all adapters concurrently
.adapter() and .action() return the same instance, so you can chain freely. .start() launches all adapters in parallel via Promise.all.
Adapters in Depth
MCP Stdio
The standard MCP transport for local tool servers. Communicates over stdin/stdout using the MCP protocol.
import { silkweave } from '@silkweave/core'
import { stdio } from '@silkweave/mcp'
await silkweave({ name: 'my-tools', description: 'My Tools', version: '1.0.0' })
.adapter(stdio())
.action(MyAction)
.start()
How actions become MCP tools:
| Action property | MCP tool property |
|---|---|
name: 'searchDocs' |
Tool name: SearchDocs (PascalCase) |
description |
Tool description |
input (Zod schema) |
inputSchema (JSON Schema via Zod) |
| Return value | TextContent JSON response |
| Thrown errors | Structured error response with name, message, and stack |
MCP logging notifications are wired automatically - logger.info("message") in your action sends a notifications/message to the MCP client. Progress reporting works via logger.progress() when the client provides a progress token.
Claude Desktop / Claude Code configuration:
{
"mcpServers": {
"my-tools": {
"command": "node",
"args": ["path/to/server.js"]
}
}
}
MCP Streamable HTTP
A stateless MCP transport over HTTP (per the 2026 spec direction): each POST /mcp mints a fresh transport and server, handles exactly that request - streaming progress over SSE when the call carries a progress token - and tears down when the response closes. No session IDs, no session map; any request can hit any instance, so the same adapter works behind load balancers and in multi-replica deployments. GET and DELETE on the transport path answer 405 with Allow: POST - the Streamable HTTP spec's response for a server offering no standing SSE stream, and what MCP clients read as the "no stream here" signal.
import { silkweave } from '@silkweave/core'
import { http } from '@silkweave/mcp'
await silkweave({ name: 'my-tools', description: 'My Tools', version: '1.0.0' })
.adapter(http({
host: 'localhost',
port: 8080,
allowedHosts: ['localhost']
}))
.action(MyAction)
.start()
Endpoints exposed:
| Method | Path | Purpose |
|---|---|---|
POST |
/mcp |
Handle a single MCP request (per-request SSE response) |
GET/DELETE |
/mcp |
405 Allow: POST - the stateless transport offers no standing SSE stream |
GET |
/resource/:id |
Sideloaded embedded resources (disposition: 'smart') |
| various | OAuth + /.well-known/* |
When auth is configured |
GET |
/.claude-plugin/marketplace.json |
When skillsMarketplace is configured |
CORS is configured out of the box.
StartMcpHttpOptions (selected):
| Option | Type | Description |
|---|---|---|
host / port |
string / number |
Bind address and listen port |
allowedHosts |
string[] |
DNS-rebinding protection allow-list |
auth |
AuthConfig |
Bearer-token validation and/or OAuth 2.1 routes |
filterActions |
FilterActions |
Per-request tool filtering (see MCP Tool Quality) |
onToolCall |
OnToolCall |
Fire-and-forget per-call telemetry |
skills |
(Skill | SkillDefinition)[] |
Serve Agent Skills |
skillsMarketplace |
SkillsMarketplaceOptions |
Claude Code plugin marketplace for npm-published skills |
transportPaths |
string[] |
Extra paths that also serve the transport - the per-tenant connector URLs of a multi-resource server |
tRPC
Exposes your actions as end-to-end type-safe tRPC procedures. Each action becomes a query or mutation (per its kind) at camelCase(action.name), and the exported InferTrpcRouter<typeof server> gives your client a fully-typed AppRouter - no code generation.
import { silkweave } from '@silkweave/core'
import { type InferTrpcRouter, trpc } from '@silkweave/trpc'
const server = silkweave({ name: 'my-api', description: 'My API', version: '1.0.0' })
.adapter(trpc({ host: 'localhost', port: 8080 }))
.action(SearchAction)
export type AppRouter = InferTrpcRouter<typeof server>
await server.start() // tRPC server on http://localhost:8080/trpc/
// client.ts - fully typed against your actions
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from './server.js'
const client = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: 'http://localhost:8080/trpc' })] })
const { results } = await client.search.query({ query: 'hello', limit: 5 })
For serverless runtimes (Astro, Vercel, Cloudflare Workers), use trpcFetch() instead - it returns a Web Standard (Request) => Promise<Response> handler (plus GET/POST) rather than binding a port. Streaming actions are registered as tRPC subscriptions automatically.
For a Node server you already own, use trpcNode() - it returns a (req, res) handler to mount as one route among many:
const api = trpcNode({ endpoint: '/trpc', auth })
const server = silkweave({ name: 'wiki', version: '1.0.0' }).adapter(api.adapter).actions(actions)
export type AppRouter = InferTrpcRouter<typeof server>
await server.start()
httpServer.on('request', (req, res) => {
if (req.url?.startsWith('/trpc')) { return api.handler(req, res) }
// ...the MCP transport, your gateway, the SPA
})
This matters beyond convenience: an app whose browser talks tRPC and whose agent talks MCP usually needs both on one origin, because a browser cannot put an Authorization header on a WebSocket upgrade - the only credential a tab can present is a per-origin cookie. The handler does not verify its prefix (it slices endpoint unconditionally), and it configures no CORS, since a mounted handler does not own its host's response headers.
Cookie-shaped auth. auth reads exactly one credential, a bearer token. authenticate - available on all three tRPC adapters - resolves the caller from the request itself instead:
trpcNode({
endpoint: '/trpc',
auth, // the agent still presents a bearer token
authenticate: (req) => { // the user's tab presents a cookie
const user = sessions.resolve(req)
return user ? { token: user.sessionId, clientId: user.id } : null
}
})
Returning null falls through to the bearer path, so one endpoint serves both callers, and the resolved identity lands on the same auth context key the MCP adapters use - one action's run() serves the browser and the agent unchanged. Note it bypasses every check validateToken performs (expiry, issuer, audience, scopes), because what it validates is not a token, and it inherits a browser CSRF threat model bearer endpoints are structurally immune to - see the package README before reaching for it.
Fastify REST API
Turns your actions into a REST API with auto-generated OpenAPI documentation and an interactive Swagger UI powered by Scalar.
import { silkweave } from '@silkweave/core'
import { fastify } from '@silkweave/fastify'
await silkweave({ name: 'my-api', description: 'My REST API', version: '1.0.0' })
.adapter(fastify({
host: 'localhost',
port: 8080,
logger: true
}))
.action(SearchAction)
.action(GreetAction)
.start()
Route mapping:
Each action becomes a POST /{action.name} route. The Zod schema is converted to JSON Schema for request body validation and OpenAPI documentation.
| Action | Route | Body |
|---|---|---|
name: 'search' |
POST /search |
{ "query": "...", "limit": 10 } |
name: 'greet' |
POST /greet |
{ "name": "World" } |
Visit http://localhost:8080/ for the interactive Scalar API reference with try-it-out functionality.
FastifyAdapterOptions:
Extends Fastify's native FastifyHttpOptions, so any Fastify config is supported:
fastify({
host: 'localhost',
port: 8080,
logger: {
level: 'debug',
transport: { target: 'pino-pretty' }
},
connectionTimeout: 30000
})
CLI
Transforms your actions into a complete command-line application with help text, option parsing, and plain console output.
import { silkweave } from '@silkweave/core'
import { cli } from '@silkweave/cli'
await silkweave({ name: 'mytool', description: 'My CLI Tool', version: '1.0.0' })
.adapter(cli())
.action(GreetAction)
.action(SearchAction)
.start()
How Zod types map to CLI options:
| Zod Type | CLI Representation |
|---|---|
z.string() |
--option-name <string> |
z.number() |
--option-name <number> |
z.boolean() |
--option-name / --no-option-name |
z.record() |
--option-name <json> |
.default(value) |
Sets the default in help text |
.describe('...') |
Sets the option description |
Field names are automatically converted to kebab-case for flags. Action names become kebab-case subcommands.
Example output:
$ mytool greet --name "World" --enthusiastic
◇ mytool - greet
ℹ HELLO, WORLD!!!
Edge Adapter
Deploy your actions as a stateless MCP server on any Web-Standard edge/serverless runtime - Cloudflare Workers, Vercel, Bun, Deno, Hono. Each request creates a fresh server instance - no sessions, no persistent connections, fully compatible with serverless constraints.
// api/mcp.ts (edge / serverless function)
import { silkweave } from '@silkweave/core'
import { edge } from '@silkweave/edge'
import { SearchAction } from '../actions/search.js'
const { adapter, handler } = edge()
await silkweave({ name: 'my-tools', description: 'My Tools', version: '1.0.0' })
.adapter(adapter)
.action(SearchAction)
.start()
export default { fetch: handler }
The edge() function returns a compound object - adapter wires into the Silkweave builder, while handler/GET/POST/DELETE are the request handler for your route.
For Next.js App Router (app/api/mcp/route.ts):
const { adapter, GET, POST, DELETE } = edge()
await silkweave({ name: 'my-tools', description: 'My Tools', version: '1.0.0' })
.adapter(adapter)
.action(SearchAction)
.start()
export { GET, POST, DELETE }
How it works:
- Uses
WebStandardStreamableHTTPServerTransportfrom the MCP SDK in stateless mode (sessionIdGenerator: undefined) - Each request creates a fresh
McpServer+ transport, registers tools, handles the request, and returns a Web StandardResponse - Only
POSTcarries JSON-RPC;GET(standing SSE stream) andDELETE(session teardown) return405, since stateless mode has no session to attach a stream to or tear down (aGETstream would otherwise hang the request on serverless runtimes) - Logging goes to
process.stderr(Vercel log drain) and MCP client notifications - No CORS handling - use Next.js middleware or
vercel.jsonheaders
EdgeAdapterOptions:
| Option | Type | Default | Description |
|---|---|---|---|
enableJsonResponse |
boolean |
false |
Return JSON instead of SSE streams |
auth |
AuthConfig |
- | Bearer-token validation + OAuth routes |
path |
string |
/mcp |
The MCP transport path |
Because it's a Web Standard (Request) => Response handler, the same edge() adapter runs unchanged on Cloudflare Workers, Vercel, Bun, Deno, and Hono. The examples/cloudflare example is a full Worker deployment - stateless MCP + Google Workspace OAuth 2.1 with OAuth state in Cloudflare KV (using createRedisStore over a KV adapter, since Workers have no filesystem) - with a from-scratch Cloudflare + Google setup guide in its README.
Framework Integrations
Beyond the transport adapters, Silkweave meets your framework where it is.
NestJS
@silkweave/nestjs exposes your existing NestJS controllers as MCP tools - additively. Add @Mcp() 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 tool call the validated input is split back into the method's positional arguments and the handler runs directly, with @UseGuards() applied first.
@Controller('users')
export class UsersController {
@Get(':id')
@ApiParam({ name: 'id', description: 'User ID' })
@Mcp() // -> MCP tool "UsersGet", input { id: string }
get(@Param('id') id: string) {
return this.users.find(id)
}
}
@Module({
imports: [SilkweaveModule.forRoot({
silkweave: { name: 'my-app', description: 'My app', version: '1.0.0' },
adapters: [mcp({ basePath: '/mcp' })]
})],
controllers: [UsersController]
})
export class AppModule {}
Controllers keep serving HTTP unchanged; removing @Mcp() fully reverts a method.
Next.js
@silkweave/nextjs 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 and additive (it only adds route files), wrapping @silkweave/edge and @silkweave/trpc with catch-all path normalization and end-to-end tRPC types. No next/react dependency.
// silkweave/server.ts
import { defineSilkweave } from '@silkweave/nextjs'
import { banUser, listUsers } from './actions'
export const app = defineSilkweave({
name: 'my-app', description: 'My app', version: '1.0.0',
actions: [listUsers, banUser]
})
export type AppRouter = typeof app.Router
// app/api/mcp/[[...mcp]]/route.ts - one catch-all serves transport + OAuth + well-known
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
export const { GET, POST, OPTIONS } = app.trpc({ endpoint: '/api/trpc' })
Vercel AI SDK
@silkweave/ai bridges the Vercel AI SDK's useChat hook to a Silkweave streaming action over a tRPC subscription - no /api/chat route, no Data Stream Protocol parsing. createChatAction() wraps streamText() into a streaming action; silkweaveTransport() is a custom ChatTransport that turns a subscribe-style function into the ReadableStream<UIMessageChunk> useChat consumes directly.
Agent Skills over MCP
Silkweave servers can serve Agent Skills - the SKILL.md directories Claude Code and other agents use - versioned, digest-verified, and installable with one command. This is what the silkweave npm package is: the CLI that installs and updates skills from any Silkweave (or SEP-2640-conforming) MCP server.
import { defineSkill, silkweave } from '@silkweave/core'
import { http } from '@silkweave/mcp/server'
await silkweave({ name: 'team-skills', description: 'Team skills server', version: '1.0.0' })
.adapter(http({
host: 'localhost',
port: 8080,
skills: [
defineSkill({ dir: './skills/commit-message', npmPackage: 'commit-message-skill' }),
defineSkill({ dir: './skills/release-checklist', tags: ['internal'] })
],
skillsMarketplace: { owner: { name: 'Your Team' } } // optional: Claude Code plugin marketplace
}))
.start()
# every machine on the team converges on one server
npx silkweave skills sync --url https://skills.example.com/mcp --token $TOKEN
- Serving - the
skillsoption (onstdio(),http(),mcpTransport(),edge()) serves each skill three ways at once:skill://file resources (the SEP-2640 baseline),ListSkills/GetSkilltools (work with every MCP client today), and a server-instructions pointer that activates the skills in hosts. An experimentalskillsExtension: trueadditionally serves the draft SEP-2640skills/list/skills/getmethods. - Installing -
silkweave skills sync|install|list|outdated|pin|unpinmaintain a lockfile with per-file sha256 digests; every install is re-verified client-side, so a server can never write outside the target directory. - Private and mixed sharing - put the server behind bearer/OAuth auth; a per-request
filterActionsthat hides the skill tools also hides the resources and instructions. - Public skills -
silkweave skills packwraps a skill into a skills-only Claude Code plugin package fornpm publish; theskillsMarketplaceoption serves/.claude-plugin/marketplace.jsonso users get native/plugin install+/plugin update.
See @silkweave/skills (serving) and the silkweave CLI (installing, packing, universal MCP proxy).
Authentication
@silkweave/auth is split into two layers so a pure resource server never pulls the OAuth issuer machinery into its graph:
- Root (
@silkweave/auth) - the spec-required resource-server core (jose-only): validate bearer tokens, delegate issuance to an external IdP. @silkweave/auth/oauth- the opt-in authorization-server layer: front your own OAuth 2.1 flow, with persistence stores.
AuthConfig is accepted by the MCP http(), edge(), tRPC, and framework adapters.
Resource-Server Core
The root export is the minimal, spec-required core: bearer-token validation (expiry + issuer binding per RFC 9207, audience binding per RFC 8707, step-up scope challenge per SEP-2350) plus protected-resource metadata (RFC 9728, including scopes_supported). It is jose-only - importing it never pulls the OAuth issuer machinery.
import { edge } from '@silkweave/edge'
import type { AuthConfig } from '@silkweave/auth'
const auth: AuthConfig = {
// validate the bearer token against your external IdP
verifyToken: async (token) => {
const claims = await validateWithIdp(token)
return { token, clientId: claims.sub, scopes: claims.scope?.split(' ') ?? [] }
}
}
const { adapter, handler } = edge({ auth })
Opt-in OAuth 2.1
To front your own OAuth 2.1 flow, import from the @silkweave/auth/oauth subpath. This is where the authorization-server proxy lives - PKCE, refresh tokens, client ID metadata documents (CIMD), and dynamic client registration - along with the persistence stores (memory, JSON file, and Redis). Providers like google() and the createRedisStore / createJsonStore factories all come from this subpath.
import { edge } from '@silkweave/edge'
import { google, createJsonStore } from '@silkweave/auth/oauth'
const auth = google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
store: createJsonStore('./oauth-state.json')
})
const { adapter, handler } = edge({ auth })
Importing only @silkweave/auth keeps the issuer/store machinery out of your bundle; reach for @silkweave/auth/oauth only when you run the OAuth 2.1 flow yourself.
One AS, N protected resources
A server fronting several tenants needs a distinct URL per tenant - Claude Desktop and claude.ai dedupe MCP connectors by URL, so one URL means one attachable space - and each URL must be its own OAuth protected resource with its own token audience, so a token minted for tenant A is rejected when replayed at tenant B. There is still exactly one authorization server.
import { pathResolver } from '@silkweave/auth'
const AS = 'https://mcp.example.com'
const TENANT = /^\/([a-z]{8})$/
const auth = google({
clientId, clientSecret, store,
resourceUrl: AS, // AS identity + default audience
resolveResource: pathResolver({ origin: AS, match: TENANT }), // one resource per tenant
allowedResources: async (resource) => isLiveTenant(resource) // which audiences the AS will mint
})
pathResolver builds identifiers from your configured origin, never the inbound request's, so a spoofed Host cannot steer the advertised metadata URL or the expected audience. Adapters serve the RFC 9728 path-insertion metadata route automatically, and existing string resourceUrl configs are unchanged in every respect.
aud is not membership. The authorization server mints aud: .../tenantA for any authenticated user who asks, including one with no rights in tenant A - the indicator says where a token may be presented, never what its subject may do there. Per-tenant authorization stays your job on every call; validateToken returns the resolved resource so that check needs no URL re-parsing. See the auth README.
Logging and Progress
Every action receives a context.logger with eight severity levels plus a progress reporter:
run: async (input, { logger }) => {
logger.debug('Starting operation...')
logger.info('Processing item')
logger.warning('Rate limit approaching')
logger.error('Failed to connect')
// Progress reporting (renders as MCP progress notifications,
// Fastify trace logs, or console output depending on adapter)
for (let i = 1; i <= total; i++) {
logger.progress({
progress: i,
total: total,
message: `Processing item ${i} of ${total}`
})
await processItem(i)
}
return { processed: total }
}
How logging is handled per adapter:
| Level | MCP (stdio/http) | Fastify | CLI |
|---|---|---|---|
debug |
notifications/message |
logger.debug() |
log.message() |
info |
notifications/message |
logger.info() |
log.info() |
warning |
notifications/message |
logger.warn() |
log.warn() |
error |
notifications/message |
logger.error() |
log.error() |
critical |
notifications/message |
logger.fatal() |
log.error() |
progress |
notifications/progress |
logger.trace() |
console.info() |
Advanced Patterns
Multiple Adapters Simultaneously
Run an MCP server and a REST API from the same set of actions:
import { silkweave } from '@silkweave/core'
import { stdio } from '@silkweave/mcp'
import { fastify } from '@silkweave/fastify'
await silkweave({ name: 'my-platform', description: 'Multi-transport', version: '1.0.0' })
.adapter(stdio())
.adapter(fastify({ host: 'localhost', port: 8080, logger: true }))
.action(SearchAction)
.action(GreetAction)
.action(AnalyzeAction)
.start()
All adapters start concurrently. The MCP stdio server communicates over stdin/stdout while Fastify listens on port 8080 - each serving the exact same actions.
CLI Arguments vs Options
By default, all Zod fields become CLI --options. Use the args property to promote fields to positional arguments:
export const DeployAction = createAction({
name: 'deploy',
description: 'Deploy to an environment',
input: z.object({
environment: z.string().describe('Target environment'),
tag: z.string().describe('Release tag'),
dryRun: z.boolean().describe('Simulate without deploying').default(false)
}),
args: ['environment', 'tag'],
run: async ({ environment, tag, dryRun }, { logger }) => {
logger.info(`Deploying ${tag} to ${environment}${dryRun ? ' (dry run)' : ''}`)
// ...
return { deployed: !dryRun }
}
})
$ mytool deploy production v2.1.0 --dry-run
◇ mytool - deploy
ℹ Deploying v2.1.0 to production (dry run)
Fields listed in args become positional arguments in the CLI. All other fields remain as --options. The args property has no effect on MCP or REST adapters - they always receive all fields as a single input object.
Complex Input Types
Zod's full expressiveness is available for input schemas:
export const ImportAction = createAction({
name: 'import',
description: 'Import data from a source',
input: z.object({
source: z.string().describe('Data source URL'),
format: z.string().describe('File format').default('json'),
batchSize: z.number().int().min(1).max(10000).describe('Records per batch').default(500),
tags: z.record(z.string()).describe('Key-value metadata tags').optional(),
overwrite: z.boolean().describe('Overwrite existing records').default(false)
}),
run: async (input, { logger }) => {
logger.info(`Importing from ${input.source} in ${input.format} format`)
logger.info(`Batch size: ${input.batchSize}, overwrite: ${input.overwrite}`)
if (input.tags) {
logger.debug(`Tags: ${JSON.stringify(input.tags)}`)
}
// ...
return { imported: 1500 }
}
})
In MCP, this becomes a tool with a full JSON Schema. In the CLI, tags becomes --tags <json> accepting a JSON string. In Fastify, it's a documented POST body.
MCP Tool Quality
Four features (3.2) make the MCP surface agent-grade - all opt-in, all defined on the action or the adapter:
createAction({
name: 'users.get',
description: 'Get a user by id',
input: z.object({ id: z.string().describe('User id from UsersList') }),
output: z.object({ id: z.string(), name: z.string() }),
disposition: 'structured', // output becomes the MCP outputSchema contract
annotations: { idempotentHint: true }, // merged over the kind-derived readOnlyHint
tags: ['users', 'read'], // matched by filterActions below
kind: 'query',
run: async ({ id }) => getUser(id)
No comments yet
Be the first to share your take.