@cairnkeep/cli 2.8.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 (43) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +28 -2
  3. package/bin/cairn +13 -1
  4. package/docs/context-packs.md +94 -0
  5. package/docs/harness-compatibility.md +7 -1
  6. package/docs/learning/CURRICULUM-MAP.md +9 -4
  7. package/docs/learning/FEATURE-GUIDE.md +7 -2
  8. package/docs/learning/README.md +6 -2
  9. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  10. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  11. package/docs/learning/lessons/L02-installation.md +2 -2
  12. package/docs/learning/lessons/L03-first-project.md +1 -1
  13. package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
  14. package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
  15. package/docs/learning/lessons/L21-context-packs.md +79 -0
  16. package/docs/learning/tracks/operator.md +4 -2
  17. package/docs/mcp-tool-profiles.md +43 -0
  18. package/docs/operating.md +38 -0
  19. package/docs/privacy-and-data-flow.md +17 -0
  20. package/docs/storage.md +17 -0
  21. package/examples/eval/bundled-fake.json +1 -1
  22. package/mcp-memory-server/dist/context-pack-cli.js +185 -0
  23. package/mcp-memory-server/dist/context-pack.js +830 -0
  24. package/mcp-memory-server/dist/eval-report.js +1 -0
  25. package/mcp-memory-server/dist/eval-runner.js +2 -0
  26. package/mcp-memory-server/dist/eval-schema.js +1 -0
  27. package/mcp-memory-server/dist/index.js +68 -17
  28. package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
  29. package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
  30. package/mcp-memory-server/dist/mcp-tool-profile.js +162 -0
  31. package/mcp-memory-server/package.json +3 -1
  32. package/package.json +1 -1
  33. package/schemas/context-pack.schema.json +33 -0
  34. package/schemas/eval-report.schema.json +1 -0
  35. package/scripts/completion.sh +24 -5
  36. package/scripts/doctor.sh +39 -0
  37. package/scripts/test-cli-dispatch.sh +6 -0
  38. package/scripts/test-completion.sh +5 -0
  39. package/scripts/test-learning-docs.sh +4 -3
  40. package/scripts/test-package-install.sh +17 -0
  41. package/scripts/test-uninstall.sh +21 -0
  42. package/scripts/uninstall.sh +26 -1
  43. package/templates/env.example.template +11 -0
@@ -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",
@@ -10,6 +10,7 @@ import { canonicalDigest, evalReportSchema, evalReportRuntimeContractDigest, } f
10
10
  import { EvalProcessError, runBoundedCommand, runBoundedJsonAdapter, } from "./eval-process.js";
11
11
  import { checkpointEvalReport, createEvalReportStore, } from "./eval-report.js";
12
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;
@@ -735,6 +736,7 @@ function initialReport(plan, experimentId, fallbackExperimentKind) {
735
736
  package_version: plan.source.kind === "bundled_fake" ? plan.source.package_version : "0.1.0",
736
737
  runtime_id: "node-local",
737
738
  task_set_digest: plan.task_set_digest,
739
+ mcp_tool_profile_digest: resolveMcpToolProfile({ projectRoot: process.cwd() }).profile_digest,
738
740
  report_digest: canonicalDigest({ experiment_id: experimentId, plan_digest: plan.plan_digest }),
739
741
  schema_digests: [...evalReportSchemaDigests()],
740
742
  note_snapshot_digests: [],
@@ -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),
@@ -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
+ });
@@ -0,0 +1,162 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { closeSync, constants, existsSync, lstatSync, openSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
+ import { chmod, mkdir, open, rename, rm } from "node:fs/promises";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { MCP_TOOL_CATALOG, MCP_TOOL_NAMES, isCairnToolName } from "./mcp-tool-catalog.js";
6
+ const MAX_CONFIG_BYTES = 64 * 1024;
7
+ function paths(projectRoot) {
8
+ const root = resolve(projectRoot);
9
+ const directory = join(root, ".ai");
10
+ const config = join(directory, "mcp-tools.json");
11
+ if (dirname(directory) !== root || dirname(config) !== directory)
12
+ throw new Error("MCP tool profile path is unsafe.");
13
+ return { root, directory, config };
14
+ }
15
+ function exactAllowed(mode, requested) {
16
+ if (mode === "full")
17
+ return [...MCP_TOOL_NAMES];
18
+ if (mode === "read-only") {
19
+ return MCP_TOOL_NAMES.filter((name) => MCP_TOOL_CATALOG[name].annotations.readOnlyHint);
20
+ }
21
+ const unknown = requested.filter((name) => !isCairnToolName(name));
22
+ if (unknown.length)
23
+ throw new Error(`Unknown MCP tool name${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}`);
24
+ if (requested.length === 0)
25
+ throw new Error("A custom MCP tool profile requires at least one allowed tool.");
26
+ const selected = new Set(requested);
27
+ return MCP_TOOL_NAMES.filter((name) => selected.has(name));
28
+ }
29
+ function canonicalProfile(mode, allowed) {
30
+ return { schema_version: 1, mode, allowed_tools: exactAllowed(mode, allowed) };
31
+ }
32
+ function storedProfile(mode, allowed) {
33
+ const effective = exactAllowed(mode, allowed);
34
+ return { schema_version: 1, mode, allowed_tools: mode === "custom" ? effective : [] };
35
+ }
36
+ export function mcpToolProfileDigest(config) {
37
+ return createHash("sha256").update(JSON.stringify(config), "utf8").digest("hex");
38
+ }
39
+ function parseConfig(value) {
40
+ if (!value || typeof value !== "object" || Array.isArray(value))
41
+ throw new Error("Invalid MCP tool profile configuration.");
42
+ const row = value;
43
+ if (Object.keys(row).some((key) => !["schema_version", "mode", "allowed_tools"].includes(key))
44
+ || row.schema_version !== 1
45
+ || !["full", "read-only", "custom"].includes(String(row.mode))
46
+ || !Array.isArray(row.allowed_tools)
47
+ || row.allowed_tools.some((name) => typeof name !== "string")) {
48
+ throw new Error("Invalid MCP tool profile configuration.");
49
+ }
50
+ const mode = row.mode;
51
+ const requested = row.allowed_tools;
52
+ const canonical = storedProfile(mode, requested);
53
+ if (mode !== "custom" && requested.length > 0)
54
+ throw new Error("Only custom MCP tool profiles may contain allowed_tools.");
55
+ if (mode === "custom" && new Set(requested).size !== requested.length)
56
+ throw new Error("Duplicate MCP tool names are not allowed.");
57
+ return canonical;
58
+ }
59
+ function readProjectConfig(projectRoot) {
60
+ const { directory, config } = paths(projectRoot);
61
+ if (!existsSync(config))
62
+ return undefined;
63
+ const dirInfo = lstatSync(directory);
64
+ const info = lstatSync(config);
65
+ if (!dirInfo.isDirectory() || dirInfo.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()
66
+ || info.size > MAX_CONFIG_BYTES || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) {
67
+ throw new Error("MCP tool profile configuration is unsafe.");
68
+ }
69
+ const fd = requireNoFollow(config);
70
+ try {
71
+ const bytes = readFileSync(fd);
72
+ if (bytes.byteLength > MAX_CONFIG_BYTES)
73
+ throw new Error("MCP tool profile configuration is too large.");
74
+ return parseConfig(JSON.parse(bytes.toString("utf8")));
75
+ }
76
+ finally {
77
+ // readFileSync does not own numeric descriptors.
78
+ closeSync(fd);
79
+ }
80
+ }
81
+ function requireNoFollow(path) {
82
+ return openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
83
+ }
84
+ function environmentConfig(env) {
85
+ const rawMode = env.CAIRN_MCP_TOOL_PROFILE?.trim();
86
+ if (!rawMode)
87
+ return undefined;
88
+ if (!["full", "read-only", "custom"].includes(rawMode))
89
+ throw new Error("CAIRN_MCP_TOOL_PROFILE must be full, read-only, or custom.");
90
+ const allowed = (env.CAIRN_MCP_ALLOWED_TOOLS ?? "").split(",").map((name) => name.trim()).filter(Boolean);
91
+ if (rawMode !== "custom" && allowed.length)
92
+ throw new Error("CAIRN_MCP_ALLOWED_TOOLS is valid only with the custom profile.");
93
+ return storedProfile(rawMode, allowed);
94
+ }
95
+ export function resolveMcpToolProfile(options = {}) {
96
+ const projectRoot = options.projectRoot ?? process.cwd();
97
+ const env = options.env ?? process.env;
98
+ const { root, config } = paths(projectRoot);
99
+ const environment = environmentConfig(env);
100
+ const project = environment ? undefined : readProjectConfig(root);
101
+ const source = environment ? "environment" : project ? "project" : "default";
102
+ const selected = environment ?? project ?? storedProfile("full", []);
103
+ const effective = canonicalProfile(selected.mode, selected.allowed_tools);
104
+ return { ...effective, source, profile_digest: mcpToolProfileDigest(effective), project_root: root, config_path: config, issues: [] };
105
+ }
106
+ async function atomicWrite(path, value) {
107
+ if (existsSync(dirname(path))) {
108
+ const existingDirectory = lstatSync(dirname(path));
109
+ if (!existingDirectory.isDirectory() || existingDirectory.isSymbolicLink())
110
+ throw new Error("MCP tool profile directory is unsafe.");
111
+ }
112
+ else {
113
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
114
+ }
115
+ const directoryInfo = lstatSync(dirname(path));
116
+ if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
117
+ throw new Error("MCP tool profile directory is unsafe.");
118
+ if (existsSync(path)) {
119
+ const existing = lstatSync(path);
120
+ if (!existing.isFile() || existing.isSymbolicLink())
121
+ throw new Error("MCP tool profile configuration is unsafe.");
122
+ }
123
+ const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
124
+ let handle;
125
+ try {
126
+ handle = await open(temporary, "wx", 0o600);
127
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
128
+ await handle.sync();
129
+ await handle.close();
130
+ handle = undefined;
131
+ await chmod(temporary, 0o600);
132
+ await rename(temporary, path);
133
+ await chmod(path, 0o600);
134
+ }
135
+ finally {
136
+ if (handle)
137
+ await handle.close().catch(() => undefined);
138
+ await rm(temporary, { force: true });
139
+ }
140
+ }
141
+ export async function setMcpToolProfile(options) {
142
+ const { root, config } = paths(options.projectRoot ?? process.cwd());
143
+ const value = storedProfile(options.mode, options.allowedTools ?? []);
144
+ await atomicWrite(config, value);
145
+ return resolveMcpToolProfile({ projectRoot: root, env: {} });
146
+ }
147
+ export async function resetMcpToolProfile(projectRoot = process.cwd()) {
148
+ const { config } = paths(projectRoot);
149
+ if (!existsSync(config))
150
+ return;
151
+ const info = lstatSync(config);
152
+ if (!info.isFile() || info.isSymbolicLink())
153
+ throw new Error("MCP tool profile configuration is unsafe.");
154
+ rmSync(config);
155
+ }
156
+ export function profileAllowsTool(status, name) {
157
+ return status.allowed_tools.includes(name);
158
+ }
159
+ export function canonicalProjectRoot(projectRoot) {
160
+ const resolved = resolve(projectRoot);
161
+ return existsSync(resolved) ? realpathSync(resolved) : resolved;
162
+ }
@@ -46,9 +46,11 @@
46
46
  "check:eval-statistics": "node scripts/smoke-eval-statistics.mjs",
47
47
  "check:skill-lifecycle": "node scripts/smoke-skill-lifecycle.mjs",
48
48
  "check:graph-cli": "node scripts/smoke-graph-cli.mjs",
49
+ "check:mcp-trust": "node scripts/smoke-mcp-trust.mjs",
50
+ "check:context-pack": "node scripts/smoke-context-pack.mjs",
49
51
  "check:notes": "npm run build && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment",
50
52
  "test": "npm run test:smoke",
51
- "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli",
53
+ "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:context-pack",
52
54
  "test:search": "npm run build && npm run check:search",
53
55
  "test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
54
56
  "test:all": "npm run test:memory"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,33 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://raw.githubusercontent.com/cairnkeep/cairnkeep/main/schemas/context-pack.schema.json",
4
+ "title": "Cairnkeep context pack manifest v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "id", "version", "title", "description", "license", "files"],
8
+ "properties": {
9
+ "schema_version": { "const": 1 },
10
+ "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "maxLength": 64 },
11
+ "version": { "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", "maxLength": 128 },
12
+ "title": { "type": "string", "minLength": 1, "maxLength": 256 },
13
+ "description": { "type": "string", "minLength": 1, "maxLength": 2048 },
14
+ "license": { "type": "string", "minLength": 1, "maxLength": 128 },
15
+ "files": {
16
+ "type": "array",
17
+ "maxItems": 1024,
18
+ "items": {
19
+ "type": "object",
20
+ "additionalProperties": false,
21
+ "required": ["path", "kind", "title", "description", "keywords", "sha256"],
22
+ "properties": {
23
+ "path": { "type": "string", "minLength": 1, "maxLength": 512 },
24
+ "kind": { "enum": ["document", "skill"] },
25
+ "title": { "type": "string", "minLength": 1, "maxLength": 256 },
26
+ "description": { "type": "string", "maxLength": 2048 },
27
+ "keywords": { "type": "array", "maxItems": 64, "items": { "type": "string", "minLength": 1, "maxLength": 128 } },
28
+ "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
@@ -412,6 +412,7 @@
412
412
  "package_version": { "type": "string", "minLength": 1, "maxLength": 128 },
413
413
  "runtime_id": { "$ref": "#/$defs/identifier" },
414
414
  "task_set_digest": { "$ref": "#/$defs/digest" },
415
+ "mcp_tool_profile_digest": { "$ref": "#/$defs/digest" },
415
416
  "report_digest": { "$ref": "#/$defs/digest" },
416
417
  "schema_digests": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "$ref": "#/$defs/digest" } },
417
418
  "note_snapshot_digests": { "type": "array", "maxItems": 10000, "items": { "$ref": "#/$defs/digest" } },