Skip to content

Commit 55b5042

Browse files
fix(connectors)!: refuse a non-3.x OpenAPI document at load; mcp tools/list follows nextCursor and a 401/403 is a fatal configuration fault (#26798)
Refs #26709 (items 2, 3a, 3b) This PR carries items 2, 3(a) and 3(b) of the card's Work section. Items 1 (declarative `include`), 3(c) (`toolsSnapshotSha256`) and 4 (the offline normalization tool) stay on the card: items 1 and 3(c) each add a `providerConfig` key, which triage's grade sends back to triage before it is built, and item 4 has no home yet. Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: updated here — content/docs/automation/connectors.mdx (no page under skills/ teaches the openapi or mcp provider: `git grep -i "swagger|tools/list|provider: 'openapi'|provider: 'mcp'" -- skills` returns 0 hits) ## 维护者速读 - **改了什么**:声明式 `openapi` 连接器加载时拒收非 OpenAPI 3.x 文档(含 Swagger 2.0);`mcp` 连接器的 `tools/list` 现在跟着 `nextCursor` 翻完所有页(上限 100 页);MCP 服务器对凭据回 401/403 时,实例直接判为配置错误,启动失败、不再重试。 - **为什么改**:目录调研显示 APIs.guru 里约 40% 是 Swagger 2.0,今天它们会"加载成功"但动作是错的、没有任何报错;分页的 MCP 服务器第一页之后的工具全部静默丢失;401 被当成"上游不可用"反复重试,掩盖了凭据配错。 - **风险与回滚**:属于收窄(BREAKING):原本靠 Swagger 2.0 文档或错误凭据"降级启动"的实例,现在会在启动时报错。回滚即 revert 本 PR;两包各一个 changeset,互相独立。 - **先看**:`packages/connectors/connector-mcp/src/mcp-provider.ts`(401/403 判定与 catch 分支,风险最高)、`packages/connectors/connector-mcp/src/mcp-connector.ts`(翻页循环与上限)、`packages/connectors/connector-openapi/src/openapi-provider.ts`(`assertOpenApi3Document`)。 - **两分钟验证**:`pnpm --filter @objectstack/connector-mcp exec vitest run src/mcp-http-discovery-faults.test.ts`,预期 5 个用例全绿(真 HTTP MCP 服务器:两页工具全部发现;401 一次请求即失败、启动报错、不重试)。 - **需要你做的**:无,席位落地。 ## What changes - **`connector-openapi` — only OpenAPI 3.x loads (item 2).** `loadOpenApiDocument` now passes every `providerConfig.spec` form (inline object, http(s) URL, package-relative file) through `assertOpenApi3Document`: an `openapi` field that is absent or not `3.x` (a `swagger: "2.0"` document included) throws a plain error, the provider's existing fatal path (fatal at boot, a skipped entry on reload; never the upstream-unavailable marker). The message names the source (`file './x.json'`, `URL '…'` or `(the inline document)`), what was found (`a Swagger "2.0" document`, `a document with no 'openapi' version field`, `a document declaring openapi: "4.0.0"`) and the remedy: convert to OpenAPI 3.x offline. Nothing is converted at runtime, and the instance never comes up half-mapped. 3.0 and 3.1 load as before. - **`connector-mcp` — `tools/list` pagination (item 3a).** The SDK-backed client's `listTools()` follows `nextCursor` until the server stops sending one, so every tool on every page becomes an action and is offered to `providerConfig.include`. **Bound: `MCP_TOOLS_LIST_MAX_PAGES = 100`.** If the server is still answering a `nextCursor` after 100 pages, discovery throws `MCP server still returned a tools/list nextCursor after 100 pages (N tools so far); refusing to build the connector from a truncated tool list.` A truncated list is never used. On a declarative instance, that failure is classified like any other failed discovery: the instance degrades with that reason and is retried (see open question 1). `McpClientLike.listTools()` is now documented as returning every page. - **`connector-mcp` — a rejected credential is a configuration fault (item 3b).** In the provider's catch, `credentialRejectionStatus` looks for an HTTP 401/403 on connect, handshake or `tools/list`. It recognises the MCP SDK's own transport errors (`StreamableHTTPError` with code 401/403, or `UnauthorizedError`) anywhere in the `cause` chain; the SDK is imported lazily, only on this failure path. A match throws plain: `connector-mcp provider: connector 'NAME' was refused by its MCP server (HTTP 401): … This is a configuration fault and is not retried — fix the credential and reload.` A JSON-RPC `McpError` that merely carries code 401 is not a match, and every other connect or discovery failure keeps `ConnectorUpstreamUnavailableError`. The comment that recorded today's opposite choice ("A credential the server rejects also lands here …") now states the new rule and its reason: retrying cannot make a credential valid, a degraded instance retried with backoff reads as "server down", and credential-protected remote servers are the common case. The comment cites ADR-0097's configuration/operational split and the openapi provider's matching treatment of a 401 on its spec fetch. - **Docs.** `content/docs/automation/connectors.mdx` states the 3.x-only rule, the pagination bound and the 401/403 classification. - **Changesets.** One per package (`26709-openapi-refuse-swagger2.md`, `26709-mcp-tools-list-pages-fatal-401.md`), `minor` (kind `narrowed`), each with both declaration lines and an ADR-0087 `not-required (no-migration-prescription)` disposition. Landing: `packages/connectors/connector-openapi/src/**` and `packages/connectors/connector-mcp/src/**` (the claimed file surface), plus the two changesets and the connectors docs page. Nothing under `packages/spec/**`. ## Tests - `connector-openapi/src/openapi-provider.test.ts`, new block "refuses a document that is not OpenAPI 3.x": a Swagger 2.0 file-path spec, a Swagger 2.0 URL spec and an inline Swagger 2.0 document are each refused. Each refusal is asserted to be an `Error` that is NOT upstream-unavailable, and its message is asserted to name the file or URL and `Convert the document to OpenAPI 3.x offline`. A document with no `openapi` field and one declaring `4.0.0` are refused the same way. Control: 3.0.3, 3.1.0 and 3.1 load. - `connector-mcp/src/mcp-http-discovery-faults.test.ts` (new): a dependency-free Streamable-HTTP MCP server on an ephemeral localhost port, reached through the real SDK client (no injected client): - two-page `tools/list`: actions are `alpha, beta, gamma`, and the server saw the cursors `[undefined, 'page-2']`; - a server that never stops paging: discovery rejects with the bound message after exactly 100 `tools/list` calls; - HTTP 401 and 403: the provider's error is plain (NOT upstream-unavailable), its message names the status and `is not retried`, and the server received exactly one request; - a declarative instance booted through `AutomationServicePlugin` + `ConnectorMcpPlugin` against a 401 server: `kernel.bootstrap()` rejects with the refusal, no degraded reason is recorded, and after 300 ms the server has still seen one request (no retry loop). - `connector-mcp/src/mcp-provider.test.ts`, new block "a rejected credential is a configuration fault" (injected clients throwing the SDK's real error classes): 401/403 on connect, 401 on `tools/list` (client closed), `UnauthorizedError`, a 403 nested in `cause`. Controls: `StreamableHTTPError(503)` and `McpError(401)` stay upstream-unavailable. ## Acceptance notes - **ADR-0097 wording.** ADR-0097 §"Upstream availability" says "The `mcp` provider classifies connect / `tools/list` failures as unavailable". This PR narrows that sentence to "except a rejected credential (401/403), which is a configuration fault", which is consistent with the ADR's own split: an unresolvable `credentialRef` is already fatal there, and so is the openapi provider's 401 on its spec fetch. `docs/adr/**` is outside this card's file surface and is a governed surface, so the ADR is not edited here. A one-line amendment is owed; this is recorded in the dev report for the seat. - The version gate sits in the declarative provider's `loadOpenApiDocument`, as the card names. The programmatic `createOpenApiConnector` still trusts its caller's document, as ADR-0023 states ("The caller is responsible for loading and de-referencing ($ref) the doc"). - `MCP_TOOLS_LIST_MAX_PAGES` is a module export of `mcp-connector.ts` only (the tests read it). It is not re-exported from the package entry, so the package's public surface is unchanged. ## Verification All runs are on head `2177e83a` (worktree `objectstack-issue-26709`, base `a485b5a3`). `origin/main` has since moved to `4f05de4b` with 4 commits, none touching `packages/connectors`, `packages/services/service-automation` or `packages/spec/src/integration`. <details><summary>Package tests and typecheck</summary> ``` pnpm --filter @objectstack/connector-openapi --filter @objectstack/connector-mcp test (under os-verify-lock) connector-mcp Test Files 5 passed (5) Tests 46 passed (46) connector-openapi Test Files 4 passed (4) Tests 41 passed (41) pnpm --filter … typecheck connector-openapi check:test-typecheck: OK — 1 file(s) / 5 error(s) / 1 pinned signature(s) held in test-typecheck-debt.json connector-mcp check:test-typecheck: OK — 1 file(s) / 5 error(s) / 1 pinned signature(s) held in test-typecheck-debt.json os-verify-lock: VERDICT command-exit 0 pnpm exec eslint (the 6 changed .ts files) exit 0, no output ``` </details> <details><summary>Ablations (each put the forbidden behaviour back, saw red, restored; node scripts/ablation-replace.mjs, restore proven by blob == HEAD and empty git diff HEAD)</summary> ``` A1 openapi version gate → `if (true) {` × refuses a Swagger 2.0 file-path spec, naming the file and the offline conversion × refuses a Swagger 2.0 document fetched from a URL, naming the URL × refuses an inline Swagger 2.0 document × refuses a document with no openapi field, and one declaring a version other than 3.x AssertionError: expected 'createOpenApiConnector: no base URL —…' to contain 'connector \'pets\' providerConfig.spe…' Tests 4 failed | 25 passed (29) — restored: blob 7ea7cdd == HEAD A2 pagination → `return tools; /* ABLATION: first page only */` (first attempt used the replacement `return tools;`, which already occurred in the file; the tool refused it as a no-op before running anything — rerun with a distinct marker) × follows nextCursor until exhausted: every tool on every page becomes an action AssertionError: expected [ 'alpha', 'beta' ] to deeply equal [ 'alpha', 'beta', 'gamma' ] × fails discovery loudly when the server still pages after 100 pages — never a truncated list restored: blob 7bbe1e1 == HEAD A3 page bound → `return tools; /* ABLATION: silent truncation */` before the throw × fails discovery loudly when the server still pages after 100 pages — never a truncated list AssertionError: promise resolved "{ def: { name: 'endless', …(8) }, …(2) }" instead of rejecting Tests 1 failed | 4 passed (5) — restored: blob 7bbe1e1 == HEAD A4 credential rejection → `if (false) {` × HTTP 401 / 403 on connect / handshake throws plain, naming the status × HTTP 401 on tools/list throws plain too, and closes the client × the SDK's UnauthorizedError, or a 401 in the cause chain, is the same fault × an HTTP 401 / 403 on the handshake is a plain configuration fault (not upstream-unavailable), sent once × a declarative instance answered 401 fails the boot instead of degrading into a retry loop AssertionError: promise resolved "undefined" instead of rejecting Tests 7 failed | 22 passed (29) — restored: blob 8d083b8 == HEAD ``` The tests import `src/` directly (relative imports, no package `exports` hop), so no rebuild was needed for an ablation to reach the code under test. </details> <details><summary>Derived gates (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack)</summary> ``` 87 command(s) derived; 84 exit 0; 3 exit 3 (PREREQUISITE NOT MET, nothing measured): node scripts/check-plugin-teardown-shape.mjs --self-test needs `git fetch --unshallow` (checker-health self-test) pnpm --filter @objectstack/spec run check:skill-examples reads packages/client-react/dist, not built here pnpm check:dual-build-cjs-loads reads every package's dist/, not built here (Build Core owns it) --ran reconciliation: 87 derived famil(ies) accounted for — 84 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3) ``` All other families (artifact rosters, wide-population, CI-only jobs, type-check lanes) are CI's: see this PR's check-runs. </details> <details><summary>Dogfood battery (Clause-② carries the (narrowing) arm)</summary> ``` head 2177e83 · pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test (no OS_TEST_SHARD, no path) os-verify-lock: VERDICT command-exit 0 · held the lock 1412s Test Files 302 passed | 3 skipped (305) Tests 2505 passed | 20 skipped (2525) no failures ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (no `packages/spec` change) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on the changed files passes (repo-wide `pnpm lint` is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. Dev seat: os-dev cloud session `session_01QSeKYmG4MEJUzuz837U8Uq`, dispatched by `domain:server#3` (`session_01QSeKYmQXM3rHJcPnrEPk4B`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYmG4MEJUzuz837U8Uq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6279efe commit 55b5042

9 files changed

Lines changed: 499 additions & 15 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/connector-mcp': minor
3+
---
4+
5+
fix(connector-mcp): `tools/list` follows `nextCursor` through every page, and a rejected credential (HTTP 401 / 403) is a fatal configuration fault, not a retried upstream outage
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no export is removed: discovery now reads every tools/list page, and a credential the server rejects is classified as a configuration fault. The fix is to a credential, not to the metadata's shape, so there is nothing for a metadata migration to rewrite and no tombstone. -->
11+
12+
- **Pagination.** Discovery called `tools/list` once with no cursor, so a server that pages its tool list lost every tool past the first page, silently. The SDK-backed client now follows `nextCursor` until the server stops answering one, so every tool on every page becomes an action (and is offered to `providerConfig.include`). The walk is bounded at 100 pages: a server still answering a `nextCursor` after that fails discovery with an error naming the bound, never a connector built from a truncated tool list. On a declarative instance that failure is classified like any other failed discovery (the instance degrades and is retried). `McpClientLike.listTools()` is documented as returning every page; an injected client returns the whole list.
13+
- **Rejected credential.** An HTTP 401 / 403 during connect, handshake or `tools/list` was wrapped as `ConnectorUpstreamUnavailableError`, so the instance degraded and was retried as if the server were down. The `mcp` provider now tells it apart by the MCP SDK's own HTTP-transport error (`StreamableHTTPError` with status 401 / 403, or `UnauthorizedError`, anywhere in the `cause` chain) and throws it plain: fatal at boot, a skipped entry on reload, never retried. A JSON-RPC error that merely carries code 401 is not one, and every other connect / discovery failure keeps the retryable classification.
14+
15+
**BREAKING** for a declarative `mcp` instance whose server rejects its credential: it now fails boot with `connector-mcp provider: connector 'NAME' was refused by its MCP server (HTTP 401): … This is a configuration fault and is not retried …` instead of booting degraded. The remedy is a credential the server accepts (the instance `auth`, or `providerConfig.transport.headers`); the metadata keeps its shape.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/connector-openapi': minor
3+
---
4+
5+
fix(connector-openapi): a document that is not OpenAPI 3.x is refused at load — a Swagger 2.0 document included
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no export changes: providerConfig.spec keeps its three forms, and what is now refused is an external document the generator never mapped correctly. The fix is to the document, outside the metadata, so there is nothing for a metadata migration to rewrite and no tombstone. -->
11+
12+
The declarative `openapi` provider loaded any JSON object as its document. A Swagger 2.0 document "loaded" with its `host` / `basePath` ignored and its `body` / `formData` parameters dropped, so the instance came up with wrong actions and no error. The provider now checks the document's `openapi` field on every `providerConfig.spec` form (inline object, http(s) URL, package-relative file): absent, or anything but `3.x` (a `swagger: "2.0"` document included), is a configuration fault — fatal at boot, a skipped entry on reload — whose message names the inline document, URL or file and the remedy. The runtime never converts.
13+
14+
**BREAKING** accept-set narrowing at the `openapi` provider's document load. What an upgrader sees: an instance whose `spec` is a Swagger 2.0 document now fails boot with `connector-openapi provider: connector 'NAME' providerConfig.spec file './x.json' is a Swagger "2.0" document; … Convert the document to OpenAPI 3.x offline …`. The document is converted outside the runtime (any Swagger 2.0 → OpenAPI 3 converter); the metadata keeps its shape. A 3.x document (3.0 or 3.1) loads as before.

‎content/docs/automation/connectors.mdx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@ Each operation becomes an action keyed by its `operationId` (or a
148148
`method_path` slug when the document omits one) — so `getInvoice` in the
149149
document is `actionId: 'getInvoice'` in your flow.
150150

151+
The document must be **OpenAPI 3.x**. A Swagger 2.0 document — or one with no
152+
`openapi` version field — is refused at boot with an error naming the inline
153+
document, URL or file it came from: convert it to OpenAPI 3.x offline and point
154+
`spec` at the converted document. The runtime never converts.
155+
151156
### `provider: 'mcp'` — one action per tool
152157

153158
Config: **`transport`** (required) and **`include`** (optional tool-name
@@ -177,7 +182,13 @@ export const SearchToolsConnector = defineConnector({
177182
```
178183

179184
At materialization the provider connects, calls `tools/list`, and maps each
180-
tool to an action — the MCP server's tool names are your `actionId`s.
185+
tool to an action — the MCP server's tool names are your `actionId`s. A
186+
paginated tool list is followed through every page (`nextCursor`), up to 100
187+
pages; a server still paging after that fails discovery instead of yielding a
188+
truncated tool list. A server that is unreachable or failing degrades the
189+
instance, which is retried. A credential the server rejects (HTTP 401 / 403 on
190+
connect, handshake or `tools/list`) is a configuration fault instead: it fails
191+
the boot and is not retried — fix the credential and reload.
181192

182193
<Callout type="warn">
183194
**Declarative stdio transports are denied by default.** Materializing one

‎packages/connectors/connector-mcp/src/mcp-connector.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@ export interface McpToolDescriptor {
7979
* (mirrors `fetchImpl` injection in `connector-rest`).
8080
*/
8181
export interface McpClientLike {
82-
/** List the server's tools (`tools/list`). */
82+
/**
83+
* List the server's tools (`tools/list`) — **every page**: a paginating
84+
* server's `nextCursor` is followed until exhausted, so the result is the
85+
* whole tool list, never just its first page.
86+
*/
8387
listTools(): Promise<McpToolDescriptor[]>;
8488
/** Invoke a tool (`tools/call`); returns the raw MCP result. */
8589
callTool(name: string, args: Record<string, unknown>): Promise<unknown>;
@@ -171,6 +175,14 @@ function normalizeResult(raw: unknown): Record<string, unknown> {
171175
*/
172176
const MCP_REQUEST_TIMEOUT_MS = 30_000;
173177

178+
/**
179+
* Upper bound on the `tools/list` pages one discovery follows. A server that
180+
* still answers a `nextCursor` after this many pages (a cursor loop, or a tool
181+
* list far beyond any palette) fails discovery loudly with an error naming the
182+
* bound — the connector is never built from a truncated tool list.
183+
*/
184+
export const MCP_TOOLS_LIST_MAX_PAGES = 100;
185+
174186
/**
175187
* The default {@link McpClientLike} — lazily imports the official MCP SDK so it
176188
* is only loaded when a real connection is made (tests inject their own client).
@@ -206,8 +218,21 @@ async function defaultClientFactory(
206218

207219
return {
208220
async listTools() {
209-
const res = await client.listTools(undefined, { timeout: MCP_REQUEST_TIMEOUT_MS });
210-
return (res.tools ?? []) as McpToolDescriptor[];
221+
const tools: McpToolDescriptor[] = [];
222+
let cursor: string | undefined;
223+
for (let page = 0; page < MCP_TOOLS_LIST_MAX_PAGES; page++) {
224+
const res = await client.listTools(cursor === undefined ? undefined : { cursor }, {
225+
timeout: MCP_REQUEST_TIMEOUT_MS,
226+
});
227+
tools.push(...((res.tools ?? []) as McpToolDescriptor[]));
228+
// MCP pagination: an absent (or empty) `nextCursor` is the last page.
229+
if (typeof res.nextCursor !== 'string' || res.nextCursor.length === 0) return tools;
230+
cursor = res.nextCursor;
231+
}
232+
throw new Error(
233+
`MCP server still returned a tools/list nextCursor after ${MCP_TOOLS_LIST_MAX_PAGES} pages ` +
234+
`(${tools.length} tools so far); refusing to build the connector from a truncated tool list.`,
235+
);
211236
},
212237
async callTool(name, args) {
213238
return client.callTool({ name, arguments: args }, undefined, { timeout: MCP_REQUEST_TIMEOUT_MS });
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `tools/list` pagination and the credential-rejection fault, measured against
4+
// REAL Streamable-HTTP servers through the SDK-backed default client.
5+
//
6+
// Nothing here injects a client: each case starts a dependency-free MCP server
7+
// on an ephemeral localhost port (JSON-RPC over POST, answered as plain JSON),
8+
// so what is asserted is what the MCP SDK's own transport and client do with
9+
// the server's answers — the cursor it really sends, the 401 it really raises.
10+
11+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
12+
import type { AddressInfo } from 'node:net';
13+
import { afterEach, describe, expect, it } from 'vitest';
14+
import { LiteKernel } from '@objectstack/core';
15+
import { isConnectorUpstreamUnavailable } from '@objectstack/spec/integration';
16+
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
17+
import { ConnectorMcpPlugin } from './connector-mcp-plugin.js';
18+
import { createMcpConnector, MCP_TOOLS_LIST_MAX_PAGES } from './mcp-connector.js';
19+
import { createMcpProviderFactory } from './mcp-provider.js';
20+
21+
type JsonRpc = { jsonrpc: '2.0'; id?: number | string; method: string; params?: Record<string, unknown> };
22+
23+
interface Fixture {
24+
url: string;
25+
/** Every JSON-RPC message the server received, in order. */
26+
received: JsonRpc[];
27+
/** Every HTTP request the server received (method), in order. */
28+
requests: string[];
29+
}
30+
31+
const servers: Server[] = [];
32+
afterEach(async () => {
33+
await Promise.all(servers.splice(0).map((s) => new Promise((r) => s.close(r))));
34+
});
35+
36+
async function readBody(req: IncomingMessage): Promise<string> {
37+
let body = '';
38+
for await (const chunk of req) body += chunk;
39+
return body;
40+
}
41+
42+
/**
43+
* Start a fixture MCP server. `toolsList` answers one `tools/list` request
44+
* from its cursor; `reject` (when set) answers every request with that status.
45+
*/
46+
async function startServer(opts: {
47+
toolsList?: (cursor: string | undefined) => { tools: unknown[]; nextCursor?: string };
48+
reject?: number;
49+
}): Promise<Fixture> {
50+
const fixture: Fixture = { url: '', received: [], requests: [] };
51+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
52+
void (async () => {
53+
fixture.requests.push(req.method ?? '');
54+
const body = await readBody(req);
55+
if (opts.reject !== undefined) {
56+
res.writeHead(opts.reject, { 'content-type': 'application/json' });
57+
res.end(JSON.stringify({ error: 'invalid_token' }));
58+
return;
59+
}
60+
// No server-initiated SSE stream; DELETE (session end) is a no-op.
61+
if (req.method !== 'POST') {
62+
res.writeHead(req.method === 'GET' ? 405 : 200);
63+
res.end();
64+
return;
65+
}
66+
const msg = JSON.parse(body) as JsonRpc;
67+
fixture.received.push(msg);
68+
if (msg.id === undefined) {
69+
res.writeHead(202);
70+
res.end();
71+
return;
72+
}
73+
let result: unknown;
74+
if (msg.method === 'initialize') {
75+
result = {
76+
protocolVersion: msg.params?.protocolVersion,
77+
capabilities: { tools: {} },
78+
serverInfo: { name: 'http-fixture', version: '1.0.0' },
79+
};
80+
} else if (msg.method === 'tools/list' && opts.toolsList) {
81+
result = opts.toolsList(msg.params?.cursor as string | undefined);
82+
} else {
83+
res.writeHead(200, { 'content-type': 'application/json' });
84+
res.end(JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } }));
85+
return;
86+
}
87+
res.writeHead(200, { 'content-type': 'application/json' });
88+
res.end(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }));
89+
})();
90+
});
91+
servers.push(server);
92+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()));
93+
fixture.url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/mcp`;
94+
return fixture;
95+
}
96+
97+
const tool = (name: string) => ({ name, description: `${name} tool`, inputSchema: { type: 'object' } });
98+
99+
/** Two pages: `alpha`, `beta` then (cursor `page-2`) `gamma`. */
100+
const twoPages = (cursor: string | undefined) =>
101+
cursor === undefined
102+
? { tools: [tool('alpha'), tool('beta')], nextCursor: 'page-2' }
103+
: cursor === 'page-2'
104+
? { tools: [tool('gamma')] }
105+
: { tools: [] };
106+
107+
describe('tools/list pagination — real Streamable-HTTP server', () => {
108+
it('follows nextCursor until exhausted: every tool on every page becomes an action', async () => {
109+
const server = await startServer({ toolsList: twoPages });
110+
const bundle = await createMcpConnector({ name: 'paged', transport: { kind: 'http', url: server.url } });
111+
try {
112+
expect(bundle.def.actions?.map((a) => a.key)).toEqual(['alpha', 'beta', 'gamma']);
113+
expect(Object.keys(bundle.handlers)).toEqual(['alpha', 'beta', 'gamma']);
114+
// The second request carried the server's cursor, and there was no third.
115+
const listCalls = server.received.filter((m) => m.method === 'tools/list');
116+
expect(listCalls.map((m) => m.params?.cursor)).toEqual([undefined, 'page-2']);
117+
} finally {
118+
await bundle.close();
119+
}
120+
});
121+
122+
it(`fails discovery loudly when the server still pages after ${MCP_TOOLS_LIST_MAX_PAGES} pages — never a truncated list`, async () => {
123+
let page = 0;
124+
const server = await startServer({
125+
toolsList: () => ({ tools: [tool(`t_${page}`)], nextCursor: `c${++page}` }),
126+
});
127+
await expect(
128+
createMcpConnector({ name: 'endless', transport: { kind: 'http', url: server.url } }),
129+
).rejects.toThrow(
130+
`MCP server still returned a tools/list nextCursor after ${MCP_TOOLS_LIST_MAX_PAGES} pages ` +
131+
`(${MCP_TOOLS_LIST_MAX_PAGES} tools so far); refusing to build the connector from a truncated tool list.`,
132+
);
133+
expect(server.received.filter((m) => m.method === 'tools/list')).toHaveLength(MCP_TOOLS_LIST_MAX_PAGES);
134+
});
135+
});
136+
137+
describe('credential rejection — real Streamable-HTTP server', () => {
138+
it.each([401, 403])(
139+
'an HTTP %i on the handshake is a plain configuration fault (not upstream-unavailable), sent once',
140+
async (status) => {
141+
const server = await startServer({ reject: status });
142+
const factory = createMcpProviderFactory();
143+
const materialized = factory({
144+
name: 'vendor',
145+
label: 'Vendor',
146+
type: 'api',
147+
providerConfig: { transport: { kind: 'http', url: server.url } },
148+
auth: { type: 'bearer', token: 'wrong-token' },
149+
});
150+
const err = await Promise.resolve(materialized).then(
151+
() => { throw new Error('expected rejection'); },
152+
(e: unknown) => e,
153+
);
154+
expect(err).toBeInstanceOf(Error);
155+
expect(isConnectorUpstreamUnavailable(err)).toBe(false);
156+
expect((err as Error).message).toContain(
157+
`connector-mcp provider: connector 'vendor' was refused by its MCP server (HTTP ${status})`,
158+
);
159+
expect((err as Error).message).toContain('is not retried');
160+
expect(server.requests).toEqual(['POST']);
161+
},
162+
);
163+
164+
it('a declarative instance answered 401 fails the boot instead of degrading into a retry loop', async () => {
165+
const server = await startServer({ reject: 401 });
166+
const declared = [
167+
{
168+
name: 'vendor_mcp',
169+
label: 'Vendor MCP',
170+
type: 'api',
171+
provider: 'mcp',
172+
providerConfig: { transport: { kind: 'http', url: server.url } },
173+
},
174+
];
175+
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
176+
kernel.use(new AutomationServicePlugin());
177+
kernel.use({
178+
name: 'test.connector-metadata',
179+
type: 'standard',
180+
version: '1.0.0',
181+
dependencies: ['com.objectstack.service-automation'],
182+
async init(ctx: { registerService(name: string, service: unknown): void }) {
183+
ctx.registerService('objectql', {
184+
registry: { listItems: (type: string) => (type === 'connector' ? declared : []) },
185+
});
186+
},
187+
async start() {},
188+
} as never);
189+
kernel.use(new ConnectorMcpPlugin());
190+
try {
191+
await expect(kernel.bootstrap()).rejects.toThrow(/was refused by its MCP server \(HTTP 401\)/);
192+
const engine = kernel.getService('automation') as AutomationEngine;
193+
expect(engine.getConnectorDegradedReason('vendor_mcp')).toBeUndefined();
194+
// Give a (wrongly) scheduled retry the chance to fire: none does.
195+
await new Promise((r) => setTimeout(r, 300));
196+
expect(server.requests).toEqual(['POST']);
197+
} finally {
198+
await kernel.shutdown().catch(() => {});
199+
}
200+
});
201+
});

0 commit comments

Comments
 (0)