Skip to content

feat: initial connect form and document browser MCP apps [WIP] SKUNK-9#1176

Draft
lerouxb wants to merge 7 commits into
v2from
mcp-apps-docs
Draft

feat: initial connect form and document browser MCP apps [WIP] SKUNK-9#1176
lerouxb wants to merge 7 commits into
v2from
mcp-apps-docs

Conversation

@lerouxb

@lerouxb lerouxb commented May 11, 2026

Copy link
Copy Markdown
Member

From what I can tell the best practise for MCP Apps is to use system fonts and colours. The official SDK in theory passes in Host Style Variables which this tries to use preferably and then it has fallbacks which I styled to look like the MCP Inspector. This way things shouldn't look too out of place inside a chat UI like Claude Desktop. Apparently the industry is also (kinda?) standardising around Shadcn/UI & Tailwind. Not sure how reliable that information really is, but both Claude and Gemini repeated it.

@modelcontextprotocol/ext-apps provides a bunch of standard css variables. Here's an example of what Claude Desktop passes in, for example:
<html lang="en" data-theme="light" style="
color-scheme: light;
--color-background-primary: light-dark(rgba(255, 255, 255, 1), rgba(48, 48, 46, 1));
--color-background-secondary: light-dark(rgba(245, 244, 237, 1), rgba(38, 38, 36, 1));
--color-background-tertiary: light-dark(rgba(250, 249, 245, 1), rgba(20, 20, 19, 1));
--color-background-inverse: light-dark(rgba(20, 20, 19, 1), rgba(250, 249, 245, 1));
--color-background-ghost: light-dark(rgba(255, 255, 255, 0), rgba(48, 48, 46, 0));
--color-background-info: light-dark(rgba(214, 228, 246, 1), rgba(37, 62, 95, 1));
--color-background-danger: light-dark(rgba(247, 236, 236, 1), rgba(96, 42, 40, 1));
--color-background-success: light-dark(rgba(233, 241, 220, 1), rgba(27, 70, 20, 1));
--color-background-warning: light-dark(rgba(246, 238, 223, 1), rgba(72, 58, 15, 1));
--color-background-disabled: light-dark(rgba(255, 255, 255, 0.5), rgba(48, 48, 46, 0.5));
--color-text-primary: light-dark(rgba(20, 20, 19, 1), rgba(250, 249, 245, 1));
--color-text-secondary: light-dark(rgba(61, 61, 58, 1), rgba(194, 192, 182, 1));
--color-text-tertiary: light-dark(rgba(115, 114, 108, 1), rgba(156, 154, 146, 1));
--color-text-inverse: light-dark(rgba(255, 255, 255, 1), rgba(20, 20, 19, 1));
--color-text-ghost: light-dark(rgba(115, 114, 108, 0.5), rgba(156, 154, 146, 0.5));
--color-text-info: light-dark(rgba(50, 102, 173, 1), rgba(128, 170, 221, 1));
--color-text-danger: light-dark(rgba(127, 44, 40, 1), rgba(238, 136, 132, 1));
--color-text-success: light-dark(rgba(38, 91, 25, 1), rgba(122, 185, 72, 1));
--color-text-warning: light-dark(rgba(90, 72, 21, 1), rgba(209, 160, 65, 1));
--color-text-disabled: light-dark(rgba(20, 20, 19, 0.5), rgba(250, 249, 245, 0.5));
--color-border-primary: light-dark(rgba(31, 30, 29, 0.4), rgba(222, 220, 209, 0.4));
--color-border-secondary: light-dark(rgba(31, 30, 29, 0.3), rgba(222, 220, 209, 0.3));
--color-border-tertiary: light-dark(rgba(31, 30, 29, 0.15), rgba(222, 220, 209, 0.15));
--color-border-inverse: light-dark(rgba(255, 255, 255, 0.3), rgba(20, 20, 19, 0.15));
--color-border-ghost: light-dark(rgba(31, 30, 29, 0), rgba(222, 220, 209, 0));
--color-border-info: light-dark(rgba(70, 130, 213, 1), rgba(70, 130, 213, 1));
--color-border-danger: light-dark(rgba(167, 61, 57, 1), rgba(205, 92, 88, 1));
--color-border-success: light-dark(rgba(67, 116, 38, 1), rgba(89, 145, 48, 1));
--color-border-warning: light-dark(rgba(128, 92, 31, 1), rgba(168, 120, 41, 1));
--color-border-disabled: light-dark(rgba(31, 30, 29, 0.1), rgba(222, 220, 209, 0.1));
--color-ring-primary: light-dark(rgba(20, 20, 19, 0.7), rgba(250, 249, 245, 0.7));
--color-ring-secondary: light-dark(rgba(61, 61, 58, 0.7), rgba(194, 192, 182, 0.7));
--color-ring-inverse: light-dark(rgba(255, 255, 255, 0.7), rgba(20, 20, 19, 0.7));
--color-ring-info: light-dark(rgba(50, 102, 173, 0.5), rgba(128, 170, 221, 0.5));
--color-ring-danger: light-dark(rgba(167, 61, 57, 0.5), rgba(205, 92, 88, 0.5)); 
--color-ring-success: light-dark(rgba(67, 116, 38, 0.5), rgba(89, 145, 48, 0.5));
--color-ring-warning: light-dark(rgba(128, 92, 31, 0.5), rgba(168, 120, 41, 0.5));
--font-sans: Anthropic Sans, sans-serif;
--font-mono: ui-monospace, monospace;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-text-xs-size: 12px;
--font-text-sm-size: 14px;
--font-text-md-size: 16px;
--font-text-lg-size: 20px;
--font-heading-xs-size: 12px;
--font-heading-sm-size: 14px;
--font-heading-md-size: 16px;
--font-heading-lg-size: 20px;
--font-heading-xl-size: 24px;
--font-heading-2xl-size: 28px;
--font-heading-3xl-size: 36px;
--font-text-xs-line-height: 1.4;
--font-text-sm-line-height: 1.4;
--font-text-md-line-height: 1.4;
--font-text-lg-line-height: 1.25;
--font-heading-xs-line-height: 1.4;
--font-heading-sm-line-height: 1.4;
--font-heading-md-line-height: 1.4;
--font-heading-lg-line-height: 1.25;
--font-heading-xl-line-height: 1.25;
--font-heading-2xl-line-height: 1.1;
--font-heading-3xl-line-height: 1;
--border-radius-xs: 4px;
--border-radius-sm: 6px;
--border-radius-md: 8px;
--border-radius-lg: 10px;
--border-radius-xl: 12px;
--border-radius-full: 9999px;
--border-width-regular: 0.5px;
--shadow-hairline: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
">
Here it is in Claude Desktop: Screenshot 2026-05-12 at 14 37 28 Screenshot 2026-05-12 at 14 37 36 Screenshot 2026-05-12 at 14 38 00 Screenshot 2026-05-12 at 14 38 23 Screenshot 2026-05-15 at 16 53 24 Screenshot 2026-05-15 at 16 52 09
For Claude Desktop, configure ~/Library/Application Support/Claude/claude_desktop_config.json:
{
  "mcpServers": {
    "my-local-server": {
      "command": "node",
      "args": [
        "/Users/leroux.bodenstein/mongo/mongodb-mcp-server/dist/index.js",
        "--previewFeatures",
        "mcpApps"
      ]
    }
  }
}
Or test it in mcp inspector with:

(yes it seems to give every mcp app's iframe 233px of width..)

~/mongo/mongodb-mcp-server % MDB_MCP_PREVIEW_FEATURES=mcpApps npx @modelcontextprotocol/inspector \
    node dist/esm/index.js
Screenshot 2026-05-12 at 14 43 56 Screenshot 2026-05-12 at 14 44 06
mcp inspector gets a bit tedious especially for the DocumentBrowser app and for that the ext-apps repo's basic-host is a bit better IMHO:

I used caddy as a reverse proxy because basic-host only supports the http transport and it uses it in the browser, so CORS is an issue. Here's my Caddyfile:

:8010 {
    # 1. Handle the "Preflight" OPTIONS request
    @cors_preflight method OPTIONS
    header @cors_preflight Access-Control-Allow-Origin "*"
    header @cors_preflight Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"

    header @cors_preflight Access-Control-Allow-Headers "Content-Type, mcp-session-id, mcp-protocol-version"
    header Access-Control-Allow-Headers "Content-Type, mcp-session-id, mcp-protocol-version"
    header Access-Control-Expose-Headers "mcp-session-id"

    respond @cors_preflight 204

    # 2. Proxy the actual request
    reverse_proxy 127.0.0.1:3000 {
        header_up Host {upstream_hostport}
      	flush_interval -1 # not 100% sure this is neceassary
    }

    # 3. Add header to the actual response
    header Access-Control-Allow-Origin "*"
}

Run the mcp server like this, passing in a connection string so that there's immediately a connection for the DocumentBrowser MCP App to use:

~/mongo/mongodb-mcp-server % pnpm build && MDB_MCP_CONNECTION_STRING=mongodb://127.0.0.1:51296/ node dist/esm/index.js --transport http --loggers stderr --previewFeatures=mcpApps

Then you can run basic-host like this:

~/src/ext-apps/examples/basic-host % SERVERS='["http://127.0.0.1:8010/mcp"]' npm run start

Then navigate to http://127.0.0.1:8080/.

It is one or two lines of code changes if you want to hack it to hardcode the initial input.

Even this can get a bit tedious, so I added an MCP App "dev mode" where the server will load the app from disk every time. This way the server does not have to be restarted every time you change an mcp app.

So you can now run:

~/mongo/mongodb-mcp-server % MCPAPP_DEV=true MDB_MCP_CONNECTION_STRING=mongodb://127.0.0.1:51296/ node dist/esm/index.js --transport http --loggers stderr --previewFeatures=mcpApps

Then when making changes to packages/apps you just recompile:

~/mongo/mongodb-mcp-server/packages/apps % pnpm compile

And then if you hard-refresh in the mcp host app you see the latest app.

You can go even further with this two line hack to pre-fill the query to run:

~/src/ext-apps/examples/basic-host % git diff
diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx
index 3d488a79..22522032 100644
--- a/examples/basic-host/src/index.tsx
+++ b/examples/basic-host/src/index.tsx
@@ -6,6 +6,14 @@ import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy,
 import { getTheme, toggleTheme, onThemeChange, type Theme } from "./theme";
 import styles from "./index.module.css";

+const DEFAULT_INPUT_JSON = `
+{
+"database": "test",
+"collection": "test",
+"query": { "aggregate": { "pipeline": [ { "$limit": 1 } ] } }
+}
+`;
+
 /**
  * Check if a tool is visible to the model (not app-only).
  * Tools with `visibility: ["app"]` should not be shown in tool lists.
@@ -141,7 +149,7 @@ interface CallToolPanelProps {
 function CallToolPanel({ serversPromise, addToolCall, initialServer, initialTool, autoCall }: CallToolPanelProps) {
   const [selectedServer, setSelectedServer] = useState<ServerInfo | null>(null);
   const [selectedTool, setSelectedTool] = useState("");
-  const [inputJson, setInputJson] = useState("{}");
+  const [inputJson, setInputJson] = useState(DEFAULT_INPUT_JSON);
   const [hasAutoCalledRef] = useState({ called: false });

   // Filter out app-only tools, prioritize tools with UIs

Some suggested follow-ups:

  • I'm not sure the colour scheme switches between light and dark automatically like it should. And if it should.
  • Might need a max-width for the connection string input, maybe put the connect button next to it. But we have no control over the width of the iframe we get.
  • I think we can do better to communicate the height of the iframe back to the parent. Once you connect it is still as high as it was even though the content is shorter.
  • we should probably add some tests to make sure that we provide defaults for all the css vars and that we always use those rather than hardcode values
  • the data browser needs pagination, probably a way to edit the query, the ability to collapse/expand things, should probably hide excess object keys or array items by default, probably truncate excessively long strings until you expand them..

@lerouxb lerouxb changed the title feat: initial connect form MCP App [WIP] feat: initial connect form MCP App [WIP] SKUNK-9 May 12, 2026
return registrationSuccessful;
}

protected override handleError(error: unknown, args: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was actually a bug. If you get a connection error, then the base handleError would replace that with a catchall saying that you have to be connected.

Comment thread tsconfig.json Outdated
@lerouxb lerouxb changed the title feat: initial connect form MCP App [WIP] SKUNK-9 feat: initial connect form and document browser MCP apps [WIP] SKUNK-9 May 15, 2026
@gagik gagik force-pushed the v2 branch 2 times, most recently from 1b9c790 to 3cedb1b Compare May 21, 2026 10:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant