@growth-labs/mcp-server
v0.4.1
Published
Server framework for Growth Labs MCP services. Provides defineMcpTool primitive, createMcpServer composition, audit/metrics interfaces, error envelopes, role-based authorization, and a hand-rolled JSON-RPC HTTP transport behind a swappable Transport abstr
Readme
@growth-labs/mcp-server
Server framework for Growth Labs MCP services. Wraps a transport-agnostic
JSON-RPC dispatcher with the conventions every MCP service needs:
defineMcpTool primitive, createMcpServer composition, audit logging,
metrics, error envelopes, role-based authorization, input/output validation
(strict-in / warn-out-by-default), and rate limiting.
Why hand-rolled vs. the official SDK?
The official MCP TypeScript implementation has split into two packages:
@modelcontextprotocol/sdk (stable, but Node-stream-based — does not run
on Cloudflare Workers) and @modelcontextprotocol/server (Workers-compatible,
but 2.0.0-alpha with only two published versions and a peer-dep on
another alpha package).
For substrate that downstream services depend on, alpha churn is
unacceptable. v1 hand-rolls a minimal MCP JSON-RPC dispatcher mirroring
fulcrum-labs/memory (kb-server)'s production-tested pattern.
The transport is behind a swappable Transport interface, so adopting
@modelcontextprotocol/server once it stabilizes is a transport
implementation change — not a public-API change for consumers.
Install
pnpm add @growth-labs/mcp-server @growth-labs/mcp-authRuntime deps: zod, zod-to-json-schema, @growth-labs/mcp-auth.
Quick start
import { mcpAuthMiddleware, tokenMapResolver } from '@growth-labs/mcp-auth'
import { createMcpServer, defineMcpTool } from '@growth-labs/mcp-server'
import { z } from 'zod'
const listCards = defineMcpTool({
name: 'kanban_list_cards',
description: 'Lists projected kanban cards.',
input_schema: z.object({ project_id: z.string().optional() }),
output_schema: z.object({
cards: z.array(z.object({ id: z.string(), title: z.string() })),
}),
examples: [{ description: 'list', input: {}, output: { cards: [] } }],
stability: 'stable',
required_roles: ['operator'],
handler: async (ctx, input) => ({ cards: [] }),
})
const server = createMcpServer({
service: 'foundry',
version: '0.1.0',
auth: mcpAuthMiddleware({
resolvers: [tokenMapResolver({ envVarName: 'FOUNDRY_MCP_TOKENS_JSON' })],
required: true,
}),
audit: { async write(event) { /* persist */ } },
metrics: { emit(event) { /* WAE */ } },
tools: [listCards],
protectedResourceMetadata: {
resource: 'https://mcp.example.com/mcp',
authorizationServers: ['https://auth.example.com'],
scopesSupported: ['openid', 'email', 'profile', 'mcp:example'],
},
})
export default { fetch: server.fetch }Tool primitive
defineMcpTool is the only sanctioned way to build a tool. Required fields:
| Field | Purpose |
|---|---|
| name | Lowercase snake_case, 1–64 chars matching ^[a-z][a-z0-9_]{0,63}$ (e.g. kanban_list_cards). Stable forever once shipped. Periods are forbidden — Anthropic's tool-name validator (^[a-zA-Z0-9_-]{1,64}$) rejects them, and Claude clients refuse to register the entire tool list when any one tool's name fails to match. |
| description | One paragraph for LLM clients. |
| agent_guidance | When to use this vs. similar tools. Optional but recommended. |
| input_schema | Zod schema. Converted to JSON Schema in tools/list. |
| output_schema | Zod schema. Validated strict in dev, warn-mode in production. |
| examples | At least one realistic input/output pair, surfaced in tools/list. |
| stability | stable | beta | experimental | deprecated. |
| required_roles | Empty array = any authenticated actor. |
| rate_limit | Optional override; otherwise framework defaults apply. |
| handler | (ctx, input) => Promise<output>. |
Handler-thrown McpError instances pass their code through to the
client. Generic Errors become code: 'internal' with a safe message;
the original is logged but never returned.
Stability levels
Per-tool, surfaced in tools/list so LLM clients can reason about it.
stable— API contract frozen.beta— likely to change but documented and supported.experimental— may change or disappear.deprecated— still works; requiresdeprecation_messagepointing at the replacement.
Error envelope
Tool-level errors return HTTP 200 (per MCP convention — the protocol envelope succeeded; the tool inside it didn't) with:
{
"content": [{ "type": "text", "text": "<envelope JSON>" }],
"isError": true
}The envelope JSON:
{
"error": {
"code": "not_found" | "forbidden" | "validation_error" | "rate_limited" | "internal" | "unauthenticated",
"message": "Human-readable summary",
"request_id": "req_xxxxxxxx",
"tool": "kanban_list_cards"
}
}McpError.internal is never serialized to the client. It is logged
to the Worker tail.
Validation
- Input: always strict. Bad input returns
validation_error. - Output: configurable via
outputValidationMode(strict|warn). Defaultwarn(production-friendly: drift logs but doesn't break clients). Set tostrictin dev/CI to catch schema drift.
Rate limiting
Per-tool optional rate_limit: { per_actor, per_service }. Framework defaults:
120 req/min per actor, 5000 req/min per service. v1 ships an
InMemoryRateLimiter (per-isolate, approximate — fine for catching
runaway loops). Swap to a DO-backed limiter when cross-isolate
consistency matters.
Transport
The default HttpJsonRpcTransport speaks plain JSON-RPC 2.0 over HTTP:
POST /mcpwith{ jsonrpc, id, method, params }body.- Methods:
initialize,notifications/initialized,tools/list,tools/call. - Protocol version:
2025-06-18. - HTTP 200 for results and tool-level errors; HTTP 202 for notifications; HTTP 401 for auth failures (handled upstream by middleware).
To swap transports — e.g. once @modelcontextprotocol/server graduates
from alpha — implement the Transport interface and pass it as
transport in McpServerConfig. The rest of the framework is
transport-independent.
OAuth protected resource metadata
MCP services that support OAuth should pass protectedResourceMetadata to
createMcpServer:
createMcpServer({
service: 'foundry',
version: '0.1.0',
auth,
audit,
metrics,
tools,
protectedResourceMetadata: {
resource: 'https://mcp.foundry.fulcrum-labs.com/mcp',
authorizationServers: ['https://auth.fulcrum-labs.com'],
scopesSupported: ['openid', 'email', 'profile', 'mcp:foundry'],
bearerMethodsSupported: ['header'], // default
resourceDocumentation: 'https://docs.example.com/foundry-mcp',
},
})When configured, the server:
- serves
GET /.well-known/oauth-protected-resourcewith RFC 9728 metadata plusmcp_protocol_version: "2025-06-18"; - sets
Cache-Control: public, max-age=3600; - adds
resource_metadata="<origin>/.well-known/oauth-protected-resource"to BearerWWW-Authenticate401 challenges.
The resource_metadata URL is derived from the configured resource
origin, not the inbound request URL, so services behind proxies advertise
their canonical public endpoint.
Audit + metrics
Audit and metrics are interfaces; the consumer provides storage.
interface AuditLogger { write(event: ToolAuditEvent): Promise<void> }
interface MetricsEmitter { emit(event: ToolMetricsEvent): void }Every tools/call produces exactly one audit row and one metrics event,
regardless of outcome. Audit/metrics writer errors are logged but never
break the request — best-effort.
v2 follow-ups
- Evaluate
@modelcontextprotocol/serverwhen it reaches a stable major release; swap the default transport if the API surface is acceptable. - Optional
defineMcpResourceprimitive (MCP supports resources distinct from tools; v1 only does tools). - DO-backed rate limiter for cross-isolate consistency.
- Lint rule (or test helper) consumers can adopt to enforce
defineMcpToolusage in their tools dir. - Optional
tagsfield on tool definitions for client-side filtering.
