lowdown-proxy 0.1.16 → 0.1.17
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/README.md +39 -7
- package/dist/classify.js +78 -0
- package/dist/cli.js +13 -4
- package/dist/mcp-server.js +22 -9
- package/dist/proxy.js +68 -59
- package/dist/record.js +43 -0
- package/package.json +4 -3
- package/dist/supabase.js +0 -52
package/README.md
CHANGED
|
@@ -100,19 +100,34 @@ C:\Users\you\AppData\Local\MyApp\app.log: 1 new line(s)
|
|
|
100
100
|
| Flag | Description | Default |
|
|
101
101
|
|---|---|---|
|
|
102
102
|
| `--actor` | Identifier of the calling agent | `anonymous` |
|
|
103
|
-
| `--target` | Label for the target tool | command
|
|
103
|
+
| `--target` | Label for the target tool | package or file name from the command (never the full command line) |
|
|
104
104
|
| `--source` | `organic` \| `seeded` \| `synthetic` (internal/testing only) | `organic` |
|
|
105
|
+
| `--test` | Record this session as `synthetic` — use it for intentional failure tests so they are not counted against the server | off |
|
|
105
106
|
| `--watch-log` | App log file to watch; repeat for several files (see [App logs in tool results](#app-logs-in-tool-results)) | none |
|
|
106
107
|
| `--log-pattern` | Regular expression for log lines to report | error, exception, fatal, uncaught, unhandled, failed |
|
|
107
108
|
|
|
108
|
-
##
|
|
109
|
+
## Recording
|
|
109
110
|
|
|
110
|
-
|
|
111
|
+
From 0.1.17 the proxy records through the public API (`POST /api/interactions`). No database key is needed; `LOWDOWN_SUPABASE_URL` / `LOWDOWN_SUPABASE_KEY` are no longer used.
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
| Variable | Effect |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `LOWDOWN_RECORD=0` | Pass-through only, nothing is recorded |
|
|
116
|
+
| `LOWDOWN_API_URL` | Record to (and query) another Lowdown deployment |
|
|
117
|
+
|
|
118
|
+
What one record contains: actor, target label, tool name (or protocol method), outcome, latency, node id, and — when the server reported them — `server_version` (from `initialize`) and `schema_hash` (same hash as the Scanner's, from `tools/list`). Arguments and results are never recorded.
|
|
119
|
+
|
|
120
|
+
How a response is classified (MCP spec, *Tools → Error Handling*):
|
|
121
|
+
|
|
122
|
+
| Response | Recorded as | In `success_rate` |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `tools/call` result | success | yes |
|
|
125
|
+
| `tools/call` result with `isError: true` | failure, `tool_error`, `error_layer: tool` | yes |
|
|
126
|
+
| JSON-RPC error -32700 / -32600 / -32601 / -32602 (malformed request, unknown tool, invalid arguments) | failure, `error_layer: request` | no — counted as `rejected_requests` |
|
|
127
|
+
| Other JSON-RPC errors (internal error, timeout) | failure, `error_layer: server` | yes |
|
|
128
|
+
| `initialize`, `tools/list`, `ping`, ... | `request_kind: protocol` | no — counted as `protocol_requests` |
|
|
129
|
+
|
|
130
|
+
`success_rate` counts `organic` records only; `seeded` and `synthetic` are reported separately in `by_source`. Every record carries `recorder_version`, so records made before 0.1.17 (which counted `isError` results as success) can be told apart.
|
|
116
131
|
|
|
117
132
|
## Public API
|
|
118
133
|
|
|
@@ -141,11 +156,17 @@ Basic response (no node):
|
|
|
141
156
|
{
|
|
142
157
|
"target": "brave-search",
|
|
143
158
|
"interactions": 30,
|
|
159
|
+
"rated_interactions": 30,
|
|
144
160
|
"success_rate": 0.933,
|
|
161
|
+
"protocol_requests": 0,
|
|
162
|
+
"rejected_requests": 0,
|
|
163
|
+
"by_source": { "organic": 30, "seeded": 0, "synthetic": 0 },
|
|
145
164
|
"confidence": "medium"
|
|
146
165
|
}
|
|
147
166
|
```
|
|
148
167
|
|
|
168
|
+
`interactions` is every record for the target; `rated_interactions` is the denominator of `success_rate`, and `confidence` is based on it.
|
|
169
|
+
|
|
149
170
|
Contributor response (with node):
|
|
150
171
|
|
|
151
172
|
```json
|
|
@@ -267,6 +288,17 @@ MCP 서버를 감싸는 stdio 프록시가 첫 번째 진입점입니다.
|
|
|
267
288
|
|
|
268
289
|
도구 호출은 성공했는데 그 뒤의 앱(씬, 게임, 개발 서버)이 실패하는 경우가 있습니다. 진짜 에러는 앱의 로그 파일에만 찍혀서 에이전트는 모릅니다. `--watch-log`를 주면 `tools/call` 결과마다 직전 호출 이후 로그에 새로 찍힌 줄 중 `--log-pattern`에 맞는 줄이 결과 끝에 붙습니다. 에이전트가 따로 호출할 것은 없습니다. 로그 내용은 로컬에만 머물고 Lowdown에 기록되지 않습니다.
|
|
269
290
|
|
|
291
|
+
### 기록 방식 (0.1.17~)
|
|
292
|
+
|
|
293
|
+
프록시는 공개 API(`POST /api/interactions`)로 기록합니다. DB 키가 필요 없고 `LOWDOWN_SUPABASE_URL` / `LOWDOWN_SUPABASE_KEY`는 더 이상 쓰지 않습니다. `LOWDOWN_RECORD=0`이면 기록 없이 패스스루만 합니다.
|
|
294
|
+
|
|
295
|
+
- `tools/call` 결과의 `isError: true`는 실패(`tool_error`)로 기록합니다.
|
|
296
|
+
- 잘못된 요청(-32700/-32600/-32601/-32602: 형식 오류, 없는 도구, 잘못된 인자)은 서버 실패와 분리해 `rejected_requests`로 셉니다. `success_rate`에는 들어가지 않습니다.
|
|
297
|
+
- `initialize`, `tools/list` 같은 비도구 요청은 `protocol_requests`로 따로 셉니다.
|
|
298
|
+
- `success_rate`는 `organic` 기록만 계산합니다. `seeded` / `synthetic`은 `by_source`로 따로 보여줍니다. 일부러 실패시키는 테스트는 `--test`로 실행하면 `synthetic`으로 남습니다.
|
|
299
|
+
- 각 기록에는 그 시점의 `server_version`(initialize), `schema_hash`(tools/list, Scanner와 같은 계산), `recorder_version`이 함께 남습니다. 인자와 결과 내용은 기록하지 않습니다.
|
|
300
|
+
- `--target`을 주지 않으면 명령줄 전체가 아니라 패키지·파일 이름 하나만 라벨로 씁니다.
|
|
301
|
+
|
|
270
302
|
### 설계 원칙 (요약)
|
|
271
303
|
|
|
272
304
|
- **설정 없음**: 프록시를 실행하면 모든 것이 자동으로 처리됩니다.
|
package/dist/classify.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
const REQUEST_ERROR_CODES = new Set([-32700, -32600, -32601, -32602]);
|
|
3
|
+
export function classifyResponse(method, msg) {
|
|
4
|
+
const request_kind = method === "tools/call" ? "tool_call" : "protocol";
|
|
5
|
+
if (msg?.error) {
|
|
6
|
+
const code = typeof msg.error.code === "number" ? msg.error.code : undefined;
|
|
7
|
+
const message = String(msg.error.message ?? "").toLowerCase();
|
|
8
|
+
if (code !== undefined && REQUEST_ERROR_CODES.has(code)) {
|
|
9
|
+
return {
|
|
10
|
+
request_kind,
|
|
11
|
+
outcome: "failure",
|
|
12
|
+
failure_type: code === -32602 ? "invalid_arguments" : "protocol_error",
|
|
13
|
+
error_layer: "request",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
let failure_type = "unknown";
|
|
17
|
+
if (message.includes("timeout") || message.includes("timed out"))
|
|
18
|
+
failure_type = "timeout";
|
|
19
|
+
else if (code === -32603)
|
|
20
|
+
failure_type = "server_error";
|
|
21
|
+
else if (code === -32000)
|
|
22
|
+
failure_type = "tool_error";
|
|
23
|
+
return { request_kind, outcome: "failure", failure_type, error_layer: "server" };
|
|
24
|
+
}
|
|
25
|
+
if (request_kind === "tool_call" && msg?.result?.isError === true) {
|
|
26
|
+
return { request_kind, outcome: "failure", failure_type: "tool_error", error_layer: "tool" };
|
|
27
|
+
}
|
|
28
|
+
return { request_kind, outcome: "success" };
|
|
29
|
+
}
|
|
30
|
+
// ── schema_hash: Scanner(scanner/lib.ts hashTools)와 같은 계산 → 프록시 기록과 Scanner 관찰을 비교할 수 있다 ──
|
|
31
|
+
export function canonicalJson(v) {
|
|
32
|
+
if (v === null || typeof v !== "object")
|
|
33
|
+
return JSON.stringify(v) ?? "null";
|
|
34
|
+
if (Array.isArray(v))
|
|
35
|
+
return `[${v.map(canonicalJson).join(",")}]`;
|
|
36
|
+
const o = v;
|
|
37
|
+
const parts = Object.keys(o)
|
|
38
|
+
.sort()
|
|
39
|
+
.filter((k) => o[k] !== undefined)
|
|
40
|
+
.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`);
|
|
41
|
+
return `{${parts.join(",")}}`;
|
|
42
|
+
}
|
|
43
|
+
/** name + inputSchema + outputSchema. 도구 순서·키 순서와 무관. */
|
|
44
|
+
export function schemaHash(tools) {
|
|
45
|
+
const objs = tools.map((t) => typeof t === "object" && t !== null && !Array.isArray(t) ? t : {});
|
|
46
|
+
const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
47
|
+
const schema = objs
|
|
48
|
+
.map((t) => canonicalJson({ name: t.name, inputSchema: t.inputSchema, outputSchema: t.outputSchema }))
|
|
49
|
+
.sort(byName);
|
|
50
|
+
return createHash("sha256").update(`[${schema.join(",")}]`).digest("hex");
|
|
51
|
+
}
|
|
52
|
+
/** 서버가 initialize 에서 알려준 버전. 문자열이 아니면 버린다. */
|
|
53
|
+
export function serverVersionOf(msg) {
|
|
54
|
+
const v = msg?.result?.serverInfo?.version;
|
|
55
|
+
return typeof v === "string" && v.length > 0 ? v.slice(0, 100) : undefined;
|
|
56
|
+
}
|
|
57
|
+
const RUNNERS = new Set([
|
|
58
|
+
"npx", "npx.cmd", "uvx", "uv", "bunx", "pnpx", "pnpm", "yarn", "dlx", "run", "tool", "exec",
|
|
59
|
+
"node", "node.exe", "python", "python3", "python.exe", "deno", "bun", "cmd", "cmd.exe",
|
|
60
|
+
]);
|
|
61
|
+
/**
|
|
62
|
+
* --target 이 없을 때 쓸 라벨. 명령줄 전체(인자에 키·경로가 있을 수 있음)를 공개 기록에 넣지 않기 위해
|
|
63
|
+
* 실행기(npx/uvx/node …)와 옵션을 건너뛴 첫 토큰 하나만 쓴다. 경로면 파일 이름만.
|
|
64
|
+
*/
|
|
65
|
+
export function defaultTargetLabel(command, args) {
|
|
66
|
+
const tokens = [command, ...args];
|
|
67
|
+
for (const raw of tokens) {
|
|
68
|
+
const base = raw.split(/[\\/]/).pop() ?? raw;
|
|
69
|
+
if (raw.startsWith("-") || raw.toLowerCase() === "/c")
|
|
70
|
+
continue;
|
|
71
|
+
if (RUNNERS.has(base.toLowerCase()))
|
|
72
|
+
continue;
|
|
73
|
+
if (raw.startsWith("@"))
|
|
74
|
+
return raw.slice(0, 200); // 스코프 패키지 이름은 '/' 를 포함하므로 그대로
|
|
75
|
+
return base.slice(0, 200);
|
|
76
|
+
}
|
|
77
|
+
return "unknown";
|
|
78
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runProxy } from "./proxy.js";
|
|
3
3
|
import { LogWatcher } from "./log-watch.js";
|
|
4
|
+
import { defaultTargetLabel } from "./classify.js";
|
|
4
5
|
// 사용법:
|
|
5
6
|
// npx lowdown-proxy → 인자 없음: Lowdown 조회용 MCP 서버(stdio) 시작 (Registry 설치 경로)
|
|
6
|
-
// npx lowdown-proxy [--actor <id>] [--target <label>] [--source organic|seeded|synthetic] -- <command> [args...]
|
|
7
|
+
// npx lowdown-proxy [--actor <id>] [--target <label>] [--source organic|seeded|synthetic] [--test] -- <command> [args...]
|
|
7
8
|
// → MCP 서버를 감싸는 기록 프록시
|
|
9
|
+
// --test : 이 세션의 기록을 synthetic 으로 남긴다(일부러 실패시키는 테스트가 서버 실패로 집계되지 않게).
|
|
10
|
+
// --target 이 없으면 명령줄 전체가 아니라 패키지/파일 이름 하나만 라벨로 쓴다(인자의 키·경로가 공개 기록에 남지 않게).
|
|
8
11
|
function parseArgs(argv) {
|
|
9
12
|
const dashIndex = argv.indexOf("--");
|
|
10
13
|
if (dashIndex === -1) {
|
|
11
|
-
console.error("사용법: lowdown-proxy [--actor <id>] [--target <label>] [--source organic|seeded|synthetic] -- <command> [args...]\n" +
|
|
14
|
+
console.error("사용법: lowdown-proxy [--actor <id>] [--target <label>] [--source organic|seeded|synthetic] [--test] -- <command> [args...]\n" +
|
|
12
15
|
"예시: lowdown-proxy -- npx some-mcp-server\n" +
|
|
13
16
|
"인자 없이 실행하면 Lowdown 조회용 MCP 서버가 시작됩니다.");
|
|
14
17
|
process.exit(1);
|
|
@@ -25,10 +28,16 @@ function parseArgs(argv) {
|
|
|
25
28
|
};
|
|
26
29
|
const getAll = (name) => flags.flatMap((f, i) => (f === name && flags[i + 1] ? [flags[i + 1]] : []));
|
|
27
30
|
const watchLogs = getAll("--watch-log");
|
|
31
|
+
const sourceFlag = getFlag("--source", "organic");
|
|
32
|
+
if (!["organic", "seeded", "synthetic"].includes(sourceFlag)) {
|
|
33
|
+
console.error(`[lowdown] --source 는 organic|seeded|synthetic 중 하나여야 합니다: ${sourceFlag}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const source = (flags.includes("--test") ? "synthetic" : sourceFlag);
|
|
28
37
|
return {
|
|
29
38
|
actor: getFlag("--actor", "anonymous"),
|
|
30
|
-
targetLabel: getFlag("--target", commandParts.
|
|
31
|
-
source
|
|
39
|
+
targetLabel: getFlag("--target", defaultTargetLabel(commandParts[0], commandParts.slice(1))),
|
|
40
|
+
source,
|
|
32
41
|
command: commandParts[0],
|
|
33
42
|
args: commandParts.slice(1),
|
|
34
43
|
logWatcher: watchLogs.length ? new LogWatcher(watchLogs, getFlag("--log-pattern", "")) : undefined,
|
package/dist/mcp-server.js
CHANGED
|
@@ -20,8 +20,9 @@ const server = new McpServer({
|
|
|
20
20
|
});
|
|
21
21
|
// ── 1. get_lowdown ──────────────────────────────────────────────
|
|
22
22
|
server.tool("get_lowdown", "Look up what Lowdown has recorded about an agent, tool, or service. " +
|
|
23
|
-
"Returns interactions
|
|
24
|
-
"
|
|
23
|
+
"Returns recorded interactions (count, outcomes, success rate). The success rate counts organic tool calls and " +
|
|
24
|
+
"agent reports only; protocol requests, rejected requests, and seeded or synthetic records are counted separately. " +
|
|
25
|
+
"For MCP Registry servers it also returns a separate `scanner` block: daily Lowdown Scanner observations of initialize and tools/list reachability, " +
|
|
25
26
|
"HTTP status, tool count, and tool-schema changes over the last 30 days. " +
|
|
26
27
|
"Scanner data is observation, not a score. A target outside the observed sample returns `observed: false`, " +
|
|
27
28
|
"which says nothing about its status.", {
|
|
@@ -35,7 +36,9 @@ server.tool("get_lowdown", "Look up what Lowdown has recorded about an agent, to
|
|
|
35
36
|
});
|
|
36
37
|
// ── 2. record_interaction ───────────────────────────────────────
|
|
37
38
|
server.tool("record_interaction", "Record an interaction with an agent, tool, or service after using it. " +
|
|
38
|
-
"Helps build the shared reputation dataset for the community."
|
|
39
|
+
"Helps build the shared reputation dataset for the community. " +
|
|
40
|
+
"If the call was an intentional negative test (e.g. deliberately invalid input), set source to 'synthetic' " +
|
|
41
|
+
"so it is not counted as a failure of the target.", {
|
|
39
42
|
actor: z.string().describe("Your agent/system identifier"),
|
|
40
43
|
target: z.string().describe("Identifier of what you used"),
|
|
41
44
|
target_type: z.enum(["agent", "tool", "service", "human"]),
|
|
@@ -45,12 +48,18 @@ server.tool("record_interaction", "Record an interaction with an agent, tool, or
|
|
|
45
48
|
.optional()
|
|
46
49
|
.describe("Required when outcome is failure. Distinguish tool failure vs argument error."),
|
|
47
50
|
latency_ms: z.number().optional().describe("How long it took in milliseconds"),
|
|
48
|
-
source: z.enum(["organic", "seeded", "synthetic"]).optional()
|
|
51
|
+
source: z.enum(["organic", "seeded", "synthetic"]).optional()
|
|
52
|
+
.describe("organic (default) for real use; synthetic for tests, including intentional failure tests"),
|
|
49
53
|
}, async ({ actor, target, target_type, task_type, outcome, failure_type, latency_ms, source }) => {
|
|
50
54
|
const res = await fetch(`${BASE_URL}/api/interactions`, {
|
|
51
55
|
method: "POST",
|
|
52
56
|
headers: { "Content-Type": "application/json" },
|
|
53
|
-
body: JSON.stringify({
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
actor, target, target_type, task_type, outcome, failure_type, latency_ms, source,
|
|
59
|
+
request_kind: "reported",
|
|
60
|
+
node_id: NODE_ID,
|
|
61
|
+
recorder_version: PKG_VERSION,
|
|
62
|
+
}),
|
|
54
63
|
});
|
|
55
64
|
const data = await res.json();
|
|
56
65
|
return {
|
|
@@ -61,7 +70,7 @@ server.tool("record_interaction", "Record an interaction with an agent, tool, or
|
|
|
61
70
|
server.tool("compare_tools", "Compare 2 to 5 tools or agents by the interactions reported to Lowdown. " +
|
|
62
71
|
"Candidates are ordered by reported success rate (candidates without data last), with interaction count and confidence. " +
|
|
63
72
|
"Scanner observations are not used for the ordering. " +
|
|
64
|
-
"A suggestion is included only when the first candidate has at least 5
|
|
73
|
+
"A suggestion is included only when the first candidate has at least 5 rated interactions.", {
|
|
65
74
|
candidates: z.array(z.string()).min(2).max(5).describe("List of tool/agent identifiers to compare"),
|
|
66
75
|
task_type: z.string().optional().describe("Filter by task type if you want task-specific comparison"),
|
|
67
76
|
}, async ({ candidates, task_type }) => {
|
|
@@ -98,19 +107,19 @@ server.tool("compare_tools", "Compare 2 to 5 tools or agents by the interactions
|
|
|
98
107
|
.map((r, i) => {
|
|
99
108
|
const rate = r.success_rate !== null ? `${(Number(r.success_rate) * 100).toFixed(0)}%` : "no data";
|
|
100
109
|
const latency = r.avg_latency_ms ? `, latency: ${r.avg_latency_ms}ms` : "";
|
|
101
|
-
return `${i + 1}. ${r.target} — success: ${rate}, interactions: ${r
|
|
110
|
+
return `${i + 1}. ${r.target} — success: ${rate}, rated interactions: ${ratedCount(r)}, confidence: ${r.confidence}${latency}`;
|
|
102
111
|
})
|
|
103
112
|
.join("\n");
|
|
104
113
|
// recommendation + reason 자동 생성
|
|
105
114
|
const best = ranked[0];
|
|
106
115
|
let recommendation = null;
|
|
107
|
-
if (best
|
|
116
|
+
if (ratedCount(best) >= 5 && best?.success_rate !== null && best?.success_rate !== undefined) {
|
|
108
117
|
const rate = `${(Number(best.success_rate) * 100).toFixed(0)}%`;
|
|
109
118
|
const latencyNote = best.avg_latency_ms ? ` with avg latency ${best.avg_latency_ms}ms` : "";
|
|
110
119
|
const taskNote = task_type ? ` for ${task_type}` : "";
|
|
111
120
|
recommendation = {
|
|
112
121
|
target: best.target,
|
|
113
|
-
reason: `Highest success rate${taskNote} (${rate}) across ${best
|
|
122
|
+
reason: `Highest success rate${taskNote} (${rate}) across ${ratedCount(best)} rated interactions${latencyNote}.`,
|
|
114
123
|
};
|
|
115
124
|
}
|
|
116
125
|
return {
|
|
@@ -133,6 +142,10 @@ server.tool("get_node_stats", "Get your node's contribution stats and network po
|
|
|
133
142
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
134
143
|
};
|
|
135
144
|
});
|
|
145
|
+
// success_rate 의 분모(organic 도구 호출·보고). 0.1.17 이전 API 응답에는 없으므로 interactions 로 대체.
|
|
146
|
+
function ratedCount(r) {
|
|
147
|
+
return Number(r?.rated_interactions ?? r?.interactions ?? 0);
|
|
148
|
+
}
|
|
136
149
|
// ── 서버 시작 ───────────────────────────────────────────────────
|
|
137
150
|
const transport = new StdioServerTransport();
|
|
138
151
|
await server.connect(transport);
|
package/dist/proxy.js
CHANGED
|
@@ -1,45 +1,36 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
-
import { recordInteraction } from "./
|
|
3
|
+
import { recordInteraction } from "./record.js";
|
|
4
4
|
import { getNodeId } from "./node-identity.js";
|
|
5
5
|
import { annotateToolResult } from "./log-watch.js";
|
|
6
|
+
import { classifyResponse, schemaHash, serverVersionOf } from "./classify.js";
|
|
6
7
|
/**
|
|
7
8
|
* lowdown-proxy 핵심 로직.
|
|
8
9
|
*
|
|
9
10
|
* 설계 원칙 (lowdown-brief.md 참고):
|
|
10
11
|
* - 패스스루가 최우선. 기록 로직이 통신을 막거나 지연시켜선 안 된다.
|
|
11
12
|
* - "무슨 일이 일어났는가"만 기록한다(Interaction 레이어). 품질 판단(Opinion)은 v0 범위 밖.
|
|
12
|
-
* -
|
|
13
|
+
* - 판정 기준은 classify.ts: tools/call 의 isError 는 실패, 잘못된 요청(-32602 등)은 서버 실패와 분리,
|
|
14
|
+
* tools/call 이 아닌 요청은 protocol 로 남기고 성공률에 넣지 않는다.
|
|
15
|
+
* - 각 행에 그 시점의 server_version(initialize)·schema_hash(tools/list)를 찍는다.
|
|
13
16
|
*/
|
|
14
17
|
export function runProxy(opts) {
|
|
15
18
|
const nodeId = getNodeId();
|
|
19
|
+
const record = opts.record ?? ((r) => recordInteraction(r));
|
|
16
20
|
const child = spawn(opts.command, opts.args, {
|
|
17
21
|
stdio: ["pipe", "pipe", "inherit"], // stderr는 그대로 상위로 흘려보냄(디버깅용)
|
|
18
22
|
shell: process.platform === "win32",
|
|
19
23
|
});
|
|
20
24
|
const pending = new Map();
|
|
25
|
+
const state = {};
|
|
21
26
|
// ── Client(부모 프로세스의 stdin) → Server(child.stdin) ──
|
|
22
|
-
// 요청을 가로채서 tools/call이면 pending에 기록해두고, 그대로 통과시킴
|
|
23
27
|
const clientReader = createInterface({ input: process.stdin });
|
|
24
28
|
clientReader.on("line", (line) => {
|
|
25
29
|
// 항상 먼저 그대로 전달 — 파싱 실패해도 패스스루는 깨지면 안 됨
|
|
26
30
|
child.stdin.write(line + "\n");
|
|
27
|
-
|
|
28
|
-
const msg = JSON.parse(line);
|
|
29
|
-
if (msg?.id !== undefined && msg?.method) {
|
|
30
|
-
pending.set(msg.id, {
|
|
31
|
-
method: msg.method,
|
|
32
|
-
toolName: msg.method === "tools/call" ? msg.params?.name : undefined,
|
|
33
|
-
startedAt: Date.now(),
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
// JSON이 아니거나 파싱 실패 — 무시하고 패스스루만 유지
|
|
39
|
-
}
|
|
31
|
+
trackRequest(pending, line);
|
|
40
32
|
});
|
|
41
33
|
// ── Server(child.stdout) → Client(부모 프로세스의 stdout) ──
|
|
42
|
-
// 응답을 가로채서 pending과 매칭되면 interaction 기록, 그대로 통과시킴
|
|
43
34
|
const serverReader = createInterface({ input: child.stdout });
|
|
44
35
|
serverReader.on("line", (line) => {
|
|
45
36
|
let out = line;
|
|
@@ -57,48 +48,9 @@ export function runProxy(opts) {
|
|
|
57
48
|
}
|
|
58
49
|
}
|
|
59
50
|
process.stdout.write(out + "\n");
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const call = pending.get(msg.id);
|
|
64
|
-
pending.delete(msg.id);
|
|
65
|
-
const latency_ms = Date.now() - call.startedAt;
|
|
66
|
-
const outcome = msg.error
|
|
67
|
-
? "failure"
|
|
68
|
-
: "success";
|
|
69
|
-
let failure_type;
|
|
70
|
-
if (msg.error) {
|
|
71
|
-
const code = msg.error?.code;
|
|
72
|
-
const message = msg.error?.message ?? "";
|
|
73
|
-
if (code === -32602)
|
|
74
|
-
failure_type = "invalid_arguments";
|
|
75
|
-
else if (message.toLowerCase().includes("timeout"))
|
|
76
|
-
failure_type = "timeout";
|
|
77
|
-
else if (code === -32603)
|
|
78
|
-
failure_type = "server_error";
|
|
79
|
-
else if (code === -32000)
|
|
80
|
-
failure_type = "tool_error";
|
|
81
|
-
else
|
|
82
|
-
failure_type = "unknown";
|
|
83
|
-
}
|
|
84
|
-
const record = {
|
|
85
|
-
actor: opts.actor,
|
|
86
|
-
target: opts.targetLabel,
|
|
87
|
-
target_type: "tool",
|
|
88
|
-
task_type: call.toolName ?? call.method,
|
|
89
|
-
outcome,
|
|
90
|
-
failure_type,
|
|
91
|
-
latency_ms,
|
|
92
|
-
source: opts.source,
|
|
93
|
-
node_id: nodeId,
|
|
94
|
-
};
|
|
95
|
-
// fire-and-forget — 응답 전달을 기다리게 하지 않음
|
|
96
|
-
void recordInteraction(record);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
// 무시, 패스스루 유지
|
|
101
|
-
}
|
|
51
|
+
const rec = buildRecord(pending, state, line, { ...opts, nodeId });
|
|
52
|
+
if (rec)
|
|
53
|
+
void Promise.resolve(record(rec)).catch(() => { }); // fire-and-forget
|
|
102
54
|
});
|
|
103
55
|
child.on("exit", (code) => {
|
|
104
56
|
process.exit(code ?? 0);
|
|
@@ -106,3 +58,60 @@ export function runProxy(opts) {
|
|
|
106
58
|
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
107
59
|
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
108
60
|
}
|
|
61
|
+
/** 클라이언트 → 서버 한 줄. id 있는 요청이면 pending 에 등록. */
|
|
62
|
+
export function trackRequest(pending, line, now = Date.now()) {
|
|
63
|
+
try {
|
|
64
|
+
const msg = JSON.parse(line);
|
|
65
|
+
if (msg?.id !== undefined && typeof msg?.method === "string") {
|
|
66
|
+
pending.set(msg.id, {
|
|
67
|
+
method: msg.method,
|
|
68
|
+
toolName: msg.method === "tools/call" && typeof msg.params?.name === "string" ? msg.params.name : undefined,
|
|
69
|
+
paged: msg.method === "tools/list" && msg.params?.cursor !== undefined,
|
|
70
|
+
startedAt: now,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// JSON이 아니거나 파싱 실패 — 무시하고 패스스루만 유지
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** 서버 → 클라이언트 한 줄. 대기 중인 요청의 응답이면 기록 1건을 만든다(상태도 갱신). */
|
|
79
|
+
export function buildRecord(pending, state, line, opts, now = Date.now()) {
|
|
80
|
+
let msg;
|
|
81
|
+
try {
|
|
82
|
+
msg = JSON.parse(line);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (msg?.id === undefined || !pending.has(msg.id))
|
|
88
|
+
return null;
|
|
89
|
+
const call = pending.get(msg.id);
|
|
90
|
+
pending.delete(msg.id);
|
|
91
|
+
if (!msg.error) {
|
|
92
|
+
if (call.method === "initialize") {
|
|
93
|
+
const v = serverVersionOf(msg);
|
|
94
|
+
if (v)
|
|
95
|
+
state.serverVersion = v;
|
|
96
|
+
}
|
|
97
|
+
else if (call.method === "tools/list" && Array.isArray(msg.result?.tools) && !call.paged && msg.result?.nextCursor === undefined) {
|
|
98
|
+
state.schemaHash = schemaHash(msg.result.tools);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const c = classifyResponse(call.method, msg);
|
|
102
|
+
return {
|
|
103
|
+
actor: opts.actor,
|
|
104
|
+
target: opts.targetLabel,
|
|
105
|
+
target_type: "tool",
|
|
106
|
+
task_type: call.toolName ?? call.method,
|
|
107
|
+
outcome: c.outcome,
|
|
108
|
+
...(c.failure_type ? { failure_type: c.failure_type } : {}),
|
|
109
|
+
...(c.error_layer ? { error_layer: c.error_layer } : {}),
|
|
110
|
+
request_kind: c.request_kind,
|
|
111
|
+
latency_ms: now - call.startedAt,
|
|
112
|
+
source: opts.source,
|
|
113
|
+
node_id: opts.nodeId,
|
|
114
|
+
...(state.serverVersion ? { server_version: state.serverVersion } : {}),
|
|
115
|
+
...(state.schemaHash ? { schema_hash: state.schemaHash } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
package/dist/record.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const DEFAULT_API_URL = "https://lowdown-proxy.vercel.app";
|
|
3
|
+
/** dist/*.js 기준 ../package.json — 기록 행마다 어떤 판정 로직(버전)이 남긴 기록인지 남긴다. */
|
|
4
|
+
export const RECORDER_VERSION = (() => {
|
|
5
|
+
try {
|
|
6
|
+
return createRequire(import.meta.url)("../package.json").version;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return "0.0.0";
|
|
10
|
+
}
|
|
11
|
+
})();
|
|
12
|
+
export function apiBaseUrl() {
|
|
13
|
+
return (process.env.LOWDOWN_API_URL ?? DEFAULT_API_URL).replace(/\/+$/, "");
|
|
14
|
+
}
|
|
15
|
+
let warnedLegacyKey = false;
|
|
16
|
+
/**
|
|
17
|
+
* interaction 을 공개 API(POST /api/interactions)로 보낸다. DB 키가 필요 없다.
|
|
18
|
+
* 절대 throw 하지 않는다 — 기록 실패가 프록시 통신을 막아서는 안 된다(패스스루가 본 기능).
|
|
19
|
+
*/
|
|
20
|
+
export async function recordInteraction(record, deps = {}) {
|
|
21
|
+
if (process.env.LOWDOWN_RECORD === "0")
|
|
22
|
+
return;
|
|
23
|
+
if (process.env.LOWDOWN_SUPABASE_KEY && !warnedLegacyKey) {
|
|
24
|
+
warnedLegacyKey = true;
|
|
25
|
+
console.error("[lowdown] LOWDOWN_SUPABASE_KEY 는 더 이상 쓰지 않습니다(기록은 공개 API 경유). 설정에서 지워도 됩니다.");
|
|
26
|
+
}
|
|
27
|
+
const f = deps.fetchImpl ?? fetch;
|
|
28
|
+
try {
|
|
29
|
+
const res = await f(`${apiBaseUrl()}/api/interactions`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify({ ...record, recorder_version: record.recorder_version ?? RECORDER_VERSION }),
|
|
33
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? 3000),
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok && process.env.LOWDOWN_DEBUG) {
|
|
36
|
+
console.error(`[lowdown] interaction 기록 실패: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (process.env.LOWDOWN_DEBUG)
|
|
41
|
+
console.error("[lowdown] interaction 기록 예외:", err);
|
|
42
|
+
}
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lowdown-proxy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"mcpName": "io.github.PetShopBros/lowdown-proxy",
|
|
5
5
|
"description": "Record and query AI agent/tool/service interactions. Shared interaction history for the multi-agent ecosystem.",
|
|
6
6
|
"type": "module",
|
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
"dist"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
-
"build": "tsc",
|
|
16
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
17
17
|
"dev": "tsx src/cli.ts",
|
|
18
18
|
"scanner:typecheck": "tsc -p scanner",
|
|
19
19
|
"scanner:test": "node --test scanner/lib.test.ts scanner/reputation.test.ts scanner/report.test.ts",
|
|
20
|
-
"scanner:dry": "node scanner/scan.ts --dry-run --limit=30"
|
|
20
|
+
"scanner:dry": "node scanner/scan.ts --dry-run --limit=30",
|
|
21
|
+
"test": "npm run build && node --test test/p0.test.ts"
|
|
21
22
|
},
|
|
22
23
|
"keywords": [
|
|
23
24
|
"mcp",
|
package/dist/supabase.js
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { createClient } from "@supabase/supabase-js";
|
|
2
|
-
let client = null;
|
|
3
|
-
function getClient() {
|
|
4
|
-
if (client)
|
|
5
|
-
return client;
|
|
6
|
-
const url = process.env.LOWDOWN_SUPABASE_URL;
|
|
7
|
-
const key = process.env.LOWDOWN_SUPABASE_KEY;
|
|
8
|
-
if (!url || !key) {
|
|
9
|
-
// 설계 원칙: 자격증명이 없어도 프록시 자체는 죽지 않는다.
|
|
10
|
-
// 패스스루가 우선이고, 기록은 부가 기능이다.
|
|
11
|
-
return null;
|
|
12
|
-
}
|
|
13
|
-
client = createClient(url, key, { auth: { persistSession: false } });
|
|
14
|
-
return client;
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* interaction을 Supabase에 기록한다.
|
|
18
|
-
* 절대 throw하지 않는다 — 기록 실패가 프록시 통신을 막아서는 안 된다.
|
|
19
|
-
* (기록은 부가 기능, 패스스루가 본 기능)
|
|
20
|
-
*/
|
|
21
|
-
export async function recordInteraction(record) {
|
|
22
|
-
const supabase = getClient();
|
|
23
|
-
if (!supabase) {
|
|
24
|
-
if (process.env.LOWDOWN_DEBUG) {
|
|
25
|
-
console.error("[lowdown] LOWDOWN_SUPABASE_URL/KEY 미설정 — 기록 건너뜀:", record);
|
|
26
|
-
}
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
try {
|
|
30
|
-
// Database 타입을 별도 생성하지 않은 v0 상태라 insert 페이로드는 any로 캐스팅.
|
|
31
|
-
// (나중에 `supabase gen types`로 타입을 뽑으면 제거 가능)
|
|
32
|
-
const { error } = await supabase.from("interactions").insert({
|
|
33
|
-
actor: record.actor,
|
|
34
|
-
target: record.target,
|
|
35
|
-
target_type: record.target_type,
|
|
36
|
-
task_type: record.task_type,
|
|
37
|
-
outcome: record.outcome,
|
|
38
|
-
latency_ms: record.latency_ms ?? null,
|
|
39
|
-
source: record.source ?? "organic",
|
|
40
|
-
node_id: record.node_id ?? null,
|
|
41
|
-
failure_type: record.failure_type ?? null,
|
|
42
|
-
});
|
|
43
|
-
if (error && process.env.LOWDOWN_DEBUG) {
|
|
44
|
-
console.error("[lowdown] interaction 기록 실패:", error.message);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
catch (err) {
|
|
48
|
-
if (process.env.LOWDOWN_DEBUG) {
|
|
49
|
-
console.error("[lowdown] interaction 기록 예외:", err);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|