From 34ea765df894ad86808b242856aa101d55a5633f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 09:27:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20validate=5Fexpression=20tool=20?= =?UTF-8?q?=E2=80=94=20validate=20CEL=20against=20a=20schema=20before=20au?= =?UTF-8?q?thoring=20(#1928)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an agent-callable MCP tool that runs the same build-time expression checks as objectstack build, so an AI can validate a formula / predicate / flow condition while authoring instead of shipping one that silently evaluates to null. Given { objectName, expression, site? } it resolves the object's real schema (field names + types via the principal-bound describeObject bridge) and returns errors (bare refs, unknown fields/functions), warnings (text/boolean in arithmetic, date-equality), the fields/functions/roots in scope for self-correction, and an inferred type for formula sites. site (formula | validation | flow_condition | template, default formula) maps to the validator's role + scope. Read-only, data:read-gated, fail-closed on sys_*. mcp gains a @objectstack/formula dependency (acyclic; formula is a leaf). Tests: mcp 80 (+8 tool cases; updated the 3 tool-surface assertions + SKILL.md drift guard). Build/DTS clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Hnji7EEYR2mGt6pY53a8Bm --- .changeset/mcp-validate-expression-tool.md | 28 ++++ packages/mcp/README.md | 1 + packages/mcp/package.json | 1 + .../mcp/src/mcp-http-tools.scopes.test.ts | 2 +- packages/mcp/src/mcp-http-tools.ts | 84 ++++++++++++ .../mcp/src/mcp-server-runtime.http.test.ts | 1 + .../mcp/src/mcp-validate-expression.test.ts | 125 ++++++++++++++++++ packages/mcp/src/skill.ts | 6 + pnpm-lock.yaml | 3 + 9 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 .changeset/mcp-validate-expression-tool.md create mode 100644 packages/mcp/src/mcp-validate-expression.test.ts diff --git a/.changeset/mcp-validate-expression-tool.md b/.changeset/mcp-validate-expression-tool.md new file mode 100644 index 0000000000..6bda9ef262 --- /dev/null +++ b/.changeset/mcp-validate-expression-tool.md @@ -0,0 +1,28 @@ +--- +"@objectstack/mcp": minor +--- + +feat(mcp): `validate_expression` tool — validate a CEL expression against a schema before authoring (#1928) + +Adds an agent-callable MCP tool that runs the same build-time expression checks +as `objectstack build`, so an AI can validate a formula / predicate / flow +condition **while authoring** instead of shipping one that silently evaluates to +`null`. Given `{ objectName, expression, site? }` it resolves the object's real +schema (field names + types, via the principal-bound `describeObject` bridge) +and returns: + +- **errors** — bare field refs (`amount` → `record.amount`), unknown fields + (with a did-you-mean), unknown functions; +- **warnings** — text/boolean fields misused in arithmetic, date-equality + pitfalls; +- **inScope** — the fields, stdlib functions, and namespace roots available, so + the model can self-correct; +- **inferredType** for a `formula` site. + +`site` (`formula` | `validation` | `flow_condition` | `template`, default +`formula`) maps to the validator's role + scope — `flow_condition` binds fields +bare, the rest bind `record.`. Read-only, gated by the `data:read` OAuth +scope, and fail-closed on `sys_*` objects like the other schema tools. This is +the authoring-time surface the guardrail series (#1928) always pointed at; +`@objectstack/mcp` gains a `@objectstack/formula` dependency (acyclic; formula is +a leaf). diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 9dbf62d5fb..c868108f16 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -105,6 +105,7 @@ the open framework. // Object data (RLS-enforced as the caller) 'list_objects' // List objects (system sys_* objects hidden by default) 'describe_object' // Object schema: fields + features +'validate_expression' // Check a CEL expression against a schema before authoring it 'query_records' // Filter / sort / paginate 'get_record' // Fetch one by id 'create_record' / 'update_record' / 'delete_record' diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 6cdf7dbca4..287f82337f 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -20,6 +20,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@objectstack/core": "workspace:*", + "@objectstack/formula": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "zod": "^4.4.3" diff --git a/packages/mcp/src/mcp-http-tools.scopes.test.ts b/packages/mcp/src/mcp-http-tools.scopes.test.ts index 37ef1806da..4354955ec1 100644 --- a/packages/mcp/src/mcp-http-tools.scopes.test.ts +++ b/packages/mcp/src/mcp-http-tools.scopes.test.ts @@ -22,7 +22,7 @@ import { import { MCPServerRuntime } from './mcp-server-runtime.js'; import type { McpActionBridge, McpDataBridge } from './mcp-http-tools.js'; -const READ_TOOLS = ['list_objects', 'describe_object', 'query_records', 'get_record', 'aggregate_records']; +const READ_TOOLS = ['list_objects', 'describe_object', 'validate_expression', 'query_records', 'get_record', 'aggregate_records']; const WRITE_TOOLS = ['create_record', 'update_record', 'delete_record']; const ACTION_TOOLS = ['list_actions', 'run_action']; diff --git a/packages/mcp/src/mcp-http-tools.ts b/packages/mcp/src/mcp-http-tools.ts index 63b3267a35..3bcf222590 100644 --- a/packages/mcp/src/mcp-http-tools.ts +++ b/packages/mcp/src/mcp-http-tools.ts @@ -27,6 +27,12 @@ import { MCP_OAUTH_SCOPE_DATA_WRITE, MCP_OAUTH_SCOPE_ACTIONS, } from '@objectstack/spec/ai'; +import { + validateExpression, + introspectScope, + inferExpressionType, + type FieldRole, +} from '@objectstack/formula'; export interface McpObjectSummary { name: string; @@ -189,6 +195,21 @@ function jsonText(value: unknown): string { } } +/** + * Authoring site → the `(role, scope)` the shared validator uses. A `formula` + * field is a `value` expression bound to the `record` namespace; a `validation` + * rule is a `record`-scoped `predicate`; a `flow_condition` is a `predicate` + * whose fields are flattened to top level; a `template` is a text template. + * Exposing a single friendly `site` keeps the tool aligned with how an author + * thinks about *where* the expression goes. + */ +const VALIDATE_SITE_MAP: Record = { + formula: { role: 'value', scope: 'record' }, + validation: { role: 'predicate', scope: 'record' }, + flow_condition: { role: 'predicate', scope: 'flattened' }, + template: { role: 'template', scope: 'record' }, +}; + /** * Register the object-CRUD tool set on a fresh per-request {@link McpServer}. * All execution is delegated to `bridge`, which is bound to the caller's @@ -258,6 +279,69 @@ export function registerObjectTools( }, ); + // Validate a CEL expression against a real object schema BEFORE it is + // authored into metadata — the same checks `objectstack build` runs, so an + // agent gets a build-accurate verdict plus the fields/functions in scope to + // self-correct, instead of shipping a formula that silently evaluates to + // `null` (#1928). Read-only (schema introspection); no data is touched. + server.registerTool( + 'validate_expression', + { + description: + 'Validate a CEL expression against an object\'s schema before authoring it into metadata. Returns ' + + 'build-time errors (bare field refs, unknown fields, unknown functions) and advisory warnings ' + + '(text/boolean fields misused in arithmetic, date-equality pitfalls), plus the fields and stdlib ' + + 'functions in scope so you can self-correct. `site` says where the expression will live: a `formula` ' + + 'field, a `validation`/predicate, or a `flow_condition` (fields are bound bare in flow conditions).', + inputSchema: { + objectName: z.string().describe('The object/table the expression is authored against, e.g. "task"'), + expression: z.string().describe('The CEL expression to validate, e.g. "record.amount / 100"'), + site: z + .enum(['formula', 'validation', 'flow_condition', 'template']) + .optional() + .describe( + 'Where the expression will live. formula/validation bind `record.`; flow_condition binds fields bare. Default: formula.', + ), + }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, + async ({ objectName, expression, site }) => { + const bad = guard(objectName); + if (bad) return errorResult(bad); + try { + const def = (await bridge.describeObject(objectName)) as { fields?: unknown } | null; + if (!def) return errorResult(`Object "${objectName}" not found`); + const fieldDefs = Array.isArray(def.fields) ? (def.fields as Array>) : []; + const fields: string[] = []; + const fieldTypes: Record = {}; + for (const f of fieldDefs) { + if (typeof f?.name !== 'string') continue; + fields.push(f.name); + if (typeof f?.type === 'string') fieldTypes[f.name] = f.type; + } + const { role, scope } = VALIDATE_SITE_MAP[site ?? 'formula']; + const hint = { objectName, fields, fieldTypes, scope } as const; + const result = validateExpression(role, expression, hint); + const inScope = introspectScope(role, hint); + const inferredType = role === 'value' ? inferExpressionType(expression, hint) : undefined; + return textResult({ + ok: result.ok, + errors: result.errors, + warnings: result.warnings, + ...(inferredType ? { inferredType } : {}), + inScope: { + dialect: inScope.dialect, + roots: inScope.roots, + fields: inScope.fields, + functions: inScope.functions, + }, + }); + } catch (err) { + return errorResult(messageOf(err)); + } + }, + ); + server.registerTool( 'query_records', { diff --git a/packages/mcp/src/mcp-server-runtime.http.test.ts b/packages/mcp/src/mcp-server-runtime.http.test.ts index 45a7f34bc9..3a1a4ab2a6 100644 --- a/packages/mcp/src/mcp-server-runtime.http.test.ts +++ b/packages/mcp/src/mcp-server-runtime.http.test.ts @@ -97,6 +97,7 @@ describe('MCPServerRuntime.handleHttpRequest (Streamable HTTP)', () => { 'list_objects', 'query_records', 'update_record', + 'validate_expression', ].sort(), ); }); diff --git a/packages/mcp/src/mcp-validate-expression.test.ts b/packages/mcp/src/mcp-validate-expression.test.ts new file mode 100644 index 0000000000..b7443bacec --- /dev/null +++ b/packages/mcp/src/mcp-validate-expression.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { MCPServerRuntime } from './mcp-server-runtime.js'; +import type { McpDataBridge } from './mcp-http-tools.js'; + +/** + * A data bridge whose `describeObject` returns a realistic schema (field names + * AND types), so the `validate_expression` tool can run the full build-time + * check — including the #1928 type-soundness warning that needs field types. + */ +function makeBridge(): McpDataBridge { + return { + async listObjects() { + return [{ name: 'crm_opportunity', label: 'Opportunity', fieldCount: 4 }]; + }, + async describeObject(name: string) { + if (name !== 'crm_opportunity') return null; + return { + name: 'crm_opportunity', + label: 'Opportunity', + fields: [ + { name: 'amount', type: 'currency', label: 'Amount', required: false }, + { name: 'probability', type: 'percent', label: 'Probability', required: false }, + { name: 'title', type: 'text', label: 'Title', required: true }, + { name: 'is_active', type: 'boolean', label: 'Active', required: false }, + { name: 'stage', type: 'select', label: 'Stage', required: false }, + ], + }; + }, + async query() { return { records: [] }; }, + async get() { return null; }, + async create() { return {}; }, + async update() { return {}; }, + async remove() { return {}; }, + }; +} + +function mcpRequest(body: unknown): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(body), + }); +} + +async function call(runtime: MCPServerRuntime, body: unknown, bridge?: unknown) { + const res = await runtime.handleHttpRequest(mcpRequest(body), { bridge, parsedBody: body } as never); + const json = res.status === 202 ? null : await res.json(); + return { status: res.status, json }; +} + +const toolsCall = (id: number, name: string, args: Record) => ({ + jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args }, +}); + +async function validate(runtime: MCPServerRuntime, bridge: McpDataBridge, args: Record) { + const { json } = await call(runtime, toolsCall(1, 'validate_expression', args), bridge); + return JSON.parse(json.result.content[0].text); +} + +describe('validate_expression MCP tool (#1928)', () => { + let runtime: MCPServerRuntime; + let bridge: McpDataBridge; + + beforeEach(() => { + runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); + bridge = makeBridge(); + }); + + it('registers as a read-only tool', async () => { + const { json } = await call(runtime, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, bridge); + const byName = Object.fromEntries(json.result.tools.map((t: any) => [t.name, t])); + expect(byName.validate_expression).toBeDefined(); + expect(byName.validate_expression.annotations.readOnlyHint).toBe(true); + }); + + it('accepts a sound formula and reports its inferred type + in-scope context', async () => { + const r = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'record.amount / 100', site: 'formula' }); + expect(r.ok).toBe(true); + expect(r.errors).toHaveLength(0); + expect(r.warnings).toHaveLength(0); // currency → dyn, so int-literal arithmetic is fine + expect(r.inScope.fields).toContain('amount'); + expect(r.inScope.functions).toContain('daysBetween'); + }); + + it('flags a bare field reference in a record-scoped formula (error)', async () => { + const r = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'amount > 100', site: 'formula' }); + expect(r.ok).toBe(false); + expect(r.errors[0].message).toMatch(/bare reference `amount`|record\.amount/); + }); + + it('flags an unknown field with a did-you-mean (error)', async () => { + const r = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'record.amont > 100', site: 'validation' }); + expect(r.ok).toBe(false); + expect(r.errors[0].message).toMatch(/unknown field `amont`/); + }); + + it('warns (does not error) on a text field misused in arithmetic — tier 4', async () => { + const r = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'record.title * 2', site: 'formula' }); + expect(r.ok).toBe(true); + expect(r.warnings.some((w: any) => /type mismatch/i.test(w.message))).toBe(true); + }); + + it('validates a flattened flow condition (bare fields correct; type-soundness still applies)', async () => { + const ok = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'stage == "won" && amount > 1000', site: 'flow_condition' }); + expect(ok.ok).toBe(true); + expect(ok.warnings).toHaveLength(0); + const warn = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'title * 2 > 10', site: 'flow_condition' }); + expect(warn.warnings.some((w: any) => /type mismatch/i.test(w.message))).toBe(true); + }); + + it('errors clearly when the object does not exist', async () => { + const { json } = await call(runtime, toolsCall(1, 'validate_expression', { objectName: 'nope', expression: 'record.x > 1' }), bridge); + expect(json.result.isError).toBe(true); + expect(json.result.content[0].text).toMatch(/not found/i); + }); + + it('refuses system objects (fail-closed guard)', async () => { + const { json } = await call(runtime, toolsCall(1, 'validate_expression', { objectName: 'sys_user', expression: 'record.x > 1' }), bridge); + expect(json.result.isError).toBe(true); + expect(json.result.content[0].text).toMatch(/system object/i); + }); +}); diff --git a/packages/mcp/src/skill.ts b/packages/mcp/src/skill.ts index 6e0f4f6f46..bff06cc8e6 100644 --- a/packages/mcp/src/skill.ts +++ b/packages/mcp/src/skill.ts @@ -129,6 +129,12 @@ create/update payload. - **list_objects()** — list available objects (system \`sys_*\` objects are hidden). - **describe_object({ objectName })** — an object's fields and features. +- **validate_expression({ objectName, expression, site? })** — check a CEL + expression against an object's schema *before* you author it into a formula, + validation, or flow condition. Returns build-time errors (bare field refs, + unknown fields/functions) and advisory warnings (text/boolean fields misused + in arithmetic), plus the fields and stdlib functions in scope. \`site\` is + \`formula\` | \`validation\` | \`flow_condition\` | \`template\` (default \`formula\`). - **query_records({ objectName, where?, fields?, limit?, offset?, orderBy? })** — read records. \`where\` is a field→value match, e.g. \`{ "status": "open" }\`. Results are page-capped; use \`limit\`/\`offset\` to page. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 467c4940d2..e84b379c87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -822,6 +822,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../core + '@objectstack/formula': + specifier: workspace:* + version: link:../formula '@objectstack/spec': specifier: workspace:* version: link:../spec