github-webhook-mcp 0.6.0-rc.1 → 0.7.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
@@ -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-rc.1",
3
+ "version": "0.7.0",
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,277 @@ 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
+ async function performOAuthFlow() {
120
+ const metadata = await discoverOAuthMetadata();
121
+
122
+ const callbackServer = createServer();
123
+ await new Promise((resolve) => {
124
+ callbackServer.listen(0, "127.0.0.1", () => resolve());
125
+ });
126
+ const port = callbackServer.address().port;
127
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
128
+
129
+ const client = await ensureClientRegistration(metadata, [
130
+ redirectUri,
131
+ `http://localhost:${port}/callback`,
132
+ ]);
133
+
134
+ const codeVerifier = generateCodeVerifier();
135
+ const codeChallenge = generateCodeChallenge(codeVerifier);
136
+ const state = randomBytes(16).toString("hex");
137
+
138
+ const authUrl = new URL(metadata.authorization_endpoint);
139
+ authUrl.searchParams.set("response_type", "code");
140
+ authUrl.searchParams.set("client_id", client.client_id);
141
+ authUrl.searchParams.set("redirect_uri", redirectUri);
142
+ authUrl.searchParams.set("state", state);
143
+ authUrl.searchParams.set("code_challenge", codeChallenge);
144
+ authUrl.searchParams.set("code_challenge_method", "S256");
145
+
146
+ const authCode = await new Promise((resolve, reject) => {
147
+ const timeout = setTimeout(() => {
148
+ callbackServer.close();
149
+ reject(new Error("OAuth callback timed out after 5 minutes"));
150
+ }, 5 * 60 * 1000);
151
+
152
+ callbackServer.on("request", (req, res) => {
153
+ const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
154
+ if (url.pathname !== "/callback") {
155
+ res.writeHead(404);
156
+ res.end("Not found");
157
+ return;
158
+ }
159
+
160
+ const code = url.searchParams.get("code");
161
+ const returnedState = url.searchParams.get("state");
162
+ const error = url.searchParams.get("error");
163
+
164
+ if (error) {
165
+ res.writeHead(200, { "Content-Type": "text/html" });
166
+ res.end("<html><body><h1>Authorization failed</h1><p>You can close this tab.</p></body></html>");
167
+ clearTimeout(timeout);
168
+ callbackServer.close();
169
+ reject(new Error(`OAuth authorization failed: ${error}`));
170
+ return;
171
+ }
172
+
173
+ if (!code || returnedState !== state) {
174
+ res.writeHead(400, { "Content-Type": "text/html" });
175
+ res.end("<html><body><h1>Invalid callback</h1></body></html>");
176
+ return;
177
+ }
178
+
179
+ res.writeHead(200, { "Content-Type": "text/html" });
180
+ res.end("<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>");
181
+ clearTimeout(timeout);
182
+ callbackServer.close();
183
+ resolve(code);
184
+ });
185
+
186
+ const openCmd = process.platform === "win32" ? "start" :
187
+ process.platform === "darwin" ? "open" : "xdg-open";
188
+ exec(`${openCmd} "${authUrl.toString()}"`);
189
+
190
+ process.stderr.write(
191
+ `\n[github-webhook-mcp] Open this URL to authenticate:\n${authUrl.toString()}\n\n`,
192
+ );
193
+ });
194
+
195
+ const tokenRes = await fetch(metadata.token_endpoint, {
196
+ method: "POST",
197
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
198
+ body: new URLSearchParams({
199
+ grant_type: "authorization_code",
200
+ code: authCode,
201
+ redirect_uri: redirectUri,
202
+ client_id: client.client_id,
203
+ code_verifier: codeVerifier,
204
+ }),
205
+ });
206
+
207
+ if (!tokenRes.ok) {
208
+ throw new Error(`Token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`);
209
+ }
210
+
211
+ const tokenData = await tokenRes.json();
212
+
213
+ const tokens = {
214
+ access_token: tokenData.access_token,
215
+ refresh_token: tokenData.refresh_token,
216
+ expires_at: tokenData.expires_in
217
+ ? Date.now() + tokenData.expires_in * 1000
218
+ : undefined,
219
+ };
220
+
221
+ await saveTokens(tokens);
222
+ return tokens;
223
+ }
224
+
225
+ async function refreshAccessToken(refreshToken) {
226
+ const metadata = await discoverOAuthMetadata();
227
+ const client = await loadClientRegistration();
228
+ if (!client) throw new Error("No client registration found");
229
+
230
+ const res = await fetch(metadata.token_endpoint, {
231
+ method: "POST",
232
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
233
+ body: new URLSearchParams({
234
+ grant_type: "refresh_token",
235
+ refresh_token: refreshToken,
236
+ client_id: client.client_id,
237
+ }),
238
+ });
239
+
240
+ if (!res.ok) {
241
+ throw new Error(`Token refresh failed: ${res.status}`);
242
+ }
243
+
244
+ const data = await res.json();
245
+
246
+ const tokens = {
247
+ access_token: data.access_token,
248
+ refresh_token: data.refresh_token || refreshToken,
249
+ expires_at: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
250
+ };
251
+
252
+ await saveTokens(tokens);
253
+ return tokens;
254
+ }
255
+
256
+ async function getAccessToken() {
257
+ if (LEGACY_AUTH_TOKEN) return LEGACY_AUTH_TOKEN;
258
+
259
+ if (!_cachedTokens) {
260
+ _cachedTokens = await loadTokens();
261
+ }
262
+
263
+ if (_cachedTokens) {
264
+ if (!_cachedTokens.expires_at || _cachedTokens.expires_at > Date.now() + 60_000) {
265
+ return _cachedTokens.access_token;
266
+ }
267
+
268
+ if (_cachedTokens.refresh_token) {
269
+ try {
270
+ _cachedTokens = await refreshAccessToken(_cachedTokens.refresh_token);
271
+ return _cachedTokens.access_token;
272
+ } catch {
273
+ // Refresh failed, fall through to full OAuth flow
274
+ }
275
+ }
276
+ }
277
+
278
+ _cachedTokens = await performOAuthFlow();
279
+ return _cachedTokens.access_token;
280
+ }
281
+
282
+ /** Build common headers with OAuth Bearer auth */
283
+ async function authHeaders(extra) {
284
+ const h = { ...extra };
285
+ const token = await getAccessToken();
286
+ if (token) h["Authorization"] = `Bearer ${token}`;
287
+ return h;
288
+ }
22
289
 
23
290
  // ── Remote MCP Session (lazy, reused) ────────────────────────────────────────
24
291
 
@@ -29,10 +296,10 @@ async function getSessionId() {
29
296
 
30
297
  const res = await fetch(`${WORKER_URL}/mcp`, {
31
298
  method: "POST",
32
- headers: {
299
+ headers: await authHeaders({
33
300
  "Content-Type": "application/json",
34
301
  Accept: "application/json, text/event-stream",
35
- },
302
+ }),
36
303
  body: JSON.stringify({
37
304
  jsonrpc: "2.0",
38
305
  method: "initialize",
@@ -54,11 +321,11 @@ async function callRemoteTool(name, args) {
54
321
 
55
322
  const res = await fetch(`${WORKER_URL}/mcp`, {
56
323
  method: "POST",
57
- headers: {
324
+ headers: await authHeaders({
58
325
  "Content-Type": "application/json",
59
326
  Accept: "application/json, text/event-stream",
60
327
  "mcp-session-id": sessionId,
61
- },
328
+ }),
62
329
  body: JSON.stringify({
63
330
  jsonrpc: "2.0",
64
331
  method: "tools/call",
@@ -67,6 +334,13 @@ async function callRemoteTool(name, args) {
67
334
  }),
68
335
  });
69
336
 
337
+ // 401 = token expired or revoked, re-authenticate and retry
338
+ if (res.status === 401) {
339
+ _cachedTokens = null;
340
+ _sessionId = null;
341
+ return callRemoteTool(name, args);
342
+ }
343
+
70
344
  const text = await res.text();
71
345
 
72
346
  // Streamable HTTP may return SSE format
@@ -210,7 +484,11 @@ async function connectSSE() {
210
484
  return;
211
485
  }
212
486
 
213
- const es = new EventSourceImpl(`${WORKER_URL}/events`);
487
+ const token = await getAccessToken();
488
+ const sseUrl = token
489
+ ? `${WORKER_URL}/events?token=${encodeURIComponent(token)}`
490
+ : `${WORKER_URL}/events`;
491
+ const es = new EventSourceImpl(sseUrl);
214
492
 
215
493
  es.onmessage = (event) => {
216
494
  try {