mcp --http --public: key-less public server (skills, pitfalls, limits, listing lint, App Store search), rate limit, 64 KB bodies

This commit is contained in:
CRTkafa 2026-10-29 18:54:58 +07:00
parent 398d8cd375
commit c500866295
8 changed files with 695 additions and 15 deletions

View file

@ -2,6 +2,7 @@
// 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]
// storebound mcp --http --public [--port 7788] [--host 127.0.0.1] [--rate 60] [--trust-proxy] (see below)
// [--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
@ -9,6 +10,9 @@
// 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 <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
@ -25,12 +29,17 @@ 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;
@ -42,7 +51,9 @@ export interface HttpOptions {
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 };
/** 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;
@ -96,7 +107,7 @@ const bearer = (req: IncomingMessage): string | null => { const h = req.headers.
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 (!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);
@ -115,14 +126,14 @@ async function authorized(req: IncomingMessage, o: HttpOptions): Promise<boolean
return ok;
}
function send(res: ServerResponse, status: number, body?: unknown, headers: Record<string, string> = {}): void {
function sendRaw(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> {
async function readBody(req: IncomingMessage, max = MAX_BODY): 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); }
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');
}
@ -151,6 +162,9 @@ export async function answer(msg: Record<string, unknown>, o: Pick<HttpOptions,
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<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>> {
@ -174,6 +188,11 @@ async function runIn(argv: string[], cwd: string): Promise<{ code: number; 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<string, string> = 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<string, string> = {}) => 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];
@ -181,17 +200,21 @@ export function startHttp(o: HttpOptions): Promise<{ close: () => void; port: nu
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)))
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);
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 = await answer(msg as Record<string, unknown>, o);
const r = pub ? await handle(msg as never, noRun, pub.surface) : 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) } }); }
});
@ -221,10 +244,20 @@ 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 }
brokerProfiles?: string; readOnly?: boolean; public?: boolean; rate?: string; trustProxy?: boolean }
export async function serveHttp(f: HttpFlags): Promise<void> {
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;