npm.io
0.0.1 • Published yesterday

@venturekit/mcp

Licence
Apache-2.0
Version
0.0.1
Deps
0
Size
82 kB
Vulns
0
Weekly
0

@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 are vk_<scope>_<secret>, where scope is 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 lookup prefix are persisted; the plaintext is returned once from createMcpToken and is unrecoverable after.
  • Migration: 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 vk.migrations field) — no wiring required. getMcpMigrationsDir() is the explicit escape hatch for vk.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.