@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.
- package/CHANGELOG.md +23 -1
- package/README.md +28 -2
- package/bin/cairn +13 -1
- package/docs/context-packs.md +94 -0
- package/docs/harness-compatibility.md +7 -1
- package/docs/learning/CURRICULUM-MAP.md +9 -4
- package/docs/learning/FEATURE-GUIDE.md +7 -2
- package/docs/learning/README.md +6 -2
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +52 -21
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +67 -0
- package/docs/learning/lessons/L21-context-packs.md +79 -0
- package/docs/learning/tracks/operator.md +4 -2
- package/docs/mcp-tool-profiles.md +43 -0
- package/docs/operating.md +38 -0
- package/docs/privacy-and-data-flow.md +17 -0
- package/docs/storage.md +17 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/context-pack-cli.js +185 -0
- package/mcp-memory-server/dist/context-pack.js +830 -0
- package/mcp-memory-server/dist/eval-report.js +1 -0
- package/mcp-memory-server/dist/eval-runner.js +2 -0
- package/mcp-memory-server/dist/eval-schema.js +1 -0
- package/mcp-memory-server/dist/index.js +68 -17
- package/mcp-memory-server/dist/mcp-tool-catalog.js +47 -0
- package/mcp-memory-server/dist/mcp-tool-cli.js +85 -0
- package/mcp-memory-server/dist/mcp-tool-profile.js +162 -0
- package/mcp-memory-server/package.json +3 -1
- package/package.json +1 -1
- package/schemas/context-pack.schema.json +33 -0
- package/schemas/eval-report.schema.json +1 -0
- package/scripts/completion.sh +24 -5
- package/scripts/doctor.sh +39 -0
- package/scripts/test-cli-dispatch.sh +6 -0
- package/scripts/test-completion.sh +5 -0
- package/scripts/test-learning-docs.sh +4 -3
- package/scripts/test-package-install.sh +17 -0
- package/scripts/test-uninstall.sh +21 -0
- package/scripts/uninstall.sh +26 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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" } },
|