Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] - 2026-04-25
## [0.3.0] - 2026-04-27

### Added

- **Hook-driven request context propagation** ([#15](https://github.com/vllnt/convex-mcp/issues/15)) — The `onToolCall` hook can now return `extendArgs?: Record<string, unknown>` to merge server-resolved context into the dispatched function's args. Unblocks per-action authorization, request tracing, multi-tenancy, audit metadata, per-key feature flags, and any pattern where the server needs to inject trusted context the action can read ([#16](https://github.com/vllnt/convex-mcp/issues/16))
- **Reserved `_` prefix for tool args** ([#17](https://github.com/vllnt/convex-mcp/issues/17)) — Arg keys starting with `_` are framework-controlled. Two layers protect them: (1) `prepareTools` strips `_*` keys from the published JSON Schema so MCP clients can't see or pass them; (2) the registered tool handler explicitly rejects any `_*` key that arrives at the handler boundary (defense-in-depth for non-SDK callers). Hook-supplied `_*` keys via `extendArgs` are exempt
- **Construction-time warn for unhooked `_*` args** — `createMCPServer` emits a single `console.warn` when any tool declares reserved `_*` args but no `onToolCall` hook is configured, listing the affected tools and keys. Catches a footgun where stripped args would silently fail every dispatched call against Convex's validator
- **Reserved-key reject log line** — `[convex-mcp] reserved-key reject` warn (with `requestId`, `tool`, `keys`) when the handler-layer reject fires, for operator visibility
- 17 new tests in `tests/context-propagation.test.ts` covering merge precedence, no-op cases (undefined / empty `extendArgs`), abort precedence, phase isolation (only `before` honors `extendArgs`), schema stripping, reserved-key rejection at handler boundary, nested-key passthrough, end-to-end SDK schema-strip behavior, the new construction-time warn, and the reject log line

### Changed

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,12 +175,13 @@ Convex validators are automatically converted to JSON Schema for MCP tool defini

- **Default-deny**: Auth is required. No `validate` = startup error.
- **Generic errors**: Convex error messages are never leaked to MCP responses (may contain PII). Only "Function execution failed" is returned.
- **No function-level authz** (v1): A valid API key grants access to all exposed tools. Scope tools carefully.
- **Per-action authorization (v0.3.0)**: The `onToolCall` hook can return `extendArgs` to inject server-resolved context (e.g. `_mcp_apiKey`, scopes, tenantId) into the dispatched function's args. The action handler re-validates the injected context as defense-in-depth — even if the framework hook is bypassed, the action stays safe. See [Security › Server-side Context Propagation](docs/security.md#server-side-context-propagation-v030).
- **Reserved `_` prefix (v0.3.0)**: Arg keys starting with `_` are framework-controlled. Stripped from the published JSON Schema and rejected at the handler boundary; only the framework can inject them via `extendArgs`. Prevents callers from spoofing server-injected context.

### Known Limitations

- **Validator duplication**: You must provide Convex validators in the MCP config alongside function references. FunctionReference carries no runtime schema info. We're exploring codegen for v2.
- **Service account model**: By default, Convex functions execute without user identity (`ctx.auth` is null). Use `convexToken` in auth config to propagate identity.
- **Service account model**: By default, Convex functions execute without user identity (`ctx.auth` is null). Use `convexToken` in auth config to propagate identity, or `extendArgs` for arg-level context propagation (v0.3.0).
- **Serverless timeouts**: Vercel Hobby has 10s timeout, Pro has 60s. Use Fluid Compute for long-running actions.

## Docs
Expand Down
111 changes: 111 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const mcp = createMCPServer({
name?: string; // MCP server name (default: "convex-mcp")
version?: string; // MCP server version (default: "0.3.0")
pagination?: PaginationConfig; // opt-in pagination + two-phase discovery
hooks?: LifecycleHooks; // before/success/error tool-call hooks
});
```

Expand All @@ -29,6 +30,7 @@ const mcp = createMCPServer({
| `name` | `string` | No | `"convex-mcp"` | Server name reported in MCP `initialize` response. |
| `version` | `string` | No | `"0.3.0"` | Server version reported in MCP `initialize` response. |
| `pagination` | `PaginationConfig` | No | — | Opt-in pagination and two-phase discovery. See [Pagination](#pagination). |
| `hooks` | `LifecycleHooks` | No | — | Lifecycle hooks for tool calls. See [`LifecycleHooks`](#lifecyclehooks). |

### Throws

Expand Down Expand Up @@ -203,6 +205,112 @@ Request arrives

---

## `LifecycleHooks`

```typescript
interface LifecycleHooks {
onToolCall?: (ctx: CallContext) => Promise<OnCallResult | void> | OnCallResult | void;
}
```

Pass via `createMCPServer({ hooks: { onToolCall } })`. The hook fires three times per tool call: `before` (pre-dispatch), `success` (after the dispatched function resolves), and `error` (after it throws).

| Phase | Purpose |
|-------|---------|
| `before` | Validate, abort, inject context via `extendArgs`. The only phase whose return value affects dispatch. |
| `success` | Observe successful results (logging, metrics). Return value is ignored. |
| `error` | Customize the error message returned to the MCP client (`return { message: "..." }`). |

Per-tool overrides: `ToolDef.onError` runs before `LifecycleHooks.onToolCall` for the `error` phase.

---

## `CallContext`

```typescript
interface CallContext {
requestId: string; // crypto.randomUUID() — same across before/success/error for one tool call
toolName: string; // the registered tool name (e.g. "list_tasks")
toolDef: Omit<ToolDef, "ref" | "onError">; // tool def without the function ref or per-tool error hook
args: Record<string, unknown>;
apiKey: string | undefined; // from the Authorization header; undefined if anonymous
phase: "before" | "success" | "error";
result?: unknown; // present on "success"
error?: unknown; // present on "error"
durationMs?: number; // present on "success" and "error"
startedAt: number; // ms timestamp of dispatch start
}
```

---

## `OnCallResult`

```typescript
interface OnCallResult {
abort?: boolean; // before phase — skip dispatch, return error response
errorMessage?: string; // before phase — message when aborting (default: "Tool call rejected")
message?: string; // error phase — replace default error message
extendArgs?: Record<string, unknown>; // before phase — merge into dispatched args (v0.3.0+)
}
```

| Field | Phase | Description |
|-------|-------|-------------|
| `abort` | `before` | When `true`, framework returns an error response without dispatching. |
| `errorMessage` | `before` | Custom abort message. Falls back to `"Tool call rejected"`. |
| `message` | `error` | Replaces the framework's default `"Function execution failed"` text in the response. |
| `extendArgs` | `before` | **(v0.3.0)** Server-resolved key/value pairs merged into the dispatched function's args. Server-side wins on collision. Only honored during the `before` phase. Empty / undefined is a no-op. Keys SHOULD use the framework-reserved `_` prefix. |

### `extendArgs` example

```typescript
hooks: {
onToolCall: async ({ apiKey, phase }) => {
if (phase !== "before") return;
const validated = await validateKey(apiKey);
if (!validated.valid) return { abort: true, errorMessage: "Invalid key" };
return {
extendArgs: {
_mcp_apiKey: apiKey,
_mcp_scopes: validated.scopes,
},
};
},
}
```

The dispatched action's validator must declare the injected fields as required args:

```typescript
export const sensitiveAction = action({
args: {
_mcp_apiKey: v.string(), // injected by framework
_mcp_scopes: v.array(v.string()),
targetId: v.id("things"),
},
handler: async (ctx, args) => {
await assertMcpAuth(ctx, args._mcp_apiKey, "things:write");
// ... real logic ...
},
});
```

---

## Reserved `_` prefix in tool args (v0.3.0)

Arg keys starting with underscore are framework-controlled. Two layers protect them:

1. **Schema strip** — `prepareTools` removes `_*` keys from the published JSON Schema. The MCP SDK's Zod validator silently strips `_*` keys from incoming requests (Zod default strip mode), so spoofed values never reach the dispatched function.
2. **Handler reject** — The registered tool handler explicitly rejects any `_*` key reaching it with a structured `"Reserved arg keys not allowed"` error before the hook runs. This defends against transports that bypass the SDK's schema validation (custom transports, future protocol changes, direct `registerTools` callers).

Hook-supplied `_*` keys via `extendArgs` are exempt from both layers — the server is trusted by definition.

See [Security › Server-side Context Propagation](./security.md#server-side-context-propagation-v030) for the full pattern.

---

## `HandlerOptions`

```typescript
Expand Down Expand Up @@ -307,5 +415,8 @@ import type {
ConvexMCPServer,
FunctionType, // "query" | "mutation" | "action"
ConvexValidator,
LifecycleHooks,
CallContext,
OnCallResult,
} from "@vllnt/convex-mcp";
```
146 changes: 146 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,3 +322,149 @@ export const { GET, POST } = adminMcp.handler();
import { publicMcp } from "@/convex/mcp-public";
export const { GET, POST } = publicMcp.handler();
```

## Hook-driven request context propagation (v0.3.0)

The `onToolCall` hook can return `extendArgs` to inject server-resolved context into the dispatched function's args. Combined with the framework-reserved `_` prefix, this gives you a safe channel for per-action authorization, request tracing, multi-tenancy, audit metadata, and more.

### Per-action authorization (defense-in-depth)

The framework hook validates the apiKey + required scope. The action handler **re-validates** the injected key — so even if the hook is bypassed (custom transport, framework regression), the action stays safe.

```typescript
// convex/mcp.ts
import { createMCPServer, action } from "@vllnt/convex-mcp";
import { api } from "./_generated/api";
import { v } from "convex/values";

export const mcp = createMCPServer({
auth: { validate: async (key) => Boolean(await validateKey(key)) },
hooks: {
onToolCall: async ({ apiKey, phase, toolDef }) => {
if (phase !== "before") return;
const validated = await validateKey(apiKey);
if (!validated.valid) return { abort: true, errorMessage: "Invalid key" };
const required = toolDef.tags?.requiredScope;
if (required && !validated.scopes.includes(required)) {
return { abort: true, errorMessage: `Missing scope: ${required}` };
}
return {
extendArgs: {
_mcp_apiKey: apiKey,
_mcp_scopes: validated.scopes,
},
};
},
},
tools: {
upsert_thing: action(api.things.upsert, {
args: v.object({
_mcp_apiKey: v.string(), // injected by framework
_mcp_scopes: v.array(v.string()),
targetId: v.id("things"),
payload: v.any(),
}),
description: "Upsert a thing.",
tags: { requiredScope: "things:write" },
}),
},
});

// convex/things.ts
export const upsert = action({
args: {
_mcp_apiKey: v.string(),
_mcp_scopes: v.array(v.string()),
targetId: v.id("things"),
payload: v.any(),
},
handler: async (ctx, args) => {
// Defense-in-depth: re-validate inside the action.
await assertMcpAuth(ctx, args._mcp_apiKey, "things:write");
return await ctx.runMutation(internal.things.write, {
id: args.targetId,
payload: args.payload,
});
},
});
```

### Request tracing

```typescript
hooks: {
onToolCall: async ({ requestId, phase }) => {
if (phase !== "before") return;
return { extendArgs: { _mcp_requestId: requestId } };
},
}

// In the action — correlate domain logs with framework request lifecycle:
export const doThing = action({
args: { _mcp_requestId: v.string(), input: v.string() },
handler: async (ctx, args) => {
logger.info("doing_thing", { requestId: args._mcp_requestId, input: args.input });
// ...
},
});
```

### Multi-tenancy with server-resolved tenant routing

```typescript
hooks: {
onToolCall: async ({ apiKey, phase }) => {
if (phase !== "before") return;
const tenantId = await resolveTenantFromKey(apiKey);
if (!tenantId) return { abort: true, errorMessage: "Unknown tenant" };
return { extendArgs: { _mcp_tenantId: tenantId } };
},
}

// Action enforces server-resolved tenant ID — caller cannot spoof:
export const listProjects = query({
args: { _mcp_tenantId: v.id("tenants") },
handler: async (ctx, args) => {
return await ctx.db
.query("projects")
.withIndex("by_tenant", (q) => q.eq("tenantId", args._mcp_tenantId))
.take(50);
},
});
```

### Per-key feature flags

```typescript
hooks: {
onToolCall: async ({ apiKey, phase }) => {
if (phase !== "before") return;
const flags = await getFlagsFor(apiKey); // cached lookup
return { extendArgs: { _mcp_flags: flags } };
},
}

// Action branches on flags without re-fetching:
export const search = query({
args: {
_mcp_flags: v.object({ semanticSearch: v.boolean() }),
q: v.string(),
},
handler: async (ctx, args) => {
return args._mcp_flags.semanticSearch
? await semanticSearch(ctx, args.q)
: await keywordSearch(ctx, args.q);
},
});
```

### Anti-patterns

| Anti-pattern | Why bad | Fix |
|---|---|---|
| Long-lived secrets in `extendArgs` | Args flow into action logs and may be over-shared | Inject ID + scopes only; resolve secrets inside the action when needed |
| Hook-only authorization (no action re-check) | Framework regression / fork = silent security failure | Always re-validate the injected key inside the action handler |
| Splitting context across `extendArgs` AND a side-channel store | Two sources of truth; drift inevitable | Pick one — `extendArgs` is the canonical path |
| Allowing client `_*` passthrough (e.g., disabling the reject) | Defeats the entire safety model | Don't. The reject is what makes `extendArgs` trustworthy |

See [Security › Server-side Context Propagation](./security.md#server-side-context-propagation-v030) for the full security rationale.
Loading
Loading