@minhpnq1807/contextos 0.1.5 → 0.1.6
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 +5 -0
- package/README.md +3 -0
- package/bin/ctx.js +3 -0
- package/package.json +1 -1
- package/plugins/ctx/.codex-plugin/plugin.json +1 -1
- package/plugins/ctx/lib/mcp-proxy-install.js +118 -0
- package/plugins/ctx/mcp/proxy.js +92 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.6
|
|
4
|
+
|
|
5
|
+
- Adds a transparent stdio MCP telemetry proxy that records `tools/call` events while forwarding requests to the original MCP server.
|
|
6
|
+
- `ctx install` now wraps supported local MCP servers, including `code-review-graph` and `agentmemory`, so runtime-only tool rules can be measured from real MCP usage.
|
|
7
|
+
|
|
3
8
|
## 0.1.5
|
|
4
9
|
|
|
5
10
|
- Sanitizes stale Stop reports at display time so previously recorded system-user rules no longer appear in `ctx report` or `ctx evidence` after upgrading.
|
package/README.md
CHANGED
|
@@ -105,6 +105,7 @@ node bin/ctx.js install
|
|
|
105
105
|
3. Downloads and caches the required local MiniLM embedding model under `~/.ctx/contextos/models`.
|
|
106
106
|
4. Warms `~/.ctx/contextos/embeddings.db` for AGENTS rules and project file paths.
|
|
107
107
|
5. Registers the `ctx-mcp` MCP server and merges ContextOS global hooks into `$CODEX_HOME/hooks.json`.
|
|
108
|
+
6. Wraps supported local MCP servers, currently `code-review-graph` and `agentmemory`, with a transparent telemetry proxy so `tools/call` events can be measured.
|
|
108
109
|
|
|
109
110
|
Restart Codex after installing.
|
|
110
111
|
|
|
@@ -289,6 +290,8 @@ unknown = the rule was relevant, but the diff does not prove either way
|
|
|
289
290
|
|
|
290
291
|
For runtime-only rules, ContextOS also checks `telemetry.jsonl` for hook-visible tool names, MCP server names, and command metadata. A rule like "use code-review-graph before reading files" can be marked `followed` when telemetry contains a matching `code-review-graph` signal.
|
|
291
292
|
|
|
293
|
+
`ctx install` wraps supported stdio MCP servers with a transparent proxy. The proxy forwards MCP JSON-RPC unchanged, but records `tools/call` requests such as `code-review-graph.detect_changes_tool` to workspace telemetry. This is what lets ContextOS prove runtime rules that cannot be seen in git diff.
|
|
294
|
+
|
|
292
295
|
Host/session setup rules such as "run shell commands as user X", `sudo su - user`, `sudo -i -u user`, and `sudo -u user` are filtered before scoring. They are not injected and do not count toward `unknown` outcomes because they describe the agent runtime environment rather than project behavior.
|
|
293
296
|
|
|
294
297
|
## Development
|
package/bin/ctx.js
CHANGED
|
@@ -14,6 +14,7 @@ import { modelCacheDir, warmRuleEmbeddings } from "../plugins/ctx/lib/embedding-
|
|
|
14
14
|
import { warmFileEmbeddings } from "../plugins/ctx/lib/file-embedding-retriever.js";
|
|
15
15
|
import { scoreContext } from "../plugins/ctx/lib/score-context.js";
|
|
16
16
|
import { defaultDataRoot, workspaceDataDir, workspaceMarkerPath } from "../plugins/ctx/lib/workspace-data.js";
|
|
17
|
+
import { installMcpTelemetryProxies } from "../plugins/ctx/lib/mcp-proxy-install.js";
|
|
17
18
|
|
|
18
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const rootDir = path.resolve(__dirname, "..");
|
|
@@ -101,6 +102,7 @@ async function install({ copy = false, inject = true } = {}) {
|
|
|
101
102
|
runCodex(["plugin", "marketplace", "add", marketplaceRoot]);
|
|
102
103
|
runCodex(["plugin", "add", "ctx@contextos"]);
|
|
103
104
|
runCodex(["mcp", "add", "ctx-mcp", "--", "node", path.join(marketplaceRoot, "plugins", "ctx", "mcp", "server.js")]);
|
|
105
|
+
const proxyResult = installMcpTelemetryProxies({ codexHome: codexHome(), marketplaceRoot });
|
|
104
106
|
const hooksPath = installGlobalHooks({ codexHome: codexHome(), marketplaceRoot, injectPromptContext: inject });
|
|
105
107
|
|
|
106
108
|
console.log("Preparing required local embedding model...");
|
|
@@ -109,6 +111,7 @@ async function install({ copy = false, inject = true } = {}) {
|
|
|
109
111
|
console.log(`Stable marketplace root: ${marketplaceRoot}`);
|
|
110
112
|
console.log(`Installed ContextOS global hooks to ${hooksPath}`);
|
|
111
113
|
console.log("Installed ctx-mcp MCP server.");
|
|
114
|
+
console.log(`MCP telemetry proxies: ${proxyResult.wrapped.length ? proxyResult.wrapped.map((item) => item.name).join(", ") : "none changed"}`);
|
|
112
115
|
console.log(`Embedding model cache: ${modelCacheDir(contextOSDataDir())}`);
|
|
113
116
|
console.log(`Embedding vectors cache: ${warmResult.cachePath}`);
|
|
114
117
|
console.log(`File path embeddings warmed: ${warmResult.fileCount || 0}`);
|
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TARGETS = new Set(["code-review-graph", "agentmemory"]);
|
|
5
|
+
|
|
6
|
+
export function installMcpTelemetryProxies({ codexHome, marketplaceRoot, targets = DEFAULT_TARGETS } = {}) {
|
|
7
|
+
const configPath = path.join(codexHome, "config.toml");
|
|
8
|
+
if (!fs.existsSync(configPath)) return { wrapped: [], skipped: [], configPath };
|
|
9
|
+
|
|
10
|
+
const original = fs.readFileSync(configPath, "utf8");
|
|
11
|
+
const proxyPath = path.join(marketplaceRoot, "plugins", "ctx", "mcp", "proxy.js");
|
|
12
|
+
const result = rewriteMcpTelemetryProxies(original, { proxyPath, targets });
|
|
13
|
+
if (result.content !== original) {
|
|
14
|
+
fs.writeFileSync(configPath, result.content, "utf8");
|
|
15
|
+
}
|
|
16
|
+
return { ...result, configPath };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function rewriteMcpTelemetryProxies(toml, { proxyPath, targets = DEFAULT_TARGETS } = {}) {
|
|
20
|
+
const lines = String(toml || "").split(/\r?\n/);
|
|
21
|
+
const sections = findMcpServerSections(lines);
|
|
22
|
+
const wrapped = [];
|
|
23
|
+
const skipped = [];
|
|
24
|
+
|
|
25
|
+
for (const section of sections.reverse()) {
|
|
26
|
+
if (!targets.has(section.name)) {
|
|
27
|
+
skipped.push({ name: section.name, reason: "not-targeted" });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const body = lines.slice(section.start + 1, section.end);
|
|
32
|
+
const command = findStringValue(body, "command");
|
|
33
|
+
const args = findArrayValue(body, "args") || [];
|
|
34
|
+
if (!command) {
|
|
35
|
+
skipped.push({ name: section.name, reason: "missing-command" });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (command === "node" && args[0] === proxyPath) {
|
|
39
|
+
skipped.push({ name: section.name, reason: "already-wrapped" });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const nextBody = replaceOrInsertServerField(
|
|
44
|
+
replaceOrInsertServerField(body, "command", tomlString("node")),
|
|
45
|
+
"args",
|
|
46
|
+
tomlArray([proxyPath, "--name", section.name, "--", command, ...args])
|
|
47
|
+
);
|
|
48
|
+
lines.splice(section.start + 1, section.end - section.start - 1, ...nextBody);
|
|
49
|
+
wrapped.push({ name: section.name, command, args });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { content: lines.join("\n"), wrapped: wrapped.reverse(), skipped: skipped.reverse() };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findMcpServerSections(lines) {
|
|
56
|
+
const sections = [];
|
|
57
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
58
|
+
const match = lines[index].match(/^\[mcp_servers\.([^\].]+)\]\s*$/);
|
|
59
|
+
if (!match) continue;
|
|
60
|
+
let end = lines.length;
|
|
61
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
62
|
+
if (/^\[/.test(lines[cursor])) {
|
|
63
|
+
end = cursor;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
sections.push({ name: unquoteTomlKey(match[1]), start: index, end });
|
|
68
|
+
}
|
|
69
|
+
return sections;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function replaceOrInsertServerField(body, key, value) {
|
|
73
|
+
const next = [...body];
|
|
74
|
+
const index = next.findIndex((line) => new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(line));
|
|
75
|
+
const line = `${key} = ${value}`;
|
|
76
|
+
if (index >= 0) next[index] = line;
|
|
77
|
+
else next.unshift(line);
|
|
78
|
+
return next;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function findStringValue(lines, key) {
|
|
82
|
+
const line = lines.find((item) => new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(item));
|
|
83
|
+
if (!line) return null;
|
|
84
|
+
const match = line.match(/=\s*"((?:\\.|[^"\\])*)"/);
|
|
85
|
+
return match ? unescapeTomlString(match[1]) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findArrayValue(lines, key) {
|
|
89
|
+
const line = lines.find((item) => new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(item));
|
|
90
|
+
if (!line) return null;
|
|
91
|
+
const arrayMatch = line.match(/=\s*\[(.*)\]\s*$/);
|
|
92
|
+
if (!arrayMatch) return null;
|
|
93
|
+
const values = [];
|
|
94
|
+
const pattern = /"((?:\\.|[^"\\])*)"/g;
|
|
95
|
+
let match;
|
|
96
|
+
while ((match = pattern.exec(arrayMatch[1]))) values.push(unescapeTomlString(match[1]));
|
|
97
|
+
return values;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function tomlArray(values) {
|
|
101
|
+
return `[${values.map(tomlString).join(", ")}]`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tomlString(value) {
|
|
105
|
+
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function unescapeTomlString(value) {
|
|
109
|
+
return String(value).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function unquoteTomlKey(value) {
|
|
113
|
+
return value.replace(/^"|"$/g, "");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function escapeRegExp(value) {
|
|
117
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
118
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { appendTelemetry } from "../lib/telemetry.js";
|
|
6
|
+
import { workspaceDataDir } from "../lib/workspace-data.js";
|
|
7
|
+
|
|
8
|
+
const { serverName, command, args } = parseArgs(process.argv.slice(2));
|
|
9
|
+
const cwd = process.cwd();
|
|
10
|
+
const telemetryPath = path.join(workspaceDataDir({ cwd }), "telemetry.jsonl");
|
|
11
|
+
let inspectBuffer = "";
|
|
12
|
+
|
|
13
|
+
const child = spawn(command, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
env: process.env,
|
|
16
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
process.stdin.on("data", (chunk) => {
|
|
20
|
+
inspectClientChunk(chunk);
|
|
21
|
+
child.stdin.write(chunk);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
process.stdin.on("end", () => {
|
|
25
|
+
child.stdin.end();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
child.stdout.on("data", (chunk) => {
|
|
29
|
+
process.stdout.write(chunk);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
child.stderr.on("data", (chunk) => {
|
|
33
|
+
process.stderr.write(chunk);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
child.on("error", (error) => {
|
|
37
|
+
process.stderr.write(`contextos mcp proxy failed to start ${serverName}: ${error?.message || String(error)}\n`);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
child.on("exit", (code, signal) => {
|
|
42
|
+
if (signal) process.kill(process.pid, signal);
|
|
43
|
+
else process.exit(code ?? 0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function inspectClientChunk(chunk) {
|
|
47
|
+
inspectBuffer += chunk.toString("utf8");
|
|
48
|
+
const lines = inspectBuffer.split(/\r?\n/);
|
|
49
|
+
inspectBuffer = lines.pop() || "";
|
|
50
|
+
for (const line of lines.filter(Boolean)) {
|
|
51
|
+
let message;
|
|
52
|
+
try {
|
|
53
|
+
message = JSON.parse(line);
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (message?.method !== "tools/call") continue;
|
|
58
|
+
const toolName = message.params?.name || "unknown";
|
|
59
|
+
appendTelemetry({
|
|
60
|
+
telemetryPath,
|
|
61
|
+
event: "McpToolCall",
|
|
62
|
+
payload: {
|
|
63
|
+
cwd,
|
|
64
|
+
mcp: serverName,
|
|
65
|
+
server: serverName,
|
|
66
|
+
toolName: `${serverName}.${toolName}`,
|
|
67
|
+
tool: toolName,
|
|
68
|
+
method: message.method
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseArgs(argv) {
|
|
75
|
+
const separator = argv.indexOf("--");
|
|
76
|
+
if (separator < 0) usage();
|
|
77
|
+
|
|
78
|
+
const before = argv.slice(0, separator);
|
|
79
|
+
const after = argv.slice(separator + 1);
|
|
80
|
+
const nameIndex = before.indexOf("--name");
|
|
81
|
+
const serverName = nameIndex >= 0 ? before[nameIndex + 1] : null;
|
|
82
|
+
const command = after[0];
|
|
83
|
+
const args = after.slice(1);
|
|
84
|
+
|
|
85
|
+
if (!serverName || !command) usage();
|
|
86
|
+
return { serverName, command, args };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function usage() {
|
|
90
|
+
process.stderr.write("Usage: node proxy.js --name <mcp-server-name> -- <command> [...args]\n");
|
|
91
|
+
process.exit(2);
|
|
92
|
+
}
|