Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions brain-bar/Sources/BrainBar/MCPRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark brain_search as non-read-only for unread flow

brain_search is now advertised with readOnlyHint=true, but this tool performs writes when unread_only=true is used with an agent ID: handleBrainSearch passes those flags into db.search(...), and the DB path updates delivery state via markDelivered(...) (last_delivered_seq). This misclassification can cause MCP clients to schedule it as side-effect-free (including concurrent execution with write-heavy tools), which risks lock contention and incorrect subscription state tracking.

Useful? React with 👍 / 👎.

"inputSchema": [
"type": "object",
"properties": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand All @@ -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": [
Expand Down
43 changes: 43 additions & 0 deletions brain-bar/Tests/BrainBarTests/MCPRouterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading