@venturekit/mcp 0.0.0-dev.20260608102211

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tokens.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Opaque bearer-token primitives for MCP servers.
3
+ *
4
+ * An MCP server authenticates agents with a long-lived bearer token. A
5
+ * host issues one, stores only a hash + a short lookup prefix, and shows
6
+ * the plaintext exactly once. These helpers own the parts that are
7
+ * identical across every host — the token format, secure generation,
8
+ * and the prefix/parse helpers — while leaving hashing and storage to
9
+ * the host, which knows its own DB and crypto policy.
10
+ *
11
+ * Token shape: `<scheme>_<scope>_<secret>`, e.g. `vk_acme_Xy3…`.
12
+ * - `scheme` namespaces the token (default `vk`).
13
+ * - `scope` is an opaque routing hint (e.g. a tenant slug) the host can
14
+ * read *before* hashing, to narrow a DB lookup to one tenant. It is
15
+ * optional; without it the token is `<scheme>_<secret>`.
16
+ * - `secret` is URL-safe base64 (`A-Za-z0-9_-`) random entropy.
17
+ *
18
+ * `node:crypto` is the only import (a Node builtin), so the package keeps
19
+ * its no-third-party-runtime-dependency property.
20
+ */
21
+ import { randomBytes } from 'node:crypto';
22
+ const DEFAULT_SCHEME = 'vk';
23
+ const DEFAULT_BYTES = 24;
24
+ const MIN_BYTES = 16;
25
+ /** Chars kept from the front of a token as its (non-secret) lookup key. */
26
+ export const DEFAULT_PREFIX_LENGTH = 10;
27
+ /** A `scheme` / `scope` segment: lowercase alnum, dashes allowed inside. */
28
+ const SEGMENT_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
29
+ /**
30
+ * Mint a new bearer token + its lookup prefix. The secret is drawn from
31
+ * a CSPRNG (`node:crypto`); the host should hash {@link GeneratedBearerToken.token}
32
+ * (bcrypt/argon2/SHA-256 — its choice) and persist only the hash + prefix.
33
+ */
34
+ export function generateBearerToken(opts = {}) {
35
+ const scheme = opts.scheme ?? DEFAULT_SCHEME;
36
+ if (!SEGMENT_RE.test(scheme)) {
37
+ throw new Error(`Invalid token scheme '${scheme}' (must be lowercase alnum/dash)`);
38
+ }
39
+ if (opts.scope !== undefined && !SEGMENT_RE.test(opts.scope)) {
40
+ throw new Error(`Invalid token scope '${opts.scope}' (must be lowercase alnum/dash)`);
41
+ }
42
+ const bytes = opts.byteLength ?? DEFAULT_BYTES;
43
+ if (!Number.isInteger(bytes) || bytes < MIN_BYTES) {
44
+ throw new Error(`byteLength must be an integer >= ${MIN_BYTES} for adequate entropy`);
45
+ }
46
+ const secret = randomBytes(bytes).toString('base64url');
47
+ const token = opts.scope ? `${scheme}_${opts.scope}_${secret}` : `${scheme}_${secret}`;
48
+ return { token, prefix: bearerTokenPrefix(token) };
49
+ }
50
+ /**
51
+ * The first `length` characters of a token. Stored alongside the hash so
52
+ * a lookup is `WHERE prefix = ?` (one indexed read) rather than a scan +
53
+ * hash-compare over every row. NOT a secret — safe to show in a UI.
54
+ */
55
+ export function bearerTokenPrefix(token, length = DEFAULT_PREFIX_LENGTH) {
56
+ return token.slice(0, length);
57
+ }
58
+ /**
59
+ * Parse a `<scheme>_<scope>_<secret>` token into its parts, or `null` if
60
+ * the shape doesn't match (callers should fail closed on `null`). The
61
+ * secret must be at least 16 URL-safe chars, which rejects obviously
62
+ * truncated or malformed tokens before any DB / hash work.
63
+ */
64
+ export function parseScopedToken(token) {
65
+ const m = /^(?<scheme>[a-z0-9-]+)_(?<scope>[a-z0-9-]+)_(?<secret>[A-Za-z0-9_-]{16,})$/.exec(token);
66
+ if (!m?.groups)
67
+ return null;
68
+ return {
69
+ scheme: m.groups['scheme'],
70
+ scope: m.groups['scope'],
71
+ secret: m.groups['secret'],
72
+ };
73
+ }
74
+ /**
75
+ * Extract the token from an `Authorization` header value, stripping a
76
+ * leading `Bearer ` (case-insensitive). Returns the raw value when no
77
+ * scheme prefix is present, or `null` for an empty/missing header.
78
+ */
79
+ export function parseBearer(header) {
80
+ if (!header)
81
+ return null;
82
+ const trimmed = header.trim();
83
+ if (!trimmed)
84
+ return null;
85
+ const lower = trimmed.toLowerCase();
86
+ // A bare `Bearer` scheme with no token (e.g. `Bearer `) carries no credential.
87
+ if (lower === 'bearer')
88
+ return null;
89
+ if (lower.startsWith('bearer ')) {
90
+ return trimmed.slice(7).trim() || null;
91
+ }
92
+ return trimmed;
93
+ }
94
+ //# sourceMappingURL=tokens.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Core types for the transport-agnostic MCP server.
3
+ *
4
+ * The package is intentionally dependency-light: `zod` is a *type-only*
5
+ * peer dependency (the dispatcher only ever calls `.safeParse` on a
6
+ * schema the host supplies, and the JSON-Schema serializer reads
7
+ * `._def` structurally), so nothing here pulls zod in at runtime.
8
+ *
9
+ * The two generic parameters thread two host-owned shapes through the
10
+ * dispatcher:
11
+ * - `TContext` — the per-call value handed to every `McpTool.run`.
12
+ * The CMS uses `{ tenant, actorTokenPrefix, ... }`.
13
+ * - `TPrincipal` — the opaque authenticated identity returned by
14
+ * `authenticate` and forwarded to `rateLimit` /
15
+ * `wrapToolCall` / `createContext`. The CMS uses the
16
+ * `{ token, tenant }` pair from its bearer-token check.
17
+ */
18
+ import type { ZodType, ZodTypeDef } from 'zod';
19
+ export interface JsonRpcRequest {
20
+ jsonrpc: '2.0';
21
+ id: number | string | null;
22
+ method: string;
23
+ params?: Record<string, unknown>;
24
+ }
25
+ export interface JsonRpcError {
26
+ code: number;
27
+ message: string;
28
+ data?: unknown;
29
+ }
30
+ export interface JsonRpcResponse {
31
+ jsonrpc: '2.0';
32
+ id: number | string | null;
33
+ result?: unknown;
34
+ error?: JsonRpcError;
35
+ }
36
+ /**
37
+ * A single callable tool. `inputSchema` is any Zod schema; the `unknown`
38
+ * Input variance lets schemas using `.default()` (where Zod splits the
39
+ * Input/Output types so Input becomes optional) satisfy this type.
40
+ */
41
+ export interface McpTool<TInput = unknown, TContext = unknown, TOutput = unknown> {
42
+ name: string;
43
+ description: string;
44
+ inputSchema: ZodType<TInput, ZodTypeDef, unknown>;
45
+ run(args: TInput, ctx: TContext): Promise<TOutput>;
46
+ }
47
+ /** A keyed bag of tools sharing one `TContext`. */
48
+ export type McpToolRegistry<TContext = unknown> = Record<string, McpTool<any, TContext, any>>;
49
+ export interface McpServerInfo {
50
+ name: string;
51
+ version: string;
52
+ }
53
+ /** Outcome of {@link McpServerConfig.authenticate}. */
54
+ export interface McpAuthResult<TPrincipal> {
55
+ /**
56
+ * Tool allow-list for this principal. `null` = every registered tool
57
+ * is callable. A populated set restricts both `tools/list` and
58
+ * `tools/call`.
59
+ */
60
+ allowedTools: ReadonlySet<string> | null;
61
+ /** Opaque identity forwarded to the other hooks. */
62
+ principal: TPrincipal;
63
+ }
64
+ /** Metadata handed to {@link McpServerConfig.wrapToolCall}. */
65
+ export interface McpToolCallInfo<TPrincipal> {
66
+ toolName: string;
67
+ /** The validated, schema-coerced tool arguments. */
68
+ input: unknown;
69
+ principal: TPrincipal;
70
+ }
71
+ /**
72
+ * Everything the dispatcher needs that is *host-specific*. Authentication,
73
+ * rate limiting, audit logging, and context assembly are all hooks so the
74
+ * protocol loop itself stays free of any app concern.
75
+ */
76
+ export interface McpServerConfig<TContext, TPrincipal> {
77
+ serverInfo: McpServerInfo;
78
+ tools: McpToolRegistry<TContext>;
79
+ /** Defaults to `'2025-03-26'`. */
80
+ protocolVersion?: string;
81
+ /**
82
+ * Resolve + authorize the caller. Throw to reject — errors carrying a
83
+ * numeric `statusCode` (and optional `rpcCode` / `data` / `retryAfterSec`)
84
+ * are mapped to the JSON-RPC error response; otherwise a 401 is assumed.
85
+ */
86
+ authenticate(authHeader: string | null): Promise<McpAuthResult<TPrincipal>>;
87
+ /** Build the per-call context passed to the tool's `run`. May be async. */
88
+ createContext(principal: TPrincipal, toolName: string): TContext | Promise<TContext>;
89
+ /** Optional pre-dispatch throttle. Throw (ideally an {@link McpError}) to reject. */
90
+ rateLimit?(principal: TPrincipal, toolName: string): Promise<void>;
91
+ /** Optional wrapper around each tool invocation (e.g. an audit-log bracket). */
92
+ wrapToolCall?<T>(info: McpToolCallInfo<TPrincipal>, run: () => Promise<T>): Promise<T>;
93
+ }
94
+ export interface McpHandlerInput {
95
+ /** The raw `Authorization` header value, or null. */
96
+ authHeader: string | null;
97
+ /** The parsed JSON-RPC request body. */
98
+ body: unknown;
99
+ }
100
+ export interface McpHandlerOutput {
101
+ /** HTTP status the transport should respond with. */
102
+ statusCode: number;
103
+ /** The JSON-RPC envelope to serialize back to the client. */
104
+ response: JsonRpcResponse;
105
+ }
106
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Core types for the transport-agnostic MCP server.
3
+ *
4
+ * The package is intentionally dependency-light: `zod` is a *type-only*
5
+ * peer dependency (the dispatcher only ever calls `.safeParse` on a
6
+ * schema the host supplies, and the JSON-Schema serializer reads
7
+ * `._def` structurally), so nothing here pulls zod in at runtime.
8
+ *
9
+ * The two generic parameters thread two host-owned shapes through the
10
+ * dispatcher:
11
+ * - `TContext` — the per-call value handed to every `McpTool.run`.
12
+ * The CMS uses `{ tenant, actorTokenPrefix, ... }`.
13
+ * - `TPrincipal` — the opaque authenticated identity returned by
14
+ * `authenticate` and forwarded to `rateLimit` /
15
+ * `wrapToolCall` / `createContext`. The CMS uses the
16
+ * `{ token, tenant }` pair from its bearer-token check.
17
+ */
18
+ export {};
19
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG"}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@venturekit/mcp",
3
+ "version": "0.0.0-dev.20260608102211",
4
+ "description": "Transport-agnostic Model Context Protocol (MCP) server primitives for VentureKit: JSON-RPC dispatch, a typed tool registry, and a Zod→JSON-Schema serializer.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "src/migrations/*.sql"
11
+ ],
12
+ "vk": {
13
+ "migrations": "src/migrations"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/venturekit-dev/venturekit.private.git",
18
+ "directory": "packages/mcp"
19
+ },
20
+ "publishConfig": {
21
+ "registry": "https://registry.npmjs.org",
22
+ "access": "public"
23
+ },
24
+ "license": "Apache-2.0",
25
+ "exports": {
26
+ ".": {
27
+ "import": "./dist/index.js",
28
+ "types": "./dist/index.d.ts"
29
+ }
30
+ },
31
+ "keywords": [
32
+ "venturekit",
33
+ "mcp",
34
+ "model-context-protocol",
35
+ "json-rpc",
36
+ "tools",
37
+ "agents"
38
+ ],
39
+ "peerDependencies": {
40
+ "zod": "^3.22.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^25.6.0",
44
+ "typescript": "^5.3.0",
45
+ "zod": "^3.22.0"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc && node -e \"require('fs').cpSync('src/migrations','dist/migrations',{recursive:true})\"",
49
+ "dev": "tsc --watch",
50
+ "clean": "rm -rf dist"
51
+ }
52
+ }
@@ -0,0 +1,63 @@
1
+ -- @venturekit/mcp — initial schema: mcp_tokens.
2
+ --
3
+ -- Storage for the bearer tokens an MCP server issues to agents
4
+ -- (Cascade/Windsurf workflows, CI bots, scripts). Ships WITH the
5
+ -- package so any app that depends on `@venturekit/mcp` gets token
6
+ -- management out of the box — `vk migrate` auto-discovers this file via
7
+ -- the package's `vk.migrations` field (mirrors @venturekit-pro/audit's
8
+ -- `audit_events`).
9
+ --
10
+ -- # Generic by construction
11
+ --
12
+ -- The package owns the MCP protocol, not any host's tenancy model, so
13
+ -- the owner key is a free-form `scope` (text) — NOT a foreign key into
14
+ -- some `tenants` table that only the CMS has. A host maps `scope` to
15
+ -- whatever it owns: the VentureKit CMS uses the tenant slug (which is
16
+ -- also the token's `vk_<scope>_…` segment, letting the verifier resolve
17
+ -- the owner before hashing). `created_by` is likewise a bare text audit
18
+ -- field (a Cognito sub, a user id, a CI run id — the host decides), with
19
+ -- no FK.
20
+ --
21
+ -- # Security
22
+ --
23
+ -- Only a hash of the plaintext + a short lookup `prefix` are stored; the
24
+ -- plaintext is shown once at issuance and is unrecoverable. The hash is
25
+ -- scrypt (see `store.ts:hashTokenSecret`) — self-describing
26
+ -- (`scrypt$N$r$p$salt$dk`) so params can evolve without a column change.
27
+
28
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
29
+
30
+ CREATE TABLE IF NOT EXISTS mcp_tokens (
31
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
32
+ -- Owner key. Host-defined; the CMS uses the tenant slug. Also the
33
+ -- `vk_<scope>_…` segment of the plaintext, so the verifier can narrow
34
+ -- to one owner before hashing.
35
+ scope text NOT NULL,
36
+ -- Human label shown in the issuing UI ("Cascade workflow", "CI bot").
37
+ label text NOT NULL,
38
+ -- First chars of the plaintext (`vk_<scope>_…`) — the lookup key the
39
+ -- verifier narrows on before hash-comparing candidates.
40
+ prefix varchar(24) NOT NULL,
41
+ -- scrypt hash of the full plaintext (`scrypt$N$r$p$salt$dk`).
42
+ token_hash text NOT NULL,
43
+ -- Comma-separated MCP tool whitelist; NULL = every registered tool.
44
+ allowed_tools text,
45
+ last_used_at timestamptz,
46
+ revoked_at timestamptz,
47
+ created_at timestamptz NOT NULL DEFAULT now(),
48
+ -- Free-form audit field (no FK): a user id / Cognito sub / CI id.
49
+ created_by text
50
+ );
51
+
52
+ -- Label is unique per scope: stops accidental dupes in the UI and
53
+ -- bounds the verifier's prefix-collision hash loop.
54
+ CREATE UNIQUE INDEX IF NOT EXISTS mcp_tokens_scope_label_unique
55
+ ON mcp_tokens (scope, lower(label));
56
+
57
+ -- Verifier hot path: WHERE scope = $1 AND prefix = $2 AND revoked_at IS NULL.
58
+ CREATE INDEX IF NOT EXISTS mcp_tokens_lookup_idx
59
+ ON mcp_tokens (scope, prefix) WHERE revoked_at IS NULL;
60
+
61
+ -- Owner list: WHERE scope = $1 ORDER BY created_at DESC.
62
+ CREATE INDEX IF NOT EXISTS mcp_tokens_scope_created_idx
63
+ ON mcp_tokens (scope, created_at DESC);