@stigmer/mcp-server 3.12.7 → 3.12.9

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 (40) hide show
  1. package/cli/mcp-server-stigmer.js +4 -4
  2. package/config.d.ts +4 -1
  3. package/config.d.ts.map +1 -1
  4. package/config.js +2 -2
  5. package/config.js.map +1 -1
  6. package/domains/memory/calls.d.ts +8 -0
  7. package/domains/memory/calls.d.ts.map +1 -0
  8. package/domains/memory/calls.js +60 -0
  9. package/domains/memory/calls.js.map +1 -0
  10. package/domains/memory/context.d.ts +56 -0
  11. package/domains/memory/context.d.ts.map +1 -0
  12. package/domains/memory/context.js +92 -0
  13. package/domains/memory/context.js.map +1 -0
  14. package/domains/memory/errors.d.ts +21 -0
  15. package/domains/memory/errors.d.ts.map +1 -0
  16. package/domains/memory/errors.js +72 -0
  17. package/domains/memory/errors.js.map +1 -0
  18. package/domains/memory/tools.d.ts +12 -0
  19. package/domains/memory/tools.d.ts.map +1 -0
  20. package/domains/memory/tools.js +48 -0
  21. package/domains/memory/tools.js.map +1 -0
  22. package/index.d.ts +1 -1
  23. package/index.d.ts.map +1 -1
  24. package/index.js +1 -1
  25. package/index.js.map +1 -1
  26. package/package.json +3 -3
  27. package/server.d.ts +22 -4
  28. package/server.d.ts.map +1 -1
  29. package/server.js +32 -4
  30. package/server.js.map +1 -1
  31. package/src/config.ts +6 -3
  32. package/src/domains/memory/calls.ts +74 -0
  33. package/src/domains/memory/context.test.ts +95 -0
  34. package/src/domains/memory/context.ts +125 -0
  35. package/src/domains/memory/errors.ts +89 -0
  36. package/src/domains/memory/memory.integration.test.ts +253 -0
  37. package/src/domains/memory/tools.ts +75 -0
  38. package/src/http.integration.test.ts +1 -0
  39. package/src/index.ts +2 -0
  40. package/src/server.ts +38 -4
@@ -0,0 +1,253 @@
1
+ // In-process integration test for the memory roster (the
2
+ // channels.integration.test.ts pattern: real Connect backend with a
3
+ // stubbed memory service, real MCP client over an in-memory transport).
4
+ //
5
+ // Verifies the DD-005 D1/D2 contract surface:
6
+ // - the memory-only roster is exactly remember with ONLY a fact
7
+ // argument (agent audience; org, subject, and provenance are never
8
+ // the model's to supply);
9
+ // - argument + capture-context → request mapping: fact → spec.content,
10
+ // context org → metadata.org, context triple → spec.provenance,
11
+ // subject never travels, tool_call_id stays empty (v1);
12
+ // - the answer is the chip contract: { outcome, memory } with the
13
+ // created record as verbatim proto JSON and the honest
14
+ // proposed-not-remembered outcome line;
15
+ // - the memory-own error mapper passes domain messages verbatim as
16
+ // {error, code, reason} JSON; transport errors delegate to the
17
+ // shared classifier.
18
+
19
+ import { create } from "@bufbuild/protobuf";
20
+ import { Code, ConnectError, type ConnectRouter } from "@connectrpc/connect";
21
+ import { connectNodeAdapter } from "@connectrpc/connect-node";
22
+ import {
23
+ createServer as createHttp2Server,
24
+ type Http2Server,
25
+ type ServerHttp2Session,
26
+ } from "node:http2";
27
+ import type { AddressInfo } from "node:net";
28
+
29
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
30
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
31
+ import { MemorySchema, type Memory } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_pb";
32
+ import { MemoryCommandController } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/command_pb";
33
+ import { MemoryLifecycleState } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/enum_pb";
34
+ import { MemorySpecSchema } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/spec_pb";
35
+ import { MemoryStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/status_pb";
36
+ import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
37
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
38
+
39
+ import { configureLogger } from "../../logger";
40
+ import { MEMORY_ROUTE, createMemoryServer } from "../../server";
41
+ import { PROPOSED_OUTCOME } from "./calls";
42
+ import {
43
+ MEMORY_AGENT_ID_ENV,
44
+ MEMORY_AGENT_ID_HEADER,
45
+ MEMORY_EXECUTION_ID_ENV,
46
+ MEMORY_EXECUTION_ID_HEADER,
47
+ MEMORY_ORG_ENV,
48
+ MEMORY_ORG_HEADER,
49
+ MEMORY_SESSION_ID_ENV,
50
+ MEMORY_SESSION_ID_HEADER,
51
+ } from "./context";
52
+
53
+ configureLogger({ level: "error", format: "text" });
54
+
55
+ let backend: Http2Server;
56
+ let client: Client;
57
+ const openSessions = new Set<ServerHttp2Session>();
58
+
59
+ /** The next stubbed created record; tests set this. */
60
+ let createResponse: () => Memory;
61
+
62
+ // Requests the stub captured. An array, deliberately: resetting an optional
63
+ // property to undefined narrows its type to `undefined` for the rest of the
64
+ // flow (tsc does not un-narrow across the intervening callTool call), which
65
+ // makes every later `req?.x` a property access on `never` under
66
+ // `npm run typecheck` — the job that, unlike tsconfig.build.json, includes
67
+ // tests. Clearing and reading an array never narrows.
68
+ const capturedCreates: Memory[] = [];
69
+
70
+ interface ToolResult {
71
+ content: Array<{ type: string; text?: string }>;
72
+ isError?: boolean;
73
+ }
74
+
75
+ async function remember(args: Record<string, unknown>): Promise<ToolResult> {
76
+ return (await client.callTool({ name: "remember", arguments: args })) as ToolResult;
77
+ }
78
+
79
+ /** A plausible created record echoing the request's content. */
80
+ function proposedRecord(content: string): Memory {
81
+ return create(MemorySchema, {
82
+ apiVersion: "agentic.stigmer.ai/v1",
83
+ kind: "Memory",
84
+ metadata: create(ApiResourceMetadataSchema, { id: "mem_01test", org: "acme" }),
85
+ spec: create(MemorySpecSchema, {
86
+ content,
87
+ subjectIdentityAccountId: "ida_subject",
88
+ }),
89
+ status: create(MemoryStatusSchema, {
90
+ lifecycleState: MemoryLifecycleState.lifecycle_state_proposed,
91
+ }),
92
+ });
93
+ }
94
+
95
+ beforeAll(async () => {
96
+ const routes = (router: ConnectRouter) => {
97
+ router.service(MemoryCommandController, {
98
+ create: (req) => {
99
+ capturedCreates.push(req);
100
+ return createResponse();
101
+ },
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 stdio shape: the startup capture context stands in for the
113
+ // runner-set STIGMER_MEMORY_* environment.
114
+ const mcp = createMemoryServer(
115
+ { serverAddress: `127.0.0.1:${port}`, apiKey: "" },
116
+ { org: "acme", agentId: "agt_1", sessionId: "ses_1", agentExecutionId: "aex_1" },
117
+ );
118
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
119
+ client = new Client({ name: "memory-integration", version: "test" });
120
+ await Promise.all([mcp.connect(serverTransport), client.connect(clientTransport)]);
121
+ });
122
+
123
+ afterAll(async () => {
124
+ await client?.close();
125
+ for (const session of openSessions) session.destroy();
126
+ await new Promise<void>((resolve) => backend.close(() => resolve()));
127
+ });
128
+
129
+ describe("memory roster (DD-005 D1)", () => {
130
+ it("exposes exactly remember with only a fact argument", async () => {
131
+ const { tools } = await client.listTools();
132
+ expect(tools.map((t) => t.name)).toEqual(["remember"]);
133
+
134
+ // The agent audience supplies the fact and NOTHING else — org,
135
+ // subject, and provenance derive from the credential and the
136
+ // runner-threaded capture context, so no argument exists to forge
137
+ // them (the channels no-org rule, applied to attribution).
138
+ const properties = (tools[0].inputSchema as { properties?: Record<string, unknown> })
139
+ .properties;
140
+ expect(Object.keys(properties ?? {})).toEqual(["fact"]);
141
+ });
142
+
143
+ it("pins the cross-repo attachment strings (the TOOL_CALL_LIMIT precedent)", () => {
144
+ // The runner's memory-attachment.ts builds these independently; a
145
+ // drift strands every synthesized attachment on a 404 (route) or an
146
+ // attribution-less record (context keys).
147
+ expect(MEMORY_ROUTE).toBe("/memory");
148
+ expect(MEMORY_ORG_ENV).toBe("STIGMER_MEMORY_ORG");
149
+ expect(MEMORY_AGENT_ID_ENV).toBe("STIGMER_MEMORY_AGENT_ID");
150
+ expect(MEMORY_SESSION_ID_ENV).toBe("STIGMER_MEMORY_SESSION_ID");
151
+ expect(MEMORY_EXECUTION_ID_ENV).toBe("STIGMER_MEMORY_EXECUTION_ID");
152
+ expect(MEMORY_ORG_HEADER).toBe("x-stigmer-memory-org");
153
+ expect(MEMORY_AGENT_ID_HEADER).toBe("x-stigmer-memory-agent-id");
154
+ expect(MEMORY_SESSION_ID_HEADER).toBe("x-stigmer-memory-session-id");
155
+ expect(MEMORY_EXECUTION_ID_HEADER).toBe("x-stigmer-memory-execution-id");
156
+ });
157
+ });
158
+
159
+ describe("argument + capture context → request mapping (DD-005 D2)", () => {
160
+ it("maps the fact and the startup context; subject never travels", async () => {
161
+ createResponse = () => proposedRecord("Prefers concise answers.");
162
+ capturedCreates.length = 0;
163
+
164
+ const result = await remember({ fact: "Prefers concise answers." });
165
+
166
+ expect(result.isError).toBeFalsy();
167
+ const req = capturedCreates.at(-1);
168
+ expect(req?.metadata?.org).toBe("acme");
169
+ expect(req?.metadata?.name).toBe(""); // id-addressed; the server defaults the name
170
+ expect(req?.spec?.content).toBe("Prefers concise answers.");
171
+ // Subject is the server's to derive from the credential — the tool
172
+ // never supplies one, so forgery is structurally impossible.
173
+ expect(req?.spec?.subjectIdentityAccountId).toBe("");
174
+ expect(req?.spec?.provenance?.agentId).toBe("agt_1");
175
+ expect(req?.spec?.provenance?.sessionId).toBe("ses_1");
176
+ expect(req?.spec?.provenance?.agentExecutionId).toBe("aex_1");
177
+ // v1: MCP does not carry the harness's tool-call identity.
178
+ expect(req?.spec?.provenance?.toolCallId).toBe("");
179
+ });
180
+
181
+ it("answers with the chip contract: outcome line + created record as proto JSON", async () => {
182
+ createResponse = () => proposedRecord("Works primarily in Go.");
183
+
184
+ const result = await remember({ fact: "Works primarily in Go." });
185
+
186
+ expect(result.isError).toBeFalsy();
187
+ const body = JSON.parse(result.content[0]?.text ?? "{}") as {
188
+ outcome?: string;
189
+ memory?: Record<string, unknown>;
190
+ };
191
+ // The honest relay (DD-005 D2): proposed, the user decides.
192
+ expect(body.outcome).toBe(PROPOSED_OUTCOME);
193
+ // The record rides verbatim with proto (snake_case) field names —
194
+ // what the SDK's normalizeToolResult parses to render the chip.
195
+ const memory = body.memory as {
196
+ metadata?: { id?: string };
197
+ spec?: { content?: string };
198
+ status?: { lifecycle_state?: string };
199
+ };
200
+ expect(memory?.metadata?.id).toBe("mem_01test");
201
+ expect(memory?.spec?.content).toBe("Works primarily in Go.");
202
+ expect(memory?.status?.lifecycle_state).toBe("lifecycle_state_proposed");
203
+ });
204
+ });
205
+
206
+ describe("memory error mapper", () => {
207
+ it("passes the visible-full refusal through verbatim — never the shared rewrite", async () => {
208
+ createResponse = () => {
209
+ throw new ConnectError(
210
+ "memory is full — review and delete existing memories",
211
+ Code.FailedPrecondition,
212
+ );
213
+ };
214
+
215
+ const result = await remember({ fact: "One fact too many." });
216
+
217
+ expect(result.isError).toBe(true);
218
+ expect(JSON.parse(result.content[0]?.text ?? "{}")).toEqual({
219
+ error: "memory is full — review and delete existing memories",
220
+ code: "FAILED_PRECONDITION",
221
+ });
222
+ });
223
+
224
+ it("passes the caller-gate refusal through verbatim", async () => {
225
+ createResponse = () => {
226
+ throw new ConnectError(
227
+ "memory capture is limited to first-party sessions",
228
+ Code.PermissionDenied,
229
+ );
230
+ };
231
+
232
+ const result = await remember({ fact: "A fact from the wrong caller." });
233
+
234
+ expect(result.isError).toBe(true);
235
+ expect(JSON.parse(result.content[0]?.text ?? "{}")).toEqual({
236
+ error: "memory capture is limited to first-party sessions",
237
+ code: "PERMISSION_DENIED",
238
+ });
239
+ });
240
+
241
+ it("delegates transport codes to the shared classifier", async () => {
242
+ createResponse = () => {
243
+ throw new ConnectError("upstream down", Code.Unavailable);
244
+ };
245
+
246
+ const result = await remember({ fact: "Any fact." });
247
+
248
+ expect(result.isError).toBe(true);
249
+ expect(result.content[0]?.text).toBe(
250
+ "Stigmer server is unavailable. Ensure it is running and reachable.",
251
+ );
252
+ });
253
+ });
@@ -0,0 +1,75 @@
1
+ // The remember tool — the ONE tool of the memory roster (DD-005 D1: the
2
+ // first-party capture verb both harnesses receive through the
3
+ // runner-synthesized memory attachment).
4
+ //
5
+ // Agent audience only, by construction: this roster is what the memory
6
+ // attachment connects to, and the record's org, subject, and provenance
7
+ // all derive from the credential and the runner-threaded capture context
8
+ // (context.ts) — no org argument exists to invite rejected calls, and no
9
+ // argument can aim the record at another person. The tool is deliberately
10
+ // NOT on the full roster: a human operator manages memories through the
11
+ // console and SDK, which carry the addressing this path derives.
12
+ //
13
+ // The tool creates a PROPOSAL and nothing more (DD-005 D2/D3): the record
14
+ // lands lifecycle_state=proposed, and only the user's confirm — a
15
+ // control-plane command the model cannot reach — makes it recallable. The
16
+ // answer's `outcome` line states this so the model relays honestly.
17
+
18
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { z } from "zod";
20
+
21
+ import { resolveToken, type BackendTarget } from "../client.js";
22
+ import { proposeMemory } from "./calls.js";
23
+ import {
24
+ resolveCaptureContext,
25
+ type CaptureContext,
26
+ type RequestHeaders,
27
+ } from "./context.js";
28
+ import { memoryResult } from "./errors.js";
29
+
30
+ /**
31
+ * Register the memory capture tool; returns the tool names.
32
+ *
33
+ * `startupContext` is the stdio-shape capture context (env-loaded at
34
+ * server construction); over HTTP each request's headers supersede it —
35
+ * the resolveToken fallback shape, applied to attribution.
36
+ */
37
+ export function registerMemoryTools(
38
+ server: McpServer,
39
+ target: BackendTarget,
40
+ startupContext: CaptureContext,
41
+ ): string[] {
42
+ server.registerTool(
43
+ "remember",
44
+ {
45
+ description:
46
+ "Propose one durable fact about the person you are assisting, to be recalled in " +
47
+ "their future sessions — a stable preference, situation, or way of working " +
48
+ "(e.g. \"Prefers concise answers with code examples\"). Not task state, not " +
49
+ "secrets or credentials, not one-off details. The fact is only PROPOSED: the " +
50
+ "user reviews the exact text and decides — tell them you have suggested it, " +
51
+ "never that you have remembered it. Keep each fact self-contained and under " +
52
+ "500 characters; call once per fact.",
53
+ inputSchema: {
54
+ fact: z
55
+ .string()
56
+ .describe(
57
+ "The fact to remember, stated in the third person and self-contained " +
58
+ '(e.g. "Works primarily in Go and prefers table-driven tests"). ' +
59
+ "1–500 characters; stored and shown to the user verbatim.",
60
+ ),
61
+ },
62
+ },
63
+ (args, extra) =>
64
+ memoryResult("memory proposal", () =>
65
+ proposeMemory(
66
+ target.serverAddress,
67
+ resolveToken(extra, target.apiKey),
68
+ args.fact,
69
+ resolveCaptureContext(extra as RequestHeaders, startupContext),
70
+ ),
71
+ ),
72
+ );
73
+
74
+ return ["remember"];
75
+ }
@@ -212,6 +212,7 @@ describe("HTTP route dispatch (the closed route table)", () => {
212
212
  ["/", "mcp-server-stigmer"],
213
213
  ["/channels", "mcp-server-stigmer-channels"],
214
214
  ["/conversation", "mcp-server-stigmer-conversation"],
215
+ ["/memory", "mcp-server-stigmer-memory"],
215
216
  ])("serves the %s roster as %s", async (path, serverName) => {
216
217
  const res = await initialize(path);
217
218
  expect(res.status).toBe(200);
package/src/index.ts CHANGED
@@ -23,8 +23,10 @@ export {
23
23
  CONVERSATION_ROUTE,
24
24
  createChannelsServer,
25
25
  createConversationServer,
26
+ createMemoryServer,
26
27
  createServer,
27
28
  FULL_ROUTE,
29
+ MEMORY_ROUTE,
28
30
  SERVER_VERSION,
29
31
  } from "./server.js";
30
32
 
package/src/server.ts CHANGED
@@ -31,6 +31,11 @@ import { registerEnvironmentTools } from "./domains/environments/tools.js";
31
31
  import { registerExecutionControlTools } from "./domains/executions/tools.js";
32
32
  import { registerMcpServerResources } from "./domains/mcpservers/resources.js";
33
33
  import { registerMcpServerTools } from "./domains/mcpservers/tools.js";
34
+ import {
35
+ loadCaptureContextFromEnv,
36
+ type CaptureContext,
37
+ } from "./domains/memory/context.js";
38
+ import { registerMemoryTools } from "./domains/memory/tools.js";
34
39
  import { registerSearchTools } from "./domains/search/tools.js";
35
40
  import { registerSkillResources } from "./domains/skills/resources.js";
36
41
  import { registerSkillTools } from "./domains/skills/tools.js";
@@ -107,6 +112,29 @@ export function createConversationServer(target: BackendTarget): McpServer {
107
112
  return server;
108
113
  }
109
114
 
115
+ /**
116
+ * Build a memory-only MCP server: remember with the agent-facing argument
117
+ * surface, and nothing else (DD-005 D1 — the channels-roster pattern).
118
+ * This is the roster the runner-synthesized memory attachment connects
119
+ * to; the structural guarantee mirrors the channels roster's. Served on
120
+ * the /memory HTTP route and as the stdio roster when
121
+ * STIGMER_MCP_ROSTER=memory.
122
+ *
123
+ * `startupContext` is the stdio-shape capture context, read from the
124
+ * runner-set STIGMER_MEMORY_* environment at construction (the startup
125
+ * API key pattern). Over HTTP each request's provenance headers
126
+ * supersede it, so the http factory's env read is a harmless no-op.
127
+ */
128
+ export function createMemoryServer(
129
+ target: BackendTarget,
130
+ startupContext: CaptureContext = loadCaptureContextFromEnv(),
131
+ ): McpServer {
132
+ const server = new McpServer({ name: "mcp-server-stigmer-memory", version: SERVER_VERSION });
133
+ const tools = registerMemoryTools(server, target, startupContext);
134
+ log.info("tools registered (memory roster)", { count: tools.length, tools });
135
+ return server;
136
+ }
137
+
110
138
  /**
111
139
  * Wire up every domain's tools. Each domain returns the names it registered so
112
140
  * the startup log's count and roster cannot drift from what is actually wired,
@@ -193,10 +221,14 @@ export const CHANNELS_ROUTE = "/channels";
193
221
  /** HTTP route serving the conversation-only roster (channel-conversations A14). */
194
222
  export const CONVERSATION_ROUTE = "/conversation";
195
223
 
224
+ /** HTTP route serving the memory-only roster (memory capture, DD-005 D1). */
225
+ export const MEMORY_ROUTE = "/memory";
226
+
196
227
  /**
197
228
  * The standard HTTP route dispatch: the full roster on {@link FULL_ROUTE},
198
229
  * the channels-only roster on {@link CHANNELS_ROUTE}, the
199
- * conversation-only roster on {@link CONVERSATION_ROUTE} — and NOTHING
230
+ * conversation-only roster on {@link CONVERSATION_ROUTE}, the
231
+ * memory-only roster on {@link MEMORY_ROUTE} — and NOTHING
200
232
  * anywhere else.
201
233
  *
202
234
  * The closed route table is load-bearing, not tidiness. This dispatch
@@ -214,6 +246,7 @@ export function routedServerFactory(target: BackendTarget): RouteServerFactory {
214
246
  if (path === FULL_ROUTE) return createServer(target);
215
247
  if (path === CHANNELS_ROUTE) return createChannelsServer(target);
216
248
  if (path === CONVERSATION_ROUTE) return createConversationServer(target);
249
+ if (path === MEMORY_ROUTE) return createMemoryServer(target);
217
250
  return undefined;
218
251
  };
219
252
  }
@@ -425,12 +458,13 @@ async function routeRequest(
425
458
  }
426
459
 
427
460
  /**
428
- * The stdio server for the configured roster: the channels-only roster
429
- * when STIGMER_MCP_ROSTER names it (what the OSS runner-synthesized
430
- * attachment spawns), the full roster otherwise.
461
+ * The stdio server for the configured roster: the channels-only or
462
+ * memory-only roster when STIGMER_MCP_ROSTER names one (what the OSS
463
+ * runner-synthesized attachments spawn), the full roster otherwise.
431
464
  */
432
465
  export function stdioServer(target: BackendTarget, cfg: Config): McpServer {
433
466
  if (cfg.roster === "channels") return createChannelsServer(target);
467
+ if (cfg.roster === "memory") return createMemoryServer(target);
434
468
  return createServer(target);
435
469
  }
436
470