Skip to content
Merged
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
23 changes: 13 additions & 10 deletions src/dbjavagenix/database/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ def _query_result_json_default(value: object) -> object:
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")


def _json_dumps(value: object) -> str:
"""Serialize MCP payloads with the same driver-value rules as query results."""
return json.dumps(value, ensure_ascii=False, default=_query_result_json_default)


def _resolve_codegen_output_path(base_dir: Path, relative_path: object) -> Path:
"""Resolve a generated filename while keeping it inside its output directory."""
if not isinstance(relative_path, str) or not relative_path.strip():
Expand Down Expand Up @@ -1047,9 +1052,7 @@ async def handle_db_query_execute(arguments: Dict[str, Any]) -> List[TextContent
else:
result_text = "Query executed successfully. No rows returned."

result_text += "\n\nRaw Response: " + json.dumps(
response, ensure_ascii=False, default=_query_result_json_default
)
result_text += "\n\nRaw Response: " + _json_dumps(response)

return [TextContent(
type="text",
Expand Down Expand Up @@ -1238,7 +1241,7 @@ async def handle_db_table_describe(arguments: Dict[str, Any]) -> List[TextConten
for imp in sorted(java_imports):
result_text += f"import {imp};\n"

result_text += f"\nRaw Response: {json.dumps(response, ensure_ascii=False)}"
result_text += f"\nRaw Response: {_json_dumps(response)}"

return [TextContent(
type="text",
Expand Down Expand Up @@ -1374,7 +1377,7 @@ async def handle_db_table_columns(arguments: Dict[str, Any]) -> List[TextContent
result_text += f" Comment: {row['COLUMN_COMMENT']}\n"
result_text += "\n"

result_text += f"Raw Response: {json.dumps(response, ensure_ascii=False)}"
result_text += f"Raw Response: {_json_dumps(response)}"

return [TextContent(
type="text",
Expand Down Expand Up @@ -1476,7 +1479,7 @@ async def handle_db_table_primary_keys(arguments: Dict[str, Any]) -> List[TextCo
else:
result_text = f"No primary keys found for table {database}.{table}\n"

result_text += f"\nRaw Response: {json.dumps(response, ensure_ascii=False)}"
result_text += f"\nRaw Response: {_json_dumps(response)}"

return [TextContent(
type="text",
Expand Down Expand Up @@ -1618,7 +1621,7 @@ async def handle_db_table_foreign_keys(arguments: Dict[str, Any]) -> List[TextCo
else:
result_text = f"No foreign keys found for table {database}.{table}\n"

result_text += f"Raw Response: {json.dumps(response, ensure_ascii=False)}"
result_text += f"Raw Response: {_json_dumps(response)}"

return [TextContent(
type="text",
Expand Down Expand Up @@ -1774,7 +1777,7 @@ async def handle_db_table_indexes(arguments: Dict[str, Any]) -> List[TextContent
else:
result_text = f"No indexes found for table {database}.{table}\n"

result_text += f"Raw Response: {json.dumps(response, ensure_ascii=False)}"
result_text += f"Raw Response: {_json_dumps(response)}"

return [TextContent(
type="text",
Expand Down Expand Up @@ -2040,8 +2043,8 @@ async def handle_db_codegen_analyze(arguments: Dict[str, Any]) -> List[TextConte
result_text += f" Has Primary Key: {'Yes' if context.get('primaryKey') else 'No'}\n"

# Keep a structured payload for non-MCP callers such as the CLI.
result_text += "\n\nRaw Response: " + json.dumps(
{"success": True, **analysis_result}, ensure_ascii=False
result_text += "\n\nRaw Response: " + _json_dumps(
{"success": True, **analysis_result}
)

return [TextContent(
Expand Down
149 changes: 149 additions & 0 deletions tests/unit/test_mcp_error_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,155 @@ async def test_query_execute_serializes_real_driver_value_types(monkeypatch):
}


@pytest.mark.asyncio
async def test_metadata_handlers_serialize_driver_value_types(monkeypatch):
config = SimpleNamespace(type=DatabaseType.POSTGRESQL, database="app")

class Introspector:
def __init__(self, _manager):
pass

def get_config(self, _connection_id):
return config

def describe_table(self, _connection_id, _table, _schema):
return {
"name": "events",
"schema": "public",
"comment": datetime(2026, 9, 8, 10, 0, tzinfo=timezone.utc),
"columns": [
{
"name": "amount",
"type": "numeric",
"column_type": "numeric(10,2)",
"nullable": False,
"default_value": Decimal("12.30"),
"comment": UUID("12345678-1234-5678-1234-567812345678"),
"primary_key": False,
"precision": 10,
"scale": 2,
"max_length": None,
}
],
}

def get_columns(self, _connection_id, _table, _schema):
return [
{
"name": "amount",
"type": "numeric",
"column_type": "numeric(10,2)",
"nullable": False,
"default_value": memoryview(b"view"),
"comment": "created",
"precision": 10,
"scale": 2,
"max_length": None,
}
]

def get_primary_keys(self, _connection_id, _table, _schema):
return ["id"]

def get_foreign_keys(self, _connection_id, _table, _schema):
return [
{
"column_name": "owner_id",
"referenced_table": "users",
"referenced_column": "id",
"constraint_name": "events_owner_fk",
}
]

def get_indexes(self, _connection_id, _table, _schema):
return [
{
"key_name": "events_amount_idx",
"column_name": "amount",
"seq_in_index": 1,
"unique": False,
"index_type": "btree",
}
]

monkeypatch.setattr(mcp_tools, "DatabaseIntrospector", Introspector)
monkeypatch.setattr(
mcp_tools.connection_manager,
"get_connection_info",
lambda _id: config,
)
common = {
"connection_id": "pg-1",
"database": "app",
"table": "events",
"schema": "public",
}

describe = await mcp_tools.handle_db_table_describe(common)
columns = await mcp_tools.handle_db_table_columns(common)
primary_keys = await mcp_tools.handle_db_table_primary_keys(common)
foreign_keys = await mcp_tools.handle_db_table_foreign_keys(common)
indexes = await mcp_tools.handle_db_table_indexes(common)

assert _raw_payload(describe)["columns"][0]["default_value"] == "12.30"
assert _raw_payload(describe)["comment"] == "2026-09-08T10:00:00+00:00"
assert _raw_payload(columns)["columns"][0]["COLUMN_DEFAULT"] == {
"encoding": "base64",
"data": "dmlldw==",
}
assert _raw_payload(columns)["columns"][0]["COLUMN_COMMENT"] == "created"
assert _raw_payload(primary_keys)["primary_keys"] == ["id"]
assert _raw_payload(foreign_keys)["foreign_keys"][0]["references_table"] == "users"
assert _raw_payload(indexes)["indexes"][0]["name"] == "events_amount_idx"


@pytest.mark.asyncio
async def test_codegen_analysis_raw_response_serializes_driver_values(monkeypatch):
class Analyzer:
def __init__(self, _manager):
pass

async def analyze_table_for_codegen(self, *_args, **_kwargs):
return {
"table_name": "events",
"table_info": {
"name": "events",
"comment": "event table",
"columns": [
{
"name": "amount",
"type": "numeric",
"default_value": Decimal("12.30"),
}
],
},
"template_context": {
"columns": [{"javaType": "BigDecimal"}],
"className": "Event",
"lowerCaseName": "event",
"hasDateField": False,
"hasBigDecimalField": True,
"primaryKey": "id",
},
"java_types": ["BigDecimal"],
"imports_needed": [],
"relationships": {"primary_keys": ["id"], "foreign_keys": [], "indexes": []},
}

monkeypatch.setattr("dbjavagenix.database.codegen_tools.CodegenAnalyzer", Analyzer)
monkeypatch.setattr(
mcp_tools.connection_manager,
"get_connection_info",
lambda _id: SimpleNamespace(type=DatabaseType.POSTGRESQL, database="app"),
)

response = await mcp_tools.handle_db_codegen_analyze(
{"connection_id": "pg-1", "table_name": "events"}
)

assert _raw_payload(response)["table_info"]["columns"][0]["default_value"] == "12.30"


def test_query_result_json_default_rejects_unknown_object():
class UnknownDriverValue:
pass
Expand Down
Loading