diff --git a/cli/data/flags.json b/cli/data/flags.json index d813c82..0879954 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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", diff --git a/cli/lib/broker.ts b/cli/lib/broker.ts index 41e171b..fb8f3b5 100644 --- a/cli/lib/broker.ts +++ b/cli/lib/broker.ts @@ -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//, 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 } | 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) : {} }; +} + +/** The env a child process needs to use these profiles from the broker (STOREBOUND_PROFILE__, inline PEM / + * JSON; see credentials.ts). Profiles the broker has nothing for are left out. */ +export async function brokerProfileEnv(cfg: BrokerConfig, profiles: string[]): Promise> { + const env: Record = {}; + 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; +} diff --git a/cli/mcp-cmd.ts b/cli/mcp-cmd.ts index 390dcb2..e16bc25 100644 --- a/cli/mcp-cmd.ts +++ b/cli/mcp-cmd.ts @@ -9,5 +9,6 @@ export async function run(a: ReturnType): Promise { 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'), }); } diff --git a/cli/mcp-http.ts b/cli/mcp-http.ts index 1fe8c03..36ed269 100644 --- a/cli/mcp-http.ts +++ b/cli/mcp-http.ts @@ -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): { 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 { } /** One POST body -> the answer (null for a notification). tools/list gains `project`; tools/call runs in it. */ -export async function answer(msg: Record, o: Pick, +export async function answer(msg: Record, o: Pick & { readOnly?: boolean }, run: (argv: string[], cwd: string) => Promise<{ code: number; out: string }> = runIn): Promise | null> { const params = (msg.params ?? {}) as Record; 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) }; 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, o: Pick; required?: string[] } }[] }).tools ?? []; + const res = r.result as { tools?: { name: string; annotations?: { readOnlyHint?: boolean }; inputSchema: { properties: Record; 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, o: Pick>; 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> { + 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 { const host = f.host ?? '127.0.0.1'; @@ -212,6 +243,8 @@ export async function serveHttp(f: HttpFlags): Promise { } 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}` : ''}`); } diff --git a/docs/mcp.md b/docs/mcp.md index efe9cb4..dbbe6fd 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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//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__*` 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 diff --git a/tests/mcp-http.test.ts b/tests/mcp-http.test.ts index af1f031..7c11426 100644 --- a/tests/mcp-http.test.ts +++ b/tests/mcp-http.test.ts @@ -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__* 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(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(); } + }); +});