@ferrlabs/mcp-core 6.5.0 → 6.5.5
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/dist/__tests__/http.test.d.ts +2 -0
- package/dist/__tests__/http.test.d.ts.map +1 -0
- package/dist/__tests__/http.test.js +43 -0
- package/dist/__tests__/version.test.d.ts +2 -0
- package/dist/__tests__/version.test.d.ts.map +1 -0
- package/dist/__tests__/version.test.js +20 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +2 -1
- package/dist/auth/__tests__/persistence.test.d.ts +2 -0
- package/dist/auth/__tests__/persistence.test.d.ts.map +1 -0
- package/dist/auth/__tests__/persistence.test.js +39 -0
- package/dist/auth/context.d.ts +0 -2
- package/dist/auth/context.d.ts.map +1 -1
- package/dist/auth/context.js +1 -1
- package/dist/auth/index.d.ts +0 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +0 -1
- package/dist/auth/oauth.d.ts.map +1 -1
- package/dist/auth/oauth.js +2 -1
- package/dist/auth/persistence.d.ts.map +1 -1
- package/dist/auth/persistence.js +13 -10
- package/dist/http.d.ts +3 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +24 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/transports/__tests__/http.test.js +49 -1
- package/dist/transports/http.d.ts +4 -0
- package/dist/transports/http.d.ts.map +1 -1
- package/dist/transports/http.js +60 -8
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +11 -0
- package/package.json +1 -1
|
@@ -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 @@
|
|
|
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
|
+
});
|
package/dist/api-client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/api-client.js
CHANGED
|
@@ -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
|
|
22
|
+
const res = await fetchWithTimeout(`${base}${path}`, {
|
|
22
23
|
method,
|
|
23
24
|
headers,
|
|
24
25
|
body: body ? JSON.stringify(body) : undefined,
|
|
@@ -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
|
+
});
|
package/dist/auth/context.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/auth/context.js
CHANGED
package/dist/auth/index.d.ts
CHANGED
package/dist/auth/index.d.ts.map
CHANGED
|
@@ -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":"AAOA,wBAAsB,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,CAuChD;AAED,wBAAgB,eAAe,IAAI,IAAI,CAGtC"}
|
package/dist/auth/index.js
CHANGED
package/dist/auth/oauth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../../src/auth/oauth.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/auth/oauth.js
CHANGED
|
@@ -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
|
|
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,
|
|
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"}
|
package/dist/auth/persistence.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 @@
|
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/transports/http.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAGA,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAQ3D"}
|
package/dist/version.js
ADDED
|
@@ -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