diff --git a/brain-bar/Sources/BrainBar/MCPRouter.swift b/brain-bar/Sources/BrainBar/MCPRouter.swift index c7f57078..8f2d830a 100644 --- a/brain-bar/Sources/BrainBar/MCPRouter.swift +++ b/brain-bar/Sources/BrainBar/MCPRouter.swift @@ -395,11 +395,16 @@ final class MCPRouter: @unchecked Sendable { return toolCallResult(id: id, output: expandPalette(session: session)) } - // Check tool exists - guard isToolExposed(toolName, session: session) else { + guard Self.toolDefinitions.contains(where: { ($0["name"] as? String) == toolName }) else { return jsonRPCError(id: id, code: -32601, message: "Unknown tool: \(toolName)") } + guard isToolExposed(toolName, session: session) else { + let message = "Tool '\(toolName)' is gated by the core MCP profile. " + + "Call expand_palette, or set BRAINLAYER_MCP_PROFILE=full on the MCP server." + return jsonRPCError(id: id, code: -32601, message: message) + } + // Dispatch to handler do { try Self.validate(arguments: arguments, for: toolName) diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index 60419d55..6d26359d 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -169,6 +169,19 @@ final class MCPRouterTests: XCTestCase { } } + func testCoreProfileExplainsGateAndBothRemedies() throws { + let router = MCPRouter(profile: "core") + let response = router.handle(toolCall(id: 19, name: "brain_digest", arguments: [:])) + let error = try XCTUnwrap(response["error"] as? [String: Any]) + + XCTAssertEqual(error["code"] as? Int, -32601) + XCTAssertEqual( + error["message"] as? String, + "Tool 'brain_digest' is gated by the core MCP profile. " + + "Call expand_palette, or set BRAINLAYER_MCP_PROFILE=full on the MCP server." + ) + } + func testEnvironmentProfileControlsDefaultRouter() throws { let environmentKey = "BRAINLAYER_MCP_PROFILE" let originalProfile = ProcessInfo.processInfo.environment[environmentKey] @@ -1038,6 +1051,7 @@ No results found. XCTAssertNotNil(error, "Unknown tool should return JSON-RPC error") XCTAssertEqual(error?["code"] as? Int, -32601, "Should be method-not-found error") + XCTAssertEqual(error?["message"] as? String, "Unknown tool: nonexistent_tool") } func testBrainDigestRejectsOversizedContentAtSchemaLayer() throws { diff --git a/src/brainlayer/mcp/__init__.py b/src/brainlayer/mcp/__init__.py index 342e6b4d..a9db06ca 100644 --- a/src/brainlayer/mcp/__init__.py +++ b/src/brainlayer/mcp/__init__.py @@ -1340,7 +1340,10 @@ async def call_tool(name: str, arguments: dict[str, Any]): ) if name in _FULL_TOOL_NAMES and not _tool_palette.is_exposed(name): - return _error_result(f"Tool not exposed in the core profile: {name}. Call {EXPAND_TOOL_NAME} first.") + return _error_result( + f"Tool '{name}' is gated by the core MCP profile. " + f"Call {EXPAND_TOOL_NAME}, or set BRAINLAYER_MCP_PROFILE=full on the MCP server." + ) # --- New consolidated tools --- diff --git a/tests/test_mcp_palette.py b/tests/test_mcp_palette.py index 543af736..d25b6ef3 100644 --- a/tests/test_mcp_palette.py +++ b/tests/test_mcp_palette.py @@ -35,6 +35,29 @@ def test_environment_profile_is_resolved_once(monkeypatch): assert len(palette.expose(_full_tool_definitions())) == 13 +def test_python_core_profile_explains_gate_and_both_remedies(monkeypatch): + palette = ToolPalette("core") + monkeypatch.setattr(mcp_module, "_tool_palette", palette) + + result = asyncio.run(call_tool("brain_digest", {})) + + assert result.isError is True + assert result.content[0].text == ( + "Tool 'brain_digest' is gated by the core MCP profile. " + "Call expand_palette, or set BRAINLAYER_MCP_PROFILE=full on the MCP server." + ) + + +def test_python_unknown_tool_remains_unknown(monkeypatch): + palette = ToolPalette("core") + monkeypatch.setattr(mcp_module, "_tool_palette", palette) + + result = asyncio.run(call_tool("nonexistent_tool", {})) + + assert result.isError is True + assert result.content[0].text == "Unknown tool: nonexistent_tool" + + def test_python_palette_expands_once_and_dispatches_deferred_tools(monkeypatch): palette = ToolPalette("core") monkeypatch.setattr(mcp_module, "_tool_palette", palette) @@ -43,7 +66,7 @@ def test_python_palette_expands_once_and_dispatches_deferred_tools(monkeypatch): before = asyncio.run(call_tool("brain_tags", {})) assert before.isError is True - assert "not exposed" in before.content[0].text + assert "gated by the core MCP profile" in before.content[0].text first = asyncio.run(call_tool("expand_palette", {})) assert first.isError is False