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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ PR #42 "Fix login timeout" was opened by @alice in repo acme/web-app
> "I've reviewed all the push notifications, mark them as done."

**Expected output:**
The AI calls `list_pending_events` to find push events, then `mark_processed` for each one:
The AI calls `list_pending_events` to find push events, then clears them with a single
`mark_processed({ event_ids: [...] })` call:

```
Marked 2 push events as processed:
Expand Down Expand Up @@ -123,7 +124,7 @@ All checks passed.
| `list_pending_events` | Summaries of pending events (no full payloads) |
| `get_event` | Full payload for a single event by ID |
| `get_webhook_events` | Full payloads for all pending events |
| `mark_processed` | Mark an event as processed |
| `mark_processed` | Mark events as processed (`event_id` for one, `event_ids` for a batch) |

## Event Retention

Expand Down
13 changes: 11 additions & 2 deletions docs/0-requirements.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,21 @@ WebhookMcpAgent DO が以下のツールセットを提供する。ローカル
| F3.2 | `list_pending_events` | limit (1-100, default 20) | サマリー配列 | 未処理イベントのメタデータ一覧を返す(ペイロード含まず) |
| F3.3 | `get_event` | event_id | 完全イベント or error | UUID 指定で完全なペイロードを返す |
| F3.4 | `get_webhook_events` | limit (1-100, default 20) | 未処理イベント配列 | 未処理イベントをフルペイロード付きで返す |
| F3.5 | `mark_processed` | event_id | success, event_id, purged | イベントを処理済みにマークし、保持期間超過の処理済みイベントを自動削除する。`purged` は今回削除された件数 |
| F3.5 | `mark_processed` | event_id **または** event_ids (1-100) | 単数形: success, event_id, purged/バッチ形: success, marked, failed, results (id 単位の判定), purged | イベントを処理済みにマークし、保持期間超過の処理済みイベントを自動削除する。`purged` は今回削除された件数 |

**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) 側の戻り値構造は変更しない。

**F3.1 空状態の silent (empty silent, #221):** `pending_count == 0` の場合はラップせずリモート戻り値をそのまま返す。Claude Code 側で decision schema に一致しない JSON は silent discard されるため、hook 経由の呼び出しで `additionalContext` に何も注入されず、毎ターン空 reminder のノイズが消える。手動 tool 呼び出しではリモートの raw payload (`{pending_count: 0, types: {}, latest_received_at: null}`) が返り、AI は内容を直接判定できる。

**F3.5 バッチ形 (#245):** `mark_processed` が 1 呼び出しにつき 1 イベントしか受けなかったため、自己操作 1 回で 6〜10 件生じる到達確認イベントの消費が呼び出し回数に比例していた。`event_ids: string[]` を追加し、id を明示列挙したまま往復だけ畳む(filter 一括消費は外部イベントを誤って消しうるため採らない)。

- **後方互換:** 既存の単数 `event_id` 呼び出しは戻り値の形も含めて不変。存在しない id でも `success: true` を返す旧挙動を維持する。
- **id 単位の判定:** バッチ形は id ごとに `{event_id, success, error?}` を返す。tool 表層で `error` が取る値は `not found`(どの store にも無い)のみ。空の id は store に届く前に tool schema が弾くため、誤解を招く `not found` に化けることはない。
- **部分失敗:** 1 件の失敗で全体を落とさない。成功した id のマークは確定済みで、呼び出し側は失敗した id だけ再送すればよい。部分失敗は tool error ではなく本文で報告する。
- **マルチアカウント:** イベントは 1 つの store にのみ存在するため、アクセス可能な全 store に同じバッチを投げ、**いずれかの store が一致した id を成功**とする。全 store が取り逃した id のみ失敗。
- **purge 回数:** 保持期間 purge はバッチ 1 回につき 1 回のみ走る(id ごとではない)。
- **MCP proxy:** 静的スキーマ (`mcp-server/server/index.js` / `local-mcp/src/index.ts`) の更新が要る。新 param は再接続では反映されず npm 再公開が必要。

**イベントサマリー構造:**

```json
Expand Down Expand Up @@ -154,7 +163,7 @@ WebhookMcpAgent DO が以下のツールセットを提供する。ローカル
| 1 | `get_pending_status()` を 60 秒間隔でポーリング |
| 2 | `pending_count > 0` なら `list_pending_events()` でサマリー取得 |
| 3 | フルペイロードが必要なイベントのみ `get_event(event_id)` で取得 |
| 4 | 処理完了後 `mark_processed(event_id)` でマーク |
| 4 | 処理完了後 `mark_processed` でマーク。複数件をまとめて処理した場合は `event_ids` で 1 呼び出しに畳む |

### F7. OAuth 認証(Worker-hosted web OAuth)

Expand Down
13 changes: 11 additions & 2 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,21 @@ WebhookMcpAgent DO が以下のツールセットを提供する。ローカル
| F3.2 | `list_pending_events` | limit (1-100, default 20) | サマリー配列 | 未処理イベントのメタデータ一覧を返す(ペイロード含まず) |
| F3.3 | `get_event` | event_id | 完全イベント or error | UUID 指定で完全なペイロードを返す |
| F3.4 | `get_webhook_events` | limit (1-100, default 20) | 未処理イベント配列 | 未処理イベントをフルペイロード付きで返す |
| F3.5 | `mark_processed` | event_id | success, event_id, purged | イベントを処理済みにマークし、保持期間超過の処理済みイベントを自動削除する。`purged` は今回削除された件数 |
| F3.5 | `mark_processed` | event_id **または** event_ids (1-100) | 単数形: success, event_id, purged/バッチ形: success, marked, failed, results (id 単位の判定), purged | イベントを処理済みにマークし、保持期間超過の処理済みイベントを自動削除する。`purged` は今回削除された件数 |

**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.

**F3.1 Empty silent (#221):** When `pending_count == 0`, the local bridge returns the remote payload untouched (no wrap). Claude Code silently discards JSON that does not match a decision schema, so hook callers receive nothing in `additionalContext` — eliminating the per-turn empty-reminder noise. Manual tool callers see the raw payload (`{pending_count: 0, types: {}, latest_received_at: null}`) and can interpret it directly.

**F3.5 Batch form (#245):** `mark_processed` accepted exactly one event per call, so consuming the 6-10 self-operation acknowledgement events a single PR generates cost one round trip each. `event_ids: string[]` is added so the round trips collapse while the caller still enumerates ids explicitly (filter-based bulk consumption is deliberately not offered: it can silently consume external events).

- **Backward compatibility:** the existing singular `event_id` call is unchanged, response shape included. It still answers `success: true` for an id that was never ingested.
- **Per-id verdict:** the batch form returns `{event_id, success, error?}` per id. `not found` (no store held that id) is the only value `error` takes at the tool surface — an empty id is rejected by the tool schema before it reaches a store, so it never turns into a misleading `not found`.
- **Partial failure:** one failing id does not fail the call. Marks for the successful ids are already committed, so the caller retries only the failed ids. Partial failure is reported in the body, not as a tool error.
- **Multi-account:** an event lives in exactly one store, so the same batch goes to every accessible store and an id marked by **any** store counts as marked. Only an id missed by every store failed.
- **Purge cadence:** the retention purge runs once per batch call, not once per id.
- **MCP proxy:** the static schemas (`mcp-server/server/index.js` / `local-mcp/src/index.ts`) must be updated. A new param is not picked up by reconnecting; the npm package has to be republished.

**イベントサマリー構造:**

```json
Expand Down Expand Up @@ -154,7 +163,7 @@ WebhookMcpAgent DO が以下のツールセットを提供する。ローカル
| 1 | `get_pending_status()` を 60 秒間隔でポーリング |
| 2 | `pending_count > 0` なら `list_pending_events()` でサマリー取得 |
| 3 | フルペイロードが必要なイベントのみ `get_event(event_id)` で取得 |
| 4 | 処理完了後 `mark_processed(event_id)` でマーク |
| 4 | 処理完了後 `mark_processed` でマーク。複数件をまとめて処理した場合は `event_ids` で 1 呼び出しに畳む |

### F7. OAuth 認証(Worker-hosted web OAuth)

Expand Down
2 changes: 1 addition & 1 deletion docs/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ GitHub --POST--> Cloudflare Worker --> Durable Object (SQLite)
| `list_pending_events` | 未処理イベントのサマリー(フルペイロードなし) |
| `get_event` | ID 指定で単一イベントのフルペイロード取得 |
| `get_webhook_events` | 全未処理イベントのフルペイロード取得 |
| `mark_processed` | イベントを処理済みにマーク |
| `mark_processed` | イベントを処理済みにマーク(`event_ids` で複数件を 1 呼び出しにまとめられる) |

## モノレポ構成

Expand Down
14 changes: 11 additions & 3 deletions local-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,13 +798,21 @@ const TOOLS = [
},
{
name: "mark_processed",
description: "Mark a webhook event as processed",
description:
"Mark webhook events as processed. Pass event_ids to clear a whole batch in one call (preferred when several events were handled together); event_id marks a single event.",
inputSchema: {
type: "object" as const,
properties: {
event_id: { type: "string", description: "The event ID to mark" },
event_id: { type: "string", description: "A single event ID to mark" },
event_ids: {
type: "array",
items: { type: "string", minLength: 1 },
minItems: 1,
maxItems: 100,
description:
"Event IDs to mark in one call (1-100). Returns a per-id success/failure verdict; ids that succeed stay marked even if others fail.",
},
},
required: ["event_id"],
},
},
];
Expand Down
4 changes: 2 additions & 2 deletions mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,14 @@ All tools are read-only except `mark_processed`.
| `list_pending_events` | Summary list of pending events (`limit`: 1-100, default 20). Returns metadata only — `id`, `type`, `action`, `repo`, `sender`, `number`, `title`, `url`, `received_at` — without the full payload. |
| `get_event` | Full payload for a single webhook event by `event_id`. |
| `get_webhook_events` | Pending events with full payloads. Prefer `get_pending_status` or `list_pending_events` for polling and only fall back to this when you really need everything. |
| `mark_processed` | Mark an event as processed by `event_id` so it will no longer appear in pending queries. Required to keep the pending queue from growing unbounded. |
| `mark_processed` | Mark events as processed so they no longer appear in pending queries. Pass `event_id` for one event, or `event_ids` (1-100) to clear a whole batch in a single call. Required to keep the pending queue from growing unbounded. |

### Recommended polling flow

1. Poll `get_pending_status()` periodically (e.g. every 60 seconds).
2. If `pending_count > 0`, call `list_pending_events()` for summaries.
3. Call `get_event(event_id)` only for events that need the full payload.
4. Call `mark_processed(event_id)` after handling each event.
4. Call `mark_processed` after handling the events — `event_ids: [...]` clears the whole set in one call, which is the normal case when a batch of events was handled together.

If real-time channel notifications are enabled (Claude Code), step 1 can be skipped — the proxy will push event summaries as soon as the Worker receives them. You still need to call `mark_processed` to clear the queue.

Expand Down
2 changes: 1 addition & 1 deletion mcp-server/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
},
{
"name": "mark_processed",
"description": "Mark a webhook event as processed so it won't appear again."
"description": "Mark webhook events as processed so they won't appear again. Accepts a single event_id or a batch of event_ids."
}
],
"compatibility": {
Expand Down
17 changes: 12 additions & 5 deletions mcp-server/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -758,21 +758,28 @@ const TOOLS = [
},
{
name: "mark_processed",
title: "Mark Event Processed",
title: "Mark Events Processed",
description:
"Mark a webhook event as processed so it won't appear again.",
"Mark webhook events as processed so they won't appear again. Pass event_ids to clear a whole batch in one call (preferred when several events were handled together); event_id marks a single event.",
inputSchema: {
type: "object",
properties: {
event_id: {
type: "string",
description: "The event ID to mark as processed",
description: "A single event ID to mark as processed",
},
event_ids: {
type: "array",
items: { type: "string", minLength: 1 },
minItems: 1,
maxItems: 100,
description:
"Event IDs to mark as processed in one call (1-100). Returns a per-id success/failure verdict; ids that succeed stay marked even if others fail.",
},
},
required: ["event_id"],
},
annotations: {
title: "Mark Event Processed",
title: "Mark Events Processed",
destructiveHint: true,
},
},
Expand Down
66 changes: 53 additions & 13 deletions worker/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,21 @@ import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { PendingStatus, EventSummary, WebhookEvent } from "../../shared/src/types.js";
import { mergeMarkResults, type StoreBatchResponse } from "./mark-results.js";

interface Env {
MCP_OBJECT: DurableObjectNamespace;
WEBHOOK_STORE: DurableObjectNamespace;
TENANT_REGISTRY: DurableObjectNamespace;
}

/**
* Upper bound on ids per batched mark_processed call. Matches the 100 ceiling
* the listing tools use for `limit`, so a batch can always clear one full page
* of pending events.
*/
const MARK_BATCH_MAX = 100;

/** Tenant context passed via props when creating per-tenant instances */
export type TenantProps = {
account_id?: number;
Expand Down Expand Up @@ -57,6 +65,17 @@ export class WebhookMcpAgent extends McpAgent<Env, unknown, TenantProps> {
});
}

/** POST a mark-processed body (singular or batch) to one store. */
private markRequest(store: DurableObjectStub, body: unknown): Promise<Response> {
return store.fetch(
new Request("https://store/mark-processed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}

async init() {
this.server.tool(
"get_pending_status",
Expand Down Expand Up @@ -152,21 +171,42 @@ export class WebhookMcpAgent extends McpAgent<Env, unknown, TenantProps> {

this.server.tool(
"mark_processed",
"Mark a webhook event as processed",
{ event_id: z.string() },
async ({ event_id }) => {
// Try all stores — the event lives in exactly one, others are no-ops
"Mark webhook events as processed. Pass event_ids to clear a whole batch in one call (preferred when several events were handled together); event_id marks a single event.",
{
event_id: z.string().optional(),
// min(1) on the ITEM, not just the array: an empty-string id would pass
// schema validation, miss in every store, and reach the caller as
// "not found" — a misleading verdict for what is really a malformed
// request. Rejecting it here keeps "not found" the only per-id error.
event_ids: z.array(z.string().min(1)).min(1).max(MARK_BATCH_MAX).optional(),
},
async ({ event_id, event_ids }) => {
const stores = this.getStores();

// ── Batch form (#245): one round trip for N ids ──
if (event_ids) {
const perStore = await Promise.all(
stores.map((s) =>
this.markRequest(s, { event_ids }).then((r) => r.json() as Promise<StoreBatchResponse>),
),
);

// Per-id verdict resolution across the fan-out lives in
// mark-results.ts (unit-tested there).
const summary = mergeMarkResults(event_ids, perStore);
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
}

// ── Singular form: response shape unchanged ──
if (!event_id) {
return {
content: [{ type: "text", text: "mark_processed requires event_id or event_ids" }],
isError: true,
};
}
// Try all stores — the event lives in exactly one, others are no-ops
const results = await Promise.all(
stores.map((s) =>
s.fetch(
new Request("https://store/mark-processed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event_id }),
}),
).then((r) => r.json()),
),
stores.map((s) => this.markRequest(s, { event_id }).then((r) => r.json())),
);
// Return the first successful result
return { content: [{ type: "text", text: JSON.stringify(results[0]) }] };
Expand Down
Loading
Loading