rovecode 0.3.2
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 +662 -0
- package/README.md +737 -0
- package/THIRD_PARTY_NOTICES.md +268 -0
- package/bin/rovecode.ts +21 -0
- package/package.json +56 -0
- package/src/acp/server.ts +374 -0
- package/src/cli/auth-login.ts +122 -0
- package/src/cli/connect.ts +244 -0
- package/src/cli/context-cmd.ts +199 -0
- package/src/cli/dispatch.ts +82 -0
- package/src/cli/doctor.ts +362 -0
- package/src/cli/export.ts +276 -0
- package/src/cli/help.ts +293 -0
- package/src/cli/is-tui-invocation.ts +8 -0
- package/src/cli/main.ts +583 -0
- package/src/cli/market-cmd.ts +658 -0
- package/src/cli/mcp-login.ts +141 -0
- package/src/cli/mcp-market-cmd.ts +302 -0
- package/src/cli/output.ts +382 -0
- package/src/cli/repl.ts +250 -0
- package/src/cli/repomap-root.ts +14 -0
- package/src/cli/resume.ts +57 -0
- package/src/cli/run-flags.ts +43 -0
- package/src/cli/run-limits.ts +78 -0
- package/src/cli/runtime.ts +931 -0
- package/src/cli/session-arg.ts +30 -0
- package/src/cli/sessions-cmd.ts +145 -0
- package/src/cli/setup.ts +153 -0
- package/src/cli/skills-cmd.ts +194 -0
- package/src/cli/start-chat.ts +65 -0
- package/src/cli/trust-cmd.ts +52 -0
- package/src/coding/bash.ts +148 -0
- package/src/coding/checkpoints.ts +327 -0
- package/src/coding/diff.ts +138 -0
- package/src/coding/files.ts +341 -0
- package/src/coding/hashline.ts +274 -0
- package/src/coding/lsp-gate.ts +254 -0
- package/src/coding/lsp-servers.ts +147 -0
- package/src/coding/lsp.ts +283 -0
- package/src/coding/repomap-cache.ts +99 -0
- package/src/coding/repomap-files.ts +192 -0
- package/src/coding/repomap.ts +481 -0
- package/src/core/agents.ts +255 -0
- package/src/core/compaction.ts +259 -0
- package/src/core/config.ts +289 -0
- package/src/core/context-report.ts +228 -0
- package/src/core/context.ts +60 -0
- package/src/core/count-remote.ts +107 -0
- package/src/core/execpolicy-rules.ts +196 -0
- package/src/core/execpolicy.ts +385 -0
- package/src/core/executor.ts +454 -0
- package/src/core/guardrails.ts +400 -0
- package/src/core/hooks.ts +411 -0
- package/src/core/images.ts +230 -0
- package/src/core/intro.ts +266 -0
- package/src/core/loop.ts +567 -0
- package/src/core/modes.ts +372 -0
- package/src/core/orchestrator.ts +245 -0
- package/src/core/proc-group.ts +48 -0
- package/src/core/project-trust.ts +98 -0
- package/src/core/reflection.ts +165 -0
- package/src/core/sandbox-config.ts +186 -0
- package/src/core/session-id.ts +24 -0
- package/src/core/session-images.ts +73 -0
- package/src/core/session-ops.ts +183 -0
- package/src/core/session-text.ts +29 -0
- package/src/core/session.ts +469 -0
- package/src/core/settings.ts +170 -0
- package/src/core/tasks.ts +646 -0
- package/src/core/token-scale.ts +108 -0
- package/src/core/tools.ts +309 -0
- package/src/core/trust.ts +104 -0
- package/src/core/types.ts +330 -0
- package/src/core/update-check.ts +171 -0
- package/src/core/usage.ts +204 -0
- package/src/core/validate.ts +121 -0
- package/src/core/verify-gate.ts +159 -0
- package/src/core/verify.ts +236 -0
- package/src/core/voice.ts +158 -0
- package/src/core/win-job.ts +183 -0
- package/src/core/workspace.ts +184 -0
- package/src/design/audit.ts +797 -0
- package/src/design/direction.ts +190 -0
- package/src/design/rules.ts +157 -0
- package/src/eval/bench.ts +150 -0
- package/src/eval/gauntlet-runner.ts +215 -0
- package/src/eval/gauntlet-support.ts +84 -0
- package/src/eval/gauntlet-wave3.ts +269 -0
- package/src/eval/gauntlet-wave4.ts +217 -0
- package/src/eval/gauntlet.ts +253 -0
- package/src/index.ts +17 -0
- package/src/lanes/agy.ts +95 -0
- package/src/lanes/approval.ts +24 -0
- package/src/lanes/claude.ts +129 -0
- package/src/lanes/codex.ts +127 -0
- package/src/lanes/events.ts +130 -0
- package/src/lanes/job.ts +142 -0
- package/src/lanes/opencode.ts +122 -0
- package/src/lanes/process.ts +184 -0
- package/src/lanes/progress.ts +183 -0
- package/src/lanes/registry.ts +178 -0
- package/src/lanes/runner.ts +124 -0
- package/src/lanes/types.ts +112 -0
- package/src/market/catalogs/mcp-docs.json +111 -0
- package/src/market/catalogs/plugins.json +111 -0
- package/src/market/catalogs/skills.json +478 -0
- package/src/market/clone.ts +72 -0
- package/src/market/context-cost.ts +121 -0
- package/src/market/digest.ts +106 -0
- package/src/market/index.ts +22 -0
- package/src/market/install.ts +578 -0
- package/src/market/manifest.ts +187 -0
- package/src/market/prereq.ts +145 -0
- package/src/market/registry.ts +363 -0
- package/src/market/resolve.ts +111 -0
- package/src/market/types.ts +236 -0
- package/src/market/validate.ts +227 -0
- package/src/mcp/client.ts +449 -0
- package/src/mcp/config.ts +252 -0
- package/src/mcp/local-package.ts +211 -0
- package/src/mcp/market-catalog.ts +84 -0
- package/src/mcp/market-install.ts +289 -0
- package/src/mcp/market.ts +362 -0
- package/src/mcp/oauth.ts +251 -0
- package/src/mcp/prompts-resources.ts +249 -0
- package/src/mcp/shared.ts +149 -0
- package/src/mcp/status.ts +67 -0
- package/src/mcp/tools.ts +275 -0
- package/src/mcp/transport.ts +122 -0
- package/src/mcp/trust.ts +25 -0
- package/src/memory/blocks.ts +278 -0
- package/src/memory/recall.ts +355 -0
- package/src/memory/scope.ts +182 -0
- package/src/memory/store.ts +105 -0
- package/src/memory/tools.ts +99 -0
- package/src/plugins/cli.ts +119 -0
- package/src/plugins/discover.ts +108 -0
- package/src/plugins/index.ts +50 -0
- package/src/plugins/install.ts +184 -0
- package/src/plugins/load.ts +124 -0
- package/src/plugins/manifest.ts +92 -0
- package/src/plugins/state.ts +83 -0
- package/src/providers/auth.ts +408 -0
- package/src/providers/cache.ts +223 -0
- package/src/providers/catalog-local.ts +160 -0
- package/src/providers/catalog.ts +421 -0
- package/src/providers/middleware-context.ts +86 -0
- package/src/providers/middleware.ts +373 -0
- package/src/providers/model-list.ts +23 -0
- package/src/providers/models-index.json +1 -0
- package/src/providers/oauth/common.ts +105 -0
- package/src/providers/oauth/device-code.ts +107 -0
- package/src/providers/oauth/github-copilot.ts +146 -0
- package/src/providers/oauth/loopback.ts +158 -0
- package/src/providers/oauth/openai.ts +163 -0
- package/src/providers/oauth/openrouter.ts +89 -0
- package/src/providers/oauth/pkce.ts +45 -0
- package/src/providers/oauth/registry.ts +39 -0
- package/src/providers/oauth/seam.ts +89 -0
- package/src/providers/profile-glm53.ts +111 -0
- package/src/providers/profile-sonnet5-persona.ts +65 -0
- package/src/providers/profile-sonnet5-voice.ts +23 -0
- package/src/providers/profiles.ts +156 -0
- package/src/providers/provider-config.ts +311 -0
- package/src/providers/registry.ts +333 -0
- package/src/providers/responses.ts +209 -0
- package/src/providers/retry.ts +234 -0
- package/src/providers/router.ts +294 -0
- package/src/providers/sse.ts +26 -0
- package/src/providers/stream-errors.ts +117 -0
- package/src/providers/stream.ts +566 -0
- package/src/providers/thinking.ts +189 -0
- package/src/providers/wire-messages.ts +129 -0
- package/src/providers/wire-responses.ts +79 -0
- package/src/providers/wire-select.ts +53 -0
- package/src/server/http.ts +291 -0
- package/src/server/openapi.ts +246 -0
- package/src/sextant/card-hits.ts +102 -0
- package/src/sextant/card-keys.ts +55 -0
- package/src/sextant/context-source.ts +157 -0
- package/src/sextant/crew-cards.ts +350 -0
- package/src/sextant/draw-agents.ts +273 -0
- package/src/sextant/draw-code.ts +388 -0
- package/src/sextant/draw-context.ts +222 -0
- package/src/sextant/draw-frame.ts +164 -0
- package/src/sextant/draw-market.ts +573 -0
- package/src/sextant/draw-messages.ts +386 -0
- package/src/sextant/draw-pet.ts +230 -0
- package/src/sextant/draw-plan.ts +187 -0
- package/src/sextant/draw-tabs.ts +85 -0
- package/src/sextant/draw-util.ts +65 -0
- package/src/sextant/draw-wizard.ts +378 -0
- package/src/sextant/engine.ts +230 -0
- package/src/sextant/frame-hits.ts +25 -0
- package/src/sextant/frame.ts +101 -0
- package/src/sextant/git-status.ts +197 -0
- package/src/sextant/grid.ts +59 -0
- package/src/sextant/input.ts +119 -0
- package/src/sextant/keys.ts +521 -0
- package/src/sextant/layout.ts +86 -0
- package/src/sextant/local-commands.ts +169 -0
- package/src/sextant/market-source.ts +287 -0
- package/src/sextant/mentions.ts +200 -0
- package/src/sextant/message-hits.ts +26 -0
- package/src/sextant/model.ts +387 -0
- package/src/sextant/overlays.ts +456 -0
- package/src/sextant/panel-hits.ts +38 -0
- package/src/sextant/pet.ts +399 -0
- package/src/sextant/screen.ts +324 -0
- package/src/sextant/scroll-hits.ts +66 -0
- package/src/sextant/scrollbar.ts +82 -0
- package/src/sextant/sextant-bridge.ts +174 -0
- package/src/sextant/sextant-cards.ts +142 -0
- package/src/sextant/sextant-diff-base.ts +63 -0
- package/src/sextant/sextant-files.ts +154 -0
- package/src/sextant/sextant-frame-loop.ts +335 -0
- package/src/sextant/sextant-renderer.ts +574 -0
- package/src/sextant/sextant-repo.ts +140 -0
- package/src/sextant/theme.ts +66 -0
- package/src/sextant/tool-rows.ts +189 -0
- package/src/sextant/types.ts +493 -0
- package/src/skills/index.ts +387 -0
- package/src/skills/pack.ts +220 -0
- package/src/skills/spec.ts +162 -0
- package/src/skills/tools.ts +69 -0
- package/src/skills/versioned.ts +227 -0
- package/src/telemetry/otel-export.ts +122 -0
- package/src/telemetry/otel-lanes.ts +89 -0
- package/src/telemetry/otel-logs.ts +131 -0
- package/src/telemetry/otel-metrics.ts +136 -0
- package/src/telemetry/otel.ts +397 -0
- package/src/telemetry/otlp.ts +76 -0
- package/src/tools/ask-user.ts +156 -0
- package/src/tools/bash-bg.ts +94 -0
- package/src/tools/bash-jobs.ts +237 -0
- package/src/tools/design.ts +151 -0
- package/src/tools/evalcell.ts +338 -0
- package/src/tools/html-text.ts +139 -0
- package/src/tools/provider.ts +149 -0
- package/src/tools/task.ts +250 -0
- package/src/tools/todo.ts +320 -0
- package/src/tools/webfetch.ts +332 -0
- package/src/tools/websearch.ts +359 -0
- package/src/tui/agents-cmd.ts +41 -0
- package/src/tui/app.ts +749 -0
- package/src/tui/attach.ts +127 -0
- package/src/tui/boot-notes.ts +41 -0
- package/src/tui/builtin-prompts.ts +59 -0
- package/src/tui/checkpoints-cmd.ts +70 -0
- package/src/tui/clipboard-image.ts +81 -0
- package/src/tui/clipboard.ts +78 -0
- package/src/tui/commands.ts +283 -0
- package/src/tui/config-view.ts +53 -0
- package/src/tui/context-cmds.ts +282 -0
- package/src/tui/cost.ts +108 -0
- package/src/tui/crash-guard.ts +173 -0
- package/src/tui/focus-terminal.ts +34 -0
- package/src/tui/git-cmds.ts +273 -0
- package/src/tui/git-plain.ts +58 -0
- package/src/tui/info-cmd.ts +150 -0
- package/src/tui/input-plain.ts +76 -0
- package/src/tui/mcp-cmd.ts +128 -0
- package/src/tui/memory-note.ts +77 -0
- package/src/tui/modes-cmd.ts +45 -0
- package/src/tui/notify-seq.ts +100 -0
- package/src/tui/notify.ts +318 -0
- package/src/tui/overlays.ts +97 -0
- package/src/tui/pi-renderer.ts +428 -0
- package/src/tui/providers-cmd.ts +377 -0
- package/src/tui/reasoning-view.ts +56 -0
- package/src/tui/renderer.ts +128 -0
- package/src/tui/replay-marker.ts +29 -0
- package/src/tui/session-cmd.ts +148 -0
- package/src/tui/session-manage.ts +95 -0
- package/src/tui/sextant-attach.ts +102 -0
- package/src/tui/sextant-io.ts +202 -0
- package/src/tui/sextant-smoke.ts +110 -0
- package/src/tui/shell-cmd.ts +158 -0
- package/src/tui/smoke.ts +72 -0
- package/src/tui/staged-terminal.ts +50 -0
- package/src/tui/startup.ts +12 -0
- package/src/tui/theme.ts +59 -0
- package/src/tui/todo-label.ts +7 -0
- package/src/tui/trust-card.ts +107 -0
- package/src/tui/tui-commands.ts +87 -0
- package/tsconfig.json +30 -0
- package/vendor/pi-tui/LICENSE +21 -0
- package/vendor/pi-tui/PATCHES.md +12 -0
- package/vendor/pi-tui/PROVENANCE.md +12 -0
- package/vendor/pi-tui/README.upstream.md +854 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
- package/vendor/pi-tui/src/alt-screen-search.ts +158 -0
- package/vendor/pi-tui/src/autocomplete.ts +827 -0
- package/vendor/pi-tui/src/components/alt-screen-flash.ts +52 -0
- package/vendor/pi-tui/src/components/box.ts +138 -0
- package/vendor/pi-tui/src/components/cancellable-loader.ts +41 -0
- package/vendor/pi-tui/src/components/editor.ts +2364 -0
- package/vendor/pi-tui/src/components/h-stack.ts +45 -0
- package/vendor/pi-tui/src/components/image.ts +128 -0
- package/vendor/pi-tui/src/components/input.ts +448 -0
- package/vendor/pi-tui/src/components/loader.ts +93 -0
- package/vendor/pi-tui/src/components/markdown.ts +1016 -0
- package/vendor/pi-tui/src/components/scroll-view.ts +217 -0
- package/vendor/pi-tui/src/components/select-list.ts +230 -0
- package/vendor/pi-tui/src/components/settings-list.ts +277 -0
- package/vendor/pi-tui/src/components/spacer.ts +29 -0
- package/vendor/pi-tui/src/components/stack.ts +155 -0
- package/vendor/pi-tui/src/components/text.ts +108 -0
- package/vendor/pi-tui/src/components/truncated-text.ts +66 -0
- package/vendor/pi-tui/src/components/v-stack.ts +34 -0
- package/vendor/pi-tui/src/editor-component.ts +75 -0
- package/vendor/pi-tui/src/fuzzy.ts +138 -0
- package/vendor/pi-tui/src/index.ts +149 -0
- package/vendor/pi-tui/src/keybindings.ts +321 -0
- package/vendor/pi-tui/src/keys.ts +1402 -0
- package/vendor/pi-tui/src/kill-ring.ts +47 -0
- package/vendor/pi-tui/src/latex.ts +1381 -0
- package/vendor/pi-tui/src/layout-node.ts +52 -0
- package/vendor/pi-tui/src/layout.ts +411 -0
- package/vendor/pi-tui/src/native-modifiers.ts +60 -0
- package/vendor/pi-tui/src/native-module-path.ts +32 -0
- package/vendor/pi-tui/src/stdin-buffer.ts +445 -0
- package/vendor/pi-tui/src/terminal-colors.ts +74 -0
- package/vendor/pi-tui/src/terminal-image.ts +701 -0
- package/vendor/pi-tui/src/terminal.ts +554 -0
- package/vendor/pi-tui/src/tui-alt-screen.ts +1379 -0
- package/vendor/pi-tui/src/tui-main-screen.ts +655 -0
- package/vendor/pi-tui/src/tui.ts +1264 -0
- package/vendor/pi-tui/src/undo-stack.ts +29 -0
- package/vendor/pi-tui/src/utils.ts +1327 -0
- package/vendor/pi-tui/src/word-navigation.ts +118 -0
- package/vendor/pi-tui/test/test-themes.ts +39 -0
- package/vendor/pi-tui/test/virtual-terminal.ts +219 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/** From a market entry to a line in mcp.json — in three visible steps, so no install is silent:
|
|
2
|
+
* planInstall picks the launch form and lists what must be asked; describePlan renders EXACTLY what
|
|
3
|
+
* will be written (command + args or URL, source, publisher, version, the env NAMES, the file) for the
|
|
4
|
+
* human to read before answering; fillPlan + writeServer put it on disk. Secrets: asked by name through
|
|
5
|
+
* the caller's masked prompt, written as values only into the USER file (~/.rovecode/mcp.json, 0o600
|
|
6
|
+
* where the OS honours it) — a PROJECT file gets `${NAME}` and the loader fills it from the environment
|
|
7
|
+
* at launch (config.ts expandVars), so a token never lands in a repo. Never on the command line: a
|
|
8
|
+
* stdio server receives them through `env`, docker through `-e NAME`. */
|
|
9
|
+
|
|
10
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
import { isRecord, mcpConfigFiles, normalizeEntry, parseConfigFile, placeholderHoles, type McpServerConfig } from "./config.ts";
|
|
13
|
+
import type { EnvSpec, MarketEntry, MarketInstall } from "./market.ts";
|
|
14
|
+
import { installLabel } from "./market.ts";
|
|
15
|
+
import { mcpTrustStatus, trustMcpFile } from "./trust.ts";
|
|
16
|
+
import { localPlanLines, localPrefix, npxPackage, plannedLaunchLabel, type NpxPackage } from "./local-package.ts";
|
|
17
|
+
|
|
18
|
+
export type McpScope = "user" | "project";
|
|
19
|
+
const SERVER_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
20
|
+
const PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_-]*)\}/g;
|
|
21
|
+
|
|
22
|
+
export interface InstallPlan {
|
|
23
|
+
entry: MarketEntry;
|
|
24
|
+
install: MarketInstall;
|
|
25
|
+
scope: McpScope;
|
|
26
|
+
/** the mcp.json this lands in */
|
|
27
|
+
file: string;
|
|
28
|
+
/** the server's name in that file (= the tool prefix the model sees) */
|
|
29
|
+
name: string;
|
|
30
|
+
/** what has to be asked, in order; `secret` ones go through the masked prompt */
|
|
31
|
+
asks: EnvSpec[];
|
|
32
|
+
/** required arguments nobody can fill for the human (a directory, a database URL) */
|
|
33
|
+
pending: string[];
|
|
34
|
+
/** where a header placeholder maps back: variable name → header it belongs to */
|
|
35
|
+
headerVars: Record<string, string>;
|
|
36
|
+
/** install ONCE (mcp/local-package.ts): the npx package this line would run, and the shared prefix npm
|
|
37
|
+
* puts it in. Set only when the human asked for it — the default plan is today's npx line. The launch
|
|
38
|
+
* line written to the file is then `node <bin>`, known after npm has run, so fillPlan takes it as an
|
|
39
|
+
* argument instead of reading it from `install`. */
|
|
40
|
+
local?: { pkg: NpxPackage; prefix: string };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** the server name a registry key gets in mcp.json: its last path segment, lowercased, unsafe runs → "-" */
|
|
44
|
+
export function defaultServerName(key: string): string {
|
|
45
|
+
const tail = key.split("/").pop() ?? key;
|
|
46
|
+
const name = tail.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, 64);
|
|
47
|
+
return name.length > 0 ? name : "server";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** env-variable-safe spelling of a header name: `X-Api-Key` → `X_API_KEY` */
|
|
51
|
+
function headerVar(name: string): string { return name.toUpperCase().replace(/[^A-Z0-9_]/g, "_").replace(/^[0-9]/, "_$&"); }
|
|
52
|
+
|
|
53
|
+
export interface PlanOptions {
|
|
54
|
+
scope: McpScope; cwd: string; home: string;
|
|
55
|
+
/** which of entry.installs (default: the first) */
|
|
56
|
+
pick?: number;
|
|
57
|
+
/** override the mcp.json name */
|
|
58
|
+
name?: string;
|
|
59
|
+
/** install the npx package once and launch it with node (the human said yes to the offer). An error when
|
|
60
|
+
* the chosen form is not a plain `npx <package>` line — silently falling back to npx would write a plan
|
|
61
|
+
* the human did not approve. */
|
|
62
|
+
local?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function planInstall(entry: MarketEntry, opts: PlanOptions): InstallPlan | { error: string } {
|
|
66
|
+
if (entry.installs.length === 0) return { error: `${entry.key} lists nothing rovecode can launch or connect to (no stdio package, no streamable-http remote)` };
|
|
67
|
+
const ix = opts.pick ?? 0;
|
|
68
|
+
const install = entry.installs[ix];
|
|
69
|
+
if (!install) return { error: `${entry.key} has ${entry.installs.length} install form(s); --pick ${ix} is out of range` };
|
|
70
|
+
const name = opts.name ?? (entry.source === "curated" ? entry.key : defaultServerName(entry.key));
|
|
71
|
+
if (!SERVER_NAME.test(name)) return { error: `"${name}" is not a usable server name (lowercase letters, digits, . _ -)` };
|
|
72
|
+
const files = mcpConfigFiles(opts.cwd, opts.home);
|
|
73
|
+
const file = opts.scope === "project" ? files.project : files.user!;
|
|
74
|
+
let local: InstallPlan["local"];
|
|
75
|
+
if (opts.local === true) {
|
|
76
|
+
// The launch line install-once writes is THIS machine's absolute path under its ROVECODE_HOME. A project
|
|
77
|
+
// file is shared with everyone who clones the repo, so that line would be a server none of them can start
|
|
78
|
+
// — refused with the way out, rather than written and discovered on someone else's machine.
|
|
79
|
+
if (opts.scope === "project") return { error: `${entry.key}: install-once writes this machine's absolute path (node <home>/mcp/…), and a project file is shared with every clone — install it in user scope (drop --project) or keep the npx line` };
|
|
80
|
+
const pkg = npxPackage(install);
|
|
81
|
+
if (pkg === undefined) return { error: `${entry.key} cannot be installed once: its launch line is not a plain \`npx <package>\` (${installLabel(install)}) — drop --local to write it as it is` };
|
|
82
|
+
local = { pkg, prefix: localPrefix(opts.home) };
|
|
83
|
+
}
|
|
84
|
+
const asks: EnvSpec[] = [], headerVars: Record<string, string> = {};
|
|
85
|
+
if (install.kind === "stdio") {
|
|
86
|
+
for (const e of install.env) {
|
|
87
|
+
// a filled default is not a question; an optional plain value without one is left out and named in the preview
|
|
88
|
+
if (e.default !== undefined) continue;
|
|
89
|
+
if (e.secret || e.required) asks.push(e);
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
for (const h of install.headers) {
|
|
93
|
+
const vars = [...(h.template ?? "").matchAll(PLACEHOLDER)].map((m) => m[1]!);
|
|
94
|
+
if (h.template !== undefined && vars.length === 0) continue; // a literal header, nothing to ask
|
|
95
|
+
for (const v of vars.length ? vars : [headerVar(h.name)]) {
|
|
96
|
+
headerVars[v] = h.name;
|
|
97
|
+
const spec: EnvSpec = { name: v, required: h.required, secret: h.secret };
|
|
98
|
+
if (h.description) spec.description = h.description;
|
|
99
|
+
asks.push(spec);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { entry, install, scope: opts.scope, file, name, asks, pending: install.kind === "stdio" ? install.pending : [], headerVars, ...(local ? { local } : {}) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One `pending` fragment → the argv words it contributes. A fragment is literal words followed by one
|
|
107
|
+
* `<hole>` — "--root <path>" or "<directory the server may touch>" — so the flag survives whether or not
|
|
108
|
+
* anybody answered, and the hole is either the answer or the placeholder itself. Written into the file
|
|
109
|
+
* unanswered it is not a silent failure: mcp/config.ts refuses to launch a server that still carries one
|
|
110
|
+
* and names the line. Split on the hole rather than on whitespace, because a hole is usually a sentence. */
|
|
111
|
+
export function pendingWords(fragment: string, answer?: string): string[] {
|
|
112
|
+
const m = /^(.*?)\s*(<[^<>]*>)\s*$/.exec(fragment);
|
|
113
|
+
const filled = answer !== undefined && answer.length > 0;
|
|
114
|
+
if (!m) return [filled ? answer : fragment]; // no hole at all: an older catalog's bare hint
|
|
115
|
+
const lead = m[1]!.length > 0 ? m[1]!.split(/\s+/) : [];
|
|
116
|
+
return [...lead, filled ? answer : m[2]!];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** the raw mcp.json entry, with answers in place. Secrets: a value in the USER file, `${NAME}` in a
|
|
120
|
+
* PROJECT file (and `${NAME}` whenever the answer is empty, so a later `export NAME=…` completes it).
|
|
121
|
+
* `launch` replaces the entry's own command + args — the install-once path passes `node <bin>` here once
|
|
122
|
+
* npm has put the bin on disk (local-package.ts localLaunch); a plan without `local` never sets it. */
|
|
123
|
+
export function fillPlan(plan: InstallPlan, answers: Record<string, string>, launch?: { command: string; args: string[] }): Record<string, unknown> {
|
|
124
|
+
const ref = (spec: EnvSpec): string | undefined => {
|
|
125
|
+
const v = answers[spec.name];
|
|
126
|
+
if (v !== undefined && v.length > 0 && !(spec.secret && plan.scope === "project")) return v;
|
|
127
|
+
if (v === undefined || v.length === 0) { if (!spec.required && !(v !== undefined && spec.secret)) return undefined; }
|
|
128
|
+
return `\${${spec.name}}`;
|
|
129
|
+
};
|
|
130
|
+
const { install } = plan;
|
|
131
|
+
if (install.kind === "stdio") {
|
|
132
|
+
const env: Record<string, string> = {};
|
|
133
|
+
for (const e of install.env) {
|
|
134
|
+
if (e.default !== undefined) { env[e.name] = e.default; continue; }
|
|
135
|
+
const v = ref(e);
|
|
136
|
+
if (v !== undefined) env[e.name] = v;
|
|
137
|
+
}
|
|
138
|
+
// `pending` is the entry's required positional arguments — the filesystem server's directory, say.
|
|
139
|
+
// They go into args either as the human's answer or, when nobody could be asked (the TUI has no
|
|
140
|
+
// prompt, `--yes` did not stop), as the placeholder itself. Dropping them, which is what this did
|
|
141
|
+
// first, wrote a server that could never start and a note pointing at a line that was not there.
|
|
142
|
+
const positional = install.pending.flatMap((p) => pendingWords(p, answers[p]));
|
|
143
|
+
const line = launch ?? { command: install.command, args: install.args };
|
|
144
|
+
return { command: line.command, args: [...line.args, ...positional], ...(Object.keys(env).length ? { env } : {}) };
|
|
145
|
+
}
|
|
146
|
+
const headers: Record<string, string> = {};
|
|
147
|
+
for (const h of install.headers) {
|
|
148
|
+
if (h.template !== undefined && !PLACEHOLDER.test(h.template)) { headers[h.name] = h.template; PLACEHOLDER.lastIndex = 0; continue; }
|
|
149
|
+
PLACEHOLDER.lastIndex = 0;
|
|
150
|
+
const vars = Object.entries(plan.headerVars).filter(([, hn]) => hn === h.name).map(([v]) => v);
|
|
151
|
+
let value = h.template ?? `{${vars[0] ?? headerVar(h.name)}}`, complete = true;
|
|
152
|
+
for (const v of vars) {
|
|
153
|
+
const spec = plan.asks.find((a) => a.name === v)!;
|
|
154
|
+
const r = ref(spec);
|
|
155
|
+
if (r === undefined) { complete = false; break; }
|
|
156
|
+
value = value.split(`{${v}}`).join(r);
|
|
157
|
+
}
|
|
158
|
+
if (complete) headers[h.name] = value;
|
|
159
|
+
}
|
|
160
|
+
return { type: "http", url: install.url, ...(Object.keys(headers).length ? { headers } : {}) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** the asks whose `${NAME}` the filled entry actually carries — what the closing note tells the human to set.
|
|
164
|
+
* An optional ask nobody answered is left out of the entry (fillPlan), so naming it would send them to set a
|
|
165
|
+
* variable nothing reads. */
|
|
166
|
+
export function namesWritten(plan: InstallPlan, raw: Record<string, unknown>): string[] {
|
|
167
|
+
const text = JSON.stringify(raw);
|
|
168
|
+
return plan.asks.filter((a) => text.includes(`\${${a.name}}`)).map((a) => a.name);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** the confirmation text — everything the human must see before anything is written. `asking` says how
|
|
172
|
+
* the plan's questions get answered: "prompt" (the CLI asks, secrets masked) or "env" (the TUI has no
|
|
173
|
+
* masked input, so every asked value is written as `${NAME}` and read from the environment at launch) */
|
|
174
|
+
export function describePlan(plan: InstallPlan, asking: "prompt" | "env" = "prompt"): string[] {
|
|
175
|
+
const { entry, install } = plan;
|
|
176
|
+
const asked = (secret: boolean): string => asking === "env" ? "(${NAME} — from your environment)" : secret ? "(asked, masked, never shown)" : "(asked)";
|
|
177
|
+
const lines = [
|
|
178
|
+
`${entry.title ?? entry.key}${entry.version ? ` ${entry.version}` : ""}${entry.status ? ` [${entry.status}]` : ""}`,
|
|
179
|
+
` source ${entry.source === "curated" ? "curated list (built into rovecode)" : "MCP registry (registry.modelcontextprotocol.io)"}`,
|
|
180
|
+
` publisher ${entry.publisher ?? "unknown"}`,
|
|
181
|
+
];
|
|
182
|
+
if (entry.repository) lines.push(` repo ${entry.repository}`);
|
|
183
|
+
// install-once: the launch line the file will hold is `node <bin>`, and the rows under it say — in so many
|
|
184
|
+
// words — that a package manager runs and code lands on this machine. That is the plan being approved.
|
|
185
|
+
if (plan.local) lines.push(` runs ${plannedLaunchLabel(plan.local.pkg, plan.local.prefix)}`, ...localPlanLines(plan.local.pkg, plan.local.prefix));
|
|
186
|
+
else lines.push(install.kind === "stdio" ? ` runs ${installLabel(install)}` : ` connects ${install.url}`);
|
|
187
|
+
const envNames = install.kind === "stdio" ? install.env : [];
|
|
188
|
+
for (const e of envNames) {
|
|
189
|
+
const how = e.default !== undefined ? `= ${e.default}` : plan.asks.includes(e) ? asked(e.secret).replace("NAME", e.name) : "(optional, left unset)";
|
|
190
|
+
lines.push(` env ${e.name} ${how}${e.required ? "" : " optional"}`);
|
|
191
|
+
}
|
|
192
|
+
if (install.kind === "http") for (const h of install.headers) {
|
|
193
|
+
const vars = Object.entries(plan.headerVars).filter(([, hn]) => hn === h.name).map(([v]) => v);
|
|
194
|
+
lines.push(` header ${h.name}: ${vars.length ? `${h.template ?? `{${vars[0]}}`} ← ${vars.join(", ")} ${asked(h.secret).replace("NAME", vars[0]!)}` : h.template ?? ""}${h.required ? "" : " optional"}`);
|
|
195
|
+
}
|
|
196
|
+
for (const p of plan.pending) lines.push(` needs ${p} — ${asking === "env" ? "written as the placeholder; fill it in and the server connects" : "asked here; unanswered it is written as the placeholder"}`);
|
|
197
|
+
lines.push(` writes ${plan.file} as "${plan.name}"${asking === "prompt" && plan.scope === "project" && plan.asks.some((a) => a.secret) ? " (secrets stay out of this file: ${NAME} is read from your environment)" : ""}`);
|
|
198
|
+
return lines;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ------------------------------------------------------------------ the file
|
|
202
|
+
|
|
203
|
+
interface FileShape { json: Record<string, unknown>; servers: Record<string, unknown> }
|
|
204
|
+
function readShape(file: string): FileShape {
|
|
205
|
+
if (!existsSync(file)) return { json: {}, servers: {} };
|
|
206
|
+
const json: unknown = JSON.parse(readFileSync(file, "utf8")); // a broken file is the human's to fix; we do not overwrite it
|
|
207
|
+
if (!isRecord(json)) throw new Error(`${file}: root is not an object`);
|
|
208
|
+
const servers = isRecord(json.mcpServers) ? json.mcpServers : {};
|
|
209
|
+
return { json, servers };
|
|
210
|
+
}
|
|
211
|
+
function writeShape(file: string, shape: FileShape, secret: boolean): void {
|
|
212
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
213
|
+
writeFileSync(file, JSON.stringify({ ...shape.json, mcpServers: shape.servers }, null, 2) + "\n", { mode: 0o600 });
|
|
214
|
+
if (secret && process.platform !== "win32") chmodSync(file, 0o600);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** add or replace one server; the entry is normalized first (every `${NAME}` counted as set) so the
|
|
218
|
+
* runtime is guaranteed to accept what was written. Returns the loader's view of it. `trustHome`: the
|
|
219
|
+
* human just approved this exact content on a card/prompt, so a PROJECT file is recorded as trusted in
|
|
220
|
+
* that home's store right after the write (mcp/trust.ts) — the "trust my own" half of the gate. */
|
|
221
|
+
export function writeServer(file: string, name: string, raw: Record<string, unknown>, opts: { replace?: boolean; trustHome?: string } = {}): McpServerConfig & { trusted?: boolean } {
|
|
222
|
+
const warnings: string[] = [];
|
|
223
|
+
// allowPlaceholders: this validates the SHAPE of an entry the human just approved, and an unanswered
|
|
224
|
+
// `pending` hole is part of that entry by design — writing it is how the human gets a line to edit. The
|
|
225
|
+
// loader (parseConfigFile) applies the same check without the exemption, so the server is named and
|
|
226
|
+
// skipped until it is filled rather than launched into an argument error.
|
|
227
|
+
const cfg = normalizeEntry(name, raw, file, warnings, new Proxy({}, { get: () => "set" }) as Record<string, string>, { allowPlaceholders: true });
|
|
228
|
+
if (!cfg) throw new Error(warnings.join("; ") || `${name}: not a valid server entry`);
|
|
229
|
+
const shape = readShape(file);
|
|
230
|
+
if (shape.servers[name] !== undefined && !opts.replace) throw new Error(`${file} already has a server named "${name}" — remove it first, or add --force`);
|
|
231
|
+
// the human approved THIS entry. The rest of the file is approved only if it already was (or there was
|
|
232
|
+
// no file): adding to a cloned, unapproved file must not quietly bless the strangers already in it.
|
|
233
|
+
const mayTrust = opts.trustHome !== undefined && (!existsSync(file) || Object.keys(shape.servers).filter((k) => k !== name).length === 0 || mcpTrustStatus(opts.trustHome, file) === "trusted");
|
|
234
|
+
shape.servers[name] = raw;
|
|
235
|
+
const secret = JSON.stringify(raw).includes("env") || JSON.stringify(raw).includes("headers");
|
|
236
|
+
writeShape(file, shape, secret);
|
|
237
|
+
if (opts.trustHome === undefined) return cfg;
|
|
238
|
+
if (mayTrust) trustMcpFile(opts.trustHome, file);
|
|
239
|
+
return { ...cfg, trusted: mayTrust };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** delete one server; with `trustHome` the file's new bytes stay approved when the file was approved
|
|
243
|
+
* before (a removal is the human's edit too) — an unapproved file stays unapproved */
|
|
244
|
+
export function removeServer(file: string, name: string, opts: { trustHome?: string } = {}): boolean {
|
|
245
|
+
if (!existsSync(file)) return false;
|
|
246
|
+
const wasTrusted = opts.trustHome !== undefined && mcpTrustStatus(opts.trustHome, file) === "trusted";
|
|
247
|
+
const shape = readShape(file);
|
|
248
|
+
if (shape.servers[name] === undefined) return false;
|
|
249
|
+
delete shape.servers[name];
|
|
250
|
+
writeShape(file, shape, false);
|
|
251
|
+
if (wasTrusted) trustMcpFile(opts.trustHome!, file);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** one configured server on one line, NAMES of env/headers only — never their values (they may be keys) */
|
|
256
|
+
export function serverLine(s: McpServerConfig): string {
|
|
257
|
+
const what = s.transport === "stdio" ? [s.command, ...(s.args ?? [])].join(" ") : s.url ?? "";
|
|
258
|
+
const env = s.env && Object.keys(s.env).length ? ` env ${Object.keys(s.env).join(", ")}` : "";
|
|
259
|
+
const headers = s.headers && Object.keys(s.headers).length ? ` headers ${Object.keys(s.headers).join(", ")}` : "";
|
|
260
|
+
// an entry the loader will skip until a hand edits it says so on its own line — it is configured, not launchable
|
|
261
|
+
const holes = placeholderHoles(s);
|
|
262
|
+
const fill = holes.length ? ` (fill in ${holes.join(", ")})` : "";
|
|
263
|
+
return `${s.name.padEnd(24)} ${s.transport.padEnd(5)} ${what}${env}${headers}${fill}${s.enabled === false ? " (disabled)" : ""}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** every configured server with the scope it comes from, most local last — what the files SAY, which is
|
|
267
|
+
* more than what the runtime would load: an entry still carrying a `<…>` placeholder is kept (serverLine
|
|
268
|
+
* marks it), because the person who was told "fill in the directory after the install" and typed the
|
|
269
|
+
* command the docs point at must not be told they have nothing. The loader's own view (skipping such an
|
|
270
|
+
* entry with a warning) is loadMcpConfig. `warnings` collects what parsing had to say — an unreadable
|
|
271
|
+
* file, invalid JSON, a nameless entry — for the caller to show; it used to be discarded here. */
|
|
272
|
+
export function configuredServers(cwd: string, home: string, warnings: string[] = []): { scope: McpScope | "harvest"; file: string; server: McpServerConfig }[] {
|
|
273
|
+
const files = mcpConfigFiles(cwd, home);
|
|
274
|
+
const all = new Proxy({}, { get: () => "set" }) as Record<string, string>; // list what is configured, not what is launchable right now
|
|
275
|
+
const out: { scope: McpScope | "harvest"; file: string; server: McpServerConfig }[] = [];
|
|
276
|
+
for (const [scope, file] of [["user", files.user!], ["harvest", files.harvest], ["project", files.project]] as const) {
|
|
277
|
+
for (const server of parseConfigFile(file, warnings, all, { allowPlaceholders: true })) out.push({ scope, file, server });
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The `pending` fragments of a plan whose placeholder is STILL in the written entry — nobody answered for
|
|
283
|
+
* them at the prompt (no terminal, or an empty answer). What the closing line of an install must name, and
|
|
284
|
+
* only that: a question answered at the prompt must not leave the install telling you to go and edit a line
|
|
285
|
+
* that now holds your answer. */
|
|
286
|
+
export function unfilledPending(plan: InstallPlan, raw: Record<string, unknown>): string[] {
|
|
287
|
+
const args = Array.isArray(raw.args) ? (raw.args as unknown[]) : [];
|
|
288
|
+
return plan.pending.filter((p) => pendingWords(p).some((w) => /^<.*>$/.test(w) && args.includes(w)));
|
|
289
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/** The MCP market: where `rovecode mcp search|info|add` and the TUI's /mcp find servers to install.
|
|
2
|
+
* Two sources, merged: the built-in curated list (market-catalog.ts, offline, hand-checked) and the
|
|
3
|
+
* official registry at registry.modelcontextprotocol.io. Registry responses are UNTRUSTED DATA: every
|
|
4
|
+
* field is re-typed here, every string is capped, every list is capped, and nothing from a response is
|
|
5
|
+
* ever executed, evaluated or written to a config file without the human seeing it first
|
|
6
|
+
* (market-install.ts owns the preview + write). Responses are cached under ~/.rovecode/cache/
|
|
7
|
+
* mcp-market.json with a TTL so a flaky network or an offline train still leaves /mcp usable.
|
|
8
|
+
*
|
|
9
|
+
* Registry API, verified live on 2026-09-04 (the shape is NOT assumed from docs):
|
|
10
|
+
* GET /v0/servers?search=<substring of name>&version=latest&limit=<=100[&cursor=]
|
|
11
|
+
* → { servers: [{ server: ServerJson, _meta: { "io.modelcontextprotocol.registry/official": { status, isLatest, … } } }],
|
|
12
|
+
* metadata: { nextCursor?, count } }
|
|
13
|
+
* GET /v0/servers/<url-encoded name>/versions/latest → { server: ServerJson, _meta }
|
|
14
|
+
* ServerJson: { name, description, title?, version, repository?: { url, source, subfolder? }, websiteUrl?,
|
|
15
|
+
* packages?: [{ registryType: npm|pypi|oci|nuget|mcpb, identifier, version?, runtimeHint?, transport: { type },
|
|
16
|
+
* runtimeArguments?: Arg[], packageArguments?: Arg[], environmentVariables?: Var[] }],
|
|
17
|
+
* remotes?: [{ type: streamable-http|sse, url, headers?: Var[] }] }
|
|
18
|
+
* Arg: { type: positional|named, name?, value?, default?, isRequired?, valueHint?, description? }
|
|
19
|
+
* Var: { name, value?, default?, isRequired?, isSecret?, description? } — `value` may hold "{placeholder}"s. */
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { rovecodeHome } from "../providers/auth.ts";
|
|
24
|
+
import { isRecord, message } from "./config.ts";
|
|
25
|
+
import { CURATED } from "./market-catalog.ts";
|
|
26
|
+
|
|
27
|
+
export const REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0";
|
|
28
|
+
/** cache TTL: a day — registry entries change rarely and the human sees the version before installing */
|
|
29
|
+
export const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
const CACHE_QUERIES = 40;
|
|
31
|
+
/** the registry's detail route has been seen taking >8s from here; a CLI can wait this long once, the cache does the rest */
|
|
32
|
+
const FETCH_TIMEOUT_MS = 20_000;
|
|
33
|
+
/** hard ceilings on what a response may contribute — anything past them is dropped, never trusted */
|
|
34
|
+
export const LIMITS = { body: 2 * 1024 * 1024, servers: 100, str: 300, desc: 500, list: 32, packages: 8 } as const;
|
|
35
|
+
|
|
36
|
+
/** one environment variable (or header placeholder) a server wants; `secret` ones are asked masked */
|
|
37
|
+
export interface EnvSpec { name: string; description?: string; required: boolean; secret: boolean; default?: string }
|
|
38
|
+
/** one HTTP header a remote wants; `{name}` placeholders in `template` are asked as EnvSpecs */
|
|
39
|
+
export interface HeaderSpec { name: string; template?: string; description?: string; required: boolean; secret: boolean }
|
|
40
|
+
export type MarketInstall =
|
|
41
|
+
| { kind: "stdio"; runtime: "npx" | "uvx" | "docker" | "other"; command: string; args: string[]; env: EnvSpec[];
|
|
42
|
+
/** required arguments the registry could not fill (a path, a token on the command line): named for the human */
|
|
43
|
+
pending: string[] }
|
|
44
|
+
| { kind: "http"; url: string; headers: HeaderSpec[] };
|
|
45
|
+
|
|
46
|
+
export interface MarketEntry {
|
|
47
|
+
/** what `mcp add <key>` takes: the curated short name, or the registry's reverse-DNS name */
|
|
48
|
+
key: string;
|
|
49
|
+
title?: string;
|
|
50
|
+
description: string;
|
|
51
|
+
version?: string;
|
|
52
|
+
source: "curated" | "registry";
|
|
53
|
+
/** who publishes it — a curated entry says so, a registry entry derives it from its namespace */
|
|
54
|
+
publisher?: string;
|
|
55
|
+
/** the registry's listing status when it is anything but active (e.g. "deprecated") */
|
|
56
|
+
status?: string;
|
|
57
|
+
repository?: string;
|
|
58
|
+
homepage?: string;
|
|
59
|
+
installs: MarketInstall[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface MarketDeps {
|
|
63
|
+
fetch?: typeof fetch;
|
|
64
|
+
home?: string;
|
|
65
|
+
now?: () => number;
|
|
66
|
+
catalog?: readonly MarketEntry[];
|
|
67
|
+
registryUrl?: string;
|
|
68
|
+
/** skip the network entirely (curated + cache only) */
|
|
69
|
+
offline?: boolean;
|
|
70
|
+
}
|
|
71
|
+
export interface MarketResult { entries: MarketEntry[]; notes: string[]; fromCache: boolean }
|
|
72
|
+
|
|
73
|
+
// ------------------------------------------------------------------ validated re-typing of registry JSON
|
|
74
|
+
|
|
75
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
76
|
+
const HEADER_NAME = /^[A-Za-z0-9-]{1,64}$/;
|
|
77
|
+
const COMMAND = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
78
|
+
const PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_-]*)\}/g;
|
|
79
|
+
|
|
80
|
+
function str(v: unknown, max: number = LIMITS.str): string | undefined {
|
|
81
|
+
if (typeof v !== "string" || v.length === 0) return undefined;
|
|
82
|
+
const flat = v.replace(/[\u0000-\u001f\u007f]/g, " ");
|
|
83
|
+
return flat.length > max ? flat.slice(0, max - 1) + "…" : flat;
|
|
84
|
+
}
|
|
85
|
+
function list(v: unknown, max: number = LIMITS.list): unknown[] { return Array.isArray(v) ? v.slice(0, max) : []; }
|
|
86
|
+
function httpUrl(v: unknown): string | undefined {
|
|
87
|
+
const s = str(v, 2000);
|
|
88
|
+
if (s === undefined) return undefined;
|
|
89
|
+
try { const u = new URL(s); return u.protocol === "http:" || u.protocol === "https:" ? s : undefined; } catch { return undefined; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function envSpec(raw: unknown): EnvSpec | undefined {
|
|
93
|
+
if (!isRecord(raw)) return undefined;
|
|
94
|
+
const name = str(raw.name, 128);
|
|
95
|
+
if (name === undefined || !ENV_NAME.test(name)) return undefined;
|
|
96
|
+
const def = str(raw.value) ?? str(raw.default);
|
|
97
|
+
const out: EnvSpec = { name, required: raw.isRequired === true, secret: raw.isSecret === true };
|
|
98
|
+
const d = str(raw.description, LIMITS.desc);
|
|
99
|
+
if (d !== undefined) out.description = d;
|
|
100
|
+
if (def !== undefined && !PLACEHOLDER.test(def)) out.default = def; // a "{placeholder}" is a question, not a value
|
|
101
|
+
PLACEHOLDER.lastIndex = 0;
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function headerSpec(raw: unknown): HeaderSpec | undefined {
|
|
105
|
+
if (!isRecord(raw)) return undefined;
|
|
106
|
+
const name = str(raw.name, 64);
|
|
107
|
+
if (name === undefined || !HEADER_NAME.test(name)) return undefined;
|
|
108
|
+
const out: HeaderSpec = { name, required: raw.isRequired === true, secret: raw.isSecret === true };
|
|
109
|
+
const t = str(raw.value) ?? str(raw.default);
|
|
110
|
+
if (t !== undefined) out.template = t;
|
|
111
|
+
const d = str(raw.description, LIMITS.desc);
|
|
112
|
+
if (d !== undefined) out.description = d;
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** the angle brackets are what marks a hole, here and in mcp/config.ts — a value already wearing them
|
|
117
|
+
* keeps the wording the registry chose */
|
|
118
|
+
function hole(hint: string): string { return hint.startsWith("<") && hint.endsWith(">") ? hint : `<${hint}>`; }
|
|
119
|
+
|
|
120
|
+
/** registry Arg[] → argv words; what has no value but is required goes to `pending` for the human */
|
|
121
|
+
function argWords(raws: unknown[], pending: string[]): string[] {
|
|
122
|
+
const words: string[] = [];
|
|
123
|
+
for (const raw of raws) {
|
|
124
|
+
if (!isRecord(raw)) continue;
|
|
125
|
+
const value = str(raw.value) ?? str(raw.default);
|
|
126
|
+
const name = str(raw.name, 128);
|
|
127
|
+
const hint = str(raw.valueHint, 64) ?? name ?? "<argument>";
|
|
128
|
+
if (raw.type === "named") {
|
|
129
|
+
if (name === undefined) continue;
|
|
130
|
+
if (value !== undefined) words.push(name, value);
|
|
131
|
+
// a required flag with no value is "--root <path>", not "--root": pending entries are argv
|
|
132
|
+
// FRAGMENTS the installer appends, and the <angle-bracketed> part is the hole a human fills.
|
|
133
|
+
// Appending the bare flag would write a server that launches and then rejects its own arguments.
|
|
134
|
+
else if (raw.isRequired === true) pending.push(`${name} ${hole(str(raw.valueHint, 64) ?? "value")}`);
|
|
135
|
+
} else if (value !== undefined) words.push(value);
|
|
136
|
+
else if (raw.isRequired === true) pending.push(hole(hint));
|
|
137
|
+
}
|
|
138
|
+
return words;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** one registry package → a stdio install, or undefined (unsupported type, non-stdio transport, bad hint) */
|
|
142
|
+
export function installFromPackage(raw: unknown, notes: string[]): MarketInstall | undefined {
|
|
143
|
+
if (!isRecord(raw)) return undefined;
|
|
144
|
+
const type = str(raw.registryType, 32), id = str(raw.identifier, 200), version = str(raw.version, 64);
|
|
145
|
+
if (type === undefined || id === undefined) return undefined;
|
|
146
|
+
const transport = isRecord(raw.transport) ? str(raw.transport.type, 32) : "stdio";
|
|
147
|
+
if (transport !== undefined && transport !== "stdio") return undefined; // an http package is "run it yourself, then connect": not an install
|
|
148
|
+
const hint = str(raw.runtimeHint, 64);
|
|
149
|
+
if (hint !== undefined && !COMMAND.test(hint)) { notes.push(`${id}: runtime hint "${hint}" refused (not a bare command name)`); return undefined; }
|
|
150
|
+
const pending: string[] = [];
|
|
151
|
+
const runtimeArgs = argWords(list(raw.runtimeArguments), pending);
|
|
152
|
+
const pkgArgs = argWords(list(raw.packageArguments), pending);
|
|
153
|
+
const env = list(raw.environmentVariables).map(envSpec).filter((e): e is EnvSpec => e !== undefined);
|
|
154
|
+
const pinned = version !== undefined && version !== "latest";
|
|
155
|
+
let command: string, args: string[], runtime: Extract<MarketInstall, { kind: "stdio" }>["runtime"];
|
|
156
|
+
switch (type) {
|
|
157
|
+
case "npm":
|
|
158
|
+
command = hint ?? "npx"; runtime = command === "npx" ? "npx" : "other";
|
|
159
|
+
args = [...(command === "npx" && !runtimeArgs.includes("-y") ? ["-y"] : []), ...runtimeArgs, pinned ? `${id}@${version}` : id, ...pkgArgs];
|
|
160
|
+
break;
|
|
161
|
+
case "pypi":
|
|
162
|
+
command = hint ?? "uvx"; runtime = command === "uvx" ? "uvx" : "other";
|
|
163
|
+
args = [...runtimeArgs, pinned ? `${id}==${version}` : id, ...pkgArgs];
|
|
164
|
+
break;
|
|
165
|
+
case "oci":
|
|
166
|
+
// docker gets `-e NAME` per variable so the value travels through the environment, never argv
|
|
167
|
+
command = hint ?? "docker"; runtime = command === "docker" ? "docker" : "other";
|
|
168
|
+
args = ["run", "-i", "--rm", ...env.flatMap((e) => ["-e", e.name]), ...runtimeArgs, pinned ? `${id}:${version}` : id, ...pkgArgs];
|
|
169
|
+
break;
|
|
170
|
+
default:
|
|
171
|
+
notes.push(`${id}: package type "${type}" is not something rovecode can launch`);
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
return { kind: "stdio", runtime, command, args, env, pending };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** one registry remote → an http install, or undefined (sse is legacy and the loader refuses it anyway) */
|
|
178
|
+
export function installFromRemote(raw: unknown, notes: string[] = []): MarketInstall | undefined {
|
|
179
|
+
if (!isRecord(raw)) return undefined;
|
|
180
|
+
const type = str(raw.type, 32);
|
|
181
|
+
const url = httpUrl(raw.url);
|
|
182
|
+
if (type !== "streamable-http" && type !== "http") {
|
|
183
|
+
// the human asked why nothing was written: an sse (legacy) remote is the usual answer — say so, like the package path does
|
|
184
|
+
notes.push(`${url ?? "a remote"}: ${type === "sse" ? "sse remote (legacy transport) is not something rovecode can connect to" : `remote type "${type ?? "?"}" is not something rovecode can connect to`}`);
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
if (url === undefined) return undefined;
|
|
188
|
+
return { kind: "http", url, headers: list(raw.headers).map(headerSpec).filter((h): h is HeaderSpec => h !== undefined) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** `io.github.owner/repo` → "github.com/owner"; `com.example/x` → "example.com" — the namespace the
|
|
192
|
+
* registry verified ownership of, shown beside every registry result so the human knows who published it */
|
|
193
|
+
export function publisherOf(name: string): string {
|
|
194
|
+
const ns = name.split("/")[0] ?? name;
|
|
195
|
+
if (ns.startsWith("io.github.")) return `github.com/${ns.slice("io.github.".length)}`;
|
|
196
|
+
return ns.split(".").reverse().join(".");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** one `{ server, _meta }` item → a MarketEntry; undefined when it is not an active, well-formed server */
|
|
200
|
+
export function entryFromRegistry(item: unknown, notes: string[]): MarketEntry | undefined {
|
|
201
|
+
if (!isRecord(item) || !isRecord(item.server)) return undefined;
|
|
202
|
+
const s = item.server;
|
|
203
|
+
const name = str(s.name, 200);
|
|
204
|
+
if (name === undefined || !/^[a-z0-9][a-z0-9.-]*\/[A-Za-z0-9._-]+$/.test(name)) return undefined;
|
|
205
|
+
// the registry's own verdict on the listing (active · deprecated · deleted): a deleted server is gone,
|
|
206
|
+
// anything else is kept WITH its status so the human sees "deprecated" before installing
|
|
207
|
+
const meta = isRecord(item._meta) ? item._meta["io.modelcontextprotocol.registry/official"] : undefined;
|
|
208
|
+
const status = isRecord(meta) ? str(meta.status, 32) : undefined;
|
|
209
|
+
if (status === "deleted") return undefined;
|
|
210
|
+
const installs: MarketInstall[] = [];
|
|
211
|
+
for (const p of list(s.packages, LIMITS.packages)) { const i = installFromPackage(p, notes); if (i) installs.push(i); }
|
|
212
|
+
for (const r of list(s.remotes, LIMITS.packages)) { const i = installFromRemote(r, notes); if (i) installs.push(i); }
|
|
213
|
+
const out: MarketEntry = { key: name, description: str(s.description, LIMITS.desc) ?? "", source: "registry", publisher: publisherOf(name), installs };
|
|
214
|
+
const title = str(s.title, 120), version = str(s.version, 64), home = httpUrl(s.websiteUrl);
|
|
215
|
+
const repo = isRecord(s.repository) ? httpUrl(s.repository.url) : undefined;
|
|
216
|
+
if (title !== undefined) out.title = title;
|
|
217
|
+
if (version !== undefined) out.version = version;
|
|
218
|
+
if (status !== undefined && status !== "active") out.status = status;
|
|
219
|
+
if (repo !== undefined) out.repository = repo;
|
|
220
|
+
if (home !== undefined) out.homepage = home;
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** a list page → entries (deduped by name, capped at LIMITS.servers) */
|
|
225
|
+
/** The cursor for the NEXT page, if the registry says there is one. Kept apart from parseRegistryPage so
|
|
226
|
+
* that function's shape (and every caller of it) stays as it was. A cursor is opaque, so it is only
|
|
227
|
+
* length-capped and type-checked — never interpreted. */
|
|
228
|
+
export function nextCursorOf(json: unknown): string | undefined {
|
|
229
|
+
if (!isRecord(json) || !isRecord(json.metadata)) return undefined;
|
|
230
|
+
const c = json.metadata.nextCursor;
|
|
231
|
+
return typeof c === "string" && c.length > 0 && c.length <= LIMITS.str ? c : undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** how many pages one search will follow. 5 x 50 = 250 servers is far past what a person reads, and the
|
|
235
|
+
* ceiling matters more than the reach: a cursor loop with no bound is a request amplifier pointed at us
|
|
236
|
+
* by whatever the registry returns. */
|
|
237
|
+
export const MAX_PAGES = 5;
|
|
238
|
+
|
|
239
|
+
export function parseRegistryPage(json: unknown, notes: string[]): MarketEntry[] {
|
|
240
|
+
if (!isRecord(json)) { notes.push("registry: response is not an object"); return []; }
|
|
241
|
+
const seen = new Set<string>(), out: MarketEntry[] = [];
|
|
242
|
+
for (const item of list(json.servers, LIMITS.servers)) {
|
|
243
|
+
const e = entryFromRegistry(item, notes);
|
|
244
|
+
if (e && !seen.has(e.key)) { seen.add(e.key); out.push(e); }
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ------------------------------------------------------------------ cache (~/.rovecode/cache/mcp-market.json)
|
|
250
|
+
|
|
251
|
+
interface CacheFile { version: 1; queries: Record<string, { at: number; entries: MarketEntry[] }> }
|
|
252
|
+
export function cachePath(home: string): string { return join(home, "cache", "mcp-market.json"); }
|
|
253
|
+
|
|
254
|
+
function readCache(home: string): CacheFile {
|
|
255
|
+
const path = cachePath(home);
|
|
256
|
+
if (!existsSync(path)) return { version: 1, queries: {} };
|
|
257
|
+
try {
|
|
258
|
+
const json: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
259
|
+
if (isRecord(json) && json.version === 1 && isRecord(json.queries)) return json as unknown as CacheFile;
|
|
260
|
+
} catch { /* a broken cache is an empty cache */ }
|
|
261
|
+
return { version: 1, queries: {} };
|
|
262
|
+
}
|
|
263
|
+
function writeCache(home: string, cache: CacheFile): void {
|
|
264
|
+
// keep the newest CACHE_QUERIES; the file is a convenience, never a source of truth
|
|
265
|
+
const keep = Object.entries(cache.queries).sort((a, b) => b[1].at - a[1].at).slice(0, CACHE_QUERIES);
|
|
266
|
+
try {
|
|
267
|
+
mkdirSync(dirname(cachePath(home)), { recursive: true });
|
|
268
|
+
writeFileSync(cachePath(home), JSON.stringify({ version: 1, queries: Object.fromEntries(keep) }) + "\n");
|
|
269
|
+
} catch { /* read-only home: the search still answered */ }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ------------------------------------------------------------------ registry client
|
|
273
|
+
|
|
274
|
+
async function registryGet(path: string, deps: MarketDeps): Promise<unknown> {
|
|
275
|
+
const f = deps.fetch ?? fetch;
|
|
276
|
+
const base = deps.registryUrl ?? process.env.ROVECODE_MCP_REGISTRY ?? REGISTRY_URL;
|
|
277
|
+
const res = await f(`${base}${path}`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
278
|
+
if (!res.ok) throw new Error(`registry answered ${res.status}`);
|
|
279
|
+
const text = await res.text();
|
|
280
|
+
if (text.length > LIMITS.body) throw new Error(`registry response too large (${text.length} bytes)`);
|
|
281
|
+
return JSON.parse(text) as unknown;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** cache-through: a fresh cached answer is served as is; otherwise the network, then the cache; a
|
|
285
|
+
* failed network falls back to a STALE cached answer with a note, else to nothing with the reason */
|
|
286
|
+
async function cached(key: string, load: () => Promise<MarketEntry[]>, deps: MarketDeps, notes: string[]): Promise<{ entries: MarketEntry[]; fromCache: boolean }> {
|
|
287
|
+
const home = deps.home ?? rovecodeHome(), now = (deps.now ?? Date.now)();
|
|
288
|
+
const cache = readCache(home), hit = cache.queries[key];
|
|
289
|
+
if (hit && now - hit.at < CACHE_TTL_MS) return { entries: hit.entries, fromCache: true };
|
|
290
|
+
if (deps.offline) { if (hit) notes.push("offline: showing cached registry results"); return { entries: hit?.entries ?? [], fromCache: hit !== undefined }; }
|
|
291
|
+
try {
|
|
292
|
+
const entries = await load();
|
|
293
|
+
cache.queries[key] = { at: now, entries };
|
|
294
|
+
writeCache(home, cache);
|
|
295
|
+
return { entries, fromCache: false };
|
|
296
|
+
} catch (err) {
|
|
297
|
+
notes.push(`registry unreachable (${message(err)})${hit ? " — showing cached results" : ""}`);
|
|
298
|
+
return { entries: hit?.entries ?? [], fromCache: hit !== undefined };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** curated first (all of them for an empty query; substring on key/title/description otherwise), then
|
|
303
|
+
* the registry's name matches for a query of two+ characters, minus anything the curated list already names */
|
|
304
|
+
export async function searchMarket(query: string, deps: MarketDeps = {}): Promise<MarketResult> {
|
|
305
|
+
const notes: string[] = [];
|
|
306
|
+
const q = query.trim().toLowerCase();
|
|
307
|
+
const catalog = deps.catalog ?? CURATED;
|
|
308
|
+
const words = q.split(/\s+/).filter(Boolean);
|
|
309
|
+
const curated = catalog.filter((e) => words.every((w) => `${e.key} ${e.title ?? ""} ${e.description}`.toLowerCase().includes(w)));
|
|
310
|
+
if (q.length < 2) return { entries: curated, notes, fromCache: false };
|
|
311
|
+
const { entries, fromCache } = await cached(`search:${q}`, async () => {
|
|
312
|
+
// FOLLOW THE CURSOR. A single page was not a smaller result, it was a WRONG one: the registry answers
|
|
313
|
+
// "here are the first 50 and there is more", and stopping there tells the human their server does not
|
|
314
|
+
// exist when it is on page two. Bounded three ways — page count, total entries, and a cursor that must
|
|
315
|
+
// actually change — because a paginating client that trusts the server's cursor can be walked forever.
|
|
316
|
+
const parseNotes: string[] = [];
|
|
317
|
+
const all: MarketEntry[] = [];
|
|
318
|
+
const seenKeys = new Set<string>(); // parseRegistryPage dedupes WITHIN a page; this spans pages
|
|
319
|
+
const seenCursors = new Set<string>();
|
|
320
|
+
let cursor: string | undefined;
|
|
321
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
322
|
+
const url = `/servers?search=${encodeURIComponent(q)}&version=latest&limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
|
|
323
|
+
const json = await registryGet(url, deps);
|
|
324
|
+
for (const e of parseRegistryPage(json, parseNotes)) {
|
|
325
|
+
if (seenKeys.has(e.key)) continue;
|
|
326
|
+
seenKeys.add(e.key);
|
|
327
|
+
all.push(e);
|
|
328
|
+
}
|
|
329
|
+
if (all.length >= LIMITS.servers) break;
|
|
330
|
+
const next = nextCursorOf(json);
|
|
331
|
+
if (next === undefined || seenCursors.has(next)) break; // no more, or the registry is looping us
|
|
332
|
+
seenCursors.add(next);
|
|
333
|
+
cursor = next;
|
|
334
|
+
}
|
|
335
|
+
return all.slice(0, LIMITS.servers);
|
|
336
|
+
}, deps, notes);
|
|
337
|
+
const taken = new Set(curated.flatMap((e) => [e.key, ...(e.repository ? [e.repository] : [])]));
|
|
338
|
+
return { entries: [...curated, ...entries.filter((e) => !taken.has(e.key) && !(e.repository && taken.has(e.repository)))], notes, fromCache };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** one entry by key: the curated one if the key is a curated short name, else the registry's latest version */
|
|
342
|
+
export async function marketInfo(key: string, deps: MarketDeps = {}): Promise<{ entry?: MarketEntry; notes: string[] }> {
|
|
343
|
+
const notes: string[] = [];
|
|
344
|
+
const name = key.trim();
|
|
345
|
+
const catalog = deps.catalog ?? CURATED;
|
|
346
|
+
const hit = catalog.find((e) => e.key === name);
|
|
347
|
+
if (hit) return { entry: hit, notes };
|
|
348
|
+
if (!name.includes("/")) { notes.push(`"${name}" is not a curated name and not a registry name (those look like io.github.owner/repo) — try: rovecode mcp search ${name}`); return { notes }; }
|
|
349
|
+
const { entries } = await cached(`info:${name}`, async () => {
|
|
350
|
+
const json = await registryGet(`/servers/${encodeURIComponent(name)}/versions/latest`, deps);
|
|
351
|
+
const e = entryFromRegistry(json, notes);
|
|
352
|
+
return e ? [e] : [];
|
|
353
|
+
}, deps, notes);
|
|
354
|
+
const entry = entries[0];
|
|
355
|
+
if (!entry && notes.length === 0) notes.push(`"${name}" is not in the registry`);
|
|
356
|
+
return entry ? { entry, notes } : { notes };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** what a stdio install runs / an http install connects to — one line, exact, for lists and cards */
|
|
360
|
+
export function installLabel(i: MarketInstall): string {
|
|
361
|
+
return i.kind === "stdio" ? [i.command, ...i.args].join(" ") : i.url;
|
|
362
|
+
}
|