Skip to content

Commit 3535a09

Browse files
committed
Needs a ContentBlock object... thanks MCP!
1 parent 5cd0e17 commit 3535a09

6 files changed

Lines changed: 521 additions & 4 deletions

File tree

.claude/settings.local.json

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,42 @@
55
"mcp__github__*",
66
"WebFetch(domain:github.com)",
77
"Bash(cd:*)",
8-
"Bash(gh run view:*)"
8+
"Bash(gh run view:*)",
9+
"WebFetch(domain:docs.github.com)",
10+
"Bash(find:*)",
11+
"Bash(grep:*)"
12+
]
13+
},
14+
"hooks": {
15+
"PreToolUse": [
16+
{
17+
"hooks": [
18+
{
19+
"type": "command",
20+
"command": "D:/GitHub/ClaudeEssentials/src/CloudNimble.ClaudeEssentials.Samples.HookProcessor/bin/Debug/net10.0/CloudNimble.ClaudeEssentials.Samples.HookProcessor.exe PreToolUse"
21+
}
22+
]
23+
}
24+
],
25+
"PostToolUse": [
26+
{
27+
"hooks": [
28+
{
29+
"type": "command",
30+
"command": "D:/GitHub/ClaudeEssentials/src/CloudNimble.ClaudeEssentials.Samples.HookProcessor/bin/Debug/net10.0/CloudNimble.ClaudeEssentials.Samples.HookProcessor.exe PostToolUse"
31+
}
32+
]
33+
}
34+
],
35+
"SessionStart": [
36+
{
37+
"hooks": [
38+
{
39+
"type": "command",
40+
"command": "D:/GitHub/ClaudeEssentials/src/CloudNimble.ClaudeEssentials.Samples.HookProcessor/bin/Debug/net10.0/CloudNimble.ClaudeEssentials.Samples.HookProcessor.exe SessionStart"
41+
}
42+
]
43+
}
944
]
1045
}
1146
}
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
using CloudNimble.ClaudeEssentials.Hooks;
2+
using CloudNimble.ClaudeEssentials.Hooks.Inputs;
3+
using CloudNimble.ClaudeEssentials.Hooks.Tools.Responses;
4+
using FluentAssertions;
5+
using Microsoft.VisualStudio.TestTools.UnitTesting;
6+
using System.Linq;
7+
using System.Text.Json;
8+
using System.Text.Json.Serialization;
9+
10+
namespace CloudNimble.ClaudeEssentials.Tests.Hooks.Tools
11+
{
12+
/// <summary>
13+
/// Tests for ContentBlock deserialization, especially for MCP tool responses.
14+
/// </summary>
15+
[TestClass]
16+
public class ContentBlockTests
17+
{
18+
#region PostToolUse with ContentBlock[] Tests
19+
20+
[TestMethod]
21+
public void DeserializeMcpToolResponse_WithContentBlockArray_ShouldDeserializeCorrectly()
22+
{
23+
// Arrange - Real MCP firecrawl_map response with ContentBlock wrapper
24+
var json = """
25+
{
26+
"session_id": "e0f8803b-c95c-45f6-82be-528bc6310390",
27+
"transcript_path": "C:\\Users\\User\\.claude\\projects\\test\\session.jsonl",
28+
"cwd": "D:\\Work\\test",
29+
"permission_mode": "acceptEdits",
30+
"hook_event_name": "PostToolUse",
31+
"tool_name": "mcp__firecrawl__firecrawl_map",
32+
"tool_input": {
33+
"url": "https://example.com"
34+
},
35+
"tool_response": [
36+
{
37+
"type": "text",
38+
"text": "{\n \"links\": [\n {\n \"url\": \"https://example.com/blog\",\n \"title\": \"Blog\",\n \"description\": \"Our blog posts\"\n },\n {\n \"url\": \"https://example.com/about\"\n }\n ]\n}"
39+
}
40+
],
41+
"tool_use_id": "toolu_01Mo9JHTA5dC46gdHHDJ2VeM"
42+
}
43+
""";
44+
45+
// Act
46+
var result = JsonSerializer.Deserialize(json, ContentBlockTestContext.Default.McpPostToolUsePayload);
47+
48+
// Assert
49+
result.Should().NotBeNull();
50+
result!.HookEventName.Should().Be(HookEventName.PostToolUse);
51+
result.ToolName.Should().Be("mcp__firecrawl__firecrawl_map");
52+
result.ToolResponse.Should().NotBeNull();
53+
result.ToolResponse.Should().HaveCount(1);
54+
result.ToolResponse![0].Type.Should().Be("text");
55+
result.ToolResponse[0].IsText.Should().BeTrue();
56+
result.ToolResponse[0].IsImage.Should().BeFalse();
57+
result.ToolResponse[0].Text.Should().NotBeNullOrEmpty();
58+
}
59+
60+
[TestMethod]
61+
public void ContentBlock_GetTextContent_ShouldDeserializeInnerJson()
62+
{
63+
// Arrange
64+
var json = """
65+
{
66+
"session_id": "test-session",
67+
"transcript_path": "test.jsonl",
68+
"cwd": "D:\\test",
69+
"permission_mode": "default",
70+
"hook_event_name": "PostToolUse",
71+
"tool_name": "mcp__firecrawl__firecrawl_map",
72+
"tool_input": {},
73+
"tool_response": [
74+
{
75+
"type": "text",
76+
"text": "{\"links\":[{\"url\":\"https://example.com/page1\",\"title\":\"Page 1\",\"description\":\"First page\"},{\"url\":\"https://example.com/page2\"}]}"
77+
}
78+
],
79+
"tool_use_id": "toolu_test"
80+
}
81+
""";
82+
83+
var payload = JsonSerializer.Deserialize(json, ContentBlockTestContext.Default.McpPostToolUsePayload);
84+
var textBlock = payload!.ToolResponse!.First(b => b.IsText);
85+
86+
// Act
87+
var firecrawlResponse = textBlock.GetTextContent(ContentBlockTestContext.Default.FirecrawlMapResponse);
88+
89+
// Assert
90+
firecrawlResponse.Should().NotBeNull();
91+
firecrawlResponse!.Links.Should().HaveCount(2);
92+
firecrawlResponse.Links[0].Url.Should().Be("https://example.com/page1");
93+
firecrawlResponse.Links[0].Title.Should().Be("Page 1");
94+
firecrawlResponse.Links[0].Description.Should().Be("First page");
95+
firecrawlResponse.Links[1].Url.Should().Be("https://example.com/page2");
96+
firecrawlResponse.Links[1].Title.Should().BeNull();
97+
}
98+
99+
[TestMethod]
100+
public void ContentBlock_TryGetTextContent_WithValidJson_ShouldReturnTrue()
101+
{
102+
// Arrange
103+
var contentBlock = new ContentBlock
104+
{
105+
Type = "text",
106+
Text = "{\"links\":[{\"url\":\"https://example.com\"}]}"
107+
};
108+
109+
// Act
110+
var success = contentBlock.TryGetTextContent(ContentBlockTestContext.Default.FirecrawlMapResponse, out var result);
111+
112+
// Assert
113+
success.Should().BeTrue();
114+
result.Should().NotBeNull();
115+
result!.Links.Should().HaveCount(1);
116+
}
117+
118+
[TestMethod]
119+
public void ContentBlock_TryGetTextContent_WithInvalidJson_ShouldReturnFalse()
120+
{
121+
// Arrange
122+
var contentBlock = new ContentBlock
123+
{
124+
Type = "text",
125+
Text = "not valid json {"
126+
};
127+
128+
// Act
129+
var success = contentBlock.TryGetTextContent(ContentBlockTestContext.Default.FirecrawlMapResponse, out var result);
130+
131+
// Assert
132+
success.Should().BeFalse();
133+
result.Should().BeNull();
134+
}
135+
136+
[TestMethod]
137+
public void ContentBlock_TryGetTextContent_WithNullText_ShouldReturnFalse()
138+
{
139+
// Arrange
140+
var contentBlock = new ContentBlock
141+
{
142+
Type = "text",
143+
Text = null
144+
};
145+
146+
// Act
147+
var success = contentBlock.TryGetTextContent(ContentBlockTestContext.Default.FirecrawlMapResponse, out var result);
148+
149+
// Assert
150+
success.Should().BeFalse();
151+
result.Should().BeNull();
152+
}
153+
154+
[TestMethod]
155+
public void ContentBlock_GetTextContent_WithEmptyText_ShouldReturnNull()
156+
{
157+
// Arrange
158+
var contentBlock = new ContentBlock
159+
{
160+
Type = "text",
161+
Text = ""
162+
};
163+
164+
// Act
165+
var result = contentBlock.GetTextContent(ContentBlockTestContext.Default.FirecrawlMapResponse);
166+
167+
// Assert
168+
result.Should().BeNull();
169+
}
170+
171+
[TestMethod]
172+
public void DeserializeRealFirecrawlMapPayload_ShouldDeserializeAllLinks()
173+
{
174+
// Arrange - Full real-world payload with 54 links (truncated for test)
175+
var json = """
176+
{
177+
"session_id": "e0f8803b-c95c-45f6-82be-528bc6310390",
178+
"transcript_path": "C:\\Users\\User\\.claude\\projects\\D--Work-manufacturers\\e0f8803b-c95c-45f6-82be-528bc6310390.jsonl",
179+
"cwd": "D:\\Work\\manufacturers",
180+
"permission_mode": "acceptEdits",
181+
"hook_event_name": "PostToolUse",
182+
"tool_name": "mcp__firecrawl__firecrawl_map",
183+
"tool_input": {
184+
"url": "https://burnrate.io"
185+
},
186+
"tool_response": [
187+
{
188+
"type": "text",
189+
"text": "{\n \"links\": [\n {\n \"url\": \"https://burnrate.io/blog\",\n \"title\": \"The Finance Tool for GTM Leaders | Blog\",\n \"description\": \"Listen up, GTM leaders: YOU'RE the ones that generate non-dilutive cash flow.\"\n },\n {\n \"url\": \"https://burnrate.io/request-access\"\n },\n {\n \"url\": \"https://burnrate.io/perks\",\n \"title\": \"Perk Page\",\n \"description\": \"Chart your course for revenue growth.\"\n },\n {\n \"url\": \"https://burnrate.io/about-us\",\n \"title\": \"The Finance Tool for GTM Leaders | About Us\",\n \"description\": \"We're a small team of passionate people.\"\n },\n {\n \"url\": \"https://support.burnrate.io\",\n \"title\": \"BurnRate: Staff Login\",\n \"description\": \"Create your free Re:amaze account.\"\n }\n ]\n}"
190+
}
191+
],
192+
"tool_use_id": "toolu_01Mo9JHTA5dC46gdHHDJ2VeM"
193+
}
194+
""";
195+
196+
// Act
197+
var payload = JsonSerializer.Deserialize(json, ContentBlockTestContext.Default.McpPostToolUsePayload);
198+
var textBlock = payload!.ToolResponse!.FirstOrDefault(b => b.IsText);
199+
var firecrawlResponse = textBlock?.GetTextContent(ContentBlockTestContext.Default.FirecrawlMapResponse);
200+
201+
// Assert
202+
payload.Should().NotBeNull();
203+
payload.ToolName.Should().Be("mcp__firecrawl__firecrawl_map");
204+
205+
firecrawlResponse.Should().NotBeNull();
206+
firecrawlResponse!.Links.Should().HaveCount(5);
207+
208+
// Check first link has all properties
209+
firecrawlResponse.Links[0].Url.Should().Be("https://burnrate.io/blog");
210+
firecrawlResponse.Links[0].Title.Should().Contain("GTM Leaders");
211+
firecrawlResponse.Links[0].Description.Should().NotBeNullOrEmpty();
212+
213+
// Check link with only URL
214+
firecrawlResponse.Links[1].Url.Should().Be("https://burnrate.io/request-access");
215+
firecrawlResponse.Links[1].Title.Should().BeNull();
216+
firecrawlResponse.Links[1].Description.Should().BeNull();
217+
218+
// Check subdomain link
219+
firecrawlResponse.Links[4].Url.Should().Contain("support.burnrate.io");
220+
}
221+
222+
[TestMethod]
223+
public void ContentBlock_IsText_ShouldBeCaseInsensitive()
224+
{
225+
// Arrange & Act & Assert
226+
new ContentBlock { Type = "text" }.IsText.Should().BeTrue();
227+
new ContentBlock { Type = "TEXT" }.IsText.Should().BeTrue();
228+
new ContentBlock { Type = "Text" }.IsText.Should().BeTrue();
229+
new ContentBlock { Type = "image" }.IsText.Should().BeFalse();
230+
}
231+
232+
[TestMethod]
233+
public void ContentBlock_IsImage_ShouldBeCaseInsensitive()
234+
{
235+
// Arrange & Act & Assert
236+
new ContentBlock { Type = "image" }.IsImage.Should().BeTrue();
237+
new ContentBlock { Type = "IMAGE" }.IsImage.Should().BeTrue();
238+
new ContentBlock { Type = "Image" }.IsImage.Should().BeTrue();
239+
new ContentBlock { Type = "text" }.IsImage.Should().BeFalse();
240+
}
241+
242+
#endregion
243+
}
244+
245+
#region Test Models
246+
247+
/// <summary>
248+
/// Test model for Firecrawl map response.
249+
/// </summary>
250+
public class FirecrawlMapResponse
251+
{
252+
[JsonPropertyName("links")]
253+
public FirecrawlLink[] Links { get; set; } = [];
254+
}
255+
256+
/// <summary>
257+
/// Test model for a single Firecrawl link.
258+
/// </summary>
259+
public class FirecrawlLink
260+
{
261+
[JsonPropertyName("url")]
262+
public string Url { get; set; } = string.Empty;
263+
264+
[JsonPropertyName("title")]
265+
public string? Title { get; set; }
266+
267+
[JsonPropertyName("description")]
268+
public string? Description { get; set; }
269+
}
270+
271+
/// <summary>
272+
/// Type alias for MCP PostToolUse payload with ContentBlock[] response.
273+
/// </summary>
274+
public class McpPostToolUsePayload : PostToolUseHookInput<object, ContentBlock[]>
275+
{
276+
}
277+
278+
/// <summary>
279+
/// JSON serializer context for ContentBlock tests.
280+
/// </summary>
281+
[JsonSourceGenerationOptions(
282+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
283+
PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified,
284+
WriteIndented = false,
285+
UseStringEnumConverter = true)]
286+
[JsonSerializable(typeof(McpPostToolUsePayload))]
287+
[JsonSerializable(typeof(ContentBlock))]
288+
[JsonSerializable(typeof(ContentBlock[]))]
289+
[JsonSerializable(typeof(FirecrawlMapResponse))]
290+
[JsonSerializable(typeof(FirecrawlLink))]
291+
[JsonSerializable(typeof(FirecrawlLink[]))]
292+
[JsonSerializable(typeof(HookEventName))]
293+
[JsonSerializable(typeof(PermissionMode))]
294+
public partial class ContentBlockTestContext : JsonSerializerContext
295+
{
296+
}
297+
298+
#endregion
299+
}

src/CloudNimble.ClaudeEssentials.Tests/RoundTripSerializationTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ public void RealWorldPreToolUsePayload_ShouldDeserializeCorrectly()
161161
"session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
162162
"transcript_path": "/Users/dev/.claude/transcripts/session.jsonl",
163163
"cwd": "/Users/dev/myproject",
164-
"permission_mode": "Default",
164+
"permission_mode": "default",
165165
"hook_event_name": "PreToolUse",
166166
"tool_name": "Edit",
167167
"tool_input": {
@@ -193,7 +193,7 @@ public void RealWorldSessionStartPayload_ShouldDeserializeCorrectly()
193193
"session_id": "new-session-id",
194194
"transcript_path": "/tmp/transcript.jsonl",
195195
"cwd": "/home/user/project",
196-
"permission_mode": "AcceptEdits",
196+
"permission_mode": "acceptEdits",
197197
"hook_event_name": "SessionStart",
198198
"source": "Startup",
199199
"env_file": "/tmp/env_vars.txt"
@@ -253,7 +253,7 @@ public void Input_ShouldParseSnakeCasePropertyNames()
253253
"session_id": "test",
254254
"transcript_path": "/path",
255255
"cwd": "/dir",
256-
"permission_mode": "Default",
256+
"permission_mode": "default",
257257
"hook_event_name": "PreToolUse",
258258
"tool_name": "Bash",
259259
"tool_input": {},

src/CloudNimble.ClaudeEssentials/Hooks/ClaudeHooksJsonContext.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Generic;
12
using System.Text.Json;
23
using System.Text.Json.Serialization;
34
using CloudNimble.ClaudeEssentials.Hooks.Inputs;
@@ -114,6 +115,11 @@ namespace CloudNimble.ClaudeEssentials.Hooks
114115
[JsonSerializable(typeof(NotebookEditToolResponse))]
115116
[JsonSerializable(typeof(KillShellToolResponse))]
116117

118+
// MCP tool response wrapper types
119+
[JsonSerializable(typeof(ContentBlock))]
120+
[JsonSerializable(typeof(ContentBlock[]))]
121+
[JsonSerializable(typeof(List<ContentBlock>))]
122+
117123
// Strongly-typed PreToolUse payloads for each tool
118124
[JsonSerializable(typeof(ReadPreToolUsePayload))]
119125
[JsonSerializable(typeof(WritePreToolUsePayload))]

0 commit comments

Comments
 (0)