diff --git a/AGENTS.md b/AGENTS.md index aeec0408..fed0eca1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,20 +67,19 @@ After installing, `npm run build` builds all clients. The launcher scripts (`npm ## Repository & Project Board - **Repo**: https://github.com/modelcontextprotocol/inspector.git -- **Base Branches**: v2/main, v1.5/main, main +- **Base Branches**: v2/main (active), main (v1). v1.5/main is merged into v2/main and no longer takes new work. - **Project Boards**: - - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 - - v1.5 - https://github.com/orgs/modelcontextprotocol/projects/39 - - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 + - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all current work goes here) + - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (existing inspector version, no new activity except security and bug fixes) ## Project Status and Direction * The main branch currently contains the legacy version of the Inspector, which we are accepting bug fixes and minor improvement PRs for. -* The v1.5/main branch contains an intermediate version of the Inspector, where the shared logic between the three incarnations of the Inspector are extracted into a core subsystem with InspectorClient class as the common entry point. It also includes the TUI, a refactored CLI, and streamlined launcher. +* The v1.5/main branch was the intermediate version of the Inspector, where the shared logic between the three incarnations of the Inspector was extracted into a core subsystem with InspectorClient class as the common entry point. It also included the TUI, a refactored CLI, and streamlined launcher. The branch still exists but is **frozen** — it takes no new work. It is kept as a reference point (e.g. for tracking down a regression introduced by the merge into v2/main), so do not delete it. -* The v2/main branch currently contains the new version of the web Inspector, composed of "dumb" components which accept data and callbacks as props and contain only display logic. +* The v2/main branch currently contains the new version of the web Inspector, composed of "dumb" components which accept data and callbacks as props and contain only display logic. -* The InspectorClient from v1.5/main will be merged into v2/main, and wired up to the new web Inspector. The TUI and CLI will follow. Eventually when everything works on v2/main we will replace main with v2/main, eliminating the legacy implementations. +The Launcher, TUI, CLI, and InspectorClient from v1.5/main have been merged into v2/main. InspectorClient is wired up to the new web Inspector. Eventually, we will replace main with v2/main, eliminating the legacy implementations. ## Web backend auth token @@ -104,20 +103,67 @@ Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the gl All work should be driven by items on the project board. +> **A v2 issue is not "created" until it is BOTH labeled `v2` AND on board #28 with a Status set.** Labeling alone is not enough — a label is a repo tag; the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status. All three are distinct steps; do all three (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. + - Before starting work, check the board for the relevant item. -- **Draft items vs. issues**: Board items may be draft items (no issue number) or full GitHub issues. Before creating a new issue, always check if a matching draft item already exists on the board. If it does, convert it to an issue using `gh project item-edit` or create the issue and link it — **never create a duplicate**. +- **Every board item is a real GitHub issue.** Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — **never create a duplicate**. +- **Assign the issue to its creator.** When you create an issue, assign it to the user it is created on behalf of (`gh issue create --assignee @me ...`, or `--assignee `). Board items should never be unassigned. - **Label by version.** New issues and PRs must carry the label matching the target board / branch: - `main` → `v1` - - `v1.5/main` → `v1.5` - `v2/main` → `v2` Set the label at create time (`gh issue create --label v2 ...`, `gh pr create --label v2 ...`) — don't rely on backfilling later, since unlabeled PRs are easy to miss when filtering by version. -- When work begins, create a feature branch and move the item to "In Progress". +- **Add the issue to the board and set Status.** After creating an issue, add it to board #28 and set its Status. (PRs are never added to the board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim. +- When work begins, create a feature branch and set the item's Status to **In progress** (or one of the building statuses below). - When work is complete: - Run format, lint, typecheck, build, and test — ensure all checks pass - - Open a PR against the matching base branch (`main` for v1, `v1.5/main` for v1.5, `v2/main` for v2) and move the item to "In Review" + - Open a PR against the matching base branch (`main` for v1, `v2/main` for v2) and set the item's Status to **In review** + - **Link the PR to its issue.** The PR body's **first line must be `Closes #`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) + - **On merge of a v2 PR, manually close its issue and move the board item to Done** (option id `1bbc5632`), since auto-close won't fire on `v2/main`. Keep the `Closes #N` line anyway so the issues close automatically if/when `v2/main` is eventually merged to `main`. - If new tasks are discovered or requested during development, create issues and add them to the board. +#### V2 board (#28) `gh` recipes + +The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The IDs below are stable; if a command rejects one, re-fetch with `gh project field-list 28 --owner modelcontextprotocol --format json`. + +| Thing | ID | +| --- | --- | +| Project node ID | `PVT_kwDOCt2Azc4BJVxt` | +| Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | + +Status option IDs (`--single-select-option-id`): + +| Status | Option ID | +| --- | --- | +| Backlog | `6080ca99` | +| Building CLI / TUI / CORE | `fe170c62` | +| Building Web | `4faeae7a` | +| MCP Apps Extension | `588c6a63` | +| In progress | `d43284fe` | +| In review | `fb2103f2` | +| Done | `1bbc5632` | + +Use **In progress** for general work, one of the **Building** statuses (or **MCP Apps Extension**) while actively coding that surface, **In review** once a PR is open, and **Done** on merge. + +```sh +# 1. Add an issue to the board — prints the item id (PVTI_…); capture it. +gh project item-add 28 --owner modelcontextprotocol --url --format json + +# 2. Set its Status (here: In progress). Use the option id from the table above. +gh project item-edit \ + --project-id PVT_kwDOCt2Azc4BJVxt \ + --id \ + --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c \ + --single-select-option-id d43284fe +``` + +The one-liner that does both, capturing the item id (use the option id for the status you want): + +```sh +ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url --format json --jq '.id') +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id d43284fe +``` + ### Always test new or modified code - Ensure all code has corresponding tests - Ensure test coverage for each file is at least 90% @@ -131,8 +177,8 @@ All work should be driven by items on the project board. - The **same per-file gate** is enforced for the CLI and TUI (#1484), not just web: - **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` (see `__tests__/helpers/cli-runner.ts`) so `clients/cli/src` is measured under v8 instrumentation. A thin out-of-process layer (`__tests__/e2e.test.ts` + `scripts/smoke-cli.mjs`) still spawns the built binary for the shebang/`process.exit` paths; `src/index.ts` (binary bootstrap) is the only coverage exclusion. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. - **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it. -- Run `npm run test:integration` (also from `clients/web/`) for the v1.5-ported InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI runs it as its own step after unit tests. -- Test files live alongside the source as `.test.tsx` (or `.test.ts` for non-React modules). v1.5-ported integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. +- Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI runs it as its own step after unit tests. +- Test files live alongside the source as `.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. - Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme ### Responding to Code Reviews diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index a14dc7d5..8553cda8 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -26,11 +26,13 @@ "commander": "^13.1.0", "hono": "^4.12.18", "open": "^10.2.0", + "papaparse": "^5.5.4", "pino": "^9.14.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-icons": "^5.6.0", "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", "zod": "^4.3.6", "zustand": "^5.0.13" @@ -51,8 +53,10 @@ "@testing-library/user-event": "^14.6.1", "@types/express": "^5.0.6", "@types/node": "^24.12.4", + "@types/papaparse": "^5.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.1.0", "@vitest/coverage-v8": "^4.1.0", @@ -2979,12 +2983,28 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "license": "MIT" }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -3018,6 +3038,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.6", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", @@ -5128,6 +5158,19 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -5246,6 +5289,14 @@ "dev": true, "license": "ISC" }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5499,6 +5550,19 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -5539,6 +5603,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -5556,6 +5637,21 @@ "hermes-estree": "0.25.1" } }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -6385,6 +6481,20 @@ "dev": true, "license": "MIT" }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7623,6 +7733,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8055,6 +8171,15 @@ "license": "MIT", "peer": true }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -8334,6 +8459,26 @@ } } }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/react-textarea-autosize": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", @@ -8443,6 +8588,22 @@ "node": ">=8" } }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", diff --git a/clients/web/package.json b/clients/web/package.json index 9c3e075d..14f3c1cc 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -50,11 +50,13 @@ "commander": "^13.1.0", "hono": "^4.12.18", "open": "^10.2.0", + "papaparse": "^5.5.4", "pino": "^9.14.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-icons": "^5.6.0", "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", "zod": "^4.3.6", "zustand": "^5.0.13" @@ -72,8 +74,10 @@ "@testing-library/user-event": "^14.6.1", "@types/express": "^5.0.6", "@types/node": "^24.12.4", + "@types/papaparse": "^5.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.1.0", "@vitest/coverage-v8": "^4.1.0", diff --git a/clients/web/src/components/elements/CodeHighlight/CodeHighlight.stories.tsx b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.stories.tsx new file mode 100644 index 00000000..8c91af13 --- /dev/null +++ b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CodeHighlight } from "./CodeHighlight"; + +const meta: Meta = { + title: "Elements/CodeHighlight", + component: CodeHighlight, +}; + +export default meta; +type Story = StoryObj; + +export const Json: Story = { + args: { + language: "json", + code: JSON.stringify( + { name: "my-app", version: "1.0.0", tags: ["a", "b"] }, + null, + 2, + ), + }, +}; + +export const Xml: Story = { + args: { + language: "xml", + code: '\n first\n second\n', + }, +}; + +export const Css: Story = { + args: { + language: "css", + code: ".card {\n color: var(--text);\n padding: 1rem;\n}", + }, +}; + +export const UnknownLanguage: Story = { + args: { + language: "brainfuck", + code: "++++++++[>++++[>++>+++<<-]>+>->]", + }, +}; diff --git a/clients/web/src/components/elements/CodeHighlight/CodeHighlight.test.tsx b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.test.tsx new file mode 100644 index 00000000..91049089 --- /dev/null +++ b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.test.tsx @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + renderWithMantine, + screen, + waitFor, +} from "../../../test/renderWithMantine"; + +// --- Mock the lazily-imported react-syntax-highlighter chunks ----------------- +// The prism-light runtime: a component that renders its code into a probeable +// element, plus a `registerLanguage` spy so tests can assert load behavior. +const registerLanguage = vi.fn(); +vi.mock("react-syntax-highlighter/dist/esm/prism-light", () => ({ + default: Object.assign( + ({ language, children }: { language: string; children: string }) => ( +
+        {children}
+      
+ ), + { registerLanguage }, + ), +})); +vi.mock("react-syntax-highlighter/dist/esm/styles/prism/tomorrow", () => ({ + default: {}, +})); +vi.mock("react-syntax-highlighter/dist/esm/languages/prism/json", () => ({ + default: { __grammar: "json" }, +})); +vi.mock("react-syntax-highlighter/dist/esm/languages/prism/markup", () => ({ + default: { __grammar: "markup" }, +})); +vi.mock("react-syntax-highlighter/dist/esm/languages/prism/css", () => ({ + default: { __grammar: "css" }, +})); +vi.mock("react-syntax-highlighter/dist/esm/languages/prism/yaml", () => ({ + default: { __grammar: "yaml" }, +})); +// markdown's import rejects, exercising the failed-load path. +vi.mock("react-syntax-highlighter/dist/esm/languages/prism/markdown", () => { + throw new Error("grammar load failed"); +}); + +// Module-level caches in CodeHighlight must reset between tests; re-import fresh. +async function loadComponent() { + vi.resetModules(); + registerLanguage.mockClear(); + return (await import("./CodeHighlight")).CodeHighlight; +} + +beforeEach(() => { + vi.resetModules(); +}); + +describe("CodeHighlight", () => { + it("renders plain code initially, then upgrades to the highlighter", async () => { + const CodeHighlight = await loadComponent(); + renderWithMantine(); + // Plain Mantine Code before the grammar resolves. + expect(screen.queryByTestId("prism")).not.toBeInTheDocument(); + expect(screen.getByText('{"a":1}')).toBeInTheDocument(); + // After the lazy grammar loads, the prism runtime takes over. + const prism = await screen.findByTestId("prism"); + expect(prism).toHaveAttribute("data-language", "json"); + expect(registerLanguage).toHaveBeenCalledWith("json", { + __grammar: "json", + }); + }); + + it("resolves the xml alias to the markup grammar", async () => { + const CodeHighlight = await loadComponent(); + renderWithMantine(); + const prism = await screen.findByTestId("prism"); + expect(prism).toHaveAttribute("data-language", "markup"); + expect(registerLanguage).toHaveBeenCalledWith("markup", { + __grammar: "markup", + }); + }); + + it("stays plain for an unknown language and never imports a grammar", async () => { + const CodeHighlight = await loadComponent(); + renderWithMantine(); + // Give the effect a chance to run. + await waitFor(() => expect(screen.getByText("++.")).toBeInTheDocument()); + expect(screen.queryByTestId("prism")).not.toBeInTheDocument(); + expect(registerLanguage).not.toHaveBeenCalled(); + }); + + it("stays plain when a grammar import rejects", async () => { + const CodeHighlight = await loadComponent(); + renderWithMantine(); + await waitFor(() => expect(screen.getByText("# hi")).toBeInTheDocument()); + // markdown's import throws → failed load → never registered, stays plain. + expect(screen.queryByTestId("prism")).not.toBeInTheDocument(); + expect(registerLanguage).not.toHaveBeenCalledWith( + "markdown", + expect.anything(), + ); + }); + + it("shares one grammar load across concurrent mounts of the same language", async () => { + const CodeHighlight = await loadComponent(); + renderWithMantine( + <> + + + , + ); + await waitFor(() => expect(screen.getAllByTestId("prism")).toHaveLength(2)); + // Both mounts shared the in-flight promise → grammar registered once. + expect(registerLanguage).toHaveBeenCalledTimes(1); + expect(registerLanguage).toHaveBeenCalledWith("css", { __grammar: "css" }); + }); + + it("reuses an already-registered grammar without re-importing", async () => { + const CodeHighlight = await loadComponent(); + const { unmount } = renderWithMantine( + , + ); + await screen.findByTestId("prism"); + expect(registerLanguage).toHaveBeenCalledTimes(1); + unmount(); + // A second mount finds json already registered: ready synchronously, no + // second registerLanguage call. + renderWithMantine(); + expect(await screen.findByTestId("prism")).toBeInTheDocument(); + expect(registerLanguage).toHaveBeenCalledTimes(1); + }); + + it("loads a second language reusing the already-loaded runtime", async () => { + const CodeHighlight = await loadComponent(); + const { unmount } = renderWithMantine( + , + ); + await screen.findByTestId("prism"); + unmount(); + // Runtime is cached now; mounting css registers only the new grammar. + renderWithMantine(); + const prism = await screen.findByTestId("prism"); + expect(prism).toHaveAttribute("data-language", "css"); + expect(registerLanguage).toHaveBeenCalledTimes(2); + expect(registerLanguage).toHaveBeenLastCalledWith("css", { + __grammar: "css", + }); + }); + + it("shares one runtime load across two different languages mounted together", async () => { + const CodeHighlight = await loadComponent(); + renderWithMantine( + <> + + + , + ); + await waitFor(() => expect(screen.getAllByTestId("prism")).toHaveLength(2)); + // Both grammars registered, but the shared runtime promise loaded once. + expect(registerLanguage).toHaveBeenCalledTimes(2); + }); + + it("does not retry a language whose grammar previously failed", async () => { + const CodeHighlight = await loadComponent(); + const { unmount } = renderWithMantine( + , + ); + await waitFor(() => expect(screen.getByText("# a")).toBeInTheDocument()); + unmount(); + // markdown is in failedLoads now; a second mount short-circuits and never + // attempts the import again — still plain, still no registration. + renderWithMantine(); + await waitFor(() => expect(screen.getByText("# b")).toBeInTheDocument()); + expect(screen.queryByTestId("prism")).not.toBeInTheDocument(); + expect(registerLanguage).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx new file mode 100644 index 00000000..cfce77b8 --- /dev/null +++ b/clients/web/src/components/elements/CodeHighlight/CodeHighlight.tsx @@ -0,0 +1,168 @@ +import { Code } from "@mantine/core"; +import { useEffect, useState } from "react"; +import type { ComponentType } from "react"; + +/** + * Lazy syntax-highlighting code block. The `react-syntax-highlighter` + * prism-light runtime, its theme chunk, and each language grammar are + * dynamic-imported on first use so a session that never opens a highlightable + * resource never pays for them. While a grammar is still loading (or the + * language is unknown) the raw code is shown in a plain Mantine `Code` block so + * users never see a flash of unstyled tokens. + */ +export interface CodeHighlightProps { + /** Highlight.js / Prism language tag (or an alias — see {@link LANGUAGE_ALIASES}). */ + language: string; + /** The source text to render. */ + code: string; +} + +/** A Prism grammar object; opaque to us — registered with the runtime as-is. */ +type Grammar = unknown; + +/** The prism-light runtime component plus its `registerLanguage` static. */ +type PrismRuntime = ComponentType<{ + language: string; + style: Record; + customStyle?: Record; + wrapLongLines?: boolean; + children: string; +}> & { registerLanguage: (name: string, grammar: Grammar) => void }; + +/** + * Static per-language import thunks. Each is a separate dynamic import so Vite + * emits one lazily-loaded chunk per grammar. Add an entry here (and an alias + * below if the canonical Prism name differs) as the type matrix grows. + */ +const LANGUAGE_LOADERS: Record Promise<{ default: Grammar }>> = { + json: () => import("react-syntax-highlighter/dist/esm/languages/prism/json"), + markup: () => + import("react-syntax-highlighter/dist/esm/languages/prism/markup"), + css: () => import("react-syntax-highlighter/dist/esm/languages/prism/css"), + yaml: () => import("react-syntax-highlighter/dist/esm/languages/prism/yaml"), + markdown: () => + import("react-syntax-highlighter/dist/esm/languages/prism/markdown"), +}; + +/** Friendly language tags → the canonical Prism grammar name they resolve to. */ +const LANGUAGE_ALIASES: Record = { + xml: "markup", + html: "markup", + htm: "markup", + svg: "markup", + yml: "yaml", + md: "markdown", +}; + +/** Grammars successfully loaded + registered this session. */ +const registeredLanguages = new Set(); +/** Grammars whose import rejected / are unknown — never retried. */ +const failedLoads = new Set(); +/** In-flight grammar loads, so concurrent mounts share one async call. */ +const loadingPromises = new Map>(); + +/** The shared prism-light runtime component + theme. */ +interface Runtime { + Prism: PrismRuntime; + style: Record; +} + +/** The shared prism-light runtime + theme, loaded once. */ +let runtime: Runtime | null = null; +let runtimePromise: Promise | null = null; + +/** Resolve an alias to its canonical Prism grammar name. */ +function resolveLanguage(language: string): string { + return LANGUAGE_ALIASES[language] ?? language; +} + +/** Load (and cache) the prism-light runtime component and the `tomorrow` theme. */ +async function ensureRuntime(): Promise { + if (runtime) return runtime; + if (!runtimePromise) { + runtimePromise = (async () => { + const [prismMod, styleMod] = await Promise.all([ + import("react-syntax-highlighter/dist/esm/prism-light"), + import("react-syntax-highlighter/dist/esm/styles/prism/tomorrow"), + ]); + runtime = { + Prism: prismMod.default as PrismRuntime, + style: styleMod.default, + }; + return runtime; + })(); + } + return runtimePromise; +} + +/** + * Ensure the grammar for `language` is loaded and registered. Resolves once the + * language is ready, a prior load failed, or the language is unknown — callers + * re-check {@link isLanguageReady} afterward rather than relying on this throwing. + */ +async function ensureLanguage(language: string): Promise { + const name = resolveLanguage(language); + if (registeredLanguages.has(name) || failedLoads.has(name)) return; + const inFlight = loadingPromises.get(name); + if (inFlight) return inFlight; + + const loader = LANGUAGE_LOADERS[name]; + if (!loader) { + failedLoads.add(name); + return; + } + + const load = (async () => { + try { + const rt = await ensureRuntime(); + const { default: grammar } = await loader(); + rt.Prism.registerLanguage(name, grammar); + registeredLanguages.add(name); + } catch { + failedLoads.add(name); + } finally { + loadingPromises.delete(name); + } + })(); + loadingPromises.set(name, load); + return load; +} + +const PlainCode = Code.withProps({ block: true }); + +export function CodeHighlight({ language, code }: CodeHighlightProps) { + // Readiness is derived from the module-level caches during render (so a + // language already loaded this session highlights on first paint, including + // after the `language` prop changes). The effect only bumps a tick when an + // async load finishes, forcing a re-render that re-reads the caches. + const [, bumpTick] = useState(0); + + useEffect(() => { + let cancelled = false; + void ensureLanguage(language).then(() => { + if (!cancelled) bumpTick((t) => t + 1); + }); + return () => { + cancelled = true; + }; + }, [language]); + + const resolved = resolveLanguage(language); + const rt = runtime; + // Plain block until the runtime has loaded and this grammar is registered. + if (!rt || !registeredLanguages.has(resolved)) { + return {code}; + } + + const { Prism, style } = rt; + return ( + + {code} + + ); +} diff --git a/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx b/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx new file mode 100644 index 00000000..2a710c4e --- /dev/null +++ b/clients/web/src/components/elements/ContentViewer/BinaryNotice.tsx @@ -0,0 +1,24 @@ +import { Code, Flex, Stack } from "@mantine/core"; + +const ContentWrapper = Flex.withProps({ + pos: "relative", + direction: "column", +}); + +/** + * Fallback shown when content can't be previewed — an unsupported binary MIME + * type, or a blob whose base64 fails to decode. Kept in its own module so blob + * renderers (e.g. {@link PdfFrame}) can degrade to it without importing back + * into {@link ContentViewer} (which would form an import cycle). + */ +export function BinaryNotice({ mimeType }: { mimeType: string }) { + return ( + + + + {`[Binary content (${mimeType}) — preview not supported]`} + + + + ); +} diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx index ac861fdd..a97ce01f 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.stories.tsx @@ -86,3 +86,68 @@ export const ResourceLinkBlock: Story = { }, }, }; + +// --- Resource contents (Resources screen) per-MIME dispatch ------------------- + +export const JsonHighlighted: Story = { + args: { + contents: { + uri: "file:///config.json", + mimeType: "application/json", + text: '{"name":"my-app","version":"1.0.0","tags":["a","b"]}', + }, + copyable: true, + }, +}; + +export const XmlHighlighted: Story = { + args: { + contents: { + uri: "file:///feed.xml", + mimeType: "application/xml", + text: "NewsOne", + }, + copyable: true, + }, +}; + +export const CssHighlighted: Story = { + args: { + contents: { + uri: "file:///styles.css", + mimeType: "text/css", + text: ".card { color: var(--text); padding: 1rem; border-radius: 8px; }", + }, + copyable: true, + }, +}; + +export const CsvTableContents: Story = { + args: { + contents: { + uri: "file:///people.csv", + mimeType: "text/csv", + text: "name,role,city\nAlice,Engineer,Berlin\nBob,Designer,Lisbon\nCarol,PM,Oslo", + }, + }, +}; + +export const HtmlSandboxed: Story = { + args: { + contents: { + uri: "file:///report.html", + mimeType: "text/html", + text: "

Report

This renders in a sandboxed iframe.

", + }, + }, +}; + +export const BinaryUnsupported: Story = { + args: { + contents: { + uri: "file:///archive.zip", + mimeType: "application/zip", + blob: "UEsDBAoAAAAAAA==", + }, + }, +}; diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx index 7c2733a3..1b04403f 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx @@ -1,8 +1,26 @@ -import { describe, it, expect } from "vitest"; -import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { + BlobResourceContents, + ContentBlock, + TextResourceContents, +} from "@modelcontextprotocol/sdk/types.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { ContentViewer } from "./ContentViewer"; +// Stub the lazy highlighter so JSON/XML/CSS branches are assertable +// synchronously (its real dynamic-import behavior is covered in +// CodeHighlight.test.tsx). +vi.mock("../CodeHighlight/CodeHighlight", () => ({ + CodeHighlight: ({ language, code }: { language: string; code: string }) => ( +
+      {code}
+    
+ ), +})); + +const toBase64 = (text: string): string => + Buffer.from(text, "utf-8").toString("base64"); + describe("ContentViewer", () => { it("renders text content as-is when not JSON", () => { const block: ContentBlock = { type: "text", text: "hello world" }; @@ -185,4 +203,247 @@ describe("ContentViewer", () => { ); expect(screen.getByRole("button")).toBeInTheDocument(); }); + + it("highlights a JSON text block when mimeType is application/json", () => { + const block: ContentBlock = { type: "text", text: '{"a":1}' }; + renderWithMantine( + , + ); + const probe = screen.getByTestId("code-highlight"); + expect(probe).toHaveAttribute("data-language", "json"); + expect(probe.textContent).toContain('"a": 1'); + }); + + it("renders nothing when neither block nor contents is provided", () => { + const { container } = renderWithMantine(); + // MantineProvider injects a