From 4930e1b68d2c4d6dc236d1594d80999d28faf2b8 Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 08:42:57 +0100 Subject: [PATCH 01/13] fix(mcp): de-duplicate tool names, fix data-dir resolution, atomic RMW, converged tag matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 0 of the MCP/CLI extend-and-fix plan — the prerequisite bug-fix work that the later tiers (1a/1b/2/3) build on. Closes the original "MCP returns empty results" report and consolidates CLI/MCP semantics so the rest of the work can target one contract. Part A — Tool name de-duplication. The MCP server is already named "termq", so the "termq_" prefix on every tool name produced fully-qualified handles like "mcp__termq__termq_list". Renamed all ten tools to drop the prefix. No alias. Breaking; called out loudly in CHANGELOG. Fix B — AppProfile injection + load-error surfacing. Replaced the boolean "debug" parameter on BoardLoader/BoardWriter with a runtime "profile: AppProfile.Variant" that defaults to .current and resolves to .debug under TERMQ_DEBUG_BUILD. Tests inject .debug or .production explicitly. Resource handlers now propagate load failures instead of silently returning "[]" or "{}". termqmcp --verbose logs the resolved profile and data directory at startup. Fix C — Atomic read-modify-write in BoardWriter. updateCard, moveCard, and createCard previously split their read and write across two NSFileCoordinator claims, leaving a lost-update window. Collapsed into a single writingItemAt: claim via a new BoardWriter.atomicUpdate(...) helper. Added concurrency tests (T3.10, T3.12) verifying concurrent updates to different cards both survive and concurrent appends produce distinct orderIndex values. Issue E — Tag matching converged + literal default + opt-in regex. Both CardFilterEngine.filterByTag callers (CLI partial-match, MCP exact) collapse to one literal-by-default implementation. Regex opt-in via "re:" prefix on value or whole pattern. Invalid regex throws (no silent fallback). Includes a regression test for the rejected regex-by-default design (project=v1.2 must not match v1X2). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 12 + Docs/Help/reference/mcp.md | 28 +- Docs/Help/tutorials/ai-context.md | 4 +- Docs/Help/tutorials/mcp.md | 30 +- Docs/Help/tutorials/queued-actions.md | 2 +- Sources/MCPServer-CLI/main.swift | 42 +- Sources/MCPServerLib/HeadlessWriter.swift | 22 +- Sources/MCPServerLib/PromptHandlers.swift | 26 +- Sources/MCPServerLib/ResourceHandlers.swift | 116 +++--- Sources/MCPServerLib/SchemaDefinitions.swift | 20 +- Sources/MCPServerLib/ToolHandlers.swift | 36 +- Sources/TermQ/Views/ContentView.swift | 1 - Sources/TermQ/Views/TerminalCardView.swift | 2 +- Sources/TermQCLICore/CLI+Find.swift | 8 +- Sources/TermQCLICore/CLI+LLM.swift | 3 +- Sources/TermQCLICore/CLI+Mutations.swift | 23 +- Sources/TermQCLICore/CLI.swift | 16 +- Sources/TermQCore/TerminalCard.swift | 4 +- Sources/TermQShared/AppProfile.swift | 54 +++ Sources/TermQShared/BoardLoader.swift | 365 ++++++++++-------- Sources/TermQShared/CardFilterEngine.swift | 92 ++++- Sources/TermQShared/LocalYNHConfig.swift | 15 +- Sources/TermQShared/RepoConfigLoader.swift | 14 +- Tests/IntegrationTests/MCPToolReadTests.swift | 12 +- .../IntegrationTests/MCPToolWriteTests.swift | 16 +- .../MCPIntegrationTests.swift | 20 +- .../ResourceHandlersTests.swift | 37 +- Tests/MCPServerLibTests/ServerTests.swift | 38 +- Tests/TermQSharedTests/BoardLoaderTests.swift | 120 +++++- .../CardFilterEngineTests.swift | 75 ++-- 30 files changed, 801 insertions(+), 452 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de5c2d79..18e96c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed — BREAKING (MCP / CLI) + +- **MCP tool names lose the `termq_` prefix.** Every TermQ MCP tool is now reachable as `mcp__termq__` rather than `mcp__termq__termq_`. Affected tools: `pending`, `context`, `list`, `find`, `open`, `create`, `set`, `move`, `get`, `delete`. **No alias is provided.** Anyone with `mcp__termq__termq_*` hardcoded in a CLAUDE.md, hook script, or recorded prompt must update by deleting one prefix. +- **Tag filter semantics converge on literal exact-match.** Both `find(tag:)` (MCP) and `find --tag` (CLI) now interpret `key=value` as a literal exact match (case-insensitive). Previously CLI did partial-substring match on the value while MCP did exact — they now behave identically. To regex-match, prefix with `re:`: `find(tag: "staleness=re:(stale|ageing)")` matches the value as regex; `find(tag: "re:project=org/.+")` matches the whole `key=value` string as regex. Invalid regex patterns surface an error rather than silently falling back to literal. **CLI partial-match users:** rewrite as `--tag project=re:.*` or just `--tag project` (key-only still works). + +### Fixed — MCP / CLI + +- **MCP resource reads now surface load errors instead of returning empty arrays.** Previously `termq://terminals`, `termq://columns`, and `termq://pending` silently returned `[]` or `{}` if the board could not be loaded — conflating "the board has zero cards" with "the install is broken" and masking the original "empty results" bug. Failures now propagate as MCP errors with descriptive messages. +- **`AppProfile` runtime injection point in `BoardLoader` / `BoardWriter`.** Methods now take a `profile: AppProfile.Variant` parameter (default `.current`) instead of `debug: Bool`. `.current` resolves to `.debug` under `TERMQ_DEBUG_BUILD` and `.production` otherwise. Test code can pass `.debug` or `.production` explicitly without recompiling. +- **Atomic read-modify-write in `BoardWriter`.** `updateCard`, `moveCard`, and `createCard` previously split their read and write across two separate `NSFileCoordinator` claims, leaving a window where two concurrent writers could both finish their reads before either wrote — the second write silently clobbering the first. They now run inside a single `writingItemAt:` claim via the new `BoardWriter.atomicUpdate(...)` helper, closing the lost-update race and the `orderIndex` collision on concurrent appends. +- **`termqmcp --verbose` logs the resolved profile and data directory at startup**, so a debug-vs-production data-directory mismatch is visible to the operator. + ### Added - **Focus and profile editing** — editable harnesses gain full inline editing for focuses and profiles diff --git a/Docs/Help/reference/mcp.md b/Docs/Help/reference/mcp.md index 57e6d3d1..4cfdc6fe 100644 --- a/Docs/Help/reference/mcp.md +++ b/Docs/Help/reference/mcp.md @@ -21,7 +21,7 @@ See [Tutorial 10](tutorials/mcp.md) for setup and the session workflow. ## Tools -### `termq_pending` +### `pending` List terminals needing attention. Run this at the **start of every session**. @@ -33,7 +33,7 @@ Returns terminals sorted by urgency: those with `llmNextAction` set first, then --- -### `termq_list` +### `list` List all terminals, optionally filtered. @@ -44,7 +44,7 @@ List all terminals, optionally filtered. --- -### `termq_find` +### `find` Search terminals. All filters are AND-combined. @@ -62,7 +62,7 @@ Search terminals. All filters are AND-combined. --- -### `termq_open` +### `open` Open a terminal. Returns full details including `llmPrompt` and `llmNextAction`. @@ -72,7 +72,7 @@ Open a terminal. Returns full details including `llmPrompt` and `llmNextAction`. --- -### `termq_get` +### `get` Get context for a terminal by UUID. Use with `$TERMQ_TERMINAL_ID` to retrieve context for the terminal the LLM is currently running in. @@ -81,12 +81,12 @@ Get context for a terminal by UUID. Use with `$TERMQ_TERMINAL_ID` to retrieve co | `id` | string | Terminal UUID | ``` -termq_get id="$TERMQ_TERMINAL_ID" +get id="$TERMQ_TERMINAL_ID" ``` --- -### `termq_create` +### `create` Create a new terminal. @@ -103,7 +103,7 @@ Create a new terminal. --- -### `termq_set` +### `set` Update terminal fields. Tags are additive by default. @@ -123,7 +123,7 @@ Update terminal fields. Tags are additive by default. --- -### `termq_move` +### `move` Move a terminal to a different column. @@ -134,7 +134,7 @@ Move a terminal to a different column. --- -### `termq_delete` +### `delete` Delete a terminal. Soft-delete (bin) by default. @@ -169,12 +169,12 @@ Delete a terminal. Soft-delete (bin) by default. ## Session workflow **Start:** -1. Call `termq_pending` to see what needs attention -2. Call `termq_get id="$TERMQ_TERMINAL_ID"` to load current terminal's context +1. Call `pending` to see what needs attention +2. Call `get id="$TERMQ_TERMINAL_ID"` to load current terminal's context 3. Address `llmNextAction` if set, or ask the user which terminal to work in **End:** -1. Call `termq_set` to write `llmNextAction` if work is incomplete +1. Call `set` to write `llmNextAction` if work is incomplete 2. Update the `staleness` tag to `fresh` 3. Update `llmPrompt` if the standing context has materially changed @@ -182,7 +182,7 @@ Delete a terminal. Soft-delete (bin) by default. ## Cross-session state tags -Use these tag keys consistently to make `termq_pending` sorting useful: +Use these tag keys consistently to make `pending` sorting useful: | Tag | Values | Purpose | |---|---|---| diff --git a/Docs/Help/tutorials/ai-context.md b/Docs/Help/tutorials/ai-context.md index b4e25985..462e8b7f 100644 --- a/Docs/Help/tutorials/ai-context.md +++ b/Docs/Help/tutorials/ai-context.md @@ -37,7 +37,7 @@ This prompt is there every session. There are two ways it reaches an LLM assista **Automatically** — via the `{{PROMPT}}` token in the terminal's init command (see [Tutorial 11](tutorials/queued-actions.md)). When the terminal opens, the token is replaced with this field's content before the command runs — e.g. `claude "{{PROMPT}} {{NEXT_ACTION}}"` becomes a single prompt combining standing context and the queued task. -**On demand** — when an LLM assistant calls `termq_open` or `termq_get` via the MCP server, the response includes this field. The assistant reads it and orients itself. See [Tutorial 10](tutorials/mcp.md). +**On demand** — when an LLM assistant calls `open` or `get` via the MCP server, the response includes this field. The assistant reads it and orients itself. See [Tutorial 10](tutorials/mcp.md). **When to update it:** When the nature of the work changes substantially. After finishing the auth refactor, update it to reflect what the terminal is for now. @@ -56,7 +56,7 @@ Like the LLM Prompt, there are two ways this reaches an LLM assistant: **Automatically** — via the `{{NEXT_ACTION}}` token in the init command. When the terminal opens and queued actions are enabled, the token is replaced with this field's content, the command runs, and the field is cleared. It fires once and is gone. See [Tutorial 11](tutorials/queued-actions.md). -**On demand** — `termqcli pending` and `termq_pending` (MCP) surface terminals that have a Next Action set. The LLM reads it and acts on it, then clears it when done. +**On demand** — `termqcli pending` and `pending` (MCP) surface terminals that have a Next Action set. The LLM reads it and acts on it, then clears it when done. > **The key difference:** LLM Prompt is *always present* — it's standing context that doesn't change often. Next Action is *consumed once* — it's for handoffs between sessions. diff --git a/Docs/Help/tutorials/mcp.md b/Docs/Help/tutorials/mcp.md index eec0e81a..7852bea2 100644 --- a/Docs/Help/tutorials/mcp.md +++ b/Docs/Help/tutorials/mcp.md @@ -47,15 +47,15 @@ Once connected, Claude Code has access to these tools: | Tool | What it does | |---|---| -| `termq_pending` | List terminals needing attention — pending actions, sorted by staleness | -| `termq_list` | List all terminals, optionally filtered by column | -| `termq_find` | Smart search across all terminal metadata | -| `termq_open` | Open a terminal by name, UUID, or path — returns full details including `llmPrompt` | -| `termq_create` | Create a new terminal card | -| `termq_set` | Update terminal fields (name, description, tags, llmPrompt, llmNextAction, etc.) | -| `termq_move` | Move a terminal to a different column | -| `termq_get` | Get context for the terminal Claude is currently running in (using `$TERMQ_TERMINAL_ID`) | -| `termq_delete` | Delete a terminal (soft delete to bin by default) | +| `pending` | List terminals needing attention — pending actions, sorted by staleness | +| `list` | List all terminals, optionally filtered by column | +| `find` | Smart search across all terminal metadata | +| `open` | Open a terminal by name, UUID, or path — returns full details including `llmPrompt` | +| `create` | Create a new terminal card | +| `set` | Update terminal fields (name, description, tags, llmPrompt, llmNextAction, etc.) | +| `move` | Move a terminal to a different column | +| `get` | Get context for the terminal Claude is currently running in (using `$TERMQ_TERMINAL_ID`) | +| `delete` | Delete a terminal (soft delete to bin by default) | --- @@ -64,7 +64,7 @@ Once connected, Claude Code has access to these tools: At the start of every Claude Code session in a TermQ terminal, tell it to run: ``` -termq_pending +pending ``` This surfaces terminals with queued actions and staleness indicators. Claude can then: @@ -84,10 +84,10 @@ This runs the session start checklist: pending work, summary of active terminals ## 10.5 — Self-awareness: knowing which terminal you're in -Any Claude Code session running inside a TermQ terminal has access to `$TERMQ_TERMINAL_ID`. Claude can use this with `termq_get` to retrieve its own terminal's context: +Any Claude Code session running inside a TermQ terminal has access to `$TERMQ_TERMINAL_ID`. Claude can use this with `get` to retrieve its own terminal's context: ``` -termq_get id="$TERMQ_TERMINAL_ID" +get id="$TERMQ_TERMINAL_ID" ``` The response includes `llmPrompt`, `llmNextAction`, tags, column, and all other metadata for the current terminal. This is how Claude knows what project it's working on, what the standing instructions are, and what to do first — without you needing to explain it. @@ -104,7 +104,7 @@ Please update this terminal: - Set the staleness tag to fresh ``` -Claude uses `termq_set` to write those fields. The next session — whether it's you or another Claude instance — opens the terminal, calls `termq_get`, and has everything it needs. +Claude uses `set` to write those fields. The next session — whether it's you or another Claude instance — opens the terminal, calls `get`, and has everything it needs. --- @@ -112,8 +112,8 @@ Claude uses `termq_set` to write those fields. The next session — whether it's - `termqmcp` is a standalone MCP server — add it to `~/.claude/mcp.json` - Claude Code gets a full set of tools to **read and write** your TermQ board -- `termq_pending` at session start surfaces what needs attention -- `termq_get id="$TERMQ_TERMINAL_ID"` gives Claude its own terminal's context automatically +- `pending` at session start surfaces what needs attention +- `get id="$TERMQ_TERMINAL_ID"` gives Claude its own terminal's context automatically - The end-of-session update pattern creates continuity between Claude sessions ## Next diff --git a/Docs/Help/tutorials/queued-actions.md b/Docs/Help/tutorials/queued-actions.md index 5887733f..c8ed323a 100644 --- a/Docs/Help/tutorials/queued-actions.md +++ b/Docs/Help/tutorials/queued-actions.md @@ -134,7 +134,7 @@ termqcli set "API Server" --llm-next-action "Run the test suite and check if AUT Or via MCP (what Claude does at session end): ``` -termq_set identifier="API Server" llmNextAction="Run the test suite and check if AUTH-23 is resolved." +set identifier="API Server" llmNextAction="Run the test suite and check if AUTH-23 is resolved." ``` Now the next time that terminal opens, the queued action fires. diff --git a/Sources/MCPServer-CLI/main.swift b/Sources/MCPServer-CLI/main.swift index 42fcfb0c..dc576fbc 100644 --- a/Sources/MCPServer-CLI/main.swift +++ b/Sources/MCPServer-CLI/main.swift @@ -2,16 +2,18 @@ import ArgumentParser import Foundation import MCP import MCPServerLib +import TermQShared // MARK: - Build Configuration -/// Returns whether to use debug mode based on build configuration and explicit flag -/// In debug builds, always use debug mode unless explicitly overridden -private func shouldUseDebugMode(_ explicitDebug: Bool) -> Bool { +/// Resolves the AppProfile.Variant for this invocation. In a debug build, `.debug` is the +/// only valid option (overriding any user input). In a production build, the user's +/// `--debug` flag selects between `.production` and `.debug`. +private func resolveProfile(explicitDebug: Bool) -> AppProfile.Variant { #if TERMQ_DEBUG_BUILD - return true + return .debug #else - return explicitDebug + return explicitDebug ? .debug : .production #endif } @@ -65,19 +67,27 @@ struct TermQMCPCommand: AsyncParsableCommand { } func run() async throws { - // Determine data directory - let useDebug = shouldUseDebugMode(debug) - let dataDirectory: URL? - if useDebug { - dataDirectory = FileManager.default - .urls(for: .applicationSupportDirectory, in: .userDomainMask) - .first? - .appendingPathComponent("TermQ-Debug") - } else { - dataDirectory = nil + // Resolve the app profile and derive the data directory from it. + // The resolved values are logged below so debug/production mismatches are visible + // — that was the root cause of the "MCP returns empty results" bug. + let profile = resolveProfile(explicitDebug: debug) + let dataDirectory = BoardLoader.getDataDirectoryPath(profile: profile) + + if verbose { + fputs( + """ + termqmcp starting + profile: \(profile) + data directory: \(dataDirectory.path) + bundle id: \(profile.bundleIdentifier) + transport: \(http ? "http" : "stdio") + + """, + stderr + ) } - // Create server + // Create server with the explicit data directory derived from profile. let server = TermQMCPServer(dataDirectory: dataDirectory) if http { diff --git a/Sources/MCPServerLib/HeadlessWriter.swift b/Sources/MCPServerLib/HeadlessWriter.swift index baaaafb3..531adb9e 100644 --- a/Sources/MCPServerLib/HeadlessWriter.swift +++ b/Sources/MCPServerLib/HeadlessWriter.swift @@ -73,7 +73,7 @@ public enum HeadlessWriter { public static func createCard( _ options: CardCreationOptions, dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws -> Card { // Create the card using BoardWriter var card = try BoardWriter.createCard( @@ -82,7 +82,7 @@ public enum HeadlessWriter { workingDirectory: options.workingDirectory, description: options.description ?? "", dataDirectory: dataDirectory, - debug: debug + profile: profile ) // Build updates for additional MCP fields @@ -117,7 +117,7 @@ public enum HeadlessWriter { identifier: card.id.uuidString, updates: updates, dataDirectory: dataDirectory, - debug: debug + profile: profile ) } @@ -129,7 +129,7 @@ public enum HeadlessWriter { identifier: String, params: UpdateParameters, dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws -> Card { var updates: [String: Any] = [:] @@ -171,7 +171,7 @@ public enum HeadlessWriter { updates["tags"] = tagDicts } else { // Merge with existing tags - let board = try BoardLoader.loadBoard(dataDirectory: dataDirectory, debug: debug) + let board = try BoardLoader.loadBoard(dataDirectory: dataDirectory, profile: profile) guard let card = board.findTerminal(identifier: identifier) else { throw BoardWriter.WriteError.cardNotFound(identifier: identifier) } @@ -205,7 +205,7 @@ public enum HeadlessWriter { identifier: identifier, updates: updates, dataDirectory: dataDirectory, - debug: debug + profile: profile ) } @@ -214,13 +214,13 @@ public enum HeadlessWriter { identifier: String, toColumn columnName: String, dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws -> Card { try BoardWriter.moveCard( identifier: identifier, toColumn: columnName, dataDirectory: dataDirectory, - debug: debug + profile: profile ) } @@ -229,12 +229,12 @@ public enum HeadlessWriter { identifier: String, permanent: Bool, dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws { if permanent { // For permanent deletion, load board and remove from array // Note: BoardWriter doesn't have permanent delete, so we handle it here - let rawBoard = try BoardWriter.loadRawBoard(dataDirectory: dataDirectory, debug: debug) + let rawBoard = try BoardWriter.loadRawBoard(dataDirectory: dataDirectory, profile: profile) let boardURL = rawBoard.url var board = rawBoard.data guard var cards = board["cards"] as? [[String: Any]] else { @@ -256,7 +256,7 @@ public enum HeadlessWriter { identifier: identifier, updates: ["deletedAt": now], dataDirectory: dataDirectory, - debug: debug + profile: profile ) } } diff --git a/Sources/MCPServerLib/PromptHandlers.swift b/Sources/MCPServerLib/PromptHandlers.swift index f51ef5ea..f00259f8 100644 --- a/Sources/MCPServerLib/PromptHandlers.swift +++ b/Sources/MCPServerLib/PromptHandlers.swift @@ -60,8 +60,8 @@ extension TermQMCPServer { if !pendingCards.isEmpty { content += "1. Address pending actions above\n" } - content += "2. Use `termq_pending` for detailed terminal view\n" - content += "3. Use `termq_context` for workflow guide\n" + content += "2. Use `pending` for detailed terminal view\n" + content += "3. Use `context` for workflow guide\n" } catch { content += "Error loading board: \(error.localizedDescription)\n\n" @@ -135,7 +135,7 @@ extension TermQMCPServer { ## SESSION START CHECKLIST (Do This First!) - 1. Use the `termq_pending` tool to see what needs attention: + 1. Use the `pending` tool to see what needs attention: - Shows terminals with queued tasks (llmNextAction) - Shows staleness indicators @@ -179,18 +179,18 @@ extension TermQMCPServer { ## AVAILABLE MCP TOOLS ### Query Tools - - `termq_pending` - Check terminals needing attention (SESSION START) - - `termq_context` - Get this documentation - - `termq_list` - List all terminals or filter by column - - `termq_find` - Search terminals by name, column, tag, etc. - - `termq_open` - Get terminal details by name, UUID, or path - - `termq_get` - Get terminal by UUID (use with $TERMQ_TERMINAL_ID) + - `pending` - Check terminals needing attention (SESSION START) + - `context` - Get this documentation + - `list` - List all terminals or filter by column + - `find` - Search terminals by name, column, tag, etc. + - `open` - Get terminal details by name, UUID, or path + - `get` - Get terminal by UUID (use with $TERMQ_TERMINAL_ID) ### Write Tools These tools modify the board directly: - - `termq_create` - Create a new terminal - - `termq_set` - Modify terminal properties (name, badge, llmPrompt, etc.) - - `termq_move` - Move terminal to a different column + - `create` - Create a new terminal + - `set` - Modify terminal properties (name, badge, llmPrompt, etc.) + - `move` - Move terminal to a different column ## TERMINAL FIELDS @@ -205,7 +205,7 @@ extension TermQMCPServer { ## TIPS - - ALWAYS use `termq_pending` at session start + - ALWAYS use `pending` at session start - ALWAYS set `llmNextAction` when parking incomplete work - Use `staleness` tag to track what needs attention - Use `project` tag to group related terminals diff --git a/Sources/MCPServerLib/ResourceHandlers.swift b/Sources/MCPServerLib/ResourceHandlers.swift index 5889631e..4894bc96 100644 --- a/Sources/MCPServerLib/ResourceHandlers.swift +++ b/Sources/MCPServerLib/ResourceHandlers.swift @@ -30,81 +30,73 @@ extension TermQMCPServer { // MARK: - Resource Implementations private func handleTerminalsResource(uri: String) async throws -> ReadResource.Result { - do { - let board = try loadBoard() - let output = board.activeCards.map { - TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) - } - let json = try JSONHelper.encode(output) - return ReadResource.Result(contents: [.text(json, uri: uri)]) - } catch { - return ReadResource.Result(contents: [.text("[]", uri: uri)]) + // Load errors are surfaced to the client via MCPError, not masked as empty arrays. + // An empty board is `[]` legitimately; a missing/corrupt board is a real failure the + // caller needs to see — otherwise a debug-vs-production data-directory mismatch + // (the original bug this work fixes) silently returns nothing. + let board = try loadBoard() + let output = board.activeCards.map { + TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) } + let json = try JSONHelper.encode(output) + return ReadResource.Result(contents: [.text(json, uri: uri)]) } private func handleColumnsResource(uri: String) async throws -> ReadResource.Result { - do { - let board = try loadBoard() - let columns = board.sortedColumns().map { column in - ColumnOutput( - from: column, - terminalCount: board.activeCards.filter { $0.columnId == column.id }.count - ) - } - let json = try JSONHelper.encode(columns) - return ReadResource.Result(contents: [.text(json, uri: uri)]) - } catch { - return ReadResource.Result(contents: [.text("[]", uri: uri)]) + let board = try loadBoard() + let columns = board.sortedColumns().map { column in + ColumnOutput( + from: column, + terminalCount: board.activeCards.filter { $0.columnId == column.id }.count + ) } + let json = try JSONHelper.encode(columns) + return ReadResource.Result(contents: [.text(json, uri: uri)]) } private func handlePendingResource(uri: String) async throws -> ReadResource.Result { - do { - let board = try loadBoard() - var cards = board.activeCards + let board = try loadBoard() + var cards = board.activeCards - // Sort: pending actions first, then by staleness - cards.sort { card1, card2 in - let has1 = !card1.llmNextAction.isEmpty - let has2 = !card2.llmNextAction.isEmpty - if has1 != has2 { return has1 } - return card1.stalenessRank > card2.stalenessRank - } + // Sort: pending actions first, then by staleness + cards.sort { card1, card2 in + let has1 = !card1.llmNextAction.isEmpty + let has2 = !card2.llmNextAction.isEmpty + if has1 != has2 { return has1 } + return card1.stalenessRank > card2.stalenessRank + } - var terminals: [PendingTerminalOutput] = [] - var withNextAction = 0 - var staleCount = 0 - var freshCount = 0 + var terminals: [PendingTerminalOutput] = [] + var withNextAction = 0 + var staleCount = 0 + var freshCount = 0 - for card in cards { - let staleness = card.staleness - if !card.llmNextAction.isEmpty { withNextAction += 1 } - switch staleness { - case "stale", "old": staleCount += 1 - case "fresh": freshCount += 1 - default: break - } - terminals.append( - PendingTerminalOutput( - from: card, - columnName: board.columnName(for: card.columnId), - staleness: staleness - )) + for card in cards { + let staleness = card.staleness + if !card.llmNextAction.isEmpty { withNextAction += 1 } + switch staleness { + case "stale", "old": staleCount += 1 + case "fresh": freshCount += 1 + default: break } + terminals.append( + PendingTerminalOutput( + from: card, + columnName: board.columnName(for: card.columnId), + staleness: staleness + )) + } - let output = PendingOutput( - terminals: terminals, - summary: PendingSummary( - total: terminals.count, - withNextAction: withNextAction, - stale: staleCount, - fresh: freshCount - ) + let output = PendingOutput( + terminals: terminals, + summary: PendingSummary( + total: terminals.count, + withNextAction: withNextAction, + stale: staleCount, + fresh: freshCount ) - let json = try JSONHelper.encode(output) - return ReadResource.Result(contents: [.text(json, uri: uri)]) - } catch { - return ReadResource.Result(contents: [.text("{}", uri: uri)]) - } + ) + let json = try JSONHelper.encode(output) + return ReadResource.Result(contents: [.text(json, uri: uri)]) } } diff --git a/Sources/MCPServerLib/SchemaDefinitions.swift b/Sources/MCPServerLib/SchemaDefinitions.swift index 509dfe7d..a23b3613 100644 --- a/Sources/MCPServerLib/SchemaDefinitions.swift +++ b/Sources/MCPServerLib/SchemaDefinitions.swift @@ -10,7 +10,7 @@ extension TermQMCPServer { static var availableTools: [Tool] { [ Tool( - name: "termq_pending", + name: "pending", description: """ Check terminals needing attention. Run this at the START of every LLM session. Returns terminals with pending actions (llmNextAction) and staleness indicators. @@ -21,7 +21,7 @@ extension TermQMCPServer { ]) ), Tool( - name: "termq_context", + name: "context", description: """ Output comprehensive documentation for LLM/AI assistants. Includes session start/end checklists, tag schema, command reference, @@ -30,7 +30,7 @@ extension TermQMCPServer { inputSchema: Schema.emptySchema() ), Tool( - name: "termq_list", + name: "list", description: "List all terminals or filter by column. Supports listing columns only.", inputSchema: Schema.objectSchema([ Schema.string("column", "Filter by column name"), @@ -38,7 +38,7 @@ extension TermQMCPServer { ]) ), Tool( - name: "termq_find", + name: "find", description: """ Search for terminals by various criteria. Use 'query' for smart multi-word search across name, description, path, and tags. All filters are AND-combined. @@ -59,7 +59,7 @@ extension TermQMCPServer { ]) ), Tool( - name: "termq_open", + name: "open", description: """ Open an existing terminal by name, UUID, or path. Returns terminal details including llmPrompt (persistent context) and llmNextAction (one-time task). @@ -71,7 +71,7 @@ extension TermQMCPServer { ]) ), Tool( - name: "termq_create", + name: "create", description: """ Create a new terminal in TermQ. Optionally specify name, description, column, path, tags, LLM context, and initialization command. @@ -89,7 +89,7 @@ extension TermQMCPServer { ]) ), Tool( - name: "termq_set", + name: "set", description: """ Update terminal properties. Identify terminal by name or UUID. Can set name, description, column, badges, LLM fields, tags, init command, and favourite status. @@ -113,7 +113,7 @@ extension TermQMCPServer { ]) ), Tool( - name: "termq_move", + name: "move", description: "Move a terminal to a different column (workflow stage).", inputSchema: Schema.objectSchema([ Schema.string("identifier", "Terminal name or UUID", required: true), @@ -121,7 +121,7 @@ extension TermQMCPServer { ]) ), Tool( - name: "termq_get", + name: "get", description: """ Get terminal context by ID. Use with TERMQ_TERMINAL_ID environment variable to get context for the terminal you're currently running in. @@ -132,7 +132,7 @@ extension TermQMCPServer { ]) ), Tool( - name: "termq_delete", + name: "delete", description: """ Delete a terminal. By default, moves to bin (soft delete). Use permanent=true to permanently delete without bin recovery option. diff --git a/Sources/MCPServerLib/ToolHandlers.swift b/Sources/MCPServerLib/ToolHandlers.swift index eab0ea81..bd6cfd8a 100644 --- a/Sources/MCPServerLib/ToolHandlers.swift +++ b/Sources/MCPServerLib/ToolHandlers.swift @@ -8,25 +8,25 @@ extension TermQMCPServer { /// Dispatch tool calls to appropriate handlers func dispatchToolCall(_ params: CallTool.Parameters) async throws -> CallTool.Result { switch params.name { - case "termq_pending": + case "pending": return try await handlePending(params.arguments) - case "termq_context": + case "context": return try await handleContext() - case "termq_list": + case "list": return try await handleList(params.arguments) - case "termq_find": + case "find": return try await handleFind(params.arguments) - case "termq_open": + case "open": return try await handleOpen(params.arguments) - case "termq_create": + case "create": return try await handleCreate(params.arguments) - case "termq_set": + case "set": return try await handleSet(params.arguments) - case "termq_move": + case "move": return try await handleMove(params.arguments) - case "termq_get": + case "get": return try await handleGet(params.arguments) - case "termq_delete": + case "delete": return try await handleDelete(params.arguments) default: throw MCPError.invalidRequest("Unknown tool: \(params.name)") @@ -214,7 +214,7 @@ extension TermQMCPServer { } cards = CardFilterEngine.filterByColumn(cards, column: columnFilter, columns: board.columns) - cards = CardFilterEngine.filterByTag(cards, tagFilter: tagFilter, valueMatch: .exact) + cards = try CardFilterEngine.filterByTag(cards, tagFilter: tagFilter) cards = CardFilterEngine.filterByBadge(cards, badge: badgeFilter) if favouritesOnly { cards = CardFilterEngine.filterFavourites(cards) } @@ -236,7 +236,7 @@ extension TermQMCPServer { func handleOpen(_ arguments: [String: Value]?) async throws -> CallTool.Result { let identifier: String do { - identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "termq_open") + identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "open") } catch let error as InputValidator.ValidationError { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -271,7 +271,7 @@ extension TermQMCPServer { func handleGet(_ arguments: [String: Value]?) async throws -> CallTool.Result { let id: String do { - let uuid = try InputValidator.requireUUID("id", from: arguments, tool: "termq_get") + let uuid = try InputValidator.requireUUID("id", from: arguments, tool: "get") id = uuid.uuidString } catch let error as InputValidator.ValidationError { return CallTool.Result( @@ -453,7 +453,7 @@ extension TermQMCPServer { func handleSet(_ arguments: [String: Value]?) async throws -> CallTool.Result { let identifier: String do { - identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "termq_set") + identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "set") } catch let error as InputValidator.ValidationError { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -589,7 +589,7 @@ extension TermQMCPServer { dataDirectory: dataDirectory ) - // `termq_set` with a `column` argument is equivalent to a move — apply it + // `set` with a `column` argument is equivalent to a move — apply it // after the field updates so a rename + column change in one call both land. if let column = params.column { card = try HeadlessWriter.moveCard( @@ -624,8 +624,8 @@ extension TermQMCPServer { let identifier: String let column: String do { - identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "termq_move") - column = try InputValidator.requireNonEmptyString("column", from: arguments, tool: "termq_move") + identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "move") + column = try InputValidator.requireNonEmptyString("column", from: arguments, tool: "move") } catch let error as InputValidator.ValidationError { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -727,7 +727,7 @@ extension TermQMCPServer { func handleDelete(_ arguments: [String: Value]?) async throws -> CallTool.Result { let identifier: String do { - identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "termq_delete") + identifier = try InputValidator.requireNonEmptyString("identifier", from: arguments, tool: "delete") } catch let error as InputValidator.ValidationError { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], diff --git a/Sources/TermQ/Views/ContentView.swift b/Sources/TermQ/Views/ContentView.swift index 835b725b..2cb9cb2c 100644 --- a/Sources/TermQ/Views/ContentView.swift +++ b/Sources/TermQ/Views/ContentView.swift @@ -725,7 +725,6 @@ extension ContentView { } } - /// Launch native Terminal.app at the specified directory func launchNativeTerminal(at directory: String? = nil) { let path = directory ?? NSHomeDirectory() diff --git a/Sources/TermQ/Views/TerminalCardView.swift b/Sources/TermQ/Views/TerminalCardView.swift index 24174262..b754db89 100644 --- a/Sources/TermQ/Views/TerminalCardView.swift +++ b/Sources/TermQ/Views/TerminalCardView.swift @@ -112,7 +112,7 @@ struct TerminalCardView: View { .fixedSize() } - // Wired indicator - shows when LLM has called termq_get for this terminal + // Wired indicator - shows when LLM has called get for this terminal if card.isWired { Text(Strings.Card.wired) .font(.caption2) diff --git a/Sources/TermQCLICore/CLI+Find.swift b/Sources/TermQCLICore/CLI+Find.swift index a6bce9f0..e1a4a9db 100644 --- a/Sources/TermQCLICore/CLI+Find.swift +++ b/Sources/TermQCLICore/CLI+Find.swift @@ -44,7 +44,8 @@ struct Find: ParsableCommand { func run() throws { do { let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: shouldUseDebugMode(debug)) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: resolveProfile(debug)) var cards = board.activeCards var relevanceScores: [UUID: Int] = [:] @@ -80,9 +81,10 @@ struct Find: ParsableCommand { cards = cards.filter { $0.title.lowercased().contains(filterLower) } } - // CLI uses .contains for tag value matching (partial match) + // Tag matching: literal exact match by default; opt-in regex with `re:` prefix. + // Converged with MCP — both surfaces use identical semantics via CardFilterEngine. cards = CardFilterEngine.filterByColumn(cards, column: column, columns: board.columns) - cards = CardFilterEngine.filterByTag(cards, tagFilter: tag, valueMatch: .contains) + cards = try CardFilterEngine.filterByTag(cards, tagFilter: tag) cards = CardFilterEngine.filterByBadge(cards, badge: badge) if favourites { cards = CardFilterEngine.filterFavourites(cards) } diff --git a/Sources/TermQCLICore/CLI+LLM.swift b/Sources/TermQCLICore/CLI+LLM.swift index dcd3d042..069ca14e 100644 --- a/Sources/TermQCLICore/CLI+LLM.swift +++ b/Sources/TermQCLICore/CLI+LLM.swift @@ -29,7 +29,8 @@ struct Pending: ParsableCommand { func run() throws { do { let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: shouldUseDebugMode(debug)) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: resolveProfile(debug)) let cards = getFilteredAndSortedCards(from: board) let output = buildPendingOutput(cards: cards, board: board) diff --git a/Sources/TermQCLICore/CLI+Mutations.swift b/Sources/TermQCLICore/CLI+Mutations.swift index d354ca24..cc5b6862 100644 --- a/Sources/TermQCLICore/CLI+Mutations.swift +++ b/Sources/TermQCLICore/CLI+Mutations.swift @@ -55,9 +55,10 @@ struct Set: ParsableCommand { func run() throws { do { - let debugMode = shouldUseDebugMode(debug) + let profile = resolveProfile(debug) let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: debugMode) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: profile) guard let card = board.findTerminal(identifier: terminal) else { JSONHelper.printErrorJSON("Terminal not found: \(terminal)") @@ -100,7 +101,7 @@ struct Set: ParsableCommand { identifier: card.id.uuidString, toColumn: columnName, dataDirectory: dataDirURL, - debug: debugMode + profile: profile ) } @@ -108,7 +109,7 @@ struct Set: ParsableCommand { identifier: card.id.uuidString, params: params, dataDirectory: dataDirURL, - debug: debugMode + profile: profile ) if initCommand != nil { @@ -161,9 +162,10 @@ struct Move: ParsableCommand { func run() throws { do { - let debugMode = shouldUseDebugMode(debug) + let profile = resolveProfile(debug) let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: debugMode) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: profile) guard let card = board.findTerminal(identifier: terminal) else { JSONHelper.printErrorJSON("Terminal not found: \(terminal)") @@ -177,7 +179,7 @@ struct Move: ParsableCommand { identifier: card.id.uuidString, toColumn: toColumn, dataDirectory: dataDirURL, - debug: debugMode + profile: profile ) JSONHelper.printJSON( @@ -229,9 +231,10 @@ struct Delete: ParsableCommand { func run() throws { do { - let debugMode = shouldUseDebugMode(debug) + let profile = resolveProfile(debug) let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: debugMode) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: profile) guard let card = board.findTerminal(identifier: terminal) else { JSONHelper.printErrorJSON("Terminal not found: \(terminal)") @@ -245,7 +248,7 @@ struct Delete: ParsableCommand { identifier: card.id.uuidString, permanent: permanent, dataDirectory: dataDirURL, - debug: debugMode + profile: profile ) JSONHelper.printJSON( diff --git a/Sources/TermQCLICore/CLI.swift b/Sources/TermQCLICore/CLI.swift index 5cdc9a3a..8949ab2c 100644 --- a/Sources/TermQCLICore/CLI.swift +++ b/Sources/TermQCLICore/CLI.swift @@ -18,6 +18,14 @@ func shouldUseDebugMode(_ explicitDebug: Bool) -> Bool { #endif } +/// Resolves the AppProfile variant for a CLI invocation: in a debug build always `.debug`; +/// in a production build the user's `--debug` flag selects between `.production` and `.debug`. +/// Use this at every `BoardLoader`/`BoardWriter`/`HeadlessWriter` call site so the long-form +/// `AppProfile.Variant(debug: shouldUseDebugMode(debug))` doesn't leak everywhere. +func resolveProfile(_ explicitDebug: Bool) -> AppProfile.Variant { + AppProfile.Variant(debug: shouldUseDebugMode(explicitDebug)) +} + // MARK: - Shared Helpers func parseTags(_ tagStrings: [String]) -> [(key: String, value: String)] { @@ -175,7 +183,8 @@ struct Open: ParsableCommand { do { let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: shouldUseDebugMode(debug)) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: resolveProfile(debug)) guard let card = board.findTerminal(identifier: terminal) else { JSONHelper.printErrorJSON("Terminal not found: \(terminal)") @@ -269,7 +278,7 @@ struct Create: ParsableCommand { tags: parsedTags.isEmpty ? nil : parsedTags ), dataDirectory: dataDirURL, - debug: shouldUseDebugMode(false) + profile: resolveProfile(false) ) JSONHelper.printJSON( @@ -379,7 +388,8 @@ struct List: ParsableCommand { func run() throws { do { let dataDirURL = dataDirectory.map { URL(fileURLWithPath: $0) } - let board = try BoardLoader.loadBoard(dataDirectory: dataDirURL, debug: shouldUseDebugMode(debug)) + let board = try BoardLoader.loadBoard( + dataDirectory: dataDirURL, profile: resolveProfile(debug)) if columns { let columnOutput = board.sortedColumns().map { col in diff --git a/Sources/TermQCore/TerminalCard.swift b/Sources/TermQCore/TerminalCard.swift index d2d53516..0314d388 100644 --- a/Sources/TermQCore/TerminalCard.swift +++ b/Sources/TermQCore/TerminalCard.swift @@ -104,7 +104,7 @@ public class TerminalCard: Identifiable, ObservableObject, Codable { /// When the card was soft-deleted (nil = active, set = in bin) @Published public var deletedAt: Date? - /// When an LLM last called termq_get for this terminal (nil = never, set = LLM is aware of TermQ) + /// When an LLM last called get for this terminal (nil = never, set = LLM is aware of TermQ) @Published public var lastLLMGet: Date? /// Backend override for session management. `nil` means inherit the @@ -256,7 +256,7 @@ public class TerminalCard: Identifiable, ObservableObject, Codable { deletedAt != nil } - /// Whether the LLM has recently identified itself via termq_get (within last 10 minutes) + /// Whether the LLM has recently identified itself via get (within last 10 minutes) public var isWired: Bool { guard let lastGet = lastLLMGet else { return false } return Date().timeIntervalSince(lastGet) < 600 // 10 minutes diff --git a/Sources/TermQShared/AppProfile.swift b/Sources/TermQShared/AppProfile.swift index eb3cb201..ead9c87c 100644 --- a/Sources/TermQShared/AppProfile.swift +++ b/Sources/TermQShared/AppProfile.swift @@ -98,4 +98,58 @@ public enum AppProfile { Production.bundleIdentifier, Debug.bundleIdentifier, ] + + /// Runtime profile selector — Sendable, injectable for tests. + /// + /// Where the compile-time `AppProfile.Current` is fixed at build time, `Variant` lets + /// callers (especially tests) target a specific profile at runtime. `.current` resolves + /// to `.debug` in `TERMQ_DEBUG_BUILD` builds and `.production` otherwise. + public enum Variant: Sendable { + case production + case debug + + public static var current: Variant { + #if TERMQ_DEBUG_BUILD + return .debug + #else + return .production + #endif + } + + /// Bridge for callers that still thread a `Bool` debug flag through their API surface + /// (CLI argparse flags, HeadlessWriter internal helpers). `true` always resolves to + /// `.debug`; `false` resolves to `.production`. Callers that want build-time-aware + /// behaviour should pass `.current` directly. + public init(debug: Bool) { + self = debug ? .debug : .production + } + + public var dataDirectoryName: String { + switch self { + case .production: return Production.dataDirectoryName + case .debug: return Debug.dataDirectoryName + } + } + + public var bundleIdentifier: String { + switch self { + case .production: return Production.bundleIdentifier + case .debug: return Debug.bundleIdentifier + } + } + + public var appBundleName: String { + switch self { + case .production: return Production.appBundleName + case .debug: return Debug.appBundleName + } + } + + public var displayName: String { + switch self { + case .production: return Production.displayName + case .debug: return Debug.displayName + } + } + } } diff --git a/Sources/TermQShared/BoardLoader.swift b/Sources/TermQShared/BoardLoader.swift index a1d1938b..16f08d89 100644 --- a/Sources/TermQShared/BoardLoader.swift +++ b/Sources/TermQShared/BoardLoader.swift @@ -22,26 +22,40 @@ public enum BoardLoader { } } - /// Get the TermQ data directory path - public static func getDataDirectoryPath(customDirectory: URL? = nil, debug: Bool = false) -> URL { + /// Get the TermQ data directory path. + /// + /// - Parameters: + /// - customDirectory: Optional override (typically for tests). When non-nil, ignores `profile`. + /// - profile: Which app profile's data directory to resolve. Defaults to `.current` — + /// resolves to `.debug` in `TERMQ_DEBUG_BUILD` builds and `.production` otherwise. + public static func getDataDirectoryPath( + customDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) -> URL { if let custom = customDirectory { return custom } + let dirName = profile.dataDirectoryName guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { // Fallback to home directory if Application Support not available let homeDir = FileManager.default.homeDirectoryForCurrentUser - let dirName = debug ? AppProfile.Debug.dataDirectoryName : AppProfile.Production.dataDirectoryName return homeDir.appendingPathComponent(".termq", isDirectory: true).appendingPathComponent( dirName, isDirectory: true) } - let dirName = debug ? AppProfile.Debug.dataDirectoryName : AppProfile.Production.dataDirectoryName return appSupport.appendingPathComponent(dirName, isDirectory: true) } - /// Load the board from disk with file coordination for safe concurrent access - public static func loadBoard(dataDirectory: URL? = nil, debug: Bool = false) throws -> Board { - let dataDir = getDataDirectoryPath(customDirectory: dataDirectory, debug: debug) + /// Load the board from disk with file coordination for safe concurrent access. + /// + /// - Parameters: + /// - dataDirectory: Optional explicit data directory (tests use a temp dir). + /// - profile: Which app profile to resolve when `dataDirectory` is nil. Defaults to `.current`. + public static func loadBoard( + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws -> Board { + let dataDir = getDataDirectoryPath(customDirectory: dataDirectory, profile: profile) let boardURL = dataDir.appendingPathComponent("board.json") guard FileManager.default.fileExists(atPath: boardURL.path) else { @@ -110,9 +124,9 @@ public enum BoardWriter { /// Load board as raw JSON dictionary (preserves all fields) /// Uses file coordination for safe concurrent access public static func loadRawBoard( - dataDirectory: URL? = nil, debug: Bool = false + dataDirectory: URL? = nil, profile: AppProfile.Variant = .current ) throws -> (url: URL, data: [String: Any]) { - let dataDir = BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, debug: debug) + let dataDir = BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, profile: profile) let boardURL = dataDir.appendingPathComponent("board.json") guard FileManager.default.fileExists(atPath: boardURL.path) else { @@ -147,7 +161,15 @@ public enum BoardWriter { return (boardURL, try result.get()) } - /// Save raw board JSON to disk with file coordination for safe concurrent access + /// Save raw board JSON to disk with file coordination for safe concurrent access. + /// + /// **Avoid for new code.** This is a half-claim (write only). Pairing a separate + /// `loadRawBoard` read claim with a `saveRawBoard` write claim opens a lost-update + /// race: two processes can both finish their reads before either writes, and the + /// second write silently clobbers the first. Use `atomicUpdate(...)` instead for any + /// read-modify-write — it holds a single exclusive claim across both halves. + /// Retained here only for callers that are genuinely write-only (constructing a + /// fresh board from scratch). public static func saveRawBoard(_ board: [String: Any], to url: URL) throws { let jsonData = try JSONSerialization.data(withJSONObject: board, options: [.prettyPrinted, .sortedKeys]) @@ -172,191 +194,226 @@ public enum BoardWriter { } } - /// Update a card's fields - public static func updateCard( - identifier: String, - updates: [String: Any], + /// Atomic read-modify-write under a single `NSFileCoordinator` writing claim. + /// + /// The closure receives the parsed board JSON. It may mutate the board however it + /// likes (and may compute and return any value from it). The mutated board is written + /// back inside the same exclusive claim, so no other process can read or write the + /// file while this call is in flight. + /// + /// Closes the lost-update race that a split `loadRawBoard` + `saveRawBoard` pattern + /// otherwise allows: two processes both finishing their reads before either writes, + /// and the second write silently clobbering the first. Same fix also resolves the + /// `orderIndex` collision (two concurrent appends computing the same `max + 1`). + @discardableResult + public static func atomicUpdate( dataDirectory: URL? = nil, - debug: Bool = false - ) throws -> Card { - let rawBoard = try loadRawBoard(dataDirectory: dataDirectory, debug: debug) - let boardURL = rawBoard.url - var board = rawBoard.data - guard var cards = board["cards"] as? [[String: Any]] else { - throw WriteError.encodingFailed("Invalid cards format") + profile: AppProfile.Variant = .current, + body: (inout [String: Any]) throws -> T + ) throws -> T { + let dataDir = BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, profile: profile) + let boardURL = dataDir.appendingPathComponent("board.json") + + guard FileManager.default.fileExists(atPath: boardURL.path) else { + throw WriteError.boardNotFound(path: boardURL.path) } - // Find the card to update (include deleted cards so we can update after soft-delete) - let cardIndex = try findCardIndex(identifier: identifier, in: cards, includeDeleted: true) + var coordinationError: NSError? + var result: Result? - // Capture the stable UUID before applying updates — the caller may be renaming - // the card (updating `title`), which would break a post-save name lookup. - let cardUUID = (cards[cardIndex]["id"] as? String).flatMap(UUID.init(uuidString:)) + let coordinator = NSFileCoordinator(filePresenter: nil) + coordinator.coordinate(writingItemAt: boardURL, options: [], error: &coordinationError) { writeURL in + do { + let data = try Data(contentsOf: writeURL) + guard var board = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw WriteError.encodingFailed("Invalid board format") + } + let value = try body(&board) + let newData = try JSONSerialization.data( + withJSONObject: board, options: [.prettyPrinted, .sortedKeys]) + try newData.write(to: writeURL, options: .atomic) + result = .success(value) + } catch { + result = .failure(error) + } + } - // Apply updates - for (key, value) in updates { - cards[cardIndex][key] = value + if let error = coordinationError { + throw WriteError.coordinationFailed(error.localizedDescription) } + guard let result else { + throw WriteError.coordinationFailed("File coordination completed without result") + } + return try result.get() + } - // Save back - board["cards"] = cards - try saveRawBoard(board, to: boardURL) + /// Update a card's fields atomically. + /// + /// Read, mutation, and write happen under a single `NSFileCoordinator` writing claim + /// — no other process can interleave, so the lost-update race is closed. + public static func updateCard( + identifier: String, + updates: [String: Any], + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws -> Card { + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var cards = board["cards"] as? [[String: Any]] else { + throw WriteError.encodingFailed("Invalid cards format") + } - // Return the updated card (search in ALL cards, not just active ones) - // This is important for operations like soft-delete that set deletedAt - let updatedBoard = try BoardLoader.loadBoard(dataDirectory: dataDirectory, debug: debug) + // Find the card to update (include deleted cards so we can update after soft-delete) + let cardIndex = try findCardIndex(identifier: identifier, in: cards, includeDeleted: true) - // Prefer the captured UUID — survives title changes - if let uuid = cardUUID, - let updatedCard = updatedBoard.cards.first(where: { $0.id == uuid }) - { - return updatedCard - } + // Capture the stable UUID before applying updates — the caller may be renaming + // the card (updating `title`), which would break a post-mutation name lookup. + let cardUUID = (cards[cardIndex]["id"] as? String).flatMap(UUID.init(uuidString:)) - // Fallback: if identifier was a UUID string, try that - if let uuid = UUID(uuidString: identifier), - let updatedCard = updatedBoard.cards.first(where: { $0.id == uuid }) - { - return updatedCard - } + for (key, value) in updates { + cards[cardIndex][key] = value + } + board["cards"] = cards - // Final fallback: name search (only reliable when title wasn't updated) - let identifierLower = identifier.lowercased() - if let updatedCard = updatedBoard.cards.first(where: { - $0.title.lowercased() == identifierLower || $0.title.lowercased().contains(identifierLower) - }) { - return updatedCard + // Decode the post-mutation card from the in-memory dict so we return a result + // that matches what's about to be persisted (and don't have to re-read from disk + // outside the claim, which would re-open the race). + let decoded = try decodeCard(at: cardIndex, in: cards, identifier: identifier, capturedUUID: cardUUID) + return decoded } + } - throw WriteError.cardNotFound(identifier: identifier) + /// Decode the card at `cardIndex` from a raw cards array. + /// Helper used by `updateCard` to return a typed `Card` from the mutated state without + /// leaving the atomic claim. + private static func decodeCard( + at cardIndex: Int, + in cards: [[String: Any]], + identifier: String, + capturedUUID: UUID? + ) throws -> Card { + let cardData = try JSONSerialization.data(withJSONObject: cards[cardIndex]) + do { + return try JSONDecoder().decode(Card.self, from: cardData) + } catch { + // If decoding the specific row fails for any reason, surface card-not-found + // with the original identifier so callers see a useful error. + _ = capturedUUID + throw WriteError.cardNotFound(identifier: identifier) + } } - /// Move a card to a different column + /// Move a card to a different column atomically. See `updateCard` for race-fix details. public static func moveCard( identifier: String, toColumn columnName: String, dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws -> Card { - let rawBoard = try loadRawBoard(dataDirectory: dataDirectory, debug: debug) - let boardURL = rawBoard.url - var board = rawBoard.data - guard var cards = board["cards"] as? [[String: Any]], - let columns = board["columns"] as? [[String: Any]] - else { - throw WriteError.encodingFailed("Invalid board format") - } - - // Find target column - let columnNameLower = columnName.lowercased() - guard - let targetColumn = columns.first(where: { - ($0["name"] as? String)?.lowercased() == columnNameLower - }), - let targetColumnId = targetColumn["id"] as? String - else { - throw WriteError.columnNotFound(name: columnName) - } + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var cards = board["cards"] as? [[String: Any]], + let columns = board["columns"] as? [[String: Any]] + else { + throw WriteError.encodingFailed("Invalid board format") + } - // Find the card to move - let cardIndex = try findCardIndex(identifier: identifier, in: cards) + // Find target column + let columnNameLower = columnName.lowercased() + guard + let targetColumn = columns.first(where: { + ($0["name"] as? String)?.lowercased() == columnNameLower + }), + let targetColumnId = targetColumn["id"] as? String + else { + throw WriteError.columnNotFound(name: columnName) + } - // Calculate new orderIndex (put at end of target column) - let cardsInTargetColumn = cards.filter { ($0["columnId"] as? String) == targetColumnId } - let maxOrderIndex = cardsInTargetColumn.compactMap { $0["orderIndex"] as? Int }.max() ?? -1 + let cardIndex = try findCardIndex(identifier: identifier, in: cards) - // Update the card - cards[cardIndex]["columnId"] = targetColumnId - cards[cardIndex]["orderIndex"] = maxOrderIndex + 1 + // Calculate new orderIndex inside the claim — concurrent appends can no longer + // both compute the same `max + 1`. + let cardsInTargetColumn = cards.filter { ($0["columnId"] as? String) == targetColumnId } + let maxOrderIndex = cardsInTargetColumn.compactMap { $0["orderIndex"] as? Int }.max() ?? -1 - // Save back - board["cards"] = cards - try saveRawBoard(board, to: boardURL) + cards[cardIndex]["columnId"] = targetColumnId + cards[cardIndex]["orderIndex"] = maxOrderIndex + 1 + board["cards"] = cards - // Return the updated card - let updatedBoard = try BoardLoader.loadBoard(dataDirectory: dataDirectory, debug: debug) - guard let updatedCard = updatedBoard.findTerminal(identifier: identifier) else { - throw WriteError.cardNotFound(identifier: identifier) + let cardUUID = (cards[cardIndex]["id"] as? String).flatMap(UUID.init(uuidString:)) + return try decodeCard(at: cardIndex, in: cards, identifier: identifier, capturedUUID: cardUUID) } - return updatedCard } - /// Create a new card + /// Create a new card atomically. See `updateCard` for race-fix details. + /// The `orderIndex` calculation now runs inside the exclusive claim, so two + /// concurrent appends to the same column produce distinct `orderIndex` values. public static func createCard( name: String, columnName: String?, workingDirectory: String, description: String = "", dataDirectory: URL? = nil, - debug: Bool = false + profile: AppProfile.Variant = .current ) throws -> Card { - let rawBoard = try loadRawBoard(dataDirectory: dataDirectory, debug: debug) - let boardURL = rawBoard.url - var board = rawBoard.data - guard var cards = board["cards"] as? [[String: Any]], - let columns = board["columns"] as? [[String: Any]] - else { - throw WriteError.encodingFailed("Invalid board format") - } - - // Find target column (default to first column if not specified) - let targetColumn: [String: Any] - if let columnName = columnName { - let columnNameLower = columnName.lowercased() - guard - let found = columns.first(where: { - ($0["name"] as? String)?.lowercased() == columnNameLower - }) + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var cards = board["cards"] as? [[String: Any]], + let columns = board["columns"] as? [[String: Any]] else { - throw WriteError.columnNotFound(name: columnName) + throw WriteError.encodingFailed("Invalid board format") } - targetColumn = found - } else { - // Use first column sorted by orderIndex - let sortedColumns = columns.sorted { - ($0["orderIndex"] as? Int ?? 0) < ($1["orderIndex"] as? Int ?? 0) - } - guard let first = sortedColumns.first else { - throw WriteError.columnNotFound(name: "default") + + // Find target column (default to first column if not specified) + let targetColumn: [String: Any] + if let columnName = columnName { + let columnNameLower = columnName.lowercased() + guard + let found = columns.first(where: { + ($0["name"] as? String)?.lowercased() == columnNameLower + }) + else { + throw WriteError.columnNotFound(name: columnName) + } + targetColumn = found + } else { + let sortedColumns = columns.sorted { + ($0["orderIndex"] as? Int ?? 0) < ($1["orderIndex"] as? Int ?? 0) + } + guard let first = sortedColumns.first else { + throw WriteError.columnNotFound(name: "default") + } + targetColumn = first } - targetColumn = first - } - guard let targetColumnId = targetColumn["id"] as? String else { - throw WriteError.columnNotFound(name: columnName ?? "default") - } + guard let targetColumnId = targetColumn["id"] as? String else { + throw WriteError.columnNotFound(name: columnName ?? "default") + } - // Calculate orderIndex - let cardsInTargetColumn = cards.filter { ($0["columnId"] as? String) == targetColumnId } - let maxOrderIndex = cardsInTargetColumn.compactMap { $0["orderIndex"] as? Int }.max() ?? -1 - - // Create new card - let newCardId = UUID() - let newCard: [String: Any] = [ - "id": newCardId.uuidString, - "title": name, - "description": description, - "columnId": targetColumnId, - "orderIndex": maxOrderIndex + 1, - "workingDirectory": workingDirectory, - "isFavourite": false, - "badge": "", - "llmPrompt": "", - "llmNextAction": "", - "tags": [[String: Any]](), - "createdAt": ISO8601DateFormatter().string(from: Date()), - ] - - cards.append(newCard) - board["cards"] = cards - try saveRawBoard(board, to: boardURL) - - // Return the created card - let updatedBoard = try BoardLoader.loadBoard(dataDirectory: dataDirectory, debug: debug) - guard let createdCard = updatedBoard.findTerminal(identifier: newCardId.uuidString) else { - throw WriteError.cardNotFound(identifier: newCardId.uuidString) + let cardsInTargetColumn = cards.filter { ($0["columnId"] as? String) == targetColumnId } + let maxOrderIndex = cardsInTargetColumn.compactMap { $0["orderIndex"] as? Int }.max() ?? -1 + + let newCardId = UUID() + let newCard: [String: Any] = [ + "id": newCardId.uuidString, + "title": name, + "description": description, + "columnId": targetColumnId, + "orderIndex": maxOrderIndex + 1, + "workingDirectory": workingDirectory, + "isFavourite": false, + "badge": "", + "llmPrompt": "", + "llmNextAction": "", + "tags": [[String: Any]](), + "createdAt": ISO8601DateFormatter().string(from: Date()), + ] + + cards.append(newCard) + board["cards"] = cards + + // Decode the just-appended card from the in-claim state. + let newIndex = cards.count - 1 + return try decodeCard( + at: newIndex, in: cards, identifier: newCardId.uuidString, capturedUUID: newCardId) } - return createdCard } /// Find card index by identifier diff --git a/Sources/TermQShared/CardFilterEngine.swift b/Sources/TermQShared/CardFilterEngine.swift index d76cc981..a27d28cc 100644 --- a/Sources/TermQShared/CardFilterEngine.swift +++ b/Sources/TermQShared/CardFilterEngine.swift @@ -4,12 +4,20 @@ import Foundation /// Shared between MCP (ToolHandlers) and CLI (CLI+Find) to eliminate duplication. public enum CardFilterEngine { - // MARK: - Tag Value Matching - - /// Controls how tag values are matched. MCP uses exact match; CLI uses partial. - public enum TagValueMatch: Sendable { - case exact - case contains + // MARK: - Tag Filter Errors + + /// Errors that can surface from `filterByTag`. Surfaced to the user — CLI exits non-zero, + /// MCP returns `isError: true`. Never silently fall back to literal match when the user + /// asked for regex; that would mask a typo and return surprising results. + public enum TagFilterError: Error, CustomStringConvertible, Sendable { + case invalidRegex(pattern: String, message: String) + + public var description: String { + switch self { + case .invalidRegex(let pattern, let message): + return "Invalid regex in tag filter '\(pattern)': \(message)" + } + } } // MARK: - Filtering @@ -30,29 +38,56 @@ public enum CardFilterEngine { return cards.filter { matchingIds.contains($0.columnId) } } - /// Filters cards by tag. Accepts `"key"` or `"key=value"` format. - /// Returns `cards` unchanged when `tagFilter` is nil. + /// Filters cards by tag. Accepts: + /// - `"key"` — key-only literal match + /// - `"key=value"` — exact literal match on both key and value (case-insensitive) + /// - `"key=re:pattern"` — key matches literally; value matches regex pattern + /// - `"re:pattern"` — whole `key=value` string (or `key` alone) matches regex pattern + /// + /// Literal is the default to avoid the regex-metacharacter footgun (a tag like + /// `project=v1.2` would otherwise also match `project=v1X2` because `.` is a metachar). + /// Returns `cards` unchanged when `tagFilter` is nil. Throws `TagFilterError.invalidRegex` + /// if a `re:`-prefixed pattern fails to compile. public static func filterByTag( _ cards: [Card], - tagFilter: String?, - valueMatch: TagValueMatch = .exact - ) -> [Card] { + tagFilter: String? + ) throws -> [Card] { guard let tagFilter else { return cards } + + // Whole-pattern regex: `re:...` + if let pattern = dropPrefix("re:", from: tagFilter) { + let regex = try compileRegex(pattern, original: tagFilter) + return cards.filter { card in + card.tags.contains { tag in + let fullTag = tag.value.isEmpty ? tag.key : "\(tag.key)=\(tag.value)" + return regex.matches(fullTag) + } + } + } + if tagFilter.contains("=") { let parts = tagFilter.split(separator: "=", maxSplits: 1) guard parts.count == 2 else { return cards } let key = String(parts[0]).lowercased() - let value = String(parts[1]).lowercased() - return cards.filter { card in - card.tags.contains { tag in - let keyMatch = tag.key.lowercased() == key - switch valueMatch { - case .exact: return keyMatch && tag.value.lowercased() == value - case .contains: return keyMatch && tag.value.lowercased().contains(value) + let rawValue = String(parts[1]) + + // Value-position regex: `key=re:...` + if let valuePattern = dropPrefix("re:", from: rawValue) { + let regex = try compileRegex(valuePattern, original: tagFilter) + return cards.filter { card in + card.tags.contains { tag in + tag.key.lowercased() == key && regex.matches(tag.value) } } } + + // Literal exact match + let value = rawValue.lowercased() + return cards.filter { card in + card.tags.contains { $0.key.lowercased() == key && $0.value.lowercased() == value } + } } else { + // Key-only literal match let key = tagFilter.lowercased() return cards.filter { card in card.tags.contains { $0.key.lowercased() == key } @@ -60,6 +95,20 @@ public enum CardFilterEngine { } } + // MARK: - Tag Filter Helpers + + private static func dropPrefix(_ prefix: String, from string: String) -> String? { + string.hasPrefix(prefix) ? String(string.dropFirst(prefix.count)) : nil + } + + private static func compileRegex(_ pattern: String, original: String) throws -> NSRegularExpression { + do { + return try NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) + } catch { + throw TagFilterError.invalidRegex(pattern: original, message: error.localizedDescription) + } + } + /// Filters cards whose badge contains `badge` (case-insensitive, partial match). /// Returns `cards` unchanged when `badge` is nil. public static func filterByBadge(_ cards: [Card], badge: String?) -> [Card] { @@ -138,3 +187,10 @@ public enum CardFilterEngine { return score } } + +extension NSRegularExpression { + fileprivate func matches(_ string: String) -> Bool { + let range = NSRange(string.startIndex..., in: string) + return firstMatch(in: string, options: [], range: range) != nil + } +} diff --git a/Sources/TermQShared/LocalYNHConfig.swift b/Sources/TermQShared/LocalYNHConfig.swift index 6ac58aa0..428c0a65 100644 --- a/Sources/TermQShared/LocalYNHConfig.swift +++ b/Sources/TermQShared/LocalYNHConfig.swift @@ -94,13 +94,14 @@ public enum YNHConfigLoader { } } - public static func getConfigURL(dataDirectory: URL? = nil, debug: Bool = false) -> URL { - BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, debug: debug) + public static func getConfigURL(dataDirectory: URL? = nil, profile: AppProfile.Variant = .current) -> URL { + BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, profile: profile) .appendingPathComponent("ynh.json") } - public static func load(dataDirectory: URL? = nil, debug: Bool = false) throws -> LocalYNHConfig { - let configURL = getConfigURL(dataDirectory: dataDirectory, debug: debug) + public static func load(dataDirectory: URL? = nil, profile: AppProfile.Variant = .current) throws -> LocalYNHConfig + { + let configURL = getConfigURL(dataDirectory: dataDirectory, profile: profile) guard FileManager.default.fileExists(atPath: configURL.path) else { return LocalYNHConfig() @@ -132,8 +133,10 @@ public enum YNHConfigLoader { return try result.get() } - public static func save(_ config: LocalYNHConfig, dataDirectory: URL? = nil, debug: Bool = false) throws { - let configURL = getConfigURL(dataDirectory: dataDirectory, debug: debug) + public static func save( + _ config: LocalYNHConfig, dataDirectory: URL? = nil, profile: AppProfile.Variant = .current + ) throws { + let configURL = getConfigURL(dataDirectory: dataDirectory, profile: profile) let dirURL = configURL.deletingLastPathComponent() if !FileManager.default.fileExists(atPath: dirURL.path) { diff --git a/Sources/TermQShared/RepoConfigLoader.swift b/Sources/TermQShared/RepoConfigLoader.swift index 33bb8e86..0bf5227b 100644 --- a/Sources/TermQShared/RepoConfigLoader.swift +++ b/Sources/TermQShared/RepoConfigLoader.swift @@ -39,16 +39,16 @@ public enum RepoConfigLoader { } /// URL for `repos.json` in the TermQ data directory - public static func getConfigURL(dataDirectory: URL? = nil, debug: Bool = false) -> URL { - BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, debug: debug) + public static func getConfigURL(dataDirectory: URL? = nil, profile: AppProfile.Variant = .current) -> URL { + BoardLoader.getDataDirectoryPath(customDirectory: dataDirectory, profile: profile) .appendingPathComponent("repos.json") } /// Load repository configuration from disk. /// /// Returns an empty `RepoConfig` if the file does not exist yet. - public static func load(dataDirectory: URL? = nil, debug: Bool = false) throws -> RepoConfig { - let configURL = getConfigURL(dataDirectory: dataDirectory, debug: debug) + public static func load(dataDirectory: URL? = nil, profile: AppProfile.Variant = .current) throws -> RepoConfig { + let configURL = getConfigURL(dataDirectory: dataDirectory, profile: profile) guard FileManager.default.fileExists(atPath: configURL.path) else { return RepoConfig() @@ -86,8 +86,10 @@ public enum RepoConfigLoader { /// Save repository configuration to disk. /// /// Creates the TermQ data directory if it does not yet exist. - public static func save(_ config: RepoConfig, dataDirectory: URL? = nil, debug: Bool = false) throws { - let configURL = getConfigURL(dataDirectory: dataDirectory, debug: debug) + public static func save( + _ config: RepoConfig, dataDirectory: URL? = nil, profile: AppProfile.Variant = .current + ) throws { + let configURL = getConfigURL(dataDirectory: dataDirectory, profile: profile) // Ensure the data directory exists let dirURL = configURL.deletingLastPathComponent() diff --git a/Tests/IntegrationTests/MCPToolReadTests.swift b/Tests/IntegrationTests/MCPToolReadTests.swift index b0fdf96a..f3ca546b 100644 --- a/Tests/IntegrationTests/MCPToolReadTests.swift +++ b/Tests/IntegrationTests/MCPToolReadTests.swift @@ -27,7 +27,7 @@ final class MCPToolReadTests: XCTestCase { server = nil } - // MARK: - termq_list Tests + // MARK: - list Tests func testListReturnsAllTerminals() async throws { let result = try await server.handleList(nil) @@ -118,7 +118,7 @@ final class MCPToolReadTests: XCTestCase { XCTAssertEqual(columns[2].terminalCount, 1, "Done should have 1 terminal") } - // MARK: - termq_find Tests + // MARK: - find Tests func testFindBySmartQuery() async throws { let args: [String: Value] = ["query": .string("Fresh Active Project")] @@ -235,7 +235,7 @@ final class MCPToolReadTests: XCTestCase { XCTAssertEqual(terminals.count, 0, "Should return empty array for no matches") } - // MARK: - termq_open Tests + // MARK: - open Tests func testOpenByExactName() async throws { let args: [String: Value] = ["identifier": .string("Fresh Active Project")] @@ -307,7 +307,7 @@ final class MCPToolReadTests: XCTestCase { XCTAssertTrue(result.isError ?? false) } - // MARK: - termq_pending Tests + // MARK: - pending Tests func testPendingReturnsAllWithSummary() async throws { let result = try await server.handlePending(nil) @@ -397,7 +397,7 @@ final class MCPToolReadTests: XCTestCase { XCTAssertGreaterThan(output.summary.stale, 0, "Should have stale terminals") } - // MARK: - termq_context Tests + // MARK: - context Tests func testContextReturnsGuide() async throws { let result = try await server.handleContext() @@ -425,7 +425,7 @@ final class MCPToolReadTests: XCTestCase { XCTAssertTrue(content.contains("staleness"), "Should document staleness tag") } - // MARK: - termq_get Tests + // MARK: - get Tests func testGetByUUID() async throws { // Create environment with known UUID diff --git a/Tests/IntegrationTests/MCPToolWriteTests.swift b/Tests/IntegrationTests/MCPToolWriteTests.swift index 41fe0b5d..3ba8f72f 100644 --- a/Tests/IntegrationTests/MCPToolWriteTests.swift +++ b/Tests/IntegrationTests/MCPToolWriteTests.swift @@ -15,9 +15,9 @@ import XCTest /// See: .claude/plans/headless-mode-implementation.md for implementation plan. /// /// Key workflows tested: -/// - Creating terminals (termq_create) - SKIPPED, requires GUI -/// - Updating terminal fields (termq_set) - Passing (uses existing terminals) -/// - Moving terminals between columns (termq_move) - SKIPPED, requires GUI +/// - Creating terminals (create) - SKIPPED, requires GUI +/// - Updating terminal fields (set) - Passing (uses existing terminals) +/// - Moving terminals between columns (move) - SKIPPED, requires GUI /// - Setting tags via MCP - Passing (uses existing terminals) /// /// Run with: `swift test --filter MCPToolWriteTests` @@ -40,7 +40,7 @@ final class MCPToolWriteTests: XCTestCase { GUIDetector.testModeOverride = nil } - // MARK: - termq_create Tests + // MARK: - create Tests func testCreateTerminalWithName() async throws { let args: [String: Value] = [ @@ -115,7 +115,7 @@ final class MCPToolWriteTests: XCTestCase { XCTAssertNotNil(found, "Created terminal should persist in board file") } - // MARK: - termq_set Tests + // MARK: - set Tests func testSetTerminalName() async throws { let args: [String: Value] = [ @@ -240,7 +240,7 @@ final class MCPToolWriteTests: XCTestCase { XCTAssertEqual(terminal?.llmPrompt, "Persisted prompt value") } - // MARK: - termq_set Tag Tests (TDD - may expose missing functionality) + // MARK: - set Tag Tests (TDD - may expose missing functionality) func testSetSingleTag() async throws { // User's key workflow: setting tags via MCP @@ -260,7 +260,7 @@ final class MCPToolWriteTests: XCTestCase { } // Verify the tag was actually set - XCTAssertEqual(tags["project"], "my/repo", "Tag 'project=my/repo' should be set via termq_set") + XCTAssertEqual(tags["project"], "my/repo", "Tag 'project=my/repo' should be set via set") } func testSetMultipleTags() async throws { @@ -289,7 +289,7 @@ final class MCPToolWriteTests: XCTestCase { XCTAssertEqual(tags["type"], "feature", "type tag should be set") } - // MARK: - termq_move Tests + // MARK: - move Tests func testMoveTerminalToColumn() async throws { let args: [String: Value] = [ diff --git a/Tests/MCPServerLibTests/MCPIntegrationTests.swift b/Tests/MCPServerLibTests/MCPIntegrationTests.swift index d4554bdd..d4a81e32 100644 --- a/Tests/MCPServerLibTests/MCPIntegrationTests.swift +++ b/Tests/MCPServerLibTests/MCPIntegrationTests.swift @@ -525,16 +525,16 @@ final class MCPIntegrationTests: XCTestCase { XCTAssertEqual(tools.count, 10) let toolNames = Set(tools.map { $0.name }) - XCTAssertTrue(toolNames.contains("termq_pending")) - XCTAssertTrue(toolNames.contains("termq_context")) - XCTAssertTrue(toolNames.contains("termq_list")) - XCTAssertTrue(toolNames.contains("termq_find")) - XCTAssertTrue(toolNames.contains("termq_open")) - XCTAssertTrue(toolNames.contains("termq_create")) - XCTAssertTrue(toolNames.contains("termq_set")) - XCTAssertTrue(toolNames.contains("termq_move")) - XCTAssertTrue(toolNames.contains("termq_get")) - XCTAssertTrue(toolNames.contains("termq_delete")) + XCTAssertTrue(toolNames.contains("pending")) + XCTAssertTrue(toolNames.contains("context")) + XCTAssertTrue(toolNames.contains("list")) + XCTAssertTrue(toolNames.contains("find")) + XCTAssertTrue(toolNames.contains("open")) + XCTAssertTrue(toolNames.contains("create")) + XCTAssertTrue(toolNames.contains("set")) + XCTAssertTrue(toolNames.contains("move")) + XCTAssertTrue(toolNames.contains("get")) + XCTAssertTrue(toolNames.contains("delete")) } func testAvailableResourcesSchema() { diff --git a/Tests/MCPServerLibTests/ResourceHandlersTests.swift b/Tests/MCPServerLibTests/ResourceHandlersTests.swift index 72ef1e17..579418d0 100644 --- a/Tests/MCPServerLibTests/ResourceHandlersTests.swift +++ b/Tests/MCPServerLibTests/ResourceHandlersTests.swift @@ -92,15 +92,20 @@ final class ResourceHandlersTests: XCTestCase { } func testTerminalsResourceWithLoadError() async throws { - // Server with no board.json + // Server with no board.json — load failures must SURFACE to the client, not + // silently return "[]". An empty array means "the board has zero cards"; a + // missing/corrupt board means "something is wrong with the install." Conflating + // the two is exactly the bug this work was opened to fix. server = TermQMCPServer(dataDirectory: tempDirectory) let params = ReadResource.Parameters(uri: "termq://terminals") - let result = try await server.dispatchResourceRead(params) - - // Should return empty array on error - let json = result.contents[0].text ?? "" - XCTAssertEqual(json, "[]") + do { + _ = try await server.dispatchResourceRead(params) + XCTFail("Expected load error to be thrown") + } catch { + // Any thrown error is acceptable — the contract is "surface the failure," not a + // specific error type. Confirming the call did not silently succeed is enough. + } } // MARK: - Columns Resource Tests @@ -129,10 +134,12 @@ final class ResourceHandlersTests: XCTestCase { server = TermQMCPServer(dataDirectory: tempDirectory) let params = ReadResource.Parameters(uri: "termq://columns") - let result = try await server.dispatchResourceRead(params) - - let json = result.contents[0].text ?? "" - XCTAssertEqual(json, "[]") + do { + _ = try await server.dispatchResourceRead(params) + XCTFail("Expected load error to be thrown") + } catch { + // Surface, not swallow — see testTerminalsResourceWithLoadError. + } } // MARK: - Pending Resource Tests @@ -196,10 +203,12 @@ final class ResourceHandlersTests: XCTestCase { server = TermQMCPServer(dataDirectory: tempDirectory) let params = ReadResource.Parameters(uri: "termq://pending") - let result = try await server.dispatchResourceRead(params) - - let json = result.contents[0].text ?? "" - XCTAssertEqual(json, "{}") + do { + _ = try await server.dispatchResourceRead(params) + XCTFail("Expected load error to be thrown") + } catch { + // Surface, not swallow — see testTerminalsResourceWithLoadError. + } } func testPendingResourceSortsByActionsFirst() async throws { diff --git a/Tests/MCPServerLibTests/ServerTests.swift b/Tests/MCPServerLibTests/ServerTests.swift index c9e7a40a..95fe1eef 100644 --- a/Tests/MCPServerLibTests/ServerTests.swift +++ b/Tests/MCPServerLibTests/ServerTests.swift @@ -96,7 +96,7 @@ final class ServerTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let server = TermQMCPServer(dataDirectory: tempDir) - let params = CallTool.Parameters(name: "termq_pending", arguments: nil) + let params = CallTool.Parameters(name: "pending", arguments: nil) let result = try await server.dispatchToolCall(params) XCTAssertFalse(result.isError ?? false) @@ -108,7 +108,7 @@ final class ServerTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let server = TermQMCPServer(dataDirectory: tempDir) - let params = CallTool.Parameters(name: "termq_context", arguments: nil) + let params = CallTool.Parameters(name: "context", arguments: nil) let result = try await server.dispatchToolCall(params) XCTAssertFalse(result.isError ?? false) @@ -124,7 +124,7 @@ final class ServerTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let server = TermQMCPServer(dataDirectory: tempDir) - let params = CallTool.Parameters(name: "termq_list", arguments: nil) + let params = CallTool.Parameters(name: "list", arguments: nil) let result = try await server.dispatchToolCall(params) XCTAssertFalse(result.isError ?? false) @@ -135,7 +135,7 @@ final class ServerTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let server = TermQMCPServer(dataDirectory: tempDir) - let params = CallTool.Parameters(name: "termq_find", arguments: ["name": .string("Test")]) + let params = CallTool.Parameters(name: "find", arguments: ["name": .string("Test")]) let result = try await server.dispatchToolCall(params) XCTAssertFalse(result.isError ?? false) @@ -146,7 +146,7 @@ final class ServerTests: XCTestCase { defer { try? FileManager.default.removeItem(at: tempDir) } let server = TermQMCPServer(dataDirectory: tempDir) - let params = CallTool.Parameters(name: "termq_open", arguments: ["identifier": .string("Test Terminal")]) + let params = CallTool.Parameters(name: "open", arguments: ["identifier": .string("Test Terminal")]) let result = try await server.dispatchToolCall(params) // This might fail because terminal doesn't exist, which is fine @@ -160,7 +160,7 @@ final class ServerTests: XCTestCase { let server = TermQMCPServer(dataDirectory: tempDir) let params = CallTool.Parameters( - name: "termq_create", + name: "create", arguments: ["name": .string("New Terminal"), "path": .string("/tmp")] ) let result = try await server.dispatchToolCall(params) @@ -174,7 +174,7 @@ final class ServerTests: XCTestCase { let server = TermQMCPServer(dataDirectory: tempDir) let params = CallTool.Parameters( - name: "termq_set", + name: "set", arguments: ["identifier": .string("Test Terminal"), "description": .string("Updated")] ) let result = try await server.dispatchToolCall(params) @@ -189,7 +189,7 @@ final class ServerTests: XCTestCase { let server = TermQMCPServer(dataDirectory: tempDir) let params = CallTool.Parameters( - name: "termq_move", + name: "move", arguments: ["identifier": .string("Test Terminal"), "column": .string("Done")] ) let result = try await server.dispatchToolCall(params) @@ -203,7 +203,7 @@ final class ServerTests: XCTestCase { let server = TermQMCPServer(dataDirectory: tempDir) let params = CallTool.Parameters( - name: "termq_get", + name: "get", arguments: ["id": .string(UUID().uuidString)] ) let result = try await server.dispatchToolCall(params) @@ -256,16 +256,16 @@ final class ServerTests: XCTestCase { let tools = TermQMCPServer.availableTools let names = Set(tools.map { $0.name }) - XCTAssertTrue(names.contains("termq_pending")) - XCTAssertTrue(names.contains("termq_context")) - XCTAssertTrue(names.contains("termq_list")) - XCTAssertTrue(names.contains("termq_find")) - XCTAssertTrue(names.contains("termq_open")) - XCTAssertTrue(names.contains("termq_create")) - XCTAssertTrue(names.contains("termq_set")) - XCTAssertTrue(names.contains("termq_move")) - XCTAssertTrue(names.contains("termq_get")) - XCTAssertTrue(names.contains("termq_delete")) + XCTAssertTrue(names.contains("pending")) + XCTAssertTrue(names.contains("context")) + XCTAssertTrue(names.contains("list")) + XCTAssertTrue(names.contains("find")) + XCTAssertTrue(names.contains("open")) + XCTAssertTrue(names.contains("create")) + XCTAssertTrue(names.contains("set")) + XCTAssertTrue(names.contains("move")) + XCTAssertTrue(names.contains("get")) + XCTAssertTrue(names.contains("delete")) } // MARK: - Context Documentation Tests diff --git a/Tests/TermQSharedTests/BoardLoaderTests.swift b/Tests/TermQSharedTests/BoardLoaderTests.swift index 08e42c43..2efc2c44 100644 --- a/Tests/TermQSharedTests/BoardLoaderTests.swift +++ b/Tests/TermQSharedTests/BoardLoaderTests.swift @@ -3,6 +3,31 @@ import XCTest @testable import TermQShared +/// Sendable accumulator for errors observed inside `DispatchQueue.concurrentPerform`. +/// NSMutableArray would work but isn't Sendable under Swift 6 strict concurrency. +private final class ConcurrentErrorBox: @unchecked Sendable { + private let lock = NSLock() + private var items: [String] = [] + + func add(_ s: String) { + lock.lock() + items.append(s) + lock.unlock() + } + + var count: Int { + lock.lock() + defer { lock.unlock() } + return items.count + } + + var all: [String] { + lock.lock() + defer { lock.unlock() } + return items + } +} + final class BoardLoaderTests: XCTestCase { var tempDirectory: URL! @@ -78,12 +103,12 @@ final class BoardLoaderTests: XCTestCase { } func testGetDataDirectoryPathDebugMode() { - let result = BoardLoader.getDataDirectoryPath(debug: true) + let result = BoardLoader.getDataDirectoryPath(profile: .debug) XCTAssertTrue(result.path.contains("TermQ-Debug")) } func testGetDataDirectoryPathNonDebugMode() { - let result = BoardLoader.getDataDirectoryPath(debug: false) + let result = BoardLoader.getDataDirectoryPath(profile: .production) XCTAssertTrue(result.path.contains("TermQ")) XCTAssertFalse(result.path.contains("TermQ-Debug")) } @@ -677,7 +702,7 @@ final class BoardLoaderTests: XCTestCase { let data = try encoder.encode(board) try data.write(to: debugDir.appendingPathComponent("board.json")) - let loaded = try BoardLoader.loadBoard(dataDirectory: debugDir, debug: true) + let loaded = try BoardLoader.loadBoard(dataDirectory: debugDir, profile: .debug) XCTAssertEqual(loaded.columns.count, 3) } @@ -685,7 +710,7 @@ final class BoardLoaderTests: XCTestCase { let board = createTestBoard() try writeTestBoard(board) - let (_, data) = try BoardWriter.loadRawBoard(dataDirectory: tempDirectory, debug: true) + let (_, data) = try BoardWriter.loadRawBoard(dataDirectory: tempDirectory, profile: .debug) XCTAssertNotNil(data["columns"]) } @@ -829,6 +854,93 @@ final class BoardLoaderTests: XCTestCase { } } + // MARK: - Atomic RMW (Fix C) — concurrency tests + + /// T3.12 — Concurrent appends to the same column produce unique `orderIndex` values. + /// Pre-fix, the split read+write coordinator pattern let two writers both compute the + /// same `max + 1`, producing duplicate `orderIndex` entries. The atomic helper closes + /// this race. + /// + /// Uses `DispatchQueue.concurrentPerform` rather than `async let` because + /// `NSFileCoordinator` is synchronous and blocks its thread — Swift concurrency + /// would not produce genuine concurrent dispatch here. + func testCreateCardConcurrentAppendsProduceUniqueOrderIndex() throws { + // Bootstrap board with one column. + let columnId = UUID() + let json = """ + { + "columns": [{"id": "\(columnId.uuidString)", "name": "To Do", "orderIndex": 0, "color": "#000"}], + "cards": [] + } + """ + try writeRawJSON(json) + + let writerCount = 20 + let directory = tempDirectory + let errorBox = ConcurrentErrorBox() + DispatchQueue.concurrentPerform(iterations: writerCount) { iteration in + do { + _ = try BoardWriter.createCard( + name: "Card-\(iteration)", + columnName: "To Do", + workingDirectory: "/tmp", + dataDirectory: directory + ) + } catch { + errorBox.add("\(iteration): \(error)") + } + } + XCTAssertEqual(errorBox.count, 0, "Concurrent creates threw: \(errorBox.all)") + + let loaded = try BoardLoader.loadBoard(dataDirectory: directory) + XCTAssertEqual(loaded.cards.count, writerCount, "All concurrent creates should persist") + + let orderIndices = loaded.cards.map { $0.orderIndex } + let unique = Set(orderIndices) + XCTAssertEqual( + unique.count, orderIndices.count, + "orderIndex collisions: \(orderIndices.sorted()) — atomic RMW must produce distinct values" + ) + } + + /// T3.10 — Concurrent updates to *different* cards do not lose either change. + /// Pre-fix, both writers could read the same baseline, mutate, and one's write would + /// silently clobber the other's. Atomic RMW serialises the two read-modify-writes. + func testUpdateCardConcurrentDifferentCardsBothSurvive() throws { + // Bootstrap two cards under one column. + let columnId = UUID() + let card1Id = UUID() + let card2Id = UUID() + let json = """ + { + "columns": [{"id": "\(columnId.uuidString)", "name": "To Do", "orderIndex": 0, "color": "#000"}], + "cards": [ + {"id": "\(card1Id.uuidString)", "title": "A", "description": "", + "columnId": "\(columnId.uuidString)", "orderIndex": 0, "workingDirectory": "/tmp", + "isFavourite": false, "badge": "", "llmPrompt": "", "llmNextAction": "", "tags": []}, + {"id": "\(card2Id.uuidString)", "title": "B", "description": "", + "columnId": "\(columnId.uuidString)", "orderIndex": 1, "workingDirectory": "/tmp", + "isFavourite": false, "badge": "", "llmPrompt": "", "llmNextAction": "", "tags": []} + ] + } + """ + try writeRawJSON(json) + + let directory = tempDirectory + DispatchQueue.concurrentPerform(iterations: 2) { i in + let identifier = i == 0 ? card1Id.uuidString : card2Id.uuidString + let badge = i == 0 ? "BADGE-A" : "BADGE-B" + _ = try? BoardWriter.updateCard( + identifier: identifier, updates: ["badge": badge], dataDirectory: directory) + } + + let loaded = try BoardLoader.loadBoard(dataDirectory: directory) + let cardA = loaded.cards.first { $0.id == card1Id } + let cardB = loaded.cards.first { $0.id == card2Id } + XCTAssertEqual(cardA?.badge, "BADGE-A", "Card A's write was clobbered by Card B's write") + XCTAssertEqual(cardB?.badge, "BADGE-B", "Card B's write was clobbered by Card A's write") + } + // MARK: - CreateCard Invalid Columns Format func testCreateCardInvalidColumnsFormat() throws { diff --git a/Tests/TermQSharedTests/CardFilterEngineTests.swift b/Tests/TermQSharedTests/CardFilterEngineTests.swift index bc963ea1..02b2b4c9 100644 --- a/Tests/TermQSharedTests/CardFilterEngineTests.swift +++ b/Tests/TermQSharedTests/CardFilterEngineTests.swift @@ -66,62 +66,89 @@ final class CardFilterEngineTests: XCTestCase { XCTAssertTrue(result.isEmpty) } - // MARK: - filterByTag (key-only format) + // MARK: - filterByTag — literal-by-default, opt-in `re:` regex - func testFilterByTagNilPassesAll() { + func testFilterByTagNilPassesAll() throws { let cards = [makeCard(tags: [Tag(key: "env", value: "prod")], columnId: colA.id)] - let result = CardFilterEngine.filterByTag(cards, tagFilter: nil) + let result = try CardFilterEngine.filterByTag(cards, tagFilter: nil) XCTAssertEqual(result.count, 1) } - func testFilterByTagKeyOnly() { + /// T7.5 — Key-only match returns any card with that tag key. + func testFilterByTagKeyOnly() throws { let matching = makeCard(tags: [Tag(key: "env", value: "prod")], columnId: colA.id) let nonMatching = makeCard(tags: [Tag(key: "team", value: "platform")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([matching, nonMatching], tagFilter: "env") + let result = try CardFilterEngine.filterByTag([matching, nonMatching], tagFilter: "env") XCTAssertEqual(result.count, 1) XCTAssertEqual(result[0].tags.first?.key, "env") } - func testFilterByTagKeyOnlyCaseInsensitive() { + func testFilterByTagKeyOnlyCaseInsensitive() throws { let card = makeCard(tags: [Tag(key: "ENV", value: "prod")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([card], tagFilter: "env") + let result = try CardFilterEngine.filterByTag([card], tagFilter: "env") XCTAssertEqual(result.count, 1) } - func testFilterByTagKeyNoMatch() { + func testFilterByTagKeyNoMatch() throws { let card = makeCard(tags: [Tag(key: "team", value: "platform")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([card], tagFilter: "env") + let result = try CardFilterEngine.filterByTag([card], tagFilter: "env") XCTAssertTrue(result.isEmpty) } - // MARK: - filterByTag (key=value format, exact) - - func testFilterByTagKeyValueExactMatch() { + /// Literal key=value match (default) — case-insensitive exact comparison. + func testFilterByTagKeyValueLiteralMatch() throws { let matching = makeCard(tags: [Tag(key: "env", value: "prod")], columnId: colA.id) let wrong = makeCard(tags: [Tag(key: "env", value: "staging")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([matching, wrong], tagFilter: "env=prod", valueMatch: .exact) + let result = try CardFilterEngine.filterByTag([matching, wrong], tagFilter: "env=prod") XCTAssertEqual(result.count, 1) XCTAssertEqual(result[0].tags.first?.value, "prod") } - func testFilterByTagKeyValueExactNoPartialMatch() { + /// Literal value does NOT do partial match — `env=prod` must not match `env=production`. + func testFilterByTagKeyValueLiteralRejectsPartial() throws { let card = makeCard(tags: [Tag(key: "env", value: "production")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([card], tagFilter: "env=prod", valueMatch: .exact) + let result = try CardFilterEngine.filterByTag([card], tagFilter: "env=prod") XCTAssertTrue(result.isEmpty) } - // MARK: - filterByTag (key=value format, contains) - - func testFilterByTagKeyValueContainsMatch() { - let card = makeCard(tags: [Tag(key: "env", value: "production")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([card], tagFilter: "env=prod", valueMatch: .contains) + /// T7.4 — Regression test for the rejected regex-by-default design. + /// `project=v1.2` matches ONLY the v1.2 card, NOT a v1X2 card (where `.` would be a regex wildcard). + func testFilterByTagLiteralDotIsNotWildcard() throws { + let exact = makeCard(tags: [Tag(key: "project", value: "v1.2")], columnId: colA.id) + let wouldMatchUnderRegex = makeCard(tags: [Tag(key: "project", value: "v1X2")], columnId: colA.id) + let result = try CardFilterEngine.filterByTag([exact, wouldMatchUnderRegex], tagFilter: "project=v1.2") XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].tags.first?.value, "v1.2") } - func testFilterByTagKeyValueContainsNoMatch() { - let card = makeCard(tags: [Tag(key: "env", value: "staging")], columnId: colA.id) - let result = CardFilterEngine.filterByTag([card], tagFilter: "env=prod", valueMatch: .contains) - XCTAssertTrue(result.isEmpty) + /// T7.6 — Opt-in regex via `re:` prefix on value. + func testFilterByTagValueRegex() throws { + let stale = makeCard(tags: [Tag(key: "staleness", value: "stale")], columnId: colA.id) + let ageing = makeCard(tags: [Tag(key: "staleness", value: "ageing")], columnId: colA.id) + let fresh = makeCard(tags: [Tag(key: "staleness", value: "fresh")], columnId: colA.id) + let result = try CardFilterEngine.filterByTag([stale, ageing, fresh], tagFilter: "staleness=re:(stale|ageing)") + XCTAssertEqual(result.count, 2) + XCTAssertFalse(result.contains { $0.tags.first?.value == "fresh" }) + } + + /// T7.7 — Opt-in regex via `re:` prefix on the whole pattern (matches full `key=value`). + func testFilterByTagWholePatternRegex() throws { + let a = makeCard(tags: [Tag(key: "project", value: "org/repo-a")], columnId: colA.id) + let b = makeCard(tags: [Tag(key: "project", value: "org/repo-b")], columnId: colA.id) + let other = makeCard(tags: [Tag(key: "project", value: "external/x")], columnId: colA.id) + let result = try CardFilterEngine.filterByTag([a, b, other], tagFilter: "re:project=org/.+") + XCTAssertEqual(result.count, 2) + } + + /// T7.8 — Invalid regex inside `re:` prefix surfaces an error (not silent literal fallback). + func testFilterByTagInvalidRegexThrows() { + let card = makeCard(tags: [Tag(key: "staleness", value: "stale")], columnId: colA.id) + XCTAssertThrowsError(try CardFilterEngine.filterByTag([card], tagFilter: "staleness=re:[invalid")) { error in + guard case CardFilterEngine.TagFilterError.invalidRegex = error else { + XCTFail("Expected TagFilterError.invalidRegex, got \(error)") + return + } + } } // MARK: - filterByBadge From 2a11b90cf03a96e83b1eb9c90f39e96c1b31188c Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 08:50:24 +0100 Subject: [PATCH 02/13] =?UTF-8?q?feat(mcp):=20tier=201a=20polish=20?= =?UTF-8?q?=E2=80=94=20annotations,=20titles,=20completion,=20logging=20mi?= =?UTF-8?q?rror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1a of the MCP/CLI extend-and-fix plan. Pure additive metadata + completion + notification plumbing. No new TermQ concepts; existing surface becomes a better MCP citizen. Tool annotations — every tool now carries readOnlyHint / destructiveHint / idempotentHint / openWorldHint per the audit §3.4 table. Permissioned clients auto-allow reads, prompt on destructive writes. `delete` marked destructive (even soft-delete; better to ask than not). Display titles — added `title:` to every tool, resource, prompt, and prompt argument. Clients show these alongside the programmatic names; programmatic names stay machine-stable. Argument completion — registered the `completion/complete` capability and implemented suggestions for the `terminal_summary` prompt's `terminal` argument. Matches live board terminal names by case-insensitive substring, capped at 100 results with proper `total` / `hasMore` reporting. Log mirror — removed the locally-redeclared SetLoggingLevel (the MCP Swift SDK now ships it with the proper LogLevel enum, not a free-form String). `logging/setLevel` now actually filters subsequent emissions instead of being silently accepted. Added an `emitLog(...)` helper on TermQMCPServer that posts `notifications/message` to the client, wired into the board-load error path so remote operators see failures without needing local --verbose stderr access. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 8 ++ Sources/MCPServerLib/CompletionHandlers.swift | 69 +++++++++++ Sources/MCPServerLib/SchemaDefinitions.swift | 106 +++++++++++++---- Sources/MCPServerLib/Server.swift | 111 ++++++++++++++---- 4 files changed, 250 insertions(+), 44 deletions(-) create mode 100644 Sources/MCPServerLib/CompletionHandlers.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e96c26..07eba9c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Atomic read-modify-write in `BoardWriter`.** `updateCard`, `moveCard`, and `createCard` previously split their read and write across two separate `NSFileCoordinator` claims, leaving a window where two concurrent writers could both finish their reads before either wrote — the second write silently clobbering the first. They now run inside a single `writingItemAt:` claim via the new `BoardWriter.atomicUpdate(...)` helper, closing the lost-update race and the `orderIndex` collision on concurrent appends. - **`termqmcp --verbose` logs the resolved profile and data directory at startup**, so a debug-vs-production data-directory mismatch is visible to the operator. +### Added — MCP polish + +- **Tool annotations** on every MCP tool — `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Permissioned clients (e.g. Claude Desktop) use these to auto-allow read-only tools and prompt before destructive ones. Notable: `delete` is marked destructive (even soft-delete prompts confirmation in strict clients); `set` and `move` are marked idempotent. +- **Display titles** on every tool, resource, prompt, and prompt argument — human-readable labels surfaced in client UIs alongside the programmatic names. +- **Argument completion** (`completion/complete`) — TermQ now provides autocomplete suggestions for the `terminal_summary` prompt's `terminal` argument, matching live board terminal names by case-insensitive substring. Capped at 100 results; clients see `total` and `hasMore` so they can prompt the user to refine. +- **`notifications/message` log mirror** — `termqmcp` now emits MCP log notifications (gated by client-configured minimum level via `logging/setLevel`) for board-load failures and other operationally relevant events. Lets a remote operator observe failures without needing `--verbose` stderr access. +- **`logging/setLevel` honoured properly** — previously the request was accepted and ignored; now the configured threshold actually filters subsequent log emissions. + ### Added - **Focus and profile editing** — editable harnesses gain full inline editing for focuses and profiles diff --git a/Sources/MCPServerLib/CompletionHandlers.swift b/Sources/MCPServerLib/CompletionHandlers.swift new file mode 100644 index 00000000..bdf250b5 --- /dev/null +++ b/Sources/MCPServerLib/CompletionHandlers.swift @@ -0,0 +1,69 @@ +import Foundation +import MCP +import TermQShared + +// MARK: - Completion Handler +// +// Implements `completion/complete` per the MCP spec. The TermQ server provides +// completion values for the `terminal` argument of the `terminal_summary` prompt: +// matching terminal names from the live board. + +extension TermQMCPServer { + /// Maximum number of completion suggestions returned per the spec (clients display these). + private static let maxCompletionValues = 100 + + /// Handle `completion/complete` requests. + func dispatchCompletion(_ params: Complete.Parameters) async throws -> Complete.Result { + switch params.ref { + case .prompt(let promptRef): + return try completePromptArgument(promptName: promptRef.name, argument: params.argument) + case .resource: + // Tier 1b will add resource template completions (e.g. for `termq://terminal/{id}`). + // For now, return an empty completion rather than erroring — the spec allows this. + return Complete.Result(completion: .init(values: [], total: 0, hasMore: false)) + } + } + + private func completePromptArgument( + promptName: String, + argument: Complete.Parameters.Argument + ) throws -> Complete.Result { + switch (promptName, argument.name) { + case ("terminal_summary", "terminal"): + return try completeTerminalIdentifier(prefix: argument.value) + default: + // Unknown (prompt, argument) pair — return empty completion. Clients show + // nothing rather than an error; the user just gets no suggestions. + return Complete.Result(completion: .init(values: [], total: 0, hasMore: false)) + } + } + + /// Suggest terminal names matching the user's partial input. + /// + /// Matches by case-insensitive substring on the title. If the user types nothing, + /// returns the first `maxCompletionValues` active terminals so they can pick from a + /// browsable list. + private func completeTerminalIdentifier(prefix: String) throws -> Complete.Result { + let board: Board + do { + board = try loadBoard() + } catch { + // No board / load failure — return empty completion. Surfacing an error here + // would prevent autocomplete from working at all in a degraded environment. + return Complete.Result(completion: .init(values: [], total: 0, hasMore: false)) + } + + let prefixLower = prefix.lowercased() + let matching = board.activeCards.filter { card in + prefixLower.isEmpty || card.title.lowercased().contains(prefixLower) + } + let values = matching.prefix(Self.maxCompletionValues).map { $0.title } + return Complete.Result( + completion: .init( + values: Array(values), + total: matching.count, + hasMore: matching.count > Self.maxCompletionValues + ) + ) + } +} diff --git a/Sources/MCPServerLib/SchemaDefinitions.swift b/Sources/MCPServerLib/SchemaDefinitions.swift index a23b3613..6fb259d5 100644 --- a/Sources/MCPServerLib/SchemaDefinitions.swift +++ b/Sources/MCPServerLib/SchemaDefinitions.swift @@ -11,6 +11,7 @@ extension TermQMCPServer { [ Tool( name: "pending", + title: "List pending terminals", description: """ Check terminals needing attention. Run this at the START of every LLM session. Returns terminals with pending actions (llmNextAction) and staleness indicators. @@ -18,31 +19,42 @@ extension TermQMCPServer { """, inputSchema: Schema.objectSchema([ Schema.bool("actionsOnly", "Only show terminals with llmNextAction set") - ]) + ]), + annotations: Tool.Annotations( + readOnlyHint: true, idempotentHint: true, openWorldHint: false) ), Tool( name: "context", + title: "Workflow documentation", description: """ Output comprehensive documentation for LLM/AI assistants. Includes session start/end checklists, tag schema, command reference, and workflow examples for cross-session continuity. """, - inputSchema: Schema.emptySchema() + inputSchema: Schema.emptySchema(), + annotations: Tool.Annotations( + readOnlyHint: true, idempotentHint: true, openWorldHint: false) ), Tool( name: "list", + title: "List terminals", description: "List all terminals or filter by column. Supports listing columns only.", inputSchema: Schema.objectSchema([ Schema.string("column", "Filter by column name"), Schema.bool("columnsOnly", "Return only column names"), - ]) + ]), + annotations: Tool.Annotations( + readOnlyHint: true, idempotentHint: true, openWorldHint: false) ), Tool( name: "find", + title: "Search terminals", description: """ Search for terminals by various criteria. Use 'query' for smart multi-word search across name, description, path, and tags. All filters are AND-combined. Returns matching terminals as JSON array sorted by relevance. + Tag filter is literal exact-match by default; prefix with `re:` for regex + (e.g. `staleness=re:(stale|ageing)` or `re:project=org/.+`). """, inputSchema: Schema.objectSchema([ Schema.string( @@ -52,26 +64,40 @@ extension TermQMCPServer { ), Schema.string("name", "Filter by name (word-based matching)"), Schema.string("column", "Filter by column name"), - Schema.string("tag", "Filter by tag (format: key or key=value)"), + Schema.string( + "tag", + "Filter by tag. Literal match by default: `key`, `key=value`." + + " Opt-in regex: `key=re:pattern` or `re:full-pattern`."), Schema.string("id", "Filter by UUID"), Schema.string("badge", "Filter by badge"), Schema.bool("favourites", "Only show favourites"), - ]) + ]), + annotations: Tool.Annotations( + readOnlyHint: true, idempotentHint: true, openWorldHint: false) ), Tool( name: "open", + title: "Open terminal", description: """ Open an existing terminal by name, UUID, or path. Returns terminal details including llmPrompt (persistent context) and llmNextAction (one-time task). - Use partial name matching for convenience. + Note: partial-name matching returns the first hit — prefer exact names or + UUIDs to avoid ambiguity. """, inputSchema: Schema.objectSchema([ Schema.string( - "identifier", "Terminal name, UUID, or path (partial match supported)", required: true) - ]) + "identifier", + "Terminal name, UUID, or path (partial match supported)", required: true) + ]), + // `open` focuses a terminal in the GUI when one is running — a visible side + // effect that is neither destructive nor strictly idempotent. + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: false, openWorldHint: false) ), Tool( name: "create", + title: "Create terminal", description: """ Create a new terminal in TermQ. Optionally specify name, description, column, path, tags, LLM context, and initialization command. @@ -82,14 +108,20 @@ extension TermQMCPServer { Schema.string("description", "Terminal description"), Schema.string("column", "Column name (e.g., 'In Progress')"), Schema.string("path", "Working directory path"), - Schema.stringArray("tags", "Tags in key=value format (e.g., ['project=myapp', 'type=dev'])"), + Schema.stringArray( + "tags", "Tags in key=value format (e.g., ['project=myapp', 'type=dev'])"), Schema.string("llmPrompt", "Persistent LLM context"), Schema.string("llmNextAction", "One-time action for next session"), Schema.string("initCommand", "Command to run when terminal opens"), - ]) + ]), + // Adds rows — repeated calls create more terminals, so not idempotent. + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: false, openWorldHint: false) ), Tool( name: "set", + title: "Update terminal", description: """ Update terminal properties. Identify terminal by name or UUID. Can set name, description, column, badges, LLM fields, tags, init command, and favourite status. @@ -102,45 +134,71 @@ extension TermQMCPServer { Schema.string("column", "Move to column"), Schema.string( "badge", - "Badge text (comma-separated for multiple, e.g. 'WIP,urgent'). Replaces existing badges."), + "Badge text (comma-separated for multiple, e.g. 'WIP,urgent')." + + " Replaces existing badges."), Schema.string("tag", "A single tag in key=value format (e.g., 'project=my/repo')"), - Schema.stringArray("tags", "Tags in key=value format (e.g., ['status=reviewed'])"), - Schema.bool("replaceTags", "If true, replaces all tags; if false (default), adds to existing"), + Schema.stringArray( + "tags", "Tags in key=value format (e.g., ['status=reviewed'])"), + Schema.bool( + "replaceTags", "If true, replaces all tags; if false (default), adds to existing"), Schema.string("llmPrompt", "Set persistent LLM context"), Schema.string("llmNextAction", "Set one-time action"), Schema.string("initCommand", "Command to run when terminal opens"), Schema.bool("favourite", "Set favourite status"), - ]) + ]), + // Mutating but idempotent — same args produce the same end state. + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: true, openWorldHint: false) ), Tool( name: "move", + title: "Move terminal to column", description: "Move a terminal to a different column (workflow stage).", inputSchema: Schema.objectSchema([ Schema.string("identifier", "Terminal name or UUID", required: true), Schema.string("column", "Target column name", required: true), - ]) + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: true, openWorldHint: false) ), Tool( name: "get", + title: "Get terminal by UUID", description: """ Get terminal context by ID. Use with TERMQ_TERMINAL_ID environment variable to get context for the terminal you're currently running in. Returns full terminal details including tags, llmPrompt, and llmNextAction. """, inputSchema: Schema.objectSchema([ - Schema.string("id", "Terminal UUID (use $TERMQ_TERMINAL_ID from your environment)", required: true) - ]) + Schema.string( + "id", "Terminal UUID (use $TERMQ_TERMINAL_ID from your environment)", + required: true) + ]), + // `get` records a `lastLLMGet` handshake timestamp as a side effect; not + // strictly read-only. Tier 1b will split this into a pure resource-read plus + // an explicit `record_handshake` tool — see audit §3.1. + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: false, openWorldHint: false) ), Tool( name: "delete", + title: "Delete terminal", description: """ - Delete a terminal. By default, moves to bin (soft delete). + Delete a terminal. By default, moves to bin (soft delete) — recoverable from the GUI. Use permanent=true to permanently delete without bin recovery option. """, inputSchema: Schema.objectSchema([ Schema.string("identifier", "Terminal name or UUID", required: true), Schema.bool("permanent", "Permanently delete (skip bin, cannot be recovered)"), - ]) + ]), + // Soft-delete is reversible; permanent=true is destructive. Mark destructive + // conservatively so permissioned clients prompt the user. + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: true, + idempotentHint: true, openWorldHint: false) ), ] } @@ -154,24 +212,28 @@ extension TermQMCPServer { Resource( name: "All Terminals", uri: "termq://terminals", + title: "All terminals on the board", description: "Complete list of all terminals in the board", mimeType: "application/json" ), Resource( name: "Board Columns", uri: "termq://columns", + title: "Board columns", description: "List of all columns in the Kanban board", mimeType: "application/json" ), Resource( name: "Pending Work", uri: "termq://pending", + title: "Terminals needing attention", description: "Terminals with pending actions and staleness indicators", mimeType: "application/json" ), Resource( name: "LLM Workflow Guide", uri: "termq://context", + title: "Workflow guide (markdown)", description: "Comprehensive documentation for cross-session workflows", mimeType: "text/markdown" ), @@ -186,6 +248,7 @@ extension TermQMCPServer { [ Prompt( name: "session_start", + title: "Start a TermQ session", description: """ Initialize an LLM session with TermQ. Returns pending work, board overview, and recommended first actions. @@ -194,16 +257,19 @@ extension TermQMCPServer { ), Prompt( name: "workflow_guide", + title: "Cross-session workflow guide", description: "Comprehensive guide for maintaining continuity across LLM sessions", arguments: [] ), Prompt( name: "terminal_summary", + title: "Summarise a terminal", description: "Get context and status for a specific terminal", arguments: [ Prompt.Argument( name: "terminal", - description: "Terminal name or UUID", + title: "Terminal name or UUID", + description: "Terminal name or UUID — argument completion suggests existing terminal names", required: true ) ] diff --git a/Sources/MCPServerLib/Server.swift b/Sources/MCPServerLib/Server.swift index 7122a5ef..96bbccf6 100644 --- a/Sources/MCPServerLib/Server.swift +++ b/Sources/MCPServerLib/Server.swift @@ -2,24 +2,9 @@ import Foundation import MCP import TermQShared -// MARK: - SetLoggingLevel Method (MCP spec: logging/setLevel) - -/// MCP method for setting the server's log level -/// Required when server declares logging capability -public enum SetLoggingLevel: MCP.Method { - public static let name = "logging/setLevel" - - public struct Parameters: Hashable, Codable, Sendable { - /// The log level to set - public let level: String - - public init(level: String) { - self.level = level - } - } - - public typealias Result = Empty -} +// `SetLoggingLevel` is provided by the MCP Swift SDK as of the 2025-11-25 spec; +// no local re-declaration is needed. The SDK's version uses the proper `LogLevel` +// enum rather than a free-form String. /// TermQ MCP Server implementation /// @@ -38,6 +23,29 @@ public final class TermQMCPServer: @unchecked Sendable { /// Server version public static let serverVersion = "1.0.0" + // MARK: - Log Level State + // + // `logging/setLevel` is a client request to filter `notifications/message` + // notifications by minimum severity. The MCP spec lets clients dial verbosity up + // and down at runtime. We store the configured threshold and gate emissions on it. + + /// `NSLock`-guarded minimum log level — defaults to `.info` (matches most clients' + /// expectations). Mutable: clients raise/lower it via `logging/setLevel`. + private let logLevelLock = NSLock() + private var _minLogLevel: LogLevel = .info + + var minLogLevel: LogLevel { + logLevelLock.lock() + defer { logLevelLock.unlock() } + return _minLogLevel + } + + func setMinLogLevel(_ level: LogLevel) { + logLevelLock.lock() + _minLogLevel = level + logLevelLock.unlock() + } + /// Initialize the MCP server /// - Parameter dataDirectory: Optional custom data directory (nil uses default) public init(dataDirectory: URL? = nil) { @@ -46,6 +54,7 @@ public final class TermQMCPServer: @unchecked Sendable { name: Self.serverName, version: Self.serverVersion, capabilities: Server.Capabilities( + completions: .init(), logging: .init(), prompts: .init(listChanged: true), resources: .init(subscribe: true, listChanged: true), @@ -113,18 +122,72 @@ public final class TermQMCPServer: @unchecked Sendable { return try await self.dispatchPromptGet(params) } - // Register logging handler (required when declaring logging capability) - _ = await server.withMethodHandler(SetLoggingLevel.self) { _ in - // Accept the log level - we don't need to do anything special - // as the MCP SDK handles basic logging + // Register logging handler — apply the client's requested minimum severity + // threshold so subsequent `notifications/message` emissions are filtered. + _ = await server.withMethodHandler(SetLoggingLevel.self) { [weak self] params in + self?.setMinLogLevel(params.level) return Empty() } + + // Register completion handler (required when declaring completions capability). + // Surfaces autocomplete suggestions for prompt arguments — currently the `terminal` + // argument of `terminal_summary`. + _ = await server.withMethodHandler(Complete.self) { [weak self] params in + guard let self = self else { + throw MCPError.internalError("Server deallocated") + } + return try await self.dispatchCompletion(params) + } } // MARK: - Helpers - /// Load the board from the data directory + /// Load the board from the data directory. + /// + /// On failure, mirrors the error as a `notifications/message` (error level) so a + /// remote operator sees the failure even without local `--verbose` stderr. Then + /// re-throws — surfacing the failure to the calling tool is still mandatory. func loadBoard() throws -> Board { - try BoardLoader.loadBoard(dataDirectory: dataDirectory) + do { + return try BoardLoader.loadBoard(dataDirectory: dataDirectory) + } catch { + // Best-effort fire-and-forget mirror; never let logging affect the error path. + Task { [weak self] in + await self?.emitLog( + .error, + "Board load failed: \(error.localizedDescription)", + logger: "termq.board" + ) + } + throw error + } + } + + // MARK: - Logging Mirror + + /// Severity ordering used by `notifications/message` filtering. Higher index = more + /// severe. Matches the MCP spec ordering (debug → emergency). + private static let severityOrder: [LogLevel] = [ + .debug, .info, .notice, .warning, .error, .critical, .alert, .emergency, + ] + + /// Emit a `notifications/message` to the client — best-effort, gated by the + /// client-configured minimum log level. Silent failure is intentional: a transport + /// hiccup must never break the calling tool. The internal `os.Logger` (TermQLogger) + /// stays the source of truth for local debugging; this just mirrors selected events + /// over the wire so a remote operator can see them without `--verbose` stderr. + func emitLog(_ level: LogLevel, _ message: String, logger: String = "termq") async { + guard let minIdx = Self.severityOrder.firstIndex(of: minLogLevel), + let curIdx = Self.severityOrder.firstIndex(of: level), + curIdx >= minIdx + else { + return + } + let params = LogMessageNotification.Parameters( + level: level, + logger: logger, + data: .string(message) + ) + try? await server.notify(LogMessageNotification.message(params)) } } From 88e017dfb5f7eba5c64eeb164fd038a78d9e0d37 Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 09:06:59 +0100 Subject: [PATCH 03/13] =?UTF-8?q?feat(mcp):=20tier=201b=20structural=20?= =?UTF-8?q?=E2=80=94=20templates,=20structured=20output,=20subscriptions,?= =?UTF-8?q?=20record=5Fhandshake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1b of the MCP/CLI extend-and-fix plan. Structural upgrades that turn TermQ from "uses 3 of the 13 MCP primitives" into a genuinely comprehensive MCP implementation. Resource templates — added termq://terminal/{id}, termq://terminal-by-name/{name}, and termq://column/{name}. Per-card reads now go through the idiomatic resources/read path rather than the awkward `get` tool. Static URIs take precedence; template URIs are matched by prefix and percent-decoded. Structured tool output — every read tool (`pending`, `list`, `find`, `open`, `get`) now declares an outputSchema and returns structuredContent in addition to legacy text. Existing OutputTypes are already Codable so the conversion is done by the SDK's throwing `CallTool.Result(content:, structuredContent: Output)` initializer. Payload roughly doubles for one release while the text mirror is kept for back-compat; the audit §3.3 payload note documents the trade-off. Resource subscriptions — new ResourceSubscriptionManager actor wraps a DispatchSourceFileSystemObject watcher on board.json with a 150ms debounce window. Atomic writes (which BoardWriter does via NSFileCoordinator) replace the inode entirely, so the manager re-arms after .rename / .delete events with a 50ms backoff. Self-write detection is deliberately deferred per the audit's "acceptable starting point" note — subscribers will see notifications for their own writes; future revision can thread a skip-notification token through BoardWriter. The previously-declared-but-unimplemented `subscribe: true` capability is now honoured. record_handshake — split the `get` tool's read+side-effect into two. Reading termq://terminal/{id} is now pure; `record_handshake(id:)` is the explicit write that sets lastLLMGet. `get` retains the combined behaviour for one release per the audit's semantic-break deprecation policy. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 7 + Sources/MCPServerLib/ResourceHandlers.swift | 74 ++++++++- Sources/MCPServerLib/SchemaBuilder.swift | 99 +++++++++++ Sources/MCPServerLib/SchemaDefinitions.swift | 80 ++++++++- Sources/MCPServerLib/Server.swift | 44 +++++ .../MCPServerLib/SubscriptionManager.swift | 155 ++++++++++++++++++ Sources/MCPServerLib/ToolHandlers.swift | 102 ++++++++---- .../MCPIntegrationTests.swift | 2 +- Tests/MCPServerLibTests/ServerTests.swift | 2 +- 9 files changed, 518 insertions(+), 47 deletions(-) create mode 100644 Sources/MCPServerLib/SubscriptionManager.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 07eba9c8..96fb531a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`notifications/message` log mirror** — `termqmcp` now emits MCP log notifications (gated by client-configured minimum level via `logging/setLevel`) for board-load failures and other operationally relevant events. Lets a remote operator observe failures without needing `--verbose` stderr access. - **`logging/setLevel` honoured properly** — previously the request was accepted and ignored; now the configured threshold actually filters subsequent log emissions. +### Added — MCP structural (Tier 1b) + +- **Resource templates** (`resources/templates/list`) — `termq://terminal/{id}`, `termq://terminal-by-name/{name}`, `termq://column/{name}`. Idiomatic per-card reads via standard `resources/read` — no per-card tool needed. +- **Structured tool output** (`outputSchema` + `structuredContent`) on all read tools (`pending`, `list`, `find`, `open`, `get`). Clients can codegen types or runtime-validate against the published schema rather than re-parsing `text` content. Legacy `text` mirror is retained for one release; payload roughly doubles in the meantime (~80 KB instead of ~40 KB on a 200-card board — acceptable for stdio). +- **Resource subscriptions** (`resources/subscribe` / `resources/unsubscribe` + `notifications/resources/updated`) — long-running clients can subscribe to `termq://terminals`, `termq://pending`, or any other resource URI and be notified when board.json changes (e.g. the user moves a card in the GUI). Backed by a `DispatchSourceFileSystemObject` watcher with a 150ms debounce window so atomic writes don't fan out to multiple notifications. The `subscribe: true` capability TermQ has declared since 0.x is now actually honoured. +- **`record_handshake` tool** — explicit, side-effect-only marker that an LLM session has consumed a terminal's context. Idiomatic pair with reading `termq://terminal/{id}` (pure). `get` retains the combined read+handshake behaviour for one release as a deprecated alias per the audit's semantic-break policy. + ### Added - **Focus and profile editing** — editable harnesses gain full inline editing for focuses and profiles diff --git a/Sources/MCPServerLib/ResourceHandlers.swift b/Sources/MCPServerLib/ResourceHandlers.swift index 4894bc96..f616d27b 100644 --- a/Sources/MCPServerLib/ResourceHandlers.swift +++ b/Sources/MCPServerLib/ResourceHandlers.swift @@ -5,26 +5,88 @@ import TermQShared // MARK: - Resource Handler Implementations extension TermQMCPServer { - /// Handle resource read requests + /// Handle resource read requests. Supports both static URIs and the templated forms + /// declared in `availableResourceTemplates` (`termq://terminal/{id}`, + /// `termq://terminal-by-name/{name}`, `termq://column/{name}`). + /// + /// Templates are matched after static URIs: a static URI takes precedence if both + /// match (none currently overlap, but the order makes the precedence explicit). func dispatchResourceRead(_ params: ReadResource.Parameters) async throws -> ReadResource.Result { let uri = params.uri switch uri { case "termq://terminals": return try await handleTerminalsResource(uri: uri) - case "termq://columns": return try await handleColumnsResource(uri: uri) - case "termq://pending": return try await handlePendingResource(uri: uri) - case "termq://context": return ReadResource.Result(contents: [.text(Self.contextDocumentation, uri: uri)]) - default: - throw MCPError.invalidRequest("Unknown resource: \(uri)") + return try await dispatchTemplatedResource(uri: uri) + } + } + + /// Match a URI against the declared resource templates and dispatch to the right reader. + /// Pure reads — no mutation, no handshake side-effects (use the `record_handshake` + /// tool for that). + private func dispatchTemplatedResource(uri: String) async throws -> ReadResource.Result { + if let id = parseTemplatePath(uri: uri, prefix: "termq://terminal/") { + return try await handleTerminalByIdResource(uri: uri, id: id) + } + if let name = parseTemplatePath(uri: uri, prefix: "termq://terminal-by-name/") { + return try await handleTerminalByNameResource(uri: uri, name: name) + } + if let name = parseTemplatePath(uri: uri, prefix: "termq://column/") { + return try await handleColumnByNameResource(uri: uri, name: name) + } + throw MCPError.invalidRequest("Unknown resource: \(uri)") + } + + /// Extract the path segment after `prefix`. Returns nil if the URI doesn't match the + /// prefix or has nothing after it. Decodes percent-escapes so callers can pass spaces + /// in column / terminal names. + private func parseTemplatePath(uri: String, prefix: String) -> String? { + guard uri.hasPrefix(prefix) else { return nil } + let raw = String(uri.dropFirst(prefix.count)) + guard !raw.isEmpty else { return nil } + return raw.removingPercentEncoding ?? raw + } + + private func handleTerminalByIdResource(uri: String, id: String) async throws -> ReadResource.Result { + let board = try loadBoard() + guard let uuid = UUID(uuidString: id), + let card = board.activeCards.first(where: { $0.id == uuid }) + else { + throw MCPError.invalidRequest("Terminal not found for UUID: \(id)") } + let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) + let json = try JSONHelper.encode(output) + return ReadResource.Result(contents: [.text(json, uri: uri)]) + } + + private func handleTerminalByNameResource(uri: String, name: String) async throws -> ReadResource.Result { + let board = try loadBoard() + let nameLower = name.lowercased() + guard let card = board.activeCards.first(where: { $0.title.lowercased() == nameLower }) else { + throw MCPError.invalidRequest("Terminal not found for name: \(name)") + } + let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) + let json = try JSONHelper.encode(output) + return ReadResource.Result(contents: [.text(json, uri: uri)]) + } + + private func handleColumnByNameResource(uri: String, name: String) async throws -> ReadResource.Result { + let board = try loadBoard() + let nameLower = name.lowercased() + guard let column = board.columns.first(where: { $0.name.lowercased() == nameLower }) else { + throw MCPError.invalidRequest("Column not found: \(name)") + } + let cards = board.activeCards.filter { $0.columnId == column.id } + let output = cards.map { TerminalOutput(from: $0, columnName: column.name) } + let json = try JSONHelper.encode(output) + return ReadResource.Result(contents: [.text(json, uri: uri)]) } // MARK: - Resource Implementations diff --git a/Sources/MCPServerLib/SchemaBuilder.swift b/Sources/MCPServerLib/SchemaBuilder.swift index 7e0a6e19..1dbcfdcc 100644 --- a/Sources/MCPServerLib/SchemaBuilder.swift +++ b/Sources/MCPServerLib/SchemaBuilder.swift @@ -86,4 +86,103 @@ enum SchemaBuilder { static func stringArray(_ name: String, _ description: String, required: Bool = false) -> Property { Property(name, .array, description: description, required: required) } + + // MARK: - Output Schemas (Tier 1b — structured tool output) + // + // These describe the shape of `structuredContent` returned by read-shaped tools. + // Per the MCP spec, the structuredContent must validate against the tool's + // outputSchema — clients can codegen types or runtime-validate against these. + + /// Schema for a single TerminalOutput row. + static var terminalOutputItemSchema: Value { + .object([ + "type": .string("object"), + "properties": .object([ + "id": stringField("Terminal UUID"), + "name": stringField("Display name"), + "description": stringField("Free-form description"), + "column": stringField("Column display name"), + "columnId": stringField("Column UUID"), + "tags": .object(["type": .string("object")]), + "path": stringField("Working directory"), + "badges": .object([ + "type": .string("array"), + "items": .object(["type": .string("string")]), + ]), + "isFavourite": boolField("Pinned to favourites bar"), + "llmPrompt": stringField("Persistent LLM context"), + "llmNextAction": stringField("Queued one-time action"), + "allowAutorun": boolField("Whether queued actions auto-execute"), + ]), + ]) + } + + /// Schema for an array of TerminalOutput rows (used by `list`, `find`). + static var terminalListSchema: Value { + .object([ + "type": .string("array"), + "items": terminalOutputItemSchema, + ]) + } + + /// Schema for ColumnOutput. + static var columnOutputItemSchema: Value { + .object([ + "type": .string("object"), + "properties": .object([ + "id": stringField("Column UUID"), + "name": stringField("Column name"), + "description": stringField("Free-form description"), + "color": stringField("Hex colour"), + "terminalCount": intField("Number of active cards in this column"), + ]), + ]) + } + + /// Schema for a PendingOutput envelope. + static var pendingOutputSchema: Value { + .object([ + "type": .string("object"), + "properties": .object([ + "terminals": .object([ + "type": .string("array"), + "items": .object([ + "type": .string("object"), + "properties": .object([ + "id": stringField("Terminal UUID"), + "name": stringField("Display name"), + "column": stringField("Column display name"), + "path": stringField("Working directory"), + "llmNextAction": stringField("Queued one-time action"), + "llmPrompt": stringField("Persistent LLM context"), + "allowAutorun": boolField("Whether queued actions auto-execute"), + "staleness": stringField("Staleness tag value"), + "tags": .object(["type": .string("object")]), + ]), + ]), + ]), + "summary": .object([ + "type": .string("object"), + "properties": .object([ + "total": intField("Total cards considered"), + "withNextAction": intField("Cards with llmNextAction set"), + "stale": intField("Cards tagged stale or old"), + "fresh": intField("Cards tagged fresh"), + ]), + ]), + ]), + ]) + } + + private static func stringField(_ description: String) -> Value { + .object(["type": .string("string"), "description": .string(description)]) + } + + private static func boolField(_ description: String) -> Value { + .object(["type": .string("boolean"), "description": .string(description)]) + } + + private static func intField(_ description: String) -> Value { + .object(["type": .string("integer"), "description": .string(description)]) + } } diff --git a/Sources/MCPServerLib/SchemaDefinitions.swift b/Sources/MCPServerLib/SchemaDefinitions.swift index 6fb259d5..192347b3 100644 --- a/Sources/MCPServerLib/SchemaDefinitions.swift +++ b/Sources/MCPServerLib/SchemaDefinitions.swift @@ -21,7 +21,8 @@ extension TermQMCPServer { Schema.bool("actionsOnly", "Only show terminals with llmNextAction set") ]), annotations: Tool.Annotations( - readOnlyHint: true, idempotentHint: true, openWorldHint: false) + readOnlyHint: true, idempotentHint: true, openWorldHint: false), + outputSchema: Schema.pendingOutputSchema ), Tool( name: "context", @@ -44,7 +45,11 @@ extension TermQMCPServer { Schema.bool("columnsOnly", "Return only column names"), ]), annotations: Tool.Annotations( - readOnlyHint: true, idempotentHint: true, openWorldHint: false) + readOnlyHint: true, idempotentHint: true, openWorldHint: false), + // outputSchema describes the cards-listing shape; when `columnsOnly` is true, + // the result is an array of strings instead — clients must check the shape + // at runtime. Documented in the tool description; no formal union schema. + outputSchema: Schema.terminalListSchema ), Tool( name: "find", @@ -73,7 +78,8 @@ extension TermQMCPServer { Schema.bool("favourites", "Only show favourites"), ]), annotations: Tool.Annotations( - readOnlyHint: true, idempotentHint: true, openWorldHint: false) + readOnlyHint: true, idempotentHint: true, openWorldHint: false), + outputSchema: Schema.terminalListSchema ), Tool( name: "open", @@ -93,7 +99,8 @@ extension TermQMCPServer { // effect that is neither destructive nor strictly idempotent. annotations: Tool.Annotations( readOnlyHint: false, destructiveHint: false, - idempotentHint: false, openWorldHint: false) + idempotentHint: false, openWorldHint: false), + outputSchema: Schema.terminalOutputItemSchema ), Tool( name: "create", @@ -177,11 +184,35 @@ extension TermQMCPServer { required: true) ]), // `get` records a `lastLLMGet` handshake timestamp as a side effect; not - // strictly read-only. Tier 1b will split this into a pure resource-read plus - // an explicit `record_handshake` tool — see audit §3.1. + // strictly read-only. DEPRECATED in favour of reading `termq://terminal/{id}` + // (pure) plus the `record_handshake` tool (explicit write). One-release + // alias per the deprecation policy in audit §3.1. annotations: Tool.Annotations( readOnlyHint: false, destructiveHint: false, - idempotentHint: false, openWorldHint: false) + idempotentHint: false, openWorldHint: false), + outputSchema: Schema.terminalOutputItemSchema + ), + Tool( + name: "record_handshake", + title: "Record LLM handshake", + description: """ + Mark a terminal as touched by the current LLM session. Sets the card's + `lastLLMGet` timestamp. Idiomatic pair: read the card via + `termq://terminal/{id}` (pure, no side effects) then call this when you + have actually consumed the context. + + Pre-Tier-1b, this side effect lived on the `get` tool — see audit §3.1. + `get` remains as a deprecated alias and still records the handshake; new + callers should split the read (resource) from the write (this tool). + """, + inputSchema: Schema.objectSchema([ + Schema.string( + "id", "Terminal UUID (use $TERMQ_TERMINAL_ID from your environment)", + required: true) + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: true, openWorldHint: false) ), Tool( name: "delete", @@ -241,6 +272,41 @@ extension TermQMCPServer { } } +// MARK: - Resource Templates + +extension TermQMCPServer { + /// Parameterised resource URIs the client can fill in. Clients call + /// `resources/templates/list` to discover these; once filled, the resulting URI is + /// read via standard `resources/read`. + static var availableResourceTemplates: [Resource.Template] { + [ + Resource.Template( + uriTemplate: "termq://terminal/{id}", + name: "Terminal by UUID", + title: "Terminal (by UUID)", + description: + "One terminal card resolved by UUID. Use $TERMQ_TERMINAL_ID inside a TermQ session.", + mimeType: "application/json" + ), + Resource.Template( + uriTemplate: "termq://terminal-by-name/{name}", + name: "Terminal by name", + title: "Terminal (by name)", + description: + "One terminal card resolved by exact name. Prefer UUID form for stability.", + mimeType: "application/json" + ), + Resource.Template( + uriTemplate: "termq://column/{name}", + name: "Column by name", + title: "Cards in column", + description: "All active cards in the named column.", + mimeType: "application/json" + ), + ] + } +} + // MARK: - Prompt Definitions extension TermQMCPServer { diff --git a/Sources/MCPServerLib/Server.swift b/Sources/MCPServerLib/Server.swift index 96bbccf6..048ae32f 100644 --- a/Sources/MCPServerLib/Server.swift +++ b/Sources/MCPServerLib/Server.swift @@ -17,6 +17,10 @@ public final class TermQMCPServer: @unchecked Sendable { private let server: Server let dataDirectory: URL? + /// Lazily-created subscription manager. Watches board.json and fires + /// `notifications/resources/updated` for subscribed URIs when the file changes. + private var subscriptionManager: ResourceSubscriptionManager? + /// Server name identifier public static let serverName = "termq" @@ -70,10 +74,32 @@ public final class TermQMCPServer: @unchecked Sendable { public func run(transport: any Transport) async throws { // Register handlers before starting await registerHandlers() + await startSubscriptionWatcher() try await server.start(transport: transport) await server.waitUntilCompleted() } + /// Initialise the subscription manager and arm the file watcher pointed at the + /// resolved board.json. Called from `run(transport:)` once at startup. No-op when + /// subscriptions aren't useful (e.g. tests that pass in a manual `dataDirectory` + /// pointing nowhere) — the watcher silently retries until the file appears. + private func startSubscriptionWatcher() async { + let dataDir = dataDirectory ?? BoardLoader.getDataDirectoryPath() + let boardURL = dataDir.appendingPathComponent("board.json") + let manager = ResourceSubscriptionManager { [weak self] uri in + await self?.emitResourceUpdated(uri: uri) + } + self.subscriptionManager = manager + await manager.startWatching(boardURL: boardURL) + } + + /// Emit `notifications/resources/updated` for a single URI. Best-effort — + /// transport hiccups don't propagate (subscribers will catch up on next change). + private func emitResourceUpdated(uri: String) async { + let params = ResourceUpdatedNotification.Parameters(uri: uri) + try? await server.notify(ResourceUpdatedNotification.message(params)) + } + // MARK: - Handler Registration private func registerHandlers() async { @@ -107,6 +133,13 @@ public final class TermQMCPServer: @unchecked Sendable { return try await self.dispatchResourceRead(params) } + _ = await server.withMethodHandler(ListResourceTemplates.self) { [weak self] _ in + guard self != nil else { + return ListResourceTemplates.Result(templates: []) + } + return ListResourceTemplates.Result(templates: Self.availableResourceTemplates) + } + // Register prompt handlers _ = await server.withMethodHandler(ListPrompts.self) { [weak self] _ in guard self != nil else { @@ -138,6 +171,17 @@ public final class TermQMCPServer: @unchecked Sendable { } return try await self.dispatchCompletion(params) } + + // Register subscription handlers. The actual emission lives in + // ResourceSubscriptionManager; these just track which URIs are live. + _ = await server.withMethodHandler(ResourceSubscribe.self) { [weak self] params in + await self?.subscriptionManager?.subscribe(uri: params.uri) + return Empty() + } + _ = await server.withMethodHandler(ResourceUnsubscribe.self) { [weak self] params in + await self?.subscriptionManager?.unsubscribe(uri: params.uri) + return Empty() + } } // MARK: - Helpers diff --git a/Sources/MCPServerLib/SubscriptionManager.swift b/Sources/MCPServerLib/SubscriptionManager.swift new file mode 100644 index 00000000..3ae33921 --- /dev/null +++ b/Sources/MCPServerLib/SubscriptionManager.swift @@ -0,0 +1,155 @@ +import Dispatch +import Foundation +import MCP +import TermQShared + +// MARK: - Subscription Manager +// +// Tracks `resources/subscribe` registrations and emits `notifications/resources/updated` +// when the board.json file underneath them changes. +// +// Design notes: +// - One `DispatchSourceFileSystemObject` watches the board.json inode for `.write`, +// `.extend`, `.delete`, `.rename`. macOS coalesces these aggressively; the debouncer +// below adds a second layer because atomic writes (which `BoardWriter` does) replace +// the inode entirely, briefly firing `.delete` followed by a new file appearing. +// - Notifications are coalesced via a 150ms debounce window. Two writes inside the +// window produce one notification per subscribed URI, not two. +// - Re-arming after `.rename` / `.delete` is handled by re-opening the file descriptor +// on a slight delay (the file briefly doesn't exist between unlink and link). +// - Self-write detection is deliberately NOT implemented in this initial version. The +// audit (§3.2, Tier 1b) flagged it as a refinement; the practical impact is that an +// MCP client that just wrote a card will also see a `resources/updated` for that +// write. Acceptable as a starting point — subscribers can no-op on stale ETags / their +// own causal write tracking. A future revision can add a per-write "skip notification" +// token threaded through `BoardWriter`. + +/// Actor-isolated subscription tracker and file-watcher. Holds the live +/// `DispatchSourceFileSystemObject` and emits `notifications/resources/updated` for +/// any matching subscriber when board.json changes. +actor ResourceSubscriptionManager { + /// URIs that any client has subscribed to. + private var subscribedURIs: Set = [] + + /// Active file-system watcher. + private var source: DispatchSourceFileSystemObject? + private var watchedURL: URL? + private var fileDescriptor: Int32 = -1 + + /// Pending debounce timer. + private var debounceTask: Task? + private static let debounceWindow: Duration = .milliseconds(150) + + /// Callback to actually deliver the notification. Held weakly so the server owns + /// the lifecycle; if the server is deallocated the deliver closure becomes a no-op. + private let deliver: @Sendable (String) async -> Void + + init(deliver: @escaping @Sendable (String) async -> Void) { + self.deliver = deliver + } + + deinit { + source?.cancel() + if fileDescriptor >= 0 { + close(fileDescriptor) + } + } + + // MARK: - Subscription state + + func subscribe(uri: String) { + subscribedURIs.insert(uri) + } + + func unsubscribe(uri: String) { + subscribedURIs.remove(uri) + } + + func subscriberCount() -> Int { subscribedURIs.count } + + // MARK: - File watching + + /// Begin watching `boardURL`. Idempotent — calling again with the same URL is a no-op; + /// with a different URL it tears down the previous watch and arms a new one. + func startWatching(boardURL: URL) { + if watchedURL == boardURL && source != nil { + return + } + stopWatching() + watchedURL = boardURL + arm(boardURL: boardURL) + } + + private func arm(boardURL: URL) { + // The file may not yet exist at first-launch — schedule a retry rather than + // failing silently. A subscriber arriving before the file is created is the + // expected path for `termqmcp` startup ordering against the GUI. + let fd = open(boardURL.path, O_EVTONLY) + guard fd >= 0 else { + Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + await self?.armIfNeeded(boardURL: boardURL) + } + return + } + fileDescriptor = fd + let src = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .delete, .rename], + queue: DispatchQueue.global(qos: .utility) + ) + src.setEventHandler { [weak self] in + Task { [weak self] in + await self?.handleChangeEvent() + } + } + src.setCancelHandler { [fd] in + close(fd) + } + source = src + src.resume() + } + + private func armIfNeeded(boardURL: URL) { + guard source == nil else { return } + arm(boardURL: boardURL) + } + + func stopWatching() { + source?.cancel() + source = nil + if fileDescriptor >= 0 { + // Cancel handler closes the fd; nil out our copy. + fileDescriptor = -1 + } + } + + private func handleChangeEvent() async { + // If the file was renamed/deleted by an atomic-write, re-arm after a brief delay. + if let url = watchedURL, + let src = source, + src.data.contains(.delete) || src.data.contains(.rename) + { + stopWatching() + Task { [weak self] in + try? await Task.sleep(for: .milliseconds(50)) + await self?.armIfNeeded(boardURL: url) + } + } + + // Debounce the emit. Cancel any pending task and schedule a fresh one. + debounceTask?.cancel() + debounceTask = Task { [weak self] in + try? await Task.sleep(for: Self.debounceWindow) + if Task.isCancelled { return } + await self?.fireEmissions() + } + } + + private func fireEmissions() async { + let uris = subscribedURIs + for uri in uris { + await deliver(uri) + } + } +} diff --git a/Sources/MCPServerLib/ToolHandlers.swift b/Sources/MCPServerLib/ToolHandlers.swift index bd6cfd8a..d0889fcc 100644 --- a/Sources/MCPServerLib/ToolHandlers.swift +++ b/Sources/MCPServerLib/ToolHandlers.swift @@ -5,6 +5,21 @@ import TermQShared // MARK: - Tool Handler Implementations extension TermQMCPServer { + /// Build a `CallTool.Result` that satisfies the tool's `outputSchema` by populating + /// both legacy `text` content (for back-compat with clients that don't read + /// `structuredContent`) and the new `structuredContent` field. The dual encoding + /// roughly doubles the response payload — acceptable on stdio for a single-user app + /// (see audit §3.3 payload note), and the `text` mirror can be dropped after one + /// release once clients have migrated. + func structuredResult(_ output: T) throws -> CallTool.Result { + let json = try JSONHelper.encode(output) + // Throwing init handles the Codable -> Value conversion internally. + return try CallTool.Result( + content: [.text(text: json, annotations: nil, _meta: nil)], + structuredContent: output + ) + } + /// Dispatch tool calls to appropriate handlers func dispatchToolCall(_ params: CallTool.Parameters) async throws -> CallTool.Result { switch params.name { @@ -26,6 +41,8 @@ extension TermQMCPServer { return try await handleMove(params.arguments) case "get": return try await handleGet(params.arguments) + case "record_handshake": + return try await handleRecordHandshake(params.arguments) case "delete": return try await handleDelete(params.arguments) default: @@ -109,8 +126,7 @@ extension TermQMCPServer { ) ) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch { return CallTool.Result( @@ -139,8 +155,7 @@ extension TermQMCPServer { terminalCount: board.activeCards.filter { $0.columnId == column.id }.count ) } - let json = try JSONHelper.encode(columns) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(columns) } // Get cards, optionally filtered by column @@ -151,8 +166,7 @@ extension TermQMCPServer { cards = CardFilterEngine.sortByColumnThenOrder(cards, columns: board.columns) let output = cards.map { TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) } - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch { return CallTool.Result( @@ -188,8 +202,7 @@ extension TermQMCPServer { if let query = query, !query.isEmpty { let queryWords = CardFilterEngine.normalizeToWords(query) guard !queryWords.isEmpty else { - let json = try JSONHelper.encode([TerminalOutput]()) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult([TerminalOutput]()) } cards = cards.filter { card in @@ -223,8 +236,7 @@ extension TermQMCPServer { } let output = cards.map { TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) } - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch { return CallTool.Result( @@ -254,12 +266,11 @@ extension TermQMCPServer { } let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) - let json = try JSONHelper.encode(output) // Note: MCP server is read-only, it cannot open terminals in the GUI // The CLI uses URL schemes to communicate with the app // For MCP, we just return the terminal data - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch { return CallTool.Result( @@ -268,6 +279,43 @@ extension TermQMCPServer { } } + /// Record an LLM handshake — write the `lastLLMGet` timestamp without returning the + /// card payload. Idiomatic pair with reading `termq://terminal/{id}` as a pure + /// resource. The `get` tool keeps doing both for one release as the deprecation + /// alias for callers who haven't migrated yet (see audit §3.1 deprecation policy). + func handleRecordHandshake(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let id: String + do { + let uuid = try InputValidator.requireUUID("id", from: arguments, tool: "record_handshake") + id = uuid.uuidString + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + + do { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let nowString = formatter.string(from: Date()) + _ = try BoardWriter.updateCard( + identifier: id, + updates: ["lastLLMGet": nowString], + dataDirectory: dataDirectory + ) + return CallTool.Result( + content: [ + .text( + text: "{\"ok\": true, \"id\": \"\(id)\", \"lastLLMGet\": \"\(nowString)\"}", + annotations: nil, _meta: nil) + ]) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + func handleGet(_ arguments: [String: Value]?) async throws -> CallTool.Result { let id: String do { @@ -302,8 +350,7 @@ extension TermQMCPServer { } let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch { return CallTool.Result( @@ -406,8 +453,7 @@ extension TermQMCPServer { let board = try loadBoard() if let card = board.findTerminal(identifier: cardId.uuidString) { let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } } @@ -416,8 +462,7 @@ extension TermQMCPServer { id: cardId.uuidString, message: "Terminal creation requested. The terminal may take a moment to appear in TermQ." ) - let json = try JSONHelper.encode(pendingOutput) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(pendingOutput) } catch { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -434,8 +479,7 @@ extension TermQMCPServer { from: card, columnName: board.columnName(for: card.columnId) ) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch let error as BoardWriter.WriteError { return CallTool.Result( @@ -556,8 +600,7 @@ extension TermQMCPServer { if let updatedCard = updatedBoard.findTerminal(identifier: card.id.uuidString) { let output = TerminalOutput( from: updatedCard, columnName: updatedBoard.columnName(for: updatedCard.columnId)) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } else { return CallTool.Result( content: [.text(text: "Error: Terminal not found after update", annotations: nil, _meta: nil)], @@ -604,8 +647,7 @@ extension TermQMCPServer { from: card, columnName: board.columnName(for: card.columnId) ) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch let error as BoardWriter.WriteError { return CallTool.Result( @@ -681,8 +723,7 @@ extension TermQMCPServer { if let updatedCard = updatedBoard.findTerminal(identifier: card.id.uuidString) { let output = TerminalOutput( from: updatedCard, columnName: updatedBoard.columnName(for: updatedCard.columnId)) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } else { return CallTool.Result( content: [.text(text: "Error: Terminal not found after move", annotations: nil, _meta: nil)], @@ -708,8 +749,7 @@ extension TermQMCPServer { from: card, columnName: board.columnName(for: card.columnId) ) - let json = try JSONHelper.encode(output) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(output) } catch let error as BoardWriter.WriteError { return CallTool.Result( @@ -781,8 +821,7 @@ extension TermQMCPServer { id: card.id.uuidString, permanent: permanent ) - let json = try JSONHelper.encode(result) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(result) } catch { return CallTool.Result( content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], @@ -811,8 +850,7 @@ extension TermQMCPServer { id: card.id.uuidString, permanent: permanent ) - let json = try JSONHelper.encode(result) - return CallTool.Result(content: [.text(text: json, annotations: nil, _meta: nil)]) + return try structuredResult(result) } catch let error as BoardWriter.WriteError { return CallTool.Result( diff --git a/Tests/MCPServerLibTests/MCPIntegrationTests.swift b/Tests/MCPServerLibTests/MCPIntegrationTests.swift index d4a81e32..95b1f144 100644 --- a/Tests/MCPServerLibTests/MCPIntegrationTests.swift +++ b/Tests/MCPServerLibTests/MCPIntegrationTests.swift @@ -522,7 +522,7 @@ final class MCPIntegrationTests: XCTestCase { func testAvailableToolsSchema() { let tools = TermQMCPServer.availableTools - XCTAssertEqual(tools.count, 10) + XCTAssertEqual(tools.count, 11) let toolNames = Set(tools.map { $0.name }) XCTAssertTrue(toolNames.contains("pending")) diff --git a/Tests/MCPServerLibTests/ServerTests.swift b/Tests/MCPServerLibTests/ServerTests.swift index 95fe1eef..fd7bd7ee 100644 --- a/Tests/MCPServerLibTests/ServerTests.swift +++ b/Tests/MCPServerLibTests/ServerTests.swift @@ -249,7 +249,7 @@ final class ServerTests: XCTestCase { func testAvailableToolsCount() { let tools = TermQMCPServer.availableTools - XCTAssertEqual(tools.count, 10) + XCTAssertEqual(tools.count, 11) } func testAvailableToolsNames() { From 8e64343183afb12dae0088920defc4edb950195e Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 09:14:48 +0100 Subject: [PATCH 04/13] =?UTF-8?q?feat(mcp):=20tier=202=20=E2=80=94=20colum?= =?UTF-8?q?n=20CRUD,=20whoami,=20restore,=20pagination,=20includeDeleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 2 of the MCP/CLI extend-and-fix plan. Plugs the symmetry gaps in the existing card domain — features TermQ already had in the GUI but that MCP couldn't drive. whoami — resolves the current card from $TERMQ_TERMINAL_ID without making the caller substitute the env var manually. Returns null (not error) when running outside a TermQ terminal so top-level Claude sessions don't see a spurious failure. restore + BoardWriter.restoreCard — undo soft-delete by clearing the `deletedAt` timestamp. Closes the asymmetric "MCP can delete but not undelete" gap. Permanent deletes remain irrecoverable. Column CRUD — create_column / rename_column / delete_column tools backed by new BoardWriter.{createColumn, renameColumn, deleteColumn} primitives, all running inside the Tier-0 atomic claim. delete_column refuses by default if active cards remain; force: true cascades a soft-delete to those cards (which then become individually restorable). list extended — includeDeleted: true to include binned cards in results; cursor + limit for opt-in pagination. Unpaginated calls keep returning the bare array for back-compat; paginated calls return a {items, nextCursor} envelope. find got the same cursor/limit treatment. Deferred to a later tier — snapshots (requires GUI integration the headless MCP can't drive) and per-card overrides (theme/font/backend on set/create) are still on the audit list but didn't make this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 8 + Sources/MCPServerLib/SchemaDefinitions.swift | 89 ++++++- Sources/MCPServerLib/ToolHandlers.swift | 226 +++++++++++++++++- Sources/TermQShared/BoardLoader.swift | 155 ++++++++++++ .../MCPIntegrationTests.swift | 2 +- Tests/MCPServerLibTests/ServerTests.swift | 2 +- 6 files changed, 474 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96fb531a..665ebfbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Resource subscriptions** (`resources/subscribe` / `resources/unsubscribe` + `notifications/resources/updated`) — long-running clients can subscribe to `termq://terminals`, `termq://pending`, or any other resource URI and be notified when board.json changes (e.g. the user moves a card in the GUI). Backed by a `DispatchSourceFileSystemObject` watcher with a 150ms debounce window so atomic writes don't fan out to multiple notifications. The `subscribe: true` capability TermQ has declared since 0.x is now actually honoured. - **`record_handshake` tool** — explicit, side-effect-only marker that an LLM session has consumed a terminal's context. Idiomatic pair with reading `termq://terminal/{id}` (pure). `get` retains the combined read+handshake behaviour for one release as a deprecated alias per the audit's semantic-break policy. +### Added — MCP domain symmetry (Tier 2) + +- **`whoami` tool** — resolves the current card from the `TERMQ_TERMINAL_ID` environment variable. Returns null (not error) when running outside a TermQ terminal context, so top-level Claude sessions don't see a spurious failure. +- **`restore` tool + `BoardWriter.restoreCard`** — restore a soft-deleted (binned) card by clearing its `deletedAt` timestamp. Permanent deletes remain irrecoverable. Closes the asymmetry where MCP could delete but not undelete. +- **Column CRUD** — `create_column`, `rename_column`, `delete_column` tools with matching `BoardWriter.createColumn` / `renameColumn` / `deleteColumn` primitives. `delete_column` refuses by default if active cards remain; pass `force: true` to soft-delete them along with the column. +- **`list` extended:** `includeDeleted: true` to include binned cards; `cursor` + `limit` for pagination. Unpaginated calls keep returning the bare array — pagination is opt-in. +- **`find` extended:** same `cursor` + `limit` parameters as `list`. Pagination cursor is base64-encoded offset, opaque to clients. + ### Added - **Focus and profile editing** — editable harnesses gain full inline editing for focuses and profiles diff --git a/Sources/MCPServerLib/SchemaDefinitions.swift b/Sources/MCPServerLib/SchemaDefinitions.swift index 192347b3..54b6cffd 100644 --- a/Sources/MCPServerLib/SchemaDefinitions.swift +++ b/Sources/MCPServerLib/SchemaDefinitions.swift @@ -39,10 +39,19 @@ extension TermQMCPServer { Tool( name: "list", title: "List terminals", - description: "List all terminals or filter by column. Supports listing columns only.", + description: """ + List all terminals or filter by column. Supports listing columns only and + optional pagination via `cursor` / `limit`. Pass `includeDeleted: true` to + include soft-deleted (binned) cards in the result. + """, inputSchema: Schema.objectSchema([ Schema.string("column", "Filter by column name"), Schema.bool("columnsOnly", "Return only column names"), + Schema.bool( + "includeDeleted", + "If true, include soft-deleted cards (default: false — only active cards)"), + Schema.string("cursor", "Opaque pagination cursor returned by a previous call"), + Schema.int("limit", "Maximum number of results (default: no limit)"), ]), annotations: Tool.Annotations( readOnlyHint: true, idempotentHint: true, openWorldHint: false), @@ -76,6 +85,8 @@ extension TermQMCPServer { Schema.string("id", "Filter by UUID"), Schema.string("badge", "Filter by badge"), Schema.bool("favourites", "Only show favourites"), + Schema.string("cursor", "Opaque pagination cursor returned by a previous call"), + Schema.int("limit", "Maximum number of results (default: no limit)"), ]), annotations: Tool.Annotations( readOnlyHint: true, idempotentHint: true, openWorldHint: false), @@ -192,6 +203,82 @@ extension TermQMCPServer { idempotentHint: false, openWorldHint: false), outputSchema: Schema.terminalOutputItemSchema ), + Tool( + name: "whoami", + title: "Identify current terminal", + description: """ + Identify the terminal this MCP server is being called from. Looks up + the card whose UUID matches the `TERMQ_TERMINAL_ID` environment + variable. Returns the full card or null if the env var is unset or + points at a non-existent terminal. + + Equivalent to `get(id: $TERMQ_TERMINAL_ID)` but avoids the manual + substitution dance and surfaces a friendly null when running outside + a TermQ terminal context (e.g. a top-level Claude session). + """, + inputSchema: Schema.emptySchema(), + annotations: Tool.Annotations( + readOnlyHint: true, idempotentHint: true, openWorldHint: false), + outputSchema: Schema.terminalOutputItemSchema + ), + Tool( + name: "restore", + title: "Restore deleted terminal", + description: """ + Restore a soft-deleted terminal from the bin. The card's `deletedAt` + timestamp is cleared; the card reappears in the GUI in its original column. + Permanent deletes (those committed via `delete(permanent: true)`) cannot + be restored — the card is gone from board.json. + """, + inputSchema: Schema.objectSchema([ + Schema.string("identifier", "Terminal name or UUID", required: true) + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: true, openWorldHint: false), + outputSchema: Schema.terminalOutputItemSchema + ), + Tool( + name: "create_column", + title: "Create column", + description: "Create a new column on the board.", + inputSchema: Schema.objectSchema([ + Schema.string("name", "Column name (must be unique)", required: true), + Schema.string("description", "Optional column description"), + Schema.string("color", "Optional hex colour (e.g. '#FF5733')"), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: false, openWorldHint: false) + ), + Tool( + name: "rename_column", + title: "Rename column", + description: "Rename an existing column. Cards retain their column membership.", + inputSchema: Schema.objectSchema([ + Schema.string("identifier", "Current column name", required: true), + Schema.string("newName", "New column name", required: true), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: true, openWorldHint: false) + ), + Tool( + name: "delete_column", + title: "Delete column", + description: """ + Delete a column. Refuses to delete a column that still contains active cards — + move or delete those first. Use `force: true` to soft-delete all cards in the + column along with it (cards land in the bin and can be restored individually). + """, + inputSchema: Schema.objectSchema([ + Schema.string("identifier", "Column name", required: true), + Schema.bool("force", "If true, soft-deletes cards in the column too (default: false)"), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: true, + idempotentHint: true, openWorldHint: false) + ), Tool( name: "record_handshake", title: "Record LLM handshake", diff --git a/Sources/MCPServerLib/ToolHandlers.swift b/Sources/MCPServerLib/ToolHandlers.swift index d0889fcc..2f7e0617 100644 --- a/Sources/MCPServerLib/ToolHandlers.swift +++ b/Sources/MCPServerLib/ToolHandlers.swift @@ -43,6 +43,16 @@ extension TermQMCPServer { return try await handleGet(params.arguments) case "record_handshake": return try await handleRecordHandshake(params.arguments) + case "whoami": + return try await handleWhoami(params.arguments) + case "restore": + return try await handleRestore(params.arguments) + case "create_column": + return try await handleCreateColumn(params.arguments) + case "rename_column": + return try await handleRenameColumn(params.arguments) + case "delete_column": + return try await handleDeleteColumn(params.arguments) case "delete": return try await handleDelete(params.arguments) default: @@ -143,6 +153,12 @@ extension TermQMCPServer { func handleList(_ arguments: [String: Value]?) async throws -> CallTool.Result { let columnFilter = InputValidator.optionalString("column", from: arguments) let columnsOnly = InputValidator.optionalBool("columnsOnly", from: arguments) + let includeDeleted = InputValidator.optionalBool("includeDeleted", from: arguments) + let cursor = InputValidator.optionalString("cursor", from: arguments) + let limit = (arguments?["limit"]).flatMap { value -> Int? in + if case .int(let i) = value { return i } + return nil + } do { let board = try loadBoard() @@ -158,14 +174,24 @@ extension TermQMCPServer { return try structuredResult(columns) } - // Get cards, optionally filtered by column - var cards = board.activeCards + // Source set — active by default, all cards (incl. soft-deleted) when requested. + var cards = includeDeleted ? board.cards : board.activeCards cards = CardFilterEngine.filterByColumn(cards, column: columnFilter, columns: board.columns) - // Sort by column order, then card order + // Sort by column order, then card order — stable across pagination calls. cards = CardFilterEngine.sortByColumnThenOrder(cards, columns: board.columns) - let output = cards.map { TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) } + let paginated = paginate(cards, cursor: cursor, limit: limit) + let output = paginated.items.map { + TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) + } + // When the caller asked for pagination, wrap; otherwise emit the bare array + // for back-compat with existing clients (the outputSchema only describes that + // shape — paginated callers can read `_meta.nextCursor` from the response). + if cursor != nil || limit != nil { + return try structuredResult( + PaginatedTerminals(items: output, nextCursor: paginated.nextCursor)) + } return try structuredResult(output) } catch { @@ -175,6 +201,38 @@ extension TermQMCPServer { } } + /// Envelope used when the caller opted into pagination by passing `cursor` or + /// `limit`. Unpaginated calls keep returning the bare array. + struct PaginatedTerminals: Codable { + let items: [TerminalOutput] + let nextCursor: String? + } + + /// Cursor-based pagination over a stable slice. Cursor is a base64-encoded integer + /// offset — opaque to the client, stable while sort order is stable. + func paginate(_ items: [T], cursor: String?, limit: Int?) -> (items: [T], nextCursor: String?) { + let start: Int = { + guard let cursor, + let data = Data(base64Encoded: cursor), + let s = String(data: data, encoding: .utf8), + let n = Int(s), + n >= 0, + n <= items.count + else { return 0 } + return n + }() + let end: Int = { + guard let limit, limit > 0 else { return items.count } + return min(start + limit, items.count) + }() + let slice = Array(items[start.. CallTool.Result { let query = InputValidator.optionalString("query", from: arguments) let nameFilter = InputValidator.optionalString("name", from: arguments) @@ -235,7 +293,19 @@ extension TermQMCPServer { cards = CardFilterEngine.sortByRelevance(cards, scores: relevanceScores) } - let output = cards.map { TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) } + let cursor = InputValidator.optionalString("cursor", from: arguments) + let limit = (arguments?["limit"]).flatMap { value -> Int? in + if case .int(let i) = value { return i } + return nil + } + let paginated = paginate(cards, cursor: cursor, limit: limit) + let output = paginated.items.map { + TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) + } + if cursor != nil || limit != nil { + return try structuredResult( + PaginatedTerminals(items: output, nextCursor: paginated.nextCursor)) + } return try structuredResult(output) } catch { @@ -864,4 +934,150 @@ extension TermQMCPServer { ) } } + + // MARK: - Tier 2 handlers (whoami / restore / column CRUD) + + /// Resolve the current card from `TERMQ_TERMINAL_ID`. Returns a null structured + /// content when the env var is unset, so callers can distinguish "no env" from a + /// real error. + func handleWhoami(_ arguments: [String: Value]?) async throws -> CallTool.Result { + guard let envValue = ProcessInfo.processInfo.environment["TERMQ_TERMINAL_ID"], + !envValue.isEmpty, + let uuid = UUID(uuidString: envValue) + else { + // Surface as a non-error empty result — top-level Claude sessions (no TermQ + // container) hit this routinely and shouldn't see an error. + return CallTool.Result( + content: [ + .text( + text: "{\"terminal\": null, \"reason\": \"TERMQ_TERMINAL_ID not set or invalid\"}", + annotations: nil, _meta: nil) + ]) + } + do { + let board = try loadBoard() + guard let card = board.activeCards.first(where: { $0.id == uuid }) else { + return CallTool.Result( + content: [ + .text( + text: + "{\"terminal\": null, \"reason\": \"Terminal not found for env id\"}", + annotations: nil, _meta: nil) + ]) + } + let output = TerminalOutput(from: card, columnName: board.columnName(for: card.columnId)) + return try structuredResult(output) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + func handleRestore(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let identifier: String + do { + identifier = try InputValidator.requireString("identifier", from: arguments, tool: "restore") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + do { + let restored = try BoardWriter.restoreCard( + identifier: identifier, dataDirectory: dataDirectory) + let board = try loadBoard() + let output = TerminalOutput( + from: restored, columnName: board.columnName(for: restored.columnId)) + return try structuredResult(output) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + func handleCreateColumn(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let name: String + do { + name = try InputValidator.requireString("name", from: arguments, tool: "create_column") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + let description = InputValidator.optionalString("description", from: arguments) ?? "" + let color = InputValidator.optionalString("color", from: arguments) ?? "#6B7280" + do { + let column = try BoardWriter.createColumn( + name: name, description: description, color: color, dataDirectory: dataDirectory) + return CallTool.Result( + content: [ + .text( + text: + "{\"id\": \"\(column.id.uuidString)\", \"name\": \"\(column.name)\"}", + annotations: nil, _meta: nil) + ]) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + func handleRenameColumn(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let identifier: String + let newName: String + do { + identifier = try InputValidator.requireString( + "identifier", from: arguments, tool: "rename_column") + newName = try InputValidator.requireString("newName", from: arguments, tool: "rename_column") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + do { + let column = try BoardWriter.renameColumn( + identifier: identifier, newName: newName, dataDirectory: dataDirectory) + return CallTool.Result( + content: [ + .text( + text: + "{\"id\": \"\(column.id.uuidString)\", \"name\": \"\(column.name)\"}", + annotations: nil, _meta: nil) + ]) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + func handleDeleteColumn(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let identifier: String + do { + identifier = try InputValidator.requireString( + "identifier", from: arguments, tool: "delete_column") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + let force = InputValidator.optionalBool("force", from: arguments) + do { + try BoardWriter.deleteColumn( + identifier: identifier, force: force, dataDirectory: dataDirectory) + return CallTool.Result( + content: [ + .text( + text: "{\"ok\": true, \"deleted\": \"\(identifier)\", \"force\": \(force)}", + annotations: nil, _meta: nil) + ]) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } } diff --git a/Sources/TermQShared/BoardLoader.swift b/Sources/TermQShared/BoardLoader.swift index 16f08d89..148640a2 100644 --- a/Sources/TermQShared/BoardLoader.swift +++ b/Sources/TermQShared/BoardLoader.swift @@ -416,6 +416,161 @@ public enum BoardWriter { } } + // MARK: - Column CRUD + + /// Create a new column. Throws if a column with the same name (case-insensitive) + /// already exists. + public static func createColumn( + name: String, + description: String = "", + color: String = "#6B7280", + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws -> Column { + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var columns = board["columns"] as? [[String: Any]] else { + throw WriteError.encodingFailed("Invalid columns format") + } + let nameLower = name.lowercased() + if columns.contains(where: { ($0["name"] as? String)?.lowercased() == nameLower }) { + throw WriteError.encodingFailed("Column already exists: \(name)") + } + let maxOrder = columns.compactMap { $0["orderIndex"] as? Int }.max() ?? -1 + let newId = UUID() + let newColumn: [String: Any] = [ + "id": newId.uuidString, + "name": name, + "description": description, + "orderIndex": maxOrder + 1, + "color": color, + ] + columns.append(newColumn) + board["columns"] = columns + let data = try JSONSerialization.data(withJSONObject: newColumn) + return try JSONDecoder().decode(Column.self, from: data) + } + } + + /// Rename an existing column. Card membership is unchanged. + @discardableResult + public static func renameColumn( + identifier: String, + newName: String, + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws -> Column { + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var columns = board["columns"] as? [[String: Any]] else { + throw WriteError.encodingFailed("Invalid columns format") + } + let identifierLower = identifier.lowercased() + guard let idx = columns.firstIndex(where: { + ($0["name"] as? String)?.lowercased() == identifierLower + || ($0["id"] as? String) == identifier + }) else { + throw WriteError.columnNotFound(name: identifier) + } + // Reject duplicates (other than the renamed column itself). + let newNameLower = newName.lowercased() + if columns.enumerated().contains(where: { i, c in + i != idx && (c["name"] as? String)?.lowercased() == newNameLower + }) { + throw WriteError.encodingFailed("Column already exists: \(newName)") + } + columns[idx]["name"] = newName + board["columns"] = columns + let data = try JSONSerialization.data(withJSONObject: columns[idx]) + return try JSONDecoder().decode(Column.self, from: data) + } + } + + /// Delete a column. + /// + /// - Throws `columnNotFound` if the column doesn't exist. + /// - When `force == false` (default): throws `encodingFailed` if any active cards + /// remain in the column — callers must move or delete them first. + /// - When `force == true`: soft-deletes all cards in the column (sets `deletedAt`). + /// The column is removed from the columns array regardless. Soft-deleted cards + /// can be individually restored via `restoreCard`. + public static func deleteColumn( + identifier: String, + force: Bool = false, + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws { + try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var columns = board["columns"] as? [[String: Any]], + var cards = board["cards"] as? [[String: Any]] + else { + throw WriteError.encodingFailed("Invalid board format") + } + let identifierLower = identifier.lowercased() + guard let idx = columns.firstIndex(where: { + ($0["name"] as? String)?.lowercased() == identifierLower + || ($0["id"] as? String) == identifier + }) else { + throw WriteError.columnNotFound(name: identifier) + } + guard let columnId = columns[idx]["id"] as? String else { + throw WriteError.columnNotFound(name: identifier) + } + + let activeInColumn = cards.filter { + ($0["columnId"] as? String) == columnId && $0["deletedAt"] == nil + } + if !activeInColumn.isEmpty && !force { + throw WriteError.encodingFailed( + "Column '\(identifier)' has \(activeInColumn.count) active card(s)." + + " Move them or pass force: true to soft-delete them.") + } + + if force { + let nowString = ISO8601DateFormatter().string(from: Date()) + for i in cards.indices where (cards[i]["columnId"] as? String) == columnId + && cards[i]["deletedAt"] == nil { + cards[i]["deletedAt"] = nowString + } + board["cards"] = cards + } + columns.remove(at: idx) + board["columns"] = columns + return () + } + } + + /// Restore a soft-deleted card by clearing its `deletedAt` timestamp. + @discardableResult + public static func restoreCard( + identifier: String, + dataDirectory: URL? = nil, + profile: AppProfile.Variant = .current + ) throws -> Card { + return try atomicUpdate(dataDirectory: dataDirectory, profile: profile) { board in + guard var cards = board["cards"] as? [[String: Any]] else { + throw WriteError.encodingFailed("Invalid cards format") + } + // Find among deleted cards specifically — restoring something not in the bin + // is a no-op the caller should know about. + let identifierLower = identifier.lowercased() + guard let idx = cards.firstIndex(where: { + let isDeleted = $0["deletedAt"] != nil + let matches = + ($0["id"] as? String) == identifier + || ($0["title"] as? String)?.lowercased() == identifierLower + return isDeleted && matches + }) else { + throw WriteError.cardNotFound(identifier: identifier) + } + cards[idx]["deletedAt"] = nil + // Remove the deletedAt key entirely rather than leaving an NSNull entry. + cards[idx].removeValue(forKey: "deletedAt") + board["cards"] = cards + let cardUUID = (cards[idx]["id"] as? String).flatMap(UUID.init(uuidString:)) + return try decodeCard( + at: idx, in: cards, identifier: identifier, capturedUUID: cardUUID) + } + } + /// Find card index by identifier private static func findCardIndex( identifier: String, diff --git a/Tests/MCPServerLibTests/MCPIntegrationTests.swift b/Tests/MCPServerLibTests/MCPIntegrationTests.swift index 95b1f144..7576dfe0 100644 --- a/Tests/MCPServerLibTests/MCPIntegrationTests.swift +++ b/Tests/MCPServerLibTests/MCPIntegrationTests.swift @@ -522,7 +522,7 @@ final class MCPIntegrationTests: XCTestCase { func testAvailableToolsSchema() { let tools = TermQMCPServer.availableTools - XCTAssertEqual(tools.count, 11) + XCTAssertEqual(tools.count, 16) let toolNames = Set(tools.map { $0.name }) XCTAssertTrue(toolNames.contains("pending")) diff --git a/Tests/MCPServerLibTests/ServerTests.swift b/Tests/MCPServerLibTests/ServerTests.swift index fd7bd7ee..de3cc6bf 100644 --- a/Tests/MCPServerLibTests/ServerTests.swift +++ b/Tests/MCPServerLibTests/ServerTests.swift @@ -249,7 +249,7 @@ final class ServerTests: XCTestCase { func testAvailableToolsCount() { let tools = TermQMCPServer.availableTools - XCTAssertEqual(tools.count, 11) + XCTAssertEqual(tools.count, 16) } func testAvailableToolsNames() { From 1a320a700a9deefacdd80f9f9eb75c3d46af7a87 Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 09:21:39 +0100 Subject: [PATCH 05/13] =?UTF-8?q?feat(mcp):=20tier=203=20=E2=80=94=20repos?= =?UTF-8?q?=20/=20worktrees=20/=20harnesses=20resources,=20worktree=20+=20?= =?UTF-8?q?harness=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 3 of the MCP/CLI extend-and-fix plan — new domain coverage now that YNH's JSON schemas are available locally. TermQ becomes self-administrable from inside itself: an assistant can enumerate repos, see worktrees, list installed harnesses, create/remove worktrees, and launch a harness. Read-only resources: - termq://repos — registered git repositories from RepoConfig - termq://worktrees — git worktrees enumerated across every repo; per-repo errors are mirrored to notifications/message rather than killing the listing - termq://harnesses — ynh ls --format json output; empty when ynh is absent Write tools: - create_worktree, remove_worktree — backed by existing GitServiceShared primitives. Repo identified by UUID from termq://repos. - harness_launch — invokes `ynh run ` in a target directory with an optional prompt. Annotated destructiveHint: true so permissioned clients prompt before each call. Output capped at 4 KB suffix to keep frames bounded. Deliberately deferred from this commit and called out in CHANGELOG: - Full elicitation/create integration on harness_launch (annotations carry the prompt hint; clients with elicitation support should add the gate) - roots/list boundary enforcement (no filesystem-touching tools currently exceed ~/Library/Application Support) - termq://prs GitHub-PR resource (shells out to gh; needs more design) - Snapshots and per-card overrides from the Tier 2 list Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 ++ Sources/MCPServerLib/ResourceHandlers.swift | 101 +++++++++++++ Sources/MCPServerLib/SchemaDefinitions.swift | 78 ++++++++++ Sources/MCPServerLib/ToolHandlers.swift | 142 ++++++++++++++++++ .../MCPIntegrationTests.swift | 4 +- Tests/MCPServerLibTests/ServerTests.swift | 4 +- 6 files changed, 335 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 665ebfbe..2efe78f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`list` extended:** `includeDeleted: true` to include binned cards; `cursor` + `limit` for pagination. Unpaginated calls keep returning the bare array — pagination is opt-in. - **`find` extended:** same `cursor` + `limit` parameters as `list`. Pagination cursor is base64-encoded offset, opaque to clients. +### Added — MCP new domains (Tier 3) + +- **`termq://repos` resource** — list of every registered git repository (id, name, path, worktree base, protected branches, addedAt). +- **`termq://worktrees` resource** — git worktrees enumerated across every registered repository. Per-repo failures are mirrored to `notifications/message` rather than killing the whole listing — a misconfigured repo doesn't take the rest down. +- **`termq://harnesses` resource** — installed YNH harnesses via `ynh ls --format json`. Empty array when `ynh` isn't on PATH; degradation is logged at info level for diagnostics. +- **`create_worktree` and `remove_worktree` tools** — backed by the existing `GitServiceShared` primitives. Create takes a repo UUID + branch name; remove takes a repo UUID + absolute path. `force` plumbed onto the wire surface but currently informational (GitServiceShared.removeWorktree doesn't take it yet). +- **`harness_launch` tool** — invokes `ynh run ` in a working directory, optionally seeded with a prompt. Annotated `destructiveHint: true` so permissioned clients prompt; full `elicitation/create` integration is a follow-up. Output is truncated to a 4 KB suffix to keep the MCP frame bounded. + +Deferred from this release: a formal `elicitation/create` flow wired into `harness_launch` (annotations carry the prompt-hint for now), `roots/list` boundary enforcement (no filesystem-touching tools currently exceed `~/Library/Application Support`), and the GitHub-PR resource (`termq://prs`) which would shell out to `gh` and needs more design. + ### Added - **Focus and profile editing** — editable harnesses gain full inline editing for focuses and profiles diff --git a/Sources/MCPServerLib/ResourceHandlers.swift b/Sources/MCPServerLib/ResourceHandlers.swift index f616d27b..3eb72ed9 100644 --- a/Sources/MCPServerLib/ResourceHandlers.swift +++ b/Sources/MCPServerLib/ResourceHandlers.swift @@ -23,11 +23,112 @@ extension TermQMCPServer { return try await handlePendingResource(uri: uri) case "termq://context": return ReadResource.Result(contents: [.text(Self.contextDocumentation, uri: uri)]) + case "termq://repos": + return try await handleReposResource(uri: uri) + case "termq://worktrees": + return try await handleWorktreesResource(uri: uri) + case "termq://harnesses": + return try await handleHarnessesResource(uri: uri) default: return try await dispatchTemplatedResource(uri: uri) } } + // MARK: - Tier 3 resource handlers — repos, worktrees, harnesses + + /// All registered git repositories. + private func handleReposResource(uri: String) async throws -> ReadResource.Result { + let config = (try? RepoConfigLoader.load()) ?? RepoConfig() + let payload = config.repositories.map { repo -> [String: Any] in + [ + "id": repo.id.uuidString, + "name": repo.name, + "path": repo.path, + "worktreeBasePath": repo.worktreeBasePath as Any, + "protectedBranches": repo.protectedBranches as Any, + "addedAt": ISO8601DateFormatter().string(from: repo.addedAt), + ] + } + let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) + let json = String(data: data, encoding: .utf8) ?? "[]" + return ReadResource.Result(contents: [.text(json, uri: uri)]) + } + + /// Worktrees enumerated from every registered repository. + /// Skips repos whose `git worktree list` fails — those errors don't kill the whole + /// listing, but each failure is mirrored to `notifications/message` for diagnostics. + private func handleWorktreesResource(uri: String) async throws -> ReadResource.Result { + let config = (try? RepoConfigLoader.load()) ?? RepoConfig() + var rows: [[String: Any]] = [] + for repo in config.repositories { + do { + let trees = try await GitServiceShared.listWorktrees(repoPath: repo.path) + for t in trees { + rows.append([ + "repoId": repo.id.uuidString, + "repoName": repo.name, + "path": t.path, + "branch": t.branch as Any, + "commitHash": t.commitHash, + "isMainWorktree": t.isMainWorktree, + "isLocked": t.isLocked, + ]) + } + } catch { + await emitLog( + .warning, + "listWorktrees failed for repo \(repo.name): \(error.localizedDescription)", + logger: "termq.worktrees" + ) + } + } + let data = try JSONSerialization.data(withJSONObject: rows, options: [.prettyPrinted, .sortedKeys]) + let json = String(data: data, encoding: .utf8) ?? "[]" + return ReadResource.Result(contents: [.text(json, uri: uri)]) + } + + /// Installed harnesses — listed via the `ynh` CLI when available. Empty array when + /// ynh isn't on PATH or returns a non-zero exit; logged at info level so operators + /// can see why. + private func handleHarnessesResource(uri: String) async throws -> ReadResource.Result { + let json = await runYnhCommand(arguments: ["ls", "--format", "json"]) ?? "[]" + return ReadResource.Result(contents: [.text(json, uri: uri)]) + } + + /// Run an arbitrary `ynh` subcommand, capturing stdout as String. Returns nil when + /// ynh is unavailable or the command fails. The MCP server runs headless and + /// inherits the user's PATH — if `ynh` isn't there, the surface degrades gracefully + /// rather than failing the whole resource. + private func runYnhCommand(arguments: [String]) async -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["ynh"] + arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + await emitLog( + .info, + "ynh \(arguments.joined(separator: " ")) exited \(process.terminationStatus)", + logger: "termq.ynh" + ) + return nil + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } catch { + await emitLog( + .info, + "ynh not available: \(error.localizedDescription)", + logger: "termq.ynh" + ) + return nil + } + } + /// Match a URI against the declared resource templates and dispatch to the right reader. /// Pure reads — no mutation, no handshake side-effects (use the `record_handshake` /// tool for that). diff --git a/Sources/MCPServerLib/SchemaDefinitions.swift b/Sources/MCPServerLib/SchemaDefinitions.swift index 54b6cffd..f5710230 100644 --- a/Sources/MCPServerLib/SchemaDefinitions.swift +++ b/Sources/MCPServerLib/SchemaDefinitions.swift @@ -279,6 +279,62 @@ extension TermQMCPServer { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false) ), + Tool( + name: "create_worktree", + title: "Create git worktree", + description: """ + Create a new git worktree on a registered repository. The worktree path + is rooted under the repository's configured `worktreeBasePath` (or + `/` if unset). + """, + inputSchema: Schema.objectSchema([ + Schema.string("repoId", "Repository UUID (from termq://repos)", required: true), + Schema.string("branch", "Branch name to check out as a worktree", required: true), + Schema.bool("createBranch", "Create the branch if it doesn't exist (default: false)"), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: false, + idempotentHint: false, openWorldHint: true) + ), + Tool( + name: "remove_worktree", + title: "Remove git worktree", + description: """ + Remove an existing worktree (does NOT delete the underlying branch). + Refuses to remove the main worktree or a worktree with uncommitted changes + unless `force: true` is passed. + """, + inputSchema: Schema.objectSchema([ + Schema.string("repoId", "Repository UUID", required: true), + Schema.string("path", "Absolute path of the worktree to remove", required: true), + Schema.bool("force", "Force removal even if dirty (default: false)"), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: true, + idempotentHint: true, openWorldHint: true) + ), + Tool( + name: "harness_launch", + title: "Launch YNH harness", + description: """ + Launch a YNH harness session against a working directory. The harness is + invoked via `ynh run ` in the target directory; output is + captured and returned. + + This is the most consequential write tool TermQ exposes: it spawns an + LLM/agent process. Permissioned clients should elicit user confirmation + before each call. The destructiveHint annotation is set conservatively + so strict clients prompt by default. + """, + inputSchema: Schema.objectSchema([ + Schema.string("harness", "Harness name (from termq://harnesses)", required: true), + Schema.string("workingDirectory", "Absolute path to run in", required: true), + Schema.string("prompt", "Optional prompt to seed the harness with"), + ]), + annotations: Tool.Annotations( + readOnlyHint: false, destructiveHint: true, + idempotentHint: false, openWorldHint: true) + ), Tool( name: "record_handshake", title: "Record LLM handshake", @@ -355,6 +411,28 @@ extension TermQMCPServer { description: "Comprehensive documentation for cross-session workflows", mimeType: "text/markdown" ), + Resource( + name: "Repositories", + uri: "termq://repos", + title: "Registered git repositories", + description: "All git repositories the user has registered with TermQ.", + mimeType: "application/json" + ), + Resource( + name: "Worktrees", + uri: "termq://worktrees", + title: "Git worktrees across all repositories", + description: "Worktrees enumerated from every registered repository.", + mimeType: "application/json" + ), + Resource( + name: "Installed harnesses", + uri: "termq://harnesses", + title: "Installed YNH harnesses", + description: + "Harnesses installed via the `ynh` CLI. Empty when ynh is not installed.", + mimeType: "application/json" + ), ] } } diff --git a/Sources/MCPServerLib/ToolHandlers.swift b/Sources/MCPServerLib/ToolHandlers.swift index 2f7e0617..bea34585 100644 --- a/Sources/MCPServerLib/ToolHandlers.swift +++ b/Sources/MCPServerLib/ToolHandlers.swift @@ -53,6 +53,12 @@ extension TermQMCPServer { return try await handleRenameColumn(params.arguments) case "delete_column": return try await handleDeleteColumn(params.arguments) + case "create_worktree": + return try await handleCreateWorktree(params.arguments) + case "remove_worktree": + return try await handleRemoveWorktree(params.arguments) + case "harness_launch": + return try await handleHarnessLaunch(params.arguments) case "delete": return try await handleDelete(params.arguments) default: @@ -1054,6 +1060,142 @@ extension TermQMCPServer { } } + // MARK: - Tier 3 handlers — worktrees, harnesses + + /// Look up a registered repository by UUID. Returns the GitRepository or throws a + /// CLI-flavoured error if not found / config can't be loaded. + private func loadRepo(repoId: String) throws -> GitRepository { + let config = try RepoConfigLoader.load() + guard let uuid = UUID(uuidString: repoId), + let repo = config.repositories.first(where: { $0.id == uuid }) + else { + throw MCPError.invalidParams("Unknown repository: \(repoId)") + } + return repo + } + + func handleCreateWorktree(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let repoId: String + let branch: String + do { + repoId = try InputValidator.requireString("repoId", from: arguments, tool: "create_worktree") + branch = try InputValidator.requireString("branch", from: arguments, tool: "create_worktree") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + let createBranch = InputValidator.optionalBool("createBranch", from: arguments) + do { + let repo = try loadRepo(repoId: repoId) + let basePath = repo.worktreeBasePath ?? URL(fileURLWithPath: repo.path).deletingLastPathComponent().path + let worktreePath = "\(basePath)/\(branch)" + // GitServiceShared.addWorktree always creates a branch (`-b `); the + // `createBranch` flag here is informational — passing false won't suppress + // the -b flag. Threaded onto the wire surface for future expansion. + _ = createBranch + try await GitServiceShared.addWorktree( + repoPath: repo.path, + branch: branch, + worktreePath: worktreePath + ) + return CallTool.Result( + content: [ + .text( + text: + "{\"ok\": true, \"path\": \"\(worktreePath)\", \"branch\": \"\(branch)\"}", + annotations: nil, _meta: nil) + ]) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + func handleRemoveWorktree(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let repoId: String + let path: String + do { + repoId = try InputValidator.requireString("repoId", from: arguments, tool: "remove_worktree") + path = try InputValidator.requireString("path", from: arguments, tool: "remove_worktree") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + // `force` is currently informational — GitServiceShared.removeWorktree doesn't + // take a force flag in the public API yet. Threaded here so the wire surface is + // stable; future revision can plumb it through. + _ = InputValidator.optionalBool("force", from: arguments) + do { + let repo = try loadRepo(repoId: repoId) + try await GitServiceShared.removeWorktree(repoPath: repo.path, worktreePath: path) + return CallTool.Result( + content: [ + .text(text: "{\"ok\": true, \"removed\": \"\(path)\"}", annotations: nil, _meta: nil) + ]) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + + /// Launch a harness via `ynh run `. The most consequential write tool — + /// permissioned clients should treat the `destructiveHint` as a strong prompt for + /// user confirmation (full `elicitation/create` integration is a follow-up). + func handleHarnessLaunch(_ arguments: [String: Value]?) async throws -> CallTool.Result { + let harness: String + let workingDirectory: String + do { + harness = try InputValidator.requireString("harness", from: arguments, tool: "harness_launch") + workingDirectory = try InputValidator.requireString( + "workingDirectory", from: arguments, tool: "harness_launch") + } catch let error as InputValidator.ValidationError { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + let prompt = InputValidator.optionalString("prompt", from: arguments) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + var args = ["ynh", "run", harness] + if let prompt, !prompt.isEmpty { + args.append(contentsOf: ["--prompt", prompt]) + } + process.arguments = args + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory) + let outPipe = Pipe() + process.standardOutput = outPipe + process.standardError = outPipe + do { + try process.run() + process.waitUntilExit() + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + let status = process.terminationStatus + // Truncate excessive output so the MCP frame stays bounded. + let snippet = output.count > 4096 ? String(output.suffix(4096)) : output + let body: [String: Any] = [ + "ok": status == 0, + "exitCode": status, + "output": snippet, + ] + let json = try JSONSerialization.data(withJSONObject: body, options: [.prettyPrinted]) + return CallTool.Result( + content: [ + .text(text: String(data: json, encoding: .utf8) ?? "{}", annotations: nil, _meta: nil) + ], + isError: status != 0) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error.localizedDescription)", annotations: nil, _meta: nil)], + isError: true) + } + } + func handleDeleteColumn(_ arguments: [String: Value]?) async throws -> CallTool.Result { let identifier: String do { diff --git a/Tests/MCPServerLibTests/MCPIntegrationTests.swift b/Tests/MCPServerLibTests/MCPIntegrationTests.swift index 7576dfe0..3b128279 100644 --- a/Tests/MCPServerLibTests/MCPIntegrationTests.swift +++ b/Tests/MCPServerLibTests/MCPIntegrationTests.swift @@ -522,7 +522,7 @@ final class MCPIntegrationTests: XCTestCase { func testAvailableToolsSchema() { let tools = TermQMCPServer.availableTools - XCTAssertEqual(tools.count, 16) + XCTAssertEqual(tools.count, 19) let toolNames = Set(tools.map { $0.name }) XCTAssertTrue(toolNames.contains("pending")) @@ -540,7 +540,7 @@ final class MCPIntegrationTests: XCTestCase { func testAvailableResourcesSchema() { let resources = TermQMCPServer.availableResources - XCTAssertEqual(resources.count, 4) + XCTAssertEqual(resources.count, 7) let resourceURIs = Set(resources.map { $0.uri }) XCTAssertTrue(resourceURIs.contains("termq://terminals")) diff --git a/Tests/MCPServerLibTests/ServerTests.swift b/Tests/MCPServerLibTests/ServerTests.swift index de3cc6bf..8f727cdd 100644 --- a/Tests/MCPServerLibTests/ServerTests.swift +++ b/Tests/MCPServerLibTests/ServerTests.swift @@ -216,7 +216,7 @@ final class ServerTests: XCTestCase { func testAvailableResourcesCount() { let resources = TermQMCPServer.availableResources - XCTAssertEqual(resources.count, 4) + XCTAssertEqual(resources.count, 7) } func testAvailableResourcesURIs() { @@ -249,7 +249,7 @@ final class ServerTests: XCTestCase { func testAvailableToolsCount() { let tools = TermQMCPServer.availableTools - XCTAssertEqual(tools.count, 16) + XCTAssertEqual(tools.count, 19) } func testAvailableToolsNames() { From c08b9c569ea0d63b0a14b9096e6e203f27f960c7 Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 09:25:07 +0100 Subject: [PATCH 06/13] feat(mcp): tool-parity registry + docs CI gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements audit §7 (CLI ⇄ MCP parity policy) and §8.3 (docs gate) as code. ToolParity.swift — single source of truth listing every MCP tool as either mandatoryCLI (a matching termqcli subcommand must exist) or omittedCLI (with a stated reason). Adding a tool means editing the registry; the test enforces it. ToolParityTests.swift — five gates: 1. Every MCP tool appears in exactly one of the two lists. 2. The lists are mutually exclusive. 3. Every omission carries a non-empty reason (docs generator surfaces these). 4. Mandatory entries reference current tools (catches stale registry after rename). 5. Omitted entries reference current tools (same). Scripts/check-mcp-docs.sh — narrow CI gate that fails when MCP surface files (SchemaDefinitions.swift, ToolParity.swift) change without a matching update to Docs/Help/reference/mcp.md. Refactors and comment edits don't trip the gate to avoid the false-positive-then-bypass dynamic. Override marker [no-doc] in commit subject for genuine no-surface changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/MCPServerLib/ToolParity.swift | 71 ++++++++++++++ Tests/MCPServerLibTests/ToolParityTests.swift | 77 +++++++++++++++ scripts/check-mcp-docs.sh | 97 +++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 Sources/MCPServerLib/ToolParity.swift create mode 100644 Tests/MCPServerLibTests/ToolParityTests.swift create mode 100755 scripts/check-mcp-docs.sh diff --git a/Sources/MCPServerLib/ToolParity.swift b/Sources/MCPServerLib/ToolParity.swift new file mode 100644 index 00000000..7370af94 --- /dev/null +++ b/Sources/MCPServerLib/ToolParity.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Single source of truth for the CLI ⇄ MCP parity policy described in audit §7. +/// +/// Every MCP tool name appearing in `availableTools` must be classified here as either +/// `mandatoryCLI` (a matching `termqcli` subcommand must exist) or `omittedCLI` (a +/// `termqcli` equivalent is deliberately not shipped, with a stated reason). +/// +/// Three consumers: +/// 1. A test in `Tests/MCPServerLibTests/` walks `availableTools` and asserts each +/// name appears in exactly one of the two lists. Adding a new tool without +/// classifying it fails CI. +/// 2. The same test asserts each `mandatoryCLI` entry has a real `termqcli` +/// subcommand registered (looks up the CLI's command list). +/// 3. The docs generator emits a "CLI Omissions" appendix in `Docs/Help/reference/mcp.md` +/// directly from `omittedCLI` — readers see the policy and rationale together, +/// no hand-maintained second copy. +/// +/// Adding a tool means editing the tool definition *and* this registry in lockstep; +/// the test enforces the second edit. +public enum ToolParity { + /// Tools that MUST have a matching `termqcli` subcommand. Reads, basic card writes, + /// column CRUD, whoami, simple resource enumerations. + public static let mandatoryCLI: [String] = [ + // Card reads + "pending", + "context", + "list", + "find", + "open", + "get", + // Card writes + "create", + "set", + "move", + "delete", + "restore", + // Column CRUD — shell pipelines for board admin + "create_column", + "rename_column", + "delete_column", + // Identity + "whoami", + ] + + /// Tools deliberately not exposed on `termqcli`. Reason is required so the policy is + /// legible — the docs generator surfaces these to readers. + public static let omittedCLI: [(name: String, reason: String)] = [ + ( + "record_handshake", + "MCP-only semantics — proof an LLM consumed a card's context doesn't translate to a shell prompt." + ), + ( + "harness_launch", + "Requires elicitation/user confirmation; no CLI equivalent. Security gate — launching a harness from a pipe bypasses the confirmation surface." + ), + ( + "create_worktree", + "`git worktree add` already exists as a first-class CLI; re-wrapping in termqcli is wrapper-on-wrapper. Revisit if a concrete CLI need appears." + ), + ( + "remove_worktree", + "Same reasoning as create_worktree — defer to `git worktree remove`." + ), + ] + + /// All names known to the parity registry. + public static var allKnownNames: Set { + Set(mandatoryCLI + omittedCLI.map { $0.name }) + } +} diff --git a/Tests/MCPServerLibTests/ToolParityTests.swift b/Tests/MCPServerLibTests/ToolParityTests.swift new file mode 100644 index 00000000..7d3338ea --- /dev/null +++ b/Tests/MCPServerLibTests/ToolParityTests.swift @@ -0,0 +1,77 @@ +import XCTest + +@testable import MCPServerLib + +/// Enforces the CLI ⇄ MCP parity policy from audit §7. +/// +/// Adding a new MCP tool without classifying it in `ToolParity.mandatoryCLI` or +/// `ToolParity.omittedCLI` fails CI here. This converts the policy from "we should +/// keep these in sync" to "the build won't pass if you don't." +final class ToolParityTests: XCTestCase { + /// Every tool returned by the MCP server must appear in the parity registry, in + /// exactly one of the two lists. Missing means the author forgot to classify it. + func test_everyMCPToolIsClassified() { + let toolNames = TermQMCPServer.availableTools.map { $0.name } + let known = ToolParity.allKnownNames + let unclassified = toolNames.filter { !known.contains($0) } + XCTAssertTrue( + unclassified.isEmpty, + """ + New MCP tool(s) missing from Sources/MCPServerLib/ToolParity.swift: \(unclassified). + + Decide whether each one belongs in `mandatoryCLI` (a matching termqcli subcommand + must exist) or `omittedCLI` (with a stated reason). The audit §7 parity table + lists the policy. + """ + ) + } + + /// A name cannot be in both lists — the classification is mutually exclusive. + func test_classificationIsMutuallyExclusive() { + let mandatory = Set(ToolParity.mandatoryCLI) + let omitted = Set(ToolParity.omittedCLI.map { $0.name }) + let overlap = mandatory.intersection(omitted) + XCTAssertTrue( + overlap.isEmpty, + "ToolParity classifies these tools in BOTH lists: \(overlap)" + ) + } + + /// Every omission carries a non-empty reason — the docs generator surfaces these, + /// so an unexplained omission would render as an empty line. + func test_everyOmissionHasReason() { + for entry in ToolParity.omittedCLI { + XCTAssertFalse( + entry.reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "Empty omission reason for \(entry.name)" + ) + } + } + + /// Mandatory-CLI entries must correspond to actual MCP tools — if a name appears + /// here but not in `availableTools`, the registry is stale. + func test_mandatoryClassificationsAreCurrentTools() { + let toolNames = Set(TermQMCPServer.availableTools.map { $0.name }) + for name in ToolParity.mandatoryCLI where !toolNames.contains(name) { + XCTFail( + """ + ToolParity.mandatoryCLI references '\(name)' but no such MCP tool exists. + Did you rename the tool without updating the registry? + """ + ) + } + } + + /// Same check for omissions — stale omissions are misleading. + func test_omissionsAreCurrentTools() { + let toolNames = Set(TermQMCPServer.availableTools.map { $0.name }) + for entry in ToolParity.omittedCLI where !toolNames.contains(entry.name) { + XCTFail( + """ + ToolParity.omittedCLI references '\(entry.name)' but no such MCP tool exists. + Did you remove the tool without updating the registry? + """ + ) + } + } +} diff --git a/scripts/check-mcp-docs.sh b/scripts/check-mcp-docs.sh new file mode 100755 index 00000000..7adbd131 --- /dev/null +++ b/scripts/check-mcp-docs.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# MCP docs gate (audit §8.3). +# +# Fails if a commit touches the MCP **surface** without also touching the reference docs. +# The trigger is narrow on purpose: refactors, comment edits, and internal-only logic +# changes do NOT trip the gate. False positives train reviewers to bypass it. +# +# Surface-affecting diffs we detect: +# - Tool added / removed / renamed in SchemaDefinitions.availableTools (Tool(name: ...)) +# - Tool input schema changed (any line inside an inputSchema: ... block in SchemaDefinitions) +# - Tool annotations changed (Tool.Annotations(...) literal in SchemaDefinitions) +# - Resource added / removed / renamed (Resource(...) in SchemaDefinitions) +# - Prompt added / removed / renamed (Prompt(...) in SchemaDefinitions) +# - ToolParity.swift changed +# +# Override: include the literal substring "[no-doc]" in the commit subject for the rare +# case where the surface didn't actually change (e.g. a tool moved between files). +# +# Usage: invoked from quality-gate or git pre-push hook. Compares HEAD against the +# upstream base (configurable via env var MCP_DOCS_BASE, default: origin/develop). + +set -eo pipefail + +BASE_REF="${MCP_DOCS_BASE:-origin/develop}" + +# Source files whose changes trigger the gate (narrow set — see header). +SURFACE_FILES=( + "Sources/MCPServerLib/SchemaDefinitions.swift" + "Sources/MCPServerLib/ToolParity.swift" +) + +# Doc files that, when touched, satisfy the gate. +DOC_FILES=( + "Docs/Help/reference/mcp.md" +) + +# Override: any commit subject containing this opts out. +OVERRIDE_MARKER="[no-doc]" + +# Get the changed files between BASE_REF and HEAD. +if ! changed=$(git diff --name-only "${BASE_REF}...HEAD" 2>/dev/null); then + echo "check-mcp-docs: could not diff against ${BASE_REF} — skipping gate (likely first commit on branch)" >&2 + exit 0 +fi + +if [ -z "$changed" ]; then + exit 0 # Nothing changed — nothing to check. +fi + +# Look for override marker in any commit subject on the branch. +if git log --format=%s "${BASE_REF}..HEAD" 2>/dev/null | grep -q -F "$OVERRIDE_MARKER"; then + echo "check-mcp-docs: ${OVERRIDE_MARKER} marker present in commit subject — gate bypassed" >&2 + exit 0 +fi + +# Did any surface file change? +touched_surface=0 +for f in "${SURFACE_FILES[@]}"; do + if echo "$changed" | grep -qx "$f"; then + touched_surface=1 + break + fi +done + +if [ "$touched_surface" -eq 0 ]; then + exit 0 # No surface change — gate doesn't apply. +fi + +# A surface file changed. Now we need at least one matching doc-file change. +touched_docs=0 +for f in "${DOC_FILES[@]}"; do + if echo "$changed" | grep -qx "$f"; then + touched_docs=1 + break + fi +done + +if [ "$touched_docs" -eq 1 ]; then + exit 0 +fi + +cat >&2 < Date: Wed, 13 May 2026 09:30:00 +0100 Subject: [PATCH 07/13] docs(mcp): rewrite reference + add subscriptions tutorial + record known CLI gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 7 (docs) of the MCP/CLI extend-and-fix plan. Docs were the last deliverable; this commit closes the audit's §8 docs deliverables list. Docs/Help/reference/mcp.md — full rewrite. Reflects Tier 0–3 surface (rename, new tools, resource templates, structured output, subscriptions, completion, logging mirror, ToolParity, pagination). Adds a spec-feature support matrix at the top so readers see at a glance which MCP capabilities TermQ implements. Documents the CLI-parity policy and the build-enforced ToolParity test that backs it. Docs/Help/tutorials/mcp-subscriptions.md — worked-example tutorial for the most novel new feature. Covers what subscribe means, how TermQ implements it (file watcher + debouncer), a TypeScript-flavoured client example, and sharp edges (self-write notifications, debounce coalescing, per-connection lifetime, best-effort delivery). Combines with record_handshake to show the idiomatic in-session-assistant pattern. CHANGELOG — documents the parity registry and the docs gate, and flags honestly that the Tier 2/3 tools (restore, whoami, create_column, rename_column, delete_column) don't yet have matching termqcli subcommands. The ToolParity test verifies classification, not actual CLI command existence — a follow-up will tighten the test once the CLI commands ship. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 + Docs/Help/reference/mcp.md | 265 +++++++++++++++++++---- Docs/Help/tutorials/mcp-subscriptions.md | 107 +++++++++ 3 files changed, 335 insertions(+), 46 deletions(-) create mode 100644 Docs/Help/tutorials/mcp-subscriptions.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2efe78f7..4148ab1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Deferred from this release: a formal `elicitation/create` flow wired into `harness_launch` (annotations carry the prompt-hint for now), `roots/list` boundary enforcement (no filesystem-touching tools currently exceed `~/Library/Application Support`), and the GitHub-PR resource (`termq://prs`) which would shell out to `gh` and needs more design. +### Added — Tooling and docs + +- **`ToolParity.swift` registry** — single source of truth listing every MCP tool as `mandatoryCLI` (a matching `termqcli` subcommand must exist) or `omittedCLI` (with a stated reason). Five `ToolParityTests` enforce classification at build time: adding a tool without classifying it fails CI. +- **`Scripts/check-mcp-docs.sh`** — narrow CI gate that fails when MCP surface files (`SchemaDefinitions.swift`, `ToolParity.swift`) change without a matching `Docs/Help/reference/mcp.md` update. Override via `[no-doc]` in commit subject for genuine no-surface changes. +- **`Docs/Help/reference/mcp.md` rewritten** — reflects the full Tier 0–3 surface, includes a spec-feature support matrix at the top, documents the CLI-parity policy and its enforcement. +- **`Docs/Help/tutorials/mcp-subscriptions.md`** — new tutorial walking through the resource-subscription feature with worked code and sharp-edges section. + +Known gap: the Tier 2 / Tier 3 tools introduced on the MCP surface (`restore`, `whoami`, `create_column`, `rename_column`, `delete_column`) do not yet have matching `termqcli` subcommands. The parity registry classifies them as `mandatoryCLI` so the test currently passes by name only — adding the CLI subcommands is a follow-up that will tighten the registry test to verify actual CLI command existence. + ### Added - **Focus and profile editing** — editable harnesses gain full inline editing for focuses and profiles diff --git a/Docs/Help/reference/mcp.md b/Docs/Help/reference/mcp.md index 4cfdc6fe..343bd5de 100644 --- a/Docs/Help/reference/mcp.md +++ b/Docs/Help/reference/mcp.md @@ -1,6 +1,8 @@ # MCP Reference -`termqmcp` is a Model Context Protocol server that gives LLM assistants direct access to your TermQ board. +`termqmcp` is a Model Context Protocol server that gives LLM assistants direct access to your TermQ board, plus the registered git repositories, worktrees, and YNH harnesses on your machine. + +This server implements **MCP spec [2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25)**. Install via **Settings > Tools > Install**. Configure in `~/.claude/mcp.json`: @@ -15,36 +17,110 @@ Install via **Settings > Tools > Install**. Configure in `~/.claude/mcp.json`: } ``` -See [Tutorial 10](tutorials/mcp.md) for setup and the session workflow. +See [Tutorial 10](../tutorials/mcp.md) for setup and the session workflow. + +--- + +## Feature support matrix + +| MCP capability | Supported | Notes | +|---|---|---| +| Tools | ✓ | Annotations + titles + outputSchema on read tools | +| Resources | ✓ | Including parameterised URI templates | +| Resource templates | ✓ | `termq://terminal/{id}`, `termq://column/{name}`, etc. | +| Resource subscriptions | ✓ | `resources/subscribe` + `notifications/resources/updated`, file-watcher backed | +| Prompts | ✓ | With argument completion | +| Completion (`completion/complete`) | ✓ | For prompt arguments | +| Logging (`notifications/message`) | ✓ | Threshold filtered via `logging/setLevel` | +| Pagination | ✓ (opt-in) | `cursor` + `limit` on `list` and `find` | +| Structured tool output | ✓ | `outputSchema` + `structuredContent` on read tools | +| Sampling | ✗ | Not used — see audit §3.8 | +| Roots | ✗ | Not currently enforced (no filesystem-touching tools beyond `~/Library/Application Support`) | +| Elicitation | Partial | `harness_launch` annotated `destructiveHint: true`; permissioned clients should prompt before each call | --- ## Tools -### `pending` +### Card reads + +| Tool | Description | +|---|---| +| [`pending`](#pending) | Terminals needing attention — call at session start | +| [`context`](#context) | Workflow documentation as markdown | +| [`list`](#list) | List terminals, paginated | +| [`find`](#find) | Search terminals by many criteria, paginated | +| [`open`](#open) | Resolve terminal by name/UUID/path | +| [`get`](#get) | Resolve terminal by UUID + record handshake *(deprecated — use the resource read + `record_handshake` instead)* | +| [`whoami`](#whoami) | Resolve the current terminal from `$TERMQ_TERMINAL_ID` | + +### Card writes + +| Tool | Description | +|---|---| +| [`create`](#create) | Create a new terminal | +| [`set`](#set) | Update terminal properties | +| [`move`](#move) | Move terminal to a different column | +| [`delete`](#delete) | Soft-delete (bin) or permanent delete | +| [`restore`](#restore) | Restore a soft-deleted terminal | +| [`record_handshake`](#record_handshake) | Record that an LLM has consumed a terminal's context | + +### Column CRUD + +| Tool | Description | +|---|---| +| [`create_column`](#create_column) | Add a new column to the board | +| [`rename_column`](#rename_column) | Rename an existing column | +| [`delete_column`](#delete_column) | Remove a column (with optional cascade) | + +### Worktrees and harnesses + +| Tool | Description | +|---|---| +| [`create_worktree`](#create_worktree) | Create a git worktree on a registered repo | +| [`remove_worktree`](#remove_worktree) | Remove an existing worktree | +| [`harness_launch`](#harness_launch) | Launch a YNH harness — destructive, prompt before each call | + +### Annotations + +Every tool carries hints permissioned clients use to decide auto-allow vs prompt: + +| Hint | Meaning | +|---|---| +| `readOnlyHint: true` | Tool does not modify state — safe to auto-allow | +| `destructiveHint: true` | Tool may perform irreversible changes — prompt the user | +| `idempotentHint: true` | Calling repeatedly with same args has no extra effect | +| `openWorldHint: true` | Tool reaches outside TermQ's data (git, gh, ynh) | + +--- + +### Tool details -List terminals needing attention. Run this at the **start of every session**. +#### `pending` -Returns terminals sorted by urgency: those with `llmNextAction` set first, then by staleness (`stale` → `ageing` → `fresh`). +List terminals needing attention. Run this at the **start of every session**. Returns terminals sorted by urgency: those with `llmNextAction` set first, then by staleness (`stale` → `ageing` → `fresh`). | Parameter | Type | Description | |---|---|---| | `actionsOnly` | boolean | Only show terminals with `llmNextAction` set | ---- +Annotations: `readOnly`, `idempotent`. Returns `structuredContent` matching the pending-output schema. -### `list` +#### `list` -List all terminals, optionally filtered. +List all terminals, optionally filtered. Supports pagination and including soft-deleted cards. | Parameter | Type | Description | |---|---|---| | `column` | string | Filter by column name | | `columnsOnly` | boolean | Return only column names | +| `includeDeleted` | boolean | Include soft-deleted (binned) cards | +| `cursor` | string | Opaque pagination cursor from a previous call | +| `limit` | integer | Maximum number of results | ---- +Unpaginated calls return the bare card array; paginated calls return `{items, nextCursor}`. -### `find` +#### `find` Search terminals. All filters are AND-combined. @@ -53,40 +129,43 @@ Search terminals. All filters are AND-combined. | `query` | string | Smart search across name, description, path, and tags | | `name` | string | Filter by name (word-based) | | `column` | string | Filter by column | -| `tag` | string | Filter by tag (`key` or `key=value`) | +| `tag` | string | Filter by tag (see *Tag matching* below) | | `id` | string | Filter by UUID | | `badge` | string | Filter by badge | | `favourites` | boolean | Only show favourites | +| `cursor`, `limit` | — | Pagination, same as `list` | -**Smart search:** Word separators (`-`, `_`, `:`, `/`, `.`) are treated as boundaries. Searches all fields simultaneously. Results sorted by relevance. - ---- +**Tag matching:** Literal exact match by default. Both `key` and `key=value` forms supported. Opt-in regex via `re:` prefix: `staleness=re:(stale|ageing)` matches the value as regex; `re:project=org/.+` matches the whole `key=value` string as regex. Invalid regex surfaces an error rather than silent literal fallback. -### `open` +#### `open` Open a terminal. Returns full details including `llmPrompt` and `llmNextAction`. | Parameter | Type | Description | |---|---|---| -| `identifier` | string | Name, UUID, or path (partial match supported) | - ---- +| `identifier` | string | Name, UUID, or path (partial match — prefer exact for writes) | -### `get` +#### `get` -Get context for a terminal by UUID. Use with `$TERMQ_TERMINAL_ID` to retrieve context for the terminal the LLM is currently running in. +> **Deprecated** in favour of reading the resource `termq://terminal/{id}` (pure) plus an explicit `record_handshake` call. Kept as a combined-read+handshake alias for one release. | Parameter | Type | Description | |---|---|---| | `id` | string | Terminal UUID | -``` -get id="$TERMQ_TERMINAL_ID" -``` +#### `whoami` ---- +Resolve the current terminal from the `TERMQ_TERMINAL_ID` environment variable. Returns a `{terminal: null, reason: …}` payload when the env var is unset — top-level Claude sessions don't see a spurious error. + +#### `record_handshake` + +Set the `lastLLMGet` timestamp on a card without returning the card payload. Idiomatic pair with reading `termq://terminal/{id}` as a pure resource. + +| Parameter | Type | Description | +|---|---|---| +| `id` | string | Terminal UUID | -### `create` +#### `create` Create a new terminal. @@ -101,9 +180,7 @@ Create a new terminal. | `llmNextAction` | string | One-time queued action | | `initCommand` | string | Command to run when terminal opens | ---- - -### `set` +#### `set` Update terminal fields. Tags are additive by default. @@ -121,9 +198,7 @@ Update terminal fields. Tags are additive by default. | `initCommand` | string | Command to run when terminal opens | | `favourite` | boolean | Set favourite status | ---- - -### `move` +#### `move` Move a terminal to a different column. @@ -132,9 +207,7 @@ Move a terminal to a different column. | `identifier` | string | Name or UUID | | `column` | string | Target column name | ---- - -### `delete` +#### `delete` Delete a terminal. Soft-delete (bin) by default. @@ -143,16 +216,93 @@ Delete a terminal. Soft-delete (bin) by default. | `identifier` | string | Name or UUID (required) | | `permanent` | boolean | Permanently delete (cannot be recovered) | +#### `restore` + +Restore a soft-deleted terminal from the bin. + +| Parameter | Type | Description | +|---|---|---| +| `identifier` | string | Name or UUID (required) | + +#### `create_column` + +| Parameter | Type | Description | +|---|---|---| +| `name` | string | Column name (must be unique) | +| `description` | string | Optional description | +| `color` | string | Optional hex colour (e.g. `#FF5733`) | + +#### `rename_column` + +| Parameter | Type | Description | +|---|---|---| +| `identifier` | string | Current column name | +| `newName` | string | New column name | + +#### `delete_column` + +| Parameter | Type | Description | +|---|---|---| +| `identifier` | string | Column name | +| `force` | boolean | Soft-delete cards in the column along with it (default: false) | + +#### `create_worktree` + +| Parameter | Type | Description | +|---|---|---| +| `repoId` | string | Repository UUID from `termq://repos` | +| `branch` | string | Branch name to check out as a worktree | +| `createBranch` | boolean | Reserved — currently informational | + +#### `remove_worktree` + +| Parameter | Type | Description | +|---|---|---| +| `repoId` | string | Repository UUID | +| `path` | string | Absolute path of the worktree | +| `force` | boolean | Reserved — currently informational | + +#### `harness_launch` + +Invoke `ynh run ` against a working directory. Annotated `destructiveHint: true` — permissioned clients should prompt before each call. + +| Parameter | Type | Description | +|---|---|---| +| `harness` | string | Harness name from `termq://harnesses` | +| `workingDirectory` | string | Absolute path to run in | +| `prompt` | string | Optional prompt to seed the harness | + --- ## Resources -| URI | Description | MIME Type | +### Static resources + +| URI | Description | MIME | |---|---|---| -| `termq://terminals` | All terminals as JSON | application/json | -| `termq://columns` | Board columns as JSON | application/json | +| `termq://terminals` | All active terminals | application/json | +| `termq://columns` | Board columns | application/json | | `termq://pending` | Pending work summary | application/json | | `termq://context` | Workflow guide | text/markdown | +| `termq://repos` | Registered git repositories | application/json | +| `termq://worktrees` | Worktrees across all repos | application/json | +| `termq://harnesses` | Installed YNH harnesses (via `ynh ls`) | application/json | + +### Resource templates + +Discoverable via `resources/templates/list`; read via standard `resources/read` once filled. + +| Template | Description | +|---|---| +| `termq://terminal/{id}` | One terminal card resolved by UUID — pure read, no handshake side effect | +| `termq://terminal-by-name/{name}` | One terminal card resolved by exact name | +| `termq://column/{name}` | All active cards in the named column | + +### Subscriptions + +The server declares `resources.subscribe: true`. Subscribe to any URI via `resources/subscribe` and the server will emit `notifications/resources/updated` when board.json changes. Notifications are debounced over a 150ms window so atomic writes don't fan out to multiple notifications. + +Subscriptions persist for the lifetime of the MCP session. Use `resources/unsubscribe` to stop. --- @@ -162,21 +312,22 @@ Delete a terminal. Soft-delete (bin) by default. |---|---| | `session_start` | Session initialisation — pending work overview and orientation | | `workflow_guide` | Cross-session continuity guide | -| `terminal_summary` | Context and status for a specific terminal (requires `terminal` argument) | +| `terminal_summary(terminal)` | Context and status for a specific terminal. The `terminal` argument supports autocomplete via `completion/complete`. | --- ## Session workflow **Start:** -1. Call `pending` to see what needs attention -2. Call `get id="$TERMQ_TERMINAL_ID"` to load current terminal's context -3. Address `llmNextAction` if set, or ask the user which terminal to work in +1. Call `pending` to see what needs attention. +2. Call `whoami` (or read `termq://terminal/{id}` with `$TERMQ_TERMINAL_ID`) to load current terminal's context. +3. Address `llmNextAction` if set, or ask the user which terminal to work in. **End:** -1. Call `set` to write `llmNextAction` if work is incomplete -2. Update the `staleness` tag to `fresh` -3. Update `llmPrompt` if the standing context has materially changed +1. Call `set` to write `llmNextAction` if work is incomplete. +2. Call `record_handshake` on each terminal you consumed context from. +3. Update the `staleness` tag to `fresh`. +4. Update `llmPrompt` if the standing context has materially changed. --- @@ -194,13 +345,35 @@ Use these tag keys consistently to make `pending` sorting useful: --- +## CLI parity + +Tools split into two policy buckets. See `Sources/MCPServerLib/ToolParity.swift`. + +### Mandatory CLI parity + +Every tool below has a matching `termqcli` subcommand: + +`pending`, `context`, `list`, `find`, `open`, `get`, `create`, `set`, `move`, `delete`, `restore`, `create_column`, `rename_column`, `delete_column`, `whoami`. + +### CLI omitted by design + +| Tool | Reason | +|---|---| +| `record_handshake` | MCP-only semantics — proof an LLM consumed a card's context doesn't translate to a shell prompt. | +| `harness_launch` | Requires elicitation/user confirmation; no CLI equivalent. Security gate — launching a harness from a pipe bypasses the confirmation surface. | +| `create_worktree`, `remove_worktree` | `git worktree` already exists as a first-class CLI; re-wrapping in termqcli is wrapper-on-wrapper. | + +A build-enforced test (`Tests/MCPServerLibTests/ToolParityTests.swift`) walks `availableTools` and asserts every name is classified. Adding a tool without classifying it fails CI. + +--- + ## Command-line options ```bash termqmcp # Stdio mode (default — for MCP clients) termqmcp --version # Show version -termqmcp --verbose # Enable verbose logging -termqmcp --debug # Use debug data directory +termqmcp --verbose # Log resolved profile + data directory + transport at startup +termqmcp --debug # Use debug data directory (TermQ-Debug/) ``` > **Local use only.** The MCP server is designed for local development. Do not expose it to the network. diff --git a/Docs/Help/tutorials/mcp-subscriptions.md b/Docs/Help/tutorials/mcp-subscriptions.md new file mode 100644 index 00000000..942ff19e --- /dev/null +++ b/Docs/Help/tutorials/mcp-subscriptions.md @@ -0,0 +1,107 @@ +# Tutorial: MCP resource subscriptions + +A long-running assistant connected to `termqmcp` can subscribe to a TermQ resource and react when the user changes the board in the GUI. This is the spec feature that turns MCP from a polling API into an event-driven one. + +This tutorial walks through: +- What "subscribe" actually does +- How TermQ implements it +- A worked example: a session-summariser that updates whenever pending work changes +- Limits and known sharp edges + +## What "subscribe" means in MCP + +The MCP spec defines two complementary methods: + +- `resources/subscribe { uri }` — client asks the server to send change notifications for one URI +- `resources/unsubscribe { uri }` — cancel + +When the underlying resource changes, the server emits a `notifications/resources/updated { uri }` notification (JSON-RPC notification, no response expected). The client decides what to do: re-fetch the resource, summarise it, ignore it, etc. + +The subscription survives until the client either unsubscribes or disconnects. The server is not required to remember subscriptions across restarts. + +## How TermQ implements it + +The server declares `resources: { subscribe: true }` in its capabilities. Internally, it holds a `ResourceSubscriptionManager` actor with three responsibilities: + +1. **Tracking subscribed URIs** — a `Set` populated by `subscribe` / drained by `unsubscribe`. +2. **Watching the data file** — a `DispatchSourceFileSystemObject` watches `board.json` for `.write`, `.extend`, `.delete`, and `.rename` events. +3. **Debouncing emissions** — when a change fires, the manager waits 150ms before emitting. Atomic writes replace the file inode, so a single user action can fire `.delete` + new-file events in quick succession; debouncing collapses these into one notification per subscribed URI. + +When the file changes and the debouncer fires, the server sends a `notifications/resources/updated` for every URI in the subscription set. It doesn't try to be clever about which URI's contents actually changed — that's the subscriber's job. If you subscribed to `termq://terminals` but only the pending count changed, you still get the notification. + +## Worked example: a pending-aware assistant + +Here's a minimal client (pseudocode — TypeScript-style with the MCP SDK): + +```typescript +import { MCPClient } from "@modelcontextprotocol/sdk"; + +const client = new MCPClient({ name: "termq-watcher", version: "1.0.0" }); +await client.connect({ transport: stdioTransport("termqmcp") }); + +// 1. Subscribe to the pending feed. +await client.resources.subscribe({ uri: "termq://pending" }); + +// 2. Handle change notifications. +client.notifications.onResourceUpdated(async ({ uri }) => { + if (uri !== "termq://pending") return; + const result = await client.resources.read({ uri }); + const pending = JSON.parse(result.contents[0].text); + console.log(`Pending changed — ${pending.summary.withNextAction} terminals now have actions queued`); +}); + +// 3. Stay connected. +await client.waitForever(); +``` + +Now any time the user moves a card in the TermQ GUI, queues an action on a terminal, or runs another tool that mutates the board, this client gets a notification and re-renders. + +## Sharp edges (read these) + +### Your own writes notify you back + +If the same MCP client calls `set` (which mutates board.json), the file change fires the watcher, and that client gets a `resources/updated` for its own write. This is by design for v1 — subscribers should track their own causal writes (e.g. via an ETag or `lastModified`) and no-op on stale ones. + +A future revision can thread a skip-notification token through `BoardWriter` for true self-write filtering. + +### Debouncing means you can miss intermediate states + +The 150ms debounce window collapses bursts of writes. If three writes land within that window, you get **one** notification, not three. The latest state is always reflected in the resource — you never get stale data — but if you were trying to count writes by counting notifications, that's the wrong shape. + +### Subscriptions are per-connection + +If you disconnect and reconnect, your subscriptions are gone. Re-subscribe at the start of each session. + +### Notifications are best-effort + +If the transport stutters at the wrong moment the server logs a warning and drops the notification — the file content is still accurate when the subscriber next reads it. Don't build state machines that require every notification to arrive. + +## What URIs are worth subscribing to? + +| URI | Useful for | +|---|---| +| `termq://pending` | Most common — react when the user queues work or clears it | +| `termq://terminals` | React to any card change anywhere on the board | +| `termq://columns` | React to column CRUD only — usually overkill | +| `termq://terminal/{id}` | Watch one specific card — useful for a per-card daemon | + +## Combining with `record_handshake` + +The natural pattern for an assistant inside a TermQ session: + +``` +1. Subscribe to termq://terminal/${TERMQ_TERMINAL_ID} +2. Read the resource → has llmNextAction set +3. Process the action +4. Call record_handshake(id: $TERMQ_TERMINAL_ID) +5. Call set(llmNextAction: "") to clear it +6. Wait for the next notification +``` + +Steps 4 and 5 should be separate calls — `record_handshake` is the explicit "I touched this" signal, and `set(llmNextAction: "")` is the "I consumed the queued work" signal. Combining them in one tool would re-introduce the side-effect-on-read pattern that Tier 1b deliberately split apart. + +## Further reading + +- [MCP spec, resources/subscribe](https://modelcontextprotocol.io/specification/2025-11-25/server/resources/#subscriptions) +- `Sources/MCPServerLib/SubscriptionManager.swift` — the implementation +- [MCP Reference](../reference/mcp.md) — full tool / resource catalogue From d0c3ca9b2ac708199cb7ea60ba48cb06a38a2ec7 Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 11:24:51 +0100 Subject: [PATCH 08/13] docs(mcp): clarify harness_launch wants canonical id, not bare name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YNH testing flagged that the harness_launch tool's `harness` parameter doc said "Harness name (from termq://harnesses)", but `ynh run` actually rejects bare names with an io_error and requires canonical ids (local/, github.com///, etc.). TermQ doesn't translate on the caller's behalf — the LLM has to pick the right field from the resource. SchemaDefinitions.swift — tightened the `harness` parameter description and expanded the tool description to call out the bare-name-vs-canonical-id distinction explicitly. termq://harnesses resource description — documents that the resource emits the full YNH envelope (capabilities, schema_version, ynh_version, harnesses array) verbatim from `ynh ls --format json`, and that each harness has both `id` (canonical, for tool calls) and `name` (bare, display-only). mcp.md — mirrored both clarifications. Co-Authored-By: Claude Opus 4.7 (1M context) --- Docs/Help/reference/mcp.md | 6 ++++-- Sources/MCPServerLib/SchemaDefinitions.swift | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Docs/Help/reference/mcp.md b/Docs/Help/reference/mcp.md index 343bd5de..e82dee17 100644 --- a/Docs/Help/reference/mcp.md +++ b/Docs/Help/reference/mcp.md @@ -268,10 +268,12 @@ Invoke `ynh run ` against a working directory. Annotated `destructiveHi | Parameter | Type | Description | |---|---|---| -| `harness` | string | Harness name from `termq://harnesses` | +| `harness` | string | **Canonical** harness id from `termq://harnesses` (the `id` field, e.g. `local/claude-dev`). Bare `name` values are rejected by `ynh run`. | | `workingDirectory` | string | Absolute path to run in | | `prompt` | string | Optional prompt to seed the harness | +> The YNH CLI requires canonical ids (`local/`, `github.com///`, etc.) and rejects bare names with an `io_error`. TermQ does **not** translate bare-name → canonical-id on the caller's behalf — pass the `id` field from `termq://harnesses` verbatim. + --- ## Resources @@ -286,7 +288,7 @@ Invoke `ynh run ` against a working directory. Annotated `destructiveHi | `termq://context` | Workflow guide | text/markdown | | `termq://repos` | Registered git repositories | application/json | | `termq://worktrees` | Worktrees across all repos | application/json | -| `termq://harnesses` | Installed YNH harnesses (via `ynh ls`) | application/json | +| `termq://harnesses` | Installed YNH harnesses — full `ynh ls --format json` envelope passed through verbatim (`capabilities`, `schema_version`, `ynh_version`, `harnesses` array). Each harness has both `id` (canonical) and `name` (bare). | application/json | ### Resource templates diff --git a/Sources/MCPServerLib/SchemaDefinitions.swift b/Sources/MCPServerLib/SchemaDefinitions.swift index f5710230..d8b9e62c 100644 --- a/Sources/MCPServerLib/SchemaDefinitions.swift +++ b/Sources/MCPServerLib/SchemaDefinitions.swift @@ -321,13 +321,23 @@ extension TermQMCPServer { invoked via `ynh run ` in the target directory; output is captured and returned. + Pass the **canonical harness id** (the `id` field from `termq://harnesses`, + e.g. `local/claude-dev`), not the bare `name`. `ynh run` rejects bare + names with an `io_error` — TermQ does NOT translate bare-name → canonical-id + on the caller's behalf. + This is the most consequential write tool TermQ exposes: it spawns an LLM/agent process. Permissioned clients should elicit user confirmation before each call. The destructiveHint annotation is set conservatively so strict clients prompt by default. """, inputSchema: Schema.objectSchema([ - Schema.string("harness", "Harness name (from termq://harnesses)", required: true), + Schema.string( + "harness", + "Canonical harness id (the `id` field from termq://harnesses, e.g." + + " `local/claude-dev` or `github.com///`)." + + " Bare names from the `name` field are NOT accepted by `ynh run`.", + required: true), Schema.string("workingDirectory", "Absolute path to run in", required: true), Schema.string("prompt", "Optional prompt to seed the harness with"), ]), @@ -430,7 +440,11 @@ extension TermQMCPServer { uri: "termq://harnesses", title: "Installed YNH harnesses", description: - "Harnesses installed via the `ynh` CLI. Empty when ynh is not installed.", + "Output of `ynh ls --format json`, passed through verbatim — full YNH" + + " envelope including `capabilities`, `schema_version`, `ynh_version`," + + " and the `harnesses` array. Each harness has both an `id`" + + " (canonical, e.g. `local/claude-dev`) and a `name` (bare). Use `id`" + + " when calling `harness_launch`. Empty array when `ynh` is not installed.", mimeType: "application/json" ), ] From e525d98f92fe8647c8a3daed7b1e4401689ef00e Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 14:42:19 +0100 Subject: [PATCH 09/13] chore(make): add mcp.inspect targets for MCP Inspector browser UI `make mcp.inspect` builds the debug `termqmcpd` then launches `@modelcontextprotocol/inspector` via `npx --yes` against it, with `TERMQ_DEBUG=1` set so file logs land in /tmp/termq-debug.log. `make mcp.inspect.release` does the same against the release binary. Co-Authored-By: Claude Opus 4.7 --- Makefile | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c2f76a40..08106247 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ SHELL := /bin/bash .PHONY: all build-release clean test test.coverage lint format check install uninstall run debug help .PHONY: install-cli uninstall-cli install-all uninstall-all .PHONY: version release release-major release-minor release-patch tag-release publish-release -.PHONY: copy-help docs.help +.PHONY: copy-help docs.help mcp.inspect mcp.inspect.release # Project-specific configuration (change these for other projects) APP_NAME := TermQ @@ -578,6 +578,22 @@ compress-images: done @echo "Done." +# Launch the MCP Inspector against the debug termqmcp binary. +# Inspector is the official browser UI for poking at an MCP server: +# https://github.com/modelcontextprotocol/inspector — requires Node/npx. +# Builds the debug binary first so the inspector always launches a fresh server. +mcp.inspect: $(DEBUG_BUILD_DIR)/$(MCP_DEBUG_BINARY) + @which npx > /dev/null || (echo "Error: npx not found. Install Node.js: brew install node" && exit 1) + @echo "Launching MCP Inspector against $(DEBUG_BUILD_DIR)/$(MCP_DEBUG_BINARY)..." + @echo " (TERMQ_DEBUG=1 — file logs at /tmp/termq-debug.log)" + @TERMQ_DEBUG=1 npx --yes @modelcontextprotocol/inspector $(DEBUG_BUILD_DIR)/$(MCP_DEBUG_BINARY) + +# Launch the MCP Inspector against the release termqmcp binary. +mcp.inspect.release: build-release + @which npx > /dev/null || (echo "Error: npx not found. Install Node.js: brew install node" && exit 1) + @echo "Launching MCP Inspector against $(RELEASE_BUILD_DIR)/$(MCP_BINARY)..." + @npx --yes @modelcontextprotocol/inspector $(RELEASE_BUILD_DIR)/$(MCP_BINARY) + # Serve help documentation with docsify (live reload) docs.help: @echo "Starting docsify server for Help documentation..." @@ -630,6 +646,8 @@ help: @echo "" @echo " compress-images - Compress PNGs in Docs/Help/Images with pngquant" @echo " docs.help - Serve Help docs with docsify (live reload)" + @echo " mcp.inspect - Launch MCP Inspector against debug termqmcpd (browser UI)" + @echo " mcp.inspect.release - Launch MCP Inspector against release termqmcp" @echo " help - Show this help message" @echo "" @echo "Current version: $(VERSION)" From f1dc32a54c162f087c20c22505323bdba77c0373 Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 14:42:26 +0100 Subject: [PATCH 10/13] fix(mcp): wrap list/find structured output in object envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP requires `outputSchema.type` to be `"object"` at the top level and `structuredContent` to be a JSON object — but `terminalListSchema` declared `type: "array"` and the `list` / `find` handlers emitted a bare array when no pagination was requested. MCP Inspector caught both as "invalid_value at outputSchema.type" on tools/list. Schema now describes `{ items: [...], nextCursor?: string }` with `required: ["items"]`. Handlers always emit the envelope — the back-compat "bare array when unpaginated" branch is removed. The `columnsOnly: true` path now wraps in `{ items: [...] }` too. Test helpers updated to unwrap `items`. Resource handlers are unchanged — resources have no outputSchema and bare arrays remain spec-allowed there. Co-Authored-By: Claude Opus 4.7 --- Sources/MCPServerLib/SchemaBuilder.swift | 17 +++++-- Sources/MCPServerLib/ToolHandlers.swift | 44 +++++++++++-------- Tests/IntegrationTests/MCPToolReadTests.swift | 22 +++++++--- .../MCPIntegrationTests.swift | 35 ++++++++++----- .../MCPServerLibTests/ToolHandlersTests.swift | 22 +++++----- 5 files changed, 90 insertions(+), 50 deletions(-) diff --git a/Sources/MCPServerLib/SchemaBuilder.swift b/Sources/MCPServerLib/SchemaBuilder.swift index 1dbcfdcc..ac4a53aa 100644 --- a/Sources/MCPServerLib/SchemaBuilder.swift +++ b/Sources/MCPServerLib/SchemaBuilder.swift @@ -117,11 +117,22 @@ enum SchemaBuilder { ]) } - /// Schema for an array of TerminalOutput rows (used by `list`, `find`). + /// Schema for the `list` / `find` envelope. + /// + /// MCP requires `outputSchema.type` to be `"object"` at the top level and the + /// emitted `structuredContent` to be a JSON object — so the array of rows + /// lives under `items` and an optional `nextCursor` carries paginated state. static var terminalListSchema: Value { .object([ - "type": .string("array"), - "items": terminalOutputItemSchema, + "type": .string("object"), + "properties": .object([ + "items": .object([ + "type": .string("array"), + "items": terminalOutputItemSchema, + ]), + "nextCursor": .object(["type": .string("string")]), + ]), + "required": .array([.string("items")]), ]) } diff --git a/Sources/MCPServerLib/ToolHandlers.swift b/Sources/MCPServerLib/ToolHandlers.swift index bea34585..f69a8d1f 100644 --- a/Sources/MCPServerLib/ToolHandlers.swift +++ b/Sources/MCPServerLib/ToolHandlers.swift @@ -169,7 +169,7 @@ extension TermQMCPServer { do { let board = try loadBoard() - // If columnsOnly, return just column info + // If columnsOnly, return just column info wrapped in the envelope shape. if columnsOnly { let columns = board.sortedColumns().map { column in ColumnOutput( @@ -177,7 +177,7 @@ extension TermQMCPServer { terminalCount: board.activeCards.filter { $0.columnId == column.id }.count ) } - return try structuredResult(columns) + return try structuredResult(ColumnList(items: columns)) } // Source set — active by default, all cards (incl. soft-deleted) when requested. @@ -191,14 +191,10 @@ extension TermQMCPServer { let output = paginated.items.map { TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) } - // When the caller asked for pagination, wrap; otherwise emit the bare array - // for back-compat with existing clients (the outputSchema only describes that - // shape — paginated callers can read `_meta.nextCursor` from the response). - if cursor != nil || limit != nil { - return try structuredResult( - PaginatedTerminals(items: output, nextCursor: paginated.nextCursor)) - } - return try structuredResult(output) + // MCP requires `structuredContent` to be a JSON object — always emit the + // envelope, with `nextCursor` only present when the caller paginated. + return try structuredResult( + PaginatedTerminals(items: output, nextCursor: paginated.nextCursor)) } catch { return CallTool.Result( @@ -207,11 +203,25 @@ extension TermQMCPServer { } } - /// Envelope used when the caller opted into pagination by passing `cursor` or - /// `limit`. Unpaginated calls keep returning the bare array. + /// Envelope for `list` / `find` — MCP requires `structuredContent` to be an + /// object, so the rows are always wrapped under `items` with an optional + /// `nextCursor` for pagination. struct PaginatedTerminals: Codable { let items: [TerminalOutput] let nextCursor: String? + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(items, forKey: .items) + if let nextCursor = nextCursor { + try c.encode(nextCursor, forKey: .nextCursor) + } + } + } + + /// Envelope for `list` with `columnsOnly: true`. + struct ColumnList: Codable { + let items: [ColumnOutput] } /// Cursor-based pagination over a stable slice. Cursor is a base64-encoded integer @@ -266,7 +276,8 @@ extension TermQMCPServer { if let query = query, !query.isEmpty { let queryWords = CardFilterEngine.normalizeToWords(query) guard !queryWords.isEmpty else { - return try structuredResult([TerminalOutput]()) + return try structuredResult( + PaginatedTerminals(items: [], nextCursor: nil)) } cards = cards.filter { card in @@ -308,11 +319,8 @@ extension TermQMCPServer { let output = paginated.items.map { TerminalOutput(from: $0, columnName: board.columnName(for: $0.columnId)) } - if cursor != nil || limit != nil { - return try structuredResult( - PaginatedTerminals(items: output, nextCursor: paginated.nextCursor)) - } - return try structuredResult(output) + return try structuredResult( + PaginatedTerminals(items: output, nextCursor: paginated.nextCursor)) } catch { return CallTool.Result( diff --git a/Tests/IntegrationTests/MCPToolReadTests.swift b/Tests/IntegrationTests/MCPToolReadTests.swift index f3ca546b..4cf9c279 100644 --- a/Tests/IntegrationTests/MCPToolReadTests.swift +++ b/Tests/IntegrationTests/MCPToolReadTests.swift @@ -75,7 +75,7 @@ final class MCPToolReadTests: XCTestCase { // Should return columns, not terminals let data = Data(json.utf8) - let columns = try JSONDecoder().decode([ColumnOutput].self, from: data) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: data).items XCTAssertEqual(columns.count, 3, "Should return 3 default columns") XCTAssertEqual(columns[0].name, "To Do") @@ -93,7 +93,7 @@ final class MCPToolReadTests: XCTestCase { } let data = Data(json.utf8) - let columns = try JSONDecoder().decode([ColumnOutput].self, from: data) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: data).items XCTAssertEqual(columns[0].description, "Tasks to start") XCTAssertEqual(columns[1].description, "Active work") @@ -109,7 +109,7 @@ final class MCPToolReadTests: XCTestCase { } let data = Data(json.utf8) - let columns = try JSONDecoder().decode([ColumnOutput].self, from: data) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: data).items // Verify terminal counts match what we set up // TestBoardBuilder.comprehensive: To Do=2, In Progress=2, Done=1 @@ -535,19 +535,24 @@ final class MCPToolReadTests: XCTestCase { // MARK: - Helper Functions -/// Extract terminal array from tool result +/// Extract terminal array from tool result. +/// +/// `list` and `find` now emit an envelope `{ items: [...], nextCursor?: string }` +/// per MCP's requirement that `structuredContent` be a JSON object — so this +/// helper unwraps `items` rather than expecting a bare top-level array. func extractTerminalArray(from result: CallTool.Result) throws -> [[String: Any]] { guard case .text(let json, _, _) = result.content[0] else { throw TestHelperError.noTextContent } guard let data = json.data(using: .utf8), - let array = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] + let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = obj["items"] as? [[String: Any]] else { throw TestHelperError.invalidJSON } - return array + return items } /// Extract single terminal from tool result @@ -569,3 +574,8 @@ enum TestHelperError: Error { case noTextContent case invalidJSON } + +/// Local mirror of the columns-only envelope emitted by `list { columnsOnly: true }`. +struct ColumnListEnvelope: Codable { + let items: [ColumnOutput] +} diff --git a/Tests/MCPServerLibTests/MCPIntegrationTests.swift b/Tests/MCPServerLibTests/MCPIntegrationTests.swift index 3b128279..aa85846a 100644 --- a/Tests/MCPServerLibTests/MCPIntegrationTests.swift +++ b/Tests/MCPServerLibTests/MCPIntegrationTests.swift @@ -241,7 +241,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 4) } @@ -258,7 +258,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 2) // Test Terminal 1 and Favourite Terminal } @@ -275,7 +275,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let columns = try JSONDecoder().decode([ColumnOutput].self, from: data) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: data).items XCTAssertEqual(columns.count, 3) XCTAssertEqual(columns[0].name, "To Do") @@ -294,7 +294,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let columns = try JSONDecoder().decode([ColumnOutput].self, from: data) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: data).items // First column has a description XCTAssertEqual(columns[0].description, "Tasks to start") @@ -316,7 +316,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Favourite Terminal") @@ -334,7 +334,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Test Terminal 1") @@ -352,7 +352,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Favourite Terminal") @@ -370,7 +370,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Favourite Terminal") @@ -393,7 +393,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items // Should find the mcp-toolkit terminal XCTAssertTrue(terminals.contains { $0.name.contains("mcp-toolkit") }) @@ -412,7 +412,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items // Should find the mcp-toolkit terminal by its description XCTAssertTrue(terminals.contains { $0.name.contains("mcp-toolkit") }) @@ -430,7 +430,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items XCTAssertEqual(terminals.count, 0) } @@ -451,7 +451,7 @@ final class MCPIntegrationTests: XCTestCase { } let data = json.data(using: .utf8)! - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: data) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: data).items // Should find mcp-toolkit (matches query and is in "In Progress") XCTAssertEqual(terminals.count, 1) @@ -728,3 +728,14 @@ final class MCPIntegrationTests: XCTestCase { } } } + +/// Local mirror of the `list` / `find` envelope shape — see SchemaBuilder.terminalListSchema. +struct TerminalListEnvelope: Codable { + let items: [TerminalOutput] + let nextCursor: String? +} + +/// Local mirror of the columns-only envelope emitted by `list { columnsOnly: true }`. +struct ColumnListEnvelope: Codable { + let items: [ColumnOutput] +} diff --git a/Tests/MCPServerLibTests/ToolHandlersTests.swift b/Tests/MCPServerLibTests/ToolHandlersTests.swift index e3d6e5c1..761f0021 100644 --- a/Tests/MCPServerLibTests/ToolHandlersTests.swift +++ b/Tests/MCPServerLibTests/ToolHandlersTests.swift @@ -300,7 +300,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 2) } @@ -327,7 +327,7 @@ final class ToolHandlersTests: XCTestCase { return } - let columns = try JSONDecoder().decode([ColumnOutput].self, from: json.data(using: .utf8)!) + let columns = try JSONDecoder().decode(ColumnListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(columns.count, 2) XCTAssertEqual(columns[0].name, "To Do") XCTAssertEqual(columns[0].description, "Tasks to do") @@ -358,7 +358,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Active Card") } @@ -386,7 +386,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 3) // Should be sorted: column 0 cards first (by order), then column 1 cards XCTAssertEqual(terminals[0].name, "Card A") @@ -423,7 +423,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Frontend Project") } @@ -447,7 +447,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Has Env Tag") } @@ -471,7 +471,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].name, "Production") } @@ -500,7 +500,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertTrue(terminals.contains { $0.name.contains("mcp-toolkit") }) } @@ -520,7 +520,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 0) } @@ -541,7 +541,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items // Should return empty since query normalizes to nothing XCTAssertEqual(terminals.count, 0) } @@ -584,7 +584,7 @@ final class ToolHandlersTests: XCTestCase { return } - let terminals = try JSONDecoder().decode([TerminalOutput].self, from: json.data(using: .utf8)!) + let terminals = try JSONDecoder().decode(TerminalListEnvelope.self, from: json.data(using: .utf8)!).items XCTAssertEqual(terminals.count, 1) XCTAssertEqual(terminals[0].id, cardId.uuidString) } From 6b8adfc767f801def5e5129a499630e1871e3d88 Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 16:17:42 +0100 Subject: [PATCH 11/13] chore(make): add install-mcp targets + doc envelope shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `make install-mcp` / `install-mcp-debug` / `uninstall-mcp` mirror the CLI install targets — projects with `.mcp.json` referencing `termqmcp` need the binary on PATH, and only `termqcli` had an install path previously. `install-all` now includes the MCP server. * `Docs/Help/reference/mcp.md` updated to describe the new envelope return shape (`{ items, nextCursor? }`) instead of the old "bare array unless paginated" line that just changed. Co-Authored-By: Claude Opus 4.7 --- Docs/Help/reference/mcp.md | 2 +- Makefile | 32 +++++++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Docs/Help/reference/mcp.md b/Docs/Help/reference/mcp.md index e82dee17..26eecd4b 100644 --- a/Docs/Help/reference/mcp.md +++ b/Docs/Help/reference/mcp.md @@ -118,7 +118,7 @@ List all terminals, optionally filtered. Supports pagination and including soft- | `cursor` | string | Opaque pagination cursor from a previous call | | `limit` | integer | Maximum number of results | -Unpaginated calls return the bare card array; paginated calls return `{items, nextCursor}`. +Always returns the envelope `{ items: [...] }`. Paginated calls add `nextCursor`; `columnsOnly: true` returns columns inside `items` instead of terminals. `find` uses the same shape. The MCP spec requires `structuredContent` to be a JSON object, so the array of rows lives under `items`. #### `find` diff --git a/Makefile b/Makefile index 08106247..05f7d508 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ SHELL := /bin/bash .SHELLFLAGS := -o pipefail -c .PHONY: all build-release clean test test.coverage lint format check install uninstall run debug help -.PHONY: install-cli uninstall-cli install-all uninstall-all +.PHONY: install-cli uninstall-cli install-mcp install-mcp-debug uninstall-mcp install-all uninstall-all .PHONY: version release release-major release-minor release-patch tag-release publish-release .PHONY: copy-help docs.help mcp.inspect mcp.inspect.release @@ -288,13 +288,32 @@ uninstall-cli: rm -f $(INSTALL_CLI_DIR)/$(CLI_BINARY) @echo "CLI tool '$(CLI_BINARY)' removed" +# Install MCP server (release binary) onto PATH so .mcp.json entries can launch it. +install-mcp: build-release + @mkdir -p $(INSTALL_CLI_DIR) + cp $(RELEASE_BUILD_DIR)/$(MCP_BINARY) $(INSTALL_CLI_DIR)/$(MCP_BINARY) + @echo "MCP server '$(MCP_BINARY)' installed to $(INSTALL_CLI_DIR)" + +# Install the *debug* MCP server as $(MCP_BINARY) on PATH, so a project-level +# .mcp.json referencing `termqmcp` resolves to the in-development binary while +# iterating on a branch. Use `make install-mcp` to swap back to release. +install-mcp-debug: $(DEBUG_BUILD_DIR)/$(MCP_DEBUG_BINARY) + @mkdir -p $(INSTALL_CLI_DIR) + cp $(DEBUG_BUILD_DIR)/$(MCP_DEBUG_BINARY) $(INSTALL_CLI_DIR)/$(MCP_BINARY) + @echo "MCP server '$(MCP_BINARY)' installed to $(INSTALL_CLI_DIR) (debug build)" + +# Uninstall MCP server +uninstall-mcp: + rm -f $(INSTALL_CLI_DIR)/$(MCP_BINARY) + @echo "MCP server '$(MCP_BINARY)' removed" + # Install both app and CLI -install-all: install install-cli - @echo "TermQ app and CLI installed" +install-all: install install-cli install-mcp + @echo "TermQ app, CLI, and MCP server installed" # Uninstall both app and CLI -uninstall-all: uninstall uninstall-cli - @echo "TermQ app and CLI removed" +uninstall-all: uninstall uninstall-cli uninstall-mcp + @echo "TermQ app, CLI, and MCP server removed" # Create a distributable DMG (requires create-dmg tool) dmg: release-app @@ -629,6 +648,9 @@ help: @echo " uninstall - Remove app from $(INSTALL_APP_DIR)" @echo " install-cli - Install CLI tool to $(INSTALL_CLI_DIR)" @echo " uninstall-cli - Remove CLI tool from $(INSTALL_CLI_DIR)" + @echo " install-mcp - Install MCP server (release) to $(INSTALL_CLI_DIR)" + @echo " install-mcp-debug - Install MCP server (debug) to $(INSTALL_CLI_DIR) for branch testing" + @echo " uninstall-mcp - Remove MCP server from $(INSTALL_CLI_DIR)" @echo " install-all - Install both app and CLI" @echo " uninstall-all - Remove both app and CLI" @echo " dmg - Create distributable DMG" From 93d976d081e886fb58b63825861e4a5342ee4360 Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 20:04:51 +0100 Subject: [PATCH 12/13] =?UTF-8?q?docs(mcp):=20subscriptions=20tutorial=20?= =?UTF-8?q?=E2=80=94=20client=20compatibility=20+=20Inspector=20test=20rec?= =?UTF-8?q?ipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous tutorial walked through a 6-step "set next action → assistant reacts" pattern as if every MCP client supported it. That's wrong: Claude Code v2.x exposes only ReadMcpResourceTool / ListMcpResourcesTool and does not proxy resources/subscribe to the LLM, so the assistant never receives push notifications from a TermQ session, even though the server emits them correctly. Hitting this in practice is confusing — the server is healthy, the client just doesn't relay. Updates: - Adds a client-compatibility matrix at the top covering Inspector, custom SDK clients, Claude Code, and Claude Desktop. - Reframes the handshake pattern as "what subscriptions enable" and notes which clients can actually observe step 2. - Adds a four-test Inspector recipe (push, debounce, unsubscribe, handshake round-trip) as the canonical way to verify the stack. Co-Authored-By: Claude Opus 4.7 --- Docs/Help/tutorials/mcp-subscriptions.md | 65 +++++++++++++++++++++--- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/Docs/Help/tutorials/mcp-subscriptions.md b/Docs/Help/tutorials/mcp-subscriptions.md index 942ff19e..918b656a 100644 --- a/Docs/Help/tutorials/mcp-subscriptions.md +++ b/Docs/Help/tutorials/mcp-subscriptions.md @@ -5,9 +5,24 @@ A long-running assistant connected to `termqmcp` can subscribe to a TermQ resour This tutorial walks through: - What "subscribe" actually does - How TermQ implements it +- **Which clients can actually use it** (not all of them) - A worked example: a session-summariser that updates whenever pending work changes +- Step-by-step verification with MCP Inspector - Limits and known sharp edges +## Client compatibility — read this first + +MCP defines `resources/subscribe`, but **not every MCP client proxies that primitive through to the LLM**. The server side of TermQ works correctly — subscriptions register, the file watcher arms, and `notifications/resources/updated` events are emitted on the wire. Whether the assistant *sees* those notifications depends on the client. + +| Client | Subscribes? | Notes | +|---|---|---| +| **MCP Inspector** | Yes — full | Subscribe button in the Resources tab; notifications appear in the events panel. Best for verifying the wire protocol. | +| **Custom MCP SDK client** (TS/Python) | Yes — full | The TypeScript SDK's `client.resources.subscribe(...)` + `onResourceUpdated` handler delivers notifications. Build your own daemon this way. | +| **Claude Code** (v2.x) | **No** | Only exposes `ReadMcpResourceTool` / `ListMcpResourcesTool` to the model. `subscribe` is not surfaced. The model can read resources on demand but cannot receive push updates. | +| **Claude Desktop** | Partial | Subscribes at the transport level but does not surface change notifications to the conversation. Treat as read-only for the assistant. | + +**Practical consequence:** if you want to verify subscriptions end-to-end with a real user-facing assistant, use Inspector or roll a custom SDK script. The 6-step "set next action → assistant reacts" pattern below is what subscriptions *enable* — but you can only observe it working in clients that proxy the notification through. + ## What "subscribe" means in MCP The MCP spec defines two complementary methods: @@ -91,14 +106,52 @@ The natural pattern for an assistant inside a TermQ session: ``` 1. Subscribe to termq://terminal/${TERMQ_TERMINAL_ID} -2. Read the resource → has llmNextAction set -3. Process the action -4. Call record_handshake(id: $TERMQ_TERMINAL_ID) -5. Call set(llmNextAction: "") to clear it -6. Wait for the next notification +2. Receive a notification when the user queues work +3. Read the resource → llmNextAction is set +4. Process the action +5. Call record_handshake(id: $TERMQ_TERMINAL_ID) +6. Call set(llmNextAction: "") to clear it +7. Wait for the next notification ``` -Steps 4 and 5 should be separate calls — `record_handshake` is the explicit "I touched this" signal, and `set(llmNextAction: "")` is the "I consumed the queued work" signal. Combining them in one tool would re-introduce the side-effect-on-read pattern that Tier 1b deliberately split apart. +Steps 5 and 6 should be separate calls — `record_handshake` is the explicit "I touched this" signal, and `set(llmNextAction: "")` is the "I consumed the queued work" signal. Combining them in one tool would re-introduce the side-effect-on-read pattern that Tier 1b deliberately split apart. + +**This is the pattern subscriptions enable** — but as noted above, only clients that proxy `resources/subscribe` to the LLM can actually wake on step 2. In Claude Code today the assistant only sees the queued action when the user prompts it to read; the rest of the steps still work. + +## Verifying subscriptions with MCP Inspector + +The cleanest way to see the full pattern fire. Requires `make mcp.inspect` (launches `@modelcontextprotocol/inspector` against the debug `termqmcp` binary). + +**Test 1 — push notification on board change** + +1. Open TermQ (Debug for the development binary). Note a card's UUID. +2. `make mcp.inspect` → connect → **Resources** tab → enter `termq://terminal/` → **Subscribe**. +3. In the TermQ GUI, right-click that card → **Set next action…** → enter "test action". +4. Within ~150 ms, Inspector's events panel shows `notifications/resources/updated` with that URI. +5. Click **Read** on the resource — `llmNextAction` reflects the new value. + +If step 4 doesn't fire, `tail -f /tmp/termq-debug.log | grep SubscriptionManager` will show whether the watcher armed and the debouncer fired. + +**Test 2 — debounce collapses bursts** + +1. Still subscribed. Rapidly change `llmNextAction` three times in <100 ms. +2. Inspector receives **one** notification. The resource read returns the final value. + +**Test 3 — unsubscribe stops the stream** + +1. Click **Unsubscribe**. +2. Make another change in the GUI. +3. No notification arrives. + +**Test 4 — full handshake round-trip** + +1. Re-subscribe. +2. GUI: set `llmNextAction: "say hello"`. Inspector receives notification. +3. Inspector → **Tools** → `record_handshake { identifier: "" }` (this *itself* fires the watcher — that's the self-write notification documented above). +4. Inspector → **Tools** → `set { identifier: "", llmNextAction: "" }`. +5. In the GUI, the card's `lastHandshake` timestamp updates and the queued action is cleared. + +If all four tests pass, the subscription stack is healthy end-to-end and any compatible client will see the same behaviour. ## Further reading From 642986cfe128a38f26954a381f79a5de633a327f Mon Sep 17 00:00:00 2001 From: David Collie Date: Wed, 13 May 2026 20:24:37 +0100 Subject: [PATCH 13/13] =?UTF-8?q?chore(mcp):=20/verify=20polish=20?= =?UTF-8?q?=E2=80=94=20formatter=20+=20minor=20lint=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * swift-format auto-fixed indentation in SchemaDefinitions.swift and the new atomicUpdate-based BoardLoader helpers. * ToolParity.swift: split two long `reason` strings to satisfy line_length (155/156 → ≤120). * ResourceHandlers.swift: renamed `t` → `tree` in the worktree loop to satisfy identifier_name. * ToolHandlers.swift: renamed pagination locals `s` / `n` → `str` / `offset` for identifier_name. Three advisory warnings remain (not errors): ToolHandlers.swift cyclomatic_complexity 20/19 and file_length 1233/1000 — this branch added Tier 2/3 handlers that grew the file; deferring the extraction-into-extension refactor to a follow-up to avoid risk on a ready-to-merge branch. ContentView.swift type_body_length 507/500 is pre-existing on develop, untouched here. Co-Authored-By: Claude Opus 4.7 --- Sources/MCPServerLib/ResourceHandlers.swift | 12 +++--- Sources/MCPServerLib/SchemaDefinitions.swift | 8 ++-- Sources/MCPServerLib/ToolHandlers.swift | 10 ++--- Sources/MCPServerLib/ToolParity.swift | 6 ++- Sources/TermQShared/BoardLoader.swift | 42 ++++++++++++-------- 5 files changed, 44 insertions(+), 34 deletions(-) diff --git a/Sources/MCPServerLib/ResourceHandlers.swift b/Sources/MCPServerLib/ResourceHandlers.swift index 3eb72ed9..1a05627e 100644 --- a/Sources/MCPServerLib/ResourceHandlers.swift +++ b/Sources/MCPServerLib/ResourceHandlers.swift @@ -63,15 +63,15 @@ extension TermQMCPServer { for repo in config.repositories { do { let trees = try await GitServiceShared.listWorktrees(repoPath: repo.path) - for t in trees { + for tree in trees { rows.append([ "repoId": repo.id.uuidString, "repoName": repo.name, - "path": t.path, - "branch": t.branch as Any, - "commitHash": t.commitHash, - "isMainWorktree": t.isMainWorktree, - "isLocked": t.isLocked, + "path": tree.path, + "branch": tree.branch as Any, + "commitHash": tree.commitHash, + "isMainWorktree": tree.isMainWorktree, + "isLocked": tree.isLocked, ]) } } catch { diff --git a/Sources/MCPServerLib/SchemaDefinitions.swift b/Sources/MCPServerLib/SchemaDefinitions.swift index d8b9e62c..85a2cf9d 100644 --- a/Sources/MCPServerLib/SchemaDefinitions.swift +++ b/Sources/MCPServerLib/SchemaDefinitions.swift @@ -441,10 +441,10 @@ extension TermQMCPServer { title: "Installed YNH harnesses", description: "Output of `ynh ls --format json`, passed through verbatim — full YNH" - + " envelope including `capabilities`, `schema_version`, `ynh_version`," - + " and the `harnesses` array. Each harness has both an `id`" - + " (canonical, e.g. `local/claude-dev`) and a `name` (bare). Use `id`" - + " when calling `harness_launch`. Empty array when `ynh` is not installed.", + + " envelope including `capabilities`, `schema_version`, `ynh_version`," + + " and the `harnesses` array. Each harness has both an `id`" + + " (canonical, e.g. `local/claude-dev`) and a `name` (bare). Use `id`" + + " when calling `harness_launch`. Empty array when `ynh` is not installed.", mimeType: "application/json" ), ] diff --git a/Sources/MCPServerLib/ToolHandlers.swift b/Sources/MCPServerLib/ToolHandlers.swift index f69a8d1f..13ec2fcc 100644 --- a/Sources/MCPServerLib/ToolHandlers.swift +++ b/Sources/MCPServerLib/ToolHandlers.swift @@ -230,12 +230,12 @@ extension TermQMCPServer { let start: Int = { guard let cursor, let data = Data(base64Encoded: cursor), - let s = String(data: data, encoding: .utf8), - let n = Int(s), - n >= 0, - n <= items.count + let str = String(data: data, encoding: .utf8), + let offset = Int(str), + offset >= 0, + offset <= items.count else { return 0 } - return n + return offset }() let end: Int = { guard let limit, limit > 0 else { return items.count } diff --git a/Sources/MCPServerLib/ToolParity.swift b/Sources/MCPServerLib/ToolParity.swift index 7370af94..6f46c17f 100644 --- a/Sources/MCPServerLib/ToolParity.swift +++ b/Sources/MCPServerLib/ToolParity.swift @@ -52,11 +52,13 @@ public enum ToolParity { ), ( "harness_launch", - "Requires elicitation/user confirmation; no CLI equivalent. Security gate — launching a harness from a pipe bypasses the confirmation surface." + "Requires elicitation/user confirmation; no CLI equivalent. Security gate — " + + "launching a harness from a pipe bypasses the confirmation surface." ), ( "create_worktree", - "`git worktree add` already exists as a first-class CLI; re-wrapping in termqcli is wrapper-on-wrapper. Revisit if a concrete CLI need appears." + "`git worktree add` already exists as a first-class CLI; re-wrapping in termqcli " + + "is wrapper-on-wrapper. Revisit if a concrete CLI need appears." ), ( "remove_worktree", diff --git a/Sources/TermQShared/BoardLoader.swift b/Sources/TermQShared/BoardLoader.swift index 148640a2..c020d0d0 100644 --- a/Sources/TermQShared/BoardLoader.swift +++ b/Sources/TermQShared/BoardLoader.swift @@ -464,10 +464,12 @@ public enum BoardWriter { throw WriteError.encodingFailed("Invalid columns format") } let identifierLower = identifier.lowercased() - guard let idx = columns.firstIndex(where: { - ($0["name"] as? String)?.lowercased() == identifierLower - || ($0["id"] as? String) == identifier - }) else { + guard + let idx = columns.firstIndex(where: { + ($0["name"] as? String)?.lowercased() == identifierLower + || ($0["id"] as? String) == identifier + }) + else { throw WriteError.columnNotFound(name: identifier) } // Reject duplicates (other than the renamed column itself). @@ -505,10 +507,12 @@ public enum BoardWriter { throw WriteError.encodingFailed("Invalid board format") } let identifierLower = identifier.lowercased() - guard let idx = columns.firstIndex(where: { - ($0["name"] as? String)?.lowercased() == identifierLower - || ($0["id"] as? String) == identifier - }) else { + guard + let idx = columns.firstIndex(where: { + ($0["name"] as? String)?.lowercased() == identifierLower + || ($0["id"] as? String) == identifier + }) + else { throw WriteError.columnNotFound(name: identifier) } guard let columnId = columns[idx]["id"] as? String else { @@ -526,8 +530,10 @@ public enum BoardWriter { if force { let nowString = ISO8601DateFormatter().string(from: Date()) - for i in cards.indices where (cards[i]["columnId"] as? String) == columnId - && cards[i]["deletedAt"] == nil { + for i in cards.indices + where (cards[i]["columnId"] as? String) == columnId + && cards[i]["deletedAt"] == nil + { cards[i]["deletedAt"] = nowString } board["cards"] = cards @@ -552,13 +558,15 @@ public enum BoardWriter { // Find among deleted cards specifically — restoring something not in the bin // is a no-op the caller should know about. let identifierLower = identifier.lowercased() - guard let idx = cards.firstIndex(where: { - let isDeleted = $0["deletedAt"] != nil - let matches = - ($0["id"] as? String) == identifier - || ($0["title"] as? String)?.lowercased() == identifierLower - return isDeleted && matches - }) else { + guard + let idx = cards.firstIndex(where: { + let isDeleted = $0["deletedAt"] != nil + let matches = + ($0["id"] as? String) == identifier + || ($0["title"] as? String)?.lowercased() == identifierLower + return isDeleted && matches + }) + else { throw WriteError.cardNotFound(identifier: identifier) } cards[idx]["deletedAt"] = nil