@cairnkeep/cli 2.7.0 → 2.9.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +51 -2
  3. package/bin/cairn +19 -1
  4. package/docs/context-packs.md +94 -0
  5. package/docs/design/meta-agent.md +39 -35
  6. package/docs/harness-compatibility.md +7 -1
  7. package/docs/learning/CURRICULUM-MAP.md +14 -4
  8. package/docs/learning/FEATURE-GUIDE.md +11 -4
  9. package/docs/learning/README.md +8 -2
  10. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  11. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  12. package/docs/learning/lessons/L02-installation.md +2 -2
  13. package/docs/learning/lessons/L03-first-project.md +1 -1
  14. package/docs/learning/lessons/L17-agent-boundary.md +12 -8
  15. package/docs/learning/lessons/L18-local-code-graph.md +97 -0
  16. package/docs/learning/lessons/L19-validated-skill-improvement.md +118 -0
  17. package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
  18. package/docs/learning/lessons/L21-context-packs.md +79 -0
  19. package/docs/learning/tracks/evidence-and-evaluation.md +5 -3
  20. package/docs/learning/tracks/operator.md +4 -2
  21. package/docs/learning/tracks/practitioner.md +5 -4
  22. package/docs/mcp-tool-profiles.md +43 -0
  23. package/docs/operating.md +61 -0
  24. package/docs/privacy-and-data-flow.md +59 -1
  25. package/docs/skill-improvement.md +124 -0
  26. package/docs/storage.md +42 -0
  27. package/examples/eval/bundled-fake.json +1 -1
  28. package/mcp-memory-server/dist/context-pack-cli.js +185 -0
  29. package/mcp-memory-server/dist/context-pack.js +830 -0
  30. package/mcp-memory-server/dist/eval-plan.js +22 -5
  31. package/mcp-memory-server/dist/eval-process.js +31 -0
  32. package/mcp-memory-server/dist/eval-report.js +1 -0
  33. package/mcp-memory-server/dist/eval-runner.js +12 -4
  34. package/mcp-memory-server/dist/eval-schema.js +2 -1
  35. package/mcp-memory-server/dist/eval-workspace.js +50 -1
  36. package/mcp-memory-server/dist/index.js +68 -17
  37. package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
  38. package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
  39. package/mcp-memory-server/dist/mcp-tool-profile.js +162 -0
  40. package/mcp-memory-server/dist/skill-cli.js +236 -0
  41. package/mcp-memory-server/dist/skill-evaluation.js +244 -0
  42. package/mcp-memory-server/dist/skill-schema.js +164 -0
  43. package/mcp-memory-server/dist/skill-store.js +583 -0
  44. package/mcp-memory-server/package.json +4 -1
  45. package/package.json +1 -1
  46. package/schemas/context-pack.schema.json +33 -0
  47. package/schemas/eval-report.schema.json +2 -1
  48. package/schemas/skill-adapter.schema.json +39 -0
  49. package/schemas/skill-proposal-protocol.schema.json +97 -0
  50. package/scripts/completion.sh +62 -5
  51. package/scripts/doctor.sh +39 -0
  52. package/scripts/test-cli-dispatch.sh +6 -0
  53. package/scripts/test-completion.sh +16 -0
  54. package/scripts/test-learning-docs.sh +13 -3
  55. package/scripts/test-package-install.sh +35 -0
  56. package/scripts/test-skill-cli.sh +30 -0
  57. package/scripts/test-uninstall.sh +21 -0
  58. package/scripts/uninstall.sh +26 -1
  59. package/templates/env.example.template +11 -0
  60. package/templates/graph-policy.md.template +3 -1
@@ -186,7 +186,7 @@ function validateBundledBinding(taskSetPath, taskSet, taskSetBytes) {
186
186
  binding_path: realpathSync(bindingPath),
187
187
  };
188
188
  }
189
- function validateArms(arms) {
189
+ function validateArms(arms, experimentKind) {
190
190
  if (arms.length < 1 || arms.length > 2)
191
191
  throw new Error("Evaluation requires one or two arms.");
192
192
  const seen = new Set();
@@ -201,8 +201,12 @@ function validateArms(arms) {
201
201
  }
202
202
  if (arm.id === "baseline" && arm.disabled_capability !== null)
203
203
  throw new Error("Baseline cannot disable a capability.");
204
- if (arm.id === "treatment" && arm.disabled_capability === null)
205
- throw new Error("Treatment must disable exactly one capability.");
204
+ if (arm.id === "treatment" && experimentKind === "ablation" && arm.disabled_capability === null) {
205
+ throw new Error("Ablation treatment must disable exactly one capability.");
206
+ }
207
+ if (experimentKind === "skill_candidate" && arm.disabled_capability !== null) {
208
+ throw new Error("Skill-candidate arms cannot disable capabilities.");
209
+ }
206
210
  return { id: arm.id, disabled_capability: arm.disabled_capability };
207
211
  });
208
212
  }
@@ -214,7 +218,10 @@ export function isEvalEnabled(value = process.env.CAIRN_EVAL) {
214
218
  }
215
219
  export function buildEvalSchedule(options) {
216
220
  const taskSet = evalTaskSetSchema.parse(options.taskSet);
217
- const arms = validateArms(options.arms);
221
+ const inferredKind = options.arms.length === 1 ? "two_pass" : options.arms.some(({ disabled_capability }) => disabled_capability !== null)
222
+ ? "ablation"
223
+ : "skill_candidate";
224
+ const arms = validateArms(options.arms, inferredKind);
218
225
  if (!Number.isSafeInteger(options.repetitions) || options.repetitions < 1 || options.repetitions > MAX_REPETITIONS) {
219
226
  throw new Error(`Evaluation repetitions must be an integer from 1 to ${MAX_REPETITIONS}.`);
220
227
  }
@@ -282,7 +289,15 @@ export function validateEvalInputs(options) {
282
289
  }
283
290
  const repetitions = options.repetitions ?? 1;
284
291
  const seed = options.seed ?? "cairn-eval-v1";
285
- const arms = validateArms(options.arms ?? [{ id: "baseline", disabled_capability: null }]);
292
+ const suppliedArms = options.arms ?? [{ id: "baseline", disabled_capability: null }];
293
+ const experimentKind = options.experimentKind
294
+ ?? (suppliedArms.length === 1 ? "two_pass" : "ablation");
295
+ if (experimentKind === "two_pass" && suppliedArms.length !== 1)
296
+ throw new Error("Two-pass evaluation requires one arm.");
297
+ if ((experimentKind === "ablation" || experimentKind === "skill_candidate") && suppliedArms.length !== 2) {
298
+ throw new Error(`${experimentKind} evaluation requires two arms.`);
299
+ }
300
+ const arms = validateArms(suppliedArms, experimentKind);
286
301
  const passes = ["run1", "run2"];
287
302
  const schedule = buildEvalSchedule({ taskSet, arms, repetitions, passes, seed });
288
303
  const adapterBase = dirname(adapterInput.path);
@@ -304,6 +319,7 @@ export function validateEvalInputs(options) {
304
319
  repetitions,
305
320
  seed,
306
321
  arms,
322
+ experiment_kind: experimentKind,
307
323
  passes,
308
324
  concurrency: 1,
309
325
  task_set_commit: taskSetCommit,
@@ -320,6 +336,7 @@ export function validateEvalInputs(options) {
320
336
  repetitions,
321
337
  seed,
322
338
  arms,
339
+ experiment_kind: experimentKind,
323
340
  passes: ["run1", "run2"],
324
341
  concurrency: 1,
325
342
  invocation_count: schedule.invocation_count,
@@ -246,6 +246,37 @@ function hasTrailingJsonDocument(value) {
246
246
  }
247
247
  return false;
248
248
  }
249
+ export async function runBoundedJsonProcess(options) {
250
+ const request = options.request_schema.safeParse(options.request);
251
+ if (!request.success)
252
+ throw new EvalProcessError("invalid_request");
253
+ const commandResult = await runCommandInternal({
254
+ command: options.command,
255
+ cwd: options.cwd,
256
+ env: options.env,
257
+ timeout_ms: options.timeout_ms,
258
+ max_stdout_bytes: options.max_stdout_bytes,
259
+ kill_grace_ms: options.kill_grace_ms,
260
+ signal: options.signal,
261
+ stdout_mode: "raw",
262
+ stdin_document: Buffer.from(JSON.stringify(request.data), "utf8"),
263
+ });
264
+ if (commandResult.exit_code !== 0 || commandResult.signal !== null || commandResult.cleanup !== "closed") {
265
+ throw new EvalProcessError("adapter_error", commandResult);
266
+ }
267
+ let parsed;
268
+ try {
269
+ parsed = JSON.parse(commandResult.stdout ?? "");
270
+ }
271
+ catch {
272
+ throw new EvalProcessError(hasTrailingJsonDocument(commandResult.stdout ?? "") ? "multiple_json" : "invalid_json", commandResult);
273
+ }
274
+ const result = options.result_schema.safeParse(parsed);
275
+ if (!result.success)
276
+ throw new EvalProcessError("invalid_result", commandResult);
277
+ const { stdout: _stdout, ...processResult } = commandResult;
278
+ return { ...processResult, result: result.data };
279
+ }
249
280
  export async function runBoundedJsonAdapter(options) {
250
281
  const request = evalAdapterRequestSchema.safeParse(options.request);
251
282
  if (!request.success)
@@ -578,6 +578,7 @@ export function renderEvalReport(value) {
578
578
  `Cairn evaluation report: ${report.experiment_id}`,
579
579
  `status: ${report.status}`,
580
580
  `evidence: ${report.evidence.evidence_scope === "offline-framework" ? "framework-only" : "live-evaluation"}`,
581
+ `MCP tool profile digest: ${report.evidence.mcp_tool_profile_digest ?? "not-recorded"}`,
581
582
  "All effects are estimates; small samples, missing intervals, and intervals crossing zero are inconclusive.",
582
583
  "",
583
584
  "Aggregates",
@@ -9,7 +9,8 @@ import { CAPABILITY_IDS, capabilityStatusSchema, } from "./capability-schema.js"
9
9
  import { canonicalDigest, evalReportSchema, evalReportRuntimeContractDigest, } from "./eval-schema.js";
10
10
  import { EvalProcessError, runBoundedCommand, runBoundedJsonAdapter, } from "./eval-process.js";
11
11
  import { checkpointEvalReport, createEvalReportStore, } from "./eval-report.js";
12
- import { cleanupEvalWorkspace, createEvalWorkspace, runTaskPreparation, runTaskVerifier, } from "./eval-workspace.js";
12
+ import { applyEvalWorkspaceOverlay, cleanupEvalWorkspace, createEvalWorkspace, runTaskPreparation, runTaskVerifier, } from "./eval-workspace.js";
13
+ import { resolveMcpToolProfile } from "./mcp-tool-profile.js";
13
14
  const moduleDirectory = dirname(fileURLToPath(import.meta.url));
14
15
  const publishedReportSchemaPath = resolve(moduleDirectory, "..", "..", "schemas", "eval-report.schema.json");
15
16
  let reportSchemaDigestsCache;
@@ -557,6 +558,8 @@ export async function runEvalObservation(options) {
557
558
  observation.missing_reasons.push("preparation_failed");
558
559
  }
559
560
  else {
561
+ if (options.workspace_overlay)
562
+ await applyEvalWorkspaceOverlay(workspace, options.workspace_overlay);
560
563
  const request = {
561
564
  schema_version: 1,
562
565
  experiment_id: options.report.experiment_id,
@@ -698,7 +701,7 @@ export async function runEvalObservation(options) {
698
701
  await checkpoint(options.report_store, options.report);
699
702
  return { observation, snapshot: distilledSnapshot, cancelled: observation.terminal_state === "cancelled" };
700
703
  }
701
- function initialReport(plan, experimentId) {
704
+ function initialReport(plan, experimentId, fallbackExperimentKind) {
702
705
  const now = new Date().toISOString();
703
706
  const sourceCommit = plan.task_set_commit ?? (plan.source.kind === "git" ? plan.source.revision : plan.task_set_digest);
704
707
  const missingness = { count: 0, reasons: [] };
@@ -706,7 +709,7 @@ function initialReport(plan, experimentId) {
706
709
  schema_version: 1,
707
710
  experiment_id: experimentId,
708
711
  status: "partial",
709
- experiment_kind: plan.arms.length === 1 ? "two_pass" : "ablation",
712
+ experiment_kind: plan.experiment_kind ?? fallbackExperimentKind,
710
713
  task_set_digest: plan.task_set_digest,
711
714
  adapter_config_digest: plan.adapter_config_digest,
712
715
  source_revision: plan.source.kind === "git" ? plan.source.revision : plan.task_set_digest,
@@ -733,6 +736,7 @@ function initialReport(plan, experimentId) {
733
736
  package_version: plan.source.kind === "bundled_fake" ? plan.source.package_version : "0.1.0",
734
737
  runtime_id: "node-local",
735
738
  task_set_digest: plan.task_set_digest,
739
+ mcp_tool_profile_digest: resolveMcpToolProfile({ projectRoot: process.cwd() }).profile_digest,
736
740
  report_digest: canonicalDigest({ experiment_id: experimentId, plan_digest: plan.plan_digest }),
737
741
  schema_digests: [...evalReportSchemaDigests()],
738
742
  note_snapshot_digests: [],
@@ -750,7 +754,7 @@ async function runExperiment(options, capabilityArms) {
750
754
  });
751
755
  if (reportStore.experiment_id !== experimentId)
752
756
  throw new Error("report_experiment_mismatch");
753
- const report = initialReport(options.plan, experimentId);
757
+ const report = initialReport(options.plan, experimentId, capabilityArms ? "ablation" : "two_pass");
754
758
  const snapshotsByTask = new Map();
755
759
  const notesByTask = new Map();
756
760
  const snapshots = [];
@@ -782,6 +786,7 @@ async function runExperiment(options, capabilityArms) {
782
786
  distill_command: options.distill_command,
783
787
  distill_timeout_ms: options.distill_timeout_ms,
784
788
  capability_runtime: capabilityRuntime,
789
+ workspace_overlay: options.workspace_overlays?.[row.arm],
785
790
  });
786
791
  executed.push(row.observation_id);
787
792
  if (result.snapshot) {
@@ -813,6 +818,9 @@ export async function runTwoPassExperiment(options) {
813
818
  return runExperiment(options);
814
819
  }
815
820
  export async function runCapabilityAblation(options) {
821
+ if (options.plan.experiment_kind !== undefined && options.plan.experiment_kind !== "ablation") {
822
+ throw new Error("ablation_experiment_kind_required");
823
+ }
816
824
  const arms = buildAblationArms(options.disabled_capability);
817
825
  const intended = arms.map(({ id, disabled_capability }) => ({ id, disabled_capability }));
818
826
  if (canonicalDigest(options.plan.arms) !== canonicalDigest(intended))
@@ -13,7 +13,7 @@ export const EVAL_OBSERVATION_TERMINAL_STATES = [
13
13
  "invalid_result",
14
14
  ];
15
15
  export const EVAL_PASS_STATES = ["passed", "failed", "unknown"];
16
- export const EVAL_EXPERIMENT_KINDS = ["two_pass", "ablation"];
16
+ export const EVAL_EXPERIMENT_KINDS = ["two_pass", "ablation", "skill_candidate"];
17
17
  const MAX_TEXT_BYTES = 1024 * 1024;
18
18
  const MAX_COMMAND_ARGS = 256;
19
19
  const MAX_TASKS = 10_000;
@@ -309,6 +309,7 @@ const evidenceProvenanceSchema = z.strictObject({
309
309
  package_version: z.string().min(1).max(128),
310
310
  runtime_id: identifierSchema,
311
311
  task_set_digest: digestSchema,
312
+ mcp_tool_profile_digest: digestSchema.optional(),
312
313
  report_digest: digestSchema,
313
314
  schema_digests: z.array(digestSchema).length(2),
314
315
  note_snapshot_digests: z.array(digestSchema).max(MAX_TASKS),
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { constants, existsSync } from "node:fs";
2
3
  import { chmod, lstat, mkdir, mkdtemp, open, readFile, realpath, writeFile, } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
@@ -155,7 +156,7 @@ export async function createEvalWorkspace(options) {
155
156
  };
156
157
  try {
157
158
  if (options.plan.source.kind === "git") {
158
- const added = await gitCommand(options.plan.source.repository_root, ["worktree", "add", "--detach", workspace.source_path, options.plan.source.revision], "exit-only");
159
+ const added = await gitCommand(options.plan.source.repository_root, ["worktree", "add", "--quiet", "--detach", workspace.source_path, options.plan.source.revision], "exit-only");
159
160
  requireSuccessfulCommand(added, "workspace_add_failed");
160
161
  workspace.worktree_registered = true;
161
162
  await verifyGitWorkspace(workspace);
@@ -192,6 +193,54 @@ export async function runTaskPreparation(workspace, options = {}) {
192
193
  signal: options.signal,
193
194
  });
194
195
  }
196
+ export async function applyEvalWorkspaceOverlay(workspace, overlay) {
197
+ const segments = overlay.relative_path.split("/");
198
+ if (overlay.relative_path.startsWith("/") || overlay.relative_path.endsWith("/")
199
+ || overlay.relative_path.includes("\\") || segments.some((segment) => segment === "" || segment === "." || segment === "..")
200
+ || /[\u0000-\u001f\u007f]/.test(overlay.relative_path)) {
201
+ throw new Error("workspace_overlay_path_invalid");
202
+ }
203
+ const target = resolve(workspace.source_path, overlay.relative_path);
204
+ if (!isContained(workspace.source_path, target) || target === workspace.source_path) {
205
+ throw new Error("workspace_overlay_path_escape");
206
+ }
207
+ let cursor = workspace.source_path;
208
+ for (const segment of segments.slice(0, -1)) {
209
+ cursor = join(cursor, segment);
210
+ try {
211
+ const info = await lstat(cursor);
212
+ if (info.isSymbolicLink() || !info.isDirectory())
213
+ throw new Error("workspace_overlay_parent_unsafe");
214
+ }
215
+ catch (error) {
216
+ if (error.code !== "ENOENT")
217
+ throw error;
218
+ await mkdir(cursor, { mode: 0o700 });
219
+ }
220
+ }
221
+ try {
222
+ const info = await lstat(target);
223
+ if (info.isSymbolicLink() || !info.isFile())
224
+ throw new Error("workspace_overlay_target_unsafe");
225
+ }
226
+ catch (error) {
227
+ if (error.code !== "ENOENT")
228
+ throw error;
229
+ }
230
+ const parent = await realpath(dirname(target));
231
+ const source = await realpath(workspace.source_path);
232
+ if (!isContained(source, parent))
233
+ throw new Error("workspace_overlay_parent_escape");
234
+ const bytes = Buffer.from(overlay.content, "utf8");
235
+ const digest = createHash("sha256").update(bytes).digest("hex");
236
+ if (digest !== overlay.digest)
237
+ throw new Error("workspace_overlay_digest_mismatch");
238
+ await writeFile(target, bytes, { mode: 0o600 });
239
+ await chmod(target, 0o600);
240
+ const storedDigest = createHash("sha256").update(await readFile(target)).digest("hex");
241
+ if (storedDigest !== overlay.digest)
242
+ throw new Error("workspace_overlay_write_mismatch");
243
+ }
195
244
  function verifierReason(code) {
196
245
  return `verifier_${code}`;
197
246
  }
@@ -17,6 +17,9 @@ import { EmbeddingCache, cosineSimilarity, embedTexts, getEmbeddingConfig, hashT
17
17
  import { computeRepoState, exploreCacheKey, normalizeExploreQuery, readExploreCache, writeExploreCache, } from "./explore-cache.js";
18
18
  import { canonicalTagsSchema, isTypedMemoryNodesEnabled, memoryImportEnvelopeSchema, noteNodeSchema, nodeTypeSchema, } from "./node-schema.js";
19
19
  import { attachNodeMetadata, applyReviewedTypedNode, commitMemoryImport, createTypedNode, deleteTypedNode, getTypedNode, invalidateReviewedTypedNode, listTypedHistory, planMemoryImport, supersedeTypedNode, } from "./node-store.js";
20
+ import { contextPackHttpEnabled, contextPacksEnabled, listVisibleContext, readVisibleContext, searchVisibleContext } from "./context-pack.js";
21
+ import { metadataForTool } from "./mcp-tool-catalog.js";
22
+ import { profileAllowsTool, resolveMcpToolProfile } from "./mcp-tool-profile.js";
20
23
  const MINIMUM_NODE_MAJOR = 22;
21
24
  const nodeMajor = Number.parseInt(process.versions.node, 10);
22
25
  if (!Number.isInteger(nodeMajor) || nodeMajor < MINIMUM_NODE_MAJOR) {
@@ -822,8 +825,18 @@ function gitToplevel(cwd) {
822
825
  // only allows one connected transport per server). All instances share the
823
826
  // module-level helpers + AgentFS below. Enables a single long-lived process to
824
827
  // serve many concurrent clients within one trusted server-side storage domain.
825
- function buildMemoryServer(context, capabilitySnapshot, withCapability) {
828
+ function buildMemoryServer(context, capabilitySnapshot, withCapability, toolProfile = resolveMcpToolProfile({ projectRoot: process.cwd() })) {
826
829
  const server = new McpServer({ name: "cairn-memory", version: "0.1.0" });
830
+ const registerTool = (name, config, callback) => {
831
+ const metadata = metadataForTool(name);
832
+ if (!profileAllowsTool(toolProfile, name))
833
+ return;
834
+ server.registerTool(name, {
835
+ ...config,
836
+ title: metadata.title,
837
+ annotations: metadata.annotations,
838
+ }, callback);
839
+ };
827
840
  const capabilityEnabled = (id) => capabilitySnapshot?.capabilities.find((state) => state.id === id)?.enabled ?? true;
828
841
  const registerCapabilityTool = (id, name, config, callback) => {
829
842
  if (!capabilityEnabled(id))
@@ -840,15 +853,22 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
840
853
  },
841
854
  }, callback)
842
855
  : callback;
843
- server.registerTool(name, config, handler);
856
+ registerTool(name, config, handler);
844
857
  };
845
858
  const memoryConfig = () => context.memoryConfig ?? getMemoryConfig();
846
859
  const scopeOptions = { projectId: context.projectId };
847
860
  const typedNodesEnabled = isTypedMemoryNodesEnabled();
848
861
  const artifactToolsEnabled = isArtifactStoreEnabled() && (!context.remote || isArtifactHttpEnabled());
862
+ const contextPackToolsEnabled = contextPacksEnabled() && (!context.remote || contextPackHttpEnabled());
849
863
  if (artifactToolsEnabled && context.remote && !context.projectId) {
850
864
  throw new ClientContextError("Remote artifact access requires X-Cairn-Project.");
851
865
  }
866
+ if (contextPackToolsEnabled && context.remote && !context.projectId) {
867
+ throw new ClientContextError("Remote context pack access requires X-Cairn-Project.");
868
+ }
869
+ const contextPackProject = context.remote
870
+ ? { projectId: context.projectId }
871
+ : { projectRoot: process.cwd() };
852
872
  let fallbackArtifactSessionRef;
853
873
  const resolveArtifactSessionRef = (sessionRef) => {
854
874
  if (sessionRef)
@@ -892,7 +912,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
892
912
  generatedFileInputContentSchema,
893
913
  ]),
894
914
  }).strict();
895
- server.registerTool("artifact_write", {
915
+ registerTool("artifact_write", {
896
916
  description: "Write one bounded inline artifact to the immutable project artifact store.",
897
917
  inputSchema: artifactWriteToolSchema,
898
918
  annotations: {
@@ -920,7 +940,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
920
940
  structuredContent: payload,
921
941
  };
922
942
  });
923
- server.registerTool("artifact_read", {
943
+ registerTool("artifact_read", {
924
944
  description: "Read one artifact by exact ID or an unambiguous ID prefix.",
925
945
  inputSchema: z.object({ artifact_id: artifactIdentifierSchema }).strict(),
926
946
  annotations: {
@@ -936,7 +956,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
936
956
  structuredContent: payload,
937
957
  };
938
958
  });
939
- server.registerTool("artifact_list", {
959
+ registerTool("artifact_list", {
940
960
  description: "List artifact metadata newest first with exact filters and opaque pagination.",
941
961
  inputSchema: z.object({
942
962
  session_ref: artifactSessionRefSchema.optional(),
@@ -976,7 +996,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
976
996
  structuredContent: payload,
977
997
  };
978
998
  });
979
- server.registerTool("artifact_delete", {
999
+ registerTool("artifact_delete", {
980
1000
  description: "Hard-delete one artifact by exact ID or an unambiguous ID prefix.",
981
1001
  inputSchema: z.object({ artifact_id: artifactIdentifierSchema }).strict(),
982
1002
  annotations: {
@@ -1013,7 +1033,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1013
1033
  };
1014
1034
  });
1015
1035
  }
1016
- server.registerTool("memory_read", {
1036
+ registerTool("memory_read", {
1017
1037
  description: "Read an exact key or search memory entries across AgentFS scopes.",
1018
1038
  // Plain object schema — a .refine() wrapper (ZodEffects) makes the SDK
1019
1039
  // publish an empty JSON Schema, hiding the parameters from clients.
@@ -1151,7 +1171,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1151
1171
  structuredContent: payload,
1152
1172
  };
1153
1173
  });
1154
- server.registerTool("memory_list", {
1174
+ registerTool("memory_list", {
1155
1175
  description: "List keys from a scoped AgentFS database.",
1156
1176
  inputSchema: z.object({
1157
1177
  scope: z.string(),
@@ -1189,7 +1209,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1189
1209
  structuredContent: typedNodesEnabled ? { keys, nodes } : { keys },
1190
1210
  };
1191
1211
  });
1192
- server.registerTool("memory_delete", {
1212
+ registerTool("memory_delete", {
1193
1213
  description: "Delete a key from a scoped AgentFS database.",
1194
1214
  inputSchema: z.object({
1195
1215
  scope: z.string(),
@@ -1273,7 +1293,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1273
1293
  structuredContent: payload,
1274
1294
  };
1275
1295
  });
1276
- server.registerTool("memory_extract", {
1296
+ registerTool("memory_extract", {
1277
1297
  description: "Extract durable memory candidates from a session summary or selected text. Review the returned candidates before writing them.",
1278
1298
  inputSchema: z.object({
1279
1299
  scope: z.string(),
@@ -1294,7 +1314,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1294
1314
  structuredContent: payload,
1295
1315
  };
1296
1316
  });
1297
- server.registerTool("memory_supersede", {
1317
+ registerTool("memory_supersede", {
1298
1318
  description: "Preserve the current value of a memory entry in hidden history, then write a new live value to the base key.",
1299
1319
  inputSchema: z.object({
1300
1320
  scope: z.string(),
@@ -1380,7 +1400,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1380
1400
  await agent.close();
1381
1401
  }
1382
1402
  });
1383
- server.registerTool("memory_apply_reviewed", {
1403
+ registerTool("memory_apply_reviewed", {
1384
1404
  description: "Idempotently apply a human-reviewed memory revision with durable provenance.",
1385
1405
  inputSchema: z.object({
1386
1406
  scope: z.string(),
@@ -1484,7 +1504,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1484
1504
  await agent.close();
1485
1505
  }
1486
1506
  });
1487
- server.registerTool("memory_invalidate_reviewed", {
1507
+ registerTool("memory_invalidate_reviewed", {
1488
1508
  description: "Invalidate a reviewed revision, removing it only when the live value still matches.",
1489
1509
  inputSchema: z.object({
1490
1510
  scope: z.string(),
@@ -1609,7 +1629,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1609
1629
  await agent.close();
1610
1630
  }
1611
1631
  });
1612
- server.registerTool("memory_history", {
1632
+ registerTool("memory_history", {
1613
1633
  description: "Read prior versions of a memory entry from the hidden history namespace.",
1614
1634
  inputSchema: z.object({
1615
1635
  scope: z.string(),
@@ -1678,7 +1698,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1678
1698
  };
1679
1699
  });
1680
1700
  if (typedNodesEnabled) {
1681
- server.registerTool("memory_import", {
1701
+ registerTool("memory_import", {
1682
1702
  description: "Validate, plan, and atomically import a bounded batch of typed memory nodes.",
1683
1703
  inputSchema: memoryImportEnvelopeSchema,
1684
1704
  annotations: {
@@ -1730,7 +1750,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1730
1750
  };
1731
1751
  });
1732
1752
  }
1733
- server.registerTool("domain_knowledge_query", {
1753
+ registerTool("domain_knowledge_query", {
1734
1754
  description: "Query an AnythingLLM workspace in query mode for domain knowledge.",
1735
1755
  inputSchema: z.object({
1736
1756
  workspace: z.string().min(1).optional(),
@@ -1750,7 +1770,7 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1750
1770
  structuredContent: { workspace: workspaceSlug, answer },
1751
1771
  };
1752
1772
  });
1753
- server.registerTool("domain_knowledge_sync", {
1773
+ registerTool("domain_knowledge_sync", {
1754
1774
  description: "Upload and embed configured project documentation into an AnythingLLM workspace. Uses anythingllm-projects.json so include/exclude rules are honored. Use mode='replace' with confirm_replace=true when stale workspace docs must be removed before re-embedding.",
1755
1775
  inputSchema: z.object({
1756
1776
  workspace: z.string().min(1).optional(),
@@ -1879,6 +1899,37 @@ function buildMemoryServer(context, capabilitySnapshot, withCapability) {
1879
1899
  const payload = { ok: true, status: body.status, cluster_healthy: body.cluster_healthy ?? null };
1880
1900
  return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1881
1901
  });
1902
+ if (contextPackToolsEnabled) {
1903
+ registerTool("context_pack_list", {
1904
+ description: "List documents and explicitly approved skills from context packs enabled for this project.",
1905
+ inputSchema: z.object({}).strict(),
1906
+ }, async () => {
1907
+ const payload = await listVisibleContext(contextPackProject);
1908
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1909
+ });
1910
+ registerTool("context_pack_search", {
1911
+ description: "Search documents and explicitly approved skills in context packs enabled for this project.",
1912
+ inputSchema: z.object({
1913
+ query: z.string().min(1).max(4096),
1914
+ limit: z.number().int().min(1).max(100).optional(),
1915
+ }).strict(),
1916
+ }, async ({ query, limit }) => {
1917
+ const payload = await searchVisibleContext(query, { ...contextPackProject, limit });
1918
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1919
+ });
1920
+ registerTool("context_pack_read", {
1921
+ description: "Read a bounded UTF-8 range from one enabled context-pack document or approved skill.",
1922
+ inputSchema: z.object({
1923
+ pack: z.string().min(1).max(128),
1924
+ path: z.string().min(1).max(512),
1925
+ offset: z.number().int().min(0).optional(),
1926
+ max_bytes: z.number().int().min(1).max(8192).optional(),
1927
+ }).strict(),
1928
+ }, async ({ pack, path, offset, max_bytes }) => {
1929
+ const payload = await readVisibleContext(pack, path, { ...contextPackProject, offset, maxBytes: max_bytes });
1930
+ return { content: [{ type: "text", text: asToolText(payload) }], structuredContent: payload };
1931
+ });
1932
+ }
1882
1933
  return server;
1883
1934
  }
1884
1935
  function createMemoryServer(context = {}) {
@@ -0,0 +1,47 @@
1
+ const observation = (title, openWorldHint = false) => ({
2
+ title,
3
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint },
4
+ });
5
+ const additive = (title, openWorldHint = false) => ({
6
+ title,
7
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint },
8
+ });
9
+ const mutation = (title, openWorldHint = false) => ({
10
+ title,
11
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint },
12
+ });
13
+ // This is the authoritative MCP catalog. Adding a registered tool without an
14
+ // entry is a startup error, so clients can safely classify every Cairnkeep tool.
15
+ export const MCP_TOOL_CATALOG = {
16
+ artifact_write: additive("Write artifact"),
17
+ artifact_read: observation("Read artifact"),
18
+ artifact_list: observation("List artifacts"),
19
+ artifact_delete: mutation("Delete artifact"),
20
+ memory_read: observation("Read memory"),
21
+ memory_write: mutation("Write memory"),
22
+ memory_list: observation("List memory"),
23
+ memory_delete: mutation("Delete memory"),
24
+ memory_search: observation("Search memory"),
25
+ memory_extract: observation("Extract memory candidates", true),
26
+ memory_supersede: mutation("Supersede memory"),
27
+ memory_apply_reviewed: mutation("Apply reviewed memory"),
28
+ memory_invalidate_reviewed: mutation("Invalidate reviewed memory"),
29
+ memory_history: observation("Read memory history"),
30
+ memory_import: mutation("Import memory"),
31
+ domain_knowledge_query: observation("Query domain knowledge", true),
32
+ domain_knowledge_sync: mutation("Synchronize domain knowledge", true),
33
+ context_explore: observation("Explore repository context", true),
34
+ route_check: observation("Check route", true),
35
+ context_pack_list: observation("List context packs"),
36
+ context_pack_search: observation("Search context packs"),
37
+ context_pack_read: observation("Read context pack file"),
38
+ };
39
+ export const MCP_TOOL_NAMES = Object.freeze(Object.keys(MCP_TOOL_CATALOG));
40
+ export function isCairnToolName(value) {
41
+ return Object.hasOwn(MCP_TOOL_CATALOG, value);
42
+ }
43
+ export function metadataForTool(name) {
44
+ if (!isCairnToolName(name))
45
+ throw new Error(`MCP tool ${name} has no annotation contract.`);
46
+ return MCP_TOOL_CATALOG[name];
47
+ }
@@ -0,0 +1,85 @@
1
+ import { MCP_TOOL_CATALOG, MCP_TOOL_NAMES } from "./mcp-tool-catalog.js";
2
+ import { resetMcpToolProfile, resolveMcpToolProfile, setMcpToolProfile } from "./mcp-tool-profile.js";
3
+ function usage() {
4
+ process.stderr.write("Usage: cairn mcp-tools list [--json] | status [--project PATH] [--json] | set full|read-only [--project PATH] | set custom --tool NAME... [--project PATH] | reset [--project PATH]\n");
5
+ process.exit(2);
6
+ }
7
+ function option(args, name) {
8
+ const index = args.indexOf(name);
9
+ if (index < 0)
10
+ return undefined;
11
+ const value = args[index + 1];
12
+ if (!value || value.startsWith("--"))
13
+ usage();
14
+ args.splice(index, 2);
15
+ return value;
16
+ }
17
+ function flags(args, name) {
18
+ const values = [];
19
+ for (let index = 0; index < args.length;) {
20
+ if (args[index] !== name) {
21
+ index += 1;
22
+ continue;
23
+ }
24
+ args.splice(index, 1);
25
+ let consumed = 0;
26
+ while (index < args.length && !args[index].startsWith("--")) {
27
+ values.push(args[index]);
28
+ args.splice(index, 1);
29
+ consumed += 1;
30
+ }
31
+ if (consumed === 0)
32
+ usage();
33
+ }
34
+ return values;
35
+ }
36
+ function output(value, json, human) {
37
+ process.stdout.write(`${json ? JSON.stringify(value) : human}\n`);
38
+ }
39
+ async function main() {
40
+ const args = process.argv.slice(2);
41
+ const command = args.shift();
42
+ const jsonIndex = args.indexOf("--json");
43
+ const json = jsonIndex >= 0;
44
+ if (json)
45
+ args.splice(jsonIndex, 1);
46
+ const projectRoot = option(args, "--project") ?? process.cwd();
47
+ if (command === "list") {
48
+ if (args.length)
49
+ usage();
50
+ const tools = MCP_TOOL_NAMES.map((name) => ({ name, title: MCP_TOOL_CATALOG[name].title, annotations: MCP_TOOL_CATALOG[name].annotations }));
51
+ output({ schema_version: 1, tools }, json, tools.map(({ name, annotations }) => `${name}\t${annotations.readOnlyHint ? "read-only" : "mutation"}`).join("\n"));
52
+ return;
53
+ }
54
+ if (command === "status") {
55
+ if (args.length)
56
+ usage();
57
+ const status = resolveMcpToolProfile({ projectRoot });
58
+ output(status, json, `Profile: ${status.mode}\nSource: ${status.source}\nDigest: ${status.profile_digest}\nTools: ${status.allowed_tools.join(", ")}`);
59
+ return;
60
+ }
61
+ if (command === "set") {
62
+ const mode = args.shift();
63
+ if (!mode || !["full", "read-only", "custom"].includes(mode))
64
+ usage();
65
+ const allowedTools = flags(args, "--tool");
66
+ if (args.length || (mode === "custom" && allowedTools.length === 0) || (mode !== "custom" && allowedTools.length > 0))
67
+ usage();
68
+ const status = await setMcpToolProfile({ projectRoot, mode, allowedTools });
69
+ output(status, json, `MCP tool profile set to ${mode} (${status.profile_digest}).`);
70
+ return;
71
+ }
72
+ if (command === "reset") {
73
+ if (args.length)
74
+ usage();
75
+ await resetMcpToolProfile(projectRoot);
76
+ const status = resolveMcpToolProfile({ projectRoot });
77
+ output(status, json, `Project MCP tool profile reset; effective profile is ${status.mode} from ${status.source}.`);
78
+ return;
79
+ }
80
+ usage();
81
+ }
82
+ main().catch((error) => {
83
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
84
+ process.exitCode = 1;
85
+ });