github-webhook-mcp 0.2.1 → 0.4.1

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
@@ -1,11 +1,21 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "github-webhook-mcp",
4
- "version": "0.2.0",
4
+ "display_name": "GitHub Webhook MCP",
5
+ "version": "0.4.1",
5
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.",
6
8
  "author": {
7
- "name": "Liplus Project"
9
+ "name": "Liplus Project",
10
+ "url": "https://github.com/Liplus-Project"
8
11
  },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/Liplus-Project/github-webhook-mcp.git"
15
+ },
16
+ "homepage": "https://github.com/Liplus-Project/github-webhook-mcp",
17
+ "documentation": "https://github.com/Liplus-Project/github-webhook-mcp#readme",
18
+ "support": "https://github.com/Liplus-Project/github-webhook-mcp/issues",
9
19
  "license": "MIT",
10
20
  "server": {
11
21
  "type": "node",
@@ -51,10 +61,24 @@
51
61
  }
52
62
  ],
53
63
  "compatibility": {
64
+ "claude_desktop": ">=0.11.0",
54
65
  "platforms": [
55
66
  "win32",
56
67
  "darwin",
57
68
  "linux"
58
- ]
59
- }
69
+ ],
70
+ "runtimes": {
71
+ "node": ">=18.0.0"
72
+ }
73
+ },
74
+ "keywords": [
75
+ "github",
76
+ "webhook",
77
+ "notifications",
78
+ "mcp",
79
+ "claude"
80
+ ],
81
+ "privacy_policies": [
82
+ "https://smgjp.com/privacy-policy-github-webhook-mcp/"
83
+ ]
60
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-webhook-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.4.1",
4
4
  "description": "MCP server for browsing GitHub webhook events",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "start": "node server/index.js",
16
- "test": "node --test test/",
16
+ "test": "node --check server/index.js && node --check server/event-store.js",
17
17
  "pack:mcpb": "mcpb pack"
18
18
  },
19
19
  "dependencies": {
@@ -7,8 +7,18 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const DEFAULT_DATA_FILE = resolve(__dirname, "..", "..", "events.json");
8
8
  const PRIMARY_ENCODING = "utf-8";
9
9
  const LEGACY_ENCODINGS = ["utf-8", "cp932", "shift_jis"];
10
+ const DEFAULT_PURGE_DAYS = 1;
10
11
 
11
- function dataFilePath() {
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() {
12
22
  return process.env.EVENTS_JSON_PATH || DEFAULT_DATA_FILE;
13
23
  }
14
24
 
@@ -51,6 +61,21 @@ export function save(events) {
51
61
  writeFileSync(filePath, JSON.stringify(events, null, 2), PRIMARY_ENCODING);
52
62
  }
53
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
+
54
79
  // ── Query ───────────────────────────────────────────────────────────────────
55
80
 
56
81
  export function getPending() {
@@ -141,12 +166,16 @@ export function getPendingSummaries(limit = 20) {
141
166
 
142
167
  export function markDone(eventId) {
143
168
  const events = load();
169
+ let found = false;
144
170
  for (const event of events) {
145
171
  if (event.id === eventId) {
146
172
  event.processed = true;
147
- save(events);
148
- return true;
173
+ found = true;
174
+ break;
149
175
  }
150
176
  }
151
- return false;
177
+ if (!found) return { success: false, purged: 0 };
178
+ const { kept, purged } = purgeProcessed(events);
179
+ save(kept);
180
+ return { success: true, purged };
152
181
  }
package/server/index.js CHANGED
@@ -1,107 +1,240 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { z } from "zod";
4
+ import {
5
+ ListToolsRequestSchema,
6
+ CallToolRequestSchema,
7
+ } from "@modelcontextprotocol/sdk/types.js";
8
+ import { watch } from "node:fs";
5
9
  import {
6
10
  getPendingStatus,
7
11
  getPendingSummaries,
8
12
  getEvent,
9
13
  getPending,
10
14
  markDone,
15
+ summarizeEvent,
16
+ dataFilePath,
11
17
  } from "./event-store.js";
12
18
 
13
- const server = new McpServer({
14
- name: "github-webhook-mcp",
15
- version: "0.2.0",
16
- });
17
-
18
- // ── Tools ───────────────────────────────────────────────────────────────────
19
+ const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0";
19
20
 
20
- server.tool(
21
- "get_pending_status",
22
- "Get a lightweight snapshot of pending GitHub webhook events. Use this for periodic polling before requesting details.",
23
- {},
24
- async () => {
25
- const status = getPendingStatus();
26
- return {
27
- content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
28
- };
29
- }
30
- );
21
+ const capabilities = {
22
+ tools: {},
23
+ };
24
+ if (CHANNEL_ENABLED) {
25
+ capabilities.experimental = { "claude/channel": {} };
26
+ }
31
27
 
32
- server.tool(
33
- "list_pending_events",
34
- "List lightweight summaries for pending GitHub webhook events. Returns metadata only, without full payloads.",
28
+ const server = new Server(
29
+ { name: "github-webhook-mcp", version: "0.4.1" },
35
30
  {
36
- limit: z
37
- .number()
38
- .int()
39
- .min(1)
40
- .max(100)
41
- .default(20)
42
- .describe("Maximum number of pending events to return"),
31
+ capabilities,
32
+ 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."
34
+ : undefined,
43
35
  },
44
- async ({ limit }) => {
45
- const summaries = getPendingSummaries(limit);
46
- return {
47
- content: [{ type: "text", text: JSON.stringify(summaries, null, 2) }],
48
- };
49
- }
50
36
  );
51
37
 
52
- server.tool(
53
- "get_event",
54
- "Get the full payload for a single webhook event by ID.",
38
+ // ── Tools ───────────────────────────────────────────────────────────────────
39
+
40
+ const TOOLS = [
41
+ {
42
+ name: "get_pending_status",
43
+ title: "Get Pending Status",
44
+ description:
45
+ "Get a lightweight snapshot of pending GitHub webhook events. Use this for periodic polling before requesting details.",
46
+ inputSchema: { type: "object", properties: {} },
47
+ annotations: {
48
+ title: "Get Pending Status",
49
+ readOnlyHint: true,
50
+ },
51
+ },
52
+ {
53
+ name: "list_pending_events",
54
+ title: "List Pending Events",
55
+ description:
56
+ "List lightweight summaries for pending GitHub webhook events. Returns metadata only, without full payloads.",
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ limit: {
61
+ type: "number",
62
+ description: "Maximum number of pending events to return (1-100, default 20)",
63
+ },
64
+ },
65
+ },
66
+ annotations: {
67
+ title: "List Pending Events",
68
+ readOnlyHint: true,
69
+ },
70
+ },
71
+ {
72
+ name: "get_event",
73
+ title: "Get Event Payload",
74
+ description: "Get the full payload for a single webhook event by ID.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ event_id: { type: "string", description: "The event ID to retrieve" },
79
+ },
80
+ required: ["event_id"],
81
+ },
82
+ annotations: {
83
+ title: "Get Event Payload",
84
+ readOnlyHint: true,
85
+ },
86
+ },
55
87
  {
56
- event_id: z.string().describe("The event ID to retrieve"),
88
+ name: "get_webhook_events",
89
+ title: "Get Webhook Events",
90
+ description:
91
+ "Get pending (unprocessed) GitHub webhook events with full payloads. Prefer get_pending_status or list_pending_events for polling.",
92
+ inputSchema: { type: "object", properties: {} },
93
+ annotations: {
94
+ title: "Get Webhook Events",
95
+ readOnlyHint: true,
96
+ },
57
97
  },
58
- async ({ event_id }) => {
59
- const event = getEvent(event_id);
60
- if (event === null) {
98
+ {
99
+ name: "mark_processed",
100
+ title: "Mark Event Processed",
101
+ description: "Mark a webhook event as processed so it won't appear again.",
102
+ inputSchema: {
103
+ type: "object",
104
+ properties: {
105
+ event_id: {
106
+ type: "string",
107
+ description: "The event ID to mark as processed",
108
+ },
109
+ },
110
+ required: ["event_id"],
111
+ },
112
+ annotations: {
113
+ title: "Mark Event Processed",
114
+ destructiveHint: true,
115
+ },
116
+ },
117
+ ];
118
+
119
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
120
+
121
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
122
+ const { name, arguments: args } = req.params;
123
+
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);
61
162
  return {
62
163
  content: [
63
164
  {
64
165
  type: "text",
65
- text: JSON.stringify({ error: "not_found", event_id }),
166
+ text: JSON.stringify({
167
+ success: result.success,
168
+ event_id: args.event_id,
169
+ purged: result.purged,
170
+ }),
66
171
  },
67
172
  ],
68
173
  };
69
174
  }
70
- return {
71
- content: [{ type: "text", text: JSON.stringify(event, null, 2) }],
72
- };
73
- }
74
- );
75
-
76
- server.tool(
77
- "get_webhook_events",
78
- "Get pending (unprocessed) GitHub webhook events with full payloads. Prefer get_pending_status or list_pending_events for polling.",
79
- {},
80
- async () => {
81
- const events = getPending();
82
- return {
83
- content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
84
- };
85
- }
86
- );
87
-
88
- server.tool(
89
- "mark_processed",
90
- "Mark a webhook event as processed so it won't appear again.",
91
- {
92
- event_id: z.string().describe("The event ID to mark as processed"),
93
- },
94
- async ({ event_id }) => {
95
- const success = markDone(event_id);
96
- return {
97
- content: [
98
- { type: "text", text: JSON.stringify({ success, event_id }) },
99
- ],
100
- };
175
+ default:
176
+ throw new Error(`unknown tool: ${name}`);
101
177
  }
102
- );
178
+ });
103
179
 
104
180
  // ── Start ───────────────────────────────────────────────────────────────────
105
181
 
106
182
  const transport = new StdioServerTransport();
107
183
  await server.connect(transport);
184
+
185
+ // ── File watcher (after connect) ─────────────────────────────────────────────
186
+
187
+ 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
+ }
240
+ }