# OpenSolo MCP foundation

This directory holds the MCP (Model Context Protocol) server that lets agents
like Claude Code and Cursor read OpenSolo state and drive its panes. The
foundation is split into a tiny stdio helper binary and the real server inside
`OpenSolo.app`:

```
Claude / Cursor / Codex
       │  stdio (MCP JSON-RPC)
       ▼
   opensolo-mcp                ← Sources/opensolo-mcp/
       │  unix socket (length-prefixed JSON frames)
       │  ~/Library/Caches/com.brtdv.OpenSolo/mcp.sock
       ▼
   MCPSocketServer             ← Sources/OpenSolo/MCP/Server/
       │
       ▼
   MCPRegistryDispatcher       ← Sources/OpenSolo/MCP/Registry/
       │
       ▼
   MCPTool implementations     ← Sources/OpenSolo/MCP/Tools/
```

The helper uses [`modelcontextprotocol/swift-sdk`](https://github.com/modelcontextprotocol/swift-sdk)
to handle the full MCP protocol (initialize, capabilities, `tools/list`,
`tools/call`, …). It forwards only two internal verbs across the unix socket:
`list_tools` and `call_tool`. Everything else is the SDK's problem.

## Registering a new tool

Adding a tool means three things and nothing more:

1. Implement `MCPTool` (`Sources/OpenSolo/MCP/Registry/MCPTool.swift`).
2. Register an instance with the `MCPToolRegistry` in `OpenSoloApp.swift`.
3. Write a unit test using `MCPRegistryDispatcher` directly.

### 1. Implement the protocol

```swift
struct MyTool: MCPTool {
    let descriptor = MCPToolDescriptor(
        name: "my_tool",
        description: "Does the thing.",
        inputSchema: .object([
            "type": .string("object"),
            "properties": .object([
                "input": .object([
                    "type": .string("string"),
                    "description": .string("What to do the thing on."),
                ]),
            ]),
            "required": .array([.string("input")]),
        ])
    )

    func invoke(arguments: JSONValue, context: MCPContext) async throws -> MCPToolResult {
        guard let input = arguments["input"]?.stringValue else {
            throw MCPToolError.invalidArguments("missing 'input'")
        }
        return .text("did the thing on \(input)")
    }
}
```

Keep the schema a literal JSON Schema object — we deliberately don't have a
schema-builder DSL.

### 2. Register at app startup

Tools register at app launch in `OpenSoloApp.swift`:

```swift
@State private var mcpServer: MCPSocketServer = {
    let registry = MCPToolRegistry()
    registry.register(MCPPingTool())
    registry.register(MyTool())          // ← add your tool here
    return MCPSocketServer(
        socketURL: MCPSocketPath.resolved(),
        dispatcher: MCPRegistryDispatcher(registry: registry)
    )
}()
```

Order in `register(_:)` calls doesn't matter — `list_tools` sorts by name.

### 3. Unit-test the tool

Tests go in `Tests/OpenSoloTests/`. Drive your tool through the registry
dispatcher rather than calling `invoke` directly — that way the test also
exercises argument-parsing and the JSON-RPC error path:

```swift
@Test func myToolHandlesValidInput() async throws {
    let registry = MCPToolRegistry()
    registry.register(MyTool())
    let dispatcher = MCPRegistryDispatcher(registry: registry)

    let request = MCPWireRequest(
        id: 1, method: "call_tool",
        params: .object([
            "name": .string("my_tool"),
            "arguments": .object(["input": .string("foo")]),
        ])
    )
    let frame = try JSONEncoder().encode(request)
    let data = try await dispatcher.handle(frame: frame, session: .init()) ?? Data()
    let response = try JSONDecoder().decode(MCPWireResponse.self, from: data)

    #expect(response.error == nil)
    #expect(response.result?["content"]?.arrayValue?.first?["text"]?.stringValue
            == "did the thing on foo")
}
```

## Reaching AppStore from a tool

`MCPContext` (currently just `sessionId`) is the seam for AppStore access.
**Don't** make your tool hold an `AppStore` reference directly — AppStore is
`@MainActor` and dragging it into a sendable tool fights strict concurrency
hard.

Instead, extend `MCPContext` with the specific projection your tool needs and
inject it at registry-construction time:

```swift
public struct MCPContext: Sendable {
    public let sessionId: UUID
    public let projects: @Sendable () async -> [ProjectSnapshot]   // ← new
}
```

Then in `OpenSoloApp.swift`, when the registry is built, capture `store` weakly
and hop to `@MainActor` inside the closure. This keeps the registry itself
free of UI-layer types so unit tests can still construct one with stubs.

## Wire format reference

Internal RPC (`opensolo-mcp` ↔ `OpenSolo.app`) over the unix socket:

```
[ 4-byte big-endian length ] [ N bytes UTF-8 JSON ]
```

Request shape:

```json
{ "id": 7, "method": "call_tool",
  "params": { "name": "ping", "arguments": { "message": "hi" } } }
```

Response shape (success):

```json
{ "id": 7, "result": { "content": [{"type":"text","text":"pong: hi"}], "isError": false } }
```

Response shape (failure):

```json
{ "id": 7, "error": { "code": -32601, "message": "unknown method 'foo'" } }
```

Error codes mirror the JSON-RPC well-known set (see `MCPWireError` in
`OpenSoloMCPCore`) so the helper can re-emit them verbatim to the MCP client.

## Security model

- Unix socket only (`AF_UNIX`, no TCP) with mode `0600` — only the running
  user can connect.
- No tokens, no per-client auth in v1. Filesystem permissions are the entire
  auth story; anyone who can connect already runs as your user.
- MCP server only listens while `OpenSolo.app` is alive. Quit the app and the
  socket disappears with it (`shutdownListen` unlinks it on stop).
