Skip to content

Commit bf96b74

Browse files
feat(mcp): expose 6 MCP prompts + JMESPath schema parity (koala73#3892)
* feat(mcp): expose 6 MCP prompts + JMESPath schema parity Turns on the MCP prompts capability with six concrete workflow templates (country-briefing, energy-shock-watch, market-open-prep, conflict-pulse, route-risk-check, freshness-audit). Each step pre-bakes a literal JMESPath projection so the first execution lands on the right shape without a discovery round-trip. prompts/list + prompts/get are quota-exempt (per-minute limit still applies) to mirror the describe_tool metadata posture — counting template fetches against the 50/day Pro cap would discourage exploration. `capabilities.prompts.listChanged: false` is advertised because the stateless edge transport can't push notifications/prompts/list_changed today. Adds a parity test (tests/mcp-prompts.test.mjs) that compiles every embedded JMESPath via the same parser used at runtime and asserts every field identifier resolves to a property in the referenced tool's declared outputSchema. A typo'd field path in a prompt OR a renamed field in a tool's schema fails this test by name with both the prompt and the broken path — verified by sabotaging both cases before commit. SERVER_VERSION 1.7.0 -> 1.8.0; server-card prompts flag flipped in lockstep. SERVER_INSTRUCTIONS mentions prompts/list as a discoverable affordance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mcp/prompts): strip empty-string optional args from rendered tool args When a caller omits an optional prompt arg (e.g. country in energy-shock-watch / conflict-pulse), the renderer used to emit `arguments: {"country":""}` in the step block. The LLM then passed `""` through to the tool literally. Today's referenced tools all truthy-check filter args via argStr/argStrList (safe), but a future tool that guards with `!== undefined` would try to filter by empty string instead of serving the global view. Strip top-level keys that resolved to '' so the no-filter call renders as `arguments: {}` — unambiguous, matches the model's intent when the arg is omitted. Required-arg absence already returned -32602 above, so this only affects optional-arg absence. Adds a regression test asserting the rendered block is `arguments: {}` when an optional arg is omitted (and absent the literal `"country":""`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c19d80a commit bf96b74

10 files changed

Lines changed: 904 additions & 13 deletions

File tree

api/api-route-exceptions.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@
114114
"owner": "@SebastienMelki",
115115
"removal_issue": null
116116
},
117+
{
118+
"path": "api/mcp/prompts/index.ts",
119+
"category": "external-protocol",
120+
"reason": "MCP PROMPT_REGISTRY + buildPromptResponse + prompts/list public-shape projection. Workflow templates surfaced via prompts/list + prompts/get JSON-RPC methods, shape dictated by the MCP spec.",
121+
"owner": "@SebastienMelki",
122+
"removal_issue": null
123+
},
117124
{
118125
"path": "api/mcp-proxy.ts",
119126
"category": "external-protocol",

api/mcp.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,17 @@ export type {
6464
} from './mcp/types';
6565
export { compressDescription, utf8ByteLength } from './mcp/utils';
6666

67+
export { buildPromptResponse, PROMPT_LIST_RESPONSE, PROMPT_REGISTRY } from './mcp/prompts/index';
68+
6769
// Test-only escape hatch. Exposes the TOOL_REGISTRY by REFERENCE so mutations
6870
// inside `tests/mcp-tool-output-contracts.test.mjs` (which monkey-patches
6971
// `_execute` on individual RPC tools) propagate through the live binding.
72+
// PROMPT_REGISTRY follows the same live-binding contract so a test that
73+
// monkey-patches a prompt (e.g. the sabotage cases in mcp-prompts.test.mjs)
74+
// observes the same array the handler dispatches against.
75+
import { PROMPT_REGISTRY as __PROMPT_REGISTRY } from './mcp/prompts/index';
7076
import { TOOL_REGISTRY as __TOOL_REGISTRY } from './mcp/registry/index';
7177
export const __testing__ = {
7278
TOOL_REGISTRY: __TOOL_REGISTRY,
79+
PROMPT_REGISTRY: __PROMPT_REGISTRY,
7380
};

api/mcp/constants.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,23 @@ export const SERVER_NAME = 'worldmonitor';
125125
// unknown fields, and discovery on 2025-03-26 sessions should still benefit.
126126
// - Purely additive on the wire — no input contract change. Bundle delta is
127127
// documented in the v1.6.0 PR body.
128+
// Bumped 1.7.0 → 1.8.0 (2026-05-24) reflecting:
129+
// - MCP `prompts` capability turned on. Six workflow templates exposed via
130+
// prompts/list + prompts/get (country-briefing, energy-shock-watch,
131+
// market-open-prep, conflict-pulse, route-risk-check, freshness-audit).
132+
// Each template pre-bakes a literal JMESPath projection per step so the
133+
// LLM doesn't have to discover the response shape on first execution.
134+
// - Wire-visible additive capability — clients that ignore the new methods
135+
// keep working; capable clients (Claude Desktop slash menu, MCP Inspector)
136+
// surface the workflows in a discovery affordance.
137+
// - prompts/list and prompts/get are quota-exempt (per-minute limit only)
138+
// to mirror the describe_tool metadata posture — counting template
139+
// fetches against the 50/day Pro cap would discourage exploration.
140+
// - capabilities.prompts.listChanged = false advertised, because the
141+
// stateless edge transport can't push notifications/prompts/list_changed
142+
// today.
143+
// - Server-card prompts capability flag flipped false → true in the same
144+
// commit so external scanners see the wire and the card agree.
128145
// Bumped 1.6.0 → 1.7.0 (2026-05-23) reflecting:
129146
// - De-blanket the `Tool.annotations` object. Previously buildPublicTool
130147
// hard-coded `{ readOnlyHint: true, openWorldHint: true }` for every
@@ -144,7 +161,7 @@ export const SERVER_NAME = 'worldmonitor';
144161
// hints keep working; new four-hint clients get a richer signal.
145162
// Keep aligned with public/.well-known/mcp/server-card.json::serverInfo.version
146163
// — discovery scanners cross-check both values.
147-
export const SERVER_VERSION = '1.7.0';
164+
export const SERVER_VERSION = '1.8.0';
148165

149166
// MCP logging capability — valid severity levels per the 2025-03-26 spec
150167
// (RFC 5424 subset). Stateless HTTP transport: we ACK the level but do not
@@ -193,6 +210,8 @@ export const SERVER_INSTRUCTIONS = [
193210
`Limits: expression ≤ ${JMESPATH_MAX_EXPR_BYTES} bytes; projected payload ≤ ${JMESPATH_MAX_OUTPUT_BYTES} bytes. Failures return {_jmespath_error, original_keys} inside the normal result envelope. Bad expressions DO consume one daily quota unit on retry — original_keys is echoed so you can self-correct in one extra call.`,
194211
'',
195212
`tools/list returns COMPRESSED tool descriptions (first sentence, ≤${TOOL_DESCRIPTION_MAX_BYTES}B per tool). Call describe_tool({tool_name}) to get the full uncompressed definition for any tool you're considering — especially useful when the compressed entry is ambiguous about behaviour or argument semantics. describe_tool is metadata-only and is EXEMPT from the Pro daily quota (still counts toward the 60/min rate limit), so use it freely while exploring. describe_tool({tool_name: 'nonexistent'}) returns {error: 'unknown_tool', available: [...]} so you can self-correct.`,
213+
'',
214+
'Issue prompts/list to discover pre-built workflow templates (country-briefing, energy-shock-watch, market-open-prep, conflict-pulse, route-risk-check, freshness-audit). Each prompt pre-bakes a JMESPath projection per step so the first execution lands on the right shape. prompts/list + prompts/get are quota-exempt (per-minute limit only).',
196215
].join('\n');
197216

198217
// Country-code whitelist for get_consumer_prices. The consumer-prices seeder

api/mcp/handler.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
SERVER_VERSION,
1515
} from './constants';
1616
import { dispatchToolsCall } from './dispatch';
17+
import { buildPromptResponse, PROMPT_LIST_RESPONSE } from './prompts/index';
1718
import { TOOL_LIST_BYTES, TOOL_LIST_RESPONSE } from './registry/index';
1819
import { rpcError, rpcOk } from './rpc';
1920
import { emitTelemetry, principalIdForLog } from './telemetry';
@@ -94,7 +95,11 @@ export async function mcpHandler(
9495
});
9596
return rpcOk(id, {
9697
protocolVersion: negotiatedVersion,
97-
capabilities: { tools: {}, logging: {} },
98+
// `prompts.listChanged: false` is the spec-correct value for our
99+
// transport — the stateless edge route cannot push
100+
// `notifications/prompts/list_changed`, so advertising `true` would
101+
// be a lie. Same posture applies if/when `resources` lands later.
102+
capabilities: { tools: {}, logging: {}, prompts: { listChanged: false } },
98103
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
99104
instructions: SERVER_INSTRUCTIONS,
100105
}, { 'Mcp-Session-Id': sessionId, ...corsHeaders });
@@ -107,6 +112,22 @@ export async function mcpHandler(
107112
return rpcOk(id, { tools: TOOL_LIST_RESPONSE }, corsHeaders);
108113
case 'tools/call':
109114
return dispatchToolsCall(req, context, deps, body, corsHeaders, ctx);
115+
// Prompts are metadata-class — they ship a workflow template, not data.
116+
// Symmetric posture with `describe_tool`: quota-exempt (counting template
117+
// fetches against the 50/day cap would discourage exploration, which
118+
// defeats the prompt-discovery point), but the per-minute rate limit
119+
// applied above still gates abusive loops.
120+
case 'prompts/list':
121+
return rpcOk(id, { prompts: PROMPT_LIST_RESPONSE }, corsHeaders);
122+
case 'prompts/get': {
123+
const params = body.params as { name?: unknown; arguments?: Record<string, unknown> } | null;
124+
if (!params || typeof params.name !== 'string') {
125+
return rpcError(id, -32602, 'Invalid params: missing prompt name');
126+
}
127+
const built = buildPromptResponse(params.name, params.arguments);
128+
if (!built.ok) return rpcError(id, built.code, built.message);
129+
return rpcOk(id, { description: built.description, messages: built.messages }, corsHeaders);
130+
}
110131
case 'logging/setLevel': {
111132
const level = (body.params as { level?: string } | null)?.level;
112133
if (typeof level !== 'string' || !MCP_LOG_LEVELS.has(level)) {

0 commit comments

Comments
 (0)