Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/32530-describe-object-readonly-computed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/mcp': patch
'@objectstack/runtime': patch
---

fix(mcp,runtime): `describe_object` serves `readonly: true` where a write never sets the value

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`.

`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.
13 changes: 9 additions & 4 deletions packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,9 @@ export interface McpDataBridge {
* `validate_expression` hands it to the shared validator as
* `ExprSchemaHint.optionFields`, so the tool refuses the `label()` call the
* object save door refuses, in the same words. It is a member of its own,
* not a key on {@link describeObject}'s answer, so `describe_object` serves
* what it served before.
* not a key on {@link describeObject}'s answer: `describe_object` already
* serves each field's `options`, and this list names the fields that carry
* them without a second copy of the object.
*
* OPTIONAL, like {@link listObjectsDiagnosed}: a bridge that omits it gets
* no `label()` target check, exactly as before (the validator's opt-in).
Expand Down Expand Up @@ -2253,8 +2254,12 @@ export function registerObjectTools(
{
description:
'Get the schema of a data object as you may read it, with what a write needs. Each field: name, type, label, '
+ 'required, readonly (a write to it is dropped), system (a platform-injected column), options (value + label; '
+ 'send the value) on a field that offers a fixed set, and defaultValue when an insert applies one. '
+ 'required, readonly (a write never sets it: declared read-only, or a value the platform computes, such as a '
+ 'formula, an autonumber or a field with an expression; leave it out of a write), system (a platform-injected '
+ 'column), options (value + label; send the value) on a field that offers a fixed set, and defaultValue when '
+ 'an insert applies one. A defaultValue may be a token the server resolves (NOW(), current_user) or an '
+ 'expression object: to get it, omit the field, never copy the value. readonly is the schema\'s, not your '
+ 'edit permission: a field you may read but not edit shows readonly false, and a write naming it is refused. '
+ 'validations: each active rule\'s name, type, message, severity, events and the fields it names (the '
+ 'message is what a refused write answers). Also the enabled features.',
inputSchema: strictToolInput(
Expand Down
50 changes: 45 additions & 5 deletions packages/mcp/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { z } from 'zod';
// [#28715] The engine's own reading of "this field declares options" (its
// `label()` map is built from it), never a second spelling here.
import { declaresOptions } from '@objectstack/formula';
import { RUNTIME_OWNED_FIELD_TYPES, isVirtualSearchField } from '@objectstack/spec/data';

/**
* Configuration for the MCP Server Runtime.
Expand Down Expand Up @@ -104,6 +105,7 @@ interface ServedFieldDef {
system?: boolean;
options?: unknown[];
defaultValue?: unknown;
expression?: unknown;
}

/**
Expand All @@ -113,10 +115,11 @@ interface ServedFieldDef {
* that names a denied field anywhere, its message included) is not described.
*
* Per field: `required`, `readonly` and `system` (an auto-injected column such
* as `created_at` is `readonly` and `system`), the allowed `options` as value +
* label for a field that declares them (a picklist-bound field carries its
* list's resolved options on the served object, {@link declaresOptions}), and
* the `defaultValue` an INSERT applies. Per active validation rule: its name,
* as `created_at` is `readonly` and `system`; [#32530] `readonly` is also true
* where a write never sets the value, {@link writeNeverSetsField}), the allowed
* `options` as value + label for a field that declares them (a picklist-bound
* field carries its list's resolved options on the served object,
* {@link declaresOptions}), and the `defaultValue` an INSERT applies. Per active validation rule: its name,
* kind, message, severity, the write events it runs on, and the fields it names
* — ⛔ never its condition, pattern or transition table: the rule's message is
* what a refused write answers with, and its expression is server policy.
Expand All @@ -139,7 +142,7 @@ export function describeServedObject(def: ServedObjectDef): Record<string, unkno
type: f?.type,
label: f?.label ?? key,
required: f?.required ?? false,
readonly: f?.readonly === true,
readonly: writeNeverSetsField(f),
system: f?.system === true,
...(declaresOptions(f) ? { options: describedOptions(f.options as unknown[]) } : {}),
...(f?.defaultValue !== undefined ? { defaultValue: f.defaultValue } : {}),
Expand All @@ -149,6 +152,43 @@ export function describeServedObject(def: ServedObjectDef): Record<string, unkno
};
}

/**
* [#32530] `readonly` as {@link describeServedObject} serves it: a caller's
* write can never set this field's value. True for the declared flag, and for
* a value the engine owns whatever the payload says:
*
* - a runtime-owned type (`RUNTIME_OWNED_FIELD_TYPES`, today `autonumber`):
* the write path strips a caller's value even with no `readonly` flag, and a
* hand-authored literal carries none (the field builder injects it);
* - a `formula`, virtual — computed on read, no column (`isVirtualSearchField`,
* the spec's one statement of that set): the write path strips it;
* - a stored field declaring an `expression`: the engine computes it from the
* row the write stores, so a client-sent value never stands (the spec's
* `expression` contract).
*
* ⛔ Not the caller's field edit permission: a field the caller may read but
* not edit is served the schema's answer, and the write naming it is refused
* 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).
*
* The twin is `writeNeverSetsField` in `packages/runtime/src/domains/mcp.ts`.
*/
function writeNeverSetsField(f: ServedFieldDef | undefined): boolean {
if (!f) return false;
if (f.readonly === true) return true;
if (typeof f.type === 'string' && RUNTIME_OWNED_FIELD_TYPES.has(f.type)) return true;
if (isVirtualSearchField(f)) return true;
// Truthy, the engine's own test for a stored expression it plans.
return Boolean(f.expression);
}

/** A field's options as value + label: the stored value a write sends, and what a person reads. */
function describedOptions(options: unknown[]): Array<{ value: unknown; label: unknown }> {
return options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
* Each bridge answers `optionFieldsOf(name)` from the object `describeObject`
* reads, through `@objectstack/formula`'s `declaresOptions`, and the tool
* hands that list to the validator on the `formula` site, the one site the
* door judges it on. `describe_object` serves what it served before.
* door judges it on. The list is a member of its own, not a key on
* `describe_object`'s answer.
*
* Which predicate: measured on real kernels (`ObjectKernel` + `MetadataPlugin`
* + `ObjectQLPlugin`, with and without an `environmentId`), the protocol's
Expand Down
64 changes: 62 additions & 2 deletions packages/mcp/src/stdio-data-bridge.object-schema-mask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
import { createStdioDataBridge } from './stdio-data-bridge.js';
import { registerObjectTools } from './mcp-http-tools.js';
import { MCPServerPlugin } from './plugin.js';
import type { MCPServerRuntime } from './mcp-server-runtime.js';
import { buildObjectSchemaResource, type MCPServerRuntime } from './mcp-server-runtime.js';

const OBJECT = FLS_CONTRACT_OBJECT.name;
const account = () => JSON.parse(JSON.stringify(FLS_CONTRACT_OBJECT));
Expand Down Expand Up @@ -142,7 +142,8 @@ const DECLARED_ANSWER = {
system: false,
options: [{ value: 'band_a', label: 'Band A' }, { value: 'band_b', label: 'Band B' }],
},
{ name: 'bonus_formula', type: 'formula', label: 'Bonus', required: false, readonly: false, system: false },
// [#32530] A formula is computed on read: a write to it never stands.
{ name: 'bonus_formula', type: 'formula', label: 'Bonus', required: false, readonly: true, system: false },
],
validations: [
{ name: 'graded_account_needs_name', type: 'script', message: 'A graded account needs a name.', fields: [] },
Expand Down Expand Up @@ -541,3 +542,62 @@ describe('[#32435] stdio describe_object serves what a write needs, as the calle
expect(text).toBe((await describeAs(undefined)).text);
});
});

describe('[#32530] stdio describe_object and the objects resource serve readonly where a write never stands', () => {
const TICKET = {
name: 'ticket',
label: 'Ticket',
fields: {
// CONTROL: an ordinary field, writable.
title: { type: 'text', label: 'Title' },
// CONTROL: a declared `readonly` stays true.
frozen_note: { type: 'text', label: 'Frozen note', readonly: true },
// A hand-authored runtime-owned literal: no `readonly` flag.
ticket_no: { type: 'autonumber', label: 'Ticket No' },
// Computed on read, no column.
total: { type: 'formula', label: 'Total', expression: 'record.qty * 2' },
// The write path strips a formula by its TYPE, an expression or not.
bare_formula: { type: 'formula', label: 'Bare formula' },
// A stored field the engine computes on every write.
doubled: { type: 'number', label: 'Doubled', expression: 'record.qty * 2' },
},
};
const EXPECTED = { title: false, frozen_note: true, ticket_no: true, total: true, bare_formula: true, doubled: true };
const readonlyOf = (described: any) => Object.fromEntries(described.fields.map((f: any) => [f.name, f.readonly]));
const metadataService = () => {
const object = () => JSON.parse(JSON.stringify(TICKET));
return {
listObjects: vi.fn(async () => [object()]),
getObject: vi.fn(async (name: string) => (name === TICKET.name ? object() : undefined)),
} as unknown as IMetadataService;
};

it('describe_object over stdio', async () => {
const bridge = createStdioDataBridge({
engine: {} as unknown as IDataEngine,
metadataService: metadataService(),
resolvePrincipal: async () => ({ userId: 'u_member' }) as unknown as ExecutionContext,
});
const server = new McpServer({ name: 'pin', version: '0' });
registerObjectTools(server, bridge);
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
await server.connect(serverSide);
const client = new Client({ name: 'pin', version: '0' });
await client.connect(clientSide);
try {
const res = (await client.callTool({ name: 'describe_object', arguments: { objectName: TICKET.name } })) as {
isError?: boolean;
content: Array<{ text: string }>;
};
expect(res.isError === true).toBe(false);
expect(readonlyOf(JSON.parse(res.content[0]!.text))).toEqual(EXPECTED);
} finally {
await client.close();
}
});

it('the objectstack://objects/{name} resource', async () => {
const result = await buildObjectSchemaResource(metadataService(), TICKET.name);
expect(readonlyOf(JSON.parse(result.contents[0]!.text))).toEqual(EXPECTED);
});
});
Loading
Loading