feat(cli): expose runTool / runSystemTool as library functions#744
Merged
IvanMurzak merged 7 commits intomainfrom May 10, 2026
Merged
feat(cli): expose runTool / runSystemTool as library functions#744IvanMurzak merged 7 commits intomainfrom
IvanMurzak merged 7 commits intomainfrom
Conversation
- 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.
…sions - Deleted obsolete DLLs: System.Runtime.CompilerServices.Unsafe.6.1.2.dll, System.Text.Encoding.CodePages.7.0.0.dll, System.Text.Encodings.Web.8.0.0.dll, System.Text.Json.8.0.5.dll, System.Threading.Channels.8.0.0.dll, System.Threading.Tasks.Extensions.4.5.4.dll. - Added updated DLLs: System.Runtime.CompilerServices.Unsafe.dll, System.Text.Encoding.CodePages.dll, System.Text.Encodings.Web.dll, System.Text.Json.dll, System.Threading.Channels.dll, System.Threading.Tasks.Extensions.dll. - Updated corresponding .meta files for new DLLs.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR promotes the existing run-tool / run-system-tool CLI functionality into the unity-mcp-cli library API by introducing runTool() and runSystemTool() exports, then refactors the CLI commands to delegate the HTTP invocation to those new library functions.
Changes:
- Added
runTool/runSystemToollibrary functions with structured{ kind: 'success' | 'failure' }results and a comprehensive new test suite. - Refactored
unity-mcp-cli run-toolandrun-system-toolcommands to call the new library functions instead of implementing fetch logic inline. - Updated CLI changelog to document the new library surface area.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.dll.meta | Updates the Unity asset GUID for the test DLL plugin import metadata. |
| Unity-Tests/6000.6.0a2/Assets/Plugins/NuGet/McpPlugin.Common.dll.meta | Updates the Unity asset GUID for the test DLL plugin import metadata. |
| cli/tests/lib-run-tool.test.ts | Adds Vitest coverage for runTool / runSystemTool success paths, validation, transport failures, and connection resolution. |
| cli/src/lib/types.ts | Introduces new public types for run-tool results/options and failure reasons. |
| cli/src/lib/run-tool.ts | Adds library-safe implementations of runTool / runSystemTool, including URL/token resolution and error classification. |
| cli/src/lib.ts | Exposes runTool / runSystemTool and their types as top-level library exports. |
| cli/src/commands/run-tool.ts | Refactors the CLI command to delegate to runTool and maps structured failures back to CLI output/exit behavior. |
| cli/src/commands/run-system-tool.ts | Refactors the CLI command to delegate to runSystemTool and maps structured failures back to CLI output/exit behavior. |
| cli/package.json | Fixes JSON formatting (no functional change). |
| cli/CHANGELOG.md | Documents the new runTool / runSystemTool library exports and the internal CLI refactor. |
Comment on lines
+134
to
+140
| if ( | ||
| (opts.url === undefined || opts.url.length === 0) && | ||
| (typeof opts.unityProjectPath !== 'string' || opts.unityProjectPath.trim().length === 0) | ||
| ) { | ||
| return makeFailure({ | ||
| endpoint: '', | ||
| reason: 'invalid-input', |
| ): { kind: 'success'; url: string; token: string | undefined } | RunToolFailure { | ||
| if (opts.url) { | ||
| const url = opts.url.replace(/\/$/, ''); | ||
| return { kind: 'success', url, token: opts.token }; |
Comment on lines
+175
to
+179
| const config = exists ? readConfig(projectPath) : null; | ||
| const fromConfig = config | ||
| ? resolveConnectionFromConfig(config) | ||
| : { url: undefined, token: undefined }; | ||
|
|
Comment on lines
+155
to
+167
| // Validate the project path. Library-safe variant of the CLI's | ||
| // `resolveAndValidateProjectPath` — returns a structured failure | ||
| // instead of calling `process.exit` when the path is missing. | ||
| 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; |
Comment on lines
+466
to
+467
| * the body is `{}`. Anything other than `undefined` / `null` / | ||
| * `object` is rejected with a `kind: 'failure'` result. |
Comment on lines
+77
to
+99
| if (result.kind === 'success') { | ||
| spinner?.success(`${toolName} completed`); | ||
|
|
||
| const responseText = stringifyForRaw(result.data); | ||
| if (options.raw) { | ||
| process.stdout.write(responseText); | ||
| } else { | ||
| ui.success('Response:'); | ||
| console.log(typeof responseData === 'string' | ||
| ? responseData | ||
| : JSON.stringify(responseData, null, 2)); | ||
| console.log(typeof result.data === 'string' | ||
| ? result.data | ||
| : JSON.stringify(result.data, 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'); | ||
| return; | ||
| } | ||
|
|
||
| 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})`; | ||
| } | ||
| } | ||
| spinner?.stop(); | ||
| handleFailure(result, { toolName, endpoint, raw: options.raw, timeoutMs }); | ||
| }); | ||
|
|
||
| if (options.raw) { | ||
| process.stderr.write(displayMessage + '\n'); | ||
| function stringifyForRaw(data: unknown): string { | ||
| if (typeof data === 'string') return data; | ||
| if (data === undefined) return ''; | ||
| return JSON.stringify(data); | ||
| } |
Comment on lines
102
to
114
| function handleFailure(failure: RunToolFailure, ctx: FailureContext): never { | ||
| switch (failure.reason) { | ||
| case 'http-error': { | ||
| if (ctx.raw) { | ||
| process.stdout.write(stringifyForRaw(failure.data)); | ||
| } else { | ||
| ui.error(`Failed to call system tool: ${displayMessage}`); | ||
| 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)); | ||
| } | ||
| } |
Comment on lines
+71
to
+93
| if (result.kind === 'success') { | ||
| spinner?.success(`${toolName} completed`); | ||
|
|
||
| const responseText = stringifyForRaw(result.data); | ||
| if (options.raw) { | ||
| process.stdout.write(responseText); | ||
| } else { | ||
| ui.success('Response:'); | ||
| console.log(typeof responseData === 'string' | ||
| ? responseData | ||
| : JSON.stringify(responseData, null, 2)); | ||
| console.log(typeof result.data === 'string' | ||
| ? result.data | ||
| : JSON.stringify(result.data, 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'); | ||
| return; | ||
| } | ||
|
|
||
| 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})`; | ||
| } | ||
| } | ||
| spinner?.stop(); | ||
| handleFailure(result, { toolName, endpoint, raw: options.raw, timeoutMs }); | ||
| }); | ||
|
|
||
| if (options.raw) { | ||
| process.stderr.write(displayMessage + '\n'); | ||
| function stringifyForRaw(data: unknown): string { | ||
| if (typeof data === 'string') return data; | ||
| if (data === undefined) return ''; | ||
| return JSON.stringify(data); | ||
| } |
Comment on lines
1
to
3
| fileFormatVersion: 2 | ||
| guid: 8ca0a15d310a5f843b0937012d11f4ea | ||
| guid: f40aae41f95ca764c8729414e1abe315 | ||
| PluginImporter: |
Comment on lines
1
to
3
| fileFormatVersion: 2 | ||
| guid: f2b17cb86ebbab0498de7a05697c50c4 | ||
| guid: c061112e81f53a24fb5437ec4fe122c5 | ||
| PluginImporter: |
Contributor
Test Results 12 files 546 suites 43m 58s ⏱️ Results for commit 2981e57. ♻️ This comment has been updated with latest results. |
Comment on lines
+133
to
+141
| if ( | ||
| (opts.url === undefined || opts.url.length === 0) && | ||
| (typeof opts.unityProjectPath !== 'string' || opts.unityProjectPath.trim().length === 0) | ||
| ) { | ||
| return makeFailure({ | ||
| endpoint: '', | ||
| reason: 'invalid-input', | ||
| message: 'Either unityProjectPath or url must be provided.', | ||
| }); |
| const validationFailure = validateOptions(opts); | ||
| if (validationFailure) return validationFailure; | ||
|
|
||
| const resolved = resolveConnection(opts); |
Comment on lines
+466
to
+467
| * the body is `{}`. Anything other than `undefined` / `null` / | ||
| * `object` is rejected with a `kind: 'failure'` result. |
Comment on lines
+246
to
+253
| 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'; | ||
|
|
- 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
| } | ||
| const projectPath = validated.projectPath; | ||
|
|
||
| const config = readConfig(projectPath); |
Comment on lines
+102
to
+106
| if (result.kind === 'success') { | ||
| spinner?.success(`${toolName} completed`); | ||
| if (options.raw) { | ||
| process.stdout.write(stringifyForRaw(result.data)); | ||
| } else { |
Comment on lines
+132
to
+136
| 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
+466
to
+467
| * the body is `{}`. Anything other than `undefined` / `null` / | ||
| * `object` is rejected with a `kind: 'failure'` result. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
runToolandrunSystemToollibrary functions that mirror the existing CLI commands (unity-mcp-cli run-tool,unity-mcp-cli run-system-tool).{ kind: 'success' | 'failure', ... }results, no console output past the lib boundary, no thrown exceptions.Test plan
npm test -- lib-run-tool— 23 cases covering happy path, validation failures, HTTP error classes, timeouts, connection refused, invalid JSON, plus regression coverage of the connection-resolution layering.npm test— full suite (370 cases) green after merging main.unity-mcp-cli run-tool tool-list --unity-project-path …against a running Unity editor matches the pre-refactor output.🤖 Generated with Claude Code