granite-mem 0.1.3 → 0.1.4

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 (3) hide show
  1. package/README.md +40 -1
  2. package/dist/index.js +1128 -10
  3. package/package.json +7 -3
package/README.md CHANGED
@@ -60,6 +60,7 @@ Granite is designed to be easy for agents to read and act on:
60
60
  - notes are plain files
61
61
  - metadata is explicit
62
62
  - commands support `--json`
63
+ - Granite ships with an MCP server
63
64
  - vault structure is predictable
64
65
  - search, backlinks, and recommendations are available from the CLI
65
66
 
@@ -85,7 +86,13 @@ mem add "Talked to Alice about local-first sync tradeoffs"
85
86
  mem new "Local-first sync tradeoffs" --type permanent
86
87
  mem list
87
88
  mem search "sync"
88
- mem serve
89
+ ```
90
+
91
+ Start one long-running interface when you need it:
92
+
93
+ ```bash
94
+ mem serve # local web UI
95
+ mem mcp # MCP server for agent clients
89
96
  ```
90
97
 
91
98
  `mem new` does more than create a file. It can immediately suggest related links, tags, and the next note to create, which is the core of Granite's value loop.
@@ -175,6 +182,37 @@ mem recommend sync-constraints --json
175
182
 
176
183
  That makes Granite a useful substrate for local workflows, scripts, and agent memory.
177
184
 
185
+ ## MCP Server
186
+
187
+ Granite ships with an MCP server so LLM clients can control the vault directly through tools, resources, and prompts.
188
+
189
+ Start it over stdio for local MCP clients:
190
+
191
+ ```bash
192
+ mem mcp --vault /path/to/vault
193
+ ```
194
+
195
+ Start it over Streamable HTTP:
196
+
197
+ ```bash
198
+ mem mcp --transport http --host 127.0.0.1 --port 3321
199
+ ```
200
+
201
+ The server exposes:
202
+
203
+ - tools for vault overview, list/get/search, create/update, backlinks, link suggestions, recommendations, and doctor
204
+ - resources for `granite.yml`, vault overview, note types, and individual notes via `granite://notes/{slug}`
205
+ - prompts for refining notes and reviewing links/next steps
206
+
207
+ Example stdio client configuration:
208
+
209
+ ```json
210
+ {
211
+ "command": "mem",
212
+ "args": ["mcp", "--vault", "/path/to/vault"]
213
+ }
214
+ ```
215
+
178
216
  ## Commands
179
217
 
180
218
  ```bash
@@ -192,6 +230,7 @@ mem recommend <slug> [--json]
192
230
  mem types
193
231
  mem doctor
194
232
  mem serve [-p <port>]
233
+ mem mcp [--vault <path>] [--transport <stdio|http>]
195
234
  ```
196
235
 
197
236
  ## Development
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command } from "commander";
4
+ import { Command, InvalidArgumentError } from "commander";
5
5
 
6
6
  // src/commands/init.ts
7
7
  import fs3 from "fs";
@@ -533,8 +533,8 @@ function countNoteFiles(vaultRoot, config) {
533
533
  }
534
534
  function upsertNoteAndLinks(db, note) {
535
535
  const upsertNote = db.prepare(`
536
- INSERT INTO notes (slug, id, title, type, created, modified, tags, aliases, body, filepath)
537
- VALUES (@slug, @id, @title, @type, @created, @modified, @tags, @aliases, @body, @filepath)
536
+ INSERT INTO notes (slug, id, title, type, created, modified, tags, aliases, body, filepath, status, source)
537
+ VALUES (@slug, @id, @title, @type, @created, @modified, @tags, @aliases, @body, @filepath, @status, @source)
538
538
  ON CONFLICT(slug) DO UPDATE SET
539
539
  id = excluded.id,
540
540
  title = excluded.title,
@@ -544,7 +544,9 @@ function upsertNoteAndLinks(db, note) {
544
544
  tags = excluded.tags,
545
545
  aliases = excluded.aliases,
546
546
  body = excluded.body,
547
- filepath = excluded.filepath
547
+ filepath = excluded.filepath,
548
+ status = excluded.status,
549
+ source = excluded.source
548
550
  `);
549
551
  const deleteLinks = db.prepare("DELETE FROM links WHERE source_slug = ?");
550
552
  const insertLink = db.prepare(`
@@ -562,7 +564,9 @@ function upsertNoteAndLinks(db, note) {
562
564
  tags: JSON.stringify(note.frontmatter.tags),
563
565
  aliases: JSON.stringify(note.frontmatter.aliases),
564
566
  body: note.body,
565
- filepath: note.filepath
567
+ filepath: note.filepath,
568
+ status: note.frontmatter.status ?? "active",
569
+ source: note.frontmatter.source ?? "human"
566
570
  });
567
571
  deleteLinks.run(note.slug);
568
572
  const links = parseWikilinks(note.body);
@@ -1354,7 +1358,8 @@ function showCommand(slug, options) {
1354
1358
  }
1355
1359
 
1356
1360
  // src/core/search.ts
1357
- function searchNotes(db, query) {
1361
+ function searchNotes(db, query, limit = 20) {
1362
+ const cappedLimit = Math.max(1, Math.min(limit, 100));
1358
1363
  const stmt = db.prepare(`
1359
1364
  SELECT
1360
1365
  n.slug,
@@ -1365,9 +1370,9 @@ function searchNotes(db, query) {
1365
1370
  JOIN notes n ON n.rowid = notes_fts.rowid
1366
1371
  WHERE notes_fts MATCH ?
1367
1372
  ORDER BY rank
1368
- LIMIT 20
1373
+ LIMIT ?
1369
1374
  `);
1370
- const rows = stmt.all(query);
1375
+ const rows = stmt.all(query, cappedLimit);
1371
1376
  return rows.map((r) => ({
1372
1377
  slug: r.slug,
1373
1378
  title: r.title,
@@ -1959,9 +1964,1109 @@ function printRecommendations(vaultRoot, config, filepath, strategy) {
1959
1964
  }
1960
1965
  }
1961
1966
 
1967
+ // src/commands/mcp.ts
1968
+ import path9 from "path";
1969
+
1970
+ // src/mcp/server.ts
1971
+ import { serve as serve2 } from "@hono/node-server";
1972
+ import { Hono as Hono2 } from "hono";
1973
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
1974
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1975
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
1976
+ import * as z from "zod/v4";
1977
+
1978
+ // src/version.ts
1979
+ var GRANITE_VERSION = "0.1.2";
1980
+
1981
+ // src/mcp/server.ts
1982
+ var readOnlyAnnotations = {
1983
+ readOnlyHint: true,
1984
+ destructiveHint: false,
1985
+ idempotentHint: true,
1986
+ openWorldHint: false
1987
+ };
1988
+ var writeAnnotations = {
1989
+ readOnlyHint: false,
1990
+ destructiveHint: false,
1991
+ idempotentHint: false,
1992
+ openWorldHint: false
1993
+ };
1994
+ var fieldDefinitionSchema = z.object({
1995
+ type: z.enum(["text", "date", "number", "boolean", "wikilink", "list", "enum"]),
1996
+ of: z.string().optional(),
1997
+ options: z.array(z.string()).optional(),
1998
+ required: z.boolean().optional(),
1999
+ default: z.string().optional(),
2000
+ description: z.string().optional()
2001
+ });
2002
+ var noteTypeInfoSchema = z.object({
2003
+ name: z.string(),
2004
+ description: z.string(),
2005
+ folder: z.string(),
2006
+ line_limit: z.number(),
2007
+ warn_only: z.boolean(),
2008
+ slug_format: z.enum(["title", "date"]),
2009
+ instructions: z.string().optional(),
2010
+ fields: z.record(z.string(), fieldDefinitionSchema).optional()
2011
+ });
2012
+ var noteSummarySchema = z.object({
2013
+ slug: z.string(),
2014
+ title: z.string(),
2015
+ type: z.string(),
2016
+ created: z.string(),
2017
+ modified: z.string(),
2018
+ tags: z.array(z.string()),
2019
+ aliases: z.array(z.string()),
2020
+ status: z.enum(["inbox", "active", "archived"]),
2021
+ source: z.enum(["human", "agent", "extraction"]),
2022
+ filepath: z.string(),
2023
+ resource_uri: z.string()
2024
+ });
2025
+ var wikiLinkSchema = z.object({
2026
+ raw: z.string(),
2027
+ target: z.string(),
2028
+ display: z.string(),
2029
+ resolved: z.boolean(),
2030
+ resolved_slug: z.string().optional()
2031
+ });
2032
+ var noteDetailsSchema = noteSummarySchema.extend({
2033
+ body: z.string(),
2034
+ frontmatter: z.record(z.string(), z.unknown()),
2035
+ outgoing_links: z.array(wikiLinkSchema)
2036
+ });
2037
+ var searchResultSchema = z.object({
2038
+ slug: z.string(),
2039
+ title: z.string(),
2040
+ snippet: z.string(),
2041
+ score: z.number()
2042
+ });
2043
+ var backlinkSchema = z.object({
2044
+ source_slug: z.string(),
2045
+ source_title: z.string(),
2046
+ context: z.string()
2047
+ });
2048
+ var linkSuggestionSchema = z.object({
2049
+ target_slug: z.string(),
2050
+ target_title: z.string(),
2051
+ mentions: z.number()
2052
+ });
2053
+ var recommendationSchema = z.object({
2054
+ additions: z.array(z.object({ text: z.string() })),
2055
+ links: z.array(z.object({
2056
+ slug: z.string(),
2057
+ title: z.string(),
2058
+ type: z.string(),
2059
+ reason: z.string(),
2060
+ source: z.enum(["mention", "search"])
2061
+ })),
2062
+ tags: z.array(z.object({
2063
+ tag: z.string(),
2064
+ weight: z.number(),
2065
+ source_slugs: z.array(z.string())
2066
+ })),
2067
+ next_steps: z.array(z.object({
2068
+ type: z.string(),
2069
+ title_hint: z.string().optional(),
2070
+ reason: z.string()
2071
+ }))
2072
+ });
2073
+ var doctorIssueSchema = z.object({
2074
+ level: z.enum(["error", "warning", "info"]),
2075
+ file: z.string(),
2076
+ message: z.string()
2077
+ });
2078
+ var vaultOverviewSchema = z.object({
2079
+ vault_root: z.string(),
2080
+ vault_name: z.string(),
2081
+ default_type: z.string(),
2082
+ auto_rebuild: z.boolean(),
2083
+ index_last_rebuild: z.string().optional(),
2084
+ note_count: z.number(),
2085
+ notes_by_type: z.record(z.string(), z.number()),
2086
+ recent_notes: z.array(noteSummarySchema)
2087
+ });
2088
+ function createGraniteMcpServer(runtime) {
2089
+ const server = new McpServer(
2090
+ {
2091
+ name: "granite",
2092
+ version: GRANITE_VERSION,
2093
+ title: "Granite MCP Server"
2094
+ },
2095
+ {
2096
+ capabilities: { logging: {} },
2097
+ instructions: [
2098
+ "Granite exposes a local-first markdown vault.",
2099
+ "Prefer read tools and resources first, then write with granite_create_note or granite_update_note.",
2100
+ "Resources are read-only views; notes live on disk and the SQLite index is derived state.",
2101
+ "Use granite_recommend_note_actions to preserve Granite\u2019s capture -> link -> recommend loop."
2102
+ ].join(" ")
2103
+ }
2104
+ );
2105
+ registerTools(server, runtime);
2106
+ registerResources(server, runtime);
2107
+ registerPrompts(server, runtime);
2108
+ return server;
2109
+ }
2110
+ async function startGraniteMcpStdioServer(runtime) {
2111
+ const server = createGraniteMcpServer(runtime);
2112
+ const transport = new StdioServerTransport();
2113
+ await server.connect(transport);
2114
+ console.error(`Granite MCP server listening on stdio for ${runtime.vaultRoot}`);
2115
+ }
2116
+ function startGraniteMcpHttpServer(runtime, options) {
2117
+ const app = createGraniteMcpHttpApp(runtime, options);
2118
+ console.error(`Granite MCP server listening on http://${options.host}:${options.port}/mcp`);
2119
+ console.error(`Health check: http://${options.host}:${options.port}/health`);
2120
+ console.error(`Vault: ${runtime.vaultRoot}`);
2121
+ serve2({
2122
+ fetch: app.fetch,
2123
+ hostname: options.host,
2124
+ port: options.port
2125
+ });
2126
+ }
2127
+ async function withResponseCleanup(response, cleanup) {
2128
+ let cleanedUp = false;
2129
+ const cleanupOnce = async () => {
2130
+ if (cleanedUp) {
2131
+ return;
2132
+ }
2133
+ cleanedUp = true;
2134
+ await cleanup();
2135
+ };
2136
+ if (!response.body) {
2137
+ await cleanupOnce();
2138
+ return response;
2139
+ }
2140
+ const reader = response.body.getReader();
2141
+ const wrappedBody = new ReadableStream({
2142
+ async pull(controller) {
2143
+ try {
2144
+ const { done, value } = await reader.read();
2145
+ if (done) {
2146
+ controller.close();
2147
+ await cleanupOnce();
2148
+ return;
2149
+ }
2150
+ controller.enqueue(value);
2151
+ } catch (error) {
2152
+ controller.error(error);
2153
+ await cleanupOnce();
2154
+ }
2155
+ },
2156
+ async cancel(reason) {
2157
+ try {
2158
+ await reader.cancel(reason);
2159
+ } finally {
2160
+ await cleanupOnce();
2161
+ }
2162
+ }
2163
+ });
2164
+ return new Response(wrappedBody, {
2165
+ status: response.status,
2166
+ statusText: response.statusText,
2167
+ headers: response.headers
2168
+ });
2169
+ }
2170
+ function registerTools(server, runtime) {
2171
+ server.registerTool("granite_get_vault_overview", {
2172
+ title: "Granite Vault Overview",
2173
+ description: "Summarize the current Granite vault, including counts by type and recent notes.",
2174
+ inputSchema: {
2175
+ recent_limit: z.number().int().min(1).max(20).optional().describe("How many recent notes to include. Defaults to 10.")
2176
+ },
2177
+ outputSchema: vaultOverviewSchema,
2178
+ annotations: readOnlyAnnotations
2179
+ }, async ({ recent_limit }) => {
2180
+ const overview = runtime.getVaultOverview(recent_limit ?? 10);
2181
+ return toolResult(
2182
+ overview,
2183
+ `Vault "${overview.vault_name}" with ${overview.note_count} notes.`
2184
+ );
2185
+ });
2186
+ server.registerTool("granite_list_note_types", {
2187
+ title: "Granite Note Types",
2188
+ description: "List the note types configured in the vault.",
2189
+ outputSchema: z.object({
2190
+ default_type: z.string(),
2191
+ note_types: z.array(noteTypeInfoSchema)
2192
+ }),
2193
+ annotations: readOnlyAnnotations
2194
+ }, async () => {
2195
+ const noteTypes = runtime.listNoteTypes();
2196
+ return toolResult({
2197
+ default_type: runtime.getDefaultNoteType(),
2198
+ note_types: noteTypes
2199
+ }, `Loaded ${noteTypes.length} Granite note types.`);
2200
+ });
2201
+ server.registerTool("granite_list_notes", {
2202
+ title: "List Granite Notes",
2203
+ description: "List notes from the Granite vault with optional filters.",
2204
+ inputSchema: {
2205
+ type: z.string().optional().describe("Filter by note type."),
2206
+ status: z.enum(["inbox", "active", "archived"]).optional().describe("Filter by note status."),
2207
+ source: z.enum(["human", "agent", "extraction"]).optional().describe("Filter by note source."),
2208
+ since: z.string().optional().describe("Only notes modified on or after this ISO or YYYY-MM-DD value."),
2209
+ limit: z.number().int().min(1).max(200).optional().describe("Maximum number of notes to return. Defaults to 25.")
2210
+ },
2211
+ outputSchema: z.object({
2212
+ notes: z.array(noteSummarySchema),
2213
+ total: z.number()
2214
+ }),
2215
+ annotations: readOnlyAnnotations
2216
+ }, async (args) => {
2217
+ const notes = runtime.listNotes(args);
2218
+ return toolResult({
2219
+ notes,
2220
+ total: notes.length
2221
+ }, `Returned ${notes.length} note(s).`);
2222
+ });
2223
+ server.registerTool("granite_get_note", {
2224
+ title: "Get Granite Note",
2225
+ description: "Read a Granite note by slug, including frontmatter, body, and resolved outgoing links.",
2226
+ inputSchema: {
2227
+ slug: z.string().describe("The note slug.")
2228
+ },
2229
+ outputSchema: noteDetailsSchema,
2230
+ annotations: readOnlyAnnotations
2231
+ }, async ({ slug }) => {
2232
+ const note = runtime.getNote(slug);
2233
+ return {
2234
+ ...toolResult(note, `Loaded "${note.title}" (${note.slug}).`),
2235
+ content: [
2236
+ { type: "text", text: `Loaded "${note.title}" (${note.slug}).` },
2237
+ createNoteResourceLink(note.title, note.resource_uri)
2238
+ ]
2239
+ };
2240
+ });
2241
+ server.registerTool("granite_search_notes", {
2242
+ title: "Search Granite Notes",
2243
+ description: "Run full-text search against the Granite index.",
2244
+ inputSchema: {
2245
+ query: z.string().describe("Full-text query for the SQLite FTS index."),
2246
+ limit: z.number().int().min(1).max(50).optional().describe("Maximum number of search results. Defaults to 10.")
2247
+ },
2248
+ outputSchema: z.object({
2249
+ query: z.string(),
2250
+ results: z.array(searchResultSchema)
2251
+ }),
2252
+ annotations: readOnlyAnnotations
2253
+ }, async ({ query, limit }) => {
2254
+ const results = runtime.search(query, limit ?? 10);
2255
+ return toolResult({
2256
+ query,
2257
+ results
2258
+ }, `Found ${results.length} result(s) for "${query}".`);
2259
+ });
2260
+ server.registerTool("granite_create_note", {
2261
+ title: "Create Granite Note",
2262
+ description: "Create a new note in the Granite vault.",
2263
+ inputSchema: {
2264
+ title: z.string().describe("Note title."),
2265
+ type: z.string().optional().describe("Note type. Defaults to the vault default type."),
2266
+ body: z.string().optional().describe("Optional note body. If omitted, Granite uses the type template."),
2267
+ tags: z.array(z.string()).optional().describe("Tags to add immediately."),
2268
+ aliases: z.array(z.string()).optional().describe("Aliases to add immediately."),
2269
+ status: z.enum(["inbox", "active", "archived"]).optional().describe("Initial note status."),
2270
+ source: z.enum(["human", "agent", "extraction"]).optional().describe("Initial note source.")
2271
+ },
2272
+ outputSchema: z.object({
2273
+ note: noteDetailsSchema,
2274
+ recommendations: recommendationSchema
2275
+ }),
2276
+ annotations: writeAnnotations
2277
+ }, async (args) => {
2278
+ const result = runtime.createNote(args);
2279
+ return toolResult(result, `Created "${result.note.title}" (${result.note.slug}).`);
2280
+ });
2281
+ server.registerTool("granite_capture_note", {
2282
+ title: "Capture Granite Note",
2283
+ description: "Quick-capture a note from free-form text, similar to mem add.",
2284
+ inputSchema: {
2285
+ text: z.string().describe("Raw capture text."),
2286
+ type: z.string().optional().describe("Optional note type override. Defaults to the vault default type."),
2287
+ tags: z.array(z.string()).optional().describe("Tags to add immediately."),
2288
+ aliases: z.array(z.string()).optional().describe("Aliases to add immediately."),
2289
+ status: z.enum(["inbox", "active", "archived"]).optional().describe("Initial note status."),
2290
+ source: z.enum(["human", "agent", "extraction"]).optional().describe("Initial note source.")
2291
+ },
2292
+ outputSchema: z.object({
2293
+ note: noteDetailsSchema,
2294
+ recommendations: recommendationSchema
2295
+ }),
2296
+ annotations: writeAnnotations
2297
+ }, async (args) => {
2298
+ const result = runtime.captureNote(args);
2299
+ return toolResult(result, `Captured "${result.note.title}" (${result.note.slug}).`);
2300
+ });
2301
+ server.registerTool("granite_update_note", {
2302
+ title: "Update Granite Note",
2303
+ description: "Update frontmatter or body fields for an existing Granite note.",
2304
+ inputSchema: {
2305
+ slug: z.string().describe("Slug of the note to update."),
2306
+ title: z.string().optional().describe("Replace the note title."),
2307
+ body: z.string().optional().describe("Replace the entire note body."),
2308
+ append: z.string().optional().describe("Append text to the existing note body."),
2309
+ tags: z.array(z.string()).optional().describe("Tags to add."),
2310
+ aliases: z.array(z.string()).optional().describe("Aliases to add."),
2311
+ status: z.enum(["inbox", "active", "archived"]).optional().describe("New note status."),
2312
+ source: z.enum(["human", "agent", "extraction"]).optional().describe("New note source.")
2313
+ },
2314
+ outputSchema: z.object({
2315
+ note: noteDetailsSchema,
2316
+ recommendations: recommendationSchema
2317
+ }),
2318
+ annotations: writeAnnotations
2319
+ }, async ({ slug, ...updates }) => {
2320
+ const result = runtime.updateNote(slug, updates);
2321
+ return toolResult(result, `Updated "${result.note.title}" (${result.note.slug}).`);
2322
+ });
2323
+ server.registerTool("granite_get_backlinks", {
2324
+ title: "Granite Backlinks",
2325
+ description: "List notes that link to a given Granite note.",
2326
+ inputSchema: {
2327
+ slug: z.string().describe("Slug of the target note.")
2328
+ },
2329
+ outputSchema: z.object({
2330
+ slug: z.string(),
2331
+ backlinks: z.array(backlinkSchema)
2332
+ }),
2333
+ annotations: readOnlyAnnotations
2334
+ }, async ({ slug }) => {
2335
+ const backlinks = runtime.getBacklinks(slug);
2336
+ return toolResult({
2337
+ slug,
2338
+ backlinks
2339
+ }, `Found ${backlinks.length} backlink(s) for "${slug}".`);
2340
+ });
2341
+ server.registerTool("granite_suggest_links", {
2342
+ title: "Suggest Granite Links",
2343
+ description: "Suggest missing wikilinks for a Granite note based on mentions.",
2344
+ inputSchema: {
2345
+ slug: z.string().describe("Slug of the note to inspect.")
2346
+ },
2347
+ outputSchema: z.object({
2348
+ slug: z.string(),
2349
+ suggestions: z.array(linkSuggestionSchema)
2350
+ }),
2351
+ annotations: readOnlyAnnotations
2352
+ }, async ({ slug }) => {
2353
+ const suggestions = runtime.suggestLinks(slug);
2354
+ return toolResult({
2355
+ slug,
2356
+ suggestions
2357
+ }, `Found ${suggestions.length} suggested link(s) for "${slug}".`);
2358
+ });
2359
+ server.registerTool("granite_recommend_note_actions", {
2360
+ title: "Recommend Granite Actions",
2361
+ description: "Recommend follow-up links, tags, additions, and next notes for a Granite note.",
2362
+ inputSchema: {
2363
+ slug: z.string().describe("Slug of the note to analyze.")
2364
+ },
2365
+ outputSchema: z.object({
2366
+ slug: z.string(),
2367
+ recommendations: recommendationSchema
2368
+ }),
2369
+ annotations: readOnlyAnnotations
2370
+ }, async ({ slug }) => {
2371
+ const recommendations = runtime.recommend(slug);
2372
+ return toolResult({
2373
+ slug,
2374
+ recommendations
2375
+ }, recommendationSummary(recommendations));
2376
+ });
2377
+ server.registerTool("granite_run_doctor", {
2378
+ title: "Run Granite Doctor",
2379
+ description: "Validate vault health and report structural issues.",
2380
+ outputSchema: z.object({
2381
+ issues: z.array(doctorIssueSchema),
2382
+ counts: z.object({
2383
+ errors: z.number(),
2384
+ warnings: z.number(),
2385
+ info: z.number()
2386
+ })
2387
+ }),
2388
+ annotations: readOnlyAnnotations
2389
+ }, async () => {
2390
+ const result = runtime.runDoctor();
2391
+ return toolResult(
2392
+ result,
2393
+ `${result.counts.errors} error(s), ${result.counts.warnings} warning(s), ${result.counts.info} info item(s).`
2394
+ );
2395
+ });
2396
+ }
2397
+ function registerResources(server, runtime) {
2398
+ server.registerResource("granite-vault-config", "granite://vault/config", {
2399
+ title: "Granite Config",
2400
+ description: "Raw granite.yml configuration for the current vault.",
2401
+ mimeType: "text/yaml"
2402
+ }, async () => ({
2403
+ contents: [{
2404
+ uri: "granite://vault/config",
2405
+ text: runtime.readVaultConfigRaw(),
2406
+ mimeType: "text/yaml"
2407
+ }]
2408
+ }));
2409
+ server.registerResource("granite-vault-overview", "granite://vault/overview", {
2410
+ title: "Granite Vault Overview",
2411
+ description: "Structured summary of the current vault.",
2412
+ mimeType: "application/json"
2413
+ }, async () => ({
2414
+ contents: [{
2415
+ uri: "granite://vault/overview",
2416
+ text: runtime.readVaultOverviewJson(),
2417
+ mimeType: "application/json"
2418
+ }]
2419
+ }));
2420
+ server.registerResource("granite-note-types", "granite://vault/types", {
2421
+ title: "Granite Note Types",
2422
+ description: "Structured list of the note types configured in the current vault.",
2423
+ mimeType: "application/json"
2424
+ }, async () => ({
2425
+ contents: [{
2426
+ uri: "granite://vault/types",
2427
+ text: runtime.readVaultTypesJson(),
2428
+ mimeType: "application/json"
2429
+ }]
2430
+ }));
2431
+ const noteTemplate = new ResourceTemplate("granite://notes/{slug}", {
2432
+ list: void 0,
2433
+ complete: {
2434
+ slug: async (value) => runtime.completeSlugs(value)
2435
+ }
2436
+ });
2437
+ server.registerResource("granite-note", noteTemplate, {
2438
+ title: "Granite Note",
2439
+ description: "Read a Granite note as markdown with YAML frontmatter.",
2440
+ mimeType: "text/markdown"
2441
+ }, async (uri, variables) => {
2442
+ const variable = variables.slug;
2443
+ const slug = Array.isArray(variable) ? variable[0] : variable;
2444
+ if (!slug) {
2445
+ throw new Error("Resource URI is missing the note slug.");
2446
+ }
2447
+ return {
2448
+ contents: [{
2449
+ uri: uri.toString(),
2450
+ text: runtime.readNoteMarkdown(decodeURIComponent(slug)),
2451
+ mimeType: "text/markdown"
2452
+ }]
2453
+ };
2454
+ });
2455
+ }
2456
+ function registerPrompts(server, runtime) {
2457
+ server.registerPrompt("granite_refine_note", {
2458
+ title: "Refine Granite Note",
2459
+ description: "Create a prompt for turning a note into a cleaner permanent note draft.",
2460
+ argsSchema: {
2461
+ slug: z.string().describe("Slug of the note to refine.")
2462
+ }
2463
+ }, async ({ slug }) => {
2464
+ const note = runtime.getNote(slug);
2465
+ return {
2466
+ description: `Refine ${note.slug} into a durable Granite note.`,
2467
+ messages: [
2468
+ {
2469
+ role: "user",
2470
+ content: {
2471
+ type: "text",
2472
+ text: [
2473
+ "Refine the attached Granite note into a durable, well-structured permanent note.",
2474
+ "Keep the meaning intact, avoid inventing facts, preserve useful wikilinks, and use Granite-style headings when appropriate."
2475
+ ].join(" ")
2476
+ }
2477
+ },
2478
+ {
2479
+ role: "user",
2480
+ content: {
2481
+ type: "resource",
2482
+ resource: {
2483
+ uri: note.resource_uri,
2484
+ text: runtime.readNoteMarkdown(slug),
2485
+ mimeType: "text/markdown"
2486
+ }
2487
+ }
2488
+ }
2489
+ ]
2490
+ };
2491
+ });
2492
+ server.registerPrompt("granite_review_connections", {
2493
+ title: "Review Granite Connections",
2494
+ description: "Create a prompt for improving note links, tags, and next steps.",
2495
+ argsSchema: {
2496
+ slug: z.string().describe("Slug of the note to inspect.")
2497
+ }
2498
+ }, async ({ slug }) => {
2499
+ const note = runtime.getNote(slug);
2500
+ const backlinks = runtime.getBacklinks(slug);
2501
+ const recommendations = runtime.recommend(slug);
2502
+ return {
2503
+ description: `Review the connections around ${note.slug}.`,
2504
+ messages: [
2505
+ {
2506
+ role: "user",
2507
+ content: {
2508
+ type: "text",
2509
+ text: [
2510
+ "Review this Granite note and propose better links, tags, and the most useful follow-up note.",
2511
+ "Prefer concrete suggestions grounded in the vault context below."
2512
+ ].join(" ")
2513
+ }
2514
+ },
2515
+ {
2516
+ role: "user",
2517
+ content: {
2518
+ type: "resource",
2519
+ resource: {
2520
+ uri: note.resource_uri,
2521
+ text: runtime.readNoteMarkdown(slug),
2522
+ mimeType: "text/markdown"
2523
+ }
2524
+ }
2525
+ },
2526
+ {
2527
+ role: "user",
2528
+ content: {
2529
+ type: "text",
2530
+ text: JSON.stringify({ backlinks, recommendations }, null, 2)
2531
+ }
2532
+ }
2533
+ ]
2534
+ };
2535
+ });
2536
+ }
2537
+ function toolResult(structuredContent, summary) {
2538
+ return {
2539
+ content: [{ type: "text", text: summary }],
2540
+ structuredContent: asStructuredContent(structuredContent)
2541
+ };
2542
+ }
2543
+ function createNoteResourceLink(name, uri) {
2544
+ return {
2545
+ type: "resource_link",
2546
+ name,
2547
+ uri,
2548
+ mimeType: "text/markdown",
2549
+ description: "Read the markdown resource for this note."
2550
+ };
2551
+ }
2552
+ function recommendationSummary(recommendations) {
2553
+ return [
2554
+ `${recommendations.links.length} link suggestion(s)`,
2555
+ `${recommendations.tags.length} tag suggestion(s)`,
2556
+ `${recommendations.next_steps.length} next-step suggestion(s)`
2557
+ ].join(", ");
2558
+ }
2559
+ function asStructuredContent(value) {
2560
+ return JSON.parse(JSON.stringify(value));
2561
+ }
2562
+ function createGraniteMcpHttpApp(runtime, options) {
2563
+ const app = new Hono2();
2564
+ const allowedHosts = buildAllowedHosts(options.host, options.port);
2565
+ const allowedOrigins = buildAllowedOrigins(options.host, options.port, options.allowedOrigins ?? []);
2566
+ app.use("/mcp", async (c, next) => {
2567
+ const requestHost = (c.req.header("host") ?? "").toLowerCase();
2568
+ if (allowedHosts.size > 0 && !allowedHosts.has(requestHost)) {
2569
+ return c.json({
2570
+ jsonrpc: "2.0",
2571
+ error: { code: -32e3, message: "Forbidden host header." },
2572
+ id: null
2573
+ }, 403);
2574
+ }
2575
+ const origin = c.req.header("origin");
2576
+ if (origin && !allowedOrigins.has(origin)) {
2577
+ return c.json({
2578
+ jsonrpc: "2.0",
2579
+ error: { code: -32e3, message: "Forbidden origin." },
2580
+ id: null
2581
+ }, 403);
2582
+ }
2583
+ if (c.req.method === "OPTIONS") {
2584
+ return new Response(null, {
2585
+ status: 204,
2586
+ headers: corsHeaders(origin, allowedOrigins)
2587
+ });
2588
+ }
2589
+ await next();
2590
+ });
2591
+ app.get("/health", (c) => c.json({ status: "ok", name: "granite-mcp", version: GRANITE_VERSION }));
2592
+ app.all("/mcp", async (c) => {
2593
+ const origin = c.req.header("origin");
2594
+ const server = createGraniteMcpServer(runtime);
2595
+ const transport = new WebStandardStreamableHTTPServerTransport({
2596
+ sessionIdGenerator: void 0,
2597
+ enableJsonResponse: options.jsonResponse ?? false
2598
+ });
2599
+ try {
2600
+ await server.connect(transport);
2601
+ const response = await transport.handleRequest(c.req.raw);
2602
+ const managedResponse = await withResponseCleanup(response, async () => {
2603
+ await server.close();
2604
+ });
2605
+ return withCors(managedResponse, origin, allowedOrigins);
2606
+ } catch (error) {
2607
+ await server.close();
2608
+ throw error;
2609
+ }
2610
+ });
2611
+ return app;
2612
+ }
2613
+ function buildAllowedHosts(host, port) {
2614
+ const normalizedHost = host.toLowerCase();
2615
+ if (normalizedHost === "0.0.0.0" || normalizedHost === "::" || normalizedHost === "[::]") {
2616
+ return /* @__PURE__ */ new Set();
2617
+ }
2618
+ const allowed = /* @__PURE__ */ new Set([`${normalizedHost}:${port}`]);
2619
+ if (normalizedHost === "127.0.0.1" || normalizedHost === "localhost") {
2620
+ allowed.add(`127.0.0.1:${port}`);
2621
+ allowed.add(`localhost:${port}`);
2622
+ }
2623
+ if (normalizedHost === "::1" || normalizedHost === "[::1]") {
2624
+ allowed.add(`[::1]:${port}`);
2625
+ }
2626
+ return allowed;
2627
+ }
2628
+ function buildAllowedOrigins(host, port, extraOrigins) {
2629
+ const allowed = new Set(extraOrigins);
2630
+ const normalizedHost = host.toLowerCase();
2631
+ if (normalizedHost === "::1" || normalizedHost === "[::1]") {
2632
+ allowed.add(`http://[::1]:${port}`);
2633
+ } else if (normalizedHost !== "0.0.0.0" && normalizedHost !== "::" && normalizedHost !== "[::]") {
2634
+ allowed.add(`http://${normalizedHost}:${port}`);
2635
+ }
2636
+ if (normalizedHost === "127.0.0.1" || normalizedHost === "localhost") {
2637
+ allowed.add(`http://127.0.0.1:${port}`);
2638
+ allowed.add(`http://localhost:${port}`);
2639
+ }
2640
+ return allowed;
2641
+ }
2642
+ function withCors(response, origin, allowedOrigins) {
2643
+ const headers = new Headers(response.headers);
2644
+ for (const [key, value] of corsHeaders(origin, allowedOrigins)) {
2645
+ headers.set(key, value);
2646
+ }
2647
+ return new Response(response.body, {
2648
+ status: response.status,
2649
+ statusText: response.statusText,
2650
+ headers
2651
+ });
2652
+ }
2653
+ function corsHeaders(origin, allowedOrigins) {
2654
+ const headers = new Headers();
2655
+ if (origin && allowedOrigins.has(origin)) {
2656
+ headers.set("Access-Control-Allow-Origin", origin);
2657
+ headers.set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
2658
+ headers.set("Access-Control-Allow-Headers", "Content-Type, MCP-Protocol-Version, Last-Event-ID");
2659
+ headers.set("Access-Control-Expose-Headers", "MCP-Session-Id, MCP-Protocol-Version");
2660
+ headers.set("Vary", "Origin");
2661
+ }
2662
+ return headers;
2663
+ }
2664
+
2665
+ // src/mcp/runtime.ts
2666
+ import fs12 from "fs";
2667
+ import path8 from "path";
2668
+ var GraniteMcpRuntime = class {
2669
+ vaultRoot;
2670
+ config;
2671
+ db;
2672
+ indexCheckIntervalMs;
2673
+ lastSignature;
2674
+ lastIndexCheckAt = 0;
2675
+ constructor(vaultRoot, options = {}) {
2676
+ this.vaultRoot = path8.resolve(vaultRoot);
2677
+ this.config = loadConfig(this.vaultRoot);
2678
+ this.db = openDatabase(this.vaultRoot);
2679
+ this.indexCheckIntervalMs = options.indexCheckIntervalMs ?? 1500;
2680
+ }
2681
+ close() {
2682
+ this.db.close();
2683
+ }
2684
+ getVaultOverview(recentLimit = 10) {
2685
+ const notes = this.readAllNotes().sort((a, b) => b.frontmatter.modified.localeCompare(a.frontmatter.modified));
2686
+ const byType = {};
2687
+ for (const note of notes) {
2688
+ byType[note.frontmatter.type] = (byType[note.frontmatter.type] ?? 0) + 1;
2689
+ }
2690
+ return {
2691
+ vault_root: this.vaultRoot,
2692
+ vault_name: this.config.vault_name,
2693
+ default_type: this.config.defaults.note_type,
2694
+ auto_rebuild: this.config.index.auto_rebuild,
2695
+ index_last_rebuild: this.getMetaValue("last_rebuild"),
2696
+ note_count: notes.length,
2697
+ notes_by_type: byType,
2698
+ recent_notes: notes.slice(0, clampLimit(recentLimit, 20)).map((note) => this.toNoteSummary(note))
2699
+ };
2700
+ }
2701
+ listNoteTypes() {
2702
+ return Object.entries(this.config.note_types).map(([name, typeConfig]) => ({
2703
+ name,
2704
+ description: typeConfig.description,
2705
+ folder: typeConfig.folder,
2706
+ line_limit: typeConfig.line_limit,
2707
+ warn_only: typeConfig.warn_only,
2708
+ slug_format: typeConfig.slug_format ?? "title",
2709
+ instructions: typeConfig.instructions,
2710
+ fields: typeConfig.fields
2711
+ }));
2712
+ }
2713
+ getDefaultNoteType() {
2714
+ return this.config.defaults.note_type;
2715
+ }
2716
+ listNotes(input = {}) {
2717
+ let notes = this.readAllNotes();
2718
+ if (input.type) {
2719
+ notes = notes.filter((note) => note.frontmatter.type === input.type);
2720
+ }
2721
+ if (input.status) {
2722
+ notes = notes.filter((note) => note.frontmatter.status === input.status);
2723
+ }
2724
+ if (input.source) {
2725
+ notes = notes.filter((note) => note.frontmatter.source === input.source);
2726
+ }
2727
+ if (input.since) {
2728
+ const since = input.since;
2729
+ notes = notes.filter((note) => note.frontmatter.modified >= since);
2730
+ }
2731
+ notes.sort((a, b) => b.frontmatter.modified.localeCompare(a.frontmatter.modified));
2732
+ return notes.slice(0, clampLimit(input.limit ?? 25, 200)).map((note) => this.toNoteSummary(note));
2733
+ }
2734
+ getNote(slug) {
2735
+ const note = this.requireNote(slug);
2736
+ const allNotes = this.readAllNotes();
2737
+ const outgoingLinks = resolveWikilinks(parseWikilinks(note.body), allNotes);
2738
+ return {
2739
+ ...this.toNoteSummary(note),
2740
+ body: note.body,
2741
+ frontmatter: note.frontmatter,
2742
+ outgoing_links: outgoingLinks
2743
+ };
2744
+ }
2745
+ search(query, limit = 10) {
2746
+ this.refreshIndex();
2747
+ return searchNotes(this.db, query, clampLimit(limit, 50));
2748
+ }
2749
+ getBacklinks(slug) {
2750
+ this.requireNote(slug);
2751
+ this.refreshIndex();
2752
+ return getBacklinks(this.db, slug);
2753
+ }
2754
+ suggestLinks(slug) {
2755
+ const note = this.requireNote(slug);
2756
+ this.refreshIndex();
2757
+ return suggestLinks(this.db, note);
2758
+ }
2759
+ recommend(slug) {
2760
+ const note = this.requireNote(slug);
2761
+ this.refreshIndex();
2762
+ return getRecommendations(this.db, note, this.config);
2763
+ }
2764
+ runDoctor() {
2765
+ this.refreshIndex();
2766
+ const issues = runDoctor(this.vaultRoot, this.config, this.db);
2767
+ return {
2768
+ counts: {
2769
+ errors: issues.filter((issue) => issue.level === "error").length,
2770
+ warnings: issues.filter((issue) => issue.level === "warning").length,
2771
+ info: issues.filter((issue) => issue.level === "info").length
2772
+ },
2773
+ issues
2774
+ };
2775
+ }
2776
+ createNote(input) {
2777
+ const resolvedType = input.type ?? this.config.defaults.note_type;
2778
+ const typeConfig = this.config.note_types[resolvedType];
2779
+ if (!typeConfig) {
2780
+ throw new Error(`Unknown note type: "${resolvedType}"`);
2781
+ }
2782
+ const bodyOverride = input.body !== void 0 ? ensureTrailingNewline(input.body) : typeConfig.slug_format === "date" ? ensureTrailingNewline(input.title) : void 0;
2783
+ const created = createNote(this.vaultRoot, this.config, resolvedType, input.title, bodyOverride);
2784
+ const metadataMutations = {
2785
+ tags: input.tags,
2786
+ aliases: input.aliases,
2787
+ status: input.status,
2788
+ source: input.source
2789
+ };
2790
+ if (hasMetadataMutations(metadataMutations)) {
2791
+ this.applyMutations(created.filepath, metadataMutations);
2792
+ }
2793
+ return this.afterWrite(created.slug, true);
2794
+ }
2795
+ captureNote(input) {
2796
+ const content = input.text.trim();
2797
+ if (!content) {
2798
+ throw new Error("Capture text cannot be empty.");
2799
+ }
2800
+ const firstLine = content.split("\n")[0] ?? "Untitled";
2801
+ const title = firstLine.length > 60 ? `${firstLine.slice(0, 60).trim()}...` : firstLine;
2802
+ return this.createNote({
2803
+ title,
2804
+ type: input.type,
2805
+ body: ensureTrailingNewline(content),
2806
+ tags: input.tags,
2807
+ aliases: input.aliases,
2808
+ status: input.status,
2809
+ source: input.source
2810
+ });
2811
+ }
2812
+ updateNote(slug, input) {
2813
+ const note = this.requireNote(slug);
2814
+ const needsFullRebuild = input.title !== void 0 || (input.aliases?.length ?? 0) > 0;
2815
+ this.applyMutations(note.filepath, input);
2816
+ if (needsFullRebuild) {
2817
+ return this.afterWrite(slug, true);
2818
+ }
2819
+ this.refreshIndex();
2820
+ const updated = readNote(note.filepath);
2821
+ syncNoteInIndex(this.vaultRoot, this.config, this.db, updated);
2822
+ this.captureSignature();
2823
+ return {
2824
+ note: this.getNote(updated.slug),
2825
+ recommendations: getRecommendations(this.db, updated, this.config)
2826
+ };
2827
+ }
2828
+ readVaultConfigRaw() {
2829
+ return fs12.readFileSync(path8.join(this.vaultRoot, CONFIG_FILENAME), "utf-8");
2830
+ }
2831
+ readVaultTypesJson() {
2832
+ return JSON.stringify({
2833
+ default_type: this.config.defaults.note_type,
2834
+ note_types: this.listNoteTypes()
2835
+ }, null, 2);
2836
+ }
2837
+ readVaultOverviewJson() {
2838
+ return JSON.stringify(this.getVaultOverview(), null, 2);
2839
+ }
2840
+ readNoteMarkdown(slug) {
2841
+ const note = this.requireNote(slug);
2842
+ return fs12.readFileSync(note.filepath, "utf-8");
2843
+ }
2844
+ completeSlugs(prefix = "") {
2845
+ const normalizedPrefix = prefix.toLowerCase();
2846
+ return this.readAllNotes().sort((a, b) => b.frontmatter.modified.localeCompare(a.frontmatter.modified)).map((note) => note.slug).filter((slug) => slug.toLowerCase().startsWith(normalizedPrefix)).slice(0, 25);
2847
+ }
2848
+ noteResourceUri(slug) {
2849
+ return `granite://notes/${encodeURIComponent(slug)}`;
2850
+ }
2851
+ refreshIndex(force = false) {
2852
+ const now = Date.now();
2853
+ if (!force && now - this.lastIndexCheckAt < this.indexCheckIntervalMs) {
2854
+ return;
2855
+ }
2856
+ const currentConfigMtimeMs = this.getConfigMtimeMs();
2857
+ if (!this.lastSignature || currentConfigMtimeMs !== this.lastSignature.configMtimeMs) {
2858
+ this.config = loadConfig(this.vaultRoot);
2859
+ }
2860
+ const signature = this.computeSignature();
2861
+ let shouldRebuild = false;
2862
+ if (force) {
2863
+ shouldRebuild = true;
2864
+ } else if (this.config.index.auto_rebuild) {
2865
+ if (!this.lastSignature) {
2866
+ shouldRebuild = signature.noteCount !== this.getIndexedNoteCount() || signature.latestMutationMs > this.getLastRebuildMs();
2867
+ } else {
2868
+ shouldRebuild = signature.noteCount !== this.lastSignature.noteCount || signature.latestMutationMs !== this.lastSignature.latestMutationMs || signature.configMtimeMs !== this.lastSignature.configMtimeMs;
2869
+ }
2870
+ }
2871
+ if (shouldRebuild) {
2872
+ rebuildIndex(this.vaultRoot, this.config, this.db);
2873
+ }
2874
+ this.lastSignature = signature;
2875
+ this.lastIndexCheckAt = now;
2876
+ }
2877
+ readAllNotes() {
2878
+ return listNotes(this.vaultRoot, this.config);
2879
+ }
2880
+ requireNote(slug) {
2881
+ const note = findNoteBySlug(this.vaultRoot, this.config, slug);
2882
+ if (!note) {
2883
+ throw new Error(`Note not found: ${slug}`);
2884
+ }
2885
+ return note;
2886
+ }
2887
+ toNoteSummary(note) {
2888
+ return {
2889
+ slug: note.slug,
2890
+ title: note.frontmatter.title,
2891
+ type: note.frontmatter.type,
2892
+ created: note.frontmatter.created,
2893
+ modified: note.frontmatter.modified,
2894
+ tags: note.frontmatter.tags,
2895
+ aliases: note.frontmatter.aliases,
2896
+ status: note.frontmatter.status,
2897
+ source: note.frontmatter.source,
2898
+ filepath: note.filepath,
2899
+ resource_uri: this.noteResourceUri(note.slug)
2900
+ };
2901
+ }
2902
+ applyMutations(filepath, input) {
2903
+ const raw = fs12.readFileSync(filepath, "utf-8");
2904
+ const { frontmatter, body: existingBody } = parseFrontmatter(raw);
2905
+ let body = existingBody;
2906
+ if (input.title !== void 0) {
2907
+ frontmatter.title = input.title;
2908
+ }
2909
+ if (input.tags && input.tags.length > 0) {
2910
+ frontmatter.tags = mergeUnique(frontmatter.tags, input.tags);
2911
+ }
2912
+ if (input.aliases && input.aliases.length > 0) {
2913
+ frontmatter.aliases = mergeUnique(frontmatter.aliases, input.aliases);
2914
+ }
2915
+ if (input.status !== void 0) {
2916
+ validateStatus(input.status);
2917
+ frontmatter.status = input.status;
2918
+ }
2919
+ if (input.source !== void 0) {
2920
+ validateSource(input.source);
2921
+ frontmatter.source = input.source;
2922
+ }
2923
+ if (input.body !== void 0) {
2924
+ body = ensureTrailingNewline(input.body);
2925
+ }
2926
+ if (input.append !== void 0) {
2927
+ body = `${body.trimEnd()}
2928
+ ${input.append}
2929
+ `;
2930
+ }
2931
+ frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
2932
+ fs12.writeFileSync(filepath, serializeFrontmatter(frontmatter, body), "utf-8");
2933
+ }
2934
+ afterWrite(slug, fullRebuild) {
2935
+ if (fullRebuild) {
2936
+ this.refreshIndex(true);
2937
+ } else {
2938
+ this.refreshIndex();
2939
+ const note2 = this.requireNote(slug);
2940
+ syncNoteInIndex(this.vaultRoot, this.config, this.db, note2);
2941
+ this.captureSignature();
2942
+ }
2943
+ const note = this.requireNote(slug);
2944
+ return {
2945
+ note: this.getNote(note.slug),
2946
+ recommendations: getRecommendations(this.db, note, this.config)
2947
+ };
2948
+ }
2949
+ captureSignature() {
2950
+ this.lastSignature = this.computeSignature();
2951
+ this.lastIndexCheckAt = Date.now();
2952
+ }
2953
+ computeSignature() {
2954
+ let noteCount = 0;
2955
+ let latestNoteMtimeMs = 0;
2956
+ for (const typeConfig of Object.values(this.config.note_types)) {
2957
+ const folder = path8.join(this.vaultRoot, typeConfig.folder);
2958
+ if (!fs12.existsSync(folder)) {
2959
+ continue;
2960
+ }
2961
+ for (const entry of fs12.readdirSync(folder)) {
2962
+ if (!entry.endsWith(".md")) {
2963
+ continue;
2964
+ }
2965
+ noteCount += 1;
2966
+ const stat = fs12.statSync(path8.join(folder, entry));
2967
+ latestNoteMtimeMs = Math.max(latestNoteMtimeMs, stat.mtimeMs);
2968
+ }
2969
+ }
2970
+ const configMtimeMs = this.getConfigMtimeMs();
2971
+ return {
2972
+ noteCount,
2973
+ latestMutationMs: Math.max(latestNoteMtimeMs, configMtimeMs),
2974
+ configMtimeMs
2975
+ };
2976
+ }
2977
+ getConfigMtimeMs() {
2978
+ const configPath = path8.join(this.vaultRoot, CONFIG_FILENAME);
2979
+ return fs12.existsSync(configPath) ? fs12.statSync(configPath).mtimeMs : 0;
2980
+ }
2981
+ getLastRebuildMs() {
2982
+ const value = this.getMetaValue("last_rebuild");
2983
+ return value ? Date.parse(value) || 0 : 0;
2984
+ }
2985
+ getMetaValue(key) {
2986
+ const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
2987
+ return row?.value;
2988
+ }
2989
+ getIndexedNoteCount() {
2990
+ const row = this.db.prepare("SELECT COUNT(*) as count FROM notes").get();
2991
+ return row.count;
2992
+ }
2993
+ };
2994
+ function ensureTrailingNewline(value) {
2995
+ return value.endsWith("\n") ? value : `${value}
2996
+ `;
2997
+ }
2998
+ function clampLimit(value, max) {
2999
+ return Math.max(1, Math.min(value, max));
3000
+ }
3001
+ function mergeUnique(existing, incoming) {
3002
+ const merged = new Set(existing);
3003
+ for (const value of incoming) {
3004
+ const trimmed = value.trim();
3005
+ if (trimmed) {
3006
+ merged.add(trimmed);
3007
+ }
3008
+ }
3009
+ return [...merged];
3010
+ }
3011
+ function hasMetadataMutations(input) {
3012
+ return (input.tags?.length ?? 0) > 0 || (input.aliases?.length ?? 0) > 0 || input.status !== void 0 || input.source !== void 0;
3013
+ }
3014
+
3015
+ // src/commands/mcp.ts
3016
+ async function mcpCommand(options) {
3017
+ const transport = parseTransport(options.transport);
3018
+ const vaultRoot = resolveVaultRoot(options.vault);
3019
+ const runtime = new GraniteMcpRuntime(vaultRoot);
3020
+ const shutdown = () => {
3021
+ runtime.close();
3022
+ process.exit(0);
3023
+ };
3024
+ process.once("SIGINT", shutdown);
3025
+ process.once("SIGTERM", shutdown);
3026
+ if (transport === "http") {
3027
+ const port = parsePort(options.port);
3028
+ startGraniteMcpHttpServer(runtime, {
3029
+ host: options.host ?? "127.0.0.1",
3030
+ port,
3031
+ allowedOrigins: options.allowOrigin ?? [],
3032
+ jsonResponse: options.jsonResponse ?? false
3033
+ });
3034
+ return;
3035
+ }
3036
+ await startGraniteMcpStdioServer(runtime);
3037
+ }
3038
+ function resolveVaultRoot(explicitVault) {
3039
+ const fromEnv = process.env.GRANITE_VAULT;
3040
+ if (explicitVault) {
3041
+ return path9.resolve(explicitVault);
3042
+ }
3043
+ if (fromEnv) {
3044
+ return path9.resolve(fromEnv);
3045
+ }
3046
+ return requireVaultRoot();
3047
+ }
3048
+ function parseTransport(value) {
3049
+ const transport = value ?? "stdio";
3050
+ if (transport !== "stdio" && transport !== "http") {
3051
+ throw new Error(`Invalid MCP transport: ${transport}. Expected "stdio" or "http".`);
3052
+ }
3053
+ return transport;
3054
+ }
3055
+ function parsePort(value) {
3056
+ const raw = (value ?? process.env.MCP_PORT ?? "3321").trim();
3057
+ if (!/^\d+$/.test(raw)) {
3058
+ throw new Error(`Invalid MCP port: ${raw}`);
3059
+ }
3060
+ const parsed = Number.parseInt(raw, 10);
3061
+ if (parsed <= 0) {
3062
+ throw new Error(`Invalid MCP port: ${raw}`);
3063
+ }
3064
+ return parsed;
3065
+ }
3066
+
1962
3067
  // src/index.ts
1963
3068
  var program = new Command();
1964
- program.name("mem").description("Granite \u2014 a local-first markdown memory system").version("0.1.0");
3069
+ program.name("mem").description("Granite \u2014 a local-first markdown memory system").version(GRANITE_VERSION);
1965
3070
  program.command("init").description("Initialize the default vault in ~/.granite").action(() => {
1966
3071
  initVault();
1967
3072
  });
@@ -2004,4 +3109,17 @@ program.command("doctor").description("Validate vault health").action(() => {
2004
3109
  program.command("serve").description("Start the local web UI").option("-p, --port <port>", "Port number", "4321").action((options) => {
2005
3110
  serveCommand(options);
2006
3111
  });
2007
- program.parse();
3112
+ program.command("mcp").description("Start Granite as an MCP server").option("--vault <path>", "Vault root. Defaults to the current Granite vault or $GRANITE_VAULT").option("--transport <transport>", "Transport to use: stdio or http", parseTransportOption, "stdio").option("--host <host>", "Host for HTTP transport", "127.0.0.1").option("--port <port>", "Port for HTTP transport", "3321").option("--allow-origin <origin>", "Allow an HTTP Origin for browser-based HTTP clients", collectValues, []).option("--json-response", "Prefer JSON HTTP responses instead of SSE streams").action(async (options) => {
3113
+ await mcpCommand(options);
3114
+ });
3115
+ await program.parseAsync();
3116
+ function collectValues(value, previous) {
3117
+ return [...previous, value];
3118
+ }
3119
+ function parseTransportOption(value) {
3120
+ try {
3121
+ return parseTransport(value);
3122
+ } catch (error) {
3123
+ throw new InvalidArgumentError(error instanceof Error ? error.message : String(error));
3124
+ }
3125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granite-mem",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A local-first markdown memory system for humans and agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,18 +27,21 @@
27
27
  "build": "tsup && rm -rf dist/public && cp -r src/web/public dist/public",
28
28
  "dev": "tsx src/index.ts",
29
29
  "test": "vitest run",
30
+ "test:coverage": "vitest run --coverage",
30
31
  "test:watch": "vitest",
31
32
  "lint": "tsc --noEmit",
32
33
  "prepare": "husky"
33
34
  },
34
35
  "dependencies": {
36
+ "@hono/node-server": "^1.13.0",
37
+ "@modelcontextprotocol/sdk": "^1.29.0",
35
38
  "better-sqlite3": "^11.0.0",
36
39
  "commander": "^13.0.0",
37
40
  "gray-matter": "^4.0.3",
38
41
  "hono": "^4.0.0",
39
- "@hono/node-server": "^1.13.0",
40
42
  "js-yaml": "^4.1.0",
41
- "uuid": "^11.0.0"
43
+ "uuid": "^11.0.0",
44
+ "zod": "^3.25.76"
42
45
  },
43
46
  "devDependencies": {
44
47
  "@commitlint/cli": "^20.0.0",
@@ -47,6 +50,7 @@
47
50
  "@types/js-yaml": "^4.0.0",
48
51
  "@types/node": "^22.0.0",
49
52
  "@types/uuid": "^10.0.0",
53
+ "@vitest/coverage-v8": "^3.2.4",
50
54
  "commitizen": "^4.3.1",
51
55
  "cz-conventional-changelog": "^3.3.0",
52
56
  "husky": "^9.1.7",