UNPKG

@akiojin/unity-mcp-server

Version:

MCP server and Unity Editor bridge — enables AI assistants to control Unity for AI-assisted workflows

174 lines (152 loc) 7.9 kB
import { BaseToolHandler } from '../base/BaseToolHandler.js'; import { LspRpcClient } from '../../lsp/LspRpcClient.js'; import { ProjectInfoProvider } from '../../core/projectInfo.js'; export class ScriptEditStructuredToolHandler extends BaseToolHandler { constructor(unityConnection) { super( 'script_edit_structured', 'PRIORITY: Use this for ALL code changes. Perform structured edits (insert_before/insert_after/replace_body) on a known symbol. Required params: path (file under Assets/ or Packages/), symbolName (prefer container path e.g. MyType/Nested/Foo). Guidance: (1) Locate targets with script_symbols_get first, (2) insert_* must target a class/namespace (never a method), (3) replace_body must be a self-contained body including braces, (4) preview is for diagnostics only — apply proceeds even if diagnostics exist; errors are returned. LLM summary limits: errors≤30 (message≤200 chars, file≤260), large text (preview/diff/text/content)≤1000 chars with Truncated flag.', { type: 'object', properties: { operation: { type: 'string', enum: ['insert_before', 'insert_after', 'replace_body'], description: 'Edit type: insert_before, insert_after, or replace_body.' }, path: { type: 'string', description: 'Project-relative C# path starting with Assets/ or Packages/ (e.g., Packages/unity-mcp-server/Editor/Foo.cs). Do NOT prefix repository folders like UnityMCPServer/….' }, symbolName: { type: 'string', description: 'Target symbol name (e.g., class/method/field name).' }, kind: { type: 'string', description: 'Symbol kind (e.g., class, method, field, property). Optional but improves precision.' }, newText: { type: 'string', description: 'Text to insert or use as replacement body.' }, preview: { type: 'boolean', description: 'If true, returns a preview without writing files. Default=false to reduce large diff payloads.' } }, required: ['operation', 'path', 'symbolName'] } ); this.unityConnection = unityConnection; this.projectInfo = new ProjectInfoProvider(unityConnection); this.lsp = null; } validate(params) { super.validate(params); const { operation, path, symbolName, kind } = params; const validOperations = ['insert_before', 'insert_after', 'replace_body']; if (!validOperations.includes(operation)) { throw new Error(`Invalid operation: ${operation}`); } if (!path || path.trim() === '') { throw new Error('path cannot be empty'); } if (!symbolName || symbolName.trim() === '') { throw new Error('symbolName cannot be empty'); } // Safety guard: forbid inserting members into a method scope if ((operation === 'insert_after' || operation === 'insert_before') && (kind || '').toLowerCase() === 'method') { throw new Error('Insert operations must target class/namespace, not method scope. Use kind:"class" and insert at class level.'); } } async execute(params) { // Normalize to project-relative path const raw = String(params.path).replace(/\\\\/g, '/'); const ai = raw.indexOf('Assets/'); const pi = raw.indexOf('Packages/'); const idx = (ai >= 0 && pi >= 0) ? Math.min(ai, pi) : (ai >= 0 ? ai : pi); const relative = idx >= 0 ? raw.substring(idx) : raw; const operation = String(params.operation); const kind = (params.kind || '').toLowerCase(); const symbolName = String(params.symbolName); const preview = params?.preview === true; const body = String(params.newText || ''); // Map operations to LSP extensions const info = await this.projectInfo.get(); if (!this.lsp) this.lsp = new LspRpcClient(info.projectRoot); if (operation === 'replace_body') { const resp = await this.lsp.request('mcp/replaceSymbolBody', { relative, namePath: symbolName, body, apply: !preview }); return this._summarizeResult(resp?.result ?? resp, { preview }); } if (operation === 'insert_before' || operation === 'insert_after') { const method = operation === 'insert_before' ? 'mcp/insertBeforeSymbol' : 'mcp/insertAfterSymbol'; const resp = await this.lsp.request(method, { relative, namePath: symbolName, text: body, apply: !preview }); return this._summarizeResult(resp?.result ?? resp, { preview }); } return { error: `Unsupported operation: ${operation}` }; } /** * Summarize/trim responses to avoid huge token usage. * - Caps error items and message lengths * - Trims large text fields (e.g., preview/diff) to a short excerpt */ _summarizeResult(res, { preview }) { if (!res || typeof res !== 'object') return res; const MAX_ERRORS = 30; const MAX_MSG_LEN = 200; const MAX_TEXT_LEN = 1000; // generic cap for any preview-like text const out = {}; // Preserve common flags if present if ('id' in res) out.id = res.id; if ('success' in res) out.success = !!res.success; if ('applied' in res) out.applied = !!res.applied; // Errors trimming if (Array.isArray(res.errors)) { const trimmed = res.errors.slice(0, MAX_ERRORS).map(e => { const obj = {}; if (e && typeof e === 'object') { if ('id' in e) obj.id = e.id; if ('message' in e) obj.message = this._trimString(String(e.message), MAX_MSG_LEN); if ('file' in e) obj.file = this._trimString(String(e.file), 260); if ('line' in e) obj.line = e.line; if ('column' in e) obj.column = e.column; } else { obj.message = this._trimString(String(e), MAX_MSG_LEN); } return obj; }); out.errorCount = trimmed.length; // summarized count (<= MAX_ERRORS) out.totalErrors = res.errors.length; // raw count for reference out.errors = trimmed; } // Propagate workspace info if present (which .sln/.csproj is open) // workspace情報は返さない(厳格: .sln必須のため) // Generic handling for any large text properties commonly returned by tools for (const key of ['preview', 'diff', 'text', 'content']) { if (typeof res[key] === 'string' && res[key].length > 0) { out[key] = this._trimString(res[key], MAX_TEXT_LEN); if (res[key].length > MAX_TEXT_LEN) out[`${key}Truncated`] = true; } } // Echo minimal identifiers to aid clients for (const key of ['operation', 'path', 'relative', 'symbolName']) { if (res[key] !== undefined) out[key] = res[key]; } return Object.keys(out).length ? out : res; } _trimString(s, max) { if (typeof s !== 'string') return s; return s.length > max ? (s.slice(0, max) + '…') : s; } }