Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,4 @@
"typescript": "^5.8.0",
"vitest": "^3.1.0"
}
}
}
159 changes: 13 additions & 146 deletions cli/src/commands/run-system-tool.ts
Original file line number Diff line number Diff line change
@@ -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('<tool-name>', 'Name of the system tool to execute')
.argument('[path]', 'Unity project path (used for config and auto port detection)')
.option('--path <path>', 'Unity project path (config and auto port detection)')
.option('--url <url>', 'Direct server URL override (bypasses config)')
.option('--token <token>', 'Bearer token override (bypasses config)')
.option('--input <json>', 'JSON string of tool arguments')
.option('--input-file <file>', 'Read JSON arguments from file')
.option('--raw', 'Output raw JSON (no formatting)')
.option('--timeout <ms>', '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<string, string> = {
'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,
});
170 changes: 170 additions & 0 deletions cli/src/commands/run-tool-builder.ts
Original file line number Diff line number Diff line change
@@ -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 `<tool-name>` 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 <noun>:` and `<Capitalized> call timed out…`.
*/
errorNoun: string;
/** Library function — `runTool` or `runSystemTool`. */
invoke: (opts: RunToolOptions) => Promise<RunToolResult>;
}

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('<tool-name>', cfg.argDescription)
.argument('[path]', 'Unity project path (used for config and auto port detection)')
.option('--path <path>', 'Unity project path (config and auto port detection)')
.option('--url <url>', 'Direct server URL override (bypasses config)')
.option('--token <token>', 'Bearer token override (bypasses config)')
.option('--input <json>', 'JSON string of tool arguments')
.option('--input-file <file>', 'Read JSON arguments from file')
.option('--raw', 'Output raw JSON (no formatting)')
.option('--timeout <ms>', '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 {
Comment on lines +102 to +106
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 {
Comment on lines +132 to +136
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);
}
Loading
Loading