diff --git a/CHANGELOG.md b/CHANGELOG.md index 72be320..d465a14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to summer-engine will be documented here. Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added +- First-class LM Studio Bionic setup through `summer setup bionic`: public MCP discovery configuration, native Bionic Agent Skills, statically bound project-scope connections, setup aliases, diagnostics markers, tests, and a dedicated guide. +- MCP Roots-based project routing for clients that advertise the capability, including safe rebinding on `roots/list_changed` and ambiguity protection. + ## [2.8.1] — 2026-08-18 — "Scene mutations work again on engine 0.5.60+" ### Added diff --git a/README.md b/README.md index b21d297..0e2bcb0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ the templates present in the installed engine. The package does not silently build, host, or submit games to stores; its explicit, confirmed creator command can publish an already-exported Summer `.pck` to Summercraft for review. -**Summer** is the MIT open-source agent layer that connects your AI coding agent to Summer Engine. It is the **Summer CLI**, the **Summer MCP** server, and the **Summer agent** skills, hooks, and plugin manifests, all in one package. First-class setup works in Claude Code, Cursor, Codex, Devin Desktop (formerly Windsurf), Cline, Roo Code, Gemini CLI, GitHub Copilot CLI, GitHub Copilot in VS Code, and OpenCode. Factory Droid uses the plugin marketplace path. +**Summer** is the MIT open-source agent layer that connects your AI coding agent to Summer Engine. It is the **Summer CLI**, the **Summer MCP** server, and the **Summer agent** skills, hooks, and plugin manifests, all in one package. First-class setup works in Claude Code, Cursor, Codex, Devin Desktop (formerly Windsurf), Cline, Roo Code, Kilo Code, Gemini CLI, GitHub Copilot CLI, GitHub Copilot in VS Code, OpenCode, Bionic, and LM Studio. Factory Droid uses the plugin marketplace path. - **Source:** [github.com/SummerEngine/summer-engine-agent](https://github.com/SummerEngine/summer-engine-agent) - **CLI setup:** [www.summerengine.com/cli](https://www.summerengine.com/cli) @@ -33,7 +33,7 @@ its own license. One setup flow wires the pieces together. ## Get started: one prompt -Open your AI agent (Claude Code, Cursor, Codex, Copilot, Devin Desktop, etc.) and paste: +Open your AI agent (Claude Code, Cursor, Codex, Copilot, Bionic, Devin Desktop, etc.) and paste: > Install Summer Engine and let's make a game. Read github.com/SummerEngine/summer-engine-agent, check what I already have installed, install whatever is missing, log me in, and scaffold a starter project so we can start building. @@ -94,7 +94,7 @@ The relevant check ids are `node-version`, `cli-version`, `cli-version-current`, npx -y summer-engine@latest setup claude-code --yes --force ``` -Replace `claude-code` with the user's actual agent: `codex`, `cursor`, `windsurf`, `cline`, `roo-code`, `kilo-code`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`, or `lm-studio`. Use `github-copilot` for Copilot CLI and `vscode-copilot` for GitHub Copilot Chat/Agent in VS Code. Factory Droid still uses the plugin marketplace path in the install section below. This writes the current Summer skill bundle to `~/.claude/skills//SKILL.md` (or the agent's equivalent user-skill directory) AND writes the MCP server config so the agent can talk to the engine. The `--force` flag wipes any stale skill content first, so re-runs always end up with the latest copy. +Replace `claude-code` with the user's actual agent: `codex`, `cursor`, `windsurf`, `cline`, `roo-code`, `kilo-code`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`, `bionic`, or `lm-studio`. Use `github-copilot` for Copilot CLI and `vscode-copilot` for GitHub Copilot Chat/Agent in VS Code. Factory Droid still uses the plugin marketplace path in the install section below. For skill-capable targets, setup writes the current Summer skill bundle to the agent's user-skill directory and writes the MCP server config. The `lm-studio` target is MCP-only and uses `summer_get_agent_playbook` for in-chat guidance; the separate `bionic` target installs native Agent Skills. The `--force` flag wipes stale skill content first, so re-runs end up with the latest copy. **First-install detection:** if `~/.claude/skills/` didn't exist before this command, Claude Code wasn't watching it and won't auto-detect the new files this session. Tell the user **once**: "Skills installed - restart your agent so they load." On subsequent installs (directory already existed), skills auto-detect mid-session and no restart is needed. **You can detect first-install vs upgrade by checking if `~/.claude/skills/` existed before Step 1; record the result before running setup.** @@ -184,7 +184,7 @@ Only skip brainstorm if the user explicitly said "skip brainstorm" or "just buil - **Don't loop `summer login` if it times out at 120s.** Re-run it once and tell the user to be quicker; loop forever and the user is stuck. - **Don't jump straight into `summer create` from a vague prompt.** Invoke `summer:brainstorm-game` first (Step 6). The build skills assume `.summer/GameSoul.md` exists. -**Using a different agent?** Replace `claude-code` with any supported agent in Step 1: `codex`, `cursor`, `windsurf`, `cline`, `roo-code`, `kilo-code`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`, or `lm-studio`. (`devin` is also accepted as an alias for `windsurf`.) Skill targets vary per agent (Cursor uses `.cursor/rules/`, Devin Desktop uses `.windsurfrules`, Cline + Roo use `.clinerules/`, Copilot uses `~/.copilot/skills` or `.github/skills`, OpenCode uses agent definitions, etc.). The CLI handles the difference. After install, **Cline and Roo Code users should restart VS Code** so the extension reloads its MCP config. **Gemini users** may need to run `gemini extensions enable summer-engine` after the first install. **VS Code Copilot users** should start the `summer-engine` MCP server from Agent mode if VS Code does not autostart it. **Factory Droid** still has its own plugin path below. +**Using a different agent?** Replace `claude-code` with any supported agent in Step 1: `codex`, `cursor`, `windsurf`, `cline`, `roo-code`, `kilo-code`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`, `bionic`, or `lm-studio`. (`devin` is also accepted as an alias for `windsurf`.) Skill targets vary per agent (Cursor uses `.cursor/rules/`, Devin Desktop uses `.windsurfrules`, Cline + Roo use `.clinerules/`, Copilot uses `~/.copilot/skills` or `.github/skills`, OpenCode uses agent definitions, Bionic uses `~/.lmstudio/skills`, etc.). The CLI handles the difference. After install, **Bionic users** should enable `summer-engine` under **Settings → Connected Apps** and verify Summer skills under **Settings → Skills**. **Cline and Roo Code users** should restart VS Code so the extension reloads its MCP config. **Gemini users** may need to run `gemini extensions enable summer-engine` after the first install. **VS Code Copilot users** should start the `summer-engine` MCP server from Agent mode if VS Code does not autostart it. **Factory Droid** still has its own plugin path below. **Power-user note:** if the user specifically wants `summer` on their `PATH` for everyday terminal use outside the AI agent, a global npm install is still possible. The agent flow doesn't need it. @@ -483,13 +483,21 @@ npx -y summer-engine@latest setup opencode --yes Writes the MCP server entry into `opencode.json` (`~/.config/opencode/opencode.json` for user scope, `./opencode.json` for project) using the array-shaped `command: ["npx", "-y", "summer-engine@latest", "mcp"]` format. Restart OpenCode. Full guide: [`.opencode/INSTALL.md`](./.opencode/INSTALL.md). +### Bionic (local models and cloud-hosted open models) + +```bash +npx -y summer-engine@latest setup bionic --scope project --yes --force +``` + +Bionic is a separate agentic app from LM Studio. Run the command from the Summer game directory. It publishes Summer's MCP entry to `~/.lmstudio/mcp.json`, which Bionic discovers under **Settings → Connected Apps**, statically binds the global connection to that game, and installs native Agent Skills under `.agents/skills/`. Enable `summer-engine` under **Connected Apps**, then verify the Summer skills under **Settings → Skills**. Bionic currently does not pass its active Code Project to MCP servers, so re-run setup from a new game directory when switching games. User-scope setup without `--scope project` remains available for global skills when exactly one Summer editor is running. Full guide: [`docs/BIONIC.md`](docs/BIONIC.md). + ### LM Studio (local models) ```bash npx -y summer-engine@latest setup lm-studio --yes ``` -Writes the MCP server entry into `~/.lmstudio/mcp.json` (app-global; there is no project scope). In LM Studio, toggle the `summer-engine` server on in the **Program** tab, and raise the loaded model's context length to **32k or higher** — MCP tool schemas overflow small contexts silently. LM Studio has no rules/skills folder; the MCP server's `summer_get_agent_playbook` tool covers in-chat guidance. Pair with a tool-calling-reliable local model (gpt-oss-20b on 12–16 GB VRAM, Qwen3-Coder-30B on 24 GB). +Writes the MCP server entry into `~/.lmstudio/mcp.json` (app-global; there is no project scope). In LM Studio, toggle the `summer-engine` server on in the **Program** tab, and raise the loaded model's context length to **32k or higher** — MCP tool schemas overflow small contexts silently. The LM Studio app does not natively discover Agent Skills; the separate Bionic app does. Therefore `setup lm-studio` installs MCP only and relies on `summer_get_agent_playbook` for in-chat guidance. Pair with a tool-calling-reliable local model (gpt-oss-20b on 12–16 GB VRAM, Qwen3-Coder-30B on 24 GB). ### Ollama (local models) @@ -532,7 +540,7 @@ npx -y summer-engine@latest doctor | `summer mcp setup ` | Write MCP config for an agent. | | `summer setup [--yes]` | One shot: MCP config + recommended skills + doctor. | -Agents: `claude-code`, `codex`, `cursor`, `windsurf`, `cline`, `roo-code`, `kilo-code`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`, `lm-studio`. (`devin` and `devin-desktop` are accepted as aliases for `windsurf`.) Scopes: `--scope user` (default), `--scope project`. +Agents: `claude-code`, `codex`, `cursor`, `windsurf`, `cline`, `roo-code`, `kilo-code`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`, `bionic`, `lm-studio`. (`devin` and `devin-desktop` are accepted as aliases for `windsurf`; `lm-bionic` and `lm-studio-bionic` are accepted as aliases for `bionic`.) Scopes: `--scope user` (default), `--scope project`. --- @@ -573,6 +581,7 @@ Skills evolve fast. Two ways to help: - [Claude Code](docs/CLAUDE_CODE.md) - [Codex](docs/CODEX.md) - [Cursor](docs/CURSOR.md) +- [Bionic](docs/BIONIC.md) - [OpenCode](.opencode/INSTALL.md) - [Skills overview](docs/SKILLS.md) - [Templates](docs/TEMPLATES.md) diff --git a/docs/BIONIC.md b/docs/BIONIC.md new file mode 100644 index 0000000..d3a9b9a --- /dev/null +++ b/docs/BIONIC.md @@ -0,0 +1,56 @@ +# Bionic + +[LM Studio Bionic](https://lmstudio.ai/docs/bionic) is a separate agentic app from LM Studio. It supports local models and cloud-hosted open models, local coding projects, MCP servers, and standard [Agent Skills](https://lmstudio.ai/docs/bionic/agent/skills). + +## Recommended Setup + +Run this from the Summer game directory: + +```bash +npx -y summer-engine@latest setup bionic --scope project --yes --force +``` + +This performs two setup steps: + +- publishes the `summer-engine` stdio entry to the public [`~/.lmstudio/mcp.json`](https://lmstudio.ai/docs/app/mcp) integration file, which Bionic discovers under Connected Apps, and binds it to the current game; +- installs Summer Agent Skills under `.agents/skills//SKILL.md`. + +The published MCP registration is app-global. Bionic currently starts global MCP servers outside the active Code Project and does not advertise MCP Roots. Project-scope setup therefore pins both its stdio `cwd` and `SUMMER_ENGINE_PROJECT` to the current directory. Because Bionic does not update that global connection when its active Code Project changes, re-run the setup command from the new game directory when switching projects. + +If you always run exactly one Summer editor and prefer global skills, use user scope instead: + +```bash +npx -y summer-engine@latest setup bionic --yes --force +``` + +User scope installs skills under `~/.lmstudio/skills//SKILL.md` and leaves editor discovery automatic. + +Bionic owns its internal enabled/connection state; Summer does not write Bionic's private app-state files. + +## Enable Summer in Bionic + +1. Open **Settings → Connected Apps**. +2. Enable `summer-engine` and confirm that its tools are ready. +3. Open **Settings → Skills** and verify that the Summer skills are enabled. + +If the MCP entry does not appear after setup, restart Bionic and check **Connected Apps** again. + +## Project Skills + +For project skills without changing MCP configuration, run this from the project root (the recommended setup command above already does this): + +```bash +npx -y summer-engine@latest skills install --recommended --agent bionic --scope project +``` + +This writes standard Agent Skills to `.agents/skills//SKILL.md`, a discovery path covered by the [Bionic changelog](https://lmstudio.ai/changelog). Bionic can also expose compatible skills already installed for Codex or Claude Code through **Settings → Skills → Use skills found in other apps**. + +## Run Summer Engine + +Keep Summer Engine open on the same project while Bionic works: + +```bash +npx -y summer-engine@latest run path/to/project +``` + +Use a model with reliable tool calling and enough context for the Summer MCP tool schemas. Bionic should use Summer MCP for project files, scene/editor operations, play mode, and diagnostics. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5612037..f386156 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -23,7 +23,7 @@ the Summer product identity. 2. **MCP server**: gives AI coding agents a focused tool registry, including identity-bound project files, scene manipulation, play/stop, diagnostics, and asset import/generation 3. **Skills bundle**: the current SKILL.md playbooks that auto-trigger when the agent sees the right natural-language signal -Plus lifecycle hooks, plugin manifests, and setup targets that wire all of the above into Claude Code, Cursor, Codex, Gemini, OpenCode, GitHub Copilot CLI, GitHub Copilot in VS Code, Cline, Roo Code, Factory Droid, and Devin Desktop (formerly Windsurf). +Plus lifecycle hooks and plugin manifests. First-class setup targets wire MCP into Claude Code, Cursor, Codex, Gemini, OpenCode, GitHub Copilot CLI, GitHub Copilot in VS Code, Cline, Roo Code, Kilo Code, Devin Desktop (formerly Windsurf), Bionic, and LM Studio; skills follow each host's capabilities, so LM Studio setup is MCP-only. Factory Droid uses its plugin marketplace path. It gets published to npm as `summer-engine`. Users run it with `npx summer-engine `. diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 1d4af38..cc25af2 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -14,7 +14,7 @@ Three things, plus glue. **CLI.** Install the engine, log in, scaffold projects, run them, run doctor. The terminal side. -The glue: **lifecycle hooks** (session-start orientation, optional pre-commit doctor), plugin manifests for plugin-capable harnesses, and `summer setup` targets for Claude Code, Cursor, Codex, Gemini, OpenCode, GitHub Copilot CLI, GitHub Copilot in VS Code, Cline, Roo Code, and Devin Desktop (formerly Windsurf). +The glue: **lifecycle hooks** (session-start orientation, optional pre-commit doctor), plugin manifests for plugin-capable harnesses, and `summer setup` targets for Claude Code, Cursor, Codex, Gemini, OpenCode, GitHub Copilot CLI, GitHub Copilot in VS Code, Cline, Roo Code, Kilo Code, Devin Desktop (formerly Windsurf), Bionic, and LM Studio. ## Quick start @@ -55,10 +55,12 @@ Each agent has its own home for SKILL.md files: | `cursor` | `~/.cursor/rules` (as `summer-.mdc`) | `.cursor/rules` | | `cline` | `~/Documents/Cline/Rules` | `.clinerules` | | `roo-code` | `~/Documents/Roo/Rules` | `.clinerules` | +| `kilo-code` | `~/.kilocode/rules` | `.kilocode/rules` | | `gemini` | `~/.gemini/extensions/summer-engine/skills` | n/a | | `github-copilot` | `~/.copilot/skills` | `.github/skills` | | `vscode-copilot` | `~/.copilot/skills` | `.github/skills` | | `opencode` | `~/.config/opencode/agents/summer` | `.opencode/agents/summer` | +| `bionic` | `~/.lmstudio/skills` | `.agents/skills` | | `windsurf` (Devin Desktop) | `~/.windsurfrules` (managed blocks) | `.windsurfrules` | Use `--scope project` when you want the skills committed with the game: diff --git a/docs/SETUP_PROMPT.md b/docs/SETUP_PROMPT.md index 3ace653..a292b09 100644 --- a/docs/SETUP_PROMPT.md +++ b/docs/SETUP_PROMPT.md @@ -1,6 +1,6 @@ # Copy-Paste Setup Prompt -Use this when a human wants to install Summer Engine from Claude Code, Cursor, Codex, Devin Desktop (formerly Windsurf), Cline, Roo Code, Gemini CLI, GitHub Copilot CLI, GitHub Copilot in VS Code, or OpenCode. +Use this when a human wants to install Summer Engine from Claude Code, Cursor, Codex, Devin Desktop (formerly Windsurf), Cline, Roo Code, Kilo Code, Gemini CLI, GitHub Copilot CLI, GitHub Copilot in VS Code, OpenCode, Bionic, or LM Studio. Source: https://github.com/SummerEngine/summer-engine-agent MCP setup page: https://summerengine.com/mcp @@ -23,7 +23,7 @@ Expected agent behavior: 6. Create and run a starter project only after choosing a stable parent directory. 7. Use `summer:brainstorm-game` before building from a vague prompt. -First-class setup targets: `claude-code`, `codex`, `cursor`, `windsurf`, `cline`, `roo-code`, `kilo-code`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`, `lm-studio`. +First-class setup targets: `claude-code`, `codex`, `cursor`, `windsurf`, `cline`, `roo-code`, `kilo-code`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`, `bionic`, `lm-studio`. Factory Droid uses its plugin marketplace path today. Other older-school or adjacent surfaces worth watching are Continue, Aider, Zed, JetBrains AI/Junie, Goose, and Amp; do not claim first-class Summer setup support for those until a real config target exists. diff --git a/docs/SKILLS.md b/docs/SKILLS.md index 3f4b24e..39dd824 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -42,7 +42,7 @@ summer skills install --all --agent claude-code # All public skills summer skills install --recommended --agent cursor --scope project # Per-project ``` -Supported agents: `summer`, `codex`, `claude-code`, `cursor`, `windsurf`, `cline`, `roo-code`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`. Supported scopes: `user`, `project`. +Supported agents: `summer`, `codex`, `claude-code`, `cursor`, `windsurf`, `cline`, `roo-code`, `kilo-code`, `gemini`, `github-copilot`, `vscode-copilot`, `opencode`, `bionic`. Supported scopes: `user`, `project`. ## Recommended set diff --git a/package.json b/package.json index d8fd700..d3dbd64 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "summer-engine", "version": "2.8.1", - "description": "Local Summer CLI and MCP server for Summer Engine. Install and run the engine, connect Claude Code, Cursor, Codex, Gemini, and other agents, and build real games with bundled skills, hooks, and plugins.", + "description": "Local Summer CLI and MCP server for Summer Engine. Install and run the engine, connect Claude Code, Cursor, Codex, Bionic, Gemini, and other agents, and build real games with bundled skills, hooks, and plugins.", "keywords": [ "summer-engine", "summer-cli", @@ -17,6 +17,8 @@ "claude-code", "cursor", "codex", + "bionic", + "lm-studio", "gamedev", "game-development", "3d", diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 12ce656..a6c4ab4 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -71,9 +71,14 @@ echo "4. Create command" TMPDIR=$(mktemp -d) check "summer mcp setup cursor writes config" "SUMMER_CURSOR_MCP_CONFIG_FILE=$TMPDIR/cursor-mcp.json $CLI mcp setup cursor --scope user" check "cursor mcp config written" "grep -q 'summer-engine' $TMPDIR/cursor-mcp.json" +check "summer mcp setup bionic writes config" "SUMMER_BIONIC_CONFIG_FILE=$TMPDIR/bionic-mcp.json $CLI mcp setup bionic --scope user" +check "bionic mcp config written" "grep -q 'summer-engine' $TMPDIR/bionic-mcp.json" +check "summer mcp setup bionic binds project scope" "SUMMER_BIONIC_CONFIG_FILE=$TMPDIR/bionic-project-mcp.json $CLI mcp setup bionic --scope project" +check "bionic project config includes cwd binding" "grep -q '\"cwd\"' $TMPDIR/bionic-project-mcp.json && grep -q 'SUMMER_ENGINE_PROJECT' $TMPDIR/bionic-project-mcp.json" check "summer mcp setup codex local dev dry-run" "SUMMER_CODEX_CONFIG_FILE=$TMPDIR/codex-config.toml $CLI mcp setup codex --scope user --local-dev --dry-run" check "codex dry-run did not write" "test ! -f $TMPDIR/codex-config.toml" check "summer setup wrapper dry-run json" "SUMMER_CURSOR_MCP_CONFIG_FILE=$TMPDIR/setup-cursor-mcp.json SUMMER_SKILLS_DIR=$TMPDIR/setup-skills $CLI setup cursor --scope user --dry-run --json" +check "summer setup bionic forwards project skill scope" "SUMMER_BIONIC_CONFIG_FILE=$TMPDIR/setup-bionic-mcp.json $CLI setup bionic --scope project --dry-run --json | tr -d '\n' | grep -q '\"--scope\",[[:space:]]*\"project\"'" check "summer create empty" "$CLI create empty $TMPDIR/test-empty" check "project.godot created" "test -f $TMPDIR/test-empty/project.godot" check "main.tscn created" "test -f $TMPDIR/test-empty/main.tscn" @@ -84,6 +89,8 @@ check "summer skills install fps-controller" "SUMMER_SKILLS_DIR=$TMPDIR/summer-s check "fps-controller installed" "test -f $TMPDIR/summer-skills/fps-controller/SKILL.md" check "summer skills install --all" "SUMMER_SKILLS_DIR=$TMPDIR/summer-skills-all $CLI skills install --all" check "all skills installed" "test -f $TMPDIR/summer-skills-all/gdscript-patterns/SKILL.md" +check "summer skills install for bionic" "SUMMER_SKILLS_DIR=$TMPDIR/bionic-skills $CLI skills install fps-controller --agent bionic --scope user" +check "bionic skill installed" "test -f $TMPDIR/bionic-skills/fps-controller/SKILL.md" check "summer skills install --as-cursor-skill" "SUMMER_SKILLS_DIR=$TMPDIR/cursor-skills $CLI skills install fps-controller --as-cursor-skill" check "cursor skill installed" "test -f $TMPDIR/cursor-skills/summer-fps-controller.mdc" check "unknown template fails" "! $CLI create nonexistent $TMPDIR/test-bad 2>/dev/null" diff --git a/skills/README.md b/skills/README.md index 79f9199..9ed29a2 100644 --- a/skills/README.md +++ b/skills/README.md @@ -1,6 +1,6 @@ # Summer Skill Library -The canonical agent-readable game-dev knowledge source for Claude Code, Cursor, Codex, and Windsurf. +The canonical agent-readable game-dev knowledge source for Claude Code, Cursor, Codex, Bionic, and Windsurf. When an agent is asked to make a game in Summer Engine, this is what it reads to learn how. @@ -64,7 +64,7 @@ paths: ["**/*.gd", "**/*.tscn"] --- ``` -Cross-portable. Cursor / Codex / Windsurf understand the same SKILL.md. +Cross-portable. Cursor / Codex / Bionic / Windsurf understand the same SKILL.md. ## Authoring rules diff --git a/src/bin/summer.ts b/src/bin/summer.ts index 5b17014..25b2a3a 100644 --- a/src/bin/summer.ts +++ b/src/bin/summer.ts @@ -73,7 +73,7 @@ function printIntro(version: string): void { console.log(` ${sym.arrow()} ${c.bold("Summer Engine")} ${c.dim("v" + version)} ${c.dim("·")} AI-native game engine`); console.log(""); console.log(` ${c.bold("Setup wizard")}`); - console.log(` Open Claude Code, Cursor, Codex, Copilot, Devin Desktop (formerly Windsurf), or another supported agent and paste:`); + console.log(` Open Claude Code, Cursor, Codex, Copilot, Bionic, Devin Desktop (formerly Windsurf), or another supported agent and paste:`); console.log(` ${c.brand("\"Install Summer Engine and let's make a game.\"")}`); console.log(""); console.log(` ${c.bold("Manual commands")}`); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 46621e0..05d5ada 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -29,6 +29,7 @@ const AGENT_LABEL: Record = { "github-copilot": "GitHub Copilot CLI", "vscode-copilot": "GitHub Copilot in VS Code", opencode: "OpenCode", + bionic: "Bionic", "lm-studio": "LM Studio", }; @@ -84,6 +85,7 @@ export const setupCommand = new Command("setup") dryRun: Boolean(opts.dryRun || opts.print), yes: Boolean(opts.yes), force: Boolean(opts.force), + scope, }); const doctor = await runDoctor({ quiet: true }); @@ -133,7 +135,7 @@ function resolveScope(scopeOpt: string | undefined): ConfigScope { function setupRecommendedSkills( agent: SupportedAgent, - options: { dryRun: boolean; yes: boolean; force: boolean } + options: { dryRun: boolean; yes: boolean; force: boolean; scope: ConfigScope } ): SkillSetupResult { if (agent === "lm-studio") { return { @@ -143,7 +145,10 @@ function setupRecommendedSkills( }; } - const invocation = skillInstallInvocation(agent, { force: options.force }); + const invocation = skillInstallInvocation(agent, { + force: options.force, + scope: options.scope, + }); if (!invocation) { return { @@ -187,7 +192,10 @@ function setupRecommendedSkills( function skillInstallInvocation( agent: SupportedAgent, - opts: { force: boolean } = { force: false } + opts: { force: boolean; scope: ConfigScope } = { + force: false, + scope: "user", + } ): SkillInstallInvocation | null { const cliPath = process.argv[1]; if (!cliPath) return null; @@ -196,6 +204,10 @@ function skillInstallInvocation( const prefix = cliPath.endsWith(".js") ? [cliPath] : []; const baseArgs = ["skills", "install", "--recommended", "--agent", agent]; + // Bionic has distinct native user and project skill roots. Preserve the + // established defaults of older harnesses while honoring Bionic's explicit + // setup scope end to end. + if (agent === "bionic") baseArgs.push("--scope", opts.scope); if (opts.force) baseArgs.push("--force"); return { command, diff --git a/src/commands/skills.ts b/src/commands/skills.ts index 876b4af..c23cea0 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -207,6 +207,8 @@ function agentLabel(agent: AgentClient): string { return "GitHub Copilot in VS Code"; case "opencode": return "OpenCode"; + case "bionic": + return "Bionic"; case "summer": return "Summer"; } @@ -238,6 +240,14 @@ function resolveInstallLocation( switch (agent) { case "codex": return { kind: "skill-dir", path: join(root, ".agents", "skills") }; + case "bionic": + return { + kind: "skill-dir", + path: + scope === "user" + ? join(homedir(), ".lmstudio", "skills") + : join(process.cwd(), ".agents", "skills"), + }; case "claude-code": return { kind: "skill-dir", path: join(root, ".claude", "skills") }; case "cursor": @@ -494,6 +504,9 @@ function printInstallSummary( if (location.kind === "skill-dir") { console.log(`${label} can read skills from ${tildeified}//SKILL.md`); + if (agent === "bionic") { + console.log("Open Bionic Settings > Skills to verify or enable the Summer skills."); + } } else if (location.kind === "cursor-rule-dir") { console.log(`Cursor rules are in ${tildeified}/summer-.mdc`); } else if (location.kind === "cline-rule-dir") { diff --git a/src/lib/agent-config.test.ts b/src/lib/agent-config.test.ts index 84a1f56..c8cadfe 100644 --- a/src/lib/agent-config.test.ts +++ b/src/lib/agent-config.test.ts @@ -26,6 +26,12 @@ describe("parseAgent", () => { it("keeps windsurf as windsurf", () => { expect(parseAgent("windsurf")).toBe("windsurf"); }); + + it("maps Bionic aliases to bionic", () => { + expect(parseAgent("bionic")).toBe("bionic"); + expect(parseAgent("lm-bionic")).toBe("bionic"); + expect(parseAgent("lm-studio-bionic")).toBe("bionic"); + }); }); describe("createSummerMcpServerConfig", () => { @@ -219,6 +225,79 @@ describe("configureAgentMcp", () => { expect(result.warnings.some((w) => w.includes("no project scope"))).toBe(true); }); + it("writes a fresh bionic config in the shared LM Studio mcpServers shape", async () => { + const dir = tmp(); + const path = join(dir, "mcp.json"); + const result = await configureAgentMcp({ + agent: "bionic", + scope: "user", + env: { SUMMER_BIONIC_CONFIG_FILE: path } as NodeJS.ProcessEnv, + }); + expect(result.wrote).toBe(true); + const written = JSON.parse(readFileSync(path, "utf-8")); + expect(written.mcpServers["summer-engine"].command).toBe("npx"); + expect(written.mcpServers["summer-engine"].args).toEqual(NPX_ARGS); + expect(result.nextSteps).toContain( + "Open Bionic Settings > Connected Apps and enable the summer-engine MCP server." + ); + }); + + it("preserves unrelated Bionic MCP entries and is idempotent", async () => { + const dir = tmp(); + const path = join(dir, "mcp.json"); + writeFileSync( + path, + JSON.stringify( + { + mcpServers: { + other: { command: "node", args: ["other.js"] }, + }, + }, + null, + 2 + ) + ); + + const first = await configureAgentMcp({ + agent: "bionic", + scope: "user", + env: { SUMMER_BIONIC_CONFIG_FILE: path } as NodeJS.ProcessEnv, + }); + const second = await configureAgentMcp({ + agent: "bionic", + scope: "user", + env: { SUMMER_BIONIC_CONFIG_FILE: path } as NodeJS.ProcessEnv, + }); + + const written = JSON.parse(readFileSync(path, "utf-8")); + expect(first.wrote).toBe(true); + expect(second.wrote).toBe(false); + expect(written.mcpServers.other).toEqual({ + command: "node", + args: ["other.js"], + }); + expect(written.mcpServers["summer-engine"].args).toEqual(NPX_ARGS); + }); + + it("statically binds Bionic's global MCP entry when project scope is requested", async () => { + const dir = tmp(); + const path = join(dir, "mcp.json"); + const project = join(dir, "game"); + const result = await configureAgentMcp({ + agent: "bionic", + scope: "project", + cwd: project, + env: { SUMMER_BIONIC_CONFIG_FILE: path } as NodeJS.ProcessEnv, + }); + expect(result.wrote).toBe(true); + const written = JSON.parse(readFileSync(path, "utf-8")); + expect(written.mcpServers["summer-engine"].cwd).toBe(project); + expect(written.mcpServers["summer-engine"].env).toEqual({ + SUMMER_ENGINE_PROJECT: project, + }); + expect(result.warnings.some((w) => w.includes("statically bound"))).toBe(true); + }); + it("writes a gemini extension manifest with mcpServers entry", async () => { const dir = tmp(); const path = join(dir, "gemini-extension.json"); diff --git a/src/lib/agent-config.ts b/src/lib/agent-config.ts index 0c66ac1..7b46173 100644 --- a/src/lib/agent-config.ts +++ b/src/lib/agent-config.ts @@ -18,6 +18,7 @@ export const supportedAgents = [ "github-copilot", "vscode-copilot", "opencode", + "bionic", "lm-studio", ] as const; @@ -27,6 +28,7 @@ export type ConfigScope = "user" | "project"; export interface StdioMcpServerConfig { command: string; args: string[]; + cwd?: string; env?: Record; } @@ -88,6 +90,10 @@ const agentAliases: Record = { "github-copilot-vscode": "vscode-copilot", opencode: "opencode", "open-code": "opencode", + bionic: "bionic", + "lm-bionic": "bionic", + "lm-studio-bionic": "bionic", + lmstudiobionic: "bionic", lmstudio: "lm-studio", "lm-studio": "lm-studio", "lm_studio": "lm-studio", @@ -111,6 +117,13 @@ export async function configureAgentMcp( const env = options.env ?? process.env; const cwd = resolve(options.cwd ?? process.cwd()); const server = createSummerMcpServerConfig(Boolean(options.localDev)); + if (options.agent === "bionic" && options.scope === "project") { + // Bionic's MCP process is app-global and otherwise starts from `/`; current + // releases do not advertise MCP Roots. Bind both supported stdio channels + // so project-local setup cannot attach to an unrelated running editor. + server.cwd = cwd; + server.env = { SUMMER_ENGINE_PROJECT: cwd }; + } const target = resolveConfigTarget(options.agent, options.scope, cwd, env); const snippet = renderConfigSnippet(options.agent, server); const dryRun = Boolean(options.dryRun); @@ -250,10 +263,15 @@ function resolveConfigTarget( if (override) { if ( scope === "project" && - (agent === "cline" || agent === "roo-code" || agent === "gemini" || agent === "lm-studio") + (agent === "cline" || + agent === "roo-code" || + agent === "gemini" || + agent === "lm-studio") ) { + warnings.push(`${agent} MCP config has no project scope today; treating as user scope.`); + } else if (scope === "project" && agent === "bionic") { warnings.push( - `${agent} MCP config has no project scope today; treating as user scope.` + "Bionic stores MCP connections globally; this entry is statically bound to the current project. Re-run setup from another project to switch it." ); } return { @@ -342,10 +360,12 @@ function resolveConfigTarget( }; } - if (agent === "lm-studio") { + if (agent === "bionic" || agent === "lm-studio") { if (scope === "project") { warnings.push( - "LM Studio's MCP config is app-global (~/.lmstudio/mcp.json); treating as user scope." + agent === "bionic" + ? "Bionic stores MCP connections globally; this entry is statically bound to the current project. Re-run setup from another project to switch it." + : "LM Studio's MCP config is app-global (~/.lmstudio/mcp.json); treating as user scope." ); } return { @@ -507,6 +527,7 @@ function getConfigPathOverride( if (agent === "github-copilot") return env.SUMMER_GITHUB_COPILOT_CONFIG_FILE; if (agent === "vscode-copilot") return env.SUMMER_VSCODE_COPILOT_CONFIG_FILE; if (agent === "opencode") return env.SUMMER_OPENCODE_CONFIG_FILE; + if (agent === "bionic") return env.SUMMER_BIONIC_CONFIG_FILE; return env.SUMMER_WINDSURF_MCP_CONFIG_FILE; } @@ -859,8 +880,10 @@ function createNextSteps( ? "Restart VS Code so Roo Code reloads its MCP config." : agent === "kilo-code" ? "Restart VS Code so Kilo Code reloads its MCP config." - : agent === "lm-studio" - ? "Open LM Studio, toggle on the summer-engine MCP server in the Program tab, and raise the loaded model's context length to 32k or higher." + : agent === "bionic" + ? "Open Bionic Settings > Connected Apps and enable the summer-engine MCP server." + : agent === "lm-studio" + ? "Open LM Studio, toggle on the summer-engine MCP server in the Program tab, and raise the loaded model's context length to 32k or higher." : agent === "gemini" ? "Run `gemini extensions enable summer-engine` (if not already enabled), then restart Gemini CLI." : agent === "github-copilot" diff --git a/src/lib/skills-registry.test.ts b/src/lib/skills-registry.test.ts new file mode 100644 index 0000000..19beac1 --- /dev/null +++ b/src/lib/skills-registry.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { AGENT_CLIENTS, SKILL_REGISTRY } from "./skills-registry.js"; + +describe("Bionic skill support", () => { + it("registers Bionic as a skill client", () => { + expect(AGENT_CLIENTS).toContain("bionic"); + }); + + it("makes every public Summer skill available to Bionic", () => { + const unsupported = SKILL_REGISTRY.filter( + (skill) => skill.public && !skill.clients.includes("bionic") + ).map((skill) => skill.name); + + expect(unsupported).toEqual([]); + }); +}); diff --git a/src/lib/skills-registry.ts b/src/lib/skills-registry.ts index be931ab..41f3d6e 100644 --- a/src/lib/skills-registry.ts +++ b/src/lib/skills-registry.ts @@ -11,6 +11,7 @@ export const AGENT_CLIENTS = [ "github-copilot", "vscode-copilot", "opencode", + "bionic", ] as const; export type AgentClient = (typeof AGENT_CLIENTS)[number]; diff --git a/src/lib/version-check.test.ts b/src/lib/version-check.test.ts index 1dd5e2c..4616171 100644 --- a/src/lib/version-check.test.ts +++ b/src/lib/version-check.test.ts @@ -9,12 +9,26 @@ import { classifyDrift, compareSemver, fetchLatestRegistryVersion, + defaultSkillMarkerCandidates, parseSemver, readSkillMarker, SKILL_VERSION_MARKER_FILENAME, writeSkillMarker, } from "./version-check.js"; +describe("defaultSkillMarkerCandidates", () => { + it("includes Bionic's native global skills directory", () => { + expect(defaultSkillMarkerCandidates()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agent: "bionic", + dir: expect.stringMatching(/\.lmstudio[\\/]skills$/), + }), + ]) + ); + }); +}); + describe("parseSemver", () => { it("parses normal versions", () => { expect(parseSemver("2.3.0")).toEqual({ major: 2, minor: 3, patch: 0 }); diff --git a/src/lib/version-check.ts b/src/lib/version-check.ts index e20cf53..b69f899 100644 --- a/src/lib/version-check.ts +++ b/src/lib/version-check.ts @@ -296,6 +296,7 @@ export function defaultSkillMarkerCandidates(): SkillMarkerCandidate[] { const candidates: SkillMarkerCandidate[] = [ { agent: "claude-code", dir: join(home, ".claude", "skills") }, { agent: "codex", dir: join(home, ".agents", "skills") }, + { agent: "bionic", dir: join(home, ".lmstudio", "skills") }, { agent: "summer", dir: join(home, ".summer", "skills") }, { agent: "cline", dir: join(home, "Documents", "Cline", "Rules") }, { agent: "roo-code", dir: join(home, "Documents", "Roo", "Rules") }, diff --git a/src/mcp/client-roots.test.ts b/src/mcp/client-roots.test.ts new file mode 100644 index 0000000..fd5b6af --- /dev/null +++ b/src/mcp/client-roots.test.ts @@ -0,0 +1,192 @@ +import { mkdtemp, mkdir, realpath, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { rm } from "node:fs/promises"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ListRootsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +import { + installClientRootsHandlers, + resolveClientRootSelection, +} from "./client-roots.js"; + +const temporaryDirectories: string[] = []; + +async function makeProject(name: string): Promise { + const parent = await mkdtemp(join(tmpdir(), `summer-mcp-roots-${name}-`)); + temporaryDirectories.push(parent); + const root = join(parent, name); + await mkdir(join(root, "nested"), { recursive: true }); + await writeFile(join(root, "project.godot"), "[application]\n", "utf8"); + return realpath(root); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => + rm(path, { recursive: true, force: true }) + ) + ); +}); + +describe("resolveClientRootSelection", () => { + it("binds a file root to the containing Summer project", async () => { + const project = await makeProject("game"); + const result = await resolveClientRootSelection([ + { uri: pathToFileURL(join(project, "nested")).href, name: "Game" }, + ]); + + expect(result.error).toBeUndefined(); + expect(result.selection).toEqual({ projectPath: project, cwd: project }); + expect(result.projectRoots).toEqual([project]); + }); + + it("decodes spaces and accepts a project.godot file root", async () => { + const project = await makeProject("game with spaces"); + const result = await resolveClientRootSelection([ + { uri: pathToFileURL(join(project, "project.godot")).href }, + ]); + + expect(result.selection).toEqual({ projectPath: project, cwd: project }); + }); + + it("deduplicates multiple client roots inside the same project", async () => { + const project = await makeProject("game"); + const result = await resolveClientRootSelection([ + { uri: pathToFileURL(project).href }, + { uri: pathToFileURL(join(project, "nested")).href }, + ]); + + expect(result.selection?.projectPath).toBe(project); + expect(result.projectRoots).toEqual([project]); + }); + + it("refuses roots that resolve to different Summer projects", async () => { + const first = await makeProject("first"); + const second = await makeProject("second"); + const result = await resolveClientRootSelection([ + { uri: pathToFileURL(first).href }, + { uri: pathToFileURL(second).href }, + ]); + + expect(result.selection).toBeUndefined(); + expect(result.error).toContain("more than one Summer project root"); + }); + + it("ignores non-file and malformed roots without guessing a project", async () => { + const result = await resolveClientRootSelection([ + { uri: "https://example.com/project" }, + { uri: "not a URI" }, + ]); + + expect(result.selection).toBeUndefined(); + expect(result.error).toContain("local filesystem root"); + }); + + it("reports a local root that is not inside a Summer project", async () => { + const directory = await mkdtemp(join(tmpdir(), "summer-mcp-roots-empty-")); + temporaryDirectories.push(directory); + const result = await resolveClientRootSelection([ + { uri: pathToFileURL(directory).href }, + ]); + + expect(result.selection).toBeUndefined(); + expect(result.error).toContain("No project.godot"); + }); +}); + +describe("installClientRootsHandlers", () => { + it("does not request roots from a client that did not advertise them", async () => { + const server = new McpServer({ name: "server", version: "1" }); + const received: string[][] = []; + const statuses: string[] = []; + const errors: unknown[] = []; + installClientRootsHandlers(server, { + onRefresh: async (loadRoots) => { + try { + const roots = await loadRoots(); + received.push(roots.map((root) => root.uri)); + } catch (error) { + errors.push(error); + } + }, + onStatus: (status) => statuses.push(status), + }); + const client = new Client( + { name: "client", version: "1" }, + { capabilities: {} } + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + await client.connect(clientTransport); + await expect.poll(() => statuses).toEqual(["unsupported"]); + expect(received).toEqual([]); + expect(errors).toEqual([]); + await client.close(); + await server.close(); + }); + + it("binds initial roots and refreshes when listChanged is advertised", async () => { + const server = new McpServer({ name: "server", version: "1" }); + const received: string[][] = []; + installClientRootsHandlers(server, { + onRefresh: async (loadRoots) => { + const roots = await loadRoots(); + received.push(roots.map((root) => root.uri)); + }, + }); + const client = new Client( + { name: "client", version: "1" }, + { capabilities: { roots: { listChanged: true } } } + ); + let roots = [{ uri: "file:///first" }]; + client.setRequestHandler(ListRootsRequestSchema, async () => ({ roots })); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + await client.connect(clientTransport); + await expect.poll(() => received.length).toBe(1); + roots = [{ uri: "file:///second" }]; + await client.sendRootsListChanged(); + await expect.poll(() => received.length).toBe(2); + expect(received).toEqual([["file:///first"], ["file:///second"]]); + await client.close(); + await server.close(); + }); + + it("ignores list-changed notifications unless the client advertised them", async () => { + const server = new McpServer({ name: "server", version: "1" }); + const received: string[][] = []; + installClientRootsHandlers(server, { + onRefresh: async (loadRoots) => { + const roots = await loadRoots(); + received.push(roots.map((root) => root.uri)); + }, + }); + const client = new Client( + { name: "client", version: "1" }, + { capabilities: { roots: {} } } + ); + client.setRequestHandler(ListRootsRequestSchema, async () => ({ + roots: [{ uri: "file:///only" }], + })); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + await client.connect(clientTransport); + await expect.poll(() => received.length).toBe(1); + await clientTransport.send({ + jsonrpc: "2.0", + method: "notifications/roots/list_changed", + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(received).toHaveLength(1); + await client.close(); + await server.close(); + }); +}); diff --git a/src/mcp/client-roots.ts b/src/mcp/client-roots.ts new file mode 100644 index 0000000..e0e937a --- /dev/null +++ b/src/mcp/client-roots.ts @@ -0,0 +1,127 @@ +import { fileURLToPath } from "node:url"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; +import { findProjectRoot, type EngineSelection } from "../lib/engine.js"; + +export interface McpClientRoot { + uri: string; + name?: string; +} + +export interface ClientRootResolution { + selection?: EngineSelection; + projectRoots: string[]; + fileRoots: string[]; + error?: string; +} + +export interface ClientRootsHandlerOptions { + disabled?: boolean; + onRefresh: ( + loadRoots: () => Promise + ) => void | Promise; + onStatus?: ( + status: "disabled" | "unsupported", + ) => void | Promise; +} + +/** Install capability-gated MCP Roots discovery on a high-level server. */ +export function installClientRootsHandlers( + server: McpServer, + options: ClientRootsHandlerOptions +): void { + let rootsEnabled = false; + let listChangedEnabled = false; + const refresh = (): void | Promise => + options.onRefresh(async () => (await server.server.listRoots()).roots); + + server.server.setNotificationHandler( + RootsListChangedNotificationSchema, + async () => { + if (rootsEnabled && listChangedEnabled && !options.disabled) { + await refresh(); + } + } + ); + server.server.oninitialized = async () => { + if (options.disabled) { + await options.onStatus?.("disabled"); + return; + } + const capability = server.server.getClientCapabilities()?.roots; + rootsEnabled = Boolean(capability); + listChangedEnabled = capability?.listChanged === true; + if (!rootsEnabled) { + await options.onStatus?.("unsupported"); + return; + } + await refresh(); + }; +} + +function filePathFromRoot(root: McpClientRoot): string | null { + try { + const url = new URL(root.uri); + if (url.protocol !== "file:") return null; + return fileURLToPath(url); + } catch { + return null; + } +} + +/** + * Resolve an MCP client's workspace roots to one Summer project. + * + * Roots are advisory filesystem boundaries, so each file root may point at a + * project directory or anywhere below it. We deliberately refuse ambiguous + * root sets instead of falling back to whichever Summer editor happened to + * start first. + */ +export async function resolveClientRootSelection( + roots: McpClientRoot[] +): Promise { + const fileRoots = Array.from( + new Set( + roots + .map(filePathFromRoot) + .filter((path): path is string => path !== null) + ) + ); + const discovered = await Promise.all( + fileRoots.map((path) => findProjectRoot(path)) + ); + const projectRoots = Array.from( + new Set(discovered.filter((path): path is string => path !== null)) + ); + + if (projectRoots.length === 1) { + return { + selection: { + projectPath: projectRoots[0], + cwd: projectRoots[0], + }, + projectRoots, + fileRoots, + }; + } + + if (projectRoots.length > 1) { + return { + projectRoots, + fileRoots, + error: + "The MCP client exposed more than one Summer project root. " + + "Open a single project workspace, or configure `summer mcp --project ` explicitly.", + }; + } + + return { + projectRoots, + fileRoots, + error: + fileRoots.length === 0 + ? "The MCP client did not expose a local filesystem root for the active project." + : "No project.godot was found at or above the MCP client root" + + (fileRoots.length === 1 ? ` ${fileRoots[0]}.` : "s."), + }; +} diff --git a/src/mcp/server.selection-race.test.ts b/src/mcp/server.selection-race.test.ts new file mode 100644 index 0000000..fb8be39 --- /dev/null +++ b/src/mcp/server.selection-race.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface FakeClient { + id: string; + credentialsChanged: () => Promise; +} + +const { connect } = vi.hoisted(() => ({ + connect: vi.fn<( + selection?: { projectPath?: string } + ) => Promise>(), +})); + +vi.mock("../lib/api-client.js", () => ({ + EngineApiClient: { connect }, +})); + +import { + configureMcpEngineSelection, + getClient, + queueMcpEngineSelectionRefresh, +} from "./server.js"; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function client(id: string): FakeClient { + return { id, credentialsChanged: vi.fn(async () => false) }; +} + +beforeEach(() => { + connect.mockReset(); + configureMcpEngineSelection({ projectPath: "/initial" }); +}); + +describe("dynamic MCP engine selection", () => { + it("blocks connection until an initial roots refresh completes", async () => { + const roots = deferred(); + const current = client("current"); + connect.mockResolvedValue(current); + + const refresh = queueMcpEngineSelectionRefresh(async () => { + await roots.promise; + configureMcpEngineSelection({ projectPath: "/from-roots" }); + }); + const pending = getClient(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(connect).not.toHaveBeenCalled(); + + roots.resolve(); + await refresh; + await expect(pending).resolves.toBe(current); + expect(connect).toHaveBeenCalledWith({ projectPath: "/from-roots" }); + }); + + it("waits for a newer roots refresh queued while an older one is pending", async () => { + const firstRoots = deferred(); + const secondRoots = deferred(); + const current = client("current"); + connect.mockResolvedValue(current); + + const firstRefresh = queueMcpEngineSelectionRefresh(() => firstRoots.promise); + const pending = getClient(); + const secondRefresh = queueMcpEngineSelectionRefresh(() => secondRoots.promise); + firstRoots.resolve(); + await firstRefresh; + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(connect).not.toHaveBeenCalled(); + + secondRoots.resolve(); + await secondRefresh; + await expect(pending).resolves.toBe(current); + expect(connect).toHaveBeenCalledTimes(1); + }); + + it("discards an in-flight connection when the project selection changes", async () => { + const firstConnect = deferred(); + const stale = client("stale"); + const current = client("current"); + connect + .mockImplementationOnce(() => firstConnect.promise) + .mockResolvedValueOnce(current); + + const pending = getClient(); + await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + configureMcpEngineSelection({ projectPath: "/current" }); + firstConnect.resolve(stale); + + await expect(pending).resolves.toBe(current); + expect(connect).toHaveBeenNthCalledWith(1, { projectPath: "/initial" }); + expect(connect).toHaveBeenNthCalledWith(2, { projectPath: "/current" }); + }); + + it("does not return a cached client cleared during credential validation", async () => { + const check = deferred(); + const stale: FakeClient = { + id: "stale", + credentialsChanged: vi.fn(() => check.promise), + }; + const current = client("current"); + connect.mockResolvedValueOnce(stale).mockResolvedValueOnce(current); + + await expect(getClient()).resolves.toBe(stale); + const pending = getClient(); + await vi.waitFor(() => + expect(stale.credentialsChanged).toHaveBeenCalledTimes(1) + ); + configureMcpEngineSelection({ projectPath: "/current" }); + check.resolve(false); + + await expect(pending).resolves.toBe(current); + }); +}); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index cfd4389..ec041f9 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,7 +2,11 @@ import { createRequire } from "node:module"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { EngineApiClient } from "../lib/api-client.js"; -import type { EngineSelection } from "../lib/engine.js"; +import { findProjectRoot, type EngineSelection } from "../lib/engine.js"; +import { + installClientRootsHandlers, + resolveClientRootSelection, +} from "./client-roots.js"; import { registerSceneTools } from "./tools/scene-tools.js"; import { registerDebugTools } from "./tools/debug-tools.js"; import { registerVisualTools } from "./tools/visual-tools.js"; @@ -45,44 +49,99 @@ async function probeBootDrift(): Promise { let cachedClient: EngineApiClient | null = null; let engineSelection: EngineSelection | undefined; +let engineSelectionError: string | null = null; +let engineSelectionReady: Promise = Promise.resolve(); +let engineSelectionGeneration = 0; -export function configureMcpEngineSelection( - selection?: EngineSelection +function applyMcpEngineSelection( + selection: EngineSelection | undefined, + error: string | null = null ): void { engineSelection = selection ? { ...selection } : undefined; + engineSelectionError = error; + engineSelectionGeneration += 1; cachedClient = null; } +export function configureMcpEngineSelection( + selection?: EngineSelection +): void { + applyMcpEngineSelection(selection); + engineSelectionReady = Promise.resolve(); +} + +export function queueMcpEngineSelectionRefresh( + refreshSelection: () => void | Promise +): Promise { + const refresh = engineSelectionReady + .catch(() => undefined) + .then(refreshSelection); + // Assign before refreshSelection runs, so an immediate tool call cannot use + // the previous cwd while the client roots request is still in flight. + engineSelectionReady = refresh; + return refresh; +} + export async function getClient(): Promise { - if (cachedClient) { - // The engine rotates its api-token (and can change ports) on every launch, so - // a cached client can outlive the engine instance it was built for. If the - // on-disk creds drifted, a silent engine restart happened — drop the stale - // client and reconnect transparently, instead of surfacing the resulting 401 - // as a "disconnected from the project" error. - if (await cachedClient.credentialsChanged()) { - cachedClient = null; - } else { - return cachedClient; + while (true) { + const selectionGate = engineSelectionReady; + await selectionGate; + if (selectionGate !== engineSelectionReady) continue; + if (engineSelectionError) { + throw new Error( + engineSelectionError + "\n" + + "Open the intended Summer project in your MCP client's workspace, or run " + + "`summer mcp --project `." + ); } - } - try { - cachedClient = await EngineApiClient.connect(engineSelection); - return cachedClient; - } catch (error) { - cachedClient = null; - const reason = - error instanceof Error - ? error.message - : "Summer Engine is not running."; - throw new Error( - reason + "\n" + - "Open the intended project in Summer Engine, or run: npx summer-engine run\n" + - "Note: only tools that touch the local project need the engine. Cloud tools " + - "(summer_generate_*, summer_search_assets, summer_list_my_assets, summer_get_asset, " + - "summer_check_job) work right now without it — they only need 'npx summer-engine login'." - ); + const generation = engineSelectionGeneration; + const existingClient = cachedClient; + if (existingClient) { + // The engine rotates its api-token (and can change ports) on every launch, so + // a cached client can outlive the engine instance it was built for. If the + // on-disk creds drifted, a silent engine restart happened — drop the stale + // client and reconnect transparently, instead of surfacing the resulting 401 + // as a "disconnected from the project" error. + if (await existingClient.credentialsChanged()) { + if (cachedClient === existingClient) cachedClient = null; + } else if ( + generation === engineSelectionGeneration && + selectionGate === engineSelectionReady && + cachedClient === existingClient + ) { + return existingClient; + } + continue; + } + + const selection = engineSelection ? { ...engineSelection } : undefined; + try { + const connectedClient = await EngineApiClient.connect(selection); + if ( + generation !== engineSelectionGeneration || + selectionGate !== engineSelectionReady + ) continue; + cachedClient = connectedClient; + return connectedClient; + } catch (error) { + if ( + generation !== engineSelectionGeneration || + selectionGate !== engineSelectionReady + ) continue; + cachedClient = null; + const reason = + error instanceof Error + ? error.message + : "Summer Engine is not running."; + throw new Error( + reason + "\n" + + "Open the intended project in Summer Engine, or run: npx summer-engine run\n" + + "Note: only tools that touch the local project need the engine. Cloud tools " + + "(summer_generate_*, summer_search_assets, summer_list_my_assets, summer_get_asset, " + + "summer_check_job) work right now without it — they only need 'npx summer-engine login'." + ); + } } } @@ -262,13 +321,16 @@ export interface StartMcpServerOptions { export async function startMcpServer( options: StartMcpServerOptions = {} ): Promise { - configureMcpEngineSelection({ - instanceId: - options.instanceId ?? process.env.SUMMER_ENGINE_INSTANCE_ID, - projectPath: - options.projectPath ?? process.env.SUMMER_ENGINE_PROJECT, + const configuredInstanceId = + options.instanceId ?? process.env.SUMMER_ENGINE_INSTANCE_ID; + const configuredProjectPath = + options.projectPath ?? process.env.SUMMER_ENGINE_PROJECT; + const baseSelection: EngineSelection = { + instanceId: configuredInstanceId, + projectPath: configuredProjectPath, cwd: options.cwd ?? process.cwd(), - }); + }; + configureMcpEngineSelection(baseSelection); installMcpProcessDiagnostics(); appendMcpLogEvent("mcp:start", { version, @@ -287,6 +349,76 @@ export async function startMcpServer( version, }); + // Some app-global MCP configurations launch stdio servers outside the active + // workspace, so process.cwd() cannot identify the project. MCP Roots is the + // protocol-native project context when a client advertises it. Explicit + // CLI/env binding still wins, and roots ambiguity fails closed instead of + // selecting an unrelated running editor. + const explicitEngineSelection = Boolean( + configuredInstanceId?.trim() || configuredProjectPath?.trim() + ); + const applyClientRoots = async ( + roots: Array<{ uri: string; name?: string }> + ): Promise => { + const resolution = await resolveClientRootSelection(roots); + let status = resolution.selection ? "bound" : "rejected"; + if (resolution.selection) { + applyMcpEngineSelection({ ...baseSelection, ...resolution.selection }); + } else if ( + resolution.projectRoots.length === 0 && + baseSelection.cwd && + (await findProjectRoot(baseSelection.cwd)) + ) { + // Empty/non-Summer roots are legal. Preserve an already-valid cwd for + // clients launched inside the project, but never let an app-global cwd + // silently fall through to an unrelated sole editor. + applyMcpEngineSelection(baseSelection); + status = "cwd_fallback"; + } else { + applyMcpEngineSelection( + baseSelection, + resolution.error ?? "Unable to resolve the MCP client project root." + ); + } + appendMcpLogEvent("mcp:roots", { + status, + rootCount: roots.length, + fileRootCount: resolution.fileRoots.length, + projectRootCount: resolution.projectRoots.length, + projectPath: resolution.selection?.projectPath, + error: status === "rejected" ? resolution.error : undefined, + }); + }; + installClientRootsHandlers(server, { + disabled: explicitEngineSelection, + onRefresh: (loadRoots) => + queueMcpEngineSelectionRefresh(async () => { + try { + await applyClientRoots(await loadRoots()); + } catch (error) { + const safeCwd = baseSelection.cwd + ? await findProjectRoot(baseSelection.cwd) + : null; + if (safeCwd) { + applyMcpEngineSelection(baseSelection); + } else { + const reason = + error instanceof Error ? error.message : String(error); + applyMcpEngineSelection( + baseSelection, + "The MCP client advertised project roots, but roots/list failed: " + reason + ); + } + appendMcpLogEvent("mcp:roots_failed", errorDetails(error)); + } + }), + onStatus: (status) => { + appendMcpLogEvent("mcp:roots", { + status: status === "disabled" ? "explicit_selection" : "unsupported", + }); + }, + }); + // Passive observability: log result-size to stderr but do not modify // results. See installResultSizeLogger above. const getRegisteredToolCount = installResultSizeLogger(