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 new file mode 100644 index 000000000..202b327bd --- /dev/null +++ b/.amp/plugins/unity-mcp.ts @@ -0,0 +1,255 @@ +/** + * 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, 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' + +/** Default base URL for the local MCP for Unity Python server. */ +export const DEFAULT_BASE_URL = 'http://127.0.0.1:8080' + +/** 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 +} + +/** + * 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(), timeoutMs) + try { + const res = await fetchFn(`${baseUrl}/api/command`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: ctrl.signal, + }) + const text = await res.text() + if (!res.ok) { + return text || JSON.stringify({ success: false, error: `HTTP ${res.status}` }) + } + return text + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return formatUnreachableError(baseUrl, msg) + } finally { + clearTimeout(timer) + } +} + +/** + * 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 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: UNITY_TOOL_DESCRIPTION, + 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) { + 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(fetch as FetchLike, BASE_URL, TIMEOUT_MS, body) + }, + }) + + amp.registerCommand( + 'status', + { title: 'Status', category: 'Unity', description: 'Show connected Unity instances and server reachability' }, + async (ctx) => { + 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 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 + } + 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)`) + } + }) +} 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__