diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 261be5854..d49951c37 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 000000000..d2a8aca8b --- /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 b30584e7d..ab762816b 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 7135d1253..abf31b0de 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 000000000..57638aa75 --- /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); + } +}