Skip to content

Commit 1fa37b7

Browse files
thatsmeclaude
andcommitted
Add MCP test coverage: ToolSchema, ResourceProvider, McpAuth, AuthContext, PolicyEngine
New test files: - MCP.ToolSchemaTest — tool list structure, skill/workflow prefixes, Peri types - MCP.ResourceProviderTest — all 6 URI templates: list, get, search, not_found, invalid_id - McpAuthTest — valid/invalid/missing Bearer token, unconfigured key Updated tests: - AuthContextTest — build_mcp/2 for MCP caller type - PolicyEngineTest — MCP caller evaluation path - HealthControllerTest — assert mcp status field - MetricsControllerTest — assert mcp section with status and tools_registered 855 tests, 0 failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 257d818 commit 1fa37b7

7 files changed

Lines changed: 469 additions & 0 deletions

File tree

test/alex_claw/auth/auth_context_test.exs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,22 @@ defmodule AlexClaw.Auth.AuthContextTest do
3333
end
3434
end
3535
end
36+
37+
describe "build_mcp/2" do
38+
test "builds context for MCP tool invocation" do
39+
ctx = AuthContext.build_mcp("skill:web_search", :execute)
40+
assert ctx.caller == "mcp:skill:web_search"
41+
assert ctx.caller_type == :mcp
42+
assert ctx.permission == :execute
43+
assert ctx.tool_name == "skill:web_search"
44+
assert ctx.chain_depth == 0
45+
assert ctx.token == nil
46+
assert %DateTime{} = ctx.timestamp
47+
end
48+
49+
test "sets tool_name field" do
50+
ctx = AuthContext.build_mcp("workflow:Tech News Digest", :execute)
51+
assert ctx.tool_name == "workflow:Tech News Digest"
52+
end
53+
end
3654
end

test/alex_claw/auth/policy_engine_test.exs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,29 @@ defmodule AlexClaw.Auth.PolicyEngineTest do
6767
assert {:deny, _reason} = PolicyEngine.evaluate(ctx, nil)
6868
end
6969
end
70+
71+
describe "MCP caller type" do
72+
test "MCP calls are allowed when no mcp_restriction policies exist" do
73+
ctx = build_mcp_context("skill:system_info")
74+
assert :allow == PolicyEngine.evaluate(ctx, [])
75+
end
76+
77+
test "MCP calls bypass chain depth and token checks" do
78+
ctx = build_mcp_context("skill:research")
79+
# MCP path doesn't check chain_depth or token, only policies
80+
assert :allow == PolicyEngine.evaluate(ctx, [])
81+
end
82+
end
83+
84+
defp build_mcp_context(tool_name) do
85+
%AuthContext{
86+
caller: "mcp:#{tool_name}",
87+
caller_type: :mcp,
88+
permission: :execute,
89+
tool_name: tool_name,
90+
chain_depth: 0,
91+
timestamp: DateTime.utc_now(),
92+
token: nil
93+
}
94+
end
7095
end
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
defmodule AlexClaw.MCP.ResourceProviderTest do
2+
use AlexClaw.DataCase, async: false
3+
4+
alias AlexClaw.MCP.ResourceProvider
5+
alias Anubis.Server.{Frame, Response}
6+
7+
defp new_frame, do: Frame.new()
8+
9+
describe "register_templates/1" do
10+
test "registers all 6 resource templates" do
11+
frame = ResourceProvider.register_templates(new_frame())
12+
13+
assert map_size(frame.resource_templates) == 6
14+
15+
names = Map.keys(frame.resource_templates)
16+
assert "resources" in names
17+
assert "knowledge" in names
18+
assert "memory" in names
19+
assert "workflows" in names
20+
assert "runs" in names
21+
assert "config" in names
22+
end
23+
end
24+
25+
describe "read alexclaw://resources/" do
26+
test "list returns all resources" do
27+
AlexClaw.Resources.create_resource(%{
28+
name: "Test Feed",
29+
type: "rss_feed",
30+
url: "https://example.com/feed.xml",
31+
tags: ["test"]
32+
})
33+
34+
{:reply, %Response{} = resp, _frame} =
35+
ResourceProvider.read("alexclaw://resources/list", new_frame())
36+
37+
data = decode_response(resp)
38+
assert is_list(data)
39+
assert Enum.any?(data, &(&1["name"] == "Test Feed"))
40+
end
41+
42+
test "get by ID returns specific resource" do
43+
{:ok, resource} =
44+
AlexClaw.Resources.create_resource(%{
45+
name: "Get Test",
46+
type: "website",
47+
url: "https://example.com"
48+
})
49+
50+
{:reply, %Response{} = resp, _frame} =
51+
ResourceProvider.read("alexclaw://resources/#{resource.id}", new_frame())
52+
53+
data = decode_response(resp)
54+
assert data["id"] == resource.id
55+
assert data["name"] == "Get Test"
56+
end
57+
58+
test "returns error for non-existent resource" do
59+
{:error, _error, _frame} =
60+
ResourceProvider.read("alexclaw://resources/999999", new_frame())
61+
end
62+
63+
test "returns error for invalid ID" do
64+
{:error, _error, _frame} =
65+
ResourceProvider.read("alexclaw://resources/abc", new_frame())
66+
end
67+
end
68+
69+
describe "read alexclaw://knowledge/" do
70+
test "list returns recent knowledge entries" do
71+
AlexClaw.Knowledge.store(:documentation, "Test knowledge content",
72+
source: "test://knowledge-test"
73+
)
74+
75+
{:reply, %Response{} = resp, _frame} =
76+
ResourceProvider.read("alexclaw://knowledge/list", new_frame())
77+
78+
data = decode_response(resp)
79+
assert is_list(data)
80+
assert Enum.any?(data, &(&1["content"] == "Test knowledge content"))
81+
end
82+
83+
test "search returns matching entries" do
84+
AlexClaw.Knowledge.store(:documentation, "Elixir GenServer patterns",
85+
source: "test://knowledge-search"
86+
)
87+
88+
{:reply, %Response{} = resp, _frame} =
89+
ResourceProvider.read("alexclaw://knowledge/search:GenServer", new_frame())
90+
91+
data = decode_response(resp)
92+
assert is_list(data)
93+
end
94+
95+
test "returns error for non-existent entry" do
96+
{:error, _error, _frame} =
97+
ResourceProvider.read("alexclaw://knowledge/999999", new_frame())
98+
end
99+
end
100+
101+
describe "read alexclaw://memory/" do
102+
test "list returns recent memory entries" do
103+
AlexClaw.Memory.store(:fact, "Test memory fact", source: "test://memory-test")
104+
105+
{:reply, %Response{} = resp, _frame} =
106+
ResourceProvider.read("alexclaw://memory/list", new_frame())
107+
108+
data = decode_response(resp)
109+
assert is_list(data)
110+
assert Enum.any?(data, &(&1["content"] == "Test memory fact"))
111+
end
112+
113+
test "search returns matching entries" do
114+
AlexClaw.Memory.store(:fact, "BEAM concurrency model", source: "test://memory-search")
115+
116+
{:reply, %Response{} = resp, _frame} =
117+
ResourceProvider.read("alexclaw://memory/search:BEAM", new_frame())
118+
119+
data = decode_response(resp)
120+
assert is_list(data)
121+
end
122+
123+
test "returns error for non-existent entry" do
124+
{:error, _error, _frame} =
125+
ResourceProvider.read("alexclaw://memory/999999", new_frame())
126+
end
127+
end
128+
129+
describe "read alexclaw://workflows/" do
130+
test "list returns all workflows" do
131+
{:reply, %Response{} = resp, _frame} =
132+
ResourceProvider.read("alexclaw://workflows/list", new_frame())
133+
134+
data = decode_response(resp)
135+
assert is_list(data)
136+
137+
for wf <- data do
138+
assert Map.has_key?(wf, "id")
139+
assert Map.has_key?(wf, "name")
140+
assert Map.has_key?(wf, "enabled")
141+
end
142+
end
143+
144+
test "get by ID returns workflow with steps" do
145+
workflows = AlexClaw.Workflows.list_workflows()
146+
147+
if length(workflows) > 0 do
148+
wf = hd(workflows)
149+
150+
{:reply, %Response{} = resp, _frame} =
151+
ResourceProvider.read("alexclaw://workflows/#{wf.id}", new_frame())
152+
153+
data = decode_response(resp)
154+
assert data["id"] == wf.id
155+
assert Map.has_key?(data, "steps")
156+
assert is_list(data["steps"])
157+
end
158+
end
159+
160+
test "returns error for non-existent workflow" do
161+
{:error, _error, _frame} =
162+
ResourceProvider.read("alexclaw://workflows/999999", new_frame())
163+
end
164+
end
165+
166+
describe "read alexclaw://runs/" do
167+
test "list returns recent runs" do
168+
{:reply, %Response{} = resp, _frame} =
169+
ResourceProvider.read("alexclaw://runs/list", new_frame())
170+
171+
data = decode_response(resp)
172+
assert is_list(data)
173+
end
174+
175+
test "returns error for non-existent run" do
176+
{:error, _error, _frame} =
177+
ResourceProvider.read("alexclaw://runs/999999", new_frame())
178+
end
179+
end
180+
181+
describe "read alexclaw://config/" do
182+
test "list returns all settings" do
183+
{:reply, %Response{} = resp, _frame} =
184+
ResourceProvider.read("alexclaw://config/list", new_frame())
185+
186+
data = decode_response(resp)
187+
assert is_list(data)
188+
189+
for setting <- data do
190+
assert Map.has_key?(setting, "key")
191+
assert Map.has_key?(setting, "value")
192+
end
193+
end
194+
195+
test "sensitive values are redacted" do
196+
AlexClaw.Config.set("test.secret", "super_secret",
197+
type: "string",
198+
category: "test",
199+
sensitive: true
200+
)
201+
202+
{:reply, %Response{} = resp, _frame} =
203+
ResourceProvider.read("alexclaw://config/list", new_frame())
204+
205+
data = decode_response(resp)
206+
secret = Enum.find(data, &(&1["key"] == "test.secret"))
207+
208+
if secret do
209+
assert secret["value"] == "[REDACTED]"
210+
end
211+
end
212+
213+
test "get by key returns specific setting" do
214+
AlexClaw.Config.set("test.mcp.key", "test_value", type: "string", category: "test")
215+
216+
{:reply, %Response{} = resp, _frame} =
217+
ResourceProvider.read("alexclaw://config/test.mcp.key", new_frame())
218+
219+
data = decode_response(resp)
220+
assert data["key"] == "test.mcp.key"
221+
assert data["value"] == "test_value"
222+
end
223+
224+
test "returns error for non-existent key" do
225+
{:error, _error, _frame} =
226+
ResourceProvider.read("alexclaw://config/nonexistent.key.xyz", new_frame())
227+
end
228+
end
229+
230+
describe "unknown URI" do
231+
test "returns error for unrecognized scheme" do
232+
{:error, _error, _frame} =
233+
ResourceProvider.read("unknown://something/123", new_frame())
234+
end
235+
end
236+
237+
defp decode_response(%Response{} = resp) do
238+
# Response stores content as list of maps with "type" => "text", "text" => json_string
239+
# For resource responses, content is stored in `contents` field
240+
text = resp.contents["text"]
241+
Jason.decode!(text)
242+
end
243+
end
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
defmodule AlexClaw.MCP.ToolSchemaTest do
2+
use AlexClaw.DataCase, async: false
3+
4+
alias AlexClaw.MCP.ToolSchema
5+
6+
describe "all_tools/0" do
7+
test "returns a list of tool definitions" do
8+
tools = ToolSchema.all_tools()
9+
assert is_list(tools)
10+
assert length(tools) > 0
11+
end
12+
13+
test "each tool has name, description, and input_schema" do
14+
for tool <- ToolSchema.all_tools() do
15+
assert is_binary(tool.name), "tool name should be binary, got: #{inspect(tool.name)}"
16+
assert is_binary(tool.description), "tool description should be binary for #{tool.name}"
17+
assert is_map(tool.input_schema), "tool input_schema should be map for #{tool.name}"
18+
end
19+
end
20+
21+
test "skill tools are prefixed with skill:" do
22+
skills = ToolSchema.skill_tools()
23+
24+
for tool <- skills do
25+
assert String.starts_with?(tool.name, "skill:"),
26+
"expected skill: prefix, got: #{tool.name}"
27+
end
28+
end
29+
30+
test "workflow tools are prefixed with workflow:" do
31+
workflows = ToolSchema.workflow_tools()
32+
33+
for tool <- workflows do
34+
assert String.starts_with?(tool.name, "workflow:"),
35+
"expected workflow: prefix, got: #{tool.name}"
36+
end
37+
end
38+
end
39+
40+
describe "skill_tools/0" do
41+
test "includes core skills from registry" do
42+
tools = ToolSchema.skill_tools()
43+
names = Enum.map(tools, & &1.name)
44+
45+
# At minimum, these core skills should exist
46+
assert "skill:web_search" in names
47+
assert "skill:telegram_notify" in names
48+
end
49+
50+
test "input_schema uses Peri-compatible types" do
51+
tools = ToolSchema.skill_tools()
52+
53+
for tool <- tools do
54+
schema = tool.input_schema
55+
56+
for {key, value} <- schema do
57+
assert is_binary(key), "schema key should be string for #{tool.name}"
58+
59+
assert valid_peri_type?(value),
60+
"invalid Peri type for #{tool.name}.#{key}: #{inspect(value)}"
61+
end
62+
end
63+
end
64+
end
65+
66+
describe "workflow_tools/0" do
67+
test "returns only enabled workflows" do
68+
workflows = ToolSchema.workflow_tools()
69+
70+
for tool <- workflows do
71+
name = String.replace_leading(tool.name, "workflow:", "")
72+
wf = Enum.find(AlexClaw.Workflows.list_workflows(), &(&1.name == name))
73+
assert wf == nil or wf.enabled, "disabled workflow should not be in tool list: #{name}"
74+
end
75+
end
76+
77+
test "workflow tools have input field in schema" do
78+
for tool <- ToolSchema.workflow_tools() do
79+
assert Map.has_key?(tool.input_schema, "input"),
80+
"workflow tool #{tool.name} should have input field"
81+
end
82+
end
83+
end
84+
85+
# Validates that a value is a valid Peri schema type
86+
defp valid_peri_type?(type) when is_atom(type), do: true
87+
defp valid_peri_type?({atom, _}) when is_atom(atom), do: true
88+
defp valid_peri_type?({atom, _, _}) when is_atom(atom), do: true
89+
defp valid_peri_type?(map) when is_map(map), do: true
90+
defp valid_peri_type?(_), do: false
91+
end

0 commit comments

Comments
 (0)