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
28 changes: 28 additions & 0 deletions .changeset/mcp-validate-expression-tool.md
Original file line number Diff line number Diff line change
@@ -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.<field>`. 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).
1 change: 1 addition & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/src/mcp-http-tools.scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
84 changes: 84 additions & 0 deletions packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, { role: FieldRole; scope: 'record' | 'flattened' }> = {
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
Expand Down Expand Up @@ -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.<field>`; 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<Record<string, unknown>>) : [];
const fields: string[] = [];
const fieldTypes: Record<string, string> = {};
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',
{
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/mcp-server-runtime.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe('MCPServerRuntime.handleHttpRequest (Streamable HTTP)', () => {
'list_objects',
'query_records',
'update_record',
'validate_expression',
].sort(),
);
});
Expand Down
125 changes: 125 additions & 0 deletions packages/mcp/src/mcp-validate-expression.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => ({
jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args },
});

async function validate(runtime: MCPServerRuntime, bridge: McpDataBridge, args: Record<string, unknown>) {
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);
});
});
6 changes: 6 additions & 0 deletions packages/mcp/src/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.