Sign In

github-webhook-mcp

Package Overview
Dependencies
Maintainers
1
Versions
36
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

github-webhook-mcp - npm Package Compare versions

Comparing version
0.11.9
to
0.12.0
+92
server/remote-client.js
/**
* 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"

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

@@ -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();