github-webhook-mcp
Advanced tools
| /** | ||
| * The bridge's client face toward the Cloudflare Worker. | ||
| * | ||
| * Split out of index.js because index.js connects the stdio transport at | ||
| * import time, so nothing in it can be exercised by a test. This module has no | ||
| * side effects on import, which lets the Worker <-> bridge protocol contract be | ||
| * asserted directly (see `worker/test/mcp-stateless-contract.test.ts`). | ||
| * | ||
| * Protocol revision 2026-07-28, pinned (issue #249). The Worker serves that one | ||
| * revision and rejects every other, so negotiation would only add a round trip | ||
| * and a fallback branch that can never succeed. Pinning also means there is no | ||
| * `initialize` handshake and no `mcp-session-id`: each request carries the | ||
| * per-request `_meta` envelope the revision requires, and the SDK client | ||
| * attaches it. | ||
| * | ||
| * There is no session to hold. What is cached here is the client object and its | ||
| * transport, not server state — a dropped connection costs a reconnect, never a | ||
| * lost session. | ||
| * | ||
| * This file has a TypeScript twin in `local-mcp/src/index.ts` (the local | ||
| * development bridge). Both faces of both bridges must move together; the | ||
| * Worker's revision is a private contract between this repository's artifacts. | ||
| */ | ||
| import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; | ||
| /** The single protocol revision the Worker serves. */ | ||
| export const WORKER_PROTOCOL_VERSION = "2026-07-28"; | ||
| /** | ||
| * Build a lazily-connecting MCP client for the Worker. | ||
| * | ||
| * @param {object} options | ||
| * @param {string} options.workerUrl Worker origin; `/mcp` is appended. | ||
| * @param {string} options.clientVersion Reported as this client's version. | ||
| * @param {object} [options.authProvider] Bearer credentials, per the SDK's | ||
| * `AuthProvider` shape (`token()` plus optional `onUnauthorized()`). | ||
| * @param {typeof fetch} [options.fetch] Fetch override; tests route it at an | ||
| * in-process handler instead of the network. | ||
| */ | ||
| export function createRemoteClient({ workerUrl, clientVersion, authProvider, fetch }) { | ||
| let client = null; | ||
| let connecting = null; | ||
| async function getClient() { | ||
| if (client) return client; | ||
| // Concurrent tool calls must share one connect attempt, not race two. | ||
| connecting ??= (async () => { | ||
| const next = new Client( | ||
| { name: "github-webhook-mcp-bridge", version: clientVersion }, | ||
| { versionNegotiation: { mode: { pin: WORKER_PROTOCOL_VERSION } } }, | ||
| ); | ||
| const transport = new StreamableHTTPClientTransport(new URL(`${workerUrl}/mcp`), { | ||
| ...(authProvider ? { authProvider } : {}), | ||
| ...(fetch ? { fetch } : {}), | ||
| }); | ||
| await next.connect(transport); | ||
| client = next; | ||
| return next; | ||
| })(); | ||
| try { | ||
| return await connecting; | ||
| } catch (err) { | ||
| // Let the next call retry from scratch rather than inherit the failure. | ||
| connecting = null; | ||
| throw err; | ||
| } | ||
| } | ||
| /** Forget the cached client. The next call reconnects. */ | ||
| async function reset() { | ||
| const stale = client; | ||
| client = null; | ||
| connecting = null; | ||
| if (stale) await stale.close().catch(() => {}); | ||
| } | ||
| async function callTool(name, args) { | ||
| const active = await getClient(); | ||
| try { | ||
| return await active.callTool({ name, arguments: args }); | ||
| } catch (err) { | ||
| // A dead transport would otherwise be cached forever. Dropping it costs | ||
| // one reconnect on the next call; keeping it costs every later call. | ||
| await reset(); | ||
| throw err; | ||
| } | ||
| } | ||
| return { getClient, callTool, reset }; | ||
| } |
+2
-1
| { | ||
| "name": "github-webhook-mcp", | ||
| "version": "0.11.9", | ||
| "version": "0.12.0", | ||
| "description": "MCP server bridging GitHub webhooks via Cloudflare Worker", | ||
@@ -21,2 +21,3 @@ "type": "module", | ||
| "dependencies": { | ||
| "@modelcontextprotocol/client": "^2.0.0", | ||
| "@modelcontextprotocol/sdk": "^1.0.0", | ||
@@ -23,0 +24,0 @@ "ws": "^8.18.0" |
+31
-0
@@ -7,2 +7,13 @@ # github-webhook-mcp | ||
| ## Breaking change: MCP protocol revision 2026-07-28 | ||
| From this release the Worker serves **MCP protocol revision 2026-07-28 only**, with no compatibility lane for the previous revision. | ||
| - **Proxy versions older than this release stop working.** They open a session with `initialize`, which the Worker no longer answers. The failure is quiet: the proxy does not crash, it returns the protocol error as tool output text. | ||
| - **Real-time channel notifications keep arriving, which hides the breakage.** The `/events` WebSocket stream is not MCP and is unaffected, so a stale proxy still pushes event summaries while every tool call — including `mark_processed` — fails. The pending queue stops being cleared even though notifications look healthy. | ||
| - **Restart the MCP client to pick up the new proxy.** `npx` resolves `@latest` at process start, so an already-running Claude Desktop, Claude Code, or Codex keeps the copy it launched with however new the published version is. Quit it fully and reopen. | ||
| - **Pinning the proxy version leaves you stuck.** If your MCP client config pins a version older than this release, restarting does not help; remove the pin (or move it forward) first. | ||
| The proxy's two protocol faces are independent: it still speaks the 2025-era MCP revision to your client over stdio. Only the face toward the Worker moved. | ||
| ## What this proxy does | ||
@@ -55,2 +66,21 @@ | ||
| ## Updating | ||
| npx resolves the package version once, at process start — including when the client config pins | ||
| `@latest`. A client that is already running keeps the version it launched with, so a new npm | ||
| release does not reach it until that process is replaced: **restart the MCP client (Claude Desktop, | ||
| Claude Code, Codex) to pick up a new version.** The restart is what moves the client onto the new | ||
| version. | ||
| This matters most for releases that change the tool schemas the proxy advertises, because those | ||
| schemas are served from the proxy's own code rather than fetched from the Worker — until the | ||
| process restarts, the client keeps seeing the old tool definitions. | ||
| Verify what the registry holds with `--prefer-online`. The npm CLI caches registry metadata, so a | ||
| bare `npm view` can report the previous version shortly after a publish: | ||
| ```bash | ||
| npm view github-webhook-mcp version --prefer-online | ||
| ``` | ||
| ## Client configuration | ||
@@ -186,2 +216,3 @@ | ||
| - **`Failed to reach worker`.** Check that `WEBHOOK_WORKER_URL` is correct and reachable from your machine. | ||
| - **Every tool call returns a protocol error, but channel notifications still arrive.** The running proxy predates the 2026-07-28 revision the Worker now serves. Restart the MCP client so `npx` fetches the current version; if your config pins an older version, move the pin forward first. See the breaking-change note at the top of this page. | ||
| - **`Authentication failed after retry`.** Cached tokens were rejected and re-authentication did not succeed. Remove `~/.github-webhook-mcp/oauth-tokens.json` and retry. | ||
@@ -188,0 +219,0 @@ - **Upgrading from v0.10.x / v0.11.0.** Existing tokens files are ignored (flow marker mismatch) and a fresh web-flow authorize URL is emitted on the next tool call. No manual cleanup is required. |
+43
-80
@@ -22,2 +22,15 @@ #!/usr/bin/env node | ||
| * | ||
| * The bridge has two independent protocol faces (issue #249): | ||
| * | ||
| * Claude Desktop -> bridge : SDK v1 stdio server, 2025-era. Unchanged. | ||
| * bridge -> Worker : SDK v2 client pinned to protocol revision | ||
| * 2026-07-28. Stateless — no `initialize` | ||
| * handshake and no `mcp-session-id`; every | ||
| * request carries the per-request `_meta` | ||
| * envelope the revision requires. | ||
| * | ||
| * The Worker's revision is a private contract between the artifacts of this | ||
| * repository (`server.json` declares stdio transport only, so nothing else | ||
| * reaches the Worker), so the Desktop face is not bound by it. | ||
| * | ||
| * Discord MCP pattern: data lives in the cloud, local MCP is a thin bridge. | ||
@@ -38,2 +51,3 @@ */ | ||
| import WebSocketClient from "ws"; | ||
| import { createRemoteClient } from "./remote-client.js"; | ||
@@ -600,85 +614,35 @@ const require = createRequire(import.meta.url); | ||
| async function buildAuthHeaders(token, extra) { | ||
| const h = { ...extra }; | ||
| if (token) h["Authorization"] = `Bearer ${token}`; | ||
| return h; | ||
| } | ||
| // ── Remote MCP Client (lazy, reused) ───────────────────────────────────────── | ||
| // Construction and caching live in ./remote-client.js so they can be tested | ||
| // without importing this module (which connects the stdio transport on import). | ||
| // | ||
| // There is no session here any more: the 2026-07-28 revision makes every | ||
| // request self-contained, so `getSessionIdWithToken` and the `mcp-session-id` | ||
| // header are gone rather than migrated. The 401 retry that used to be wired by | ||
| // hand around the session is now the transport's, driven by `onUnauthorized`. | ||
| // ── Remote MCP Session (lazy, reused) ──────────────────────────────────────── | ||
| const remote = createRemoteClient({ | ||
| workerUrl: WORKER_URL, | ||
| clientVersion: PACKAGE_VERSION, | ||
| // The OAuth flow above stays the source of tokens; this only hands the | ||
| // current one over, and clears the cache when the Worker says it is stale so | ||
| // the next `token()` re-mints. | ||
| authProvider: { | ||
| token: () => getAccessTokenForToolCall(), | ||
| onUnauthorized: async () => { | ||
| _cachedTokens = null; | ||
| await getAccessTokenForToolCall(); | ||
| }, | ||
| }, | ||
| }); | ||
| let _sessionId = null; | ||
| async function callRemoteTool(name, args) { | ||
| // Resolve credentials first so an authorization requirement surfaces as | ||
| // AuthRequiredError from here, where the caller already handles it, rather | ||
| // than from inside the transport wrapped as a network failure. | ||
| await getAccessTokenForToolCall(); | ||
| async function getSessionIdWithToken(token) { | ||
| if (_sessionId) return _sessionId; | ||
| const res = await fetch(`${WORKER_URL}/mcp`, { | ||
| method: "POST", | ||
| headers: await buildAuthHeaders(token, { | ||
| "Content-Type": "application/json", | ||
| Accept: "application/json, text/event-stream", | ||
| }), | ||
| body: JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| method: "initialize", | ||
| params: { | ||
| protocolVersion: "2024-11-05", | ||
| capabilities: {}, | ||
| clientInfo: { name: "local-bridge", version: "1.0.0" }, | ||
| }, | ||
| id: "init", | ||
| }), | ||
| }); | ||
| _sessionId = res.headers.get("mcp-session-id") || ""; | ||
| return _sessionId; | ||
| return await remote.callTool(name, args); | ||
| } | ||
| async function callRemoteToolWithToken(name, args, token, _retried = false) { | ||
| const sessionId = await getSessionIdWithToken(token); | ||
| const res = await fetch(`${WORKER_URL}/mcp`, { | ||
| method: "POST", | ||
| headers: await buildAuthHeaders(token, { | ||
| "Content-Type": "application/json", | ||
| Accept: "application/json, text/event-stream", | ||
| "mcp-session-id": sessionId, | ||
| }), | ||
| body: JSON.stringify({ | ||
| jsonrpc: "2.0", | ||
| method: "tools/call", | ||
| params: { name, arguments: args }, | ||
| id: crypto.randomUUID(), | ||
| }), | ||
| }); | ||
| // 401 = token expired or revoked. Clear session + token cache and retry | ||
| // once with a freshly acquired token (refresh or full flow). | ||
| if (res.status === 401) { | ||
| if (_retried) { | ||
| return { content: [{ type: "text", text: "Authentication failed after retry. Please re-authenticate." }] }; | ||
| } | ||
| _cachedTokens = null; | ||
| _sessionId = null; | ||
| const freshToken = await getAccessTokenForToolCall(); | ||
| return callRemoteToolWithToken(name, args, freshToken, true); | ||
| } | ||
| const text = await res.text(); | ||
| // Streamable HTTP may return SSE format | ||
| const dataLine = text.split("\n").find((l) => l.startsWith("data: ")); | ||
| const json = dataLine ? JSON.parse(dataLine.slice(6)) : JSON.parse(text); | ||
| if (json.error) { | ||
| // Session expired — retry once with a fresh session | ||
| if ((json.error.code === -32600 || json.error.code === -32001) && !_retried) { | ||
| _sessionId = null; | ||
| return callRemoteToolWithToken(name, args, token, true); | ||
| } | ||
| return { content: [{ type: "text", text: JSON.stringify(json.error) }] }; | ||
| } | ||
| return json.result; | ||
| } | ||
| // ── MCP Server Setup ───────────────────────────────────────────────────────── | ||
@@ -906,4 +870,3 @@ | ||
| try { | ||
| const token = await getAccessTokenForToolCall(); | ||
| const result = await callRemoteToolWithToken(name, args ?? {}, token); | ||
| const result = await callRemoteTool(name, args ?? {}); | ||
| // First successful tool call confirms OAuth is working | ||
@@ -910,0 +873,0 @@ markOAuthEstablished(); |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
61529
10.26%6
20%1140
5.17%232
15.42%5
-16.67%3
50%8
14.29%+ Added
+ Added