claude-spotter 1.1.3 → 1.1.4

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.4
4
+
5
+ **MCP 投資ロジックの 2 件の穴を修正**。どちらも「名乗っているスコープ」と「実際に参照されるスコープ」が一致していない silent mismatch。前者は projectRoot 引数が効かない経路、後者は baseline が現実を無視して常に 25 件投入される経路。
6
+
7
+ ### 変更点
8
+
9
+ - **編集 [src/tool-db/investigate-mcp.mjs](src/tool-db/investigate-mcp.mjs)**: `listMcpServers` / `getStdioConfig` が projectRoot を受け取っておきながら `execClaude(claude mcp list / mcp get)` に `cwd` を渡していなかったため、`.mcp.json` 読み込みと claude CLI の walk-up が別プロジェクトを見る可能性があった。`cwd: projectRoot` を付与し、`listMcpToolsOne` を通じて projectRoot を伝搬するシグネチャに変更。通常は `process.cwd() === projectRoot` で表面化しないが、API の意味論を実装に揃える
10
+ - **編集 [src/tool-db/claude-ai-baseline.mjs](src/tool-db/claude-ai-baseline.mjs)**: flat な `listClaudeAiNames` / `getClaudeAiDescription` を削除、server 単位の `getClaudeAiBaselineByServer()` に再編。Gmail / Calendar / Drive を個別集合として保持し、呼び出し側で現実に存在するサーバーのみ注入できるようにした
11
+ - **編集 [src/tool-db/refresh.mjs](src/tool-db/refresh.mjs)**: `buildInvestigationSnapshot` で `listMcpServers` の結果に基づき baseline を filter。`claude mcp list` に `claude.ai Gmail` / `claude.ai Google Calendar` / `claude.ai Google Drive` が存在しない環境 (隔離 `CLAUDE_CONFIG_DIR`, claude.ai OAuth 未連携, 部分連携) では該当 baseline は投入されない。純粋関数 `filterClaudeAiBaseline` を named export として切り出しテスト可能にした
12
+ - **編集 [test/tool-db.test.mjs](test/tool-db.test.mjs)**: `filterClaudeAiBaseline` の回帰テスト 3 件追加 — 全 3 サーバー存在 / Gmail のみ存在 / 全不在
13
+
14
+ ### 背景
15
+
16
+ #### projectRoot の silent mismatch
17
+
18
+ v0.10.0 で `.mcp.json` の project scope 対応を入れた際、`readMcpServers({projectRoot})` は projectRoot を尊重するようにしたが、同じ関数内で spawn している `claude mcp list` / `claude mcp get` には `cwd` を渡し忘れていた。claude CLI は cwd から親方向に walk-up して `.mcp.json` を探すため、Spotter が引数で指定した projectRoot と claude CLI が勝手に見つけた project scope が乖離する可能性が残っていた。
19
+
20
+ #### claude.ai baseline の無条件注入
21
+
22
+ v0.8.0 で claude.ai OAuth 系 MCP (Gmail / Calendar / Drive) を手書き baseline として導入した際、「live HTTP investigate が成功した場合 override される」という想定で無条件注入ロジックを置いていた。しかし claude.ai 系は `.mcp.json` に載らず OAuth proxy 経由のため、`listMcpToolsAll` の investigate 対象にそもそも入らない = override 経路は発動不能。結果、claude.ai 未連携 / 部分連携環境 (隔離 `CLAUDE_CONFIG_DIR` での bellbot 等) で最大 25 件の幻ツールが catalog に残り、Bell が呼べないツールを Spotter が推奨する誤検出源になっていた。
23
+
24
+ ### 設計判断
25
+
26
+ - **`listMcpToolsAll` のシグネチャは触らない**: baseline filter 用に `listMcpServers` を buildInvestigationSnapshot で先に呼ぶと、内部で listMcpToolsAll がもう一度 CLI spawn する。pre-resolved servers 引数で避けられるが、API 表面を増やすコストに対し `claude mcp list` は 0.5-2s の 1 度だけなので受容
27
+ - **診断ログ追加**: baseline 注入時に `claude.ai baseline injected: N tools from <server list>` を logFn に出力。どの環境で何件入ったか後から追えるようにした
28
+ - **後方互換 export は削除**: `listClaudeAiNames` / `getClaudeAiDescription` は [src/index.mjs](src/index.mjs) に re-export されておらず、外部利用の形跡なし。残しても drift 源になるため削除
29
+
3
30
  ## 1.1.3
4
31
 
5
32
  **v1.1.x の実装進展にドキュメントを追従させる docs-only リリース**。コード変更なし。npm package tarball 同梱の README が古い手順を指していたため再 publish。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -75,12 +75,19 @@ const DRIVE = {
75
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
76
  };
77
77
 
78
- const ALL = { ...GMAIL, ...CALENDAR, ...DRIVE };
79
-
80
- export function listClaudeAiNames() {
81
- return Object.keys(ALL);
82
- }
78
+ // Server-level structure. Keys are the literal server names as reported by
79
+ // `claude mcp list` — the callers in refresh.mjs filter by this name so that the
80
+ // baseline is only injected for servers actually visible in the current session.
81
+ // Without this filter, 25 phantom tools leak into the catalog in any environment
82
+ // where Claude.ai OAuth is not connected (isolated CLAUDE_CONFIG_DIR, Max plan
83
+ // users without Desktop integration, or partial Gmail-only / Calendar-only setups).
84
+ const BY_SERVER = {
85
+ 'claude.ai Gmail': GMAIL,
86
+ 'claude.ai Google Calendar': CALENDAR,
87
+ 'claude.ai Google Drive': DRIVE,
88
+ };
83
89
 
84
- export function getClaudeAiDescription(name) {
85
- return ALL[name] ?? null;
90
+ // Returns Map<serverName, {toolName: description}>.
91
+ export function getClaudeAiBaselineByServer() {
92
+ return new Map(Object.entries(BY_SERVER));
86
93
  }
@@ -45,7 +45,7 @@ export async function listMcpToolsAll({ logFn = () => {}, claudeBin = 'claude',
45
45
  const out = new Map();
46
46
  for (const server of servers) {
47
47
  try {
48
- const tools = await listMcpToolsOne({ server, logFn });
48
+ const tools = await listMcpToolsOne({ server, logFn, claudeBin, projectRoot });
49
49
  out.set(server.name, tools);
50
50
  } catch (err) {
51
51
  logFn(`mcp investigate failed for "${server.name}": ${err.message}`);
@@ -64,9 +64,15 @@ export async function listMcpToolsAll({ logFn = () => {}, claudeBin = 'claude',
64
64
  // full descriptor (with env/headers). Otherwise we fall back to the parsed CLI line,
65
65
  // which at minimum gives us name + transport + url (or triggers `claude mcp get` for
66
66
  // stdio command tokenisation).
67
+ //
68
+ // We pass `cwd: projectRoot` to the CLI so its project-scope walk-up lands in the same
69
+ // directory we read `.mcp.json` from. Without this, `claude` walks up from the parent
70
+ // process's cwd and can resolve a different project than `readMcpServers` does.
67
71
  export async function listMcpServers({ claudeBin = 'claude', projectRoot } = {}) {
72
+ const execOpts = { encoding: 'utf8' };
73
+ if (projectRoot) execOpts.cwd = projectRoot;
68
74
  const [{ stdout }, mcpServers] = await Promise.all([
69
- execClaude(claudeBin, ['mcp', 'list'], { encoding: 'utf8' }),
75
+ execClaude(claudeBin, ['mcp', 'list'], execOpts),
70
76
  readMcpServers({ projectRoot }),
71
77
  ]);
72
78
  const cliList = parseMcpListOutput(stdout);
@@ -115,12 +121,12 @@ export function parseMcpListOutput(text) {
115
121
  // Fetch tools/list from a single MCP server. The `server` descriptor either came
116
122
  // from `.mcp.json` (carries env / headers) or from CLI output (bare). For stdio
117
123
  // entries without full config we fall back to `claude mcp get`.
118
- export async function listMcpToolsOne({ server, logFn = () => {}, claudeBin = 'claude' }) {
124
+ export async function listMcpToolsOne({ server, logFn = () => {}, claudeBin = 'claude', projectRoot }) {
119
125
  if (server.transport === 'stdio') {
120
126
  const hasFullConfig = server.command !== undefined;
121
127
  const config = hasFullConfig
122
128
  ? { command: server.command, args: server.args ?? [], env: server.env ?? {} }
123
- : await getStdioConfig({ name: server.name, claudeBin });
129
+ : await getStdioConfig({ name: server.name, claudeBin, projectRoot });
124
130
  return spawnAndQuery(config, server.name);
125
131
  }
126
132
  if (server.transport === 'http' || server.transport === 'sse') {
@@ -135,8 +141,12 @@ export async function listMcpToolsOne({ server, logFn = () => {}, claudeBin = 'c
135
141
  }
136
142
 
137
143
  // Parse `claude mcp get <name>` to extract Command + Args for stdio servers.
138
- async function getStdioConfig({ name, claudeBin }) {
139
- const { stdout } = await execClaude(claudeBin, ['mcp', 'get', name], { encoding: 'utf8' });
144
+ // `cwd: projectRoot` pins the CLI's scope walk-up to the same directory used for
145
+ // `.mcp.json` reading see listMcpServers for the rationale.
146
+ async function getStdioConfig({ name, claudeBin, projectRoot }) {
147
+ const execOpts = { encoding: 'utf8' };
148
+ if (projectRoot) execOpts.cwd = projectRoot;
149
+ const { stdout } = await execClaude(claudeBin, ['mcp', 'get', name], execOpts);
140
150
  let command = null;
141
151
  let argsRaw = null;
142
152
  for (const rawLine of stdout.split('\n')) {
@@ -11,12 +11,26 @@
11
11
  // sub-agents.
12
12
 
13
13
  import { resolveAll } from './lookup.mjs';
14
- import { listMcpToolsAll, bellVisibleName } from './investigate-mcp.mjs';
15
- import { getClaudeAiDescription, listClaudeAiNames } from './claude-ai-baseline.mjs';
14
+ import { listMcpToolsAll, listMcpServers, bellVisibleName } from './investigate-mcp.mjs';
15
+ import { getClaudeAiBaselineByServer } from './claude-ai-baseline.mjs';
16
16
  import { listSkillsAll } from './investigate-skills.mjs';
17
17
  import { listAgentsAll } from './investigate-agents.mjs';
18
18
  import { localDbPath, globalDbPath } from './loader.mjs';
19
19
 
20
+ // Pure filter: returns the subset of the claude.ai baseline whose server name is
21
+ // present in `presentServerNames`. Accepts a Set for O(1) membership. Extracted as
22
+ // a named export so it can be unit-tested without a live `claude` CLI.
23
+ export function filterClaudeAiBaseline(presentServerNames) {
24
+ const out = new Map();
25
+ for (const [serverName, tools] of getClaudeAiBaselineByServer()) {
26
+ if (!presentServerNames.has(serverName)) continue;
27
+ for (const [toolName, description] of Object.entries(tools)) {
28
+ out.set(toolName, description);
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
20
34
  // Build the (name → description) map for an investigation pass across all sources:
21
35
  // - claude.ai MCP baseline (Gmail / Calendar / Drive — OAuth, not locally introspectable)
22
36
  // - MCP servers via stdio + HTTP/SSE (user + project .mcp.json, live fetched)
@@ -27,11 +41,19 @@ import { localDbPath, globalDbPath } from './loader.mjs';
27
41
  export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin = 'claude', projectRoot } = {}) {
28
42
  const snapshot = new Map();
29
43
 
30
- // Anthropic-provided `claude.ai ...` MCP servers hardcoded because the OAuth proxy
44
+ // Anthropic-provided `claude.ai ...` MCP servers. Hardcoded because the OAuth proxy
31
45
  // is not reachable without reading ~/.claude/.credentials.json (deliberately avoided).
32
- // If a live HTTP investigate for the same name later succeeds below, it overrides.
33
- for (const name of listClaudeAiNames()) {
34
- snapshot.set(name, getClaudeAiDescription(name));
46
+ // Injected only for servers actually present in `claude mcp list` otherwise phantom
47
+ // tools (Gmail/Calendar/Drive) leak into environments where those servers are not
48
+ // connected. See filterClaudeAiBaseline above.
49
+ const servers = await listMcpServers({ claudeBin, projectRoot });
50
+ const presentServerNames = new Set(servers.map((s) => s.name));
51
+ const baseline = filterClaudeAiBaseline(presentServerNames);
52
+ for (const [name, description] of baseline) {
53
+ snapshot.set(name, description);
54
+ }
55
+ if (baseline.size > 0) {
56
+ logFn(`claude.ai baseline injected: ${baseline.size} tools from ${[...presentServerNames].filter((n) => n.startsWith('claude.ai ')).join(', ')}`);
35
57
  }
36
58
 
37
59
  // MCP servers (stdio + user-registered HTTP/SSE). projectRoot forwards for