@venturekit/mcp
v0.0.0-dev.20260609102541
Published
Transport-agnostic Model Context Protocol (MCP) server primitives for VentureKit: JSON-RPC dispatch, a typed tool registry, and a Zod→JSON-Schema serializer.
Maintainers
Readme
@venturekit/mcp
Transport-agnostic Model Context Protocol (MCP) server primitives for VentureKit apps.
The package 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, batteries-included bearer-token layer: the vk_<scope>_<secret>
token format, a Postgres-backed token store, and the mcp_tokens migration that
ships with the package. App-specific concerns — authentication, rate limiting,
audit logging, per-call context — are injected through hooks, so any app can
expose an MCP endpoint (and issue/verify tokens for it) without re-implementing
the generic parts.
It stays dependency-light: zod is a type-only peer dependency, token hashing
uses node:crypto scrypt (no third-party crypto dep), and the store talks to your
database through a structural Querier (pass @venturekit/data's query — no DB
driver dependency). The dispatcher core itself remains entirely storage-free.
Why not the official SDK?
@modelcontextprotocol/sdk is built around long-lived transports/sessions
(stdio, SSE, streamable HTTP). VentureKit APIs run as stateless request →
response handlers (Lambda / API Gateway), where one HTTP POST carries one
JSON-RPC message. This package implements exactly that slice — initialize,
notifications/initialized, ping, tools/list, tools/call — with no
session state to manage.
Usage
import { handleMcpRequest, McpError, type McpServerConfig } from '@venturekit/mcp';
import { z } from 'zod';
// 1. Define tools. `run` receives validated args + your per-call context.
const tools = {
echo: {
name: 'echo',
description: 'Echo a message back.',
inputSchema: z.object({ message: z.string() }),
run: async (args: { message: string }, ctx: { tenantId: string }) => ({
echoed: args.message,
tenant: ctx.tenantId,
}),
},
};
// 2. Wire the host concerns as hooks.
const config: McpServerConfig<{ tenantId: string }, { tenantId: string }> = {
serverInfo: { name: 'my-app-mcp', version: '1.0.0' },
tools,
async authenticate(authHeader) {
const tenantId = await verifyBearer(authHeader); // throw McpError(...) to reject
return { allowedTools: null, principal: { tenantId } };
},
createContext: (principal) => principal,
// optional:
async rateLimit(principal) {
/* throw new McpError('rate limited', { statusCode: 429, data: { retryAfterSec: 30 } }) */
},
wrapToolCall: (info, run) => withAudit(info, run),
};
// 3. Dispatch a request from your HTTP route.
const { statusCode, response } = await handleMcpRequest(config, {
authHeader: req.headers.authorization ?? null,
body: req.body,
});Hook contract
| Hook | Required | Purpose |
| --------------- | -------- | ----------------------------------------------------------------------- |
| authenticate | yes | Resolve + authorize the caller; returns { allowedTools, principal }. |
| createContext | yes | Build the value passed to each tool's run (may be async). |
| rateLimit | no | Pre-dispatch throttle. Throw to reject. |
| wrapToolCall | no | Wrap each invocation (audit-log bracket, tracing span, etc.). |
Errors thrown from authenticate / rateLimit are mapped to 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. A retryAfterSec field is lifted into error.data automatically.
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.
Allow-lists
authenticate returns allowedTools: null exposes every registered tool;
a populated ReadonlySet<string> restricts both tools/list and tools/call
to those names (handy for scoping a per-token capability set).
Bearer tokens & storage
MCP servers authenticate agents with long-lived bearer tokens. This package ships the whole generic stack so you don't re-build it per app:
- Format & primitives (no deps,
node:crypto):generateBearerToken,parseScopedToken,parseBearer,bearerTokenPrefix. Tokens arevk_<scope>_<secret>, wherescopeis an opaque owner key the verifier can read before hashing to narrow a lookup. - Postgres store (
Querier-first, scrypt-hashed):createMcpToken,listMcpTokens,revokeMcpToken,verifyMcpToken. Only a scrypt hash + the lookupprefixare persisted; the plaintext is returned once fromcreateMcpTokenand is unrecoverable after. - Migration:
mcp_tokensships asmigrations/vk_mcp_0001_tokens.sqland is auto-discovered byvk migratefor any app that depends on this package (via thevk.migrationsfield) — no wiring required.getMcpMigrationsDir()is the explicit escape hatch forvk.config.ts:extraMigrationsDirs.
The store is keyed on a generic scope (text, no foreign key), so it is not
coupled to any host's tenancy model. A host maps scope to whatever it owns — a
tenant slug, a user id, a project id — and layers its own checks on top:
import { query } from '@venturekit/data';
import { createMcpToken, verifyMcpToken } from '@venturekit/mcp';
// Issue (scope = the host's owner key — here, a tenant slug):
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 — surface it exactly once.
// Verify inside your `authenticate` hook:
const tok = await verifyMcpToken(query, plaintext); // active row, or null
if (!tok) throw new McpError('Invalid token', { statusCode: 401 });
// …then resolve `tok.scope` to your domain object + enforce its status.Pass any @venturekit/data-compatible query; the store never imports a DB
driver itself.
