@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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=http-host.test.d.ts.map
@@ -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 { unauthorizedChallenge, resolveAllowedOrigin, isHostAllowed, defaultBindHost, parseList, } from '../http.js';
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<void>;
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":"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"}
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"}
@@ -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 = parseList(process.env.FERRLABS_MCP_ALLOWED_HOSTS);
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(req, res) {
98
- const origin = publicOrigin(req);
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(req, res) {
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: origin,
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(req, res);
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(req, res);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferrlabs/mcp-core",
3
- "version": "8.2.1",
3
+ "version": "9.0.0",
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",