diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 2b3aa55..595d3f7 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -3,12 +3,12 @@ name: Integration UnitTest on: push: branches: - - main - develop pull_request: branches: - main - develop + workflow_call: jobs: macos-browser-test: @@ -26,8 +26,9 @@ jobs: repository: Next2D/player ref: main - run: npm install -g npm@latest - - run: npm install + - run: npm ci - run: npm run test + - run: npm audit --audit-level=high --omit=dev windows-browser-test: runs-on: windows-latest @@ -44,5 +45,6 @@ jobs: repository: Next2D/player ref: main - run: npm install -g npm@latest - - run: npm install - - run: npm run test \ No newline at end of file + - run: npm ci + - run: npm run test + - run: npm audit --audit-level=high --omit=dev \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b089ac5..5275e2b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,12 +3,12 @@ name: Lint on: push: branches: - - main - develop pull_request: branches: - main - develop + workflow_call: jobs: macos-browser-test: @@ -23,7 +23,7 @@ jobs: node-version: 24 registry-url: "https://registry.npmjs.org" - run: npm install -g npm@latest - - run: npm install + - run: npm ci - run: npm run lint windows-browser-test: @@ -38,5 +38,5 @@ jobs: node-version: 24 registry-url: "https://registry.npmjs.org" - run: npm install -g npm@latest - - run: npm install + - run: npm ci - run: npm run lint \ No newline at end of file diff --git a/.github/workflows/marketplace-publish.yml b/.github/workflows/marketplace-publish.yml index 64747bf..0b98967 100644 --- a/.github/workflows/marketplace-publish.yml +++ b/.github/workflows/marketplace-publish.yml @@ -9,7 +9,14 @@ permissions: contents: read jobs: + lint: + uses: ./.github/workflows/lint.yml + + integration: + uses: ./.github/workflows/integration.yml + publish: + needs: [lint, integration] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -21,7 +28,7 @@ jobs: node-version: 24 registry-url: "https://registry.npmjs.org" - run: npm install -g npm@latest - - run: npm install + - run: npm ci - run: npm run build - run: npx @vscode/vsce publish env: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1d1a709..dae986a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,7 +10,14 @@ permissions: contents: read jobs: + lint: + uses: ./.github/workflows/lint.yml + + integration: + uses: ./.github/workflows/integration.yml + publish: + needs: [lint, integration] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -22,6 +29,6 @@ jobs: node-version: 24 registry-url: "https://registry.npmjs.org" - run: npm install -g npm@latest - - run: npm install + - run: npm ci - run: npm run build - run: npm publish diff --git a/package-lock.json b/package-lock.json index d900447..5768eed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "next2d-development-mcp", - "version": "1.0.2", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "next2d-development-mcp", - "version": "1.0.2", + "version": "1.1.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1" @@ -16,7 +16,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^25.3.0", + "@types/node": "^25.3.1", "@types/vscode": "^1.109.0", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", @@ -1627,9 +1627,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", - "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "version": "25.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.1.tgz", + "integrity": "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 2ec0c56..81857db 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "next2d-development-mcp", "displayName": "Next2D Development MCP", - "version": "1.0.2", + "version": "1.1.0", "description": "MCP server for Next2D application development assistance", "type": "module", "author": "Toshiyuki Ienaga ", @@ -52,7 +52,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@types/node": "^25.3.0", + "@types/node": "^25.3.1", "@types/vscode": "^1.109.0", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", diff --git a/src/prompts/index.ts b/src/prompts/index.ts index ece2b90..a5cc887 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -118,6 +118,38 @@ export function registerPrompts(server: McpServer): void { }) ); + server.registerPrompt( + "orchestrate", + { + "description": + "Activate orchestrator mode for systematic Next2D development. " + + "Handles both NEW screen creation and EXISTING screen modification. " + + "Claude will inspect the screen state, plan changes, execute MCP tools, and validate.", + "argsSchema": { + "task": z.string().describe( + "What you want to build or change (e.g. 'add search to quest/list', 'implement home screen')" + ), + "screenPath": z.string().describe( + "Target screen path matching routing.json key (e.g. 'quest/list', 'home')" + ), + "mode": z.enum(["create", "modify"]).optional().default("create").describe( + "'create' for a new screen, 'modify' to change an existing screen" + ) + } + }, + async ({ task, screenPath, mode }) => ({ + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": ORCHESTRATE_GUIDE(task, screenPath, mode ?? "create") + } + } + ] + }) + ); + server.registerPrompt( "debug-help", { @@ -177,6 +209,120 @@ export function registerPrompts(server: McpServer): void { ); } +function ORCHESTRATE_GUIDE(task: string, screenPath: string, mode: "create" | "modify"): string { + const isModify = mode === "modify"; + + const createWorkflow = ` +## Phase 1: Analyze Current State +Call **\`analyze_project\`** to understand the overall project state. + +## Phase 2: Generate Implementation Plan +Call **\`plan_feature\`** with: +- \`screenPath\`: \`"${screenPath}"\` +- \`hasApi\`: \`true\` if API data is needed +- \`hasContent\`: \`true\` if Animation Tool (.n2d) assets are needed + +The tool returns an ordered step list. Steps marked ✅ are already implemented — skip them. + +## Phase 3: Execute the Plan +For each step in the plan: +1. Call the specified MCP tool to generate the code +2. Write the file to the exact path shown +3. Implement business logic as described in the step notes +4. Run \`npm run generate\` after updating routing.json + +## Phase 4: Validate +Call **\`validate_architecture\`** to confirm all files exist and the structure is consistent. + +**Start now:** call \`analyze_project\`. +`; + + const modifyWorkflow = ` +## Phase 1: Inspect the Target Screen +Call **\`inspect_screen\`** with \`screenPath: "${screenPath}"\`. + +This returns: +- routing.json configuration for this screen +- All related file paths (View, ViewModel, Page, UseCases, Repositories, Animations, Content) +- File line counts so you know how much is already implemented +- Missing files flagged with ❌ + +## Phase 2: Read the Relevant Files +Using the **Read** tool, read the files that are related to the change: +- If changing UI → read the Page component +- If changing data flow → read ViewModel + UseCase +- If changing API → read Repository + Interface +- If changing navigation → read UseCase that calls \`app.gotoView()\` + +**Read only what you need** — targeted modification, not full rewrites. + +## Phase 3: Plan and Execute Changes +Based on what you read, decide the minimal set of changes: + +| Need to... | Action | +|---|---| +| Add a new action | Create UseCase with \`create_usecase\` tool | +| Add API access | Create Repository with \`create_repository\` tool | +| Add a UI element | Create component with \`create_ui_component\` tool | +| Add animation | Create animation with \`create_animation\` tool | +| Modify existing logic | Edit the specific file directly | + +**Modification rules:** +- Change **only** what the task requires — no unrelated refactoring +- Keep single responsibility: one UseCase per action +- If modifying ViewModel, make sure View still delegates (no logic added to View) +- Buttons added/modified must use \`ButtonAtom.disable()\`/\`enable()\` + +## Phase 4: Validate +Call **\`validate_architecture\`** to confirm the screen structure remains consistent. + +**Start now:** call \`inspect_screen\` with \`screenPath: "${screenPath}"\`. +`; + + return `# Next2D Development Orchestrator + +## Task +${task} + +| Item | Value | +|------|-------| +| Target screen | \`${screenPath}\` | +| Mode | **${isModify ? "Modify existing screen" : "Create new screen"}** | + +## Your Role +You are a systematic development orchestrator for Next2D projects. +Follow the workflow below **in order**. Do not skip phases. +${isModify ? modifyWorkflow : createWorkflow} +--- + +## Architecture Rules (always apply) +- **View** → extends \`View\`, delegates to Page: \`page.initialize(this.vm)\` +- **ViewModel** → holds UseCases, no direct UI, fetches data in \`initialize()\` +- **UseCase** → one action = one class, single \`execute()\` entry point +- **Repository** → try-catch required, endpoint from \`config.api.endPoint\`, no \`any\` type +- **Buttons** → \`ButtonAtom.disable()\` on press, \`enable()\` after action or in \`Job.COMPLETE\` +- **Interfaces** → \`I\` prefix, minimal properties only + +--- + +## Tool Reference + +| Tool | When to use | +|------|-------------| +| \`inspect_screen\` | Before modifying — understand what exists | +| \`analyze_project\` | Before creating — see overall project state | +| \`plan_feature\` | Generate ordered creation steps | +| \`create_usecase\` | Add a new action/behavior | +| \`create_repository\` | Add new API/data access | +| \`create_interface\` | Define a new response/DTO type | +| \`create_ui_component\` | Add Page/Molecule/Atom/Content | +| \`create_animation\` | Add Tween animation | +| \`create_view\` | Generate View + ViewModel pair | +| \`add_route\` | Register new route in routing.json | +| \`validate_architecture\` | Final consistency check | +`; +} + const ARCHITECTURE_GUIDE = `# Next2D Architecture & Coding Conventions ## MVVM + Clean Architecture @@ -206,11 +352,11 @@ const ARCHITECTURE_GUIDE = `# Next2D Architecture & Coding Conventions - All public methods must have JSDoc comments ### View Rules -- Extends \`Sprite\` (via framework \`View\` class) +- Extends \`View\` generic class from framework - **No business logic** - only display structure -- Constructor receives ViewModel and creates Page component with \`addChild()\` -- \`initialize()\`: Delegates to Page for UI setup and event binding -- \`onEnter()\`: Delegates to Page for entry animations +- Constructor receives ViewModel, calls \`super(vm)\`, creates Page component and \`addChild()\` +- \`initialize()\`: Delegates to \`this._xxxPage.initialize(this.vm)\` +- \`onEnter()\`: Delegates to \`await this._xxxPage.onEnter()\` - \`onExit()\`: Cleanup when view is hidden ### ViewModel Rules @@ -259,20 +405,48 @@ content.addEventListener(PointerEvent.POINTER_DOWN, (event: PointerEvent): void \`\`\`typescript // ButtonAtom provides disable()/enable() for mouseEnabled/mouseChildren control -// In Page.initialize(vm): +// Pattern 1: Normal button (re-enable on POINTER_UP) button.addEventListener(PointerEvent.POINTER_UP, async (): Promise => { button.disable(); // Immediately disable to prevent double-press await vm.onClickButton(); button.enable(); // Re-enable after processing (skip if navigating away) }); -// In ViewModel (for async operations): -async onClickButton (): Promise { - try { - await this.fetchDataUseCase.execute(); - } catch (error) { - console.error(error); - } -} +// Pattern 2: Tween animation (re-enable in Job.COMPLETE callback) +button.addEventListener(PointerEvent.POINTER_UP, (): void => { + button.disable(); + new ButtonPointerUpAnimation(button, () => { button.enable(); }).start(); +}); +\`\`\` + +### Display Object Hierarchy +\`\`\` +DisplayObject (base) +├── InteractiveObject +│ ├── DisplayObjectContainer +│ │ └── Sprite +│ │ └── MovieClip ← addChild() allowed, timeline animation +│ └── TextField ← addChild() NOT allowed, text display/input +├── Shape ← addChild() NOT allowed, lightweight vector drawing +└── Video ← addChild() NOT allowed, video playback +\`\`\` +**Key type constraints:** +- \`Shape\` does NOT extend \`DisplayObjectContainer\` → \`addChild()\` unavailable +- \`Shape\` cannot be directly cast to \`Sprite\` → use \`as unknown as Sprite\` two-step assertion +- \`hitArea\` property type is \`Sprite | null\` → type assertion required when passing \`Shape\` + +### Content Security Policy (CSP) +Required directives for Next2D Player (WebGL/WebGPU, Web Workers, Blob URLs): +\`\`\` +default-src 'self' data: blob: ← Blob URL/Data URI used internally +style-src 'self' 'unsafe-inline' ← Dynamic style injection by Player +worker-src 'self' blob: data: ← Web Worker via Blob/Data URI +\`\`\` +**NEVER add \`frame-ancestors 'none'\`** — it will break the application. + +### E2E Testing Recommendation +After screen transitions or UI behavior changes, verify with Playwright: +\`\`\`bash +npx playwright test \`\`\` `; diff --git a/src/prompts/prompts.test.ts b/src/prompts/prompts.test.ts index d5433bf..d57fe83 100644 --- a/src/prompts/prompts.test.ts +++ b/src/prompts/prompts.test.ts @@ -9,10 +9,10 @@ describe("Prompt registration", () => { server = new McpServer({ name: "test", version: "0.0.1" }); }); - it("registers 3 prompts without error", () => { + it("registers 4 prompts without error", () => { const spy = vi.spyOn(server, "registerPrompt"); registerPrompts(server); - expect(spy).toHaveBeenCalledTimes(3); + expect(spy).toHaveBeenCalledTimes(4); }); it("registers expected prompt names", () => { @@ -22,5 +22,6 @@ describe("Prompt registration", () => { expect(names).toContain("new-screen"); expect(names).toContain("architecture-guide"); expect(names).toContain("debug-help"); + expect(names).toContain("orchestrate"); }); }); diff --git a/src/resources/index.ts b/src/resources/index.ts index ea46dd8..e58424b 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -175,9 +175,9 @@ Application Layer (model/application/) \`\`\` ### View Layer (src/view/, src/ui/) -- **View**: Extends Sprite, manages display structure. No business logic. -- **ViewModel**: Bridge between View and Model. Holds UseCases. -- **UI Components**: Atomic Design hierarchy (Atom → Molecule → Organism → Page) +- **View**: Extends \`View\` generic class. No business logic. Constructor calls \`super(vm)\`, creates Page component. +- **ViewModel**: Bridge between View and Model. Holds UseCases. Initialized before View. +- **UI Components**: Atomic Design hierarchy (Atom → Molecule → Organism → Page). View delegates to Page for UI setup. ### Interface Layer (src/interface/) - TypeScript interfaces with \`I\` prefix @@ -224,9 +224,26 @@ URL-to-View mapping with request configurations. \`\`\` ViewModel.constructor → ViewModel.initialize() → View.constructor(vm) → View.initialize() → View.onEnter() → (interaction) → View.onExit() \`\`\` +**Note:** View delegates to Page component: \`initialize()\` calls \`page.initialize(this.vm)\`, \`onEnter()\` calls \`await page.onEnter()\`. + +## Display Object Hierarchy +\`\`\` +DisplayObject (base) +├── InteractiveObject +│ ├── DisplayObjectContainer +│ │ └── Sprite +│ │ └── MovieClip ← addChild() allowed, timeline animation +│ └── TextField ← addChild() NOT allowed, text display/input +├── Shape ← addChild() NOT allowed, lightweight vector drawing +└── Video ← addChild() NOT allowed, video playback +\`\`\` +**Key constraints:** +- \`Shape\` has no \`addChild()\` — use \`Sprite\` or \`MovieClip\` as container +- Casting \`Shape\` to \`Sprite\`: requires \`as unknown as Sprite\` two-step assertion +- \`hitArea\` is \`Sprite | null\` — type assertion required for \`Shape\` ## Key Rules -1. View: Display only. Delegate events to ViewModel. +1. View: Display only. Delegate events to ViewModel. Use \`View\` generic. 2. ViewModel: Hold UseCases. Depend on interfaces. Get data via \`app.getResponse()\`. 3. UseCase: Single responsibility. \`execute()\` entry point. Can call Repository, Domain, framework APIs. 4. Repository: try-catch required. Config for endpoints. Return typed interfaces. @@ -234,6 +251,8 @@ ViewModel.constructor → ViewModel.initialize() → View.constructor(vm) → Vi 6. No \`any\` type. Explicit types always. 7. Domain: No external API/DB dependencies (Next2D display APIs allowed). Pure business logic. 8. Animation: Separate from components. Use Tween/Easing/Job. +9. CSP: \`default-src 'self' data: blob:\`, \`worker-src 'self' blob: data:\`, \`style-src 'self' 'unsafe-inline'\` required. NEVER add \`frame-ancestors 'none'\`. +10. E2E: After UI/screen changes, run \`npx playwright test\` to verify behavior. ## DisplayObject Centering Pattern \`\`\`typescript diff --git a/src/tools/analyzeProject.ts b/src/tools/analyzeProject.ts new file mode 100644 index 0000000..f5525c7 --- /dev/null +++ b/src/tools/analyzeProject.ts @@ -0,0 +1,174 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +function walkDir(dir: string, suffix: string, results: string[] = []): string[] { + if (!fs.existsSync(dir)) { return results } + for (const entry of fs.readdirSync(dir, { "withFileTypes": true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkDir(full, suffix, results); + } else if (entry.isFile() && entry.name.endsWith(suffix)) { + results.push(full); + } + } + return results; +} + +export function registerAnalyzeProject(server: McpServer): void { + server.registerTool( + "analyze_project", + { + "description": + "Analyze the current Next2D project state. " + + "Reads routing.json, scans View/ViewModel/UseCase/Repository files, " + + "and reports what is implemented vs. missing. " + + "Use this before planning new features to understand the current state.", + "inputSchema": { + "projectPath": z.string().optional().default(".").describe( + "Path to the project root directory (default: current directory)" + ) + } + }, + async ({ projectPath }) => { + const base = path.resolve(projectPath); + const lines: string[] = [ + "## Next2D Project Analysis", + "", + `**Project root:** \`${base}\``, + "" + ]; + + // --- routing.json --- + const routingPath = path.join(base, "src/config/routing.json"); + const routes: string[] = []; + const missingViews: string[] = []; + + if (fs.existsSync(routingPath)) { + try { + const routing: Record = JSON.parse( + fs.readFileSync(routingPath, "utf-8") + ); + const clusters = Object.keys(routing).filter((k) => k.startsWith("@")); + const screenRoutes = Object.keys(routing).filter((k) => !k.startsWith("@")); + + lines.push(`### Screens (${screenRoutes.length} routes, ${clusters.length} clusters)`); + + for (const route of screenRoutes) { + routes.push(route); + const screenDir = route.includes("/") + ? route.split("/")[0].toLowerCase() + : route.toLowerCase(); + const pascal = route + .split("/") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(""); + + const viewFile = path.join(base, `src/view/${screenDir}/${pascal}View.ts`); + const vmFile = path.join(base, `src/view/${screenDir}/${pascal}ViewModel.ts`); + const hasView = fs.existsSync(viewFile); + const hasVM = fs.existsSync(vmFile); + + const entry = routing[route] as Record; + const reqs = Array.isArray(entry.requests) + ? (entry.requests as Array>) + : []; + const hasApi = reqs.some((r) => r.type === "json" || r.type === "custom"); + const hasContent = reqs.some((r) => r.type === "content"); + const isPrivate = entry.private === true; + + const tags = [ + hasApi ? "[API]" : "", + hasContent ? "[Content]" : "", + isPrivate ? "[private]" : "" + ].filter(Boolean).join(" "); + + const missing: string[] = []; + if (!hasView) { missing.push("View") } + if (!hasVM) { missing.push("ViewModel") } + + const icon = missing.length === 0 ? "✅" : "⚠️"; + const missingNote = missing.length > 0 ? ` ← missing: ${missing.join(", ")}` : ""; + lines.push(`- ${icon} \`${route}\` → ${pascal} ${tags}${missingNote}`); + + if (missing.length > 0) { + missingViews.push(route); + } + } + lines.push(""); + } catch { + lines.push("❌ routing.json is not valid JSON", ""); + } + } else { + lines.push("❌ `src/config/routing.json` not found", ""); + } + + // --- UseCases --- + const usecaseBase = path.join(base, "src/model/application"); + const usecaseFiles = walkDir(usecaseBase, "UseCase.ts"); + lines.push(`### UseCases (${usecaseFiles.length})`); + if (usecaseFiles.length === 0) { + lines.push("- (none)"); + } else { + for (const f of usecaseFiles) { + lines.push(`- \`${path.relative(base, f)}\``); + } + } + lines.push(""); + + // --- Repositories --- + const repoBase = path.join(base, "src/model/infrastructure/repository"); + const repoFiles = walkDir(repoBase, "Repository.ts"); + lines.push(`### Repositories (${repoFiles.length})`); + if (repoFiles.length === 0) { + lines.push("- (none)"); + } else { + for (const f of repoFiles) { + lines.push(`- \`${path.relative(base, f)}\``); + } + } + lines.push(""); + + // --- UI Components --- + const atomFiles = walkDir(path.join(base, "src/ui/component/atom"), ".ts"); + const moleculeFiles = walkDir(path.join(base, "src/ui/component/molecule"), ".ts"); + const pageFiles = walkDir(path.join(base, "src/ui/component/page"), ".ts"); + const contentFiles = walkDir(path.join(base, "src/ui/content"), ".ts"); + + lines.push("### UI Components"); + lines.push(`- Atoms: ${atomFiles.length}`); + lines.push(`- Molecules: ${moleculeFiles.length}`); + lines.push(`- Pages: ${pageFiles.length}`); + lines.push(`- Contents: ${contentFiles.length}`); + lines.push(""); + + // --- Summary & next actions --- + lines.push("### Summary"); + if (missingViews.length === 0 && routes.length > 0) { + lines.push("✅ All routes have corresponding View/ViewModel implementations."); + } else if (missingViews.length > 0) { + lines.push(`⚠️ ${missingViews.length} route(s) missing View/ViewModel:`); + for (const r of missingViews) { + lines.push(` - \`${r}\` → run \`npm run generate\` or use \`create_view\` tool`); + } + } + lines.push(""); + lines.push("### Suggested Next Steps"); + lines.push("- Use `plan_feature` tool to generate an ordered implementation plan for a new screen"); + lines.push("- Use `validate_architecture` tool for a full structural check"); + if (missingViews.length > 0) { + lines.push("- Run `npm run generate` to auto-create missing View/ViewModel pairs"); + } + + return { + "content": [ + { + "type": "text", + "text": lines.join("\n") + } + ] + }; + } + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 185b1dd..f7f0e0f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -10,6 +10,9 @@ import { registerValidateArchitecture } from "./validateArchitecture.js"; import { registerCreateAnimation } from "./createAnimation.js"; import { registerCreateDomainService } from "./createDomainService.js"; import { registerCreateLoading } from "./createLoading.js"; +import { registerAnalyzeProject } from "./analyzeProject.js"; +import { registerPlanFeature } from "./planFeature.js"; +import { registerInspectScreen } from "./inspectScreen.js"; export { z }; @@ -24,4 +27,7 @@ export function registerTools(server: McpServer): void { registerCreateAnimation(server); registerCreateDomainService(server); registerCreateLoading(server); + registerAnalyzeProject(server); + registerPlanFeature(server); + registerInspectScreen(server); } diff --git a/src/tools/inspectScreen.ts b/src/tools/inspectScreen.ts new file mode 100644 index 0000000..88b1094 --- /dev/null +++ b/src/tools/inspectScreen.ts @@ -0,0 +1,239 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +function toPascal(name: string): string { + return name + .split(/[/\-_]/) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(""); +} + +function walkDir(dir: string, suffix: string, results: string[] = []): string[] { + if (!fs.existsSync(dir)) { return results } + for (const entry of fs.readdirSync(dir, { "withFileTypes": true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkDir(full, suffix, results); + } else if (entry.isFile() && entry.name.endsWith(suffix)) { + results.push(full); + } + } + return results; +} + +function fileStatus( + base: string, + relativePath: string +): { exists: boolean; lines: number; path: string } { + const full = path.join(base, relativePath); + if (!fs.existsSync(full)) { + return { "exists": false, "lines": 0, "path": relativePath }; + } + const content = fs.readFileSync(full, "utf-8"); + const lines = content.split("\n").length; + return { "exists": true, lines, "path": relativePath }; +} + +export function registerInspectScreen(server: McpServer): void { + server.registerTool( + "inspect_screen", + { + "description": + "Inspect all implementation files for a specific Next2D screen. " + + "Lists View, ViewModel, Page, UseCases, Repositories, Animations, and Content " + + "related to the given screen path. Shows file existence, line counts, and " + + "routing.json configuration. Use this before modifying an existing screen " + + "to understand what is already implemented.", + "inputSchema": { + "screenPath": z.string().describe( + "Screen path matching routing.json key (e.g. 'quest/list', 'home', 'settings')" + ), + "projectPath": z.string().optional().default(".").describe( + "Path to the project root directory (default: current directory)" + ) + } + }, + async ({ screenPath, projectPath }) => { + const base = path.resolve(projectPath); + const pascal = toPascal(screenPath); + const screenDir = screenPath.includes("/") + ? screenPath.split("/")[0].toLowerCase() + : screenPath.toLowerCase(); + + const lines: string[] = [ + `## Screen Inspection: \`${screenPath}\``, + "", + "| Item | Value |", + "|------|-------|", + `| Screen path | \`${screenPath}\` |`, + `| Class prefix | \`${pascal}\` |`, + `| Screen directory | \`src/view/${screenDir}/\` |`, + "" + ]; + + // --- routing.json entry --- + lines.push("### routing.json Configuration"); + const routingPath = path.join(base, "src/config/routing.json"); + if (fs.existsSync(routingPath)) { + try { + const routing: Record = JSON.parse( + fs.readFileSync(routingPath, "utf-8") + ); + if (screenPath in routing) { + lines.push("```json"); + lines.push(JSON.stringify({ [screenPath]: routing[screenPath] }, null, 4)); + lines.push("```"); + } else { + lines.push(`⚠️ Route \`${screenPath}\` not found in routing.json`); + lines.push("→ Add route first with `add_route` tool or `npm run generate`"); + } + } catch { + lines.push("❌ routing.json parse error"); + } + } else { + lines.push("❌ routing.json not found"); + } + lines.push(""); + + // --- Core MVVM files --- + lines.push("### MVVM Files"); + const coreFiles = [ + { "label": "View", "rel": `src/view/${screenDir}/${pascal}View.ts` }, + { "label": "ViewModel", "rel": `src/view/${screenDir}/${pascal}ViewModel.ts` } + ]; + for (const f of coreFiles) { + const s = fileStatus(base, f.rel); + const icon = s.exists ? "✅" : "❌"; + const detail = s.exists ? `(${s.lines} lines)` : "← missing"; + lines.push(`- ${icon} **${f.label}**: \`${f.rel}\` ${detail}`); + } + lines.push(""); + + // --- Page component --- + lines.push("### Page Component"); + const pageStat = fileStatus(base, `src/ui/component/page/${screenDir}/${pascal}Page.ts`); + const pageIcon = pageStat.exists ? "✅" : "❌"; + const pageDetail = pageStat.exists ? `(${pageStat.lines} lines)` : "← missing"; + lines.push(`- ${pageIcon} **Page**: \`${pageStat.path}\` ${pageDetail}`); + lines.push(""); + + // --- UseCases --- + lines.push("### UseCases"); + const usecaseDir = path.join(base, `src/model/application/${screenDir}/usecase`); + const usecaseFiles = walkDir(usecaseDir, ".ts"); + if (usecaseFiles.length === 0) { + lines.push("- ❌ No UseCases found"); + lines.push(` → Expected in: \`src/model/application/${screenDir}/usecase/\``); + } else { + for (const f of usecaseFiles) { + const rel = path.relative(base, f); + const content = fs.readFileSync(f, "utf-8"); + const lc = content.split("\n").length; + lines.push(`- ✅ \`${rel}\` (${lc} lines)`); + } + } + lines.push(""); + + // --- Repositories --- + lines.push("### Repositories"); + const repoDir = path.join(base, "src/model/infrastructure/repository"); + const allRepos = walkDir(repoDir, "Repository.ts"); + // Match repositories whose name starts with the pascal prefix + const screenRepos = allRepos.filter((f) => + path.basename(f).startsWith(pascal) + ); + if (screenRepos.length === 0) { + lines.push("- (none matching this screen)"); + } else { + for (const f of screenRepos) { + const rel = path.relative(base, f); + const content = fs.readFileSync(f, "utf-8"); + const lc = content.split("\n").length; + lines.push(`- ✅ \`${rel}\` (${lc} lines)`); + } + } + lines.push(""); + + // --- Animations --- + lines.push("### Animations"); + const animDir = path.join(base, `src/ui/animation/${screenDir}`); + const animFiles = walkDir(animDir, "Animation.ts"); + if (animFiles.length === 0) { + lines.push("- (none)"); + } else { + for (const f of animFiles) { + const rel = path.relative(base, f); + lines.push(`- ✅ \`${rel}\``); + } + } + lines.push(""); + + // --- Content --- + lines.push("### Animation Tool Content"); + const contentStat = fileStatus(base, `src/ui/content/${pascal}Content.ts`); + if (contentStat.exists) { + lines.push(`- ✅ \`${contentStat.path}\` (${contentStat.lines} lines)`); + } else { + lines.push("- (none)"); + } + lines.push(""); + + // --- Interfaces --- + lines.push("### Interfaces"); + const interfaceDir = path.join(base, "src/interface"); + const allInterfaces = walkDir(interfaceDir, ".ts"); + // Match interfaces whose name contains the pascal prefix + const screenInterfaces = allInterfaces.filter((f) => { + const name = path.basename(f, ".ts"); + return name.includes(pascal) || name.startsWith(`I${pascal}`); + }); + if (screenInterfaces.length === 0) { + lines.push("- (none matching this screen)"); + } else { + for (const f of screenInterfaces) { + const rel = path.relative(base, f); + lines.push(`- ✅ \`${rel}\``); + } + } + lines.push(""); + + // --- Summary --- + const missing: string[] = []; + if (!coreFiles[0] || !fileStatus(base, coreFiles[0].rel).exists) { + missing.push("View"); + } + if (!coreFiles[1] || !fileStatus(base, coreFiles[1].rel).exists) { + missing.push("ViewModel"); + } + if (!pageStat.exists) { missing.push("Page") } + if (usecaseFiles.length === 0) { missing.push("UseCase") } + + lines.push("### Summary"); + if (missing.length === 0) { + lines.push("✅ All core files are implemented."); + } else { + lines.push(`⚠️ Missing: ${missing.join(", ")}`); + } + lines.push(""); + lines.push("### Next Steps for Modification"); + lines.push("Read the files listed above with the **Read** tool to understand the current implementation."); + lines.push("Then plan your changes:"); + lines.push("- **Add UseCase**: `create_usecase` tool"); + lines.push("- **Add Repository**: `create_repository` tool"); + lines.push("- **Add UI component**: `create_ui_component` tool"); + lines.push("- **Add Animation**: `create_animation` tool"); + lines.push("- **Modify existing file**: Read → plan changes → Edit"); + + return { + "content": [ + { + "type": "text", + "text": lines.join("\n") + } + ] + }; + } + ); +} diff --git a/src/tools/planFeature.ts b/src/tools/planFeature.ts new file mode 100644 index 0000000..6ea1b02 --- /dev/null +++ b/src/tools/planFeature.ts @@ -0,0 +1,291 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +function toPascal(name: string): string { + return name + .split(/[/\-_]/) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(""); +} + +export function registerPlanFeature(server: McpServer): void { + server.registerTool( + "plan_feature", + { + "description": + "Generate a systematic, ordered implementation plan for a new Next2D screen or feature. " + + "Checks what already exists in the project and produces step-by-step instructions " + + "with specific MCP tool calls to execute. " + + "Run analyze_project first to understand current state.", + "inputSchema": { + "screenPath": z.string().describe( + "Screen path matching routing.json key (e.g. 'quest/list', 'home', 'settings')" + ), + "hasApi": z.boolean().optional().default(false).describe( + "Does this screen fetch data from an external API?" + ), + "hasContent": z.boolean().optional().default(false).describe( + "Does this screen use Animation Tool (.n2d) content?" + ), + "projectPath": z.string().optional().default(".").describe( + "Path to project root directory (default: current directory)" + ) + } + }, + async ({ screenPath, hasApi, hasContent, projectPath }) => { + const base = path.resolve(projectPath); + const pascal = toPascal(screenPath); + const screenDir = screenPath.includes("/") + ? screenPath.split("/")[0].toLowerCase() + : screenPath.toLowerCase(); + + // --- Detect what already exists --- + const routingPath = path.join(base, "src/config/routing.json"); + let routeExists = false; + if (fs.existsSync(routingPath)) { + try { + const routing: Record = JSON.parse( + fs.readFileSync(routingPath, "utf-8") + ); + routeExists = screenPath in routing; + } catch { /* ignore */ } + } + + const viewExists = fs.existsSync( + path.join(base, `src/view/${screenDir}/${pascal}View.ts`) + ); + const vmExists = fs.existsSync( + path.join(base, `src/view/${screenDir}/${pascal}ViewModel.ts`) + ); + const repoExists = fs.existsSync( + path.join(base, `src/model/infrastructure/repository/${pascal}Repository.ts`) + ); + const pageExists = fs.existsSync( + path.join(base, `src/ui/component/page/${screenDir}/${pascal}Page.ts`) + ); + const contentExists = fs.existsSync( + path.join(base, `src/ui/content/${pascal}Content.ts`) + ); + + const lines: string[] = [ + `## Implementation Plan: \`${screenPath}\``, + "", + "| Item | Value |", + "|------|-------|", + `| Screen path | \`${screenPath}\` |`, + `| Class prefix | \`${pascal}\` |`, + `| View directory | \`src/view/${screenDir}/\` |`, + `| Features | ${[hasApi ? "API data" : "", hasContent ? "Animation Tool content" : ""].filter(Boolean).join(", ") || "Basic screen"} |`, + "", + "---", + "" + ]; + + let step = 1; + + // ── Step: Route ────────────────────────────────────────────── + if (routeExists) { + lines.push(`### ✅ Route \`${screenPath}\` already defined in routing.json`); + } else { + lines.push(`### Step ${step}: Add Route`); + lines.push("**Tool:** `add_route`"); + lines.push("```json"); + lines.push("{"); + lines.push(` "path": "${screenPath}",`); + lines.push(" \"requests\": ["); + if (hasContent) { + lines.push(` { "type": "content", "path": "{{ content.endPoint }}content/${screenPath}.json", "name": "${pascal}Content", "cache": true }${hasApi ? "," : ""}`); + } + if (hasApi) { + lines.push(` { "type": "json", "path": "{{ api.endPoint }}api/${screenPath}.json", "name": "${pascal}Data" }`); + } + lines.push(" ]"); + lines.push("}"); + lines.push("```"); + step++; + } + lines.push(""); + + // ── Step: Interface (API response type) ────────────────────── + if (hasApi) { + lines.push(`### Step ${step}: Define API Response Interface`); + lines.push("**Tool:** `create_interface`"); + lines.push("```"); + lines.push("create_interface({"); + lines.push(` name: "${pascal}Response",`); + lines.push(" properties: [{ name: \"id\", type: \"string\" }, { name: \"name\", type: \"string\" }]"); + lines.push("})"); + lines.push("```"); + lines.push(`**Output:** \`src/interface/I${pascal}Response.ts\``); + lines.push("**Note:** Define only the properties you actually use (minimal interface rule)"); + lines.push(""); + step++; + + // ── Step: Repository ───────────────────────────────────── + if (repoExists) { + lines.push(`### ✅ ${pascal}Repository already exists`); + } else { + lines.push(`### Step ${step}: Create Repository`); + lines.push("**Tool:** `create_repository`"); + lines.push("```"); + lines.push(`create_repository({ name: "${pascal}", method: "get" })`); + lines.push("```"); + lines.push(`**Output:** \`src/model/infrastructure/repository/${pascal}Repository.ts\``); + lines.push("**Implement:**"); + lines.push(`- Endpoint: \`\${config.api.endPoint}api/${screenPath}.json\``); + lines.push(`- Return type: \`Promise\``); + lines.push("- Wrap in try-catch (required)"); + step++; + } + lines.push(""); + + // ── Step: Fetch UseCase ────────────────────────────────── + lines.push(`### Step ${step}: Create Fetch UseCase`); + lines.push("**Tool:** `create_usecase`"); + lines.push("```"); + lines.push(`create_usecase({ name: "Fetch${pascal}Data", screen: "${screenDir}" })`); + lines.push("```"); + lines.push(`**Output:** \`src/model/application/${screenDir}/usecase/Fetch${pascal}DataUseCase.ts\``); + lines.push("**Implement:** Call Repository, return typed data"); + lines.push(""); + step++; + } + + // ── Step: Navigation UseCase ───────────────────────────────── + lines.push(`### Step ${step}: Create Navigation UseCase`); + lines.push("**Tool:** `create_usecase`"); + lines.push("```"); + lines.push(`create_usecase({ name: "NavigateToView", screen: "${screenDir}" })`); + lines.push("```"); + lines.push(`**Output:** \`src/model/application/${screenDir}/usecase/NavigateToViewUseCase.ts\``); + lines.push("**Implement:** `await app.gotoView(viewName)` inside execute()"); + lines.push(""); + step++; + + // ── Step: View / ViewModel ─────────────────────────────────── + if (viewExists && vmExists) { + lines.push(`### ✅ ${pascal}View and ${pascal}ViewModel already exist`); + } else { + lines.push(`### Step ${step}: Generate View & ViewModel`); + lines.push("**Preferred:** `npm run generate` (auto-generates from routing.json)"); + lines.push("**Or Tool:** `create_view`"); + lines.push("```"); + lines.push(`create_view({ name: "${screenPath}" })`); + lines.push("```"); + lines.push("**Output:**"); + lines.push(`- \`src/view/${screenDir}/${pascal}View.ts\``); + lines.push(`- \`src/view/${screenDir}/${pascal}ViewModel.ts\``); + lines.push("**After creation:**"); + lines.push(`- Register in \`src/Packages.ts\` (import ${pascal}View, ${pascal}ViewModel)`); + lines.push(`- Add \`"${screenPath}"\` to \`src/interface/IViewName.ts\` union type`); + step++; + } + lines.push(""); + + // ── Step: Page Component ───────────────────────────────────── + if (pageExists) { + lines.push(`### ✅ ${pascal}Page already exists`); + } else { + lines.push(`### Step ${step}: Create Page Component`); + lines.push("**Tool:** `create_ui_component`"); + lines.push("```"); + lines.push(`create_ui_component({ name: "${pascal}Page", level: "page", screen: "${screenDir}" })`); + lines.push("```"); + lines.push(`**Output:** \`src/ui/component/page/${screenDir}/${pascal}Page.ts\``); + lines.push("**Implement:**"); + lines.push("- `initialize(vm)`: Create Atom/Molecule components, register event listeners"); + lines.push("- `onEnter()`: Start entry animations"); + lines.push("- Events must delegate to ViewModel methods (no logic in Page)"); + step++; + } + lines.push(""); + + // ── Step: Content (if needed) ──────────────────────────────── + if (hasContent) { + if (contentExists) { + lines.push(`### ✅ ${pascal}Content already exists`); + } else { + lines.push(`### Step ${step}: Create Animation Tool Content Wrapper`); + lines.push("**Tool:** `create_ui_component`"); + lines.push("```"); + lines.push(`create_ui_component({ name: "${pascal}Content", level: "content" })`); + lines.push("```"); + lines.push(`**Output:** \`src/ui/content/${pascal}Content.ts\``); + lines.push("**Remember:** Set `namespace` to match the Animation Tool symbol name exactly"); + step++; + } + lines.push(""); + } + + // ── Step: Animation ────────────────────────────────────────── + lines.push(`### Step ${step}: Create Entry Animation (recommended)`); + lines.push("**Tool:** `create_animation`"); + lines.push("```"); + lines.push(`create_animation({ component: "${pascal}Page", action: "Show", screen: "${screenDir}" })`); + lines.push("```"); + lines.push(`**Output:** \`src/ui/animation/${screenDir}/${pascal}PageShowAnimation.ts\``); + lines.push("**Implement:** Use `Tween.add()` with `Easing.*`. Call `job.start()` in `onEnter()`"); + lines.push(""); + step++; + + // ── Step: Validate ─────────────────────────────────────────── + lines.push(`### Step ${step}: Validate Architecture`); + lines.push("**Tool:** `validate_architecture`"); + lines.push("Confirm all required files exist and the project structure is consistent."); + lines.push(""); + + // ── Implementation Notes ───────────────────────────────────── + lines.push("---"); + lines.push(""); + lines.push("## Key Implementation Rules"); + lines.push(""); + lines.push("### View Pattern"); + lines.push("```typescript"); + lines.push(`export class ${pascal}View extends View<${pascal}ViewModel> {`); + lines.push(` private readonly _${screenDir}Page: ${pascal}Page;`); + lines.push(` constructor(vm: ${pascal}ViewModel) {`); + lines.push(" super(vm);"); + lines.push(` this._${screenDir}Page = new ${pascal}Page();`); + lines.push(` this.addChild(this._${screenDir}Page);`); + lines.push(" }"); + lines.push(` async initialize(): Promise { this._${screenDir}Page.initialize(this.vm); }`); + lines.push(` async onEnter(): Promise { await this._${screenDir}Page.onEnter(); }`); + lines.push(" async onExit(): Promise { return void 0; }"); + lines.push("}"); + lines.push("```"); + lines.push(""); + lines.push("### ViewModel Pattern"); + if (hasApi) { + lines.push("```typescript"); + lines.push("async initialize(): Promise {"); + lines.push(" const response = app.getResponse();"); + lines.push(` if (response.has("${pascal}Data")) {`); + lines.push(` this.data = response.get("${pascal}Data") as I${pascal}Response;`); + lines.push(" }"); + lines.push("}"); + lines.push("```"); + } + lines.push(""); + lines.push("### Button Double-Press Prevention"); + lines.push("```typescript"); + lines.push("// In Page.initialize(vm):"); + lines.push("btn.addEventListener(PointerEvent.POINTER_UP, async (): Promise => {"); + lines.push(" btn.disable();"); + lines.push(" await vm.onClickButton();"); + lines.push(" btn.enable(); // omit if navigating away"); + lines.push("});"); + lines.push("```"); + + return { + "content": [ + { + "type": "text", + "text": lines.join("\n") + } + ] + }; + } + ); +} diff --git a/src/tools/tools.test.ts b/src/tools/tools.test.ts index 86a9bd7..da2ed71 100644 --- a/src/tools/tools.test.ts +++ b/src/tools/tools.test.ts @@ -8,6 +8,9 @@ import { registerAddRoute } from "./addRoute.js"; import { registerCreateInterface } from "./createInterface.js"; import { registerCreateAnimation } from "./createAnimation.js"; import { registerCreateDomainService } from "./createDomainService.js"; +import { registerAnalyzeProject } from "./analyzeProject.js"; +import { registerPlanFeature } from "./planFeature.js"; +import { registerInspectScreen } from "./inspectScreen.js"; import { registerTools } from "./index.js"; describe("Tool registration", () => { @@ -49,10 +52,22 @@ describe("Tool registration", () => { expect(() => registerCreateDomainService(server)).not.toThrow(); }); - it("registerTools registers all 10 tools", () => { + it("registerAnalyzeProject registers without error", () => { + expect(() => registerAnalyzeProject(server)).not.toThrow(); + }); + + it("registerPlanFeature registers without error", () => { + expect(() => registerPlanFeature(server)).not.toThrow(); + }); + + it("registerInspectScreen registers without error", () => { + expect(() => registerInspectScreen(server)).not.toThrow(); + }); + + it("registerTools registers all 13 tools", () => { const spy = vi.spyOn(server, "registerTool"); registerTools(server); - expect(spy).toHaveBeenCalledTimes(10); + expect(spy).toHaveBeenCalledTimes(13); const toolNames = spy.mock.calls.map((call) => call[0]); expect(toolNames).toContain("create_view"); @@ -65,5 +80,8 @@ describe("Tool registration", () => { expect(toolNames).toContain("create_animation"); expect(toolNames).toContain("create_domain_service"); expect(toolNames).toContain("create_loading"); + expect(toolNames).toContain("analyze_project"); + expect(toolNames).toContain("plan_feature"); + expect(toolNames).toContain("inspect_screen"); }); });