@crowley/rag-mcp 1.2.1 → 1.5.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.
@@ -19,7 +19,13 @@ export declare const DEFAULT_ENRICHABLE_TOOLS: Set<string>;
19
19
  export declare const DEFAULT_SKIP_TOOLS: Set<string>;
20
20
  export declare class ContextEnricher {
21
21
  private config;
22
+ private cache;
23
+ private static CACHE_TTL_MS;
22
24
  constructor(config?: Partial<EnrichmentConfig>);
25
+ /**
26
+ * Clear enrichment cache (call on session end).
27
+ */
28
+ clearCache(): void;
23
29
  /**
24
30
  * Before hook: auto-recall relevant memories/patterns/ADRs.
25
31
  * Returns a context prefix string or null if nothing relevant found.
@@ -45,6 +45,8 @@ export const DEFAULT_SKIP_TOOLS = new Set([
45
45
  ]);
46
46
  export class ContextEnricher {
47
47
  config;
48
+ cache = new Map();
49
+ static CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
48
50
  constructor(config = {}) {
49
51
  this.config = {
50
52
  enrichableTools: config.enrichableTools ?? DEFAULT_ENRICHABLE_TOOLS,
@@ -54,6 +56,12 @@ export class ContextEnricher {
54
56
  timeoutMs: config.timeoutMs ?? 2000,
55
57
  };
56
58
  }
59
+ /**
60
+ * Clear enrichment cache (call on session end).
61
+ */
62
+ clearCache() {
63
+ this.cache.clear();
64
+ }
57
65
  /**
58
66
  * Before hook: auto-recall relevant memories/patterns/ADRs.
59
67
  * Returns a context prefix string or null if nothing relevant found.
@@ -68,11 +76,29 @@ export class ContextEnricher {
68
76
  const query = this.extractQuery(args);
69
77
  if (!query)
70
78
  return null;
79
+ // Check per-session cache
80
+ const cacheKey = `${ctx.activeSessionId || 'no-session'}:${query.slice(0, 100)}`;
81
+ const cached = this.cache.get(cacheKey);
82
+ if (cached && Date.now() < cached.expiresAt) {
83
+ return cached.result;
84
+ }
71
85
  try {
72
86
  const memories = await this.recallWithTimeout(query, ctx);
73
- if (memories.length === 0)
74
- return null;
75
- return this.formatContext(memories);
87
+ const result = memories.length === 0 ? null : this.formatContext(memories);
88
+ // Store in cache
89
+ this.cache.set(cacheKey, {
90
+ result,
91
+ expiresAt: Date.now() + ContextEnricher.CACHE_TTL_MS,
92
+ });
93
+ // Evict expired entries lazily (every 50 calls)
94
+ if (this.cache.size > 100) {
95
+ const now = Date.now();
96
+ for (const [key, entry] of this.cache) {
97
+ if (now > entry.expiresAt)
98
+ this.cache.delete(key);
99
+ }
100
+ }
101
+ return result;
76
102
  }
77
103
  catch {
78
104
  // Enrichment should never break tool calls
@@ -150,8 +176,10 @@ export class ContextEnricher {
150
176
  const controller = new AbortController();
151
177
  const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
152
178
  try {
153
- // Parallel recall: general memories + decisions/ADRs
154
- const [memoriesRes, decisionsRes] = await Promise.all([
179
+ // Parallel recall: general memories + decisions/ADRs + LTM (when enabled)
180
+ const graphRecallEnabled = process.env.GRAPH_RECALL_ENABLED === "true";
181
+ const consolidationEnabled = process.env.CONSOLIDATION_ENABLED === "true";
182
+ const recalls = [
155
183
  ctx.api
156
184
  .post("/api/memory/recall-durable", {
157
185
  projectName: ctx.projectName,
@@ -168,7 +196,19 @@ export class ContextEnricher {
168
196
  type: "decision",
169
197
  }, { signal: controller.signal })
170
198
  .catch(() => null),
171
- ]);
199
+ ];
200
+ // Phase 2+4: also recall from LTM (episodic+semantic with Ebbinghaus decay)
201
+ if (consolidationEnabled) {
202
+ recalls.push(ctx.api
203
+ .post("/api/memory/recall-ltm", {
204
+ projectName: ctx.projectName,
205
+ query,
206
+ limit: this.config.maxAutoRecall,
207
+ graphRecall: graphRecallEnabled,
208
+ }, { signal: controller.signal })
209
+ .catch(() => null));
210
+ }
211
+ const [memoriesRes, decisionsRes, ltmRes] = await Promise.all(recalls);
172
212
  const memories = [];
173
213
  const seenIds = new Set();
174
214
  // Process general memories
@@ -197,6 +237,23 @@ export class ContextEnricher {
197
237
  }
198
238
  }
199
239
  }
240
+ // Process LTM results (episodic + semantic)
241
+ if (ltmRes?.data?.results) {
242
+ for (const r of ltmRes.data.results) {
243
+ const mem = r.memory;
244
+ const id = mem?.id;
245
+ if (r.score >= this.config.minRelevance && id && !seenIds.has(id)) {
246
+ seenIds.add(id);
247
+ memories.push({
248
+ type: mem?.subtype || mem?.type || "insight",
249
+ content: mem?.content || "",
250
+ score: r.score,
251
+ });
252
+ }
253
+ }
254
+ }
255
+ // Sort by score and limit
256
+ memories.sort((a, b) => b.score - a.score);
200
257
  return memories.slice(0, this.config.maxAutoRecall + 2);
201
258
  }
202
259
  finally {
package/dist/schemas.d.ts CHANGED
@@ -11,40 +11,57 @@ import type { ToolInputSchema } from "./types.js";
11
11
  * Used during Phase 2 migration while ToolRegistry still expects raw JSON Schema.
12
12
  * Phase 3 passes Zod schemas directly to McpServer.registerTool().
13
13
  */
14
- export declare function zodToInputSchema(schema: z.ZodObject<z.ZodRawShape>): ToolInputSchema;
14
+ export declare function zodToInputSchema(schema: z.ZodObject<Record<string, z.ZodType>>): ToolInputSchema;
15
15
  export declare const QueryStr: z.ZodString;
16
16
  export declare const Limit: z.ZodDefault<z.ZodNumber>;
17
17
  export declare const Offset: z.ZodDefault<z.ZodNumber>;
18
18
  export declare const FilePath: z.ZodString;
19
- export declare const FilePaths: z.ZodArray<z.ZodString, "many">;
19
+ export declare const FilePaths: z.ZodArray<z.ZodString>;
20
20
  export declare const Content: z.ZodString;
21
21
  export declare const CollectionSuffix: z.ZodString;
22
- export declare const MemoryType: z.ZodEnum<["decision", "insight", "pattern", "adr", "tech_debt", "todo", "architecture", "convention", "bug_fix", "optimization"]>;
23
- export declare const ResponseFormat: z.ZodDefault<z.ZodEnum<["json", "markdown"]>>;
24
- export declare const Importance: z.ZodDefault<z.ZodEnum<["low", "medium", "high", "critical"]>>;
25
- export declare const Priority: z.ZodEnum<["low", "medium", "high", "critical"]>;
26
- export declare const Severity: z.ZodEnum<["low", "medium", "high", "critical"]>;
22
+ export declare const MemoryType: z.ZodEnum<{
23
+ decision: "decision";
24
+ insight: "insight";
25
+ todo: "todo";
26
+ pattern: "pattern";
27
+ adr: "adr";
28
+ architecture: "architecture";
29
+ tech_debt: "tech_debt";
30
+ convention: "convention";
31
+ bug_fix: "bug_fix";
32
+ optimization: "optimization";
33
+ }>;
34
+ export declare const ResponseFormat: z.ZodDefault<z.ZodEnum<{
35
+ markdown: "markdown";
36
+ json: "json";
37
+ }>>;
38
+ export declare const Importance: z.ZodDefault<z.ZodEnum<{
39
+ low: "low";
40
+ medium: "medium";
41
+ high: "high";
42
+ critical: "critical";
43
+ }>>;
44
+ export declare const Priority: z.ZodEnum<{
45
+ low: "low";
46
+ medium: "medium";
47
+ high: "high";
48
+ critical: "critical";
49
+ }>;
50
+ export declare const Severity: z.ZodEnum<{
51
+ low: "low";
52
+ medium: "medium";
53
+ high: "high";
54
+ critical: "critical";
55
+ }>;
27
56
  export declare const PaginationParams: z.ZodObject<{
28
57
  limit: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
29
58
  offset: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
30
- }, "strip", z.ZodTypeAny, {
31
- limit?: number | undefined;
32
- offset?: number | undefined;
33
- }, {
34
- limit?: number | undefined;
35
- offset?: number | undefined;
36
- }>;
59
+ }, z.core.$strip>;
37
60
  export declare const SearchFilters: z.ZodOptional<z.ZodObject<{
38
61
  file_type: z.ZodOptional<z.ZodString>;
39
62
  directory: z.ZodOptional<z.ZodString>;
40
- }, "strip", z.ZodTypeAny, {
41
- file_type?: string | undefined;
42
- directory?: string | undefined;
43
- }, {
44
- file_type?: string | undefined;
45
- directory?: string | undefined;
46
- }>>;
47
- export declare const Tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
63
+ }, z.core.$strip>>;
64
+ export declare const Tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
48
65
  export declare const Confidence: z.ZodOptional<z.ZodNumber>;
49
66
  /** Base shape for search tools: query + optional limit + optional filters */
50
67
  export declare const SearchInput: z.ZodObject<{
@@ -53,45 +70,29 @@ export declare const SearchInput: z.ZodObject<{
53
70
  filters: z.ZodOptional<z.ZodObject<{
54
71
  file_type: z.ZodOptional<z.ZodString>;
55
72
  directory: z.ZodOptional<z.ZodString>;
56
- }, "strip", z.ZodTypeAny, {
57
- file_type?: string | undefined;
58
- directory?: string | undefined;
59
- }, {
60
- file_type?: string | undefined;
61
- directory?: string | undefined;
62
- }>>;
63
- }, "strip", z.ZodTypeAny, {
64
- query: string;
65
- limit?: number | undefined;
66
- filters?: {
67
- file_type?: string | undefined;
68
- directory?: string | undefined;
69
- } | undefined;
70
- }, {
71
- query: string;
72
- limit?: number | undefined;
73
- filters?: {
74
- file_type?: string | undefined;
75
- directory?: string | undefined;
76
- } | undefined;
77
- }>;
73
+ }, z.core.$strip>>;
74
+ }, z.core.$strip>;
78
75
  /** Base shape for memory record tools */
79
76
  export declare const MemoryRecordInput: z.ZodObject<{
80
77
  content: z.ZodString;
81
- type: z.ZodEnum<["decision", "insight", "pattern", "adr", "tech_debt", "todo", "architecture", "convention", "bug_fix", "optimization"]>;
82
- tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
83
- importance: z.ZodOptional<z.ZodDefault<z.ZodEnum<["low", "medium", "high", "critical"]>>>;
78
+ type: z.ZodEnum<{
79
+ decision: "decision";
80
+ insight: "insight";
81
+ todo: "todo";
82
+ pattern: "pattern";
83
+ adr: "adr";
84
+ architecture: "architecture";
85
+ tech_debt: "tech_debt";
86
+ convention: "convention";
87
+ bug_fix: "bug_fix";
88
+ optimization: "optimization";
89
+ }>;
90
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
91
+ importance: z.ZodOptional<z.ZodDefault<z.ZodEnum<{
92
+ low: "low";
93
+ medium: "medium";
94
+ high: "high";
95
+ critical: "critical";
96
+ }>>>;
84
97
  context: z.ZodOptional<z.ZodString>;
85
- }, "strip", z.ZodTypeAny, {
86
- type: "decision" | "insight" | "todo" | "adr" | "pattern" | "architecture" | "tech_debt" | "convention" | "bug_fix" | "optimization";
87
- content: string;
88
- context?: string | undefined;
89
- tags?: string[] | undefined;
90
- importance?: "low" | "medium" | "high" | "critical" | undefined;
91
- }, {
92
- type: "decision" | "insight" | "todo" | "adr" | "pattern" | "architecture" | "tech_debt" | "convention" | "bug_fix" | "optimization";
93
- content: string;
94
- context?: string | undefined;
95
- tags?: string[] | undefined;
96
- importance?: "low" | "medium" | "high" | "critical" | undefined;
97
- }>;
98
+ }, z.core.$strip>;
package/dist/schemas.js CHANGED
@@ -5,7 +5,6 @@
5
5
  * from raw JSON Schema objects to Zod-based inputSchema definitions.
6
6
  */
7
7
  import { z } from "zod";
8
- import { zodToJsonSchema } from "zod-to-json-schema";
9
8
  // ── JSON Schema conversion ──────────────────────────────────
10
9
  /**
11
10
  * Convert a Zod object schema to the MCP ToolInputSchema format.
@@ -13,7 +12,7 @@ import { zodToJsonSchema } from "zod-to-json-schema";
13
12
  * Phase 3 passes Zod schemas directly to McpServer.registerTool().
14
13
  */
15
14
  export function zodToInputSchema(schema) {
16
- const jsonSchema = zodToJsonSchema(schema, { target: "openApi3" });
15
+ const jsonSchema = z.toJSONSchema(schema);
17
16
  return {
18
17
  type: "object",
19
18
  properties: (jsonSchema.properties ?? {}),
@@ -8,6 +8,7 @@
8
8
  * During Phase 2 migration, ToolRegistry continues to use its own copy.
9
9
  * Phase 3 replaces ToolRegistry with wrapHandler() + McpServer.registerTool().
10
10
  */
11
+ import { validationPipeline } from "./validation-hooks.js";
11
12
  // ── Timeouts ────────────────────────────────────────────────
12
13
  /** Default tool timeout in milliseconds */
13
14
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -154,6 +155,43 @@ export function trackUsage(name, args, startTime, success, result, errorMessage,
154
155
  })
155
156
  .catch(() => { });
156
157
  }
158
+ // ── Sensory buffer ─────────────────────────────────────────
159
+ /** Extract file paths from tool args */
160
+ function extractFiles(args) {
161
+ const files = [];
162
+ for (const key of ['file', 'filePath', 'currentFile', 'path']) {
163
+ const v = args[key];
164
+ if (typeof v === 'string' && v.length > 0)
165
+ files.push(v);
166
+ }
167
+ const arr = args.affectedFiles || args.files;
168
+ if (Array.isArray(arr)) {
169
+ for (const f of arr) {
170
+ if (typeof f === 'string')
171
+ files.push(f);
172
+ }
173
+ }
174
+ return files.slice(0, 20);
175
+ }
176
+ /** Fire-and-forget: capture tool event in sensory buffer */
177
+ function appendToSensoryBuffer(name, args, startTime, success, resultText, ctx) {
178
+ if (!ctx.activeSessionId)
179
+ return;
180
+ if (TRACKING_EXCLUDE.has(name))
181
+ return;
182
+ ctx.api
183
+ .post("/api/sensory/append", {
184
+ projectName: ctx.projectName,
185
+ sessionId: ctx.activeSessionId,
186
+ toolName: name,
187
+ inputSummary: summarizeInput(name, args).slice(0, 500),
188
+ outputSummary: (resultText || "").slice(0, 500),
189
+ filesTouched: extractFiles(args),
190
+ success,
191
+ durationMs: Date.now() - startTime,
192
+ })
193
+ .catch(() => { });
194
+ }
157
195
  // ── Error formatting ────────────────────────────────────────
158
196
  /** Format an error caught during tool execution */
159
197
  export function formatToolError(error, ctx) {
@@ -182,13 +220,22 @@ export function wrapHandler(name, handler, deps) {
182
220
  }
183
221
  const startTime = Date.now();
184
222
  try {
223
+ // Validate: run PreToolUse hooks
224
+ const validation = await validationPipeline.validate(name, args, ctx);
225
+ if (!validation.allowed) {
226
+ return `Blocked: ${validation.reason || 'validation failed'}`;
227
+ }
228
+ const validatedArgs = validation.modifiedArgs || args;
229
+ const warningPrefix = validation.warnings?.length
230
+ ? `⚠️ ${validation.warnings.join(' | ')}\n\n`
231
+ : '';
185
232
  // Before: auto-enrich context
186
233
  const contextPrefix = ctx.enrichmentEnabled && deps.enricher
187
- ? await deps.enricher.before(name, args, ctx)
234
+ ? await deps.enricher.before(name, validatedArgs, ctx)
188
235
  : null;
189
236
  // Execute original handler (with timeout)
190
237
  const timeoutMs = TOOL_TIMEOUTS[name] ?? DEFAULT_TIMEOUT_MS;
191
- const result = await withTimeout(handler(args, ctx), timeoutMs, name);
238
+ const result = await withTimeout(handler(validatedArgs, ctx), timeoutMs, name);
192
239
  // Extract text for tracking/enrichment
193
240
  const text = typeof result === "string" ? result : result.text;
194
241
  // After: track interaction (fire-and-forget)
@@ -197,12 +244,15 @@ export function wrapHandler(name, handler, deps) {
197
244
  }
198
245
  // Track usage (fire-and-forget)
199
246
  trackUsage(name, args, startTime, true, text, undefined, ctx);
200
- // Prepend context if available
201
- if (contextPrefix) {
247
+ // Capture in sensory buffer (fire-and-forget)
248
+ appendToSensoryBuffer(name, args, startTime, true, text, ctx);
249
+ // Prepend context/warnings if available
250
+ const prefix = [warningPrefix, contextPrefix].filter(Boolean).join('');
251
+ if (prefix) {
202
252
  if (typeof result === "string") {
203
- return contextPrefix + "\n\n" + result;
253
+ return prefix + result;
204
254
  }
205
- return { text: contextPrefix + "\n\n" + result.text, structured: result.structured };
255
+ return { text: prefix + result.text, structured: result.structured };
206
256
  }
207
257
  return result;
208
258
  }
@@ -210,6 +260,8 @@ export function wrapHandler(name, handler, deps) {
210
260
  const errorMessage = formatToolError(error, ctx);
211
261
  // Track failed usage (fire-and-forget)
212
262
  trackUsage(name, args, startTime, false, "", errorMessage, ctx);
263
+ // Capture failure in sensory buffer (fire-and-forget)
264
+ appendToSensoryBuffer(name, args, startTime, false, errorMessage, ctx);
213
265
  return errorMessage;
214
266
  }
215
267
  };
@@ -59,6 +59,74 @@ export function createAgentTools(projectName) {
59
59
  return result;
60
60
  },
61
61
  },
62
+ {
63
+ name: "tribunal_debate",
64
+ description: `Run an adversarial debate on a topic for ${projectName}. Multiple advocates argue positions, a judge renders a verdict. Use for architecture decisions, tech choices, or code approach trade-offs.`,
65
+ schema: z.object({
66
+ topic: z.string().describe("The debate topic (e.g., 'Should we use REST or gRPC for the new API?')"),
67
+ positions: z.array(z.string()).min(2).max(4).describe("Positions to debate (2-4 options, e.g., ['REST', 'gRPC'])"),
68
+ context: z.string().optional().describe("Additional context for the debate"),
69
+ maxRounds: z.coerce.number().optional().describe("Number of rebuttal rounds (default: 1, max: 3)"),
70
+ useCodeContext: z.boolean().optional().describe("Fetch relevant code, ADRs, and patterns as evidence (default: false)"),
71
+ autoRecord: z.boolean().optional().describe("Save verdict as a decision in project memory (default: false)"),
72
+ }),
73
+ annotations: TOOL_ANNOTATIONS["tribunal_debate"] || { priority: 0.4, readOnlyHint: true },
74
+ handler: async (args, ctx) => {
75
+ const { topic, positions, context, maxRounds, useCodeContext, autoRecord } = args;
76
+ const response = await ctx.api.post("/api/tribunal/debate", {
77
+ projectName: ctx.projectName,
78
+ topic,
79
+ positions,
80
+ context,
81
+ maxRounds,
82
+ useCodeContext,
83
+ autoRecord,
84
+ });
85
+ const data = response.data;
86
+ // Format result as markdown
87
+ let result = `## Tribunal Debate: ${data.topic}\n`;
88
+ result += `**Status:** ${data.status}`;
89
+ result += ` | **Duration:** ${Math.round(data.durationMs / 1000)}s`;
90
+ result += ` | **Cost:** ~$${data.cost?.estimatedUsd?.toFixed(3) || '?'}\n\n`;
91
+ // Phases summary
92
+ if (data.phases) {
93
+ result += `### Phases\n`;
94
+ for (const phase of data.phases) {
95
+ result += `- **${phase.name}**: ${Math.round(phase.durationMs / 1000)}s, ${phase.tokens} tokens\n`;
96
+ }
97
+ result += `\n`;
98
+ }
99
+ // Arguments
100
+ if (data.arguments && data.arguments.length > 0) {
101
+ result += `### Arguments\n`;
102
+ for (const arg of data.arguments) {
103
+ const label = arg.round === 0 ? 'Initial' : `Rebuttal R${arg.round}`;
104
+ result += `#### ${arg.position} (${label})\n${arg.content}\n\n`;
105
+ }
106
+ }
107
+ // Verdict
108
+ if (data.verdict) {
109
+ result += `### Verdict\n`;
110
+ result += `**Recommendation:** ${data.verdict.recommendation}\n`;
111
+ result += `**Confidence:** ${data.verdict.confidence}\n\n`;
112
+ if (data.verdict.scores) {
113
+ result += `**Scores:**\n`;
114
+ for (const s of data.verdict.scores) {
115
+ result += `- ${s.position}: ${s.score}/10\n`;
116
+ }
117
+ result += `\n`;
118
+ }
119
+ result += `**Reasoning:**\n${data.verdict.reasoning}\n\n`;
120
+ result += `**Trade-offs:**\n${data.verdict.tradeoffs}\n\n`;
121
+ result += `**Dissent:**\n${data.verdict.dissent}\n\n`;
122
+ result += `**Conditions:**\n${data.verdict.conditions}\n`;
123
+ }
124
+ if (data.error) {
125
+ result += `\n**Error:** ${data.error}\n`;
126
+ }
127
+ return result;
128
+ },
129
+ },
62
130
  {
63
131
  name: "get_agent_types",
64
132
  description: `List available agent types for ${projectName} with descriptions.`,
@@ -61,19 +61,22 @@ export function createMemoryTools(projectName) {
61
61
  description: "Retrieve relevant memories based on context. Searches agent memory for past decisions, insights, and notes related to the query.",
62
62
  schema: z.object({
63
63
  query: z.string().describe("What to recall (semantic search)"),
64
- type: z.enum(["decision", "insight", "context", "todo", "conversation", "note", "all"]).optional().describe("Filter by memory type (default: all)"),
64
+ type: z.enum(["decision", "insight", "context", "todo", "conversation", "note", "procedure", "all"]).optional().describe("Filter by memory type (default: all)"),
65
65
  limit: z.coerce.number().optional().describe("Max memories to retrieve (default: 5)"),
66
+ graphRecall: z.boolean().optional().describe("Enable graph-aware recall with spreading activation (default: false)"),
66
67
  }),
67
68
  annotations: TOOL_ANNOTATIONS["recall"],
68
69
  handler: async (args, ctx) => {
69
70
  const query = args.query;
70
71
  const type = args.type || "all";
71
72
  const limit = args.limit || 5;
73
+ const graphRecall = args.graphRecall || false;
72
74
  const response = await ctx.api.post("/api/memory/recall", {
73
75
  projectName: ctx.projectName,
74
76
  query,
75
77
  type,
76
78
  limit,
79
+ graphRecall,
77
80
  });
78
81
  const results = response.data.results || [];
79
82
  if (results.length === 0) {
package/dist/types.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Shared types for the MCP server tool modules.
3
3
  */
4
4
  import type { AxiosInstance } from "axios";
5
- import type { ZodObject, ZodRawShape } from "zod";
5
+ import type { z } from "zod";
6
6
  import type { ToolAnnotations } from "./annotations.js";
7
7
  /** MCP tool input schema shape (raw JSON Schema) */
8
8
  export interface ToolInputSchema {
@@ -42,8 +42,8 @@ export interface ToolModule {
42
42
  export interface ToolSpec {
43
43
  name: string;
44
44
  description: string;
45
- schema: ZodObject<ZodRawShape>;
46
- outputSchema?: ZodObject<ZodRawShape>;
45
+ schema: z.ZodObject<Record<string, z.ZodType>>;
46
+ outputSchema?: z.ZodObject<Record<string, z.ZodType>>;
47
47
  annotations?: ToolAnnotations;
48
48
  handler: ToolHandler;
49
49
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * PreToolUse Validation Hooks
3
+ *
4
+ * Validation pipeline that runs before tool execution.
5
+ * Hooks can block execution, modify args, or add warnings.
6
+ *
7
+ * Inspired by claude-quanta-plugin's PreToolUse hooks pattern.
8
+ */
9
+ import type { ToolContext } from "./types.js";
10
+ export interface ValidationResult {
11
+ allowed: boolean;
12
+ reason?: string;
13
+ warnings?: string[];
14
+ modifiedArgs?: Record<string, unknown>;
15
+ }
16
+ export type ValidationHook = (toolName: string, args: Record<string, unknown>, ctx: ToolContext) => Promise<ValidationResult> | ValidationResult;
17
+ /**
18
+ * Prevent destructive operations without explicit confirmation.
19
+ * Blocks: index_codebase with force=true, forget (memory deletion).
20
+ */
21
+ export declare const destructiveGuard: ValidationHook;
22
+ /**
23
+ * Validate required fields for critical tools.
24
+ */
25
+ export declare const requiredFieldsValidator: ValidationHook;
26
+ /**
27
+ * Sanitize inputs — trim strings, enforce reasonable limits.
28
+ */
29
+ export declare const inputSanitizer: ValidationHook;
30
+ export declare class ValidationPipeline {
31
+ private hooks;
32
+ constructor(hooks?: ValidationHook[]);
33
+ addHook(hook: ValidationHook): void;
34
+ /**
35
+ * Run all hooks in sequence. First rejection stops the pipeline.
36
+ * Args can be modified by hooks (each hook sees the potentially modified args).
37
+ */
38
+ validate(toolName: string, args: Record<string, unknown>, ctx: ToolContext): Promise<ValidationResult>;
39
+ }
40
+ export declare const validationPipeline: ValidationPipeline;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * PreToolUse Validation Hooks
3
+ *
4
+ * Validation pipeline that runs before tool execution.
5
+ * Hooks can block execution, modify args, or add warnings.
6
+ *
7
+ * Inspired by claude-quanta-plugin's PreToolUse hooks pattern.
8
+ */
9
+ // ── Built-in Hooks ──────────────────────────────────────────
10
+ /**
11
+ * Prevent destructive operations without explicit confirmation.
12
+ * Blocks: index_codebase with force=true, forget (memory deletion).
13
+ */
14
+ export const destructiveGuard = (toolName, args) => {
15
+ if (toolName === 'index_codebase' && args.force === true) {
16
+ return {
17
+ allowed: true,
18
+ warnings: ['Force reindex will delete and rebuild the entire index. This may take several minutes.'],
19
+ };
20
+ }
21
+ if (toolName === 'forget') {
22
+ return {
23
+ allowed: true,
24
+ warnings: ['This will permanently delete a memory entry.'],
25
+ };
26
+ }
27
+ return { allowed: true };
28
+ };
29
+ /**
30
+ * Validate required fields for critical tools.
31
+ */
32
+ export const requiredFieldsValidator = (toolName, args) => {
33
+ // Search tools must have a query
34
+ const searchTools = ['search_codebase', 'hybrid_search', 'search_docs', 'find_feature', 'ask_codebase'];
35
+ if (searchTools.includes(toolName)) {
36
+ const query = args.query || args.question;
37
+ if (!query || (typeof query === 'string' && query.trim().length < 3)) {
38
+ return { allowed: false, reason: `${toolName} requires a query of at least 3 characters` };
39
+ }
40
+ }
41
+ // Memory tools must have content
42
+ if (toolName === 'remember' && (!args.content || (typeof args.content === 'string' && args.content.trim().length < 10))) {
43
+ return { allowed: false, reason: 'remember requires content of at least 10 characters' };
44
+ }
45
+ return { allowed: true };
46
+ };
47
+ /**
48
+ * Sanitize inputs — trim strings, enforce reasonable limits.
49
+ */
50
+ export const inputSanitizer = (toolName, args) => {
51
+ const modified = { ...args };
52
+ let changed = false;
53
+ // Trim string values
54
+ for (const [key, value] of Object.entries(modified)) {
55
+ if (typeof value === 'string' && value !== value.trim()) {
56
+ modified[key] = value.trim();
57
+ changed = true;
58
+ }
59
+ }
60
+ // Cap limit params to prevent excessive results
61
+ if (typeof modified.limit === 'number' && modified.limit > 50) {
62
+ modified.limit = 50;
63
+ changed = true;
64
+ }
65
+ return changed
66
+ ? { allowed: true, modifiedArgs: modified }
67
+ : { allowed: true };
68
+ };
69
+ // ── Validation Pipeline ─────────────────────────────────────
70
+ export class ValidationPipeline {
71
+ hooks = [];
72
+ constructor(hooks) {
73
+ this.hooks = hooks || [
74
+ destructiveGuard,
75
+ requiredFieldsValidator,
76
+ inputSanitizer,
77
+ ];
78
+ }
79
+ addHook(hook) {
80
+ this.hooks.push(hook);
81
+ }
82
+ /**
83
+ * Run all hooks in sequence. First rejection stops the pipeline.
84
+ * Args can be modified by hooks (each hook sees the potentially modified args).
85
+ */
86
+ async validate(toolName, args, ctx) {
87
+ let currentArgs = { ...args };
88
+ const allWarnings = [];
89
+ for (const hook of this.hooks) {
90
+ const result = await hook(toolName, currentArgs, ctx);
91
+ if (!result.allowed) {
92
+ return result;
93
+ }
94
+ if (result.warnings) {
95
+ allWarnings.push(...result.warnings);
96
+ }
97
+ if (result.modifiedArgs) {
98
+ currentArgs = result.modifiedArgs;
99
+ }
100
+ }
101
+ return {
102
+ allowed: true,
103
+ warnings: allWarnings.length > 0 ? allWarnings : undefined,
104
+ modifiedArgs: currentArgs !== args ? currentArgs : undefined,
105
+ };
106
+ }
107
+ }
108
+ export const validationPipeline = new ValidationPipeline();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crowley/rag-mcp",
3
- "version": "1.2.1",
3
+ "version": "1.5.0",
4
4
  "description": "Universal RAG MCP Server for any project",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,8 +35,7 @@
35
35
  "@modelcontextprotocol/sdk": "^1.0.0",
36
36
  "axios": "^1.6.0",
37
37
  "glob": "^11.0.0",
38
- "zod": "^3.25.76",
39
- "zod-to-json-schema": "^3.25.1"
38
+ "zod": "^4.0.0"
40
39
  },
41
40
  "devDependencies": {
42
41
  "@types/node": "^20.10.0",