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); }); });