by cyanheads
Agent-native TypeScript framework for building MCP servers. Declarative definitions with auth, multi-backend storage, OpenTelemetry, and first-class support for Bun/Node/Cloudflare Workers.
# Add to your Claude Code skills
git clone https://github.com/cyanheads/mcp-ts-core@cyanheads/mcp-ts-core is the infrastructure layer for TypeScript MCP servers. Install it as a dependency — don't fork it. You write tools, resources, and prompts; the framework handles transports, auth, storage, config, logging, telemetry, and lifecycle.
import { createApp, tool, z } from '@cyanheads/mcp-ts-core';
const greet = tool('greet', {
description: 'Greet someone by name and return a personalized message.',
annotations: { readOnlyHint: true },
input: z.object({ name: z.string().describe('Name of the person to greet') }),
output: z.object({ message: z.string().describe('The greeting message') }),
handler: async (input) => ({ message: `Hello, ${input.name}!` }),
});
await createApp({ tools: [greet] });
That's a complete MCP server. Every tool call is automatically logged with duration, payload sizes, memory usage, and request correlation — no instrumentation code needed. createApp() handles config parsing, logger init, transport startup, signal handlers, and graceful shutdown.
No comments yet. Be the first to share your thoughts!
bunx @cyanheads/mcp-ts-core init my-mcp-server
cd my-mcp-server
bun install
You get a scaffolded project with CLAUDE.md, Agent Skills, and a src/ tree ready for your tools. Infrastructure — transports, auth, storage, telemetry, lifecycle, linting — lives in node_modules. What's left is domain: which APIs to wrap, which workflows to expose.
Start your coding agent (Claude Code, Codex, Cursor), describe the system you want to expose, and it drives the build. The included skills cover the full cycle: setup, design-mcp-server, scaffolding, testing, security-pass, release-and-publish.
Here's what tool definitions look like:
import { tool, z } from '@cyanheads/mcp-ts-core';
export const search = tool('search', {
description: 'Search for items by query.',
input: z.object({
query: z.string().describe('Search query'),
limit: z.number().default(10).describe('Max results'),
}),
output: z.object({ items: z.array(z.string()).describe('Search results') }),
async handler(input) {
const results = await doSearch(input.query, input.limit);
return { items: results };
},
});
And resources:
import { resource, z } from '@cyanheads/mcp-ts-core';
export const itemData = resource('items://{itemId}', {
description: 'Retrieve item data by ID.',
params: z.object({ itemId: z.string().describe('Item ID') }),
async handler(params, ctx) {
return await getItem(params.itemId);
},
});
Everything registers through createApp() in your entry point:
await createApp({
name: 'my-mcp-server',
version: '0.1.0',
tools: allToolDefinitions,
resources: allResourceDefinitions,
prompts: allPromptDefinitions,
});
It also works on Cloudflare Workers with createWorkerHandler() — same definitions, different entry point.
tool(), resource(), prompt() builders with Zod schemas. appTool()/appResource() add interactive HTML UIs.ctx for logging, tenant-scoped storage, elicitation, sampling, cancellation, and task progress.auth: ['scope'] on definitions. Framework checks scopes before dispatch — no wrapper code.task: true for long-running ops; framework manages create/poll/progress/complete/cancel.lint:mcp) and devcheck step.errors: [{ reason, code, when, retryable? }] on a tool/resource and the handler receives a typed ctx.fail(reason, …) keyed against the declared reasons. The contract publishes in tools/list so clients preview failure modes; the linter cross-checks the handler body. Error factories (notFound(), httpErrorFromResponse(), …) for ad-hoc throws; plain Error works too — framework auto-classifies.in-memory, filesystem, Supabase, Cloudflare D1/KV/R2. Swap providers via env var; handlers don't change.none, jwt, or oauth. Local secret or JWKS verification.CLAUDE.md with the full exports catalog so AI agents ramp up without prompting.my-mcp-server/
src/
index.ts # createApp() entry point
worker.ts # createWorkerHandler() (optional)
config/
server-config.ts # Server-specific env vars
services/
[domain]/ # Domain services (init/accessor pattern)
mcp-server/
tools/definitions/ # Tool definitions (.tool.ts)
resources/definitions/ # Resource definitions (.resource.ts)
prompts/definitions/ # Prompt definitions (.prompt.ts)
package.json
tsconfig.json # extends @cyanheads/mcp-ts-core/tsconfig.base.json
CLAUDE.md # Points to core's CLAUDE.md for framework docs
No src/utils/, no src/storage/, no src/types-global/, no src/mcp-server/transports/ — infrastructure lives in node_modules.
All core config is Zod-validated from environment variables. Server-specific config uses a separate Zod schema with lazy parsing.
| Variable | Description | Default |
|:---------|:------------|:--------|
| MCP_TRANSPORT_TYPE | stdio or http | stdio |
| MCP_HTTP_PORT | HTTP server port | 3010 |
| MCP_HTTP_HOST | HTTP server hostname | 127.0.0.1 |
| MCP_AUTH_MODE | none, jwt, or oauth | none |
| MCP_AUTH_SECRET_KEY | JWT signing secret (required for jwt mode) | — |
| STORAGE_PROVIDER_TYPE | in-memory, filesystem, supabase, cloudflare-d1/kv/r2 | in-memory |
| OTEL_ENABLED | Enable OpenTelemetry | false |
| OPENROUTER_API_KEY | OpenRouter LLM API key | — |
See CLAUDE.md for the full configuration reference.
| Function | Purpose |
|:---------|:--------|
| createApp(options) | Node.js server — handles full lifecycle |
| createWorkerHandler(options) | Cloudflare Workers — returns { fetch, scheduled } |
| Builder | Usage |
|:--------|:------|
| tool(name, options) | Define a tool with handler(input, ctx) |
| resource(uriTemplate, options) | Define a resource with handler(params, ctx) |
| prompt(name, options) | Define a prompt with generate(args) |
| appTool(name, options) | Define an MCP Apps tool with auto-populated _meta.ui |
| appResource(uriTemplate, options) | Define an MCP Apps HTML resource with the correct MIME type and _meta.ui mirroring for read content |
Handlers receive a unified Context object:
| Property | Type | Description |
|:---------|:-----|:------------|
| ctx.log | ContextLogger | Request-scoped logger (auto-correlates requestId, traceId, tenantId) |
| ctx.state | ContextState | Tenant-scoped key-value storage |
| ctx.elicit | Function? | Ask the user for input (when client supports it) |
| ctx.sample | Function? | Request LLM completion from the client |
| ctx.signal | AbortSignal | Cancellation signal |
| ctx.notifyResourceUpdated | Function? | Notify subscribed clients a resource changed |
| ctx.notifyResourceListChanged | Function? | Notify clients the resource list changed |
| ctx.progress | ContextProgress? | Task progress reporting (when task: true) |
| ctx.requestId | string | Unique request ID |
| ctx.tenantId | string? | Tenant ID (from JWT or 'default' for stdio) |
import { createApp, tool, resource, prompt } from '@cyanheads/mcp-ts-core';
import { createWorkerHandler } from '@cyanheads/mcp-ts-core/worker';
import { McpError, JsonRpcErrorCode, notFound, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
import { checkScopes } from '@cyanheads/mcp-ts-core/auth';
import { markdown, fetchWithTimeout } from '@cyanheads/mcp-ts-core/utils';
import { OpenRouterProvider, GraphService } from '@cyanheads/mcp-ts-core/services';
import { validateDefinitions } from '@cyanheads/mcp-ts-core/linter';
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
import { fuzzTool, fuzzResource, fuzzPrompt } from '@cyanheads/mcp-ts-core/testing/fuzz';
See CLAUDE.md for the complete exports reference.