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 +28 -4
- package/package.json +2 -2
- package/server/event-store.js +33 -4
- package/server/index.js +208 -75
package/manifest.json
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": "0.3",
|
|
3
3
|
"name": "github-webhook-mcp",
|
|
4
|
-
"
|
|
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.
|
|
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 --
|
|
16
|
+
"test": "node --check server/index.js && node --check server/event-store.js",
|
|
17
17
|
"pack:mcpb": "mcpb pack"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
package/server/event-store.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
148
|
-
|
|
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 {
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
33
|
-
"
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
.
|
|
39
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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({
|
|
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
|
-
|
|
71
|
-
|
|
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
|
+
}
|