@hybridlabor-api/aos 4.4.1 → 4.4.2-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/firecrawl/SKILL.md +149 -0
- package/.agents/skills/firecrawl/rules/install.md +82 -0
- package/.agents/skills/firecrawl/rules/security.md +26 -0
- package/.agents/skills/firecrawl-agent/SKILL.md +58 -0
- package/.agents/skills/firecrawl-build/SKILL.md +39 -0
- package/.agents/skills/firecrawl-build-interact/SKILL.md +68 -0
- package/.agents/skills/firecrawl-build-onboarding/SKILL.md +103 -0
- package/.agents/skills/firecrawl-build-onboarding/references/auth-flow.md +39 -0
- package/.agents/skills/firecrawl-build-onboarding/references/project-setup.md +20 -0
- package/.agents/skills/firecrawl-build-onboarding/references/sdk-installation.md +17 -0
- package/.agents/skills/firecrawl-build-scrape/SKILL.md +69 -0
- package/.agents/skills/firecrawl-build-search/SKILL.md +69 -0
- package/.agents/skills/firecrawl-crawl/SKILL.md +59 -0
- package/.agents/skills/firecrawl-download/SKILL.md +70 -0
- package/.agents/skills/firecrawl-interact/SKILL.md +84 -0
- package/.agents/skills/firecrawl-map/SKILL.md +51 -0
- package/.agents/skills/firecrawl-scrape/SKILL.md +69 -0
- package/.agents/skills/firecrawl-search/SKILL.md +60 -0
- package/CLAUDE.md +573 -0
- package/README.de.md +0 -8
- package/README.md +0 -8
- package/README.pt.md +0 -8
- package/assets/brand/ao-ant-mark.svg +91 -0
- package/assets/brand/bdb-core-mark.svg +34 -0
- package/assets/brand/heimdall-guardian-mark.svg +16 -0
- package/assets/brand/memb-crystal-mark.svg +18 -0
- package/bin/aos-config.mjs +89 -0
- package/bin/aos-dashboard.mjs +608 -0
- package/bin/aos-uninstall.mjs +227 -0
- package/docs/sessions/audit-2026-09-14-silent-failures.md +114 -0
- package/docs/skills_table.md +0 -3
- package/installer.js +387 -237
- package/mcps/RhinoMCP/cc-plugin/.claude/settings.json +10 -0
- package/mcps/after-effects-mcp/build/index.js +840 -0
- package/mcps/after-effects-mcp/build/scripts/applyEffect.jsx +153 -0
- package/mcps/after-effects-mcp/build/scripts/applyEffectTemplate.jsx +218 -0
- package/mcps/after-effects-mcp/build/scripts/createComposition.jsx +71 -0
- package/mcps/after-effects-mcp/build/scripts/createShapeLayer.jsx +147 -0
- package/mcps/after-effects-mcp/build/scripts/createSolidLayer.jsx +114 -0
- package/mcps/after-effects-mcp/build/scripts/createTextLayer.jsx +115 -0
- package/mcps/after-effects-mcp/build/scripts/getLayerInfo.jsx +192 -0
- package/mcps/after-effects-mcp/build/scripts/getProjectInfo.jsx +90 -0
- package/mcps/after-effects-mcp/build/scripts/listCompositions.jsx +50 -0
- package/mcps/after-effects-mcp/build/scripts/mcp-bridge-auto.jsx +1773 -0
- package/mcps/after-effects-mcp/build/scripts/setLayerProperties.jsx +160 -0
- package/mcps/computer-use-mcp/dist/client.d.ts +150 -0
- package/mcps/computer-use-mcp/dist/client.js +136 -0
- package/mcps/computer-use-mcp/dist/entrypoint.d.ts +16 -0
- package/mcps/computer-use-mcp/dist/entrypoint.js +26 -0
- package/mcps/computer-use-mcp/dist/native.d.ts +212 -0
- package/mcps/computer-use-mcp/dist/native.js +50 -0
- package/mcps/computer-use-mcp/dist/server.d.ts +32 -0
- package/mcps/computer-use-mcp/dist/server.js +342 -0
- package/mcps/computer-use-mcp/dist/session.d.ts +101 -0
- package/mcps/computer-use-mcp/dist/session.js +2372 -0
- package/package.json +13 -4
- package/skills/global_config/aos-project-init/SKILL.md +97 -130
- package/skills/global_config/aos-project-init/assets/openwikiignore.template +62 -0
- package/skills/global_config/aos-project-init/scripts/aos-project-doctor.mjs +38 -1
- package/skills/global_config/aos-setup/SKILL.md +98 -8
- package/skills/global_config/aos-setup/scripts/aos-doctor.mjs +21 -2
- package/skills/global_config/ask-tim/SKILL.md +2 -7
- package/skills/global_config/openwiki-skill/scripts/openwiki_daemon.py +65 -12
- package/bin/setup-saas.mjs +0 -792
- package/mcps/blender-mcp-server/.dockerignore +0 -19
- package/mcps/blender-mcp-server/.github/workflows/ci.yml +0 -67
- package/mcps/blender-mcp-server/.github/workflows/publish-pypi.yml +0 -54
- package/mcps/blender-mcp-server/CHANGELOG.md +0 -64
- package/mcps/blender-mcp-server/CONTRIBUTING.md +0 -107
- package/mcps/blender-mcp-server/Dockerfile +0 -19
- package/mcps/blender-mcp-server/LICENSE +0 -21
- package/mcps/blender-mcp-server/README.md +0 -409
- package/mcps/blender-mcp-server/addon/__init__.py +0 -1440
- package/mcps/blender-mcp-server/addon/models.py +0 -223
- package/mcps/blender-mcp-server/pyproject.toml +0 -120
- package/mcps/blender-mcp-server/scripts/blender_bridge_request.py +0 -71
- package/mcps/blender-mcp-server/scripts/blender_create_test_cube.py +0 -49
- package/mcps/blender-mcp-server/scripts/blender_scene_info.py +0 -38
- package/mcps/blender-mcp-server/scripts/build_addon_zip.sh +0 -29
- package/mcps/blender-mcp-server/scripts/demos/README.md +0 -186
- package/mcps/blender-mcp-server/scripts/demos/dam_break_scene.py +0 -276
- package/mcps/blender-mcp-server/scripts/demos/open_dam_break_gui.py +0 -33
- package/mcps/blender-mcp-server/scripts/demos/pipe_movement_study.py +0 -518
- package/mcps/blender-mcp-server/scripts/demos/pipe_pressure_failure_scene.py +0 -806
- package/mcps/blender-mcp-server/scripts/demos/procedural_dam_break_scene.py +0 -600
- package/mcps/blender-mcp-server/scripts/demos/run_dam_break.py +0 -510
- package/mcps/blender-mcp-server/scripts/launch_blender_gui.py +0 -41
- package/mcps/blender-mcp-server/scripts/library/README.md +0 -64
- package/mcps/blender-mcp-server/scripts/library/apply_transforms.py +0 -46
- package/mcps/blender-mcp-server/scripts/library/camera.py +0 -51
- package/mcps/blender-mcp-server/scripts/library/collections.py +0 -65
- package/mcps/blender-mcp-server/scripts/library/create_mesh.py +0 -41
- package/mcps/blender-mcp-server/scripts/library/effector.py +0 -48
- package/mcps/blender-mcp-server/scripts/library/fluid_domain.py +0 -80
- package/mcps/blender-mcp-server/scripts/library/fluid_inflow.py +0 -98
- package/mcps/blender-mcp-server/scripts/library/frame_range.py +0 -35
- package/mcps/blender-mcp-server/scripts/library/keyframes.py +0 -59
- package/mcps/blender-mcp-server/scripts/library/rigid_body.py +0 -52
- package/mcps/blender-mcp-server/scripts/library/save_blend.py +0 -40
- package/mcps/blender-mcp-server/scripts/server_start.sh +0 -26
- package/mcps/blender-mcp-server/src/blender_mcp_server/__init__.py +0 -3
- package/mcps/blender-mcp-server/src/blender_mcp_server/headless.py +0 -330
- package/mcps/blender-mcp-server/src/blender_mcp_server/server.py +0 -606
- package/mcps/davinci-mcp-professional/.bandit +0 -35
- package/mcps/davinci-mcp-professional/.claude/commands/security-review.md +0 -191
- package/mcps/davinci-mcp-professional/.claude/settings.json +0 -1
- package/mcps/davinci-mcp-professional/.editorconfig +0 -27
- package/mcps/davinci-mcp-professional/.gitattributes +0 -207
- package/mcps/davinci-mcp-professional/.github/CODEOWNERS +0 -2
- package/mcps/davinci-mcp-professional/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
- package/mcps/davinci-mcp-professional/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
- package/mcps/davinci-mcp-professional/.github/PULL_REQUEST_TEMPLATE.md +0 -20
- package/mcps/davinci-mcp-professional/.github/SECURITY.md +0 -97
- package/mcps/davinci-mcp-professional/.github/dependabot.yml +0 -27
- package/mcps/davinci-mcp-professional/.github/workflows/ci.yml +0 -39
- package/mcps/davinci-mcp-professional/ATTRIBUTION.md +0 -4
- package/mcps/davinci-mcp-professional/AUTHORS.md +0 -9
- package/mcps/davinci-mcp-professional/BUGS.md +0 -55
- package/mcps/davinci-mcp-professional/CLAUDE.md +0 -229
- package/mcps/davinci-mcp-professional/CONTRIBUTING.md +0 -51
- package/mcps/davinci-mcp-professional/COPYING.md +0 -228
- package/mcps/davinci-mcp-professional/Doxyfile +0 -379
- package/mcps/davinci-mcp-professional/README.md +0 -143
- package/mcps/davinci-mcp-professional/SECURITY.md +0 -40
- package/mcps/davinci-mcp-professional/USING.md +0 -301
- package/mcps/davinci-mcp-professional/VERSION.md +0 -13
- package/mcps/davinci-mcp-professional/build.py +0 -169
- package/mcps/davinci-mcp-professional/claude_desktop_config_template.json +0 -11
- package/mcps/davinci-mcp-professional/hooks/ci-check.sh +0 -29
- package/mcps/davinci-mcp-professional/hooks/pre-push +0 -46
- package/mcps/davinci-mcp-professional/main.py +0 -40
- package/mcps/davinci-mcp-professional/mcp_server.py +0 -30
- package/mcps/davinci-mcp-professional/pyproject.toml +0 -115
- package/mcps/davinci-mcp-professional/security_audit.py +0 -379
- package/mcps/davinci-mcp-professional/src/davinci_mcp/__init__.py +0 -19
- package/mcps/davinci-mcp-professional/src/davinci_mcp/cli.py +0 -138
- package/mcps/davinci-mcp-professional/src/davinci_mcp/resolve_client.py +0 -342
- package/mcps/davinci-mcp-professional/src/davinci_mcp/resources/__init__.py +0 -60
- package/mcps/davinci-mcp-professional/src/davinci_mcp/server.py +0 -185
- package/mcps/davinci-mcp-professional/src/davinci_mcp/tools/__init__.py +0 -146
- package/mcps/davinci-mcp-professional/src/davinci_mcp/types.py +0 -125
- package/mcps/davinci-mcp-professional/src/davinci_mcp/utils/__init__.py +0 -19
- package/mcps/davinci-mcp-professional/src/davinci_mcp/utils/platform.py +0 -239
- package/mcps/davinci-mcp-professional/test.py +0 -186
- package/mcps/davinci-mcp-professional/uv.lock +0 -2073
- package/mcps/davinci-resolve-mcp/.claude/skills/media-analysis.md +0 -44
- package/mcps/davinci-resolve-mcp/.clinerules +0 -17
- package/mcps/davinci-resolve-mcp/.cursorrules +0 -17
- package/mcps/davinci-resolve-mcp/.github/copilot-instructions.md +0 -17
- package/mcps/davinci-resolve-mcp/.github/workflows/npm-publish.yml +0 -109
- package/mcps/davinci-resolve-mcp/.windsurfrules +0 -17
- package/mcps/davinci-resolve-mcp/AGENTS.md +0 -114
- package/mcps/davinci-resolve-mcp/CHANGELOG.md +0 -2648
- package/mcps/davinci-resolve-mcp/CLAUDE.md +0 -15
- package/mcps/davinci-resolve-mcp/LICENSE +0 -21
- package/mcps/davinci-resolve-mcp/README.md +0 -173
- package/mcps/davinci-resolve-mcp/SECURITY.md +0 -53
- package/mcps/davinci-resolve-mcp/bin/davinci-resolve-mcp.mjs +0 -450
- package/mcps/davinci-resolve-mcp/examples/README.md +0 -53
- package/mcps/davinci-resolve-mcp/examples/markers/README.md +0 -81
- package/mcps/davinci-resolve-mcp/examples/media/README.md +0 -94
- package/mcps/davinci-resolve-mcp/examples/timeline/README.md +0 -98
- package/mcps/davinci-resolve-mcp/install.py +0 -1687
- package/mcps/davinci-resolve-mcp/package.json +0 -52
- package/mcps/davinci-resolve-mcp/release-notes/v2.22.0.md +0 -51
- package/mcps/davinci-resolve-mcp/release-notes/v2.23.0.md +0 -51
- package/mcps/davinci-resolve-mcp/release-notes/v2.23.1.md +0 -35
- package/mcps/davinci-resolve-mcp/release-notes/v2.24.1.md +0 -32
- package/mcps/davinci-resolve-mcp/scripts/audit_api_parity.py +0 -275
- package/mcps/davinci-resolve-mcp/scripts/audit_readwrite_symmetry.py +0 -80
- package/mcps/davinci-resolve-mcp/scripts/doctor.py +0 -227
- package/mcps/davinci-resolve-mcp/scripts/gen_api_limitations.py +0 -144
- package/mcps/davinci-resolve-mcp/scripts/live_media_analysis_polish_probe.py +0 -65
- package/mcps/davinci-resolve-mcp/scripts/measure_bridge_cost.py +0 -65
- package/mcps/davinci-resolve-mcp/scripts/regen_panel_screenshots.py +0 -76
- package/mcps/davinci-resolve-mcp/src/__init__.py +0 -3
- package/mcps/davinci-resolve-mcp/src/analysis_dashboard.py +0 -15505
- package/mcps/davinci-resolve-mcp/src/batch_cli.py +0 -540
- package/mcps/davinci-resolve-mcp/src/control_panel.py +0 -13
- package/mcps/davinci-resolve-mcp/src/granular/__init__.py +0 -17
- package/mcps/davinci-resolve-mcp/src/granular/common.py +0 -741
- package/mcps/davinci-resolve-mcp/src/granular/folder.py +0 -420
- package/mcps/davinci-resolve-mcp/src/granular/gallery.py +0 -306
- package/mcps/davinci-resolve-mcp/src/granular/graph.py +0 -309
- package/mcps/davinci-resolve-mcp/src/granular/media_pool.py +0 -679
- package/mcps/davinci-resolve-mcp/src/granular/media_pool_item.py +0 -1020
- package/mcps/davinci-resolve-mcp/src/granular/media_storage.py +0 -179
- package/mcps/davinci-resolve-mcp/src/granular/project.py +0 -1654
- package/mcps/davinci-resolve-mcp/src/granular/resolve_control.py +0 -538
- package/mcps/davinci-resolve-mcp/src/granular/timeline.py +0 -1075
- package/mcps/davinci-resolve-mcp/src/granular/timeline_item.py +0 -2251
- package/mcps/davinci-resolve-mcp/src/resolve_mcp_server.py +0 -43
- package/mcps/davinci-resolve-mcp/src/server.py +0 -24116
- package/mcps/davinci-resolve-mcp/src/utils/__init__.py +0 -3
- package/mcps/davinci-resolve-mcp/src/utils/actor_identity.py +0 -46
- package/mcps/davinci-resolve-mcp/src/utils/analysis_caps.py +0 -678
- package/mcps/davinci-resolve-mcp/src/utils/analysis_memory.py +0 -755
- package/mcps/davinci-resolve-mcp/src/utils/analysis_runs.py +0 -307
- package/mcps/davinci-resolve-mcp/src/utils/analysis_store.py +0 -945
- package/mcps/davinci-resolve-mcp/src/utils/api_truth.py +0 -525
- package/mcps/davinci-resolve-mcp/src/utils/app_control.py +0 -319
- package/mcps/davinci-resolve-mcp/src/utils/audio_fairlight_live_probe.py +0 -263
- package/mcps/davinci-resolve-mcp/src/utils/brain_edits.py +0 -387
- package/mcps/davinci-resolve-mcp/src/utils/bridge_metrics.py +0 -76
- package/mcps/davinci-resolve-mcp/src/utils/cdl.py +0 -20
- package/mcps/davinci-resolve-mcp/src/utils/clip_query.py +0 -85
- package/mcps/davinci-resolve-mcp/src/utils/cloud_operations.py +0 -192
- package/mcps/davinci-resolve-mcp/src/utils/color_grade_live_probe.py +0 -444
- package/mcps/davinci-resolve-mcp/src/utils/contracts.py +0 -102
- package/mcps/davinci-resolve-mcp/src/utils/cut_ir.py +0 -115
- package/mcps/davinci-resolve-mcp/src/utils/dctl_templates.py +0 -368
- package/mcps/davinci-resolve-mcp/src/utils/deep_vision.py +0 -643
- package/mcps/davinci-resolve-mcp/src/utils/destructive_hook.py +0 -606
- package/mcps/davinci-resolve-mcp/src/utils/edit_engine.py +0 -737
- package/mcps/davinci-resolve-mcp/src/utils/embeddings.py +0 -922
- package/mcps/davinci-resolve-mcp/src/utils/entities.py +0 -579
- package/mcps/davinci-resolve-mcp/src/utils/extension_authoring_live_probe.py +0 -292
- package/mcps/davinci-resolve-mcp/src/utils/failure_tracker.py +0 -119
- package/mcps/davinci-resolve-mcp/src/utils/fuse_templates.py +0 -1968
- package/mcps/davinci-resolve-mcp/src/utils/fusion_composition_live_probe.py +0 -284
- package/mcps/davinci-resolve-mcp/src/utils/fusion_group_settings.py +0 -334
- package/mcps/davinci-resolve-mcp/src/utils/layout_presets.py +0 -333
- package/mcps/davinci-resolve-mcp/src/utils/mcp_stdio.py +0 -32
- package/mcps/davinci-resolve-mcp/src/utils/mcp_transport.py +0 -128
- package/mcps/davinci-resolve-mcp/src/utils/media_analysis.py +0 -7690
- package/mcps/davinci-resolve-mcp/src/utils/media_analysis_jobs.py +0 -805
- package/mcps/davinci-resolve-mcp/src/utils/media_pool_changes.py +0 -121
- package/mcps/davinci-resolve-mcp/src/utils/media_pool_ingest_live_probe.py +0 -592
- package/mcps/davinci-resolve-mcp/src/utils/multicam.py +0 -393
- package/mcps/davinci-resolve-mcp/src/utils/object_inspection.py +0 -301
- package/mcps/davinci-resolve-mcp/src/utils/page_lock.py +0 -75
- package/mcps/davinci-resolve-mcp/src/utils/platform.py +0 -157
- package/mcps/davinci-resolve-mcp/src/utils/proc.py +0 -26
- package/mcps/davinci-resolve-mcp/src/utils/project_cleanup.py +0 -73
- package/mcps/davinci-resolve-mcp/src/utils/project_lifecycle_live_probe.py +0 -376
- package/mcps/davinci-resolve-mcp/src/utils/project_lint.py +0 -131
- package/mcps/davinci-resolve-mcp/src/utils/project_properties.py +0 -601
- package/mcps/davinci-resolve-mcp/src/utils/project_spec.py +0 -459
- package/mcps/davinci-resolve-mcp/src/utils/readback.py +0 -91
- package/mcps/davinci-resolve-mcp/src/utils/render_deliver_live_probe.py +0 -384
- package/mcps/davinci-resolve-mcp/src/utils/resolve_ai_governance.py +0 -214
- package/mcps/davinci-resolve-mcp/src/utils/resolve_ai_ledger.py +0 -244
- package/mcps/davinci-resolve-mcp/src/utils/resolve_busy.py +0 -171
- package/mcps/davinci-resolve-mcp/src/utils/resolve_connection.py +0 -77
- package/mcps/davinci-resolve-mcp/src/utils/review_annotation_live_probe.py +0 -352
- package/mcps/davinci-resolve-mcp/src/utils/script_templates.py +0 -1193
- package/mcps/davinci-resolve-mcp/src/utils/shot_relationships.py +0 -544
- package/mcps/davinci-resolve-mcp/src/utils/structural_diff.py +0 -175
- package/mcps/davinci-resolve-mcp/src/utils/sync_detection.py +0 -889
- package/mcps/davinci-resolve-mcp/src/utils/timeline_brain_db.py +0 -826
- package/mcps/davinci-resolve-mcp/src/utils/timeline_conform_live_probe.py +0 -280
- package/mcps/davinci-resolve-mcp/src/utils/timeline_kernel_live_probe.py +0 -1092
- package/mcps/davinci-resolve-mcp/src/utils/timeline_kernel_probe.py +0 -185
- package/mcps/davinci-resolve-mcp/src/utils/timeline_title_text.py +0 -87
- package/mcps/davinci-resolve-mcp/src/utils/timeline_versioning.py +0 -730
- package/mcps/davinci-resolve-mcp/src/utils/update_check.py +0 -685
- package/mcps/vectorworks-mcp/.dockerignore +0 -8
- package/mcps/vectorworks-mcp/Dockerfile +0 -28
- package/mcps/vectorworks-mcp/README.md +0 -156
- package/mcps/vectorworks-mcp/app/__init__.py +0 -2
- package/mcps/vectorworks-mcp/app/api.py +0 -52
- package/mcps/vectorworks-mcp/app/chunking.py +0 -56
- package/mcps/vectorworks-mcp/app/config.py +0 -29
- package/mcps/vectorworks-mcp/app/embeddings.py +0 -28
- package/mcps/vectorworks-mcp/app/indexer.py +0 -106
- package/mcps/vectorworks-mcp/app/main.py +0 -33
- package/mcps/vectorworks-mcp/app/mcp_server.py +0 -71
- package/mcps/vectorworks-mcp/app/search.py +0 -54
- package/mcps/vectorworks-mcp/app/utils.py +0 -14
- package/mcps/vectorworks-mcp/data/.gitkeep +0 -1
- package/mcps/vectorworks-mcp/data/vw-app-help/VW2022_PluginScript.htm +0 -95
- package/mcps/vectorworks-mcp/data/vw-app-help/VW2022_ScriptEditor.htm +0 -266
- package/mcps/vectorworks-mcp/data/vw-app-help/VW2022_Scripting.htm +0 -106
- package/mcps/vectorworks-mcp/data/vw-app-help/VW2023_CreatingScriptedPlugins.htm +0 -327
- package/mcps/vectorworks-mcp/data/vw-app-help/VW2024_RunningScripts.htm +0 -116
- package/mcps/vectorworks-mcp/data/vw-devwiki/VS_Function_Reference_category.html +0 -2129
- package/mcps/vectorworks-mcp/data/vw-jp-ref/ObjectsSymbols.html +0 -2035
- package/mcps/vectorworks-mcp/data/vw-jp-ref/ScriptFunctionReference.html +0 -29
- package/mcps/vectorworks-mcp/design.md +0 -57
- package/mcps/vectorworks-mcp/docker-compose.yml +0 -18
- package/mcps/vectorworks-mcp/index/.gitkeep +0 -1
- package/mcps/vectorworks-mcp/index/meta.jsonl +0 -3956
- package/mcps/vectorworks-mcp/index/vw.faiss +0 -0
- package/mcps/vectorworks-mcp/requirements.txt +0 -10
- package/mcps/vectorworks-mcp/scripts/fetch_docs_minimal.sh +0 -84
- package/mcps/vectorworks-mcp/scripts/fetch_github_vectorworks.sh +0 -85
- package/mcps/vectorworks-mcp/templates/index.html +0 -40
- package/scripts/ecosystem-health-audit.js +0 -257
- package/skills/bdb-dev-os-skill/SKILL.md +0 -49
- package/skills/bdb-dev-os-skill/package.json +0 -11
- package/skills/bdbsaastraining/SKILL.md +0 -316
- package/skills/bdbsaastraining/references/exam-pool.md +0 -313
- package/skills/bdbsaastraining/references/interaction-modes.md +0 -94
- package/skills/bdbsaastraining/references/track-a-ai-agent.md +0 -111
- package/skills/bdbsaastraining/references/track-b-wordpress.md +0 -111
- package/skills/bdbsaastraining/references/track-c-mailserver.md +0 -107
- package/skills/bdbsaastraining/references/track-d-debian.md +0 -109
- package/skills/bdbsaastraining/references/track-e-custom.md +0 -83
- package/skills/bdbsaastraining/scripts/build_profile.py +0 -529
- package/skills/bdbsaastraining/scripts/generate_certificate.py +0 -161
- package/skills/bdbsaastraining/scripts/preflight_check.sh +0 -150
- package/skills/bdbsaastraining/templates/certificate_template.html +0 -508
- package/skills/global_config/bdb-ecosystem-health/SKILL.md +0 -68
- package/skills/global_config/bdbsaas-ops/SKILL.md +0 -16
- package/skills/global_config/bdbsaashost/SKILL.md +0 -125
|
@@ -1,1440 +0,0 @@
|
|
|
1
|
-
bl_info = {
|
|
2
|
-
"name": "Blender MCP Bridge",
|
|
3
|
-
"author": "Blender MCP Server",
|
|
4
|
-
"version": (0, 1, 0),
|
|
5
|
-
"blender": (3, 6, 0),
|
|
6
|
-
"location": "View3D > Sidebar > MCP",
|
|
7
|
-
"description": "TCP bridge for MCP server to control Blender",
|
|
8
|
-
"category": "Development",
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
import builtins
|
|
12
|
-
import errno
|
|
13
|
-
import io
|
|
14
|
-
import json
|
|
15
|
-
import logging
|
|
16
|
-
import math
|
|
17
|
-
import os
|
|
18
|
-
import queue
|
|
19
|
-
import socket
|
|
20
|
-
import sys
|
|
21
|
-
import threading
|
|
22
|
-
import time
|
|
23
|
-
import traceback
|
|
24
|
-
import uuid
|
|
25
|
-
from contextlib import redirect_stderr, redirect_stdout
|
|
26
|
-
from typing import Any
|
|
27
|
-
|
|
28
|
-
import bpy
|
|
29
|
-
from bpy.app.handlers import persistent
|
|
30
|
-
|
|
31
|
-
from .models import (
|
|
32
|
-
ExportFileParams,
|
|
33
|
-
JobIdParams,
|
|
34
|
-
MaterialAssignParams,
|
|
35
|
-
MaterialCreateParams,
|
|
36
|
-
MaterialSetColorParams,
|
|
37
|
-
MaterialSetTextureParams,
|
|
38
|
-
ObjectCreateMeshParams,
|
|
39
|
-
ObjectDeleteParams,
|
|
40
|
-
ObjectDuplicateParams,
|
|
41
|
-
ObjectGetHierarchyParams,
|
|
42
|
-
ObjectGetTransformParams,
|
|
43
|
-
ObjectRotateParams,
|
|
44
|
-
ObjectScaleParams,
|
|
45
|
-
ObjectTranslateParams,
|
|
46
|
-
PythonExecuteParams,
|
|
47
|
-
RenderAnimationParams,
|
|
48
|
-
RenderStillParams,
|
|
49
|
-
SceneListObjectsParams,
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
logger = logging.getLogger(__name__)
|
|
53
|
-
|
|
54
|
-
HOST = "127.0.0.1"
|
|
55
|
-
PORT = 9876
|
|
56
|
-
BUFFER_SIZE = 65536
|
|
57
|
-
|
|
58
|
-
# Security: restrict file operations to these directories (set via addon preferences)
|
|
59
|
-
SAFE_MODE = False
|
|
60
|
-
ALLOWED_PATHS: list[str] = []
|
|
61
|
-
TOOL_WHITELIST: set[str] | None = None # None = all tools allowed
|
|
62
|
-
|
|
63
|
-
# Python execution settings (set via addon preferences)
|
|
64
|
-
ALLOW_INLINE_CODE = True
|
|
65
|
-
APPROVED_SCRIPT_ROOTS: list[str] = []
|
|
66
|
-
BLOCKED_MODULES: set[str] = {
|
|
67
|
-
"subprocess",
|
|
68
|
-
"socket",
|
|
69
|
-
"webbrowser",
|
|
70
|
-
"ctypes",
|
|
71
|
-
"multiprocessing",
|
|
72
|
-
}
|
|
73
|
-
DEFAULT_SYNC_TIMEOUT = 30
|
|
74
|
-
DEFAULT_ASYNC_TIMEOUT = 300
|
|
75
|
-
MAX_SYNC_TIMEOUT = 300
|
|
76
|
-
MAX_ASYNC_TIMEOUT = 3600
|
|
77
|
-
MAX_OUTPUT_SIZE = 50000 # Cap stdout/stderr returned to client
|
|
78
|
-
LOG_CODE_PREVIEW_LEN = 120 # Max chars of code shown in log messages
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _truncate(text: str, limit: int) -> str:
|
|
82
|
-
"""Truncate text with an ellipsis indicator."""
|
|
83
|
-
if len(text) <= limit:
|
|
84
|
-
return text
|
|
85
|
-
return text[:limit] + "…"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def _cap_output(text: str) -> str:
|
|
89
|
-
"""Cap output string to MAX_OUTPUT_SIZE."""
|
|
90
|
-
if len(text) <= MAX_OUTPUT_SIZE:
|
|
91
|
-
return text
|
|
92
|
-
return text[:MAX_OUTPUT_SIZE] + f"\n… (truncated, {len(text)} total chars)"
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _build_cube_pydata(
|
|
96
|
-
size: float,
|
|
97
|
-
) -> tuple[list[tuple[float, float, float]], list[tuple[int, ...]]]:
|
|
98
|
-
half = size / 2.0
|
|
99
|
-
verts = [
|
|
100
|
-
(-half, -half, -half),
|
|
101
|
-
(half, -half, -half),
|
|
102
|
-
(half, half, -half),
|
|
103
|
-
(-half, half, -half),
|
|
104
|
-
(-half, -half, half),
|
|
105
|
-
(half, -half, half),
|
|
106
|
-
(half, half, half),
|
|
107
|
-
(-half, half, half),
|
|
108
|
-
]
|
|
109
|
-
faces = [
|
|
110
|
-
(0, 1, 2, 3),
|
|
111
|
-
(4, 5, 6, 7),
|
|
112
|
-
(0, 1, 5, 4),
|
|
113
|
-
(1, 2, 6, 5),
|
|
114
|
-
(2, 3, 7, 6),
|
|
115
|
-
(3, 0, 4, 7),
|
|
116
|
-
]
|
|
117
|
-
return verts, faces
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def _build_plane_pydata(
|
|
121
|
-
size: float,
|
|
122
|
-
) -> tuple[list[tuple[float, float, float]], list[tuple[int, ...]]]:
|
|
123
|
-
half = size / 2.0
|
|
124
|
-
verts = [
|
|
125
|
-
(-half, -half, 0.0),
|
|
126
|
-
(half, -half, 0.0),
|
|
127
|
-
(half, half, 0.0),
|
|
128
|
-
(-half, half, 0.0),
|
|
129
|
-
]
|
|
130
|
-
return verts, [(0, 1, 2, 3)]
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def _build_cylinder_pydata(
|
|
134
|
-
size: float, segments: int = 24
|
|
135
|
-
) -> tuple[list[tuple[float, float, float]], list[tuple[int, ...]]]:
|
|
136
|
-
radius = size / 2.0
|
|
137
|
-
half_depth = size / 2.0
|
|
138
|
-
verts: list[tuple[float, float, float]] = []
|
|
139
|
-
for ring_z in (-half_depth, half_depth):
|
|
140
|
-
for i in range(segments):
|
|
141
|
-
angle = 2.0 * math.pi * i / segments
|
|
142
|
-
verts.append((radius * math.cos(angle), radius * math.sin(angle), ring_z))
|
|
143
|
-
|
|
144
|
-
faces: list[tuple[int, ...]] = []
|
|
145
|
-
for i in range(segments):
|
|
146
|
-
nxt = (i + 1) % segments
|
|
147
|
-
faces.append((i, nxt, segments + nxt, segments + i))
|
|
148
|
-
faces.append(tuple(range(segments - 1, -1, -1)))
|
|
149
|
-
faces.append(tuple(range(segments, 2 * segments)))
|
|
150
|
-
return verts, faces
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def _build_cone_pydata(
|
|
154
|
-
size: float, segments: int = 24
|
|
155
|
-
) -> tuple[list[tuple[float, float, float]], list[tuple[int, ...]]]:
|
|
156
|
-
radius = size / 2.0
|
|
157
|
-
half_depth = size / 2.0
|
|
158
|
-
verts: list[tuple[float, float, float]] = []
|
|
159
|
-
for i in range(segments):
|
|
160
|
-
angle = 2.0 * math.pi * i / segments
|
|
161
|
-
verts.append((radius * math.cos(angle), radius * math.sin(angle), -half_depth))
|
|
162
|
-
apex_index = len(verts)
|
|
163
|
-
verts.append((0.0, 0.0, half_depth))
|
|
164
|
-
|
|
165
|
-
faces: list[tuple[int, ...]] = []
|
|
166
|
-
for i in range(segments):
|
|
167
|
-
nxt = (i + 1) % segments
|
|
168
|
-
faces.append((i, nxt, apex_index))
|
|
169
|
-
faces.append(tuple(range(segments - 1, -1, -1)))
|
|
170
|
-
return verts, faces
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _build_uv_sphere_pydata(
|
|
174
|
-
size: float, segments: int = 24, rings: int = 12
|
|
175
|
-
) -> tuple[list[tuple[float, float, float]], list[tuple[int, ...]]]:
|
|
176
|
-
radius = size / 2.0
|
|
177
|
-
verts: list[tuple[float, float, float]] = [(0.0, 0.0, radius)]
|
|
178
|
-
for ring in range(1, rings):
|
|
179
|
-
phi = math.pi * ring / rings
|
|
180
|
-
sin_phi = math.sin(phi)
|
|
181
|
-
cos_phi = math.cos(phi)
|
|
182
|
-
for seg in range(segments):
|
|
183
|
-
theta = 2.0 * math.pi * seg / segments
|
|
184
|
-
verts.append(
|
|
185
|
-
(
|
|
186
|
-
radius * sin_phi * math.cos(theta),
|
|
187
|
-
radius * sin_phi * math.sin(theta),
|
|
188
|
-
radius * cos_phi,
|
|
189
|
-
)
|
|
190
|
-
)
|
|
191
|
-
bottom_index = len(verts)
|
|
192
|
-
verts.append((0.0, 0.0, -radius))
|
|
193
|
-
|
|
194
|
-
faces: list[tuple[int, ...]] = []
|
|
195
|
-
for seg in range(segments):
|
|
196
|
-
nxt = (seg + 1) % segments
|
|
197
|
-
faces.append((0, 1 + nxt, 1 + seg))
|
|
198
|
-
|
|
199
|
-
for ring in range(rings - 2):
|
|
200
|
-
ring_start = 1 + ring * segments
|
|
201
|
-
next_start = ring_start + segments
|
|
202
|
-
for seg in range(segments):
|
|
203
|
-
nxt = (seg + 1) % segments
|
|
204
|
-
faces.append(
|
|
205
|
-
(
|
|
206
|
-
ring_start + seg,
|
|
207
|
-
ring_start + nxt,
|
|
208
|
-
next_start + nxt,
|
|
209
|
-
next_start + seg,
|
|
210
|
-
)
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
last_ring_start = 1 + (rings - 2) * segments
|
|
214
|
-
for seg in range(segments):
|
|
215
|
-
nxt = (seg + 1) % segments
|
|
216
|
-
faces.append((last_ring_start + seg, last_ring_start + nxt, bottom_index))
|
|
217
|
-
return verts, faces
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def _build_torus_pydata(
|
|
221
|
-
size: float, major_segments: int = 24, minor_segments: int = 12
|
|
222
|
-
) -> tuple[list[tuple[float, float, float]], list[tuple[int, ...]]]:
|
|
223
|
-
major_radius = size * 0.35
|
|
224
|
-
minor_radius = size * 0.15
|
|
225
|
-
verts: list[tuple[float, float, float]] = []
|
|
226
|
-
for major in range(major_segments):
|
|
227
|
-
major_angle = 2.0 * math.pi * major / major_segments
|
|
228
|
-
cos_major = math.cos(major_angle)
|
|
229
|
-
sin_major = math.sin(major_angle)
|
|
230
|
-
for minor in range(minor_segments):
|
|
231
|
-
minor_angle = 2.0 * math.pi * minor / minor_segments
|
|
232
|
-
ring = major_radius + minor_radius * math.cos(minor_angle)
|
|
233
|
-
verts.append(
|
|
234
|
-
(
|
|
235
|
-
ring * cos_major,
|
|
236
|
-
ring * sin_major,
|
|
237
|
-
minor_radius * math.sin(minor_angle),
|
|
238
|
-
)
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
faces: list[tuple[int, ...]] = []
|
|
242
|
-
for major in range(major_segments):
|
|
243
|
-
major_next = (major + 1) % major_segments
|
|
244
|
-
for minor in range(minor_segments):
|
|
245
|
-
minor_next = (minor + 1) % minor_segments
|
|
246
|
-
v0 = major * minor_segments + minor
|
|
247
|
-
v1 = major * minor_segments + minor_next
|
|
248
|
-
v2 = major_next * minor_segments + minor_next
|
|
249
|
-
v3 = major_next * minor_segments + minor
|
|
250
|
-
faces.append((v0, v1, v2, v3))
|
|
251
|
-
return verts, faces
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def _build_primitive_pydata(
|
|
255
|
-
mesh_type: str, size: float
|
|
256
|
-
) -> tuple[list[tuple[float, float, float]], list[tuple[int, ...]]]:
|
|
257
|
-
builders = {
|
|
258
|
-
"cube": _build_cube_pydata,
|
|
259
|
-
"sphere": _build_uv_sphere_pydata,
|
|
260
|
-
"cylinder": _build_cylinder_pydata,
|
|
261
|
-
"plane": _build_plane_pydata,
|
|
262
|
-
"cone": _build_cone_pydata,
|
|
263
|
-
"torus": _build_torus_pydata,
|
|
264
|
-
}
|
|
265
|
-
builder = builders.get(mesh_type)
|
|
266
|
-
if builder is None:
|
|
267
|
-
raise ValueError(f"Unknown mesh type: {mesh_type}. Options: {list(builders.keys())}")
|
|
268
|
-
return builder(size)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def _create_primitive_mesh_object(
|
|
272
|
-
mesh_type: str,
|
|
273
|
-
*,
|
|
274
|
-
name: str | None = None,
|
|
275
|
-
location: list[float] | tuple[float, float, float] | None = None,
|
|
276
|
-
size: float = 2.0,
|
|
277
|
-
):
|
|
278
|
-
obj_name = name or mesh_type.capitalize()
|
|
279
|
-
verts, faces = _build_primitive_pydata(mesh_type.lower(), size)
|
|
280
|
-
mesh = bpy.data.meshes.new(f"{obj_name}Mesh")
|
|
281
|
-
mesh.from_pydata(verts, [], faces)
|
|
282
|
-
mesh.update()
|
|
283
|
-
obj = bpy.data.objects.new(obj_name, mesh)
|
|
284
|
-
bpy.context.scene.collection.objects.link(obj)
|
|
285
|
-
if location is not None:
|
|
286
|
-
obj.location = tuple(location)
|
|
287
|
-
return obj
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def _make_exec_builtins(blocked_modules: set[str]) -> dict[str, Any]:
|
|
291
|
-
"""Create a per-execution builtins dict with a guarded __import__."""
|
|
292
|
-
exec_builtins = dict(builtins.__dict__)
|
|
293
|
-
original_import = exec_builtins["__import__"]
|
|
294
|
-
|
|
295
|
-
def guarded_import(name, *a, **kw):
|
|
296
|
-
top_level = name.split(".")[0]
|
|
297
|
-
if top_level in blocked_modules:
|
|
298
|
-
raise ImportError(f"Import of '{name}' is blocked by MCP safety policy")
|
|
299
|
-
return original_import(name, *a, **kw)
|
|
300
|
-
|
|
301
|
-
exec_builtins["__import__"] = guarded_import
|
|
302
|
-
return exec_builtins
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
# Last execution status — shown in the Blender UI panel
|
|
306
|
-
_last_execution: dict[str, Any] = {
|
|
307
|
-
"request_id": None,
|
|
308
|
-
"source": None,
|
|
309
|
-
"status": None,
|
|
310
|
-
"duration_seconds": None,
|
|
311
|
-
"error_summary": None,
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
class ScriptExecutionTimeout(TimeoutError):
|
|
316
|
-
"""Raised when script execution exceeds the configured timeout."""
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
class ScriptExecutionCancelled(RuntimeError):
|
|
320
|
-
"""Raised when script execution is cancelled cooperatively."""
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def _get_addon_preferences():
|
|
324
|
-
"""Return this add-on's preferences object when available."""
|
|
325
|
-
context = getattr(bpy, "context", None)
|
|
326
|
-
preferences = getattr(context, "preferences", None)
|
|
327
|
-
addons = getattr(preferences, "addons", None)
|
|
328
|
-
if addons is None:
|
|
329
|
-
return None
|
|
330
|
-
|
|
331
|
-
addon_entry = None
|
|
332
|
-
getter = getattr(addons, "get", None)
|
|
333
|
-
if callable(getter):
|
|
334
|
-
addon_entry = getter(__name__)
|
|
335
|
-
elif isinstance(addons, dict):
|
|
336
|
-
addon_entry = addons.get(__name__)
|
|
337
|
-
|
|
338
|
-
return getattr(addon_entry, "preferences", None)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def _sync_runtime_settings():
|
|
342
|
-
"""Apply add-on preferences to module-level runtime settings."""
|
|
343
|
-
global SAFE_MODE, PORT, ALLOW_INLINE_CODE, APPROVED_SCRIPT_ROOTS, ALLOWED_PATHS
|
|
344
|
-
|
|
345
|
-
prefs = _get_addon_preferences()
|
|
346
|
-
if prefs is None:
|
|
347
|
-
return
|
|
348
|
-
|
|
349
|
-
SAFE_MODE = bool(getattr(prefs, "safe_mode", SAFE_MODE))
|
|
350
|
-
PORT = int(getattr(prefs, "port", PORT))
|
|
351
|
-
ALLOW_INLINE_CODE = bool(getattr(prefs, "allow_inline_code", ALLOW_INLINE_CODE))
|
|
352
|
-
|
|
353
|
-
raw_roots = getattr(prefs, "approved_script_roots", "") or ""
|
|
354
|
-
path_helper = getattr(getattr(bpy, "path", None), "abspath", None)
|
|
355
|
-
approved_roots = []
|
|
356
|
-
for root in raw_roots.split(";"):
|
|
357
|
-
root = root.strip()
|
|
358
|
-
if not root:
|
|
359
|
-
continue
|
|
360
|
-
resolved = path_helper(root) if callable(path_helper) else root
|
|
361
|
-
approved_roots.append(os.path.realpath(resolved))
|
|
362
|
-
|
|
363
|
-
APPROVED_SCRIPT_ROOTS = approved_roots
|
|
364
|
-
ALLOWED_PATHS = approved_roots.copy() if SAFE_MODE else []
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
class CommandHandler:
|
|
368
|
-
"""Dispatches JSON commands to the appropriate bpy operations."""
|
|
369
|
-
|
|
370
|
-
def __init__(self):
|
|
371
|
-
self._handlers: dict[str, callable] = {}
|
|
372
|
-
self._register_builtins()
|
|
373
|
-
|
|
374
|
-
def _register_builtins(self):
|
|
375
|
-
self._handlers["scene.get_info"] = self._scene_get_info
|
|
376
|
-
self._handlers["scene.list_objects"] = self._scene_list_objects
|
|
377
|
-
self._handlers["object.get_transform"] = self._object_get_transform
|
|
378
|
-
self._handlers["object.get_hierarchy"] = self._object_get_hierarchy
|
|
379
|
-
self._handlers["material.list"] = self._material_list
|
|
380
|
-
self._handlers["object.create_mesh"] = self._object_create_mesh
|
|
381
|
-
self._handlers["object.delete"] = self._object_delete
|
|
382
|
-
self._handlers["object.translate"] = self._object_translate
|
|
383
|
-
self._handlers["object.rotate"] = self._object_rotate
|
|
384
|
-
self._handlers["object.scale"] = self._object_scale
|
|
385
|
-
self._handlers["object.duplicate"] = self._object_duplicate
|
|
386
|
-
self._handlers["material.create"] = self._material_create
|
|
387
|
-
self._handlers["material.assign"] = self._material_assign
|
|
388
|
-
self._handlers["material.set_color"] = self._material_set_color
|
|
389
|
-
self._handlers["material.set_texture"] = self._material_set_texture
|
|
390
|
-
self._handlers["render.still"] = self._render_still
|
|
391
|
-
self._handlers["render.animation"] = self._render_animation
|
|
392
|
-
self._handlers["export.gltf"] = self._export_gltf
|
|
393
|
-
self._handlers["export.obj"] = self._export_obj
|
|
394
|
-
self._handlers["export.fbx"] = self._export_fbx
|
|
395
|
-
self._handlers["history.undo"] = self._history_undo
|
|
396
|
-
self._handlers["history.redo"] = self._history_redo
|
|
397
|
-
self._handlers["python.execute"] = self._python_execute
|
|
398
|
-
self._handlers["python.execute_async"] = self._python_execute_async
|
|
399
|
-
self._handlers["job.status"] = self._job_status
|
|
400
|
-
self._handlers["job.cancel"] = self._job_cancel
|
|
401
|
-
self._handlers["job.list"] = self._job_list
|
|
402
|
-
|
|
403
|
-
_VALIDATORS: dict[str, type] = {
|
|
404
|
-
"scene.list_objects": SceneListObjectsParams,
|
|
405
|
-
"object.get_transform": ObjectGetTransformParams,
|
|
406
|
-
"object.get_hierarchy": ObjectGetHierarchyParams,
|
|
407
|
-
"object.create_mesh": ObjectCreateMeshParams,
|
|
408
|
-
"object.delete": ObjectDeleteParams,
|
|
409
|
-
"object.translate": ObjectTranslateParams,
|
|
410
|
-
"object.rotate": ObjectRotateParams,
|
|
411
|
-
"object.scale": ObjectScaleParams,
|
|
412
|
-
"object.duplicate": ObjectDuplicateParams,
|
|
413
|
-
"material.create": MaterialCreateParams,
|
|
414
|
-
"material.assign": MaterialAssignParams,
|
|
415
|
-
"material.set_color": MaterialSetColorParams,
|
|
416
|
-
"material.set_texture": MaterialSetTextureParams,
|
|
417
|
-
"render.still": RenderStillParams,
|
|
418
|
-
"render.animation": RenderAnimationParams,
|
|
419
|
-
"export.gltf": ExportFileParams,
|
|
420
|
-
"export.obj": ExportFileParams,
|
|
421
|
-
"export.fbx": ExportFileParams,
|
|
422
|
-
"python.execute": PythonExecuteParams,
|
|
423
|
-
"python.execute_async": PythonExecuteParams,
|
|
424
|
-
"job.status": JobIdParams,
|
|
425
|
-
"job.cancel": JobIdParams,
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
def handle(self, command: str, params: dict) -> Any:
|
|
429
|
-
_sync_runtime_settings()
|
|
430
|
-
# Security: check tool whitelist
|
|
431
|
-
if TOOL_WHITELIST is not None and command not in TOOL_WHITELIST:
|
|
432
|
-
raise PermissionError(f"Command '{command}' is not in the tool whitelist")
|
|
433
|
-
handler = self._handlers.get(command)
|
|
434
|
-
if not handler:
|
|
435
|
-
raise ValueError(f"Unknown command: {command}")
|
|
436
|
-
validator = self._VALIDATORS.get(command)
|
|
437
|
-
if validator is not None:
|
|
438
|
-
params = validator.model_validate(params).model_dump(exclude_none=True)
|
|
439
|
-
return handler(params)
|
|
440
|
-
|
|
441
|
-
# -- Scene tools --
|
|
442
|
-
|
|
443
|
-
@staticmethod
|
|
444
|
-
def _validate_filepath(filepath: str) -> str:
|
|
445
|
-
"""Security: validate that a filepath is within allowed directories."""
|
|
446
|
-
if not SAFE_MODE:
|
|
447
|
-
return filepath
|
|
448
|
-
abs_path = os.path.abspath(bpy.path.abspath(filepath))
|
|
449
|
-
if not ALLOWED_PATHS:
|
|
450
|
-
# In safe mode with no allowed paths, only allow the blend file directory
|
|
451
|
-
blend_dir = os.path.dirname(bpy.data.filepath) if bpy.data.filepath else os.getcwd()
|
|
452
|
-
ALLOWED_PATHS.append(blend_dir)
|
|
453
|
-
for allowed in ALLOWED_PATHS:
|
|
454
|
-
if abs_path.startswith(os.path.abspath(allowed)):
|
|
455
|
-
return filepath
|
|
456
|
-
raise PermissionError(
|
|
457
|
-
f"File access denied: '{filepath}' is outside allowed directories. Allowed: {ALLOWED_PATHS}"
|
|
458
|
-
)
|
|
459
|
-
|
|
460
|
-
def _scene_get_info(self, params: dict) -> dict:
|
|
461
|
-
scene = bpy.context.scene
|
|
462
|
-
return {
|
|
463
|
-
"name": scene.name,
|
|
464
|
-
"frame_current": scene.frame_current,
|
|
465
|
-
"frame_start": scene.frame_start,
|
|
466
|
-
"frame_end": scene.frame_end,
|
|
467
|
-
"render_engine": scene.render.engine,
|
|
468
|
-
"resolution_x": scene.render.resolution_x,
|
|
469
|
-
"resolution_y": scene.render.resolution_y,
|
|
470
|
-
"object_count": len(scene.objects),
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
def _scene_list_objects(self, params: dict) -> dict:
|
|
474
|
-
type_filter = params.get("type")
|
|
475
|
-
objects = []
|
|
476
|
-
for obj in bpy.context.scene.objects:
|
|
477
|
-
if type_filter and obj.type != type_filter.upper():
|
|
478
|
-
continue
|
|
479
|
-
objects.append(
|
|
480
|
-
{
|
|
481
|
-
"name": obj.name,
|
|
482
|
-
"type": obj.type,
|
|
483
|
-
"location": list(obj.location),
|
|
484
|
-
"visible": obj.visible_get(),
|
|
485
|
-
}
|
|
486
|
-
)
|
|
487
|
-
return {"objects": objects}
|
|
488
|
-
|
|
489
|
-
def _object_get_transform(self, params: dict) -> dict:
|
|
490
|
-
name = params["name"]
|
|
491
|
-
obj = bpy.data.objects.get(name)
|
|
492
|
-
if not obj:
|
|
493
|
-
raise ValueError(f"Object '{name}' not found")
|
|
494
|
-
return {
|
|
495
|
-
"name": obj.name,
|
|
496
|
-
"location": list(obj.location),
|
|
497
|
-
"rotation_euler": list(obj.rotation_euler),
|
|
498
|
-
"scale": list(obj.scale),
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
def _object_get_hierarchy(self, params: dict) -> dict:
|
|
502
|
-
def build_tree(obj):
|
|
503
|
-
return {
|
|
504
|
-
"name": obj.name,
|
|
505
|
-
"type": obj.type,
|
|
506
|
-
"children": [build_tree(c) for c in obj.children],
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
name = params.get("name")
|
|
510
|
-
if name:
|
|
511
|
-
obj = bpy.data.objects.get(name)
|
|
512
|
-
if not obj:
|
|
513
|
-
raise ValueError(f"Object '{name}' not found")
|
|
514
|
-
return build_tree(obj)
|
|
515
|
-
|
|
516
|
-
roots = [o for o in bpy.context.scene.objects if o.parent is None]
|
|
517
|
-
return {"roots": [build_tree(r) for r in roots]}
|
|
518
|
-
|
|
519
|
-
def _material_list(self, params: dict) -> dict:
|
|
520
|
-
materials = []
|
|
521
|
-
for mat in bpy.data.materials:
|
|
522
|
-
materials.append(
|
|
523
|
-
{
|
|
524
|
-
"name": mat.name,
|
|
525
|
-
"use_nodes": mat.use_nodes,
|
|
526
|
-
"user_count": mat.users,
|
|
527
|
-
}
|
|
528
|
-
)
|
|
529
|
-
return {"materials": materials}
|
|
530
|
-
|
|
531
|
-
# -- Object mutation tools --
|
|
532
|
-
|
|
533
|
-
def _object_create_mesh(self, params: dict) -> dict:
|
|
534
|
-
mesh_type = params.get("type", "cube").lower()
|
|
535
|
-
name = params.get("name")
|
|
536
|
-
location = params.get("location", [0, 0, 0])
|
|
537
|
-
size = params.get("size", 2.0)
|
|
538
|
-
obj = _create_primitive_mesh_object(
|
|
539
|
-
mesh_type,
|
|
540
|
-
name=name,
|
|
541
|
-
location=location,
|
|
542
|
-
size=size,
|
|
543
|
-
)
|
|
544
|
-
return {"name": obj.name, "type": obj.type, "location": list(obj.location)}
|
|
545
|
-
|
|
546
|
-
def _object_delete(self, params: dict) -> dict:
|
|
547
|
-
name = params["name"]
|
|
548
|
-
obj = bpy.data.objects.get(name)
|
|
549
|
-
if not obj:
|
|
550
|
-
raise ValueError(f"Object '{name}' not found")
|
|
551
|
-
bpy.data.objects.remove(obj, do_unlink=True)
|
|
552
|
-
return {"deleted": name}
|
|
553
|
-
|
|
554
|
-
def _object_translate(self, params: dict) -> dict:
|
|
555
|
-
name = params["name"]
|
|
556
|
-
obj = bpy.data.objects.get(name)
|
|
557
|
-
if not obj:
|
|
558
|
-
raise ValueError(f"Object '{name}' not found")
|
|
559
|
-
offset = params.get("offset", [0, 0, 0])
|
|
560
|
-
absolute = params.get("location")
|
|
561
|
-
if absolute:
|
|
562
|
-
obj.location = absolute
|
|
563
|
-
else:
|
|
564
|
-
obj.location.x += offset[0]
|
|
565
|
-
obj.location.y += offset[1]
|
|
566
|
-
obj.location.z += offset[2]
|
|
567
|
-
return {"name": obj.name, "location": list(obj.location)}
|
|
568
|
-
|
|
569
|
-
def _object_rotate(self, params: dict) -> dict:
|
|
570
|
-
import math
|
|
571
|
-
|
|
572
|
-
name = params["name"]
|
|
573
|
-
obj = bpy.data.objects.get(name)
|
|
574
|
-
if not obj:
|
|
575
|
-
raise ValueError(f"Object '{name}' not found")
|
|
576
|
-
rotation = params.get("rotation", [0, 0, 0])
|
|
577
|
-
degrees = params.get("degrees", True)
|
|
578
|
-
if degrees:
|
|
579
|
-
rotation = [math.radians(r) for r in rotation]
|
|
580
|
-
obj.rotation_euler = rotation
|
|
581
|
-
return {"name": obj.name, "rotation_euler": list(obj.rotation_euler)}
|
|
582
|
-
|
|
583
|
-
def _object_scale(self, params: dict) -> dict:
|
|
584
|
-
name = params["name"]
|
|
585
|
-
obj = bpy.data.objects.get(name)
|
|
586
|
-
if not obj:
|
|
587
|
-
raise ValueError(f"Object '{name}' not found")
|
|
588
|
-
scale = params.get("scale", [1, 1, 1])
|
|
589
|
-
obj.scale = scale
|
|
590
|
-
return {"name": obj.name, "scale": list(obj.scale)}
|
|
591
|
-
|
|
592
|
-
def _object_duplicate(self, params: dict) -> dict:
|
|
593
|
-
name = params["name"]
|
|
594
|
-
obj = bpy.data.objects.get(name)
|
|
595
|
-
if not obj:
|
|
596
|
-
raise ValueError(f"Object '{name}' not found")
|
|
597
|
-
new_obj = obj.copy()
|
|
598
|
-
new_obj.data = obj.data.copy()
|
|
599
|
-
new_name = params.get("new_name")
|
|
600
|
-
if new_name:
|
|
601
|
-
new_obj.name = new_name
|
|
602
|
-
bpy.context.collection.objects.link(new_obj)
|
|
603
|
-
return {
|
|
604
|
-
"name": new_obj.name,
|
|
605
|
-
"original": obj.name,
|
|
606
|
-
"location": list(new_obj.location),
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
# -- Material tools --
|
|
610
|
-
|
|
611
|
-
def _material_create(self, params: dict) -> dict:
|
|
612
|
-
name = params.get("name", "Material")
|
|
613
|
-
mat = bpy.data.materials.new(name=name)
|
|
614
|
-
mat.use_nodes = True
|
|
615
|
-
color = params.get("color")
|
|
616
|
-
if color:
|
|
617
|
-
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
618
|
-
if bsdf:
|
|
619
|
-
# color is [r, g, b] or [r, g, b, a], values 0-1
|
|
620
|
-
rgba = list(color) + [1.0] * (4 - len(color))
|
|
621
|
-
bsdf.inputs["Base Color"].default_value = rgba[:4]
|
|
622
|
-
return {"name": mat.name, "use_nodes": mat.use_nodes}
|
|
623
|
-
|
|
624
|
-
def _material_assign(self, params: dict) -> dict:
|
|
625
|
-
obj_name = params["object"]
|
|
626
|
-
mat_name = params["material"]
|
|
627
|
-
obj = bpy.data.objects.get(obj_name)
|
|
628
|
-
if not obj:
|
|
629
|
-
raise ValueError(f"Object '{obj_name}' not found")
|
|
630
|
-
mat = bpy.data.materials.get(mat_name)
|
|
631
|
-
if not mat:
|
|
632
|
-
raise ValueError(f"Material '{mat_name}' not found")
|
|
633
|
-
if obj.data.materials:
|
|
634
|
-
obj.data.materials[0] = mat
|
|
635
|
-
else:
|
|
636
|
-
obj.data.materials.append(mat)
|
|
637
|
-
return {"object": obj.name, "material": mat.name}
|
|
638
|
-
|
|
639
|
-
def _material_set_color(self, params: dict) -> dict:
|
|
640
|
-
mat_name = params["name"]
|
|
641
|
-
color = params["color"] # [r, g, b] or [r, g, b, a]
|
|
642
|
-
mat = bpy.data.materials.get(mat_name)
|
|
643
|
-
if not mat:
|
|
644
|
-
raise ValueError(f"Material '{mat_name}' not found")
|
|
645
|
-
if not mat.use_nodes:
|
|
646
|
-
mat.use_nodes = True
|
|
647
|
-
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
648
|
-
if not bsdf:
|
|
649
|
-
raise ValueError(f"Material '{mat_name}' has no Principled BSDF node")
|
|
650
|
-
rgba = list(color) + [1.0] * (4 - len(color))
|
|
651
|
-
bsdf.inputs["Base Color"].default_value = rgba[:4]
|
|
652
|
-
return {"name": mat.name, "color": rgba[:4]}
|
|
653
|
-
|
|
654
|
-
def _material_set_texture(self, params: dict) -> dict:
|
|
655
|
-
mat_name = params["name"]
|
|
656
|
-
filepath = params["filepath"]
|
|
657
|
-
self._validate_filepath(filepath)
|
|
658
|
-
mat = bpy.data.materials.get(mat_name)
|
|
659
|
-
if not mat:
|
|
660
|
-
raise ValueError(f"Material '{mat_name}' not found")
|
|
661
|
-
if not mat.use_nodes:
|
|
662
|
-
mat.use_nodes = True
|
|
663
|
-
tree = mat.node_tree
|
|
664
|
-
bsdf = tree.nodes.get("Principled BSDF")
|
|
665
|
-
if not bsdf:
|
|
666
|
-
raise ValueError(f"Material '{mat_name}' has no Principled BSDF node")
|
|
667
|
-
tex_node = tree.nodes.new("ShaderNodeTexImage")
|
|
668
|
-
tex_node.image = bpy.data.images.load(filepath)
|
|
669
|
-
tree.links.new(tex_node.outputs["Color"], bsdf.inputs["Base Color"])
|
|
670
|
-
return {"name": mat.name, "texture": filepath}
|
|
671
|
-
|
|
672
|
-
# -- Render tools --
|
|
673
|
-
|
|
674
|
-
def _render_still(self, params: dict) -> dict:
|
|
675
|
-
scene = bpy.context.scene
|
|
676
|
-
output_path = params.get("output_path", "//render.png")
|
|
677
|
-
self._validate_filepath(output_path)
|
|
678
|
-
resolution_x = params.get("resolution_x")
|
|
679
|
-
resolution_y = params.get("resolution_y")
|
|
680
|
-
engine = params.get("engine")
|
|
681
|
-
|
|
682
|
-
if engine:
|
|
683
|
-
scene.render.engine = engine.upper()
|
|
684
|
-
if resolution_x:
|
|
685
|
-
scene.render.resolution_x = resolution_x
|
|
686
|
-
if resolution_y:
|
|
687
|
-
scene.render.resolution_y = resolution_y
|
|
688
|
-
|
|
689
|
-
scene.render.filepath = output_path
|
|
690
|
-
bpy.ops.render.render(write_still=True)
|
|
691
|
-
abs_path = bpy.path.abspath(output_path)
|
|
692
|
-
return {
|
|
693
|
-
"output_path": abs_path,
|
|
694
|
-
"engine": scene.render.engine,
|
|
695
|
-
"resolution": [scene.render.resolution_x, scene.render.resolution_y],
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
def _render_animation(self, params: dict) -> dict:
|
|
699
|
-
scene = bpy.context.scene
|
|
700
|
-
output_path = params.get("output_path", "//render_")
|
|
701
|
-
self._validate_filepath(output_path)
|
|
702
|
-
frame_start = params.get("frame_start")
|
|
703
|
-
frame_end = params.get("frame_end")
|
|
704
|
-
engine = params.get("engine")
|
|
705
|
-
|
|
706
|
-
if engine:
|
|
707
|
-
scene.render.engine = engine.upper()
|
|
708
|
-
if frame_start is not None:
|
|
709
|
-
scene.frame_start = frame_start
|
|
710
|
-
if frame_end is not None:
|
|
711
|
-
scene.frame_end = frame_end
|
|
712
|
-
|
|
713
|
-
scene.render.filepath = output_path
|
|
714
|
-
bpy.ops.render.render(animation=True)
|
|
715
|
-
abs_path = bpy.path.abspath(output_path)
|
|
716
|
-
return {
|
|
717
|
-
"output_path": abs_path,
|
|
718
|
-
"frame_range": [scene.frame_start, scene.frame_end],
|
|
719
|
-
"engine": scene.render.engine,
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
# -- Export tools --
|
|
723
|
-
|
|
724
|
-
def _export_gltf(self, params: dict) -> dict:
|
|
725
|
-
filepath = params["filepath"]
|
|
726
|
-
self._validate_filepath(filepath)
|
|
727
|
-
if not filepath.endswith((".glb", ".gltf")):
|
|
728
|
-
filepath += ".glb"
|
|
729
|
-
bpy.ops.export_scene.gltf(filepath=filepath)
|
|
730
|
-
return {"filepath": filepath, "format": "glTF"}
|
|
731
|
-
|
|
732
|
-
def _export_obj(self, params: dict) -> dict:
|
|
733
|
-
filepath = params["filepath"]
|
|
734
|
-
self._validate_filepath(filepath)
|
|
735
|
-
if not filepath.endswith(".obj"):
|
|
736
|
-
filepath += ".obj"
|
|
737
|
-
bpy.ops.wm.obj_export(filepath=filepath)
|
|
738
|
-
return {"filepath": filepath, "format": "OBJ"}
|
|
739
|
-
|
|
740
|
-
def _export_fbx(self, params: dict) -> dict:
|
|
741
|
-
filepath = params["filepath"]
|
|
742
|
-
self._validate_filepath(filepath)
|
|
743
|
-
if not filepath.endswith(".fbx"):
|
|
744
|
-
filepath += ".fbx"
|
|
745
|
-
bpy.ops.export_scene.fbx(filepath=filepath)
|
|
746
|
-
return {"filepath": filepath, "format": "FBX"}
|
|
747
|
-
|
|
748
|
-
# -- History tools --
|
|
749
|
-
|
|
750
|
-
def _history_undo(self, params: dict) -> dict:
|
|
751
|
-
bpy.ops.ed.undo()
|
|
752
|
-
return {"action": "undo"}
|
|
753
|
-
|
|
754
|
-
def _history_redo(self, params: dict) -> dict:
|
|
755
|
-
bpy.ops.ed.redo()
|
|
756
|
-
return {"action": "redo"}
|
|
757
|
-
|
|
758
|
-
# -- Python execution tools --
|
|
759
|
-
|
|
760
|
-
@staticmethod
|
|
761
|
-
def _validate_script_path(script_path: str) -> str:
|
|
762
|
-
"""Validate that a script path is under an approved root directory."""
|
|
763
|
-
real_path = os.path.realpath(script_path)
|
|
764
|
-
if not os.path.isfile(real_path):
|
|
765
|
-
raise FileNotFoundError(f"Script not found: {script_path}")
|
|
766
|
-
if not real_path.endswith(".py"):
|
|
767
|
-
raise ValueError(f"Script must be a .py file: {script_path}")
|
|
768
|
-
|
|
769
|
-
roots = APPROVED_SCRIPT_ROOTS
|
|
770
|
-
if not roots:
|
|
771
|
-
blend_dir = os.path.dirname(bpy.data.filepath) if bpy.data.filepath else os.getcwd()
|
|
772
|
-
roots = [blend_dir]
|
|
773
|
-
|
|
774
|
-
for root in roots:
|
|
775
|
-
if real_path.startswith(os.path.realpath(root) + os.sep) or real_path == os.path.realpath(root):
|
|
776
|
-
return real_path
|
|
777
|
-
raise PermissionError(f"Script path denied: '{script_path}' is outside approved roots. Approved: {roots}")
|
|
778
|
-
|
|
779
|
-
@staticmethod
|
|
780
|
-
def _make_namespace(args: dict, cancel_event: threading.Event | None = None) -> dict:
|
|
781
|
-
"""Build the execution namespace for exec()."""
|
|
782
|
-
import mathutils
|
|
783
|
-
|
|
784
|
-
ns: dict[str, Any] = {
|
|
785
|
-
"bpy": bpy,
|
|
786
|
-
"math": math,
|
|
787
|
-
"mathutils": mathutils,
|
|
788
|
-
"mcp_create_mesh": _create_primitive_mesh_object,
|
|
789
|
-
"args": args or {},
|
|
790
|
-
"__result__": None,
|
|
791
|
-
"__builtins__": _make_exec_builtins(BLOCKED_MODULES),
|
|
792
|
-
}
|
|
793
|
-
if cancel_event is not None:
|
|
794
|
-
ns["__cancel_event__"] = cancel_event
|
|
795
|
-
return ns
|
|
796
|
-
|
|
797
|
-
@staticmethod
|
|
798
|
-
def _safe_json(value: Any) -> Any:
|
|
799
|
-
"""Ensure a value is JSON-serializable, falling back to repr()."""
|
|
800
|
-
if value is None:
|
|
801
|
-
return None
|
|
802
|
-
try:
|
|
803
|
-
json.dumps(value)
|
|
804
|
-
return value
|
|
805
|
-
except (TypeError, ValueError):
|
|
806
|
-
return repr(value)
|
|
807
|
-
|
|
808
|
-
def _run_code(
|
|
809
|
-
self,
|
|
810
|
-
code: str,
|
|
811
|
-
namespace: dict,
|
|
812
|
-
timeout_seconds: float,
|
|
813
|
-
request_id: str | None = None,
|
|
814
|
-
cancel_event: threading.Event | None = None,
|
|
815
|
-
) -> dict:
|
|
816
|
-
"""Execute code with stdout/stderr capture and cooperative timeout checks."""
|
|
817
|
-
stdout_buf = io.StringIO()
|
|
818
|
-
stderr_buf = io.StringIO()
|
|
819
|
-
start = time.monotonic()
|
|
820
|
-
previous_trace = sys.gettrace()
|
|
821
|
-
|
|
822
|
-
def trace_calls(frame, event, arg):
|
|
823
|
-
if event == "line":
|
|
824
|
-
if cancel_event is not None and cancel_event.is_set():
|
|
825
|
-
raise ScriptExecutionCancelled("Execution cancelled")
|
|
826
|
-
if timeout_seconds > 0 and (time.monotonic() - start) > timeout_seconds:
|
|
827
|
-
raise ScriptExecutionTimeout(f"Execution exceeded timeout of {timeout_seconds:.3f}s")
|
|
828
|
-
return trace_calls
|
|
829
|
-
|
|
830
|
-
sys.settrace(trace_calls)
|
|
831
|
-
try:
|
|
832
|
-
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
|
|
833
|
-
exec(compile(code, "<mcp-script>", "exec"), namespace)
|
|
834
|
-
except Exception as exc:
|
|
835
|
-
elapsed = time.monotonic() - start
|
|
836
|
-
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
837
|
-
# Filter out internal bridge frames
|
|
838
|
-
filtered = [line for line in tb_lines if "addon/__init__" not in line]
|
|
839
|
-
error_str = "".join(filtered).strip()
|
|
840
|
-
logger.warning(
|
|
841
|
-
"Script execution failed [%s] after %.3fs: %s",
|
|
842
|
-
request_id or "?",
|
|
843
|
-
elapsed,
|
|
844
|
-
_truncate(str(exc), 200),
|
|
845
|
-
)
|
|
846
|
-
return {
|
|
847
|
-
"result": None,
|
|
848
|
-
"stdout": _cap_output(stdout_buf.getvalue()),
|
|
849
|
-
"stderr": _cap_output(stderr_buf.getvalue()),
|
|
850
|
-
"error": error_str,
|
|
851
|
-
"duration_seconds": round(elapsed, 4),
|
|
852
|
-
"timed_out": isinstance(exc, ScriptExecutionTimeout),
|
|
853
|
-
"cancelled": isinstance(exc, ScriptExecutionCancelled),
|
|
854
|
-
}
|
|
855
|
-
finally:
|
|
856
|
-
sys.settrace(previous_trace)
|
|
857
|
-
|
|
858
|
-
elapsed = time.monotonic() - start
|
|
859
|
-
logger.info(
|
|
860
|
-
"Script execution succeeded [%s] in %.3fs",
|
|
861
|
-
request_id or "?",
|
|
862
|
-
elapsed,
|
|
863
|
-
)
|
|
864
|
-
return {
|
|
865
|
-
"result": self._safe_json(namespace.get("__result__")),
|
|
866
|
-
"stdout": _cap_output(stdout_buf.getvalue()),
|
|
867
|
-
"stderr": _cap_output(stderr_buf.getvalue()),
|
|
868
|
-
"error": None,
|
|
869
|
-
"duration_seconds": round(elapsed, 4),
|
|
870
|
-
"timed_out": False,
|
|
871
|
-
"cancelled": False,
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
def _python_execute(self, params: dict) -> dict:
|
|
875
|
-
global _last_execution
|
|
876
|
-
code = params.get("code")
|
|
877
|
-
script_path = params.get("script_path")
|
|
878
|
-
args = params.get("args", {})
|
|
879
|
-
timeout_seconds = min(params.get("timeout_seconds", DEFAULT_SYNC_TIMEOUT), MAX_SYNC_TIMEOUT)
|
|
880
|
-
|
|
881
|
-
request_id = f"exec-{uuid.uuid4().hex[:8]}"
|
|
882
|
-
|
|
883
|
-
if code and script_path:
|
|
884
|
-
raise ValueError("Provide either 'code' or 'script_path', not both")
|
|
885
|
-
if not code and not script_path:
|
|
886
|
-
raise ValueError("Either 'code' or 'script_path' must be provided")
|
|
887
|
-
|
|
888
|
-
if code is not None:
|
|
889
|
-
if not ALLOW_INLINE_CODE:
|
|
890
|
-
raise PermissionError("Inline code execution is disabled. Use script_path instead.")
|
|
891
|
-
source_label = f"inline ({_truncate(code, LOG_CODE_PREVIEW_LEN)})"
|
|
892
|
-
else:
|
|
893
|
-
validated = self._validate_script_path(script_path)
|
|
894
|
-
with open(validated) as f:
|
|
895
|
-
code = f.read()
|
|
896
|
-
source_label = f"file ({script_path})"
|
|
897
|
-
|
|
898
|
-
logger.info("python.execute [%s] starting: %s", request_id, source_label)
|
|
899
|
-
|
|
900
|
-
namespace = self._make_namespace(args)
|
|
901
|
-
result = self._run_code(code, namespace, timeout_seconds, request_id, cancel_event=None)
|
|
902
|
-
|
|
903
|
-
_last_execution = {
|
|
904
|
-
"request_id": request_id,
|
|
905
|
-
"source": source_label,
|
|
906
|
-
"status": "error" if result.get("error") else "ok",
|
|
907
|
-
"duration_seconds": result.get("duration_seconds"),
|
|
908
|
-
"error_summary": (_truncate(result["error"], 200) if result.get("error") else None),
|
|
909
|
-
}
|
|
910
|
-
return result
|
|
911
|
-
|
|
912
|
-
def _python_execute_async(self, params: dict) -> dict:
|
|
913
|
-
code = params.get("code")
|
|
914
|
-
script_path = params.get("script_path")
|
|
915
|
-
args = params.get("args", {})
|
|
916
|
-
timeout_seconds = min(params.get("timeout_seconds", DEFAULT_ASYNC_TIMEOUT), MAX_ASYNC_TIMEOUT)
|
|
917
|
-
|
|
918
|
-
if code and script_path:
|
|
919
|
-
raise ValueError("Provide either 'code' or 'script_path', not both")
|
|
920
|
-
if not code and not script_path:
|
|
921
|
-
raise ValueError("Either 'code' or 'script_path' must be provided")
|
|
922
|
-
|
|
923
|
-
if code is not None:
|
|
924
|
-
if not ALLOW_INLINE_CODE:
|
|
925
|
-
raise PermissionError("Inline code execution is disabled. Use script_path instead.")
|
|
926
|
-
source_label = f"inline ({_truncate(code, LOG_CODE_PREVIEW_LEN)})"
|
|
927
|
-
else:
|
|
928
|
-
validated = self._validate_script_path(script_path)
|
|
929
|
-
with open(validated) as f:
|
|
930
|
-
code = f.read()
|
|
931
|
-
source_label = f"file ({script_path})"
|
|
932
|
-
|
|
933
|
-
job_id = _job_manager.create_job(code, args, timeout_seconds, self)
|
|
934
|
-
logger.info("python.execute_async [%s] queued: %s", job_id, source_label)
|
|
935
|
-
return {"job_id": job_id}
|
|
936
|
-
|
|
937
|
-
def _job_status(self, params: dict) -> dict:
|
|
938
|
-
job_id = params.get("job_id")
|
|
939
|
-
if not job_id:
|
|
940
|
-
raise ValueError("'job_id' is required")
|
|
941
|
-
return _job_manager.get_status(job_id)
|
|
942
|
-
|
|
943
|
-
def _job_cancel(self, params: dict) -> dict:
|
|
944
|
-
job_id = params.get("job_id")
|
|
945
|
-
if not job_id:
|
|
946
|
-
raise ValueError("'job_id' is required")
|
|
947
|
-
return _job_manager.cancel(job_id)
|
|
948
|
-
|
|
949
|
-
def _job_list(self, params: dict) -> dict:
|
|
950
|
-
return _job_manager.list_jobs()
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
class JobManager:
|
|
954
|
-
"""Manages async job lifecycle for long-running Blender scripts."""
|
|
955
|
-
|
|
956
|
-
def __init__(self):
|
|
957
|
-
self._jobs: dict[str, dict[str, Any]] = {}
|
|
958
|
-
self._lock = threading.Lock()
|
|
959
|
-
|
|
960
|
-
def create_job(
|
|
961
|
-
self,
|
|
962
|
-
code: str,
|
|
963
|
-
args: dict,
|
|
964
|
-
timeout_seconds: float,
|
|
965
|
-
handler: CommandHandler,
|
|
966
|
-
) -> str:
|
|
967
|
-
job_id = f"job-{uuid.uuid4().hex[:8]}"
|
|
968
|
-
now = time.time()
|
|
969
|
-
cancel_event = threading.Event()
|
|
970
|
-
|
|
971
|
-
job = {
|
|
972
|
-
"job_id": job_id,
|
|
973
|
-
"status": "queued",
|
|
974
|
-
"cancellation_requested": False,
|
|
975
|
-
"created_at": now,
|
|
976
|
-
"started_at": None,
|
|
977
|
-
"completed_at": None,
|
|
978
|
-
"result": None,
|
|
979
|
-
"stdout": "",
|
|
980
|
-
"stderr": "",
|
|
981
|
-
"error": None,
|
|
982
|
-
"code": code,
|
|
983
|
-
"args": args,
|
|
984
|
-
"timeout_seconds": timeout_seconds,
|
|
985
|
-
"cancel_event": cancel_event,
|
|
986
|
-
"handler": handler,
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
with self._lock:
|
|
990
|
-
self._jobs[job_id] = job
|
|
991
|
-
|
|
992
|
-
bpy.app.timers.register(lambda: self._execute_job(job_id), first_interval=0.01)
|
|
993
|
-
return job_id
|
|
994
|
-
|
|
995
|
-
def _execute_job(self, job_id: str) -> None:
|
|
996
|
-
global _last_execution
|
|
997
|
-
with self._lock:
|
|
998
|
-
job = self._jobs.get(job_id)
|
|
999
|
-
if not job or job["status"] != "queued":
|
|
1000
|
-
return
|
|
1001
|
-
job["status"] = "running"
|
|
1002
|
-
job["started_at"] = time.time()
|
|
1003
|
-
|
|
1004
|
-
logger.info("Job [%s] running", job_id)
|
|
1005
|
-
|
|
1006
|
-
cancel_event = job["cancel_event"]
|
|
1007
|
-
if cancel_event.is_set():
|
|
1008
|
-
with self._lock:
|
|
1009
|
-
job["status"] = "cancelled"
|
|
1010
|
-
job["completed_at"] = time.time()
|
|
1011
|
-
logger.info("Job [%s] cancelled before start", job_id)
|
|
1012
|
-
return
|
|
1013
|
-
|
|
1014
|
-
handler = job["handler"]
|
|
1015
|
-
namespace = handler._make_namespace(job["args"], cancel_event)
|
|
1016
|
-
result = handler._run_code(
|
|
1017
|
-
job["code"],
|
|
1018
|
-
namespace,
|
|
1019
|
-
job["timeout_seconds"],
|
|
1020
|
-
job_id,
|
|
1021
|
-
cancel_event=cancel_event,
|
|
1022
|
-
)
|
|
1023
|
-
|
|
1024
|
-
with self._lock:
|
|
1025
|
-
job["result"] = result.get("result")
|
|
1026
|
-
job["stdout"] = result.get("stdout", "")
|
|
1027
|
-
job["stderr"] = result.get("stderr", "")
|
|
1028
|
-
job["error"] = result.get("error")
|
|
1029
|
-
if result.get("cancelled") or cancel_event.is_set():
|
|
1030
|
-
job["status"] = "cancelled"
|
|
1031
|
-
else:
|
|
1032
|
-
job["status"] = "failed" if result.get("error") else "succeeded"
|
|
1033
|
-
job["completed_at"] = time.time()
|
|
1034
|
-
|
|
1035
|
-
elapsed = (job["completed_at"] - job["started_at"]) if job["started_at"] else 0
|
|
1036
|
-
logger.info("Job [%s] %s in %.3fs", job_id, job["status"], elapsed)
|
|
1037
|
-
|
|
1038
|
-
_last_execution = {
|
|
1039
|
-
"request_id": job_id,
|
|
1040
|
-
"source": "async job",
|
|
1041
|
-
"status": job["status"],
|
|
1042
|
-
"duration_seconds": round(elapsed, 4),
|
|
1043
|
-
"error_summary": _truncate(job["error"], 200) if job.get("error") else None,
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
def get_status(self, job_id: str) -> dict:
|
|
1047
|
-
with self._lock:
|
|
1048
|
-
job = self._jobs.get(job_id)
|
|
1049
|
-
if not job:
|
|
1050
|
-
raise ValueError(f"Unknown job: {job_id}")
|
|
1051
|
-
return {
|
|
1052
|
-
"job_id": job["job_id"],
|
|
1053
|
-
"status": job["status"],
|
|
1054
|
-
"cancellation_requested": job["cancellation_requested"],
|
|
1055
|
-
"created_at": job["created_at"],
|
|
1056
|
-
"started_at": job["started_at"],
|
|
1057
|
-
"completed_at": job["completed_at"],
|
|
1058
|
-
"result": job["result"],
|
|
1059
|
-
"stdout": job["stdout"],
|
|
1060
|
-
"stderr": job["stderr"],
|
|
1061
|
-
"error": job["error"],
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
def cancel(self, job_id: str) -> dict:
|
|
1065
|
-
with self._lock:
|
|
1066
|
-
job = self._jobs.get(job_id)
|
|
1067
|
-
if not job:
|
|
1068
|
-
raise ValueError(f"Unknown job: {job_id}")
|
|
1069
|
-
|
|
1070
|
-
job["cancel_event"].set()
|
|
1071
|
-
job["cancellation_requested"] = True
|
|
1072
|
-
|
|
1073
|
-
if job["status"] == "queued":
|
|
1074
|
-
job["status"] = "cancelled"
|
|
1075
|
-
job["completed_at"] = time.time()
|
|
1076
|
-
|
|
1077
|
-
status = job["status"]
|
|
1078
|
-
cancellation_requested = job["cancellation_requested"]
|
|
1079
|
-
|
|
1080
|
-
return {
|
|
1081
|
-
"job_id": job_id,
|
|
1082
|
-
"status": status,
|
|
1083
|
-
"cancellation_requested": cancellation_requested,
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
def list_jobs(self) -> dict:
|
|
1087
|
-
with self._lock:
|
|
1088
|
-
jobs = [
|
|
1089
|
-
{
|
|
1090
|
-
"job_id": j["job_id"],
|
|
1091
|
-
"status": j["status"],
|
|
1092
|
-
"created_at": j["created_at"],
|
|
1093
|
-
}
|
|
1094
|
-
for j in self._jobs.values()
|
|
1095
|
-
]
|
|
1096
|
-
return {"jobs": jobs}
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
# Global job manager instance
|
|
1100
|
-
_job_manager = JobManager()
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
class BlenderMCPServer:
|
|
1104
|
-
"""TCP socket server running inside Blender."""
|
|
1105
|
-
|
|
1106
|
-
def __init__(self):
|
|
1107
|
-
self._server_socket: socket.socket | None = None
|
|
1108
|
-
self._thread: threading.Thread | None = None
|
|
1109
|
-
self._running = False
|
|
1110
|
-
self._host = HOST
|
|
1111
|
-
self._port = PORT
|
|
1112
|
-
self._handler = CommandHandler()
|
|
1113
|
-
self._request_queue: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
1114
|
-
self._drain_timer_callback = self._drain_request_queue
|
|
1115
|
-
|
|
1116
|
-
def start(self):
|
|
1117
|
-
if self._running:
|
|
1118
|
-
self._register_request_queue_timer()
|
|
1119
|
-
return
|
|
1120
|
-
_sync_runtime_settings()
|
|
1121
|
-
self._host = HOST
|
|
1122
|
-
self._port = PORT
|
|
1123
|
-
self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
1124
|
-
self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
1125
|
-
self._server_socket.settimeout(1.0)
|
|
1126
|
-
try:
|
|
1127
|
-
self._server_socket.bind((self._host, self._port))
|
|
1128
|
-
except OSError as exc:
|
|
1129
|
-
if self._server_socket is not None:
|
|
1130
|
-
self._server_socket.close()
|
|
1131
|
-
self._server_socket = None
|
|
1132
|
-
if exc.errno == errno.EADDRINUSE:
|
|
1133
|
-
logger.warning(
|
|
1134
|
-
"Blender MCP Bridge port %s already in use; skipping bridge startup",
|
|
1135
|
-
self._port,
|
|
1136
|
-
)
|
|
1137
|
-
self._running = False
|
|
1138
|
-
return
|
|
1139
|
-
raise
|
|
1140
|
-
self._running = True
|
|
1141
|
-
self._server_socket.listen(1)
|
|
1142
|
-
self._thread = threading.Thread(target=self._accept_loop, daemon=True)
|
|
1143
|
-
self._thread.start()
|
|
1144
|
-
self._register_request_queue_timer()
|
|
1145
|
-
logger.info(f"Blender MCP Bridge listening on {self._host}:{self._port}")
|
|
1146
|
-
|
|
1147
|
-
def _register_request_queue_timer(self):
|
|
1148
|
-
timers = bpy.app.timers
|
|
1149
|
-
is_registered = getattr(timers, "is_registered", None)
|
|
1150
|
-
if callable(is_registered):
|
|
1151
|
-
try:
|
|
1152
|
-
if is_registered(self._drain_timer_callback):
|
|
1153
|
-
return
|
|
1154
|
-
except Exception:
|
|
1155
|
-
logger.debug("Unable to check MCP Bridge queue timer registration", exc_info=True)
|
|
1156
|
-
try:
|
|
1157
|
-
timers.register(self._drain_timer_callback, first_interval=0.01, persistent=True)
|
|
1158
|
-
except TypeError:
|
|
1159
|
-
# Blender versions before the persistent timer flag still need the bridge to run.
|
|
1160
|
-
timers.register(self._drain_timer_callback, first_interval=0.01)
|
|
1161
|
-
logger.debug("Registered MCP Bridge request queue timer")
|
|
1162
|
-
|
|
1163
|
-
def stop(self):
|
|
1164
|
-
self._running = False
|
|
1165
|
-
if self._server_socket:
|
|
1166
|
-
self._server_socket.close()
|
|
1167
|
-
self._server_socket = None
|
|
1168
|
-
if self._thread:
|
|
1169
|
-
self._thread.join(timeout=3)
|
|
1170
|
-
self._thread = None
|
|
1171
|
-
while not self._request_queue.empty():
|
|
1172
|
-
queued = self._request_queue.get_nowait()
|
|
1173
|
-
queued["response"] = {
|
|
1174
|
-
"id": queued["request"].get("id"),
|
|
1175
|
-
"success": False,
|
|
1176
|
-
"error": "Blender MCP Bridge stopped",
|
|
1177
|
-
}
|
|
1178
|
-
queued["event"].set()
|
|
1179
|
-
logger.info("Blender MCP Bridge stopped")
|
|
1180
|
-
|
|
1181
|
-
def _accept_loop(self):
|
|
1182
|
-
while self._running:
|
|
1183
|
-
try:
|
|
1184
|
-
conn, addr = self._server_socket.accept()
|
|
1185
|
-
logger.info(f"MCP client connected from {addr}")
|
|
1186
|
-
client_thread = threading.Thread(target=self._handle_client, args=(conn,), daemon=True)
|
|
1187
|
-
client_thread.start()
|
|
1188
|
-
except TimeoutError:
|
|
1189
|
-
continue
|
|
1190
|
-
except OSError:
|
|
1191
|
-
break
|
|
1192
|
-
|
|
1193
|
-
def _handle_client(self, conn: socket.socket):
|
|
1194
|
-
conn.settimeout(None)
|
|
1195
|
-
buffer = b""
|
|
1196
|
-
try:
|
|
1197
|
-
while self._running:
|
|
1198
|
-
data = conn.recv(BUFFER_SIZE)
|
|
1199
|
-
if not data:
|
|
1200
|
-
break
|
|
1201
|
-
buffer += data
|
|
1202
|
-
# Messages are newline-delimited JSON
|
|
1203
|
-
while b"\n" in buffer:
|
|
1204
|
-
line, buffer = buffer.split(b"\n", 1)
|
|
1205
|
-
if not line.strip():
|
|
1206
|
-
continue
|
|
1207
|
-
try:
|
|
1208
|
-
request = json.loads(line)
|
|
1209
|
-
response = self._submit_request(request)
|
|
1210
|
-
except json.JSONDecodeError as e:
|
|
1211
|
-
response = {
|
|
1212
|
-
"id": None,
|
|
1213
|
-
"success": False,
|
|
1214
|
-
"error": f"Invalid JSON: {e}",
|
|
1215
|
-
}
|
|
1216
|
-
conn.sendall(json.dumps(response).encode() + b"\n")
|
|
1217
|
-
except Exception as e:
|
|
1218
|
-
logger.error(f"Client handler error: {e}")
|
|
1219
|
-
finally:
|
|
1220
|
-
conn.close()
|
|
1221
|
-
|
|
1222
|
-
# Commands that modify scene state and need undo push
|
|
1223
|
-
MUTATION_COMMANDS = {
|
|
1224
|
-
"object.create_mesh",
|
|
1225
|
-
"object.delete",
|
|
1226
|
-
"object.translate",
|
|
1227
|
-
"object.rotate",
|
|
1228
|
-
"object.scale",
|
|
1229
|
-
"object.duplicate",
|
|
1230
|
-
"material.create",
|
|
1231
|
-
"material.assign",
|
|
1232
|
-
"material.set_color",
|
|
1233
|
-
"material.set_texture",
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
def _submit_request(self, request: dict) -> dict:
|
|
1237
|
-
queued = {"request": request, "event": threading.Event(), "response": None}
|
|
1238
|
-
logger.debug("Enqueue MCP request id=%s command=%s", request.get("id"), request.get("command"))
|
|
1239
|
-
self._request_queue.put(queued)
|
|
1240
|
-
queued["event"].wait()
|
|
1241
|
-
return queued["response"]
|
|
1242
|
-
|
|
1243
|
-
def _drain_request_queue(self):
|
|
1244
|
-
while True:
|
|
1245
|
-
try:
|
|
1246
|
-
queued = self._request_queue.get_nowait()
|
|
1247
|
-
except queue.Empty:
|
|
1248
|
-
break
|
|
1249
|
-
request = queued["request"]
|
|
1250
|
-
logger.debug(
|
|
1251
|
-
"Processing MCP request id=%s command=%s",
|
|
1252
|
-
request.get("id"),
|
|
1253
|
-
request.get("command"),
|
|
1254
|
-
)
|
|
1255
|
-
queued["response"] = self._process_request(queued["request"])
|
|
1256
|
-
logger.debug(
|
|
1257
|
-
"Finished MCP request id=%s command=%s",
|
|
1258
|
-
request.get("id"),
|
|
1259
|
-
request.get("command"),
|
|
1260
|
-
)
|
|
1261
|
-
queued["event"].set()
|
|
1262
|
-
return 0.01 if self._running else None
|
|
1263
|
-
|
|
1264
|
-
@staticmethod
|
|
1265
|
-
def _maybe_push_undo(command: str):
|
|
1266
|
-
undo_push = bpy.ops.ed.undo_push
|
|
1267
|
-
poll = getattr(undo_push, "poll", None)
|
|
1268
|
-
if callable(poll) and not poll():
|
|
1269
|
-
logger.debug(f"Skipping undo push for {command}: context poll failed")
|
|
1270
|
-
return
|
|
1271
|
-
undo_push(message=f"MCP: {command}")
|
|
1272
|
-
|
|
1273
|
-
def _process_request(self, request: dict) -> dict:
|
|
1274
|
-
req_id = request.get("id")
|
|
1275
|
-
command = request.get("command", "")
|
|
1276
|
-
params = request.get("params", {})
|
|
1277
|
-
try:
|
|
1278
|
-
# Auto-push undo before mutations
|
|
1279
|
-
if command in self.MUTATION_COMMANDS:
|
|
1280
|
-
self._maybe_push_undo(command)
|
|
1281
|
-
result = self._handler.handle(command, params)
|
|
1282
|
-
return {"id": req_id, "success": True, "result": result}
|
|
1283
|
-
except Exception as e:
|
|
1284
|
-
logger.error(f"Command '{command}' failed: {e}\n{traceback.format_exc()}")
|
|
1285
|
-
return {"id": req_id, "success": False, "error": str(e)}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
# Global server instance
|
|
1289
|
-
_server: BlenderMCPServer | None = None
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
def _server_healthy() -> bool:
|
|
1293
|
-
global _server
|
|
1294
|
-
return bool(
|
|
1295
|
-
_server
|
|
1296
|
-
and _server._running
|
|
1297
|
-
and _server._server_socket is not None
|
|
1298
|
-
and _server._thread is not None
|
|
1299
|
-
and _server._thread.is_alive()
|
|
1300
|
-
)
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
def _ensure_server_running():
|
|
1304
|
-
global _server
|
|
1305
|
-
if _server_healthy():
|
|
1306
|
-
_server._register_request_queue_timer()
|
|
1307
|
-
return None
|
|
1308
|
-
if _server:
|
|
1309
|
-
try:
|
|
1310
|
-
_server.stop()
|
|
1311
|
-
except Exception:
|
|
1312
|
-
logger.exception("Failed stopping stale MCP Bridge during restart")
|
|
1313
|
-
_sync_runtime_settings()
|
|
1314
|
-
_server = BlenderMCPServer()
|
|
1315
|
-
_server.start()
|
|
1316
|
-
return None
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
@persistent
|
|
1320
|
-
def _on_load_post(_dummy):
|
|
1321
|
-
bpy.app.timers.register(_ensure_server_running, first_interval=0.1)
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
class MCP_AddonPreferences(bpy.types.AddonPreferences):
|
|
1325
|
-
bl_idname = __name__
|
|
1326
|
-
|
|
1327
|
-
safe_mode: bpy.props.BoolProperty(
|
|
1328
|
-
name="Safe Mode",
|
|
1329
|
-
description="Restrict file access to project directory and enable tool whitelist",
|
|
1330
|
-
default=False,
|
|
1331
|
-
)
|
|
1332
|
-
port: bpy.props.IntProperty(
|
|
1333
|
-
name="Port",
|
|
1334
|
-
description="TCP port for the MCP bridge",
|
|
1335
|
-
default=9876,
|
|
1336
|
-
min=1024,
|
|
1337
|
-
max=65535,
|
|
1338
|
-
)
|
|
1339
|
-
allow_inline_code: bpy.props.BoolProperty(
|
|
1340
|
-
name="Allow Inline Code",
|
|
1341
|
-
description="Allow python.execute to run inline code strings. Disable to only allow script files",
|
|
1342
|
-
default=True,
|
|
1343
|
-
)
|
|
1344
|
-
approved_script_roots: bpy.props.StringProperty(
|
|
1345
|
-
name="Approved Script Roots",
|
|
1346
|
-
description="Semicolon-separated list of directories from which script files may be loaded",
|
|
1347
|
-
default="",
|
|
1348
|
-
)
|
|
1349
|
-
|
|
1350
|
-
def draw(self, context):
|
|
1351
|
-
layout = self.layout
|
|
1352
|
-
layout.prop(self, "safe_mode")
|
|
1353
|
-
layout.prop(self, "port")
|
|
1354
|
-
layout.separator()
|
|
1355
|
-
layout.label(text="Python Execution")
|
|
1356
|
-
layout.prop(self, "allow_inline_code")
|
|
1357
|
-
layout.prop(self, "approved_script_roots")
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
class MCP_PT_Panel(bpy.types.Panel):
|
|
1361
|
-
bl_label = "MCP Bridge"
|
|
1362
|
-
bl_idname = "MCP_PT_panel"
|
|
1363
|
-
bl_space_type = "VIEW_3D"
|
|
1364
|
-
bl_region_type = "UI"
|
|
1365
|
-
bl_category = "MCP"
|
|
1366
|
-
|
|
1367
|
-
def draw(self, context):
|
|
1368
|
-
layout = self.layout
|
|
1369
|
-
global _server
|
|
1370
|
-
if _server and _server._running:
|
|
1371
|
-
layout.label(text=f"● Listening on {_server._host}:{_server._port}", icon="LINKED")
|
|
1372
|
-
layout.operator("mcp.stop_server", text="Stop Server")
|
|
1373
|
-
else:
|
|
1374
|
-
layout.label(text="○ Server stopped", icon="UNLINKED")
|
|
1375
|
-
layout.operator("mcp.start_server", text="Start Server")
|
|
1376
|
-
|
|
1377
|
-
# Last script execution status
|
|
1378
|
-
if _last_execution["request_id"]:
|
|
1379
|
-
layout.separator()
|
|
1380
|
-
layout.label(text="Last Execution", icon="SCRIPT")
|
|
1381
|
-
box = layout.box()
|
|
1382
|
-
status = _last_execution["status"]
|
|
1383
|
-
icon = "CHECKMARK" if status in ("ok", "succeeded") else "ERROR"
|
|
1384
|
-
box.label(text=f"Status: {status}", icon=icon)
|
|
1385
|
-
box.label(text=f"ID: {_last_execution['request_id']}")
|
|
1386
|
-
if _last_execution["duration_seconds"] is not None:
|
|
1387
|
-
box.label(text=f"Duration: {_last_execution['duration_seconds']:.3f}s")
|
|
1388
|
-
if _last_execution["error_summary"]:
|
|
1389
|
-
box.label(text=f"Error: {_last_execution['error_summary']}", icon="ERROR")
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
class MCP_OT_StartServer(bpy.types.Operator):
|
|
1393
|
-
bl_idname = "mcp.start_server"
|
|
1394
|
-
bl_label = "Start MCP Server"
|
|
1395
|
-
|
|
1396
|
-
def execute(self, context):
|
|
1397
|
-
global _server
|
|
1398
|
-
if _server is None:
|
|
1399
|
-
_server = BlenderMCPServer()
|
|
1400
|
-
_server.start()
|
|
1401
|
-
self.report({"INFO"}, f"MCP Bridge started on {_server._host}:{_server._port}")
|
|
1402
|
-
return {"FINISHED"}
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
class MCP_OT_StopServer(bpy.types.Operator):
|
|
1406
|
-
bl_idname = "mcp.stop_server"
|
|
1407
|
-
bl_label = "Stop MCP Server"
|
|
1408
|
-
|
|
1409
|
-
def execute(self, context):
|
|
1410
|
-
global _server
|
|
1411
|
-
if _server:
|
|
1412
|
-
_server.stop()
|
|
1413
|
-
self.report({"INFO"}, "MCP Bridge stopped")
|
|
1414
|
-
return {"FINISHED"}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
classes = (MCP_AddonPreferences, MCP_PT_Panel, MCP_OT_StartServer, MCP_OT_StopServer)
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
def register():
|
|
1421
|
-
for cls in classes:
|
|
1422
|
-
bpy.utils.register_class(cls)
|
|
1423
|
-
if _on_load_post not in bpy.app.handlers.load_post:
|
|
1424
|
-
bpy.app.handlers.load_post.append(_on_load_post)
|
|
1425
|
-
_ensure_server_running()
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
def unregister():
|
|
1429
|
-
global _server
|
|
1430
|
-
if _on_load_post in bpy.app.handlers.load_post:
|
|
1431
|
-
bpy.app.handlers.load_post.remove(_on_load_post)
|
|
1432
|
-
if _server:
|
|
1433
|
-
_server.stop()
|
|
1434
|
-
_server = None
|
|
1435
|
-
for cls in reversed(classes):
|
|
1436
|
-
bpy.utils.unregister_class(cls)
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
if __name__ == "__main__":
|
|
1440
|
-
register()
|