@ferrlabs/mcp-core 6.5.0 → 6.5.4

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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=http.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/http.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { fetchWithTimeout, fetchTimeoutMs } from '../http.js';
3
+ afterEach(() => {
4
+ vi.unstubAllGlobals();
5
+ delete process.env.FERRLABS_MCP_HTTP_TIMEOUT_MS;
6
+ });
7
+ describe('fetchTimeoutMs', () => {
8
+ it('defaults to 15000ms', () => {
9
+ expect(fetchTimeoutMs()).toBe(15_000);
10
+ });
11
+ it('honours a valid override', () => {
12
+ process.env.FERRLABS_MCP_HTTP_TIMEOUT_MS = '500';
13
+ expect(fetchTimeoutMs()).toBe(500);
14
+ });
15
+ it('ignores a non-positive or non-numeric override', () => {
16
+ process.env.FERRLABS_MCP_HTTP_TIMEOUT_MS = 'nope';
17
+ expect(fetchTimeoutMs()).toBe(15_000);
18
+ process.env.FERRLABS_MCP_HTTP_TIMEOUT_MS = '0';
19
+ expect(fetchTimeoutMs()).toBe(15_000);
20
+ });
21
+ });
22
+ describe('fetchWithTimeout', () => {
23
+ it('aborts and throws a timeout error when the upstream hangs', async () => {
24
+ vi.stubGlobal('fetch', (_url, init) => {
25
+ return new Promise((_resolve, reject) => {
26
+ init.signal?.addEventListener('abort', () => {
27
+ reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
28
+ });
29
+ });
30
+ });
31
+ await expect(fetchWithTimeout('https://example.test', {}, 10)).rejects.toThrow(/timed out/);
32
+ });
33
+ it('passes the signal through and returns the response on success', async () => {
34
+ const ok = { ok: true };
35
+ const seen = {};
36
+ vi.stubGlobal('fetch', (_url, init) => {
37
+ seen.signal = init.signal ?? undefined;
38
+ return Promise.resolve(ok);
39
+ });
40
+ await expect(fetchWithTimeout('https://example.test')).resolves.toBe(ok);
41
+ expect(seen.signal).toBeInstanceOf(AbortSignal);
42
+ });
43
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=version.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/version.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { readPackageVersion } from '../version.js';
7
+ describe('readPackageVersion', () => {
8
+ it('reads the version from the parent-directory package.json of an entry module', () => {
9
+ const dir = mkdtempSync(join(tmpdir(), 'mcp-version-'));
10
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ version: '9.9.9' }), 'utf8');
11
+ const entryUrl = pathToFileURL(join(dir, 'dist', 'index.js')).href;
12
+ expect(readPackageVersion(entryUrl)).toBe('9.9.9');
13
+ });
14
+ it('throws when the package.json has no version field', () => {
15
+ const dir = mkdtempSync(join(tmpdir(), 'mcp-version-'));
16
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x' }), 'utf8');
17
+ const entryUrl = pathToFileURL(join(dir, 'dist', 'index.js')).href;
18
+ expect(() => readPackageVersion(entryUrl)).toThrow(/no version field/);
19
+ });
20
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAKA,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;gBAC9B,OAAO,EAAE,MAAM;CAI5B;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":"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;gBAC9B,OAAO,EAAE,MAAM;CAI5B;AAED,wBAAsB,UAAU,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,CAAC,CAAC,CAmD1F"}
@@ -1,5 +1,6 @@
1
1
  import { clearPersistedToken } from './auth/persistence.js';
2
2
  import { clearTokenCache } from './auth/index.js';
3
+ import { fetchWithTimeout } from './http.js';
3
4
  const DEFAULT_API_URL = process.env.API_URL ?? 'https://api.ferrlabs.com';
4
5
  export class UnauthorizedError extends Error {
5
6
  constructor(message) {
@@ -18,7 +19,7 @@ export async function apiRequest(path, options = {}) {
18
19
  headers['x-api-token'] = token;
19
20
  headers['Authorization'] = `Bearer ${token}`;
20
21
  }
21
- const res = await fetch(`${base}${path}`, {
22
+ const res = await fetchWithTimeout(`${base}${path}`, {
22
23
  method,
23
24
  headers,
24
25
  body: body ? JSON.stringify(body) : undefined,
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=persistence.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persistence.test.d.ts","sourceRoot":"","sources":["../../../src/auth/__tests__/persistence.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, rmSync, statSync } from 'node:fs';
3
+ import { tmpdir, platform } from 'node:os';
4
+ import { join } from 'node:path';
5
+ const isPosix = platform() !== 'win32';
6
+ describe('writePersistedToken', () => {
7
+ const originalEnv = process.env;
8
+ let dir;
9
+ beforeEach(() => {
10
+ process.env = { ...originalEnv };
11
+ delete process.env.FERRLABS_MCP_NO_PERSIST;
12
+ dir = mkdtempSync(join(tmpdir(), 'mcp-persist-'));
13
+ process.env.FERRLABS_MCP_TOKEN_PATH = join(dir, 'nested', 'token.json');
14
+ });
15
+ afterEach(() => {
16
+ process.env = originalEnv;
17
+ rmSync(dir, { recursive: true, force: true });
18
+ });
19
+ it('round-trips the persisted token', async () => {
20
+ const { writePersistedToken, readPersistedToken } = await import('../persistence.js');
21
+ await writePersistedToken('fl_secret', ['read']);
22
+ expect(await readPersistedToken()).toBe('fl_secret');
23
+ });
24
+ it.runIf(isPosix)('writes the token file with mode no broader than 0600', async () => {
25
+ const { writePersistedToken } = await import('../persistence.js');
26
+ const path = process.env.FERRLABS_MCP_TOKEN_PATH;
27
+ await writePersistedToken('fl_secret');
28
+ const mode = statSync(path).mode & 0o777;
29
+ expect(mode & 0o177).toBe(0);
30
+ expect(mode).toBe(0o600);
31
+ });
32
+ it.runIf(isPosix)('creates the parent directory with mode 0700', async () => {
33
+ const { writePersistedToken } = await import('../persistence.js');
34
+ const path = process.env.FERRLABS_MCP_TOKEN_PATH;
35
+ await writePersistedToken('fl_secret');
36
+ const dirMode = statSync(join(path, '..')).mode & 0o777;
37
+ expect(dirMode & 0o077).toBe(0);
38
+ });
39
+ });
@@ -1,8 +1,6 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
1
  interface AuthContext {
3
2
  bearerToken?: string;
4
3
  }
5
- export declare const authContext: AsyncLocalStorage<AuthContext>;
6
4
  export declare function runWithAuthContext<T>(ctx: AuthContext, fn: () => Promise<T>): Promise<T>;
7
5
  export declare function getRequestBearerToken(): string | undefined;
8
6
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/auth/context.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAErD,UAAU,WAAW;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,WAAW,gCAAuC,CAAC;AAEhE,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAExF;AAED,wBAAgB,qBAAqB,IAAI,MAAM,GAAG,SAAS,CAE1D"}
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../../src/auth/context.ts"],"names":[],"mappings":"AAEA,UAAU,WAAW;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAID,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,GAAG,EAAE,WAAW,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAExF;AAED,wBAAgB,qBAAqB,IAAI,MAAM,GAAG,SAAS,CAE1D"}
@@ -1,5 +1,5 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
- export const authContext = new AsyncLocalStorage();
2
+ const authContext = new AsyncLocalStorage();
3
3
  export function runWithAuthContext(ctx, fn) {
4
4
  return authContext.run(ctx, fn);
5
5
  }
@@ -1,4 +1,3 @@
1
1
  export declare function getToken(): Promise<string>;
2
2
  export declare function clearTokenCache(): void;
3
- export declare const resetTokenCacheForTesting: typeof clearTokenCache;
4
3
  //# 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;AAED,eAAO,MAAM,yBAAyB,wBAAkB,CAAC"}
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"}
@@ -41,4 +41,3 @@ export function clearTokenCache() {
41
41
  cached = null;
42
42
  inFlight = null;
43
43
  }
44
- export const resetTokenCacheForTesting = clearTokenCache;
@@ -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;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;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"}
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { platform } from 'node:os';
5
+ import { fetchWithTimeout } from '../http.js';
5
6
  const AUTH_BASE = process.env.FERRLABS_AUTH_URL ?? 'https://auth.ferrlabs.com';
6
7
  const API_BASE = process.env.API_URL ?? 'https://api.ferrlabs.com';
7
8
  const CLIENT_ID = 'mcp';
@@ -96,7 +97,7 @@ function bindCallbackListener(port, expectedState) {
96
97
  });
97
98
  }
98
99
  async function exchangeCodeForToken(code, codeVerifier, redirectUri) {
99
- const res = await fetch(`${API_BASE}/v1/auth/exchange`, {
100
+ const res = await fetchWithTimeout(`${API_BASE}/v1/auth/exchange`, {
100
101
  method: 'POST',
101
102
  headers: { 'Content-Type': 'application/json', 'User-Agent': 'ferrlabs-mcp/oauth' },
102
103
  body: JSON.stringify({
@@ -1 +1 @@
1
- {"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../src/auth/persistence.ts"],"names":[],"mappings":"AAmCA,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAYjE;AAED,wBAAsB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBzF;AAED,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC,CAOzD"}
1
+ {"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../src/auth/persistence.ts"],"names":[],"mappings":"AAmCA,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAYjE;AAED,wBAAsB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBzF;AAED,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC,CAMzD"}
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
1
+ import { mkdir, readFile, writeFile, chmod, rename, unlink } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { homedir, platform } from 'node:os';
4
4
  function defaultTokenPath() {
@@ -34,24 +34,27 @@ export async function writePersistedToken(token, scopes) {
34
34
  if (process.env.FERRLABS_MCP_NO_PERSIST === '1')
35
35
  return;
36
36
  const path = defaultTokenPath();
37
- await mkdir(dirname(path), { recursive: true });
37
+ const isPosix = platform() !== 'win32';
38
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
38
39
  const payload = {
39
40
  token,
40
41
  obtained_at: new Date().toISOString(),
41
42
  scopes,
42
43
  };
43
- await writeFile(path, JSON.stringify(payload, null, 2), 'utf8');
44
- if (platform() !== 'win32') {
45
- try {
46
- await chmod(path, 0o600);
47
- }
48
- catch {
49
- // ignore — best effort on platforms that don't support POSIX modes
44
+ const tmpPath = `${path}.${process.pid}.tmp`;
45
+ await writeFile(tmpPath, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 });
46
+ try {
47
+ if (isPosix) {
48
+ await chmod(tmpPath, 0o600);
50
49
  }
50
+ await rename(tmpPath, path);
51
+ }
52
+ catch (err) {
53
+ await unlink(tmpPath).catch(() => undefined);
54
+ throw err;
51
55
  }
52
56
  }
53
57
  export async function clearPersistedToken() {
54
- const { unlink } = await import('node:fs/promises');
55
58
  try {
56
59
  await unlink(defaultTokenPath());
57
60
  }
package/dist/http.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export declare function fetchTimeoutMs(): number;
2
+ export declare function fetchWithTimeout(url: string, init?: RequestInit, timeoutMs?: number): Promise<Response>;
3
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAEA,wBAAgB,cAAc,IAAI,MAAM,CAKvC;AAED,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,MAAM,EACX,IAAI,GAAE,WAAgB,EACtB,SAAS,GAAE,MAAyB,GACnC,OAAO,CAAC,QAAQ,CAAC,CAanB"}
package/dist/http.js ADDED
@@ -0,0 +1,24 @@
1
+ const DEFAULT_TIMEOUT_MS = 15_000;
2
+ export function fetchTimeoutMs() {
3
+ const raw = process.env.FERRLABS_MCP_HTTP_TIMEOUT_MS;
4
+ if (!raw)
5
+ return DEFAULT_TIMEOUT_MS;
6
+ const parsed = Number(raw);
7
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
8
+ }
9
+ export async function fetchWithTimeout(url, init = {}, timeoutMs = fetchTimeoutMs()) {
10
+ const controller = new AbortController();
11
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
12
+ try {
13
+ return await fetch(url, { ...init, signal: controller.signal });
14
+ }
15
+ catch (err) {
16
+ if (err instanceof Error && err.name === 'AbortError') {
17
+ throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
18
+ }
19
+ throw err;
20
+ }
21
+ finally {
22
+ clearTimeout(timer);
23
+ }
24
+ }
package/dist/index.d.ts CHANGED
@@ -2,5 +2,7 @@ export { runMcp, type RunMcpOptions } from './runner.js';
2
2
  export { apiRequest, UnauthorizedError } from './api-client.js';
3
3
  export { getToken, clearTokenCache } from './auth/index.js';
4
4
  export { runWithAuthContext, getRequestBearerToken } from './auth/context.js';
5
+ export { readPackageVersion } from './version.js';
5
6
  export type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ export { fetchWithTimeout, fetchTimeoutMs } from './http.js';
6
8
  //# 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,YAAY,EAAE,SAAS,EAAE,MAAM,yCAAyC,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,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"}
package/dist/index.js CHANGED
@@ -2,3 +2,5 @@ export { runMcp } from './runner.js';
2
2
  export { apiRequest, UnauthorizedError } from './api-client.js';
3
3
  export { getToken, clearTokenCache } from './auth/index.js';
4
4
  export { runWithAuthContext, getRequestBearerToken } from './auth/context.js';
5
+ export { readPackageVersion } from './version.js';
6
+ export { fetchWithTimeout, fetchTimeoutMs } from './http.js';
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { unauthorizedChallenge } from '../http.js';
2
+ import { unauthorizedChallenge, resolveAllowedOrigin, isHostAllowed, defaultBindHost, parseList, } from '../http.js';
3
3
  describe('unauthorizedChallenge', () => {
4
4
  const metadataUrl = 'https://mcp.ferrvault.com/.well-known/oauth-protected-resource';
5
5
  it('returns an RFC 6749 §5.2 error body where `error` is a string', () => {
@@ -22,3 +22,51 @@ describe('unauthorizedChallenge', () => {
22
22
  expect(wwwAuthenticate).toContain(`resource_metadata="${metadataUrl}"`);
23
23
  });
24
24
  });
25
+ describe('resolveAllowedOrigin', () => {
26
+ const allowlist = ['https://app.ferrlabs.com', 'http://localhost:5173'];
27
+ it('echoes an origin that is on the allowlist', () => {
28
+ expect(resolveAllowedOrigin('https://app.ferrlabs.com', allowlist)).toBe('https://app.ferrlabs.com');
29
+ });
30
+ it('refuses to reflect an origin that is not allowlisted', () => {
31
+ expect(resolveAllowedOrigin('https://evil.test', allowlist)).toBeUndefined();
32
+ });
33
+ it('returns undefined when no origin header is present', () => {
34
+ expect(resolveAllowedOrigin(undefined, allowlist)).toBeUndefined();
35
+ });
36
+ it('never falls back to a wildcard when the allowlist is empty', () => {
37
+ expect(resolveAllowedOrigin('https://app.ferrlabs.com', [])).toBeUndefined();
38
+ });
39
+ });
40
+ describe('isHostAllowed', () => {
41
+ it('allows any host when no allowlist is configured', () => {
42
+ expect(isHostAllowed('anything.test', [])).toBe(true);
43
+ });
44
+ it('matches the host ignoring port and case', () => {
45
+ expect(isHostAllowed('MCP.ferrlabs.com:3000', ['mcp.ferrlabs.com'])).toBe(true);
46
+ });
47
+ it('rejects a host outside the allowlist (DNS-rebinding guard)', () => {
48
+ expect(isHostAllowed('attacker.test', ['mcp.ferrlabs.com'])).toBe(false);
49
+ });
50
+ it('rejects a missing host header when an allowlist is set', () => {
51
+ expect(isHostAllowed(undefined, ['mcp.ferrlabs.com'])).toBe(false);
52
+ });
53
+ });
54
+ describe('defaultBindHost', () => {
55
+ it('defaults to loopback', () => {
56
+ expect(defaultBindHost(undefined, false)).toBe('127.0.0.1');
57
+ });
58
+ it('binds all interfaces only when explicitly opted in', () => {
59
+ expect(defaultBindHost(undefined, true)).toBe('0.0.0.0');
60
+ });
61
+ it('honours an explicit host over the opt-in flag', () => {
62
+ expect(defaultBindHost('10.0.0.5', true)).toBe('10.0.0.5');
63
+ });
64
+ });
65
+ describe('parseList', () => {
66
+ it('splits, trims, and drops empties', () => {
67
+ expect(parseList(' a , b ,, c ')).toEqual(['a', 'b', 'c']);
68
+ });
69
+ it('returns an empty array for undefined', () => {
70
+ expect(parseList(undefined)).toEqual([]);
71
+ });
72
+ });
@@ -1,4 +1,8 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function parseList(raw: string | undefined): string[];
3
+ export declare function resolveAllowedOrigin(requestOrigin: string | undefined, allowlist: string[]): string | undefined;
4
+ export declare function isHostAllowed(hostHeader: string | undefined, allowedHosts: string[]): boolean;
5
+ export declare function defaultBindHost(envHost: string | undefined, bindAll: boolean): string;
2
6
  export declare function unauthorizedChallenge(metadataUrl: string): {
3
7
  wwwAuthenticate: string;
4
8
  body: string;
@@ -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;AAoBzE,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;AAoBD,wBAAsB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAqI5E"}
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"}
@@ -2,6 +2,7 @@ import { createServer as createHttpServer, } from 'node:http';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { runWithAuthContext } from '../auth/context.js';
5
+ const MAX_BODY_BYTES = 1_000_000;
5
6
  function extractBearer(req) {
6
7
  const header = req.headers['authorization'];
7
8
  if (typeof header !== 'string')
@@ -9,6 +10,32 @@ function extractBearer(req) {
9
10
  const match = /^Bearer\s+(.+)$/i.exec(header);
10
11
  return match?.[1];
11
12
  }
13
+ export function parseList(raw) {
14
+ if (!raw)
15
+ return [];
16
+ return raw
17
+ .split(',')
18
+ .map((s) => s.trim())
19
+ .filter((s) => s.length > 0);
20
+ }
21
+ export function resolveAllowedOrigin(requestOrigin, allowlist) {
22
+ if (requestOrigin === undefined)
23
+ return undefined;
24
+ return allowlist.includes(requestOrigin) ? requestOrigin : undefined;
25
+ }
26
+ export function isHostAllowed(hostHeader, allowedHosts) {
27
+ if (allowedHosts.length === 0)
28
+ return true;
29
+ if (!hostHeader)
30
+ return false;
31
+ const host = hostHeader.split(':')[0].toLowerCase();
32
+ return allowedHosts.some((allowed) => allowed.split(':')[0].toLowerCase() === host);
33
+ }
34
+ export function defaultBindHost(envHost, bindAll) {
35
+ if (envHost)
36
+ return envHost;
37
+ return bindAll ? '0.0.0.0' : '127.0.0.1';
38
+ }
12
39
  function publicOrigin(req) {
13
40
  const proto = req.headers['x-forwarded-proto']?.split(',')[0]?.trim() ?? 'https';
14
41
  const host = req.headers['x-forwarded-host'] ?? req.headers.host ?? '';
@@ -24,12 +51,27 @@ export function unauthorizedChallenge(metadataUrl) {
24
51
  }),
25
52
  };
26
53
  }
54
+ class PayloadTooLargeError extends Error {
55
+ constructor() {
56
+ super(`Request body exceeds the ${MAX_BODY_BYTES}-byte limit`);
57
+ this.name = 'PayloadTooLargeError';
58
+ }
59
+ }
27
60
  async function readJsonBody(req) {
28
61
  if (req.method !== 'POST')
29
62
  return undefined;
30
63
  return new Promise((resolve, reject) => {
31
64
  const chunks = [];
32
- req.on('data', (c) => chunks.push(c));
65
+ let total = 0;
66
+ req.on('data', (c) => {
67
+ total += c.length;
68
+ if (total > MAX_BODY_BYTES) {
69
+ req.destroy();
70
+ reject(new PayloadTooLargeError());
71
+ return;
72
+ }
73
+ chunks.push(c);
74
+ });
33
75
  req.on('end', () => {
34
76
  const raw = Buffer.concat(chunks).toString('utf8');
35
77
  if (!raw)
@@ -45,7 +87,11 @@ async function readJsonBody(req) {
45
87
  });
46
88
  }
47
89
  export async function startHttpServer(opts) {
48
- const { port, host = '0.0.0.0', stateless = true } = opts;
90
+ const { port, stateless = true } = opts;
91
+ const bindAll = process.env.FERRLABS_MCP_BIND_ALL === '1';
92
+ const host = defaultBindHost(opts.host, bindAll);
93
+ const allowedOrigins = parseList(process.env.FERRLABS_MCP_ALLOWED_ORIGINS);
94
+ const allowedHosts = parseList(process.env.FERRLABS_MCP_ALLOWED_HOSTS);
49
95
  const authServer = opts.authorizationServer ?? process.env.FERRLABS_AUTH_URL ?? 'https://api.ferrlabs.com';
50
96
  const transports = new Map();
51
97
  function sendUnauthorized(req, res) {
@@ -110,14 +156,14 @@ export async function startHttpServer(opts) {
110
156
  await runWithAuthContext({ bearerToken }, () => transport.handleRequest(req, res, body));
111
157
  }
112
158
  function applyCorsHeaders(req, res) {
113
- const origin = req.headers.origin ?? '*';
114
- res.setHeader('Access-Control-Allow-Origin', origin);
115
159
  res.setHeader('Vary', 'Origin');
160
+ const allowed = resolveAllowedOrigin(req.headers.origin, allowedOrigins);
161
+ if (!allowed)
162
+ return;
163
+ res.setHeader('Access-Control-Allow-Origin', allowed);
116
164
  res.setHeader('Access-Control-Allow-Credentials', 'true');
117
165
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
118
- const requested = req.headers['access-control-request-headers'] ??
119
- 'authorization, content-type, mcp-session-id, mcp-protocol-version';
120
- res.setHeader('Access-Control-Allow-Headers', requested);
166
+ res.setHeader('Access-Control-Allow-Headers', 'authorization, content-type, mcp-session-id, mcp-protocol-version');
121
167
  res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id, www-authenticate');
122
168
  res.setHeader('Access-Control-Max-Age', '86400');
123
169
  }
@@ -128,6 +174,11 @@ export async function startHttpServer(opts) {
128
174
  res.end();
129
175
  return;
130
176
  }
177
+ if (!isHostAllowed(req.headers.host, allowedHosts)) {
178
+ res.writeHead(421, { 'Content-Type': 'application/json' });
179
+ res.end(JSON.stringify({ error: 'Host not allowed' }));
180
+ return;
181
+ }
131
182
  const url = req.url ?? '/';
132
183
  if (url === '/health' || url === '/livez' || url === '/readyz') {
133
184
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -141,7 +192,8 @@ export async function startHttpServer(opts) {
141
192
  if (url === '/mcp' || url.startsWith('/mcp?')) {
142
193
  handleMcpRequest(req, res).catch((err) => {
143
194
  if (!res.headersSent) {
144
- res.writeHead(500, { 'Content-Type': 'application/json' });
195
+ const status = err instanceof PayloadTooLargeError ? 413 : 500;
196
+ res.writeHead(status, { 'Content-Type': 'application/json' });
145
197
  res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
146
198
  }
147
199
  else {
@@ -0,0 +1,2 @@
1
+ export declare function readPackageVersion(entryUrl: string): string;
2
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAGA,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAQ3D"}
@@ -0,0 +1,11 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ export function readPackageVersion(entryUrl) {
4
+ const packageJsonUrl = new URL('../package.json', entryUrl);
5
+ const raw = readFileSync(fileURLToPath(packageJsonUrl), 'utf8');
6
+ const parsed = JSON.parse(raw);
7
+ if (typeof parsed.version !== 'string' || parsed.version.length === 0) {
8
+ throw new Error(`package.json at ${packageJsonUrl.href} has no version field`);
9
+ }
10
+ return parsed.version;
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferrlabs/mcp-core",
3
- "version": "6.5.0",
3
+ "version": "6.5.4",
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",