From 059365a8aec3a3ea6030a0e2fc80dc55f4a564e6 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 17 Aug 2026 15:20:38 +0200 Subject: [PATCH] fix(mcp-server): let a deployed standalone server know its public url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run() hardcoded baseUrl to http://localhost:, and that explicit value wins over the environment's api_endpoint. A process cannot derive its own public url — dns, proxy and tls termination live outside it — so a deployed standalone server advertised localhost as its OAuth issuer, and on the in-memory store minted localhost upload urls no remote client can reach. FOREST_MCP_SERVER_URL, when set, becomes the base url and fixes both at once. Unset, nothing changes. The value must be an http(s) origin with no path, query, fragment or credentials, enforced at startup: the OAuth endpoints are concatenated onto the href and the uploads base resolves against the origin, so anything else advertises broken urls that only fail once a client follows them. The rejection reports the parsed origin rather than the raw value, so credentials this check exists to withhold do not end up in the logs instead. MCP_SERVER_PORT is validated on its own rather than as a side effect of parsing the default url, which a configured FOREST_MCP_SERVER_URL replaces: listen() would otherwise reject a bad port with a bare RangeError, after the schema fetch and the OAuth initialization, and not at all in the configuration this option adds. The startup line reports both facts now that they can differ — the port the socket bound, and the url clients are told — and warns when a server is left on the localhost default, which is the only runtime chance to catch it. --- packages/mcp-server/README.md | 8 ++ packages/mcp-server/src/server.ts | 58 +++++++++- packages/mcp-server/test/server.test.ts | 148 ++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 3 deletions(-) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 437293e87d..5d0a7f8389 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -63,6 +63,7 @@ yarn start:dev # Development (loads .env file automatically) | `FOREST_ENV_SECRET` | **Yes** | - | Your Forest Admin environment secret | | `FOREST_AUTH_SECRET` | **Yes** | - | Your Forest Admin authentication secret (must match your agent) | | `MCP_SERVER_PORT` | No | `3931` | Port for the HTTP server | +| `FOREST_MCP_SERVER_URL` | No | `http://localhost:` | Public URL this server is reachable at — an http(s) **origin only**, no path. **Required for any deployed server**: without it the OAuth metadata advertise `localhost`, and the in-memory upload store mints `localhost` upload URLs no remote client can reach | | `FOREST_MCP_ENABLED_TOOLS` | No | - | Comma-separated list of tools to enable (allowlist) | | `FOREST_MCP_ALLOWED_OAUTH_CLIENTS` | No | - | Comma-separated domains of the OAuth client applications allowed to connect (`allowedOAuthClients`). Unset, any registered client is accepted | | `FOREST_AGENT_URL` | No | your environment's back-end URL | URL the MCP server uses to reach the back-end's data layer. Set it when the server runs next to a self-hosted back-end at an internal address (e.g. `http://localhost:3310`), instead of the public URL registered in Forest | @@ -92,6 +93,13 @@ Or set the variables inline: FOREST_ENV_SECRET="your-env-secret" FOREST_AUTH_SECRET="your-auth-secret" npx forest-mcp-server ``` +Deployed behind a public URL, add it — clients are sent wherever this says: + +```bash +FOREST_MCP_SERVER_URL="https://mcp.example.com" \ + FOREST_ENV_SECRET="your-env-secret" FOREST_AUTH_SECRET="your-auth-secret" npx forest-mcp-server +``` + ## Restrict Tools You can restrict which tools the MCP server exposes using `enabledTools`. Only the listed tools will be available. **New tools added in future releases will NOT be automatically enabled** — you must explicitly add them. diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index d4c7bf9455..6b2cc45a7c 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -788,8 +788,49 @@ export default class ForestMCPServer { * Run the MCP server as a standalone HTTP server. */ async run(): Promise { - const port = Number(process.env.MCP_SERVER_PORT) || 3931; - const baseUrl = new URL(`http://localhost:${port}`); + // Parsed before defaulting: `Number(x) || 3931` turns port 0, which means "any free port", + // into 3931. A configured FOREST_MCP_SERVER_URL also replaces the default url, so nothing else + // parses the port either. + const rawPort = process.env.MCP_SERVER_PORT; + const port = rawPort ? Number(rawPort) : 3931; + const configuredUrl = process.env.FOREST_MCP_SERVER_URL; + + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error( + `Invalid MCP_SERVER_PORT "${rawPort}": expected an integer between 0 and 65535.`, + ); + } + + // The url is built here, before listen() picks the port, so 0 cannot appear in it. + if (port === 0 && !configuredUrl) { + throw new Error( + 'MCP_SERVER_PORT=0 binds a port chosen by the OS, which cannot be in the url advertised ' + + 'to clients. Set FOREST_MCP_SERVER_URL to the public url they should use.', + ); + } + + const publicUrl = configuredUrl || `http://localhost:${port}`; + const baseUrl = URL.canParse(publicUrl) ? new URL(publicUrl) : undefined; + + // Origin only: the OAuth endpoints are concatenated onto this href, the uploads base resolves + // against it. + if ( + !baseUrl || + !['http:', 'https:'].includes(baseUrl.protocol) || + baseUrl.href !== `${baseUrl.origin}/` + ) { + // Never the raw value: it may carry credentials. `origin` is "null" for an opaque scheme, + // which is what a forgotten scheme parses as. + const shown = + baseUrl && baseUrl.origin !== 'null' + ? baseUrl.origin + : publicUrl.slice(publicUrl.lastIndexOf('@') + 1); + + throw new Error( + `Invalid FOREST_MCP_SERVER_URL "${shown}": expected an http(s) origin with no path, ` + + 'query, fragment or credentials, e.g. https://mcp.example.com', + ); + } const app = await this.buildExpressApp(baseUrl); @@ -797,7 +838,18 @@ export default class ForestMCPServer { this.httpServer = http.createServer(app); this.httpServer.listen(port, () => { - this.logger('Info', `Forest Admin MCP Server running on http://localhost:${port}`); + this.logger( + 'Info', + `Forest Admin MCP Server running on port ${port}, advertising ${baseUrl.href}`, + ); + + if (!configuredUrl) { + this.logger( + 'Warn', + `Advertising http://localhost:${port} to clients. Deployed behind a public url? Set ` + + 'FOREST_MCP_SERVER_URL, or remote OAuth and file uploads will point at localhost.', + ); + } }); } } diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 60e3e36a59..a31c3bf077 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -118,9 +118,157 @@ describe('ForestMCPServer Instance', () => { describe('run method', () => { afterEach(async () => { + delete process.env.FOREST_MCP_SERVER_URL; + delete process.env.MCP_SERVER_PORT; await shutDownHttpServer(server?.httpServer as http.Server); }); + it('advertises FOREST_MCP_SERVER_URL instead of localhost when set', async () => { + const testPort = await getAvailablePort(); + process.env.MCP_SERVER_PORT = testPort.toString(); + process.env.FOREST_MCP_SERVER_URL = 'https://mcp.example.com'; + + server = new ForestMCPServer({ + authSecret: 'AUTH_SECRET', + envSecret: 'ENV_SECRET', + forestServerClient: createMockForestServerClient(), + }); + server.run(); + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + // global.fetch is the Forest mock here. + const response = await originalFetch( + `http://localhost:${testPort}/.well-known/oauth-authorization-server`, + ); + const metadata = (await response.json()) as { issuer: string }; + + expect(metadata.issuer).toBe('https://mcp.example.com/'); + + // The other consumer of the same base url — the half that motivated the option. + const { ephemeralStorage } = server as unknown as { + ephemeralStorage: { createUploadUrl(p: { key: string }): Promise<{ url: string }> }; + }; + const { url } = await ephemeralStorage.createUploadUrl({ key: 'k' }); + + expect(url.startsWith('https://mcp.example.com/mcp/uploads/')).toBe(true); + }); + + // The OAuth endpoints are concatenated onto the href and the uploads base resolves against the + // origin, so anything beyond an http(s) origin advertises broken urls instead of failing here. + it.each([ + ['a trailing slash', 'https://mcp.example.com/', 'https://mcp.example.com/'], + ['no trailing slash', 'https://mcp.example.com', 'https://mcp.example.com/'], + ['an explicit default port', 'https://mcp.example.com:443', 'https://mcp.example.com/'], + ['a custom port', 'https://mcp.example.com:8443', 'https://mcp.example.com:8443/'], + ])('accepts a FOREST_MCP_SERVER_URL with %s', async (_, value, expected) => { + const testPort = await getAvailablePort(); + process.env.MCP_SERVER_PORT = testPort.toString(); + process.env.FOREST_MCP_SERVER_URL = value; + + server = new ForestMCPServer({ + authSecret: 'AUTH_SECRET', + envSecret: 'ENV_SECRET', + forestServerClient: createMockForestServerClient(), + }); + server.run(); + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + const response = await originalFetch( + `http://localhost:${testPort}/.well-known/oauth-authorization-server`, + ); + + expect(((await response.json()) as { issuer: string }).issuer).toBe(expected); + }); + + it('refuses MCP_SERVER_PORT=0 unless it is told what to advertise', async () => { + process.env.MCP_SERVER_PORT = '0'; + + server = new ForestMCPServer({ + authSecret: 'AUTH_SECRET', + envSecret: 'ENV_SECRET', + forestServerClient: createMockForestServerClient(), + }); + + await expect(server.run()).rejects.toThrow(/MCP_SERVER_PORT=0 binds a port chosen by the OS/); + }); + + it('binds an ephemeral port on MCP_SERVER_PORT=0 rather than falling back to 3931', async () => { + process.env.MCP_SERVER_PORT = '0'; + process.env.FOREST_MCP_SERVER_URL = 'https://mcp.example.com'; + + server = new ForestMCPServer({ + authSecret: 'AUTH_SECRET', + envSecret: 'ENV_SECRET', + forestServerClient: createMockForestServerClient(), + }); + server.run(); + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + const { port } = (server.httpServer as http.Server).address() as net.AddressInfo; + + expect(port).toBeGreaterThan(0); + expect(port).not.toBe(3931); + }); + + it.each([ + ['out of range', '99999'], + ['fractional', '3931.5'], + ['negative', '-1'], + ['non-numeric', 'abc'], + ])('refuses an %s MCP_SERVER_PORT before doing any work', async (_, value) => { + process.env.MCP_SERVER_PORT = value; + process.env.FOREST_MCP_SERVER_URL = 'https://mcp.example.com'; + + server = new ForestMCPServer({ + authSecret: 'AUTH_SECRET', + envSecret: 'ENV_SECRET', + forestServerClient: createMockForestServerClient(), + }); + + await expect(server.run()).rejects.toThrow(`Invalid MCP_SERVER_PORT "${value}"`); + }); + + it('does not echo credentials back when rejecting them', async () => { + process.env.FOREST_MCP_SERVER_URL = 'https://svc:p4ssw0rd@mcp.example.com'; + + server = new ForestMCPServer({ + authSecret: 'AUTH_SECRET', + envSecret: 'ENV_SECRET', + forestServerClient: createMockForestServerClient(), + }); + const error = (await server.run().catch((e: Error) => e)) as Error; + + expect(error.message).toContain('Invalid FOREST_MCP_SERVER_URL "https://mcp.example.com"'); + expect(error.message).not.toContain('p4ssw0rd'); + }); + + it.each([ + ['no scheme', 'mcp.example.com', 'mcp.example.com'], + ['a host-port pair that parses as a scheme', 'mcp.example.com:8080', 'mcp.example.com:8080'], + ['a path', 'https://example.com/mcp-server', 'https://example.com'], + ['a query', 'https://example.com?tenant=x', 'https://example.com'], + ['a fragment', 'https://example.com#prod', 'https://example.com'], + ['a non-http scheme', 'ftp://example.com', 'ftp://example.com'], + ['credentials', 'https://user:secret@example.com', 'https://example.com'], + ['credentials and no scheme', 'user:secret@example.com', 'example.com'], + ])('refuses a FOREST_MCP_SERVER_URL with %s', async (_, value, shown) => { + process.env.FOREST_MCP_SERVER_URL = value; + + server = new ForestMCPServer({ + authSecret: 'AUTH_SECRET', + envSecret: 'ENV_SECRET', + forestServerClient: createMockForestServerClient(), + }); + + await expect(server.run()).rejects.toThrow(`Invalid FOREST_MCP_SERVER_URL "${shown}"`); + }); + it('should start server on specified port', async () => { const testPort = await getAvailablePort(); process.env.MCP_SERVER_PORT = testPort.toString();