feat(hooks): hook-driven request context propagation in tool dispatch#19
Merged
feat(hooks): hook-driven request context propagation in tool dispatch#19
Conversation
…gation Closes #15, #16, #17. Closes part of #18 (security.md + llms.txt updated; api-reference.md, examples.md, llms-full.txt deferred to a docs follow-up). ## What Two coupled additions enabling per-tool server-side context injection: 1. **extendArgs (#16)** — \`OnCallResult\` gains an optional \`extendArgs?: Record<string, unknown>\` field. When the \`onToolCall\` hook returns it during the \`before\` phase, the framework merges the key/value pairs into args before \`client[type](toolDef.ref, args)\`. On collision: server-side wins. Empty / undefined is a no-op. 2. **Reserved \`_\` prefix (#17)** — Arg keys starting with underscore are framework-controlled. Two layers protect them: - \`prepareTools\` strips \`_*\` keys from the published JSON Schema (\`zodShape\`). The MCP SDK's Zod validator silently strips \`_*\` keys from incoming requests via Zod's default strip mode. - The registered tool handler explicitly rejects any \`_*\` key reaching it with a structured "Reserved arg keys not allowed" error (defense-in-depth for non-SDK transports). Hook-supplied \`_*\` keys via \`extendArgs\` are exempt — server is trusted. ## Why Today the \`onToolCall\` hook receives \`apiKey\` but cannot pass it (or any server-resolved context) downstream. Per-action authorization, request tracing, multi-tenancy, audit metadata, per-key feature flags, and per-key quotas all require server-injected context. Without this primitive, consumers either fork the framework or replace the dispatch transport. This is the framework primitive — not coupled to apiKey, not coupled to auth at all. \`extendArgs\` carries arbitrary server-resolved key/value pairs; the consumer decides what to put there. See \`docs/security.md\` for 4 example patterns. ## Backward compatibility Strictly additive: - \`OnCallResult\` gets a new optional field. Existing hooks unchanged. - \`prepareTools\` strips \`_*\` from the published shape. Consumers who publish \`_*\` keys today get them removed from the schema (verify zero impact: search across vllnt-owned consumers turned up zero). - The handler-level \`_*\` reject is new behavior. Any consumer accepting \`_*\` request args today (none expected) would now get a structured rejection instead. ## Tests 12 new tests in \`tests/context-propagation.test.ts\`: - merges extendArgs into dispatched args (query, mutation, action) - server-side wins on key collision - undefined / empty extendArgs is a no-op (no allocation) - abort wins over extendArgs - only \`before\` phase honors extendArgs - handler rejects \`_*\` request args before hook runs - handler error message lists every reserved key - hook-supplied \`_*\` via extendArgs bypasses reject (server trusted) - only top-level keys are rejected (nested \`_*\` passes through) - published JSON Schema strips \`_*\` properties + required - end-to-end: SDK schema-strip + extendArgs injection produces trusted args at the dispatched function All 127 tests pass (115 existing + 12 new). Lint + typecheck clean.
_ prefix for context propagation (closes #15)Closes #18. Adds full v0.3.0 coverage to docs that were missed in the initial PR: - README.md: brief mention of per-action authorization + reserved prefix in the Security section, link to docs/security.md - llms-full.txt: full Lifecycle Hooks & Request Context Propagation section (~120 lines) with all 5 patterns + anti-patterns; Types section updated with LifecycleHooks, CallContext, OnCallResult, expanded ToolDef - docs/api-reference.md: LifecycleHooks, CallContext, OnCallResult reference sections; reserved-prefix subsection; hooks field added to ServerConfig table; new types added to Exported Types - docs/examples.md: 4 worked patterns (per-action authorization with defense-in-depth, request tracing, multi-tenancy, per-key feature flags) + anti-patterns table Verification: - 127/127 tests pass, lint + typecheck green - 0 references to any specific downstream consumer across all docs
- docs/security.md: document hook fail-open behavior; require `_*` action validators to be non-optional so Convex's own validator is the safety net if the framework hook throws or is bypassed - register.ts: log `[convex-mcp] reserved-key reject` (with requestId, tool, keys) when handler-layer rejection fires - register.ts + server.ts: emit one construction-time `console.warn` listing tools that declare `_*` args without an `onToolCall` hook configured, catching the silent "stripped from schema, never injected" footgun - tests: replace `as ConvexValidator` cast with `satisfies`; add coverage for findToolsWithReservedArgs, both warn branches, and the reject log Quality gates: lint PASS, typecheck PASS, tests 132/132, coverage 100% on statements/branches/functions/lines, build PASS.
Move specs/active/2026-03-24-lifecycle-hooks.md → specs/shipped/ and append history.log entry. Spec covers the full v0.3.0 hook + context-propagation work: lifecycle hooks, per-tool config, X-Request-Id, extendArgs, reserved `_` prefix, and the review-driven hardening (security doc callout, construction-time warn, reject log).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #15, #16, #17, #18.
What
Two coupled additions enabling per-tool server-side context injection.
`extendArgs` (#16)
`OnCallResult` gains an optional `extendArgs?: Record<string, unknown>` field. When the `onToolCall` hook returns it during the `before` phase, the framework merges the key/value pairs into args before `client[type](toolDef.ref, args)`.
Reserved `_` prefix (#17)
Arg keys starting with underscore are framework-controlled. Two layers protect them:
Hook-supplied `_*` keys via `extendArgs` are exempt from both layers — the server is trusted by definition.
Why
The `onToolCall` hook already receives `apiKey`, but currently has no way to pass that or any server-resolved context downstream. This blocks a class of common MCP server patterns:
The framework stays neutral: it doesn't define WHAT goes in `extendArgs`, only HOW. The reserved-prefix protection is what makes it safe.
Files changed
Backward compatibility
Strictly additive:
Test plan
_-prefix in tool args — reject client-supplied keys to prevent context spoofing #17Documentation coverage (full)
All v0.3.0 doc surfaces updated in commit `2822ef5`:
Motivation
Surfaced by an audit of a real-world Convex MCP server where ~80 ops/control-plane actions were publicly callable. The framework hook validated apiKey + scope, but the action handlers had no way to re-validate — so any caller bypassing the MCP HTTP layer hit zero authorization. Per-action re-validation requires the framework to propagate apiKey into args. This PR ships that primitive.
The design is intentionally generic — auth is one of six documented patterns. Anyone building a Convex+MCP server with per-action authorization, request tracing, multi-tenancy, audit, feature flags, or quotas can use this.