Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>` | 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 |
Expand Down Expand Up @@ -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.
Expand Down
58 changes: 55 additions & 3 deletions packages/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,16 +788,68 @@ export default class ForestMCPServer {
* Run the MCP server as a standalone HTTP server.
*/
async run(): Promise<void> {
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}`;
Comment thread
hercemer42 marked this conversation as resolved.
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 (
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
!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);

// Create HTTP server from Express app
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.',
);
}
});
}
}
148 changes: 148 additions & 0 deletions packages/mcp-server/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,157 @@

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();
Expand Down Expand Up @@ -3513,7 +3661,7 @@
envSecret: 'ENV_SECRET',
authSecret: 'AUTH_SECRET',
logger,
enabledTools: ['list', 'lst', 'creat' as any],

Check warning on line 3664 in packages/mcp-server/test/server.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (mcp-server)

Unexpected any. Specify a different type
});

expect(server).toBeDefined();
Expand Down
Loading