feat: port CLI, TUI, and launcher from v1.5 into v2 (#1452)

Ports the CLI, TUI, and launcher from v1.5 into v2 behind a single
`mcp-inspector` entrypoint (launcher → web/cli/tui). Includes the
`mcp-inspector --web --dev` launcher fix (Vite alias resolution + prod-server
double-start), the root `postinstall` install cascade with simplified CI, and
spec/AGENTS docs (no-workspaces rationale, Development setup).

Closes #1246.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bob Dickinson 2026-06-14 17:27:49 -07:00 • committed by GitHub
commit 2488ca5e38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 25594 additions and 236 deletions

View file

@ -0,0 +1,12 @@
import open from "open";
/**
* Opens a URL in the user's default browser.
* Used when handling oauthAuthorizationRequired to launch the OAuth authorization page.
*
* @param url - URL to open (string or URL)
* @returns Promise that resolves when the opener completes (or rejects on error)
*/
export async function openUrl(url: string | URL): Promise<void> {
await open(typeof url === "string" ? url : url.href);
}

View file

@ -0,0 +1,47 @@
/**
* Converts prompt arguments to ink-form format
*/
import type { FormStructure, FormSection, FormField } from "ink-form";
import type { PromptArgument } from "@modelcontextprotocol/sdk/types.js";
/**
* Converts prompt arguments array to ink-form structure
*/
export function promptArgsToForm(
promptArguments: PromptArgument[],
promptName: string,
): FormStructure {
const fields: FormField[] = [];
if (!promptArguments || promptArguments.length === 0) {
return {
title: `Get Prompt: ${promptName}`,
sections: [{ title: "Parameters", fields: [] }],
};
}
for (const arg of promptArguments) {
const field: FormField = {
name: arg.name,
label: arg.name,
type: "string", // Prompt arguments are always strings
required: arg.required !== false, // Default to required unless explicitly false
description: arg.description,
};
fields.push(field);
}
const sections: FormSection[] = [
{
title: "Prompt Arguments",
fields,
},
];
return {
title: `Get Prompt: ${promptName}`,
sections,
};
}

View file

@ -0,0 +1,137 @@
/**
* Converts JSON Schema to ink-form format
*/
import type { FormStructure, FormSection, FormField } from "ink-form";
/** Minimal JSON Schema property shape used when building tool parameter forms */
interface JsonSchemaProperty {
type?: string;
title?: string;
enum?: unknown[];
items?: { enum?: unknown[] };
minimum?: number;
maximum?: number;
default?: unknown;
}
/** Minimal JSON Schema object shape (properties + required) */
interface JsonSchemaObject {
properties?: Record<string, JsonSchemaProperty>;
required?: string[];
}
/**
* Converts a JSON Schema to ink-form structure
*/
export function schemaToForm(
schema: JsonSchemaObject | null | undefined,
toolName: string,
): FormStructure {
const fields: FormField[] = [];
if (!schema || !schema.properties) {
return {
title: `Test Tool: ${toolName}`,
sections: [{ title: "Parameters", fields: [] }],
};
}
const properties = schema.properties || {};
const required = schema.required || [];
for (const [key, prop] of Object.entries(properties)) {
const property = prop as JsonSchemaProperty;
const baseField = {
name: key,
label: property.title || key,
required: required.includes(key),
};
let field: FormField;
// Handle enum -> select
if (property.enum) {
if (property.type === "array" && property.items?.enum) {
// For array of enums, we'll use select but handle it differently
// Note: ink-form doesn't have multiselect, so we'll use select
field = {
type: "select",
...baseField,
options: property.items.enum.map((val: unknown) => ({
label: String(val),
value: String(val),
})),
} as FormField;
} else {
// Single select
field = {
type: "select",
...baseField,
options: property.enum.map((val: unknown) => ({
label: String(val),
value: String(val),
})),
} as FormField;
}
} else {
// Map JSON Schema types to ink-form types
switch (property.type) {
case "string":
field = {
type: "string",
...baseField,
} as FormField;
break;
case "integer":
field = {
type: "integer",
...baseField,
...(property.minimum !== undefined && { min: property.minimum }),
...(property.maximum !== undefined && { max: property.maximum }),
} as FormField;
break;
case "number":
field = {
type: "float",
...baseField,
...(property.minimum !== undefined && { min: property.minimum }),
...(property.maximum !== undefined && { max: property.maximum }),
} as FormField;
break;
case "boolean":
field = {
type: "boolean",
...baseField,
} as FormField;
break;
default:
// Default to string for unknown types
field = {
type: "string",
...baseField,
} as FormField;
}
}
// Set initial value from default (ink-form FormField allows initialValue for some types)
if (property.default !== undefined) {
(field as FormField & { initialValue?: unknown }).initialValue =
property.default;
}
fields.push(field);
}
const sections: FormSection[] = [
{
title: "Parameters",
fields,
},
];
return {
title: `Test Tool: ${toolName}`,
sections,
};
}

View file

@ -0,0 +1,47 @@
/**
* Converts URI Template to ink-form format for resource templates
*/
import type { FormStructure, FormSection, FormField } from "ink-form";
import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
/**
* Converts a URI Template to ink-form structure
*/
export function uriTemplateToForm(
uriTemplate: string,
templateName: string,
): FormStructure {
const fields: FormField[] = [];
try {
const template = new UriTemplate(uriTemplate);
const variableNames = template.variableNames || [];
for (const variableName of variableNames) {
const field: FormField = {
name: variableName,
label: variableName,
type: "string",
required: false, // URI template variables are typically optional
};
fields.push(field);
}
} catch (error) {
// If parsing fails, return empty form
console.error("Failed to parse URI template:", error);
}
const sections: FormSection[] = [
{
title: "Template Variables",
fields,
},
];
return {
title: `Read Resource: ${templateName}`,
sections,
};
}