MCP Apps Development¶
Create and test MCP Apps using the included development environment.
Prerequisites¶
- Docker (only dependency needed)
Quick Start¶
git clone https://github.com/txn2/mcp-data-platform
cd mcp-data-platform
docker compose -f docker-compose.dev.yml up
This starts:
| Component | URL | Purpose |
|---|---|---|
| Test Harness | http://localhost:8000 | App development and testing |
| MCP Server | http://localhost:3001 | MCP protocol (SSE) |
| MCP Inspector | http://localhost:6274 | Protocol debugging |
| Trino | http://localhost:8090 | Query engine |
Development Workflow¶
1. Open Test Harness¶
Open http://localhost:8000/test-harness.html
The test harness provides:
- Left panel: Editable JSON test data
- Right panel: Live app preview
- Send Test Data: Sends data to the app
- Reload App: Reloads after editing source files
2. Edit and Test¶
- Edit
./apps/platform-info/index.html(or./apps/prompt-browser/index.html/./apps/query-results/index.htmlfor those apps) - Click Reload App in the test harness
- Click Send Test Data
- See changes immediately
Changes are served instantly — no restart needed. The dev config (configs/mcpapps-dev.yaml) uses assets_path to point at the local source directory, overriding the embedded HTML so edits are reflected without rebuilding the binary.
3. Test with Real Queries¶
To test with actual Trino queries:
- Open http://localhost:6274 (MCP Inspector)
- Connect to:
http://mcp-server:3001/sse - Run
trino_query:
Creating New Apps¶
App Structure¶
my-app/
├── index.html # Entry point (required)
├── styles.css # Optional
├── app.js # Optional
└── assets/ # Optional
MCP Apps Protocol¶
Apps communicate with the host via postMessage:
// Initialize on load
window.parent.postMessage({
jsonrpc: '2.0',
id: 1,
method: 'ui/initialize',
params: {
protocolVersion: '2025-01-09',
appInfo: { name: 'My App', version: '1.0.0' }
}
}, '*');
// Listen for tool results
window.addEventListener('message', (event) => {
if (event.data?.method === 'ui/notifications/tool-result') {
const data = JSON.parse(event.data.params.content[0].text);
// Render your UI
}
});
Calling Tools from an App¶
An app is not limited to the result that rendered it. It can call tools itself with tools/call, or ui/call-tool on hosts speaking protocol 2025-01-09. Tool visibility defaults to ["model", "app"], so every tool this server exposes is app-callable.
Those calls reach the server over the same MCP transport as the agent's and are gated the same way. When the session handle requirement is on, the platform refuses any tool call that does not carry a session_id minted by platform_info:
SESSION_REQUIRED: Call platform_info first. It returns a session_id you must
pass as the session_id argument on every subsequent tool call.
An app that calls any tool other than platform_info must therefore handshake first:
- Call
platform_infowith empty arguments before the first data call. The result's first text content block is JSON carryingsession_id. - Thread that value as the
session_idargument on every subsequent call. Do it in whatever wrapper issues the call, so no call site can forget it. - On a rejection whose message contains
SESSION_REQUIRED,SESSION_EXPIRED, orSETUP_REQUIRED, discard the stored handle, callplatform_infoagain, and replay the call once. Handles expire on inactivity, so a long-lived app will hit this.SETUP_REQUIREDcomes from the transport-keyed session gate a deployment may run instead of explicit handles; its remedy is the same call. - Treat a handshake result with no
session_idas "handles are not enabled in this deployment" and send nosession_idargument. Threading nothing is correct there.
platform_info is never gated, because it is the tool that mints the handle. The handshake itself cannot be refused for want of a handle.
Do not make the handshake fatal. A deployment may have handles off, and a persona may allow your app's tool while denying platform_info, so a failed handshake should still let the first data call run and report whatever the platform actually says. Blanking the app on a platform_info failure breaks cases that worked before the handshake existed.
apps/prompt-browser/index.html implements this. Its handshake, withSession, and callTool functions are the smallest complete version.
One correlation hazard: hosts that deliver results as ui/notifications/tool-result notifications send no request id, so an app that matches responses by payload shape must be able to tell the platform_info result from its own tool's result. config_version and features are unconditional on the platform_info payload and identify it. Do not key on prompts: platform_info also carries a prompts array listing the server's registered MCP prompts.
The test harness enforces this gate. It answers a platform_info call from any app other than platform-info with a handshake payload carrying a freshly minted session_id, and refuses any other tool call whose session_id is missing or stale with the same SESSION_REQUIRED / SESSION_EXPIRED text the platform returns. An app that skips the handshake therefore fails in development rather than only in a gated deployment. The Expire Session button revokes the live handle so the next call is refused, which is how you exercise the recovery path without waiting for a real expiry.
Minimal Example¶
<!DOCTYPE html>
<html>
<head><title>My App</title></head>
<body>
<div id="results"></div>
<script>
window.addEventListener('message', (event) => {
if (event.data?.method === 'ui/notifications/tool-result') {
const data = JSON.parse(event.data.params.content[0].text);
document.getElementById('results').innerHTML =
`<pre>${JSON.stringify(data, null, 2)}</pre>`;
}
});
window.parent.postMessage({
jsonrpc: '2.0',
id: 1,
method: 'ui/initialize',
params: { protocolVersion: '2025-01-09' }
}, '*');
</script>
</body>
</html>
Add to Development Environment¶
-
Create your app directory under
apps/: -
Add configuration to
configs/mcpapps-dev.yaml: -
Restart the dev environment:
Test Queries¶
All queries use Trino's memory catalog (no database setup needed).
Simple data:
{"sql": "SELECT 1 as id, 'Product A' as name, 15000.50 as revenue UNION ALL SELECT 2, 'Product B', 23000.75 UNION ALL SELECT 3, 'Product C', 8500.25"}
More rows:
{"sql": "SELECT n as id, 'Item ' || CAST(n AS VARCHAR) as name, ROUND(RANDOM() * 10000, 2) as value FROM UNNEST(SEQUENCE(1, 20)) AS t(n)"}
Date series:
{"sql": "SELECT DATE '2024-01-01' + INTERVAL '1' DAY * n as date, ROUND(RANDOM() * 1000, 2) as sales FROM UNNEST(SEQUENCE(0, 29)) AS t(n)"}
Architecture¶
graph LR
subgraph "Browser"
TH[Test Harness<br/>:8000]
MI[MCP Inspector<br/>:6274]
end
subgraph "Docker"
MCP[MCP Server<br/>:3001]
T[Trino<br/>:8090]
end
TH <-->|postMessage| MCP
MI <-->|MCP Protocol| MCP
MCP <-->|SQL| T
Debugging¶
- Open browser DevTools (F12)
- Console tab shows
[MCP-APP]prefixed logs - Network tab shows MCP protocol messages
Troubleshooting¶
| Problem | Solution |
|---|---|
| Changes not appearing | Click Reload App, or hard refresh (Cmd+Shift+R) |
| "No results to display" | Check browser console for errors |
App shows SESSION_REQUIRED |
The app called a tool before handshaking; see Calling Tools from an App |
| Trino not responding | Wait for startup: curl http://localhost:8090/v1/info |
| Port already in use | docker compose -f docker-compose.dev.yml down |