Skip to content

@venturekit/mcp

@venturekit/mcp lets any VentureKit API expose a Model Context Protocol endpoint that AI agents can call. It owns the protocol core — a stateless JSON-RPC dispatcher, a typed tool registry, and a Zod → JSON Schema serializer for tools/list — plus an optional bearer-token layer: the vk_<scope>_<secret> token format, a Postgres-backed token store, and the vk_mcp_tokens migration that ships with the package. App-specific concerns (authentication, rate limiting, audit logging, per-call context) are injected through hooks.

It is deliberately dependency-light: zod (v4) is a type-only peer dependency, token hashing uses node:crypto scrypt, and the store talks to your database through a structural Querier — pass @venturekit/data’s query. The dispatcher core itself is storage-free.

Terminal window
npm install @venturekit/mcp zod

A tool is a name, a description, a Zod input schema and a run function. run receives the validated arguments and the per-call context your createContext hook builds.

src/mcp/tools.ts
import { z } from 'zod';
import type { McpToolRegistry } from '@venturekit/mcp';
export interface ToolContext { tenantId: string }
export const tools: McpToolRegistry<ToolContext> = {
add_blog_post: {
name: 'add_blog_post',
description: 'Create a draft blog post for the current tenant.',
inputSchema: z.object({ title: z.string(), body: z.string() }),
run: async (args, ctx) => {
const post = await createDraft(ctx.tenantId, args);
return { id: post.id, url: post.url };
},
},
};

A tool’s own thrown error is not a protocol error: per the MCP spec it is returned as a successful response with isError: true, so the agent can read the message and recover.

src/mcp/server.ts
import { query } from '@venturekit/data';
import { handleMcpRequest, verifyMcpToken, parseBearer, McpError, type McpServerConfig } from '@venturekit/mcp';
import { tools, type ToolContext } from './tools';
interface Principal { tenantId: string; tokenId: string }
export const mcpServer: McpServerConfig<ToolContext, Principal> = {
serverInfo: { name: 'my-app-mcp', version: '1.0.0' },
tools,
// Required: resolve + authorize the caller. Throw McpError to reject.
async authenticate(authHeader) {
const tok = await verifyMcpToken(query, parseBearer(authHeader) ?? '');
if (!tok) throw new McpError('Invalid token', { statusCode: 401 });
return {
// null → every registered tool; a Set restricts tools/list AND tools/call
allowedTools: tok.allowedTools ? new Set(tok.allowedTools.split(',')) : null,
principal: { tenantId: tok.scope, tokenId: tok.id },
};
},
// Required: the value passed to each tool's `run`.
createContext: (principal) => ({ tenantId: principal.tenantId }),
// Optional: pre-dispatch throttle. `retryAfterSec` is lifted into error.data.
async rateLimit(principal) {
if (await overQuota(principal.tokenId)) {
throw new McpError('Rate limited', { statusCode: 429, data: { retryAfterSec: 30 } });
}
},
// Optional: wrap every invocation — audit bracket, tracing span, …
wrapToolCall: (info, run) => withAudit(info, run),
};
HookRequiredPurpose
authenticate(authHeader)yesResolve and authorize the caller; returns { allowedTools, principal }
createContext(principal, toolName)yesBuild the value passed to each tool’s run (may be async)
rateLimit(principal, toolName)noPre-dispatch throttle — throw to reject
wrapToolCall(info, run)noWrap each invocation (audit log, tracing, …)

Errors thrown from authenticate / rateLimit become the JSON-RPC error response. They may be an McpError (carrying statusCode / rpcCode / data) or any error exposing those fields — a host’s existing error classes work unchanged.

src/routes/mcp/post.ts
import { handler, rawResult } from '@venturekit/runtime';
import { handleMcpRequest } from '@venturekit/mcp';
import { mcpServer } from '../../mcp/server';
export const main = handler(async (body, ctx) => {
const { statusCode, response } = await handleMcpRequest(mcpServer, {
authHeader: ctx.rawEvent.headers.authorization ?? null,
body,
});
// MCP clients expect the bare JSON-RPC envelope, not the { data, meta } wrapper.
return rawResult({ statusCode, headers: { 'content-type': 'application/json' }, body: JSON.stringify(response) });
});

handleMcpRequest() returns { statusCode, response } — an HTTP status to send and the JSON-RPC response object. The route is public from the API Gateway’s point of view (no scopes); the authenticate hook is the gate.

MCP servers authenticate agents with long-lived bearer tokens. The package ships the generic stack:

  • Format & primitives (node:crypto only): generateBearerToken, parseScopedToken, parseBearer, bearerTokenPrefix. Tokens are vk_<scope>_<secret>, where scope is an opaque owner key the verifier can read before hashing to narrow the lookup.
  • Postgres store (Querier-first, scrypt-hashed): createMcpToken, listMcpTokens, revokeMcpToken, verifyMcpToken. Only a scrypt hash and the lookup prefix are persisted; the plaintext is returned once from createMcpToken and is unrecoverable afterwards.
  • Migration: vk_mcp_tokens ships as migrations/vk_mcp_0001_tokens.sql and is auto-discovered by vk migrate for any app that depends on this package (via the package’s vk.migrations field). getMcpMigrationsDir() is the explicit escape hatch.
import { query } from '@venturekit/data';
import { createMcpToken, listMcpTokens, revokeMcpToken } from '@venturekit/mcp';
// Issue — scope is YOUR owner key (tenant slug, user id, project id …)
const { row, token } = await createMcpToken(query, {
scope: tenant.slug,
label: 'Cascade workflow',
allowedTools: ['add_blog_post'], // omit/empty → every tool
createdBy: user.id,
});
// `token` is the plaintext — show it exactly once.
await listMcpTokens(query, tenant.slug); // McpTokenRow[] (hash included, plaintext never)
await revokeMcpToken(query, tenant.slug, row.id); // sets revoked_at; verifyMcpToken() then returns null

The store is keyed on a free-form scope (text, no foreign key), so it is not coupled to any tenancy model — map scope to whatever you own and layer your own checks on top of verifyMcpToken()’s result.