Skip to content

Commit 1cc4c5b

Browse files
alban bertoliniclaude
andcommitted
feat(mcp-server): add create tool
Add a new MCP tool to create records in a collection: - Accepts collectionName and attributes object - Handles LLM sending attributes as JSON string - Activity logging with 'create' action - Returns the created record 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0d57bec commit 1cc4c5b

3 files changed

Lines changed: 340 additions & 0 deletions

File tree

packages/mcp-server/src/server.ts

Lines changed: 3 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 declareCreateTool from './tools/create';
2324
import declareDescribeCollectionTool from './tools/describe-collection';
2425
import declareListTool from './tools/list';
2526
import declareListRelatedTool from './tools/list-related';
@@ -51,6 +52,7 @@ const defaultLogger: Logger = (level, message) => {
5152
const SAFE_ARGUMENTS_FOR_LOGGING: Record<string, string[]> = {
5253
list: ['collectionName'],
5354
listRelated: ['collectionName', 'relationName', 'parentRecordId'],
55+
create: ['collectionName'],
5456
describeCollection: ['collectionName'],
5557
};
5658

@@ -131,6 +133,7 @@ export default class ForestMCPServer {
131133
);
132134
declareListTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames);
133135
declareListRelatedTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames);
136+
declareCreateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames);
134137
}
135138

136139
private ensureSecretsAreSet(): { envSecret: string; authSecret: string } {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { Logger } from '../server';
2+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3+
4+
import { z } from 'zod';
5+
6+
import createActivityLog from '../utils/activity-logs-creator.js';
7+
import buildClient from '../utils/agent-caller.js';
8+
import parseAgentError from '../utils/error-parser.js';
9+
import registerToolWithLogging from '../utils/tool-with-logging.js';
10+
11+
// Preprocess to handle LLM sending attributes as JSON string instead of object
12+
const attributesWithPreprocess = z.preprocess(val => {
13+
if (typeof val !== 'string') return val;
14+
15+
try {
16+
return JSON.parse(val);
17+
} catch {
18+
return val;
19+
}
20+
}, z.record(z.string(), z.unknown()));
21+
22+
interface CreateArgument {
23+
collectionName: string;
24+
attributes: Record<string, unknown>;
25+
}
26+
27+
function createArgumentShape(collectionNames: string[]) {
28+
return {
29+
collectionName:
30+
collectionNames.length > 0 ? z.enum(collectionNames as [string, ...string[]]) : z.string(),
31+
attributes: attributesWithPreprocess.describe(
32+
'The attributes of the record to create. Must be an object with field names as keys.',
33+
),
34+
};
35+
}
36+
37+
export default function declareCreateTool(
38+
mcpServer: McpServer,
39+
forestServerUrl: string,
40+
logger: Logger,
41+
collectionNames: string[] = [],
42+
): void {
43+
const argumentShape = createArgumentShape(collectionNames);
44+
45+
registerToolWithLogging(
46+
mcpServer,
47+
'create',
48+
{
49+
title: 'Create a record',
50+
description: 'Create a new record in the specified collection.',
51+
inputSchema: argumentShape,
52+
},
53+
async (options: CreateArgument, extra) => {
54+
const { rpcClient } = await buildClient(extra);
55+
56+
await createActivityLog(forestServerUrl, extra, 'create', {
57+
collectionName: options.collectionName,
58+
});
59+
60+
try {
61+
const record = await rpcClient
62+
.collection(options.collectionName)
63+
.create(options.attributes);
64+
65+
return { content: [{ type: 'text', text: JSON.stringify({ record }) }] };
66+
} catch (error) {
67+
const errorDetail = parseAgentError(error);
68+
throw errorDetail ? new Error(errorDetail) : error;
69+
}
70+
},
71+
logger,
72+
);
73+
}
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import type { Logger } from '../../src/server';
2+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3+
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol';
4+
import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types';
5+
6+
import declareCreateTool from '../../src/tools/create';
7+
import createActivityLog from '../../src/utils/activity-logs-creator';
8+
import buildClient from '../../src/utils/agent-caller';
9+
10+
jest.mock('../../src/utils/agent-caller');
11+
jest.mock('../../src/utils/activity-logs-creator');
12+
13+
const mockLogger: Logger = jest.fn();
14+
15+
const mockBuildClient = buildClient as jest.MockedFunction<typeof buildClient>;
16+
const mockCreateActivityLog = createActivityLog as jest.MockedFunction<typeof createActivityLog>;
17+
18+
describe('declareCreateTool', () => {
19+
let mcpServer: McpServer;
20+
let registeredToolHandler: (options: unknown, extra: unknown) => Promise<unknown>;
21+
let registeredToolConfig: { title: string; description: string; inputSchema: unknown };
22+
23+
beforeEach(() => {
24+
jest.clearAllMocks();
25+
26+
mcpServer = {
27+
registerTool: jest.fn((name, config, handler) => {
28+
registeredToolConfig = config;
29+
registeredToolHandler = handler;
30+
}),
31+
} as unknown as McpServer;
32+
33+
mockCreateActivityLog.mockResolvedValue(undefined);
34+
});
35+
36+
describe('tool registration', () => {
37+
it('should register a tool named "create"', () => {
38+
declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger);
39+
40+
expect(mcpServer.registerTool).toHaveBeenCalledWith(
41+
'create',
42+
expect.any(Object),
43+
expect.any(Function),
44+
);
45+
});
46+
47+
it('should register tool with correct title and description', () => {
48+
declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger);
49+
50+
expect(registeredToolConfig.title).toBe('Create a record');
51+
expect(registeredToolConfig.description).toBe(
52+
'Create a new record in the specified collection.',
53+
);
54+
});
55+
56+
it('should define correct input schema', () => {
57+
declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger);
58+
59+
expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName');
60+
expect(registeredToolConfig.inputSchema).toHaveProperty('attributes');
61+
});
62+
63+
it('should use string type for collectionName when no collection names provided', () => {
64+
declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger);
65+
66+
const schema = registeredToolConfig.inputSchema as Record<
67+
string,
68+
{ options?: string[]; parse: (value: unknown) => unknown }
69+
>;
70+
expect(schema.collectionName.options).toBeUndefined();
71+
expect(() => schema.collectionName.parse('any-collection')).not.toThrow();
72+
});
73+
74+
it('should use enum type for collectionName when collection names provided', () => {
75+
declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger, [
76+
'users',
77+
'products',
78+
]);
79+
80+
const schema = registeredToolConfig.inputSchema as Record<
81+
string,
82+
{ options: string[]; parse: (value: unknown) => unknown }
83+
>;
84+
expect(schema.collectionName.options).toEqual(['users', 'products']);
85+
expect(() => schema.collectionName.parse('users')).not.toThrow();
86+
expect(() => schema.collectionName.parse('invalid-collection')).toThrow();
87+
});
88+
});
89+
90+
describe('tool execution', () => {
91+
const mockExtra = {
92+
authInfo: {
93+
token: 'test-token',
94+
extra: {
95+
forestServerToken: 'forest-token',
96+
renderingId: '123',
97+
},
98+
},
99+
} as unknown as RequestHandlerExtra<ServerRequest, ServerNotification>;
100+
101+
beforeEach(() => {
102+
declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger);
103+
});
104+
105+
it('should call buildClient with the extra parameter', async () => {
106+
const mockCreate = jest.fn().mockResolvedValue({ id: 1, name: 'New Record' });
107+
const mockCollection = jest.fn().mockReturnValue({ create: mockCreate });
108+
mockBuildClient.mockReturnValue({
109+
rpcClient: { collection: mockCollection },
110+
authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 },
111+
} as unknown as ReturnType<typeof buildClient>);
112+
113+
await registeredToolHandler(
114+
{ collectionName: 'users', attributes: { name: 'John' } },
115+
mockExtra,
116+
);
117+
118+
expect(mockBuildClient).toHaveBeenCalledWith(mockExtra);
119+
});
120+
121+
it('should call rpcClient.collection with the collection name', async () => {
122+
const mockCreate = jest.fn().mockResolvedValue({ id: 1, name: 'New Record' });
123+
const mockCollection = jest.fn().mockReturnValue({ create: mockCreate });
124+
mockBuildClient.mockReturnValue({
125+
rpcClient: { collection: mockCollection },
126+
authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 },
127+
} as unknown as ReturnType<typeof buildClient>);
128+
129+
await registeredToolHandler(
130+
{ collectionName: 'products', attributes: { name: 'Product' } },
131+
mockExtra,
132+
);
133+
134+
expect(mockCollection).toHaveBeenCalledWith('products');
135+
});
136+
137+
it('should call create with the attributes', async () => {
138+
const mockCreate = jest
139+
.fn()
140+
.mockResolvedValue({ id: 1, name: 'John', email: 'john@test.com' });
141+
const mockCollection = jest.fn().mockReturnValue({ create: mockCreate });
142+
mockBuildClient.mockReturnValue({
143+
rpcClient: { collection: mockCollection },
144+
authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 },
145+
} as unknown as ReturnType<typeof buildClient>);
146+
147+
const attributes = { name: 'John', email: 'john@test.com' };
148+
await registeredToolHandler({ collectionName: 'users', attributes }, mockExtra);
149+
150+
expect(mockCreate).toHaveBeenCalledWith(attributes);
151+
});
152+
153+
it('should return the created record as JSON text content', async () => {
154+
const createdRecord = { id: 1, name: 'John', email: 'john@test.com' };
155+
const mockCreate = jest.fn().mockResolvedValue(createdRecord);
156+
const mockCollection = jest.fn().mockReturnValue({ create: mockCreate });
157+
mockBuildClient.mockReturnValue({
158+
rpcClient: { collection: mockCollection },
159+
authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 },
160+
} as unknown as ReturnType<typeof buildClient>);
161+
162+
const result = await registeredToolHandler(
163+
{ collectionName: 'users', attributes: { name: 'John', email: 'john@test.com' } },
164+
mockExtra,
165+
);
166+
167+
expect(result).toEqual({
168+
content: [{ type: 'text', text: JSON.stringify({ record: createdRecord }) }],
169+
});
170+
});
171+
172+
describe('activity logging', () => {
173+
beforeEach(() => {
174+
const mockCreate = jest.fn().mockResolvedValue({ id: 1 });
175+
const mockCollection = jest.fn().mockReturnValue({ create: mockCreate });
176+
mockBuildClient.mockReturnValue({
177+
rpcClient: { collection: mockCollection },
178+
authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 },
179+
} as unknown as ReturnType<typeof buildClient>);
180+
});
181+
182+
it('should create activity log with "create" action type', async () => {
183+
await registeredToolHandler(
184+
{ collectionName: 'users', attributes: { name: 'John' } },
185+
mockExtra,
186+
);
187+
188+
expect(mockCreateActivityLog).toHaveBeenCalledWith(
189+
'https://api.forestadmin.com',
190+
mockExtra,
191+
'create',
192+
{ collectionName: 'users' },
193+
);
194+
});
195+
});
196+
197+
describe('attributes parsing', () => {
198+
it('should parse attributes sent as JSON string (LLM workaround)', () => {
199+
const attributes = { name: 'John', age: 30 };
200+
const attributesAsString = JSON.stringify(attributes);
201+
202+
const inputSchema = registeredToolConfig.inputSchema as Record<
203+
string,
204+
{ parse: (value: unknown) => unknown }
205+
>;
206+
const parsedAttributes = inputSchema.attributes.parse(attributesAsString);
207+
208+
expect(parsedAttributes).toEqual(attributes);
209+
});
210+
211+
it('should handle attributes as object when not sent as string', async () => {
212+
const mockCreate = jest.fn().mockResolvedValue({ id: 1 });
213+
const mockCollection = jest.fn().mockReturnValue({ create: mockCreate });
214+
mockBuildClient.mockReturnValue({
215+
rpcClient: { collection: mockCollection },
216+
authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 },
217+
} as unknown as ReturnType<typeof buildClient>);
218+
219+
const attributes = { name: 'John', age: 30 };
220+
await registeredToolHandler({ collectionName: 'users', attributes }, mockExtra);
221+
222+
expect(mockCreate).toHaveBeenCalledWith(attributes);
223+
});
224+
});
225+
226+
describe('error handling', () => {
227+
it('should parse error with nested error.text structure in message', async () => {
228+
const errorPayload = {
229+
error: {
230+
status: 400,
231+
text: JSON.stringify({
232+
errors: [{ name: 'ValidationError', detail: 'Name is required' }],
233+
}),
234+
},
235+
};
236+
const agentError = new Error(JSON.stringify(errorPayload));
237+
const mockCreate = jest.fn().mockRejectedValue(agentError);
238+
const mockCollection = jest.fn().mockReturnValue({ create: mockCreate });
239+
mockBuildClient.mockReturnValue({
240+
rpcClient: { collection: mockCollection },
241+
authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 },
242+
} as unknown as ReturnType<typeof buildClient>);
243+
244+
await expect(
245+
registeredToolHandler({ collectionName: 'users', attributes: {} }, mockExtra),
246+
).rejects.toThrow('Name is required');
247+
});
248+
249+
it('should rethrow original error when no parsable error found', async () => {
250+
const agentError = { unknownProperty: 'some value' };
251+
const mockCreate = jest.fn().mockRejectedValue(agentError);
252+
const mockCollection = jest.fn().mockReturnValue({ create: mockCreate });
253+
mockBuildClient.mockReturnValue({
254+
rpcClient: { collection: mockCollection },
255+
authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 },
256+
} as unknown as ReturnType<typeof buildClient>);
257+
258+
await expect(
259+
registeredToolHandler({ collectionName: 'users', attributes: {} }, mockExtra),
260+
).rejects.toEqual(agentError);
261+
});
262+
});
263+
});
264+
});

0 commit comments

Comments
 (0)