dsh-mcp-apps
Renders MCP Apps as sandboxed interactive iframes in DeepSeek Harness (DSH) web chat.
What is this?
MCP servers can return more than text: the MCP Apps extension (SEP-1865) lets a tool result carry a ui:// resource — a small, self-contained web app such as a dashboard, a map, or a form. By default, DSH doesn't know what to do with these and just shows the raw tool result. dsh-mcp-apps is a Cordis 4 plugin for DSH that recognizes these ui:// resources and renders them as live, interactive apps directly inside the chat turn, in a sandboxed iframe. The app can also call back into the host to run more tools (e.g. a "Refresh" button), gated behind an explicit, per-server approval policy.
Features
- Renders MCP Apps (
ui:// resources) as interactive dashboards, charts, maps, and forms right inside DSH chat.
- Every app runs sandboxed in an iframe with an auto-generated Content Security Policy — no
allow-same-origin, no ambient access to the host page.
- Connects to any MCP server over stdio, SSE, or Streamable HTTP, with automatic reconnect.
- Apps can call back into host tools (e.g. a "Refresh Data" button), off by default and configurable per server (
deny / approve / allow).
- Rendered apps carry an
Interactive App badge and resist DSH's auto-collapse behavior, so they don't fold away when the model finishes streaming.
- Handles secrets safely:
${VAR} expansion for env vars and headers blocks DSH-internal and secret-shaped variable names unless you explicitly allow them.
- Registers host-side MCP tools (without the iframe UI) on headless DSH hosts too: the plugin's
inject list (tools, connection) has no hard webServer requirement, so tool calls still work even where there's no browser to render into.
Requirements
- Node.js >= 22
- DSH (DeepSeek Harness)
>= 0.1.7-rc.2 with Cordis ~4.0.4
- The web profile, for the iframe UI. The plugin's client bundle is injected only when
dsh.client.platform is web (see package.json), so its UI does not render in other profiles (e.g. tui, headless) even if the plugin is installed there — but the plugin's host-side tools still register on those profiles (see Features).
The packed plugin has been installed and exercised in a clean DSH 0.1.7-rc.2 web profile, including iframe rendering and delivery of the fixture tool input/result. Other DSH/Cordis combinations remain unverified; see the compatibility record.
Compatibility
DSH checks a plugin's @deepseek-ai/dsh-* peer ranges against its own runtime version (evaluatePluginCompatibility) and refuses to load an incompatible plugin. Match your DSH version to a plugin release:
dsh-mcp-apps | Requires DSH |
|---|
0.2.2 and later | >= 0.1.7-rc.2 |
0.2.1 and earlier | 0.1.5-rc.x |
See docs/dsh-compatibility.md for how the check works and why an exact-pinned peer (no range operator) fails against a later prerelease.
Install
Via the DSH Harness UI
-
Open DSH Harness and click Plugins in the sidebar.
Plugins nav
-
Click + Add plugin, then enter one of the following and click Install:
- GitHub repository address:
https://github.com/katticot/dsh-mcp-apps
- Package name:
dsh-mcp-apps (Official npm registry)
- Local plugin directory: absolute path to your clone
Add plugin dialog
Via the CLI
pnpm dlx @deepseek-ai/dsh@0.1.7-rc.2 plugin --profile web add dsh-mcp-apps
This forwards to pnpm inside your DSH profile directory ($DSH_HOME/profiles/web), not your current project — it adds dsh-mcp-apps to that profile's own package.json. Don't run a plain pnpm add dsh-mcp-apps in an unrelated project and expect it to do anything for DSH.
dsh-mcp-apps ships as a DSH bundle (package.json#dsh.bundle): installing it also activates it, whether you install it through the UI or the CLI above. The package's own dsh/cordis.patch.yml inserts a single id: mcp-apps row with an empty server map, so the plugin is loaded (with no servers configured yet) as soon as it's installed — you don't hand-write that insert: yourself, and you shouldn't (see Configure below).
Configure
The plugin is already loaded with an empty servers: {} by the bundle patch above. To add servers, patch that same row from your own profile's patch file (e.g. ~/.dsh/profiles/web/cordis.patch.yml) — a top-level entry (no insert:) that targets the existing id: mcp-apps:
- id: mcp-apps
config:
# Required for any `oauth`-enabled server below: this DSH host's own
# externally reachable base URL, used to build the OAuth redirect_uri.
# It is never derived from an inbound request (a request's Host header
# is caller-controlled and cannot be trusted for a value baked into
# dynamic client registration) — set it explicitly to match however this
# host is actually reached (localhost, a LAN address, a reverse proxy...).
externalUrl: 'http://localhost:PORT'
servers:
# A remote server speaking Streamable HTTP or SSE
my-server:
transport: streamable-http
url: 'https://your-mcp-server.example.com/mcp'
# An OAuth-protected remote server, proxied through mcp-remote
my-oauth-server:
transport: stdio
command: npx
args: [-y, mcp-remote@0.1.37, 'https://your-mcp-server.example.com/mcp']
# An OAuth-protected remote server, handled natively (no mcp-remote)
my-native-oauth-server:
transport: streamable-http
url: 'https://your-mcp-server.example.com/mcp'
oauth: true
oauth: true enables native OAuth for sse/streamable-http servers: dynamic client registration (RFC 7591) plus the authorization-code + PKCE flow, with the "Connect Account" button in the plugin's status UI kicking off the browser-facing part. It's remote-transport only — stdio servers keep using the mcp-remote proxy above. oauth requires the top-level externalUrl to be set, and also accepts an object to configure a statically registered client instead of DCR:
my-static-oauth-server:
transport: streamable-http
url: 'https://your-mcp-server.example.com/mcp'
oauth:
clientId: 'my-client-id'
clientSecret: '${OAUTH_CLIENT_SECRET}'
scopes: [read, write]
Tokens (and any dynamically registered client) are stored per server in ~/.dsh/mcp-apps/oauth/<server-name>.json (plaintext, 0600/0700 permissions — the same access-controlled-not-encrypted trust model this plugin already applies to headers/env secrets).
A patch replaces the targeted row's whole config — it doesn't merge — so restate every key you want (including defaultTimeoutMs and every server, not just the one you're adding or changing).
Do not wrap this in insert:. insert: creates a new row; since the bundle already inserted one id: mcp-apps row for you, inserting a second one gives you two plugin instances both registering the same /api/mcp-apps/* RPC routes, which throws (connection: exact Fetch route "..." is already registered) — see Migrating from 0.2.x if you have an older insert:-based entry lying around.
A fuller example, showing environment expansion, headers, and reverse tool-calls:
- id: mcp-apps
config:
defaultTimeoutMs: 30000
servers:
# Local stdio subprocess (env values support ${VAR} / ${VAR:-default} expansion)
local-tool:
transport: stdio
command: go
args: ['run', 'main.go']
cwd: '/path/to/mcp-server'
env:
DATABASE_URL: '${DATABASE_URL}'
toolCallTimeoutMs: 45000
allowAppToolCalls: approve
# Remote SSE server with an auth header
remote-analytics:
transport: sse
url: 'https://mcp.example.com/sse'
headers:
Authorization: 'Bearer ${MCP_TOKEN}'
allowedVars: [MCP_TOKEN] # secret-shaped vars are blocked unless listed
reconnectOptions:
maxRetries: 10
Migrating from 0.2.x
Before 0.2.3, this package wasn't a bundle — you activated it yourself with an - insert: - id: <anything> \n name: dsh-mcp-apps entry in your profile's cordis.patch.yml. Since installing now activates the plugin automatically (via the package's own bundle patch), that old insert: entry is a second, duplicate instance and must be removed:
- Delete your old
- insert: block that named dsh-mcp-apps (whatever id you gave it) from your profile's cordis.patch.yml.
- Replace it with a bare patch entry targeting
id: mcp-apps (no insert:), restating your servers config as shown above.
- Run
npx @deepseek-ai/dsh --profile web --dump-config and confirm exactly one row with name: dsh-mcp-apps appears. Two rows means duplicate servers and a crash on load (see above) — remove whichever insert: you added.
Run & verify
After saving cordis.patch.yml, restart the web profile so the loader applies the plugin entry:
pnpm dlx @deepseek-ai/dsh@0.1.7-rc.2 web
Call a tool on a configured server that returns a ui:// resource. Its tool input and result render in a sandboxed iframe with an Interactive App badge. App callbacks follow that server's allowAppToolCalls policy.
Reverse Tool-Call Security Model
Rendered apps can call back into host tools (e.g. a "Refresh Data" button). That path is locked down by default:
- Session tokens: every
tools/call request from an app must carry a valid AppSessionStore session token, scoped to the tool names and server it was issued for. A missing, expired, or mismatched-server token is rejected before the tool ever runs (src/index.ts).
allowAppToolCalls (per server, default false/'deny'): a 'deny' | 'approve' | 'allow' setting (a plain boolean is also accepted as legacy shorthand for false/'allow').
'deny' (default): no tools are exposed for reverse calls from that server's apps.
'allow' / true: reverse calls are permitted without approval.
'approve': each reverse call is routed through ctx.approval (@deepseek-ai/dsh-user-approval) and requires the originating agent to have an open turn (agent.status === 'running'); outside of that window, or if the approval/agent services aren't available, the call fails with unavailable rather than silently allowing or hanging.
- App-only / model-only visibility: tools marked app-only (
isToolVisibilityAppOnly) are never registered with the LLM's tool list, and tools marked model-only (isToolVisibilityModelOnly) are excluded from the set an app is allowed to call back into — each direction is a one-way gate.
Configuration Reference
| Option | Type | Default | Description |
|---|
servers | Record<string, ServerConfig> | {} | Map of server identifiers to transport configs. Names cannot contain __ or end with _. |
defaultTimeoutMs | number | 30000 | Fallback timeout in ms for tool calls (must be >= 1). |
externalUrl | string | — | This DSH host's own externally reachable base URL (http(s)://...), used to build the OAuth redirect_uri for any servers.<id>.oauth-enabled server. Required if any server has oauth set; never derived from an inbound request. |
servers.<id>.transport | 'stdio' | 'sse' | 'streamable-http' | (Required) | Transport mechanism. Only transports defined by the MCP specification are supported: stdio, streamable-http, and sse (deprecated upstream, kept for older servers). |
servers.<id>.command | string | — | Executable binary path (for stdio). |
servers.<id>.args | string[] | [] | Command arguments (for stdio). |
servers.<id>.cwd | string | — | Working directory (for stdio). |
servers.<id>.env | Record<string, string> | |
${VAR} / ${VAR:-default} expansion only applies to env and headers values — not to url, args, or cwd. $$ is an escaped literal $. A default value cannot itself contain a nested ${...} expansion (e.g. ${MISSING:-${PORT}} is taken literally, not expanded recursively).
Troubleshooting
- Tools show up twice: running
@deepseek-ai/dsh-mcp-client against the same MCP server alongside this plugin registers that server's tools through both plugins, duplicating them. Use one or the other for a given server.
${VAR} isn't expanding: DSH_*-prefixed and secret-shaped (KEY/PASSWORD/SECRET/TOKEN) variable names, plus SSH_AUTH_SOCK/GPG_AGENT_INFO, are blocked by default — add the name to that server's allowedVars.
- The app's button does nothing:
allowAppToolCalls defaults to deny. Set it to approve or allow. Under approve, the call also needs an open, running agent turn to prompt for approval — it fails with unavailable outside of that window.
- Remote connection rejected: plain
http:// is only allowed to a loopback host (localhost, 127.0.0.1, ::1); anything else must be https://.
- Nothing renders: the client UI only loads under the web profile (
dsh.client.platform: "web"). It won't render in tui, headless, or other profiles.
- Plugin doesn't seem to load / two
dsh-mcp-apps rows: the plugin activates itself on install (it's a bundle) with an id: mcp-apps row — your own profile patch should target that same id with a bare top-level entry (no insert:, see Configure). An insert: there creates a second row, which crashes on load (duplicate /api/mcp-apps/* RPC route registration); see Migrating from 0.2.x. Run npx @deepseek-ai/dsh --profile web --patch <file> --dump-config to print the composed config and confirm exactly one name: dsh-mcp-apps row appears.
Plugin dsh-mcp-apps@X.Y.Z is incompatible with dsh <version>: peerDependencies {...}: your DSH runtime version doesn't satisfy this plugin's @deepseek-ai/dsh-* peer ranges. Check the compatibility table — dsh-mcp-apps@0.2.2+ requires DSH >= 0.1.7-rc.2; upgrade DSH, or install an older plugin version matched to your DSH release.
- : check that is set ( fails immediately with a clear error if it isn't) and that it matches an address the browser can actually reach back to for the callback. Click again — a stale authorize attempt's PKCE verifier expires after 10 minutes. Make sure the popup isn't blocked by the browser and that it's able to reach both the authorization server and . If the authorization server rejects the callback with a redirect_uri mismatch, changed since the client was registered — clear to force fresh dynamic client registration and try again.
How it works
The plugin bridges a sandboxed iframe and the DSH host over postMessage-based JSON-RPC, manages a pool of MCP server connections, and normalizes tool names/visibility for the LLM. For the full internals — CSP synthesis, the postMessage handshake, tool fingerprinting, the anti-collapse behavior, and the resize circuit breaker — see docs/how-it-works.md.
Development & Testing
# Install dependencies
pnpm install
# Type-check
pnpm typecheck
# Build host and client bundles (tsdown)
pnpm build
# Run Vitest test suite
pnpm test
./client (the browser-side bundle loaded by DSH's window.__ModuleLoader__) is loader-only: it has no types entry in package.json#exports and cannot be imported directly from Node or a bundler — only the . (host) entry ships type declarations.
See CONTRIBUTING.md for the release process.
License
MIT © katticot