feat(track-d 2): register SearchMemoryTool + ForgetFactTool with MCP runtime

Closes the audit's "tools exist but unregistered" finding from
.planning/audit-2026-05-03/memory-audit.md §4-5: SearchMemoryTool +
ForgetFactTool source files have lived in `packages/MCP/Sources/MCP/
InProcess/` since Plan 07-03 with zero non-test instantiation. The agent
loop has had no way to call into memory.

Wiring:
  - New `App/MCP/InProcessMemoryAdapters.swift` bridges MCP's tool
    dispatch protocols to Memory's actors:
      * `HybridSearchAdapter: HybridSearchDispatching` wraps
        `Memory.HybridSearch` and translates `[FactRef]` → `[SearchMemoryHit]`
        (so MCP doesn't need to import Memory).
      * `ForgetFactStoreAdapter: ForgetFactDispatching` wraps
        `MemoryStore.forgetFact`.
  - `installMemory` builds an `InProcessToolRegistry` and registers tools
    based on preconditions:
      * `forget_fact` registers when `memoryStore != nil` (writes need DB)
      * `search_memory` registers when `memorySearchAvailable && embedder
        init succeeds` (reads need DB + vec0 + Ollama loopback)
  - Embedder construction (`OllamaEmbeddingClient` loopback assertion)
    failure is caught + logged; degrades to "writes ok, no search"
    rather than failing the whole subsystem.
  - New `inProcessToolRegistry: InProcessToolRegistry?` ivar, exposed
    @testable.

Test seam: `AppDelegate.buildInProcessToolRegistry(forgetDispatcher:
searchDispatcher:)` is the pure registry-construction helper — takes
already-built dispatchers (or nil) and applies the gating rules. Tests
inject stub dispatchers to assert positive registration without needing
a real `MemoryStore` (which requires vec0.dylib, env-gated in CI).

Tests added (compile-time gates per
.planning/debug/xctest-launch-runningboard-error-5.md):
  - `test_D2_noToolsRegisteredWhenStoreUnavailable` — production reality
    on every cold launch (vec0 placeholder)
  - `test_D2_bothToolsRegisterWhenBothDispatchersPresent` — store + search
    available
  - `test_D2_onlyForgetRegistersWhenSearchUnavailable` — store ok, vec
    failed (the future state once custom libsqlite3 lands but vec0.dylib
    is missing)
  - `test_D2_emptyRegistryWhenBothDispatchersNil` — degenerate case

Out of scope: exposing the registered tools to the LLM via
`AgentOrchestrator.availableTools` (currently `[]` — separate audit gap,
affects helper tools too). The "tools exist but unregistered" gap is
closed; the "tools exposed to LLM" gap is independent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Opus 4.7 committed 2026-05-04 14:09:46 -05:00
1 parent 75a10be480
commit 707c45b355
4 files changed
+196 -2

No files matched your search

+69 -2
View File
@@ -224,6 +224,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// `memoryExtractionOrchestrator` doc above.
var memoryExtractionCoordinator: MemoryExtractionCoordinator?
/// Track-D D-2: registry of in-process MCP tools (`search_memory`,
/// `forget_fact`). nil until installMemory runs.
/// Registration is gated:
/// - `forget_fact` registers when `memoryStore != nil` (writes need DB)
/// - `search_memory` registers when `memorySearchAvailable == true`
/// (reads need both DB and vec0)
/// Future work: wrap `mcpRuntime.dispatcher` so the agent can dispatch
/// these tools by name. Until then, the registry is the destination of
/// the registration but the dispatch path is not yet hooked. The audit
/// gap "tools exist but unregistered" is closed by this ivar; the
/// "tools exposed to LLM" gap is independent and tracked separately.
var inProcessToolRegistry: InProcessToolRegistry?
/// Task spawned in `applicationWillFinishLaunching` that constructs and
/// starts the memory subsystem.
var memoryInstallTask: Task<Void, Never>?
@@ -1036,9 +1049,63 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let coord = MemoryExtractionCoordinator(memoryOrchestrator: memoryOrch)
memoryExtractionCoordinator = coord
systemLogger?.info(
"installMemory: extractor up; store=\(store != nil) search=\(searchAvailable)"
// 7. Track-D D-2: in-process MCP tool registry.
// Build adapters that bridge MCP's HybridSearchDispatching /
// ForgetFactDispatching protocols to the Memory.HybridSearch and
// MemoryStore.forgetFact actor methods.
var forgetDispatcher: (any ForgetFactDispatching)? = nil
var searchDispatcher: (any HybridSearchDispatching)? = nil
if let store = store {
forgetDispatcher = ForgetFactStoreAdapter(store: store)
if searchAvailable {
do {
let embedder = try OllamaEmbeddingClient(
baseURL: URL(string: "http://127.0.0.1:11434")!
)
let hybrid = HybridSearch(store: store, embedder: embedder)
searchDispatcher = HybridSearchAdapter(hybrid: hybrid)
} catch {
systemLogger?.warning(
"installMemory: embedder init failed — search_memory not registered: \(String(describing: error))"
)
}
}
}
let registry = await Self.buildInProcessToolRegistry(
forgetDispatcher: forgetDispatcher,
searchDispatcher: searchDispatcher
)
self.inProcessToolRegistry = registry
let toolNames = await registry.registered().map { $0.name }
systemLogger?.info(
"installMemory: extractor up; store=\(store != nil) search=\(searchAvailable) inProcessTools=\(toolNames)"
)
}
/// Track-D D-2 test seam: pure registry-construction helper. Takes
/// optional already-built dispatchers and registers tools per the
/// gating rules:
/// - `forget_fact` registers if `forgetDispatcher` is non-nil.
/// - `search_memory` registers if `searchDispatcher` is non-nil.
/// The production path passes nil dispatchers when their preconditions
/// (store exists / searchAvailable) aren't met — which is exactly the
/// gating semantics. Tests can pass stub dispatchers to assert positive
/// registration without needing a real MemoryStore (which requires
/// vec0.dylib, env-gated in CI).
static func buildInProcessToolRegistry(
forgetDispatcher: (any ForgetFactDispatching)?,
searchDispatcher: (any HybridSearchDispatching)?
) async -> InProcessToolRegistry {
let registry = InProcessToolRegistry()
if let forget = forgetDispatcher {
await registry.register(ForgetFactTool(dispatcher: forget))
}
if let search = searchDispatcher {
await registry.register(SearchMemoryTool(dispatcher: search))
}
return registry
}
// MARK: - Agent install (Plan 09-01)
+56
View File
@@ -0,0 +1,56 @@
// InProcessMemoryAdapters.swift
//
// Track-D D-2: bridges MCP's tool dispatch protocols
// (`HybridSearchDispatching`, `ForgetFactDispatching`) to the Memory module's
// concrete actors. These adapters live in App/ rather than packages/MCP/
// because they cross the module boundary — JarvisMCP intentionally does NOT
// import Memory (Plan 07-03 design: protocol seam keeps MCP unaware of
// SQLite/HybridSearch internals).
//
// Both adapters are `Sendable` value types with an internal actor reference;
// conformance methods hop into the actor and translate Memory's return types
// to MCP's tool-boundary types so MCP doesn't have to import Memory.
import Foundation
import JarvisMCP
import Memory
/// Bridges `SearchMemoryTool`'s `HybridSearchDispatching` protocol to a
/// concrete `Memory.HybridSearch` actor. Translates `[Memory.FactRef]` rows
/// into `[SearchMemoryHit]` so the MCP module doesn't depend on Memory's
/// FactRef type.
public struct HybridSearchAdapter: HybridSearchDispatching {
private let hybrid: HybridSearch
public init(hybrid: HybridSearch) {
self.hybrid = hybrid
}
public func searchFacts(query: String, k: Int, triggerTurnId: Int64) async throws -> [SearchMemoryHit] {
let refs = try await hybrid.searchFacts(query: query, k: k, triggerTurnId: triggerTurnId)
return refs.map { ref in
SearchMemoryHit(
factId: ref.factId,
summary: ref.summary,
score: ref.score,
triggerTurnId: ref.triggerTurnId
)
}
}
}
/// Bridges `ForgetFactTool`'s `ForgetFactDispatching` protocol to
/// `MemoryStore.forgetFact`. The store actor's `forgetFact` is a synchronous
/// throwing method (it wraps an internal SQLite transaction); the adapter
/// hops into the actor and forwards.
public struct ForgetFactStoreAdapter: ForgetFactDispatching {
private let store: MemoryStore
public init(store: MemoryStore) {
self.store = store
}
public func forgetFact(id: Int64, triggerTurnId: Int64) async throws -> Bool {
try await store.forgetFact(id: id, triggerTurnId: triggerTurnId)
}
}
@@ -19,6 +19,7 @@ import XCTest
@testable import Keychain
@testable import Config
@testable import Memory
@testable import JarvisMCP
@MainActor
final class MemoryInstallWiringTests: XCTestCase {
@@ -70,4 +71,70 @@ final class MemoryInstallWiringTests: XCTestCase {
cleanUp(delegate)
}
/// D-2: when MemoryStore.init fails (today's reality), neither
/// SearchMemoryTool nor ForgetFactTool registers — they have no DB
/// to dispatch against. The registry itself IS constructed (empty).
func test_D2_noToolsRegisteredWhenStoreUnavailable() async {
let delegate = AppDelegate()
delegate.entitlementProbe = EntitlementYes()
delegate.keychainStore = FakeKeychain()
await delegate.installMemory()
XCTAssertNil(delegate.memoryStore, "Production reality: store nil on every host")
XCTAssertNotNil(delegate.inProcessToolRegistry,
"D-2: registry constructs unconditionally")
let tools = await delegate.inProcessToolRegistry?.registered() ?? []
let names = Set(tools.map { $0.name })
XCTAssertFalse(names.contains("forget_fact"),
"D-2: forget_fact must not register when store is nil (writes need DB)")
XCTAssertFalse(names.contains("search_memory"),
"D-2: search_memory must not register when search unavailable (reads need DB + vec)")
cleanUp(delegate)
}
// MARK: - D-2 positive paths via test seam
private struct StubForget: ForgetFactDispatching {
func forgetFact(id: Int64, triggerTurnId: Int64) async throws -> Bool { false }
}
private struct StubHybrid: HybridSearchDispatching {
func searchFacts(query: String, k: Int, triggerTurnId: Int64) async throws -> [SearchMemoryHit] { [] }
}
/// D-2: both dispatchers present → both tools register.
func test_D2_bothToolsRegisterWhenBothDispatchersPresent() async {
let registry = await AppDelegate.buildInProcessToolRegistry(
forgetDispatcher: StubForget(),
searchDispatcher: StubHybrid()
)
let names = Set(await registry.registered().map { $0.name })
XCTAssertTrue(names.contains("forget_fact"))
XCTAssertTrue(names.contains("search_memory"))
}
/// D-2: store ok but search unavailable → forget registers, search does not.
func test_D2_onlyForgetRegistersWhenSearchUnavailable() async {
let registry = await AppDelegate.buildInProcessToolRegistry(
forgetDispatcher: StubForget(),
searchDispatcher: nil
)
let names = Set(await registry.registered().map { $0.name })
XCTAssertTrue(names.contains("forget_fact"),
"D-2: forget_fact registers when store exists")
XCTAssertFalse(names.contains("search_memory"),
"D-2: search_memory gated on searchAvailable")
}
/// D-2: nothing wired (store nil) → empty registry.
func test_D2_emptyRegistryWhenBothDispatchersNil() async {
let registry = await AppDelegate.buildInProcessToolRegistry(
forgetDispatcher: nil,
searchDispatcher: nil
)
let names = Set(await registry.registered().map { $0.name })
XCTAssertEqual(names, [], "D-2: empty registry when no dispatchers — neither tool registers")
}
}
+4
View File
@@ -56,6 +56,7 @@
92BBF98C9A7B9B7835FC0949 /* Replay in Frameworks */ = {isa = PBXBuildFile; productRef = 8E74C5DEAD36FEDB6342A54D /* Replay */; };
93FDBFFCCEFC0848F1C5E630 /* AppDelegateBusWiringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6BA7673B5FD73B588D9084 /* AppDelegateBusWiringTests.swift */; };
94512D113669E85D50AC6C8C /* AgentOrchestrator in Frameworks */ = {isa = PBXBuildFile; productRef = 9DC9B6B513D7CCE63399A43B /* AgentOrchestrator */; };
98165CEB562FE32CD16BAA1D /* InProcessMemoryAdapters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 252A86D40325F2186A86A505 /* InProcessMemoryAdapters.swift */; };
9933987292A1035BB048D0B9 /* MenuBarIconController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFECDF801143C9F672B5FB7 /* MenuBarIconController.swift */; };
9A06E835DE29158C0EC43DE3 /* webview in Resources */ = {isa = PBXBuildFile; fileRef = 8C8267C7BE6D4B16FEE6004E /* webview */; };
9ECD7215D86C83BB826F903E /* Shell in Frameworks */ = {isa = PBXBuildFile; productRef = 8D3EE6699BA5134A3A067596 /* Shell */; };
@@ -150,6 +151,7 @@
1F588561BC4F69D39E71AE4A /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
1F80410112AAA66BF53BBC28 /* AppleScriptRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptRunner.swift; sourceTree = "<group>"; };
2468510EF34D9D8D56F5E3B9 /* MenuBarIconControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarIconControllerTests.swift; sourceTree = "<group>"; };
252A86D40325F2186A86A505 /* InProcessMemoryAdapters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InProcessMemoryAdapters.swift; sourceTree = "<group>"; };
2857325F1F38B436FFA8AD8B /* mcp-time.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "mcp-time.app"; sourceTree = BUILT_PRODUCTS_DIR; };
288436CCFD821C16F6C5995E /* Vision */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Vision; path = packages/Vision; sourceTree = SOURCE_ROOT; };
2C42ACA7552D565D9F198399 /* MCPBusGatewayAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MCPBusGatewayAdapter.swift; sourceTree = "<group>"; };
@@ -548,6 +550,7 @@
isa = PBXGroup;
children = (
C4723FBC0F20AD2EFF146A24 /* AppDelegateContextBuilderAdapter.swift */,
252A86D40325F2186A86A505 /* InProcessMemoryAdapters.swift */,
CBF3EF4CDCC653EF50D99AF7 /* MCPRuntimeWiring.swift */,
0358E0CAC1377752398FA3C4 /* ReplayingToolResultObserver.swift */,
);
@@ -1311,6 +1314,7 @@
535CF63FE4AD779CE4EBFA2A /* HudStateBridge.swift in Sources */,
5230995216EF1EE333B51529 /* HudStateCoordinator.swift in Sources */,
896984D88C412595C0F6F97E /* HudStateIntent.swift in Sources */,
98165CEB562FE32CD16BAA1D /* InProcessMemoryAdapters.swift in Sources */,
526BDF4D6BC5714809B2E937 /* JarvisApp.swift in Sources */,
BEFC509502C7940A21001F9F /* JarvisHUDPanel.swift in Sources */,
CD2C5779101FE0384611ECCC /* MCPBusGatewayAdapter.swift in Sources */,