Skip to content
Open
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
11 changes: 9 additions & 2 deletions codemcp/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env python3

import json
import logging
import os

Expand Down Expand Up @@ -27,7 +28,7 @@ async def codemcp(
subtool: str,
*,
path: str | None = None,
content: str | None = None,
content: object = None, # Changed from str | None to object to accept any type
old_string: str | None = None,
new_string: str | None = None,
offset: int | None = None,
Expand Down Expand Up @@ -64,6 +65,7 @@ async def codemcp(
subject_line: A short subject line in Git conventional commit format (for InitProject)
reuse_head_chat_id: If True, reuse the chat ID from the HEAD commit instead of generating a new one (for InitProject)
thought: The thought content for the Think tool (used for complex reasoning or cache memory)
content: For WriteFile, can be any serializable object (will be converted to JSON if not a string)
... (there are other arguments which are documented later)
"""
try:
Expand Down Expand Up @@ -173,7 +175,12 @@ def normalize_newlines(s):
if description is None:
raise ValueError("description is required for WriteFile subtool")

content_str = content or ""
# If content is not a string, serialize it to a string using json.dumps
if content is not None and not isinstance(content, str):
content_str = json.dumps(content)
else:
content_str = content or ""

return await write_file_content(path, content_str, description, chat_id)

if subtool == "EditFile":
Expand Down
5 changes: 3 additions & 2 deletions codemcp/tools/init_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,8 @@ async def init_project(
## WriteFile chat_id path content description

Write a file to the local filesystem. Overwrites the existing file if there is one.
Provide a short description of the change.
Provide a short description of the change. The content parameter can be a string or any JSON-serializable object
(dictionaries, lists, numbers, booleans, etc.) which will be automatically serialized to JSON if not a string.

Before using this tool:

Expand Down Expand Up @@ -458,7 +459,7 @@ async def init_project(
Args:
subtool: The subtool to execute (ReadFile, WriteFile, EditFile, LS, InitProject, UserPrompt, RunCommand, RM, Think)
path: The path to the file or directory to operate on
content: Content for WriteFile subtool
content: Content for WriteFile subtool (can be a string or any JSON-serializable object)
old_string: String to replace for EditFile subtool
new_string: Replacement string for EditFile subtool
offset: Line offset for ReadFile subtool
Expand Down
192 changes: 192 additions & 0 deletions e2e/test_write_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

"""Tests for the WriteFile subtool."""

import json
import os
import unittest

Expand Down Expand Up @@ -531,6 +532,197 @@ async def test_user_prompt_with_markdown_code_block(self):
codemcp-id: test-chat-id""",
)

async def test_write_non_string_content(self):
"""Test that WriteFile correctly serializes non-string content using json.dumps."""
test_file_path = os.path.join(self.temp_dir.name, "non_string_content.json")

# Create a complex data structure with different types
content = {
"name": "Test Data",
"values": [1, 2, 3, 4, 5],
"nested": {"boolean": True, "null_value": None, "number": 42.5},
}

# First add the file to git to make it tracked
with open(test_file_path, "w") as f:
f.write("")

# Add it to git
await self.git_run(["add", test_file_path])

# Commit it
await self.git_run(
["commit", "-m", "Add empty file for non-string content test"]
)

async with self.create_client_session() as session:
# First initialize project to get chat_id
init_result_text = await self.call_tool_assert_success(
session,
"codemcp",
{
"subtool": "InitProject",
"path": self.temp_dir.name,
"user_prompt": "Test initialization for non-string content test",
"subject_line": "test: initialize for non-string content test",
"reuse_head_chat_id": False,
},
)

# Extract chat_id from the init result
chat_id = self.extract_chat_id_from_text(init_result_text)

# Call the WriteFile tool with non-string content
result_text = await self.call_tool_assert_success(
session,
"codemcp",
{
"subtool": "WriteFile",
"path": test_file_path,
"content": content, # This is a dictionary, not a string
"description": "Create file with non-string content",
"chat_id": chat_id,
},
)

# Verify the success message
self.assertIn("Successfully wrote to", result_text)

# Verify the file was created with the correct content (serialized as JSON)
with open(test_file_path) as f:
file_content = f.read()

# Parse the JSON content and compare with the original dictionary
parsed_content = json.loads(file_content)
self.assertEqual(parsed_content, content)

# Verify that the content was written as a properly formatted JSON string
expected_json = json.dumps(content)
self.assertEqual(file_content, expected_json)

# Verify git state (working tree should be clean after automatic commit)
status = await self.git_run(["status"], capture_output=True, text=True)
self.assertIn("working tree clean", status)

async def test_stdio_client_non_string_content(self):
"""True E2E test that goes through stdio_client to test non-string content serialization."""
import re

test_file_path = os.path.join(
self.temp_dir.name, "stdio_non_string_content.json"
)

# Create a complex data structure with different types including nested structures
content = {
"name": "StdIO Test Data",
"values": [1, 2, 3, 4, 5],
"nested": {
"boolean": True,
"null_value": None,
"number": 42.5,
"array": ["a", "b", "c"],
"deep_nested": {"key1": "value1", "key2": 123, "key3": False},
},
"types": [
{"type": "int", "example": 1},
{"type": "float", "example": 3.14},
{"type": "string", "example": "hello"},
{"type": "boolean", "example": False},
],
}

# First add the file to git to make it tracked
with open(test_file_path, "w") as f:
f.write("")

# Add it to git
await self.git_run(["add", test_file_path])

# Commit it
await self.git_run(
["commit", "-m", "Add empty file for stdio non-string content test"]
)

# Create a client session that goes through the stdio client
async with self.create_client_session() as session:
# First initialize project to get chat_id using the real session
init_result_text = await self.call_tool_assert_success(
session,
"codemcp",
{
"subtool": "InitProject",
"path": self.temp_dir.name,
"user_prompt": "Test initialization for stdio non-string content test",
"subject_line": "test: initialize for stdio test",
"reuse_head_chat_id": False,
},
)

# Extract chat_id from the result
if isinstance(init_result_text, list) and len(init_result_text) > 0 and hasattr(init_result_text[0], "text"):
init_result_text = init_result_text[0].text

chat_id_match = re.search(r"chat ID: ([a-zA-Z0-9-]+)", init_result_text)
self.assertIsNotNone(chat_id_match, "Could not find chat ID in response")
chat_id = chat_id_match.group(1)

# Call the WriteFile tool through the session with non-string content
result_text = await self.call_tool_assert_success(
session,
"codemcp",
{
"subtool": "WriteFile",
"path": test_file_path,
"content": content, # This is a complex dictionary, not a string
"description": "Create file with non-string content via stdio",
"chat_id": chat_id,
},
)

# Verify the success message
self.assertIn("Successfully wrote to", result_text)

# Verify the file was created with the correct content (serialized as JSON)
with open(test_file_path) as f:
file_content = f.read()

# Parse the JSON content and compare with the original dictionary
parsed_content = json.loads(file_content)
self.assertEqual(parsed_content, content)

# Verify that complex nested structures were preserved
self.assertEqual(parsed_content["nested"]["deep_nested"]["key2"], 123)
self.assertEqual(parsed_content["types"][1]["example"], 3.14)

# Read the file back using ReadFile to verify it works with the client session
read_content = await self.call_tool_assert_success(
session,
"codemcp",
{
"subtool": "ReadFile",
"path": test_file_path,
"chat_id": chat_id,
},
)

# We already have read_content directly from call_tool_assert_success

# ReadFile might include line numbers, let's strip those out
if read_content.strip().startswith("1\t"):
# Strip the line numbers and any leading whitespace
read_lines = [
line.split("\t", 1)[1] if "\t" in line else line
for line in read_content.strip().splitlines()
]
read_content = "".join(read_lines)

# The content might have slightly different formatting, so we'll parse both as JSON objects and compare
read_json = json.loads(read_content)
file_json = json.loads(file_content)

# Compare the parsed JSON objects
self.assertEqual(read_json, file_json)


if __name__ == "__main__":
unittest.main()