dsh-skill-mcp-panel
A Skill list and MCP server manager for DeepSeek Harness, mounted as a sidebar panel and themed entirely from the host's own design tokens.
One sidebar entry — 技能与 MCP — opens a full page with two dense lists: every Agent skill this deployment serves, and every configured MCP server. Each row is a single line: name, a short description, a switch, and a 详情 toggle that expands in place. MCP rows can also be edited, created, and deleted, and both lists can reveal the directory the thing lives in.
The page carries no theme code. Every colour, radius, font, and focus ring comes
from the host's own --dsw-* tokens, and its type follows the host's own font
size, so it tracks the deployment theme (light, dark, or a third-party theme) and
the Settings → Appearance → Font size setting automatically.
Typography
dsh-client-ui-layout projects the content font size onto body as an inline
variable and derives a delta from it:
--dsh-content-font-size: 14px; /* 12–17, the user's setting */
--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px);
Every size on this page is expressed the way the conversation surfaces express
theirs — <base>px + var(--smp-delta) — where --smp-delta aliases
--dsh-content-font-delta. Nothing is a fixed pixel size, and a test asserts
that, so the panel grows and shrinks with the setting exactly as the transcript
beside it does.
The two lists
Skills
The catalog visible to the current session composition, so project- and preset-scoped skills appear beside bundled ones.
| Control | Behaviour |
|---|---|
| Name | the /name identifier, plus its discovery source, plus a policy tag only when the policy is not the default |
| Description | clamped to one line; the full text stays in the tooltip |
| Switch | enables or disables the skill |
| 详情 | provider, source, path, the invocation policy spelled out, the full body, and 打开目录 / 删除 |
A skill has two independent invocation flags, both on by default:
| Flag | Meaning |
|---|---|
modelInvocable |
the skill is in the model's catalog, so the Agent may load it on its own |
userInvocable |
the skill is in the composer's / menu, so you can invoke it by typing /name |
Because both are on for almost every skill, tagging them everywhere says nothing. The row tags only the surprising cases, and the detail panel spells the policy out in a sentence instead of leaving two words to interpret.
How the switch works
Switching a skill off is maintained by this plugin, not by editing files. DSH has no per-skill switch: a skill exists because a provider lists it, and each provider decides its own invocation policy — the shipped Office provider hard-codes both flags and ignores frontmatter, so no file edit could ever disable those skills.
The lever that works for every provider is the registry's own duplicate rule:
within a layer, the candidate with the lowest rank wins the name. This plugin
therefore registers one provider that re-declares each switched-off skill with
rank: 0 and invocation: { modelInvocable: false, userInvocable: false }. That
removes it from the model catalog and the human / menu while keeping it visible
here — with its original description, provider, and body — so it can be switched
back on.
The set lives in one JSON file, <DSH home>/storages/skill-mcp-panel.json, and
every change invalidates the registry's catalog cache.
Removing a skill
删除 really removes the file — it is not another way of switching off. A
directory bundle is the whole <root>/<name>/ directory, so its references and
scripts go with it; anything else is the single file. The confirmation says which
of the two removal modes applies before anything happens.
| The skill comes from | Mode | What happens |
|---|---|---|
the user — project-dsh, project-agents, user-dsh, user-agents, custom |
permanent | the file is deleted outright; nothing is kept |
the deployment — bundled and anything else |
parked | the file leaves its root but is moved to the trash and listed under 已删除, where 恢复 puts it back |
The split follows the registry's own source bucket, which is what says who owns
the file. Your own skills are yours to delete, so they are deleted. A shipped skill
is different: removing one destroys a file an upgrade owns and an install would
silently restore, and the switch cannot disable the Office skills at all. Parking
those keeps the one lever that does work reversible.
A parked skill goes to <DSH home>/storages/skill-mcp-panel.trash/<id>/ with a
manifest.json naming its original path. Restoring refuses to overwrite a path
that has since reappeared, and a trash id is validated as a single path segment,
so nothing outside the trash can be addressed.
Removal is always two steps: the action row is replaced by a confirmation naming the skill, and only the confirmation's 删除 calls the host.
MCP servers
One row per configured server.
| Control | Behaviour |
|---|---|
| Namespace | mcp__<serverName>__*, with tags for status, transport, and tool count |
| Target | the command …args line, or the endpoint URL |
| Switch | writes the row's disabled flag in the profile patch, exactly as the deployment's own plugin list does |
| 详情 | entry id, plugin, transport, redacted configuration, every registered tool, then 打开目录, 编辑, and 删除 |
| 新建服务器 | replaces the list with a create form, so the empty state and the form never share the view |
| 删除 | removes the row from the profile patch, then waits for the Loader to drop it |
The create/edit form
A fixed two-column grid: server name and transport share the first row, then the transport's own fields — command and working directory, or a URL spanning both columns — and arguments and environment variables side by side at three rows each. Everything not needed for the common case (timeout, fail-on-startup) sits behind one 更多选项 disclosure.
Format guidance lives in the controls' placeholders, never as prose under them, so no field grows a second line and the columns stay aligned. The form replaces the list while it is open, so the header cannot offer a button the form already is. Editing loads the deployment's own form projection on demand, which is the only per-item read the page performs besides a skill's full body.
Opening a directory
打开目录 reveals a skill's own folder, or the profile directory holding
cordis.patch.yml for an MCP row. It reuses the deployment's own capability rather
than re-implementing platform launching: the page asks GET /open-in-app/apps
which file-manager entry this host resolved (explorer, finder, or
filemanager) and posts {app, path} to POST /open-in-app/open. When the
catalog resolves no file manager — an SSH launch, for example — the control is not
offered at all.
The directory always comes from the host payload, and the host derives it with
dirname from a path it already resolved. The page never invents the path.
Freshness
Nothing polls, and there is no refresh button. The list is read on mount — which happens every time the panel is opened — again when the window regains focus (throttled to one read per four seconds), and after every write the page performs. The controls that mutate anything re-read the list themselves, so the page never shows a state the deployment has already left.
Install
dsh plugin --profile desktop add <path-to-this-directory>
or, from an Agent session, plugin_manager with action: install_bundle and the
absolute path of this directory as the target.
A host-code change needs a process restart. The deployment composes the browser
half from dsh.client and hot-reloads its bundle, so a page refresh is enough for
UI changes; the Node half is imported once into the process's ESM cache, so a new
lib/index.js takes effect only after the application restarts.
Publishing to npm
This repository is the plugin's home; npm is an optional second channel.
npm run publish:npm handles it, and it exists because two things about this name
are easy to get wrong.
The bare name is taken. dsh-skill-mcp-panel on npm belongs to an unrelated
DSH plugin — Fishquito7/dsh-skill-mcp-panel,
which covers the same ground with more features and is actively maintained. An
unscoped publish fails with a 403, and adding a confusingly similar neighbour would
be worse than not publishing at all. So the script publishes under the scope of
whoever is logged in, which cannot collide with anyone.
A mirror cannot accept a publish. A registry= line in ~/.npmrc is often a
read-only mirror, so the script always targets registry.npmjs.org explicitly.
npm login --registry=https://registry.npmjs.org/ # once; a website login does not count
npm run publish:npm
Signing in at npmjs.com is not the same as signing in the CLI: npm keeps the
browser session and the command line separate, and a token has to land in
~/.npmrc. The script says exactly this if it finds no token.
The package name is swapped in for the duration of the publish and restored
afterwards — even when the publish fails — so the repository keeps its own name.
prepublishOnly runs the test suite as a gate, and the version has to be bumped
(npm version patch) before publishing again.
Layout
| Path | Role |
|---|---|
package.json |
Declares dsh.bundle.patch and the dsh.client browser half |
cordis.patch.yml |
The bundle patch that inserts the Node half into a profile |
lib/index.js |
Node half: the /skill-mcp read/write surface |
lib/client.js |
Browser half: the sidebar.panellist entry and the main page |
test/host.test.mjs |
Node-half tests over a fake host, validating writes as real YAML |
test/client.test.mjs |
Browser-half render tests over a minimal hook-aware renderer |
There is no build step. lib/ is the authored source and the shipped artifact, and
the browser half is hand-authored in the module-table format
(window.__ModuleLoader__.load({ id, factory })), so no bundler is required and
pnpm never has to run a dependency build script. There are no runtime
dependencies.
Tests
npm test
Node's built-in test runner, 83 tests, no dependencies. The host suite drives the real route handler over a fake host context whose config editor parses the profile patch with a real YAML reader, so every emitted document is validated by a parser other than the writer that produced it. The client suite renders the browser half through a minimal hook-aware renderer and asserts on the resulting tree.
If no YAML reader is installed anywhere, the host suite still runs; it just stops
checking emitted documents with an independent parser. Point DSH_YAML_MODULE at
one to restore that layer.
Host API
Everything lives under one prefix route. Every request first passes the
deployment's own trust fence, ctx.connection.requestRejection() — the same
Host/Origin and browser-authentication check every shipped host route uses
(/open-in-app included). A composition without a connection service falls back
to a Sec-Fetch-Site/Origin check.
| Route | Method | Purpose |
|---|---|---|
/skill-mcp/state |
GET | the merged snapshot; ?bodies=0 skips body previews, ?session=<id> scopes the skill read |
/skill-mcp/skill |
GET | one skill's full definition, used only to enrich its body |
/skill-mcp/skill/enabled |
POST | { name, enabled } — writes the enablement overlay |
/skill-mcp/skill/delete |
POST | { name } — deletes a skill the user owns, or parks a shipped one |
/skill-mcp/skill/restore |
POST | { trashId } — puts a parked skill back |
/skill-mcp/mcp/server |
GET | one server's form projection, loaded on demand by the edit form |
/skill-mcp/mcp/save |
POST | { entryId, config } — persists through ctx.configEditor.edit() |
/skill-mcp/mcp/create |
POST | { config } — appends a row to the profile patch |
/skill-mcp/mcp/enabled |
POST | { entryId, enabled } — writes the row's disabled flag |
/skill-mcp/mcp/delete |
POST | { entryId } — removes the row from the profile patch |
Only the detail view's body enrichment and the edit form use a per-item route; the list payload already carries everything else, so expanding a row can never show an empty panel.
The Node half requires webServer, connection, skills, and tools, and reads
the optional agents, agentPresets, configEditor, and hmr services through
ctx.get(...). The skill read resolves its scope in this order: a session named by
?session=, the newest live root Agent (with its workspace cwd), the default
Agent preset's standing scope, then the global layer alone; the resolved source is
reported as skills.scopeSource.
How writes stay safe
- Configuration edits go through
ctx.configEditor.edit(), which validates the next value against the running plugin, reconciles the Loader, and rolls back on failure. - Row creation, the
disabledflag, and row removal are text patches of the profile's own patch file, serialized throughctx.hmr.runExclusiveand written atomically. The Loader is then polled: if the row does not mount, the flag does not apply, or a removed row stays mounted, within six seconds the previous file bytes are restored and the call fails with the reason. - Skill enablement writes only this plugin's own overlay document and invalidates the registry's catalog cache. No skill file is edited.
- Skill removal never follows a path the page supplied; the host resolves the skill, derives the target with its own rules, and validates a trash id as a single path segment.
- Credentials never reach the browser. The list and detail payloads replace every credential-shaped value with a keep marker; the form shows those fields blank and saving a blank marker restores the stored value. A brand-new server has nothing to restore, so a blank credential field is simply dropped.
License
MIT
No comments yet. Be the first to write one.