Blender AI Wiki / English Поиск

MCP ADVANCED: STREAMING & TOOL COMPOSITION

Advanced MCP patterns for production AI agents working with Blender

⚡ MCP (продвинутый) 10 блоков кода обновлено 2026-10-01

Advanced MCP patterns for production AI agents working with Blender


🎯 STREAMING RESPONSES

Real-time Tool Output

# MCP Client with streaming support
class StreamingMCPClient:
    def __init__(self, server_cmd: list[str]):
        self.process = await asyncio.create_subprocess_exec(
            *server_cmd,
            stdin=asyncio.subprocess.PIPE,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
    
    async def stream_tool(self, tool_name: str, params: dict):
        """Yield partial results as they arrive"""
        request = {
            "jsonrpc": "2.0",
            "id": uuid.uuid4().hex,
            "method": "tools/call",
            "params": {"name": tool_name, "arguments": params}
        }
        
        self.process.stdin.write((json.dumps(request) + "\n").encode())
        await self.process.stdin.drain()
        
        buffer = ""
        async for line in self.process.stdout:
            line = line.decode().strip()
            if not line:
                continue
            try:
                response = json.loads(line)
                if "result" in response:
                    # Stream partial content
                    if isinstance(response["result"], str):
                        for chunk in response["result"].split():
                            yield chunk + " "
                    else:
                        yield json.dumps(response["result"])
                break
            except json.JSONDecodeError:
                continue  # Incomplete JSON, wait for more

Progressive Scene Building

# Build complex scene incrementally with feedback
async def build_scene_progressively(client, scene_description: str):
    steps = [
        ("Analyzing requirements", analyze_requirements),
        ("Creating base geometry", create_base_geometry),
        ("Adding materials", apply_materials),
        ("Setting up lighting", setup_lighting),
        ("Configuring camera", setup_camera),
        ("Final render", render_scene),
    ]
    
    for step_name, step_func in steps:
        yield {"status": "progress", "step": step_name}
        result = await step_func()
        yield {"status": "step_complete", "step": step_name, "result": result}
    
    yield {"status": "complete", "scene": "ready"}

🔗 TOOL COMPOSITION (PIPELINES)

Sequential Pipeline

class MCPipeline:
    def __init__(self, client):
        self.client = client
        self.steps = []
    
    def add_step(self, tool_name: str, params_map: Callable):
        """params_map: (prev_result) -> next_params"""
        self.steps.append((tool_name, params_map))
        return self
    
    async def run(self, initial_params: dict):
        result = initial_params
        for tool_name, params_map in self.steps:
            params = params_map(result)
            result = await self.client.call_tool(tool_name, params)
            if "error" in result:
                raise PipelineError(f"{tool_name} failed: {result['error']}")
        return result

# Usage: Search → Download → Import → Material
pipeline = MCPipeline(client) \
    .add_step("search_polyhaven_assets", lambda r: {
        "asset_type": "models", "categories": ["chair"], "query": r["query"]
    }) \
    .add_step("download_polyhaven_asset", lambda r: {
        "asset_id": r["results"][0]["id"], 
        "asset_type": "models", 
        "resolution": "2k"
    }) \
    .add_step("set_material", lambda r: {
        "object_name": r["imported_object"],
        "material_name": "Mat_Chair",
        "color": [0.8, 0.6, 0.4, 1.0]
    })

result = await pipeline.run({"query": "modern office chair"})

Parallel Execution with Dependency Graph

class DependencyGraph:
    def __init__(self):
        self.nodes = {}  # tool_name -> {params, deps: []}
    
    def add(self, name: str, params: dict, depends_on: list[str] = None):
        self.nodes[name] = {"params": params, "deps": depends_on or []}
    
    async def execute(self, client):
        completed = {}
        pending = set(self.nodes.keys())
        
        while pending:
            # Find ready nodes (all deps completed)
            ready = [
                name for name in pending
                if all(dep in completed for dep in self.nodes[name]["deps"])
            ]
            
            if not ready:
                raise CircularDependencyError()
            
            # Execute ready nodes in parallel
            tasks = [
                client.call_tool(name, self.nodes[name]["params"])
                for name in ready
            ]
            results = await asyncio.gather(*tasks)
            
            for name, result in zip(ready, results):
                completed[name] = result
                pending.remove(name)
        
        return completed

# Usage: Parallel asset downloads
graph = DependencyGraph()
graph.add("download_chair", {"asset_id": "chair_01", "type": "models"}, [])
graph.add("download_table", {"asset_id": "table_01", "type": "models"}, [])
graph.add("download_lamp", {"asset_id": "lamp_01", "type": "models"}, [])
graph.add("setup_scene", {"assets": ["chair", "table", "lamp"]}, 
          ["download_chair", "download_table", "download_lamp"])

results = await graph.execute(client)

⚡ ERROR HANDLING & RETRY LOGIC

Retry with Exponential Backoff

class ResilientMCPClient:
    def __init__(self, client, max_retries=3, base_delay=1.0):
        self.client = client
        self.max_retries = max_retries
        self.base_delay = base_delay
    
    async def call_with_retry(self, tool_name: str, params: dict):
        last_error = None
        for attempt in range(self.max_retries + 1):
            try:
                result = await self.client.call_tool(tool_name, params)
                if "error" not in result:
                    return result
                last_error = result["error"]
            except Exception as e:
                last_error = str(e)
            
            if attempt < self.max_retries:
                delay = self.base_delay * (2 ** attempt) + random.uniform(0, 0.1)
                await asyncio.sleep(delay)
        
        raise MCPError(f"Failed after {self.max_retries} retries: {last_error}")

Circuit Breaker Pattern

class CircuitBreaker:
    def __init__(self, failure_threshold=5, timeout=60):
        self.failures = 0
        self.failure_threshold = failure_threshold
        self.timeout = timeout
        self.state = "closed"  # closed, open, half-open
        self.last_failure_time = None
    
    async def call(self, func, *args, **kwargs):
        if self.state == "open":
            if time.time() - self.last_failure_time > self.timeout:
                self.state = "half-open"
            else:
                raise CircuitOpenError("Circuit breaker open")
        
        try:
            result = await func()
            if self.state == "half-open":
                self.state = "closed"
                self.failures = 0
            return result
        except Exception as e:
            self.failures += 1
            self.last_failure_time = time.time()
            if self.failures >= self.failure_threshold:
                self.state = "open"
            raise

🔒 SECURITY & SANDBOXING

Blender-MCP Safe Mode Configuration

# Server-side safe mode enforcement
class SafeModeValidator:
    FORBIDDEN_MODULES = {
        'os', 'sys', 'subprocess', 'socket', 'urllib', 'requests',
        'importlib', 'pkgutil', 'runpy', 'zipimport',
        'threading', 'multiprocessing', 'asyncio',
        'ctypes', 'cffi', 'crypt', 'hashlib', 'secrets',
        'tempfile', 'shutil', 'glob', 'fnmatch', 'linecache',
        'pickle', 'shelve', 'dbm', 'sqlite3', 'csv',
        'configparser', 'tomllib', 'json', 'xml', 'yaml',
    }
    
    FORBIDDEN_BUILTINS = {
        'eval', 'exec', 'compile', 'open', '__import__',
        'getattr', 'setattr', 'delattr', 'globals', 'locals',
        'vars', 'dir', 'help', 'breakpoint',
    }
    
    FORBIDDEN_BPY = {
        'bpy.ops.wm.open_mainfile',
        'bpy.ops.wm.save_as_mainfile',
        'bpy.ops.wm.read_factory_settings',
        'bpy.ops.script.python_file_run',
        'bpy.ops.text.run_script',
        'bpy.app.handlers',
        'bpy.app.timers',
        'bpy.app.driver_namespace',
        'bpy.utils.register_class',
        'bpy.utils.unregister_class',
        'bpy.types',
        'bpy.data.libraries.load',
        'bpy.data.texts.new',
        'bpy.data.texts.remove',
    }
    
    @classmethod
    def validate_code(cls, code: str) -> tuple[bool, str]:
        tree = ast.parse(code)
        
        for node in ast.walk(tree):
            # Check imports
            if isinstance(node, (ast.Import, ast.ImportFrom)):
                for alias in node.names:
                    if alias.name.split('.')[0] in cls.FORBIDDEN_MODULES:
                        return False, f"Forbidden import: {alias.name}"
            
            # Check builtin calls
            if isinstance(node, ast.Call):
                if isinstance(node.func, ast.Name):
                    if node.func.id in cls.FORBIDDEN_BUILTINS:
                        return False, f"Forbidden builtin: {node.func.id}"
                elif isinstance(node.func, ast.Attribute):
                    full_name = ast.unparse(node.func)
                    if any(full_name.startswith(f) for f in cls.FORBIDDEN_BPY):
                        return False, f"Forbidden bpy: {full_name}"
        
        return True, "OK"

Client-Side Permission Model

class PermissionManager:
    PERMISSIONS = {
        "read_scene": ["get_scene_info", "get_object_info", "get_viewport_screenshot"],
        "modify_scene": ["create_object", "modify_object", "delete_object"],
        "materials": ["set_material", "create_material"],
        "assets": ["download_polyhaven_asset", "search_polyhaven_assets"],
        "ai_generation": ["create_rodin_job", "poll_rodin_job_status"],
        "code_execution": ["execute_blender_code"],
        "admin": ["get_addon_info", "set_telemetry_consent"],
    }
    
    def __init__(self, allowed_roles: list[str]):
        self.allowed = set()
        for role in allowed_roles:
            self.allowed.update(self.PERMISSIONS.get(role, []))
    
    def check(self, tool_name: str) -> bool:
        return tool_name in self.allowed
    
    def filter_tools(self, all_tools: list) -> list:
        return [t for t in all_tools if t["name"] in self.allowed]

📊 OBSERVABILITY & LOGGING

Structured Logging for MCP

import structlog

logger = structlog.get_logger()

class ObservableMCPClient:
    def __init__(self, client):
        self.client = client
        self.logger = structlog.get_logger("mcp.client")
    
    async def call_tool(self, tool_name: str, params: dict):
        start = time.time()
        request_id = uuid.uuid4().hex[:8]
        
        self.logger.info("mcp_request", 
            request_id=request_id,
            tool=tool_name,
            params_keys=list(params.keys())
        )
        
        try:
            result = await self.client.call_tool(tool_name, params)
            duration = time.time() - start
            
            self.logger.info("mcp_response",
                request_id=request_id,
                tool=tool_name,
                duration_ms=round((time.time() - start) * 1000, 2),
                success="error" not in result
            )
            return result
        except Exception as e:
            self.logger.error("mcp_error",
                request_id=request_id,
                tool=tool_name,
                error=str(e),
                duration_ms=round((time.time() - start) * 1000, 2)
            )
            raise

🧪 TESTING MCP TOOLS

Unit Test Framework

import pytest
from unittest.mock import AsyncMock, MagicMock

class TestMCPTools:
    @pytest.fixture
    def mock_client(self):
        client = MagicMock()
        client.call_tool = AsyncMock()
        return client
    
    @pytest.mark.asyncio
    async def test_create_object(self, mock_client):
        mock_client.call_tool.return_value = {
            "name": "Cube", "type": "MESH", "location": [0, 0, 0]
        }
        
        result = await create_object(mock_client, "CUBE", "TestCube", [1, 2, 3])
        
        assert result["name"] == "TestCube"
        mock_client.call_tool.assert_called_once_with(
            "create_object",
            {"entity_type": "CUBE", "name": "TestCube", "location": [1, 2, 3]}
        )
    
    @pytest.mark.asyncio
    async def test_retry_on_failure(self, mock_client):
        mock_client.call_tool.side_effect = [
            Exception("Network error"),
            {"name": "Cube", "type": "MESH"}
        ]
        
        client = ResilientMCPClient(mock_client, max_retries=2)
        result = await client.call_with_retry("create_object", {})
        
        assert result["name"] == "Cube"
        assert mock_client.call_tool.call_count == 2

# Integration test with real Blender
@pytest.mark.integration
@pytest.mark.asyncio
async def test_full_scene_creation(blender_mcp_client):
    """Requires running Blender with MCP server"""
    result = await blender_mcp_client.call_tool("get_scene_info", {})
    assert "objects" in result
    assert isinstance(result["objects"], list)


🌍 MULTILANG

Language File
English multilang/en/mcp_advanced/streaming_tools.md
Russian multilang/ru/mcp_advanced/streaming_tools.md

Связанные материалы

Подобраны по тегам и категории.