@stigmer/mcp-server 3.11.0 → 3.12.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.
Files changed (99) hide show
  1. package/README.md +4 -22
  2. package/cli/mcp-server-stigmer.js +11 -11
  3. package/config.d.ts +1 -5
  4. package/config.d.ts.map +1 -1
  5. package/config.js +2 -2
  6. package/config.js.map +1 -1
  7. package/domains/resourceuri.d.ts.map +1 -1
  8. package/domains/resourceuri.js +0 -1
  9. package/domains/resourceuri.js.map +1 -1
  10. package/domains/search/tools.d.ts.map +1 -1
  11. package/domains/search/tools.js +4 -5
  12. package/domains/search/tools.js.map +1 -1
  13. package/gen/agent.d.ts +1 -57
  14. package/gen/agent.d.ts.map +1 -1
  15. package/gen/agent.js +5 -28
  16. package/gen/agent.js.map +1 -1
  17. package/gen/environment.d.ts +1 -1
  18. package/gen/environment.d.ts.map +1 -1
  19. package/gen/environment.js +3 -3
  20. package/gen/environment.js.map +1 -1
  21. package/gen/mcpserver.d.ts +1 -1
  22. package/gen/mcpserver.d.ts.map +1 -1
  23. package/gen/mcpserver.js +7 -7
  24. package/gen/mcpserver.js.map +1 -1
  25. package/gen/workflow.d.ts +3 -3
  26. package/gen/workflow.d.ts.map +1 -1
  27. package/gen/workflow.js +34 -34
  28. package/gen/workflow.js.map +1 -1
  29. package/index.d.ts +1 -1
  30. package/index.d.ts.map +1 -1
  31. package/index.js +1 -1
  32. package/index.js.map +1 -1
  33. package/package.json +3 -3
  34. package/server.d.ts +6 -18
  35. package/server.d.ts.map +1 -1
  36. package/server.js +6 -36
  37. package/server.js.map +1 -1
  38. package/src/config.ts +3 -7
  39. package/src/domains/apply.integration.test.ts +3 -40
  40. package/src/domains/deletes.integration.test.ts +1 -25
  41. package/src/domains/reads.integration.test.ts +1 -18
  42. package/src/domains/resources.integration.test.ts +0 -18
  43. package/src/domains/resourceuri.test.ts +0 -2
  44. package/src/domains/resourceuri.ts +0 -1
  45. package/src/domains/search/search.integration.test.ts +3 -5
  46. package/src/domains/search/tools.ts +4 -5
  47. package/src/gen/agent.ts +5 -32
  48. package/src/gen/environment.ts +3 -3
  49. package/src/gen/mcpserver.ts +7 -7
  50. package/src/gen/workflow.ts +34 -34
  51. package/src/http.integration.test.ts +2 -3
  52. package/src/index.ts +0 -2
  53. package/src/server.ts +6 -36
  54. package/domains/datastores/apply.d.ts +0 -4
  55. package/domains/datastores/apply.d.ts.map +0 -1
  56. package/domains/datastores/apply.js +0 -28
  57. package/domains/datastores/apply.js.map +0 -1
  58. package/domains/datastores/delete.d.ts +0 -3
  59. package/domains/datastores/delete.d.ts.map +0 -1
  60. package/domains/datastores/delete.js +0 -38
  61. package/domains/datastores/delete.js.map +0 -1
  62. package/domains/datastores/fetch.d.ts +0 -3
  63. package/domains/datastores/fetch.d.ts.map +0 -1
  64. package/domains/datastores/fetch.js +0 -23
  65. package/domains/datastores/fetch.js.map +0 -1
  66. package/domains/datastores/resources.d.ts +0 -5
  67. package/domains/datastores/resources.d.ts.map +0 -1
  68. package/domains/datastores/resources.js +0 -16
  69. package/domains/datastores/resources.js.map +0 -1
  70. package/domains/datastores/tools.d.ts +0 -5
  71. package/domains/datastores/tools.d.ts.map +0 -1
  72. package/domains/datastores/tools.js +0 -46
  73. package/domains/datastores/tools.js.map +0 -1
  74. package/domains/records/calls.d.ts +0 -48
  75. package/domains/records/calls.d.ts.map +0 -1
  76. package/domains/records/calls.js +0 -118
  77. package/domains/records/calls.js.map +0 -1
  78. package/domains/records/errors.d.ts +0 -24
  79. package/domains/records/errors.d.ts.map +0 -1
  80. package/domains/records/errors.js +0 -79
  81. package/domains/records/errors.js.map +0 -1
  82. package/domains/records/tools.d.ts +0 -7
  83. package/domains/records/tools.d.ts.map +0 -1
  84. package/domains/records/tools.js +0 -139
  85. package/domains/records/tools.js.map +0 -1
  86. package/gen/datastore.d.ts +0 -861
  87. package/gen/datastore.d.ts.map +0 -1
  88. package/gen/datastore.js +0 -267
  89. package/gen/datastore.js.map +0 -1
  90. package/src/domains/datastores/apply.ts +0 -33
  91. package/src/domains/datastores/delete.ts +0 -47
  92. package/src/domains/datastores/fetch.ts +0 -32
  93. package/src/domains/datastores/resources.ts +0 -21
  94. package/src/domains/datastores/tools.ts +0 -76
  95. package/src/domains/records/calls.ts +0 -209
  96. package/src/domains/records/errors.ts +0 -98
  97. package/src/domains/records/records.integration.test.ts +0 -313
  98. package/src/domains/records/tools.ts +0 -210
  99. package/src/gen/datastore.ts +0 -264
@@ -1,209 +0,0 @@
1
- // Record-RPC invocations for the five record tools — 1:1 projections
2
- // of DatastoreRecordQueryController / DatastoreRecordCommandController
3
- // (DD-005: the tool layer adds no semantics; validation, grants,
4
- // scopes, constraints, and messages all live in record-RPC domain
5
- // logic on the control plane).
6
- //
7
- // One file for the five thin calls rather than the resource domains'
8
- // file-per-verb split: resource verbs each carry orchestration
9
- // (reference resolution, YAML parsing, two-step deletes); record calls
10
- // are each "build request, call, marshal" and the shared argument
11
- // mapping (filter/value building) is the only substance.
12
-
13
- import { create, fromJson, type JsonObject } from "@bufbuild/protobuf";
14
- import { ValueSchema, type Value } from "@bufbuild/protobuf/wkt";
15
- import {
16
- DatastoreRecordCommandController,
17
- } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/record_command_pb";
18
- import {
19
- DatastoreRecordQueryController,
20
- } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/record_query_pb";
21
- import {
22
- DatastoreDescriptionSchema,
23
- DeleteRecordRequestSchema,
24
- DescribeDatastoreRequestSchema,
25
- FindRecordsRequestSchema,
26
- InsertRecordRequestSchema,
27
- RecordConditionOp,
28
- RecordConditionSchema,
29
- RecordEnvelopeSchema,
30
- RecordFilterSchema,
31
- RecordListSchema,
32
- RecordOrderBySchema,
33
- RecordSortDirection,
34
- UpdateRecordRequestSchema,
35
- type RecordCondition,
36
- type RecordFilter,
37
- type RecordOrderBy,
38
- } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/record_io_pb";
39
- import { withClient } from "../client.js";
40
- import { toProtoJson } from "../marshal.js";
41
-
42
- /**
43
- * A filter condition as the model supplies it. `op` uses the natural
44
- * spelling "in" (the proto enum's `is_in` exists only because `in` is
45
- * reserved in Java/Python — a substrate detail the model never sees).
46
- */
47
- export interface ConditionArg {
48
- field: string;
49
- op: string;
50
- value?: string | number | boolean | null;
51
- values?: (string | number | boolean)[];
52
- }
53
-
54
- export interface FindArgs {
55
- datastore: string;
56
- org?: string;
57
- collection: string;
58
- conditions?: ConditionArg[];
59
- order_by?: { field: string; direction?: string };
60
- limit?: number;
61
- offset?: number;
62
- }
63
-
64
- /** Model-facing operator spelling → proto enum. */
65
- const CONDITION_OPS: Readonly<Record<string, RecordConditionOp>> = {
66
- eq: RecordConditionOp.eq,
67
- neq: RecordConditionOp.neq,
68
- gt: RecordConditionOp.gt,
69
- gte: RecordConditionOp.gte,
70
- lt: RecordConditionOp.lt,
71
- lte: RecordConditionOp.lte,
72
- in: RecordConditionOp.is_in,
73
- not_in: RecordConditionOp.not_in,
74
- is_null: RecordConditionOp.is_null,
75
- not_null: RecordConditionOp.not_null,
76
- };
77
-
78
- export async function findRecords(
79
- serverAddress: string,
80
- token: string,
81
- args: FindArgs,
82
- ): Promise<string> {
83
- const request = create(FindRecordsRequestSchema, {
84
- datastore: args.datastore,
85
- org: args.org ?? "",
86
- collection: args.collection,
87
- filter: buildFilter(args.conditions),
88
- orderBy: buildOrderBy(args.order_by),
89
- limit: args.limit ?? 0,
90
- offset: args.offset ?? 0,
91
- });
92
- return withClient(DatastoreRecordQueryController, serverAddress, token, async (client, opts) =>
93
- toProtoJson(RecordListSchema, await client.findRecords(request, opts)),
94
- );
95
- }
96
-
97
- export async function insertRecord(
98
- serverAddress: string,
99
- token: string,
100
- args: { datastore: string; org?: string; collection: string; record: Record<string, unknown> },
101
- ): Promise<string> {
102
- const request = create(InsertRecordRequestSchema, {
103
- datastore: args.datastore,
104
- org: args.org ?? "",
105
- collection: args.collection,
106
- // google.protobuf.Struct fields are plain JSON objects in
107
- // protobuf-es v2 — no conversion.
108
- record: args.record as JsonObject,
109
- });
110
- return withClient(DatastoreRecordCommandController, serverAddress, token, async (client, opts) =>
111
- toProtoJson(RecordEnvelopeSchema, await client.insertRecord(request, opts)),
112
- );
113
- }
114
-
115
- export async function updateRecord(
116
- serverAddress: string,
117
- token: string,
118
- args: {
119
- datastore: string;
120
- org?: string;
121
- collection: string;
122
- id: string;
123
- fields: Record<string, unknown>;
124
- },
125
- ): Promise<string> {
126
- const request = create(UpdateRecordRequestSchema, {
127
- datastore: args.datastore,
128
- org: args.org ?? "",
129
- collection: args.collection,
130
- id: args.id,
131
- fields: args.fields as JsonObject,
132
- });
133
- return withClient(DatastoreRecordCommandController, serverAddress, token, async (client, opts) =>
134
- toProtoJson(RecordEnvelopeSchema, await client.updateRecord(request, opts)),
135
- );
136
- }
137
-
138
- export async function deleteRecord(
139
- serverAddress: string,
140
- token: string,
141
- args: { datastore: string; org?: string; collection: string; id: string },
142
- ): Promise<string> {
143
- const request = create(DeleteRecordRequestSchema, {
144
- datastore: args.datastore,
145
- org: args.org ?? "",
146
- collection: args.collection,
147
- id: args.id,
148
- });
149
- return withClient(DatastoreRecordCommandController, serverAddress, token, async (client, opts) =>
150
- toProtoJson(RecordEnvelopeSchema, await client.deleteRecord(request, opts)),
151
- );
152
- }
153
-
154
- export async function describeDatastore(
155
- serverAddress: string,
156
- token: string,
157
- args: { datastore: string; org?: string },
158
- ): Promise<string> {
159
- const request = create(DescribeDatastoreRequestSchema, {
160
- datastore: args.datastore,
161
- org: args.org ?? "",
162
- });
163
- return withClient(DatastoreRecordQueryController, serverAddress, token, async (client, opts) =>
164
- toProtoJson(DatastoreDescriptionSchema, await client.describeDatastore(request, opts)),
165
- );
166
- }
167
-
168
- /**
169
- * Build the typed AND-only filter (DD-005 SD-2). An unknown operator is
170
- * left unspecified for the server to reject with its own domain
171
- * message — the bridge never fabricates validation of its own.
172
- */
173
- function buildFilter(conditions: ConditionArg[] | undefined): RecordFilter | undefined {
174
- if (conditions === undefined || conditions.length === 0) {
175
- return undefined;
176
- }
177
- return create(RecordFilterSchema, {
178
- conditions: conditions.map(buildCondition),
179
- });
180
- }
181
-
182
- function buildCondition(arg: ConditionArg): RecordCondition {
183
- return create(RecordConditionSchema, {
184
- field: arg.field,
185
- op: CONDITION_OPS[arg.op] ?? RecordConditionOp.record_condition_op_unspecified,
186
- value: arg.value !== undefined ? scalarValue(arg.value) : undefined,
187
- values: (arg.values ?? []).map(scalarValue),
188
- });
189
- }
190
-
191
- function buildOrderBy(
192
- orderBy: { field: string; direction?: string } | undefined,
193
- ): RecordOrderBy | undefined {
194
- if (orderBy === undefined) {
195
- return undefined;
196
- }
197
- return create(RecordOrderBySchema, {
198
- field: orderBy.field,
199
- direction: orderBy.direction === "desc"
200
- ? RecordSortDirection.desc
201
- : orderBy.direction === "asc"
202
- ? RecordSortDirection.asc
203
- : RecordSortDirection.record_sort_direction_unspecified,
204
- });
205
- }
206
-
207
- function scalarValue(v: string | number | boolean | null): Value {
208
- return fromJson(ValueSchema, v);
209
- }
@@ -1,98 +0,0 @@
1
- // The records-domain error mapper (DD-005 SD-6) — a deliberate,
2
- // documented divergence from the shared ../rpcerr.ts.
3
- //
4
- // Record-RPC domain errors carry agent-relayable messages that are
5
- // cross-edition contract bytes (DD-002 SD-5): "that slot is already
6
- // booked", "you are not allowed to insert records in bookings". The
7
- // shared helper would rewrite them ("Check your API key permissions")
8
- // and discards google.rpc.ErrorInfo. Here the domain codes pass the
9
- // server's message through VERBATIM as isError JSON text
10
- // `{error, code, reason, constraint}` — reason/constraint extracted
11
- // from ErrorInfo — so the model can self-correct (retry another slot
12
- // on CONSTRAINT_VIOLATION; stop and relay on PERMISSION_DENIED).
13
- // Transport codes still delegate to the shared helper, whose advice is
14
- // right for them.
15
-
16
- import { Code, ConnectError } from "@connectrpc/connect";
17
- import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
18
- import { ErrorInfoSchema } from "@stigmer/protos/google/rpc/error_details_pb";
19
- import { rpcError } from "../rpcerr.js";
20
- import { errorResult, textResult } from "../toolresult.js";
21
-
22
- /**
23
- * The gRPC codes whose messages are the record-RPC domain contract
24
- * (DD-005 SD-6): declared constraint messages, fixed denial texts, and
25
- * domain validation — all written to be relayed to end users verbatim.
26
- */
27
- const DOMAIN_CODES: ReadonlySet<Code> = new Set([
28
- Code.PermissionDenied,
29
- Code.AlreadyExists,
30
- Code.FailedPrecondition,
31
- Code.InvalidArgument,
32
- Code.NotFound,
33
- ]);
34
-
35
- /** The structured error payload record tools return for domain errors. */
36
- export interface RecordToolError {
37
- /** The server's relayable message, byte-for-byte. */
38
- error: string;
39
- /** gRPC status name in SCREAMING_SNAKE (e.g. ALREADY_EXISTS). */
40
- code: string;
41
- /** ErrorInfo reason (e.g. CONSTRAINT_VIOLATION), when present. */
42
- reason?: string;
43
- /** Violated constraint name from ErrorInfo metadata, when present. */
44
- constraint?: string;
45
- }
46
-
47
- /**
48
- * Run a record-tool body: a string payload becomes a text result; a
49
- * domain error becomes an isError JSON result carrying the verbatim
50
- * message + ErrorInfo companions; a transport error delegates to the
51
- * shared classifier. The records analog of ../toolresult.ts
52
- * textOrError, and the only try/catch in this domain.
53
- */
54
- export async function recordResult(
55
- toolContext: string,
56
- produce: () => Promise<string>,
57
- ): Promise<CallToolResult> {
58
- try {
59
- return textResult(await produce());
60
- } catch (err) {
61
- const ce = ConnectError.from(err);
62
- if (DOMAIN_CODES.has(ce.code)) {
63
- return {
64
- content: [{ type: "text", text: JSON.stringify(domainError(ce)) }],
65
- isError: true,
66
- };
67
- }
68
- return errorResult(rpcError(ce, toolContext));
69
- }
70
- }
71
-
72
- /** Project a domain ConnectError into the structured tool payload. */
73
- export function domainError(ce: ConnectError): RecordToolError {
74
- const payload: RecordToolError = {
75
- error: ce.rawMessage,
76
- code: grpcStatusName(ce.code),
77
- };
78
- // The record RPCs attach one google.rpc.ErrorInfo per domain error
79
- // (domain datastore.stigmer.ai); absence means an older server — the
80
- // message alone still carries the contract.
81
- const details = ce.findDetails(ErrorInfoSchema);
82
- if (details.length > 0) {
83
- const info = details[0];
84
- if (info.reason !== "") {
85
- payload.reason = info.reason;
86
- }
87
- const constraint = info.metadata["constraint"];
88
- if (constraint !== undefined && constraint !== "") {
89
- payload.constraint = constraint;
90
- }
91
- }
92
- return payload;
93
- }
94
-
95
- /** Connect's PascalCase code name → the gRPC SCREAMING_SNAKE status name. */
96
- function grpcStatusName(code: Code): string {
97
- return Code[code].replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
98
- }
@@ -1,313 +0,0 @@
1
- // In-process integration test for the record tools (search-pattern:
2
- // real Connect backend with stubbed record services, real MCP client
3
- // over an in-memory transport).
4
- //
5
- // Verifies the T05 contract surface:
6
- // - the records-only roster is exactly the five tools (T05 R1), with
7
- // honest annotations surfaced through tools/list (pinning the SDK's
8
- // annotations support) and NO org argument (agent audience);
9
- // - the main roster carries the same five tools WITH the org argument
10
- // (direct audience);
11
- // - argument → request mapping (typed filter, "in" → is_in, paging);
12
- // - the records-own error mapper: domain errors pass the server's
13
- // message verbatim as {error, code, reason, constraint} JSON with
14
- // ErrorInfo companions; transport errors delegate to the shared
15
- // classifier (DD-005 SD-6).
16
-
17
- import { create } from "@bufbuild/protobuf";
18
- import { Code, ConnectError, type ConnectRouter } from "@connectrpc/connect";
19
- import { connectNodeAdapter } from "@connectrpc/connect-node";
20
- import {
21
- createServer as createHttp2Server,
22
- type Http2Server,
23
- type ServerHttp2Session,
24
- } from "node:http2";
25
- import type { AddressInfo } from "node:net";
26
-
27
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
28
- import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
29
- import { DatastoreRecordCommandController } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/record_command_pb";
30
- import {
31
- DatastoreDescriptionSchema,
32
- RecordEnvelopeSchema,
33
- RecordListSchema,
34
- type FindRecordsRequest,
35
- type InsertRecordRequest,
36
- } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/record_io_pb";
37
- import { DatastoreRecordQueryController } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/record_query_pb";
38
- import { ErrorInfoSchema } from "@stigmer/protos/google/rpc/error_details_pb";
39
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
40
-
41
- import { configureLogger } from "../../logger";
42
- import { createRecordsServer, createServer } from "../../server";
43
-
44
- configureLogger({ level: "error", format: "text" });
45
-
46
- const RECORD_TOOLS = [
47
- "describe_datastore",
48
- "find_records",
49
- "insert_record",
50
- "update_record",
51
- "delete_record",
52
- ];
53
-
54
- let backend: Http2Server;
55
- let client: Client;
56
- const openSessions = new Set<ServerHttp2Session>();
57
-
58
- /** The next stubbed outcome per RPC; tests set these. */
59
- let findResponse: () => ReturnType<typeof create<typeof RecordListSchema>>;
60
- let insertOutcome: () => ReturnType<typeof create<typeof RecordEnvelopeSchema>>;
61
-
62
- /**
63
- * Requests the stubs captured. An object (not two `let`s) because TS
64
- * does not reset let-narrowing across awaited calls for closure
65
- * assignments — property narrowing it does.
66
- */
67
- const captured: {
68
- find?: FindRecordsRequest;
69
- insert?: InsertRecordRequest;
70
- } = {};
71
-
72
- interface ToolResult {
73
- content: Array<{ type: string; text?: string }>;
74
- isError?: boolean;
75
- }
76
-
77
- async function callTool(name: string, args: Record<string, unknown>): Promise<ToolResult> {
78
- return (await client.callTool({ name, arguments: args })) as ToolResult;
79
- }
80
-
81
- beforeAll(async () => {
82
- const routes = (router: ConnectRouter) => {
83
- router.service(DatastoreRecordQueryController, {
84
- findRecords: (req) => {
85
- captured.find = req;
86
- return findResponse();
87
- },
88
- describeDatastore: () =>
89
- create(DatastoreDescriptionSchema, {
90
- datastore: "clinic",
91
- timezone: "Asia/Kolkata",
92
- partitions: ["default"],
93
- }),
94
- });
95
- router.service(DatastoreRecordCommandController, {
96
- insertRecord: (req) => {
97
- captured.insert = req;
98
- return insertOutcome();
99
- },
100
- updateRecord: () => create(RecordEnvelopeSchema, { id: "dsr_updated" }),
101
- deleteRecord: () => create(RecordEnvelopeSchema, { id: "dsr_deleted" }),
102
- });
103
- };
104
- backend = createHttp2Server(connectNodeAdapter({ routes }));
105
- backend.on("session", (session) => {
106
- openSessions.add(session);
107
- session.on("close", () => openSessions.delete(session));
108
- });
109
- await new Promise<void>((resolve) => backend.listen(0, "127.0.0.1", resolve));
110
- const port = (backend.address() as AddressInfo).port;
111
-
112
- // The records-only roster is the surface under test — the audience
113
- // the runner-synthesized attachment serves.
114
- const mcp = createRecordsServer({ serverAddress: `127.0.0.1:${port}`, apiKey: "" });
115
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
116
- client = new Client({ name: "records-integration", version: "test" });
117
- await Promise.all([mcp.connect(serverTransport), client.connect(clientTransport)]);
118
- });
119
-
120
- afterAll(async () => {
121
- await client?.close();
122
- for (const session of openSessions) session.destroy();
123
- await new Promise<void>((resolve) => backend.close(() => resolve()));
124
- });
125
-
126
- describe("records roster (T05 R1)", () => {
127
- it("exposes exactly the five record tools with honest annotations and no org argument", async () => {
128
- const { tools } = await client.listTools();
129
- expect(tools.map((t) => t.name).sort()).toEqual([...RECORD_TOOLS].sort());
130
-
131
- const byName = new Map(tools.map((t) => [t.name, t]));
132
- // Annotations are part of the DD-005 contract for external clients
133
- // and the classifier; this pins the SDK actually surfacing them.
134
- expect(byName.get("find_records")?.annotations).toMatchObject({ readOnlyHint: true });
135
- expect(byName.get("describe_datastore")?.annotations).toMatchObject({ readOnlyHint: true });
136
- expect(byName.get("update_record")?.annotations).toMatchObject({ idempotentHint: true });
137
- expect(byName.get("delete_record")?.annotations).toMatchObject({
138
- destructiveHint: true,
139
- idempotentHint: true,
140
- });
141
-
142
- // The agent audience gets no org argument — a session-bound
143
- // caller's org is server-derived, and offering the argument would
144
- // only invite INVALID_ARGUMENT rejections (T05 R3).
145
- for (const tool of tools) {
146
- const properties = (tool.inputSchema as { properties?: Record<string, unknown> }).properties;
147
- expect(Object.keys(properties ?? {}), tool.name).not.toContain("org");
148
- }
149
- });
150
-
151
- it("the main roster carries the five tools WITH the org argument", async () => {
152
- // Direct principals (external MCP clients) must be able to name the
153
- // org their credential does not carry (DD-006 session-14 amendment).
154
- const full = createServer({ serverAddress: "127.0.0.1:1", apiKey: "" });
155
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
156
- const fullClient = new Client({ name: "records-full-roster", version: "test" });
157
- await Promise.all([full.connect(serverTransport), fullClient.connect(clientTransport)]);
158
- try {
159
- const { tools } = await fullClient.listTools();
160
- const names = tools.map((t) => t.name);
161
- for (const name of RECORD_TOOLS) {
162
- expect(names).toContain(name);
163
- }
164
- const find = tools.find((t) => t.name === "find_records");
165
- const properties = (find?.inputSchema as { properties?: Record<string, unknown> })
166
- .properties;
167
- expect(Object.keys(properties ?? {})).toContain("org");
168
- } finally {
169
- await fullClient.close();
170
- }
171
- });
172
- });
173
-
174
- describe("argument → request mapping", () => {
175
- it("builds the typed filter (in → is_in), order_by, and paging", async () => {
176
- findResponse = () =>
177
- create(RecordListSchema, {
178
- records: [create(RecordEnvelopeSchema, { id: "dsr_1" })],
179
- total: 1,
180
- limit: 25,
181
- });
182
- captured.find = undefined;
183
-
184
- const result = await callTool("find_records", {
185
- datastore: "clinic",
186
- collection: "bookings",
187
- conditions: [
188
- { field: "status", op: "eq", value: "confirmed" },
189
- { field: "patient_name", op: "in", values: ["Asha", "Ravi"] },
190
- ],
191
- order_by: { field: "slot_start", direction: "desc" },
192
- limit: 10,
193
- offset: 20,
194
- });
195
-
196
- expect(result.isError).toBeFalsy();
197
- // Assertion, not annotation: control-flow analysis otherwise pins the
198
- // const to the `= undefined` reset above (the stub assigns in a
199
- // closure the analysis cannot see across the await).
200
- const req = captured.find as FindRecordsRequest | undefined;
201
- expect(req?.datastore).toBe("clinic");
202
- expect(req?.org).toBe(""); // agent audience: org never travels
203
- expect(req?.partition).toBe(""); // partition is never a tool argument (DD-010)
204
- expect(req?.filter?.conditions).toHaveLength(2);
205
- expect(req?.filter?.conditions[0]).toMatchObject({ field: "status", op: 1 /* eq */ });
206
- expect(req?.filter?.conditions[1]).toMatchObject({ field: "patient_name", op: 7 /* is_in */ });
207
- expect(req?.filter?.conditions[1]?.values).toHaveLength(2);
208
- expect(req?.orderBy).toMatchObject({ field: "slot_start", direction: 2 /* desc */ });
209
- expect(req?.limit).toBe(10);
210
- expect(req?.offset).toBe(20);
211
-
212
- const body = JSON.parse(result.content[0]?.text ?? "{}") as { total?: number };
213
- expect(body.total).toBe(1);
214
- });
215
-
216
- it("passes the record payload through as a Struct, untouched", async () => {
217
- insertOutcome = () => create(RecordEnvelopeSchema, { id: "dsr_new" });
218
- captured.insert = undefined;
219
-
220
- const result = await callTool("insert_record", {
221
- datastore: "clinic",
222
- collection: "bookings",
223
- record: { slot_start: "2026-07-21T04:30:00Z", patient_name: "Asha" },
224
- });
225
-
226
- expect(result.isError).toBeFalsy();
227
- const req = captured.insert as InsertRecordRequest | undefined;
228
- expect(req?.record).toMatchObject({
229
- slot_start: "2026-07-21T04:30:00Z",
230
- patient_name: "Asha",
231
- });
232
- });
233
- });
234
-
235
- describe("records error mapper (DD-005 SD-6)", () => {
236
- it("passes a constraint violation through verbatim with ErrorInfo companions", async () => {
237
- insertOutcome = () => {
238
- throw new ConnectError("that slot is already booked", Code.AlreadyExists, undefined, [
239
- {
240
- desc: ErrorInfoSchema,
241
- value: create(ErrorInfoSchema, {
242
- reason: "CONSTRAINT_VIOLATION",
243
- domain: "datastore.stigmer.ai",
244
- metadata: { constraint: "one_confirmed_per_slot" },
245
- }),
246
- },
247
- ]);
248
- };
249
-
250
- const result = await callTool("insert_record", {
251
- datastore: "clinic",
252
- collection: "bookings",
253
- record: { slot_start: "2026-07-21T04:30:00Z", patient_name: "Ravi" },
254
- });
255
-
256
- expect(result.isError).toBe(true);
257
- expect(JSON.parse(result.content[0]?.text ?? "{}")).toEqual({
258
- error: "that slot is already booked",
259
- code: "ALREADY_EXISTS",
260
- reason: "CONSTRAINT_VIOLATION",
261
- constraint: "one_confirmed_per_slot",
262
- });
263
- });
264
-
265
- it("passes a permission denial through verbatim — never the shared rewrite", async () => {
266
- insertOutcome = () => {
267
- throw new ConnectError(
268
- "you are not allowed to insert records in schedule_exceptions",
269
- Code.PermissionDenied,
270
- undefined,
271
- [
272
- {
273
- desc: ErrorInfoSchema,
274
- value: create(ErrorInfoSchema, {
275
- reason: "VERB_DENIED",
276
- domain: "datastore.stigmer.ai",
277
- }),
278
- },
279
- ],
280
- );
281
- };
282
-
283
- const result = await callTool("insert_record", {
284
- datastore: "clinic",
285
- collection: "schedule_exceptions",
286
- record: { exception_date: "2026-07-22" },
287
- });
288
-
289
- expect(result.isError).toBe(true);
290
- const body = JSON.parse(result.content[0]?.text ?? "{}") as Record<string, string>;
291
- // The relayable domain bytes — NOT "Check your API key permissions".
292
- expect(body.error).toBe("you are not allowed to insert records in schedule_exceptions");
293
- expect(body.code).toBe("PERMISSION_DENIED");
294
- expect(body.reason).toBe("VERB_DENIED");
295
- expect(body.constraint).toBeUndefined();
296
- });
297
-
298
- it("delegates transport codes to the shared classifier", async () => {
299
- findResponse = () => {
300
- throw new ConnectError("upstream down", Code.Unavailable);
301
- };
302
-
303
- const result = await callTool("find_records", {
304
- datastore: "clinic",
305
- collection: "bookings",
306
- });
307
-
308
- expect(result.isError).toBe(true);
309
- expect(result.content[0]?.text).toBe(
310
- "Stigmer server is unavailable. Ensure it is running and reachable.",
311
- );
312
- });
313
- });