claude-spotter 0.7.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,71 @@
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
+
32
+ ## 0.8.0
33
+
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 本を同時に潰した。
35
+
36
+ ### 事の発端
37
+
38
+ v0.7.0 リリース直後、新規セッションで `spotter db refresh` を打ったら即失敗:
39
+
40
+ 1. **Windows で `spawn claude ENOENT`**: Node の `execFile` / `spawn` は Windows で `.cmd` 拡張子のラッパーを直接起動できない。`doctor` は `cmd.exe /c claude` 経由で回避していたが、新規コード [src/tool-db/investigate-mcp.mjs](src/tool-db/investigate-mcp.mjs) は `execFileP(claudeBin, ...)` をそのまま使っていて全 Windows 環境で DB refresh 不可。
41
+ 2. **HTTP/SSE transport 全滅**: v0.7.0 は stdio しか実装していなかったため、`claude.ai Google Drive` / `claude.ai Google Calendar` / `claude.ai Gmail` / `x-api` の 4 サーバーが `sse transport not yet supported` / `http transport not yet supported` でスキップされ、Haiku は Gmail や Calendar のツールを推奨できない状態だった。
42
+ 3. **claude.ai 系 MCP は `claude mcp get` で取得不可**: `claude mcp get "claude.ai Gmail"` は `No MCP server found` を返す。Anthropic 提供の MCP は `.mcp.json` に登録されず、Claude Code は `~/.claude/.credentials.json` の OAuth token を使って `mcp-proxy.anthropic.com` に直接アクセスしている。Spotter は credentials を読まない方針なので、これらは **hardcoded baseline** でカバーする。
43
+
44
+ ### 変更点
45
+
46
+ - **新規 [src/tool-db/investigate-mcp-http.mjs](src/tool-db/investigate-mcp-http.mjs)**: MCP Streamable HTTP transport 実装。POST + `Content-Type: application/json` + `Accept: application/json, text/event-stream` で JSON-RPC 往復、`Mcp-Session-Id` header で session 維持、SSE 形式レスポンスも parse。`initialize` → `notifications/initialized` → `tools/list` を 10 秒 timeout で実行
47
+ - **新規 [src/tool-db/claude-ai-baseline.mjs](src/tool-db/claude-ai-baseline.mjs)**: `claude.ai Gmail` (10) / `claude.ai Google Calendar` (8) / `claude.ai Google Drive` (7) の合計 25 件の {name, description} を手書き baseline として保持。deferred-baseline と同じ設計パターン
48
+ - **編集 [src/tool-db/investigate-mcp.mjs](src/tool-db/investigate-mcp.mjs)**:
49
+ - `execClaude()` helper 追加: Windows では `execFileP('cmd.exe', ['/c', claudeBin, ...args])` 経由、他は `execFileP(claudeBin, args)` そのまま。`listMcpServers` と `getStdioConfig` の 2 箇所で利用
50
+ - `buildStdioSpawn()` helper 追加: MCP stdio サーバーの Command が Windows で `.cmd` / `.bat` 拡張子なら `cmd.exe /c` 経由で spawn (caveat 等 `.exe` は影響なし)
51
+ - `listMcpToolsOne` の HTTP/SSE 分岐を `listToolsHttp` に dispatch
52
+ - **編集 [src/tool-db/refresh.mjs](src/tool-db/refresh.mjs)**: `buildInvestigationSnapshot` に claude.ai baseline を deferred の直後に merge。後続の live HTTP investigate が成功すれば上書き
53
+ - **編集 [package.json](package.json), [src/version.mjs](src/version.mjs)**: `0.7.0` → `0.8.0`
54
+
55
+ ### 実測
56
+
57
+ `spotter db rebuild` で **48 tools resolved**:
58
+ - deferred baseline: 17 (Claude Code 組込み遅延ツール)
59
+ - claude.ai baseline: 25 (Gmail 10 + Calendar 8 + Drive 7)
60
+ - stdio MCP (Caveat): 6
61
+
62
+ `claude.ai Gmail/Calendar/Drive` への live HTTP fetch は **HTTP 403 Forbidden** (認証 token なしで直接叩けない、想定通り) → baseline がカバー。`x-api` は **HTTP 401 Unauthorized** (ユーザー設定 MCP で Authorization header 未対応、次回課題)。`caveat` は stdio で正常 fetch。
63
+
64
+ ### 残る課題
65
+
66
+ - **ユーザー設定 HTTP MCP の認証 header**: `claude mcp get <name>` の出力から Authorization 等を抽出して fetch に付与する仕組みが未実装。v0.8.0 では x-api が落ちる (baseline でも救えない、公開情報でない)
67
+ - **claude.ai baseline の手動メンテ**: Anthropic が tool を追加・変更したら手で追従。deferred baseline と同じ trade-off
68
+
3
69
  ## 0.7.0
4
70
 
5
71
  **カタログを tool-db に置き換え**。手書きの `tools.yaml` (5 つの抽象ツール) を捨て、**実際にセッションで使えるツール (MCP + Claude Code 組込み 遅延ツール) の name + description を自動収集してキャッシュする** 仕組みに置き換え。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.7.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": {
@@ -0,0 +1,86 @@
1
+ // Hardcoded description baseline for Anthropic-provided `claude.ai ...` MCP servers
2
+ // (Gmail / Google Calendar / Google Drive).
3
+ //
4
+ // Why hardcoded, not fetched:
5
+ // These servers are NOT registered in local `.mcp.json`. Claude Code calls them via
6
+ // its own mcp-proxy.anthropic.com endpoint using the OAuth token in
7
+ // ~/.claude/.credentials.json. Spotter deliberately does NOT read credentials — so
8
+ // we cannot dynamically invoke tools/list against the proxy. The tool surface is
9
+ // public (the same deferred-tool list Claude Code exposes) and changes slowly.
10
+ //
11
+ // Maintenance:
12
+ // When Anthropic adds new tools to these MCP servers, append them here. The daemon
13
+ // merges this baseline into the tool-db during `spotter db refresh`.
14
+ //
15
+ // Bell-visible names follow the convention `mcp__<server-id>__<tool-name>` where
16
+ // <server-id> is the `claude.ai ...` server name with non-[A-Za-z0-9_-] chars replaced
17
+ // by `_` — e.g. "claude.ai Gmail" → "claude_ai_Gmail".
18
+
19
+ const GMAIL = {
20
+ mcp__claude_ai_Gmail__search_threads:
21
+ 'Search Gmail threads matching a query (e.g. from:alice subject:invoice, is:unread, after:2024-01-01). Use when the user asks to find, read, or reference specific mail.',
22
+ mcp__claude_ai_Gmail__get_thread:
23
+ 'Fetch the full body of a Gmail thread by id — all messages, headers, and text. Use after search_threads to read the actual content.',
24
+ mcp__claude_ai_Gmail__list_drafts:
25
+ 'List existing Gmail drafts. Use before create_draft when the user asks about pending unsent mail or wants to resume a draft.',
26
+ mcp__claude_ai_Gmail__create_draft:
27
+ 'Create a new Gmail draft (to, subject, body, optional cc/bcc). Does NOT send. Use when the user asks Claude to compose a message for later review.',
28
+ mcp__claude_ai_Gmail__list_labels:
29
+ 'List all Gmail labels (both system and user-created). Use before label_message / label_thread to find the correct label id.',
30
+ mcp__claude_ai_Gmail__create_label:
31
+ 'Create a new Gmail label. Use when the user wants to organize mail into a category that does not yet exist.',
32
+ mcp__claude_ai_Gmail__label_message:
33
+ 'Apply a label to a single Gmail message by id.',
34
+ mcp__claude_ai_Gmail__unlabel_message:
35
+ 'Remove a label from a single Gmail message by id.',
36
+ mcp__claude_ai_Gmail__label_thread:
37
+ 'Apply a label to an entire Gmail thread (all messages in the thread).',
38
+ mcp__claude_ai_Gmail__unlabel_thread:
39
+ 'Remove a label from an entire Gmail thread.',
40
+ };
41
+
42
+ const CALENDAR = {
43
+ mcp__claude_ai_Google_Calendar__list_calendars:
44
+ 'List all Google Calendars the user has access to (primary, shared, subscribed). Use first when the user does not specify which calendar.',
45
+ mcp__claude_ai_Google_Calendar__list_events:
46
+ 'List events in a calendar over a time range. Use when the user asks about upcoming meetings, schedule conflicts, or "what is on my calendar".',
47
+ mcp__claude_ai_Google_Calendar__get_event:
48
+ 'Fetch a single event by id with full details (attendees, description, attachments).',
49
+ mcp__claude_ai_Google_Calendar__create_event:
50
+ 'Create a new Google Calendar event (title, start/end, attendees, optional location/description). Use when the user asks to schedule something.',
51
+ mcp__claude_ai_Google_Calendar__update_event:
52
+ 'Modify an existing event (reschedule, change attendees, update description). Use when the user asks to move or edit a meeting.',
53
+ mcp__claude_ai_Google_Calendar__delete_event:
54
+ 'Delete an event from Google Calendar. Use when the user asks to cancel a meeting.',
55
+ mcp__claude_ai_Google_Calendar__respond_to_event:
56
+ 'RSVP to an event invitation (accept / decline / tentative).',
57
+ mcp__claude_ai_Google_Calendar__suggest_time:
58
+ 'Find free time slots across multiple calendars / attendees. Use when the user asks to find a mutually available meeting time.',
59
+ };
60
+
61
+ const DRIVE = {
62
+ mcp__claude_ai_Google_Drive__search_files:
63
+ 'Search Google Drive for files matching a query (name, content, mimeType). Use when the user asks to find a doc/sheet/slide by topic or title.',
64
+ mcp__claude_ai_Google_Drive__list_recent_files:
65
+ 'List recently accessed Google Drive files. Use when the user asks "what was I just working on in Drive?" or similar.',
66
+ mcp__claude_ai_Google_Drive__get_file_metadata:
67
+ 'Fetch metadata (name, mimeType, owner, modified time, sharing) for a Drive file by id — NOT the content. Use before read_file_content to verify the file type.',
68
+ mcp__claude_ai_Google_Drive__read_file_content:
69
+ 'Read the textual content of a Drive file (Docs, Sheets, plain text). Use when the user asks Claude to reference or summarize a file.',
70
+ mcp__claude_ai_Google_Drive__download_file_content:
71
+ 'Download binary file content from Drive (PDF, image, zip). Use when the user needs the raw bytes rather than extracted text.',
72
+ mcp__claude_ai_Google_Drive__create_file:
73
+ 'Create a new file in Google Drive (text content or uploaded blob). Use when the user asks to save something to Drive.',
74
+ mcp__claude_ai_Google_Drive__get_file_permissions:
75
+ 'List who has access to a Drive file and their role (reader/commenter/writer/owner). Use when the user asks who can see or edit a file.',
76
+ };
77
+
78
+ const ALL = { ...GMAIL, ...CALENDAR, ...DRIVE };
79
+
80
+ export function listClaudeAiNames() {
81
+ return Object.keys(ALL);
82
+ }
83
+
84
+ export function getClaudeAiDescription(name) {
85
+ return ALL[name] ?? null;
86
+ }
@@ -0,0 +1,136 @@
1
+ // HTTP (Streamable HTTP transport) MCP investigation.
2
+ //
3
+ // Spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
4
+ // Wire:
5
+ // POST <url> with JSON-RPC body + Content-Type: application/json
6
+ // Accept: application/json, text/event-stream (server may reply with either)
7
+ // First response may set Mcp-Session-Id; echo it on subsequent requests.
8
+ //
9
+ // We only need initialize → notifications/initialized → tools/list; the handshake is
10
+ // plain request/response, no long-lived SSE stream required.
11
+ //
12
+ // Auth: `claude mcp get <name>` does NOT expose bearer tokens for user-configured
13
+ // servers. If a server requires auth beyond public URL access, investigate will fail
14
+ // with a descriptive error — caller logs and skips that server.
15
+
16
+ const PROTOCOL_VERSION = '2025-03-26';
17
+ const REQUEST_TIMEOUT_MS = 10_000;
18
+
19
+ export class McpHttpError extends Error {
20
+ constructor(message, server) {
21
+ super(message);
22
+ this.name = 'McpHttpError';
23
+ this.server = server;
24
+ }
25
+ }
26
+
27
+ export async function listToolsHttp({ url, serverName, headers: staticHeaders = {} }) {
28
+ let sessionId = null;
29
+
30
+ const post = async (body) => {
31
+ const ctl = new AbortController();
32
+ const t = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS);
33
+ try {
34
+ const headers = {
35
+ 'Content-Type': 'application/json',
36
+ 'Accept': 'application/json, text/event-stream',
37
+ ...staticHeaders,
38
+ };
39
+ if (sessionId) headers['Mcp-Session-Id'] = sessionId;
40
+ const res = await fetch(url, {
41
+ method: 'POST',
42
+ headers,
43
+ body: JSON.stringify(body),
44
+ signal: ctl.signal,
45
+ });
46
+ const sid = res.headers.get('mcp-session-id');
47
+ if (sid && !sessionId) sessionId = sid;
48
+ if (!res.ok) {
49
+ throw new McpHttpError(`HTTP ${res.status} ${res.statusText}`, serverName);
50
+ }
51
+ // Response may be application/json or text/event-stream.
52
+ const contentType = res.headers.get('content-type') ?? '';
53
+ if (contentType.includes('text/event-stream')) {
54
+ return parseSseSingle(await res.text(), serverName);
55
+ }
56
+ if (contentType.includes('application/json')) {
57
+ return await res.json();
58
+ }
59
+ // Some servers omit content-type; try JSON.
60
+ const text = await res.text();
61
+ if (text.trim().length === 0) return null;
62
+ try {
63
+ return JSON.parse(text);
64
+ } catch {
65
+ throw new McpHttpError(`unexpected response content-type "${contentType}"`, serverName);
66
+ }
67
+ } finally {
68
+ clearTimeout(t);
69
+ }
70
+ };
71
+
72
+ const postNotification = async (body) => {
73
+ // Notifications may return 202 Accepted with empty body; swallow the result.
74
+ await post(body).catch((err) => {
75
+ // Some servers reject notifications with no body response as non-2xx; treat as OK
76
+ // only if the error is clearly a parse issue on empty body.
77
+ if (err instanceof McpHttpError) throw err;
78
+ throw err;
79
+ });
80
+ };
81
+
82
+ // 1. initialize
83
+ const initResult = await post({
84
+ jsonrpc: '2.0',
85
+ id: 1,
86
+ method: 'initialize',
87
+ params: {
88
+ protocolVersion: PROTOCOL_VERSION,
89
+ capabilities: {},
90
+ clientInfo: { name: 'spotter', version: '0.9.0' },
91
+ },
92
+ });
93
+ if (!initResult || initResult.error) {
94
+ throw new McpHttpError(`initialize failed: ${JSON.stringify(initResult?.error ?? initResult)}`, serverName);
95
+ }
96
+
97
+ // 2. notifications/initialized
98
+ await postNotification({
99
+ jsonrpc: '2.0',
100
+ method: 'notifications/initialized',
101
+ });
102
+
103
+ // 3. tools/list
104
+ const listResult = await post({
105
+ jsonrpc: '2.0',
106
+ id: 2,
107
+ method: 'tools/list',
108
+ params: {},
109
+ });
110
+ if (!listResult || listResult.error) {
111
+ throw new McpHttpError(`tools/list failed: ${JSON.stringify(listResult?.error ?? listResult)}`, serverName);
112
+ }
113
+
114
+ const tools = (listResult.result?.tools ?? []).map((t) => ({
115
+ name: t.name,
116
+ description: typeof t.description === 'string' ? t.description : '',
117
+ }));
118
+ return tools;
119
+ }
120
+
121
+ // SSE responses wrap a single JSON message in one `data:` event. Parse it out.
122
+ function parseSseSingle(sseText, serverName) {
123
+ for (const rawLine of sseText.split('\n')) {
124
+ const line = rawLine.trim();
125
+ if (line.startsWith('data:')) {
126
+ const payload = line.slice('data:'.length).trim();
127
+ if (payload.length === 0) continue;
128
+ try {
129
+ return JSON.parse(payload);
130
+ } catch {
131
+ throw new McpHttpError(`failed to parse SSE data payload`, serverName);
132
+ }
133
+ }
134
+ }
135
+ throw new McpHttpError(`no data event in SSE response`, serverName);
136
+ }
@@ -12,12 +12,24 @@
12
12
  import { spawn } from 'node:child_process';
13
13
  import { execFile } from 'node:child_process';
14
14
  import { promisify } from 'node:util';
15
+ import { listToolsHttp } from './investigate-mcp-http.mjs';
16
+ import { readMcpServers, describeServer } from './mcp-config.mjs';
15
17
 
16
18
  const execFileP = promisify(execFile);
17
19
 
18
20
  const PROTOCOL_VERSION = '2025-03-26'; // MCP protocol version we claim to speak.
19
21
  const HANDSHAKE_TIMEOUT_MS = 10_000;
20
22
 
23
+ // On Windows, `claude` is a .cmd shim; Node's execFile cannot locate it directly without
24
+ // going through cmd.exe. Matches the pattern in src/daemon/haiku-caller.mjs buildSpawnArgs.
25
+ // We use cmd.exe /c rather than shell:true to avoid DEP0190 on Node 24+.
26
+ async function execClaude(claudeBin, args, opts) {
27
+ if (process.platform === 'win32') {
28
+ return execFileP('cmd.exe', ['/c', claudeBin, ...args], opts);
29
+ }
30
+ return execFileP(claudeBin, args, opts);
31
+ }
32
+
21
33
  export class McpInvestigationError extends Error {
22
34
  constructor(message, server) {
23
35
  super(message);
@@ -42,11 +54,30 @@ export async function listMcpToolsAll({ logFn = () => {}, claudeBin = 'claude' }
42
54
  return out;
43
55
  }
44
56
 
45
- // Parse `claude mcp list` output. Returns array of {name, transport, command, url}.
46
- // 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).
47
67
  export async function listMcpServers({ claudeBin = 'claude' } = {}) {
48
- const { stdout } = await execFileP(claudeBin, ['mcp', 'list'], { encoding: 'utf8' });
49
- 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
+ });
50
81
  }
51
82
 
52
83
  // `claude mcp list` output lines look like:
@@ -81,25 +112,31 @@ export function parseMcpListOutput(text) {
81
112
  return out;
82
113
  }
83
114
 
84
- // Fetch tools/list from a single MCP server. HTTP/SSE servers are not yet supported here
85
- // (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`.
86
118
  export async function listMcpToolsOne({ server, logFn = () => {}, claudeBin = 'claude' }) {
87
119
  if (server.transport === 'stdio') {
88
- 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 });
89
124
  return spawnAndQuery(config, server.name);
90
125
  }
91
126
  if (server.transport === 'http' || server.transport === 'sse') {
92
- throw new McpInvestigationError(
93
- `${server.transport} transport not yet supported (need HTTP MCP client)`,
94
- server.name
95
- );
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.
129
+ if (!server.url) {
130
+ throw new McpInvestigationError(`no URL available for ${server.transport} server`, server.name);
131
+ }
132
+ return listToolsHttp({ url: server.url, serverName: server.name, headers: server.headers ?? {} });
96
133
  }
97
134
  throw new McpInvestigationError(`unknown transport: ${server.transport}`, server.name);
98
135
  }
99
136
 
100
137
  // Parse `claude mcp get <name>` to extract Command + Args for stdio servers.
101
138
  async function getStdioConfig({ name, claudeBin }) {
102
- const { stdout } = await execFileP(claudeBin, ['mcp', 'get', name], { encoding: 'utf8' });
139
+ const { stdout } = await execClaude(claudeBin, ['mcp', 'get', name], { encoding: 'utf8' });
103
140
  let command = null;
104
141
  let argsRaw = null;
105
142
  for (const rawLine of stdout.split('\n')) {
@@ -119,11 +156,22 @@ function splitArgs(s) {
119
156
  return s.split(/\s+/).filter((t) => t.length > 0);
120
157
  }
121
158
 
122
- async function spawnAndQuery({ command, args }, serverName) {
159
+ // On Windows, `.cmd` / `.bat` shims cannot be spawned directly without cmd.exe.
160
+ // Unix-like paths or `.exe` binaries go through spawn as-is.
161
+ function buildStdioSpawn(command, args) {
162
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(command)) {
163
+ return { cmd: 'cmd.exe', cmdArgs: ['/c', command, ...args] };
164
+ }
165
+ return { cmd: command, cmdArgs: args };
166
+ }
167
+
168
+ async function spawnAndQuery({ command, args, env = {} }, serverName) {
123
169
  return new Promise((resolve, reject) => {
124
- const child = spawn(command, args, {
170
+ const { cmd, cmdArgs } = buildStdioSpawn(command, args);
171
+ const child = spawn(cmd, cmdArgs, {
125
172
  stdio: ['pipe', 'pipe', 'pipe'],
126
173
  windowsHide: true,
174
+ env: { ...process.env, ...env },
127
175
  });
128
176
  let buffer = '';
129
177
  let nextId = 1;
@@ -199,7 +247,7 @@ async function spawnAndQuery({ command, args }, serverName) {
199
247
  await request('initialize', {
200
248
  protocolVersion: PROTOCOL_VERSION,
201
249
  capabilities: {},
202
- clientInfo: { name: 'spotter', version: '0.7.0' },
250
+ clientInfo: { name: 'spotter', version: '0.9.0' },
203
251
  });
204
252
  send({ jsonrpc: '2.0', method: 'notifications/initialized' });
205
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
+ }
@@ -8,6 +8,7 @@
8
8
  import { resolveAll } from './lookup.mjs';
9
9
  import { listMcpToolsAll, bellVisibleName } from './investigate-mcp.mjs';
10
10
  import { getDeferredDescription, listDeferredNames } from './deferred-baseline.mjs';
11
+ import { getClaudeAiDescription, listClaudeAiNames } from './claude-ai-baseline.mjs';
11
12
  import { localDbPath, globalDbPath } from './loader.mjs';
12
13
 
13
14
  // Build the (name → description) map for an investigation pass:
@@ -19,12 +20,19 @@ import { localDbPath, globalDbPath } from './loader.mjs';
19
20
  export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin = 'claude' } = {}) {
20
21
  const snapshot = new Map();
21
22
 
22
- // Deferred built-ins.
23
+ // Deferred built-ins (Claude Code deferred tools like WebFetch, TodoWrite, etc.).
23
24
  for (const name of listDeferredNames()) {
24
25
  snapshot.set(name, getDeferredDescription(name));
25
26
  }
26
27
 
27
- // MCP servers.
28
+ // Anthropic-provided `claude.ai ...` MCP servers — hardcoded because the OAuth proxy
29
+ // is not reachable without reading ~/.claude/.credentials.json (deliberately avoided).
30
+ // If a live HTTP investigate for the same name later succeeds below, it overrides.
31
+ for (const name of listClaudeAiNames()) {
32
+ snapshot.set(name, getClaudeAiDescription(name));
33
+ }
34
+
35
+ // MCP servers (stdio + user-registered HTTP/SSE).
28
36
  const mcp = await listMcpToolsAll({ logFn, claudeBin });
29
37
  for (const [serverName, tools] of mcp.entries()) {
30
38
  for (const tool of tools) {
package/src/version.mjs CHANGED
@@ -1 +1 @@
1
- export const version = '0.7.0';
1
+ export const version = '0.9.0';