recallx 1.0.8 → 1.2.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.
package/app/mcp/server.js CHANGED
@@ -64,8 +64,264 @@ function coerceBooleanSchema(defaultValue) {
64
64
  return value;
65
65
  }, z.boolean().default(defaultValue));
66
66
  }
67
+ const textSummaryItemLimit = 5;
68
+ const textSummaryLength = 140;
69
+ const textTitleLength = 80;
70
+ function isRecord(value) {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value);
72
+ }
73
+ function cleanInlineText(value, maxLength = textSummaryLength) {
74
+ const compact = value.replace(/\s+/g, " ").trim();
75
+ if (compact.length <= maxLength) {
76
+ return compact;
77
+ }
78
+ return `${compact.slice(0, maxLength - 3).trimEnd()}...`;
79
+ }
80
+ function formatKeyLabel(key) {
81
+ return key
82
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
83
+ .replace(/[_-]+/g, " ")
84
+ .trim();
85
+ }
86
+ function capitalize(value) {
87
+ return value ? `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}` : value;
88
+ }
89
+ function pickString(record, keys) {
90
+ for (const key of keys) {
91
+ const candidate = record[key];
92
+ if (typeof candidate === "string" && candidate.trim()) {
93
+ return candidate;
94
+ }
95
+ }
96
+ return undefined;
97
+ }
98
+ function summarizeValue(value) {
99
+ if (typeof value === "string" && value.trim()) {
100
+ return cleanInlineText(value);
101
+ }
102
+ if (typeof value === "number" || typeof value === "boolean") {
103
+ return String(value);
104
+ }
105
+ if (Array.isArray(value)) {
106
+ return `${value.length} item(s)`;
107
+ }
108
+ if (isRecord(value)) {
109
+ const title = pickString(value, ["title", "name", "id", "nodeId"]);
110
+ if (title) {
111
+ return cleanInlineText(title);
112
+ }
113
+ return `${Object.keys(value).length} field(s)`;
114
+ }
115
+ return undefined;
116
+ }
117
+ function unwrapSummaryRecord(record) {
118
+ const resultType = typeof record.resultType === "string" ? record.resultType : undefined;
119
+ if (resultType && isRecord(record[resultType])) {
120
+ const nested = record[resultType];
121
+ return {
122
+ kind: (resultType === "activity" ? pickString(nested, ["activityType"]) : undefined) ??
123
+ (resultType === "node" ? pickString(nested, ["type"]) : undefined) ??
124
+ resultType,
125
+ value: nested
126
+ };
127
+ }
128
+ if (isRecord(record.node)) {
129
+ return {
130
+ kind: resultType ?? "node",
131
+ value: record.node
132
+ };
133
+ }
134
+ if (isRecord(record.activity)) {
135
+ return {
136
+ kind: resultType ?? "activity",
137
+ value: record.activity
138
+ };
139
+ }
140
+ return {
141
+ kind: pickString(record, ["type", "activityType", "status"]) ??
142
+ (typeof record.resultType === "string" ? record.resultType : undefined),
143
+ value: record
144
+ };
145
+ }
146
+ function summarizeRecordLine(record, index) {
147
+ const { kind, value } = unwrapSummaryRecord(record);
148
+ const title = pickString(value, ["title", "targetNodeTitle", "name", "workspaceName", "rootPath", "id", "nodeId"]);
149
+ const identifier = pickString(value, ["id", "nodeId", "targetNodeId"]);
150
+ const detail = pickString(value, ["summary", "reason", "body", "message", "staleReason", "workspaceRoot"]);
151
+ const parts = [`${index}.`];
152
+ if (kind) {
153
+ parts.push(`[${kind}]`);
154
+ }
155
+ if (title) {
156
+ parts.push(cleanInlineText(title, textTitleLength));
157
+ }
158
+ if (identifier && identifier !== title) {
159
+ parts.push(`(id: ${cleanInlineText(identifier, textTitleLength)})`);
160
+ }
161
+ let line = parts.join(" ");
162
+ if (detail) {
163
+ line += ` - ${cleanInlineText(detail)}`;
164
+ }
165
+ return line;
166
+ }
167
+ function formatItemsSummary(payload) {
168
+ const rawItems = payload.items;
169
+ if (!Array.isArray(rawItems)) {
170
+ return null;
171
+ }
172
+ const items = rawItems.filter(isRecord);
173
+ const shown = Math.min(items.length, textSummaryItemLimit);
174
+ const total = typeof payload.total === "number" ? payload.total : items.length;
175
+ const lines = [`Results: ${shown} shown of ${total} total.`];
176
+ if (!items.length) {
177
+ lines.push("No results.");
178
+ }
179
+ else {
180
+ for (const [index, item] of items.slice(0, textSummaryItemLimit).entries()) {
181
+ lines.push(summarizeRecordLine(item, index + 1));
182
+ }
183
+ }
184
+ if (total > shown) {
185
+ lines.push(`More available: ${total - shown} additional result(s).`);
186
+ }
187
+ else if (items.length > shown) {
188
+ lines.push(`More available: ${items.length - shown} additional item(s).`);
189
+ }
190
+ if (typeof payload.nextCursor === "string" && payload.nextCursor.trim()) {
191
+ lines.push(`Next cursor: ${cleanInlineText(payload.nextCursor, textTitleLength)}`);
192
+ }
193
+ return lines.join("\n");
194
+ }
195
+ function formatBundleSummary(payload) {
196
+ const bundle = isRecord(payload.bundle) ? payload.bundle : payload;
197
+ if (!isRecord(payload.bundle) && !isRecord(bundle.target)) {
198
+ return null;
199
+ }
200
+ const target = isRecord(bundle.target) ? bundle.target : null;
201
+ const type = target ? pickString(target, ["type"]) : undefined;
202
+ const mode = pickString(bundle, ["mode"]);
203
+ const preset = pickString(bundle, ["preset"]);
204
+ const summary = pickString(bundle, ["summary"]);
205
+ const items = Array.isArray(bundle.items) ? bundle.items.filter(isRecord) : [];
206
+ const decisions = Array.isArray(bundle.decisions) ? bundle.decisions : [];
207
+ const openQuestions = Array.isArray(bundle.openQuestions) ? bundle.openQuestions : [];
208
+ const activityDigest = Array.isArray(bundle.activityDigest) ? bundle.activityDigest : [];
209
+ const lines = [`Context bundle: Target${type ? ` [${type}]` : ""}.`];
210
+ if (mode || preset) {
211
+ lines.push(`Mode: ${[mode, preset].filter(Boolean).join(", ")}.`);
212
+ }
213
+ if (summary) {
214
+ lines.push(`Summary: ${cleanInlineText(summary)}`);
215
+ }
216
+ lines.push(`Items: ${items.length}.`);
217
+ for (const [index, item] of items.slice(0, textSummaryItemLimit).entries()) {
218
+ lines.push(summarizeRecordLine(item, index + 1));
219
+ }
220
+ if (items.length > textSummaryItemLimit) {
221
+ lines.push(`More bundle items: ${items.length - textSummaryItemLimit}.`);
222
+ }
223
+ if (activityDigest.length) {
224
+ lines.push(`Activity digest: ${activityDigest.length} entr${activityDigest.length === 1 ? "y" : "ies"}.`);
225
+ }
226
+ if (decisions.length) {
227
+ lines.push(`Decisions: ${decisions.length}.`);
228
+ }
229
+ if (openQuestions.length) {
230
+ lines.push(`Open questions: ${openQuestions.length}.`);
231
+ }
232
+ return lines.join("\n");
233
+ }
234
+ function formatWriteSummary(payload) {
235
+ const primaryKey = ["node", "activity", "relation"].find((key) => isRecord(payload[key]));
236
+ if (!primaryKey && !isRecord(payload.landing)) {
237
+ return null;
238
+ }
239
+ const lines = [];
240
+ if (primaryKey && isRecord(payload[primaryKey])) {
241
+ lines.push(`${capitalize(primaryKey)} stored: ${summarizeRecordLine(payload[primaryKey], 1).replace(/^1\.\s*/, "")}`);
242
+ }
243
+ if (isRecord(payload.landing)) {
244
+ const landing = payload.landing;
245
+ const parts = [
246
+ typeof landing.storedAs === "string" ? `storedAs=${landing.storedAs}` : null,
247
+ typeof landing.canonicality === "string" ? `canonicality=${landing.canonicality}` : null,
248
+ typeof landing.status === "string" ? `status=${landing.status}` : null,
249
+ typeof landing.governanceState === "string" ? `governance=${landing.governanceState}` : null
250
+ ].filter((value) => Boolean(value));
251
+ if (parts.length) {
252
+ lines.push(`Landing: ${parts.join(", ")}.`);
253
+ }
254
+ if (typeof landing.reason === "string" && landing.reason.trim()) {
255
+ lines.push(`Reason: ${cleanInlineText(landing.reason)}`);
256
+ }
257
+ }
258
+ return lines.length ? lines.join("\n") : null;
259
+ }
260
+ function formatObjectSummary(payload) {
261
+ const preferredKeys = [
262
+ "status",
263
+ "message",
264
+ "workspaceName",
265
+ "workspaceRoot",
266
+ "rootPath",
267
+ "schemaVersion",
268
+ "authMode",
269
+ "bindAddress",
270
+ "queued",
271
+ "queuedCount",
272
+ "nodeId",
273
+ "id",
274
+ "title",
275
+ "type",
276
+ "summary",
277
+ "reason",
278
+ "nextCursor"
279
+ ];
280
+ const orderedKeys = Array.from(new Set([...preferredKeys.filter((key) => key in payload), ...Object.keys(payload).filter((key) => !preferredKeys.includes(key))]));
281
+ const lines = [];
282
+ for (const key of orderedKeys) {
283
+ const summary = summarizeValue(payload[key]);
284
+ if (!summary) {
285
+ continue;
286
+ }
287
+ lines.push(`${formatKeyLabel(key)}: ${summary}`);
288
+ if (lines.length >= 8) {
289
+ break;
290
+ }
291
+ }
292
+ return lines.length ? lines.join("\n") : "Structured response returned.";
293
+ }
294
+ function formatArraySummary(items) {
295
+ const recordItems = items.filter(isRecord);
296
+ if (!recordItems.length) {
297
+ return `Items: ${items.length}.`;
298
+ }
299
+ const lines = [`Items: ${recordItems.length}.`];
300
+ for (const [index, item] of recordItems.slice(0, textSummaryItemLimit).entries()) {
301
+ lines.push(summarizeRecordLine(item, index + 1));
302
+ }
303
+ if (recordItems.length > textSummaryItemLimit) {
304
+ lines.push(`More available: ${recordItems.length - textSummaryItemLimit} additional item(s).`);
305
+ }
306
+ return lines.join("\n");
307
+ }
67
308
  function formatStructuredContent(content) {
68
- return JSON.stringify(content, null, 2);
309
+ if (Array.isArray(content)) {
310
+ return formatArraySummary(content);
311
+ }
312
+ if (isRecord(content)) {
313
+ return formatBundleSummary(content) ?? formatItemsSummary(content) ?? formatWriteSummary(content) ?? formatObjectSummary(content);
314
+ }
315
+ if (typeof content === "string" && content.trim()) {
316
+ return cleanInlineText(content);
317
+ }
318
+ if (typeof content === "number" || typeof content === "boolean") {
319
+ return String(content);
320
+ }
321
+ return "Structured response returned.";
322
+ }
323
+ function renderToolText(_toolName, structuredContent) {
324
+ return formatStructuredContent(structuredContent);
69
325
  }
70
326
  function formatInvalidBundleModeMessage(input) {
71
327
  const quotedInput = typeof input === "string" && input.trim() ? `'${input}'` : "that value";
@@ -85,12 +341,12 @@ function bundlePresetSchema(defaultValue) {
85
341
  error: (issue) => formatInvalidBundlePresetMessage(issue.input)
86
342
  })).default(defaultValue);
87
343
  }
88
- function toolResult(structuredContent) {
344
+ function toolResult(toolName, structuredContent) {
89
345
  return {
90
346
  content: [
91
347
  {
92
348
  type: "text",
93
- text: formatStructuredContent(structuredContent)
349
+ text: renderToolText(toolName, structuredContent)
94
350
  }
95
351
  ],
96
352
  structuredContent
@@ -133,14 +389,14 @@ const readOnlyToolAnnotations = {
133
389
  readOnlyHint: true,
134
390
  idempotentHint: true
135
391
  };
136
- function createGetToolHandler(apiClient, path) {
137
- return async () => toolResult(await apiClient.get(path));
392
+ function createGetToolHandler(toolName, apiClient, path) {
393
+ return async () => toolResult(toolName, await apiClient.get(path));
138
394
  }
139
- function createPostToolHandler(apiClient, path) {
140
- return async (input) => toolResult(await apiClient.post(path, input));
395
+ function createPostToolHandler(toolName, apiClient, path) {
396
+ return async (input) => toolResult(toolName, await apiClient.post(path, input));
141
397
  }
142
- function createNormalizedPostToolHandler(apiClient, path, normalize) {
143
- return async (input) => toolResult(await apiClient.post(path, normalize(input)));
398
+ function createNormalizedPostToolHandler(toolName, apiClient, path, normalize) {
399
+ return async (input) => toolResult(toolName, await apiClient.post(path, normalize(input)));
144
400
  }
145
401
  function withReadOnlyAnnotations(config) {
146
402
  return {
@@ -312,7 +568,7 @@ export function createRecallXMcpServer(params) {
312
568
  workspaceRoot: process.cwd(),
313
569
  workspaceName: "RecallX MCP",
314
570
  retentionDays: 14,
315
- slowRequestMs: 250,
571
+ slowRequestMs: 50,
316
572
  capturePayloadShape: true
317
573
  };
318
574
  let currentObservabilityState = params?.observabilityState ?? defaultObservabilityState;
@@ -331,11 +587,55 @@ export function createRecallXMcpServer(params) {
331
587
  const observability = createObservabilityWriter({
332
588
  getState: () => currentObservabilityState
333
589
  });
590
+ // Session-level feedback tracking for automatic signal collection.
591
+ // Tracks which node IDs appeared in read results so that after a write we can
592
+ // auto-append search/relation feedback for items that were actually useful.
593
+ const sessionFeedback = {
594
+ recentSearches: [],
595
+ recentBundles: [],
596
+ runId: `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
597
+ trackSearch(query, resultIds, resultType) {
598
+ this.recentSearches.push({ query, resultIds, resultType });
599
+ if (this.recentSearches.length > 50)
600
+ this.recentSearches.shift();
601
+ },
602
+ trackBundle(targetId, itemIds) {
603
+ this.recentBundles.push({ targetId, itemIds });
604
+ if (this.recentBundles.length > 50)
605
+ this.recentBundles.shift();
606
+ }
607
+ };
608
+ /**
609
+ * Try to auto-append feedback for search results that led to successful reads.
610
+ * Called after a successful write tool (create_node, append_activity, capture_memory).
611
+ */
612
+ async function autoAppendSearchFeedback() {
613
+ // Only append for the most recent search that had results. Avoid spam by
614
+ // checking if we already appended feedback for the same search in this session.
615
+ const lastSearch = sessionFeedback.recentSearches.at(-1);
616
+ if (!lastSearch || !lastSearch.resultIds.length)
617
+ return;
618
+ try {
619
+ await apiClient.post("/search-feedback-events", {
620
+ resultType: lastSearch.resultType,
621
+ resultId: lastSearch.resultIds[0],
622
+ verdict: "useful",
623
+ query: lastSearch.query,
624
+ sessionId: sessionFeedback.runId,
625
+ confidence: 0.7
626
+ });
627
+ // Remove the search so we don't append again for the same search.
628
+ sessionFeedback.recentSearches.pop();
629
+ }
630
+ catch {
631
+ // Feedback append is best-effort — don't break the main tool call.
632
+ }
633
+ }
334
634
  const server = new McpServer({
335
635
  name: "recallx-mcp",
336
636
  version: params?.serverVersion ?? RECALLX_VERSION
337
637
  }, {
338
- instructions: "Use RecallX as a local knowledge backend. Treat the current workspace as the default scope, and do not create or open another workspace unless the user explicitly asks. When the work is clearly project-shaped, search for an existing project inside the current workspace first: prefer recallx_search_nodes with type=project, broaden with recallx_search_workspace when needed, create a project node only if no suitable one exists, and then anchor follow-up context with recallx_context_bundle targetId. Once a project is known, do not keep writing untargeted workspace captures for routine work logs: append activity to that project or pass targetNodeId on capture writes. Reserve workspace-scope inbox activity for genuinely untargeted, cross-project, or not-yet-classified short logs. If the conversation is not project-specific, keep memory at workspace scope. Prefer read tools first, and include source details on durable writes when you want caller-specific provenance.",
638
+ instructions: "Use RecallX as the default local memory layer for meaningful work, not as an afterthought. Treat the current workspace as the default scope, and do not create or open another workspace unless the user explicitly asks. Before making assumptions or starting a meaningful task, read context first: confirm the active workspace, use recallx_search_workspace as the broad default when the target is still unclear, narrow with recallx_search_nodes or recallx_search_activities when needed, and build a compact recallx_context_bundle before deep execution when a node or project is known. When the work is clearly project-shaped, search for an existing project inside the current workspace first, create one only if no suitable project exists, and then anchor follow-up context and routine logs to that project. Once a project is known, do not keep writing untargeted workspace captures for routine work logs: append activity to that project or pass targetNodeId on capture writes. Reserve workspace-scope inbox activity for genuinely untargeted, cross-project, or not-yet-classified short logs. Prefer read tools before durable writes, prefer compact context over repeated broad browsing, and write back concise summaries, decisions, or feedback when RecallX materially helped the task. Include source details on durable writes when you want caller-specific provenance. Feedback signals (search usefulness, relation usefulness) are automatically recorded on your behalf — you do NOT need to call feedback tools manually.",
339
639
  capabilities: {
340
640
  logging: {}
341
641
  }
@@ -388,20 +688,26 @@ export function createRecallXMcpServer(params) {
388
688
  includeDetails: z.boolean().optional().default(true)
389
689
  }),
390
690
  outputSchema: healthOutputSchema
391
- }, async () => toolResult(await apiClient.get("/health")));
392
- registerReadOnlyTool(server, "recallx_workspace_current", {
393
- title: "Current Workspace",
394
- description: "Read the currently active RecallX workspace and auth mode. Use this to confirm the default workspace scope before deciding whether an explicit user request justifies switching workspaces.",
395
- outputSchema: workspaceInfoSchema
396
- }, createGetToolHandler(apiClient, "/workspace"));
397
- registerReadOnlyTool(server, "recallx_workspace_list", {
398
- title: "List Workspaces",
399
- description: "List known RecallX workspaces and identify the currently active one.",
691
+ }, async () => toolResult("recallx_health", await apiClient.get("/health")));
692
+ registerReadOnlyTool(server, "recallx_workspace_info", {
693
+ title: "Workspace Information",
694
+ description: "Read the active RecallX workspace and optionally list all known workspaces in one call. **When to use:** at the start of any task to confirm scope. Do not create or open another workspace unless the user explicitly asks.",
695
+ inputSchema: {
696
+ includeList: coerceBooleanSchema(false).describe("Set true to also return all known workspaces alongside the active one.")
697
+ },
400
698
  outputSchema: z.object({
401
699
  current: workspaceInfoSchema,
402
- items: z.array(workspaceInfoSchema.extend({ isCurrent: z.boolean(), lastOpenedAt: z.string() }))
700
+ items: z.array(workspaceInfoSchema.extend({ isCurrent: z.boolean(), lastOpenedAt: z.string() })).optional()
403
701
  })
404
- }, createGetToolHandler(apiClient, "/workspaces"));
702
+ }, async ({ includeList }) => {
703
+ const current = await apiClient.get("/workspace");
704
+ const result = { current: current };
705
+ if (includeList) {
706
+ const list = await apiClient.get("/workspaces");
707
+ result.items = (list.items ?? []);
708
+ }
709
+ return toolResult("recallx_workspace_info", result);
710
+ });
405
711
  registerTool(server, "recallx_workspace_create", {
406
712
  title: "Create Workspace",
407
713
  description: "Create a RecallX workspace on disk and switch the running service to it without restarting. Only use this when the user explicitly requests creating or switching to a new workspace.",
@@ -409,17 +715,22 @@ export function createRecallXMcpServer(params) {
409
715
  rootPath: z.string().min(1).describe("Absolute or user-resolved path for the new workspace root."),
410
716
  workspaceName: z.string().min(1).optional().describe("Human-friendly workspace name.")
411
717
  }
412
- }, createPostToolHandler(apiClient, "/workspaces"));
718
+ }, createPostToolHandler("recallx_workspace_create", apiClient, "/workspaces"));
413
719
  registerTool(server, "recallx_workspace_open", {
414
720
  title: "Open Workspace",
415
721
  description: "Switch the running RecallX service to another existing workspace. Only use this when the user explicitly requests opening or switching workspaces.",
416
722
  inputSchema: {
417
723
  rootPath: z.string().min(1).describe("Existing workspace root path to open.")
418
724
  }
419
- }, createPostToolHandler(apiClient, "/workspaces/open"));
420
- registerReadOnlyTool(server, "recallx_semantic_status", {
421
- title: "Semantic Index Status",
422
- description: "Read the current semantic indexing status, provider configuration, and queued item counts.",
725
+ }, createPostToolHandler("recallx_workspace_open", apiClient, "/workspaces/open"));
726
+ registerReadOnlyTool(server, "recallx_semantic_overview", {
727
+ title: "Semantic Overview",
728
+ description: "Read semantic index status, counts, and optionally active issues in one call. **When to use:** during workspace health checks or when search results seem unexpectedly stale. Not needed for routine coding tasks.",
729
+ inputSchema: {
730
+ includeIssues: coerceBooleanSchema(false).describe("Set true to also return recent semantic indexing issues."),
731
+ issueLimit: coerceIntegerSchema(5, 1, 25).describe("Max issue items when includeIssues is true."),
732
+ issueStatuses: z.array(z.enum(["pending", "stale", "failed"])).max(3).optional().describe("Issue statuses to include.")
733
+ },
423
734
  outputSchema: z.object({
424
735
  enabled: z.boolean(),
425
736
  provider: z.string().nullable(),
@@ -432,37 +743,30 @@ export function createRecallXMcpServer(params) {
432
743
  stale: z.number(),
433
744
  ready: z.number(),
434
745
  failed: z.number()
435
- })
436
- })
437
- }, createGetToolHandler(apiClient, "/semantic/status"));
438
- registerReadOnlyTool(server, "recallx_semantic_issues", {
439
- title: "Semantic Index Issues",
440
- description: "Read semantic indexing issues with optional status filters and cursor pagination.",
441
- inputSchema: {
442
- limit: coerceIntegerSchema(5, 1, 25).describe("Maximum number of semantic issue items to return."),
443
- cursor: z.string().min(1).optional().describe("Opaque cursor from a previous semantic issues call."),
444
- statuses: z.array(z.enum(["pending", "stale", "failed"])).max(3).optional().describe("Optional issue statuses to include.")
445
- },
446
- outputSchema: z.object({
447
- items: z.array(z.object({
746
+ }),
747
+ issues: z.array(z.object({
448
748
  nodeId: z.string(),
449
749
  title: z.string().nullable(),
450
750
  embeddingStatus: z.enum(["pending", "processing", "stale", "ready", "failed"]),
451
751
  staleReason: z.string().nullable(),
452
752
  updatedAt: z.string()
453
- })),
454
- nextCursor: z.string().nullable()
753
+ })).optional(),
754
+ nextCursor: z.string().nullable().optional()
455
755
  })
456
- }, async ({ limit, cursor, statuses }) => {
457
- const params = new URLSearchParams();
458
- params.set("limit", String(limit));
459
- if (cursor) {
460
- params.set("cursor", cursor);
461
- }
462
- if (statuses?.length) {
463
- params.set("statuses", statuses.join(","));
756
+ }, async ({ includeIssues, issueLimit, issueStatuses }) => {
757
+ const status = await apiClient.get("/semantic/status");
758
+ const result = { ...status };
759
+ if (includeIssues) {
760
+ const params = new URLSearchParams();
761
+ params.set("limit", String(issueLimit));
762
+ if (issueStatuses?.length) {
763
+ params.set("statuses", issueStatuses.join(","));
764
+ }
765
+ const issuesPayload = await apiClient.get(`/semantic/issues?${params.toString()}`);
766
+ result.issues = (issuesPayload.items ?? []);
767
+ result.nextCursor = typeof issuesPayload.nextCursor === "string" ? issuesPayload.nextCursor : null;
464
768
  }
465
- return toolResult(await apiClient.get(`/semantic/issues?${params.toString()}`));
769
+ return toolResult("recallx_semantic_overview", result);
466
770
  });
467
771
  registerReadOnlyTool(server, "recallx_search_nodes", {
468
772
  title: "Search Nodes",
@@ -486,7 +790,14 @@ export function createRecallXMcpServer(params) {
486
790
  offset: coerceIntegerSchema(0, 0, 10_000),
487
791
  sort: z.enum(["relevance", "updated_at"]).default("relevance")
488
792
  }
489
- }, createNormalizedPostToolHandler(apiClient, "/nodes/search", normalizeNodeSearchInput));
793
+ }, async (input) => {
794
+ const result = await apiClient.post("/nodes/search", normalizeNodeSearchInput(input));
795
+ const items = Array.isArray(result.items) ? result.items : [];
796
+ const ids = items.filter((item) => isRecord(item) && typeof item.id === "string").map((item) => item.id);
797
+ const query = typeof input.query === "string" ? input.query : "";
798
+ sessionFeedback.trackSearch(query, ids, "node");
799
+ return toolResult("recallx_search_nodes", result);
800
+ });
490
801
  registerReadOnlyTool(server, "recallx_search_activities", {
491
802
  title: "Search Activities",
492
803
  description: "Search operational activity timelines by keyword and optional filters. Prefer this for recent logs, change history, and 'what happened recently' questions. Accepts `activityType` and `targetNodeId` aliases and normalizes single strings into arrays.",
@@ -508,7 +819,14 @@ export function createRecallXMcpServer(params) {
508
819
  offset: coerceIntegerSchema(0, 0, 10_000),
509
820
  sort: z.enum(["relevance", "updated_at"]).default("relevance")
510
821
  }
511
- }, createNormalizedPostToolHandler(apiClient, "/activities/search", normalizeActivitySearchInput));
822
+ }, async (input) => {
823
+ const result = await apiClient.post("/activities/search", normalizeActivitySearchInput(input));
824
+ const items = Array.isArray(result.items) ? result.items : [];
825
+ const ids = items.filter((item) => isRecord(item) && typeof item.id === "string").map((item) => item.id);
826
+ const query = typeof input.query === "string" ? input.query : "";
827
+ sessionFeedback.trackSearch(query, ids, "activity");
828
+ return toolResult("recallx_search_activities", result);
829
+ });
512
830
  registerReadOnlyTool(server, "recallx_search_workspace", {
513
831
  title: "Search Workspace",
514
832
  description: "Search nodes, activities, or both through one workspace-wide endpoint. This is the preferred broad entry point when the target node or request shape is still unclear, or when you need both node and activity recall in the current workspace. Use `scopes` as an array such as `[\"nodes\", \"activities\"]`, or use `scope: \"activities\"` for a single scope. Do not pass a comma-separated string like `\"nodes,activities\"`.",
@@ -538,14 +856,22 @@ export function createRecallXMcpServer(params) {
538
856
  offset: coerceIntegerSchema(0, 0, 10_000),
539
857
  sort: z.enum(["relevance", "updated_at", "smart"]).default("relevance")
540
858
  }
541
- }, createNormalizedPostToolHandler(apiClient, "/search", normalizeWorkspaceSearchInput));
859
+ }, async (input) => {
860
+ const result = await apiClient.post("/search", normalizeWorkspaceSearchInput(input));
861
+ const items = Array.isArray(result.items) ? result.items : [];
862
+ const ids = items.filter((item) => isRecord(item) && typeof (item.id ?? item.nodeId) === "string").map((item) => (item.id ?? item.nodeId));
863
+ const mixedTypes = [...new Set(items.filter((item) => isRecord(item) && typeof item.type === "string").map((item) => item.type))];
864
+ const query = typeof input.query === "string" ? input.query : "";
865
+ sessionFeedback.trackSearch(query, ids, `mixed(${mixedTypes.join(",") || "unknown"})`);
866
+ return toolResult("recallx_search_workspace", result);
867
+ });
542
868
  registerReadOnlyTool(server, "recallx_get_node", {
543
869
  title: "Get Node",
544
870
  description: "Fetch a node together with its related nodes, activities, artifacts, and provenance.",
545
871
  inputSchema: {
546
872
  nodeId: z.string().min(1).describe("Target node id.")
547
873
  }
548
- }, async ({ nodeId }) => toolResult(await apiClient.get(`/nodes/${encodeURIComponent(nodeId)}`)));
874
+ }, async ({ nodeId }) => toolResult("recallx_get_node", await apiClient.get(`/nodes/${encodeURIComponent(nodeId)}`)));
549
875
  registerReadOnlyTool(server, "recallx_get_related", {
550
876
  title: "Get Node Neighborhood",
551
877
  description: "Fetch the canonical RecallX node neighborhood with optional inferred relations.",
@@ -565,63 +891,104 @@ export function createRecallXMcpServer(params) {
565
891
  if (relationTypeFilter.length) {
566
892
  query.set("types", relationTypeFilter.join(","));
567
893
  }
568
- return toolResult(await apiClient.get(`/nodes/${encodeURIComponent(nodeId)}/neighborhood?${query.toString()}`));
894
+ return toolResult("recallx_get_related", await apiClient.get(`/nodes/${encodeURIComponent(nodeId)}/neighborhood?${query.toString()}`));
569
895
  });
570
- registerTool(server, "recallx_upsert_inferred_relation", {
571
- title: "Upsert Inferred Relation",
572
- description: "Upsert a lightweight inferred relation for retrieval, graph expansion, and later weight adjustment.",
896
+ registerTool(server, "recallx_manage_inferred_relations", {
897
+ title: "Manage Inferred Relations",
898
+ description: "Create or update inferred relations, or trigger a maintenance recompute pass. Use `action='upsert'` to add/update a single relation; use `action='recompute'` to refresh scores from usage events. **When to use:** only when you have strong evidence that two nodes are related and the system has not already inferred it (upsert), or during maintenance workflows (recompute). For routine tasks, prefer `recallx_get_related` to read existing inferred links.",
573
899
  inputSchema: {
574
- fromNodeId: z.string().min(1),
575
- toNodeId: z.string().min(1),
576
- relationType: z.enum(relationTypes),
577
- baseScore: z.number(),
578
- usageScore: z.number().default(0),
579
- finalScore: z.number(),
900
+ action: z.enum(["upsert", "recompute"]).describe("Whether to upsert a single inferred relation or trigger a recompute pass."),
901
+ fromNodeId: z.string().min(1).optional().describe("Source node for upsert action."),
902
+ toNodeId: z.string().min(1).optional().describe("Target node for upsert action."),
903
+ relationType: z.enum(relationTypes).optional().describe("Relation type for upsert."),
904
+ baseScore: z.number().optional().describe("Base confidence score for upsert."),
905
+ usageScore: z.number().default(0).describe("Usage bonus for upsert."),
906
+ finalScore: z.number().optional().describe("Combined score for upsert."),
580
907
  status: z.enum(inferredRelationStatuses).default("active"),
581
- generator: z.string().min(1).describe("Short generator label such as deterministic-linker or coaccess-pass."),
908
+ generator: z.string().min(1).optional().describe("Generator label for upsert or filter for recompute."),
582
909
  evidence: jsonRecordSchema,
583
910
  expiresAt: z.string().optional(),
584
- metadata: jsonRecordSchema
911
+ metadata: jsonRecordSchema,
912
+ relationIds: z.array(z.string().min(1)).max(200).optional().describe("Specific relation IDs to recompute."),
913
+ limit: z.number().int().min(1).max(500).default(100).describe("Max relations for recompute pass.")
585
914
  }
586
- }, createPostToolHandler(apiClient, "/inferred-relations"));
587
- registerTool(server, "recallx_append_relation_usage_event", {
588
- title: "Append Relation Usage Event",
589
- description: "Append a lightweight usage signal after a relation actually helped retrieval or final output.",
590
- inputSchema: {
591
- relationId: z.string().min(1),
592
- relationSource: z.enum(relationSources),
593
- eventType: z.enum(relationUsageEventTypes),
594
- sessionId: z.string().optional(),
595
- runId: z.string().optional(),
596
- source: sourceSchema.optional(),
597
- delta: z.number(),
598
- metadata: jsonRecordSchema
915
+ }, async (input) => {
916
+ if (input.action === "upsert") {
917
+ if (!input.fromNodeId || !input.toNodeId || !input.relationType || input.baseScore === undefined || input.finalScore === undefined) {
918
+ throw new Error("Invalid arguments for tool recallx_manage_inferred_relations: action='upsert' requires fromNodeId, toNodeId, relationType, baseScore, and finalScore.");
919
+ }
920
+ const body = {
921
+ fromNodeId: input.fromNodeId,
922
+ toNodeId: input.toNodeId,
923
+ relationType: input.relationType,
924
+ baseScore: input.baseScore,
925
+ usageScore: input.usageScore,
926
+ finalScore: input.finalScore,
927
+ status: input.status,
928
+ generator: input.generator,
929
+ evidence: input.evidence,
930
+ expiresAt: input.expiresAt,
931
+ metadata: input.metadata
932
+ };
933
+ return toolResult("recallx_manage_inferred_relations", await apiClient.post("/inferred-relations", body));
599
934
  }
600
- }, createPostToolHandler(apiClient, "/relation-usage-events"));
601
- registerTool(server, "recallx_append_search_feedback", {
602
- title: "Append Search Feedback",
603
- description: "Append a usefulness signal for a node or activity search result after it helped or failed a task.",
935
+ const body = { limit: input.limit };
936
+ if (input.relationIds?.length)
937
+ body.relationIds = input.relationIds;
938
+ if (input.generator)
939
+ body.generator = input.generator;
940
+ return toolResult("recallx_manage_inferred_relations", await apiClient.post("/inferred-relations/recompute", body));
941
+ });
942
+ registerTool(server, "recallx_append_feedback", {
943
+ title: "Append Feedback",
944
+ description: "Append a usefulness signal for search results or relation links. **Note:** this tool is normally called automatically by the MCP bridge after your task completes. Only call it directly if you want to record ad-hoc feedback during a session.",
604
945
  inputSchema: {
605
- resultType: z.enum(searchFeedbackResultTypes),
606
- resultId: z.string().min(1),
607
- verdict: z.enum(searchFeedbackVerdicts),
608
- query: z.string().optional(),
946
+ feedbackType: z.enum(["search", "relation"]).describe("Whether this is search result feedback or relation usage feedback."),
947
+ resultType: z.enum(searchFeedbackResultTypes).optional().describe("Required when feedbackType='search': 'node' or 'activity'."),
948
+ resultId: z.string().min(1).optional().describe("Required when feedbackType='search': the node or activity ID."),
949
+ verdict: z.enum(searchFeedbackVerdicts).optional().describe("Required when feedbackType='search': 'useful', 'not_useful', or 'uncertain'."),
950
+ relationId: z.string().min(1).optional().describe("Required when feedbackType='relation': the relation ID."),
951
+ relationSource: z.enum(relationSources).optional().describe("Required when feedbackType='relation': 'canonical' or 'inferred'."),
952
+ relationEventType: z.enum(relationUsageEventTypes).optional().describe("Required when feedbackType='relation': e.g. 'bundle_included', 'bundle_used_in_output'."),
953
+ query: z.string().optional().describe("Original search query for context."),
609
954
  sessionId: z.string().optional(),
610
955
  runId: z.string().optional(),
611
956
  source: sourceSchema.optional(),
612
957
  confidence: z.number().min(0).max(1).default(1),
958
+ delta: z.number().default(1).describe("Score delta for relation feedback."),
613
959
  metadata: jsonRecordSchema
614
960
  }
615
- }, createPostToolHandler(apiClient, "/search-feedback-events"));
616
- registerTool(server, "recallx_recompute_inferred_relations", {
617
- title: "Recompute Inferred Relations",
618
- description: "Run an explicit maintenance pass that refreshes inferred relation usage_score and final_score from usage events.",
619
- inputSchema: {
620
- relationIds: z.array(z.string().min(1)).max(200).optional(),
621
- generator: z.string().min(1).optional(),
622
- limit: z.number().int().min(1).max(500).default(100)
961
+ }, async (input) => {
962
+ if (input.feedbackType === "search") {
963
+ if (!input.resultType || !input.resultId || !input.verdict) {
964
+ throw new Error("Invalid arguments for tool recallx_append_feedback: feedbackType='search' requires resultType, resultId, and verdict.");
965
+ }
966
+ return toolResult("recallx_append_feedback", await apiClient.post("/search-feedback-events", {
967
+ resultType: input.resultType,
968
+ resultId: input.resultId,
969
+ verdict: input.verdict,
970
+ query: input.query,
971
+ sessionId: input.sessionId,
972
+ runId: input.runId,
973
+ source: input.source,
974
+ confidence: input.confidence,
975
+ metadata: input.metadata
976
+ }));
623
977
  }
624
- }, createPostToolHandler(apiClient, "/inferred-relations/recompute"));
978
+ if (!input.relationId || !input.relationSource || !input.relationEventType) {
979
+ throw new Error("Invalid arguments for tool recallx_append_feedback: feedbackType='relation' requires relationId, relationSource, and relationEventType.");
980
+ }
981
+ return toolResult("recallx_append_feedback", await apiClient.post("/relation-usage-events", {
982
+ relationId: input.relationId,
983
+ relationSource: input.relationSource,
984
+ eventType: input.relationEventType,
985
+ sessionId: input.sessionId,
986
+ runId: input.runId,
987
+ source: input.source,
988
+ delta: input.delta,
989
+ metadata: input.metadata
990
+ }));
991
+ });
625
992
  registerTool(server, "recallx_append_activity", {
626
993
  title: "Append Activity",
627
994
  description: "Append an activity entry to a specific RecallX node or project timeline with provenance. Use this when you already know the target node or project; otherwise prefer recallx_capture_memory for general workspace-scope updates.",
@@ -632,7 +999,7 @@ export function createRecallXMcpServer(params) {
632
999
  source: sourceSchema,
633
1000
  metadata: jsonRecordSchema
634
1001
  }
635
- }, createPostToolHandler(apiClient, "/activities"));
1002
+ }, createPostToolHandler("recallx_append_activity", apiClient, "/activities"));
636
1003
  registerTool(server, "recallx_capture_memory", {
637
1004
  title: "Capture Memory",
638
1005
  description: "Safely capture a memory item without choosing low-level storage first. Prefer this as the default write only when the conversation is not yet tied to a specific project or node. Once a project or target node is known, include targetNodeId or switch to recallx_append_activity for routine work logs. General short logs can stay at workspace scope and be auto-routed into activities, while reusable content can still land as durable memory.",
@@ -646,7 +1013,7 @@ export function createRecallXMcpServer(params) {
646
1013
  source: sourceSchema,
647
1014
  metadata: jsonRecordSchema
648
1015
  }
649
- }, createPostToolHandler(apiClient, "/capture"));
1016
+ }, createPostToolHandler("recallx_capture_memory", apiClient, "/capture"));
650
1017
  registerTool(server, "recallx_create_node", {
651
1018
  title: "Create Node",
652
1019
  description: "Create a durable RecallX node with provenance. Use this for reusable knowledge; when creating a project node in the current workspace, search first and only create one if no suitable project already exists. Short work-log updates are usually better captured with `recallx_capture_memory` or `recallx_append_activity`.",
@@ -663,7 +1030,9 @@ export function createRecallXMcpServer(params) {
663
1030
  }
664
1031
  }, async (input) => {
665
1032
  try {
666
- return toolResult(await apiClient.post("/nodes", input));
1033
+ const result = await apiClient.post("/nodes", input);
1034
+ await autoAppendSearchFeedback();
1035
+ return toolResult("recallx_create_node", result);
667
1036
  }
668
1037
  catch (error) {
669
1038
  if (error instanceof RecallXApiError &&
@@ -695,7 +1064,11 @@ export function createRecallXMcpServer(params) {
695
1064
  .min(1)
696
1065
  .max(100)
697
1066
  }
698
- }, async (input) => toolResult(await apiClient.post("/nodes/batch", input)));
1067
+ }, async (input) => {
1068
+ const result = await apiClient.post("/nodes/batch", input);
1069
+ await autoAppendSearchFeedback();
1070
+ return toolResult("recallx_create_nodes", result);
1071
+ });
699
1072
  registerTool(server, "recallx_create_relation", {
700
1073
  title: "Create Relation",
701
1074
  description: "Create a relation between two nodes. Agent-created relations typically start suggested and are promoted automatically when confidence improves.",
@@ -707,38 +1080,37 @@ export function createRecallXMcpServer(params) {
707
1080
  source: sourceSchema,
708
1081
  metadata: jsonRecordSchema
709
1082
  }
710
- }, createPostToolHandler(apiClient, "/relations"));
711
- registerReadOnlyTool(server, "recallx_list_governance_issues", {
712
- title: "List Governance Issues",
713
- description: "List contested or low-confidence governance items that may need inspection.",
1083
+ }, createPostToolHandler("recallx_create_relation", apiClient, "/relations"));
1084
+ registerReadOnlyTool(server, "recallx_governance", {
1085
+ title: "Governance",
1086
+ description: "Read governance issues, check a specific entity's state, or trigger a recompute pass. **When to use:** after creating/editing content to verify it landed in good shape, or when reviewing items flagged as contested/low_confidence. Use action='issues' (default) to list problems, action='state' to inspect one entity, or action='recompute' to refresh state.",
714
1087
  inputSchema: {
715
- states: z.array(z.enum(governanceStates)).default(["contested", "low_confidence"]),
716
- limit: z.number().int().min(1).max(100).default(20)
1088
+ action: z.enum(["issues", "state", "recompute"]).default("issues"),
1089
+ states: z.array(z.enum(governanceStates)).default(["contested", "low_confidence"]).describe("Issue states to include (for action='issues')."),
1090
+ limit: z.number().int().min(1).max(100).default(20).describe("Max issues (for action='issues') or recompute batch (for action='recompute')."),
1091
+ entityType: z.enum(["node", "relation"]).optional().describe("Required for action='state': entity type to inspect."),
1092
+ entityId: z.string().min(1).optional().describe("Required for action='state': entity ID to inspect."),
1093
+ entityIds: z.array(z.string().min(1)).max(200).optional().describe("Specific entity IDs to recompute (for action='recompute').")
1094
+ }
1095
+ }, async ({ action, states, limit, entityType, entityId, entityIds }) => {
1096
+ if (action === "state") {
1097
+ if (!entityType || !entityId) {
1098
+ throw new Error("Invalid arguments for tool recallx_governance: action='state' requires entityType and entityId.");
1099
+ }
1100
+ return toolResult("recallx_governance", await apiClient.get(`/governance/state/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`));
1101
+ }
1102
+ if (action === "recompute") {
1103
+ const body = { limit };
1104
+ if (entityIds?.length)
1105
+ body.entityIds = entityIds;
1106
+ return toolResult("recallx_governance", await apiClient.post("/governance/recompute", body));
717
1107
  }
718
- }, async ({ states, limit }) => {
719
1108
  const query = new URLSearchParams({
720
1109
  states: states.join(","),
721
1110
  limit: String(limit)
722
1111
  });
723
- return toolResult(await apiClient.get(`/governance/issues?${query.toString()}`));
1112
+ return toolResult("recallx_governance", await apiClient.get(`/governance/issues?${query.toString()}`));
724
1113
  });
725
- registerReadOnlyTool(server, "recallx_get_governance_state", {
726
- title: "Get Governance State",
727
- description: "Read the current automatic governance state and recent events for a node or relation.",
728
- inputSchema: {
729
- entityType: z.enum(["node", "relation"]),
730
- entityId: z.string().min(1)
731
- }
732
- }, async ({ entityType, entityId }) => toolResult(await apiClient.get(`/governance/state/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`)));
733
- registerTool(server, "recallx_recompute_governance", {
734
- title: "Recompute Governance",
735
- description: "Run a bounded automatic governance recompute pass for nodes, relations, or both.",
736
- inputSchema: {
737
- entityType: z.enum(["node", "relation"]).optional(),
738
- entityIds: z.array(z.string().min(1)).max(200).optional(),
739
- limit: z.number().int().min(1).max(500).default(100)
740
- }
741
- }, createPostToolHandler(apiClient, "/governance/recompute"));
742
1114
  registerReadOnlyTool(server, "recallx_context_bundle", {
743
1115
  title: "Build Context Bundle",
744
1116
  description: "Build a compact RecallX context bundle for coding, research, writing, or decision support. Omit targetId to get a workspace-entry bundle when the work is not yet tied to a specific project or node, and add targetId only after you know which project or node should anchor the context.",
@@ -766,30 +1138,35 @@ export function createRecallXMcpServer(params) {
766
1138
  maxItems: 10
767
1139
  })
768
1140
  }
769
- }, async ({ targetId, ...input }) => toolResult(await apiClient.post("/context/bundles", {
770
- ...input,
771
- ...(targetId
772
- ? {
773
- target: {
774
- id: targetId
1141
+ }, async ({ targetId, ...input }) => {
1142
+ const result = await apiClient.post("/context/bundles", {
1143
+ ...input,
1144
+ ...(targetId
1145
+ ? {
1146
+ target: {
1147
+ id: targetId
1148
+ }
775
1149
  }
776
- }
777
- : {})
778
- })));
1150
+ : {})
1151
+ });
1152
+ const items = Array.isArray(result.items) ? result.items : [];
1153
+ const ids = items.filter((item) => isRecord(item) && typeof item.id === "string").map((item) => item.id);
1154
+ sessionFeedback.trackBundle(targetId, ids);
1155
+ return toolResult("recallx_context_bundle", result);
1156
+ });
779
1157
  registerTool(server, "recallx_semantic_reindex", {
780
- title: "Queue Semantic Reindex",
781
- description: "Queue semantic reindexing for a bounded set of recent active workspace nodes.",
1158
+ title: "Semantic Reindex",
1159
+ description: "Queue semantic reindexing for recent workspace nodes or a specific node. **When to use:** after editing node content that needs updated embeddings, or when semantic search results seem stale. Omit nodeId to reindex recent nodes.",
782
1160
  inputSchema: {
783
- limit: coerceIntegerSchema(250, 1, 1000)
1161
+ nodeId: z.string().min(1).optional().describe("If provided, reindex only this specific node. Otherwise, reindex recent active nodes."),
1162
+ limit: coerceIntegerSchema(250, 1, 1000).describe("Max nodes to reindex (ignored when nodeId is provided).")
784
1163
  }
785
- }, createPostToolHandler(apiClient, "/semantic/reindex"));
786
- registerTool(server, "recallx_semantic_reindex_node", {
787
- title: "Queue Node Semantic Reindex",
788
- description: "Queue semantic reindexing for a specific node id.",
789
- inputSchema: {
790
- nodeId: z.string().min(1)
1164
+ }, async ({ nodeId, limit }) => {
1165
+ if (nodeId) {
1166
+ return toolResult("recallx_semantic_reindex", await apiClient.post(`/semantic/reindex/${encodeURIComponent(nodeId)}`, {}));
791
1167
  }
792
- }, async ({ nodeId }) => toolResult(await apiClient.post(`/semantic/reindex/${encodeURIComponent(nodeId)}`, {})));
1168
+ return toolResult("recallx_semantic_reindex", await apiClient.post("/semantic/reindex", { limit }));
1169
+ });
793
1170
  registerReadOnlyTool(server, "recallx_rank_candidates", {
794
1171
  title: "Rank Candidate Nodes",
795
1172
  description: "Rank a bounded set of candidate node ids for a target using RecallX request-time retrieval scoring.",
@@ -799,6 +1176,6 @@ export function createRecallXMcpServer(params) {
799
1176
  preset: bundlePresetSchema("for-assistant"),
800
1177
  targetNodeId: z.string().optional()
801
1178
  }
802
- }, createPostToolHandler(apiClient, "/retrieval/rank-candidates"));
1179
+ }, createPostToolHandler("recallx_rank_candidates", apiClient, "/retrieval/rank-candidates"));
803
1180
  return server;
804
1181
  }