github-webhook-mcp 0.7.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server/index.js +105 -34
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-webhook-mcp",
3
- "version": "0.7.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
@@ -116,7 +116,29 @@ async function ensureClientRegistration(metadata, redirectUris) {
116
116
 
117
117
  // ── OAuth Localhost Callback Flow ────────────────────────────────────────────
118
118
 
119
- async function performOAuthFlow() {
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() {
120
142
  const metadata = await discoverOAuthMetadata();
121
143
 
122
144
  const callbackServer = createServer();
@@ -143,13 +165,15 @@ async function performOAuthFlow() {
143
165
  authUrl.searchParams.set("code_challenge", codeChallenge);
144
166
  authUrl.searchParams.set("code_challenge_method", "S256");
145
167
 
146
- const authCode = await new Promise((resolve, reject) => {
168
+ // Promise that resolves when the callback is received
169
+ const tokenPromise = new Promise((resolve, reject) => {
147
170
  const timeout = setTimeout(() => {
148
171
  callbackServer.close();
172
+ _pendingOAuth = null;
149
173
  reject(new Error("OAuth callback timed out after 5 minutes"));
150
174
  }, 5 * 60 * 1000);
151
175
 
152
- callbackServer.on("request", (req, res) => {
176
+ callbackServer.on("request", async (req, res) => {
153
177
  const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
154
178
  if (url.pathname !== "/callback") {
155
179
  res.writeHead(404);
@@ -166,6 +190,7 @@ async function performOAuthFlow() {
166
190
  res.end("<html><body><h1>Authorization failed</h1><p>You can close this tab.</p></body></html>");
167
191
  clearTimeout(timeout);
168
192
  callbackServer.close();
193
+ _pendingOAuth = null;
169
194
  reject(new Error(`OAuth authorization failed: ${error}`));
170
195
  return;
171
196
  }
@@ -180,46 +205,81 @@ async function performOAuthFlow() {
180
205
  res.end("<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>");
181
206
  clearTimeout(timeout);
182
207
  callbackServer.close();
183
- resolve(code);
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
+ }
184
244
  });
245
+ });
185
246
 
186
- const openCmd = process.platform === "win32" ? "start" :
187
- process.platform === "darwin" ? "open" : "xdg-open";
188
- exec(`${openCmd} "${authUrl.toString()}"`);
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
+ );
189
252
 
190
- process.stderr.write(
191
- `\n[github-webhook-mcp] Open this URL to authenticate:\n${authUrl.toString()}\n\n`,
192
- );
193
- });
253
+ // Store pending state so subsequent tool calls can await or re-surface the URL
254
+ _pendingOAuth = { authUrl: authUrl.toString(), tokenPromise };
194
255
 
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
- });
256
+ return _pendingOAuth;
257
+ }
206
258
 
207
- if (!tokenRes.ok) {
208
- throw new Error(`Token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`);
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);
209
269
  }
210
270
 
211
- const tokenData = await tokenRes.json();
271
+ // Start a new OAuth flow
272
+ const pending = await startOAuthFlow();
212
273
 
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
- };
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;
220
280
 
221
- await saveTokens(tokens);
222
- return tokens;
281
+ // Browser likely didn't open or user hasn't authenticated yet — surface the URL
282
+ throw new OAuthPendingError(pending.authUrl);
223
283
  }
224
284
 
225
285
  async function refreshAccessToken(refreshToken) {
@@ -466,6 +526,17 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
466
526
  try {
467
527
  return await callRemoteTool(name, args ?? {});
468
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
+ }
469
540
  return {
470
541
  content: [{ type: "text", text: `Failed to reach worker: ${err}` }],
471
542
  isError: true,