public MCP: the bare host answers MCP (POST /) and names the connect URL (GET /)
This commit is contained in:
parent
9081ac921d
commit
b82becf7fb
4 changed files with 28 additions and 2 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)'));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue