Resolve merge conflicts in DynamicJsonForm.tsx and ToolsTab.tsx
- Merged JSON validation functionality from PR branch with enhanced form features from main - Preserved validateJson method and DynamicJsonFormRef interface for tool validation - Added support for oneOf schemas, enum dropdowns, and enhanced string field types - Integrated meta display functionality and resource content props - Maintained JSON validation before tool execution to prevent invalid data submission
This commit is contained in:
commit
8ce1bbd346
42 changed files with 3238 additions and 582 deletions
64
.github/workflows/claude.yml
vendored
Normal file
64
.github/workflows/claude.yml
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
) &&
|
||||
(
|
||||
github.actor == 'ihrpr' ||
|
||||
github.actor == 'olaservo'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
|
||||
# model: "claude-opus-4-20250514"
|
||||
|
||||
# Optional: Customize the trigger phrase (default: @claude)
|
||||
# trigger_phrase: "/claude"
|
||||
|
||||
# Optional: Trigger when specific user is assigned to an issue
|
||||
# assignee_trigger: "claude-bot"
|
||||
|
||||
# Optional: Allow Claude to run specific commands
|
||||
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||
|
||||
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||
# custom_instructions: |
|
||||
# Follow our coding standards
|
||||
# Ensure all new code has tests
|
||||
# Use TypeScript for new files
|
||||
|
||||
# Optional: Custom environment variables for Claude
|
||||
# claude_env: |
|
||||
# NODE_ENV: test
|
||||
41
.github/workflows/cli_tests.yml
vendored
Normal file
41
.github/workflows/cli_tests.yml
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
name: CLI Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "cli/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "cli/**"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd ..
|
||||
npm ci --ignore-scripts
|
||||
|
||||
- name: Build CLI
|
||||
run: npm run build
|
||||
|
||||
- name: Explicitly pre-install test dependencies
|
||||
run: npx -y @modelcontextprotocol/server-everything --help || true
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
env:
|
||||
NPM_CONFIG_YES: true
|
||||
CI: true
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -9,7 +9,8 @@ client/tsconfig.app.tsbuildinfo
|
|||
client/tsconfig.node.tsbuildinfo
|
||||
cli/build
|
||||
test-output
|
||||
# symlinked by `npm run link:sdk`:
|
||||
sdk
|
||||
client/playwright-report/
|
||||
client/results.json
|
||||
client/test-results/
|
||||
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -166,6 +166,12 @@ If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGER
|
|||
DANGEROUSLY_OMIT_AUTH=true npm start
|
||||
```
|
||||
|
||||
You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server:
|
||||
|
||||
```bash
|
||||
MCP_PROXY_AUTH_TOKEN=$(openssl rand -hex 32) npm start
|
||||
```
|
||||
|
||||
#### Local-only Binding
|
||||
|
||||
By default, both the MCP Inspector proxy server and client bind only to `localhost` to prevent network access. This ensures they are not accessible from other devices on the network. If you need to bind to all interfaces for development purposes, you can override this with the `HOST` environment variable:
|
||||
|
|
@ -254,6 +260,12 @@ Development mode:
|
|||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# To co-develop with the typescript-sdk package (assuming it's cloned in ../typescript-sdk; set MCP_SDK otherwise):
|
||||
npm run dev:sdk "cd sdk && npm run examples:simple-server:w"
|
||||
# then open http://localhost:3000/mcp as SHTTP in the inspector.
|
||||
# To go back to the deployed SDK version:
|
||||
# npm run unlink:sdk && npm i
|
||||
```
|
||||
|
||||
> **Note for Windows users:**
|
||||
|
|
@ -303,7 +315,7 @@ npx @modelcontextprotocol/inspector --cli node build/index.js --method prompts/l
|
|||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com
|
||||
|
||||
# Connect to a remote MCP server (with Streamable HTTP transport)
|
||||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http
|
||||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list
|
||||
|
||||
# Call a tool on a remote server
|
||||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --method tools/call --tool-name remotetool --tool-arg param=value
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-cli",
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.2",
|
||||
"description": "CLI for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
},
|
||||
"devDependencies": {},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.13.1",
|
||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||
"commander": "^13.1.0",
|
||||
"spawn-rx": "^5.1.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const colors = {
|
|||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { execSync, spawn } from "child_process";
|
||||
import { spawn } from "child_process";
|
||||
import os from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
|
|
@ -680,7 +680,7 @@ async function runTests() {
|
|||
// Test 26: HTTP transport with explicit --transport http flag
|
||||
await runBasicTest(
|
||||
"http_transport_with_explicit_flag",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://127.0.0.1:3001/mcp",
|
||||
"--transport",
|
||||
"http",
|
||||
"--cli",
|
||||
|
|
@ -710,6 +710,26 @@ async function runTests() {
|
|||
"tools/list",
|
||||
);
|
||||
|
||||
// Test 29: HTTP transport without URL (should fail)
|
||||
await runErrorTest(
|
||||
"http_transport_without_url",
|
||||
"--transport",
|
||||
"http",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
);
|
||||
|
||||
// Test 30: SSE transport without URL (should fail)
|
||||
await runErrorTest(
|
||||
"sse_transport_without_url",
|
||||
"--transport",
|
||||
"sse",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
);
|
||||
|
||||
// Kill HTTP server
|
||||
try {
|
||||
process.kill(-httpServer.pid);
|
||||
|
|
|
|||
|
|
@ -50,28 +50,15 @@ function delay(ms: number): Promise<void> {
|
|||
}
|
||||
|
||||
async function runWebClient(args: Args): Promise<void> {
|
||||
const inspectorServerPath = resolve(
|
||||
__dirname,
|
||||
"../../",
|
||||
"server",
|
||||
"build",
|
||||
"index.js",
|
||||
);
|
||||
|
||||
// Path to the client entry point
|
||||
const inspectorClientPath = resolve(
|
||||
__dirname,
|
||||
"../../",
|
||||
"client",
|
||||
"bin",
|
||||
"client.js",
|
||||
"start.js",
|
||||
);
|
||||
|
||||
const CLIENT_PORT: string = process.env.CLIENT_PORT ?? "6274";
|
||||
const SERVER_PORT: string = process.env.SERVER_PORT ?? "6277";
|
||||
|
||||
console.log("Starting MCP inspector...");
|
||||
|
||||
const abort = new AbortController();
|
||||
let cancelled: boolean = false;
|
||||
process.on("SIGINT", () => {
|
||||
|
|
@ -79,42 +66,26 @@ async function runWebClient(args: Args): Promise<void> {
|
|||
abort.abort();
|
||||
});
|
||||
|
||||
let server: ReturnType<typeof spawnPromise>;
|
||||
let serverOk: unknown;
|
||||
// Build arguments to pass to start.js
|
||||
const startArgs: string[] = [];
|
||||
|
||||
// Pass environment variables
|
||||
for (const [key, value] of Object.entries(args.envArgs)) {
|
||||
startArgs.push("-e", `${key}=${value}`);
|
||||
}
|
||||
|
||||
// Pass command and args (using -- to separate them)
|
||||
if (args.command) {
|
||||
startArgs.push("--", args.command, ...args.args);
|
||||
}
|
||||
|
||||
try {
|
||||
server = spawnPromise(
|
||||
"node",
|
||||
[
|
||||
inspectorServerPath,
|
||||
...(args.command ? [`--env`, args.command] : []),
|
||||
...(args.args ? [`--args=${args.args.join(" ")}`] : []),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT,
|
||||
MCP_ENV_VARS: JSON.stringify(args.envArgs),
|
||||
},
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Make sure server started before starting client
|
||||
serverOk = await Promise.race([server, delay(2 * 1000)]);
|
||||
} catch (error) {}
|
||||
|
||||
if (serverOk) {
|
||||
try {
|
||||
await spawnPromise("node", [inspectorClientPath], {
|
||||
env: { ...process.env, PORT: CLIENT_PORT },
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!cancelled || process.env.DEBUG) throw e;
|
||||
}
|
||||
await spawnPromise("node", [inspectorClientPath, ...startArgs], {
|
||||
signal: abort.signal,
|
||||
echoOutput: true,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!cancelled || process.env.DEBUG) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -275,7 +246,7 @@ async function main(): Promise<void> {
|
|||
const args = parseArgs();
|
||||
|
||||
if (args.cli) {
|
||||
runCli(args);
|
||||
await runCli(args);
|
||||
} else {
|
||||
await runWebClient(args);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ export async function connect(
|
|||
): Promise<void> {
|
||||
try {
|
||||
await client.connect(transport);
|
||||
|
||||
if (client.getServerCapabilities()?.logging) {
|
||||
// default logging level is undefined in the spec, but the user of the
|
||||
// inspector most likely wants debug.
|
||||
await client.setLoggingLevel("debug");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to connect to MCP server: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
|
|
|||
|
|
@ -287,6 +287,8 @@ async function main(): Promise<void> {
|
|||
try {
|
||||
const args = parseArgs();
|
||||
await callMethod(args);
|
||||
// Explicitly exit to ensure process terminates in CI
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,24 +14,6 @@ export type TransportOptions = {
|
|||
url?: string;
|
||||
};
|
||||
|
||||
function createSSETransport(options: TransportOptions): Transport {
|
||||
const baseUrl = new URL(options.url ?? "");
|
||||
const sseUrl = baseUrl.pathname.endsWith("/sse")
|
||||
? baseUrl
|
||||
: new URL("/sse", baseUrl);
|
||||
|
||||
return new SSEClientTransport(sseUrl);
|
||||
}
|
||||
|
||||
function createHTTPTransport(options: TransportOptions): Transport {
|
||||
const baseUrl = new URL(options.url ?? "");
|
||||
const mcpUrl = baseUrl.pathname.endsWith("/mcp")
|
||||
? baseUrl
|
||||
: new URL("/mcp", baseUrl);
|
||||
|
||||
return new StreamableHTTPClientTransport(mcpUrl);
|
||||
}
|
||||
|
||||
function createStdioTransport(options: TransportOptions): Transport {
|
||||
let args: string[] = [];
|
||||
|
||||
|
|
@ -75,12 +57,18 @@ export function createTransport(options: TransportOptions): Transport {
|
|||
return createStdioTransport(options);
|
||||
}
|
||||
|
||||
// If not STDIO, then it must be either SSE or HTTP.
|
||||
if (!options.url) {
|
||||
throw new Error("URL must be provided for SSE or HTTP transport types.");
|
||||
}
|
||||
const url = new URL(options.url);
|
||||
|
||||
if (transportType === "sse") {
|
||||
return createSSETransport(options);
|
||||
return new SSEClientTransport(url);
|
||||
}
|
||||
|
||||
if (transportType === "http") {
|
||||
return createHTTPTransport(options);
|
||||
return new StreamableHTTPClientTransport(url);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported transport type: ${transportType}`);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ async function startDevServer(serverOptions) {
|
|||
...process.env,
|
||||
SERVER_PORT,
|
||||
CLIENT_PORT,
|
||||
MCP_PROXY_TOKEN: sessionToken,
|
||||
MCP_PROXY_AUTH_TOKEN: sessionToken,
|
||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||
},
|
||||
signal: abort.signal,
|
||||
|
|
@ -91,15 +91,17 @@ async function startProdServer(serverOptions) {
|
|||
"node",
|
||||
[
|
||||
inspectorServerPath,
|
||||
...(command ? [`--env`, command] : []),
|
||||
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
|
||||
...(command ? [`--command=${command}`] : []),
|
||||
...(mcpServerArgs && mcpServerArgs.length > 0
|
||||
? [`--args=${mcpServerArgs.join(" ")}`]
|
||||
: []),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
SERVER_PORT,
|
||||
CLIENT_PORT,
|
||||
MCP_PROXY_TOKEN: sessionToken,
|
||||
MCP_PROXY_AUTH_TOKEN: sessionToken,
|
||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||
},
|
||||
signal: abort.signal,
|
||||
|
|
@ -247,8 +249,9 @@ async function main() {
|
|||
: "Starting MCP inspector...",
|
||||
);
|
||||
|
||||
// Generate session token for authentication
|
||||
const sessionToken = randomBytes(32).toString("hex");
|
||||
// Use provided token from environment or generate a new one
|
||||
const sessionToken =
|
||||
process.env.MCP_PROXY_AUTH_TOKEN || randomBytes(32).toString("hex");
|
||||
const authDisabled = !!process.env.DANGEROUSLY_OMIT_AUTH;
|
||||
|
||||
const abort = new AbortController();
|
||||
|
|
|
|||
16
client/e2e/startup-state.spec.ts
Normal file
16
client/e2e/startup-state.spec.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Adjust the URL if your dev server runs on a different port
|
||||
const APP_URL = "http://localhost:6274/";
|
||||
|
||||
test.describe("Startup State", () => {
|
||||
test("should not navigate to a tab when Inspector first opens", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(APP_URL);
|
||||
|
||||
// Check that there is no hash fragment in the URL
|
||||
const url = page.url();
|
||||
expect(url).not.toContain("#");
|
||||
});
|
||||
});
|
||||
|
|
@ -32,4 +32,5 @@ module.exports = {
|
|||
"/e2e/",
|
||||
"\\.config\\.(js|ts|cjs|mjs)$",
|
||||
],
|
||||
randomize: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/inspector-client",
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.2",
|
||||
"description": "Client-side application for the Model Context Protocol inspector",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
|
|
@ -25,9 +25,8 @@
|
|||
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.13.1",
|
||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"ajv": "^6.12.6",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
|
|
@ -37,6 +36,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"ajv": "^6.12.6",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
|
|
@ -49,19 +49,19 @@
|
|||
"serve-handler": "^6.1.6",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.11.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/node": "^22.17.0",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/serve-handler": "^6.1.4",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"co": "^4.6.0",
|
||||
"eslint": "^9.11.1",
|
||||
|
|
@ -70,11 +70,11 @@
|
|||
"globals": "^15.9.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"ts-jest": "^29.2.6",
|
||||
"ts-jest": "^29.4.0",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.7.0",
|
||||
"vite": "^6.3.0"
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,10 @@ import {
|
|||
initializeInspectorConfig,
|
||||
saveInspectorConfig,
|
||||
} from "./utils/configUtils";
|
||||
import ElicitationTab, {
|
||||
PendingElicitationRequest,
|
||||
ElicitationResponse,
|
||||
} from "./components/ElicitationTab";
|
||||
|
||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||
|
||||
|
|
@ -80,6 +84,9 @@ const App = () => {
|
|||
ResourceTemplate[]
|
||||
>([]);
|
||||
const [resourceContent, setResourceContent] = useState<string>("");
|
||||
const [resourceContentMap, setResourceContentMap] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
||||
const [promptContent, setPromptContent] = useState<string>("");
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
|
|
@ -116,6 +123,14 @@ const App = () => {
|
|||
return localStorage.getItem("lastHeaderName") || "";
|
||||
});
|
||||
|
||||
const [oauthClientId, setOauthClientId] = useState<string>(() => {
|
||||
return localStorage.getItem("lastOauthClientId") || "";
|
||||
});
|
||||
|
||||
const [oauthScope, setOauthScope] = useState<string>(() => {
|
||||
return localStorage.getItem("lastOauthScope") || "";
|
||||
});
|
||||
|
||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||
Array<
|
||||
PendingRequest & {
|
||||
|
|
@ -124,13 +139,19 @@ const App = () => {
|
|||
}
|
||||
>
|
||||
>([]);
|
||||
const [pendingElicitationRequests, setPendingElicitationRequests] = useState<
|
||||
Array<
|
||||
PendingElicitationRequest & {
|
||||
resolve: (response: ElicitationResponse) => void;
|
||||
decline: (error: Error) => void;
|
||||
}
|
||||
>
|
||||
>([]);
|
||||
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
|
||||
|
||||
// Auth debugger state
|
||||
const [authState, setAuthState] =
|
||||
useState<AuthDebuggerState>(EMPTY_DEBUGGER_STATE);
|
||||
|
||||
// Helper function to update specific auth state properties
|
||||
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
|
||||
setAuthState((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
|
@ -158,6 +179,19 @@ const App = () => {
|
|||
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
|
||||
const progressTokenRef = useRef(0);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
const initialTab = hash || "resources";
|
||||
return initialTab;
|
||||
});
|
||||
|
||||
const currentTabRef = useRef<string>(activeTab);
|
||||
const lastToolCallOriginTabRef = useRef<string>(activeTab);
|
||||
|
||||
useEffect(() => {
|
||||
currentTabRef.current = activeTab;
|
||||
}, [activeTab]);
|
||||
|
||||
const { height: historyPaneHeight, handleDragStart } = useDraggablePane(300);
|
||||
const {
|
||||
width: sidebarWidth,
|
||||
|
|
@ -184,6 +218,8 @@ const App = () => {
|
|||
env,
|
||||
bearerToken,
|
||||
headerName,
|
||||
oauthClientId,
|
||||
oauthScope,
|
||||
config,
|
||||
onNotification: (notification) => {
|
||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||
|
|
@ -200,9 +236,65 @@ const App = () => {
|
|||
{ id: nextRequestId.current++, request, resolve, reject },
|
||||
]);
|
||||
},
|
||||
onElicitationRequest: (request, resolve) => {
|
||||
const currentTab = lastToolCallOriginTabRef.current;
|
||||
|
||||
setPendingElicitationRequests((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nextRequestId.current++,
|
||||
request: {
|
||||
id: nextRequestId.current,
|
||||
message: request.params.message,
|
||||
requestedSchema: request.params.requestedSchema,
|
||||
},
|
||||
originatingTab: currentTab,
|
||||
resolve,
|
||||
decline: (error: Error) => {
|
||||
console.error("Elicitation request rejected:", error);
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
setActiveTab("elicitations");
|
||||
window.location.hash = "elicitations";
|
||||
},
|
||||
getRoots: () => rootsRef.current,
|
||||
defaultLoggingLevel: logLevel,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (serverCapabilities) {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
const validTabs = [
|
||||
...(serverCapabilities?.resources ? ["resources"] : []),
|
||||
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||
"ping",
|
||||
"sampling",
|
||||
"elicitations",
|
||||
"roots",
|
||||
"auth",
|
||||
];
|
||||
|
||||
const isValidTab = validTabs.includes(hash);
|
||||
|
||||
if (!isValidTab) {
|
||||
const defaultTab = serverCapabilities?.resources
|
||||
? "resources"
|
||||
: serverCapabilities?.prompts
|
||||
? "prompts"
|
||||
: serverCapabilities?.tools
|
||||
? "tools"
|
||||
: "ping";
|
||||
|
||||
setActiveTab(defaultTab);
|
||||
window.location.hash = defaultTab;
|
||||
}
|
||||
}
|
||||
}, [serverCapabilities]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastCommand", command);
|
||||
}, [command]);
|
||||
|
|
@ -227,11 +319,18 @@ const App = () => {
|
|||
localStorage.setItem("lastHeaderName", headerName);
|
||||
}, [headerName]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastOauthClientId", oauthClientId);
|
||||
}, [oauthClientId]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("lastOauthScope", oauthScope);
|
||||
}, [oauthScope]);
|
||||
|
||||
useEffect(() => {
|
||||
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
|
||||
}, [config]);
|
||||
|
||||
// Auto-connect to previously saved serverURL after OAuth callback
|
||||
const onOAuthConnect = useCallback(
|
||||
(serverUrl: string) => {
|
||||
setSseUrl(serverUrl);
|
||||
|
|
@ -241,7 +340,6 @@ const App = () => {
|
|||
[connectMcpServer],
|
||||
);
|
||||
|
||||
// Update OAuth debug state during debug callback
|
||||
const onOAuthDebugConnect = useCallback(
|
||||
async ({
|
||||
authorizationCode,
|
||||
|
|
@ -262,7 +360,6 @@ const App = () => {
|
|||
}
|
||||
|
||||
if (restoredState && authorizationCode) {
|
||||
// Restore the previous auth state and continue the OAuth flow
|
||||
let currentState: AuthDebuggerState = {
|
||||
...restoredState,
|
||||
authorizationCode,
|
||||
|
|
@ -273,12 +370,10 @@ const App = () => {
|
|||
};
|
||||
|
||||
try {
|
||||
// Create a new state machine instance to continue the flow
|
||||
const stateMachine = new OAuthStateMachine(sseUrl, (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
});
|
||||
|
||||
// Continue stepping through the OAuth flow from where we left off
|
||||
while (
|
||||
currentState.oauthStep !== "complete" &&
|
||||
currentState.oauthStep !== "authorization_code"
|
||||
|
|
@ -287,7 +382,6 @@ const App = () => {
|
|||
}
|
||||
|
||||
if (currentState.oauthStep === "complete") {
|
||||
// After the flow completes or reaches a user-input step, update the app state
|
||||
updateAuthState({
|
||||
...currentState,
|
||||
statusMessage: {
|
||||
|
|
@ -310,7 +404,6 @@ const App = () => {
|
|||
});
|
||||
}
|
||||
} else if (authorizationCode) {
|
||||
// Fallback to the original behavior if no state was restored
|
||||
updateAuthState({
|
||||
authorizationCode,
|
||||
oauthStep: "token_request",
|
||||
|
|
@ -320,7 +413,6 @@ const App = () => {
|
|||
[sseUrl],
|
||||
);
|
||||
|
||||
// Load OAuth tokens when sseUrl changes
|
||||
useEffect(() => {
|
||||
const loadOAuthTokens = async () => {
|
||||
try {
|
||||
|
|
@ -374,10 +466,36 @@ const App = () => {
|
|||
}, [roots]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.location.hash) {
|
||||
window.location.hash = "resources";
|
||||
if (mcpClient && !window.location.hash) {
|
||||
const defaultTab = serverCapabilities?.resources
|
||||
? "resources"
|
||||
: serverCapabilities?.prompts
|
||||
? "prompts"
|
||||
: serverCapabilities?.tools
|
||||
? "tools"
|
||||
: "ping";
|
||||
window.location.hash = defaultTab;
|
||||
} else if (!mcpClient && window.location.hash) {
|
||||
// Clear hash when disconnected - completely remove the fragment
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
}, [mcpClient, serverCapabilities]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash && hash !== activeTab) {
|
||||
setActiveTab(hash);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
return () => window.removeEventListener("hashchange", handleHashChange);
|
||||
}, [activeTab]);
|
||||
|
||||
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
|
||||
setPendingSampleRequests((prev) => {
|
||||
|
|
@ -395,6 +513,44 @@ const App = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleResolveElicitation = (
|
||||
id: number,
|
||||
response: ElicitationResponse,
|
||||
) => {
|
||||
setPendingElicitationRequests((prev) => {
|
||||
const request = prev.find((r) => r.id === id);
|
||||
if (request) {
|
||||
request.resolve(response);
|
||||
|
||||
if (request.originatingTab) {
|
||||
const originatingTab = request.originatingTab;
|
||||
|
||||
const validTabs = [
|
||||
...(serverCapabilities?.resources ? ["resources"] : []),
|
||||
...(serverCapabilities?.prompts ? ["prompts"] : []),
|
||||
...(serverCapabilities?.tools ? ["tools"] : []),
|
||||
"ping",
|
||||
"sampling",
|
||||
"elicitations",
|
||||
"roots",
|
||||
"auth",
|
||||
];
|
||||
|
||||
if (validTabs.includes(originatingTab)) {
|
||||
setActiveTab(originatingTab);
|
||||
window.location.hash = originatingTab;
|
||||
|
||||
setTimeout(() => {
|
||||
setActiveTab(originatingTab);
|
||||
window.location.hash = originatingTab;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
return prev.filter((r) => r.id !== id);
|
||||
});
|
||||
};
|
||||
|
||||
const clearError = (tabKey: keyof typeof errors) => {
|
||||
setErrors((prev) => ({ ...prev, [tabKey]: null }));
|
||||
};
|
||||
|
|
@ -452,7 +608,23 @@ const App = () => {
|
|||
setNextResourceTemplateCursor(response.nextCursor);
|
||||
};
|
||||
|
||||
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
|
||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "prompts/get" as const,
|
||||
params: { name, arguments: args },
|
||||
},
|
||||
GetPromptResultSchema,
|
||||
"prompts",
|
||||
);
|
||||
setPromptContent(JSON.stringify(response, null, 2));
|
||||
};
|
||||
|
||||
const readResource = async (uri: string) => {
|
||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "resources/read" as const,
|
||||
|
|
@ -461,7 +633,12 @@ const App = () => {
|
|||
ReadResourceResultSchema,
|
||||
"resources",
|
||||
);
|
||||
setResourceContent(JSON.stringify(response, null, 2));
|
||||
const content = JSON.stringify(response, null, 2);
|
||||
setResourceContent(content);
|
||||
setResourceContentMap((prev) => ({
|
||||
...prev,
|
||||
[uri]: content,
|
||||
}));
|
||||
};
|
||||
|
||||
const subscribeToResource = async (uri: string) => {
|
||||
|
|
@ -509,18 +686,6 @@ const App = () => {
|
|||
setNextPromptCursor(response.nextCursor);
|
||||
};
|
||||
|
||||
const getPrompt = async (name: string, args: Record<string, string> = {}) => {
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
method: "prompts/get" as const,
|
||||
params: { name, arguments: args },
|
||||
},
|
||||
GetPromptResultSchema,
|
||||
"prompts",
|
||||
);
|
||||
setPromptContent(JSON.stringify(response, null, 2));
|
||||
};
|
||||
|
||||
const listTools = async () => {
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
|
|
@ -532,11 +697,12 @@ const App = () => {
|
|||
);
|
||||
setTools(response.tools);
|
||||
setNextToolCursor(response.nextCursor);
|
||||
// Cache output schemas for validation
|
||||
cacheToolOutputSchemas(response.tools);
|
||||
};
|
||||
|
||||
const callTool = async (name: string, params: Record<string, unknown>) => {
|
||||
lastToolCallOriginTabRef.current = currentTabRef.current;
|
||||
|
||||
try {
|
||||
const response = await sendMCPRequest(
|
||||
{
|
||||
|
|
@ -552,6 +718,7 @@ const App = () => {
|
|||
CompatibilityCallToolResultSchema,
|
||||
"tools",
|
||||
);
|
||||
|
||||
setToolResult(response);
|
||||
} catch (e) {
|
||||
const toolResult: CompatibilityCallToolResult = {
|
||||
|
|
@ -586,7 +753,6 @@ const App = () => {
|
|||
setStdErrNotifications([]);
|
||||
};
|
||||
|
||||
// Helper component for rendering the AuthDebugger
|
||||
const AuthDebuggerWrapper = () => (
|
||||
<TabsContent value="auth">
|
||||
<AuthDebugger
|
||||
|
|
@ -598,7 +764,6 @@ const App = () => {
|
|||
</TabsContent>
|
||||
);
|
||||
|
||||
// Helper function to render OAuth callback components
|
||||
if (window.location.pathname === "/oauth/callback") {
|
||||
const OAuthCallback = React.lazy(
|
||||
() => import("./components/OAuthCallback"),
|
||||
|
|
@ -650,6 +815,10 @@ const App = () => {
|
|||
setBearerToken={setBearerToken}
|
||||
headerName={headerName}
|
||||
setHeaderName={setHeaderName}
|
||||
oauthClientId={oauthClientId}
|
||||
setOauthClientId={setOauthClientId}
|
||||
oauthScope={oauthScope}
|
||||
setOauthScope={setOauthScope}
|
||||
onConnect={connectMcpServer}
|
||||
onDisconnect={disconnectMcpServer}
|
||||
stdErrNotifications={stdErrNotifications}
|
||||
|
|
@ -658,7 +827,6 @@ const App = () => {
|
|||
loggingSupported={!!serverCapabilities?.logging || false}
|
||||
clearStdErrNotifications={clearStdErrNotifications}
|
||||
/>
|
||||
{/* Drag handle for resizing sidebar */}
|
||||
<div
|
||||
onMouseDown={handleSidebarDragStart}
|
||||
style={{
|
||||
|
|
@ -679,21 +847,12 @@ const App = () => {
|
|||
<div className="flex-1 overflow-auto">
|
||||
{mcpClient ? (
|
||||
<Tabs
|
||||
defaultValue={
|
||||
Object.keys(serverCapabilities ?? {}).includes(
|
||||
window.location.hash.slice(1),
|
||||
)
|
||||
? window.location.hash.slice(1)
|
||||
: serverCapabilities?.resources
|
||||
? "resources"
|
||||
: serverCapabilities?.prompts
|
||||
? "prompts"
|
||||
: serverCapabilities?.tools
|
||||
? "tools"
|
||||
: "ping"
|
||||
}
|
||||
value={activeTab}
|
||||
className="w-full p-4"
|
||||
onValueChange={(value) => (window.location.hash = value)}
|
||||
onValueChange={(value) => {
|
||||
setActiveTab(value);
|
||||
window.location.hash = value;
|
||||
}}
|
||||
>
|
||||
<TabsList className="mb-4 py-0">
|
||||
<TabsTrigger
|
||||
|
|
@ -730,6 +889,15 @@ const App = () => {
|
|||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="elicitations" className="relative">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Elicitations
|
||||
{pendingElicitationRequests.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
|
||||
{pendingElicitationRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="roots">
|
||||
<FolderTree className="w-4 h-4 mr-2" />
|
||||
Roots
|
||||
|
|
@ -846,7 +1014,6 @@ const App = () => {
|
|||
clearTools={() => {
|
||||
setTools([]);
|
||||
setNextToolCursor(undefined);
|
||||
// Clear cached output schemas
|
||||
cacheToolOutputSchemas([]);
|
||||
}}
|
||||
callTool={async (name, params) => {
|
||||
|
|
@ -863,6 +1030,11 @@ const App = () => {
|
|||
toolResult={toolResult}
|
||||
nextCursor={nextToolCursor}
|
||||
error={errors.tools}
|
||||
resourceContent={resourceContentMap}
|
||||
onReadResource={(uri: string) => {
|
||||
clearError("resources");
|
||||
readResource(uri);
|
||||
}}
|
||||
/>
|
||||
<ConsoleTab />
|
||||
<PingTab
|
||||
|
|
@ -880,6 +1052,10 @@ const App = () => {
|
|||
onApprove={handleApproveSampling}
|
||||
onReject={handleRejectSampling}
|
||||
/>
|
||||
<ElicitationTab
|
||||
pendingRequests={pendingElicitationRequests}
|
||||
onResolve={handleResolveElicitation}
|
||||
/>
|
||||
<RootsTab
|
||||
roots={roots}
|
||||
setRoots={setRoots}
|
||||
|
|
|
|||
159
client/src/__tests__/App.routing.test.tsx
Normal file
159
client/src/__tests__/App.routing.test.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { render, waitFor } from "@testing-library/react";
|
||||
import App from "../App";
|
||||
import { useConnection } from "../lib/hooks/useConnection";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
|
||||
// Mock auth dependencies first
|
||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../lib/oauth-state-machine", () => ({
|
||||
OAuthStateMachine: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../lib/auth", () => ({
|
||||
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
|
||||
tokens: jest.fn().mockResolvedValue(null),
|
||||
clear: jest.fn(),
|
||||
})),
|
||||
DebugInspectorOAuthClientProvider: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the config utils
|
||||
jest.mock("../utils/configUtils", () => ({
|
||||
...jest.requireActual("../utils/configUtils"),
|
||||
getMCPProxyAddress: jest.fn(() => "http://localhost:6277"),
|
||||
getMCPProxyAuthToken: jest.fn(() => ({
|
||||
token: "",
|
||||
header: "X-MCP-Proxy-Auth",
|
||||
})),
|
||||
getInitialTransportType: jest.fn(() => "stdio"),
|
||||
getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"),
|
||||
getInitialCommand: jest.fn(() => "mcp-server-everything"),
|
||||
getInitialArgs: jest.fn(() => ""),
|
||||
initializeInspectorConfig: jest.fn(() => ({})),
|
||||
saveInspectorConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
// Default connection state is disconnected
|
||||
const disconnectedConnectionState = {
|
||||
connectionStatus: "disconnected" as const,
|
||||
serverCapabilities: null,
|
||||
mcpClient: null,
|
||||
requestHistory: [],
|
||||
makeRequest: jest.fn(),
|
||||
sendNotification: jest.fn(),
|
||||
handleCompletion: jest.fn(),
|
||||
completionsSupported: false,
|
||||
connect: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
|
||||
// Connected state for tests that need an active connection
|
||||
const connectedConnectionState = {
|
||||
...disconnectedConnectionState,
|
||||
connectionStatus: "connected" as const,
|
||||
serverCapabilities: {},
|
||||
mcpClient: {
|
||||
request: jest.fn(),
|
||||
notification: jest.fn(),
|
||||
close: jest.fn(),
|
||||
} as unknown as Client,
|
||||
};
|
||||
|
||||
// Mock required dependencies, but unrelated to routing.
|
||||
jest.mock("../lib/hooks/useDraggablePane", () => ({
|
||||
useDraggablePane: () => ({
|
||||
height: 300,
|
||||
handleDragStart: jest.fn(),
|
||||
}),
|
||||
useDraggableSidebar: () => ({
|
||||
width: 320,
|
||||
isDragging: false,
|
||||
handleDragStart: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("../components/Sidebar", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>Sidebar</div>,
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) });
|
||||
|
||||
// Use an empty module mock, so that mock state can be reset between tests.
|
||||
jest.mock("../lib/hooks/useConnection", () => ({
|
||||
useConnection: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("App - URL Fragment Routing", () => {
|
||||
const mockUseConnection = jest.mocked(useConnection);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
// Inspector starts disconnected.
|
||||
mockUseConnection.mockReturnValue(disconnectedConnectionState);
|
||||
});
|
||||
|
||||
test("does not set hash when starting disconnected", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
test("sets default hash based on server capabilities priority", async () => {
|
||||
// Tab priority follows UI order: Resources | Prompts | Tools | Ping | Sampling | Roots | Auth
|
||||
//
|
||||
// Server capabilities determine the first three tabs; if none are present, falls back to Ping.
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
capabilities: { resources: { listChanged: true, subscribe: true } },
|
||||
expected: "#resources",
|
||||
},
|
||||
{
|
||||
capabilities: { prompts: { listChanged: true, subscribe: true } },
|
||||
expected: "#prompts",
|
||||
},
|
||||
{
|
||||
capabilities: { tools: { listChanged: true, subscribe: true } },
|
||||
expected: "#tools",
|
||||
},
|
||||
{ capabilities: {}, expected: "#ping" },
|
||||
];
|
||||
|
||||
const { rerender } = render(<App />);
|
||||
|
||||
for (const { capabilities, expected } of testCases) {
|
||||
window.location.hash = "";
|
||||
mockUseConnection.mockReturnValue({
|
||||
...connectedConnectionState,
|
||||
serverCapabilities: capabilities,
|
||||
});
|
||||
|
||||
rerender(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("clears hash when disconnected", async () => {
|
||||
// Start with a hash set (simulating a connection)
|
||||
window.location.hash = "#resources";
|
||||
|
||||
// App starts disconnected (default mock)
|
||||
render(<App />);
|
||||
|
||||
// Should clear the hash when disconnected
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -28,10 +28,10 @@ export interface DynamicJsonFormRef {
|
|||
|
||||
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
||||
if (supportedTypes.includes(schema.type)) return true;
|
||||
if (schema.type && supportedTypes.includes(schema.type)) return true;
|
||||
if (schema.type === "object") {
|
||||
return Object.values(schema.properties ?? {}).every((prop) =>
|
||||
supportedTypes.includes(prop.type),
|
||||
return Object.values(schema.properties ?? {}).every(
|
||||
(prop) => prop.type && supportedTypes.includes(prop.type),
|
||||
);
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
|
|
@ -236,10 +236,89 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
parentSchema?.required?.includes(propertyName || "") ?? false;
|
||||
|
||||
switch (propSchema.type) {
|
||||
case "string":
|
||||
case "string": {
|
||||
if (
|
||||
propSchema.oneOf &&
|
||||
propSchema.oneOf.every(
|
||||
(option) =>
|
||||
typeof option.const === "string" &&
|
||||
typeof option.title === "string",
|
||||
)
|
||||
) {
|
||||
return (
|
||||
<select
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
required={isRequired}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">Select an option...</option>
|
||||
{propSchema.oneOf.map((option) => (
|
||||
<option
|
||||
key={option.const as string}
|
||||
value={option.const as string}
|
||||
>
|
||||
{option.title as string}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (propSchema.enum) {
|
||||
return (
|
||||
<select
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (!val && !isRequired) {
|
||||
handleFieldChange(path, undefined);
|
||||
} else {
|
||||
handleFieldChange(path, val);
|
||||
}
|
||||
}}
|
||||
required={isRequired}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">Select an option...</option>
|
||||
{propSchema.enum.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
let inputType = "text";
|
||||
switch (propSchema.format) {
|
||||
case "email":
|
||||
inputType = "email";
|
||||
break;
|
||||
case "uri":
|
||||
inputType = "url";
|
||||
break;
|
||||
case "date":
|
||||
inputType = "date";
|
||||
break;
|
||||
case "date-time":
|
||||
inputType = "datetime-local";
|
||||
break;
|
||||
default:
|
||||
inputType = "text";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
type={inputType}
|
||||
value={(currentValue as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
|
|
@ -248,8 +327,13 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={isRequired}
|
||||
minLength={propSchema.minLength}
|
||||
maxLength={propSchema.maxLength}
|
||||
pattern={propSchema.pattern}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
|
|
@ -268,8 +352,11 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={isRequired}
|
||||
min={propSchema.minimum}
|
||||
max={propSchema.maximum}
|
||||
/>
|
||||
);
|
||||
|
||||
case "integer":
|
||||
return (
|
||||
<Input
|
||||
|
|
@ -282,7 +369,6 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
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);
|
||||
}
|
||||
|
|
@ -290,8 +376,11 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
|
|||
}}
|
||||
placeholder={propSchema.description}
|
||||
required={isRequired}
|
||||
min={propSchema.minimum}
|
||||
max={propSchema.maximum}
|
||||
/>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<Input
|
||||
|
|
|
|||
173
client/src/components/ElicitationRequest.tsx
Normal file
173
client/src/components/ElicitationRequest.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DynamicJsonForm from "./DynamicJsonForm";
|
||||
import JsonView from "./JsonView";
|
||||
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
|
||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
||||
import {
|
||||
PendingElicitationRequest,
|
||||
ElicitationResponse,
|
||||
} from "./ElicitationTab";
|
||||
import Ajv from "ajv";
|
||||
|
||||
export type ElicitationRequestProps = {
|
||||
request: PendingElicitationRequest;
|
||||
onResolve: (id: number, response: ElicitationResponse) => void;
|
||||
};
|
||||
|
||||
const ElicitationRequest = ({
|
||||
request,
|
||||
onResolve,
|
||||
}: ElicitationRequestProps) => {
|
||||
const [formData, setFormData] = useState<JsonValue>({});
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const defaultValue = generateDefaultValue(request.request.requestedSchema);
|
||||
setFormData(defaultValue);
|
||||
setValidationError(null);
|
||||
}, [request.request.requestedSchema]);
|
||||
|
||||
const validateEmailFormat = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const validateFormData = (
|
||||
data: JsonValue,
|
||||
schema: JsonSchemaType,
|
||||
): boolean => {
|
||||
if (
|
||||
schema.type === "object" &&
|
||||
schema.properties &&
|
||||
typeof data === "object" &&
|
||||
data !== null
|
||||
) {
|
||||
const dataObj = data as Record<string, unknown>;
|
||||
|
||||
if (Array.isArray(schema.required)) {
|
||||
for (const field of schema.required) {
|
||||
const value = dataObj[field];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
setValidationError(`Required field missing: ${field}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [fieldName, fieldValue] of Object.entries(dataObj)) {
|
||||
const fieldSchema = schema.properties[fieldName];
|
||||
if (
|
||||
fieldSchema &&
|
||||
fieldSchema.format === "email" &&
|
||||
typeof fieldValue === "string"
|
||||
) {
|
||||
if (!validateEmailFormat(fieldValue)) {
|
||||
setValidationError(`Invalid email format: ${fieldName}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAccept = () => {
|
||||
try {
|
||||
if (!validateFormData(formData, request.request.requestedSchema)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ajv = new Ajv();
|
||||
const validate = ajv.compile(request.request.requestedSchema);
|
||||
const isValid = validate(formData);
|
||||
|
||||
if (!isValid) {
|
||||
const errorMessage = ajv.errorsText(validate.errors);
|
||||
setValidationError(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
onResolve(request.id, {
|
||||
action: "accept",
|
||||
content: formData as Record<string, unknown>,
|
||||
});
|
||||
} catch (error) {
|
||||
setValidationError(
|
||||
error instanceof Error ? error.message : "Validation failed",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
onResolve(request.id, { action: "decline" });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onResolve(request.id, { action: "cancel" });
|
||||
};
|
||||
|
||||
const schemaTitle =
|
||||
request.request.requestedSchema.title || "Information Request";
|
||||
const schemaDescription = request.request.requestedSchema.description;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="elicitation-request"
|
||||
className="flex gap-4 p-4 border rounded-lg space-y-4"
|
||||
>
|
||||
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">{schemaTitle}</h4>
|
||||
<p className="text-sm">{request.request.message}</p>
|
||||
{schemaDescription && (
|
||||
<p className="text-xs text-muted-foreground">{schemaDescription}</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<h5 className="text-xs font-medium mb-1">Request Schema:</h5>
|
||||
<JsonView
|
||||
data={JSON.stringify(request.request.requestedSchema, null, 2)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">Response Form</h4>
|
||||
<DynamicJsonForm
|
||||
schema={request.request.requestedSchema}
|
||||
value={formData}
|
||||
onChange={(newValue: JsonValue) => {
|
||||
setFormData(newValue);
|
||||
setValidationError(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{validationError && (
|
||||
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
<strong>Validation Error:</strong> {validationError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 mt-1">
|
||||
<Button type="button" onClick={handleAccept}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleDecline}>
|
||||
Decline
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElicitationRequest;
|
||||
56
client/src/components/ElicitationTab.tsx
Normal file
56
client/src/components/ElicitationTab.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { JsonSchemaType } from "@/utils/jsonUtils";
|
||||
import ElicitationRequest from "./ElicitationRequest";
|
||||
|
||||
export interface ElicitationRequestData {
|
||||
id: number;
|
||||
message: string;
|
||||
requestedSchema: JsonSchemaType;
|
||||
}
|
||||
|
||||
export interface ElicitationResponse {
|
||||
action: "accept" | "decline" | "cancel";
|
||||
content?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PendingElicitationRequest = {
|
||||
id: number;
|
||||
request: ElicitationRequestData;
|
||||
originatingTab?: string;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
pendingRequests: PendingElicitationRequest[];
|
||||
onResolve: (id: number, response: ElicitationResponse) => void;
|
||||
};
|
||||
|
||||
const ElicitationTab = ({ pendingRequests, onResolve }: Props) => {
|
||||
return (
|
||||
<TabsContent value="elicitations">
|
||||
<div className="h-96">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
When the server requests information from the user, requests will
|
||||
appear here for response.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="mt-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">Recent Requests</h3>
|
||||
{pendingRequests.map((request) => (
|
||||
<ElicitationRequest
|
||||
key={request.id}
|
||||
request={request}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
{pendingRequests.length === 0 && (
|
||||
<p className="text-gray-500">No pending requests</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElicitationTab;
|
||||
|
|
@ -48,6 +48,7 @@ const PromptsTab = ({
|
|||
ref: PromptReference | ResourceReference,
|
||||
argName: string,
|
||||
value: string,
|
||||
context?: Record<string, string>,
|
||||
) => Promise<string[]>;
|
||||
completionsSupported: boolean;
|
||||
promptContent: string;
|
||||
|
|
@ -73,6 +74,7 @@ const PromptsTab = ({
|
|||
},
|
||||
argName,
|
||||
value,
|
||||
promptArgs,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue