Implement get_script command for improved script retrieval in MCP

- Introduced a new `get_script` command to allow agents to read script source code directly from Roblox Studio, enhancing conflict resolution during sync operations.
- Updated the MCP stream handling to support the new command, including error handling for missing or invalid parameters.
- Enhanced the App component to manage the `get_script` command and its response, ensuring proper integration with the existing MCP functionality.
- Updated the TODO list to reflect the addition of the `get_script` command and its associated documentation requirements.

These changes aim to improve the overall functionality and user experience of the MCP integration, particularly in script management.
This commit is contained in:
Joe 2026-03-01 09:41:20 -06:00
parent 9aa3145b06
commit fd49602adb
8 changed files with 1487 additions and 207 deletions

View file

@ -0,0 +1,269 @@
---
name: MCP get_script endpoint
overview: Add a `get_script` MCP tool that reads script source from Studio. Accepts `id` (server Ref) or `fsPath` (resolved server-side to id via RojoTree). Plugin uses a shallow copy of instanceMap.fromIds retained from the last MCP sync for O(1) lookup.
todos:
- id: generalize-channels
content: Rename McpSyncState -> McpState, generalize command/result channels to serde_json::Value, rename sync_in_progress -> command_in_progress, update all references in mcp.rs, api.rs, mod.rs
status: completed
- id: rust-structs-and-tool
content: Add GetScriptCommand/GetScriptResult structs in mcp.rs, add get_script tool definition (with full workflow description) in handle_tools_list, add handle_get_script handler
status: completed
- id: api-resolution
content: "In api.rs: add command type routing in MCP stream handler (getScript bypasses active_api_connections guard), resolve fsPath -> id via tree for getScript commands, enrich result with id/fsPath"
status: completed
- id: plugin-mcpstream
content: "In McpStream.lua: add handler for data.type == 'getScript', forward to onGetScriptCommand callback"
status: completed
- id: plugin-retain-map
content: "In App/init.lua: at top of setConfirmCallback, shallow-copy instanceMap.fromIds into self._lastMcpFromIds (survives InstanceMap:stop())"
status: completed
- id: plugin-handler
content: "In App/init.lua: wire onGetScriptCommand, implement handleMcpGetScript with id-based lookup via self._lastMcpFromIds, source reading (regular + draft), SHA1 hash, include instancePath in response"
status: completed
- id: tests
content: Rust unit tests (serde, handler, guards, response formatting), api.rs resolution tests, plugin tests (id lookup via retained map, shallow copy survives stop(), source reading, hash)
status: completed
isProject: false
---
# MCP `get_script` Endpoint
## Design
The agent provides `id` (server Ref from a sync response) or `fsPath` (Atlas filesystem path). Both resolve to a server Ref which the plugin looks up via a shallow copy of `instanceMap.fromIds` retained from the last MCP sync. No raw instance paths, no QueryDescendants, no name ambiguity issues.
This endpoint exists for conflict resolution: agent calls `atlas_sync`, gets a conflict response with `id` and `fsPath` per change, then calls `get_script` to read the Studio version of conflicting scripts. The `studioHash` in the response feeds back into subsequent sync override verification.
```mermaid
sequenceDiagram
participant Agent
participant mcpRs as mcp.rs
participant apiRs as api.rs
participant Plugin as Plugin McpStream
Agent->>mcpRs: get_script(id OR fsPath, fromDraft?)
mcpRs->>apiRs: command_tx (Value)
Note over apiRs: If fsPath: resolve to id via RojoTree
apiRs->>Plugin: {type: "getScript", requestId, id, fromDraft}
Plugin->>Plugin: self._lastMcpFromIds[id] (O(1))
Plugin->>Plugin: Read Source (or draft), compute SHA1
Plugin->>apiRs: {requestId, status, source, studioHash, className, instancePath}
Note over apiRs: Enrich with id, fsPath
apiRs->>mcpRs: result_tx (Value)
mcpRs->>Agent: Tool response with source + metadata
```
## Channel Generalization
Change `McpSyncState` channels from typed structs to `serde_json::Value` so any command/result can flow through. This enables `get_script` and future endpoints without additional channel plumbing.
- Rename `McpSyncState` -> `McpState`
- Rename `sync_in_progress` -> `command_in_progress` (any MCP command blocks others)
- `command_tx: watch::Sender<Option<Value>>` (was `Option<McpSyncCommand>`)
- `result_tx: Mutex<Option<oneshot::Sender<Value>>>` (was `oneshot::Sender<McpSyncResult>`)
Each handler serializes its command struct to Value before sending, deserializes from Value on response.
## Retained InstanceMap (Shallow Copy)
**Critical:** `InstanceMap:stop()` iterates all entries and calls `removeInstance`, which sets `fromIds[id] = nil` for every entry. This is called by `ServeSession:__stopInternal()` during `endSession()`. Storing a reference to the InstanceMap object or its `fromIds` table gives an empty map after session teardown.
**Solution:** At the top of `setConfirmCallback` (before any branch that could trigger session teardown), make a shallow copy of `fromIds`:
```lua
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
local retainedFromIds = {}
for id, inst in instanceMap.fromIds do
retainedFromIds[id] = inst
end
self._lastMcpFromIds = retainedFromIds
-- ... rest of existing callback logic
end)
```
This copy:
- Survives `InstanceMap:stop()` because it's a separate table
- Is O(N) but fast (just string key -> Instance ref copies, typical project has a few thousand entries)
- Is replaced on the next MCP sync (each sync creates a fresh copy)
- Stale entries handled gracefully: if `instance.Parent == nil`, the instance was destroyed since the sync
- Cleared when MCP stream disconnects (`self._lastMcpFromIds = nil`)
**Note:** The `already_connected` early return (line 1131-1138 in `startMcpSync`) does NOT create a ServeSession, so `_lastMcpFromIds` is not updated in that case -- it retains whatever value it had from the last actual sync. This is correct behavior.
## get_script Input Schema
```json
{
"id": "00abcdef01234567890abcdef0123456",
"fsPath": "src/server/MyScript.server.luau",
"fromDraft": false
}
```
- `id`: Server Ref (32-char hex) from a previous sync response. Looked up via retained `_lastMcpFromIds` on plugin.
- `fsPath`: Atlas filesystem path relative to project root. Resolved server-side to `id` via RojoTree.
- Priority: `id` > `fsPath`. At least one required.
- `fromDraft`: If true, read unsaved editor content via `ScriptEditorService:GetEditorSource`. Default: false.
## get_script Response
```
Script source for ServerScriptService/MyScript (Script):
Hash: a1b2c3d4e5...
<json>
{"status":"success","source":"print('hello')","studioHash":"a1b2c3...","className":"Script","instancePath":"ServerScriptService/MyScript","id":"00abcdef...","fsPath":"src/server/MyScript.server.luau","isDraft":false}
</json>
```
- `instancePath`: Human-readable instance path derived from `instance:GetFullName()` on the plugin side, reformatted from `game.A.B.C` to `A/B/C`. Included for readability and logging; not used for any lookup.
## Tool Description
The `get_script` tool definition in `handle_tools_list` must include a comprehensive description that teaches the agent the full workflow:
```
Read a script's source code from Roblox Studio. The id and fsPath parameters come from
a previous atlas_sync response's change entries.
If you haven't run atlas_sync yet, call atlas_sync(mode: "dryrun") first -- this
establishes the instance mapping without applying any changes, then you can call
get_script with the id or fsPath from that response.
Conflict resolution workflow:
1. atlas_sync(mode: "fastfail") -- get changes with studioHash, fail if unresolved
2. get_script(id: "...") -- read the Studio version of a conflicting script
3. Merge the Studio source with your local changes, write to filesystem
4. atlas_sync(overrides: [{id, direction: "push", studioHash: "...from get_script response..."}])
The studioHash in the response is the SHA1 of the git blob format of the script's Source
property. Pass it as the studioHash in sync overrides to verify the script hasn't changed
between your get_script call and the retry sync.
```
## Server-Side Resolution (api.rs)
When api.rs receives a `getScript` command from the channel:
1. **If `fsPath` present** (and no `id`): `project_root.join(fsPath)` -> `tree.get_ids_at_path(&abs_path)` -> get first Ref -> verify exists and is a script class -> inject as `id` in the command forwarded to plugin. Store fsPath for response enrichment.
2. **If `id` present**: optionally verify it exists in the tree and is a script class. Pass through to plugin. Resolve fsPath from tree metadata for response enrichment.
3. **On response from plugin**: enrich with `id` (Ref hex) and `fsPath`.
## Plugin-Side Resolution
The plugin receives `{ type: "getScript", requestId, id, fromDraft }`:
```lua
local fromIds = self._lastMcpFromIds
if not fromIds then
-- error: "No previous sync session. Run atlas_sync(mode: \"dryrun\") first to establish the instance mapping."
end
local instance = fromIds[id]
if not instance then
-- error: "Instance not found by id. The sync session may be stale, run atlas_sync again."
end
if instance.Parent == nil then
-- error: "Instance was deleted since last sync. Run atlas_sync again."
end
if not instance:IsA("LuaSourceContainer") then
-- error: "Instance is not a script (ClassName)"
end
```
After resolution:
1. Read source: if `fromDraft`, use `ScriptEditorService:GetEditorSource(instance)`; else `instance.Source`
2. Compute studioHash: `SHA1(buffer.fromstring("blob " .. #source .. "\0" .. source))`
3. Build instancePath: `string.gsub(instance:GetFullName(), "^game%.", ""):gsub("%.", "/")`
4. Return `{ requestId, status = "success", source, studioHash, className = instance.ClassName, instancePath }`
## Guards
- `command_in_progress` prevents concurrent MCP commands (channel has one result slot)
- `plugin_stream_connected` required (otherwise no way to communicate with Studio)
- NOT blocked by `active_api_connections > 0`: In api.rs, the `active_api_connections` guard must only apply to `sync` commands, not `getScript`. The command type routing checks the `type` field before this guard. Reading a script is safe during live sync; if no retained map exists, the plugin returns a clear error.
- If `id` or `fsPath` resolves to a non-script class in the tree, return error immediately from api.rs without plugin round-trip
## Files to Change
### Rust: `[src/web/mcp.rs](src/web/mcp.rs)`
- Rename `McpSyncState` -> `McpState`, generalize channels to `Value`
- Add `GetScriptCommand` struct: `{ request_id, id?, fs_path?, from_draft }`
- Add `GetScriptResult` struct: `{ status, source?, studio_hash?, class_name?, instance_path?, id?, fs_path?, is_draft?, message? }`
- Add `get_script` tool definition in `handle_tools_list` with full workflow description and inputSchema (id, fsPath, fromDraft)
- Add `handle_get_script` handler (parse params, validate at least one of id/fsPath, send command, format response)
- Update `handle_atlas_sync` to serialize/deserialize via Value
- Update all references from `McpSyncState` to `McpState`
- Tests: wire type serde, handler parse, response formatting
### Rust: `[src/web/api.rs](src/web/api.rs)`
- Update `McpSyncState` -> `McpState` references
- Update channel types to `Value` in MCP stream handler
- Add command type routing: check `type` field on received command Value BEFORE the `active_api_connections` guard. `sync` goes through the guard; `getScript` bypasses it.
- For `getScript`: resolve fsPath -> id via tree if needed, verify script class, forward to plugin with `id`, enrich result with id/fsPath on response
- For `sync`: existing behavior (forward, enrich fsPath on result)
### Rust: `[src/web/mod.rs](src/web/mod.rs)` (if `McpSyncState` is referenced)
- Update type references
### Plugin: `[plugin/src/McpStream.lua](plugin/src/McpStream.lua)`
- Add handler for `data.type == "getScript"`
- Call `self._onGetScriptCommand(data.requestId, data)`, expect Promise result
- Send JSON result back over WebSocket (same pattern as sync)
### Plugin: `[plugin/src/App/init.lua](plugin/src/App/init.lua)`
- **Shallow copy instanceMap.fromIds**: At the top of `setConfirmCallback` in `startMcpSync`, copy `instanceMap.fromIds` into `self._lastMcpFromIds`. This must happen before any branch (the copy survives `InstanceMap:stop()` which clears the original).
- Clear `self._lastMcpFromIds = nil` when MCP stream disconnects.
- Wire `onGetScriptCommand` callback in McpStream constructor.
- Add `handleMcpGetScript(requestId, params)` method:
- Lookup via `self._lastMcpFromIds[id]`
- Handle missing map ("Run atlas_sync first")
- Handle stale entries (not found, Parent == nil)
- LuaSourceContainer check
- Source reading (regular + draft via ScriptEditorService)
- SHA1 hash computation (using existing `SHA1.luau`)
- Build instancePath from `instance:GetFullName()`
- Return Promise resolving to result table
## Test Plan
### Rust unit tests (mcp.rs)
- `GetScriptResult` serde round-trip with all fields (including `instancePath`)
- `handle_tools_list` includes `get_script` tool with correct inputSchema and workflow description
- `handle_get_script` returns error when no plugin stream connected
- `handle_get_script` returns error when command already in progress
- `handle_get_script` requires at least one of id/fsPath
- Response formatting: success case includes `<json>` block, error case sets `isError`
### Rust integration (api.rs)
- `getScript` with `fsPath`: resolves via `get_ids_at_path`, injects `id`, forwards to plugin
- `getScript` with `id`: passes through, resolves `fsPath` for enrichment
- `getScript` with non-script `fsPath`: returns error before forwarding
- `getScript` with non-existent `fsPath`: returns error
- `getScript` bypasses `active_api_connections > 0` guard (command type routing)
- Result enrichment adds `id` and `fsPath`
### Plugin tests
- Shallow copy survives `InstanceMap:stop()`: copy retains entries after original is cleared
- Id lookup: found, not found, stale (Parent nil), no retained map
- Non-script instance: returns error
- Source reading: regular source, draft source via ScriptEditorService
- StudioHash: correct git blob format SHA1
- instancePath: correctly derived from GetFullName
- Retained map cleared on MCP stream disconnect
- `already_connected` early return does not update retained map

View file

@ -0,0 +1,379 @@
---
name: MCP sync endpoint enhancement
overview: Enhance the `atlas_sync` MCP tool to support modes (standard/manual/fastfail/dryrun), agent-specified override auto-accepts with hash + property verification, and enriched change metadata (studioHash, property before/after values, className, patchType, fsPath) in the response.
todos:
- id: rust-structs
content: Add SyncOverride struct (id-based matching, with expectedProperties), expand McpSyncCommand (mode, overrides), expand SyncChange (id, className, patchType, studioHash, defaultSelection, fsPath, properties) in mcp.rs
status: completed
- id: rust-handler
content: Update handle_atlas_sync to parse mode/overrides from params, include in command, handle new statuses (dryrun, fastfail_unresolved), emit structured JSON in response
status: completed
- id: rust-tool-schema
content: Update atlas_sync inputSchema in handle_tools_list with mode and overrides parameters
status: completed
- id: rust-api-enrich
content: In api.rs MCP stream handler, enrich changes with fsPath by looking up Ref in tree's InstigatingSource::Path after receiving plugin result. Handle Ref::from_str failures gracefully (plugin-generated GUIDs for removed nodes).
status: completed
- id: plugin-mcpstream
content: Update McpStream.lua to forward mode and overrides from sync command to onSyncCommand callback
status: completed
- id: plugin-startmcpsync
content: Update startMcpSync in App/init.lua to accept mode/overrides, implement mode logic (manual/fastfail/dryrun) and id-based override matching with studioHash + expectedProperties verification (decodeValue before trueEquals). Thread patch/instanceMap to _buildMcpChangeList.
status: completed
- id: plugin-changelist
content: Expand _buildMcpChangeList signature to receive patch + instanceMap. Encode properties using raw changedProperties (incoming) and encodeProperty via descriptor lookup (current). Skip Source for scripts. Handle encode failures gracefully.
status: completed
- id: rust-tests
content: Update existing mcp.rs tests and add new tests for modes, overrides, enriched SyncChange fields, and new statuses
status: completed
- id: test-suite
content: Full coverage test suite. Rust side - wire type serde for all new structs, mode passthrough, fsPath enrichment (valid Ref, invalid Ref, ProjectNode, missing instance), JSON response block parsing. Plugin side - _buildMcpChangeList property encoding (scripts skip Source, encode failures, multiple property types), override verification (studioHash match/mismatch, expectedProperties match/mismatch/decode, stale id), mode behavior (standard fast-forward, standard user-review, manual always-review, fastfail unresolved, fastfail all-resolved, dryrun returns without applying), end-to-end MCP stream forwarding of mode/overrides.
status: completed
isProject: false
---
# MCP Sync Endpoint Enhancement
## Context
The current `atlas_sync` tool takes no parameters and has one behavior: if all changes are git-auto-selected, fast-forward; otherwise show Studio UI and block. The agent has no way to control this, no way to auto-accept specific changes, and gets minimal metadata back.
The enhanced endpoint enables an agent workflow like:
1. `atlas_sync(mode: "fastfail")` -- get changes with hashes, fail if any are unresolved
2. `get_script(id)` -- read conflicting script from Studio (future endpoint, uses `id` from sync response)
3. Merge changes, write to filesystem
4. `atlas_sync(overrides: [{id, direction, studioHash}])` -- auto-accept with hash verification
## New Input Schema
```json
{
"mode": "standard",
"overrides": [
{
"id": "00abcdef01234567890abcdef0123456",
"direction": "push",
"studioHash": "a1b2c3...",
"expectedProperties": {
"Disabled": false
}
},
{
"id": "00abcdef01234567890abcdef0123457",
"direction": "push",
"expectedProperties": {
"Position": {"Vector3": [0, 5, 0]},
"Anchored": true
}
}
]
}
```
- **mode**: `"standard"` (default) | `"manual"` | `"fastfail"` | `"dryrun"`
- **overrides**: array of auto-accept directives per instance:
- `id` (required): the server Ref (32-char hex) from a previous sync response. Matched directly via `patchTree:getNode(id)` -- O(1), no ambiguity possible.
- `direction` (required): `"push"` or `"pull"`
- `studioHash` (scripts only): SHA1 of git blob of Source, verifies Source hasn't changed since agent last inspected
- `expectedProperties` (optional): map of property name to expected current Studio value. Plugin verifies each against the live instance. Uses RbxDom encoded format (same as response). If any mismatch, the override is rejected and the change becomes unresolved.
### Why `id` instead of `path`
Instance names are not unique in Roblox -- a parent can have multiple children with the same name. Paths built from `instance.Name` segments (e.g. `Workspace/Part`) are ambiguous when duplicates exist. The server Ref (`id`) is a unique 128-bit identifier per instance, assigned by the server and round-tripped through all responses. Using it eliminates the path ambiguity problem entirely on both the response and override sides, with no need for path escaping or debugId disambiguation.
The `path` field in responses remains for human readability (agents can display it, use it in logs). The `fsPath` field provides the unique Atlas filesystem path for file editing. Neither is used for override matching.
## New Response Change Schema
Currently each change is `{path, direction}`. Enhanced:
```json
{
"path": "ServerScriptService/MyScript",
"id": "00abcdef...",
"direction": "push",
"patchType": "Edit",
"className": "Script",
"studioHash": "a1b2c3...",
"defaultSelection": "push",
"fsPath": "src/server/MyScript.server.luau",
"properties": {
"Disabled": {
"current": false,
"incoming": true
}
}
}
```
For a non-script instance with property changes:
```json
{
"path": "Workspace/MyPart",
"id": "00abcdef...",
"direction": "push",
"patchType": "Edit",
"className": "Part",
"defaultSelection": "pull",
"fsPath": "src/workspace/MyPart.model.json5",
"properties": {
"Position": {
"current": {"Vector3": [0, 5, 0]},
"incoming": {"Vector3": [10, 5, 0]}
},
"Anchored": {
"current": false,
"incoming": true
}
}
}
```
- `studioHash`: SHA1 of `"blob <len>\0<source>"` (git blob format) of the script's Source in Studio. Only present for script classes. Used by agents for override verification.
- `fsPath`: Atlas filesystem path relative to project root. Enriched server-side from `InstigatingSource::Path`. Null for project-node instances or additions.
- `defaultSelection`: what git auto-selected (`"push"` / `"pull"` / null)
- `properties`: map of changed property name to `{current, incoming}` values in RbxDom encoded format. `current` is the live Studio value, `incoming` is the Atlas value. For scripts, Source is omitted from `properties` (use `studioHash` + `get_script` instead to avoid massive payloads). Only present for Edit patchType.
## Mode Behavior (plugin confirm callback)
| Mode | Behavior |
| ---------- | ------------------------------------------------------------------------------------------------------------------------- |
| `standard` | Current: fast-forward if all pre-selected, otherwise show UI |
| `manual` | Always show UI, never auto-accept |
| `fastfail` | If any change has null `defaultSelection` AND no matching override, resolve immediately with `fastfail_unresolved` status |
| `dryrun` | Compute full change list with hashes, resolve immediately, abort session without applying |
## Override Matching and Verification (plugin side)
For each override, the plugin:
1. **Match by `id`**: Look up the PatchTree node via `patchTree:getNode(override.id)`. If not found (stale id from a previous session), skip the override.
2. **studioHash verification** (scripts): If `override.studioHash` is provided, compute SHA1 of git blob of `node.instance.Source` and compare. Mismatch = override rejected (someone changed the script since the agent last looked).
3. **expectedProperties verification** (any instance): If `override.expectedProperties` is provided, for each property in the map:
a. **Decode** the expected value via `decodeValue(expectedValue, instanceMap)` -- the override arrives as RbxDom-encoded JSON (e.g. `{"Vector3": [0, 5, 0]}`), which must be decoded to a live Roblox value before comparison.
b. Read the current Studio value via `getProperty(node.instance, propName)`.
c. Compare using `trueEquals(decodedExpected, currentValue)` -- fuzzy float comparison, EnumItem handling, etc.
d. Any mismatch = override rejected (someone changed a property since the agent last looked).
4. If all verifications pass, set the node's selection to `override.direction`, treating it as pre-selected for mode logic.
5. If verification fails, the node remains unresolved. In `fastfail` mode this means the sync fails immediately with the unresolved changes listed. In `standard` mode the user sees them in the Studio UI.
This design ensures: no path ambiguity (id is unique), no path escaping needed, O(1) node lookup, and the agent never silently overwrites concurrent changes from other developers.
## Files to Change
### Rust: `[src/web/mcp.rs](src/web/mcp.rs)`
- Add `SyncOverride` struct: `{id, direction, studio_hash?, expected_properties?}`
- Expand `McpSyncCommand`: add `mode: String`, `overrides: Vec<SyncOverride>`
- Expand `SyncChange`: add `id`, `class_name`, `patch_type`, `studio_hash`, `default_selection`, `fs_path`, `properties` (all optional)
- Update `atlas_sync` tool definition in `handle_tools_list` with new inputSchema
- Update `handle_atlas_sync` to parse mode + overrides from params and include them in the command
- Handle new statuses (`fastfail_unresolved`, `dryrun`) in response formatting
- Update `handle_atlas_sync` response text to include richer change info (structured JSON instead of plain text list)
- Update tests
### Rust: `[src/web/api.rs](src/web/api.rs)` (MCP stream handler)
- Serialize mode + overrides into the WebSocket command to the plugin
- After receiving `McpSyncResult`, enrich each change's `fs_path`:
- Attempt to parse `change.id` as `Ref` (32-char hex, via `Ref::from_str`). If parse fails (e.g. plugin-generated GUID for removed nodes), leave `fs_path = None` and continue.
- Look up `tree.get_instance(ref).metadata.instigating_source`
- If `InstigatingSource::Path(p)`, strip project root prefix, set `fs_path`
- If instance not found or source is `ProjectNode`, leave `fs_path = None`
- Forward enriched result via oneshot
### Plugin: `[plugin/src/McpStream.lua](plugin/src/McpStream.lua)`
- Forward `data.mode` and `data.overrides` from the sync command to `self._onSyncCommand(data.requestId, data.mode, data.overrides)`
### Plugin: `[plugin/src/App/init.lua](plugin/src/App/init.lua)`
`**startMcpSync(requestId, mode, overrides)`:**
- Accept `mode` and `overrides` parameters
- In the confirm callback (which receives `instanceMap, patch, serverInfo`), after building PatchTree:
- Apply overrides: for each override, look up node by `patchTree:getNode(override.id)`, verify studioHash for scripts, verify expectedProperties via `decodeValue` + `trueEquals`, set selection
- `dryrun`: build full change list via `_buildMcpChangeList(patchTree, patch, instanceMap, nil, true)`, resolve with `status: "dryrun"`, return `"Abort"`
- `fastfail`: check if any node is unresolved after defaults + overrides; if so, resolve with `fastfail_unresolved` + full change list, return `"Abort"`
- `manual`: force into user-review flow (skip the `allPreSelected` fast-forward)
- `standard`: current behavior
- All calls to `_buildMcpChangeList` in this function must pass `patch` and `instanceMap` (available as closure variables from the confirm callback)
`**_buildMcpChangeList(patchTree, patch, instanceMap, selections, includeAll)`:**
Signature change: receives the original `patch` object and `instanceMap` in addition to the tree, because:
- `node.changeList` stores **decoded** Roblox values (Vector3 userdata, EnumItem, etc.) that cannot be JSON-serialized
- We need the raw `patch.updated[].changedProperties` for incoming values (already in RbxDom-encoded wire format)
- We need `encodeProperty(instance, propName, descriptor)` to encode current Studio values into the same wire format
- Both require looking up the `RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propName)` for each property
Implementation:
- Add `includeAll` parameter (for dryrun/fastfail: include all changes regardless of selection)
- Add `className = node.className`
- Add `patchType = node.patchType`
- Add `id = node.id` (server Ref, for fsPath enrichment). Note: for Removed nodes, this may be a plugin-generated GUID rather than a server Ref -- the Rust `fsPath` enrichment must handle `Ref::from_str` parse failures gracefully (set `fsPath = null`).
- Add `defaultSelection = node.defaultSelection`
- For script instances (`node.instance:IsA("LuaSourceContainer")`): compute and add `studioHash` = SHA1 of git blob of `instance.Source`
- Add `properties` map for Edit nodes: build a `patchLookup` from `patch.updated` keyed by `change.id`, then for each property in `patchLookup[node.id].changedProperties`:
- **Skip** `Source` for script classes (handled by `studioHash` + future `get_script`)
- `incoming`: use the raw value from `changedProperties[propName]` (already RbxDom-encoded wire format, JSON-safe)
- `current`: look up descriptor via `RbxDom.findCanonicalPropertyDescriptor(node.className, propName)`, then call `encodeProperty(node.instance, propName, descriptor)` to get the RbxDom-encoded current value
- Store as `{current = encodedCurrent, incoming = encodedIncoming}`
- If encoding fails for either side, skip that property (don't crash the response)
## New Statuses
| Status | When | isError |
| -------------------------------------------------------- | ---------------------------------------- | --------- |
| `dryrun` | Dryrun mode, returning what would happen | false |
| `fastfail_unresolved` | Fastfail mode, unresolved changes exist | true |
| (existing) `success`, `empty`, `rejected`, `error`, etc. | Unchanged | Unchanged |
## Response Format Enhancement
Currently the MCP tool response is plain text. After this change, for all statuses that include changes (success, fastfail, dryrun, rejected), include a structured JSON block inside the text content so the agent can parse it:
```
Sync completed successfully.
Changes:
- [push] ServerScriptService/MyScript (Edit, Script, fs=src/server/MyScript.server.luau)
- [pull] Workspace/MyPart (Edit, Part, fs=src/workspace/MyPart.model.json5)
<json>
{"status":"success","changes":[{"path":"ServerScriptService/MyScript","id":"00ab...","direction":"push","patchType":"Edit","className":"Script","studioHash":"abc123","defaultSelection":"push","fsPath":"src/server/MyScript.server.luau","properties":{"Disabled":{"current":false,"incoming":true}}},{"path":"Workspace/MyPart","id":"00cd...","direction":"pull","patchType":"Edit","className":"Part","defaultSelection":"pull","fsPath":"src/workspace/MyPart.model.json5","properties":{"Position":{"current":{"Vector3":[0,5,0]},"incoming":{"Vector3":[10,5,0]}}}}]}
</json>
```
The `<json>` block gives the agent machine-readable data while keeping the human-readable summary above it. Note: script Source is deliberately excluded from `properties` to avoid massive payloads -- agents use `studioHash` for verification and the future `get_script` tool to read actual source content when needed.
## Test Suite
Full coverage testing across both Rust and Lua, organized by layer.
### Rust: `src/web/mcp.rs` unit tests
**Wire type serde (extend existing `wire_types` module):**
- `SyncOverride` serializes/deserializes with camelCase (`id`, `direction`, `studioHash`, `expectedProperties`)
- `SyncOverride` defaults: missing `studioHash` and `expectedProperties` deserialize to `None`
- `McpSyncCommand` serializes `mode` and `overrides` fields
- `SyncChange` serializes all new optional fields (`id`, `className`, `patchType`, `studioHash`, `defaultSelection`, `fsPath`, `properties`)
- `SyncChange` omits `None` optional fields (skip_serializing_if)
- `McpSyncResult` with enriched `SyncChange` entries round-trips correctly
**Handler tests (extend existing `handler_tests` module):**
- `handle_tools_list` returns updated inputSchema with `mode` and `overrides`
- `handle_atlas_sync` parses `mode` from params and passes through to command
- `handle_atlas_sync` parses `overrides` from params and passes through to command
- `handle_atlas_sync` defaults `mode` to `"standard"` when omitted
- `handle_atlas_sync` defaults `overrides` to empty array when omitted
**Guard tests (extend existing `atlas_sync_guards` module):**
- Success result with enriched changes includes `<json>` block in response text
- Dryrun status maps to `isError: false`
- `fastfail_unresolved` status maps to `isError: true`
- Empty status still returns `isError: false`
- Rejected with enriched changes includes `<json>` block
### Rust: `src/web/api.rs` fsPath enrichment tests
- Valid Ref resolves to `InstigatingSource::Path` -> fsPath is set (relative to project root)
- Valid Ref with `InstigatingSource::ProjectNode` -> fsPath is None
- Invalid Ref string (plugin GUID) -> fsPath is None, no panic
- Ref for instance not in tree -> fsPath is None
- Multiple changes: only those with valid paths get fsPath
### Plugin: `_buildMcpChangeList` tests (`plugin/src/App/init.spec.lua` or dedicated file)
**Basic enrichment:**
- Edit node includes `id`, `className`, `patchType`, `defaultSelection`
- Add node includes `id`, `className`, `patchType = "Add"`
- Remove node includes `id`, `className`, `patchType = "Remove"`
- Script node includes `studioHash` (correct git blob SHA1)
- Non-script node has no `studioHash`
**Property encoding:**
- Edit node with `changedProperties` produces `properties` map with `{current, incoming}` in RbxDom-encoded format
- `Source` is skipped for script classes
- `Name` change appears in properties as `changedName`
- Failed encode for current value skips that property (no crash)
- Failed encode for incoming value skips that property (no crash)
- Multiple property types: Bool, Number, String, Vector3, Color3, Enum
**includeAll parameter:**
- `includeAll = false` with selections: only includes selected nodes
- `includeAll = false` without selections: includes all with defaultSelection or "push"
- `includeAll = true`: includes every node with patchType regardless of selection
### Plugin: Override verification tests
**id-based matching:**
- Override with valid node id matches correctly
- Override with unknown id is silently skipped
- Multiple overrides match their respective nodes
**studioHash verification:**
- Matching hash: override accepted, node selection set
- Mismatching hash: override rejected, node stays unresolved
- No studioHash on override for script: override accepted (no verification)
- studioHash on non-script: ignored (only Source hashing applies to scripts)
**expectedProperties verification:**
- All properties match: override accepted
- One property mismatch: override rejected entirely
- Empty expectedProperties: override accepted (no verification)
- Property value decoded via `decodeValue` before `trueEquals` comparison
- Fuzzy float comparison works (e.g. `0.30000001` matches `0.3`)
- EnumItem comparison works (e.g. `{Enum = 2}` vs `Enum.Material.Plastic`)
### Plugin: Mode behavior tests
**standard mode:**
- All pre-selected: fast-forwards, resolves `success`, returns Confirm with selections
- Some unresolved: shows Confirming UI, waits for user
- Empty patch: resolves `empty`
**manual mode:**
- All pre-selected: still shows Confirming UI (no fast-forward)
- User confirms: resolves `success`
- User aborts: resolves `rejected`
**fastfail mode:**
- All resolved (via defaults + overrides): fast-forwards, resolves `success`
- Some unresolved after defaults + overrides: resolves `fastfail_unresolved` immediately, returns `"Abort"`, includes full change list with `includeAll = true`
- Override verification failure makes node unresolved, triggers fastfail
**dryrun mode:**
- Resolves immediately with `status: "dryrun"`, returns `"Abort"`
- Change list includes all nodes with full metadata (properties, studioHash)
- Session is aborted without applying any changes
- Works with empty patch (resolves `empty`)
### Plugin: McpStream forwarding tests
- `{type: "sync", requestId, mode, overrides}` forwards all three fields to `onSyncCommand`
- Missing `mode` defaults to `"standard"` (or nil, handled by `startMcpSync`)
- Missing `overrides` defaults to empty table
- Result is JSON-encoded and sent back over WebSocket

View file

@ -341,11 +341,10 @@ can we allow the terminal to stay so i can like run some git commands myself wit
- we should steal this system fully: D:\UserGenerated\studio-rust-mcp-server\plugin\Utils\Paths.luau - we should steal this system fully: D:\UserGenerated\studio-rust-mcp-server\plugin\Utils\Paths.luau
- in the sync feedback it gives, we should include Paths.GetRelativePath so the agent is able to find it via the read_script ALONGSIDE the current info we can give so it can also find it on the Atlas filesystem - in the sync feedback it gives, we should include Paths.GetRelativePath so the agent is able to find it via the read_script ALONGSIDE the current info we can give so it can also find it on the Atlas filesystem
- investigate this further, because i just want to give the agent the best possible way to query roblox instances, and also atlas instances on the filesystem. if using this Paths module is an overstep and we can accomplish it with a fully atlas-based approach, that's fine - aslong as it works CONSISTENTLY and is simple. - investigate this further, because i just want to give the agent the best possible way to query roblox instances, and also atlas instances on the filesystem. if using this Paths module is an overstep and we can accomplish it with a fully atlas-based approach, that's fine - aslong as it works CONSISTENTLY and is simple.
* get_script_history PATH
-
* port all of the basic MCP endpoints offered via https://github.com/Roblox/studio-rust-mcp-server * port all of the basic MCP endpoints offered via https://github.com/Roblox/studio-rust-mcp-server
- integrate them into how we've done our mcp in atlas - integrate them into how we've done our mcp in atlas
- make sure to handle the placeid filtering like we already do when establishing the connection - make sure to handle the placeid filtering like we already do when establishing the connection
- we've done some actual fixes over at D:\UserGenerated\studio-rust-mcp-server on these, we should probably pull those fixed versions! we don't need that whole studio selection system from over there, though.
* syncback * syncback
- invokes plugin based syncback - invokes plugin based syncback
- ensure this doesn't interrupt the connection of the mcp, since i think we shutdown the api? we should just not shutdown the api or something - ensure this doesn't interrupt the connection of the mcp, since i think we shutdown the api? we should just not shutdown the api or something
@ -353,6 +352,8 @@ can we allow the terminal to stay so i can like run some git commands myself wit
- also add logging for commands (including the json) it runs and the resulting json. try to keep this on one line please, json compact. - also add logging for commands (including the json) it runs and the resulting json. try to keep this on one line please, json compact.
* ensure all mcp route documentation is well formed and gives the agent knowledge on how to use this system to the fullest. * ensure all mcp route documentation is well formed and gives the agent knowledge on how to use this system to the fullest.
- I did .md based docs over with my D:\UserGenerated\studio-rust-mcp-server codebase, we should probably switch to this as it's cleaner - I did .md based docs over with my D:\UserGenerated\studio-rust-mcp-server codebase, we should probably switch to this as it's cleaner
- Instruct on good sync flow, with get_script merging.
- If user requests get_script and we havn't sync'd yet, just do a dryrun sync to get the instance id map going.
[sync] [sync]
right now sync is running REALLY slow right now sync is running REALLY slow

View file

@ -211,6 +211,9 @@ function App:init()
onSyncCommand = function(requestId, mode, overrides) onSyncCommand = function(requestId, mode, overrides)
return self:startMcpSync(requestId, mode, overrides) return self:startMcpSync(requestId, mode, overrides)
end, end,
onGetScriptCommand = function(requestId, data)
return self:handleMcpGetScript(requestId, data)
end,
getPluginConfig = function(key) getPluginConfig = function(key)
return Settings:get(key) return Settings:get(key)
end, end,
@ -239,6 +242,7 @@ function App:willUnmount()
if self.mcpStream then if self.mcpStream then
self.mcpStream:stop() self.mcpStream:stop()
self.mcpStream = nil self.mcpStream = nil
self._lastMcpFromIds = nil
end end
end end
@ -1186,6 +1190,12 @@ function App:startMcpSync(requestId, mode, overrides)
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo) serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
cachedServerInfo = serverInfo cachedServerInfo = serverInfo
local retainedFromIds = {}
for id, inst in instanceMap.fromIds do
retainedFromIds[id] = inst
end
self._lastMcpFromIds = retainedFromIds
PatchSet.removeDataModelName(patch, instanceMap) PatchSet.removeDataModelName(patch, instanceMap)
if PatchSet.isEmpty(patch) then if PatchSet.isEmpty(patch) then
@ -1538,6 +1548,85 @@ function App:_buildMcpChangeList(patchTree, patch, instanceMap, selections, incl
return changes return changes
end end
function App:handleMcpGetScript(requestId, params)
local ScriptEditorService = game:GetService("ScriptEditorService")
return Promise.resolve():andThen(function()
local fromIds = self._lastMcpFromIds
if not fromIds then
return {
requestId = requestId,
status = "error",
message = 'No previous sync session. Run atlas_sync(mode: "dryrun") first to establish the instance mapping.',
}
end
local id = params.id
if not id then
return {
requestId = requestId,
status = "error",
message = "No id provided. The server should have resolved fsPath to an id.",
}
end
local instance = fromIds[id]
if not instance then
return {
requestId = requestId,
status = "error",
message = "Instance not found by id. The sync session may be stale, run atlas_sync again.",
}
end
if instance.Parent == nil then
return {
requestId = requestId,
status = "error",
message = "Instance was deleted since last sync. Run atlas_sync again.",
}
end
if not instance:IsA("LuaSourceContainer") then
return {
requestId = requestId,
status = "error",
message = `Instance is not a script (class: {instance.ClassName})`,
}
end
local source
local isDraft = params.fromDraft == true
if isDraft then
local ok, draft = pcall(ScriptEditorService.GetEditorSource, ScriptEditorService, instance)
if ok then
source = draft
else
source = instance.Source
isDraft = false
end
else
source = instance.Source
end
local gitBlob = "blob " .. tostring(#source) .. "\0" .. source
local studioHash = SHA1(buffer.fromstring(gitBlob))
local fullName = instance:GetFullName()
local instancePath = string.gsub(fullName, "^game%.", "")
instancePath = string.gsub(instancePath, "%.", "/")
return {
requestId = requestId,
status = "success",
source = source,
studioHash = studioHash,
className = instance.ClassName,
instancePath = instancePath,
isDraft = isDraft,
}
end)
end
function App:render() function App:render()
local pluginName = "Atlas " .. Version.display(Config.version) local pluginName = "Atlas " .. Version.display(Config.version)

View file

@ -17,6 +17,7 @@ function McpStream.new(options)
local self = setmetatable({}, McpStream) local self = setmetatable({}, McpStream)
self._onSyncCommand = options.onSyncCommand self._onSyncCommand = options.onSyncCommand
self._onGetScriptCommand = options.onGetScriptCommand
self._getPluginConfig = options.getPluginConfig self._getPluginConfig = options.getPluginConfig
self._wsClient = nil self._wsClient = nil
self._running = false self._running = false
@ -125,6 +126,33 @@ function McpStream:_tryConnect()
end) end)
end end
end) end)
elseif data.type == "getScript" and data.requestId then
Log.info("MCP stream: received getScript command (requestId={})", data.requestId)
local resultPromise = self._onGetScriptCommand(data.requestId, data)
resultPromise
:andThen(function(result)
if self._wsClient then
local json = HttpService:JSONEncode(result)
pcall(function()
self._wsClient:Send(json)
end)
end
end)
:catch(function(err)
Log.warn("MCP stream: getScript command failed: {}", tostring(err))
if self._wsClient then
local errorResult = HttpService:JSONEncode({
requestId = data.requestId,
status = "error",
message = tostring(err),
})
pcall(function()
self._wsClient:Send(errorResult)
end)
end
end)
end end
end) end)

View file

@ -125,7 +125,7 @@ pub async fn call(
serve_session: Arc<ServeSession>, serve_session: Arc<ServeSession>,
mut request: Request<Incoming>, mut request: Request<Incoming>,
syncback_signal: Arc<super::SyncbackSignal>, syncback_signal: Arc<super::SyncbackSignal>,
mcp_state: Arc<super::mcp::McpSyncState>, mcp_state: Arc<super::mcp::McpState>,
active_api_connections: Arc<std::sync::atomic::AtomicUsize>, active_api_connections: Arc<std::sync::atomic::AtomicUsize>,
) -> Response<Full<Bytes>> { ) -> Response<Full<Bytes>> {
let service = ApiService::new(serve_session); let service = ApiService::new(serve_session);
@ -3554,6 +3554,115 @@ fn enrich_sync_changes_with_fs_path(
} }
} }
// ---------------------------------------------------------------------------
// MCP get_script resolution helpers
// ---------------------------------------------------------------------------
struct GetScriptContext {
id: String,
fs_path: Option<String>,
}
fn resolve_get_script_command(
cmd: &serde_json::Value,
serve_session: &ServeSession,
) -> Result<(serde_json::Value, GetScriptContext), serde_json::Value> {
use crate::snapshot::InstigatingSource;
use std::str::FromStr;
let request_id = cmd
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let from_draft = cmd
.get("fromDraft")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let id_param = cmd.get("id").and_then(|v| v.as_str());
let fs_path_param = cmd.get("fsPath").and_then(|v| v.as_str());
let tree = serve_session.tree();
let project_path = serve_session.root_project().folder_location();
let (resolved_ref, resolved_id, resolved_fs_path) = if let Some(id_str) = id_param {
let r = rbx_dom_weak::types::Ref::from_str(id_str).map_err(|_| {
serde_json::json!({
"requestId": request_id,
"status": "error",
"message": format!("Invalid id format: {id_str}"),
})
})?;
let fs_path = tree.get_metadata(r).and_then(|meta| {
if let Some(InstigatingSource::Path(path)) = &meta.instigating_source {
path.strip_prefix(project_path)
.ok()
.map(|rel| rel.to_string_lossy().into_owned())
} else {
None
}
});
(r, id_str.to_string(), fs_path)
} else if let Some(fs_path_str) = fs_path_param {
let abs_path = project_path.join(fs_path_str);
let ids = tree.get_ids_at_path(&abs_path);
if ids.is_empty() {
return Err(serde_json::json!({
"requestId": request_id,
"status": "error",
"message": format!("No instance found at fsPath: {fs_path_str}"),
}));
}
let r = ids[0];
(r, r.to_string(), Some(fs_path_str.to_string()))
} else {
return Err(serde_json::json!({
"requestId": request_id,
"status": "error",
"message": "Either id or fsPath must be provided.",
}));
};
if let Some(inst) = tree.get_instance(resolved_ref) {
let class = inst.class_name();
if !matches!(class.as_ref(), "Script" | "LocalScript" | "ModuleScript") {
return Err(serde_json::json!({
"requestId": request_id,
"status": "error",
"message": format!("Instance is not a script (class: {class})"),
}));
}
}
let forwarded = serde_json::json!({
"type": "getScript",
"requestId": request_id,
"id": resolved_id,
"fromDraft": from_draft,
});
let ctx = GetScriptContext {
id: resolved_id,
fs_path: resolved_fs_path,
};
Ok((forwarded, ctx))
}
fn enrich_get_script_result(result: &mut serde_json::Value, ctx: &GetScriptContext) {
if let Some(obj) = result.as_object_mut() {
obj.insert("id".to_string(), serde_json::Value::String(ctx.id.clone()));
if let Some(fs_path) = &ctx.fs_path {
obj.insert(
"fsPath".to_string(),
serde_json::Value::String(fs_path.clone()),
);
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MCP stream WebSocket: plugin connects here to receive sync commands // MCP stream WebSocket: plugin connects here to receive sync commands
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -3561,7 +3670,7 @@ fn enrich_sync_changes_with_fs_path(
async fn handle_mcp_stream_upgrade( async fn handle_mcp_stream_upgrade(
request: &mut Request<Incoming>, request: &mut Request<Incoming>,
serve_session: Arc<ServeSession>, serve_session: Arc<ServeSession>,
mcp_state: Arc<super::mcp::McpSyncState>, mcp_state: Arc<super::mcp::McpState>,
active_api_connections: Arc<std::sync::atomic::AtomicUsize>, active_api_connections: Arc<std::sync::atomic::AtomicUsize>,
) -> Response<Full<Bytes>> { ) -> Response<Full<Bytes>> {
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@ -3602,10 +3711,10 @@ async fn handle_mcp_stream_upgrade(
async fn handle_mcp_stream_connection( async fn handle_mcp_stream_connection(
websocket: HyperWebsocket, websocket: HyperWebsocket,
serve_session: Arc<ServeSession>, serve_session: Arc<ServeSession>,
mcp_state: Arc<super::mcp::McpSyncState>, mcp_state: Arc<super::mcp::McpState>,
active_api_connections: Arc<std::sync::atomic::AtomicUsize>, active_api_connections: Arc<std::sync::atomic::AtomicUsize>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
use super::mcp::{McpSyncResult, PluginConfig}; use super::mcp::PluginConfig;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
let mut websocket = websocket.await?; let mut websocket = websocket.await?;
@ -3692,30 +3801,36 @@ async fn handle_mcp_stream_connection(
} }
} }
// Tracks context from the most recent outgoing command so we can enrich
// the plugin's response. For getScript: stores the resolved Ref + fsPath.
let mut pending_get_script_context: Option<GetScriptContext> = None;
loop { loop {
tokio::select! { tokio::select! {
// Watch for sync commands from the MCP handler.
result = command_rx.changed() => { result = command_rx.changed() => {
if result.is_err() { if result.is_err() {
break; break;
} }
let command = command_rx.borrow_and_update().clone(); let command = command_rx.borrow_and_update().clone();
let Some(cmd) = command else { let Some(cmd_value) = command else {
continue; continue;
}; };
// If we are in connected mode right now, immediately respond let cmd_type = cmd_value
// without forwarding to the plugin. .get("type")
if active_api_connections.load(Ordering::Relaxed) > 0 { .and_then(|t| t.as_str())
let result = McpSyncResult { .unwrap_or("");
request_id: cmd.request_id,
status: "already_connected".to_string(), // For sync commands, block if a live API connection is active.
changes: vec![], // getScript is a read-only operation that can proceed regardless.
message: Some( if cmd_type == "sync" && active_api_connections.load(Ordering::Relaxed) > 0 {
"Atlas is already connected in live sync mode.".to_string(), let result = serde_json::json!({
), "requestId": cmd_value.get("requestId").and_then(|v| v.as_str()).unwrap_or(""),
}; "status": "already_connected",
"changes": [],
"message": "Atlas is already connected in live sync mode.",
});
let mut slot = mcp_state let mut slot = mcp_state
.result_tx .result_tx
.lock() .lock()
@ -3726,31 +3841,70 @@ async fn handle_mcp_stream_connection(
continue; continue;
} }
let json_cmd = serde_json::to_string(&cmd)?; // For getScript, resolve fsPath -> id via tree before forwarding.
if cmd_type == "getScript" {
match resolve_get_script_command(&cmd_value, &serve_session) {
Ok((forwarded, ctx)) => {
pending_get_script_context = Some(ctx);
let json_cmd = serde_json::to_string(&forwarded)
.unwrap_or_else(|_| "{}".to_string());
if websocket.send(Message::Text(json_cmd.into())).await.is_err() {
log::debug!("MCP stream: plugin disconnected while sending getScript");
break;
}
continue;
}
Err(error_result) => {
let mut slot = mcp_state
.result_tx
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(tx) = slot.take() {
let _ = tx.send(error_result);
}
continue;
}
}
}
let json_cmd = serde_json::to_string(&cmd_value)?;
if websocket.send(Message::Text(json_cmd.into())).await.is_err() { if websocket.send(Message::Text(json_cmd.into())).await.is_err() {
log::debug!("MCP stream: plugin disconnected while sending command"); log::debug!("MCP stream: plugin disconnected while sending command");
break; break;
} }
} }
// Handle incoming messages from the plugin.
msg = websocket.next() => { msg = websocket.next() => {
match msg { match msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
if let Ok(mut result) = serde_json::from_str::<McpSyncResult>(&text) { if let Ok(mut result_value) = serde_json::from_str::<serde_json::Value>(&text) {
enrich_sync_changes_with_fs_path( // Enrich based on the command type that produced this result.
&mut result.changes, if let Some(ctx) = pending_get_script_context.take() {
&serve_session, enrich_get_script_result(&mut result_value, &ctx);
); } else if let Some(changes) = result_value.get_mut("changes") {
if let Some(changes_arr) = changes.as_array_mut() {
let mut sync_changes: Vec<super::mcp::SyncChange> = changes_arr
.iter()
.filter_map(|v| serde_json::from_value(v.clone()).ok())
.collect();
enrich_sync_changes_with_fs_path(
&mut sync_changes,
&serve_session,
);
*changes = serde_json::to_value(&sync_changes)
.unwrap_or(serde_json::Value::Array(vec![]));
}
}
let mut slot = mcp_state let mut slot = mcp_state
.result_tx .result_tx
.lock() .lock()
.unwrap_or_else(|e| e.into_inner()); .unwrap_or_else(|e| e.into_inner());
if let Some(tx) = slot.take() { if let Some(tx) = slot.take() {
let _ = tx.send(result); let _ = tx.send(result_value);
} }
} else { } else {
log::debug!("MCP stream: ignoring non-result message from plugin"); log::debug!("MCP stream: ignoring non-JSON message from plugin");
} }
} }
Some(Ok(Message::Close(_))) | None => { Some(Ok(Message::Close(_))) | None => {
@ -3773,7 +3927,6 @@ async fn handle_mcp_stream_connection(
} }
} }
// Clean up: mark disconnected, cancel any pending sync.
mcp_state mcp_state
.plugin_stream_connected .plugin_stream_connected
.store(false, Ordering::SeqCst); .store(false, Ordering::SeqCst);
@ -3785,19 +3938,18 @@ async fn handle_mcp_stream_connection(
*slot = None; *slot = None;
} }
// If there's a pending sync request, send a disconnect error.
{ {
let mut slot = mcp_state let mut slot = mcp_state
.result_tx .result_tx
.lock() .lock()
.unwrap_or_else(|e| e.into_inner()); .unwrap_or_else(|e| e.into_inner());
if let Some(tx) = slot.take() { if let Some(tx) = slot.take() {
let _ = tx.send(McpSyncResult { let _ = tx.send(serde_json::json!({
request_id: String::new(), "requestId": "",
status: "error".to_string(), "status": "error",
changes: vec![], "changes": [],
message: Some("Plugin MCP stream disconnected.".to_string()), "message": "Plugin MCP stream disconnected.",
}); }));
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -64,7 +64,7 @@ impl SyncbackSignal {
pub struct LiveServer { pub struct LiveServer {
serve_session: Arc<ServeSession>, serve_session: Arc<ServeSession>,
syncback_signal: Arc<SyncbackSignal>, syncback_signal: Arc<SyncbackSignal>,
mcp_state: Arc<mcp::McpSyncState>, mcp_state: Arc<mcp::McpState>,
active_api_connections: Arc<AtomicUsize>, active_api_connections: Arc<AtomicUsize>,
} }
@ -73,7 +73,7 @@ impl LiveServer {
LiveServer { LiveServer {
serve_session, serve_session,
syncback_signal: Arc::new(SyncbackSignal::new()), syncback_signal: Arc::new(SyncbackSignal::new()),
mcp_state: Arc::new(mcp::McpSyncState::new()), mcp_state: Arc::new(mcp::McpState::new()),
active_api_connections: Arc::new(AtomicUsize::new(0)), active_api_connections: Arc::new(AtomicUsize::new(0)),
} }
} }