github-webhook-mcp 0.6.0 → 0.7.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
@@ -27,13 +27,14 @@
27
27
  ],
28
28
  "env": {
29
29
  "WEBHOOK_WORKER_URL": "${user_config.worker_url}",
30
- "WEBHOOK_CHANNEL": "${user_config.channel_enabled}"
30
+ "WEBHOOK_CHANNEL": "${user_config.channel_enabled}",
31
+ "WEBHOOK_AUTH_TOKEN": "${user_config.auth_token}"
31
32
  }
32
33
  }
33
34
  },
34
35
  "user_config": {
35
36
  "worker_url": {
36
- "description": "URL of the Cloudflare Worker endpoint (e.g. https://github-webhook-mcp.example.workers.dev)",
37
+ "description": "URL of the Cloudflare Worker endpoint (e.g. https://github-webhook-mcp.example.workers.dev). Authentication is handled automatically via OAuth.",
37
38
  "type": "string",
38
39
  "required": true,
39
40
  "title": "Worker URL"
@@ -44,6 +45,13 @@
44
45
  "required": false,
45
46
  "title": "Channel Notifications",
46
47
  "default": "0"
48
+ },
49
+ "auth_token": {
50
+ "description": "Legacy Bearer token for self-hosted workers without OAuth. Leave empty to use OAuth authentication (recommended).",
51
+ "type": "string",
52
+ "required": false,
53
+ "title": "Auth Token (Legacy)",
54
+ "default": ""
47
55
  }
48
56
  },
49
57
  "tools": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-webhook-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "MCP server bridging GitHub webhooks via Cloudflare Worker",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * Thin stdio MCP server that proxies tool calls to a remote
6
6
  * Cloudflare Worker + Durable Object backend via Streamable HTTP.
7
7
  * Optionally listens to SSE for real-time channel notifications.
8
+ * Authenticates via OAuth 2.1 with PKCE (localhost callback).
8
9
  *
9
10
  * Discord MCP pattern: data lives in the cloud, local MCP is a thin bridge.
10
11
  */
@@ -14,11 +15,337 @@ import {
14
15
  ListToolsRequestSchema,
15
16
  CallToolRequestSchema,
16
17
  } from "@modelcontextprotocol/sdk/types.js";
18
+ import { createServer } from "node:http";
19
+ import { randomBytes, createHash } from "node:crypto";
20
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { exec } from "node:child_process";
17
24
 
18
25
  const WORKER_URL =
19
26
  process.env.WEBHOOK_WORKER_URL ||
20
27
  "https://github-webhook-mcp.liplus.workers.dev";
21
28
  const CHANNEL_ENABLED = process.env.WEBHOOK_CHANNEL !== "0";
29
+ // Legacy auth support: if WEBHOOK_AUTH_TOKEN is set, use Bearer token directly
30
+ const LEGACY_AUTH_TOKEN = process.env.WEBHOOK_AUTH_TOKEN || "";
31
+
32
+ // ── OAuth Token Storage ──────────────────────────────────────────────────────
33
+
34
+ const TOKEN_DIR = join(homedir(), ".github-webhook-mcp");
35
+ const TOKEN_FILE = join(TOKEN_DIR, "oauth-tokens.json");
36
+ const CLIENT_REG_FILE = join(TOKEN_DIR, "oauth-client.json");
37
+
38
+ async function loadTokens() {
39
+ try {
40
+ const data = await readFile(TOKEN_FILE, "utf-8");
41
+ return JSON.parse(data);
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ async function saveTokens(tokens) {
48
+ await mkdir(TOKEN_DIR, { recursive: true });
49
+ await writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
50
+ }
51
+
52
+ let _cachedTokens = null;
53
+
54
+ // ── PKCE Utilities ───────────────────────────────────────────────────────────
55
+
56
+ function generateCodeVerifier() {
57
+ return randomBytes(32).toString("base64url");
58
+ }
59
+
60
+ function generateCodeChallenge(verifier) {
61
+ return createHash("sha256").update(verifier).digest("base64url");
62
+ }
63
+
64
+ // ── OAuth Discovery & Registration ───────────────────────────────────────────
65
+
66
+ async function discoverOAuthMetadata() {
67
+ const res = await fetch(`${WORKER_URL}/.well-known/oauth-authorization-server`);
68
+ if (!res.ok) {
69
+ throw new Error(`OAuth discovery failed: ${res.status}`);
70
+ }
71
+ return await res.json();
72
+ }
73
+
74
+ async function loadClientRegistration() {
75
+ try {
76
+ const data = await readFile(CLIENT_REG_FILE, "utf-8");
77
+ return JSON.parse(data);
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ async function saveClientRegistration(reg) {
84
+ await mkdir(TOKEN_DIR, { recursive: true });
85
+ await writeFile(CLIENT_REG_FILE, JSON.stringify(reg, null, 2), { mode: 0o600 });
86
+ }
87
+
88
+ async function ensureClientRegistration(metadata, redirectUris) {
89
+ const existing = await loadClientRegistration();
90
+ if (existing) return existing;
91
+
92
+ if (!metadata.registration_endpoint) {
93
+ throw new Error("OAuth server does not support dynamic client registration");
94
+ }
95
+
96
+ const res = await fetch(metadata.registration_endpoint, {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify({
100
+ client_name: "github-webhook-mcp-cli",
101
+ redirect_uris: redirectUris,
102
+ grant_types: ["authorization_code", "refresh_token"],
103
+ response_types: ["code"],
104
+ token_endpoint_auth_method: "none",
105
+ }),
106
+ });
107
+
108
+ if (!res.ok) {
109
+ throw new Error(`Client registration failed: ${res.status} ${await res.text()}`);
110
+ }
111
+
112
+ const reg = await res.json();
113
+ await saveClientRegistration(reg);
114
+ return reg;
115
+ }
116
+
117
+ // ── OAuth Localhost Callback Flow ────────────────────────────────────────────
118
+
119
+ // Pending OAuth state: kept alive across tool calls so the callback server
120
+ // can receive the authorization code even if the first tool call returns early.
121
+ let _pendingOAuth = null;
122
+
123
+ class OAuthPendingError extends Error {
124
+ constructor(authUrl) {
125
+ super("OAuth authentication required");
126
+ this.authUrl = authUrl;
127
+ }
128
+ }
129
+
130
+ function openBrowser(url) {
131
+ if (process.platform === "win32") {
132
+ // Windows `start` treats the first quoted arg as a window title.
133
+ // Pass an empty title so the URL is opened correctly.
134
+ exec(`start "" "${url}"`);
135
+ } else {
136
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
137
+ exec(`${openCmd} "${url}"`);
138
+ }
139
+ }
140
+
141
+ async function startOAuthFlow() {
142
+ const metadata = await discoverOAuthMetadata();
143
+
144
+ const callbackServer = createServer();
145
+ await new Promise((resolve) => {
146
+ callbackServer.listen(0, "127.0.0.1", () => resolve());
147
+ });
148
+ const port = callbackServer.address().port;
149
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
150
+
151
+ const client = await ensureClientRegistration(metadata, [
152
+ redirectUri,
153
+ `http://localhost:${port}/callback`,
154
+ ]);
155
+
156
+ const codeVerifier = generateCodeVerifier();
157
+ const codeChallenge = generateCodeChallenge(codeVerifier);
158
+ const state = randomBytes(16).toString("hex");
159
+
160
+ const authUrl = new URL(metadata.authorization_endpoint);
161
+ authUrl.searchParams.set("response_type", "code");
162
+ authUrl.searchParams.set("client_id", client.client_id);
163
+ authUrl.searchParams.set("redirect_uri", redirectUri);
164
+ authUrl.searchParams.set("state", state);
165
+ authUrl.searchParams.set("code_challenge", codeChallenge);
166
+ authUrl.searchParams.set("code_challenge_method", "S256");
167
+
168
+ // Promise that resolves when the callback is received
169
+ const tokenPromise = new Promise((resolve, reject) => {
170
+ const timeout = setTimeout(() => {
171
+ callbackServer.close();
172
+ _pendingOAuth = null;
173
+ reject(new Error("OAuth callback timed out after 5 minutes"));
174
+ }, 5 * 60 * 1000);
175
+
176
+ callbackServer.on("request", async (req, res) => {
177
+ const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
178
+ if (url.pathname !== "/callback") {
179
+ res.writeHead(404);
180
+ res.end("Not found");
181
+ return;
182
+ }
183
+
184
+ const code = url.searchParams.get("code");
185
+ const returnedState = url.searchParams.get("state");
186
+ const error = url.searchParams.get("error");
187
+
188
+ if (error) {
189
+ res.writeHead(200, { "Content-Type": "text/html" });
190
+ res.end("<html><body><h1>Authorization failed</h1><p>You can close this tab.</p></body></html>");
191
+ clearTimeout(timeout);
192
+ callbackServer.close();
193
+ _pendingOAuth = null;
194
+ reject(new Error(`OAuth authorization failed: ${error}`));
195
+ return;
196
+ }
197
+
198
+ if (!code || returnedState !== state) {
199
+ res.writeHead(400, { "Content-Type": "text/html" });
200
+ res.end("<html><body><h1>Invalid callback</h1></body></html>");
201
+ return;
202
+ }
203
+
204
+ res.writeHead(200, { "Content-Type": "text/html" });
205
+ res.end("<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>");
206
+ clearTimeout(timeout);
207
+ callbackServer.close();
208
+
209
+ try {
210
+ const tokenRes = await fetch(metadata.token_endpoint, {
211
+ method: "POST",
212
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
213
+ body: new URLSearchParams({
214
+ grant_type: "authorization_code",
215
+ code,
216
+ redirect_uri: redirectUri,
217
+ client_id: client.client_id,
218
+ code_verifier: codeVerifier,
219
+ }),
220
+ });
221
+
222
+ if (!tokenRes.ok) {
223
+ _pendingOAuth = null;
224
+ reject(new Error(`Token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`));
225
+ return;
226
+ }
227
+
228
+ const tokenData = await tokenRes.json();
229
+ const tokens = {
230
+ access_token: tokenData.access_token,
231
+ refresh_token: tokenData.refresh_token,
232
+ expires_at: tokenData.expires_in
233
+ ? Date.now() + tokenData.expires_in * 1000
234
+ : undefined,
235
+ };
236
+
237
+ await saveTokens(tokens);
238
+ _pendingOAuth = null;
239
+ resolve(tokens);
240
+ } catch (err) {
241
+ _pendingOAuth = null;
242
+ reject(err);
243
+ }
244
+ });
245
+ });
246
+
247
+ // Try to open the browser
248
+ openBrowser(authUrl.toString());
249
+ process.stderr.write(
250
+ `\n[github-webhook-mcp] Open this URL to authenticate:\n${authUrl.toString()}\n\n`,
251
+ );
252
+
253
+ // Store pending state so subsequent tool calls can await or re-surface the URL
254
+ _pendingOAuth = { authUrl: authUrl.toString(), tokenPromise };
255
+
256
+ return _pendingOAuth;
257
+ }
258
+
259
+ async function performOAuthFlow() {
260
+ // If an OAuth flow is already in progress, check if it completed
261
+ if (_pendingOAuth) {
262
+ // Race: either the token is ready or we return the URL again
263
+ const result = await Promise.race([
264
+ _pendingOAuth.tokenPromise,
265
+ new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
266
+ ]);
267
+ if (result && result.access_token) return result;
268
+ throw new OAuthPendingError(_pendingOAuth.authUrl);
269
+ }
270
+
271
+ // Start a new OAuth flow
272
+ const pending = await startOAuthFlow();
273
+
274
+ // Wait briefly for the browser-opened flow to complete (e.g. auto-open worked)
275
+ const result = await Promise.race([
276
+ pending.tokenPromise,
277
+ new Promise((resolve) => setTimeout(() => resolve(null), 3000)),
278
+ ]);
279
+ if (result && result.access_token) return result;
280
+
281
+ // Browser likely didn't open or user hasn't authenticated yet — surface the URL
282
+ throw new OAuthPendingError(pending.authUrl);
283
+ }
284
+
285
+ async function refreshAccessToken(refreshToken) {
286
+ const metadata = await discoverOAuthMetadata();
287
+ const client = await loadClientRegistration();
288
+ if (!client) throw new Error("No client registration found");
289
+
290
+ const res = await fetch(metadata.token_endpoint, {
291
+ method: "POST",
292
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
293
+ body: new URLSearchParams({
294
+ grant_type: "refresh_token",
295
+ refresh_token: refreshToken,
296
+ client_id: client.client_id,
297
+ }),
298
+ });
299
+
300
+ if (!res.ok) {
301
+ throw new Error(`Token refresh failed: ${res.status}`);
302
+ }
303
+
304
+ const data = await res.json();
305
+
306
+ const tokens = {
307
+ access_token: data.access_token,
308
+ refresh_token: data.refresh_token || refreshToken,
309
+ expires_at: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
310
+ };
311
+
312
+ await saveTokens(tokens);
313
+ return tokens;
314
+ }
315
+
316
+ async function getAccessToken() {
317
+ if (LEGACY_AUTH_TOKEN) return LEGACY_AUTH_TOKEN;
318
+
319
+ if (!_cachedTokens) {
320
+ _cachedTokens = await loadTokens();
321
+ }
322
+
323
+ if (_cachedTokens) {
324
+ if (!_cachedTokens.expires_at || _cachedTokens.expires_at > Date.now() + 60_000) {
325
+ return _cachedTokens.access_token;
326
+ }
327
+
328
+ if (_cachedTokens.refresh_token) {
329
+ try {
330
+ _cachedTokens = await refreshAccessToken(_cachedTokens.refresh_token);
331
+ return _cachedTokens.access_token;
332
+ } catch {
333
+ // Refresh failed, fall through to full OAuth flow
334
+ }
335
+ }
336
+ }
337
+
338
+ _cachedTokens = await performOAuthFlow();
339
+ return _cachedTokens.access_token;
340
+ }
341
+
342
+ /** Build common headers with OAuth Bearer auth */
343
+ async function authHeaders(extra) {
344
+ const h = { ...extra };
345
+ const token = await getAccessToken();
346
+ if (token) h["Authorization"] = `Bearer ${token}`;
347
+ return h;
348
+ }
22
349
 
23
350
  // ── Remote MCP Session (lazy, reused) ────────────────────────────────────────
24
351
 
@@ -29,10 +356,10 @@ async function getSessionId() {
29
356
 
30
357
  const res = await fetch(`${WORKER_URL}/mcp`, {
31
358
  method: "POST",
32
- headers: {
359
+ headers: await authHeaders({
33
360
  "Content-Type": "application/json",
34
361
  Accept: "application/json, text/event-stream",
35
- },
362
+ }),
36
363
  body: JSON.stringify({
37
364
  jsonrpc: "2.0",
38
365
  method: "initialize",
@@ -54,11 +381,11 @@ async function callRemoteTool(name, args) {
54
381
 
55
382
  const res = await fetch(`${WORKER_URL}/mcp`, {
56
383
  method: "POST",
57
- headers: {
384
+ headers: await authHeaders({
58
385
  "Content-Type": "application/json",
59
386
  Accept: "application/json, text/event-stream",
60
387
  "mcp-session-id": sessionId,
61
- },
388
+ }),
62
389
  body: JSON.stringify({
63
390
  jsonrpc: "2.0",
64
391
  method: "tools/call",
@@ -67,6 +394,13 @@ async function callRemoteTool(name, args) {
67
394
  }),
68
395
  });
69
396
 
397
+ // 401 = token expired or revoked, re-authenticate and retry
398
+ if (res.status === 401) {
399
+ _cachedTokens = null;
400
+ _sessionId = null;
401
+ return callRemoteTool(name, args);
402
+ }
403
+
70
404
  const text = await res.text();
71
405
 
72
406
  // Streamable HTTP may return SSE format
@@ -192,6 +526,17 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
192
526
  try {
193
527
  return await callRemoteTool(name, args ?? {});
194
528
  } catch (err) {
529
+ if (err instanceof OAuthPendingError) {
530
+ return {
531
+ content: [
532
+ {
533
+ type: "text",
534
+ text: `Authentication required. Please open this URL to authorize:\n${err.authUrl}\n\nAfter authorizing in the browser, retry the tool call.`,
535
+ },
536
+ ],
537
+ isError: true,
538
+ };
539
+ }
195
540
  return {
196
541
  content: [{ type: "text", text: `Failed to reach worker: ${err}` }],
197
542
  isError: true,
@@ -210,7 +555,11 @@ async function connectSSE() {
210
555
  return;
211
556
  }
212
557
 
213
- const es = new EventSourceImpl(`${WORKER_URL}/events`);
558
+ const token = await getAccessToken();
559
+ const sseUrl = token
560
+ ? `${WORKER_URL}/events?token=${encodeURIComponent(token)}`
561
+ : `${WORKER_URL}/events`;
562
+ const es = new EventSourceImpl(sseUrl);
214
563
 
215
564
  es.onmessage = (event) => {
216
565
  try {