mcp --http: --broker-profiles (store keys from the broker vault, memory only), --read-only, project by folder name under the roots

This commit is contained in:
CRTkafa 2026-10-29 18:38:11 +07:00
parent 164ba390dd
commit a108aae53f
6 changed files with 121 additions and 10 deletions

View file

@ -2,7 +2,7 @@ import { afterAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { basename, join } from 'node:path';
import { answer, insideRoots, introspectionOk, metadataUrl, originAllowed, projectFor, startHttp, tokenFrom } from '../cli/mcp-http';
const root = mkdtempSync(join(tmpdir(), 'mcph-'));
@ -15,7 +15,11 @@ describe('pure parts', () => {
expect(insideRoots(join(root, '..'), [root])).toBe(false);
expect(projectFor(undefined, { roots: [root], project: null })).toEqual({ error: 'project: required (absolute path of the app folder)' });
expect(projectFor(undefined, { roots: [root], project: app })).toEqual({ dir: app });
expect(projectFor('rel/dir', { roots: [root], project: null })).toEqual({ error: 'project: must be an absolute path' });
expect(projectFor('rel/dir', { roots: [root], project: null })).toEqual({ error: 'project: must be an absolute path, or a folder name under the roots' });
expect(projectFor(basename(app), { roots: [root], project: null })).toEqual({ dir: app });
expect('error' in projectFor('nope', { roots: [root], project: null })).toBe(true);
expect(projectFor(app.split(/[\/]/).pop(), { roots: [root], project: null })).toEqual({ dir: app });
expect('error' in projectFor('nope', { roots: [root], project: null })).toBe(true);
expect('error' in projectFor(tmpdir(), { roots: [root], project: null })).toBe(true);
expect('error' in projectFor(join(root, 'missing'), { roots: [root], project: null })).toBe(true);
expect(projectFor(app, { roots: [root], project: null })).toEqual({ dir: app });
@ -103,3 +107,34 @@ describe('http server', () => {
} finally { s.close(); idp.close(); }
});
});
describe('read-only and broker profiles', () => {
test('read-only lists only read tools and refuses a write tool', async () => {
const list = await answer({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, { roots: [root], project: app, readOnly: true });
const tools = (list!.result as { tools: { name: string; annotations: { readOnlyHint: boolean } }[] }).tools;
expect(tools.length).toBeGreaterThan(5);
expect(tools.every(t => t.annotations.readOnlyHint)).toBe(true);
const w = await answer({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'play_listing', arguments: {} } }, { roots: [root], project: app, readOnly: true }, async () => { throw new Error('must not run'); });
expect((w as { error: { message: string } }).error.message).toContain('read-only');
});
test('broker store credentials become STOREBOUND_PROFILE_<NAME>_* env', async () => {
const { brokerProfileEnv } = await import('../cli/lib/broker');
const srv = createServer((req, res) => {
res.setHeader('content-type', 'application/json');
const m = /^\/api\/store-credentials\/([^/]+)\/([^/]+)$/.exec(req.url ?? '');
if (req.headers.authorization !== 'Bearer kkkkkkkk') { res.statusCode = 401; return res.end('{}'); }
if (m?.[1] === 'teamA' && m[2] === 'asc-api-key') return res.end(JSON.stringify({ secret: '-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----', metadata: { issuerId: 'iss', keyId: 'KEY' } }));
if (m?.[1] === 'teamA' && m[2] === 'play-service-account') return res.end(JSON.stringify({ secret: '{"client_email":"a@example.com"}', metadata: {} }));
res.statusCode = 404; res.end('{"error":"err_not_found"}');
});
await new Promise<void>(r => srv.listen(0, '127.0.0.1', () => r()));
const port = (srv.address() as { port: number }).port;
const keyFile = join(root, 'broker.key');
(await import('node:fs')).writeFileSync(keyFile, 'kkkkkkkk', { mode: 0o600 });
try {
const env = await brokerProfileEnv({ url: `http://127.0.0.1:${port}`, keyFile, account: null, from: 'test' }, ['teamA', 'other']);
expect(Object.keys(env).sort()).toEqual(['STOREBOUND_PROFILE_TEAMA_ASC_ISSUER_ID', 'STOREBOUND_PROFILE_TEAMA_ASC_KEY_ID', 'STOREBOUND_PROFILE_TEAMA_ASC_P8', 'STOREBOUND_PROFILE_TEAMA_PLAY_SERVICE_ACCOUNT']);
expect(env.STOREBOUND_PROFILE_TEAMA_ASC_KEY_ID).toBe('KEY');
} finally { srv.close(); }
});
});