Skip to content
Draft
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
13 changes: 13 additions & 0 deletions .changeset/mcp-describe-object-search-companion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/mcp': patch
'@objectstack/runtime': patch
---

fix(mcp): `describe_object` no longer lists the hidden `__search` search companion, on either MCP transport

Clause-②: no
Metadata-protocol: none

Where pinyin search is on, the registry serves an object with the hidden `__search` companion column beside its display field. `describe_object` described every served field, so it offered an agent that column (as `readonly` and `system`), while no caller may read, write or name it: a record never echoes it, a `$searchFields` override naming it is refused, and `validate_expression` answers `unknown-field` for a read of it.

`describe_object` now leaves the companion out on both transports: the HTTP bridge in `@objectstack/runtime`, and the stdio bridge and the `objectstack://objects/{name}` resource in `@objectstack/mcp`. Both skip it by the protocol's one constant, `SEARCH_COMPANION_FIELD` from `@objectstack/spec/data`. Every other field is listed as before, the injected system columns such as `organization_id` and `created_at` included.
16 changes: 16 additions & 0 deletions .changeset/mcp-validate-expression-search-companion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/mcp': patch
'@objectstack/spec': patch
'@objectstack/objectql': patch
---

fix(mcp): `validate_expression` no longer lists the hidden `__search` search companion in `inScope.fields`, and a read of it is refused as `os validate` refuses it

Clause-②: yes (widening) — `@objectstack/spec/data` gains the `SEARCH_COMPANION_FIELD` constant (`'__search'`); `@objectstack/objectql` re-exports the same value it exported before.
Metadata-protocol: none

Where pinyin search is on, the object an MCP caller is served carries the hidden `__search` companion column beside its declared fields. `validate_expression` took its field list from that object, so it named `__search` in `inScope.fields` and answered `ok` for an expression such as `record.__search != ''`. Every record read strips that column, and `os validate` judges an expression against the declared and injected columns only, so the same rule failed there with `unknown-field`.

The tool now leaves the companion out of the fields it judges and lists. Every other field, injected system columns such as `organization_id` among them, is listed and judged as before.

The companion's name now lives in the protocol, as `SEARCH_COMPANION_FIELD` in `@objectstack/spec/data`, so a layer that only needs the name does not have to load the engine to read it. `@objectstack/objectql` provisions and strips the column under that same constant and still exports it under the same name, so engine callers change nothing. `@objectstack/mcp` reads it from the spec and gains no runtime dependency on the engine.
1 change: 1 addition & 0 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"zod": "^4.6.6"
},
"devDependencies": {
"@objectstack/objectql": "workspace:*",
"@types/node": "^26.6.5",
"typescript": "^6.0.3",
"vitest": "^4.1.11"
Expand Down
10 changes: 9 additions & 1 deletion packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import type { EngineAggregateOptions } from '@objectstack/spec/data';
// entry IS this node, so this door and the REST query door (which parses
// `FindDataRequestSchema`, whose `aggregations` are this node) judge one shape
// with one refusal.
import { AggregationNodeSchema } from '@objectstack/spec/data';
import { AggregationNodeSchema, SEARCH_COMPANION_FIELD } 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';
Expand Down Expand Up @@ -2632,8 +2632,16 @@ export function registerObjectTools(
const fieldDefs = Array.isArray(def.fields) ? (def.fields as Array<Record<string, unknown>>) : [];
const fields: string[] = [];
const fieldTypes: Record<string, string> = {};
// The hidden search companion is served beside the declared fields
// (the registry stamps it where pinyin search is on), but no
// expression may read it: every read strips it, and `os validate`
// judges against the declared and injected columns, which never
// include it. Left in, it is listed in `inScope.fields` and a read
// of it answers ok here while the gate refuses it. The name is the
// protocol's one constant, the same one the engine provisions under.
for (const f of fieldDefs) {
if (typeof f?.name !== 'string') continue;
if (f.name === SEARCH_COMPANION_FIELD) continue;
fields.push(f.name);
if (typeof f?.type === 'string') fieldTypes[f.name] = f.type;
}
Expand Down
11 changes: 9 additions & 2 deletions packages/mcp/src/mcp-server-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +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';
import { RUNTIME_OWNED_FIELD_TYPES, SEARCH_COMPANION_FIELD, isVirtualSearchField } from '@objectstack/spec/data';

/**
* Configuration for the MCP Server Runtime.
Expand Down Expand Up @@ -124,6 +124,13 @@ interface ServedFieldDef {
* — ⛔ never its condition, pattern or transition table: the rule's message is
* what a refused write answers with, and its expression is server policy.
*
* ⛔ Never the hidden search companion (`SEARCH_COMPANION_FIELD`), which the
* registry serves beside the display field where pinyin search is on: no
* caller may read, write or name it (a record never echoes it, a
* `$searchFields` override naming it is refused, `validate_expression`
* answers `unknown-field`), so the door that offers an agent a field to use
* does not list it. The HTTP twin skips it by the same constant.
*
* 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
Expand All @@ -140,7 +147,7 @@ export function describeServedObject(def: ServedObjectDef, declared: ServedObjec
return {
name: def.name,
label: def.label ?? def.name,
fields: Object.entries(def.fields ?? {}).map(([key, f]) => ({
fields: Object.entries(def.fields ?? {}).filter(([key]) => key !== SEARCH_COMPANION_FIELD).map(([key, f]) => ({
name: key,
type: f?.type,
label: f?.label ?? key,
Expand Down
61 changes: 60 additions & 1 deletion packages/mcp/src/mcp-validate-expression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import {
} from '@objectstack/lint';

import { applyInjectedSystemColumns } from '@objectstack/metadata-core';
import { provisionSearchCompanion, SEARCH_COMPANION_FIELD } from '@objectstack/objectql/core';

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

/**
Expand Down Expand Up @@ -919,3 +920,61 @@ describe('validate_expression judges the field being authored at the formula sit
expect((json as { result: { isError?: boolean } }).result.isError).toBe(true);
});
});

describe('validate_expression keeps the hidden search companion out of scope', () => {
// A field-scoped search object as the registry serves it with pinyin search
// on: the companion stamped beside the declared fields, then projected the
// way the transports project it.
const authored = {
name: 'sc_contact',
label: 'Contact',
fields: {
name: { type: 'text', label: 'Name', required: true },
email: { type: 'email', label: 'Email' },
},
};
const served = provisionSearchCompanion(applyInjectedSystemColumns(authored));
const bridge = (): McpDataBridge => ({
...makeBridge(),
async listObjects() {
return [{ name: 'sc_contact', label: 'Contact', fieldCount: Object.keys(served.fields).length }];
},
async describeObject(name: string) {
return name === 'sc_contact' ? describeServedObject(served as never) : null;
},
});

let runtime: MCPServerRuntime;
beforeEach(() => {
runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' });
});

it('the served object really carries the companion (precondition)', () => {
expect(Object.keys(served.fields)).toContain(SEARCH_COMPANION_FIELD);
});

it('inScope.fields lists the ordinary fields and no companion', async () => {
const r = await validate(runtime, bridge(), { objectName: 'sc_contact', expression: "record.name != ''", site: 'validation' });
expect(r.ok).toBe(true);
expect(r.inScope.fields).toContain('name');
expect(r.inScope.fields).toContain('email');
expect(r.inScope.fields).toContain('created_at');
expect(r.inScope.fields).not.toContain(SEARCH_COMPANION_FIELD);
});

it('a read of the companion is refused as a field the object does not have, as os validate refuses it', async () => {
const expression = `record.${SEARCH_COMPANION_FIELD} != ''`;
const gate = validateStackExpressions({
objects: [{ ...authored, validations: [{ name: AUTHORED_RULE_NAME, type: 'script', condition: expression, message: 'm' }] }],
}).filter((i) => i.where.includes(`'${AUTHORED_RULE_NAME}'`));
expect(gate.map((i) => i.severity ?? 'error')).toEqual(['error']);
expect(gate[0]!.message).toContain(SEARCH_COMPANION_FIELD);

for (const site of ['validation', 'formula', 'flow_condition'] as const) {
const r = await validate(runtime, bridge(), { objectName: 'sc_contact', expression, site });
expect(r.ok, site).toBe(false);
const refused = r.errors.filter((e: { code?: string }) => e.code === 'unknown-field');
expect(refused.map((e: { params?: { field?: string } }) => e.params?.field), site).toEqual([SEARCH_COMPANION_FIELD]);
}
});
});
67 changes: 67 additions & 0 deletions packages/mcp/src/stdio-data-bridge.object-schema-mask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ import {
} from '@objectstack/metadata-core/testing';
import {
OBJECT_SCHEMA_MASK_DISABLE_ENV,
applyInjectedSystemColumns,
assertEngineUpdateDispatch,
type ObjectSchemaReadDelegateSource,
} from '@objectstack/metadata-core';
import { SEARCH_COMPANION_FIELD } from '@objectstack/spec/data';
import { provisionSearchCompanion } from '@objectstack/objectql/core';
import { callerObjectView, createStdioDataBridge, stdioObjectView } from './stdio-data-bridge.js';
import { registerObjectTools } from './mcp-http-tools.js';
import { MCPServerPlugin } from './plugin.js';
Expand Down Expand Up @@ -721,3 +724,67 @@ describe('stdio describe_object and the objects resource judge readonly on the D
expect((await describeOverResource()).json.fields).toEqual((await describeOverStdio()).json.fields);
});
});

describe('stdio describe_object and the objects resource never list the hidden search companion', () => {
// A field-scoped search object as the registry serves it with pinyin search
// on: the injected system columns, and the companion the engine stamps
// beside the display field (`provisionSearchCompanion`, the producer's own
// function, not a transcription of its output). The companion is a column
// no caller may read, write or name, so the door that offers an agent a
// field to use does not list it. The HTTP twin is pinned on a real engine in
// `packages/runtime/src/domains/mcp-object-schema-mask.test.ts`.
const CONTACT = 'sc_contact';
const served = () => provisionSearchCompanion(applyInjectedSystemColumns({
name: CONTACT,
label: 'Contact',
fields: {
name: { type: 'text', label: 'Name', required: true },
email: { type: 'email', label: 'Email' },
},
}));
const metadataService = () => ({
listObjects: vi.fn(async () => [served()]),
getObject: vi.fn(async (name: string) => (name === CONTACT ? served() : undefined)),
}) as unknown as IMetadataService;
const KEPT = ['name', 'email', 'created_at', 'updated_at', 'organization_id'];
const namesOf = (described: any): string[] => described.fields.map((f: any) => f.name);

it('precondition: the served object carries the companion', () => {
expect(Object.keys(served().fields)).toContain(SEARCH_COMPANION_FIELD);
});

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,
});
expect(Object.keys((await bridge.servedObjectDefinitionOf!(CONTACT))!.fields as object)).toContain(SEARCH_COMPANION_FIELD);
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: CONTACT } })) as {
isError?: boolean;
content: Array<{ text: string }>;
};
expect(res.isError === true).toBe(false);
const names = namesOf(JSON.parse(res.content[0]!.text));
expect(names).not.toContain(SEARCH_COMPANION_FIELD);
expect(names).toEqual(expect.arrayContaining(KEPT));
expect(res.content[0]!.text).not.toContain(SEARCH_COMPANION_FIELD);
} finally {
await client.close();
}
});

it('the objectstack://objects/{name} resource', async () => {
const result = await buildObjectSchemaResource(metadataService(), CONTACT);
const text = result.contents[0]!.text;
expect(namesOf(JSON.parse(text))).toEqual(expect.arrayContaining(KEPT));
expect(text).not.toContain(SEARCH_COMPANION_FIELD);
});
});
8 changes: 8 additions & 0 deletions packages/mcp/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,18 @@ export default defineConfig({
// which drives the stdio exit through the shared ADR-0106 case table: the
// table is a VALUE import too, and judging this transport against a stale
// build of it would be the same silent wrong-tree verdict.
//
// The `@objectstack/objectql/core` entry (the lean engine entry, ADR-0076)
// is for `mcp-validate-expression.test.ts`, which provisions the search
// companion with the engine's own `provisionSearchCompanion` and reads its
// name from `SEARCH_COMPANION_FIELD`: VALUE imports, judged against the
// source in the checkout, not a build of it. Only the test loads the
// engine; the tool reads the name from `@objectstack/spec/data`.
alias: [
{ find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../metadata-core/src/index.ts') },
{ find: /^@objectstack\/metadata-core\/testing$/, replacement: path.resolve(__dirname, '../metadata-core/src/testing.ts') },
{ find: /^@objectstack\/lint$/, replacement: path.resolve(__dirname, '../lint/src/index.ts') },
{ find: /^@objectstack\/objectql\/core$/, replacement: path.resolve(__dirname, '../objectql/src/core.ts') },
],
},
});
11 changes: 6 additions & 5 deletions packages/objectql/src/search-companion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,17 @@
* the same column.
*/

import { resolveDisplayField, TITLE_ELIGIBLE_TYPES } from '@objectstack/spec/data';
import { resolveDisplayField, SEARCH_COMPANION_FIELD, TITLE_ELIGIBLE_TYPES } from '@objectstack/spec/data';
import { SystemFieldName } from '@objectstack/spec/system';
import { isFederatedObject } from './federated-object.js';

/**
* Name of the hidden companion column. Double-underscore prefixed so it can
* never collide with an author-declared field (machine names are snake_case
* words) and reads as platform plumbing in raw rows.
* Name of the hidden companion column. The name is the protocol's
* (`@objectstack/spec/data`), re-exported here so engine callers keep
* importing it from the engine; see the declaration there for why it lives in
* the spec.
*/
export const SEARCH_COMPANION_FIELD = '__search';
export { SEARCH_COMPANION_FIELD };

/**
* Normalizers applied to the companion column. Generalization seam: pinyin is
Expand Down
Loading
Loading