@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.
Installation
Section titled “Installation”npm install @venturekit/mcp zod1. Define tools
Section titled “1. Define tools”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.
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.
2. Wire the host concerns as hooks
Section titled “2. Wire the host concerns as hooks”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),};| Hook | Required | Purpose |
|---|---|---|
authenticate(authHeader) | yes | Resolve and authorize the caller; returns { allowedTools, principal } |
createContext(principal, toolName) | yes | Build the value passed to each tool’s run (may be async) |
rateLimit(principal, toolName) | no | Pre-dispatch throttle — throw to reject |
wrapToolCall(info, run) | no | Wrap 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.
3. Dispatch from a route
Section titled “3. Dispatch from a route”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.
Bearer tokens
Section titled “Bearer tokens”MCP servers authenticate agents with long-lived bearer tokens. The package ships the generic stack:
- Format & primitives (
node:cryptoonly):generateBearerToken,parseScopedToken,parseBearer,bearerTokenPrefix. Tokens arevk_<scope>_<secret>, wherescopeis 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 lookupprefixare persisted; the plaintext is returned once fromcreateMcpTokenand is unrecoverable afterwards. - Migration:
vk_mcp_tokensships asmigrations/vk_mcp_0001_tokens.sqland is auto-discovered byvk migratefor any app that depends on this package (via the package’svk.migrationsfield).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 nullThe 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.
Related
Section titled “Related”- API Reference — every export
@venturekit-pro/ai— agents and tool-calling on the client side of MCP@venturekit/data— thequeryfunction the token store expects