@ferrlabs/mcp-core 7.0.1 → 8.2.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.
@@ -1,14 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { apiRequest, UnauthorizedError } from '../api-client.js';
2
+ import { apiRequest, UnauthorizedError, UntrustedApiHostError } from '../api-client.js';
3
3
  const mockFetch = vi.fn();
4
4
  vi.stubGlobal('fetch', mockFetch);
5
- const clearPersistedTokenMock = vi.fn();
6
- const clearTokenCacheMock = vi.fn();
7
- vi.mock('../auth/persistence.js', () => ({
8
- clearPersistedToken: () => clearPersistedTokenMock(),
9
- }));
5
+ const invalidateTokenMock = vi.fn();
10
6
  vi.mock('../auth/index.js', () => ({
11
- clearTokenCache: () => clearTokenCacheMock(),
7
+ invalidateToken: (token) => invalidateTokenMock(token),
12
8
  }));
13
9
  function makeResponse(body, status = 200) {
14
10
  const text = body === undefined ? '' : JSON.stringify(body);
@@ -22,8 +18,26 @@ function makeResponse(body, status = 200) {
22
18
  describe('apiRequest', () => {
23
19
  beforeEach(() => {
24
20
  mockFetch.mockReset();
25
- clearPersistedTokenMock.mockReset();
26
- clearTokenCacheMock.mockReset();
21
+ invalidateTokenMock.mockReset();
22
+ invalidateTokenMock.mockResolvedValue(true);
23
+ delete process.env.FERRLABS_MCP_ALLOWED_API_HOSTS;
24
+ });
25
+ it('sends extra headers alongside the defaults', async () => {
26
+ mockFetch.mockResolvedValue(makeResponse({ ok: true }));
27
+ await apiRequest('/agents', { headers: { 'x-ferrfleet-api-version': '2026-08-04' } });
28
+ const init = mockFetch.mock.calls[0][1];
29
+ expect(init.headers['x-ferrfleet-api-version']).toBe('2026-08-04');
30
+ expect(init.headers['Content-Type']).toBe('application/json');
31
+ });
32
+ it('does not let an extra header overwrite the credential headers', async () => {
33
+ mockFetch.mockResolvedValue(makeResponse({ ok: true }));
34
+ await apiRequest('/agents', {
35
+ token: 'real',
36
+ headers: { Authorization: 'Bearer forged', 'x-api-token': 'forged' },
37
+ });
38
+ const init = mockFetch.mock.calls[0][1];
39
+ expect(init.headers['Authorization']).toBe('Bearer real');
40
+ expect(init.headers['x-api-token']).toBe('real');
27
41
  });
28
42
  it('makes a GET request and returns parsed JSON', async () => {
29
43
  mockFetch.mockResolvedValue(makeResponse({ status: 'ok' }));
@@ -56,16 +70,52 @@ describe('apiRequest', () => {
56
70
  const [, init] = mockFetch.mock.calls[0];
57
71
  expect(init.body).toBe(JSON.stringify({ name: 'ci', scopes: ['*'] }));
58
72
  });
59
- it('clears persisted token and cache on 401 with a token', async () => {
73
+ it('hands the rejected token to invalidateToken on a 401', async () => {
60
74
  mockFetch.mockResolvedValue(makeResponse({ error: 'unauthorized' }, 401));
61
75
  await expect(apiRequest('/orgs', { token: 'stale' })).rejects.toBeInstanceOf(UnauthorizedError);
62
- expect(clearPersistedTokenMock).toHaveBeenCalledOnce();
63
- expect(clearTokenCacheMock).toHaveBeenCalledOnce();
76
+ expect(invalidateTokenMock).toHaveBeenCalledWith('stale');
64
77
  });
65
- it('does not clear persisted token on 401 without a token', async () => {
78
+ it('says nothing was cleared when the token did not come from the file', async () => {
79
+ invalidateTokenMock.mockResolvedValue(false);
80
+ mockFetch.mockResolvedValue(makeResponse({ error: 'unauthorized' }, 401));
81
+ await expect(apiRequest('/orgs', { token: 'from-env' })).rejects.toThrow(/was not read from the token file/);
82
+ });
83
+ it('does not invalidate anything on a 401 without a token', async () => {
66
84
  mockFetch.mockResolvedValue(makeResponse({ error: 'unauthorized' }, 401));
67
85
  await expect(apiRequest('/orgs')).rejects.toThrow('unauthorized');
68
- expect(clearPersistedTokenMock).not.toHaveBeenCalled();
69
- expect(clearTokenCacheMock).not.toHaveBeenCalled();
86
+ expect(invalidateTokenMock).not.toHaveBeenCalled();
87
+ });
88
+ it('refuses to send a token to a host outside the allowlist', async () => {
89
+ await expect(apiRequest('/orgs', { token: 'secret', baseUrl: 'https://evil.example.com' })).rejects.toBeInstanceOf(UntrustedApiHostError);
90
+ expect(mockFetch).not.toHaveBeenCalled();
91
+ });
92
+ it('refuses to send a token in clear over http to a remote host', async () => {
93
+ await expect(apiRequest('/orgs', { token: 'secret', baseUrl: 'http://api.ferrtrack.com' })).rejects.toThrow(/in clear/);
94
+ expect(mockFetch).not.toHaveBeenCalled();
95
+ });
96
+ it('allows an unlisted host once FERRLABS_MCP_ALLOWED_API_HOSTS names it', async () => {
97
+ process.env.FERRLABS_MCP_ALLOWED_API_HOSTS = 'api.selfhosted.example';
98
+ mockFetch.mockResolvedValue(makeResponse({ ok: true }));
99
+ await apiRequest('/orgs', { token: 'secret', baseUrl: 'https://api.selfhosted.example' });
100
+ expect(mockFetch).toHaveBeenCalledOnce();
101
+ });
102
+ it('allows a loopback base URL over http for local development', async () => {
103
+ mockFetch.mockResolvedValue(makeResponse({ ok: true }));
104
+ await apiRequest('/orgs', { token: 'secret', baseUrl: 'http://127.0.0.1:3000' });
105
+ expect(mockFetch).toHaveBeenCalledOnce();
106
+ });
107
+ it('does not gate an unauthenticated call on the host allowlist', async () => {
108
+ mockFetch.mockResolvedValue(makeResponse({ ok: true }));
109
+ await apiRequest('/stats', { baseUrl: 'https://anything.example.com' });
110
+ expect(mockFetch).toHaveBeenCalledOnce();
111
+ });
112
+ it('asks fetch not to follow redirects', async () => {
113
+ mockFetch.mockResolvedValue(makeResponse({ ok: true }));
114
+ await apiRequest('/orgs', { token: 'secret' });
115
+ expect(mockFetch.mock.calls[0][1].redirect).toBe('manual');
116
+ });
117
+ it('refuses a redirect rather than walking the credential to Location', async () => {
118
+ mockFetch.mockResolvedValue(makeResponse(undefined, 302));
119
+ await expect(apiRequest('/orgs', { token: 'secret' })).rejects.toBeInstanceOf(UntrustedApiHostError);
70
120
  });
71
121
  });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=tool-text.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-text.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/tool-text.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { toToolText, maxToolBytes } from '../tool-text.js';
3
+ describe('toToolText', () => {
4
+ const originalEnv = process.env;
5
+ beforeEach(() => {
6
+ process.env = { ...originalEnv };
7
+ delete process.env.FERRLABS_MCP_MAX_TOOL_BYTES;
8
+ });
9
+ afterEach(() => {
10
+ process.env = originalEnv;
11
+ });
12
+ it('serialises compactly, without the indentation that doubled the cost', () => {
13
+ const text = toToolText({ a: 1, b: [2, 3] });
14
+ expect(text).toBe('{"a":1,"b":[2,3]}');
15
+ expect(text).not.toContain('\n');
16
+ });
17
+ it('passes a payload under the ceiling through untouched', () => {
18
+ const value = { items: ['a', 'b'] };
19
+ expect(toToolText(value)).toBe(JSON.stringify(value));
20
+ });
21
+ it('caps a payload over the ceiling and marks it as incomplete', () => {
22
+ process.env.FERRLABS_MCP_MAX_TOOL_BYTES = '200';
23
+ const text = toToolText({ blob: 'x'.repeat(5000) });
24
+ expect(Buffer.byteLength(text, 'utf8')).toBeLessThan(1000);
25
+ expect(text).toMatch(/\[truncated: \d+ of \d+ bytes dropped/);
26
+ expect(text).toMatch(/must not be read as the whole result/);
27
+ });
28
+ it('names the argument that would narrow the result', () => {
29
+ process.env.FERRLABS_MCP_MAX_TOOL_BYTES = '100';
30
+ const text = toToolText({ rows: 'y'.repeat(2000) }, { narrowWith: 'Pass a smaller limit.' });
31
+ expect(text).toContain('Pass a smaller limit.');
32
+ });
33
+ it('keeps the start of the payload so the shape stays readable', () => {
34
+ process.env.FERRLABS_MCP_MAX_TOOL_BYTES = '60';
35
+ const text = toToolText({ id: 'run-1', events: 'z'.repeat(500) });
36
+ expect(text.startsWith('{"id":"run-1"')).toBe(true);
37
+ });
38
+ it('does not cut a multi-byte character into an invalid sequence', () => {
39
+ process.env.FERRLABS_MCP_MAX_TOOL_BYTES = '25';
40
+ const text = toToolText({ n: 'é'.repeat(200) });
41
+ // A naive slice on the byte buffer lands mid-sequence and yields U+FFFD.
42
+ expect(text.slice(0, 25)).not.toContain('\uFFFD');
43
+ });
44
+ it('falls back to the default ceiling when the env var is not a positive number', () => {
45
+ process.env.FERRLABS_MCP_MAX_TOOL_BYTES = 'nonsense';
46
+ expect(maxToolBytes()).toBe(100_000);
47
+ process.env.FERRLABS_MCP_MAX_TOOL_BYTES = '-5';
48
+ expect(maxToolBytes()).toBe(100_000);
49
+ });
50
+ it('returns a string payload as-is rather than quoting it', () => {
51
+ expect(toToolText('already text')).toBe('already text');
52
+ });
53
+ });
@@ -8,10 +8,26 @@ interface RequestOptions {
8
8
  * tool call. Falls back to `API_URL` env var, then `api.ferrlabs.com`.
9
9
  */
10
10
  baseUrl?: string;
11
+ /**
12
+ * Extra headers merged into the request, after the defaults and before the
13
+ * credential headers. Product APIs that negotiate a contract version use
14
+ * this to send their own header, e.g. `x-ferrfleet-api-version`.
15
+ */
16
+ headers?: Record<string, string>;
11
17
  }
12
18
  export declare class UnauthorizedError extends Error {
13
19
  constructor(message: string);
14
20
  }
21
+ export declare class UntrustedApiHostError extends Error {
22
+ constructor(message: string);
23
+ }
24
+ /**
25
+ * Refuse to attach a credential to a request leaving for an unexpected host,
26
+ * or travelling in clear off the loopback interface.
27
+ *
28
+ * @throws {UntrustedApiHostError}
29
+ */
30
+ export declare function assertCredentialTarget(url: string): void;
15
31
  export declare function apiRequest<T>(path: string, options?: RequestOptions): Promise<T>;
16
32
  export {};
17
33
  //# sourceMappingURL=api-client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAMA,UAAU,cAAc;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED,wBAAsB,UAAU,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,CAAC,CAAC,CAmD1F"}
1
+ {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAwBA,UAAU,cAAc;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAUD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAsBxD;AAED,wBAAsB,UAAU,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,CAAC,CAAC,CAqE1F"}
@@ -1,36 +1,102 @@
1
- import { clearPersistedToken } from './auth/persistence.js';
2
- import { clearTokenCache } from './auth/index.js';
1
+ import { invalidateToken } from './auth/index.js';
3
2
  import { fetchWithTimeout } from './http.js';
4
3
  const DEFAULT_API_URL = process.env.API_URL ?? 'https://api.ferrlabs.com';
4
+ /**
5
+ * Hosts a FerrLabs credential may be sent to.
6
+ *
7
+ * Every base URL in this repo comes from an environment variable, so without
8
+ * a check a typo or a half-controlled variable forwards the token to whatever
9
+ * host it names. Extend with `FERRLABS_MCP_ALLOWED_API_HOSTS` (comma
10
+ * separated) for a self-hosted deployment.
11
+ */
12
+ const DEFAULT_ALLOWED_API_HOSTS = [
13
+ 'api.ferrlabs.com',
14
+ 'api.ferrvault.com',
15
+ 'api.ferrtrack.com',
16
+ 'api.ferrgrowth.com',
17
+ 'api.ferrfleet.com',
18
+ 'api.ferrlens.com',
19
+ ];
20
+ const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '::1'];
5
21
  export class UnauthorizedError extends Error {
6
22
  constructor(message) {
7
23
  super(message);
8
24
  this.name = 'UnauthorizedError';
9
25
  }
10
26
  }
27
+ export class UntrustedApiHostError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = 'UntrustedApiHostError';
31
+ }
32
+ }
33
+ function allowedApiHosts() {
34
+ const extra = (process.env.FERRLABS_MCP_ALLOWED_API_HOSTS ?? '')
35
+ .split(',')
36
+ .map((h) => h.trim().toLowerCase())
37
+ .filter((h) => h.length > 0);
38
+ return [...DEFAULT_ALLOWED_API_HOSTS, ...extra];
39
+ }
40
+ /**
41
+ * Refuse to attach a credential to a request leaving for an unexpected host,
42
+ * or travelling in clear off the loopback interface.
43
+ *
44
+ * @throws {UntrustedApiHostError}
45
+ */
46
+ export function assertCredentialTarget(url) {
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(url);
50
+ }
51
+ catch {
52
+ throw new UntrustedApiHostError(`refusing to send a token to a malformed URL: ${url}`);
53
+ }
54
+ const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
55
+ const loopback = LOOPBACK_HOSTS.includes(host);
56
+ if (!loopback && parsed.protocol !== 'https:') {
57
+ throw new UntrustedApiHostError(`refusing to send a token in clear over ${parsed.protocol}// to ${host}`);
58
+ }
59
+ if (!loopback && !allowedApiHosts().includes(host)) {
60
+ throw new UntrustedApiHostError(`refusing to send a token to ${host}, which is not an allowed FerrLabs API host. Set FERRLABS_MCP_ALLOWED_API_HOSTS to permit it.`);
61
+ }
62
+ }
11
63
  export async function apiRequest(path, options = {}) {
12
- const { method = 'GET', body, token, baseUrl } = options;
64
+ const { method = 'GET', body, token, baseUrl, headers: extraHeaders } = options;
13
65
  const base = baseUrl ?? DEFAULT_API_URL;
66
+ const url = `${base}${path}`;
67
+ if (token)
68
+ assertCredentialTarget(url);
14
69
  const headers = {
15
70
  'Content-Type': 'application/json',
16
71
  'User-Agent': 'ferrlabs-mcp/4.0.0',
17
72
  };
73
+ for (const [name, value] of Object.entries(extraHeaders ?? {})) {
74
+ headers[name] = value;
75
+ }
18
76
  if (token) {
19
77
  headers['x-api-token'] = token;
20
78
  headers['Authorization'] = `Bearer ${token}`;
21
79
  }
22
- const res = await fetchWithTimeout(`${base}${path}`, {
80
+ const res = await fetchWithTimeout(url, {
23
81
  method,
24
82
  headers,
25
83
  body: body ? JSON.stringify(body) : undefined,
84
+ // Redirects are followed with the credential headers still attached, so a
85
+ // 3xx off an API host walks the token to wherever Location points. No
86
+ // route in these APIs redirects, so treat one as the anomaly it is.
87
+ redirect: 'manual',
26
88
  });
89
+ if (res.status >= 300 && res.status < 400) {
90
+ throw new UntrustedApiHostError(`refusing to follow a redirect (HTTP ${res.status}) returned by ${url}`);
91
+ }
27
92
  if (res.status === 204) {
28
93
  return undefined;
29
94
  }
30
95
  if (res.status === 401 && token) {
31
- await clearPersistedToken();
32
- clearTokenCache();
33
- throw new UnauthorizedError('Stored token rejected by the FerrLabs API (likely revoked, expired, or issued under an incompatible format). The token has been cleared — retry the call to trigger a fresh OAuth login.');
96
+ const cleared = await invalidateToken(token);
97
+ throw new UnauthorizedError(cleared
98
+ ? 'Stored token rejected by the FerrLabs API (likely revoked or expired). The stored token has been cleared — retry the call to trigger a fresh OAuth login.'
99
+ : 'Token rejected by the FerrLabs API. It was not read from the token file, so nothing was cleared: check the token you supplied.');
34
100
  }
35
101
  const raw = await res.text();
36
102
  let data = undefined;
@@ -37,3 +37,51 @@ describe('getToken', () => {
37
37
  expect(a).toBe('fl_cached');
38
38
  });
39
39
  });
40
+ describe('invalidateToken', () => {
41
+ const originalEnv = process.env;
42
+ const clearPersistedTokenMock = vi.fn();
43
+ beforeEach(() => {
44
+ vi.resetModules();
45
+ clearPersistedTokenMock.mockReset();
46
+ process.env = { ...originalEnv };
47
+ delete process.env.FERRLABS_API_TOKEN;
48
+ delete process.env.FERRFLOW_API_TOKEN;
49
+ delete process.env.FERRLABS_MCP_MODE;
50
+ vi.doMock('../persistence.js', () => ({
51
+ clearPersistedToken: () => clearPersistedTokenMock(),
52
+ readPersistedToken: () => Promise.resolve('fl_from_file'),
53
+ writePersistedToken: () => Promise.resolve(),
54
+ }));
55
+ });
56
+ afterEach(() => {
57
+ process.env = originalEnv;
58
+ vi.doUnmock('../persistence.js');
59
+ });
60
+ it('deletes the token file when the rejected token came from it', async () => {
61
+ const { getToken, invalidateToken } = await import('../index.js');
62
+ const token = await getToken();
63
+ expect(token).toBe('fl_from_file');
64
+ await expect(invalidateToken(token)).resolves.toBe(true);
65
+ expect(clearPersistedTokenMock).toHaveBeenCalledOnce();
66
+ });
67
+ it('leaves the token file alone when the rejected token came from the environment', async () => {
68
+ process.env.FERRLABS_API_TOKEN = 'fl_from_env';
69
+ const { getToken, invalidateToken } = await import('../index.js');
70
+ const token = await getToken();
71
+ await expect(invalidateToken(token)).resolves.toBe(false);
72
+ expect(clearPersistedTokenMock).not.toHaveBeenCalled();
73
+ });
74
+ it('ignores a token this process never cached, so one bad bearer cannot wipe the file', async () => {
75
+ const { getToken, invalidateToken } = await import('../index.js');
76
+ await getToken();
77
+ await expect(invalidateToken('someone-elses-bearer')).resolves.toBe(false);
78
+ expect(clearPersistedTokenMock).not.toHaveBeenCalled();
79
+ });
80
+ it('drops the cache so the next call re-reads the source', async () => {
81
+ const { getToken, invalidateToken } = await import('../index.js');
82
+ const first = await getToken();
83
+ await invalidateToken(first);
84
+ process.env.FERRLABS_API_TOKEN = 'fl_replacement';
85
+ expect(await getToken()).toBe('fl_replacement');
86
+ });
87
+ });
@@ -62,3 +62,47 @@ describe('bindCallbackListener (port fallback)', () => {
62
62
  expect(settled.message).toMatch(/state mismatch/);
63
63
  });
64
64
  });
65
+ describe('bindCallbackListener (port release)', () => {
66
+ it('frees the port on close, so a later flow can bind it again', async () => {
67
+ const port = await freePort();
68
+ const first = await __testOnly.bindCallbackListener(port, 'st');
69
+ first.callback.catch(() => undefined);
70
+ expect(first.port).toBe(port);
71
+ first.close();
72
+ // Rebinding the same port is the only honest proof it was released:
73
+ // an unclosed listener makes this throw EADDRINUSE, which is the bug
74
+ // the flow's own error message told users to clear by hand.
75
+ const second = await __testOnly.bindCallbackListener(port, 'st');
76
+ second.callback.catch(() => undefined);
77
+ expect(second.port).toBe(port);
78
+ second.close();
79
+ });
80
+ it('tolerates close being called twice', async () => {
81
+ const port = await freePort();
82
+ const bound = await __testOnly.bindCallbackListener(port, 'st');
83
+ bound.callback.catch(() => undefined);
84
+ bound.close();
85
+ expect(() => bound.close()).not.toThrow();
86
+ });
87
+ it('releases the port after it has actually served a connection', async () => {
88
+ const { Agent, request } = await import('node:http');
89
+ const port = await freePort();
90
+ const bound = await __testOnly.bindCallbackListener(port, 'st');
91
+ bound.callback.catch(() => undefined);
92
+ const agent = new Agent({ keepAlive: true, maxSockets: 1 });
93
+ await new Promise((resolve, reject) => {
94
+ const req = request({ host: '127.0.0.1', port, path: '/nope', agent }, (res) => {
95
+ res.resume();
96
+ res.on('end', () => resolve());
97
+ });
98
+ req.on('error', reject);
99
+ req.end();
100
+ });
101
+ bound.close();
102
+ const again = await __testOnly.bindCallbackListener(port, 'st');
103
+ again.callback.catch(() => undefined);
104
+ expect(again.port).toBe(port);
105
+ again.close();
106
+ agent.destroy();
107
+ });
108
+ });
@@ -1,3 +1,15 @@
1
1
  export declare function getToken(): Promise<string>;
2
2
  export declare function clearTokenCache(): void;
3
+ /**
4
+ * Drop a token the API has just rejected.
5
+ *
6
+ * Only deletes the token file when the rejected token is the one this process
7
+ * read from it. A token supplied through `FERRLABS_API_TOKEN`, or a bearer
8
+ * that arrived on a single HTTP request, is not ours to delete: in HTTP mode
9
+ * one client sending a bad bearer would otherwise wipe the server's stored
10
+ * credential for everyone.
11
+ *
12
+ * Returns whether the persisted file was removed.
13
+ */
14
+ export declare function invalidateToken(token: string): Promise<boolean>;
3
15
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAOA,wBAAsB,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAuChD;AAED,wBAAgB,eAAe,IAAI,IAAI,CAGtC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAUA,wBAAsB,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CA0ChD;AAED,wBAAgB,eAAe,IAAI,IAAI,CAItC;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAWrE"}
@@ -1,7 +1,8 @@
1
- import { readPersistedToken, writePersistedToken } from './persistence.js';
1
+ import { clearPersistedToken, readPersistedToken, writePersistedToken } from './persistence.js';
2
2
  import { runLoopbackOauthFlow } from './oauth.js';
3
3
  import { getRequestBearerToken } from './context.js';
4
4
  let cached = null;
5
+ let cachedSource = null;
5
6
  let inFlight = null;
6
7
  export async function getToken() {
7
8
  const perRequest = getRequestBearerToken();
@@ -15,11 +16,13 @@ export async function getToken() {
15
16
  const envToken = process.env.FERRLABS_API_TOKEN ?? process.env.FERRFLOW_API_TOKEN;
16
17
  if (envToken) {
17
18
  cached = envToken;
19
+ cachedSource = 'env';
18
20
  return envToken;
19
21
  }
20
22
  const persisted = await readPersistedToken();
21
23
  if (persisted) {
22
24
  cached = persisted;
25
+ cachedSource = 'file';
23
26
  return persisted;
24
27
  }
25
28
  if (process.env.FERRLABS_MCP_NO_OAUTH === '1') {
@@ -30,6 +33,7 @@ export async function getToken() {
30
33
  const { token } = await runLoopbackOauthFlow();
31
34
  await writePersistedToken(token);
32
35
  cached = token;
36
+ cachedSource = 'oauth';
33
37
  return token;
34
38
  })().finally(() => {
35
39
  inFlight = null;
@@ -39,5 +43,28 @@ export async function getToken() {
39
43
  }
40
44
  export function clearTokenCache() {
41
45
  cached = null;
46
+ cachedSource = null;
42
47
  inFlight = null;
43
48
  }
49
+ /**
50
+ * Drop a token the API has just rejected.
51
+ *
52
+ * Only deletes the token file when the rejected token is the one this process
53
+ * read from it. A token supplied through `FERRLABS_API_TOKEN`, or a bearer
54
+ * that arrived on a single HTTP request, is not ours to delete: in HTTP mode
55
+ * one client sending a bad bearer would otherwise wipe the server's stored
56
+ * credential for everyone.
57
+ *
58
+ * Returns whether the persisted file was removed.
59
+ */
60
+ export async function invalidateToken(token) {
61
+ if (cached !== token)
62
+ return false;
63
+ const source = cachedSource;
64
+ clearTokenCache();
65
+ if (source === 'file' || source === 'oauth') {
66
+ await clearPersistedToken();
67
+ return true;
68
+ }
69
+ return false;
70
+ }
@@ -10,6 +10,11 @@ interface CallbackResult {
10
10
  interface BoundListener {
11
11
  port: number;
12
12
  callback: Promise<CallbackResult>;
13
+ /**
14
+ * Release the port. Safe to call more than once, and on a listener that
15
+ * already served its callback.
16
+ */
17
+ close: () => void;
13
18
  }
14
19
  /**
15
20
  * Bind a loopback HTTP listener on `port` and return a resolved promise
@@ -1 +1 @@
1
- {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/auth/oauth.ts"],"names":[],"mappings":"AAYA,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"}
1
+ {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/auth/oauth.ts"],"names":[],"mappings":"AAYA,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;IAClC;;;OAGG;IACH,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED;;;;;;;;;GASG;AACH,iBAAS,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAmEzF;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,CA8D3E"}
@@ -80,6 +80,17 @@ function bindCallbackListener(port, expectedState) {
80
80
  rejectCallback(err instanceof Error ? err : new Error(String(err)));
81
81
  }
82
82
  });
83
+ let closed = false;
84
+ const close = () => {
85
+ if (closed)
86
+ return;
87
+ closed = true;
88
+ // `close()` alone waits for open sockets, and a browser that kept the
89
+ // connection alive never yields one, so drop them first. Defensive:
90
+ // the tests cover the release, not this specific path.
91
+ server.closeAllConnections();
92
+ server.close();
93
+ };
83
94
  const onBindError = (err) => {
84
95
  server.removeListener('listening', onListening);
85
96
  rejectBound(err);
@@ -89,7 +100,7 @@ function bindCallbackListener(port, expectedState) {
89
100
  // Once bound successfully, route runtime errors into the callback
90
101
  // promise (e.g. unexpected close after we've started serving).
91
102
  server.on('error', (err) => rejectCallback(err));
92
- resolveBound({ port, callback });
103
+ resolveBound({ port, callback, close });
93
104
  };
94
105
  server.once('error', onBindError);
95
106
  server.once('listening', onListening);
@@ -141,7 +152,7 @@ export async function runLoopbackOauthFlow() {
141
152
  const detail = bindErrors.map((e) => `${e.port}: ${e.error}`).join('; ');
142
153
  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.`);
143
154
  }
144
- const { port, callback: callbackPromise } = bound;
155
+ const { port, callback: callbackPromise, close } = bound;
145
156
  const redirectUri = `http://127.0.0.1:${port}/cb`;
146
157
  const authorizeUrl = new URL(`${AUTH_BASE}/authorize`);
147
158
  authorizeUrl.searchParams.set('client_id', CLIENT_ID);
@@ -151,10 +162,21 @@ export async function runLoopbackOauthFlow() {
151
162
  authorizeUrl.searchParams.set('code_challenge_method', 'S256');
152
163
  authorizeUrl.searchParams.set('state', state);
153
164
  openBrowser(authorizeUrl.toString());
165
+ let timer;
154
166
  const timeoutPromise = new Promise((_, reject) => {
155
- setTimeout(() => reject(new Error(`OAuth flow timed out after ${AUTH_TIMEOUT_MS / 1000}s. If your browser didn't open, visit: ${authorizeUrl.toString()}`)), AUTH_TIMEOUT_MS);
167
+ timer = setTimeout(() => reject(new Error(`OAuth flow timed out after ${AUTH_TIMEOUT_MS / 1000}s. If your browser didn't open, visit: ${authorizeUrl.toString()}`)), AUTH_TIMEOUT_MS);
156
168
  });
157
- const { code } = await Promise.race([callbackPromise, timeoutPromise]);
158
- const token = await exchangeCodeForToken(code, pkce.verifier, redirectUri);
159
- return { token };
169
+ try {
170
+ const { code } = await Promise.race([callbackPromise, timeoutPromise]);
171
+ const token = await exchangeCodeForToken(code, pkce.verifier, redirectUri);
172
+ return { token };
173
+ }
174
+ finally {
175
+ // Every exit closes the listener: a timeout, a CSRF mismatch, a failed
176
+ // exchange, and the happy path. Leaving it bound is what holds ports
177
+ // 54321/54322 until the process dies, which is the failure the bind
178
+ // error above tells the user to go and clear by hand.
179
+ clearTimeout(timer);
180
+ close();
181
+ }
160
182
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { runMcp, type RunMcpOptions } from './runner.js';
2
- export { apiRequest, UnauthorizedError } from './api-client.js';
3
- export { getToken, clearTokenCache } from './auth/index.js';
2
+ export { apiRequest, UnauthorizedError, UntrustedApiHostError } from './api-client.js';
3
+ export { getToken, clearTokenCache, invalidateToken } from './auth/index.js';
4
4
  export { runWithAuthContext, getRequestBearerToken } from './auth/context.js';
5
5
  export { readPackageVersion } from './version.js';
6
6
  export type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ export { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
7
8
  export { fetchWithTimeout, fetchTimeoutMs } from './http.js';
9
+ export { toToolText, maxToolBytes } from './tool-text.js';
8
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAChE,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAC5D,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAClD,YAAY,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACvF,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAClD,YAAY,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,yCAAyC,CAAC;AAC3E,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { runMcp } from './runner.js';
2
- export { apiRequest, UnauthorizedError } from './api-client.js';
3
- export { getToken, clearTokenCache } from './auth/index.js';
2
+ export { apiRequest, UnauthorizedError, UntrustedApiHostError } from './api-client.js';
3
+ export { getToken, clearTokenCache, invalidateToken } from './auth/index.js';
4
4
  export { runWithAuthContext, getRequestBearerToken } from './auth/context.js';
5
5
  export { readPackageVersion } from './version.js';
6
+ export { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
6
7
  export { fetchWithTimeout, fetchTimeoutMs } from './http.js';
8
+ export { toToolText, maxToolBytes } from './tool-text.js';
@@ -0,0 +1,12 @@
1
+ export declare function maxToolBytes(): number;
2
+ interface ToolTextOptions {
3
+ /**
4
+ * What the caller can change to get less back, e.g. "pass a smaller
5
+ * `limit`". Appended to the truncation notice so the assistant can recover
6
+ * on its own instead of retrying the same call.
7
+ */
8
+ narrowWith?: string;
9
+ }
10
+ export declare function toToolText(value: unknown, options?: ToolTextOptions): string;
11
+ export {};
12
+ //# sourceMappingURL=tool-text.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-text.d.ts","sourceRoot":"","sources":["../src/tool-text.ts"],"names":[],"mappings":"AAEA,wBAAgB,YAAY,IAAI,MAAM,CAKrC;AAED,UAAU,eAAe;IACvB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAyBD,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM,CAkBhF"}
@@ -0,0 +1,46 @@
1
+ const DEFAULT_MAX_TOOL_BYTES = 100_000;
2
+ export function maxToolBytes() {
3
+ const raw = process.env.FERRLABS_MCP_MAX_TOOL_BYTES;
4
+ if (!raw)
5
+ return DEFAULT_MAX_TOOL_BYTES;
6
+ const parsed = Number(raw);
7
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_TOOL_BYTES;
8
+ }
9
+ /**
10
+ * Serialise a tool payload for the model, with a ceiling.
11
+ *
12
+ * Compact rather than pretty-printed: indentation roughly doubles the token
13
+ * cost and a model reader gains nothing from it.
14
+ *
15
+ * Truncation is announced in the returned text. Silently cutting a payload is
16
+ * worse than sending too much, because the assistant then reasons over a
17
+ * fragment it believes is complete.
18
+ */
19
+ /**
20
+ * Cut to at most `maxBytes`, stepping back off a UTF-8 continuation byte so
21
+ * the last character is never left half-decoded as U+FFFD.
22
+ */
23
+ function sliceUtf8(input, maxBytes) {
24
+ const buf = Buffer.from(input, 'utf8');
25
+ if (buf.length <= maxBytes)
26
+ return input;
27
+ let end = maxBytes;
28
+ while (end > 0 && (buf[end] & 0b1100_0000) === 0b1000_0000)
29
+ end--;
30
+ return buf.subarray(0, end).toString('utf8');
31
+ }
32
+ export function toToolText(value, options = {}) {
33
+ const serialised = typeof value === 'string' ? value : JSON.stringify(value);
34
+ if (serialised === undefined)
35
+ return 'null';
36
+ const budget = maxToolBytes();
37
+ const total = Buffer.byteLength(serialised, 'utf8');
38
+ if (total <= budget)
39
+ return serialised;
40
+ const kept = sliceUtf8(serialised, budget);
41
+ const dropped = total - Buffer.byteLength(kept, 'utf8');
42
+ const narrow = options.narrowWith ? ` ${options.narrowWith}` : '';
43
+ return (`${kept}\n\n[truncated: ${dropped} of ${total} bytes dropped, ` +
44
+ `so this payload is incomplete and must not be read as the whole result.${narrow} ` +
45
+ `Raise FERRLABS_MCP_MAX_TOOL_BYTES to change the ${budget}-byte ceiling.]`);
46
+ }
@@ -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;AAYzE,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAM3D;AAED,wBAAgB,oBAAoB,CAClC,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,EAAE,GAClB,MAAM,GAAG,SAAS,CAGpB;AAED,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,OAAO,CAK7F;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAGrF;AAYD,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG;IAC1D,eAAe,EAAE,MAAM,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;CACd,CAQA;AAED,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;AAoCD,wBAAsB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAiJ5E"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAYzE,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAM3D;AAED,wBAAgB,oBAAoB,CAClC,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,SAAS,EAAE,MAAM,EAAE,GAClB,MAAM,GAAG,SAAS,CAGpB;AAED,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,OAAO,CAK7F;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAGrF;AAYD,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG;IAC1D,eAAe,EAAE,MAAM,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;CACd,CAQA;AAED,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;AAoCD,wBAAsB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CA+J5E"}
@@ -133,6 +133,11 @@ export async function startHttpServer(opts) {
133
133
  }));
134
134
  return;
135
135
  }
136
+ // In stateless mode nothing ever retains what we build here, so the pair
137
+ // has to be torn down once the response is written. Left alone, every
138
+ // request adds an McpServer and a transport that live until the process
139
+ // does.
140
+ let disposable;
136
141
  if (!transport) {
137
142
  let createdSessionId;
138
143
  transport = new StreamableHTTPServerTransport({
@@ -151,9 +156,19 @@ export async function startHttpServer(opts) {
151
156
  };
152
157
  const server = opts.createServer();
153
158
  await server.connect(transport);
159
+ if (stateless)
160
+ disposable = { server, transport };
154
161
  }
155
162
  const body = await readJsonBody(req);
156
- await runWithAuthContext({ bearerToken }, () => transport.handleRequest(req, res, body));
163
+ try {
164
+ await runWithAuthContext({ bearerToken }, () => transport.handleRequest(req, res, body));
165
+ }
166
+ finally {
167
+ if (disposable) {
168
+ await disposable.transport.close().catch(() => undefined);
169
+ await disposable.server.close().catch(() => undefined);
170
+ }
171
+ }
157
172
  }
158
173
  function applyCorsHeaders(req, res) {
159
174
  res.setHeader('Vary', 'Origin');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferrlabs/mcp-core",
3
- "version": "7.0.1",
3
+ "version": "8.2.1",
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",