mcp --http: one Streamable HTTP server for every session (token or OAuth introspection auth, project argument per call, roots, origin check)
This commit is contained in:
parent
28a50e7d2e
commit
5f73fcbcf4
5 changed files with 359 additions and 2 deletions
|
|
@ -10,7 +10,7 @@ export const PLAIN: Record<string, [() => Promise<Plain>, string]> = {
|
|||
'init': [() => import('./init'), 'copy the templates (config, listing, copy, declarations, privacy policy) into this project; --profile app|game|subscription-app (default app)'],
|
||||
'import': [() => import('./import'), 'fastlane [--dir fastlane] [--profile app|game|subscription-app] [--dry]: Appfile, deliver + supply metadata and screenshots -> config, store/*.md, shots frames (read-only on fastlane, never overwrites)'],
|
||||
'install': [() => import('./install'), 'skills + MCP server for --agent claude|codex|cursor|copilot|windsurf|gemini|opencode'],
|
||||
'mcp': [async () => ({ run: async () => (await import('./mcp')).serve() }), 'stdio MCP server wrapping these commands (for any MCP-capable AI tool)'],
|
||||
'mcp': [() => import('./mcp-cmd'), 'MCP server wrapping these commands (for any MCP-capable AI tool): stdio, or --http [--port 7787] [--roots <dirs>] [--auth token|introspect] for one server every session shares'],
|
||||
'watch': [() => import('./watch'), 'store events for every app on this machine: rejected, approved, live, rollout halted, crash threshold, low reviews; loops every --every 15m or --once; sinks --notify os|ntfy|telegram|webhook|ingest; test sends a test event; --dry, --dir <folder>, --json (read-only on the stores)'],
|
||||
'status': [() => import('./status'), '[--app <name|store id|folder>] [--json]: per platform the live and next version, build, track, review state and rollout %, without uploading (read-only; read by ship pipelines, docs/ship-step.md)'],
|
||||
'events': [() => import('./events'), '[--since 24h] [--app <name>] [--kind rejected] [--json]: the events watch recorded in ~/.storebound/events.jsonl (read-only)'],
|
||||
|
|
|
|||
|
|
@ -645,7 +645,21 @@
|
|||
"to",
|
||||
"translate"
|
||||
],
|
||||
"mcp": [],
|
||||
"mcp": [
|
||||
"allow-origin",
|
||||
"allow-remote-token",
|
||||
"auth",
|
||||
"host",
|
||||
"http",
|
||||
"introspect-key-file",
|
||||
"introspect-url",
|
||||
"issuer",
|
||||
"port",
|
||||
"project",
|
||||
"resource",
|
||||
"roots",
|
||||
"token-file"
|
||||
],
|
||||
"onboard": [
|
||||
"accounts",
|
||||
"all",
|
||||
|
|
@ -1159,7 +1173,9 @@
|
|||
"allow-availability-changes",
|
||||
"allow-device-gap",
|
||||
"allow-existing",
|
||||
"allow-origin",
|
||||
"allow-price-changes",
|
||||
"allow-remote-token",
|
||||
"allow-ssh",
|
||||
"allow-stale-web",
|
||||
"allow-version-mismatch",
|
||||
|
|
@ -1172,6 +1188,7 @@
|
|||
"apps",
|
||||
"archive",
|
||||
"aso",
|
||||
"auth",
|
||||
"base",
|
||||
"base-url",
|
||||
"beta",
|
||||
|
|
@ -1243,6 +1260,8 @@
|
|||
"headless",
|
||||
"home",
|
||||
"hook",
|
||||
"host",
|
||||
"http",
|
||||
"icon",
|
||||
"icons",
|
||||
"identity",
|
||||
|
|
@ -1251,9 +1270,12 @@
|
|||
"install",
|
||||
"internal",
|
||||
"internal-only",
|
||||
"introspect-key-file",
|
||||
"introspect-url",
|
||||
"invite",
|
||||
"ios",
|
||||
"issue",
|
||||
"issuer",
|
||||
"issues",
|
||||
"jobs",
|
||||
"keep",
|
||||
|
|
@ -1350,10 +1372,12 @@
|
|||
"render",
|
||||
"repair-stuck",
|
||||
"resolve-iaps",
|
||||
"resource",
|
||||
"result-file",
|
||||
"resume",
|
||||
"review-sent",
|
||||
"root",
|
||||
"roots",
|
||||
"scan-paths",
|
||||
"scenes",
|
||||
"scheduled",
|
||||
|
|
@ -1387,6 +1411,7 @@
|
|||
"threshold",
|
||||
"timeout",
|
||||
"to",
|
||||
"token-file",
|
||||
"top",
|
||||
"track",
|
||||
"tracking-domains",
|
||||
|
|
|
|||
13
cli/mcp-cmd.ts
Normal file
13
cli/mcp-cmd.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// `storebound mcp`: stdio by default (one process per agent session), or `--http` for one shared server
|
||||
// (cli/mcp-http.ts: every session of the machine, or a team behind OAuth).
|
||||
import type { parseArgs } from './lib/args';
|
||||
|
||||
export async function run(a: ReturnType<typeof parseArgs>): Promise<void> {
|
||||
if (!a.has('http')) return (await import('./mcp')).serve();
|
||||
const { serveHttp } = await import('./mcp-http');
|
||||
await serveHttp({
|
||||
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'),
|
||||
});
|
||||
}
|
||||
214
cli/mcp-http.ts
Normal file
214
cli/mcp-http.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// `storebound mcp --http`: the same MCP server over Streamable HTTP, so one process serves every agent session on a
|
||||
// machine (or one host serves a whole team) instead of one stdio process per session.
|
||||
// storebound mcp --http [--host 127.0.0.1] [--port 7787] [--roots <dir,dir>] [--project <dir>]
|
||||
// [--auth token|introspect] [--token-file ~/.storebound/mcp-token]
|
||||
// [--introspect-url <url> --introspect-key-file <file> --resource <url> --issuer <url>]
|
||||
// Transport (https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http): one endpoint,
|
||||
// /mcp. POST carries one JSON-RPC message and gets one JSON answer (202 for a notification); GET and DELETE are 405
|
||||
// (no server-initiated stream, no session state: every call is independent, so no Mcp-Session-Id is issued). The
|
||||
// Origin header, when sent, must be a localhost origin unless --allow-origin names it (DNS rebinding).
|
||||
// Project: every tool gets a `project` argument, the absolute path of the app folder the CLI runs in. It must be inside
|
||||
// --roots (default: the home folder); --project (or STOREBOUND_PROJECT) is the default when a call has none.
|
||||
// Auth (every request to /mcp):
|
||||
// token (default): `Authorization: Bearer <token>`, the token in --token-file (made with mode 600 on first start;
|
||||
// never printed). Clients: claude mcp add --transport http storebound http://127.0.0.1:7787/mcp
|
||||
// --header "Authorization: Bearer $(cat ~/.storebound/mcp-token)".
|
||||
// introspect: an OAuth 2.0 bearer token checked at --introspect-url (RFC 7662; this server authenticates with the
|
||||
// key in --introspect-key-file). The token must be active, carry the `mcp` scope and name --resource in aud
|
||||
// (RFC 8707). Without a valid token: 401 with WWW-Authenticate: Bearer resource_metadata=".../.well-known/
|
||||
// oauth-protected-resource/mcp" (RFC 9728), which names --issuer as the authorization server. Answers are cached
|
||||
// for 60 s per token (hashed); the token itself is never logged.
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
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 { runCommand } from './lib/runner';
|
||||
|
||||
export const DEFAULT_PORT = 7787;
|
||||
const MAX_BODY = 4 * 1024 * 1024;
|
||||
|
||||
export interface HttpOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
roots: string[];
|
||||
project: string | null;
|
||||
allowOrigins: string[];
|
||||
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;
|
||||
|
||||
/** Inside one of the roots (path compare, case-insensitive on Windows); no "..", no other drive. */
|
||||
export function insideRoots(dir: string, roots: string[]): boolean {
|
||||
const norm = (p: string) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p));
|
||||
return roots.some(r => { const rel = relative(norm(r), norm(dir)); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); });
|
||||
}
|
||||
|
||||
/** 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' };
|
||||
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) };
|
||||
}
|
||||
|
||||
/** Localhost origins are always fine; others only when listed. No Origin header (non-browser clients) is fine. */
|
||||
export function originAllowed(origin: string | undefined, allow: string[]): boolean {
|
||||
if (!origin) return true;
|
||||
if (allow.includes(origin)) return true;
|
||||
try { const u = new URL(origin); return ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname); } catch { return false; }
|
||||
}
|
||||
|
||||
/** The protected resource metadata URL for a resource URL (RFC 9728 section 3: the path goes after the well-known part). */
|
||||
export function metadataUrl(resource: string): string {
|
||||
const u = new URL(resource);
|
||||
return `${u.origin}/.well-known/oauth-protected-resource${u.pathname === '/' ? '' : u.pathname}`;
|
||||
}
|
||||
|
||||
/** Introspection answer -> allowed? (active, `mcp` scope, our resource in aud). */
|
||||
export function introspectionOk(j: unknown, resource: string): boolean {
|
||||
if (!j || typeof j !== 'object') return false;
|
||||
const r = j as { active?: unknown; scope?: unknown; aud?: unknown };
|
||||
if (r.active !== true) return false;
|
||||
const scopes = typeof r.scope === 'string' ? r.scope.split(/\s+/) : [];
|
||||
if (!scopes.includes('mcp')) return false;
|
||||
const aud = Array.isArray(r.aud) ? r.aud : typeof r.aud === 'string' ? [r.aud] : [];
|
||||
return aud.includes(resource);
|
||||
}
|
||||
|
||||
const bearer = (req: IncomingMessage): string | null => { const h = req.headers.authorization ?? ''; const m = /^Bearer\s+(\S+)$/i.exec(h); return m ? m[1]! : null; };
|
||||
|
||||
const cache = new Map<string, { ok: boolean; until: number }>();
|
||||
async function authorized(req: IncomingMessage, o: HttpOptions): Promise<boolean> {
|
||||
const t = bearer(req);
|
||||
if (!t) return false;
|
||||
if (o.auth.kind === 'token') {
|
||||
const a = createHash('sha256').update(t).digest(), b = createHash('sha256').update(o.auth.token).digest();
|
||||
return a.equals(b);
|
||||
}
|
||||
const key = createHash('sha256').update(t).digest('hex');
|
||||
const hit = cache.get(key);
|
||||
if (hit && hit.until > Date.now()) return hit.ok;
|
||||
let ok = false;
|
||||
try {
|
||||
const r = await fetch(o.auth.url, { method: 'POST', signal: AbortSignal.timeout(10_000),
|
||||
headers: { authorization: `Bearer ${o.auth.key}`, 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
||||
body: new URLSearchParams({ token: t }).toString() });
|
||||
ok = r.ok && introspectionOk(await r.json(), o.auth.resource);
|
||||
} catch { return false; } // no cache on a network error
|
||||
cache.set(key, { ok, until: Date.now() + 60_000 });
|
||||
return ok;
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, body?: unknown, headers: Record<string, string> = {}): void {
|
||||
res.writeHead(status, { 'cache-control': 'no-store', ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers });
|
||||
res.end(body === undefined ? undefined : JSON.stringify(body));
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string | null> {
|
||||
let size = 0; const parts: Buffer[] = [];
|
||||
for await (const c of req) { size += (c as Buffer).length; if (size > MAX_BODY) return null; parts.push(c as Buffer); }
|
||||
return Buffer.concat(parts).toString('utf8');
|
||||
}
|
||||
|
||||
/** 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'>,
|
||||
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') {
|
||||
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 } };
|
||||
delete args.project;
|
||||
return handle({ ...msg, params: { ...params, arguments: args } } as never, argv => run(argv, p.dir));
|
||||
}
|
||||
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 ?? [];
|
||||
for (const t of tools) {
|
||||
t.inputSchema.properties = { project: PROJECT_PROP, ...t.inputSchema.properties };
|
||||
if (!o.project) t.inputSchema.required = ['project', ...(t.inputSchema.required ?? [])];
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
async function runIn(argv: string[], cwd: string): Promise<{ code: number; out: string }> {
|
||||
const r = runCommand(argv, { cwd, env: { STOREBOUND_MCP: '1' } });
|
||||
const { code, out } = await r.result;
|
||||
return { code, out };
|
||||
}
|
||||
|
||||
export function startHttp(o: HttpOptions): Promise<{ close: () => void; port: number }> {
|
||||
const resourceMeta = o.auth.kind === 'introspect' ? metadataUrl(o.auth.resource) : null;
|
||||
const server = createServer(async (req, res) => {
|
||||
const path = (req.url ?? '/').split('?')[0];
|
||||
if (resourceMeta && o.auth.kind === 'introspect' && req.method === 'GET' && path === new URL(resourceMeta).pathname)
|
||||
return send(res, 200, { resource: o.auth.resource, authorization_servers: [o.auth.issuer], bearer_methods_supported: ['header'], scopes_supported: ['mcp'], resource_name: 'storebound' });
|
||||
if (path === '/health' && req.method === 'GET') return send(res, 200, { ok: true, version: pkg.version });
|
||||
if (path !== '/mcp') return send(res, 404, { error: 'not found' });
|
||||
if (!originAllowed(req.headers.origin, o.allowOrigins)) return send(res, 403, { error: 'origin not allowed' });
|
||||
if (!(await authorized(req, o)))
|
||||
return send(res, 401, { error: 'unauthorized' }, { 'www-authenticate': resourceMeta ? `Bearer resource_metadata="${resourceMeta}"` : 'Bearer' });
|
||||
if (req.method !== 'POST') return send(res, 405, { error: 'POST only (no server-initiated stream)' }, { allow: 'POST' });
|
||||
const body = await readBody(req);
|
||||
if (body === null) return send(res, 413, { error: 'body too large' });
|
||||
let msg: unknown;
|
||||
try { msg = JSON.parse(body); } catch { return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); }
|
||||
if (Array.isArray(msg) || typeof msg !== 'object' || msg === null) return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'One JSON-RPC message per request' } });
|
||||
try {
|
||||
const r = await answer(msg as Record<string, unknown>, o);
|
||||
return r ? send(res, 200, r) : send(res, 202);
|
||||
} catch (e) { return send(res, 500, { jsonrpc: '2.0', id: (msg as { id?: unknown }).id ?? null, error: { code: -32603, message: String((e as Error).message) } }); }
|
||||
});
|
||||
return new Promise((ok, fail) => {
|
||||
server.once('error', fail);
|
||||
server.listen(o.port, o.host, () => {
|
||||
const a = server.address(); const port = typeof a === 'object' && a ? a.port : o.port;
|
||||
ok({ close: () => server.close(), port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The local bearer token: read, or made (32 random bytes, hex, mode 600) on first start. */
|
||||
export function tokenFrom(file: string): string {
|
||||
if (existsSync(file)) { const t = readFileSync(file, 'utf8').trim(); if (t.length >= 32) return t; }
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
const t = randomBytes(32).toString('hex');
|
||||
writeFileSync(file, `${t}\n`, { mode: 0o600 });
|
||||
try { chmodSync(file, 0o600); } catch { /* Windows */ }
|
||||
return t;
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
export async function serveHttp(f: HttpFlags): Promise<void> {
|
||||
const host = f.host ?? '127.0.0.1';
|
||||
const port = Number(f.port ?? DEFAULT_PORT);
|
||||
const roots = (f.roots ?? homedir()).split(',').map(s => resolve(home(s.trim()))).filter(Boolean);
|
||||
const projectArg = f.project ?? process.env.STOREBOUND_PROJECT;
|
||||
const project = projectArg ? resolve(home(projectArg)) : null;
|
||||
const allowOrigins = (f.allowOrigin ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const kind = f.auth ?? 'token';
|
||||
let auth: HttpOptions['auth'];
|
||||
if (kind === 'introspect') {
|
||||
const { introspectUrl: url, introspectKeyFile: keyFile, resource, issuer } = f;
|
||||
if (!url || !keyFile || !resource || !issuer) throw new Error('--auth introspect needs --introspect-url, --introspect-key-file, --resource and --issuer');
|
||||
auth = { kind, url, key: readFileSync(home(keyFile), 'utf8').trim(), resource, issuer };
|
||||
} else if (kind === 'token') {
|
||||
const file = home(f.tokenFile ?? '~/.storebound/mcp-token');
|
||||
auth = { kind, token: tokenFrom(file) };
|
||||
console.error(`[storebound mcp] bearer token in ${file}`);
|
||||
} 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}` : ''}`);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue