public MCP: the bare host answers MCP (POST /) and names the connect URL (GET /)

This commit is contained in:
CRTkafa 2026-11-08 02:26:11 +07:00
parent 9081ac921d
commit b82becf7fb
4 changed files with 28 additions and 2 deletions

View file

@ -3,6 +3,8 @@
## Unreleased
- Public MCP server: the bare host is the endpoint too. `POST /` answers MCP like `/mcp` (someone who pastes `https://mcp.example.com` into a connector gets connected), `GET /` names the connect URL. A signed-in server keeps `/` closed.
## 0.8.69 · 2026-11-07
- Play: `play status` and `validate` list every build still active on a testing track below the production build, with the `play release --version-code <n> --<track> completed` that replaces it (an old build with a policy issue, e.g. READ_MEDIA_IMAGES, blocks Send changes for review for the whole app). A completed production release the store page does not show yet reads "Not live yet: in review or blocked before review" instead of "In review" (CLI and desktop). A commit with `--not-sent-for-review` that Play answers with 400 "Changes are sent for review automatically" is redone without the parameter (pitfalls #129).

View file

@ -202,7 +202,13 @@ export function startHttp(o: HttpOptions): Promise<{ close: () => void; port: nu
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' });
// public: the bare host is the MCP endpoint too (people paste "mcp.example.com" into a connector), and a browser
// opening it gets the connect line instead of a 404
if (pub && path === '/' && req.method === 'GET') {
res.writeHead(200, { ...cors, 'content-type': 'text/plain; charset=utf-8' });
return res.end(`storebound MCP server ${pkg.version} (read-only, no key)\nAdd it as a remote MCP server / custom connector with this URL. Claude Code: claude mcp add --transport http storebound <this URL>\n`);
}
if (path !== '/mcp' && !(pub && path === '/')) 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));

View file

@ -235,6 +235,24 @@ describe('http, public mode', () => {
} finally { s.close(); }
});
test('the bare host is the endpoint too: POST / answers MCP, GET / names the connect URL', async () => {
const s = await startHttp({ ...base, public: { rate: 100, trustProxy: false } });
try {
const r = await fetch(`http://127.0.0.1:${s.port}/`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) });
expect(r.status).toBe(200);
expect(((await r.json()) as { result: { tools: unknown[] } }).result.tools.length).toBe(6);
const g = await fetch(`http://127.0.0.1:${s.port}/`);
expect(g.status).toBe(200);
expect(await g.text()).toContain('claude mcp add --transport http storebound');
expect((await fetch(`http://127.0.0.1:${s.port}/other`)).status).toBe(404);
} finally { s.close(); }
});
test('a signed-in server keeps / closed', async () => {
const s = await startHttp({ ...base, auth: { kind: 'token', token: 't' } as const });
try { expect((await fetch(`http://127.0.0.1:${s.port}/`)).status).toBe(404); } finally { s.close(); }
});
test('429 with Retry-After over the limit; X-Forwarded-For counts only with trustProxy', async () => {
const s = await startHttp({ ...base, public: { rate: 2, trustProxy: false } });
try {

View file

@ -614,7 +614,7 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404:
let visited = 0;
const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' ');
const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); };
const SENT = 'In review'; // decided from the store page: 404 = in review (no uncertain wording)
const SENT = 'Not live yet: in review or blocked before review'; // store page 404: the API has no review state (pitfall 129)
try {
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)'));