@jcode.labs/ragmir 2.16.2 → 2.17.1

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 (102) hide show
  1. package/README.md +37 -172
  2. package/dist/access-log.d.ts +1 -0
  3. package/dist/access-log.d.ts.map +1 -1
  4. package/dist/access-log.js +57 -24
  5. package/dist/access-log.js.map +1 -1
  6. package/dist/cli.js +15 -1
  7. package/dist/cli.js.map +1 -1
  8. package/dist/client.d.ts +3 -3
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +4 -4
  11. package/dist/client.js.map +1 -1
  12. package/dist/config.js +6 -14
  13. package/dist/config.js.map +1 -1
  14. package/dist/context-resources.d.ts +3 -3
  15. package/dist/context-resources.d.ts.map +1 -1
  16. package/dist/context-resources.js +13 -4
  17. package/dist/context-resources.js.map +1 -1
  18. package/dist/destroy.d.ts.map +1 -1
  19. package/dist/destroy.js +10 -6
  20. package/dist/destroy.js.map +1 -1
  21. package/dist/doctor.d.ts +2 -2
  22. package/dist/doctor.d.ts.map +1 -1
  23. package/dist/doctor.js +11 -3
  24. package/dist/doctor.js.map +1 -1
  25. package/dist/embeddings.d.ts +4 -0
  26. package/dist/embeddings.d.ts.map +1 -1
  27. package/dist/embeddings.js +68 -9
  28. package/dist/embeddings.js.map +1 -1
  29. package/dist/evaluate.d.ts +5 -0
  30. package/dist/evaluate.d.ts.map +1 -1
  31. package/dist/evaluate.js +50 -9
  32. package/dist/evaluate.js.map +1 -1
  33. package/dist/files.d.ts +3 -10
  34. package/dist/files.d.ts.map +1 -1
  35. package/dist/files.js +49 -15
  36. package/dist/files.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/ingest.d.ts +2 -2
  42. package/dist/ingest.d.ts.map +1 -1
  43. package/dist/ingest.js +356 -163
  44. package/dist/ingest.js.map +1 -1
  45. package/dist/ingestion-state.d.ts +53 -0
  46. package/dist/ingestion-state.d.ts.map +1 -0
  47. package/dist/ingestion-state.js +257 -0
  48. package/dist/ingestion-state.js.map +1 -0
  49. package/dist/mcp-output.d.ts +14 -0
  50. package/dist/mcp-output.d.ts.map +1 -1
  51. package/dist/mcp-output.js +121 -0
  52. package/dist/mcp-output.js.map +1 -1
  53. package/dist/mcp.d.ts +8 -0
  54. package/dist/mcp.d.ts.map +1 -1
  55. package/dist/mcp.js +362 -116
  56. package/dist/mcp.js.map +1 -1
  57. package/dist/ocr.d.ts.map +1 -1
  58. package/dist/ocr.js +6 -12
  59. package/dist/ocr.js.map +1 -1
  60. package/dist/operation.d.ts.map +1 -1
  61. package/dist/operation.js +3 -2
  62. package/dist/operation.js.map +1 -1
  63. package/dist/parsing.d.ts +1 -0
  64. package/dist/parsing.d.ts.map +1 -1
  65. package/dist/parsing.js +86 -27
  66. package/dist/parsing.js.map +1 -1
  67. package/dist/project-config-file.d.ts +11 -0
  68. package/dist/project-config-file.d.ts.map +1 -0
  69. package/dist/project-config-file.js +55 -0
  70. package/dist/project-config-file.js.map +1 -0
  71. package/dist/query.d.ts.map +1 -1
  72. package/dist/query.js +12 -4
  73. package/dist/query.js.map +1 -1
  74. package/dist/research.d.ts.map +1 -1
  75. package/dist/research.js +3 -2
  76. package/dist/research.js.map +1 -1
  77. package/dist/security.d.ts +2 -2
  78. package/dist/security.d.ts.map +1 -1
  79. package/dist/security.js +57 -21
  80. package/dist/security.js.map +1 -1
  81. package/dist/semantic-config.d.ts.map +1 -1
  82. package/dist/semantic-config.js +16 -20
  83. package/dist/semantic-config.js.map +1 -1
  84. package/dist/skill.d.ts +2 -13
  85. package/dist/skill.d.ts.map +1 -1
  86. package/dist/skill.js.map +1 -1
  87. package/dist/sources.d.ts.map +1 -1
  88. package/dist/sources.js +24 -40
  89. package/dist/sources.js.map +1 -1
  90. package/dist/store.d.ts +5 -0
  91. package/dist/store.d.ts.map +1 -1
  92. package/dist/store.js +45 -13
  93. package/dist/store.js.map +1 -1
  94. package/dist/types.d.ts +39 -3
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/version.d.ts +1 -1
  97. package/dist/version.js +1 -1
  98. package/package.json +7 -6
  99. package/skills/ragmir/SKILL.md +5 -3
  100. package/skills/ragmir-audio-summary/listening-style.md +11 -11
  101. package/skills/ragmir-audio-summary/narration-templates.md +22 -22
  102. package/skills/ragmir-audio-summary/split-lines.py +2 -1
package/dist/mcp.js CHANGED
@@ -1,106 +1,253 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
- import { accessLogUsageReport, recordMcpOutput } from "./access-log.js";
6
+ import { accessLogUsageReport, MAX_USAGE_REPORT_DAYS, recordMcpOutput } from "./access-log.js";
7
+ import { createRagmirClient } from "./client.js";
7
8
  import { findProjectConfig, loadConfig } from "./config.js";
8
- import { getKnowledgeBaseContext, getKnowledgeBaseSourceCatalog } from "./context-resources.js";
9
9
  import { RAGMIR_PROJECT_ROOT_ENV } from "./defaults.js";
10
10
  import { evaluateGoldenQueries } from "./evaluate.js";
11
11
  import { audit } from "./ingest.js";
12
12
  import { knowledgeBaseIdentity } from "./knowledge-bases.js";
13
13
  import { ingestionLimits } from "./limits.js";
14
- import { budgetMcpJson, fitAskPayload, fitExpandedCitation, fitResearchPayload, fitSearchPayload, MIN_MCP_OUTPUT_BYTES, resolveMcpOutputBudget, } from "./mcp-output.js";
14
+ import { budgetMcpJson, fitAskPayload, fitExpandedCitation, fitMcpJsonOutput, fitResearchPayload, fitSearchPayload, MIN_MCP_OUTPUT_BYTES, resolveMcpOutputBudget, } from "./mcp-output.js";
15
15
  import { routePrompt } from "./prompt-routing.js";
16
- import { ask, expandCitation, search } from "./query.js";
17
- import { compactResearchReport, compactSearchResults, research } from "./research.js";
16
+ import { compactResearchReport, compactSearchResults } from "./research.js";
18
17
  import { securityAudit } from "./security.js";
19
- import { countRows } from "./store.js";
20
18
  import { VERSION } from "./version.js";
21
- const queryToolInputSchema = z.object({
22
- query: z.string().min(1),
19
+ const MAX_MCP_INPUT_CHARACTERS = 20_000;
20
+ const MAX_MCP_PATH_CHARACTERS = 500;
21
+ const MAX_MCP_OUTPUT_BYTES = 1_048_576;
22
+ const STRICT_MCP_FRESHNESS_WARNING = "Index freshness requires attention. Run `rgr doctor` locally for detailed diagnostics.";
23
+ const STRICT_MCP_NEXT_STEP = "Run `rgr doctor` locally for detailed next steps.";
24
+ const LOCAL_NON_DESTRUCTIVE_TOOL_ANNOTATIONS = {
25
+ readOnlyHint: false,
26
+ destructiveHint: false,
27
+ idempotentHint: false,
28
+ openWorldHint: false,
29
+ };
30
+ const PURE_LOCAL_TOOL_ANNOTATIONS = {
31
+ ...LOCAL_NON_DESTRUCTIVE_TOOL_ANNOTATIONS,
32
+ readOnlyHint: true,
33
+ idempotentHint: true,
34
+ };
35
+ const POTENTIALLY_NETWORKED_TOOL_ANNOTATIONS = {
36
+ ...LOCAL_NON_DESTRUCTIVE_TOOL_ANNOTATIONS,
37
+ openWorldHint: true,
38
+ };
39
+ const queryToolInputSchema = z
40
+ .object({
41
+ query: z.string().trim().min(1).max(MAX_MCP_INPUT_CHARACTERS),
23
42
  topK: z.number().int().positive().optional(),
24
43
  contextRadius: z.number().int().min(0).optional(),
25
- maxBytes: z.number().int().min(MIN_MCP_OUTPUT_BYTES).optional(),
26
- includePaths: z.array(z.string().min(1).max(500)).max(20).optional(),
27
- excludePaths: z.array(z.string().min(1).max(500)).max(20).optional(),
28
- contextPaths: z.array(z.string().min(1).max(500)).max(20).optional(),
44
+ maxBytes: z.number().int().min(MIN_MCP_OUTPUT_BYTES).max(MAX_MCP_OUTPUT_BYTES).optional(),
45
+ includePaths: z.array(z.string().min(1).max(MAX_MCP_PATH_CHARACTERS)).max(20).optional(),
46
+ excludePaths: z.array(z.string().min(1).max(MAX_MCP_PATH_CHARACTERS)).max(20).optional(),
47
+ contextPaths: z.array(z.string().min(1).max(MAX_MCP_PATH_CHARACTERS)).max(20).optional(),
29
48
  explain: z.boolean().optional(),
30
- });
49
+ })
50
+ .strict();
31
51
  const askToolInputSchema = queryToolInputSchema.extend({
32
52
  compact: z.boolean().optional(),
33
53
  });
34
- const researchToolInputSchema = z.object({
35
- query: z.string().min(1),
54
+ const researchToolInputSchema = z
55
+ .object({
56
+ query: z.string().trim().min(1).max(MAX_MCP_INPUT_CHARACTERS),
36
57
  topK: z.number().int().positive().optional(),
37
58
  includeCode: z.boolean().optional(),
38
59
  compact: z.boolean().optional(),
39
- maxBytes: z.number().int().min(MIN_MCP_OUTPUT_BYTES).optional(),
40
- includePaths: z.array(z.string().min(1).max(500)).max(20).optional(),
41
- excludePaths: z.array(z.string().min(1).max(500)).max(20).optional(),
42
- contextPaths: z.array(z.string().min(1).max(500)).max(20).optional(),
43
- });
60
+ maxBytes: z.number().int().min(MIN_MCP_OUTPUT_BYTES).max(MAX_MCP_OUTPUT_BYTES).optional(),
61
+ includePaths: z.array(z.string().min(1).max(MAX_MCP_PATH_CHARACTERS)).max(20).optional(),
62
+ excludePaths: z.array(z.string().min(1).max(MAX_MCP_PATH_CHARACTERS)).max(20).optional(),
63
+ contextPaths: z.array(z.string().min(1).max(MAX_MCP_PATH_CHARACTERS)).max(20).optional(),
64
+ })
65
+ .strict();
44
66
  const searchToolInputSchema = queryToolInputSchema.extend({
45
67
  compact: z.boolean().optional(),
46
68
  });
47
- const evaluateToolInputSchema = z.object({
48
- goldenPath: z.string().min(1),
69
+ const evaluateToolInputSchema = z
70
+ .object({
71
+ goldenPath: z.string().min(1).max(MAX_MCP_PATH_CHARACTERS),
49
72
  topK: z.number().int().positive().optional(),
50
73
  failUnder: z.number().min(0).max(1).optional(),
51
- });
52
- const usageReportInputSchema = z.object({
53
- days: z.number().int().positive().optional(),
54
- });
55
- const promptRouteInputSchema = z.object({
56
- prompt: z.string().min(1),
57
- });
58
- const expandToolInputSchema = z.object({
74
+ maxBytes: z.number().int().min(MIN_MCP_OUTPUT_BYTES).max(MAX_MCP_OUTPUT_BYTES).optional(),
75
+ })
76
+ .strict();
77
+ const auditToolInputSchema = z
78
+ .object({
79
+ maxBytes: z.number().int().min(MIN_MCP_OUTPUT_BYTES).max(MAX_MCP_OUTPUT_BYTES).optional(),
80
+ })
81
+ .strict();
82
+ const usageReportInputSchema = z
83
+ .object({
84
+ days: z.number().int().positive().max(MAX_USAGE_REPORT_DAYS).optional(),
85
+ })
86
+ .strict();
87
+ const promptRouteInputSchema = z
88
+ .object({
89
+ prompt: z.string().trim().min(1).max(MAX_MCP_INPUT_CHARACTERS),
90
+ })
91
+ .strict();
92
+ const expandToolInputSchema = z
93
+ .object({
59
94
  citation: z.string().min(1).max(2_000),
60
95
  contextRadius: z.number().int().min(0).max(3).optional(),
61
- maxBytes: z.number().int().min(MIN_MCP_OUTPUT_BYTES).optional(),
62
- });
96
+ maxBytes: z.number().int().min(MIN_MCP_OUTPUT_BYTES).max(MAX_MCP_OUTPUT_BYTES).optional(),
97
+ })
98
+ .strict();
99
+ class LifecycleMcpServer extends McpServer {
100
+ closeClient;
101
+ constructor(closeClient) {
102
+ super({
103
+ name: "ragmir",
104
+ version: VERSION,
105
+ });
106
+ this.closeClient = closeClient;
107
+ }
108
+ async close() {
109
+ try {
110
+ await super.close();
111
+ }
112
+ finally {
113
+ await this.closeClient();
114
+ }
115
+ }
116
+ }
117
+ export function createMcpClientLifecycle(cwd) {
118
+ let clientPromise;
119
+ let clientConfigSignature;
120
+ let closePromise;
121
+ let closing = false;
122
+ return {
123
+ getClient(config) {
124
+ return (async () => {
125
+ const effectiveConfig = config ?? (await loadConfig(cwd));
126
+ if (closing) {
127
+ throw new Error("The MCP server is closed.");
128
+ }
129
+ const signature = JSON.stringify(effectiveConfig);
130
+ if (clientPromise && clientConfigSignature === signature) {
131
+ return clientPromise;
132
+ }
133
+ const previous = clientPromise;
134
+ const pending = (async () => {
135
+ if (previous) {
136
+ let previousClient;
137
+ try {
138
+ previousClient = await previous;
139
+ }
140
+ catch {
141
+ // A failed lazy initialization has no open client to close.
142
+ }
143
+ await previousClient?.close();
144
+ }
145
+ if (closing) {
146
+ throw new Error("The MCP server is closed.");
147
+ }
148
+ return createRagmirClient({ cwd });
149
+ })();
150
+ clientPromise = pending;
151
+ clientConfigSignature = signature;
152
+ void pending.catch(() => {
153
+ if (clientPromise === pending) {
154
+ clientPromise = undefined;
155
+ clientConfigSignature = undefined;
156
+ }
157
+ });
158
+ return pending;
159
+ })();
160
+ },
161
+ close() {
162
+ closing = true;
163
+ if (!closePromise) {
164
+ closePromise = (async () => {
165
+ const pending = clientPromise;
166
+ if (!pending) {
167
+ return;
168
+ }
169
+ let client;
170
+ try {
171
+ client = await pending;
172
+ }
173
+ catch {
174
+ // A failed lazy initialization has no open client to close.
175
+ return;
176
+ }
177
+ await client.close();
178
+ })();
179
+ }
180
+ return closePromise;
181
+ },
182
+ };
183
+ }
63
184
  export function createMcpServer(cwd = resolveMcpProjectRoot()) {
64
- const server = new McpServer({
65
- name: "ragmir",
66
- version: VERSION,
67
- });
185
+ const clientLifecycle = createMcpClientLifecycle(cwd);
186
+ const server = new LifecycleMcpServer(() => clientLifecycle.close());
187
+ server.server.onclose = () => {
188
+ void clientLifecycle.close().catch(() => undefined);
189
+ };
68
190
  server.registerResource("ragmir-context", "ragmir://context", {
69
191
  title: "Ragmir Knowledge Base Context",
70
192
  description: "Active base identity, readiness, freshness, coverage, and available operations.",
71
193
  mimeType: "application/json",
72
- }, async (uri) => jsonResource(uri, await getKnowledgeBaseContext(cwd)));
194
+ }, async (uri, { signal }) => {
195
+ throwIfMcpAborted(signal);
196
+ const config = await loadConfig(cwd);
197
+ const client = await clientLifecycle.getClient(config);
198
+ const context = await abortableMcpOperation(client.status({ signal }), signal);
199
+ const output = config.privacyProfile !== "strict"
200
+ ? context
201
+ : {
202
+ ...context,
203
+ indexFreshness: {
204
+ ...context.indexFreshness,
205
+ warning: context.indexFreshness.warning === null ? null : STRICT_MCP_FRESHNESS_WARNING,
206
+ },
207
+ nextSteps: context.nextSteps.length === 0 ? [] : [STRICT_MCP_NEXT_STEP],
208
+ };
209
+ return jsonResource(uri, output, mcpOutputBudget(config.mcpMaxOutputBytes), "ragmir://context");
210
+ });
73
211
  server.registerResource("ragmir-sources", "ragmir://sources", {
74
212
  title: "Ragmir Source Catalog",
75
213
  description: "Bounded source coverage, skipped-file counts, and index drift for the active base.",
76
214
  mimeType: "application/json",
77
- }, async (uri) => jsonResource(uri, await getKnowledgeBaseSourceCatalog(cwd)));
215
+ }, async (uri, { signal }) => {
216
+ throwIfMcpAborted(signal);
217
+ const config = await loadConfig(cwd);
218
+ const client = await clientLifecycle.getClient(config);
219
+ const sources = await abortableMcpOperation(client.sources({ signal }), signal);
220
+ return jsonResource(uri, sources, mcpOutputBudget(config.mcpMaxOutputBytes), "ragmir://sources");
221
+ });
78
222
  server.registerTool("ragmir_status", {
79
223
  title: "Ragmir Status",
80
224
  description: "Show active Ragmir configuration and indexed chunk count.",
81
- inputSchema: z.object({}),
82
- }, async () => {
225
+ inputSchema: z.object({}).strict(),
226
+ annotations: LOCAL_NON_DESTRUCTIVE_TOOL_ANNOTATIONS,
227
+ }, async (_input, { signal }) => {
228
+ throwIfMcpAborted(signal);
83
229
  const config = await loadConfig(cwd);
230
+ const client = await clientLifecycle.getClient(config);
231
+ const context = await abortableMcpOperation(client.status({ signal }), signal);
84
232
  const identity = knowledgeBaseIdentity(config.projectRoot);
85
- const chunksIndexed = await countRows(config);
86
233
  const strict = config.privacyProfile === "strict";
87
234
  const output = {
88
235
  knowledgeBaseId: identity?.id ?? null,
89
236
  projectRoot: strict ? "." : config.projectRoot,
90
- rawDir: strict ? path.relative(config.projectRoot, config.rawDir) : config.rawDir,
237
+ rawDir: strict ? privateProjectPath(config.projectRoot, config.rawDir) : config.rawDir,
91
238
  storageDir: strict
92
- ? path.relative(config.projectRoot, config.storageDir)
239
+ ? privateProjectPath(config.projectRoot, config.storageDir)
93
240
  : config.storageDir,
94
241
  sourcesFile: strict
95
- ? path.relative(config.projectRoot, config.sourcesFile)
242
+ ? privateProjectPath(config.projectRoot, config.sourcesFile)
96
243
  : config.sourcesFile,
97
244
  privacyProfile: config.privacyProfile,
98
245
  retrievalProfile: config.retrievalProfile,
99
246
  embeddingProvider: config.embeddingProvider,
100
- embeddingModel: config.embeddingModel,
247
+ embeddingModel: strict ? privateMcpPath(config.embeddingModel) : config.embeddingModel,
101
248
  embeddingModelRevision: config.embeddingModelRevision,
102
249
  embeddingModelPath: strict
103
- ? path.relative(config.projectRoot, config.embeddingModelPath)
250
+ ? privateProjectPath(config.projectRoot, config.embeddingModelPath)
104
251
  : config.embeddingModelPath,
105
252
  transformersAllowRemoteModels: config.transformersAllowRemoteModels,
106
253
  llmGeneration: false,
@@ -118,29 +265,37 @@ export function createMcpServer(cwd = resolveMcpProjectRoot()) {
118
265
  legacyWordCommand: config.legacyWordCommand,
119
266
  legacyWordTimeoutMs: config.legacyWordTimeoutMs,
120
267
  ingestionLimits: ingestionLimits(config),
121
- chunksIndexed,
268
+ chunksIndexed: context.coverage.chunksIndexed,
122
269
  };
123
- return textResult(output);
270
+ return boundedJsonResult(output, mcpOutputBudget(config.mcpMaxOutputBytes), "ragmir_status");
124
271
  });
125
272
  server.registerTool("ragmir_route_prompt", {
126
273
  title: "Ragmir Prompt Router",
127
274
  description: "Classify a prompt and suggest whether an agent should use Ragmir local context.",
128
275
  inputSchema: promptRouteInputSchema,
129
- }, async ({ prompt }) => textResult(routePrompt(prompt)));
276
+ annotations: PURE_LOCAL_TOOL_ANNOTATIONS,
277
+ }, async ({ prompt }, { signal }) => {
278
+ throwIfMcpAborted(signal);
279
+ const config = await loadConfig(cwd);
280
+ return boundedJsonResult(routePrompt(prompt), mcpOutputBudget(config.mcpMaxOutputBytes), "ragmir_route_prompt");
281
+ });
130
282
  server.registerTool("ragmir_search", {
131
283
  title: "Ragmir Search",
132
284
  description: "Retrieve relevant passages from the local Ragmir knowledge base.",
133
285
  inputSchema: searchToolInputSchema,
286
+ annotations: POTENTIALLY_NETWORKED_TOOL_ANNOTATIONS,
134
287
  }, async ({ query, topK, contextRadius, compact, maxBytes, includePaths, excludePaths, contextPaths, explain, }, { signal }) => {
288
+ throwIfMcpAborted(signal);
135
289
  const config = await loadConfig(cwd);
136
290
  const options = await searchOptions(cwd, topK, contextRadius, includePaths, excludePaths, contextPaths, explain);
137
- const results = await search(query, { ...options, signal });
291
+ const client = await clientLifecycle.getClient(config);
292
+ const results = await client.search(query, { ...options, signal });
138
293
  const compactResults = compactSearchResults(results);
139
294
  const compactOutput = config.privacyProfile === "strict" || compact === true;
140
295
  const preferred = compactOutput ? compactResults : results;
141
296
  const bounded = budgetMcpJson({
142
297
  tool: "ragmir_search",
143
- maxBytes: resolveMcpOutputBudget(config.mcpMaxOutputBytes, maxBytes),
298
+ maxBytes: mcpOutputBudget(config.mcpMaxOutputBytes, maxBytes),
144
299
  fullValue: results,
145
300
  preferredValue: preferred,
146
301
  compactValue: compactResults,
@@ -154,13 +309,16 @@ export function createMcpServer(cwd = resolveMcpProjectRoot()) {
154
309
  title: "Ragmir Ask",
155
310
  description: "Return cited retrieval context for a question without calling an LLM.",
156
311
  inputSchema: askToolInputSchema,
312
+ annotations: POTENTIALLY_NETWORKED_TOOL_ANNOTATIONS,
157
313
  }, async ({ query, topK, contextRadius, compact, maxBytes, includePaths, excludePaths, contextPaths, explain, }, { signal }) => {
314
+ throwIfMcpAborted(signal);
158
315
  const config = await loadConfig(cwd);
159
316
  const options = await searchOptions(cwd, topK, contextRadius, includePaths, excludePaths, contextPaths, explain);
160
317
  const cancellableOptions = { ...options, signal };
318
+ const client = await clientLifecycle.getClient(config);
161
319
  let fullPayload;
162
320
  if (config.privacyProfile === "strict") {
163
- const results = await search(query, cancellableOptions);
321
+ const results = await client.search(query, cancellableOptions);
164
322
  fullPayload = {
165
323
  answer: "Strict privacy profile returns compact cited retrieval only.",
166
324
  sources: results,
@@ -168,7 +326,7 @@ export function createMcpServer(cwd = resolveMcpProjectRoot()) {
168
326
  };
169
327
  }
170
328
  else {
171
- fullPayload = await ask(query, cancellableOptions);
329
+ fullPayload = await client.ask(query, cancellableOptions);
172
330
  }
173
331
  const compactPayload = {
174
332
  answer: "Ragmir returns compact cited retrieval only. Expand a citation when needed.",
@@ -178,7 +336,7 @@ export function createMcpServer(cwd = resolveMcpProjectRoot()) {
178
336
  const compactOutput = config.privacyProfile === "strict" || compact === true;
179
337
  const bounded = budgetMcpJson({
180
338
  tool: "ragmir_ask",
181
- maxBytes: resolveMcpOutputBudget(config.mcpMaxOutputBytes, maxBytes),
339
+ maxBytes: mcpOutputBudget(config.mcpMaxOutputBytes, maxBytes),
182
340
  fullValue: fullPayload,
183
341
  preferredValue: compactOutput ? compactPayload : fullPayload,
184
342
  compactValue: compactPayload,
@@ -192,22 +350,25 @@ export function createMcpServer(cwd = resolveMcpProjectRoot()) {
192
350
  title: "Ragmir Research",
193
351
  description: "Run an audit-backed multi-query research pass with cited evidence and optional code matches.",
194
352
  inputSchema: researchToolInputSchema,
353
+ annotations: POTENTIALLY_NETWORKED_TOOL_ANNOTATIONS,
195
354
  }, async ({ query, topK, includeCode, compact, maxBytes, includePaths, excludePaths, contextPaths }, { signal }) => {
355
+ throwIfMcpAborted(signal);
196
356
  const config = await loadConfig(cwd);
197
357
  const options = await searchOptions(cwd, topK, undefined, includePaths, excludePaths, contextPaths);
198
- const researchOptions = { cwd, signal };
358
+ const researchOptions = { signal };
199
359
  addOption(researchOptions, "topK", options.topK);
200
360
  addOption(researchOptions, "includeCode", includeCode);
201
361
  addOption(researchOptions, "includePaths", options.includePaths);
202
362
  addOption(researchOptions, "excludePaths", options.excludePaths);
203
363
  addOption(researchOptions, "contextPaths", options.contextPaths);
204
- const result = await research(query, researchOptions);
364
+ const client = await clientLifecycle.getClient(config);
365
+ const result = await client.research(query, researchOptions);
205
366
  const compactResult = compactResearchReport(result);
206
367
  const compactOutput = config.privacyProfile === "strict" || compact === true;
207
368
  const preferred = compactOutput ? compactResult : result;
208
369
  const bounded = budgetMcpJson({
209
370
  tool: "ragmir_research",
210
- maxBytes: resolveMcpOutputBudget(config.mcpMaxOutputBytes, maxBytes),
371
+ maxBytes: mcpOutputBudget(config.mcpMaxOutputBytes, maxBytes),
211
372
  fullValue: result,
212
373
  preferredValue: preferred,
213
374
  compactValue: compactResult,
@@ -221,16 +382,18 @@ export function createMcpServer(cwd = resolveMcpProjectRoot()) {
221
382
  title: "Ragmir Expand",
222
383
  description: "Expand one Ragmir citation into a bounded exact passage window.",
223
384
  inputSchema: expandToolInputSchema,
385
+ annotations: LOCAL_NON_DESTRUCTIVE_TOOL_ANNOTATIONS,
224
386
  }, async ({ citation, contextRadius, maxBytes }, { signal }) => {
387
+ throwIfMcpAborted(signal);
225
388
  const config = await loadConfig(cwd);
226
- const expanded = await expandCitation(citation, {
227
- cwd,
389
+ const client = await clientLifecycle.getClient(config);
390
+ const expanded = await client.expandCitation(citation, {
228
391
  signal,
229
392
  ...(contextRadius === undefined ? {} : { contextRadius }),
230
393
  });
231
394
  const bounded = budgetMcpJson({
232
395
  tool: "ragmir_expand",
233
- maxBytes: resolveMcpOutputBudget(config.mcpMaxOutputBytes, maxBytes),
396
+ maxBytes: mcpOutputBudget(config.mcpMaxOutputBytes, maxBytes),
234
397
  fullValue: expanded,
235
398
  preferredValue: expanded,
236
399
  compacted: false,
@@ -242,61 +405,87 @@ export function createMcpServer(cwd = resolveMcpProjectRoot()) {
242
405
  server.registerTool("ragmir_audit", {
243
406
  title: "Ragmir Audit",
244
407
  description: "Compare supported source files on disk with the current vector index.",
245
- inputSchema: z.object({}),
246
- }, async () => textResult(await audit(cwd)));
408
+ inputSchema: auditToolInputSchema,
409
+ annotations: LOCAL_NON_DESTRUCTIVE_TOOL_ANNOTATIONS,
410
+ }, async ({ maxBytes }, { signal }) => {
411
+ throwIfMcpAborted(signal);
412
+ const config = await loadConfig(cwd);
413
+ const report = await abortableMcpOperation(audit(cwd, { signal }), signal);
414
+ return boundedJsonResult(report, mcpOutputBudget(config.mcpMaxOutputBytes, maxBytes), "ragmir_audit");
415
+ });
247
416
  server.registerTool("ragmir_evaluate", {
248
417
  title: "Ragmir Evaluate",
249
418
  description: "Measure retrieval recall against a local golden query file.",
250
419
  inputSchema: evaluateToolInputSchema,
251
- }, async ({ goldenPath, topK, failUnder }) => {
252
- const result = await evaluateGoldenQueries(await evaluationOptions(cwd, goldenPath, topK));
253
- if (failUnder === undefined) {
254
- return textResult(result);
420
+ annotations: POTENTIALLY_NETWORKED_TOOL_ANNOTATIONS,
421
+ }, async ({ goldenPath, topK, failUnder, maxBytes }, { signal }) => {
422
+ throwIfMcpAborted(signal);
423
+ const config = await loadConfig(cwd);
424
+ try {
425
+ const options = evaluationOptions(cwd, goldenPath, topK, config.mcpMaxTopK);
426
+ const result = await abortableMcpOperation(evaluateGoldenQueries({ ...options, signal }), signal);
427
+ const safeResult = { ...result, goldenPath: options.goldenPath };
428
+ const output = failUnder === undefined
429
+ ? safeResult
430
+ : {
431
+ ...safeResult,
432
+ minimumRecall: failUnder,
433
+ passed: result.recall >= failUnder,
434
+ };
435
+ return boundedJsonResult(output, mcpOutputBudget(config.mcpMaxOutputBytes, maxBytes), "ragmir_evaluate");
436
+ }
437
+ catch (error) {
438
+ if (signal.aborted || config.privacyProfile !== "strict") {
439
+ throw error;
440
+ }
441
+ throw new Error("ragmir_evaluate could not read or evaluate the project-relative golden file.");
255
442
  }
256
- const minimumRecall = failUnder;
257
- return textResult({
258
- ...result,
259
- minimumRecall,
260
- passed: result.recall >= minimumRecall,
261
- });
262
443
  });
263
444
  server.registerTool("ragmir_security_audit", {
264
445
  title: "Ragmir Security Audit",
265
446
  description: "Show local privacy, provider, redaction, MCP, and gitignore posture.",
266
- inputSchema: z.object({}),
267
- }, async () => {
447
+ inputSchema: z.object({}).strict(),
448
+ annotations: PURE_LOCAL_TOOL_ANNOTATIONS,
449
+ }, async (_input, { signal }) => {
450
+ throwIfMcpAborted(signal);
268
451
  const config = await loadConfig(cwd);
269
- const report = await securityAudit(cwd);
270
- if (config.privacyProfile !== "strict") {
271
- return textResult(report);
272
- }
273
- return textResult({
274
- ...report,
275
- projectRoot: ".",
276
- providers: {
277
- ...report.providers,
278
- embeddingModelPath: path.relative(config.projectRoot, report.providers.embeddingModelPath),
279
- },
280
- accessLog: {
281
- ...report.accessLog,
282
- path: path.relative(config.projectRoot, report.accessLog.path),
283
- },
284
- storage: {
285
- ...report.storage,
286
- path: path.relative(config.projectRoot, report.storage.path),
287
- },
288
- });
452
+ const report = await abortableMcpOperation(securityAudit(cwd, { signal }), signal);
453
+ const output = config.privacyProfile !== "strict"
454
+ ? report
455
+ : {
456
+ ...report,
457
+ projectRoot: ".",
458
+ providers: {
459
+ ...report.providers,
460
+ embeddingModel: privateMcpPath(report.providers.embeddingModel),
461
+ embeddingModelPath: privateProjectPath(config.projectRoot, report.providers.embeddingModelPath),
462
+ },
463
+ accessLog: {
464
+ ...report.accessLog,
465
+ path: privateProjectPath(config.projectRoot, report.accessLog.path),
466
+ },
467
+ storage: {
468
+ ...report.storage,
469
+ path: privateProjectPath(config.projectRoot, report.storage.path),
470
+ },
471
+ };
472
+ return boundedJsonResult(output, mcpOutputBudget(config.mcpMaxOutputBytes), "ragmir_security_audit");
289
473
  });
290
474
  server.registerTool("ragmir_usage_report", {
291
475
  title: "Ragmir Usage Report",
292
476
  description: "Summarize the metadata-only local access log.",
293
477
  inputSchema: usageReportInputSchema,
294
- }, async ({ days }) => {
478
+ annotations: PURE_LOCAL_TOOL_ANNOTATIONS,
479
+ }, async ({ days }, { signal }) => {
480
+ throwIfMcpAborted(signal);
295
481
  const options = { cwd };
482
+ options.signal = signal;
296
483
  if (days !== undefined) {
297
484
  options.days = days;
298
485
  }
299
- return textResult(await accessLogUsageReport(options));
486
+ const config = await loadConfig(cwd);
487
+ const report = await abortableMcpOperation(accessLogUsageReport(options), signal);
488
+ return boundedJsonResult(report, mcpOutputBudget(config.mcpMaxOutputBytes), "ragmir_usage_report");
300
489
  });
301
490
  return server;
302
491
  }
@@ -319,27 +508,61 @@ export function resolveMcpProjectRoot(env = process.env, fallback = process.cwd(
319
508
  }
320
509
  return env.CLAUDE_PROJECT_DIR ?? fallback;
321
510
  }
322
- function textResult(value) {
323
- return {
324
- content: [
325
- {
326
- type: "text",
327
- text: JSON.stringify(value, null, 2),
328
- },
329
- ],
330
- };
331
- }
332
- function jsonResource(uri, value) {
511
+ function jsonResource(uri, value, maxBytes, source) {
512
+ const bounded = fitMcpJsonOutput(value, maxBytes, source);
333
513
  return {
334
514
  contents: [
335
515
  {
336
516
  uri: uri.href,
337
517
  mimeType: "application/json",
338
- text: JSON.stringify(value, null, 2),
518
+ text: bounded.text,
339
519
  },
340
520
  ],
521
+ _meta: { "ragmir/output": bounded.metadata },
522
+ };
523
+ }
524
+ function boundedJsonResult(value, maxBytes, source) {
525
+ const bounded = fitMcpJsonOutput(value, maxBytes, source);
526
+ return {
527
+ content: [{ type: "text", text: bounded.text }],
528
+ _meta: { "ragmir/output": bounded.metadata },
341
529
  };
342
530
  }
531
+ function mcpOutputBudget(configured, requested) {
532
+ return resolveMcpOutputBudget(Math.min(configured, MAX_MCP_OUTPUT_BYTES), requested);
533
+ }
534
+ function throwIfMcpAborted(signal) {
535
+ if (!signal.aborted) {
536
+ return;
537
+ }
538
+ if (signal.reason instanceof Error) {
539
+ throw signal.reason;
540
+ }
541
+ throw new Error("The MCP request was cancelled.");
542
+ }
543
+ function abortableMcpOperation(operation, signal) {
544
+ throwIfMcpAborted(signal);
545
+ return new Promise((resolve, reject) => {
546
+ const onAbort = () => {
547
+ cleanup();
548
+ try {
549
+ throwIfMcpAborted(signal);
550
+ }
551
+ catch (error) {
552
+ reject(error);
553
+ }
554
+ };
555
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
556
+ signal.addEventListener("abort", onAbort, { once: true });
557
+ void operation.then((value) => {
558
+ cleanup();
559
+ resolve(value);
560
+ }, (error) => {
561
+ cleanup();
562
+ reject(error);
563
+ });
564
+ });
565
+ }
343
566
  async function recordBudgetedOutput(config, bounded) {
344
567
  await recordMcpOutput(config, {
345
568
  tool: bounded.metadata.tool,
@@ -364,27 +587,50 @@ export async function searchOptions(cwd, topK, contextRadius, includePaths, excl
364
587
  addOption(result, "explain", explain);
365
588
  return result;
366
589
  }
367
- async function evaluationOptions(cwd, goldenPath, topK) {
368
- const config = await loadConfig(cwd);
590
+ function evaluationOptions(cwd, goldenPath, topK, maxTopK) {
369
591
  const result = {
370
592
  cwd,
371
593
  goldenPath: projectRelativeGoldenPath(cwd, goldenPath),
372
- maxTopK: config.mcpMaxTopK,
594
+ maxTopK,
373
595
  };
374
596
  if (topK === undefined) {
375
597
  return result;
376
598
  }
377
- return { ...result, topK: Math.min(topK, config.mcpMaxTopK) };
599
+ return { ...result, topK: Math.min(topK, maxTopK) };
378
600
  }
379
601
  export function projectRelativeGoldenPath(cwd, goldenPath) {
380
- const root = path.resolve(cwd);
602
+ if (path.isAbsolute(goldenPath)) {
603
+ throw new Error("ragmir_evaluate goldenPath must stay inside the MCP project root.");
604
+ }
605
+ const root = realpathSync.native(path.resolve(cwd));
381
606
  const absolutePath = path.resolve(root, goldenPath);
382
- const relativePath = path.relative(root, absolutePath);
383
- if (relativePath.length === 0 || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
607
+ const lexicalRelativePath = path.relative(root, absolutePath);
608
+ if (pathEscapesRoot(lexicalRelativePath)) {
609
+ throw new Error("ragmir_evaluate goldenPath must stay inside the MCP project root.");
610
+ }
611
+ const resolvedPath = realpathSync.native(absolutePath);
612
+ const relativePath = path.relative(root, resolvedPath);
613
+ if (pathEscapesRoot(relativePath)) {
384
614
  throw new Error("ragmir_evaluate goldenPath must stay inside the MCP project root.");
385
615
  }
386
616
  return relativePath;
387
617
  }
618
+ function pathEscapesRoot(relativePath) {
619
+ return (relativePath.length === 0 ||
620
+ relativePath === ".." ||
621
+ relativePath.startsWith(`..${path.sep}`) ||
622
+ path.isAbsolute(relativePath));
623
+ }
624
+ function privateMcpPath(value) {
625
+ return path.isAbsolute(value) ? "<absolute-path>" : value;
626
+ }
627
+ function privateProjectPath(projectRoot, value) {
628
+ const relativePath = path.relative(projectRoot, path.resolve(value));
629
+ if (relativePath.length === 0) {
630
+ return ".";
631
+ }
632
+ return pathEscapesRoot(relativePath) ? "<outside-project>" : relativePath;
633
+ }
388
634
  function addOption(target, key, value) {
389
635
  if (value !== undefined) {
390
636
  target[key] = value;