Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.13.0.cjs

npmMinimalAgeGate: 7d

# Allow recently-published MCP Apps SDK packages (npmMinimalAgeGate would
# otherwise block them for 7 days). Scoped tightly to the official org.
npmPreapprovedPackages:
- "@modelcontextprotocol/ext-apps@*"
- "@modelcontextprotocol/sdk@*"
127 changes: 127 additions & 0 deletions agent_docs/mcp-apps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# HyperDX MCP Apps: Developer Notes

Phase 0 of the MCP Apps experiment: the `hyperdx_query` tool returns
`structuredContent` plus a `_meta` reference to a sandboxed iframe widget. Hosts
that support the [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps/overview)
(Claude, Claude Desktop, Cursor, Goose, Postman, MCPJam, the official
`basic-host` reference client) render the widget inline.

## Where things live

| File | Role |
| --- | --- |
| `packages/mcp-widget/` | Vite + React + `@modelcontextprotocol/ext-apps` widget bundle. Builds to a single inlined HTML at `dist/mcp-app.html`. |
| `packages/api/src/mcp/ui/widget.ts` | Registers the widget bundle as the `ui://hyperdx/widget` MCP resource. Uses MIME `text/html;profile=mcp-app`. |
| `packages/api/src/mcp/tools/query/index.ts` | `hyperdx_query` tool. Tags itself with `_meta["ui/resourceUri"]` + `_meta.ui.resourceUri`. |
| `packages/api/src/mcp/tools/query/helpers.ts` | Builds the `structuredContent` payload (`displayType`, `config`, `data`, `links.openInHyperdxUrl`). |
| `packages/api/src/mcp/__tests__/widget.test.ts` | Contract tests (no ClickHouse needed). |

## Running locally

```bash
yarn dev # full HyperDX stack on slot 79
# (rebuilds the widget bundle once, runs Vite watch
# in MCP-WIDGET pane during dev)
```

Get the personal access key from the user's profile (or, on a fresh stack,
register and fetch it from `GET /me`):

```bash
curl -b cookies.txt http://localhost:30179/me | jq -r .accessKey
```

The MCP endpoint is `http://localhost:30279/api/mcp` with header
`Authorization: Bearer <key>`.

## Testing the widget rendering without Claude

The official `basic-host` reference client is the simplest way. We sparse-clone
it under `.agent-tmp/ext-apps-repo` and patch it for Bearer-token auth.

```bash
# 1. Make sure .agent-tmp/key contains your personal access key.
echo "<your-access-key>" > .agent-tmp/key

# 2. Start the host (terminal 1, leaves it running).
./.agent-tmp/start-basic-host.sh

# 3. Drive it via Playwright to render line/table/number views and capture
# screenshots into .agent-tmp/screenshots/.
node .agent-tmp/drive-host.mjs

# 4. Open http://localhost:8080 in a browser to drive it interactively.
```

Both the host and our MCP server need to be up. `start-basic-host.sh` reads
the key from `.agent-tmp/key` and points basic-host at slot 79's MCP endpoint.

### Configuring Claude Desktop

Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
"mcpServers": {
"hyperdx": {
"command": "npx",
"args": [
"-y",
"supergateway",
"--streamableHttp",
"http://localhost:30279/api/mcp",
"--header",
"Authorization: Bearer <YOUR_ACCESS_KEY>",
"--logLevel",
"none"
]
}
}
}
```

Quit and reopen Claude (⌘Q), then ask "list my hyperdx sources" to verify the
connection. If the host supports MCP Apps rendering, queries through
`hyperdx_query` will render an inline iframe with the widget; otherwise you
get the JSON `content` text and the widget is silently skipped.

## What's working today (Phase 0)

- Line, table, number views render correctly with real ClickHouse data.
- "Open in HyperDX" button uses `app.openLink()` to deep-link to
`/chart?config=<json>&from=<ms>&to=<ms>` in the HyperDX console.
- `granularity` flows through `hyperdx_query` for time-series displays.

## Known gaps

- **Other display types** (`pie`, `stacked_bar`, `search`, `sql`, `markdown`)
pass through the server end-to-end but the widget only renders
line/table/number. Adding one is ~30 lines in `packages/mcp-widget/src/views.tsx`.
- **No bidirectional refresh yet.** The widget receives the initial
`tool-result` and renders; there's no UI control yet to call back into
`hyperdx_query` with a different time range. This is what `app.callServerTool`
is for; trivial to add when needed.
- **The widget uses hand-rolled SVG, not the real dashboard tile components.**
Phase 1 is to extract pure presenters from `packages/app/src/components/`
(`DBTimeChart`, `DBTableChart`, `DBNumberChart`) so the widget renders
pixel-identically to dashboard tiles. The MCP-Apps sandbox forces a clean
data-only boundary; today we paid the duplication cost in exchange for
zero coupling to Mantine/Jotai/TanStack Query during the protocol bring-up.

## Why each piece is shaped this way

- **Widget MIME = `text/html;profile=mcp-app`**: hosts use the `;profile=mcp-app`
suffix to distinguish App resources from arbitrary HTML. Plain `text/html`
triggers an "Unsupported UI resource content format" error.
- **`_meta["ui/resourceUri"]` AND `_meta.ui.resourceUri`**: the spec/SDK
normalizes both, but at least one host (Claude Desktop) reads only the
slash-key form. Emitting both is harmless and forward-compatible.
- **Permissive CORS only on `/mcp`**: MCP clients connect from arbitrary
origins (host pages, browser-based playgrounds). Bearer auth is in the
header, not cookies, so the strict same-origin policy used elsewhere
doesn't apply.
- **Widget is its own `packages/mcp-widget` workspace, not inside `packages/app`**:
the bundle ships into iframes outside HyperDX. It can't import Mantine,
Jotai, Next.js router, etc.; those would balloon the bundle and break
inside the sandbox. Keeping it separate is the cheapest enforcement
mechanism.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,14 @@
"scripts": {
"setup": "yarn install && husky install",
"build:common-utils": "nx run @hyperdx/common-utils:dev:build",
"app:dev": "concurrently -k -n 'API,APP,ALERTS-TASK,COMMON-UTILS' -c 'green.bold,blue.bold,yellow.bold,magenta' 'nx run @hyperdx/api:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/api.log\"}' 'nx run @hyperdx/app:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/app.log\"}' 'nx run @hyperdx/api:dev-task check-alerts 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/alerts.log\"}' 'nx run @hyperdx/common-utils:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/common-utils.log\"}'",
"build:mcp-widget": "nx run @hyperdx/mcp-widget:dev:build",
"app:dev": "concurrently -k -n 'API,APP,ALERTS-TASK,COMMON-UTILS,MCP-WIDGET' -c 'green.bold,blue.bold,yellow.bold,magenta,cyan.bold' 'nx run @hyperdx/api:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/api.log\"}' 'nx run @hyperdx/app:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/app.log\"}' 'nx run @hyperdx/api:dev-task check-alerts 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/alerts.log\"}' 'nx run @hyperdx/common-utils:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/common-utils.log\"}' 'nx run @hyperdx/mcp-widget:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\"$HDX_DEV_LOGS_DIR/mcp-widget.log\"}'",
"app:dev:local": "sh -c '. ./scripts/dev-env.sh && yarn build:common-utils && concurrently -k -n \"APP,COMMON-UTILS\" -c \"blue.bold,magenta\" \"nx run @hyperdx/app:dev:local 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\\\"$HDX_DEV_LOGS_DIR/app.log\\\"}\" \"nx run @hyperdx/common-utils:dev 2>&1 | tee ${HDX_DEV_LOGS_DIR:+\\\"$HDX_DEV_LOGS_DIR/common-utils.log\\\"}\"'",
"app:lint": "nx run @hyperdx/app:ci:lint",
"app:storybook": "nx run @hyperdx/app:storybook",
"build:clickhouse": "nx run @hyperdx/common-utils:build && nx run @hyperdx/app:build:clickhouse",
"run:clickhouse": "nx run @hyperdx/app:run:clickhouse",
"dev": "sh -c '. ./scripts/dev-env.sh && yarn build:common-utils && dotenvx run --convention=nextjs -- docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml up -d && yarn app:dev; dotenvx run --convention=nextjs -- docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml down'",
"dev": "sh -c '. ./scripts/dev-env.sh && yarn build:common-utils && yarn build:mcp-widget && dotenvx run --convention=nextjs -- docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml up -d && yarn app:dev; dotenvx run --convention=nextjs -- docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml down'",
"dev:local": "IS_LOCAL_APP_MODE='DANGEROUSLY_is_local_app_mode💀' yarn dev",
"cli:dev": "yarn workspace @hyperdx/cli dev",
"dev:down": "sh -c '. ./scripts/dev-env.sh && docker compose -p \"$HDX_DEV_PROJECT\" -f docker-compose.dev.yml down && sh ./scripts/dev-kill-ports.sh'",
Expand Down
11 changes: 11 additions & 0 deletions packages/api/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
// @eslint-disable @typescript-eslint/no-var-requires

// Test-environment env defaults.
//
// `src/config.ts` captures `FRONTEND_URL` at module-load time, so any test
// that imports a module which imports `@/config` (e.g. `mcp/tools/query/helpers`)
// gets the value frozen on first load. Without a default, `FRONTEND_URL`
// resolves to `http://localhost:undefined`, and `new URL(...)` rejects it.
// Setting the port here gives every test suite a stable, deterministic
// frontend URL without requiring shell-level env setup.
process.env.HYPERDX_APP_PORT ||= '8080';

jest.retryTimes(1, { logErrorsBeforeRetry: true });

global.console = {
Expand Down
4 changes: 3 additions & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.7",
"@types/jest": "^28.1.1",
"@types/jsdom": "^21.1.7",
"@types/lodash": "^4.14.198",
"@types/minimist": "^1.2.2",
"@types/ms": "^0.7.31",
Expand All @@ -67,6 +68,7 @@
"@types/swagger-jsdoc": "^6",
"@types/uuid": "^8.3.4",
"jest": "^28.1.3",
"jsdom": "^22.1.0",
"migrate-mongo": "^11.0.0",
"nodemon": "^2.0.20",
"pino-pretty": "^13.1.1",
Expand All @@ -85,7 +87,7 @@
"dev": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/index.ts",
"dev:mcp": "npx @modelcontextprotocol/inspector",
"dev-task": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/tasks/index.ts",
"build": "rimraf ./build && tsc && tsc-alias && cp -r ./src/opamp/proto ./build/opamp/",
"build": "rimraf ./build && nx run @hyperdx/mcp-widget:ci:build && tsc && tsc-alias && cp -r ./src/opamp/proto ./build/opamp/",
"lint": "npx eslint --quiet . --ext .ts",
"lint:fix": "npx eslint . --ext .ts --fix",
"ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:openapi",
Expand Down
32 changes: 30 additions & 2 deletions packages/api/src/api-app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import compression from 'compression';
import MongoStore from 'connect-mongo';
import cors from 'cors';
import express from 'express';
import session from 'express-session';
import onHeaders from 'on-headers';
Expand Down Expand Up @@ -75,7 +76,13 @@ app.use(function (req, res, next) {
});
next();
});
app.use(defaultCors);
// Apply the strict default CORS to every route EXCEPT /mcp, which gets a
// permissive CORS configuration below (MCP clients come from arbitrary origins
// and authenticate with Bearer tokens, not cookies).
app.use((req, res, next) => {
if (req.path.startsWith('/mcp')) return next();
return defaultCors(req, res, next);
});

// ---------------------------------------------------------------------
// ----------------------- Background Jobs -----------------------------
Expand All @@ -92,7 +99,28 @@ if (config.USAGE_STATS_ENABLED) {
app.use('/', routers.rootRouter);

// SELF-AUTHENTICATED ROUTES (validated via access key, not session middleware)
app.use('/mcp', mcpRouter);
//
// MCP clients (Claude Desktop, Cursor, MCP Apps host pages, etc.) come from
// arbitrary origins, so the strict same-origin policy used for cookie-auth
// routes doesn't apply here. The endpoint is Bearer-token authenticated;
// validateUserAccessKey gates every request inside mcpRouter.
app.use(
'/mcp',
cors({
origin: true, // reflect the request origin
credentials: false, // no cookies; Bearer is in the Authorization header
methods: ['GET', 'POST', 'OPTIONS', 'DELETE'],
allowedHeaders: [
'Content-Type',
'Authorization',
'Accept',
'mcp-session-id',
'mcp-protocol-version',
],
exposedHeaders: ['mcp-session-id'],
}),
mcpRouter,
);

// PRIVATE ROUTES
app.use('/ai', isUserAuthenticated, routers.aiRouter);
Expand Down
12 changes: 12 additions & 0 deletions packages/api/src/mcp/__tests__/queryTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ describe('MCP Query Tool', () => {

expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
// MCP Apps: ensure the widget contract is populated.
const structured = (
result as { structuredContent?: Record<string, unknown> }
).structuredContent;
expect(structured).toBeDefined();
expect(structured?.displayType).toBe('line');
expect(structured?.config).toBeDefined();
expect(structured?.data).toBeDefined();
expect(
(structured?.links as { openInHyperdxUrl?: string } | undefined)
?.openInHyperdxUrl,
).toMatch(/\/chart\?/);
});

it('should execute a table query', async () => {
Expand Down
Loading