@simtlix/simfinity-js 3.0.1 → 3.2.0
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/README.md +150 -83
- package/package.json +17 -4
- package/src/auth/errors.js +2 -44
- package/src/auth/expressions.js +2 -273
- package/src/auth/index.js +3 -390
- package/src/auth/register-mongo-object-id.js +7 -0
- package/src/auth/rules.js +3 -273
- package/src/const/QLOperator.js +1 -39
- package/src/const/QLSort.js +1 -28
- package/src/const/QLValue.js +1 -39
- package/src/errors/internal-server.error.js +1 -11
- package/src/errors/simfinity.error.js +1 -15
- package/src/index.js +34 -1991
- package/src/mcp.js +2 -1377
- package/src/mongo/adapter.js +111 -0
- package/src/mongo/models.js +160 -0
- package/src/mongo/queries.js +570 -0
- package/src/mongo/transactions.js +81 -0
- package/src/plugins.js +2 -53
- package/src/scalars.js +2 -188
- package/src/validators.js +2 -250
- package/types/index.d.ts +65 -480
package/src/mcp.js
CHANGED
|
@@ -1,1377 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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;
|
|
1
|
+
export * from '@simtlix/simfinity-mcp';
|
|
2
|
+
export { default } from '@simtlix/simfinity-mcp';
|