claude-spotter 0.8.0 → 0.10.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,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
**project scope `.mcp.json` 対応**。v0.9.0 は `~/.claude/.mcp.json` (user scope) だけを読んでいたため、プロジェクト直下の `.mcp.json` に登録された MCP サーバーの認証情報 (env / headers) を拾えなかった。`<projectRoot>/.mcp.json` も読んで user scope に merge (project 勝ち = Claude Code 本体の precedence と整合) するよう変更。
|
|
6
|
+
|
|
7
|
+
### 変更点
|
|
8
|
+
|
|
9
|
+
- **編集 [src/tool-db/mcp-config.mjs](src/tool-db/mcp-config.mjs)**: `readMcpServers({projectRoot})` シグネチャへ変更。`projectRoot` が渡されれば user scope に project scope を merge して返す。missing file は空として扱う
|
|
10
|
+
- **編集 [src/tool-db/investigate-mcp.mjs](src/tool-db/investigate-mcp.mjs)**: `listMcpServers` / `listMcpToolsAll` が `projectRoot` を受け取って伝搬
|
|
11
|
+
- **編集 [src/tool-db/refresh.mjs](src/tool-db/refresh.mjs)**: `buildInvestigationSnapshot` / `refresh` が `projectRoot` を伝搬 (CLI からは既に渡されている、経路が繋がった)
|
|
12
|
+
- **編集 [test/tool-db.test.mjs](test/tool-db.test.mjs)**: project-scope override + missing-file fallback の 2 ケース追加 (total 99 tests)
|
|
13
|
+
|
|
14
|
+
### 非互換
|
|
15
|
+
|
|
16
|
+
- `readMcpServers()` → `readMcpServers({projectRoot})`: 引数なしも引き続き動く (user scope のみ) ので既存コードは影響なし
|
|
17
|
+
|
|
18
|
+
## 0.9.0
|
|
19
|
+
|
|
20
|
+
**`.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 に渡す。
|
|
21
|
+
|
|
22
|
+
### 事の発端
|
|
23
|
+
|
|
24
|
+
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 の古さと思われる)。
|
|
25
|
+
|
|
26
|
+
判明した設計上の転換点:
|
|
27
|
+
|
|
28
|
+
- **`.mcp.json` はユーザーが自己申告した MCP 設定ファイル** — ここに secrets が書かれているのはユーザーの意思。Anthropic の OAuth token を保持する `.credentials.json` とは性格が違う。`.mcp.json` を読むことは v0.8.0 で引いた境界線 (credentials は触らない) に抵触しない
|
|
29
|
+
- **`claude mcp list` は scope 統合ビュー、`.mcp.json` は user scope の詳細**。前者で名前を取り、後者で詳細を当てる併用が最も抜け漏れない
|
|
30
|
+
|
|
31
|
+
### 変更点
|
|
32
|
+
|
|
33
|
+
- **新規 [src/tool-db/mcp-config.mjs](src/tool-db/mcp-config.mjs)**: `~/.claude/.mcp.json` をパース、`describeServer()` で `{command, args, env}` (stdio) または `{url, headers}` (http/sse) のディスクリプタに正規化
|
|
34
|
+
- **編集 [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
|
|
35
|
+
- **編集 [src/tool-db/investigate-mcp-http.mjs](src/tool-db/investigate-mcp-http.mjs)**: `listToolsHttp` が `headers` パラメータを受け取って fetch の HTTP headers に merge
|
|
36
|
+
- **編集 [test/tool-db.test.mjs](test/tool-db.test.mjs)**: `describeServer` の unit test 5 件追加 (stdio + env、stdio 最小、http + headers、sse 判別、未知エントリ)
|
|
37
|
+
|
|
38
|
+
### 実測
|
|
39
|
+
|
|
40
|
+
`spotter db rebuild` で x-api の 9 ツール (get_trends / search_tweets / fetch_tweet 等) が **live fetch で投入される** ようになった (`investigated=9`)。手書き baseline は不要。`describeServer` テスト 5 件追加で total 97 tests。
|
|
41
|
+
|
|
42
|
+
### 残る課題
|
|
43
|
+
|
|
44
|
+
- **project scope `.mcp.json` 未対応**: プロジェクト直下の `.mcp.json` は読んでいない。v0.9.0 では user scope のみ
|
|
45
|
+
- **claude.ai baseline は維持**: Gmail/Calendar/Drive は `.mcp.json` に登録されない (OAuth proxy 経由) ので hardcoded のまま
|
|
46
|
+
|
|
3
47
|
## 0.8.0
|
|
4
48
|
|
|
5
49
|
**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
|
@@ -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.
|
|
90
|
+
clientInfo: { name: 'spotter', version: '0.10.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
|
|
|
@@ -39,8 +40,8 @@ export class McpInvestigationError extends Error {
|
|
|
39
40
|
|
|
40
41
|
// Returns: Map<server-name, Array<{name, description}>>.
|
|
41
42
|
// Skips servers that fail (logs the failure) so one broken server doesn't block the rest.
|
|
42
|
-
export async function listMcpToolsAll({ logFn = () => {}, claudeBin = 'claude' } = {}) {
|
|
43
|
-
const servers = await listMcpServers({ claudeBin });
|
|
43
|
+
export async function listMcpToolsAll({ logFn = () => {}, claudeBin = 'claude', projectRoot } = {}) {
|
|
44
|
+
const servers = await listMcpServers({ claudeBin, projectRoot });
|
|
44
45
|
const out = new Map();
|
|
45
46
|
for (const server of servers) {
|
|
46
47
|
try {
|
|
@@ -53,11 +54,30 @@ export async function listMcpToolsAll({ logFn = () => {}, claudeBin = 'claude' }
|
|
|
53
54
|
return out;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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).
|
|
67
|
+
export async function listMcpServers({ claudeBin = 'claude', projectRoot } = {}) {
|
|
68
|
+
const [{ stdout }, mcpServers] = await Promise.all([
|
|
69
|
+
execClaude(claudeBin, ['mcp', 'list'], { encoding: 'utf8' }),
|
|
70
|
+
readMcpServers({ projectRoot }),
|
|
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.
|
|
96
|
-
//
|
|
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
|
|
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
|
-
//
|
|
104
|
-
//
|
|
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.
|
|
250
|
+
clientInfo: { name: 'spotter', version: '0.10.0' },
|
|
227
251
|
});
|
|
228
252
|
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
229
253
|
initializedSent = true;
|
|
@@ -0,0 +1,74 @@
|
|
|
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: merges user-level `~/.claude/.mcp.json` with optional project-level
|
|
10
|
+
// `<projectRoot>/.mcp.json`. Project scope overrides user scope on name collision
|
|
11
|
+
// (matches Claude Code's own precedence — more-specific scope wins).
|
|
12
|
+
// `settings.local.json` (local scope) is not yet consulted.
|
|
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
|
+
export function projectMcpConfigPath(projectRoot) {
|
|
27
|
+
return join(projectRoot, '.mcp.json');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readOne(path) {
|
|
31
|
+
try {
|
|
32
|
+
const text = await readFile(path, 'utf8');
|
|
33
|
+
const data = JSON.parse(text);
|
|
34
|
+
return data.mcpServers ?? {};
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err.code === 'ENOENT') return {};
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Returns the merged `mcpServers` object: user scope as base, project scope overrides
|
|
42
|
+
// on name collision. Missing files are treated as empty (not an error). If projectRoot
|
|
43
|
+
// is not supplied, only user scope is read.
|
|
44
|
+
export async function readMcpServers({ projectRoot } = {}) {
|
|
45
|
+
const user = await readOne(userMcpConfigPath());
|
|
46
|
+
if (!projectRoot) return user;
|
|
47
|
+
const project = await readOne(projectMcpConfigPath(projectRoot));
|
|
48
|
+
return { ...user, ...project };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Normalise an `.mcp.json` entry into a server descriptor the investigator can use.
|
|
52
|
+
// Returns { name, transport: 'stdio'|'http'|'sse', ...transport-specific fields } or
|
|
53
|
+
// null if the entry is not recognisable.
|
|
54
|
+
export function describeServer(name, entry) {
|
|
55
|
+
if (entry.command) {
|
|
56
|
+
return {
|
|
57
|
+
name,
|
|
58
|
+
transport: 'stdio',
|
|
59
|
+
command: entry.command,
|
|
60
|
+
args: Array.isArray(entry.args) ? entry.args : [],
|
|
61
|
+
env: entry.env && typeof entry.env === 'object' ? entry.env : {},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (entry.url) {
|
|
65
|
+
const transport = entry.type === 'sse' ? 'sse' : 'http';
|
|
66
|
+
return {
|
|
67
|
+
name,
|
|
68
|
+
transport,
|
|
69
|
+
url: entry.url,
|
|
70
|
+
headers: entry.headers && typeof entry.headers === 'object' ? entry.headers : {},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
package/src/tool-db/refresh.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { localDbPath, globalDbPath } from './loader.mjs';
|
|
|
17
17
|
// - Deferred: from the hardcoded baseline
|
|
18
18
|
//
|
|
19
19
|
// Returns an in-memory snapshot the caller can use as the investigate() backend.
|
|
20
|
-
export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin = 'claude' } = {}) {
|
|
20
|
+
export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin = 'claude', projectRoot } = {}) {
|
|
21
21
|
const snapshot = new Map();
|
|
22
22
|
|
|
23
23
|
// Deferred built-ins (Claude Code deferred tools like WebFetch, TodoWrite, etc.).
|
|
@@ -32,8 +32,9 @@ export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin =
|
|
|
32
32
|
snapshot.set(name, getClaudeAiDescription(name));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
// MCP servers (stdio + user-registered HTTP/SSE).
|
|
36
|
-
|
|
35
|
+
// MCP servers (stdio + user-registered HTTP/SSE). projectRoot forwards for
|
|
36
|
+
// project-scope `.mcp.json` merge (see mcp-config.mjs).
|
|
37
|
+
const mcp = await listMcpToolsAll({ logFn, claudeBin, projectRoot });
|
|
37
38
|
for (const [serverName, tools] of mcp.entries()) {
|
|
38
39
|
for (const tool of tools) {
|
|
39
40
|
if (!tool.description || tool.description.length === 0) continue;
|
|
@@ -47,7 +48,7 @@ export async function buildInvestigationSnapshot({ logFn = () => {}, claudeBin =
|
|
|
47
48
|
// Refresh the tool-db. Discovers all currently available tools, resolves each via the
|
|
48
49
|
// 3-tier lookup, writes through. Returns the resolved Map.
|
|
49
50
|
export async function refresh({ projectRoot, logFn = () => {}, claudeBin = 'claude' }) {
|
|
50
|
-
const snapshot = await buildInvestigationSnapshot({ logFn, claudeBin });
|
|
51
|
+
const snapshot = await buildInvestigationSnapshot({ logFn, claudeBin, projectRoot });
|
|
51
52
|
const toolNames = Array.from(snapshot.keys());
|
|
52
53
|
const investigate = async (name) => snapshot.get(name) ?? null;
|
|
53
54
|
|
package/src/version.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = '0.
|
|
1
|
+
export const version = '0.10.0';
|