github-webhook-mcp 0.2.1 → 0.3.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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "github-webhook-mcp",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "description": "Browse pending GitHub webhook events. Pairs with a webhook receiver that writes events.json.",
6
6
  "author": {
7
7
  "name": "Liplus Project"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-webhook-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for browsing GitHub webhook events",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,152 +1,181 @@
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
-
11
- function dataFilePath() {
12
- return process.env.EVENTS_JSON_PATH || DEFAULT_DATA_FILE;
13
- }
14
-
15
- // ── Load / Save ─────────────────────────────────────────────────────────────
16
-
17
- export function load() {
18
- const filePath = dataFilePath();
19
- if (!existsSync(filePath)) return [];
20
-
21
- const raw = readFileSync(filePath);
22
-
23
- // Try UTF-8 first (with BOM stripping)
24
- try {
25
- let text = raw.toString("utf-8");
26
- if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
27
- const events = JSON.parse(text);
28
- return events;
29
- } catch {
30
- // fall through to legacy encodings
31
- }
32
-
33
- // Try legacy encodings
34
- for (const encoding of LEGACY_ENCODINGS) {
35
- try {
36
- const text = iconv.decode(raw, encoding);
37
- const events = JSON.parse(text);
38
- // Migrate to UTF-8
39
- save(events);
40
- return events;
41
- } catch {
42
- continue;
43
- }
44
- }
45
-
46
- throw new Error(`Unable to decode event store: ${filePath}`);
47
- }
48
-
49
- export function save(events) {
50
- const filePath = dataFilePath();
51
- writeFileSync(filePath, JSON.stringify(events, null, 2), PRIMARY_ENCODING);
52
- }
53
-
54
- // ── Query ───────────────────────────────────────────────────────────────────
55
-
56
- export function getPending() {
57
- return load().filter((e) => !e.processed);
58
- }
59
-
60
- export function getEvent(eventId) {
61
- for (const event of load()) {
62
- if (event.id === eventId) return event;
63
- }
64
- return null;
65
- }
66
-
67
- export function getPendingStatus() {
68
- const pending = getPending();
69
- const types = {};
70
- for (const event of pending) {
71
- types[event.type] = (types[event.type] || 0) + 1;
72
- }
73
- return {
74
- pending_count: pending.length,
75
- latest_received_at: pending.length > 0 ? pending[pending.length - 1].received_at : null,
76
- types,
77
- };
78
- }
79
-
80
- // ── Summary ─────────────────────────────────────────────────────────────────
81
-
82
- function eventNumber(payload) {
83
- return (
84
- payload.number ??
85
- payload.issue?.number ??
86
- payload.pull_request?.number ??
87
- null
88
- );
89
- }
90
-
91
- function eventTitle(payload) {
92
- return (
93
- payload.issue?.title ??
94
- payload.pull_request?.title ??
95
- payload.discussion?.title ??
96
- payload.check_run?.name ??
97
- payload.workflow_run?.name ??
98
- payload.workflow_job?.name ??
99
- null
100
- );
101
- }
102
-
103
- function eventUrl(payload) {
104
- return (
105
- payload.issue?.html_url ??
106
- payload.pull_request?.html_url ??
107
- payload.discussion?.html_url ??
108
- payload.check_run?.html_url ??
109
- payload.workflow_run?.html_url ??
110
- null
111
- );
112
- }
113
-
114
- export function summarizeEvent(event) {
115
- const payload = event.payload || {};
116
- return {
117
- id: event.id,
118
- type: event.type,
119
- received_at: event.received_at,
120
- processed: event.processed,
121
- trigger_status: event.trigger_status ?? null,
122
- last_triggered_at: event.last_triggered_at ?? null,
123
- action: payload.action ?? null,
124
- repo: payload.repository?.full_name ?? null,
125
- sender: payload.sender?.login ?? null,
126
- number: eventNumber(payload),
127
- title: eventTitle(payload),
128
- url: eventUrl(payload),
129
- };
130
- }
131
-
132
- export function getPendingSummaries(limit = 20) {
133
- let pending = getPending();
134
- if (limit > 0) {
135
- pending = pending.slice(-limit);
136
- }
137
- return pending.map(summarizeEvent);
138
- }
139
-
140
- // ── Mutation ────────────────────────────────────────────────────────────────
141
-
142
- export function markDone(eventId) {
143
- const events = load();
144
- for (const event of events) {
145
- if (event.id === eventId) {
146
- event.processed = true;
147
- save(events);
148
- return true;
149
- }
150
- }
151
- return false;
152
- }
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
+ }
package/server/index.js CHANGED
@@ -1,107 +1,215 @@
1
- #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { z } from "zod";
5
- import {
6
- getPendingStatus,
7
- getPendingSummaries,
8
- getEvent,
9
- getPending,
10
- markDone,
11
- } from "./event-store.js";
12
-
13
- const server = new McpServer({
14
- name: "github-webhook-mcp",
15
- version: "0.2.0",
16
- });
17
-
18
- // ── Tools ───────────────────────────────────────────────────────────────────
19
-
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
- );
31
-
32
- server.tool(
33
- "list_pending_events",
34
- "List lightweight summaries for pending GitHub webhook events. Returns metadata only, without full payloads.",
35
- {
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"),
43
- },
44
- async ({ limit }) => {
45
- const summaries = getPendingSummaries(limit);
46
- return {
47
- content: [{ type: "text", text: JSON.stringify(summaries, null, 2) }],
48
- };
49
- }
50
- );
51
-
52
- server.tool(
53
- "get_event",
54
- "Get the full payload for a single webhook event by ID.",
55
- {
56
- event_id: z.string().describe("The event ID to retrieve"),
57
- },
58
- async ({ event_id }) => {
59
- const event = getEvent(event_id);
60
- if (event === null) {
61
- return {
62
- content: [
63
- {
64
- type: "text",
65
- text: JSON.stringify({ error: "not_found", event_id }),
66
- },
67
- ],
68
- };
69
- }
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
- };
101
- }
102
- );
103
-
104
- // ── Start ───────────────────────────────────────────────────────────────────
105
-
106
- const transport = new StdioServerTransport();
107
- await server.connect(transport);
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import {
5
+ ListToolsRequestSchema,
6
+ CallToolRequestSchema,
7
+ } 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
+
19
+ const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0";
20
+
21
+ const capabilities = {
22
+ tools: {},
23
+ };
24
+ if (CHANNEL_ENABLED) {
25
+ capabilities.experimental = { "claude/channel": {} };
26
+ }
27
+
28
+ const server = new Server(
29
+ { name: "github-webhook-mcp", version: "0.3.0" },
30
+ {
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,
35
+ },
36
+ );
37
+
38
+ // ── Tools ───────────────────────────────────────────────────────────────────
39
+
40
+ const TOOLS = [
41
+ {
42
+ name: "get_pending_status",
43
+ description:
44
+ "Get a lightweight snapshot of pending GitHub webhook events. Use this for periodic polling before requesting details.",
45
+ inputSchema: { type: "object", properties: {} },
46
+ },
47
+ {
48
+ name: "list_pending_events",
49
+ description:
50
+ "List lightweight summaries for pending GitHub webhook events. Returns metadata only, without full payloads.",
51
+ inputSchema: {
52
+ type: "object",
53
+ properties: {
54
+ limit: {
55
+ type: "number",
56
+ description: "Maximum number of pending events to return (1-100, default 20)",
57
+ },
58
+ },
59
+ },
60
+ },
61
+ {
62
+ name: "get_event",
63
+ description: "Get the full payload for a single webhook event by ID.",
64
+ inputSchema: {
65
+ type: "object",
66
+ properties: {
67
+ event_id: { type: "string", description: "The event ID to retrieve" },
68
+ },
69
+ required: ["event_id"],
70
+ },
71
+ },
72
+ {
73
+ name: "get_webhook_events",
74
+ description:
75
+ "Get pending (unprocessed) GitHub webhook events with full payloads. Prefer get_pending_status or list_pending_events for polling.",
76
+ inputSchema: { type: "object", properties: {} },
77
+ },
78
+ {
79
+ name: "mark_processed",
80
+ description: "Mark a webhook event as processed so it won't appear again.",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ event_id: {
85
+ type: "string",
86
+ description: "The event ID to mark as processed",
87
+ },
88
+ },
89
+ required: ["event_id"],
90
+ },
91
+ },
92
+ ];
93
+
94
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
95
+
96
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
97
+ const { name, arguments: args } = req.params;
98
+
99
+ switch (name) {
100
+ case "get_pending_status": {
101
+ const status = getPendingStatus();
102
+ return {
103
+ content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
104
+ };
105
+ }
106
+ case "list_pending_events": {
107
+ const limit = Math.max(1, Math.min(100, Number(args?.limit) || 20));
108
+ const summaries = getPendingSummaries(limit);
109
+ return {
110
+ content: [{ type: "text", text: JSON.stringify(summaries, null, 2) }],
111
+ };
112
+ }
113
+ case "get_event": {
114
+ const event = getEvent(args.event_id);
115
+ if (event === null) {
116
+ return {
117
+ content: [
118
+ {
119
+ type: "text",
120
+ text: JSON.stringify({ error: "not_found", event_id: args.event_id }),
121
+ },
122
+ ],
123
+ };
124
+ }
125
+ return {
126
+ content: [{ type: "text", text: JSON.stringify(event, null, 2) }],
127
+ };
128
+ }
129
+ case "get_webhook_events": {
130
+ const events = getPending();
131
+ return {
132
+ content: [{ type: "text", text: JSON.stringify(events, null, 2) }],
133
+ };
134
+ }
135
+ case "mark_processed": {
136
+ const result = markDone(args.event_id);
137
+ return {
138
+ content: [
139
+ {
140
+ type: "text",
141
+ text: JSON.stringify({
142
+ success: result.success,
143
+ event_id: args.event_id,
144
+ purged: result.purged,
145
+ }),
146
+ },
147
+ ],
148
+ };
149
+ }
150
+ default:
151
+ throw new Error(`unknown tool: ${name}`);
152
+ }
153
+ });
154
+
155
+ // ── Start ───────────────────────────────────────────────────────────────────
156
+
157
+ const transport = new StdioServerTransport();
158
+ await server.connect(transport);
159
+
160
+ // ── File watcher (after connect) ─────────────────────────────────────────────
161
+
162
+ if (CHANNEL_ENABLED) {
163
+ const seenIds = new Set(getPending().map((e) => e.id));
164
+ let debounce = null;
165
+
166
+ function onFileChange() {
167
+ if (debounce) return;
168
+ debounce = setTimeout(() => {
169
+ debounce = null;
170
+ try {
171
+ const pending = getPending();
172
+ for (const event of pending) {
173
+ if (seenIds.has(event.id)) continue;
174
+ seenIds.add(event.id);
175
+ const summary = summarizeEvent(event);
176
+ const lines = [
177
+ `[${summary.type}] ${summary.repo ?? ""}`,
178
+ summary.action ? `action: ${summary.action}` : null,
179
+ summary.title ? `#${summary.number ?? ""} ${summary.title}` : null,
180
+ summary.sender ? `by ${summary.sender}` : null,
181
+ summary.url ?? null,
182
+ ].filter(Boolean);
183
+ server.notification({
184
+ method: "notifications/claude/channel",
185
+ params: {
186
+ content: lines.join("\n"),
187
+ meta: {
188
+ chat_id: "github",
189
+ message_id: event.id,
190
+ user: summary.sender ?? "github",
191
+ ts: summary.received_at,
192
+ },
193
+ },
194
+ });
195
+ }
196
+ } catch {
197
+ // file may be mid-write; ignore and retry on next change
198
+ }
199
+ }, 500);
200
+ }
201
+
202
+ try {
203
+ watch(dataFilePath(), onFileChange);
204
+ } catch {
205
+ // events.json may not exist yet; start polling fallback
206
+ const poll = setInterval(() => {
207
+ try {
208
+ watch(dataFilePath(), onFileChange);
209
+ clearInterval(poll);
210
+ } catch {
211
+ // keep waiting
212
+ }
213
+ }, 5000);
214
+ }
215
+ }