@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/README.md +318 -3
- package/package.json +4 -2
- package/src/index.js +31 -1
- package/src/mcp.js +1377 -0
- package/types/index.d.ts +613 -0
package/README.md
CHANGED
|
@@ -53,6 +53,18 @@ A powerful Node.js framework that automatically generates GraphQL schemas from y
|
|
|
53
53
|
- [Custom Mutations](#custom-mutations)
|
|
54
54
|
- [Working with Existing Mongoose Models](#working-with-existing-mongoose-models)
|
|
55
55
|
- [Programmatic Data Access](#programmatic-data-access)
|
|
56
|
+
- [MCP Generation](#-mcp-generation)
|
|
57
|
+
- [Generating tool definitions](#generating-tool-definitions)
|
|
58
|
+
- [Standalone MCP server (stdio)](#standalone-mcp-server-stdio)
|
|
59
|
+
- [HTTP MCP endpoint (alongside GraphQL)](#http-mcp-endpoint-alongside-graphql)
|
|
60
|
+
- [Options](#options)
|
|
61
|
+
- [Selecting and naming tools](#selecting-and-naming-tools)
|
|
62
|
+
- [Customizing tools](#customizing-tools)
|
|
63
|
+
- [Limits](#limits)
|
|
64
|
+
- [Tool middleware](#tool-middleware)
|
|
65
|
+
- [Remote execution](#remote-execution)
|
|
66
|
+
- [Tool results and errors](#tool-results-and-errors)
|
|
67
|
+
- [Authentication](#authentication)
|
|
56
68
|
- [Aggregation Queries](#-aggregation-queries)
|
|
57
69
|
- [Complete Example](#-complete-example)
|
|
58
70
|
- [Resources](#-resources)
|
|
@@ -352,6 +364,25 @@ query {
|
|
|
352
364
|
}
|
|
353
365
|
```
|
|
354
366
|
|
|
367
|
+
As an equivalent shorthand, you can pass the full dotted path in `field` and omit `path`:
|
|
368
|
+
|
|
369
|
+
```graphql
|
|
370
|
+
query {
|
|
371
|
+
books(
|
|
372
|
+
OR: [
|
|
373
|
+
{ conditions: [{ field: "author.name", operator: LIKE, value: "Adams" }] }
|
|
374
|
+
{ conditions: [{ field: "author.name", operator: LIKE, value: "Pratchett" }] }
|
|
375
|
+
]
|
|
376
|
+
) {
|
|
377
|
+
id
|
|
378
|
+
title
|
|
379
|
+
author { name }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Both forms produce the same query. Multi-segment paths work too — e.g. `field: "author.country.code"` is equivalent to `field: "author", path: "country.code"`. Supplying both a dotted `field` and a separate `path` is rejected as ambiguous.
|
|
385
|
+
|
|
355
386
|
#### Mixing Flat Filters with AND/OR
|
|
356
387
|
|
|
357
388
|
You can freely combine the existing flat filter syntax with AND/OR groups. Flat filters and AND groups are all ANDed together at the top level:
|
|
@@ -439,7 +470,8 @@ query {
|
|
|
439
470
|
AND: [
|
|
440
471
|
{ conditions: [{ field: "year", operator: GTE, value: 2022 }] }
|
|
441
472
|
{ conditions: [
|
|
442
|
-
{ field: "episodes", path: "name",
|
|
473
|
+
# Equivalent to: { field: "episodes", path: "name", ... }
|
|
474
|
+
{ field: "episodes.name", operator: LIKE, value: "Final" }
|
|
443
475
|
] }
|
|
444
476
|
]
|
|
445
477
|
}
|
|
@@ -462,8 +494,8 @@ This translates to: `number > 1 AND (year = 2020 OR (year >= 2022 AND episodes.n
|
|
|
462
494
|
| `QLFilterGroup` | `AND: [QLFilterGroup]`, `OR: [QLFilterGroup]`, `conditions: [QLFilterCondition]` | Recursive logical group |
|
|
463
495
|
| `QLFilterCondition` | `field: String!`, `operator: QLOperator`, `value: QLValue`, `path: String` | Individual filter condition |
|
|
464
496
|
|
|
465
|
-
- `field` identifies the entity field by name (e.g., `"title"`, `"author"`)
|
|
466
|
-
- `path` is required for object/relationship fields (e.g., `"name"`, `"country.name"`)
|
|
497
|
+
- `field` identifies the entity field by name (e.g., `"title"`, `"author"`). For object/relationship fields you can also pass the full dotted path here as a shorthand (e.g., `"author.name"`, `"author.country.name"`)
|
|
498
|
+
- `path` is required for object/relationship fields when `field` is just the top-level name (e.g., `"name"`, `"country.name"`). Omit `path` if you used the dotted-`field` shorthand. Supplying both is an error.
|
|
467
499
|
- Multiple `conditions` in the same group are combined with AND
|
|
468
500
|
- Maximum nesting depth: 5 levels
|
|
469
501
|
|
|
@@ -2540,6 +2572,289 @@ console.log(UserType.getFields()); // Access GraphQL fields
|
|
|
2540
2572
|
const BookInput = simfinity.getInputType(BookType);
|
|
2541
2573
|
```
|
|
2542
2574
|
|
|
2575
|
+
## 🤖 MCP Generation
|
|
2576
|
+
|
|
2577
|
+
Simfinity can automatically expose every generated GraphQL operation as a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) tool. For each connected type you get one MCP tool per generated query and mutation — exactly mirroring the GraphQL surface:
|
|
2578
|
+
|
|
2579
|
+
| GraphQL operation | MCP tool name |
|
|
2580
|
+
| --- | --- |
|
|
2581
|
+
| Single-entity query | `book` |
|
|
2582
|
+
| List query | `books` |
|
|
2583
|
+
| Aggregate query | `books_aggregate` |
|
|
2584
|
+
| Add mutation | `addbook` |
|
|
2585
|
+
| Update mutation | `updatebook` |
|
|
2586
|
+
| Delete mutation | `deletebook` |
|
|
2587
|
+
| State-machine action | `publish_book` |
|
|
2588
|
+
| Custom mutation | _the registered name_ |
|
|
2589
|
+
|
|
2590
|
+
Each generated tool follows MCP best practices so an agent can use it without extra context:
|
|
2591
|
+
|
|
2592
|
+
- **`description`**: reuses the GraphQL type/field descriptions and adds an actionable summary. List and aggregate tools spell out the available filter operators (`EQ, NE, LT, LTE, GT, GTE, IN, NIN, BTW, LIKE`), `AND`/`OR` groups, pagination and sorting; aggregate tools additionally document the `SUM/COUNT/AVG/MIN/MAX` operations and the `aggregation` argument.
|
|
2593
|
+
- **`title`**: a human-readable label (e.g. `List Book`, `Create Book`, `Aggregate Book`).
|
|
2594
|
+
- **`inputSchema`**: JSON Schema derived with full fidelity from the GraphQL arguments (scalars, enums, input objects, lists, non-null wrappers, and recursive filter types such as `QLFilterGroup` via `$defs`/`$ref`). GraphQL type and field descriptions are propagated, with curated descriptions for the synthetic filter/pagination/sort/aggregation types. `Date`/`DateTime`/`Time` scalars map to `string` with the matching JSON Schema `format` (`date` / `date-time` / `time`), and arguments with a GraphQL default value carry a JSON Schema `default` and are never listed as `required`.
|
|
2595
|
+
- **`outputSchema`**: JSON Schema describing the returned data (mirroring the auto-generated selection set), with type/field descriptions. Every nullable GraphQL position also accepts `null` (e.g. `type: ['string', 'null']`; enums get `null` appended), so validating MCP clients accept the `null`s GraphQL legitimately returns in `structuredContent` (get-by-id misses, unset optional fields). Non-null positions stay single-typed, and input schemas are never null-widened.
|
|
2596
|
+
- **`annotations`**: behavioral hints — queries are `readOnlyHint: true`, `delete*` is `destructiveHint: true`, `update*` is `idempotentHint: true`. Generated CRUD mutations are recognized by the placeholder descriptions Simfinity stamps on them, so a custom mutation that happens to be named `updateReport` (and carries its own description) keeps that description and gets no inferred hints.
|
|
2597
|
+
|
|
2598
|
+
On execution, each tool returns both a serialized JSON `text` content block and a machine-readable `structuredContent` (the GraphQL `data`) that conforms to the `outputSchema`. When the caller requests `pagination: { count: true }` on a **list** tool, the total record count is delivered in the tool result `_meta.count` (aggregate tools ignore the flag — their resolver never computes a total).
|
|
2599
|
+
|
|
2600
|
+
> The MCP transports require the optional `@modelcontextprotocol/sdk` dependency (`^1.13.0` or newer). Install it with `npm install @modelcontextprotocol/sdk`. `generateMCPTools` works without it. SDK load problems raise distinct error codes: `MCP_SDK_NOT_INSTALLED` (not installed), `MCP_SDK_INCOMPATIBLE` (installed but too old to provide the requested transport — upgrade it), `MCP_SDK_LOAD_FAILED` (any other import failure).
|
|
2601
|
+
|
|
2602
|
+
### Installation
|
|
2603
|
+
|
|
2604
|
+
```bash
|
|
2605
|
+
npm install @modelcontextprotocol/sdk
|
|
2606
|
+
```
|
|
2607
|
+
|
|
2608
|
+
### Generating tool definitions
|
|
2609
|
+
|
|
2610
|
+
`generateMCPTools(schema, options)` returns the tool list, a `callTool` executor and a `getOperation` inspector. It does not require the SDK and is handy for inspection or custom wiring:
|
|
2611
|
+
|
|
2612
|
+
```javascript
|
|
2613
|
+
import * as simfinity from '@simtlix/simfinity-js';
|
|
2614
|
+
|
|
2615
|
+
const schema = simfinity.createSchema();
|
|
2616
|
+
const { tools, callTool, getOperation } = simfinity.generateMCPTools(schema);
|
|
2617
|
+
|
|
2618
|
+
// tools: [{ name, title, description, inputSchema, outputSchema, annotations, kind }, ...]
|
|
2619
|
+
const result = await callTool('addbook', { input: { title: 'Dune' } });
|
|
2620
|
+
// result: { content: [{ type: 'text', text: '...JSON...' }],
|
|
2621
|
+
// structuredContent: { addbook: { ... } }, isError: false }
|
|
2622
|
+
|
|
2623
|
+
// getOperation returns the prebuilt GraphQL document a tool executes
|
|
2624
|
+
console.log(getOperation('books'));
|
|
2625
|
+
// query booksOperation($id: QLFilter, $pagination: QLPagination, ...) {
|
|
2626
|
+
// books(id: $id, pagination: $pagination, ...) { id title ... }
|
|
2627
|
+
// }
|
|
2628
|
+
```
|
|
2629
|
+
|
|
2630
|
+
Both `callTool` and `getOperation` throw a `SimfinityError` with code `MCP_TOOL_NOT_FOUND` for unknown tool names.
|
|
2631
|
+
|
|
2632
|
+
### Standalone MCP server (stdio)
|
|
2633
|
+
|
|
2634
|
+
Use stdio for a standalone executable consumed by clients like Cursor or Claude Desktop:
|
|
2635
|
+
|
|
2636
|
+
```javascript
|
|
2637
|
+
import * as simfinity from '@simtlix/simfinity-js';
|
|
2638
|
+
|
|
2639
|
+
const schema = simfinity.createSchema();
|
|
2640
|
+
await simfinity.startStdioMCPServer(schema, {
|
|
2641
|
+
serverName: 'my-api-mcp',
|
|
2642
|
+
context: { user: { id: 'system' } },
|
|
2643
|
+
});
|
|
2644
|
+
```
|
|
2645
|
+
|
|
2646
|
+
### HTTP MCP endpoint (alongside GraphQL)
|
|
2647
|
+
|
|
2648
|
+
Mount an MCP endpoint (Streamable HTTP) next to your existing GraphQL server:
|
|
2649
|
+
|
|
2650
|
+
```javascript
|
|
2651
|
+
import express from 'express';
|
|
2652
|
+
import * as simfinity from '@simtlix/simfinity-js';
|
|
2653
|
+
|
|
2654
|
+
const schema = simfinity.createSchema();
|
|
2655
|
+
const app = express();
|
|
2656
|
+
app.use(express.json());
|
|
2657
|
+
|
|
2658
|
+
const mcpHandler = await simfinity.createHTTPMCPHandler(schema, {
|
|
2659
|
+
// In HTTP mode the context factory receives the Express `req`,
|
|
2660
|
+
// so you can authenticate per request (e.g. from the Authorization header).
|
|
2661
|
+
context: (req) => ({ user: resolveUserFrom(req.headers.authorization) }),
|
|
2662
|
+
});
|
|
2663
|
+
app.post('/mcp', mcpHandler);
|
|
2664
|
+
```
|
|
2665
|
+
|
|
2666
|
+
For deployments reachable from a browser (especially localhost), enable the SDK's DNS-rebinding protection via `transportOptions`, and observe failures with `onError`:
|
|
2667
|
+
|
|
2668
|
+
```javascript
|
|
2669
|
+
const mcpHandler = await simfinity.createHTTPMCPHandler(schema, {
|
|
2670
|
+
context: (req) => ({ user: resolveUserFrom(req.headers.authorization) }),
|
|
2671
|
+
// Spread into the SDK's StreamableHTTPServerTransport options
|
|
2672
|
+
transportOptions: {
|
|
2673
|
+
enableDnsRebindingProtection: true,
|
|
2674
|
+
allowedHosts: ['127.0.0.1:3000', 'localhost:3000'],
|
|
2675
|
+
allowedOrigins: ['http://localhost:3000'],
|
|
2676
|
+
},
|
|
2677
|
+
onError: (err, req, res) => console.error('MCP request failed', err),
|
|
2678
|
+
});
|
|
2679
|
+
```
|
|
2680
|
+
|
|
2681
|
+
The handler catches all errors itself: `onError` is for logging/metrics, and a JSON-RPC internal error (HTTP 500) is sent automatically when response headers were not already written.
|
|
2682
|
+
|
|
2683
|
+
### Options
|
|
2684
|
+
|
|
2685
|
+
| Option | Default | Description |
|
|
2686
|
+
| --- | --- | --- |
|
|
2687
|
+
| `execution` | `{ mode: 'in-process' }` | `in-process` runs against the in-memory `schema`. `remote` issues HTTP GraphQL requests: `{ mode: 'remote', endpoint, headers, timeoutMs, retry: { attempts, backoffMs } }`. See [Remote execution](#remote-execution). |
|
|
2688
|
+
| `context` | `{}` | GraphQL context value, or a factory. For in-process/stdio it is called as `(extra) => context`; for the HTTP handler it is called as `(req, extra) => context` so you can authenticate per request. Passed through to execution so `scope` functions and auth rules work (they read `context.user`). |
|
|
2689
|
+
| `include` / `exclude` | — | String or array: raw GraphQL field names, prefixed tool names, or the categories `'query'` / `'mutation'`. `exclude` wins over `include`. |
|
|
2690
|
+
| `includeTypes` / `excludeTypes` | — | String or array of entity (return) type names — one entry covers every tool for that entity (`'Book'` matches `book`, `books`, `books_aggregate`, `addbook`, ...). `excludeTypes` wins. |
|
|
2691
|
+
| `selectionDepth` | `1` | Nesting depth for the auto-generated output selection set (and the mirrored `outputSchema`). |
|
|
2692
|
+
| `includeId` | `true` | Always select `id` when nothing else is selectable on an object type. |
|
|
2693
|
+
| `toolNamePrefix` | — | Prefix prepended to every published tool name (e.g. `'catalog_'`). Must match `/^[a-zA-Z0-9_-]+$/`; the resulting names must match `/^[a-zA-Z0-9_-]{1,128}$/` (`MCP_INVALID_TOOL_NAME` otherwise). |
|
|
2694
|
+
| `toolOverrides` | `{}` | Per-tool overrides keyed by tool (or unprefixed field) name: `{ description, title, annotations, selectionDepth, includeId, selection }`. See [Customizing tools](#customizing-tools). |
|
|
2695
|
+
| `toolMiddleware` | — | Array of koa-style `async (call, next)` functions run around every tool call. See [Tool middleware](#tool-middleware). |
|
|
2696
|
+
| `limits` | — | Guardrails: `{ maxPageSize, defaultPagination, maxResultBytes }`. See [Limits](#limits). |
|
|
2697
|
+
| `schemaPlugins` | — | Envelop-style plugins whose `onSchemaChange` hook is applied once before serving (in-process execution only). See [Authentication](#authentication). |
|
|
2698
|
+
| `serverName` / `serverVersion` | `'simfinity-mcp'` / `'1.0.0'` | Identity reported by the MCP server. |
|
|
2699
|
+
| `transportOptions` | — | `createHTTPMCPHandler` only: extra options spread into the SDK's `StreamableHTTPServerTransport` (e.g. `enableDnsRebindingProtection`, `allowedHosts`, `allowedOrigins`). |
|
|
2700
|
+
| `onError` | — | `createHTTPMCPHandler` only: `(err, req, res)` callback invoked when a request fails; the handler still responds with a JSON-RPC internal error (HTTP 500) if headers were not sent. |
|
|
2701
|
+
|
|
2702
|
+
Selection sets always fall back to `id` and then `__typename` when nothing else is selectable (interface/union return types select `__typename`), so the generated documents are always statically valid — and the output schemas mirror the same fallbacks.
|
|
2703
|
+
|
|
2704
|
+
### Selecting and naming tools
|
|
2705
|
+
|
|
2706
|
+
Trim and namespace the published surface with `include`/`exclude`, `includeTypes`/`excludeTypes` and `toolNamePrefix`:
|
|
2707
|
+
|
|
2708
|
+
```javascript
|
|
2709
|
+
const { tools, callTool, getOperation } = simfinity.generateMCPTools(schema, {
|
|
2710
|
+
exclude: 'mutation', // read-only server
|
|
2711
|
+
excludeTypes: 'AuditLog', // hide every tool whose entity type is AuditLog
|
|
2712
|
+
toolNamePrefix: 'catalog_', // books -> catalog_books
|
|
2713
|
+
});
|
|
2714
|
+
|
|
2715
|
+
// callTool/getOperation use the published (prefixed) name;
|
|
2716
|
+
// the underlying GraphQL document still uses the raw field name.
|
|
2717
|
+
console.log(getOperation('catalog_books'));
|
|
2718
|
+
// query booksOperation(...) { books(...) { ... } }
|
|
2719
|
+
```
|
|
2720
|
+
|
|
2721
|
+
- `include`/`exclude` entries match the raw GraphQL field name, the prefixed tool name, or the categories `'query'` / `'mutation'`. `exclude` always wins over `include`.
|
|
2722
|
+
- `includeTypes`/`excludeTypes` match the entity (return) type. Aggregate queries resolve their entity through the sibling list field (`books_aggregate` → `books`), so `excludeTypes: 'Book'` also hides the aggregate tool. With an `includeTypes` allowlist, tools without an object return type are dropped too.
|
|
2723
|
+
- Duplicate tool names (e.g. a Query field and a Mutation field sharing a name) are not allowed by MCP: the first tool wins and the later one is skipped with a `console.warn` — rename one of the GraphQL fields or use `include`/`exclude`.
|
|
2724
|
+
|
|
2725
|
+
### Customizing tools
|
|
2726
|
+
|
|
2727
|
+
`toolOverrides` tunes individual tools without touching the schema. Keys accept either the published tool name or the unprefixed field name:
|
|
2728
|
+
|
|
2729
|
+
```javascript
|
|
2730
|
+
const { tools } = simfinity.generateMCPTools(schema, {
|
|
2731
|
+
toolOverrides: {
|
|
2732
|
+
books: {
|
|
2733
|
+
title: 'Search books',
|
|
2734
|
+
description: 'Search the book catalog. Filter by title, author or publication year.',
|
|
2735
|
+
selectionDepth: 2, // deeper selection set for this tool only
|
|
2736
|
+
},
|
|
2737
|
+
publish_book: {
|
|
2738
|
+
annotations: { idempotentHint: true }, // merged over the generated annotations
|
|
2739
|
+
},
|
|
2740
|
+
book: {
|
|
2741
|
+
selection: 'id title author { name }', // explicit selection set
|
|
2742
|
+
},
|
|
2743
|
+
},
|
|
2744
|
+
});
|
|
2745
|
+
```
|
|
2746
|
+
|
|
2747
|
+
`selection` accepts `'id title'` or `'{ id title }'` and replaces the auto-generated selection set. When it is set, the `outputSchema` is **omitted** for that tool — it can no longer be guaranteed to match the returned data, and publishing a wrong schema would make validating clients reject valid results.
|
|
2748
|
+
|
|
2749
|
+
### Limits
|
|
2750
|
+
|
|
2751
|
+
`limits` protects agents (and your database) from oversized requests and responses. Violations come back as regular `isError` tool results, not exceptions:
|
|
2752
|
+
|
|
2753
|
+
```javascript
|
|
2754
|
+
const { callTool } = simfinity.generateMCPTools(schema, {
|
|
2755
|
+
limits: {
|
|
2756
|
+
maxPageSize: 100, // MCP_PAGE_SIZE_EXCEEDED above this
|
|
2757
|
+
defaultPagination: { page: 1, size: 20 }, // injected when the caller sends none
|
|
2758
|
+
maxResultBytes: 262144, // MCP_RESULT_TOO_LARGE above this
|
|
2759
|
+
},
|
|
2760
|
+
});
|
|
2761
|
+
```
|
|
2762
|
+
|
|
2763
|
+
- `maxPageSize`: rejects calls whose `pagination.size` exceeds the cap.
|
|
2764
|
+
- `defaultPagination`: injected only for tools that have a `pagination` argument, and only when the caller did not send one. `page` and `size` are both required (QLPagination declares them non-null), and `size` must not exceed `maxPageSize` — that misconfiguration throws `MCP_INVALID_LIMITS` at setup.
|
|
2765
|
+
- `maxResultBytes`: rejects results whose serialized `data` is larger than the cap (the error message suggests narrowing the query, lowering `selectionDepth` or paginating).
|
|
2766
|
+
|
|
2767
|
+
### Tool middleware
|
|
2768
|
+
|
|
2769
|
+
`toolMiddleware` wraps every tool execution with koa-style `(call, next)` functions — for logging, metrics, argument rewriting or policy checks. `call` is `{ name, args, extra, kind, operation }`:
|
|
2770
|
+
|
|
2771
|
+
```javascript
|
|
2772
|
+
const { callTool } = simfinity.generateMCPTools(schema, {
|
|
2773
|
+
toolMiddleware: [
|
|
2774
|
+
async (call, next) => {
|
|
2775
|
+
const started = Date.now();
|
|
2776
|
+
const result = await next();
|
|
2777
|
+
console.log(`[mcp] ${call.name} (${call.kind}) took ${Date.now() - started}ms`);
|
|
2778
|
+
return result;
|
|
2779
|
+
},
|
|
2780
|
+
async (call, next) => {
|
|
2781
|
+
if (call.kind === 'mutation' && process.env.MCP_READ_ONLY === 'true') {
|
|
2782
|
+
// Short-circuit: return a result without calling next()
|
|
2783
|
+
return {
|
|
2784
|
+
content: [{ type: 'text', text: 'Mutations are disabled on this server.' }],
|
|
2785
|
+
isError: true,
|
|
2786
|
+
};
|
|
2787
|
+
}
|
|
2788
|
+
return next();
|
|
2789
|
+
},
|
|
2790
|
+
],
|
|
2791
|
+
});
|
|
2792
|
+
```
|
|
2793
|
+
|
|
2794
|
+
Middleware can mutate `call.args` before calling `next()`, short-circuit by returning a result, or throw. Calling `next()` twice rejects with `MCP_MIDDLEWARE_ERROR`.
|
|
2795
|
+
|
|
2796
|
+
### Remote execution
|
|
2797
|
+
|
|
2798
|
+
`execution.mode: 'remote'` turns the MCP server into a thin proxy that POSTs each operation to an existing GraphQL endpoint. Harden it with `timeoutMs` and `retry`:
|
|
2799
|
+
|
|
2800
|
+
```javascript
|
|
2801
|
+
const { callTool } = simfinity.generateMCPTools(schema, {
|
|
2802
|
+
execution: {
|
|
2803
|
+
mode: 'remote',
|
|
2804
|
+
endpoint: 'https://api.example.com/graphql',
|
|
2805
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
2806
|
+
timeoutMs: 10000, // abort requests slower than 10s
|
|
2807
|
+
retry: { attempts: 2, backoffMs: 250 }, // up to 2 extra attempts, linear backoff
|
|
2808
|
+
},
|
|
2809
|
+
});
|
|
2810
|
+
```
|
|
2811
|
+
|
|
2812
|
+
- `timeoutMs` aborts slow requests (`AbortSignal.timeout`); the timeout signal is combined with the MCP client's cancellation signal.
|
|
2813
|
+
- `retry` re-issues failed **queries only — never mutations** — after network errors/timeouts, HTTP 5xx and 429 responses. Backoff is linear: the first retry waits `backoffMs`, the second `2 × backoffMs`, and so on (default `backoffMs`: 250).
|
|
2814
|
+
- Transport failures do not throw: they surface as `isError` tool results carrying GraphQL-shaped errors with the codes `MCP_REMOTE_HTTP_ERROR` (non-2xx response, includes `extensions.status`), `MCP_REMOTE_REQUEST_FAILED` (network error/timeout) and `MCP_REMOTE_INVALID_RESPONSE` (non-JSON body, or JSON with neither `data` nor `errors`).
|
|
2815
|
+
- If the remote response carries a numeric `extensions.count` (e.g. exposed via `simfinity.plugins.envelopCountPlugin()`), it is surfaced as `_meta.count` on the tool result.
|
|
2816
|
+
|
|
2817
|
+
`schemaPlugins` is not applied in remote mode — the remote GraphQL server enforces its own plugins.
|
|
2818
|
+
|
|
2819
|
+
### Tool results and errors
|
|
2820
|
+
|
|
2821
|
+
Successful calls return `{ content: [textJSON], structuredContent: data, isError: false }`. Failures and edge cases behave as follows:
|
|
2822
|
+
|
|
2823
|
+
- **Total count**: with `pagination: { count: true }` on a **list** tool, the count lands in `_meta.count` on the tool result — in-process it is read back from a per-call context layer (Simfinity's find resolver writes it there; counts never leak across calls), in remote mode from the response `extensions.count`. Aggregate tools never deliver a count.
|
|
2824
|
+
- **Execution errors**: `isError: true` with a `text` payload of `{ errors, data? }` — partial data is preserved alongside the errors when GraphQL returned any. `structuredContent` is omitted on errors.
|
|
2825
|
+
- **Remote transport failures** and **limit violations** are also `isError` results (see [Remote execution](#remote-execution) and [Limits](#limits) for the codes).
|
|
2826
|
+
- **Unknown tool names** are the exception: `callTool` and `getOperation` throw `MCP_TOOL_NOT_FOUND`. A call whose `extra.signal` is already aborted throws `MCP_CALL_CANCELLED` before executing.
|
|
2827
|
+
|
|
2828
|
+
### Authentication
|
|
2829
|
+
|
|
2830
|
+
The MCP tools execute the same resolvers as GraphQL, so the GraphQL `context` flows through unchanged: pass credentials via `options.context` (a value or a factory). [Query scope](#-query-scope) functions read the context on every execution and need no extra wiring.
|
|
2831
|
+
|
|
2832
|
+
Field-level rules from [`createAuthPlugin`](#-authorization) are different. The auth plugin is an Envelop plugin that wraps resolvers from its `onSchemaChange` hook — a hook that only fires when an Envelop/Yoga server boots. A standalone MCP server executes operations with bare `graphql()`, so without help the hook never fires and the field-level rules are silently skipped. Pass the plugin via `schemaPlugins` to get auth parity: every plugin's `onSchemaChange` is invoked once before serving (in-process execution only):
|
|
2833
|
+
|
|
2834
|
+
```javascript
|
|
2835
|
+
import * as simfinity from '@simtlix/simfinity-js';
|
|
2836
|
+
|
|
2837
|
+
const { createAuthPlugin, requireAuth, requireRole } = simfinity.auth;
|
|
2838
|
+
|
|
2839
|
+
// Permissions are keyed by the schema's type names. Simfinity names its
|
|
2840
|
+
// roots `RootQueryType` and `Mutation`.
|
|
2841
|
+
const permissions = {
|
|
2842
|
+
RootQueryType: { books: requireAuth() },
|
|
2843
|
+
Mutation: { deletebook: requireRole('admin') },
|
|
2844
|
+
};
|
|
2845
|
+
|
|
2846
|
+
const schema = simfinity.createSchema();
|
|
2847
|
+
await simfinity.startStdioMCPServer(schema, {
|
|
2848
|
+
// requireRole reads `user.role` by default (see rolePath to customize).
|
|
2849
|
+
context: { user: { id: 'agent', role: 'admin' } },
|
|
2850
|
+
schemaPlugins: [createAuthPlugin(permissions, { defaultPolicy: 'ALLOW' })],
|
|
2851
|
+
});
|
|
2852
|
+
```
|
|
2853
|
+
|
|
2854
|
+
Mounting the MCP handler next to a running Envelop/Yoga server that shares the same `schema` object also works: Yoga fires `onSchemaChange` at startup and `createAuthPlugin` wraps the resolvers in place, so the MCP tools execute the already-wrapped schema.
|
|
2855
|
+
|
|
2856
|
+
For per-request HTTP auth, use a function `context`: the HTTP handler invokes it as `(req, extra) => context`, so you can read the `Authorization` header from the Express `req` and map it to `context.user` exactly like your GraphQL server does.
|
|
2857
|
+
|
|
2543
2858
|
## 📊 Aggregation Queries
|
|
2544
2859
|
|
|
2545
2860
|
Simfinity.js now supports powerful GraphQL aggregation queries with GROUP BY functionality, allowing you to perform aggregate operations (SUM, COUNT, AVG, MIN, MAX) on your data.
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simtlix/simfinity-js",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "src/index.js",
|
|
6
|
+
"types": "./types/index.d.ts",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"scripts": {
|
|
8
9
|
"test": "vitest run",
|
|
@@ -28,12 +29,13 @@
|
|
|
28
29
|
"@eslint/js": "^9.30.1",
|
|
29
30
|
"eslint": "^9.30.1",
|
|
30
31
|
"globals": "^16.3.0",
|
|
31
|
-
"vitest": "^
|
|
32
|
+
"vitest": "^4.1.9"
|
|
32
33
|
},
|
|
33
34
|
"config": {
|
|
34
35
|
"owner": "simtlix"
|
|
35
36
|
},
|
|
36
37
|
"optionalDependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.13.0",
|
|
37
39
|
"graphql": "^16.11.0",
|
|
38
40
|
"graphql-middleware": "^6.1.35",
|
|
39
41
|
"mongoose": "^8.16.2"
|
package/src/index.js
CHANGED
|
@@ -316,6 +316,25 @@ const assertValidFilterPath = (path) => {
|
|
|
316
316
|
}
|
|
317
317
|
};
|
|
318
318
|
|
|
319
|
+
const normalizeFilterCondition = (condition) => {
|
|
320
|
+
const { field } = condition;
|
|
321
|
+
if (typeof field !== 'string') return condition;
|
|
322
|
+
const dotIdx = field.indexOf('.');
|
|
323
|
+
if (dotIdx < 0) return condition;
|
|
324
|
+
if (condition.path != null && condition.path !== '') {
|
|
325
|
+
throw new SimfinityError(
|
|
326
|
+
'Filter condition cannot use both dotted field syntax and a separate path',
|
|
327
|
+
'AMBIGUOUS_FILTER_FIELD',
|
|
328
|
+
400,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
...condition,
|
|
333
|
+
field: field.slice(0, dotIdx),
|
|
334
|
+
path: field.slice(dotIdx + 1),
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
|
|
319
338
|
const OP_TO_MONGO = {
|
|
320
339
|
LT: (v) => ({ $lt: v }),
|
|
321
340
|
GT: (v) => ({ $gt: v }),
|
|
@@ -534,11 +553,13 @@ const buildInputType = (gqltype) => {
|
|
|
534
553
|
|
|
535
554
|
const inputTypeBody = {
|
|
536
555
|
name: `${gqltype.name}Input`,
|
|
556
|
+
description: gqltype.description,
|
|
537
557
|
fields: fieldsArgs,
|
|
538
558
|
};
|
|
539
559
|
|
|
540
560
|
const inputTypeBodyForUpdate = {
|
|
541
561
|
name: `${gqltype.name}InputForUpdate`,
|
|
562
|
+
description: gqltype.description,
|
|
542
563
|
fields: fieldsArgForUpdate,
|
|
543
564
|
};
|
|
544
565
|
|
|
@@ -1397,7 +1418,8 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
|
|
|
1397
1418
|
const fields = gqltype.getFields();
|
|
1398
1419
|
|
|
1399
1420
|
if (filterGroup.conditions?.length > 0) {
|
|
1400
|
-
for (const
|
|
1421
|
+
for (const rawCondition of filterGroup.conditions) {
|
|
1422
|
+
const condition = normalizeFilterCondition(rawCondition);
|
|
1401
1423
|
const qlField = fields[condition.field];
|
|
1402
1424
|
if (!qlField) {
|
|
1403
1425
|
throw new SimfinityError(`Unknown filter field: ${condition.field}`, 'INVALID_FILTER_FIELD', 400);
|
|
@@ -1919,6 +1941,14 @@ export { default as validators } from './validators.js';
|
|
|
1919
1941
|
export { default as scalars } from './scalars.js';
|
|
1920
1942
|
export { default as plugins } from './plugins.js';
|
|
1921
1943
|
export { default as auth } from './auth/index.js';
|
|
1944
|
+
export { default as mcp } from './mcp.js';
|
|
1945
|
+
export {
|
|
1946
|
+
generateMCPTools,
|
|
1947
|
+
graphqlArgsToJSONSchema,
|
|
1948
|
+
createMCPServer,
|
|
1949
|
+
startStdioMCPServer,
|
|
1950
|
+
createHTTPMCPHandler,
|
|
1951
|
+
} from './mcp.js';
|
|
1922
1952
|
|
|
1923
1953
|
export { buildQuery, buildFilterGroupMatch };
|
|
1924
1954
|
|