mcp-server.test.ts194 lines · main
1import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
3import { describe, expect, test } from 'bun:test';
4
5import type { McpKey, McpKeyScope } from '../db/schema.js';
6import {
7 generateMcpKey,
8 hashMcpKey,
9 verifyMcpKey,
10 type McpVerifyDeps,
11} from './mcp-access.js';
12import { ADMIN_TOOLS, assertReadOnlyQuery, buildMcpServer, READ_TOOLS, WRITE_TOOLS } from './mcp-tools.js';
13
14/* ── fixtures ─────────────────────────────────────────────────────────── */
15
16function fakeKey(overrides: Partial<McpKey> = {}): McpKey {
17 return {
18 id: 'mck_test',
19 projectId: 'prj_test',
20 name: 'test key',
21 hash: 'deadbeef',
22 prefix: 'pk_briven_mcp_',
23 suffix: 'abcd',
24 scope: 'read',
25 enabled: true,
26 createdBy: 'usr_test',
27 createdAt: new Date(),
28 lastUsedAt: null,
29 revokedAt: null,
30 ...overrides,
31 };
32}
33
34function deps(overrides: Partial<McpVerifyDeps> = {}): McpVerifyDeps {
35 return {
36 getKeyByHash: async () => fakeKey(),
37 touchKeyLastUsed: async () => {},
38 isGlobalEnabled: async () => true,
39 isProjectEnabled: async () => true,
40 getProjectPlanTier: async () => 'pro',
41 ...overrides,
42 };
43}
44
45/* ── verifyMcpKey ─────────────────────────────────────────────────────── */
46
47describe('verifyMcpKey', () => {
48 test('hashMcpKey mirrors generateMcpKey exactly', () => {
49 const k = generateMcpKey();
50 expect(hashMcpKey(k.plaintext)).toBe(k.hash);
51 });
52
53 test('missing key → 401 missing_key', async () => {
54 const r = await verifyMcpKey(null, deps());
55 expect(r).toEqual({ ok: false, status: 401, reason: 'missing_key' });
56 });
57
58 test('wrong prefix → 401 malformed_key', async () => {
59 const r = await verifyMcpKey('sk_not_ours_123', deps());
60 expect(r).toEqual({ ok: false, status: 401, reason: 'malformed_key' });
61 });
62
63 test('no matching row → 401 unknown_key', async () => {
64 const r = await verifyMcpKey('pk_briven_mcp_xyz', deps({ getKeyByHash: async () => null }));
65 expect(r).toEqual({ ok: false, status: 401, reason: 'unknown_key' });
66 });
67
68 test('revoked key → 401 revoked_key', async () => {
69 const r = await verifyMcpKey(
70 'pk_briven_mcp_xyz',
71 deps({ getKeyByHash: async () => fakeKey({ revokedAt: new Date() }) }),
72 );
73 expect(r).toEqual({ ok: false, status: 401, reason: 'revoked_key' });
74 });
75
76 test('disabled key → 401 revoked_key', async () => {
77 const r = await verifyMcpKey(
78 'pk_briven_mcp_xyz',
79 deps({ getKeyByHash: async () => fakeKey({ enabled: false }) }),
80 );
81 expect(r).toEqual({ ok: false, status: 401, reason: 'revoked_key' });
82 });
83
84 test('global kill-switch off → 403 global_disabled', async () => {
85 const r = await verifyMcpKey('pk_briven_mcp_xyz', deps({ isGlobalEnabled: async () => false }));
86 expect(r).toEqual({ ok: false, status: 403, reason: 'global_disabled' });
87 });
88
89 test('project not enabled → 403 project_disabled', async () => {
90 const r = await verifyMcpKey('pk_briven_mcp_xyz', deps({ isProjectEnabled: async () => false }));
91 expect(r).toEqual({ ok: false, status: 403, reason: 'project_disabled' });
92 });
93
94 test('free-tier project → 403 plan_ineligible', async () => {
95 const r = await verifyMcpKey(
96 'pk_briven_mcp_xyz',
97 deps({ getProjectPlanTier: async () => 'free' }),
98 );
99 expect(r).toEqual({ ok: false, status: 403, reason: 'plan_ineligible' });
100 });
101
102 test('valid pro key → ok with binding, stamps lastUsedAt', async () => {
103 const stampedIds: string[] = [];
104 const r = await verifyMcpKey(
105 'pk_briven_mcp_xyz',
106 deps({
107 getKeyByHash: async () => fakeKey({ id: 'mck_99', projectId: 'prj_99', scope: 'admin' }),
108 touchKeyLastUsed: async (id) => {
109 stampedIds.push(id);
110 },
111 }),
112 );
113 expect(r).toEqual({ ok: true, keyId: 'mck_99', projectId: 'prj_99', scope: 'admin' });
114 expect(stampedIds).toEqual(['mck_99']);
115 });
116});
117
118/* ── read-only query guard ────────────────────────────────────────────── */
119
120describe('assertReadOnlyQuery', () => {
121 test('accepts a plain SELECT', () => {
122 expect(assertReadOnlyQuery('SELECT * FROM notes')).toBe('SELECT * FROM notes');
123 });
124 test('accepts a WITH … SELECT and strips trailing semicolon', () => {
125 expect(assertReadOnlyQuery('WITH x AS (SELECT 1) SELECT * FROM x;')).toBe(
126 'WITH x AS (SELECT 1) SELECT * FROM x',
127 );
128 });
129 test.each([
130 'INSERT INTO notes VALUES (1)',
131 'UPDATE notes SET a = 1',
132 'DELETE FROM notes',
133 'DROP TABLE notes',
134 'TRUNCATE notes',
135 'SELECT 1; DROP TABLE notes',
136 'WITH x AS (DELETE FROM notes RETURNING *) SELECT * FROM x',
137 ])('rejects write/multi-statement: %s', (sql) => {
138 expect(() => assertReadOnlyQuery(sql)).toThrow();
139 });
140});
141
142/* ── tools/list scope filtering + isolation contract ──────────────────── */
143
144async function listToolsForScope(scope: McpKeyScope) {
145 const server = buildMcpServer({
146 keyId: 'mck_x',
147 projectId: 'prj_x',
148 scope,
149 ipHash: null,
150 userAgent: null,
151 });
152 const [clientT, serverT] = InMemoryTransport.createLinkedPair();
153 const client = new Client({ name: 'test-client', version: '1.0.0' });
154 await Promise.all([server.connect(serverT), client.connect(clientT)]);
155 const { tools } = await client.listTools();
156 await client.close();
157 await server.close();
158 return tools;
159}
160
161describe('buildMcpServer — tools/list by scope', () => {
162 test('read key sees ONLY the read tools, no write/admin tools', async () => {
163 const tools = await listToolsForScope('read');
164 const names = tools.map((t) => t.name).sort();
165 expect(names).toEqual([...READ_TOOLS].sort());
166 for (const w of WRITE_TOOLS) expect(names).not.toContain(w);
167 for (const a of ADMIN_TOOLS) expect(names).not.toContain(a);
168 });
169
170 test('read-write key sees read + write tools, no admin tools', async () => {
171 const tools = await listToolsForScope('read-write');
172 const names = tools.map((t) => t.name).sort();
173 expect(names).toEqual([...READ_TOOLS, ...WRITE_TOOLS].sort());
174 for (const a of ADMIN_TOOLS) expect(names).not.toContain(a);
175 });
176
177 test('admin key sees read + write + admin tools', async () => {
178 const tools = await listToolsForScope('admin');
179 const names = tools.map((t) => t.name).sort();
180 expect(names).toEqual([...READ_TOOLS, ...WRITE_TOOLS, ...ADMIN_TOOLS].sort());
181 });
182
183 test('THE ISOLATION CONTRACT: no tool exposes a projectId argument', async () => {
184 for (const scope of ['read', 'read-write', 'admin'] as const) {
185 const tools = await listToolsForScope(scope);
186 for (const tool of tools) {
187 const props = (tool.inputSchema as { properties?: Record<string, unknown> }).properties;
188 const propNames = props ? Object.keys(props) : [];
189 expect(propNames).not.toContain('projectId');
190 expect(propNames).not.toContain('project_id');
191 }
192 }
193 });
194});