#!/usr/bin/env node
/**
* MCP Apps demo server — a zero-dependency MCP server (newline-delimited
* JSON-RPC over stdio) for testing and demonstrating
* @deepseek-ai/dsh-mcp-apps-host.
*
* It exposes two tools that exercise every host capability:
*
* - `demo_interactive` (inline form): the tool RESULT carries
* `_meta.ui.resource.text` HTML. The card renders data injected from
* `structuredContent` (via the `ui/initialize` handshake), refreshes itself
* through `tools/call`, and can stage `ui/update-model-context` + send
* `ui/message` into the conversation.
*
* - `demo_referenced` (referenced form): the tool DEFINITION carries
* `_meta.ui.resourceUri`; the result carries no `_meta` at all. The host
* fetches `ui://demo/referenced-card` through `resources/read` and inlines
* the HTML. The card also demonstrates the `ui://`-only resources/read
* security gate (a blocked URI returns a JSON-RPC error).
*
* Run standalone (any MCP client) or through DSH:
* dsh web --patch demo/mcp-apps-demo.cordis.yml # from the repo root
*
* @module demo/server.mjs
*/
import { createInterface } from 'node:readline'
const SERVER_INFO = { name: 'dsh-mcp-apps-demo', version: '0.1.0' }
const REFERENCED_URI = 'ui://demo/referenced-card'
const HTML_MIME = 'text/html;profile=mcp-app'
// ── Card HTML ───────────────────────────────────────────────────────────────
/**
* Shared card-side bridge script. Speaks JSON-RPC over postMessage with the
* DSH host (McpAppCard), logs every message, and exposes `rpc()` to card
* logic. Inline `render()`-style JS is avoided; this is plain script.
*/
function bridgeScript() {
return `
${bridgeScript()}
`
}
/** Referenced card: HTML served from a ui:// resource; also demos the security gate. */
function referencedCardHtml() {
return `
📦 MCP Apps referenced-form demo
resourceUriresources/readui:// gate
${bridgeScript()}
`
}
// ── Tool table ──────────────────────────────────────────────────────────────
const TOOLS = [
{
name: 'demo_interactive',
description:
'MCP Apps demo (inline form): returns an interactive HTML card via _meta.ui.resource.text. '
+ 'The card reads structuredContent through ui/initialize, refreshes itself via tools/call, '
+ 'and can send ui/message into the conversation. Call this with no arguments for the default demo.',
inputSchema: {
type: 'object',
properties: {
note: { type: 'string', description: 'Free-form note shown on the card.' },
count: { type: 'number', description: 'Counter value to display (card increments it on refresh).' },
},
additionalProperties: false,
},
},
{
name: 'demo_referenced',
description:
'MCP Apps demo (referenced form): the tool definition carries _meta.ui.resourceUri and the result '
+ 'carries NO _meta — the host fetches the card HTML from the ui:// resource via resources/read. '
+ 'The card demonstrates the ui://-only resources/read security gate. Call with no arguments.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
_meta: { ui: { resourceUri: REFERENCED_URI } },
},
]
const RESOURCES = [
{ uri: REFERENCED_URI, name: 'referenced-card', mimeType: HTML_MIME, text: referencedCardHtml() },
]
// ── Tool handlers ───────────────────────────────────────────────────────────
const TOOL_HANDLERS = {
/** Inline-form card: HTML rides the tool result's _meta.ui. */
demo_interactive(args) {
const count = typeof args.count === 'number' && args.count >= 0 ? Math.floor(args.count) : 0
const note = typeof args.note === 'string' ? args.note : 'hello from the model-side call'
return {
content: [{ type: 'text', text: `Interactive demo card rendered (count=${count}, note="${note}").` }],
structuredContent: {
count,
note,
// The host reads session_id from structuredContent and injects it into
// every bridge tools/call the card makes (when the card omits it).
session_id: 'demo-session',
// Echoes whether this call arrived WITH a session_id (bridge calls do).
received_session_id: typeof args.session_id === 'string' ? args.session_id : null,
},
_meta: {
ui: {
resource: {
uri: 'ui://demo/interactive-card',
mimeType: HTML_MIME,
text: interactiveCardHtml(),
},
},
},
}
},
/** Referenced-form card: no _meta on the result; the host resolves resourceUri. */
demo_referenced() {
return {
content: [{ type: 'text', text: 'Referenced-form demo card rendered.' }],
structuredContent: { source: REFERENCED_URI },
}
},
}
// ── JSON-RPC plumbing (newline-delimited over stdio) ────────────────────────
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`)
const reply = (id, result) => send({ jsonrpc: '2.0', id, result })
const replyError = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } })
const METHODS = {
initialize(params) {
return {
protocolVersion: params?.protocolVersion ?? '2025-06-18',
capabilities: { tools: { listChanged: false }, resources: { listChanged: false } },
serverInfo: SERVER_INFO,
}
},
ping() {
return {}
},
'tools/list'() {
return { tools: TOOLS }
},
'tools/call'(params) {
const handler = TOOL_HANDLERS[params?.name]
if (handler === undefined) {
throw Object.assign(new Error(`unknown tool: ${params?.name}`), { code: -32602 })
}
return handler(params?.arguments ?? {})
},
'resources/list'() {
return { resources: RESOURCES.map(({ uri, name, mimeType }) => ({ uri, name, mimeType })) }
},
'resources/read'(params) {
const resource = RESOURCES.find(r => r.uri === params?.uri)
if (resource === undefined) {
throw Object.assign(new Error(`unknown resource: ${params?.uri}`), { code: -32602 })
}
return { contents: [{ uri: resource.uri, mimeType: resource.mimeType, text: resource.text }] }
},
}
createInterface({ input: process.stdin }).on('line', (line) => {
const trimmed = line.trim()
if (trimmed === '') return
let message
try {
message = JSON.parse(trimmed)
} catch {
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } })
return
}
if (message === null || typeof message !== 'object' || typeof message.method !== 'string') return
// Notifications (no id) are acknowledged by silence.
if (message.id === undefined) return
const handler = METHODS[message.method]
if (handler === undefined) {
replyError(message.id, -32601, `method not found: ${message.method}`)
return
}
try {
reply(message.id, handler(message.params))
} catch (error) {
replyError(message.id, error?.code ?? -32603, String(error?.message ?? error))
}
})
process.on('SIGTERM', () => process.exit(0))
console.error(`[${SERVER_INFO.name}] MCP Apps demo server on stdio (2 tools, ${RESOURCES.length} ui:// resource)`)