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:
parent
164ba390dd
commit
a108aae53f
6 changed files with 121 additions and 10 deletions
|
|
@ -649,6 +649,7 @@
|
|||
"allow-origin",
|
||||
"allow-remote-token",
|
||||
"auth",
|
||||
"broker-profiles",
|
||||
"host",
|
||||
"http",
|
||||
"introspect-key-file",
|
||||
|
|
@ -656,6 +657,7 @@
|
|||
"issuer",
|
||||
"port",
|
||||
"project",
|
||||
"read-only",
|
||||
"resource",
|
||||
"roots",
|
||||
"token-file"
|
||||
|
|
@ -1194,6 +1196,7 @@
|
|||
"beta",
|
||||
"bid",
|
||||
"body",
|
||||
"broker-profiles",
|
||||
"build",
|
||||
"build-number",
|
||||
"bundle-id",
|
||||
|
|
@ -1363,6 +1366,7 @@
|
|||
"q",
|
||||
"rating",
|
||||
"react-native",
|
||||
"read-only",
|
||||
"reconvert",
|
||||
"refresh-profile",
|
||||
"region",
|
||||
|
|
|
|||
|
|
@ -325,3 +325,35 @@ export async function playPreflight(command: string, root: string, pkg: string |
|
|||
if (o.dry) { for (const g of gaps) o.warn(`${g.message}. Fix: ${g.fix}`); return gaps; }
|
||||
throw new Error(`${command} stopped before any call to Google Play:\n${gapsText(gaps)}`);
|
||||
}
|
||||
|
||||
export type StoreKind = 'asc-api-key' | 'play-service-account';
|
||||
|
||||
/**
|
||||
* A store credential of one profile from the broker's vault (GET /api/store-credentials/<profile>/<kind>, a key with
|
||||
* the store-credentials scope): { secret, metadata }. For a long-running server (`mcp --http --broker-profiles`) that
|
||||
* keeps no key files; null when the broker has none for that profile. Never logged.
|
||||
*/
|
||||
export async function brokerStoreCredential(cfg: BrokerConfig, profile: string, kind: StoreKind): Promise<{ secret: string; metadata: Record<string, unknown> } | null> {
|
||||
const a = await request(cfg, 'GET', `/api/store-credentials/${encodeURIComponent(profile)}/${kind}`);
|
||||
if (a.status === 404) return null;
|
||||
if (a.status === 401 || a.status === 403) throw new BrokerRefused(`credential broker ${host(cfg)} refused store credentials for ${profile} (${a.status} ${errOf(a)}): the key needs the store-credentials scope and the profile`, a.status);
|
||||
const j = a.json as { secret?: unknown; metadata?: unknown } | null;
|
||||
if (a.status !== 200 || !j || typeof j.secret !== 'string') throw new BrokerUnavailable(`credential broker ${host(cfg)} -> ${a.status} for ${profile}/${kind}`);
|
||||
return { secret: j.secret, metadata: j.metadata && typeof j.metadata === 'object' ? (j.metadata as Record<string, unknown>) : {} };
|
||||
}
|
||||
|
||||
/** The env a child process needs to use these profiles from the broker (STOREBOUND_PROFILE_<NAME>_<KEY>, inline PEM /
|
||||
* JSON; see credentials.ts). Profiles the broker has nothing for are left out. */
|
||||
export async function brokerProfileEnv(cfg: BrokerConfig, profiles: string[]): Promise<Record<string, string>> {
|
||||
const env: Record<string, string> = {};
|
||||
const name = (p: string, k: string) => `STOREBOUND_PROFILE_${p.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_${k}`;
|
||||
await Promise.all(profiles.map(async p => {
|
||||
const [asc, play] = await Promise.all([brokerStoreCredential(cfg, p, 'asc-api-key'), brokerStoreCredential(cfg, p, 'play-service-account')]);
|
||||
if (asc) {
|
||||
const { issuerId, keyId } = asc.metadata;
|
||||
if (typeof issuerId === 'string' && typeof keyId === 'string') Object.assign(env, { [name(p, 'ASC_P8')]: asc.secret, [name(p, 'ASC_KEY_ID')]: keyId, [name(p, 'ASC_ISSUER_ID')]: issuerId });
|
||||
}
|
||||
if (play) env[name(p, 'PLAY_SERVICE_ACCOUNT')] = play.secret;
|
||||
}));
|
||||
return env;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ export async function run(a: ReturnType<typeof parseArgs>): Promise<void> {
|
|||
host: a.get('host'), port: a.get('port'), roots: a.get('roots'), project: a.get('project'), allowOrigin: a.get('allow-origin'),
|
||||
auth: a.get('auth'), tokenFile: a.get('token-file'), introspectUrl: a.get('introspect-url'), introspectKeyFile: a.get('introspect-key-file'),
|
||||
resource: a.get('resource'), issuer: a.get('issuer'), allowRemoteToken: a.has('allow-remote-token'),
|
||||
brokerProfiles: a.get('broker-profiles'), readOnly: a.has('read-only'),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import { homedir } from 'node:os';
|
|||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import pkg from '../package.json' with { type: 'json' };
|
||||
import { handle, LEGACY, PROTOCOL } from './mcp';
|
||||
import { TOOLS } from './mcp-tools';
|
||||
import { brokerConfig, brokerProfileEnv } from './lib/broker';
|
||||
import { runCommand } from './lib/runner';
|
||||
|
||||
export const DEFAULT_PORT = 7787;
|
||||
|
|
@ -36,10 +38,14 @@ export interface HttpOptions {
|
|||
roots: string[];
|
||||
project: string | null;
|
||||
allowOrigins: string[];
|
||||
/** Credential profiles to take from the broker vault for every call (no key files on this host). */
|
||||
brokerProfiles?: string[];
|
||||
/** Only the tools that change nothing (no dry_run tools at all). */
|
||||
readOnly?: boolean;
|
||||
auth: { kind: 'token'; token: string } | { kind: 'introspect'; url: string; key: string; resource: string; issuer: string };
|
||||
}
|
||||
|
||||
export const PROJECT_PROP = { type: 'string', description: 'Absolute path of the app folder (the one with storebound.config.ts) to run in. Required unless the server was started with --project.' } as const;
|
||||
export const PROJECT_PROP = { type: 'string', description: 'The app folder (the one with storebound.config.ts) to run in: an absolute path, or a folder name under the server roots. Required unless the server was started with --project.' } as const;
|
||||
|
||||
/** Inside one of the roots (path compare, case-insensitive on Windows); no "..", no other drive. */
|
||||
export function insideRoots(dir: string, roots: string[]): boolean {
|
||||
|
|
@ -50,7 +56,12 @@ export function insideRoots(dir: string, roots: string[]): boolean {
|
|||
/** The folder a call runs in, or the reason it cannot run. */
|
||||
export function projectFor(arg: unknown, o: Pick<HttpOptions, 'roots' | 'project'>): { dir: string } | { error: string } {
|
||||
if (arg === undefined || arg === null || arg === '') return o.project ? { dir: o.project } : { error: 'project: required (absolute path of the app folder)' };
|
||||
if (typeof arg !== 'string' || !isAbsolute(arg)) return { error: 'project: must be an absolute path' };
|
||||
// a plain folder name is a project under one of the roots (a shared server keeps its checkouts there)
|
||||
if (typeof arg === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(arg)) {
|
||||
const hit = o.roots.map(r => join(r, arg)).find(d => { try { return statSync(d).isDirectory(); } catch { return false; } });
|
||||
return hit ? { dir: resolve(hit) } : { error: `project: no folder ${arg} under ${o.roots.join(', ')}` };
|
||||
}
|
||||
if (typeof arg !== 'string' || !isAbsolute(arg)) return { error: 'project: must be an absolute path, or a folder name under the roots' };
|
||||
if (!insideRoots(arg, o.roots)) return { error: `project: ${arg} is outside the server's roots (${o.roots.join(', ')})` };
|
||||
try { if (!statSync(arg).isDirectory()) return { error: `project: ${arg} is not a folder` }; } catch { return { error: `project: ${arg} does not exist` }; }
|
||||
return { dir: resolve(arg) };
|
||||
|
|
@ -116,10 +127,11 @@ async function readBody(req: IncomingMessage): Promise<string | null> {
|
|||
}
|
||||
|
||||
/** One POST body -> the answer (null for a notification). tools/list gains `project`; tools/call runs in it. */
|
||||
export async function answer(msg: Record<string, unknown>, o: Pick<HttpOptions, 'roots' | 'project'>,
|
||||
export async function answer(msg: Record<string, unknown>, o: Pick<HttpOptions, 'roots' | 'project'> & { readOnly?: boolean },
|
||||
run: (argv: string[], cwd: string) => Promise<{ code: number; out: string }> = runIn): Promise<Record<string, unknown> | null> {
|
||||
const params = (msg.params ?? {}) as Record<string, unknown>;
|
||||
if (msg.method === 'tools/call') {
|
||||
if (o.readOnly && TOOLS.find(t => t.name === params.name)?.write) return { jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32602, message: `Unknown tool: ${String(params.name)} (this server is read-only)` } };
|
||||
const args = { ...((params.arguments ?? {}) as Record<string, unknown>) };
|
||||
const p = projectFor(args.project, o);
|
||||
if ('error' in p) return { jsonrpc: '2.0', id: msg.id ?? null, result: { content: [{ type: 'text', text: `Invalid arguments: ${p.error}` }], isError: true } };
|
||||
|
|
@ -128,7 +140,9 @@ export async function answer(msg: Record<string, unknown>, o: Pick<HttpOptions,
|
|||
}
|
||||
const r = await handle(msg as never);
|
||||
if (msg.method === 'tools/list' && r && typeof r.result === 'object') {
|
||||
const tools = (r.result as { tools?: { inputSchema: { properties: Record<string, unknown>; required?: string[] } }[] }).tools ?? [];
|
||||
const res = r.result as { tools?: { name: string; annotations?: { readOnlyHint?: boolean }; inputSchema: { properties: Record<string, unknown>; required?: string[] } }[] };
|
||||
if (o.readOnly) res.tools = (res.tools ?? []).filter(t => t.annotations?.readOnlyHint === true);
|
||||
const tools = res.tools ?? [];
|
||||
for (const t of tools) {
|
||||
t.inputSchema.properties = { project: PROJECT_PROP, ...t.inputSchema.properties };
|
||||
if (!o.project) t.inputSchema.required = ['project', ...(t.inputSchema.required ?? [])];
|
||||
|
|
@ -137,13 +151,29 @@ export async function answer(msg: Record<string, unknown>, o: Pick<HttpOptions,
|
|||
return r;
|
||||
}
|
||||
|
||||
let brokerEnv: { env: Promise<Record<string, string>>; until: number } | null = null;
|
||||
/** The broker profiles' credentials as child env, fetched at most every 10 minutes (memory only, never logged). */
|
||||
async function profileEnvFor(profiles: string[] | undefined): Promise<Record<string, string>> {
|
||||
if (!profiles?.length) return {};
|
||||
if (!brokerEnv || brokerEnv.until < Date.now()) {
|
||||
const cfg = brokerConfig();
|
||||
if (!cfg) throw new Error('--broker-profiles needs a credential broker (~/.storebound/broker.json or STOREBOUND_BROKER_URL)');
|
||||
const env = brokerProfileEnv(cfg, profiles);
|
||||
brokerEnv = { env, until: Date.now() + 600_000 };
|
||||
env.catch(() => { brokerEnv = null; });
|
||||
}
|
||||
return brokerEnv.env;
|
||||
}
|
||||
let serverProfiles: string[] | undefined;
|
||||
|
||||
async function runIn(argv: string[], cwd: string): Promise<{ code: number; out: string }> {
|
||||
const r = runCommand(argv, { cwd, env: { STOREBOUND_MCP: '1' } });
|
||||
const r = runCommand(argv, { cwd, env: { STOREBOUND_MCP: '1', ...(await profileEnvFor(serverProfiles)) } });
|
||||
const { code, out } = await r.result;
|
||||
return { code, out };
|
||||
}
|
||||
|
||||
export function startHttp(o: HttpOptions): Promise<{ close: () => void; port: number }> {
|
||||
serverProfiles = o.brokerProfiles;
|
||||
const resourceMeta = o.auth.kind === 'introspect' ? metadataUrl(o.auth.resource) : null;
|
||||
const server = createServer(async (req, res) => {
|
||||
const path = (req.url ?? '/').split('?')[0];
|
||||
|
|
@ -190,7 +220,8 @@ export function tokenFrom(file: string): string {
|
|||
const home = (p: string) => (p.startsWith('~') ? join(homedir(), p.slice(1)) : p);
|
||||
|
||||
export interface HttpFlags { host?: string; port?: string; roots?: string; project?: string; allowOrigin?: string; auth?: string; tokenFile?: string;
|
||||
introspectUrl?: string; introspectKeyFile?: string; resource?: string; issuer?: string; allowRemoteToken?: boolean }
|
||||
introspectUrl?: string; introspectKeyFile?: string; resource?: string; issuer?: string; allowRemoteToken?: boolean;
|
||||
brokerProfiles?: string; readOnly?: boolean }
|
||||
|
||||
export async function serveHttp(f: HttpFlags): Promise<void> {
|
||||
const host = f.host ?? '127.0.0.1';
|
||||
|
|
@ -212,6 +243,8 @@ export async function serveHttp(f: HttpFlags): Promise<void> {
|
|||
} else throw new Error('--auth token | introspect');
|
||||
if (!['127.0.0.1', 'localhost', '::1'].includes(host) && kind === 'token' && !f.allowRemoteToken)
|
||||
throw new Error(`--host ${host} listens beyond this machine: use --auth introspect (or --allow-remote-token behind your own TLS proxy)`);
|
||||
const s = await startHttp({ host, port, roots, project, allowOrigins, auth });
|
||||
console.error(`[storebound mcp] ${pkg.version} on http://${host}:${s.port}/mcp (MCP ${PROTOCOL}, legacy ${LEGACY.join(', ')}), auth ${kind}, roots ${roots.join(', ')}${project ? `, default project ${project}` : ''}`);
|
||||
const brokerProfiles = (f.brokerProfiles ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (brokerProfiles.length) await profileEnvFor(brokerProfiles).catch(e => { throw new Error(`--broker-profiles: ${(e as Error).message}`); });
|
||||
const s = await startHttp({ host, port, roots, project, allowOrigins, auth, brokerProfiles, readOnly: !!f.readOnly });
|
||||
console.error(`[storebound mcp] ${pkg.version} on http://${host}:${s.port}/mcp (MCP ${PROTOCOL}, legacy ${LEGACY.join(', ')}), auth ${kind}${f.readOnly ? ', read-only' : ''}${brokerProfiles.length ? `, broker profiles ${brokerProfiles.join(', ')}` : ''}, roots ${roots.join(', ')}${project ? `, default project ${project}` : ''}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ refused unless `--allow-origin` names it.
|
|||
storebound mcp --http --host 0.0.0.0 --auth introspect --introspect-url https://auth.example.com/introspect \
|
||||
--introspect-key-file ~/.storebound/introspect.key --resource https://mcp.example.com/mcp --issuer https://auth.example.com
|
||||
```
|
||||
- `--read-only`: only the tools that change nothing are listed and callable.
|
||||
- `--broker-profiles a,b`: a host that keeps no key files takes these credential profiles from the credential broker's
|
||||
vault (`GET /api/store-credentials/<profile>/asc-api-key | play-service-account`, a key with the `store-credentials`
|
||||
scope; docs/credential-broker.md). They reach each tool call as `STOREBOUND_PROFILE_<NAME>_*` env, held in memory for
|
||||
10 minutes and never written. A project picks its profile as usual (the config's `account`).
|
||||
- `project` may also be a folder name under the roots (a shared server keeps its checkouts there).
|
||||
- `/health` answers `{ ok, version }` without auth.
|
||||
|
||||
## Desktop machine interface
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue