From c25563319918bca5efa69c71cd3228c7b8c64804 Mon Sep 17 00:00:00 2001 From: Tim Date: Sun, 23 Aug 2026 22:22:07 +0200 Subject: [PATCH] feat(setup): add first-class Bionic support Rebased onto v3.0.0. Ports the original change (168dc50) onto the v3 layout: agent-config and version-check under src/installer/, setup and skills commands under src/cli/commands/, the skills install location under src/installer/skill-locations.ts, AGENT_CLIENTS under src/core/skills-registry.ts, the CLI intro under src/cli/index.ts, and engine imports from src/lib/ -> src/core/. Bionic is also mapped as a v3 integration: integrations/bionic/ (README + empty manifest-target) plus a row in integrations/README.md and MANIFEST_TARGETS. The skills-registry per-skill clients concept was deleted at the v3 cutover (skills are agent-agnostic now), so only the AGENT_CLIENTS containment test was carried over. The v3 getClient keeps its opt-in headless routing block inside the new selection race-gate loop. --- CHANGELOG.md | 6 + README.md | 11 +- docs/BIONIC.md | 56 ++++++ docs/OVERVIEW.md | 4 +- docs/SETUP_PROMPT.md | 4 +- docs/SKILLS.md | 2 +- integrations/README.md | 1 + integrations/bionic/README.md | 32 ++++ integrations/bionic/manifest-target.json | 3 + package.json | 4 +- scripts/generate-registry/targets.ts | 1 + scripts/smoke-test.sh | 7 + src/cli/commands/setup.ts | 1 + src/cli/commands/skills.ts | 5 + src/cli/index.ts | 2 +- src/core/skills-registry.test.ts | 7 + src/core/skills-registry.ts | 1 + src/installer/agent-config.test.ts | 79 ++++++++ src/installer/agent-config.ts | 35 +++- src/installer/skill-locations.ts | 8 + src/installer/version-check.test.ts | 14 ++ src/installer/version-check.ts | 1 + src/mcp/client-roots.test.ts | 192 +++++++++++++++++++ src/mcp/client-roots.ts | 127 +++++++++++++ src/mcp/server.selection-race.test.ts | 121 ++++++++++++ src/mcp/server.ts | 231 ++++++++++++++++++----- 26 files changed, 888 insertions(+), 67 deletions(-) create mode 100644 docs/BIONIC.md create mode 100644 integrations/bionic/README.md create mode 100644 integrations/bionic/manifest-target.json create mode 100644 src/mcp/client-roots.test.ts create mode 100644 src/mcp/client-roots.ts create mode 100644 src/mcp/server.selection-race.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7133414a..ea6b0136 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. + ## [3.0.0] (2026-09-09): "The Library" v3 rebuilds the package around one idea: every resource is described once (`library///resource.yaml`) and everything else (the searchable index, every agent manifest, the skill and template registries, counts, aliases) is generated from it, with CI failing on drift. Migrating from v2: `docs/MIGRATION-V2-V3.md`. The design contract and the verified-vs-planned status live in the repository under `docs/design/`. diff --git a/README.md b/README.md index fe75a41b..086f0066 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ You stay in control of every change and release. Summer never publishes, install ## 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, check what I already have installed, install whatever is missing, log me in, and scaffold a starter project so we can start building. @@ -93,7 +93,7 @@ The check ids are `node-version`, `cli-version`, `cli-version-current`, `skills- 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 uses the plugin marketplace path (see [`integrations/README.md`](integrations/README.md)). This installs **every skill in the library** (`status: preview` skills included — they are labelled as preview in their guidance, and `--stable-only` skips them) 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, then runs doctor and prints what it installed and where. The `--force` flag wipes any stale skill content first, so re-runs always end up with the latest copy. `--recommended` installs only the recommended subset — don't use it from this playbook. +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 uses the plugin marketplace path (see [`integrations/README.md`](integrations/README.md)). This installs **every skill in the library** (`status: preview` skills included — they are labelled as preview in their guidance, and `--stable-only` skips them) 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, then runs doctor and prints what it installed and where. 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 any stale skill content first, so re-runs always end up with the latest copy. `--recommended` installs only the recommended subset — don't use it from this playbook. **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.** @@ -186,7 +186,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 the `brainstorm-game` skill 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; the CLI handles the difference, and [`integrations/README.md`](integrations/README.md) documents exactly what gets written where for every client. 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. +**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 (Bionic uses `~/.lmstudio/skills`); the CLI handles the difference, and [`integrations/README.md`](integrations/README.md) documents exactly what gets written where for every client. 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. **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. @@ -313,7 +313,6 @@ Not downloaded, not collected: A command, tool contract, or roadmap entry does not by itself mean a hosted service is production-ready. Managed publishing, hosting, store submission, and matchmaking are not promised by this package. - ## CLI reference | Command | What it does | @@ -344,7 +343,7 @@ matchmaking are not promised by this package. | `summer mcp setup ` | Deprecated alias of `summer setup `. | | `summer setup [--yes] [--force] [--recommended] [--stable-only]` | One shot: MCP config + all skills, preview included (`--recommended` for the subset, `--stable-only` to skip preview) + doctor. Idempotent. | -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`. ## Contributing @@ -362,7 +361,7 @@ Testing an unpublished build end to end against the real engine and a real agent - [Development guide](docs/DEVELOPMENT.md) · [v2 → v3 migration](docs/MIGRATION-V2-V3.md) - [Design: contract](docs/design/CONTRACT.md) · [decisions](docs/design/DECISIONS.md) · [roadmap](docs/design/ROADMAP.md) - [Agent support map](integrations/README.md) · [Template pinning](library/templates/README.md) · [Evals](evals/README.md) -- Per-host notes: [Claude Code](docs/CLAUDE_CODE.md) · [Codex](docs/CODEX.md) · [Cursor](docs/CURSOR.md) · [OpenCode](.opencode/INSTALL.md) +- Per-host notes: [Claude Code](docs/CLAUDE_CODE.md) · [Codex](docs/CODEX.md) · [Cursor](docs/CURSOR.md) · [Bionic](docs/BIONIC.md) · [OpenCode](.opencode/INSTALL.md) ## License diff --git a/docs/BIONIC.md b/docs/BIONIC.md new file mode 100644 index 00000000..d3a9b9a3 --- /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/OVERVIEW.md b/docs/OVERVIEW.md index 877c0ffa..894c488c 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 — and `summer tool ` runs any MCP tool from the terminal. The complete command reference is in [`DEVELOPMENT.md`](DEVELOPMENT.md#cli-command-reference); `summer --help` is the source of truth. -The glue: **lifecycle hooks** (session-start orientation, opt-in 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, LM Studio, and Devin Desktop (formerly Windsurf). The per-client map is [`../integrations/README.md`](../integrations/README.md). +The glue: **lifecycle hooks** (session-start orientation, opt-in 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, Bionic, LM Studio, and Devin Desktop (formerly Windsurf). The per-client map is [`../integrations/README.md`](../integrations/README.md). ## 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 8cdee2e4..26d25b8b 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 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 the `brainstorm-game` skill 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 0f6de880..d47525cd 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -43,7 +43,7 @@ summer skills install --all --stable-only --agent claude-code # Stable 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/integrations/README.md b/integrations/README.md index 7622496f..7eed675c 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -65,6 +65,7 @@ How the plugin manifests reference skills, and what is verified: | vscode-copilot | — | MCP: VS Code user-profile `mcp.json` / `.vscode/mcp.json`; skills: `~/.copilot/skills/` or `.github/skills/` | | opencode | — (JS plugin via npm `main`, `.opencode/plugins/summer.js`) | MCP entry in `opencode.json` (`type: "local"`, array `command`); skills: the plugin registers `library/skills/` via `skills.paths`; `skills install --agent opencode` additionally writes `agents/summer/` markdown | | lm-studio | — | MCP: `~/.lmstudio/mcp.json` (app-global); no skills folder — guidance via `summer_get_agent_playbook` | +| bionic | — | MCP: `~/.lmstudio/mcp.json` (app-global; `--scope project` statically binds the entry to the current game via stdio `cwd` + `SUMMER_ENGINE_PROJECT`); skills: `~/.lmstudio/skills/` (user) / `.agents/skills/` (project); MCP Roots-based project routing when the client advertises it (`src/mcp/client-roots.ts`) | Source of truth for the setup paths: `src/installer/agent-config.ts` and `src/cli/commands/skills.ts`. diff --git a/integrations/bionic/README.md b/integrations/bionic/README.md new file mode 100644 index 00000000..99335771 --- /dev/null +++ b/integrations/bionic/README.md @@ -0,0 +1,32 @@ +# Bionic integration + +No manifest file is generated in this repo for Bionic — +`manifest-target.json` is intentionally empty. Support is delivered at install +time by `summer setup bionic`, which writes: + +- MCP config: `~/.lmstudio/mcp.json` — the public integration file Bionic + discovers under **Settings → Connected Apps**. The file is app-global; when + `--scope project` is requested, the entry is statically bound to the current + project instead (its stdio `cwd` and `SUMMER_ENGINE_PROJECT` are pinned to + the game directory, and setup warns that re-running from another project + switches the binding). Bionic currently starts global MCP servers outside + the active Code Project and does not update the connection when the project + changes, so re-run setup from the new game directory when switching games. + The MCP server also honours MCP Roots when a client advertises them + (`src/mcp/client-roots.ts`): a single unambiguous Summer project root binds + the session, ambiguity fails closed, and explicit CLI/env binding wins. +- Skills: `summer setup bionic` installs the whole library in the same scope as + the MCP config — `~/.lmstudio/skills//SKILL.md` (user) or + `.agents/skills//SKILL.md` (project). After install, enable + `summer-engine` under **Settings → Connected Apps** and verify the Summer + skills under **Settings → Skills**. + +Bionic is a separate agentic app from LM Studio; the `lm-studio` setup target +remains MCP-only. Bionic owns its internal enabled/connection state; Summer +does not write Bionic's private app-state files. + +Full guide: `docs/BIONIC.md`. Aliases: `bionic`, `lm-bionic`, +`lm-studio-bionic`, `lmstudiobionic`. + +Source of truth: `src/installer/agent-config.ts`, +`src/installer/skill-locations.ts`, `src/installer/version-check.ts`. diff --git a/integrations/bionic/manifest-target.json b/integrations/bionic/manifest-target.json new file mode 100644 index 00000000..476c8435 --- /dev/null +++ b/integrations/bionic/manifest-target.json @@ -0,0 +1,3 @@ +{ + "targets": [] +} diff --git a/package.json b/package.json index acec5061..037c9389 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "summer-engine", "version": "3.0.0", - "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/generate-registry/targets.ts b/scripts/generate-registry/targets.ts index 0fe12e00..bf27964c 100644 --- a/scripts/generate-registry/targets.ts +++ b/scripts/generate-registry/targets.ts @@ -40,6 +40,7 @@ export const MANIFEST_TARGETS: Record = { "github-copilot": [], "vscode-copilot": [], "lm-studio": [], + bionic: [], // OpenCode consumes the package as a JS module (.opencode/plugins/summer.js) // and auto-discovers skills from disk — no generated manifest today. opencode: [], diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 12ce6567..a6c4ab42 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/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 541d881e..3533ca5a 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -25,6 +25,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", }; diff --git a/src/cli/commands/skills.ts b/src/cli/commands/skills.ts index fb1ec7f7..b9000158 100644 --- a/src/cli/commands/skills.ts +++ b/src/cli/commands/skills.ts @@ -125,6 +125,8 @@ function agentLabel(agent: AgentClient): string { return "GitHub Copilot in VS Code"; case "opencode": return "OpenCode"; + case "bionic": + return "Bionic"; case "summer": return "Summer"; } @@ -316,6 +318,9 @@ function printInstallSummary( "Gemini loads them as extension skills; run `summer setup gemini` once so the extension manifest exists, then restart Gemini CLI." ); } + 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/cli/index.ts b/src/cli/index.ts index 2c07ea48..5980eb9a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.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/core/skills-registry.test.ts b/src/core/skills-registry.test.ts index 0e506717..c86f3654 100644 --- a/src/core/skills-registry.test.ts +++ b/src/core/skills-registry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + AGENT_CLIENTS, parseSkillRegistry, selectSkillsForBulkInstall, type SkillRegistryEntry, @@ -66,3 +67,9 @@ describe("parseSkillRegistry", () => { ]); }); }); + +describe("Bionic skill support", () => { + it("registers Bionic as a skill client", () => { + expect(AGENT_CLIENTS).toContain("bionic"); + }); +}); diff --git a/src/core/skills-registry.ts b/src/core/skills-registry.ts index 885cb23f..781f1222 100644 --- a/src/core/skills-registry.ts +++ b/src/core/skills-registry.ts @@ -25,6 +25,7 @@ export const AGENT_CLIENTS = [ "github-copilot", "vscode-copilot", "opencode", + "bionic", ] as const; export type AgentClient = (typeof AGENT_CLIENTS)[number]; diff --git a/src/installer/agent-config.test.ts b/src/installer/agent-config.test.ts index b8b3ef54..78ce61ed 100644 --- a/src/installer/agent-config.test.ts +++ b/src/installer/agent-config.test.ts @@ -43,6 +43,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", () => { @@ -268,6 +274,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 the generated gemini manifest (renamed to the extension dir) plus GEMINI.md/AGENTS.md", async () => { const dir = tmp(); const path = join(dir, "gemini-extension.json"); diff --git a/src/installer/agent-config.ts b/src/installer/agent-config.ts index c830f826..89a86148 100644 --- a/src/installer/agent-config.ts +++ b/src/installer/agent-config.ts @@ -19,6 +19,7 @@ export const supportedAgents = [ "github-copilot", "vscode-copilot", "opencode", + "bionic", "lm-studio", ] as const; @@ -28,6 +29,7 @@ export type ConfigScope = "user" | "project"; export interface StdioMcpServerConfig { command: string; args: string[]; + cwd?: string; env?: Record; } @@ -94,6 +96,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", @@ -145,6 +151,13 @@ export async function configureAgentMcp( const cwd = resolve(options.cwd ?? process.cwd()); const channel = normalizeChannel(options.channel); const server = createSummerMcpServerConfig(Boolean(options.localDev), process.platform, channel); + 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); @@ -327,10 +340,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 { @@ -419,10 +437,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 { @@ -584,6 +604,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; } @@ -1003,8 +1024,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 `summer skills install --all --agent gemini` if skills were not installed, then restart Gemini CLI (or `gemini extensions enable summer-engine` if it is disabled)." : agent === "github-copilot" diff --git a/src/installer/skill-locations.ts b/src/installer/skill-locations.ts index 7f4f5f73..f37e3a8c 100644 --- a/src/installer/skill-locations.ts +++ b/src/installer/skill-locations.ts @@ -112,6 +112,14 @@ export 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": diff --git a/src/installer/version-check.test.ts b/src/installer/version-check.test.ts index 0a002704..ad8f4acf 100644 --- a/src/installer/version-check.test.ts +++ b/src/installer/version-check.test.ts @@ -9,6 +9,7 @@ import { classifyDrift, compareSemver, fetchLatestRegistryVersion, + defaultSkillMarkerCandidates, isLocalDevServerConfig, parseSemver, readRecordedMcpServer, @@ -20,6 +21,19 @@ import { type RecordedInstall, } 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/installer/version-check.ts b/src/installer/version-check.ts index 0bab3885..b84e52f3 100644 --- a/src/installer/version-check.ts +++ b/src/installer/version-check.ts @@ -303,6 +303,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 00000000..fd5b6af8 --- /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 00000000..8583aa85 --- /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 "../core/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 00000000..d23e3e1d --- /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("../core/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 731c8ddb..86bcd7af 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,7 +1,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { EngineApiClient } from "../core/api-client.js"; -import type { EngineSelection } from "../core/engine.js"; +import { findProjectRoot, type EngineSelection } from "../core/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"; @@ -76,58 +80,113 @@ 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 { - // Opt-in per-project routing (editor -> live worker -> spawned worker). - // OFF by default: with the flag unset this block is skipped entirely and - // src/core/headless/ is never even loaded (dynamic import). With the flag - // set, the router returns null whenever the existing path should serve the - // call (no project context, or a live editor has the project open), so - // editor behavior stays identical. See docs/HEADLESS_ROUTING.md. - if (process.env.SUMMER_HEADLESS_ROUTING === "1") { - const { getHeadlessRoutedClient } = await import( - "../core/headless/mcp-routing.js" - ); - const routed = await getHeadlessRoutedClient(engineSelection); - if (routed) return routed; - } + 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 `." + ); + } - 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; + // Opt-in per-project routing (editor -> live worker -> spawned worker). + // OFF by default: with the flag unset this block is skipped entirely and + // src/core/headless/ is never even loaded (dynamic import). With the flag + // set, the router returns null whenever the existing path should serve the + // call (no project context, or a live editor has the project open), so + // editor behavior stays identical. See docs/HEADLESS_ROUTING.md. + if (process.env.SUMMER_HEADLESS_ROUTING === "1") { + const { getHeadlessRoutedClient } = await import( + "../core/headless/mcp-routing.js" + ); + const routed = await getHeadlessRoutedClient(engineSelection); + if (routed) return routed; } - } - 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 -y summer-engine@latest 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 -y summer-engine@latest 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 -y summer-engine@latest 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 -y summer-engine@latest login'." + ); + } } } @@ -394,13 +453,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, @@ -416,6 +478,77 @@ export async function startMcpServer( const { server, getRegisteredToolCount } = createMcpServer(); + // 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", + }); + }, + }); + + // Fire-and-forget — never block tool registration on the npm registry. void probeBootDrift().catch((error) => { setCachedBootDriftNotice(null);