diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bdd9eb..2383639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 -- 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). diff --git a/cli/mcp-http.ts b/cli/mcp-http.ts index 3c74e13..d20b184 100644 --- a/cli/mcp-http.ts +++ b/cli/mcp-http.ts @@ -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 \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)); diff --git a/tests/mcp-public.test.ts b/tests/mcp-public.test.ts index c0b141e..26473b2 100644 --- a/tests/mcp-public.test.ts +++ b/tests/mcp-public.test.ts @@ -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 { diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index cc4d1e4..f239fc4 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -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)'));