|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, it, expect, beforeEach } from 'vitest'; |
| 4 | + |
| 5 | +import { MCPServerRuntime } from './mcp-server-runtime.js'; |
| 6 | +import type { McpDataBridge } from './mcp-http-tools.js'; |
| 7 | + |
| 8 | +/** |
| 9 | + * A data bridge whose `describeObject` returns a realistic schema (field names |
| 10 | + * AND types), so the `validate_expression` tool can run the full build-time |
| 11 | + * check — including the #1928 type-soundness warning that needs field types. |
| 12 | + */ |
| 13 | +function makeBridge(): McpDataBridge { |
| 14 | + return { |
| 15 | + async listObjects() { |
| 16 | + return [{ name: 'crm_opportunity', label: 'Opportunity', fieldCount: 4 }]; |
| 17 | + }, |
| 18 | + async describeObject(name: string) { |
| 19 | + if (name !== 'crm_opportunity') return null; |
| 20 | + return { |
| 21 | + name: 'crm_opportunity', |
| 22 | + label: 'Opportunity', |
| 23 | + fields: [ |
| 24 | + { name: 'amount', type: 'currency', label: 'Amount', required: false }, |
| 25 | + { name: 'probability', type: 'percent', label: 'Probability', required: false }, |
| 26 | + { name: 'title', type: 'text', label: 'Title', required: true }, |
| 27 | + { name: 'is_active', type: 'boolean', label: 'Active', required: false }, |
| 28 | + { name: 'stage', type: 'select', label: 'Stage', required: false }, |
| 29 | + ], |
| 30 | + }; |
| 31 | + }, |
| 32 | + async query() { return { records: [] }; }, |
| 33 | + async get() { return null; }, |
| 34 | + async create() { return {}; }, |
| 35 | + async update() { return {}; }, |
| 36 | + async remove() { return {}; }, |
| 37 | + }; |
| 38 | +} |
| 39 | + |
| 40 | +function mcpRequest(body: unknown): Request { |
| 41 | + return new Request('http://localhost/mcp', { |
| 42 | + method: 'POST', |
| 43 | + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, |
| 44 | + body: JSON.stringify(body), |
| 45 | + }); |
| 46 | +} |
| 47 | + |
| 48 | +async function call(runtime: MCPServerRuntime, body: unknown, bridge?: unknown) { |
| 49 | + const res = await runtime.handleHttpRequest(mcpRequest(body), { bridge, parsedBody: body } as never); |
| 50 | + const json = res.status === 202 ? null : await res.json(); |
| 51 | + return { status: res.status, json }; |
| 52 | +} |
| 53 | + |
| 54 | +const toolsCall = (id: number, name: string, args: Record<string, unknown>) => ({ |
| 55 | + jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args }, |
| 56 | +}); |
| 57 | + |
| 58 | +async function validate(runtime: MCPServerRuntime, bridge: McpDataBridge, args: Record<string, unknown>) { |
| 59 | + const { json } = await call(runtime, toolsCall(1, 'validate_expression', args), bridge); |
| 60 | + return JSON.parse(json.result.content[0].text); |
| 61 | +} |
| 62 | + |
| 63 | +describe('validate_expression MCP tool (#1928)', () => { |
| 64 | + let runtime: MCPServerRuntime; |
| 65 | + let bridge: McpDataBridge; |
| 66 | + |
| 67 | + beforeEach(() => { |
| 68 | + runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' }); |
| 69 | + bridge = makeBridge(); |
| 70 | + }); |
| 71 | + |
| 72 | + it('registers as a read-only tool', async () => { |
| 73 | + const { json } = await call(runtime, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, bridge); |
| 74 | + const byName = Object.fromEntries(json.result.tools.map((t: any) => [t.name, t])); |
| 75 | + expect(byName.validate_expression).toBeDefined(); |
| 76 | + expect(byName.validate_expression.annotations.readOnlyHint).toBe(true); |
| 77 | + }); |
| 78 | + |
| 79 | + it('accepts a sound formula and reports its inferred type + in-scope context', async () => { |
| 80 | + const r = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'record.amount / 100', site: 'formula' }); |
| 81 | + expect(r.ok).toBe(true); |
| 82 | + expect(r.errors).toHaveLength(0); |
| 83 | + expect(r.warnings).toHaveLength(0); // currency → dyn, so int-literal arithmetic is fine |
| 84 | + expect(r.inScope.fields).toContain('amount'); |
| 85 | + expect(r.inScope.functions).toContain('daysBetween'); |
| 86 | + }); |
| 87 | + |
| 88 | + it('flags a bare field reference in a record-scoped formula (error)', async () => { |
| 89 | + const r = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'amount > 100', site: 'formula' }); |
| 90 | + expect(r.ok).toBe(false); |
| 91 | + expect(r.errors[0].message).toMatch(/bare reference `amount`|record\.amount/); |
| 92 | + }); |
| 93 | + |
| 94 | + it('flags an unknown field with a did-you-mean (error)', async () => { |
| 95 | + const r = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'record.amont > 100', site: 'validation' }); |
| 96 | + expect(r.ok).toBe(false); |
| 97 | + expect(r.errors[0].message).toMatch(/unknown field `amont`/); |
| 98 | + }); |
| 99 | + |
| 100 | + it('warns (does not error) on a text field misused in arithmetic — tier 4', async () => { |
| 101 | + const r = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'record.title * 2', site: 'formula' }); |
| 102 | + expect(r.ok).toBe(true); |
| 103 | + expect(r.warnings.some((w: any) => /type mismatch/i.test(w.message))).toBe(true); |
| 104 | + }); |
| 105 | + |
| 106 | + it('validates a flattened flow condition (bare fields correct; type-soundness still applies)', async () => { |
| 107 | + const ok = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'stage == "won" && amount > 1000', site: 'flow_condition' }); |
| 108 | + expect(ok.ok).toBe(true); |
| 109 | + expect(ok.warnings).toHaveLength(0); |
| 110 | + const warn = await validate(runtime, bridge, { objectName: 'crm_opportunity', expression: 'title * 2 > 10', site: 'flow_condition' }); |
| 111 | + expect(warn.warnings.some((w: any) => /type mismatch/i.test(w.message))).toBe(true); |
| 112 | + }); |
| 113 | + |
| 114 | + it('errors clearly when the object does not exist', async () => { |
| 115 | + const { json } = await call(runtime, toolsCall(1, 'validate_expression', { objectName: 'nope', expression: 'record.x > 1' }), bridge); |
| 116 | + expect(json.result.isError).toBe(true); |
| 117 | + expect(json.result.content[0].text).toMatch(/not found/i); |
| 118 | + }); |
| 119 | + |
| 120 | + it('refuses system objects (fail-closed guard)', async () => { |
| 121 | + const { json } = await call(runtime, toolsCall(1, 'validate_expression', { objectName: 'sys_user', expression: 'record.x > 1' }), bridge); |
| 122 | + expect(json.result.isError).toBe(true); |
| 123 | + expect(json.result.content[0].text).toMatch(/system object/i); |
| 124 | + }); |
| 125 | +}); |
0 commit comments