lowdown-proxy 0.1.21 → 0.1.22
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 +14 -0
- package/dist/cli.js +5 -0
- package/dist/lookup.js +232 -0
- package/dist/proxy.js +27 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -124,6 +124,15 @@ C:\Users\you\AppData\Local\MyApp\app.log: 1 new line(s)
|
|
|
124
124
|
| `--watch-log` | App log file to watch; repeat for several files (see [App logs in tool results](#app-logs-in-tool-results)) | none |
|
|
125
125
|
| `--log-pattern` | Regular expression for log lines to report | error, exception, fatal, uncaught, unhandled, failed |
|
|
126
126
|
|
|
127
|
+
## Check at connect time
|
|
128
|
+
|
|
129
|
+
From 0.1.22 the proxy looks up the server it wraps in Lowdown's records (`install-check`) while the server starts, and adds what it finds to the server's `initialize` response:
|
|
130
|
+
|
|
131
|
+
- one line at the end of `result.instructions`: the dated observations Lowdown has for that server and the link to its record, for example `Lowdown install-check for mcp:io.github.user/server (dated observations, not a score): Answered initialize and tools/list on all 12 daily checks … Record: https://lowdown-proxy.vercel.app/servers/io.github.user/server?ref=cite`
|
|
132
|
+
- the same facts as structured values in `result._meta["io.github.petshopbros.lowdown/install-check"]` (`resolved_target`, `summary`, `payment_required`, `packages`, `evidence_levels`, `record_url`, `retrieved_at`; `null` means not observed or not known)
|
|
133
|
+
|
|
134
|
+
The server is found from the wrapped command (`npx`/`bunx`/`pnpx`/`npm exec`/`pnpm dlx` → `npm:<package>`, `uvx`/`uv tool run`/`pipx run` → `pypi:<package>`) or from `--target mcp:…` / `npm:…` / `pypi:…`. Only the package name is sent, never the rest of the command line. Nothing is added when Lowdown has no record for it, and the proxy waits at most 0.8 s: a slow or failed lookup leaves the response unchanged. Tool descriptions and tool results are not changed and nothing is blocked. `LOWDOWN_LOOKUP=0` turns the lookup off.
|
|
135
|
+
|
|
127
136
|
## Recording
|
|
128
137
|
|
|
129
138
|
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.
|
|
@@ -131,6 +140,7 @@ From 0.1.17 the proxy records through the public API (`POST /api/interactions`).
|
|
|
131
140
|
| Variable | Effect |
|
|
132
141
|
|---|---|
|
|
133
142
|
| `LOWDOWN_RECORD=0` | Pass-through only, nothing is recorded |
|
|
143
|
+
| `LOWDOWN_LOOKUP=0` | No connect-time lookup (see [Check at connect time](#check-at-connect-time)) |
|
|
134
144
|
| `LOWDOWN_API_URL` | Record to (and query) another Lowdown deployment |
|
|
135
145
|
|
|
136
146
|
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.
|
|
@@ -310,6 +320,10 @@ PyPI 패키지도 같은 `packages` 블록에 `pypi` 상태로 옵니다. PyPI
|
|
|
310
320
|
|
|
311
321
|
도구 호출은 성공했는데 그 뒤의 앱(씬, 게임, 개발 서버)이 실패하는 경우가 있습니다. 진짜 에러는 앱의 로그 파일에만 찍혀서 에이전트는 모릅니다. `--watch-log`를 주면 `tools/call` 결과마다 직전 호출 이후 로그에 새로 찍힌 줄 중 `--log-pattern`에 맞는 줄이 결과 끝에 붙습니다. 에이전트가 따로 호출할 것은 없습니다. 로그 내용은 로컬에만 머물고 Lowdown에 기록되지 않습니다.
|
|
312
322
|
|
|
323
|
+
### 연결 시점 확인 (0.1.22~)
|
|
324
|
+
|
|
325
|
+
프록시는 감싼 서버가 시작되는 동안 Lowdown 기록(`install-check`)에서 그 서버를 찾아, 서버의 `initialize` 응답에 붙입니다. `result.instructions` 끝에 날짜가 붙은 관측 사실과 기록 주소 한 줄, `result._meta["io.github.petshopbros.lowdown/install-check"]`에 같은 사실의 구조화된 값(`null`은 관측 안 됨·모름)이 들어갑니다. 대상은 감싼 명령(`npx`·`bunx`·`pnpx`·`npm exec`·`pnpm dlx` → `npm:<패키지>`, `uvx`·`uv tool run`·`pipx run` → `pypi:<패키지>`) 또는 `--target mcp:…`/`npm:…`/`pypi:…`에서 정합니다. 패키지 이름만 보내고 나머지 명령줄은 보내지 않습니다. 기록이 없으면 아무것도 붙이지 않고, 최대 0.8초만 기다립니다(늦거나 실패하면 응답 그대로). 도구 설명·도구 결과는 바꾸지 않고 아무것도 막지 않습니다. `LOWDOWN_LOOKUP=0`이면 조회하지 않습니다.
|
|
326
|
+
|
|
313
327
|
### 기록 방식 (0.1.17~)
|
|
314
328
|
|
|
315
329
|
프록시는 공개 API(`POST /api/interactions`)로 기록합니다. DB 키가 필요 없고 `LOWDOWN_SUPABASE_URL` / `LOWDOWN_SUPABASE_KEY`는 더 이상 쓰지 않습니다. `LOWDOWN_RECORD=0`이면 기록 없이 패스스루만 합니다.
|
package/dist/cli.js
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import { runProxy } from "./proxy.js";
|
|
3
3
|
import { LogWatcher } from "./log-watch.js";
|
|
4
4
|
import { defaultTargetLabel } from "./classify.js";
|
|
5
|
+
import { inferLookupTarget } from "./lookup.js";
|
|
5
6
|
// 사용법:
|
|
6
7
|
// npx lowdown-proxy → 인자 없음: Lowdown 조회용 MCP 서버(stdio) 시작 (Registry 설치 경로)
|
|
7
8
|
// npx lowdown-proxy [--actor <id>] [--target <label>] [--source organic|seeded|synthetic] [--test] -- <command> [args...]
|
|
8
9
|
// → MCP 서버를 감싸는 기록 프록시
|
|
9
10
|
// --test : 이 세션의 기록을 synthetic 으로 남긴다(일부러 실패시키는 테스트가 서버 실패로 집계되지 않게).
|
|
10
11
|
// --target 이 없으면 명령줄 전체가 아니라 패키지/파일 이름 하나만 라벨로 쓴다(인자의 키·경로가 공개 기록에 남지 않게).
|
|
12
|
+
// 연결 시점 확인(0.1.22): npx/uvx 등으로 실행한 패키지(또는 --target mcp:…/npm:…/pypi:…)를 Lowdown 에서 찾아
|
|
13
|
+
// initialize 응답에 한 줄을 붙인다. LOWDOWN_LOOKUP=0 이면 하지 않는다.
|
|
11
14
|
function parseArgs(argv) {
|
|
12
15
|
const dashIndex = argv.indexOf("--");
|
|
13
16
|
if (dashIndex === -1) {
|
|
@@ -34,12 +37,14 @@ function parseArgs(argv) {
|
|
|
34
37
|
process.exit(1);
|
|
35
38
|
}
|
|
36
39
|
const source = (flags.includes("--test") ? "synthetic" : sourceFlag);
|
|
40
|
+
const explicitTarget = getFlag("--target", "");
|
|
37
41
|
return {
|
|
38
42
|
actor: getFlag("--actor", "anonymous"),
|
|
39
43
|
targetLabel: getFlag("--target", defaultTargetLabel(commandParts[0], commandParts.slice(1))),
|
|
40
44
|
source,
|
|
41
45
|
command: commandParts[0],
|
|
42
46
|
args: commandParts.slice(1),
|
|
47
|
+
lookupTarget: inferLookupTarget(commandParts[0], commandParts.slice(1), explicitTarget || undefined),
|
|
43
48
|
logWatcher: watchLogs.length ? new LogWatcher(watchLogs, getFlag("--log-pattern", "")) : undefined,
|
|
44
49
|
};
|
|
45
50
|
}
|
package/dist/lookup.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { apiBaseUrl, RECORDER_VERSION } from "./record.js";
|
|
2
|
+
import { getNodeId } from "./node-identity.js";
|
|
3
|
+
/**
|
|
4
|
+
* 연결 시점 확인 (0.1.22, 2026-11-14).
|
|
5
|
+
*
|
|
6
|
+
* 프록시가 감싼 서버를 Lowdown 기록에서 찾아(install-check), 그 서버의 initialize 응답에
|
|
7
|
+
* - result.instructions 끝에 한 줄(날짜가 붙은 관측 사실 + 기록 주소)
|
|
8
|
+
* - result._meta["io.github.petshopbros.lowdown/install-check"] 에 구조화된 값
|
|
9
|
+
* 을 붙인다. 막지 않는다: 조회는 서버 시작과 동시에 시작하고, 정해진 시간(기본 0.8초) 안에 오지 않으면 그냥 지나간다.
|
|
10
|
+
* 도구 설명·도구 결과는 바꾸지 않는다. LOWDOWN_LOOKUP=0 이면 조회하지 않는다.
|
|
11
|
+
* 점수·판정은 넣지 않는다. 기록이 없으면 아무것도 붙이지 않는다(기록 없음은 문제 없음이 아니다).
|
|
12
|
+
*/
|
|
13
|
+
export const META_KEY = "io.github.petshopbros.lowdown/install-check";
|
|
14
|
+
export const LOOKUP_BUDGET_MS = 800;
|
|
15
|
+
const MAX_MESSAGE = 500;
|
|
16
|
+
const SITE = "https://lowdown-proxy.vercel.app/";
|
|
17
|
+
const NPM_NAME = /^(?:@[A-Za-z0-9~-][A-Za-z0-9._~-]*\/)?[A-Za-z0-9~-][A-Za-z0-9._~-]*$/;
|
|
18
|
+
const PYPI_NAME = /^([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9])$/;
|
|
19
|
+
const MCP_TARGET = /^mcp:[A-Za-z0-9._~-]{1,200}\/[A-Za-z0-9._~-]{1,200}$/;
|
|
20
|
+
const EXPLICIT = /^(mcp|npm|pypi):\S{1,300}$/;
|
|
21
|
+
export function lookupEnabled(env = process.env) {
|
|
22
|
+
return env.LOWDOWN_LOOKUP !== "0";
|
|
23
|
+
}
|
|
24
|
+
// 실행기별: 값을 받는 옵션(다음 토큰을 건너뜀)과 패키지를 지정하는 옵션
|
|
25
|
+
const NPM_RUNNERS = new Set(["npx", "npx.cmd", "bunx", "bunx.cmd", "pnpx", "pnpx.cmd"]);
|
|
26
|
+
const NPM_PKG_FLAGS = new Set(["-p", "--package"]);
|
|
27
|
+
const NPM_VALUE_FLAGS = new Set(["--registry", "--cache", "--userconfig", "-w", "--workspace", "--node-options", "--prefix"]);
|
|
28
|
+
const NPM_STOP_FLAGS = new Set(["-c", "--call"]);
|
|
29
|
+
const PYPI_PKG_FLAGS = new Set(["--from"]);
|
|
30
|
+
const PYPI_VALUE_FLAGS = new Set(["--with", "--with-editable", "--with-requirements", "--python", "-p", "--index", "--index-url", "--extra-index-url", "--default-index", "--constraints", "--overrides", "-c", "--directory", "--project", "--cache-dir", "--env-file"]);
|
|
31
|
+
function base(tok) {
|
|
32
|
+
return (tok.split(/[\\/]/).pop() ?? tok).toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
function npmName(spec) {
|
|
35
|
+
if (/[\\:]/.test(spec) || spec.startsWith(".") || spec.startsWith("/"))
|
|
36
|
+
return null;
|
|
37
|
+
let name = spec;
|
|
38
|
+
if (spec.startsWith("@")) {
|
|
39
|
+
const at = spec.indexOf("@", 1);
|
|
40
|
+
if (at > 0)
|
|
41
|
+
name = spec.slice(0, at);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const at = spec.indexOf("@");
|
|
45
|
+
if (at > 0)
|
|
46
|
+
name = spec.slice(0, at);
|
|
47
|
+
}
|
|
48
|
+
return name.length <= 214 && NPM_NAME.test(name) ? name : null;
|
|
49
|
+
}
|
|
50
|
+
function pypiName(spec) {
|
|
51
|
+
if (/[\\/:]/.test(spec) || spec.startsWith("."))
|
|
52
|
+
return null;
|
|
53
|
+
const name = spec.split(/==|>=|<=|~=|!=|@|\[|<|>/)[0].trim();
|
|
54
|
+
return name.length <= 200 && PYPI_NAME.test(name) ? name : null;
|
|
55
|
+
}
|
|
56
|
+
/** 실행기 다음 토큰들에서 패키지 하나. 값을 받는 옵션은 건너뛴다. */
|
|
57
|
+
function packageAfter(tokens, pkgFlags, valueFlags, stopFlags) {
|
|
58
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
59
|
+
const t = tokens[i];
|
|
60
|
+
if (t === "--")
|
|
61
|
+
return tokens[i + 1] ?? null;
|
|
62
|
+
if (stopFlags.has(t))
|
|
63
|
+
return null;
|
|
64
|
+
const eq = t.indexOf("=");
|
|
65
|
+
const flag = t.startsWith("-") && eq > 0 ? t.slice(0, eq) : t;
|
|
66
|
+
if (pkgFlags.has(flag))
|
|
67
|
+
return eq > 0 && t.startsWith("-") ? t.slice(eq + 1) : tokens[i + 1] ?? null;
|
|
68
|
+
if (valueFlags.has(flag)) {
|
|
69
|
+
if (!(t.startsWith("-") && eq > 0))
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (t.startsWith("-"))
|
|
74
|
+
continue;
|
|
75
|
+
return t;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 감싼 명령에서 조회 대상. --target 이 mcp:/npm:/pypi: 형식이면 그것을, 아니면
|
|
81
|
+
* npx·bunx·pnpx·npm exec·pnpm dlx·yarn dlx → npm:<패키지>, uvx·uv tool run·pipx run → pypi:<패키지>.
|
|
82
|
+
* 그 밖(node 스크립트, python -m, docker …)은 null — 추측하지 않는다.
|
|
83
|
+
*/
|
|
84
|
+
export function inferLookupTarget(command, args, explicitTarget) {
|
|
85
|
+
if (explicitTarget && EXPLICIT.test(explicitTarget))
|
|
86
|
+
return explicitTarget;
|
|
87
|
+
let tokens = [command, ...args];
|
|
88
|
+
while (tokens.length && (["cmd", "cmd.exe"].includes(base(tokens[0])) || tokens[0].toLowerCase() === "/c"))
|
|
89
|
+
tokens = tokens.slice(1);
|
|
90
|
+
if (!tokens.length)
|
|
91
|
+
return null;
|
|
92
|
+
const head = base(tokens[0]);
|
|
93
|
+
const rest = tokens.slice(1);
|
|
94
|
+
let eco = null;
|
|
95
|
+
let after = [];
|
|
96
|
+
if (NPM_RUNNERS.has(head)) {
|
|
97
|
+
eco = "npm";
|
|
98
|
+
after = rest;
|
|
99
|
+
}
|
|
100
|
+
else if ((head === "npm" || head === "npm.cmd") && rest[0] === "exec") {
|
|
101
|
+
eco = "npm";
|
|
102
|
+
after = rest.slice(1);
|
|
103
|
+
}
|
|
104
|
+
else if ((head === "pnpm" || head === "pnpm.cmd" || head === "yarn" || head === "yarn.cmd") && rest[0] === "dlx") {
|
|
105
|
+
eco = "npm";
|
|
106
|
+
after = rest.slice(1);
|
|
107
|
+
}
|
|
108
|
+
else if (head === "uvx" || head === "uvx.exe") {
|
|
109
|
+
eco = "pypi";
|
|
110
|
+
after = rest;
|
|
111
|
+
}
|
|
112
|
+
else if ((head === "uv" || head === "uv.exe") && rest[0] === "tool" && rest[1] === "run") {
|
|
113
|
+
eco = "pypi";
|
|
114
|
+
after = rest.slice(2);
|
|
115
|
+
}
|
|
116
|
+
else if ((head === "pipx" || head === "pipx.exe") && rest[0] === "run") {
|
|
117
|
+
eco = "pypi";
|
|
118
|
+
after = rest.slice(1);
|
|
119
|
+
}
|
|
120
|
+
if (!eco)
|
|
121
|
+
return null;
|
|
122
|
+
if (eco === "npm") {
|
|
123
|
+
const spec = packageAfter(after, NPM_PKG_FLAGS, NPM_VALUE_FLAGS, NPM_STOP_FLAGS);
|
|
124
|
+
const name = spec ? npmName(spec) : null;
|
|
125
|
+
return name ? `npm:${name}` : null;
|
|
126
|
+
}
|
|
127
|
+
const spec = packageAfter(after, PYPI_PKG_FLAGS, PYPI_VALUE_FLAGS, new Set());
|
|
128
|
+
const name = spec ? pypiName(spec) : null;
|
|
129
|
+
return name ? `pypi:${name}` : null;
|
|
130
|
+
}
|
|
131
|
+
function oneLine(s, max) {
|
|
132
|
+
const t = s.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2064\ufeff]/g, " ").replace(/\s+/g, " ").trim();
|
|
133
|
+
return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t;
|
|
134
|
+
}
|
|
135
|
+
function packageFacts(packages) {
|
|
136
|
+
if (!Array.isArray(packages))
|
|
137
|
+
return [];
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const p of packages.slice(0, 10)) {
|
|
140
|
+
if (!p || typeof p !== "object")
|
|
141
|
+
continue;
|
|
142
|
+
const reg = p.registry;
|
|
143
|
+
const name = typeof p.name === "string" ? p.name.slice(0, 214) : null;
|
|
144
|
+
if (reg === "npm") {
|
|
145
|
+
const n = p.npm;
|
|
146
|
+
out.push({ registry: "npm", name, status: n && typeof n.status === "string" ? n.status : "not_checked", latest_deprecated: n?.latest_deprecated ?? null });
|
|
147
|
+
}
|
|
148
|
+
else if (reg === "pypi") {
|
|
149
|
+
const n = p.pypi;
|
|
150
|
+
out.push({ registry: "pypi", name, status: n && typeof n.status === "string" ? n.status : "not_checked", latest_yanked: n?.latest_yanked ?? null, project_status: n?.project_status ?? null });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* install-check 응답 → 붙일 한 줄과 _meta. 확인된 Registry 이름(resolved_target)이 없으면 null.
|
|
157
|
+
* 값은 응답에 있는 그대로(없으면 null) — 모름(null)과 확인 안 함(not_checked)을 섞지 않는다.
|
|
158
|
+
*/
|
|
159
|
+
export function bundleFrom(target, body, now = Date.now()) {
|
|
160
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
161
|
+
return null;
|
|
162
|
+
const b = body;
|
|
163
|
+
if (b.error)
|
|
164
|
+
return null;
|
|
165
|
+
const resolved = typeof b.resolved_target === "string" && MCP_TARGET.test(b.resolved_target) ? b.resolved_target : null;
|
|
166
|
+
if (!resolved)
|
|
167
|
+
return null;
|
|
168
|
+
const src = b.source && typeof b.source === "object" ? b.source : {};
|
|
169
|
+
const citeUrl = typeof src.cite_url === "string" && src.cite_url.startsWith(SITE) && src.cite_url.length <= 500 ? src.cite_url : null;
|
|
170
|
+
const lookupUrl = `${SITE}api/reputation/${resolved}?menu=install-check`;
|
|
171
|
+
const message = typeof b.message === "string" ? oneLine(b.message, MAX_MESSAGE) : "";
|
|
172
|
+
const line = `Lowdown install-check for ${resolved} (dated observations, not a score): ${message ? `${message} ` : ""}Record: ${citeUrl ?? lookupUrl}`;
|
|
173
|
+
const observed = b.payment?.observed;
|
|
174
|
+
const meta = {
|
|
175
|
+
schema_version: 1,
|
|
176
|
+
target,
|
|
177
|
+
resolved_target: resolved,
|
|
178
|
+
retrieved_at: new Date(now).toISOString(),
|
|
179
|
+
summary: b.summary && typeof b.summary === "object" ? b.summary : null,
|
|
180
|
+
payment_required: observed && typeof observed.payment_required === "boolean" ? observed.payment_required : null,
|
|
181
|
+
payment_latest_check: observed && typeof observed.latest_check === "string" ? observed.latest_check : null,
|
|
182
|
+
packages: packageFacts(b.packages),
|
|
183
|
+
evidence_levels: Array.isArray(b.evidence_levels?.present) ? b.evidence_levels.present.filter((x) => typeof x === "string").slice(0, 10) : [],
|
|
184
|
+
record_url: citeUrl,
|
|
185
|
+
lookup_url: lookupUrl,
|
|
186
|
+
note: "Observed by Lowdown; not a score. null means not observed or not known. Lowdown does not block or change this server's tools.",
|
|
187
|
+
};
|
|
188
|
+
return { line, meta };
|
|
189
|
+
}
|
|
190
|
+
/** install-check 조회. 실패·시간 초과·기록 없음이면 null. 절대 throw 하지 않는다. */
|
|
191
|
+
export async function fetchBundle(target, opts = {}) {
|
|
192
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
193
|
+
const ctrl = new AbortController();
|
|
194
|
+
const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? LOOKUP_BUDGET_MS);
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetchImpl(`${apiBaseUrl()}/api/reputation/${encodeURIComponent(target)}?menu=install-check`, {
|
|
197
|
+
headers: {
|
|
198
|
+
"user-agent": `lowdown-proxy/${RECORDER_VERSION} (connect-check)`,
|
|
199
|
+
"x-lowdown-source": "organic",
|
|
200
|
+
"x-lowdown-node-id": getNodeId(),
|
|
201
|
+
},
|
|
202
|
+
signal: ctrl.signal,
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok)
|
|
205
|
+
return null;
|
|
206
|
+
return bundleFrom(target, await res.json(), (opts.now ?? Date.now)());
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** 서버 → 클라이언트 initialize 응답 한 줄에 붙인다. 형식이 맞지 않으면 원래 줄 그대로. */
|
|
216
|
+
export function addBundleToInitialize(line, bundle) {
|
|
217
|
+
if (!bundle)
|
|
218
|
+
return line;
|
|
219
|
+
try {
|
|
220
|
+
const m = JSON.parse(line);
|
|
221
|
+
if (!m || typeof m !== "object" || !m.result || typeof m.result !== "object" || Array.isArray(m.result))
|
|
222
|
+
return line;
|
|
223
|
+
const prev = typeof m.result.instructions === "string" && m.result.instructions.length > 0 ? `${m.result.instructions}\n\n` : "";
|
|
224
|
+
m.result.instructions = `${prev}${bundle.line}`;
|
|
225
|
+
const meta = m.result._meta && typeof m.result._meta === "object" && !Array.isArray(m.result._meta) ? m.result._meta : {};
|
|
226
|
+
m.result._meta = { ...meta, [META_KEY]: bundle.meta };
|
|
227
|
+
return JSON.stringify(m);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return line;
|
|
231
|
+
}
|
|
232
|
+
}
|
package/dist/proxy.js
CHANGED
|
@@ -4,6 +4,7 @@ import { recordInteraction } from "./record.js";
|
|
|
4
4
|
import { getNodeId } from "./node-identity.js";
|
|
5
5
|
import { annotateToolResult } from "./log-watch.js";
|
|
6
6
|
import { classifyResponse, schemaHash, serverVersionOf } from "./classify.js";
|
|
7
|
+
import { addBundleToInitialize, fetchBundle, lookupEnabled } from "./lookup.js";
|
|
7
8
|
/**
|
|
8
9
|
* lowdown-proxy 핵심 로직.
|
|
9
10
|
*
|
|
@@ -13,6 +14,9 @@ import { classifyResponse, schemaHash, serverVersionOf } from "./classify.js";
|
|
|
13
14
|
* - 판정 기준은 classify.ts: tools/call 의 isError 는 실패, 잘못된 요청(-32602 등)은 서버 실패와 분리,
|
|
14
15
|
* tools/call 이 아닌 요청은 protocol 로 남기고 성공률에 넣지 않는다.
|
|
15
16
|
* - 각 행에 그 시점의 server_version(initialize)·schema_hash(tools/list)를 찍는다.
|
|
17
|
+
* - 연결 시점 확인(0.1.22, lookup.ts): 서버 시작과 동시에 install-check 를 조회해 initialize 응답의
|
|
18
|
+
* instructions 끝 한 줄·_meta 에 붙인다. 최대 0.8초만 기다리고, 실패하면 원래 응답 그대로. 도구는 막지 않는다.
|
|
19
|
+
* 서버 → 클라이언트 줄 순서는 바뀌지 않는다(initialize 응답을 기다리는 동안 뒤 줄도 그 뒤에 나간다).
|
|
16
20
|
*/
|
|
17
21
|
export function runProxy(opts) {
|
|
18
22
|
const nodeId = getNodeId();
|
|
@@ -23,6 +27,9 @@ export function runProxy(opts) {
|
|
|
23
27
|
});
|
|
24
28
|
const pending = new Map();
|
|
25
29
|
const state = {};
|
|
30
|
+
const lookup = opts.lookup ?? ((t) => fetchBundle(t));
|
|
31
|
+
const bundleP = opts.lookupTarget && lookupEnabled() ? lookup(opts.lookupTarget).catch(() => null) : Promise.resolve(null);
|
|
32
|
+
let outChain = Promise.resolve();
|
|
26
33
|
// ── Client(부모 프로세스의 stdin) → Server(child.stdin) ──
|
|
27
34
|
const clientReader = createInterface({ input: process.stdin });
|
|
28
35
|
clientReader.on("line", (line) => {
|
|
@@ -34,26 +41,33 @@ export function runProxy(opts) {
|
|
|
34
41
|
const serverReader = createInterface({ input: child.stdout });
|
|
35
42
|
serverReader.on("line", (line) => {
|
|
36
43
|
let out = line;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
// passthrough
|
|
44
|
+
let isInitialize = false;
|
|
45
|
+
try {
|
|
46
|
+
const m = JSON.parse(line);
|
|
47
|
+
const method = m?.id !== undefined ? pending.get(m.id)?.method : undefined;
|
|
48
|
+
if (opts.logWatcher && method === "tools/call") {
|
|
49
|
+
const annotated = annotateToolResult(m, opts.logWatcher);
|
|
50
|
+
if (annotated)
|
|
51
|
+
out = JSON.stringify(annotated);
|
|
48
52
|
}
|
|
53
|
+
isInitialize = method === "initialize" && !!m.result;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// passthrough
|
|
49
57
|
}
|
|
50
|
-
|
|
58
|
+
const ready = out;
|
|
59
|
+
outChain = outChain.then(async () => {
|
|
60
|
+
const text = isInitialize ? addBundleToInitialize(ready, await bundleP) : ready;
|
|
61
|
+
process.stdout.write(text + "\n");
|
|
62
|
+
}).catch(() => {
|
|
63
|
+
process.stdout.write(ready + "\n");
|
|
64
|
+
});
|
|
51
65
|
const rec = buildRecord(pending, state, line, { ...opts, nodeId });
|
|
52
66
|
if (rec)
|
|
53
67
|
void Promise.resolve(record(rec)).catch(() => { }); // fire-and-forget
|
|
54
68
|
});
|
|
55
69
|
child.on("exit", (code) => {
|
|
56
|
-
process.exit(code ?? 0);
|
|
70
|
+
void outChain.finally(() => process.exit(code ?? 0));
|
|
57
71
|
});
|
|
58
72
|
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
59
73
|
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lowdown-proxy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"mcpName": "io.github.PetShopBros/lowdown-proxy",
|
|
5
5
|
"description": "Check an MCP server before you install it: dated facts from daily initialize/tools/list checks of public MCP servers, MCP Registry declarations, npm/PyPI package state, tool-definition changes and payment terms (x402, MPP, L402). Remote MCP and stdio. No scores.",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"sql:check": "node scanner/migrations.ts check",
|
|
21
21
|
"scanner:test": "node --test scanner/lib.test.ts scanner/registry-diff.test.ts scanner/first-seen.test.ts scanner/archive.test.ts scanner/reputation.test.ts scanner/report.test.ts scanner/tool-defs.test.ts scanner/market.test.ts scanner/private-archive.test.ts scanner/requested.test.ts scanner/sandbox-public.test.ts scanner/npm.test.ts scanner/pypi.test.ts scanner/pages.test.ts scanner/unavailable.test.ts scanner/target-status.test.ts scanner/content-flags.test.ts scanner/page.test.ts scanner/cards.test.ts scanner/mcp-remote.test.ts scanner/find.test.ts scanner/no-bom.test.ts scanner/status.test.ts scanner/sitemap.test.ts scanner/schema.test.ts scanner/agents.test.ts scanner/server-card.test.ts scanner/declared.test.ts scanner/erc8004.test.ts scanner/card-discovery.test.ts scanner/rotating.test.ts scanner/smoke.test.ts scanner/migrations.test.ts scanner/ops.test.ts scanner/monthly.test.ts scanner/payment.test.ts scanner/attest.test.ts",
|
|
22
22
|
"scanner:dry": "node scanner/scan.ts --dry-run --limit=30",
|
|
23
|
-
"test": "npm run build && node --test test/p0.test.ts",
|
|
23
|
+
"test": "npm run build && node --test test/p0.test.ts test/lookup.test.ts",
|
|
24
24
|
"sandbox:test": "node --test sandbox/sandbox.test.ts sandbox/observe.test.ts",
|
|
25
25
|
"sandbox:typecheck": "tsc -p sandbox"
|
|
26
26
|
},
|