Skip to content

Commit 0a1b5e3

Browse files
feat(forest mcp): add the authorize handler (#1341)
1 parent bc6bfa8 commit 0a1b5e3

File tree

5 files changed

+669
-7
lines changed

5 files changed

+669
-7
lines changed

packages/mcp-server/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ The following environment variables are required to run the server:
3636
|----------|----------|---------|-------------|
3737
| `FOREST_ENV_SECRET` | **Yes** | - | Your Forest Admin environment secret |
3838
| `FOREST_AUTH_SECRET` | **Yes** | - | Your Forest Admin authentication secret (must match your agent) |
39-
| `FOREST_SERVER_URL` | No | `https://api.forestadmin.com` | Forest Admin server URL |
4039
| `MCP_SERVER_PORT` | No | `3931` | Port for the HTTP server |
4140

4241
### Example Configuration
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk/shared/auth.js';
2+
import type { Response } from 'express';
3+
4+
import ForestAdminOAuthProvider from './forest-oauth-provider.js';
5+
import MockServer from './test-utils/mock-server.js';
6+
7+
describe('ForestAdminOAuthProvider', () => {
8+
let originalEnv: NodeJS.ProcessEnv;
9+
let mockServer: MockServer;
10+
const originalFetch = global.fetch;
11+
12+
beforeAll(() => {
13+
originalEnv = { ...process.env };
14+
});
15+
16+
afterAll(() => {
17+
process.env = originalEnv;
18+
global.fetch = originalFetch;
19+
});
20+
21+
beforeEach(() => {
22+
process.env.FOREST_ENV_SECRET = 'test-env-secret';
23+
process.env.FOREST_AUTH_SECRET = 'test-auth-secret';
24+
mockServer = new MockServer();
25+
});
26+
27+
afterEach(() => {
28+
mockServer.reset();
29+
});
30+
31+
describe('constructor', () => {
32+
it('should create instance with forestServerUrl', () => {
33+
const customProvider = new ForestAdminOAuthProvider({
34+
forestServerUrl: 'https://custom.forestadmin.com',
35+
});
36+
37+
expect(customProvider).toBeDefined();
38+
});
39+
});
40+
41+
describe('initialize', () => {
42+
it('should not throw when FOREST_ENV_SECRET is missing', async () => {
43+
delete process.env.FOREST_ENV_SECRET;
44+
const customProvider = new ForestAdminOAuthProvider({
45+
forestServerUrl: 'https://api.forestadmin.com',
46+
});
47+
48+
await expect(customProvider.initialize()).resolves.not.toThrow();
49+
});
50+
51+
it('should fetch environmentId from Forest Admin API', async () => {
52+
mockServer.get('/liana/environment', { data: { id: '98765' } });
53+
global.fetch = mockServer.fetch;
54+
55+
const testProvider = new ForestAdminOAuthProvider({
56+
forestServerUrl: 'https://api.forestadmin.com',
57+
});
58+
59+
await testProvider.initialize();
60+
61+
// Verify fetch was called with correct URL and headers
62+
expect(mockServer.fetch).toHaveBeenCalledWith(
63+
'https://api.forestadmin.com/liana/environment',
64+
expect.objectContaining({
65+
method: 'GET',
66+
headers: expect.objectContaining({
67+
'forest-secret-key': 'test-env-secret',
68+
'Content-Type': 'application/json',
69+
}),
70+
}),
71+
);
72+
});
73+
74+
it('should set environmentId after successful initialization', async () => {
75+
mockServer.get('/liana/environment', { data: { id: '54321' } });
76+
global.fetch = mockServer.fetch;
77+
78+
const testProvider = new ForestAdminOAuthProvider({
79+
forestServerUrl: 'https://api.forestadmin.com',
80+
});
81+
82+
await testProvider.initialize();
83+
84+
// Verify environmentId is set by checking authorize redirect includes it
85+
const mockResponse = { redirect: jest.fn() };
86+
const mockClient = {
87+
client_id: 'test-client',
88+
redirect_uris: ['https://example.com/callback'],
89+
} as OAuthClientInformationFull;
90+
91+
await testProvider.authorize(
92+
mockClient,
93+
{
94+
redirectUri: 'https://example.com/callback',
95+
codeChallenge: 'challenge',
96+
state: 'state',
97+
scopes: ['mcp:read'],
98+
resource: new URL('https://localhost:3931'),
99+
},
100+
mockResponse as unknown as Response,
101+
);
102+
103+
const redirectUrl = new URL((mockResponse.redirect as jest.Mock).mock.calls[0][0]);
104+
expect(redirectUrl.searchParams.get('environmentId')).toBe('54321');
105+
});
106+
107+
it('should handle non-OK response from Forest Admin API', async () => {
108+
mockServer.get('/liana/environment', { error: 'Unauthorized' }, 401);
109+
global.fetch = mockServer.fetch;
110+
111+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
112+
113+
const testProvider = new ForestAdminOAuthProvider({
114+
forestServerUrl: 'https://api.forestadmin.com',
115+
});
116+
117+
await testProvider.initialize();
118+
119+
expect(consoleSpy).toHaveBeenCalledWith(
120+
'[WARN] Failed to fetch environmentId from Forest Admin API:',
121+
expect.any(Error),
122+
);
123+
124+
consoleSpy.mockRestore();
125+
});
126+
127+
it('should handle fetch network errors gracefully', async () => {
128+
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
129+
130+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
131+
132+
const testProvider = new ForestAdminOAuthProvider({
133+
forestServerUrl: 'https://api.forestadmin.com',
134+
});
135+
136+
await testProvider.initialize();
137+
138+
expect(consoleSpy).toHaveBeenCalledWith(
139+
'[WARN] Failed to fetch environmentId from Forest Admin API:',
140+
expect.any(Error),
141+
);
142+
143+
consoleSpy.mockRestore();
144+
});
145+
146+
it('should use correct forest server URL for API call', async () => {
147+
mockServer.get('/liana/environment', { data: { id: '11111' } });
148+
global.fetch = mockServer.fetch;
149+
150+
const testProvider = new ForestAdminOAuthProvider({
151+
forestServerUrl: 'https://custom.forestadmin.com',
152+
});
153+
154+
await testProvider.initialize();
155+
156+
expect(mockServer.fetch).toHaveBeenCalledWith(
157+
'https://custom.forestadmin.com/liana/environment',
158+
expect.any(Object),
159+
);
160+
});
161+
});
162+
163+
describe('clientsStore.getClient', () => {
164+
it('should fetch client from Forest Admin API', async () => {
165+
const clientData = {
166+
client_id: 'test-client-123',
167+
redirect_uris: ['https://example.com/callback'],
168+
client_name: 'Test Client',
169+
};
170+
mockServer.get('/oauth/register/test-client-123', clientData);
171+
global.fetch = mockServer.fetch;
172+
173+
const provider = new ForestAdminOAuthProvider({
174+
forestServerUrl: 'https://api.forestadmin.com',
175+
});
176+
177+
const client = await provider.clientsStore.getClient('test-client-123');
178+
179+
expect(client).toEqual(clientData);
180+
expect(mockServer.fetch).toHaveBeenCalledWith(
181+
'https://api.forestadmin.com/oauth/register/test-client-123',
182+
expect.objectContaining({
183+
method: 'GET',
184+
headers: expect.objectContaining({
185+
'Content-Type': 'application/json',
186+
}),
187+
}),
188+
);
189+
});
190+
191+
it('should return undefined when client is not found', async () => {
192+
mockServer.get('/oauth/register/unknown-client', { error: 'Not found' }, 404);
193+
global.fetch = mockServer.fetch;
194+
195+
const provider = new ForestAdminOAuthProvider({
196+
forestServerUrl: 'https://api.forestadmin.com',
197+
});
198+
199+
const client = await provider.clientsStore.getClient('unknown-client');
200+
201+
expect(client).toBeUndefined();
202+
});
203+
204+
it('should return undefined on server error', async () => {
205+
mockServer.get('/oauth/register/error-client', { error: 'Internal error' }, 500);
206+
global.fetch = mockServer.fetch;
207+
208+
const provider = new ForestAdminOAuthProvider({
209+
forestServerUrl: 'https://api.forestadmin.com',
210+
});
211+
212+
const client = await provider.clientsStore.getClient('error-client');
213+
214+
expect(client).toBeUndefined();
215+
});
216+
});
217+
218+
describe('authorize', () => {
219+
let mockResponse: Partial<Response>;
220+
let mockClient: OAuthClientInformationFull;
221+
let initializedProvider: ForestAdminOAuthProvider;
222+
223+
beforeEach(async () => {
224+
mockResponse = {
225+
redirect: jest.fn(),
226+
};
227+
mockClient = {
228+
client_id: 'test-client-id',
229+
redirect_uris: ['https://example.com/callback'],
230+
} as OAuthClientInformationFull;
231+
232+
// Create provider and mock the fetch to set environmentId
233+
initializedProvider = new ForestAdminOAuthProvider({
234+
forestServerUrl: 'https://api.forestadmin.com',
235+
});
236+
237+
// Mock fetch to return a valid response
238+
const mockFetch = jest.fn().mockResolvedValue({
239+
ok: true,
240+
json: () => Promise.resolve({ data: { id: '12345' } }),
241+
});
242+
global.fetch = mockFetch;
243+
244+
await initializedProvider.initialize();
245+
});
246+
247+
afterEach(() => {
248+
jest.restoreAllMocks();
249+
});
250+
251+
it('should redirect to Forest Admin authentication URL', async () => {
252+
await initializedProvider.authorize(
253+
mockClient,
254+
{
255+
redirectUri: 'https://example.com/callback',
256+
codeChallenge: 'test-code-challenge',
257+
state: 'test-state',
258+
scopes: ['mcp:read', 'profile'],
259+
resource: new URL('https://localhost:3931'),
260+
},
261+
mockResponse as Response,
262+
);
263+
264+
expect(mockResponse.redirect).toHaveBeenCalledWith(
265+
expect.stringContaining('https://app.forestadmin.com/oauth/authorize'),
266+
);
267+
});
268+
269+
it('should include all required query parameters in redirect URL', async () => {
270+
await initializedProvider.authorize(
271+
mockClient,
272+
{
273+
redirectUri: 'https://example.com/callback',
274+
codeChallenge: 'test-code-challenge',
275+
state: 'test-state',
276+
scopes: ['mcp:read', 'profile'],
277+
resource: new URL('https://localhost:3931'),
278+
},
279+
mockResponse as Response,
280+
);
281+
282+
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
283+
const url = new URL(redirectCall);
284+
285+
expect(url.hostname).toBe('app.forestadmin.com');
286+
expect(url.pathname).toBe('/oauth/authorize');
287+
expect(url.searchParams.get('redirect_uri')).toBe('https://example.com/callback');
288+
expect(url.searchParams.get('code_challenge')).toBe('test-code-challenge');
289+
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
290+
expect(url.searchParams.get('response_type')).toBe('code');
291+
expect(url.searchParams.get('client_id')).toBe('test-client-id');
292+
expect(url.searchParams.get('state')).toBe('test-state');
293+
expect(url.searchParams.get('scope')).toBe('mcp:read+profile');
294+
expect(url.searchParams.get('environmentId')).toBe('12345');
295+
});
296+
297+
it('should redirect to error URL when environmentId is not set', async () => {
298+
// Create a provider without initializing (environmentId is undefined)
299+
const uninitializedProvider = new ForestAdminOAuthProvider({
300+
forestServerUrl: 'https://api.forestadmin.com',
301+
});
302+
303+
await uninitializedProvider.authorize(
304+
mockClient,
305+
{
306+
redirectUri: 'https://example.com/callback',
307+
codeChallenge: 'test-code-challenge',
308+
state: 'test-state',
309+
scopes: ['mcp:read'],
310+
resource: new URL('https://localhost:3931'),
311+
},
312+
mockResponse as Response,
313+
);
314+
315+
const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
316+
317+
expect(redirectCall).toContain('https://example.com/callback');
318+
expect(redirectCall).toContain('error=server_error');
319+
});
320+
});
321+
});

0 commit comments

Comments
 (0)