Skip to content

Commit bdb1840

Browse files
alban bertoliniclaude
andcommitted
refactor(mcp-server): rename listRelated tool and add enableCount support
- Rename tool from getHasMany to listRelated for clarity - Rename has-many.ts to list-related.ts - Add enableCount option with totalCount support (parallel execution) - Change response format to always return { records: [...] } - Add count() method to Relation class in agent-client - Fix typo in error message for invalid relations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 88f0417 commit bdb1840

File tree

6 files changed

+168
-55
lines changed

6 files changed

+168
-55
lines changed

packages/agent-client/src/domains/relation.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,16 @@ export default class Relation<TypingsSchema> {
2828
query: QuerySerializer.serialize(options, this.collectionName),
2929
});
3030
}
31+
32+
async count(options?: SelectOptions): Promise<number> {
33+
return Number(
34+
(
35+
await this.httpRequester.query<{ count: number }>({
36+
method: 'get',
37+
path: `/forest/${this.collectionName}/${this.parentId}/relationships/${this.name}/count`,
38+
query: QuerySerializer.serialize(options, this.collectionName),
39+
})
40+
).count,
41+
);
42+
}
3143
}

packages/agent-client/test/domains/relation.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,55 @@ describe('Relation', () => {
7070
expect(result).toEqual(expectedData);
7171
});
7272
});
73+
74+
describe('count', () => {
75+
it('should call httpRequester.query with correct count path', async () => {
76+
const relation = new Relation('posts', 'users', 1, httpRequester);
77+
httpRequester.query.mockResolvedValue({ count: 42 });
78+
79+
const result = await relation.count();
80+
81+
expect(httpRequester.query).toHaveBeenCalledWith({
82+
method: 'get',
83+
path: '/forest/users/1/relationships/posts/count',
84+
query: expect.any(Object),
85+
});
86+
expect(result).toBe(42);
87+
});
88+
89+
it('should handle string parent id', async () => {
90+
const relation = new Relation('posts', 'users', 'abc-123', httpRequester);
91+
httpRequester.query.mockResolvedValue({ count: 10 });
92+
93+
await relation.count();
94+
95+
expect(httpRequester.query).toHaveBeenCalledWith({
96+
method: 'get',
97+
path: '/forest/users/abc-123/relationships/posts/count',
98+
query: expect.any(Object),
99+
});
100+
});
101+
102+
it('should pass options to query serializer', async () => {
103+
const relation = new Relation('posts', 'users', 1, httpRequester);
104+
httpRequester.query.mockResolvedValue({ count: 5 });
105+
106+
await relation.count({ search: 'title' });
107+
108+
expect(httpRequester.query).toHaveBeenCalledWith({
109+
method: 'get',
110+
path: '/forest/users/1/relationships/posts/count',
111+
query: expect.objectContaining({ search: 'title' }),
112+
});
113+
});
114+
115+
it('should return number from count response', async () => {
116+
const relation = new Relation('posts', 'users', 1, httpRequester);
117+
httpRequester.query.mockResolvedValue({ count: '100' });
118+
119+
const result = await relation.count();
120+
121+
expect(result).toBe(100);
122+
});
123+
});
73124
});

packages/mcp-server/src/server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import * as http from 'http';
2020

2121
import ForestOAuthProvider from './forest-oauth-provider';
2222
import { isMcpRoute } from './mcp-paths';
23-
import declareListHasManyTool from './tools/has-many';
23+
import declareListRelatedTool from './tools/list-related';
2424
import declareListTool from './tools/list';
2525
import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher';
2626
import interceptResponseForErrorLogging from './utils/sse-error-logger';
@@ -126,7 +126,7 @@ export default class ForestMCPServer {
126126
}
127127

128128
declareListTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames);
129-
declareListHasManyTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames);
129+
declareListRelatedTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames);
130130
}
131131

132132
private ensureSecretsAreSet(): { envSecret: string; authSecret: string } {

packages/mcp-server/src/tools/has-many.ts renamed to packages/mcp-server/src/tools/list-related.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
import type { ListArgument } from './list.js';
12
import type { SelectOptions } from '@forestadmin/agent-client';
23
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
34

45
import { z } from 'zod';
56

6-
import type { ListArgument } from './list.js';
77
import { createListArgumentShape } from './list.js';
88
import { Logger } from '../server.js';
99
import createActivityLog from '../utils/activity-logs-creator.js';
@@ -25,7 +25,7 @@ type HasManyArgument = ListArgument & {
2525
parentRecordId: string | number;
2626
};
2727

28-
export default function declareListHasManyTool(
28+
export default function declareListRelatedTool(
2929
mcpServer: McpServer,
3030
forestServerUrl: string,
3131
logger: Logger,
@@ -35,10 +35,10 @@ export default function declareListHasManyTool(
3535

3636
registerToolWithLogging(
3737
mcpServer,
38-
'getHasMany',
38+
'listRelated',
3939
{
40-
title: 'List records from a hasMany relationship',
41-
description: 'Retrieve a list of records from the specified hasMany relationship.',
40+
title: 'List records from a relation',
41+
description: 'Retrieve a list of records from the specified relation (hasMany).',
4242
inputSchema: listArgumentShape,
4343
},
4444
async (options: HasManyArgument, extra) => {
@@ -51,12 +51,22 @@ export default function declareListHasManyTool(
5151
});
5252

5353
try {
54-
const result = await rpcClient
54+
const relation = rpcClient
5555
.collection(options.collectionName)
56-
.relation(options.relationName, options.parentRecordId)
57-
.list(options as SelectOptions);
56+
.relation(options.relationName, options.parentRecordId);
57+
58+
if (options.enableCount) {
59+
const [records, totalCount] = await Promise.all([
60+
relation.list(options as SelectOptions),
61+
relation.count(options as SelectOptions),
62+
]);
63+
64+
return { content: [{ type: 'text', text: JSON.stringify({ records, totalCount }) }] };
65+
}
66+
67+
const records = await relation.list(options as SelectOptions);
5868

59-
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
69+
return { content: [{ type: 'text', text: JSON.stringify({ records }) }] };
6070
} catch (error) {
6171
// Parse error text if it's a JSON string from the agent
6272
const errorDetail = parseAgentError(error);
@@ -73,7 +83,7 @@ export default function declareListHasManyTool(
7383
.some(field => field.field === options.relationName)
7484
) {
7585
throw new Error(
76-
`The relation name provided is invalid for this collection. Available relation for the collection are ${
86+
`The relation name provided is invalid for this collection. Available relations for collection ${
7787
options.collectionName
7888
} are: ${fields
7989
.filter(field => field.relationship === 'HasMany')

packages/mcp-server/test/server.test.ts

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import jsonwebtoken from 'jsonwebtoken';
44
import request from 'supertest';
55

66
import MockServer from './test-utils/mock-server';
7-
import { clearSchemaCache } from './utils/schema-fetcher.js';
7+
import { clearSchemaCache } from '../src/utils/schema-fetcher.js';
88
import ForestMCPServer from '../src/server';
99

1010
function shutDownHttpServer(server: http.Server | undefined): Promise<void> {
@@ -2154,11 +2154,11 @@ describe('ForestMCPServer Instance', () => {
21542154
});
21552155

21562156
/**
2157-
* Integration tests for the getHasMany tool
2158-
* Tests that the getHasMany tool is properly registered and accessible
2157+
* Integration tests for the listRelated tool
2158+
* Tests that the listRelated tool is properly registered and accessible
21592159
*/
2160-
describe('GetHasMany tool integration', () => {
2161-
let hasManyServer: ForestAdminMCPServer;
2160+
describe('listRelated tool integration', () => {
2161+
let hasManyServer: ForestMCPServer;
21622162
let hasManyHttpServer: http.Server;
21632163
let hasManyMockServer: MockServer;
21642164

@@ -2213,7 +2213,7 @@ describe('ForestMCPServer Instance', () => {
22132213

22142214
global.fetch = hasManyMockServer.fetch;
22152215

2216-
hasManyServer = new ForestAdminMCPServer();
2216+
hasManyServer = new ForestMCPServer();
22172217
hasManyServer.run();
22182218

22192219
await new Promise(resolve => {
@@ -2233,7 +2233,7 @@ describe('ForestMCPServer Instance', () => {
22332233
});
22342234
});
22352235

2236-
it('should have getHasMany tool registered in the MCP server', async () => {
2236+
it('should have listRelated tool registered in the MCP server', async () => {
22372237
const authSecret = process.env.FOREST_AUTH_SECRET || 'test-auth-secret';
22382238
const validToken = jsonwebtoken.sign(
22392239
{
@@ -2290,24 +2290,24 @@ describe('ForestMCPServer Instance', () => {
22902290
expect(responseData.result.tools).toBeDefined();
22912291
expect(Array.isArray(responseData.result.tools)).toBe(true);
22922292

2293-
// Verify the 'getHasMany' tool is registered
2294-
const getHasManyTool = responseData.result.tools.find(
2295-
(tool: { name: string }) => tool.name === 'getHasMany',
2293+
// Verify the 'listRelated' tool is registered
2294+
const listRelatedTool = responseData.result.tools.find(
2295+
(tool: { name: string }) => tool.name === 'listRelated',
22962296
);
2297-
expect(getHasManyTool).toBeDefined();
2298-
expect(getHasManyTool.description).toBe(
2299-
'Retrieve a list of records from the specified hasMany relationship.',
2297+
expect(listRelatedTool).toBeDefined();
2298+
expect(listRelatedTool.description).toBe(
2299+
'Retrieve a list of records from the specified relation (hasMany).',
23002300
);
2301-
expect(getHasManyTool.inputSchema).toBeDefined();
2302-
expect(getHasManyTool.inputSchema.properties).toHaveProperty('collectionName');
2303-
expect(getHasManyTool.inputSchema.properties).toHaveProperty('relationName');
2304-
expect(getHasManyTool.inputSchema.properties).toHaveProperty('parentRecordId');
2305-
expect(getHasManyTool.inputSchema.properties).toHaveProperty('search');
2306-
expect(getHasManyTool.inputSchema.properties).toHaveProperty('filters');
2307-
expect(getHasManyTool.inputSchema.properties).toHaveProperty('sort');
2301+
expect(listRelatedTool.inputSchema).toBeDefined();
2302+
expect(listRelatedTool.inputSchema.properties).toHaveProperty('collectionName');
2303+
expect(listRelatedTool.inputSchema.properties).toHaveProperty('relationName');
2304+
expect(listRelatedTool.inputSchema.properties).toHaveProperty('parentRecordId');
2305+
expect(listRelatedTool.inputSchema.properties).toHaveProperty('search');
2306+
expect(listRelatedTool.inputSchema.properties).toHaveProperty('filters');
2307+
expect(listRelatedTool.inputSchema.properties).toHaveProperty('sort');
23082308

23092309
// Verify collectionName has enum with the collection names from the mocked schema
2310-
const collectionNameSchema = getHasManyTool.inputSchema.properties.collectionName as {
2310+
const collectionNameSchema = listRelatedTool.inputSchema.properties.collectionName as {
23112311
type: string;
23122312
enum?: string[];
23132313
};
@@ -2316,7 +2316,7 @@ describe('ForestMCPServer Instance', () => {
23162316
expect(collectionNameSchema.enum).toEqual(['users', 'orders']);
23172317
});
23182318

2319-
it('should create activity log with forestServerToken and relation label when calling getHasMany tool', async () => {
2319+
it('should create activity log with forestServerToken and relation label when calling listRelated tool', async () => {
23202320
const authSecret = process.env.FOREST_AUTH_SECRET || 'test-auth-secret';
23212321
const forestServerToken = 'original-forest-server-token-for-has-many';
23222322

@@ -2347,7 +2347,7 @@ describe('ForestMCPServer Instance', () => {
23472347
jsonrpc: '2.0',
23482348
method: 'tools/call',
23492349
params: {
2350-
name: 'getHasMany',
2350+
name: 'listRelated',
23512351
arguments: {
23522352
collectionName: 'users',
23532353
relationName: 'orders',
@@ -2383,7 +2383,7 @@ describe('ForestMCPServer Instance', () => {
23832383
});
23842384
});
23852385

2386-
it('should accept string parentRecordId when calling getHasMany tool', async () => {
2386+
it('should accept string parentRecordId when calling listRelated tool', async () => {
23872387
const authSecret = process.env.FOREST_AUTH_SECRET || 'test-auth-secret';
23882388
const forestServerToken = 'forest-token-for-string-id-test';
23892389

@@ -2412,7 +2412,7 @@ describe('ForestMCPServer Instance', () => {
24122412
jsonrpc: '2.0',
24132413
method: 'tools/call',
24142414
params: {
2415-
name: 'getHasMany',
2415+
name: 'listRelated',
24162416
arguments: {
24172417
collectionName: 'users',
24182418
relationName: 'orders',

0 commit comments

Comments
 (0)