@ferrlabs/mcp-core 6.3.1 → 6.3.3

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.
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { generatePkcePair } from '../oauth.js';
2
+ import { generatePkcePair, __testOnly } from '../oauth.js';
3
3
  import { createHash } from 'node:crypto';
4
+ import { createServer } from 'node:http';
4
5
  describe('generatePkcePair', () => {
5
6
  it('returns a verifier between 43 and 128 chars (RFC 7636)', () => {
6
7
  const { verifier } = generatePkcePair();
@@ -24,3 +25,40 @@ describe('generatePkcePair', () => {
24
25
  expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
25
26
  });
26
27
  });
28
+ function closeServer(server) {
29
+ return new Promise((resolve) => server.close(() => resolve()));
30
+ }
31
+ async function freePort() {
32
+ const probe = createServer();
33
+ await new Promise((resolve) => probe.listen(0, '127.0.0.1', () => resolve()));
34
+ const addr = probe.address();
35
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
36
+ await closeServer(probe);
37
+ return port;
38
+ }
39
+ describe('bindCallbackListener (port fallback)', () => {
40
+ it('rejects synchronously with EADDRINUSE when the port is already bound', async () => {
41
+ const port = await freePort();
42
+ const squatter = createServer();
43
+ await new Promise((resolve) => squatter.listen(port, '127.0.0.1', () => resolve()));
44
+ try {
45
+ await expect(__testOnly.bindCallbackListener(port, 'st')).rejects.toThrow(/EADDRINUSE/);
46
+ }
47
+ finally {
48
+ await closeServer(squatter);
49
+ }
50
+ });
51
+ it('resolves with the bound port when the socket is free', async () => {
52
+ const port = await freePort();
53
+ const bound = await __testOnly.bindCallbackListener(port, 'st');
54
+ expect(bound.port).toBe(port);
55
+ // Attach a catch handler *before* triggering the rejection so the
56
+ // unhandled-rejection guard in vitest stays quiet.
57
+ const callbackSettled = bound.callback.catch((err) => err);
58
+ const res = await fetch(`http://127.0.0.1:${port}/cb?code=x&state=wrong`);
59
+ expect(res.status).toBe(400);
60
+ const settled = await callbackSettled;
61
+ expect(settled).toBeInstanceOf(Error);
62
+ expect(settled.message).toMatch(/state mismatch/);
63
+ });
64
+ });
@@ -3,10 +3,32 @@ interface PkcePair {
3
3
  challenge: string;
4
4
  }
5
5
  export declare function generatePkcePair(): PkcePair;
6
+ interface CallbackResult {
7
+ code: string;
8
+ state: string;
9
+ }
10
+ interface BoundListener {
11
+ port: number;
12
+ callback: Promise<CallbackResult>;
13
+ }
14
+ /**
15
+ * Bind a loopback HTTP listener on `port` and return a resolved promise
16
+ * only once the socket is actually `listening`. Rejects with the bind
17
+ * error (e.g. `EADDRINUSE`) before returning, so callers can synchronously
18
+ * fall back to a different port via `await` + `try/catch`.
19
+ *
20
+ * The returned `callback` promise lives for the duration of the OAuth
21
+ * flow and resolves when `/cb` is hit with a matching `state`, or rejects
22
+ * on CSRF mismatch / internal error.
23
+ */
24
+ declare function bindCallbackListener(port: number, expectedState: string): Promise<BoundListener>;
6
25
  export interface RunLoopbackFlowResult {
7
26
  token: string;
8
27
  scope?: string;
9
28
  }
29
+ export declare const __testOnly: {
30
+ bindCallbackListener: typeof bindCallbackListener;
31
+ };
10
32
  export declare function runLoopbackOauthFlow(): Promise<RunLoopbackFlowResult>;
11
33
  export {};
12
34
  //# sourceMappingURL=oauth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/auth/oauth.ts"],"names":[],"mappings":"AAWA,UAAU,QAAQ;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,gBAAgB,IAAI,QAAQ,CAI3C;AA4FD,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,qBAAqB,CAAC,CA+C3E"}
1
+ {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/auth/oauth.ts"],"names":[],"mappings":"AAWA,UAAU,QAAQ;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,gBAAgB,IAAI,QAAQ,CAI3C;AAwBD,UAAU,cAAc;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC,cAAc,CAAC,CAAC;CACnC;AAED;;;;;;;;;GASG;AACH,iBAAS,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAwDzF;AA4BD,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,UAAU;;CAEtB,CAAC;AAEF,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAoD3E"}
@@ -32,8 +32,24 @@ function openBrowser(url) {
32
32
  });
33
33
  child.unref();
34
34
  }
35
- function listenForCallback(port, expectedState) {
36
- return new Promise((resolve, reject) => {
35
+ /**
36
+ * Bind a loopback HTTP listener on `port` and return a resolved promise
37
+ * only once the socket is actually `listening`. Rejects with the bind
38
+ * error (e.g. `EADDRINUSE`) before returning, so callers can synchronously
39
+ * fall back to a different port via `await` + `try/catch`.
40
+ *
41
+ * The returned `callback` promise lives for the duration of the OAuth
42
+ * flow and resolves when `/cb` is hit with a matching `state`, or rejects
43
+ * on CSRF mismatch / internal error.
44
+ */
45
+ function bindCallbackListener(port, expectedState) {
46
+ return new Promise((resolveBound, rejectBound) => {
47
+ let resolveCallback;
48
+ let rejectCallback;
49
+ const callback = new Promise((res, rej) => {
50
+ resolveCallback = res;
51
+ rejectCallback = rej;
52
+ });
37
53
  const server = createServer((req, res) => {
38
54
  try {
39
55
  const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
@@ -49,21 +65,33 @@ function listenForCallback(port, expectedState) {
49
65
  }
50
66
  if (state !== expectedState) {
51
67
  res.writeHead(400).end('state mismatch');
52
- reject(new Error('OAuth callback state mismatch (CSRF protection triggered)'));
68
+ rejectCallback(new Error('OAuth callback state mismatch (CSRF protection triggered)'));
53
69
  server.close();
54
70
  return;
55
71
  }
56
72
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', Connection: 'close' });
57
73
  res.end("<!doctype html><html><body style='font-family:sans-serif;padding:2rem;color:#1e293b'><h1>Authorized</h1><p>You can close this tab and return to your terminal.</p></body></html>");
58
74
  setImmediate(() => server.close());
59
- resolve({ code, state });
75
+ resolveCallback({ code, state });
60
76
  }
61
77
  catch (err) {
62
78
  res.writeHead(500).end('Internal error');
63
- reject(err instanceof Error ? err : new Error(String(err)));
79
+ rejectCallback(err instanceof Error ? err : new Error(String(err)));
64
80
  }
65
81
  });
66
- server.on('error', reject);
82
+ const onBindError = (err) => {
83
+ server.removeListener('listening', onListening);
84
+ rejectBound(err);
85
+ };
86
+ const onListening = () => {
87
+ server.removeListener('error', onBindError);
88
+ // Once bound successfully, route runtime errors into the callback
89
+ // promise (e.g. unexpected close after we've started serving).
90
+ server.on('error', (err) => rejectCallback(err));
91
+ resolveBound({ port, callback });
92
+ };
93
+ server.once('error', onBindError);
94
+ server.once('listening', onListening);
67
95
  server.listen(port, '127.0.0.1');
68
96
  });
69
97
  }
@@ -87,24 +115,32 @@ async function exchangeCodeForToken(code, codeVerifier, redirectUri) {
87
115
  throw new Error('OAuth exchange returned no token');
88
116
  return data.token;
89
117
  }
118
+ export const __testOnly = {
119
+ bindCallbackListener,
120
+ };
90
121
  export async function runLoopbackOauthFlow() {
91
122
  const pkce = generatePkcePair();
92
123
  const state = generateState();
93
- let port = null;
94
- let callbackPromise = null;
124
+ let bound = null;
125
+ const bindErrors = [];
95
126
  for (const candidate of REDIRECT_PORTS) {
96
127
  try {
97
- callbackPromise = listenForCallback(candidate, state);
98
- port = candidate;
128
+ // eslint-disable-next-line no-await-in-loop -- intentional: probe ports sequentially
129
+ bound = await bindCallbackListener(candidate, state);
99
130
  break;
100
131
  }
101
- catch {
102
- callbackPromise = null;
132
+ catch (err) {
133
+ bindErrors.push({
134
+ port: candidate,
135
+ error: err instanceof Error ? err.message : String(err),
136
+ });
103
137
  }
104
138
  }
105
- if (port === null || !callbackPromise) {
106
- throw new Error(`Could not bind any of the loopback ports (${REDIRECT_PORTS.join(', ')}). Close whatever is using them and retry.`);
139
+ if (!bound) {
140
+ const detail = bindErrors.map((e) => `${e.port}: ${e.error}`).join('; ');
141
+ throw new Error(`Could not bind any of the loopback ports (${REDIRECT_PORTS.join(', ')}). ${detail}. Close whatever is holding the ports (commonly an MCP subprocess from another Claude session stuck in OAuth), then retry.`);
107
142
  }
143
+ const { port, callback: callbackPromise } = bound;
108
144
  const redirectUri = `http://127.0.0.1:${port}/cb`;
109
145
  const authorizeUrl = new URL(`${AUTH_BASE}/authorize`);
110
146
  authorizeUrl.searchParams.set('client_id', CLIENT_ID);
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAiBzE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,YAAY,EAAE,MAAM,SAAS,CAAC;IAC9B;;;;;;;;;;;;OAYG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAoBD,wBAAsB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuH5E"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAiBzE,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,YAAY,EAAE,MAAM,SAAS,CAAC;IAC9B;;;;;;;;;;;;OAYG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAoBD,wBAAsB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA6I5E"}
@@ -106,7 +106,25 @@ export async function startHttpServer(opts) {
106
106
  const body = await readJsonBody(req);
107
107
  await runWithAuthContext({ bearerToken }, () => transport.handleRequest(req, res, body));
108
108
  }
109
+ function applyCorsHeaders(req, res) {
110
+ const origin = req.headers.origin ?? '*';
111
+ res.setHeader('Access-Control-Allow-Origin', origin);
112
+ res.setHeader('Vary', 'Origin');
113
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
114
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
115
+ const requested = req.headers['access-control-request-headers'] ??
116
+ 'authorization, content-type, mcp-session-id, mcp-protocol-version';
117
+ res.setHeader('Access-Control-Allow-Headers', requested);
118
+ res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id, www-authenticate');
119
+ res.setHeader('Access-Control-Max-Age', '86400');
120
+ }
109
121
  const httpServer = createHttpServer((req, res) => {
122
+ applyCorsHeaders(req, res);
123
+ if (req.method === 'OPTIONS') {
124
+ res.writeHead(204);
125
+ res.end();
126
+ return;
127
+ }
110
128
  const url = req.url ?? '/';
111
129
  if (url === '/health' || url === '/livez' || url === '/readyz') {
112
130
  res.writeHead(200, { 'Content-Type': 'application/json' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferrlabs/mcp-core",
3
- "version": "6.3.1",
3
+ "version": "6.3.3",
4
4
  "description": "Shared core for FerrLabs MCP servers — auth, transports (stdio + Streamable HTTP), api-client, runner",
5
5
  "license": "MPL-2.0",
6
6
  "type": "module",