From 4b21ccf8b2275cf60e133c22fe32daea831525fc Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 11 Dec 2025 12:02:07 +0100 Subject: [PATCH 1/4] feat(ai-proxy): introduce @forestadmin/ai-proxy package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new package for AI/LLM integration with MCP server support. - Router for handling AI proxy requests with provider configuration - MCP client for connecting to Model Context Protocol servers - Remote tools abstraction for external tool invocations - Provider dispatcher for multi-provider LLM support - Comprehensive test coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/ai-proxy/README.md | 0 packages/ai-proxy/jest.config.ts | 8 + packages/ai-proxy/package.json | 39 ++ packages/ai-proxy/src/errors.ts | 82 +++ .../src/examples/run-mcp-calculator-server.ts | 20 + .../src/examples/run-mcp-zendesk-server.ts | 31 + .../src/examples/simple-mcp-server.ts | 93 +++ packages/ai-proxy/src/index.ts | 14 + packages/ai-proxy/src/mcp-client.ts | 109 +++ packages/ai-proxy/src/mcp-config-checker.ts | 9 + packages/ai-proxy/src/provider-dispatcher.ts | 85 +++ packages/ai-proxy/src/remote-tool.ts | 29 + packages/ai-proxy/src/remote-tools.ts | 61 ++ packages/ai-proxy/src/router.ts | 91 +++ .../ai-proxy/test/index.integration.test.ts | 49 ++ packages/ai-proxy/test/mcp-client.test.ts | 241 +++++++ .../ai-proxy/test/provider-dispatcher.test.ts | 175 +++++ packages/ai-proxy/test/remote-tools.test.ts | 110 +++ packages/ai-proxy/test/router.test.ts | 257 +++++++ packages/ai-proxy/test/tsconfig.json | 8 + packages/ai-proxy/tsconfig.eslint.json | 3 + packages/ai-proxy/tsconfig.json | 7 + yarn.lock | 642 +++++++++++++++++- 23 files changed, 2139 insertions(+), 24 deletions(-) create mode 100644 packages/ai-proxy/README.md create mode 100644 packages/ai-proxy/jest.config.ts create mode 100644 packages/ai-proxy/package.json create mode 100644 packages/ai-proxy/src/errors.ts create mode 100644 packages/ai-proxy/src/examples/run-mcp-calculator-server.ts create mode 100644 packages/ai-proxy/src/examples/run-mcp-zendesk-server.ts create mode 100644 packages/ai-proxy/src/examples/simple-mcp-server.ts create mode 100644 packages/ai-proxy/src/index.ts create mode 100644 packages/ai-proxy/src/mcp-client.ts create mode 100644 packages/ai-proxy/src/mcp-config-checker.ts create mode 100644 packages/ai-proxy/src/provider-dispatcher.ts create mode 100644 packages/ai-proxy/src/remote-tool.ts create mode 100644 packages/ai-proxy/src/remote-tools.ts create mode 100644 packages/ai-proxy/src/router.ts create mode 100644 packages/ai-proxy/test/index.integration.test.ts create mode 100644 packages/ai-proxy/test/mcp-client.test.ts create mode 100644 packages/ai-proxy/test/provider-dispatcher.test.ts create mode 100644 packages/ai-proxy/test/remote-tools.test.ts create mode 100644 packages/ai-proxy/test/router.test.ts create mode 100644 packages/ai-proxy/test/tsconfig.json create mode 100644 packages/ai-proxy/tsconfig.eslint.json create mode 100644 packages/ai-proxy/tsconfig.json diff --git a/packages/ai-proxy/README.md b/packages/ai-proxy/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/ai-proxy/jest.config.ts b/packages/ai-proxy/jest.config.ts new file mode 100644 index 0000000000..d622773e8a --- /dev/null +++ b/packages/ai-proxy/jest.config.ts @@ -0,0 +1,8 @@ +/* eslint-disable import/no-relative-packages */ +import jestConfig from '../../jest.config'; + +export default { + ...jestConfig, + collectCoverageFrom: ['/src/**/*.ts'], + testMatch: ['/test/**/*.test.ts'], +}; diff --git a/packages/ai-proxy/package.json b/packages/ai-proxy/package.json new file mode 100644 index 0000000000..a270f587a2 --- /dev/null +++ b/packages/ai-proxy/package.json @@ -0,0 +1,39 @@ +{ + "name": "@forestadmin/ai-proxy", + "version": "0.1.0", + "main": "dist/index.js", + "license": "GPL-3.0", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ForestAdmin/agent-nodejs.git", + "directory": "packages/ai-proxy" + }, + "dependencies": { + "openai": "4.95.0", + "@forestadmin/datasource-toolkit": "1.50.0", + "@langchain/community": "0.3.57", + "@langchain/core": "1.1.4", + "@langchain/langgraph": "1.0.4", + "@langchain/mcp-adapters": "1.0.3" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "1.20.0", + "express": "5.1.0", + "@types/express": "5.0.1", + "node-zendesk": "6.0.1" + }, + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts" + ], + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "clean": "rm -rf coverage dist", + "lint": "eslint src", + "test": "jest" + } +} diff --git a/packages/ai-proxy/src/errors.ts b/packages/ai-proxy/src/errors.ts new file mode 100644 index 0000000000..a6d143266f --- /dev/null +++ b/packages/ai-proxy/src/errors.ts @@ -0,0 +1,82 @@ +/** + * ------------------------------------- + * ------------------------------------- + * ------------------------------------- + * All custom errors must extend the AIError class. + * This inheritance is crucial for proper error translation + * and consistent handling throughout the system. + * ------------------------------------- + * ------------------------------------- + * ------------------------------------- + */ + +// eslint-disable-next-line max-classes-per-file +export class AIError extends Error { + constructor(message: string) { + super(message); + this.name = 'AIError'; + } +} + +export class AIUnprocessableError extends AIError { + constructor(message: string) { + super(message); + this.name = 'AIUnprocessableError'; + } +} + +export class AINotConfiguredError extends AIError { + constructor() { + super('AI is not configured. Please call addAI() on your agent.'); + this.name = 'AINotConfiguredError'; + } +} + +export class OpenAIUnprocessableError extends AIUnprocessableError { + constructor(message: string) { + super(message); + this.name = 'OpenAIError'; + } +} + +export class AIToolUnprocessableError extends AIUnprocessableError { + constructor(message: string) { + super(message); + this.name = 'AIToolError'; + } +} + +export class AIToolNotFoundError extends AIError { + constructor(message: string) { + super(message); + this.name = 'AIToolNotFoundError'; + } +} + +export class McpError extends AIError { + constructor(message: string) { + super(message); + this.name = 'McpError'; + } +} + +export class McpConnectionError extends McpError { + constructor(message: string) { + super(message); + this.name = 'McpConnectionError'; + } +} + +export class McpConflictError extends McpError { + constructor(entityName: string) { + super(`"${entityName}" already exists for your project`); + this.name = 'McpConflictError'; + } +} + +export class McpConfigError extends McpError { + constructor(message: string) { + super(message); + this.name = 'McpConfigError'; + } +} diff --git a/packages/ai-proxy/src/examples/run-mcp-calculator-server.ts b/packages/ai-proxy/src/examples/run-mcp-calculator-server.ts new file mode 100644 index 0000000000..172160bef9 --- /dev/null +++ b/packages/ai-proxy/src/examples/run-mcp-calculator-server.ts @@ -0,0 +1,20 @@ +/* eslint-disable import/no-extraneous-dependencies, import/extensions */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +/* eslint-enable import/no-extraneous-dependencies, import/extensions */ + +import runMcpServer from './simple-mcp-server'; + +const server = new McpServer({ + name: 'calculator', + version: '1.0.0', +}); + +server.tool('add', { a: z.number(), b: z.number() }, async ({ a, b }) => { + // eslint-disable-next-line no-console + console.log('Received add request:', a, b); + + return { content: [{ type: 'text', text: String(a + b) }] }; +}); + +runMcpServer(server, 3123); diff --git a/packages/ai-proxy/src/examples/run-mcp-zendesk-server.ts b/packages/ai-proxy/src/examples/run-mcp-zendesk-server.ts new file mode 100644 index 0000000000..1da0994126 --- /dev/null +++ b/packages/ai-proxy/src/examples/run-mcp-zendesk-server.ts @@ -0,0 +1,31 @@ +/* eslint-disable import/no-extraneous-dependencies, import/extensions */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import zendesk from 'node-zendesk'; +/* eslint-enable import/no-extraneous-dependencies, import/extensions */ + +import runMcpServer from './simple-mcp-server'; + +const client = zendesk.createClient({ + username: 'email', + token: 'tFGjxI3V97pqlLOR4dGlMmoclIaV3CI2E49Ol5CN', + subdomain: 'forestadmin-91613', +}); + +const server = new McpServer({ + name: 'zendesk', + version: '1.0.0', +}); + +server.tool('listUsers', {}, async () => { + const users = await client.users.list(); + + return { content: [{ type: 'text', text: JSON.stringify(users.map(u => u.name)) }] }; +}); + +server.tool('listArticles', {}, async () => { + const articles = await client.helpcenter.articles.list(); + + return { content: [{ type: 'text', text: JSON.stringify(articles) }] }; +}); + +runMcpServer(server, 3124); diff --git a/packages/ai-proxy/src/examples/simple-mcp-server.ts b/packages/ai-proxy/src/examples/simple-mcp-server.ts new file mode 100644 index 0000000000..f848e23bc4 --- /dev/null +++ b/packages/ai-proxy/src/examples/simple-mcp-server.ts @@ -0,0 +1,93 @@ +/* eslint-disable import/no-extraneous-dependencies, import/extensions */ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express from 'express'; +/* eslint-enable import/no-extraneous-dependencies, import/extensions */ + +const BEARER_TOKEN = 'your-secure-token-here'; + +export default (server: McpServer, port: number, secureToken: string = BEARER_TOKEN) => { + const app = express(); + app.use(express.json()); + + // Bearer Token Middleware + app.use('/mcp', (req, res, next) => { + const authHeader = req.headers.authorization || ''; + const token = authHeader.replace(/^Bearer\s+/i, ''); + + if (token !== secureToken) { + res.status(401).json({ + jsonrpc: '2.0', + error: { + code: -32001, + message: 'Unauthorized: Invalid or missing Bearer token', + }, + id: null, + }); + + return; + } + + next(); + }); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + app.post('/mcp', async (req, res) => { + try { + const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + res.on('close', () => { + // eslint-disable-next-line no-console + console.log('Request closed'); + void transport.close(); + void server.close(); + }); + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } + } + }); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + app.get('/mcp', async (req, res) => { + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed.' }, + id: null, + }), + ); + }); + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + app.delete('/mcp', async (req, res) => { + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed.' }, + id: null, + }), + ); + }); + + return app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`MCP Stateless Streamable HTTP Server listening on port ${port}`); + }); +}; diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts new file mode 100644 index 0000000000..a1ac8d3502 --- /dev/null +++ b/packages/ai-proxy/src/index.ts @@ -0,0 +1,14 @@ +import type { McpConfiguration } from './mcp-client'; + +import McpConfigChecker from './mcp-config-checker'; + +export * from './provider-dispatcher'; +export * from './remote-tools'; +export * from './router'; +export * from './mcp-client'; + +export * from './errors'; + +export function validMcpConfigurationOrThrow(mcpConfig: McpConfiguration) { + return McpConfigChecker.check(mcpConfig); +} diff --git a/packages/ai-proxy/src/mcp-client.ts b/packages/ai-proxy/src/mcp-client.ts new file mode 100644 index 0000000000..760df5de87 --- /dev/null +++ b/packages/ai-proxy/src/mcp-client.ts @@ -0,0 +1,109 @@ +import type { Logger } from '@forestadmin/datasource-toolkit'; + +import { MultiServerMCPClient } from '@langchain/mcp-adapters'; + +import { McpConnectionError } from './errors'; +import RemoteTool from './remote-tool'; + +export type McpConfiguration = { + configs: MultiServerMCPClient['config']['mcpServers']; +} & Omit; + +export default class McpClient { + private readonly mcpClients: Record = {}; + private readonly logger?: Logger; + + readonly tools: RemoteTool[] = []; + + constructor(config: McpConfiguration, logger?: Logger) { + this.logger = logger; + // split the config into several clients to be more resilient + // if a mcp server is down, the others will still work + Object.entries(config.configs).forEach(([name, serverConfig]) => { + this.mcpClients[name] = new MultiServerMCPClient({ + mcpServers: { [name]: serverConfig }, + ...config, + }); + }); + } + + async loadTools(): Promise { + const errors: Array<{ server: string; error: Error }> = []; + + await Promise.all( + Object.entries(this.mcpClients).map(async ([name, client]) => { + try { + const tools = (await client.getTools()) ?? []; + const extendedTools = tools.map( + tool => + new RemoteTool({ + tool, + sourceId: name, + sourceType: 'mcp-server', + }), + ); + this.tools.push(...extendedTools); + } catch (error) { + this.logger?.('Error', `Error loading tools for ${name}`, error as Error); + errors.push({ server: name, error: error as Error }); + } + }), + ); + + // Surface partial failures to provide better feedback + if (errors.length > 0) { + const errorMessage = errors.map(e => `${e.server}: ${e.error.message}`).join('; '); + this.logger?.( + 'Error', + `Failed to load tools from ${errors.length}/${Object.keys(this.mcpClients).length} ` + + `MCP server(s): ${errorMessage}`, + ); + } + + return this.tools; + } + + async testConnections(): Promise { + try { + await Promise.all( + Object.values(this.mcpClients).map(client => client.initializeConnections()), + ); + + return true; + } catch (error) { + throw new McpConnectionError((error as Error).message); + } finally { + try { + await this.closeConnections(); + } catch (cleanupError) { + // Log but don't throw - we don't want to mask the original connection error + this.logger?.('Error', 'Error during test connection cleanup', cleanupError as Error); + } + } + } + + async closeConnections(): Promise { + const entries = Object.entries(this.mcpClients); + const results = await Promise.allSettled(entries.map(([, client]) => client.close())); + + // Check for failures but don't throw - cleanup should be best-effort + const failures = results + .map((result, index) => ({ result, name: entries[index][0] })) + .filter(({ result }) => result.status === 'rejected'); + + if (failures.length > 0) { + failures.forEach(({ name, result }) => { + this.logger?.( + 'Error', + `Failed to close MCP connection for ${name}`, + (result as PromiseRejectedResult).reason, + ); + }); + this.logger?.( + 'Error', + `Failed to close ${failures.length}/${results.length} MCP connections. ` + + `This may result in resource leaks.`, + ); + } + } +} diff --git a/packages/ai-proxy/src/mcp-config-checker.ts b/packages/ai-proxy/src/mcp-config-checker.ts new file mode 100644 index 0000000000..95433c5f7d --- /dev/null +++ b/packages/ai-proxy/src/mcp-config-checker.ts @@ -0,0 +1,9 @@ +import type { McpConfiguration } from './mcp-client'; + +import McpClient from './mcp-client'; + +export default class McpConfigChecker { + static check(mcpConfig: McpConfiguration) { + return new McpClient(mcpConfig).testConnections(); + } +} diff --git a/packages/ai-proxy/src/provider-dispatcher.ts b/packages/ai-proxy/src/provider-dispatcher.ts new file mode 100644 index 0000000000..53a63432df --- /dev/null +++ b/packages/ai-proxy/src/provider-dispatcher.ts @@ -0,0 +1,85 @@ +import type { RemoteTools } from './remote-tools'; +import type { ClientOptions } from 'openai'; +import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'; + +import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling'; +import OpenAI from 'openai'; + +import { AINotConfiguredError, OpenAIUnprocessableError } from './errors'; + +export type OpenAiConfiguration = ClientOptions & { + provider: 'openai'; + model: ChatCompletionCreateParamsNonStreaming['model']; +}; + +export type AiConfiguration = OpenAiConfiguration; + +export type AiProvider = AiConfiguration['provider']; + +export type OpenAIBody = Pick< + ChatCompletionCreateParamsNonStreaming, + 'tools' | 'messages' | 'tool_choice' +>; + +export type DispatchBody = OpenAIBody; + +type OpenAiClient = { client: OpenAI; model: ChatCompletionCreateParamsNonStreaming['model'] }; + +export class ProviderDispatcher { + private readonly aiClient: OpenAiClient | null = null; + + private readonly remoteTools: RemoteTools; + + constructor(configuration: AiConfiguration | null, remoteTools: RemoteTools) { + this.remoteTools = remoteTools; + + if (configuration?.provider === 'openai' && configuration.apiKey) { + const { provider, model, ...clientOptions } = configuration; + this.aiClient = { + client: new OpenAI(clientOptions), + model, + }; + } + } + + async dispatch(body: DispatchBody): Promise { + if (!this.aiClient) { + throw new AINotConfiguredError(); + } + + // tools, messages and tool_choice must be extracted from the body and passed as options + // because we don't want to let users to pass any other option + const { tools, messages, tool_choice: toolChoice } = body; + + const options = { + model: this.aiClient.model, + // Add the remote tools to the tools to be used by the AI + tools: this.enhanceOpenAIRemoteTools(tools), + messages, + tool_choice: toolChoice, + } as ChatCompletionCreateParamsNonStreaming; + + try { + return await this.aiClient.client.chat.completions.create(options); + } catch (error) { + throw new OpenAIUnprocessableError(`Error while calling OpenAI: ${(error as Error).message}`); + } + } + + private enhanceOpenAIRemoteTools(tools?: ChatCompletionCreateParamsNonStreaming['tools']) { + if (!tools || !Array.isArray(tools)) return tools; + + const remoteToolFunctions = this.remoteTools.tools.map(extendedTools => + convertToOpenAIFunction(extendedTools.base), + ); + + return tools.map(tool => { + const remoteFunction = remoteToolFunctions.find( + functionDefinition => functionDefinition.name === tool.function.name, + ); + if (remoteFunction) return { ...tool, function: remoteFunction }; + + return tool; + }); + } +} diff --git a/packages/ai-proxy/src/remote-tool.ts b/packages/ai-proxy/src/remote-tool.ts new file mode 100644 index 0000000000..5e10a748eb --- /dev/null +++ b/packages/ai-proxy/src/remote-tool.ts @@ -0,0 +1,29 @@ +import type { StructuredToolInterface } from '@langchain/core/tools'; + +export type SourceType = 'server' | 'mcp-server'; + +export default class RemoteTool { + base: StructuredToolInterface; + sourceId: string; + sourceType: SourceType; + + constructor(options: { + tool: StructuredToolInterface; + sourceId?: string; + sourceType?: SourceType; + }) { + this.base = options.tool; + this.sourceId = options.sourceId; + this.sourceType = options.sourceType; + } + + get sanitizedName() { + return this.sanitizeName(this.base.name); + } + + private sanitizeName(name: string): string { + // OpenAI function names must be alphanumeric and can contain underscores + // This function replaces non-alphanumeric characters with underscores + return name.replace(/[^a-zA-Z0-9_-]/g, '_'); + } +} diff --git a/packages/ai-proxy/src/remote-tools.ts b/packages/ai-proxy/src/remote-tools.ts new file mode 100644 index 0000000000..b16dacb06e --- /dev/null +++ b/packages/ai-proxy/src/remote-tools.ts @@ -0,0 +1,61 @@ +import type { ResponseFormat } from '@langchain/core/tools'; +import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'; + +import { BraveSearch } from '@langchain/community/tools/brave_search'; +import { toJsonSchema } from '@langchain/core/utils/json_schema'; + +import { AIToolNotFoundError, AIToolUnprocessableError } from './errors'; +import RemoteTool from './remote-tool'; + +export type Messages = ChatCompletionCreateParamsNonStreaming['messages']; + +export type RemoteToolsApiKeys = + | { ['AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY']: string } + | Record; // To avoid to cast the object because env is not always well typed from the caller + +export class RemoteTools { + private readonly apiKeys?: RemoteToolsApiKeys; + readonly tools: RemoteTool[] = []; + + constructor(apiKeys: RemoteToolsApiKeys, tools?: RemoteTool[]) { + this.apiKeys = apiKeys; + this.tools.push(...(tools ?? [])); + + if (this.apiKeys?.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY) { + this.tools.push( + new RemoteTool({ + sourceId: 'brave_search', + sourceType: 'server', + tool: new BraveSearch({ apiKey: this.apiKeys.AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY }), + }), + ); + } + } + + get toolDefinitionsForFrontend() { + return this.tools.map(extendedTool => { + return { + name: extendedTool.sanitizedName, + description: extendedTool.base.description, + responseFormat: 'content' as ResponseFormat, + schema: toJsonSchema(extendedTool.base.schema), + sourceId: extendedTool.sourceId, + sourceType: extendedTool.sourceType, + }; + }); + } + + async invokeTool(toolName: string, messages: ChatCompletionCreateParamsNonStreaming['messages']) { + const extendedTool = this.tools.find(exTool => exTool.sanitizedName === toolName); + + if (!extendedTool) throw new AIToolNotFoundError(`Tool ${toolName} not found`); + + try { + return (await extendedTool.base.invoke(messages)) as unknown; + } catch (error) { + throw new AIToolUnprocessableError( + `Error while calling tool ${toolName}: ${(error as Error).message}`, + ); + } + } +} diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts new file mode 100644 index 0000000000..ced1e9f2b2 --- /dev/null +++ b/packages/ai-proxy/src/router.ts @@ -0,0 +1,91 @@ +import type { McpConfiguration } from './mcp-client'; +import type { AiConfiguration, DispatchBody } from './provider-dispatcher'; +import type { Messages, RemoteToolsApiKeys } from './remote-tools'; +import type { Logger } from '@forestadmin/datasource-toolkit'; + +import { AIUnprocessableError, ProviderDispatcher } from './index'; +import McpClient from './mcp-client'; +import { RemoteTools } from './remote-tools'; + +export type InvokeRemoteToolBody = { inputs: Messages }; +export type Body = DispatchBody | InvokeRemoteToolBody | undefined; +export type Route = 'ai-query' | 'remote-tools' | 'invoke-remote-tool'; +export type Query = { + 'tool-name'?: string; +}; +export type ApiKeys = RemoteToolsApiKeys; + +export class Router { + private readonly localToolsApiKeys?: ApiKeys; + private readonly aiConfiguration: AiConfiguration | null; + private readonly logger?: Logger; + + constructor(params?: { + aiConfiguration?: AiConfiguration; + localToolsApiKeys?: ApiKeys; + logger?: Logger; + }) { + this.aiConfiguration = params?.aiConfiguration ?? null; + this.localToolsApiKeys = params?.localToolsApiKeys; + this.logger = params?.logger; + } + + /** + * Route the request to the appropriate handler + * + * List of routes: + * // dispatch the query to the AI + * - /ai-query + * + * // invoke a remote tool by name + * - /invoke-remote-tool?tool-name=:toolName + * + * // get the list of available remote tools + * - /remote-tools + */ + async route(args: { body?: Body; route: Route; query?: Query; mcpConfigs?: McpConfiguration }) { + let mcpClient: McpClient | undefined; + + try { + if (args.mcpConfigs) { + mcpClient = new McpClient(args.mcpConfigs, this.logger); + } + + const remoteTools = new RemoteTools(this.localToolsApiKeys, await mcpClient?.loadTools()); + + if (args.route === 'ai-query') { + return await new ProviderDispatcher(this.aiConfiguration, remoteTools).dispatch( + args.body as DispatchBody, + ); + } + + if (args.route === 'invoke-remote-tool') { + return await remoteTools.invokeTool( + args.query['tool-name'], + (args.body as InvokeRemoteToolBody).inputs, + ); + } + + if (args.route === 'remote-tools') { + return remoteTools.toolDefinitionsForFrontend; + } + + // don't add mcpConfigs to the error message, as it may contain sensitive information + throw new AIUnprocessableError( + `No action to perform: ${JSON.stringify({ + body: args.body, + route: args.route, + query: args.query, + })}`, + ); + } finally { + if (mcpClient) { + try { + await mcpClient.closeConnections(); + } catch (cleanupError) { + this.logger?.('Error', 'Error during MCP connection cleanup', cleanupError as Error); + } + } + } + } +} diff --git a/packages/ai-proxy/test/index.integration.test.ts b/packages/ai-proxy/test/index.integration.test.ts new file mode 100644 index 0000000000..bd2ebfbda7 --- /dev/null +++ b/packages/ai-proxy/test/index.integration.test.ts @@ -0,0 +1,49 @@ +// eslint-disable-next-line import/extensions +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { validMcpConfigurationOrThrow } from '../src'; +import runMcpServer from '../src/examples/simple-mcp-server'; + +describe('Simple MCP Server', () => { + let server: ReturnType; + + beforeAll(() => { + const mcp = new McpServer({ name: 'simpleServer', version: '1.0.0' }); + mcp.tool('fake', () => { + return { content: [{ type: 'text', text: 'fake it' }] }; + }); + server = runMcpServer(mcp, 3123); + }); + + afterAll(() => { + server.close(); + }); + + describe('validMcpConfigurationOrThrow', () => { + it('should return true when the config is right', async () => { + const result = await validMcpConfigurationOrThrow({ + configs: { + simpleServer: { + url: 'http://localhost:3123/mcp', + type: 'http', + headers: { + Authorization: `Bearer your-secure-token-here`, + }, + }, + }, + }); + + expect(result).toBe(true); + }); + + it('should throw an error when the config is wrong', async () => { + await expect( + validMcpConfigurationOrThrow({ + configs: { + simpleServer: { url: 'http://localhost:3123/wrong', type: 'http' }, + }, + }), + ).rejects.toThrow('Failed to connect to streamable HTTP'); + }); + }); +}); diff --git a/packages/ai-proxy/test/mcp-client.test.ts b/packages/ai-proxy/test/mcp-client.test.ts new file mode 100644 index 0000000000..7a350f8ed9 --- /dev/null +++ b/packages/ai-proxy/test/mcp-client.test.ts @@ -0,0 +1,241 @@ +import type { McpConfiguration } from '../src'; + +import { tool } from '@langchain/core/tools'; + +import { McpConnectionError } from '../src'; +import McpClient from '../src/mcp-client'; +import RemoteTool from '../src/remote-tool'; + +const getToolsMock = jest.fn(); +const closeMock = jest.fn(); +const initializeConnectionsMock = jest.fn(); +jest.mock('@langchain/mcp-adapters', () => { + return { + MultiServerMCPClient: jest.fn().mockImplementation(() => ({ + getTools: getToolsMock, + close: closeMock, + initializeConnections: initializeConnectionsMock, + })), + }; +}); + +describe('McpClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const aConfig: McpConfiguration = { + configs: { + slack: { + transport: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-slack'], + env: {}, + }, + }, + }; + + describe('loadTools', () => { + describe('when there is a tool to load', () => { + it('should load the tools', async () => { + const tool1 = tool(() => {}, { + name: 'tool1', + description: 'description1', + schema: undefined, + responseFormat: 'content', + }); + const tool2 = tool(() => {}, { + name: 'tool2', + description: 'description2', + schema: undefined, + responseFormat: 'content', + }); + const mcpClient = new McpClient(aConfig); + getToolsMock.mockResolvedValue([tool1, tool2]); + + await mcpClient.loadTools(); + + expect(mcpClient.tools).toEqual([ + new RemoteTool({ + tool: tool1, + sourceId: 'slack', + sourceType: 'mcp-server', + }), + new RemoteTool({ + tool: tool2, + sourceId: 'slack', + sourceType: 'mcp-server', + }), + ]); + }); + }); + + describe('when there is no tool to load', () => { + it('should not load the tools', async () => { + const mcpClient = new McpClient(aConfig); + getToolsMock.mockResolvedValue(undefined); + + await mcpClient.loadTools(); + + expect(mcpClient.tools.length).toEqual(0); + }); + }); + + describe('when there is an error while loading the tools', () => { + it('should not throw an error and try to load every mcp tools', async () => { + const mcpClient = new McpClient({ + configs: { + slack: { + transport: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-slack'], + env: {}, + }, + slack2: { + transport: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-slack'], + env: {}, + }, + }, + }); + getToolsMock + .mockRejectedValueOnce(new Error('Error loading tools')) + .mockResolvedValueOnce(['tool1', 'tool2']); + + await mcpClient.loadTools(); + + expect(mcpClient.tools.length).toEqual(2); + }); + }); + }); + + describe('closeConnection', () => { + it('should close the connection', async () => { + const mcpClient = new McpClient(aConfig); + + await mcpClient.closeConnections(); + + expect(closeMock).toHaveBeenCalled(); + }); + + it('should attempt to close all connections when multiple servers configured', async () => { + const mcpClient = new McpClient({ + configs: { + slack: { + transport: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-slack'], + env: {}, + }, + github: { + transport: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + env: {}, + }, + }, + }); + closeMock.mockResolvedValue(undefined); + + await mcpClient.closeConnections(); + + expect(closeMock).toHaveBeenCalledTimes(2); + }); + + it('should attempt to close all connections even if one fails', async () => { + const loggerMock = jest.fn(); + const mcpClient = new McpClient( + { + configs: { + slack: { + transport: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-slack'], + env: {}, + }, + github: { + transport: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + env: {}, + }, + }, + }, + loggerMock, + ); + closeMock + .mockRejectedValueOnce(new Error('Slack close failed')) + .mockResolvedValueOnce(undefined); + + await mcpClient.closeConnections(); + + // Should attempt to close both connections + expect(closeMock).toHaveBeenCalledTimes(2); + // Should log the error via logger + expect(loggerMock).toHaveBeenCalledWith( + 'Error', + expect.stringContaining('Failed to close MCP connection for'), + expect.any(Error), + ); + expect(loggerMock).toHaveBeenCalledWith( + 'Error', + expect.stringContaining('Failed to close 1/2 MCP connections'), + ); + }); + + it('should not throw when closeConnections fails', async () => { + const loggerMock = jest.fn(); + const mcpClient = new McpClient(aConfig, loggerMock); + closeMock.mockRejectedValue(new Error('Close failed')); + + // Should not throw + await mcpClient.closeConnections(); + + expect(loggerMock).toHaveBeenCalled(); + }); + }); + + describe('testConnections', () => { + it('should init the connections & close the connections even if there is no error', async () => { + const mcpClient = new McpClient(aConfig); + + await mcpClient.testConnections(); + + expect(closeMock).toHaveBeenCalled(); + expect(initializeConnectionsMock).toHaveBeenCalled(); + }); + + describe('when the connection fails', () => { + it('should throw a McpConnectionError', async () => { + const mcpClient = new McpClient(aConfig); + const errorMessage = 'Connection error'; + initializeConnectionsMock.mockRejectedValue(new Error(errorMessage)); + + await expect(mcpClient.testConnections()).rejects.toThrow( + new McpConnectionError(errorMessage), + ); + expect(closeMock).toHaveBeenCalled(); + }); + + it('should preserve original connection error when cleanup also fails', async () => { + const loggerMock = jest.fn(); + const mcpClient = new McpClient(aConfig, loggerMock); + const connectionError = 'Connection failed'; + initializeConnectionsMock.mockRejectedValue(new Error(connectionError)); + closeMock.mockRejectedValue(new Error('Cleanup failed')); + + // Original connection error should be thrown, not the cleanup error + await expect(mcpClient.testConnections()).rejects.toThrow( + new McpConnectionError(connectionError), + ); + // Cleanup failure should be logged via closeConnections internal logging + expect(loggerMock).toHaveBeenCalledWith( + 'Error', + expect.stringContaining('Failed to close MCP connection for'), + expect.any(Error), + ); + }); + }); + }); +}); diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts new file mode 100644 index 0000000000..797bffa98a --- /dev/null +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -0,0 +1,175 @@ +import type { DispatchBody } from '../src'; + +import { convertToOpenAIFunction } from '@langchain/core/utils/function_calling'; + +import { AINotConfiguredError, ProviderDispatcher, RemoteTools } from '../src'; + +const openaiCreateMock = jest.fn().mockResolvedValue('response'); + +jest.mock('openai', () => { + return jest.fn().mockImplementation(() => { + return { chat: { completions: { create: openaiCreateMock } } }; + }); +}); + +describe('ProviderDispatcher', () => { + const apiKeys = { AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY: 'api-key' }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('dispatch', () => { + describe('when AI is not configured', () => { + it('should throw AINotConfiguredError', async () => { + const dispatcher = new ProviderDispatcher(null, new RemoteTools(apiKeys)); + await expect(dispatcher.dispatch({} as DispatchBody)).rejects.toThrow(AINotConfiguredError); + await expect(dispatcher.dispatch({} as DispatchBody)).rejects.toThrow( + 'AI is not configured. Please call addAI() on your agent.', + ); + }); + }); + }); + + describe('openai', () => { + describe('when openai is configured', () => { + it('should return the response from openai', async () => { + const dispatcher = new ProviderDispatcher( + { + provider: 'openai', + apiKey: 'dev', + model: 'gpt-4o', + }, + new RemoteTools(apiKeys), + ); + const response = await dispatcher.dispatch({ + tools: [], + messages: [], + } as unknown as DispatchBody); + expect(response).toBe('response'); + }); + + describe('when the user tries to override the configuration', () => { + it('should not allow the user to override the configuration', async () => { + const dispatcher = new ProviderDispatcher( + { + provider: 'openai', + apiKey: 'dev', + model: 'BASE MODEL', + }, + new RemoteTools(apiKeys), + ); + await dispatcher.dispatch({ + model: 'OTHER MODEL', + propertyInjection: 'hack', + + tools: [], + messages: [], + tool_choice: 'auto', + } as unknown as DispatchBody); + + expect(openaiCreateMock).toHaveBeenCalledWith({ + model: 'BASE MODEL', + tools: [], + messages: [], + tool_choice: 'auto', + }); + }); + }); + + describe('when the openai client throws an error', () => { + it('should throw an OpenAIUnprocessableError', async () => { + const dispatcher = new ProviderDispatcher( + { + provider: 'openai', + apiKey: 'dev', + model: 'gpt-4o', + }, + new RemoteTools(apiKeys), + ); + openaiCreateMock.mockRejectedValueOnce(new Error('OpenAI error')); + + await expect( + dispatcher.dispatch({ tools: [], messages: [] } as unknown as DispatchBody), + ).rejects.toThrow('Error while calling OpenAI: OpenAI error'); + }); + }); + }); + + describe('when there is a remote tool', () => { + it('should enhance the remote tools definition', async () => { + const remoteTools = new RemoteTools(apiKeys); + remoteTools.invokeTool = jest.fn().mockResolvedValue('response'); + + const dispatcher = new ProviderDispatcher( + { + provider: 'openai', + apiKey: 'dev', + model: 'gpt-4o', + }, + remoteTools, + ); + await dispatcher.dispatch({ + tools: [ + { + type: 'function', + // parameters is an empty object because it simulates the front end sending an empty object + // because it doesn't know the parameters of the tool + function: { name: remoteTools.tools[0].base.name, parameters: {} }, + }, + ], + messages: [], + } as unknown as DispatchBody); + + expect(openaiCreateMock).toHaveBeenCalledWith({ + messages: [], + model: 'gpt-4o', + tool_choice: undefined, + tools: [ + { + type: 'function', + function: convertToOpenAIFunction(remoteTools.tools[0].base), + }, + ], + }); + }); + }); + + describe('when there is not remote tool', () => { + it('should not enhance the remote tools definition', async () => { + const remoteTools = new RemoteTools(apiKeys); + remoteTools.invokeTool = jest.fn().mockResolvedValue('response'); + + const dispatcher = new ProviderDispatcher( + { + provider: 'openai', + apiKey: 'dev', + model: 'gpt-4o', + }, + remoteTools, + ); + await dispatcher.dispatch({ + tools: [ + { + type: 'function', + function: { name: 'notRemoteTool', parameters: {} }, + }, + ], + messages: [], + } as unknown as DispatchBody); + + expect(openaiCreateMock).toHaveBeenCalledWith({ + messages: [], + model: 'gpt-4o', + tool_choice: undefined, + tools: [ + { + type: 'function', + function: { name: 'notRemoteTool', parameters: {} }, + }, + ], + }); + }); + }); + }); +}); diff --git a/packages/ai-proxy/test/remote-tools.test.ts b/packages/ai-proxy/test/remote-tools.test.ts new file mode 100644 index 0000000000..3a4d9a98d7 --- /dev/null +++ b/packages/ai-proxy/test/remote-tools.test.ts @@ -0,0 +1,110 @@ +import type { Tool } from '@langchain/core/tools'; +import type { JSONSchema } from '@langchain/core/utils/json_schema'; + +import { toJsonSchema } from '@langchain/core/utils/json_schema'; + +import { RemoteTools } from '../src'; +import RemoteTool from '../src/remote-tool'; + +describe('RemoteTools', () => { + const apiKeys = { AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY: 'api-key' }; + + describe('when AI_REMOTE_TOOL_BRAVE_SEARCH_API_KEY is not set', () => { + it('should not add the tool', () => { + const remoteTools = new RemoteTools({}); + expect(remoteTools.tools.length).toEqual(0); + }); + }); + + describe('when envs is null', () => { + it('should init the remote tool instance without error', () => { + expect(() => new RemoteTools(null)).not.toThrow(); + }); + }); + + describe('tools', () => { + it('should return the tools', () => { + const remoteTools = new RemoteTools(apiKeys); + expect(remoteTools.tools.length).toEqual(1); + }); + + describe('when tools are passed in the constructor', () => { + it('should return the tools', () => { + const tools = [ + new RemoteTool({ + tool: { + name: 'tool1', + description: 'description1', + responseFormat: 'content', + schema: {}, + } as Tool, + }), + ]; + const remoteTools = new RemoteTools(apiKeys, tools); + expect(remoteTools.tools.length).toEqual(2); + expect(remoteTools.tools[0].base.name).toEqual('tool1'); + }); + }); + }); + + describe('toolDefinitionsForFrontend', () => { + it('should return the tools with extended definitions', () => { + const remoteTools = new RemoteTools(apiKeys); + expect(remoteTools.toolDefinitionsForFrontend).toEqual([ + { + name: remoteTools.tools[0].sanitizedName, + description: remoteTools.tools[0].base.description, + responseFormat: 'content', + schema: toJsonSchema(remoteTools.tools[0].base.schema as JSONSchema), + sourceId: remoteTools.tools[0].sourceId, + sourceType: remoteTools.tools[0].sourceType, + }, + ]); + }); + }); + + describe('invokeTool', () => { + it('should call invokeTool', async () => { + const remoteTools = new RemoteTools(apiKeys); + remoteTools.invokeTool = jest.fn().mockResolvedValue('response'); + + const response = await remoteTools.invokeTool('tool-name', []); + + expect(response).toEqual('response'); + }); + + describe('when the tool is not found', () => { + it('should throw an error', async () => { + const remoteTools = new RemoteTools(apiKeys); + + await expect(() => remoteTools.invokeTool('not-found', [])).rejects.toThrow( + 'Tool not-found not found', + ); + }); + }); + + describe('when the tool throws an error', () => { + it('should throw an error', async () => { + const remoteTools = new RemoteTools(apiKeys); + remoteTools.tools[0].base.invoke = jest.fn().mockRejectedValue(new Error('error')); + + await expect(() => + remoteTools.invokeTool(remoteTools.tools[0].base.name, []), + ).rejects.toThrow(`Error while calling tool ${remoteTools.tools[0].base.name}: error`); + }); + }); + + describe('when the tool name is sanitized', () => { + it('should find the right tool to invoke', async () => { + const remoteTools = new RemoteTools(apiKeys); + + remoteTools.tools[0].base.name = 'brave search'; + remoteTools.tools[0].base.invoke = jest.fn().mockResolvedValue('response'); + + await remoteTools.invokeTool(remoteTools.tools[0].sanitizedName, []); + + expect(remoteTools.tools[0].base.invoke).toHaveBeenCalledWith([]); + }); + }); + }); +}); diff --git a/packages/ai-proxy/test/router.test.ts b/packages/ai-proxy/test/router.test.ts new file mode 100644 index 0000000000..5363e1c117 --- /dev/null +++ b/packages/ai-proxy/test/router.test.ts @@ -0,0 +1,257 @@ +import type { DispatchBody, Route } from '../src'; +import type { Logger } from '@forestadmin/datasource-toolkit'; + +import { AIUnprocessableError, Router } from '../src'; +import McpClient from '../src/mcp-client'; + +const invokeToolMock = jest.fn(); +const toolDefinitionsForFrontend = [{ name: 'tool-name', description: 'tool-description' }]; + +jest.mock('../src/remote-tools', () => { + return { + RemoteTools: jest.fn().mockImplementation(() => ({ + tools: [], + toolDefinitionsForFrontend, + invokeTool: invokeToolMock, + })), + }; +}); + +const dispatchMock = jest.fn(); +jest.mock('../src/provider-dispatcher', () => { + return { + ProviderDispatcher: jest.fn().mockImplementation(() => ({ + dispatch: dispatchMock, + })), + }; +}); + +jest.mock('../src/mcp-client', () => { + return jest.fn().mockImplementation(() => ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn(), + })); +}); + +const MockedMcpClient = McpClient as jest.MockedClass; + +describe('route', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when the route is /ai-query', () => { + it('calls the provider dispatcher', async () => { + const router = new Router({ + aiConfiguration: { + provider: 'openai', + apiKey: 'dev', + model: 'gpt-4o', + }, + }); + + await router.route({ + route: 'ai-query', + body: { tools: [], tool_choice: 'required', messages: [] } as unknown as DispatchBody, + }); + + expect(dispatchMock).toHaveBeenCalledWith({ + tools: [], + tool_choice: 'required', + messages: [], + }); + }); + }); + + describe('when the route is /invoke-remote-tool', () => { + it('calls the remote tools', async () => { + const router = new Router({}); + + await router.route({ + route: 'invoke-remote-tool', + query: { 'tool-name': 'tool-name' }, + body: { inputs: [] }, + }); + + expect(invokeToolMock).toHaveBeenCalledWith('tool-name', []); + }); + }); + + describe('when the route is /remote-tools', () => { + it('returns the remote tools definitions', async () => { + const router = new Router({}); + + const result = await router.route({ route: 'remote-tools' }); + + expect(result).toEqual(toolDefinitionsForFrontend); + }); + }); + + describe('when the route is unknown', () => { + it('throws an error', async () => { + const router = new Router({}); + + await expect(router.route({ route: 'unknown' as Route })).rejects.toThrow( + new AIUnprocessableError('No action to perform: {"route":"unknown"}'), + ); + }); + + it('does not include mcpConfigs in the error message', async () => { + const router = new Router({}); + + await expect( + router.route({ + route: 'unknown' as Route, + mcpConfigs: { configs: {} }, + }), + ).rejects.toThrow(new AIUnprocessableError('No action to perform: {"route":"unknown"}')); + }); + }); + + describe('MCP connection cleanup', () => { + it('closes the MCP connection after successful route handling', async () => { + const router = new Router({}); + + await router.route({ + route: 'remote-tools', + mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, + }); + + expect(MockedMcpClient).toHaveBeenCalledTimes(1); + const mcpClientInstance = MockedMcpClient.mock.results[0].value as jest.Mocked; + expect(mcpClientInstance.closeConnections).toHaveBeenCalledTimes(1); + }); + + it('closes the MCP connection even when an error occurs', async () => { + const router = new Router({}); + + await expect( + router.route({ + route: 'unknown' as Route, + mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, + }), + ).rejects.toThrow(); + + expect(MockedMcpClient).toHaveBeenCalledTimes(1); + const mcpClientInstance = MockedMcpClient.mock.results[0].value as jest.Mocked; + expect(mcpClientInstance.closeConnections).toHaveBeenCalledTimes(1); + }); + + it('does not call closeConnections when no mcpConfigs provided', async () => { + const router = new Router({}); + + await router.route({ + route: 'remote-tools', + }); + + expect(MockedMcpClient).not.toHaveBeenCalled(); + }); + + it('does not throw when closeConnections fails during successful route', async () => { + const mockLogger = jest.fn(); + const router = new Router({ + logger: mockLogger, + }); + const closeError = new Error('Cleanup failed'); + + jest.mocked(McpClient).mockImplementation( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn().mockRejectedValue(closeError), + } as unknown as McpClient), + ); + + // Should not throw even though cleanup fails + const result = await router.route({ + route: 'remote-tools', + mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, + }); + + expect(result).toBeDefined(); + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + 'Error during MCP connection cleanup', + closeError, + ); + }); + + it('preserves original error when both route and cleanup fail', async () => { + const mockLogger = jest.fn(); + const router = new Router({ + logger: mockLogger, + }); + const closeError = new Error('Cleanup failed'); + + jest.mocked(McpClient).mockImplementation( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn().mockRejectedValue(closeError), + } as unknown as McpClient), + ); + + // Should throw the original route error, not the cleanup error + await expect( + router.route({ + route: 'unknown' as Route, + mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, + }), + ).rejects.toThrow(AIUnprocessableError); + + // Cleanup error should be logged + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + 'Error during MCP connection cleanup', + closeError, + ); + }); + }); + + describe('Logger injection', () => { + it('uses the injected logger instead of console', async () => { + const customLogger: Logger = jest.fn(); + const router = new Router({ + logger: customLogger, + }); + const closeError = new Error('Cleanup failed'); + + jest.mocked(McpClient).mockImplementation( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn().mockRejectedValue(closeError), + } as unknown as McpClient), + ); + + await router.route({ + route: 'remote-tools', + mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, + }); + + // Custom logger should be called + expect(customLogger).toHaveBeenCalledWith( + 'Error', + 'Error during MCP connection cleanup', + closeError, + ); + }); + + it('passes logger to McpClient', async () => { + const customLogger: Logger = jest.fn(); + const router = new Router({ + logger: customLogger, + }); + + await router.route({ + route: 'remote-tools', + mcpConfigs: { configs: { server1: { command: 'test', args: [] } } }, + }); + + expect(MockedMcpClient).toHaveBeenCalledWith( + { configs: { server1: { command: 'test', args: [] } } }, + customLogger, + ); + }); + }); +}); diff --git a/packages/ai-proxy/test/tsconfig.json b/packages/ai-proxy/test/tsconfig.json new file mode 100644 index 0000000000..0e85eeffcb --- /dev/null +++ b/packages/ai-proxy/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["test/**/*"], + "files": ["../../../node_modules/jest-extended/types/index.d.ts"] +} diff --git a/packages/ai-proxy/tsconfig.eslint.json b/packages/ai-proxy/tsconfig.eslint.json new file mode 100644 index 0000000000..9bdc52705d --- /dev/null +++ b/packages/ai-proxy/tsconfig.eslint.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.eslint.json" +} diff --git a/packages/ai-proxy/tsconfig.json b/packages/ai-proxy/tsconfig.json new file mode 100644 index 0000000000..e0d66374ae --- /dev/null +++ b/packages/ai-proxy/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 0b4949507d..d1ce518a4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1454,6 +1454,11 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@cfworker/json-schema@^4.0.2": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz#4a2a3947ee9fa7b7c24be981422831b8674c3be6" + integrity sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og== + "@colors/colors@1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" @@ -1889,6 +1894,29 @@ "@shikijs/types" "^3.19.0" "@shikijs/vscode-textmate" "^10.0.2" +"@graphql-typed-document-node/core@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" + integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== + +"@grpc/grpc-js@^1.14.0": + version "1.14.2" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.2.tgz#d245069181a1a8057abd35522d6052482730cf19" + integrity sha512-QzVUtEFyu05UNx2xr0fCQmStUO17uVQhGNowtxs00IgTZT6/W2PBLfUkj30s0FKJ29VtTa3ArVNIhNP6akQhqA== + dependencies: + "@grpc/proto-loader" "^0.8.0" + "@js-sdsl/ordered-map" "^4.4.2" + +"@grpc/proto-loader@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.0.tgz#b6c324dd909c458a0e4aa9bfd3d69cf78a4b9bd8" + integrity sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.5.3" + yargs "^17.7.2" + "@hapi/bourne@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" @@ -2378,6 +2406,11 @@ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.6.3.tgz#41ae1c07de1ebe0f6dde1abcbc9700a09b9c6056" integrity sha512-T1rRxzdqkEXcou0ZprN1q9yDRlvzCPLqmlNt5IIsGBzoEVgLCCYrKEwc84+TvsXuAc95VAZwtWD2zVsKPY4bcA== +"@js-sdsl/ordered-map@^4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" + integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== + "@koa/bodyparser@^6.0.0": version "6.0.0" resolved "https://registry.yarnpkg.com/@koa/bodyparser/-/bodyparser-6.0.0.tgz#e362ddb3691276064f36e8cbf79b66f5873360a0" @@ -2404,6 +2437,98 @@ koa-compose "^4.1.0" path-to-regexp "^6.3.0" +"@langchain/community@0.3.57": + version "0.3.57" + resolved "https://registry.yarnpkg.com/@langchain/community/-/community-0.3.57.tgz#7c5e7dfdf90d764df4314944eb6de3a8a2eaa86b" + integrity sha512-xUe5UIlh1yZjt/cMtdSVlCoC5xm/RMN/rp+KZGLbquvjQeONmQ2rvpCqWjAOgQ6SPLqKiXvoXaKSm20r+LHISw== + dependencies: + "@langchain/openai" ">=0.2.0 <0.7.0" + "@langchain/weaviate" "^0.2.0" + binary-extensions "^2.2.0" + expr-eval "^2.0.2" + flat "^5.0.2" + js-yaml "^4.1.0" + langchain ">=0.2.3 <0.3.0 || >=0.3.4 <0.4.0" + langsmith "^0.3.67" + uuid "^10.0.0" + zod "^3.25.32" + +"@langchain/core@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.4.tgz#73160c719ef99c77301fb66c7cd4a3995dcc790c" + integrity sha512-AZVHVoLJzhHU/jsjeNto1pvfHaPxGT+V3PcVyvUw0kCiWftdu1bxfwhwSsZJ9B9iJeXJdCIUe089+NYd3FsEuw== + dependencies: + "@cfworker/json-schema" "^4.0.2" + ansi-styles "^5.0.0" + camelcase "6" + decamelize "1.2.0" + js-tiktoken "^1.0.12" + langsmith "^0.3.64" + mustache "^4.2.0" + p-queue "^6.6.2" + uuid "^10.0.0" + zod "^3.25.76 || ^4" + +"@langchain/langgraph-checkpoint@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz#ece2ede439d0d0b0b532c4be7817fd5029afe4f8" + integrity sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A== + dependencies: + uuid "^10.0.0" + +"@langchain/langgraph-sdk@~1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.2.0.tgz#f9db1cd5524efda1e08423b9c10da370e0e2a834" + integrity sha512-nFfNJWc9P2job2uUoL37nXfz0VW9eLEtidP0edrgeHUW7BczIQzLXC9ucJHHHGLjlK0S522kmai0abAULv3pGA== + dependencies: + p-queue "^6.6.2" + p-retry "4" + uuid "^9.0.0" + +"@langchain/langgraph@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-1.0.4.tgz#12e472b1462ae944e53d956630166be101916e85" + integrity sha512-EYLyN/uv1ubMBd3RN/y+eAxY0FJWKrnzRw8HuDJdmDcyomgV9btyHK2zDN70sO3QDDuAU9voLNNUZeFBQkBYMQ== + dependencies: + "@langchain/langgraph-checkpoint" "^1.0.0" + "@langchain/langgraph-sdk" "~1.2.0" + uuid "^10.0.0" + +"@langchain/mcp-adapters@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@langchain/mcp-adapters/-/mcp-adapters-1.0.3.tgz#243f8717e359c27f34c417e9a37ddbda70b9c622" + integrity sha512-tQacqDcqikrli/gH7CGAOUfNJ0pFOwtoKPWq2mRP7BTApOPWyfZ8Okw5nSwPDZ7Eau/QyLAH7DaWfNHmwzXPGw== + dependencies: + "@modelcontextprotocol/sdk" "^1.18.2" + debug "^4.4.3" + zod "^3.25.76 || ^4" + optionalDependencies: + extended-eventsource "^1.7.0" + +"@langchain/openai@>=0.1.0 <0.7.0", "@langchain/openai@>=0.2.0 <0.7.0": + version "0.6.16" + resolved "https://registry.yarnpkg.com/@langchain/openai/-/openai-0.6.16.tgz#fed8bc90127d15255e0e4ee527c7eadb75b21e9c" + integrity sha512-v9INBOjE0w6ZrUE7kP9UkRyNsV7daH7aPeSOsPEJ35044UI3udPHwNduQ8VmaOUsD26OvSdg1b1GDhrqWLMaRw== + dependencies: + js-tiktoken "^1.0.12" + openai "5.12.2" + zod "^3.25.32" + +"@langchain/textsplitters@>=0.0.0 <0.2.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@langchain/textsplitters/-/textsplitters-0.1.0.tgz#f37620992192df09ecda3dfbd545b36a6bcbae46" + integrity sha512-djI4uw9rlkAb5iMhtLED+xJebDdAG935AdP4eRTB02R7OB/act55Bj9wsskhZsvuyQRpO4O1wQOp85s6T6GWmw== + dependencies: + js-tiktoken "^1.0.12" + +"@langchain/weaviate@^0.2.0": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@langchain/weaviate/-/weaviate-0.2.3.tgz#7b2557ea9a369bb7ce05dfc553f56b6ff062d90b" + integrity sha512-WqNGn1eSrI+ZigJd7kZjCj3fvHBYicKr054qts2nNJ+IyO5dWmY3oFTaVHFq1OLFVZJJxrFeDnxSEOC3JnfP0w== + dependencies: + uuid "^10.0.0" + weaviate-client "^3.5.2" + "@lerna/create@8.2.3": version "8.2.3" resolved "https://registry.yarnpkg.com/@lerna/create/-/create-8.2.3.tgz#8e88fedb60eb699f2f5057e7344d9f980b7f9554" @@ -2500,6 +2625,44 @@ semver "^7.3.5" tar "^6.1.11" +"@modelcontextprotocol/sdk@1.20.0": + version "1.20.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.20.0.tgz#3ff5c58ef23dd2a62ca93a2cc8b8e51f945e53b6" + integrity sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q== + dependencies: + ajv "^6.12.6" + content-type "^1.0.5" + cors "^2.8.5" + cross-spawn "^7.0.5" + eventsource "^3.0.2" + eventsource-parser "^3.0.0" + express "^5.0.1" + express-rate-limit "^7.5.0" + pkce-challenge "^5.0.0" + raw-body "^3.0.0" + zod "^3.23.8" + zod-to-json-schema "^3.24.1" + +"@modelcontextprotocol/sdk@^1.18.2": + version "1.24.3" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.24.3.tgz#81a3fcc919cb4ce8630e2bcecf59759176eb331a" + integrity sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw== + dependencies: + ajv "^8.17.1" + ajv-formats "^3.0.1" + content-type "^1.0.5" + cors "^2.8.5" + cross-spawn "^7.0.5" + eventsource "^3.0.2" + eventsource-parser "^3.0.0" + express "^5.0.1" + express-rate-limit "^7.5.0" + jose "^6.1.1" + pkce-challenge "^5.0.0" + raw-body "^3.0.0" + zod "^3.25 || ^4.0" + zod-to-json-schema "^3.25.0" + "@mongodb-js/saslprep@^1.1.0": version "1.1.1" resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.1.1.tgz#9a6c2516bc9188672c4d953ec99760ba49970da7" @@ -3377,6 +3540,59 @@ "@pnpm/network.ca-file" "^1.0.1" config-chain "^1.1.11" +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" + integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== + "@qiwi/multi-semantic-release@^7.1.2": version "7.1.2" resolved "https://registry.yarnpkg.com/@qiwi/multi-semantic-release/-/multi-semantic-release-7.1.2.tgz#bc1cc8a5ef98160939b39fef01d40d2334eaac4e" @@ -4631,6 +4847,16 @@ "@types/range-parser" "*" "@types/send" "*" +"@types/express-serve-static-core@^5.0.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz#74f47555b3d804b54cb7030e6f9aa0c7485cfc5b" + integrity sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + "@types/express@*": version "4.17.21" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" @@ -4641,6 +4867,15 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/express@5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.1.tgz#138d741c6e5db8cc273bec5285cd6e9d0779fc9f" + integrity sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^5.0.0" + "@types/serve-static" "*" + "@types/geojson@^7946.0.10": version "7946.0.13" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.13.tgz#e6e77ea9ecf36564980a861e24e62a095988775e" @@ -4813,6 +5048,14 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== +"@types/node-fetch@^2.6.4": + version "2.6.13" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.13.tgz#e0c9b7b5edbdb1b50ce32c127e85e880872d56ee" + integrity sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw== + dependencies: + "@types/node" "*" + form-data "^4.0.4" + "@types/node@*": version "20.9.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.9.0.tgz#bfcdc230583aeb891cf51e73cfdaacdd8deae298" @@ -4825,6 +5068,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.5.1.tgz#178d58ee7e4834152b0e8b4d30cbfab578b9bb30" integrity sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg== +"@types/node@>=13.7.0": + version "25.0.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.0.tgz#c0e0022c3c7b41635c49322e6b3a0279fffa7d62" + integrity sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew== + dependencies: + undici-types "~7.16.0" + "@types/node@>=18": version "22.10.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" @@ -4903,6 +5153,11 @@ dependencies: "@types/node" "*" +"@types/retry@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/semver@^7.3.12": version "7.5.5" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.5.tgz#deed5ab7019756c9c90ea86139106b0346223f35" @@ -5151,6 +5406,16 @@ abbrev@^3.0.0, abbrev@^3.0.1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-3.0.1.tgz#8ac8b3b5024d31464fe2a5feeea9f4536bf44025" integrity sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg== +abort-controller-x@^0.4.0: + version "0.4.3" + resolved "https://registry.yarnpkg.com/abort-controller-x/-/abort-controller-x-0.4.3.tgz#ff269788386fabd58a7b6eeaafcb6cf55c2958e0" + integrity sha512-VtUwTNU8fpMwvWGn4xE93ywbogTYsuT+AUxAXOeelbXuQVIwNmC5YLeho9sH4vZ4ITW8414TTAOG1nW6uIVHCA== + +abort-controller-x@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/abort-controller-x/-/abort-controller-x-0.5.0.tgz#2c0531a83c7717eccd47435bfe123bccfd34e2b8" + integrity sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -5222,6 +5487,13 @@ agentkeepalive@^4.1.3: dependencies: humanize-ms "^1.2.1" +agentkeepalive@^4.2.1: + version "4.6.0" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" + integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== + dependencies: + humanize-ms "^1.2.1" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -5272,6 +5544,16 @@ ajv@^8.0.0, ajv@^8.1.0, ajv@^8.10.0, ajv@^8.11.0: require-from-string "^2.0.2" uri-js "^4.2.2" +ajv@^8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ansi-colors@^4.1.1: version "4.1.3" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" @@ -5787,7 +6069,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.3.1: +base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -5845,6 +6127,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +binary-extensions@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + binary-extensions@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-3.1.0.tgz#be31cd3aa5c7e3dc42c501e57d4fff87d665e17e" @@ -6051,7 +6338,7 @@ byte-size@8.1.1: resolved "https://registry.yarnpkg.com/byte-size/-/byte-size-8.1.1.tgz#3424608c62d59de5bfda05d31e0313c6174842ae" integrity sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg== -bytes@3.1.2: +bytes@3.1.2, bytes@~3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== @@ -6209,16 +6496,16 @@ camelcase-keys@^6.2.2: map-obj "^4.0.0" quick-lru "^4.0.1" +camelcase@6, camelcase@^6.2.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + camelcase@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.2.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - caniuse-lite@^1.0.30001541: version "1.0.30001562" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001562.tgz#9d16c5fd7e9c592c4cd5e304bc0f75b0008b2759" @@ -6738,6 +7025,13 @@ console-control-strings@^1.0.0, console-control-strings@^1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== +console-table-printer@^2.12.1: + version "2.15.0" + resolved "https://registry.yarnpkg.com/console-table-printer/-/console-table-printer-2.15.0.tgz#5c808204640b8f024d545bde8aabe5d344dfadc1" + integrity sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw== + dependencies: + simple-wcswidth "^1.1.2" + content-disposition@0.5.4, content-disposition@^0.5.3, content-disposition@~0.5.2, content-disposition@~0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -6924,7 +7218,7 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== -cors@2.8.5: +cors@2.8.5, cors@^2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== @@ -6988,6 +7282,20 @@ croner@^6.0.6: resolved "https://registry.yarnpkg.com/croner/-/croner-6.0.7.tgz#73416ee178626c226a5765e498e1e8454738bdba" integrity sha512-k3Xx3Rcclfr60Yx4TmvsF3Yscuiql8LSvYLaphTsaq5Hk8La4Z/udmUANMOTKpgGGroI2F6/XOr9cU9OFkYluQ== +cross-fetch@^3.1.5: + version "3.2.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.2.0.tgz#34e9192f53bc757d6614304d9e5e6fb4edb782e3" + integrity sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q== + dependencies: + node-fetch "^2.7.0" + +cross-fetch@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.1.0.tgz#8f69355007ee182e47fa692ecbaa37a52e43c3d2" + integrity sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw== + dependencies: + node-fetch "^2.7.0" + cross-spawn@^6.0.0: version "6.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.6.tgz#30d0efa0712ddb7eb5a76e1e8721bffafa6b5d57" @@ -6999,7 +7307,7 @@ cross-spawn@^6.0.0: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.5, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -7075,7 +7383,7 @@ debug@^3.2.6, debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.3.1, debug@^4.4.1: +debug@^4.3.1, debug@^4.4.1, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -7104,7 +7412,7 @@ decamelize-keys@^1.1.0: decamelize "^1.1.0" map-obj "^1.0.0" -decamelize@^1.1.0: +decamelize@1.2.0, decamelize@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== @@ -8003,11 +8311,23 @@ events@^3.0.0, events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +eventsource-parser@^3.0.0, eventsource-parser@^3.0.1: + version "3.0.6" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz#292e165e34cacbc936c3c92719ef326d4aeb4e90" + integrity sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg== + eventsource@2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-2.0.2.tgz#76dfcc02930fb2ff339520b6d290da573a9e8508" integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA== +eventsource@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-3.0.7.tgz#1157622e2f5377bb6aef2114372728ba0c156989" + integrity sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA== + dependencies: + eventsource-parser "^3.0.1" + excel4node@^1.8.0: version "1.8.2" resolved "https://registry.yarnpkg.com/excel4node/-/excel4node-1.8.2.tgz#2d2f8b2ae56a3d3c7ae6a29bb2652b25807b021f" @@ -8137,7 +8457,17 @@ exponential-backoff@^3.1.1: resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.1.tgz#64ac7526fe341ab18a39016cd22c787d01e00bf6" integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== -express@4.21.2, express@^4.17.1, express@^4.18.2, express@^4.21.1: +expr-eval@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/expr-eval/-/expr-eval-2.0.2.tgz#fa6f044a7b0c93fde830954eb9c5b0f7fbc7e201" + integrity sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg== + +express-rate-limit@^7.5.0: + version "7.5.1" + resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-7.5.1.tgz#8c3a42f69209a3a1c969890070ece9e20a879dec" + integrity sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw== + +express@4.21.2, express@5.1.0, express@^4.17.1, express@^4.18.2, express@^4.21.1, express@^5.0.1: version "4.21.2" resolved "https://registry.yarnpkg.com/express/-/express-4.21.2.tgz#cf250e48362174ead6cea4a566abef0162c1ec32" integrity sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA== @@ -8186,6 +8516,11 @@ extend@^3.0.0: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== +extended-eventsource@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/extended-eventsource/-/extended-eventsource-1.7.0.tgz#8dd50a96aeca1571f407fc2d4bc08c234b140f19" + integrity sha512-s8rtvZuYcKBpzytHb5g95cHbZ1J99WeMnV18oKc5wKoxkHzlzpPc/bNAm7Da2Db0BDw0CAu1z3LpH+7UsyzIpw== + external-editor@^3.0.0, external-editor@^3.0.3, external-editor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" @@ -8331,6 +8666,11 @@ fast-uri@^2.0.0, fast-uri@^2.1.0: resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-2.3.0.tgz#bdae493942483d299e7285dcb4627767d42e2793" integrity sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw== +fast-uri@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" + integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== + fast-xml-parser@4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz#86dbf3f18edf8739326447bcaac31b4ae7f6514f" @@ -8687,6 +9027,11 @@ forest-ip-utils@^1.0.1: ip-address "^5.8.9" range_check "^1.4.0" +form-data-encoder@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + form-data@^4.0.0, form-data@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" @@ -8698,6 +9043,14 @@ form-data@^4.0.0, form-data@^4.0.4: hasown "^2.0.2" mime-types "^2.1.12" +formdata-node@^4.3.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" + integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== + dependencies: + node-domexception "1.0.0" + web-streams-polyfill "4.0.0-beta.3" + formidable@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/formidable/-/formidable-2.1.2.tgz#fa973a2bec150e4ce7cac15589d7a25fc30ebd89" @@ -9217,6 +9570,14 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +graphql-request@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/graphql-request/-/graphql-request-6.1.0.tgz#f4eb2107967af3c7a5907eb3131c671eac89be4f" + integrity sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw== + dependencies: + "@graphql-typed-document-node/core" "^3.2.0" + cross-fetch "^3.1.5" + graphql-tag@^2.12.6: version "2.12.6" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" @@ -9231,6 +9592,11 @@ graphql@14.5.7: dependencies: iterall "^1.2.2" +graphql@^16.12.0: + version "16.12.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.12.0.tgz#28cc2462435b1ac3fdc6976d030cef83a0c13ac7" + integrity sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ== + handlebars@4.7.7: version "4.7.7" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" @@ -9471,6 +9837,17 @@ http-errors@^1.6.3, http-errors@~1.8.0: statuses ">= 1.5.0 < 2" toidentifier "1.0.1" +http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + http-proxy-agent@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" @@ -9580,6 +9957,13 @@ iconv-lite@^0.6.2, iconv-lite@^0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +iconv-lite@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.0.tgz#c50cd80e6746ca8115eb98743afa81aa0e147a3e" + integrity sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -9698,7 +10082,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -10732,11 +11116,23 @@ jose@^4.15.9: resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.9.tgz#9b68eda29e9a0614c042fa29387196c7dd800100" integrity sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA== +jose@^6.1.1: + version "6.1.3" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.1.3.tgz#8453d7be88af7bb7d64a0481d6a35a0145ba3ea5" + integrity sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ== + js-md4@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/js-md4/-/js-md4-0.3.2.tgz#cd3b3dc045b0c404556c81ddb5756c23e59d7cf5" integrity sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA== +js-tiktoken@^1.0.12: + version "1.0.21" + resolved "https://registry.yarnpkg.com/js-tiktoken/-/js-tiktoken-1.0.21.tgz#368a9957591a30a62997dd0c4cf30866f00f8221" + integrity sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g== + dependencies: + base64-js "^1.5.1" + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -10904,6 +11300,11 @@ jsonparse@^1.2.0, jsonparse@^1.3.1: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== +jsonpointer@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-5.0.1.tgz#2110e0af0900fd37467b5907ecd13a7884a1b559" + integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ== + jsonwebtoken@9.0.2, jsonwebtoken@^9.0.0: version "9.0.2" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" @@ -11117,6 +11518,35 @@ koa@^3.0.1: type-is "^2.0.1" vary "^1.1.2" +"langchain@>=0.2.3 <0.3.0 || >=0.3.4 <0.4.0": + version "0.3.36" + resolved "https://registry.yarnpkg.com/langchain/-/langchain-0.3.36.tgz#6ab7f4028adae16bf74538aa2127df9fe2fddf02" + integrity sha512-PqC19KChFF0QlTtYDFgfEbIg+SCnCXox29G8tY62QWfj9bOW7ew2kgWmPw5qoHLOTKOdQPvXET20/1Pdq8vAtQ== + dependencies: + "@langchain/openai" ">=0.1.0 <0.7.0" + "@langchain/textsplitters" ">=0.0.0 <0.2.0" + js-tiktoken "^1.0.12" + js-yaml "^4.1.0" + jsonpointer "^5.0.1" + langsmith "^0.3.67" + openapi-types "^12.1.3" + p-retry "4" + uuid "^10.0.0" + yaml "^2.2.1" + zod "^3.25.32" + +langsmith@^0.3.64, langsmith@^0.3.67: + version "0.3.85" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.3.85.tgz#5b44287b140c8db1063462ca6243fd1bb3272f31" + integrity sha512-Txuaxnpcra57qld4+hkqHhd9L2D6G6kEAReWXdr/sddxPu6ycBHXStqDciituC642lJmzPdarrYtll2vSwMbnQ== + dependencies: + "@types/uuid" "^10.0.0" + chalk "^4.1.2" + console-table-printer "^2.12.1" + p-queue "^6.6.2" + semver "^7.6.3" + uuid "^10.0.0" + lerna@^8.2.3: version "8.2.3" resolved "https://registry.yarnpkg.com/lerna/-/lerna-8.2.3.tgz#0a9c07eda4cfac84a480b3e66915189ccfb5bd2c" @@ -11635,6 +12065,11 @@ loglevel@^1.4.1: resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.1.tgz#5c621f83d5b48c54ae93b6156353f555963377b4" integrity sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg== +long@^5.0.0, long@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + long@^5.2.1: version "5.2.3" resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" @@ -12524,6 +12959,11 @@ multimatch@5.0.0: arrify "^2.0.1" minimatch "^3.0.4" +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + mute-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" @@ -12652,6 +13092,30 @@ next-tick@1, next-tick@^1.1.0: resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== +nice-grpc-client-middleware-retry@^3.1.12: + version "3.1.13" + resolved "https://registry.yarnpkg.com/nice-grpc-client-middleware-retry/-/nice-grpc-client-middleware-retry-3.1.13.tgz#25de76d3ab86328a35e3b5c9093a4cb03d98b2a0" + integrity sha512-Q9I/wm5lYkDTveKFirrTHBkBY137yavXZ4xQDXTPIycUp7aLXD8xPTHFhqtAFWUw05aS91uffZZRgdv3HS0y/g== + dependencies: + abort-controller-x "^0.4.0" + nice-grpc-common "^2.0.2" + +nice-grpc-common@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/nice-grpc-common/-/nice-grpc-common-2.0.2.tgz#e6aeebb2bd19d87114b351e291e30d79dd38acf7" + integrity sha512-7RNWbls5kAL1QVUOXvBsv1uO0wPQK3lHv+cY1gwkTzirnG1Nop4cBJZubpgziNbaVc/bl9QJcyvsf/NQxa3rjQ== + dependencies: + ts-error "^1.0.6" + +nice-grpc@^2.1.13: + version "2.1.14" + resolved "https://registry.yarnpkg.com/nice-grpc/-/nice-grpc-2.1.14.tgz#ce598d52a8218e4312b9f8ac0f179e20049154e3" + integrity sha512-GK9pKNxlvnU5FAdaw7i2FFuR9CqBspcE+if2tqnKXBcE0R8525wj4BZvfcwj7FjvqbssqKxRHt2nwedalbJlww== + dependencies: + "@grpc/grpc-js" "^1.14.0" + abort-controller-x "^0.4.0" + nice-grpc-common "^2.0.2" + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -12676,6 +13140,11 @@ node-addon-api@^4.2.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== +node-domexception@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-emoji@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-2.2.0.tgz#1d000e3c76e462577895be1b436f4aa2d6760eb0" @@ -12693,7 +13162,7 @@ node-fetch@2.6.7: dependencies: whatwg-url "^5.0.0" -node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7: +node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@^2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -12784,6 +13253,13 @@ node-releases@^2.0.21: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.21.tgz#f59b018bc0048044be2d4c4c04e4c8b18160894c" integrity sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw== +node-zendesk@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/node-zendesk/-/node-zendesk-6.0.1.tgz#d935a7bf3bdb25316b3ac547e434f4c6202e9366" + integrity sha512-WWXF9NisTZR766J9Mzaxl3q3L+DR9TqrN4PZTiQoVBwtWCWQ3eChDMJbNxy98En6F/ZqJWHs9ojRd7R5gOtdoA== + dependencies: + cross-fetch "^4.0.0" + nodemon@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.0.3.tgz#244a62d1c690eece3f6165c6cdb0db03ebd80b76" @@ -13375,6 +13851,29 @@ open@^8.0.0, open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +openai@4.95.0: + version "4.95.0" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.95.0.tgz#e391f02561874b7cd5e8becd56feec77d88be32f" + integrity sha512-tWHLTA+/HHyWlP8qg0mQLDSpI2NQLhk6zHLJL8yb59qn2pEI8rbEiAGSDPViLvi3BRDoQZIX5scaJ3xYGr2nhw== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + +openai@5.12.2: + version "5.12.2" + resolved "https://registry.yarnpkg.com/openai/-/openai-5.12.2.tgz#512ab6b80eb8414837436e208f1b951442b97761" + integrity sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ== + +openapi-types@^12.1.3: + version "12.1.3" + resolved "https://registry.yarnpkg.com/openapi-types/-/openapi-types-12.1.3.tgz#471995eb26c4b97b7bd356aacf7b91b73e777dd3" + integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== + openid-client@4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-4.2.2.tgz#ad958fafd5a535d5ce6a8d55d983f6d898f7eed7" @@ -13572,7 +14071,7 @@ p-pipe@3.1.0: resolved "https://registry.yarnpkg.com/p-pipe/-/p-pipe-3.1.0.tgz#48b57c922aa2e1af6a6404cb7c6bf0eb9cc8e60e" integrity sha512-08pj8ATpzMR0Y80x50yJHn37NF6vjrqHutASaX5LiH5npS9XPvrUmscd9MF5R4fuYRHOxQR1FfMIlF7AzwoPqw== -p-queue@6.6.2: +p-queue@6.6.2, p-queue@^6.6.2: version "6.6.2" resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== @@ -13590,6 +14089,14 @@ p-reduce@^3.0.0: resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-3.0.0.tgz#f11773794792974bd1f7a14c72934248abff4160" integrity sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q== +p-retry@4: + version "4.6.2" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" + integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== + dependencies: + "@types/retry" "0.12.0" + retry "^0.13.1" + p-some@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-some/-/p-some-4.1.0.tgz#28e73bc1e0d62db54c2ed513acd03acba30d5c04" @@ -14110,6 +14617,11 @@ pirates@^4.0.4: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== +pkce-challenge@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d" + integrity sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ== + pkg-conf@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/pkg-conf/-/pkg-conf-2.1.0.tgz#2126514ca6f2abfebd168596df18ba57867f0058" @@ -14354,6 +14866,24 @@ proto-list@~1.2.1: resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== +protobufjs@^7.5.3: + version "7.5.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" + integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/node" ">=13.7.0" + long "^5.0.0" + protocols@^2.0.0, protocols@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/protocols/-/protocols-2.0.1.tgz#8f155da3fc0f32644e83c5782c8e8212ccf70a86" @@ -14481,6 +15011,16 @@ raw-body@2.5.2, raw-body@^2.3.3: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" + rc@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" @@ -14822,6 +15362,11 @@ retry@^0.12.0: resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== +retry@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + reusify@^1.0.2, reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -15162,7 +15707,7 @@ setimmediate@^1.0.5: resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== -setprototypeof@1.2.0: +setprototypeof@1.2.0, setprototypeof@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== @@ -15319,6 +15864,11 @@ simple-update-notifier@^2.0.0: dependencies: semver "^7.5.3" +simple-wcswidth@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b" + integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw== + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -15611,7 +16161,7 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -statuses@^2.0.1: +statuses@^2.0.1, statuses@~2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== @@ -16163,7 +16713,7 @@ toad-cache@^3.3.0: resolved "https://registry.yarnpkg.com/toad-cache/-/toad-cache-3.7.0.tgz#b9b63304ea7c45ec34d91f1d2fa513517025c441" integrity sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw== -toidentifier@1.0.1: +toidentifier@1.0.1, toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== @@ -16232,6 +16782,11 @@ trough@^1.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406" integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA== +ts-error@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/ts-error/-/ts-error-1.0.6.tgz#277496f2a28de6c184cfce8dfd5cdd03a4e6b0fc" + integrity sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA== + ts-invariant@^0.4.0: version "0.4.4" resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" @@ -16634,6 +17189,11 @@ undici-types@~6.21.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== +undici-types@~7.16.0: + version "7.16.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" + integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== + undici@^5.25.4: version "5.29.0" resolved "https://registry.yarnpkg.com/undici/-/undici-5.29.0.tgz#419595449ae3f2cdcba3580a2e8903399bd1f5a3" @@ -16952,6 +17512,25 @@ wcwidth@^1.0.0, wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +weaviate-client@^3.5.2: + version "3.10.0" + resolved "https://registry.yarnpkg.com/weaviate-client/-/weaviate-client-3.10.0.tgz#a6a9d04ad29cb8f77eeb721f1509caea0f93a6ea" + integrity sha512-PB338DjIwUus1Mq1dxhCc6fEp+yA+aY4H4sSFDS0No/GguEufd6SDhHHVLOYMy2cPgX35dWgEx5jUbG5o3aPZA== + dependencies: + abort-controller-x "^0.5.0" + graphql "^16.12.0" + graphql-request "^6.1.0" + long "^5.3.2" + nice-grpc "^2.1.13" + nice-grpc-client-middleware-retry "^3.1.12" + nice-grpc-common "^2.0.2" + uuid "^9.0.1" + +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -17203,16 +17782,16 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yaml@^2.2.1, yaml@^2.8.1: + version "2.8.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5" + integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== + yaml@^2.6.0: version "2.7.1" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.7.1.tgz#44a247d1b88523855679ac7fa7cda6ed7e135cf6" integrity sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ== -yaml@^2.8.1: - version "2.8.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5" - integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== - yargs-parser@21.1.1, yargs-parser@^21.0.1, yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" @@ -17228,7 +17807,7 @@ yargs-parser@^22.0.0: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8" integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== -yargs@17.7.2, yargs@^17.0.0, yargs@^17.3.1, yargs@^17.6.2: +yargs@17.7.2, yargs@^17.0.0, yargs@^17.3.1, yargs@^17.6.2, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== @@ -17304,6 +17883,21 @@ zen-observable@^0.8.0: resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== +zod-to-json-schema@^3.24.1, zod-to-json-schema@^3.25.0: + version "3.25.0" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz#df504c957c4fb0feff467c74d03e6aab0b013e1c" + integrity sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ== + +zod@^3.23.8, zod@^3.25.32: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== + +"zod@^3.25 || ^4.0", "zod@^3.25.76 || ^4": + version "4.1.13" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.13.tgz#93699a8afe937ba96badbb0ce8be6033c0a4b6b1" + integrity sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920" From 7c6a2465b0024ffd762cbd29e4b8fcada1f5cda5 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 11 Dec 2025 12:07:20 +0100 Subject: [PATCH 2/4] feat(forestadmin-client): add MCP server configuration service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add service to fetch MCP server configurations from Forest Admin API. - New McpServerConfigService for fetching MCP server configs - Add getMcpServerConfigs endpoint to ForestHttpApi - Integrate service into ForestAdminClient 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/build-application-services.ts | 6 ++++ .../src/forest-admin-client-with-cache.ts | 2 ++ packages/forestadmin-client/src/index.ts | 2 ++ .../src/mcp-server-config/index.ts | 17 +++++++++ .../src/mcp-server-config/types.ts | 5 +++ .../src/permissions/forest-http-api.ts | 10 ++++++ .../forestadmin-client/src/schema/types.ts | 1 + packages/forestadmin-client/src/types.ts | 4 +++ .../forest-admin-server-interface.ts | 1 + .../test/mcp-server-config/index.test.ts | 35 +++++++++++++++++++ .../test/permissions/forest-http-api.test.ts | 12 +++++++ 11 files changed, 95 insertions(+) create mode 100644 packages/forestadmin-client/src/mcp-server-config/index.ts create mode 100644 packages/forestadmin-client/src/mcp-server-config/types.ts create mode 100644 packages/forestadmin-client/test/mcp-server-config/index.test.ts diff --git a/packages/forestadmin-client/src/build-application-services.ts b/packages/forestadmin-client/src/build-application-services.ts index 10847fa8e0..123768160e 100644 --- a/packages/forestadmin-client/src/build-application-services.ts +++ b/packages/forestadmin-client/src/build-application-services.ts @@ -3,6 +3,7 @@ import EventsSubscriptionService from './events-subscription'; import NativeRefreshEventsHandlerService from './events-subscription/native-refresh-events-handler-service'; import { RefreshEventsHandlerService } from './events-subscription/types'; import IpWhiteListService from './ip-whitelist'; +import McpServerConfigFromApiService, { McpServerConfigService } from './mcp-server-config'; import ModelCustomizationFromApiService from './model-customizations/model-customization-from-api'; import { ModelCustomizationService } from './model-customizations/types'; import ActionPermissionService from './permissions/action-permission'; @@ -32,6 +33,7 @@ export default function buildApplicationServices( chartHandler: ChartHandler; auth: ForestAdminAuthServiceInterface; modelCustomizationService: ModelCustomizationService; + mcpServerConfigService: McpServerConfigService; eventsSubscription: EventsSubscriptionService; eventsHandler: RefreshEventsHandlerService; } { @@ -87,5 +89,9 @@ export default function buildApplicationServices( forestAdminServerInterface, optionsWithDefaults, ), + mcpServerConfigService: new McpServerConfigFromApiService( + forestAdminServerInterface, + optionsWithDefaults, + ), }; } diff --git a/packages/forestadmin-client/src/forest-admin-client-with-cache.ts b/packages/forestadmin-client/src/forest-admin-client-with-cache.ts index ab4edb71a3..96ad840b4d 100644 --- a/packages/forestadmin-client/src/forest-admin-client-with-cache.ts +++ b/packages/forestadmin-client/src/forest-admin-client-with-cache.ts @@ -5,6 +5,7 @@ import { } from './events-subscription/types'; import IpWhiteListService from './ip-whitelist'; import { IpWhitelistConfiguration } from './ip-whitelist/types'; +import { McpServerConfigService } from './mcp-server-config'; import { ModelCustomizationService } from './model-customizations/types'; import RenderingPermissionService from './permissions/rendering-permission'; import { RawTree } from './permissions/types'; @@ -30,6 +31,7 @@ export default class ForestAdminClientWithCache implements ForestAdminClient { protected readonly schemaService: SchemaService, public readonly authService: ForestAdminAuthServiceInterface, public readonly modelCustomizationService: ModelCustomizationService, + public readonly mcpServerConfigService: McpServerConfigService, protected readonly eventsSubscription: BaseEventsSubscriptionService, protected readonly eventsHandler: RefreshEventsHandlerService, ) {} diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index 2aa634758e..77850d05b0 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -43,6 +43,7 @@ export default function createForestAdminClient( schema, auth, modelCustomizationService, + mcpServerConfigService, eventsSubscription, eventsHandler, } = buildApplicationServices(new ForestHttpApi(), options); @@ -57,6 +58,7 @@ export default function createForestAdminClient( schema, auth, modelCustomizationService, + mcpServerConfigService, eventsSubscription, eventsHandler, ); diff --git a/packages/forestadmin-client/src/mcp-server-config/index.ts b/packages/forestadmin-client/src/mcp-server-config/index.ts new file mode 100644 index 0000000000..f79b910da8 --- /dev/null +++ b/packages/forestadmin-client/src/mcp-server-config/index.ts @@ -0,0 +1,17 @@ +import type { McpConfiguration } from '@forestadmin/ai-proxy'; + +import { McpServerConfigService } from './types'; +import { ForestAdminClientOptionsWithDefaults, ForestAdminServerInterface } from '../types'; + +export default class McpServerConfigFromApiService implements McpServerConfigService { + constructor( + private readonly forestadminServerInterface: ForestAdminServerInterface, + private readonly options: ForestAdminClientOptionsWithDefaults, + ) {} + + async getConfiguration(): Promise { + return this.forestadminServerInterface.getMcpServerConfigs(this.options); + } +} + +export { McpServerConfigService } from './types'; diff --git a/packages/forestadmin-client/src/mcp-server-config/types.ts b/packages/forestadmin-client/src/mcp-server-config/types.ts new file mode 100644 index 0000000000..b4b52c7750 --- /dev/null +++ b/packages/forestadmin-client/src/mcp-server-config/types.ts @@ -0,0 +1,5 @@ +import type { McpConfiguration } from '@forestadmin/ai-proxy'; + +export interface McpServerConfigService { + getConfiguration(): Promise; +} diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index 79f1b7cc00..c7220638c3 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -1,3 +1,5 @@ +import type { McpConfiguration } from '@forestadmin/ai-proxy'; + import { EnvironmentPermissionsV4, RenderingPermissionV4, UserPermissionV4 } from './types'; import AuthService from '../auth'; import { ModelCustomization } from '../model-customizations/types'; @@ -34,6 +36,14 @@ export default class ForestHttpApi implements ForestAdminServerInterface { return ServerUtils.query(options, 'get', '/liana/model-customizations'); } + async getMcpServerConfigs(options: HttpOptions): Promise { + return ServerUtils.query( + options, + 'get', + '/liana/mcp-server-configs-with-details', + ); + } + makeAuthService(options: Required): ForestAdminAuthServiceInterface { return new AuthService(options); } diff --git a/packages/forestadmin-client/src/schema/types.ts b/packages/forestadmin-client/src/schema/types.ts index c71806ce45..dbf9898e20 100644 --- a/packages/forestadmin-client/src/schema/types.ts +++ b/packages/forestadmin-client/src/schema/types.ts @@ -6,6 +6,7 @@ export type ForestSchema = { liana: string; liana_version: string; liana_features: Record | null; + ai_llms: Array<{ provider: string }> | null; stack: { engine: string; engine_version: string; diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 1e5579656b..57c0ec3513 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -1,10 +1,12 @@ import type { ChartRequest } from './charts/chart-handler'; import type { Chart, QueryChart } from './charts/types'; +import type { McpConfiguration } from '@forestadmin/ai-proxy'; import { ParsedUrlQuery } from 'querystring'; import { Tokens, UserInfo } from './auth/types'; import { IpWhitelistConfiguration } from './ip-whitelist/types'; +import { McpServerConfigService } from './mcp-server-config/types'; import { ModelCustomization, ModelCustomizationService } from './model-customizations/types'; import { HttpOptions } from './permissions/forest-http-api'; import { @@ -49,6 +51,7 @@ export interface ForestAdminClient { readonly contextVariablesInstantiator: ContextVariablesInstantiatorInterface; readonly chartHandler: ChartHandlerInterface; readonly modelCustomizationService: ModelCustomizationService; + readonly mcpServerConfigService: McpServerConfigService; readonly authService: ForestAdminAuthServiceInterface; verifySignedActionParameters(signedParameters: string): TSignedParameters; @@ -161,5 +164,6 @@ export interface ForestAdminServerInterface { getUsers: (...args) => Promise; getRenderingPermissions: (renderingId: number, ...args) => Promise; getModelCustomizations: (options: HttpOptions) => Promise; + getMcpServerConfigs: (options: HttpOptions) => Promise; makeAuthService(options: ForestAdminClientOptionsWithDefaults): ForestAdminAuthServiceInterface; } diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts index a3b070477f..2a278d2f10 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts @@ -6,6 +6,7 @@ const forestAdminServerInterface = { getEnvironmentPermissions: jest.fn(), getUsers: jest.fn(), getModelCustomizations: jest.fn(), + getMcpServerConfigs: jest.fn(), makeAuthService: jest.fn(), }), }; diff --git a/packages/forestadmin-client/test/mcp-server-config/index.test.ts b/packages/forestadmin-client/test/mcp-server-config/index.test.ts new file mode 100644 index 0000000000..94c670df5d --- /dev/null +++ b/packages/forestadmin-client/test/mcp-server-config/index.test.ts @@ -0,0 +1,35 @@ +import McpServerConfigFromApiService from '../../src/mcp-server-config'; +import * as factories from '../__factories__'; + +describe('McpServerConfigFromApiService', () => { + describe('getConfiguration', () => { + it('should call getMcpServerConfigs on the server interface', async () => { + const mcpConfig = { + mcpServers: [{ name: 'server1', url: 'http://localhost:3000' }], + }; + const serverInterface = factories.forestAdminServerInterface.build(); + (serverInterface.getMcpServerConfigs as jest.Mock).mockResolvedValue(mcpConfig); + + const options = factories.forestAdminClientOptions.build(); + const service = new McpServerConfigFromApiService(serverInterface, options); + + const result = await service.getConfiguration(); + + expect(serverInterface.getMcpServerConfigs).toHaveBeenCalledWith(options); + expect(result).toEqual(mcpConfig); + }); + + it('should return empty mcpServers when server returns empty config', async () => { + const mcpConfig = { mcpServers: [] }; + const serverInterface = factories.forestAdminServerInterface.build(); + (serverInterface.getMcpServerConfigs as jest.Mock).mockResolvedValue(mcpConfig); + + const options = factories.forestAdminClientOptions.build(); + const service = new McpServerConfigFromApiService(serverInterface, options); + + const result = await service.getConfiguration(); + + expect(result).toEqual({ mcpServers: [] }); + }); + }); +}); diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 71ac6eeefc..79755f9ed5 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -42,4 +42,16 @@ describe('ForestHttpApi', () => { expect(ServerUtils.query).toHaveBeenCalledWith(options, 'get', '/liana/model-customizations'); }); }); + + describe('getMcpServerConfigs', () => { + it('should call the right endpoint', async () => { + await new ForestHttpApi().getMcpServerConfigs(options); + + expect(ServerUtils.query).toHaveBeenCalledWith( + options, + 'get', + '/liana/mcp-server-configs-with-details', + ); + }); + }); }); From 9dd379af3b428c0fdd52267e0605106f769697c5 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 11 Dec 2025 12:10:13 +0100 Subject: [PATCH 3/4] feat(agent): add AI integration with addAI customization method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for AI/LLM integration in the agent package. - New addAI customization method for configuring AI provider - AI proxy route handler at /_internal/ai-proxy/:route - Integration with MCP server configuration service - Schema flag for AI enabled projects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/agent/package.json | 1 + packages/agent/src/agent.ts | 34 ++++++- packages/agent/src/routes/ai/ai-proxy.ts | 38 ++++++++ packages/agent/src/routes/index.ts | 15 +++- packages/agent/src/types.ts | 4 + .../src/utils/forest-schema/generator.ts | 8 +- .../test/__factories__/forest-admin-client.ts | 3 + packages/agent/test/agent.test.ts | 71 +++++++++++++++ .../agent/test/routes/ai/ai-proxy.test.ts | 90 +++++++++++++++++++ packages/agent/test/routes/index.test.ts | 32 +++++++ .../utils/forest-schema/generator.test.ts | 2 + 11 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 packages/agent/src/routes/ai/ai-proxy.ts create mode 100644 packages/agent/test/routes/ai/ai-proxy.test.ts diff --git a/packages/agent/package.json b/packages/agent/package.json index f728572dd5..2d4ce1924a 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -14,6 +14,7 @@ "dependencies": { "@fast-csv/format": "^4.3.5", "@fastify/express": "^1.1.0", + "@forestadmin/ai-proxy": "0.1.0", "@forestadmin/datasource-customizer": "1.67.1", "@forestadmin/datasource-toolkit": "1.50.0", "@forestadmin/forestadmin-client": "1.36.14", diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index bccf70428f..f4d08cd895 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -20,7 +20,7 @@ import FrameworkMounter from './framework-mounter'; import makeRoutes from './routes'; import makeServices, { ForestAdminHttpDriverServices } from './services'; import CustomizationService from './services/model-customizations/customization'; -import { AgentOptions, AgentOptionsWithDefaults } from './types'; +import { AgentOptions, AgentOptionsWithDefaults, AiConfiguration } from './types'; import SchemaGenerator from './utils/forest-schema/generator'; import OptionsValidator from './utils/options-validator'; @@ -40,6 +40,7 @@ export default class Agent extends FrameworkMounter protected nocodeCustomizer: DataSourceCustomizer; protected customizationService: CustomizationService; protected schemaGenerator: SchemaGenerator; + protected aiConfiguration: AiConfiguration | null = null; /** * Create a new Agent Builder. @@ -190,8 +191,30 @@ export default class Agent extends FrameworkMounter return this; } + /** + * Add AI configuration to the agent. + * This enables AI-powered features through the /forest/_internal/ai-proxy/* endpoints. + * + * @param configuration AI client configuration + * @example + * agent.addAI({ + * provider: 'openai', + * apiKey: process.env.OPENAI_API_KEY, + * model: 'gpt-4' + * }); + */ + addAI(configuration: AiConfiguration): this { + if (this.aiConfiguration) { + throw new Error('addAI() can only be called once'); + } + + this.aiConfiguration = configuration; + + return this; + } + protected getRoutes(dataSource: DataSource, services: ForestAdminHttpDriverServices) { - return makeRoutes(dataSource, this.options, services); + return makeRoutes(dataSource, this.options, services, this.aiConfiguration); } /** @@ -261,7 +284,12 @@ export default class Agent extends FrameworkMounter // Either load the schema from the file system or build it let schema: Pick; - const { meta } = SchemaGenerator.buildMetadata(this.customizationService.buildFeatures()); + // Get the AI provider name if configured (e.g., 'openai') + const aiProvider = this.aiConfiguration?.provider ?? null; + const { meta } = SchemaGenerator.buildMetadata( + this.customizationService.buildFeatures(), + aiProvider, + ); // When using experimental no-code features even in production we need to build a new schema if (!experimental?.webhookCustomActions && isProduction) { diff --git a/packages/agent/src/routes/ai/ai-proxy.ts b/packages/agent/src/routes/ai/ai-proxy.ts new file mode 100644 index 0000000000..0935ba2b26 --- /dev/null +++ b/packages/agent/src/routes/ai/ai-proxy.ts @@ -0,0 +1,38 @@ +import { Router as AiProxyRouter } from '@forestadmin/ai-proxy'; +import KoaRouter from '@koa/router'; +import { Context } from 'koa'; + +import { ForestAdminHttpDriverServices } from '../../services'; +import { AgentOptionsWithDefaults, AiConfiguration, HttpCode, RouteType } from '../../types'; +import BaseRoute from '../base-route'; + +export default class AiProxyRoute extends BaseRoute { + readonly type = RouteType.PrivateRoute; + private readonly aiProxyRouter: AiProxyRouter; + + constructor( + services: ForestAdminHttpDriverServices, + options: AgentOptionsWithDefaults, + aiConfiguration: AiConfiguration, + ) { + super(services, options); + this.aiProxyRouter = new AiProxyRouter({ + aiConfiguration, + logger: this.options.logger, + }); + } + + setupRoutes(router: KoaRouter): void { + router.post('/_internal/ai-proxy/:route', this.handleAiProxy.bind(this)); + } + + private async handleAiProxy(context: Context): Promise { + context.response.body = await this.aiProxyRouter.route({ + route: context.params.route, + body: context.request.body, + query: context.query, + mcpConfigs: await this.options.forestAdminClient.mcpServerConfigService.getConfiguration(), + }); + context.response.status = HttpCode.Ok; + } +} diff --git a/packages/agent/src/routes/index.ts b/packages/agent/src/routes/index.ts index b528f8ddea..d49295a57c 100644 --- a/packages/agent/src/routes/index.ts +++ b/packages/agent/src/routes/index.ts @@ -11,6 +11,7 @@ import Get from './access/get'; import List from './access/list'; import ListRelated from './access/list-related'; import NativeQueryDatasource from './access/native-query-datasource'; +import AiProxyRoute from './ai/ai-proxy'; import BaseRoute from './base-route'; import Capabilities from './capabilities'; import ActionRoute from './modification/action/action'; @@ -28,7 +29,7 @@ import ErrorHandling from './system/error-handling'; import HealthCheck from './system/healthcheck'; import Logger from './system/logger'; import { ForestAdminHttpDriverServices as Services } from '../services'; -import { AgentOptionsWithDefaults as Options } from '../types'; +import { AiConfiguration, AgentOptionsWithDefaults as Options } from '../types'; export const ROOT_ROUTES_CTOR = [ Authentication, @@ -164,10 +165,21 @@ function getActionRoutes( return routes; } +function getAiRoutes( + options: Options, + services: Services, + aiConfiguration?: AiConfiguration, +): BaseRoute[] { + if (!aiConfiguration) return []; + + return [new AiProxyRoute(services, options, aiConfiguration)]; +} + export default function makeRoutes( dataSource: DataSource, options: Options, services: Services, + aiConfiguration?: AiConfiguration, ): BaseRoute[] { const routes = [ ...getRootRoutes(options, services), @@ -177,6 +189,7 @@ export default function makeRoutes( ...getApiChartRoutes(dataSource, options, services), ...getRelatedRoutes(dataSource, options, services), ...getActionRoutes(dataSource, options, services), + ...getAiRoutes(options, services, aiConfiguration), ]; // Ensure routes and middlewares are loaded in the right order. diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index d9a24316c6..b7dfc28cd4 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -1,7 +1,11 @@ +import type { AiConfiguration, AiProvider } from '@forestadmin/ai-proxy'; + import { CompositeId, Logger, LoggerLevel } from '@forestadmin/datasource-toolkit'; import { ForestAdminClient } from '@forestadmin/forestadmin-client'; import { IncomingMessage, ServerResponse } from 'http'; +export type { AiConfiguration, AiProvider }; + /** Options to configure behavior of an agent's forestadmin driver */ export type AgentOptions = { authSecret: string; diff --git a/packages/agent/src/utils/forest-schema/generator.ts b/packages/agent/src/utils/forest-schema/generator.ts index 85ded5e5dd..3900c92ef1 100644 --- a/packages/agent/src/utils/forest-schema/generator.ts +++ b/packages/agent/src/utils/forest-schema/generator.ts @@ -2,7 +2,7 @@ import { DataSource } from '@forestadmin/datasource-toolkit'; import { ForestSchema } from '@forestadmin/forestadmin-client'; import SchemaGeneratorCollection from './generator-collection'; -import { AgentOptionsWithDefaults } from '../../types'; +import { AgentOptionsWithDefaults, AiProvider } from '../../types'; export default class SchemaGenerator { private readonly schemaGeneratorCollection: SchemaGeneratorCollection; @@ -21,7 +21,10 @@ export default class SchemaGenerator { }; } - static buildMetadata(features: Record | null): Pick { + static buildMetadata( + features: Record | null, + aiLlm: AiProvider | null = null, + ): Pick { const { version } = require('../../../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires,global-require return { @@ -29,6 +32,7 @@ export default class SchemaGenerator { liana: 'forest-nodejs-agent', liana_version: version, liana_features: features, + ai_llms: aiLlm ? [{ provider: aiLlm }] : null, stack: { engine: 'nodejs', engine_version: process.versions && process.versions.node, diff --git a/packages/agent/test/__factories__/forest-admin-client.ts b/packages/agent/test/__factories__/forest-admin-client.ts index 8366cd7069..d9c20dea71 100644 --- a/packages/agent/test/__factories__/forest-admin-client.ts +++ b/packages/agent/test/__factories__/forest-admin-client.ts @@ -43,6 +43,9 @@ const forestAdminClientFactory = ForestAdminClientFactory.define(() => ({ modelCustomizationService: { getConfiguration: jest.fn(), }, + mcpServerConfigService: { + getConfiguration: jest.fn(), + }, subscribeToServerEvents: jest.fn(), close: jest.fn(), onRefreshCustomizations: jest.fn(), diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index b7b9df38f9..fc4061c111 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -127,6 +127,7 @@ describe('Agent', () => { liana: 'forest-nodejs-agent', liana_version: expect.stringMatching(/\d+\.\d+\.\d+.*/), liana_features: null, + ai_llms: null, stack: expect.anything(), }, }); @@ -155,6 +156,7 @@ describe('Agent', () => { liana_features: { 'webhook-custom-actions': expect.stringMatching(/\d+\.\d+\.\d+.*/), }, + ai_llms: null, stack: expect.anything(), }, }); @@ -329,4 +331,73 @@ describe('Agent', () => { }); }); }); + + describe('addAI', () => { + const options = factories.forestAdminHttpDriverOptions.build({ + isProduction: false, + forestAdminClient: factories.forestAdminClient.build({ postSchema: mockPostSchema }), + }); + + test('should store the AI configuration', () => { + const agent = new Agent(options); + const result = agent.addAI({ + provider: 'openai', + apiKey: 'test-key', + model: 'gpt-4', + }); + + expect(result).toBe(agent); + }); + + test('should throw an error when called more than once', () => { + const agent = new Agent(options); + + agent.addAI({ + provider: 'openai', + apiKey: 'test-key', + model: 'gpt-4', + }); + + expect(() => + agent.addAI({ + provider: 'openai', + apiKey: 'another-key', + model: 'gpt-4-turbo', + }), + ).toThrow('addAI() can only be called once'); + }); + + test('should include ai_llms in schema meta when AI is configured', async () => { + const agent = new Agent(options); + agent.addAI({ + provider: 'openai', + apiKey: 'test-key', + model: 'gpt-4', + }); + + await agent.start(); + + expect(mockPostSchema).toHaveBeenCalledWith( + expect.objectContaining({ + meta: expect.objectContaining({ + ai_llms: [{ provider: 'openai' }], + }), + }), + ); + }); + + test('should not include ai_llms in schema meta when AI is not configured', async () => { + const agent = new Agent(options); + + await agent.start(); + + expect(mockPostSchema).toHaveBeenCalledWith( + expect.objectContaining({ + meta: expect.objectContaining({ + ai_llms: null, + }), + }), + ); + }); + }); }); diff --git a/packages/agent/test/routes/ai/ai-proxy.test.ts b/packages/agent/test/routes/ai/ai-proxy.test.ts new file mode 100644 index 0000000000..ad36c7238f --- /dev/null +++ b/packages/agent/test/routes/ai/ai-proxy.test.ts @@ -0,0 +1,90 @@ +import { createMockContext } from '@shopify/jest-koa-mocks'; + +import AiProxyRoute from '../../../src/routes/ai/ai-proxy'; +import { RouteType } from '../../../src/types'; +import * as factories from '../../__factories__'; + +jest.mock('@forestadmin/ai-proxy', () => ({ + Router: jest.fn().mockImplementation(() => ({ + route: jest.fn().mockResolvedValue({ result: 'success' }), + })), +})); + +describe('AiProxyRoute', () => { + const services = factories.forestAdminHttpDriverServices.build(); + const router = factories.router.mockAllMethods().build(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create an AiProxyRouter with the configuration', () => { + const { Router } = jest.requireMock('@forestadmin/ai-proxy'); + const options = factories.forestAdminHttpDriverOptions.build(); + const aiConfiguration = { provider: 'openai' as const, apiKey: 'test-key', model: 'gpt-4' }; + + // eslint-disable-next-line no-new + new AiProxyRoute(services, options, aiConfiguration); + + expect(Router).toHaveBeenCalledWith({ + aiConfiguration, + logger: options.logger, + }); + }); + }); + + describe('type', () => { + it('should be a private route', () => { + const options = factories.forestAdminHttpDriverOptions.build(); + const aiConfiguration = { provider: 'openai' as const, apiKey: 'test-key', model: 'gpt-4' }; + + const route = new AiProxyRoute(services, options, aiConfiguration); + + expect(route.type).toBe(RouteType.PrivateRoute); + }); + }); + + describe('setupRoutes', () => { + it('should register POST /_internal/ai-proxy/:route', () => { + const options = factories.forestAdminHttpDriverOptions.build(); + const aiConfiguration = { provider: 'openai' as const, apiKey: 'test-key', model: 'gpt-4' }; + + const route = new AiProxyRoute(services, options, aiConfiguration); + route.setupRoutes(router); + + expect(router.post).toHaveBeenCalledWith('/_internal/ai-proxy/:route', expect.any(Function)); + }); + }); + + describe('handleAiProxy', () => { + it('should route requests through the AI proxy router', async () => { + const mcpServerConfigService = { + getConfiguration: jest.fn().mockResolvedValue({ mcpServers: [] }), + }; + const options = factories.forestAdminHttpDriverOptions.build({ + forestAdminClient: { + mcpServerConfigService, + } as never, + }); + const aiConfiguration = { provider: 'openai' as const, apiKey: 'test-key', model: 'gpt-4' }; + + const route = new AiProxyRoute(services, options, aiConfiguration); + + const context = createMockContext({ + requestBody: { message: 'hello' }, + customProperties: { + params: { route: 'chat' }, + query: { stream: 'true' }, + }, + }); + + // Call the handler - access private method for testing + // eslint-disable-next-line @typescript-eslint/dot-notation + await route['handleAiProxy'](context); + + expect(context.response.body).toEqual({ result: 'success' }); + expect(context.response.status).toBe(200); + }); + }); +}); diff --git a/packages/agent/test/routes/index.test.ts b/packages/agent/test/routes/index.test.ts index 2afaaa7ceb..65040ce6dc 100644 --- a/packages/agent/test/routes/index.test.ts +++ b/packages/agent/test/routes/index.test.ts @@ -34,6 +34,13 @@ import Logger from '../../src/routes/system/logger'; import { RouteType } from '../../src/types'; import * as factories from '../__factories__'; +// Mock the ai-proxy module to avoid langchain module resolution issues in tests +jest.mock('@forestadmin/ai-proxy', () => ({ + Router: jest.fn().mockImplementation(() => ({ + route: jest.fn(), + })), +})); + describe('Route index', () => { it('should declare all the routes', () => { expect(ROOT_ROUTES_CTOR).toEqual([ @@ -298,5 +305,30 @@ describe('Route index', () => { expect(lqRoute).toBeTruthy(); }); }); + + describe('with AI configuration', () => { + test('should instantiate AI proxy route when aiConfiguration is provided', async () => { + const dataSource = factories.dataSource.buildWithCollection( + factories.collection.build({ name: 'books' }), + ); + const aiConfiguration = { provider: 'openai' as const, apiKey: 'test-key', model: 'gpt-4' }; + + const routes = makeRoutes( + dataSource, + factories.forestAdminHttpDriverOptions.build(), + factories.forestAdminHttpDriverServices.build(), + aiConfiguration, + ); + + // Should have one more route than without AI configuration + const routesWithoutAi = makeRoutes( + dataSource, + factories.forestAdminHttpDriverOptions.build(), + factories.forestAdminHttpDriverServices.build(), + ); + + expect(routes.length).toEqual(routesWithoutAi.length + 1); + }); + }); }); }); diff --git a/packages/agent/test/utils/forest-schema/generator.test.ts b/packages/agent/test/utils/forest-schema/generator.test.ts index e9a8f11d40..63d19324cc 100644 --- a/packages/agent/test/utils/forest-schema/generator.test.ts +++ b/packages/agent/test/utils/forest-schema/generator.test.ts @@ -40,6 +40,7 @@ describe('SchemaGenerator', () => { liana: 'forest-nodejs-agent', liana_version: expect.any(String), liana_features: null, + ai_llms: null, stack: { engine: 'nodejs', engine_version: expect.any(String), @@ -62,6 +63,7 @@ describe('SchemaGenerator', () => { 'webhook-custom-actions': '1.0.0', 'awesome-feature': '3.0.0', }, + ai_llms: null, stack: { engine: 'nodejs', engine_version: expect.any(String), From b14cc6679ac5986fed7b042f7352a14301827df3 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 13 Dec 2025 10:14:36 +0100 Subject: [PATCH 4/4] refactor(mcp-server): extract framework-agnostic handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This refactor introduces McpHandlers class that provides framework-agnostic handlers for MCP OAuth and protocol requests. This allows: 1. MCP logic to be reused with any HTTP framework (Express, Koa, etc.) 2. Agent to use native Koa routes instead of Express-to-Koa bridging 3. Cleaner separation between protocol logic and HTTP framework Key changes: - Add McpHandlers class with framework-agnostic methods: - getOAuthMetadata(): returns OAuth metadata - getProtectedResourceMetadata(): returns protected resource metadata - handleAuthorize(): returns redirect URL or error - handleToken(): returns tokens or error - handleMcpRequest(): handles MCP protocol requests - Add getAuthorizationUrl() to ForestOAuthProvider (returns URL string) - Update agent to create native Koa routes using McpHandlers - Update FrameworkMounter to mount Koa router for MCP routes - Remove express-to-koa wrapper (no longer needed) - Simplify useMcp() API (no factory needed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/agent/src/agent.ts | 244 +++- packages/agent/src/framework-mounter.ts | 136 +- packages/agent/src/types.ts | 15 +- packages/mcp-server/package.json | 49 + packages/mcp-server/src/__mocks__/version.ts | 3 + packages/mcp-server/src/cli.ts | 11 + packages/mcp-server/src/factory.ts | 68 + .../src/forest-oauth-provider.test.ts | 747 +++++++++++ .../mcp-server/src/forest-oauth-provider.ts | 428 +++++++ packages/mcp-server/src/handlers.ts | 377 ++++++ packages/mcp-server/src/index.ts | 25 + packages/mcp-server/src/mcp-paths.ts | 7 + packages/mcp-server/src/polyfills.ts | 30 + .../mcp-server/src/schemas/filter.test.ts | 268 ++++ packages/mcp-server/src/schemas/filter.ts | 61 + packages/mcp-server/src/server.test.ts | 1136 +++++++++++++++++ packages/mcp-server/src/server.ts | 351 +++++ .../mcp-server/src/test-utils/mock-server.ts | 243 ++++ packages/mcp-server/src/tools/list.test.ts | 492 +++++++ packages/mcp-server/src/tools/list.ts | 116 ++ .../src/utils/activity-logs-creator.test.ts | 315 +++++ .../src/utils/activity-logs-creator.ts | 79 ++ .../mcp-server/src/utils/agent-caller.test.ts | 145 +++ packages/mcp-server/src/utils/agent-caller.ts | 36 + .../mcp-server/src/utils/error-parser.test.ts | 142 +++ packages/mcp-server/src/utils/error-parser.ts | 64 + .../src/utils/schema-fetcher.test.ts | 280 ++++ .../mcp-server/src/utils/schema-fetcher.ts | 139 ++ packages/mcp-server/src/version.ts | 8 + packages/mcp-server/tsconfig.json | 7 + yarn.lock | 119 +- 31 files changed, 6090 insertions(+), 51 deletions(-) create mode 100644 packages/mcp-server/package.json create mode 100644 packages/mcp-server/src/__mocks__/version.ts create mode 100644 packages/mcp-server/src/cli.ts create mode 100644 packages/mcp-server/src/factory.ts create mode 100644 packages/mcp-server/src/forest-oauth-provider.test.ts create mode 100644 packages/mcp-server/src/forest-oauth-provider.ts create mode 100644 packages/mcp-server/src/handlers.ts create mode 100644 packages/mcp-server/src/index.ts create mode 100644 packages/mcp-server/src/mcp-paths.ts create mode 100644 packages/mcp-server/src/polyfills.ts create mode 100644 packages/mcp-server/src/schemas/filter.test.ts create mode 100644 packages/mcp-server/src/schemas/filter.ts create mode 100644 packages/mcp-server/src/server.test.ts create mode 100644 packages/mcp-server/src/server.ts create mode 100644 packages/mcp-server/src/test-utils/mock-server.ts create mode 100644 packages/mcp-server/src/tools/list.test.ts create mode 100644 packages/mcp-server/src/tools/list.ts create mode 100644 packages/mcp-server/src/utils/activity-logs-creator.test.ts create mode 100644 packages/mcp-server/src/utils/activity-logs-creator.ts create mode 100644 packages/mcp-server/src/utils/agent-caller.test.ts create mode 100644 packages/mcp-server/src/utils/agent-caller.ts create mode 100644 packages/mcp-server/src/utils/error-parser.test.ts create mode 100644 packages/mcp-server/src/utils/error-parser.ts create mode 100644 packages/mcp-server/src/utils/schema-fetcher.test.ts create mode 100644 packages/mcp-server/src/utils/schema-fetcher.ts create mode 100644 packages/mcp-server/src/version.ts create mode 100644 packages/mcp-server/tsconfig.json diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index f4d08cd895..bd53b8a7d4 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -10,6 +10,13 @@ import { } from '@forestadmin/datasource-customizer'; import { DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit'; import { ForestSchema } from '@forestadmin/forestadmin-client'; +import { + McpHandlers, + isMcpRoute, + type AuthorizeParams, + type McpHandlersOptions, + type TokenParams, +} from '@forestadmin/mcp-server'; import bodyParser from '@koa/bodyparser'; import cors from '@koa/cors'; import Router from '@koa/router'; @@ -20,7 +27,7 @@ import FrameworkMounter from './framework-mounter'; import makeRoutes from './routes'; import makeServices, { ForestAdminHttpDriverServices } from './services'; import CustomizationService from './services/model-customizations/customization'; -import { AgentOptions, AgentOptionsWithDefaults, AiConfiguration } from './types'; +import { AgentOptions, AgentOptionsWithDefaults, McpOptions } from './types'; import SchemaGenerator from './utils/forest-schema/generator'; import OptionsValidator from './utils/options-validator'; @@ -40,7 +47,12 @@ export default class Agent extends FrameworkMounter protected nocodeCustomizer: DataSourceCustomizer; protected customizationService: CustomizationService; protected schemaGenerator: SchemaGenerator; - protected aiConfiguration: AiConfiguration | null = null; + + /** MCP options registered via useMcp() */ + private mcpOptions: McpOptions | null = null; + + /** MCP handlers instance */ + private mcpHandlers: McpHandlers | null = null; /** * Create a new Agent Builder. @@ -74,11 +86,12 @@ export default class Agent extends FrameworkMounter * Start the agent. */ async start(): Promise { - const router = await this.buildRouterAndSendSchema(); + const { router, mcpRouter } = await this.buildRouterAndSendSchema(); await this.options.forestAdminClient.subscribeToServerEvents(); this.options.forestAdminClient.onRefreshCustomizations(this.restart.bind(this)); + this.setMcpRouter(mcpRouter ?? null); await this.mount(router); } @@ -97,9 +110,10 @@ export default class Agent extends FrameworkMounter */ async restart(): Promise { // We force sending schema when restarting - const updatedRouter = await this.buildRouterAndSendSchema(); + const { router, mcpRouter } = await this.buildRouterAndSendSchema(); - await this.remount(updatedRouter); + this.setMcpRouter(mcpRouter ?? null); + await this.remount(router); } /** @@ -192,41 +206,44 @@ export default class Agent extends FrameworkMounter } /** - * Add AI configuration to the agent. - * This enables AI-powered features through the /forest/_internal/ai-proxy/* endpoints. + * Enable MCP (Model Context Protocol) server support. + * This allows AI assistants to interact with your Forest Admin data. * - * @param configuration AI client configuration + * @param options Optional configuration for the MCP server * @example - * agent.addAI({ - * provider: 'openai', - * apiKey: process.env.OPENAI_API_KEY, - * model: 'gpt-4' - * }); + * agent.useMcp({ baseUrl: 'https://my-app.example.com' }); */ - addAI(configuration: AiConfiguration): this { - if (this.aiConfiguration) { - throw new Error('addAI() can only be called once'); - } - - this.aiConfiguration = configuration; + useMcp(options?: McpOptions): this { + this.mcpOptions = options || {}; return this; } protected getRoutes(dataSource: DataSource, services: ForestAdminHttpDriverServices) { - return makeRoutes(dataSource, this.options, services, this.aiConfiguration); + return makeRoutes(dataSource, this.options, services); } /** * Create an http handler which can respond to all queries which are expected from an agent. + * Returns the main router and optional MCP router. */ - private async getRouter(dataSource: DataSource): Promise { + private async getRouter( + dataSource: DataSource, + ): Promise<{ router: Router; mcpRouter?: Router }> { // Bootstrap app const services = makeServices(this.options); const routes = this.getRoutes(dataSource, services); + await Promise.all(routes.map(route => route.bootstrap())); - // Build router + // Initialize MCP router if configured via useMcp() + let mcpRouter: Router | undefined; + + if (this.mcpOptions !== null) { + mcpRouter = await this.createMcpRouter(); + } + + // Build main router const router = new Router(); router.all('(.*)', cors({ credentials: true, maxAge: 24 * 3600, privateNetworkAccess: true })); router.use( @@ -239,10 +256,180 @@ export default class Agent extends FrameworkMounter ); routes.forEach(route => route.setupRoutes(router)); + return { router, mcpRouter }; + } + + /** + * Create a Koa router with MCP routes using the framework-agnostic handlers. + */ + private async createMcpRouter(): Promise { + const handlerOptions: McpHandlersOptions = { + forestServerUrl: this.options.forestServerUrl, + envSecret: this.options.envSecret, + authSecret: this.options.authSecret, + logger: this.options.logger, + }; + + // Reuse existing handlers instance or create new one + if (!this.mcpHandlers) { + this.mcpHandlers = new McpHandlers(handlerOptions); + await this.mcpHandlers.initialize(); + } + + const handlers = this.mcpHandlers; + + // Determine base URL + const baseUrl = this.mcpOptions?.baseUrl + ? new URL('/', this.mcpOptions.baseUrl) + : handlers.getBaseUrl(); + + if (!baseUrl) { + throw new Error( + 'Could not determine base URL for MCP server. ' + + 'Either provide a baseUrl option to useMcp() or ensure the Forest Admin environment has an api_endpoint configured.', + ); + } + + // Create router with MCP routes + const router = new Router(); + + // CORS for MCP routes + router.use(cors({ credentials: true })); + + // Body parser for MCP routes + router.use( + bodyParser({ + encoding: 'utf-8', + parsedMethods: ['POST'], + }), + ); + + // OAuth metadata endpoint (RFC 8414) + router.get('/.well-known/oauth-authorization-server', ctx => { + ctx.body = handlers.getOAuthMetadata(baseUrl); + }); + + // OAuth protected resource metadata (RFC 9728) + router.get('/.well-known/oauth-protected-resource', ctx => { + ctx.body = handlers.getProtectedResourceMetadata(baseUrl); + }); + + // Authorization endpoint + router.get('/oauth/authorize', async ctx => { + await this.handleAuthorize(ctx, handlers); + }); + + router.post('/oauth/authorize', async ctx => { + await this.handleAuthorize(ctx, handlers); + }); + + // Token endpoint + router.post('/oauth/token', async ctx => { + await this.handleToken(ctx, handlers); + }); + + // MCP endpoint (protected) + router.post('/mcp', async ctx => { + await this.handleMcp(ctx, handlers); + }); + + this.options.logger('Info', 'MCP server initialized successfully'); + return router; } - private async buildRouterAndSendSchema(): Promise { + private async handleAuthorize(ctx: any, handlers: McpHandlers): Promise { + ctx.set('Cache-Control', 'no-store'); + + const params = ctx.method === 'POST' ? ctx.request.body : ctx.query; + const authorizeParams: AuthorizeParams = { + clientId: params.client_id, + redirectUri: params.redirect_uri, + codeChallenge: params.code_challenge, + state: params.state, + scope: params.scope, + }; + + const result = await handlers.handleAuthorize(authorizeParams); + + if (result.type === 'redirect') { + ctx.redirect(result.url); + } else { + ctx.status = result.status; + ctx.body = { error: result.error, error_description: result.errorDescription }; + } + } + + private async handleToken(ctx: any, handlers: McpHandlers): Promise { + const body = ctx.request.body as Record; + const tokenParams: TokenParams = { + grantType: body.grant_type, + clientId: body.client_id, + code: body.code, + codeVerifier: body.code_verifier, + redirectUri: body.redirect_uri, + refreshToken: body.refresh_token, + }; + + const result = await handlers.handleToken(tokenParams); + + if (result.type === 'success') { + ctx.body = result.tokens; + } else { + ctx.status = result.status; + ctx.body = { error: result.error, error_description: result.errorDescription }; + } + } + + private async handleMcp(ctx: any, handlers: McpHandlers): Promise { + // Extract bearer token + const authHeader = ctx.get('Authorization'); + + if (!authHeader?.startsWith('Bearer ')) { + ctx.status = 401; + ctx.body = { error: 'unauthorized', error_description: 'Bearer token required' }; + + return; + } + + const token = authHeader.slice(7); + + try { + // Verify token + const authInfo = await handlers.verifyAccessToken(token); + + // Check scopes + if (!authInfo.scopes.includes('mcp:read')) { + ctx.status = 403; + ctx.body = { error: 'insufficient_scope', error_description: 'mcp:read scope required' }; + + return; + } + + // Handle MCP request - this writes directly to the response for streaming + const result = await handlers.handleMcpRequest(ctx.req, ctx.res, ctx.request.body); + + if (result.type === 'handled') { + ctx.respond = false; // Let the transport handle the response + } else { + ctx.status = result.status; + ctx.body = result.error; + } + } catch (error: any) { + this.options.logger('Error', `MCP Error: ${error}`); + ctx.status = error.message?.includes('token') ? 401 : 500; + ctx.body = { + jsonrpc: '2.0', + error: { code: -32603, message: error.message || 'Internal error' }, + id: null, + }; + } + } + + private async buildRouterAndSendSchema(): Promise<{ + router: Router; + mcpRouter?: Router; + }> { const { isProduction, logger, typingsPath, typingsMaxDepth } = this.options; // It allows to rebuild the full customization stack with no code customizations @@ -254,7 +441,7 @@ export default class Agent extends FrameworkMounter this.nocodeCustomizer.use(this.customizationService.addCustomizations); const dataSource = await this.nocodeCustomizer.getDataSource(logger); - const [router] = await Promise.all([ + const [routers] = await Promise.all([ this.getRouter(dataSource), this.sendSchema(dataSource), !isProduction && typingsPath @@ -262,7 +449,7 @@ export default class Agent extends FrameworkMounter : Promise.resolve(), ]); - return router; + return routers; } /** @@ -284,12 +471,7 @@ export default class Agent extends FrameworkMounter // Either load the schema from the file system or build it let schema: Pick; - // Get the AI provider name if configured (e.g., 'openai') - const aiProvider = this.aiConfiguration?.provider ?? null; - const { meta } = SchemaGenerator.buildMetadata( - this.customizationService.buildFeatures(), - aiProvider, - ); + const { meta } = SchemaGenerator.buildMetadata(this.customizationService.buildFeatures()); // When using experimental no-code features even in production we need to build a new schema if (!experimental?.webhookCustomActions && isProduction) { diff --git a/packages/agent/src/framework-mounter.ts b/packages/agent/src/framework-mounter.ts index 519a9b5525..548228268c 100644 --- a/packages/agent/src/framework-mounter.ts +++ b/packages/agent/src/framework-mounter.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Logger } from '@forestadmin/datasource-toolkit'; +import { isMcpRoute } from '@forestadmin/mcp-server'; import Router from '@koa/router'; import { createServer } from 'http'; import Koa from 'koa'; @@ -14,10 +15,14 @@ export default class FrameworkMounter { private readonly onFirstStart: (() => Promise)[] = []; private readonly onEachStart: ((router: Router) => Promise)[] = []; + private readonly onStop: (() => Promise)[] = []; - private readonly prefix: string; + protected readonly prefix: string; private readonly logger: Logger; + /** MCP Router - stored here to be mounted on various frameworks */ + private mcpRouter: Router | null = null; + /** Compute the prefix that the main router should be mounted at in the client's application */ private get completeMountPrefix(): string { return path.posix.join('/', this.prefix, 'forest'); @@ -28,6 +33,13 @@ export default class FrameworkMounter { this.logger = logger; } + /** + * Set the MCP Router. Call this before mount() or remount(). + */ + protected setMcpRouter(router: Router | null): void { + this.mcpRouter = router; + } + protected async mount(router: Router): Promise { for (const task of this.onFirstStart) await task(); // eslint-disable-line no-await-in-loop @@ -91,7 +103,12 @@ export default class FrameworkMounter { * @param express instance of the express app or router. */ mountOnExpress(express: any): this { + // MCP middleware - uses the Koa router callback + express.use(this.getMcpMiddleware()); + + // Mount main forest routes at /{prefix}/forest express.use(this.completeMountPrefix, this.getConnectCallback(false)); + this.logger('Info', `Successfully mounted on Express.js`); return this; @@ -102,8 +119,12 @@ export default class FrameworkMounter { * @param fastify instance of the fastify app, or of a fastify context */ mountOnFastify(fastify: any): this { + // MCP middleware at root + this.useCallbackOnFastify(fastify, this.getMcpMiddleware(), '/'); + + // Mount main forest routes const callback = this.getConnectCallback(false); - this.useCallbackOnFastify(fastify, callback); + this.useCallbackOnFastify(fastify, callback, this.completeMountPrefix); this.logger('Info', `Successfully mounted on Fastify`); @@ -130,6 +151,8 @@ export default class FrameworkMounter { parentRouter.use(driverRouter.routes()); }); + // MCP routes - mounted at root level (native Koa) + koa.use(this.getMcpKoaMiddleware()); koa.use(parentRouter.routes()); this.logger('Info', `Successfully mounted on Koa`); @@ -145,9 +168,14 @@ export default class FrameworkMounter { const callback = this.getConnectCallback(false); if (adapter.constructor.name === 'ExpressAdapter') { + // MCP middleware at root + nestJs.use(this.getMcpMiddleware()); + // Mount main forest routes nestJs.use(this.completeMountPrefix, callback); } else { - this.useCallbackOnFastify(nestJs, callback); + // Fastify adapter - MCP middleware at root + this.useCallbackOnFastify(nestJs, this.getMcpMiddleware(), '/'); + this.useCallbackOnFastify(nestJs, callback, this.completeMountPrefix); } this.logger('Info', `Successfully mounted on NestJS`); @@ -155,29 +183,100 @@ export default class FrameworkMounter { return this; } - private useCallbackOnFastify(fastify: any, callback: HttpCallback): void { + private fastifyExpressRegistered: Promise | null = null; + private pendingFastifyCallbacks: Array<{ callback: HttpCallback; prefix: string }> = []; + + private useCallbackOnFastify(fastify: any, callback: HttpCallback, prefix: string): void { try { // 'fastify 2' or 'middie' or 'fastify-express' - fastify.use(this.completeMountPrefix, callback); + fastify.use(prefix, callback); } catch (e) { // 'fastify 3' if (e.code === 'FST_ERR_MISSING_MIDDLEWARE') { - fastify - .register(import('@fastify/express')) - .then(() => { - fastify.use(this.completeMountPrefix, callback); - }) - .catch(err => { - this.logger('Error', err.message); + // Queue the callback to be registered after @fastify/express is loaded + this.pendingFastifyCallbacks.push({ callback, prefix }); + + // Only register @fastify/express once + if (!this.fastifyExpressRegistered) { + this.fastifyExpressRegistered = new Promise((resolve, reject) => { + fastify.register(import('@fastify/express')).after((err: Error | null) => { + if (err) { + this.logger('Error', err.message); + reject(err); + + return; + } + + // Register all pending callbacks + for (const pending of this.pendingFastifyCallbacks) { + fastify.use(pending.prefix, pending.callback); + } + + this.pendingFastifyCallbacks = []; + resolve(); + }); }); + } } else { throw e; } } } + /** + * Get an Express/Connect-style middleware that forwards MCP requests to the Koa router. + * Non-MCP routes are passed to the next middleware. + */ + private getMcpMiddleware(): HttpCallback { + return (req, res, next) => { + const url = req.url || '/'; + + if (!isMcpRoute(url)) { + if (next) next(); + + return; + } + + if (this.mcpRouter) { + // Create a minimal Koa app to handle the request + const app = new Koa(); + app.use(this.mcpRouter.routes()); + app.use(this.mcpRouter.allowedMethods()); + app.callback()(req, res); + } else if (next) { + next(); + } + }; + } + + /** + * Get a Koa middleware that handles MCP routes using the native Koa router. + */ + private getMcpKoaMiddleware(): Koa.Middleware { + return async (ctx, next) => { + if (!isMcpRoute(ctx.url)) { + await next(); + + return; + } + + if (this.mcpRouter) { + // Create a minimal Koa app to handle the request with the router + const app = new Koa(); + app.use(this.mcpRouter.routes()); + app.use(this.mcpRouter.allowedMethods()); + + // Use the app callback directly on the raw request/response + await app.callback()(ctx.req, ctx.res); + ctx.respond = false; // Tell Koa we've handled the response + } else { + await next(); + } + }; + } + private getConnectCallback(nested: boolean): HttpCallback { - let handler = null; + let handler: ((req: any, res: any) => void) | null = null; this.onEachStart.push(async driverRouter => { let router = driverRouter; @@ -186,7 +285,16 @@ export default class FrameworkMounter { router = new Router({ prefix: this.completeMountPrefix }).use(router.routes()); } - handler = new Koa().use(router.routes()).callback(); + // Include MCP router in nested mode (standalone server) + const app = new Koa(); + + if (nested && this.mcpRouter) { + app.use(this.mcpRouter.routes()); + app.use(this.mcpRouter.allowedMethods()); + } + + app.use(router.routes()); + handler = app.callback(); }); return (req, res) => { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index b7dfc28cd4..fbe4aba09b 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -6,6 +6,19 @@ import { IncomingMessage, ServerResponse } from 'http'; export type { AiConfiguration, AiProvider }; +/** + * Options for the MCP server. + */ +export interface McpOptions { + /** + * Optional override for the base URL where the agent is publicly accessible. + * If not provided, it will be automatically fetched from Forest Admin API + * (the environment's api_endpoint configuration). + * Example: 'https://my-app.example.com' or 'http://localhost:3000' + */ + baseUrl?: string; +} + /** Options to configure behavior of an agent's forestadmin driver */ export type AgentOptions = { authSecret: string; @@ -51,7 +64,7 @@ export type AgentOptions = { }; export type AgentOptionsWithDefaults = Readonly>; -export type HttpCallback = (req: IncomingMessage, res: ServerResponse) => void; +export type HttpCallback = (req: IncomingMessage, res: ServerResponse, next?: () => void) => void; export enum HttpCode { BadRequest = 400, diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 0000000000..ac77f706f0 --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,49 @@ +{ + "name": "@forestadmin/mcp-server", + "version": "0.1.0", + "description": "Model Context Protocol server for Forest Admin with OAuth authentication", + "main": "dist/index.js", + "bin": { + "forest-mcp-server": "dist/cli.js" + }, + "license": "GPL-3.0", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ForestAdmin/agent-nodejs.git", + "directory": "packages/mcp-server" + }, + "dependencies": { + "@forestadmin-experimental/agent-nodejs-testing": "^0.36.0", + "@forestadmin/forestadmin-client": "^1.36.14", + "@modelcontextprotocol/sdk": "^1.0.4", + "cors": "^2.8.5", + "express": "^4.18.2", + "jsonapi-serializer": "^3.6.9", + "jsonwebtoken": "^9.0.2", + "zod": "^4.1.13" + }, + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts" + ], + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "start": "node dist/cli.js", + "start:dev": "node --env-file=.env dist/cli.js", + "clean": "rm -rf coverage dist", + "lint": "eslint src test", + "test": "jest" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^18.11.18", + "@types/supertest": "^6.0.2", + "supertest": "^7.1.3", + "typescript": "^5.0.0" + } +} diff --git a/packages/mcp-server/src/__mocks__/version.ts b/packages/mcp-server/src/__mocks__/version.ts new file mode 100644 index 0000000000..d8090258ff --- /dev/null +++ b/packages/mcp-server/src/__mocks__/version.ts @@ -0,0 +1,3 @@ +// Mock version module for Jest (avoids import.meta.url issues in CommonJS) +export const VERSION = '0.1.0'; +export const NAME = '@forestadmin/mcp-server'; diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts new file mode 100644 index 0000000000..0d62a6ed77 --- /dev/null +++ b/packages/mcp-server/src/cli.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import ForestMCPServer from './server'; + +// Start the server when run directly as CLI +const server = new ForestMCPServer(); + +server.run().catch(error => { + console.error('[FATAL] Server crashed:', error); + process.exit(1); +}); diff --git a/packages/mcp-server/src/factory.ts b/packages/mcp-server/src/factory.ts new file mode 100644 index 0000000000..a784314df4 --- /dev/null +++ b/packages/mcp-server/src/factory.ts @@ -0,0 +1,68 @@ +import type { HttpCallback } from './server'; + +import ForestMCPServer, { Logger } from './server'; + +/** + * Context passed from the Forest Admin agent to the MCP factory. + */ +export interface McpFactoryContext { + /** Forest Admin server URL */ + forestServerUrl: string; + /** Environment secret */ + envSecret: string; + /** Authentication secret */ + authSecret: string; + /** Logger function */ + logger: Logger; +} + +/** + * Options for the MCP factory function. + */ +export interface McpFactoryOptions { + /** + * Optional override for the base URL where the agent is publicly accessible. + * If not provided, it will be automatically fetched from Forest Admin API + * (the environment's api_endpoint configuration). + * Example: 'https://my-app.example.com' or 'http://localhost:3000' + */ + baseUrl?: string; +} + +/** + * Factory function to create an MCP HTTP callback for use with the Forest Admin agent. + * + * This function is designed to be used with the `agent.useMcp()` method: + * + * @example + * ```typescript + * import { createAgent } from '@forestadmin/agent'; + * import { createMcpServer } from '@forestadmin/mcp-server'; + * + * const agent = createAgent(options) + * .addDataSource(myDataSource) + * .useMcp(createMcpServer, { baseUrl: 'https://my-app.example.com' }); + * + * agent.mountOnExpress(app); + * agent.start(); + * ``` + * + * @param context - Context containing Forest Admin configuration (provided by the agent) + * @param options - Optional configuration for the MCP server + * @returns An HTTP callback that handles MCP routes + */ +export async function createMcpServer( + context: McpFactoryContext, + options?: McpFactoryOptions, +): Promise { + const mcpServer = new ForestMCPServer({ + forestServerUrl: context.forestServerUrl, + envSecret: context.envSecret, + authSecret: context.authSecret, + logger: context.logger, + }); + + const baseUrl = options?.baseUrl ? new URL('/', options.baseUrl) : undefined; + + return mcpServer.getHttpCallback(baseUrl); +} diff --git a/packages/mcp-server/src/forest-oauth-provider.test.ts b/packages/mcp-server/src/forest-oauth-provider.test.ts new file mode 100644 index 0000000000..d8adee6e5b --- /dev/null +++ b/packages/mcp-server/src/forest-oauth-provider.test.ts @@ -0,0 +1,747 @@ +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { Response } from 'express'; + +import createForestAdminClient from '@forestadmin/forestadmin-client'; +import jsonwebtoken from 'jsonwebtoken'; + +import ForestOAuthProvider from './forest-oauth-provider'; +import MockServer from './test-utils/mock-server'; + +jest.mock('jsonwebtoken'); +jest.mock('@forestadmin/forestadmin-client'); + +const mockCreateForestAdminClient = createForestAdminClient as jest.MockedFunction< + typeof createForestAdminClient +>; +const mockJwtDecode = jsonwebtoken.decode as jest.Mock; +const mockJwtSign = jsonwebtoken.sign as jest.Mock; + +const TEST_ENV_SECRET = 'test-env-secret'; +const TEST_AUTH_SECRET = 'test-auth-secret'; + +function createProvider(forestServerUrl = 'https://api.forestadmin.com') { + return new ForestOAuthProvider({ + forestServerUrl, + envSecret: TEST_ENV_SECRET, + authSecret: TEST_AUTH_SECRET, + logger: console.info, + }); +} + +describe('ForestOAuthProvider', () => { + let originalEnv: NodeJS.ProcessEnv; + let mockServer: MockServer; + const originalFetch = global.fetch; + + beforeAll(() => { + originalEnv = { ...process.env }; + }); + + afterAll(() => { + process.env = originalEnv; + global.fetch = originalFetch; + }); + + beforeEach(() => { + process.env.FOREST_ENV_SECRET = TEST_ENV_SECRET; + process.env.FOREST_AUTH_SECRET = TEST_AUTH_SECRET; + mockServer = new MockServer(); + }); + + afterEach(() => { + mockServer.reset(); + }); + + describe('constructor', () => { + it('should create instance with forestServerUrl', () => { + const customProvider = createProvider('https://custom.forestadmin.com'); + + expect(customProvider).toBeDefined(); + }); + }); + + describe('initialize', () => { + it('should not throw when envSecret is empty string', async () => { + const customProvider = new ForestOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + envSecret: '', + authSecret: TEST_AUTH_SECRET, + logger: console.info, + }); + + await expect(customProvider.initialize()).resolves.not.toThrow(); + }); + + it('should fetch environmentId from Forest Admin API', async () => { + mockServer.get('/liana/environment', { + data: { id: '98765', attributes: { api_endpoint: 'https://api.example.com' } }, + }); + global.fetch = mockServer.fetch; + + const testProvider = createProvider(); + + await testProvider.initialize(); + + // Verify fetch was called with correct URL and headers + expect(mockServer.fetch).toHaveBeenCalledWith( + 'https://api.forestadmin.com/liana/environment', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'forest-secret-key': 'test-env-secret', + 'Content-Type': 'application/json', + }), + }), + ); + }); + + it('should set environmentId after successful initialization', async () => { + mockServer.get('/liana/environment', { + data: { id: '54321', attributes: { api_endpoint: 'https://api.example.com' } }, + }); + global.fetch = mockServer.fetch; + + const testProvider = createProvider(); + + await testProvider.initialize(); + + // Verify environmentId is set by checking authorize redirect includes it + const mockResponse = { redirect: jest.fn() }; + const mockClient = { + client_id: 'test-client', + redirect_uris: ['https://example.com/callback'], + } as OAuthClientInformationFull; + + await testProvider.authorize( + mockClient, + { + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge', + state: 'state', + scopes: ['mcp:read'], + resource: new URL('https://localhost:3931'), + }, + mockResponse as unknown as Response, + ); + + const redirectUrl = new URL((mockResponse.redirect as jest.Mock).mock.calls[0][0]); + expect(redirectUrl.searchParams.get('environmentId')).toBe('54321'); + }); + + it('should handle non-OK response from Forest Admin API', async () => { + mockServer.get('/liana/environment', { error: 'Unauthorized' }, 401); + global.fetch = mockServer.fetch; + + const loggerSpy = jest.fn(); + const testProvider = new ForestOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + envSecret: TEST_ENV_SECRET, + authSecret: TEST_AUTH_SECRET, + logger: loggerSpy, + }); + + await testProvider.initialize(); + + expect(loggerSpy).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining('Failed to fetch environmentId from Forest Admin API'), + ); + }); + + it('should handle fetch network errors gracefully', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + const loggerSpy = jest.fn(); + const testProvider = new ForestOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + envSecret: TEST_ENV_SECRET, + authSecret: TEST_AUTH_SECRET, + logger: loggerSpy, + }); + + await testProvider.initialize(); + + expect(loggerSpy).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining('Failed to fetch environmentId from Forest Admin API'), + ); + }); + + it('should use correct forest server URL for API call', async () => { + mockServer.get('/liana/environment', { + data: { id: '11111', attributes: { api_endpoint: 'https://api.example.com' } }, + }); + global.fetch = mockServer.fetch; + + const testProvider = createProvider('https://custom.forestadmin.com'); + + await testProvider.initialize(); + + expect(mockServer.fetch).toHaveBeenCalledWith( + 'https://custom.forestadmin.com/liana/environment', + expect.any(Object), + ); + }); + }); + + describe('clientsStore.getClient', () => { + it('should fetch client from Forest Admin API', async () => { + const clientData = { + client_id: 'test-client-123', + redirect_uris: ['https://example.com/callback'], + client_name: 'Test Client', + }; + mockServer.get('/oauth/register/test-client-123', clientData); + global.fetch = mockServer.fetch; + + const provider = createProvider(); + + const client = await provider.clientsStore.getClient('test-client-123'); + + expect(client).toEqual(clientData); + expect(mockServer.fetch).toHaveBeenCalledWith( + 'https://api.forestadmin.com/oauth/register/test-client-123', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }), + ); + }); + + it('should return undefined when client is not found', async () => { + mockServer.get('/oauth/register/unknown-client', { error: 'Not found' }, 404); + global.fetch = mockServer.fetch; + + const provider = createProvider(); + + const client = await provider.clientsStore.getClient('unknown-client'); + + expect(client).toBeUndefined(); + }); + + it('should return undefined on server error', async () => { + mockServer.get('/oauth/register/error-client', { error: 'Internal error' }, 500); + global.fetch = mockServer.fetch; + + const provider = createProvider(); + + const client = await provider.clientsStore.getClient('error-client'); + + expect(client).toBeUndefined(); + }); + }); + + describe('authorize', () => { + let mockResponse: Partial; + let mockClient: OAuthClientInformationFull; + let initializedProvider: ForestOAuthProvider; + + beforeEach(async () => { + mockResponse = { + redirect: jest.fn(), + }; + mockClient = { + client_id: 'test-client-id', + redirect_uris: ['https://example.com/callback'], + } as OAuthClientInformationFull; + + // Create provider and mock the fetch to set environmentId + initializedProvider = createProvider(); + + // Mock fetch to return a valid response + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }), + }); + global.fetch = mockFetch; + + await initializedProvider.initialize(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should redirect to Forest Admin authentication URL', async () => { + await initializedProvider.authorize( + mockClient, + { + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-code-challenge', + state: 'test-state', + scopes: ['mcp:read', 'profile'], + resource: new URL('https://localhost:3931'), + }, + mockResponse as Response, + ); + + expect(mockResponse.redirect).toHaveBeenCalledWith( + expect.stringContaining('https://app.forestadmin.com/oauth/authorize'), + ); + }); + + it('should include all required query parameters in redirect URL', async () => { + await initializedProvider.authorize( + mockClient, + { + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-code-challenge', + state: 'test-state', + scopes: ['mcp:read', 'profile'], + resource: new URL('https://localhost:3931'), + }, + mockResponse as Response, + ); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + + expect(url.hostname).toBe('app.forestadmin.com'); + expect(url.pathname).toBe('/oauth/authorize'); + expect(url.searchParams.get('redirect_uri')).toBe('https://example.com/callback'); + expect(url.searchParams.get('code_challenge')).toBe('test-code-challenge'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('client_id')).toBe('test-client-id'); + expect(url.searchParams.get('state')).toBe('test-state'); + expect(url.searchParams.get('scope')).toBe('mcp:read+profile'); + expect(url.searchParams.get('environmentId')).toBe('12345'); + }); + + it('should redirect to error URL when environmentId is not set', async () => { + // Create a provider without initializing (environmentId is undefined) + const uninitializedProvider = createProvider(); + + await uninitializedProvider.authorize( + mockClient, + { + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-code-challenge', + state: 'test-state', + scopes: ['mcp:read'], + resource: new URL('https://localhost:3931'), + }, + mockResponse as Response, + ); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + + expect(redirectCall).toContain('https://example.com/callback'); + expect(redirectCall).toContain('error=server_error'); + }); + }); + + describe('exchangeAuthorizationCode', () => { + let mockClient: OAuthClientInformationFull; + let mockGetUserInfo: jest.Mock; + + beforeEach(() => { + mockClient = { + client_id: 'test-client-id', + redirect_uris: ['https://example.com/callback'], + scope: 'mcp:read mcp:write', + } as OAuthClientInformationFull; + + // Setup mock for forestAdminClient + mockGetUserInfo = jest.fn().mockResolvedValue({ + id: 123, + email: 'user@example.com', + firstName: 'Test', + lastName: 'User', + team: 'Operations', + role: 'Admin', + tags: {}, + renderingId: 456, + permissionLevel: 'admin', + }); + + mockCreateForestAdminClient.mockReturnValue({ + authService: { + getUserInfo: mockGetUserInfo, + }, + } as unknown as ReturnType); + + // Setup mock for jsonwebtoken - decode is called twice: + // first for access token, then for refresh token + const now = Math.floor(Date.now() / 1000); + mockJwtDecode + .mockReturnValueOnce({ + meta: { renderingId: 456 }, + exp: now + 3600, // expires in 1 hour + iat: now, + scope: 'mcp:read', + }) + .mockReturnValueOnce({ + exp: now + 604800, // expires in 7 days + iat: now, + }); + mockJwtSign.mockReturnValue('mocked-jwt-token'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should exchange authorization code with Forest Admin server', async () => { + mockServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .post('/oauth/token', { + access_token: 'forest-access-token', + refresh_token: 'forest-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'mcp:read', + }); + global.fetch = mockServer.fetch; + + const provider = createProvider(); + + const result = await provider.exchangeAuthorizationCode( + mockClient, + 'auth-code-123', + 'code-verifier-456', + 'https://example.com/callback', + ); + + expect(mockServer.fetch).toHaveBeenCalledWith( + 'https://api.forestadmin.com/oauth/token', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'forest-secret-key': 'test-env-secret', + }), + body: JSON.stringify({ + grant_type: 'authorization_code', + code: 'auth-code-123', + redirect_uri: 'https://example.com/callback', + client_id: 'test-client-id', + code_verifier: 'code-verifier-456', + }), + }), + ); + + expect(result.access_token).toBe('mocked-jwt-token'); + expect(result.refresh_token).toBe('mocked-jwt-token'); + expect(result.token_type).toBe('Bearer'); + // expires_in is calculated as exp - now, so it should be approximately 3600 + expect(result.expires_in).toBeGreaterThan(3590); + expect(result.expires_in).toBeLessThanOrEqual(3600); + expect(result.scope).toBe('mcp:read'); + + expect(mockJwtDecode).toHaveBeenCalledWith('forest-access-token'); + expect(mockJwtDecode).toHaveBeenCalledWith('forest-refresh-token'); + expect(mockGetUserInfo).toHaveBeenCalledWith(456, 'forest-access-token'); + + // First call: access token - expiresIn is calculated as exp - now, so it's approximately 3600 + expect(mockJwtSign).toHaveBeenCalledWith( + expect.objectContaining({ + id: 123, + email: 'user@example.com', + serverToken: 'forest-access-token', + }), + 'test-auth-secret', + { expiresIn: expect.any(Number) }, + ); + + // Second call: refresh token - expiresIn is calculated as exp - now, so it's approximately 604800 + expect(mockJwtSign).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'refresh', + clientId: 'test-client-id', + userId: 123, + renderingId: 456, + serverRefreshToken: 'forest-refresh-token', + }), + 'test-auth-secret', + { expiresIn: expect.any(Number) }, + ); + }); + + it('should throw error when token exchange fails', async () => { + mockServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .post('/oauth/token', { error: 'invalid_grant' }, 400); + global.fetch = mockServer.fetch; + + const provider = createProvider(); + + await expect( + provider.exchangeAuthorizationCode( + mockClient, + 'invalid-code', + 'code-verifier', + 'https://example.com/callback', + ), + ).rejects.toThrow('Failed to exchange authorization code'); + }); + }); + + describe('exchangeRefreshToken', () => { + let mockClient: OAuthClientInformationFull; + let mockGetUserInfo: jest.Mock; + + beforeEach(() => { + mockClient = { + client_id: 'test-client-id', + redirect_uris: ['https://example.com/callback'], + scope: 'mcp:read mcp:write', + } as OAuthClientInformationFull; + + mockGetUserInfo = jest.fn().mockResolvedValue({ + id: 123, + email: 'user@example.com', + firstName: 'Test', + lastName: 'User', + team: 'Operations', + role: 'Admin', + tags: {}, + renderingId: 456, + permissionLevel: 'admin', + }); + + mockCreateForestAdminClient.mockReturnValue({ + authService: { + getUserInfo: mockGetUserInfo, + }, + } as unknown as ReturnType); + + mockJwtSign.mockReturnValue('new-mocked-jwt-token'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should exchange refresh token for new tokens', async () => { + // Mock jwt.verify to return decoded refresh token + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + type: 'refresh', + clientId: 'test-client-id', + userId: 123, + renderingId: 456, + serverRefreshToken: 'forest-refresh-token', + }); + + // Mock jwt.decode - called twice: first for access token, then for refresh token + const now = Math.floor(Date.now() / 1000); + mockJwtDecode + .mockReturnValueOnce({ + meta: { renderingId: 456 }, + exp: now + 3600, // expires in 1 hour + iat: now, + scope: 'mcp:read', + }) + .mockReturnValueOnce({ + exp: now + 604800, // expires in 7 days + iat: now, + }); + + mockServer.post('/oauth/token', { + access_token: 'new-forest-access-token', + refresh_token: 'new-forest-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'mcp:read', + }); + global.fetch = mockServer.fetch; + + const provider = createProvider(); + + const result = await provider.exchangeRefreshToken(mockClient, 'valid-refresh-token'); + + expect(result.access_token).toBe('new-mocked-jwt-token'); + expect(result.refresh_token).toBe('new-mocked-jwt-token'); + expect(result.token_type).toBe('Bearer'); + // expires_in is calculated as exp - now, so it should be approximately 3600 + expect(result.expires_in).toBeGreaterThan(3590); + expect(result.expires_in).toBeLessThanOrEqual(3600); + + expect(mockServer.fetch).toHaveBeenCalledWith( + 'https://api.forestadmin.com/oauth/token', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: 'forest-refresh-token', + client_id: 'test-client-id', + }), + }), + ); + }); + + it('should throw error for invalid refresh token', async () => { + (jsonwebtoken.verify as jest.Mock).mockImplementation(() => { + throw new Error('invalid signature'); + }); + + const provider = createProvider(); + + await expect( + provider.exchangeRefreshToken(mockClient, 'invalid-refresh-token'), + ).rejects.toThrow('Invalid or expired refresh token'); + }); + + it('should throw error when token type is not refresh', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + type: 'access', + clientId: 'test-client-id', + }); + + const provider = createProvider(); + + await expect(provider.exchangeRefreshToken(mockClient, 'access-token')).rejects.toThrow( + 'Invalid token type', + ); + }); + + it('should throw error when client_id does not match', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + type: 'refresh', + clientId: 'different-client-id', + userId: 123, + renderingId: 456, + serverRefreshToken: 'forest-refresh-token', + }); + + const provider = createProvider(); + + await expect( + provider.exchangeRefreshToken(mockClient, 'refresh-token-for-different-client'), + ).rejects.toThrow('Token was not issued to this client'); + }); + + it('should throw error when Forest Admin refresh fails', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + type: 'refresh', + clientId: 'test-client-id', + userId: 123, + renderingId: 456, + serverRefreshToken: 'expired-forest-refresh-token', + }); + + mockServer.post('/oauth/token', { error: 'invalid_grant' }, 400); + global.fetch = mockServer.fetch; + + const provider = createProvider(); + + await expect( + provider.exchangeRefreshToken(mockClient, 'valid-refresh-token'), + ).rejects.toThrow('Failed to refresh token'); + }); + }); + + describe('verifyAccessToken', () => { + it('should verify and decode a valid access token', async () => { + const mockDecoded = { + id: 123, + email: 'user@example.com', + renderingId: 456, + serverToken: 'forest-server-token', + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + }; + + (jsonwebtoken.verify as jest.Mock).mockReturnValue(mockDecoded); + + const provider = createProvider(); + + const result = await provider.verifyAccessToken('valid-access-token'); + + expect(result.token).toBe('valid-access-token'); + expect(result.clientId).toBe('123'); + expect(result.expiresAt).toBe(mockDecoded.exp); + expect(result.scopes).toEqual(['mcp:read', 'mcp:write', 'mcp:action']); + expect(result.extra).toEqual({ + userId: 123, + email: 'user@example.com', + renderingId: 456, + environmentApiEndpoint: undefined, + forestServerToken: 'forest-server-token', + }); + }); + + it('should throw error for expired access token', async () => { + (jsonwebtoken.verify as jest.Mock).mockImplementation(() => { + throw new jsonwebtoken.TokenExpiredError('jwt expired', new Date()); + }); + + const provider = createProvider(); + + await expect(provider.verifyAccessToken('expired-token')).rejects.toThrow( + 'Access token has expired', + ); + }); + + it('should throw error for invalid access token', async () => { + (jsonwebtoken.verify as jest.Mock).mockImplementation(() => { + throw new jsonwebtoken.JsonWebTokenError('invalid signature'); + }); + + const provider = createProvider(); + + await expect(provider.verifyAccessToken('invalid-token')).rejects.toThrow( + 'Invalid access token', + ); + }); + + it('should throw error when using refresh token as access token', async () => { + (jsonwebtoken.verify as jest.Mock).mockReturnValue({ + type: 'refresh', + clientId: 'test-client-id', + }); + + const provider = createProvider(); + + await expect(provider.verifyAccessToken('refresh-token')).rejects.toThrow( + 'Cannot use refresh token as access token', + ); + }); + + it('should include environmentApiEndpoint after initialize is called', async () => { + mockServer.get('/liana/environment', { + data: { + id: '12345', + attributes: { api_endpoint: 'https://api.example.com' }, + }, + }); + + global.fetch = mockServer.fetch; + + const mockDecoded = { + id: 123, + email: 'user@example.com', + renderingId: 456, + serverToken: 'forest-server-token', + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + }; + + (jsonwebtoken.verify as jest.Mock).mockReturnValue(mockDecoded); + + const provider = createProvider(); + + // Call initialize to fetch environment data + await provider.initialize(); + + const result = await provider.verifyAccessToken('valid-access-token'); + + expect(result.extra).toEqual({ + userId: 123, + email: 'user@example.com', + renderingId: 456, + environmentApiEndpoint: 'https://api.example.com', + forestServerToken: 'forest-server-token', + }); + }); + }); +}); diff --git a/packages/mcp-server/src/forest-oauth-provider.ts b/packages/mcp-server/src/forest-oauth-provider.ts new file mode 100644 index 0000000000..a27a746ccf --- /dev/null +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -0,0 +1,428 @@ +import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'; +import type { + AuthorizationParams, + OAuthServerProvider, +} from '@modelcontextprotocol/sdk/server/auth/provider.js'; +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import type { + OAuthClientInformationFull, + OAuthTokenRevocationRequest, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { Response } from 'express'; + +import createForestAdminClient, { ForestAdminClient } from '@forestadmin/forestadmin-client'; +import { + CustomOAuthError, + InvalidClientError, + InvalidRequestError, + InvalidTokenError, + UnsupportedTokenTypeError, +} from '@modelcontextprotocol/sdk/server/auth/errors.js'; +import jsonwebtoken from 'jsonwebtoken'; +import { Logger } from './server'; + +export interface ForestOAuthProviderOptions { + forestServerUrl: string; + envSecret: string; + authSecret: string; + logger: Logger; +} + +/** + * OAuth Server Provider that integrates with Forest Admin authentication + */ +export default class ForestOAuthProvider implements OAuthServerProvider { + private forestServerUrl: string; + private envSecret: string; + private authSecret: string; + private forestClient: ForestAdminClient; + private environmentId?: number; + private environmentApiEndpoint?: string; + private logger: Logger; + + constructor({ forestServerUrl, envSecret, authSecret, logger }: ForestOAuthProviderOptions) { + this.forestServerUrl = forestServerUrl; + this.envSecret = envSecret; + this.authSecret = authSecret; + this.logger = logger; + this.forestClient = createForestAdminClient({ + forestServerUrl: this.forestServerUrl, + envSecret: this.envSecret, + }); + } + + async initialize(): Promise { + try { + await this.fetchEnvironmentId(); + } catch (error) { + // Log warning but don't throw - the MCP server can still partially function + // The authorize method will return an appropriate error when environmentId is missing + this.logger('Warn', `Failed to fetch environmentId from Forest Admin API: ${error}`); + } + } + + private async fetchEnvironmentId(): Promise { + if (!this.envSecret) { + throw new Error('FOREST_ENV_SECRET is required to fetch environment ID'); + } + + // Call Forest Admin API to get environment information + const response = await fetch(`${this.forestServerUrl}/liana/environment`, { + method: 'GET', + headers: { + 'forest-secret-key': this.envSecret, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to fetch environment from Forest Admin API: ${response.status} ${response.statusText}. ${errorText}`, + ); + } + + const data = (await response.json()) as unknown as { + data: { id: string; attributes: { api_endpoint: string } }; + }; + + this.environmentId = parseInt(data.data.id, 10); + this.environmentApiEndpoint = data.data.attributes.api_endpoint; + } + + /** + * Get the base URL for the MCP server from the environment's api_endpoint. + * Returns undefined if the environment info hasn't been fetched yet. + */ + getBaseUrl(): URL | undefined { + if (!this.environmentApiEndpoint) { + return undefined; + } + + return new URL(this.environmentApiEndpoint); + } + + get clientsStore(): OAuthRegisteredClientsStore { + return { + getClient: async (clientId: string) => { + // Call Forest Admin API to get client information + const response = await fetch(`${this.forestServerUrl}/oauth/register/${clientId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + // Log and return undefined for other errors (don't expose internal errors) + if (!response.ok) { + console.error( + `[ForestOAuthProvider] Failed to fetch client ${clientId}: ${response.status} ${response.statusText}`, + ); + + return undefined; + } + + // Return registered client if exists + return response.json(); + }, + }; + } + + /** + * Get the authorization URL for redirecting the user. + * This is the framework-agnostic version that returns a URL string. + * + * @returns The URL to redirect to, or an error redirect URL prefixed with "error:" + */ + async getAuthorizationUrl( + client: OAuthClientInformationFull, + params: AuthorizationParams, + ): Promise { + try { + // Ensure environmentId is available + if (!this.environmentId) { + throw new Error( + 'Environment ID not available. Make sure initialize() was called and the Forest Admin API is reachable.', + ); + } + + // Build redirect URL to Forest Admin agent for actual authentication + const agentAuthUrl = new URL( + '/oauth/authorize', + process.env.FOREST_FRONTEND_HOSTNAME || 'https://app.forestadmin.com', + ); + + agentAuthUrl.searchParams.set('redirect_uri', params.redirectUri); + agentAuthUrl.searchParams.set('code_challenge', params.codeChallenge); + agentAuthUrl.searchParams.set('code_challenge_method', 'S256'); + agentAuthUrl.searchParams.set('response_type', 'code'); + agentAuthUrl.searchParams.set('client_id', client.client_id); + agentAuthUrl.searchParams.set('state', params.state); + agentAuthUrl.searchParams.set('scope', params.scopes.join('+')); + + if (params.resource?.href) { + agentAuthUrl.searchParams.set('resource', params.resource.href); + } + + agentAuthUrl.searchParams.set('environmentId', this.environmentId.toString()); + + return agentAuthUrl.toString(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger('Error', `[ForestOAuthProvider] Authorization error:: ${errorMessage}`); + + // Return error redirect URL prefixed with "error:" to indicate it's an error redirect + const errorUrl = `${params.redirectUri}?error=server_error&error_description=${encodeURIComponent( + 'Authorization failed. Please try again or contact support.', + )}`; + + return `error:${errorUrl}`; + } + } + + /** + * Handle authorization request with Express response object. + * This is kept for backwards compatibility with the Express server. + */ + async authorize( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response, + ): Promise { + const url = await this.getAuthorizationUrl(client, params); + + if (url.startsWith('error:')) { + res.redirect(url.slice(6)); + } else { + res.redirect(url); + } + } + + async challengeForAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + ): Promise { + // This is never called but required by TS ! + return authorizationCode; + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + codeVerifier?: string, + redirectUri?: string, + ): Promise { + try { + return await this.generateAccessToken(client, { + grant_type: 'authorization_code', + code: authorizationCode, + redirect_uri: redirectUri, + client_id: client.client_id, + code_verifier: codeVerifier, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new InvalidRequestError(`Failed to exchange authorization code: ${message}`); + } + } + + async exchangeRefreshToken( + client: OAuthClientInformationFull, + refreshToken: string, + scopes?: string[], + ): Promise { + // Verify and decode the refresh token + let decoded: { + type: string; + clientId: string; + userId: number; + renderingId: number; + serverRefreshToken: string; + }; + + try { + decoded = jsonwebtoken.verify(refreshToken, this.authSecret) as typeof decoded; + } catch (error) { + throw new InvalidTokenError('Invalid or expired refresh token'); + } + + // Validate token type + if (decoded.type !== 'refresh') { + throw new UnsupportedTokenTypeError('Invalid token type'); + } + + // Validate client_id matches + if (decoded.clientId !== client.client_id) { + throw new InvalidClientError('Token was not issued to this client'); + } + + // Exchange the Forest refresh token for new tokens + try { + return await this.generateAccessToken(client, { + grant_type: 'refresh_token', + refresh_token: decoded.serverRefreshToken, + client_id: client.client_id, + scopes, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new InvalidRequestError(`Failed to refresh token: ${message}`); + } + } + + private async generateAccessToken( + client: OAuthClientInformationFull, + tokenPayload: Record, + ): Promise { + const response = await fetch(`${this.forestServerUrl}/oauth/token`, { + method: 'POST', + headers: { + 'forest-secret-key': this.envSecret, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(tokenPayload), + }); + + if (!response.ok) { + const errorBody = await response.json(); + throw new CustomOAuthError( + errorBody.error || 'server_error', + errorBody.error_description || 'Failed to exchange authorization code', + ); + } + + const { access_token: forestServerAccessToken, refresh_token: forestServerRefreshToken } = + (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + scope: string; + }; + + // Get updated user info + const decodedAccessToken = jsonwebtoken.decode(forestServerAccessToken) as { + meta: { renderingId: number }; + exp: number; + iat: number; + scope: string; + } | null; + + if (!decodedAccessToken) { + throw new Error('Failed to decode access token from Forest Admin server'); + } + + const { + meta: { renderingId }, + exp: expirationDate, + scope, + } = decodedAccessToken; + + const decodedRefreshToken = jsonwebtoken.decode(forestServerRefreshToken) as { + exp: number; + iat: number; + } | null; + + if (!decodedRefreshToken) { + throw new Error('Failed to decode refresh token from Forest Admin server'); + } + + const { exp: refreshTokenExpirationDate } = decodedRefreshToken; + const user = await this.forestClient.authService.getUserInfo( + renderingId, + forestServerAccessToken, + ); + + // Create new access token + const expiresIn = expirationDate - Math.floor(Date.now() / 1000); + const tokenScopes = scope ? scope.split(' ') : ['mcp:read', 'mcp:write', 'mcp:action']; + const accessToken = jsonwebtoken.sign( + { ...user, serverToken: forestServerAccessToken, scopes: tokenScopes }, + this.authSecret, + { expiresIn }, + ); + + // Create new refresh token (token rotation for security) + const refreshToken = jsonwebtoken.sign( + { + type: 'refresh', + clientId: client.client_id, + userId: user.id, + renderingId, + serverRefreshToken: forestServerRefreshToken, + }, + this.authSecret, + { expiresIn: refreshTokenExpirationDate - Math.floor(Date.now() / 1000) }, + ); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: expiresIn > 0 ? expiresIn : 3600, + refresh_token: refreshToken, + scope: scope || client.scope, + }; + } + + async verifyAccessToken(token: string): Promise { + try { + const decoded = jsonwebtoken.verify(token, this.authSecret) as { + id: number; + email: string; + renderingId: number; + serverToken: string; + scopes?: string[]; + exp: number; + iat: number; + }; + + // Ensure this is an access token (not a refresh token) + if ('type' in decoded && (decoded as { type?: string }).type === 'refresh') { + throw new UnsupportedTokenTypeError('Cannot use refresh token as access token'); + } + + // Use scopes from token if available, otherwise fall back to defaults + const scopes = decoded.scopes || ['mcp:read', 'mcp:write', 'mcp:action']; + + return { + token, + clientId: decoded.id.toString(), + expiresAt: decoded.exp, + scopes, + extra: { + userId: decoded.id, + email: decoded.email, + renderingId: decoded.renderingId, + environmentApiEndpoint: this.environmentApiEndpoint, + forestServerToken: decoded.serverToken, + }, + }; + } catch (error) { + this.logger('Error', `Error verifying token: ${error}`); + + if (error instanceof jsonwebtoken.TokenExpiredError) { + throw new InvalidTokenError('Access token has expired'); + } + + if (error instanceof jsonwebtoken.JsonWebTokenError) { + throw new InvalidTokenError('Invalid access token'); + } + + throw error; + } + } + + async revokeToken( + _client: OAuthClientInformationFull, + _request: OAuthTokenRevocationRequest, + ): Promise { + // Token revocation is not currently implemented. + // Per RFC 7009, the revocation endpoint should return success even if the token + // is already invalid or unknown, so we silently succeed here. + // TODO: Implement actual token revocation with Forest Admin server when supported. + } + + // Skip PKCE validation to match original implementation + skipLocalPkceValidation = true; +} diff --git a/packages/mcp-server/src/handlers.ts b/packages/mcp-server/src/handlers.ts new file mode 100644 index 0000000000..0914a53b36 --- /dev/null +++ b/packages/mcp-server/src/handlers.ts @@ -0,0 +1,377 @@ +/** + * Framework-agnostic MCP handlers. + * + * This module provides handlers that can be used with any HTTP framework (Express, Koa, etc.) + * by returning data/results instead of directly manipulating response objects. + */ +import './polyfills'; + +import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import type { + OAuthClientInformationFull, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { IncomingMessage, ServerResponse } from 'http'; + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + +import ForestOAuthProvider from './forest-oauth-provider'; +import declareListTool from './tools/list'; +import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; +import { NAME, VERSION } from './version'; + +export type LogLevel = 'Debug' | 'Info' | 'Warn' | 'Error'; +export type Logger = (level: LogLevel, message: string) => void; + +const defaultLogger: Logger = (level, message) => { + if (level === 'Error') console.error(`[MCP Server] ${message}`); + else if (level === 'Warn') console.warn(`[MCP Server] ${message}`); + else console.info(`[MCP Server] ${message}`); +}; + +/** + * OAuth metadata as defined by RFC 8414 + */ +export interface OAuthMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint: string; + token_endpoint_auth_methods_supported: string[]; + response_types_supported: string[]; + code_challenge_methods_supported: string[]; + scopes_supported: string[]; +} + +/** + * OAuth protected resource metadata as defined by RFC 9728 + */ +export interface OAuthProtectedResourceMetadata { + resource: string; + authorization_servers: string[]; + scopes_supported: string[]; +} + +/** + * Parameters for the authorize handler + */ +export interface AuthorizeParams { + clientId: string; + redirectUri?: string; + codeChallenge: string; + state?: string; + scope?: string; +} + +/** + * Result of the authorize handler + */ +export type AuthorizeResult = + | { type: 'redirect'; url: string } + | { type: 'error'; status: number; error: string; errorDescription: string }; + +/** + * Parameters for the token handler + */ +export interface TokenParams { + grantType: string; + clientId: string; + code?: string; + codeVerifier?: string; + redirectUri?: string; + refreshToken?: string; +} + +/** + * Result of the token handler + */ +export type TokenResult = + | { type: 'success'; tokens: OAuthTokens } + | { type: 'error'; status: number; error: string; errorDescription?: string }; + +/** + * Result of MCP request handling + */ +export type McpResult = + | { type: 'handled' } + | { type: 'error'; status: number; error: object }; + +/** + * Options for McpHandlers + */ +export interface McpHandlersOptions { + forestServerUrl: string; + envSecret: string; + authSecret: string; + logger?: Logger; +} + +/** + * Framework-agnostic MCP handlers. + * + * Use this class to integrate MCP into any HTTP framework by calling the handler methods + * and using the returned results to build framework-specific responses. + * + * @example + * ```typescript + * // In Koa + * const handlers = new McpHandlers(options); + * await handlers.initialize(); + * + * router.get('/.well-known/oauth-authorization-server', ctx => { + * ctx.body = handlers.getOAuthMetadata(baseUrl); + * }); + * + * router.post('/oauth/authorize', async ctx => { + * const result = await handlers.handleAuthorize(params); + * if (result.type === 'redirect') ctx.redirect(result.url); + * else { ctx.status = result.status; ctx.body = { error: result.error }; } + * }); + * ``` + */ +export default class McpHandlers { + public mcpServer: McpServer; + public mcpTransport?: StreamableHTTPServerTransport; + + private oauthProvider: ForestOAuthProvider; + private forestServerUrl: string; + private logger: Logger; + private initialized = false; + + constructor(options: McpHandlersOptions) { + this.forestServerUrl = options.forestServerUrl; + this.logger = options.logger || defaultLogger; + + this.oauthProvider = new ForestOAuthProvider({ + forestServerUrl: options.forestServerUrl, + envSecret: options.envSecret, + authSecret: options.authSecret, + logger: this.logger, + }); + + this.mcpServer = new McpServer({ name: NAME, version: VERSION }); + } + + /** + * Initialize the handlers. Must be called before using any handler methods. + */ + async initialize(): Promise { + if (this.initialized) return; + + // Initialize OAuth provider + await this.oauthProvider.initialize(); + + // Setup MCP transport + this.mcpTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await this.mcpServer.connect(this.mcpTransport); + + // Setup tools + await this.setupTools(); + + this.initialized = true; + } + + private async setupTools(): Promise { + try { + const schema = await fetchForestSchema(this.forestServerUrl); + const collectionNames = getCollectionNames(schema); + declareListTool(this.mcpServer, this.forestServerUrl, collectionNames); + } catch (error) { + this.logger('Warn', `Failed to fetch forest schema: ${error}`); + } + } + + /** + * Get the base URL from the environment's api_endpoint. + */ + getBaseUrl(): URL | undefined { + return this.oauthProvider.getBaseUrl(); + } + + /** + * Get OAuth Authorization Server metadata (RFC 8414) + */ + getOAuthMetadata(baseUrl: URL): OAuthMetadata { + return { + issuer: baseUrl.href, + authorization_endpoint: `${baseUrl.href}oauth/authorize`, + token_endpoint: `${baseUrl.href}oauth/token`, + registration_endpoint: `${this.forestServerUrl}/oauth/register`, + token_endpoint_auth_methods_supported: ['none'], + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + scopes_supported: ['mcp:read', 'mcp:write', 'mcp:action', 'mcp:admin'], + }; + } + + /** + * Get OAuth Protected Resource metadata (RFC 9728) + */ + getProtectedResourceMetadata(baseUrl: URL): OAuthProtectedResourceMetadata { + return { + resource: new URL('mcp', baseUrl).href, + authorization_servers: [baseUrl.href], + scopes_supported: ['mcp:read', 'mcp:write', 'mcp:action', 'mcp:admin'], + }; + } + + /** + * Handle OAuth authorization request. + * Returns a redirect URL or an error. + */ + async handleAuthorize(params: AuthorizeParams): Promise { + // Validate client + const client = await this.oauthProvider.clientsStore.getClient(params.clientId); + + if (!client) { + return { + type: 'error', + status: 400, + error: 'invalid_client', + errorDescription: 'Invalid client_id', + }; + } + + // Determine redirect URI + let resolvedRedirectUri = params.redirectUri; + + if (!resolvedRedirectUri && client.redirect_uris.length === 1) { + [resolvedRedirectUri] = client.redirect_uris; + } + + if (!resolvedRedirectUri || !client.redirect_uris.includes(resolvedRedirectUri)) { + return { + type: 'error', + status: 400, + error: 'invalid_request', + errorDescription: 'Invalid redirect_uri', + }; + } + + // Get authorization URL from provider + const redirectUrl = await this.oauthProvider.getAuthorizationUrl( + client as OAuthClientInformationFull, + { + state: params.state || '', + scopes: params.scope ? params.scope.split(' ') : [], + redirectUri: resolvedRedirectUri, + codeChallenge: params.codeChallenge, + }, + ); + + if (redirectUrl.startsWith('error:')) { + // Provider returned an error redirect + return { type: 'redirect', url: redirectUrl.slice(6) }; + } + + return { type: 'redirect', url: redirectUrl }; + } + + /** + * Handle OAuth token request. + * Returns tokens or an error. + */ + async handleToken(params: TokenParams): Promise { + // Validate client + const client = await this.oauthProvider.clientsStore.getClient(params.clientId); + + if (!client) { + return { + type: 'error', + status: 400, + error: 'invalid_client', + errorDescription: 'Invalid client_id', + }; + } + + try { + let tokens: OAuthTokens; + + if (params.grantType === 'authorization_code') { + tokens = await this.oauthProvider.exchangeAuthorizationCode( + client as OAuthClientInformationFull, + params.code || '', + params.codeVerifier, + params.redirectUri, + ); + } else if (params.grantType === 'refresh_token') { + tokens = await this.oauthProvider.exchangeRefreshToken( + client as OAuthClientInformationFull, + params.refreshToken || '', + ); + } else { + return { + type: 'error', + status: 400, + error: 'unsupported_grant_type', + errorDescription: `Grant type '${params.grantType}' is not supported`, + }; + } + + return { type: 'success', tokens }; + } catch (error: unknown) { + const oauthError = error as { errorCode?: string; message?: string }; + + return { + type: 'error', + status: 400, + error: oauthError.errorCode || 'invalid_request', + errorDescription: oauthError.message, + }; + } + } + + /** + * Verify an access token. + * Returns auth info or throws an error. + */ + async verifyAccessToken(token: string): Promise { + return this.oauthProvider.verifyAccessToken(token); + } + + /** + * Handle MCP protocol request. + * This method writes directly to the response for streaming support. + * + * @param req - Node.js IncomingMessage + * @param res - Node.js ServerResponse + * @param body - Parsed request body + */ + async handleMcpRequest( + req: IncomingMessage, + res: ServerResponse, + body: unknown, + ): Promise { + if (!this.mcpTransport) { + return { + type: 'error', + status: 500, + error: { + jsonrpc: '2.0', + error: { code: -32603, message: 'MCP transport not initialized' }, + id: null, + }, + }; + } + + try { + await this.mcpTransport.handleRequest(req, res, body); + + return { type: 'handled' }; + } catch (error: unknown) { + const errorObj = error as { message?: string }; + this.logger('Error', `MCP Error: ${error}`); + + return { + type: 'error', + status: errorObj.message?.includes('token') ? 401 : 500, + error: { + jsonrpc: '2.0', + error: { code: -32603, message: errorObj.message || 'Internal error' }, + id: null, + }, + }; + } + } +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 0000000000..0d5fd14988 --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,25 @@ +// Library exports only - no side effects + +// Express-based server (for CLI and standalone usage) +export { default as ForestMCPServer } from './server'; +export type { ForestMCPServerOptions, HttpCallback } from './server'; +export { createMcpServer } from './factory'; +export type { McpFactoryContext, McpFactoryOptions } from './factory'; + +// Framework-agnostic handlers (for integration with any HTTP framework) +export { default as McpHandlers } from './handlers'; +export type { + McpHandlersOptions, + AuthorizeParams, + AuthorizeResult, + TokenParams, + TokenResult, + McpResult, + OAuthMetadata, + OAuthProtectedResourceMetadata, + Logger, + LogLevel, +} from './handlers'; + +// Shared utilities +export { MCP_PATHS, isMcpRoute } from './mcp-paths'; diff --git a/packages/mcp-server/src/mcp-paths.ts b/packages/mcp-server/src/mcp-paths.ts new file mode 100644 index 0000000000..92dad9b35c --- /dev/null +++ b/packages/mcp-server/src/mcp-paths.ts @@ -0,0 +1,7 @@ +/** MCP-specific paths that should be handled by the MCP server */ +export const MCP_PATHS = ['/.well-known/', '/oauth/', '/mcp']; + +/** Check if a URL is an MCP route */ +export function isMcpRoute(url: string): boolean { + return MCP_PATHS.some(p => url === p || url.startsWith(p)); +} diff --git a/packages/mcp-server/src/polyfills.ts b/packages/mcp-server/src/polyfills.ts new file mode 100644 index 0000000000..009c7b4d66 --- /dev/null +++ b/packages/mcp-server/src/polyfills.ts @@ -0,0 +1,30 @@ +/** + * Polyfill for URL.canParse + * + * The MCP SDK's OAuth handler uses URL.canParse in Zod schema validation. + * Some environments (like certain Koa/Express proxy setups) may have a different + * URL implementation that doesn't include the canParse static method + * (added in Node.js 19.9.0 / 20.0.0). + * + * This polyfill must be imported BEFORE any MCP SDK imports. + */ + +// Check if URL.canParse exists on the global URL +if (typeof URL.canParse !== 'function') { + // Provide a fallback polyfill implementation + (URL as unknown as { canParse: (url: string, base?: string) => boolean }).canParse = ( + url: string, + base?: string, + ): boolean => { + try { + // eslint-disable-next-line no-new + new URL(url, base); + + return true; + } catch { + return false; + } + }; +} + +export {}; diff --git a/packages/mcp-server/src/schemas/filter.test.ts b/packages/mcp-server/src/schemas/filter.test.ts new file mode 100644 index 0000000000..abac6de655 --- /dev/null +++ b/packages/mcp-server/src/schemas/filter.test.ts @@ -0,0 +1,268 @@ +import filterSchema from './filter'; + +describe('filterSchema', () => { + describe('leaf conditions', () => { + it('should accept valid leaf condition with Equal operator', () => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'name', operator: 'Equal', value: 'John' }], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it('should accept leaf condition without value for operators that do not require it', () => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'deletedAt', operator: 'Present' }], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it.each([ + 'Equal', + 'NotEqual', + 'LessThan', + 'GreaterThan', + 'LessThanOrEqual', + 'GreaterThanOrEqual', + 'Match', + 'NotContains', + 'NotIContains', + 'LongerThan', + 'ShorterThan', + 'IncludesAll', + 'IncludesNone', + 'Today', + 'Yesterday', + 'PreviousMonth', + 'PreviousQuarter', + 'PreviousWeek', + 'PreviousYear', + 'PreviousMonthToDate', + 'PreviousQuarterToDate', + 'PreviousWeekToDate', + 'PreviousXDaysToDate', + 'PreviousXDays', + 'PreviousYearToDate', + 'Present', + 'Blank', + 'Missing', + 'In', + 'NotIn', + 'StartsWith', + 'EndsWith', + 'Contains', + 'IStartsWith', + 'IEndsWith', + 'IContains', + 'Like', + 'ILike', + 'Before', + 'After', + 'AfterXHoursAgo', + 'BeforeXHoursAgo', + 'Future', + 'Past', + ])('should accept operator "%s"', operator => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'testField', operator, value: 'test' }], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it('should reject invalid operator', () => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'name', operator: 'InvalidOperator', value: 'John' }], + }; + + expect(() => filterSchema.parse(condition)).toThrow(); + }); + + it('should require field property', () => { + const condition = { + aggregator: 'And', + conditions: [{ operator: 'Equal', value: 'John' }], + }; + + expect(() => filterSchema.parse(condition)).toThrow(); + }); + + it('should require operator property', () => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'name', value: 'John' }], + }; + + expect(() => filterSchema.parse(condition)).toThrow(); + }); + }); + + describe('branch conditions', () => { + it('should accept And aggregator', () => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'name', operator: 'Equal', value: 'John' }], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it('should accept Or aggregator', () => { + const condition = { + aggregator: 'Or', + conditions: [{ field: 'name', operator: 'Equal', value: 'John' }], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it('should reject invalid aggregator', () => { + const condition = { + aggregator: 'Xor', + conditions: [{ field: 'name', operator: 'Equal', value: 'John' }], + }; + + expect(() => filterSchema.parse(condition)).toThrow(); + }); + + it('should require conditions array', () => { + const condition = { + aggregator: 'And', + }; + + expect(() => filterSchema.parse(condition)).toThrow(); + }); + + it('should accept empty conditions array', () => { + const condition = { + aggregator: 'And', + conditions: [], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + }); + + describe('nested conditions', () => { + it('should accept nested branch conditions', () => { + const condition = { + aggregator: 'And', + conditions: [ + { field: 'name', operator: 'Equal', value: 'John' }, + { + aggregator: 'Or', + conditions: [ + { field: 'age', operator: 'GreaterThan', value: 18 }, + { field: 'status', operator: 'Equal', value: 'adult' }, + ], + }, + ], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it('should accept deeply nested conditions', () => { + const condition = { + aggregator: 'And', + conditions: [ + { + aggregator: 'Or', + conditions: [ + { + aggregator: 'And', + conditions: [ + { field: 'a', operator: 'Equal', value: 1 }, + { field: 'b', operator: 'Equal', value: 2 }, + ], + }, + { field: 'c', operator: 'Equal', value: 3 }, + ], + }, + ], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it('should accept mixed leaf and branch conditions', () => { + const condition = { + aggregator: 'And', + conditions: [ + { field: 'active', operator: 'Equal', value: true }, + { + aggregator: 'Or', + conditions: [ + { field: 'role', operator: 'Equal', value: 'admin' }, + { field: 'role', operator: 'Equal', value: 'superuser' }, + ], + }, + { field: 'verified', operator: 'Equal', value: true }, + ], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + }); + + describe('value types', () => { + it('should accept string values', () => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'name', operator: 'Equal', value: 'John' }], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it('should accept number values', () => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'age', operator: 'GreaterThan', value: 25 }], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it('should accept boolean values', () => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'active', operator: 'Equal', value: true }], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it('should accept null values', () => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'deletedAt', operator: 'Equal', value: null }], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it('should accept array values for In operator', () => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'status', operator: 'In', value: ['active', 'pending', 'approved'] }], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + + it('should accept date string values', () => { + const condition = { + aggregator: 'And', + conditions: [{ field: 'createdAt', operator: 'After', value: '2024-01-01T00:00:00Z' }], + }; + + expect(() => filterSchema.parse(condition)).not.toThrow(); + }); + }); +}); diff --git a/packages/mcp-server/src/schemas/filter.ts b/packages/mcp-server/src/schemas/filter.ts new file mode 100644 index 0000000000..49a120df78 --- /dev/null +++ b/packages/mcp-server/src/schemas/filter.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +const plainConditionTreeLeafSchema = z.object({ + field: z.string(), + operator: z.enum([ + 'Equal', + 'NotEqual', + 'LessThan', + 'GreaterThan', + 'LessThanOrEqual', + 'GreaterThanOrEqual', + 'Match', + 'NotContains', + 'NotIContains', + 'LongerThan', + 'ShorterThan', + 'IncludesAll', + 'IncludesNone', + 'Today', + 'Yesterday', + 'PreviousMonth', + 'PreviousQuarter', + 'PreviousWeek', + 'PreviousYear', + 'PreviousMonthToDate', + 'PreviousQuarterToDate', + 'PreviousWeekToDate', + 'PreviousXDaysToDate', + 'PreviousXDays', + 'PreviousYearToDate', + 'Present', + 'Blank', + 'Missing', + 'In', + 'NotIn', + 'StartsWith', + 'EndsWith', + 'Contains', + 'IStartsWith', + 'IEndsWith', + 'IContains', + 'Like', + 'ILike', + 'Before', + 'After', + 'AfterXHoursAgo', + 'BeforeXHoursAgo', + 'Future', + 'Past', + ]), + value: z.unknown().optional(), +}); + +const plainConditionTreeBranchSchema: z.ZodType = z.object({ + aggregator: z.enum(['And', 'Or']), + conditions: z.array( + z.lazy(() => plainConditionTreeBranchSchema).or(plainConditionTreeLeafSchema), + ), +}); + +export default plainConditionTreeBranchSchema; diff --git a/packages/mcp-server/src/server.test.ts b/packages/mcp-server/src/server.test.ts new file mode 100644 index 0000000000..8ab933c396 --- /dev/null +++ b/packages/mcp-server/src/server.test.ts @@ -0,0 +1,1136 @@ +import type * as http from 'http'; + +import jsonwebtoken from 'jsonwebtoken'; +import request from 'supertest'; + +import ForestMCPServer from './server'; +import MockServer from './test-utils/mock-server'; + +function shutDownHttpServer(server: http.Server | undefined): Promise { + if (!server) return Promise.resolve(); + + return new Promise(resolve => { + server.close(() => { + resolve(); + }); + }); +} + +/** + * Integration tests for ForestMCPServer instance + * Tests the actual server class and its behavior + */ +describe('ForestMCPServer Instance', () => { + let server: ForestMCPServer; + let originalEnv: NodeJS.ProcessEnv; + let modifiedEnv: NodeJS.ProcessEnv; + let mockServer: MockServer; + const originalFetch = global.fetch; + + beforeAll(() => { + originalEnv = { ...process.env }; + process.env.FOREST_ENV_SECRET = 'test-env-secret'; + process.env.FOREST_AUTH_SECRET = 'test-auth-secret'; + process.env.FOREST_SERVER_URL = 'https://test.forestadmin.com'; + process.env.AGENT_HOSTNAME = 'http://localhost:3310'; + + // Setup mock for Forest Admin server + mockServer = new MockServer(); + mockServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get('/liana/forest-schema', { + data: [ + { + id: 'users', + type: 'collections', + attributes: { name: 'users', fields: [{ field: 'id', type: 'Number' }] }, + }, + { + id: 'products', + type: 'collections', + attributes: { name: 'products', fields: [{ field: 'name', type: 'String' }] }, + }, + ], + meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null }, + }) + .get(/\/oauth\/register\/registered-client/, { + client_id: 'registered-client', + redirect_uris: ['https://example.com/callback'], + client_name: 'Test Client', + }) + .get(/\/oauth\/register\//, { error: 'Client not found' }, 404); + + global.fetch = mockServer.fetch; + }); + + afterAll(async () => { + process.env = originalEnv; + global.fetch = originalFetch; + }); + + beforeEach(() => { + modifiedEnv = { ...process.env }; + mockServer.clear(); + }); + + afterEach(async () => { + process.env = modifiedEnv; + }); + + describe('constructor', () => { + it('should create server instance', () => { + server = new ForestMCPServer(); + + expect(server).toBeDefined(); + expect(server).toBeInstanceOf(ForestMCPServer); + }); + + it('should initialize with FOREST_SERVER_URL', () => { + process.env.FOREST_SERVER_URL = 'https://custom.forestadmin.com'; + server = new ForestMCPServer(); + + expect(server.forestServerUrl).toBe('https://custom.forestadmin.com'); + }); + + it('should fallback to FOREST_URL', () => { + delete process.env.FOREST_SERVER_URL; + process.env.FOREST_URL = 'https://fallback.forestadmin.com'; + server = new ForestMCPServer(); + + expect(server.forestServerUrl).toBe('https://fallback.forestadmin.com'); + }); + + it('should use default URL when neither is provided', () => { + delete process.env.FOREST_SERVER_URL; + delete process.env.FOREST_URL; + server = new ForestMCPServer(); + + expect(server.forestServerUrl).toBe('https://api.forestadmin.com'); + }); + + it('should create MCP server instance', () => { + server = new ForestMCPServer(); + + expect(server.mcpServer).toBeDefined(); + }); + }); + + describe('environment validation', () => { + it('should throw error when FOREST_ENV_SECRET is missing', async () => { + delete process.env.FOREST_ENV_SECRET; + server = new ForestMCPServer(); + + await expect(server.run()).rejects.toThrow( + 'FOREST_ENV_SECRET is not set. Provide it via options.envSecret or FOREST_ENV_SECRET environment variable.', + ); + }); + + it('should throw error when FOREST_AUTH_SECRET is missing', async () => { + delete process.env.FOREST_AUTH_SECRET; + server = new ForestMCPServer(); + + await expect(server.run()).rejects.toThrow( + 'FOREST_AUTH_SECRET is not set. Provide it via options.authSecret or FOREST_AUTH_SECRET environment variable.', + ); + }); + }); + + describe('run method', () => { + afterEach(async () => { + await shutDownHttpServer(server?.httpServer as http.Server); + }); + + it('should start server on specified port', async () => { + const testPort = 39310; // Use a different port for testing + process.env.MCP_SERVER_PORT = testPort.toString(); + + server = new ForestMCPServer(); + + // Start the server without awaiting (it runs indefinitely) + server.run(); + + // Wait a bit for the server to start + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + // Verify the server is running by making a request + const { httpServer } = server; + expect(httpServer).toBeDefined(); + + // Make a request to verify server is responding + const response = await request(httpServer as http.Server) + .post('/mcp') + .send({ jsonrpc: '2.0', method: 'tools/list', id: 1 }); + + expect(response.status).toBeDefined(); + }); + + it('should create transport instance', async () => { + const testPort = 39311; + process.env.MCP_SERVER_PORT = testPort.toString(); + + server = new ForestMCPServer(); + server.run(); + + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + expect(server.mcpTransport).toBeDefined(); + }); + }); + + describe('HTTP endpoint', () => { + let httpServer: http.Server; + + beforeAll(async () => { + const testPort = 39312; + process.env.MCP_SERVER_PORT = testPort.toString(); + + server = new ForestMCPServer(); + server.run(); + + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + httpServer = server.httpServer as http.Server; + }); + + afterAll(async () => { + await shutDownHttpServer(server?.httpServer as http.Server); + }); + + it('should handle POST requests to /mcp', async () => { + const response = await request(httpServer) + .post('/mcp') + .send({ jsonrpc: '2.0', method: 'initialize', id: 1 }); + + expect(response.status).not.toBe(404); + }); + + it('should reject GET requests', async () => { + const response = await request(httpServer).get('/mcp'); + + expect(response.status).toBe(405); + }); + + it('should handle CORS', async () => { + const response = await request(httpServer) + .post('/mcp') + .set('Origin', 'https://example.com') + .send({ jsonrpc: '2.0', method: 'initialize', id: 1 }); + + expect(response.headers['access-control-allow-origin']).toBe('*'); + }); + + it('should return JSON-RPC error on transport failure', async () => { + // Send invalid request + const response = await request(httpServer).post('/mcp').send('invalid json'); + + // Should handle the error gracefully + expect(response.status).toBeGreaterThanOrEqual(400); + }); + + describe('OAuth metadata endpoint', () => { + it('should return OAuth metadata at /.well-known/oauth-authorization-server', async () => { + const response = await request(server.httpServer).get( + '/.well-known/oauth-authorization-server', + ); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toMatch(/application\/json/); + expect(response.body.issuer).toBe('http://localhost:39312/'); + expect(response.body.registration_endpoint).toBe( + 'https://test.forestadmin.com/oauth/register', + ); + expect(response.body.authorization_endpoint).toBe(`http://localhost:39312/oauth/authorize`); + expect(response.body.token_endpoint).toBe(`http://localhost:39312/oauth/token`); + expect(response.body.revocation_endpoint).toBeUndefined(); + expect(response.body.scopes_supported).toEqual([ + 'mcp:read', + 'mcp:write', + 'mcp:action', + 'mcp:admin', + ]); + expect(response.body.response_types_supported).toEqual(['code']); + expect(response.body.grant_types_supported).toEqual([ + 'authorization_code', + 'refresh_token', + ]); + expect(response.body.code_challenge_methods_supported).toEqual(['S256']); + expect(response.body.token_endpoint_auth_methods_supported).toEqual(['none']); + }); + + it('should return registration_endpoint with custom FOREST_SERVER_URL', async () => { + // Clean up previous server + await shutDownHttpServer(server?.httpServer as http.Server); + + process.env.FOREST_SERVER_URL = 'https://custom.forestadmin.com'; + process.env.MCP_SERVER_PORT = '39314'; + + server = new ForestMCPServer(); + server.run(); + + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + const response = await request(server.httpServer).get( + '/.well-known/oauth-authorization-server', + ); + + expect(response.body.registration_endpoint).toBe( + 'https://custom.forestadmin.com/oauth/register', + ); + }); + }); + + describe('/oauth/authorize endpoint', () => { + it('should return 400 when required parameters are missing', async () => { + const response = await request(httpServer).get('/oauth/authorize'); + + expect(response.status).toBe(400); + }); + + it('should return 400 when client_id is missing', async () => { + const response = await request(httpServer).get('/oauth/authorize').query({ + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + state: 'test-state', + }); + + expect(response.status).toBe(400); + }); + + it('should return 400 when redirect_uri is missing', async () => { + const response = await request(httpServer).get('/oauth/authorize').query({ + client_id: 'test-client', + response_type: 'code', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + state: 'test-state', + }); + + expect(response.status).toBe(400); + }); + + it('should return 400 when code_challenge is missing', async () => { + const response = await request(httpServer).get('/oauth/authorize').query({ + client_id: 'test-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge_method: 'S256', + state: 'test-state', + }); + + expect(response.status).toBe(400); + }); + + it('should return 400 when client is not registered', async () => { + const response = await request(httpServer).get('/oauth/authorize').query({ + client_id: 'unregistered-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + state: 'test-state', + scope: 'mcp:read', + }); + + expect(response.status).toBe(400); + }); + + it('should redirect to Forest Admin frontend with correct parameters', async () => { + const response = await request(httpServer).get('/oauth/authorize').query({ + client_id: 'registered-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + state: 'test-state', + scope: 'mcp:read profile', + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain('https://app.forestadmin.com/oauth/authorize'); + + const redirectUrl = new URL(response.headers.location); + expect(redirectUrl.searchParams.get('redirect_uri')).toBe('https://example.com/callback'); + expect(redirectUrl.searchParams.get('code_challenge')).toBe('test-challenge'); + expect(redirectUrl.searchParams.get('code_challenge_method')).toBe('S256'); + expect(redirectUrl.searchParams.get('response_type')).toBe('code'); + expect(redirectUrl.searchParams.get('client_id')).toBe('registered-client'); + expect(redirectUrl.searchParams.get('state')).toBe('test-state'); + expect(redirectUrl.searchParams.get('scope')).toBe('mcp:read+profile'); + expect(redirectUrl.searchParams.get('environmentId')).toBe('12345'); + }); + + it('should redirect to default frontend when FOREST_FRONTEND_HOSTNAME is not set', async () => { + const response = await request(httpServer).get('/oauth/authorize').query({ + client_id: 'registered-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + state: 'test-state', + scope: 'mcp:read', + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain('https://app.forestadmin.com/oauth/authorize'); + }); + + it('should handle POST method for authorize', async () => { + // POST /authorize uses form-encoded body + const response = await request(httpServer).post('/oauth/authorize').type('form').send({ + client_id: 'registered-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'test-challenge', + code_challenge_method: 'S256', + state: 'test-state', + scope: 'mcp:read', + resource: 'https://example.com/resource', + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toStrictEqual( + `https://app.forestadmin.com/oauth/authorize?redirect_uri=${encodeURIComponent( + 'https://example.com/callback', + )}&code_challenge=test-challenge&code_challenge_method=S256&response_type=code&client_id=registered-client&state=test-state&scope=${encodeURIComponent( + 'mcp:read', + )}&resource=${encodeURIComponent('https://example.com/resource')}&environmentId=12345`, + ); + }); + }); + }); + + /** + * Integration tests for /oauth/token endpoint + * Uses a separate server instance with mock server for Forest Admin API + */ + describe('/oauth/token endpoint', () => { + let mcpServer: ForestMCPServer; + let mcpHttpServer: http.Server; + let mcpMockServer: MockServer; + + beforeAll(async () => { + process.env.FOREST_ENV_SECRET = 'test-env-secret'; + process.env.FOREST_AUTH_SECRET = 'test-auth-secret'; + process.env.FOREST_SERVER_URL = 'https://test.forestadmin.com'; + process.env.MCP_SERVER_PORT = '39320'; + + // Setup mock for Forest Admin server API responses + mcpMockServer = new MockServer(); + mcpMockServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get('/liana/forest-schema', { + data: [ + { + id: 'users', + type: 'collections', + attributes: { name: 'users', fields: [{ field: 'id', type: 'Number' }] }, + }, + { + id: 'products', + type: 'collections', + attributes: { name: 'products', fields: [{ field: 'name', type: 'String' }] }, + }, + ], + meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null }, + }) + .get(/\/oauth\/register\/registered-client/, { + client_id: 'registered-client', + redirect_uris: ['https://example.com/callback'], + client_name: 'Test Client', + scope: 'mcp:read mcp:write', + }) + .get(/\/oauth\/register\//, { error: 'Client not found' }, 404) + // Mock Forest Admin OAuth token endpoint - returns valid JWTs with meta.renderingId, exp, iat, scope + // access_token JWT payload: { meta: { renderingId: 456 }, scope: 'mcp:read mcp:write', iat: 2524608000, exp: 2524611600 } + // refresh_token JWT payload: { iat: 2524608000, exp: 2525212800 } + .post('/oauth/token', { + access_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXRhIjp7InJlbmRlcmluZ0lkIjo0NTZ9LCJzY29wZSI6Im1jcDpyZWFkIG1jcDp3cml0ZSIsImlhdCI6MjUyNDYwODAwMCwiZXhwIjoyNTI0NjExNjAwfQ.fake', + refresh_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjI1MjQ2MDgwMDAsImV4cCI6MjUyNTIxMjgwMH0.fake', + expires_in: 3600, + token_type: 'Bearer', + scope: 'mcp:read mcp:write', + }) + // Mock Forest Admin user info endpoint (called by forestadmin-client via superagent) + .get(/\/liana\/v2\/renderings\/\d+\/authorization/, { + data: { + id: '123', + attributes: { + email: 'user@example.com', + first_name: 'Test', + last_name: 'User', + teams: ['Operations'], + role: 'Admin', + permission_level: 'admin', + tags: [], + }, + }, + }); + + global.fetch = mcpMockServer.fetch; + // Also mock superagent for forestadmin-client requests + mcpMockServer.setupSuperagentMock(); + + // Create and start server + mcpServer = new ForestMCPServer(); + mcpServer.run(); + + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + mcpHttpServer = mcpServer.httpServer as http.Server; + }); + + afterAll(async () => { + mcpMockServer.restoreSuperagent(); + await new Promise(resolve => { + if (mcpServer?.httpServer) { + (mcpServer.httpServer as http.Server).close(() => resolve()); + } else { + resolve(); + } + }); + }); + + it('should return 400 when grant_type is missing', async () => { + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + code: 'auth-code-123', + redirect_uri: 'https://example.com/callback', + client_id: 'registered-client', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('should return 400 when code is missing', async () => { + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'authorization_code', + redirect_uri: 'https://example.com/callback', + client_id: 'registered-client', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('should call Forest Admin server to exchange code', async () => { + mcpMockServer.clear(); + + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'authorization_code', + code: 'valid-auth-code', + redirect_uri: 'https://example.com/callback', + client_id: 'registered-client', + code_verifier: 'test-code-verifier', + }); + + expect(mcpMockServer.fetch).toHaveBeenCalledWith( + 'https://test.forestadmin.com/oauth/token', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"grant_type":"authorization_code"'), + }), + ); + expect(response.status).toBe(200); + expect(response.body.access_token).toBeDefined(); + expect(response.body.refresh_token).toBeDefined(); + expect(response.body.token_type).toBe('Bearer'); + // expires_in is calculated as exp - now from the JWT, so it's a large value for our test tokens + expect(response.body.expires_in).toBeGreaterThan(0); + // The scope is returned from the decoded forest token + expect(response.body.scope).toBe('mcp:read mcp:write'); + const accessToken = response.body.access_token as string; + expect( + () => + jsonwebtoken.verify(accessToken, process.env.FOREST_AUTH_SECRET) as { + renderingId: number; + }, + ).not.toThrow(); + // The forestadmin-client transforms the response from the API + // (e.g., first_name → firstName, id string → number, teams[0] → team) + const decoded = jsonwebtoken.decode(accessToken) as Record; + expect(decoded).toMatchObject({ + id: 123, + email: 'user@example.com', + firstName: 'Test', + lastName: 'User', + team: 'Operations', + role: 'Admin', + permissionLevel: 'admin', + renderingId: 456, + tags: {}, + serverToken: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXRhIjp7InJlbmRlcmluZ0lkIjo0NTZ9LCJzY29wZSI6Im1jcDpyZWFkIG1jcDp3cml0ZSIsImlhdCI6MjUyNDYwODAwMCwiZXhwIjoyNTI0NjExNjAwfQ.fake', + }); + // JWT should also have iat and exp claims + expect(decoded.iat).toBeDefined(); + expect(decoded.exp).toBeDefined(); + + // Verify refresh token structure + const refreshToken = response.body.refresh_token as string; + const decodedRefreshToken = jsonwebtoken.decode(refreshToken) as Record; + expect(decodedRefreshToken).toMatchObject({ + type: 'refresh', + clientId: 'registered-client', + userId: 123, + renderingId: 456, + // The serverRefreshToken is the JWT returned from Forest Admin + serverRefreshToken: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjI1MjQ2MDgwMDAsImV4cCI6MjUyNTIxMjgwMH0.fake', + }); + }); + + it('should exchange refresh token for new tokens', async () => { + mcpMockServer.clear(); + + // First, get initial tokens + const initialResponse = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'authorization_code', + code: 'valid-auth-code', + redirect_uri: 'https://example.com/callback', + client_id: 'registered-client', + code_verifier: 'test-code-verifier', + }); + + expect(initialResponse.status).toBe(200); + const refreshToken = initialResponse.body.refresh_token as string; + + // Clear mock to track new calls + mcpMockServer.clear(); + + // Now exchange refresh token for new tokens + const refreshResponse = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: 'registered-client', + }); + + expect(refreshResponse.status).toBe(200); + expect(refreshResponse.body.access_token).toBeDefined(); + expect(refreshResponse.body.refresh_token).toBeDefined(); + expect(refreshResponse.body.token_type).toBe('Bearer'); + // expires_in is calculated as exp - now from the JWT (duration in seconds) + expect(refreshResponse.body.expires_in).toBeGreaterThan(0); + + // Verify the new access token is valid + const newAccessToken = refreshResponse.body.access_token as string; + expect(() => + jsonwebtoken.verify(newAccessToken, process.env.FOREST_AUTH_SECRET), + ).not.toThrow(); + + // Verify token rotation: new refresh token is returned + const newRefreshToken = refreshResponse.body.refresh_token as string; + expect(newRefreshToken).toBeDefined(); + // Verify it's a valid JWT with refresh token structure + const decodedNewRefresh = jsonwebtoken.decode(newRefreshToken) as Record; + expect(decodedNewRefresh.type).toBe('refresh'); + expect(decodedNewRefresh.clientId).toBe('registered-client'); + + // Verify Forest Admin token endpoint was called with refresh_token grant + expect(mcpMockServer.fetch).toHaveBeenCalledWith( + 'https://test.forestadmin.com/oauth/token', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"grant_type":"refresh_token"'), + }), + ); + + // Note: Token rotation is implemented - the new refresh token should be different + // However, since both requests use the same mock returning the same forest-server-refresh-token, + // the generated JWT will have similar claims but different iat/exp timestamps + const oldDecoded = jsonwebtoken.decode(refreshToken) as { iat: number; exp: number }; + const newDecoded = jsonwebtoken.decode(newRefreshToken) as { iat: number; exp: number }; + expect(newDecoded.iat).toBeGreaterThanOrEqual(oldDecoded.iat); + }); + + it('should return 400 for invalid refresh token', async () => { + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'refresh_token', + refresh_token: 'invalid-token', + client_id: 'registered-client', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBeDefined(); + }); + + it('should return 400 when refresh_token is missing for refresh_token grant', async () => { + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'refresh_token', + client_id: 'registered-client', + }); + + expect(response.status).toBe(400); + }); + + it('should return 400 when client_id does not match refresh token', async () => { + // Create a refresh token for a different client + const refreshToken = jsonwebtoken.sign( + { + type: 'refresh', + clientId: 'different-client', + userId: 123, + renderingId: 456, + serverRefreshToken: 'forest-refresh-token', + }, + process.env.FOREST_AUTH_SECRET, + { expiresIn: '7d' }, + ); + + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: 'registered-client', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBeDefined(); + }); + + describe('error handling', () => { + const setupErrorMock = (errorResponse: object, statusCode: number) => { + mcpMockServer.reset(); + mcpMockServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get('/liana/forest-schema', { + data: [ + { + id: 'users', + type: 'collections', + attributes: { name: 'users', fields: [{ field: 'id', type: 'Number' }] }, + }, + ], + meta: { + liana: 'forest-express-sequelize', + liana_version: '9.0.0', + liana_features: null, + }, + }) + .get(/\/oauth\/register\/registered-client/, { + client_id: 'registered-client', + redirect_uris: ['https://example.com/callback'], + client_name: 'Test Client', + scope: 'mcp:read mcp:write', + }) + .get(/\/oauth\/register\//, { error: 'Client not found' }, 404) + .post('/oauth/token', errorResponse, statusCode); + }; + + // Note: The implementation wraps all OAuth errors in InvalidRequestError, + // so the error code is always 'invalid_request' with the original error in the description + + it('should return error when authorization code is invalid', async () => { + setupErrorMock( + { + error: 'invalid_grant', + error_description: 'The authorization code has expired or is invalid', + }, + 400, + ); + + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'authorization_code', + code: 'expired-or-invalid-code', + redirect_uri: 'https://example.com/callback', + client_id: 'registered-client', + code_verifier: 'test-code-verifier', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('Failed to exchange authorization code'); + }); + + it('should return error when client authentication fails', async () => { + setupErrorMock( + { + error: 'invalid_client', + error_description: 'Client authentication failed', + }, + 401, + ); + + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'authorization_code', + code: 'some-code', + redirect_uri: 'https://example.com/callback', + client_id: 'registered-client', + code_verifier: 'test-code-verifier', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('Failed to exchange authorization code'); + }); + + it('should return error when requested scope is invalid', async () => { + setupErrorMock( + { + error: 'invalid_scope', + error_description: 'The requested scope is invalid or unknown', + }, + 400, + ); + + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'authorization_code', + code: 'some-code', + redirect_uri: 'https://example.com/callback', + client_id: 'registered-client', + code_verifier: 'test-code-verifier', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('Failed to exchange authorization code'); + }); + + it('should return error when client is not authorized', async () => { + setupErrorMock( + { + error: 'unauthorized_client', + error_description: 'The client is not authorized to use this grant type', + }, + 403, + ); + + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'authorization_code', + code: 'some-code', + redirect_uri: 'https://example.com/callback', + client_id: 'registered-client', + code_verifier: 'test-code-verifier', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('Failed to exchange authorization code'); + }); + + it('should return error when Forest Admin server has internal error', async () => { + setupErrorMock( + { + error: 'server_error', + error_description: 'An unexpected error occurred on the server', + }, + 500, + ); + + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'authorization_code', + code: 'some-code', + redirect_uri: 'https://example.com/callback', + client_id: 'registered-client', + code_verifier: 'test-code-verifier', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('Failed to exchange authorization code'); + }); + + it('should use default error description when not provided by Forest server', async () => { + setupErrorMock({ error: 'invalid_request' }, 400); + + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'authorization_code', + code: 'some-code', + redirect_uri: 'https://example.com/callback', + client_id: 'registered-client', + code_verifier: 'test-code-verifier', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('Failed to exchange authorization code'); + }); + + it('should return error when Forest server returns error without error code', async () => { + setupErrorMock({ message: 'Something went wrong' }, 500); + + const response = await request(mcpHttpServer).post('/oauth/token').type('form').send({ + grant_type: 'authorization_code', + code: 'some-code', + redirect_uri: 'https://example.com/callback', + client_id: 'registered-client', + code_verifier: 'test-code-verifier', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('Failed to exchange authorization code'); + }); + }); + }); + + /** + * Integration tests for the list tool + * Tests that the list tool is properly registered and accessible + */ + describe('List tool integration', () => { + let listServer: ForestMCPServer; + let listHttpServer: http.Server; + let listMockServer: MockServer; + + beforeAll(async () => { + process.env.FOREST_ENV_SECRET = 'test-env-secret'; + process.env.FOREST_AUTH_SECRET = 'test-auth-secret'; + process.env.FOREST_SERVER_URL = 'https://test.forestadmin.com'; + process.env.AGENT_HOSTNAME = 'http://localhost:3310'; + process.env.MCP_SERVER_PORT = '39330'; + + listMockServer = new MockServer(); + listMockServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get('/liana/forest-schema', { + data: [ + { + id: 'users', + type: 'collections', + attributes: { name: 'users', fields: [{ field: 'id', type: 'Number' }] }, + }, + { + id: 'products', + type: 'collections', + attributes: { name: 'products', fields: [{ field: 'name', type: 'String' }] }, + }, + ], + meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null }, + }) + .get(/\/oauth\/register\/registered-client/, { + client_id: 'registered-client', + redirect_uris: ['https://example.com/callback'], + client_name: 'Test Client', + scope: 'mcp:read mcp:write', + }) + .get(/\/oauth\/register\//, { error: 'Client not found' }, 404); + + global.fetch = listMockServer.fetch; + + listServer = new ForestMCPServer(); + listServer.run(); + + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + listHttpServer = listServer.httpServer as http.Server; + }); + + afterAll(async () => { + await new Promise(resolve => { + if (listServer?.httpServer) { + (listServer.httpServer as http.Server).close(() => resolve()); + } else { + resolve(); + } + }); + }); + + it('should have list tool registered in the MCP server', () => { + expect(listServer.mcpServer).toBeDefined(); + // The tool should be registered during server initialization + // We verify this by checking the server started successfully + expect(listHttpServer).toBeDefined(); + }); + + it('should require authentication to access /mcp endpoint', async () => { + const response = await request(listHttpServer).post('/mcp').send({ + jsonrpc: '2.0', + method: 'tools/list', + id: 1, + }); + + // Without a valid bearer token, we should get an authentication error + expect(response.status).toBe(401); + }); + + it('should reject requests with invalid bearer token', async () => { + const response = await request(listHttpServer) + .post('/mcp') + .set('Authorization', 'Bearer invalid-token') + .send({ + jsonrpc: '2.0', + method: 'tools/list', + id: 1, + }); + + expect(response.status).toBe(401); + }); + + it('should accept requests with valid bearer token and list available tools', async () => { + // Create a valid JWT token + const authSecret = process.env.FOREST_AUTH_SECRET || 'test-auth-secret'; + const validToken = jsonwebtoken.sign( + { + id: 123, + email: 'user@example.com', + renderingId: 456, + }, + authSecret, + { expiresIn: '1h' }, + ); + + const response = await request(listHttpServer) + .post('/mcp') + .set('Authorization', `Bearer ${validToken}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + method: 'tools/list', + id: 1, + }); + + expect(response.status).toBe(200); + + // The MCP SDK returns the response as text that needs to be parsed + // The response may be in JSON-RPC format or as a newline-delimited JSON stream + let responseData: { + jsonrpc: string; + id: number; + result: { + tools: Array<{ + name: string; + description: string; + inputSchema: { properties: Record }; + }>; + }; + }; + + if (response.body && Object.keys(response.body).length > 0) { + responseData = response.body; + } else { + // Parse the text response - MCP returns Server-Sent Events format with "data: " prefix + const textResponse = response.text; + const lines = textResponse.split('\n').filter((line: string) => line.trim()); + // Find the line with the actual JSON-RPC response (starts with "data: ") + const dataLine = lines.find((line: string) => line.startsWith('data: ')); + + if (dataLine) { + responseData = JSON.parse(dataLine.replace('data: ', '')); + } else { + responseData = JSON.parse(lines[lines.length - 1]); + } + } + + expect(responseData.jsonrpc).toBe('2.0'); + expect(responseData.id).toBe(1); + expect(responseData.result).toBeDefined(); + expect(responseData.result.tools).toBeDefined(); + expect(Array.isArray(responseData.result.tools)).toBe(true); + + // Verify the 'list' tool is registered + const listTool = responseData.result.tools.find( + (tool: { name: string }) => tool.name === 'list', + ); + expect(listTool).toBeDefined(); + expect(listTool.description).toBe( + 'Retrieve a list of records from the specified collection.', + ); + expect(listTool.inputSchema).toBeDefined(); + expect(listTool.inputSchema.properties).toHaveProperty('collectionName'); + expect(listTool.inputSchema.properties).toHaveProperty('search'); + expect(listTool.inputSchema.properties).toHaveProperty('filters'); + expect(listTool.inputSchema.properties).toHaveProperty('sort'); + + // Verify collectionName has enum with the collection names from the mocked schema + const collectionNameSchema = listTool.inputSchema.properties.collectionName as { + type: string; + enum?: string[]; + }; + expect(collectionNameSchema.type).toBe('string'); + expect(collectionNameSchema.enum).toBeDefined(); + expect(collectionNameSchema.enum).toEqual(['users', 'products']); + }); + + it('should create activity log with forestServerToken when calling list tool', async () => { + // This test verifies that the activity log API is called with the forestServerToken + // (the original Forest server token) and NOT the MCP JWT token. + // The forestServerToken is embedded in the MCP JWT during token exchange and extracted + // by verifyAccessToken into authInfo.extra.forestServerToken + + const authSecret = process.env.FOREST_AUTH_SECRET || 'test-auth-secret'; + const forestServerToken = 'original-forest-server-token-for-activity-log'; + + // Create MCP JWT with embedded serverToken (as done during OAuth token exchange) + const mcpToken = jsonwebtoken.sign( + { + id: 123, + email: 'user@example.com', + renderingId: 456, + serverToken: forestServerToken, + }, + authSecret, + { expiresIn: '1h' }, + ); + + // Setup mock to capture the activity log API call and mock agent response + listMockServer.clear(); + listMockServer + .post('/api/activity-logs-requests', { success: true }) + .post('/forest/rpc', { result: [{ id: 1, name: 'Test' }] }); + + const response = await request(listHttpServer) + .post('/mcp') + .set('Authorization', `Bearer ${mcpToken}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'list', + arguments: { collectionName: 'users' }, + }, + id: 2, + }); + + // The tool call should succeed (or fail on agent call, but activity log should be created first) + expect(response.status).toBe(200); + + // Verify activity log API was called with the correct forestServerToken + // The mock fetch captures all calls as [url, options] tuples + const activityLogCall = listMockServer.fetch.mock.calls.find( + (call: [string, RequestInit]) => + call[0] === 'https://test.forestadmin.com/api/activity-logs-requests', + ) as [string, RequestInit] | undefined; + + expect(activityLogCall).toBeDefined(); + expect(activityLogCall![1].headers).toMatchObject({ + Authorization: `Bearer ${forestServerToken}`, + 'Content-Type': 'application/json', + 'Forest-Application-Source': 'MCP', + }); + + // Verify the body contains the correct data + const body = JSON.parse(activityLogCall![1].body as string); + expect(body.data.attributes.action).toBe('index'); + expect(body.data.relationships.collection.data).toEqual({ + id: 'users', + type: 'collections', + }); + }); + }); +}); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 0000000000..7f45f4c1e3 --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,351 @@ +// Import polyfills FIRST - before any MCP SDK imports +// This ensures URL.canParse is available for MCP SDK's Zod validation +import './polyfills'; + +import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js'; +import { tokenHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/token.js'; +import { allowedMethods } from '@modelcontextprotocol/sdk/server/auth/middleware/allowedMethods.js'; +import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; +import { + createOAuthMetadata, + mcpAuthMetadataRouter, +} from '@modelcontextprotocol/sdk/server/auth/router.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import cors from 'cors'; +import express, { Express } from 'express'; +import * as http from 'http'; + +import ForestOAuthProvider from './forest-oauth-provider'; +import { isMcpRoute } from './mcp-paths'; +import declareListTool from './tools/list'; +import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; +import { NAME, VERSION } from './version'; + +export type LogLevel = 'Debug' | 'Info' | 'Warn' | 'Error'; +export type Logger = (level: LogLevel, message: string) => void; + +export type HttpCallback = ( + req: http.IncomingMessage, + res: http.ServerResponse, + next?: () => void, +) => void; + +function getDefaultLogFn(level: LogLevel): (message: string) => void { + if (level === 'Error') return (msg: string) => console.error(`[MCP Server] ${msg}`); + if (level === 'Warn') return (msg: string) => console.warn(`[MCP Server] ${msg}`); + + return (msg: string) => console.info(`[MCP Server] ${msg}`); +} + +const defaultLogger: Logger = (level, message) => { + getDefaultLogFn(level)(message); +}; + +/** + * Options for configuring the Forest Admin MCP Server + */ +export interface ForestMCPServerOptions { + /** Forest Admin server URL */ + forestServerUrl?: string; + /** Forest Admin environment secret */ + envSecret?: string; + /** Forest Admin authentication secret */ + authSecret?: string; + /** Optional logger function. Defaults to console logging. */ + logger?: Logger; +} + +/** + * Forest Admin MCP Server + * + * This server provides HTTP REST API access to Forest Admin operations + * with OAuth authentication support. + * + * Environment Variables (used as fallback when options not provided): + * - FOREST_ENV_SECRET: Your Forest Admin environment secret (required) + * - FOREST_AUTH_SECRET: Your Forest Admin authentication secret, it must be the same one as the one on your agent (required) + * - FOREST_SERVER_URL: Forest Admin server URL (optional) + * - MCP_SERVER_PORT: Port for the HTTP server (default: 3931) + */ + +export default class ForestMCPServer { + public mcpServer: McpServer; + public mcpTransport?: StreamableHTTPServerTransport; + public httpServer?: http.Server; + public expressApp?: Express; + public forestServerUrl: string; + + private envSecret?: string; + private authSecret?: string; + private logger: Logger; + + constructor(options?: ForestMCPServerOptions) { + this.forestServerUrl = + options?.forestServerUrl || + process.env.FOREST_SERVER_URL || + process.env.FOREST_URL || + 'https://api.forestadmin.com'; + + this.envSecret = options?.envSecret || process.env.FOREST_ENV_SECRET; + this.authSecret = options?.authSecret || process.env.FOREST_AUTH_SECRET; + this.logger = options?.logger || defaultLogger; + + this.mcpServer = new McpServer({ + name: NAME, + version: VERSION, + }); + } + + private async setupTools(): Promise { + let collectionNames: string[] = []; + + try { + const schema = await fetchForestSchema(this.forestServerUrl); + collectionNames = getCollectionNames(schema); + } catch (error) { + this.logger( + 'Warn', + `Failed to fetch forest schema, collection names will not be available: ${error}`, + ); + } + + declareListTool(this.mcpServer, this.forestServerUrl, collectionNames); + } + + private ensureSecretsAreSet(): { envSecret: string; authSecret: string } { + if (!this.envSecret) { + throw new Error( + 'FOREST_ENV_SECRET is not set. Provide it via options.envSecret or FOREST_ENV_SECRET environment variable.', + ); + } + + if (!this.authSecret) { + throw new Error( + 'FOREST_AUTH_SECRET is not set. Provide it via options.authSecret or FOREST_AUTH_SECRET environment variable.', + ); + } + + return { envSecret: this.envSecret, authSecret: this.authSecret }; + } + + /** + * Build and return the Express app without starting a standalone server. + * Useful for embedding the MCP server into another application. + * + * @param baseUrl - Optional base URL override. If not provided, will use the + * environmentApiEndpoint from Forest Admin API. + * @returns The configured Express application + */ + async buildExpressApp(baseUrl?: URL): Promise { + const { envSecret, authSecret } = this.ensureSecretsAreSet(); + + // Fetch schema and setup tools before building the app + await this.setupTools(); + + this.mcpTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await this.mcpServer.connect(this.mcpTransport); + + const app = express(); + + app.use( + cors({ + origin: '*', + }), + ); + + // Initialize OAuth provider + const oauthProvider = new ForestOAuthProvider({ + forestServerUrl: this.forestServerUrl, + envSecret, + authSecret, + logger: this.logger, + }); + await oauthProvider.initialize(); + + // Use provided baseUrl or get it from the OAuth provider (environmentApiEndpoint) + const effectiveBaseUrl = baseUrl || oauthProvider.getBaseUrl(); + + if (!effectiveBaseUrl) { + throw new Error( + 'Could not determine base URL for MCP server. ' + + 'Either provide a baseUrl parameter or ensure the Forest Admin environment has an api_endpoint configured.', + ); + } + + const scopesSupported = ['mcp:read', 'mcp:write', 'mcp:action', 'mcp:admin']; + + // Create OAuth metadata with custom registration_endpoint pointing to Forest Admin + const oauthMetadata = createOAuthMetadata({ + provider: oauthProvider, + issuerUrl: effectiveBaseUrl, + baseUrl: effectiveBaseUrl, + scopesSupported, + }); + + oauthMetadata.token_endpoint_auth_methods_supported = ['none']; + oauthMetadata.response_types_supported = ['code']; + oauthMetadata.code_challenge_methods_supported = ['S256']; + + oauthMetadata.token_endpoint = `${effectiveBaseUrl.href}oauth/token`; + oauthMetadata.authorization_endpoint = `${effectiveBaseUrl.href}oauth/authorize`; + // Override registration_endpoint to point to Forest Admin server + oauthMetadata.registration_endpoint = `${this.forestServerUrl}/oauth/register`; + // Remove revocation_endpoint from metadata (not supported) + delete oauthMetadata.revocation_endpoint; + + // Body parsers MUST come before OAuth handlers because the token handler + // expects req.body to be parsed. When proxied from Koa, the body is already + // available but Express needs to see it properly. + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + app.use( + '/oauth/authorize', + authorizationHandler({ + provider: oauthProvider, + }), + ); + app.use('/oauth/token', tokenHandler({ provider: oauthProvider })); + + // Mount metadata router with custom metadata + // The resourceServerUrl should include the /mcp path to match RFC 9728 requirements. + // This creates the .well-known/oauth-protected-resource/mcp endpoint. + const mcpResourceUrl = new URL('mcp', effectiveBaseUrl); + app.use( + mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: mcpResourceUrl, + scopesSupported, + }), + ); + + app.use(allowedMethods(['POST'])); + + app.post( + '/mcp', + requireBearerAuth({ + verifier: oauthProvider, + requiredScopes: ['mcp:read'], + }), + (req, res) => { + void (async () => { + try { + // Use the shared transport instance that's already connected to the MCP server + if (!this.mcpTransport) { + throw new Error('MCP transport not initialized'); + } + + // Handle the incoming request through the connected transport + await this.mcpTransport.handleRequest(req, res, req.body); + } catch (error) { + this.logger('Error', `MCP Error: ${error}`); + + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: (error as Error)?.message || 'Internal server error', + }, + id: null, + }); + } + } + })(); + }, + ); + + // Global error handler to catch any unhandled errors + // Express requires all 4 parameters to recognize this as an error handler + // Capture logger for use in error handler (arrow function would lose context) + const { logger } = this; + app.use( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + logger('Error', `Unhandled error: ${err.message}`); + + if (!res.headersSent) { + res.status(500).json({ + error: 'internal_server_error', + error_description: err.message, + }); + } + }, + ); + + this.expressApp = app; + + return app; + } + + /** + * Build and return an HTTP callback that can be used as middleware. + * The callback will handle MCP-related routes (/.well-known/*, /oauth/*, /mcp) + * and call next() for other routes. + * + * @param baseUrl - Optional base URL override. If not provided, will use the + * environmentApiEndpoint from Forest Admin API. + * @returns An HTTP callback function + */ + async getHttpCallback(baseUrl?: URL): Promise { + const app = await this.buildExpressApp(baseUrl); + + return (req, res, next) => { + const url = req.url || '/'; + + if (isMcpRoute(url)) { + // Fix for streams that have been consumed by another framework (like Koa) + // Express's finalhandler calls unpipe() which expects _readableState.pipes to exist + // Node.js unpipe() accesses _readableState.pipes.length, so pipes must be an array + /* eslint-disable @typescript-eslint/no-explicit-any, no-underscore-dangle */ + const reqAny = req as any; + + // Ensure _readableState exists with proper structure + if (!reqAny._readableState) { + reqAny._readableState = { + pipes: [], + pipesCount: 0, + flowing: null, + ended: true, + endEmitted: true, + reading: false, + }; + } else if (!Array.isArray(reqAny._readableState.pipes)) { + // pipes must be an array for Node.js unpipe() to work + reqAny._readableState.pipes = []; + } + /* eslint-enable @typescript-eslint/no-explicit-any, no-underscore-dangle */ + + // Handle MCP route with Express app + app(req, res); + } else if (next) { + // Not an MCP route, call next middleware + next(); + } else { + // No next callback and not an MCP route - this shouldn't happen in normal usage + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + } + }; + } + + /** + * 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}`); + + 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}`); + }); + } +} diff --git a/packages/mcp-server/src/test-utils/mock-server.ts b/packages/mcp-server/src/test-utils/mock-server.ts new file mode 100644 index 0000000000..24288cae94 --- /dev/null +++ b/packages/mcp-server/src/test-utils/mock-server.ts @@ -0,0 +1,243 @@ +type MockRouteHandler = T | ((url: string, options?: RequestInit) => T); + +interface MockRoute { + pattern: string | RegExp; + method?: string; + response: MockRouteHandler; + status?: number; +} + +// Store original superagent methods for restoration +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let originalSuperagent: any = null; + +/** + * Mock server class for mocking fetch requests to specific routes + * Also intercepts superagent requests used by forestadmin-client + */ +export default class MockServer { + private routes: MockRoute[] = []; + private mockFn: jest.Mock; + private superagentMocked = false; + + constructor() { + this.mockFn = jest.fn((url: string, options?: RequestInit) => { + return this.handleRequest(url, options); + }); + } + + /** + * Setup superagent mocking to intercept HTTP requests made by forestadmin-client + */ + setupSuperagentMock(): void { + if (this.superagentMocked) return; + + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, global-require + const superagent = require('superagent'); + + if (!originalSuperagent) { + originalSuperagent = { ...superagent }; + } + + // Create mock request builder using arrow function to preserve 'this' + const createMockRequest = (method: string, reqUrl: string) => { + let headers: Record = {}; + let body: unknown = null; + + const mockRequest = { + set: (key: string | Record, value?: string) => { + if (typeof key === 'object') { + headers = { ...headers, ...key }; + } else if (value !== undefined) { + headers[key] = value; + } + + return mockRequest; + }, + timeout: () => mockRequest, + send: async (data?: unknown) => { + body = data; + + return this.handleSuperagentRequest(method, reqUrl, headers, body); + }, + }; + + return mockRequest; + }; + + // Override superagent methods + superagent.get = (reqUrl: string) => createMockRequest('GET', reqUrl); + superagent.post = (reqUrl: string) => createMockRequest('POST', reqUrl); + superagent.put = (reqUrl: string) => createMockRequest('PUT', reqUrl); + superagent.delete = (reqUrl: string) => createMockRequest('DELETE', reqUrl); + + this.superagentMocked = true; + } + + /** + * Restore original superagent methods + */ + restoreSuperagent(): void { + if (!this.superagentMocked || !originalSuperagent) return; + + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires, global-require + const superagent = require('superagent'); + + Object.assign(superagent, originalSuperagent); + this.superagentMocked = false; + } + + private async handleSuperagentRequest( + method: string, + reqUrl: string, + headers: Record, + body: unknown, + ): Promise<{ body: unknown; status: number }> { + for (const route of this.routes) { + const patternMatch = + typeof route.pattern === 'string' + ? reqUrl.includes(route.pattern) + : route.pattern.test(reqUrl); + + const methodMatch = !route.method || route.method.toUpperCase() === method.toUpperCase(); + + if (patternMatch && methodMatch) { + const payload = + typeof route.response === 'function' + ? (route.response as (url: string, options?: RequestInit) => unknown)(reqUrl, { + method, + headers: headers as unknown as HeadersInit, + body: body as BodyInit, + }) + : route.response; + + const status = route.status ?? 200; + + return { body: payload, status }; + } + } + + // No match found - throw error like superagent would for 404 + const error = new Error(`Not Found: ${method} ${reqUrl}`) as Error & { + response: { status: number; body: { errors: { detail: string }[] } }; + }; + error.response = { + status: 404, + body: { errors: [{ detail: 'Not found' }] }, + }; + throw error; + } + + /** + * Register a route with a JSON payload or a function that returns a payload + */ + route( + pattern: string | RegExp, + response: MockRouteHandler, + options: { method?: string; status?: number } = {}, + ): this { + this.routes.push({ + pattern, + method: options.method, + response, + status: options.status ?? 200, + }); + + return this; + } + + /** + * Register a GET route + */ + get(pattern: string | RegExp, response: MockRouteHandler, status?: number): this { + return this.route(pattern, response, { method: 'GET', status }); + } + + /** + * Register a POST route + */ + post(pattern: string | RegExp, response: MockRouteHandler, status?: number): this { + return this.route(pattern, response, { method: 'POST', status }); + } + + /** + * Register a PUT route + */ + put(pattern: string | RegExp, response: MockRouteHandler, status?: number): this { + return this.route(pattern, response, { method: 'PUT', status }); + } + + /** + * Register a DELETE route + */ + delete(pattern: string | RegExp, response: MockRouteHandler, status?: number): this { + return this.route(pattern, response, { method: 'DELETE', status }); + } + + /** + * Register a PATCH route + */ + patch(pattern: string | RegExp, response: MockRouteHandler, status?: number): this { + return this.route(pattern, response, { method: 'PATCH', status }); + } + + /** + * Get the mock function to use as global.fetch + */ + get fetch(): jest.Mock { + return this.mockFn; + } + + /** + * Clear mock call history + */ + clear(): void { + this.mockFn.mockClear(); + } + + /** + * Reset all routes + */ + reset(): void { + this.routes = []; + this.mockFn.mockClear(); + } + + private handleRequest(url: string, options?: RequestInit): Promise { + const urlString = url.toString(); + const method = options?.method || 'GET'; + + for (const route of this.routes) { + const patternMatch = + typeof route.pattern === 'string' + ? urlString.includes(route.pattern) + : route.pattern.test(urlString); + + const methodMatch = !route.method || route.method.toUpperCase() === method.toUpperCase(); + + if (patternMatch && methodMatch) { + const payload = + typeof route.response === 'function' + ? (route.response as (url: string, options?: RequestInit) => unknown)(url, options) + : route.response; + + const status = route.status ?? 200; + + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: () => Promise.resolve(payload), + } as Response); + } + } + + // Default: return 404 for unknown endpoints + return Promise.resolve({ + ok: false, + status: 404, + statusText: 'Not Found', + json: () => Promise.resolve({ error: 'Not found' }), + } as Response); + } +} diff --git a/packages/mcp-server/src/tools/list.test.ts b/packages/mcp-server/src/tools/list.test.ts new file mode 100644 index 0000000000..4683bfba2e --- /dev/null +++ b/packages/mcp-server/src/tools/list.test.ts @@ -0,0 +1,492 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import declareListTool from './list'; +import createActivityLog from '../utils/activity-logs-creator'; +import buildClient from '../utils/agent-caller'; +import * as schemaFetcher from '../utils/schema-fetcher'; + +jest.mock('../utils/agent-caller'); +jest.mock('../utils/activity-logs-creator'); +jest.mock('../utils/schema-fetcher'); + +const mockBuildClient = buildClient as jest.MockedFunction; +const mockCreateActivityLog = createActivityLog as jest.MockedFunction; +const mockFetchForestSchema = schemaFetcher.fetchForestSchema as jest.MockedFunction< + typeof schemaFetcher.fetchForestSchema +>; +const mockGetFieldsOfCollection = schemaFetcher.getFieldsOfCollection as jest.MockedFunction< + typeof schemaFetcher.getFieldsOfCollection +>; + +describe('declareListTool', () => { + let mcpServer: McpServer; + let registeredToolHandler: (options: unknown, extra: unknown) => Promise; + let registeredToolConfig: { title: string; description: string; inputSchema: unknown }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create a mock MCP server that captures the registered tool + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + + mockCreateActivityLog.mockResolvedValue(undefined); + }); + + describe('tool registration', () => { + it('should register a tool named "list"', () => { + declareListTool(mcpServer, 'https://api.forestadmin.com'); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'list', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareListTool(mcpServer, 'https://api.forestadmin.com'); + + expect(registeredToolConfig.title).toBe('List records from a collection'); + expect(registeredToolConfig.description).toBe( + 'Retrieve a list of records from the specified collection.', + ); + }); + + it('should define correct input schema', () => { + declareListTool(mcpServer, 'https://api.forestadmin.com'); + + expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); + expect(registeredToolConfig.inputSchema).toHaveProperty('search'); + expect(registeredToolConfig.inputSchema).toHaveProperty('filters'); + expect(registeredToolConfig.inputSchema).toHaveProperty('sort'); + }); + + it('should use string type for collectionName when no collection names provided', () => { + declareListTool(mcpServer, 'https://api.forestadmin.com'); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options?: string[]; parse: (value: unknown) => unknown } + >; + // String type should not have options property (enum has options) + expect(schema.collectionName.options).toBeUndefined(); + // Should accept any string + expect(() => schema.collectionName.parse('any-collection')).not.toThrow(); + }); + + it('should use string type for collectionName when empty array provided', () => { + declareListTool(mcpServer, 'https://api.forestadmin.com', []); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options?: string[]; parse: (value: unknown) => unknown } + >; + // String type should not have options property + expect(schema.collectionName.options).toBeUndefined(); + // Should accept any string + expect(() => schema.collectionName.parse('any-collection')).not.toThrow(); + }); + + it('should use enum type for collectionName when collection names provided', () => { + declareListTool(mcpServer, 'https://api.forestadmin.com', ['users', 'products', 'orders']); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options: string[]; parse: (value: unknown) => unknown } + >; + // Enum type should have options property with the collection names + expect(schema.collectionName.options).toEqual(['users', 'products', 'orders']); + // Should accept valid collection names + expect(() => schema.collectionName.parse('users')).not.toThrow(); + expect(() => schema.collectionName.parse('products')).not.toThrow(); + // Should reject invalid collection names + expect(() => schema.collectionName.parse('invalid-collection')).toThrow(); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: '123', + }, + }, + } as unknown as RequestHandlerExtra; + + beforeEach(() => { + declareListTool(mcpServer, 'https://api.forestadmin.com'); + }); + + it('should call buildClient with the extra parameter', async () => { + const mockList = jest.fn().mockResolvedValue([{ id: 1, name: 'Item 1' }]); + const mockCollection = jest.fn().mockReturnValue({ list: mockList }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler({ collectionName: 'users' }, mockExtra); + + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + }); + + it('should call rpcClient.collection with the collection name', async () => { + const mockList = jest.fn().mockResolvedValue([]); + const mockCollection = jest.fn().mockReturnValue({ list: mockList }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler({ collectionName: 'products' }, mockExtra); + + expect(mockCollection).toHaveBeenCalledWith('products'); + }); + + it('should return results as JSON text content', async () => { + const mockData = [ + { id: 1, name: 'Product 1' }, + { id: 2, name: 'Product 2' }, + ]; + const mockList = jest.fn().mockResolvedValue(mockData); + const mockCollection = jest.fn().mockReturnValue({ list: mockList }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = await registeredToolHandler({ collectionName: 'products' }, mockExtra); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(mockData) }], + }); + }); + + describe('activity logging', () => { + beforeEach(() => { + const mockList = jest.fn().mockResolvedValue([]); + const mockCollection = jest.fn().mockReturnValue({ list: mockList }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should create activity log with "index" action type for basic list', async () => { + await registeredToolHandler({ collectionName: 'users' }, mockExtra); + + expect(mockCreateActivityLog).toHaveBeenCalledWith( + 'https://api.forestadmin.com', + mockExtra, + 'index', + { collectionName: 'users' }, + ); + }); + + it('should create activity log with "search" action type when search is provided', async () => { + await registeredToolHandler({ collectionName: 'users', search: 'john' }, mockExtra); + + expect(mockCreateActivityLog).toHaveBeenCalledWith( + 'https://api.forestadmin.com', + mockExtra, + 'search', + { collectionName: 'users' }, + ); + }); + + it('should create activity log with "filter" action type when filters are provided', async () => { + await registeredToolHandler( + { + collectionName: 'users', + filters: { field: 'status', operator: 'Equal', value: 'active' }, + }, + mockExtra, + ); + + expect(mockCreateActivityLog).toHaveBeenCalledWith( + 'https://api.forestadmin.com', + mockExtra, + 'filter', + { collectionName: 'users' }, + ); + }); + + it('should prioritize "search" over "filter" when both are provided', async () => { + await registeredToolHandler( + { + collectionName: 'users', + search: 'john', + filters: { field: 'status', operator: 'Equal', value: 'active' }, + }, + mockExtra, + ); + + expect(mockCreateActivityLog).toHaveBeenCalledWith( + 'https://api.forestadmin.com', + mockExtra, + 'search', + { collectionName: 'users' }, + ); + }); + }); + + describe('list parameters', () => { + let mockList: jest.Mock; + let mockCollection: jest.Mock; + + beforeEach(() => { + mockList = jest.fn().mockResolvedValue([]); + mockCollection = jest.fn().mockReturnValue({ list: mockList }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should call list with empty parameters for basic request', async () => { + await registeredToolHandler({ collectionName: 'users' }, mockExtra); + + expect(mockList).toHaveBeenCalledWith({}); + }); + + it('should pass search parameter to list', async () => { + await registeredToolHandler({ collectionName: 'users', search: 'test query' }, mockExtra); + + expect(mockList).toHaveBeenCalledWith({ search: 'test query' }); + }); + + it('should pass filters wrapped in conditionTree', async () => { + const filters = { field: 'name', operator: 'Equal', value: 'John' }; + + await registeredToolHandler({ collectionName: 'users', filters }, mockExtra); + + expect(mockList).toHaveBeenCalledWith({ + filters: { conditionTree: filters }, + }); + }); + + it('should pass sort parameter when both field and ascending are provided', async () => { + await registeredToolHandler( + { + collectionName: 'users', + sort: { field: 'createdAt', ascending: true }, + }, + mockExtra, + ); + + expect(mockList).toHaveBeenCalledWith({ + sort: { field: 'createdAt', ascending: true }, + }); + }); + + it('should pass sort parameter when ascending is false', async () => { + await registeredToolHandler( + { + collectionName: 'users', + sort: { field: 'createdAt', ascending: false }, + }, + mockExtra, + ); + + expect(mockList).toHaveBeenCalledWith({ + sort: { field: 'createdAt', ascending: false }, + }); + }); + + it('should not pass sort when only field is provided', async () => { + await registeredToolHandler( + { + collectionName: 'users', + sort: { field: 'createdAt' }, + }, + mockExtra, + ); + + expect(mockList).toHaveBeenCalledWith({}); + }); + + it('should pass all parameters together', async () => { + const filters = { + aggregator: 'And', + conditions: [{ field: 'active', operator: 'Equal', value: true }], + }; + + await registeredToolHandler( + { + collectionName: 'users', + search: 'john', + filters, + sort: { field: 'name', ascending: true }, + }, + mockExtra, + ); + + expect(mockList).toHaveBeenCalledWith({ + search: 'john', + filters: { conditionTree: filters }, + sort: { field: 'name', ascending: true }, + }); + }); + }); + + describe('error handling', () => { + let mockList: jest.Mock; + let mockCollection: jest.Mock; + + beforeEach(() => { + mockList = jest.fn(); + mockCollection = jest.fn().mockReturnValue({ list: mockList }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should parse error with nested error.text structure in message', async () => { + // The RPC client throws an Error with message containing JSON: { error: { text: '...' } } + const errorPayload = { + error: { + status: 400, + text: JSON.stringify({ + errors: [{ name: 'ValidationError', detail: 'Invalid filters provided' }], + }), + }, + }; + const agentError = new Error(JSON.stringify(errorPayload)); + mockList.mockRejectedValue(agentError); + + await expect(registeredToolHandler({ collectionName: 'users' }, mockExtra)).rejects.toThrow( + 'Invalid filters provided', + ); + }); + + it('should parse error with direct text property in message', async () => { + // The RPC client throws an Error with message containing JSON: { text: '...' } + const errorPayload = { + text: JSON.stringify({ + errors: [{ name: 'ValidationError', detail: 'Direct text error' }], + }), + }; + const agentError = new Error(JSON.stringify(errorPayload)); + mockList.mockRejectedValue(agentError); + + await expect(registeredToolHandler({ collectionName: 'users' }, mockExtra)).rejects.toThrow( + 'Direct text error', + ); + }); + + it('should use message property from parsed JSON when no text field', async () => { + // The RPC client throws an Error with message containing JSON: { message: '...' } + const errorPayload = { + message: 'Error message from JSON payload', + }; + const agentError = new Error(JSON.stringify(errorPayload)); + mockList.mockRejectedValue(agentError); + + await expect(registeredToolHandler({ collectionName: 'users' }, mockExtra)).rejects.toThrow( + 'Error message from JSON payload', + ); + }); + + it('should fall back to error.message when message is not valid JSON', async () => { + // The RPC client throws an Error with a plain string message (not JSON) + const agentError = new Error('Plain error message'); + mockList.mockRejectedValue(agentError); + + await expect(registeredToolHandler({ collectionName: 'users' }, mockExtra)).rejects.toThrow( + 'Plain error message', + ); + }); + + it('should rethrow original error when no parsable error found', async () => { + // An object without a message property + const agentError = { unknownProperty: 'some value' }; + mockList.mockRejectedValue(agentError); + + await expect(registeredToolHandler({ collectionName: 'users' }, mockExtra)).rejects.toEqual( + agentError, + ); + }); + + it('should provide helpful error message for Invalid sort errors', async () => { + // The RPC client throws an "Invalid sort" error + const errorPayload = { + error: { + status: 400, + text: JSON.stringify({ + errors: [{ name: 'ValidationError', detail: 'Invalid sort field: invalidField' }], + }), + }, + }; + const agentError = new Error(JSON.stringify(errorPayload)); + mockList.mockRejectedValue(agentError); + + // Mock schema fetcher to return collection fields + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'id', + type: 'Number', + isSortable: true, + enum: null, + reference: null, + isReadOnly: false, + isRequired: true, + isPrimaryKey: true, + }, + { + field: 'name', + type: 'String', + isSortable: true, + enum: null, + reference: null, + isReadOnly: false, + isRequired: false, + isPrimaryKey: false, + }, + { + field: 'email', + type: 'String', + isSortable: true, + enum: null, + reference: null, + isReadOnly: false, + isRequired: false, + isPrimaryKey: false, + }, + { + field: 'computed', + type: 'String', + isSortable: false, + enum: null, + reference: null, + isReadOnly: true, + isRequired: false, + isPrimaryKey: false, + }, + ]; + const mockSchema: schemaFetcher.ForestSchema = { + collections: [{ name: 'users', fields: mockFields }], + }; + mockFetchForestSchema.mockResolvedValue(mockSchema); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + await expect(registeredToolHandler({ collectionName: 'users' }, mockExtra)).rejects.toThrow( + 'The sort field provided is invalid for this collection. Available fields for the collection users are: id, name, email.', + ); + + expect(mockFetchForestSchema).toHaveBeenCalledWith('https://api.forestadmin.com'); + expect(mockGetFieldsOfCollection).toHaveBeenCalledWith(mockSchema, 'users'); + }); + }); + }); +}); diff --git a/packages/mcp-server/src/tools/list.ts b/packages/mcp-server/src/tools/list.ts new file mode 100644 index 0000000000..8663a04593 --- /dev/null +++ b/packages/mcp-server/src/tools/list.ts @@ -0,0 +1,116 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import filterSchema from '../schemas/filter.js'; +import createActivityLog from '../utils/activity-logs-creator.js'; +import buildClient from '../utils/agent-caller.js'; +import parseAgentError from '../utils/error-parser.js'; +import { fetchForestSchema, getFieldsOfCollection } from '../utils/schema-fetcher.js'; + +function createListArgumentShape(collectionNames: string[]) { + return { + collectionName: + collectionNames.length > 0 ? z.enum(collectionNames as [string, ...string[]]) : z.string(), + search: z.string().optional(), + filters: filterSchema.optional(), + sort: z + .object({ + field: z.string(), + ascending: z.boolean(), + }) + .optional(), + }; +} + +type ListArgument = { + collectionName: string; + search?: string; + filters?: z.infer; + sort?: { field: string; ascending: boolean }; +}; + +function getListParameters(options: ListArgument): { + filters?: Record; + search?: string; + sort?: { field: string; ascending: boolean }; +} { + const parameters: { + filters?: Record; + search?: string; + sort?: { field: string; ascending: boolean }; + } = {}; + + if (options.filters) { + parameters.filters = { conditionTree: options.filters as Record }; + } + + if (options.search) { + parameters.search = options.search; + } + + if (options.sort?.field && 'ascending' in options.sort) { + parameters.sort = options.sort as { field: string; ascending: boolean }; + } + + return parameters; +} + +export default function declareListTool( + mcpServer: McpServer, + forestServerUrl: string, + collectionNames: string[] = [], +): void { + const listArgumentShape = createListArgumentShape(collectionNames); + + mcpServer.registerTool( + 'list', + { + title: 'List records from a collection', + description: 'Retrieve a list of records from the specified collection.', + inputSchema: listArgumentShape, + }, + async (options: ListArgument, extra) => { + const { rpcClient } = await buildClient(extra); + + let actionType = 'index'; + + if (options.search) { + actionType = 'search'; + } else if (options.filters) { + actionType = 'filter'; + } + + await createActivityLog(forestServerUrl, extra, actionType, { + collectionName: options.collectionName, + }); + + try { + const result = await rpcClient + .collection(options.collectionName) + .list(getListParameters(options)); + + return { content: [{ type: 'text', text: JSON.stringify(result) }] }; + } catch (error) { + // Parse error text if it's a JSON string from the agent + const errorDetail = parseAgentError(error); + + if (errorDetail?.includes('Invalid sort')) { + const fields = getFieldsOfCollection( + await fetchForestSchema(forestServerUrl), + options.collectionName, + ); + throw new Error( + `The sort field provided is invalid for this collection. Available fields for the collection ${ + options.collectionName + } are: ${fields + .filter(field => field.isSortable) + .map(field => field.field) + .join(', ')}.`, + ); + } + + throw errorDetail ? new Error(errorDetail) : error; + } + }, + ); +} diff --git a/packages/mcp-server/src/utils/activity-logs-creator.test.ts b/packages/mcp-server/src/utils/activity-logs-creator.test.ts new file mode 100644 index 0000000000..b1fb357929 --- /dev/null +++ b/packages/mcp-server/src/utils/activity-logs-creator.test.ts @@ -0,0 +1,315 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import createActivityLog from './activity-logs-creator'; + +describe('createActivityLog', () => { + const originalFetch = global.fetch; + let mockFetch: jest.Mock; + + beforeEach(() => { + mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + global.fetch = mockFetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + const createMockRequest = (overrides = {}) => + ({ + authInfo: { + extra: { + forestServerToken: 'test-forest-token', + renderingId: '12345', + ...overrides, + }, + }, + } as unknown as RequestHandlerExtra); + + describe('action type mapping', () => { + it.each([ + ['index', 'read'], + ['search', 'read'], + ['filter', 'read'], + ['listHasMany', 'read'], + ['actionForm', 'read'], + ['availableActions', 'read'], + ['availableCollections', 'read'], + ['action', 'write'], + ['create', 'write'], + ['update', 'write'], + ['delete', 'write'], + ])('should map action "%s" to type "%s"', async (action, expectedType) => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, action); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.forestadmin.com/api/activity-logs-requests', + expect.objectContaining({ + body: expect.stringContaining(`"type":"${expectedType}"`), + }), + ); + }); + + it('should throw error for unknown action type', async () => { + const request = createMockRequest(); + + await expect( + createActivityLog('https://api.forestadmin.com', request, 'unknownAction'), + ).rejects.toThrow('Unknown action type: unknownAction'); + }); + }); + + describe('request formatting', () => { + it('should send correct headers with forestServerToken from authInfo.extra', async () => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, 'index'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.forestadmin.com/api/activity-logs-requests', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Forest-Application-Source': 'MCP', + Authorization: 'Bearer test-forest-token', + }, + }), + ); + }); + + it('should use forestServerToken for Authorization header (not the MCP token)', async () => { + // This test documents that the activity log API requires the original Forest server token, + // not the MCP-generated JWT token. The forestServerToken must be passed through + // authInfo.extra from the OAuth provider's verifyAccessToken method. + const request = { + authInfo: { + token: 'mcp-jwt-token-should-not-be-used', + extra: { + forestServerToken: 'original-forest-server-token', + renderingId: '12345', + }, + }, + } as unknown as RequestHandlerExtra; + + await createActivityLog('https://api.forestadmin.com', request, 'index'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.forestadmin.com/api/activity-logs-requests', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer original-forest-server-token', + }), + }), + ); + }); + + it('should send undefined token when forestServerToken is missing from extra', async () => { + // This test documents the error case: if forestServerToken is not passed in extra, + // the Authorization header will be "Bearer undefined" which will fail + const request = { + authInfo: { + token: 'mcp-jwt-token', + extra: { + renderingId: '12345', + // forestServerToken is missing! + }, + }, + } as unknown as RequestHandlerExtra; + + await createActivityLog('https://api.forestadmin.com', request, 'index'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.forestadmin.com/api/activity-logs-requests', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer undefined', + }), + }), + ); + }); + + it('should include collection name in relationships when provided', async () => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, 'index', { + collectionName: 'users', + }); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.data.relationships.collection.data).toEqual({ + id: 'users', + type: 'collections', + }); + }); + + it('should set collection data to null when collectionName is not provided', async () => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, 'index'); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.data.relationships.collection.data).toBeNull(); + }); + + it('should include rendering relationship', async () => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, 'index'); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.data.relationships.rendering.data).toEqual({ + id: '12345', + type: 'renderings', + }); + }); + + it('should include label in attributes when provided', async () => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, 'action', { + label: 'Custom Action Label', + }); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.data.attributes.label).toBe('Custom Action Label'); + }); + + it('should include single recordId in records array', async () => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, 'update', { + recordId: 42, + }); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.data.attributes.records).toEqual([42]); + }); + + it('should include multiple recordIds in records array', async () => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, 'delete', { + recordIds: [1, 2, 3], + }); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.data.attributes.records).toEqual([1, 2, 3]); + }); + + it('should prefer recordIds over recordId when both provided', async () => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, 'delete', { + recordId: 99, + recordIds: [1, 2, 3], + }); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.data.attributes.records).toEqual([1, 2, 3]); + }); + + it('should send empty records array when no recordId or recordIds provided', async () => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, 'index'); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.data.attributes.records).toEqual([]); + }); + + it('should include action name in attributes', async () => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, 'search'); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.data.attributes.action).toBe('search'); + }); + + it('should use correct data structure', async () => { + const request = createMockRequest(); + + await createActivityLog('https://api.forestadmin.com', request, 'index', { + collectionName: 'products', + recordId: 1, + label: 'View Product', + }); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody).toEqual({ + data: { + id: 1, + type: 'activity-logs-requests', + attributes: { + type: 'read', + action: 'index', + label: 'View Product', + records: [1], + }, + relationships: { + rendering: { + data: { + id: '12345', + type: 'renderings', + }, + }, + collection: { + data: { + id: 'products', + type: 'collections', + }, + }, + }, + }, + }); + }); + }); + + describe('error handling', () => { + it('should throw error when response is not ok', async () => { + mockFetch.mockResolvedValue({ + ok: false, + text: () => Promise.resolve('Server error message'), + }); + + const request = createMockRequest(); + + await expect( + createActivityLog('https://api.forestadmin.com', request, 'index'), + ).rejects.toThrow('Failed to create activity log: Server error message'); + }); + + it('should not throw when response is ok', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true }), + }); + + const request = createMockRequest(); + + await expect( + createActivityLog('https://api.forestadmin.com', request, 'index'), + ).resolves.not.toThrow(); + }); + }); + + describe('URL construction', () => { + it('should append /api/activity-logs-requests to forest server URL', async () => { + const request = createMockRequest(); + + await createActivityLog('https://custom.forestadmin.com', request, 'index'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://custom.forestadmin.com/api/activity-logs-requests', + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts new file mode 100644 index 0000000000..86d5d0b6aa --- /dev/null +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -0,0 +1,79 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +export default async function createActivityLog( + forestServerUrl: string, + request: RequestHandlerExtra, + action: string, + extra?: { + collectionName?: string; + recordId?: string | number; + recordIds?: string[] | number[]; + label?: string; + }, +) { + const actionToType = { + index: 'read', + search: 'read', + filter: 'read', + listHasMany: 'read', + actionForm: 'read', + action: 'write', + create: 'write', + update: 'write', + delete: 'write', + availableActions: 'read', + availableCollections: 'read', + }; + + if (!actionToType[action]) { + throw new Error(`Unknown action type: ${action}`); + } + + const type = actionToType[action] as 'read' | 'write'; + + const forestServerToken = request.authInfo?.extra?.forestServerToken as string; + const renderingId = request.authInfo?.extra?.renderingId as string; + + const response = await fetch(`${forestServerUrl}/api/activity-logs-requests`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Forest-Application-Source': 'MCP', + Authorization: `Bearer ${forestServerToken}`, + // 'forest-secret-key': process.env.FOREST_ENV_SECRET || '', + }, + body: JSON.stringify({ + data: { + id: 1, + type: 'activity-logs-requests', + attributes: { + type, + action, + label: extra?.label, + records: (extra?.recordIds || (extra?.recordId ? [extra.recordId] : [])) as string[], + }, + relationships: { + rendering: { + data: { + id: renderingId, + type: 'renderings', + }, + }, + collection: { + data: extra?.collectionName + ? { + id: extra.collectionName, + type: 'collections', + } + : null, + }, + }, + }, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to create activity log: ${await response.text()}`); + } +} diff --git a/packages/mcp-server/src/utils/agent-caller.test.ts b/packages/mcp-server/src/utils/agent-caller.test.ts new file mode 100644 index 0000000000..aa08b8ab0a --- /dev/null +++ b/packages/mcp-server/src/utils/agent-caller.test.ts @@ -0,0 +1,145 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import { createRemoteAgentClient } from '@forestadmin-experimental/agent-nodejs-testing'; + +import buildClient from './agent-caller'; + +jest.mock('@forestadmin-experimental/agent-nodejs-testing'); + +const mockCreateRemoteAgentClient = createRemoteAgentClient as jest.MockedFunction< + typeof createRemoteAgentClient +>; + +describe('buildClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should create a remote agent client with the token from authInfo', () => { + const mockRpcClient = { collection: jest.fn() }; + mockCreateRemoteAgentClient.mockReturnValue( + mockRpcClient as unknown as ReturnType, + ); + + const request = { + authInfo: { + token: 'test-auth-token', + extra: { + userId: 123, + renderingId: 456, + environmentId: 789, + projectId: 101, + environmentApiEndpoint: 'http://localhost:3310', + }, + }, + } as unknown as RequestHandlerExtra; + + const result = buildClient(request); + + expect(mockCreateRemoteAgentClient).toHaveBeenCalledWith({ + token: 'test-auth-token', + url: 'http://localhost:3310', + actionEndpoints: {}, + }); + expect(result.rpcClient).toBe(mockRpcClient); + }); + + it('should return authData from request.authInfo.extra', () => { + const mockRpcClient = { collection: jest.fn() }; + mockCreateRemoteAgentClient.mockReturnValue( + mockRpcClient as unknown as ReturnType, + ); + + const request = { + authInfo: { + token: 'test-token', + extra: { + userId: 999, + renderingId: 888, + environmentId: 777, + projectId: 666, + environmentApiEndpoint: 'http://localhost:3310', + }, + }, + } as unknown as RequestHandlerExtra; + + const result = buildClient(request); + + expect(result.authData).toEqual({ + userId: 999, + renderingId: 888, + environmentId: 777, + projectId: 666, + environmentApiEndpoint: 'http://localhost:3310', + }); + }); + + it('should use environmentApiEndpoint from authInfo.extra', () => { + const mockRpcClient = { collection: jest.fn() }; + mockCreateRemoteAgentClient.mockReturnValue( + mockRpcClient as unknown as ReturnType, + ); + + const request = { + authInfo: { + token: 'test-token', + extra: { + environmentApiEndpoint: 'http://custom-agent:4000', + }, + }, + } as unknown as RequestHandlerExtra; + + buildClient(request); + + expect(mockCreateRemoteAgentClient).toHaveBeenCalledWith({ + token: 'test-token', + url: 'http://custom-agent:4000', + actionEndpoints: {}, + }); + }); + + it('should throw error when token is missing', () => { + const request = { + authInfo: { + extra: { + environmentApiEndpoint: 'http://localhost:3310', + }, + }, + } as unknown as RequestHandlerExtra; + + expect(() => buildClient(request)).toThrow('Authentication token is missing'); + }); + + it('should throw error when authInfo is missing', () => { + const request = {} as unknown as RequestHandlerExtra; + + expect(() => buildClient(request)).toThrow('Authentication token is missing'); + }); + + it('should throw error when environmentApiEndpoint is missing', () => { + const request = { + authInfo: { + token: 'test-token', + extra: { + userId: 123, + }, + }, + } as unknown as RequestHandlerExtra; + + expect(() => buildClient(request)).toThrow('Environment API endpoint is missing or invalid'); + }); + + it('should throw error when environmentApiEndpoint is not a string', () => { + const request = { + authInfo: { + token: 'test-token', + extra: { + environmentApiEndpoint: 12345, // number instead of string + }, + }, + } as unknown as RequestHandlerExtra; + + expect(() => buildClient(request)).toThrow('Environment API endpoint is missing or invalid'); + }); +}); diff --git a/packages/mcp-server/src/utils/agent-caller.ts b/packages/mcp-server/src/utils/agent-caller.ts new file mode 100644 index 0000000000..b274d9fc6c --- /dev/null +++ b/packages/mcp-server/src/utils/agent-caller.ts @@ -0,0 +1,36 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import { createRemoteAgentClient } from '@forestadmin-experimental/agent-nodejs-testing'; + +export default function buildClient( + request: RequestHandlerExtra, +) { + const token = request.authInfo?.token; + const url = request.authInfo?.extra?.environmentApiEndpoint; + + if (!token) { + throw new Error('Authentication token is missing'); + } + + if (!url || typeof url !== 'string') { + throw new Error('Environment API endpoint is missing or invalid'); + } + + const rpcClient = createRemoteAgentClient({ + token, + url, + actionEndpoints: {}, + }); + + return { + rpcClient, + authData: request.authInfo?.extra as { + userId: number; + renderingId: number; + environmentId?: number; + projectId?: number; + environmentApiEndpoint: string; + }, + }; +} diff --git a/packages/mcp-server/src/utils/error-parser.test.ts b/packages/mcp-server/src/utils/error-parser.test.ts new file mode 100644 index 0000000000..31e99cd2b7 --- /dev/null +++ b/packages/mcp-server/src/utils/error-parser.test.ts @@ -0,0 +1,142 @@ +import parseAgentError from './error-parser'; + +describe('parseAgentError', () => { + describe('nested error.text structure', () => { + it('should parse error with nested error.text containing JSON:API errors', () => { + const errorPayload = { + error: { + status: 400, + text: JSON.stringify({ + errors: [{ name: 'ValidationError', detail: 'Invalid filters provided' }], + }), + }, + }; + const error = new Error(JSON.stringify(errorPayload)); + + expect(parseAgentError(error)).toBe('Invalid filters provided'); + }); + + it('should return null when error.text has no errors array', () => { + const errorPayload = { + error: { + status: 400, + text: JSON.stringify({ success: false }), + }, + }; + const error = new Error(JSON.stringify(errorPayload)); + + expect(parseAgentError(error)).toBeNull(); + }); + }); + + describe('direct text property', () => { + it('should parse error with direct text property containing JSON:API errors', () => { + const errorPayload = { + text: JSON.stringify({ + errors: [{ name: 'ValidationError', detail: 'Direct text error' }], + }), + }; + const error = new Error(JSON.stringify(errorPayload)); + + expect(parseAgentError(error)).toBe('Direct text error'); + }); + + it('should return null when text has no errors array', () => { + const errorPayload = { + text: JSON.stringify({ success: false }), + }; + const error = new Error(JSON.stringify(errorPayload)); + + expect(parseAgentError(error)).toBeNull(); + }); + }); + + describe('message property fallback', () => { + it('should use message property from parsed JSON when no text field', () => { + const errorPayload = { + message: 'Error message from JSON payload', + }; + const error = new Error(JSON.stringify(errorPayload)); + + expect(parseAgentError(error)).toBe('Error message from JSON payload'); + }); + }); + + describe('plain error message fallback', () => { + it('should fall back to error.message when message is not valid JSON', () => { + const error = new Error('Plain error message'); + + expect(parseAgentError(error)).toBe('Plain error message'); + }); + }); + + describe('error priority', () => { + it('should prioritize error.text over direct text', () => { + const errorPayload = { + error: { + text: JSON.stringify({ + errors: [{ detail: 'From error.text' }], + }), + }, + text: JSON.stringify({ + errors: [{ detail: 'From direct text' }], + }), + }; + const error = new Error(JSON.stringify(errorPayload)); + + expect(parseAgentError(error)).toBe('From error.text'); + }); + + it('should prioritize text over message', () => { + const errorPayload = { + text: JSON.stringify({ + errors: [{ detail: 'From text' }], + }), + message: 'From message', + }; + const error = new Error(JSON.stringify(errorPayload)); + + expect(parseAgentError(error)).toBe('From text'); + }); + }); + + describe('edge cases', () => { + it('should return null for object without message property', () => { + const error = { unknownProperty: 'some value' }; + + expect(parseAgentError(error)).toBeNull(); + }); + + it('should return null for null error', () => { + expect(parseAgentError(null)).toBeNull(); + }); + + it('should return null for undefined error', () => { + expect(parseAgentError(undefined)).toBeNull(); + }); + + it('should handle empty errors array', () => { + const errorPayload = { + error: { + text: JSON.stringify({ errors: [] }), + }, + }; + const error = new Error(JSON.stringify(errorPayload)); + + expect(parseAgentError(error)).toBeNull(); + }); + + it('should handle error without detail property', () => { + const errorPayload = { + error: { + text: JSON.stringify({ + errors: [{ name: 'ValidationError' }], + }), + }, + }; + const error = new Error(JSON.stringify(errorPayload)); + + expect(parseAgentError(error)).toBeNull(); + }); + }); +}); diff --git a/packages/mcp-server/src/utils/error-parser.ts b/packages/mcp-server/src/utils/error-parser.ts new file mode 100644 index 0000000000..0e1732f6f5 --- /dev/null +++ b/packages/mcp-server/src/utils/error-parser.ts @@ -0,0 +1,64 @@ +/** + * Parse JSON:API error text to extract the detail message. + */ +function parseJsonApiErrorText(text: string): string | null { + try { + const parsed = JSON.parse(text) as { + errors?: Array<{ detail?: string; name?: string }>; + }; + + if (parsed.errors?.[0]?.detail) { + return parsed.errors[0].detail; + } + } catch { + // Ignore parsing failures + } + + return null; +} + +/** + * Parse error from the agent RPC client. + * The error structure can vary: + * - Error with message containing JSON: { error: { text: '{"errors":[{"detail":"..."}]}' } } + * - Error with message containing JSON: { text: '{"errors":[{"detail":"..."}]}' } + * - Error with message containing JSON: { message: '...' } + * - Error with plain string message + */ +export default function parseAgentError(error: unknown): string | null { + try { + const err = JSON.parse((error as Error).message) as { + error?: { text?: string }; + text?: string; + message?: string; + }; + + // Try nested error.text first (e.g., { error: { text: '...' } }) + if (err.error?.text) { + const detail = parseJsonApiErrorText(err.error.text); + + if (detail) return detail; + } + + // Try direct text property (e.g., { text: '...' }) + if (err.text) { + const detail = parseJsonApiErrorText(err.text); + + if (detail) return detail; + } + + // Fallback to message property + if (err.message) { + return err.message; + } + + return null; + } catch { + // If parsing fails, try to get message directly + if (error && typeof error === 'object' && 'message' in error) { + return (error as { message: string }).message || null; + } + + return null; + } +} diff --git a/packages/mcp-server/src/utils/schema-fetcher.test.ts b/packages/mcp-server/src/utils/schema-fetcher.test.ts new file mode 100644 index 0000000000..32e517847b --- /dev/null +++ b/packages/mcp-server/src/utils/schema-fetcher.test.ts @@ -0,0 +1,280 @@ +import { + fetchForestSchema, + getCollectionNames, + clearSchemaCache, + setSchemaCache, + type ForestSchema, +} from './schema-fetcher'; + +describe('schema-fetcher', () => { + const originalFetch = global.fetch; + const originalEnv = process.env.FOREST_ENV_SECRET; + let mockFetch: jest.Mock; + + beforeEach(() => { + mockFetch = jest.fn(); + global.fetch = mockFetch; + process.env.FOREST_ENV_SECRET = 'test-env-secret'; + clearSchemaCache(); + }); + + afterAll(() => { + global.fetch = originalFetch; + process.env.FOREST_ENV_SECRET = originalEnv; + }); + + // Helper to create JSON:API formatted schema response + const createJsonApiSchema = ( + collections: Array<{ name: string; fields: Array<{ field: string; type: string }> }>, + ) => ({ + data: collections.map((col, index) => ({ + id: `collection-${index}`, + type: 'collections', + attributes: { + name: col.name, + fields: col.fields, + }, + })), + meta: { + liana: 'forest-express-sequelize', + liana_version: '9.0.0', + liana_features: null, + }, + }); + + describe('fetchForestSchema', () => { + const mockJsonApiResponse = createJsonApiSchema([ + { name: 'users', fields: [{ field: 'id', type: 'Number' }] }, + { name: 'products', fields: [{ field: 'name', type: 'String' }] }, + ]); + + it('should fetch schema from forest server and deserialize JSON:API response', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockJsonApiResponse), + }); + + const result = await fetchForestSchema('https://api.forestadmin.com'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.forestadmin.com/liana/forest-schema', + { + method: 'GET', + headers: { + 'forest-secret-key': 'test-env-secret', + 'Content-Type': 'application/json', + }, + }, + ); + expect(result.collections).toHaveLength(2); + expect(result.collections[0].name).toBe('users'); + expect(result.collections[1].name).toBe('products'); + }); + + it('should use cached schema on subsequent calls', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockJsonApiResponse), + }); + + const result1 = await fetchForestSchema('https://api.forestadmin.com'); + const result2 = await fetchForestSchema('https://api.forestadmin.com'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result1).toEqual(result2); + }); + + it('should refetch schema after cache expires (24 hours)', async () => { + const oldSchema: ForestSchema = { + collections: [{ name: 'old_collection', fields: [] }], + }; + const newJsonApiResponse = createJsonApiSchema([{ name: 'new_collection', fields: [] }]); + + // Set cache with an old timestamp (more than 24 hours ago) + const oneDayAgo = Date.now() - 25 * 60 * 60 * 1000; + setSchemaCache(oldSchema, oneDayAgo); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(newJsonApiResponse), + }); + + const result = await fetchForestSchema('https://api.forestadmin.com'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.collections).toHaveLength(1); + expect(result.collections[0].name).toBe('new_collection'); + }); + + it('should not refetch schema before cache expires', async () => { + const cachedSchema: ForestSchema = { + collections: [{ name: 'cached_collection', fields: [] }], + }; + + // Set cache with a recent timestamp (less than 24 hours ago) + const recentTime = Date.now() - 1 * 60 * 60 * 1000; // 1 hour ago + setSchemaCache(cachedSchema, recentTime); + + const result = await fetchForestSchema('https://api.forestadmin.com'); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toEqual(cachedSchema); + }); + + it('should throw error when FOREST_ENV_SECRET is not set', async () => { + delete process.env.FOREST_ENV_SECRET; + + await expect(fetchForestSchema('https://api.forestadmin.com')).rejects.toThrow( + 'FOREST_ENV_SECRET environment variable is not set', + ); + }); + + it('should throw error when response is not ok', async () => { + mockFetch.mockResolvedValue({ + ok: false, + text: () => Promise.resolve('Server error message'), + }); + + await expect(fetchForestSchema('https://api.forestadmin.com')).rejects.toThrow( + 'Failed to fetch forest schema: Server error message', + ); + }); + + it('should use custom forest server URL', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockJsonApiResponse), + }); + + await fetchForestSchema('https://custom.forestadmin.com'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://custom.forestadmin.com/liana/forest-schema', + expect.any(Object), + ); + }); + }); + + describe('getCollectionNames', () => { + it('should extract collection names from schema', () => { + const schema: ForestSchema = { + collections: [ + { name: 'users', fields: [] }, + { name: 'products', fields: [] }, + { name: 'orders', fields: [] }, + ], + }; + + const result = getCollectionNames(schema); + + expect(result).toEqual(['users', 'products', 'orders']); + }); + + it('should return empty array for empty collections', () => { + const schema: ForestSchema = { + collections: [], + }; + + const result = getCollectionNames(schema); + + expect(result).toEqual([]); + }); + }); + + describe('clearSchemaCache', () => { + // Helper to create JSON:API formatted schema response + const createJsonApiSchema = ( + collections: Array<{ name: string; fields: Array<{ field: string; type: string }> }>, + ) => ({ + data: collections.map((col, index) => ({ + id: `collection-${index}`, + type: 'collections', + attributes: { + name: col.name, + fields: col.fields, + }, + })), + meta: { + liana: 'forest-express-sequelize', + liana_version: '9.0.0', + liana_features: null, + }, + }); + + it('should clear the cache so next fetch makes API call', async () => { + const jsonApiResponse = createJsonApiSchema([{ name: 'test', fields: [] }]); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(jsonApiResponse), + }); + + // First fetch + await fetchForestSchema('https://api.forestadmin.com'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Clear cache + clearSchemaCache(); + + // Second fetch should make API call + await fetchForestSchema('https://api.forestadmin.com'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + + describe('setSchemaCache', () => { + // Helper to create JSON:API formatted schema response + const createJsonApiSchema = ( + collections: Array<{ name: string; fields: Array<{ field: string; type: string }> }>, + ) => ({ + data: collections.map((col, index) => ({ + id: `collection-${index}`, + type: 'collections', + attributes: { + name: col.name, + fields: col.fields, + }, + })), + meta: { + liana: 'forest-express-sequelize', + liana_version: '9.0.0', + liana_features: null, + }, + }); + + it('should set cache with current timestamp by default', async () => { + const schema: ForestSchema = { + collections: [{ name: 'cached', fields: [] }], + }; + + setSchemaCache(schema); + + const result = await fetchForestSchema('https://api.forestadmin.com'); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toEqual(schema); + }); + + it('should set cache with custom timestamp', async () => { + const schema: ForestSchema = { + collections: [{ name: 'old_cached', fields: [] }], + }; + const newJsonApiResponse = createJsonApiSchema([{ name: 'new', fields: [] }]); + + // Set cache with old timestamp + const oldTime = Date.now() - 25 * 60 * 60 * 1000; + setSchemaCache(schema, oldTime); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(newJsonApiResponse), + }); + + const result = await fetchForestSchema('https://api.forestadmin.com'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(result.collections).toHaveLength(1); + expect(result.collections[0].name).toBe('new'); + }); + }); +}); diff --git a/packages/mcp-server/src/utils/schema-fetcher.ts b/packages/mcp-server/src/utils/schema-fetcher.ts new file mode 100644 index 0000000000..f386f13818 --- /dev/null +++ b/packages/mcp-server/src/utils/schema-fetcher.ts @@ -0,0 +1,139 @@ +import JsonApiSerializer from 'jsonapi-serializer'; + +/** + * Schema Fetcher Utility + * + * Fetches the Forest Admin schema from the `/liana/forest-schema` endpoint + * and caches it for 24 hours. + */ + +export interface ForestField { + field: string; + type: string; + isFilterable?: boolean; + isSortable?: boolean; + enum: string[] | null; + inverseOf?: string | null; + reference: string | null; + isReadOnly: boolean; + isRequired: boolean; + integration?: string | null; + validations?: unknown[]; + defaultValue?: unknown; + isPrimaryKey: boolean; +} + +export interface ForestCollection { + name: string; + fields: ForestField[]; +} + +export interface ForestSchema { + collections: ForestCollection[]; +} + +interface JSONAPIItem { + id: string; + type: string; + attributes: Record; + relationships: Record; +} + +interface SchemaCache { + schema: ForestSchema; + fetchedAt: number; +} + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +let schemaCache: SchemaCache | null = null; + +/** + * Fetches the Forest Admin schema from the server. + * The schema is cached for 24 hours to reduce API calls. + * + * @param forestServerUrl - The Forest Admin server URL + * @returns The Forest Admin schema containing collections + */ +export async function fetchForestSchema(forestServerUrl: string): Promise { + const now = Date.now(); + + // Return cached schema if it's still valid (less than 24 hours old) + if (schemaCache && now - schemaCache.fetchedAt < ONE_DAY_MS) { + return schemaCache.schema; + } + + const envSecret = process.env.FOREST_ENV_SECRET; + + if (!envSecret) { + throw new Error('FOREST_ENV_SECRET environment variable is not set'); + } + + const response = await fetch(`${forestServerUrl}/liana/forest-schema`, { + method: 'GET', + headers: { + 'forest-secret-key': envSecret, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to fetch forest schema: ${errorText}`); + } + + const schema = (await response.json()) as { + data: JSONAPIItem[]; + included?: JSONAPIItem[]; + meta: { liana: string; liana_version: string; liana_features: string[] | null }; + }; + const serializer = new JsonApiSerializer.Deserializer({ + keyForAttribute: 'camelCase', + }); + const collections = (await serializer.deserialize(schema)) as ForestCollection[]; + + // Update cache + schemaCache = { + schema: { collections }, + fetchedAt: now, + }; + + return { collections }; +} + +/** + * Extracts collection names from the Forest Admin schema. + * + * @param schema - The Forest Admin schema + * @returns Array of collection names + */ +export function getCollectionNames(schema: ForestSchema): string[] { + return schema.collections.map(collection => collection.name); +} + +export function getFieldsOfCollection(schema: ForestSchema, collectionName: string): ForestField[] { + const collection = schema.collections.find(col => col.name === collectionName); + + if (!collection) { + throw new Error(`Collection "${collectionName}" not found in schema`); + } + + return collection.fields; +} + +/** + * Clears the schema cache. Useful for testing. + */ +export function clearSchemaCache(): void { + schemaCache = null; +} + +/** + * Sets the schema cache. Useful for testing. + */ +export function setSchemaCache(schema: ForestSchema, fetchedAt?: number): void { + schemaCache = { + schema, + fetchedAt: fetchedAt ?? Date.now(), + }; +} diff --git a/packages/mcp-server/src/version.ts b/packages/mcp-server/src/version.ts new file mode 100644 index 0000000000..55d8ae2adf --- /dev/null +++ b/packages/mcp-server/src/version.ts @@ -0,0 +1,8 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const packageJsonPath = path.resolve(__dirname, '..', 'package.json'); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + +export const VERSION: string = packageJson.version; +export const NAME: string = packageJson.name; diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 0000000000..e0d66374ae --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index d1ce518a4d..03148016f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1794,6 +1794,19 @@ path-to-regexp "^6.3.0" reusify "^1.0.4" +"@forestadmin-experimental/agent-nodejs-testing@^0.36.0": + version "0.36.0" + resolved "https://registry.yarnpkg.com/@forestadmin-experimental/agent-nodejs-testing/-/agent-nodejs-testing-0.36.0.tgz#7b0358c1145e07c0cc4d8e2dce49da98e1dd95a8" + integrity sha512-SJUVeNstVBounzNe0+ic1J5LBlVdZpYCLpekvdj6Lf3ND5wEPnM1YpIoSZewPZNJ04z2kdbxTfPINHsWltePHw== + dependencies: + "@forestadmin/agent" "1.66.0" + "@forestadmin/datasource-customizer" "1.67.1" + "@forestadmin/datasource-toolkit" "1.50.0" + "@forestadmin/forestadmin-client" "1.36.14" + jsonapi-serializer "^3.5.1" + jsonwebtoken "^9.0.2" + superagent "^8.1.2" + "@forestadmin/agent@1.65.1": version "1.65.1" resolved "https://registry.yarnpkg.com/@forestadmin/agent/-/agent-1.65.1.tgz#64f37ac6a85eeef1f585964e1c861773306a9d7d" @@ -2643,7 +2656,7 @@ zod "^3.23.8" zod-to-json-schema "^3.24.1" -"@modelcontextprotocol/sdk@^1.18.2": +"@modelcontextprotocol/sdk@^1.0.4", "@modelcontextprotocol/sdk@^1.18.2": version "1.24.3" resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.24.3.tgz#81a3fcc919cb4ce8630e2bcecf59759176eb331a" integrity sha512-YgSHW29fuzKKAHTGe9zjNoo+yF8KaQPzDC2W9Pv41E7/57IfY+AMGJ/aDFlgTLcVVELoggKE4syABCE75u3NCw== @@ -4817,6 +4830,13 @@ "@types/keygrip" "*" "@types/node" "*" +"@types/cors@^2.8.17": + version "2.8.19" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342" + integrity sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg== + dependencies: + "@types/node" "*" + "@types/debug@^4.1.8": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -4876,6 +4896,16 @@ "@types/express-serve-static-core" "^5.0.0" "@types/serve-static" "*" +"@types/express@^4.17.21": + version "4.17.25" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.25.tgz#070c8c73a6fee6936d65c195dbbfb7da5026649b" + integrity sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "^1" + "@types/geojson@^7946.0.10": version "7946.0.13" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.13.tgz#e6e77ea9ecf36564980a861e24e62a095988775e" @@ -5171,6 +5201,14 @@ "@types/mime" "^1" "@types/node" "*" +"@types/send@<1": + version "0.17.6" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.6.tgz#aeb5385be62ff58a52cd5459daa509ae91651d25" + integrity sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + "@types/serve-static@*": version "1.15.5" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.5.tgz#15e67500ec40789a1e8c9defc2d32a896f05b033" @@ -5180,6 +5218,15 @@ "@types/mime" "*" "@types/node" "*" +"@types/serve-static@^1": + version "1.15.10" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.10.tgz#768169145a778f8f5dfcb6360aead414a3994fee" + integrity sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw== + dependencies: + "@types/http-errors" "*" + "@types/node" "*" + "@types/send" "<1" + "@types/ssh2@^1.11.11": version "1.11.16" resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.11.16.tgz#a57c8e07dfd1d446ed73127764273873d1f0d7ca" @@ -5192,7 +5239,7 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== -"@types/superagent@^8.1.9": +"@types/superagent@^8.1.0", "@types/superagent@^8.1.9": version "8.1.9" resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-8.1.9.tgz#28bfe4658e469838ed0bf66d898354bcab21f49f" integrity sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ== @@ -5202,6 +5249,14 @@ "@types/node" "*" form-data "^4.0.0" +"@types/supertest@^6.0.2": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-6.0.3.tgz#d736f0e994b195b63e1c93e80271a2faf927388c" + integrity sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w== + dependencies: + "@types/methods" "^1.1.4" + "@types/superagent" "^8.1.0" + "@types/unist@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" @@ -6278,7 +6333,7 @@ bson@^6.10.3: resolved "https://registry.yarnpkg.com/bson/-/bson-6.10.3.tgz#5f9a463af6b83e264bedd08b236d1356a30eda47" integrity sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ== -buffer-equal-constant-time@1.0.1: +buffer-equal-constant-time@1.0.1, buffer-equal-constant-time@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== @@ -11281,6 +11336,14 @@ jsonapi-serializer@3.6.2: inflected "^1.1.6" lodash "^4.16.3" +jsonapi-serializer@^3.5.1, jsonapi-serializer@^3.6.9: + version "3.6.9" + resolved "https://registry.yarnpkg.com/jsonapi-serializer/-/jsonapi-serializer-3.6.9.tgz#a2ea0b53a24cf4bb7659232406ed8caa2423e9b2" + integrity sha512-LeRPlP93Mz6+Klu13OKcnXNLvtH1gbeo/yfThqihAMw7vUBCWWs6jHImpR/tQwzAxJi7F1+bfVJxeHoNCrbZiQ== + dependencies: + inflected "^1.1.6" + lodash "^4.16.3" + jsonc-parser@3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76" @@ -11321,6 +11384,22 @@ jsonwebtoken@9.0.2, jsonwebtoken@^9.0.0: ms "^2.1.1" semver "^7.5.4" +jsonwebtoken@^9.0.2: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + dependencies: + jws "^4.0.1" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + jszip@^3.10.0: version "3.10.1" resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" @@ -11359,6 +11438,15 @@ jwa@^2.0.0: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + jws@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" @@ -11375,6 +11463,14 @@ jws@^4.0.0: jwa "^2.0.0" safe-buffer "^5.0.1" +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + jwt-decode@2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79" @@ -16407,7 +16503,7 @@ super-regex@^1.0.0: function-timeout "^1.0.1" time-span "^5.1.0" -superagent@8.1.2: +superagent@8.1.2, superagent@^8.1.2: version "8.1.2" resolved "https://registry.yarnpkg.com/superagent/-/superagent-8.1.2.tgz#03cb7da3ec8b32472c9d20f6c2a57c7f3765f30b" integrity sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA== @@ -16438,6 +16534,14 @@ superagent@^10.2.3: mime "2.6.0" qs "^6.11.2" +supertest@^7.1.3: + version "7.1.4" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-7.1.4.tgz#3175e2539f517ca72fdc7992ffff35b94aca7d34" + integrity sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg== + dependencies: + methods "^1.1.2" + superagent "^10.2.3" + supports-color@^10.2.2: version "10.2.2" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-10.2.2.tgz#466c2978cc5cd0052d542a0b576461c2b802ebb4" @@ -17137,6 +17241,11 @@ typescript@^4.5.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +typescript@^5.0.0: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + typescript@^5.6.3: version "5.9.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6" @@ -17893,7 +18002,7 @@ zod@^3.23.8, zod@^3.25.32: resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== -"zod@^3.25 || ^4.0", "zod@^3.25.76 || ^4": +"zod@^3.25 || ^4.0", "zod@^3.25.76 || ^4", zod@^4.1.13: version "4.1.13" resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.13.tgz#93699a8afe937ba96badbb0ce8be6033c0a4b6b1" integrity sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==