From 65c72a3e8a1f4ff7b78888fb0fbe68208d567d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Molini=C3=A9?= Date: Mon, 1 Dec 2025 12:51:36 +0100 Subject: [PATCH 1/3] feat(mcp): introduce empty mcp server (#1338) --- .eslintrc.js | 7 + packages/mcp-server/README.md | 118 +++++++++++++ packages/mcp-server/jest.config.ts | 8 + packages/mcp-server/package.json | 44 +++++ packages/mcp-server/src/index.ts | 11 ++ packages/mcp-server/src/server.test.ts | 206 +++++++++++++++++++++++ packages/mcp-server/src/server.ts | 113 +++++++++++++ packages/mcp-server/tsconfig.eslint.json | 4 + packages/mcp-server/tsconfig.json | 10 ++ yarn.lock | 167 +++++++++++++++++- 10 files changed, 679 insertions(+), 9 deletions(-) create mode 100644 packages/mcp-server/README.md create mode 100644 packages/mcp-server/jest.config.ts create mode 100644 packages/mcp-server/package.json create mode 100644 packages/mcp-server/src/index.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/tsconfig.eslint.json create mode 100644 packages/mcp-server/tsconfig.json diff --git a/.eslintrc.js b/.eslintrc.js index 37c89694ba..06a1e2b071 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -115,5 +115,12 @@ module.exports = { ], }, })), + { + // ESM package requiring .js extensions for imports + files: ['packages/mcp-server/**/*'], + rules: { + 'import/extensions': ['error', 'ignorePackages'], + }, + }, ], }; diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 0000000000..261be58543 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,118 @@ +# @forestadmin/mcp-server + +Model Context Protocol (MCP) server for Forest Admin with OAuth authentication support. + +## Overview + +This MCP server provides HTTP REST API access to Forest Admin operations, enabling AI assistants and other MCP clients to interact with your Forest Admin data through a standardized protocol. + +## Installation + +```bash +npm install @forestadmin/mcp-server +``` + +## Usage + +### Running the Server + +You can start the server using the CLI: + +```bash +npx forest-mcp-server +``` + +Or programmatically: + +```bash +node dist/index.js +``` + +### Environment Variables + +The following environment variables are required to run the server: + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `FOREST_ENV_SECRET` | **Yes** | - | Your Forest Admin environment secret | +| `FOREST_AUTH_SECRET` | **Yes** | - | Your Forest Admin authentication secret (must match your agent) | +| `FOREST_SERVER_URL` | No | `https://api.forestadmin.com` | Forest Admin server URL | +| `MCP_SERVER_PORT` | No | `3931` | Port for the HTTP server | + +### Example Configuration + +```bash +export FOREST_ENV_SECRET="your-env-secret" +export FOREST_AUTH_SECRET="your-auth-secret" +export MCP_SERVER_PORT=3931 + +npx forest-mcp-server +``` + +## API Endpoint + +Once running, the MCP server exposes a single endpoint: + +- **POST** `/mcp` - Main MCP protocol endpoint + +The server expects MCP protocol messages in the request body and returns MCP-formatted responses. + +## Features + +- **HTTP Transport**: Uses streamable HTTP transport for MCP communication +- **OAuth Authentication**: Built-in support for Forest Admin OAuth +- **CORS Enabled**: Allows cross-origin requests +- **Express-based**: Built on top of Express.js for reliability and extensibility + +## Development + +### Building + +```bash +npm run build +``` + +### Watch Mode + +```bash +npm run build:watch +``` + +### Linting + +```bash +npm run lint +``` + +### Testing + +```bash +npm test +``` + +### Cleaning + +```bash +npm run clean +``` + +## Architecture + +The server consists of: + +- **ForestAdminMCPServer**: Main server class managing the MCP server lifecycle +- **McpServer**: Core MCP protocol implementation +- **StreamableHTTPServerTransport**: HTTP transport layer for MCP +- **Express App**: HTTP server handling incoming requests + +## License + +GPL-3.0 + +## Repository + +[https://github.com/ForestAdmin/agent-nodejs](https://github.com/ForestAdmin/agent-nodejs) + +## Support + +For issues and feature requests, please visit the [GitHub repository](https://github.com/ForestAdmin/agent-nodejs/tree/main/packages/mcp-server). diff --git a/packages/mcp-server/jest.config.ts b/packages/mcp-server/jest.config.ts new file mode 100644 index 0000000000..444602c035 --- /dev/null +++ b/packages/mcp-server/jest.config.ts @@ -0,0 +1,8 @@ +/* eslint-disable import/no-relative-packages */ +import jestConfig from '../../jest.config'; + +export default { + ...jestConfig, + collectCoverageFrom: ['/src/**/*.ts', '!/src/**/*.test.ts'], + testMatch: ['/src/**/*.test.ts'], +}; diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 0000000000..8bbf43a71a --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,44 @@ +{ + "name": "@forestadmin/mcp-server", + "version": "0.1.0", + "description": "Model Context Protocol server for Forest Admin with OAuth authentication", + "type": "module", + "main": "dist/index.js", + "bin": { + "forest-mcp-server": "dist/index.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": { + "@modelcontextprotocol/sdk": "^1.0.4", + "cors": "^2.8.5", + "express": "^4.18.2" + }, + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts" + ], + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "start": "node dist/index.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/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 0000000000..58c718c34d --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import ForestAdminMCPServer from './server.js'; + +// Start the server +const server = new ForestAdminMCPServer(); + +server.run().catch(error => { + console.error('[FATAL] Server crashed:', error); + process.exit(1); +}); diff --git a/packages/mcp-server/src/server.test.ts b/packages/mcp-server/src/server.test.ts new file mode 100644 index 0000000000..d7103d1454 --- /dev/null +++ b/packages/mcp-server/src/server.test.ts @@ -0,0 +1,206 @@ +import type * as http from 'http'; + +import request from 'supertest'; + +import ForestAdminMCPServer from './server'; + +function shutDownHttpServer(server: http.Server | undefined): Promise { + if (!server) return Promise.resolve(); + + return new Promise((resolve, reject) => { + server.close(err => { + if (err) { + return reject(err); + } + + resolve(); + }); + }); +} + +/** + * Integration tests for ForestAdminMCPServer instance + * Tests the actual server class and its behavior + */ +describe('ForestAdminMCPServer Instance', () => { + let server: ForestAdminMCPServer; + let originalEnv: NodeJS.ProcessEnv; + let modifiedEnv: NodeJS.ProcessEnv; + + 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'; + }); + + afterAll(async () => { + process.env = originalEnv; + }); + + beforeEach(() => { + modifiedEnv = { ...process.env }; + }); + + afterEach(async () => { + process.env = modifiedEnv; + }); + + describe('constructor', () => { + it('should create server instance', () => { + server = new ForestAdminMCPServer(); + + expect(server).toBeDefined(); + expect(server).toBeInstanceOf(ForestAdminMCPServer); + }); + + it('should initialize with FOREST_SERVER_URL', () => { + process.env.FOREST_SERVER_URL = 'https://custom.forestadmin.com'; + server = new ForestAdminMCPServer(); + + 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 ForestAdminMCPServer(); + + 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 ForestAdminMCPServer(); + + expect(server.forestServerUrl).toBe('https://api.forestadmin.com'); + }); + + it('should create MCP server instance', () => { + server = new ForestAdminMCPServer(); + + 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 ForestAdminMCPServer(); + + await expect(server.run()).rejects.toThrow( + 'FOREST_ENV_SECRET environment variable is not set', + ); + }); + + it('should throw error when FOREST_AUTH_SECRET is missing', async () => { + delete process.env.FOREST_AUTH_SECRET; + server = new ForestAdminMCPServer(); + + await expect(server.run()).rejects.toThrow( + 'FOREST_AUTH_SECRET environment variable is not set', + ); + }); + }); + + 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 ForestAdminMCPServer(); + + // 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 ForestAdminMCPServer(); + 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 ForestAdminMCPServer(); + 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); + }); + }); +}); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 0000000000..f63fd6ad2d --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,113 @@ +import { allowedMethods } from '@modelcontextprotocol/sdk/server/auth/middleware/allowedMethods.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import cors from 'cors'; +import express from 'express'; +import * as http from 'http'; + +/** + * Forest Admin MCP Server + * + * This server provides HTTP REST API access to Forest Admin operations + * with OAuth authentication support. + * + * Environment Variables: + * - 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 ForestAdminMCPServer { + public mcpServer: McpServer; + public mcpTransport?: StreamableHTTPServerTransport; + public httpServer?: http.Server; + public forestServerUrl: string; + + constructor() { + this.forestServerUrl = + process.env.FOREST_SERVER_URL || process.env.FOREST_URL || 'https://api.forestadmin.com'; + + // Create MCP Server + this.mcpServer = new McpServer({ + name: '@forestadmin/mcp-server', + version: '0.1.0', + }); + + // Register tools with MCP Server + this.setupTools(); + } + + private setupTools(): void { + // FIXME: To implement + } + + private ensureEnvironmentVariablesAreSet(): void { + if (!process.env.FOREST_ENV_SECRET) { + throw new Error('FOREST_ENV_SECRET environment variable is not set'); + } + + if (!process.env.FOREST_AUTH_SECRET) { + throw new Error('FOREST_AUTH_SECRET environment variable is not set'); + } + } + + async run(): Promise { + this.ensureEnvironmentVariablesAreSet(); + + const port = Number(process.env.MCP_SERVER_PORT) || 3931; + + this.mcpTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await this.mcpServer.connect(this.mcpTransport); + + const app = express(); + + app.use( + cors({ + origin: '*', + }), + ); + + // Body parsers must come after OAuth router to avoid interfering with its parsing + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + app.use(allowedMethods(['POST'])); + + app.post('/mcp', (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) { + console.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, + }); + } + } + })(); + }); + + // Create HTTP server from Express app + this.httpServer = http.createServer(app); + + this.httpServer.listen(port, () => { + console.info(`[INFO] Forest Admin MCP Server running on http://localhost:${port}`); + }); + } +} diff --git a/packages/mcp-server/tsconfig.eslint.json b/packages/mcp-server/tsconfig.eslint.json new file mode 100644 index 0000000000..30fa406dcf --- /dev/null +++ b/packages/mcp-server/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "test/**/*"] +} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 0000000000..5c33a11c09 --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "module": "ES2022", + "moduleResolution": "node", + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 4c7bddf8ab..e82b58be96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2489,6 +2489,25 @@ semver "^7.3.5" tar "^6.1.11" +"@modelcontextprotocol/sdk@^1.0.4": + version "1.23.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.23.0.tgz#692fc54856bc8881c202c97bad103da44efe967f" + integrity sha512-MCGd4K9aZKvuSqdoBkdMvZNcYXCkZRYVs/Gh92mdV5IHbctX9H9uIvd4X93+9g8tBbXv08sxc/QHXTzf8y65bA== + 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" + 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" @@ -4555,6 +4574,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" @@ -4595,6 +4621,16 @@ "@types/qs" "*" "@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" @@ -4863,6 +4899,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" @@ -4872,6 +4916,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" @@ -4884,7 +4937,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== @@ -4894,6 +4947,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@^2", "@types/unist@^2.0.0", "@types/unist@^2.0.2": version "2.0.10" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.10.tgz#04ffa7f406ab628f7f7e97ca23e290cd8ab15efc" @@ -5214,6 +5275,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" @@ -5998,7 +6069,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== @@ -6871,7 +6942,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== @@ -6946,7 +7017,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== @@ -7950,11 +8021,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" @@ -8084,7 +8167,12 @@ 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: +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@^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== @@ -8278,6 +8366,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" @@ -9418,6 +9511,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" @@ -9527,6 +9631,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" @@ -9645,7 +9756,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== @@ -14040,6 +14151,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" @@ -14406,6 +14522,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" @@ -15087,7 +15213,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== @@ -15546,7 +15672,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== @@ -15839,6 +15965,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" @@ -16114,7 +16248,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== @@ -16532,6 +16666,11 @@ typescript@^4.5.4, typescript@^4.9.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" @@ -17263,6 +17402,16 @@ 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.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.25 || ^4.0": + 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 bc6bfa87cf1996e9309eb4813293fac6711da321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Molini=C3=A9?= Date: Wed, 3 Dec 2025 09:54:01 +0100 Subject: [PATCH 2/3] feat(forest mcp): add oauth metadata discovery (#1339) --- packages/mcp-server/jest.config.ts | 4 + .../mcp-server/src/forest-oauth-provider.ts | 109 ++++++++++++++++++ packages/mcp-server/src/server.test.ts | 61 ++++++++-- packages/mcp-server/src/server.ts | 48 ++++++++ 4 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 packages/mcp-server/src/forest-oauth-provider.ts diff --git a/packages/mcp-server/jest.config.ts b/packages/mcp-server/jest.config.ts index 444602c035..878b5a379c 100644 --- a/packages/mcp-server/jest.config.ts +++ b/packages/mcp-server/jest.config.ts @@ -5,4 +5,8 @@ export default { ...jestConfig, collectCoverageFrom: ['/src/**/*.ts', '!/src/**/*.test.ts'], testMatch: ['/src/**/*.test.ts'], + // Map .js imports to .ts files for ESM compatibility + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, }; 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..b30584e7d9 --- /dev/null +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -0,0 +1,109 @@ +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'; + +/** + * OAuth Server Provider that integrates with Forest Admin authentication + */ +export default class ForestAdminOAuthProvider implements OAuthServerProvider { + private forestServerUrl: string; + + constructor({ forestServerUrl }: { forestServerUrl: string }) { + this.forestServerUrl = forestServerUrl; + } + + async initialize(): Promise { + // FIXME: Fetch environmentId on startup if needed + } + + get clientsStore(): OAuthRegisteredClientsStore { + return { + getClient: (clientId: string) => { + // FIXME: To implement + return clientId && null; + }, + }; + } + + async authorize( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response, + ): Promise { + // FIXME: To implement + res.sendStatus(501); + } + + 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 { + // FIXME: To implement the exchange with Forest Admin server + + return { + access_token: redirectUri && 'Fake token', + token_type: 'Bearer', + expires_in: 3600, + // refresh_token: refreshToken, + scope: 'mcp:read', + }; + } + + async exchangeRefreshToken( + client: OAuthClientInformationFull, + refreshToken: string, + scopes?: string[], + ): Promise { + // Generate new access token + // FIXME: To implement the exchange with Forest Admin server + return { + access_token: 'Fake token', + token_type: 'Bearer', + expires_in: 3600, + scope: scopes?.join(' ') || 'mcp:read', + }; + } + + async verifyAccessToken(token: string): Promise { + // FIXME: To implement the verification with Forest Admin server + + return { + token, + clientId: 'fake client id', + expiresAt: 136472874, + scopes: ['mcp:read'], + }; + } + + async revokeToken( + client: OAuthClientInformationFull, + request: OAuthTokenRevocationRequest, + ): Promise { + // FIXME: To implement the revocation with Forest Admin server + if (request) { + // Remove this if + } + } + + // Skip PKCE validation to match original implementation + skipLocalPkceValidation = true; +} diff --git a/packages/mcp-server/src/server.test.ts b/packages/mcp-server/src/server.test.ts index d7103d1454..7135d12538 100644 --- a/packages/mcp-server/src/server.test.ts +++ b/packages/mcp-server/src/server.test.ts @@ -2,17 +2,13 @@ import type * as http from 'http'; import request from 'supertest'; -import ForestAdminMCPServer from './server'; +import ForestAdminMCPServer from './server.js'; function shutDownHttpServer(server: http.Server | undefined): Promise { if (!server) return Promise.resolve(); - return new Promise((resolve, reject) => { - server.close(err => { - if (err) { - return reject(err); - } - + return new Promise(resolve => { + server.close(() => { resolve(); }); }); @@ -202,5 +198,56 @@ describe('ForestAdminMCPServer Instance', () => { // 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']); + 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 ForestAdminMCPServer(); + 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', + ); + }); + }); }); }); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index f63fd6ad2d..8f2c79b9ee 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -1,10 +1,18 @@ +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 { + 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 from 'express'; import * as http from 'http'; +import ForestAdminOAuthProvider from './forest-oauth-provider.js'; + /** * Forest Admin MCP Server * @@ -56,6 +64,7 @@ export default class ForestAdminMCPServer { this.ensureEnvironmentVariablesAreSet(); const port = Number(process.env.MCP_SERVER_PORT) || 3931; + const baseUrl = new URL(`http://localhost:${port}`); this.mcpTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, @@ -70,6 +79,45 @@ export default class ForestAdminMCPServer { }), ); + // Initialize OAuth provider + const oauthProvider = new ForestAdminOAuthProvider({ forestServerUrl: this.forestServerUrl }); + await oauthProvider.initialize(); + + 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: baseUrl, + baseUrl, + scopesSupported, + }); + + oauthMetadata.grant_types_supported = ['authorization_code']; + oauthMetadata.token_endpoint_auth_methods_supported = ['none']; + oauthMetadata.response_types_supported = ['code']; + oauthMetadata.code_challenge_methods_supported = ['S256']; + + oauthMetadata.token_endpoint = `${baseUrl.href}oauth/token`; + oauthMetadata.authorization_endpoint = `${baseUrl.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; + + // Mount authorization and token handlers + app.use('/oauth/authorize', authorizationHandler({ provider: oauthProvider })); + app.use('/oauth/token', tokenHandler({ provider: oauthProvider })); + + // Mount metadata router with custom metadata + app.use( + mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: baseUrl, + scopesSupported, + }), + ); + // Body parsers must come after OAuth router to avoid interfering with its parsing app.use(express.json()); app.use(express.urlencoded({ extended: true })); From 0a1b5e38091ecfbe30def575724c49eff0fd08d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Molini=C3=A9?= Date: Thu, 4 Dec 2025 15:12:52 +0100 Subject: [PATCH 3/3] feat(forest mcp): add the authorize handler (#1341) --- packages/mcp-server/README.md | 1 - .../src/forest-oauth-provider.test.ts | 321 ++++++++++++++++++ .../mcp-server/src/forest-oauth-provider.ts | 80 ++++- packages/mcp-server/src/server.test.ts | 139 ++++++++ .../mcp-server/src/test-utils/mock-server.ts | 135 ++++++++ 5 files changed, 669 insertions(+), 7 deletions(-) create mode 100644 packages/mcp-server/src/forest-oauth-provider.test.ts create mode 100644 packages/mcp-server/src/test-utils/mock-server.ts diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 261be58543..d49951c373 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -36,7 +36,6 @@ The following environment variables are required to run the server: |----------|----------|---------|-------------| | `FOREST_ENV_SECRET` | **Yes** | - | Your Forest Admin environment secret | | `FOREST_AUTH_SECRET` | **Yes** | - | Your Forest Admin authentication secret (must match your agent) | -| `FOREST_SERVER_URL` | No | `https://api.forestadmin.com` | Forest Admin server URL | | `MCP_SERVER_PORT` | No | `3931` | Port for the HTTP server | ### Example Configuration 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..d2a8aca8bc --- /dev/null +++ b/packages/mcp-server/src/forest-oauth-provider.test.ts @@ -0,0 +1,321 @@ +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { Response } from 'express'; + +import ForestAdminOAuthProvider from './forest-oauth-provider.js'; +import MockServer from './test-utils/mock-server.js'; + +describe('ForestAdminOAuthProvider', () => { + 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 = new ForestAdminOAuthProvider({ + forestServerUrl: 'https://custom.forestadmin.com', + }); + + expect(customProvider).toBeDefined(); + }); + }); + + describe('initialize', () => { + it('should not throw when FOREST_ENV_SECRET is missing', async () => { + delete process.env.FOREST_ENV_SECRET; + const customProvider = new ForestAdminOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + }); + + await expect(customProvider.initialize()).resolves.not.toThrow(); + }); + + it('should fetch environmentId from Forest Admin API', async () => { + mockServer.get('/liana/environment', { data: { id: '98765' } }); + global.fetch = mockServer.fetch; + + const testProvider = new ForestAdminOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + }); + + 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' } }); + global.fetch = mockServer.fetch; + + const testProvider = new ForestAdminOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + }); + + 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 consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const testProvider = new ForestAdminOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + }); + + await testProvider.initialize(); + + expect(consoleSpy).toHaveBeenCalledWith( + '[WARN] Failed to fetch environmentId from Forest Admin API:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('should handle fetch network errors gracefully', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const testProvider = new ForestAdminOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + }); + + await testProvider.initialize(); + + expect(consoleSpy).toHaveBeenCalledWith( + '[WARN] Failed to fetch environmentId from Forest Admin API:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('should use correct forest server URL for API call', async () => { + mockServer.get('/liana/environment', { data: { id: '11111' } }); + global.fetch = mockServer.fetch; + + const testProvider = new ForestAdminOAuthProvider({ + forestServerUrl: '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 = new ForestAdminOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + }); + + 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 = new ForestAdminOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + }); + + 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 = new ForestAdminOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + }); + + const client = await provider.clientsStore.getClient('error-client'); + + expect(client).toBeUndefined(); + }); + }); + + describe('authorize', () => { + let mockResponse: Partial; + let mockClient: OAuthClientInformationFull; + let initializedProvider: ForestAdminOAuthProvider; + + 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 = new ForestAdminOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + }); + + // Mock fetch to return a valid response + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { id: '12345' } }), + }); + 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 = new ForestAdminOAuthProvider({ + forestServerUrl: 'https://api.forestadmin.com', + }); + + 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'); + }); + }); +}); diff --git a/packages/mcp-server/src/forest-oauth-provider.ts b/packages/mcp-server/src/forest-oauth-provider.ts index b30584e7d9..ab762816b1 100644 --- a/packages/mcp-server/src/forest-oauth-provider.ts +++ b/packages/mcp-server/src/forest-oauth-provider.ts @@ -16,20 +16,63 @@ import type { Response } from 'express'; */ export default class ForestAdminOAuthProvider implements OAuthServerProvider { private forestServerUrl: string; + private environmentId?: number; constructor({ forestServerUrl }: { forestServerUrl: string }) { this.forestServerUrl = forestServerUrl; } async initialize(): Promise { - // FIXME: Fetch environmentId on startup if needed + await this.fetchEnvironmentId(); + } + + private async fetchEnvironmentId(): Promise { + try { + const envSecret = process.env.FOREST_ENV_SECRET; + + if (!envSecret) { + return; + } + + // Call Forest Admin API to get environment information + const response = await fetch(`${this.forestServerUrl}/liana/environment`, { + method: 'GET', + headers: { + 'forest-secret-key': envSecret, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch environment: ${response.statusText}`); + } + + const data = (await response.json()) as unknown as { data: { id: string } }; + + this.environmentId = parseInt(data.data.id, 10); + } catch (error) { + console.error('[WARN] Failed to fetch environmentId from Forest Admin API:', error); + } } get clientsStore(): OAuthRegisteredClientsStore { return { - getClient: (clientId: string) => { - // FIXME: To implement - return clientId && null; + 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', + }, + }); + + // Return undefined if client is not found + if (!response.ok) { + return undefined; + } + + // Return registered client if exists + return response.json(); }, }; } @@ -39,8 +82,33 @@ export default class ForestAdminOAuthProvider implements OAuthServerProvider { params: AuthorizationParams, res: Response, ): Promise { - // FIXME: To implement - res.sendStatus(501); + try { + // Redirect 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('+')); + agentAuthUrl.searchParams.set('resource', params.resource?.href); + agentAuthUrl.searchParams.set('environmentId', this.environmentId.toString()); + + res.redirect(agentAuthUrl.toString()); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + res.redirect( + `${params.redirectUri}?error=server_error&error_description=${encodeURIComponent( + errorMessage, + )}`, + ); + } } async challengeForAuthorizationCode( diff --git a/packages/mcp-server/src/server.test.ts b/packages/mcp-server/src/server.test.ts index 7135d12538..abf31b0dee 100644 --- a/packages/mcp-server/src/server.test.ts +++ b/packages/mcp-server/src/server.test.ts @@ -3,6 +3,7 @@ import type * as http from 'http'; import request from 'supertest'; import ForestAdminMCPServer from './server.js'; +import MockServer from './test-utils/mock-server.js'; function shutDownHttpServer(server: http.Server | undefined): Promise { if (!server) return Promise.resolve(); @@ -22,6 +23,8 @@ describe('ForestAdminMCPServer Instance', () => { let server: ForestAdminMCPServer; let originalEnv: NodeJS.ProcessEnv; let modifiedEnv: NodeJS.ProcessEnv; + let mockServer: MockServer; + const originalFetch = global.fetch; beforeAll(() => { originalEnv = { ...process.env }; @@ -29,14 +32,29 @@ describe('ForestAdminMCPServer Instance', () => { 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' } }) + .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 () => { @@ -249,5 +267,126 @@ describe('ForestAdminMCPServer Instance', () => { ); }); }); + + 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`, + ); + }); + }); }); }); 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..57638aa751 --- /dev/null +++ b/packages/mcp-server/src/test-utils/mock-server.ts @@ -0,0 +1,135 @@ +type MockRouteHandler = T | ((url: string, options?: RequestInit) => T); + +interface MockRoute { + pattern: string | RegExp; + method?: string; + response: MockRouteHandler; + status?: number; +} + +/** + * Mock server class for mocking fetch requests to specific routes + */ +export default class MockServer { + private routes: MockRoute[] = []; + private mockFn: jest.Mock; + + constructor() { + this.mockFn = jest.fn((url: string, options?: RequestInit) => { + return this.handleRequest(url, options); + }); + } + + /** + * 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); + } +}