github-webhook-mcp 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/manifest.json CHANGED
@@ -2,9 +2,9 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "github-webhook-mcp",
4
4
  "display_name": "GitHub Webhook MCP",
5
- "version": "0.4.1",
6
- "description": "Browse pending GitHub webhook events. Pairs with a webhook receiver that writes events.json.",
7
- "long_description": "GitHub Webhook MCP helps Claude review and react to GitHub notifications from a local event store. It surfaces lightweight pending-event summaries, exposes full webhook payloads on demand, and lets users mark handled events as processed without exposing the event file directly.",
5
+ "version": "1.0.0",
6
+ "description": "Real-time GitHub webhook notifications via Cloudflare Worker + Durable Object.",
7
+ "long_description": "GitHub Webhook MCP bridges GitHub webhook events to Claude via a Cloudflare Worker backend. Events are stored in a Durable Object with SQLite, queried through MCP tools, and optionally pushed in real-time via SSE channel notifications. No local webhook receiver needed — just point your GitHub webhook at the Worker URL.",
8
8
  "author": {
9
9
  "name": "Liplus Project",
10
10
  "url": "https://github.com/Liplus-Project"
@@ -26,16 +26,24 @@
26
26
  "${__dirname}/server/index.js"
27
27
  ],
28
28
  "env": {
29
- "EVENTS_JSON_PATH": "${user_config.events_json_path}"
29
+ "WEBHOOK_WORKER_URL": "${user_config.worker_url}",
30
+ "WEBHOOK_CHANNEL": "${user_config.channel_enabled}"
30
31
  }
31
32
  }
32
33
  },
33
34
  "user_config": {
34
- "events_json_path": {
35
- "description": "Absolute path to the events.json file written by the webhook receiver.",
35
+ "worker_url": {
36
+ "description": "URL of the Cloudflare Worker endpoint (e.g. https://github-webhook-mcp.example.workers.dev)",
36
37
  "type": "string",
37
38
  "required": true,
38
- "title": "Events JSON Path"
39
+ "title": "Worker URL"
40
+ },
41
+ "channel_enabled": {
42
+ "description": "Enable SSE channel notifications for Claude Code CLI (set to '0' to disable, '1' to enable)",
43
+ "type": "string",
44
+ "required": false,
45
+ "title": "Channel Notifications",
46
+ "default": "0"
39
47
  }
40
48
  },
41
49
  "tools": [
@@ -75,6 +83,7 @@
75
83
  "github",
76
84
  "webhook",
77
85
  "notifications",
86
+ "cloudflare",
78
87
  "mcp",
79
88
  "claude"
80
89
  ],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "github-webhook-mcp",
3
- "version": "0.4.1",
4
- "description": "MCP server for browsing GitHub webhook events",
3
+ "version": "0.6.0",
4
+ "description": "MCP server bridging GitHub webhooks via Cloudflare Worker",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "github-webhook-mcp": "server/index.js"
@@ -13,13 +13,12 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "start": "node server/index.js",
16
- "test": "node --check server/index.js && node --check server/event-store.js",
16
+ "test": "node --check server/index.js",
17
17
  "pack:mcpb": "mcpb pack"
18
18
  },
19
19
  "dependencies": {
20
20
  "@modelcontextprotocol/sdk": "^1.0.0",
21
- "iconv-lite": "^0.6.3",
22
- "zod": "^3.22.0"
21
+ "eventsource": "^2.0.2"
23
22
  },
24
23
  "devDependencies": {
25
24
  "@anthropic-ai/mcpb": "^2.1.0"
@@ -30,7 +29,8 @@
30
29
  "keywords": [
31
30
  "mcp",
32
31
  "github",
33
- "webhook"
32
+ "webhook",
33
+ "cloudflare"
34
34
  ],
35
35
  "license": "MIT",
36
36
  "repository": {
package/server/index.js CHANGED
@@ -1,41 +1,108 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * GitHub Webhook MCP — Cloudflare Worker bridge
4
+ *
5
+ * Thin stdio MCP server that proxies tool calls to a remote
6
+ * Cloudflare Worker + Durable Object backend via Streamable HTTP.
7
+ * Optionally listens to SSE for real-time channel notifications.
8
+ *
9
+ * Discord MCP pattern: data lives in the cloud, local MCP is a thin bridge.
10
+ */
2
11
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
13
  import {
5
14
  ListToolsRequestSchema,
6
15
  CallToolRequestSchema,
7
16
  } from "@modelcontextprotocol/sdk/types.js";
8
- import { watch } from "node:fs";
9
- import {
10
- getPendingStatus,
11
- getPendingSummaries,
12
- getEvent,
13
- getPending,
14
- markDone,
15
- summarizeEvent,
16
- dataFilePath,
17
- } from "./event-store.js";
18
17
 
18
+ const WORKER_URL =
19
+ process.env.WEBHOOK_WORKER_URL ||
20
+ "https://github-webhook-mcp.liplus.workers.dev";
19
21
  const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0";
20
22
 
21
- const capabilities = {
22
- tools: {},
23
- };
23
+ // ── Remote MCP Session (lazy, reused) ────────────────────────────────────────
24
+
25
+ let _sessionId = null;
26
+
27
+ async function getSessionId() {
28
+ if (_sessionId) return _sessionId;
29
+
30
+ const res = await fetch(`${WORKER_URL}/mcp`, {
31
+ method: "POST",
32
+ headers: {
33
+ "Content-Type": "application/json",
34
+ Accept: "application/json, text/event-stream",
35
+ },
36
+ body: JSON.stringify({
37
+ jsonrpc: "2.0",
38
+ method: "initialize",
39
+ params: {
40
+ protocolVersion: "2024-11-05",
41
+ capabilities: {},
42
+ clientInfo: { name: "local-bridge", version: "1.0.0" },
43
+ },
44
+ id: "init",
45
+ }),
46
+ });
47
+
48
+ _sessionId = res.headers.get("mcp-session-id") || "";
49
+ return _sessionId;
50
+ }
51
+
52
+ async function callRemoteTool(name, args) {
53
+ const sessionId = await getSessionId();
54
+
55
+ const res = await fetch(`${WORKER_URL}/mcp`, {
56
+ method: "POST",
57
+ headers: {
58
+ "Content-Type": "application/json",
59
+ Accept: "application/json, text/event-stream",
60
+ "mcp-session-id": sessionId,
61
+ },
62
+ body: JSON.stringify({
63
+ jsonrpc: "2.0",
64
+ method: "tools/call",
65
+ params: { name, arguments: args },
66
+ id: crypto.randomUUID(),
67
+ }),
68
+ });
69
+
70
+ const text = await res.text();
71
+
72
+ // Streamable HTTP may return SSE format
73
+ const dataLine = text.split("\n").find((l) => l.startsWith("data: "));
74
+ const json = dataLine ? JSON.parse(dataLine.slice(6)) : JSON.parse(text);
75
+
76
+ if (json.error) {
77
+ // Session expired — retry once with a fresh session
78
+ if (json.error.code === -32600 || json.error.code === -32001) {
79
+ _sessionId = null;
80
+ return callRemoteTool(name, args);
81
+ }
82
+ return { content: [{ type: "text", text: JSON.stringify(json.error) }] };
83
+ }
84
+
85
+ return json.result;
86
+ }
87
+
88
+ // ── MCP Server Setup ─────────────────────────────────────────────────────────
89
+
90
+ const capabilities = { tools: {} };
24
91
  if (CHANNEL_ENABLED) {
25
92
  capabilities.experimental = { "claude/channel": {} };
26
93
  }
27
94
 
28
95
  const server = new Server(
29
- { name: "github-webhook-mcp", version: "0.4.1" },
96
+ { name: "github-webhook-mcp", version: "1.0.0" },
30
97
  {
31
98
  capabilities,
32
99
  instructions: CHANNEL_ENABLED
33
- ? "GitHub webhook events arrive as <channel source=\"github-webhook-mcp\" ...>. They are one-way: read them and act, no reply expected."
100
+ ? 'GitHub webhook events arrive as <channel source="github-webhook-mcp" ...>. They are one-way: read them and act, no reply expected.'
34
101
  : undefined,
35
102
  },
36
103
  );
37
104
 
38
- // ── Tools ───────────────────────────────────────────────────────────────────
105
+ // ── Tool Definitions ─────────────────────────────────────────────────────────
39
106
 
40
107
  const TOOLS = [
41
108
  {
@@ -59,7 +126,8 @@ const TOOLS = [
59
126
  properties: {
60
127
  limit: {
61
128
  type: "number",
62
- description: "Maximum number of pending events to return (1-100, default 20)",
129
+ description:
130
+ "Maximum number of pending events to return (1-100, default 20)",
63
131
  },
64
132
  },
65
133
  },
@@ -98,7 +166,8 @@ const TOOLS = [
98
166
  {
99
167
  name: "mark_processed",
100
168
  title: "Mark Event Processed",
101
- description: "Mark a webhook event as processed so it won't appear again.",
169
+ description:
170
+ "Mark a webhook event as processed so it won't appear again.",
102
171
  inputSchema: {
103
172
  type: "object",
104
173
  properties: {
@@ -120,121 +189,71 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
120
189
 
121
190
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
122
191
  const { name, arguments: args } = req.params;
192
+ try {
193
+ return await callRemoteTool(name, args ?? {});
194
+ } catch (err) {
195
+ return {
196
+ content: [{ type: "text", text: `Failed to reach worker: ${err}` }],
197
+ isError: true,
198
+ };
199
+ }
200
+ });
123
201
 
124
- switch (name) {
125
- case "get_pending_status": {
126
- const status = getPendingStatus();
127
- return {
128
- content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
129
- };
130
- }
131
- case "list_pending_events": {
132
- const limit = Math.max(1, Math.min(100, Number(args?.limit) || 20));
133
- const summaries = getPendingSummaries(limit);
134
- return {
135
- content: [{ type: "text", text: JSON.stringify(summaries, null, 2) }],
136
- };
137
- }
138
- case "get_event": {
139
- const event = getEvent(args.event_id);
140
- if (event === null) {
141
- return {
142
- content: [
143
- {
144
- type: "text",
145
- text: JSON.stringify({ error: "not_found", event_id: args.event_id }),
146
- },
147
- ],
148
- };
149
- }
150
- return {
151
- content: [{ type: "text", text: JSON.stringify(event, null, 2) }],
152
- };
153
- }
154
- case "get_webhook_events": {
155
- const events = getPending();
156
- return {
157
- content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
158
- };
159
- }
160
- case "mark_processed": {
161
- const result = markDone(args.event_id);
162
- return {
163
- content: [
164
- {
165
- type: "text",
166
- text: JSON.stringify({
167
- success: result.success,
168
- event_id: args.event_id,
169
- purged: result.purged,
170
- }),
202
+ // ── SSE Listener → Channel Notifications ─────────────────────────────────────
203
+
204
+ async function connectSSE() {
205
+ let EventSourceImpl;
206
+ try {
207
+ EventSourceImpl = (await import("eventsource")).default;
208
+ } catch {
209
+ // eventsource not installed — skip SSE
210
+ return;
211
+ }
212
+
213
+ const es = new EventSourceImpl(`${WORKER_URL}/events`);
214
+
215
+ es.onmessage = (event) => {
216
+ try {
217
+ const data = JSON.parse(event.data);
218
+ if ("heartbeat" in data || "status" in data) return;
219
+ if (!data.summary) return;
220
+
221
+ const s = data.summary;
222
+ const lines = [
223
+ `[${s.type}] ${s.repo ?? ""}`,
224
+ s.action ? `action: ${s.action}` : null,
225
+ s.title ? `#${s.number ?? ""} ${s.title}` : null,
226
+ s.sender ? `by ${s.sender}` : null,
227
+ s.url ?? null,
228
+ ].filter(Boolean);
229
+
230
+ server.notification({
231
+ method: "notifications/claude/channel",
232
+ params: {
233
+ content: lines.join("\n"),
234
+ meta: {
235
+ chat_id: "github",
236
+ message_id: s.id,
237
+ user: s.sender ?? "github",
238
+ ts: s.received_at,
171
239
  },
172
- ],
173
- };
240
+ },
241
+ });
242
+ } catch {
243
+ // Ignore parse errors
174
244
  }
175
- default:
176
- throw new Error(`unknown tool: ${name}`);
177
- }
178
- });
245
+ };
179
246
 
180
- // ── Start ───────────────────────────────────────────────────────────────────
247
+ es.onerror = () => {
248
+ // EventSource auto-reconnects
249
+ };
250
+ }
251
+
252
+ // ── Start ────────────────────────────────────────────────────────────────────
181
253
 
182
254
  const transport = new StdioServerTransport();
183
255
  await server.connect(transport);
184
256
 
185
- // ── File watcher (after connect) ─────────────────────────────────────────────
186
-
187
257
  if (CHANNEL_ENABLED) {
188
- const seenIds = new Set(getPending().map((e) => e.id));
189
- let debounce = null;
190
-
191
- function onFileChange() {
192
- if (debounce) return;
193
- debounce = setTimeout(() => {
194
- debounce = null;
195
- try {
196
- const pending = getPending();
197
- for (const event of pending) {
198
- if (seenIds.has(event.id)) continue;
199
- seenIds.add(event.id);
200
- const summary = summarizeEvent(event);
201
- const lines = [
202
- `[${summary.type}] ${summary.repo ?? ""}`,
203
- summary.action ? `action: ${summary.action}` : null,
204
- summary.title ? `#${summary.number ?? ""} ${summary.title}` : null,
205
- summary.sender ? `by ${summary.sender}` : null,
206
- summary.url ?? null,
207
- ].filter(Boolean);
208
- server.notification({
209
- method: "notifications/claude/channel",
210
- params: {
211
- content: lines.join("\n"),
212
- meta: {
213
- chat_id: "github",
214
- message_id: event.id,
215
- user: summary.sender ?? "github",
216
- ts: summary.received_at,
217
- },
218
- },
219
- });
220
- }
221
- } catch {
222
- // file may be mid-write; ignore and retry on next change
223
- }
224
- }, 500);
225
- }
226
-
227
- try {
228
- watch(dataFilePath(), onFileChange);
229
- } catch {
230
- // events.json may not exist yet; start polling fallback
231
- const poll = setInterval(() => {
232
- try {
233
- watch(dataFilePath(), onFileChange);
234
- clearInterval(poll);
235
- } catch {
236
- // keep waiting
237
- }
238
- }, 5000);
239
- }
258
+ connectSSE();
240
259
  }
@@ -1,181 +0,0 @@
1
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
- import { resolve, dirname } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import iconv from "iconv-lite";
5
-
6
- const __dirname = dirname(fileURLToPath(import.meta.url));
7
- const DEFAULT_DATA_FILE = resolve(__dirname, "..", "..", "events.json");
8
- const PRIMARY_ENCODING = "utf-8";
9
- const LEGACY_ENCODINGS = ["utf-8", "cp932", "shift_jis"];
10
- const DEFAULT_PURGE_DAYS = 1;
11
-
12
- function purgeDays() {
13
- const env = process.env.PURGE_AFTER_DAYS;
14
- if (env !== undefined) {
15
- const n = Number(env);
16
- if (Number.isFinite(n) && n >= 0) return n;
17
- }
18
- return DEFAULT_PURGE_DAYS;
19
- }
20
-
21
- export function dataFilePath() {
22
- return process.env.EVENTS_JSON_PATH || DEFAULT_DATA_FILE;
23
- }
24
-
25
- // ── Load / Save ─────────────────────────────────────────────────────────────
26
-
27
- export function load() {
28
- const filePath = dataFilePath();
29
- if (!existsSync(filePath)) return [];
30
-
31
- const raw = readFileSync(filePath);
32
-
33
- // Try UTF-8 first (with BOM stripping)
34
- try {
35
- let text = raw.toString("utf-8");
36
- if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
37
- const events = JSON.parse(text);
38
- return events;
39
- } catch {
40
- // fall through to legacy encodings
41
- }
42
-
43
- // Try legacy encodings
44
- for (const encoding of LEGACY_ENCODINGS) {
45
- try {
46
- const text = iconv.decode(raw, encoding);
47
- const events = JSON.parse(text);
48
- // Migrate to UTF-8
49
- save(events);
50
- return events;
51
- } catch {
52
- continue;
53
- }
54
- }
55
-
56
- throw new Error(`Unable to decode event store: ${filePath}`);
57
- }
58
-
59
- export function save(events) {
60
- const filePath = dataFilePath();
61
- writeFileSync(filePath, JSON.stringify(events, null, 2), PRIMARY_ENCODING);
62
- }
63
-
64
- // ── Purge ──────────────────────────────────────────────────────────────────
65
-
66
- export function purgeProcessed(events) {
67
- const days = purgeDays();
68
- if (days < 0) return { kept: events, purged: 0 };
69
- const cutoff = Date.now() - days * 86_400_000;
70
- const before = events.length;
71
- const kept = events.filter((e) => {
72
- if (!e.processed) return true;
73
- const ts = Date.parse(e.received_at);
74
- return Number.isNaN(ts) || ts > cutoff;
75
- });
76
- return { kept, purged: before - kept.length };
77
- }
78
-
79
- // ── Query ───────────────────────────────────────────────────────────────────
80
-
81
- export function getPending() {
82
- return load().filter((e) => !e.processed);
83
- }
84
-
85
- export function getEvent(eventId) {
86
- for (const event of load()) {
87
- if (event.id === eventId) return event;
88
- }
89
- return null;
90
- }
91
-
92
- export function getPendingStatus() {
93
- const pending = getPending();
94
- const types = {};
95
- for (const event of pending) {
96
- types[event.type] = (types[event.type] || 0) + 1;
97
- }
98
- return {
99
- pending_count: pending.length,
100
- latest_received_at: pending.length > 0 ? pending[pending.length - 1].received_at : null,
101
- types,
102
- };
103
- }
104
-
105
- // ── Summary ─────────────────────────────────────────────────────────────────
106
-
107
- function eventNumber(payload) {
108
- return (
109
- payload.number ??
110
- payload.issue?.number ??
111
- payload.pull_request?.number ??
112
- null
113
- );
114
- }
115
-
116
- function eventTitle(payload) {
117
- return (
118
- payload.issue?.title ??
119
- payload.pull_request?.title ??
120
- payload.discussion?.title ??
121
- payload.check_run?.name ??
122
- payload.workflow_run?.name ??
123
- payload.workflow_job?.name ??
124
- null
125
- );
126
- }
127
-
128
- function eventUrl(payload) {
129
- return (
130
- payload.issue?.html_url ??
131
- payload.pull_request?.html_url ??
132
- payload.discussion?.html_url ??
133
- payload.check_run?.html_url ??
134
- payload.workflow_run?.html_url ??
135
- null
136
- );
137
- }
138
-
139
- export function summarizeEvent(event) {
140
- const payload = event.payload || {};
141
- return {
142
- id: event.id,
143
- type: event.type,
144
- received_at: event.received_at,
145
- processed: event.processed,
146
- trigger_status: event.trigger_status ?? null,
147
- last_triggered_at: event.last_triggered_at ?? null,
148
- action: payload.action ?? null,
149
- repo: payload.repository?.full_name ?? null,
150
- sender: payload.sender?.login ?? null,
151
- number: eventNumber(payload),
152
- title: eventTitle(payload),
153
- url: eventUrl(payload),
154
- };
155
- }
156
-
157
- export function getPendingSummaries(limit = 20) {
158
- let pending = getPending();
159
- if (limit > 0) {
160
- pending = pending.slice(-limit);
161
- }
162
- return pending.map(summarizeEvent);
163
- }
164
-
165
- // ── Mutation ────────────────────────────────────────────────────────────────
166
-
167
- export function markDone(eventId) {
168
- const events = load();
169
- let found = false;
170
- for (const event of events) {
171
- if (event.id === eventId) {
172
- event.processed = true;
173
- found = true;
174
- break;
175
- }
176
- }
177
- if (!found) return { success: false, purged: 0 };
178
- const { kept, purged } = purgeProcessed(events);
179
- save(kept);
180
- return { success: true, purged };
181
- }