Skip to content

Commit 64189c0

Browse files
authored
feat(cli): expose runTool / runSystemTool as library functions (#744)
* feat: implement runTool and runSystemTool library functions - Added runTool and runSystemTool functions to handle invoking MCP tools via the Unity plugin's HTTP API. - Introduced structured error handling for various failure scenarios including HTTP errors, timeouts, and connection issues. - Updated CLI command to utilize the new library functions, improving separation of concerns and code maintainability. - Enhanced type definitions for tool invocation options and results, ensuring better type safety. - Created comprehensive tests for both successful invocations and various failure modes, ensuring robust functionality. * refactor: modularize run tool command implementation and improve error handling * fix: improve HTTP error message formatting in runTool and related tests * refactor(cli): tidy run-tool builder + lib validation - Validate --timeout before parseInput so a bad value short-circuits before reading --input-file or hitting the network. - Compute authSource once and reuse for both verbose and ui.label outputs (was duplicated). - Drop dead `?? '60000'` fallback (commander already defaults the option's value). - Tighten lib validateOptions: typeof-string check on opts.url matches the existing opts.unityProjectPath shape, removing an asymmetric defensive gap. simplify-pass: 1
1 parent 9b50eaa commit 64189c0

11 files changed

Lines changed: 1097 additions & 295 deletions

File tree

Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll.meta

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll.meta

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,37 @@ All notable changes to `unity-mcp-cli` will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- **`runTool` / `runSystemTool` library functions.** The HTTP-tool
13+
invocation paths previously available only as the `run-tool` /
14+
`run-system-tool` CLI commands are now first-class library exports:
15+
16+
```ts
17+
import { runTool, runSystemTool } from 'unity-mcp-cli';
18+
19+
const result = await runTool({
20+
toolName: 'tool-list',
21+
unityProjectPath: '/path/to/Unity/project',
22+
input: { regexSearch: 'gameobject', includeDescription: true },
23+
});
24+
if (result.kind === 'success') {
25+
// result.data === parsed JSON body from the Unity plugin
26+
}
27+
```
28+
29+
Same URL/token resolution priority as the CLI commands (explicit
30+
override → project config → deterministic localhost port). Returns
31+
a discriminated `{ kind: 'success' | 'failure', ... }` union; never
32+
throws past the public boundary; emits no console output.
33+
34+
### Changed
35+
36+
- **`run-tool` / `run-system-tool` commands** now delegate to the new
37+
library functions internally. CLI behaviour is unchanged.
38+
839
## [0.67.0] - 2026-04-21
940

1041
### Added

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,4 @@
7070
"typescript": "^5.8.0",
7171
"vitest": "^3.1.0"
7272
}
73-
}
73+
}
Lines changed: 13 additions & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -1,146 +1,13 @@
1-
import { Command } from 'commander';
2-
import * as ui from '../utils/ui.js';
3-
import { verbose } from '../utils/ui.js';
4-
import { resolveAndValidateProjectPath, resolveConnection } from '../utils/connection.js';
5-
import { parseInput } from '../utils/input.js';
6-
7-
interface RunSystemToolOptions {
8-
path?: string;
9-
url?: string;
10-
token?: string;
11-
input?: string;
12-
inputFile?: string;
13-
raw?: boolean;
14-
timeout?: string;
15-
}
16-
17-
export const runSystemToolCommand = new Command('run-system-tool')
18-
.description('Execute a system tool via the HTTP API (not exposed to MCP clients)')
19-
.argument('<tool-name>', 'Name of the system tool to execute')
20-
.argument('[path]', 'Unity project path (used for config and auto port detection)')
21-
.option('--path <path>', 'Unity project path (config and auto port detection)')
22-
.option('--url <url>', 'Direct server URL override (bypasses config)')
23-
.option('--token <token>', 'Bearer token override (bypasses config)')
24-
.option('--input <json>', 'JSON string of tool arguments')
25-
.option('--input-file <file>', 'Read JSON arguments from file')
26-
.option('--raw', 'Output raw JSON (no formatting)')
27-
.option('--timeout <ms>', 'Request timeout in milliseconds (default: 60000)', '60000')
28-
.action(async (toolName: string, positionalPath: string | undefined, options: RunSystemToolOptions) => {
29-
const projectPath = resolveAndValidateProjectPath(positionalPath, options);
30-
const { url: baseUrl, token } = resolveConnection(projectPath, options);
31-
const body = parseInput(options);
32-
const endpoint = `${baseUrl}/api/system-tools/${encodeURIComponent(toolName)}`;
33-
34-
verbose(`System tool: ${toolName}`);
35-
verbose(`Endpoint: ${endpoint}`);
36-
verbose(`Body: ${body}`);
37-
38-
const headers: Record<string, string> = {
39-
'Content-Type': 'application/json',
40-
};
41-
42-
if (token) {
43-
headers['Authorization'] = `Bearer ${token}`;
44-
verbose(`Authorization header set (source: ${options.token ? '--token flag' : 'config'})`);
45-
}
46-
47-
const authSource = options.token ? '--token flag' : 'config';
48-
49-
if (!options.raw) {
50-
ui.heading('Run System Tool');
51-
ui.label('Tool', toolName);
52-
ui.label('URL', endpoint);
53-
if (token) {
54-
ui.label('Auth', `from ${authSource}`);
55-
}
56-
ui.divider();
57-
}
58-
59-
const spinner = options.raw ? null : ui.startSpinner(`Calling ${toolName}...`);
60-
61-
const timeoutMs = parseInt(options.timeout ?? '60000', 10);
62-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
63-
ui.error(`Invalid --timeout value: "${options.timeout}". Must be a positive integer (milliseconds).`);
64-
process.exit(1);
65-
}
66-
const controller = new AbortController();
67-
const fetchTimeout = setTimeout(() => controller.abort(), timeoutMs);
68-
69-
try {
70-
const response = await fetch(endpoint, {
71-
method: 'POST',
72-
headers,
73-
body,
74-
signal: controller.signal,
75-
});
76-
77-
const responseText = await response.text();
78-
let responseData: unknown;
79-
try {
80-
responseData = JSON.parse(responseText);
81-
} catch {
82-
responseData = responseText;
83-
}
84-
85-
if (!response.ok) {
86-
spinner?.stop();
87-
if (options.raw) {
88-
process.stdout.write(responseText);
89-
} else {
90-
ui.error(`HTTP ${response.status}: ${response.statusText}`);
91-
if (responseData) {
92-
ui.info(typeof responseData === 'string'
93-
? responseData
94-
: JSON.stringify(responseData, null, 2));
95-
}
96-
}
97-
process.exit(1);
98-
}
99-
100-
spinner?.success(`${toolName} completed`);
101-
102-
if (options.raw) {
103-
process.stdout.write(responseText);
104-
} else {
105-
ui.success('Response:');
106-
console.log(typeof responseData === 'string'
107-
? responseData
108-
: JSON.stringify(responseData, null, 2));
109-
}
110-
} catch (err) {
111-
spinner?.stop();
112-
const isTimeout = err instanceof Error && err.name === 'AbortError';
113-
const cause = err instanceof Error && 'cause' in err ? (err.cause as Error & { code?: string }) : null;
114-
const causeCode = cause?.code ?? '';
115-
const rootMessage = cause?.message || causeCode || (err instanceof Error ? err.message : String(err));
116-
const errorSignature = `${rootMessage} ${causeCode}`;
117-
const isConnectionRefused = errorSignature.includes('ECONNREFUSED');
118-
const isConnectionReset = errorSignature.includes('ECONNRESET');
119-
const isNetworkError = errorSignature.includes('EAI_AGAIN') || errorSignature.includes('ENOTFOUND');
120-
121-
let displayMessage: string;
122-
if (isTimeout) {
123-
displayMessage = `System tool call timed out after ${timeoutMs / 1000} seconds: ${toolName}`;
124-
} else if (isConnectionRefused) {
125-
displayMessage = `Connection refused at ${endpoint}. Is the MCP server running? Start Unity Editor with the MCP plugin first.`;
126-
} else if (isConnectionReset) {
127-
displayMessage = `Connection was reset by the server at ${endpoint}. The server may have crashed or restarted.`;
128-
} else if (isNetworkError) {
129-
displayMessage = `Cannot reach ${endpoint}. Check your network connection and server URL.`;
130-
} else {
131-
displayMessage = `${rootMessage}`;
132-
if (cause && cause.message !== rootMessage) {
133-
displayMessage += ` (${cause.message})`;
134-
}
135-
}
136-
137-
if (options.raw) {
138-
process.stderr.write(displayMessage + '\n');
139-
} else {
140-
ui.error(`Failed to call system tool: ${displayMessage}`);
141-
}
142-
process.exit(1);
143-
} finally {
144-
clearTimeout(fetchTimeout);
145-
}
146-
});
1+
import { runSystemTool } from '../lib/run-tool.js';
2+
import { buildRunToolCommand } from './run-tool-builder.js';
3+
4+
export const runSystemToolCommand = buildRunToolCommand({
5+
name: 'run-system-tool',
6+
description: 'Execute a system tool via the HTTP API (not exposed to MCP clients)',
7+
argDescription: 'Name of the system tool to execute',
8+
routePrefix: '/api/system-tools',
9+
verboseLabel: 'System tool',
10+
headingLabel: 'Run System Tool',
11+
errorNoun: 'system tool',
12+
invoke: runSystemTool,
13+
});
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { Command } from 'commander';
2+
import * as ui from '../utils/ui.js';
3+
import { verbose } from '../utils/ui.js';
4+
import { resolveAndValidateProjectPath, resolveConnection } from '../utils/connection.js';
5+
import { parseInput } from '../utils/input.js';
6+
import type { RunToolFailure, RunToolOptions, RunToolResult } from '../lib/types.js';
7+
8+
interface BuilderOptions {
9+
/** CLI subcommand name, e.g. `'run-tool'`. */
10+
name: string;
11+
/** `description()` text for the commander command. */
12+
description: string;
13+
/** Description shown next to the `<tool-name>` argument. */
14+
argDescription: string;
15+
/** URL path prefix the lib function targets, e.g. `'/api/tools'`. */
16+
routePrefix: string;
17+
/** Verbose-log label, e.g. `'Tool'` or `'System tool'`. */
18+
verboseLabel: string;
19+
/** Heading printed by `ui.heading`, e.g. `'Run Tool'`. */
20+
headingLabel: string;
21+
/**
22+
* Lower-case noun used in user-facing failure copy:
23+
* `Failed to call <noun>:` and `<Capitalized> call timed out…`.
24+
*/
25+
errorNoun: string;
26+
/** Library function — `runTool` or `runSystemTool`. */
27+
invoke: (opts: RunToolOptions) => Promise<RunToolResult>;
28+
}
29+
30+
interface CliOptions {
31+
path?: string;
32+
url?: string;
33+
token?: string;
34+
input?: string;
35+
inputFile?: string;
36+
raw?: boolean;
37+
timeout?: string;
38+
}
39+
40+
interface FailureContext {
41+
toolName: string;
42+
endpoint: string;
43+
raw: boolean | undefined;
44+
timeoutMs: number;
45+
errorNoun: string;
46+
}
47+
48+
export function buildRunToolCommand(cfg: BuilderOptions): Command {
49+
return new Command(cfg.name)
50+
.description(cfg.description)
51+
.argument('<tool-name>', cfg.argDescription)
52+
.argument('[path]', 'Unity project path (used for config and auto port detection)')
53+
.option('--path <path>', 'Unity project path (config and auto port detection)')
54+
.option('--url <url>', 'Direct server URL override (bypasses config)')
55+
.option('--token <token>', 'Bearer token override (bypasses config)')
56+
.option('--input <json>', 'JSON string of tool arguments')
57+
.option('--input-file <file>', 'Read JSON arguments from file')
58+
.option('--raw', 'Output raw JSON (no formatting)')
59+
.option('--timeout <ms>', 'Request timeout in milliseconds (default: 60000)', '60000')
60+
.action(async (toolName: string, positionalPath: string | undefined, options: CliOptions) => {
61+
// Validate --timeout first so a bad value short-circuits before
62+
// we read --input-file or hit the network.
63+
const timeoutMs = parseInt(options.timeout!, 10);
64+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
65+
ui.error(`Invalid --timeout value: "${options.timeout}". Must be a positive integer (milliseconds).`);
66+
process.exit(1);
67+
}
68+
69+
// Resolve path + connection up front so the heading and verbose
70+
// output reflect the final endpoint before the HTTP call fires.
71+
const projectPath = resolveAndValidateProjectPath(positionalPath, options);
72+
const { url: baseUrl, token } = resolveConnection(projectPath, options);
73+
const body = parseInput(options);
74+
const endpoint = `${baseUrl}${cfg.routePrefix}/${encodeURIComponent(toolName)}`;
75+
const authSource = options.token ? '--token flag' : 'config';
76+
77+
verbose(`${cfg.verboseLabel}: ${toolName}`);
78+
verbose(`Endpoint: ${endpoint}`);
79+
verbose(`Body: ${body}`);
80+
if (token) verbose(`Authorization header set (source: ${authSource})`);
81+
82+
if (!options.raw) {
83+
ui.heading(cfg.headingLabel);
84+
ui.label('Tool', toolName);
85+
ui.label('URL', endpoint);
86+
if (token) ui.label('Auth', `from ${authSource}`);
87+
ui.divider();
88+
}
89+
90+
const spinner = options.raw ? null : ui.startSpinner(`Calling ${toolName}...`);
91+
92+
// The CLI already resolved url/token, so passing them explicitly
93+
// makes the lib's resolver a no-op rather than re-reading config.
94+
const result = await cfg.invoke({
95+
toolName,
96+
url: baseUrl,
97+
...(token ? { token } : {}),
98+
input: body,
99+
timeoutMs,
100+
});
101+
102+
if (result.kind === 'success') {
103+
spinner?.success(`${toolName} completed`);
104+
if (options.raw) {
105+
process.stdout.write(stringifyForRaw(result.data));
106+
} else {
107+
ui.success('Response:');
108+
console.log(typeof result.data === 'string'
109+
? result.data
110+
: JSON.stringify(result.data, null, 2));
111+
}
112+
return;
113+
}
114+
115+
spinner?.stop();
116+
handleFailure(result, {
117+
toolName,
118+
endpoint,
119+
raw: options.raw,
120+
timeoutMs,
121+
errorNoun: cfg.errorNoun,
122+
});
123+
});
124+
}
125+
126+
function stringifyForRaw(data: unknown): string {
127+
if (typeof data === 'string') return data;
128+
if (data === undefined) return '';
129+
return JSON.stringify(data);
130+
}
131+
132+
function handleFailure(failure: RunToolFailure, ctx: FailureContext): never {
133+
if (failure.reason === 'http-error') {
134+
if (ctx.raw) {
135+
process.stdout.write(stringifyForRaw(failure.data));
136+
} else {
137+
ui.error(`HTTP ${failure.httpStatus}: ${failure.message}`);
138+
if (failure.data !== undefined) {
139+
ui.info(typeof failure.data === 'string'
140+
? failure.data
141+
: JSON.stringify(failure.data, null, 2));
142+
}
143+
}
144+
process.exit(1);
145+
}
146+
147+
const message = buildFailureMessage(failure, ctx);
148+
if (ctx.raw) process.stderr.write(message + '\n');
149+
else ui.error(`Failed to call ${ctx.errorNoun}: ${message}`);
150+
process.exit(1);
151+
}
152+
153+
function buildFailureMessage(failure: RunToolFailure, ctx: FailureContext): string {
154+
switch (failure.reason) {
155+
case 'timeout':
156+
return `${capitalize(ctx.errorNoun)} call timed out after ${ctx.timeoutMs / 1000} seconds: ${ctx.toolName}`;
157+
case 'connection-refused':
158+
return `Connection refused at ${ctx.endpoint}. Is the MCP server running? Start Unity Editor with the MCP plugin first.`;
159+
case 'connection-reset':
160+
return `Connection was reset by the server at ${ctx.endpoint}. The server may have crashed or restarted.`;
161+
case 'network-error':
162+
return `Cannot reach ${ctx.endpoint}. Check your network connection and server URL.`;
163+
default:
164+
return failure.message;
165+
}
166+
}
167+
168+
function capitalize(s: string): string {
169+
return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
170+
}

0 commit comments

Comments
 (0)