maestro/src/bridge/mcp-api.test.ts
2026-06-03 05:08:00 +00:00

328 lines
10 KiB
TypeScript

import { describe, it, expect, afterEach } from 'vitest';
import express from 'express';
import Database from 'better-sqlite3';
import { runMigrations } from '../db/migrate.js';
import { createRegistry } from '../mcp/registry.js';
import { createTokenManager } from '../mcp/token-manager.js';
import { createToolCache } from '../mcp/tool-cache.js';
import { createAdminRouter, createUserRouter, createUserServersRouter } from './mcp-api.js';
import request from 'supertest';
const openDbs: Database.Database[] = [];
function makeApp(opts: { currentRole: 'admin' | 'user' | 'anon'; userId?: string }) {
const validKey = 'a'.repeat(64);
process.env.MCP_ENCRYPTION_KEY = validKey;
const db = new Database(':memory:');
openDbs.push(db);
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY);`);
db.exec(`CREATE TABLE jobs (id TEXT PRIMARY KEY, wait_reason TEXT);`);
db.exec(`CREATE TABLE local_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT);`); // runMigrations needs this
runMigrations(db);
db.prepare('INSERT INTO users(id) VALUES(?)').run('u1');
db.prepare('INSERT INTO users(id) VALUES(?)').run('u2');
const reg = createRegistry(db);
const tm = createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) });
const cache = createToolCache(db, 600);
const userId = opts.userId ?? 'u1';
const requireAdmin: express.RequestHandler = (_req, res, next) => {
if (opts.currentRole === 'admin') next();
else res.status(403).json({ error: 'admin required' });
};
const requireAuth: express.RequestHandler = (_req, res, next) => {
if (opts.currentRole !== 'anon') next();
else res.status(401).json({ error: 'unauth' });
};
const app = express();
app.use(express.json());
app.use(
'/api/mcp/servers',
createAdminRouter({
db,
registry: reg,
tokenManager: tm,
toolCache: cache,
requireAdmin,
requireAuth,
getUserId: () => userId,
insecureLocalTestMode: true,
}),
);
app.use(
'/api/mcp/connections',
createUserRouter({
db,
registry: reg,
tokenManager: tm,
toolCache: cache,
requireAdmin,
requireAuth,
getUserId: () => userId,
insecureLocalTestMode: true,
}),
);
app.use(
'/api/mcp/user-servers',
createUserServersRouter({
db,
registry: reg,
tokenManager: tm,
toolCache: cache,
requireAdmin,
requireAuth,
getUserId: () => userId,
insecureLocalTestMode: true,
}),
);
return { app, db, reg, tm };
}
describe('mcp-api', () => {
afterEach(() => {
while (openDbs.length) {
const db = openDbs.pop();
try {
db?.close();
} catch {
/* ignore */
}
}
delete process.env.MCP_ENCRYPTION_KEY;
});
it('non-admin cannot POST /api/mcp/servers', async () => {
const { app } = makeApp({ currentRole: 'user' });
const res = await request(app).post('/api/mcp/servers').send({ id: 'canva' });
expect(res.status).toBe(403);
});
it('admin can upsert (oauth) + list + delete', async () => {
const { app } = makeApp({ currentRole: 'admin' });
const post = await request(app).post('/api/mcp/servers').send({
id: 'canva',
name: 'Canva',
url: 'http://127.0.0.1:1/mcp',
authKind: 'oauth',
oauthClientId: 'cid',
oauthClientSecret: 'secret',
});
expect(post.status).toBe(200);
const list = await request(app).get('/api/mcp/servers');
expect(list.body.servers).toHaveLength(1);
// authKind and ownerId included in response
expect(list.body.servers[0].authKind).toBe('oauth');
expect(list.body.servers[0].ownerId).toBeNull();
// Secret must not leak
expect(JSON.stringify(list.body.servers)).not.toContain('secret');
const del = await request(app).delete('/api/mcp/servers/canva');
expect(del.status).toBe(200);
});
it('admin can upsert api_key server', async () => {
const { app } = makeApp({ currentRole: 'admin' });
const post = await request(app).post('/api/mcp/servers').send({
id: 'myapi',
name: 'My API',
url: 'http://127.0.0.1:1/mcp',
authKind: 'api_key',
staticToken: 'sk-test-admin',
});
expect(post.status).toBe(200);
const list = await request(app).get('/api/mcp/servers');
expect(list.body.servers[0].authKind).toBe('api_key');
// Static token must not leak
expect(JSON.stringify(list.body.servers)).not.toContain('sk-test-admin');
});
it('admin POST api_key fails without staticToken', async () => {
const { app } = makeApp({ currentRole: 'admin' });
const post = await request(app).post('/api/mcp/servers').send({
id: 'myapi',
name: 'My API',
url: 'http://127.0.0.1:1/mcp',
authKind: 'api_key',
});
expect(post.status).toBe(400);
});
it('user sees connection state with authKind + ownerId', async () => {
const { app, reg, tm } = makeApp({ currentRole: 'user' });
reg.upsert({
id: 'canva',
name: 'Canva',
url: 'http://127.0.0.1:1/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'i',
oauthClientSecret: 's',
oauthScopes: null,
});
const accessTokenLiteral = 'access-token-do-not-leak-xyz';
const refreshTokenLiteral = 'refresh-token-do-not-leak-xyz';
tm.saveTokens({
userId: 'u1',
serverId: 'canva',
accessToken: accessTokenLiteral,
refreshToken: refreshTokenLiteral,
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
scope: null,
});
const res = await request(app).get('/api/mcp/connections');
expect(res.body.connections).toHaveLength(1);
expect(res.body.connections[0]).toMatchObject({
serverId: 'canva',
serverName: 'Canva',
connected: true,
authKind: 'oauth',
ownerId: null,
});
const serialized = JSON.stringify(res.body);
expect(serialized).not.toContain(accessTokenLiteral);
expect(serialized).not.toContain(refreshTokenLiteral);
});
it('connections GET uses listEnabledForUser (includes user-owned servers)', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
// Global server
reg.upsert({
id: 'global-server',
name: 'Global',
url: 'http://127.0.0.1:1/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'i',
oauthClientSecret: 's',
oauthScopes: null,
});
// User-owned server
reg.upsert({
id: 'u1-server',
name: 'U1 Server',
url: 'http://127.0.0.1:2/mcp',
authKind: 'api_key',
ownerId: 'u1',
staticToken: 'sk-u1',
});
const res = await request(app).get('/api/mcp/connections');
expect(res.status).toBe(200);
const ids = res.body.connections.map((c: { serverId: string }) => c.serverId);
expect(ids).toContain('global-server');
expect(ids).toContain('u1-server');
});
it('user can POST /api/mcp/user-servers with api_key', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
const post = await request(app).post('/api/mcp/user-servers').send({
id: 'my-tool',
name: 'My Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
staticToken: 'sk-test',
});
expect(post.status).toBe(200);
// Server should appear in listEnabledForUser
const servers = reg.listEnabledForUser('u1');
expect(servers.find((s) => s.id === 'my-tool')).toBeTruthy();
expect(servers.find((s) => s.id === 'my-tool')?.ownerId).toBe('u1');
});
it('user cannot DELETE another user\'s server (403)', async () => {
// u2 creates a server, u1 tries to delete it
const { db, reg } = makeApp({ currentRole: 'user', userId: 'u2' });
reg.upsert({
id: 'u2-tool',
name: 'U2 Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
ownerId: 'u2',
staticToken: 'sk-u2',
});
// Now make an app as u1
const validKey = 'a'.repeat(64);
process.env.MCP_ENCRYPTION_KEY = validKey;
const reg2 = createRegistry(db);
const tm2 = createTokenManager(db, { doRefresh: async () => ({ access_token: 'x' }) });
const cache2 = createToolCache(db, 600);
const app2 = express();
app2.use(express.json());
app2.use('/api/mcp/user-servers', createUserServersRouter({
db,
registry: reg2,
tokenManager: tm2,
toolCache: cache2,
requireAdmin: (_req, _res, next) => next(),
requireAuth: (_req, _res, next) => next(),
getUserId: () => 'u1',
insecureLocalTestMode: true,
}));
const del = await request(app2).delete('/api/mcp/user-servers/u2-tool');
expect(del.status).toBe(403);
});
it('user cannot DELETE a global server via user-servers route', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
reg.upsert({
id: 'global-tool',
name: 'Global Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'cid',
oauthClientSecret: 'csec',
oauthScopes: null,
});
const del = await request(app).delete('/api/mcp/user-servers/global-tool');
expect(del.status).toBe(403);
});
it('id collision: POST user-servers fails 409 if id already exists', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
// Pre-create a global server with same id
reg.upsert({
id: 'existing',
name: 'Existing',
url: 'http://127.0.0.1:9/mcp',
authKind: 'oauth',
ownerId: null,
oauthClientId: 'cid',
oauthClientSecret: 'csec',
oauthScopes: null,
});
const post = await request(app).post('/api/mcp/user-servers').send({
id: 'existing',
name: 'My Tool',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
staticToken: 'sk-test',
});
expect(post.status).toBe(409);
});
it('DELETE /api/mcp/connections returns 400 for api_key global server', async () => {
const { app, reg } = makeApp({ currentRole: 'user', userId: 'u1' });
reg.upsert({
id: 'apikey-global',
name: 'API Key Global',
url: 'http://127.0.0.1:9/mcp',
authKind: 'api_key',
ownerId: null,
staticToken: 'sk-global',
});
const del = await request(app).delete('/api/mcp/connections/apikey-global');
expect(del.status).toBe(400);
});
});