dsh-overall-mcp
Manifest validHost-wide MCP server panel: imports the servers declared in Claude Desktop, Claude Code, Cursor and Codex CLI configs, searches six upstream catalogs and installs from whichever of them declares an in
dsh-overall-mcp
English | 中文
A DeepSeek Harness plugin that scans every MCP configuration on the machine on first install and registers what it finds; aggregates six open MCP catalogs (5,364 deduplicated entries measured 2026-10) into one browsable, one-click-installable panel; and keeps a local census index of MCP repositories on GitHub (1,758 rows measured), recovering install commands from repository evidence and labelling each recovered command with where it came from.
README.zh-CN.md is the same document in Chinese. docs/ENGINEERING.md keeps the engineering record — the measurements, the alternatives that were rejected, and why each choice went the way it did. The README describes what the plugin does; that file describes how it got there. docs/install-check.md is narrower: one measured install from GitHub — the timings, what the lockfile pins, and why this plugin is not published to npm.
What it looks like
These are screenshots of the real panel: tools/readme-shots.mjs renders them from the real client code (the same client/client.js) over a fixed fixture, so the pictures cannot drift from what the code draws. npm test checks that every image referenced here exists.
Registered: source groups, and the two walks on the GitHub card

Market: six catalogues behind one search box

Detail: everything behind one card

Features
| Capability | What it does | |
|---|---|---|
| 🔍 | Scan on first install | Discovers and registers every MCP server configured in Claude Desktop, Claude Code, Cursor, VS Code (Copilot), Windsurf, Codex CLI and Gemini CLI |
| 🌐 | MCP market | Six sources aggregated in parallel — official Registry, Smithery, Docker MCP Catalog, GitHub MCP, Awesome remote, Awesome list — 5,364 entries measured. Browse with no keyword, filter by source, search. (The GitHub source reads the local census index and makes zero requests when an index exists; rows without install data get a command recovered from repository evidence, with the source named.) |
| ⚡ | One-click connect | npm package → npx -y <pkg>, Python package → uvx, hosted endpoint → streamable-http, Docker image → docker run -i --rm mcp/<name>; listing-only entries offer their repository link |
| 🧩 | Settings before it can work | A server that installs can still be unusable — a missing API key makes every tool call answer 401. The declaration is read from the official Registry (or from the entry's own launcher), stored with the row, and shown as a 待配置 badge plus a one-line page banner; 配置 opens a masked form, saving remounts only a row that was already enabled, and the install reply opens the form for what it just wrote (GET /servers, POST /configure, overall_mcp_configure, round 25) |
| 🛠️ | Native tool bridge | Each enabled server is mounted as one dsh-mcp-client instance; its tools register as native DSH tools named mcp__<name>__<tool> |
| 🖥️ | Sidebar panel | Global MCP, directly under Plugins: a Registered tab (grouped by source, collapsible, per-group switch, status filters) and an MCP Market tab |
| 🎛️ | Per-group switches | One switch per source group enables/disables every server from that source (also available as overall_mcp_set_source_enabled) |
| ⬆️ | Version check and update | Checks npm/PyPI for servers launched through npx/uvx (including Windows cmd.exe /c wrappers); a green Update all (N) runs them with per-row progress, and turns into ✓ done when the queue has answered every row |
| 🪪 | Identity line | The panel's second line says which code this process is running: plugin version, pid, uptime, readable data files, log lines. When the files on disk are newer than the process it adds ⚠ code updated, restart DSH — "still broken" and "not loaded yet" stop being the same sentence (GET /status) |
| 🧾 | Action log | The host writes its actions to events.ndjson (browse/per-source outcome/quota/scan/census/evidence/install/update/mount/registry repair), expandable from the header. Metadata only, never secrets, last 400 lines |
| 🔑 | GitHub credentials and census resume | A token can be pasted in the panel (or taken from gh auth token); it takes effect only after validation and is sent only to GitHub hosts. Anonymous still works — the budget is just 60/h instead of 5,000/h. One network failure no longer ends a census (per-repository retry with backoff, stop after repeated rejections), and the next run resumes from where it stopped (GET|POST|DELETE /auth) |
| ⏯️ | Start and stop the census yourself | The GitHub access card on the Registered tab states where the census stands (how many rows, when it last read, who stopped it) and offers the verb that matches: start (no waiting for the cache to expire) or stop with live progress. Stopping is safe — rows are written as they arrive, the next run resumes from the missing repositories, and a stopped run will not spend budget on install evidence afterwards (GET|POST /census, round 22) |
| 🔧 | Start and stop the recovery walk yourself | The same card carries a second walk (reading repository evidence to recover install commands) with its own state line and its own verb: tried 78 / 2.1k repositories while running, resume recovery when rows are left. It used to start by itself when the market opened and could only be ended by unloading the plugin — and a full pass is 150ms × more than two thousand repositories (GET|POST /install-evidence, round 23) |
| 🗂️ | Read the local census as a source | The source filter offers local census (index) right beside GitHub: same index on disk, but not one request spent, and an old index is not quietly re-walked. It is not a seventh source — counts still belong to GitHub (one upstream, one number) — it just makes "I am reading the list I already have" a choice you can make (?source=census, round 24) |
| 🧮 | Every number and time states its meaning | Per-source counts use the same ruler as "N total" (deduplicated, with deduped shown separately); "listing read at" is local read time while "upstream updated" is the repository's last push; when sorting by usage, a card with no usage data says so instead of silently sorting by quality; inferred categories carry "(inferred)" on the card, in the detail panel and in the category dropdown |
Quick start
dsh plugin --profile web add ./dsh-overall-mcp
Restart the profile: the first load scans the host's MCP configs. The entry appears in the sidebar under Plugins.
The page is a global panel in the sidebar (not a setting, not a plugin config area): the rail icon is registered in the sidebar.panellist slot (order: 5, between the official Plugins page at 0 and Tasks at 10) and the panel itself in the layout's keyed main slot — both under the same id overall-mcp. A keyed slot rejects an unknown key, so the two strings must agree; tools/client-smoke.mjs pins them together. The page is its own scroll container (centred, 1180px max) with --dsh-frame-top-clearance on top so it does not hide under the macOS traffic lights or a Windows title bar.
The Registered tab
- Header counts:
N servers · M enabled · K disabled · C connected · X connection errors - Filters: source, status (enabled/disabled/connected/error), name search
- Groups by import source, each with its count and enabled count, a group switch, and a clickable header to collapse
- Each row: status dot, name, purpose line, facts line (transport · status · tool count · tool prefix
mcp__<serverName>__· package version), enable/disable, remove, and 配置 when the row declares settings - One writer at a time, atomic writes, and tools that tell the truth. Every "read registry → await (scan / spawn npx / query npm) → write back" action is queued on one promise chain, because two interleaved read-modify-writes lose the earlier change. The registry is written through a temporary file plus
rename(it is the one file whose loss unmounts every server). A registry that fails to parse is no longer treated as a first run: the original is copied toregistry.json.corrupt-<timestamp>, the panel shows a red line with the reason, and the file is never overwritten with an empty registry. A scan no longer clears favourites (the merge preserves the top-level keys it does not own), installing over a hand-writtensource: manualentry returns 409 unlessoverwrite: true, a failed mount reportscounted: falseinstead of looking like success, and the group switch disconnects by name so it cannot take another source's same-named row with it. - Purpose lines. The host picks one human sentence by strength of evidence (
lib/about.js, clamped to 150 characters and two lines): the server's owninstructionsfrom the MCPinitializeresult (source: 'server'), else the tools it registered (source: 'tools'), else its launch command (source: 'command'). The origin is labelled in the line'stitle, and if all three are missing the line is omitted rather than invented. Because dsh-mcp-client publishesinstructionsonly while the server is connected, "not found" is not cached as final: the host remembers the liveness at last read and re-assembles only when it flips. - Orphan rows. The registry records the evidence of each scan (
registry.scanned); rows no longer defined by any configuration file are markedsource deletedwith one "Clean up N" action (POST /forget-orphans). A row that currently holds a mount is never cleaned up, and a configuration that could not be read is not evidence of absence.
The Market tab
- Opening it loads the whole catalog (~5.4k entries; ~3MB — the upstream cost of a slice is identical). Each source has a 60-second TTL and a manual Refresh.
- Fetching is conditional. Each upstream URL remembers its
ETagand body; the next request sendsIf-None-Matchand a304reuses the stored body without re-parsing JSON. Inside the TTL nothing is asked at all. The meta line states which happened:listing read at 12:34:56 (cached) · 3 sources unchanged. - A fetch failure keeps the previous listing (marked
stale, entries still usable) and still reports the error; only a cold-start failure reports empty. - Total shown compactly (
5.3k available MCPs, truncated rather than rounded, so the headline never exceeds the real count). - Sorting (view-only, no upstream request):
Default (recommended)andBest qualityuse the same weighted score (certified 400 > official 300 > installable 120, plus log10 popularity up to +200, deprecated −500);Most usedsums Smithery calls + Docker pulls + this panel's installs (different units, and both the tooltip and the hint row say so); plusMost stars,Recently updated,Name A→Z. Missing numbers never sort last: rows with a number beat rows without, and both fall back to quality. - Paging 20/50/100 with first/prev/next/last.
- Categories are a dropdown, not a chip row: 75 upstream
###headings collapse into 19 functional groups on the host; the upstream heading stays on the entry for provenance. Sources that publish no category at all (official Registry and Smithery publish none) are inferred by keyword — 78 of 100 sampled official entries and 10/10 Smithery matched — and the result carriesgroupSource: 'inferred', labelled everywhere it appears. Unmatched entries honestly sayUncategorised, which means "we don't know", not "the upstream said so". - Ranks are visible: with a non-default sort every card's footer starts with
#1 #2 #3…, active controls get an inked border, and the meta line names the sort. - Cards show icon, title-as-link, GitHub mark, version, badges, three-line description, tags, facts printed on the face (stars · usage · updated) and a transport chip (the full command lives in the detail panel). The wall is a row-major equal-width grid (
display:grid+minmax(0,1fr)per track), not acolumn-countwaterfall: a column waterfall fills top-to-bottom, which made aMost usedsort read as unsorted. - Favourites persist to
registry.favorites; the filter row has Favourites only (N), a view filter that says how many are hidden by the current source/search rather than showing an empty list. - Submissions: when a listing lives in a GitHub repository, that row gets a Submit ↗ link that opens a pre-filled
issues/newpage (no token, no backend); a workflow in that repository validates the issue and writesregistry.json. - Export log (⤓) writes out everything this page knows — plugin version, the registry, per-source counts and each source's own time/cache/304 state, current filters, both errors. Assembled client-side; the host's logs are not this plugin's to export.
- Only the card area scrolls: the page is a fixed shell, the tab body and the card wall are the scrollers, and the chips/search/filter/meta rows are pinned.
tools/shot.mjsmeasures that contract at four window widths.
Automatic diagnosis of a failed install
spawn npx ENOENT and MCP error -32000: Connection closed are the failures users actually hit, and the raw line is barely actionable. The host hands the entry's facts plus the original error to the user's own model and shows the answer next to the error:
- Silent:
ctx.llm.streamdirectly (temperature: 0, 20s timeout) — no session, no message in any conversation, nothing on disk. - No leakage: name/source/transport/command/arguments (max 8)/url/repository/homepage/version/category plus the raw error. The entry's
envandheadersare never sent — those hold the credentials — and command/url pass throughredactSecrets/redactUrl(labelled credentials such as--api-key=…, plussk-,ghp_,xoxb-,AKIAand JWT shapes become<redacted>). Best effort, and the real defence is not sendingenv/headersat all. - Hard constraints in the system prompt: at most three lines (cause / next step / a third only if the error alone is insufficient), never invent a URL, argument, version or config key, never claim to have run a command, never suggest disabling a sandbox or skipping a safety check.
- Every "no explanation" says why:
diagnosisSkipped: "no-llm"or"no-route"(no request is made in either case),diagnosisErrorwhen the call fails — and a200response withconnect.ok === falserenders a failure block (raw error, then the diagnosis) instead of claiming success. - Identical
(entry, error)pairs are answered from a ten-minute memo (32 entries), and hosts without a model are unaffected:llmis injected optionally.
Settings a server needs before it can work
A server can install, mount, read 已连接 — and still answer every tools/call with 401, because the token was never filled in. Until round 25 the first hint was the broken tool call. The declaration is now read at install time, stored with the row, and shown before anything is clicked.
- Where the declaration comes from (
lib/server-config.js): the official Registry'spackages[].environmentVariables[](name, description,isRequired,isSecret) andremotes[].headers[]; for every other source, what the entry's own launcher implies —-e NAME/--env NAME,$NAME/${NAME}/{NAME}placeholders in the command, an argument or the URL, and a bare credential-shaped token such asGITHUB_PERSONAL_ACCESS_TOKEN(exactly how the official GitHub image is launched). Astreamable-http/sseentry always gets one optionalAuthorizationheader: most remote endpoints are anonymous, and a required field nobody can fill would make every one of them look broken. Sources are unioned,originrecords the strongest evidence (official>args>manual), and a row that declares nothing carries noconfigat all. - Stored, not re-derived: the spec is written into the registry entry (
config: { origin, fields }), so a badge survives a restart and a hand-edited config file is not second-guessed. The registry file is stored with0o600mode on POSIX and tightened on read; credentials are saved in plaintext locally and rely on filesystem permissions for security. Only a missing required value counts — an empty optional field is a hint, not a problem. - Masked on the way out:
GET /serverscarriesconfig: { origin, fields: [{key, kind, label, help, required, secret, hasValue, value}], missing, pending }andneedConfig: trueper row, plus the count of enabled rows that need it. A secret echoes••••when set and''when not; a non-secret (a base URL) echoes back so the reader can see which environment it points at.envandheadersthemselves never leave the host. - The form: a row with a declaration gets 配置; a row missing a required value also gets a
待配置chip and the primary button style, and the page says it once above the filters (有 N 台服务器装好了但还差配置, with a去配置shortcut). The sheet lists the declared fields as masked inputs marked必填, adds one key/value row for anything the declaration missed, and saves withPOST /configure. Saving writes the values and remounts only a row that was already enabled — a stopped row stays stopped. An empty string deletes the key (清除), and a save that changes nothing says so instead of claiming success. - Install opens the form: a catalogue item whose declaration has a required value installs as 「待配置」, the reply carries
needConfigandconfig.missing, and the panel opens the sheet on the row it just wrote. The model diagnosis is skipped for this case (diagnosisSkipped: 'needs-config') and for credential-shaped mount errors: the catalogue already said which key is missing, so asking a model to re-derive it from401would be slower and worse. - For the model:
overall_mcp_listmarks rows withneedConfigand listsconfig.missing;overall_mcp_configurewrites values keyed by field name ({"env": {...}}/{"headers": {...}}) and echoes secrets as••••.
Submitting to a community listing
The write direction needs no local service — whoever hosts the listing hosts the submission:
| Role | Artifact | Location |
|---|---|---|
| Listing repo | the listings document the market reads | registry.json |
| Listing repo | validates an incoming issue and writes that file | .github/workflows/registry-submission.yml |
| Listing repo | all validation and write logic (no dependencies) | tools/registry-submission.mjs |
| Listing repo | the human form (fields match the parser) | .github/ISSUE_TEMPLATE/mcp-listing.yml |
| Panel | the row's Submit ↗ pre-filled link | lib/submission.js |
| Host | listing URL → its GitHub coordinates | index.js |
The issue body is untrusted input: it is validated while parsing and re-checked before writing, and it reaches the shell only through env: (never interpolated into a script). Exit codes are the control flow: 0 listed/updated/no-op, 1 validation failed (a comment lists why, the file is untouched), 2 the tool itself failed.
node tools/registry-submission.mjs --body-file /tmp/issue.md # validate only
node tools/registry-submission.mjs --body-file /tmp/issue.md --write --comment /tmp/comment.md
node tools/registry-submission.mjs --check --registry registry.json
npm run test:registry
Rules: name and description are required (120/600 characters); either a stdio command or a streamable-http URL, never both; the command may not contain |, ;, &&, backticks, redirection or $() — that line runs on someone else's machine, so an entry needing a shell is rejected rather than silently executed; repositories and homepages must be https. One listing keeps one row, keyed by repository, then URL, then name, and updatedAt does not move when nothing else did.
The mirror chain when GitHub is unreachable
In this network github.com, raw.githubusercontent.com and hub.docker.com can time out entirely while both MCP registries answer normally — and the two Awesome sources (96% of the market) would disappear. Both listings therefore walk a trust-ordered mirror chain — raw.githubusercontent.com → cdn.jsdelivr.net → fastly.jsdelivr.net → testingcf.jsdelivr.net — with all four started at once and the first success winning (measured 450ms on a healthy network; serial waiting was the main cause of a slow first open, because raw and fastly each burn ~10s when GitHub is blocked). No per-URL retry inside the chain, per-mirror ETags, 60-second per-source caching, and the Docker source degrades alone since there is no open mirror for its registry API.
Popularity numbers: what each one counts
| Source | Field | Card shows | Meaning |
|---|---|---|---|
| Smithery | useCount | 87.6k launches | launches on that platform |
| Docker MCP Catalog | pull_count | 1.9M pulls (cumulative) | cumulative image pulls, not a rolling window |
| Official Registry | — | not shown | publishes no popularity field |
| Both Awesome lists | — | not shown | same |
Popularity is a weak signal in sorting (400 maximum against 1000 for "installable") and log10-scaled, so a 1.9M-pull image cannot flatten the rest. GitHub stars are separate: they are a local supplement (up to 30 visible repositories per pass, cached 6 hours), the host reads the rate-limit state and pauses when exhausted (60/h unauthenticated, no batch endpoint), and a failed image fetch is retried on a 5-minute TTL rather than cached as "this repository has no images".
Model tools
overall_mcp_scan— rescan the host's MCP configs and import (idempotent; same name with a changed contract is updated in place; hand-written entries are never overwritten, only reported as skipped; the reply includesorphans)overall_mcp_list— the local registry (source, transport, enabled state, connection state)overall_mcp_catalog [query] [source] [limit] [refresh]— browse the market; entries carryinstallable/installed/author/tags/installsoverall_mcp_search <query>— keyword search over the same aggregation (server-side capped at 50)overall_mcp_install <item> [overwrite]— install and connect; refuses (409) to replace a manual entry withoutoverwrite: true; a failed mount is not counted (counted: false)overall_mcp_configure <name> [env] [headers] [slot] [from]— write the settings a server declared (secrets come back as••••); saving remounts an enabled row and leaves a stopped row stoppedoverall_mcp_set_enabled <name> <bool> [slot] [from]— enable/disable one serveroverall_mcp_set_source_enabled <source> <bool>— the group switchoverall_mcp_check_updates/overall_mcp_update <name> [slot] [from]— npm/PyPI checks and update-with-reconnectoverall_mcp_remove <name> [slot] [from]— delete this plugin's registry row and unmount its fiber
With several rows sharing a name, update/remove/set_enabled require slot (the row index from overall_mcp_list) or from (the version that row showed); without one they return 409 rather than guessing — updating the wrong row reports "already up to date" and removing the wrong row deletes an unrelated record. Rows reported as provided-externally (another mcp-client mounts those tools) return stillProvided: true and a note: disabling records this plugin's intent and removing deletes this plugin's row, but the mcp__<name>__ tools do not disappear.
Version check and update
The Registered tab has Check updates; rows with an update show a green updatable → x.y.z badge and an Update button. "Cannot determine the latest version" is not "up to date": it has its own count, its own wording and a red check failed chip with the reason, because an offline user was previously told everything was current.
The queue runs serially (one writer: one registry file, one fiber per server) with a real i / N counter — not a download percentage, since the host answers only after rewriting and reconnecting. Each row's button is its own state (updating…, queued, disabled), a row that has been answered stops offering an update button and shows its result instead, and when nothing is left the position becomes a green ✓ done (N updated / no update needed). Closing the progress panel does not change the registry, and answered rows are remembered by row address so they do not reappear in the count unless the listing reports a newer version.
A second row with the same name is a different record. Names are not row identity — this machine has four rea-cursor rows, three with an empty serverName — so every update from the panel carries slot and from, and a check result is only shown when it describes the version that row currently pins (checkDescribesEntry).
The plugin's own update is the one thing that belongs next to the version number, and only node_modules installs get a button: a link:/local checkout (this repository's own development shape) resolves to the source directory, so npm's version is irrelevant and no button appears.
| Install shape | Verdict | Panel |
|---|---|---|
profile node_modules/dsh-overall-mcp | version owned by pnpm; compare npm dist-tags.latest | Update to vX next to the version, then "restart DSH" |
link: / local checkout | the loaded code is the version | no button; the check says self-update is skipped |
| npm query failed (unpublished/404/timeout) | undecidable | no button; the reason is shown as-is |
GET /api/overall-mcp/self reports the plugin's own verdict without triggering server checks (10-minute memo); POST /self-update runs the install and returns the exact command line.
Detecting a version requires the entry to be launched through a package runner: npx -y pkg@1.2.3 and its Windows cmd.exe /d /c wrapper ✅, uvx/pipx ✅, unpinned npx -y pkg is treated as always current ⚪, and direct commands such as codegraph serve --mcp report "not launched through a package runner" ❌ rather than guessing.
Install/connect uses bounded waiting: 8 seconds, after which the reply says starting: true and the connection continues in the background. A failed or timed-out attempt leaves no ghost fiber — only a real mount records a fiber and a serverName, and a late failure is reclaimed and disposed. Every panel request has a deadline (api(): 30s default, /scan and /catalog 120s, /install and update routes 300s, /self-update 600s) and times out with a message instead of freezing every button; the host distinguishes "the row you read has moved" (stale entry) and "several rows share this name" (ambiguous server name) as 409 — retry after refreshing, not an internal error.
Schema guard
Some MCP servers publish tool JSON Schemas that a model API's regex validator rejects; REA v6.1.0's "pattern": "^[^\\0]*$" makes DeepSeek reject the whole request (Invalid schema for function …: "^[^\\0]*$" is not a "regex"). This plugin installs a narrow guard for mcp__* tools in this process (restored on unload): \0 is rewritten to the equivalent \x00, and other non-portable constructs (backreferences, lookbehind, \p{...}) drop the pattern keyword — a looser schema, never a wrong one. Every repair is logged, and GET /api/overall-mcp/tools exposes the registered schema for forensics.
Duplicate mounts
If a server name is already mounted by another mcp-client (for example rea-mcp from a harness home patch), this plugin does not mount it again: the row reads provided-externally (blue dot, "provided by another plugin, duplicate mount skipped"). That avoids both a duplicate tool set inflating the context and a duplicate connection bringing a bad schema into the request. Such a row has no fiber here, so the panel can still record intent or delete this plugin's row — and the tooltips, the action's note and the model tools all say the tools remain provided elsewhere rather than claiming a disconnect.
Duplicate rows in the registry
The same npx -y rea-agents@6.5.0 mcp command once appeared as five rows (four rea-cursor + one rea): an older merge renamed conflicting imports to name-<source>, and because serverName made the renamed row look like a different record, the name-based orphan cleanup skipped it forever. collapseDuplicateRows(registry) now folds "name + command contract" into one row before the merge writes: the survivor is the row carrying serverName (the hardest field to re-derive), else one flagged providedExternally, else the first; enabled comes from the surviving row (folding must not change whether a server runs); about/updateCheck/updatedAt take the newest and importedAt the earliest. Folding runs at load time, around the merge, and the scan reply reports collapsed: [{name, dropped}]. This is distinct from the cross-plugin duplicate above: rea (the harness patch) and rea-cursor (this plugin's mount of the same command) are two different configurations, and this plugin will not delete either on the user's behalf.
HTTP API (used by the panel; Connection-authenticated)
GET /servers, GET /tools, GET /status, GET /events?limit=,
GET|POST|DELETE /auth, GET|POST /census (read status / {action:'start'|'stop'}),
GET|POST /install-evidence (the recovery walk: read status / {action:'start'|'stop', force?}),
GET|POST /updates, POST /update,
GET|POST /catalog?query=&source=&limit=&refresh= (source=census is the view over the local census index, see round 24),
GET|POST /sources, POST /sources/remove, POST /scan,
POST /install, POST /configure, POST /set-enabled, POST /set-source-enabled, POST /remove,
POST /forget-orphans, POST /favorite, GET /favorites,
GET /repo-stats?repos=owner/name,... — all under /api/overall-mcp/.
Status-code layering (lib/http-status.js, unit-tested): unknown server/unknown source → 404; a missing parameter, no installable transport, is not launched through a package runner, a value that is present but unusable (on must be a boolean), an illegal verb out of an enum (unknown census action, unknown evidence action), a verb sent on a GET (action must be sent as a POST) → 400; ambiguous server name, stale entry, manual entry → 409; an upstream version lookup failure → 502; everything else → 500 with a log.warn line (method, URL, message) — 500 means "we did not anticipate this path", which used to share 400 with "you typed something wrong".
Bodies: /install is { item, overwrite? }; /update, /remove, /set-enabled are { name, slot?, from? } (409 without slot/from when the name is ambiguous); /sources is { url, label? }; /sources/remove is { id }.
/install's reply separates the two facts: server is the server name and installed is a boolean with the same meaning it has on a catalog entry (renamed in round 20 — one word used to carry two meanings). Then connect ({ok:false, error} means the entry was written but did not connect, with counted:false), optionally followed by diagnosis, diagnosisSkipped or diagnosisError.
Favourites
POST /favorite with {"key":"<source>:<id|title>","on":true|false} writes the desired end state; without on it falls back to toggling (the older behaviour), and a non-boolean on is an error rather than a toggle. The key is the card's React key — official Registry ids move with versions, so source + title is the stable identity. Favourites live in registry.favorites and /catalog marks each entry. GET /favorites → {"favorites":[...],"count":N} exists because /catalog only marks what the current query returned: "what did I favourite?" needs an independent answer, and the panel's Favourites only (N) filter says how many the current source/search is hiding.
/catalog caching
Two layers (lib/catalog-cache.js): the URL layer (per-URL ETag + body, If-None-Match → 304 reuses it without re-parsing; LRU 64 URLs / 8MB) and the source layer (60-second TTL keyed by source|query|cap, falling back to the previous result on failure with stale set). The cache is passed in explicitly, the query string is truncated to 200 characters before it enters the key (otherwise a caller can grow the key space without bound), and a manual refresh skips the TTL while still using the conditional request — a refresh is usually one 304, and the meta line says N sources unchanged.
counts[id] and sources[id].merged are post-merge counts while sources[id].items remains what that source walked; deduped is how many rows were folded away, and a source that answered empty without error is 0 rather than absent.
Host locations scanned
| Application | Location (per platform) |
|---|---|
| Claude Desktop | %APPDATA%/Claude/claude_desktop_config.json, ~/Library/Application Support/Claude/..., ~/.config/Claude/... |
| Claude Code | ~/.claude.json, ~/.claude/settings.json |
| Cursor | ~/.cursor/mcp.json |
| VS Code (Copilot) | .../Code/User/mcp.json |
| Windsurf | ~/.codeium/windsurf/mcp_config.json |
| Codex CLI | ~/.codex/config.toml ([mcp_servers.*]) |
| Gemini CLI | ~/.gemini/settings.json (mcpServers) |
Only these whitelisted paths are read: no recursive directory scanning, no telemetry.
Market sources (measured 2026-10)
| Source | Endpoint | Notes |
|---|---|---|
| Official Registry | registry.modelcontextprotocol.io/v0/servers | Records are per version (~7,242 records ≈ 2,582 servers), deduplicated by isLatest, falling back to updatedAt then version. Install data comes from packages (npm → npx, otherwise uvx) or remotes (streamable-http) |
| Smithery | registry.smithery.ai/servers | displayName, useCount, iconUrl, verified; installs via npx -y @smithery/cli run <qualifiedName> |
| Docker MCP Catalog | hub.docker.com/v2/repositories/mcp/ | ~245 images, docker run -i --rm mcp/<name>, pull_count as popularity. Anonymous reads only page one: page 2 returns 403 pagination offset too large for anonymous requests; sign in to page further. That is an answer about who is asking, not a fault, so the source is not degraded — it returns the 100 rows it has with a note, and that 403 is never retried (a real 403 rate limit still is) |
| GitHub MCP | api.github.com/search/repositories (three queries, with a User-Agent) | A discovery source: 100 per query, three queries, deduplicated by owner/repo, top 200 by stars. The search response carries no install information, so install: null. With a census index present it reads the index instead — zero requests, up to 800 rows, keywords filtered over the whole census |
| Awesome remote | raw.githubusercontent.com/punkpeye/awesome-remote-mcp-servers/main/README.md | Hosted endpoints: the backticked URL installs as streamable-http |
| Awesome list | raw.githubusercontent.com/punkpeye/awesome-mcp-servers/main/README.md | Community index (~4,224 rows parsed); discovery + repository link only, because a launch command cannot be inferred safely |
PulseMCP's public API has sunset (
v0betareturns 410API_SUNSET), so its adapter is kept but disabled by default; Glama needs an API key and is not included.npm searchwas evaluated and rejected (unauthenticatedpopularityis a constant, so it cannot separate real MCP servers from packages that merely mention mcp), and the baretopic:mcp-serverquery mixes in n8n and gemini-cli, which is why the second query additionally requiresmcpin the description.Cross-catalog merging uses the GitHub repository as the shared identity: the same repository from several catalogs keeps one row (preferring the installable one) and records all sources in
sources. Docker images and remote endpoints stay independent.
Data files
registry.json— the authoritative registry. Written through a temp file +rename; if it fails to parse the original is copied toregistry.json.corrupt-<timestamp>and the panel is told (registryError) instead of treating the state as a first run.installs.json— this market's install counts, incremented only after a successful mount.scan-report.json— the last scan report.events.ndjson— the action log (last 400 lines / 256KB). Metadata only; deleting it loses nothing but the log.github-auth.json— the pasted GitHub token (0600). The only file holding a plaintext credential: delete it (or press Clear) to return to anonymous immediately.github-install.json— commands recovered from repository evidence, each with the file it came from and the registry where the package was confirmed to exist. Derived data: deleting it just re-walks.github-index.json— the census index ({version, at, minStars, queries, requests, complete, partial, rows}withfirstSeen/lastSeen). Written every 5 seconds while walking, so an interrupted run still leaves the rows it received. Derived data: corrupt → warn and treat as "no index" (not quarantined, not backed up).- Catalog listings are cached per source for 60 seconds (the raw data only; "installed" is recomputed on every read), and upstream URL bodies +
ETags are stored in a second layer. Star lookups cache for 6 hours.
Security
- The scanner reads whitelisted files only; stdio
envreferences variable names rather than expanding host secrets. - A catalog entry's launch command and source are visible before it is installed, and the user confirms.
- GitHub credentials (pasted token /
DSH_GITHUB_TOKEN/GITHUB_TOKEN/gh auth token) are read in plaintext only insidelib/github-auth.jsand sent, by host allowlist, only toapi.github.comandraw.githubusercontent.com— Docker Hub, npm, PyPI, Smithery and the official registry never see a byte of it. The panel,events.ndjsonand/statussee only the mask and the login. - A local plugin: it exposes no external HTTP surface.
Development
npm test # unit tests (lib/ is dependency-free)
npm run test:registry # validate the published registry.json
npm run test:probes # render probes: build the real page and assert on the DOM
npm run test:layout # needs a real Chromium via CHROME_PATH (local only)
npm run test:live # needs a base URL + token (local, live instance)
npm run verify:ci # lint + test + test:registry + test:probes
npm run verify # verify:ci + test:layout
npm run lint is a syntax check of every source file, and npm test is an explicit file list — a new test file that is not listed is silently skipped.
Individual probes worth knowing:
node tools/client-smoke.mjs # client registration contract; pins CLIENT_VERSION == package.json == newest CHANGELOG section
node tools/render-detail.mjs # detail panel: facts, links, command, and the copy button really reaching the clipboard
node tools/render-card.mjs # card: title is the link, body is not a button, no nested controls, command only in detail
node tools/render-market-tab.mjs # whole market chrome: header, identity line, log box, tabs, search, filters, meta, cards, footer
node tools/probe-server-about.mjs # purpose line placement, clamping, evidence-labelled title, no empty element without evidence
node tools/verify-css.mjs # every className has a rule in the injected stylesheet
node tools/verify-sort-paging.mjs # 6 sorts, 3 page sizes, out-of-range clamping, compact counts
node tools/verify-detail-links.mjs # detail links: per-source coverage, zero dead ends, zero duplicate URLs
node tools/verify-group-row.mjs # the chip row's show/hide decision
node tools/registry-submission.mjs --check --registry registry.json
node tools/probe-favorites.mjs <token> # live favourite round trip
node tools/measure-sources.mjs # per-source upstream counts (pagination and truncation, measured)
node tools/probe-mcp-schema.mjs -- <cmd> # inspect an MCP server's tool schemas
Two render probes read the live catalogs, so they exit 2 (not 1) when no source answers: with every source down their assertions are vacuously true, and a probe reporting "0 dead ends" over 0 entries is exactly the lie those files exist to prevent. Exit 2 means "the network was unreachable", and the CI matrix therefore cannot read an offline run as a pass.
| File | Responsibility |
|---|---|
lib/scanner.js | host config scan and normalisation; registry merge (manual entries keep their name, a changed contract updates in place, byte-identical rows are skipped) and duplicate-row folding |
lib/catalog.js | the six remote catalogs: fetch, normalise, merge, sort, GitHub supplement; the GitHub search source and rate-limit retry; custom-source parsing; the process-level anonymous search-quota state |
lib/github-index.js | the census: star-window queries, throttled paging, cross-window dedup, merge with the previous index (30-day sliding window), freshness text, named stop reasons, and a progress payload every 5 seconds |
lib/repo-install.js | install-command recovery from package.json bin / pyproject.toml scripts / server.json / fenced README commands, each confirmed against npm or PyPI, with its source named |
lib/catalog-cache.js | the two cache layers (ETag/304 reuse, 60-second source TTL) and forgetSourceId |
lib/custom-sources.js | custom source documents: https-only validation, id derivation, label inference, add/remove with tolerance for a corrupt file |
lib/submission.js | the submission direction: parse an issue body, validate, dedupe key, pre-filled issues/new URL |
lib/install-diagnosis.js | diagnosis text: system prompt, fact extraction (never env/headers), credential redaction, reading the model's answer |
lib/about.js | "what is this MCP for": one sentence from instructions / registered tools / launch command, with markdown stripping and evidence labels |
lib/http-status.js | internal errors → HTTP status codes |
lib/events.js | the action log: emit, serialised bounded appendEvents, tail readEvents, eventFileFacts |
index.js | the cordis host plugin: registry, one ctx.plugin(mcpClient) per server, defineTool management tools, HTTP routes |
client/client.js | the __ModuleLoader__ client plugin: the sidebar rail entry and the panel itself |
Published package contents
package.json has a files allowlist: index.js, lib/ (17 .js files), client/client.js, cordis.patch.yml, registry.json, README.md, README.zh-CN.md and CHANGELOG.md. Everything else (tools/, test/, prototype/, INTEGRATION-TODO.md, .github/) is development material that no runtime path reads: index.js reads the registry from the data directory, not the repository root.
FAQ
The market shows HTTP 404 or stays on "discovering…"
Host plugin code is loaded once at DSH startup. If the plugin was updated while DSH was open (common with a link: install into a workspace), the front end is new and the host is old, so /api/overall-mcp/catalog does not exist yet.
- Quit DSH completely (on desktop, check the tray icon — closing the window is not quitting) and reopen.
- A newer page says "host plugin is an older version (missing /api/overall-mcp/catalog)" rather than only showing 404.
- Check the header's second line: a stable
pidwith a rising uptime is that same process, and the⚠ code updated, restart DSHmarker next to it is the page recognising the situation itself.
I changed the plugin's code and the panel behaves the same
Same cause: the host loads once. plugin.source.stale in GET /status is the machine-readable version of that comparison, plugin.source.files lists each file's mtime and size, and plugin.dir is the directory the host actually loaded — which, with a link: install, is not the copy in node_modules.
Why does a group switch take several seconds?
Because every row in that source really does spawn a dsh-mcp-client subprocess and complete a handshake; it is not a boolean. Before round 20 this was serial, worst case members × 8s (40 seconds measured for five members); now at most four are in flight, so five members take about two waves. "switching…" is that work's progress, not a hang. The same round guarantees one mount per name: duplicate same-name rows used to each attempt a mount and the loser was recorded as "provided by another mcp-client", an artefact of the concurrency itself.
I only want the repositories this machine already censused, without spending requests
Pick local census (index) in the market page's source filter (right beside GitHub):
- It reads the local census index and spends no requests; an old index is not quietly re-walked — refreshing it is the start verb on the GitHub card.
- It is not a new source: counts still belong to GitHub (one upstream, one number), and only the name on the numbers line follows your choice (
local census (index) 1.8k), so the filter and the line cannot disagree. - With no index yet it says so and points at start on the GitHub card; the view is read-only and will not start anything for you.
- Command line equivalent:
POST /catalog {"source":"census"}.
I want to run the install-command recovery now, or stop it
Same card, one line below the census: it states tried N repositories · recovered M install commands and offers resume recovery or stop recovery.
- This walk reads
package.json/pyproject.toml/server.json/ README from each repository overraw.githubusercontent.com(no API budget), paced at 150ms per request, capped at 6000 requests / 25 minutes. - Stop recovery takes effect between two requests: the repository being read finishes, everything already recovered stays in
github-install.json, and the line saysstopped by hand (tried N repositories, continues next time). - The command-line equivalent is
POST /install-evidencewith{"action":"start"|"stop"}({"force":true}skips the freshness check);GETon the same route only reads status and triggers nothing. - A
disposestop (the plugin was unloaded) is recorded as a different reason thanuser, because "I pressed it" and "the host was unloaded" are not the same fact.
The census stopped halfway ("request N failed")
That is GitHub's quota or a network flap, not a broken plugin. Anonymous allows 10 searches/minute and 60 core requests/hour; a full census reads files from thousands of repositories and will not finish anonymously.
- Paste a token in the GitHub access card (or press use my GitHub login) to raise the budget to 5,000/hour.
- You can also just continue: pressing census again resumes from the breakpoint, because rows that already have evidence are skipped. The recovery walk below it works the same way.
GET /status(or the census line in/events) reportsplanned / attempted / remaining / retries— how far the walk got and how many retries it needed.
I entered a token and the panel still says "anonymous"
Only a validated token becomes active (checked once against GET /user), so a mistyped token falls back to anonymous instead of turning every request into a 401. The card's status line distinguishes 401 (invalid or expired) from an unreachable network. A pasted token needs no restart, but the /auth route itself only exists after a restart if the running host predates it.
License
MIT
Comments
Loading…
Similar plugins
by chenbin-dev
Scans MCP servers configured in Claude Code, Codex and CodeBuddy and probes real connectivity via stdio and streamable-http initialize handshakes, with enable/disable toggles and a /mcp control panel.
★ 1
↓ 302/wk
MIT
JavaScript
Aug 26, 2026
dsh plugin --profile web add dsh-scan-mcpDeepSeek Harness bundle: manage MCP servers — list, probe, add from pasted project references, toggle, remove, rotate credentials. Official dsh-mcp-client contract compliant.
★ 0
NOASSERTION
JavaScript
dsh plugin --profile web add @dsh-contrib/dsh-mcp-managerby GeekRicardo
Exposes the DSH dynamic-Cordis toolset to Claude Code over MCP (streamable HTTP on the existing web port, no new listener): ten tools to list, inspect, define, run, stop and permanently remove dynamic
★ 1
MIT
JavaScript
Oct 10, 2026
dsh plugin --profile web add dsh-cordis-mcpby appthin
MCP management page in DSH Settings: list, enable/disable, restart and delete MCP servers, edit them in form or JSON mode, expand a server to see the tools it registers, and paste-import MCP configs f
★ 11
↓ 575/wk
MIT
JavaScript
Oct 15, 2026
dsh plugin --profile web add dsh-mcp-manager-plusDSH 设置面板:一个页面管理技能(SKILL.md)与 MCP 服务器,全局 / 项目级分层,改完即生效。Manage skills and MCP servers from one DSH settings panel.
★ 2
↓ 453/wk
MIT
JavaScript
dsh plugin --profile web add @j62268781-alt/dsh-skills-mcp-managerby PerryLink
MCP management console for the official DeepSeek Harness MCP client: /mcp command with health diagnostics and pipeline trial calls, a Settings MCP tab with server CRUD (approval-gated writes, automati
★ 77
Apache-2.0
TypeScript
Oct 19, 2026
dsh plugin --profile web add dsh-mcp-panel