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
45 changes: 32 additions & 13 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
AgentOptions,
AgentOptionsWithDefaults,
HttpCallback,
McpRouteMatcher,
WorkflowExecutorEmbedOptions,
} from './types';
import type { AiProviderDefinition } from '@forestadmin/agent-toolkit';
Expand Down Expand Up @@ -55,6 +56,7 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
/** Whether MCP server should be mounted */
private mcpEnabled = false;
private mcpEnabledTools?: ToolName[];
private mcpBasePath?: string;

/** In-process workflow executor, created only when addWorkflowExecutor() is called. */
private embeddedExecutor: EmbeddedWorkflowExecutor | null = null;
Expand Down Expand Up @@ -94,12 +96,12 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
*/
async start(): Promise<void> {
try {
const { router, mcpHttpCallback } = await this.buildRouterAndSendSchema();
const { router, mcpHttpCallback, mcpIsMcpRoute } = await this.buildRouterAndSendSchema();

await this.options.forestAdminClient.subscribeToServerEvents();
this.options.forestAdminClient.onRefreshCustomizations(this.restart.bind(this));

this.setMcpCallback(mcpHttpCallback ?? null);
this.setMcpCallback(mcpHttpCallback ?? null, mcpIsMcpRoute);
await this.mount(router);

// Boot after mount(): the embedded executor reaches the agent over HTTP, and the
Expand Down Expand Up @@ -140,9 +142,9 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter

try {
// We force sending schema when restarting
const { router, mcpHttpCallback } = await this.buildRouterAndSendSchema();
const { router, mcpHttpCallback, mcpIsMcpRoute } = await this.buildRouterAndSendSchema();

this.setMcpCallback(mcpHttpCallback ?? null);
this.setMcpCallback(mcpHttpCallback ?? null, mcpIsMcpRoute);
await this.remount(router);
} finally {
this.isRestarting = false;
Expand Down Expand Up @@ -247,10 +249,15 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
* agent.mountAiMcpServer();
* // Example: read-only mode (only browse data, no create/update/delete/actions)
* agent.mountAiMcpServer({ enabledTools: ['describeCollection', 'list', 'listRelated'] });
* // Example: scope MCP routes under a prefix to avoid colliding with your app's own OAuth routes.
* // OAuth discovery metadata stays at the origin root (prefix-suffixed), so root `.well-known`
* // traffic must still reach the agent.
* agent.mountAiMcpServer({ basePath: '/ai' });
*/
mountAiMcpServer(options?: { enabledTools?: ToolName[] }): this {
mountAiMcpServer(options?: { enabledTools?: ToolName[]; basePath?: string }): this {
this.mcpEnabled = true;
this.mcpEnabledTools = options?.enabledTools;
this.mcpBasePath = options?.basePath;

return this;
}
Expand Down Expand Up @@ -350,9 +357,11 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
* Create an http handler which can respond to all queries which are expected from an agent.
* Returns the main router and optional MCP HTTP callback.
*/
private async getRouter(
dataSource: DataSource,
): Promise<{ router: Router; mcpHttpCallback?: HttpCallback }> {
private async getRouter(dataSource: DataSource): Promise<{
router: Router;
mcpHttpCallback?: HttpCallback;
mcpIsMcpRoute?: McpRouteMatcher;
}> {
// Bootstrap app
const services = makeServices(this.options);
const routes = this.getRoutes(dataSource, services);
Expand All @@ -361,9 +370,11 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter

// Initialize MCP server if enabled via mountAiMcpServer()
let mcpHttpCallback: HttpCallback | undefined;
let mcpIsMcpRoute: McpRouteMatcher | undefined;

if (this.mcpEnabled) {
mcpHttpCallback = await this.initializeMcpServer();
({ httpCallback: mcpHttpCallback, isMcpRoute: mcpIsMcpRoute } =
await this.initializeMcpServer());
}

// Build main router
Expand All @@ -379,20 +390,25 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
);
routes.forEach(route => route.setupRoutes(router));

return { router, mcpHttpCallback };
return { router, mcpHttpCallback, mcpIsMcpRoute };
}

/**
* Initialize the MCP server.
* Uses dynamic import to defer loading until mountAiMcpServer() is actually used.
* This avoids loading the mcp-server dependency at startup for users who don't use MCP.
*/
private async initializeMcpServer(): Promise<HttpCallback> {
private async initializeMcpServer(): Promise<{
httpCallback: HttpCallback;
isMcpRoute: McpRouteMatcher;
}> {
const mcpLogger = (level, message) => this.options.logger(level, `[MCP] ${message}`);

try {
// Dynamic import to defer loading until actually needed
const { ForestMCPServer, ForestServerClientImpl } = await import('@forestadmin/mcp-server');
const { ForestMCPServer, ForestServerClientImpl, makeIsMcpRoute } = await import(
'@forestadmin/mcp-server'
);

const forestServerClient = new ForestServerClientImpl(
this.options.forestAdminClient.schemaService,
Expand All @@ -408,13 +424,15 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
logger: mcpLogger,
forestServerClient,
enabledTools: this.mcpEnabledTools,
basePath: this.mcpBasePath,
});

const httpCallback = await mcpServer.getHttpCallback();
const isMcpRoute = makeIsMcpRoute(this.mcpBasePath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Fable 5 — [Preferential] The matcher is rebuilt here from the raw string while the callback's internal filter builds its own from the normalized field (server.ts:646) — they agree only because both call the same function, and { mcpHttpCallback?; mcpIsMcpRoute? } makes callback-without-matcher representable (silently falling back to the root-path matcher in McpMiddleware). Having getHttpCallback() return its matcher — or passing the pair as one object to setMcpCallback — makes the mismatch unrepresentable, and would let agent/types.ts reuse McpRouteMatcher via import type instead of duplicating it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged (preferential). The two matchers agree by construction — both are makeIsMcpRoute fed the same basePath (normalized identically), so they can't diverge without editing the function itself. Returning the matcher from getHttpCallback would change its return contract and ripple into the standalone run() path and its tests; I kept the threading (which mirrors the existing httpCallback channel) to keep the diff contained. Happy to do the object-return refactor as a follow-up if you prefer it.


mcpLogger('Info', 'Server initialized successfully');

return httpCallback;
return { httpCallback, isMcpRoute };
} catch (error) {
const { message } = error as Error;
mcpLogger('Error', `Failed to initialize MCP server: ${message}`);
Expand Down Expand Up @@ -443,6 +461,7 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
private async buildRouterAndSendSchema(): Promise<{
router: Router;
mcpHttpCallback?: HttpCallback;
mcpIsMcpRoute?: McpRouteMatcher;
}> {
const { isProduction, logger, typingsPath, typingsMaxDepth } = this.options;

Expand Down
6 changes: 3 additions & 3 deletions packages/agent/src/framework-mounter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { HttpCallback } from './types';
import type { HttpCallback, McpRouteMatcher } from './types';
import type { Logger } from '@forestadmin/datasource-toolkit';
import type net from 'net';

Expand Down Expand Up @@ -40,8 +40,8 @@ export default class FrameworkMounter {
/**
* Set the MCP HTTP callback. Call this before mount() or remount().
*/
protected setMcpCallback(callback: HttpCallback | null): void {
this.mcpMiddleware.setCallback(callback);
protected setMcpCallback(callback: HttpCallback | null, routeMatcher?: McpRouteMatcher): void {
this.mcpMiddleware.setCallback(callback, routeMatcher);
}

protected async mount(router: Router): Promise<void> {
Expand Down
12 changes: 5 additions & 7 deletions packages/agent/src/mcp-middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { HttpCallback } from './types';
import type { HttpCallback, McpRouteMatcher } from './types';
import type Koa from 'koa';

import expressToKoa from './utils/express-to-koa';
Expand All @@ -22,12 +22,11 @@ function isMcpRoute(url: string): boolean {
*/
export default class McpMiddleware {
private mcpHttpCallback: HttpCallback | null = null;
private routeMatcher: McpRouteMatcher = isMcpRoute;

/**
* Set the MCP HTTP callback. Call this before using the middleware.
*/
setCallback(callback: HttpCallback | null): void {
setCallback(callback: HttpCallback | null, routeMatcher?: McpRouteMatcher): void {
this.mcpHttpCallback = callback;
this.routeMatcher = routeMatcher ?? isMcpRoute;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Fable 5 — [Preferential] The local fallback isMcpRoute (L9-16) keeps the old bare-startsWith semantics — it still claims /mcp-dashboard, which makeIsMcpRoute deliberately stopped matching — and the class JSDoc above still presents the root paths as unconditional. The fallback is nearly dead (null callback ⇒ pass-through), but nothing marks it as fallback-only, so a future reader may trust the stale semantics. Align it with the segment-boundary matcher or mark it explicitly as the null-callback fallback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept as-is (preferential). The local isMcpRoute is only the default value before a matcher is set / when the callback is null — a pass-through case. When MCP is enabled the agent always passes the prefix-aware matcher via setCallback(cb, matcher), so the bare-startsWith semantics are never exercised in a live MCP mount. Importing the real matcher from @forestadmin/mcp-server here would defeat the dynamic-import laziness (non-MCP users must not load the package), which is why the local copy exists.

}

/**
Expand Down Expand Up @@ -64,8 +63,7 @@ export default class McpMiddleware {
next();
}
},
// MCP routes are at root: /.well-known/*, /oauth/*, /mcp
isMcpRoute,
url => this.routeMatcher(url),
);
}
}
2 changes: 2 additions & 0 deletions packages/agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export type AgentOptionsWithDefaults = Readonly<Required<AgentOptions>>;

export type HttpCallback = (req: IncomingMessage, res: ServerResponse, next?: () => void) => void;

export type McpRouteMatcher = (url: string) => boolean;

export enum HttpCode {
BadRequest = 400,
Unauthorized = 401,
Expand Down
27 changes: 27 additions & 0 deletions packages/agent/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,33 @@ describe('Agent', () => {
);
});

test('should pass basePath to ForestMCPServer', async () => {
const options = factories.forestAdminHttpDriverOptions.build();
const agent = new Agent(options);

agent.mountAiMcpServer({ basePath: '/mcp' });
await agent.start();

expect(mcpServerSpy).toHaveBeenCalledWith(expect.objectContaining({ basePath: '/mcp' }));
});

test('threads a basePath-scoped route matcher to the MCP middleware', async () => {
const options = factories.forestAdminHttpDriverOptions.build();
const agent = new Agent(options);
type SetMcpCallback = (cb: unknown, matcher?: (url: string) => boolean) => void;
const setMcpCallbackSpy = jest.spyOn(
agent as unknown as { setMcpCallback: SetMcpCallback },
'setMcpCallback',
);

agent.mountAiMcpServer({ basePath: '/ai' });
await agent.start();

const matcher = setMcpCallbackSpy.mock.calls.at(-1)?.[1];
expect(matcher?.('/ai/mcp')).toBe(true);
expect(matcher?.('/oauth/token')).toBe(false);
});

test('should log error when MCP initialization fails', async () => {
const mockLogger = jest.fn();
const options = factories.forestAdminHttpDriverOptions.build({ logger: mockLogger });
Expand Down
35 changes: 35 additions & 0 deletions packages/agent/test/mcp-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import McpMiddleware from '../src/mcp-middleware';

describe('McpMiddleware', () => {
function makeCtx(url: string) {
return { url, req: {}, res: { once: jest.fn() }, respond: true } as any;

Check warning on line 5 in packages/agent/test/mcp-middleware.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent)

Unexpected any. Specify a different type
}

test('getKoaMiddleware gates requests with the custom route matcher', async () => {
const middleware = new McpMiddleware();
const callback = jest.fn((req, res, next) => next());
middleware.setCallback(callback, url => url.startsWith('/custom'));

const koaMiddleware = middleware.getKoaMiddleware();

await koaMiddleware(makeCtx('/mcp'), jest.fn().mockResolvedValue(undefined) as any);

Check warning on line 15 in packages/agent/test/mcp-middleware.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent)

Unexpected any. Specify a different type
expect(callback).not.toHaveBeenCalled();

await koaMiddleware(makeCtx('/custom/x'), jest.fn().mockResolvedValue(undefined) as any);

Check warning on line 18 in packages/agent/test/mcp-middleware.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent)

Unexpected any. Specify a different type
expect(callback).toHaveBeenCalledTimes(1);
});

test('falls back to the default root matcher when none is provided', async () => {
const middleware = new McpMiddleware();
const callback = jest.fn((req, res, next) => next());
middleware.setCallback(callback);

const koaMiddleware = middleware.getKoaMiddleware();

await koaMiddleware(makeCtx('/api/other'), jest.fn().mockResolvedValue(undefined) as any);

Check warning on line 29 in packages/agent/test/mcp-middleware.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent)

Unexpected any. Specify a different type
expect(callback).not.toHaveBeenCalled();

await koaMiddleware(makeCtx('/mcp'), jest.fn().mockResolvedValue(undefined) as any);

Check warning on line 32 in packages/agent/test/mcp-middleware.test.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (agent)

Unexpected any. Specify a different type
expect(callback).toHaveBeenCalledTimes(1);
});
});
2 changes: 1 addition & 1 deletion packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Library exports only - no side effects
export { default as ForestMCPServer } from './server';
export type { ForestMCPServerOptions, HttpCallback, ToolName } from './server';
export { MCP_PATHS, isMcpRoute } from './mcp-paths';
export { MCP_PATHS, isMcpRoute, makeIsMcpRoute } from './mcp-paths';
export { ForestServerClientImpl, createForestServerClient } from './http-client';
export type {
ForestServerClient,
Expand Down
59 changes: 54 additions & 5 deletions packages/mcp-server/src/mcp-paths.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,56 @@
/** MCP-specific paths that should be handled by the MCP server */
export const MCP_PATHS = ['/.well-known/', '/oauth/', '/mcp'];
export type McpRouteMatcher = (url: string) => boolean;

/** Check if a URL is an MCP route */
export function isMcpRoute(url: string): boolean {
return MCP_PATHS.some(p => url === p || url.startsWith(p));
export function normalizeMountPath(input?: string): string {
if (!input) return '';

const trimmed = input.trim();
if (trimmed === '' || trimmed === '/') return '';

const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
const collapsed = withLeadingSlash.replace(/\/+/g, '/').replace(/\/+$/, '');

// Allow only unreserved path characters per segment. The prefix is interpolated raw into
// Express/Koa route patterns (path-to-regexp) and resolved through new URL() for the OAuth
// metadata; an allowlist keeps those two interpretations identical and future-proof.
if (!/^(\/[A-Za-z0-9_-]+)+$/.test(collapsed)) {
throw new Error(
`Invalid MCP mount path "${input}": use a plain path prefix like "/mcp" ` +
`(letters, digits, "-" and "_" only).`,
);
}

return collapsed;
}

/**
* Well-known paths stay anchored at the origin root (per RFC 8414/9728) but carry the prefix
* as a suffix, so a host's own root OAuth metadata is not claimed.
*/
export function buildMcpPaths(prefix = ''): string[] {
const normalized = normalizeMountPath(prefix);

const wellKnown = normalized
? [
`/.well-known/oauth-authorization-server${normalized}`,
`/.well-known/oauth-protected-resource${normalized}`,
]
: ['/.well-known/'];

return [...wellKnown, `${normalized}/oauth/`, `${normalized}/mcp`];
}

export function makeIsMcpRoute(prefix = ''): McpRouteMatcher {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const paths = buildMcpPaths(prefix);

// Match on the pathname (req.url carries the query string) and on a segment boundary, so
// '/mcp?x=1' still matches and '/ai/mcp' does not shadow '/ai/mcp-dashboard'.
return (url: string) => {
const [pathname] = url.split(/[?#]/, 1);

return paths.some(p => pathname === p || pathname.startsWith(p.endsWith('/') ? p : `${p}/`));
};
}

export const MCP_PATHS = buildMcpPaths('');

export const isMcpRoute = makeIsMcpRoute('');
Loading
Loading