Compare commits

..

88 Commits
0.5.1 ... 0.7.0

Author SHA1 Message Date
Ola Hungerford
c031831e71 Merge pull request #213 from olaservo/release/0.7.0
Bump version to 0.7.0
2025-03-25 03:40:18 -07:00
Ola Hungerford
16b38071e7 Bump version to 0.7.0 2025-03-24 12:36:17 -07:00
Ola Hungerford
731cee8511 Merge pull request #206 from markacianfrani/main
fix(sidebar): maintain order when changing values
2025-03-24 12:01:12 -07:00
Mark Anthony Cianfrani
30e7a4d7b7 Merge branch 'main' into main 2025-03-24 12:59:13 -04:00
Mark Anthony Cianfrani
f2f209dbd3 fix(sidebar): maintain order when changing values 2025-03-24 12:56:41 -04:00
Ola Hungerford
f8b7b88a25 Merge pull request #198 from nathanArseneau/error-when-switching-Tools-without-changing-value
fix: set default value for input fields in ToolsTab component
2025-03-24 09:29:05 -07:00
Ola Hungerford
2890e036ed Merge pull request #201 from Larmyliu/feat/proxyServerUrl
Update Vite configuration to enable host access and use `window.location.hostname` for Inspector URL
2025-03-22 21:13:39 -07:00
Ola Hungerford
008204fda5 Merge pull request #193 from seuros/main
feat: Add utility function to escape Unicode characters in tool results
2025-03-22 19:14:16 -07:00
Abdelkader Boudih
af44efb236 chore: extract utils escapeUnicode 2025-03-22 16:25:33 +00:00
Larmyliu
51f3135c76 Merge branch 'main' into feat/proxyServerUrl 2025-03-23 00:11:58 +08:00
Abdelkader Boudih
3488bdb613 feat: Add utility function to escape Unicode characters in tool results 2025-03-21 22:21:26 +00:00
Ola Hungerford
043f6040c6 Merge pull request #174 from ryanrozich/fix-env-var-parsing
Fix environment variable parsing to handle values with equals signs
2025-03-21 09:01:09 -07:00
Ola Hungerford
74d0fcf5a3 Merge branch 'main' into fix-env-var-parsing 2025-03-21 08:39:32 -07:00
Ola Hungerford
de8795106c Merge pull request #189 from cliffhall/add-log-level-setting
Add log level setting in UI
2025-03-21 08:38:58 -07:00
Cliff Hall
c726c53b00 Merge branch 'main' into add-log-level-setting 2025-03-21 11:06:31 -04:00
Ola Hungerford
20db043b40 Merge branch 'main' into fix-env-var-parsing 2025-03-21 07:07:04 -07:00
Ola Hungerford
dfc9cf7629 Merge pull request #159 from olaservo/handle-empty-json-fields
Improve on tool input handling and add tests
2025-03-21 06:36:44 -07:00
jazminliu
4fdbcee706 Update Vite configuration to enable host access and fix proxy server URL to use the current hostname. 2025-03-20 22:04:59 +08:00
Nathan Arseneau
029e482e05 fix: set default value for input fields in ToolsTab component 2025-03-19 20:15:14 -04:00
Ola Hungerford
c463dc58c2 Simplify check for defaults and add another test 2025-03-18 06:23:25 -07:00
Ola Hungerford
a85d5e7050 Fix formatting 2025-03-17 07:56:23 -07:00
Ola Hungerford
7ddba51b36 Generate empty objects and arrays for non required object and array fields 2025-03-17 06:36:35 -07:00
Ola Hungerford
50131c6960 Merge branch 'main' into handle-empty-json-fields 2025-03-16 15:56:24 -07:00
Ola Hungerford
28978ea24f Update package lock after re-running npm install 2025-03-16 15:42:16 -07:00
Ola Hungerford
cae7c76358 Fix formatting 2025-03-16 15:31:49 -07:00
Ola Hungerford
50a65d0c7a Use generateDefaultValue for object and array defaults 2025-03-16 15:29:59 -07:00
Ola Hungerford
e1b015e40d Add comments explaining extra parsing logic 2025-03-16 15:28:07 -07:00
Ola Hungerford
7c4ed6abca Use working-directory instead of cd to client 2025-03-16 15:24:48 -07:00
Ola Hungerford
a3740c4798 Remove unneeded DynamicJsonForm.tsx 2025-03-16 15:24:34 -07:00
cliffhall
d8b5bdb613 Run prettier 2025-03-15 17:03:22 -04:00
cliffhall
5104952239 Add log level setting in UI
* This fixes #188
* In App.tsx
  - import LoggingLevel from sdk
  - add [logLevel, setLogLevel] useState with value of type LoggingLevel initialized to "debug"
  - add useEffect that stores the new logLevel in localStorage as "logLevel"
  - added sendLogLevelRequest function that takes a level argument of type LoggingLevel and sends the appropriate request. It calls setLogLevel when done, to update the local UI
  - pass logLevel and sendLogLevelRequest to Sidebar component as props
* In Sidebar.tsx
  - Import LoggingLevel and LoggingLevelSchema from sdk
  - add props and prop types for  logLevel and sendLogLevelRequest and loggingSupported
  - add Select component populated with the enum values of LoggingLevelSchema, shown only if loggingSupported is true and connectionStatus is "connected"
*
2025-03-15 16:37:10 -04:00
Ryan Rozich
67722aea71 Merge branch 'main' into fix-env-var-parsing 2025-03-15 14:27:46 -05:00
Ola Hungerford
cedf02d152 Merge pull request #185 from cliffhall/add-logging-message-handler
Add support for server logging messages
2025-03-15 09:44:22 -07:00
Cliff Hall
f01f02d5be Merge branch 'modelcontextprotocol:main' into add-logging-message-handler 2025-03-15 12:38:27 -04:00
Ola Hungerford
56932e8a93 Merge pull request #178 from lloydzhou/main
Add SSE 'Accept' header
2025-03-15 06:29:18 -07:00
lloydzhou
aeaf32fa45 fix 2025-03-15 12:58:11 +08:00
lloydzhou
090b7efdea add sse accept header 2025-03-15 12:58:11 +08:00
cliffhall
d70e6dc0e8 In useConnection.ts,
- import LoggingMessageNotificationSchema
- set onNotification as notification handler for LoggingMessageNotificationSchema
2025-03-13 15:17:17 -04:00
Ola Hungerford
1f214deeab Merge branch 'main' into handle-empty-json-fields 2025-03-13 06:17:18 -07:00
Ola Hungerford
c77252900a Merge pull request #180 from seuros/version
feat: Fetch version from package.json in useConnection hook
2025-03-12 13:48:11 -07:00
Ola Hungerford
498c02b0f1 Merge pull request #182 from leoshimo/leoshimo/181-sampling-dark-mode
fix: add dark mode support to SamplingTab JSON display (#181)
2025-03-12 13:06:24 -07:00
Ola Hungerford
60c4645eaf Fix formatting 2025-03-12 08:20:11 -07:00
Ryan Rozich
fe8b1ee88b remove comments 2025-03-11 22:46:38 -05:00
Ryan Rozich
04a90e8d89 Fix environment variable parsing to handle values with equals signs 2025-03-11 22:46:38 -05:00
leoshimo
e5ee00bf89 fix: add dark mode support to SamplingTab JSON display (#181) 2025-03-11 12:59:28 -07:00
Abdelkader Boudih
397a0f651f feat: Fetch version from package.json in useConnection hook 2025-03-11 13:33:45 +00:00
Justin Spahr-Summers
0281e5f821 Fix formatting 2025-03-11 10:56:53 +00:00
Justin Spahr-Summers
f56961ac62 Bump version 2025-03-11 10:55:14 +00:00
Justin Spahr-Summers
15bbb7502b Merge pull request #175 from avi1mizrahi/main
Add Bearer Token Support
2025-03-11 10:50:51 +00:00
Ola Hungerford
7caf6f8ba8 Merge pull request #170 from cliffhall/add-subscribe-to-resource
Add subscribe to resource functionality
2025-03-10 21:08:46 -07:00
Avi Mizrahi
dbd616905c Support bearer token 2025-03-10 17:50:12 +02:00
Ola Hungerford
1ff410ca3d Merge branch 'main' into handle-empty-json-fields 2025-03-10 05:46:57 -07:00
Cliff Hall
35a0f4611a Merge branch 'main' into add-subscribe-to-resource 2025-03-08 15:35:54 -05:00
cliffhall
952bee2605 Fix prettier complaints 2025-03-08 15:08:12 -05:00
cliffhall
a669272fda Track subscribed resources and show the appropriate subscribe or unsubscribe button on selected resource panel.
If the server does not support resource subscriptions, do not show any subscription buttons.

* In App.tsx
  - useState for resourceSubscriptions, setResourceSubscriptions a Set of type string.
  - in subscribeToResource()
    - only make the request to subscribe if the uri is not in the resourceSubscriptions set
  - in unsubscribeFromResource()
    - only make the request to unsubscribe if the uri is in the resourceSubscriptions set
  - in ResourceTab element,
    - pass a boolean resourceSubscriptionsSupported as serverCapabilities.resources.subscribe
    - pass resourceSubscriptions as a prop
* In ResourcesTab.tsx
  - deconstruct resourceSubscriptions and resourceSubscriptionsSupported from props and add prop type
  - in selected resource panel
    - don't show subscribe or unsubscribe buttons unless resourceSubscriptionsSupported is true
    - only show subscribe button if selected resource uri is not in resourceSubscriptions set
    - only show unsubscribe button if selected resource uri is in resourceSubscriptions set
    - wrap buttons in a flex div that is
      - justified right
      - has a minimal gap between
      - 2/5 wide (just big enough to contain two buttons and leave the h3 text 3/5 of the row to render and not overflow.
2025-03-08 13:40:37 -05:00
cliffhall
747c0154c5 WIP: Subscribe to resources
* In App.tsx
  - added subscribeToResource()
    - takes a uri
    - sends a `resources/subscribe` message with the uri
  - added unsubscribeFromResource()
    - takes a uri
    - sends a `resources/unsubscribe` message with the uri
  - in ResourcesTab element,
    - pass subscribeToResource and subscribeToResource invokers to component
* In notificationTypes.ts
  - add ServerNotificationSchema to NotificationSchema to permit server update messages.

* In ResourcesTab.tsx
  - deconstruct subscribeToResource and unsubscribeFromResource and add prop types
  - Add Subscribe and Unsubscribe buttons to selected resource panel, left of the refresh button. They call the sub and unsub functions that came in on props, passing the selected resource URI.
  - [WIP]: Will show the appropriate button in a follow up commit.
* In useConnection.ts
  - import ResourceUpdatedNotificationSchema
  - in the connect function,
    - set onNotification as the handler for ResourceUpdatedNotificationSchema
2025-03-08 11:05:13 -05:00
Ola Hungerford
0870a81990 Merge pull request #169 from cliffhall/dial-back-ping-button-energy
Removing the all the hype from the ping button.
2025-03-07 19:02:37 -07:00
cliffhall
ca18faa7c3 Removing the all the hype from the ping button.
Discussion at:
https://github.com/orgs/modelcontextprotocol/discussions/186
2025-03-07 13:05:45 -05:00
Ola Hungerford
014730fb2f Merge pull request #164 from TornjV/patch-1
Restore timeout search param
2025-03-06 10:22:33 -07:00
Veljko Tornjanski
9c690e004b Update useConnection.ts 2025-03-05 18:17:39 +01:00
Ola Hungerford
b9b116a5f2 Remove duplicate react-dialog from merge 2025-03-05 07:55:15 -07:00
Ola Hungerford
4efe7d7899 Merge branch 'main' into handle-empty-json-fields 2025-03-05 07:52:40 -07:00
Ola Hungerford
00836dbf9e Fix formatting 2025-03-04 20:55:57 -07:00
Ola Hungerford
dd02b69036 Merge branch 'handle-empty-json-fields' of https://github.com/olaservo/inspector into handle-empty-json-fields 2025-03-04 20:54:16 -07:00
Ola Hungerford
f9b105c0ef Use debounce instead 2025-03-04 20:54:13 -07:00
Ola Hungerford
1ae77e9ef8 Merge branch 'main' into handle-empty-json-fields 2025-02-28 17:25:38 -07:00
Ola Hungerford
06773bb6dd Fix formatting 2025-02-28 09:13:33 -07:00
Ola Hungerford
b01e386659 Always use JSON mode if the schema type is object and has no properties 2025-02-28 09:06:51 -07:00
Ola Hungerford
e7f55f083f Fix formatting 2025-02-28 07:34:01 -07:00
Ola Hungerford
36aa7316ea Fix issue where array type defaults to object 2025-02-28 07:31:26 -07:00
Ola Hungerford
0e50b68f96 Fix formatting 2025-02-28 06:30:23 -07:00
Ola Hungerford
a1eb343b79 Remove unused function plus tests 2025-02-28 06:26:04 -07:00
Ola Hungerford
82bbe58a46 Fix formatting 2025-02-27 22:08:04 -07:00
Ola Hungerford
44982e6c97 Default to nulls and update tests 2025-02-27 21:33:37 -07:00
Ola Hungerford
6ec82e21b1 Remove some fluff 2025-02-27 07:48:19 -07:00
Ola Hungerford
abd4877dae Revert to only run on main 2025-02-27 07:37:47 -07:00
Ola Hungerford
d1f5b3b933 Fix formatting 2025-02-27 07:30:38 -07:00
Ola Hungerford
720480cbbb Remove console.warn and extra comments to reduce code noise 2025-02-27 07:28:13 -07:00
Ola Hungerford
8ac7ef0985 Fix path to client 2025-02-27 07:23:40 -07:00
Ola Hungerford
238c22830b Fix formatting 2025-02-27 07:21:04 -07:00
Ola Hungerford
426fb87640 Remove comment ans trigger workflow run 2025-02-27 07:15:31 -07:00
Ola Hungerford
90ce628040 Test workflow in my branch 2025-02-27 06:54:36 -07:00
Ola Hungerford
d4a64fb5d8 Add client tests to workflow 2025-02-27 06:52:19 -07:00
Ola Hungerford
ede1ea0faa Merge branch 'main' into handle-empty-json-fields 2025-02-27 06:42:17 -07:00
Ola Hungerford
0747479694 Handle edge case and add tests for functions 2025-02-27 06:40:01 -07:00
Ola Hungerford
0b105b29c1 Extract functions 2025-02-26 19:55:01 -07:00
Ola Hungerford
0e29e2c1cf Resolve issues where JSON fields are not being rendered in form mode 2025-02-26 19:34:33 -07:00
Ola Hungerford
592dacad39 Start adding changes to address json fields 2025-02-26 09:50:47 -07:00
28 changed files with 4418 additions and 239 deletions

View File

@@ -25,6 +25,11 @@ jobs:
# Working around https://github.com/npm/cli/issues/4828 # Working around https://github.com/npm/cli/issues/4828
# - run: npm ci # - run: npm ci
- run: npm install --no-package-lock - run: npm install --no-package-lock
- name: Run client tests
working-directory: ./client
run: npm test
- run: npm run build - run: npm run build
publish: publish:

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ server/build
client/dist client/dist
client/tsconfig.app.tsbuildinfo client/tsconfig.app.tsbuildinfo
client/tsconfig.node.tsbuildinfo client/tsconfig.node.tsbuildinfo
.vscode

View File

@@ -38,6 +38,10 @@ CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging). For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).
### Authentication
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header.
### From this repository ### From this repository
If you're working on the inspector itself: If you're working on the inspector itself:

View File

@@ -27,9 +27,15 @@ async function main() {
} }
if (parsingFlags && arg === "-e" && i + 1 < args.length) { if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const [key, value] = args[++i].split("="); const envVar = args[++i];
if (key && value) { const equalsIndex = envVar.indexOf("=");
if (equalsIndex !== -1) {
const key = envVar.substring(0, equalsIndex);
const value = envVar.substring(equalsIndex + 1);
envVars[key] = value; envVars[key] = value;
} else {
envVars[envVar] = "";
} }
} else if (!command) { } else if (!command) {
command = arg; command = arg;

37
client/jest.config.cjs Normal file
View File

@@ -0,0 +1,37 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"^../components/DynamicJsonForm$":
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
"^../../components/DynamicJsonForm$":
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
jsx: "react-jsx",
tsconfig: "tsconfig.jest.json",
},
],
},
extensionsToTreatAsEsm: [".ts", ".tsx"],
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
// Exclude directories and files that don't need to be tested
testPathIgnorePatterns: [
"/node_modules/",
"/dist/",
"/bin/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
// Exclude the same patterns from coverage reports
coveragePathIgnorePatterns: [
"/node_modules/",
"/dist/",
"/bin/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-client", "name": "@modelcontextprotocol/inspector-client",
"version": "0.5.1", "version": "0.7.0",
"description": "Client-side application for the Model Context Protocol inspector", "description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -18,7 +18,9 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "jest --config jest.config.cjs",
"test:watch": "jest --config jest.config.cjs --watch"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1", "@modelcontextprotocol/sdk": "^1.6.1",
@@ -35,8 +37,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"lucide-react": "^0.447.0", "lucide-react": "^0.447.0",
"prismjs": "^1.29.0",
"pkce-challenge": "^4.1.0", "pkce-challenge": "^4.1.0",
"prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
@@ -48,18 +50,23 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/react": "^18.3.10", "@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/serve-handler": "^6.1.4", "@types/serve-handler": "^6.1.4",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"co": "^4.6.0",
"eslint": "^9.11.1", "eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12", "eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0", "globals": "^15.9.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"ts-jest": "^29.2.6",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.7.0", "typescript-eslint": "^8.7.0",
"vite": "^5.4.8" "vite": "^5.4.8"

View File

@@ -15,6 +15,7 @@ import {
Root, Root,
ServerNotification, ServerNotification,
Tool, Tool,
LoggingLevel,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import React, { Suspense, useEffect, useRef, useState } from "react"; import React, { Suspense, useEffect, useRef, useState } from "react";
import { useConnection } from "./lib/hooks/useConnection"; import { useConnection } from "./lib/hooks/useConnection";
@@ -47,7 +48,7 @@ import ToolsTab from "./components/ToolsTab";
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const PROXY_PORT = params.get("proxyPort") ?? "3000"; const PROXY_PORT = params.get("proxyPort") ?? "3000";
const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`; const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`;
const App = () => { const App = () => {
// Handle OAuth callback route // Handle OAuth callback route
@@ -91,12 +92,16 @@ const App = () => {
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio" (localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
); );
}); });
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]); const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState< const [stdErrNotifications, setStdErrNotifications] = useState<
StdErrNotification[] StdErrNotification[]
>([]); >([]);
const [roots, setRoots] = useState<Root[]>([]); const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({}); const [env, setEnv] = useState<Record<string, string>>({});
const [bearerToken, setBearerToken] = useState<string>(() => {
return localStorage.getItem("lastBearerToken") || "";
});
const [pendingSampleRequests, setPendingSampleRequests] = useState< const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array< Array<
@@ -128,6 +133,10 @@ const App = () => {
const [selectedResource, setSelectedResource] = useState<Resource | null>( const [selectedResource, setSelectedResource] = useState<Resource | null>(
null, null,
); );
const [resourceSubscriptions, setResourceSubscriptions] = useState<
Set<string>
>(new Set<string>());
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null); const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
const [selectedTool, setSelectedTool] = useState<Tool | null>(null); const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
const [nextResourceCursor, setNextResourceCursor] = useState< const [nextResourceCursor, setNextResourceCursor] = useState<
@@ -160,6 +169,7 @@ const App = () => {
args, args,
sseUrl, sseUrl,
env, env,
bearerToken,
proxyServerUrl: PROXY_SERVER_URL, proxyServerUrl: PROXY_SERVER_URL,
onNotification: (notification) => { onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]); setNotifications((prev) => [...prev, notification as ServerNotification]);
@@ -195,6 +205,10 @@ const App = () => {
localStorage.setItem("lastTransportType", transportType); localStorage.setItem("lastTransportType", transportType);
}, [transportType]); }, [transportType]);
useEffect(() => {
localStorage.setItem("lastBearerToken", bearerToken);
}, [bearerToken]);
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback) // Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
useEffect(() => { useEffect(() => {
const serverUrl = params.get("serverUrl"); const serverUrl = params.get("serverUrl");
@@ -308,6 +322,38 @@ const App = () => {
setResourceContent(JSON.stringify(response, null, 2)); setResourceContent(JSON.stringify(response, null, 2));
}; };
const subscribeToResource = async (uri: string) => {
if (!resourceSubscriptions.has(uri)) {
await makeRequest(
{
method: "resources/subscribe" as const,
params: { uri },
},
z.object({}),
"resources",
);
const clone = new Set(resourceSubscriptions);
clone.add(uri);
setResourceSubscriptions(clone);
}
};
const unsubscribeFromResource = async (uri: string) => {
if (resourceSubscriptions.has(uri)) {
await makeRequest(
{
method: "resources/unsubscribe" as const,
params: { uri },
},
z.object({}),
"resources",
);
const clone = new Set(resourceSubscriptions);
clone.delete(uri);
setResourceSubscriptions(clone);
}
};
const listPrompts = async () => { const listPrompts = async () => {
const response = await makeRequest( const response = await makeRequest(
{ {
@@ -368,6 +414,17 @@ const App = () => {
await sendNotification({ method: "notifications/roots/list_changed" }); await sendNotification({ method: "notifications/roots/list_changed" });
}; };
const sendLogLevelRequest = async (level: LoggingLevel) => {
await makeRequest(
{
method: "logging/setLevel" as const,
params: { level },
},
z.object({}),
);
setLogLevel(level);
};
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
<Sidebar <Sidebar
@@ -382,8 +439,13 @@ const App = () => {
setSseUrl={setSseUrl} setSseUrl={setSseUrl}
env={env} env={env}
setEnv={setEnv} setEnv={setEnv}
bearerToken={bearerToken}
setBearerToken={setBearerToken}
onConnect={connectMcpServer} onConnect={connectMcpServer}
stdErrNotifications={stdErrNotifications} stdErrNotifications={stdErrNotifications}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
/> />
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
@@ -485,6 +547,18 @@ const App = () => {
clearError("resources"); clearError("resources");
setSelectedResource(resource); setSelectedResource(resource);
}} }}
resourceSubscriptionsSupported={
serverCapabilities?.resources?.subscribe || false
}
resourceSubscriptions={resourceSubscriptions}
subscribeToResource={(uri) => {
clearError("resources");
subscribeToResource(uri);
}}
unsubscribeFromResource={(uri) => {
clearError("resources");
unsubscribeFromResource(uri);
}}
handleCompletion={handleCompletion} handleCompletion={handleCompletion}
completionsSupported={completionsSupported} completionsSupported={completionsSupported}
resourceContent={resourceContent} resourceContent={resourceContent}

View File

@@ -1,26 +1,36 @@
import { useState } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor"; import JsonEditor from "./JsonEditor";
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
export type JsonValue = export type JsonValue =
| string | string
| number | number
| boolean | boolean
| null | null
| undefined
| JsonValue[] | JsonValue[]
| { [key: string]: JsonValue }; | { [key: string]: JsonValue };
export type JsonSchemaType = { export type JsonSchemaType = {
type: "string" | "number" | "integer" | "boolean" | "array" | "object"; type:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
description?: string; description?: string;
required?: boolean;
default?: JsonValue;
properties?: Record<string, JsonSchemaType>; properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType; items?: JsonSchemaType;
}; };
type JsonObject = { [key: string]: JsonValue };
interface DynamicJsonFormProps { interface DynamicJsonFormProps {
schema: JsonSchemaType; schema: JsonSchemaType;
value: JsonValue; value: JsonValue;
@@ -28,13 +38,6 @@ interface DynamicJsonFormProps {
maxDepth?: number; maxDepth?: number;
} }
const formatFieldLabel = (key: string): string => {
return key
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
.replace(/_/g, " ") // Replace underscores with spaces
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
};
const DynamicJsonForm = ({ const DynamicJsonForm = ({
schema, schema,
value, value,
@@ -43,29 +46,65 @@ const DynamicJsonForm = ({
}: DynamicJsonFormProps) => { }: DynamicJsonFormProps) => {
const [isJsonMode, setIsJsonMode] = useState(false); const [isJsonMode, setIsJsonMode] = useState(false);
const [jsonError, setJsonError] = useState<string>(); const [jsonError, setJsonError] = useState<string>();
// Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing
const [rawJsonValue, setRawJsonValue] = useState<string>(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
const generateDefaultValue = (propSchema: JsonSchemaType): JsonValue => { // Use a ref to manage debouncing timeouts to avoid parsing JSON
switch (propSchema.type) { // on every keystroke which would be inefficient and error-prone
case "string": const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
return "";
case "number": // Debounce JSON parsing and parent updates to handle typing gracefully
case "integer": const debouncedUpdateParent = useCallback(
return 0; (jsonString: string) => {
case "boolean": // Clear any existing timeout
return false; if (timeoutRef.current) {
case "array": clearTimeout(timeoutRef.current);
return [];
case "object": {
const obj: JsonObject = {};
if (propSchema.properties) {
Object.entries(propSchema.properties).forEach(([key, prop]) => {
obj[key] = generateDefaultValue(prop);
});
}
return obj;
} }
default:
return null; // Set a new timeout
timeoutRef.current = setTimeout(() => {
try {
const parsed = JSON.parse(jsonString);
onChange(parsed);
setJsonError(undefined);
} catch {
// Don't set error during normal typing
}
}, 300);
},
[onChange, setJsonError],
);
// Update rawJsonValue when value prop changes
useEffect(() => {
if (!isJsonMode) {
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
}
}, [value, schema, isJsonMode]);
const handleSwitchToFormMode = () => {
if (isJsonMode) {
// When switching to Form mode, ensure we have valid JSON
try {
const parsed = JSON.parse(rawJsonValue);
// Update the parent component's state with the parsed value
onChange(parsed);
// Switch to form mode
setIsJsonMode(false);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
} else {
// Update raw JSON value when switching to JSON mode
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
setIsJsonMode(true);
} }
}; };
@@ -103,21 +142,68 @@ const DynamicJsonForm = ({
switch (propSchema.type) { switch (propSchema.type) {
case "string": case "string":
return (
<Input
type="text"
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required fields by setting undefined
// This preserves the distinction between empty string and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "number": case "number":
return (
<Input
type="number"
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required number fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
if (!isNaN(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "integer": case "integer":
return ( return (
<Input <Input
type={propSchema.type === "string" ? "text" : "number"} type="number"
value={(currentValue as string | number) ?? ""} step="1"
onChange={(e) => value={(currentValue as number)?.toString() ?? ""}
handleFieldChange( onChange={(e) => {
path, const val = e.target.value;
propSchema.type === "string" // Allow clearing non-required integer fields
? e.target.value // This preserves the distinction between 0 and unset
: Number(e.target.value), if (!val && !propSchema.required) {
) handleFieldChange(path, undefined);
} } else {
const num = Number(val);
// Only update if it's a valid integer
if (!isNaN(num) && Number.isInteger(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description} placeholder={propSchema.description}
required={propSchema.required}
/> />
); );
case "boolean": case "boolean":
@@ -127,25 +213,53 @@ const DynamicJsonForm = ({
checked={(currentValue as boolean) ?? false} checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)} onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4" className="w-4 h-4"
required={propSchema.required}
/> />
); );
case "object": case "object": {
if (!propSchema.properties) return null; // Handle case where we have a value but no schema properties
return ( const objectValue = (currentValue as JsonObject) || {};
<div className="space-y-4 border rounded-md p-4">
{Object.entries(propSchema.properties).map(([key, prop]) => ( // If we have schema properties, use them to render fields
<div key={key} className="space-y-2"> if (propSchema.properties) {
<Label>{formatFieldLabel(key)}</Label> return (
{renderFormFields( <div className="space-y-4 border rounded-md p-4">
prop, {Object.entries(propSchema.properties).map(([key, prop]) => (
(currentValue as JsonObject)?.[key], <div key={key} className="space-y-2">
[...path, key], <Label>{formatFieldLabel(key)}</Label>
depth + 1, {renderFormFields(
)} prop,
</div> objectValue[key],
))} [...path, key],
</div> depth + 1,
); )}
</div>
))}
</div>
);
}
// If we have a value but no schema properties, render fields based on the value
else if (Object.keys(objectValue).length > 0) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(objectValue).map(([key, value]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
<Input
type="text"
value={String(value)}
onChange={(e) =>
handleFieldChange([...path, key], e.target.value)
}
/>
</div>
))}
</div>
);
}
// If we have neither schema properties nor value, return null
return null;
}
case "array": { case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : []; const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null; if (!propSchema.items) return null;
@@ -187,9 +301,12 @@ const DynamicJsonForm = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const defaultValue = generateDefaultValue(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [ handleFieldChange(path, [
...arrayValue, ...arrayValue,
generateDefaultValue(propSchema.items as JsonSchemaType), defaultValue ?? null,
]); ]);
}} }}
title={ title={
@@ -215,139 +332,65 @@ const DynamicJsonForm = ({
return; return;
} }
const updateArray = (
array: JsonValue[],
path: string[],
value: JsonValue,
): JsonValue[] => {
const [index, ...restPath] = path;
const arrayIndex = Number(index);
// Validate array index
if (isNaN(arrayIndex)) {
console.error(`Invalid array index: ${index}`);
return array;
}
// Check array bounds
if (arrayIndex < 0) {
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
return array;
}
const newArray = [...array];
if (restPath.length === 0) {
newArray[arrayIndex] = value;
} else {
// Ensure index position exists
if (arrayIndex >= array.length) {
console.warn(`Extending array to index ${arrayIndex}`);
newArray.length = arrayIndex + 1;
newArray.fill(null, array.length, arrayIndex);
}
newArray[arrayIndex] = updateValue(
newArray[arrayIndex],
restPath,
value,
);
}
return newArray;
};
const updateObject = (
obj: JsonObject,
path: string[],
value: JsonValue,
): JsonObject => {
const [key, ...restPath] = path;
// Validate object key
if (typeof key !== "string") {
console.error(`Invalid object key: ${key}`);
return obj;
}
const newObj = { ...obj };
if (restPath.length === 0) {
newObj[key] = value;
} else {
// Ensure key exists
if (!(key in newObj)) {
console.warn(`Creating new key in object: ${key}`);
newObj[key] = {};
}
newObj[key] = updateValue(newObj[key], restPath, value);
}
return newObj;
};
const updateValue = (
current: JsonValue,
path: string[],
value: JsonValue,
): JsonValue => {
if (path.length === 0) return value;
try {
if (!current) {
current = !isNaN(Number(path[0])) ? [] : {};
}
// Type checking
if (Array.isArray(current)) {
return updateArray(current, path, value);
} else if (typeof current === "object" && current !== null) {
return updateObject(current, path, value);
} else {
console.error(
`Cannot update path ${path.join(".")} in non-object/array value:`,
current,
);
return current;
}
} catch (error) {
console.error(`Error updating value at path ${path.join(".")}:`, error);
return current;
}
};
try { try {
const newValue = updateValue(value, path, fieldValue); const newValue = updateValueAtPath(value, path, fieldValue);
onChange(newValue); onChange(newValue);
} catch (error) { } catch (error) {
console.error("Failed to update form value:", error); console.error("Failed to update form value:", error);
// Keep the original value unchanged
onChange(value); onChange(value);
} }
}; };
const shouldUseJsonMode =
schema.type === "object" &&
(!schema.properties || Object.keys(schema.properties).length === 0);
useEffect(() => {
if (shouldUseJsonMode && !isJsonMode) {
setIsJsonMode(true);
}
}, [shouldUseJsonMode, isJsonMode]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
variant="outline"
size="sm"
onClick={() => setIsJsonMode(!isJsonMode)}
>
{isJsonMode ? "Switch to Form" : "Switch to JSON"} {isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button> </Button>
</div> </div>
{isJsonMode ? ( {isJsonMode ? (
<JsonEditor <JsonEditor
value={JSON.stringify(value ?? generateDefaultValue(schema), null, 2)} value={rawJsonValue}
onChange={(newValue) => { onChange={(newValue) => {
try { // Always update local state
onChange(JSON.parse(newValue)); setRawJsonValue(newValue);
setJsonError(undefined);
} catch (err) { // Use the debounced function to attempt parsing and updating parent
setJsonError(err instanceof Error ? err.message : "Invalid JSON"); debouncedUpdateParent(newValue);
}
}} }}
error={jsonError} error={jsonError}
/> />
) : // If schema type is object but value is not an object or is empty, and we have actual JSON data,
// render a simple representation of the JSON data
schema.type === "object" &&
(typeof value !== "object" ||
value === null ||
Object.keys(value).length === 0) &&
rawJsonValue &&
rawJsonValue !== "{}" ? (
<div className="space-y-4 border rounded-md p-4">
<p className="text-sm text-gray-500">
Form view not available for this JSON structure. Using simplified
view:
</p>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto">
{rawJsonValue}
</pre>
<p className="text-sm text-gray-500">
Use JSON mode for full editing capabilities.
</p>
</div>
) : ( ) : (
renderFormFields(schema, value) renderFormFields(schema, value)
)} )}

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from "react";
import Editor from "react-simple-code-editor"; import Editor from "react-simple-code-editor";
import Prism from "prismjs"; import Prism from "prismjs";
import "prismjs/components/prism-json"; import "prismjs/components/prism-json";
@@ -10,7 +11,20 @@ interface JsonEditorProps {
error?: string; error?: string;
} }
const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => { const JsonEditor = ({
value,
onChange,
error: externalError,
}: JsonEditorProps) => {
const [editorContent, setEditorContent] = useState(value);
const [internalError, setInternalError] = useState<string | undefined>(
undefined,
);
useEffect(() => {
setEditorContent(value);
}, [value]);
const formatJson = (json: string): string => { const formatJson = (json: string): string => {
try { try {
return JSON.stringify(JSON.parse(json), null, 2); return JSON.stringify(JSON.parse(json), null, 2);
@@ -19,25 +33,42 @@ const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
} }
}; };
const handleEditorChange = (newContent: string) => {
setEditorContent(newContent);
setInternalError(undefined);
onChange(newContent);
};
const handleFormatJson = () => {
try {
const formatted = formatJson(editorContent);
setEditorContent(formatted);
onChange(formatted);
setInternalError(undefined);
} catch (err) {
setInternalError(err instanceof Error ? err.message : "Invalid JSON");
}
};
const displayError = internalError || externalError;
return ( return (
<div className="relative space-y-2"> <div className="relative space-y-2">
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button variant="outline" size="sm" onClick={handleFormatJson}>
variant="outline"
size="sm"
onClick={() => onChange(formatJson(value))}
>
Format JSON Format JSON
</Button> </Button>
</div> </div>
<div <div
className={`border rounded-md ${ className={`border rounded-md ${
error ? "border-red-500" : "border-gray-200 dark:border-gray-800" displayError
? "border-red-500"
: "border-gray-200 dark:border-gray-800"
}`} }`}
> >
<Editor <Editor
value={value} value={editorContent}
onValueChange={onChange} onValueChange={handleEditorChange}
highlight={(code) => highlight={(code) =>
Prism.highlight(code, Prism.languages.json, "json") Prism.highlight(code, Prism.languages.json, "json")
} }
@@ -51,7 +82,9 @@ const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
className="w-full" className="w-full"
/> />
</div> </div>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>} {displayError && (
<p className="text-sm text-red-500 mt-1">{displayError}</p>
)}
</div> </div>
); );
}; };

View File

@@ -7,11 +7,9 @@ const PingTab = ({ onPingClick }: { onPingClick: () => void }) => {
<div className="col-span-2 flex justify-center items-center"> <div className="col-span-2 flex justify-center items-center">
<Button <Button
onClick={onPingClick} onClick={onPingClick}
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white font-bold py-6 px-12 rounded-full shadow-lg transform transition duration-300 hover:scale-110 focus:outline-none focus:ring-4 focus:ring-purple-300 animate-pulse" className="font-bold py-6 px-12 rounded-full"
> >
<span className="text-3xl mr-2">🚀</span> Ping Server
MEGA PING
<span className="text-3xl ml-2">💥</span>
</Button> </Button>
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -26,6 +26,10 @@ const ResourcesTab = ({
readResource, readResource,
selectedResource, selectedResource,
setSelectedResource, setSelectedResource,
resourceSubscriptionsSupported,
resourceSubscriptions,
subscribeToResource,
unsubscribeFromResource,
handleCompletion, handleCompletion,
completionsSupported, completionsSupported,
resourceContent, resourceContent,
@@ -52,6 +56,10 @@ const ResourcesTab = ({
nextCursor: ListResourcesResult["nextCursor"]; nextCursor: ListResourcesResult["nextCursor"];
nextTemplateCursor: ListResourceTemplatesResult["nextCursor"]; nextTemplateCursor: ListResourceTemplatesResult["nextCursor"];
error: string | null; error: string | null;
resourceSubscriptionsSupported: boolean;
resourceSubscriptions: Set<string>;
subscribeToResource: (uri: string) => void;
unsubscribeFromResource: (uri: string) => void;
}) => { }) => {
const [selectedTemplate, setSelectedTemplate] = const [selectedTemplate, setSelectedTemplate] =
useState<ResourceTemplate | null>(null); useState<ResourceTemplate | null>(null);
@@ -164,14 +172,38 @@ const ResourcesTab = ({
: "Select a resource or template"} : "Select a resource or template"}
</h3> </h3>
{selectedResource && ( {selectedResource && (
<Button <div className="flex row-auto gap-1 justify-end w-2/5">
variant="outline" {resourceSubscriptionsSupported &&
size="sm" !resourceSubscriptions.has(selectedResource.uri) && (
onClick={() => readResource(selectedResource.uri)} <Button
> variant="outline"
<RefreshCw className="w-4 h-4 mr-2" /> size="sm"
Refresh onClick={() => subscribeToResource(selectedResource.uri)}
</Button> >
Subscribe
</Button>
)}
{resourceSubscriptionsSupported &&
resourceSubscriptions.has(selectedResource.uri) && (
<Button
variant="outline"
size="sm"
onClick={() =>
unsubscribeFromResource(selectedResource.uri)
}
>
Unsubscribe
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => readResource(selectedResource.uri)}
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
)} )}
</div> </div>
<div className="p-4"> <div className="p-4">

View File

@@ -43,7 +43,7 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
<h3 className="text-lg font-semibold">Recent Requests</h3> <h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => ( {pendingRequests.map((request) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4"> <div key={request.id} className="p-4 border rounded-lg space-y-4">
<pre className="bg-gray-50 p-2 rounded"> <pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
{JSON.stringify(request.request, null, 2)} {JSON.stringify(request.request, null, 2)}
</pre> </pre>
<div className="flex space-x-2"> <div className="flex space-x-2">

View File

@@ -19,6 +19,10 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { StdErrNotification } from "@/lib/notificationTypes"; import { StdErrNotification } from "@/lib/notificationTypes";
import {
LoggingLevel,
LoggingLevelSchema,
} from "@modelcontextprotocol/sdk/types.js";
import useTheme from "../lib/useTheme"; import useTheme from "../lib/useTheme";
import { version } from "../../../package.json"; import { version } from "../../../package.json";
@@ -35,8 +39,13 @@ interface SidebarProps {
setSseUrl: (url: string) => void; setSseUrl: (url: string) => void;
env: Record<string, string>; env: Record<string, string>;
setEnv: (env: Record<string, string>) => void; setEnv: (env: Record<string, string>) => void;
bearerToken: string;
setBearerToken: (token: string) => void;
onConnect: () => void; onConnect: () => void;
stdErrNotifications: StdErrNotification[]; stdErrNotifications: StdErrNotification[];
logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean;
} }
const Sidebar = ({ const Sidebar = ({
@@ -51,11 +60,17 @@ const Sidebar = ({
setSseUrl, setSseUrl,
env, env,
setEnv, setEnv,
bearerToken,
setBearerToken,
onConnect, onConnect,
stdErrNotifications, stdErrNotifications,
logLevel,
sendLogLevelRequest,
loggingSupported,
}: SidebarProps) => { }: SidebarProps) => {
const [theme, setTheme] = useTheme(); const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false); const [showEnvVars, setShowEnvVars] = useState(false);
const [showBearerToken, setShowBearerToken] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set()); const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
return ( return (
@@ -110,15 +125,43 @@ const Sidebar = ({
</div> </div>
</> </>
) : ( ) : (
<div className="space-y-2"> <>
<label className="text-sm font-medium">URL</label> <div className="space-y-2">
<Input <label className="text-sm font-medium">URL</label>
placeholder="URL" <Input
value={sseUrl} placeholder="URL"
onChange={(e) => setSseUrl(e.target.value)} value={sseUrl}
className="font-mono" onChange={(e) => setSseUrl(e.target.value)}
/> className="font-mono"
</div> />
</div>
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowBearerToken(!showBearerToken)}
className="flex items-center w-full"
>
{showBearerToken ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Authentication
</Button>
{showBearerToken && (
<div className="space-y-2">
<label className="text-sm font-medium">Bearer Token</label>
<Input
placeholder="Bearer Token"
value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)}
className="font-mono"
type="password"
/>
</div>
)}
</div>
</>
)} )}
{transportType === "stdio" && ( {transportType === "stdio" && (
<div className="space-y-2"> <div className="space-y-2">
@@ -144,9 +187,17 @@ const Sidebar = ({
value={key} value={key}
onChange={(e) => { onChange={(e) => {
const newKey = e.target.value; const newKey = e.target.value;
const newEnv = { ...env }; const newEnv = Object.entries(env).reduce(
delete newEnv[key]; (acc, [k, v]) => {
newEnv[newKey] = value; if (k === key) {
acc[newKey] = value;
} else {
acc[k] = v;
}
return acc;
},
{} as Record<string, string>,
);
setEnv(newEnv); setEnv(newEnv);
setShownEnvVars((prev) => { setShownEnvVars((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -257,6 +308,28 @@ const Sidebar = ({
: "Disconnected"} : "Disconnected"}
</span> </span>
</div> </div>
{loggingSupported && connectionStatus === "connected" && (
<div className="space-y-2">
<label className="text-sm font-medium">Logging Level</label>
<Select
value={logLevel}
onValueChange={(value: LoggingLevel) =>
sendLogLevelRequest(value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select logging level" />
</SelectTrigger>
<SelectContent>
{Object.values(LoggingLevelSchema.enum).map((level) => (
<SelectItem value={level}>{level}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{stdErrNotifications.length > 0 && ( {stdErrNotifications.length > 0 && (
<> <>
<div className="mt-4 border-t border-gray-200 pt-4"> <div className="mt-4 border-t border-gray-200 pt-4">

View File

@@ -6,16 +6,17 @@ import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm"; import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
import { generateDefaultValue } from "@/utils/schemaUtils";
import { import {
CallToolResultSchema,
CompatibilityCallToolResult,
ListToolsResult, ListToolsResult,
Tool, Tool,
CallToolResultSchema,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { AlertCircle, Send } from "lucide-react"; import { AlertCircle, Send } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ListPane from "./ListPane"; import ListPane from "./ListPane";
import { escapeUnicode } from "@/utils/escapeUnicode";
import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js";
const ToolsTab = ({ const ToolsTab = ({
tools, tools,
@@ -53,7 +54,7 @@ const ToolsTab = ({
<> <>
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4> <h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"> <pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(toolResult, null, 2)} {escapeUnicode(toolResult)}
</pre> </pre>
<h4 className="font-semibold mb-2">Errors:</h4> <h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => ( {parsedResult.error.errors.map((error, idx) => (
@@ -61,7 +62,7 @@ const ToolsTab = ({
key={idx} key={idx}
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64" className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"
> >
{JSON.stringify(error, null, 2)} {escapeUnicode(error)}
</pre> </pre>
))} ))}
</> </>
@@ -100,7 +101,7 @@ const ToolsTab = ({
</audio> </audio>
) : ( ) : (
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64"> <pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(item.resource, null, 2)} {escapeUnicode(item.resource)}
</pre> </pre>
))} ))}
</div> </div>
@@ -112,7 +113,7 @@ const ToolsTab = ({
<> <>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4> <h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"> <pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{JSON.stringify(toolResult.toolResult, null, 2)} {escapeUnicode(toolResult.toolResult)}
</pre> </pre>
</> </>
); );
@@ -214,7 +215,10 @@ const ToolsTab = ({
description: prop.description, description: prop.description,
items: prop.items, items: prop.items,
}} }}
value={(params[key] as JsonValue) ?? {}} value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
}
onChange={(newValue: JsonValue) => { onChange={(newValue: JsonValue) => {
setParams({ setParams({
...params, ...params,
@@ -229,6 +233,7 @@ const ToolsTab = ({
id={key} id={key}
name={key} name={key}
placeholder={prop.description} placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) => onChange={(e) =>
setParams({ setParams({
...params, ...params,

View File

@@ -9,6 +9,8 @@ import {
CreateMessageRequestSchema, CreateMessageRequestSchema,
ListRootsRequestSchema, ListRootsRequestSchema,
ProgressNotificationSchema, ProgressNotificationSchema,
ResourceUpdatedNotificationSchema,
LoggingMessageNotificationSchema,
Request, Request,
Result, Result,
ServerCapabilities, ServerCapabilities,
@@ -25,8 +27,11 @@ import { SESSION_KEYS } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes"; import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { authProvider } from "../auth"; import { authProvider } from "../auth";
import packageJson from "../../../package.json";
const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000; const params = new URLSearchParams(window.location.search);
const DEFAULT_REQUEST_TIMEOUT_MSEC =
parseInt(params.get("timeout") ?? "") || 10000;
interface UseConnectionOptions { interface UseConnectionOptions {
transportType: "stdio" | "sse"; transportType: "stdio" | "sse";
@@ -35,6 +40,7 @@ interface UseConnectionOptions {
sseUrl: string; sseUrl: string;
env: Record<string, string>; env: Record<string, string>;
proxyServerUrl: string; proxyServerUrl: string;
bearerToken?: string;
requestTimeout?: number; requestTimeout?: number;
onNotification?: (notification: Notification) => void; onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void;
@@ -55,6 +61,7 @@ export function useConnection({
sseUrl, sseUrl,
env, env,
proxyServerUrl, proxyServerUrl,
bearerToken,
requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC, requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC,
onNotification, onNotification,
onStdErrNotification, onStdErrNotification,
@@ -200,7 +207,7 @@ export function useConnection({
const client = new Client<Request, Notification, Result>( const client = new Client<Request, Notification, Result>(
{ {
name: "mcp-inspector", name: "mcp-inspector",
version: "0.0.1", version: packageJson.version,
}, },
{ {
capabilities: { capabilities: {
@@ -226,9 +233,11 @@ export function useConnection({
// Inject auth manually instead of using SSEClientTransport, because we're // Inject auth manually instead of using SSEClientTransport, because we're
// proxying through the inspector server first. // proxying through the inspector server first.
const headers: HeadersInit = {}; const headers: HeadersInit = {};
const tokens = await authProvider.tokens();
if (tokens) { // Use manually provided bearer token if available, otherwise use OAuth tokens
headers["Authorization"] = `Bearer ${tokens.access_token}`; const token = bearerToken || (await authProvider.tokens())?.access_token;
if (token) {
headers["Authorization"] = `Bearer ${token}`;
} }
const clientTransport = new SSEClientTransport(backendUrl, { const clientTransport = new SSEClientTransport(backendUrl, {
@@ -245,6 +254,16 @@ export function useConnection({
ProgressNotificationSchema, ProgressNotificationSchema,
onNotification, onNotification,
); );
client.setNotificationHandler(
ResourceUpdatedNotificationSchema,
onNotification,
);
client.setNotificationHandler(
LoggingMessageNotificationSchema,
onNotification,
);
} }
if (onStdErrNotification) { if (onStdErrNotification) {

View File

@@ -1,6 +1,7 @@
import { import {
NotificationSchema as BaseNotificationSchema, NotificationSchema as BaseNotificationSchema,
ClientNotificationSchema, ClientNotificationSchema,
ServerNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod"; import { z } from "zod";
@@ -13,7 +14,7 @@ export const StdErrNotificationSchema = BaseNotificationSchema.extend({
export const NotificationSchema = ClientNotificationSchema.or( export const NotificationSchema = ClientNotificationSchema.or(
StdErrNotificationSchema, StdErrNotificationSchema,
); ).or(ServerNotificationSchema);
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>; export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
export type Notification = z.infer<typeof NotificationSchema>; export type Notification = z.infer<typeof NotificationSchema>;

View File

@@ -0,0 +1,27 @@
import { escapeUnicode } from "../escapeUnicode";
describe("escapeUnicode", () => {
it("should escape Unicode characters in a string", () => {
const input = { text: "你好世界" };
const expected = '{\n "text": "\\\\u4f60\\\\u597d\\\\u4e16\\\\u754c"\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle empty strings", () => {
const input = { text: "" };
const expected = '{\n "text": ""\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle null and undefined values", () => {
const input = { text: null, value: undefined };
const expected = '{\n "text": null\n}';
expect(escapeUnicode(input)).toBe(expected);
});
it("should handle numbers and booleans", () => {
const input = { number: 123, boolean: true };
const expected = '{\n "number": 123,\n "boolean": true\n}';
expect(escapeUnicode(input)).toBe(expected);
});
});

View File

@@ -0,0 +1,180 @@
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils";
import { JsonValue } from "../../components/DynamicJsonForm";
describe("updateValueAtPath", () => {
// Basic functionality tests
test("returns the new value when path is empty", () => {
expect(updateValueAtPath({ foo: "bar" }, [], "newValue")).toBe("newValue");
});
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({
foo: "bar",
});
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({
foo: "bar",
});
});
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
expect(updateValueAtPath(null as any, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(undefined as any, ["0"], "bar")).toEqual(["bar"]);
});
// Object update tests
test("updates a simple object property", () => {
const obj = { name: "John", age: 30 };
expect(updateValueAtPath(obj, ["age"], 31)).toEqual({
name: "John",
age: 31,
});
});
test("updates a nested object property", () => {
const obj = { user: { name: "John", address: { city: "New York" } } };
expect(
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
});
test("creates missing object properties", () => {
const obj = { user: { name: "John" } };
expect(
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
});
// Array update tests
test("updates an array item", () => {
const arr = [1, 2, 3, 4];
expect(updateValueAtPath(arr, ["2"], 5)).toEqual([1, 2, 5, 4]);
});
test("extends an array when index is out of bounds", () => {
const arr = [1, 2, 3];
const result = updateValueAtPath(arr, ["5"], "new") as JsonValue[];
// Check overall array structure
expect(result).toEqual([1, 2, 3, null, null, "new"]);
// Explicitly verify that indices 3 and 4 contain null, not undefined
expect(result[3]).toBe(null);
expect(result[4]).toBe(null);
// Verify these aren't "holes" in the array (important distinction)
expect(3 in result).toBe(true);
expect(4 in result).toBe(true);
// Verify the array has the correct length
expect(result.length).toBe(6);
// Verify the array doesn't have holes by checking every index exists
expect(result.every((_, index: number) => index in result)).toBe(true);
});
test("updates a nested array item", () => {
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
expect(updateValueAtPath(obj, ["users", "1", "name"], "Janet")).toEqual({
users: [{ name: "John" }, { name: "Janet" }],
});
});
// Error handling tests
test("returns original value when trying to update a primitive with a path", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const result = updateValueAtPath("string", ["foo"], "bar");
expect(result).toBe("string");
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns original array when index is invalid", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const arr = [1, 2, 3];
expect(updateValueAtPath(arr, ["invalid"], 4)).toEqual(arr);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns original array when index is negative", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const arr = [1, 2, 3];
expect(updateValueAtPath(arr, ["-1"], 4)).toEqual(arr);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("handles sparse arrays correctly by filling holes with null", () => {
// Create a sparse array by deleting an element
const sparseArr = [1, 2, 3];
delete sparseArr[1]; // Now sparseArr is [1, <1 empty item>, 3]
// Update a value beyond the array length
const result = updateValueAtPath(sparseArr, ["5"], "new") as JsonValue[];
// Check overall array structure
expect(result).toEqual([1, null, 3, null, null, "new"]);
// Explicitly verify that index 1 (the hole) contains null, not undefined
expect(result[1]).toBe(null);
// Verify this isn't a hole in the array
expect(1 in result).toBe(true);
// Verify all indices contain null (not undefined)
expect(result[1]).not.toBe(undefined);
expect(result[3]).toBe(null);
expect(result[4]).toBe(null);
});
});
describe("getValueAtPath", () => {
test("returns the original value when path is empty", () => {
const obj = { foo: "bar" };
expect(getValueAtPath(obj, [])).toBe(obj);
});
test("returns the value at a simple path", () => {
const obj = { name: "John", age: 30 };
expect(getValueAtPath(obj, ["name"])).toBe("John");
});
test("returns the value at a nested path", () => {
const obj = { user: { name: "John", address: { city: "New York" } } };
expect(getValueAtPath(obj, ["user", "address", "city"])).toBe("New York");
});
test("returns default value when path does not exist", () => {
const obj = { user: { name: "John" } };
expect(getValueAtPath(obj, ["user", "address", "city"], "Unknown")).toBe(
"Unknown",
);
});
test("returns default value when input is null/undefined", () => {
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default");
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe(
"default",
);
});
test("handles array indices correctly", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["1"])).toBe("b");
});
test("returns default value for out of bounds array indices", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["5"], "default")).toBe("default");
});
test("returns default value for invalid array indices", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["invalid"], "default")).toBe("default");
});
test("navigates through mixed object and array paths", () => {
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane");
});
});

View File

@@ -0,0 +1,139 @@
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
import { JsonSchemaType } from "../../components/DynamicJsonForm";
describe("generateDefaultValue", () => {
test("generates default string", () => {
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
});
test("generates default number", () => {
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
});
test("generates default integer", () => {
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
});
test("generates default boolean", () => {
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
false,
);
});
test("generates default array", () => {
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
});
test("generates default empty object", () => {
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
{},
);
});
test("generates default null for unknown types", () => {
// @ts-expect-error Testing with invalid type
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
null,
);
});
test("generates empty array for non-required array", () => {
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
[],
);
});
test("generates empty object for non-required object", () => {
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
{},
);
});
test("generates null for non-required primitive types", () => {
expect(generateDefaultValue({ type: "string", required: false })).toBe(
null,
);
expect(generateDefaultValue({ type: "number", required: false })).toBe(
null,
);
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
null,
);
});
test("generates object with properties", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
properties: {
name: { type: "string", required: true },
age: { type: "number", required: true },
isActive: { type: "boolean", required: true },
},
};
expect(generateDefaultValue(schema)).toEqual({
name: "",
age: 0,
isActive: false,
});
});
test("handles nested objects", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
properties: {
user: {
type: "object",
required: true,
properties: {
name: { type: "string", required: true },
address: {
type: "object",
required: true,
properties: {
city: { type: "string", required: true },
},
},
},
},
},
};
expect(generateDefaultValue(schema)).toEqual({
user: {
name: "",
address: {
city: "",
},
},
});
});
test("uses schema default value when provided", () => {
expect(generateDefaultValue({ type: "string", default: "test" })).toBe(
"test",
);
});
});
describe("formatFieldLabel", () => {
test("formats camelCase", () => {
expect(formatFieldLabel("firstName")).toBe("First Name");
});
test("formats snake_case", () => {
expect(formatFieldLabel("first_name")).toBe("First name");
});
test("formats single word", () => {
expect(formatFieldLabel("name")).toBe("Name");
});
test("formats mixed case with underscores", () => {
expect(formatFieldLabel("user_firstName")).toBe("User first Name");
});
test("handles empty string", () => {
expect(formatFieldLabel("")).toBe("");
});
});

View File

@@ -0,0 +1,16 @@
// Utility function to escape Unicode characters
export function escapeUnicode(obj: unknown): string {
return JSON.stringify(
obj,
(_key: string, value) => {
if (typeof value === "string") {
// Replace non-ASCII characters with their Unicode escape sequences
return value.replace(/[^\0-\x7F]/g, (char) => {
return "\\u" + ("0000" + char.charCodeAt(0).toString(16)).slice(-4);
});
}
return value;
},
2,
);
}

View File

@@ -0,0 +1,149 @@
import { JsonValue } from "../components/DynamicJsonForm";
export type JsonObject = { [key: string]: JsonValue };
/**
* Updates a value at a specific path in a nested JSON structure
* @param obj The original JSON value
* @param path Array of keys/indices representing the path to the value
* @param value The new value to set
* @returns A new JSON value with the updated path
*/
export function updateValueAtPath(
obj: JsonValue,
path: string[],
value: JsonValue,
): JsonValue {
if (path.length === 0) return value;
if (obj === null || obj === undefined) {
obj = !isNaN(Number(path[0])) ? [] : {};
}
if (Array.isArray(obj)) {
return updateArray(obj, path, value);
} else if (typeof obj === "object" && obj !== null) {
return updateObject(obj as JsonObject, path, value);
} else {
console.error(
`Cannot update path ${path.join(".")} in non-object/array value:`,
obj,
);
return obj;
}
}
/**
* Updates an array at a specific path
*/
function updateArray(
array: JsonValue[],
path: string[],
value: JsonValue,
): JsonValue[] {
const [index, ...restPath] = path;
const arrayIndex = Number(index);
if (isNaN(arrayIndex)) {
console.error(`Invalid array index: ${index}`);
return array;
}
if (arrayIndex < 0) {
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
return array;
}
let newArray: JsonValue[] = [];
for (let i = 0; i < array.length; i++) {
newArray[i] = i in array ? array[i] : null;
}
if (arrayIndex >= newArray.length) {
const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null);
// Copy over the existing elements (now guaranteed to be dense)
for (let i = 0; i < newArray.length; i++) {
extendedArray[i] = newArray[i];
}
newArray = extendedArray;
}
if (restPath.length === 0) {
newArray[arrayIndex] = value;
} else {
newArray[arrayIndex] = updateValueAtPath(
newArray[arrayIndex],
restPath,
value,
);
}
return newArray;
}
/**
* Updates an object at a specific path
*/
function updateObject(
obj: JsonObject,
path: string[],
value: JsonValue,
): JsonObject {
const [key, ...restPath] = path;
// Validate object key
if (typeof key !== "string") {
console.error(`Invalid object key: ${key}`);
return obj;
}
const newObj = { ...obj };
if (restPath.length === 0) {
newObj[key] = value;
} else {
// Ensure key exists
if (!(key in newObj)) {
newObj[key] = {};
}
newObj[key] = updateValueAtPath(newObj[key], restPath, value);
}
return newObj;
}
/**
* Gets a value at a specific path in a nested JSON structure
* @param obj The JSON value to traverse
* @param path Array of keys/indices representing the path to the value
* @param defaultValue Value to return if path doesn't exist
* @returns The value at the path, or defaultValue if not found
*/
export function getValueAtPath(
obj: JsonValue,
path: string[],
defaultValue: JsonValue = null,
): JsonValue {
if (path.length === 0) return obj;
const [first, ...rest] = path;
if (obj === null || obj === undefined) {
return defaultValue;
}
if (Array.isArray(obj)) {
const index = Number(first);
if (isNaN(index) || index < 0 || index >= obj.length) {
return defaultValue;
}
return getValueAtPath(obj[index], rest, defaultValue);
}
if (typeof obj === "object" && obj !== null) {
if (!(first in obj)) {
return defaultValue;
}
return getValueAtPath((obj as JsonObject)[first], rest, defaultValue);
}
return defaultValue;
}

View File

@@ -0,0 +1,57 @@
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm";
import { JsonObject } from "./jsonPathUtils";
/**
* Generates a default value based on a JSON schema type
* @param schema The JSON schema definition
* @returns A default value matching the schema type, or null for non-required fields
*/
export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
if ("default" in schema) {
return schema.default;
}
if (!schema.required) {
if (schema.type === "array") return [];
if (schema.type === "object") return {};
return null;
}
switch (schema.type) {
case "string":
return "";
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object": {
if (!schema.properties) return {};
const obj: JsonObject = {};
Object.entries(schema.properties)
.filter(([, prop]) => prop.required)
.forEach(([key, prop]) => {
const value = generateDefaultValue(prop);
obj[key] = value;
});
return obj;
}
default:
return null;
}
}
/**
* Formats a field key into a human-readable label
* @param key The field key to format
* @returns A formatted label string
*/
export function formatFieldLabel(key: string): string {
return key
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
.replace(/_/g, " ") // Replace underscores with spaces
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
}

10
client/tsconfig.jest.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"jsx": "react-jsx",
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "node"
},
"include": ["src"]
}

View File

@@ -5,7 +5,9 @@ import { defineConfig } from "vite";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {}, server: {
host: true,
},
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),

3270
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector", "name": "@modelcontextprotocol/inspector",
"version": "0.5.1", "version": "0.7.0",
"description": "Model Context Protocol inspector", "description": "Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",
@@ -34,14 +34,15 @@
"publish-all": "npm publish --workspaces --access public && npm publish --access public" "publish-all": "npm publish --workspaces --access public && npm publish --access public"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/inspector-client": "0.4.1", "@modelcontextprotocol/inspector-client": "^0.7.0",
"@modelcontextprotocol/inspector-server": "0.4.1", "@modelcontextprotocol/inspector-server": "^0.7.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2", "spawn-rx": "^5.1.2",
"ts-node": "^10.9.2" "ts-node": "^10.9.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/shell-quote": "^1.7.5", "@types/shell-quote": "^1.7.5",
"prettier": "3.3.3" "prettier": "3.3.3"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@modelcontextprotocol/inspector-server", "name": "@modelcontextprotocol/inspector-server",
"version": "0.5.1", "version": "0.7.0",
"description": "Server-side application for the Model Context Protocol inspector", "description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT", "license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)", "author": "Anthropic, PBC (https://anthropic.com)",

View File

@@ -66,7 +66,9 @@ const createTransport = async (req: express.Request) => {
return transport; return transport;
} else if (transportType === "sse") { } else if (transportType === "sse") {
const url = query.url as string; const url = query.url as string;
const headers: HeadersInit = {}; const headers: HeadersInit = {
Accept: "text/event-stream",
};
for (const key of SSE_HEADERS_PASSTHROUGH) { for (const key of SSE_HEADERS_PASSTHROUGH) {
if (req.headers[key] === undefined) { if (req.headers[key] === undefined) {
continue; continue;