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
2 changes: 2 additions & 0 deletions docs/0-requirements.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions mcp-server/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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) {
Expand Down
178 changes: 178 additions & 0 deletions mcp-server/test/get-pending-status-decision-shape.test.mjs
Original file line number Diff line number Diff line change
@@ -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": "<plain text>"
* }
* }
*
* 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);
});
Loading