@seanhogg/builderforce-memory-mcp 2026.6.18
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/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/backend.d.ts +60 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +11 -0
- package/dist/backend.js.map +1 -0
- package/dist/backends/memory-store.d.ts +73 -0
- package/dist/backends/memory-store.d.ts.map +1 -0
- package/dist/backends/memory-store.js +152 -0
- package/dist/backends/memory-store.js.map +1 -0
- package/dist/bin/http.d.ts +14 -0
- package/dist/bin/http.d.ts.map +1 -0
- package/dist/bin/http.js +33 -0
- package/dist/bin/http.js.map +1 -0
- package/dist/bin/stdio.d.ts +18 -0
- package/dist/bin/stdio.d.ts.map +1 -0
- package/dist/bin/stdio.js +24 -0
- package/dist/bin/stdio.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/tools.d.ts +47 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +186 -0
- package/dist/tools.js.map +1 -0
- package/dist/transports/http.d.ts +30 -0
- package/dist/transports/http.d.ts.map +1 -0
- package/dist/transports/http.js +45 -0
- package/dist/transports/http.js.map +1 -0
- package/dist/transports/mcp-server.d.ts +17 -0
- package/dist/transports/mcp-server.d.ts.map +1 -0
- package/dist/transports/mcp-server.js +26 -0
- package/dist/transports/mcp-server.js.map +1 -0
- package/dist/transports/sdk.d.ts +45 -0
- package/dist/transports/sdk.d.ts.map +1 -0
- package/dist/transports/sdk.js +44 -0
- package/dist/transports/sdk.js.map +1 -0
- package/dist/transports/stdio.d.ts +14 -0
- package/dist/transports/stdio.d.ts.map +1 -0
- package/dist/transports/stdio.js +17 -0
- package/dist/transports/stdio.js.map +1 -0
- package/package.json +86 -0
- package/src/backend.ts +66 -0
- package/src/backends/memory-store.ts +217 -0
- package/src/bin/http.ts +36 -0
- package/src/bin/stdio.ts +26 -0
- package/src/index.ts +31 -0
- package/src/tools.ts +214 -0
- package/src/transports/http.ts +64 -0
- package/src/transports/mcp-server.ts +40 -0
- package/src/transports/sdk.ts +75 -0
- package/src/transports/stdio.ts +19 -0
package/src/backend.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryBackend — the storage seam every transport is written against.
|
|
3
|
+
*
|
|
4
|
+
* The MCP tools (src/tools.ts) and all three transports (SDK / stdio / HTTP)
|
|
5
|
+
* depend ONLY on this interface, never on a concrete store. Ship the local
|
|
6
|
+
* `MemoryStoreBackend` (IndexedDB via @seanhogg/builderforce-memory) today; drop in a
|
|
7
|
+
* networked builderforce.ai adapter later with zero changes to the tools or
|
|
8
|
+
* transports.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A single recalled memory, normalised across backends. */
|
|
12
|
+
export interface RecallHit {
|
|
13
|
+
/** Stable identifier for the memory. */
|
|
14
|
+
key: string;
|
|
15
|
+
/** The stored value. */
|
|
16
|
+
content: string;
|
|
17
|
+
/**
|
|
18
|
+
* Optional relevance score (higher = closer). Semantic backends that expose
|
|
19
|
+
* ranking can populate this; the local MemoryStore ranks but does not surface
|
|
20
|
+
* a score, so it is left undefined and recall order carries the signal.
|
|
21
|
+
*/
|
|
22
|
+
score?: number;
|
|
23
|
+
/** Tags for grouping/filtering. */
|
|
24
|
+
tags?: string[];
|
|
25
|
+
/** Importance weight 0–1. */
|
|
26
|
+
importance?: number;
|
|
27
|
+
/** Unix-ms write time. */
|
|
28
|
+
timestamp?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Arguments for writing a memory. */
|
|
32
|
+
export interface RememberInput {
|
|
33
|
+
key: string;
|
|
34
|
+
content: string;
|
|
35
|
+
tags?: string[];
|
|
36
|
+
/** Importance weight 0–1. */
|
|
37
|
+
importance?: number;
|
|
38
|
+
/** Time-to-live in milliseconds. */
|
|
39
|
+
ttlMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The minimal capability surface the MCP layer needs. Deliberately small —
|
|
44
|
+
* the token-saving design exposes recall-on-demand, not a "dump everything"
|
|
45
|
+
* call, so this interface has no `recallAll`.
|
|
46
|
+
*/
|
|
47
|
+
export interface MemoryBackend {
|
|
48
|
+
/**
|
|
49
|
+
* Semantic top-K recall. Backends with an embedding model (the SSM
|
|
50
|
+
* runtime) should use it; lexical fallback is acceptable. `topK` is already
|
|
51
|
+
* clamped by the caller — the backend may return fewer, never more.
|
|
52
|
+
*/
|
|
53
|
+
recall(query: string, topK: number): Promise<RecallHit[]>;
|
|
54
|
+
|
|
55
|
+
/** Exact lookup by key. Returns undefined when absent or expired. */
|
|
56
|
+
get(key: string): Promise<RecallHit | undefined>;
|
|
57
|
+
|
|
58
|
+
/** All non-expired entries carrying `tag`, capped to `limit`. */
|
|
59
|
+
recallByTag(tag: string, limit: number): Promise<RecallHit[]>;
|
|
60
|
+
|
|
61
|
+
/** Store or overwrite a memory. Optional — read-only backends omit it. */
|
|
62
|
+
remember?(input: RememberInput): Promise<void>;
|
|
63
|
+
|
|
64
|
+
/** Delete a memory by key. Optional — read-only backends omit it. */
|
|
65
|
+
forget?(key: string): Promise<void>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryStoreBackend — local adapter mapping @seanhogg/builderforce-memory's MemoryStore
|
|
3
|
+
* onto the MemoryBackend seam.
|
|
4
|
+
*
|
|
5
|
+
* Recall quality: when an SSM runtime is supplied it is forwarded to
|
|
6
|
+
* `recallSimilar`, so recall uses SSM-embedding cosine similarity and improves
|
|
7
|
+
* as the model is adapted/distilled. With no runtime it transparently falls
|
|
8
|
+
* back to Jaccard word-overlap (still useful, just lexical).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MemoryBackend, RecallHit, RememberInput } from "../backend.js";
|
|
12
|
+
|
|
13
|
+
// Structural views of the @seanhogg/builderforce-memory surface we use, so this package
|
|
14
|
+
// type-checks without a hard dependency on the runtime package.
|
|
15
|
+
interface MemoryEntryLike {
|
|
16
|
+
key: string;
|
|
17
|
+
content: string;
|
|
18
|
+
timestamp?: number;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
importance?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface MemoryStoreLike {
|
|
24
|
+
remember(key: string, content: string, opts?: { ttlMs?: number; tags?: string[]; importance?: number }): Promise<void>;
|
|
25
|
+
recall(key: string): Promise<MemoryEntryLike | undefined>;
|
|
26
|
+
recallAll(): Promise<MemoryEntryLike[]>;
|
|
27
|
+
recallByTag(tag: string): Promise<MemoryEntryLike[]>;
|
|
28
|
+
recallSimilar(query: string, topK: number, runtime?: unknown): Promise<MemoryEntryLike[]>;
|
|
29
|
+
forget(key: string): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toHit(e: MemoryEntryLike): RecallHit {
|
|
33
|
+
return {
|
|
34
|
+
key: e.key,
|
|
35
|
+
content: e.content,
|
|
36
|
+
tags: e.tags,
|
|
37
|
+
importance: e.importance,
|
|
38
|
+
timestamp: e.timestamp,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class MemoryStoreBackend implements MemoryBackend {
|
|
43
|
+
constructor(
|
|
44
|
+
private readonly store: MemoryStoreLike,
|
|
45
|
+
/** Optional SSMRuntime; enables embedding-based recall when present. */
|
|
46
|
+
private readonly runtime?: unknown,
|
|
47
|
+
) {}
|
|
48
|
+
|
|
49
|
+
async recall(query: string, topK: number): Promise<RecallHit[]> {
|
|
50
|
+
const entries = await this.store.recallSimilar(query, topK, this.runtime);
|
|
51
|
+
return entries.map(toHit);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async get(key: string): Promise<RecallHit | undefined> {
|
|
55
|
+
const e = await this.store.recall(key);
|
|
56
|
+
return e ? toHit(e) : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async recallByTag(tag: string, limit: number): Promise<RecallHit[]> {
|
|
60
|
+
const entries = await this.store.recallByTag(tag);
|
|
61
|
+
return entries.slice(0, limit).map(toHit);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async remember(input: RememberInput): Promise<void> {
|
|
65
|
+
await this.store.remember(input.key, input.content, {
|
|
66
|
+
ttlMs: input.ttlMs,
|
|
67
|
+
tags: input.tags,
|
|
68
|
+
importance: input.importance,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async forget(key: string): Promise<void> {
|
|
73
|
+
await this.store.forget(key);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Options for {@link createLocalMemoryStoreBackend}. */
|
|
78
|
+
export interface LocalBackendOptions {
|
|
79
|
+
/** IndexedDB database name. Defaults to MemoryStore's own default ('ssmjs'). */
|
|
80
|
+
dbName?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Optional SSMRuntime for embedding-based recall. Omit for lexical (Jaccard)
|
|
83
|
+
* recall. In the agent-runtime, pass `ssmMemoryService.runtime` here to reuse
|
|
84
|
+
* the already-loaded hippocampus instead of standing up a second model.
|
|
85
|
+
*/
|
|
86
|
+
runtime?: unknown;
|
|
87
|
+
/**
|
|
88
|
+
* Absolute path to a JSON file that mirrors the store to disk. Without it the
|
|
89
|
+
* store is purely in-memory (fake-indexeddb) and evaporates when the process
|
|
90
|
+
* exits — fine for a long-lived server, fatal for a per-session subprocess
|
|
91
|
+
* (e.g. an MCP stdio client that respawns the server each launch).
|
|
92
|
+
*
|
|
93
|
+
* When set, the store is hydrated from the file on creation and re-snapshotted
|
|
94
|
+
* after every remember/forget, giving durable cross-process memory. TTLs are
|
|
95
|
+
* dropped on persist: the snapshot is the durable long-term tier.
|
|
96
|
+
*/
|
|
97
|
+
persistFile?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The on-disk snapshot shape — a flat array of durable entries. */
|
|
101
|
+
interface SnapshotEntry {
|
|
102
|
+
key: string;
|
|
103
|
+
content: string;
|
|
104
|
+
tags?: string[];
|
|
105
|
+
importance?: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type FsLike = {
|
|
109
|
+
readFileSync(path: string, enc: "utf8"): string;
|
|
110
|
+
writeFileSync(path: string, data: string): void;
|
|
111
|
+
mkdirSync(path: string, opts: { recursive: boolean }): void;
|
|
112
|
+
existsSync(path: string): boolean;
|
|
113
|
+
};
|
|
114
|
+
type PathLike = { dirname(p: string): string };
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Wraps a MemoryStoreBackend so every write is mirrored to a JSON file, and
|
|
118
|
+
* hydrates that file back into the store on boot. This is what turns a respawned
|
|
119
|
+
* stdio subprocess into a persistent memory: the store itself is in-memory, the
|
|
120
|
+
* file is the source of truth across process lifetimes.
|
|
121
|
+
*/
|
|
122
|
+
class DiskPersistedBackend implements MemoryBackend {
|
|
123
|
+
constructor(
|
|
124
|
+
private readonly inner: MemoryStoreBackend,
|
|
125
|
+
private readonly store: MemoryStoreLike,
|
|
126
|
+
private readonly file: string,
|
|
127
|
+
private readonly fs: FsLike,
|
|
128
|
+
) {}
|
|
129
|
+
|
|
130
|
+
recall(query: string, topK: number): Promise<RecallHit[]> {
|
|
131
|
+
return this.inner.recall(query, topK);
|
|
132
|
+
}
|
|
133
|
+
get(key: string): Promise<RecallHit | undefined> {
|
|
134
|
+
return this.inner.get(key);
|
|
135
|
+
}
|
|
136
|
+
recallByTag(tag: string, limit: number): Promise<RecallHit[]> {
|
|
137
|
+
return this.inner.recallByTag(tag, limit);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async remember(input: RememberInput): Promise<void> {
|
|
141
|
+
await this.inner.remember(input);
|
|
142
|
+
await this.snapshot();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async forget(key: string): Promise<void> {
|
|
146
|
+
await this.inner.forget(key);
|
|
147
|
+
await this.snapshot();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async snapshot(): Promise<void> {
|
|
151
|
+
const entries = await this.store.recallAll();
|
|
152
|
+
const out: SnapshotEntry[] = entries.map((e) => ({
|
|
153
|
+
key: e.key,
|
|
154
|
+
content: e.content,
|
|
155
|
+
tags: e.tags,
|
|
156
|
+
importance: e.importance,
|
|
157
|
+
}));
|
|
158
|
+
this.fs.writeFileSync(this.file, JSON.stringify(out, null, 2));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Reads a snapshot file and replays it into the store as durable (no-TTL) entries. */
|
|
163
|
+
async function hydrateFromDisk(store: MemoryStoreLike, file: string, fs: FsLike): Promise<void> {
|
|
164
|
+
if (!fs.existsSync(file)) return;
|
|
165
|
+
let parsed: unknown;
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
168
|
+
} catch {
|
|
169
|
+
// Corrupt/partial snapshot — start clean rather than crash the server.
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (!Array.isArray(parsed)) return;
|
|
173
|
+
for (const raw of parsed as SnapshotEntry[]) {
|
|
174
|
+
if (!raw || typeof raw.key !== "string" || typeof raw.content !== "string") continue;
|
|
175
|
+
await store.remember(raw.key, raw.content, { tags: raw.tags, importance: raw.importance });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Builds a MemoryStoreBackend over a fresh MemoryStore, wiring fake-indexeddb in
|
|
181
|
+
* Node exactly as SsmMemoryService does. @seanhogg/builderforce-memory and fake-indexeddb
|
|
182
|
+
* are imported indirectly so they remain optional peers — a consumer that only
|
|
183
|
+
* wants a custom backend (or the HTTP thin-client) never has to install them.
|
|
184
|
+
*/
|
|
185
|
+
export async function createLocalMemoryStoreBackend(opts: LocalBackendOptions = {}): Promise<MemoryBackend> {
|
|
186
|
+
// Indirect import prevents the bundler/tsc from resolving optional peers.
|
|
187
|
+
const _import = (m: string): Promise<unknown> =>
|
|
188
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
|
189
|
+
new Function("m", "return import(m)")(m) as Promise<unknown>;
|
|
190
|
+
|
|
191
|
+
const memoryMod = (await _import("@seanhogg/builderforce-memory")) as { MemoryStore: new (o: unknown) => MemoryStoreLike };
|
|
192
|
+
const { MemoryStore } = memoryMod;
|
|
193
|
+
|
|
194
|
+
// IndexedDB shim for Node. In the browser the global is used automatically.
|
|
195
|
+
let idbFactory: unknown;
|
|
196
|
+
try {
|
|
197
|
+
const fake = (await _import("fake-indexeddb")) as { IDBFactory: new () => unknown };
|
|
198
|
+
idbFactory = new fake.IDBFactory();
|
|
199
|
+
} catch {
|
|
200
|
+
// Browser or a host that provides global indexedDB — MemoryStore handles it.
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const store = new MemoryStore({ idbFactory, dbName: opts.dbName });
|
|
204
|
+
const backend = new MemoryStoreBackend(store, opts.runtime);
|
|
205
|
+
|
|
206
|
+
if (!opts.persistFile) return backend;
|
|
207
|
+
|
|
208
|
+
// Disk-mirror requested. node:fs/path are loaded indirectly so a browser
|
|
209
|
+
// bundle of this module never statically pulls in Node builtins.
|
|
210
|
+
const fs = (await _import("node:fs")) as FsLike;
|
|
211
|
+
const path = (await _import("node:path")) as PathLike;
|
|
212
|
+
const dir = path.dirname(opts.persistFile);
|
|
213
|
+
if (dir && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
214
|
+
|
|
215
|
+
await hydrateFromDisk(store, opts.persistFile, fs);
|
|
216
|
+
return new DiskPersistedBackend(backend, store, opts.persistFile, fs);
|
|
217
|
+
}
|
package/src/bin/http.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `builderforce-memory-mcp-http` — Streamable HTTP MCP server over the LOCAL
|
|
4
|
+
* MemoryStore. A reference host; in production builderforce.ai would mount
|
|
5
|
+
* createMemoryHttpHandler() against a shared/remote backend instead.
|
|
6
|
+
*
|
|
7
|
+
* Env:
|
|
8
|
+
* PORT Listen port (default 8787).
|
|
9
|
+
* BUILDERFORCE_MEMORY_TOKEN Bearer token required on every request (recommended).
|
|
10
|
+
* BUILDERFORCE_MEMORY_DB IndexedDB database name.
|
|
11
|
+
* BUILDERFORCE_MEMORY_READONLY '1' to disable remember/forget tools.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import { createLocalMemoryStoreBackend } from "../backends/memory-store.js";
|
|
16
|
+
import { createMemoryHttpHandler } from "../transports/http.js";
|
|
17
|
+
|
|
18
|
+
const backend = await createLocalMemoryStoreBackend({
|
|
19
|
+
dbName: process.env["BUILDERFORCE_MEMORY_DB"],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const handler = createMemoryHttpHandler(backend, {
|
|
23
|
+
authToken: process.env["BUILDERFORCE_MEMORY_TOKEN"],
|
|
24
|
+
writable: process.env["BUILDERFORCE_MEMORY_READONLY"] !== "1",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const port = Number(process.env["PORT"] ?? 8787);
|
|
28
|
+
|
|
29
|
+
http.createServer((req, res) => {
|
|
30
|
+
void handler(req, res).catch((err) => {
|
|
31
|
+
if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" });
|
|
32
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
33
|
+
});
|
|
34
|
+
}).listen(port, () => {
|
|
35
|
+
process.stderr.write(`[builderforce-memory-mcp-http] listening on :${port}\n`);
|
|
36
|
+
});
|
package/src/bin/stdio.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `builderforce-memory-mcp` — stdio MCP server over the LOCAL MemoryStore.
|
|
4
|
+
*
|
|
5
|
+
* Env:
|
|
6
|
+
* BUILDERFORCE_MEMORY_DB IndexedDB database name (default: MemoryStore default).
|
|
7
|
+
* BUILDERFORCE_MEMORY_READONLY '1' to disable remember/forget tools.
|
|
8
|
+
* BUILDERFORCE_MEMORY_FILE Absolute path to a JSON snapshot. When set, memory
|
|
9
|
+
* persists across process restarts — REQUIRED for an
|
|
10
|
+
* MCP client that respawns this server each session
|
|
11
|
+
* (otherwise fake-indexeddb loses everything on exit).
|
|
12
|
+
*
|
|
13
|
+
* Recall is lexical (Jaccard) here — this headless binary does not stand up the
|
|
14
|
+
* SSM runtime/GPU. For SSM-embedding recall, embed the package in-process and
|
|
15
|
+
* pass `runtime` to createLocalMemoryStoreBackend (see README).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createLocalMemoryStoreBackend } from "../backends/memory-store.js";
|
|
19
|
+
import { runStdio } from "../transports/stdio.js";
|
|
20
|
+
|
|
21
|
+
const backend = await createLocalMemoryStoreBackend({
|
|
22
|
+
dbName: process.env["BUILDERFORCE_MEMORY_DB"],
|
|
23
|
+
persistFile: process.env["BUILDERFORCE_MEMORY_FILE"],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await runStdio(backend, { writable: process.env["BUILDERFORCE_MEMORY_READONLY"] !== "1" });
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @seanhogg/builderforce-memory-mcp — expose @seanhogg/builderforce-memory to MCP clients.
|
|
3
|
+
*
|
|
4
|
+
* One token-saving tool core over a pluggable MemoryBackend, three transports:
|
|
5
|
+
* - createMemoryMcpServer → in-process Claude Agent SDK (type:"sdk")
|
|
6
|
+
* - runStdio → stdio subprocess (any language)
|
|
7
|
+
* - createMemoryHttpHandler→ Streamable HTTP (multi-tenant / networked)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── Seam ────────────────────────────────────────────────────────────────────
|
|
11
|
+
export type { MemoryBackend, RecallHit, RememberInput } from "./backend.js";
|
|
12
|
+
|
|
13
|
+
// ── Local backend (IndexedDB via @seanhogg/builderforce-memory) ────────────────────────
|
|
14
|
+
export { MemoryStoreBackend, createLocalMemoryStoreBackend } from "./backends/memory-store.js";
|
|
15
|
+
export type { LocalBackendOptions } from "./backends/memory-store.js";
|
|
16
|
+
|
|
17
|
+
// ── Tool core ─────────────────────────────────────────────────────────────────
|
|
18
|
+
export { buildMemoryTools } from "./tools.js";
|
|
19
|
+
export type { MemoryTool, MemoryToolsOptions, ToolResult } from "./tools.js";
|
|
20
|
+
|
|
21
|
+
// ── Transports ──────────────────────────────────────────────────────────────
|
|
22
|
+
export { createMemoryMcpServer } from "./transports/sdk.js";
|
|
23
|
+
export type { SdkServerOptions, SdkMcpServerConfig } from "./transports/sdk.js";
|
|
24
|
+
|
|
25
|
+
export { buildMcpServer } from "./transports/mcp-server.js";
|
|
26
|
+
export type { McpServerOptions } from "./transports/mcp-server.js";
|
|
27
|
+
|
|
28
|
+
export { runStdio } from "./transports/stdio.js";
|
|
29
|
+
|
|
30
|
+
export { createMemoryHttpHandler } from "./transports/http.js";
|
|
31
|
+
export type { HttpHandlerOptions } from "./transports/http.js";
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic MCP tool definitions over a MemoryBackend.
|
|
3
|
+
*
|
|
4
|
+
* Defined once here, then registered into whichever server framework a
|
|
5
|
+
* transport uses — the Claude Agent SDK's `tool()` (in-process) or the MCP
|
|
6
|
+
* SDK's `registerTool()` (stdio/HTTP). Both accept the same (name, description,
|
|
7
|
+
* zod raw shape, handler→CallToolResult) shape, so the handlers below are the
|
|
8
|
+
* single source of truth.
|
|
9
|
+
*
|
|
10
|
+
* Token-saving is enforced HERE, server-side, regardless of what the model
|
|
11
|
+
* asks for: recall is top-K (capped), each entry's content is truncated, and
|
|
12
|
+
* there is deliberately no "return everything" tool. Moving memory out of the
|
|
13
|
+
* prompt only saves tokens if recall is selective — a tool that dumps the whole
|
|
14
|
+
* store back into context is more expensive than inlining it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import type { MemoryBackend, RecallHit } from "./backend.js";
|
|
19
|
+
|
|
20
|
+
/** The MCP CallToolResult shape both server frameworks expect. */
|
|
21
|
+
export interface ToolResult {
|
|
22
|
+
content: Array<{ type: "text"; text: string }>;
|
|
23
|
+
isError?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A framework-neutral tool: maps 1:1 onto Agent-SDK `tool()` and MCP `registerTool()`. */
|
|
27
|
+
export interface MemoryTool {
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
/** Zod *raw shape* (e.g. `{ query: z.string() }`), not a ZodObject. */
|
|
31
|
+
inputSchema: z.ZodRawShape;
|
|
32
|
+
handler: (args: Record<string, unknown>) => Promise<ToolResult>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MemoryToolsOptions {
|
|
36
|
+
/** Hard cap on entries any recall tool returns to the model. Default 5. */
|
|
37
|
+
maxResults?: number;
|
|
38
|
+
/** Max characters of each entry's content surfaced to the model. Default 500. */
|
|
39
|
+
maxContentChars?: number;
|
|
40
|
+
/** Expose write tools (remember/forget). Default true; forced false if the backend is read-only. */
|
|
41
|
+
writable?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_MAX_RESULTS = 5;
|
|
45
|
+
const DEFAULT_MAX_CONTENT = 500;
|
|
46
|
+
|
|
47
|
+
function clip(s: string, n: number): string {
|
|
48
|
+
return s.length <= n ? s : `${s.slice(0, n)}…`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ok(text: string): ToolResult {
|
|
52
|
+
return { content: [{ type: "text", text }] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fail(text: string): ToolResult {
|
|
56
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderHits(hits: RecallHit[], maxChars: number): string {
|
|
60
|
+
if (hits.length === 0) return "No matching memories.";
|
|
61
|
+
return hits
|
|
62
|
+
.map((h) => {
|
|
63
|
+
const tags = h.tags?.length ? ` tags=[${h.tags.join(", ")}]` : "";
|
|
64
|
+
const score = h.score != null ? ` score=${h.score.toFixed(3)}` : "";
|
|
65
|
+
return `• ${h.key}${score}${tags}\n ${clip(h.content, maxChars)}`;
|
|
66
|
+
})
|
|
67
|
+
.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Builds the memory tool set bound to `backend`. Write tools are included only
|
|
72
|
+
* when `writable` is not false AND the backend actually implements them.
|
|
73
|
+
*/
|
|
74
|
+
export function buildMemoryTools(backend: MemoryBackend, opts: MemoryToolsOptions = {}): MemoryTool[] {
|
|
75
|
+
const maxResults = Math.max(1, opts.maxResults ?? DEFAULT_MAX_RESULTS);
|
|
76
|
+
const maxContent = Math.max(80, opts.maxContentChars ?? DEFAULT_MAX_CONTENT);
|
|
77
|
+
const writable = opts.writable !== false;
|
|
78
|
+
|
|
79
|
+
const tools: MemoryTool[] = [
|
|
80
|
+
{
|
|
81
|
+
name: "memory_recall",
|
|
82
|
+
description:
|
|
83
|
+
"Semantically recall the most relevant stored memories for a query. " +
|
|
84
|
+
"Call this BEFORE answering whenever the task may depend on prior context, user " +
|
|
85
|
+
"preferences, project decisions, or facts learned in earlier sessions — instead of " +
|
|
86
|
+
"assuming that context is already in your prompt. Returns a small ranked set, not the " +
|
|
87
|
+
"whole store.",
|
|
88
|
+
inputSchema: {
|
|
89
|
+
query: z.string().describe("What to look for — a question, topic, or keywords."),
|
|
90
|
+
topK: z
|
|
91
|
+
.number()
|
|
92
|
+
.int()
|
|
93
|
+
.min(1)
|
|
94
|
+
.max(maxResults)
|
|
95
|
+
.optional()
|
|
96
|
+
.describe(`How many memories to return (max ${maxResults}).`),
|
|
97
|
+
},
|
|
98
|
+
handler: async (args) => {
|
|
99
|
+
try {
|
|
100
|
+
const query = String(args["query"] ?? "");
|
|
101
|
+
if (!query.trim()) return fail("query is required.");
|
|
102
|
+
const k = Math.min(maxResults, Number(args["topK"] ?? maxResults));
|
|
103
|
+
const hits = await backend.recall(query, k);
|
|
104
|
+
return ok(renderHits(hits.slice(0, maxResults), maxContent));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return fail(`recall failed: ${String(err)}`);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "memory_get",
|
|
112
|
+
description:
|
|
113
|
+
"Fetch a single memory by its exact key. Use when you already know the key " +
|
|
114
|
+
"(e.g. one surfaced by memory_recall) and want its full, untruncated value.",
|
|
115
|
+
inputSchema: {
|
|
116
|
+
key: z.string().describe("The exact memory key."),
|
|
117
|
+
},
|
|
118
|
+
handler: async (args) => {
|
|
119
|
+
try {
|
|
120
|
+
const key = String(args["key"] ?? "");
|
|
121
|
+
if (!key) return fail("key is required.");
|
|
122
|
+
const hit = await backend.get(key);
|
|
123
|
+
return hit ? ok(`• ${hit.key}\n ${hit.content}`) : ok(`No memory found for key "${key}".`);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return fail(`get failed: ${String(err)}`);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "memory_recall_by_tag",
|
|
131
|
+
description:
|
|
132
|
+
"List memories carrying a given tag (e.g. 'user', 'project', 'decision'). " +
|
|
133
|
+
"Use to pull a known category of context rather than searching semantically.",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
tag: z.string().describe("The tag to filter by."),
|
|
136
|
+
limit: z
|
|
137
|
+
.number()
|
|
138
|
+
.int()
|
|
139
|
+
.min(1)
|
|
140
|
+
.max(maxResults)
|
|
141
|
+
.optional()
|
|
142
|
+
.describe(`Max entries to return (max ${maxResults}).`),
|
|
143
|
+
},
|
|
144
|
+
handler: async (args) => {
|
|
145
|
+
try {
|
|
146
|
+
const tag = String(args["tag"] ?? "");
|
|
147
|
+
if (!tag) return fail("tag is required.");
|
|
148
|
+
const limit = Math.min(maxResults, Number(args["limit"] ?? maxResults));
|
|
149
|
+
const hits = await backend.recallByTag(tag, limit);
|
|
150
|
+
return ok(renderHits(hits, maxContent));
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return fail(`recall_by_tag failed: ${String(err)}`);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
if (writable && backend.remember) {
|
|
159
|
+
tools.push({
|
|
160
|
+
name: "memory_remember",
|
|
161
|
+
description:
|
|
162
|
+
"Persist a fact for future sessions. Call when you learn something durable and reusable: " +
|
|
163
|
+
"a user preference, a project constraint, a decision and its rationale. Keep keys stable " +
|
|
164
|
+
"and descriptive (e.g. 'user.preferred-language') so the same fact overwrites rather than " +
|
|
165
|
+
"duplicating.",
|
|
166
|
+
inputSchema: {
|
|
167
|
+
key: z.string().describe("Stable, descriptive identifier; reusing a key overwrites it."),
|
|
168
|
+
content: z.string().describe("The fact to store."),
|
|
169
|
+
tags: z.array(z.string()).optional().describe("Optional grouping tags."),
|
|
170
|
+
importance: z.number().min(0).max(1).optional().describe("Importance 0–1 (default 0.5)."),
|
|
171
|
+
ttlMs: z.number().int().positive().optional().describe("Optional time-to-live in ms."),
|
|
172
|
+
},
|
|
173
|
+
handler: async (args) => {
|
|
174
|
+
try {
|
|
175
|
+
const key = String(args["key"] ?? "");
|
|
176
|
+
const content = String(args["content"] ?? "");
|
|
177
|
+
if (!key || !content) return fail("key and content are required.");
|
|
178
|
+
await backend.remember!({
|
|
179
|
+
key,
|
|
180
|
+
content,
|
|
181
|
+
tags: args["tags"] as string[] | undefined,
|
|
182
|
+
importance: args["importance"] as number | undefined,
|
|
183
|
+
ttlMs: args["ttlMs"] as number | undefined,
|
|
184
|
+
});
|
|
185
|
+
return ok(`Remembered "${key}".`);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return fail(`remember failed: ${String(err)}`);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (writable && backend.forget) {
|
|
194
|
+
tools.push({
|
|
195
|
+
name: "memory_forget",
|
|
196
|
+
description: "Delete a memory by key. Use to remove a fact that is now wrong or obsolete.",
|
|
197
|
+
inputSchema: {
|
|
198
|
+
key: z.string().describe("The exact memory key to delete."),
|
|
199
|
+
},
|
|
200
|
+
handler: async (args) => {
|
|
201
|
+
try {
|
|
202
|
+
const key = String(args["key"] ?? "");
|
|
203
|
+
if (!key) return fail("key is required.");
|
|
204
|
+
await backend.forget!(key);
|
|
205
|
+
return ok(`Forgot "${key}".`);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return fail(`forget failed: ${String(err)}`);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return tools;
|
|
214
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport — the multi-tenant / networked path (builderforce.ai hosting,
|
|
3
|
+
* remote claws). Stateless Streamable HTTP: one short-lived McpServer +
|
|
4
|
+
* transport per request, so it scales horizontally with no sticky sessions.
|
|
5
|
+
* Bearer-token auth gates every request.
|
|
6
|
+
*
|
|
7
|
+
* Consumed from the Claude Agent SDK as:
|
|
8
|
+
* mcpServers: {
|
|
9
|
+
* builderforce_memory: { type: "http", url: "https://mcp.builderforce.ai/memory",
|
|
10
|
+
* headers: { Authorization: `Bearer ${KEY}` } }
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
15
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
16
|
+
import type { MemoryBackend } from "../backend.js";
|
|
17
|
+
import { buildMcpServer, type McpServerOptions } from "./mcp-server.js";
|
|
18
|
+
|
|
19
|
+
export interface HttpHandlerOptions extends McpServerOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Shared secret required in `Authorization: Bearer <token>`. When set, every
|
|
22
|
+
* request without a matching token is rejected 401. Omit only behind a trusted
|
|
23
|
+
* gateway that has already authenticated the caller.
|
|
24
|
+
*/
|
|
25
|
+
authToken?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function unauthorized(res: ServerResponse): void {
|
|
29
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
30
|
+
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns a `(req, res) => Promise<void>` handler you mount on any Node HTTP
|
|
35
|
+
* server (or Express). Per request it spins up a stateless MCP server bound to
|
|
36
|
+
* `backend`, handles the MCP exchange, and tears down on response close.
|
|
37
|
+
*/
|
|
38
|
+
export function createMemoryHttpHandler(
|
|
39
|
+
backend: MemoryBackend,
|
|
40
|
+
opts: HttpHandlerOptions = {},
|
|
41
|
+
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
42
|
+
return async (req, res) => {
|
|
43
|
+
if (opts.authToken) {
|
|
44
|
+
const header = req.headers["authorization"];
|
|
45
|
+
if (header !== `Bearer ${opts.authToken}`) {
|
|
46
|
+
unauthorized(res);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const server = buildMcpServer(backend, opts);
|
|
52
|
+
// Stateless: no session id generator → a fresh transport per request.
|
|
53
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
54
|
+
|
|
55
|
+
res.on("close", () => {
|
|
56
|
+
void transport.close();
|
|
57
|
+
void server.close();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await server.connect(transport);
|
|
61
|
+
// Pass undefined body — the transport reads/parses the request stream itself.
|
|
62
|
+
await transport.handleRequest(req, res);
|
|
63
|
+
};
|
|
64
|
+
}
|