Skip to content

Commit 88f0417

Browse files
Vincent Moliniéalban bertolini
authored andcommitted
feat(forest mcp): add has many tool
1 parent b43de39 commit 88f0417

File tree

6 files changed

+1083
-2
lines changed

6 files changed

+1083
-2
lines changed

packages/mcp-server/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +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';
2324
import declareListTool from './tools/list';
2425
import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher';
2526
import interceptResponseForErrorLogging from './utils/sse-error-logger';
@@ -125,6 +126,7 @@ export default class ForestMCPServer {
125126
}
126127

127128
declareListTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames);
129+
declareListHasManyTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames);
128130
}
129131

130132
private ensureSecretsAreSet(): { envSecret: string; authSecret: string } {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { SelectOptions } from '@forestadmin/agent-client';
2+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3+
4+
import { z } from 'zod';
5+
6+
import type { ListArgument } from './list.js';
7+
import { createListArgumentShape } from './list.js';
8+
import { Logger } from '../server.js';
9+
import createActivityLog from '../utils/activity-logs-creator.js';
10+
import buildClient from '../utils/agent-caller.js';
11+
import parseAgentError from '../utils/error-parser.js';
12+
import { fetchForestSchema, getFieldsOfCollection } from '../utils/schema-fetcher.js';
13+
import registerToolWithLogging from '../utils/tool-with-logging.js';
14+
15+
function createHasManyArgumentShape(collectionNames: string[]) {
16+
return {
17+
...createListArgumentShape(collectionNames),
18+
relationName: z.string(),
19+
parentRecordId: z.union([z.string(), z.number()]),
20+
};
21+
}
22+
23+
type HasManyArgument = ListArgument & {
24+
relationName: string;
25+
parentRecordId: string | number;
26+
};
27+
28+
export default function declareListHasManyTool(
29+
mcpServer: McpServer,
30+
forestServerUrl: string,
31+
logger: Logger,
32+
collectionNames: string[] = [],
33+
): void {
34+
const listArgumentShape = createHasManyArgumentShape(collectionNames);
35+
36+
registerToolWithLogging(
37+
mcpServer,
38+
'getHasMany',
39+
{
40+
title: 'List records from a hasMany relationship',
41+
description: 'Retrieve a list of records from the specified hasMany relationship.',
42+
inputSchema: listArgumentShape,
43+
},
44+
async (options: HasManyArgument, extra) => {
45+
const { rpcClient } = await buildClient(extra);
46+
47+
await createActivityLog(forestServerUrl, extra, 'index', {
48+
collectionName: options.collectionName,
49+
recordId: options.parentRecordId,
50+
label: `list hasMany relation "${options.relationName}"`,
51+
});
52+
53+
try {
54+
const result = await rpcClient
55+
.collection(options.collectionName)
56+
.relation(options.relationName, options.parentRecordId)
57+
.list(options as SelectOptions);
58+
59+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
60+
} catch (error) {
61+
// Parse error text if it's a JSON string from the agent
62+
const errorDetail = parseAgentError(error);
63+
64+
const fields = getFieldsOfCollection(
65+
await fetchForestSchema(forestServerUrl),
66+
options.collectionName,
67+
);
68+
69+
if (
70+
error.message?.toLowerCase()?.includes('not found') &&
71+
!fields
72+
.filter(field => field.relationship === 'HasMany')
73+
.some(field => field.field === options.relationName)
74+
) {
75+
throw new Error(
76+
`The relation name provided is invalid for this collection. Available relation for the collection are ${
77+
options.collectionName
78+
} are: ${fields
79+
.filter(field => field.relationship === 'HasMany')
80+
.map(field => field.field)
81+
.join(', ')}.`,
82+
);
83+
}
84+
85+
if (errorDetail?.includes('Invalid sort')) {
86+
throw new Error(
87+
`The sort field provided is invalid for this collection. Available fields for the collection ${
88+
options.collectionName
89+
} are: ${fields
90+
.filter(field => field.isSortable)
91+
.map(field => field.field)
92+
.join(', ')}.`,
93+
);
94+
}
95+
96+
throw errorDetail ? new Error(errorDetail) : error;
97+
}
98+
},
99+
logger,
100+
);
101+
}

packages/mcp-server/src/tools/list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ const listArgumentSchema = z.object({
6161
.describe('When true, also returns totalCount of matching records'),
6262
});
6363

64-
type ListArgument = z.infer<typeof listArgumentSchema>;
64+
export type ListArgument = z.infer<typeof listArgumentSchema>;
6565

66-
function createListArgumentShape(collectionNames: string[]) {
66+
export function createListArgumentShape(collectionNames: string[]) {
6767
return {
6868
...listArgumentSchema.shape,
6969
collectionName:

packages/mcp-server/src/utils/schema-fetcher.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface ForestField {
2121
validations?: unknown[];
2222
defaultValue?: unknown;
2323
isPrimaryKey: boolean;
24+
relationship?: 'HasMany' | 'BelongsTo' | 'HasOne' | null;
2425
}
2526

2627
export interface ForestCollection {

0 commit comments

Comments
 (0)