Merge branch 'main' into fix/validate-json-on-tool-execution
This commit is contained in:
commit
98580cd5a9
11 changed files with 633 additions and 137 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import open from "open";
|
||||||
import { join, dirname } from "path";
|
import { join, dirname } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import handler from "serve-handler";
|
import handler from "serve-handler";
|
||||||
|
|
@ -42,9 +43,12 @@ const server = http.createServer((request, response) => {
|
||||||
const port = parseInt(process.env.CLIENT_PORT || "6274", 10);
|
const port = parseInt(process.env.CLIENT_PORT || "6274", 10);
|
||||||
const host = process.env.HOST || "localhost";
|
const host = process.env.HOST || "localhost";
|
||||||
server.on("listening", () => {
|
server.on("listening", () => {
|
||||||
console.log(
|
const url = process.env.INSPECTOR_URL || `http://${host}:${port}`;
|
||||||
`🔍 MCP Inspector is up and running at http://${host}:${port} 🚀`,
|
console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`);
|
||||||
);
|
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
|
||||||
|
console.log(`🌐 Opening browser...`);
|
||||||
|
open(url);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
server.on("error", (err) => {
|
server.on("error", (err) => {
|
||||||
if (err.message.includes(`EADDRINUSE`)) {
|
if (err.message.includes(`EADDRINUSE`)) {
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,24 @@ import { fileURLToPath } from "url";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
|
||||||
|
|
||||||
function delay(ms) {
|
function delay(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms, true));
|
return new Promise((resolve) => setTimeout(resolve, ms, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClientUrl(port, authDisabled, sessionToken) {
|
function getClientUrl(port, authDisabled, sessionToken, serverPort) {
|
||||||
const host = process.env.HOST || "localhost";
|
const host = process.env.HOST || "localhost";
|
||||||
const baseUrl = `http://${host}:${port}`;
|
const baseUrl = `http://${host}:${port}`;
|
||||||
return authDisabled
|
|
||||||
? baseUrl
|
const params = new URLSearchParams();
|
||||||
: `${baseUrl}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`;
|
if (serverPort && serverPort !== DEFAULT_MCP_PROXY_LISTEN_PORT) {
|
||||||
|
params.set("MCP_PROXY_PORT", serverPort);
|
||||||
|
}
|
||||||
|
if (!authDisabled) {
|
||||||
|
params.set("MCP_PROXY_AUTH_TOKEN", sessionToken);
|
||||||
|
}
|
||||||
|
return params.size > 0 ? `${baseUrl}/?${params.toString()}` : baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDevServer(serverOptions) {
|
async function startDevServer(serverOptions) {
|
||||||
|
|
@ -31,8 +38,8 @@ async function startDevServer(serverOptions) {
|
||||||
cwd: resolve(__dirname, "../..", "server"),
|
cwd: resolve(__dirname, "../..", "server"),
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
SERVER_PORT: SERVER_PORT,
|
SERVER_PORT,
|
||||||
CLIENT_PORT: CLIENT_PORT,
|
CLIENT_PORT,
|
||||||
MCP_PROXY_TOKEN: sessionToken,
|
MCP_PROXY_TOKEN: sessionToken,
|
||||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||||
},
|
},
|
||||||
|
|
@ -90,8 +97,8 @@ async function startProdServer(serverOptions) {
|
||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
SERVER_PORT: SERVER_PORT,
|
SERVER_PORT,
|
||||||
CLIENT_PORT: CLIENT_PORT,
|
CLIENT_PORT,
|
||||||
MCP_PROXY_TOKEN: sessionToken,
|
MCP_PROXY_TOKEN: sessionToken,
|
||||||
MCP_ENV_VARS: JSON.stringify(envVars),
|
MCP_ENV_VARS: JSON.stringify(envVars),
|
||||||
},
|
},
|
||||||
|
|
@ -107,29 +114,40 @@ async function startProdServer(serverOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDevClient(clientOptions) {
|
async function startDevClient(clientOptions) {
|
||||||
const { CLIENT_PORT, authDisabled, sessionToken, abort, cancelled } =
|
const {
|
||||||
clientOptions;
|
CLIENT_PORT,
|
||||||
|
SERVER_PORT,
|
||||||
|
authDisabled,
|
||||||
|
sessionToken,
|
||||||
|
abort,
|
||||||
|
cancelled,
|
||||||
|
} = clientOptions;
|
||||||
const clientCommand = "npx";
|
const clientCommand = "npx";
|
||||||
const host = process.env.HOST || "localhost";
|
const host = process.env.HOST || "localhost";
|
||||||
const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host];
|
const clientArgs = ["vite", "--port", CLIENT_PORT, "--host", host];
|
||||||
|
|
||||||
const client = spawn(clientCommand, clientArgs, {
|
const client = spawn(clientCommand, clientArgs, {
|
||||||
cwd: resolve(__dirname, ".."),
|
cwd: resolve(__dirname, ".."),
|
||||||
env: { ...process.env, CLIENT_PORT: CLIENT_PORT },
|
env: { ...process.env, CLIENT_PORT },
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
echoOutput: true,
|
echoOutput: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-open browser after vite starts
|
const url = getClientUrl(
|
||||||
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
|
CLIENT_PORT,
|
||||||
const url = getClientUrl(CLIENT_PORT, authDisabled, sessionToken);
|
authDisabled,
|
||||||
|
sessionToken,
|
||||||
|
SERVER_PORT,
|
||||||
|
);
|
||||||
|
|
||||||
// Give vite time to start before opening browser
|
// Give vite time to start before opening or logging the URL
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`);
|
||||||
|
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
|
||||||
|
console.log("🌐 Opening browser...");
|
||||||
open(url);
|
open(url);
|
||||||
console.log(`\n🔗 Opening browser at: ${url}\n`);
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
client.subscribe({
|
client.subscribe({
|
||||||
|
|
@ -146,8 +164,14 @@ async function startDevClient(clientOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startProdClient(clientOptions) {
|
async function startProdClient(clientOptions) {
|
||||||
const { CLIENT_PORT, authDisabled, sessionToken, abort, cancelled } =
|
const {
|
||||||
clientOptions;
|
CLIENT_PORT,
|
||||||
|
SERVER_PORT,
|
||||||
|
authDisabled,
|
||||||
|
sessionToken,
|
||||||
|
abort,
|
||||||
|
cancelled,
|
||||||
|
} = clientOptions;
|
||||||
const inspectorClientPath = resolve(
|
const inspectorClientPath = resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
"../..",
|
"../..",
|
||||||
|
|
@ -156,14 +180,19 @@ async function startProdClient(clientOptions) {
|
||||||
"client.js",
|
"client.js",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only auto-open browser if not cancelled
|
const url = getClientUrl(
|
||||||
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false" && !cancelled) {
|
CLIENT_PORT,
|
||||||
const url = getClientUrl(CLIENT_PORT, authDisabled, sessionToken);
|
authDisabled,
|
||||||
open(url);
|
sessionToken,
|
||||||
}
|
SERVER_PORT,
|
||||||
|
);
|
||||||
|
|
||||||
await spawnPromise("node", [inspectorClientPath], {
|
await spawnPromise("node", [inspectorClientPath], {
|
||||||
env: { ...process.env, CLIENT_PORT: CLIENT_PORT },
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CLIENT_PORT,
|
||||||
|
INSPECTOR_URL: url,
|
||||||
|
},
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
echoOutput: true,
|
echoOutput: true,
|
||||||
});
|
});
|
||||||
|
|
@ -210,7 +239,7 @@ async function main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
|
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
|
||||||
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
|
const SERVER_PORT = process.env.SERVER_PORT ?? DEFAULT_MCP_PROXY_LISTEN_PORT;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
isDev
|
isDev
|
||||||
|
|
@ -255,6 +284,7 @@ async function main() {
|
||||||
try {
|
try {
|
||||||
const clientOptions = {
|
const clientOptions = {
|
||||||
CLIENT_PORT,
|
CLIENT_PORT,
|
||||||
|
SERVER_PORT,
|
||||||
authDisabled,
|
authDisabled,
|
||||||
sessionToken,
|
sessionToken,
|
||||||
abort,
|
abort,
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,39 @@ export interface DynamicJsonFormRef {
|
||||||
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
const isSimpleObject = (schema: JsonSchemaType): boolean => {
|
||||||
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
|
||||||
if (supportedTypes.includes(schema.type)) return true;
|
if (supportedTypes.includes(schema.type)) return true;
|
||||||
if (schema.type !== "object") return false;
|
if (schema.type === "object") {
|
||||||
return Object.values(schema.properties ?? {}).every((prop) =>
|
return Object.values(schema.properties ?? {}).every((prop) =>
|
||||||
supportedTypes.includes(prop.type),
|
supportedTypes.includes(prop.type),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if (schema.type === "array") {
|
||||||
|
return !!schema.items && isSimpleObject(schema.items);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArrayItemDefault = (schema: JsonSchemaType): JsonValue => {
|
||||||
|
if ("default" in schema && schema.default !== undefined) {
|
||||||
|
return schema.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (schema.type) {
|
||||||
|
case "string":
|
||||||
|
return "";
|
||||||
|
case "number":
|
||||||
|
case "integer":
|
||||||
|
return 0;
|
||||||
|
case "boolean":
|
||||||
|
return false;
|
||||||
|
case "array":
|
||||||
|
return [];
|
||||||
|
case "object":
|
||||||
|
return {};
|
||||||
|
case "null":
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(({
|
const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(({
|
||||||
|
|
@ -136,6 +165,8 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(({
|
||||||
currentValue: JsonValue,
|
currentValue: JsonValue,
|
||||||
path: string[] = [],
|
path: string[] = [],
|
||||||
depth: number = 0,
|
depth: number = 0,
|
||||||
|
parentSchema?: JsonSchemaType,
|
||||||
|
propertyName?: string,
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
depth >= maxDepth &&
|
depth >= maxDepth &&
|
||||||
|
|
@ -145,7 +176,8 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(({
|
||||||
return (
|
return (
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={JSON.stringify(
|
value={JSON.stringify(
|
||||||
currentValue ?? generateDefaultValue(propSchema),
|
currentValue ??
|
||||||
|
generateDefaultValue(propSchema, propertyName, parentSchema),
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
)}
|
)}
|
||||||
|
|
@ -163,6 +195,10 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this property is required in the parent schema
|
||||||
|
const isRequired =
|
||||||
|
parentSchema?.required?.includes(propertyName || "") ?? false;
|
||||||
|
|
||||||
switch (propSchema.type) {
|
switch (propSchema.type) {
|
||||||
case "string":
|
case "string":
|
||||||
return (
|
return (
|
||||||
|
|
@ -171,16 +207,11 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(({
|
||||||
value={(currentValue as string) ?? ""}
|
value={(currentValue as string) ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
// Allow clearing non-required fields by setting undefined
|
// Always allow setting string values, including empty strings
|
||||||
// This preserves the distinction between empty string and unset
|
handleFieldChange(path, val);
|
||||||
if (!val && !propSchema.required) {
|
|
||||||
handleFieldChange(path, undefined);
|
|
||||||
} else {
|
|
||||||
handleFieldChange(path, val);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
placeholder={propSchema.description}
|
placeholder={propSchema.description}
|
||||||
required={propSchema.required}
|
required={isRequired}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "number":
|
case "number":
|
||||||
|
|
@ -190,9 +221,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(({
|
||||||
value={(currentValue as number)?.toString() ?? ""}
|
value={(currentValue as number)?.toString() ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
// Allow clearing non-required number fields
|
if (!val && !isRequired) {
|
||||||
// This preserves the distinction between 0 and unset
|
|
||||||
if (!val && !propSchema.required) {
|
|
||||||
handleFieldChange(path, undefined);
|
handleFieldChange(path, undefined);
|
||||||
} else {
|
} else {
|
||||||
const num = Number(val);
|
const num = Number(val);
|
||||||
|
|
@ -202,7 +231,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={propSchema.description}
|
placeholder={propSchema.description}
|
||||||
required={propSchema.required}
|
required={isRequired}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "integer":
|
case "integer":
|
||||||
|
|
@ -213,9 +242,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(({
|
||||||
value={(currentValue as number)?.toString() ?? ""}
|
value={(currentValue as number)?.toString() ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value;
|
const val = e.target.value;
|
||||||
// Allow clearing non-required integer fields
|
if (!val && !isRequired) {
|
||||||
// This preserves the distinction between 0 and unset
|
|
||||||
if (!val && !propSchema.required) {
|
|
||||||
handleFieldChange(path, undefined);
|
handleFieldChange(path, undefined);
|
||||||
} else {
|
} else {
|
||||||
const num = Number(val);
|
const num = Number(val);
|
||||||
|
|
@ -226,7 +253,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={propSchema.description}
|
placeholder={propSchema.description}
|
||||||
required={propSchema.required}
|
required={isRequired}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "boolean":
|
case "boolean":
|
||||||
|
|
@ -236,9 +263,135 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(({
|
||||||
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}
|
required={isRequired}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "object":
|
||||||
|
if (!propSchema.properties) {
|
||||||
|
return (
|
||||||
|
<JsonEditor
|
||||||
|
value={JSON.stringify(currentValue ?? {}, null, 2)}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(newValue);
|
||||||
|
handleFieldChange(path, parsed);
|
||||||
|
setJsonError(undefined);
|
||||||
|
} catch (err) {
|
||||||
|
setJsonError(
|
||||||
|
err instanceof Error ? err.message : "Invalid JSON",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
error={jsonError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 border rounded p-3">
|
||||||
|
{Object.entries(propSchema.properties).map(([key, subSchema]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
{key}
|
||||||
|
{propSchema.required?.includes(key) && (
|
||||||
|
<span className="text-red-500 ml-1">*</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{renderFormFields(
|
||||||
|
subSchema as JsonSchemaType,
|
||||||
|
(currentValue as Record<string, JsonValue>)?.[key],
|
||||||
|
[...path, key],
|
||||||
|
depth + 1,
|
||||||
|
propSchema,
|
||||||
|
key,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "array": {
|
||||||
|
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
|
||||||
|
if (!propSchema.items) return null;
|
||||||
|
|
||||||
|
// If the array items are simple, render as form fields, otherwise use JSON editor
|
||||||
|
if (isSimpleObject(propSchema.items)) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{propSchema.description && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{propSchema.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{propSchema.items?.description && (
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Items: {propSchema.items.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{arrayValue.map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
{renderFormFields(
|
||||||
|
propSchema.items as JsonSchemaType,
|
||||||
|
item,
|
||||||
|
[...path, index.toString()],
|
||||||
|
depth + 1,
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newArray = [...arrayValue];
|
||||||
|
newArray.splice(index, 1);
|
||||||
|
handleFieldChange(path, newArray);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const defaultValue = getArrayItemDefault(
|
||||||
|
propSchema.items as JsonSchemaType,
|
||||||
|
);
|
||||||
|
handleFieldChange(path, [...arrayValue, defaultValue]);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
propSchema.items?.description
|
||||||
|
? `Add new ${propSchema.items.description}`
|
||||||
|
: "Add new item"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For complex arrays, fall back to JSON editor
|
||||||
|
return (
|
||||||
|
<JsonEditor
|
||||||
|
value={JSON.stringify(currentValue ?? [], null, 2)}
|
||||||
|
onChange={(newValue) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(newValue);
|
||||||
|
handleFieldChange(path, parsed);
|
||||||
|
setJsonError(undefined);
|
||||||
|
} catch (err) {
|
||||||
|
setJsonError(
|
||||||
|
err instanceof Error ? err.message : "Invalid JSON",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
error={jsonError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
|
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
|
||||||
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
import { generateDefaultValue } from "@/utils/schemaUtils";
|
import { generateDefaultValue, isPropertyRequired } from "@/utils/schemaUtils";
|
||||||
import {
|
import {
|
||||||
CompatibilityCallToolResult,
|
CompatibilityCallToolResult,
|
||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
|
|
@ -50,7 +50,11 @@ const ToolsTab = ({
|
||||||
selectedTool?.inputSchema.properties ?? [],
|
selectedTool?.inputSchema.properties ?? [],
|
||||||
).map(([key, value]) => [
|
).map(([key, value]) => [
|
||||||
key,
|
key,
|
||||||
generateDefaultValue(value as JsonSchemaType),
|
generateDefaultValue(
|
||||||
|
value as JsonSchemaType,
|
||||||
|
key,
|
||||||
|
selectedTool?.inputSchema as JsonSchemaType,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
setParams(Object.fromEntries(params));
|
setParams(Object.fromEntries(params));
|
||||||
}, [selectedTool]);
|
}, [selectedTool]);
|
||||||
|
|
@ -100,6 +104,9 @@ const ToolsTab = ({
|
||||||
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
|
||||||
([key, value]) => {
|
([key, value]) => {
|
||||||
const prop = value as JsonSchemaType;
|
const prop = value as JsonSchemaType;
|
||||||
|
const inputSchema =
|
||||||
|
selectedTool.inputSchema as JsonSchemaType;
|
||||||
|
const required = isPropertyRequired(key, inputSchema);
|
||||||
return (
|
return (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<Label
|
<Label
|
||||||
|
|
@ -107,6 +114,9 @@ const ToolsTab = ({
|
||||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
{key}
|
{key}
|
||||||
|
{required && (
|
||||||
|
<span className="text-red-500 ml-1">*</span>
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
{prop.type === "boolean" ? (
|
{prop.type === "boolean" ? (
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
<div className="flex items-center space-x-2 mt-2">
|
||||||
|
|
@ -172,12 +182,13 @@ const ToolsTab = ({
|
||||||
name={key}
|
name={key}
|
||||||
placeholder={prop.description}
|
placeholder={prop.description}
|
||||||
value={(params[key] as string) ?? ""}
|
value={(params[key] as string) ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
setParams({
|
setParams({
|
||||||
...params,
|
...params,
|
||||||
[key]: Number(e.target.value),
|
[key]: value === "" ? "" : Number(value),
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
256
client/src/components/__tests__/DynamicJsonForm.array.test.tsx
Normal file
256
client/src/components/__tests__/DynamicJsonForm.array.test.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, jest } from "@jest/globals";
|
||||||
|
import DynamicJsonForm from "../DynamicJsonForm";
|
||||||
|
import type { JsonSchemaType } from "@/utils/jsonUtils";
|
||||||
|
|
||||||
|
describe("DynamicJsonForm Array Fields", () => {
|
||||||
|
const renderSimpleArrayForm = (props = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
schema: {
|
||||||
|
type: "array" as const,
|
||||||
|
description: "Test array field",
|
||||||
|
items: {
|
||||||
|
type: "string" as const,
|
||||||
|
description: "Array item",
|
||||||
|
},
|
||||||
|
} satisfies JsonSchemaType,
|
||||||
|
value: [],
|
||||||
|
onChange: jest.fn(),
|
||||||
|
};
|
||||||
|
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderComplexArrayForm = (props = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
schema: {
|
||||||
|
type: "array" as const,
|
||||||
|
description: "Test complex array field",
|
||||||
|
items: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
nested: { type: "object" as const },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies JsonSchemaType,
|
||||||
|
value: [],
|
||||||
|
onChange: jest.fn(),
|
||||||
|
};
|
||||||
|
return render(<DynamicJsonForm {...defaultProps} {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Simple Array Rendering", () => {
|
||||||
|
it("should render form fields for simple array items", () => {
|
||||||
|
renderSimpleArrayForm({ value: ["item1", "item2"] });
|
||||||
|
|
||||||
|
// Should show array description
|
||||||
|
expect(screen.getByText("Test array field")).toBeDefined();
|
||||||
|
expect(screen.getByText("Items: Array item")).toBeDefined();
|
||||||
|
|
||||||
|
// Should show input fields for each item
|
||||||
|
const inputs = screen.getAllByRole("textbox");
|
||||||
|
expect(inputs).toHaveLength(2);
|
||||||
|
expect(inputs[0]).toHaveProperty("value", "item1");
|
||||||
|
expect(inputs[1]).toHaveProperty("value", "item2");
|
||||||
|
|
||||||
|
// Should show remove buttons
|
||||||
|
const removeButtons = screen.getAllByText("Remove");
|
||||||
|
expect(removeButtons).toHaveLength(2);
|
||||||
|
|
||||||
|
// Should show add button
|
||||||
|
expect(screen.getByText("Add Item")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add new items when Add Item button is clicked", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderSimpleArrayForm({ value: ["item1"], onChange });
|
||||||
|
|
||||||
|
const addButton = screen.getByText("Add Item");
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith(["item1", ""]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove items when Remove button is clicked", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderSimpleArrayForm({ value: ["item1", "item2"], onChange });
|
||||||
|
|
||||||
|
const removeButtons = screen.getAllByText("Remove");
|
||||||
|
fireEvent.click(removeButtons[0]);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith(["item2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update item values when input changes", () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderSimpleArrayForm({ value: ["item1"], onChange });
|
||||||
|
|
||||||
|
const input = screen.getByRole("textbox");
|
||||||
|
fireEvent.change(input, { target: { value: "updated item" } });
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith(["updated item"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty arrays", () => {
|
||||||
|
renderSimpleArrayForm({ value: [] });
|
||||||
|
|
||||||
|
// Should show description and add button but no items
|
||||||
|
expect(screen.getByText("Test array field")).toBeDefined();
|
||||||
|
expect(screen.getByText("Add Item")).toBeDefined();
|
||||||
|
expect(screen.queryByText("Remove")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Complex Array Fallback", () => {
|
||||||
|
it("should render JSON editor for complex arrays", () => {
|
||||||
|
renderComplexArrayForm();
|
||||||
|
|
||||||
|
// Should render as JSON editor (textarea)
|
||||||
|
const textarea = screen.getByRole("textbox");
|
||||||
|
expect(textarea).toHaveProperty("type", "textarea");
|
||||||
|
|
||||||
|
// Should not show form-specific array controls
|
||||||
|
expect(screen.queryByText("Add Item")).toBeNull();
|
||||||
|
expect(screen.queryByText("Remove")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Array Type Detection", () => {
|
||||||
|
it("should detect string arrays as simple", () => {
|
||||||
|
const schema = {
|
||||||
|
type: "array" as const,
|
||||||
|
items: { type: "string" as const },
|
||||||
|
};
|
||||||
|
renderSimpleArrayForm({ schema, value: ["test"] });
|
||||||
|
|
||||||
|
// Should render form fields, not JSON editor
|
||||||
|
expect(screen.getByRole("textbox")).not.toHaveProperty(
|
||||||
|
"type",
|
||||||
|
"textarea",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect number arrays as simple", () => {
|
||||||
|
const schema = {
|
||||||
|
type: "array" as const,
|
||||||
|
items: { type: "number" as const },
|
||||||
|
};
|
||||||
|
renderSimpleArrayForm({ schema, value: [1, 2] });
|
||||||
|
|
||||||
|
// Should render form fields (number inputs)
|
||||||
|
const inputs = screen.getAllByRole("spinbutton");
|
||||||
|
expect(inputs).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect boolean arrays as simple", () => {
|
||||||
|
const schema = {
|
||||||
|
type: "array" as const,
|
||||||
|
items: { type: "boolean" as const },
|
||||||
|
};
|
||||||
|
renderSimpleArrayForm({ schema, value: [true, false] });
|
||||||
|
|
||||||
|
// Should render form fields (checkboxes)
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
expect(checkboxes).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect simple object arrays as simple", () => {
|
||||||
|
const schema = {
|
||||||
|
type: "array" as const,
|
||||||
|
items: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" as const },
|
||||||
|
age: { type: "number" as const },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderSimpleArrayForm({ schema, value: [{ name: "John", age: 30 }] });
|
||||||
|
|
||||||
|
// Should render form fields for simple objects
|
||||||
|
expect(screen.getByText("Add Item")).toBeDefined();
|
||||||
|
expect(screen.getByText("Remove")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Array with Different Item Types", () => {
|
||||||
|
it("should handle integer array items", () => {
|
||||||
|
const schema = {
|
||||||
|
type: "array" as const,
|
||||||
|
items: { type: "integer" as const },
|
||||||
|
};
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderSimpleArrayForm({ schema, value: [1, 2], onChange });
|
||||||
|
|
||||||
|
const inputs = screen.getAllByRole("spinbutton");
|
||||||
|
expect(inputs).toHaveLength(2);
|
||||||
|
expect(inputs[0]).toHaveProperty("value", "1");
|
||||||
|
expect(inputs[1]).toHaveProperty("value", "2");
|
||||||
|
|
||||||
|
// Test adding new integer item
|
||||||
|
const addButton = screen.getByText("Add Item");
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
expect(onChange).toHaveBeenCalledWith([1, 2, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle boolean array items", () => {
|
||||||
|
const schema = {
|
||||||
|
type: "array" as const,
|
||||||
|
items: { type: "boolean" as const },
|
||||||
|
};
|
||||||
|
const onChange = jest.fn();
|
||||||
|
renderSimpleArrayForm({ schema, value: [true, false], onChange });
|
||||||
|
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
expect(checkboxes).toHaveLength(2);
|
||||||
|
expect(checkboxes[0]).toHaveProperty("checked", true);
|
||||||
|
expect(checkboxes[1]).toHaveProperty("checked", false);
|
||||||
|
|
||||||
|
// Test adding new boolean item
|
||||||
|
const addButton = screen.getByText("Add Item");
|
||||||
|
fireEvent.click(addButton);
|
||||||
|
expect(onChange).toHaveBeenCalledWith([true, false, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Array Item Descriptions", () => {
|
||||||
|
it("should show item description when available", () => {
|
||||||
|
const schema = {
|
||||||
|
type: "array" as const,
|
||||||
|
description: "List of names",
|
||||||
|
items: {
|
||||||
|
type: "string" as const,
|
||||||
|
description: "Person name",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderSimpleArrayForm({ schema });
|
||||||
|
|
||||||
|
expect(screen.getByText("List of names")).toBeDefined();
|
||||||
|
expect(screen.getByText("Items: Person name")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use item description in add button title", () => {
|
||||||
|
const schema = {
|
||||||
|
type: "array" as const,
|
||||||
|
items: {
|
||||||
|
type: "string" as const,
|
||||||
|
description: "Email address",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
renderSimpleArrayForm({ schema });
|
||||||
|
|
||||||
|
const addButton = screen.getByText("Add Item");
|
||||||
|
expect(addButton).toHaveProperty("title", "Add new Email address");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default title when no item description", () => {
|
||||||
|
const schema = {
|
||||||
|
type: "array" as const,
|
||||||
|
items: { type: "string" as const },
|
||||||
|
};
|
||||||
|
renderSimpleArrayForm({ schema });
|
||||||
|
|
||||||
|
const addButton = screen.getByText("Add Item");
|
||||||
|
expect(addButton).toHaveProperty("title", "Add new item");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -111,6 +111,27 @@ describe("ToolsTab", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should allow typing negative numbers", async () => {
|
||||||
|
renderToolsTab({
|
||||||
|
selectedTool: mockTools[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByRole("spinbutton") as HTMLInputElement;
|
||||||
|
|
||||||
|
// Complete the negative number
|
||||||
|
fireEvent.change(input, { target: { value: "-42" } });
|
||||||
|
expect(input.value).toBe("-42");
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole("button", { name: /run tool/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submitButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(defaultProps.callTool).toHaveBeenCalledWith(mockTools[0].name, {
|
||||||
|
num: -42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should disable button and change text while tool is running", async () => {
|
it("should disable button and change text while tool is running", async () => {
|
||||||
// Create a promise that we can resolve later
|
// Create a promise that we can resolve later
|
||||||
let resolvePromise: ((value: unknown) => void) | undefined;
|
let resolvePromise: ((value: unknown) => void) | undefined;
|
||||||
|
|
|
||||||
|
|
@ -11,72 +11,68 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
describe("generateDefaultValue", () => {
|
describe("generateDefaultValue", () => {
|
||||||
test("generates default string", () => {
|
test("generates default string", () => {
|
||||||
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
|
const parentSchema = { type: "object" as const, required: ["testProp"] };
|
||||||
|
expect(
|
||||||
|
generateDefaultValue({ type: "string" }, "testProp", parentSchema),
|
||||||
|
).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generates default number", () => {
|
test("generates default number", () => {
|
||||||
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
|
const parentSchema = { type: "object" as const, required: ["testProp"] };
|
||||||
|
expect(
|
||||||
|
generateDefaultValue({ type: "number" }, "testProp", parentSchema),
|
||||||
|
).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generates default integer", () => {
|
test("generates default integer", () => {
|
||||||
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
|
const parentSchema = { type: "object" as const, required: ["testProp"] };
|
||||||
|
expect(
|
||||||
|
generateDefaultValue({ type: "integer" }, "testProp", parentSchema),
|
||||||
|
).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generates default boolean", () => {
|
test("generates default boolean", () => {
|
||||||
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
|
const parentSchema = { type: "object" as const, required: ["testProp"] };
|
||||||
false,
|
expect(
|
||||||
);
|
generateDefaultValue({ type: "boolean" }, "testProp", parentSchema),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generates default array", () => {
|
test("generates default array", () => {
|
||||||
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
|
expect(generateDefaultValue({ type: "array" })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generates default empty object", () => {
|
test("generates default empty object", () => {
|
||||||
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
|
expect(generateDefaultValue({ type: "object" })).toEqual({});
|
||||||
{},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generates default null for unknown types", () => {
|
test("generates default null for unknown types", () => {
|
||||||
// @ts-expect-error Testing with invalid type
|
// @ts-expect-error Testing with invalid type
|
||||||
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
|
expect(generateDefaultValue({ type: "unknown" })).toBe(undefined);
|
||||||
null,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generates empty array for non-required array", () => {
|
test("generates empty array for non-required array", () => {
|
||||||
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
|
expect(generateDefaultValue({ type: "array" })).toEqual([]);
|
||||||
[],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generates empty object for non-required object", () => {
|
test("generates empty object for non-required object", () => {
|
||||||
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
|
expect(generateDefaultValue({ type: "object" })).toEqual({});
|
||||||
{},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generates null for non-required primitive types", () => {
|
test("generates undefined for non-required primitive types", () => {
|
||||||
expect(generateDefaultValue({ type: "string", required: false })).toBe(
|
expect(generateDefaultValue({ type: "string" })).toBe(undefined);
|
||||||
undefined,
|
expect(generateDefaultValue({ type: "number" })).toBe(undefined);
|
||||||
);
|
expect(generateDefaultValue({ type: "boolean" })).toBe(undefined);
|
||||||
expect(generateDefaultValue({ type: "number", required: false })).toBe(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generates object with properties", () => {
|
test("generates object with properties", () => {
|
||||||
const schema: JsonSchemaType = {
|
const schema: JsonSchemaType = {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: true,
|
required: ["name", "age", "isActive"],
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: "string", required: true },
|
name: { type: "string" },
|
||||||
age: { type: "number", required: true },
|
age: { type: "number" },
|
||||||
isActive: { type: "boolean", required: true },
|
isActive: { type: "boolean" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(generateDefaultValue(schema)).toEqual({
|
expect(generateDefaultValue(schema)).toEqual({
|
||||||
|
|
@ -89,18 +85,18 @@ describe("generateDefaultValue", () => {
|
||||||
test("handles nested objects", () => {
|
test("handles nested objects", () => {
|
||||||
const schema: JsonSchemaType = {
|
const schema: JsonSchemaType = {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: true,
|
required: ["user"],
|
||||||
properties: {
|
properties: {
|
||||||
user: {
|
user: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: true,
|
required: ["name", "address"],
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: "string", required: true },
|
name: { type: "string" },
|
||||||
address: {
|
address: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: true,
|
required: ["city"],
|
||||||
properties: {
|
properties: {
|
||||||
city: { type: "string", required: true },
|
city: { type: "string" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,26 @@ import {
|
||||||
DEFAULT_INSPECTOR_CONFIG,
|
DEFAULT_INSPECTOR_CONFIG,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
|
|
||||||
|
const getSearchParam = (key: string): string | null => {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
return url.searchParams.get(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getMCPProxyAddress = (config: InspectorConfig): string => {
|
export const getMCPProxyAddress = (config: InspectorConfig): string => {
|
||||||
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
|
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
|
||||||
if (proxyFullAddress) {
|
if (proxyFullAddress) {
|
||||||
return proxyFullAddress;
|
return proxyFullAddress;
|
||||||
}
|
}
|
||||||
return `${window.location.protocol}//${window.location.hostname}:${DEFAULT_MCP_PROXY_LISTEN_PORT}`;
|
|
||||||
|
// Check for proxy port from query params, fallback to default
|
||||||
|
const proxyPort =
|
||||||
|
getSearchParam("MCP_PROXY_PORT") || DEFAULT_MCP_PROXY_LISTEN_PORT;
|
||||||
|
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}:${proxyPort}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
|
export const getMCPServerRequestTimeout = (config: InspectorConfig): number => {
|
||||||
|
|
@ -40,15 +54,6 @@ export const getMCPProxyAuthToken = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSearchParam = (key: string): string | null => {
|
|
||||||
try {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
return url.searchParams.get(key);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getInitialTransportType = ():
|
export const getInitialTransportType = ():
|
||||||
| "stdio"
|
| "stdio"
|
||||||
| "sse"
|
| "sse"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export type JsonSchemaType = {
|
||||||
| "object"
|
| "object"
|
||||||
| "null";
|
| "null";
|
||||||
description?: string;
|
description?: string;
|
||||||
required?: boolean;
|
required?: string[];
|
||||||
default?: JsonValue;
|
default?: JsonValue;
|
||||||
properties?: Record<string, JsonSchemaType>;
|
properties?: Record<string, JsonSchemaType>;
|
||||||
items?: JsonSchemaType;
|
items?: JsonSchemaType;
|
||||||
|
|
|
||||||
|
|
@ -81,46 +81,70 @@ export function hasOutputSchema(toolName: string): boolean {
|
||||||
/**
|
/**
|
||||||
* Generates a default value based on a JSON schema type
|
* Generates a default value based on a JSON schema type
|
||||||
* @param schema The JSON schema definition
|
* @param schema The JSON schema definition
|
||||||
* @returns A default value matching the schema type, or null for non-required fields
|
* @param propertyName Optional property name for checking if it's required in parent schema
|
||||||
|
* @param parentSchema Optional parent schema to check required array
|
||||||
|
* @returns A default value matching the schema type
|
||||||
*/
|
*/
|
||||||
export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
|
export function generateDefaultValue(
|
||||||
if ("default" in schema) {
|
schema: JsonSchemaType,
|
||||||
|
propertyName?: string,
|
||||||
|
parentSchema?: JsonSchemaType,
|
||||||
|
): JsonValue {
|
||||||
|
if ("default" in schema && schema.default !== undefined) {
|
||||||
return schema.default;
|
return schema.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!schema.required) {
|
// Check if this property is required in the parent schema
|
||||||
if (schema.type === "array") return [];
|
const isRequired =
|
||||||
if (schema.type === "object") return {};
|
propertyName && parentSchema
|
||||||
return undefined;
|
? isPropertyRequired(propertyName, parentSchema)
|
||||||
}
|
: false;
|
||||||
|
|
||||||
switch (schema.type) {
|
switch (schema.type) {
|
||||||
case "string":
|
case "string":
|
||||||
return "";
|
return isRequired ? "" : undefined;
|
||||||
case "number":
|
case "number":
|
||||||
case "integer":
|
case "integer":
|
||||||
return 0;
|
return isRequired ? 0 : undefined;
|
||||||
case "boolean":
|
case "boolean":
|
||||||
return false;
|
return isRequired ? false : undefined;
|
||||||
case "array":
|
case "array":
|
||||||
return [];
|
return [];
|
||||||
case "object": {
|
case "object": {
|
||||||
if (!schema.properties) return {};
|
if (!schema.properties) return {};
|
||||||
|
|
||||||
const obj: JsonObject = {};
|
const obj: JsonObject = {};
|
||||||
Object.entries(schema.properties)
|
// Only include properties that are required according to the schema's required array
|
||||||
.filter(([, prop]) => prop.required)
|
Object.entries(schema.properties).forEach(([key, prop]) => {
|
||||||
.forEach(([key, prop]) => {
|
if (isPropertyRequired(key, schema)) {
|
||||||
const value = generateDefaultValue(prop);
|
const value = generateDefaultValue(prop, key, schema);
|
||||||
obj[key] = value;
|
if (value !== undefined) {
|
||||||
});
|
obj[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
default:
|
case "null":
|
||||||
return null;
|
return null;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if a property is required in a schema
|
||||||
|
* @param propertyName The name of the property to check
|
||||||
|
* @param schema The parent schema containing the required array
|
||||||
|
* @returns true if the property is required, false otherwise
|
||||||
|
*/
|
||||||
|
export function isPropertyRequired(
|
||||||
|
propertyName: string,
|
||||||
|
schema: JsonSchemaType,
|
||||||
|
): boolean {
|
||||||
|
return schema.required?.includes(propertyName) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a field key into a human-readable label
|
* Formats a field key into a human-readable label
|
||||||
* @param key The field key to format
|
* @param key The field key to format
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { findActualExecutable } from "spawn-rx";
|
||||||
import mcpProxy from "./mcpProxy.js";
|
import mcpProxy from "./mcpProxy.js";
|
||||||
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
|
||||||
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
|
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
|
||||||
const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [
|
const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [
|
||||||
"authorization",
|
"authorization",
|
||||||
|
|
@ -528,24 +529,19 @@ app.get("/config", originValidationMiddleware, authMiddleware, (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = parseInt(process.env.SERVER_PORT || "6277", 10);
|
const PORT = parseInt(
|
||||||
|
process.env.SERVER_PORT || DEFAULT_MCP_PROXY_LISTEN_PORT,
|
||||||
|
10,
|
||||||
|
);
|
||||||
const HOST = process.env.HOST || "localhost";
|
const HOST = process.env.HOST || "localhost";
|
||||||
|
|
||||||
const server = app.listen(PORT, HOST);
|
const server = app.listen(PORT, HOST);
|
||||||
server.on("listening", () => {
|
server.on("listening", () => {
|
||||||
console.log(`⚙️ Proxy server listening on ${HOST}:${PORT}`);
|
console.log(`⚙️ Proxy server listening on ${HOST}:${PORT}`);
|
||||||
if (!authDisabled) {
|
if (!authDisabled) {
|
||||||
console.log(`🔑 Session token: ${sessionToken}`);
|
|
||||||
console.log(
|
console.log(
|
||||||
`Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth`,
|
`🔑 Session token: ${sessionToken}\n ` +
|
||||||
);
|
`Use this token to authenticate requests or set DANGEROUSLY_OMIT_AUTH=true to disable auth`,
|
||||||
|
|
||||||
// Display clickable URL with pre-filled token
|
|
||||||
const clientPort = process.env.CLIENT_PORT || "6274";
|
|
||||||
const clientHost = process.env.HOST || "localhost";
|
|
||||||
const clientUrl = `http://${clientHost}:${clientPort}/?MCP_PROXY_AUTH_TOKEN=${sessionToken}`;
|
|
||||||
console.log(
|
|
||||||
`\n🔗 Open inspector with token pre-filled:\n ${clientUrl}\n`,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue