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 +10 -2
- package/package.json +1 -1
- package/server/index.js +354 -5
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
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
|
|
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 {
|