@ferrlabs/mcp-core 8.2.1 → 9.0.0
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/transports/__tests__/http-host.test.d.ts +2 -0
- package/dist/transports/__tests__/http-host.test.d.ts.map +1 -0
- package/dist/transports/__tests__/http-host.test.js +78 -0
- package/dist/transports/__tests__/http.test.js +74 -16
- package/dist/transports/http-host.d.ts +17 -0
- package/dist/transports/http-host.d.ts.map +1 -0
- package/dist/transports/http-host.js +65 -0
- package/dist/transports/http.d.ts +3 -2
- package/dist/transports/http.d.ts.map +1 -1
- package/dist/transports/http.js +20 -28
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-host.test.d.ts","sourceRoot":"","sources":["../../../src/transports/__tests__/http-host.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { hostnameOf, isHostAllowed, resolvePublicEndpoint } from '../http-host.js';
|
|
3
|
+
describe('hostnameOf', () => {
|
|
4
|
+
it.each([
|
|
5
|
+
['[::1]:3000', '[::1]'],
|
|
6
|
+
['[::1]', '[::1]'],
|
|
7
|
+
['localhost:3000', 'localhost'],
|
|
8
|
+
['localhost', 'localhost'],
|
|
9
|
+
['MCP.ferrlabs.com:443', 'mcp.ferrlabs.com'],
|
|
10
|
+
])('reads %s as %s', (header, expected) => {
|
|
11
|
+
expect(hostnameOf(header)).toBe(expected);
|
|
12
|
+
});
|
|
13
|
+
it.each(['', '[::1', 'a b', 'evil.test/x', 'user@host', 'host:port'])('rejects the malformed header %j', (header) => {
|
|
14
|
+
expect(hostnameOf(header)).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
describe('isHostAllowed', () => {
|
|
18
|
+
it('matches an IPv6 Host against the same IPv6 entry only', () => {
|
|
19
|
+
expect(isHostAllowed('[::1]:3000', ['[::1]'])).toBe(true);
|
|
20
|
+
expect(isHostAllowed('[::2]:3000', ['[::1]'])).toBe(false);
|
|
21
|
+
expect(isHostAllowed('[::1]:3000', ['localhost'])).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
it('does not let a malformed header collapse into a matching token', () => {
|
|
24
|
+
expect(isHostAllowed('[', ['['])).toBe(false);
|
|
25
|
+
expect(isHostAllowed('[::1', ['[::1]'])).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
it('rejects everything when the allowlist is empty', () => {
|
|
28
|
+
expect(isHostAllowed('mcp.ferrlabs.com', [])).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
it('rejects a missing Host header', () => {
|
|
31
|
+
expect(isHostAllowed(undefined, ['mcp.ferrlabs.com'])).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('resolvePublicEndpoint', () => {
|
|
35
|
+
const bindAll = { bindHost: '0.0.0.0', port: 3000 };
|
|
36
|
+
it('uses the public URL origin and allows only its host by default', () => {
|
|
37
|
+
expect(resolvePublicEndpoint({
|
|
38
|
+
...bindAll,
|
|
39
|
+
publicUrl: 'https://mcp.ferrlabs.com/',
|
|
40
|
+
allowedHosts: [],
|
|
41
|
+
})).toEqual({ publicUrl: 'https://mcp.ferrlabs.com', allowedHosts: ['mcp.ferrlabs.com'] });
|
|
42
|
+
});
|
|
43
|
+
it('normalises explicit allowlist entries, ports and brackets included', () => {
|
|
44
|
+
expect(resolvePublicEndpoint({
|
|
45
|
+
...bindAll,
|
|
46
|
+
publicUrl: 'https://mcp.ferrlabs.com',
|
|
47
|
+
allowedHosts: ['MCP.ferrlabs.com:443', '[::1]:3000'],
|
|
48
|
+
}).allowedHosts).toEqual(['mcp.ferrlabs.com', '[::1]']);
|
|
49
|
+
});
|
|
50
|
+
it('defaults to a loopback URL and loopback hosts when bound to loopback', () => {
|
|
51
|
+
expect(resolvePublicEndpoint({
|
|
52
|
+
publicUrl: undefined,
|
|
53
|
+
allowedHosts: [],
|
|
54
|
+
bindHost: '127.0.0.1',
|
|
55
|
+
port: 4000,
|
|
56
|
+
})).toEqual({
|
|
57
|
+
publicUrl: 'http://127.0.0.1:4000',
|
|
58
|
+
allowedHosts: ['127.0.0.1', 'localhost', '[::1]'],
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
it('refuses a non-loopback bind without a public URL', () => {
|
|
62
|
+
expect(() => resolvePublicEndpoint({
|
|
63
|
+
...bindAll,
|
|
64
|
+
publicUrl: undefined,
|
|
65
|
+
allowedHosts: ['mcp.ferrlabs.com'],
|
|
66
|
+
})).toThrow(/FERRLABS_MCP_PUBLIC_URL/);
|
|
67
|
+
});
|
|
68
|
+
it('refuses a public URL that is not http or https', () => {
|
|
69
|
+
expect(() => resolvePublicEndpoint({ ...bindAll, publicUrl: 'ftp://mcp.ferrlabs.com', allowedHosts: [] })).toThrow(/http or https/);
|
|
70
|
+
});
|
|
71
|
+
it('refuses a malformed allowlist entry instead of dropping it', () => {
|
|
72
|
+
expect(() => resolvePublicEndpoint({
|
|
73
|
+
...bindAll,
|
|
74
|
+
publicUrl: 'https://mcp.ferrlabs.com',
|
|
75
|
+
allowedHosts: ['a b'],
|
|
76
|
+
})).toThrow(/invalid entry/);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { request } from 'node:http';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { unauthorizedChallenge, resolveAllowedOrigin, defaultBindHost, parseList, startHttpServer, } from '../http.js';
|
|
3
5
|
describe('unauthorizedChallenge', () => {
|
|
4
6
|
const metadataUrl = 'https://mcp.ferrvault.com/.well-known/oauth-protected-resource';
|
|
5
7
|
it('returns an RFC 6749 §5.2 error body where `error` is a string', () => {
|
|
@@ -37,20 +39,6 @@ describe('resolveAllowedOrigin', () => {
|
|
|
37
39
|
expect(resolveAllowedOrigin('https://app.ferrlabs.com', [])).toBeUndefined();
|
|
38
40
|
});
|
|
39
41
|
});
|
|
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
42
|
describe('defaultBindHost', () => {
|
|
55
43
|
it('defaults to loopback', () => {
|
|
56
44
|
expect(defaultBindHost(undefined, false)).toBe('127.0.0.1');
|
|
@@ -70,3 +58,73 @@ describe('parseList', () => {
|
|
|
70
58
|
expect(parseList(undefined)).toEqual([]);
|
|
71
59
|
});
|
|
72
60
|
});
|
|
61
|
+
describe('startHttpServer', () => {
|
|
62
|
+
let server;
|
|
63
|
+
afterEach(async () => {
|
|
64
|
+
await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve()));
|
|
65
|
+
server = undefined;
|
|
66
|
+
});
|
|
67
|
+
async function start() {
|
|
68
|
+
server = await startHttpServer({
|
|
69
|
+
port: 0,
|
|
70
|
+
host: '127.0.0.1',
|
|
71
|
+
publicUrl: 'https://mcp.ferrlabs.test',
|
|
72
|
+
createServer: () => new McpServer({ name: 'test', version: '0.0.0' }),
|
|
73
|
+
});
|
|
74
|
+
return server.address().port;
|
|
75
|
+
}
|
|
76
|
+
function get(port, path, headers) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const req = request({ host: '127.0.0.1', port, path, method: 'GET', headers }, (res) => {
|
|
79
|
+
let body = '';
|
|
80
|
+
res.setEncoding('utf8');
|
|
81
|
+
res.on('data', (c) => (body += c));
|
|
82
|
+
res.on('end', () => resolve({
|
|
83
|
+
status: res.statusCode ?? 0,
|
|
84
|
+
body,
|
|
85
|
+
wwwAuthenticate: res.headers['www-authenticate'],
|
|
86
|
+
}));
|
|
87
|
+
});
|
|
88
|
+
req.on('error', reject);
|
|
89
|
+
req.end();
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
it('advertises the configured public URL whatever X-Forwarded-Host says', async () => {
|
|
93
|
+
const port = await start();
|
|
94
|
+
const res = await get(port, '/.well-known/oauth-protected-resource', {
|
|
95
|
+
Host: 'mcp.ferrlabs.test',
|
|
96
|
+
'X-Forwarded-Host': 'attacker.test',
|
|
97
|
+
'X-Forwarded-Proto': 'http',
|
|
98
|
+
});
|
|
99
|
+
expect(res.status).toBe(200);
|
|
100
|
+
expect(JSON.parse(res.body).resource).toBe('https://mcp.ferrlabs.test');
|
|
101
|
+
});
|
|
102
|
+
it('points the 401 challenge at the configured public URL', async () => {
|
|
103
|
+
const port = await start();
|
|
104
|
+
const res = await get(port, '/mcp', {
|
|
105
|
+
Host: 'mcp.ferrlabs.test',
|
|
106
|
+
'X-Forwarded-Host': 'attacker.test',
|
|
107
|
+
});
|
|
108
|
+
expect(res.status).toBe(401);
|
|
109
|
+
expect(res.wwwAuthenticate).toContain('resource_metadata="https://mcp.ferrlabs.test/.well-known/oauth-protected-resource"');
|
|
110
|
+
});
|
|
111
|
+
it('rejects a Host outside the allowlist derived from the public URL', async () => {
|
|
112
|
+
const port = await start();
|
|
113
|
+
const res = await get(port, '/.well-known/oauth-protected-resource', {
|
|
114
|
+
Host: 'attacker.test',
|
|
115
|
+
});
|
|
116
|
+
expect(res.status).toBe(421);
|
|
117
|
+
});
|
|
118
|
+
it('answers health probes whatever the Host header, so kubelet probes on the pod IP pass', async () => {
|
|
119
|
+
const port = await start();
|
|
120
|
+
const res = await get(port, '/health', { Host: '10.42.0.17:3000' });
|
|
121
|
+
expect(res.status).toBe(200);
|
|
122
|
+
});
|
|
123
|
+
it('refuses to start on a non-loopback address without a public URL', async () => {
|
|
124
|
+
await expect(startHttpServer({
|
|
125
|
+
port: 0,
|
|
126
|
+
host: '0.0.0.0',
|
|
127
|
+
createServer: () => new McpServer({ name: 'test', version: '0.0.0' }),
|
|
128
|
+
})).rejects.toThrow(/FERRLABS_MCP_PUBLIC_URL/);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class HttpConfigError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export interface PublicEndpoint {
|
|
5
|
+
publicUrl: string;
|
|
6
|
+
allowedHosts: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function hostnameOf(hostHeader: string): string | undefined;
|
|
9
|
+
export declare function isHostAllowed(hostHeader: string | undefined, allowedHosts: string[]): boolean;
|
|
10
|
+
export declare function isLoopback(bindHost: string): boolean;
|
|
11
|
+
export declare function resolvePublicEndpoint(opts: {
|
|
12
|
+
publicUrl: string | undefined;
|
|
13
|
+
allowedHosts: string[];
|
|
14
|
+
bindHost: string;
|
|
15
|
+
port: number;
|
|
16
|
+
}): PublicEndpoint;
|
|
17
|
+
//# sourceMappingURL=http-host.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-host.d.ts","sourceRoot":"","sources":["../../src/transports/http-host.ts"],"names":[],"mappings":"AAEA,qBAAa,eAAgB,SAAQ,KAAK;IACxC,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,wBAAgB,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAOjE;AAED,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,OAAO,CAI7F;AAED,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEpD;AAyBD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE;IAC1C,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,cAAc,CAqBjB"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost', '[::1]'];
|
|
2
|
+
export class HttpConfigError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'HttpConfigError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function hostnameOf(hostHeader) {
|
|
9
|
+
if (hostHeader.length === 0 || /[\s/?#@\\]/.test(hostHeader))
|
|
10
|
+
return undefined;
|
|
11
|
+
try {
|
|
12
|
+
return new URL(`http://${hostHeader}`).hostname;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function isHostAllowed(hostHeader, allowedHosts) {
|
|
19
|
+
if (!hostHeader)
|
|
20
|
+
return false;
|
|
21
|
+
const hostname = hostnameOf(hostHeader);
|
|
22
|
+
return hostname !== undefined && allowedHosts.includes(hostname);
|
|
23
|
+
}
|
|
24
|
+
export function isLoopback(bindHost) {
|
|
25
|
+
return [...LOOPBACK_HOSTNAMES, '::1'].includes(bindHost);
|
|
26
|
+
}
|
|
27
|
+
function parsePublicUrl(raw) {
|
|
28
|
+
let url;
|
|
29
|
+
try {
|
|
30
|
+
url = new URL(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new HttpConfigError(`FERRLABS_MCP_PUBLIC_URL is not a valid URL: ${raw}`);
|
|
34
|
+
}
|
|
35
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
36
|
+
throw new HttpConfigError(`FERRLABS_MCP_PUBLIC_URL must be http or https, got ${url.protocol}`);
|
|
37
|
+
}
|
|
38
|
+
return url;
|
|
39
|
+
}
|
|
40
|
+
function parseAllowedHosts(entries) {
|
|
41
|
+
return entries.map((entry) => {
|
|
42
|
+
const hostname = hostnameOf(entry);
|
|
43
|
+
if (hostname === undefined) {
|
|
44
|
+
throw new HttpConfigError(`FERRLABS_MCP_ALLOWED_HOSTS has an invalid entry: ${entry}`);
|
|
45
|
+
}
|
|
46
|
+
return hostname;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function resolvePublicEndpoint(opts) {
|
|
50
|
+
const explicitHosts = parseAllowedHosts(opts.allowedHosts);
|
|
51
|
+
if (opts.publicUrl) {
|
|
52
|
+
const url = parsePublicUrl(opts.publicUrl);
|
|
53
|
+
return {
|
|
54
|
+
publicUrl: url.origin,
|
|
55
|
+
allowedHosts: explicitHosts.length > 0 ? explicitHosts : [url.hostname],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (isLoopback(opts.bindHost)) {
|
|
59
|
+
return {
|
|
60
|
+
publicUrl: `http://127.0.0.1:${opts.port}`,
|
|
61
|
+
allowedHosts: explicitHosts.length > 0 ? explicitHosts : LOOPBACK_HOSTNAMES,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
throw new HttpConfigError(`The HTTP transport is bound to ${opts.bindHost}, so it needs FERRLABS_MCP_PUBLIC_URL, the URL clients reach it at (e.g. https://mcp.ferrlabs.com). It is advertised as the OAuth resource and its host becomes the default Host allowlist.`);
|
|
65
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { type Server } from 'node:http';
|
|
1
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
3
|
export declare function parseList(raw: string | undefined): string[];
|
|
3
4
|
export declare function resolveAllowedOrigin(requestOrigin: string | undefined, allowlist: string[]): string | undefined;
|
|
4
|
-
export declare function isHostAllowed(hostHeader: string | undefined, allowedHosts: string[]): boolean;
|
|
5
5
|
export declare function defaultBindHost(envHost: string | undefined, bindAll: boolean): string;
|
|
6
6
|
export declare function unauthorizedChallenge(metadataUrl: string): {
|
|
7
7
|
wwwAuthenticate: string;
|
|
@@ -11,6 +11,7 @@ export interface HttpServerOptions {
|
|
|
11
11
|
port: number;
|
|
12
12
|
host?: string;
|
|
13
13
|
stateless?: boolean;
|
|
14
|
+
publicUrl?: string;
|
|
14
15
|
createServer: () => McpServer;
|
|
15
16
|
/**
|
|
16
17
|
* URL of the OAuth 2.0 Authorization Server users authenticate against.
|
|
@@ -27,5 +28,5 @@ export interface HttpServerOptions {
|
|
|
27
28
|
*/
|
|
28
29
|
authorizationServer?: string;
|
|
29
30
|
}
|
|
30
|
-
export declare function startHttpServer(opts: HttpServerOptions): Promise<
|
|
31
|
+
export declare function startHttpServer(opts: HttpServerOptions): Promise<Server>;
|
|
31
32
|
//# sourceMappingURL=http.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,MAAM,EAEZ,MAAM,WAAW,CAAC;AAGnB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAazE,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,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CAGrF;AAKD,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,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,SAAS,CAAC;IAC9B;;;;;;;;;;;;OAYG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAoCD,wBAAsB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAsK9E"}
|
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
|
+
import { isHostAllowed, resolvePublicEndpoint } from './http-host.js';
|
|
5
6
|
const MAX_BODY_BYTES = 1_000_000;
|
|
6
7
|
function extractBearer(req) {
|
|
7
8
|
const header = req.headers['authorization'];
|
|
@@ -23,24 +24,11 @@ export function resolveAllowedOrigin(requestOrigin, allowlist) {
|
|
|
23
24
|
return undefined;
|
|
24
25
|
return allowlist.includes(requestOrigin) ? requestOrigin : undefined;
|
|
25
26
|
}
|
|
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
27
|
export function defaultBindHost(envHost, bindAll) {
|
|
35
28
|
if (envHost)
|
|
36
29
|
return envHost;
|
|
37
30
|
return bindAll ? '0.0.0.0' : '127.0.0.1';
|
|
38
31
|
}
|
|
39
|
-
function publicOrigin(req) {
|
|
40
|
-
const proto = req.headers['x-forwarded-proto']?.split(',')[0]?.trim() ?? 'https';
|
|
41
|
-
const host = req.headers['x-forwarded-host'] ?? req.headers.host ?? '';
|
|
42
|
-
return `${proto}://${host}`;
|
|
43
|
-
}
|
|
44
32
|
const UNAUTHORIZED_DESCRIPTION = 'Missing or invalid bearer token. Fetch the OAuth protected-resource metadata and run the authorization code + PKCE flow.';
|
|
45
33
|
export function unauthorizedChallenge(metadataUrl) {
|
|
46
34
|
return {
|
|
@@ -91,23 +79,26 @@ export async function startHttpServer(opts) {
|
|
|
91
79
|
const bindAll = process.env.FERRLABS_MCP_BIND_ALL === '1';
|
|
92
80
|
const host = defaultBindHost(opts.host, bindAll);
|
|
93
81
|
const allowedOrigins = parseList(process.env.FERRLABS_MCP_ALLOWED_ORIGINS);
|
|
94
|
-
const allowedHosts =
|
|
82
|
+
const { publicUrl, allowedHosts } = resolvePublicEndpoint({
|
|
83
|
+
publicUrl: opts.publicUrl ?? process.env.FERRLABS_MCP_PUBLIC_URL,
|
|
84
|
+
allowedHosts: parseList(process.env.FERRLABS_MCP_ALLOWED_HOSTS),
|
|
85
|
+
bindHost: host,
|
|
86
|
+
port,
|
|
87
|
+
});
|
|
95
88
|
const authServer = opts.authorizationServer ?? process.env.FERRLABS_AUTH_URL ?? 'https://api.ferrlabs.com';
|
|
96
89
|
const transports = new Map();
|
|
97
|
-
function sendUnauthorized(
|
|
98
|
-
const
|
|
99
|
-
const { wwwAuthenticate, body } = unauthorizedChallenge(`${origin}/.well-known/oauth-protected-resource`);
|
|
90
|
+
function sendUnauthorized(res) {
|
|
91
|
+
const { wwwAuthenticate, body } = unauthorizedChallenge(`${publicUrl}/.well-known/oauth-protected-resource`);
|
|
100
92
|
res.writeHead(401, {
|
|
101
93
|
'Content-Type': 'application/json',
|
|
102
94
|
'WWW-Authenticate': wwwAuthenticate,
|
|
103
95
|
});
|
|
104
96
|
res.end(body);
|
|
105
97
|
}
|
|
106
|
-
function sendResourceMetadata(
|
|
107
|
-
const origin = publicOrigin(req);
|
|
98
|
+
function sendResourceMetadata(res) {
|
|
108
99
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
109
100
|
res.end(JSON.stringify({
|
|
110
|
-
resource:
|
|
101
|
+
resource: publicUrl,
|
|
111
102
|
authorization_servers: [authServer],
|
|
112
103
|
bearer_methods_supported: ['header'],
|
|
113
104
|
resource_documentation: 'https://ferrlabs.com',
|
|
@@ -116,7 +107,7 @@ export async function startHttpServer(opts) {
|
|
|
116
107
|
async function handleMcpRequest(req, res) {
|
|
117
108
|
const bearerToken = extractBearer(req);
|
|
118
109
|
if (!bearerToken) {
|
|
119
|
-
sendUnauthorized(
|
|
110
|
+
sendUnauthorized(res);
|
|
120
111
|
return;
|
|
121
112
|
}
|
|
122
113
|
const sessionId = req.headers['mcp-session-id'] ?? undefined;
|
|
@@ -189,19 +180,19 @@ export async function startHttpServer(opts) {
|
|
|
189
180
|
res.end();
|
|
190
181
|
return;
|
|
191
182
|
}
|
|
192
|
-
if (!isHostAllowed(req.headers.host, allowedHosts)) {
|
|
193
|
-
res.writeHead(421, { 'Content-Type': 'application/json' });
|
|
194
|
-
res.end(JSON.stringify({ error: 'Host not allowed' }));
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
183
|
const url = req.url ?? '/';
|
|
198
184
|
if (url === '/health' || url === '/livez' || url === '/readyz') {
|
|
199
185
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
200
186
|
res.end(JSON.stringify({ status: 'ok', service: 'ferrlabs-mcp' }));
|
|
201
187
|
return;
|
|
202
188
|
}
|
|
189
|
+
if (!isHostAllowed(req.headers.host, allowedHosts)) {
|
|
190
|
+
res.writeHead(421, { 'Content-Type': 'application/json' });
|
|
191
|
+
res.end(JSON.stringify({ error: 'Host not allowed' }));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
203
194
|
if (url === '/.well-known/oauth-protected-resource') {
|
|
204
|
-
sendResourceMetadata(
|
|
195
|
+
sendResourceMetadata(res);
|
|
205
196
|
return;
|
|
206
197
|
}
|
|
207
198
|
if (url === '/mcp' || url.startsWith('/mcp?')) {
|
|
@@ -223,8 +214,9 @@ export async function startHttpServer(opts) {
|
|
|
223
214
|
await new Promise((resolve, reject) => {
|
|
224
215
|
httpServer.once('error', reject);
|
|
225
216
|
httpServer.listen(port, host, () => {
|
|
226
|
-
console.error(`ferrlabs-mcp HTTP transport listening on http://${host}:${port}/mcp`);
|
|
217
|
+
console.error(`ferrlabs-mcp HTTP transport listening on http://${host}:${port}/mcp, advertised as ${publicUrl}`);
|
|
227
218
|
resolve();
|
|
228
219
|
});
|
|
229
220
|
});
|
|
221
|
+
return httpServer;
|
|
230
222
|
}
|
package/package.json
CHANGED