lowdown-proxy 0.1.21 → 0.1.23

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 CHANGED
@@ -2,13 +2,14 @@
2
2
 
3
3
  [![Listed on mcpservers.org](https://mcpservers.org/badge.svg)](https://mcpservers.org/servers/petshopbros/lowdown-proxy)
4
4
 
5
- Agents should not have to trust anyone they've never interacted with.
5
+ Lowdown records dated facts about public MCP servers before you install or connect them:
6
+ whether each server answered `initialize` and `tools/list` on daily checks, what the official
7
+ MCP Registry and the server's own cards declare, the state of its npm and PyPI packages,
8
+ when its tool definitions changed, and the payment terms it states. Facts, not scores.
6
9
 
7
- `lowdown-proxy` records interactions between AI agents, tools, and services — and makes
8
- that history queryable by anyone. Before choosing a tool, calling an API, or delegating
9
- to another agent, you can ask: *what has the network seen from this one?*
10
-
11
- A transparent stdio proxy for MCP servers is the first entry point. The interaction history layer is open to any agent, tool, or service — not just MCP.
10
+ Ask it three ways: the remote MCP server at `https://lowdown-proxy.vercel.app/mcp`
11
+ (`get_lowdown`, `find_servers`), the HTTP API, or the open daily dataset (CC BY 4.0).
12
+ `lowdown-proxy` also wraps a stdio MCP server and adds this record to its `initialize` response.
12
13
 
13
14
  See [DESIGN.md](./DESIGN.md) for background and design principles.
14
15
 
@@ -124,6 +125,15 @@ C:\Users\you\AppData\Local\MyApp\app.log: 1 new line(s)
124
125
  | `--watch-log` | App log file to watch; repeat for several files (see [App logs in tool results](#app-logs-in-tool-results)) | none |
125
126
  | `--log-pattern` | Regular expression for log lines to report | error, exception, fatal, uncaught, unhandled, failed |
126
127
 
128
+ ## Check at connect time
129
+
130
+ 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:
131
+
132
+ - 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`
133
+ - 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)
134
+
135
+ 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.
136
+
127
137
  ## Recording
128
138
 
129
139
  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 +141,7 @@ From 0.1.17 the proxy records through the public API (`POST /api/interactions`).
131
141
  | Variable | Effect |
132
142
  |---|---|
133
143
  | `LOWDOWN_RECORD=0` | Pass-through only, nothing is recorded |
144
+ | `LOWDOWN_LOOKUP=0` | No connect-time lookup (see [Check at connect time](#check-at-connect-time)) |
134
145
  | `LOWDOWN_API_URL` | Record to (and query) another Lowdown deployment |
135
146
 
136
147
  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 +321,10 @@ PyPI 패키지도 같은 `packages` 블록에 `pypi` 상태로 옵니다. PyPI
310
321
 
311
322
  도구 호출은 성공했는데 그 뒤의 앱(씬, 게임, 개발 서버)이 실패하는 경우가 있습니다. 진짜 에러는 앱의 로그 파일에만 찍혀서 에이전트는 모릅니다. `--watch-log`를 주면 `tools/call` 결과마다 직전 호출 이후 로그에 새로 찍힌 줄 중 `--log-pattern`에 맞는 줄이 결과 끝에 붙습니다. 에이전트가 따로 호출할 것은 없습니다. 로그 내용은 로컬에만 머물고 Lowdown에 기록되지 않습니다.
312
323
 
324
+ ### 연결 시점 확인 (0.1.22~)
325
+
326
+ 프록시는 감싼 서버가 시작되는 동안 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`이면 조회하지 않습니다.
327
+
313
328
  ### 기록 방식 (0.1.17~)
314
329
 
315
330
  프록시는 공개 API(`POST /api/interactions`)로 기록합니다. DB 키가 필요 없고 `LOWDOWN_SUPABASE_URL` / `LOWDOWN_SUPABASE_KEY`는 더 이상 쓰지 않습니다. `LOWDOWN_RECORD=0`이면 기록 없이 패스스루만 합니다.
@@ -328,4 +343,4 @@ PyPI 패키지도 같은 `packages` 블록에 `pypi` 상태로 옵니다. PyPI
328
343
  - **사실만 기록**: v0는 성공/실패/지연시간만 기록합니다. 품질 판단은 범위 밖입니다.
329
344
  - **출처 구분**: 모든 기록은 `organic` / `seeded` / `synthetic`으로 태깅됩니다.
330
345
 
331
- 자세한 배경과 설계 원칙은 [DESIGN.md](./DESIGN.md)를 참고하세요.
346
+ 자세한 배경과 설계 원칙은 [DESIGN.md](./DESIGN.md)를 참고하세요.
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
+ }
@@ -18,50 +18,94 @@ const server = new McpServer({
18
18
  name: "lowdown",
19
19
  version: PKG_VERSION,
20
20
  });
21
+ // 오류 응답(2026-11-15): 원격 MCP(api/mcp.ts toolError·upstreamError)와 같은 모양 { error, message, retryable, http_status? }.
22
+ // 조회 API 가 표준 오류 JSON 을 주면 그대로 넘기고, 연결 실패·JSON 아닌 응답도 isError 로 알린다(예외로 끝나지 않게).
23
+ function toolError(body) {
24
+ return { isError: true, content: [{ type: "text", text: JSON.stringify(body) }] };
25
+ }
26
+ async function callApi(url, init, what) {
27
+ let res;
28
+ try {
29
+ res = await fetch(url, { ...init, signal: AbortSignal.timeout(20_000) });
30
+ }
31
+ catch {
32
+ return { ok: false, result: toolError({ error: "temporarily_unavailable", message: `Lowdown ${what} did not answer. Retry later.`, retryable: true }) };
33
+ }
34
+ const text = await res.text();
35
+ let data = null;
36
+ try {
37
+ data = JSON.parse(text);
38
+ }
39
+ catch { /* JSON 아님 */ }
40
+ if (!res.ok || data === null) {
41
+ const base = data && typeof data === "object" && typeof data.error === "string" ? data : {
42
+ error: res.status === 429 ? "rate_limited" : res.status >= 500 ? "temporarily_unavailable" : "invalid_parameter",
43
+ message: `Lowdown ${what} answered HTTP ${res.status}.`,
44
+ };
45
+ return { ok: false, result: toolError({ ...base, retryable: typeof base.retryable === "boolean" ? base.retryable : res.status === 429 || res.status >= 500, http_status: res.status }) };
46
+ }
47
+ return { ok: true, data };
48
+ }
49
+ // annotations(2026-11-15): 원격 MCP 와 같은 기준. 쓰기는 record_interaction 하나뿐(Lowdown 기록에 한 줄 추가, 지우거나 바꾸지 않음).
50
+ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
21
51
  // ── 1. get_lowdown ──────────────────────────────────────────────
22
52
  // 메뉴 목록은 원격 MCP(api/mcp.ts MENUS)·조회 API(api/reputation/[target].ts DISHES·SETS)와 같아야 한다.
23
53
  const MENUS = [
24
54
  "install-check", "change-watch", "full",
25
55
  "status", "payment", "packages", "tools", "declared-vs-observed", "content-flags", "tool-list", "registry", "history", "agent-reports",
26
56
  ];
27
- server.tool("get_lowdown", "Look up recorded facts about an MCP server before installing or connecting to it. " +
28
- "Accepts an MCP Registry name ('mcp:io.github.user/server'), a short name, or a package ('npm:@scope/name', 'pypi:name'). " +
29
- "Returns dated facts, not scores: `status` (latest daily initialize/tools/list check: answered, required authentication, required payment, or no response), " +
30
- "`payment` (HTTP 402 payment terms stated on the latest check — x402, MPP or L402 — and payment support declared in the server's card or an ERC-8004 registration file; Lowdown does not pay), " +
31
- "`registry` (what the official MCP Registry declares, and changes in the last 30 days), " +
32
- "`packages` (declared npm/PyPI packages: deprecated, yanked, archived, or 404), " +
33
- "`scanner` (when tool definitions changed and content markers such as a new URL or invisible characters), and agent reports. " +
34
- "The optional `menu` returns only one part, e.g. 'install-check' (the facts used before installing). " +
35
- "Absence of data says nothing about a server's status.", {
36
- target: z.string().min(1).max(300).describe("MCP Registry name with the mcp: prefix (e.g. 'mcp:io.github.user/server'), a short name, or a package with the npm: or pypi: prefix"),
37
- menu: z.enum(MENUS).optional().describe("Optional. Before installing, use 'install-check' (status, payment, packages, declared-vs-observed, content-flags). 'change-watch' = what changed recently, " +
38
- "or one part: status, payment, packages, tools, declared-vs-observed, content-flags, tool-list, registry, history, agent-reports. Omit or 'full' for everything."),
57
+ server.registerTool("get_lowdown", {
58
+ title: "Look up recorded facts about an MCP server",
59
+ description: "Look up recorded facts about an MCP server before installing or connecting to it. " +
60
+ "Accepts an MCP Registry name ('mcp:io.github.user/server'), a short name, or a package ('npm:@scope/name', 'pypi:name'). " +
61
+ "Returns dated facts, not scores: `status` (latest daily initialize/tools/list check: answered, required authentication, required payment, or no response), " +
62
+ "`payment` (HTTP 402 payment terms stated on the latest check — x402, MPP or L402 — and payment support declared in the server's card or an ERC-8004 registration file; Lowdown does not pay), " +
63
+ "`registry` (what the official MCP Registry declares, and changes in the last 30 days), " +
64
+ "`packages` (declared npm/PyPI packages: deprecated, yanked, archived, or 404), " +
65
+ "`scanner` (when tool definitions changed and content markers such as a new URL or invisible characters), and agent reports. " +
66
+ "The optional `menu` returns only one part, e.g. 'install-check' (the facts used before installing). " +
67
+ "Absence of data says nothing about a server's status. " +
68
+ "When Lowdown has no record for the name, the answer says so in `message` (with close Registry names in `registry_candidates` when there are any); it is not an error. " +
69
+ "If Lowdown cannot read its records, the tool returns an error with `retryable: true`. Read-only, no authentication, no tool of the looked-up server is called.",
70
+ inputSchema: {
71
+ target: z.string().min(1).max(300).describe("MCP Registry name with the mcp: prefix (e.g. 'mcp:io.github.user/server'), a short name, or a package with the npm: or pypi: prefix"),
72
+ menu: z.enum(MENUS).optional().describe("Optional. Before installing, use 'install-check' (status, payment, packages, declared-vs-observed, content-flags). 'change-watch' = what changed recently, " +
73
+ "or one part: status, payment, packages, tools, declared-vs-observed, content-flags, tool-list, registry, history, agent-reports. Omit or 'full' for everything."),
74
+ },
75
+ annotations: { title: "Look up recorded facts about an MCP server", ...READ_ONLY },
39
76
  }, async ({ target, menu }) => {
40
77
  const q = menu && menu !== "full" ? `?menu=${encodeURIComponent(menu)}` : "";
41
- const res = await fetch(`${BASE_URL}/api/reputation/${encodeURIComponent(target.trim())}${q}`, { headers: { "x-lowdown-source": "organic", "x-lowdown-node-id": NODE_ID } });
42
- const data = await res.json();
78
+ const r = await callApi(`${BASE_URL}/api/reputation/${encodeURIComponent(target.trim())}${q}`, { headers: { "x-lowdown-source": "organic", "x-lowdown-node-id": NODE_ID } }, "lookup");
79
+ if (!r.ok)
80
+ return r.result;
43
81
  return {
44
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
82
+ content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }],
45
83
  };
46
84
  });
47
85
  // ── 2. record_interaction ───────────────────────────────────────
48
- server.tool("record_interaction", "Record an interaction with an agent, tool, or service after using it. " +
49
- "Helps build the shared reputation dataset for the community. " +
50
- "If the call was an intentional negative test (e.g. deliberately invalid input), set source to 'synthetic' " +
51
- "so it is not counted as a failure of the target.", {
52
- actor: z.string().describe("Your agent/system identifier"),
53
- target: z.string().describe("Identifier of what you used"),
54
- target_type: z.enum(["agent", "tool", "service", "human"]),
55
- task_type: z.string().describe("What kind of task was performed. e.g. 'web_search', 'code_gen'"),
56
- outcome: z.enum(["success", "partial", "failure"]),
57
- failure_type: z.enum(["invalid_arguments", "timeout", "server_error", "tool_error", "unknown"])
58
- .optional()
59
- .describe("Required when outcome is failure. Distinguish tool failure vs argument error."),
60
- latency_ms: z.number().optional().describe("How long it took in milliseconds"),
61
- source: z.enum(["organic", "seeded", "synthetic"]).optional()
62
- .describe("organic (default) for real use; synthetic for tests, including intentional failure tests"),
86
+ server.registerTool("record_interaction", {
87
+ title: "Report how a tool or agent worked for you",
88
+ description: "Record an interaction with an agent, tool, or service after using it. " +
89
+ "Helps build the shared reputation dataset for the community. " +
90
+ "If the call was an intentional negative test (e.g. deliberately invalid input), set source to 'synthetic' " +
91
+ "so it is not counted as a failure of the target. " +
92
+ "Writes one report to Lowdown (kept apart from Scanner observations); nothing is changed or deleted, and each call adds a new report.",
93
+ inputSchema: {
94
+ actor: z.string().describe("Your agent/system identifier"),
95
+ target: z.string().describe("Identifier of what you used"),
96
+ target_type: z.enum(["agent", "tool", "service", "human"]),
97
+ task_type: z.string().describe("What kind of task was performed. e.g. 'web_search', 'code_gen'"),
98
+ outcome: z.enum(["success", "partial", "failure"]),
99
+ failure_type: z.enum(["invalid_arguments", "timeout", "server_error", "tool_error", "unknown"])
100
+ .optional()
101
+ .describe("Required when outcome is failure. Distinguish tool failure vs argument error."),
102
+ latency_ms: z.number().optional().describe("How long it took in milliseconds"),
103
+ source: z.enum(["organic", "seeded", "synthetic"]).optional()
104
+ .describe("organic (default) for real use; synthetic for tests, including intentional failure tests"),
105
+ },
106
+ annotations: { title: "Report how a tool or agent worked for you", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
63
107
  }, async ({ actor, target, target_type, task_type, outcome, failure_type, latency_ms, source }) => {
64
- const res = await fetch(`${BASE_URL}/api/interactions`, {
108
+ const r = await callApi(`${BASE_URL}/api/interactions`, {
65
109
  method: "POST",
66
110
  headers: { "Content-Type": "application/json" },
67
111
  body: JSON.stringify({
@@ -70,19 +114,28 @@ server.tool("record_interaction", "Record an interaction with an agent, tool, or
70
114
  node_id: NODE_ID,
71
115
  recorder_version: PKG_VERSION,
72
116
  }),
73
- });
74
- const data = await res.json();
117
+ }, "report");
118
+ if (!r.ok)
119
+ return r.result;
120
+ if (!r.data?.ok)
121
+ return toolError({ error: String(r.data?.error ?? "not_recorded"), message: "Lowdown did not record the report.", retryable: false });
75
122
  return {
76
- content: [{ type: "text", text: data.ok ? "Recorded." : `Error: ${data.error}` }],
123
+ content: [{ type: "text", text: "Recorded." }],
77
124
  };
78
125
  });
79
126
  // ── 3. compare_tools ────────────────────────────────────────────
80
- server.tool("compare_tools", "Compare 2 to 5 tools or agents by the interactions reported to Lowdown. " +
81
- "Candidates are ordered by reported success rate (candidates without data last), with interaction count and confidence. " +
82
- "Scanner observations are not used for the ordering. " +
83
- "A suggestion is included only when the first candidate has at least 5 rated interactions.", {
84
- candidates: z.array(z.string()).min(2).max(5).describe("List of tool/agent identifiers to compare"),
85
- task_type: z.string().optional().describe("Filter by task type if you want task-specific comparison"),
127
+ server.registerTool("compare_tools", {
128
+ title: "Compare tools by reported outcomes",
129
+ description: "Compare 2 to 5 tools or agents by the interactions reported to Lowdown. " +
130
+ "Candidates are ordered by reported success rate (candidates without data last), with interaction count and confidence. " +
131
+ "Scanner observations are not used for the ordering. " +
132
+ "A suggestion is included only when the first candidate has at least 5 rated interactions. " +
133
+ "A candidate Lowdown cannot read is listed with no data rather than failing the whole comparison. Read-only.",
134
+ inputSchema: {
135
+ candidates: z.array(z.string()).min(2).max(5).describe("List of tool/agent identifiers to compare"),
136
+ task_type: z.string().optional().describe("Filter by task type if you want task-specific comparison"),
137
+ },
138
+ annotations: { title: "Compare tools by reported outcomes", ...READ_ONLY },
86
139
  }, async ({ candidates, task_type }) => {
87
140
  // compare_calls 로그 (fire-and-forget)
88
141
  void fetch(`${BASE_URL}/api/compare-log`, {
@@ -144,12 +197,18 @@ server.tool("compare_tools", "Compare 2 to 5 tools or agents by the interactions
144
197
  };
145
198
  });
146
199
  // ── 4. get_node_stats ───────────────────────────────────────────
147
- server.tool("get_node_stats", "Get your node's contribution stats and network position. " +
148
- "Shows how much your proxy has contributed to the Lowdown network.", {}, async () => {
149
- const res = await fetch(`${BASE_URL}/api/node/${encodeURIComponent(NODE_ID)}`, { headers: { "x-lowdown-source": "organic" } });
150
- const data = await res.json();
200
+ server.registerTool("get_node_stats", {
201
+ title: "Show this proxy node's reports",
202
+ description: "Get your node's contribution stats and network position. " +
203
+ "Shows how much your proxy has contributed to the Lowdown network. " +
204
+ "The node is the local identity in ~/.lowdown/node.json; no input is needed. Read-only.",
205
+ annotations: { title: "Show this proxy node's reports", ...READ_ONLY },
206
+ }, async () => {
207
+ const r = await callApi(`${BASE_URL}/api/node/${encodeURIComponent(NODE_ID)}`, { headers: { "x-lowdown-source": "organic" } }, "node stats");
208
+ if (!r.ok)
209
+ return r.result;
151
210
  return {
152
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
211
+ content: [{ type: "text", text: JSON.stringify(r.data, null, 2) }],
153
212
  };
154
213
  });
155
214
  // success_rate 의 분모(organic 도구 호출·보고). 0.1.17 이전 API 응답에는 없으므로 interactions 로 대체.
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
- if (opts.logWatcher) {
38
- try {
39
- const m = JSON.parse(line);
40
- if (m?.id !== undefined && pending.get(m.id)?.method === "tools/call") {
41
- const annotated = annotateToolResult(m, opts.logWatcher);
42
- if (annotated)
43
- out = JSON.stringify(annotated);
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
- process.stdout.write(out + "\n");
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.21",
3
+ "version": "0.1.23",
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",
@@ -18,9 +18,9 @@
18
18
  "scanner:typecheck": "tsc -p scanner",
19
19
  "sql:baseline": "node scanner/migrations.ts baseline",
20
20
  "sql:check": "node scanner/migrations.ts check",
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",
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 scanner/access-report.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 test/mcp-server.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
  },