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

@ -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",

View file

@ -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;
}

View file

@ -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'),
});
}

View file

@ -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}` : ''}`);
}