328 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|