-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathmcp-validate-expression.test.ts
More file actions
125 lines (108 loc) · 5.66 KB
/
Copy pathmcp-validate-expression.test.ts
File metadata and controls
125 lines (108 loc) · 5.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
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);
});
});