Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "Model Context Protocol server for Forest Admin with OAuth authentication",
"main": "dist/index.js",
"bin": {
"forest-mcp-server": "dist/index.js"
"forest-mcp-server": "dist/cli.js"
},
"license": "GPL-3.0",
"publishConfig": {
Expand Down Expand Up @@ -32,8 +32,8 @@
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"start": "node dist/index.js",
"start:dev": "node --env-file=.env dist/index.js",
"start": "node dist/cli.js",
"start:dev": "node --env-file=.env dist/cli.js",
"clean": "rm -rf coverage dist",
"lint": "eslint src test",
"test": "jest"
Expand Down
11 changes: 11 additions & 0 deletions packages/mcp-server/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env node

import ForestMCPServer from './server.js';

// Start the server when run directly as CLI
const server = new ForestMCPServer();

server.run().catch(error => {
console.error('[FATAL] Server crashed:', error);
process.exit(1);
});
120 changes: 43 additions & 77 deletions packages/mcp-server/src/forest-oauth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Response } from 'express';
import createForestAdminClient from '@forestadmin/forestadmin-client';
import jsonwebtoken from 'jsonwebtoken';

import ForestAdminOAuthProvider from './forest-oauth-provider';
import ForestOAuthProvider from './forest-oauth-provider';
import MockServer from './test-utils/mock-server';

jest.mock('jsonwebtoken');
Expand All @@ -16,7 +16,18 @@ const mockCreateForestAdminClient = createForestAdminClient as jest.MockedFuncti
const mockJwtDecode = jsonwebtoken.decode as jest.Mock;
const mockJwtSign = jsonwebtoken.sign as jest.Mock;

describe('ForestAdminOAuthProvider', () => {
const TEST_ENV_SECRET = 'test-env-secret';
const TEST_AUTH_SECRET = 'test-auth-secret';

function createProvider(forestServerUrl = 'https://api.forestadmin.com') {
return new ForestOAuthProvider({
forestServerUrl,
envSecret: TEST_ENV_SECRET,
authSecret: TEST_AUTH_SECRET,
});
}

describe('ForestOAuthProvider', () => {
let originalEnv: NodeJS.ProcessEnv;
let mockServer: MockServer;
const originalFetch = global.fetch;
Expand All @@ -31,8 +42,8 @@ describe('ForestAdminOAuthProvider', () => {
});

beforeEach(() => {
process.env.FOREST_ENV_SECRET = 'test-env-secret';
process.env.FOREST_AUTH_SECRET = 'test-auth-secret';
process.env.FOREST_ENV_SECRET = TEST_ENV_SECRET;
process.env.FOREST_AUTH_SECRET = TEST_AUTH_SECRET;
mockServer = new MockServer();
});

Expand All @@ -42,19 +53,18 @@ describe('ForestAdminOAuthProvider', () => {

describe('constructor', () => {
it('should create instance with forestServerUrl', () => {
const customProvider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://custom.forestadmin.com',
});
const customProvider = createProvider('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({
it('should not throw when envSecret is empty string', async () => {
const customProvider = new ForestOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
envSecret: '',
authSecret: TEST_AUTH_SECRET,
});

await expect(customProvider.initialize()).resolves.not.toThrow();
Expand All @@ -66,9 +76,7 @@ describe('ForestAdminOAuthProvider', () => {
});
global.fetch = mockServer.fetch;

const testProvider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const testProvider = createProvider();

await testProvider.initialize();

Expand All @@ -91,9 +99,7 @@ describe('ForestAdminOAuthProvider', () => {
});
global.fetch = mockServer.fetch;

const testProvider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const testProvider = createProvider();

await testProvider.initialize();

Expand Down Expand Up @@ -126,9 +132,7 @@ describe('ForestAdminOAuthProvider', () => {

const consoleSpy = jest.spyOn(console, 'error').mockImplementation();

const testProvider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const testProvider = createProvider();

await testProvider.initialize();

Expand All @@ -145,9 +149,7 @@ describe('ForestAdminOAuthProvider', () => {

const consoleSpy = jest.spyOn(console, 'error').mockImplementation();

const testProvider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const testProvider = createProvider();

await testProvider.initialize();

Expand All @@ -165,9 +167,7 @@ describe('ForestAdminOAuthProvider', () => {
});
global.fetch = mockServer.fetch;

const testProvider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://custom.forestadmin.com',
});
const testProvider = createProvider('https://custom.forestadmin.com');

await testProvider.initialize();

Expand All @@ -188,9 +188,7 @@ describe('ForestAdminOAuthProvider', () => {
mockServer.get('/oauth/register/test-client-123', clientData);
global.fetch = mockServer.fetch;

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

const client = await provider.clientsStore.getClient('test-client-123');

Expand All @@ -210,9 +208,7 @@ describe('ForestAdminOAuthProvider', () => {
mockServer.get('/oauth/register/unknown-client', { error: 'Not found' }, 404);
global.fetch = mockServer.fetch;

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

const client = await provider.clientsStore.getClient('unknown-client');

Expand All @@ -223,9 +219,7 @@ describe('ForestAdminOAuthProvider', () => {
mockServer.get('/oauth/register/error-client', { error: 'Internal error' }, 500);
global.fetch = mockServer.fetch;

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

const client = await provider.clientsStore.getClient('error-client');

Expand All @@ -236,7 +230,7 @@ describe('ForestAdminOAuthProvider', () => {
describe('authorize', () => {
let mockResponse: Partial<Response>;
let mockClient: OAuthClientInformationFull;
let initializedProvider: ForestAdminOAuthProvider;
let initializedProvider: ForestOAuthProvider;

beforeEach(async () => {
mockResponse = {
Expand All @@ -248,9 +242,7 @@ describe('ForestAdminOAuthProvider', () => {
} as OAuthClientInformationFull;

// Create provider and mock the fetch to set environmentId
initializedProvider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
initializedProvider = createProvider();

// Mock fetch to return a valid response
const mockFetch = jest.fn().mockResolvedValue({
Expand Down Expand Up @@ -317,9 +309,7 @@ describe('ForestAdminOAuthProvider', () => {

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',
});
const uninitializedProvider = createProvider();

await uninitializedProvider.authorize(
mockClient,
Expand Down Expand Up @@ -405,9 +395,7 @@ describe('ForestAdminOAuthProvider', () => {
});
global.fetch = mockServer.fetch;

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

const result = await provider.exchangeAuthorizationCode(
mockClient,
Expand Down Expand Up @@ -479,9 +467,7 @@ describe('ForestAdminOAuthProvider', () => {
.post('/oauth/token', { error: 'invalid_grant' }, 400);
global.fetch = mockServer.fetch;

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

await expect(
provider.exchangeAuthorizationCode(
Expand Down Expand Up @@ -563,9 +549,7 @@ describe('ForestAdminOAuthProvider', () => {
});
global.fetch = mockServer.fetch;

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

const result = await provider.exchangeRefreshToken(mockClient, 'valid-refresh-token');

Expand Down Expand Up @@ -594,9 +578,7 @@ describe('ForestAdminOAuthProvider', () => {
throw new Error('invalid signature');
});

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

await expect(
provider.exchangeRefreshToken(mockClient, 'invalid-refresh-token'),
Expand All @@ -609,9 +591,7 @@ describe('ForestAdminOAuthProvider', () => {
clientId: 'test-client-id',
});

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

await expect(provider.exchangeRefreshToken(mockClient, 'access-token')).rejects.toThrow(
'Invalid token type',
Expand All @@ -627,9 +607,7 @@ describe('ForestAdminOAuthProvider', () => {
serverRefreshToken: 'forest-refresh-token',
});

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

await expect(
provider.exchangeRefreshToken(mockClient, 'refresh-token-for-different-client'),
Expand All @@ -648,9 +626,7 @@ describe('ForestAdminOAuthProvider', () => {
mockServer.post('/oauth/token', { error: 'invalid_grant' }, 400);
global.fetch = mockServer.fetch;

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

await expect(
provider.exchangeRefreshToken(mockClient, 'valid-refresh-token'),
Expand All @@ -671,9 +647,7 @@ describe('ForestAdminOAuthProvider', () => {

(jsonwebtoken.verify as jest.Mock).mockReturnValue(mockDecoded);

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

const result = await provider.verifyAccessToken('valid-access-token');

Expand All @@ -695,9 +669,7 @@ describe('ForestAdminOAuthProvider', () => {
throw new jsonwebtoken.TokenExpiredError('jwt expired', new Date());
});

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

await expect(provider.verifyAccessToken('expired-token')).rejects.toThrow(
'Access token has expired',
Expand All @@ -709,9 +681,7 @@ describe('ForestAdminOAuthProvider', () => {
throw new jsonwebtoken.JsonWebTokenError('invalid signature');
});

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

await expect(provider.verifyAccessToken('invalid-token')).rejects.toThrow(
'Invalid access token',
Expand All @@ -724,9 +694,7 @@ describe('ForestAdminOAuthProvider', () => {
clientId: 'test-client-id',
});

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

await expect(provider.verifyAccessToken('refresh-token')).rejects.toThrow(
'Cannot use refresh token as access token',
Expand Down Expand Up @@ -754,9 +722,7 @@ describe('ForestAdminOAuthProvider', () => {

(jsonwebtoken.verify as jest.Mock).mockReturnValue(mockDecoded);

const provider = new ForestAdminOAuthProvider({
forestServerUrl: 'https://api.forestadmin.com',
});
const provider = createProvider();

// Call initialize to fetch environment data
await provider.initialize();
Expand Down
Loading
Loading