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();