@simtlix/simfinity-js 2.6.3 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp.js ADDED
@@ -0,0 +1,1377 @@
1
+ import {
2
+ graphql,
3
+ getNamedType,
4
+ astFromValue,
5
+ valueFromASTUntyped,
6
+ GraphQLNonNull,
7
+ GraphQLList,
8
+ GraphQLScalarType,
9
+ GraphQLEnumType,
10
+ GraphQLInputObjectType,
11
+ GraphQLObjectType,
12
+ } from 'graphql';
13
+ import SimfinityError from './errors/simfinity.error.js';
14
+
15
+ const FILTER_OPERATORS_TEXT = 'EQ, NE, LT, LTE, GT, GTE, IN, NIN, BTW, LIKE';
16
+
17
+ /** Allowed shape for published MCP tool names (SEP-986). */
18
+ const TOOL_NAME_RE = /^[a-zA-Z0-9_-]{1,128}$/;
19
+
20
+ /**
21
+ * Curated fallback documentation for the synthetic filter / pagination / sort /
22
+ * aggregation types and arguments that Simfinity generates (see
23
+ * `createArgsForQuery` in src/index.js). These types carry no GraphQL
24
+ * `description`, so this map supplies one only when none is present. Keys are
25
+ * either a type name (`QLFilter`) or a `TypeName.fieldName` pair.
26
+ */
27
+ const SIMFINITY_FILTER_DOCS = {
28
+ QLOperator: 'Comparison operator: EQ (equals), NE (not equals), LT, LTE, GT, GTE, IN (value in list), NIN (value not in list), BTW (between, value is [min, max]), LIKE (substring match).',
29
+ QLValue: 'Filter value. Accepts a scalar, or an array for the IN, NIN and BTW operators.',
30
+ QLFilter: 'Single-field filter: a comparison operator plus the value to compare against.',
31
+ QLTypeFilter: 'Filter applied to a related-entity field, addressed by a dot-separated path.',
32
+ QLTypeFilterExpression: 'Filter on a related entity: a list of path-based terms.',
33
+ QLFilterCondition: 'A single filter condition: field, operator, value and an optional nested path.',
34
+ QLFilterGroup: 'Recursive logical group of filter conditions combined with AND/OR.',
35
+ QLPagination: 'Pagination control: page (1-based), size (page size) and an optional count flag to include the total.',
36
+ 'QLPagination.page': '1-based page number.',
37
+ 'QLPagination.size': 'Number of records per page.',
38
+ 'QLPagination.count': 'When true, list queries return the total record count in the tool result `_meta.count` (aggregate queries ignore this flag).',
39
+ QLSortExpression: 'Sort specification: an ordered list of sort terms.',
40
+ QLSort: 'A single sort term: the field to sort by and the direction.',
41
+ 'QLSort.field': 'Field name to sort by.',
42
+ 'QLSort.order': 'Sort direction (ASC or DESC).',
43
+ QLSortOrder: 'Sort direction: ASC (ascending) or DESC (descending).',
44
+ QLAggregationOperation: 'Aggregation operation: SUM, COUNT, AVG, MIN or MAX.',
45
+ QLTypeAggregationExpression: 'Aggregation specification: a groupId field path to group by and a list of facts to compute.',
46
+ QLTypeAggregationFact: 'A single aggregation fact: the operation, an output factName and the field path to aggregate.',
47
+ IdInputType: 'Reference to a related entity by its id.',
48
+ };
49
+
50
+ /**
51
+ * Curated fallback documentation for well-known operation-level arguments that
52
+ * Simfinity injects into list and aggregate queries.
53
+ */
54
+ const SIMFINITY_ARG_DOCS = {
55
+ id: 'Unique identifier (id) of the record.',
56
+ pagination: 'Pagination: page (1-based), size, and an optional count flag (on list queries, the total is delivered in the tool result `_meta.count`).',
57
+ sort: 'Sort results by one or more fields (terms of { field, order: ASC | DESC }).',
58
+ AND: 'Logical AND group(s): every nested condition or group must match.',
59
+ OR: 'Logical OR group(s): at least one nested condition or group must match.',
60
+ aggregation: 'Aggregation spec: groupId to group by and facts [{ operation: SUM | COUNT | AVG | MIN | MAX, factName, path }].',
61
+ };
62
+
63
+ /**
64
+ * Resolve the JSON Schema primitive type for a GraphQL scalar. Custom validated
65
+ * scalars (created via createValidatedScalar) expose a `baseScalarType`, which is
66
+ * used to derive the underlying primitive. Date-like scalars (the names
67
+ * Simfinity maps to Mongoose Date) carry a string `format`. Unknown/opaque
68
+ * scalars map to an empty schema, meaning "any value is accepted", with a
69
+ * curated description where one exists (e.g. QLValue).
70
+ * @param {import('graphql').GraphQLScalarType} type
71
+ * @returns {Object} JSON Schema fragment
72
+ */
73
+ const scalarToJSONSchema = (type) => {
74
+ const name = type.baseScalarType ? type.baseScalarType.name : type.name;
75
+ switch (name) {
76
+ case 'Int':
77
+ return { type: 'integer' };
78
+ case 'Float':
79
+ return { type: 'number' };
80
+ case 'Boolean':
81
+ return { type: 'boolean' };
82
+ case 'ID':
83
+ case 'String':
84
+ return { type: 'string' };
85
+ case 'Date':
86
+ return { type: 'string', format: 'date' };
87
+ case 'DateTime':
88
+ return { type: 'string', format: 'date-time' };
89
+ case 'Time':
90
+ return { type: 'string', format: 'time' };
91
+ default: {
92
+ const doc = SIMFINITY_FILTER_DOCS[name];
93
+ return doc ? { description: doc } : {};
94
+ }
95
+ }
96
+ };
97
+
98
+ const withDescription = (schema, description) => {
99
+ if (!description) {
100
+ return schema;
101
+ }
102
+ return { ...schema, description };
103
+ };
104
+
105
+ /**
106
+ * Convert a GraphQL internal default value into its external (wire) form so
107
+ * the JSON Schema `default` matches what a client must actually send — most
108
+ * notably enum member NAMES rather than their internal values. Returns
109
+ * undefined when the value cannot be represented (in which case no `default`
110
+ * is emitted).
111
+ * @param {*} value internal default value
112
+ * @param {import('graphql').GraphQLInputType} type
113
+ * @returns {*}
114
+ */
115
+ const externalDefaultValue = (value, type) => {
116
+ try {
117
+ const ast = astFromValue(value, type);
118
+ return ast ? valueFromASTUntyped(ast) : undefined;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ };
123
+
124
+ /**
125
+ * Make a JSON Schema fragment accept `null` in addition to its declared types.
126
+ * GraphQL data contains `null` for every nullable position (missing optional
127
+ * fields, get-by-id misses), so output schemas must allow it or validating MCP
128
+ * clients reject perfectly valid `structuredContent`. Schemas without a `type`
129
+ * keyword already accept null and pass through unchanged.
130
+ * @param {Object} schema
131
+ * @returns {Object}
132
+ */
133
+ const nullableSchema = (schema) => {
134
+ if (!schema || !schema.type) {
135
+ return schema;
136
+ }
137
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
138
+ if (types.includes('null')) {
139
+ return schema;
140
+ }
141
+ const result = { ...schema, type: [...types, 'null'] };
142
+ if (Array.isArray(result.enum) && !result.enum.includes(null)) {
143
+ result.enum = [...result.enum, null];
144
+ }
145
+ return result;
146
+ };
147
+
148
+ /**
149
+ * Register an enum type as a JSON Schema `$defs` entry (idempotent). The enum
150
+ * member names are used as allowed values (what GraphQL expects through
151
+ * `variableValues`), and the type description (or curated fallback) is attached.
152
+ * @param {import('graphql').GraphQLEnumType} type
153
+ * @param {Object} defs
154
+ */
155
+ const ensureEnumDef = (type, defs) => {
156
+ if (defs[type.name]) {
157
+ return;
158
+ }
159
+ const def = {
160
+ type: 'string',
161
+ enum: type.getValues().map((value) => value.name),
162
+ };
163
+ const description = type.description || SIMFINITY_FILTER_DOCS[type.name];
164
+ if (description) {
165
+ def.description = description;
166
+ }
167
+ defs[type.name] = def;
168
+ };
169
+
170
+ /**
171
+ * Register an input object type as a JSON Schema `$defs` entry (idempotent). A
172
+ * placeholder is inserted before recursing so that self-referential input types
173
+ * (such as QLFilterGroup) resolve to a `$ref` instead of looping forever. Type
174
+ * and field descriptions are propagated, with curated fallbacks for Simfinity's
175
+ * synthetic filter types. Fields with a GraphQL default value carry a JSON
176
+ * Schema `default` and are not listed as required (GraphQL fills them in).
177
+ * @param {import('graphql').GraphQLInputObjectType} type
178
+ * @param {Object} defs
179
+ */
180
+ const ensureInputDef = (type, defs) => {
181
+ if (defs[type.name]) {
182
+ return;
183
+ }
184
+ defs[type.name] = {};
185
+
186
+ const properties = {};
187
+ const required = [];
188
+ for (const [fieldName, field] of Object.entries(type.getFields())) {
189
+ const { schema, isRequired } = typeToJSONSchema(field.type, defs);
190
+ const fieldDescription = field.description || SIMFINITY_FILTER_DOCS[`${type.name}.${fieldName}`];
191
+ let property = withDescription(schema, fieldDescription);
192
+ const hasDefault = field.defaultValue !== undefined;
193
+ if (hasDefault) {
194
+ const external = externalDefaultValue(field.defaultValue, field.type);
195
+ if (external !== undefined) {
196
+ property = { ...property, default: external };
197
+ }
198
+ }
199
+ properties[fieldName] = property;
200
+ if (isRequired && !hasDefault) {
201
+ required.push(fieldName);
202
+ }
203
+ }
204
+
205
+ const def = { type: 'object', properties };
206
+ const description = type.description || SIMFINITY_FILTER_DOCS[type.name];
207
+ if (description) {
208
+ def.description = description;
209
+ }
210
+ if (required.length) {
211
+ def.required = required;
212
+ }
213
+ defs[type.name] = def;
214
+ };
215
+
216
+ /**
217
+ * Convert a GraphQL type into a JSON Schema fragment, collecting named input and
218
+ * enum types into the shared `defs` map and returning whether the type is
219
+ * required (non-null).
220
+ * @param {import('graphql').GraphQLType} type
221
+ * @param {Object} defs
222
+ * @returns {{ schema: Object, isRequired: boolean }}
223
+ */
224
+ function typeToJSONSchema(type, defs) {
225
+ if (type instanceof GraphQLNonNull) {
226
+ const inner = typeToJSONSchema(type.ofType, defs);
227
+ return { schema: inner.schema, isRequired: true };
228
+ }
229
+ if (type instanceof GraphQLList) {
230
+ const inner = typeToJSONSchema(type.ofType, defs);
231
+ return { schema: { type: 'array', items: inner.schema }, isRequired: false };
232
+ }
233
+ if (type instanceof GraphQLScalarType) {
234
+ return { schema: scalarToJSONSchema(type), isRequired: false };
235
+ }
236
+ if (type instanceof GraphQLEnumType) {
237
+ ensureEnumDef(type, defs);
238
+ return { schema: { $ref: `#/$defs/${type.name}` }, isRequired: false };
239
+ }
240
+ if (type instanceof GraphQLInputObjectType) {
241
+ ensureInputDef(type, defs);
242
+ return { schema: { $ref: `#/$defs/${type.name}` }, isRequired: false };
243
+ }
244
+ return { schema: {}, isRequired: false };
245
+ }
246
+
247
+ const resolveArgDescription = (arg) => {
248
+ if (arg.description) {
249
+ return arg.description;
250
+ }
251
+ const named = getNamedType(arg.type);
252
+ if (named.name === 'QLFilter') {
253
+ return `Filter on \`${arg.name}\` using { operator, value }.`;
254
+ }
255
+ if (named.name === 'QLTypeFilterExpression') {
256
+ return `Filter on the related \`${arg.name}\` entity using { terms: [{ path, operator, value }] }.`;
257
+ }
258
+ return SIMFINITY_ARG_DOCS[arg.name];
259
+ };
260
+
261
+ /**
262
+ * Build the MCP `inputSchema` (JSON Schema) for a single GraphQL field by
263
+ * converting every argument with full fidelity (scalars, enums, input objects,
264
+ * lists, non-null wrappers and recursive types via `$defs`/`$ref`). Argument and
265
+ * type descriptions are propagated, with curated fallbacks for Simfinity's
266
+ * synthetic filter/pagination/sort/aggregation arguments. Arguments with a
267
+ * GraphQL default value carry a JSON Schema `default` and are never required.
268
+ * @param {import('graphql').GraphQLField} field
269
+ * @returns {Object} JSON Schema describing the tool input
270
+ */
271
+ export const graphqlArgsToJSONSchema = (field) => {
272
+ const defs = {};
273
+ const properties = {};
274
+ const required = [];
275
+
276
+ for (const arg of field.args || []) {
277
+ const { schema, isRequired } = typeToJSONSchema(arg.type, defs);
278
+ let property = withDescription(schema, resolveArgDescription(arg));
279
+ const hasDefault = arg.defaultValue !== undefined;
280
+ if (hasDefault) {
281
+ const external = externalDefaultValue(arg.defaultValue, arg.type);
282
+ if (external !== undefined) {
283
+ property = { ...property, default: external };
284
+ }
285
+ }
286
+ properties[arg.name] = property;
287
+ if (isRequired && !hasDefault) {
288
+ required.push(arg.name);
289
+ }
290
+ }
291
+
292
+ const result = { type: 'object', properties };
293
+ if (required.length) {
294
+ result.required = required;
295
+ }
296
+ if (Object.keys(defs).length) {
297
+ result.$defs = defs;
298
+ }
299
+ return result;
300
+ };
301
+
302
+ const hasRequiredArgs = (field) => (field.args || [])
303
+ .some((arg) => arg.type instanceof GraphQLNonNull && arg.defaultValue === undefined);
304
+
305
+ /**
306
+ * Auto-generate a GraphQL selection set string for a field's return type so MCP
307
+ * callers only need to supply arguments. Scalar/enum leaves are always selected;
308
+ * object fields are expanded up to `depth` levels, always falling back to `id`
309
+ * when nothing else is selectable. Interface/union return types and object types
310
+ * with no selectable leaves fall back to `__typename` so the generated document
311
+ * is always statically valid. Cycles are avoided via the `visited` set.
312
+ * @param {import('graphql').GraphQLType} type return type of the field
313
+ * @param {number} depth remaining nesting depth for object expansion
314
+ * @param {boolean} includeId always include `id` when present and nothing else selected
315
+ * @param {Set<string>} visited already-expanded object type names
316
+ * @returns {string} selection set (with leading space) or empty string for leaves
317
+ */
318
+ const buildSelectionSet = (type, depth, includeId, visited = new Set()) => {
319
+ const named = getNamedType(type);
320
+ if (named instanceof GraphQLScalarType || named instanceof GraphQLEnumType) {
321
+ return '';
322
+ }
323
+ if (!(named instanceof GraphQLObjectType)) {
324
+ // Interface/union: a selection set is mandatory; __typename is always valid.
325
+ return ' { __typename }';
326
+ }
327
+
328
+ const fields = named.getFields();
329
+ const lines = [];
330
+ const nextVisited = new Set(visited).add(named.name);
331
+
332
+ for (const [fieldName, field] of Object.entries(fields)) {
333
+ const fieldNamed = getNamedType(field.type);
334
+ if (fieldNamed instanceof GraphQLScalarType || fieldNamed instanceof GraphQLEnumType) {
335
+ if (!hasRequiredArgs(field)) {
336
+ lines.push(fieldName);
337
+ }
338
+ } else if (fieldNamed instanceof GraphQLObjectType) {
339
+ if (depth > 0 && !nextVisited.has(fieldNamed.name) && !hasRequiredArgs(field)) {
340
+ const sub = buildSelectionSet(field.type, depth - 1, includeId, nextVisited);
341
+ if (sub) {
342
+ lines.push(`${fieldName}${sub}`);
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ if (lines.length === 0 && includeId && fields.id && !hasRequiredArgs(fields.id)) {
349
+ lines.push('id');
350
+ }
351
+ if (lines.length === 0) {
352
+ lines.push('__typename');
353
+ }
354
+ return ` { ${lines.join(' ')} }`;
355
+ };
356
+
357
+ /**
358
+ * Normalize a user-provided explicit selection (from `toolOverrides`) into the
359
+ * leading-space `{ ... }` form `buildOperation` expects. Accepts either
360
+ * `'{ id title }'` or `'id title'`.
361
+ * @param {string} selection
362
+ * @returns {string}
363
+ */
364
+ const normalizeSelection = (selection) => {
365
+ const trimmed = String(selection).trim();
366
+ if (!trimmed) {
367
+ return '';
368
+ }
369
+ return trimmed.startsWith('{') ? ` ${trimmed}` : ` { ${trimmed} }`;
370
+ };
371
+
372
+ const isEmptyObjectSchema = (schema) => {
373
+ if (!schema) {
374
+ return true;
375
+ }
376
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
377
+ if (types.includes('object')) {
378
+ return !schema.properties || Object.keys(schema.properties).length === 0;
379
+ }
380
+ if (types.includes('array')) {
381
+ return isEmptyObjectSchema(schema.items);
382
+ }
383
+ return false;
384
+ };
385
+
386
+ /**
387
+ * Build a JSON Schema describing a field's return type, mirroring the inclusion
388
+ * rules of {@link buildSelectionSet} (scalars/enums always; object fields up to
389
+ * `depth`; `id`/`__typename` fallbacks) so the schema matches what the tool
390
+ * actually returns. Every nullable GraphQL position accepts `null` in the
391
+ * schema. GraphQL type and field descriptions are propagated.
392
+ * @param {import('graphql').GraphQLType} type
393
+ * @param {number} depth
394
+ * @param {boolean} includeId
395
+ * @param {Set<string>} visited
396
+ * @returns {Object} JSON Schema for the return type
397
+ */
398
+ const returnTypeToSchema = (type, depth, includeId, visited = new Set()) => {
399
+ if (type instanceof GraphQLNonNull) {
400
+ return unwrappedReturnTypeToSchema(type.ofType, depth, includeId, visited);
401
+ }
402
+ return nullableSchema(unwrappedReturnTypeToSchema(type, depth, includeId, visited));
403
+ };
404
+
405
+ function unwrappedReturnTypeToSchema(type, depth, includeId, visited) {
406
+ if (type instanceof GraphQLList) {
407
+ return { type: 'array', items: returnTypeToSchema(type.ofType, depth, includeId, visited) };
408
+ }
409
+ if (type instanceof GraphQLScalarType) {
410
+ return scalarToJSONSchema(type);
411
+ }
412
+ if (type instanceof GraphQLEnumType) {
413
+ return { type: 'string', enum: type.getValues().map((value) => value.name) };
414
+ }
415
+ if (!(type instanceof GraphQLObjectType)) {
416
+ return {};
417
+ }
418
+
419
+ const fields = type.getFields();
420
+ const properties = {};
421
+ const nextVisited = new Set(visited).add(type.name);
422
+
423
+ for (const [fieldName, field] of Object.entries(fields)) {
424
+ const fieldNamed = getNamedType(field.type);
425
+ if (fieldNamed instanceof GraphQLScalarType || fieldNamed instanceof GraphQLEnumType) {
426
+ if (!hasRequiredArgs(field)) {
427
+ properties[fieldName] = withDescription(
428
+ returnTypeToSchema(field.type, depth, includeId, nextVisited),
429
+ field.description,
430
+ );
431
+ }
432
+ } else if (fieldNamed instanceof GraphQLObjectType) {
433
+ if (depth > 0 && !nextVisited.has(fieldNamed.name) && !hasRequiredArgs(field)) {
434
+ const sub = returnTypeToSchema(field.type, depth - 1, includeId, nextVisited);
435
+ if (!isEmptyObjectSchema(sub)) {
436
+ properties[fieldName] = withDescription(sub, field.description);
437
+ }
438
+ }
439
+ }
440
+ }
441
+
442
+ if (Object.keys(properties).length === 0 && includeId && fields.id && !hasRequiredArgs(fields.id)) {
443
+ properties.id = withDescription(
444
+ returnTypeToSchema(fields.id.type, depth, includeId, nextVisited),
445
+ fields.id.description,
446
+ );
447
+ }
448
+ if (Object.keys(properties).length === 0) {
449
+ properties.__typename = { type: 'string' };
450
+ }
451
+
452
+ const schema = { type: 'object', properties };
453
+ if (type.description) {
454
+ schema.description = type.description;
455
+ }
456
+ return schema;
457
+ }
458
+
459
+ /**
460
+ * Build the MCP `outputSchema` for a field. The result is wrapped under the
461
+ * field name so that `structuredContent = graphqlResult.data` (which is shaped
462
+ * `{ [fieldName]: value }`) conforms to the schema.
463
+ * @param {string} fieldName
464
+ * @param {import('graphql').GraphQLField} field
465
+ * @param {number} selectionDepth
466
+ * @param {boolean} includeId
467
+ * @returns {Object} JSON Schema describing the tool output
468
+ */
469
+ const buildOutputSchema = (fieldName, field, selectionDepth, includeId) => ({
470
+ type: 'object',
471
+ properties: {
472
+ [fieldName]: returnTypeToSchema(field.type, selectionDepth, includeId),
473
+ },
474
+ });
475
+
476
+ /**
477
+ * Build the GraphQL operation document string for a field. All arguments are
478
+ * declared as variables so the MCP caller supplies them via `variableValues`.
479
+ * @param {'query'|'mutation'} kind
480
+ * @param {string} fieldName
481
+ * @param {import('graphql').GraphQLField} field
482
+ * @param {string} selection selection set (leading-space form) or empty string
483
+ * @returns {string} GraphQL operation source
484
+ */
485
+ const buildOperation = (kind, fieldName, field, selection) => {
486
+ const args = field.args || [];
487
+ const varDefs = args
488
+ .map((arg) => `$${arg.name}: ${arg.type.toString()}`)
489
+ .join(', ');
490
+ const argUsage = args
491
+ .map((arg) => `${arg.name}: $${arg.name}`)
492
+ .join(', ');
493
+
494
+ const header = varDefs ? `${kind} ${fieldName}Operation(${varDefs})` : `${kind} ${fieldName}Operation`;
495
+ const call = argUsage ? `${fieldName}(${argUsage})` : fieldName;
496
+ return `${header} {\n ${call}${selection}\n}`;
497
+ };
498
+
499
+ const isListType = (type) => {
500
+ const inner = type instanceof GraphQLNonNull ? type.ofType : type;
501
+ return inner instanceof GraphQLList;
502
+ };
503
+
504
+ const hasArg = (field, name) => (field.args || []).some((arg) => arg.name === name);
505
+
506
+ /**
507
+ * Classify a root field into a logical operation kind so descriptions, titles
508
+ * and annotations can be tailored. Queries are classified structurally.
509
+ * Generated CRUD mutations are recognized by the placeholder descriptions
510
+ * Simfinity stamps on them ('add'/'update'/'delete' — see buildMutation in
511
+ * src/index.js); name prefixes are only a fallback for description-less fields,
512
+ * so custom mutations and state-machine actions whose names happen to start
513
+ * with add/update/delete are not mislabeled.
514
+ * @param {'query'|'mutation'} kind
515
+ * @param {string} fieldName
516
+ * @param {import('graphql').GraphQLField} field
517
+ * @returns {'single'|'list'|'aggregate'|'add'|'update'|'delete'|'custom'}
518
+ */
519
+ const classifyOperation = (kind, fieldName, field) => {
520
+ if (kind === 'query') {
521
+ if (hasArg(field, 'aggregation')) {
522
+ return 'aggregate';
523
+ }
524
+ if (isListType(field.type)) {
525
+ return 'list';
526
+ }
527
+ return 'single';
528
+ }
529
+ // Generated CRUD mutations carry BOTH the placeholder description and the
530
+ // name prefix; requiring the conjunction avoids misreading a custom mutation
531
+ // that happens to have one of them (e.g. registerMutation('reindex', 'update')).
532
+ if (field.description === 'add' && fieldName.startsWith('add')) {
533
+ return 'add';
534
+ }
535
+ if (field.description === 'update' && fieldName.startsWith('update')) {
536
+ return 'update';
537
+ }
538
+ if (field.description === 'delete' && fieldName.startsWith('delete')) {
539
+ return 'delete';
540
+ }
541
+ if (!field.description) {
542
+ if (fieldName.startsWith('add')) {
543
+ return 'add';
544
+ }
545
+ if (fieldName.startsWith('update')) {
546
+ return 'update';
547
+ }
548
+ if (fieldName.startsWith('delete')) {
549
+ return 'delete';
550
+ }
551
+ }
552
+ return 'custom';
553
+ };
554
+
555
+ // Simfinity sets these trivial placeholder descriptions on generated CRUD
556
+ // mutations; they carry no useful information for an agent, so we ignore them
557
+ // and synthesize a richer description instead.
558
+ const PLACEHOLDER_DESCRIPTIONS = new Set(['add', 'update', 'delete']);
559
+
560
+ const buildToolDescription = (op, fieldName, field, entityType) => {
561
+ if (field.description && !PLACEHOLDER_DESCRIPTIONS.has(field.description)) {
562
+ return field.description;
563
+ }
564
+ const entity = entityType ? entityType.name : 'record';
565
+ const entityDoc = entityType && entityType.description ? ` ${entityType.description}` : '';
566
+
567
+ switch (op) {
568
+ case 'single':
569
+ return `Fetch a single ${entity} by id. Returns the matching ${entity} or null.${entityDoc}`;
570
+ case 'list':
571
+ return `List and search ${entity} records. Supports per-field filters (operators: ${FILTER_OPERATORS_TEXT}), nested AND/OR filter groups, pagination (page/size) and sorting. Returns an array of ${entity}.${entityDoc}`;
572
+ case 'aggregate':
573
+ return `Run grouped aggregations (SUM, COUNT, AVG, MIN, MAX) over ${entity} records. Provide \`aggregation\` with a groupId and facts. Supports per-field filters (operators: ${FILTER_OPERATORS_TEXT}), nested AND/OR filter groups, pagination (page/size) and sorting, like the list query. Returns grouped aggregation results (groupId, facts).${entityDoc}`;
574
+ case 'add':
575
+ return `Create a new ${entity}. Provide \`input\` with the fields to set. Returns the created ${entity}.${entityDoc}`;
576
+ case 'update':
577
+ return `Update an existing ${entity}. Provide \`input\` including the \`id\` and the fields to change. Returns the updated ${entity}.${entityDoc}`;
578
+ case 'delete':
579
+ return `Delete a ${entity} by id. Returns the deleted ${entity}.${entityDoc}`;
580
+ default:
581
+ return `Execute the \`${fieldName}\` operation.${entityDoc}`;
582
+ }
583
+ };
584
+
585
+ const buildToolTitle = (op, fieldName, entityType) => {
586
+ const entity = entityType ? entityType.name : fieldName;
587
+ switch (op) {
588
+ case 'single':
589
+ return `Get ${entity}`;
590
+ case 'list':
591
+ return `List ${entity}`;
592
+ case 'aggregate':
593
+ return `Aggregate ${entity}`;
594
+ case 'add':
595
+ return `Create ${entity}`;
596
+ case 'update':
597
+ return `Update ${entity}`;
598
+ case 'delete':
599
+ return `Delete ${entity}`;
600
+ default:
601
+ return fieldName;
602
+ }
603
+ };
604
+
605
+ const buildAnnotations = (op, title) => {
606
+ const base = { title, openWorldHint: false };
607
+ switch (op) {
608
+ case 'single':
609
+ case 'list':
610
+ case 'aggregate':
611
+ return { ...base, readOnlyHint: true };
612
+ case 'update':
613
+ return { ...base, readOnlyHint: false, idempotentHint: true };
614
+ case 'delete':
615
+ return {
616
+ ...base, readOnlyHint: false, destructiveHint: true, idempotentHint: true,
617
+ };
618
+ default:
619
+ return { ...base, readOnlyHint: false };
620
+ }
621
+ };
622
+
623
+ /** Normalize an option that accepts a single value or an array into an array. */
624
+ const toArray = (value) => {
625
+ if (value == null) {
626
+ return null;
627
+ }
628
+ return Array.isArray(value) ? value : [value];
629
+ };
630
+
631
+ const matchesSelector = (selectors, names, kind) => selectors
632
+ .some((selector) => selector === kind || names.includes(selector));
633
+
634
+ const isIncluded = ({
635
+ names, kind, entityName, include, exclude, includeTypes, excludeTypes,
636
+ }) => {
637
+ if (exclude && matchesSelector(exclude, names, kind)) {
638
+ return false;
639
+ }
640
+ if (excludeTypes && entityName && excludeTypes.includes(entityName)) {
641
+ return false;
642
+ }
643
+ if (include && !matchesSelector(include, names, kind)) {
644
+ return false;
645
+ }
646
+ if (includeTypes && !(entityName && includeTypes.includes(entityName))) {
647
+ return false;
648
+ }
649
+ return true;
650
+ };
651
+
652
+ /**
653
+ * Combine abort signals into one (Node 18-compatible fallback for
654
+ * AbortSignal.any).
655
+ * @param {Array<AbortSignal|undefined>} signals
656
+ * @returns {AbortSignal|undefined}
657
+ */
658
+ const combineAbortSignals = (signals) => {
659
+ const list = signals.filter(Boolean);
660
+ if (list.length === 0) {
661
+ return undefined;
662
+ }
663
+ if (list.length === 1) {
664
+ return list[0];
665
+ }
666
+ if (typeof AbortSignal.any === 'function') {
667
+ return AbortSignal.any(list);
668
+ }
669
+ const controller = new AbortController();
670
+ for (const signal of list) {
671
+ if (signal.aborted) {
672
+ controller.abort(signal.reason);
673
+ break;
674
+ }
675
+ signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true });
676
+ }
677
+ return controller.signal;
678
+ };
679
+
680
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
681
+
682
+ const remoteTransportError = (message, code, extra = {}) => ({
683
+ errors: [{ message, extensions: { code, ...extra } }],
684
+ });
685
+
686
+ /**
687
+ * Execute a GraphQL operation against a remote HTTP endpoint. Transport
688
+ * failures (network errors, timeouts, non-2xx statuses, non-JSON bodies) are
689
+ * mapped to a GraphQL-shaped `{ errors }` result so they surface to the MCP
690
+ * client as a regular `isError` tool result instead of an opaque exception.
691
+ * Network errors, timeouts, HTTP 5xx and 429 are retried (queries only) when
692
+ * `execution.retry` is configured.
693
+ * @param {string} query
694
+ * @param {Object} variables
695
+ * @param {{ endpoint: string, headers?: Object, timeoutMs?: number,
696
+ * retry?: { attempts?: number, backoffMs?: number } }} execution
697
+ * @param {{ signal?: AbortSignal, canRetry?: boolean }} [callOptions]
698
+ * @returns {Promise<Object>} GraphQL response body (or synthesized errors)
699
+ */
700
+ const executeRemote = async (query, variables, execution, { signal, canRetry } = {}) => {
701
+ const retry = execution.retry || {};
702
+ // Coerce retry knobs defensively: NaN/strings/negatives must not disable the
703
+ // loop (returning undefined) or multiply attempts ('2' + 1 === '21').
704
+ const extraAttempts = Number.isFinite(Number(retry.attempts)) && Number(retry.attempts) > 0
705
+ ? Math.floor(Number(retry.attempts))
706
+ : 0;
707
+ const maxAttempts = canRetry ? extraAttempts + 1 : 1;
708
+ const backoffMs = Number.isFinite(Number(retry.backoffMs)) && Number(retry.backoffMs) >= 0
709
+ ? Number(retry.backoffMs)
710
+ : 250;
711
+ let lastFailure;
712
+
713
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
714
+ if (attempt > 1) {
715
+ await sleep(backoffMs * (attempt - 1));
716
+ }
717
+ const requestSignal = combineAbortSignals([
718
+ signal,
719
+ execution.timeoutMs ? AbortSignal.timeout(execution.timeoutMs) : undefined,
720
+ ]);
721
+
722
+ let response;
723
+ try {
724
+ response = await globalThis.fetch(execution.endpoint, {
725
+ method: 'POST',
726
+ headers: { 'content-type': 'application/json', ...(execution.headers || {}) },
727
+ body: JSON.stringify({ query, variables }),
728
+ signal: requestSignal,
729
+ });
730
+ } catch (err) {
731
+ if (signal && signal.aborted) {
732
+ throw err;
733
+ }
734
+ lastFailure = remoteTransportError(
735
+ `GraphQL endpoint request failed: ${err && err.message ? err.message : err}`,
736
+ 'MCP_REMOTE_REQUEST_FAILED',
737
+ );
738
+ continue;
739
+ }
740
+
741
+ if (!response.ok) {
742
+ // GraphQL-over-HTTP servers commonly reply 400 with a real GraphQL
743
+ // errors body (validation/coercion failures) — pass that through
744
+ // verbatim instead of masking it with a synthesized transport error.
745
+ let errorBody;
746
+ try {
747
+ errorBody = await response.json();
748
+ } catch {
749
+ errorBody = undefined;
750
+ }
751
+ const isGraphQLBody = !!errorBody && (Array.isArray(errorBody.errors) || errorBody.data !== undefined);
752
+ lastFailure = isGraphQLBody ? errorBody : remoteTransportError(
753
+ `GraphQL endpoint responded with HTTP ${response.status}`,
754
+ 'MCP_REMOTE_HTTP_ERROR',
755
+ { status: response.status },
756
+ );
757
+ if (response.status >= 500 || response.status === 429) {
758
+ continue;
759
+ }
760
+ return lastFailure;
761
+ }
762
+
763
+ let body;
764
+ try {
765
+ body = await response.json();
766
+ } catch {
767
+ return remoteTransportError('GraphQL endpoint returned a non-JSON response', 'MCP_REMOTE_INVALID_RESPONSE');
768
+ }
769
+ if (!body || (body.data === undefined && !body.errors)) {
770
+ return remoteTransportError('GraphQL endpoint returned an unexpected payload (no data or errors)', 'MCP_REMOTE_INVALID_RESPONSE');
771
+ }
772
+ return body;
773
+ }
774
+ return lastFailure;
775
+ };
776
+
777
+ const resolveExecution = (execution) => {
778
+ const resolved = execution ?? { mode: 'in-process' };
779
+ if (typeof resolved !== 'object' || Array.isArray(resolved)) {
780
+ throw new SimfinityError(`Invalid MCP execution config: expected an object, got ${Array.isArray(resolved) ? 'array' : typeof resolved}`, 'MCP_INVALID_EXECUTION_MODE', 500);
781
+ }
782
+ const mode = resolved.mode || 'in-process';
783
+ if (mode === 'remote' && !resolved.endpoint) {
784
+ throw new SimfinityError('execution.endpoint is required when execution.mode is "remote"', 'MCP_MISSING_ENDPOINT', 500);
785
+ }
786
+ if (mode !== 'remote' && mode !== 'in-process') {
787
+ throw new SimfinityError(`Unknown MCP execution mode: ${mode}`, 'MCP_INVALID_EXECUTION_MODE', 500);
788
+ }
789
+ return { mode, execution: resolved };
790
+ };
791
+
792
+ /**
793
+ * Apply Envelop-style schema plugins (e.g. simfinity's createAuthPlugin) to the
794
+ * schema by invoking their `onSchemaChange` hooks. In a regular GraphQL server
795
+ * Envelop fires these hooks at startup; standalone MCP servers execute via bare
796
+ * `graphql()` and would otherwise silently skip resolver-wrapping plugins such
797
+ * as field-level auth.
798
+ * @param {import('graphql').GraphQLSchema} schema
799
+ * @param {Array<Object>} schemaPlugins
800
+ */
801
+ const applySchemaPlugins = (schema, schemaPlugins) => {
802
+ for (const plugin of schemaPlugins || []) {
803
+ if (plugin && typeof plugin.onSchemaChange === 'function') {
804
+ plugin.onSchemaChange({ schema, replaceSchema: () => {} });
805
+ }
806
+ }
807
+ };
808
+
809
+ /**
810
+ * Compose `toolMiddleware` functions (koa-style `(call, next)`) around the
811
+ * terminal executor. Middleware may inspect/modify `call.args`, short-circuit
812
+ * by returning a result without calling `next()`, or throw.
813
+ * @param {Array<Function>|undefined} middlewares
814
+ * @param {Function} terminal `(call) => Promise<CallToolResult>`
815
+ * @returns {Function}
816
+ */
817
+ const composeToolMiddleware = (middlewares, terminal) => {
818
+ if (!middlewares || middlewares.length === 0) {
819
+ return terminal;
820
+ }
821
+ return async (call) => {
822
+ let lastIndex = -1;
823
+ const dispatch = (i) => {
824
+ if (i <= lastIndex) {
825
+ return Promise.reject(new SimfinityError('next() called multiple times in MCP tool middleware', 'MCP_MIDDLEWARE_ERROR', 500));
826
+ }
827
+ lastIndex = i;
828
+ if (i === middlewares.length) {
829
+ return Promise.resolve(terminal(call));
830
+ }
831
+ return Promise.resolve(middlewares[i](call, () => dispatch(i + 1)));
832
+ };
833
+ const result = await dispatch(0);
834
+ if (result === undefined) {
835
+ // A middleware performed side effects but neither returned a result nor
836
+ // called next(); fail loudly at the source instead of sending an
837
+ // undefined CallToolResult over the wire.
838
+ throw new SimfinityError(`MCP tool middleware for "${call.name}" returned undefined (did it forget to return next()?)`, 'MCP_MIDDLEWARE_ERROR', 500);
839
+ }
840
+ return result;
841
+ };
842
+ };
843
+
844
+ const limitErrorResult = (message, code) => ({
845
+ content: [{
846
+ type: 'text',
847
+ text: JSON.stringify({ errors: [{ message, extensions: { code } }] }, null, 2),
848
+ }],
849
+ isError: true,
850
+ });
851
+
852
+ /**
853
+ * Build the `callTool` executor bound to a given execution mode and context.
854
+ * @param {Object} params
855
+ * @param {import('graphql').GraphQLSchema} params.schema
856
+ * @param {Object} params.toolIndex operation index from {@link buildToolDefinitions}
857
+ * @param {'in-process'|'remote'} params.mode
858
+ * @param {Object} params.execution
859
+ * @param {Object|Function} params.context GraphQL context value or factory
860
+ * @param {Object} [params.limits] result/pagination guardrails
861
+ * @param {Array<Function>} [params.toolMiddleware]
862
+ * @returns {Function} async `(name, args, extra) => CallToolResult`
863
+ */
864
+ const createCallTool = ({
865
+ schema, toolIndex, mode, execution, context, limits = {}, toolMiddleware,
866
+ }) => {
867
+ if (limits.defaultPagination && limits.maxPageSize
868
+ && typeof limits.defaultPagination.size === 'number'
869
+ && limits.defaultPagination.size > limits.maxPageSize) {
870
+ // Fail at setup: otherwise every unpaginated call would get an isError
871
+ // blaming the caller for a page size the server itself injected.
872
+ throw new SimfinityError(`limits.defaultPagination.size (${limits.defaultPagination.size}) exceeds limits.maxPageSize (${limits.maxPageSize})`, 'MCP_INVALID_LIMITS', 500);
873
+ }
874
+
875
+ const terminal = async (call) => {
876
+ const entry = toolIndex[call.name];
877
+ if (!entry) {
878
+ throw new SimfinityError(`Unknown MCP tool: ${call.name}`, 'MCP_TOOL_NOT_FOUND', 404);
879
+ }
880
+ if (call.extra && call.extra.signal && call.extra.signal.aborted) {
881
+ throw new SimfinityError(`MCP tool call cancelled: ${call.name}`, 'MCP_CALL_CANCELLED', 499);
882
+ }
883
+
884
+ let args = call.args || {};
885
+ if (entry.hasPagination) {
886
+ if (limits.defaultPagination && args.pagination == null) {
887
+ args = { ...args, pagination: { ...limits.defaultPagination } };
888
+ }
889
+ const size = args.pagination ? args.pagination.size : undefined;
890
+ if (limits.maxPageSize && typeof size === 'number' && size > limits.maxPageSize) {
891
+ return limitErrorResult(
892
+ `pagination.size ${size} exceeds the maximum allowed page size ${limits.maxPageSize}. Request a smaller page.`,
893
+ 'MCP_PAGE_SIZE_EXCEEDED',
894
+ );
895
+ }
896
+ }
897
+
898
+ let result;
899
+ let contextValue;
900
+ let wantsCount = false;
901
+ if (mode === 'remote') {
902
+ result = await executeRemote(entry.operation, args, execution, {
903
+ signal: call.extra ? call.extra.signal : undefined,
904
+ canRetry: entry.kind === 'query',
905
+ });
906
+ } else {
907
+ contextValue = typeof context === 'function' ? await context(call.extra) : context;
908
+ // Counted list calls get a private context layer: simfinity's find
909
+ // resolver writes context.count, and reading it back from a context
910
+ // object shared across calls would race concurrent calls and surface
911
+ // stale counts on operations (e.g. aggregates) that never write one.
912
+ wantsCount = entry.op === 'list'
913
+ && !!(args.pagination && args.pagination.count === true)
914
+ && contextValue !== null && typeof contextValue === 'object';
915
+ if (wantsCount) {
916
+ contextValue = Object.create(contextValue);
917
+ }
918
+ result = await graphql({
919
+ schema,
920
+ source: entry.operation,
921
+ variableValues: args,
922
+ contextValue,
923
+ });
924
+ }
925
+
926
+ const isError = !!(result.errors && result.errors.length);
927
+ if (isError) {
928
+ const payload = { errors: result.errors };
929
+ if (result.data != null) {
930
+ // Keep partial results visible alongside the errors.
931
+ payload.data = result.data;
932
+ }
933
+ return {
934
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
935
+ isError: true,
936
+ };
937
+ }
938
+
939
+ const text = JSON.stringify(result.data, null, 2);
940
+ if (limits.maxResultBytes) {
941
+ const bytes = Buffer.byteLength(text, 'utf8');
942
+ if (bytes > limits.maxResultBytes) {
943
+ return limitErrorResult(
944
+ `Result of ${bytes} bytes exceeds maxResultBytes (${limits.maxResultBytes}). Narrow the query, lower selectionDepth or paginate.`,
945
+ 'MCP_RESULT_TOO_LARGE',
946
+ );
947
+ }
948
+ }
949
+
950
+ const response = {
951
+ content: [{ type: 'text', text }],
952
+ isError: false,
953
+ };
954
+ if (result.data != null) {
955
+ response.structuredContent = result.data;
956
+ }
957
+ const count = mode === 'remote'
958
+ ? (result.extensions && typeof result.extensions.count === 'number' ? result.extensions.count : undefined)
959
+ // Own-property check: only a count written DURING this call counts.
960
+ : (wantsCount && Object.hasOwn(contextValue, 'count') && typeof contextValue.count === 'number' ? contextValue.count : undefined);
961
+ if (count !== undefined) {
962
+ response._meta = { count };
963
+ }
964
+ return response;
965
+ };
966
+
967
+ const run = composeToolMiddleware(toolMiddleware, terminal);
968
+
969
+ return async (name, args = {}, extra) => {
970
+ const entry = toolIndex[name];
971
+ if (!entry) {
972
+ throw new SimfinityError(`Unknown MCP tool: ${name}`, 'MCP_TOOL_NOT_FOUND', 404);
973
+ }
974
+ return run({
975
+ name, args, extra, kind: entry.kind, operation: entry.operation,
976
+ });
977
+ };
978
+ };
979
+
980
+ /**
981
+ * Build the context-independent tool definitions and the operation index from a
982
+ * Simfinity-generated GraphQLSchema. Shared by {@link generateMCPTools} and the
983
+ * transport factories so the (potentially expensive) schema traversal happens
984
+ * once, while execution context can be bound per request.
985
+ * @param {import('graphql').GraphQLSchema} schema
986
+ * @param {Object} [options]
987
+ * @returns {{ tools: Array, toolIndex: Object }}
988
+ */
989
+ const buildToolDefinitions = (schema, options = {}) => {
990
+ if (!schema || typeof schema.getQueryType !== 'function') {
991
+ throw new SimfinityError('A valid GraphQLSchema is required to generate MCP tools', 'MCP_INVALID_SCHEMA', 500);
992
+ }
993
+
994
+ const {
995
+ selectionDepth = 1,
996
+ includeId = true,
997
+ toolNamePrefix = '',
998
+ toolOverrides = {},
999
+ } = options;
1000
+ const include = toArray(options.include);
1001
+ const exclude = toArray(options.exclude);
1002
+ const includeTypes = toArray(options.includeTypes);
1003
+ const excludeTypes = toArray(options.excludeTypes);
1004
+
1005
+ if (toolNamePrefix && !/^[a-zA-Z0-9_-]+$/.test(toolNamePrefix)) {
1006
+ throw new SimfinityError(`Invalid toolNamePrefix "${toolNamePrefix}": only letters, digits, underscore and hyphen are allowed`, 'MCP_INVALID_TOOL_NAME', 500);
1007
+ }
1008
+
1009
+ const queryType = schema.getQueryType();
1010
+
1011
+ const resolveEntityType = (op, fieldName, field) => {
1012
+ if (op === 'aggregate' && queryType) {
1013
+ const base = fieldName.replace(/_aggregate$/, '');
1014
+ const sibling = queryType.getFields()[base];
1015
+ if (sibling) {
1016
+ const named = getNamedType(sibling.type);
1017
+ return named instanceof GraphQLObjectType ? named : null;
1018
+ }
1019
+ return null;
1020
+ }
1021
+ const named = getNamedType(field.type);
1022
+ return named instanceof GraphQLObjectType ? named : null;
1023
+ };
1024
+
1025
+ const tools = [];
1026
+ const toolIndex = Object.create(null);
1027
+
1028
+ const addToolsFromType = (rootType, kind) => {
1029
+ if (!rootType) {
1030
+ return;
1031
+ }
1032
+ for (const [fieldName, field] of Object.entries(rootType.getFields())) {
1033
+ const toolName = `${toolNamePrefix}${fieldName}`;
1034
+ const op = classifyOperation(kind, fieldName, field);
1035
+ const entityType = resolveEntityType(op, fieldName, field);
1036
+ if (!isIncluded({
1037
+ names: [fieldName, toolName],
1038
+ kind,
1039
+ entityName: entityType ? entityType.name : null,
1040
+ include,
1041
+ exclude,
1042
+ includeTypes,
1043
+ excludeTypes,
1044
+ })) {
1045
+ continue;
1046
+ }
1047
+ if (!TOOL_NAME_RE.test(toolName)) {
1048
+ throw new SimfinityError(`Generated MCP tool name "${toolName}" is invalid (allowed: letters, digits, underscore, hyphen; max 128 chars)`, 'MCP_INVALID_TOOL_NAME', 500);
1049
+ }
1050
+ if (toolIndex[toolName]) {
1051
+ console.warn(`[simfinity-mcp] Duplicate tool name "${toolName}": ${kind} field "${fieldName}" collides with an existing ${toolIndex[toolName].kind} tool and is skipped — calls to "${toolName}" execute the ${toolIndex[toolName].kind}. (Before 2.7.0 both were listed and the ${kind} silently won.) Rename one of the GraphQL fields or use include/exclude to disambiguate.`);
1052
+ continue;
1053
+ }
1054
+
1055
+ const override = toolOverrides[toolName] || toolOverrides[fieldName] || {};
1056
+ const depth = override.selectionDepth ?? selectionDepth;
1057
+ const withId = override.includeId ?? includeId;
1058
+ const title = override.title || buildToolTitle(op, fieldName, entityType);
1059
+ const description = override.description || buildToolDescription(op, fieldName, field, entityType);
1060
+ const annotations = { ...buildAnnotations(op, title), ...(override.annotations || {}) };
1061
+ const selection = override.selection
1062
+ ? normalizeSelection(override.selection)
1063
+ : buildSelectionSet(field.type, depth, withId);
1064
+
1065
+ const tool = {
1066
+ name: toolName,
1067
+ title,
1068
+ description,
1069
+ inputSchema: graphqlArgsToJSONSchema(field),
1070
+ annotations,
1071
+ kind,
1072
+ };
1073
+ // An explicit selection override makes the mirrored output schema
1074
+ // unreliable, so it is omitted rather than published wrong.
1075
+ if (!override.selection) {
1076
+ tool.outputSchema = buildOutputSchema(fieldName, field, depth, withId);
1077
+ }
1078
+ tools.push(tool);
1079
+ toolIndex[toolName] = {
1080
+ operation: buildOperation(kind, fieldName, field, selection),
1081
+ kind,
1082
+ op,
1083
+ fieldName,
1084
+ hasPagination: hasArg(field, 'pagination'),
1085
+ };
1086
+ }
1087
+ };
1088
+
1089
+ addToolsFromType(queryType, 'query');
1090
+ addToolsFromType(schema.getMutationType(), 'mutation');
1091
+
1092
+ return { tools, toolIndex };
1093
+ };
1094
+
1095
+ /**
1096
+ * Generate MCP tool definitions and an executor from a Simfinity-generated
1097
+ * GraphQLSchema. Every root Query and Mutation field becomes a tool whose name
1098
+ * matches the GraphQL field name (e.g. `addbook`, `books`, `process_order`),
1099
+ * optionally prefixed via `toolNamePrefix`. Each tool carries a `title`, an
1100
+ * actionable `description` (reusing GraphQL type descriptions), an
1101
+ * `inputSchema`, an `outputSchema` and behavioral `annotations`.
1102
+ *
1103
+ * @param {import('graphql').GraphQLSchema} schema schema returned by createSchema()
1104
+ * @param {Object} [options]
1105
+ * @param {{ mode?: 'in-process'|'remote', endpoint?: string, headers?: Object,
1106
+ * timeoutMs?: number, retry?: { attempts?: number, backoffMs?: number } }} [options.execution]
1107
+ * execution strategy. Defaults to in-process execution against `schema`.
1108
+ * `timeoutMs` aborts slow remote requests; `retry` re-issues failed remote
1109
+ * queries (never mutations) after transport failures, HTTP 5xx or 429.
1110
+ * @param {Object|Function} [options.context] GraphQL context value (or a
1111
+ * `(extra) => context` factory) passed through to in-process execution.
1112
+ * @param {string|string[]} [options.include] only expose these tool/field names
1113
+ * or categories ('query' / 'mutation').
1114
+ * @param {string|string[]} [options.exclude] never expose these tool/field
1115
+ * names or categories.
1116
+ * @param {string|string[]} [options.includeTypes] only expose tools whose
1117
+ * entity (return) type has one of these names.
1118
+ * @param {string|string[]} [options.excludeTypes] never expose tools whose
1119
+ * entity (return) type has one of these names.
1120
+ * @param {number} [options.selectionDepth=1] nesting depth for the auto-generated
1121
+ * output selection set and output schema.
1122
+ * @param {boolean} [options.includeId=true] always select `id` when nothing else
1123
+ * is selectable on an object type.
1124
+ * @param {string} [options.toolNamePrefix] prefix applied to every published
1125
+ * tool name (e.g. 'catalog_'), for disambiguating multiple servers.
1126
+ * @param {Object} [options.toolOverrides] per-tool overrides keyed by tool (or
1127
+ * field) name: `{ description, title, annotations, selectionDepth, includeId,
1128
+ * selection }`. An explicit `selection` replaces the generated selection set
1129
+ * (and omits the outputSchema, which could no longer be guaranteed accurate).
1130
+ * @param {Array<Function>} [options.toolMiddleware] koa-style
1131
+ * `(call, next) => result` functions run around every tool execution;
1132
+ * `call` is `{ name, args, extra, kind, operation }`.
1133
+ * @param {{ maxPageSize?: number, defaultPagination?: Object,
1134
+ * maxResultBytes?: number }} [options.limits] guardrails: reject oversized
1135
+ * pages/results, inject default pagination when the caller sends none.
1136
+ * @param {Array<Object>} [options.schemaPlugins] Envelop-style plugins (e.g.
1137
+ * simfinity's createAuthPlugin) whose `onSchemaChange` hook is applied before
1138
+ * serving, so resolver-wrapping plugins also apply to in-process execution.
1139
+ * @returns {{ tools: Array, callTool: Function, getOperation: Function }}
1140
+ */
1141
+ export const generateMCPTools = (schema, options = {}) => {
1142
+ const { mode, execution } = resolveExecution(options.execution);
1143
+ if (mode === 'in-process') {
1144
+ applySchemaPlugins(schema, options.schemaPlugins);
1145
+ }
1146
+ const { tools, toolIndex } = buildToolDefinitions(schema, options);
1147
+ const callTool = createCallTool({
1148
+ schema,
1149
+ toolIndex,
1150
+ mode,
1151
+ execution,
1152
+ context: options.context ?? {},
1153
+ limits: options.limits,
1154
+ toolMiddleware: options.toolMiddleware,
1155
+ });
1156
+ const getOperation = (name) => {
1157
+ const entry = toolIndex[name];
1158
+ if (!entry) {
1159
+ throw new SimfinityError(`Unknown MCP tool: ${name}`, 'MCP_TOOL_NOT_FOUND', 404);
1160
+ }
1161
+ return entry.operation;
1162
+ };
1163
+ return { tools, callTool, getOperation };
1164
+ };
1165
+
1166
+ const isModuleNotFound = (err) => !!err && (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'MODULE_NOT_FOUND');
1167
+
1168
+ const sdkNotInstalledError = (what) => new SimfinityError(
1169
+ `The optional dependency "@modelcontextprotocol/sdk" is required for ${what}. Install it with: npm install @modelcontextprotocol/sdk`,
1170
+ 'MCP_SDK_NOT_INSTALLED',
1171
+ 500,
1172
+ );
1173
+
1174
+ const sdkLoadFailedError = (err) => new SimfinityError(
1175
+ `Failed to load "@modelcontextprotocol/sdk": ${err && err.message ? err.message : err}`,
1176
+ 'MCP_SDK_LOAD_FAILED',
1177
+ 500,
1178
+ );
1179
+
1180
+ const sdkIncompatibleError = (what) => new SimfinityError(
1181
+ `The installed "@modelcontextprotocol/sdk" does not provide ${what}; upgrade it with: npm install @modelcontextprotocol/sdk@latest`,
1182
+ 'MCP_SDK_INCOMPATIBLE',
1183
+ 500,
1184
+ );
1185
+
1186
+ const loadSdkCore = async () => {
1187
+ try {
1188
+ const [serverModule, typesModule] = await Promise.all([
1189
+ import('@modelcontextprotocol/sdk/server/index.js'),
1190
+ import('@modelcontextprotocol/sdk/types.js'),
1191
+ ]);
1192
+ return {
1193
+ Server: serverModule.Server,
1194
+ ListToolsRequestSchema: typesModule.ListToolsRequestSchema,
1195
+ CallToolRequestSchema: typesModule.CallToolRequestSchema,
1196
+ };
1197
+ } catch (err) {
1198
+ if (isModuleNotFound(err)) {
1199
+ throw sdkNotInstalledError('MCP servers');
1200
+ }
1201
+ throw sdkLoadFailedError(err);
1202
+ }
1203
+ };
1204
+
1205
+ const newServerInstance = (sdk, tools, callTool, options) => {
1206
+ const server = new sdk.Server(
1207
+ {
1208
+ name: options.serverName || 'simfinity-mcp',
1209
+ version: options.serverVersion || '1.0.0',
1210
+ },
1211
+ { capabilities: { tools: {} } },
1212
+ );
1213
+
1214
+ server.setRequestHandler(sdk.ListToolsRequestSchema, async () => ({
1215
+ tools: tools.map((tool) => {
1216
+ const published = {
1217
+ name: tool.name,
1218
+ title: tool.title,
1219
+ description: tool.description,
1220
+ inputSchema: tool.inputSchema,
1221
+ annotations: tool.annotations,
1222
+ };
1223
+ if (tool.outputSchema) {
1224
+ published.outputSchema = tool.outputSchema;
1225
+ }
1226
+ return published;
1227
+ }),
1228
+ }));
1229
+
1230
+ server.setRequestHandler(sdk.CallToolRequestSchema, async (request, extra) => {
1231
+ const { name, arguments: args } = request.params;
1232
+ return callTool(name, args || {}, extra);
1233
+ });
1234
+
1235
+ return server;
1236
+ };
1237
+
1238
+ /**
1239
+ * Create a transport-agnostic MCP Server exposing every GraphQL operation as a
1240
+ * tool. Requires the optional `@modelcontextprotocol/sdk` dependency.
1241
+ * @param {import('graphql').GraphQLSchema} schema
1242
+ * @param {Object} [options] same options as {@link generateMCPTools}, plus
1243
+ * `serverName` and `serverVersion`.
1244
+ * @returns {Promise<Object>} an SDK Server instance (call `server.connect(transport)`)
1245
+ */
1246
+ export const createMCPServer = async (schema, options = {}) => {
1247
+ const sdk = await loadSdkCore();
1248
+ const { tools, callTool } = generateMCPTools(schema, options);
1249
+ return newServerInstance(sdk, tools, callTool, options);
1250
+ };
1251
+
1252
+ /**
1253
+ * Create an MCP Server and connect it over stdio. Use for a standalone MCP
1254
+ * executable (e.g. for Cursor / Claude Desktop).
1255
+ * @param {import('graphql').GraphQLSchema} schema
1256
+ * @param {Object} [options] same options as {@link createMCPServer}.
1257
+ * @returns {Promise<Object>} the connected SDK Server instance
1258
+ */
1259
+ export const startStdioMCPServer = async (schema, options = {}) => {
1260
+ // Load the core first so a missing SDK reports MCP_SDK_NOT_INSTALLED, while a
1261
+ // present-but-old SDK missing the transport reports MCP_SDK_INCOMPATIBLE.
1262
+ const server = await createMCPServer(schema, options);
1263
+
1264
+ let StdioServerTransport;
1265
+ try {
1266
+ ({ StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'));
1267
+ } catch (err) {
1268
+ if (isModuleNotFound(err)) {
1269
+ throw sdkIncompatibleError('the stdio transport');
1270
+ }
1271
+ throw sdkLoadFailedError(err);
1272
+ }
1273
+
1274
+ const transport = new StdioServerTransport();
1275
+ await server.connect(transport);
1276
+ return server;
1277
+ };
1278
+
1279
+ /**
1280
+ * Create an Express-style request handler that serves the MCP over Streamable
1281
+ * HTTP, suitable for mounting next to the existing GraphQL endpoint. A fresh,
1282
+ * stateless server + transport pair is created per request.
1283
+ *
1284
+ * The tool definitions are built once; the execution context is resolved per
1285
+ * request. When `options.context` is a function it is invoked as
1286
+ * `(req, extra) => context` for every call, where `req` is the Express request,
1287
+ * enabling per-request authentication (e.g. reading an Authorization header).
1288
+ *
1289
+ * @param {import('graphql').GraphQLSchema} schema
1290
+ * @param {Object} [options] same options as {@link createMCPServer}; in HTTP mode
1291
+ * a function `context` receives `(req, extra)`. Additionally:
1292
+ * @param {Object} [options.transportOptions] extra options passed to the SDK's
1293
+ * StreamableHTTPServerTransport (e.g. `enableDnsRebindingProtection`,
1294
+ * `allowedHosts`, `allowedOrigins` — recommended for localhost deployments).
1295
+ * @param {Function} [options.onError] `(err, req, res)` invoked when the
1296
+ * handler fails; the handler then responds 500 (JSON-RPC internal error) if
1297
+ * headers were not already sent.
1298
+ * @returns {Promise<Function>} async `(req, res) => {}` handler
1299
+ */
1300
+ export const createHTTPMCPHandler = async (schema, options = {}) => {
1301
+ const sdk = await loadSdkCore();
1302
+ let StreamableHTTPServerTransport;
1303
+ try {
1304
+ ({ StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js'));
1305
+ } catch (err) {
1306
+ if (isModuleNotFound(err)) {
1307
+ throw sdkIncompatibleError('the Streamable HTTP transport');
1308
+ }
1309
+ throw sdkLoadFailedError(err);
1310
+ }
1311
+
1312
+ const { mode, execution } = resolveExecution(options.execution);
1313
+ if (mode === 'in-process') {
1314
+ applySchemaPlugins(schema, options.schemaPlugins);
1315
+ }
1316
+ const { tools, toolIndex } = buildToolDefinitions(schema, options);
1317
+
1318
+ return async (req, res) => {
1319
+ try {
1320
+ const context = typeof options.context === 'function'
1321
+ ? (extra) => options.context(req, extra)
1322
+ : (options.context ?? {});
1323
+ const callTool = createCallTool({
1324
+ schema,
1325
+ toolIndex,
1326
+ mode,
1327
+ execution,
1328
+ context,
1329
+ limits: options.limits,
1330
+ toolMiddleware: options.toolMiddleware,
1331
+ });
1332
+ const server = newServerInstance(sdk, tools, callTool, options);
1333
+ const transport = new StreamableHTTPServerTransport({
1334
+ sessionIdGenerator: undefined,
1335
+ ...(options.transportOptions || {}),
1336
+ });
1337
+ res.on('close', () => {
1338
+ transport.close();
1339
+ server.close();
1340
+ });
1341
+ await server.connect(transport);
1342
+ await transport.handleRequest(req, res, req.body);
1343
+ } catch (err) {
1344
+ if (typeof options.onError === 'function') {
1345
+ try {
1346
+ await options.onError(err, req, res);
1347
+ } catch {
1348
+ // onError (sync or async) must never mask the original failure handling
1349
+ }
1350
+ }
1351
+ if (!res.headersSent) {
1352
+ res.writeHead(500, { 'content-type': 'application/json' });
1353
+ res.end(JSON.stringify({
1354
+ jsonrpc: '2.0',
1355
+ error: { code: -32603, message: 'Internal server error' },
1356
+ id: null,
1357
+ }));
1358
+ } else {
1359
+ try {
1360
+ res.end();
1361
+ } catch {
1362
+ // socket already gone
1363
+ }
1364
+ }
1365
+ }
1366
+ };
1367
+ };
1368
+
1369
+ const mcp = {
1370
+ generateMCPTools,
1371
+ graphqlArgsToJSONSchema,
1372
+ createMCPServer,
1373
+ startStdioMCPServer,
1374
+ createHTTPMCPHandler,
1375
+ };
1376
+
1377
+ export default mcp;