Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/mcp-server/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const server = new ForestMCPServer({
envSecret: process.env.FOREST_ENV_SECRET,
authSecret: process.env.FOREST_AUTH_SECRET,
enabledTools: parseToolList(process.env.FOREST_MCP_ENABLED_TOOLS),
agentUrl: process.env.FOREST_AGENT_URL,
});

server.run().catch(error => {
Expand Down
9 changes: 8 additions & 1 deletion packages/mcp-server/src/forest-oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface ForestOAuthProviderOptions {
envSecret: string;
authSecret: string;
logger: Logger;
// Pre-normalized by the caller (ForestMCPServer); not re-validated here.
agentUrl?: string;
}

/**
Expand All @@ -43,6 +45,7 @@ export default class ForestOAuthProvider implements OAuthServerProvider {
private forestClient: ForestAdminClient;
private environmentId?: number;
private environmentApiEndpoint?: string;
private agentUrl?: string;
private logger: Logger;

constructor({
Expand All @@ -51,12 +54,14 @@ export default class ForestOAuthProvider implements OAuthServerProvider {
envSecret,
authSecret,
logger,
agentUrl,
}: ForestOAuthProviderOptions) {
this.forestServerUrl = forestServerUrl;
this.forestAppUrl = forestAppUrl;
this.envSecret = envSecret;
this.authSecret = authSecret;
this.logger = logger;
this.agentUrl = agentUrl;
this.forestClient = createForestAdminClient({
forestServerUrl: this.forestServerUrl,
envSecret: this.envSecret,
Expand Down Expand Up @@ -408,7 +413,9 @@ export default class ForestOAuthProvider implements OAuthServerProvider {
userId: decoded.id,
email: decoded.email,
renderingId: decoded.renderingId,
environmentApiEndpoint: this.environmentApiEndpoint,
// Tools call back into the agent at this URL. Prefer the configured internal agentUrl
// (self-hosted) and fall back to the environment's public api_endpoint.
environmentApiEndpoint: this.agentUrl ?? this.environmentApiEndpoint,
forestServerToken: decoded.serverToken,
},
};
Expand Down
9 changes: 9 additions & 0 deletions packages/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import declareGetActionFormTool from './tools/get-action-form';
import declareListTool from './tools/list';
import declareListRelatedTool from './tools/list-related';
import declareUpdateTool from './tools/update';
import normalizeAgentUrl from './utils/normalize-agent-url';
import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher';
import interceptResponseForErrorLogging from './utils/sse-error-logger';
import { NAME, VERSION } from './version';
Expand Down Expand Up @@ -126,6 +127,11 @@ export interface ForestMCPServerOptions {
* domain root.
*/
basePath?: string;
/**
* Standalone MCP server only (set via FOREST_AGENT_URL). Internal URL the tools use to reach
* the agent's data layer, defaulting to the environment's public `api_endpoint`.
*/
agentUrl?: string;
}

/**
Expand All @@ -148,6 +154,7 @@ export default class ForestMCPServer {
private collectionNames: string[] = [];
private enabledTools: Set<ToolName>;
private basePath: string;
private agentUrl?: string;

constructor(options?: ForestMCPServerOptions) {
this.forestServerUrl = options?.forestServerUrl || 'https://api.forestadmin.com';
Expand All @@ -157,6 +164,7 @@ export default class ForestMCPServer {
this.logger = options?.logger || defaultLogger;
this.enabledTools = this.resolveEnabledTools(options);
this.basePath = normalizeMountPath(options?.basePath);
this.agentUrl = normalizeAgentUrl(options?.agentUrl);

// Use injected forestServerClient or create default
this.forestServerClient = options?.forestServerClient ?? this.createDefaultForestServerClient();
Expand Down Expand Up @@ -475,6 +483,7 @@ export default class ForestMCPServer {
envSecret,
authSecret,
logger: this.logger,
agentUrl: this.agentUrl,
});
await oauthProvider.initialize();

Expand Down
25 changes: 25 additions & 0 deletions packages/mcp-server/src/utils/normalize-agent-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// agent-client concatenates the request path directly onto this URL, so a query string or fragment
// would swallow it — reject them, and return the parsed, trailing-slash-free form.
export default function normalizeAgentUrl(agentUrl?: string): string | undefined {
if (!agentUrl) return undefined;

let parsed: URL;

try {
parsed = new URL(agentUrl);
} catch {
throw new Error(`Invalid agentUrl "${agentUrl}": it must be an absolute http(s) URL.`);
}

if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`Invalid agentUrl "${agentUrl}": only http and https are supported.`);
}

if (parsed.search || parsed.hash) {
throw new Error(
`Invalid agentUrl "${agentUrl}": it must not contain a query string or fragment.`,
);
}

return parsed.href.replace(/\/+$/, '');
}
48 changes: 47 additions & 1 deletion packages/mcp-server/test/forest-oauth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ const TEST_ENV_SECRET = 'test-env-secret';
const TEST_AUTH_SECRET = 'test-auth-secret';
const TEST_FOREST_APP_URL = 'https://app.forestadmin.com';

function createProvider(forestServerUrl = 'https://api.forestadmin.com') {
function createProvider(forestServerUrl = 'https://api.forestadmin.com', agentUrl?: string) {
return new ForestOAuthProvider({
forestServerUrl,
forestAppUrl: TEST_FOREST_APP_URL,
envSecret: TEST_ENV_SECRET,
authSecret: TEST_AUTH_SECRET,
logger: console.info,
agentUrl,
});
}

Expand Down Expand Up @@ -835,6 +836,51 @@ describe('ForestOAuthProvider', () => {
});
});

it('uses agentUrl as the tool callback endpoint when configured', async () => {
(jsonwebtoken.verify as jest.Mock).mockReturnValue({
id: 1,
email: 'user@example.com',
renderingId: 2,
serverToken: 'forest-server-token',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
});

const provider = createProvider(
'https://api.forestadmin.com',
'http://forest-agent.internal:3310',
);
const result = await provider.verifyAccessToken('valid-access-token');

expect((result.extra as { environmentApiEndpoint: string }).environmentApiEndpoint).toBe(
'http://forest-agent.internal:3310',
);
});

it('falls back to the environment api_endpoint when no agentUrl is set', async () => {
mockServer.get('/liana/environment', {
data: { id: '1', attributes: { api_endpoint: 'https://public.example.com' } },
});
global.fetch = mockServer.fetch;

(jsonwebtoken.verify as jest.Mock).mockReturnValue({
id: 1,
email: 'user@example.com',
renderingId: 2,
serverToken: 'forest-server-token',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
});

const provider = createProvider();
await provider.initialize();
const result = await provider.verifyAccessToken('valid-access-token');

expect((result.extra as { environmentApiEndpoint: string }).environmentApiEndpoint).toBe(
'https://public.example.com',
);
});

it('should throw error for expired access token', async () => {
(jsonwebtoken.verify as jest.Mock).mockImplementation(() => {
throw new jsonwebtoken.TokenExpiredError('jwt expired', new Date());
Expand Down
121 changes: 121 additions & 0 deletions packages/mcp-server/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2704,6 +2704,127 @@
});
});

describe('agentUrl option', () => {
const originalFetch = global.fetch;

afterEach(() => {
global.fetch = originalFetch;
});

it('does not affect the advertised OAuth metadata URLs', async () => {
const mockFetchServer = new MockServer();
mockFetchServer
.get('/liana/environment', {
data: { id: '1', attributes: { api_endpoint: 'https://api.example.com' } },
})
.get(/\/oauth\/register\//, { error: 'Client not found' }, 404);
global.fetch = mockFetchServer.fetch;

const server = new ForestMCPServer({
envSecret: 'ENV_SECRET',
authSecret: 'AUTH_SECRET',
forestServerClient: createMockForestServerClient(),
agentUrl: 'http://forest-agent.internal:3310',
});
const app = await server.buildExpressApp(new URL('http://localhost:3000'));

const response = await request(app).get('/.well-known/oauth-authorization-server');

expect(response.status).toBe(200);
expect(response.body.issuer).toBe('http://localhost:3000/');
expect(response.body.authorization_endpoint).toBe('http://localhost:3000/oauth/authorize');
expect(response.body.token_endpoint).toBe('http://localhost:3000/oauth/token');
});

it('routes tool calls to agentUrl instead of the public api_endpoint', async () => {
clearSchemaCache();
process.env.FOREST_ENV_SECRET = 'test-env-secret';
process.env.FOREST_AUTH_SECRET = 'test-auth-secret';
process.env.MCP_SERVER_PORT = (await getAvailablePort()).toString();

let capturedForestUrl: string | undefined;
const mockServer = new MockServer();
mockServer
// Public endpoint Forest has on file — the tool must NOT use this one.
.get('/liana/environment', {
data: { id: '1', attributes: { api_endpoint: 'https://public.example.com' } },
})
.get('/liana/forest-schema', {
data: [
{
id: 'users',
type: 'collections',
attributes: {
name: 'users',
fields: [{ field: 'id', type: 'Number', isSortable: true }],
},
},
],
meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null },
})
.post('/api/activity-logs-requests', {
data: { id: 'log-1', attributes: { index: 'logs' } },
})
.get(/\/forest\/\w+/, url => {
capturedForestUrl = url;

return { data: [] };
});
global.fetch = mockServer.fetch;
mockServer.setupSuperagentMock();

const server = new ForestMCPServer({
envSecret: 'test-env-secret',
authSecret: 'test-auth-secret',
forestServerUrl: 'https://test.forestadmin.com',
forestServerClient: createMockForestServerClient({
fetchSchema: jest
.fn()
.mockResolvedValue([
{ name: 'users', fields: [{ field: 'id', type: 'Number', isSortable: true }] },
]),
}),
agentUrl: 'http://internal-agent:9999',
});
server.run();
await new Promise(resolve => {
setTimeout(resolve, 500);
});

try {
const token = jsonwebtoken.sign(
{
id: 123,
email: 'user@example.com',
renderingId: 456,
serverToken: 'forest-server-token',
},
'test-auth-secret',
{ expiresIn: '1h' },
);

const response = await request(server.httpServer as http.Server)
.post('/mcp')
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json, text/event-stream')
.send({
jsonrpc: '2.0',
method: 'tools/call',
id: 1,
params: { name: 'list', arguments: { collectionName: 'users' } },
});

expect(response.status).toBe(200);
expect(capturedForestUrl).toContain('http://internal-agent:9999/forest/');
expect(capturedForestUrl).not.toContain('public.example.com');
} finally {
mockServer.restoreSuperagent();
await shutDownHttpServer(server.httpServer as http.Server);
}
});
});

describe('handleMcpRequest cleanup', () => {
const originalFetch = global.fetch;
let cleanupServer: ForestMCPServer;
Expand Down Expand Up @@ -3088,7 +3209,7 @@
envSecret: 'ENV_SECRET',
authSecret: 'AUTH_SECRET',
logger,
enabledTools: ['list', 'lst', 'creat' as any],

Check warning on line 3212 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
27 changes: 27 additions & 0 deletions packages/mcp-server/test/utils/normalize-agent-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import normalizeAgentUrl from '../../src/utils/normalize-agent-url';

describe('normalizeAgentUrl', () => {
it.each([undefined, ''])('returns undefined for %p', input => {
expect(normalizeAgentUrl(input)).toBeUndefined();
});

it.each([
['http://forest-agent.internal:3310', 'http://forest-agent.internal:3310'],
['https://forest-agent.internal', 'https://forest-agent.internal'],
])('accepts %p and returns it unchanged', (input, expected) => {
expect(normalizeAgentUrl(input)).toBe(expected);
});

it('preserves a base path and strips a trailing slash', () => {
expect(normalizeAgentUrl('http://internal:3310/backend/')).toBe('http://internal:3310/backend');
});

it.each([
'not-a-url',
'ftp://internal:3310',
'http://internal:3310?x=1',
'http://internal:3310/#frag',
])('throws for %p', input => {
expect(() => normalizeAgentUrl(input)).toThrow(/Invalid agentUrl/);
});
});
Loading