Skip to content

Commit 3dc9fce

Browse files
os-zhuangclaude
andauthored
feat(mcp): aggregate_records MCP tool over the engine read path (#2976)
* feat(mcp,runtime,plugin-security): aggregate_records MCP tool over the engine read path Adds GROUP BY aggregation to the MCP surface so agents can answer quantitative questions in one call instead of paging raw rows through query_records (capped + token-expensive). - mcp: new data:read tool aggregate_records (count/sum/avg/min/max/ count_distinct, groupBy incl. {field,dateGranularity} date bucketing, where, timezone). Optional McpDataBridge.aggregate seam — runtimes that omit it simply don't register the tool. Output rows capped at the query limit with a truncated flag. - runtime: callData('aggregate') branch resolves the ObjectQL ENGINE (never the raw per-env dataDriver) so the security middleware — RLS, tenant scoping, ADR-0090 D10 delegator intersection — always runs; requires >=1 aggregation (the engine's in-memory path would otherwise degrade to raw rows the FLS masker does not cover). aggregate maps to the 'list' ApiMethod in the exposure gate. - plugin-security: FLS aggregate-INPUT gate (2.5b) — result masking never runs for aggregate and aliases hide field provenance, so groupBy / aggregation references to FLS-unreadable fields are rejected fail-closed with the offending names, including the D10 delegator field-mask intersection. Tests: mcp 72 passed (6 new: registration/degradation, delegation, sys_* guard, min(1) schema, truncation, FLS error surface); plugin- security 426 passed (3 new FLS aggregate gate cases); runtime 507 passed (new aggregate exposure-mapping case). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012RE5oFswe4DUvExuAgvbg2 * docs(ai): open MCP surface now ships server-side aggregation (aggregate_records) natural-language-queries.mdx stated the open MCP surface has no server-side aggregate tool and a BYO agent must sum client-side — the aggregate_records tool added in this branch makes that false. Update the tool enumeration + the aggregation callout, and keep the distinction from the cloud dataset-level aggregate_data tool (cross-object / cubes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012RE5oFswe4DUvExuAgvbg2 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7d5291 commit 3dc9fce

11 files changed

Lines changed: 492 additions & 12 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/mcp': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/plugin-security': patch
5+
---
6+
7+
feat(mcp): `aggregate_records` tool — GROUP BY aggregation over the engine read path
8+
9+
New MCP tool `aggregate_records` (count/sum/avg/min/max/count_distinct, optional
10+
groupBy incl. date bucketing, where filter, IANA timezone) in the `data:read`
11+
family. Execution routes through the ObjectQL ENGINE (`callData('aggregate')`
12+
deliberately never uses the raw per-env driver), so RLS/tenant scoping and the
13+
D10 delegator intersection apply exactly as on find.
14+
15+
Security hardening shipped with it:
16+
17+
- plugin-security: new FLS aggregate-INPUT gate — result masking never runs for
18+
`aggregate` (output rows carry only aliases), so any groupBy / aggregation
19+
reference to an FLS-unreadable field is now rejected fail-closed with the
20+
offending field names (mirrors the FLS write gate).
21+
- runtime: `aggregate` maps to the `list` ApiMethod in the object exposure gate
22+
(an object whose `apiMethods` whitelist excludes `list` cannot leak row
23+
statistics through GROUP BY), and the aggregate action requires at least one
24+
aggregation (the engine's in-memory path would otherwise degrade to raw rows
25+
that the FLS masker does not cover).
26+
27+
The bridge seam is optional: a runtime that does not implement
28+
`McpDataBridge.aggregate` simply does not register the tool (graceful
29+
degradation, same contract as the action tools).

content/docs/ai/natural-language-queries.mdx

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Natural Language Queries
3-
description: How agents query live data through the open MCP data tools (query_records, get_record) under RLS
3+
description: How agents query live data through the open MCP data tools (query_records, get_record, aggregate_records) under RLS
44
---
55

66
# Natural Language Queries
@@ -15,19 +15,23 @@ back this are part of the open framework — no ObjectOS runtime and no
1515
cloud Studio are required.
1616

1717
Your AI queries your data through the open MCP **data tools**`query_records`
18-
(filter, field selection, sort, pagination) and `get_record`, plus the
19-
discovery tools `list_objects` and `describe_object` — which the model calls
20-
with structured arguments. These run as ordinary [ObjectQL](/docs/protocol/objectql) queries over
18+
(filter, field selection, sort, pagination), `get_record`, and `aggregate_records`
19+
(group-by count/sum/avg/min/max/count_distinct, with optional date bucketing),
20+
plus the discovery tools `list_objects` and `describe_object` — which the model
21+
calls with structured arguments. These run as ordinary [ObjectQL](/docs/protocol/objectql) queries over
2122
your objects (ObjectStack uses ObjectQL, not SOQL), and they execute under the
2223
caller's `ExecutionContext`, so row-level security applies exactly as it does
2324
for the REST API.
2425

2526
<Callout type="info">
26-
**Aggregation is a cloud data tool.** The open MCP surface reads and filters
27-
rows; it does not ship a server-side `aggregate_data` (group-by / roll-up)
28-
tool — `query_records` has no aggregation arguments. A BYO agent aggregates by
29-
fetching the rows it's allowed to see and summing client-side; the built-in
30-
`aggregate_data` tool is part of the cloud in-product chat runtime (below).
27+
**Server-side aggregation is open too.** `aggregate_records` runs a single-object
28+
GROUP BY (count/sum/avg/min/max/count_distinct, optional date bucketing and
29+
filter) through the same ObjectQL engine read path — so totals and breakdowns
30+
come back in one call under row-level **and** field-level security, instead of
31+
the agent paging every visible row and summing client-side. It does not join
32+
across objects or read datasets/cubes: the cross-object, dataset-level
33+
`aggregate_data` roll-up tool remains part of the cloud in-product chat runtime
34+
(below).
3135
</Callout>
3236

3337
The open MCP server plugin exposes these tools automatically — it bridges the
@@ -45,7 +49,7 @@ await kernel.bootstrap();
4549

4650
Point any MCP client at the server and ask questions in natural language: the
4751
model discovers `list_objects` / `describe_object` / `query_records` /
48-
`get_record` and calls them under RLS as the authenticated caller.
52+
`get_record` / `aggregate_records` and calls them under RLS as the authenticated caller.
4953

5054
There is no separate natural-language-to-query metadata type to author — the
5155
model is prompted with the available objects and translates the user's question
@@ -55,7 +59,7 @@ into `query_records` calls at runtime.
5559
**ObjectOS — bundled in-product chat.** ObjectOS ships an in-UI
5660
AI runtime — the `ask` data-query assistant
5761
and the `/api/v1/ai/*` chat endpoints — that registers these same data tools,
58-
plus the group-by/roll-up `aggregate_data` tool, into its own chat loop. That
62+
plus the dataset-level (cross-object) `aggregate_data` roll-up tool, into its own chat loop. That
5963
runtime is not part of the open framework and is documented separately in the
6064
[ObjectOS docs](https://docs.objectos.app/docs/ai); this page covers the open path
6165
only. On the open-source framework, use

packages/mcp/src/mcp-http-tools.scopes.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
import { MCPServerRuntime } from './mcp-server-runtime.js';
2323
import type { McpActionBridge, McpDataBridge } from './mcp-http-tools.js';
2424

25-
const READ_TOOLS = ['list_objects', 'describe_object', 'query_records', 'get_record'];
25+
const READ_TOOLS = ['list_objects', 'describe_object', 'query_records', 'get_record', 'aggregate_records'];
2626
const WRITE_TOOLS = ['create_record', 'update_record', 'delete_record'];
2727
const ACTION_TOOLS = ['list_actions', 'run_action'];
2828

@@ -34,6 +34,7 @@ function makeBridge(): McpDataBridge & McpActionBridge & { calls: any[] } {
3434
async describeObject(name: string) { calls.push(['describeObject', name]); return { name }; },
3535
async query(object: string, opts: any) { calls.push(['query', object, opts]); return { object, records: [] }; },
3636
async get(object: string, id: string) { calls.push(['get', object, id]); return { id }; },
37+
async aggregate(object: string, opts: any) { calls.push(['aggregate', object, opts]); return []; },
3738
async create(object: string, data: any) { calls.push(['create', object, data]); return { object, id: 'n1' }; },
3839
async update(object: string, id: string, data: any) { calls.push(['update', object, id, data]); return { object, id }; },
3940
async remove(object: string, id: string) { calls.push(['remove', object, id]); return { object, id, deleted: true }; },

packages/mcp/src/mcp-http-tools.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,22 @@ export interface McpDataBridge {
5656
create(object: string, data: Record<string, unknown>): Promise<unknown>;
5757
update(object: string, id: string, data: Record<string, unknown>): Promise<unknown>;
5858
remove(object: string, id: string): Promise<unknown>;
59+
/**
60+
* GROUP BY aggregation through the ObjectQL engine's read path (RLS + the
61+
* FLS aggregate-input gate). OPTIONAL: a runtime that cannot route
62+
* aggregation through the engine simply omits it and the
63+
* `aggregate_records` tool is not registered (graceful degradation, same
64+
* contract as {@link McpActionBridge}).
65+
*/
66+
aggregate?(
67+
object: string,
68+
opts: {
69+
where?: Record<string, unknown>;
70+
groupBy?: Array<string | { field: string; dateGranularity?: string; alias?: string }>;
71+
aggregations: Array<{ function: string; field?: string; alias: string; distinct?: boolean }>;
72+
timezone?: string;
73+
},
74+
): Promise<unknown[]>;
5975
}
6076

6177
export interface RegisterObjectToolsOptions {
@@ -282,6 +298,82 @@ export function registerObjectTools(
282298
},
283299
);
284300

301+
if (typeof bridge.aggregate === 'function') {
302+
const aggregateFn = bridge.aggregate.bind(bridge);
303+
server.registerTool(
304+
'aggregate_records',
305+
{
306+
description:
307+
'Aggregate records with GROUP BY: count/sum/avg/min/max/count_distinct over an object, ' +
308+
'optionally grouped by fields (dates can be bucketed by day/week/month/quarter/year). ' +
309+
'Use this instead of paging query_records when a question needs totals or breakdowns. ' +
310+
'Runs under the caller\'s permissions, row-level security and field-level security.',
311+
inputSchema: {
312+
objectName: z.string().describe('The object/table name'),
313+
aggregations: z
314+
.array(
315+
z.object({
316+
function: z
317+
.enum(['count', 'sum', 'avg', 'min', 'max', 'count_distinct'])
318+
.describe('Aggregation function'),
319+
field: z.string().optional().describe('Field to aggregate (omit for count(*))'),
320+
alias: z.string().describe('Result column name'),
321+
}),
322+
)
323+
.min(1)
324+
.describe('Metrics to compute, e.g. [{"function":"sum","field":"amount","alias":"total"}]'),
325+
groupBy: z
326+
.array(
327+
z.union([
328+
z.string(),
329+
z.object({
330+
field: z.string().describe('Field to group by'),
331+
dateGranularity: z
332+
.enum(['day', 'week', 'month', 'quarter', 'year'])
333+
.optional()
334+
.describe('Bucket a date field into uniform periods'),
335+
alias: z.string().optional().describe('Alias for the projected group value'),
336+
}),
337+
]),
338+
)
339+
.optional()
340+
.describe('Grouping fields; omit for a single overall row'),
341+
where: z
342+
.record(z.string(), z.unknown())
343+
.optional()
344+
.describe('Filter conditions applied before aggregation, e.g. {"status":"open"}'),
345+
timezone: z
346+
.string()
347+
.optional()
348+
.describe('IANA timezone for date bucketing (defaults to UTC)'),
349+
},
350+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
351+
},
352+
async ({ objectName, aggregations, groupBy, where, timezone }) => {
353+
const bad = guard(objectName);
354+
if (bad) return errorResult(bad);
355+
try {
356+
const rows = (await aggregateFn(objectName, {
357+
where,
358+
groupBy,
359+
aggregations,
360+
timezone,
361+
})) ?? [];
362+
// Group count is unbounded (a high-cardinality groupBy can return
363+
// thousands of rows) — cap the tool output like query_records does.
364+
const truncated = rows.length > maxLimit;
365+
return textResult({
366+
rows: truncated ? rows.slice(0, maxLimit) : rows,
367+
totalGroups: rows.length,
368+
...(truncated ? { truncated: true } : {}),
369+
});
370+
} catch (err) {
371+
return errorResult(messageOf(err));
372+
}
373+
},
374+
);
375+
}
376+
285377
server.registerTool(
286378
'get_record',
287379
{

packages/mcp/src/mcp-server-runtime.http.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,164 @@ describe('MCPServerRuntime.handleHttpRequest (Streamable HTTP)', () => {
223223
expect(json.result.content[0].text).toContain('RLS: not permitted');
224224
});
225225
});
226+
227+
describe('aggregate_records (GROUP BY aggregation tool)', () => {
228+
let runtime: MCPServerRuntime;
229+
230+
/** Bridge WITH the optional aggregate seam implemented. */
231+
function makeAggBridge(rows: unknown[] = [{ status: 'open', n: 2 }]) {
232+
const base = makeBridge();
233+
const calls = base.calls;
234+
return {
235+
...base,
236+
calls,
237+
async aggregate(object: string, opts: any) {
238+
calls.push(['aggregate', object, opts]);
239+
return rows;
240+
},
241+
};
242+
}
243+
244+
beforeEach(() => {
245+
runtime = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' });
246+
});
247+
248+
it('registers only when the bridge implements aggregate (graceful degradation)', async () => {
249+
const without = await call(runtime, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, makeBridge());
250+
expect(without.json.result.tools.map((t: any) => t.name)).not.toContain('aggregate_records');
251+
252+
const runtime2 = new MCPServerRuntime({ name: 'objectstack-test', version: '9.9.9' });
253+
const withAgg = await call(runtime2, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, makeAggBridge());
254+
const byName = Object.fromEntries(withAgg.json.result.tools.map((t: any) => [t.name, t]));
255+
expect(byName.aggregate_records).toBeDefined();
256+
expect(byName.aggregate_records.annotations.readOnlyHint).toBe(true);
257+
});
258+
259+
it('delegates groupBy/aggregations/where to the bridge and returns rows', async () => {
260+
const bridge = makeAggBridge([{ status: 'open', n: 2 }, { status: 'done', n: 5 }]);
261+
const { json } = await call(
262+
runtime,
263+
{
264+
jsonrpc: '2.0',
265+
id: 2,
266+
method: 'tools/call',
267+
params: {
268+
name: 'aggregate_records',
269+
arguments: {
270+
objectName: 'task',
271+
groupBy: ['status', { field: 'created_at', dateGranularity: 'month' }],
272+
aggregations: [{ function: 'count', alias: 'n' }],
273+
where: { active: true },
274+
},
275+
},
276+
},
277+
bridge,
278+
);
279+
expect(json.result.isError).toBeFalsy();
280+
const payload = JSON.parse(json.result.content[0].text);
281+
expect(payload.rows).toHaveLength(2);
282+
expect(payload.totalGroups).toBe(2);
283+
expect(payload.truncated).toBeUndefined();
284+
const aggCall = bridge.calls.find((c: any[]) => c[0] === 'aggregate');
285+
expect(aggCall[1]).toBe('task');
286+
expect(aggCall[2].groupBy).toEqual(['status', { field: 'created_at', dateGranularity: 'month' }]);
287+
expect(aggCall[2].aggregations).toEqual([{ function: 'count', alias: 'n' }]);
288+
expect(aggCall[2].where).toEqual({ active: true });
289+
});
290+
291+
it('rejects system objects fail-closed without consulting the bridge', async () => {
292+
const bridge = makeAggBridge();
293+
const { json } = await call(
294+
runtime,
295+
{
296+
jsonrpc: '2.0',
297+
id: 3,
298+
method: 'tools/call',
299+
params: {
300+
name: 'aggregate_records',
301+
arguments: {
302+
objectName: 'sys_user',
303+
aggregations: [{ function: 'count', alias: 'n' }],
304+
},
305+
},
306+
},
307+
bridge,
308+
);
309+
expect(json.result.isError).toBe(true);
310+
expect(json.result.content[0].text).toMatch(/system object/i);
311+
expect(bridge.calls.find((c: any[]) => c[0] === 'aggregate')).toBeUndefined();
312+
});
313+
314+
it('rejects an empty aggregations array (schema min(1))', async () => {
315+
const bridge = makeAggBridge();
316+
const { json } = await call(
317+
runtime,
318+
{
319+
jsonrpc: '2.0',
320+
id: 4,
321+
method: 'tools/call',
322+
params: {
323+
name: 'aggregate_records',
324+
arguments: { objectName: 'task', aggregations: [] },
325+
},
326+
},
327+
bridge,
328+
);
329+
// Zod input validation fails before the handler runs — surfaced as a
330+
// tool error or JSON-RPC error depending on SDK version; both are fine,
331+
// the invariant is that the bridge is never reached.
332+
expect(json.result?.isError === true || json.error != null).toBe(true);
333+
expect(bridge.calls.find((c: any[]) => c[0] === 'aggregate')).toBeUndefined();
334+
});
335+
336+
it('caps the returned group rows at the query limit with a truncated flag', async () => {
337+
const manyGroups = Array.from({ length: 60 }, (_, i) => ({ k: `g${i}`, n: i }));
338+
const bridge = makeAggBridge(manyGroups);
339+
const { json } = await call(
340+
runtime,
341+
{
342+
jsonrpc: '2.0',
343+
id: 5,
344+
method: 'tools/call',
345+
params: {
346+
name: 'aggregate_records',
347+
arguments: {
348+
objectName: 'task',
349+
groupBy: ['k'],
350+
aggregations: [{ function: 'count', alias: 'n' }],
351+
},
352+
},
353+
},
354+
bridge,
355+
);
356+
const payload = JSON.parse(json.result.content[0].text);
357+
expect(payload.rows).toHaveLength(50); // DEFAULT_MAX_LIMIT
358+
expect(payload.totalGroups).toBe(60);
359+
expect(payload.truncated).toBe(true);
360+
});
361+
362+
it('surfaces bridge errors (e.g. FLS denial) as tool errors', async () => {
363+
const bridge = makeAggBridge();
364+
bridge.aggregate = async () => {
365+
throw new Error('[Security] Field read denied: not permitted to aggregate [salary]');
366+
};
367+
const { json } = await call(
368+
runtime,
369+
{
370+
jsonrpc: '2.0',
371+
id: 6,
372+
method: 'tools/call',
373+
params: {
374+
name: 'aggregate_records',
375+
arguments: {
376+
objectName: 'employee',
377+
aggregations: [{ function: 'sum', field: 'salary', alias: 'total' }],
378+
},
379+
},
380+
},
381+
bridge,
382+
);
383+
expect(json.result.isError).toBe(true);
384+
expect(json.result.content[0].text).toContain('Field read denied');
385+
});
386+
});

packages/mcp/src/mcp-server-runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const READ_ONLY_TOOLS = new Set([
5151
'describe_object',
5252
'query_records',
5353
'get_record',
54+
'aggregate_records',
5455
'aggregate_data',
5556
]);
5657

0 commit comments

Comments
 (0)