Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/25759-mcp-stateless-get-405.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/mcp': patch
'@objectstack/runtime': patch
---

fix(mcp,runtime)!: the stateless `/api/v1/mcp` surface answers `GET` and `DELETE` with `405` and `Allow: POST`, so an MCP client stops re-opening a GET stream that ends at once

Clause-②: yes (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) A transport verb answer on the MCP HTTP surface: no key of any metadata schema is removed, renamed or re-shaped, no export, type or error code is removed or renamed, and the 405 body is the MCP SDK transport's own. There is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing at the MCP HTTP transport (`MCPServerRuntime.handleHttpRequest`, `@objectstack/mcp`), and on the standalone server's `/api/v1/mcp` route (`createDispatcherPlugin`, `@objectstack/runtime`).

- **Before.** The transport is stateless (no session id) and closes its per-request server when the request ends. A `GET` still reached the SDK's stream handler, which answered `200 text/event-stream` and then lost the stream when the server closed. An MCP client reads a GET stream that ends as a dropped connection and opens another one: claude-code re-opened it 2–3 times a second for as long as a session stayed connected. A `DELETE` answered `200` and ended nothing, because no session exists. Every other verb answered `405` with `Allow: GET, POST, DELETE`.
- **Now.** Every verb but `POST` that reaches the transport (`OPTIONS` with CORS off; with CORS on the CORS middleware's `204` answers first on both doors) answers `405` with `Allow: POST` and the JSON-RPC body `{"jsonrpc":"2.0","error":{"code":-32000,"message":"Method not allowed."},"id":null}`, before a server is built. This is the Streamable HTTP transport's answer for a server that offers no GET stream and does not end sessions. The MCP SDK client treats a `405` on GET as "no stream offered" and does not retry it.
- **The standalone server.** `createDispatcherPlugin` mounts `/api/v1/mcp` for `GET`, `POST` and `DELETE`, and now also declares it a path whose domain decides every verb (the declaration `/mcp/skill` and `/keys` already carry). Before, a `PUT`, a `PATCH` or an `OPTIONS` with CORS off matched no route there and the HTTP adapter answered it itself: `405` with `Allow: DELETE, GET, HEAD, POST` and a `METHOD_NOT_ALLOWED` envelope, to an anonymous caller too. Now those verbs reach the dispatcher like the mounted ones: an anonymous caller gets `401`, a signed-in one the transport's `405` with `Allow: POST`. A `createHonoApp` host already handed every verb to the dispatcher, so both doors now answer every verb alike.
- **Unchanged.** `POST` (JSON-RPC: `initialize`, `tools/list`, `tools/call`, …) answers exactly as before. An anonymous caller is refused `401` before any verb answer, on every verb and on both doors.
- **Who sees it.** A client that opens the GET stream on `/api/v1/mcp` now gets `405` instead of a `200` stream that ends at once. Nothing was ever sent on that stream. On the standalone server, an anonymous `PUT`, `PATCH` or `OPTIONS` (CORS off; with CORS on the CORS middleware's `204` answers `OPTIONS` first on both doors) on `/api/v1/mcp` now gets `401` instead of `405`.

**The remedy.** None needed for an MCP client: it stops opening the stream. A probe or health check that read `GET /api/v1/mcp` as `200` should send a `POST initialize` instead.
210 changes: 210 additions & 0 deletions packages/mcp/src/mcp-server-runtime.http-verbs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25759] The stateless Streamable HTTP surface serves POST alone: GET and
* DELETE (and every other verb) answer `405` with `Allow: POST`.
*
* ## The defect
*
* `handleHttpRequest` builds a stateless transport (`sessionIdGenerator:
* undefined`, `enableJsonResponse: true`) and closes the per-request server in
* `finally`. Before this change a GET went on to the SDK's `handleGetRequest`,
* which answered `200 text/event-stream`, and the stream closed with the
* server. Measured on `5874d25302`: `200`, empty body, ~13 ms. The SDK client
* reads a GET stream that ends as a dropped connection and opens another one;
* production saw ~183 `GET /api/v1/mcp` a minute from one machine with Claude
* Code sessions open (cloud#3103). A DELETE answered `200` and ended nothing,
* because no session exists.
*
* ## What is pinned
*
* 1. GET, DELETE and the other verbs answer `405`, `Allow: POST`, with the
* SDK transport's own JSON-RPC 405 body, and no bridge member is read.
* 2. POST is unchanged: `initialize` and `tools/list` round trips answer
* `200` with no session id.
* 3. The SDK's own client, connected over this surface, opens the GET stream
* once and never again, and its tool calls still work. This is the
* reconnect loop itself: with the 200 stream, the same client re-opens the
* GET every time it closes.
*/

import { describe, it, expect } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

import { MCPServerRuntime } from './mcp-server-runtime.js';
import type { McpDataBridge } from './mcp-http-tools.js';

const URL_ = 'http://localhost/api/v1/mcp';

/** A bridge that records every member read, so a refused verb can be shown to read none. */
function makeBridge(): McpDataBridge & { calls: string[] } {
const calls: string[] = [];
return {
calls,
async listObjects() {
calls.push('listObjects');
return [{ name: 'task', label: 'Task', fieldCount: 1 }];
},
async describeObject() {
calls.push('describeObject');
return { name: 'task', fields: [{ name: 'title', type: 'text' }] };
},
async query(object: string) {
calls.push('query');
return { object, records: [], total: 0 };
},
async get(_object: string, id: string) {
calls.push('get');
return { id };
},
async create(object: string, data: any) {
calls.push('create');
return { object, id: 'n1', record: data };
},
async update(object: string, id: string, data: any) {
calls.push('update');
return { object, id, record: data };
},
async remove(object: string, id: string) {
calls.push('remove');
return { object, id, success: true };
},
};
}

/** The headers a claude-code client sends on its GET (cloud#3103's reading). */
const CLIENT_GET_HEADERS = {
accept: 'text/event-stream',
'mcp-protocol-version': '2025-06-18',
'x-api-key': 'osk_test',
};

const INIT = {
jsonrpc: '2.0',
id: 0,
method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 't', version: '1' } },
};

async function post(runtime: MCPServerRuntime, body: unknown, bridge?: McpDataBridge): Promise<Response> {
return runtime.handleHttpRequest(
new Request(URL_, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify(body),
}),
{ bridge, parsedBody: body },
);
}

async function expectMethodNotAllowed(res: Response): Promise<void> {
expect(res.status).toBe(405);
expect(res.headers.get('allow')).toBe('POST');
expect(res.headers.get('content-type')).toContain('application/json');
expect(res.headers.get('mcp-session-id')).toBeNull();
expect(await res.json()).toEqual({
jsonrpc: '2.0',
error: { code: -32000, message: 'Method not allowed.' },
id: null,
});
}

describe('[#25759] the stateless /api/v1/mcp surface answers every verb but POST with 405', () => {
it('GET answers 405 with Allow: POST, not an SSE stream that ends at once', async () => {
const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' });
const bridge = makeBridge();
const res = await runtime.handleHttpRequest(new Request(URL_, { method: 'GET', headers: CLIENT_GET_HEADERS }), {
bridge,
});

expect(res.headers.get('content-type')).not.toContain('text/event-stream');
await expectMethodNotAllowed(res);
expect(bridge.calls, 'a refused verb reads no bridge member').toEqual([]);
});

it('DELETE answers 405 with Allow: POST: there is no session to end', async () => {
const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' });
for (const headers of [{}, { 'mcp-session-id': 'never-issued', 'mcp-protocol-version': '2025-06-18' }]) {
const res = await runtime.handleHttpRequest(new Request(URL_, { method: 'DELETE', headers }), {
bridge: makeBridge(),
});
await expectMethodNotAllowed(res);
}
});

it('every other verb answers 405 with Allow: POST (the SDK used to name GET and DELETE in Allow)', async () => {
const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' });
for (const method of ['PUT', 'PATCH', 'OPTIONS']) {
const res = await runtime.handleHttpRequest(new Request(URL_, { method }), { bridge: makeBridge() });
await expectMethodNotAllowed(res);
}
const head = await runtime.handleHttpRequest(new Request(URL_, { method: 'HEAD' }), { bridge: makeBridge() });
expect(head.status).toBe(405);
expect(head.headers.get('allow')).toBe('POST');
});

it('POST is unchanged: initialize and tools/list round trips answer 200 with no session id', async () => {
const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' });
const bridge = makeBridge();

const init = await post(runtime, INIT, bridge);
expect(init.status).toBe(200);
expect(init.headers.get('mcp-session-id')).toBeNull();
const initJson: any = await init.json();
expect(initJson.result.serverInfo).toEqual({ name: 'objectstack-test', version: '9.9.9' });
expect(initJson.result.capabilities.tools).toBeDefined();

const initialized = await post(runtime, { jsonrpc: '2.0', method: 'notifications/initialized' }, bridge);
expect(initialized.status).toBe(202);

const list = await post(runtime, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, bridge);
expect(list.status).toBe(200);
const names = ((await list.json()) as any).result.tools.map((t: any) => t.name);
expect(names).toContain('query_records');
expect(names).toContain('list_objects');
});

it('the SDK client opens the GET stream once and never again, and its calls still work', async () => {
const runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' });
const bridge = makeBridge();
const seen: string[] = [];

// Route the client's fetch straight into the surface, as a host's dispatcher does.
const fetchIntoSurface = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const request = new Request(input, init);
seen.push(request.method);
const text = request.method === 'POST' ? await request.text() : '';
return runtime.handleHttpRequest(request, { bridge, parsedBody: text ? JSON.parse(text) : undefined });
};

const transport = new StreamableHTTPClientTransport(new URL(URL_), {
fetch: fetchIntoSurface as typeof fetch,
// A reconnect as fast as the loop can go, so a dropped GET stream shows
// up as many GETs inside the window below rather than one per second.
reconnectionOptions: {
initialReconnectionDelay: 5,
maxReconnectionDelay: 5,
reconnectionDelayGrowFactor: 1,
maxRetries: 1000,
},
});
const client = new Client({ name: 'verb-test', version: '1.0.0' });
const errors: unknown[] = [];
client.onerror = (err) => errors.push(err);

try {
await client.connect(transport);
const tools = await client.listTools();
expect(tools.tools.map((t) => t.name)).toContain('query_records');

await new Promise((resolve) => setTimeout(resolve, 250));

const gets = seen.filter((m) => m === 'GET').length;
expect(gets, `the client opened the GET stream ${gets} times: ${seen.join(' ')}`).toBe(1);
expect(errors, 'a 405 on GET is the "no stream offered" answer, not a client error').toEqual([]);
} finally {
await client.close();
}
});
});
51 changes: 51 additions & 0 deletions packages/mcp/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,35 @@ export async function buildObjectListResource(
};
}

/**
* The verbs the stateless Streamable HTTP surface serves: POST alone. Sent as
* the `Allow` header of {@link methodNotAllowedResponse}.
*/
const MCP_HTTP_ALLOWED_METHODS = 'POST';

/**
* The stateless transport's answer to any verb but POST (#25759): `405` with
* `Allow: POST`. The body is the SDK transport's own 405 body
* (`handleUnsupportedRequest`), so a client reads the same JSON-RPC error it
* read for an unsupported verb before; only the status of GET and DELETE and
* the `Allow` value change.
*/
function methodNotAllowedResponse(): Response {
return new Response(
// Mirrors `handleUnsupportedRequest` in the SDK's
// `server/webStandardStreamableHttp.js`, `Allow` aside.
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message: 'Method not allowed.' },
id: null,
}),
{
status: 405,
headers: { Allow: MCP_HTTP_ALLOWED_METHODS, 'Content-Type': 'application/json' },
},
);
}

/**
* MCPServerRuntime — Bridges ObjectStack kernel services to the Model Context Protocol.
*
Expand Down Expand Up @@ -1624,6 +1653,8 @@ export class MCPServerRuntime {
* Stateless by design: a fresh {@link McpServer} + transport is built per
* request (the SDK-recommended pattern for stateless HTTP — it avoids any
* cross-request session/request-id collision and keeps each call isolated).
* So it serves POST alone: GET (a server-initiated SSE stream), DELETE (a
* session end) and every other verb answer `405` with `Allow: POST` (#25759).
* The tool set is the object-CRUD bridge plus — when the bridge can resolve
* the framework's action mechanism — the business-action tools
* (`list_actions` / `run_action`, and `resume_run` when the bridge can
Expand Down Expand Up @@ -1660,6 +1691,26 @@ export class MCPServerRuntime {
toolOptions?: RegisterObjectToolsOptions & RegisterActionToolsOptions;
} = {},
): Promise<Response> {
// [#25759] This transport answers POST and nothing else. It is stateless
// (no session id) and the per-request server is closed in `finally`, so it
// can hold no server-initiated SSE stream and has no session to end:
// - GET used to reach the SDK's `handleGetRequest`, which answered `200
// text/event-stream` and then lost the stream when `finally` closed the
// server. The client read a dropped stream and opened another one, 2–3
// times a second for as long as a session stayed connected. The Streamable
// HTTP transport gives a server with no GET stream one answer: `405`, and
// the SDK client then stops opening one (`client/streamableHttp.js`
// treats 405 on GET as "this server offers no stream").
// - DELETE ends a session. The SDK client sends it only for a session id
// the server issued, and this server never issues one; the transport
// lets a server that does not end sessions answer `405`.
// Every other verb was a 405 already, with an `Allow` that named GET and
// DELETE. All of them are answered here, before a server is built, so a
// refused verb pays for no tool registration.
if (request.method !== 'POST') {
return methodNotAllowedResponse();
}

// The prompt surface is wired by capability, like the action tools: a
// bridge that cannot read skill metadata gets no `prompts` capability and
// no handlers, rather than a capability that answers nothing.
Expand Down
1 change: 1 addition & 0 deletions packages/qa/http-conformance/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"devDependencies": {
"@objectstack/driver-sqlite-wasm": "workspace:*",
"@objectstack/hono": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/plugin-approvals": "workspace:*",
"@objectstack/plugin-auth": "workspace:*",
Expand Down
Loading
Loading