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
2 changes: 1 addition & 1 deletion .changeset/32530-describe-object-readonly-computed.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/mcp': patch
'@objectstack/runtime': patch
Expand All @@ -8,6 +8,6 @@
Clause-②: yes (widening)
Metadata-protocol: none

`describe_object` and the `objectstack://objects/{name}` resource served each field's `readonly` from the declared flag alone, so an agent was told it could write fields whose sent value the engine drops or replaces. Both transports now also answer `readonly: true` for a runtime-owned type (`autonumber`, including a hand-written field literal with no flag), for a `formula` field, and for a stored field that declares an `expression`. On the HTTP transport, `readonly` is judged on the field as declared, so a stored expression the caller is not shown (it names a field they cannot read) still answers `true`, and the expression itself stays hidden. Over stdio and on the resource, which hold only the object as the caller is served it, the expression case answers `true` where the caller is shown the expression. An ordinary field still answers `readonly: false`, and a declared `readonly` still answers `true`.
`describe_object` and the `objectstack://objects/{name}` resource served each field's `readonly` from the declared flag alone, so an agent was told it could write fields whose sent value the engine drops or replaces. Both transports now also answer `readonly: true` for a runtime-owned type (`autonumber`, including a hand-written field literal with no flag), for a `formula` field, and for a stored field that declares an `expression`. On every transport, the resource included, `readonly` is judged on the field as declared, so a stored expression the caller is not shown (it names a field they cannot read) still answers `true`, and the expression itself stays hidden. An ordinary field still answers `readonly: false`, and a declared `readonly` still answers `true`.

`readonly` is the schema's answer, not the caller's field edit permission: a field the caller may read but not edit still answers `readonly: false`, and a write naming it is refused as before. The tool description now says so, and it also says that a `defaultValue` may be a token the server resolves (`NOW()`, `current_user`) or an expression object, so an agent omits the field instead of copying the value.
10 changes: 10 additions & 0 deletions .changeset/33030-stdio-get-record-coded-not-found.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/mcp': patch
---

fix(mcp): stdio `get_record` answers a missing or hidden id with the coded `RECORD_NOT_FOUND`

Clause-②: no
Metadata-protocol: none

Over the stdio transport, `get_record` on an id that names no row, or a row the caller may not read, answered the bare sentence `Record "<id>" not found in "<object>"` with no code. It now answers the coded envelope the HTTP `/mcp` door answers for the same call, `{ "error": { "code": "RECORD_NOT_FOUND", "message": "Record <id> not found in <object>", "status": 404 } }`, so a stdio client can branch on the code. A missing id and a hidden id answer the same envelope. `update_record` and `delete_record` already answered it and are unchanged.
10 changes: 10 additions & 0 deletions .changeset/33156-stdio-describe-readonly-declared.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/mcp': patch
---

fix(mcp): stdio `describe_object` and the objects resource judge `readonly` on the declared field

Clause-②: no
Metadata-protocol: none

Over stdio and on the `objectstack://objects/{name}` resource, a stored field that declares an `expression` the caller is not shown (it names a field they cannot read) was described `readonly: false`, so an agent could send a value the engine then replaced. `readonly` is now judged on the field as declared, as the HTTP `/mcp` door judges it, so that field answers `readonly: true` on every transport. The expression itself, and the field it names, are still not served: only the `readonly` answer is read from the declaration.
12 changes: 11 additions & 1 deletion packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ import { AggregationNodeSchema } from '@objectstack/spec/data';
// [#23529] The catalogue's spelling of `PERMISSION_DENIED`, the code whose
// `details` `errorResultFromThrown` withholds. Imported, never hand-spelled.
import { StandardErrorCode, type ErrorCode } from '@objectstack/spec/api';
// The repo's ONE not-found envelope, which the HTTP bridge's `get` throws:
// `get_record` refuses a null read with it, so both doors answer one 404.
import { recordNotFoundError } from '@objectstack/core';
// [#23701] The one key every door that translates the engine's unique-violation
// envelope asks (the registered code AND the class name, never message text),
// and the one classification whose arm states the wire code for it.
Expand Down Expand Up @@ -2801,7 +2804,14 @@ export function registerObjectTools(
if (bad) return errorResult(bad);
try {
const record = await bridge.get(objectName, recordId);
if (record == null) return errorResult(`Record "${recordId}" not found in "${objectName}"`);
// A bridge that answers a miss with `null` (the stdio bridge, by
// design) is refused with the repo's ONE not-found envelope, thrown
// into the catch below: the HTTP bridge's `get` throws that same
// value (`protocol.getData`, through `callData`), so both doors
// render one throw through one renderer and answer one coded
// `RECORD_NOT_FOUND` / 404. A missing id and an id the caller may not
// read reach here as the same empty read, so they answer alike.
if (record == null) throw recordNotFoundError(objectName, recordId);
return textResult(record);
} catch (err) {
return dataDoorErrorResult(err, 'get_record', objectName);
Expand Down
72 changes: 61 additions & 11 deletions packages/mcp/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,16 @@ interface ServedFieldDef {
* One projection for this package's two readers of it: the stdio bridge's
* `describeObject` (`stdio-data-bridge.ts`) and the `objectstack://objects/{name}`
* resource ({@link buildObjectSchemaResource}), each handing it the object as
* the caller is served it, so the two answer alike (#25245). The HTTP bridge's
* the caller is served it, so the two answer alike (#25245), and the
* declaration that object was projected from ({@link readObjectWithDeclaration}):
* `readonly` is judged on the declared field, {@link writeNeverSetsField}, and
* nothing else is read from it. The HTTP bridge's
* twin is `describeServedObject` in `packages/runtime/src/domains/mcp.ts`
* (`@objectstack/runtime` does not depend on this package): the transports
* answer one shape, and the `describe_object` description (`mcp-http-tools.ts`)
* states it.
*/
export function describeServedObject(def: ServedObjectDef): Record<string, unknown> {
export function describeServedObject(def: ServedObjectDef, declared: ServedObjectDef = def): Record<string, unknown> {
return {
name: def.name,
label: def.label ?? def.name,
Expand All @@ -142,7 +145,12 @@ export function describeServedObject(def: ServedObjectDef): Record<string, unkno
type: f?.type,
label: f?.label ?? key,
required: f?.required ?? false,
readonly: writeNeverSetsField(f),
// Judged on the DECLARED field, never the served one: the caller's mask
// deletes a readable field's `expression` when it names a field they
// cannot read, and the engine still computes the field. Only the boolean
// is read there; the served definition, without that expression, is
// what the answer is built from.
readonly: writeNeverSetsField(declared.fields?.[key]),
system: f?.system === true,
...(declaresOptions(f) ? { options: describedOptions(f.options as unknown[]) } : {}),
...(f?.defaultValue !== undefined ? { defaultValue: f.defaultValue } : {}),
Expand Down Expand Up @@ -171,12 +179,10 @@ export function describeServedObject(def: ServedObjectDef): Record<string, unkno
* at the field-level write gate. The served object carries the read projection
* only, and the `describe_object` description says so.
*
* ⚠️ Asked of the object as the caller is SERVED it, the only object this
* package's two readers hand over: the caller's mask deletes a readable
* field's `expression` when it names a field the caller cannot read, so for
* that caller the expression arm does not hold and the field is served
* `readonly: false` while the engine still computes it. The HTTP twin asks
* the declared field instead (the bridge holds the declaration it masks).
* Asked of the DECLARED field ({@link describeServedObject}'s `declared`),
* as the HTTP twin asks it: the caller's mask deletes an `expression` that
* names a field the caller cannot read, while the engine still computes the
* column from it, so the served field alone would answer `readonly: false`.
*
* The twin is `writeNeverSetsField` in `packages/runtime/src/domains/mcp.ts`.
*/
Expand Down Expand Up @@ -1243,6 +1249,47 @@ export type McpObjectView = Pick<IMetadataService, 'listObjects' | 'getObject'>;
*/
const runtimeObjectViews = new WeakMap<MCPServerRuntime, McpObjectView>();

/** One object as a view serves it, beside the declaration it was projected from. */
export interface ObjectWithDeclaration {
served: unknown;
declared: unknown;
}

/**
* The read a MASKING view ({@link setDeclaredObjectRead}) answers both halves
* of {@link ObjectWithDeclaration} with, from one read of the definition it
* masks. Keyed weakly by the view, so a view and its read are collected
* together; package-internal, like {@link setRuntimeObjectView}.
*/
const declaredObjectReads = new WeakMap<McpObjectView, (name: string) => Promise<ObjectWithDeclaration>>();

/**
* Register `read` as the declared-object read of `view`, a view that serves a
* caller a projection of each definition (the stdio caller view,
* `callerObjectView` in `stdio-data-bridge.ts`). Package-internal; not
* re-exported from `index.ts`.
*/
export function setDeclaredObjectRead(view: McpObjectView, read: (name: string) => Promise<ObjectWithDeclaration>): void {
declaredObjectReads.set(view, read);
}

/**
* `name` as `view` serves it, and the declaration it was projected from: what
* {@link describeServedObject} needs to judge `readonly` on the declared field
* while serving only the projection. A view that registered no declared read
* masks nothing, so the object it serves IS the declaration.
*
* ⛔ The declaration never leaves this package's two describe readers: only
* {@link writeNeverSetsField}'s boolean is read off it, so nothing the caller's
* mask withholds (an expression naming a field they cannot read) is served.
*/
export async function readObjectWithDeclaration(view: McpObjectView, name: string): Promise<ObjectWithDeclaration> {
const read = declaredObjectReads.get(view);
if (read) return read(name);
const def = await view.getObject(name);
return { served: def, declared: def };
}

/**
* [#31605] The long-lived server's principal: the `OS_MCP_STDIO_API_KEY`
* identity the plugin resolves per call (ADR-0101), bound by the plugin once
Expand Down Expand Up @@ -1324,9 +1371,12 @@ export async function buildObjectSchemaResource(
// refuses a schema whose field visibility it could not evaluate) answers
// in this resource's `{ error, code, status }` vocabulary
// ({@link objectResourceRefusal}); an uncoded throw travels, as before.
// …beside the declaration it was projected from, for `readonly` alone
// ({@link readObjectWithDeclaration}).
let objectDef: unknown;
let declared: unknown;
try {
objectDef = await objects.getObject(objectName);
({ served: objectDef, declared } = await readObjectWithDeclaration(objects, objectName));
} catch (err) {
const refusal = objectResourceRefusal(err);
if (refusal) return body(refusal);
Expand Down Expand Up @@ -1361,7 +1411,7 @@ export async function buildObjectSchemaResource(
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify(describeServedObject(objectDef as ServedObjectDef), null, 2),
text: JSON.stringify(describeServedObject(objectDef as ServedObjectDef, declared as ServedObjectDef), null, 2),
}],
};
}
Expand Down
150 changes: 150 additions & 0 deletions packages/mcp/src/stdio-data-bridge.not-found.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@
*/

import { describe, it, expect, vi } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { recordNotFoundError } from '@objectstack/core';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts';
import { createStdioDataBridge } from './stdio-data-bridge.js';
import { MCPServerRuntime } from './mcp-server-runtime.js';
import type { McpDataBridge } from './mcp-http-tools.js';
import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';

/** An engine that resolves NO row for any id — every by-id write is a miss. */
Expand Down Expand Up @@ -124,3 +129,148 @@ describe('#8422 stdio bridge by-id writes throw the shared not-found envelope',
expect(updateErr?.code).toBe('RECORD_NOT_FOUND');
});
});

// ---------------------------------------------------------------------------
// The tool door: `get_record` over the long-lived server stdio attaches
// ---------------------------------------------------------------------------

/**
* One row, `secret1`, that only its owner reads: the engine double answers the
* row to `owner` and nothing to anyone else, which is what the security
* middleware's row filter does to a by-id read the caller may not see. So for
* {@link PRINCIPAL} `secret1` is HIDDEN and `ghost` is MISSING, and the two
* reach the bridge as the same empty read.
*/
function makeRowFilteringEngine() {
const row = { id: 'secret1', title: 'owner only' };
const visibleTo = (opts?: { context?: { userId?: string } }) => opts?.context?.userId === 'owner';
// The file's one engine double, with its by-id read made row-filtering.
const engine = makeEmptyEngine();
const find = vi.fn(async (_object: string, query?: { where?: { id?: unknown } }, opts?: { context?: { userId?: string } }) =>
query?.where?.id === row.id && visibleTo(opts) ? [row] : []);
return Object.assign(engine, { find });
}

function stdioBridgeAs(principal: ExecutionContext) {
const engine = makeRowFilteringEngine();
const bridge = createStdioDataBridge({
engine: engine as unknown as IDataEngine,
metadataService: makeMetadata() as unknown as IMetadataService,
resolvePrincipal: async () => principal,
});
return { bridge, engine };
}

/** A `tools/call` on the long-lived server (`runtime.server`, the one `start()` attaches stdio to). */
async function callOnLongLivedServer(bridge: McpDataBridge, name: string, args: Record<string, unknown>) {
const runtime = new MCPServerRuntime({ name: 'pin', version: '0.0.0' });
runtime.bridgeDataTools(bridge);
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
await runtime.server.connect(serverSide);
const client = new Client({ name: 'pin', version: '0.0.0' });
await client.connect(clientSide);
try {
return await client.callTool({ name, arguments: args });
} finally {
await client.close();
}
}

/**
* The HTTP `/mcp` door's answer to the same call: its bridge's `get` reaches
* `protocol.getData` through `callData`, which throws `recordNotFoundError`
* for an id that names no row the caller may read (pinned as the HTTP face's
* answer in `mcp-coded-refusal.test.ts`). Driven through `handleHttpRequest`,
* the real Streamable HTTP transport, so the two answers compared below come
* from the two doors' own tool registrations.
*/
async function getRecordOverHttp(objectName: string, recordId: string) {
const bridge: McpDataBridge = {
async listObjects() { return [{ name: 'task', label: 'Task', fieldCount: 0 }]; },
async describeObject() { return null; },
async query(object) { return { object, records: [], total: 0 }; },
async get(object, id) { throw recordNotFoundError(object, id); },
async create(object, data) { return { object, id: 'n1', record: data }; },
async update(object, id, data) { return { object, id, record: data }; },
async remove(object, id) { return { object, id, success: true }; },
};
const body = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'get_record', arguments: { objectName, recordId } } };
const res = await new MCPServerRuntime({ name: 'pin', version: '0.0.0' }).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 },
);
expect(res.status).toBe(200);
return ((await res.json()) as { result: unknown }).result;
}

/** The coded envelope `get_record` answers a miss with, as both doors put it on the wire. */
function notFoundWire(object: string, id: string) {
return { error: { code: 'RECORD_NOT_FOUND', message: `Record ${id} not found in ${object}`, status: 404 } };
}

describe('stdio get_record answers a missing or hidden id with the coded RECORD_NOT_FOUND / 404', () => {
it('a MISSING id: isError, and the text is the coded envelope, not a bare sentence', async () => {
const { bridge } = stdioBridgeAs(PRINCIPAL);
const result = (await callOnLongLivedServer(bridge, 'get_record', { objectName: 'task', recordId: 'ghost' })) as {
isError?: boolean;
content: Array<{ text: string }>;
};
expect(result.isError).toBe(true);
expect(JSON.parse(result.content[0]!.text)).toEqual(notFoundWire('task', 'ghost'));
});

it('a HIDDEN id answers the same envelope as a missing one: no existence oracle', async () => {
const { bridge, engine } = stdioBridgeAs(PRINCIPAL);
const hidden = (await callOnLongLivedServer(bridge, 'get_record', { objectName: 'task', recordId: 'secret1' })) as {
isError?: boolean;
content: Array<{ text: string }>;
};
expect(hidden.isError).toBe(true);
expect(JSON.parse(hidden.content[0]!.text)).toEqual(notFoundWire('task', 'secret1'));
// The read ran as the caller, so the row filter is what hid it.
expect(engine.find).toHaveBeenCalledWith('task', expect.objectContaining({ where: { id: 'secret1' } }), { context: PRINCIPAL });
});

it('CONTROL: the hidden row is served to its owner, so the refusal above is the row filter, not a missing row', async () => {
const { bridge } = stdioBridgeAs({ userId: 'owner', isSystem: false } as unknown as ExecutionContext);
const served = (await callOnLongLivedServer(bridge, 'get_record', { objectName: 'task', recordId: 'secret1' })) as {
isError?: boolean;
content: Array<{ text: string }>;
};
expect(served.isError).toBeFalsy();
expect(JSON.parse(served.content[0]!.text)).toEqual({ id: 'secret1', title: 'owner only' });
});

it('PARITY: the stdio answer equals the HTTP /mcp answer for the same call, byte for byte', async () => {
for (const recordId of ['ghost', 'secret1']) {
const { bridge } = stdioBridgeAs(PRINCIPAL);
const overStdio = await callOnLongLivedServer(bridge, 'get_record', { objectName: 'task', recordId });
const overHttp = await getRecordOverHttp('task', recordId);
expect(overStdio, `get_record ${recordId}`).toEqual(overHttp);
}
});

it('update_record and delete_record on the same ids answer the same coded envelope (already coded, held here)', async () => {
for (const recordId of ['ghost', 'secret1']) {
const { bridge } = stdioBridgeAs(PRINCIPAL);
const updated = (await callOnLongLivedServer(bridge, 'update_record', {
objectName: 'task',
recordId,
data: { title: 'x' },
})) as { isError?: boolean; content: Array<{ text: string }> };
expect(updated.isError).toBe(true);
expect(JSON.parse(updated.content[0]!.text)).toEqual(notFoundWire('task', recordId));
const deleted = (await callOnLongLivedServer(bridge, 'delete_record', {
objectName: 'task',
recordId,
confirm: true,
})) as { isError?: boolean; content: Array<{ text: string }> };
expect(deleted.isError).toBe(true);
expect(JSON.parse(deleted.content[0]!.text)).toEqual(notFoundWire('task', recordId));
}
});
});
Loading
Loading