{"id":"2dade8c9-a48e-4f00-b146-1ee282e49bc2","slug":"mcp-chat-cards","kind":"mcp","name":"mcp-chat-cards","summary":"An MCP (Model Context Protocol) server that renders interactive HTML inside desktop clients. The conversations renders HTML tables, charts, forms, short video clips, collapsible sections, and numbered","owner":"jhauga","repoUrl":"https://github.com/jhauga/mcp-chat-cards","license":"MIT","licenseTier":"permissive","trust":"community","stars":0,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-08-10T04:20:21.000Z","addedAt":"2026-10-21T04:57:53.441Z","logoUrl":"https://avatars.githubusercontent.com/u/10998676?u=63da66da9097151b6226adfcf9f9d03f4248c06a&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["browser-web","communication","cloud-infra"],"tags":["ai-chat-interface"],"runtime":"node","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-26T06:49:22.150Z","sample":false,"url":"https://conduits.dev/tools/mcp-chat-cards","language":"TypeScript","transports":["stdio"],"install":{"npm":"mcp-chat-cards"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# ![mcp-chat-cards](assets/logo.png)\n\nAn MCP (Model Context Protocol) server that renders interactive HTML cards inside desktop\nchat clients. Instead of walls of text, conversations get tab boxes, tables, charts, forms,\nshort video clips, collapsible sections, and numbered outlines. On hosts that support the\n[MCP Apps extension](https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp)\n(SEP-1865), cards render inline in the conversation as sandboxed iframes; other hosts\nreceive each card as a self-contained embedded HTML resource.\n\n## Features\n\n- **Tab cards**: view different contexts of one subject side by side (per language,\n  per OS, per skill level), including code samples with copy buttons.\n- **Table cards**: build HTML tables from explicit rows or loosely delimited raw text.\n  Delimiters are auto-detected, columns are balanced, and URL cells become links.\n- **Chart cards**: dynamically generated SVG bar, line, pie, and donut charts with\n  legends and a collapsible data table for accessibility.\n- **Form cards**: forms the user fills in to give the conversation context and direction.\n  Submitting sends the values back to the chat as the next prompt.\n- **Mirrored website forms**: fetch a real page, rebuild one of its forms as a card, then\n  submit the actual website form with the user-entered values via `submit_web_form`.\n- **Video cards**: HTML `<video>` players for short clips.\n- **Animation cards**: model-composed animated clips for when no direct video URL exists -\n  sequential scenes of text, staggered bullet builds, simple SVG diagrams that trace\n  themselves in, and single-series charts that zoom into the data range under discussion,\n  all with video-style play/pause, replay, and progress controls.\n- **Show/hide cards**: collapsible sections with show-all/hide-all controls.\n- **Sequential list cards**: nested ordered lists numbered 1., 1.1., 1.1.1. via CSS counters.\n- **Document cards**: render a markdown document (a guide or walkthrough the model wrote)\n  as one interactive card with collapsible sections, styled tables, and copyable code.\n- **Code tour cards**: present a project as one tab per source file, in the order the\n  files are given (README and manifests hoisted first). Source files are syntax-highlighted\n  for their detected language and line-numbered behind a **Raw | Code** toggle, the way a\n  repository browser shows a file. Markdown files render as a formatted page instead, behind\n  a **Raw | Rendered** toggle - a README in a tour reads as documentation, and the source is\n  still one click away.\n- **Model-generated file ingestion**: read local text files and unpack zip archives the\n  model produced earlier in the conversation, so its own deliverables plug straight into\n  cards without retyping anything.\n- **Automatic multi-part splitting**: large documents and projects are packed into parts\n  that each stay under a card size budget, so hosts never truncate an oversized result.\n  Each part renders as its own card and the result says exactly how to fetch the next.\n- **Reference fetching**: `fetch_reference` pulls a public page and returns structured\n  data (headings, text, tables, images, links, form specs) ready to feed into card tools.\n- **Card tutor**: educational cards can mark terms that show tooltips after a hover dwell,\n  and define right-click context actions that send model-anticipated prompts.\n- **Movable content**: card blocks can be drag-reordered, drag out of the card carries the\n  block as HTML, and every card has a \"Copy card\" button that copies the standalone HTML\n  for pasting into other responses or files.\n- **Header toggles**: every card header has a `</>` button that shows or hides the card's\n  own HTML source in a code panel, and a chevron button beside it that collapses the card\n  down to its header row and expands it again.\n- **Efficient prompt builder**: every card's right-click menu has a built-in \"Generate\n  Efficient Verbose Prompt\" item that opens a three-step flow: a 350-character gist, up to\n  three rounds of multiple-choice refinement (each question with a free-text \"Other\"), and\n  a finished prompt shown with its character and token count, editable in place, copyable,\n  and sent only after a confirmation box. The model reads a bounded gist and bounded\n  answers instead of a rambling thread, so the only long text in the exchange is the prompt\n  the user wanted.\n- **Copyable cards**: every card's right-click menu has a built-in \"Copy card\" item that\n  copies the standalone HTML, mirroring the header button. For visual placement,\n  `get_insert_bookmarklet` returns a bookmarklet that shows a floating \"Insert Card\" item\n  on right-click and injects the copied card at that spot (page-local preview; gone on\n  reload).\n\n## When the model should call this server\n\nThe server advertises itself for conversations about research, education, professional\nskills, general hobbyist skills (woodworking, arts, and similar), professional topics,\nhistory, and news, plus related subjects, whenever a card communicates better than text.\n\n## Installation\n\n```bash\ngit clone https://github.com/jhauga/mcp-chat-cards.git\ncd mcp-chat-cards\nnpm install\nnpm run build\n```\n\nRequires Node.js 18.17 or newer.\n\n## Use with Claude Desktop\n\nAdd the server to `claude_desktop_config.json` (Settings > Developer > Edit Config),\nadjusting the path to where you cloned the repository:\n\n```json\n{\n  \"mcpServers\": {\n    \"chat-cards\": {\n      \"command\": \"node\",\n      \"args\": [\"C:/path/to/mcp-chat-cards/dist/index.js\"]\n    }\n  }\n}\n```\n\nRestart the desktop client after saving, or use **Developer > Reload MCP Configuration**\n(the Developer menu appears after Help > Troubleshooting > Enable Developer Mode). The\nsame stdio command works in any MCP host; for hosts that support MCP UI resources, cards\nrender as sandboxed iframes.\n\nWhere the desktop client keeps its logs depends on how it was installed: the Microsoft\nStore build writes `mcp-server-chat-cards.log` under `%LOCALAPPDATA%\\Claude\\Logs\\`, the\nclassic installer under `%APPDATA%\\Claude\\logs\\`. The server's own stderr lines land in\nthat file, which is what [Debugging cards in Claude Desktop](#debugging-cards-in-claude-desktop)\nreads.\n\n> **Host status (October 2026).** Claude Desktop builds since September 2026 carry a\n> local stdio server into Claude app chats through a device bridge that announces its\n> tools without their `ui` binding and passes the model only `structuredContent`. The host\n> never classifies a card tool as an App, never reads the template, and shows the result\n> as a JSON row, in new and old conversations alike; the upstream report is\n> [anthropics/claude-ai-mcp#1069](https://github.com/anthropics/claude-ai-mcp/issues/1069).\n> Since 1.0.0-alpha.1.0.0 the server works around it: while a host has not read the card\n> template, each result also carries a ready-made widget for the chat's own inline\n> renderer (the visualizer's `show_widget` tool), and the model paints the card with it.\n> See [Visualizer widget](#how-cards-render). Hosts that mount the template render\n> cards as before, and [Run as a custom connector](#run-as-a-custom-connector-streamable-http)\n> remains an untested alternative.\n\n## Run as a custom connector (Streamable HTTP)\n\n`npm run start:http` starts the same server over Streamable HTTP so a chat client can\nreach it as a remote (\"custom\") connector. The client's cloud makes the connection, so the\nprocess has to be reachable from the internet, normally through a tunnel on the machine\nthat runs it, and everything is gated by one bearer token.\n\n| Variable | Purpose |\n| --- | --- |\n| `MCP_CHAT_CARDS_HTTP_TOKEN` | Required. The server refuses to start without it and rejects every request that does not carry `Authorization: Bearer <token>` (16 characters minimum). |\n| `MCP_CHAT_CARDS_HTTP_PORT` | Port to listen on (default `3310`). |\n| `MCP_CHAT_CARDS_HTTP_HOST` | Address to bind (default `127.0.0.1`, loopback only). |\n| `MCP_CHAT_CARDS_FS_ROOT` | Strongly recommended here. The local file tools read the filesystem of the machine running the server, so confine them to one folder before exposing it. |\n\nThe endpoint is `/mcp`; every other path answers 404. Each client session gets its own\nserver instance (the session id travels in the `mcp-session-id` header, as the transport\nspecifies), request bodies are capped at 4 MiB, and the SDK's DNS-rebinding check is off,\nbecause behind a tunnel the `Host` header is the tunnel's name and the token is the gate.\n\nA minimal setup, with `set` instead of `export` in a Windows command prompt:\n\n```bash\nexport MCP_CHAT_CARDS_HTTP_TOKEN=<token>\nexport MCP_CHAT_CARDS_FS_ROOT=/path/to/an/empty/folder\nnpm run start:http\ncloudflared tunnel --url http://127.0.0.1:3310\n```\n\nThen add the tunnel's URL plus `/mcp` (for example `https://example.com/mcp`) in the\nclient's connector settings, with authentication set to \"No sign in\" and one request\nheader, `Authorization: Bearer <token>`. In Claude, custom connectors live under\nSettings > Connectors and have to be enabled in each conversation's tools menu; the Free\nplan allows one.\n\n**Status: experimental.** The entry point is covered by `test/http.test.ts`, has been\nexercised with the SDK's Streamable HTTP client and through a public tunnel, and is the\nroute reported upstream as rendering Apps on current desktop builds, but a card has not\nyet been seen rendering in Claude through it from this repository. Nothing here is needed\nfor the stdio setup above.\n\n## Tools\n\n| Tool | Purpose |\n| ---- | ------- |\n| `create_tab_card` | Tabbed views of one subject (text, HTML, or code per tab) |\n| `create_table_card` | HTML table from rows or raw text with delimiter detection |\n| `create_chart_card` | SVG bar, line, pie, or donut chart with data table |\n| `create_form_card` | Form whose submission becomes the next conversation prompt |\n| `create_video_card` | HTML video player for a short clip (direct file URL, `data:video/*`, or `blob:`; streaming platform pages are rejected) |\n| `create_reveal_card` | Collapsible show/hide sections (text, HTML, or code per section) |\n| `create_list_card` | Nested sequential outline (1., 1.1., 1.1.1.) |\n| `create_markdown_card` | Render a markdown document (content or file path) as one card |\n| `create_code_tour_card` | Render a project (zip path or explicit files) as file tabs: highlighted source behind Raw/Code, `.md` behind Raw/Rendered |\n| `create_prompt_gist_card` | Step 1 of the efficient-prompt flow: collect a 350-character gist |\n| `create_prompt_refine_card` | Step 2: multiple-choice questions that close the gaps (max 3 rounds) |\n| `create_efficient_prompt_card` | Step 3: the finished prompt, editable, copyable, sent on confirmation |\n| `read_local_file` | Read a local text file for review or card building |\n| `unpack_archive` | List a local zip and return its text file contents |\n| `fetch_reference` | Fetch a public page and return structured extracted data |\n| `mirror_web_form` | Rebuild a website form as an interactive card |\n| `submit_web_form` | Submit user-confirmed values to the real website form |\n\n### Example: `create_table_card`\n\n```json\n{\n  \"title\": \"JavaScript array methods\",\n  \"headers\": [\"Method\", \"Purpose\"],\n  \"rows\": [\n    [\"map\", \"Transform each item\"],\n    [\"filter\", \"Keep matching items\"],\n    [\"reduce\", \"Fold items into one value\"]\n  ]\n}\n```\n\n**Output:** a text summary plus an embedded resource\n(`ui://mcp-chat-cards/<id>`, `text/html`) containing the full card document.\n\n### Example: `create_form_card`\n\n```json\n{\n  \"title\": \"Study preferences\",\n  \"promptTemplate\": \"Teach {{topic}} with {{style}} examples.\",\n  \"fields\": [\n    { \"name\": \"topic\", \"label\": \"Topic\", \"required\": true },\n    { \"name\": \"style\", \"type\": \"select\", \"options\": [\"practical\", \"theoretical\"] }\n  ]\n}\n```\n\nWhen the user submits the card, the filled template is posted to the host as the next\nprompt. If the host does not consume the message, the card reveals the prompt text with a\ncopy button as a fallback.\n\n### Example: the efficient-prompt flow\n\n\"Generate Efficient Verbose Prompt\" in any card's right-click menu sends a message tagged\n`[efficient-prompt]`. The model answers with `create_prompt_gist_card`, and the gist the\nuser submits carries the rules for the rest of the flow, so nothing has to be restated:\n\n```json\n{\n  \"round\": 1,\n  \"questions\": [\n    {\n      \"name\": \"depth\",\n      \"question\": \"How much detail?\",\n      \"options\": [\"Overview\", \"Step by step\", \"Line by line\"]\n    },\n    {\n      \"name\": \"output\",\n      \"question\": \"What should come back?\",\n      \"options\": [\"A table\", \"A checklist\", \"Prose\"],\n      \"multiple\": true\n    }\n  ]\n}\n```\n\nEach question also renders a free-text \"Other\" input under the group's own name, so a typed\nanswer arrives joined with the checked ones. Anything left blank comes back empty, which the\nsubmitted prompt spells out as \"no preference\". The flow ends with:\n\n```json\n{\n  \"prompt\": \"Audit config/loader.py for unhandled error paths. Output: a table of file, line, failure mode, and fix, then one line on the riskiest one.\",\n  \"notes\": \"Assumes the current logging setup stays.\"\n}\n```\n\nThe card shows that prompt with its character and token count and a **Send this prompt**\nbutton. Clicking it opens a confirmation box; only \"Yes, send it\" hands the prompt to the\nconversation. On hosts with no channel back to the chat, the copy button is the whole\nstory: the prompt is copied and the user pastes it as their next message.\n\n### Example: plug-n-play with files the model generates\n\nSuppose earlier in the conversation the model built a small project and delivered\n`project.zip` plus a `GUIDE.md` walkthrough. Instead of the user unpacking and reading\nthem by hand, the model turns them into interactive cards in two calls:\n\n```json\n{ \"path\": \"C:/Users/jane/Downloads/GUIDE.md\" }\n```\n\nsent to `create_markdown_card` renders the whole guide as one card: the first H1 becomes\nthe title, each H2 section folds into a show/hide reveal, tables get card styling, and\nevery fenced code block gets a copy button.\n\n```json\n{\n  \"title\": \"Project source tour\",\n  \"archivePath\": \"C:/Users/jane/Downloads/project.zip\",\n  \"intro\": \"Read the guide card first, then follow these files in order.\"\n}\n```\n\nsent to `create_code_tour_card` unpacks the archive in memory and renders one tab per\ntext file, ordered README, manifest, then source, each language-tagged with a copy\nbutton. Explicit `files` keep the order the caller gives them (README and manifests are\nhoisted to the front), so a deliberate teaching order - data model, then errors, then\nthe entry point - renders as written instead of alphabetically. Build caches\n(`target/`, `node_modules/`) and binary entries are skipped automatically. Use `unpack_archive` or `read_local_file` first when the model needs to\ninspect contents before deciding which cards to build.\n\nWhen a document or project exceeds the card size budget (default about 32,000 characters\nof card markup, tunable via `MCP_CHAT_CARDS_CARD_BUDGET`), the tool splits it at natural\nboundaries - H2 sections for documents, whole files for tours - and returns part 1 with\nan instruction like \"call create_code_tour_card again with part: 2\". The model repeats\nthe call until every part has rendered as its own card, and oversized single files are\ntruncated with a visible notice.\n\nThe budget is measured against what a file costs **after** rendering, not its length on\ndisk: source is HTML-escaped into the tab panel, where a single `<` becomes four\ncharacters, so a handful of markup-dense files can cost far more than their raw size\nsuggests. Every finished result is then measured once more against the host's ceiling\n(about 40,000 characters of serialized result, tunable via\n`MCP_CHAT_CARDS_RESULT_LIMIT`). A card that is still too large and cannot split itself -\na table with thousands of rows, say - returns an actionable error naming the overage\ninstead of an oversized result that the host would silently refuse to render.\n\n### Example: mirrored website form\n\n1. `mirror_web_form` with `{ \"url\": \"https://example.com/newsletter\" }` renders the page's\n   signup form as a card.\n2. The user fills it in and submits; the card returns a structured payload to the chat.\n3. The model reviews the values with the user, then calls `submit_web_form` with the\n   payload to submit the real form and reports the HTTP result.\n\n## How cards render\n\nThe server supports three delivery paths, chosen automatically per connection:\n\n**Inline in the conversation (MCP Apps, SEP-1865).** The server pre-declares one UI\ntemplate resource at `ui://mcp-chat-cards/card.html` with MIME type\n`text/html;profile=mcp-app`, and every card tool links to it through\n`_meta.ui.resourceUri` and declares an output schema (hosts drop `structuredContent`\nfrom schema-less tools, leaving the card nothing to paint). Hosts that support the Apps\nextension render the template inline in the chat as a sandboxed iframe, complete the\n`ui/initialize` handshake, and deliver the tool result to it (Claude Desktop did so until\nSeptember 2026; see the host status note under\n[Use with Claude Desktop](#use-with-claude-desktop)). The template is defensive about the parts that fail silently: it announces\n`ui/notifications/initialized` on a timeout as well as on the handshake reply (the host\nwithholds the tool result until it sees the announcement), locates the card payload by\ndeep search rather than one fixed nesting, reports `ui/notifications/size-changed` after\nevery paint and on resize so the iframe takes its real height, and adopts the host's\ntheme (including a full dark palette). Card interactions travel back over JSON-RPC\npostMessage: form submissions and context actions become `ui/message` requests (the next\nconversation prompt), links go through `ui/open-link`, and telemetry uses logging\nnotifications.\n\n**Standalone document fallback.** For clients that did not declare the Apps extension,\neach result carries a link to `ui://mcp-chat-cards/html/<card-id>`. Reading that resource\nreturns the same card as a self-contained HTML document (inline CSS and JS, restrictive\nContent-Security-Policy, no external scripts). Hosts without Apps support but with MCP-UI\nstyle rendering show that document in a sandboxed panel; there the card posts MCP-UI\nstyle messages (`prompt`, `notify`, `link`).\n\nThe document is deliberately **not** inlined in the tool result. Its theme and runtime are\nbyte-for-byte identical in every card, so inlining repeated about 16 KB of boilerplate per\ncall: it crowded out the caller's context and was the main reason an otherwise ordinary\ncard could exceed a host's result ceiling and render nothing at all. Set\n`MCP_CHAT_CARDS_EMBED_HTML=1` to restore the inline copy for a host that cannot follow a\nresource link. The server keeps the last 24 rendered cards available for reading.\n\n**Visualizer widget (hosts that never mount the template).** Some hosts negotiate the\nApps extension, call the card tools, and still never mount a view: Claude's device\nbridge, which carries local stdio servers into Claude app chats, strips the `ui` binding\nand hands the model only `structuredContent`. Those chats have an inline HTML renderer\nof their own, the visualizer tool `show_widget`. Until the host reads the card template\non a connection (the one act that proves it mounts Apps views), every card result for an\nApps client also carries `structuredContent.display`:\n\n```json\n{\n  \"shown\": false,\n  \"action\": \"This host does not mount MCP Apps views for this server, so ... call show_widget ...\",\n  \"title\": \"rust_iterator_adapters_table_card\",\n  \"loading_messages\": [\"Laying out the table card\"],\n  \"widget_code\": \"<style id=\\\"mcc-critical\\\">...</style><h2 class=\\\"sr-only\\\">...</h2><article class=\\\"mcc-card\\\">...</article><script type=\\\"application/json\\\" id=\\\"mcc-config\\\">...</script><script src=\\\"https://cdn.jsdelivr.net/gh/jhauga/mcp-chat-cards@v<version>/widget/mcc-widget.js\\\"></script>\"\n}\n```\n\nThe model passes `title`, `loading_messages`, and `widget_code` to `show_widget` and the\ncard renders inline. `widget_code` is the card's own article plus a short critical style\nthat keeps it legible while it streams, and one script tag for\n[`widget/mcc-widget.js`](widget/mcc-widget.js): the full card theme mapped onto the\nhost's design tokens (light and dark), a bridge that turns form submissions and context\nactions into `sendPrompt()` and links into `openLink()`, and the same card runtime the\nApps template runs. The bundle is generated by `npm run build`, committed, and served by\njsDelivr from the release tag (the visualizer only loads scripts from a few CDNs), so it\ncan never disagree with the server that emitted the markup. If it cannot load, the card\nstill reads correctly with the critical style; tabs, copy buttons, and menus need the\nbundle.\n\n| Variable | Effect |\n| --- | --- |\n| `MCP_CHAT_CARDS_RENDER` | `auto` (default): widget until the host reads the template. `apps`: never ship a widget (use once the host mounts cards again, to avoid painting twice). `widget`: always ship one. |\n| `MCP_CHAT_CARDS_WIDGET_SRC` | Bundle URL override, for a fork or a local test server. |\n\nBecause a widget result carries the article twice (once for the template, once inside\n`widget_code`), cards that split by size pack half as much per part while a widget ships.\nKnown limits of this path: the visualizer's sandbox loads nothing from origins outside\nits CDN allowlist, so video and image cards show their captions and links but not the\nmedia; the card's hover and ready notifications have no channel back to the model; and on\nthe first card after a host starts mounting Apps views again, the card can appear twice\n(once in the Apps frame, once as a widget) until the template read switches the\nconnection to `apps`.\n\n### Consistent result shape\n\nEvery card tool returns the same three things, whatever the card kind:\n\n| Part | Contents |\n| --- | --- |\n| `content[0]` | Text summary of the card, meaningful on its own |\n| `content[1]` | Resource link to the standalone HTML (omitted for Apps hosts) |\n| `structuredContent` | `{ \"card\": { \"articleHtml\": \"…\", \"config\": { \"id\", \"kind\", … } }, \"parts\": { … } }`, plus `display` while a visualizer widget ships |\n\n### Rendering surface\n\nCards render inline in the Claude desktop app on builds that mount the template, and in\nClaude app chats that reach a local server through the device bridge by way of the\nvisualizer widget (see the host status note under\n[Use with Claude Desktop](#use-with-claude-desktop)).\nOther surfaces (mobile and web)\nreceive the text summary and the structured payload but do not paint the card, so each\nsummary is written to stand alone and tools are instructed to state a card's conclusion in\nthe conversation as well.\n\n### Text fields are plain text\n\nEvery text field a tool accepts - titles, labels, cell values, tutor tips - is plain text\nand is escaped by the server exactly once. Callers must not pre-escape: pass\n`Predict, Spot & Fix`, not `Predict, Spot &amp; Fix`.\n\nInput is never decoded first, in any field. A caller who does pass `&amp;` gets a card\nthat displays the six characters `&amp;`, because that is what a guide documenting HTML,\nescaping, or templating means to show its reader. The rule is the same everywhere, so the\nsame string renders identically in a table cell, a tab, a markdown table, and a code\nsample.\n\nRaw HTML is only honoured in fields named `html` (`create_tab_card` and\n`create_reveal_card` sections), where it is sanitized: scripts, event handlers, frames,\nforms, and dangerous URLs are stripped while formatting such as `<b>` and `<i>` is kept.\nEverywhere else - including HTML written inside a `create_markdown_card` document - tags\nrender as literal text, so use markdown syntax for formatting there. Escaped block-level\nHTML is wrapped in the paragraph the markdown structure implies rather than dropped\nbetween siblings as loose text, and HTML comments are dropped rather than shown, since a\n`<!-- markdownlint-disable -->` directive is not prose.\n\n### Where paths resolve\n\n`create_markdown_card(path)`, `create_code_tour_card(archivePath)`, `read_local_file`, and\n`unpack_archive` all resolve paths on **the filesystem of the machine running this server**.\nWhen the server is reached over a remote bridge, that is not the caller's sandbox: a file\nthe model generated on its own side does not exist here. Pass the content inline\n(`markdown`, `files`) in that case. A path from the wrong operating system - say\n`/home/demo-user/project.zip` sent to a server running on Windows - is rejected with an\nerror naming the mismatch rather than resolved onto a drive where it never existed; the\nsame goes for Windows paths sent to a POSIX server and for unexpanded `~/` paths. Set\n`MCP_CHAT_CARDS_FS_ROOT` to confine local reads to one directory.\n\n## Interactivity notes and limits\n\n- Drag and drop reorders blocks inside one card. Dragging a block out of the card carries\n  its HTML in the drag data; dropping into another response requires host support, which\n  desktop chat clients generally do not expose yet.\n- \"Copy card\" copies the standalone HTML document so a card can be reproduced in another\n  response, a file, or a browser tab.\n- Tutor tooltips appear after hovering a marked term for about 1.2 seconds; the card also\n  notifies the host so the model can follow up while the user explores. Each term is\n  marked once per card (longest term wins where two overlap), duplicates in the term list\n  are ignored, and marking never happens inside code samples, native tooltips, or another\n  term's tip. Matching is case-sensitive, so a tip written for `PATH` does not attach\n  itself to a filesystem `path`; set `caseInsensitive: true` on a term to match any\n  casing. Pass `tutorTermsInCode: true` to `create_code_tour_card` to opt code in.\n- Every card result carries `structuredContent.parts` as `{ current, total, hasMore }`,\n  so one completeness check works on every card type. While `hasMore` is true, content has\n  been withheld and the caller calls the same tool again with `part: current + 1`. Only\n  `create_markdown_card` and `create_code_tour_card` can split; every other card always\n  reports `{ current: 1, total: 1, hasMore: false }`. The rendered title keeps its\n  human-readable `(part 1 of 2)` suffix for the reader.\n- The split threshold is measured on **rendered** characters, not on the input a caller\n  writes. Escaping expands source unpredictably - `<`, `>`, `&`, and quotes each become\n  four to six characters - so a payload that looks well under the budget can cross it and\n  a larger-looking one may not. Read `parts.hasMore` rather than predicting from input\n  size. A markdown document with no H2 headings has no split boundary and always renders\n  as one part.\n- In a code tour, a source file whose language the highlighter knows gets a **Raw | Code**\n  toggle. Code is the view on arrival: syntax-highlighted, with line numbers in an\n  unselectable gutter, so a copy or a drag through the file picks up the file and not its\n  numbering. Raw is the same text with the highlighting and gutter switched off, so the card\n  carries one copy of the source rather than two. Languages covered: C, C++, C#, CSS, Go,\n  Java, JavaScript, JSON, JSX, PHP, Python, Ruby, Rust, shell, SQL, TOML, TSX, TypeScript,\n  and YAML, plus the usual aliases (`node`, `js`, `py`, `golang`, `yml`, and so on). Anything\n  else renders as the plain code block it always did.\n- Highlighted files cost more card budget than plain ones (roughly two to three times, since\n  every token carries a span), and the packer measures the rendered panel, so a large tour\n  splits into more parts than it used to rather than overflowing a card.\n- In a code tour, a `.md`, `.markdown`, or `.mdx` file gets a Raw/Rendered toggle. Rendered\n  is the view on arrival and shows the document as a page (headings, card-styled tables,\n  links, fenced code with copy buttons, never folded into reveals); Raw is the ordinary\n  code block, whitespace preserved, whose copy button returns the source byte for byte.\n  Both views ship inside the card, so switching costs no round trip and the choice survives\n  switching between file tabs. Every other extension is unchanged.\n- Right-click context actions are defined by the model per card; `{{selection}}` in an\n  action prompt is replaced with the user's selected text. Two items are always present\n  regardless of what the model defined: \"Generate Efficient Verbose Prompt\" and \"Copy card\".\n- The finished prompt card never sends on a single click. The first click opens a\n  confirmation box; the prompt goes to the conversation only when that box is accepted, and\n  what is sent is whatever the textarea holds at that moment, edits included.\n- Form answers are gathered per field name, and empty values are dropped, so a choice group\n  and a text input can share one name and arrive as a single comma-joined answer. Every\n  named control still yields a key, so an unanswered group renders as an empty value rather\n  than leaving a literal `{{name}}` in the prompt.\n\n## Security\n\n- Local file tools (`read_local_file`, `unpack_archive`, and the path/archive inputs of\n  the document and code tour cards) read text only, cap sizes, refuse binaries, and cap\n  archive extraction (entry count, per-file, and total bytes). Set\n  `MCP_CHAT_CARDS_FS_ROOT` to confine all local reads to one directory.\n- Outbound requests are limited to http(s) URLs resolving to public addresses. Localhost,\n  RFC 1918, link-local, CGNAT, and equivalent IPv6 ranges are blocked, redirects are\n  re-validated, bodies are size-capped, and requests time out. Set\n  `MCP_CHAT_CARDS_ALLOW_PRIVATE=1` only if you intentionally need intranet fetches.\n- All model- and user-supplied text is HTML-escaped; fetched third-party HTML is sanitized\n  (scripts, event handlers, frames, forms, and dangerous URLs are stripped).\n- Cards ship a restrictive CSP and load no external scripts, stylesheets, or fonts.\n- `submit_web_form` should only be called with values the user entered in a mirrored form\n  card or explicitly confirmed.\n\n## Debugging cards in Claude Desktop\n\nA card that arrives as text, or mounts and stays empty, fails silently. Work down this\nlist; each step separates the host from the server.\n\n1. **Did the host mount anything?** Enable Developer Mode (Help > Troubleshooting > Enable\n   Developer Mode), press `Ctrl+Shift+I` (`Cmd+Option+I` on macOS), and inspect the tool\n   call. A rendered card is an iframe nested inside another iframe. No inner iframe means\n   the host never treated the tool as an App: continue with step 2. An inner iframe that\n   stays empty means the handshake or the payload failed: go to step 5.\n2. **Did the host read the template?** Open `mcp-server-chat-cards.log` (locations under\n   [Use with Claude Desktop](#use-with-claude-desktop)). The server writes one stderr line\n   per host interaction, all prefixed `mcp-chat-cards`:\n\n   ```text\n   mcp-chat-cards client <name> <version> connected (apps extension: declared)\n   mcp-chat-cards card table card-abc123-1 rendered (1658 chars, apps client: yes)\n   mcp-chat-cards template read by host (ui://mcp-chat-cards/card.html, 66232 chars)\n   ```\n\n   A `card ... rendered` line with no `template read by host` line near it is the\n   signature of the host-side regression: the host negotiated Apps, called the tool, and\n   never asked for the template. In the default render mode the next line then reads\n   `card <id> sent as a visualizer widget (render mode auto, template read by host: no)`,\n   which means the result carried `structuredContent.display` and the card appears only\n   once the model calls `show_widget` with it. A JSON row with no widget after it means\n   the model skipped that call (ask it to show the card) or the chat has no visualizer. The host's own `resources/read` entries in the same file\n   are only a lower bound (it logs one of its connections and never logs `tools/call`), so\n   read the server's lines rather than the host's.\n3. **Is the server side healthy?** Run `npm run probe`. It spawns `dist/index.js` over real\n   stdio as a client that declares the Apps extension and checks the things a host\n   needs: the server's extension capability, the `ui` binding on every card tool, output\n   schemas, a visualizer widget on a result before any template read, the template's MIME\n   type and content, a card result's `structuredContent` with no widget once the template\n   was read, and the absence of the standalone fallback for Apps clients. All passing\n   means a text-only card is the host's doing.\n4. **What does the model see?** Ask, in the same conversation, which chat-cards tools\n   advertise a `ui://` resource. The model answers from the tool list the host gave it;\n   \"none\" means the host dropped the binding before the model ever saw it.\n5. **Inside a mounted iframe**, filter its console for `[mcp-chat-cards]`: every JSON-RPC\n   message the card sends and receives is logged with its direction (`tx`/`rx`), so a\n   missing handshake reply or an undelivered tool result is visible immediately. Run\n   `window.__mccDebug()` there for a snapshot: template version, whether `initialized` was\n   announced, whether a card painted, the rendered card HTML, and the last 50 protocol\n   messages. Paste its output when reporting a rendering issue.\n\n## Supplemental ports\n\nThe [extension/](extension/) folder contains supplemental ports of the card tools for\nother hosts. Both are self-contained - they do not import from `src/` or `dist/`, and\nthe MCP server is unaffected by them.\n\n- [extension/github/](extension/github/README.md) - a GitHub Copilot canvas extension\n  named `chat-cards`. The agent drives the same card kinds (tabs, tables, charts, forms,\n  show/hide, sequential lists, markdown documents, video) through canvas actions, and\n  form submissions come back to the conversation as prompts. Its README covers the\n  action list, the differences from the MCP tools, and how the folder maps onto the\n  awesome-copilot contribution layout.\n- [extension/claude/](extension/claude/README.md) - a Claude artifact template: one\n  self-contained HTML page whose card spec block Claude fills and publishes as an\n  artifact. Forms and right-click actions produce prompts the user copies back into\n  the conversation.\n\n## Development\n\n```bash\nnpm run build      # compile TypeScript to dist/ and regenerate widget/mcc-widget.js\nnpm test           # run the vitest suite (unit + in-memory MCP integration)\nnpm run probe      # check the built server over real stdio, as a host would\nnpm run coverage   # run tests with V8 coverage\nnpm run dev        # compile in watch mode\n```\n\n`widget/mcc-widget.js` is committed because jsDelivr serves it from the tagged\nrepository; a test fails when it drifts from the sources. Cards point at\n`@v<package version>`, so push the release tag along with the commit\n(`git push origin main v<version>`), or widget-delivered cards fall back to their critical\nstyle until it exists. Do not load the bundle URL before the tag is on GitHub: jsDelivr\ncaches the miss. If that happened, opening\n`https://purge.jsdelivr.net/gh/jhauga/mcp-chat-cards@v<version>/widget/mcc-widget.js`\nclears it.\n\nThe bundle runs inside the host's frame, so it must never feed back into the host: no\nobservers on `:root` style or class, no DOM writes on a host-triggered path, no\nmicrotask loops. `test/widget.test.ts` mounts it beside a host that touches `:root` on\nevery DOM change and fails if the two keep triggering each other.\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n"}