feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user