loading…
Search for a command to run...
loading…
Agent-native TypeScript framework for building MCP servers. Declarative definitions with auth, multi-backend storage, OpenTelemetry, and first-class support for
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.
Agent-native TypeScript framework for building MCP servers. Build tools, not infrastructure. Declarative definitions with auth, multi-backend storage, OpenTelemetry, and first-class support for Bun/Node/Cloudflare Workers.
@cyanheads/mcp-ts-core is the infrastructure layer for TypeScript MCP servers. Install it as a dependency — don't fork it. Your agent collaborates with you to design and build the tools, resources, and prompts for your server.
The framework handles the plumbing: transports, auth, config, logging, telemetry, & more. Define your domain logic with the builders and let the framework take care of the rest.
import { createApp, tool, z } from '@cyanheads/mcp-ts-core';
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
const search = tool('search', {
description: 'Search the catalog and return ranked matches.',
annotations: { readOnlyHint: true },
input: z.object({
query: z.string().describe('Search terms'),
limit: z.number().default(10).describe('Max results'),
}),
output: z.object({
items: z.array(z.string()).describe('Matching item names, best first'),
}),
enrichment: {
effectiveQuery: z.string().describe('Query as the server parsed it'),
totalCount: z.number().describe('Total matches before the limit'),
notice: z.string().optional().describe('Guidance when nothing matched'),
},
errors: [
{
reason: 'index_unavailable',
code: JsonRpcErrorCode.ServiceUnavailable,
when: 'The upstream search index is unreachable.',
retryable: true,
recovery: 'Retry in a few seconds — the index may be briefly unavailable.',
},
],
handler: async (input, ctx) => {
const res = await runSearch(input.query, input.limit);
if (!res) throw ctx.fail('index_unavailable'); // genuine failure → typed error contract
ctx.enrich({ effectiveQuery: res.parsed, totalCount: res.total });
if (res.items.length === 0) {
ctx.enrich({ notice: `No matches for "${input.query}". Try broader terms.` }); // empty result → notice, not a throw
}
return { items: res.items }; // enrichment never rides in the domain return
},
});
await createApp({ tools: [search] });
That's a complete MCP server, showing both flagship contracts. enrichment carries the context an agent reasons with — the parsed query, the true total, an empty-result notice — which the framework merges into structuredContent and mirrors into content[], so structuredContent-only clients (Claude Code) and content[]-only clients (Claude Desktop) both see it, no format() needed. The typed errors[] contract handles genuine failures (an empty result is a notice, not a throw). The linter cross-checks both against the handler body, and both publish in tools/list so clients preview a tool's success and failure shapes. Every tool call is automatically logged with duration, payload sizes, and request correlation — no instrumentation code needed; createApp() handles config parsing, logger init, transport startup, signal handlers, and graceful shutdown.
bunx @cyanheads/mcp-ts-core init my-mcp-server
cd my-mcp-server
bun install
You get a scaffolded project with CLAUDE.md/AGENTS.md, Agent Skills, plugin metadata (Codex + Claude Code), 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 (i.e. Claude Code, Codex) and describe what you want. The agent knows what to do from there. The included Agent Skills cover the full cycle: setup, design-mcp-server, scaffolding, testing, security-pass, release-and-publish, maintenance, & more.
The headline tool returns structured output — clients that read structuredContent (Claude Code) get it directly. To also render markdown for clients that read content[] (Claude Desktop), add a format(). The format-parity linter checks it renders every output field, so the two surfaces never drift:
import { tool, z } from '@cyanheads/mcp-ts-core';
export const itemSearch = tool('item_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 };
},
format: (result) => [
{ type: 'text', text: result.items.map((name) => `- ${name}`).join('\n') },
],
});
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,
instructions: 'Brief composition hints for the model.', // optional, sent on every `initialize`
});
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.instructions on createApp/createWorkerHandler rides every initialize for the model. Cross-tool composition hints, regional notes, scope guidance — without leaking text into every tool description.title, websiteUrl, description, icons (SEP-973) on createApp/createWorkerHandler flow to initialize serverInfo, the /.well-known/mcp.json server card, and the landing page.ctx for logging, tenant-scoped storage, elicitation, cancellation, and task progress.auth: ['scope'] on definitions, checked before dispatch (no wrapper code). Modes: none, jwt, or oauth (local secret or JWKS).task: true for long-running ops; framework manages create/poll/progress/complete/cancel.lint:mcp or devcheck — not invoked at server startup.errors: [{ reason, code, when, recovery, retryable? }] and handlers get a typed ctx.fail(reason, …). Contracts publish in tools/list so clients preview failure modes; the linter cross-checks the handler. Factories (notFound(), httpErrorFromResponse(), …) cover ad-hoc throws; plain Error auto-classifies.in-memory, filesystem, Supabase, Cloudflare D1/KV/R2. Swap via env var; handlers don't change.canvas_id) for multi-agent collaboration; sliding TTL + per-tenant scoping. Opt-in via CANVAS_PROVIDER_TYPE=duckdb; fails closed on Workers.CLAUDE.md / AGENTS.md and Agent Skills that give your coding agent full framework knowledge — it can scaffold tools, write tests, run security audits, and ship releases without you writing the boilerplate.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 / AGENTS.md # Point to core's CLAUDE.md / AGENTS.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 |
CANVAS_PROVIDER_TYPE |
none or duckdb (Tier 3, optional peer dep @duckdb/node-api) |
none |
OTEL_ENABLED |
Enable OpenTelemetry | false |
OPENROUTER_API_KEY |
OpenRouter LLM API key | — |
See CLAUDE.md/AGENTS.md for the full configuration reference.
| Function | Purpose |
|---|---|
createApp(options) |
Node.js server — handles full lifecycle |
createWorkerHandler(options) |
Cloudflare Workers — returns an ExportedHandler |
| 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 |
ElicitFn? |
Ask the user for input — form schema, or .url() for an external link (when client supports it) |
ctx.fail |
(reason, msg?, data?) => McpError |
Typed error throw — reason checked against errors[] contract at compile time |
ctx.signal |
AbortSignal |
Cancellation signal |
ctx.notifyResourceUpdated |
Function? |
Notify subscribed clients a resource changed |
ctx.notifyResourceListChanged |
Function? |
Notify clients the resource list changed |
ctx.notifyPromptListChanged |
Function? |
Notify clients the prompt list changed |
ctx.notifyToolListChanged |
Function? |
Notify clients the tool list changed |
ctx.progress |
ContextProgress? |
Task progress reporting (when task: true) |
ctx.requestId |
string |
Unique request ID |
ctx.tenantId |
string? |
Tenant ID (JWT tid claim, or 'default' for stdio and HTTP+MCP_AUTH_MODE=none) |
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 type { DataCanvas, CanvasInstance } from '@cyanheads/mcp-ts-core/canvas';
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/AGENTS.md for the complete exports reference.
The examples/ directory contains a reference server consuming core through public exports, demonstrating all patterns:
| Tool | Pattern |
|---|---|
template_echo_message |
Basic tool with format, auth |
template_cat_fact |
External API call, error factories |
template_madlibs_elicitation |
ctx.elicit for interactive input |
template_image_test |
Image content blocks |
template_async_countdown |
task: true with ctx.progress |
template_data_explorer |
MCP Apps with linked UI resource via appTool()/appResource() builders |
import { createMockContext } from '@cyanheads/mcp-ts-core/testing';
import { myTool } from '@/mcp-server/tools/definitions/my-tool.tool.js';
const ctx = createMockContext({ tenantId: 'test-tenant' });
const input = myTool.input.parse({ query: 'test' });
const result = await myTool.handler(input, ctx);
createMockContext() provides stubbed log, state, and signal. Pass { tenantId } for state operations, { elicit } for elicitation mocking, { progress: true } for task tools.
Schema-aware fuzz testing via fast-check. Generates valid inputs from Zod schemas and adversarial payloads (prototype pollution, injection strings, type confusion) to verify handler invariants.
import { fuzzTool } from '@cyanheads/mcp-ts-core/testing/fuzz';
const report = await fuzzTool(myTool, { numRuns: 100 });
expect(report.crashes).toHaveLength(0);
expect(report.leaks).toHaveLength(0);
expect(report.prototypePollution).toBe(false);
Also exports fuzzResource, fuzzPrompt, zodToArbitrary, and ADVERSARIAL_STRINGS for custom property-based tests.
init.bun run rebuild # clean + build (scripts/clean.ts + scripts/build.ts)
bun run devcheck # full gate: lint/format, typecheck, MCP defs, framework antipatterns, docs/skills/changelog sync, tests, audit, outdated, secrets/TODO scan
bun run lint:mcp # validate MCP definitions against spec
bun run test:all # vitest: unit + Workers pool + integration
Issues and pull requests welcome. Run checks before submitting:
bun run devcheck
bun run test:all
Apache 2.0 — see LICENSE.
Run in your terminal:
claude mcp add ts-core -- npx -y @cyanheads/mcp-ts-corepro tip
Just installed Ts Core? Say to Claude: "remember why I installed Ts Coreand what I want to try" — it'll save into your Vault.
how this works →CSA PROJECT - FZCO © 2026 IFZA Business Park, DDP, Premises Number 31174 - 001
Security
Low riskAutomated heuristic from public metadata — not a security guarantee.