pikiloop 0.4.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/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- package/package.json +82 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP extension management — CRUD, catalog merge, health check, session merge.
|
|
3
|
+
*
|
|
4
|
+
* Global extensions live in ~/.pikiloop/setting.json under extensions.mcp.
|
|
5
|
+
* Workspace extensions live in <workdir>/.mcp.json (standard format).
|
|
6
|
+
*
|
|
7
|
+
* getCatalogItems() produces the unified list the dashboard renders:
|
|
8
|
+
* recommended-registry entries merged with installed entries, with a single
|
|
9
|
+
* state field per item (recommended | needs_auth | disabled | ready | unhealthy).
|
|
10
|
+
*
|
|
11
|
+
* mergeExtensionsForSession() is called by bridge.ts before spawning an agent —
|
|
12
|
+
* it resolves disabled flags, expands OAuth Bearer headers from the token store,
|
|
13
|
+
* and hands the final config map to the agent CLI.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { loadUserConfig, saveUserConfig } from '../../core/config/user-config.js';
|
|
20
|
+
import { getRecommendedMcpServers, } from './registry.js';
|
|
21
|
+
import { hasValidMcpToken, injectOAuthHeaders } from './oauth.js';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Global extensions (setting.json)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export function loadGlobalMcpExtensions() {
|
|
26
|
+
const config = loadUserConfig();
|
|
27
|
+
const mcp = config.extensions?.mcp;
|
|
28
|
+
if (!mcp || typeof mcp !== 'object')
|
|
29
|
+
return [];
|
|
30
|
+
return Object.entries(mcp).map(([name, cfg]) => ({
|
|
31
|
+
name,
|
|
32
|
+
config: cfg,
|
|
33
|
+
scope: 'global',
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
export function addGlobalMcpExtension(name, config) {
|
|
37
|
+
const userConfig = loadUserConfig();
|
|
38
|
+
const extensions = userConfig.extensions ?? {};
|
|
39
|
+
const mcp = { ...(extensions.mcp ?? {}) };
|
|
40
|
+
mcp[name] = config;
|
|
41
|
+
saveUserConfig({ ...userConfig, extensions: { ...extensions, mcp } });
|
|
42
|
+
}
|
|
43
|
+
export function removeGlobalMcpExtension(name) {
|
|
44
|
+
const userConfig = loadUserConfig();
|
|
45
|
+
const mcp = { ...(userConfig.extensions?.mcp ?? {}) };
|
|
46
|
+
if (!(name in mcp))
|
|
47
|
+
return false;
|
|
48
|
+
delete mcp[name];
|
|
49
|
+
saveUserConfig({
|
|
50
|
+
...userConfig,
|
|
51
|
+
extensions: { ...userConfig.extensions, mcp },
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
export function updateGlobalMcpExtension(name, patch) {
|
|
56
|
+
const userConfig = loadUserConfig();
|
|
57
|
+
const mcp = { ...(userConfig.extensions?.mcp ?? {}) };
|
|
58
|
+
if (!(name in mcp))
|
|
59
|
+
return false;
|
|
60
|
+
mcp[name] = { ...mcp[name], ...patch };
|
|
61
|
+
saveUserConfig({
|
|
62
|
+
...userConfig,
|
|
63
|
+
extensions: { ...userConfig.extensions, mcp },
|
|
64
|
+
});
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Workspace extensions (.mcp.json)
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
function workspaceMcpJsonPath(workdir) {
|
|
71
|
+
return path.join(workdir, '.mcp.json');
|
|
72
|
+
}
|
|
73
|
+
function readMcpJson(filePath) {
|
|
74
|
+
try {
|
|
75
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
76
|
+
const parsed = JSON.parse(raw);
|
|
77
|
+
const servers = parsed?.mcpServers ?? parsed;
|
|
78
|
+
if (typeof servers === 'object' && servers !== null && !Array.isArray(servers)) {
|
|
79
|
+
return servers;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch { /* not found or invalid */ }
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
function writeMcpJson(filePath, servers) {
|
|
86
|
+
const content = JSON.stringify({ mcpServers: servers }, null, 2) + '\n';
|
|
87
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
88
|
+
fs.writeFileSync(filePath, content);
|
|
89
|
+
}
|
|
90
|
+
export function loadWorkspaceMcpExtensions(workdir) {
|
|
91
|
+
const mcpPath = workspaceMcpJsonPath(workdir);
|
|
92
|
+
const servers = readMcpJson(mcpPath);
|
|
93
|
+
return Object.entries(servers).map(([name, cfg]) => ({
|
|
94
|
+
name,
|
|
95
|
+
config: cfg,
|
|
96
|
+
scope: 'workspace',
|
|
97
|
+
source: mcpPath,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
export function addWorkspaceMcpExtension(workdir, name, config) {
|
|
101
|
+
const mcpPath = workspaceMcpJsonPath(workdir);
|
|
102
|
+
const servers = readMcpJson(mcpPath);
|
|
103
|
+
servers[name] = config;
|
|
104
|
+
writeMcpJson(mcpPath, servers);
|
|
105
|
+
}
|
|
106
|
+
export function removeWorkspaceMcpExtension(workdir, name) {
|
|
107
|
+
const mcpPath = workspaceMcpJsonPath(workdir);
|
|
108
|
+
const servers = readMcpJson(mcpPath);
|
|
109
|
+
if (!(name in servers))
|
|
110
|
+
return false;
|
|
111
|
+
delete servers[name];
|
|
112
|
+
writeMcpJson(mcpPath, servers);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
export function updateWorkspaceMcpExtension(workdir, name, patch) {
|
|
116
|
+
const mcpPath = workspaceMcpJsonPath(workdir);
|
|
117
|
+
const servers = readMcpJson(mcpPath);
|
|
118
|
+
if (!(name in servers))
|
|
119
|
+
return false;
|
|
120
|
+
servers[name] = { ...servers[name], ...patch };
|
|
121
|
+
writeMcpJson(mcpPath, servers);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Unified listing
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
export function listAllMcpExtensions(workdir) {
|
|
128
|
+
const global = loadGlobalMcpExtensions();
|
|
129
|
+
const workspace = workdir ? loadWorkspaceMcpExtensions(workdir) : [];
|
|
130
|
+
const claudeMcp = [];
|
|
131
|
+
if (workdir) {
|
|
132
|
+
const claudePath = path.join(workdir, '.claude', '.mcp.json');
|
|
133
|
+
const servers = readMcpJson(claudePath);
|
|
134
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
135
|
+
claudeMcp.push({ name, config: cfg, scope: 'workspace', source: claudePath });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return [...global, ...workspace, ...claudeMcp];
|
|
139
|
+
}
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Catalog — merged recommended + installed with state computation
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
function cmdSummary(config) {
|
|
144
|
+
if (config.type === 'http' && config.url)
|
|
145
|
+
return config.url;
|
|
146
|
+
const cmd = config.command || '';
|
|
147
|
+
const args = (config.args || []).filter(a => a !== '-y');
|
|
148
|
+
return [cmd, ...args].join(' ').trim();
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Generic @modelcontextprotocol/server-* demos that historically shipped in the
|
|
152
|
+
* recommended list but were later removed (no product identity, overlap with
|
|
153
|
+
* built-in agent capabilities like search/time). We hide them from the catalog
|
|
154
|
+
* UI so old installs don't clutter the Connected section. The configs are kept
|
|
155
|
+
* in setting.json untouched — users can still edit by hand if they want.
|
|
156
|
+
*/
|
|
157
|
+
const HIDDEN_GENERIC_DEMO_PACKAGES = new Set([
|
|
158
|
+
'@modelcontextprotocol/server-time',
|
|
159
|
+
'@modelcontextprotocol/server-fetch',
|
|
160
|
+
'@modelcontextprotocol/server-memory',
|
|
161
|
+
'mcp-server-time',
|
|
162
|
+
'mcp-server-fetch',
|
|
163
|
+
'mcp-server-memory',
|
|
164
|
+
]);
|
|
165
|
+
const HIDDEN_GENERIC_DEMO_NAMES = new Set(['time', 'fetch', 'memory']);
|
|
166
|
+
function isGenericDemoEntry(entry) {
|
|
167
|
+
if (HIDDEN_GENERIC_DEMO_NAMES.has(entry.name.toLowerCase()))
|
|
168
|
+
return true;
|
|
169
|
+
const args = entry.config.args || [];
|
|
170
|
+
return args.some(a => HIDDEN_GENERIC_DEMO_PACKAGES.has(a));
|
|
171
|
+
}
|
|
172
|
+
function transportSummary(transport) {
|
|
173
|
+
if (transport.type === 'http')
|
|
174
|
+
return transport.url;
|
|
175
|
+
return [transport.command, ...transport.args.filter(a => a !== '-y')].join(' ');
|
|
176
|
+
}
|
|
177
|
+
function hasRequiredCredentials(config, auth) {
|
|
178
|
+
if (auth.type !== 'credentials')
|
|
179
|
+
return true;
|
|
180
|
+
const bag = { ...(config.env || {}), ...(config.headers || {}) };
|
|
181
|
+
for (const field of auth.fields) {
|
|
182
|
+
if (!field.required)
|
|
183
|
+
continue;
|
|
184
|
+
if (!bag[field.key] || !String(bag[field.key]).trim())
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
function computeStateForInstalled(config, auth, id, unhealthyIds) {
|
|
190
|
+
if (config.enabled === false || config.disabled === true)
|
|
191
|
+
return 'disabled';
|
|
192
|
+
if (auth.type === 'credentials' && !hasRequiredCredentials(config, auth))
|
|
193
|
+
return 'needs_auth';
|
|
194
|
+
if (auth.type === 'mcp-oauth' && !hasValidMcpToken(id))
|
|
195
|
+
return 'needs_auth';
|
|
196
|
+
if (unhealthyIds?.has(id))
|
|
197
|
+
return 'unhealthy';
|
|
198
|
+
return 'ready';
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Produce the unified catalog for the dashboard: every recommended registry
|
|
202
|
+
* entry, plus any custom installed entries the user added, each with a
|
|
203
|
+
* computed state field.
|
|
204
|
+
*
|
|
205
|
+
* When `scope` is provided, recommended entries are filtered to those whose
|
|
206
|
+
* `recommendedScope` matches (or is `'both'`). Custom entries are filtered
|
|
207
|
+
* by where they are installed — `scope: 'global'` excludes workspace entries
|
|
208
|
+
* and vice versa.
|
|
209
|
+
*/
|
|
210
|
+
export function getCatalogItems(opts = {}) {
|
|
211
|
+
const recommended = getRecommendedMcpServers();
|
|
212
|
+
const installed = [
|
|
213
|
+
...loadGlobalMcpExtensions(),
|
|
214
|
+
...(opts.workdir ? loadWorkspaceMcpExtensions(opts.workdir) : []),
|
|
215
|
+
];
|
|
216
|
+
// Build lookup: catalogId -> installed entry (preferring global).
|
|
217
|
+
const installedByCatalogId = new Map();
|
|
218
|
+
const customEntries = [];
|
|
219
|
+
for (const entry of installed) {
|
|
220
|
+
const catalogId = entry.config.catalogId;
|
|
221
|
+
if (catalogId && recommended.some(r => r.id === catalogId)) {
|
|
222
|
+
if (!installedByCatalogId.has(catalogId))
|
|
223
|
+
installedByCatalogId.set(catalogId, entry);
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
customEntries.push(entry);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const scopeMatchesRec = (rec) => {
|
|
230
|
+
if (!opts.scope)
|
|
231
|
+
return true;
|
|
232
|
+
return rec.recommendedScope === opts.scope || rec.recommendedScope === 'both';
|
|
233
|
+
};
|
|
234
|
+
const scopeMatchesEntry = (entry) => {
|
|
235
|
+
if (!opts.scope)
|
|
236
|
+
return true;
|
|
237
|
+
if (opts.scope === 'both')
|
|
238
|
+
return true;
|
|
239
|
+
return entry.scope === opts.scope;
|
|
240
|
+
};
|
|
241
|
+
const items = [];
|
|
242
|
+
// Builtin items derive their installed/state from a top-level config flag
|
|
243
|
+
// rather than `extensions.mcp`. Each catalogId maps to one flag — extend the
|
|
244
|
+
// switch when adding a new builtin.
|
|
245
|
+
const userConfig = loadUserConfig();
|
|
246
|
+
const builtinInstalled = (catalogId) => {
|
|
247
|
+
if (catalogId === 'pikiloop-browser')
|
|
248
|
+
return userConfig.browserEnabled === true;
|
|
249
|
+
if (catalogId === 'peekaboo')
|
|
250
|
+
return userConfig.peekabooEnabled === true;
|
|
251
|
+
return false;
|
|
252
|
+
};
|
|
253
|
+
// 1. Registry entries — preserve registry ordering.
|
|
254
|
+
for (const rec of recommended) {
|
|
255
|
+
if (!scopeMatchesRec(rec))
|
|
256
|
+
continue;
|
|
257
|
+
const entry = installedByCatalogId.get(rec.id);
|
|
258
|
+
let state;
|
|
259
|
+
let installed;
|
|
260
|
+
let installedKey;
|
|
261
|
+
let scope;
|
|
262
|
+
let config;
|
|
263
|
+
if (rec.isBuiltin) {
|
|
264
|
+
installed = builtinInstalled(rec.id);
|
|
265
|
+
state = installed ? 'ready' : 'recommended';
|
|
266
|
+
installedKey = installed ? rec.id : undefined;
|
|
267
|
+
// Leave scope undefined: builtins aren't tied to global/workspace
|
|
268
|
+
// storage, and the catalog UI's scope filter ignores items with no scope.
|
|
269
|
+
scope = undefined;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
state = entry
|
|
273
|
+
? computeStateForInstalled(entry.config, rec.auth, rec.id, opts.unhealthyIds)
|
|
274
|
+
: 'recommended';
|
|
275
|
+
installed = !!entry;
|
|
276
|
+
installedKey = entry?.name;
|
|
277
|
+
scope = entry?.scope;
|
|
278
|
+
config = entry?.config;
|
|
279
|
+
}
|
|
280
|
+
items.push({
|
|
281
|
+
id: rec.id,
|
|
282
|
+
name: rec.name,
|
|
283
|
+
description: rec.description,
|
|
284
|
+
descriptionZh: rec.descriptionZh,
|
|
285
|
+
category: rec.category,
|
|
286
|
+
iconSlug: rec.iconSlug,
|
|
287
|
+
iconUrl: rec.iconUrl,
|
|
288
|
+
homepage: rec.homepage,
|
|
289
|
+
transport: { type: rec.transport.type, summary: transportSummary(rec.transport) },
|
|
290
|
+
auth: rec.auth,
|
|
291
|
+
state,
|
|
292
|
+
isRecommended: true,
|
|
293
|
+
installed,
|
|
294
|
+
scope,
|
|
295
|
+
config,
|
|
296
|
+
installedKey,
|
|
297
|
+
recommendedScope: rec.recommendedScope,
|
|
298
|
+
isBuiltin: rec.isBuiltin,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
// 2. Custom entries — user-added servers not in the recommended registry.
|
|
302
|
+
for (const entry of customEntries) {
|
|
303
|
+
if (!scopeMatchesEntry(entry))
|
|
304
|
+
continue;
|
|
305
|
+
if (isGenericDemoEntry(entry))
|
|
306
|
+
continue;
|
|
307
|
+
const auth = { type: 'none' };
|
|
308
|
+
const state = computeStateForInstalled(entry.config, auth, entry.name, opts.unhealthyIds);
|
|
309
|
+
items.push({
|
|
310
|
+
id: entry.name,
|
|
311
|
+
name: entry.name,
|
|
312
|
+
description: cmdSummary(entry.config),
|
|
313
|
+
descriptionZh: cmdSummary(entry.config),
|
|
314
|
+
category: 'custom',
|
|
315
|
+
transport: {
|
|
316
|
+
type: entry.config.type === 'http' ? 'http' : 'stdio',
|
|
317
|
+
summary: cmdSummary(entry.config),
|
|
318
|
+
},
|
|
319
|
+
auth,
|
|
320
|
+
state,
|
|
321
|
+
isRecommended: false,
|
|
322
|
+
installed: true,
|
|
323
|
+
scope: entry.scope,
|
|
324
|
+
config: entry.config,
|
|
325
|
+
installedKey: entry.name,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return items;
|
|
329
|
+
}
|
|
330
|
+
export function getCatalogItem(id, opts = {}) {
|
|
331
|
+
return getCatalogItems(opts).find(i => i.id === id);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Build an `McpServerConfig` from a recommended entry plus user-supplied
|
|
335
|
+
* credentials. Used when installing a recommended server via the catalog flow.
|
|
336
|
+
*/
|
|
337
|
+
export function buildInstalledConfigFromRecommended(rec, opts = { enabled: false }) {
|
|
338
|
+
const creds = opts.credentials || {};
|
|
339
|
+
if (rec.transport.type === 'stdio') {
|
|
340
|
+
const env = {};
|
|
341
|
+
if (rec.auth.type === 'credentials') {
|
|
342
|
+
for (const f of rec.auth.fields)
|
|
343
|
+
if (creds[f.key])
|
|
344
|
+
env[f.key] = creds[f.key];
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
type: 'stdio',
|
|
348
|
+
command: rec.transport.command,
|
|
349
|
+
args: rec.transport.args,
|
|
350
|
+
...(Object.keys(env).length ? { env } : {}),
|
|
351
|
+
enabled: opts.enabled,
|
|
352
|
+
catalogId: rec.id,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
const headers = {};
|
|
356
|
+
if (rec.auth.type === 'credentials') {
|
|
357
|
+
// Convention: first non-empty credential becomes Authorization: Bearer <value>.
|
|
358
|
+
// Matches how Stripe, Perplexity, and similar providers expect the token.
|
|
359
|
+
const first = rec.auth.fields.find(f => creds[f.key]);
|
|
360
|
+
if (first)
|
|
361
|
+
headers.Authorization = `Bearer ${creds[first.key]}`;
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
type: 'http',
|
|
365
|
+
url: rec.transport.url,
|
|
366
|
+
...(Object.keys(headers).length ? { headers } : {}),
|
|
367
|
+
enabled: opts.enabled,
|
|
368
|
+
catalogId: rec.id,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Merge for session — called by bridge.ts
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
/**
|
|
375
|
+
* Build the merged MCP server list for a session.
|
|
376
|
+
* Priority (low → high): global → workspace .mcp.json → .claude/.mcp.json → ~/.claude/.mcp.json → builtins.
|
|
377
|
+
* Disabled servers are filtered out. OAuth Bearer headers are injected for
|
|
378
|
+
* any http-type server that has a valid token in the token store.
|
|
379
|
+
*/
|
|
380
|
+
export function mergeExtensionsForSession(builtinServers, workdir) {
|
|
381
|
+
const merged = {};
|
|
382
|
+
// 1. Global extensions from setting.json (lowest priority)
|
|
383
|
+
const userConfig = loadUserConfig();
|
|
384
|
+
const globalMcp = userConfig.extensions?.mcp;
|
|
385
|
+
if (globalMcp) {
|
|
386
|
+
for (const [name, cfg] of Object.entries(globalMcp)) {
|
|
387
|
+
if (cfg.enabled === false || cfg.disabled)
|
|
388
|
+
continue;
|
|
389
|
+
if (cfg.type === 'http' && cfg.url) {
|
|
390
|
+
const oauthKey = cfg.catalogId || name;
|
|
391
|
+
const headers = injectOAuthHeaders(oauthKey, { headers: cfg.headers });
|
|
392
|
+
merged[name] = {
|
|
393
|
+
type: 'http',
|
|
394
|
+
url: cfg.url,
|
|
395
|
+
...(Object.keys(headers).length ? { headers } : {}),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
else if (cfg.command) {
|
|
399
|
+
merged[name] = {
|
|
400
|
+
type: 'stdio',
|
|
401
|
+
command: cfg.command,
|
|
402
|
+
args: cfg.args || [],
|
|
403
|
+
...(cfg.env ? { env: cfg.env } : {}),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// 2. Workspace .mcp.json files (overwrite global)
|
|
409
|
+
if (workdir) {
|
|
410
|
+
for (const candidate of [
|
|
411
|
+
path.join(workdir, '.mcp.json'),
|
|
412
|
+
path.join(workdir, '.claude', '.mcp.json'),
|
|
413
|
+
path.join(os.homedir(), '.claude', '.mcp.json'),
|
|
414
|
+
]) {
|
|
415
|
+
try {
|
|
416
|
+
const raw = fs.readFileSync(candidate, 'utf-8');
|
|
417
|
+
const parsed = JSON.parse(raw);
|
|
418
|
+
const servers = parsed?.mcpServers ?? parsed;
|
|
419
|
+
if (servers && typeof servers === 'object') {
|
|
420
|
+
for (const [name, cfg] of Object.entries(servers)) {
|
|
421
|
+
if (cfg?.disabled === true) {
|
|
422
|
+
delete merged[name];
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
Object.assign(merged, { [name]: cfg });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch { /* skip */ }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// 3. Built-in servers (highest priority)
|
|
434
|
+
for (const server of builtinServers) {
|
|
435
|
+
merged[server.name] = {
|
|
436
|
+
type: 'stdio',
|
|
437
|
+
command: server.command,
|
|
438
|
+
args: server.args,
|
|
439
|
+
...(server.env ? { env: server.env } : {}),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
// Filter out any remaining disabled entries
|
|
443
|
+
for (const [name, cfg] of Object.entries(merged)) {
|
|
444
|
+
if (cfg?.disabled === true || cfg?.enabled === false) {
|
|
445
|
+
delete merged[name];
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return merged;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Convert global + workspace extensions to RegisteredMcpServer[] for Codex
|
|
452
|
+
* and Gemini agents that consume server arrays instead of merged configs.
|
|
453
|
+
*
|
|
454
|
+
* Supports both stdio and HTTP transports. For HTTP entries, OAuth Bearer
|
|
455
|
+
* headers are injected from the token store (same path as
|
|
456
|
+
* mergeExtensionsForSession), so a one-time global authorization carries
|
|
457
|
+
* across every workspace.
|
|
458
|
+
*/
|
|
459
|
+
export function getGlobalExtensionsAsServers(workdir) {
|
|
460
|
+
const merged = new Map();
|
|
461
|
+
const toEntry = (name, cfg) => {
|
|
462
|
+
if (cfg.type === 'http' && cfg.url) {
|
|
463
|
+
const oauthKey = cfg.catalogId || name;
|
|
464
|
+
const headers = injectOAuthHeaders(oauthKey, { headers: cfg.headers });
|
|
465
|
+
return {
|
|
466
|
+
name,
|
|
467
|
+
type: 'http',
|
|
468
|
+
url: cfg.url,
|
|
469
|
+
...(Object.keys(headers).length ? { headers } : {}),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
if (cfg.command) {
|
|
473
|
+
return {
|
|
474
|
+
name,
|
|
475
|
+
type: 'stdio',
|
|
476
|
+
command: cfg.command,
|
|
477
|
+
args: cfg.args || [],
|
|
478
|
+
...(cfg.env ? { env: cfg.env } : {}),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
};
|
|
483
|
+
const userConfig = loadUserConfig();
|
|
484
|
+
const globalMcp = userConfig.extensions?.mcp;
|
|
485
|
+
if (globalMcp) {
|
|
486
|
+
for (const [name, cfg] of Object.entries(globalMcp)) {
|
|
487
|
+
if (cfg.enabled === false || cfg.disabled)
|
|
488
|
+
continue;
|
|
489
|
+
const entry = toEntry(name, cfg);
|
|
490
|
+
if (entry)
|
|
491
|
+
merged.set(name, entry);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (workdir) {
|
|
495
|
+
const wsServers = readMcpJson(workspaceMcpJsonPath(workdir));
|
|
496
|
+
for (const [name, cfg] of Object.entries(wsServers)) {
|
|
497
|
+
if (cfg.disabled) {
|
|
498
|
+
merged.delete(name);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const entry = toEntry(name, cfg);
|
|
502
|
+
if (entry)
|
|
503
|
+
merged.set(name, entry);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return [...merged.values()];
|
|
507
|
+
}
|
|
508
|
+
const HEALTH_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
509
|
+
const healthCache = new Map();
|
|
510
|
+
function healthFingerprint(config) {
|
|
511
|
+
return JSON.stringify({
|
|
512
|
+
type: config.type || 'stdio',
|
|
513
|
+
url: config.url,
|
|
514
|
+
command: config.command,
|
|
515
|
+
args: config.args,
|
|
516
|
+
hasEnv: !!config.env && Object.keys(config.env).length > 0,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
export function getCachedHealth(id, config) {
|
|
520
|
+
const entry = healthCache.get(id);
|
|
521
|
+
if (!entry)
|
|
522
|
+
return undefined;
|
|
523
|
+
if (Date.now() - entry.cachedAt > HEALTH_CACHE_TTL_MS)
|
|
524
|
+
return undefined;
|
|
525
|
+
if (entry.fingerprint !== healthFingerprint(config))
|
|
526
|
+
return undefined;
|
|
527
|
+
return entry.result;
|
|
528
|
+
}
|
|
529
|
+
export function cacheHealth(id, config, result) {
|
|
530
|
+
healthCache.set(id, { result, fingerprint: healthFingerprint(config), cachedAt: Date.now() });
|
|
531
|
+
}
|
|
532
|
+
export async function checkMcpHealth(config, timeoutMs = 10_000) {
|
|
533
|
+
if (config.type === 'http') {
|
|
534
|
+
try {
|
|
535
|
+
const start = Date.now();
|
|
536
|
+
const controller = new AbortController();
|
|
537
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
538
|
+
const res = await fetch(config.url, { signal: controller.signal, method: 'GET' });
|
|
539
|
+
clearTimeout(timer);
|
|
540
|
+
return { ok: res.ok || res.status === 405 || res.status === 401, elapsedMs: Date.now() - start };
|
|
541
|
+
}
|
|
542
|
+
catch (e) {
|
|
543
|
+
return { ok: false, error: e?.message || 'unreachable' };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (!config.command)
|
|
547
|
+
return { ok: false, error: 'no command specified' };
|
|
548
|
+
return new Promise((resolve) => {
|
|
549
|
+
const start = Date.now();
|
|
550
|
+
let checkInterval = null;
|
|
551
|
+
const cleanup = () => {
|
|
552
|
+
if (checkInterval) {
|
|
553
|
+
clearInterval(checkInterval);
|
|
554
|
+
checkInterval = null;
|
|
555
|
+
}
|
|
556
|
+
clearTimeout(timer);
|
|
557
|
+
};
|
|
558
|
+
const child = spawn(config.command, config.args || [], {
|
|
559
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
560
|
+
env: { ...process.env, ...config.env },
|
|
561
|
+
});
|
|
562
|
+
const timer = setTimeout(() => {
|
|
563
|
+
cleanup();
|
|
564
|
+
child.kill();
|
|
565
|
+
resolve({ ok: false, error: `timeout after ${timeoutMs}ms`, elapsedMs: Date.now() - start });
|
|
566
|
+
}, timeoutMs);
|
|
567
|
+
let stdout = '';
|
|
568
|
+
child.stdout?.on('data', (data) => { stdout += data.toString(); });
|
|
569
|
+
child.on('error', (err) => {
|
|
570
|
+
cleanup();
|
|
571
|
+
resolve({ ok: false, error: err.message, elapsedMs: Date.now() - start });
|
|
572
|
+
});
|
|
573
|
+
const initRequest = JSON.stringify({
|
|
574
|
+
jsonrpc: '2.0',
|
|
575
|
+
id: 1,
|
|
576
|
+
method: 'initialize',
|
|
577
|
+
params: {
|
|
578
|
+
protocolVersion: '2024-11-05',
|
|
579
|
+
capabilities: {},
|
|
580
|
+
clientInfo: { name: 'pikiloop-health-check', version: '1.0.0' },
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
const header = `Content-Length: ${Buffer.byteLength(initRequest)}\r\n\r\n`;
|
|
584
|
+
try {
|
|
585
|
+
child.stdin?.write(header + initRequest);
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
cleanup();
|
|
589
|
+
resolve({ ok: false, error: 'failed to write to stdin', elapsedMs: Date.now() - start });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
checkInterval = setInterval(() => {
|
|
593
|
+
const hasResponse = stdout.includes('"result"') || stdout.includes('"serverInfo"');
|
|
594
|
+
if (!hasResponse)
|
|
595
|
+
return;
|
|
596
|
+
cleanup();
|
|
597
|
+
const toolsRequest = JSON.stringify({
|
|
598
|
+
jsonrpc: '2.0',
|
|
599
|
+
id: 2,
|
|
600
|
+
method: 'tools/list',
|
|
601
|
+
params: {},
|
|
602
|
+
});
|
|
603
|
+
const toolsHeader = `Content-Length: ${Buffer.byteLength(toolsRequest)}\r\n\r\n`;
|
|
604
|
+
try {
|
|
605
|
+
child.stdin?.write(toolsHeader + toolsRequest);
|
|
606
|
+
}
|
|
607
|
+
catch { /* best-effort */ }
|
|
608
|
+
setTimeout(() => {
|
|
609
|
+
child.kill();
|
|
610
|
+
const tools = [];
|
|
611
|
+
try {
|
|
612
|
+
const jsonMatches = stdout.match(/\{[^{}]*"tools"\s*:\s*\[[\s\S]*?\]\s*[^{}]*\}/g);
|
|
613
|
+
if (jsonMatches) {
|
|
614
|
+
for (const m of jsonMatches) {
|
|
615
|
+
try {
|
|
616
|
+
const parsed = JSON.parse(m);
|
|
617
|
+
if (Array.isArray(parsed.tools)) {
|
|
618
|
+
for (const tool of parsed.tools)
|
|
619
|
+
if (tool.name)
|
|
620
|
+
tools.push(tool.name);
|
|
621
|
+
}
|
|
622
|
+
if (parsed.result?.tools) {
|
|
623
|
+
for (const tool of parsed.result.tools)
|
|
624
|
+
if (tool.name)
|
|
625
|
+
tools.push(tool.name);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch { /* try next match */ }
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
catch { /* best effort */ }
|
|
633
|
+
resolve({ ok: true, tools: tools.length ? tools : undefined, elapsedMs: Date.now() - start });
|
|
634
|
+
}, 1500);
|
|
635
|
+
}, 100);
|
|
636
|
+
});
|
|
637
|
+
}
|