// `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 ] [--project ] // [--auth token|introspect] [--token-file ~/.storebound/mcp-token] // storebound mcp --http --public [--port 7788] [--host 127.0.0.1] [--rate 60] [--trust-proxy] (see below) // [--introspect-url --introspect-key-file --resource --issuer ] // 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. // Public (--public): the key-less server behind a public URL. No sign-in, no project, no store credentials; it serves only the // read-only tools of mcp-public.ts (skills, pitfalls, limits, listing lint, App Store search), never starts a process, rate // limited per client (--rate per minute, 429 + Retry-After; --trust-proxy reads the first X-Forwarded-For entry), body <= 64 KB. // Auth (every request to /mcp): // token (default): `Authorization: Bearer `, 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 { clientKey, createLimiter, publicSurface, type PublicDeps } from './mcp-public'; import { TOOLS } from './mcp-tools'; import { brokerConfig, brokerProfileEnv } from './lib/broker'; import { runCommand } from './lib/runner'; export const DEFAULT_PORT = 7787; export const PUBLIC_PORT = 7788; const MAX_BODY = 4 * 1024 * 1024; /** Public mode: one JSON-RPC message of a read-only tool never needs more. */ export const PUBLIC_MAX_BODY = 64 * 1024; export const PUBLIC_DEFAULT_RATE = 60; export interface HttpOptions { host: string; port: number; 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; /** Public key-less mode (`--public`): auth kind 'none', the tool surface of mcp-public.ts, a rate limit per client. */ public?: { rate: number; trustProxy: boolean } & PublicDeps; auth: { kind: 'none' } | { kind: 'token'; token: string } | { kind: 'introspect'; url: string; key: string; resource: string; issuer: string }; } 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 { 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): { 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)' }; // 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) }; } /** 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(); async function authorized(req: IncomingMessage, o: HttpOptions): Promise { const t = bearer(req); if (!t || o.auth.kind === 'none') 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 sendRaw(res: ServerResponse, status: number, body?: unknown, headers: Record = {}): 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, max = MAX_BODY): Promise { let size = 0; const parts: Buffer[] = []; for await (const c of req) { size += (c as Buffer).length; if (size > max) 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, 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 } }; 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 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 ?? [])]; } } return r; } /** Public mode never runs the CLI: a call that would is a bug, not a request. */ const noRun = async (): Promise<{ code: number; out: string }> => { throw new Error('public mode does not run commands'); }; let brokerEnv: { env: Promise>; 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', ...(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; if (o.auth.kind === 'none' && !o.public) throw new Error('auth none is only for --public'); const pub = o.public ? { limiter: createLimiter(o.public.rate), surface: publicSurface(o.public), trustProxy: o.public.trustProxy } : null; // no ambient credential in public mode, so any browser origin may call it const cors: Record = pub ? { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, accept, mcp-protocol-version', 'access-control-max-age': '86400' } : {}; const send = (res: ServerResponse, status: number, body?: unknown, headers: Record = {}) => sendRaw(res, status, body, { ...cors, ...headers }); 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 (pub) { if (req.method === 'OPTIONS') return send(res, 204); const hit = pub.limiter.hit(clientKey(req.headers['x-forwarded-for'], req.socket.remoteAddress, pub.trustProxy)); if (!hit.ok) return send(res, 429, { error: 'rate limit exceeded' }, { 'retry-after': String(hit.retryAfter) }); } else if (!originAllowed(req.headers.origin, o.allowOrigins)) return send(res, 403, { error: 'origin not allowed' }); if (!pub && !(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, pub ? PUBLIC_MAX_BODY : MAX_BODY); 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 = pub ? await handle(msg as never, noRun, pub.surface) : await answer(msg as Record, 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) } }); } }); // a tool call can run for many minutes (check on a big project, uploads): Node's 300 s request timeout would cut it server.requestTimeout = 0; server.timeout = 0; 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; brokerProfiles?: string; readOnly?: boolean; public?: boolean; rate?: string; trustProxy?: boolean } export async function serveHttp(f: HttpFlags): Promise { const host = f.host ?? '127.0.0.1'; if (f.public) { const bad = (['auth', 'tokenFile', 'roots', 'project', 'brokerProfiles', 'allowOrigin', 'introspectUrl'] as const).filter(k => f[k] !== undefined); if (bad.length || f.readOnly) throw new Error('--public takes no credentials or project: do not combine it with --auth, --token-file, --roots, --project, --broker-profiles, --allow-origin, --introspect-* or --read-only'); const rate = Number(f.rate ?? PUBLIC_DEFAULT_RATE); if (!Number.isInteger(rate) || rate < 1) throw new Error('--rate: requests per minute per client, a whole number >= 1'); const port = Number(f.port ?? PUBLIC_PORT); const s = await startHttp({ host, port, roots: [], project: null, allowOrigins: [], auth: { kind: 'none' }, public: { rate, trustProxy: !!f.trustProxy } }); console.error(`[storebound mcp] ${pkg.version} PUBLIC key-less server on http://${host}:${s.port}/mcp (MCP ${PROTOCOL}, legacy ${LEGACY.join(', ')}), read-only, ${rate} requests/min per client${f.trustProxy ? ', X-Forwarded-For trusted' : ''}`); return; } 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 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}` : ''}`); }