Repository navigation
Expand file tree
/
Copy pathapiTools.ts
More file actions
415 lines (382 loc) · 17 KB
/
Copy pathapiTools.ts
File metadata and controls
415 lines (382 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
import type { Tool } from '@modelcontextprotocol/server';
import { compactInputSchema } from './compactInputSchema.js';
import type { McpPageSize } from './types.js';
import { adminApiPrefix } from './urls.js';
import { AdminForthDataTypes } from 'adminforth';
import type {
AdminForthResourceFrontend,
AdminUser,
IAdminForth,
IAdminForthHttpResponse,
IRegisteredApiSchema,
} from 'adminforth';
const METHODS_WITHOUT_REQUEST_BODY = new Set(['GET', 'HEAD']);
const NON_TOOL_NAME_CHARACTER_RE = /[^a-zA-Z0-9_]+/g;
const EDGE_UNDERSCORE_RE = /^_+|_+$/g;
// Milliseconds cost tokens on every datetime value and are never needed to answer the user.
const ISO_MILLISECONDS_RE = /\.\d+(?=Z$)/;
const TOOL_TIMEOUT = Symbol('TOOL_TIMEOUT');
// _label repeats field values of the row, _clickUrl is a frontend navigation helper.
const ROW_HELPER_FIELDS = new Set(['_label', '_clickUrl']);
// destructiveHint is only a hint: whether a client asks for approval depends on its permission mode,
// so dangerous tools also require a confirmation in chat.
const DANGEROUS_TOOL_NOTE = 'This tool changes data. If you have not loaded the mutate_data skill yet, load it with fetch_skill first. Before calling, show the user exactly what will change and wait for their explicit confirmation in chat, even when the client does not ask for approval.';
// Column properties needed to read, filter and aggregate records; writes need the detailed response.
// Non-sortable columns also get sortable: false.
const GET_RESOURCE_ESSENTIAL_COLUMN_FIELDS = ['name', 'label', 'type', 'enum', 'foreignResource'];
// The rest of foreignResource configures the foreign record picker in the UI.
const GET_RESOURCE_ESSENTIAL_FOREIGN_RESOURCE_FIELDS = ['resourceId', 'polymorphicOn', 'polymorphicResources'];
type GetResourceOutput = { resource: AdminForthResourceFrontend };
type GetResourceDataOutput = { data: Array<Record<string, unknown>>; total?: number; recordIds?: unknown[] };
type ToolOverride = {
// Appended to the endpoint description, so clients learn how the tool behaves before loading the schema.
descriptionNote: string;
// Arguments handled by the MCP plugin itself: they are added to the tool input schema and never reach the handler.
arguments?: Record<string, Record<string, unknown>>;
// Arguments the client may omit: they are dropped from the required list and filled with these values.
defaultArguments?: Record<string, unknown>;
prepareArguments?: (handlerArguments: Record<string, unknown>) => Record<string, unknown>;
// Receives every client argument with defaults applied, and the arguments exactly as the client passed them.
project?: (output: unknown, args: Record<string, unknown>, clientArgs: Record<string, unknown>) => unknown;
};
function createToolOverrides(pageSize: McpPageSize, adminforth: IAdminForth): Record<string, ToolOverride> {
return {
get_resource: {
descriptionNote: 'By default only the columns needed to read, filter and aggregate records are returned. Pass detailed: true right away when you are going to create or update records or run actions; the detailed response already includes the default one.',
arguments: {
detailed: {
type: 'boolean',
description: 'Set to true before create_record, update_record, start_custom_action or start_custom_bulk_action: it returns every column property (required fields, which fields can be set on create and edit, validation rules, length and value limits, showIf conditions, editing notes) and the custom and bulk actions of the resource. By default only the column properties needed to read, filter and aggregate records are returned.',
},
},
project: (output, { detailed }) => (
detailed
? withoutFrontendOnlyFields(output as GetResourceOutput)
: essentialResource(output as GetResourceOutput)
),
},
get_resource_data: {
descriptionNote: `Returns ${pageSize.default} rows unless limit is passed, and at most ${pageSize.max} rows per call; the response note tells when more rows exist. Row properties with null values, empty arrays or empty objects are omitted, so a column missing from a row is empty. Datetimes are UTC in ISO 8601 without milliseconds. When you pass columns, include the primary key column to get record ids (composite keys come as _primaryKeyValue in list rows).`,
defaultArguments: { source: 'list', limit: pageSize.default, offset: 0 },
prepareArguments: (handlerArguments) => ({
...handlerArguments,
limit: Math.min(handlerArguments.limit as number, pageSize.max),
}),
project: (output, args, clientArgs) => compactResourceData(
output as GetResourceDataOutput,
args,
clientArgs,
pageSize,
datetimeColumnNames(adminforth, args.resourceId as string),
),
},
};
}
type RegisteredToolSchema = IRegisteredApiSchema & {
handler: NonNullable<IRegisteredApiSchema['handler']>;
};
interface ToolCatalog {
schemas: Map<string, RegisteredToolSchema>;
tools: Tool[];
}
function isRegisteredToolSchema(schema: IRegisteredApiSchema): schema is RegisteredToolSchema {
return typeof schema.handler === 'function' && !schema.agent?.hiddenFromAgents;
}
function endpointPathToToolName(path: string): string {
return path.replace(NON_TOOL_NAME_CHARACTER_RE, '_').replace(EDGE_UNDERSCORE_RE, '');
}
// Drops frontend-only parts of the get_resource response that are useless for MCP clients and only waste their context.
// Copies instead of deleting: the response shares nested objects with the AdminForth config.
function withoutFrontendOnlyFields({ resource }: GetResourceOutput): GetResourceOutput {
const { pageInjections, ...options } = resource.options;
return {
resource: {
...resource,
columns: resource.columns.map(({ filterOptions, components, ...column }) => column),
options: {
...options,
actions: options.actions?.map(({ customComponent, ...action }) => action),
},
},
};
}
function pickFields<T extends object>(source: T, fields: string[]): Partial<T> {
return Object.fromEntries(
fields.filter((field) => field in source).map((field) => [field, source[field as keyof T]]),
) as Partial<T>;
}
// Virtual columns are skipped: they cannot be filtered, sorted, aggregated or selected in get_resource_data.
function essentialResource({ resource }: GetResourceOutput) {
const primaryKeyNames = resource.columns.filter((column) => column.primaryKey).map((column) => column.name);
return {
resource: {
resourceId: resource.resourceId,
label: resource.label,
primaryKey: primaryKeyNames.length === 1 ? primaryKeyNames[0] : primaryKeyNames,
columns: resource.columns
.filter((column) => !column.backendOnly && !column.virtual)
.map((column) => ({
...pickFields(column, GET_RESOURCE_ESSENTIAL_COLUMN_FIELDS),
// most columns are sortable, so only the exceptions are listed
...(column.sortable === false && { sortable: false }),
...(column.foreignResource && {
foreignResource: pickFields(column.foreignResource, GET_RESOURCE_ESSENTIAL_FOREIGN_RESOURCE_FIELDS),
}),
})),
},
};
}
function datetimeColumnNames(adminforth: IAdminForth, resourceId: string): Set<string> {
const resource = adminforth.config.resources.find((candidate) => candidate.resourceId === resourceId)!;
return new Set(
resource.dataSourceColumns
.filter((column) => column.type === AdminForthDataTypes.DATETIME)
.map((column) => column.name),
);
}
// null, [] and {} carry no data, so the column is left out of the row; clients read a missing column as empty.
function isEmptyValue(value: unknown): boolean {
if (value === null) return true;
if (Array.isArray(value)) return value.length === 0;
return typeof value === 'object' && Object.keys(value).length === 0;
}
function compactRow(row: Record<string, unknown>, datetimeColumns: Set<string>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(row)
.filter(([name, value]) => !isEmptyValue(value) && !ROW_HELPER_FIELDS.has(name))
.map(([name, value]) => [
name,
datetimeColumns.has(name) ? (value as string).replace(ISO_MILLISECONDS_RE, '') : value,
]),
);
}
// recordIds repeat the primary keys that list rows already carry, the frontend only needs them for paging in the show view.
// The note stops clients from silently paging through every record: they should ask the user first. It is added only
// for page requests (no limit, so the default page, or a full page); a client that asked for exactly N records,
// such as the single oldest one, gets what it asked for without the note.
function compactResourceData(
{ recordIds, data, ...rest }: GetResourceDataOutput,
args: Record<string, unknown>,
clientArgs: Record<string, unknown>,
pageSize: McpPageSize,
datetimeColumns: Set<string>,
) {
const offset = args.offset as number;
const nextOffset = offset + data.length;
const isPageRequest = clientArgs.limit === undefined || (clientArgs.limit as number) >= pageSize.max;
const notes = [
(args.limit as number) > pageSize.max && `limit is capped at ${pageSize.max} rows per call.`,
isPageRequest && rest.total !== undefined && rest.total > nextOffset
&& `Loaded ${data.length} of ${rest.total} records (offset ${offset}). Ask the user before loading more; the next page starts at offset ${nextOffset}.`,
].filter(Boolean);
return {
...rest,
data: data.map((row) => compactRow(row, datetimeColumns)),
...(notes.length > 0 && { note: notes.join(' ') }),
};
}
function overrideInputSchema(
inputSchema: Record<string, unknown>,
override: ToolOverride | undefined,
): Record<string, unknown> {
if (!override) return inputSchema;
const defaultedNames = new Set(Object.keys(override.defaultArguments ?? {}));
return {
...inputSchema,
...(Array.isArray(inputSchema.required) && {
required: inputSchema.required.filter((name: string) => !defaultedNames.has(name)),
}),
properties: { ...(inputSchema.properties as Record<string, unknown>), ...override.arguments },
};
}
function withoutPluginArguments(
args: Record<string, unknown>,
override: ToolOverride | undefined,
): Record<string, unknown> {
const pluginArgumentNames = new Set(Object.keys(override?.arguments ?? {}));
return Object.fromEntries(Object.entries(args).filter(([name]) => !pluginArgumentNames.has(name)));
}
function createDirectResponse(): IAdminForthHttpResponse & { status: number; message?: string } {
return {
status: 200,
setHeader() {},
setStatus(status, message) {
this.status = status;
this.message = message;
},
blobStream() {
throw new Error('File streaming is not available through MCP tools');
},
};
}
export class AdminForthApiTools {
constructor(
private readonly adminforth: IAdminForth,
private readonly hiddenResourceIds: ReadonlySet<string>,
pageSize: McpPageSize,
private readonly toolTimeoutMs: number,
) {
this.toolOverrides = createToolOverrides(pageSize, adminforth);
}
private readonly toolOverrides: Record<string, ToolOverride>;
private fullCatalog?: ToolCatalog;
private readOnlyCatalog?: ToolCatalog;
private catalog(readOnly: boolean): ToolCatalog {
return readOnly
? (this.readOnlyCatalog ??= this.buildCatalog(true))
: (this.fullCatalog ??= this.buildCatalog(false));
}
private buildCatalog(readOnly: boolean): ToolCatalog {
const apiPrefix = adminApiPrefix(this.adminforth.config.baseUrl);
const schemas = new Map(
this.adminforth.openApi.registeredSchemas
.filter(isRegisteredToolSchema)
.filter((schema) => !readOnly || schema.agent?.onlyReadsData)
.map((schema) => [endpointPathToToolName(schema.path.slice(apiPrefix.length)), schema] as const),
);
const tools = Array.from(schemas.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, schema]) => ({
name,
...this.description(name, schema),
inputSchema: compactInputSchema(overrideInputSchema(schema.request_schema ?? {
type: 'object',
properties: {},
additionalProperties: true,
}, this.toolOverrides[name])) as Tool['inputSchema'],
...(schema.agent?.onlyReadsData && {
annotations: { readOnlyHint: true },
}),
...(schema.agent?.requiresHumanApproval && {
annotations: { destructiveHint: true },
}),
}));
return { schemas, tools };
}
private description(name: string, schema: RegisteredToolSchema): { description?: string } {
const description = [
schema.description,
this.toolOverrides[name]?.descriptionNote,
schema.agent?.requiresHumanApproval && DANGEROUS_TOOL_NOTE,
].filter(Boolean).join(' ');
return description ? { description } : {};
}
// The handler gets an abort signal that fires on timeout or when the MCP request is aborted;
// a handler that ignores it keeps running in the background, but the client is not kept waiting.
private async withTimeout<T>(
requestSignal: AbortSignal,
run: (abortSignal: AbortSignal) => T | Promise<T>,
): Promise<T | typeof TOOL_TIMEOUT> {
const controller = new AbortController();
const abortHandler = () => controller.abort();
requestSignal.addEventListener('abort', abortHandler, { once: true });
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<typeof TOOL_TIMEOUT>((resolve) => {
timer = setTimeout(() => {
controller.abort();
resolve(TOOL_TIMEOUT);
}, this.toolTimeoutMs);
});
try {
return await Promise.race([run(controller.signal), timeout]);
} finally {
clearTimeout(timer);
requestSignal.removeEventListener('abort', abortHandler);
}
}
list(readOnly: boolean): Tool[] {
return this.catalog(readOnly).tools;
}
async call(params: {
name: string;
arguments?: Record<string, unknown>;
adminUser: AdminUser;
headers: Record<string, any>;
requestUrl: string;
abortSignal: AbortSignal;
readOnly: boolean;
}): Promise<{ output: unknown; isError: boolean }> {
const schema = this.catalog(params.readOnly).schemas.get(params.name);
if (!schema) {
return { output: { error: `Unknown tool: ${params.name}` }, isError: true };
}
if (
typeof params.arguments?.resourceId === 'string'
&& this.hiddenResourceIds.has(params.arguments.resourceId)
) {
return {
output: { error: 'This resource is not available through MCP.' },
isError: true,
};
}
const override = this.toolOverrides[params.name];
const args = { ...override?.defaultArguments, ...params.arguments };
const pluginFreeArguments = withoutPluginArguments(args, override);
const handlerArguments = override?.prepareArguments?.(pluginFreeArguments) ?? pluginFreeArguments;
const method = schema.method.toUpperCase();
const hasBody = !METHODS_WITHOUT_REQUEST_BODY.has(method);
const body = hasBody ? handlerArguments : {};
const query = hasBody ? {} : handlerArguments;
const requestValidation = this.adminforth.openApi.validateRequestSchema(schema, body);
if (!requestValidation.valid) {
return {
output: { error: 'REQUEST_VALIDATION_FAILED', details: requestValidation.errors },
isError: true,
};
}
const response = createDirectResponse();
const output = await this.withTimeout(params.abortSignal, (abortSignal) => schema.handler({
body,
query,
headers: params.headers,
cookies: [],
adminUser: params.adminUser,
response,
requestUrl: params.requestUrl,
abortSignal,
_raw_express_req: undefined as never,
_raw_express_res: undefined as never,
tr: (message, category, translationParams, pluralizationNumber) => this.adminforth.tr(
message,
category,
params.headers['accept-language'],
translationParams,
pluralizationNumber,
),
}));
if (output === TOOL_TIMEOUT) {
return {
output: {
error: `Tool timed out after ${this.toolTimeoutMs / 1000} seconds. A change it started may still complete: check the result before calling it again.`,
},
isError: true,
};
}
if (response.message) {
return {
output: response.message,
isError: response.status >= 400,
};
}
if (output === undefined) {
return {
output: { error: 'Tool handler completed without returning a response.' },
isError: true,
};
}
const responseValidation = this.adminforth.openApi.validateResponseSchema(schema, output);
if (!responseValidation.valid) {
return {
output: { error: 'RESPONSE_VALIDATION_FAILED', details: responseValidation.errors },
isError: true,
};
}
const isError = response.status >= 400 || Boolean(
output && typeof output === 'object' && 'error' in output,
);
return {
output: isError || !override?.project ? output : override.project(output, args, params.arguments ?? {}),
isError,
};
}
}