@astrocyteai/local 0.1.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,243 @@
1
+ /**
2
+ * Context Tree — hierarchical markdown file storage.
3
+ *
4
+ * Stores memories as .md files with YAML frontmatter in a
5
+ * domain-based directory structure. See docs/context-tree-format.md.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { randomUUID } from "node:crypto";
11
+ import YAML from "yaml";
12
+ import type { MemoryEntry } from "./types.js";
13
+
14
+ export class ContextTree {
15
+ private memoryDir: string;
16
+
17
+ constructor(private root: string) {
18
+ this.memoryDir = path.join(root, "memory");
19
+ fs.mkdirSync(this.memoryDir, { recursive: true });
20
+ }
21
+
22
+ store(options: {
23
+ content: string;
24
+ bank_id: string;
25
+ domain?: string;
26
+ tags?: string[];
27
+ memory_layer?: "fact" | "observation" | "model";
28
+ fact_type?: "world" | "experience" | "observation";
29
+ occurred_at?: string;
30
+ source?: string;
31
+ metadata?: Record<string, string | number | boolean | null>;
32
+ }): MemoryEntry {
33
+ const id = randomUUID().replace(/-/g, "").slice(0, 12);
34
+ const now = new Date().toISOString();
35
+ const domain = options.domain || "general";
36
+ const domainDir = path.join(this.memoryDir, domain);
37
+ fs.mkdirSync(domainDir, { recursive: true });
38
+
39
+ const filename = this.makeFilename(options.content);
40
+ let filePath = path.join(domainDir, `${filename}.md`);
41
+ let counter = 2;
42
+ while (fs.existsSync(filePath)) {
43
+ filePath = path.join(domainDir, `${filename}-${counter}.md`);
44
+ counter++;
45
+ }
46
+
47
+ const relPath = path.relative(this.memoryDir, filePath);
48
+
49
+ const entry: MemoryEntry = {
50
+ id,
51
+ bank_id: options.bank_id,
52
+ text: options.content,
53
+ domain,
54
+ file_path: relPath,
55
+ memory_layer: options.memory_layer || "fact",
56
+ fact_type: options.fact_type || "world",
57
+ tags: options.tags || [],
58
+ created_at: now,
59
+ updated_at: now,
60
+ occurred_at: options.occurred_at,
61
+ recall_count: 0,
62
+ source: options.source,
63
+ metadata: options.metadata || {},
64
+ };
65
+
66
+ this.writeEntry(filePath, entry);
67
+ return entry;
68
+ }
69
+
70
+ read(entryId: string): MemoryEntry | null {
71
+ for (const entry of this.scanAll()) {
72
+ if (entry.id === entryId) return entry;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ update(entryId: string, content: string): MemoryEntry | null {
78
+ for (const mdFile of this.allMdFiles()) {
79
+ const entry = this.readFile(mdFile);
80
+ if (entry && entry.id === entryId) {
81
+ entry.text = content;
82
+ entry.updated_at = new Date().toISOString();
83
+ this.writeEntry(mdFile, entry);
84
+ return entry;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ delete(entryId: string): boolean {
91
+ for (const mdFile of this.allMdFiles()) {
92
+ const entry = this.readFile(mdFile);
93
+ if (entry && entry.id === entryId) {
94
+ fs.unlinkSync(mdFile);
95
+ // Remove empty domain directories
96
+ const parent = path.dirname(mdFile);
97
+ if (parent !== this.memoryDir) {
98
+ try {
99
+ const remaining = fs.readdirSync(parent);
100
+ if (remaining.length === 0) fs.rmdirSync(parent);
101
+ } catch {}
102
+ }
103
+ return true;
104
+ }
105
+ }
106
+ return false;
107
+ }
108
+
109
+ recordRecall(entryId: string): void {
110
+ for (const mdFile of this.allMdFiles()) {
111
+ const entry = this.readFile(mdFile);
112
+ if (entry && entry.id === entryId) {
113
+ entry.recall_count++;
114
+ entry.last_recalled_at = new Date().toISOString();
115
+ this.writeEntry(mdFile, entry);
116
+ return;
117
+ }
118
+ }
119
+ }
120
+
121
+ listDomains(bankId?: string): string[] {
122
+ if (!fs.existsSync(this.memoryDir)) return [];
123
+ const domains: string[] = [];
124
+ for (const d of fs.readdirSync(this.memoryDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
125
+ if (!d.isDirectory() || d.name.startsWith("_")) continue;
126
+ if (!bankId) {
127
+ domains.push(d.name);
128
+ } else {
129
+ const domainPath = path.join(this.memoryDir, d.name);
130
+ const files = fs.readdirSync(domainPath).filter((f) => f.endsWith(".md"));
131
+ for (const f of files) {
132
+ const entry = this.readFile(path.join(domainPath, f));
133
+ if (entry && entry.bank_id === bankId) {
134
+ domains.push(d.name);
135
+ break;
136
+ }
137
+ }
138
+ }
139
+ }
140
+ return domains;
141
+ }
142
+
143
+ listEntries(bankId: string, domain?: string): MemoryEntry[] {
144
+ const searchDir = domain ? path.join(this.memoryDir, domain) : this.memoryDir;
145
+ if (!fs.existsSync(searchDir)) return [];
146
+ const entries: MemoryEntry[] = [];
147
+ for (const mdFile of this.allMdFiles(searchDir)) {
148
+ const entry = this.readFile(mdFile);
149
+ if (entry && entry.bank_id === bankId) entries.push(entry);
150
+ }
151
+ return entries;
152
+ }
153
+
154
+ scanAll(bankId?: string): MemoryEntry[] {
155
+ if (!fs.existsSync(this.memoryDir)) return [];
156
+ const entries: MemoryEntry[] = [];
157
+ for (const mdFile of this.allMdFiles()) {
158
+ const entry = this.readFile(mdFile);
159
+ if (entry && (!bankId || entry.bank_id === bankId)) entries.push(entry);
160
+ }
161
+ return entries;
162
+ }
163
+
164
+ count(bankId?: string): number {
165
+ return this.scanAll(bankId).length;
166
+ }
167
+
168
+ // ── Internal ──
169
+
170
+ private makeFilename(content: string): string {
171
+ let slug = content.slice(0, 50).toLowerCase().trim();
172
+ slug = slug.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
173
+ return slug || "memory";
174
+ }
175
+
176
+ private writeEntry(filePath: string, entry: MemoryEntry): void {
177
+ const frontmatter: Record<string, unknown> = {
178
+ id: entry.id,
179
+ bank_id: entry.bank_id,
180
+ memory_layer: entry.memory_layer,
181
+ fact_type: entry.fact_type,
182
+ tags: entry.tags,
183
+ created_at: entry.created_at,
184
+ updated_at: entry.updated_at,
185
+ recall_count: entry.recall_count,
186
+ };
187
+ if (entry.occurred_at) frontmatter.occurred_at = entry.occurred_at;
188
+ if (entry.last_recalled_at) frontmatter.last_recalled_at = entry.last_recalled_at;
189
+ if (entry.source) frontmatter.source = entry.source;
190
+ if (Object.keys(entry.metadata).length > 0) frontmatter.metadata = entry.metadata;
191
+
192
+ const fmStr = YAML.stringify(frontmatter);
193
+ fs.writeFileSync(filePath, `---\n${fmStr}---\n\n${entry.text}\n`, "utf-8");
194
+ }
195
+
196
+ private readFile(filePath: string): MemoryEntry | null {
197
+ try {
198
+ const content = fs.readFileSync(filePath, "utf-8");
199
+ if (!content.startsWith("---")) return null;
200
+
201
+ const parts = content.split("---");
202
+ if (parts.length < 3) return null;
203
+
204
+ const fm = YAML.parse(parts[1]) || {};
205
+ const text = parts.slice(2).join("---").trim();
206
+ const relPath = path.relative(this.memoryDir, filePath);
207
+ const domain = path.dirname(filePath) === this.memoryDir ? "general" : path.basename(path.dirname(filePath));
208
+
209
+ return {
210
+ id: fm.id || "",
211
+ bank_id: fm.bank_id || "",
212
+ text,
213
+ domain,
214
+ file_path: relPath,
215
+ memory_layer: fm.memory_layer || "fact",
216
+ fact_type: fm.fact_type || "world",
217
+ tags: fm.tags || [],
218
+ created_at: fm.created_at || "",
219
+ updated_at: fm.updated_at || "",
220
+ occurred_at: fm.occurred_at,
221
+ recall_count: fm.recall_count || 0,
222
+ last_recalled_at: fm.last_recalled_at,
223
+ source: fm.source,
224
+ metadata: fm.metadata || {},
225
+ };
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ private *allMdFiles(dir?: string): Generator<string> {
232
+ const searchDir = dir || this.memoryDir;
233
+ if (!fs.existsSync(searchDir)) return;
234
+ for (const entry of fs.readdirSync(searchDir, { withFileTypes: true })) {
235
+ const fullPath = path.join(searchDir, entry.name);
236
+ if (entry.isDirectory() && !entry.name.startsWith("_")) {
237
+ yield* this.allMdFiles(fullPath);
238
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
239
+ yield fullPath;
240
+ }
241
+ }
242
+ }
243
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * LLM-curated retain for local Context Tree.
3
+ *
4
+ * When an LLM provider is available, the LLM decides:
5
+ * - What action to take: ADD, UPDATE, MERGE, SKIP, DELETE
6
+ * - Which domain to store in (instead of just using the first tag)
7
+ * - What memory_layer to assign (fact, observation, model)
8
+ *
9
+ * Falls back to simple mechanical retain when no LLM is configured.
10
+ */
11
+
12
+ import type { ContextTree } from "./context-tree.js";
13
+ import type { SearchEngine } from "./search.js";
14
+
15
+ export interface LLMProvider {
16
+ complete(options: {
17
+ messages: Array<{ role: string; content: string }>;
18
+ maxTokens?: number;
19
+ temperature?: number;
20
+ }): Promise<{ text: string }>;
21
+ }
22
+
23
+ export interface CurationDecision {
24
+ action: "add" | "update" | "merge" | "skip" | "delete";
25
+ domain: string;
26
+ content: string;
27
+ memory_layer: "fact" | "observation" | "model";
28
+ reasoning: string;
29
+ target_id?: string;
30
+ }
31
+
32
+ const CURATION_PROMPT = `You are a memory curation agent for a local Context Tree. Analyze the new content and decide how to store it.
33
+
34
+ ## Existing memories (most similar):
35
+ {existing}
36
+
37
+ ## Context Tree domains currently in use:
38
+ {domains}
39
+
40
+ ## New content:
41
+ {content}
42
+
43
+ ## Decide:
44
+ 1. action: "add" (new info), "update" (replace existing), "merge" (combine with existing), "skip" (redundant), "delete" (contradicts old)
45
+ 2. domain: Which Context Tree directory to store in (e.g., "preferences", "architecture", "decisions"). Use an existing domain if appropriate, or suggest a new one.
46
+ 3. memory_layer: "fact" (raw info), "observation" (pattern/insight), "model" (consolidated understanding)
47
+ 4. content: The processed text to store (may rewrite for clarity)
48
+ 5. reasoning: Brief explanation
49
+
50
+ Respond with JSON:
51
+ {"action": "add", "domain": "preferences", "content": "...", "memory_layer": "fact", "reasoning": "...", "target_id": null}`;
52
+
53
+ export async function curateLocalRetain(options: {
54
+ content: string;
55
+ bankId: string;
56
+ tree: ContextTree;
57
+ search: SearchEngine;
58
+ llmProvider: LLMProvider;
59
+ contextLimit?: number;
60
+ }): Promise<CurationDecision> {
61
+ const { content, bankId, tree, search, llmProvider, contextLimit = 5 } = options;
62
+
63
+ // Get existing similar memories for context
64
+ const existingHits = search.search(content, bankId, { limit: contextLimit });
65
+ let existingText: string;
66
+ if (existingHits.length > 0) {
67
+ existingText = existingHits
68
+ .map(
69
+ (h) =>
70
+ `- [${h.id}] (${h.domain}/${h.file_path}) score=${h.score.toFixed(2)}: ${h.text.slice(0, 200)}`
71
+ )
72
+ .join("\n");
73
+ } else {
74
+ existingText = "(no existing memories)";
75
+ }
76
+
77
+ // Get current domains
78
+ const domains = tree.listDomains(bankId);
79
+ const domainsText = domains.length > 0 ? domains.join(", ") : "(none yet)";
80
+
81
+ const prompt = CURATION_PROMPT.replace("{existing}", existingText)
82
+ .replace("{domains}", domainsText)
83
+ .replace("{content}", content);
84
+
85
+ try {
86
+ const completion = await llmProvider.complete({
87
+ messages: [{ role: "user", content: prompt }],
88
+ maxTokens: 500,
89
+ temperature: 0,
90
+ });
91
+ return parseResponse(completion.text, content);
92
+ } catch {
93
+ return {
94
+ action: "add",
95
+ domain: "general",
96
+ content,
97
+ memory_layer: "fact",
98
+ reasoning: "LLM curation failed, defaulting to ADD",
99
+ };
100
+ }
101
+ }
102
+
103
+ export function parseResponse(
104
+ response: string,
105
+ originalContent: string
106
+ ): CurationDecision {
107
+ try {
108
+ let text = response.trim();
109
+
110
+ // Extract from code block if present
111
+ if (text.includes("```")) {
112
+ const start = text.indexOf("```") + 3;
113
+ let contentStart = start;
114
+ if (text.slice(start).startsWith("json")) {
115
+ contentStart = start + 4;
116
+ }
117
+ const end = text.indexOf("```", contentStart);
118
+ if (end > contentStart) {
119
+ text = text.slice(contentStart, end).trim();
120
+ }
121
+ }
122
+
123
+ const data = JSON.parse(text);
124
+ if (typeof data !== "object" || data === null) {
125
+ throw new Error("Expected JSON object");
126
+ }
127
+
128
+ let action = (data.action || "add").toLowerCase();
129
+ if (!["add", "update", "merge", "skip", "delete"].includes(action)) {
130
+ action = "add";
131
+ }
132
+
133
+ let memoryLayer = (data.memory_layer || "fact").toLowerCase();
134
+ if (!["fact", "observation", "model"].includes(memoryLayer)) {
135
+ memoryLayer = "fact";
136
+ }
137
+
138
+ // Sanitize domain name
139
+ let domain = (data.domain || "general").toLowerCase().trim();
140
+ domain = domain.replace(/ /g, "-").replace(/\//g, "-");
141
+ if (!domain) domain = "general";
142
+
143
+ return {
144
+ action: action as CurationDecision["action"],
145
+ domain,
146
+ content: data.content || originalContent,
147
+ memory_layer: memoryLayer as CurationDecision["memory_layer"],
148
+ reasoning: data.reasoning || "",
149
+ target_id: data.target_id || undefined,
150
+ };
151
+ } catch {
152
+ return {
153
+ action: "add",
154
+ domain: "general",
155
+ content: originalContent,
156
+ memory_layer: "fact",
157
+ reasoning: "Failed to parse LLM response",
158
+ };
159
+ }
160
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @astrocyteai/local — Zero-infrastructure memory for AI coding agents.
3
+ *
4
+ * Context Tree + SQLite FTS5 search. No database, no embeddings, no API keys.
5
+ */
6
+
7
+ export { ContextTree } from "./context-tree.js";
8
+ export { SearchEngine } from "./search.js";
9
+ export { createMcpServer, startMcpServer } from "./mcp-server.js";
10
+ export type { McpServerOptions } from "./mcp-server.js";
11
+ export { curateLocalRetain, parseResponse } from "./curated-retain.js";
12
+ export type { LLMProvider, CurationDecision } from "./curated-retain.js";
13
+ export { LocalRecallCache, LocalTieredRetriever } from "./tiered-retrieval.js";
14
+ export type {
15
+ MemoryEntry,
16
+ SearchHit,
17
+ RetainResult,
18
+ RecallResult,
19
+ BrowseResult,
20
+ LocalConfig,
21
+ } from "./types.js";
@@ -0,0 +1,282 @@
1
+ /**
2
+ * MCP server — exposes Context Tree as MCP tools.
3
+ *
4
+ * See docs/mcp-tools.md for tool schemas.
5
+ *
6
+ * Usage:
7
+ * npx @astrocyteai/local --root .astrocyte
8
+ * astrocyte-local-mcp --root .astrocyte
9
+ */
10
+
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { z } from "zod";
14
+ import { ContextTree } from "./context-tree.js";
15
+ import { SearchEngine } from "./search.js";
16
+
17
+ export interface McpServerOptions {
18
+ root: string;
19
+ defaultBank?: string;
20
+ transport?: "stdio" | "sse";
21
+ port?: number;
22
+ }
23
+
24
+ export function createMcpServer(options: McpServerOptions): McpServer {
25
+ const { root, defaultBank = "project" } = options;
26
+
27
+ const tree = new ContextTree(root);
28
+ const search = new SearchEngine(`${root}/_search.db`);
29
+ search.buildIndex(tree);
30
+
31
+ const server = new McpServer(
32
+ {
33
+ name: "astrocyte-local",
34
+ version: "0.1.0",
35
+ },
36
+ {
37
+ instructions:
38
+ "Local memory server. Use memory_retain to store information, " +
39
+ "memory_recall to search memories, memory_browse to explore the " +
40
+ "Context Tree hierarchy, and memory_forget to remove memories.",
41
+ }
42
+ );
43
+
44
+ // ── memory_retain ──
45
+
46
+ server.tool(
47
+ "memory_retain",
48
+ "Store content into local memory.",
49
+ {
50
+ content: z.string().describe("The text to memorize."),
51
+ bank_id: z.string().optional().describe("Memory bank (default: project)."),
52
+ tags: z.array(z.string()).optional().describe("Optional tags for filtering."),
53
+ domain: z.string().optional().describe("Context Tree domain (auto-inferred if omitted)."),
54
+ },
55
+ async (args) => {
56
+ const bankId = args.bank_id || defaultBank;
57
+ const domain = args.domain || (args.tags?.[0] ?? "general");
58
+
59
+ const entry = tree.store({
60
+ content: args.content,
61
+ bank_id: bankId,
62
+ domain,
63
+ tags: args.tags || [],
64
+ });
65
+ search.addDocument(entry);
66
+
67
+ return {
68
+ content: [
69
+ {
70
+ type: "text" as const,
71
+ text: JSON.stringify({
72
+ stored: true,
73
+ memory_id: entry.id,
74
+ domain: entry.domain,
75
+ file: entry.file_path,
76
+ }),
77
+ },
78
+ ],
79
+ };
80
+ }
81
+ );
82
+
83
+ // ── memory_recall ──
84
+
85
+ server.tool(
86
+ "memory_recall",
87
+ "Search local memory for content relevant to a query.",
88
+ {
89
+ query: z.string().describe("Natural language search query."),
90
+ bank_id: z.string().optional().describe("Memory bank (default: project)."),
91
+ max_results: z.number().optional().describe("Maximum results (default: 10)."),
92
+ tags: z.array(z.string()).optional().describe("Filter by tags."),
93
+ },
94
+ async (args) => {
95
+ const bankId = args.bank_id || defaultBank;
96
+ const hits = search.search(args.query, bankId, {
97
+ limit: args.max_results || 10,
98
+ tags: args.tags,
99
+ });
100
+
101
+ // Record recall
102
+ for (const h of hits) {
103
+ tree.recordRecall(h.id);
104
+ }
105
+
106
+ return {
107
+ content: [
108
+ {
109
+ type: "text" as const,
110
+ text: JSON.stringify({
111
+ hits: hits.map((h) => ({
112
+ text: h.text,
113
+ score: Math.round(h.score * 10000) / 10000,
114
+ domain: h.domain,
115
+ file: h.file_path,
116
+ memory_id: h.id,
117
+ })),
118
+ total: hits.length,
119
+ }),
120
+ },
121
+ ],
122
+ };
123
+ }
124
+ );
125
+
126
+ // ── memory_browse ──
127
+
128
+ server.tool(
129
+ "memory_browse",
130
+ "Browse the Context Tree hierarchy.",
131
+ {
132
+ path: z.string().optional().describe("Path to browse (empty for root, 'preferences' for a domain)."),
133
+ bank_id: z.string().optional().describe("Memory bank (default: project)."),
134
+ },
135
+ async (args) => {
136
+ const bankId = args.bank_id || defaultBank;
137
+ const browsePath = args.path || "";
138
+
139
+ if (!browsePath) {
140
+ const domains = tree.listDomains(bankId);
141
+ const total = tree.count(bankId);
142
+ return {
143
+ content: [
144
+ {
145
+ type: "text" as const,
146
+ text: JSON.stringify({
147
+ path: "",
148
+ domains,
149
+ entries: [],
150
+ total_memories: total,
151
+ }),
152
+ },
153
+ ],
154
+ };
155
+ }
156
+
157
+ const entries = tree.listEntries(bankId, browsePath);
158
+ return {
159
+ content: [
160
+ {
161
+ type: "text" as const,
162
+ text: JSON.stringify({
163
+ path: browsePath,
164
+ domains: [],
165
+ entries: entries.map((e) => ({
166
+ file: e.file_path,
167
+ title: e.text.slice(0, 80),
168
+ memory_id: e.id,
169
+ recall_count: e.recall_count,
170
+ })),
171
+ total_memories: entries.length,
172
+ }),
173
+ },
174
+ ],
175
+ };
176
+ }
177
+ );
178
+
179
+ // ── memory_forget ──
180
+
181
+ server.tool(
182
+ "memory_forget",
183
+ "Remove memories from local storage.",
184
+ {
185
+ memory_ids: z.array(z.string()).describe("IDs of memories to delete."),
186
+ bank_id: z.string().optional().describe("Memory bank (default: project)."),
187
+ },
188
+ async (args) => {
189
+ let deleted = 0;
190
+ const filesRemoved: string[] = [];
191
+
192
+ for (const mid of args.memory_ids) {
193
+ const entry = tree.read(mid);
194
+ if (entry) {
195
+ filesRemoved.push(entry.file_path);
196
+ }
197
+ if (tree.delete(mid)) {
198
+ search.removeDocument(mid);
199
+ deleted++;
200
+ }
201
+ }
202
+
203
+ return {
204
+ content: [
205
+ {
206
+ type: "text" as const,
207
+ text: JSON.stringify({
208
+ deleted_count: deleted,
209
+ files_removed: filesRemoved,
210
+ }),
211
+ },
212
+ ],
213
+ };
214
+ }
215
+ );
216
+
217
+ // ── memory_banks ──
218
+
219
+ server.tool(
220
+ "memory_banks",
221
+ "List available memory banks.",
222
+ {},
223
+ async () => {
224
+ const allEntries = tree.scanAll();
225
+ const bankIds = [...new Set(allEntries.map((e) => e.bank_id))].sort();
226
+ if (!bankIds.includes(defaultBank)) {
227
+ bankIds.unshift(defaultBank);
228
+ }
229
+
230
+ return {
231
+ content: [
232
+ {
233
+ type: "text" as const,
234
+ text: JSON.stringify({
235
+ banks: bankIds,
236
+ default: defaultBank,
237
+ root,
238
+ }),
239
+ },
240
+ ],
241
+ };
242
+ }
243
+ );
244
+
245
+ // ── memory_health ──
246
+
247
+ server.tool(
248
+ "memory_health",
249
+ "Check local memory system health.",
250
+ {},
251
+ async () => {
252
+ const total = tree.count();
253
+ return {
254
+ content: [
255
+ {
256
+ type: "text" as const,
257
+ text: JSON.stringify({
258
+ healthy: true,
259
+ total_memories: total,
260
+ index_status: "current",
261
+ root,
262
+ }),
263
+ },
264
+ ],
265
+ };
266
+ }
267
+ );
268
+
269
+ return server;
270
+ }
271
+
272
+ export async function startMcpServer(options: McpServerOptions): Promise<void> {
273
+ const server = createMcpServer(options);
274
+
275
+ if (options.transport === "sse") {
276
+ // SSE transport would require express — for now only stdio
277
+ throw new Error("SSE transport not yet implemented in TypeScript. Use stdio.");
278
+ }
279
+
280
+ const transport = new StdioServerTransport();
281
+ await server.connect(transport);
282
+ }