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 +29 -0
- package/package.json +1 -1
- package/src/tool-db/investigate-mcp-http.mjs +3 -2
- package/src/tool-db/investigate-mcp.mjs +37 -13
- package/src/tool-db/mcp-config.mjs +62 -0
- package/src/version.mjs +1 -1
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
|
@@ -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.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
|
-
//
|
|
57
|
-
//
|
|
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
|
|
60
|
-
|
|
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.
|
|
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.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.
|
|
1
|
+
export const version = '0.9.0';
|