From 7e0331c10e93124a112d9434adb23602308ed4e6 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sat, 25 Apr 2026 22:51:07 +0900 Subject: [PATCH] feat(bridge): wrap get_pending_status as Claude Code UserPromptSubmit decision JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `type: "mcp_tool"` UserPromptSubmit hook では、tool が返した text content が JSON parse 成功 かつ decision schema に一致する場合のみ AI 文脈 (additionalContext) に注入される。現状の `get_pending_status` は generic JSON `{pending_count, types, latest_received_at}` を返すため parse は通るが decision schema にマッチせず silent discard されており、`LI_PLUS_WEBHOOK_DELIVERY=mcp_hook` モード (PR Liplus-Project/liplus-language#1164) で AI が webhook の存在に気づけない。 Local bridge 側で `get_pending_status` の戻り値を Claude Code docs literal の canonical shape (`hookSpecificOutput.hookEventName="UserPromptSubmit"` + `additionalContext` に pending_count / types / latest_received_at の自然文要約) にラップする。Cloudflare Worker / DO 側は変更不要。`decision: "block"` は付けない。 手動 tool 呼び出し時も同 shape で返るが、AI は decision JSON を読めるため致命的影響はない。 他 MCP client (Codex / Cursor / Continue.dev) との互換性は本 issue 起案時点で 未実証 (可能性レベルの懸念)。 Closes #215 --- docs/0-requirements.ja.md | 2 + docs/0-requirements.md | 2 + mcp-server/server/index.js | 83 ++++++++ ...get-pending-status-decision-shape.test.mjs | 178 ++++++++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 mcp-server/test/get-pending-status-decision-shape.test.mjs diff --git a/docs/0-requirements.ja.md b/docs/0-requirements.ja.md index 1377dc3..e33296c 100644 --- a/docs/0-requirements.ja.md +++ b/docs/0-requirements.ja.md @@ -102,6 +102,8 @@ WebhookMcpAgent DO が以下のツールセットを提供する。ローカル | F3.4 | `get_webhook_events` | limit (1-100, default 20) | 未処理イベント配列 | 未処理イベントをフルペイロード付きで返す | | F3.5 | `mark_processed` | event_id | success, event_id | イベントを処理済みにマークする | +**F3.1 ローカルブリッジ整形:** ローカルブリッジは `get_pending_status` の戻り値を Claude Code UserPromptSubmit hook の decision JSON shape (`hookSpecificOutput.hookEventName="UserPromptSubmit"` + `additionalContext` に pending_count / types / latest_received_at の自然文要約) にラップして返す。これは `type: "mcp_tool"` UserPromptSubmit hook 経由の呼び出しで戻り値が AI 文脈に注入されるための要件であり、手動 tool 呼び出し時も同 shape で返る。リモート (Worker + DO) 側の戻り値構造は変更しない。 + **イベントサマリー構造:** ```json diff --git a/docs/0-requirements.md b/docs/0-requirements.md index dfad7b9..c09cd16 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -102,6 +102,8 @@ WebhookMcpAgent DO が以下のツールセットを提供する。ローカル | F3.4 | `get_webhook_events` | limit (1-100, default 20) | 未処理イベント配列 | 未処理イベントをフルペイロード付きで返す | | F3.5 | `mark_processed` | event_id | success, event_id | イベントを処理済みにマークする | +**F3.1 Local bridge shaping:** The local bridge wraps `get_pending_status` results into the Claude Code UserPromptSubmit hook decision JSON shape (`hookSpecificOutput.hookEventName="UserPromptSubmit"` plus a natural-language summary of pending_count / types / latest_received_at in `additionalContext`). This is required so values returned via `type: "mcp_tool"` UserPromptSubmit hooks reach the AI prompt context; manual tool calls receive the same shape. The remote (Worker + DO) return contract is unchanged. + **イベントサマリー構造:** ```json diff --git a/mcp-server/server/index.js b/mcp-server/server/index.js index c413851..01e0601 100644 --- a/mcp-server/server/index.js +++ b/mcp-server/server/index.js @@ -780,6 +780,86 @@ const TOOLS = [ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); +/** + * Build a short natural-language summary of a `get_pending_status` payload. + * Target length is around 100 characters so the wrapped `additionalContext` + * stays useful as Claude Code UserPromptSubmit context without bloating the + * prompt. Defensive about field shape because the remote handler's schema + * may evolve. + */ +function formatPendingStatusSummary(payload) { + const count = + payload && typeof payload.pending_count === "number" + ? payload.pending_count + : 0; + if (count === 0) { + return "No pending GitHub webhook events."; + } + const typesObj = + payload && payload.types && typeof payload.types === "object" + ? payload.types + : null; + const typesStr = typesObj + ? Object.entries(typesObj) + .map(([type, n]) => `${type}:${n}`) + .join(", ") + : ""; + const latest = + payload && typeof payload.latest_received_at === "string" + ? ` latest ${payload.latest_received_at}` + : ""; + return ( + `${count} pending GitHub webhook events` + + (typesStr ? ` (${typesStr})` : "") + + `.${latest}` + ); +} + +/** + * Re-shape a `get_pending_status` MCP tool result into the Claude Code + * UserPromptSubmit hook decision JSON form documented at + * https://code.claude.com/docs/en/hooks (search: `type: "mcp_tool"`). + * + * Why: with `LI_PLUS_WEBHOOK_DELIVERY=mcp_hook`, this tool is invoked from a + * `type: "mcp_tool"` UserPromptSubmit hook. Claude Code parses the tool's + * text content as JSON: a valid decision shape is injected into the prompt + * context, anything else is silently discarded. The remote handler currently + * returns generic `{pending_count, types, latest_received_at}` JSON which + * parses fine but matches no decision schema, so the hook output never + * reaches the AI. Wrapping the payload as `hookSpecificOutput.additionalContext` + * keeps the Cloudflare Worker side untouched (per #215 constraint) while + * making the hook output AI-visible. + * + * Manual `tool_use` callers still get a JSON object back; AI can read the + * decision shape directly. No `decision: "block"` field is set, so the + * user's prompt is never blocked. + */ +function wrapGetPendingStatusAsDecisionJson(result) { + if (!result || !Array.isArray(result.content) || result.content.length === 0) { + return result; + } + const first = result.content[0]; + if (!first || first.type !== "text" || typeof first.text !== "string") { + return result; + } + let summary; + try { + summary = formatPendingStatusSummary(JSON.parse(first.text)); + } catch { + summary = first.text.slice(0, 200); + } + const decision = { + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: summary, + }, + }; + return { + ...result, + content: [{ type: "text", text: JSON.stringify(decision) }], + }; +} + function formatAuthRequiredResponse(pending) { const parts = []; parts.push("OAuth authorization required."); @@ -807,6 +887,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { const result = await callRemoteToolWithToken(name, args ?? {}, token); // First successful tool call confirms OAuth is working markOAuthEstablished(); + if (name === "get_pending_status") { + return wrapGetPendingStatusAsDecisionJson(result); + } return result; } catch (err) { if (err instanceof AuthRequiredError) { diff --git a/mcp-server/test/get-pending-status-decision-shape.test.mjs b/mcp-server/test/get-pending-status-decision-shape.test.mjs new file mode 100644 index 0000000..4c609b9 --- /dev/null +++ b/mcp-server/test/get-pending-status-decision-shape.test.mjs @@ -0,0 +1,178 @@ +/** + * Decision-JSON wrapping tests for `get_pending_status` (issue #215). + * + * Background: with `LI_PLUS_WEBHOOK_DELIVERY=mcp_hook`, this tool runs from a + * `type: "mcp_tool"` Claude Code UserPromptSubmit hook. Per docs literal at + * https://code.claude.com/docs/en/hooks the tool's text content is parsed as + * JSON and processed as a decision; the canonical UserPromptSubmit decision + * shape is: + * + * { + * "hookSpecificOutput": { + * "hookEventName": "UserPromptSubmit", + * "additionalContext": "" + * } + * } + * + * The remote handler returns generic `{pending_count, types, latest_received_at}` + * JSON which parses fine but matches no decision schema, so the hook output is + * silently discarded. This wrapper re-shapes the local bridge response into + * the decision form so Claude Code injects `additionalContext` into the + * UserPromptSubmit prompt context. + * + * Tests mirror the wrapper inline because server/index.js cannot be imported + * without starting an MCP server (top-level `await server.connect`); same + * convention as migration / open-browser / web-auth-required tests. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +function formatPendingStatusSummary(payload) { + const count = + payload && typeof payload.pending_count === "number" + ? payload.pending_count + : 0; + if (count === 0) { + return "No pending GitHub webhook events."; + } + const typesObj = + payload && payload.types && typeof payload.types === "object" + ? payload.types + : null; + const typesStr = typesObj + ? Object.entries(typesObj) + .map(([type, n]) => `${type}:${n}`) + .join(", ") + : ""; + const latest = + payload && typeof payload.latest_received_at === "string" + ? ` latest ${payload.latest_received_at}` + : ""; + return ( + `${count} pending GitHub webhook events` + + (typesStr ? ` (${typesStr})` : "") + + `.${latest}` + ); +} + +function wrapGetPendingStatusAsDecisionJson(result) { + if (!result || !Array.isArray(result.content) || result.content.length === 0) { + return result; + } + const first = result.content[0]; + if (!first || first.type !== "text" || typeof first.text !== "string") { + return result; + } + let summary; + try { + summary = formatPendingStatusSummary(JSON.parse(first.text)); + } catch { + summary = first.text.slice(0, 200); + } + const decision = { + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: summary, + }, + }; + return { + ...result, + content: [{ type: "text", text: JSON.stringify(decision) }], + }; +} + +test("wraps non-empty pending payload as UserPromptSubmit decision JSON", () => { + const remote = { + content: [ + { + type: "text", + text: JSON.stringify({ + pending_count: 12, + latest_received_at: "2026-04-25T10:00:00Z", + types: { issues: 7, push: 5 }, + }), + }, + ], + }; + + const wrapped = wrapGetPendingStatusAsDecisionJson(remote); + assert.equal(wrapped.content.length, 1); + assert.equal(wrapped.content[0].type, "text"); + + const decision = JSON.parse(wrapped.content[0].text); + assert.equal(decision.hookSpecificOutput.hookEventName, "UserPromptSubmit"); + assert.equal(typeof decision.hookSpecificOutput.additionalContext, "string"); + assert.match(decision.hookSpecificOutput.additionalContext, /12 pending/); + assert.match(decision.hookSpecificOutput.additionalContext, /issues:7/); + assert.match(decision.hookSpecificOutput.additionalContext, /push:5/); + assert.match( + decision.hookSpecificOutput.additionalContext, + /latest 2026-04-25T10:00:00Z/, + ); + assert.equal("decision" in decision, false); +}); + +test("wraps zero-pending payload with explicit no-events sentence", () => { + const remote = { + content: [ + { + type: "text", + text: JSON.stringify({ + pending_count: 0, + latest_received_at: null, + types: {}, + }), + }, + ], + }; + + const decision = JSON.parse( + wrapGetPendingStatusAsDecisionJson(remote).content[0].text, + ); + assert.equal( + decision.hookSpecificOutput.additionalContext, + "No pending GitHub webhook events.", + ); +}); + +test("falls back to truncated raw text when remote payload is not JSON", () => { + const remote = { + content: [ + { type: "text", text: "remote returned an unexpected plain string" }, + ], + }; + + const decision = JSON.parse( + wrapGetPendingStatusAsDecisionJson(remote).content[0].text, + ); + assert.equal(decision.hookSpecificOutput.hookEventName, "UserPromptSubmit"); + assert.equal( + decision.hookSpecificOutput.additionalContext, + "remote returned an unexpected plain string", + ); +}); + +test("passes result through untouched when content is missing or non-text", () => { + const noContent = { isError: true }; + assert.deepEqual(wrapGetPendingStatusAsDecisionJson(noContent), noContent); + + const empty = { content: [] }; + assert.deepEqual(wrapGetPendingStatusAsDecisionJson(empty), empty); + + const nonText = { content: [{ type: "image", data: "x" }] }; + assert.deepEqual(wrapGetPendingStatusAsDecisionJson(nonText), nonText); +}); + +test("preserves sibling result fields like isError when wrapping", () => { + const remote = { + isError: false, + content: [ + { + type: "text", + text: JSON.stringify({ pending_count: 0, types: {} }), + }, + ], + }; + const wrapped = wrapGetPendingStatusAsDecisionJson(remote); + assert.equal(wrapped.isError, false); +});