diff --git a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll.meta b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll.meta index 6e244a941..3e2874804 100644 --- a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll.meta +++ b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: f2b17cb86ebbab0498de7a05697c50c4 +guid: c061112e81f53a24fb5437ec4fe122c5 PluginImporter: externalObjects: {} serializedVersion: 3 diff --git a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll.meta b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll.meta index fd7c5d202..cca9f5ffb 100644 --- a/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll.meta +++ b/Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 8ca0a15d310a5f843b0937012d11f4ea +guid: f40aae41f95ca764c8729414e1abe315 PluginImporter: externalObjects: {} serializedVersion: 3 diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index a0a9c6bbb..ec207e3a0 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to `unity-mcp-cli` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`runTool` / `runSystemTool` library functions.** The HTTP-tool + invocation paths previously available only as the `run-tool` / + `run-system-tool` CLI commands are now first-class library exports: + + ```ts + import { runTool, runSystemTool } from 'unity-mcp-cli'; + + const result = await runTool({ + toolName: 'tool-list', + unityProjectPath: '/path/to/Unity/project', + input: { regexSearch: 'gameobject', includeDescription: true }, + }); + if (result.kind === 'success') { + // result.data === parsed JSON body from the Unity plugin + } + ``` + + Same URL/token resolution priority as the CLI commands (explicit + override → project config → deterministic localhost port). Returns + a discriminated `{ kind: 'success' | 'failure', ... }` union; never + throws past the public boundary; emits no console output. + +### Changed + +- **`run-tool` / `run-system-tool` commands** now delegate to the new + library functions internally. CLI behaviour is unchanged. + ## [0.67.0] - 2026-04-21 ### Added diff --git a/cli/package.json b/cli/package.json index ae16f6722..46a3dbb71 100644 --- a/cli/package.json +++ b/cli/package.json @@ -70,4 +70,4 @@ "typescript": "^5.8.0", "vitest": "^3.1.0" } -} +} \ No newline at end of file diff --git a/cli/src/commands/run-system-tool.ts b/cli/src/commands/run-system-tool.ts index 6db3ce834..3752e0b81 100644 --- a/cli/src/commands/run-system-tool.ts +++ b/cli/src/commands/run-system-tool.ts @@ -1,146 +1,13 @@ -import { Command } from 'commander'; -import * as ui from '../utils/ui.js'; -import { verbose } from '../utils/ui.js'; -import { resolveAndValidateProjectPath, resolveConnection } from '../utils/connection.js'; -import { parseInput } from '../utils/input.js'; - -interface RunSystemToolOptions { - path?: string; - url?: string; - token?: string; - input?: string; - inputFile?: string; - raw?: boolean; - timeout?: string; -} - -export const runSystemToolCommand = new Command('run-system-tool') - .description('Execute a system tool via the HTTP API (not exposed to MCP clients)') - .argument('', 'Name of the system tool to execute') - .argument('[path]', 'Unity project path (used for config and auto port detection)') - .option('--path ', 'Unity project path (config and auto port detection)') - .option('--url ', 'Direct server URL override (bypasses config)') - .option('--token ', 'Bearer token override (bypasses config)') - .option('--input ', 'JSON string of tool arguments') - .option('--input-file ', 'Read JSON arguments from file') - .option('--raw', 'Output raw JSON (no formatting)') - .option('--timeout ', 'Request timeout in milliseconds (default: 60000)', '60000') - .action(async (toolName: string, positionalPath: string | undefined, options: RunSystemToolOptions) => { - const projectPath = resolveAndValidateProjectPath(positionalPath, options); - const { url: baseUrl, token } = resolveConnection(projectPath, options); - const body = parseInput(options); - const endpoint = `${baseUrl}/api/system-tools/${encodeURIComponent(toolName)}`; - - verbose(`System tool: ${toolName}`); - verbose(`Endpoint: ${endpoint}`); - verbose(`Body: ${body}`); - - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (token) { - headers['Authorization'] = `Bearer ${token}`; - verbose(`Authorization header set (source: ${options.token ? '--token flag' : 'config'})`); - } - - const authSource = options.token ? '--token flag' : 'config'; - - if (!options.raw) { - ui.heading('Run System Tool'); - ui.label('Tool', toolName); - ui.label('URL', endpoint); - if (token) { - ui.label('Auth', `from ${authSource}`); - } - ui.divider(); - } - - const spinner = options.raw ? null : ui.startSpinner(`Calling ${toolName}...`); - - const timeoutMs = parseInt(options.timeout ?? '60000', 10); - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - ui.error(`Invalid --timeout value: "${options.timeout}". Must be a positive integer (milliseconds).`); - process.exit(1); - } - const controller = new AbortController(); - const fetchTimeout = setTimeout(() => controller.abort(), timeoutMs); - - try { - const response = await fetch(endpoint, { - method: 'POST', - headers, - body, - signal: controller.signal, - }); - - const responseText = await response.text(); - let responseData: unknown; - try { - responseData = JSON.parse(responseText); - } catch { - responseData = responseText; - } - - if (!response.ok) { - spinner?.stop(); - if (options.raw) { - process.stdout.write(responseText); - } else { - ui.error(`HTTP ${response.status}: ${response.statusText}`); - if (responseData) { - ui.info(typeof responseData === 'string' - ? responseData - : JSON.stringify(responseData, null, 2)); - } - } - process.exit(1); - } - - spinner?.success(`${toolName} completed`); - - if (options.raw) { - process.stdout.write(responseText); - } else { - ui.success('Response:'); - console.log(typeof responseData === 'string' - ? responseData - : JSON.stringify(responseData, null, 2)); - } - } catch (err) { - spinner?.stop(); - const isTimeout = err instanceof Error && err.name === 'AbortError'; - const cause = err instanceof Error && 'cause' in err ? (err.cause as Error & { code?: string }) : null; - const causeCode = cause?.code ?? ''; - const rootMessage = cause?.message || causeCode || (err instanceof Error ? err.message : String(err)); - const errorSignature = `${rootMessage} ${causeCode}`; - const isConnectionRefused = errorSignature.includes('ECONNREFUSED'); - const isConnectionReset = errorSignature.includes('ECONNRESET'); - const isNetworkError = errorSignature.includes('EAI_AGAIN') || errorSignature.includes('ENOTFOUND'); - - let displayMessage: string; - if (isTimeout) { - displayMessage = `System tool call timed out after ${timeoutMs / 1000} seconds: ${toolName}`; - } else if (isConnectionRefused) { - displayMessage = `Connection refused at ${endpoint}. Is the MCP server running? Start Unity Editor with the MCP plugin first.`; - } else if (isConnectionReset) { - displayMessage = `Connection was reset by the server at ${endpoint}. The server may have crashed or restarted.`; - } else if (isNetworkError) { - displayMessage = `Cannot reach ${endpoint}. Check your network connection and server URL.`; - } else { - displayMessage = `${rootMessage}`; - if (cause && cause.message !== rootMessage) { - displayMessage += ` (${cause.message})`; - } - } - - if (options.raw) { - process.stderr.write(displayMessage + '\n'); - } else { - ui.error(`Failed to call system tool: ${displayMessage}`); - } - process.exit(1); - } finally { - clearTimeout(fetchTimeout); - } - }); +import { runSystemTool } from '../lib/run-tool.js'; +import { buildRunToolCommand } from './run-tool-builder.js'; + +export const runSystemToolCommand = buildRunToolCommand({ + name: 'run-system-tool', + description: 'Execute a system tool via the HTTP API (not exposed to MCP clients)', + argDescription: 'Name of the system tool to execute', + routePrefix: '/api/system-tools', + verboseLabel: 'System tool', + headingLabel: 'Run System Tool', + errorNoun: 'system tool', + invoke: runSystemTool, +}); diff --git a/cli/src/commands/run-tool-builder.ts b/cli/src/commands/run-tool-builder.ts new file mode 100644 index 000000000..198944bc9 --- /dev/null +++ b/cli/src/commands/run-tool-builder.ts @@ -0,0 +1,170 @@ +import { Command } from 'commander'; +import * as ui from '../utils/ui.js'; +import { verbose } from '../utils/ui.js'; +import { resolveAndValidateProjectPath, resolveConnection } from '../utils/connection.js'; +import { parseInput } from '../utils/input.js'; +import type { RunToolFailure, RunToolOptions, RunToolResult } from '../lib/types.js'; + +interface BuilderOptions { + /** CLI subcommand name, e.g. `'run-tool'`. */ + name: string; + /** `description()` text for the commander command. */ + description: string; + /** Description shown next to the `` argument. */ + argDescription: string; + /** URL path prefix the lib function targets, e.g. `'/api/tools'`. */ + routePrefix: string; + /** Verbose-log label, e.g. `'Tool'` or `'System tool'`. */ + verboseLabel: string; + /** Heading printed by `ui.heading`, e.g. `'Run Tool'`. */ + headingLabel: string; + /** + * Lower-case noun used in user-facing failure copy: + * `Failed to call :` and ` call timed out…`. + */ + errorNoun: string; + /** Library function — `runTool` or `runSystemTool`. */ + invoke: (opts: RunToolOptions) => Promise; +} + +interface CliOptions { + path?: string; + url?: string; + token?: string; + input?: string; + inputFile?: string; + raw?: boolean; + timeout?: string; +} + +interface FailureContext { + toolName: string; + endpoint: string; + raw: boolean | undefined; + timeoutMs: number; + errorNoun: string; +} + +export function buildRunToolCommand(cfg: BuilderOptions): Command { + return new Command(cfg.name) + .description(cfg.description) + .argument('', cfg.argDescription) + .argument('[path]', 'Unity project path (used for config and auto port detection)') + .option('--path ', 'Unity project path (config and auto port detection)') + .option('--url ', 'Direct server URL override (bypasses config)') + .option('--token ', 'Bearer token override (bypasses config)') + .option('--input ', 'JSON string of tool arguments') + .option('--input-file ', 'Read JSON arguments from file') + .option('--raw', 'Output raw JSON (no formatting)') + .option('--timeout ', 'Request timeout in milliseconds (default: 60000)', '60000') + .action(async (toolName: string, positionalPath: string | undefined, options: CliOptions) => { + // Validate --timeout first so a bad value short-circuits before + // we read --input-file or hit the network. + const timeoutMs = parseInt(options.timeout!, 10); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + ui.error(`Invalid --timeout value: "${options.timeout}". Must be a positive integer (milliseconds).`); + process.exit(1); + } + + // Resolve path + connection up front so the heading and verbose + // output reflect the final endpoint before the HTTP call fires. + const projectPath = resolveAndValidateProjectPath(positionalPath, options); + const { url: baseUrl, token } = resolveConnection(projectPath, options); + const body = parseInput(options); + const endpoint = `${baseUrl}${cfg.routePrefix}/${encodeURIComponent(toolName)}`; + const authSource = options.token ? '--token flag' : 'config'; + + verbose(`${cfg.verboseLabel}: ${toolName}`); + verbose(`Endpoint: ${endpoint}`); + verbose(`Body: ${body}`); + if (token) verbose(`Authorization header set (source: ${authSource})`); + + if (!options.raw) { + ui.heading(cfg.headingLabel); + ui.label('Tool', toolName); + ui.label('URL', endpoint); + if (token) ui.label('Auth', `from ${authSource}`); + ui.divider(); + } + + const spinner = options.raw ? null : ui.startSpinner(`Calling ${toolName}...`); + + // The CLI already resolved url/token, so passing them explicitly + // makes the lib's resolver a no-op rather than re-reading config. + const result = await cfg.invoke({ + toolName, + url: baseUrl, + ...(token ? { token } : {}), + input: body, + timeoutMs, + }); + + if (result.kind === 'success') { + spinner?.success(`${toolName} completed`); + if (options.raw) { + process.stdout.write(stringifyForRaw(result.data)); + } else { + ui.success('Response:'); + console.log(typeof result.data === 'string' + ? result.data + : JSON.stringify(result.data, null, 2)); + } + return; + } + + spinner?.stop(); + handleFailure(result, { + toolName, + endpoint, + raw: options.raw, + timeoutMs, + errorNoun: cfg.errorNoun, + }); + }); +} + +function stringifyForRaw(data: unknown): string { + if (typeof data === 'string') return data; + if (data === undefined) return ''; + return JSON.stringify(data); +} + +function handleFailure(failure: RunToolFailure, ctx: FailureContext): never { + if (failure.reason === 'http-error') { + if (ctx.raw) { + process.stdout.write(stringifyForRaw(failure.data)); + } else { + ui.error(`HTTP ${failure.httpStatus}: ${failure.message}`); + if (failure.data !== undefined) { + ui.info(typeof failure.data === 'string' + ? failure.data + : JSON.stringify(failure.data, null, 2)); + } + } + process.exit(1); + } + + const message = buildFailureMessage(failure, ctx); + if (ctx.raw) process.stderr.write(message + '\n'); + else ui.error(`Failed to call ${ctx.errorNoun}: ${message}`); + process.exit(1); +} + +function buildFailureMessage(failure: RunToolFailure, ctx: FailureContext): string { + switch (failure.reason) { + case 'timeout': + return `${capitalize(ctx.errorNoun)} call timed out after ${ctx.timeoutMs / 1000} seconds: ${ctx.toolName}`; + case 'connection-refused': + return `Connection refused at ${ctx.endpoint}. Is the MCP server running? Start Unity Editor with the MCP plugin first.`; + case 'connection-reset': + return `Connection was reset by the server at ${ctx.endpoint}. The server may have crashed or restarted.`; + case 'network-error': + return `Cannot reach ${ctx.endpoint}. Check your network connection and server URL.`; + default: + return failure.message; + } +} + +function capitalize(s: string): string { + return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/cli/src/commands/run-tool.ts b/cli/src/commands/run-tool.ts index 5d1cfdd8a..545aec682 100644 --- a/cli/src/commands/run-tool.ts +++ b/cli/src/commands/run-tool.ts @@ -1,146 +1,13 @@ -import { Command } from 'commander'; -import * as ui from '../utils/ui.js'; -import { verbose } from '../utils/ui.js'; -import { resolveAndValidateProjectPath, resolveConnection } from '../utils/connection.js'; -import { parseInput } from '../utils/input.js'; - -interface RunToolOptions { - path?: string; - url?: string; - token?: string; - input?: string; - inputFile?: string; - raw?: boolean; - timeout?: string; -} - -export const runToolCommand = new Command('run-tool') - .description('Execute an MCP tool via the HTTP API') - .argument('', 'Name of the MCP tool to execute') - .argument('[path]', 'Unity project path (used for config and auto port detection)') - .option('--path ', 'Unity project path (config and auto port detection)') - .option('--url ', 'Direct server URL override (bypasses config)') - .option('--token ', 'Bearer token override (bypasses config)') - .option('--input ', 'JSON string of tool arguments') - .option('--input-file ', 'Read JSON arguments from file') - .option('--raw', 'Output raw JSON (no formatting)') - .option('--timeout ', 'Request timeout in milliseconds (default: 60000)', '60000') - .action(async (toolName: string, positionalPath: string | undefined, options: RunToolOptions) => { - const projectPath = resolveAndValidateProjectPath(positionalPath, options); - const { url: baseUrl, token } = resolveConnection(projectPath, options); - const body = parseInput(options); - const endpoint = `${baseUrl}/api/tools/${encodeURIComponent(toolName)}`; - - verbose(`Tool: ${toolName}`); - verbose(`Endpoint: ${endpoint}`); - verbose(`Body: ${body}`); - - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (token) { - headers['Authorization'] = `Bearer ${token}`; - verbose(`Authorization header set (source: ${options.token ? '--token flag' : 'config'})`); - } - - const authSource = options.token ? '--token flag' : 'config'; - - if (!options.raw) { - ui.heading('Run Tool'); - ui.label('Tool', toolName); - ui.label('URL', endpoint); - if (token) { - ui.label('Auth', `from ${authSource}`); - } - ui.divider(); - } - - const spinner = options.raw ? null : ui.startSpinner(`Calling ${toolName}...`); - - const timeoutMs = parseInt(options.timeout ?? '60000', 10); - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - ui.error(`Invalid --timeout value: "${options.timeout}". Must be a positive integer (milliseconds).`); - process.exit(1); - } - const controller = new AbortController(); - const fetchTimeout = setTimeout(() => controller.abort(), timeoutMs); - - try { - const response = await fetch(endpoint, { - method: 'POST', - headers, - body, - signal: controller.signal, - }); - - const responseText = await response.text(); - let responseData: unknown; - try { - responseData = JSON.parse(responseText); - } catch { - responseData = responseText; - } - - if (!response.ok) { - spinner?.stop(); - if (options.raw) { - process.stdout.write(responseText); - } else { - ui.error(`HTTP ${response.status}: ${response.statusText}`); - if (responseData) { - ui.info(typeof responseData === 'string' - ? responseData - : JSON.stringify(responseData, null, 2)); - } - } - process.exit(1); - } - - spinner?.success(`${toolName} completed`); - - if (options.raw) { - process.stdout.write(responseText); - } else { - ui.success('Response:'); - console.log(typeof responseData === 'string' - ? responseData - : JSON.stringify(responseData, null, 2)); - } - } catch (err) { - spinner?.stop(); - const isTimeout = err instanceof Error && err.name === 'AbortError'; - const cause = err instanceof Error && 'cause' in err ? (err.cause as Error & { code?: string }) : null; - const causeCode = cause?.code ?? ''; - const rootMessage = cause?.message || causeCode || (err instanceof Error ? err.message : String(err)); - const errorSignature = `${rootMessage} ${causeCode}`; - const isConnectionRefused = errorSignature.includes('ECONNREFUSED'); - const isConnectionReset = errorSignature.includes('ECONNRESET'); - const isNetworkError = errorSignature.includes('EAI_AGAIN') || errorSignature.includes('ENOTFOUND'); - - let displayMessage: string; - if (isTimeout) { - displayMessage = `Tool call timed out after ${timeoutMs / 1000} seconds: ${toolName}`; - } else if (isConnectionRefused) { - displayMessage = `Connection refused at ${endpoint}. Is the MCP server running? Start Unity Editor with the MCP plugin first.`; - } else if (isConnectionReset) { - displayMessage = `Connection was reset by the server at ${endpoint}. The server may have crashed or restarted.`; - } else if (isNetworkError) { - displayMessage = `Cannot reach ${endpoint}. Check your network connection and server URL.`; - } else { - displayMessage = `${rootMessage}`; - if (cause && cause.message !== rootMessage) { - displayMessage += ` (${cause.message})`; - } - } - - if (options.raw) { - process.stderr.write(displayMessage + '\n'); - } else { - ui.error(`Failed to call tool: ${displayMessage}`); - } - process.exit(1); - } finally { - clearTimeout(fetchTimeout); - } - }); +import { runTool } from '../lib/run-tool.js'; +import { buildRunToolCommand } from './run-tool-builder.js'; + +export const runToolCommand = buildRunToolCommand({ + name: 'run-tool', + description: 'Execute an MCP tool via the HTTP API', + argDescription: 'Name of the MCP tool to execute', + routePrefix: '/api/tools', + verboseLabel: 'Tool', + headingLabel: 'Run Tool', + errorNoun: 'tool', + invoke: runTool, +}); diff --git a/cli/src/lib.ts b/cli/src/lib.ts index 08f285873..7d4824c8b 100644 --- a/cli/src/lib.ts +++ b/cli/src/lib.ts @@ -21,6 +21,7 @@ export { removePlugin } from './lib/remove-plugin.js'; export { configure } from './lib/configure.js'; export { setupMcp, listAgentIds } from './lib/setup-mcp.js'; export { openProject } from './lib/open.js'; +export { runTool, runSystemTool } from './lib/run-tool.js'; export type { // Shared @@ -58,4 +59,14 @@ export type { OpenProjectFailure, OpenProjectAuthOption, OpenProjectTransport, + // run-tool / run-system-tool + RunToolOptions, + RunToolResult, + RunToolSuccess, + RunToolFailure, + RunToolFailureReason, + RunSystemToolOptions, + RunSystemToolResult, + RunSystemToolSuccess, + RunSystemToolFailure, } from './lib/types.js'; diff --git a/cli/src/lib/run-tool.ts b/cli/src/lib/run-tool.ts new file mode 100644 index 000000000..d550103ba --- /dev/null +++ b/cli/src/lib/run-tool.ts @@ -0,0 +1,266 @@ +// Library-safe `runTool` / `runSystemTool` implementations. +// +// Constraints (same contract as the rest of `lib/*.ts`): +// - No commander, no spinners, no process.exit, no console output. +// - Errors are returned in `{ kind: 'failure', success: false, ... }`, +// never thrown past the public boundary. + +import { readConfig, resolveConnectionFromConfig } from '../utils/config.js'; +import { generatePortFromDirectory } from '../utils/port.js'; +import { requireProjectPath } from './validation.js'; +import type { + RunToolFailure, + RunToolFailureReason, + RunToolOptions, + RunToolResult, + RunToolSuccess, +} from './types.js'; + +const DEFAULT_TIMEOUT_MS = 60_000; + +interface ErrorCause { + code?: string; + message?: string; +} + +/** + * Invoke a regular MCP tool over the Unity plugin's HTTP API. + * + * URL/token resolution priority: explicit override → project config → + * deterministic localhost port. POSTs to `/api/tools/{name}`. No + * console output, no `process.exit`; errors are returned in the + * `kind: 'failure'` variant. + */ +export async function runTool(opts: RunToolOptions): Promise { + return invokeTool('/api/tools', opts); +} + +/** + * Invoke a system tool (internal tool not exposed to MCP clients) over + * the Unity plugin's HTTP API. POSTs to `/api/system-tools/{name}`. + */ +export async function runSystemTool(opts: RunToolOptions): Promise { + return invokeTool('/api/system-tools', opts); +} + +async function invokeTool(routePrefix: string, opts: RunToolOptions): Promise { + const validationFailure = validateOptions(opts); + if (validationFailure) return validationFailure; + + const resolved = resolveConnection(opts); + if (resolved.kind === 'failure') return resolved; + const { url, token } = resolved; + + const body = serializeInput(opts.input); + if ('error' in body) { + return makeFailure({ + endpoint: '', + reason: 'invalid-input', + message: body.error.message, + error: body.error, + }); + } + + const endpoint = `${url}${routePrefix}/${encodeURIComponent(opts.toolName)}`; + + const fetchImpl = opts.fetchImpl ?? globalThis.fetch; + const timeoutMs = + typeof opts.timeoutMs === 'number' && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const externalAbort = (): void => controller.abort(); + if (opts.signal) { + if (opts.signal.aborted) controller.abort(); + else opts.signal.addEventListener('abort', externalAbort, { once: true }); + } + + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + try { + const response = await fetchImpl(endpoint, { + method: 'POST', + headers, + body: body.json, + signal: controller.signal, + }); + + const text = await safeReadText(response); + const data = parseJsonOrText(text); + + if (!response.ok) { + return makeFailure({ + endpoint, + reason: 'http-error', + httpStatus: response.status, + data, + message: response.statusText || `HTTP ${response.status}`, + }); + } + + const success: RunToolSuccess = { + kind: 'success', + success: true, + endpoint, + httpStatus: response.status, + data, + }; + return success; + } catch (err) { + return classifyFetchError(err, endpoint, timeoutMs); + } finally { + clearTimeout(timer); + opts.signal?.removeEventListener('abort', externalAbort); + } +} + +function validateOptions(opts: RunToolOptions): RunToolFailure | null { + if (!opts || typeof opts !== 'object') { + return makeFailure({ + endpoint: '', + reason: 'invalid-input', + message: 'options object is required.', + }); + } + if (typeof opts.toolName !== 'string' || opts.toolName.trim().length === 0) { + return makeFailure({ + endpoint: '', + reason: 'invalid-input', + message: 'toolName is required and must be a non-empty string.', + }); + } + const hasUrl = typeof opts.url === 'string' && opts.url.length > 0; + const hasProjectPath = + typeof opts.unityProjectPath === 'string' && opts.unityProjectPath.trim().length > 0; + if (!hasUrl && !hasProjectPath) { + return makeFailure({ + endpoint: '', + reason: 'invalid-input', + message: 'Either unityProjectPath or url must be provided.', + }); + } + return null; +} + +function resolveConnection( + opts: RunToolOptions, +): { kind: 'success'; url: string; token: string | undefined } | RunToolFailure { + if (opts.url) { + return { kind: 'success', url: opts.url.replace(/\/$/, ''), token: opts.token }; + } + + // `unityProjectPath` is library-only — does NOT require an `Assets/` + // folder, unlike the CLI's `resolveAndValidateProjectPath`. The + // deterministic-port fallback works against the path string alone. + const validated = requireProjectPath(opts.unityProjectPath); + if (!validated.ok) { + return makeFailure({ + endpoint: '', + reason: 'invalid-input', + message: validated.error.message, + error: validated.error, + }); + } + const projectPath = validated.projectPath; + + const config = readConfig(projectPath); + const fromConfig = config + ? resolveConnectionFromConfig(config) + : { url: undefined, token: undefined }; + + const url = fromConfig.url + ? fromConfig.url.replace(/\/$/, '') + : `http://localhost:${generatePortFromDirectory(projectPath)}`; + + return { kind: 'success', url, token: opts.token ?? fromConfig.token }; +} + +function serializeInput(input: unknown): { json: string } | { error: Error } { + if (input === undefined || input === null) return { json: '{}' }; + if (typeof input === 'string') { + // Validate the round-trip so the server never sees malformed bodies. + try { + JSON.parse(input); + return { json: input }; + } catch (err) { + return { + error: new Error( + `input string is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ), + }; + } + } + if (typeof input !== 'object') { + return { + error: new Error('input must be a plain object, JSON string, undefined, or null.'), + }; + } + try { + return { json: JSON.stringify(input) }; + } catch (err) { + return { + error: new Error( + `input could not be serialized to JSON: ${err instanceof Error ? err.message : String(err)}`, + ), + }; + } +} + +async function safeReadText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} + +function parseJsonOrText(text: string): unknown { + if (text.length === 0) return undefined; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function getCause(err: unknown): ErrorCause | undefined { + if (!(err instanceof Error) || !('cause' in err)) return undefined; + return err.cause as ErrorCause | undefined; +} + +function classifyFetchError( + err: unknown, + endpoint: string, + timeoutMs: number, +): RunToolFailure { + if (err instanceof Error && err.name === 'AbortError') { + return makeFailure({ + endpoint, + reason: 'timeout', + message: `Tool call timed out after ${timeoutMs}ms.`, + error: err, + }); + } + + const error = err instanceof Error ? err : new Error(String(err)); + const causeCode = getCause(err)?.code; + + let reason: RunToolFailureReason = 'unknown'; + if (causeCode === 'ECONNREFUSED') reason = 'connection-refused'; + else if (causeCode === 'ECONNRESET') reason = 'connection-reset'; + else if (causeCode === 'ENOTFOUND' || causeCode === 'EAI_AGAIN') reason = 'network-error'; + + return makeFailure({ + endpoint, + reason, + message: error.message, + error, + }); +} + +function makeFailure( + fields: Omit, +): RunToolFailure { + return { kind: 'failure', success: false, ...fields }; +} diff --git a/cli/src/lib/types.ts b/cli/src/lib/types.ts index 7dc984e01..91116ddca 100644 --- a/cli/src/lib/types.ts +++ b/cli/src/lib/types.ts @@ -417,3 +417,126 @@ export interface OpenEnvInputs { transport?: OpenProjectOptions['transport']; startServer?: OpenProjectOptions['startServer']; } + +// --------------------------------------------------------------------------- +// run-tool / run-system-tool +// --------------------------------------------------------------------------- + +/** + * Coarse failure category for {@link RunToolFailure}. Mirrors the + * branches the CLI's `run-tool` command surfaces in its error path so + * library consumers can render the same diagnostics without re-deriving + * them from the underlying `Error`. + */ +export type RunToolFailureReason = + | 'invalid-input' + | 'connection-refused' + | 'connection-reset' + | 'network-error' + | 'timeout' + | 'http-error' + | 'unknown'; + +/** + * Options accepted by both {@link runTool} and {@link runSystemTool}. + * + * Either `unityProjectPath` (preferred — resolves URL + token from the + * project's `UserSettings/AI-Game-Developer-Config.json`, falling back + * to a deterministic localhost port when the file is absent) or `url` + * (explicit endpoint override) MUST be provided. + */ +export interface RunToolOptions { + /** + * Tool name to invoke. Forwarded as the `{name}` segment of the + * route — the function URL-encodes it before issuing the request. + */ + toolName: string; + /** + * Absolute or relative path to the Unity project. Used to read the + * project's config (host + token) and, as a last resort, to derive + * the deterministic localhost port mirroring the C# plugin's hash. + */ + unityProjectPath?: string; + /** Explicit base URL override (no trailing slash required). */ + url?: string; + /** Bearer token override. */ + token?: string; + /** + * Tool arguments, serialized as the JSON request body. When omitted, + * the body is `{}`. Anything other than `undefined` / `null` / + * `object` is rejected with a `kind: 'failure'` result. + */ + input?: unknown; + /** + * Per-request timeout in milliseconds. Defaults to `60000` (matching + * the CLI command's `--timeout` default). Values <= 0 are treated as + * the default to keep accidental "0 = disable" mistakes from + * stalling polling callers. + */ + timeoutMs?: number; + /** + * Optional abort signal. When fired, the in-flight fetch is + * cancelled and the result resolves to a `kind: 'failure'` with + * `reason: 'timeout'`. + */ + signal?: AbortSignal; + /** + * Optional injection point so tests can swap the `fetch` + * implementation. Defaults to the global `fetch`. + */ + fetchImpl?: typeof fetch; +} + +/** Successful `runTool` / `runSystemTool` outcome. Narrow with `kind === 'success'`. */ +export interface RunToolSuccess { + kind: 'success'; + /** Always `true` for the success variant. Wire-compatible alias for `kind === 'success'`. */ + success: true; + /** Resolved endpoint URL that was hit (post URL/token resolution). */ + endpoint: string; + /** HTTP status code returned by the Unity plugin. */ + httpStatus: number; + /** + * Parsed response body. The Unity plugin returns + * `{ status: "success", structured?: , content?: }` + * — consumers typically read `data.structured` or `data.content` + * depending on whether the invoked tool returns structured content. + * Non-JSON responses surface the raw text string. + */ + data: unknown; +} + +/** Failed `runTool` / `runSystemTool` outcome. Narrow with `kind === 'failure'`. */ +export interface RunToolFailure { + kind: 'failure'; + /** Always `false` for the failure variant. Wire-compatible alias for `kind === 'failure'`. */ + success: false; + /** + * Resolved endpoint URL. Empty string when the failure is + * `reason: 'invalid-input'` and resolution never happened. + */ + endpoint: string; + /** Coarse cause — see {@link RunToolFailureReason}. */ + reason: RunToolFailureReason; + /** HTTP status code when `reason === 'http-error'`. */ + httpStatus?: number; + /** + * Response body for diagnostics on `http-error` (parsed JSON when the + * server returned JSON, otherwise the raw text). Omitted on transport + * failures where no response was received. + */ + data?: unknown; + /** Human-readable failure summary; never thrown past the public boundary. */ + message: string; + /** Captured error, when applicable. */ + error?: Error; +} + +export type RunToolResult = RunToolSuccess | RunToolFailure; + +// `runSystemTool` shares the exact shape of `runTool`; these aliases +// exist purely for naming symmetry at the consumer site. +export type RunSystemToolOptions = RunToolOptions; +export type RunSystemToolResult = RunToolResult; +export type RunSystemToolSuccess = RunToolSuccess; +export type RunSystemToolFailure = RunToolFailure; diff --git a/cli/tests/lib-run-tool.test.ts b/cli/tests/lib-run-tool.test.ts new file mode 100644 index 000000000..2fb9944af --- /dev/null +++ b/cli/tests/lib-run-tool.test.ts @@ -0,0 +1,467 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { runTool, runSystemTool, type RunToolOptions, type RunToolResult } from '../src/lib.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response { + return new Response(JSON.stringify(body), { + status, + statusText, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function textResponse(body: string, status = 200, statusText = 'OK'): Response { + return new Response(body, { status, statusText }); +} + +function mkUnityProject(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'unity-mcp-runtool-')); + fs.mkdirSync(path.join(dir, 'Assets'), { recursive: true }); + return dir; +} + +interface FetchSpy { + fetch: typeof fetch; + calls: Array<{ url: string; init: RequestInit | undefined }>; +} + +function makeFetchSpy(impl: (url: string, init?: RequestInit) => Promise): FetchSpy { + const calls: FetchSpy['calls'] = []; + const fetchImpl: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url; + calls.push({ url, init }); + return impl(url, init); + }; + return { fetch: fetchImpl, calls }; +} + +// --------------------------------------------------------------------------- +// Successful invocation +// --------------------------------------------------------------------------- + +describe('runTool — happy path', () => { + it('posts to /api/tools/{name} with JSON body and returns the parsed structured response', async () => { + const spy = makeFetchSpy(async () => + jsonResponse({ status: 'success', structured: { result: [{ name: 'tool-list' }] } }), + ); + + const result: RunToolResult = await runTool({ + toolName: 'tool-list', + url: 'http://localhost:55000', + input: { regexSearch: 'foo', includeDescription: true }, + fetchImpl: spy.fetch, + }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') throw new Error('expected success kind'); + expect(result.endpoint).toBe('http://localhost:55000/api/tools/tool-list'); + expect(result.httpStatus).toBe(200); + expect(result.data).toEqual({ status: 'success', structured: { result: [{ name: 'tool-list' }] } }); + + expect(spy.calls).toHaveLength(1); + const call = spy.calls[0]; + expect(call.url).toBe('http://localhost:55000/api/tools/tool-list'); + expect(call.init?.method).toBe('POST'); + expect(call.init?.body).toBe('{"regexSearch":"foo","includeDescription":true}'); + const headers = call.init?.headers as Record; + expect(headers['Content-Type']).toBe('application/json'); + expect(headers['Authorization']).toBeUndefined(); + }); + + it('forwards the bearer token when provided', async () => { + const spy = makeFetchSpy(async () => jsonResponse({ status: 'success', content: [] })); + + await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + token: 'secret-123', + fetchImpl: spy.fetch, + }); + + const headers = spy.calls[0].init?.headers as Record; + expect(headers['Authorization']).toBe('Bearer secret-123'); + }); + + it('URL-encodes the tool name segment', async () => { + const spy = makeFetchSpy(async () => jsonResponse({ status: 'success', content: [] })); + + await runTool({ + toolName: 'weird name/with slashes', + url: 'http://localhost:55000', + fetchImpl: spy.fetch, + }); + + expect(spy.calls[0].url).toBe( + 'http://localhost:55000/api/tools/weird%20name%2Fwith%20slashes', + ); + }); + + it('strips a trailing slash from the base URL', async () => { + const spy = makeFetchSpy(async () => jsonResponse({ status: 'success', content: [] })); + + await runTool({ + toolName: 'ping', + url: 'http://localhost:55000/', + fetchImpl: spy.fetch, + }); + + expect(spy.calls[0].url).toBe('http://localhost:55000/api/tools/ping'); + }); + + it('defaults the body to "{}" when no input is provided', async () => { + const spy = makeFetchSpy(async () => jsonResponse({ status: 'success', content: [] })); + + await runTool({ toolName: 'ping', url: 'http://localhost:55000', fetchImpl: spy.fetch }); + + expect(spy.calls[0].init?.body).toBe('{}'); + }); + + it('accepts a pre-serialized JSON string for input', async () => { + const spy = makeFetchSpy(async () => jsonResponse({ status: 'success', content: [] })); + + await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + input: '{"a":1}', + fetchImpl: spy.fetch, + }); + + expect(spy.calls[0].init?.body).toBe('{"a":1}'); + }); + + it('returns the raw text body when the response is not JSON', async () => { + const spy = makeFetchSpy(async () => textResponse('plain-text-response', 200)); + + const result = await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + fetchImpl: spy.fetch, + }); + + expect(result.kind).toBe('success'); + if (result.kind !== 'success') throw new Error('expected success kind'); + expect(result.data).toBe('plain-text-response'); + }); +}); + +// --------------------------------------------------------------------------- +// runSystemTool — endpoint differs, everything else mirrors runTool +// --------------------------------------------------------------------------- + +describe('runSystemTool', () => { + it('posts to /api/system-tools/{name}', async () => { + const spy = makeFetchSpy(async () => jsonResponse({ status: 'success', content: [] })); + + await runSystemTool({ + toolName: 'ping', + url: 'http://localhost:55000', + fetchImpl: spy.fetch, + }); + + expect(spy.calls[0].url).toBe('http://localhost:55000/api/system-tools/ping'); + }); +}); + +// --------------------------------------------------------------------------- +// Validation failures +// --------------------------------------------------------------------------- + +describe('runTool — invalid input', () => { + it('returns kind:"failure" with reason "invalid-input" when toolName is empty', async () => { + const result = await runTool({ + toolName: '', + url: 'http://localhost:55000', + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') throw new Error('expected failure kind'); + expect(result.reason).toBe('invalid-input'); + expect(result.message).toContain('toolName'); + }); + + it('returns kind:"failure" when neither url nor unityProjectPath is provided', async () => { + const result = await runTool({ toolName: 'ping' } as unknown as RunToolOptions); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') throw new Error('expected failure kind'); + expect(result.reason).toBe('invalid-input'); + expect(result.message).toContain('url'); + }); + + it('rejects non-object, non-string, non-null input', async () => { + const result = await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + input: 42 as unknown, + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') throw new Error('expected failure kind'); + expect(result.reason).toBe('invalid-input'); + }); + + it('rejects an input string that is not valid JSON', async () => { + const result = await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + input: 'not json', + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') throw new Error('expected failure kind'); + expect(result.reason).toBe('invalid-input'); + expect(result.message).toContain('not valid JSON'); + }); +}); + +// --------------------------------------------------------------------------- +// Transport failures +// --------------------------------------------------------------------------- + +describe('runTool — failure modes', () => { + it('classifies HTTP 4xx as reason "http-error" and surfaces the body', async () => { + const spy = makeFetchSpy(async () => + jsonResponse({ error: 'Bad request' }, 400, 'Bad Request'), + ); + + const result = await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + fetchImpl: spy.fetch, + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') throw new Error('expected failure kind'); + expect(result.reason).toBe('http-error'); + expect(result.httpStatus).toBe(400); + expect(result.data).toEqual({ error: 'Bad request' }); + expect(result.message).toBe('Bad Request'); + }); + + it('classifies HTTP 5xx as reason "http-error"', async () => { + const spy = makeFetchSpy(async () => textResponse('boom', 500, 'Internal Server Error')); + + const result = await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + fetchImpl: spy.fetch, + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') throw new Error('expected failure kind'); + expect(result.reason).toBe('http-error'); + expect(result.httpStatus).toBe(500); + expect(result.data).toBe('boom'); + }); + + it('classifies an AbortError as reason "timeout"', async () => { + const fetchImpl: typeof fetch = (_url, init) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(new DOMException('aborted', 'AbortError')); + return; + } + signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')); + }); + }); + + const result = await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + timeoutMs: 30, + fetchImpl, + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') throw new Error('expected failure kind'); + expect(result.reason).toBe('timeout'); + expect(result.message).toContain('30ms'); + }); + + it('classifies ECONNREFUSED as reason "connection-refused"', async () => { + const fetchImpl: typeof fetch = async () => { + const err = new TypeError('fetch failed'); + (err as unknown as { cause: { code: string } }).cause = { code: 'ECONNREFUSED' }; + throw err; + }; + + const result = await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + fetchImpl, + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') throw new Error('expected failure kind'); + expect(result.reason).toBe('connection-refused'); + }); + + it('classifies an unknown error as reason "unknown"', async () => { + const fetchImpl: typeof fetch = async () => { + throw new Error('mystery'); + }; + + const result = await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + fetchImpl, + }); + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') throw new Error('expected failure kind'); + expect(result.reason).toBe('unknown'); + expect(result.message).toBe('mystery'); + }); +}); + +// --------------------------------------------------------------------------- +// Connection resolution from Unity project path +// --------------------------------------------------------------------------- + +describe('runTool — connection resolution', () => { + let projectPath: string; + + beforeEach(() => { + projectPath = mkUnityProject(); + }); + + afterEach(() => { + fs.rmSync(projectPath, { recursive: true, force: true }); + }); + + it('falls back to deterministic localhost port when no config exists', async () => { + const spy = makeFetchSpy(async () => jsonResponse({ status: 'success', content: [] })); + + const result = await runTool({ + toolName: 'ping', + unityProjectPath: projectPath, + fetchImpl: spy.fetch, + }); + + expect(result.kind).toBe('success'); + expect(spy.calls[0].url).toMatch(/^http:\/\/localhost:\d{5}\/api\/tools\/ping$/); + }); + + it('uses the host from project config when present', async () => { + fs.mkdirSync(path.join(projectPath, 'UserSettings'), { recursive: true }); + fs.writeFileSync( + path.join(projectPath, 'UserSettings', 'AI-Game-Developer-Config.json'), + JSON.stringify({ + connectionMode: 'Custom', + host: 'http://192.168.1.10:55555', + token: 'config-token', + }), + ); + const spy = makeFetchSpy(async () => jsonResponse({ status: 'success', content: [] })); + + await runTool({ + toolName: 'ping', + unityProjectPath: projectPath, + fetchImpl: spy.fetch, + }); + + expect(spy.calls[0].url).toBe('http://192.168.1.10:55555/api/tools/ping'); + const headers = spy.calls[0].init?.headers as Record; + expect(headers['Authorization']).toBe('Bearer config-token'); + }); + + it('explicit url override beats project config', async () => { + fs.mkdirSync(path.join(projectPath, 'UserSettings'), { recursive: true }); + fs.writeFileSync( + path.join(projectPath, 'UserSettings', 'AI-Game-Developer-Config.json'), + JSON.stringify({ connectionMode: 'Custom', host: 'http://config-host:1', token: 't' }), + ); + const spy = makeFetchSpy(async () => jsonResponse({ status: 'success', content: [] })); + + await runTool({ + toolName: 'ping', + unityProjectPath: projectPath, + url: 'http://override-host:2', + fetchImpl: spy.fetch, + }); + + expect(spy.calls[0].url).toBe('http://override-host:2/api/tools/ping'); + }); +}); + +// --------------------------------------------------------------------------- +// Library hygiene +// --------------------------------------------------------------------------- + +describe('runTool — library hygiene', () => { + it('produces no stdout / stderr noise during a successful call', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const errFn = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + const fetchImpl: typeof fetch = async () => + jsonResponse({ status: 'success', content: [] }); + + const result = await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + fetchImpl, + }); + + expect(result.kind).toBe('success'); + expect(log).not.toHaveBeenCalled(); + expect(errFn).not.toHaveBeenCalled(); + expect(stdout).not.toHaveBeenCalled(); + expect(stderr).not.toHaveBeenCalled(); + + log.mockRestore(); + errFn.mockRestore(); + stdout.mockRestore(); + stderr.mockRestore(); + }); + + it('wire-compatible: result.success mirrors result.kind === "success"', async () => { + const fetchImpl: typeof fetch = async () => + jsonResponse({ status: 'success', content: [] }); + + const ok = await runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + fetchImpl, + }); + expect(ok.success).toBe(ok.kind === 'success'); + + const fail = await runTool({ toolName: '', url: 'http://localhost:55000' }); + expect(fail.success).toBe(fail.kind === 'success'); + }); + + it('honours an externally supplied AbortSignal', async () => { + const controller = new AbortController(); + const fetchImpl: typeof fetch = (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')); + }); + }); + + const promise = runTool({ + toolName: 'ping', + url: 'http://localhost:55000', + signal: controller.signal, + fetchImpl, + }); + + // Abort immediately — the signal is forwarded into the internal + // controller and the fetch should reject with AbortError. + controller.abort(); + const result = await promise; + + expect(result.kind).toBe('failure'); + if (result.kind !== 'failure') throw new Error('expected failure kind'); + expect(result.reason).toBe('timeout'); + }); +});