40 KiB
Bird's-Eye View Generation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add 3D bird's-eye view and perspective rendering to CivilPlan MCP using Google's Nano Banana Pro (Gemini 3 Pro Image) API, bump version to 2.0.0, and create a polished release with comprehensive documentation.
Architecture: New MCP tool civilplan_generate_birdseye_view calls the Gemini API via google-genai SDK. A service layer wraps API calls; prompt templates are specialized per project domain (road/building/water/river/landscape). SVG drawings from the existing generator can optionally feed in as reference images via cairosvg.
Tech Stack: google-genai, cairosvg, Pillow, FastMCP 2.0+, Python 3.11+
Task 1: Add GEMINI_API_KEY to Configuration
Files:
-
Modify:
civilplan_mcp/config.py -
Modify:
.env.example -
Test:
tests/test_config_and_secure_store.py -
Step 1: Write failing test for GEMINI_API_KEY in settings
Add to tests/test_config_and_secure_store.py:
def test_settings_has_gemini_api_key():
from civilplan_mcp.config import Settings
s = Settings()
assert hasattr(s, "gemini_api_key")
assert s.gemini_api_key == ""
- Step 2: Run test to verify it fails
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_config_and_secure_store.py::test_settings_has_gemini_api_key -v
Expected: FAIL — Settings has no gemini_api_key attribute
- Step 3: Add gemini_api_key to Settings and get_settings()
In civilplan_mcp/config.py, add field to Settings class:
class Settings(BaseModel):
# ... existing fields ...
gemini_api_key: str = Field(default_factory=lambda: os.getenv("GEMINI_API_KEY", ""))
In get_settings(), add after the existing vworld block:
if not settings.gemini_api_key:
settings.gemini_api_key = secure_keys.get("GEMINI_API_KEY", "")
In check_api_keys(), add:
if not settings.gemini_api_key:
missing.append("GEMINI_API_KEY")
- Step 4: Update .env.example
Append to .env.example:
GEMINI_API_KEY=
- Step 5: Run test to verify it passes
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_config_and_secure_store.py::test_settings_has_gemini_api_key -v
Expected: PASS
- Step 6: Run full test suite to check nothing broke
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/ -v
Expected: All existing tests PASS
- Step 7: Commit
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add civilplan_mcp/config.py .env.example tests/test_config_and_secure_store.py
git commit -m "feat: add GEMINI_API_KEY to config system"
Task 2: Create Gemini Image Service
Files:
-
Create:
civilplan_mcp/services/__init__.py -
Create:
civilplan_mcp/services/gemini_image.py -
Test:
tests/test_gemini_image.py -
Step 1: Write failing tests for GeminiImageService
Create tests/test_gemini_image.py:
from __future__ import annotations
import base64
from unittest.mock import MagicMock, patch
import pytest
def test_generate_image_returns_dict():
from civilplan_mcp.services.gemini_image import GeminiImageService
svc = GeminiImageService(api_key="test-key")
assert svc.api_key == "test-key"
assert svc.model == "gemini-3-pro-image-preview"
def test_generate_image_calls_api(tmp_path):
from civilplan_mcp.services.gemini_image import GeminiImageService
# Create a fake 1x1 white PNG
import io
from PIL import Image as PILImage
img = PILImage.new("RGB", (1, 1), "white")
buf = io.BytesIO()
img.save(buf, format="PNG")
fake_png_bytes = buf.getvalue()
mock_image = MagicMock()
mock_image.save = MagicMock()
mock_part = MagicMock()
mock_part.text = None
mock_part.inline_data = MagicMock()
mock_part.as_image.return_value = mock_image
mock_response = MagicMock()
mock_response.parts = [mock_part]
with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai:
mock_client = MagicMock()
mock_genai.Client.return_value = mock_client
mock_client.models.generate_content.return_value = mock_response
svc = GeminiImageService(api_key="test-key")
result = svc.generate_image(
prompt="test prompt",
output_path=str(tmp_path / "test.png"),
aspect_ratio="16:9",
image_size="2K",
)
assert result["status"] == "success"
assert result["path"] == str(tmp_path / "test.png")
mock_image.save.assert_called_once_with(str(tmp_path / "test.png"))
def test_generate_image_with_reference(tmp_path):
from civilplan_mcp.services.gemini_image import GeminiImageService
mock_image = MagicMock()
mock_image.save = MagicMock()
mock_part = MagicMock()
mock_part.text = None
mock_part.inline_data = MagicMock()
mock_part.as_image.return_value = mock_image
mock_response = MagicMock()
mock_response.parts = [mock_part]
with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai:
mock_client = MagicMock()
mock_genai.Client.return_value = mock_client
mock_client.models.generate_content.return_value = mock_response
svc = GeminiImageService(api_key="test-key")
# Create a small reference image
from PIL import Image as PILImage
ref_path = tmp_path / "ref.png"
PILImage.new("RGB", (10, 10), "gray").save(str(ref_path))
result = svc.generate_image(
prompt="test prompt with reference",
output_path=str(tmp_path / "out.png"),
reference_image_path=str(ref_path),
)
assert result["status"] == "success"
# Verify contents list included the reference image
call_args = mock_client.models.generate_content.call_args
contents = call_args.kwargs.get("contents") or call_args[1].get("contents")
assert len(contents) == 2 # prompt + image
def test_generate_image_api_failure(tmp_path):
from civilplan_mcp.services.gemini_image import GeminiImageService
with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai:
mock_client = MagicMock()
mock_genai.Client.return_value = mock_client
mock_client.models.generate_content.side_effect = Exception("API error")
svc = GeminiImageService(api_key="test-key")
result = svc.generate_image(
prompt="test prompt",
output_path=str(tmp_path / "fail.png"),
)
assert result["status"] == "error"
assert "API error" in result["error"]
def test_generate_image_no_image_in_response(tmp_path):
from civilplan_mcp.services.gemini_image import GeminiImageService
mock_part = MagicMock()
mock_part.text = "Sorry, I cannot generate that image."
mock_part.inline_data = None
mock_response = MagicMock()
mock_response.parts = [mock_part]
with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai:
mock_client = MagicMock()
mock_genai.Client.return_value = mock_client
mock_client.models.generate_content.return_value = mock_response
svc = GeminiImageService(api_key="test-key")
result = svc.generate_image(
prompt="test prompt",
output_path=str(tmp_path / "noimg.png"),
)
assert result["status"] == "error"
assert "no image" in result["error"].lower()
- Step 2: Run tests to verify they fail
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_gemini_image.py -v
Expected: FAIL — module civilplan_mcp.services.gemini_image not found
- Step 3: Create services package and gemini_image.py
Create civilplan_mcp/services/__init__.py:
Create civilplan_mcp/services/gemini_image.py:
from __future__ import annotations
import logging
from pathlib import Path
from google import genai
from google.genai import types
from PIL import Image
logger = logging.getLogger(__name__)
class GeminiImageService:
def __init__(self, api_key: str, model: str = "gemini-3-pro-image-preview"):
self.api_key = api_key
self.model = model
self._client = genai.Client(api_key=api_key)
def generate_image(
self,
*,
prompt: str,
output_path: str,
reference_image_path: str | None = None,
aspect_ratio: str = "16:9",
image_size: str = "2K",
) -> dict:
try:
contents: list = [prompt]
if reference_image_path:
ref_img = Image.open(reference_image_path)
contents.append(ref_img)
config = types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
aspect_ratio=aspect_ratio,
image_size=image_size,
),
)
response = self._client.models.generate_content(
model=self.model,
contents=contents,
config=config,
)
for part in response.parts:
if part.inline_data is not None:
image = part.as_image()
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
image.save(output_path)
return {"status": "success", "path": output_path}
return {"status": "error", "error": "No image in API response"}
except Exception as exc:
logger.error("Gemini image generation failed: %s", exc)
return {"status": "error", "error": str(exc)}
- Step 4: Run tests to verify they pass
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_gemini_image.py -v
Expected: All 5 tests PASS
- Step 5: Commit
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add civilplan_mcp/services/__init__.py civilplan_mcp/services/gemini_image.py tests/test_gemini_image.py
git commit -m "feat: add Gemini image service for Nano Banana Pro API"
Task 3: Create Prompt Templates
Files:
-
Create:
civilplan_mcp/prompts/__init__.py -
Create:
civilplan_mcp/prompts/birdseye_templates.py -
Test:
tests/test_birdseye_templates.py -
Step 1: Write failing tests for prompt templates
Create tests/test_birdseye_templates.py:
from __future__ import annotations
import pytest
def test_build_birdseye_prompt_road():
from civilplan_mcp.prompts.birdseye_templates import build_prompt
prompt = build_prompt(
view_type="birdseye",
project_type="road",
project_summary="경기도 지방도 890m, 폭 6m, 2차선 아스팔트 도로",
details={"length_m": 890, "width_m": 6, "lanes": 2, "pavement": "아스콘"},
)
assert "bird's-eye view" in prompt.lower() or "aerial" in prompt.lower()
assert "890" in prompt
assert "road" in prompt.lower() or "도로" in prompt
def test_build_perspective_prompt_building():
from civilplan_mcp.prompts.birdseye_templates import build_prompt
prompt = build_prompt(
view_type="perspective",
project_type="building",
project_summary="서울시 강남구 5층 오피스 빌딩",
details={"floors": 5, "use": "오피스"},
)
assert "perspective" in prompt.lower()
assert "building" in prompt.lower() or "빌딩" in prompt
def test_all_project_types_have_templates():
from civilplan_mcp.prompts.birdseye_templates import DOMAIN_PROMPTS
expected = {"road", "building", "water", "river", "landscape", "mixed"}
assert set(DOMAIN_PROMPTS.keys()) == expected
def test_build_prompt_unknown_type_uses_mixed():
from civilplan_mcp.prompts.birdseye_templates import build_prompt
prompt = build_prompt(
view_type="birdseye",
project_type="unknown_type",
project_summary="Test project",
details={},
)
assert isinstance(prompt, str)
assert len(prompt) > 50
- Step 2: Run tests to verify they fail
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_templates.py -v
Expected: FAIL — module not found
- Step 3: Create prompts package and templates
Create civilplan_mcp/prompts/__init__.py:
Create civilplan_mcp/prompts/birdseye_templates.py:
from __future__ import annotations
from typing import Any
DOMAIN_PROMPTS: dict[str, str] = {
"road": (
"Focus on the road alignment cutting through the terrain. "
"Show road surface markings, lane dividers, shoulders, drainage ditches along both sides, "
"utility corridors (water/sewer pipes if applicable), guard rails, and traffic signage. "
"Include surrounding land use context: rice paddies, hills, or residential areas typical of Korean rural/suburban settings."
),
"building": (
"Focus on the building mass, facade materials, and rooftop details. "
"Show the site context including parking areas, landscaping, pedestrian paths, and adjacent roads. "
"Include typical Korean urban context: neighboring buildings, street trees, and utility poles."
),
"water": (
"Focus on the pipeline route and treatment facility layout. "
"Show pipe trenches, manholes at regular intervals, pump stations, and connection points. "
"Include cross-section hints showing pipe burial depth. "
"Surrounding context: Korean suburban or rural terrain with roads running alongside."
),
"river": (
"Focus on the riverbank improvements, embankment structures, and flood control elements. "
"Show gabion walls, riprap, walking paths along the levee, bridge crossings, and flood gates. "
"Include the water surface with natural flow patterns and riparian vegetation."
),
"landscape": (
"Focus on the green space design, vegetation patterns, and hardscape elements. "
"Show walking trails, rest areas with benches, playground equipment, water features, "
"and ornamental planting beds. Include seasonal Korean trees (cherry blossom, pine, maple)."
),
"mixed": (
"Show a comprehensive development site with multiple infrastructure elements. "
"Include roads, buildings, utility corridors, and landscaped areas working together. "
"Emphasize how different systems connect and interact within the Korean development context."
),
}
VIEW_INSTRUCTIONS: dict[str, str] = {
"birdseye": (
"Create a photorealistic aerial bird's-eye view rendering, "
"camera angle approximately 45-60 degrees from above, "
"showing the full project extent with surrounding context."
),
"perspective": (
"Create a photorealistic eye-level perspective rendering, "
"camera positioned at human eye height (1.6m), "
"showing the project from the most representative viewpoint."
),
}
def build_prompt(
*,
view_type: str,
project_type: str,
project_summary: str,
details: dict[str, Any],
) -> str:
view_instruction = VIEW_INSTRUCTIONS.get(view_type, VIEW_INSTRUCTIONS["birdseye"])
domain_context = DOMAIN_PROMPTS.get(project_type, DOMAIN_PROMPTS["mixed"])
detail_lines = []
for key, value in details.items():
if value is not None:
detail_lines.append(f"- {key}: {value}")
detail_block = "\n".join(detail_lines) if detail_lines else "No additional details."
return (
f"{view_instruction}\n\n"
f"Project description: {project_summary}\n\n"
f"Technical details:\n{detail_block}\n\n"
f"Domain-specific guidance:\n{domain_context}\n\n"
f"Style: Professional architectural visualization for a Korean construction project plan. "
f"Clear daytime weather, natural lighting, high detail. "
f"Include a subtle north arrow and scale reference in the corner."
)
- Step 4: Run tests to verify they pass
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_templates.py -v
Expected: All 4 tests PASS
- Step 5: Commit
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add civilplan_mcp/prompts/__init__.py civilplan_mcp/prompts/birdseye_templates.py tests/test_birdseye_templates.py
git commit -m "feat: add project-type-specific prompt templates for birdseye rendering"
Task 4: Create Bird's-Eye View Generator Tool
Files:
-
Create:
civilplan_mcp/tools/birdseye_generator.py -
Test:
tests/test_birdseye_generator.py -
Step 1: Write failing tests
Create tests/test_birdseye_generator.py:
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
SAMPLE_PROJECT_SPEC = {
"project_id": "PRJ-20260404-001",
"domain": "토목_도로",
"project_type": ["도로"],
"road": {"class": "소로3류", "length_m": 890, "width_m": 6, "lanes": 2, "pavement": "아스콘"},
"terrain": "구릉",
"region": "경기도",
"year_start": 2026,
"year_end": 2028,
"utilities": ["상수도", "하수도"],
}
DOMAIN_MAP = {
"토목_도로": "road",
"건축": "building",
"토목_상하수도": "water",
"토목_하천": "river",
"조경": "landscape",
"복합": "mixed",
}
def _mock_generate_image(**kwargs):
return {"status": "success", "path": kwargs.get("output_path", "/tmp/test.png")}
@patch("civilplan_mcp.tools.birdseye_generator.get_settings")
@patch("civilplan_mcp.tools.birdseye_generator.GeminiImageService")
def test_generate_birdseye_returns_two_images(mock_svc_cls, mock_settings, tmp_path):
from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view
mock_settings.return_value = MagicMock(
gemini_api_key="test-key",
output_dir=tmp_path,
)
mock_svc = MagicMock()
mock_svc.generate_image.side_effect = lambda **kw: {
"status": "success",
"path": kw["output_path"],
}
mock_svc_cls.return_value = mock_svc
result = generate_birdseye_view(
project_summary="경기도 지방도 890m 도로",
project_spec=SAMPLE_PROJECT_SPEC,
)
assert result["status"] == "success"
assert "birdseye_view" in result
assert "perspective_view" in result
assert result["birdseye_view"]["status"] == "success"
assert result["perspective_view"]["status"] == "success"
assert mock_svc.generate_image.call_count == 2
@patch("civilplan_mcp.tools.birdseye_generator.get_settings")
@patch("civilplan_mcp.tools.birdseye_generator.GeminiImageService")
def test_generate_birdseye_with_svg_reference(mock_svc_cls, mock_settings, tmp_path):
from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view
mock_settings.return_value = MagicMock(
gemini_api_key="test-key",
output_dir=tmp_path,
)
mock_svc = MagicMock()
mock_svc.generate_image.side_effect = lambda **kw: {
"status": "success",
"path": kw["output_path"],
}
mock_svc_cls.return_value = mock_svc
svg_content = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="gray"/></svg>'
with patch("civilplan_mcp.tools.birdseye_generator.svg_to_png") as mock_svg:
mock_svg.return_value = str(tmp_path / "ref.png")
# Create fake ref.png so path exists
(tmp_path / "ref.png").write_bytes(b"\x89PNG\r\n")
result = generate_birdseye_view(
project_summary="경기도 도로",
project_spec=SAMPLE_PROJECT_SPEC,
svg_drawing=svg_content,
)
assert result["status"] == "success"
mock_svg.assert_called_once()
# Both calls should include reference_image_path
for call in mock_svc.generate_image.call_args_list:
assert call.kwargs.get("reference_image_path") is not None
@patch("civilplan_mcp.tools.birdseye_generator.get_settings")
def test_generate_birdseye_no_api_key(mock_settings):
from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view
mock_settings.return_value = MagicMock(gemini_api_key="")
result = generate_birdseye_view(
project_summary="Test",
project_spec=SAMPLE_PROJECT_SPEC,
)
assert result["status"] == "error"
assert "GEMINI_API_KEY" in result["error"]
@patch("civilplan_mcp.tools.birdseye_generator.get_settings")
@patch("civilplan_mcp.tools.birdseye_generator.GeminiImageService")
def test_generate_birdseye_partial_failure(mock_svc_cls, mock_settings, tmp_path):
from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view
mock_settings.return_value = MagicMock(
gemini_api_key="test-key",
output_dir=tmp_path,
)
call_count = [0]
def side_effect(**kw):
call_count[0] += 1
if call_count[0] == 1:
return {"status": "success", "path": kw["output_path"]}
return {"status": "error", "error": "Rate limit exceeded"}
mock_svc = MagicMock()
mock_svc.generate_image.side_effect = side_effect
mock_svc_cls.return_value = mock_svc
result = generate_birdseye_view(
project_summary="Test",
project_spec=SAMPLE_PROJECT_SPEC,
)
# Should still return partial results
assert result["status"] == "partial"
assert result["birdseye_view"]["status"] == "success"
assert result["perspective_view"]["status"] == "error"
def test_domain_to_project_type_mapping():
from civilplan_mcp.tools.birdseye_generator import _domain_to_project_type
assert _domain_to_project_type("토목_도로") == "road"
assert _domain_to_project_type("건축") == "building"
assert _domain_to_project_type("토목_상하수도") == "water"
assert _domain_to_project_type("토목_하천") == "river"
assert _domain_to_project_type("조경") == "landscape"
assert _domain_to_project_type("복합") == "mixed"
assert _domain_to_project_type("unknown") == "mixed"
- Step 2: Run tests to verify they fail
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_generator.py -v
Expected: FAIL — module not found
- Step 3: Create birdseye_generator.py
Create civilplan_mcp/tools/birdseye_generator.py:
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Any
from civilplan_mcp.config import get_settings
from civilplan_mcp.models import ProjectDomain
from civilplan_mcp.prompts.birdseye_templates import build_prompt
from civilplan_mcp.services.gemini_image import GeminiImageService
from civilplan_mcp.tools._base import wrap_response
logger = logging.getLogger(__name__)
DOMAIN_MAP: dict[str, str] = {
"토목_도로": "road",
"건축": "building",
"토목_상하수도": "water",
"토목_하천": "river",
"조경": "landscape",
"복합": "mixed",
}
def _domain_to_project_type(domain: str) -> str:
return DOMAIN_MAP.get(domain, "mixed")
def svg_to_png(svg_content: str, output_path: str) -> str:
import cairosvg
cairosvg.svg2png(bytestring=svg_content.encode("utf-8"), write_to=output_path)
return output_path
def generate_birdseye_view(
*,
project_summary: str,
project_spec: dict[str, Any],
svg_drawing: str | None = None,
resolution: str = "2K",
output_dir: str | None = None,
) -> dict[str, Any]:
"""Generate bird's-eye view and perspective renderings using Nano Banana Pro."""
settings = get_settings()
if not settings.gemini_api_key:
return {
"status": "error",
"error": "GEMINI_API_KEY is not configured. Set it in .env or run setup_keys.py.",
}
out = Path(output_dir or settings.output_dir) / "renders"
out.mkdir(parents=True, exist_ok=True)
domain = project_spec.get("domain", "복합")
project_type = _domain_to_project_type(domain)
project_id = project_spec.get("project_id", "unknown")
# Extract details for prompt
details: dict[str, Any] = {}
road = project_spec.get("road")
if road:
details.update({k: v for k, v in road.items() if v is not None})
details["terrain"] = project_spec.get("terrain")
details["region"] = project_spec.get("region")
details["utilities"] = project_spec.get("utilities")
# Convert SVG to PNG reference if provided
ref_path: str | None = None
if svg_drawing:
try:
ref_png = str(out / f"{project_id}_ref.png")
ref_path = svg_to_png(svg_drawing, ref_png)
except Exception as exc:
logger.warning("SVG to PNG conversion failed, proceeding without reference: %s", exc)
ref_path = None
svc = GeminiImageService(api_key=settings.gemini_api_key)
results: dict[str, Any] = {}
for view_type in ("birdseye", "perspective"):
prompt = build_prompt(
view_type=view_type,
project_type=project_type,
project_summary=project_summary,
details=details,
)
file_path = str(out / f"{project_id}_{view_type}.png")
result = svc.generate_image(
prompt=prompt,
output_path=file_path,
reference_image_path=ref_path,
aspect_ratio="16:9",
image_size=resolution,
)
results[f"{view_type}_view"] = result
birdseye_ok = results["birdseye_view"].get("status") == "success"
perspective_ok = results["perspective_view"].get("status") == "success"
if birdseye_ok and perspective_ok:
status = "success"
elif birdseye_ok or perspective_ok:
status = "partial"
else:
status = "error"
domain_enum = ProjectDomain(domain) if domain in ProjectDomain.__members__.values() else ProjectDomain.복합
return wrap_response(
{
"status": status,
"project_id": project_id,
"model": "nano-banana-pro (gemini-3-pro-image-preview)",
"resolution": resolution,
"birdseye_view": results["birdseye_view"],
"perspective_view": results["perspective_view"],
},
domain_enum,
)
- Step 4: Run tests to verify they pass
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_generator.py -v
Expected: All 5 tests PASS
- Step 5: Commit
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add civilplan_mcp/tools/birdseye_generator.py tests/test_birdseye_generator.py
git commit -m "feat: add birdseye view generator MCP tool"
Task 5: Register Tool in Server and Update Dependencies
Files:
-
Modify:
civilplan_mcp/server.py -
Modify:
civilplan_mcp/__init__.py -
Modify:
requirements.txt -
Modify:
pyproject.toml -
Test:
tests/test_server_registration.py -
Step 1: Write failing test for new tool registration
Add to tests/test_server_registration.py:
def test_birdseye_tool_registered():
from civilplan_mcp.server import build_mcp
app = build_mcp()
tool_names = [t.name for t in app._tool_manager.tools.values()]
assert "civilplan_generate_birdseye_view" in tool_names
- Step 2: Run test to verify it fails
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_server_registration.py::test_birdseye_tool_registered -v
Expected: FAIL — tool not registered
- Step 3: Register tool in server.py
Add import at top of civilplan_mcp/server.py:
from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view
Add registration in build_mcp() after the last _register_write_tool call:
_register_write_tool(app, "civilplan_generate_birdseye_view", generate_birdseye_view)
- Step 4: Update version to 2.0.0
In civilplan_mcp/__init__.py:
__all__ = ["__version__"]
__version__ = "2.0.0"
In pyproject.toml, change:
version = "2.0.0"
In civilplan_mcp/config.py, change:
version: str = "2.0.0"
- Step 5: Update dependencies
In requirements.txt, add before pytest:
google-genai>=1.0.0
cairosvg>=2.7.0
Pillow>=10.0.0
In pyproject.toml, add to dependencies list:
"google-genai>=1.0.0",
"cairosvg>=2.7.0",
"Pillow>=10.0.0",
- Step 6: Install new dependencies
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && pip install google-genai cairosvg Pillow
- Step 7: Run test to verify registration passes
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_server_registration.py -v
Expected: PASS (including the new test)
- Step 8: Run full test suite
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/ -v
Expected: All tests PASS
- Step 9: Commit
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add civilplan_mcp/server.py civilplan_mcp/__init__.py civilplan_mcp/config.py requirements.txt pyproject.toml tests/test_server_registration.py
git commit -m "feat: register birdseye tool, bump to v2.0.0, add new dependencies"
Task 6: Write Comprehensive README
Files:
-
Modify:
README.md -
Step 1: Read current README for structure reference
Run: cat C:/Users/sinmb/workspace/CivilPlan-MCP-v2/README.md
- Step 2: Rewrite README.md
Full rewrite of README.md with these sections:
# CivilPlan MCP v2.0
> AI-powered construction project planning for Korean civil engineering and building projects.
> Connects to Claude, ChatGPT, and other AI agents via the Model Context Protocol (MCP).
건설/건축 공사 사업계획을 AI와 함께 만듭니다.
MCP(Model Context Protocol)를 통해 Claude, ChatGPT 등 AI 에이전트와 연결됩니다.
---
## What's New in v2.0
| Feature | Description |
|---------|-------------|
| **3D Bird's-Eye View** | Generate photorealistic aerial and perspective renderings using Nano Banana Pro (Gemini 3 Pro Image) |
| **Perspective Rendering** | Eye-level visualizations for project presentations |
| **SVG Reference Input** | Use existing drawings as reference for more accurate renderings |
| **20 MCP Tools** | Now includes rendering alongside planning, estimation, and documentation |
---
## Features
### Planning & Analysis (11 Tools)
| Tool | Description |
|------|-------------|
| `civilplan_parse_project` | Parse natural language project descriptions into structured data |
| `civilplan_get_legal_procedures` | Identify required permits, approvals, and legal procedures |
| `civilplan_get_phase_checklist` | Get phase-by-phase project checklists |
| `civilplan_evaluate_impact_assessments` | Evaluate 9 environmental/feasibility assessments |
| `civilplan_estimate_quantities` | Calculate material volumes from standard cross-sections |
| `civilplan_get_unit_prices` | Retrieve regional unit cost data (조달청 기준) |
| `civilplan_get_applicable_guidelines` | Find applicable design guidelines |
| `civilplan_fetch_guideline_summary` | Fetch guideline details |
| `civilplan_select_bid_type` | Recommend bidding methodology |
| `civilplan_estimate_waste_disposal` | Calculate construction waste estimates |
| `civilplan_query_land_info` | Query land/cadastral information (V-World API) |
### Financial Analysis (2 Tools)
| Tool | Description |
|------|-------------|
| `civilplan_analyze_feasibility` | IRR, NPV, payback period, DSCR analysis |
| `civilplan_validate_against_benchmark` | Validate costs against government benchmarks |
### Document Generation (7 Tools)
| Tool | Description |
|------|-------------|
| `civilplan_generate_boq_excel` | Excel BOQ with cost breakdown (6 sheets) |
| `civilplan_generate_investment_doc` | Word investment plan document |
| `civilplan_generate_budget_report` | Budget report document |
| `civilplan_generate_schedule` | Gantt-style project timeline (Excel) |
| `civilplan_generate_svg_drawing` | SVG conceptual drawings |
| `civilplan_generate_dxf_drawing` | DXF CAD drawings |
| `civilplan_generate_birdseye_view` | **NEW** 3D aerial + perspective renderings |
### Supported Domains
| Domain | Korean | Status |
|--------|--------|--------|
| Roads | 토목_도로 | Full support |
| Buildings | 건축 | Full support |
| Water/Sewerage | 토목_상하수도 | Full support |
| Rivers | 토목_하천 | Full support |
| Landscaping | 조경 | Partial (in progress) |
| Mixed | 복합 | Full support |
---
## Quick Start
### 1. Clone & Install
```bash
git clone https://github.com/sinmb79/Construction-project-master.git
cd Construction-project-master
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS/Linux
source .venv/bin/activate
pip install -r requirements.txt
2. Configure API Keys
Copy the example environment file:
cp .env.example .env
Edit .env and add your keys:
# Required for 3D rendering (Nano Banana Pro)
GEMINI_API_KEY=your_gemini_api_key_here
# Optional - for land information queries
DATA_GO_KR_API_KEY=your_data_go_kr_key
VWORLD_API_KEY=your_vworld_key
How to get a Gemini API key:
- Go to Google AI Studio
- Click "Get API key" in the left sidebar
- Create a new API key or use an existing project
- Copy the key to your
.envfile
Alternative: Encrypted local storage (Windows)
python setup_keys.py
3. Start the Server
python server.py
Server runs at: http://127.0.0.1:8765/mcp
Connecting to AI Agents
Claude Desktop
Add to your Claude Desktop config file:
Windows: %APPDATA%\Claude\claude_desktop_config.json
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"civilplan": {
"url": "http://127.0.0.1:8765/mcp"
}
}
}
Restart Claude Desktop after editing.
Claude Code (CLI)
claude mcp add civilplan http://127.0.0.1:8765/mcp
ChatGPT / OpenAI
ChatGPT supports MCP servers via compatible plugins or custom GPT configurations.
Connect using the server URL: http://127.0.0.1:8765/mcp
Other MCP Clients
Any MCP-compatible client can connect using:
- Transport: Streamable HTTP
- URL:
http://127.0.0.1:8765/mcp
Example: Full Project Workflow
Input to AI agent:
"경기도 화성시 지방도 890m, 폭 6m, 2차선 아스팔트 도로, 상하수도 포함, 2026~2028년 시행"
The AI agent can then use CivilPlan tools to:
- Parse → Structured project data (domain, dimensions, region, utilities)
- Legal procedures → 18 required permits, 18-month approval timeline
- Impact assessment → 9 assessment categories evaluated
- Quantity estimation → Material volumes (토공, 포장, 배수, 상하수도)
- Unit pricing → Regional cost data applied (경기도 factor: 1.05)
- BOQ generation → Excel with 6 sheets, total ~10.67 billion KRW
- Investment document → Word document for approval process
- Schedule → Gantt timeline with phase durations
- Drawings → SVG/DXF conceptual drawings
- 3D Rendering → Bird's-eye view + perspective visualization
3D Rendering (Bird's-Eye View)
The civilplan_generate_birdseye_view tool generates two images per call:
| Output | Description |
|---|---|
| Bird's-eye view | Aerial 45-60° angle showing full project extent |
| Perspective view | Eye-level view from the most representative viewpoint |
Input options:
- Text-only: Uses project description and technical details
- Text + SVG: Uses existing SVG drawing as spatial reference for more accurate results
Resolution: 2K (default) or 4K
Powered by: Google Nano Banana Pro (Gemini 3 Pro Image) via the google-genai SDK
Architecture
MCP Client (Claude / ChatGPT / any MCP client)
│
│ MCP Protocol (Streamable HTTP)
▼
┌─────────────────────────────────────┐
│ CivilPlan MCP Server │
│ (FastMCP 2.0+) │
│ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ 20 Tools │ │ Data Layer │ │
│ │ (parse, │ │ (JSON, SQL, │ │
│ │ legal, │ │ CSV) │ │
│ │ render) │ │ │ │
│ └────┬─────┘ └──────────────┘ │
│ │ │
│ ┌────▼─────────────────────┐ │
│ │ Services │ │
│ │ ├── Gemini Image API │ │
│ │ ├── V-World API │ │
│ │ └── Public Data Portal │ │
│ └───────────────────────────┘ │
└─────────────────────────────────────┘
Data Sources
| Data | Source | Update Frequency |
|---|---|---|
| Unit prices | 조달청 표준시장단가 | Bi-annual (Jan, Jul) |
| Wages | 건설업 임금실태조사 | Bi-annual (Jan, Sep) |
| Legal procedures | 법제처 법령정보 | Manual updates |
| Region factors | 통계청 | Annual |
| Land prices | User-supplied CSV | As needed |
The server includes an automatic update scheduler (APScheduler) that checks for new data releases.
Limitations
| Item | Detail |
|---|---|
| Accuracy | ±20-30% (planning stage estimates only) |
| Scope | Conceptual planning — not for detailed engineering design |
| Landscaping | Legal procedure data incomplete |
| Land data | Requires manual CSV download to data/land_prices/ |
| Rendering | Requires internet + Gemini API key |
| Platform | Encrypted key storage (DPAPI) is Windows-only; use .env on other platforms |
Disclaimer
참고용 개략 자료 — 공식 제출 불가
All outputs are approximate reference materials for planning purposes only. They cannot replace professional engineering design review and are not valid for official submission. Verify all results with domain experts before use in actual projects.
License
MIT License. Free to use, modify, and distribute.
Author
22B Labs — Hongik Ingan: reducing inequality in access to expert planning knowledge.
- [ ] **Step 3: Commit**
```bash
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add README.md
git commit -m "docs: rewrite README for v2.0.0 with rendering guide and connection instructions"
Task 7: Final Validation and Release
Files:
-
No new files
-
Step 1: Run full test suite
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/ -v
Expected: All tests PASS
- Step 2: Verify server starts without errors
Run: cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && timeout 5 python server.py 2>&1 || true
Expected: Server starts, shows warnings for missing API keys, no import errors
- Step 3: Tag release
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git tag -a v2.0.0 -m "v2.0.0: Add 3D bird's-eye view rendering with Nano Banana Pro"
- Step 4: Push to remote with tags
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git push origin main --tags
- Step 5: Create GitHub release
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
gh release create v2.0.0 --title "CivilPlan MCP v2.0.0 — 3D Bird's-Eye View Rendering" --notes "$(cat <<'NOTES'
## What's New
### 3D Bird's-Eye View Rendering
- New `civilplan_generate_birdseye_view` MCP tool
- Powered by Google Nano Banana Pro (Gemini 3 Pro Image)
- Generates aerial (bird's-eye) + eye-level (perspective) renderings
- Supports text-only or text + SVG reference image input
- 2K / 4K resolution options
- Domain-specific prompt templates for road, building, water, river, landscape, and mixed projects
### Infrastructure
- New Gemini API integration via `google-genai` SDK
- Added `GEMINI_API_KEY` to configuration system
- Now 20 MCP tools total
### Documentation
- Complete README rewrite with:
- Tool reference tables
- Claude Desktop / Claude Code / ChatGPT connection guides
- API key setup instructions
- Full workflow example
- Architecture diagram
## Installation
```bash
git clone https://github.com/sinmb79/Construction-project-master.git
cd Construction-project-master
python -m venv .venv && .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
# Add your GEMINI_API_KEY to .env
python server.py
Breaking Changes
None. All existing 19 tools remain unchanged. NOTES )"
Expected: Release created on GitHub