From 10525d883666525a97ff295bd452d97f488f14db Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Thu, 23 Apr 2026 19:23:41 +0300 Subject: [PATCH] security: Swift MCPRouter adds ToolAnnotations to all 11 tools (cyberMaster H1) Swift is the primary brainlayer transport (.mcp.json.example defaults to socat UNIX-CONNECT:/tmp/brainbar.sock). It shipped with zero tool annotations while the Python server has 12/12. LLM clients couldn't distinguish destructive from read-only tools on the default transport. Now every Swift tool carries readOnlyHint, destructiveHint, idempotentHint, openWorldHint matching the Python server's READ_ONLY / WRITE / WRITE_IDEMPOTENT / DESTRUCTIVE taxonomy. Per cyberMaster OSS audit 2026-04-23, finding H1 in ~/Gits/cyber/docs.local/security/MASTER-findings-2026-04-23.md. --- brain-bar/Sources/BrainBar/MCPRouter.swift | 43 +++++++++++++++++++ .../Tests/BrainBarTests/MCPRouterTests.swift | 43 +++++++++++++++++++ .../SocketIntegrationTests.swift | 30 +++++++++++++ 3 files changed, 116 insertions(+) diff --git a/brain-bar/Sources/BrainBar/MCPRouter.swift b/brain-bar/Sources/BrainBar/MCPRouter.swift index 51aab535..3129726d 100644 --- a/brain-bar/Sources/BrainBar/MCPRouter.swift +++ b/brain-bar/Sources/BrainBar/MCPRouter.swift @@ -448,10 +448,43 @@ final class MCPRouter: @unchecked Sendable { // MARK: - Tool Definitions + private static func toolAnnotations( + readOnly: Bool, + destructive: Bool, + idempotent: Bool, + openWorld: Bool = false + ) -> [String: Any] { + [ + "readOnlyHint": readOnly, + "destructiveHint": destructive, + "idempotentHint": idempotent, + "openWorldHint": openWorld, + ] + } + + nonisolated(unsafe) static let readOnlyAnnotations = toolAnnotations( + readOnly: true, + destructive: false, + idempotent: true + ) + + nonisolated(unsafe) static let writeAnnotations = toolAnnotations( + readOnly: false, + destructive: false, + idempotent: false + ) + + nonisolated(unsafe) static let writeIdempotentAnnotations = toolAnnotations( + readOnly: false, + destructive: false, + idempotent: true + ) + nonisolated(unsafe) static let toolDefinitions: [[String: Any]] = [ [ "name": "brain_search", "description": "Search through past conversations and learnings. Hybrid semantic + keyword search.", + "annotations": MCPRouter.readOnlyAnnotations, "inputSchema": [ "type": "object", "properties": [ @@ -470,6 +503,7 @@ final class MCPRouter: @unchecked Sendable { [ "name": "brain_store", "description": "Save decisions, learnings, mistakes, ideas, todos to memory.", + "annotations": MCPRouter.writeAnnotations, "inputSchema": [ "type": "object", "properties": [ @@ -483,6 +517,7 @@ final class MCPRouter: @unchecked Sendable { [ "name": "brain_recall", "description": "Get current working context, browse sessions, or inspect session details.", + "annotations": MCPRouter.readOnlyAnnotations, "inputSchema": [ "type": "object", "properties": [ @@ -494,6 +529,7 @@ final class MCPRouter: @unchecked Sendable { [ "name": "brain_entity", "description": "Look up a known entity in the knowledge graph.", + "annotations": MCPRouter.readOnlyAnnotations, "inputSchema": [ "type": "object", "properties": [ @@ -505,6 +541,7 @@ final class MCPRouter: @unchecked Sendable { [ "name": "brain_digest", "description": "Ingest raw content (transcripts, docs, articles). Extracts entities, relations, action items.", + "annotations": MCPRouter.writeAnnotations, "inputSchema": [ "type": "object", "properties": [ @@ -516,6 +553,7 @@ final class MCPRouter: @unchecked Sendable { [ "name": "brain_update", "description": "Update, archive, or merge existing memories.", + "annotations": MCPRouter.writeIdempotentAnnotations, "inputSchema": [ "type": "object", "properties": [ @@ -528,6 +566,7 @@ final class MCPRouter: @unchecked Sendable { [ "name": "brain_expand", "description": "Drill into a specific search result. Returns full content + surrounding chunks.", + "annotations": MCPRouter.readOnlyAnnotations, "inputSchema": [ "type": "object", "properties": [ @@ -541,6 +580,7 @@ final class MCPRouter: @unchecked Sendable { [ "name": "brain_tags", "description": "List, search, or suggest tags across the knowledge base.", + "annotations": MCPRouter.readOnlyAnnotations, "inputSchema": [ "type": "object", "properties": [ @@ -551,6 +591,7 @@ final class MCPRouter: @unchecked Sendable { [ "name": "brain_subscribe", "description": "Subscribe an agent to push notifications for matching tags.", + "annotations": MCPRouter.writeAnnotations, "inputSchema": [ "type": "object", "properties": [ @@ -563,6 +604,7 @@ final class MCPRouter: @unchecked Sendable { [ "name": "brain_unsubscribe", "description": "Remove some or all tag subscriptions for an agent.", + "annotations": MCPRouter.writeIdempotentAnnotations, "inputSchema": [ "type": "object", "properties": [ @@ -575,6 +617,7 @@ final class MCPRouter: @unchecked Sendable { [ "name": "brain_ack", "description": "Acknowledge that an agent processed messages through the given chunk rowid.", + "annotations": MCPRouter.writeIdempotentAnnotations, "inputSchema": [ "type": "object", "properties": [ diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index a35cee33..035e8ce3 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -107,6 +107,49 @@ final class MCPRouterTests: XCTestCase { } } + func testEachToolHasExpectedAnnotations() throws { + let router = MCPRouter() + let request: [String: Any] = [ + "jsonrpc": "2.0", + "id": 12, + "method": "tools/list", + ] + + let response = router.handle(request) + let tools = (response["result"] as? [String: Any])?["tools"] as? [[String: Any]] ?? [] + let toolsByName = Dictionary( + uniqueKeysWithValues: tools.compactMap { tool -> (String, [String: Any])? in + guard let name = tool["name"] as? String else { return nil } + return (name, tool) + } + ) + + let expected: [String: (readOnly: Bool, destructive: Bool, idempotent: Bool, openWorld: Bool)] = [ + "brain_search": (true, false, true, false), + "brain_store": (false, false, false, false), + "brain_recall": (true, false, true, false), + "brain_entity": (true, false, true, false), + "brain_digest": (false, false, false, false), + "brain_update": (false, false, true, false), + "brain_expand": (true, false, true, false), + "brain_tags": (true, false, true, false), + "brain_subscribe": (false, false, false, false), + "brain_unsubscribe": (false, false, true, false), + "brain_ack": (false, false, true, false), + ] + + XCTAssertEqual(toolsByName.count, expected.count) + + for (name, taxonomy) in expected { + let annotations = toolsByName[name]?["annotations"] as? [String: Any] + XCTAssertNotNil(annotations, "\(name) must expose MCP tool annotations") + XCTAssertEqual(annotations?["readOnlyHint"] as? Bool, taxonomy.readOnly, "\(name) readOnlyHint mismatch") + XCTAssertEqual(annotations?["destructiveHint"] as? Bool, taxonomy.destructive, "\(name) destructiveHint mismatch") + XCTAssertEqual(annotations?["idempotentHint"] as? Bool, taxonomy.idempotent, "\(name) idempotentHint mismatch") + XCTAssertEqual(annotations?["openWorldHint"] as? Bool, taxonomy.openWorld, "\(name) openWorldHint mismatch") + } + } + // MARK: - Tools call func testToolsCallDispatchesToHandler() throws { diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index 1896e9c2..215b22ed 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -92,6 +92,36 @@ final class SocketIntegrationTests: XCTestCase { let tools = (response["result"] as? [String: Any])?["tools"] as? [[String: Any]] XCTAssertNotNil(tools) XCTAssertEqual(tools?.count, 11) + + let toolsByName = Dictionary( + uniqueKeysWithValues: (tools ?? []).compactMap { tool -> (String, [String: Any])? in + guard let name = tool["name"] as? String else { return nil } + return (name, tool) + } + ) + + let expected: [String: (readOnly: Bool, destructive: Bool, idempotent: Bool, openWorld: Bool)] = [ + "brain_search": (true, false, true, false), + "brain_store": (false, false, false, false), + "brain_recall": (true, false, true, false), + "brain_entity": (true, false, true, false), + "brain_digest": (false, false, false, false), + "brain_update": (false, false, true, false), + "brain_expand": (true, false, true, false), + "brain_tags": (true, false, true, false), + "brain_subscribe": (false, false, false, false), + "brain_unsubscribe": (false, false, true, false), + "brain_ack": (false, false, true, false), + ] + + for (name, taxonomy) in expected { + let annotations = toolsByName[name]?["annotations"] as? [String: Any] + XCTAssertNotNil(annotations, "\(name) must expose MCP tool annotations over socket transport") + XCTAssertEqual(annotations?["readOnlyHint"] as? Bool, taxonomy.readOnly, "\(name) readOnlyHint mismatch") + XCTAssertEqual(annotations?["destructiveHint"] as? Bool, taxonomy.destructive, "\(name) destructiveHint mismatch") + XCTAssertEqual(annotations?["idempotentHint"] as? Bool, taxonomy.idempotent, "\(name) idempotentHint mismatch") + XCTAssertEqual(annotations?["openWorldHint"] as? Bool, taxonomy.openWorld, "\(name) openWorldHint mismatch") + } } // MARK: - MCP tools/call brain_search over socket