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
56 changes: 56 additions & 0 deletions .changeset/list-diagnosed-consumer-sweep.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
"@objectstack/service-datasource": minor
"@objectstack/runtime": minor
"@objectstack/mcp": minor
---

fix(runtime,mcp,service-datasource): the #6504 consumer sweep — three list consumers stop making claims a known-partial read cannot support (#6504)

<!-- adr-0087: not-required (no-migration-prescription) No authorable surface is
added, renamed, retired or tombstoned. Two package-local host-wiring interfaces
gain OPTIONAL members (`DatasourceAdminServiceConfig.countBoundObjectsDiagnosed`,
`McpDataBridge.listObjectsDiagnosed`); `packages/spec` is untouched, since
`IMetadataService.listDiagnosed` — the contract this consumes — landed in PR
#7721. -->

`IMetadataService.listDiagnosed?(type)` (PR #7721) lets a plural read say whether
its answer can be trusted as complete. This is the consumer half: the callers
that were restating a possibly-short listing as a fact about the environment.

Each consumer was qualified individually, per PR #6051's discipline, and most
were left alone — a caller publishing a snapshot with no count has nothing to
mis-state. Three make a claim, and each now withholds exactly that claim while
still serving everything it could read:

- **`removeDatasource` no longer deletes on a bound-object count it could not
take completely.** The guard `if (bound > 0) throw` is the only thing standing
in front of an irreversible delete that also unbinds the datasource's secret,
and its input is derived from the metadata service's object listing. During a
loader outage that listing goes silently short, and the worst value is the
benign one: `0` reads exactly like "nothing is bound", so the guard OPENED.
It now refuses with `SERVICE_UNAVAILABLE` / 503 — a dependency outage the
operator can retry, not a client error — and the record, its credential and
its pool all survive.
- **The MCP `list_objects` tool stops publishing `totalCount` on a known-partial
listing.** This is the same claim PR #7721 removed from the
`objectstack://objects` resource, on the other MCP primitive: same payload
shape, different door, never covered. A degraded read now serves the same
objects with `totalCount` **absent** and `partial` / `returnedCount` /
`warning` plus the 503 envelope in its place, so a client reading the total
gets `undefined` rather than a believable wrong integer. Both bridges
implement it — stdio (`@objectstack/mcp`) and HTTP (`@objectstack/runtime`) —
because a completeness claim must not depend on which transport a client
connected over.
- **The ADR-0015 §5.2 boot gate stops announcing an all-clear over a sweep it
could not complete.** It validated whatever `listObjects()` returned and then
logged *all federated objects match their remote schema*, with a count.
Federated objects behind an unreadable loader were never validated, so
`onMismatch: 'fail'` could not have fired for them. The gate now warns that
the swept set was incomplete and names what it did validate. ⛔ It does **not**
abort boot on a degraded metadata read: turning a transient outage into a
refusal to start would be a new failure mode bought with a diagnosis fix.

Every new member is optional in the same way `listDiagnosed` itself is: a host
whose metadata service predates the verdict behaves exactly as it did before,
and a service without it reports nothing degraded — precisely what it could
express.
245 changes: 245 additions & 0 deletions packages/mcp/src/mcp-http-tools.list-objects-outage.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #6504 (consumer sweep) — the `list_objects` TOOL publishes `totalCount`, and
* a count taken over a known-partial listing is the strongest false claim a
* read can make.
*
* ---------------------------------------------------------------------------
* Why this surface, when PR #7721 already closed its sibling
* ---------------------------------------------------------------------------
* PR #7721 fixed `objectstack://objects` — the RESOURCE — because it rendered
* `{ objects, totalCount }` and told an MCP client, with a number, that the
* environment contained fewer objects than it does. The `list_objects` TOOL
* renders the SAME payload from the same underlying listing and was not
* covered: the resource is served by `MCPServerRuntime` off `IMetadataService`
* directly, while the tool is served through the injected `McpDataBridge` (the
* stdio bridge in this package, the HTTP bridge in `packages/runtime`), so the
* two paths never met. A client asking "how many objects does this app have?"
* therefore got an honest answer over one door and a confident wrong integer
* over the other, depending on which primitive it happened to use.
*
* The fix is the resource's, in the resource's words: withhold the CLAIM, not
* the data. Healthy stays byte-identical; degraded serves the same objects with
* `totalCount` ABSENT and `partial` / `returnedCount` / `warning` plus the 503
* envelope in its place. A client reading `totalCount` then gets `undefined` —
* which fails, or renders as nothing — where a plausible integer would have
* been believed.
*
* ---------------------------------------------------------------------------
* DOUBLES HERE, and where the real loader failure is pinned instead
* ---------------------------------------------------------------------------
* `packages/mcp` does not depend on `@objectstack/metadata` — adding it for a
* test would be a larger change than the fix — so the bridge below is a double,
* the same split PR #7721 and #6055 both took and stated rather than papered
* over. The verdict these doubles hand back is the exact shape
* `MetadataManager.listDiagnosed()` returns from a live `ECONNRESET`, pinned
* against a real `DatabaseLoader` in
* `packages/metadata/src/metadata-manager-list-diagnosed.test.ts` and, for this
* sweep's consumer half, in
* `packages/runtime/src/list-diagnosed-consumer-sweep.test.ts` — which drives
* the runtime's implementation of this very bridge member off a real failing
* loader. What is pinned HERE is the only thing that lives here: what the tool
* renders once it holds the verdict.
*
* Everything below drives the REAL MCP HTTP transport (`tools/call`), not the
* handler in isolation, so the payload asserted is the one a client receives.
*
* ---------------------------------------------------------------------------
* Both directions, on the COUNT
* ---------------------------------------------------------------------------
* The load-bearing pair is two answers with the SAME objects and the same
* length — one from an outage, one from a genuinely small environment — where
* only the presence of `totalCount` may differ. A test asserting merely that
* `partial` appears would pass on a build that also kept publishing the wrong
* total beside it, which is the failure this is guarding against, so the
* ABSENCE of the key is asserted explicitly in the degraded direction and its
* presence in the healthy one.
*
* ---------------------------------------------------------------------------
* Reverse verification, direction predicted BEFORE running
* ---------------------------------------------------------------------------
* Ordinary red. Reversion is defined as restoring the pre-#6504 tool body —
* `const objects = await bridge.listObjects()` and an unconditional
* `{ objects: visible, totalCount: visible.length }` — leaving
* `listObjectsDiagnosed` declared on the interface and implemented on both
* bridges, but unread. That is the *declared-but-unconsumed* shape, and it is
* the ablation worth taking, because a whole-file revert would also delete the
* interface member and turn the optionality cases red for the wrong reason.
*
* Predicted, written down before running: **3 red / 3 green** of the 6. Red are
* the three cases that discriminate on the withheld claim (the degraded
* payload, the same-count pair, and the system-object filter's
* `returnedCount`). Green are the healthy byte-identical case and both
* optionality cases — a bridge with no diagnosed member takes the same code
* path in either direction, which is exactly what makes the member optional.
* Measured result is recorded in the PR body as it came out.
*/

import { describe, it, expect, beforeEach } from 'vitest';

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

const LOADER_FAILURE = 'database: read ECONNRESET';

/** The one object that survived the outage, plus a system object for the filter case. */
const READABLE: McpObjectSummary[] = [
{ name: 'task', label: 'Task', fieldCount: 4 },
{ name: 'sys_user', label: 'User', fieldCount: 9 },
];

type BridgeOpts = {
objects?: McpObjectSummary[];
/** Omit entirely to model a bridge predating #6504 (the member is optional). */
diagnosed?: { degraded: boolean; errors: string[] } | 'absent';
};

function makeBridge(opts: BridgeOpts = {}): McpDataBridge {
const objects = opts.objects ?? READABLE;
const bridge: any = {
async listObjects() { return objects; },
async describeObject(name: string) { return { name }; },
async query() { return { records: [] }; },
async get() { return {}; },
async create() { return {}; },
async update() { return {}; },
async remove() { return { success: true }; },
};
if (opts.diagnosed !== 'absent') {
const verdict = opts.diagnosed ?? { degraded: false, errors: [] };
bridge.listObjectsDiagnosed = async () => ({ objects, ...verdict });
}
return bridge as McpDataBridge;
}

/** Call `list_objects` over the real transport and hand back its parsed body. */
async function listObjects(
runtime: MCPServerRuntime,
bridge: McpDataBridge,
toolOptions?: Record<string, unknown>,
): Promise<any> {
const body = {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'list_objects', arguments: {} },
};
const res = await runtime.handleHttpRequest(
new Request('http://localhost/api/v1/mcp', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify(body),
}),
{ bridge, parsedBody: body, ...(toolOptions ? { toolOptions } : {}) } as any,
);
const json: any = await res.json();
expect(json.error, 'precondition: the tool must have answered').toBeUndefined();
expect(json.result?.isError, 'precondition: the tool must not have errored').not.toBe(true);
return JSON.parse(json.result.content[0].text);
}

describe('#6504 — list_objects withholds its totalCount on a known-partial listing', () => {
let runtime: MCPServerRuntime;
beforeEach(() => {
runtime = new MCPServerRuntime({ name: 't', version: '1.0.0' });
});

it('healthy: `{ objects, totalCount }`, unchanged — a complete read may state its count', async () => {
const body = await listObjects(runtime, makeBridge({ diagnosed: { degraded: false, errors: [] } }));

expect(body.totalCount).toBe(1);
expect(body.objects.map((o: any) => o.name)).toEqual(['task']);
// Nothing from the degraded branch leaks into a healthy answer.
expect(body.partial).toBeUndefined();
expect(body.warning).toBeUndefined();
expect(body.code).toBeUndefined();
});

it('degraded: the SAME objects, `totalCount` ABSENT, and a structural 503 envelope', async () => {
const body = await listObjects(
runtime,
makeBridge({ diagnosed: { degraded: true, errors: [LOADER_FAILURE] } }),
);

// The data is still served — this is a diagnosis fix, not a functional one.
expect(body.objects.map((o: any) => o.name)).toEqual(['task']);

// The claim, and only the claim, is withheld. `undefined` rather than a
// smaller integer is the entire point: a client reading it fails loudly
// instead of believing a number nobody established.
expect(body.totalCount).toBeUndefined();
expect('totalCount' in body, 'the key must be ABSENT, not present-and-nullish').toBe(false);

expect(body.partial).toBe(true);
expect(body.returnedCount).toBe(1);
expect(body.code).toBe('SERVICE_UNAVAILABLE');
expect(body.status).toBe(503);
expect(body.warning).toMatch(/known to be INCOMPLETE/);
// The sentence names the served count as a FLOOR, never as a total.
expect(body.warning).toMatch(/at least that many objects/);
});

it('the outage and the small environment differ ONLY in the claim, never in the data', async () => {
// The pair that gives the verdict meaning: byte-equal object lists, equal
// lengths, opposite entitlement to publish a total.
const outage = await listObjects(
runtime,
makeBridge({ diagnosed: { degraded: true, errors: [LOADER_FAILURE] } }),
);
const small = await listObjects(
runtime,
makeBridge({ diagnosed: { degraded: false, errors: [] } }),
);

expect(outage.objects).toEqual(small.objects);
expect(outage.objects).toHaveLength(small.objects.length);
expect(small.totalCount).toBe(1);
expect(outage.totalCount).toBeUndefined();
expect(outage.returnedCount).toBe(small.totalCount);
});

it('`returnedCount` counts what is SERVED — after the system-object filter, not before', async () => {
// Naming the pre-filter number would restate the same over-claim one field
// along: the client can see two objects and would be told about three.
const body = await listObjects(
runtime,
makeBridge({
objects: [
{ name: 'task' },
{ name: 'invoice' },
{ name: 'sys_user' },
],
diagnosed: { degraded: true, errors: [LOADER_FAILURE] },
}),
{ allowSystemObjects: false },
);

expect(body.objects.map((o: any) => o.name)).toEqual(['task', 'invoice']);
expect(body.returnedCount).toBe(2);
expect(body.warning).toMatch(/2 are being served/);
});

it('a bridge PREDATING listObjectsDiagnosed behaves exactly as before', async () => {
// The optionality is the bridge's own graceful-degradation contract, and a
// host that cannot ask its metadata service for a verdict must not have one
// invented for it.
const body = await listObjects(runtime, makeBridge({ diagnosed: 'absent' }));

expect(body.totalCount).toBe(1);
expect(body.partial).toBeUndefined();
});

it('a bridge predating it does not become "degraded" merely by being old', async () => {
// The direction that matters for a false ALARM: absence of the member is
// "cannot report", never "known-partial". Asserted separately from the case
// above because that one would also pass if the 503 envelope were emitted
// alongside a totalCount.
const body = await listObjects(runtime, makeBridge({ diagnosed: 'absent' }));

expect(body.code).toBeUndefined();
expect(body.status).toBeUndefined();
expect(body.warning).toBeUndefined();
expect(body.returnedCount).toBeUndefined();
});
});
66 changes: 63 additions & 3 deletions packages/mcp/src/mcp-http-tools.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -46,6 +46,11 @@ import {
inferExpressionType,
type FieldRole,
} from '@objectstack/formula';
import {
METADATA_UNAVAILABLE_CODE,
metadataPartialListingSentence,
type DiagnosedObjectListing,
} from './metadata-completeness.js';

export interface McpObjectSummary {
name: string;
Expand All@@ -60,6 +65,24 @@ export interface McpObjectSummary {
*/
export interface McpDataBridge {
listObjects(): Promise<McpObjectSummary[]>;
/**
* [#6504] The same listing, plus whether it can be trusted as COMPLETE.
*
* The `list_objects` tool renders its answer as `{ objects, totalCount }`,
* and `totalCount` is a positive, numeric claim about what this environment
* declares. During a metadata loader outage that claim is simply false, and
* nothing in the payload lets a client tell it from a genuinely small
* environment — the ADR-0110 D3 shape the `objectstack://objects` RESOURCE
* already closed (PR #7721) and this TOOL did not. The two are the same
* question asked over two transports, so they now answer it the same way.
*
* OPTIONAL, and its optionality is the bridge's own graceful-degradation
* contract (same as {@link McpDataBridge.aggregate}), stacked on
* `IMetadataService.listDiagnosed`'s: a bridge that cannot ask its metadata
* service for a verdict omits this member, the tool behaves exactly as it did
* before, and nothing anywhere claims completeness it did not establish.
*/
listObjectsDiagnosed?(): Promise<DiagnosedObjectListing<McpObjectSummary>>;
describeObject(name: string): Promise<unknown | null>;
query(
object: string,
Expand DownExpand Up@@ -306,11 +329,48 @@ export function registerObjectTools(
inputSchema: {},
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
},
// [#6504] This tool MIS-DESCRIBES during a metadata loader outage, which
// is why it changes while most consumers in that sweep correctly do not:
// it publishes `totalCount`, and a count is the strongest positive claim
// a read can make. The fix withholds the CLAIM, not the data — the same
// treatment, in the same words, the `objectstack://objects` resource got
// in PR #7721.
//
// - healthy → `{ objects, totalCount }`, byte-identical to before. A
// count from a complete read is a fact this tool was
// always right to state.
// - degraded → the same `objects` (the reachable set is still the most
// useful true thing here), `totalCount` ABSENT, and in its
// place `partial` / `returnedCount` / `warning` plus the
// 503 envelope so a client can branch structurally.
//
// Dropping the key rather than reporting a smaller number is the point: a
// client reading `totalCount` gets `undefined` — which fails, or renders
// as nothing — where a plausible-looking integer would have been believed.
//
// `returnedCount` counts what this tool actually SERVES, i.e. after the
// system-object filter, not what the bridge handed over. The two differ
// whenever `allowSystemObjects` is false, and naming the pre-filter number
// here would restate the same over-claim one field along.
async () => {
try {
const objects = await bridge.listObjects();
const visible = allowSystem ? objects : objects.filter((o) => !isSystemObject(o.name));
return textResult({ objects: visible, totalCount: visible.length });
const diagnosed = bridge.listObjectsDiagnosed
? await bridge.listObjectsDiagnosed()
: { objects: await bridge.listObjects(), degraded: false, errors: [] };
const visible = allowSystem
? diagnosed.objects
: diagnosed.objects.filter((o) => !isSystemObject(o.name));
if (!diagnosed.degraded) {
return textResult({ objects: visible, totalCount: visible.length });
}
return textResult({
objects: visible,
partial: true,
returnedCount: visible.length,
warning: metadataPartialListingSentence('objects', visible.length),
code: METADATA_UNAVAILABLE_CODE,
status: 503,
});
} catch (err) {
return errorResult(messageOf(err));
}
Expand Down
Loading
Loading