@stigmer/mcp-server 3.2.3 → 3.3.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.
@@ -0,0 +1,206 @@
1
+ // MCP tools for the Datastore record domain — the five agent-facing
2
+ // record tools of DD-005: 1:1 projections of the record RPCs, with the
3
+ // records-own error mapper (errors.ts) and honest MCP annotations (the
4
+ // bridge's first use of annotations; the platform's own connect-time
5
+ // classifier honors destructiveHint fail-closed for external clients,
6
+ // while the runner-synthesized attachment is approval-free by
7
+ // construction and never passes through that classifier).
8
+ //
9
+ // Two audiences, one definition (registered per roster):
10
+ // - "agent" (the records-only roster the runner-synthesized
11
+ // attachment connects to): NO `org` argument — a session-bound
12
+ // caller's org is server-derived from the session, and an explicit
13
+ // org is rejected (T05 R3), so offering the argument would only
14
+ // invite rejected calls.
15
+ // - "direct" (the main roster external MCP clients see): optional
16
+ // `org`, which cloud direct principals must name (their credential
17
+ // carries no ambient org — the DD-006 session-14 amendment). OSS
18
+ // resolves an empty org to the local system org.
19
+ // `partition` is deliberately NOT a tool argument for either audience
20
+ // (DD-010: never from tool arguments); partition-explicit work is the
21
+ // console/CLI/SDK surface.
22
+
23
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
+ import { z } from "zod";
25
+
26
+ import { resolveToken, type BackendTarget } from "../client.js";
27
+ import {
28
+ deleteRecord,
29
+ describeDatastore,
30
+ findRecords,
31
+ insertRecord,
32
+ updateRecord,
33
+ type ConditionArg,
34
+ } from "./calls.js";
35
+ import { recordResult } from "./errors.js";
36
+
37
+ /** Which roster the tools are being registered on (see file header). */
38
+ export type RecordToolAudience = "agent" | "direct";
39
+
40
+ const ENCODINGS =
41
+ "Field value encodings: date YYYY-MM-DD, time HH:MM[:SS], timestamp RFC 3339 UTC " +
42
+ "(e.g. 2026-07-21T04:30:00Z).";
43
+
44
+ const scalar = z.union([z.string(), z.number(), z.boolean()]);
45
+
46
+ const conditionShape = z.object({
47
+ field: z
48
+ .string()
49
+ .describe("Declared field name, or a filterable system field (id, created_at, updated_at)."),
50
+ op: z
51
+ .enum(["eq", "neq", "gt", "gte", "lt", "lte", "in", "not_in", "is_null", "not_null"])
52
+ .describe(
53
+ "Comparison operator. eq/neq/in/not_in for string and enum fields; " +
54
+ "gt/gte/lt/lte for numeric and temporal fields; is_null/not_null for optional fields.",
55
+ ),
56
+ value: scalar
57
+ .nullable()
58
+ .optional()
59
+ .describe("Comparison value for scalar operators, in the field's canonical encoding."),
60
+ values: z.array(scalar).optional().describe("Comparison values for in / not_in."),
61
+ });
62
+
63
+ /** Register the five record tools for one audience; returns the tool names. */
64
+ export function registerRecordTools(
65
+ server: McpServer,
66
+ target: BackendTarget,
67
+ audience: RecordToolAudience,
68
+ ): string[] {
69
+ // The org argument exists only on the direct roster (file header).
70
+ const orgShape: { org?: z.ZodOptional<z.ZodString> } = {};
71
+ if (audience === "direct") {
72
+ orgShape.org = z
73
+ .string()
74
+ .optional()
75
+ .describe(
76
+ "Organization that owns the datastore. Required on Stigmer Cloud; " +
77
+ "omit against a local backend.",
78
+ );
79
+ }
80
+
81
+ server.registerTool(
82
+ "describe_datastore",
83
+ {
84
+ description:
85
+ "Describe a datastore: its collections, field declarations and encodings, constraint " +
86
+ "messages, data partitions, and the verbs you are allowed to use per collection " +
87
+ "(empty access means you are not allowed to touch that collection). Call this before " +
88
+ `the first record operation against an unfamiliar datastore. ${ENCODINGS}`,
89
+ inputSchema: {
90
+ datastore: z.string().describe("Datastore slug (e.g. clinic)."),
91
+ ...orgShape,
92
+ },
93
+ annotations: { readOnlyHint: true },
94
+ },
95
+ (args, extra) =>
96
+ recordResult(`datastore "${args.datastore}"`, () =>
97
+ describeDatastore(target.serverAddress, resolveToken(extra, target.apiKey), args),
98
+ ),
99
+ );
100
+
101
+ server.registerTool(
102
+ "find_records",
103
+ {
104
+ description:
105
+ "Find records in a datastore collection with a typed filter (conditions are AND-combined). " +
106
+ "Returns records plus total/limit/offset for paging. Results are ordered by created_at " +
107
+ `descending unless order_by is given. ${ENCODINGS}`,
108
+ inputSchema: {
109
+ datastore: z.string().describe("Datastore slug (e.g. clinic)."),
110
+ ...orgShape,
111
+ collection: z.string().describe("Collection to query (e.g. bookings)."),
112
+ conditions: z
113
+ .array(conditionShape)
114
+ .optional()
115
+ .describe("Filter conditions, AND-combined. Omit to list all readable records."),
116
+ order_by: z
117
+ .object({
118
+ field: z.string().describe("Field to sort by."),
119
+ direction: z.enum(["asc", "desc"]).optional().describe("Sort direction (default asc)."),
120
+ })
121
+ .optional()
122
+ .describe("Sort order. Omit for created_at descending with id tiebreak."),
123
+ limit: z.number().int().optional().describe("Page size (default 25, max 100)."),
124
+ offset: z.number().int().optional().describe("Records to skip for paging."),
125
+ },
126
+ annotations: { readOnlyHint: true },
127
+ },
128
+ (args, extra) =>
129
+ recordResult(`records in "${args.datastore}/${args.collection}"`, () =>
130
+ findRecords(target.serverAddress, resolveToken(extra, target.apiKey), {
131
+ ...args,
132
+ conditions: args.conditions as ConditionArg[] | undefined,
133
+ }),
134
+ ),
135
+ );
136
+
137
+ server.registerTool(
138
+ "insert_record",
139
+ {
140
+ description:
141
+ "Insert one record into a datastore collection. System fields (id, created_at, " +
142
+ "updated_at, created_by) are server-stamped — never include them. Declared constraints " +
143
+ "are enforced by the store; a violation returns the constraint's message verbatim " +
144
+ "(e.g. a taken slot returns ALREADY_EXISTS — pick another, do not retry the same one). " +
145
+ `When uncertain whether a record already exists, find first. ${ENCODINGS}`,
146
+ inputSchema: {
147
+ datastore: z.string().describe("Datastore slug (e.g. clinic)."),
148
+ ...orgShape,
149
+ collection: z.string().describe("Collection to insert into (e.g. bookings)."),
150
+ record: z
151
+ .record(z.unknown())
152
+ .describe("Declared fields for the new record, in canonical encodings."),
153
+ },
154
+ },
155
+ (args, extra) =>
156
+ recordResult(`record in "${args.datastore}/${args.collection}"`, () =>
157
+ insertRecord(target.serverAddress, resolveToken(extra, target.apiKey), args),
158
+ ),
159
+ );
160
+
161
+ server.registerTool(
162
+ "update_record",
163
+ {
164
+ description:
165
+ "Update one record by id with a partial merge: only the supplied fields change, and an " +
166
+ "explicit null clears a field. Constraints are re-evaluated on the merged result. " +
167
+ `Returns the full updated record. ${ENCODINGS}`,
168
+ inputSchema: {
169
+ datastore: z.string().describe("Datastore slug (e.g. clinic)."),
170
+ ...orgShape,
171
+ collection: z.string().describe("Collection holding the record."),
172
+ id: z.string().describe("Record id (from a previous find or insert)."),
173
+ fields: z
174
+ .record(z.unknown())
175
+ .describe("Fields to merge. Explicit null clears a field; omitted fields keep their values."),
176
+ },
177
+ annotations: { idempotentHint: true },
178
+ },
179
+ (args, extra) =>
180
+ recordResult(`record "${args.id}" in "${args.datastore}/${args.collection}"`, () =>
181
+ updateRecord(target.serverAddress, resolveToken(extra, target.apiKey), args),
182
+ ),
183
+ );
184
+
185
+ server.registerTool(
186
+ "delete_record",
187
+ {
188
+ description:
189
+ "Delete one record by id. Returns the deleted record. Record tools never delete " +
190
+ "collections or datastores.",
191
+ inputSchema: {
192
+ datastore: z.string().describe("Datastore slug (e.g. clinic)."),
193
+ ...orgShape,
194
+ collection: z.string().describe("Collection holding the record."),
195
+ id: z.string().describe("Record id (from a previous find or insert)."),
196
+ },
197
+ annotations: { destructiveHint: true, idempotentHint: true },
198
+ },
199
+ (args, extra) =>
200
+ recordResult(`record "${args.id}" in "${args.datastore}/${args.collection}"`, () =>
201
+ deleteRecord(target.serverAddress, resolveToken(extra, target.apiKey), args),
202
+ ),
203
+ );
204
+
205
+ return ["describe_datastore", "find_records", "insert_record", "update_record", "delete_record"];
206
+ }
package/src/gen/agent.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import { generateSlug, visibilityFromString } from "./apply-runtime.js";
7
7
  import { create } from "@bufbuild/protobuf";
8
8
  import { AgentSchema, type Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
9
- import { AgentSpecSchema, ToolApprovalOverrideSchema, McpServerUsageSchema, McpAccessSchema, SubAgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
9
+ import { AgentSpecSchema, ToolApprovalOverrideSchema, McpServerUsageSchema, McpAccessSchema, SubAgentSchema, DatastoreUsageSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
10
10
  import { EnvVarDeclarationSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
11
11
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
12
12
  import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
@@ -28,6 +28,7 @@ export const AgentInputShape = {
28
28
  skill_refs: z.array(z.lazy(() => SkillRefInputSchema)).optional().describe("Skill resources providing additional knowledge to the agent."),
29
29
  sub_agents: z.array(z.lazy(() => SubAgentInputSchema)).optional().describe("Sub-agents that can be delegated to. Sub-agents can access a subset of the parent's MCP servers and tools."),
30
30
  env: z.record(z.lazy(() => EnvVarDeclarationInputSchema)).optional().describe("Environment variable declarations for this agent. Keys are variable names; values describe their metadata and optionality."),
31
+ datastore_usages: z.array(z.lazy(() => DatastoreUsageInputSchema)).optional().describe("Datastores this agent can use. Each entry must reference a Datastore resource by slug."),
31
32
  } as const;
32
33
 
33
34
  export const AgentInputSchema = z.object(AgentInputShape);
@@ -83,6 +84,17 @@ const EnvVarDeclarationInputSchema = z.object({
83
84
  });
84
85
  type EnvVarDeclarationInput = z.infer<typeof EnvVarDeclarationInputSchema>;
85
86
 
87
+ const DatastoreRefInputSchema = z.object({
88
+ org: z.string().optional().describe("Organization that owns the referenced resource. When non-empty: must be a valid org slug (lowercase alphanumeric with hyphens, starts with a letter, 1-63 characters). Example: 'stigmer', 'acme-corp'. When empty: the reference is relative — the server resolves it to the parent resource's organization at write time. All stored and returned references always have org populated (absolute form). Use empty org for same-org references (the common case). Use explicit org for cross-org references (e.g., marketplace resources)."),
89
+ slug: z.string().describe("Resource slug (user-friendly identifier, unique within org). Format: lowercase alphanumeric with hyphens, must start with a letter and end with a letter or digit (e.g., 'web-search', 'code-reviewer'). Length: 2-63 characters."),
90
+ });
91
+ type DatastoreRefInput = z.infer<typeof DatastoreRefInputSchema>;
92
+
93
+ const DatastoreUsageInputSchema = z.object({
94
+ datastore_ref: z.lazy(() => DatastoreRefInputSchema).describe("Reference to the Datastore resource."),
95
+ });
96
+ type DatastoreUsageInput = z.infer<typeof DatastoreUsageInputSchema>;
97
+
86
98
 
87
99
  /** Build the fully-formed Agent proto from the flat MCP apply input. */
88
100
  export function agentInputToProto(input: AgentInput): Agent {
@@ -97,6 +109,7 @@ export function agentInputToProto(input: AgentInput): Agent {
97
109
  if (input.env !== undefined) {
98
110
  for (const [k, v] of Object.entries(input.env)) spec.env[k] = envVarDeclarationInputToProto(v);
99
111
  }
112
+ if (input.datastore_usages !== undefined) spec.datastoreUsages = input.datastore_usages.map(datastoreUsageInputToProto);
100
113
  return Object.assign(create(AgentSchema), {
101
114
  apiVersion: "agentic.stigmer.ai/v1",
102
115
  kind: "Agent",
@@ -171,3 +184,17 @@ function envVarDeclarationInputToProto(input: EnvVarDeclarationInput) {
171
184
  return result;
172
185
  }
173
186
 
187
+ function datastoreRefInputToProto(input: DatastoreRefInput) {
188
+ return create(ApiResourceReferenceSchema, {
189
+ org: input.org,
190
+ slug: input.slug,
191
+ kind: ApiResourceKind.datastore,
192
+ });
193
+ }
194
+
195
+ function datastoreUsageInputToProto(input: DatastoreUsageInput) {
196
+ const result = create(DatastoreUsageSchema);
197
+ if (input.datastore_ref !== undefined) result.datastoreRef = datastoreRefInputToProto(input.datastore_ref);
198
+ return result;
199
+ }
200
+
@@ -36,6 +36,7 @@ beforeAll(async () => {
36
36
  stigmerServerAddress: "localhost:7234",
37
37
  apiKey: "",
38
38
  transport: "http",
39
+ roster: "full",
39
40
  httpPort: String(port),
40
41
  httpAuthEnabled: true,
41
42
  oauth: {
package/src/index.ts CHANGED
@@ -6,12 +6,19 @@
6
6
 
7
7
  import { loadConfigFromEnv, validateConfig, type Config } from "./config.js";
8
8
  import { configureLogger, log } from "./logger.js";
9
- import { createServer, serveBoth, serveHttp, serveStdio, isNormalShutdown } from "./server.js";
9
+ import {
10
+ isNormalShutdown,
11
+ routedServerFactory,
12
+ serveBoth,
13
+ serveHttp,
14
+ serveStdio,
15
+ stdioServer,
16
+ } from "./server.js";
10
17
  import type { BackendTarget } from "./domains/client.js";
11
18
 
12
- export type { Config, OAuthConfig, Transport } from "./config.js";
19
+ export type { Config, OAuthConfig, Roster, Transport } from "./config.js";
13
20
  export { loadConfigFromEnv, validateConfig } from "./config.js";
14
- export { createServer, SERVER_VERSION } from "./server.js";
21
+ export { createServer, createRecordsServer, RECORDS_ROUTE, SERVER_VERSION } from "./server.js";
15
22
 
16
23
  /** Returns a Config populated from environment variables (no validation). */
17
24
  export function defaultConfig(): Config {
@@ -44,10 +51,10 @@ export async function run(cfg: Config, signal: AbortSignal): Promise<void> {
44
51
  try {
45
52
  switch (cfg.transport) {
46
53
  case "stdio":
47
- await serveStdio(createServer(target), signal);
54
+ await serveStdio(stdioServer(target, cfg), signal);
48
55
  break;
49
56
  case "http":
50
- await serveHttp(() => createServer(target), cfg, signal);
57
+ await serveHttp(routedServerFactory(target), cfg, signal);
51
58
  break;
52
59
  case "both":
53
60
  await serveBoth(target, cfg, signal);
package/src/server.ts CHANGED
@@ -24,6 +24,7 @@ import { registerAgentTools } from "./domains/agents/tools.js";
24
24
  import type { BackendTarget } from "./domains/client.js";
25
25
  import { registerMcpServerResources } from "./domains/mcpservers/resources.js";
26
26
  import { registerMcpServerTools } from "./domains/mcpservers/tools.js";
27
+ import { registerRecordTools } from "./domains/records/tools.js";
27
28
  import { registerSearchTools } from "./domains/search/tools.js";
28
29
  import { registerSkillResources } from "./domains/skills/resources.js";
29
30
  import { registerSkillTools } from "./domains/skills/tools.js";
@@ -60,6 +61,22 @@ export function createServer(target: BackendTarget): McpServer {
60
61
  return server;
61
62
  }
62
63
 
64
+ /**
65
+ * Build a records-only MCP server: the five record tools with the
66
+ * agent-facing argument surface, and nothing else (T05 R1). This is
67
+ * the roster the runner-synthesized datastore attachment connects to —
68
+ * a structural guarantee that an agent session never sees the
69
+ * management tools (apply/delete/…) its empty approval maps would make
70
+ * approval-free. Served on the /records HTTP route and as the stdio
71
+ * roster when STIGMER_MCP_ROSTER=records.
72
+ */
73
+ export function createRecordsServer(target: BackendTarget): McpServer {
74
+ const server = new McpServer({ name: "mcp-server-stigmer-records", version: SERVER_VERSION });
75
+ const tools = registerRecordTools(server, target, "agent");
76
+ log.info("tools registered (records roster)", { count: tools.length, tools });
77
+ return server;
78
+ }
79
+
63
80
  /**
64
81
  * Wire up every domain's tools. Each domain returns the names it registered so
65
82
  * the startup log's count and roster cannot drift from what is actually wired,
@@ -75,6 +92,10 @@ function registerTools(server: McpServer, target: BackendTarget): void {
75
92
  ...registerValidateWorkflowYamlTool(server, target),
76
93
  ...registerTaskKindTools(server, target),
77
94
  ...registerWorkflowExecutionTools(server, target),
95
+ // The record tools also serve external MCP clients — as direct
96
+ // principals with the org argument and honest annotations (the
97
+ // agent-facing variant lives on the records-only roster).
98
+ ...registerRecordTools(server, target, "direct"),
78
99
  ];
79
100
  log.info("tools registered", { count: tools.length, tools });
80
101
  }
@@ -123,6 +144,25 @@ export async function serveStdio(server: McpServer, signal: AbortSignal): Promis
123
144
  /** Builds a fresh, fully-registered server. One is created per MCP session. */
124
145
  export type ServerFactory = () => McpServer;
125
146
 
147
+ /**
148
+ * Builds the server for an inbound HTTP `initialize` request, selected
149
+ * by request path. Only the initialize request consults the path — an
150
+ * established session's transport already carries the server it was
151
+ * built with, so follow-up requests dispatch by Mcp-Session-Id alone.
152
+ */
153
+ export type RouteServerFactory = (path: string) => McpServer;
154
+
155
+ /** HTTP route serving the records-only roster (T05 R1). */
156
+ export const RECORDS_ROUTE = "/records";
157
+
158
+ /**
159
+ * The standard HTTP route dispatch: the records-only roster on
160
+ * {@link RECORDS_ROUTE}, the full roster everywhere else.
161
+ */
162
+ export function routedServerFactory(target: BackendTarget): RouteServerFactory {
163
+ return (path) => (path === RECORDS_ROUTE ? createRecordsServer(target) : createServer(target));
164
+ }
165
+
126
166
  /**
127
167
  * Serve over Streamable HTTP until `signal` aborts.
128
168
  *
@@ -144,7 +184,11 @@ export type ServerFactory = () => McpServer;
144
184
  * DNS-rebinding allow-lists are intentionally out of parity scope (the Go server
145
185
  * has none).
146
186
  */
147
- export async function serveHttp(makeServer: ServerFactory, cfg: Config, signal: AbortSignal): Promise<void> {
187
+ export async function serveHttp(
188
+ makeServer: RouteServerFactory,
189
+ cfg: Config,
190
+ signal: AbortSignal,
191
+ ): Promise<void> {
148
192
  const sessions = new Map<string, StreamableHTTPServerTransport>();
149
193
 
150
194
  const httpServer = createHttpServer((req, res) => {
@@ -189,8 +233,8 @@ export async function serveBoth(target: BackendTarget, cfg: Config, signal: Abor
189
233
  signal.addEventListener("abort", onParentAbort, { once: true });
190
234
 
191
235
  const tasks = [
192
- serveStdio(createServer(target), linked.signal),
193
- serveHttp(() => createServer(target), cfg, linked.signal),
236
+ serveStdio(stdioServer(target, cfg), linked.signal),
237
+ serveHttp(routedServerFactory(target), cfg, linked.signal),
194
238
  ];
195
239
 
196
240
  try {
@@ -215,7 +259,7 @@ async function routeRequest(
215
259
  req: IncomingMessage & { auth?: AuthInfo },
216
260
  res: ServerResponse,
217
261
  sessions: Map<string, StreamableHTTPServerTransport>,
218
- makeServer: ServerFactory,
262
+ makeServer: RouteServerFactory,
219
263
  cfg: Config,
220
264
  ): Promise<void> {
221
265
  if (req.method === "GET" && req.url === "/health") {
@@ -247,6 +291,7 @@ async function routeRequest(
247
291
  req.auth = { token, clientId: "stigmer-mcp-passthrough", scopes: [] };
248
292
  }
249
293
 
294
+ const path = requestPath(req);
250
295
  const sessionId = headerValue(req, "mcp-session-id");
251
296
 
252
297
  // Established session → dispatch to its transport.
@@ -288,10 +333,19 @@ async function routeRequest(
288
333
  if (transport.sessionId !== undefined) sessions.delete(transport.sessionId);
289
334
  };
290
335
 
291
- await makeServer().connect(transport);
336
+ await makeServer(path).connect(transport);
292
337
  await transport.handleRequest(req, res, body);
293
338
  }
294
339
 
340
+ /**
341
+ * The stdio server for the configured roster: the records-only roster
342
+ * when STIGMER_MCP_ROSTER=records (what the OSS runner-synthesized
343
+ * datastore attachment spawns), the full roster otherwise.
344
+ */
345
+ export function stdioServer(target: BackendTarget, cfg: Config): McpServer {
346
+ return cfg.roster === "records" ? createRecordsServer(target) : createServer(target);
347
+ }
348
+
295
349
  /** Return a single header value, collapsing the array form Node may produce. */
296
350
  function headerValue(req: IncomingMessage, name: string): string | undefined {
297
351
  const v = req.headers[name];