From eef3f76af353400c0bc58a5fc09e8e92eee4e13b Mon Sep 17 00:00:00 2001 From: Indrajeet Haldar Date: Sun, 10 May 2026 20:05:12 -0400 Subject: [PATCH 1/2] Add Amp plugin for Unity MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .amp/plugins/unity-mcp.ts — a thin Amp plugin that exposes a single `unity` tool. The tool POSTs to the existing Python MCP server's /api/command REST endpoint, so the Python side keeps the full tool catalog and Amp only sees one tool definition (low token cost). - Single tool: { tool, params, unity_instance? } - Palette command: 'Unity: Status' (hits /api/instances) - Configurable via UNITY_MCP_SERVER_URL and UNITY_MCP_TIMEOUT_MS - session.start logs whether a Unity instance is connected Amp-Thread-ID: https://ampcode.com/threads/T-019e144f-2307-731e-b3d4-d3bcd4b298b3 Co-authored-by: Amp --- .amp/plugins/unity-mcp.ts | 141 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 .amp/plugins/unity-mcp.ts diff --git a/.amp/plugins/unity-mcp.ts b/.amp/plugins/unity-mcp.ts new file mode 100644 index 000000000..d6644274f --- /dev/null +++ b/.amp/plugins/unity-mcp.ts @@ -0,0 +1,141 @@ +/** + * Unity MCP — Amp plugin + * + * Exposes the Unity Editor (via the MCP for Unity Python server's REST endpoint) + * to Amp through a single `unity` tool. The Python server retains every tool + * implementation and dispatches to Unity over WebSocket; this plugin is a thin + * proxy so Amp sees one tool definition (cheap on tokens) instead of 38+. + * + * Requires the MCP for Unity Python server running locally + * (default http://127.0.0.1:8080) and the Unity Editor connected to it. + * + * Configuration: + * UNITY_MCP_SERVER_URL Override base URL (default http://127.0.0.1:8080) + * UNITY_MCP_TIMEOUT_MS Per-call timeout in ms (default 120000) + */ + +import type { PluginAPI } from '@ampcode/plugin' + +const BASE_URL = (process.env.UNITY_MCP_SERVER_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '') +const TIMEOUT_MS = Number(process.env.UNITY_MCP_TIMEOUT_MS ?? 120_000) + +type CommandBody = { + type: string + params: Record + unity_instance?: string +} + +async function postCommand(body: CommandBody): Promise { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS) + try { + const res = await fetch(`${BASE_URL}/api/command`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: ctrl.signal, + }) + const text = await res.text() + if (!res.ok) { + // Pass through the server's structured error so the model can read it. + return text || JSON.stringify({ success: false, error: `HTTP ${res.status}` }) + } + return text + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return JSON.stringify({ + success: false, + error: `Unity MCP server unreachable at ${BASE_URL}: ${msg}. Is the Python server running and is Unity open with the MCP for Unity package?`, + }) + } finally { + clearTimeout(timer) + } +} + +async function listInstances(): Promise { + try { + const res = await fetch(`${BASE_URL}/api/instances`, { signal: AbortSignal.timeout(5_000) }) + return await res.text() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return JSON.stringify({ success: false, error: msg }) + } +} + +export default function (amp: PluginAPI) { + amp.registerTool({ + name: 'unity', + description: [ + 'Call any MCP for Unity tool through the local Python MCP server.', + 'Use this for every Unity Editor automation: GameObjects, scripts, scenes, assets, prefabs, components, build, tests, console, screenshots, etc.', + '', + 'Common `tool` values (full list in the unity-mcp-orchestrator skill):', + ' • Reads: find_gameobjects, find_in_file, read_console, unity_reflect, unity_docs, preflight', + ' • GameObj: manage_gameobject, manage_components, manage_prefabs, manage_scene', + ' • Scripts: manage_script, script_apply_edits, refresh_unity, execute_code', + ' • Assets: manage_asset, manage_material, manage_texture, manage_shader, manage_packages', + ' • Editor: manage_editor, execute_menu_item, manage_camera, set_active_instance', + ' • Misc: batch_execute, run_tests, manage_build, manage_animation, manage_ui, manage_vfx', + '', + 'Resources (read-only state) live under mcpforunity://… and are fetched by name through the same Python server (use the relevant manage_* tool or read_console for reads).', + 'Prefer `batch_execute` when issuing 3+ related calls — it is 10–100× faster.', + 'After scripts change, poll editor state for `is_compiling=false` then call read_console for errors.', + '`params` is the exact parameter object the underlying tool expects. If unsure of the shape, load the unity-mcp-orchestrator skill or call `unity` with `tool="preflight"` first.', + ].join('\n'), + inputSchema: { + type: 'object', + properties: { + tool: { + type: 'string', + description: 'Underlying Unity MCP tool name, e.g. "manage_gameobject", "find_gameobjects", "read_console".', + }, + params: { + type: 'object', + description: 'Parameter object for the tool. Shape depends on the tool.', + additionalProperties: true, + }, + unity_instance: { + type: 'string', + description: 'Optional Unity instance name or hash when multiple Unity Editors are connected. Omit to use the first available instance.', + }, + }, + required: ['tool'], + }, + async execute(input) { + const tool = String(input.tool ?? '').trim() + if (!tool) { + return JSON.stringify({ success: false, error: 'Missing required field: tool' }) + } + const params = (input.params && typeof input.params === 'object' ? input.params : {}) as Record + const body: CommandBody = { type: tool, params } + if (typeof input.unity_instance === 'string' && input.unity_instance.length > 0) { + body.unity_instance = input.unity_instance + } + return postCommand(body) + }, + }) + + amp.registerCommand( + 'status', + { title: 'Status', category: 'Unity', description: 'Show connected Unity instances and server reachability' }, + async (ctx) => { + const text = await listInstances() + await ctx.ui.notify(`Unity MCP @ ${BASE_URL}\n${text}`) + }, + ) + + amp.on('session.start', async (_event, ctx) => { + try { + const res = await fetch(`${BASE_URL}/api/instances`, { signal: AbortSignal.timeout(1_500) }) + if (!res.ok) { + ctx.logger.log(`Unity MCP server reachable but /api/instances returned HTTP ${res.status}`) + return + } + const data = (await res.json()) as { instances?: unknown[] } + const count = Array.isArray(data.instances) ? data.instances.length : 0 + ctx.logger.log(`Unity MCP plugin loaded — ${count} Unity instance(s) connected at ${BASE_URL}`) + } catch { + ctx.logger.log(`Unity MCP plugin loaded — server not reachable at ${BASE_URL} (will retry per-call)`) + } + }) +} From b19fc6035de81a2d95f43881ca132376fd496412 Mon Sep 17 00:00:00 2001 From: Indrajeet Haldar Date: Sun, 10 May 2026 20:15:27 -0400 Subject: [PATCH 2/2] Address PR review: timeout hardening, JSDoc, unit tests, CI - Harden UNITY_MCP_TIMEOUT_MS parsing: clamp NaN/non-numeric/non-positive/ non-finite values to the 120s default. Without this, Number(undefined) yields NaN and AbortController fires immediately, aborting every request before the network is touched. - Add JSDoc/TSDoc on every exported symbol and the plugin entry point to satisfy the 80% docstring coverage check. - Refactor: extract pure helpers (resolveTimeoutMs, normalizeBaseUrl, buildCommandBody, formatUnreachableError, postCommand, listInstances) with an injectable FetchLike type so they can be unit-tested without a live network. - Add 28 unit tests in .amp/plugins/__tests__/unity-mcp.test.ts covering timeout parsing edge cases, URL normalization, body construction, unreachable-error formatting, and postCommand/listInstances behavior (success, non-2xx with body, non-2xx without body, network failure, timeout abort). - Add .github/workflows/amp-plugin-tests.yml to run 'bun test' on every push/PR that touches .amp/plugins/**. Amp-Thread-ID: https://ampcode.com/threads/T-019e144f-2307-731e-b3d4-d3bcd4b298b3 Co-authored-by: Amp --- .amp/plugins/__tests__/unity-mcp.test.ts | 274 +++++++++++++++++++++++ .amp/plugins/unity-mcp.ts | 198 ++++++++++++---- .github/workflows/amp-plugin-tests.yml | 30 +++ 3 files changed, 460 insertions(+), 42 deletions(-) create mode 100644 .amp/plugins/__tests__/unity-mcp.test.ts create mode 100644 .github/workflows/amp-plugin-tests.yml diff --git a/.amp/plugins/__tests__/unity-mcp.test.ts b/.amp/plugins/__tests__/unity-mcp.test.ts new file mode 100644 index 000000000..02c503b66 --- /dev/null +++ b/.amp/plugins/__tests__/unity-mcp.test.ts @@ -0,0 +1,274 @@ +/** + * Unit tests for the Unity MCP Amp plugin. + * + * Run with: + * bun test .amp/plugins/__tests__/unity-mcp.test.ts + * + * Covers the pure helpers (timeout/url parsing, body building, error + * formatting) and the network-facing helpers (postCommand, listInstances) + * with an injected fetch stub. + */ + +import { describe, expect, test } from 'bun:test' + +import { + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT_MS, + type CommandBody, + type FetchLike, + buildCommandBody, + formatUnreachableError, + listInstances, + normalizeBaseUrl, + postCommand, + resolveTimeoutMs, +} from '../unity-mcp' + +// --------------------------------------------------------------------------- +// resolveTimeoutMs — guards against the "NaN -> immediate abort" bug +// --------------------------------------------------------------------------- + +describe('resolveTimeoutMs', () => { + test('returns default when env var is undefined', () => { + expect(resolveTimeoutMs(undefined)).toBe(DEFAULT_TIMEOUT_MS) + }) + + test('returns default when env var is empty string', () => { + expect(resolveTimeoutMs('')).toBe(DEFAULT_TIMEOUT_MS) + }) + + test('returns default when env var is non-numeric', () => { + expect(resolveTimeoutMs('abc')).toBe(DEFAULT_TIMEOUT_MS) + }) + + test('returns default when env var parses to NaN', () => { + expect(resolveTimeoutMs('not-a-number')).toBe(DEFAULT_TIMEOUT_MS) + }) + + test('returns default when env var is zero', () => { + expect(resolveTimeoutMs('0')).toBe(DEFAULT_TIMEOUT_MS) + }) + + test('returns default when env var is negative', () => { + expect(resolveTimeoutMs('-100')).toBe(DEFAULT_TIMEOUT_MS) + }) + + test('returns default when env var is Infinity', () => { + expect(resolveTimeoutMs('Infinity')).toBe(DEFAULT_TIMEOUT_MS) + }) + + test('accepts a positive integer string', () => { + expect(resolveTimeoutMs('5000')).toBe(5000) + }) + + test('accepts a positive float string', () => { + expect(resolveTimeoutMs('1500.5')).toBe(1500.5) + }) + + test('respects a custom default', () => { + expect(resolveTimeoutMs(undefined, 999)).toBe(999) + expect(resolveTimeoutMs('bad', 999)).toBe(999) + }) +}) + +// --------------------------------------------------------------------------- +// normalizeBaseUrl +// --------------------------------------------------------------------------- + +describe('normalizeBaseUrl', () => { + test('leaves a clean URL untouched', () => { + expect(normalizeBaseUrl('http://127.0.0.1:8080')).toBe('http://127.0.0.1:8080') + }) + + test('strips a single trailing slash', () => { + expect(normalizeBaseUrl('http://127.0.0.1:8080/')).toBe('http://127.0.0.1:8080') + }) + + test('strips multiple trailing slashes', () => { + expect(normalizeBaseUrl('http://127.0.0.1:8080///')).toBe('http://127.0.0.1:8080') + }) + + test('matches the documented default', () => { + expect(DEFAULT_BASE_URL).toBe('http://127.0.0.1:8080') + }) +}) + +// --------------------------------------------------------------------------- +// buildCommandBody +// --------------------------------------------------------------------------- + +describe('buildCommandBody', () => { + test('builds the minimum valid body', () => { + expect(buildCommandBody({ tool: 'find_gameobjects' })).toEqual({ + type: 'find_gameobjects', + params: {}, + }) + }) + + test('passes params through unchanged', () => { + const params = { name: 'Main Camera', includeInactive: true } + expect(buildCommandBody({ tool: 'find_gameobjects', params })).toEqual({ + type: 'find_gameobjects', + params, + }) + }) + + test('coerces missing/non-object params to {}', () => { + expect(buildCommandBody({ tool: 'x', params: undefined }).params).toEqual({}) + expect(buildCommandBody({ tool: 'x', params: null }).params).toEqual({}) + expect(buildCommandBody({ tool: 'x', params: 'string' }).params).toEqual({}) + expect(buildCommandBody({ tool: 'x', params: 42 }).params).toEqual({}) + expect(buildCommandBody({ tool: 'x', params: [1, 2, 3] }).params).toEqual({}) + }) + + test('includes unity_instance only when non-empty', () => { + expect(buildCommandBody({ tool: 'x' }).unity_instance).toBeUndefined() + expect(buildCommandBody({ tool: 'x', unity_instance: '' }).unity_instance).toBeUndefined() + expect(buildCommandBody({ tool: 'x', unity_instance: 'MyProject' }).unity_instance).toBe('MyProject') + }) + + test('ignores non-string unity_instance values', () => { + expect(buildCommandBody({ tool: 'x', unity_instance: 123 }).unity_instance).toBeUndefined() + expect(buildCommandBody({ tool: 'x', unity_instance: null }).unity_instance).toBeUndefined() + }) + + test('trims tool name and rejects empty', () => { + expect(buildCommandBody({ tool: ' find_gameobjects ' }).type).toBe('find_gameobjects') + expect(() => buildCommandBody({})).toThrow(/Missing required field: tool/) + expect(() => buildCommandBody({ tool: ' ' })).toThrow(/Missing required field: tool/) + expect(() => buildCommandBody({ tool: '' })).toThrow(/Missing required field: tool/) + }) +}) + +// --------------------------------------------------------------------------- +// formatUnreachableError +// --------------------------------------------------------------------------- + +describe('formatUnreachableError', () => { + test('returns parseable JSON with success=false', () => { + const out = formatUnreachableError('http://127.0.0.1:8080', 'connection refused') + const parsed = JSON.parse(out) + expect(parsed.success).toBe(false) + expect(parsed.error).toContain('http://127.0.0.1:8080') + expect(parsed.error).toContain('connection refused') + expect(parsed.error).toContain('Python server') + }) +}) + +// --------------------------------------------------------------------------- +// postCommand — fetch stub, no real network +// --------------------------------------------------------------------------- + +/** Build a minimal Response-like object the helpers can consume. */ +function fakeResponse(opts: { ok?: boolean; status?: number; body: string }) { + return { + ok: opts.ok ?? true, + status: opts.status ?? 200, + text: async () => opts.body, + json: async () => JSON.parse(opts.body), + } +} + +describe('postCommand', () => { + const baseBody: CommandBody = { type: 'find_gameobjects', params: { name: 'X' } } + + test('passes through 2xx response body verbatim', async () => { + const successPayload = JSON.stringify({ success: true, data: [{ id: 1 }] }) + let capturedUrl = '' + let capturedInit: { method?: string; headers?: Record; body?: string } | undefined + const fetchFn: FetchLike = async (url, init) => { + capturedUrl = url + capturedInit = init + return fakeResponse({ body: successPayload }) + } + + const out = await postCommand(fetchFn, 'http://127.0.0.1:8080', 5_000, baseBody) + + expect(out).toBe(successPayload) + expect(capturedUrl).toBe('http://127.0.0.1:8080/api/command') + expect(capturedInit?.method).toBe('POST') + expect(capturedInit?.headers?.['content-type']).toBe('application/json') + expect(JSON.parse(capturedInit!.body!)).toEqual(baseBody) + }) + + test('returns server body for non-2xx responses (preserving structured error)', async () => { + const errPayload = JSON.stringify({ success: false, error: 'No Unity instances connected' }) + const fetchFn: FetchLike = async () => fakeResponse({ ok: false, status: 503, body: errPayload }) + + const out = await postCommand(fetchFn, 'http://127.0.0.1:8080', 5_000, baseBody) + expect(out).toBe(errPayload) + }) + + test('synthesizes an error JSON when non-2xx body is empty', async () => { + const fetchFn: FetchLike = async () => fakeResponse({ ok: false, status: 500, body: '' }) + + const out = await postCommand(fetchFn, 'http://127.0.0.1:8080', 5_000, baseBody) + const parsed = JSON.parse(out) + expect(parsed.success).toBe(false) + expect(parsed.error).toBe('HTTP 500') + }) + + test('returns formatted unreachable error on fetch rejection', async () => { + const fetchFn: FetchLike = async () => { + throw new Error('ECONNREFUSED') + } + + const out = await postCommand(fetchFn, 'http://127.0.0.1:8080', 5_000, baseBody) + const parsed = JSON.parse(out) + expect(parsed.success).toBe(false) + expect(parsed.error).toContain('ECONNREFUSED') + expect(parsed.error).toContain('http://127.0.0.1:8080') + }) + + test('aborts after the configured timeout', async () => { + const fetchFn: FetchLike = (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + const err = new Error('aborted') + err.name = 'AbortError' + reject(err) + }) + }) + + const start = Date.now() + const out = await postCommand(fetchFn, 'http://127.0.0.1:8080', 50, baseBody) + const elapsed = Date.now() - start + + const parsed = JSON.parse(out) + expect(parsed.success).toBe(false) + // Should abort close to the 50ms deadline, never instantly (the bug + // being guarded against would abort at t≈0). + expect(elapsed).toBeGreaterThanOrEqual(40) + expect(elapsed).toBeLessThan(2_000) + }) +}) + +// --------------------------------------------------------------------------- +// listInstances +// --------------------------------------------------------------------------- + +describe('listInstances', () => { + test('returns server body verbatim on success', async () => { + const payload = JSON.stringify({ success: true, instances: [{ project: 'Demo' }] }) + let capturedUrl = '' + const fetchFn: FetchLike = async (url) => { + capturedUrl = url + return fakeResponse({ body: payload }) + } + + const out = await listInstances(fetchFn, 'http://127.0.0.1:8080') + expect(out).toBe(payload) + expect(capturedUrl).toBe('http://127.0.0.1:8080/api/instances') + }) + + test('returns JSON error string on fetch rejection', async () => { + const fetchFn: FetchLike = async () => { + throw new Error('boom') + } + + const out = await listInstances(fetchFn, 'http://127.0.0.1:8080') + const parsed = JSON.parse(out) + expect(parsed.success).toBe(false) + expect(parsed.error).toBe('boom') + }) +}) diff --git a/.amp/plugins/unity-mcp.ts b/.amp/plugins/unity-mcp.ts index d6644274f..202b327bd 100644 --- a/.amp/plugins/unity-mcp.ts +++ b/.amp/plugins/unity-mcp.ts @@ -11,25 +11,117 @@ * * Configuration: * UNITY_MCP_SERVER_URL Override base URL (default http://127.0.0.1:8080) - * UNITY_MCP_TIMEOUT_MS Per-call timeout in ms (default 120000) + * UNITY_MCP_TIMEOUT_MS Per-call timeout in ms, must be a positive number + * (default 120000; falls back to default if missing, + * non-numeric, NaN, infinite, or <= 0) */ import type { PluginAPI } from '@ampcode/plugin' -const BASE_URL = (process.env.UNITY_MCP_SERVER_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '') -const TIMEOUT_MS = Number(process.env.UNITY_MCP_TIMEOUT_MS ?? 120_000) +/** Default base URL for the local MCP for Unity Python server. */ +export const DEFAULT_BASE_URL = 'http://127.0.0.1:8080' -type CommandBody = { +/** Default per-call timeout for proxied Unity commands, in milliseconds. */ +export const DEFAULT_TIMEOUT_MS = 120_000 + +/** Timeout for the lightweight `/api/instances` reachability probes, in milliseconds. */ +export const INSTANCES_PROBE_TIMEOUT_MS = 5_000 + +/** Timeout for the optional `session.start` reachability probe, in milliseconds. */ +export const SESSION_START_PROBE_TIMEOUT_MS = 1_500 + +/** + * Body shape sent to the Python server's `/api/command` endpoint, mirroring + * the request the existing `unity-mcp` CLI issues. + */ +export type CommandBody = { type: string params: Record unity_instance?: string } -async function postCommand(body: CommandBody): Promise { +/** + * Minimal `fetch`-shaped function we depend on. Defined explicitly so tests can + * supply a stub without relying on global mocking. + */ +export type FetchLike = ( + input: string, + init?: { method?: string; headers?: Record; body?: string; signal?: AbortSignal }, +) => Promise<{ ok: boolean; status: number; text(): Promise; json(): Promise }> + +/** + * Strip a trailing slash (or run of slashes) so callers can append a path + * without producing `//api/command`. + */ +export function normalizeBaseUrl(raw: string): string { + return raw.replace(/\/+$/, '') +} + +/** + * Resolve the per-call timeout from an environment variable value, clamping + * missing / non-numeric / non-positive / non-finite values to a sane default. + * + * Without this, `Number(undefined)` yields `NaN`, and an `AbortController` + * scheduled with `setTimeout(_, NaN)` fires immediately, aborting every + * request before the network even touches the wire. + */ +export function resolveTimeoutMs(raw: string | undefined, defaultMs: number = DEFAULT_TIMEOUT_MS): number { + if (raw === undefined || raw === '') return defaultMs + const parsed = Number(raw) + return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultMs +} + +/** + * Build the JSON body for `/api/command` from the agent-supplied tool input. + * Only includes `unity_instance` when the caller passed a non-empty string. + */ +export function buildCommandBody(input: Record): CommandBody { + const tool = String(input.tool ?? '').trim() + if (!tool) { + throw new Error('Missing required field: tool') + } + const rawParams = input.params + const params = + rawParams && typeof rawParams === 'object' && !Array.isArray(rawParams) + ? (rawParams as Record) + : {} + const body: CommandBody = { type: tool, params } + if (typeof input.unity_instance === 'string' && input.unity_instance.length > 0) { + body.unity_instance = input.unity_instance + } + return body +} + +/** + * Format a structured failure response that mirrors the Python server's + * `{ success: false, error: ... }` shape so the agent can parse either case + * with the same code path. + */ +export function formatUnreachableError(baseUrl: string, message: string): string { + return JSON.stringify({ + success: false, + error: `Unity MCP server unreachable at ${baseUrl}: ${message}. Is the Python server running and is Unity open with the MCP for Unity package?`, + }) +} + +/** + * POST a Unity command to the Python server and return the raw response body + * verbatim so the calling agent sees the server's structured success/error + * shape unchanged. + * + * Network failures and non-2xx responses are normalized to a JSON string with + * `{ success: false, error: ... }` so callers never receive a thrown error. + */ +export async function postCommand( + fetchFn: FetchLike, + baseUrl: string, + timeoutMs: number, + body: CommandBody, +): Promise { const ctrl = new AbortController() - const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS) + const timer = setTimeout(() => ctrl.abort(), timeoutMs) try { - const res = await fetch(`${BASE_URL}/api/command`, { + const res = await fetchFn(`${baseUrl}/api/command`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), @@ -37,51 +129,73 @@ async function postCommand(body: CommandBody): Promise { }) const text = await res.text() if (!res.ok) { - // Pass through the server's structured error so the model can read it. return text || JSON.stringify({ success: false, error: `HTTP ${res.status}` }) } return text } catch (err) { const msg = err instanceof Error ? err.message : String(err) - return JSON.stringify({ - success: false, - error: `Unity MCP server unreachable at ${BASE_URL}: ${msg}. Is the Python server running and is Unity open with the MCP for Unity package?`, - }) + return formatUnreachableError(baseUrl, msg) } finally { clearTimeout(timer) } } -async function listInstances(): Promise { +/** + * GET the current list of connected Unity instances from the Python server. + * Returns the raw response body, or a JSON error string on failure. + */ +export async function listInstances( + fetchFn: FetchLike, + baseUrl: string, + timeoutMs: number = INSTANCES_PROBE_TIMEOUT_MS, +): Promise { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), timeoutMs) try { - const res = await fetch(`${BASE_URL}/api/instances`, { signal: AbortSignal.timeout(5_000) }) + const res = await fetchFn(`${baseUrl}/api/instances`, { signal: ctrl.signal }) return await res.text() } catch (err) { const msg = err instanceof Error ? err.message : String(err) return JSON.stringify({ success: false, error: msg }) + } finally { + clearTimeout(timer) } } +/** + * Description shown to the LLM for the single `unity` tool. Kept terse and + * pointed at the existing `unity-mcp-orchestrator` skill so Amp pays the + * tokens for the catalog only when the model actually needs it. + */ +export const UNITY_TOOL_DESCRIPTION = [ + 'Call any MCP for Unity tool through the local Python MCP server.', + 'Use this for every Unity Editor automation: GameObjects, scripts, scenes, assets, prefabs, components, build, tests, console, screenshots, etc.', + '', + 'Common `tool` values (full list in the unity-mcp-orchestrator skill):', + ' • Reads: find_gameobjects, find_in_file, read_console, unity_reflect, unity_docs, preflight', + ' • GameObj: manage_gameobject, manage_components, manage_prefabs, manage_scene', + ' • Scripts: manage_script, script_apply_edits, refresh_unity, execute_code', + ' • Assets: manage_asset, manage_material, manage_texture, manage_shader, manage_packages', + ' • Editor: manage_editor, execute_menu_item, manage_camera, set_active_instance', + ' • Misc: batch_execute, run_tests, manage_build, manage_animation, manage_ui, manage_vfx', + '', + 'Resources (read-only state) live under mcpforunity://… and are fetched by name through the same Python server (use the relevant manage_* tool or read_console for reads).', + 'Prefer `batch_execute` when issuing 3+ related calls — it is 10–100× faster.', + 'After scripts change, poll editor state for `is_compiling=false` then call read_console for errors.', + '`params` is the exact parameter object the underlying tool expects. If unsure of the shape, load the unity-mcp-orchestrator skill or call `unity` with `tool="preflight"` first.', +].join('\n') + +const BASE_URL = normalizeBaseUrl(process.env.UNITY_MCP_SERVER_URL ?? DEFAULT_BASE_URL) +const TIMEOUT_MS = resolveTimeoutMs(process.env.UNITY_MCP_TIMEOUT_MS, DEFAULT_TIMEOUT_MS) + +/** + * Plugin entry point. Registers the `unity` proxy tool, the `Unity: Status` + * palette command, and a `session.start` listener that logs reachability. + */ export default function (amp: PluginAPI) { amp.registerTool({ name: 'unity', - description: [ - 'Call any MCP for Unity tool through the local Python MCP server.', - 'Use this for every Unity Editor automation: GameObjects, scripts, scenes, assets, prefabs, components, build, tests, console, screenshots, etc.', - '', - 'Common `tool` values (full list in the unity-mcp-orchestrator skill):', - ' • Reads: find_gameobjects, find_in_file, read_console, unity_reflect, unity_docs, preflight', - ' • GameObj: manage_gameobject, manage_components, manage_prefabs, manage_scene', - ' • Scripts: manage_script, script_apply_edits, refresh_unity, execute_code', - ' • Assets: manage_asset, manage_material, manage_texture, manage_shader, manage_packages', - ' • Editor: manage_editor, execute_menu_item, manage_camera, set_active_instance', - ' • Misc: batch_execute, run_tests, manage_build, manage_animation, manage_ui, manage_vfx', - '', - 'Resources (read-only state) live under mcpforunity://… and are fetched by name through the same Python server (use the relevant manage_* tool or read_console for reads).', - 'Prefer `batch_execute` when issuing 3+ related calls — it is 10–100× faster.', - 'After scripts change, poll editor state for `is_compiling=false` then call read_console for errors.', - '`params` is the exact parameter object the underlying tool expects. If unsure of the shape, load the unity-mcp-orchestrator skill or call `unity` with `tool="preflight"` first.', - ].join('\n'), + description: UNITY_TOOL_DESCRIPTION, inputSchema: { type: 'object', properties: { @@ -102,16 +216,14 @@ export default function (amp: PluginAPI) { required: ['tool'], }, async execute(input) { - const tool = String(input.tool ?? '').trim() - if (!tool) { - return JSON.stringify({ success: false, error: 'Missing required field: tool' }) - } - const params = (input.params && typeof input.params === 'object' ? input.params : {}) as Record - const body: CommandBody = { type: tool, params } - if (typeof input.unity_instance === 'string' && input.unity_instance.length > 0) { - body.unity_instance = input.unity_instance + let body: CommandBody + try { + body = buildCommandBody(input) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return JSON.stringify({ success: false, error: msg }) } - return postCommand(body) + return postCommand(fetch as FetchLike, BASE_URL, TIMEOUT_MS, body) }, }) @@ -119,14 +231,16 @@ export default function (amp: PluginAPI) { 'status', { title: 'Status', category: 'Unity', description: 'Show connected Unity instances and server reachability' }, async (ctx) => { - const text = await listInstances() + const text = await listInstances(fetch as FetchLike, BASE_URL) await ctx.ui.notify(`Unity MCP @ ${BASE_URL}\n${text}`) }, ) amp.on('session.start', async (_event, ctx) => { try { - const res = await fetch(`${BASE_URL}/api/instances`, { signal: AbortSignal.timeout(1_500) }) + const res = await (fetch as FetchLike)(`${BASE_URL}/api/instances`, { + signal: AbortSignal.timeout(SESSION_START_PROBE_TIMEOUT_MS), + }) if (!res.ok) { ctx.logger.log(`Unity MCP server reachable but /api/instances returned HTTP ${res.status}`) return diff --git a/.github/workflows/amp-plugin-tests.yml b/.github/workflows/amp-plugin-tests.yml new file mode 100644 index 000000000..75ad53764 --- /dev/null +++ b/.github/workflows/amp-plugin-tests.yml @@ -0,0 +1,30 @@ +name: Amp Plugin Tests + +on: + push: + branches: ["**"] + paths: + - .amp/plugins/** + - .github/workflows/amp-plugin-tests.yml + pull_request: + branches: [main, beta] + paths: + - .amp/plugins/** + - .github/workflows/amp-plugin-tests.yml + workflow_dispatch: {} + +jobs: + test: + name: Run Bun Tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Run plugin tests + run: bun test ./.amp/plugins/__tests__