From 4e5871c50bdf79c96bc6a4936ab496de5459136d Mon Sep 17 00:00:00 2001 From: oveddan Date: Tue, 22 Sep 2026 17:50:29 -0600 Subject: [PATCH] feat: make an MCP server the agent interface, serving its own skill Decides #12: no standalone binary (Node 20 is an acceptable requirement), and agents reach the Twister through `mft-config mcp`, a stdio MCP server. - Tools: list_devices, export_configuration, plan_changes. No write tool while the #14 defects are open. Plans are built only from snapshots the server exported itself, and device operations are serialized. - No MIDI port is held between calls, so the server contends with a DAW or the vendor utility no more than one CLI invocation does. - Serves the mft-configurator skill over the MCP Skills Extension (SEP-2640): skill:// resources plus skills/list and skills/get with per-file digests. The skill moves to skills/ in the npm package as its single copy, rewritten around the tools. - Built on the v2 TypeScript SDK, which serves both the 2025 initialize handshake and the 2026-07-28 server/discover handshake the extension is specified against. - Removes the Claude Code plugin and the Codex curl install; both hosts now install with one `mcp add` line. The README leads with that setup. - The test runner is now TypeScript, run through tsx. Closes #12 Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 16 -- .claude-plugin/plugin.json | 23 -- .claude/skills/mft-configurator/SKILL.md | 106 -------- README.md | 93 +++++-- docs/agent-skill.md | 149 ++++++---- package.json | 15 +- pnpm-lock.yaml | 114 ++++++++ skills/mft-configurator/SKILL.md | 50 ++++ .../mft-configurator/agents/openai.yaml | 0 .../mft-configurator/references/settings.md | 56 ++-- src/cli.ts | 17 +- src/mcp.ts | 256 ++++++++++++++++++ src/skills.ts | 121 +++++++++ test/applier.test.ts | 86 +----- test/fake-twister.ts | 89 ++++++ test/mcp.test.ts | 242 +++++++++++++++++ test/{run-tests.mjs => run-tests.ts} | 0 17 files changed, 1089 insertions(+), 344 deletions(-) delete mode 100644 .claude-plugin/marketplace.json delete mode 100644 .claude-plugin/plugin.json delete mode 100644 .claude/skills/mft-configurator/SKILL.md create mode 100644 skills/mft-configurator/SKILL.md rename {.claude/skills => skills}/mft-configurator/agents/openai.yaml (100%) rename {.claude/skills => skills}/mft-configurator/references/settings.md (71%) create mode 100644 src/mcp.ts create mode 100644 src/skills.ts create mode 100644 test/fake-twister.ts create mode 100644 test/mcp.test.ts rename test/{run-tests.mjs => run-tests.ts} (100%) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index 476a9a0..0000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "mft-api", - "owner": { - "name": "Dan Oved", - "url": "https://github.com/oveddan" - }, - "description": "MIDI Fighter Twister configuration tooling for coding agents.", - "plugins": [ - { - "name": "mft-configurator", - "source": "./", - "description": "Inspect and configure a DJ TechTools MIDI Fighter Twister in plain language, through the guarded mft-config CLI.", - "category": "hardware" - } - ] -} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json deleted file mode 100644 index d006e5c..0000000 --- a/.claude-plugin/plugin.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "mft-configurator", - "displayName": "MIDI Fighter Twister Configurator", - "description": "Inspect and configure a DJ TechTools MIDI Fighter Twister in plain language, through the guarded mft-config CLI.", - "version": "0.1.0", - "author": { - "name": "Dan Oved", - "url": "https://github.com/oveddan" - }, - "homepage": "https://github.com/oveddan/mft-api#readme", - "repository": "https://github.com/oveddan/mft-api", - "license": "MIT", - "keywords": [ - "midi-fighter-twister", - "midi", - "djtechtools", - "hardware", - "sysex" - ], - "skills": [ - "./.claude/skills" - ] -} diff --git a/.claude/skills/mft-configurator/SKILL.md b/.claude/skills/mft-configurator/SKILL.md deleted file mode 100644 index b7cf4f0..0000000 --- a/.claude/skills/mft-configurator/SKILL.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -name: mft-configurator -description: Safely discover, inspect, export, and explicitly configure a DJ TechTools MIDI Fighter Twister through the mft-api CLI. Use for Twister device discovery, JSON configuration exports, knob colors, push-switch modes and MIDI mappings, global settings, four- or eight-bank layouts, firmware compatibility, or guarded plan/apply changes. ---- - -# MIDI Fighter Twister Configurator - -Operate the `mft-config` CLI. Treat the connected controller as user-owned hardware: inspect by default and mutate only after explicit authorization. - -## Prepare the CLI - -The CLI is published to npm. No checkout, no build. - -1. Prefer an installed `mft-config` on `PATH`. Check with `mft-config --help`. -2. If it is absent, use `npx -y mft-config …` in its place throughout this skill. Do not clone or build the repository to obtain it. - -**Pick one working directory and stay in it.** This is safety-critical and easy -to get wrong now that there is no repository root to anchor to: - -- `.mft-state/` — the write backups and the **single-use plan journal** — is - created relative to the current working directory. -- Running `apply` from a different directory than a previous `apply` consults a - *different journal*, which silently defeats the single-use guarantee that - stops a plan from being replayed. -- So: ask the user for a working directory once, `cd` there, and run every - `export`, `plan`, and `apply` from that same directory for the whole session. - If you cannot establish one, say so rather than guessing. - -Use only the artifact names `twister-config.json` and `patch-plan.json`. The -unit ID and complete mapping in a snapshot are private device data — do not -commit, publish, or paste them. - -Do not invent raw SysEx, and do not reach around the CLI to the device. The -`plan` and `apply` commands are the entire write boundary. - -## Inspect without changing the device - -Use only these commands for read-only requests: - -```sh -mft-config list -mft-config export --device 0 --out twister-config.json -``` - -Omit `--device 0` only when exactly one Twister is connected. Device indices reflect current port order, not stable identity. Use `--timeout ` when the default 500 ms is too short. - -Summarize the export semantically, including: - -- firmware date and identity strength; -- four versus eight banks and 16 encoders per bank; -- global MIDI, brightness, palette, animation, sleep, and side-button settings; -- each knob's encoder mapping, push-switch mapping/action, colors, detent, indicator, movement, and super-knob flag; -- all warnings. - -Never expose a unit ID unnecessarily. Do not commit or publish an exported configuration. - -## Propose a configuration change - -Proceed only when the user explicitly asks to change settings. Before constructing paths or values, read [references/settings.md](references/settings.md). - -1. Export a fresh complete snapshot. -2. Translate the request into the smallest set of supported `--set path=value` operations. Compare with the snapshot and omit fields already at the desired value; one no-op aborts the entire plan. -3. Create a plan offline; this does not open MIDI ports: - -```sh -mft-config plan \ - --snapshot twister-config.json \ - --set bank.1.encoder.1.colors.active=green \ - --out patch-plan.json -``` - -4. Inspect the plan. Report the expected and desired values, target unit/firmware, eligibility reasons, expiration, and affected records. Do not treat a generated plan as permission to apply it. - -Plans expire after 15 minutes. If the plan expired or the device may have changed since export, re-export and re-plan. Never edit a plan file: its ID is a content hash. - -## Apply is currently disabled - -`mft-config apply` refuses in the current release while the write-path defects in -[issue #14](https://github.com/oveddan/mft-api/issues/14) are open — `.mft-state` resolves against the current working directory, so an apply run from a different directory consults a different journal, and a plan whose apply died mid-write can be replayed. - -When a user asks to change settings: still export, still plan, still report the plan. Then tell them the change cannot be written yet, name the issue, and offer the vendor MIDI Fighter Utility as the way to make it by hand. Do not look for a way around the block — there is an environment variable that lifts it and it is deliberately not for agent use. - -The rest of this section describes the flow that returns once #14 lands. Keep following it for everything up to the write. - -```sh -mft-config list --timeout 1500 -mft-config apply --plan patch-plan.json --yes --device 0 --timeout 1500 # refuses today -``` - -Require an explicit user instruction to apply the reviewed plan to the physical controller. Do not apply a plan created for a merely hypothetical request. Before confirmation, disclose that the CLI has no restore command: its backup JSON is a record, not an automated recovery path. A failed or unknown apply may require the vendor Twister Utility or manual reconfiguration. - -Preserve these invariants: - -- Apply only an eligible, unexpired plan bound to the freshly discovered unit and snapshot. -- Re-run `list` immediately before apply and select the current positional device index. For export, verify that `device.unitId` identifies the intended controller; apply performs its own binding check. -- Accept live writes only for the exact firmware allowlist enforced by the CLI. Never weaken or work around firmware gating. -- Let the CLI create its backup, journal each sequential non-retried write, and perform complete readback verification. -- Stop on stale state, identity mismatch, timeout, `failed`, or `unknown`. Never retry a write or restore automatically; a readback timeout may mean the write landed. -- Report the backup and verified snapshot paths without printing sensitive contents. -- Never send or enable system, reset, factory-reset, bootloader, arbitrary MIDI, or arbitrary SysEx commands. Configuration push commands are the entire write boundary. - -If a request falls outside supported plan paths, explain the limitation. Modify the implementation only when the user separately asks for software development; do not improvise bytes against hardware. - -## Answer capability questions - -Read [references/settings.md](references/settings.md) when explaining colors, switch states, MIDI mappings, globals, or bank addressing. Distinguish persistent configuration from runtime LED state, the current bank, and current encoder values, which are not exported. diff --git a/README.md b/README.md index 0df0394..607e38b 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,21 @@ MIDI channel, and MIDI number are independently configurable. ## What this tool does +Agents use it as an MCP server (a connection an agent host such as Claude Code +or Codex runs for you), `mft-config mcp`, which offers three tools: + +- `list_devices` discovers connected Twisters without changing them. +- `export_configuration` reads the complete configuration and returns it with a + `snapshotId`. +- `plan_changes` turns a `snapshotId` and a list of `{ path, value }` changes + into a reviewable plan. It is offline and never opens a MIDI port. + +The server also serves the `mft-configurator` skill — the instructions that +teach an agent how to use those tools safely — so there is nothing else to +install. It has no write tool. + +The same operations are available to people as a CLI: + - `list` discovers connected Twisters without changing them. - `export` reads device identity, global settings, and all 64 or 128 banked encoder records into JSON. @@ -230,6 +245,7 @@ MIDI channel, and MIDI number are independently configurable. It does not open MIDI ports. - `apply` writes an eligible plan, saves a backup first, and reads the entire controller back after every changed target to verify the result. +- `mcp` starts the MCP server above. Only `apply` can write configuration. System commands, reset commands, and bootloader commands are always blocked. Live writes currently require firmware @@ -253,29 +269,52 @@ Requires Node.js 20 or newer. Prebuilt MIDI binaries ship for macOS, Windows, and Linux, so no compiler is needed on common platforms. Nothing below needs a clone, a build, or a local toolchain. -### Claude Code +The recommended setup is to add the MCP server to your agent. That is the whole +install: the server brings the skill with it, and `npx` fetches the latest +published version each time the server starts. -Two commands, and the agent can talk to the controller: +### Claude Code ```sh -/plugin marketplace add oveddan/mft-api -/plugin install mft-configurator@mft-api +claude mcp add -s user mft-config -- npx -y mft-config mcp ``` -That installs the `mft-configurator` skill. It reaches the CLI through -`npx -y mft-config`, so there is no second install step — though a global -install (below) makes every command noticeably faster to start. +`-s user` makes the server available in every project. Without it, Claude Code +registers it only for the directory you ran the command in. + +Start a new session, and `/mcp` should list `mft-config` as connected. Then plug +in the Twister and ask: + +> What's on my Twister right now? ### Codex -Codex has no marketplace, so the skill is fetched directly. This copies only -the skill directory: +```sh +codex mcp add mft-config -- npx -y mft-config mcp +``` + +### Anything else that speaks MCP + +Configure a stdio server with the command `npx -y mft-config mcp`. A host that +does not implement the MCP Skills Extension can still read the skill: the +server's instructions point to `skill://mft-configurator/SKILL.md`, an ordinary +resource. + +Tools, the served skill, and why the server holds no MIDI port between calls +are in [docs/agent-skill.md](docs/agent-skill.md). + +### Upgrading from the plugin + +Earlier releases installed a Claude Code plugin, `mft-configurator@mft-api`, +whose skill drove the CLI. The plugin is gone. Remove it and add the server +instead, so you are not left with two copies of the skill: ```sh -mkdir -p ~/.codex/skills/mft-configurator && curl -fsSL https://github.com/oveddan/mft-api/archive/refs/heads/main.tar.gz | tar -xz --strip-components=4 -C ~/.codex/skills/mft-configurator mft-api-main/.claude/skills/mft-configurator +/plugin uninstall mft-configurator@mft-api +/plugin marketplace remove mft-api ``` -### The CLI on its own +### Without an agent: the CLI ```sh npm install -g mft-config @@ -287,20 +326,18 @@ Or run it without installing anything: npx -y mft-config list ``` -Every example below uses `mft-config`. The old `mft-export` name still works as -a deprecated alias and will be removed in a future release. +The CLI examples below use `mft-config`. The old `mft-export` name still works +as a deprecated alias and will be removed in a future release. -Upgrading, removing, and the details of both agent installs are in -[docs/agent-skill.md](docs/agent-skill.md). - -**Run every command from the same directory.** `mft-config` writes its backups -and its single-use plan journal to `.mft-state/` relative to the current working -directory, so switching directories between `plan` and `apply` consults a -different journal. Pick a directory and stay in it. +**Run every CLI command from the same directory.** `mft-config` writes its +backups and its single-use plan journal to `.mft-state/` relative to the current +working directory, so switching directories between `plan` and `apply` consults +a different journal. Pick a directory and stay in it. ## Read the controller -List connected Twisters: +With the MCP server, just ask the agent; it calls `list_devices` and +`export_configuration`. From the CLI, list connected Twisters: ```sh mft-config list @@ -464,6 +501,10 @@ Changes use a deliberate three-step workflow: export a fresh snapshot, create and inspect an offline plan, then explicitly apply it. Plans expire after 15 minutes and are bound to the snapshot hash, firmware version, and device ID. +Through the MCP server the agent performs the first two steps with +`export_configuration` and `plan_changes`, passing each `--set path=value` below +as a `{ "path", "value" }` change. The recipes show the CLI form. + **The third step is disabled in this release** — see the note under [What this tool does](#what-this-tool-does). The recipes below still work through `plan`, which is offline and never opens a MIDI port; only the final `apply` refuses. @@ -625,7 +666,15 @@ pnpm run check ``` `pnpm run build` compiles to `dist/`, and `node dist/cli.js` is then equivalent -to the installed `mft-config` command. `pnpm pack` produces the publishable +to the installed `mft-config` command. To point your agent at the checkout +instead of the published package, replace the registered server with the built +one by absolute path, and rebuild after each change: + +```sh +claude mcp remove -s user mft-config +claude mcp add -s user mft-config -- node /absolute/path/to/mft-api/dist/cli.js mcp +``` + `pnpm pack` produces the publishable tarball and runs the full check first. `MFT_UNSAFE_APPLY=1` lifts the `apply` block so the write path can be exercised diff --git a/docs/agent-skill.md b/docs/agent-skill.md index 978d739..7d6b656 100644 --- a/docs/agent-skill.md +++ b/docs/agent-skill.md @@ -1,70 +1,103 @@ -# Agent skill installation +# Agents: the MCP server and its skill -The `mft-configurator` skill lets Claude Code or Codex inspect and configure a -MIDI Fighter Twister in plain language. It drives the `mft-config` CLI; it never -talks to the device directly. +Agents reach the Twister through `mft-config mcp`, a Model Context Protocol +server on stdio. It is the primary interface for agents; the CLI remains for +people running one-off commands. -No clone and no build is required for any path below. +The server needs Node.js 20 or newer and nothing else. There is no clone, no +build, and no standalone binary. -## Claude Code: install the plugin +## Install -```sh -/plugin marketplace add oveddan/mft-api -/plugin install mft-configurator@mft-api -``` - -The plugin bundles the skill. `/plugin update` upgrades it, and -`/plugin uninstall mft-configurator@mft-api` removes it. - -Working inside a checkout of this repository, Claude Code discovers the skill -from `.claude/skills/` automatically and the plugin is unnecessary. - -## Codex: fetch the skill directory - -There is no marketplace equivalent, so this is a single command rather than -pretending parity exists. It fetches only the skill directory: +Claude Code (`-s user` makes it available in every project, not just the +current directory): ```sh -mkdir -p ~/.codex/skills/mft-configurator && curl -fsSL https://github.com/oveddan/mft-api/archive/refs/heads/main.tar.gz | tar -xz --strip-components=4 -C ~/.codex/skills/mft-configurator mft-api-main/.claude/skills/mft-configurator +claude mcp add -s user mft-config -- npx -y mft-config mcp ``` -It overwrites in place, so re-running it upgrades. Delete the destination -directory first if you want files removed upstream to disappear too. - -The same command with `~/.claude/skills/mft-configurator` as the destination -still works for Claude Code, if you would rather not use the plugin. - -## The CLI - -The skill invokes `npx -y mft-config` when the command is not on `PATH`, so -this is optional. Installing it globally removes the npx startup cost from -every command: +Codex: ```sh -npm install -g mft-config +codex mcp add mft-config -- npx -y mft-config mcp ``` -## Use it - -Invoke `/mft-configurator` in Claude Code or `$mft-configurator` in Codex, or -just ask the agent to inspect or configure a Twister. - -The skill defaults to discovery and export. Applying a configuration plan to the -controller requires a separate, explicit instruction. - -## Notes - -`SKILL.md` uses the portable Agent Skills format understood by both Claude Code -and Codex. The optional `agents/openai.yaml`, generated with Codex's skill -creator, adds Codex UI metadata; Claude Code ignores it. Both the plugin and the -Codex one-liner serve the same single copy of the skill in `.claude/skills/`, so -there is no second copy to keep in sync. - -**Run the agent from one consistent working directory.** `mft-config` writes its -backups and its single-use plan journal to `.mft-state/` relative to the current -working directory, so an `apply` run from a different directory than an earlier -one consults a different journal. - -An MCP server, so that one install serves any agent that speaks the protocol -rather than one skill copy per tool, is still open; see -[#12](https://github.com/oveddan/mft-api/issues/12). +`npx -y` fetches the latest published version when the server starts. For a +faster start, `npm install -g mft-config` and register `mft-config mcp` instead. + +## Tools + +| Tool | Does | Touches the device | +| --- | --- | --- | +| `list_devices` | Discovers Twisters by Universal Identity request | Read requests only | +| `export_configuration` | Reads the full configuration; returns it and a `snapshotId` | Read requests only | +| `plan_changes` | Builds a patch plan from a `snapshotId` and `{ path, value }` changes | No — offline | + +There is **no write tool**. Writing is disabled while the defects in +[#14](https://github.com/oveddan/mft-api/issues/14) are open, and a server tool +would widen that boundary. When writing returns, it is intended to take a plan +ID the server created — never plan JSON from the agent — per invariant 9 in +[write-safety.md](write-safety.md). + +`plan_changes` plans only from a snapshot the server exported itself. The agent +never supplies configuration to plan from, so a plan cannot be built against a +fabricated device state. The server keeps its 16 most recent snapshots in +memory; none survive a restart. + +## The skill is served by the server + +The server implements the MCP Skills Extension +([SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640), +`io.modelcontextprotocol/skills`): + +- it declares the extension in its capabilities; +- every file of [`skills/mft-configurator/`](../skills/mft-configurator) is a + resource under `skill://mft-configurator/…`; +- `skills/list` and `skills/get` return the skill's frontmatter and a manifest + of every file with its SHA-256 digest and size, computed from the exact bytes + the server serves. + +`skills/` in the npm package is the single copy. The Claude Code plugin and the +Codex `curl | tar` install that used to distribute it are gone, so there is +nothing to keep in sync with the server. + +A host that does not implement the extension still gets the skill: the server's +instructions name `skill://mft-configurator/SKILL.md`, which is an ordinary +readable resource. + +The extension is specified against MCP revision 2026-07-28, where a server +declares it through `server/discover`. The server is built on the v2 TypeScript +SDK, which serves that revision and the 2025 `initialize` handshake from the +same code, so the declaration is visible on either. No SDK implements the +extension itself yet, so `skills/list` and `skills/get` are registered here as +custom methods. + +As of September 2026 the extension's own implementation list does not name +Claude Code or Codex as hosts. Until they support it, the instructions pointer +above is how those agents find the skill. + +## MIDI ports are not held between calls + +Each tool call discovers the controller, opens its ports, and closes them +before returning — exactly what one CLI invocation does. The server therefore +contends with a DAW or the vendor MIDI Fighter Utility for the port no more than +the CLI already does. + +Holding the port open was the main argument for a long-running process, and it +was rejected for that reason. On Windows a MIDI port is exclusive, so a server +that kept it would lock a DAW out for as long as the agent session lasted. On +macOS CoreMIDI shares ports, but the server would then be listening to +performance traffic it has no use for. Discovery costs about `timeoutMs` per +MIDI output, which is small next to an agent's turn. + +Device operations within one server run one at a time, since two discoveries +probing every port at once would read each other's identity replies. Two +separate servers, or the server and the CLI, are not coordinated; see +[write-safety.md](write-safety.md#one-writer-at-a-time). + +## Why not a standalone binary + +Considered in [#12](https://github.com/oveddan/mft-api/issues/12) and rejected. +Requiring Node is acceptable for this audience, and a single-file executable +would have to carry the native MIDI addon for every platform plus macOS signing +and notarization, for a user who already has Node in nearly every case. diff --git a/package.json b/package.json index 58accba..1476e40 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "midi", "sysex", "djtechtools", - "cli" + "cli", + "mcp" ], "homepage": "https://github.com/oveddan/mft-api#readme", "bugs": "https://github.com/oveddan/mft-api/issues", @@ -24,24 +25,28 @@ }, "files": [ "dist", - "schema" + "schema", + "skills" ], "exports": { "./package.json": "./package.json" }, "scripts": { "build": "tsc -p tsconfig.json", - "test": "node test/run-tests.mjs", - "check": "tsc -p tsconfig.json && tsc -p tsconfig.test.json && node test/run-tests.mjs", + "test": "tsx test/run-tests.ts", + "check": "tsc -p tsconfig.json && tsc -p tsconfig.test.json && tsx test/run-tests.ts", "prepack": "npm run check" }, "engines": { "node": ">=20" }, "dependencies": { - "@julusian/midi": "^3.0.1" + "@julusian/midi": "^3.0.1", + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.6.5" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/node": "^24.0.0", "tsx": "^4.20.0", "typescript": "^5.9.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc768b5..4bf4027 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,16 @@ importers: '@julusian/midi': specifier: ^3.0.1 version: 3.8.0 + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.0.0 + zod: + specifier: ^4.6.5 + version: 4.6.5 devDependencies: + '@modelcontextprotocol/client': + specifier: ^2.0.0 + version: 2.0.0 '@types/node': specifier: ^24.0.0 version: 24.13.3 @@ -184,27 +193,73 @@ packages: resolution: {integrity: sha512-zlOEsgDry/Wj1df1U6SfR2Z2RDHAZjXl9vFdV84DaaxmkA/7p6l1qX6yvCRAhKl9EnIpiAy+MrNrisKv+0N8Vw==} engines: {node: '>=14.15'} + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + node-addon-api@6.1.0: resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-prebuilds@1.1.0: resolution: {integrity: sha512-jyai+KTQ2OwbN6iRYw88XbYOMgtpoSYJpjYebx7d9ihqz3txNi3ucsBt3va0iVWe6svSlaqpijMHFF/eJCMZzg==} engines: {node: '>= 14.15.0'} hasBin: true + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -221,6 +276,14 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + snapshots: '@esbuild/aix-ppc64@0.28.2': @@ -307,10 +370,35 @@ snapshots: pkg-prebuilds: 1.1.0 tslib: 2.8.1 + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + jose: 6.2.12 + pkce-challenge: 5.0.1 + zod: 4.6.5 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.6.5 + + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.6.5 + '@types/node@24.13.3': dependencies: undici-types: 7.18.2 + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -340,13 +428,33 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + fsevents@2.3.3: optional: true + isexe@2.0.0: {} + + jose@6.2.12: {} + node-addon-api@6.1.0: {} + path-key@3.1.1: {} + + pkce-challenge@5.0.1: {} + pkg-prebuilds@1.1.0: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + tslib@2.8.1: {} tsx@4.23.11: @@ -358,3 +466,9 @@ snapshots: typescript@5.9.3: {} undici-types@7.18.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + zod@4.6.5: {} diff --git a/skills/mft-configurator/SKILL.md b/skills/mft-configurator/SKILL.md new file mode 100644 index 0000000..a9b0fea --- /dev/null +++ b/skills/mft-configurator/SKILL.md @@ -0,0 +1,50 @@ +--- +name: mft-configurator +description: Safely discover, inspect, export, and plan changes to a DJ TechTools MIDI Fighter Twister through the mft-config MCP server. Use for Twister device discovery, configuration exports, knob colors, push-switch modes and MIDI mappings, global settings, four- or eight-bank layouts, firmware compatibility, or reviewable change plans. +--- + +# MIDI Fighter Twister Configurator + +Operate the Twister through the `mft-config` MCP server's tools. Treat the connected controller as user-owned hardware: inspect by default and plan a change only when the user asks for one. + +The server is the whole boundary. Do not invent raw SysEx, and do not reach around the server to the device — not with another MIDI tool, and not by running the `mft-config` CLI in a shell. + +## Inspect without changing the device + +- `list_devices` — every connected Twister with its positional `index`, port names, and firmware date. +- `export_configuration` — the complete configuration of one Twister, plus a `snapshotId`. Pass `device` only when more than one is connected; indices follow port order and are not stable identities. + +Both tools accept `timeoutMs` (default 500). Raise it when discovery misses a connected controller. + +Summarize an export semantically, including: + +- firmware date and identity strength; +- four versus eight banks and 16 encoders per bank; +- global MIDI, brightness, palette, animation, sleep, and side-button settings; +- each knob's encoder mapping, push-switch mapping/action, colors, detent, indicator, movement, and super-knob flag; +- all warnings. + +The export contains the unit ID and the complete mapping, which are private device data. Never repeat the unit ID unnecessarily, and do not commit or publish an export. + +## Plan a configuration change + +Proceed only when the user explicitly asks to change settings. Before constructing paths or values, read [references/settings.md](references/settings.md). + +1. Call `export_configuration` for a fresh snapshot. +2. Translate the request into the smallest set of `{ path, value }` changes. Compare with the export and omit fields already at the desired value; one no-op rejects the entire plan. +3. Call `plan_changes` with the `snapshotId` and the changes. It is offline: it opens no MIDI port. +4. Report the expected and desired values, target firmware, eligibility reasons, expiration, and affected records. A plan is not permission to write anything. + +`plan_changes` plans only from snapshots the server itself exported, and it keeps only recent ones in memory. If it reports an unknown `snapshotId`, export again. Plans expire after 15 minutes; if one expired or the device may have changed, re-export and re-plan. + +## Writing is not available + +The server cannot write settings to the controller. Writes are disabled while the defects in [issue #14](https://github.com/oveddan/mft-api/issues/14) are open. + +When a user asks to change settings: still export, still plan, still report the plan. Then tell them the change cannot be written from here yet, name the issue, and offer the vendor MIDI Fighter Utility as the way to make it by hand. Do not look for a way around this — not through the CLI, and not through an environment variable. + +If a request falls outside the supported plan paths, explain the limitation. Modify the implementation only when the user separately asks for software development; do not improvise bytes against hardware. + +## Answer capability questions + +Read [references/settings.md](references/settings.md) when explaining colors, switch states, MIDI mappings, globals, or bank addressing. Distinguish persistent configuration from runtime LED state, the current bank, and current encoder values, which are not exported. diff --git a/.claude/skills/mft-configurator/agents/openai.yaml b/skills/mft-configurator/agents/openai.yaml similarity index 100% rename from .claude/skills/mft-configurator/agents/openai.yaml rename to skills/mft-configurator/agents/openai.yaml diff --git a/.claude/skills/mft-configurator/references/settings.md b/skills/mft-configurator/references/settings.md similarity index 71% rename from .claude/skills/mft-configurator/references/settings.md rename to skills/mft-configurator/references/settings.md index d4930c0..562d825 100644 --- a/.claude/skills/mft-configurator/references/settings.md +++ b/skills/mft-configurator/references/settings.md @@ -1,6 +1,6 @@ # Supported settings -Use these semantic paths with `plan --set path=value`. Banks and encoders are one-based. Each bank contains encoders 1–16. Firmware `2026-07-02` supports non-mutating detection of four versus eight banks; legacy firmware is treated as four-bank. +Use these semantic paths as the `path` of each change passed to `plan_changes`. Banks and encoders are one-based. Each bank contains encoders 1–16. Firmware `2026-07-02` supports non-mutating detection of four versus eight banks; legacy firmware is treated as four-bank. ## Encoder fields @@ -38,17 +38,17 @@ Changing `global.colorMap.code` reinterprets every stored color index and can re Example: make the top row of bank 1 green while active and purple while inactive: -```sh -mft-config plan --snapshot twister-config.json \ - --set bank.1.encoder.1.colors.active=green \ - --set bank.1.encoder.1.colors.inactive=purple \ - --set bank.1.encoder.2.colors.active=green \ - --set bank.1.encoder.2.colors.inactive=purple \ - --set bank.1.encoder.3.colors.active=green \ - --set bank.1.encoder.3.colors.inactive=purple \ - --set bank.1.encoder.4.colors.active=green \ - --set bank.1.encoder.4.colors.inactive=purple \ - --out patch-plan.json +```json +[ + { "path": "bank.1.encoder.1.colors.active", "value": "green" }, + { "path": "bank.1.encoder.1.colors.inactive", "value": "purple" }, + { "path": "bank.1.encoder.2.colors.active", "value": "green" }, + { "path": "bank.1.encoder.2.colors.inactive", "value": "purple" }, + { "path": "bank.1.encoder.3.colors.active", "value": "green" }, + { "path": "bank.1.encoder.3.colors.inactive", "value": "purple" }, + { "path": "bank.1.encoder.4.colors.active", "value": "green" }, + { "path": "bank.1.encoder.4.colors.inactive", "value": "purple" } +] ``` ## Global fields @@ -68,33 +68,33 @@ mft-config plan --snapshot twister-config.json \ | `global.sleep.animation.code` | `0` lights off, `1` rainbow wave | | `global.bankAnimationsEnabled` | `true` or `false` | -Side-button actions are exported but are not currently supported planner paths. Do not claim they can be changed with this CLI. +Side-button actions are exported but are not currently supported planner paths. Do not claim they can be changed here. ## Common plans Make push switches 1 and 2 in bank 1 toggle CC values: -```sh -mft-config plan --snapshot twister-config.json \ - --set bank.1.encoder.1.switch.action.code=1 \ - --set bank.1.encoder.2.switch.action.code=1 \ - --out patch-plan.json +```json +[ + { "path": "bank.1.encoder.1.switch.action.code", "value": 1 }, + { "path": "bank.1.encoder.2.switch.action.code", "value": 1 } +] ``` Map bank 2 encoder 5 rotation to CC 74 on channel 3 and its push to note 60 on channel 10: -```sh -mft-config plan --snapshot twister-config.json \ - --set bank.2.encoder.5.encoder.type.code=1 \ - --set bank.2.encoder.5.encoder.midiChannel=3 \ - --set bank.2.encoder.5.encoder.midiNumber=74 \ - --set bank.2.encoder.5.switch.action.code=2 \ - --set bank.2.encoder.5.switch.midiChannel=10 \ - --set bank.2.encoder.5.switch.midiNumber=60 \ - --out patch-plan.json +```json +[ + { "path": "bank.2.encoder.5.encoder.type.code", "value": 1 }, + { "path": "bank.2.encoder.5.encoder.midiChannel", "value": 3 }, + { "path": "bank.2.encoder.5.encoder.midiNumber", "value": 74 }, + { "path": "bank.2.encoder.5.switch.action.code", "value": 2 }, + { "path": "bank.2.encoder.5.switch.midiChannel", "value": 10 }, + { "path": "bank.2.encoder.5.switch.midiNumber", "value": 60 } +] ``` -The planner rejects no-op values. If one field already has the requested value, omit that `--set` and plan only the remaining changes. +The planner rejects no-op values. If one field already has the requested value, omit that change and plan only the remaining changes. ## Firmware and export boundaries diff --git a/src/cli.ts b/src/cli.ts index b8c5c3b..f339be0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,7 +10,7 @@ import { applyPatchPlan } from "./applier.js"; import { assertPlanNotConsumed } from "./journal.js"; interface Arguments { - command: "list" | "export" | "plan" | "apply" | "help"; + command: "list" | "export" | "plan" | "apply" | "mcp" | "help"; device?: number; out?: string; timeoutMs: number; @@ -26,6 +26,10 @@ function usage(): string { mft-config export [--device ] [--out ] [--timeout ] mft-config plan --snapshot --set [--set ] [--out ] mft-config apply --plan --yes [--device ] (disabled) + mft-config mcp + +mcp runs a Model Context Protocol server on stdio, for agents. It offers list, +export, and plan as tools and serves the mft-configurator agent skill. This tool only sends Universal Identity, global pull (0x02), encoder bulk-pull (0x04/0x01), and device-ID pull (0x05) messages. The plan command is offline. @@ -40,6 +44,10 @@ function parseArguments(argv: string[]): Arguments { if (command === "help" || command === "--help" || command === "-h") { return { command: "help", timeoutMs: 500, sets: [], yes: false }; } + if (command === "mcp") { + if (argv.length > 1) throw new Error("mcp takes no options"); + return { command: "mcp", timeoutMs: 500, sets: [], yes: false }; + } if (command !== "list" && command !== "export" && command !== "plan" && command !== "apply") throw new Error(`Unknown command: ${command}`); const result: Arguments = { command, timeoutMs: 500, sets: [], yes: false }; @@ -140,6 +148,13 @@ async function main(): Promise { process.stdout.write(`${usage()}\n`); return; } + if (args.command === "mcp") { + // stdout carries the protocol from here on; diagnostics go to stderr. + const { runMcpServer } = await import("./mcp.js"); + const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { version: string }; + await runMcpServer(version); + return; + } if (args.command === "plan") { const snapshot = JSON.parse(await readFile(resolve(args.snapshot!), "utf8")) as ConfigExport; const plan = createPatchPlan(snapshot, args.sets.map(parseSet)); diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..8c89a90 --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,256 @@ +import { McpServer, ProtocolError, ProtocolErrorCode, type CallToolResult } from "@modelcontextprotocol/server"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import { z } from "zod"; + +import { exportConfiguration } from "./exporter.js"; +import type { MidiBackend } from "./midi.js"; +import type { ConfigExport, DeviceDescriptor } from "./model.js"; +import { createPatchPlan } from "./planner.js"; +import { snapshotHash } from "./snapshot.js"; +import { loadSkills, skillEntry, SKILLS_EXTENSION, type Skill } from "./skills.js"; + +// The MCP server is the primary way an agent reaches the Twister. It exposes the +// same read and offline-plan operations as the CLI, as typed tools, and serves +// the agent skill that explains them. +// +// Two properties differ from the CLI on purpose: +// +// - Plans are made only from snapshots this server exported. The agent names a +// snapshot by ID; it never hands the server configuration JSON to plan from. +// - Device operations run one at a time. A host can issue tool calls +// concurrently, and two discoveries probing every port at once would read +// each other's identity replies. +// +// It is built on SDK v2, which serves both protocol eras from one factory: the +// 2025 `initialize` handshake, and the 2026-07-28 `server/discover` handshake +// the Skills Extension is specified against. A host on either era sees the +// extension declared. +// +// No MIDI port is held between calls. Each call discovers, opens, and closes, +// exactly as one CLI invocation does, so the server contends for ports with a +// DAW or the vendor utility no more than the CLI already does. +// +// There is no apply tool. Writing is disabled in the CLI until the defects in +// https://github.com/oveddan/mft-api/issues/14 are fixed, and a server tool +// would widen that boundary rather than narrow it. + +const SNAPSHOT_LIMIT = 16; +const LISTING_TTL_MS = 300_000; + +// These mirror the CLI's `list` output and device selection, which stay inline +// in cli.ts so this server changes nothing on the CLI's hardware paths. + +function summarizeDevices(devices: DeviceDescriptor[]): unknown[] { + return devices.map((device, index) => ({ + index, + inputPort: device.inputPort.name, + outputPort: device.outputPort.name, + familyId: device.identity.familyId, + modelId: device.identity.modelId, + firmwareDate: device.identity.firmwareDate, + })); +} + +/** Refuses to guess between several controllers: an index is positional, not an identity. */ +function selectDevice(devices: DeviceDescriptor[], index: number | undefined): DeviceDescriptor { + if (devices.length === 0) throw new Error("No MIDI Fighter Twister responded to identity discovery"); + if (devices.length > 1 && index === undefined) { + throw new Error(`Found ${devices.length} devices; select one with the device argument`); + } + const selectedIndex = index ?? 0; + const device = devices[selectedIndex]; + if (!device) throw new Error(`Device index ${selectedIndex} does not exist; call list_devices again`); + return device; +} + +export interface McpServerOptions { + backend: MidiBackend; + skills: Skill[]; + version: string; +} + +const timeoutMs = z + .number() + .int() + .min(100) + .max(10_000) + .optional() + .describe("Milliseconds to wait for each device reply. Default 500; raise it if discovery misses a connected Twister."); + +const deviceIndex = z + .number() + .int() + .min(0) + .optional() + .describe("Positional index from list_devices. Required when more than one Twister is connected. Indices follow port order and are not stable identities."); + +function json(value: unknown): CallToolResult { + return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }; +} + +function instructions(skills: Skill[]): string { + const skill = skills.find((candidate) => candidate.frontmatter.name === "mft-configurator"); + return [ + "Inspect and plan changes to a DJ TechTools MIDI Fighter Twister over USB MIDI.", + "Treat the controller as user-owned hardware: inspect by default, and plan a change only when the user asks for one.", + "Writing settings to the device is not available through this server.", + skill ? `Before planning a change, read the skill at ${skill.uri} and the settings reference it links to.` : "", + ] + .filter(Boolean) + .join("\n"); +} + +/** State every server instance must share: one device lock, one snapshot store. */ +interface SharedState { + exclusive(operation: () => Promise): Promise; + remember(configuration: ConfigExport): string; + snapshots: ReadonlyMap; +} + +function createSharedState(): SharedState { + let queue: Promise = Promise.resolve(); + // Keyed by snapshot hash, oldest evicted first. Insertion order is recency + // because a re-export of an unchanged device deletes and re-inserts its key. + const snapshots = new Map(); + return { + exclusive(operation) { + const run = queue.then(operation, operation); + queue = run.catch(() => undefined); + return run; + }, + remember(configuration) { + const id = snapshotHash(configuration); + snapshots.delete(id); + snapshots.set(id, configuration); + while (snapshots.size > SNAPSHOT_LIMIT) snapshots.delete(snapshots.keys().next().value!); + return id; + }, + snapshots, + }; +} + +/** + * Returns a factory for server instances. The stdio entry point may build more + * than one per connection while it settles the protocol era, so the device lock + * and the snapshot store are created once here and shared by every instance. + */ +export function createMcpServerFactory(options: McpServerOptions): () => McpServer { + const state = createSharedState(); + return () => buildServer(options, state); +} + +const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const; +const cacheable = { resultType: "complete", ttlMs: LISTING_TTL_MS, cacheScope: "public" } as const; + +function buildServer(options: McpServerOptions, state: SharedState): McpServer { + const { backend, skills } = options; + const { exclusive, remember, snapshots } = state; + const skillsByUri = new Map(skills.map((skill) => [skill.uri, skill])); + const server = new McpServer( + { name: "mft-config", version: options.version }, + { + capabilities: { resources: {}, tools: {}, extensions: { [SKILLS_EXTENSION]: {} } }, + instructions: instructions(skills), + }, + ); + + server.registerTool( + "list_devices", + { + title: "List Twisters", + description: + "Discover connected MIDI Fighter Twisters by sending a Universal Identity request to every MIDI output. Returns each device's positional index, port names, and firmware date.", + inputSchema: z.object({ timeoutMs }), + annotations: readOnly, + }, + ({ timeoutMs }) => exclusive(async () => json(summarizeDevices(await backend.discover(timeoutMs ?? 500)))), + ); + + server.registerTool( + "export_configuration", + { + title: "Export configuration", + description: [ + "Read the complete persistent configuration of one Twister: globals and every encoder in every bank.", + "Sends only read requests. Returns a snapshotId to pass to plan_changes, and the configuration itself.", + "The configuration contains the unit ID, which is private device data; do not repeat it unnecessarily.", + ].join(" "), + inputSchema: z.object({ device: deviceIndex, timeoutMs }), + annotations: readOnly, + }, + ({ device, timeoutMs }) => + exclusive(async () => { + const timeout = timeoutMs ?? 500; + const selected = selectDevice(await backend.discover(timeout), device); + const connection = backend.connect(selected); + try { + const configuration = await exportConfiguration(connection, selected, { timeoutMs: timeout }); + return json({ snapshotId: remember(configuration), configuration }); + } finally { + connection.close(); + } + }), + ); + + server.registerTool( + "plan_changes", + { + title: "Plan configuration changes", + description: [ + "Build a reviewable patch plan from a snapshot this server exported. Offline: opens no MIDI port and changes nothing.", + "Each change is a semantic path and value, such as bank.1.encoder.1.colors.active = green; the skill's settings reference lists every path.", + "Omit changes already at the desired value: one no-op rejects the whole plan.", + "Plans expire after 15 minutes.", + ].join(" "), + inputSchema: z.object({ + snapshotId: z.string().describe("snapshotId returned by export_configuration."), + changes: z + .array( + z.object({ + path: z.string().describe("Semantic setting path, e.g. bank.1.encoder.1.colors.active or global.brightness.rgb."), + value: z.union([z.string(), z.number(), z.boolean()]).describe("Desired value: a palette name, number, or boolean as the path requires."), + }), + ) + .min(1), + }), + annotations: readOnly, + }, + async ({ snapshotId, changes }) => { + const snapshot = snapshots.get(snapshotId); + if (!snapshot) { + throw new Error(`Unknown snapshotId ${snapshotId}. Export the configuration again; the server keeps only its ${SNAPSHOT_LIMIT} most recent snapshots, and none survive a restart.`); + } + return json(createPatchPlan(snapshot, changes)); + }, + ); + + for (const skill of skills) { + for (const file of skill.files) { + const isEntry = file.uri === skill.uri; + server.registerResource( + isEntry ? skill.frontmatter.name : `${skill.frontmatter.name}/${file.path}`, + file.uri, + isEntry ? { mimeType: file.mimeType, description: skill.frontmatter.description } : { mimeType: file.mimeType }, + async () => ({ contents: [{ uri: file.uri, mimeType: file.mimeType, text: file.text }] }), + ); + } + } + + // Every skill fits on one page, so the cursor is accepted and ignored. + server.server.setRequestHandler("skills/list", { params: z.looseObject({ cursor: z.string().optional() }).optional() }, () => ({ + ...cacheable, + skills: skills.map(skillEntry), + })); + server.server.setRequestHandler("skills/get", { params: z.looseObject({ uri: z.string() }) }, ({ uri }) => { + const skill = skillsByUri.get(uri); + if (!skill) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown skill: ${uri}`); + return { ...cacheable, skill: skillEntry(skill) }; + }); + + return server; +} + +export async function runMcpServer(version: string): Promise { + const { RtMidiBackend } = await import("./midi.js"); + serveStdio(createMcpServerFactory({ backend: new RtMidiBackend(), skills: await loadSkills(), version })); +} diff --git a/src/skills.ts b/src/skills.ts new file mode 100644 index 0000000..75f74d7 --- /dev/null +++ b/src/skills.ts @@ -0,0 +1,121 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import { extname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Serves the agent skill over MCP per the Skills Extension (SEP-2640, +// io.modelcontextprotocol/skills): every file of the skill directory is a +// `skill:///` resource, and `skills/list` / `skills/get` return a +// manifest carrying each file's digest and size. +// +// The skill directory shipped in the npm package is the single source. The +// server reads it once at startup, so the digests it publishes always describe +// the exact bytes it serves. + +export const SKILLS_EXTENSION = "io.modelcontextprotocol/skills"; + +/** `skills/` at the package root. Resolves the same from `src/` and `dist/`. */ +export const DEFAULT_SKILLS_ROOT = fileURLToPath(new URL("../skills/", import.meta.url)); + +export interface SkillFile { + uri: string; + path: string; + mimeType: string; + text: string; + digest: string; + size: number; +} + +export interface Skill { + uri: string; + frontmatter: { name: string; description: string; [key: string]: unknown }; + files: SkillFile[]; +} + +/** The `Skill` entry shape that `skills/list` and `skills/get` return. */ +export function skillEntry(skill: Skill): { uri: string; frontmatter: Skill["frontmatter"]; resources: Array<{ uri: string; digest: string; size: number }> } { + return { + uri: skill.uri, + frontmatter: skill.frontmatter, + resources: skill.files.map(({ uri, digest, size }) => ({ uri, digest, size })), + }; +} + +const MIME_TYPES: Record = { + ".md": "text/markdown", + ".yaml": "application/yaml", + ".yml": "application/yaml", + ".json": "application/json", +}; + +/** + * Parses the flat `key: value` frontmatter this project's skills use. + * + * The extension requires the frontmatter to be returned verbatim, so anything + * this parser does not understand is refused rather than approximated — a + * nested key silently dropped here would be a manifest that misdescribes the + * file it points at. + */ +export function parseFrontmatter(markdown: string): Skill["frontmatter"] { + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown); + if (!match) throw new Error("SKILL.md must begin with YAML frontmatter"); + const fields: Record = {}; + for (const line of match[1]!.split(/\r?\n/)) { + if (line.trim() === "") continue; + const field = /^([A-Za-z][\w-]*):\s+(.+)$/.exec(line); + if (!field) throw new Error(`Unsupported SKILL.md frontmatter line: ${line}`); + const value = field[2]!.trim(); + if (/^["'[{|>&*!]/.test(value)) throw new Error(`Unsupported SKILL.md frontmatter value for ${field[1]}`); + fields[field[1]!] = value; + } + if (!fields.name || !fields.description) throw new Error("SKILL.md frontmatter requires name and description"); + return fields as Skill["frontmatter"]; +} + +async function listFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all( + entries.map((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? listFiles(path) : Promise.resolve(entry.isFile() ? [path] : []); + }), + ); + return nested.flat().sort(); +} + +export async function loadSkill(directory: string): Promise { + const markdown = await readFile(join(directory, "SKILL.md"), "utf8"); + const frontmatter = parseFrontmatter(markdown); + const base = `skill://${frontmatter.name}`; + const files = await Promise.all( + (await listFiles(directory)).map(async (absolute): Promise => { + const bytes = await readFile(absolute); + const path = relative(directory, absolute).split(sep).join("/"); + return { + uri: `${base}/${path}`, + path, + mimeType: MIME_TYPES[extname(path)] ?? "text/plain", + text: bytes.toString("utf8"), + digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + size: bytes.length, + }; + }), + ); + return { uri: `${base}/SKILL.md`, frontmatter, files }; +} + +/** Every immediate subdirectory of `root` that contains a SKILL.md is a skill. */ +export async function loadSkills(root: string = DEFAULT_SKILLS_ROOT): Promise { + const entries = await readdir(root, { withFileTypes: true }); + const skills: Skill[] = []; + for (const entry of entries.filter((candidate) => candidate.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) { + const skill = await loadSkill(join(root, entry.name)); + // The spec requires the URI's final segment to equal the skill name, and + // Agent Skills requires the name to equal the directory name. + if (skill.frontmatter.name !== entry.name) { + throw new Error(`Skill directory ${entry.name} declares name ${skill.frontmatter.name}`); + } + skills.push(skill); + } + return skills; +} diff --git a/test/applier.test.ts b/test/applier.test.ts index 8c84fdb..830bd7a 100644 --- a/test/applier.test.ts +++ b/test/applier.test.ts @@ -6,92 +6,8 @@ import test from "node:test"; import { applyPatchPlan } from "../src/applier.js"; import { exportConfiguration } from "../src/exporter.js"; -import type { DeviceDescriptor } from "../src/model.js"; -import type { ConfigurationWriteConnection, MessageHandler } from "../src/midi.js"; import { createPatchPlan } from "../src/planner.js"; -import { assertConfigurationWrite, assertReadOnlyRequest } from "../src/protocol.js"; - -const GLOBALS = [0, 4, 1, 1, 2, 0, 3, 9, 4, 0, 5, 0, 6, 8, 7, 0, 8, 63, 9, 127, 31, 127, 32, 127, 33, 0, 34, 6, 35, 3, 36, 7, 37, 1, 38, 1]; -const BASE_ENCODER = [10, 0, 11, 0, 12, 1, 13, 2, 14, 0, 15, 0, 16, 1, 17, 0, 18, 1, 19, 25, 20, 5, 21, 63, 22, 2, 23, 0, 24, 5]; - -class FakeWritableTwister implements ConfigurationWriteConnection { - readonly writes: number[][] = []; - private readonly handlers = new Set(); - private readonly encoders = new Map(); - private globals: number[]; - private failGlobalWrite = false; - - failNextGlobalWrite(): void { - this.failGlobalWrite = true; - } - - constructor(globals: number[] = GLOBALS) { - this.globals = [...globals]; - for (let tag = 1; tag <= 64; tag += 1) this.encoders.set(tag, [...BASE_ENCODER]); - } - - send(message: ArrayLike): void { - assertReadOnlyRequest(message); - const bytes = Array.from(message); - if (bytes[4] === 5) this.emit([0xf0, 0, 1, 0x79, 5, 1, 1, 2, 3, 4, 5, 6, 7, 8, 0xf7]); - if (bytes[4] === 2) this.emit([0xf0, 0, 1, 0x79, 2, 1, ...this.globals, 0xf7]); - if (bytes[4] === 4) { - const requested = bytes[6]!; - if (requested === 65) { - this.emit([0xf0, 0, 1, 0x79, 4, 0, 65, 1, 1, 0, 0xf7]); - return; - } - const ordinal = requested === 0 ? 64 : requested; - const data = this.encoders.get(ordinal)!; - this.emit([0xf0, 0, 1, 0x79, 4, 0, requested, 1, 2, 24, ...data.slice(0, 24), 0xf7]); - this.emit([0xf0, 0, 1, 0x79, 4, 0, requested, 2, 2, 6, ...data.slice(24), 0xf7]); - } - } - - sendConfigurationWrite(message: ArrayLike): void { - assertConfigurationWrite(message); - const bytes = Array.from(message); - this.writes.push(bytes); - if (bytes[4] === 1) { - if (this.failGlobalWrite) { - this.failGlobalWrite = false; - throw new Error("simulated transport failure"); - } - const current = new Map(); - for (let index = 0; index < this.globals.length; index += 2) current.set(this.globals[index]!, this.globals[index + 1]!); - const data = bytes.slice(5, -1); - for (let index = 0; index < data.length; index += 2) current.set(data[index]!, data[index + 1]!); - this.globals = [...current.entries()].sort(([a], [b]) => a - b).flatMap(([tag, value]) => [tag, value]); - return; - } - if (bytes[4] !== 4) throw new Error("Test only supports global and encoder writes"); - const ordinal = bytes[6] === 0 ? 64 : bytes[6]!; - const current = new Map(); - const stored = this.encoders.get(ordinal)!; - for (let index = 0; index < stored.length; index += 2) current.set(stored[index]!, stored[index + 1]!); - const size = bytes[9]!; - const data = bytes.slice(10, 10 + size); - for (let index = 0; index < data.length; index += 2) current.set(data[index]!, data[index + 1]!); - this.encoders.set(ordinal, [...current.entries()].sort(([a], [b]) => a - b).flatMap(([tag, value]) => [tag, value])); - } - - subscribe(handler: MessageHandler): () => void { - this.handlers.add(handler); - return () => this.handlers.delete(handler); - } - - close(): void {} - - private emit(message: number[]): void { - for (const handler of [...this.handlers]) handler(message); - } -} - -const device: DeviceDescriptor = { - inputPort: { index: 0, name: "Twister" }, - outputPort: { index: 0, name: "Twister" }, - identity: { manufacturerId: [0, 1, 121], familyId: 5, modelId: 1, firmwareBytes: [32, 38, 7, 2], firmwareDate: "2026-07-02" }, -}; +import { device, FakeWritableTwister, GLOBALS } from "./fake-twister.js"; test("applies an eligible plan with backup, journal, and full snapshot verification", async () => { const connection = new FakeWritableTwister(); diff --git a/test/fake-twister.ts b/test/fake-twister.ts new file mode 100644 index 0000000..696e7a1 --- /dev/null +++ b/test/fake-twister.ts @@ -0,0 +1,89 @@ +// A simulated Twister that answers read requests and applies configuration +// writes to its own state, so tests can export, plan, apply, and read back +// without hardware. + +import type { DeviceDescriptor } from "../src/model.js"; +import type { ConfigurationWriteConnection, MessageHandler } from "../src/midi.js"; +import { assertConfigurationWrite, assertReadOnlyRequest } from "../src/protocol.js"; + +export const GLOBALS = [0, 4, 1, 1, 2, 0, 3, 9, 4, 0, 5, 0, 6, 8, 7, 0, 8, 63, 9, 127, 31, 127, 32, 127, 33, 0, 34, 6, 35, 3, 36, 7, 37, 1, 38, 1]; +const BASE_ENCODER = [10, 0, 11, 0, 12, 1, 13, 2, 14, 0, 15, 0, 16, 1, 17, 0, 18, 1, 19, 25, 20, 5, 21, 63, 22, 2, 23, 0, 24, 5]; + +export class FakeWritableTwister implements ConfigurationWriteConnection { + readonly writes: number[][] = []; + private readonly handlers = new Set(); + private readonly encoders = new Map(); + private globals: number[]; + private failGlobalWrite = false; + + failNextGlobalWrite(): void { + this.failGlobalWrite = true; + } + + constructor(globals: number[] = GLOBALS) { + this.globals = [...globals]; + for (let tag = 1; tag <= 64; tag += 1) this.encoders.set(tag, [...BASE_ENCODER]); + } + + send(message: ArrayLike): void { + assertReadOnlyRequest(message); + const bytes = Array.from(message); + if (bytes[4] === 5) this.emit([0xf0, 0, 1, 0x79, 5, 1, 1, 2, 3, 4, 5, 6, 7, 8, 0xf7]); + if (bytes[4] === 2) this.emit([0xf0, 0, 1, 0x79, 2, 1, ...this.globals, 0xf7]); + if (bytes[4] === 4) { + const requested = bytes[6]!; + if (requested === 65) { + this.emit([0xf0, 0, 1, 0x79, 4, 0, 65, 1, 1, 0, 0xf7]); + return; + } + const ordinal = requested === 0 ? 64 : requested; + const data = this.encoders.get(ordinal)!; + this.emit([0xf0, 0, 1, 0x79, 4, 0, requested, 1, 2, 24, ...data.slice(0, 24), 0xf7]); + this.emit([0xf0, 0, 1, 0x79, 4, 0, requested, 2, 2, 6, ...data.slice(24), 0xf7]); + } + } + + sendConfigurationWrite(message: ArrayLike): void { + assertConfigurationWrite(message); + const bytes = Array.from(message); + this.writes.push(bytes); + if (bytes[4] === 1) { + if (this.failGlobalWrite) { + this.failGlobalWrite = false; + throw new Error("simulated transport failure"); + } + const current = new Map(); + for (let index = 0; index < this.globals.length; index += 2) current.set(this.globals[index]!, this.globals[index + 1]!); + const data = bytes.slice(5, -1); + for (let index = 0; index < data.length; index += 2) current.set(data[index]!, data[index + 1]!); + this.globals = [...current.entries()].sort(([a], [b]) => a - b).flatMap(([tag, value]) => [tag, value]); + return; + } + if (bytes[4] !== 4) throw new Error("Test only supports global and encoder writes"); + const ordinal = bytes[6] === 0 ? 64 : bytes[6]!; + const current = new Map(); + const stored = this.encoders.get(ordinal)!; + for (let index = 0; index < stored.length; index += 2) current.set(stored[index]!, stored[index + 1]!); + const size = bytes[9]!; + const data = bytes.slice(10, 10 + size); + for (let index = 0; index < data.length; index += 2) current.set(data[index]!, data[index + 1]!); + this.encoders.set(ordinal, [...current.entries()].sort(([a], [b]) => a - b).flatMap(([tag, value]) => [tag, value])); + } + + subscribe(handler: MessageHandler): () => void { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + close(): void {} + + private emit(message: number[]): void { + for (const handler of [...this.handlers]) handler(message); + } +} + +export const device: DeviceDescriptor = { + inputPort: { index: 0, name: "Twister" }, + outputPort: { index: 0, name: "Twister" }, + identity: { manufacturerId: [0, 1, 121], familyId: 5, modelId: 1, firmwareBytes: [32, 38, 7, 2], firmwareDate: "2026-07-02" }, +}; diff --git a/test/mcp.test.ts b/test/mcp.test.ts new file mode 100644 index 0000000..8e18b30 --- /dev/null +++ b/test/mcp.test.ts @@ -0,0 +1,242 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { InMemoryTransport } from "@modelcontextprotocol/server"; +import { z } from "zod"; + +import { createMcpServerFactory } from "../src/mcp.js"; +import type { MidiBackend } from "../src/midi.js"; +import type { DeviceDescriptor } from "../src/model.js"; +import { parseFrontmatter, loadSkills, SKILLS_EXTENSION } from "../src/skills.js"; +import { device, FakeWritableTwister } from "./fake-twister.js"; + +const SkillEntry = z.object({ + uri: z.string(), + frontmatter: z.object({ name: z.string(), description: z.string() }).passthrough(), + resources: z.array(z.object({ uri: z.string(), digest: z.string(), size: z.number() })), +}); +// No `resultType` here: the v2 client consumes it as a protocol-level field +// before a result reaches the caller. The wire-level test below checks it. +const ListSkillsResult = z.object({ skills: z.array(SkillEntry), ttlMs: z.number(), cacheScope: z.string() }); +const GetSkillResult = z.object({ skill: SkillEntry, ttlMs: z.number(), cacheScope: z.string() }); + +class FakeBackend implements MidiBackend { + inFlight = 0; + maxInFlight = 0; + + constructor(private readonly devices: DeviceDescriptor[] = [device]) {} + + async discover(): Promise { + this.inFlight += 1; + this.maxInFlight = Math.max(this.maxInFlight, this.inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + this.inFlight -= 1; + return this.devices; + } + + connect(): FakeWritableTwister { + return new FakeWritableTwister(); + } + + connectForApply(): never { + throw new Error("The MCP server must never open a write connection"); + } +} + +async function connect(backend: MidiBackend = new FakeBackend()): Promise { + const server = createMcpServerFactory({ backend, skills: await loadSkills(), version: "0.0.0-test" })(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "test", version: "0.0.0" }); + await client.connect(clientTransport); + return client; +} + +function text(result: Awaited>): string { + const [first] = result.content as Array<{ type: string; text: string }>; + return first!.text; +} + +test("offers only read and offline-plan tools, all annotated read-only", async () => { + const client = await connect(); + const { tools } = await client.listTools(); + + // A write tool must arrive deliberately with the issue 14 fixes, not slip in. + assert.deepEqual(tools.map((tool) => tool.name).sort(), ["export_configuration", "list_devices", "plan_changes"]); + for (const tool of tools) assert.equal(tool.annotations?.readOnlyHint, true, tool.name); +}); + +test("exports a configuration and plans against its snapshotId", async () => { + const client = await connect(); + const exported = JSON.parse(text(await client.callTool({ name: "export_configuration", arguments: {} }))) as { + snapshotId: string; + configuration: { banks: unknown[] }; + }; + assert.match(exported.snapshotId, /^[0-9a-f]{64}$/); + assert.equal(exported.configuration.banks.length, 4); + + const result = await client.callTool({ + name: "plan_changes", + arguments: { snapshotId: exported.snapshotId, changes: [{ path: "bank.1.encoder.1.colors.active", value: "green" }] }, + }); + assert.equal(result.isError, undefined); + const plan = JSON.parse(text(result)) as { planId: string; snapshotHash: string; changes: unknown[] }; + assert.match(plan.planId, /^sha256:/); + assert.equal(plan.changes.length, 1); +}); + +test("plans only from snapshots the server exported itself", async () => { + const client = await connect(); + const result = await client.callTool({ + name: "plan_changes", + arguments: { snapshotId: "0".repeat(64), changes: [{ path: "bank.1.encoder.1.colors.active", value: "green" }] }, + }); + + assert.equal(result.isError, true); + assert.match(text(result), /Unknown snapshotId/); +}); + +test("refuses to guess between several connected Twisters", async () => { + const second = { ...device, inputPort: { index: 1, name: "Twister 2" }, outputPort: { index: 1, name: "Twister 2" } }; + const client = await connect(new FakeBackend([device, second])); + const result = await client.callTool({ name: "export_configuration", arguments: {} }); + + assert.equal(result.isError, true); + assert.match(text(result), /Found 2 devices; select one with the device argument/); +}); + +test("runs device operations one at a time", async () => { + const backend = new FakeBackend(); + const client = await connect(backend); + await Promise.all([ + client.callTool({ name: "list_devices", arguments: {} }), + client.callTool({ name: "export_configuration", arguments: {} }), + client.callTool({ name: "list_devices", arguments: {} }), + ]); + + assert.equal(backend.maxInFlight, 1); +}); + +test("declares the skills extension and lists the skill with digests of the served bytes", async () => { + const client = await connect(); + assert.deepEqual(client.getServerCapabilities()?.extensions?.[SKILLS_EXTENSION], {}); + + const listing = await client.request({ method: "skills/list", params: {} }, ListSkillsResult); + const skill = listing.skills.find((entry) => entry.frontmatter.name === "mft-configurator"); + assert.ok(skill); + assert.equal(skill.uri, "skill://mft-configurator/SKILL.md"); + assert.ok(skill.resources.some((resource) => resource.uri === skill.uri)); + assert.ok(skill.resources.some((resource) => resource.uri === "skill://mft-configurator/references/settings.md")); + + // A host verifies every read against the manifest, so a mismatch here is a + // skill no conforming host will load. + for (const resource of skill.resources) { + const read = await client.readResource({ uri: resource.uri }); + const bytes = Buffer.from((read.contents[0] as { text: string }).text, "utf8"); + assert.equal(bytes.length, resource.size, resource.uri); + assert.equal(`sha256:${createHash("sha256").update(bytes).digest("hex")}`, resource.digest, resource.uri); + } + + const got = await client.request({ method: "skills/get", params: { uri: skill.uri } }, GetSkillResult); + assert.deepEqual(got.skill, skill); +}); + +test("skills/get rejects a URI that is not a served skill with Invalid params", async () => { + const client = await connect(); + await assert.rejects( + client.request({ method: "skills/get", params: { uri: "skill://mft-configurator/references/settings.md" } }, GetSkillResult), + (error: { code?: number }) => error.code === -32602, + ); +}); + +test("the server instructions point at the skill", async () => { + const client = await connect(); + assert.match(client.getInstructions() ?? "", /skill:\/\/mft-configurator\/SKILL\.md/); +}); + +test("frontmatter the parser cannot render verbatim is refused rather than approximated", () => { + assert.deepEqual(parseFrontmatter("---\nname: a\ndescription: b: c\n---\nbody"), { name: "a", description: "b: c" }); + assert.throws(() => parseFrontmatter("---\nname: a\ndescription: b\nmetadata:\n key: value\n---\n"), /Unsupported/); + assert.throws(() => parseFrontmatter("---\nname: a\ndescription: \"quoted\"\n---\n"), /Unsupported/); + assert.throws(() => parseFrontmatter("# no frontmatter"), /must begin/); +}); + +test("skills/list and skills/get carry resultType, ttlMs, and cacheScope on the wire", async () => { + // The extension requires all three on both results, and an SDK client strips + // resultType before returning a result, so read the raw stdio stream instead. + const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); + const child = spawn(process.execPath, ["--import", "tsx", cli, "mcp"], { + cwd: fileURLToPath(new URL("..", import.meta.url)), + stdio: ["pipe", "pipe", "ignore"], + }); + const send = (message: object): void => void child.stdin.write(`${JSON.stringify(message)}\n`); + const results = new Map>(); + const done = new Promise((resolve, reject) => { + let buffer = ""; + child.on("error", reject); + child.stdout.on("data", (chunk: Buffer) => { + buffer += chunk.toString("utf8"); + for (let newline = buffer.indexOf("\n"); newline >= 0; newline = buffer.indexOf("\n")) { + const message = JSON.parse(buffer.slice(0, newline)) as { id?: number; result?: Record }; + buffer = buffer.slice(newline + 1); + if (message.id === undefined || !message.result) continue; + results.set(message.id, message.result); + if (message.id === 1) { + send({ jsonrpc: "2.0", method: "notifications/initialized" }); + send({ jsonrpc: "2.0", id: 2, method: "skills/list", params: {} }); + send({ jsonrpc: "2.0", id: 3, method: "skills/get", params: { uri: "skill://mft-configurator/SKILL.md" } }); + } + if (results.has(2) && results.has(3)) resolve(); + } + }); + }); + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "wire", version: "0" } } }); + try { + await done; + } finally { + child.kill(); + } + + for (const id of [2, 3]) { + const result = results.get(id)!; + assert.equal(result.resultType, "complete"); + assert.equal(typeof result.ttlMs, "number"); + assert.equal(result.cacheScope, "public"); + } +}); + +// Any stray write to stdout corrupts the protocol stream, so these drive the +// real entry point rather than the in-process server. The two cases are the two +// protocol eras: the 2025 `initialize` handshake most hosts speak today, and +// the 2026-07-28 `server/discover` handshake the Skills Extension targets. +for (const [era, mode, expectedVersion] of [ + ["2025", "legacy", "2025-11-25"], + ["2026-07-28", { pin: "2026-07-28" }, "2026-07-28"], +] as const) { + test(`mft-config mcp serves the ${era} protocol over stdio with the skills extension declared`, async () => { + const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); + const client = new Client({ name: "test", version: "0.0.0" }, { versionNegotiation: { mode } }); + await client.connect( + new StdioClientTransport({ command: process.execPath, args: ["--import", "tsx", cli, "mcp"], cwd: fileURLToPath(new URL("..", import.meta.url)), stderr: "pipe" }), + ); + try { + assert.equal(client.getNegotiatedProtocolVersion(), expectedVersion); + assert.equal(client.getServerVersion()?.name, "mft-config"); + assert.deepEqual(client.getServerCapabilities()?.extensions?.[SKILLS_EXTENSION], {}); + const { tools } = await client.listTools(); + assert.equal(tools.length, 3); + const listing = await client.request({ method: "skills/list", params: {} }, ListSkillsResult); + assert.deepEqual( + listing.skills.map((skill) => skill.uri), + ["skill://mft-configurator/SKILL.md"], + ); + } finally { + await client.close(); + } + }); +} diff --git a/test/run-tests.mjs b/test/run-tests.ts similarity index 100% rename from test/run-tests.mjs rename to test/run-tests.ts