claude-spotter 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ **`.mcp.json` を真実源として読み込み、user-registered HTTP/stdio MCP の認証情報を live fetch に活用**。v0.8.0 で HTTP transport を実装したが、`claude mcp list` / `claude mcp get` は bearer token や headers を CLI 出力に含めないため、認証が必要な MCP サーバー (x-api) は依然 401 で落ちていた。`~/.claude/.mcp.json` を直接読んで env / headers を取得、stdio なら spawn 時の env に、HTTP なら fetch request header に渡す。
6
+
7
+ ### 事の発端
8
+
9
+ v0.8.0 の `spotter db refresh` 実測で x-api (HTTP MCP) が 401 Unauthorized で落ちていた。`claude mcp list` では `x-api: https://kitepon.dynv6.net/mcp (HTTP)` と表示され URL は拾えるが、Spotter の refresh プロセスから叩くと認証情報がないため拒否。ユーザーの指摘で `.mcp.json` を直接 cat したところ、実態は **stdio** で `env: {X_BEARER_TOKEN: "..."}` を持つ設定だった。CLI 表示と actual config が食い違っていた (CLI の cache の古さと思われる)。
10
+
11
+ 判明した設計上の転換点:
12
+
13
+ - **`.mcp.json` はユーザーが自己申告した MCP 設定ファイル** — ここに secrets が書かれているのはユーザーの意思。Anthropic の OAuth token を保持する `.credentials.json` とは性格が違う。`.mcp.json` を読むことは v0.8.0 で引いた境界線 (credentials は触らない) に抵触しない
14
+ - **`claude mcp list` は scope 統合ビュー、`.mcp.json` は user scope の詳細**。前者で名前を取り、後者で詳細を当てる併用が最も抜け漏れない
15
+
16
+ ### 変更点
17
+
18
+ - **新規 [src/tool-db/mcp-config.mjs](src/tool-db/mcp-config.mjs)**: `~/.claude/.mcp.json` をパース、`describeServer()` で `{command, args, env}` (stdio) または `{url, headers}` (http/sse) のディスクリプタに正規化
19
+ - **編集 [src/tool-db/investigate-mcp.mjs](src/tool-db/investigate-mcp.mjs)**: `listMcpServers` を `claude mcp list` + `.mcp.json` の併用へ。CLI で得た name ごとに `.mcp.json` のエントリを優先使用し、なければ CLI 情報にフォールバック。`spawnAndQuery` が `env` を受け取って `{...process.env, ...env}` で spawn 時に merge
20
+ - **編集 [src/tool-db/investigate-mcp-http.mjs](src/tool-db/investigate-mcp-http.mjs)**: `listToolsHttp` が `headers` パラメータを受け取って fetch の HTTP headers に merge
21
+ - **編集 [test/tool-db.test.mjs](test/tool-db.test.mjs)**: `describeServer` の unit test 5 件追加 (stdio + env、stdio 最小、http + headers、sse 判別、未知エントリ)
22
+
23
+ ### 実測
24
+
25
+ `spotter db rebuild` で x-api の 9 ツール (get_trends / search_tweets / fetch_tweet 等) が **live fetch で投入される** ようになった (`investigated=9`)。手書き baseline は不要。`describeServer` テスト 5 件追加で total 97 tests。
26
+
27
+ ### 残る課題
28
+
29
+ - **project scope `.mcp.json` 未対応**: プロジェクト直下の `.mcp.json` は読んでいない。v0.9.0 では user scope のみ
30
+ - **claude.ai baseline は維持**: Gmail/Calendar/Drive は `.mcp.json` に登録されない (OAuth proxy 経由) ので hardcoded のまま
31
+
3
32
  ## 0.8.0
4
33
 
5
34
  **HTTP/SSE MCP transport 対応 + Windows `.cmd` 経路の ENOENT fix + claude.ai 系 MCP の hardcoded baseline**。v0.7.0 を実測したら Windows で `spotter db refresh` が `spawn claude ENOENT` で起動すらせず、fix した上で動かしたら今度は Gmail / Google Calendar / Google Drive / x-api が丸ごと抜け落ちて Haiku の視野に入らない状態だった。この 3 本を同時に潰した。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@ export class McpHttpError extends Error {
24
24
  }
25
25
  }
26
26
 
27
- export async function listToolsHttp({ url, serverName }) {
27
+ export async function listToolsHttp({ url, serverName, headers: staticHeaders = {} }) {
28
28
  let sessionId = null;
29
29
 
30
30
  const post = async (body) => {
@@ -34,6 +34,7 @@ export async function listToolsHttp({ url, serverName }) {
34
34
  const headers = {
35
35
  'Content-Type': 'application/json',
36
36
  'Accept': 'application/json, text/event-stream',
37
+ ...staticHeaders,
37
38
  };
38
39
  if (sessionId) headers['Mcp-Session-Id'] = sessionId;
39
40
  const res = await fetch(url, {
@@ -86,7 +87,7 @@ export async function listToolsHttp({ url, serverName }) {
86
87
  params: {
87
88
  protocolVersion: PROTOCOL_VERSION,
88
89
  capabilities: {},
89
- clientInfo: { name: 'spotter', version: '0.8.0' },
90
+ clientInfo: { name: 'spotter', version: '0.9.0' },
90
91
  },
91
92
  });
92
93
  if (!initResult || initResult.error) {
@@ -13,6 +13,7 @@ import { spawn } from 'node:child_process';
13
13
  import { execFile } from 'node:child_process';
14
14
  import { promisify } from 'node:util';
15
15
  import { listToolsHttp } from './investigate-mcp-http.mjs';
16
+ import { readMcpServers, describeServer } from './mcp-config.mjs';
16
17
 
17
18
  const execFileP = promisify(execFile);
18
19
 
@@ -53,11 +54,30 @@ export async function listMcpToolsAll({ logFn = () => {}, claudeBin = 'claude' }
53
54
  return out;
54
55
  }
55
56
 
56
- // Parse `claude mcp list` output. Returns array of {name, transport, command, url}.
57
- // stdio servers have command, http/sse servers have url.
57
+ // Returns the list of MCP servers to investigate. Merges two sources:
58
+ // - `claude mcp list` authoritative for *which* servers exist in this session
59
+ // (covers all scopes: user, project, local, enterprise)
60
+ // - `~/.claude/.mcp.json` — authoritative for transport details + auth secrets
61
+ // (stdio env, http headers). The CLI hides these on purpose.
62
+ //
63
+ // For each server named by the CLI, if `.mcp.json` has a matching entry we use that
64
+ // full descriptor (with env/headers). Otherwise we fall back to the parsed CLI line,
65
+ // which at minimum gives us name + transport + url (or triggers `claude mcp get` for
66
+ // stdio command tokenisation).
58
67
  export async function listMcpServers({ claudeBin = 'claude' } = {}) {
59
- const { stdout } = await execClaude(claudeBin, ['mcp', 'list'], { encoding: 'utf8' });
60
- return parseMcpListOutput(stdout);
68
+ const [{ stdout }, mcpServers] = await Promise.all([
69
+ execClaude(claudeBin, ['mcp', 'list'], { encoding: 'utf8' }),
70
+ readMcpServers(),
71
+ ]);
72
+ const cliList = parseMcpListOutput(stdout);
73
+ return cliList.map((cliEntry) => {
74
+ const configEntry = mcpServers[cliEntry.name];
75
+ if (configEntry) {
76
+ const described = describeServer(cliEntry.name, configEntry);
77
+ if (described) return described;
78
+ }
79
+ return cliEntry;
80
+ });
61
81
  }
62
82
 
63
83
  // `claude mcp list` output lines look like:
@@ -92,21 +112,24 @@ export function parseMcpListOutput(text) {
92
112
  return out;
93
113
  }
94
114
 
95
- // Fetch tools/list from a single MCP server. HTTP/SSE servers are not yet supported here
96
- // (we'd need to speak the HTTP+SSE MCP transport); we throw so the caller can log+skip.
115
+ // Fetch tools/list from a single MCP server. The `server` descriptor either came
116
+ // from `.mcp.json` (carries env / headers) or from CLI output (bare). For stdio
117
+ // entries without full config we fall back to `claude mcp get`.
97
118
  export async function listMcpToolsOne({ server, logFn = () => {}, claudeBin = 'claude' }) {
98
119
  if (server.transport === 'stdio') {
99
- const config = await getStdioConfig({ name: server.name, claudeBin });
120
+ const hasFullConfig = server.command !== undefined;
121
+ const config = hasFullConfig
122
+ ? { command: server.command, args: server.args ?? [], env: server.env ?? {} }
123
+ : await getStdioConfig({ name: server.name, claudeBin });
100
124
  return spawnAndQuery(config, server.name);
101
125
  }
102
126
  if (server.transport === 'http' || server.transport === 'sse') {
103
- // Streamable HTTP transport. For `claude.ai ...` servers, `claude mcp get` rejects
104
- // them (they are not in local .mcp.json) so server.url is unavailable those are
105
- // covered by src/tool-db/claude-ai-baseline.mjs at a higher layer.
127
+ // For `claude.ai ...` servers, CLI reports http/sse but they are NOT in local
128
+ // .mcp.json covered by src/tool-db/claude-ai-baseline.mjs at a higher layer.
106
129
  if (!server.url) {
107
130
  throw new McpInvestigationError(`no URL available for ${server.transport} server`, server.name);
108
131
  }
109
- return listToolsHttp({ url: server.url, serverName: server.name });
132
+ return listToolsHttp({ url: server.url, serverName: server.name, headers: server.headers ?? {} });
110
133
  }
111
134
  throw new McpInvestigationError(`unknown transport: ${server.transport}`, server.name);
112
135
  }
@@ -142,12 +165,13 @@ function buildStdioSpawn(command, args) {
142
165
  return { cmd: command, cmdArgs: args };
143
166
  }
144
167
 
145
- async function spawnAndQuery({ command, args }, serverName) {
168
+ async function spawnAndQuery({ command, args, env = {} }, serverName) {
146
169
  return new Promise((resolve, reject) => {
147
170
  const { cmd, cmdArgs } = buildStdioSpawn(command, args);
148
171
  const child = spawn(cmd, cmdArgs, {
149
172
  stdio: ['pipe', 'pipe', 'pipe'],
150
173
  windowsHide: true,
174
+ env: { ...process.env, ...env },
151
175
  });
152
176
  let buffer = '';
153
177
  let nextId = 1;
@@ -223,7 +247,7 @@ async function spawnAndQuery({ command, args }, serverName) {
223
247
  await request('initialize', {
224
248
  protocolVersion: PROTOCOL_VERSION,
225
249
  capabilities: {},
226
- clientInfo: { name: 'spotter', version: '0.8.0' },
250
+ clientInfo: { name: 'spotter', version: '0.9.0' },
227
251
  });
228
252
  send({ jsonrpc: '2.0', method: 'notifications/initialized' });
229
253
  initializedSent = true;
@@ -0,0 +1,62 @@
1
+ // Read MCP server definitions directly from `.mcp.json` rather than parsing
2
+ // `claude mcp list` text output.
3
+ //
4
+ // Why: `.mcp.json` is the authoritative source for stdio env vars (e.g. bearer tokens
5
+ // passed to the MCP subprocess) and HTTP headers (e.g. Authorization). The CLI output
6
+ // of `claude mcp list` / `claude mcp get` hides those secrets. Without them, an HTTP
7
+ // MCP server returns 401 and a stdio MCP server spawns without its API key.
8
+ //
9
+ // Scope: reads user-level `~/.claude/.mcp.json`. Project-level `.mcp.json` and
10
+ // `settings.local.json` are not yet consulted — user scope covers the common case
11
+ // (globally-installed MCP servers) and is the scope that Spotter's tool-db is
12
+ // global-first anyway.
13
+ //
14
+ // This file does NOT read ~/.claude/.credentials.json (Anthropic OAuth token). That
15
+ // remains off-limits per the v0.8.0 design decision. `.mcp.json` is user-authored
16
+ // configuration where the user has already chosen to persist their own MCP credentials.
17
+
18
+ import { readFile } from 'node:fs/promises';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ export function userMcpConfigPath() {
23
+ return join(homedir(), '.claude', '.mcp.json');
24
+ }
25
+
26
+ // Returns the raw `mcpServers` object from ~/.claude/.mcp.json, or {} if missing.
27
+ // Throws only on malformed JSON (not on missing file).
28
+ export async function readMcpServers() {
29
+ try {
30
+ const text = await readFile(userMcpConfigPath(), 'utf8');
31
+ const data = JSON.parse(text);
32
+ return data.mcpServers ?? {};
33
+ } catch (err) {
34
+ if (err.code === 'ENOENT') return {};
35
+ throw err;
36
+ }
37
+ }
38
+
39
+ // Normalise an `.mcp.json` entry into a server descriptor the investigator can use.
40
+ // Returns { name, transport: 'stdio'|'http'|'sse', ...transport-specific fields } or
41
+ // null if the entry is not recognisable.
42
+ export function describeServer(name, entry) {
43
+ if (entry.command) {
44
+ return {
45
+ name,
46
+ transport: 'stdio',
47
+ command: entry.command,
48
+ args: Array.isArray(entry.args) ? entry.args : [],
49
+ env: entry.env && typeof entry.env === 'object' ? entry.env : {},
50
+ };
51
+ }
52
+ if (entry.url) {
53
+ const transport = entry.type === 'sse' ? 'sse' : 'http';
54
+ return {
55
+ name,
56
+ transport,
57
+ url: entry.url,
58
+ headers: entry.headers && typeof entry.headers === 'object' ? entry.headers : {},
59
+ };
60
+ }
61
+ return null;
62
+ }
package/src/version.mjs CHANGED
@@ -1 +1 @@
1
- export const version = '0.8.0';
1
+ export const version = '0.9.0';