Compare commits
298 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74198743ab | |||
| cde5c67134 | |||
| baad41da98 | |||
| d57bff184e | |||
| f6d9fcaae2 | |||
| 8d94bb606c | |||
| b175d4d890 | |||
| 6973f657d7 | |||
| a0d1b38c6e | |||
| c5f68256c5 | |||
| 9698e8724d | |||
| f3e1f42413 | |||
| 8a957b1b69 | |||
| 6e90064160 | |||
| 5c4e97a3f6 | |||
| 351be5a40d | |||
| 67944a7e1c | |||
| e37653f956 | |||
| 235e72d3d7 | |||
| ba8e86e31c | |||
| 67f330be6c | |||
| 445b744196 | |||
| ad73c526b7 | |||
| 26310d05f0 | |||
| 459550e7d3 | |||
| a69a4d19d0 | |||
| f2a62627d0 | |||
| 0abf510ec0 | |||
| 008187a0a4 | |||
| 4bd15e5deb | |||
| 8234683bc3 | |||
| 5b3da8da85 | |||
| 894e015c01 | |||
| a66a2bc519 | |||
| b8851a0ae3 | |||
| aee199e6cf | |||
| 223a2d626a | |||
| b7fce0fafd | |||
| 551c60fb45 | |||
| af6a42b2ac | |||
| 7cae21f7c9 | |||
| 8048fba931 | |||
| 1b36ca77ab | |||
| eb85ea31bb | |||
| 8627d9e968 | |||
| 3da9adf44e | |||
| bcb24ae641 | |||
| c8ede3c30b | |||
| fb1c664309 | |||
| 90f19dfc0f | |||
| 75492b0d38 | |||
| 54bb347ee1 | |||
| 51bcc26ea9 | |||
| d813147ca7 | |||
| dbb6d46fa4 | |||
| e7050e2ad8 | |||
| 206f1c378e | |||
| 35380594b4 | |||
| 0055c9ecf2 | |||
| a74a048898 | |||
| 37676d4645 | |||
| 7492cfad66 | |||
| 59db9ea0b0 | |||
| 9234cf1add | |||
| a21199d3db | |||
| 1abda1ca0f | |||
| 0118bc7b9b | |||
| bbb822db16 | |||
| 08e1dcb1f5 | |||
| ec7141a5aa | |||
| 1b029d97b8 | |||
| 4ed3ed7e14 | |||
| c5232bd7bf | |||
| f9e23fd6eb | |||
| 457ed9c9ff | |||
| dadb4d3576 | |||
| ba771f100f | |||
| 2b9cb5defd | |||
| 34227126c2 | |||
| ef94602eba | |||
| 155e7be399 | |||
| 1fb9e6cece | |||
| f2cf082ba8 | |||
| d580464f4a | |||
| fe6b354ee2 | |||
| ec965dc8ee | |||
| cb07a382ea | |||
| 8f450c0e7b | |||
| fdee539371 | |||
| 0e9187c5a9 | |||
| 46af00019c | |||
| 2b041cb771 | |||
| 0fc40d0fda | |||
| 68f50fed55 | |||
| 9d5615409c | |||
| 48ce693bb5 | |||
| 74531e06d0 | |||
| 20458add3f | |||
| 45b899b093 | |||
| 12133f698e | |||
| 797b3064c9 | |||
| 057c61fb16 | |||
| c91f21f3d1 | |||
| 67fae6a93d | |||
| 9e4b3d5a91 | |||
| bf0745cc94 | |||
| 72049c2518 | |||
| bc0282b5f8 | |||
| 4c262a7227 | |||
| 530c5f4aa0 | |||
| 61a3b4611f | |||
| 834cf3ac56 | |||
| 28c9552d1d | |||
| db67d3cc76 | |||
| 896cdcfa0f | |||
| 34de0e4e82 | |||
| 1a0f3aa779 | |||
| 30b379b68c | |||
| 842ce3f6bc | |||
| a933edeef1 | |||
| 74ff305b67 | |||
| f22d49ed07 | |||
| 3f79288b54 | |||
| 1df9573f7a | |||
| 35d5f14003 | |||
| 5321b2929e | |||
| 05aa50d409 | |||
| a910e9327d | |||
| 9d1fedd3a5 | |||
| 320ea6b72b | |||
| 54e8e694b1 | |||
| 69bbffd9cc | |||
| 6b666bff46 | |||
| 8ec7cbb1e9 | |||
| a77b8ee123 | |||
| 498dac5230 | |||
| 3519a96d06 | |||
| af0b92461a | |||
| 89f0354ccc | |||
| f809c672b5 | |||
| 6a267d074b | |||
| bcde33c7d0 | |||
| ee3268fbe0 | |||
| f6a38ffaa8 | |||
| b13ffce0a0 | |||
| 5d205c9c13 | |||
| b39e01efd7 | |||
| 98eea5b6f9 | |||
| fe36ed91f2 | |||
| 8c85f9ca5f | |||
| 98df35a33e | |||
| 70d6963d0d | |||
| efb83e0f28 | |||
| 54c6694117 | |||
| 2402f88daf | |||
| 7bedfa2c65 | |||
| 1cf1dbefb8 | |||
| dafa8db8bb | |||
| 42ab4f13cf | |||
| 65e79efb24 | |||
| 5ffc13b635 | |||
| 77dc122079 | |||
| 50bfd20fd4 | |||
| c14f1f46cd | |||
| ce774bcc6f | |||
| 52c8371f4a | |||
| f8d6d42150 | |||
| b3abe863af | |||
| 469487f6ed | |||
| 7a2966367d | |||
| 0466b299a7 | |||
| b34304ed57 | |||
| 96963531fc | |||
| 4c9a7c55ae | |||
| 8a75203251 | |||
| da6e81260e | |||
| e1f1335655 | |||
| b017db83a1 | |||
| bc136fab7e | |||
| 6c24bcbb91 | |||
| 11a05799d3 | |||
| 403271dc0c | |||
| cc4abf67b9 | |||
| 35cf20e02d | |||
| 5209f82efb | |||
| 1f55387e9e | |||
| 32bbca73ba | |||
| 0e6999ea21 | |||
| 0d120bd041 | |||
| 508832dae1 | |||
| 6cf3c1830c | |||
| 0b23a02886 | |||
| 71987ee537 | |||
| b7829dca05 | |||
| 9b0e9a69b1 | |||
| ad0e14d07f | |||
| 7fd5fffedf | |||
| 620173eef6 | |||
| 0fe4f62a30 | |||
| 533810f018 | |||
| 2ee23a39d8 | |||
| 894c85bd54 | |||
| 01809a7367 | |||
| a20f1bfdf8 | |||
| 7879e07815 | |||
| eced0fbd07 | |||
| aa6d7f5866 | |||
| 3e5197779d | |||
| 9206931a3c | |||
| ff3be54f1c | |||
| 1b0f5f4973 | |||
| 8ed0d8f207 | |||
| 007b55916c | |||
| eeef35aa61 | |||
| be2d989899 | |||
| 306143882a | |||
| 0c07820b5a | |||
| d2ad90d5bb | |||
| 642dca7062 | |||
| faafced061 | |||
| c3df0f95e6 | |||
| f714957d83 | |||
| 40af243229 | |||
| 69b71fc7cf | |||
| 5ad207520a | |||
| 78d77c1e0a | |||
| 5cf43d5de2 | |||
| 51ef10633b | |||
| 83094598c5 | |||
| 5da29c8e35 | |||
| 4f3560d121 | |||
| d5e521a759 | |||
| b2c51251f3 | |||
| 71efa1aafa | |||
| aa3ff016e2 | |||
| 4557d2ce40 | |||
| d282a65fc6 | |||
| ad56700059 | |||
| df2f5ebb47 | |||
| feb86b059f | |||
| c23e84f965 | |||
| 195ca5c10c | |||
| 53f1b9662f | |||
| eeffb9e853 | |||
| 6c142a9710 | |||
| f781c6f7b1 | |||
| 8228c20d47 | |||
| 85953d8e1e | |||
| f8b6131bfc | |||
| cd3d4c69f0 | |||
| 7f6e0893dd | |||
| 39105688a5 | |||
| 2a6b3df8e1 | |||
| 0c2fc8c0d9 | |||
| b5144de0cf | |||
| 29c54279a9 | |||
| 178593f355 | |||
| a70df64cae | |||
| 2a2ac5f85e | |||
| e01ba74e84 | |||
| 565540d0ba | |||
| 394c91f8cf | |||
| 89bfd98d9f | |||
| 5c9dd8d6e0 | |||
| 374912b463 | |||
| debb91aa7e | |||
| 40860c172e | |||
| 50ebe83c0a | |||
| 7295345013 | |||
| a2502c708b | |||
| 4ede59e89a | |||
| 3017e4c097 | |||
| de7675a649 | |||
| aa7bb8f1a4 | |||
| 0a8af05f9c | |||
| 04322732bc | |||
| 09d82b310e | |||
| 50b45f4834 | |||
| 39ad0043c6 | |||
| e5ca804692 | |||
| 1c694b6469 | |||
| c1e9382031 | |||
| b6c632b75f | |||
| a8ea1fcc25 | |||
| ebb950d39c | |||
| b508d4bcce | |||
| 23537158bc | |||
| 870ed26510 | |||
| 395daecda8 | |||
| 337f40600a | |||
| 14425cfad1 | |||
| c38404a98a | |||
| 70d3933da4 | |||
| cce2066d3b | |||
| 5d95e42eb5 | |||
| f35706e621 | |||
| 697031c526 | |||
| 32797ce473 |
@@ -1,27 +1,61 @@
|
||||
{
|
||||
"name": "claude-code-marketplace",
|
||||
"name": "leo-claude-mktplace",
|
||||
"owner": {
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Project management plugins with Gitea and NetBox integrations",
|
||||
"version": "2.2.0"
|
||||
"version": "5.2.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "projman",
|
||||
"version": "2.2.0",
|
||||
"version": "3.2.0",
|
||||
"description": "Sprint planning and project management with Gitea integration",
|
||||
"source": "./plugins/projman",
|
||||
"author": {
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace/src/branch/main/plugins/projman/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace.git",
|
||||
"mcpServers": ["gitea"],
|
||||
"integrationFile": "claude-md-integration.md"
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/projman/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"mcpServers": ["./.mcp.json"],
|
||||
"category": "development",
|
||||
"tags": ["sprint", "agile", "gitea", "project-management"],
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "doc-guardian",
|
||||
"version": "1.0.0",
|
||||
"description": "Automatic documentation drift detection and synchronization",
|
||||
"source": "./plugins/doc-guardian",
|
||||
"author": {
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/doc-guardian/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"hooks": ["./hooks/hooks.json"],
|
||||
"category": "productivity",
|
||||
"tags": ["documentation", "drift-detection", "sync"],
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "code-sentinel",
|
||||
"version": "1.0.0",
|
||||
"description": "Security scanning and code refactoring tools",
|
||||
"source": "./plugins/code-sentinel",
|
||||
"author": {
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/code-sentinel/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"hooks": ["./hooks/hooks.json"],
|
||||
"category": "security",
|
||||
"tags": ["security-scan", "refactoring", "vulnerabilities"],
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "project-hygiene",
|
||||
@@ -32,25 +66,28 @@
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace/src/branch/main/plugins/project-hygiene/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace.git",
|
||||
"mcpServers": [],
|
||||
"integrationFile": "claude-md-integration.md",
|
||||
"hooks": ["PostToolUse"]
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/project-hygiene/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"hooks": ["./hooks/hooks.json"],
|
||||
"category": "productivity",
|
||||
"tags": ["cleanup", "automation", "hygiene"],
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "cmdb-assistant",
|
||||
"version": "1.0.0",
|
||||
"description": "NetBox CMDB integration for infrastructure management",
|
||||
"version": "1.1.0",
|
||||
"description": "NetBox CMDB integration with data quality validation and machine registration",
|
||||
"source": "./plugins/cmdb-assistant",
|
||||
"author": {
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace/src/branch/main/plugins/cmdb-assistant/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace.git",
|
||||
"mcpServers": ["netbox"],
|
||||
"integrationFile": "claude-md-integration.md"
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/cmdb-assistant/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"mcpServers": ["./.mcp.json"],
|
||||
"category": "infrastructure",
|
||||
"tags": ["cmdb", "netbox", "dcim", "ipam", "data-quality", "validation"],
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "claude-config-maintainer",
|
||||
@@ -61,19 +98,105 @@
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace/src/branch/main/plugins/claude-config-maintainer/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace.git",
|
||||
"mcpServers": [],
|
||||
"integrationFile": "claude-md-integration.md"
|
||||
}
|
||||
],
|
||||
"pluginDetection": {
|
||||
"mcpServerMapping": {
|
||||
"gitea": "projman",
|
||||
"netbox": "cmdb-assistant"
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/claude-config-maintainer/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"category": "development",
|
||||
"tags": ["claude-md", "configuration", "optimization"],
|
||||
"license": "MIT"
|
||||
},
|
||||
"hookMapping": {
|
||||
"PostToolUse:Write|Edit": "project-hygiene"
|
||||
}
|
||||
{
|
||||
"name": "clarity-assist",
|
||||
"version": "1.0.0",
|
||||
"description": "Prompt optimization and requirement clarification with ND-friendly accommodations",
|
||||
"source": "./plugins/clarity-assist",
|
||||
"author": {
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/clarity-assist/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"category": "productivity",
|
||||
"tags": ["prompts", "requirements", "clarification", "nd-friendly"],
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "git-flow",
|
||||
"version": "1.0.0",
|
||||
"description": "Git workflow automation with intelligent commit messages and branch management",
|
||||
"source": "./plugins/git-flow",
|
||||
"author": {
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/git-flow/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"category": "development",
|
||||
"tags": ["git", "workflow", "commits", "branching"],
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "pr-review",
|
||||
"version": "1.0.0",
|
||||
"description": "Multi-agent pull request review with confidence scoring and actionable feedback",
|
||||
"source": "./plugins/pr-review",
|
||||
"author": {
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/pr-review/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"mcpServers": ["./.mcp.json"],
|
||||
"category": "development",
|
||||
"tags": ["code-review", "pull-requests", "security", "quality"],
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "data-platform",
|
||||
"version": "1.0.0",
|
||||
"description": "Data engineering tools with pandas, PostgreSQL/PostGIS, and dbt integration",
|
||||
"source": "./plugins/data-platform",
|
||||
"author": {
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/data-platform/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"mcpServers": ["./.mcp.json"],
|
||||
"category": "data",
|
||||
"tags": ["pandas", "postgresql", "postgis", "dbt", "data-engineering", "etl"],
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "viz-platform",
|
||||
"version": "1.0.0",
|
||||
"description": "Visualization tools with Dash Mantine Components validation, Plotly charts, and theming",
|
||||
"source": "./plugins/viz-platform",
|
||||
"author": {
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/viz-platform/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"mcpServers": ["./.mcp.json"],
|
||||
"category": "visualization",
|
||||
"tags": ["dash", "plotly", "mantine", "charts", "dashboards", "theming", "dmc"],
|
||||
"license": "MIT"
|
||||
},
|
||||
{
|
||||
"name": "contract-validator",
|
||||
"version": "1.0.0",
|
||||
"description": "Cross-plugin compatibility validation and Claude.md agent verification",
|
||||
"source": "./plugins/contract-validator",
|
||||
"author": {
|
||||
"name": "Leo Miranda",
|
||||
"email": "leobmiranda@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/contract-validator/README.md",
|
||||
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
|
||||
"mcpServers": ["./.mcp.json"],
|
||||
"category": "development",
|
||||
"tags": ["validation", "contracts", "compatibility", "agents", "interfaces", "cross-plugin"],
|
||||
"license": "MIT"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,6 +31,8 @@ venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv/
|
||||
.venv
|
||||
**/.venv
|
||||
|
||||
# PyCharm
|
||||
.idea/
|
||||
|
||||
506
CHANGELOG.md
506
CHANGELOG.md
@@ -1,9 +1,456 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to support-claude-mktplace will be documented in this file.
|
||||
All notable changes to the Leo Claude Marketplace will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [5.2.0] - 2026-01-28
|
||||
|
||||
### Added
|
||||
|
||||
#### Sprint 5: Documentation (V5.2.0 Plugin Enhancements)
|
||||
Documentation and guides for the plugin enhancements initiative.
|
||||
|
||||
**git-flow v1.2.0:**
|
||||
- **Branching Strategy Guide** (`docs/BRANCHING-STRATEGY.md`) - Complete documentation of `development -> staging -> main` promotion flow with Mermaid diagrams
|
||||
|
||||
**clarity-assist v1.2.0:**
|
||||
- **ND Support Guide** (`docs/ND-SUPPORT.md`) - Documentation of neurodivergent accommodations, features, and usage examples
|
||||
|
||||
**Gitea MCP Server:**
|
||||
- **`update_issue` milestone parameter** - Can now assign/change milestones programmatically
|
||||
|
||||
**Sprint Completed:**
|
||||
- Milestone: Sprint 5 - Documentation (closed 2026-01-28)
|
||||
- Issues: #266, #267, #268, #269
|
||||
- Wiki: [Change V5.2.0: Plugin Enhancements (Sprint 5 Documentation)](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/Change-V5.2.0%3A-Plugin-Enhancements-%28Sprint-5-Documentation%29)
|
||||
|
||||
---
|
||||
|
||||
#### Sprint 4: Commands (V5.2.0 Plugin Enhancements)
|
||||
Implementation of 18 new user-facing commands across 8 plugins.
|
||||
|
||||
**projman v3.3.0:**
|
||||
- **`/sprint-diagram`** - Generate Mermaid diagram of sprint issues with dependencies and status
|
||||
|
||||
**pr-review v1.1.0:**
|
||||
- **`/pr-diff`** - Formatted diff with inline review comments and annotations
|
||||
- **Confidence threshold config** - `PR_REVIEW_CONFIDENCE_THRESHOLD` env var (default: 0.7)
|
||||
|
||||
**data-platform v1.2.0:**
|
||||
- **`/data-quality`** - DataFrame quality checks (nulls, duplicates, types, outliers) with pass/warn/fail scoring
|
||||
- **`/lineage-viz`** - dbt lineage visualization as Mermaid diagrams
|
||||
- **`/dbt-test`** - Formatted dbt test runner with summary and failure details
|
||||
|
||||
**viz-platform v1.1.0:**
|
||||
- **`/chart-export`** - Export charts to PNG, SVG, PDF via kaleido
|
||||
- **`/accessibility-check`** - Color blind validation (WCAG contrast ratios)
|
||||
- **`/breakpoints`** - Responsive layout breakpoint configuration
|
||||
- **New MCP tools**: `chart_export`, `accessibility_validate_colors`, `accessibility_validate_theme`, `accessibility_suggest_alternative`, `layout_set_breakpoints`
|
||||
- **New dependency**: kaleido>=0.2.1 for chart rendering
|
||||
|
||||
**contract-validator v1.2.0:**
|
||||
- **`/dependency-graph`** - Mermaid visualization of plugin dependencies with data flow
|
||||
|
||||
**doc-guardian v1.1.0:**
|
||||
- **`/changelog-gen`** - Generate changelog from conventional commits
|
||||
- **`/doc-coverage`** - Documentation coverage metrics by function/class
|
||||
- **`/stale-docs`** - Flag documentation behind code changes
|
||||
|
||||
**claude-config-maintainer v1.1.0:**
|
||||
- **`/config-diff`** - Track CLAUDE.md changes over time with behavioral impact analysis
|
||||
- **`/config-lint`** - 31 lint rules for CLAUDE.md (security, structure, content, format, best practices)
|
||||
|
||||
**cmdb-assistant v1.2.0:**
|
||||
- **`/cmdb-topology`** - Infrastructure topology diagrams (rack, network, site views)
|
||||
- **`/change-audit`** - NetBox audit trail queries with filtering
|
||||
- **`/ip-conflicts`** - Detect IP conflicts and overlapping prefixes
|
||||
|
||||
**Sprint Completed:**
|
||||
- Milestone: Sprint 4 - Commands (closed 2026-01-28)
|
||||
- Issues: #241-#258 (18/18 closed)
|
||||
- Wiki: [Change V5.2.0: Plugin Enhancements (Sprint 4 Commands)](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/Change-V5.2.0%3A-Plugin-Enhancements-%28Sprint-4-Commands%29)
|
||||
- Lessons: [Sprint 4 - Plugin Commands Implementation](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/lessons/sprints/sprint-4---plugin-commands-implementation)
|
||||
|
||||
### Fixed
|
||||
- **MCP:** Project directory detection - all run.sh scripts now capture `CLAUDE_PROJECT_DIR` from PWD before changing directories
|
||||
- **Docs:** Added Gitea auto-close behavior and MCP session restart notes to DEBUGGING-CHECKLIST.md
|
||||
|
||||
---
|
||||
|
||||
#### Sprint 3: Hooks (V5.2.0 Plugin Enhancements)
|
||||
Implementation of 6 foundational hooks across 4 plugins.
|
||||
|
||||
**git-flow v1.1.0:**
|
||||
- **Commit message enforcement hook** - PreToolUse hook validates conventional commit format on all `git commit` commands (not just `/commit`). Blocks invalid commits with format guidance.
|
||||
- **Branch name validation hook** - PreToolUse hook validates branch naming on `git checkout -b` and `git switch -c`. Enforces `type/description` format, lowercase, max 50 chars.
|
||||
|
||||
**clarity-assist v1.1.0:**
|
||||
- **Vagueness detection hook** - UserPromptSubmit hook detects vague prompts and suggests `/clarify` when ambiguity, missing context, or unclear scope detected.
|
||||
|
||||
**data-platform v1.1.0:**
|
||||
- **Schema diff detection hook** - PostToolUse hook monitors edits to schema files (dbt models, SQL migrations). Warns on breaking changes (column removal, type narrowing, constraint addition).
|
||||
|
||||
**contract-validator v1.1.0:**
|
||||
- **SessionStart auto-validate hook** - Smart validation that only runs when plugin files changed since last check. Detects interface compatibility issues at session start.
|
||||
- **Breaking change detection hook** - PostToolUse hook monitors plugin interface files (README.md, plugin.json). Warns when changes would break consumers.
|
||||
|
||||
**Sprint Completed:**
|
||||
- Milestone: Sprint 3 - Hooks (closed 2026-01-28)
|
||||
- Issues: #225, #226, #227, #228, #229, #230
|
||||
- Wiki: [Change V5.2.0: Plugin Enhancements Proposal](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/Change-V5.2.0:-Plugin-Enhancements-Proposal)
|
||||
- Lessons: Background agent permissions, agent runaway detection, MCP branch detection bug
|
||||
|
||||
### Known Issues
|
||||
- **MCP Bug #231:** Branch detection in Gitea MCP runs from installed plugin directory, not user's project directory. Workaround: close issues via Gitea web UI.
|
||||
|
||||
---
|
||||
|
||||
#### Gitea MCP Server - create_pull_request Tool
|
||||
- **`create_pull_request`**: Create new pull requests via MCP
|
||||
- Parameters: title, body, head (source branch), base (target branch), labels
|
||||
- Branch-aware security: only allowed on development/feature branches
|
||||
- Completes the PR lifecycle (was previously missing - only had list/get/review/comment)
|
||||
|
||||
#### cmdb-assistant v1.1.0 - Data Quality Validation
|
||||
- **SessionStart Hook**: Tests NetBox API connectivity at session start
|
||||
- Warns if VMs exist without site assignment
|
||||
- Warns if devices exist without platform
|
||||
- Non-blocking: displays warning, doesn't prevent work
|
||||
- **PreToolUse Hook**: Validates input parameters before VM/device operations
|
||||
- Warns about missing site, tenant, platform
|
||||
- Non-blocking: suggests best practices without blocking
|
||||
- **`/cmdb-audit` Command**: Comprehensive data quality analysis
|
||||
- Scopes: all, vms, devices, naming, roles
|
||||
- Identifies Critical/High/Medium/Low issues
|
||||
- Provides prioritized remediation recommendations
|
||||
- **`/cmdb-register` Command**: Register current machine into NetBox
|
||||
- Discovers system info: hostname, platform, hardware, network interfaces
|
||||
- Discovers running apps: Docker containers, systemd services
|
||||
- Creates device with interfaces, IPs, and sets primary IP
|
||||
- Creates cluster and VMs for Docker containers
|
||||
- **`/cmdb-sync` Command**: Sync machine state with NetBox
|
||||
- Compares current state with NetBox record
|
||||
- Shows diff of changes (interfaces, IPs, containers)
|
||||
- Updates with user confirmation
|
||||
- Supports --full and --dry-run flags
|
||||
- **NetBox Best Practices Skill**: Reference documentation
|
||||
- Dependency order for object creation
|
||||
- Naming conventions (`{role}-{site}-{number}`, `{env}-{app}-{number}`)
|
||||
- Role consolidation guidance
|
||||
- Site/tenant/platform assignment requirements
|
||||
- **Agent Enhancement**: Updated cmdb-assistant agent with validation requirements
|
||||
- Proactive suggestions for missing fields
|
||||
- Naming convention checks
|
||||
- Dependency order enforcement
|
||||
- Duplicate prevention
|
||||
|
||||
---
|
||||
|
||||
## [5.0.0] - 2026-01-26
|
||||
|
||||
### Added
|
||||
|
||||
#### Sprint 1: viz-platform Plugin ✅ Completed
|
||||
- **viz-platform** v1.0.0 - Visualization tools with Dash Mantine Components validation and theming
|
||||
- **DMC Tools** (3 tools): `list_components`, `get_component_props`, `validate_component`
|
||||
- Version-locked component registry prevents Claude from hallucinating invalid props
|
||||
- Static JSON registry approach for fast, deterministic validation
|
||||
- **Chart Tools** (2 tools): `chart_create`, `chart_configure_interaction`
|
||||
- Plotly-based visualization with theme token support
|
||||
- **Layout Tools** (5 tools): `layout_create`, `layout_add_filter`, `layout_set_grid`, `layout_get`, `layout_add_section`
|
||||
- Dashboard composition with responsive grid system
|
||||
- **Theme Tools** (6 tools): `theme_create`, `theme_extend`, `theme_validate`, `theme_export_css`, `theme_list`, `theme_activate`
|
||||
- Design token-based theming system
|
||||
- Dual storage: user-level (`~/.config/claude/themes/`) and project-level
|
||||
- **Page Tools** (5 tools): `page_create`, `page_add_navbar`, `page_set_auth`, `page_list`, `page_get_app_config`
|
||||
- Multi-page Dash app structure generation
|
||||
- **Commands**: `/chart`, `/dashboard`, `/theme`, `/theme-new`, `/theme-css`, `/component`, `/initial-setup`
|
||||
- **Agents**: `theme-setup`, `layout-builder`, `component-check`
|
||||
- **SessionStart Hook**: DMC version check (non-blocking)
|
||||
- **Tests**: 94 tests passing
|
||||
- config.py: 82% coverage
|
||||
- component_registry.py: 92% coverage
|
||||
- dmc_tools.py: 88% coverage
|
||||
- chart_tools.py: 68% coverage
|
||||
- theme_tools.py: 99% coverage
|
||||
|
||||
**Sprint Completed:**
|
||||
- Milestone: Sprint 1 - viz-platform Plugin (closed 2026-01-26)
|
||||
- Issues: #170-#182 (13/13 closed)
|
||||
- Wiki: [Sprint-1-viz-platform-Implementation-Plan](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/Sprint-1-viz-platform-Implementation-Plan)
|
||||
- Lessons: [sprint-1---viz-platform-plugin-implementation](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/lessons/sprints/sprint-1---viz-platform-plugin-implementation)
|
||||
- Reference: `docs/changes/CHANGE_V04_0_0_PROPOSAL_ORIGINAL.md` (Phases 4-5)
|
||||
|
||||
---
|
||||
|
||||
## [4.1.0] - 2026-01-26
|
||||
|
||||
### Added
|
||||
- **projman:** Wiki-based planning workflow enhancement (V04.1.0)
|
||||
- Flexible input source detection in `/sprint-plan` (file, wiki, or conversation)
|
||||
- Wiki proposal and implementation page creation during sprint planning
|
||||
- Wiki reference linking in created issues
|
||||
- Wiki status updates in `/sprint-close` (Implemented/Partial/Failed)
|
||||
- Metadata section in lessons learned with implementation link for traceability
|
||||
- New `/proposal-status` command for viewing proposal/implementation tree
|
||||
- **projman:** `/suggest-version` command - Analyzes CHANGELOG and recommends semantic version bump
|
||||
- **projman:** SessionStart hook now suggests sprint planning when open issues exist without milestone
|
||||
- **projman:** SessionStart hook now warns about unreleased CHANGELOG entries
|
||||
|
||||
### Changed
|
||||
- **doc-guardian:** Hook now tracks documentation dependencies and queues specific files needing updates
|
||||
- Outputs which specific docs need updating (e.g., "commands changed → update needed: docs/COMMANDS-CHEATSHEET.md README.md")
|
||||
- Maintains queue file (`.doc-guardian-queue`) for batch processing
|
||||
- **docs:** COMMANDS-CHEATSHEET.md updated with data-platform plugin (7 commands + hook)
|
||||
|
||||
### Fixed
|
||||
- Documentation drift: COMMANDS-CHEATSHEET.md was missing data-platform plugin added in v4.0.0
|
||||
- Proactive sprint planning: projman now suggests `/sprint-plan` at session start when unplanned issues exist
|
||||
|
||||
### Known Issues
|
||||
- **MCP Bug #160:** `update_wiki_page` tool renames pages to "unnamed" when page_name contains URL-encoded characters (`:` → `%3A`). Workaround: use `create_wiki_page` to overwrite instead.
|
||||
|
||||
---
|
||||
|
||||
## [4.0.0] - 2026-01-25
|
||||
|
||||
### Added
|
||||
|
||||
#### New Plugin: data-platform v1.0.0
|
||||
- **pandas MCP Tools** (14 tools): DataFrame operations with Arrow IPC data_ref persistence
|
||||
- `read_csv`, `read_parquet`, `read_json` - Load data with chunking support
|
||||
- `to_csv`, `to_parquet` - Export to various formats
|
||||
- `describe`, `head`, `tail` - Data exploration
|
||||
- `filter`, `select`, `groupby`, `join` - Data transformation
|
||||
- `list_data`, `drop_data` - Memory management
|
||||
|
||||
- **PostgreSQL MCP Tools** (10 tools): Database operations with asyncpg connection pooling
|
||||
- `pg_connect`, `pg_query`, `pg_execute` - Core database operations
|
||||
- `pg_tables`, `pg_columns`, `pg_schemas` - Schema exploration
|
||||
- `st_tables`, `st_geometry_type`, `st_srid`, `st_extent` - PostGIS spatial support
|
||||
|
||||
- **dbt MCP Tools** (8 tools): Build tool wrapper with pre-execution validation
|
||||
- `dbt_parse` - Pre-flight validation (catches dbt 1.9+ deprecations)
|
||||
- `dbt_run`, `dbt_test`, `dbt_build` - Execution with auto-validation
|
||||
- `dbt_compile`, `dbt_ls`, `dbt_docs_generate`, `dbt_lineage` - Analysis tools
|
||||
|
||||
- **Commands**: `/ingest`, `/profile`, `/schema`, `/explain`, `/lineage`, `/run`
|
||||
- **Agents**: `data-ingestion` (loading/transformation), `data-analysis` (exploration/profiling)
|
||||
- **SessionStart Hook**: Graceful PostgreSQL connection check (non-blocking warning)
|
||||
|
||||
- **Key Features**:
|
||||
- data_ref system for DataFrame persistence across tool calls
|
||||
- 100k row limit with chunking support for large datasets
|
||||
- Hybrid configuration (system: `~/.config/claude/postgres.env`, project: `.env`)
|
||||
- Auto-detection of dbt projects
|
||||
- Arrow IPC format for efficient memory management
|
||||
|
||||
---
|
||||
|
||||
## [3.2.0] - 2026-01-24
|
||||
|
||||
### Added
|
||||
- **git-flow:** `/commit` now detects protected branches before committing
|
||||
- Warns when on protected branch (main, master, development, staging, production)
|
||||
- Offers to create feature branch automatically instead of committing directly
|
||||
- Configurable via `GIT_PROTECTED_BRANCHES` environment variable
|
||||
- **netbox:** Platform and primary_ip parameters added to device update tools
|
||||
- **claude-config-maintainer:** Auto-enforce mandatory behavior rules via SessionStart hook
|
||||
- **scripts:** `release.sh` - Versioning workflow script for consistent releases
|
||||
- **scripts:** `verify-hooks.sh` - Verify all hooks are command type
|
||||
|
||||
### Changed
|
||||
- **doc-guardian:** Hook switched from `prompt` type to `command` type
|
||||
- Prompt hooks unreliable - Claude ignores explicit instructions
|
||||
- New `notify.sh` bash script guarantees exact output behavior
|
||||
- Only notifies for config file changes (commands/, agents/, skills/, hooks/)
|
||||
- Silent exit for all other files - no blocking possible
|
||||
- **All hooks:** Converted to command type with stricter plugin prefix enforcement
|
||||
- All hooks now mandate `[plugin-name]` prefix with "NO EXCEPTIONS" rule
|
||||
- Simplified output formats with word limits
|
||||
- Consistent structure across projman, pr-review, code-sentinel, doc-guardian
|
||||
- **CLAUDE.md:** Replaced destructive "ALWAYS CLEAR CACHE" rule with "VERIFY AND RESTART"
|
||||
- Cache clearing mid-session breaks MCP tools
|
||||
- Added guidance for proper plugin development workflow
|
||||
|
||||
### Fixed
|
||||
- **cmdb-assistant:** Complete MCP tool schemas for update operations (#138)
|
||||
- **netbox:** Shorten tool names to meet 64-char API limit (#134)
|
||||
- **cmdb-assistant:** Correct NetBox API URL format in setup wizard (#132)
|
||||
- **gitea/projman:** Type safety for `create_label_smart`, curl-based debug-report (#124)
|
||||
- **netbox:** Add diagnostic logging for JSON parse errors (#121)
|
||||
- **labels:** Add duplicate check before creating labels (#116)
|
||||
- **hooks:** Convert ALL hooks to command type with proper prefixes (#114)
|
||||
- Protected branch workflow: Claude no longer commits directly to protected branches (fixes #109)
|
||||
- doc-guardian hook no longer blocks workflow (fixes #110)
|
||||
|
||||
---
|
||||
|
||||
## [3.1.1] - 2026-01-22
|
||||
|
||||
### Added
|
||||
- **git-flow:** `/commit-sync` now prunes stale remote-tracking branches with `git fetch --prune`
|
||||
- **git-flow:** `/commit-sync` detects and reports local branches with deleted upstreams
|
||||
- **git-flow:** `/branch-cleanup` now handles stale branches (upstream gone) separately from merged branches
|
||||
- **git-flow:** New `GIT_CLEANUP_STALE` environment variable for stale branch cleanup control
|
||||
|
||||
### Changed
|
||||
- **All hooks:** Added `[plugin-name]` prefix to all hook messages for better identification
|
||||
- `[projman]`, `[pr-review]`, `[code-sentinel]`, `[doc-guardian]` prefixes
|
||||
- **doc-guardian:** Hook now notification-only (no file reads or blocking operations)
|
||||
- Suggests running `/doc-sync` instead of performing inline checks
|
||||
- Significantly reduces workflow interruption
|
||||
|
||||
### Fixed
|
||||
- doc-guardian hook no longer stalls workflow with deep file analysis
|
||||
|
||||
---
|
||||
|
||||
## [3.1.0] - 2026-01-21
|
||||
|
||||
### Added
|
||||
|
||||
#### Debug Workflow Commands (projman)
|
||||
- **`/debug-report`** - Run diagnostics in test projects, create structured issues in marketplace
|
||||
- Runs 5 diagnostic MCP tool tests with explicit repo parameter
|
||||
- Captures full project context (git remote, cwd, branch)
|
||||
- Generates structured issue with hypothesis and investigation steps
|
||||
- Creates issue in configured marketplace repository automatically
|
||||
|
||||
- **`/debug-review`** - Investigate diagnostic issues with human approval gates
|
||||
- Lists open diagnostic issues for triage
|
||||
- Maps errors to relevant code files using error-to-file mapping
|
||||
- MANDATORY: Reads relevant files before proposing any fix
|
||||
- Three approval gates: investigation summary, fix approach, PR creation
|
||||
- Creates feature branch, commits, and PR with proper linking
|
||||
|
||||
#### MCP Server Improvements
|
||||
- Dynamic label format detection in `suggest_labels`
|
||||
- Supports slash format (`Type/Bug`) and colon-space format (`Type: Bug`)
|
||||
- Fetches actual labels from repo and matches suggestions to real format
|
||||
- Handles Effort/Efforts singular/plural normalization
|
||||
|
||||
### Changed
|
||||
- **`/labels-sync`** completely rewritten with explicit execution steps
|
||||
- Step 1 now explicitly requires running `git remote get-url origin` via Bash
|
||||
- All MCP tool calls show required `repo` parameter
|
||||
- Added "DO NOT" section preventing common mistakes
|
||||
- Removed confusing "Label Reference" section that caused file creation prompts
|
||||
|
||||
### Fixed
|
||||
- MCP tools no longer fail with "Use 'owner/repo' format" error
|
||||
- Root cause: MCP server is sandboxed and cannot auto-detect project directory
|
||||
- Solution: Command documentation now instructs Claude to detect repo via Bash first
|
||||
|
||||
---
|
||||
|
||||
## [3.0.1] - 2026-01-21
|
||||
|
||||
### Added
|
||||
- `/project-init` command for quick project setup when system is already configured
|
||||
- `/project-sync` command to sync .env with git remote after repository move/rename
|
||||
- SessionStart hooks for automatic mismatch detection between git remote and .env
|
||||
- Interactive setup wizard (`/initial-setup`) redesigned to use Claude tools instead of bash script
|
||||
|
||||
### Changed
|
||||
- `GITEA_ORG` moved from system-level to project-level configuration (different projects may belong to different organizations)
|
||||
- Environment variables renamed to match MCP server expectations:
|
||||
- `GITEA_URL` → `GITEA_API_URL` (must include `/api/v1`)
|
||||
- `GITEA_TOKEN` → `GITEA_API_TOKEN`
|
||||
- `NETBOX_URL` → `NETBOX_API_URL` (must include `/api`)
|
||||
- `NETBOX_TOKEN` → `NETBOX_API_TOKEN`
|
||||
- Setup commands now validate repository via Gitea API before saving configuration
|
||||
- README.md simplified to show only wizard setup path (manual setup moved to CONFIGURATION.md)
|
||||
|
||||
### Fixed
|
||||
- API URL paths in curl commands (removed redundant `/api/v1` since it's now in the URL variable)
|
||||
- Documentation now correctly references environment variable names
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0] - 2026-01-20
|
||||
|
||||
### Added
|
||||
|
||||
#### New Plugins
|
||||
- **clarity-assist** v1.0.0 - Prompt optimization with ND accommodations
|
||||
- `/clarify` command for full 4-D methodology optimization
|
||||
- `/quick-clarify` command for rapid single-pass clarification
|
||||
- clarity-coach agent with ND-friendly questioning patterns
|
||||
- prompt-patterns skill with optimization rules
|
||||
|
||||
- **git-flow** v1.0.0 - Git workflow automation
|
||||
- `/commit` command with smart conventional commit messages
|
||||
- `/commit-push`, `/commit-merge`, `/commit-sync` workflow commands
|
||||
- `/branch-start`, `/branch-cleanup` branch management commands
|
||||
- `/git-status` enhanced status with recommendations
|
||||
- `/git-config` interactive configuration
|
||||
- git-assistant agent for complex operations
|
||||
- workflow-patterns skill with branching strategies
|
||||
|
||||
- **pr-review** v1.0.0 - Multi-agent pull request review
|
||||
- `/pr-review` command for comprehensive multi-agent review
|
||||
- `/pr-summary` command for quick PR overview
|
||||
- `/pr-findings` command for filtering review findings
|
||||
- coordinator agent for orchestrating reviews
|
||||
- security-reviewer, performance-analyst, maintainability-auditor, test-validator agents
|
||||
- review-patterns skill with confidence scoring rules
|
||||
|
||||
#### Gitea MCP Server Enhancements
|
||||
- 6 new Pull Request tools:
|
||||
- `list_pull_requests` - List PRs with filters
|
||||
- `get_pull_request` - Get PR details
|
||||
- `get_pr_diff` - Get PR diff
|
||||
- `get_pr_comments` - Get PR comments
|
||||
- `create_pr_review` - Create review (approve, request changes, comment)
|
||||
- `add_pr_comment` - Add comment to PR
|
||||
|
||||
#### Documentation
|
||||
- `docs/CONFIGURATION.md` - Centralized configuration guide for all plugins
|
||||
|
||||
### Changed
|
||||
- **BREAKING:** Marketplace renamed from `claude-code-marketplace` to `leo-claude-mktplace`
|
||||
- **BREAKING:** MCP servers moved from plugin directories to shared `mcp-servers/` at repository root
|
||||
- All plugins now have `category`, `tags`, and `license` fields in marketplace.json
|
||||
- Plugin MCP dependencies now use symlinks to shared servers
|
||||
- projman version bumped to 3.0.0 (includes PR tools integration)
|
||||
- projman CONFIGURATION.md slimmed down, links to central docs
|
||||
|
||||
### Removed
|
||||
- Standalone MCP server directories inside plugins (replaced with symlinks)
|
||||
|
||||
---
|
||||
|
||||
## [2.3.0] - 2026-01-20
|
||||
|
||||
### Added
|
||||
|
||||
#### New Plugins
|
||||
- **doc-guardian** v1.0.0 - Documentation lifecycle management
|
||||
- `/doc-audit` command for full project documentation drift analysis
|
||||
- `/doc-sync` command to batch apply pending documentation updates
|
||||
- PostToolUse hook for automatic drift detection
|
||||
- Stop hook reminder for pending updates
|
||||
- doc-analyzer agent for cross-reference analysis
|
||||
- doc-patterns skill for documentation structure knowledge
|
||||
|
||||
- **code-sentinel** v1.0.0 - Security scanning and refactoring
|
||||
- `/security-scan` command for comprehensive security audit
|
||||
- `/refactor` command to apply refactoring patterns
|
||||
- `/refactor-dry` command to preview refactoring opportunities
|
||||
- PreToolUse hook for real-time security scanning
|
||||
- security-reviewer agent for vulnerability analysis
|
||||
- refactor-advisor agent for code structure improvements
|
||||
- security-patterns skill for vulnerability detection rules
|
||||
|
||||
#### projman Enhancements
|
||||
- `/test-gen` command - Generate unit, integration, and e2e tests for specified code
|
||||
|
||||
### Changed
|
||||
- Marketplace version bumped to 2.3.0
|
||||
- projman version bumped to 2.3.0
|
||||
|
||||
## [2.2.0] - 2026-01-20
|
||||
|
||||
### Added
|
||||
@@ -15,18 +462,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `metadata` wrapper for description/version in marketplace.json
|
||||
- Keywords to all plugin manifests for better discoverability
|
||||
- `commands` and `agents` directory references to plugin manifests
|
||||
- Versioning rule: version displayed only in main README.md title
|
||||
|
||||
### Changed
|
||||
- Updated marketplace.json with required fields per Claude Code spec
|
||||
- Fixed installation documentation to use official Claude Code methods
|
||||
- Prioritized public HTTPS URL over Tailscale SSH URL in documentation
|
||||
- Updated all plugin manifests with author, homepage, repository, license fields
|
||||
- Consolidated version display to main README.md title only
|
||||
- Removed version numbers from plugin documentation titles
|
||||
|
||||
### Fixed
|
||||
- Plugin manifests now include all required fields per Claude Code spec
|
||||
- Installation section uses `extraKnownMarketplaces` instead of undocumented `pluginMarketplace`
|
||||
|
||||
## [2.1.0] - Previous Release
|
||||
### Removed
|
||||
- `docs/references/` directory (obsolete planning documents)
|
||||
- Version numbers from individual plugin README titles
|
||||
- Version section from plugins/projman/README.md
|
||||
|
||||
## [2.1.0] - 2026-01-15
|
||||
|
||||
### Added
|
||||
- `docs/CANONICAL-PATHS.md` - Single source of truth for all file paths
|
||||
@@ -37,16 +492,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Update documentation (`docs/UPDATING.md`)
|
||||
- `/initial-setup` slash command
|
||||
- File creation governance rules in CLAUDE.md
|
||||
- Architecture diagram specifications in `docs/architecture/`
|
||||
- `.scratch/` directory for transient work
|
||||
- `scripts/` directory for setup automation
|
||||
- `docs/architecture/` for Draw.io diagrams
|
||||
- `docs/workflows/` for workflow documentation
|
||||
|
||||
### Changed
|
||||
- Replaced `docs/CORRECT-ARCHITECTURE.md` reference with `docs/CANONICAL-PATHS.md`
|
||||
- Added mandatory path verification section to CLAUDE.md
|
||||
- Reorganized documentation into `docs/references/`, `docs/architecture/`, `docs/workflows/`
|
||||
- Updated CLAUDE.md with file creation governance
|
||||
|
||||
### Fixed
|
||||
@@ -54,21 +505,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
### Removed
|
||||
- Organization/workspace GID variable (no longer needed)
|
||||
- Deprecated `cmdb-assistant/` plugin
|
||||
- Development output files (test scripts, status reports)
|
||||
- IDE-specific workspace files
|
||||
- Stray files from project root
|
||||
|
||||
## [0.1.0] - Initial Release
|
||||
## [2.0.0] - 2026-01-06
|
||||
|
||||
### Added
|
||||
- projman plugin for sprint management
|
||||
- Full Gitea integration with wiki, milestones, dependencies
|
||||
- Parallel execution batching via dependency graph
|
||||
- Wiki tools for lessons learned (`create_lesson`, `search_lessons`)
|
||||
- Milestone tools (`list_milestones`, `create_milestone`, `update_milestone`)
|
||||
- Dependency tools (`list_issue_dependencies`, `create_issue_dependency`, `get_execution_order`)
|
||||
- Validation tools (`validate_repo_org`, `get_branch_protection`)
|
||||
- MCP servers bundled inside plugins (not shared at root)
|
||||
|
||||
### Changed
|
||||
- MCP server architecture: bundled in plugins instead of shared at root
|
||||
- Configuration uses `${CLAUDE_PLUGIN_ROOT}/mcp-servers/` paths
|
||||
|
||||
## [1.0.0] - 2025-12-15
|
||||
|
||||
### Added
|
||||
- projman plugin with basic sprint commands
|
||||
- `/sprint-plan`, `/sprint-start`, `/sprint-status`, `/sprint-close` commands
|
||||
- `/labels-sync` command for label taxonomy synchronization
|
||||
- Three-agent model (planner, orchestrator, executor)
|
||||
- Gitea MCP server with issue and label tools
|
||||
- 43-label taxonomy system
|
||||
- Hybrid configuration system (system + project level)
|
||||
- Branch-aware security model
|
||||
|
||||
## [0.1.0] - 2025-12-01
|
||||
|
||||
### Added
|
||||
- Initial repository structure
|
||||
- projman plugin structure (planned)
|
||||
- projman-pmo plugin structure (planned)
|
||||
- project-hygiene plugin for cleanup automation
|
||||
- Gitea MCP server
|
||||
- Wiki.js MCP server
|
||||
- 43-label taxonomy system
|
||||
- Lessons learned capture system
|
||||
- Hybrid configuration system (system + project level)
|
||||
- Three-agent model (planner, orchestrator, executor)
|
||||
- Branch-aware security model
|
||||
- claude-config-maintainer plugin structure
|
||||
- cmdb-assistant plugin structure
|
||||
- Basic marketplace manifest
|
||||
|
||||
781
CLAUDE.md
781
CLAUDE.md
@@ -1,478 +1,379 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to Claude Code when working with code in this repository.
|
||||
## ⛔ MANDATORY BEHAVIOR RULES - READ FIRST
|
||||
|
||||
**These rules are NON-NEGOTIABLE. Violating them wastes the user's time and money.**
|
||||
|
||||
### 1. WHEN USER ASKS YOU TO CHECK SOMETHING - CHECK EVERYTHING
|
||||
- Search ALL locations, not just where you think it is
|
||||
- Check cache directories: `~/.claude/plugins/cache/`
|
||||
- Check installed: `~/.claude/plugins/marketplaces/`
|
||||
- Check source: `~/claude-plugins-work/`
|
||||
- **NEVER say "no" or "that's not the issue" without exhaustive verification**
|
||||
|
||||
### 2. WHEN USER SAYS SOMETHING IS WRONG - BELIEVE THEM
|
||||
- The user knows their system better than you
|
||||
- Investigate thoroughly before disagreeing
|
||||
- If user suspects cache, CHECK THE CACHE
|
||||
- If user suspects a file, READ THE FILE
|
||||
- **Your confidence is often wrong. User's instincts are often right.**
|
||||
|
||||
### 3. NEVER SAY "DONE" WITHOUT VERIFICATION
|
||||
- Run the actual command/script to verify
|
||||
- Show the output to the user
|
||||
- Check ALL affected locations
|
||||
- **"Done" means VERIFIED WORKING, not "I made changes"**
|
||||
|
||||
### 4. SHOW EXACTLY WHAT USER ASKS FOR
|
||||
- If user asks for messages, show the MESSAGES
|
||||
- If user asks for code, show the CODE
|
||||
- If user asks for output, show the OUTPUT
|
||||
- **Don't interpret or summarize unless asked**
|
||||
|
||||
### 5. AFTER PLUGIN UPDATES - VERIFY AND RESTART
|
||||
|
||||
**⚠️ DO NOT clear cache mid-session** - this breaks MCP tools that are already loaded.
|
||||
|
||||
1. Run `./scripts/verify-hooks.sh` to check hook types
|
||||
2. If changes affect MCP servers or hooks, inform the user:
|
||||
> "Plugin changes require a session restart to take effect. Please restart Claude Code."
|
||||
3. Cache clearing is ONLY safe **before** starting a new session (not during)
|
||||
|
||||
See `docs/DEBUGGING-CHECKLIST.md` for details on cache timing.
|
||||
|
||||
**FAILURE TO FOLLOW THESE RULES = WASTED USER TIME = UNACCEPTABLE**
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Project Overview
|
||||
|
||||
This repository contains Claude Code plugins for project management:
|
||||
**Repository:** leo-claude-mktplace
|
||||
**Version:** 5.1.0
|
||||
**Status:** Production Ready
|
||||
|
||||
1. **`projman`** - Single-repository project management plugin with Gitea integration
|
||||
2. **`projman-pmo`** - Multi-project PMO coordination plugin
|
||||
3. **`claude-config-maintainer`** - CLAUDE.md optimization and maintenance plugin
|
||||
4. **`cmdb-assistant`** - NetBox CMDB integration for infrastructure management
|
||||
A plugin marketplace for Claude Code containing:
|
||||
|
||||
These plugins transform a proven 15-sprint workflow into reusable, distributable tools for managing software development with Claude Code, Gitea, and agile methodologies.
|
||||
| Plugin | Description | Version |
|
||||
|--------|-------------|---------|
|
||||
| `projman` | Sprint planning and project management with Gitea integration | 3.2.0 |
|
||||
| `git-flow` | Git workflow automation with smart commits and branch management | 1.0.0 |
|
||||
| `pr-review` | Multi-agent PR review with confidence scoring | 1.0.0 |
|
||||
| `clarity-assist` | Prompt optimization with ND-friendly accommodations | 1.0.0 |
|
||||
| `doc-guardian` | Automatic documentation drift detection and synchronization | 1.0.0 |
|
||||
| `code-sentinel` | Security scanning and code refactoring tools | 1.0.0 |
|
||||
| `claude-config-maintainer` | CLAUDE.md optimization and maintenance | 1.0.0 |
|
||||
| `cmdb-assistant` | NetBox CMDB integration for infrastructure management | 1.0.0 |
|
||||
| `data-platform` | pandas, PostgreSQL, and dbt integration for data engineering | 1.0.0 |
|
||||
| `viz-platform` | DMC validation, Plotly charts, and theming for dashboards | 1.0.0 |
|
||||
| `contract-validator` | Cross-plugin compatibility validation and agent verification | 1.0.0 |
|
||||
| `project-hygiene` | Post-task cleanup automation via hooks | 0.1.0 |
|
||||
|
||||
**Status:** projman v1.0.0 complete with full Gitea integration
|
||||
## Quick Start
|
||||
|
||||
## File Creation Governance
|
||||
```bash
|
||||
# Validate marketplace compliance
|
||||
./scripts/validate-marketplace.sh
|
||||
|
||||
### Allowed Root Files
|
||||
|
||||
Only these files may exist at the repository root:
|
||||
|
||||
- `CLAUDE.md` - This file
|
||||
- `README.md` - Repository overview
|
||||
- `LICENSE` - License file
|
||||
- `CHANGELOG.md` - Version history
|
||||
- `.gitignore` - Git ignore rules
|
||||
- `.env.example` - Environment template (if needed)
|
||||
|
||||
### Allowed Root Directories
|
||||
|
||||
Only these directories may exist at the repository root:
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `.claude/` | Claude Code local settings |
|
||||
| `.claude-plugin/` | Marketplace manifest |
|
||||
| `.claude-plugins/` | Local marketplace definitions |
|
||||
| `.scratch/` | Transient work (auto-cleaned) |
|
||||
| `docs/` | Documentation |
|
||||
| `hooks/` | Shared hooks (if any) |
|
||||
| `plugins/` | All plugins (projman, projman-pmo, project-hygiene, cmdb-assistant, claude-config-maintainer) |
|
||||
| `scripts/` | Setup and maintenance scripts |
|
||||
|
||||
### File Creation Rules
|
||||
|
||||
1. **No new root files** - Do not create files directly in the repository root unless listed above
|
||||
2. **No new root directories** - Do not create top-level directories without explicit approval
|
||||
3. **Transient work goes in `.scratch/`** - Any temporary files, test outputs, or exploratory work must be created in `.scratch/`
|
||||
4. **Clean up after tasks** - Delete files in `.scratch/` when the task is complete
|
||||
5. **Documentation location** - All documentation goes in `docs/` with appropriate subdirectory:
|
||||
- `docs/references/` - Reference specifications and summaries
|
||||
- `docs/architecture/` - Architecture diagrams (Draw.io files)
|
||||
- `docs/workflows/` - Workflow documentation
|
||||
6. **No output files** - Do not leave generated output, logs, or test results outside designated directories
|
||||
|
||||
### Enforcement
|
||||
|
||||
Before creating any file, verify:
|
||||
|
||||
1. Is this file type allowed in the target location?
|
||||
2. If temporary, am I using `.scratch/`?
|
||||
3. If documentation, am I using the correct `docs/` subdirectory?
|
||||
4. Will this file be cleaned up after the task?
|
||||
|
||||
**Violation of these rules creates technical debt and project chaos.**
|
||||
|
||||
## Path Verification (MANDATORY)
|
||||
|
||||
### Before Generating Any Prompt or Creating Any File
|
||||
|
||||
**This is non-negotiable. Failure to follow causes structural damage.**
|
||||
|
||||
1. **READ `docs/CANONICAL-PATHS.md` FIRST**
|
||||
- This file is the single source of truth
|
||||
- Never infer paths from memory or context
|
||||
- Never assume paths based on conversation history
|
||||
|
||||
2. **List All Paths**
|
||||
- Before generating a prompt, list every file path it will create/modify
|
||||
- Show the list to the user
|
||||
|
||||
3. **Verify Each Path**
|
||||
- Check each path against `docs/CANONICAL-PATHS.md`
|
||||
- If a path is not in that file, STOP and ask
|
||||
|
||||
4. **Show Verification**
|
||||
- Present a verification table to user:
|
||||
```
|
||||
| Path | Matches CANONICAL-PATHS.md? |
|
||||
|------|----------------------------|
|
||||
| plugins/projman/... | ✅ Yes |
|
||||
# After updates
|
||||
./scripts/post-update.sh # Rebuild venvs, verify symlinks
|
||||
```
|
||||
|
||||
5. **Get Confirmation**
|
||||
- User must confirm paths are correct before proceeding
|
||||
### Plugin Commands by Category
|
||||
|
||||
### Relative Path Rules
|
||||
| Category | Commands |
|
||||
|----------|----------|
|
||||
| **Setup** | `/initial-setup`, `/project-init`, `/project-sync` |
|
||||
| **Sprint** | `/sprint-plan`, `/sprint-start`, `/sprint-status`, `/sprint-close` |
|
||||
| **Quality** | `/review`, `/test-check`, `/test-gen` |
|
||||
| **Versioning** | `/suggest-version` |
|
||||
| **PR Review** | `/pr-review:initial-setup`, `/pr-review:project-init` |
|
||||
| **Docs** | `/doc-audit`, `/doc-sync` |
|
||||
| **Security** | `/security-scan`, `/refactor`, `/refactor-dry` |
|
||||
| **Config** | `/config-analyze`, `/config-optimize` |
|
||||
| **Data** | `/ingest`, `/profile`, `/schema`, `/explain`, `/lineage`, `/run` |
|
||||
| **Visualization** | `/component`, `/chart`, `/dashboard`, `/theme`, `/theme-new`, `/theme-css` |
|
||||
| **Validation** | `/validate-contracts`, `/check-agent`, `/list-interfaces` |
|
||||
| **Debug** | `/debug-report`, `/debug-review` |
|
||||
|
||||
- Plugin to bundled MCP server: `${CLAUDE_PLUGIN_ROOT}/mcp-servers/{server}`
|
||||
- Marketplace to plugin: `./../../../plugins/{plugin-name}`
|
||||
- **ALWAYS calculate from CANONICAL-PATHS.md, never from memory**
|
||||
|
||||
### Recovery Protocol
|
||||
|
||||
If you suspect paths are wrong:
|
||||
1. Read `docs/CANONICAL-PATHS.md`
|
||||
2. Compare actual structure against documented structure
|
||||
3. Report discrepancies
|
||||
4. Generate corrective prompt if needed
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Three-Agent Model
|
||||
|
||||
The plugins implement a three-agent architecture that mirrors the proven workflow:
|
||||
|
||||
**Planner Agent** (`agents/planner.md`)
|
||||
- Performs architecture analysis and sprint planning
|
||||
- Creates detailed planning documents
|
||||
- Makes architectural decisions
|
||||
- Creates Gitea issues with appropriate labels
|
||||
- Personality: Asks clarifying questions, thinks through edge cases, never rushes
|
||||
|
||||
**Orchestrator Agent** (`agents/orchestrator.md`)
|
||||
- Coordinates sprint execution
|
||||
- Generates lean execution prompts (not full docs)
|
||||
- Tracks progress and updates documentation
|
||||
- Handles Git operations (commit, merge, cleanup)
|
||||
- Manages task dependencies
|
||||
- Personality: Concise, action-oriented, tracks details meticulously
|
||||
|
||||
**Executor Agent** (`agents/executor.md`)
|
||||
- Implements features according to execution prompts
|
||||
- Writes clean, tested code
|
||||
- Follows architectural decisions from planning
|
||||
- Generates completion reports
|
||||
- Personality: Implementation-focused, follows specs precisely
|
||||
|
||||
### MCP Server Integration
|
||||
|
||||
**Gitea MCP Server** (Python) - bundled in projman plugin
|
||||
|
||||
**Issue Tools:**
|
||||
- `list_issues` - Query issues with filters
|
||||
- `get_issue` - Fetch single issue details
|
||||
- `create_issue` - Create new issue with labels
|
||||
- `update_issue` - Modify existing issue
|
||||
- `add_comment` - Add comments to issues
|
||||
- `get_labels` - Fetch org + repo label taxonomy
|
||||
- `suggest_labels` - Analyze context and suggest appropriate labels
|
||||
|
||||
**Milestone Tools:**
|
||||
- `list_milestones` - List sprint milestones
|
||||
- `get_milestone` - Get milestone details
|
||||
- `create_milestone` - Create sprint milestone
|
||||
- `update_milestone` - Update/close milestone
|
||||
|
||||
**Dependency Tools:**
|
||||
- `list_issue_dependencies` - Get issue dependencies
|
||||
- `create_issue_dependency` - Create dependency between issues
|
||||
- `get_execution_order` - Get parallel execution batches
|
||||
|
||||
**Wiki Tools (Gitea Wiki):**
|
||||
- `list_wiki_pages` - List wiki pages
|
||||
- `get_wiki_page` - Fetch specific page content
|
||||
- `create_wiki_page` - Create new wiki page
|
||||
- `create_lesson` - Create lessons learned document
|
||||
- `search_lessons` - Search past lessons by tags
|
||||
|
||||
**Validation Tools:**
|
||||
- `validate_repo_org` - Check repo belongs to organization
|
||||
- `get_branch_protection` - Check branch protection rules
|
||||
- `create_label` - Create missing required labels
|
||||
|
||||
**Key Architecture Points:**
|
||||
- MCP servers are **bundled inside each plugin** at `plugins/{plugin}/mcp-servers/`
|
||||
- This ensures plugins work when cached by Claude Code (only plugin directory is cached)
|
||||
- Configuration uses hybrid approach (system-level + project-level)
|
||||
- All plugins reference `${CLAUDE_PLUGIN_ROOT}/mcp-servers/` in their `.mcp.json` files
|
||||
|
||||
## Branch-Aware Security Model
|
||||
|
||||
Plugin behavior adapts to the current Git branch to prevent accidental changes:
|
||||
|
||||
**Development Mode** (`development`, `feat/*`)
|
||||
- Full access to all operations
|
||||
- Can create Gitea issues
|
||||
- Can modify all files
|
||||
|
||||
**Staging Mode** (`staging`)
|
||||
- Read-only for application code
|
||||
- Can modify `.env` files
|
||||
- Can create issues to document needed fixes
|
||||
- Warns on attempted code changes
|
||||
|
||||
**Production Mode** (`main`)
|
||||
- Read-only for application code
|
||||
- Emergency-only `.env` modifications
|
||||
- Can create incident issues
|
||||
- Blocks code changes
|
||||
|
||||
This behavior is implemented in both CLAUDE.md (file-level) and plugin agents (tool-level).
|
||||
|
||||
## Label Taxonomy System
|
||||
|
||||
The project uses a sophisticated 43-label taxonomy at organization level:
|
||||
|
||||
**Organization Labels (27):**
|
||||
- Agent/2, Complexity/3, Efforts/5, Priority/4, Risk/3, Source/4, Type/6
|
||||
|
||||
**Repository Labels (16):**
|
||||
- Component/9, Tech/7
|
||||
|
||||
**Important Labels:**
|
||||
- `Type/Refactor` - For architectural changes and code restructuring (exclusive Type label)
|
||||
- Used for service extraction, architecture modifications, technical debt
|
||||
|
||||
The label system includes:
|
||||
- `skills/label-taxonomy/labels-reference.md` - Local reference synced from Gitea
|
||||
- Label suggestion logic that detects appropriate labels from context
|
||||
- `/labels-sync` command to review and sync changes from Gitea
|
||||
|
||||
## Lessons Learned System
|
||||
|
||||
**Critical Feature:** After 15 sprints without lesson capture, repeated mistakes occurred (e.g., Claude Code infinite loops on similar issues 2-3 times).
|
||||
|
||||
**Gitea Wiki Structure:**
|
||||
Lessons learned are stored in the Gitea repository's built-in wiki under `lessons-learned/sprints/`.
|
||||
|
||||
**Workflow:**
|
||||
- Orchestrator captures lessons at sprint close via Gitea Wiki MCP tools
|
||||
- Planner searches relevant lessons at sprint start using `search_lessons`
|
||||
- Tags enable cross-project lesson discovery
|
||||
- Focus on preventable repetitions, not every detail
|
||||
- Web interface available through Gitea Wiki
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Build Order
|
||||
|
||||
1. **Phase 1-8:** Build `projman` plugin first (single-repo)
|
||||
2. **Phase 9-11:** Build `pmo` plugin second (multi-project)
|
||||
3. **Phase 12:** Production deployment
|
||||
|
||||
See [docs/reference-material/projman-implementation-plan.md](docs/reference-material/projman-implementation-plan.md) for the complete 12-phase implementation plan.
|
||||
|
||||
### Repository Structure (DEFINITIVE)
|
||||
|
||||
⚠️ **See `docs/CANONICAL-PATHS.md` for the authoritative path reference - THIS IS THE SINGLE SOURCE OF TRUTH**
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
personal-projects/support-claude-mktplace/
|
||||
leo-claude-mktplace/
|
||||
├── .claude-plugin/
|
||||
│ └── marketplace.json
|
||||
├── plugins/ # ← ALL PLUGINS (with bundled MCP servers)
|
||||
│ ├── projman/ # ← PROJECT PLUGIN
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ │ └── plugin.json
|
||||
│ │ ├── .mcp.json # Points to ${CLAUDE_PLUGIN_ROOT}/mcp-servers/
|
||||
│ │ ├── mcp-servers/ # ← MCP servers BUNDLED IN plugin
|
||||
│ │ │ └── gitea/ # Gitea + Wiki tools
|
||||
│ │ │ ├── .venv/
|
||||
│ │ │ ├── requirements.txt
|
||||
│ │ │ ├── mcp_server/
|
||||
│ │ │ └── tests/
|
||||
│ │ ├── commands/
|
||||
│ │ │ ├── sprint-plan.md
|
||||
│ │ │ ├── sprint-start.md
|
||||
│ │ │ ├── sprint-status.md
|
||||
│ │ │ ├── sprint-close.md
|
||||
│ │ │ ├── labels-sync.md
|
||||
│ │ │ └── initial-setup.md
|
||||
│ │ ├── agents/
|
||||
│ │ │ ├── planner.md
|
||||
│ │ │ ├── orchestrator.md
|
||||
│ │ │ └── executor.md
|
||||
│ │ ├── skills/
|
||||
│ │ │ └── label-taxonomy/
|
||||
│ │ │ └── labels-reference.md
|
||||
│ │ ├── README.md
|
||||
│ │ └── CONFIGURATION.md
|
||||
│ ├── projman-pmo/ # ← PMO PLUGIN
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ │ └── plugin.json
|
||||
│ └── marketplace.json # Marketplace manifest
|
||||
├── mcp-servers/ # SHARED MCP servers (v3.0.0+)
|
||||
│ ├── gitea/ # Gitea MCP (issues, PRs, wiki)
|
||||
│ ├── netbox/ # NetBox MCP (CMDB)
|
||||
│ ├── data-platform/ # pandas, PostgreSQL, dbt
|
||||
│ └── viz-platform/ # DMC validation, charts, themes
|
||||
├── plugins/
|
||||
│ ├── projman/ # Sprint management
|
||||
│ │ ├── .claude-plugin/plugin.json
|
||||
│ │ ├── .mcp.json
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ │ └── pmo-coordinator.md
|
||||
│ │ └── README.md
|
||||
│ ├── cmdb-assistant/ # ← CMDB PLUGIN
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ │ └── plugin.json
|
||||
│ │ ├── .mcp.json # Points to ${CLAUDE_PLUGIN_ROOT}/mcp-servers/
|
||||
│ │ ├── mcp-servers/ # ← MCP servers BUNDLED IN plugin
|
||||
│ │ │ └── netbox/
|
||||
│ │ │ ├── .venv/
|
||||
│ │ │ ├── requirements.txt
|
||||
│ │ │ └── mcp_server/
|
||||
│ │ ├── commands/
|
||||
│ │ ├── mcp-servers/gitea -> ../../../mcp-servers/gitea # SYMLINK
|
||||
│ │ ├── commands/ # 14 commands (incl. setup, debug, suggest-version)
|
||||
│ │ ├── hooks/ # SessionStart: mismatch detection + sprint suggestions
|
||||
│ │ ├── agents/ # 4 agents
|
||||
│ │ └── skills/label-taxonomy/
|
||||
│ ├── git-flow/ # Git workflow automation
|
||||
│ │ ├── .claude-plugin/plugin.json
|
||||
│ │ ├── commands/ # 8 commands
|
||||
│ │ └── agents/
|
||||
│ └── project-hygiene/ # ← CLEANUP PLUGIN
|
||||
│ └── ...
|
||||
├── scripts/ # Setup and maintenance scripts
|
||||
│ ├── setup.sh
|
||||
│ └── post-update.sh
|
||||
│ ├── pr-review/ # Multi-agent PR review
|
||||
│ │ ├── .claude-plugin/plugin.json
|
||||
│ │ ├── .mcp.json
|
||||
│ │ ├── mcp-servers/gitea -> ../../../mcp-servers/gitea # SYMLINK
|
||||
│ │ ├── commands/ # 6 commands (incl. setup)
|
||||
│ │ ├── hooks/ # SessionStart mismatch detection
|
||||
│ │ └── agents/ # 5 agents
|
||||
│ ├── clarity-assist/ # Prompt optimization
|
||||
│ │ ├── .claude-plugin/plugin.json
|
||||
│ │ ├── commands/ # 2 commands
|
||||
│ │ └── agents/
|
||||
│ ├── data-platform/ # Data engineering (NEW v4.0.0)
|
||||
│ │ ├── .claude-plugin/plugin.json
|
||||
│ │ ├── .mcp.json
|
||||
│ │ ├── mcp-servers/ # pandas, postgresql, dbt MCPs
|
||||
│ │ ├── commands/ # 7 commands
|
||||
│ │ ├── hooks/ # SessionStart PostgreSQL check
|
||||
│ │ └── agents/ # 2 agents
|
||||
│ ├── viz-platform/ # Visualization (NEW v4.0.0)
|
||||
│ │ ├── .claude-plugin/plugin.json
|
||||
│ │ ├── .mcp.json
|
||||
│ │ ├── mcp-servers/ # viz-platform MCP
|
||||
│ │ ├── commands/ # 7 commands
|
||||
│ │ ├── hooks/ # SessionStart DMC check
|
||||
│ │ └── agents/ # 3 agents
|
||||
│ ├── doc-guardian/ # Documentation drift detection
|
||||
│ ├── code-sentinel/ # Security scanning & refactoring
|
||||
│ ├── claude-config-maintainer/
|
||||
│ ├── cmdb-assistant/
|
||||
│ └── project-hygiene/
|
||||
├── scripts/
|
||||
│ ├── setup.sh, post-update.sh
|
||||
│ ├── validate-marketplace.sh # Marketplace compliance validation
|
||||
│ ├── verify-hooks.sh # Verify all hooks are command type
|
||||
│ └── check-venv.sh # Check MCP server venvs exist
|
||||
└── docs/
|
||||
├── CANONICAL-PATHS.md # Single source of truth for paths
|
||||
└── CONFIGURATION.md # Centralized configuration guide
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
**MCP Servers (Bundled in Plugins):**
|
||||
- **Gitea MCP**: Issues, labels, wiki, milestones, dependencies (bundled in projman)
|
||||
- **NetBox MCP**: Infrastructure management (bundled in cmdb-assistant)
|
||||
- Servers are **bundled inside each plugin** that needs them
|
||||
- This ensures plugins work when cached by Claude Code
|
||||
|
||||
**Python Implementation:**
|
||||
- Python chosen over Node.js for MCP servers
|
||||
- Better suited for configuration management and modular code
|
||||
- Easier to maintain and extend
|
||||
- Virtual environment (.venv) per MCP server
|
||||
|
||||
**Hybrid Configuration:**
|
||||
- **System-level**: `~/.config/claude/gitea.env` (credentials)
|
||||
- **Project-level**: `project-root/.env` (repository specification)
|
||||
- Merge strategy: project overrides system
|
||||
- Benefits: Single token per service, easy multi-project setup
|
||||
|
||||
**Skills as Knowledge, Not Orchestrators:**
|
||||
- Skills provide supporting knowledge loaded when relevant
|
||||
- Agents are the primary interface
|
||||
- Reduces token usage
|
||||
- Makes knowledge reusable across agents
|
||||
|
||||
**Branch Detection:**
|
||||
- Two layers: CLAUDE.md (file access) + Plugin agents (tool usage)
|
||||
- Defense in depth approach
|
||||
- Plugin works with or without CLAUDE.md
|
||||
|
||||
## Multi-Project Context (PMO Plugin)
|
||||
|
||||
The `projman-pmo` plugin coordinates interdependent projects across an organization. Example use cases:
|
||||
- Main product repository
|
||||
- Marketing/documentation sites
|
||||
- Extracted services
|
||||
- Supporting tools
|
||||
|
||||
PMO plugin adds:
|
||||
- Cross-project issue aggregation (all repos in organization)
|
||||
- Dependency tracking and visualization
|
||||
- Resource allocation across projects
|
||||
- Deployment coordination
|
||||
- Multi-project prioritization
|
||||
- Company-wide lessons learned search
|
||||
|
||||
**Configuration Difference:**
|
||||
- PMO operates at company level (no `GITEA_REPO`)
|
||||
- Accesses all repositories in organization
|
||||
- Aggregates issues and lessons across projects
|
||||
|
||||
Build PMO plugin AFTER projman is working and validated.
|
||||
|
||||
## Testing Approach
|
||||
|
||||
**Local Marketplace:**
|
||||
Create local marketplace for plugin development:
|
||||
```
|
||||
~/projman-dev-marketplace/
|
||||
├── .claude-plugin/
|
||||
│ └── marketplace.json
|
||||
└── projman/ # Symlink to plugin directory
|
||||
```
|
||||
|
||||
**Integration Testing:**
|
||||
Test in a real repository with actual Gitea instance before distribution.
|
||||
|
||||
**Success Metrics:**
|
||||
- Sprint planning time reduced 40%
|
||||
- Manual steps eliminated: 10+ per sprint
|
||||
- Lessons learned capture rate: 100% (vs 0% before)
|
||||
- Label accuracy on issues: 90%+
|
||||
- User satisfaction: Better than current manual workflow
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Never modify docker-compose files with 'version' attribute** - It's obsolete
|
||||
- **Focus on implementation, not over-engineering** - This system has been validated over 15 sprints
|
||||
- **Lessons learned is critical** - Prevents repeated mistakes (e.g., Claude infinite loops)
|
||||
- **Type/Refactor label** - Newly implemented at org level for architectural work
|
||||
- **Branch detection must be 100% reliable** - Prevents production accidents
|
||||
- **Python for MCP servers** - Use Python 3.8+ with virtual environments
|
||||
- **CLI tools forbidden** - Use MCP tools exclusively, never CLI tools like `tea` or `gh`
|
||||
|
||||
## CRITICAL: Rules You MUST Follow
|
||||
|
||||
### DO NOT MODIFY .gitignore Without Explicit Permission
|
||||
- This is a **private repository** - credentials in `.env` files are intentional
|
||||
- **NEVER** add `.env` or `.env.*` to .gitignore
|
||||
- **NEVER** add venv patterns unless explicitly asked
|
||||
- If you think something should be ignored, ASK FIRST
|
||||
### File Operations
|
||||
- **NEVER** create files in repository root unless listed in "Allowed Root Files"
|
||||
- **NEVER** modify `.gitignore` without explicit permission
|
||||
- **ALWAYS** use `.scratch/` for temporary/exploratory work
|
||||
- **ALWAYS** verify paths against `docs/CANONICAL-PATHS.md` before creating files
|
||||
|
||||
### Plugin Structure Requirements
|
||||
- **plugin.json MUST be in `.claude-plugin/` directory** - NOT in plugin root
|
||||
- Every plugin in the repo MUST be listed in the marketplace.json
|
||||
- After creating/modifying a plugin, VERIFY it's in the marketplace
|
||||
### Plugin Development
|
||||
- **plugin.json MUST be in `.claude-plugin/` directory** (not plugin root)
|
||||
- **Every plugin MUST be listed in marketplace.json**
|
||||
- **MCP servers are SHARED at root** with symlinks from plugins
|
||||
- **MCP server venv path**: `${CLAUDE_PLUGIN_ROOT}/mcp-servers/{name}/.venv/bin/python`
|
||||
- **CLI tools forbidden** - Use MCP tools exclusively (never `tea`, `gh`, etc.)
|
||||
|
||||
### Hooks Syntax (Claude Code Official)
|
||||
- **Valid events**: `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `SessionEnd`, `Notification`, `Stop`, `SubagentStop`, `PreCompact`
|
||||
- **INVALID events**: `task-completed`, `file-changed`, `git-commit-msg-needed` (these DO NOT exist)
|
||||
- Hooks schema:
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"EventName": [
|
||||
{
|
||||
"matcher": "optional-pattern",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/path/to/script.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
#### ⚠️ plugin.json Format Rules (CRITICAL)
|
||||
- **Hooks in separate file** - Use `hooks/hooks.json` (auto-discovered), NOT inline in plugin.json
|
||||
- **NEVER reference hooks** - Don't add `"hooks": "..."` field to plugin.json at all
|
||||
- **Agents auto-discover** - NEVER add `"agents": ["./agents/"]` - .md files found automatically
|
||||
- **Always validate** - Run `./scripts/validate-marketplace.sh` before committing
|
||||
- **Working examples:** projman, pr-review, claude-config-maintainer all use `hooks/hooks.json`
|
||||
- See lesson: `lessons/patterns/plugin-manifest-validation---hooks-and-agents-format-requirements`
|
||||
|
||||
### Hooks (Valid Events Only)
|
||||
`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `SessionEnd`, `Notification`, `Stop`, `SubagentStop`, `PreCompact`
|
||||
|
||||
**INVALID:** `task-completed`, `file-changed`, `git-commit-msg-needed`
|
||||
|
||||
### Allowed Root Files
|
||||
`CLAUDE.md`, `README.md`, `LICENSE`, `CHANGELOG.md`, `.gitignore`, `.env.example`
|
||||
|
||||
### Allowed Root Directories
|
||||
`.claude/`, `.claude-plugin/`, `.claude-plugins/`, `.scratch/`, `docs/`, `hooks/`, `mcp-servers/`, `plugins/`, `scripts/`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Four-Agent Model (projman)
|
||||
|
||||
| Agent | Personality | Responsibilities |
|
||||
|-------|-------------|------------------|
|
||||
| **Planner** | Thoughtful, methodical | Sprint planning, architecture analysis, issue creation, lesson search |
|
||||
| **Orchestrator** | Concise, action-oriented | Sprint execution, parallel batching, Git operations, lesson capture |
|
||||
| **Executor** | Implementation-focused | Code implementation, branch management, MR creation |
|
||||
| **Code Reviewer** | Thorough, practical | Pre-close quality review, security scan, test verification |
|
||||
|
||||
### MCP Server Tools (Gitea)
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `add_comment`, `aggregate_issues` |
|
||||
| Labels | `get_labels`, `suggest_labels`, `create_label`, `create_label_smart` |
|
||||
| Milestones | `list_milestones`, `get_milestone`, `create_milestone`, `update_milestone`, `delete_milestone` |
|
||||
| Dependencies | `list_issue_dependencies`, `create_issue_dependency`, `remove_issue_dependency`, `get_execution_order` |
|
||||
| Wiki | `list_wiki_pages`, `get_wiki_page`, `create_wiki_page`, `update_wiki_page`, `create_lesson`, `search_lessons` |
|
||||
| **Pull Requests** | `list_pull_requests`, `get_pull_request`, `get_pr_diff`, `get_pr_comments`, `create_pr_review`, `add_pr_comment` |
|
||||
| Validation | `validate_repo_org`, `get_branch_protection` |
|
||||
|
||||
### Hybrid Configuration
|
||||
|
||||
| Level | Location | Purpose |
|
||||
|-------|----------|---------|
|
||||
| System | `~/.config/claude/gitea.env` | Credentials (GITEA_API_URL, GITEA_API_TOKEN) |
|
||||
| Project | `.env` in project root | Repository specification (GITEA_ORG, GITEA_REPO) |
|
||||
|
||||
**Note:** `GITEA_ORG` is at project level since different projects may belong to different organizations.
|
||||
|
||||
### Branch-Aware Security
|
||||
|
||||
| Branch Pattern | Mode | Capabilities |
|
||||
|----------------|------|--------------|
|
||||
| `development`, `feat/*` | Development | Full access |
|
||||
| `staging` | Staging | Read-only code, can create issues |
|
||||
| `main`, `master` | Production | Read-only, emergency only |
|
||||
|
||||
## Label Taxonomy
|
||||
|
||||
43 labels total: 27 organization + 16 repository
|
||||
|
||||
**Organization:** Agent/2, Complexity/3, Efforts/5, Priority/4, Risk/3, Source/4, Type/6
|
||||
**Repository:** Component/9, Tech/7
|
||||
|
||||
Sync with `/labels-sync` command.
|
||||
|
||||
## Lessons Learned System
|
||||
|
||||
Stored in Gitea Wiki under `lessons-learned/sprints/`.
|
||||
|
||||
**Workflow:**
|
||||
1. Orchestrator captures at sprint close via MCP tools
|
||||
2. Planner searches at sprint start using `search_lessons`
|
||||
3. Tags enable cross-project discovery
|
||||
|
||||
## Common Operations
|
||||
|
||||
### Adding a New Plugin
|
||||
|
||||
1. Create `plugins/{name}/.claude-plugin/plugin.json`
|
||||
2. Add entry to `.claude-plugin/marketplace.json` with category, tags, license
|
||||
3. Create `README.md` and `claude-md-integration.md`
|
||||
4. If using MCP server, create symlink: `ln -s ../../../mcp-servers/{server} plugins/{name}/mcp-servers/{server}`
|
||||
5. Run `./scripts/validate-marketplace.sh`
|
||||
6. Update `CHANGELOG.md`
|
||||
|
||||
### Adding a Command to projman
|
||||
|
||||
1. Create `plugins/projman/commands/{name}.md`
|
||||
2. Update `plugins/projman/README.md`
|
||||
3. Update marketplace description if significant
|
||||
|
||||
### Validation
|
||||
|
||||
```bash
|
||||
./scripts/validate-marketplace.sh # Validates all manifests
|
||||
```
|
||||
|
||||
### MCP Server Configuration
|
||||
- MCP servers MUST use venv python: `${CLAUDE_PLUGIN_ROOT}/../../mcp-servers/NAME/.venv/bin/python`
|
||||
- NEVER use bare `python` command - always use venv path
|
||||
- Test MCP servers after any config change
|
||||
## Path Verification Protocol
|
||||
|
||||
### Before Completing Any Plugin Work
|
||||
1. Verify plugin.json is in `.claude-plugin/` directory
|
||||
2. Verify plugin is listed in marketplace.json
|
||||
3. Test MCP server configs load correctly
|
||||
4. Verify hooks use valid event types
|
||||
5. Check .gitignore wasn't modified inappropriately
|
||||
**Before creating any file:**
|
||||
|
||||
1. Read `docs/CANONICAL-PATHS.md`
|
||||
2. List all paths to be created/modified
|
||||
3. Verify each against canonical paths
|
||||
4. If not in canonical paths, STOP and ask
|
||||
|
||||
## Documentation Index
|
||||
|
||||
This repository contains comprehensive planning documentation:
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| `docs/CANONICAL-PATHS.md` | **Single source of truth** for paths |
|
||||
| `docs/COMMANDS-CHEATSHEET.md` | All commands quick reference |
|
||||
| `docs/CONFIGURATION.md` | Centralized setup guide |
|
||||
| `docs/DEBUGGING-CHECKLIST.md` | Systematic troubleshooting guide |
|
||||
| `docs/UPDATING.md` | Update guide for the marketplace |
|
||||
| `plugins/projman/CONFIGURATION.md` | Projman quick reference (links to central) |
|
||||
| `plugins/projman/README.md` | Projman full documentation |
|
||||
|
||||
- **`docs/CANONICAL-PATHS.md`** - ⚠️ SINGLE SOURCE OF TRUTH for all paths (MANDATORY reading before any file operations)
|
||||
- **`docs/DOCUMENT-INDEX.md`** - Complete guide to all planning documents
|
||||
- **`docs/projman-implementation-plan-updated.md`** - Full 12-phase implementation plan
|
||||
- **`docs/projman-python-quickstart.md`** - Python-specific implementation guide
|
||||
- **`docs/two-mcp-architecture-guide.md`** - Deep dive into two-MCP architecture
|
||||
## Installation Paths
|
||||
|
||||
**Start with:** `docs/DOCUMENT-INDEX.md` for navigation guidance
|
||||
Understanding where files live is critical for debugging:
|
||||
|
||||
## Recent Updates (Updated: 2025-06-11)
|
||||
| Context | Path | Purpose |
|
||||
|---------|------|---------|
|
||||
| **Source** | `~/claude-plugins-work/` | Development - edit here |
|
||||
| **Installed** | `~/.claude/plugins/marketplaces/leo-claude-mktplace/` | Runtime - Claude uses this |
|
||||
| **Cache** | `~/.claude/` | Plugin metadata and settings |
|
||||
|
||||
### Planning Phase Complete
|
||||
- Comprehensive 12-phase implementation plan finalized
|
||||
- Architecture decisions documented and validated
|
||||
- Two-MCP-server approach confirmed (Gitea + Wiki.js)
|
||||
- Python selected for MCP server implementation
|
||||
- Hybrid configuration strategy defined (system + project level)
|
||||
- Wiki.js structure planned with configurable base path
|
||||
- Repository structure designed with shared MCP servers
|
||||
**Key insight:** Edits to source require reinstall/update to take effect at runtime.
|
||||
|
||||
### Key Architectural Decisions Made
|
||||
1. **Shared MCP Servers**: Both plugins use the same MCP codebase at `mcp-servers/`
|
||||
2. **Mode Detection**: MCP servers detect project vs company-wide mode via environment variables
|
||||
3. **Python Implementation**: MCP servers written in Python (not Node.js) for better configuration handling
|
||||
4. **Wiki.js Integration**: Lessons learned and documentation moved to Wiki.js for better collaboration
|
||||
5. **Hybrid Config**: System-level credentials + project-level paths for flexibility
|
||||
## Debugging & Troubleshooting
|
||||
|
||||
### Next Steps
|
||||
- Begin Phase 1.1a: Gitea MCP Server implementation
|
||||
- Set up Python virtual environments
|
||||
- Create configuration loaders
|
||||
- Implement core Gitea tools (issues, labels)
|
||||
- Write integration tests
|
||||
See `docs/DEBUGGING-CHECKLIST.md` for systematic troubleshooting.
|
||||
|
||||
**Common Issues:**
|
||||
| Symptom | Likely Cause | Fix |
|
||||
|---------|--------------|-----|
|
||||
| "X MCP servers failed" | Missing venv in installed path | `cd ~/.claude/plugins/marketplaces/leo-claude-mktplace && ./scripts/setup.sh` |
|
||||
| MCP tools not available | Symlink broken or venv missing | Run `/debug-report` to diagnose |
|
||||
| Changes not taking effect | Editing source, not installed | Reinstall plugin or edit installed path |
|
||||
|
||||
**Debug Commands:**
|
||||
- `/debug-report` - Run full diagnostics, create issue if needed
|
||||
- `/debug-review` - Investigate and propose fixes
|
||||
|
||||
## Versioning Workflow
|
||||
|
||||
This project follows [SemVer](https://semver.org/) and [Keep a Changelog](https://keepachangelog.com).
|
||||
|
||||
### Version Locations (must stay in sync)
|
||||
|
||||
| Location | Format | Example |
|
||||
|----------|--------|---------|
|
||||
| Git tags | `vX.Y.Z` | `v3.2.0` |
|
||||
| README.md title | `# Leo Claude Marketplace - vX.Y.Z` | `v3.2.0` |
|
||||
| marketplace.json | `"version": "X.Y.Z"` | `3.2.0` |
|
||||
| CHANGELOG.md | `## [X.Y.Z] - YYYY-MM-DD` | `[3.2.0] - 2026-01-24` |
|
||||
|
||||
### During Development
|
||||
|
||||
**All changes go under `[Unreleased]` in CHANGELOG.md.** Never create a versioned section until release time.
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- New feature description
|
||||
|
||||
### Fixed
|
||||
- Bug fix description
|
||||
```
|
||||
|
||||
### Creating a Release
|
||||
|
||||
Use the release script to ensure consistency:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh 3.2.0
|
||||
```
|
||||
|
||||
The script will:
|
||||
1. Validate `[Unreleased]` section has content
|
||||
2. Replace `[Unreleased]` with `[3.2.0] - YYYY-MM-DD`
|
||||
3. Update README.md title
|
||||
4. Update marketplace.json version
|
||||
5. Commit and create git tag
|
||||
|
||||
### SemVer Guidelines
|
||||
|
||||
| Change Type | Version Bump | Example |
|
||||
|-------------|--------------|---------|
|
||||
| Bug fixes only | PATCH (x.y.**Z**) | 3.1.1 → 3.1.2 |
|
||||
| New features (backwards compatible) | MINOR (x.**Y**.0) | 3.1.2 → 3.2.0 |
|
||||
| Breaking changes | MAJOR (**X**.0.0) | 3.2.0 → 4.0.0 |
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-01-24
|
||||
|
||||
369
README.md
369
README.md
@@ -1,15 +1,17 @@
|
||||
# Claude Code Marketplace
|
||||
# Leo Claude Marketplace - v5.2.0
|
||||
|
||||
A collection of Claude Code plugins for project management, infrastructure automation, and development workflows.
|
||||
|
||||
## Plugins
|
||||
|
||||
### [projman](./plugins/projman/README.md) v2.2.0
|
||||
### Development & Project Management
|
||||
|
||||
#### [projman](./plugins/projman/README.md)
|
||||
**Sprint Planning and Project Management**
|
||||
|
||||
AI-guided sprint planning with full Gitea integration. Transforms a proven 15-sprint workflow into a distributable plugin.
|
||||
|
||||
- Three-agent model: Planner, Orchestrator, Executor, Code Reviewer
|
||||
- Four-agent model: Planner, Orchestrator, Executor, Code Reviewer
|
||||
- Intelligent label suggestions from 43-label taxonomy
|
||||
- Lessons learned capture via Gitea Wiki
|
||||
- Native issue dependencies with parallel execution
|
||||
@@ -17,60 +19,146 @@ AI-guided sprint planning with full Gitea integration. Transforms a proven 15-sp
|
||||
- Branch-aware security (development/staging/production)
|
||||
- Pre-sprint-close code quality review and test verification
|
||||
|
||||
**Commands:** `/sprint-plan`, `/sprint-start`, `/sprint-status`, `/sprint-close`, `/labels-sync`, `/initial-setup`, `/review`, `/test-check`
|
||||
**Commands:** `/sprint-plan`, `/sprint-start`, `/sprint-status`, `/sprint-close`, `/labels-sync`, `/initial-setup`, `/project-init`, `/project-sync`, `/review`, `/test-check`, `/test-gen`, `/debug-report`, `/debug-review`, `/suggest-version`, `/proposal-status`
|
||||
|
||||
### [claude-config-maintainer](./plugins/claude-config-maintainer/README.md)
|
||||
#### [git-flow](./plugins/git-flow/README.md) *NEW in v3.0.0*
|
||||
**Git Workflow Automation**
|
||||
|
||||
Smart git operations with intelligent commit messages and branch management.
|
||||
|
||||
- Auto-generated conventional commit messages
|
||||
- Multiple workflow styles (simple, feature-branch, pr-required, trunk-based)
|
||||
- Branch naming enforcement
|
||||
- Merge and cleanup automation
|
||||
- Protected branch awareness
|
||||
|
||||
**Commands:** `/commit`, `/commit-push`, `/commit-merge`, `/commit-sync`, `/branch-start`, `/branch-cleanup`, `/git-status`, `/git-config`
|
||||
|
||||
#### [pr-review](./plugins/pr-review/README.md) *NEW in v3.0.0*
|
||||
**Multi-Agent PR Review**
|
||||
|
||||
Comprehensive pull request review using specialized agents.
|
||||
|
||||
- Multi-agent review: Security, Performance, Maintainability, Tests
|
||||
- Confidence scoring (only reports HIGH/MEDIUM confidence findings)
|
||||
- Actionable feedback with suggested fixes
|
||||
- Gitea integration for automated review submission
|
||||
|
||||
**Commands:** `/pr-review`, `/pr-summary`, `/pr-findings`, `/initial-setup`, `/project-init`, `/project-sync`
|
||||
|
||||
#### [claude-config-maintainer](./plugins/claude-config-maintainer/README.md)
|
||||
**CLAUDE.md Optimization and Maintenance**
|
||||
|
||||
Analyze, optimize, and create CLAUDE.md configuration files for Claude Code projects.
|
||||
|
||||
- Structure and clarity scoring (100-point system)
|
||||
- Automatic optimization with preview and backup
|
||||
- Project-aware initialization with stack detection
|
||||
- Best practices enforcement
|
||||
|
||||
**Commands:** `/config-analyze`, `/config-optimize`, `/config-init`
|
||||
|
||||
### [cmdb-assistant](./plugins/cmdb-assistant/README.md)
|
||||
**NetBox CMDB Integration**
|
||||
#### [contract-validator](./plugins/contract-validator/README.md) *NEW in v5.0.0*
|
||||
**Cross-Plugin Compatibility Validation**
|
||||
|
||||
Full CRUD operations for network infrastructure management directly from Claude Code.
|
||||
Validate plugin marketplaces for command conflicts, tool overlaps, and broken agent references.
|
||||
|
||||
- Device, IP, site, and rack management
|
||||
- Smart search across all NetBox modules
|
||||
- Conversational infrastructure queries
|
||||
- Audit trail and change tracking
|
||||
- Interface parsing from plugin README.md files
|
||||
- Agent extraction from CLAUDE.md definitions
|
||||
- Pairwise compatibility checks between all plugins
|
||||
- Data flow validation for agent sequences
|
||||
- Markdown or JSON reports with actionable suggestions
|
||||
|
||||
**Commands:** `/cmdb-search`, `/cmdb-device`, `/cmdb-ip`, `/cmdb-site`
|
||||
**Commands:** `/validate-contracts`, `/check-agent`, `/list-interfaces`, `/initial-setup`
|
||||
|
||||
### [project-hygiene](./plugins/project-hygiene/README.md)
|
||||
### Productivity
|
||||
|
||||
#### [clarity-assist](./plugins/clarity-assist/README.md) *NEW in v3.0.0*
|
||||
**Prompt Optimization with ND Accommodations**
|
||||
|
||||
Transform vague requests into clear specifications using structured methodology.
|
||||
|
||||
- 4-D methodology: Deconstruct, Diagnose, Develop, Deliver
|
||||
- ND-friendly question patterns (option-based, chunked)
|
||||
- Conflict detection and escalation protocols
|
||||
|
||||
**Commands:** `/clarify`, `/quick-clarify`
|
||||
|
||||
#### [doc-guardian](./plugins/doc-guardian/README.md)
|
||||
**Documentation Lifecycle Management**
|
||||
|
||||
Automatic documentation drift detection and synchronization.
|
||||
|
||||
**Commands:** `/doc-audit`, `/doc-sync`
|
||||
|
||||
#### [project-hygiene](./plugins/project-hygiene/README.md)
|
||||
**Post-Task Cleanup Automation**
|
||||
|
||||
Hook-based cleanup that runs after Claude completes work.
|
||||
|
||||
- Deletes temp files (`*.tmp`, `*.bak`, `__pycache__`, etc.)
|
||||
- Warns about unexpected files in project root
|
||||
- Identifies orphaned supporting files
|
||||
- Configurable via `.hygiene.json`
|
||||
### Security
|
||||
|
||||
#### [code-sentinel](./plugins/code-sentinel/README.md)
|
||||
**Security Scanning & Refactoring**
|
||||
|
||||
Security vulnerability detection and code refactoring tools.
|
||||
|
||||
**Commands:** `/security-scan`, `/refactor`, `/refactor-dry`
|
||||
|
||||
### Infrastructure
|
||||
|
||||
#### [cmdb-assistant](./plugins/cmdb-assistant/README.md)
|
||||
**NetBox CMDB Integration**
|
||||
|
||||
Full CRUD operations for network infrastructure management directly from Claude Code.
|
||||
|
||||
**Commands:** `/initial-setup`, `/cmdb-search`, `/cmdb-device`, `/cmdb-ip`, `/cmdb-site`, `/cmdb-audit`, `/cmdb-register`, `/cmdb-sync`
|
||||
|
||||
### Data Engineering
|
||||
|
||||
#### [data-platform](./plugins/data-platform/README.md) *NEW in v4.0.0*
|
||||
**pandas, PostgreSQL/PostGIS, and dbt Integration**
|
||||
|
||||
Comprehensive data engineering toolkit with persistent DataFrame storage.
|
||||
|
||||
- 14 pandas tools with Arrow IPC data_ref system
|
||||
- 10 PostgreSQL/PostGIS tools with connection pooling
|
||||
- 8 dbt tools with automatic pre-validation
|
||||
- 100k row limit with chunking support
|
||||
- Auto-detection of dbt projects
|
||||
|
||||
**Commands:** `/ingest`, `/profile`, `/schema`, `/explain`, `/lineage`, `/run`
|
||||
|
||||
### Visualization
|
||||
|
||||
#### [viz-platform](./plugins/viz-platform/README.md) *NEW in v4.0.0*
|
||||
**Dash Mantine Components Validation and Theming**
|
||||
|
||||
Visualization toolkit with version-locked component validation and design token theming.
|
||||
|
||||
- 3 DMC tools with static JSON registry (prevents prop hallucination)
|
||||
- 2 Chart tools with Plotly and theme integration
|
||||
- 5 Layout tools for dashboard composition
|
||||
- 6 Theme tools with design token system
|
||||
- 5 Page tools for multi-page app structure
|
||||
- Dual theme storage: user-level and project-level
|
||||
|
||||
**Commands:** `/chart`, `/dashboard`, `/theme`, `/theme-new`, `/theme-css`, `/component`, `/initial-setup`
|
||||
|
||||
## MCP Servers
|
||||
|
||||
MCP servers are **bundled inside each plugin** that needs them. This ensures plugins work when cached by Claude Code.
|
||||
MCP servers are **shared at repository root** with **symlinks** from plugins that use them.
|
||||
|
||||
### Gitea MCP Server (bundled in projman)
|
||||
### Gitea MCP Server (shared)
|
||||
|
||||
Full Gitea API integration for project management.
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `add_comment` |
|
||||
| Labels | `get_labels`, `suggest_labels`, `create_label` |
|
||||
| Wiki | `list_wiki_pages`, `get_wiki_page`, `create_wiki_page`, `create_lesson`, `search_lessons` |
|
||||
| Milestones | `list_milestones`, `get_milestone`, `create_milestone`, `update_milestone` |
|
||||
| Dependencies | `list_issue_dependencies`, `create_issue_dependency`, `get_execution_order` |
|
||||
| Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `add_comment`, `aggregate_issues` |
|
||||
| Labels | `get_labels`, `suggest_labels`, `create_label`, `create_label_smart` |
|
||||
| Wiki | `list_wiki_pages`, `get_wiki_page`, `create_wiki_page`, `update_wiki_page`, `create_lesson`, `search_lessons` |
|
||||
| Milestones | `list_milestones`, `get_milestone`, `create_milestone`, `update_milestone`, `delete_milestone` |
|
||||
| Dependencies | `list_issue_dependencies`, `create_issue_dependency`, `remove_issue_dependency`, `get_execution_order` |
|
||||
| **Pull Requests** | `list_pull_requests`, `get_pull_request`, `get_pr_diff`, `get_pr_comments`, `create_pr_review`, `add_pr_comment` *(NEW in v3.0.0)* |
|
||||
| Validation | `validate_repo_org`, `get_branch_protection` |
|
||||
|
||||
### NetBox MCP Server (bundled in cmdb-assistant)
|
||||
### NetBox MCP Server (shared)
|
||||
|
||||
Comprehensive NetBox REST API integration for infrastructure management.
|
||||
|
||||
@@ -82,6 +170,39 @@ Comprehensive NetBox REST API integration for infrastructure management.
|
||||
| Virtualization | Clusters, VMs, Interfaces |
|
||||
| Extras | Tags, Custom Fields, Audit Log |
|
||||
|
||||
### Data Platform MCP Server (shared) *NEW in v4.0.0*
|
||||
|
||||
pandas, PostgreSQL/PostGIS, and dbt integration for data engineering.
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| pandas | `read_csv`, `read_parquet`, `read_json`, `to_csv`, `to_parquet`, `describe`, `head`, `tail`, `filter`, `select`, `groupby`, `join`, `list_data`, `drop_data` |
|
||||
| PostgreSQL | `pg_connect`, `pg_query`, `pg_execute`, `pg_tables`, `pg_columns`, `pg_schemas` |
|
||||
| PostGIS | `st_tables`, `st_geometry_type`, `st_srid`, `st_extent` |
|
||||
| dbt | `dbt_parse`, `dbt_run`, `dbt_test`, `dbt_build`, `dbt_compile`, `dbt_ls`, `dbt_docs_generate`, `dbt_lineage` |
|
||||
|
||||
### Viz Platform MCP Server (shared) *NEW in v4.0.0*
|
||||
|
||||
Dash Mantine Components validation and visualization tools.
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| DMC | `list_components`, `get_component_props`, `validate_component` |
|
||||
| Chart | `chart_create`, `chart_configure_interaction` |
|
||||
| Layout | `layout_create`, `layout_add_filter`, `layout_set_grid`, `layout_get`, `layout_add_section` |
|
||||
| Theme | `theme_create`, `theme_extend`, `theme_validate`, `theme_export_css`, `theme_list`, `theme_activate` |
|
||||
| Page | `page_create`, `page_add_navbar`, `page_set_auth`, `page_list`, `page_get_app_config` |
|
||||
|
||||
### Contract Validator MCP Server (shared) *NEW in v5.0.0*
|
||||
|
||||
Cross-plugin compatibility validation tools.
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| Parse | `parse_plugin_interface`, `parse_claude_md_agents` |
|
||||
| Validation | `validate_compatibility`, `validate_agent_refs`, `validate_data_flow` |
|
||||
| Report | `generate_compatibility_report`, `list_issues` |
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
@@ -94,7 +215,7 @@ Comprehensive NetBox REST API integration for infrastructure management.
|
||||
|
||||
**Option 1 - CLI command (recommended):**
|
||||
```bash
|
||||
/plugin marketplace add https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace.git
|
||||
/plugin marketplace add https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git
|
||||
```
|
||||
|
||||
**Option 2 - Settings file (for team distribution):**
|
||||
@@ -103,139 +224,128 @@ Add to `.claude/settings.json` in your target project:
|
||||
```json
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"support-claude-mktplace": {
|
||||
"leo-claude-mktplace": {
|
||||
"source": {
|
||||
"source": "git",
|
||||
"url": "https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace.git"
|
||||
"url": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Option 3 - Local development:**
|
||||
```bash
|
||||
# Clone the repository first
|
||||
git clone https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace.git
|
||||
### Run Interactive Setup
|
||||
|
||||
# Then add from local path
|
||||
/plugin marketplace add /path/to/support-claude-mktplace
|
||||
After installing plugins, run the setup wizard:
|
||||
|
||||
```
|
||||
/initial-setup
|
||||
```
|
||||
|
||||
**Alternative SSH URL (for authenticated access):**
|
||||
The wizard handles everything:
|
||||
- Sets up MCP server (Python venv + dependencies)
|
||||
- Creates system config (`~/.config/claude/gitea.env`)
|
||||
- Guides you through adding your API token
|
||||
- Detects and validates your repository via API
|
||||
- Creates project config (`.env`)
|
||||
|
||||
**For new projects** (when system is already configured):
|
||||
```
|
||||
ssh://git@hotserv.tailc9b278.ts.net:2222/personal-projects/support-claude-mktplace.git
|
||||
/project-init
|
||||
```
|
||||
|
||||
### Configure MCP Server Dependencies
|
||||
|
||||
If using plugins with MCP servers (projman, cmdb-assistant), install dependencies:
|
||||
|
||||
```bash
|
||||
# Gitea MCP (for projman)
|
||||
cd plugins/projman/mcp-servers/gitea
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
deactivate
|
||||
|
||||
# NetBox MCP (for cmdb-assistant)
|
||||
cd ../../../cmdb-assistant/mcp-servers/netbox
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
deactivate
|
||||
**After moving a repository:**
|
||||
```
|
||||
/project-sync
|
||||
```
|
||||
|
||||
### Configure Credentials
|
||||
See [docs/CONFIGURATION.md](./docs/CONFIGURATION.md) for manual setup and advanced options.
|
||||
|
||||
**System-level credentials:**
|
||||
```bash
|
||||
mkdir -p ~/.config/claude
|
||||
## Verifying Plugin Installation
|
||||
|
||||
# Gitea credentials
|
||||
cat > ~/.config/claude/gitea.env << 'EOF'
|
||||
GITEA_URL=https://gitea.example.com
|
||||
GITEA_TOKEN=your_token
|
||||
GITEA_ORG=your_org
|
||||
EOF
|
||||
After installing plugins, the `/plugin` command may show `(no content)` - this is normal Claude Code behavior and doesn't indicate an error.
|
||||
|
||||
# NetBox credentials
|
||||
cat > ~/.config/claude/netbox.env << 'EOF'
|
||||
NETBOX_API_URL=https://netbox.example.com/api
|
||||
NETBOX_API_TOKEN=your_token
|
||||
EOF
|
||||
**To verify a plugin is installed correctly:**
|
||||
|
||||
chmod 600 ~/.config/claude/*.env
|
||||
1. **Check installed plugins list:**
|
||||
```
|
||||
|
||||
**Project-level settings:**
|
||||
```bash
|
||||
# In your target project root
|
||||
cat > .env << 'EOF'
|
||||
GITEA_REPO=your-repository-name
|
||||
EOF
|
||||
/plugin list
|
||||
```
|
||||
Look for `✔ plugin-name · Installed`
|
||||
|
||||
2. **Test a plugin command directly:**
|
||||
```
|
||||
/git-flow:git-status
|
||||
/projman:sprint-status
|
||||
/clarity-assist:clarify
|
||||
```
|
||||
If the command executes and shows output, the plugin is working.
|
||||
|
||||
3. **Check for loading errors:**
|
||||
```
|
||||
/plugin list
|
||||
```
|
||||
Look for any `Plugin Loading Errors` section - this indicates manifest issues.
|
||||
|
||||
**Command format:** All plugin commands use the format `/plugin-name:command-name`
|
||||
|
||||
| Plugin | Test Command |
|
||||
|--------|--------------|
|
||||
| git-flow | `/git-flow:git-status` |
|
||||
| projman | `/projman:sprint-status` |
|
||||
| pr-review | `/pr-review:pr-summary` |
|
||||
| clarity-assist | `/clarity-assist:clarify` |
|
||||
| doc-guardian | `/doc-guardian:doc-audit` |
|
||||
| code-sentinel | `/code-sentinel:security-scan` |
|
||||
| claude-config-maintainer | `/claude-config-maintainer:config-analyze` |
|
||||
| cmdb-assistant | `/cmdb-assistant:cmdb-search` |
|
||||
| data-platform | `/data-platform:ingest` |
|
||||
| viz-platform | `/viz-platform:chart` |
|
||||
| contract-validator | `/contract-validator:validate-contracts` |
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
support-claude-mktplace/
|
||||
leo-claude-mktplace/
|
||||
├── .claude-plugin/ # Marketplace manifest
|
||||
│ └── marketplace.json
|
||||
├── plugins/ # All plugins (with bundled MCP servers)
|
||||
│ ├── projman/ # Sprint management plugin
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── .mcp.json
|
||||
│ │ ├── mcp-servers/ # Bundled MCP server
|
||||
│ │ │ └── gitea/
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ └── skills/
|
||||
│ ├── claude-config-maintainer/ # CLAUDE.md optimization plugin
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── commands/
|
||||
│ │ └── agents/
|
||||
├── mcp-servers/ # SHARED MCP servers (v3.0.0+)
|
||||
│ ├── gitea/ # Gitea MCP (issues, PRs, wiki)
|
||||
│ ├── netbox/ # NetBox MCP (CMDB)
|
||||
│ ├── data-platform/ # Data engineering (pandas, PostgreSQL, dbt)
|
||||
│ ├── viz-platform/ # Visualization (DMC, Plotly, theming)
|
||||
│ └── contract-validator/ # Cross-plugin validation (v5.0.0)
|
||||
├── plugins/ # All plugins
|
||||
│ ├── projman/ # Sprint management
|
||||
│ ├── git-flow/ # Git workflow automation
|
||||
│ ├── pr-review/ # PR review
|
||||
│ ├── clarity-assist/ # Prompt optimization
|
||||
│ ├── data-platform/ # Data engineering
|
||||
│ ├── viz-platform/ # Visualization
|
||||
│ ├── contract-validator/ # Cross-plugin validation (NEW)
|
||||
│ ├── claude-config-maintainer/ # CLAUDE.md optimization
|
||||
│ ├── cmdb-assistant/ # NetBox CMDB integration
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── .mcp.json
|
||||
│ │ ├── mcp-servers/ # Bundled MCP server
|
||||
│ │ │ └── netbox/
|
||||
│ │ ├── commands/
|
||||
│ │ └── agents/
|
||||
│ ├── projman-pmo/ # PMO coordination plugin (planned)
|
||||
│ └── project-hygiene/ # Cleanup automation plugin
|
||||
├── docs/ # Reference documentation
|
||||
│ ├── CANONICAL-PATHS.md # Single source of truth for paths
|
||||
│ └── references/
|
||||
└── scripts/ # Setup and maintenance scripts
|
||||
└── validate-marketplace.sh # Marketplace compliance validation
|
||||
│ ├── doc-guardian/ # Documentation drift detection
|
||||
│ ├── code-sentinel/ # Security scanning
|
||||
│ └── project-hygiene/ # Cleanup automation
|
||||
├── docs/ # Documentation
|
||||
│ ├── CANONICAL-PATHS.md # Path reference
|
||||
│ └── CONFIGURATION.md # Setup guide
|
||||
├── scripts/ # Setup scripts
|
||||
└── CHANGELOG.md # Version history
|
||||
```
|
||||
|
||||
## Key Features (v2.2.0)
|
||||
|
||||
### Parallel Execution
|
||||
Tasks are batched by dependency graph for optimal parallel execution:
|
||||
```
|
||||
Batch 1 (parallel): Task A, Task B, Task C
|
||||
Batch 2 (parallel): Task D, Task E (depend on Batch 1)
|
||||
Batch 3 (sequential): Task F (depends on Batch 2)
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
- **Tasks:** `[Sprint XX] <type>: <description>`
|
||||
- **Branches:** `feat/`, `fix/`, `debug/` prefixes with issue numbers
|
||||
|
||||
### CLI Tools Blocked
|
||||
All agents use MCP tools exclusively. CLI tools like `tea` or `gh` are forbidden to ensure consistent, auditable operations.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [CLAUDE.md](./CLAUDE.md) | Main project instructions |
|
||||
| [CONFIGURATION.md](./docs/CONFIGURATION.md) | Centralized setup guide |
|
||||
| [COMMANDS-CHEATSHEET.md](./docs/COMMANDS-CHEATSHEET.md) | All commands quick reference |
|
||||
| [UPDATING.md](./docs/UPDATING.md) | Update guide for the marketplace |
|
||||
| [CANONICAL-PATHS.md](./docs/CANONICAL-PATHS.md) | Authoritative path reference |
|
||||
| [projman/CONFIGURATION.md](./plugins/projman/CONFIGURATION.md) | Projman setup guide |
|
||||
| [DEBUGGING-CHECKLIST.md](./docs/DEBUGGING-CHECKLIST.md) | Systematic troubleshooting guide |
|
||||
| [CHANGELOG.md](./CHANGELOG.md) | Version history |
|
||||
|
||||
## License
|
||||
|
||||
@@ -244,5 +354,4 @@ MIT License
|
||||
## Support
|
||||
|
||||
- **Issues**: Contact repository maintainer
|
||||
- **Repository**: `https://gitea.hotserv.cloud/personal-projects/support-claude-mktplace.git`
|
||||
- **SSH URL**: `ssh://git@hotserv.tailc9b278.ts.net:2222/personal-projects/support-claude-mktplace.git`
|
||||
- **Repository**: `https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git`
|
||||
|
||||
@@ -2,51 +2,174 @@
|
||||
|
||||
**This file defines ALL valid paths in this repository. No exceptions. No inference. No assumptions.**
|
||||
|
||||
Last Updated: 2025-12-15
|
||||
Last Updated: 2026-01-27 (v5.1.0)
|
||||
|
||||
---
|
||||
|
||||
## Repository Root Structure
|
||||
|
||||
```
|
||||
support-claude-mktplace/
|
||||
leo-claude-mktplace/
|
||||
├── .claude/ # Claude Code local settings
|
||||
├── .claude-plugin/ # Marketplace manifest (claude-code-marketplace)
|
||||
├── .claude-plugin/ # Marketplace manifest
|
||||
│ └── marketplace.json
|
||||
├── .scratch/ # Transient work (auto-cleaned)
|
||||
├── docs/ # All documentation
|
||||
│ ├── architecture/ # Draw.io diagrams and specs
|
||||
│ ├── references/ # Reference specifications
|
||||
│ └── workflows/ # Workflow documentation
|
||||
│ ├── CANONICAL-PATHS.md # This file - single source of truth
|
||||
│ ├── COMMANDS-CHEATSHEET.md # All commands quick reference
|
||||
│ ├── CONFIGURATION.md # Centralized configuration guide
|
||||
│ ├── DEBUGGING-CHECKLIST.md # Systematic troubleshooting guide
|
||||
│ └── UPDATING.md # Update guide
|
||||
├── hooks/ # Shared hooks (if any)
|
||||
├── plugins/ # ALL plugins with bundled MCP servers
|
||||
│ ├── projman/
|
||||
├── mcp-servers/ # SHARED MCP servers (v3.0.0+)
|
||||
│ ├── gitea/ # Gitea MCP server
|
||||
│ │ ├── mcp_server/
|
||||
│ │ │ ├── server.py
|
||||
│ │ │ ├── gitea_client.py
|
||||
│ │ │ ├── config.py
|
||||
│ │ │ └── tools/
|
||||
│ │ │ ├── issues.py
|
||||
│ │ │ ├── labels.py
|
||||
│ │ │ ├── wiki.py
|
||||
│ │ │ ├── milestones.py
|
||||
│ │ │ ├── dependencies.py
|
||||
│ │ │ └── pull_requests.py # NEW in v3.0.0
|
||||
│ │ ├── requirements.txt
|
||||
│ │ └── .venv/
|
||||
│ ├── netbox/ # NetBox MCP server
|
||||
│ │ ├── mcp_server/
|
||||
│ │ ├── requirements.txt
|
||||
│ │ └── .venv/
|
||||
│ ├── data-platform/ # Data engineering MCP (NEW v4.0.0)
|
||||
│ │ ├── mcp_server/
|
||||
│ │ │ ├── server.py
|
||||
│ │ │ ├── pandas_tools.py
|
||||
│ │ │ ├── postgres_tools.py
|
||||
│ │ │ └── dbt_tools.py
|
||||
│ │ ├── requirements.txt
|
||||
│ │ └── .venv/
|
||||
│ ├── contract-validator/ # Contract validation MCP (NEW v5.0.0)
|
||||
│ │ ├── mcp_server/
|
||||
│ │ │ ├── server.py
|
||||
│ │ │ ├── parse_tools.py
|
||||
│ │ │ ├── validation_tools.py
|
||||
│ │ │ └── report_tools.py
|
||||
│ │ ├── tests/
|
||||
│ │ ├── requirements.txt
|
||||
│ │ └── .venv/
|
||||
│ └── viz-platform/ # Visualization MCP (NEW v4.1.0)
|
||||
│ ├── mcp_server/
|
||||
│ │ ├── server.py
|
||||
│ │ ├── config.py
|
||||
│ │ ├── component_registry.py
|
||||
│ │ ├── dmc_tools.py
|
||||
│ │ ├── chart_tools.py
|
||||
│ │ ├── layout_tools.py
|
||||
│ │ ├── theme_tools.py
|
||||
│ │ ├── theme_store.py
|
||||
│ │ └── page_tools.py
|
||||
│ ├── registry/ # DMC component JSON registries
|
||||
│ ├── tests/ # 94 tests
|
||||
│ ├── requirements.txt
|
||||
│ └── .venv/
|
||||
├── plugins/ # ALL plugins
|
||||
│ ├── projman/ # Sprint management
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── mcp-servers/ # MCP servers bundled IN plugin
|
||||
│ │ │ └── gitea/ # Gitea + Wiki tools
|
||||
│ │ ├── .mcp.json
|
||||
│ │ ├── mcp-servers/
|
||||
│ │ │ └── gitea -> ../../../mcp-servers/gitea # SYMLINK
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ ├── skills/
|
||||
│ │ └── claude-md-integration.md # CLAUDE.md integration snippet
|
||||
│ ├── projman-pmo/
|
||||
│ ├── project-hygiene/
|
||||
│ ├── cmdb-assistant/
|
||||
│ │ └── claude-md-integration.md
|
||||
│ ├── doc-guardian/ # Documentation drift detection
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── mcp-servers/ # MCP servers bundled IN plugin
|
||||
│ │ │ └── netbox/
|
||||
│ │ ├── hooks/
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ └── claude-md-integration.md # CLAUDE.md integration snippet
|
||||
│ │ ├── skills/
|
||||
│ │ └── claude-md-integration.md
|
||||
│ ├── code-sentinel/ # Security scanning & refactoring
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── hooks/
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ ├── skills/
|
||||
│ │ └── claude-md-integration.md
|
||||
│ ├── cmdb-assistant/ # NetBox CMDB integration
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── .mcp.json
|
||||
│ │ ├── mcp-servers/
|
||||
│ │ │ └── netbox -> ../../../mcp-servers/netbox # SYMLINK
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ └── claude-md-integration.md
|
||||
│ ├── claude-config-maintainer/
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ └── claude-md-integration.md # CLAUDE.md integration snippet
|
||||
│ └── project-hygiene/
|
||||
│ │ └── claude-md-integration.md
|
||||
│ ├── project-hygiene/
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── hooks/
|
||||
│ │ └── claude-md-integration.md
|
||||
│ ├── clarity-assist/ # NEW in v3.0.0
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ ├── skills/
|
||||
│ │ └── claude-md-integration.md
|
||||
│ ├── git-flow/ # NEW in v3.0.0
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ ├── skills/
|
||||
│ │ └── claude-md-integration.md
|
||||
│ ├── pr-review/ # NEW in v3.0.0
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── .mcp.json
|
||||
│ │ ├── mcp-servers/
|
||||
│ │ │ └── gitea -> ../../../mcp-servers/gitea # SYMLINK
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ ├── skills/
|
||||
│ │ └── claude-md-integration.md
|
||||
│ ├── data-platform/ # NEW in v4.0.0
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── .mcp.json
|
||||
│ │ ├── mcp-servers/
|
||||
│ │ │ └── data-platform -> ../../../mcp-servers/data-platform # SYMLINK
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ ├── hooks/
|
||||
│ │ └── claude-md-integration.md
|
||||
│ ├── contract-validator/ # NEW in v5.0.0
|
||||
│ │ ├── .claude-plugin/
|
||||
│ │ ├── .mcp.json
|
||||
│ │ ├── mcp-servers/
|
||||
│ │ │ └── contract-validator -> ../../../mcp-servers/contract-validator # SYMLINK
|
||||
│ │ ├── commands/
|
||||
│ │ ├── agents/
|
||||
│ │ └── claude-md-integration.md
|
||||
│ └── viz-platform/ # NEW in v4.1.0
|
||||
│ ├── .claude-plugin/
|
||||
│ ├── .mcp.json
|
||||
│ ├── mcp-servers/
|
||||
│ │ └── viz-platform -> ../../../mcp-servers/viz-platform # SYMLINK
|
||||
│ ├── commands/
|
||||
│ ├── agents/
|
||||
│ ├── hooks/
|
||||
│ └── claude-md-integration.md # CLAUDE.md integration snippet
|
||||
│ └── claude-md-integration.md
|
||||
├── scripts/ # Setup and maintenance scripts
|
||||
│ ├── setup.sh # Initial setup (create venvs, config templates)
|
||||
│ ├── post-update.sh # Post-update (rebuild venvs, verify symlinks)
|
||||
│ ├── check-venv.sh # Check if venvs exist (for hooks)
|
||||
│ ├── validate-marketplace.sh # Marketplace compliance validation
|
||||
│ ├── verify-hooks.sh # Verify all hooks use correct event types
|
||||
│ ├── setup-venvs.sh # Setup/repair MCP server venvs
|
||||
│ ├── venv-repair.sh # Repair broken venv symlinks
|
||||
│ └── release.sh # Release automation with version bumping
|
||||
├── CLAUDE.md
|
||||
├── README.md
|
||||
├── LICENSE
|
||||
@@ -69,31 +192,37 @@ support-claude-mktplace/
|
||||
| Plugin .mcp.json | `plugins/{plugin-name}/.mcp.json` | `plugins/projman/.mcp.json` |
|
||||
| Plugin integration snippet | `plugins/{plugin-name}/claude-md-integration.md` | `plugins/projman/claude-md-integration.md` |
|
||||
|
||||
### MCP Server Paths (Bundled in Plugins)
|
||||
### MCP Server Paths (v3.0.0 Architecture)
|
||||
|
||||
MCP servers are now **bundled inside each plugin** to ensure they work when plugins are cached.
|
||||
MCP servers are **shared at repository root** with **symlinks** from plugins.
|
||||
|
||||
| Context | Pattern | Example |
|
||||
|---------|---------|---------|
|
||||
| MCP server location | `plugins/{plugin}/mcp-servers/{server}/` | `plugins/projman/mcp-servers/gitea/` |
|
||||
| MCP server code | `plugins/{plugin}/mcp-servers/{server}/mcp_server/` | `plugins/projman/mcp-servers/gitea/mcp_server/` |
|
||||
| MCP venv | `plugins/{plugin}/mcp-servers/{server}/.venv/` | `plugins/projman/mcp-servers/gitea/.venv/` |
|
||||
| Shared MCP server | `mcp-servers/{server}/` | `mcp-servers/gitea/` |
|
||||
| MCP server code | `mcp-servers/{server}/mcp_server/` | `mcp-servers/gitea/mcp_server/` |
|
||||
| MCP venv | `mcp-servers/{server}/.venv/` | `mcp-servers/gitea/.venv/` |
|
||||
| Plugin symlink | `plugins/{plugin}/mcp-servers/{server}` | `plugins/projman/mcp-servers/gitea` |
|
||||
|
||||
### Relative Path Patterns (CRITICAL)
|
||||
### Symlink Pattern
|
||||
|
||||
| From | To | Pattern |
|
||||
|------|----|---------|
|
||||
| Plugin .mcp.json | Bundled MCP server | `${CLAUDE_PLUGIN_ROOT}/mcp-servers/{server}` |
|
||||
| marketplace.json | Plugin | `./plugins/{plugin-name}` |
|
||||
Plugins that use MCP servers create symlinks:
|
||||
```bash
|
||||
# From plugin directory
|
||||
ln -s ../../../mcp-servers/gitea plugins/projman/mcp-servers/gitea
|
||||
```
|
||||
|
||||
The symlink target is relative: `../../../mcp-servers/{server}`
|
||||
|
||||
### Documentation Paths
|
||||
|
||||
| Type | Location |
|
||||
|------|----------|
|
||||
| Reference specs | `docs/references/` |
|
||||
| Architecture diagrams | `docs/architecture/` |
|
||||
| Workflow docs | `docs/workflows/` |
|
||||
| This file | `docs/CANONICAL-PATHS.md` |
|
||||
| Update guide | `docs/UPDATING.md` |
|
||||
| Configuration guide | `docs/CONFIGURATION.md` |
|
||||
| Commands cheat sheet | `docs/COMMANDS-CHEATSHEET.md` |
|
||||
| Debugging checklist | `docs/DEBUGGING-CHECKLIST.md` |
|
||||
|
||||
---
|
||||
|
||||
@@ -111,15 +240,15 @@ MCP servers are now **bundled inside each plugin** to ensure they work when plug
|
||||
2. Verify each path against patterns in this file
|
||||
3. Show verification to user before proceeding
|
||||
|
||||
### Relative Path Calculation
|
||||
### Relative Path Calculation (v3.0.0)
|
||||
|
||||
From `plugins/projman/.mcp.json` to bundled `mcp-servers/gitea/`:
|
||||
From `plugins/projman/.mcp.json` to shared `mcp-servers/gitea/`:
|
||||
```
|
||||
plugins/projman/.mcp.json
|
||||
→ MCP servers are IN the plugin at mcp-servers/
|
||||
→ Uses ${CLAUDE_PLUGIN_ROOT}/mcp-servers/gitea/
|
||||
→ Symlink at plugins/projman/mcp-servers/gitea points to ../../../mcp-servers/gitea
|
||||
|
||||
Result: mcp-servers/gitea/
|
||||
With variable: ${CLAUDE_PLUGIN_ROOT}/mcp-servers/gitea/
|
||||
Result in .mcp.json: ${CLAUDE_PLUGIN_ROOT}/mcp-servers/gitea/.venv/bin/python
|
||||
```
|
||||
|
||||
From `.claude-plugin/marketplace.json` to `plugins/projman/`:
|
||||
@@ -138,18 +267,31 @@ Result: ./plugins/projman
|
||||
| Wrong | Why | Correct |
|
||||
|-------|-----|---------|
|
||||
| `projman/` at root | Plugins go in `plugins/` | `plugins/projman/` |
|
||||
| `mcp-servers/` at root | MCP servers are bundled in plugins | `plugins/{plugin}/mcp-servers/` |
|
||||
| `../../mcp-servers/` from plugin | Old pattern, doesn't work with caching | `${CLAUDE_PLUGIN_ROOT}/mcp-servers/` |
|
||||
| `./../../../plugins/projman` in marketplace | Wrong (old nested structure) | `./plugins/projman` |
|
||||
| Direct path in .mcp.json to root mcp-servers | Use symlink | Symlink at `plugins/{plugin}/mcp-servers/` |
|
||||
| Creating new mcp-servers inside plugins | Use shared + symlink | Symlink to `mcp-servers/` |
|
||||
| Hardcoding absolute paths | Breaks portability | Use `${CLAUDE_PLUGIN_ROOT}` |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Note
|
||||
## Architecture Note (v3.0.0)
|
||||
|
||||
MCP servers are bundled inside each plugin (not shared at root) because:
|
||||
- Claude Code caches only the plugin directory when installed
|
||||
- Relative paths to parent directories break in the cache
|
||||
- Each plugin must be self-contained to work properly
|
||||
MCP servers are now **shared at repository root** with **symlinks** from plugins:
|
||||
|
||||
**Benefits:**
|
||||
- Single source of truth for each MCP server
|
||||
- Updates apply to all plugins automatically
|
||||
- Reduced duplication
|
||||
- Symlinks work with Claude Code caching
|
||||
|
||||
**Symlink Pattern:**
|
||||
```
|
||||
plugins/projman/mcp-servers/gitea -> ../../../mcp-servers/gitea
|
||||
plugins/cmdb-assistant/mcp-servers/netbox -> ../../../mcp-servers/netbox
|
||||
plugins/pr-review/mcp-servers/gitea -> ../../../mcp-servers/gitea
|
||||
plugins/data-platform/mcp-servers/data-platform -> ../../../mcp-servers/data-platform
|
||||
plugins/viz-platform/mcp-servers/viz-platform -> ../../../mcp-servers/viz-platform
|
||||
plugins/contract-validator/mcp-servers/contract-validator -> ../../../mcp-servers/contract-validator
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -157,6 +299,14 @@ MCP servers are bundled inside each plugin (not shared at root) because:
|
||||
|
||||
| Date | Change | By |
|
||||
|------|--------|-----|
|
||||
| 2026-01-19 | Added claude-md-integration.md path pattern for plugin integration snippets | Claude Code |
|
||||
| 2025-12-15 | Restructured: MCP servers now bundled in plugins | Claude Code |
|
||||
| 2026-01-26 | v5.0.0: Added contract-validator plugin and MCP server | Claude Code |
|
||||
| 2026-01-26 | v4.1.0: Added viz-platform plugin and MCP server | Claude Code |
|
||||
| 2026-01-25 | v4.0.0: Added data-platform plugin and MCP server | Claude Code |
|
||||
| 2026-01-20 | v3.0.0: MCP servers moved to root with symlinks | Claude Code |
|
||||
| 2026-01-20 | v3.0.0: Added clarity-assist, git-flow, pr-review plugins | Claude Code |
|
||||
| 2026-01-20 | v3.0.0: Added docs/CONFIGURATION.md | Claude Code |
|
||||
| 2026-01-20 | v3.0.0: Renamed marketplace to leo-claude-mktplace | Claude Code |
|
||||
| 2026-01-20 | Removed docs/references/ (obsolete planning docs) | Claude Code |
|
||||
| 2026-01-19 | Added claude-md-integration.md path pattern | Claude Code |
|
||||
| 2025-12-15 | Restructured: MCP servers bundled in plugins | Claude Code |
|
||||
| 2025-12-12 | Initial creation | Claude Code |
|
||||
|
||||
257
docs/COMMANDS-CHEATSHEET.md
Normal file
257
docs/COMMANDS-CHEATSHEET.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Plugin Commands Cheat Sheet
|
||||
|
||||
Quick reference for all commands in the Leo Claude Marketplace.
|
||||
|
||||
---
|
||||
|
||||
## Command Reference Table
|
||||
|
||||
| Plugin | Command | Auto | Manual | Description |
|
||||
|--------|---------|:----:|:------:|-------------|
|
||||
| **projman** | `/sprint-plan` | | X | Start sprint planning with AI-guided architecture analysis and issue creation |
|
||||
| **projman** | `/sprint-start` | | X | Begin sprint execution with dependency analysis and parallel task coordination |
|
||||
| **projman** | `/sprint-status` | | X | Check current sprint progress and identify blockers |
|
||||
| **projman** | `/review` | | X | Pre-sprint-close code quality review (debug artifacts, security, error handling) |
|
||||
| **projman** | `/test-check` | | X | Run tests and verify coverage before sprint close |
|
||||
| **projman** | `/sprint-close` | | X | Complete sprint and capture lessons learned to Gitea Wiki |
|
||||
| **projman** | `/labels-sync` | | X | Synchronize label taxonomy from Gitea |
|
||||
| **projman** | `/initial-setup` | | X | Full setup wizard: MCP server + system config + project config |
|
||||
| **projman** | `/project-init` | | X | Quick project setup (assumes system config exists) |
|
||||
| **projman** | `/project-sync` | | X | Sync config with git remote after repo move/rename |
|
||||
| **projman** | *SessionStart hook* | X | | Detects git remote vs .env mismatch, warns to run /project-sync |
|
||||
| **projman** | `/test-gen` | | X | Generate comprehensive tests for specified code |
|
||||
| **projman** | `/debug-report` | | X | Run diagnostics and create structured issue in marketplace |
|
||||
| **projman** | `/debug-review` | | X | Investigate diagnostic issues and propose fixes with approval gates |
|
||||
| **projman** | `/suggest-version` | | X | Analyze CHANGELOG and recommend semantic version bump |
|
||||
| **projman** | `/proposal-status` | | X | View proposal and implementation hierarchy with status |
|
||||
| **git-flow** | `/commit` | | X | Create commit with auto-generated conventional message |
|
||||
| **git-flow** | `/commit-push` | | X | Commit and push to remote in one operation |
|
||||
| **git-flow** | `/commit-merge` | | X | Commit current changes, then merge into target branch |
|
||||
| **git-flow** | `/commit-sync` | | X | Full sync: commit, push, and sync with upstream/base branch |
|
||||
| **git-flow** | `/branch-start` | | X | Create new feature/fix/chore branch with naming conventions |
|
||||
| **git-flow** | `/branch-cleanup` | | X | Remove merged branches locally and optionally on remote |
|
||||
| **git-flow** | `/git-status` | | X | Enhanced git status with recommendations |
|
||||
| **git-flow** | `/git-config` | | X | Configure git-flow settings for the project |
|
||||
| **pr-review** | `/initial-setup` | | X | Setup wizard for pr-review (shares Gitea MCP with projman) |
|
||||
| **pr-review** | `/project-init` | | X | Quick project setup for PR reviews |
|
||||
| **pr-review** | `/project-sync` | | X | Sync config with git remote after repo move/rename |
|
||||
| **pr-review** | *SessionStart hook* | X | | Detects git remote vs .env mismatch |
|
||||
| **pr-review** | `/pr-review` | | X | Full multi-agent PR review with confidence scoring |
|
||||
| **pr-review** | `/pr-summary` | | X | Quick summary of PR changes |
|
||||
| **pr-review** | `/pr-findings` | | X | List and filter review findings by category/severity |
|
||||
| **clarity-assist** | `/clarify` | | X | Full 4-D prompt optimization with ND accommodations |
|
||||
| **clarity-assist** | `/quick-clarify` | | X | Rapid single-pass clarification for simple requests |
|
||||
| **doc-guardian** | `/doc-audit` | | X | Full documentation audit - scans for doc drift |
|
||||
| **doc-guardian** | `/doc-sync` | | X | Synchronize pending documentation updates |
|
||||
| **doc-guardian** | *PostToolUse hook* | X | | Silently detects doc drift on Write/Edit |
|
||||
| **code-sentinel** | `/security-scan` | | X | Full security audit (SQL injection, XSS, secrets, etc.) |
|
||||
| **code-sentinel** | `/refactor` | | X | Apply refactoring patterns to improve code |
|
||||
| **code-sentinel** | `/refactor-dry` | | X | Preview refactoring without applying changes |
|
||||
| **code-sentinel** | *PreToolUse hook* | X | | Scans code before writing; blocks critical issues |
|
||||
| **claude-config-maintainer** | `/config-analyze` | | X | Analyze CLAUDE.md for optimization opportunities |
|
||||
| **claude-config-maintainer** | `/config-optimize` | | X | Optimize CLAUDE.md structure with preview/backup |
|
||||
| **claude-config-maintainer** | `/config-init` | | X | Initialize new CLAUDE.md for a project |
|
||||
| **cmdb-assistant** | `/initial-setup` | | X | Setup wizard for NetBox MCP server |
|
||||
| **cmdb-assistant** | `/cmdb-search` | | X | Search NetBox for devices, IPs, sites |
|
||||
| **cmdb-assistant** | `/cmdb-device` | | X | Manage network devices (create, view, update, delete) |
|
||||
| **cmdb-assistant** | `/cmdb-ip` | | X | Manage IP addresses and prefixes |
|
||||
| **cmdb-assistant** | `/cmdb-site` | | X | Manage sites, locations, racks, and regions |
|
||||
| **cmdb-assistant** | `/cmdb-audit` | | X | Data quality analysis (VMs, devices, naming, roles) |
|
||||
| **cmdb-assistant** | `/cmdb-register` | | X | Register current machine into NetBox with running apps |
|
||||
| **cmdb-assistant** | `/cmdb-sync` | | X | Sync machine state with NetBox (detect drift, update) |
|
||||
| **project-hygiene** | *PostToolUse hook* | X | | Removes temp files, warns about unexpected root files |
|
||||
| **data-platform** | `/ingest` | | X | Load data from CSV, Parquet, JSON into DataFrame |
|
||||
| **data-platform** | `/profile` | | X | Generate data profiling report with statistics |
|
||||
| **data-platform** | `/schema` | | X | Explore database schemas, tables, columns |
|
||||
| **data-platform** | `/explain` | | X | Explain query execution plan |
|
||||
| **data-platform** | `/lineage` | | X | Show dbt model lineage and dependencies |
|
||||
| **data-platform** | `/run` | | X | Run dbt models with validation |
|
||||
| **data-platform** | `/initial-setup` | | X | Setup wizard for data-platform MCP servers |
|
||||
| **data-platform** | *SessionStart hook* | X | | Checks PostgreSQL connection (non-blocking warning) |
|
||||
| **viz-platform** | `/initial-setup` | | X | Setup wizard for viz-platform MCP server |
|
||||
| **viz-platform** | `/chart` | | X | Create Plotly charts with theme integration |
|
||||
| **viz-platform** | `/dashboard` | | X | Create dashboard layouts with filters and grids |
|
||||
| **viz-platform** | `/theme` | | X | Apply existing theme to visualizations |
|
||||
| **viz-platform** | `/theme-new` | | X | Create new custom theme with design tokens |
|
||||
| **viz-platform** | `/theme-css` | | X | Export theme as CSS custom properties |
|
||||
| **viz-platform** | `/component` | | X | Inspect DMC component props and validation |
|
||||
| **viz-platform** | *SessionStart hook* | X | | Checks DMC version (non-blocking warning) |
|
||||
|
||||
---
|
||||
|
||||
## Plugins by Category
|
||||
|
||||
| Category | Plugins | Primary Use |
|
||||
|----------|---------|-------------|
|
||||
| **Setup** | projman, pr-review, cmdb-assistant, data-platform | `/initial-setup`, `/project-init` |
|
||||
| **Task Planning** | projman, clarity-assist | Sprint management, requirement clarification |
|
||||
| **Code Quality** | code-sentinel, pr-review | Security scanning, PR reviews |
|
||||
| **Documentation** | doc-guardian, claude-config-maintainer | Doc sync, CLAUDE.md maintenance |
|
||||
| **Git Operations** | git-flow | Commits, branches, workflow automation |
|
||||
| **Infrastructure** | cmdb-assistant | NetBox CMDB management |
|
||||
| **Data Engineering** | data-platform | pandas, PostgreSQL, dbt operations |
|
||||
| **Visualization** | viz-platform | DMC validation, Plotly charts, theming |
|
||||
| **Maintenance** | project-hygiene | Automatic cleanup |
|
||||
|
||||
---
|
||||
|
||||
## Hook-Based Automation Summary
|
||||
|
||||
| Plugin | Hook Event | Behavior |
|
||||
|--------|------------|----------|
|
||||
| **projman** | SessionStart | Checks git remote vs .env; warns if mismatch detected; suggests sprint planning if issues exist |
|
||||
| **pr-review** | SessionStart | Checks git remote vs .env; warns if mismatch detected |
|
||||
| **doc-guardian** | PostToolUse (Write/Edit) | Tracks documentation drift; auto-updates dependent docs |
|
||||
| **code-sentinel** | PreToolUse (Write/Edit) | Scans for security issues; blocks critical vulnerabilities |
|
||||
| **project-hygiene** | PostToolUse (Write/Edit) | Cleans temp files, warns about misplaced files |
|
||||
| **data-platform** | SessionStart | Checks PostgreSQL connection; non-blocking warning if unavailable |
|
||||
| **viz-platform** | SessionStart | Checks DMC version; non-blocking warning if mismatch detected |
|
||||
|
||||
---
|
||||
|
||||
## Dev Workflow Examples
|
||||
|
||||
### Example 1: Starting a New Feature Sprint
|
||||
|
||||
A typical workflow for planning and executing a feature sprint:
|
||||
|
||||
```
|
||||
1. /clarify # Clarify requirements if vague
|
||||
2. /sprint-plan # Plan the sprint with architecture analysis
|
||||
3. /labels-sync # Ensure labels are up-to-date
|
||||
4. /sprint-start # Begin execution with dependency ordering
|
||||
5. /branch-start feat/... # Create feature branch
|
||||
... implement features ...
|
||||
6. /commit # Commit with conventional message
|
||||
7. /sprint-status # Check progress mid-sprint
|
||||
8. /review # Pre-close quality review
|
||||
9. /test-check # Verify test coverage
|
||||
10. /sprint-close # Capture lessons learned
|
||||
```
|
||||
|
||||
### Example 2: Daily Development Cycle
|
||||
|
||||
Quick daily workflow with git-flow:
|
||||
|
||||
```
|
||||
1. /git-status # Check current state
|
||||
2. /branch-start fix/... # Start bugfix branch
|
||||
... make changes ...
|
||||
3. /commit # Auto-generate commit message
|
||||
4. /commit-push # Push to remote
|
||||
5. /branch-cleanup # Clean merged branches
|
||||
```
|
||||
|
||||
### Example 3: Pull Request Review Workflow
|
||||
|
||||
Reviewing a PR before merge:
|
||||
|
||||
```
|
||||
1. /pr-summary # Quick overview of changes
|
||||
2. /pr-review # Full multi-agent review
|
||||
3. /pr-findings # Filter findings by severity
|
||||
4. /security-scan # Deep security audit if needed
|
||||
```
|
||||
|
||||
### Example 4: Documentation Maintenance
|
||||
|
||||
Keeping docs in sync:
|
||||
|
||||
```
|
||||
1. /doc-audit # Scan for documentation drift
|
||||
2. /doc-sync # Apply pending updates
|
||||
3. /config-analyze # Check CLAUDE.md health
|
||||
4. /config-optimize # Optimize if needed
|
||||
```
|
||||
|
||||
### Example 5: Code Refactoring Session
|
||||
|
||||
Safe refactoring with preview:
|
||||
|
||||
```
|
||||
1. /refactor-dry # Preview opportunities
|
||||
2. /security-scan # Baseline security check
|
||||
3. /refactor # Apply improvements
|
||||
4. /test-check # Verify nothing broke
|
||||
5. /commit # Commit with descriptive message
|
||||
```
|
||||
|
||||
### Example 6: Infrastructure Documentation
|
||||
|
||||
Managing infrastructure with CMDB:
|
||||
|
||||
```
|
||||
1. /cmdb-search "server" # Find existing devices
|
||||
2. /cmdb-device view X # Check device details
|
||||
3. /cmdb-ip list # List available IPs
|
||||
4. /cmdb-site view Y # Check site info
|
||||
```
|
||||
|
||||
### Example 6b: Data Engineering Workflow
|
||||
|
||||
Working with data pipelines:
|
||||
|
||||
```
|
||||
1. /ingest file.csv # Load data into DataFrame
|
||||
2. /profile # Generate data profiling report
|
||||
3. /schema # Explore database schemas
|
||||
4. /lineage model_name # View dbt model dependencies
|
||||
5. /run model_name # Execute dbt models
|
||||
6. /explain "SELECT ..." # Analyze query execution plan
|
||||
```
|
||||
|
||||
### Example 7: First-Time Setup (New Machine)
|
||||
|
||||
Setting up the marketplace for the first time:
|
||||
|
||||
```
|
||||
1. /initial-setup # Full setup: MCP + system config + project
|
||||
# → Follow prompts for Gitea URL, org
|
||||
# → Add token manually when prompted
|
||||
# → Confirm repository name
|
||||
2. # Restart Claude Code session
|
||||
3. /labels-sync # Sync Gitea labels
|
||||
4. /sprint-plan # Plan first sprint
|
||||
```
|
||||
|
||||
### Example 8: New Project Setup (System Already Configured)
|
||||
|
||||
Adding a new project when system config exists:
|
||||
|
||||
```
|
||||
1. /project-init # Quick project setup
|
||||
# → Confirms detected repo name
|
||||
# → Creates .env
|
||||
2. /labels-sync # Sync Gitea labels
|
||||
3. /sprint-plan # Plan first sprint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Tips
|
||||
|
||||
- **Hooks run automatically** - doc-guardian and code-sentinel protect you without manual invocation
|
||||
- **Use `/commit` over `git commit`** - generates better commit messages following conventions
|
||||
- **Run `/review` before `/sprint-close`** - catches issues before closing the sprint
|
||||
- **Use `/clarify` for vague requests** - especially helpful for complex requirements
|
||||
- **`/refactor-dry` is safe** - always preview before applying refactoring changes
|
||||
|
||||
---
|
||||
|
||||
## MCP Server Requirements
|
||||
|
||||
Some plugins require MCP server connectivity:
|
||||
|
||||
| Plugin | MCP Server | Purpose |
|
||||
|--------|------------|---------|
|
||||
| projman | Gitea | Issues, PRs, wiki, labels, milestones |
|
||||
| pr-review | Gitea | PR operations and reviews |
|
||||
| cmdb-assistant | NetBox | Infrastructure CMDB |
|
||||
| data-platform | pandas, PostgreSQL, dbt | DataFrames, database queries, dbt builds |
|
||||
| viz-platform | viz-platform | DMC validation, charts, layouts, themes, pages |
|
||||
|
||||
Ensure credentials are configured in `~/.config/claude/gitea.env`, `~/.config/claude/netbox.env`, or `~/.config/claude/postgres.env`.
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2026-01-26*
|
||||
546
docs/CONFIGURATION.md
Normal file
546
docs/CONFIGURATION.md
Normal file
@@ -0,0 +1,546 @@
|
||||
# Configuration Guide
|
||||
|
||||
Centralized configuration documentation for all plugins and MCP servers in the Leo Claude Marketplace.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
**After installing the marketplace and plugins via Claude Code:**
|
||||
|
||||
```
|
||||
/initial-setup
|
||||
```
|
||||
|
||||
The interactive wizard handles everything except manually adding your API tokens.
|
||||
|
||||
---
|
||||
|
||||
## Setup Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ FIRST TIME SETUP │
|
||||
│ (once per machine) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
/initial-setup
|
||||
│
|
||||
┌──────────────────────────────┼──────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PHASE 1 │ │ PHASE 2 │ │ PHASE 3 │
|
||||
│ Automated │───────────▶│ Automated │───────────▶│ Interactive │
|
||||
│ │ │ │ │ │
|
||||
│ • Check │ │ • Find MCP path │ │ • Ask Gitea URL │
|
||||
│ Python │ │ • Create venv │ │ • Ask Org name │
|
||||
│ version │ │ • Install deps │ │ • Create config │
|
||||
└─────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ PHASE 4 │
|
||||
│ USER ACTION │
|
||||
│ │
|
||||
│ Edit config file to add │
|
||||
│ API token (for security) │
|
||||
│ │
|
||||
│ nano ~/.config/claude/ │
|
||||
│ gitea.env │
|
||||
└───────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┬──────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PHASE 5 │ │ PHASE 6 │ │ PHASE 7 │
|
||||
│ Interactive │ │ Automated │ │ Automated │
|
||||
│ │ │ │ │ │
|
||||
│ • Confirm │ │ • Create .env │ │ • Test API │
|
||||
│ repo name │ │ • Check │ │ • Show summary │
|
||||
│ from git │ │ .gitignore │ │ • Restart note │
|
||||
└─────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ RESTART SESSION │
|
||||
│ │
|
||||
│ MCP tools available │
|
||||
│ after restart │
|
||||
└───────────────────────────┘
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ NEW PROJECT SETUP │
|
||||
│ (once per project) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
/project-init /initial-setup
|
||||
(direct path) (smart detection)
|
||||
│ │
|
||||
│ ┌──────────┴──────────┐
|
||||
│ ▼ ▼
|
||||
│ "Quick setup" "Full setup"
|
||||
│ (skips to (re-runs
|
||||
│ project config) everything)
|
||||
│ │ │
|
||||
└────────────────────┴─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ PROJECT CONFIG │
|
||||
│ │
|
||||
│ • Detect repo from │
|
||||
│ git remote │
|
||||
│ • Confirm with user │
|
||||
│ • Create .env │
|
||||
│ • Check .gitignore │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
Done!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Runs Automatically vs User Interaction
|
||||
|
||||
### `/initial-setup` - Full Setup
|
||||
|
||||
| Phase | Type | What Happens |
|
||||
|-------|------|--------------|
|
||||
| **1. Environment Check** | Automated | Verifies Python 3.10+ is installed |
|
||||
| **2. MCP Server Setup** | Automated | Finds plugin path, creates venv, installs dependencies |
|
||||
| **3. System Config Creation** | Interactive | Asks for Gitea URL and organization name |
|
||||
| **4. Token Entry** | **User Action** | User manually edits config file to add API token |
|
||||
| **5. Project Detection** | Interactive | Shows detected repo name, asks for confirmation |
|
||||
| **6. Project Config** | Automated | Creates `.env` file, checks `.gitignore` |
|
||||
| **7. Validation** | Automated | Tests API connectivity, shows summary |
|
||||
|
||||
### `/project-init` - Quick Project Setup
|
||||
|
||||
| Phase | Type | What Happens |
|
||||
|-------|------|--------------|
|
||||
| **1. Pre-flight Check** | Automated | Verifies system config exists |
|
||||
| **2. Project Detection** | Interactive | Shows detected repo name, asks for confirmation |
|
||||
| **3. Project Config** | Automated | Creates/updates `.env` file |
|
||||
| **4. Gitignore Check** | Interactive | Asks to add `.env` to `.gitignore` if missing |
|
||||
|
||||
---
|
||||
|
||||
## Three Commands for Different Scenarios
|
||||
|
||||
| Command | When to Use | What It Does |
|
||||
|---------|-------------|--------------|
|
||||
| `/initial-setup` | First time on a machine | Full setup: MCP server + system config + project config |
|
||||
| `/project-init` | Starting a new project | Quick setup: project config only (assumes system is ready) |
|
||||
| `/project-sync` | After repo move/rename | Updates .env to match current git remote |
|
||||
|
||||
**Typical workflow:**
|
||||
1. Install plugin → run `/initial-setup` (once per machine)
|
||||
2. Start new project → run `/project-init` (once per project)
|
||||
3. Repository moved? → run `/project-sync` (updates config)
|
||||
|
||||
**Smart features:**
|
||||
- `/initial-setup` detects existing system config and offers quick project setup
|
||||
- All commands validate org/repo via Gitea API before saving (auto-fills if verified)
|
||||
- SessionStart hook automatically detects git remote vs .env mismatches
|
||||
|
||||
---
|
||||
|
||||
## Configuration Architecture
|
||||
|
||||
This marketplace uses a **hybrid configuration** approach:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ SYSTEM-LEVEL (once per machine) │
|
||||
│ ~/.config/claude/ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ gitea.env │ GITEA_API_URL, GITEA_API_TOKEN │
|
||||
│ netbox.env │ NETBOX_API_URL, NETBOX_API_TOKEN │
|
||||
│ git-flow.env │ GIT_WORKFLOW_STYLE, GIT_DEFAULT_BASE, etc. │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Shared across all projects
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PROJECT-LEVEL (once per project) │
|
||||
│ <project-root>/.env │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ GITEA_REPO │ Repository as owner/repo format │
|
||||
│ GIT_WORKFLOW_STYLE │ (optional) Override system default │
|
||||
│ PR_REVIEW_* │ (optional) PR review settings │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Single token per service (update once, use everywhere)
|
||||
- Easy multi-project setup (just run `/project-init` in each project)
|
||||
- Security (tokens never committed to git, never typed into AI chat)
|
||||
- Project isolation (each project can override defaults)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running `/initial-setup`:
|
||||
|
||||
1. **Python 3.10+** installed
|
||||
```bash
|
||||
python3 --version # Should be 3.10.0 or higher
|
||||
```
|
||||
|
||||
2. **Git repository** initialized (for project setup)
|
||||
```bash
|
||||
git status # Should show initialized repository
|
||||
```
|
||||
|
||||
3. **Claude Code** installed and working with the marketplace
|
||||
|
||||
---
|
||||
|
||||
## Setup Methods
|
||||
|
||||
### Method 1: Interactive Wizard (Recommended)
|
||||
|
||||
Run the setup wizard in Claude Code:
|
||||
|
||||
```
|
||||
/initial-setup
|
||||
```
|
||||
|
||||
The wizard will guide you through each step interactively.
|
||||
|
||||
**Note:** After first-time setup, you'll need to restart your Claude Code session for MCP tools to become available.
|
||||
|
||||
### Method 2: Manual Setup
|
||||
|
||||
If you prefer to set up manually or need to troubleshoot:
|
||||
|
||||
#### Step 1: MCP Server Setup
|
||||
|
||||
```bash
|
||||
# Navigate to marketplace directory
|
||||
cd /path/to/leo-claude-mktplace
|
||||
|
||||
# Set up Gitea MCP server
|
||||
cd mcp-servers/gitea
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
deactivate
|
||||
|
||||
# (Optional) Set up NetBox MCP server
|
||||
cd ../netbox
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
deactivate
|
||||
```
|
||||
|
||||
#### Step 2: System Configuration
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/claude
|
||||
|
||||
# Gitea configuration (credentials only)
|
||||
cat > ~/.config/claude/gitea.env << 'EOF'
|
||||
GITEA_API_URL=https://gitea.example.com
|
||||
GITEA_API_TOKEN=your_token_here
|
||||
EOF
|
||||
chmod 600 ~/.config/claude/gitea.env
|
||||
```
|
||||
|
||||
#### Step 3: Project Configuration
|
||||
|
||||
In each project root:
|
||||
|
||||
```bash
|
||||
cat > .env << 'EOF'
|
||||
GITEA_REPO=your-organization/your-repo-name
|
||||
EOF
|
||||
```
|
||||
|
||||
Add `.env` to `.gitignore` if not already there.
|
||||
|
||||
### Method 3: Automation Script (CI/Scripting)
|
||||
|
||||
For automated setups or CI environments:
|
||||
|
||||
```bash
|
||||
cd /path/to/leo-claude-mktplace
|
||||
./scripts/setup.sh
|
||||
```
|
||||
|
||||
This script is useful for CI/CD pipelines and bulk provisioning.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### System-Level Files
|
||||
|
||||
Located in `~/.config/claude/`:
|
||||
|
||||
| File | Required By | Purpose |
|
||||
|------|-------------|---------|
|
||||
| `gitea.env` | projman, pr-review | Gitea API credentials |
|
||||
| `netbox.env` | cmdb-assistant | NetBox API credentials |
|
||||
| `git-flow.env` | git-flow | Default git workflow settings |
|
||||
|
||||
### Gitea Configuration
|
||||
|
||||
```bash
|
||||
# ~/.config/claude/gitea.env
|
||||
GITEA_API_URL=https://gitea.example.com/api/v1
|
||||
GITEA_API_TOKEN=your_gitea_token_here
|
||||
```
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `GITEA_API_URL` | Gitea API endpoint (with `/api/v1`) | `https://gitea.example.com/api/v1` |
|
||||
| `GITEA_API_TOKEN` | Personal access token | `abc123...` |
|
||||
|
||||
**Note:** `GITEA_REPO` is configured at the project level in `owner/repo` format since different projects may belong to different organizations.
|
||||
|
||||
**Generating a Gitea Token:**
|
||||
1. Log into Gitea → **User Icon** → **Settings**
|
||||
2. **Applications** tab → **Manage Access Tokens**
|
||||
3. **Generate New Token** with permissions:
|
||||
- `repo` (all sub-permissions)
|
||||
- `read:org`
|
||||
- `read:user`
|
||||
- `write:repo` (for wiki access)
|
||||
4. Copy token immediately (shown only once)
|
||||
|
||||
### NetBox Configuration
|
||||
|
||||
```bash
|
||||
# ~/.config/claude/netbox.env
|
||||
NETBOX_API_URL=https://netbox.example.com
|
||||
NETBOX_API_TOKEN=your_netbox_token_here
|
||||
```
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `NETBOX_API_URL` | NetBox base URL | `https://netbox.example.com` |
|
||||
| `NETBOX_API_TOKEN` | API token | `abc123...` |
|
||||
|
||||
### Git-Flow Configuration
|
||||
|
||||
```bash
|
||||
# ~/.config/claude/git-flow.env
|
||||
GIT_WORKFLOW_STYLE=feature-branch
|
||||
GIT_DEFAULT_BASE=development
|
||||
GIT_AUTO_DELETE_MERGED=true
|
||||
GIT_AUTO_PUSH=false
|
||||
GIT_PROTECTED_BRANCHES=main,master,development,staging,production
|
||||
GIT_COMMIT_STYLE=conventional
|
||||
GIT_CO_AUTHOR=true
|
||||
```
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `GIT_WORKFLOW_STYLE` | `feature-branch` | Branching strategy |
|
||||
| `GIT_DEFAULT_BASE` | `development` | Default base branch |
|
||||
| `GIT_AUTO_DELETE_MERGED` | `true` | Delete merged branches |
|
||||
| `GIT_AUTO_PUSH` | `false` | Auto-push after commit |
|
||||
| `GIT_PROTECTED_BRANCHES` | `main,master,...` | Protected branches |
|
||||
| `GIT_COMMIT_STYLE` | `conventional` | Commit message style |
|
||||
| `GIT_CO_AUTHOR` | `true` | Include Claude co-author |
|
||||
|
||||
---
|
||||
|
||||
## Project-Level Configuration
|
||||
|
||||
Create `.env` in each project root:
|
||||
|
||||
```bash
|
||||
# Required for projman, pr-review (use owner/repo format)
|
||||
GITEA_REPO=your-organization/your-repo-name
|
||||
|
||||
# Optional: Override git-flow defaults
|
||||
GIT_WORKFLOW_STYLE=pr-required
|
||||
GIT_DEFAULT_BASE=main
|
||||
|
||||
# Optional: PR review settings
|
||||
PR_REVIEW_CONFIDENCE_THRESHOLD=0.5
|
||||
PR_REVIEW_AUTO_SUBMIT=false
|
||||
```
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `GITEA_REPO` | Yes | Repository in `owner/repo` format (e.g., `my-org/my-repo`) |
|
||||
| `GIT_WORKFLOW_STYLE` | No | Override system default |
|
||||
| `PR_REVIEW_*` | No | PR review settings |
|
||||
|
||||
---
|
||||
|
||||
## Plugin Configuration Summary
|
||||
|
||||
| Plugin | System Config | Project Config | Setup Commands |
|
||||
|--------|---------------|----------------|----------------|
|
||||
| **projman** | gitea.env | .env (GITEA_REPO=owner/repo) | `/initial-setup`, `/project-init`, `/project-sync` |
|
||||
| **pr-review** | gitea.env | .env (GITEA_REPO=owner/repo) | `/initial-setup`, `/project-init`, `/project-sync` |
|
||||
| **git-flow** | git-flow.env (optional) | .env (optional) | None needed |
|
||||
| **clarity-assist** | None | None | None needed |
|
||||
| **cmdb-assistant** | netbox.env | None | `/initial-setup` |
|
||||
| **data-platform** | postgres.env | .env (optional) | `/initial-setup` |
|
||||
| **viz-platform** | None | .env (optional DMC_VERSION) | `/initial-setup` |
|
||||
| **doc-guardian** | None | None | None needed |
|
||||
| **code-sentinel** | None | None | None needed |
|
||||
| **project-hygiene** | None | None | None needed |
|
||||
| **claude-config-maintainer** | None | None | None needed |
|
||||
|
||||
---
|
||||
|
||||
## Multi-Project Workflow
|
||||
|
||||
Once system-level config is set up, adding new projects is simple:
|
||||
|
||||
**Option 1: Use `/project-init` (faster)**
|
||||
```
|
||||
cd ~/projects/new-project
|
||||
/project-init
|
||||
```
|
||||
|
||||
**Option 2: Use `/initial-setup` (auto-detects)**
|
||||
```
|
||||
cd ~/projects/new-project
|
||||
/initial-setup
|
||||
# → Detects system config exists
|
||||
# → Offers "Quick project setup" option
|
||||
```
|
||||
|
||||
Both approaches work. Use `/project-init` when you know the system is already configured.
|
||||
|
||||
---
|
||||
|
||||
## Automatic Validation Features
|
||||
|
||||
### API Validation
|
||||
|
||||
When running `/initial-setup`, `/project-init`, or `/project-sync`, the commands:
|
||||
|
||||
1. **Detect** organization and repository from git remote URL
|
||||
2. **Validate** via Gitea API: `GET /api/v1/repos/{org}/{repo}`
|
||||
3. **Auto-fill** if repository exists and is accessible (no confirmation needed)
|
||||
4. **Ask for confirmation** only if validation fails (404 or permission error)
|
||||
|
||||
This catches typos and permission issues before saving configuration.
|
||||
|
||||
### Mismatch Detection (SessionStart Hook)
|
||||
|
||||
When you start a Claude Code session, a hook automatically:
|
||||
|
||||
1. Reads `GITEA_REPO` (in `owner/repo` format) from `.env`
|
||||
2. Compares with current `git remote get-url origin`
|
||||
3. **Warns** if mismatch detected: "Repository location mismatch. Run `/project-sync` to update."
|
||||
|
||||
This helps when you:
|
||||
- Move a repository to a different organization
|
||||
- Rename a repository
|
||||
- Clone a repo but forget to update `.env`
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Test Gitea Connection
|
||||
|
||||
```bash
|
||||
source ~/.config/claude/gitea.env
|
||||
curl -H "Authorization: token $GITEA_API_TOKEN" "$GITEA_API_URL/user"
|
||||
```
|
||||
|
||||
### Verify Project Setup
|
||||
|
||||
In Claude Code, after restarting your session:
|
||||
```
|
||||
/labels-sync
|
||||
```
|
||||
|
||||
If this works, your setup is complete.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### MCP tools not available
|
||||
|
||||
**Cause:** Session wasn't restarted after setup.
|
||||
**Solution:** Exit Claude Code and start a new session.
|
||||
|
||||
### "Configuration not found" error
|
||||
|
||||
```bash
|
||||
# Check system config exists
|
||||
ls -la ~/.config/claude/gitea.env
|
||||
|
||||
# Check permissions (should be 600)
|
||||
stat ~/.config/claude/gitea.env
|
||||
```
|
||||
|
||||
### Authentication failed
|
||||
|
||||
```bash
|
||||
# Test token directly
|
||||
source ~/.config/claude/gitea.env
|
||||
curl -H "Authorization: token $GITEA_API_TOKEN" "$GITEA_API_URL/user"
|
||||
```
|
||||
|
||||
If you get 401, regenerate your token in Gitea.
|
||||
|
||||
### MCP server won't start
|
||||
|
||||
```bash
|
||||
# Check venv exists
|
||||
ls /path/to/mcp-servers/gitea/.venv
|
||||
|
||||
# Reinstall if missing
|
||||
cd /path/to/mcp-servers/gitea
|
||||
rm -rf .venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
deactivate
|
||||
```
|
||||
|
||||
### Wrong repository
|
||||
|
||||
```bash
|
||||
# Check project .env
|
||||
cat .env
|
||||
|
||||
# Verify GITEA_REPO is in owner/repo format and matches Gitea exactly
|
||||
# Example: GITEA_REPO=my-org/my-repo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit tokens**
|
||||
- Keep credentials in `~/.config/claude/` only
|
||||
- Add `.env` to `.gitignore`
|
||||
|
||||
2. **Secure configuration files**
|
||||
```bash
|
||||
chmod 600 ~/.config/claude/*.env
|
||||
```
|
||||
|
||||
3. **Never type tokens into AI chat**
|
||||
- Always edit config files directly in your editor
|
||||
- The `/initial-setup` wizard respects this
|
||||
|
||||
4. **Rotate tokens periodically**
|
||||
- Every 6-12 months
|
||||
- Immediately if compromised
|
||||
|
||||
5. **Minimum permissions**
|
||||
- Only grant required token permissions
|
||||
- Use separate tokens for different environments
|
||||
299
docs/DEBUGGING-CHECKLIST.md
Normal file
299
docs/DEBUGGING-CHECKLIST.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# Debugging Checklist for Marketplace Troubleshooting
|
||||
|
||||
**Purpose:** Systematic approach to diagnose and fix plugin loading issues.
|
||||
|
||||
Last Updated: 2026-01-28
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Identify the Loading Path
|
||||
|
||||
Claude Code loads plugins from different locations depending on context:
|
||||
|
||||
| Location | Path | When Used |
|
||||
|----------|------|-----------|
|
||||
| **Source** | `~/claude-plugins-work/` | When developing in this directory |
|
||||
| **Installed** | `~/.claude/plugins/marketplaces/leo-claude-mktplace/` | After marketplace install |
|
||||
| **Cache** | `~/.claude/` | Plugin metadata, settings |
|
||||
|
||||
**Determine which path Claude is using:**
|
||||
|
||||
```bash
|
||||
# Check if installed marketplace exists
|
||||
ls -la ~/.claude/plugins/marketplaces/leo-claude-mktplace/
|
||||
|
||||
# Check Claude's current plugin loading
|
||||
cat ~/.claude/settings.local.json | grep -A5 "mcpServers"
|
||||
```
|
||||
|
||||
**Key insight:** If you're editing source but Claude uses installed, your changes won't take effect.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Verify Files Exist at Runtime Location
|
||||
|
||||
Check the files Claude will actually load:
|
||||
|
||||
```bash
|
||||
# For installed marketplace
|
||||
RUNTIME=~/.claude/plugins/marketplaces/leo-claude-mktplace
|
||||
|
||||
# Check MCP server exists
|
||||
ls -la $RUNTIME/mcp-servers/gitea/
|
||||
ls -la $RUNTIME/mcp-servers/netbox/
|
||||
|
||||
# Check plugin manifests
|
||||
ls -la $RUNTIME/plugins/projman/.claude-plugin/plugin.json
|
||||
ls -la $RUNTIME/plugins/pr-review/.claude-plugin/plugin.json
|
||||
|
||||
# Check .mcp.json files
|
||||
cat $RUNTIME/plugins/projman/.mcp.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Verify Virtual Environments Exist
|
||||
|
||||
**This is the most common failure point after installation.**
|
||||
|
||||
MCP servers require Python venvs to exist at the INSTALLED location:
|
||||
|
||||
```bash
|
||||
RUNTIME=~/.claude/plugins/marketplaces/leo-claude-mktplace
|
||||
|
||||
# Check venvs exist
|
||||
ls -la $RUNTIME/mcp-servers/gitea/.venv/bin/python
|
||||
ls -la $RUNTIME/mcp-servers/netbox/.venv/bin/python
|
||||
|
||||
# If missing, create them:
|
||||
cd $RUNTIME && ./scripts/setup.sh
|
||||
```
|
||||
|
||||
**Common error:** "X MCP servers failed to start" = venvs don't exist in installed path.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Verify Symlink Resolution
|
||||
|
||||
Plugins use symlinks to shared MCP servers. Verify they resolve correctly:
|
||||
|
||||
```bash
|
||||
RUNTIME=~/.claude/plugins/marketplaces/leo-claude-mktplace
|
||||
|
||||
# Check symlinks exist and resolve
|
||||
readlink -f $RUNTIME/plugins/projman/mcp-servers/gitea
|
||||
readlink -f $RUNTIME/plugins/pr-review/mcp-servers/gitea
|
||||
readlink -f $RUNTIME/plugins/cmdb-assistant/mcp-servers/netbox
|
||||
|
||||
# Should resolve to:
|
||||
# $RUNTIME/mcp-servers/gitea
|
||||
# $RUNTIME/mcp-servers/netbox
|
||||
```
|
||||
|
||||
**If broken:** Symlinks are relative. If directory structure differs, they'll break.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Test MCP Server Startup
|
||||
|
||||
Manually test if the MCP server can start:
|
||||
|
||||
```bash
|
||||
RUNTIME=~/.claude/plugins/marketplaces/leo-claude-mktplace
|
||||
|
||||
# Test Gitea MCP
|
||||
cd $RUNTIME/mcp-servers/gitea
|
||||
PYTHONPATH=. .venv/bin/python -c "from mcp_server.server import main; print('OK')"
|
||||
|
||||
# Test NetBox MCP
|
||||
cd $RUNTIME/mcp-servers/netbox
|
||||
PYTHONPATH=. .venv/bin/python -c "from mcp_server.server import main; print('OK')"
|
||||
```
|
||||
|
||||
**If import fails:** Check requirements.txt installed, check Python version compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Verify Configuration Files
|
||||
|
||||
Check environment variables are set:
|
||||
|
||||
```bash
|
||||
# System-level credentials (should exist)
|
||||
cat ~/.config/claude/gitea.env
|
||||
# Should contain: GITEA_API_URL, GITEA_API_TOKEN
|
||||
|
||||
cat ~/.config/claude/netbox.env
|
||||
# Should contain: NETBOX_API_URL, NETBOX_API_TOKEN
|
||||
|
||||
# Project-level config (in target project)
|
||||
cat /path/to/project/.env
|
||||
# Should contain: GITEA_REPO=owner/repo (e.g., my-org/my-repo)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Verify Hooks Configuration
|
||||
|
||||
Check hooks are valid:
|
||||
|
||||
```bash
|
||||
RUNTIME=~/.claude/plugins/marketplaces/leo-claude-mktplace
|
||||
|
||||
# List all hooks.json files
|
||||
find $RUNTIME/plugins -name "hooks.json" -exec echo "=== {} ===" \; -exec cat {} \;
|
||||
|
||||
# Verify hook events are valid
|
||||
# Valid: PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, SessionEnd,
|
||||
# Notification, Stop, SubagentStop, PreCompact
|
||||
# INVALID: task-completed, file-changed, git-commit-msg-needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Diagnostic Commands
|
||||
|
||||
Run these to quickly identify issues:
|
||||
|
||||
```bash
|
||||
RUNTIME=~/.claude/plugins/marketplaces/leo-claude-mktplace
|
||||
|
||||
echo "=== Installation Status ==="
|
||||
[ -d "$RUNTIME" ] && echo "Installed: YES" || echo "Installed: NO"
|
||||
|
||||
echo -e "\n=== Virtual Environments ==="
|
||||
[ -f "$RUNTIME/mcp-servers/gitea/.venv/bin/python" ] && echo "Gitea venv: OK" || echo "Gitea venv: MISSING"
|
||||
[ -f "$RUNTIME/mcp-servers/netbox/.venv/bin/python" ] && echo "NetBox venv: OK" || echo "NetBox venv: MISSING"
|
||||
|
||||
echo -e "\n=== Symlinks ==="
|
||||
[ -L "$RUNTIME/plugins/projman/mcp-servers/gitea" ] && echo "projman->gitea: OK" || echo "projman->gitea: MISSING"
|
||||
[ -L "$RUNTIME/plugins/pr-review/mcp-servers/gitea" ] && echo "pr-review->gitea: OK" || echo "pr-review->gitea: MISSING"
|
||||
[ -L "$RUNTIME/plugins/cmdb-assistant/mcp-servers/netbox" ] && echo "cmdb-assistant->netbox: OK" || echo "cmdb-assistant->netbox: MISSING"
|
||||
|
||||
echo -e "\n=== Config Files ==="
|
||||
[ -f ~/.config/claude/gitea.env ] && echo "gitea.env: OK" || echo "gitea.env: MISSING"
|
||||
[ -f ~/.config/claude/netbox.env ] && echo "netbox.env: OK" || echo "netbox.env: MISSING"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues and Fixes
|
||||
|
||||
| Issue | Symptom | Fix |
|
||||
|-------|---------|-----|
|
||||
| Missing venvs | "X MCP servers failed" | `cd ~/.claude/plugins/marketplaces/leo-claude-mktplace && ./scripts/setup.sh` |
|
||||
| Broken symlinks | MCP tools not available | Reinstall marketplace or manually recreate symlinks |
|
||||
| Wrong path edits | Changes don't take effect | Edit installed path or reinstall after source changes |
|
||||
| Missing credentials | MCP connection errors | Create `~/.config/claude/gitea.env` with API credentials |
|
||||
| Invalid hook events | Hooks don't fire | Use only valid event names (see Step 7) |
|
||||
| Gitea issues not closing | Merged to non-default branch | Manually close issues (see below) |
|
||||
| MCP changes not taking effect | Session caching | Restart Claude Code session (see below) |
|
||||
|
||||
### Gitea Auto-Close Behavior
|
||||
|
||||
**Issue:** Using `Closes #XX` or `Fixes #XX` in commit/PR messages does NOT auto-close issues when merging to `development`.
|
||||
|
||||
**Root Cause:** Gitea only auto-closes issues when merging to the **default branch** (typically `main` or `master`). Merging to `development`, `staging`, or any other branch will NOT trigger auto-close.
|
||||
|
||||
**Workaround:**
|
||||
1. Use the Gitea MCP tool to manually close issues after merging to development:
|
||||
```
|
||||
mcp__plugin_projman_gitea__update_issue(issue_number=XX, state="closed")
|
||||
```
|
||||
2. Or close issues via the Gitea web UI
|
||||
3. The auto-close keywords will still work when the changes are eventually merged to `main`
|
||||
|
||||
**Recommendation:** Include the `Closes #XX` keywords in commits anyway - they'll work when the final merge to `main` happens.
|
||||
|
||||
### MCP Session Restart Requirement
|
||||
|
||||
**Issue:** Changes to MCP servers, hooks, or plugin configuration don't take effect immediately.
|
||||
|
||||
**Root Cause:** Claude Code loads MCP tools and plugin configuration at session start. These are cached in session memory and not reloaded dynamically.
|
||||
|
||||
**What requires a session restart:**
|
||||
- MCP server code changes (Python files in `mcp-servers/`)
|
||||
- Changes to `.mcp.json` files
|
||||
- Changes to `hooks/hooks.json`
|
||||
- Changes to `plugin.json`
|
||||
- Adding new MCP tools or modifying tool signatures
|
||||
|
||||
**What does NOT require a restart:**
|
||||
- Command/skill markdown files (`.md`) - these are read on invocation
|
||||
- Agent markdown files - read when agent is invoked
|
||||
|
||||
**Correct workflow after plugin changes:**
|
||||
1. Make changes to source files
|
||||
2. Run `./scripts/verify-hooks.sh` to validate
|
||||
3. Inform user: "Please restart Claude Code for changes to take effect"
|
||||
4. **Do NOT clear cache mid-session** - see "Cache Clearing" section
|
||||
|
||||
---
|
||||
|
||||
## After Fixing Issues
|
||||
|
||||
1. **Restart Claude Code** - Plugins are loaded at startup
|
||||
2. **Verify fix works** - Run a simple command that uses the MCP
|
||||
3. **Document the issue** - If it's a new failure mode, add to this checklist
|
||||
|
||||
---
|
||||
|
||||
## Cache Clearing: When It's Safe vs Destructive
|
||||
|
||||
**⚠️ CRITICAL: Never clear plugin cache mid-session.**
|
||||
|
||||
### Why Cache Clearing Breaks MCP Tools
|
||||
|
||||
When Claude Code starts a session:
|
||||
1. MCP tools are loaded from the cache directory
|
||||
2. Tool definitions include **absolute paths** to the venv (e.g., `~/.claude/plugins/cache/.../venv/`)
|
||||
3. These paths are cached in the session memory
|
||||
4. Deleting the cache removes the venv, but the session still references the old paths
|
||||
5. Any MCP tool making HTTP requests fails with TLS certificate errors
|
||||
|
||||
### When Cache Clearing is SAFE
|
||||
|
||||
| Scenario | Safe? | Action |
|
||||
|----------|-------|--------|
|
||||
| Before starting Claude Code | ✅ Yes | Clear cache, then start session |
|
||||
| Between sessions | ✅ Yes | Clear cache after `/exit`, before next session |
|
||||
| During a session | ❌ NO | Never - will break MCP tools |
|
||||
| After plugin source edits | ❌ NO | Restart session instead |
|
||||
|
||||
### Recovery: MCP Tools Broken Mid-Session
|
||||
|
||||
If you accidentally cleared cache during a session and MCP tools fail:
|
||||
|
||||
```
|
||||
Error: Could not find a suitable TLS CA certificate bundle, invalid path:
|
||||
/home/.../.claude/plugins/cache/.../certifi/cacert.pem
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
1. Exit the current session (`/exit` or Ctrl+C)
|
||||
2. Start a new Claude Code session
|
||||
3. MCP tools will reload from the reinstalled cache
|
||||
|
||||
### Correct Workflow for Plugin Development
|
||||
|
||||
1. Make changes to plugin source files
|
||||
2. Run `./scripts/verify-hooks.sh` (verifies hook types)
|
||||
3. Tell user: "Please restart Claude Code for changes to take effect"
|
||||
4. **Do NOT clear cache** - session restart handles reloading
|
||||
|
||||
---
|
||||
|
||||
## Automated Diagnostics
|
||||
|
||||
Use these commands for automated checking:
|
||||
|
||||
- `/debug-report` - Run full diagnostics, create issue if problems found
|
||||
- `/debug-review` - Investigate existing diagnostic issues and propose fixes
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `CLAUDE.md` - Installation Paths and Troubleshooting sections
|
||||
- `docs/CONFIGURATION.md` - Setup and configuration guide
|
||||
- `docs/UPDATING.md` - Update procedures
|
||||
175
docs/UPDATING.md
175
docs/UPDATING.md
@@ -1,24 +1,71 @@
|
||||
# Updating support-claude-mktplace
|
||||
# Updating Leo Claude Marketplace
|
||||
|
||||
This guide covers how to update your local installation when new versions are released.
|
||||
|
||||
## Quick Update
|
||||
---
|
||||
|
||||
## ⚠️ CRITICAL: Run Setup in Installed Location
|
||||
|
||||
When Claude Code installs a marketplace, it copies files to `~/.claude/plugins/marketplaces/` but **does NOT create Python virtual environments**. You must run setup manually after installation or update.
|
||||
|
||||
**After installing or updating the marketplace:**
|
||||
|
||||
```bash
|
||||
# 1. Pull latest changes
|
||||
cd /path/to/support-claude-mktplace
|
||||
cd ~/.claude/plugins/marketplaces/leo-claude-mktplace && ./scripts/setup.sh
|
||||
```
|
||||
|
||||
This creates the required `.venv` directories for MCP servers. Without this step, **all MCP servers will fail to start**.
|
||||
|
||||
---
|
||||
|
||||
## Quick Update (Source Repository)
|
||||
|
||||
```bash
|
||||
# 1. Pull latest changes to source
|
||||
cd /path/to/leo-claude-mktplace
|
||||
git pull origin main
|
||||
|
||||
# 2. Run post-update script
|
||||
# 2. Run post-update script (updates source repo venvs)
|
||||
./scripts/post-update.sh
|
||||
|
||||
# 3. CRITICAL: Run setup in installed marketplace location
|
||||
cd ~/.claude/plugins/marketplaces/leo-claude-mktplace && ./scripts/setup.sh
|
||||
```
|
||||
|
||||
**Then restart your Claude Code session** to load any changes.
|
||||
|
||||
---
|
||||
|
||||
## What the Post-Update Script Does
|
||||
|
||||
1. **Updates Python dependencies** for Gitea and Wiki.js MCP servers
|
||||
1. **Updates Python dependencies** for MCP servers (gitea, netbox)
|
||||
2. **Shows recent changelog entries** so you know what changed
|
||||
3. **Validates your configuration** is still compatible
|
||||
|
||||
---
|
||||
|
||||
## After Updating: Re-run Setup if Needed
|
||||
|
||||
### When to Re-run `/initial-setup`
|
||||
|
||||
You typically **don't need** to re-run setup after updates. However, re-run if:
|
||||
|
||||
- Changelog mentions **new required environment variables**
|
||||
- Changelog mentions **breaking changes** to configuration
|
||||
- MCP tools stop working after update
|
||||
|
||||
### For Existing Projects
|
||||
|
||||
If an update requires new project-level configuration:
|
||||
|
||||
```
|
||||
/project-init
|
||||
```
|
||||
|
||||
This will detect existing settings and only add what's missing.
|
||||
|
||||
---
|
||||
|
||||
## Manual Steps After Update
|
||||
|
||||
Some updates may require manual configuration changes:
|
||||
@@ -29,8 +76,7 @@ If the changelog mentions new environment variables:
|
||||
|
||||
1. Check the variable name and purpose in the changelog
|
||||
2. Add it to the appropriate config file:
|
||||
- Gitea variables → `~/.config/claude/gitea.env`
|
||||
- Wiki.js variables → `~/.config/claude/wikijs.env`
|
||||
- System variables → `~/.config/claude/gitea.env` or `netbox.env`
|
||||
- Project variables → `.env` in your project root
|
||||
|
||||
### New MCP Server Features
|
||||
@@ -38,14 +84,50 @@ If the changelog mentions new environment variables:
|
||||
If a new MCP server tool is added:
|
||||
|
||||
1. The post-update script handles dependency installation
|
||||
2. Check `docs/references/MCP-*.md` for usage documentation
|
||||
3. New tools are available immediately after update
|
||||
2. Check plugin documentation for usage
|
||||
3. New tools are available immediately after session restart
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
Breaking changes will be clearly marked in CHANGELOG.md with migration instructions.
|
||||
|
||||
## Troubleshooting
|
||||
### Setup Script and Configuration Workflow Changes
|
||||
|
||||
When updating, review if changes affect the setup workflow:
|
||||
|
||||
1. **Check for setup command changes:**
|
||||
```bash
|
||||
git diff HEAD~1 plugins/*/commands/initial-setup.md
|
||||
git diff HEAD~1 plugins/*/commands/project-init.md
|
||||
git diff HEAD~1 plugins/*/commands/project-sync.md
|
||||
```
|
||||
|
||||
2. **Check for hook changes:**
|
||||
```bash
|
||||
git diff HEAD~1 plugins/*/hooks/hooks.json
|
||||
```
|
||||
|
||||
3. **Check for configuration structure changes:**
|
||||
```bash
|
||||
git diff HEAD~1 docs/CONFIGURATION.md
|
||||
```
|
||||
|
||||
**If setup commands changed:**
|
||||
- Review what's new (new validation steps, new prompts, etc.)
|
||||
- Consider re-running `/initial-setup` or `/project-init` to benefit from improvements
|
||||
- Existing configurations remain valid unless changelog notes breaking changes
|
||||
|
||||
**If hooks changed:**
|
||||
- Restart your Claude Code session to load new hooks
|
||||
- New hooks (like SessionStart validation) activate automatically
|
||||
|
||||
**If configuration structure changed:**
|
||||
- Check if new variables are required
|
||||
- Run `/project-sync` if repository detection logic improved
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting Updates
|
||||
|
||||
### Dependencies fail to install
|
||||
|
||||
@@ -57,27 +139,52 @@ python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
deactivate
|
||||
|
||||
# Repeat for wikijs
|
||||
cd ../wikijs
|
||||
rm -rf .venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
deactivate
|
||||
```
|
||||
|
||||
### Configuration no longer works
|
||||
|
||||
1. Check CHANGELOG.md for breaking changes
|
||||
2. Compare your config files with updated `.env.example` (if provided)
|
||||
3. Run `./scripts/setup.sh` to validate configuration
|
||||
2. Run `/initial-setup` to re-validate and fix configuration
|
||||
3. Compare your config files with documentation in `docs/CONFIGURATION.md`
|
||||
|
||||
### MCP server won't start
|
||||
### MCP server won't start after update
|
||||
|
||||
**Most common cause:** Virtual environments don't exist in the installed marketplace.
|
||||
|
||||
```bash
|
||||
# Fix: Run setup in installed location
|
||||
cd ~/.claude/plugins/marketplaces/leo-claude-mktplace && ./scripts/setup.sh
|
||||
```
|
||||
|
||||
If that doesn't work:
|
||||
|
||||
1. Check Python version: `python3 --version` (requires 3.10+)
|
||||
2. Verify venv exists: `ls mcp-servers/gitea/.venv`
|
||||
3. Check logs for specific errors
|
||||
2. Verify venv exists in INSTALLED location:
|
||||
```bash
|
||||
ls ~/.claude/plugins/marketplaces/leo-claude-mktplace/mcp-servers/gitea/.venv
|
||||
ls ~/.claude/plugins/marketplaces/leo-claude-mktplace/mcp-servers/netbox/.venv
|
||||
```
|
||||
3. If missing, the symlinks won't resolve. Run setup.sh as shown above.
|
||||
4. Restart Claude Code session
|
||||
5. Check logs for specific errors
|
||||
|
||||
### "X MCP servers failed" on startup
|
||||
|
||||
This almost always means the venvs don't exist in the installed marketplace:
|
||||
|
||||
```bash
|
||||
cd ~/.claude/plugins/marketplaces/leo-claude-mktplace && ./scripts/setup.sh
|
||||
```
|
||||
|
||||
Then restart Claude Code.
|
||||
|
||||
### New commands not available
|
||||
|
||||
1. Restart your Claude Code session
|
||||
2. Verify the plugin is still installed
|
||||
3. Check if the command requires additional setup
|
||||
|
||||
---
|
||||
|
||||
## Version Pinning
|
||||
|
||||
@@ -88,14 +195,28 @@ To stay on a specific version:
|
||||
git tag
|
||||
|
||||
# Checkout specific version
|
||||
git checkout v1.0.0
|
||||
git checkout v3.0.0
|
||||
|
||||
# Run post-update
|
||||
./scripts/post-update.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checking Current Version
|
||||
|
||||
The version is displayed in the main README.md title and in `CHANGELOG.md`.
|
||||
|
||||
```bash
|
||||
# Check version from changelog
|
||||
head -20 CHANGELOG.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Check `docs/references/` for component documentation
|
||||
- Review CHANGELOG.md for recent changes
|
||||
- Check `docs/CONFIGURATION.md` for setup guide
|
||||
- Check `docs/COMMANDS-CHEATSHEET.md` for command reference
|
||||
- Review `CHANGELOG.md` for recent changes
|
||||
- Search existing issues in Gitea
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Target File:** `docs/architecture/agent-workflow.drawio`
|
||||
|
||||
**Purpose:** Shows when Planner, Orchestrator, and Executor agents trigger during sprint lifecycle.
|
||||
**Purpose:** Shows when Planner, Orchestrator, Executor, and Code Reviewer agents trigger during sprint lifecycle.
|
||||
|
||||
**Diagram Type:** Swimlane / Sequence Diagram
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
| planner-lane | Planner Agent | #4A90D9 | 2 |
|
||||
| orchestrator-lane | Orchestrator Agent | #7CB342 | 3 |
|
||||
| executor-lane | Executor Agent | #FF9800 | 4 |
|
||||
| gitea-lane | Gitea | #9E9E9E | 5 |
|
||||
| wikijs-lane | Wiki.js | #9E9E9E | 6 (rightmost) |
|
||||
| reviewer-lane | Code Reviewer Agent | #9C27B0 | 5 |
|
||||
| gitea-lane | Gitea (Issues + Wiki) | #9E9E9E | 6 (rightmost) |
|
||||
|
||||
---
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
| p1-start | /sprint-plan | rounded-rect | user-lane | 1 |
|
||||
| p1-activate | Planner Activates | rectangle | planner-lane | 2 |
|
||||
| p1-search-lessons | Search Lessons Learned | rectangle | planner-lane | 3 |
|
||||
| p1-wikijs-query | Query Past Lessons | rectangle | wikijs-lane | 4 |
|
||||
| p1-gitea-wiki-query | Query Past Lessons (Wiki) | rectangle | gitea-lane | 4 |
|
||||
| p1-return-lessons | Return Relevant Lessons | rectangle | planner-lane | 5 |
|
||||
| p1-clarify | Ask Clarifying Questions | diamond | planner-lane | 6 |
|
||||
| p1-user-answers | Provide Answers | rectangle | user-lane | 7 |
|
||||
@@ -44,8 +44,8 @@
|
||||
|------|----|-------|-------|
|
||||
| p1-start | p1-activate | invokes | solid |
|
||||
| p1-activate | p1-search-lessons | | solid |
|
||||
| p1-search-lessons | p1-wikijs-query | GraphQL search | solid |
|
||||
| p1-wikijs-query | p1-return-lessons | lessons data | dashed |
|
||||
| p1-search-lessons | p1-gitea-wiki-query | REST API (search_lessons) | solid |
|
||||
| p1-gitea-wiki-query | p1-return-lessons | lessons data | dashed |
|
||||
| p1-return-lessons | p1-clarify | | solid |
|
||||
| p1-clarify | p1-user-answers | questions | solid |
|
||||
| p1-user-answers | p1-clarify | answers | dashed |
|
||||
@@ -65,7 +65,7 @@
|
||||
| p2-orch-activate | Orchestrator Activates | rectangle | orchestrator-lane | 12 |
|
||||
| p2-fetch-issues | Fetch Sprint Issues | rectangle | orchestrator-lane | 13 |
|
||||
| p2-gitea-list | List Open Issues | rectangle | gitea-lane | 14 |
|
||||
| p2-sequence | Sequence Work | rectangle | orchestrator-lane | 15 |
|
||||
| p2-sequence | Sequence Work (Dependencies) | rectangle | orchestrator-lane | 15 |
|
||||
| p2-dispatch | Dispatch Task | rectangle | orchestrator-lane | 16 |
|
||||
| p2-exec-activate | Executor Activates | rectangle | executor-lane | 17 |
|
||||
| p2-implement | Implement Task | rectangle | executor-lane | 18 |
|
||||
@@ -83,7 +83,7 @@
|
||||
| p2-orch-activate | p2-fetch-issues | | solid |
|
||||
| p2-fetch-issues | p2-gitea-list | REST API | solid |
|
||||
| p2-gitea-list | p2-sequence | issues data | dashed |
|
||||
| p2-sequence | p2-dispatch | | solid |
|
||||
| p2-sequence | p2-dispatch | parallel batching | solid |
|
||||
| p2-dispatch | p2-exec-activate | execution prompt | solid |
|
||||
| p2-exec-activate | p2-implement | | solid |
|
||||
| p2-implement | p2-update-status | | solid |
|
||||
@@ -95,23 +95,50 @@
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2.5: CODE REVIEW (Pre-Close)
|
||||
|
||||
### Nodes
|
||||
|
||||
| ID | Label | Type | Lane | Sequence |
|
||||
|----|-------|------|------|----------|
|
||||
| p25-start | /review | rounded-rect | user-lane | 24 |
|
||||
| p25-reviewer-activate | Code Reviewer Activates | rectangle | reviewer-lane | 25 |
|
||||
| p25-scan-changes | Scan Recent Changes | rectangle | reviewer-lane | 26 |
|
||||
| p25-check-quality | Check Code Quality | rectangle | reviewer-lane | 27 |
|
||||
| p25-security-scan | Security Scan | rectangle | reviewer-lane | 28 |
|
||||
| p25-report | Generate Review Report | rectangle | reviewer-lane | 29 |
|
||||
| p25-complete | Review Complete | rounded-rect | reviewer-lane | 30 |
|
||||
|
||||
### Edges
|
||||
|
||||
| From | To | Label | Style |
|
||||
|------|----|-------|-------|
|
||||
| p25-start | p25-reviewer-activate | invokes | solid |
|
||||
| p25-reviewer-activate | p25-scan-changes | | solid |
|
||||
| p25-scan-changes | p25-check-quality | | solid |
|
||||
| p25-check-quality | p25-security-scan | | solid |
|
||||
| p25-security-scan | p25-report | | solid |
|
||||
| p25-report | p25-complete | | solid |
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3: SPRINT CLOSE
|
||||
|
||||
### Nodes
|
||||
|
||||
| ID | Label | Type | Lane | Sequence |
|
||||
|----|-------|------|------|----------|
|
||||
| p3-start | /sprint-close | rounded-rect | user-lane | 24 |
|
||||
| p3-orch-activate | Orchestrator Activates | rectangle | orchestrator-lane | 25 |
|
||||
| p3-review | Review Sprint | rectangle | orchestrator-lane | 26 |
|
||||
| p3-gitea-status | Get Final Status | rectangle | gitea-lane | 27 |
|
||||
| p3-capture | Capture Lessons Learned | rectangle | orchestrator-lane | 28 |
|
||||
| p3-user-input | Confirm Lessons | diamond | user-lane | 29 |
|
||||
| p3-create-wiki | Create Wiki Pages | rectangle | orchestrator-lane | 30 |
|
||||
| p3-wikijs-create | Store Lessons | rectangle | wikijs-lane | 31 |
|
||||
| p3-close-issues | Close Issues | rectangle | orchestrator-lane | 32 |
|
||||
| p3-gitea-close | Mark Closed | rectangle | gitea-lane | 33 |
|
||||
| p3-complete | Sprint Closed | rounded-rect | orchestrator-lane | 34 |
|
||||
| p3-start | /sprint-close | rounded-rect | user-lane | 31 |
|
||||
| p3-orch-activate | Orchestrator Activates | rectangle | orchestrator-lane | 32 |
|
||||
| p3-review | Review Sprint | rectangle | orchestrator-lane | 33 |
|
||||
| p3-gitea-status | Get Final Status | rectangle | gitea-lane | 34 |
|
||||
| p3-capture | Capture Lessons Learned | rectangle | orchestrator-lane | 35 |
|
||||
| p3-user-input | Confirm Lessons | diamond | user-lane | 36 |
|
||||
| p3-create-wiki | Create Wiki Pages | rectangle | orchestrator-lane | 37 |
|
||||
| p3-gitea-wiki-create | Store Lessons (Wiki) | rectangle | gitea-lane | 38 |
|
||||
| p3-close-issues | Close Issues | rectangle | orchestrator-lane | 39 |
|
||||
| p3-gitea-close | Mark Closed | rectangle | gitea-lane | 40 |
|
||||
| p3-complete | Sprint Closed | rounded-rect | orchestrator-lane | 41 |
|
||||
|
||||
### Edges
|
||||
|
||||
@@ -123,8 +150,8 @@
|
||||
| p3-gitea-status | p3-capture | status data | dashed |
|
||||
| p3-capture | p3-user-input | proposed lessons | solid |
|
||||
| p3-user-input | p3-create-wiki | confirmed | solid |
|
||||
| p3-create-wiki | p3-wikijs-create | GraphQL mutation | solid |
|
||||
| p3-wikijs-create | p3-close-issues | confirm | dashed |
|
||||
| p3-create-wiki | p3-gitea-wiki-create | REST API (create_lesson) | solid |
|
||||
| p3-gitea-wiki-create | p3-close-issues | confirm | dashed |
|
||||
| p3-close-issues | p3-gitea-close | REST API | solid |
|
||||
| p3-gitea-close | p3-complete | confirm | dashed |
|
||||
|
||||
@@ -133,59 +160,71 @@
|
||||
## LAYOUT NOTES
|
||||
|
||||
```
|
||||
+--------+------------+---------------+------------+--------+----------+
|
||||
| User | Planner | Orchestrator | Executor | Gitea | Wiki.js |
|
||||
+--------+------------+---------------+------------+--------+----------+
|
||||
+--------+------------+---------------+------------+----------+------------------+
|
||||
| User | Planner | Orchestrator | Executor | Reviewer | Gitea |
|
||||
| | | | | | (Issues + Wiki) |
|
||||
+--------+------------+---------------+------------+----------+------------------+
|
||||
| | | | | | |
|
||||
| PHASE 1: SPRINT PLANNING |
|
||||
|---------------------------------------------------------------------+
|
||||
|-------------------------------------------------------------------------------|
|
||||
| O | | | | | |
|
||||
| | | | | | | |
|
||||
| +---->| O | | | | |
|
||||
| | | | | | | |
|
||||
| | +----------|---------------|------------|------->| O |
|
||||
| | |<---------|---------------|------------|--------+ | |
|
||||
| | +----------|---------------|------------|--------->| O (Wiki Query) |
|
||||
| | |<---------|---------------|------------|----------+ | |
|
||||
| | | | | | | |
|
||||
| | O<> | | | | |
|
||||
| O<--->+ | | | | | |
|
||||
| | | | | | | |
|
||||
| | +----------|---------------|----------->| O | |
|
||||
| | +----------|---------------|------------|--------->| O (Issues) |
|
||||
| | O | | | | |
|
||||
| | | | | | |
|
||||
|---------------------------------------------------------------------+
|
||||
|-------------------------------------------------------------------------------|
|
||||
| PHASE 2: SPRINT EXECUTION |
|
||||
|---------------------------------------------------------------------+
|
||||
|-------------------------------------------------------------------------------|
|
||||
| O | | | | | |
|
||||
| | | | | | | |
|
||||
| +-----|----------->| O | | | |
|
||||
| | | | | | | |
|
||||
| | | +-------------|----------->| O | |
|
||||
| | | |<------------|------------+ | | |
|
||||
| | | +-------------|------------|--------->| O (Issues) |
|
||||
| | | |<------------|------------|----------+ | |
|
||||
| | | | | | | |
|
||||
| | | +------------>| O | | |
|
||||
| | | | | | | |
|
||||
| | | | +--------->| O | |
|
||||
| | | | |<---------+ | | |
|
||||
| | | | +----------|--------->| O (Issues) |
|
||||
| | | | |<---------|----------+ | |
|
||||
| | | O<------------+ | | | |
|
||||
| | | | | | | |
|
||||
| | | O (loop) | | | |
|
||||
| | | | | | |
|
||||
|---------------------------------------------------------------------+
|
||||
|-------------------------------------------------------------------------------|
|
||||
| PHASE 2.5: CODE REVIEW |
|
||||
|-------------------------------------------------------------------------------|
|
||||
| O | | | | | |
|
||||
| | | | | | | |
|
||||
| +-----|------------|---------------|----------->| O | |
|
||||
| | | | | | | |
|
||||
| | | | | O->O->O | |
|
||||
| | | | | | | |
|
||||
| | | | | O | |
|
||||
| | | | | | |
|
||||
|-------------------------------------------------------------------------------|
|
||||
| PHASE 3: SPRINT CLOSE |
|
||||
|---------------------------------------------------------------------+
|
||||
|-------------------------------------------------------------------------------|
|
||||
| O | | | | | |
|
||||
| | | | | | | |
|
||||
| +-----|----------->| O | | | |
|
||||
| | | +-------------|----------->| O | |
|
||||
| | | |<------------|------------+ | | |
|
||||
| | | +-------------|------------|--------->| O (Issues) |
|
||||
| | | |<------------|------------|----------+ | |
|
||||
| | | | | | | |
|
||||
| O<----|-----------<+ | | | | |
|
||||
| +-----|----------->| | | | | |
|
||||
| | | +-------------|------------|------->| O |
|
||||
| | | |<------------|------------|--------+ | |
|
||||
| | | +-------------|----------->| O | |
|
||||
| | | +-------------|------------|--------->| O (Wiki Create) |
|
||||
| | | |<------------|------------|----------+ | |
|
||||
| | | +-------------|------------|--------->| O (Issues Close) |
|
||||
| | | O | | | |
|
||||
+--------+------------+---------------+------------+--------+----------+
|
||||
+--------+------------+---------------+------------+----------+------------------+
|
||||
```
|
||||
|
||||
---
|
||||
@@ -198,7 +237,8 @@
|
||||
| Blue | #4A90D9 | Planner Agent |
|
||||
| Green | #7CB342 | Orchestrator Agent |
|
||||
| Orange | #FF9800 | Executor Agent |
|
||||
| Gray | #9E9E9E | External Services |
|
||||
| Purple | #9C27B0 | Code Reviewer Agent |
|
||||
| Gray | #9E9E9E | External Services (Gitea) |
|
||||
|
||||
---
|
||||
|
||||
@@ -219,3 +259,13 @@
|
||||
|-------|---------|
|
||||
| Solid | Action/Request |
|
||||
| Dashed | Response/Data return |
|
||||
|
||||
---
|
||||
|
||||
## ARCHITECTURE NOTES
|
||||
|
||||
- **Gitea provides BOTH issue tracking AND wiki** (no separate wiki service)
|
||||
- All wiki operations use Gitea REST API via MCP tools
|
||||
- Lessons learned stored in Gitea Wiki under `lessons-learned/sprints/`
|
||||
- MCP tools: `search_lessons`, `create_lesson`, `list_wiki_pages`, `get_wiki_page`
|
||||
- Four-agent model: Planner, Orchestrator, Executor, Code Reviewer
|
||||
|
||||
@@ -13,22 +13,26 @@
|
||||
| ID | Label | Type | Color | Position |
|
||||
|----|-------|------|-------|----------|
|
||||
| projman | projman | rectangle | #4A90D9 | top-center |
|
||||
| projman-pmo | projman-pmo | rectangle | #4A90D9 | top-right |
|
||||
| projman-pmo | projman-pmo (planned) | rectangle | #4A90D9 | top-right |
|
||||
| project-hygiene | project-hygiene | rectangle | #4A90D9 | top-left |
|
||||
| claude-config | claude-config-maintainer | rectangle | #4A90D9 | bottom-left |
|
||||
| cmdb-assistant | cmdb-assistant | rectangle | #4A90D9 | bottom-right |
|
||||
|
||||
### MCP Servers (Green - #7CB342)
|
||||
|
||||
| ID | Label | Type | Color | Position |
|
||||
|----|-------|------|-------|----------|
|
||||
| gitea-mcp | Gitea MCP Server | rectangle | #7CB342 | middle-left |
|
||||
| wikijs-mcp | Wiki.js MCP Server | rectangle | #7CB342 | middle-right |
|
||||
MCP servers are **bundled inside each plugin** that needs them.
|
||||
|
||||
| ID | Label | Type | Color | Position | Bundled In |
|
||||
|----|-------|------|-------|----------|------------|
|
||||
| gitea-mcp | Gitea MCP Server | rectangle | #7CB342 | middle-left | projman |
|
||||
| netbox-mcp | NetBox MCP Server | rectangle | #7CB342 | middle-right | cmdb-assistant |
|
||||
|
||||
### External Systems (Gray - #9E9E9E)
|
||||
|
||||
| ID | Label | Type | Color | Position |
|
||||
|----|-------|------|-------|----------|
|
||||
| gitea-instance | Gitea\ngitea.hotserv.cloud | cylinder | #9E9E9E | bottom-left |
|
||||
| wikijs-instance | Wiki.js\nwikijs.hotserv.cloud | cylinder | #9E9E9E | bottom-right |
|
||||
| gitea-instance | Gitea\n(Issues + Wiki) | cylinder | #9E9E9E | bottom-left |
|
||||
| netbox-instance | NetBox | cylinder | #9E9E9E | bottom-right |
|
||||
|
||||
### Configuration (Orange - #FF9800)
|
||||
|
||||
@@ -45,10 +49,8 @@
|
||||
|
||||
| From | To | Label | Style | Arrow |
|
||||
|------|----|-------|-------|-------|
|
||||
| projman | gitea-mcp | uses | solid | forward |
|
||||
| projman | wikijs-mcp | uses | solid | forward |
|
||||
| projman-pmo | gitea-mcp | uses (company-wide) | solid | forward |
|
||||
| projman-pmo | wikijs-mcp | uses (company-wide) | solid | forward |
|
||||
| projman | gitea-mcp | bundled | solid | bidirectional |
|
||||
| cmdb-assistant | netbox-mcp | bundled | solid | bidirectional |
|
||||
|
||||
### Plugin Dependencies
|
||||
|
||||
@@ -61,16 +63,16 @@
|
||||
| From | To | Label | Style | Arrow |
|
||||
|------|----|-------|-------|-------|
|
||||
| gitea-mcp | gitea-instance | REST API | solid | forward |
|
||||
| wikijs-mcp | wikijs-instance | GraphQL | solid | forward |
|
||||
| netbox-mcp | netbox-instance | REST API | solid | forward |
|
||||
|
||||
### Configuration Connections
|
||||
|
||||
| From | To | Label | Style | Arrow |
|
||||
|------|----|-------|-------|-------|
|
||||
| system-config | gitea-mcp | credentials | dashed | forward |
|
||||
| system-config | wikijs-mcp | credentials | dashed | forward |
|
||||
| system-config | netbox-mcp | credentials | dashed | forward |
|
||||
| project-config | gitea-mcp | repo context | dashed | forward |
|
||||
| project-config | wikijs-mcp | project path | dashed | forward |
|
||||
| project-config | netbox-mcp | site context | dashed | forward |
|
||||
|
||||
---
|
||||
|
||||
@@ -78,9 +80,8 @@
|
||||
|
||||
| ID | Label | Contains | Style |
|
||||
|----|-------|----------|-------|
|
||||
| plugins-group | Plugins | projman, projman-pmo, project-hygiene | light blue border |
|
||||
| mcp-group | Shared MCP Servers | gitea-mcp, wikijs-mcp | light green border |
|
||||
| external-group | External Services | gitea-instance, wikijs-instance | light gray border |
|
||||
| plugins-group | Plugins | projman, projman-pmo, project-hygiene, claude-config, cmdb-assistant | light blue border |
|
||||
| external-group | External Services | gitea-instance, netbox-instance | light gray border |
|
||||
| config-group | Configuration | system-config, project-config | light orange border |
|
||||
|
||||
---
|
||||
@@ -92,25 +93,21 @@
|
||||
| PLUGINS GROUP |
|
||||
| +----------------+ +----------------+ +-------------------+ |
|
||||
| | project- | | projman | | projman-pmo | |
|
||||
| | hygiene | | | | | |
|
||||
| +----------------+ +-------+--------+ +--------+----------+ |
|
||||
| | | |
|
||||
| | hygiene | | [gitea-mcp] | | (planned) | |
|
||||
| +----------------+ +-------+--------+ +-------------------+ |
|
||||
| | |
|
||||
| +----------------+ +-------------------+ |
|
||||
| | claude-config | | cmdb-assistant | |
|
||||
| | -maintainer | | [netbox-mcp] | |
|
||||
| +----------------+ +--------+----------+ |
|
||||
+------------------------------------------------------------------+
|
||||
| |
|
||||
v v
|
||||
+------------------------------------------------------------------+
|
||||
| MCP SERVERS GROUP |
|
||||
| +-------------------+ +-------------------+ |
|
||||
| | Gitea MCP Server | | Wiki.js MCP Server| |
|
||||
| +--------+----------+ +---------+---------+ |
|
||||
+------------------------------------------------------------------+
|
||||
| |
|
||||
v v
|
||||
|
|
||||
v
|
||||
+------------------------------------------------------------------+
|
||||
| EXTERNAL SERVICES GROUP |
|
||||
| +-------------------+ +-------------------+ |
|
||||
| | Gitea | | Wiki.js | |
|
||||
| | gitea.hotserv.cloud | wikijs.hotserv.cloud |
|
||||
| | Gitea | | NetBox | |
|
||||
| | (Issues + Wiki) | | | |
|
||||
| +-------------------+ +-------------------+ |
|
||||
+------------------------------------------------------------------+
|
||||
|
||||
@@ -128,6 +125,15 @@ CONFIG GROUP (left side): CONFIG GROUP (right side):
|
||||
| Color | Hex | Meaning |
|
||||
|-------|-----|---------|
|
||||
| Blue | #4A90D9 | Plugins |
|
||||
| Green | #7CB342 | MCP Servers |
|
||||
| Green | #7CB342 | MCP Servers (bundled in plugins) |
|
||||
| Gray | #9E9E9E | External Systems |
|
||||
| Orange | #FF9800 | Configuration |
|
||||
|
||||
---
|
||||
|
||||
## ARCHITECTURE NOTES
|
||||
|
||||
- MCP servers are **bundled inside plugins** (not shared at root)
|
||||
- Gitea provides both issue tracking AND wiki (lessons learned)
|
||||
- No separate Wiki.js - all wiki functionality uses Gitea Wiki
|
||||
- Each plugin is self-contained for Claude Code caching
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,692 +0,0 @@
|
||||
# Project Management Plugins - Project Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This project builds two Claude Code plugins that transform a proven 15-sprint workflow into reusable, distributable tools for managing software development with Gitea, Wiki.js, and agile methodologies.
|
||||
|
||||
**Status:** Planning phase complete, ready for implementation
|
||||
|
||||
---
|
||||
|
||||
## The Two Plugins
|
||||
|
||||
### 1. projman (Single-Repository)
|
||||
|
||||
**Purpose:** Project management for individual repositories
|
||||
**Users:** Developers, Team Leads
|
||||
**Build Order:** Build FIRST
|
||||
|
||||
**Key Features:**
|
||||
- Sprint planning with AI agents
|
||||
- Issue creation with 43-label taxonomy
|
||||
- Lessons learned capture in Wiki.js
|
||||
- Branch-aware security model
|
||||
- Hybrid configuration system
|
||||
|
||||
**Reference:** [PLUGIN-PROJMAN.md](./PLUGIN-PROJMAN.md)
|
||||
|
||||
### 2. projman-pmo (Multi-Project)
|
||||
|
||||
**Purpose:** PMO coordination across organization
|
||||
**Users:** PMO Coordinators, Engineering Managers, CTOs
|
||||
**Build Order:** Build SECOND (after projman validated)
|
||||
|
||||
**Key Features:**
|
||||
- Cross-project status aggregation
|
||||
- Dependency tracking and visualization
|
||||
- Resource conflict detection
|
||||
- Release coordination
|
||||
- Company-wide lessons learned search
|
||||
|
||||
**Reference:** [PLUGIN-PMO.md](./PLUGIN-PMO.md)
|
||||
|
||||
---
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Shared MCP Servers
|
||||
|
||||
Both plugins share the same MCP server codebase at repository root (`mcp-servers/`):
|
||||
|
||||
**1. Gitea MCP Server**
|
||||
- Issue management (CRUD operations)
|
||||
- Label taxonomy system (43 labels)
|
||||
- Mode detection (project vs company-wide)
|
||||
|
||||
**Reference:** [MCP-GITEA.md](./MCP-GITEA.md)
|
||||
|
||||
**2. Wiki.js MCP Server**
|
||||
- Documentation management
|
||||
- Lessons learned capture and search
|
||||
- GraphQL API integration
|
||||
- Company-wide knowledge base
|
||||
|
||||
**Reference:** [MCP-WIKIJS.md](./MCP-WIKIJS.md)
|
||||
|
||||
### Mode Detection
|
||||
|
||||
The MCP servers detect their operating mode based on environment variables:
|
||||
|
||||
**Project Mode (projman):**
|
||||
- `GITEA_REPO` present → operates on single repository
|
||||
- `WIKIJS_PROJECT` present → operates on single project path
|
||||
|
||||
**Company Mode (pmo):**
|
||||
- No `GITEA_REPO` → operates on all repositories
|
||||
- No `WIKIJS_PROJECT` → operates on entire company namespace
|
||||
|
||||
---
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
personal-projects/support-claude-mktplace/
|
||||
├── mcp-servers/ # ← SHARED BY BOTH PLUGINS
|
||||
│ ├── gitea/ # Gitea MCP Server
|
||||
│ │ ├── .venv/
|
||||
│ │ ├── requirements.txt
|
||||
│ │ ├── mcp_server/
|
||||
│ │ └── tests/
|
||||
│ └── wikijs/ # Wiki.js MCP Server
|
||||
│ ├── .venv/
|
||||
│ ├── requirements.txt
|
||||
│ ├── mcp_server/
|
||||
│ └── tests/
|
||||
├── projman/ # ← PROJECT PLUGIN
|
||||
│ ├── .claude-plugin/
|
||||
│ │ └── plugin.json
|
||||
│ ├── .mcp.json # Points to ../mcp-servers/
|
||||
│ ├── commands/
|
||||
│ │ ├── sprint-plan.md
|
||||
│ │ ├── sprint-start.md
|
||||
│ │ ├── sprint-status.md
|
||||
│ │ ├── sprint-close.md
|
||||
│ │ └── labels-sync.md
|
||||
│ ├── agents/
|
||||
│ │ ├── planner.md
|
||||
│ │ ├── orchestrator.md
|
||||
│ │ └── executor.md
|
||||
│ ├── skills/
|
||||
│ │ └── label-taxonomy/
|
||||
│ ├── README.md
|
||||
│ └── CONFIGURATION.md
|
||||
└── projman-pmo/ # ← PMO PLUGIN
|
||||
├── .claude-plugin/
|
||||
│ └── plugin.json
|
||||
├── .mcp.json # Points to ../mcp-servers/
|
||||
├── commands/
|
||||
│ ├── pmo-status.md
|
||||
│ ├── pmo-priorities.md
|
||||
│ ├── pmo-dependencies.md
|
||||
│ └── pmo-schedule.md
|
||||
├── agents/
|
||||
│ └── pmo-coordinator.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Architecture
|
||||
|
||||
### Hybrid Configuration System
|
||||
|
||||
The plugins use a hybrid configuration approach that balances security and flexibility:
|
||||
|
||||
**System-Level (Once per machine):**
|
||||
- `~/.config/claude/gitea.env` - Gitea credentials
|
||||
- `~/.config/claude/wikijs.env` - Wiki.js credentials
|
||||
|
||||
**Project-Level (Per repository):**
|
||||
- `project-root/.env` - Repository and project paths
|
||||
|
||||
**Benefits:**
|
||||
- Single token per service (update once)
|
||||
- Project isolation
|
||||
- Security (tokens never committed)
|
||||
- Easy multi-project setup
|
||||
|
||||
### Configuration Example
|
||||
|
||||
**System-Level:**
|
||||
```bash
|
||||
# ~/.config/claude/gitea.env
|
||||
GITEA_API_URL=https://gitea.example.com/api/v1
|
||||
GITEA_API_TOKEN=your_token
|
||||
GITEA_OWNER=bandit
|
||||
|
||||
# ~/.config/claude/wikijs.env
|
||||
WIKIJS_API_URL=https://wiki.your-company.com/graphql
|
||||
WIKIJS_API_TOKEN=your_token
|
||||
WIKIJS_BASE_PATH=/your-org
|
||||
```
|
||||
|
||||
**Project-Level:**
|
||||
```bash
|
||||
# project-root/.env (for projman)
|
||||
GITEA_REPO=cuisineflow
|
||||
WIKIJS_PROJECT=projects/cuisineflow
|
||||
|
||||
# No .env needed for pmo (company-wide mode)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
### 1. Two MCP Servers (Shared)
|
||||
|
||||
**Decision:** Separate Gitea and Wiki.js servers at repository root
|
||||
**Why:**
|
||||
- Clear separation of concerns
|
||||
- Independent configuration
|
||||
- Better maintainability
|
||||
- Professional architecture
|
||||
|
||||
### 2. Python Implementation
|
||||
|
||||
**Decision:** Python 3.10+ for MCP servers
|
||||
**Why:**
|
||||
- Modern async/await improvements
|
||||
- Better type hints support
|
||||
- Good balance of compatibility vs features
|
||||
- Widely available (released Oct 2021)
|
||||
- Most production servers have 3.10+ by now
|
||||
|
||||
### 3. Wiki.js for Lessons Learned
|
||||
|
||||
**Decision:** Use Wiki.js instead of Git-based Wiki
|
||||
**Why:**
|
||||
- Rich editor and search
|
||||
- Built-in tag system
|
||||
- Version history
|
||||
- Web-based collaboration
|
||||
- GraphQL API
|
||||
- Company-wide accessibility
|
||||
|
||||
### 4. Hybrid Configuration
|
||||
|
||||
**Decision:** System-level + project-level configuration
|
||||
**Why:**
|
||||
- Single token per service (security)
|
||||
- Per-project customization (flexibility)
|
||||
- Easy multi-project setup
|
||||
- Never commit tokens to git
|
||||
|
||||
### 5. Mode Detection in MCP Servers
|
||||
|
||||
**Decision:** Detect mode based on environment variables
|
||||
**Why:**
|
||||
- Same codebase for both plugins
|
||||
- No code duplication
|
||||
- Fix bugs once, both benefit
|
||||
- Clear separation of concerns
|
||||
|
||||
### 6. Build Order: projman First
|
||||
|
||||
**Decision:** Build projman completely before starting pmo
|
||||
**Why:**
|
||||
- Validate core functionality
|
||||
- Establish patterns
|
||||
- Reduce risk
|
||||
- PMO builds on projman foundation
|
||||
|
||||
---
|
||||
|
||||
## The Three-Agent Model
|
||||
|
||||
### Projman Agents
|
||||
|
||||
**Planner Agent:**
|
||||
- Sprint planning and architecture analysis
|
||||
- Asks clarifying questions
|
||||
- Suggests appropriate labels
|
||||
- Creates Gitea issues
|
||||
- Searches relevant lessons learned
|
||||
|
||||
**Orchestrator Agent:**
|
||||
- Sprint progress monitoring
|
||||
- Task coordination
|
||||
- Blocker identification
|
||||
- Git operations
|
||||
- Generates lean execution prompts
|
||||
|
||||
**Executor Agent:**
|
||||
- Implementation guidance
|
||||
- Code review suggestions
|
||||
- Testing strategy
|
||||
- Quality standards enforcement
|
||||
- Documentation
|
||||
|
||||
### PMO Agent
|
||||
|
||||
**PMO Coordinator:**
|
||||
- Strategic view across all projects
|
||||
- Cross-project dependency tracking
|
||||
- Resource conflict detection
|
||||
- Release coordination
|
||||
- Delegates to projman agents for details
|
||||
|
||||
---
|
||||
|
||||
## Wiki.js Structure
|
||||
|
||||
```
|
||||
Wiki.js: https://wiki.your-company.com
|
||||
└── /your-org/
|
||||
├── projects/ # Project-specific
|
||||
│ ├── project-a/
|
||||
│ │ ├── lessons-learned/
|
||||
│ │ │ ├── sprints/
|
||||
│ │ │ ├── patterns/
|
||||
│ │ │ └── INDEX.md
|
||||
│ │ └── documentation/
|
||||
│ ├── project-b/
|
||||
│ ├── project-c/
|
||||
│ └── company-site/
|
||||
├── company/ # Company-wide
|
||||
│ ├── processes/
|
||||
│ ├── standards/
|
||||
│ └── tools/
|
||||
└── shared/ # Cross-project
|
||||
├── architecture-patterns/
|
||||
├── best-practices/
|
||||
└── tech-stack/
|
||||
```
|
||||
|
||||
**Reference:** [MCP-WIKIJS.md](./MCP-WIKIJS.md#wiki-js-structure)
|
||||
|
||||
---
|
||||
|
||||
## Label Taxonomy System
|
||||
|
||||
### Dynamic Label System (44 labels currently)
|
||||
|
||||
Labels are **fetched dynamically from Gitea** at runtime via the `/labels-sync` command:
|
||||
|
||||
**Organization Labels (28):**
|
||||
- Agent/2
|
||||
- Complexity/3
|
||||
- Efforts/5
|
||||
- Priority/4
|
||||
- Risk/3
|
||||
- Source/4
|
||||
- Type/6 (Bug, Feature, Refactor, Documentation, Test, Chore)
|
||||
|
||||
**Repository Labels (16):**
|
||||
- Component/9 (Backend, Frontend, API, Database, Auth, Deploy, Testing, Docs, Infra)
|
||||
- Tech/7 (Python, JavaScript, Docker, PostgreSQL, Redis, Vue, FastAPI)
|
||||
|
||||
### Type/Refactor Label
|
||||
|
||||
**Organization-level label** for architectural work:
|
||||
- Service extraction
|
||||
- Architecture modifications
|
||||
- Code restructuring
|
||||
- Technical debt reduction
|
||||
|
||||
**Note:** Label count may change. Always sync from Gitea using `/labels-sync` command. When new labels are detected, the command will explain changes and update suggestion logic.
|
||||
|
||||
**Reference:** [PLUGIN-PROJMAN.md](./PLUGIN-PROJMAN.md#label-taxonomy-system)
|
||||
|
||||
---
|
||||
|
||||
## Build Order & Phases
|
||||
|
||||
### Build projman First (Phases 1-8)
|
||||
|
||||
**Phase 1:** Core Infrastructure (MCP servers)
|
||||
**Phase 2:** Sprint Planning Commands
|
||||
**Phase 3:** Agent System
|
||||
**Phase 4:** Lessons Learned System
|
||||
**Phase 5:** Testing & Validation
|
||||
**Phase 6:** Documentation & Refinement
|
||||
**Phase 7:** Marketplace Preparation
|
||||
**Phase 8:** Production Hardening
|
||||
|
||||
**Reference:** [PLUGIN-PROJMAN.md](./PLUGIN-PROJMAN.md#implementation-phases)
|
||||
|
||||
### Build pmo Second (Phases 9-12)
|
||||
|
||||
**Phase 9:** PMO Plugin Foundation
|
||||
**Phase 10:** PMO Commands & Workflows
|
||||
**Phase 11:** PMO Testing & Integration
|
||||
**Phase 12:** Production Deployment
|
||||
|
||||
**Reference:** [PLUGIN-PMO.md](./PLUGIN-PMO.md#implementation-phases)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start Guide
|
||||
|
||||
### 1. System Configuration
|
||||
|
||||
```bash
|
||||
# Create config directory
|
||||
mkdir -p ~/.config/claude
|
||||
|
||||
# Gitea config
|
||||
cat > ~/.config/claude/gitea.env << EOF
|
||||
GITEA_API_URL=https://gitea.example.com/api/v1
|
||||
GITEA_API_TOKEN=your_gitea_token
|
||||
GITEA_OWNER=bandit
|
||||
EOF
|
||||
|
||||
# Wiki.js config
|
||||
cat > ~/.config/claude/wikijs.env << EOF
|
||||
WIKIJS_API_URL=https://wiki.your-company.com/graphql
|
||||
WIKIJS_API_TOKEN=your_wikijs_token
|
||||
WIKIJS_BASE_PATH=/your-org
|
||||
EOF
|
||||
|
||||
# Secure files
|
||||
chmod 600 ~/.config/claude/*.env
|
||||
```
|
||||
|
||||
### 2. Project Configuration
|
||||
|
||||
```bash
|
||||
# In each project root (for projman)
|
||||
cat > .env << EOF
|
||||
GITEA_REPO=cuisineflow
|
||||
WIKIJS_PROJECT=projects/cuisineflow
|
||||
EOF
|
||||
|
||||
# Add to .gitignore
|
||||
echo ".env" >> .gitignore
|
||||
```
|
||||
|
||||
### 3. MCP Server Setup
|
||||
|
||||
```bash
|
||||
# Gitea MCP Server
|
||||
cd mcp-servers/gitea
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Wiki.js MCP Server
|
||||
cd mcp-servers/wikijs
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Validate Setup
|
||||
|
||||
```bash
|
||||
# Test MCP servers
|
||||
python -m mcp_server.server --test # In each MCP directory
|
||||
|
||||
# Test plugin loading
|
||||
claude plugin test projman
|
||||
claude plugin test projman-pmo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Document Organization
|
||||
|
||||
This documentation is organized into 4 focused files plus this summary:
|
||||
|
||||
### 1. Gitea MCP Server Reference
|
||||
|
||||
**File:** [MCP-GITEA.md](./MCP-GITEA.md)
|
||||
|
||||
**Contains:**
|
||||
- Configuration setup
|
||||
- Python implementation
|
||||
- API client code
|
||||
- Issue and label tools
|
||||
- Testing strategies
|
||||
- Mode detection
|
||||
- Performance optimization
|
||||
|
||||
**Use when:** Implementing or troubleshooting Gitea integration
|
||||
|
||||
### 2. Wiki.js MCP Server Reference
|
||||
|
||||
**File:** [MCP-WIKIJS.md](./MCP-WIKIJS.md)
|
||||
|
||||
**Contains:**
|
||||
- Configuration setup
|
||||
- GraphQL client implementation
|
||||
- Wiki.js structure
|
||||
- Lessons learned system
|
||||
- Documentation tools
|
||||
- Company-wide patterns
|
||||
- PMO multi-project methods
|
||||
|
||||
**Use when:** Implementing or troubleshooting Wiki.js integration
|
||||
|
||||
### 3. Projman Plugin Reference
|
||||
|
||||
**File:** [PLUGIN-PROJMAN.md](./PLUGIN-PROJMAN.md)
|
||||
|
||||
**Contains:**
|
||||
- Plugin structure
|
||||
- Commands (sprint-plan, sprint-start, sprint-status, sprint-close, labels-sync)
|
||||
- Three agents (planner, orchestrator, executor)
|
||||
- Sprint workflow
|
||||
- Label taxonomy
|
||||
- Branch-aware security
|
||||
- Implementation phases 1-8
|
||||
|
||||
**Use when:** Building or using the projman plugin
|
||||
|
||||
### 4. PMO Plugin Reference
|
||||
|
||||
**File:** [PLUGIN-PMO.md](./PLUGIN-PMO.md)
|
||||
|
||||
**Contains:**
|
||||
- PMO plugin structure
|
||||
- Multi-project commands
|
||||
- PMO coordinator agent
|
||||
- Cross-project coordination
|
||||
- Dependency tracking
|
||||
- Resource conflict detection
|
||||
- Implementation phases 9-12
|
||||
|
||||
**Use when:** Building or using the projman-pmo plugin
|
||||
|
||||
### 5. This Summary
|
||||
|
||||
**File:** PROJECT-SUMMARY.md (this document)
|
||||
|
||||
**Contains:**
|
||||
- Project overview
|
||||
- Architecture decisions
|
||||
- Configuration approach
|
||||
- Quick start guide
|
||||
- References to detailed docs
|
||||
|
||||
**Use when:** Getting started or need high-level overview
|
||||
|
||||
---
|
||||
|
||||
## Key Success Metrics
|
||||
|
||||
### Technical Metrics
|
||||
|
||||
- Sprint planning time reduced by 40%
|
||||
- Manual steps eliminated: 10+ per sprint
|
||||
- Lessons learned capture rate: 100% (vs 0% before)
|
||||
- Label accuracy on issues: 90%+
|
||||
- Configuration setup time: < 5 minutes
|
||||
|
||||
### User Metrics
|
||||
|
||||
- User satisfaction: Better than current manual workflow
|
||||
- Learning curve: < 1 hour to basic proficiency
|
||||
- Error rate: < 5% incorrect operations
|
||||
- Adoption rate: 100% team adoption within 1 month
|
||||
|
||||
### PMO Metrics
|
||||
|
||||
- Cross-project visibility: 100% (vs fragmented before)
|
||||
- Dependency detection: Automated (vs manual tracking)
|
||||
- Resource conflict identification: Proactive (vs reactive)
|
||||
- Release coordination: Streamlined (vs ad-hoc)
|
||||
|
||||
---
|
||||
|
||||
## Critical Lessons from 15 Sprints
|
||||
|
||||
### Why Lessons Learned Is Critical
|
||||
|
||||
After 15 sprints without systematic lesson capture, repeated mistakes occurred:
|
||||
- Claude Code infinite loops on similar issues: 2-3 times
|
||||
- Same architectural mistakes: Multiple occurrences
|
||||
- Forgotten optimizations: Re-discovered each time
|
||||
|
||||
**Solution:** Mandatory lessons learned capture at sprint close, searchable at sprint start
|
||||
|
||||
### Branch Detection Must Be 100% Reliable
|
||||
|
||||
Production accidents are unacceptable. Branch-aware security prevents:
|
||||
- Accidental code changes on production branch
|
||||
- Sprint planning on wrong branch
|
||||
- Deployment mistakes
|
||||
|
||||
**Implementation:** Two layers - CLAUDE.md (file-level) + Plugin agents (tool-level)
|
||||
|
||||
### Configuration Complexity Is a Blocker
|
||||
|
||||
Previous attempts failed due to:
|
||||
- Complex per-project setup
|
||||
- Token management overhead
|
||||
- Multiple configuration files
|
||||
|
||||
**Solution:** Hybrid approach - system-level tokens + simple project-level paths
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Immediate Actions
|
||||
|
||||
1. **Set up system configuration** (Gitea + Wiki.js tokens)
|
||||
2. **Create Wiki.js base structure** at `/your-org`
|
||||
3. **Begin Phase 1.1a** - Gitea MCP Server implementation
|
||||
4. **Begin Phase 1.1b** - Wiki.js MCP Server implementation
|
||||
|
||||
### Phase Execution
|
||||
|
||||
1. **Phases 1-4:** Build core projman functionality
|
||||
2. **Phase 5:** Validate with real sprint (e.g., Intuit Engine extraction)
|
||||
3. **Phases 6-8:** Polish, document, and harden projman
|
||||
4. **Phases 9-12:** Build and validate pmo plugin
|
||||
|
||||
### Validation Points
|
||||
|
||||
- **After Phase 1:** MCP servers working and tested
|
||||
- **After Phase 4:** Complete projman workflow end-to-end
|
||||
- **After Phase 5:** Real sprint successfully managed
|
||||
- **After Phase 8:** Production-ready projman
|
||||
- **After Phase 11:** Multi-project coordination validated
|
||||
- **After Phase 12:** Complete system operational
|
||||
|
||||
---
|
||||
|
||||
## Implementation Decisions (Pre-Development)
|
||||
|
||||
These decisions were finalized before development:
|
||||
|
||||
### 1. Python Version: 3.10+
|
||||
- **Rationale:** Balance of modern features and wide availability
|
||||
- **Benefits:** Modern async, good type hints, widely deployed
|
||||
- **Minimum:** Python 3.10.0
|
||||
|
||||
### 2. Wiki.js Base Structure: Needs Creation
|
||||
- **Status:** `/your-org` structure does NOT exist yet
|
||||
- **Action:** Run `setup_wiki_structure.py` during Phase 1.1b
|
||||
- **Script:** See MCP-WIKIJS.md for complete setup script
|
||||
- **Post-setup:** Verify at https://wiki.your-company.com/your-org
|
||||
|
||||
### 3. Testing Strategy: Both Mocks and Real APIs
|
||||
- **Unit tests:** Use mocks for fast feedback during development
|
||||
- **Integration tests:** Use real Gitea/Wiki.js APIs for validation
|
||||
- **CI/CD:** Run both test suites
|
||||
- **Developers:** Can skip integration tests locally if needed
|
||||
- **Markers:** Use pytest markers (`@pytest.mark.integration`)
|
||||
|
||||
### 4. Token Permissions: Confirmed
|
||||
- **Gitea token:**
|
||||
- `repo` (all) - Read/write repositories, issues, labels
|
||||
- `read:org` - Organization information and labels
|
||||
- `read:user` - User information
|
||||
- **Wiki.js token:**
|
||||
- Read/create/update pages
|
||||
- Manage tags
|
||||
- Search access
|
||||
|
||||
### 5. Label System: Dynamic (44 labels)
|
||||
- **Current count:** 44 labels (28 org + 16 repo)
|
||||
- **Approach:** Fetch dynamically via API, never hardcode
|
||||
- **Sync:** `/labels-sync` command updates local reference and suggestion logic
|
||||
- **New labels:** Command explains changes and asks for confirmation
|
||||
|
||||
### 6. Branch Detection: Defense in Depth
|
||||
- **Layer 1:** MCP tools check branch and block operations
|
||||
- **Layer 2:** Agent prompts check branch and warn users
|
||||
- **Layer 3:** CLAUDE.md provides context (third layer)
|
||||
- **Rationale:** Multiple layers prevent production accidents
|
||||
|
||||
---
|
||||
|
||||
## Important Reminders
|
||||
|
||||
1. **Build projman FIRST** - Don't start pmo until projman is validated
|
||||
2. **MCP servers are SHARED** - Located at `mcp-servers/`, not inside plugins
|
||||
3. **Lessons learned is critical** - Prevents repeated mistakes
|
||||
4. **Test with real work** - Validate with actual sprints, not just unit tests
|
||||
5. **Security first** - Branch detection must be 100% reliable
|
||||
6. **Keep it simple** - Avoid over-engineering, focus on proven workflow
|
||||
7. **Python 3.10+** - Minimum version requirement
|
||||
8. **Wiki.js setup** - Must run setup script before projman works
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Documentation Structure
|
||||
|
||||
**Need details on:**
|
||||
- Gitea integration → [MCP-GITEA.md](./MCP-GITEA.md)
|
||||
- Wiki.js integration → [MCP-WIKIJS.md](./MCP-WIKIJS.md)
|
||||
- Projman usage → [PLUGIN-PROJMAN.md](./PLUGIN-PROJMAN.md)
|
||||
- PMO usage → [PLUGIN-PMO.md](./PLUGIN-PMO.md)
|
||||
- Overview → This document
|
||||
|
||||
### Quick Reference
|
||||
|
||||
| Question | Reference |
|
||||
|----------|-----------|
|
||||
| How do I set up configuration? | This document, "Quick Start Guide" |
|
||||
| What's the repository structure? | This document, "Repository Structure" |
|
||||
| How do Gitea tools work? | [MCP-GITEA.md](./MCP-GITEA.md) |
|
||||
| How do Wiki.js tools work? | [MCP-WIKIJS.md](./MCP-WIKIJS.md) |
|
||||
| How do I use sprint commands? | [PLUGIN-PROJMAN.md](./PLUGIN-PROJMAN.md#commands) |
|
||||
| How do agents work? | [PLUGIN-PROJMAN.md](./PLUGIN-PROJMAN.md#three-agent-model) |
|
||||
| How do I coordinate multiple projects? | [PLUGIN-PMO.md](./PLUGIN-PMO.md) |
|
||||
| What's the build order? | This document, "Build Order & Phases" |
|
||||
|
||||
---
|
||||
|
||||
## Project Timeline
|
||||
|
||||
**Planning:** Complete ✅
|
||||
**Phase 1-8 (projman):** 6-8 weeks estimated
|
||||
**Phase 9-12 (pmo):** 2-4 weeks estimated
|
||||
**Total:** 8-12 weeks from start to production
|
||||
|
||||
**Note:** No fixed deadlines - work at sustainable pace and validate thoroughly
|
||||
|
||||
---
|
||||
|
||||
## You're Ready!
|
||||
|
||||
You have everything you need to build the projman and projman-pmo plugins. All architectural decisions are finalized and documented.
|
||||
|
||||
**Start here:** [MCP-GITEA.md](./MCP-GITEA.md) - Set up Gitea MCP Server
|
||||
|
||||
Good luck with the build! 🚀
|
||||
3
mcp-servers/contract-validator/mcp_server/__init__.py
Normal file
3
mcp-servers/contract-validator/mcp_server/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Contract Validator MCP Server - Cross-plugin compatibility validation."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
415
mcp-servers/contract-validator/mcp_server/parse_tools.py
Normal file
415
mcp-servers/contract-validator/mcp_server/parse_tools.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Parse tools for extracting interfaces from plugin documentation.
|
||||
|
||||
Provides structured extraction of:
|
||||
- Plugin interfaces from README.md (commands, agents, tools)
|
||||
- Agent definitions from CLAUDE.md (tool sequences, workflows)
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ToolInfo(BaseModel):
|
||||
"""Information about a single tool"""
|
||||
name: str
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CommandInfo(BaseModel):
|
||||
"""Information about a plugin command"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class AgentInfo(BaseModel):
|
||||
"""Information about a plugin agent"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
tools: list[str] = []
|
||||
|
||||
|
||||
class PluginInterface(BaseModel):
|
||||
"""Structured plugin interface extracted from README"""
|
||||
plugin_name: str
|
||||
description: Optional[str] = None
|
||||
commands: list[CommandInfo] = []
|
||||
agents: list[AgentInfo] = []
|
||||
tools: list[ToolInfo] = []
|
||||
tool_categories: dict[str, list[str]] = {}
|
||||
features: list[str] = []
|
||||
|
||||
|
||||
class ClaudeMdAgent(BaseModel):
|
||||
"""Agent definition extracted from CLAUDE.md"""
|
||||
name: str
|
||||
personality: Optional[str] = None
|
||||
responsibilities: list[str] = []
|
||||
tool_refs: list[str] = []
|
||||
workflow_steps: list[str] = []
|
||||
|
||||
|
||||
class ParseTools:
|
||||
"""Tools for parsing plugin documentation"""
|
||||
|
||||
async def parse_plugin_interface(self, plugin_path: str) -> dict:
|
||||
"""
|
||||
Parse plugin README.md to extract interface declarations.
|
||||
|
||||
Args:
|
||||
plugin_path: Path to plugin directory or README.md file
|
||||
|
||||
Returns:
|
||||
Structured interface with commands, agents, tools, etc.
|
||||
"""
|
||||
# Resolve path to README
|
||||
path = Path(plugin_path)
|
||||
if path.is_dir():
|
||||
readme_path = path / "README.md"
|
||||
else:
|
||||
readme_path = path
|
||||
|
||||
if not readme_path.exists():
|
||||
return {
|
||||
"error": f"README.md not found at {readme_path}",
|
||||
"plugin_path": plugin_path
|
||||
}
|
||||
|
||||
content = readme_path.read_text()
|
||||
plugin_name = self._extract_plugin_name(content, path)
|
||||
|
||||
interface = PluginInterface(
|
||||
plugin_name=plugin_name,
|
||||
description=self._extract_description(content),
|
||||
commands=self._extract_commands(content),
|
||||
agents=self._extract_agents_from_readme(content),
|
||||
tools=self._extract_tools(content),
|
||||
tool_categories=self._extract_tool_categories(content),
|
||||
features=self._extract_features(content)
|
||||
)
|
||||
|
||||
return interface.model_dump()
|
||||
|
||||
async def parse_claude_md_agents(self, claude_md_path: str) -> dict:
|
||||
"""
|
||||
Parse CLAUDE.md to extract agent definitions and tool sequences.
|
||||
|
||||
Args:
|
||||
claude_md_path: Path to CLAUDE.md file
|
||||
|
||||
Returns:
|
||||
List of agents with their tool sequences
|
||||
"""
|
||||
path = Path(claude_md_path)
|
||||
|
||||
if not path.exists():
|
||||
return {
|
||||
"error": f"CLAUDE.md not found at {path}",
|
||||
"claude_md_path": claude_md_path
|
||||
}
|
||||
|
||||
content = path.read_text()
|
||||
agents = self._extract_agents_from_claude_md(content)
|
||||
|
||||
return {
|
||||
"file": str(path),
|
||||
"agents": [a.model_dump() for a in agents],
|
||||
"agent_count": len(agents)
|
||||
}
|
||||
|
||||
def _extract_plugin_name(self, content: str, path: Path) -> str:
|
||||
"""Extract plugin name from content or path"""
|
||||
# Try to get from H1 header
|
||||
match = re.search(r'^#\s+(.+?)(?:\s+Plugin|\s*$)', content, re.MULTILINE)
|
||||
if match:
|
||||
name = match.group(1).strip()
|
||||
# Handle cases like "# data-platform Plugin"
|
||||
name = re.sub(r'\s*Plugin\s*$', '', name, flags=re.IGNORECASE)
|
||||
return name
|
||||
|
||||
# Fall back to directory name
|
||||
if path.is_dir():
|
||||
return path.name
|
||||
return path.parent.name
|
||||
|
||||
def _extract_description(self, content: str) -> Optional[str]:
|
||||
"""Extract plugin description from first paragraph after title"""
|
||||
# Get content after H1, before first H2
|
||||
match = re.search(r'^#\s+.+?\n\n(.+?)(?=\n##|\n\n##|\Z)', content, re.MULTILINE | re.DOTALL)
|
||||
if match:
|
||||
desc = match.group(1).strip()
|
||||
# Take first paragraph only
|
||||
desc = desc.split('\n\n')[0].strip()
|
||||
return desc
|
||||
return None
|
||||
|
||||
def _extract_commands(self, content: str) -> list[CommandInfo]:
|
||||
"""Extract commands from Commands section"""
|
||||
commands = []
|
||||
|
||||
# Find Commands section
|
||||
commands_section = self._extract_section(content, "Commands")
|
||||
if not commands_section:
|
||||
return commands
|
||||
|
||||
# Parse table format: | Command | Description |
|
||||
# Only match actual command names (start with / or alphanumeric)
|
||||
table_pattern = r'\|\s*`?(/[a-z][-a-z0-9]*)`?\s*\|\s*([^|]+)\s*\|'
|
||||
for match in re.finditer(table_pattern, commands_section):
|
||||
cmd_name = match.group(1).strip()
|
||||
desc = match.group(2).strip()
|
||||
|
||||
# Skip header row and separators
|
||||
if cmd_name.lower() in ('command', 'commands') or cmd_name.startswith('-'):
|
||||
continue
|
||||
|
||||
commands.append(CommandInfo(
|
||||
name=cmd_name,
|
||||
description=desc
|
||||
))
|
||||
|
||||
# Also look for ### `/command-name` format (with backticks)
|
||||
cmd_header_pattern = r'^###\s+`(/[a-z][-a-z0-9]*)`\s*\n(.+?)(?=\n###|\n##|\Z)'
|
||||
for match in re.finditer(cmd_header_pattern, commands_section, re.MULTILINE | re.DOTALL):
|
||||
cmd_name = match.group(1).strip()
|
||||
desc_block = match.group(2).strip()
|
||||
# Get first line or paragraph as description
|
||||
desc = desc_block.split('\n')[0].strip()
|
||||
|
||||
# Don't duplicate if already found in table
|
||||
if not any(c.name == cmd_name for c in commands):
|
||||
commands.append(CommandInfo(name=cmd_name, description=desc))
|
||||
|
||||
# Also look for ### /command-name format (without backticks)
|
||||
cmd_header_pattern2 = r'^###\s+(/[a-z][-a-z0-9]*)\s*\n(.+?)(?=\n###|\n##|\Z)'
|
||||
for match in re.finditer(cmd_header_pattern2, commands_section, re.MULTILINE | re.DOTALL):
|
||||
cmd_name = match.group(1).strip()
|
||||
desc_block = match.group(2).strip()
|
||||
# Get first line or paragraph as description
|
||||
desc = desc_block.split('\n')[0].strip()
|
||||
|
||||
# Don't duplicate if already found in table
|
||||
if not any(c.name == cmd_name for c in commands):
|
||||
commands.append(CommandInfo(name=cmd_name, description=desc))
|
||||
|
||||
return commands
|
||||
|
||||
def _extract_agents_from_readme(self, content: str) -> list[AgentInfo]:
|
||||
"""Extract agents from Agents section in README"""
|
||||
agents = []
|
||||
|
||||
# Find Agents section
|
||||
agents_section = self._extract_section(content, "Agents")
|
||||
if not agents_section:
|
||||
return agents
|
||||
|
||||
# Parse table format: | Agent | Description |
|
||||
# Only match actual agent names (alphanumeric with dashes/underscores)
|
||||
table_pattern = r'\|\s*`?([a-z][-a-z0-9_]*)`?\s*\|\s*([^|]+)\s*\|'
|
||||
for match in re.finditer(table_pattern, agents_section):
|
||||
agent_name = match.group(1).strip()
|
||||
desc = match.group(2).strip()
|
||||
|
||||
# Skip header row and separators
|
||||
if agent_name.lower() in ('agent', 'agents') or agent_name.startswith('-'):
|
||||
continue
|
||||
|
||||
agents.append(AgentInfo(name=agent_name, description=desc))
|
||||
|
||||
return agents
|
||||
|
||||
def _extract_tools(self, content: str) -> list[ToolInfo]:
|
||||
"""Extract tool list from Tools Summary or similar section"""
|
||||
tools = []
|
||||
|
||||
# Find Tools Summary section
|
||||
tools_section = self._extract_section(content, "Tools Summary")
|
||||
if not tools_section:
|
||||
tools_section = self._extract_section(content, "Tools")
|
||||
if not tools_section:
|
||||
tools_section = self._extract_section(content, "MCP Server Tools")
|
||||
|
||||
if not tools_section:
|
||||
return tools
|
||||
|
||||
# Parse category headers: ### category (N tools)
|
||||
category_pattern = r'###\s*(.+?)\s*(?:\((\d+)\s*tools?\))?\s*\n([^#]+)'
|
||||
for match in re.finditer(category_pattern, tools_section):
|
||||
category = match.group(1).strip()
|
||||
tool_list_text = match.group(3).strip()
|
||||
|
||||
# Extract tool names from backtick lists
|
||||
tool_names = re.findall(r'`([a-z_]+)`', tool_list_text)
|
||||
for name in tool_names:
|
||||
tools.append(ToolInfo(name=name, category=category))
|
||||
|
||||
# Also look for inline tool lists without categories
|
||||
inline_pattern = r'`([a-z_]+)`'
|
||||
all_tool_names = set(t.name for t in tools)
|
||||
for match in re.finditer(inline_pattern, tools_section):
|
||||
name = match.group(1)
|
||||
if name not in all_tool_names:
|
||||
tools.append(ToolInfo(name=name))
|
||||
all_tool_names.add(name)
|
||||
|
||||
return tools
|
||||
|
||||
def _extract_tool_categories(self, content: str) -> dict[str, list[str]]:
|
||||
"""Extract tool categories with their tool lists"""
|
||||
categories = {}
|
||||
|
||||
tools_section = self._extract_section(content, "Tools Summary")
|
||||
if not tools_section:
|
||||
tools_section = self._extract_section(content, "Tools")
|
||||
if not tools_section:
|
||||
return categories
|
||||
|
||||
# Parse category headers: ### category (N tools)
|
||||
category_pattern = r'###\s*(.+?)\s*(?:\((\d+)\s*tools?\))?\s*\n([^#]+)'
|
||||
for match in re.finditer(category_pattern, tools_section):
|
||||
category = match.group(1).strip()
|
||||
tool_list_text = match.group(3).strip()
|
||||
|
||||
# Extract tool names from backtick lists
|
||||
tool_names = re.findall(r'`([a-z_]+)`', tool_list_text)
|
||||
if tool_names:
|
||||
categories[category] = tool_names
|
||||
|
||||
return categories
|
||||
|
||||
def _extract_features(self, content: str) -> list[str]:
|
||||
"""Extract features from Features section"""
|
||||
features = []
|
||||
|
||||
features_section = self._extract_section(content, "Features")
|
||||
if not features_section:
|
||||
return features
|
||||
|
||||
# Parse bullet points
|
||||
bullet_pattern = r'^[-*]\s+\*\*(.+?)\*\*'
|
||||
for match in re.finditer(bullet_pattern, features_section, re.MULTILINE):
|
||||
features.append(match.group(1).strip())
|
||||
|
||||
return features
|
||||
|
||||
def _extract_section(self, content: str, section_name: str) -> Optional[str]:
|
||||
"""Extract content of a markdown section by header name"""
|
||||
# Match ## Section Name - include all content until next ## (same level or higher)
|
||||
pattern = rf'^##\s+{re.escape(section_name)}(?:\s*\([^)]*\))?\s*\n(.*?)(?=\n##[^#]|\Z)'
|
||||
match = re.search(pattern, content, re.MULTILINE | re.DOTALL | re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
# Try ### level - include content until next ## or ###
|
||||
pattern = rf'^###\s+{re.escape(section_name)}(?:\s*\([^)]*\))?\s*\n(.*?)(?=\n##|\n###[^#]|\Z)'
|
||||
match = re.search(pattern, content, re.MULTILINE | re.DOTALL | re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
|
||||
return None
|
||||
|
||||
def _extract_agents_from_claude_md(self, content: str) -> list[ClaudeMdAgent]:
|
||||
"""Extract agent definitions from CLAUDE.md"""
|
||||
agents = []
|
||||
|
||||
# Look for Four-Agent Model section specifically
|
||||
# Match section headers like "### Four-Agent Model (projman)" or "## Four-Agent Model"
|
||||
agent_model_match = re.search(
|
||||
r'^##[#]?\s+Four-Agent Model.*?\n(.*?)(?=\n##[^#]|\Z)',
|
||||
content, re.MULTILINE | re.DOTALL
|
||||
)
|
||||
agent_model_section = agent_model_match.group(1) if agent_model_match else None
|
||||
|
||||
if agent_model_section:
|
||||
# Parse agent table within this section
|
||||
# | **Planner** | Thoughtful, methodical | Sprint planning, ... |
|
||||
# Match rows where first cell starts with ** (bold) and contains a capitalized word
|
||||
agent_table_pattern = r'\|\s*\*\*([A-Z][a-zA-Z\s]+?)\*\*\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|'
|
||||
|
||||
for match in re.finditer(agent_table_pattern, agent_model_section):
|
||||
agent_name = match.group(1).strip()
|
||||
personality = match.group(2).strip()
|
||||
responsibilities = match.group(3).strip()
|
||||
|
||||
# Skip header rows and separator rows
|
||||
if agent_name.lower() in ('agent', 'agents', '---', '-', ''):
|
||||
continue
|
||||
if 'personality' in personality.lower() or '---' in personality:
|
||||
continue
|
||||
|
||||
# Skip if personality looks like tool names (contains backticks)
|
||||
if '`' in personality:
|
||||
continue
|
||||
|
||||
# Extract tool references from responsibilities
|
||||
tool_refs = re.findall(r'`([a-z_]+)`', responsibilities)
|
||||
|
||||
# Split responsibilities by comma
|
||||
resp_list = [r.strip() for r in responsibilities.split(',')]
|
||||
|
||||
agents.append(ClaudeMdAgent(
|
||||
name=agent_name,
|
||||
personality=personality,
|
||||
responsibilities=resp_list,
|
||||
tool_refs=tool_refs
|
||||
))
|
||||
|
||||
# Also look for agents table in ## Agents section
|
||||
agents_section = self._extract_section(content, "Agents")
|
||||
if agents_section:
|
||||
# Parse table: | Agent | Description |
|
||||
table_pattern = r'\|\s*`?([a-z][-a-z0-9_]+)`?\s*\|\s*([^|]+)\s*\|'
|
||||
for match in re.finditer(table_pattern, agents_section):
|
||||
agent_name = match.group(1).strip()
|
||||
desc = match.group(2).strip()
|
||||
|
||||
# Skip header rows
|
||||
if agent_name.lower() in ('agent', 'agents', '---', '-'):
|
||||
continue
|
||||
|
||||
# Check if agent already exists
|
||||
if not any(a.name.lower() == agent_name.lower() for a in agents):
|
||||
agents.append(ClaudeMdAgent(
|
||||
name=agent_name,
|
||||
responsibilities=[desc] if desc else []
|
||||
))
|
||||
|
||||
# Look for workflow sections to enrich agent data
|
||||
workflow_section = self._extract_section(content, "Workflow")
|
||||
if workflow_section:
|
||||
# Parse numbered steps
|
||||
step_pattern = r'^\d+\.\s+(.+?)$'
|
||||
workflow_steps = re.findall(step_pattern, workflow_section, re.MULTILINE)
|
||||
|
||||
# Associate workflow steps with agents mentioned
|
||||
for agent in agents:
|
||||
for step in workflow_steps:
|
||||
if agent.name.lower() in step.lower():
|
||||
agent.workflow_steps.append(step)
|
||||
# Extract any tool references in the step
|
||||
step_tools = re.findall(r'`([a-z_]+)`', step)
|
||||
agent.tool_refs.extend(t for t in step_tools if t not in agent.tool_refs)
|
||||
|
||||
# Look for agent-specific sections (### Planner Agent)
|
||||
agent_section_pattern = r'^###?\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+Agent\s*\n(.*?)(?=\n##|\n###|\Z)'
|
||||
for match in re.finditer(agent_section_pattern, content, re.MULTILINE | re.DOTALL):
|
||||
agent_name = match.group(1).strip()
|
||||
section_content = match.group(2).strip()
|
||||
|
||||
# Check if agent already exists
|
||||
existing = next((a for a in agents if a.name.lower() == agent_name.lower()), None)
|
||||
if existing:
|
||||
# Add tool refs from this section
|
||||
tool_refs = re.findall(r'`([a-z_]+)`', section_content)
|
||||
existing.tool_refs.extend(t for t in tool_refs if t not in existing.tool_refs)
|
||||
else:
|
||||
tool_refs = re.findall(r'`([a-z_]+)`', section_content)
|
||||
agents.append(ClaudeMdAgent(
|
||||
name=agent_name,
|
||||
tool_refs=tool_refs
|
||||
))
|
||||
|
||||
return agents
|
||||
337
mcp-servers/contract-validator/mcp_server/report_tools.py
Normal file
337
mcp-servers/contract-validator/mcp_server/report_tools.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Report tools for generating compatibility reports and listing issues.
|
||||
|
||||
Provides:
|
||||
- generate_compatibility_report: Full marketplace validation report
|
||||
- list_issues: Filtered issue listing
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .parse_tools import ParseTools
|
||||
from .validation_tools import ValidationTools, IssueSeverity, IssueType, ValidationIssue
|
||||
|
||||
|
||||
class ReportSummary(BaseModel):
|
||||
"""Summary statistics for a report"""
|
||||
total_plugins: int = 0
|
||||
total_commands: int = 0
|
||||
total_agents: int = 0
|
||||
total_tools: int = 0
|
||||
total_issues: int = 0
|
||||
errors: int = 0
|
||||
warnings: int = 0
|
||||
info: int = 0
|
||||
|
||||
|
||||
class ReportTools:
|
||||
"""Tools for generating reports and listing issues"""
|
||||
|
||||
def __init__(self):
|
||||
self.parse_tools = ParseTools()
|
||||
self.validation_tools = ValidationTools()
|
||||
|
||||
async def generate_compatibility_report(
|
||||
self,
|
||||
marketplace_path: str,
|
||||
format: str = "markdown"
|
||||
) -> dict:
|
||||
"""
|
||||
Generate a comprehensive compatibility report for all plugins.
|
||||
|
||||
Args:
|
||||
marketplace_path: Path to marketplace root directory
|
||||
format: Output format ("markdown" or "json")
|
||||
|
||||
Returns:
|
||||
Full compatibility report with all findings
|
||||
"""
|
||||
marketplace = Path(marketplace_path)
|
||||
plugins_dir = marketplace / "plugins"
|
||||
|
||||
if not plugins_dir.exists():
|
||||
return {
|
||||
"error": f"Plugins directory not found at {plugins_dir}",
|
||||
"marketplace_path": marketplace_path
|
||||
}
|
||||
|
||||
# Discover all plugins
|
||||
plugins = []
|
||||
for item in plugins_dir.iterdir():
|
||||
if item.is_dir() and (item / ".claude-plugin").exists():
|
||||
plugins.append(item)
|
||||
|
||||
if not plugins:
|
||||
return {
|
||||
"error": "No plugins found in marketplace",
|
||||
"marketplace_path": marketplace_path
|
||||
}
|
||||
|
||||
# Parse all plugin interfaces
|
||||
interfaces = {}
|
||||
all_issues = []
|
||||
summary = ReportSummary(total_plugins=len(plugins))
|
||||
|
||||
for plugin_path in plugins:
|
||||
interface = await self.parse_tools.parse_plugin_interface(str(plugin_path))
|
||||
if "error" not in interface:
|
||||
interfaces[interface["plugin_name"]] = interface
|
||||
summary.total_commands += len(interface.get("commands", []))
|
||||
summary.total_agents += len(interface.get("agents", []))
|
||||
summary.total_tools += len(interface.get("tools", []))
|
||||
|
||||
# Run pairwise compatibility checks
|
||||
plugin_names = list(interfaces.keys())
|
||||
compatibility_results = []
|
||||
|
||||
for i, name_a in enumerate(plugin_names):
|
||||
for name_b in plugin_names[i+1:]:
|
||||
path_a = plugins_dir / self._find_plugin_dir(plugins_dir, name_a)
|
||||
path_b = plugins_dir / self._find_plugin_dir(plugins_dir, name_b)
|
||||
|
||||
result = await self.validation_tools.validate_compatibility(
|
||||
str(path_a), str(path_b)
|
||||
)
|
||||
|
||||
if "error" not in result:
|
||||
compatibility_results.append(result)
|
||||
all_issues.extend(result.get("issues", []))
|
||||
|
||||
# Parse CLAUDE.md if exists
|
||||
claude_md = marketplace / "CLAUDE.md"
|
||||
agents_from_claude = []
|
||||
if claude_md.exists():
|
||||
agents_result = await self.parse_tools.parse_claude_md_agents(str(claude_md))
|
||||
if "error" not in agents_result:
|
||||
agents_from_claude = agents_result.get("agents", [])
|
||||
|
||||
# Validate each agent
|
||||
for agent in agents_from_claude:
|
||||
agent_result = await self.validation_tools.validate_agent_refs(
|
||||
agent["name"],
|
||||
str(claude_md),
|
||||
[str(p) for p in plugins]
|
||||
)
|
||||
if "error" not in agent_result:
|
||||
all_issues.extend(agent_result.get("issues", []))
|
||||
|
||||
# Count issues by severity
|
||||
for issue in all_issues:
|
||||
severity = issue.get("severity", "info")
|
||||
if isinstance(severity, str):
|
||||
severity_str = severity.lower()
|
||||
else:
|
||||
severity_str = severity.value if hasattr(severity, 'value') else str(severity).lower()
|
||||
|
||||
if "error" in severity_str:
|
||||
summary.errors += 1
|
||||
elif "warning" in severity_str:
|
||||
summary.warnings += 1
|
||||
else:
|
||||
summary.info += 1
|
||||
|
||||
summary.total_issues = len(all_issues)
|
||||
|
||||
# Generate report
|
||||
if format == "json":
|
||||
return {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"marketplace_path": marketplace_path,
|
||||
"summary": summary.model_dump(),
|
||||
"plugins": interfaces,
|
||||
"compatibility_checks": compatibility_results,
|
||||
"claude_md_agents": agents_from_claude,
|
||||
"all_issues": all_issues
|
||||
}
|
||||
else:
|
||||
# Generate markdown report
|
||||
report = self._generate_markdown_report(
|
||||
marketplace_path,
|
||||
summary,
|
||||
interfaces,
|
||||
compatibility_results,
|
||||
agents_from_claude,
|
||||
all_issues
|
||||
)
|
||||
return {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"marketplace_path": marketplace_path,
|
||||
"summary": summary.model_dump(),
|
||||
"report": report
|
||||
}
|
||||
|
||||
def _find_plugin_dir(self, plugins_dir: Path, plugin_name: str) -> str:
|
||||
"""Find plugin directory by name (handles naming variations)"""
|
||||
# Try exact match first
|
||||
for item in plugins_dir.iterdir():
|
||||
if item.is_dir():
|
||||
if item.name.lower() == plugin_name.lower():
|
||||
return item.name
|
||||
# Check plugin.json for name
|
||||
plugin_json = item / ".claude-plugin" / "plugin.json"
|
||||
if plugin_json.exists():
|
||||
import json
|
||||
try:
|
||||
data = json.loads(plugin_json.read_text())
|
||||
if data.get("name", "").lower() == plugin_name.lower():
|
||||
return item.name
|
||||
except:
|
||||
pass
|
||||
return plugin_name
|
||||
|
||||
def _generate_markdown_report(
|
||||
self,
|
||||
marketplace_path: str,
|
||||
summary: ReportSummary,
|
||||
interfaces: dict,
|
||||
compatibility_results: list,
|
||||
agents: list,
|
||||
issues: list
|
||||
) -> str:
|
||||
"""Generate markdown formatted report"""
|
||||
lines = [
|
||||
"# Contract Validation Report",
|
||||
"",
|
||||
f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
f"**Marketplace:** `{marketplace_path}`",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"| Metric | Count |",
|
||||
f"|--------|-------|",
|
||||
f"| Plugins | {summary.total_plugins} |",
|
||||
f"| Commands | {summary.total_commands} |",
|
||||
f"| Agents | {summary.total_agents} |",
|
||||
f"| Tools | {summary.total_tools} |",
|
||||
f"| **Issues** | **{summary.total_issues}** |",
|
||||
f"| - Errors | {summary.errors} |",
|
||||
f"| - Warnings | {summary.warnings} |",
|
||||
f"| - Info | {summary.info} |",
|
||||
"",
|
||||
]
|
||||
|
||||
# Plugin details
|
||||
lines.extend([
|
||||
"## Plugins",
|
||||
"",
|
||||
])
|
||||
|
||||
for name, interface in interfaces.items():
|
||||
cmds = len(interface.get("commands", []))
|
||||
agents_count = len(interface.get("agents", []))
|
||||
tools = len(interface.get("tools", []))
|
||||
lines.append(f"### {name}")
|
||||
lines.append("")
|
||||
lines.append(f"- Commands: {cmds}")
|
||||
lines.append(f"- Agents: {agents_count}")
|
||||
lines.append(f"- Tools: {tools}")
|
||||
lines.append("")
|
||||
|
||||
# Compatibility results
|
||||
if compatibility_results:
|
||||
lines.extend([
|
||||
"## Compatibility Checks",
|
||||
"",
|
||||
])
|
||||
|
||||
for result in compatibility_results:
|
||||
status = "✓" if result.get("compatible", True) else "✗"
|
||||
lines.append(f"### {result['plugin_a']} ↔ {result['plugin_b']} {status}")
|
||||
lines.append("")
|
||||
|
||||
if result.get("shared_tools"):
|
||||
lines.append(f"- Shared tools: `{', '.join(result['shared_tools'])}`")
|
||||
if result.get("issues"):
|
||||
for issue in result["issues"]:
|
||||
sev = issue.get("severity", "info")
|
||||
if hasattr(sev, 'value'):
|
||||
sev = sev.value
|
||||
lines.append(f"- [{sev.upper()}] {issue['message']}")
|
||||
lines.append("")
|
||||
|
||||
# Issues section
|
||||
if issues:
|
||||
lines.extend([
|
||||
"## All Issues",
|
||||
"",
|
||||
"| Severity | Type | Message |",
|
||||
"|----------|------|---------|",
|
||||
])
|
||||
|
||||
for issue in issues:
|
||||
sev = issue.get("severity", "info")
|
||||
itype = issue.get("issue_type", "unknown")
|
||||
msg = issue.get("message", "")
|
||||
|
||||
if hasattr(sev, 'value'):
|
||||
sev = sev.value
|
||||
if hasattr(itype, 'value'):
|
||||
itype = itype.value
|
||||
|
||||
# Truncate message for table
|
||||
msg_short = msg[:60] + "..." if len(msg) > 60 else msg
|
||||
lines.append(f"| {sev} | {itype} | {msg_short} |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def list_issues(
|
||||
self,
|
||||
marketplace_path: str,
|
||||
severity: str = "all",
|
||||
issue_type: str = "all"
|
||||
) -> dict:
|
||||
"""
|
||||
List validation issues with optional filtering.
|
||||
|
||||
Args:
|
||||
marketplace_path: Path to marketplace root directory
|
||||
severity: Filter by severity ("error", "warning", "info", "all")
|
||||
issue_type: Filter by type ("missing_tool", "interface_mismatch", etc., "all")
|
||||
|
||||
Returns:
|
||||
Filtered list of issues
|
||||
"""
|
||||
# Generate full report first
|
||||
report = await self.generate_compatibility_report(marketplace_path, format="json")
|
||||
|
||||
if "error" in report:
|
||||
return report
|
||||
|
||||
all_issues = report.get("all_issues", [])
|
||||
|
||||
# Filter by severity
|
||||
if severity != "all":
|
||||
filtered = []
|
||||
for issue in all_issues:
|
||||
issue_sev = issue.get("severity", "info")
|
||||
if hasattr(issue_sev, 'value'):
|
||||
issue_sev = issue_sev.value
|
||||
if isinstance(issue_sev, str) and severity.lower() in issue_sev.lower():
|
||||
filtered.append(issue)
|
||||
all_issues = filtered
|
||||
|
||||
# Filter by type
|
||||
if issue_type != "all":
|
||||
filtered = []
|
||||
for issue in all_issues:
|
||||
itype = issue.get("issue_type", "unknown")
|
||||
if hasattr(itype, 'value'):
|
||||
itype = itype.value
|
||||
if isinstance(itype, str) and issue_type.lower() in itype.lower():
|
||||
filtered.append(issue)
|
||||
all_issues = filtered
|
||||
|
||||
return {
|
||||
"marketplace_path": marketplace_path,
|
||||
"filters": {
|
||||
"severity": severity,
|
||||
"issue_type": issue_type
|
||||
},
|
||||
"total_issues": len(all_issues),
|
||||
"issues": all_issues
|
||||
}
|
||||
274
mcp-servers/contract-validator/mcp_server/server.py
Normal file
274
mcp-servers/contract-validator/mcp_server/server.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
MCP Server entry point for Contract Validator.
|
||||
|
||||
Provides cross-plugin compatibility validation and Claude.md agent verification
|
||||
tools to Claude Code via JSON-RPC 2.0 over stdio.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
from .parse_tools import ParseTools
|
||||
from .validation_tools import ValidationTools
|
||||
from .report_tools import ReportTools
|
||||
|
||||
# Suppress noisy MCP validation warnings on stderr
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("root").setLevel(logging.ERROR)
|
||||
logging.getLogger("mcp").setLevel(logging.ERROR)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContractValidatorMCPServer:
|
||||
"""MCP Server for cross-plugin compatibility validation"""
|
||||
|
||||
def __init__(self):
|
||||
self.server = Server("contract-validator-mcp")
|
||||
self.parse_tools = ParseTools()
|
||||
self.validation_tools = ValidationTools()
|
||||
self.report_tools = ReportTools()
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize server."""
|
||||
logger.info("Contract Validator MCP Server initialized")
|
||||
|
||||
def setup_tools(self):
|
||||
"""Register all available tools with the MCP server"""
|
||||
|
||||
@self.server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
"""Return list of available tools"""
|
||||
tools = [
|
||||
# Parse tools (to be implemented in #186)
|
||||
Tool(
|
||||
name="parse_plugin_interface",
|
||||
description="Parse plugin README.md to extract interface declarations (inputs, outputs, tools)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plugin_path": {
|
||||
"type": "string",
|
||||
"description": "Path to plugin directory or README.md"
|
||||
}
|
||||
},
|
||||
"required": ["plugin_path"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="parse_claude_md_agents",
|
||||
description="Parse Claude.md to extract agent definitions and their tool sequences",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"claude_md_path": {
|
||||
"type": "string",
|
||||
"description": "Path to CLAUDE.md file"
|
||||
}
|
||||
},
|
||||
"required": ["claude_md_path"]
|
||||
}
|
||||
),
|
||||
# Validation tools (to be implemented in #187)
|
||||
Tool(
|
||||
name="validate_compatibility",
|
||||
description="Validate compatibility between two plugin interfaces",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plugin_a": {
|
||||
"type": "string",
|
||||
"description": "Path to first plugin"
|
||||
},
|
||||
"plugin_b": {
|
||||
"type": "string",
|
||||
"description": "Path to second plugin"
|
||||
}
|
||||
},
|
||||
"required": ["plugin_a", "plugin_b"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="validate_agent_refs",
|
||||
description="Validate that all tool references in an agent definition exist",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_name": {
|
||||
"type": "string",
|
||||
"description": "Name of agent to validate"
|
||||
},
|
||||
"claude_md_path": {
|
||||
"type": "string",
|
||||
"description": "Path to CLAUDE.md containing agent"
|
||||
},
|
||||
"plugin_paths": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Paths to available plugins"
|
||||
}
|
||||
},
|
||||
"required": ["agent_name", "claude_md_path"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="validate_data_flow",
|
||||
description="Validate data flow through an agent's tool sequence",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_name": {
|
||||
"type": "string",
|
||||
"description": "Name of agent to validate"
|
||||
},
|
||||
"claude_md_path": {
|
||||
"type": "string",
|
||||
"description": "Path to CLAUDE.md containing agent"
|
||||
}
|
||||
},
|
||||
"required": ["agent_name", "claude_md_path"]
|
||||
}
|
||||
),
|
||||
# Report tools (to be implemented in #188)
|
||||
Tool(
|
||||
name="generate_compatibility_report",
|
||||
description="Generate a comprehensive compatibility report for all plugins",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"marketplace_path": {
|
||||
"type": "string",
|
||||
"description": "Path to marketplace root directory"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["markdown", "json"],
|
||||
"default": "markdown",
|
||||
"description": "Output format"
|
||||
}
|
||||
},
|
||||
"required": ["marketplace_path"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="list_issues",
|
||||
description="List validation issues with optional filtering",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"marketplace_path": {
|
||||
"type": "string",
|
||||
"description": "Path to marketplace root directory"
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["error", "warning", "info", "all"],
|
||||
"default": "all",
|
||||
"description": "Filter by severity"
|
||||
},
|
||||
"issue_type": {
|
||||
"type": "string",
|
||||
"enum": ["missing_tool", "interface_mismatch", "optional_dependency", "undeclared_output", "all"],
|
||||
"default": "all",
|
||||
"description": "Filter by issue type"
|
||||
}
|
||||
},
|
||||
"required": ["marketplace_path"]
|
||||
}
|
||||
)
|
||||
]
|
||||
return tools
|
||||
|
||||
@self.server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
"""Handle tool invocation."""
|
||||
try:
|
||||
# All tools return placeholder responses for now
|
||||
# Actual implementation will be added in issues #186, #187, #188
|
||||
|
||||
if name == "parse_plugin_interface":
|
||||
result = await self._parse_plugin_interface(**arguments)
|
||||
elif name == "parse_claude_md_agents":
|
||||
result = await self._parse_claude_md_agents(**arguments)
|
||||
elif name == "validate_compatibility":
|
||||
result = await self._validate_compatibility(**arguments)
|
||||
elif name == "validate_agent_refs":
|
||||
result = await self._validate_agent_refs(**arguments)
|
||||
elif name == "validate_data_flow":
|
||||
result = await self._validate_data_flow(**arguments)
|
||||
elif name == "generate_compatibility_report":
|
||||
result = await self._generate_compatibility_report(**arguments)
|
||||
elif name == "list_issues":
|
||||
result = await self._list_issues(**arguments)
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2, default=str)
|
||||
)]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Tool {name} failed: {e}")
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": str(e)}, indent=2)
|
||||
)]
|
||||
|
||||
# Parse tool implementations (Issue #186)
|
||||
|
||||
async def _parse_plugin_interface(self, plugin_path: str) -> dict:
|
||||
"""Parse plugin interface from README.md"""
|
||||
return await self.parse_tools.parse_plugin_interface(plugin_path)
|
||||
|
||||
async def _parse_claude_md_agents(self, claude_md_path: str) -> dict:
|
||||
"""Parse agents from CLAUDE.md"""
|
||||
return await self.parse_tools.parse_claude_md_agents(claude_md_path)
|
||||
|
||||
# Validation tool implementations (Issue #187)
|
||||
|
||||
async def _validate_compatibility(self, plugin_a: str, plugin_b: str) -> dict:
|
||||
"""Validate compatibility between plugins"""
|
||||
return await self.validation_tools.validate_compatibility(plugin_a, plugin_b)
|
||||
|
||||
async def _validate_agent_refs(self, agent_name: str, claude_md_path: str, plugin_paths: list = None) -> dict:
|
||||
"""Validate agent tool references"""
|
||||
return await self.validation_tools.validate_agent_refs(agent_name, claude_md_path, plugin_paths)
|
||||
|
||||
async def _validate_data_flow(self, agent_name: str, claude_md_path: str) -> dict:
|
||||
"""Validate agent data flow"""
|
||||
return await self.validation_tools.validate_data_flow(agent_name, claude_md_path)
|
||||
|
||||
# Report tool implementations (Issue #188)
|
||||
|
||||
async def _generate_compatibility_report(self, marketplace_path: str, format: str = "markdown") -> dict:
|
||||
"""Generate comprehensive compatibility report"""
|
||||
return await self.report_tools.generate_compatibility_report(marketplace_path, format)
|
||||
|
||||
async def _list_issues(self, marketplace_path: str, severity: str = "all", issue_type: str = "all") -> dict:
|
||||
"""List validation issues with filtering"""
|
||||
return await self.report_tools.list_issues(marketplace_path, severity, issue_type)
|
||||
|
||||
async def run(self):
|
||||
"""Run the MCP server"""
|
||||
await self.initialize()
|
||||
self.setup_tools()
|
||||
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await self.server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
self.server.create_initialization_options()
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point"""
|
||||
server = ContractValidatorMCPServer()
|
||||
await server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
338
mcp-servers/contract-validator/mcp_server/validation_tools.py
Normal file
338
mcp-servers/contract-validator/mcp_server/validation_tools.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Validation tools for checking cross-plugin compatibility and agent references.
|
||||
|
||||
Provides:
|
||||
- validate_compatibility: Compare two plugin interfaces
|
||||
- validate_agent_refs: Check agent tool references exist
|
||||
- validate_data_flow: Verify data flow through agent sequences
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from enum import Enum
|
||||
|
||||
from .parse_tools import ParseTools, PluginInterface, ClaudeMdAgent
|
||||
|
||||
|
||||
class IssueSeverity(str, Enum):
|
||||
ERROR = "error"
|
||||
WARNING = "warning"
|
||||
INFO = "info"
|
||||
|
||||
|
||||
class IssueType(str, Enum):
|
||||
MISSING_TOOL = "missing_tool"
|
||||
INTERFACE_MISMATCH = "interface_mismatch"
|
||||
OPTIONAL_DEPENDENCY = "optional_dependency"
|
||||
UNDECLARED_OUTPUT = "undeclared_output"
|
||||
INVALID_SEQUENCE = "invalid_sequence"
|
||||
|
||||
|
||||
class ValidationIssue(BaseModel):
|
||||
"""A single validation issue"""
|
||||
severity: IssueSeverity
|
||||
issue_type: IssueType
|
||||
message: str
|
||||
location: Optional[str] = None
|
||||
suggestion: Optional[str] = None
|
||||
|
||||
|
||||
class CompatibilityResult(BaseModel):
|
||||
"""Result of compatibility check between two plugins"""
|
||||
plugin_a: str
|
||||
plugin_b: str
|
||||
compatible: bool
|
||||
shared_tools: list[str] = []
|
||||
a_only_tools: list[str] = []
|
||||
b_only_tools: list[str] = []
|
||||
issues: list[ValidationIssue] = []
|
||||
|
||||
|
||||
class AgentValidationResult(BaseModel):
|
||||
"""Result of agent reference validation"""
|
||||
agent_name: str
|
||||
valid: bool
|
||||
tool_refs_found: list[str] = []
|
||||
tool_refs_missing: list[str] = []
|
||||
issues: list[ValidationIssue] = []
|
||||
|
||||
|
||||
class DataFlowResult(BaseModel):
|
||||
"""Result of data flow validation"""
|
||||
agent_name: str
|
||||
valid: bool
|
||||
flow_steps: list[str] = []
|
||||
issues: list[ValidationIssue] = []
|
||||
|
||||
|
||||
class ValidationTools:
|
||||
"""Tools for validating plugin compatibility and agent references"""
|
||||
|
||||
def __init__(self):
|
||||
self.parse_tools = ParseTools()
|
||||
|
||||
async def validate_compatibility(self, plugin_a: str, plugin_b: str) -> dict:
|
||||
"""
|
||||
Validate compatibility between two plugin interfaces.
|
||||
|
||||
Compares tools, commands, and agents to identify overlaps and gaps.
|
||||
|
||||
Args:
|
||||
plugin_a: Path to first plugin directory
|
||||
plugin_b: Path to second plugin directory
|
||||
|
||||
Returns:
|
||||
Compatibility report with shared tools, unique tools, and issues
|
||||
"""
|
||||
# Parse both plugins
|
||||
interface_a = await self.parse_tools.parse_plugin_interface(plugin_a)
|
||||
interface_b = await self.parse_tools.parse_plugin_interface(plugin_b)
|
||||
|
||||
# Check for parse errors
|
||||
if "error" in interface_a:
|
||||
return {
|
||||
"error": f"Failed to parse plugin A: {interface_a['error']}",
|
||||
"plugin_a": plugin_a,
|
||||
"plugin_b": plugin_b
|
||||
}
|
||||
if "error" in interface_b:
|
||||
return {
|
||||
"error": f"Failed to parse plugin B: {interface_b['error']}",
|
||||
"plugin_a": plugin_a,
|
||||
"plugin_b": plugin_b
|
||||
}
|
||||
|
||||
# Extract tool names
|
||||
tools_a = set(t["name"] for t in interface_a.get("tools", []))
|
||||
tools_b = set(t["name"] for t in interface_b.get("tools", []))
|
||||
|
||||
# Find overlaps and differences
|
||||
shared = tools_a & tools_b
|
||||
a_only = tools_a - tools_b
|
||||
b_only = tools_b - tools_a
|
||||
|
||||
issues = []
|
||||
|
||||
# Check for potential naming conflicts
|
||||
if shared:
|
||||
issues.append(ValidationIssue(
|
||||
severity=IssueSeverity.WARNING,
|
||||
issue_type=IssueType.INTERFACE_MISMATCH,
|
||||
message=f"Both plugins define tools with same names: {list(shared)}",
|
||||
location=f"{interface_a['plugin_name']} and {interface_b['plugin_name']}",
|
||||
suggestion="Ensure tools with same names have compatible interfaces"
|
||||
))
|
||||
|
||||
# Check command overlaps
|
||||
cmds_a = set(c["name"] for c in interface_a.get("commands", []))
|
||||
cmds_b = set(c["name"] for c in interface_b.get("commands", []))
|
||||
shared_cmds = cmds_a & cmds_b
|
||||
|
||||
if shared_cmds:
|
||||
issues.append(ValidationIssue(
|
||||
severity=IssueSeverity.ERROR,
|
||||
issue_type=IssueType.INTERFACE_MISMATCH,
|
||||
message=f"Command name conflict: {list(shared_cmds)}",
|
||||
location=f"{interface_a['plugin_name']} and {interface_b['plugin_name']}",
|
||||
suggestion="Rename conflicting commands to avoid ambiguity"
|
||||
))
|
||||
|
||||
result = CompatibilityResult(
|
||||
plugin_a=interface_a["plugin_name"],
|
||||
plugin_b=interface_b["plugin_name"],
|
||||
compatible=len([i for i in issues if i.severity == IssueSeverity.ERROR]) == 0,
|
||||
shared_tools=list(shared),
|
||||
a_only_tools=list(a_only),
|
||||
b_only_tools=list(b_only),
|
||||
issues=issues
|
||||
)
|
||||
|
||||
return result.model_dump()
|
||||
|
||||
async def validate_agent_refs(
|
||||
self,
|
||||
agent_name: str,
|
||||
claude_md_path: str,
|
||||
plugin_paths: list[str] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Validate that all tool references in an agent definition exist.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent to validate
|
||||
claude_md_path: Path to CLAUDE.md containing the agent
|
||||
plugin_paths: Optional list of plugin paths to check for tools
|
||||
|
||||
Returns:
|
||||
Validation result with found/missing tools and issues
|
||||
"""
|
||||
# Parse CLAUDE.md for agents
|
||||
agents_result = await self.parse_tools.parse_claude_md_agents(claude_md_path)
|
||||
|
||||
if "error" in agents_result:
|
||||
return {
|
||||
"error": agents_result["error"],
|
||||
"agent_name": agent_name
|
||||
}
|
||||
|
||||
# Find the specific agent
|
||||
agent = None
|
||||
for a in agents_result.get("agents", []):
|
||||
if a["name"].lower() == agent_name.lower():
|
||||
agent = a
|
||||
break
|
||||
|
||||
if not agent:
|
||||
return {
|
||||
"error": f"Agent '{agent_name}' not found in {claude_md_path}",
|
||||
"agent_name": agent_name,
|
||||
"available_agents": [a["name"] for a in agents_result.get("agents", [])]
|
||||
}
|
||||
|
||||
# Collect all available tools from plugins
|
||||
available_tools = set()
|
||||
if plugin_paths:
|
||||
for plugin_path in plugin_paths:
|
||||
interface = await self.parse_tools.parse_plugin_interface(plugin_path)
|
||||
if "error" not in interface:
|
||||
for tool in interface.get("tools", []):
|
||||
available_tools.add(tool["name"])
|
||||
|
||||
# Check agent tool references
|
||||
tool_refs = set(agent.get("tool_refs", []))
|
||||
found = tool_refs & available_tools if available_tools else tool_refs
|
||||
missing = tool_refs - available_tools if available_tools else set()
|
||||
|
||||
issues = []
|
||||
|
||||
# Report missing tools
|
||||
for tool in missing:
|
||||
issues.append(ValidationIssue(
|
||||
severity=IssueSeverity.ERROR,
|
||||
issue_type=IssueType.MISSING_TOOL,
|
||||
message=f"Agent '{agent_name}' references tool '{tool}' which is not found",
|
||||
location=claude_md_path,
|
||||
suggestion=f"Check if tool '{tool}' exists or fix the reference"
|
||||
))
|
||||
|
||||
# Check if agent has no tool refs (might be incomplete)
|
||||
if not tool_refs:
|
||||
issues.append(ValidationIssue(
|
||||
severity=IssueSeverity.INFO,
|
||||
issue_type=IssueType.UNDECLARED_OUTPUT,
|
||||
message=f"Agent '{agent_name}' has no documented tool references",
|
||||
location=claude_md_path,
|
||||
suggestion="Consider documenting which tools this agent uses"
|
||||
))
|
||||
|
||||
result = AgentValidationResult(
|
||||
agent_name=agent_name,
|
||||
valid=len([i for i in issues if i.severity == IssueSeverity.ERROR]) == 0,
|
||||
tool_refs_found=list(found),
|
||||
tool_refs_missing=list(missing),
|
||||
issues=issues
|
||||
)
|
||||
|
||||
return result.model_dump()
|
||||
|
||||
async def validate_data_flow(self, agent_name: str, claude_md_path: str) -> dict:
|
||||
"""
|
||||
Validate data flow through an agent's tool sequence.
|
||||
|
||||
Checks that each step's expected output can be used by the next step.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent to validate
|
||||
claude_md_path: Path to CLAUDE.md containing the agent
|
||||
|
||||
Returns:
|
||||
Data flow validation result with steps and issues
|
||||
"""
|
||||
# Parse CLAUDE.md for agents
|
||||
agents_result = await self.parse_tools.parse_claude_md_agents(claude_md_path)
|
||||
|
||||
if "error" in agents_result:
|
||||
return {
|
||||
"error": agents_result["error"],
|
||||
"agent_name": agent_name
|
||||
}
|
||||
|
||||
# Find the specific agent
|
||||
agent = None
|
||||
for a in agents_result.get("agents", []):
|
||||
if a["name"].lower() == agent_name.lower():
|
||||
agent = a
|
||||
break
|
||||
|
||||
if not agent:
|
||||
return {
|
||||
"error": f"Agent '{agent_name}' not found in {claude_md_path}",
|
||||
"agent_name": agent_name,
|
||||
"available_agents": [a["name"] for a in agents_result.get("agents", [])]
|
||||
}
|
||||
|
||||
issues = []
|
||||
flow_steps = []
|
||||
|
||||
# Extract workflow steps
|
||||
workflow_steps = agent.get("workflow_steps", [])
|
||||
responsibilities = agent.get("responsibilities", [])
|
||||
|
||||
# Build flow from workflow steps or responsibilities
|
||||
steps = workflow_steps if workflow_steps else responsibilities
|
||||
|
||||
for i, step in enumerate(steps):
|
||||
flow_steps.append(f"Step {i+1}: {step}")
|
||||
|
||||
# Check for data flow patterns
|
||||
tool_refs = agent.get("tool_refs", [])
|
||||
|
||||
# Known data flow patterns
|
||||
# e.g., data-platform produces data_ref, viz-platform consumes it
|
||||
known_producers = {
|
||||
"read_csv": "data_ref",
|
||||
"read_parquet": "data_ref",
|
||||
"pg_query": "data_ref",
|
||||
"filter": "data_ref",
|
||||
"groupby": "data_ref",
|
||||
}
|
||||
|
||||
known_consumers = {
|
||||
"describe": "data_ref",
|
||||
"head": "data_ref",
|
||||
"tail": "data_ref",
|
||||
"to_csv": "data_ref",
|
||||
"to_parquet": "data_ref",
|
||||
}
|
||||
|
||||
# Check if agent uses tools that require data_ref
|
||||
has_producer = any(t in known_producers for t in tool_refs)
|
||||
has_consumer = any(t in known_consumers for t in tool_refs)
|
||||
|
||||
if has_consumer and not has_producer:
|
||||
issues.append(ValidationIssue(
|
||||
severity=IssueSeverity.WARNING,
|
||||
issue_type=IssueType.INTERFACE_MISMATCH,
|
||||
message=f"Agent '{agent_name}' uses tools that consume data_ref but no producer found",
|
||||
location=claude_md_path,
|
||||
suggestion="Ensure a data loading tool (read_csv, pg_query, etc.) is used before data consumers"
|
||||
))
|
||||
|
||||
# Check for empty workflow
|
||||
if not steps and not tool_refs:
|
||||
issues.append(ValidationIssue(
|
||||
severity=IssueSeverity.INFO,
|
||||
issue_type=IssueType.UNDECLARED_OUTPUT,
|
||||
message=f"Agent '{agent_name}' has no documented workflow or tool sequence",
|
||||
location=claude_md_path,
|
||||
suggestion="Consider documenting the agent's workflow steps"
|
||||
))
|
||||
|
||||
result = DataFlowResult(
|
||||
agent_name=agent_name,
|
||||
valid=len([i for i in issues if i.severity == IssueSeverity.ERROR]) == 0,
|
||||
flow_steps=flow_steps,
|
||||
issues=issues
|
||||
)
|
||||
|
||||
return result.model_dump()
|
||||
41
mcp-servers/contract-validator/pyproject.toml
Normal file
41
mcp-servers/contract-validator/pyproject.toml
Normal file
@@ -0,0 +1,41 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "contract-validator-mcp"
|
||||
version = "1.0.0"
|
||||
description = "MCP Server for cross-plugin compatibility validation and agent verification"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{name = "Leo Miranda"}
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"mcp>=0.9.0",
|
||||
"pydantic>=2.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.4.3",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["mcp_server*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
9
mcp-servers/contract-validator/requirements.txt
Normal file
9
mcp-servers/contract-validator/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
# MCP SDK
|
||||
mcp>=0.9.0
|
||||
|
||||
# Utilities
|
||||
pydantic>=2.5.0
|
||||
|
||||
# Testing
|
||||
pytest>=7.4.3
|
||||
pytest-asyncio>=0.23.0
|
||||
21
mcp-servers/contract-validator/run.sh
Executable file
21
mcp-servers/contract-validator/run.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Capture original working directory before any cd operations
|
||||
# This should be the user's project directory when launched by Claude Code
|
||||
export CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CACHE_VENV="$HOME/.cache/claude-mcp-venvs/leo-claude-mktplace/contract-validator/.venv"
|
||||
LOCAL_VENV="$SCRIPT_DIR/.venv"
|
||||
|
||||
if [[ -f "$CACHE_VENV/bin/python" ]]; then
|
||||
PYTHON="$CACHE_VENV/bin/python"
|
||||
elif [[ -f "$LOCAL_VENV/bin/python" ]]; then
|
||||
PYTHON="$LOCAL_VENV/bin/python"
|
||||
else
|
||||
echo "ERROR: No venv found. Run: ./scripts/setup-venvs.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
export PYTHONPATH="$SCRIPT_DIR"
|
||||
exec "$PYTHON" -m mcp_server.server "$@"
|
||||
1
mcp-servers/contract-validator/tests/__init__.py
Normal file
1
mcp-servers/contract-validator/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests for contract-validator MCP server
|
||||
193
mcp-servers/contract-validator/tests/test_parse_tools.py
Normal file
193
mcp-servers/contract-validator/tests/test_parse_tools.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Unit tests for parse tools.
|
||||
"""
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parse_tools():
|
||||
"""Create ParseTools instance"""
|
||||
from mcp_server.parse_tools import ParseTools
|
||||
return ParseTools()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_readme(tmp_path):
|
||||
"""Create a sample README.md for testing"""
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("""# Test Plugin
|
||||
|
||||
A test plugin for validation.
|
||||
|
||||
## Features
|
||||
|
||||
- **Feature One**: Does something
|
||||
- **Feature Two**: Does something else
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/test-cmd` | Test command |
|
||||
| `/another-cmd` | Another test command |
|
||||
|
||||
## Agents
|
||||
|
||||
| Agent | Description |
|
||||
|-------|-------------|
|
||||
| `test-agent` | A test agent |
|
||||
|
||||
## Tools Summary
|
||||
|
||||
### Category A (3 tools)
|
||||
`tool_a`, `tool_b`, `tool_c`
|
||||
|
||||
### Category B (2 tools)
|
||||
`tool_d`, `tool_e`
|
||||
""")
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_claude_md(tmp_path):
|
||||
"""Create a sample CLAUDE.md for testing"""
|
||||
claude_md = tmp_path / "CLAUDE.md"
|
||||
claude_md.write_text("""# CLAUDE.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
### Four-Agent Model (test)
|
||||
|
||||
| Agent | Personality | Responsibilities |
|
||||
|-------|-------------|------------------|
|
||||
| **Planner** | Thoughtful | Planning via `create_issue`, `search_lessons` |
|
||||
| **Executor** | Focused | Implementation via `write`, `edit` |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Planner creates issues
|
||||
2. Executor implements code
|
||||
""")
|
||||
return str(claude_md)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_plugin_interface_basic(parse_tools, sample_readme):
|
||||
"""Test basic plugin interface parsing"""
|
||||
result = await parse_tools.parse_plugin_interface(sample_readme)
|
||||
|
||||
assert "error" not in result
|
||||
# Plugin name extraction strips "Plugin" suffix
|
||||
assert result["plugin_name"] == "Test"
|
||||
assert "A test plugin" in result["description"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_plugin_interface_commands(parse_tools, sample_readme):
|
||||
"""Test command extraction from README"""
|
||||
result = await parse_tools.parse_plugin_interface(sample_readme)
|
||||
|
||||
commands = result["commands"]
|
||||
assert len(commands) == 2
|
||||
assert commands[0]["name"] == "/test-cmd"
|
||||
assert commands[1]["name"] == "/another-cmd"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_plugin_interface_agents(parse_tools, sample_readme):
|
||||
"""Test agent extraction from README"""
|
||||
result = await parse_tools.parse_plugin_interface(sample_readme)
|
||||
|
||||
agents = result["agents"]
|
||||
assert len(agents) == 1
|
||||
assert agents[0]["name"] == "test-agent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_plugin_interface_tools(parse_tools, sample_readme):
|
||||
"""Test tool extraction from README"""
|
||||
result = await parse_tools.parse_plugin_interface(sample_readme)
|
||||
|
||||
tools = result["tools"]
|
||||
tool_names = [t["name"] for t in tools]
|
||||
assert "tool_a" in tool_names
|
||||
assert "tool_b" in tool_names
|
||||
assert "tool_e" in tool_names
|
||||
assert len(tools) >= 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_plugin_interface_categories(parse_tools, sample_readme):
|
||||
"""Test tool category extraction"""
|
||||
result = await parse_tools.parse_plugin_interface(sample_readme)
|
||||
|
||||
categories = result["tool_categories"]
|
||||
assert "Category A" in categories
|
||||
assert "Category B" in categories
|
||||
assert "tool_a" in categories["Category A"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_plugin_interface_features(parse_tools, sample_readme):
|
||||
"""Test feature extraction"""
|
||||
result = await parse_tools.parse_plugin_interface(sample_readme)
|
||||
|
||||
features = result["features"]
|
||||
assert "Feature One" in features
|
||||
assert "Feature Two" in features
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_plugin_interface_not_found(parse_tools, tmp_path):
|
||||
"""Test error when README not found"""
|
||||
result = await parse_tools.parse_plugin_interface(str(tmp_path / "nonexistent"))
|
||||
|
||||
assert "error" in result
|
||||
assert "not found" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_claude_md_agents(parse_tools, sample_claude_md):
|
||||
"""Test agent extraction from CLAUDE.md"""
|
||||
result = await parse_tools.parse_claude_md_agents(sample_claude_md)
|
||||
|
||||
assert "error" not in result
|
||||
assert result["agent_count"] == 2
|
||||
|
||||
agents = result["agents"]
|
||||
agent_names = [a["name"] for a in agents]
|
||||
assert "Planner" in agent_names
|
||||
assert "Executor" in agent_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_claude_md_tool_refs(parse_tools, sample_claude_md):
|
||||
"""Test tool reference extraction from agents"""
|
||||
result = await parse_tools.parse_claude_md_agents(sample_claude_md)
|
||||
|
||||
agents = {a["name"]: a for a in result["agents"]}
|
||||
planner = agents["Planner"]
|
||||
|
||||
assert "create_issue" in planner["tool_refs"]
|
||||
assert "search_lessons" in planner["tool_refs"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_claude_md_not_found(parse_tools, tmp_path):
|
||||
"""Test error when CLAUDE.md not found"""
|
||||
result = await parse_tools.parse_claude_md_agents(str(tmp_path / "CLAUDE.md"))
|
||||
|
||||
assert "error" in result
|
||||
assert "not found" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_plugin_with_direct_file(parse_tools, sample_readme):
|
||||
"""Test parsing with direct file path instead of directory"""
|
||||
readme_path = Path(sample_readme) / "README.md"
|
||||
result = await parse_tools.parse_plugin_interface(str(readme_path))
|
||||
|
||||
assert "error" not in result
|
||||
# Plugin name extraction strips "Plugin" suffix
|
||||
assert result["plugin_name"] == "Test"
|
||||
261
mcp-servers/contract-validator/tests/test_report_tools.py
Normal file
261
mcp-servers/contract-validator/tests/test_report_tools.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Unit tests for report tools.
|
||||
"""
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def report_tools():
|
||||
"""Create ReportTools instance"""
|
||||
from mcp_server.report_tools import ReportTools
|
||||
return ReportTools()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_marketplace(tmp_path):
|
||||
"""Create a sample marketplace structure"""
|
||||
import json
|
||||
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
plugins_dir.mkdir()
|
||||
|
||||
# Plugin 1
|
||||
plugin1 = plugins_dir / "plugin-one"
|
||||
plugin1.mkdir()
|
||||
plugin1_meta = plugin1 / ".claude-plugin"
|
||||
plugin1_meta.mkdir()
|
||||
(plugin1_meta / "plugin.json").write_text(json.dumps({"name": "plugin-one"}))
|
||||
(plugin1 / "README.md").write_text("""# plugin-one
|
||||
|
||||
First test plugin.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/cmd-one` | Command one |
|
||||
|
||||
## Tools Summary
|
||||
|
||||
### Tools (2 tools)
|
||||
`tool_a`, `tool_b`
|
||||
""")
|
||||
|
||||
# Plugin 2
|
||||
plugin2 = plugins_dir / "plugin-two"
|
||||
plugin2.mkdir()
|
||||
plugin2_meta = plugin2 / ".claude-plugin"
|
||||
plugin2_meta.mkdir()
|
||||
(plugin2_meta / "plugin.json").write_text(json.dumps({"name": "plugin-two"}))
|
||||
(plugin2 / "README.md").write_text("""# plugin-two
|
||||
|
||||
Second test plugin.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/cmd-two` | Command two |
|
||||
|
||||
## Tools Summary
|
||||
|
||||
### Tools (2 tools)
|
||||
`tool_c`, `tool_d`
|
||||
""")
|
||||
|
||||
# Plugin 3 (with conflict)
|
||||
plugin3 = plugins_dir / "plugin-three"
|
||||
plugin3.mkdir()
|
||||
plugin3_meta = plugin3 / ".claude-plugin"
|
||||
plugin3_meta.mkdir()
|
||||
(plugin3_meta / "plugin.json").write_text(json.dumps({"name": "plugin-three"}))
|
||||
(plugin3 / "README.md").write_text("""# plugin-three
|
||||
|
||||
Third test plugin with conflict.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/cmd-one` | Conflicting command |
|
||||
|
||||
## Tools Summary
|
||||
|
||||
### Tools (1 tool)
|
||||
`tool_e`
|
||||
""")
|
||||
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def marketplace_no_plugins(tmp_path):
|
||||
"""Create marketplace with no plugins"""
|
||||
plugins_dir = tmp_path / "plugins"
|
||||
plugins_dir.mkdir()
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def marketplace_no_dir(tmp_path):
|
||||
"""Create path without plugins directory"""
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_report_json_format(report_tools, sample_marketplace):
|
||||
"""Test JSON format report generation"""
|
||||
result = await report_tools.generate_compatibility_report(
|
||||
sample_marketplace, "json"
|
||||
)
|
||||
|
||||
assert "error" not in result
|
||||
assert "generated_at" in result
|
||||
assert "summary" in result
|
||||
assert "plugins" in result
|
||||
assert result["summary"]["total_plugins"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_report_markdown_format(report_tools, sample_marketplace):
|
||||
"""Test markdown format report generation"""
|
||||
result = await report_tools.generate_compatibility_report(
|
||||
sample_marketplace, "markdown"
|
||||
)
|
||||
|
||||
assert "error" not in result
|
||||
assert "report" in result
|
||||
assert "# Contract Validation Report" in result["report"]
|
||||
assert "## Summary" in result["report"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_report_finds_conflicts(report_tools, sample_marketplace):
|
||||
"""Test that report finds command conflicts"""
|
||||
result = await report_tools.generate_compatibility_report(
|
||||
sample_marketplace, "json"
|
||||
)
|
||||
|
||||
assert "error" not in result
|
||||
assert result["summary"]["errors"] > 0
|
||||
assert result["summary"]["total_issues"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_report_counts_correctly(report_tools, sample_marketplace):
|
||||
"""Test summary counts are correct"""
|
||||
result = await report_tools.generate_compatibility_report(
|
||||
sample_marketplace, "json"
|
||||
)
|
||||
|
||||
summary = result["summary"]
|
||||
assert summary["total_plugins"] == 3
|
||||
assert summary["total_commands"] == 3 # 3 commands total
|
||||
assert summary["total_tools"] == 5 # a, b, c, d, e
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_report_no_plugins(report_tools, marketplace_no_plugins):
|
||||
"""Test error when no plugins found"""
|
||||
result = await report_tools.generate_compatibility_report(
|
||||
marketplace_no_plugins, "json"
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
assert "no plugins" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_report_no_plugins_dir(report_tools, marketplace_no_dir):
|
||||
"""Test error when plugins directory doesn't exist"""
|
||||
result = await report_tools.generate_compatibility_report(
|
||||
marketplace_no_dir, "json"
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
assert "not found" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_issues_all(report_tools, sample_marketplace):
|
||||
"""Test listing all issues"""
|
||||
result = await report_tools.list_issues(sample_marketplace, "all", "all")
|
||||
|
||||
assert "error" not in result
|
||||
assert "issues" in result
|
||||
assert result["total_issues"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_issues_filter_by_severity(report_tools, sample_marketplace):
|
||||
"""Test filtering issues by severity"""
|
||||
all_result = await report_tools.list_issues(sample_marketplace, "all", "all")
|
||||
error_result = await report_tools.list_issues(sample_marketplace, "error", "all")
|
||||
|
||||
# Error count should be less than or equal to all
|
||||
assert error_result["total_issues"] <= all_result["total_issues"]
|
||||
|
||||
# All issues should have error severity
|
||||
for issue in error_result["issues"]:
|
||||
sev = issue.get("severity", "")
|
||||
if hasattr(sev, 'value'):
|
||||
sev = sev.value
|
||||
assert "error" in str(sev).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_issues_filter_by_type(report_tools, sample_marketplace):
|
||||
"""Test filtering issues by type"""
|
||||
result = await report_tools.list_issues(
|
||||
sample_marketplace, "all", "interface_mismatch"
|
||||
)
|
||||
|
||||
# All issues should have matching type
|
||||
for issue in result["issues"]:
|
||||
itype = issue.get("issue_type", "")
|
||||
if hasattr(itype, 'value'):
|
||||
itype = itype.value
|
||||
assert "interface_mismatch" in str(itype).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_issues_combined_filters(report_tools, sample_marketplace):
|
||||
"""Test combined severity and type filters"""
|
||||
result = await report_tools.list_issues(
|
||||
sample_marketplace, "error", "interface_mismatch"
|
||||
)
|
||||
|
||||
assert "error" not in result
|
||||
# Should have command conflict errors
|
||||
assert result["total_issues"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_markdown_has_all_sections(report_tools, sample_marketplace):
|
||||
"""Test markdown report contains all expected sections"""
|
||||
result = await report_tools.generate_compatibility_report(
|
||||
sample_marketplace, "markdown"
|
||||
)
|
||||
|
||||
report = result["report"]
|
||||
assert "## Summary" in report
|
||||
assert "## Plugins" in report
|
||||
# Compatibility section only if there are checks
|
||||
assert "Plugin One" in report or "plugin-one" in report.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_includes_suggestions(report_tools, sample_marketplace):
|
||||
"""Test that issues include suggestions"""
|
||||
result = await report_tools.generate_compatibility_report(
|
||||
sample_marketplace, "json"
|
||||
)
|
||||
|
||||
issues = result.get("all_issues", [])
|
||||
# Find an issue with a suggestion
|
||||
issues_with_suggestions = [
|
||||
i for i in issues
|
||||
if i.get("suggestion")
|
||||
]
|
||||
assert len(issues_with_suggestions) > 0
|
||||
256
mcp-servers/contract-validator/tests/test_validation_tools.py
Normal file
256
mcp-servers/contract-validator/tests/test_validation_tools.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Unit tests for validation tools.
|
||||
"""
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validation_tools():
|
||||
"""Create ValidationTools instance"""
|
||||
from mcp_server.validation_tools import ValidationTools
|
||||
return ValidationTools()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_a(tmp_path):
|
||||
"""Create first test plugin"""
|
||||
plugin_dir = tmp_path / "plugin-a"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / ".claude-plugin").mkdir()
|
||||
|
||||
readme = plugin_dir / "README.md"
|
||||
readme.write_text("""# Plugin A
|
||||
|
||||
Test plugin A.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/setup-a` | Setup A |
|
||||
| `/shared-cmd` | Shared command |
|
||||
|
||||
## Tools Summary
|
||||
|
||||
### Core (2 tools)
|
||||
`tool_one`, `tool_two`
|
||||
""")
|
||||
return str(plugin_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_b(tmp_path):
|
||||
"""Create second test plugin"""
|
||||
plugin_dir = tmp_path / "plugin-b"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / ".claude-plugin").mkdir()
|
||||
|
||||
readme = plugin_dir / "README.md"
|
||||
readme.write_text("""# Plugin B
|
||||
|
||||
Test plugin B.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/setup-b` | Setup B |
|
||||
| `/shared-cmd` | Shared command (conflict!) |
|
||||
|
||||
## Tools Summary
|
||||
|
||||
### Core (2 tools)
|
||||
`tool_two`, `tool_three`
|
||||
""")
|
||||
return str(plugin_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_no_conflict(tmp_path):
|
||||
"""Create plugin with no conflicts"""
|
||||
plugin_dir = tmp_path / "plugin-c"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / ".claude-plugin").mkdir()
|
||||
|
||||
readme = plugin_dir / "README.md"
|
||||
readme.write_text("""# Plugin C
|
||||
|
||||
Test plugin C.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `/unique-cmd` | Unique command |
|
||||
|
||||
## Tools Summary
|
||||
|
||||
### Core (1 tool)
|
||||
`unique_tool`
|
||||
""")
|
||||
return str(plugin_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def claude_md_with_agents(tmp_path):
|
||||
"""Create CLAUDE.md with agent definitions"""
|
||||
claude_md = tmp_path / "CLAUDE.md"
|
||||
claude_md.write_text("""# CLAUDE.md
|
||||
|
||||
### Four-Agent Model
|
||||
|
||||
| Agent | Personality | Responsibilities |
|
||||
|-------|-------------|------------------|
|
||||
| **TestAgent** | Careful | Uses `tool_one`, `tool_two`, `missing_tool` |
|
||||
| **ValidAgent** | Thorough | Uses `tool_one` only |
|
||||
| **EmptyAgent** | Unknown | General tasks |
|
||||
""")
|
||||
return str(claude_md)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_compatibility_command_conflict(validation_tools, plugin_a, plugin_b):
|
||||
"""Test detection of command name conflicts"""
|
||||
result = await validation_tools.validate_compatibility(plugin_a, plugin_b)
|
||||
|
||||
assert "error" not in result
|
||||
assert result["compatible"] is False
|
||||
|
||||
# Find the command conflict issue
|
||||
error_issues = [i for i in result["issues"] if i["severity"].value == "error"]
|
||||
assert len(error_issues) > 0
|
||||
assert any("/shared-cmd" in str(i["message"]) for i in error_issues)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_compatibility_tool_overlap(validation_tools, plugin_a, plugin_b):
|
||||
"""Test detection of tool name overlaps"""
|
||||
result = await validation_tools.validate_compatibility(plugin_a, plugin_b)
|
||||
|
||||
assert "tool_two" in result["shared_tools"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_compatibility_unique_tools(validation_tools, plugin_a, plugin_b):
|
||||
"""Test identification of unique tools per plugin"""
|
||||
result = await validation_tools.validate_compatibility(plugin_a, plugin_b)
|
||||
|
||||
assert "tool_one" in result["a_only_tools"]
|
||||
assert "tool_three" in result["b_only_tools"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_compatibility_no_conflict(validation_tools, plugin_a, plugin_no_conflict):
|
||||
"""Test compatible plugins"""
|
||||
result = await validation_tools.validate_compatibility(plugin_a, plugin_no_conflict)
|
||||
|
||||
assert "error" not in result
|
||||
assert result["compatible"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_compatibility_missing_plugin(validation_tools, plugin_a, tmp_path):
|
||||
"""Test error when plugin not found"""
|
||||
result = await validation_tools.validate_compatibility(
|
||||
plugin_a,
|
||||
str(tmp_path / "nonexistent")
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_agent_refs_with_missing_tools(validation_tools, claude_md_with_agents, plugin_a):
|
||||
"""Test detection of missing tool references"""
|
||||
result = await validation_tools.validate_agent_refs(
|
||||
"TestAgent",
|
||||
claude_md_with_agents,
|
||||
[plugin_a]
|
||||
)
|
||||
|
||||
assert "error" not in result
|
||||
assert result["valid"] is False
|
||||
assert "missing_tool" in result["tool_refs_missing"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_agent_refs_valid_agent(validation_tools, claude_md_with_agents, plugin_a):
|
||||
"""Test valid agent with all tools found"""
|
||||
result = await validation_tools.validate_agent_refs(
|
||||
"ValidAgent",
|
||||
claude_md_with_agents,
|
||||
[plugin_a]
|
||||
)
|
||||
|
||||
assert "error" not in result
|
||||
assert result["valid"] is True
|
||||
assert "tool_one" in result["tool_refs_found"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_agent_refs_empty_agent(validation_tools, claude_md_with_agents, plugin_a):
|
||||
"""Test agent with no tool references"""
|
||||
result = await validation_tools.validate_agent_refs(
|
||||
"EmptyAgent",
|
||||
claude_md_with_agents,
|
||||
[plugin_a]
|
||||
)
|
||||
|
||||
assert "error" not in result
|
||||
# Should have info issue about undocumented references
|
||||
info_issues = [i for i in result["issues"] if i["severity"].value == "info"]
|
||||
assert len(info_issues) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_agent_refs_agent_not_found(validation_tools, claude_md_with_agents, plugin_a):
|
||||
"""Test error when agent not found"""
|
||||
result = await validation_tools.validate_agent_refs(
|
||||
"NonexistentAgent",
|
||||
claude_md_with_agents,
|
||||
[plugin_a]
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
assert "not found" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_data_flow_valid(validation_tools, tmp_path):
|
||||
"""Test data flow validation with valid flow"""
|
||||
claude_md = tmp_path / "CLAUDE.md"
|
||||
claude_md.write_text("""# CLAUDE.md
|
||||
|
||||
### Four-Agent Model
|
||||
|
||||
| Agent | Personality | Responsibilities |
|
||||
|-------|-------------|------------------|
|
||||
| **DataAgent** | Analytical | Load with `read_csv`, analyze with `describe`, export with `to_csv` |
|
||||
""")
|
||||
|
||||
result = await validation_tools.validate_data_flow("DataAgent", str(claude_md))
|
||||
|
||||
assert "error" not in result
|
||||
assert result["valid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_data_flow_missing_producer(validation_tools, tmp_path):
|
||||
"""Test data flow with consumer but no producer"""
|
||||
claude_md = tmp_path / "CLAUDE.md"
|
||||
claude_md.write_text("""# CLAUDE.md
|
||||
|
||||
### Four-Agent Model
|
||||
|
||||
| Agent | Personality | Responsibilities |
|
||||
|-------|-------------|------------------|
|
||||
| **BadAgent** | Careless | Just runs `describe`, `head`, `tail` without loading |
|
||||
""")
|
||||
|
||||
result = await validation_tools.validate_data_flow("BadAgent", str(claude_md))
|
||||
|
||||
assert "error" not in result
|
||||
# Should have warning about missing producer
|
||||
warning_issues = [i for i in result["issues"] if i["severity"].value == "warning"]
|
||||
assert len(warning_issues) > 0
|
||||
131
mcp-servers/data-platform/README.md
Normal file
131
mcp-servers/data-platform/README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Data Platform MCP Server
|
||||
|
||||
MCP Server providing pandas, PostgreSQL/PostGIS, and dbt tools for Claude Code.
|
||||
|
||||
## Features
|
||||
|
||||
- **pandas Tools**: DataFrame operations with Arrow IPC data_ref persistence
|
||||
- **PostgreSQL Tools**: Database queries with asyncpg connection pooling
|
||||
- **PostGIS Tools**: Spatial data operations
|
||||
- **dbt Tools**: Build tool wrapper with pre-execution validation
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd mcp-servers/data-platform
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### System-Level (PostgreSQL credentials)
|
||||
|
||||
Create `~/.config/claude/postgres.env`:
|
||||
|
||||
```env
|
||||
POSTGRES_URL=postgresql://user:password@host:5432/database
|
||||
```
|
||||
|
||||
### Project-Level (dbt paths)
|
||||
|
||||
Create `.env` in your project root:
|
||||
|
||||
```env
|
||||
DBT_PROJECT_DIR=/path/to/dbt/project
|
||||
DBT_PROFILES_DIR=/path/to/.dbt
|
||||
DATA_PLATFORM_MAX_ROWS=100000
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
### pandas Tools (14 tools)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `read_csv` | Load CSV file into DataFrame |
|
||||
| `read_parquet` | Load Parquet file into DataFrame |
|
||||
| `read_json` | Load JSON/JSONL file into DataFrame |
|
||||
| `to_csv` | Export DataFrame to CSV file |
|
||||
| `to_parquet` | Export DataFrame to Parquet file |
|
||||
| `describe` | Get statistical summary of DataFrame |
|
||||
| `head` | Get first N rows of DataFrame |
|
||||
| `tail` | Get last N rows of DataFrame |
|
||||
| `filter` | Filter DataFrame rows by condition |
|
||||
| `select` | Select specific columns from DataFrame |
|
||||
| `groupby` | Group DataFrame and aggregate |
|
||||
| `join` | Join two DataFrames |
|
||||
| `list_data` | List all stored DataFrames |
|
||||
| `drop_data` | Remove a DataFrame from storage |
|
||||
|
||||
### PostgreSQL Tools (6 tools)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `pg_connect` | Test connection and return status |
|
||||
| `pg_query` | Execute SELECT, return as data_ref |
|
||||
| `pg_execute` | Execute INSERT/UPDATE/DELETE |
|
||||
| `pg_tables` | List all tables in schema |
|
||||
| `pg_columns` | Get column info for table |
|
||||
| `pg_schemas` | List all schemas |
|
||||
|
||||
### PostGIS Tools (4 tools)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `st_tables` | List PostGIS-enabled tables |
|
||||
| `st_geometry_type` | Get geometry type of column |
|
||||
| `st_srid` | Get SRID of geometry column |
|
||||
| `st_extent` | Get bounding box of geometries |
|
||||
|
||||
### dbt Tools (8 tools)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `dbt_parse` | Validate project (pre-execution) |
|
||||
| `dbt_run` | Run models with selection |
|
||||
| `dbt_test` | Run tests |
|
||||
| `dbt_build` | Run + test |
|
||||
| `dbt_compile` | Compile SQL without executing |
|
||||
| `dbt_ls` | List resources |
|
||||
| `dbt_docs_generate` | Generate documentation |
|
||||
| `dbt_lineage` | Get model dependencies |
|
||||
|
||||
## data_ref System
|
||||
|
||||
All DataFrame operations use a `data_ref` system to persist data across tool calls:
|
||||
|
||||
1. **Load data**: Returns a `data_ref` string (e.g., `"df_a1b2c3d4"`)
|
||||
2. **Use data_ref**: Pass to other tools (filter, join, export)
|
||||
3. **List data**: Use `list_data` to see all stored DataFrames
|
||||
4. **Clean up**: Use `drop_data` when done
|
||||
|
||||
### Example Flow
|
||||
|
||||
```
|
||||
read_csv("data.csv") → {"data_ref": "sales_data", "rows": 1000}
|
||||
filter("sales_data", "amount > 100") → {"data_ref": "sales_data_filtered"}
|
||||
describe("sales_data_filtered") → {statistics}
|
||||
to_parquet("sales_data_filtered", "output.parquet") → {success}
|
||||
```
|
||||
|
||||
## Memory Management
|
||||
|
||||
- Default row limit: 100,000 rows per DataFrame
|
||||
- Configure via `DATA_PLATFORM_MAX_ROWS` environment variable
|
||||
- Use chunked processing for large files (`chunk_size` parameter)
|
||||
- Monitor with `list_data` tool (shows memory usage)
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
python -m mcp_server.server
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
```
|
||||
7
mcp-servers/data-platform/mcp_server/__init__.py
Normal file
7
mcp-servers/data-platform/mcp_server/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Data Platform MCP Server.
|
||||
|
||||
Provides pandas, PostgreSQL/PostGIS, and dbt tools to Claude Code via MCP.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
195
mcp-servers/data-platform/mcp_server/config.py
Normal file
195
mcp-servers/data-platform/mcp_server/config.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Configuration loader for Data Platform MCP Server.
|
||||
|
||||
Implements hybrid configuration system:
|
||||
- System-level: ~/.config/claude/postgres.env (credentials)
|
||||
- Project-level: .env (dbt project paths, overrides)
|
||||
- Auto-detection: dbt_project.yml discovery
|
||||
"""
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DataPlatformConfig:
|
||||
"""Hybrid configuration loader for data platform tools"""
|
||||
|
||||
def __init__(self):
|
||||
self.postgres_url: Optional[str] = None
|
||||
self.dbt_project_dir: Optional[str] = None
|
||||
self.dbt_profiles_dir: Optional[str] = None
|
||||
self.max_rows: int = 100_000
|
||||
|
||||
def load(self) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Load configuration from system and project levels.
|
||||
|
||||
Returns:
|
||||
Dict containing postgres_url, dbt_project_dir, dbt_profiles_dir, max_rows
|
||||
|
||||
Note:
|
||||
PostgreSQL credentials are optional - server can run in pandas-only mode.
|
||||
"""
|
||||
# Load system config (PostgreSQL credentials)
|
||||
system_config = Path.home() / '.config' / 'claude' / 'postgres.env'
|
||||
if system_config.exists():
|
||||
load_dotenv(system_config)
|
||||
logger.info(f"Loaded system configuration from {system_config}")
|
||||
else:
|
||||
logger.info(
|
||||
f"System config not found: {system_config} - "
|
||||
"PostgreSQL tools will be unavailable"
|
||||
)
|
||||
|
||||
# Find project directory
|
||||
project_dir = self._find_project_directory()
|
||||
|
||||
# Load project config (overrides system)
|
||||
if project_dir:
|
||||
project_config = project_dir / '.env'
|
||||
if project_config.exists():
|
||||
load_dotenv(project_config, override=True)
|
||||
logger.info(f"Loaded project configuration from {project_config}")
|
||||
|
||||
# Extract values
|
||||
self.postgres_url = os.getenv('POSTGRES_URL')
|
||||
self.dbt_project_dir = os.getenv('DBT_PROJECT_DIR')
|
||||
self.dbt_profiles_dir = os.getenv('DBT_PROFILES_DIR')
|
||||
self.max_rows = int(os.getenv('DATA_PLATFORM_MAX_ROWS', '100000'))
|
||||
|
||||
# Auto-detect dbt project if not specified
|
||||
if not self.dbt_project_dir and project_dir:
|
||||
self.dbt_project_dir = self._find_dbt_project(project_dir)
|
||||
if self.dbt_project_dir:
|
||||
logger.info(f"Auto-detected dbt project: {self.dbt_project_dir}")
|
||||
|
||||
# Default dbt profiles dir to ~/.dbt
|
||||
if not self.dbt_profiles_dir:
|
||||
default_profiles = Path.home() / '.dbt'
|
||||
if default_profiles.exists():
|
||||
self.dbt_profiles_dir = str(default_profiles)
|
||||
|
||||
return {
|
||||
'postgres_url': self.postgres_url,
|
||||
'dbt_project_dir': self.dbt_project_dir,
|
||||
'dbt_profiles_dir': self.dbt_profiles_dir,
|
||||
'max_rows': self.max_rows,
|
||||
'postgres_available': self.postgres_url is not None,
|
||||
'dbt_available': self.dbt_project_dir is not None
|
||||
}
|
||||
|
||||
def _find_project_directory(self) -> Optional[Path]:
|
||||
"""
|
||||
Find the user's project directory.
|
||||
|
||||
Returns:
|
||||
Path to project directory, or None if not found
|
||||
"""
|
||||
# Strategy 1: Check CLAUDE_PROJECT_DIR environment variable
|
||||
project_dir = os.getenv('CLAUDE_PROJECT_DIR')
|
||||
if project_dir:
|
||||
path = Path(project_dir)
|
||||
if path.exists():
|
||||
logger.info(f"Found project directory from CLAUDE_PROJECT_DIR: {path}")
|
||||
return path
|
||||
|
||||
# Strategy 2: Check PWD
|
||||
pwd = os.getenv('PWD')
|
||||
if pwd:
|
||||
path = Path(pwd)
|
||||
if path.exists() and (
|
||||
(path / '.git').exists() or
|
||||
(path / '.env').exists() or
|
||||
(path / 'dbt_project.yml').exists()
|
||||
):
|
||||
logger.info(f"Found project directory from PWD: {path}")
|
||||
return path
|
||||
|
||||
# Strategy 3: Check current working directory
|
||||
cwd = Path.cwd()
|
||||
if (cwd / '.git').exists() or (cwd / '.env').exists() or (cwd / 'dbt_project.yml').exists():
|
||||
logger.info(f"Found project directory from cwd: {cwd}")
|
||||
return cwd
|
||||
|
||||
logger.debug("Could not determine project directory")
|
||||
return None
|
||||
|
||||
def _find_dbt_project(self, start_dir: Path) -> Optional[str]:
|
||||
"""
|
||||
Find dbt_project.yml in the project or its subdirectories.
|
||||
|
||||
Args:
|
||||
start_dir: Directory to start searching from
|
||||
|
||||
Returns:
|
||||
Path to dbt project directory, or None if not found
|
||||
"""
|
||||
# Check root
|
||||
if (start_dir / 'dbt_project.yml').exists():
|
||||
return str(start_dir)
|
||||
|
||||
# Check common subdirectories
|
||||
for subdir in ['dbt', 'transform', 'analytics', 'models']:
|
||||
candidate = start_dir / subdir
|
||||
if (candidate / 'dbt_project.yml').exists():
|
||||
return str(candidate)
|
||||
|
||||
# Search one level deep
|
||||
for item in start_dir.iterdir():
|
||||
if item.is_dir() and not item.name.startswith('.'):
|
||||
if (item / 'dbt_project.yml').exists():
|
||||
return str(item)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Convenience function to load configuration.
|
||||
|
||||
Returns:
|
||||
Configuration dictionary
|
||||
"""
|
||||
config = DataPlatformConfig()
|
||||
return config.load()
|
||||
|
||||
|
||||
def check_postgres_connection() -> Dict[str, any]:
|
||||
"""
|
||||
Check PostgreSQL connection status for SessionStart hook.
|
||||
|
||||
Returns:
|
||||
Dict with connection status and message
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
config = load_config()
|
||||
if not config.get('postgres_url'):
|
||||
return {
|
||||
'connected': False,
|
||||
'message': 'PostgreSQL not configured (POSTGRES_URL not set)'
|
||||
}
|
||||
|
||||
async def test_connection():
|
||||
try:
|
||||
import asyncpg
|
||||
conn = await asyncpg.connect(config['postgres_url'], timeout=5)
|
||||
version = await conn.fetchval('SELECT version()')
|
||||
await conn.close()
|
||||
return {
|
||||
'connected': True,
|
||||
'message': f'Connected to PostgreSQL',
|
||||
'version': version.split(',')[0] if version else 'Unknown'
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'connected': False,
|
||||
'message': f'PostgreSQL connection failed: {str(e)}'
|
||||
}
|
||||
|
||||
return asyncio.run(test_connection())
|
||||
219
mcp-servers/data-platform/mcp_server/data_store.py
Normal file
219
mcp-servers/data-platform/mcp_server/data_store.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Arrow IPC DataFrame Registry.
|
||||
|
||||
Provides persistent storage for DataFrames across tool calls using Apache Arrow
|
||||
for efficient memory management and serialization.
|
||||
"""
|
||||
import pyarrow as pa
|
||||
import pandas as pd
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Dict, Optional, List, Union
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataFrameInfo:
|
||||
"""Metadata about a stored DataFrame"""
|
||||
ref: str
|
||||
rows: int
|
||||
columns: int
|
||||
column_names: List[str]
|
||||
dtypes: Dict[str, str]
|
||||
memory_bytes: int
|
||||
created_at: datetime
|
||||
source: Optional[str] = None
|
||||
|
||||
|
||||
class DataStore:
|
||||
"""
|
||||
Singleton registry for Arrow Tables (DataFrames).
|
||||
|
||||
Uses Arrow IPC format for efficient memory usage and supports
|
||||
data_ref based retrieval across multiple tool calls.
|
||||
"""
|
||||
_instance = None
|
||||
_dataframes: Dict[str, pa.Table] = {}
|
||||
_metadata: Dict[str, DataFrameInfo] = {}
|
||||
_max_rows: int = 100_000
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._dataframes = {}
|
||||
cls._metadata = {}
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'DataStore':
|
||||
"""Get the singleton instance"""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def set_max_rows(cls, max_rows: int):
|
||||
"""Set the maximum rows limit"""
|
||||
cls._max_rows = max_rows
|
||||
|
||||
def store(
|
||||
self,
|
||||
data: Union[pa.Table, pd.DataFrame],
|
||||
name: Optional[str] = None,
|
||||
source: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Store a DataFrame and return its reference.
|
||||
|
||||
Args:
|
||||
data: Arrow Table or pandas DataFrame
|
||||
name: Optional name for the reference (auto-generated if not provided)
|
||||
source: Optional source description (e.g., file path, query)
|
||||
|
||||
Returns:
|
||||
data_ref string to retrieve the DataFrame later
|
||||
"""
|
||||
# Convert pandas to Arrow if needed
|
||||
if isinstance(data, pd.DataFrame):
|
||||
table = pa.Table.from_pandas(data)
|
||||
else:
|
||||
table = data
|
||||
|
||||
# Generate reference
|
||||
data_ref = name or f"df_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Ensure unique reference
|
||||
if data_ref in self._dataframes and name is None:
|
||||
data_ref = f"{data_ref}_{uuid.uuid4().hex[:4]}"
|
||||
|
||||
# Store table
|
||||
self._dataframes[data_ref] = table
|
||||
|
||||
# Store metadata
|
||||
schema = table.schema
|
||||
self._metadata[data_ref] = DataFrameInfo(
|
||||
ref=data_ref,
|
||||
rows=table.num_rows,
|
||||
columns=table.num_columns,
|
||||
column_names=[f.name for f in schema],
|
||||
dtypes={f.name: str(f.type) for f in schema},
|
||||
memory_bytes=table.nbytes,
|
||||
created_at=datetime.now(),
|
||||
source=source
|
||||
)
|
||||
|
||||
logger.info(f"Stored DataFrame '{data_ref}': {table.num_rows} rows, {table.num_columns} cols")
|
||||
return data_ref
|
||||
|
||||
def get(self, data_ref: str) -> Optional[pa.Table]:
|
||||
"""
|
||||
Retrieve an Arrow Table by reference.
|
||||
|
||||
Args:
|
||||
data_ref: Reference string from store()
|
||||
|
||||
Returns:
|
||||
Arrow Table or None if not found
|
||||
"""
|
||||
return self._dataframes.get(data_ref)
|
||||
|
||||
def get_pandas(self, data_ref: str) -> Optional[pd.DataFrame]:
|
||||
"""
|
||||
Retrieve a DataFrame as pandas.
|
||||
|
||||
Args:
|
||||
data_ref: Reference string from store()
|
||||
|
||||
Returns:
|
||||
pandas DataFrame or None if not found
|
||||
"""
|
||||
table = self.get(data_ref)
|
||||
if table is not None:
|
||||
return table.to_pandas()
|
||||
return None
|
||||
|
||||
def get_info(self, data_ref: str) -> Optional[DataFrameInfo]:
|
||||
"""
|
||||
Get metadata about a stored DataFrame.
|
||||
|
||||
Args:
|
||||
data_ref: Reference string
|
||||
|
||||
Returns:
|
||||
DataFrameInfo or None if not found
|
||||
"""
|
||||
return self._metadata.get(data_ref)
|
||||
|
||||
def list_refs(self) -> List[Dict]:
|
||||
"""
|
||||
List all stored DataFrame references with metadata.
|
||||
|
||||
Returns:
|
||||
List of dicts with ref, rows, columns, memory info
|
||||
"""
|
||||
result = []
|
||||
for ref, info in self._metadata.items():
|
||||
result.append({
|
||||
'ref': ref,
|
||||
'rows': info.rows,
|
||||
'columns': info.columns,
|
||||
'column_names': info.column_names,
|
||||
'memory_mb': round(info.memory_bytes / (1024 * 1024), 2),
|
||||
'source': info.source,
|
||||
'created_at': info.created_at.isoformat()
|
||||
})
|
||||
return result
|
||||
|
||||
def drop(self, data_ref: str) -> bool:
|
||||
"""
|
||||
Remove a DataFrame from the store.
|
||||
|
||||
Args:
|
||||
data_ref: Reference string
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
"""
|
||||
if data_ref in self._dataframes:
|
||||
del self._dataframes[data_ref]
|
||||
del self._metadata[data_ref]
|
||||
logger.info(f"Dropped DataFrame '{data_ref}'")
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear(self):
|
||||
"""Remove all stored DataFrames"""
|
||||
count = len(self._dataframes)
|
||||
self._dataframes.clear()
|
||||
self._metadata.clear()
|
||||
logger.info(f"Cleared {count} DataFrames from store")
|
||||
|
||||
def total_memory_bytes(self) -> int:
|
||||
"""Get total memory used by all stored DataFrames"""
|
||||
return sum(info.memory_bytes for info in self._metadata.values())
|
||||
|
||||
def total_memory_mb(self) -> float:
|
||||
"""Get total memory in MB"""
|
||||
return round(self.total_memory_bytes() / (1024 * 1024), 2)
|
||||
|
||||
def check_row_limit(self, row_count: int) -> Dict:
|
||||
"""
|
||||
Check if row count exceeds limit.
|
||||
|
||||
Args:
|
||||
row_count: Number of rows
|
||||
|
||||
Returns:
|
||||
Dict with 'exceeded' bool and 'message' if exceeded
|
||||
"""
|
||||
if row_count > self._max_rows:
|
||||
return {
|
||||
'exceeded': True,
|
||||
'message': f"Row count ({row_count:,}) exceeds limit ({self._max_rows:,})",
|
||||
'suggestion': f"Use chunked processing or filter data first",
|
||||
'limit': self._max_rows
|
||||
}
|
||||
return {'exceeded': False}
|
||||
387
mcp-servers/data-platform/mcp_server/dbt_tools.py
Normal file
387
mcp-servers/data-platform/mcp_server/dbt_tools.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
dbt MCP Tools.
|
||||
|
||||
Provides dbt CLI wrapper with pre-execution validation.
|
||||
"""
|
||||
import subprocess
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from .config import load_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DbtTools:
|
||||
"""dbt CLI wrapper tools with pre-validation"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = load_config()
|
||||
self.project_dir = self.config.get('dbt_project_dir')
|
||||
self.profiles_dir = self.config.get('dbt_profiles_dir')
|
||||
|
||||
def _get_dbt_command(self, cmd: List[str]) -> List[str]:
|
||||
"""Build dbt command with project and profiles directories"""
|
||||
base = ['dbt']
|
||||
if self.project_dir:
|
||||
base.extend(['--project-dir', self.project_dir])
|
||||
if self.profiles_dir:
|
||||
base.extend(['--profiles-dir', self.profiles_dir])
|
||||
base.extend(cmd)
|
||||
return base
|
||||
|
||||
def _run_dbt(
|
||||
self,
|
||||
cmd: List[str],
|
||||
timeout: int = 300,
|
||||
capture_json: bool = False
|
||||
) -> Dict:
|
||||
"""
|
||||
Run dbt command and return result.
|
||||
|
||||
Args:
|
||||
cmd: dbt subcommand and arguments
|
||||
timeout: Command timeout in seconds
|
||||
capture_json: If True, parse JSON output
|
||||
|
||||
Returns:
|
||||
Dict with command result
|
||||
"""
|
||||
if not self.project_dir:
|
||||
return {
|
||||
'error': 'dbt project not found',
|
||||
'suggestion': 'Set DBT_PROJECT_DIR in project .env or ensure dbt_project.yml exists'
|
||||
}
|
||||
|
||||
full_cmd = self._get_dbt_command(cmd)
|
||||
logger.info(f"Running: {' '.join(full_cmd)}")
|
||||
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
# Disable dbt analytics/tracking
|
||||
env['DBT_SEND_ANONYMOUS_USAGE_STATS'] = 'false'
|
||||
|
||||
result = subprocess.run(
|
||||
full_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=self.project_dir,
|
||||
env=env
|
||||
)
|
||||
|
||||
output = {
|
||||
'success': result.returncode == 0,
|
||||
'command': ' '.join(cmd),
|
||||
'stdout': result.stdout,
|
||||
'stderr': result.stderr if result.returncode != 0 else None
|
||||
}
|
||||
|
||||
if capture_json and result.returncode == 0:
|
||||
try:
|
||||
output['data'] = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return output
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
'error': f'Command timed out after {timeout}s',
|
||||
'command': ' '.join(cmd)
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return {
|
||||
'error': 'dbt not found in PATH',
|
||||
'suggestion': 'Install dbt: pip install dbt-core dbt-postgres'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"dbt command failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def dbt_parse(self) -> Dict:
|
||||
"""
|
||||
Validate dbt project without executing (pre-flight check).
|
||||
|
||||
Returns:
|
||||
Dict with validation result and any errors
|
||||
"""
|
||||
result = self._run_dbt(['parse'])
|
||||
|
||||
# Check if _run_dbt returned an error (e.g., project not found, timeout, dbt not installed)
|
||||
if 'error' in result:
|
||||
return result
|
||||
|
||||
if not result.get('success'):
|
||||
# Extract useful error info from stderr
|
||||
stderr = result.get('stderr', '') or result.get('stdout', '')
|
||||
errors = []
|
||||
|
||||
# Look for common dbt 1.9+ deprecation warnings
|
||||
if 'deprecated' in stderr.lower():
|
||||
errors.append({
|
||||
'type': 'deprecation',
|
||||
'message': 'Deprecated syntax found - check dbt 1.9+ migration guide'
|
||||
})
|
||||
|
||||
# Look for compilation errors
|
||||
if 'compilation error' in stderr.lower():
|
||||
errors.append({
|
||||
'type': 'compilation',
|
||||
'message': 'SQL compilation error - check model syntax'
|
||||
})
|
||||
|
||||
return {
|
||||
'valid': False,
|
||||
'errors': errors,
|
||||
'details': stderr[:2000] if stderr else None,
|
||||
'suggestion': 'Fix issues before running dbt models'
|
||||
}
|
||||
|
||||
return {
|
||||
'valid': True,
|
||||
'message': 'dbt project validation passed'
|
||||
}
|
||||
|
||||
async def dbt_run(
|
||||
self,
|
||||
select: Optional[str] = None,
|
||||
exclude: Optional[str] = None,
|
||||
full_refresh: bool = False
|
||||
) -> Dict:
|
||||
"""
|
||||
Run dbt models with pre-validation.
|
||||
|
||||
Args:
|
||||
select: Model selection (e.g., "model_name", "+model_name", "tag:daily")
|
||||
exclude: Models to exclude
|
||||
full_refresh: If True, rebuild incremental models
|
||||
|
||||
Returns:
|
||||
Dict with run result
|
||||
"""
|
||||
# ALWAYS validate first
|
||||
parse_result = await self.dbt_parse()
|
||||
if not parse_result.get('valid'):
|
||||
return {
|
||||
'error': 'Pre-validation failed',
|
||||
**parse_result
|
||||
}
|
||||
|
||||
cmd = ['run']
|
||||
if select:
|
||||
cmd.extend(['--select', select])
|
||||
if exclude:
|
||||
cmd.extend(['--exclude', exclude])
|
||||
if full_refresh:
|
||||
cmd.append('--full-refresh')
|
||||
|
||||
return self._run_dbt(cmd)
|
||||
|
||||
async def dbt_test(
|
||||
self,
|
||||
select: Optional[str] = None,
|
||||
exclude: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Run dbt tests.
|
||||
|
||||
Args:
|
||||
select: Test selection
|
||||
exclude: Tests to exclude
|
||||
|
||||
Returns:
|
||||
Dict with test results
|
||||
"""
|
||||
cmd = ['test']
|
||||
if select:
|
||||
cmd.extend(['--select', select])
|
||||
if exclude:
|
||||
cmd.extend(['--exclude', exclude])
|
||||
|
||||
return self._run_dbt(cmd)
|
||||
|
||||
async def dbt_build(
|
||||
self,
|
||||
select: Optional[str] = None,
|
||||
exclude: Optional[str] = None,
|
||||
full_refresh: bool = False
|
||||
) -> Dict:
|
||||
"""
|
||||
Run dbt build (run + test) with pre-validation.
|
||||
|
||||
Args:
|
||||
select: Model/test selection
|
||||
exclude: Resources to exclude
|
||||
full_refresh: If True, rebuild incremental models
|
||||
|
||||
Returns:
|
||||
Dict with build result
|
||||
"""
|
||||
# ALWAYS validate first
|
||||
parse_result = await self.dbt_parse()
|
||||
if not parse_result.get('valid'):
|
||||
return {
|
||||
'error': 'Pre-validation failed',
|
||||
**parse_result
|
||||
}
|
||||
|
||||
cmd = ['build']
|
||||
if select:
|
||||
cmd.extend(['--select', select])
|
||||
if exclude:
|
||||
cmd.extend(['--exclude', exclude])
|
||||
if full_refresh:
|
||||
cmd.append('--full-refresh')
|
||||
|
||||
return self._run_dbt(cmd)
|
||||
|
||||
async def dbt_compile(
|
||||
self,
|
||||
select: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Compile dbt models to SQL without executing.
|
||||
|
||||
Args:
|
||||
select: Model selection
|
||||
|
||||
Returns:
|
||||
Dict with compiled SQL info
|
||||
"""
|
||||
cmd = ['compile']
|
||||
if select:
|
||||
cmd.extend(['--select', select])
|
||||
|
||||
return self._run_dbt(cmd)
|
||||
|
||||
async def dbt_ls(
|
||||
self,
|
||||
select: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
output: str = 'name'
|
||||
) -> Dict:
|
||||
"""
|
||||
List dbt resources.
|
||||
|
||||
Args:
|
||||
select: Resource selection
|
||||
resource_type: Filter by type (model, test, seed, snapshot, source)
|
||||
output: Output format ('name', 'path', 'json')
|
||||
|
||||
Returns:
|
||||
Dict with list of resources
|
||||
"""
|
||||
cmd = ['ls', '--output', output]
|
||||
if select:
|
||||
cmd.extend(['--select', select])
|
||||
if resource_type:
|
||||
cmd.extend(['--resource-type', resource_type])
|
||||
|
||||
result = self._run_dbt(cmd)
|
||||
|
||||
if result.get('success') and result.get('stdout'):
|
||||
lines = [l.strip() for l in result['stdout'].split('\n') if l.strip()]
|
||||
result['resources'] = lines
|
||||
result['count'] = len(lines)
|
||||
|
||||
return result
|
||||
|
||||
async def dbt_docs_generate(self) -> Dict:
|
||||
"""
|
||||
Generate dbt documentation.
|
||||
|
||||
Returns:
|
||||
Dict with generation result
|
||||
"""
|
||||
result = self._run_dbt(['docs', 'generate'])
|
||||
|
||||
if result.get('success') and self.project_dir:
|
||||
# Check for generated catalog
|
||||
catalog_path = Path(self.project_dir) / 'target' / 'catalog.json'
|
||||
manifest_path = Path(self.project_dir) / 'target' / 'manifest.json'
|
||||
result['catalog_generated'] = catalog_path.exists()
|
||||
result['manifest_generated'] = manifest_path.exists()
|
||||
|
||||
return result
|
||||
|
||||
async def dbt_lineage(self, model: str) -> Dict:
|
||||
"""
|
||||
Get model dependencies and lineage.
|
||||
|
||||
Args:
|
||||
model: Model name to analyze
|
||||
|
||||
Returns:
|
||||
Dict with upstream and downstream dependencies
|
||||
"""
|
||||
if not self.project_dir:
|
||||
return {'error': 'dbt project not found'}
|
||||
|
||||
manifest_path = Path(self.project_dir) / 'target' / 'manifest.json'
|
||||
|
||||
# Generate manifest if not exists
|
||||
if not manifest_path.exists():
|
||||
compile_result = await self.dbt_compile(select=model)
|
||||
if not compile_result.get('success'):
|
||||
return {
|
||||
'error': 'Failed to compile manifest',
|
||||
'details': compile_result
|
||||
}
|
||||
|
||||
if not manifest_path.exists():
|
||||
return {
|
||||
'error': 'Manifest not found',
|
||||
'suggestion': 'Run dbt compile first'
|
||||
}
|
||||
|
||||
try:
|
||||
with open(manifest_path) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
# Find the model node
|
||||
model_key = None
|
||||
for key in manifest.get('nodes', {}):
|
||||
if key.endswith(f'.{model}') or manifest['nodes'][key].get('name') == model:
|
||||
model_key = key
|
||||
break
|
||||
|
||||
if not model_key:
|
||||
return {
|
||||
'error': f'Model not found: {model}',
|
||||
'available_models': [
|
||||
n.get('name') for n in manifest.get('nodes', {}).values()
|
||||
if n.get('resource_type') == 'model'
|
||||
][:20]
|
||||
}
|
||||
|
||||
node = manifest['nodes'][model_key]
|
||||
|
||||
# Get upstream (depends_on)
|
||||
upstream = node.get('depends_on', {}).get('nodes', [])
|
||||
|
||||
# Get downstream (find nodes that depend on this one)
|
||||
downstream = []
|
||||
for key, other_node in manifest.get('nodes', {}).items():
|
||||
deps = other_node.get('depends_on', {}).get('nodes', [])
|
||||
if model_key in deps:
|
||||
downstream.append(key)
|
||||
|
||||
return {
|
||||
'model': model,
|
||||
'unique_id': model_key,
|
||||
'materialization': node.get('config', {}).get('materialized'),
|
||||
'schema': node.get('schema'),
|
||||
'database': node.get('database'),
|
||||
'upstream': upstream,
|
||||
'downstream': downstream,
|
||||
'description': node.get('description'),
|
||||
'tags': node.get('tags', [])
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"dbt_lineage failed: {e}")
|
||||
return {'error': str(e)}
|
||||
500
mcp-servers/data-platform/mcp_server/pandas_tools.py
Normal file
500
mcp-servers/data-platform/mcp_server/pandas_tools.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
pandas MCP Tools.
|
||||
|
||||
Provides DataFrame operations with Arrow IPC data_ref persistence.
|
||||
"""
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
|
||||
from .data_store import DataStore
|
||||
from .config import load_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PandasTools:
|
||||
"""pandas data manipulation tools with data_ref persistence"""
|
||||
|
||||
def __init__(self):
|
||||
self.store = DataStore.get_instance()
|
||||
config = load_config()
|
||||
self.max_rows = config.get('max_rows', 100_000)
|
||||
self.store.set_max_rows(self.max_rows)
|
||||
|
||||
def _check_and_store(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
name: Optional[str] = None,
|
||||
source: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Check row limit and store DataFrame if within limits"""
|
||||
check = self.store.check_row_limit(len(df))
|
||||
if check['exceeded']:
|
||||
return {
|
||||
'error': 'row_limit_exceeded',
|
||||
**check,
|
||||
'preview': df.head(100).to_dict(orient='records')
|
||||
}
|
||||
|
||||
data_ref = self.store.store(df, name=name, source=source)
|
||||
return {
|
||||
'data_ref': data_ref,
|
||||
'rows': len(df),
|
||||
'columns': list(df.columns),
|
||||
'dtypes': {col: str(dtype) for col, dtype in df.dtypes.items()}
|
||||
}
|
||||
|
||||
async def read_csv(
|
||||
self,
|
||||
file_path: str,
|
||||
name: Optional[str] = None,
|
||||
chunk_size: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> Dict:
|
||||
"""
|
||||
Load CSV file into DataFrame.
|
||||
|
||||
Args:
|
||||
file_path: Path to CSV file
|
||||
name: Optional name for data_ref
|
||||
chunk_size: If provided, process in chunks
|
||||
**kwargs: Additional pandas read_csv arguments
|
||||
|
||||
Returns:
|
||||
Dict with data_ref or error info
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return {'error': f'File not found: {file_path}'}
|
||||
|
||||
try:
|
||||
if chunk_size:
|
||||
# Chunked processing - return iterator info
|
||||
chunks = []
|
||||
for i, chunk in enumerate(pd.read_csv(path, chunksize=chunk_size, **kwargs)):
|
||||
chunk_ref = self.store.store(chunk, name=f"{name or 'chunk'}_{i}", source=file_path)
|
||||
chunks.append({'ref': chunk_ref, 'rows': len(chunk)})
|
||||
return {
|
||||
'chunked': True,
|
||||
'chunks': chunks,
|
||||
'total_chunks': len(chunks)
|
||||
}
|
||||
|
||||
df = pd.read_csv(path, **kwargs)
|
||||
return self._check_and_store(df, name=name, source=file_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"read_csv failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def read_parquet(
|
||||
self,
|
||||
file_path: str,
|
||||
name: Optional[str] = None,
|
||||
columns: Optional[List[str]] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Load Parquet file into DataFrame.
|
||||
|
||||
Args:
|
||||
file_path: Path to Parquet file
|
||||
name: Optional name for data_ref
|
||||
columns: Optional list of columns to load
|
||||
|
||||
Returns:
|
||||
Dict with data_ref or error info
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return {'error': f'File not found: {file_path}'}
|
||||
|
||||
try:
|
||||
table = pq.read_table(path, columns=columns)
|
||||
df = table.to_pandas()
|
||||
return self._check_and_store(df, name=name, source=file_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"read_parquet failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def read_json(
|
||||
self,
|
||||
file_path: str,
|
||||
name: Optional[str] = None,
|
||||
lines: bool = False,
|
||||
**kwargs
|
||||
) -> Dict:
|
||||
"""
|
||||
Load JSON/JSONL file into DataFrame.
|
||||
|
||||
Args:
|
||||
file_path: Path to JSON file
|
||||
name: Optional name for data_ref
|
||||
lines: If True, read as JSON Lines format
|
||||
**kwargs: Additional pandas read_json arguments
|
||||
|
||||
Returns:
|
||||
Dict with data_ref or error info
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return {'error': f'File not found: {file_path}'}
|
||||
|
||||
try:
|
||||
df = pd.read_json(path, lines=lines, **kwargs)
|
||||
return self._check_and_store(df, name=name, source=file_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"read_json failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def to_csv(
|
||||
self,
|
||||
data_ref: str,
|
||||
file_path: str,
|
||||
index: bool = False,
|
||||
**kwargs
|
||||
) -> Dict:
|
||||
"""
|
||||
Export DataFrame to CSV file.
|
||||
|
||||
Args:
|
||||
data_ref: Reference to stored DataFrame
|
||||
file_path: Output file path
|
||||
index: Whether to include index
|
||||
**kwargs: Additional pandas to_csv arguments
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
df = self.store.get_pandas(data_ref)
|
||||
if df is None:
|
||||
return {'error': f'DataFrame not found: {data_ref}'}
|
||||
|
||||
try:
|
||||
df.to_csv(file_path, index=index, **kwargs)
|
||||
return {
|
||||
'success': True,
|
||||
'file_path': file_path,
|
||||
'rows': len(df),
|
||||
'size_bytes': Path(file_path).stat().st_size
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"to_csv failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def to_parquet(
|
||||
self,
|
||||
data_ref: str,
|
||||
file_path: str,
|
||||
compression: str = 'snappy'
|
||||
) -> Dict:
|
||||
"""
|
||||
Export DataFrame to Parquet file.
|
||||
|
||||
Args:
|
||||
data_ref: Reference to stored DataFrame
|
||||
file_path: Output file path
|
||||
compression: Compression codec
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
table = self.store.get(data_ref)
|
||||
if table is None:
|
||||
return {'error': f'DataFrame not found: {data_ref}'}
|
||||
|
||||
try:
|
||||
pq.write_table(table, file_path, compression=compression)
|
||||
return {
|
||||
'success': True,
|
||||
'file_path': file_path,
|
||||
'rows': table.num_rows,
|
||||
'size_bytes': Path(file_path).stat().st_size
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"to_parquet failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def describe(self, data_ref: str) -> Dict:
|
||||
"""
|
||||
Get statistical summary of DataFrame.
|
||||
|
||||
Args:
|
||||
data_ref: Reference to stored DataFrame
|
||||
|
||||
Returns:
|
||||
Dict with statistical summary
|
||||
"""
|
||||
df = self.store.get_pandas(data_ref)
|
||||
if df is None:
|
||||
return {'error': f'DataFrame not found: {data_ref}'}
|
||||
|
||||
try:
|
||||
desc = df.describe(include='all')
|
||||
info = self.store.get_info(data_ref)
|
||||
|
||||
return {
|
||||
'data_ref': data_ref,
|
||||
'shape': {'rows': len(df), 'columns': len(df.columns)},
|
||||
'columns': list(df.columns),
|
||||
'dtypes': {col: str(dtype) for col, dtype in df.dtypes.items()},
|
||||
'memory_mb': info.memory_bytes / (1024 * 1024) if info else None,
|
||||
'null_counts': df.isnull().sum().to_dict(),
|
||||
'statistics': desc.to_dict()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"describe failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def head(self, data_ref: str, n: int = 10) -> Dict:
|
||||
"""
|
||||
Get first N rows of DataFrame.
|
||||
|
||||
Args:
|
||||
data_ref: Reference to stored DataFrame
|
||||
n: Number of rows
|
||||
|
||||
Returns:
|
||||
Dict with rows as records
|
||||
"""
|
||||
df = self.store.get_pandas(data_ref)
|
||||
if df is None:
|
||||
return {'error': f'DataFrame not found: {data_ref}'}
|
||||
|
||||
try:
|
||||
head_df = df.head(n)
|
||||
return {
|
||||
'data_ref': data_ref,
|
||||
'total_rows': len(df),
|
||||
'returned_rows': len(head_df),
|
||||
'columns': list(df.columns),
|
||||
'data': head_df.to_dict(orient='records')
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"head failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def tail(self, data_ref: str, n: int = 10) -> Dict:
|
||||
"""
|
||||
Get last N rows of DataFrame.
|
||||
|
||||
Args:
|
||||
data_ref: Reference to stored DataFrame
|
||||
n: Number of rows
|
||||
|
||||
Returns:
|
||||
Dict with rows as records
|
||||
"""
|
||||
df = self.store.get_pandas(data_ref)
|
||||
if df is None:
|
||||
return {'error': f'DataFrame not found: {data_ref}'}
|
||||
|
||||
try:
|
||||
tail_df = df.tail(n)
|
||||
return {
|
||||
'data_ref': data_ref,
|
||||
'total_rows': len(df),
|
||||
'returned_rows': len(tail_df),
|
||||
'columns': list(df.columns),
|
||||
'data': tail_df.to_dict(orient='records')
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"tail failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def filter(
|
||||
self,
|
||||
data_ref: str,
|
||||
condition: str,
|
||||
name: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Filter DataFrame rows by condition.
|
||||
|
||||
Args:
|
||||
data_ref: Reference to stored DataFrame
|
||||
condition: pandas query string (e.g., "age > 30 and city == 'NYC'")
|
||||
name: Optional name for result data_ref
|
||||
|
||||
Returns:
|
||||
Dict with new data_ref for filtered result
|
||||
"""
|
||||
df = self.store.get_pandas(data_ref)
|
||||
if df is None:
|
||||
return {'error': f'DataFrame not found: {data_ref}'}
|
||||
|
||||
try:
|
||||
filtered = df.query(condition).reset_index(drop=True)
|
||||
result_name = name or f"{data_ref}_filtered"
|
||||
return self._check_and_store(
|
||||
filtered,
|
||||
name=result_name,
|
||||
source=f"filter({data_ref}, '{condition}')"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"filter failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def select(
|
||||
self,
|
||||
data_ref: str,
|
||||
columns: List[str],
|
||||
name: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Select specific columns from DataFrame.
|
||||
|
||||
Args:
|
||||
data_ref: Reference to stored DataFrame
|
||||
columns: List of column names to select
|
||||
name: Optional name for result data_ref
|
||||
|
||||
Returns:
|
||||
Dict with new data_ref for selected columns
|
||||
"""
|
||||
df = self.store.get_pandas(data_ref)
|
||||
if df is None:
|
||||
return {'error': f'DataFrame not found: {data_ref}'}
|
||||
|
||||
try:
|
||||
# Validate columns exist
|
||||
missing = [c for c in columns if c not in df.columns]
|
||||
if missing:
|
||||
return {
|
||||
'error': f'Columns not found: {missing}',
|
||||
'available_columns': list(df.columns)
|
||||
}
|
||||
|
||||
selected = df[columns]
|
||||
result_name = name or f"{data_ref}_select"
|
||||
return self._check_and_store(
|
||||
selected,
|
||||
name=result_name,
|
||||
source=f"select({data_ref}, {columns})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"select failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def groupby(
|
||||
self,
|
||||
data_ref: str,
|
||||
by: Union[str, List[str]],
|
||||
agg: Dict[str, Union[str, List[str]]],
|
||||
name: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Group DataFrame and aggregate.
|
||||
|
||||
Args:
|
||||
data_ref: Reference to stored DataFrame
|
||||
by: Column(s) to group by
|
||||
agg: Aggregation dict (e.g., {"sales": "sum", "count": "mean"})
|
||||
name: Optional name for result data_ref
|
||||
|
||||
Returns:
|
||||
Dict with new data_ref for aggregated result
|
||||
"""
|
||||
df = self.store.get_pandas(data_ref)
|
||||
if df is None:
|
||||
return {'error': f'DataFrame not found: {data_ref}'}
|
||||
|
||||
try:
|
||||
grouped = df.groupby(by).agg(agg).reset_index()
|
||||
# Flatten column names if multi-level
|
||||
if isinstance(grouped.columns, pd.MultiIndex):
|
||||
grouped.columns = ['_'.join(col).strip('_') for col in grouped.columns]
|
||||
|
||||
result_name = name or f"{data_ref}_grouped"
|
||||
return self._check_and_store(
|
||||
grouped,
|
||||
name=result_name,
|
||||
source=f"groupby({data_ref}, by={by})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"groupby failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def join(
|
||||
self,
|
||||
left_ref: str,
|
||||
right_ref: str,
|
||||
on: Optional[Union[str, List[str]]] = None,
|
||||
left_on: Optional[Union[str, List[str]]] = None,
|
||||
right_on: Optional[Union[str, List[str]]] = None,
|
||||
how: str = 'inner',
|
||||
name: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Join two DataFrames.
|
||||
|
||||
Args:
|
||||
left_ref: Reference to left DataFrame
|
||||
right_ref: Reference to right DataFrame
|
||||
on: Column(s) to join on (if same name in both)
|
||||
left_on: Left join column(s)
|
||||
right_on: Right join column(s)
|
||||
how: Join type ('inner', 'left', 'right', 'outer')
|
||||
name: Optional name for result data_ref
|
||||
|
||||
Returns:
|
||||
Dict with new data_ref for joined result
|
||||
"""
|
||||
left_df = self.store.get_pandas(left_ref)
|
||||
right_df = self.store.get_pandas(right_ref)
|
||||
|
||||
if left_df is None:
|
||||
return {'error': f'DataFrame not found: {left_ref}'}
|
||||
if right_df is None:
|
||||
return {'error': f'DataFrame not found: {right_ref}'}
|
||||
|
||||
try:
|
||||
joined = pd.merge(
|
||||
left_df, right_df,
|
||||
on=on, left_on=left_on, right_on=right_on,
|
||||
how=how
|
||||
)
|
||||
result_name = name or f"{left_ref}_{right_ref}_joined"
|
||||
return self._check_and_store(
|
||||
joined,
|
||||
name=result_name,
|
||||
source=f"join({left_ref}, {right_ref}, how={how})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"join failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def list_data(self) -> Dict:
|
||||
"""
|
||||
List all stored DataFrames.
|
||||
|
||||
Returns:
|
||||
Dict with list of stored DataFrames and their info
|
||||
"""
|
||||
refs = self.store.list_refs()
|
||||
return {
|
||||
'count': len(refs),
|
||||
'total_memory_mb': self.store.total_memory_mb(),
|
||||
'max_rows_limit': self.max_rows,
|
||||
'dataframes': refs
|
||||
}
|
||||
|
||||
async def drop_data(self, data_ref: str) -> Dict:
|
||||
"""
|
||||
Remove a DataFrame from storage.
|
||||
|
||||
Args:
|
||||
data_ref: Reference to drop
|
||||
|
||||
Returns:
|
||||
Dict with success status
|
||||
"""
|
||||
if self.store.drop(data_ref):
|
||||
return {'success': True, 'dropped': data_ref}
|
||||
return {'error': f'DataFrame not found: {data_ref}'}
|
||||
538
mcp-servers/data-platform/mcp_server/postgres_tools.py
Normal file
538
mcp-servers/data-platform/mcp_server/postgres_tools.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
PostgreSQL/PostGIS MCP Tools.
|
||||
|
||||
Provides database operations with connection pooling and PostGIS support.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
import json
|
||||
|
||||
from .data_store import DataStore
|
||||
from .config import load_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Optional imports - gracefully handle missing dependencies
|
||||
try:
|
||||
import asyncpg
|
||||
ASYNCPG_AVAILABLE = True
|
||||
except ImportError:
|
||||
ASYNCPG_AVAILABLE = False
|
||||
logger.warning("asyncpg not available - PostgreSQL tools will be disabled")
|
||||
|
||||
try:
|
||||
import pandas as pd
|
||||
PANDAS_AVAILABLE = True
|
||||
except ImportError:
|
||||
PANDAS_AVAILABLE = False
|
||||
|
||||
|
||||
class PostgresTools:
|
||||
"""PostgreSQL/PostGIS database tools"""
|
||||
|
||||
def __init__(self):
|
||||
self.store = DataStore.get_instance()
|
||||
self.config = load_config()
|
||||
self.pool: Optional[Any] = None
|
||||
self.max_rows = self.config.get('max_rows', 100_000)
|
||||
|
||||
async def _get_pool(self):
|
||||
"""Get or create connection pool"""
|
||||
if not ASYNCPG_AVAILABLE:
|
||||
raise RuntimeError("asyncpg not installed - run: pip install asyncpg")
|
||||
|
||||
if self.pool is None:
|
||||
postgres_url = self.config.get('postgres_url')
|
||||
if not postgres_url:
|
||||
raise RuntimeError(
|
||||
"PostgreSQL not configured. Set POSTGRES_URL in "
|
||||
"~/.config/claude/postgres.env"
|
||||
)
|
||||
self.pool = await asyncpg.create_pool(postgres_url, min_size=1, max_size=5)
|
||||
return self.pool
|
||||
|
||||
async def pg_connect(self) -> Dict:
|
||||
"""
|
||||
Test PostgreSQL connection and return status.
|
||||
|
||||
Returns:
|
||||
Dict with connection status, version, and database info
|
||||
"""
|
||||
if not ASYNCPG_AVAILABLE:
|
||||
return {
|
||||
'connected': False,
|
||||
'error': 'asyncpg not installed',
|
||||
'suggestion': 'pip install asyncpg'
|
||||
}
|
||||
|
||||
postgres_url = self.config.get('postgres_url')
|
||||
if not postgres_url:
|
||||
return {
|
||||
'connected': False,
|
||||
'error': 'POSTGRES_URL not configured',
|
||||
'suggestion': 'Create ~/.config/claude/postgres.env with POSTGRES_URL=postgresql://...'
|
||||
}
|
||||
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
version = await conn.fetchval('SELECT version()')
|
||||
db_name = await conn.fetchval('SELECT current_database()')
|
||||
user = await conn.fetchval('SELECT current_user')
|
||||
|
||||
# Check for PostGIS
|
||||
postgis_version = None
|
||||
try:
|
||||
postgis_version = await conn.fetchval('SELECT PostGIS_Version()')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'connected': True,
|
||||
'database': db_name,
|
||||
'user': user,
|
||||
'version': version.split(',')[0] if version else 'Unknown',
|
||||
'postgis_version': postgis_version,
|
||||
'postgis_available': postgis_version is not None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"pg_connect failed: {e}")
|
||||
return {
|
||||
'connected': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
async def pg_query(
|
||||
self,
|
||||
query: str,
|
||||
params: Optional[List] = None,
|
||||
name: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Execute SELECT query and return results as data_ref.
|
||||
|
||||
Args:
|
||||
query: SQL SELECT query
|
||||
params: Query parameters (positional, use $1, $2, etc.)
|
||||
name: Optional name for result data_ref
|
||||
|
||||
Returns:
|
||||
Dict with data_ref for results or error
|
||||
"""
|
||||
if not PANDAS_AVAILABLE:
|
||||
return {'error': 'pandas not available'}
|
||||
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
if params:
|
||||
rows = await conn.fetch(query, *params)
|
||||
else:
|
||||
rows = await conn.fetch(query)
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
'data_ref': None,
|
||||
'rows': 0,
|
||||
'message': 'Query returned no results'
|
||||
}
|
||||
|
||||
# Convert to DataFrame
|
||||
df = pd.DataFrame([dict(r) for r in rows])
|
||||
|
||||
# Check row limit
|
||||
check = self.store.check_row_limit(len(df))
|
||||
if check['exceeded']:
|
||||
return {
|
||||
'error': 'row_limit_exceeded',
|
||||
**check,
|
||||
'preview': df.head(100).to_dict(orient='records')
|
||||
}
|
||||
|
||||
# Store result
|
||||
data_ref = self.store.store(df, name=name, source=f"pg_query: {query[:100]}...")
|
||||
return {
|
||||
'data_ref': data_ref,
|
||||
'rows': len(df),
|
||||
'columns': list(df.columns)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"pg_query failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def pg_execute(
|
||||
self,
|
||||
query: str,
|
||||
params: Optional[List] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Execute INSERT/UPDATE/DELETE query.
|
||||
|
||||
Args:
|
||||
query: SQL DML query
|
||||
params: Query parameters
|
||||
|
||||
Returns:
|
||||
Dict with affected rows count
|
||||
"""
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
if params:
|
||||
result = await conn.execute(query, *params)
|
||||
else:
|
||||
result = await conn.execute(query)
|
||||
|
||||
# Parse result (e.g., "INSERT 0 1" or "UPDATE 5")
|
||||
parts = result.split()
|
||||
affected = int(parts[-1]) if parts else 0
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'command': parts[0] if parts else 'UNKNOWN',
|
||||
'affected_rows': affected
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"pg_execute failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def pg_tables(self, schema: str = 'public') -> Dict:
|
||||
"""
|
||||
List all tables in schema.
|
||||
|
||||
Args:
|
||||
schema: Schema name (default: public)
|
||||
|
||||
Returns:
|
||||
Dict with list of tables
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
table_name,
|
||||
table_type,
|
||||
(SELECT count(*) FROM information_schema.columns c
|
||||
WHERE c.table_schema = t.table_schema
|
||||
AND c.table_name = t.table_name) as column_count
|
||||
FROM information_schema.tables t
|
||||
WHERE table_schema = $1
|
||||
ORDER BY table_name
|
||||
"""
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(query, schema)
|
||||
tables = [
|
||||
{
|
||||
'name': r['table_name'],
|
||||
'type': r['table_type'],
|
||||
'columns': r['column_count']
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {
|
||||
'schema': schema,
|
||||
'count': len(tables),
|
||||
'tables': tables
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"pg_tables failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def pg_columns(self, table: str, schema: str = 'public') -> Dict:
|
||||
"""
|
||||
Get column information for a table.
|
||||
|
||||
Args:
|
||||
table: Table name
|
||||
schema: Schema name (default: public)
|
||||
|
||||
Returns:
|
||||
Dict with column details
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
udt_name,
|
||||
is_nullable,
|
||||
column_default,
|
||||
character_maximum_length,
|
||||
numeric_precision
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1 AND table_name = $2
|
||||
ORDER BY ordinal_position
|
||||
"""
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(query, schema, table)
|
||||
columns = [
|
||||
{
|
||||
'name': r['column_name'],
|
||||
'type': r['data_type'],
|
||||
'udt': r['udt_name'],
|
||||
'nullable': r['is_nullable'] == 'YES',
|
||||
'default': r['column_default'],
|
||||
'max_length': r['character_maximum_length'],
|
||||
'precision': r['numeric_precision']
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {
|
||||
'table': f'{schema}.{table}',
|
||||
'column_count': len(columns),
|
||||
'columns': columns
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"pg_columns failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def pg_schemas(self) -> Dict:
|
||||
"""
|
||||
List all schemas in database.
|
||||
|
||||
Returns:
|
||||
Dict with list of schemas
|
||||
"""
|
||||
query = """
|
||||
SELECT schema_name
|
||||
FROM information_schema.schemata
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
||||
ORDER BY schema_name
|
||||
"""
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(query)
|
||||
schemas = [r['schema_name'] for r in rows]
|
||||
return {
|
||||
'count': len(schemas),
|
||||
'schemas': schemas
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"pg_schemas failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def st_tables(self, schema: str = 'public') -> Dict:
|
||||
"""
|
||||
List PostGIS-enabled tables.
|
||||
|
||||
Args:
|
||||
schema: Schema name (default: public)
|
||||
|
||||
Returns:
|
||||
Dict with list of tables with geometry columns
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
f_table_name as table_name,
|
||||
f_geometry_column as geometry_column,
|
||||
type as geometry_type,
|
||||
srid,
|
||||
coord_dimension
|
||||
FROM geometry_columns
|
||||
WHERE f_table_schema = $1
|
||||
ORDER BY f_table_name
|
||||
"""
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(query, schema)
|
||||
tables = [
|
||||
{
|
||||
'table': r['table_name'],
|
||||
'geometry_column': r['geometry_column'],
|
||||
'geometry_type': r['geometry_type'],
|
||||
'srid': r['srid'],
|
||||
'dimensions': r['coord_dimension']
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {
|
||||
'schema': schema,
|
||||
'count': len(tables),
|
||||
'postgis_tables': tables
|
||||
}
|
||||
except Exception as e:
|
||||
if 'geometry_columns' in str(e):
|
||||
return {
|
||||
'error': 'PostGIS not installed or extension not enabled',
|
||||
'suggestion': 'Run: CREATE EXTENSION IF NOT EXISTS postgis;'
|
||||
}
|
||||
logger.error(f"st_tables failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def st_geometry_type(self, table: str, column: str, schema: str = 'public') -> Dict:
|
||||
"""
|
||||
Get geometry type of a column.
|
||||
|
||||
Args:
|
||||
table: Table name
|
||||
column: Geometry column name
|
||||
schema: Schema name
|
||||
|
||||
Returns:
|
||||
Dict with geometry type information
|
||||
"""
|
||||
query = f"""
|
||||
SELECT DISTINCT ST_GeometryType({column}) as geom_type
|
||||
FROM {schema}.{table}
|
||||
WHERE {column} IS NOT NULL
|
||||
LIMIT 10
|
||||
"""
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(query)
|
||||
types = [r['geom_type'] for r in rows]
|
||||
return {
|
||||
'table': f'{schema}.{table}',
|
||||
'column': column,
|
||||
'geometry_types': types
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"st_geometry_type failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def st_srid(self, table: str, column: str, schema: str = 'public') -> Dict:
|
||||
"""
|
||||
Get SRID of geometry column.
|
||||
|
||||
Args:
|
||||
table: Table name
|
||||
column: Geometry column name
|
||||
schema: Schema name
|
||||
|
||||
Returns:
|
||||
Dict with SRID information
|
||||
"""
|
||||
query = f"""
|
||||
SELECT DISTINCT ST_SRID({column}) as srid
|
||||
FROM {schema}.{table}
|
||||
WHERE {column} IS NOT NULL
|
||||
LIMIT 1
|
||||
"""
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(query)
|
||||
srid = row['srid'] if row else None
|
||||
|
||||
# Get SRID description
|
||||
srid_info = None
|
||||
if srid:
|
||||
srid_query = """
|
||||
SELECT srtext, proj4text
|
||||
FROM spatial_ref_sys
|
||||
WHERE srid = $1
|
||||
"""
|
||||
srid_row = await conn.fetchrow(srid_query, srid)
|
||||
if srid_row:
|
||||
srid_info = {
|
||||
'description': srid_row['srtext'][:200] if srid_row['srtext'] else None,
|
||||
'proj4': srid_row['proj4text']
|
||||
}
|
||||
|
||||
return {
|
||||
'table': f'{schema}.{table}',
|
||||
'column': column,
|
||||
'srid': srid,
|
||||
'info': srid_info
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"st_srid failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def st_extent(self, table: str, column: str, schema: str = 'public') -> Dict:
|
||||
"""
|
||||
Get bounding box of all geometries.
|
||||
|
||||
Args:
|
||||
table: Table name
|
||||
column: Geometry column name
|
||||
schema: Schema name
|
||||
|
||||
Returns:
|
||||
Dict with bounding box coordinates
|
||||
"""
|
||||
query = f"""
|
||||
SELECT
|
||||
ST_XMin(extent) as xmin,
|
||||
ST_YMin(extent) as ymin,
|
||||
ST_XMax(extent) as xmax,
|
||||
ST_YMax(extent) as ymax
|
||||
FROM (
|
||||
SELECT ST_Extent({column}) as extent
|
||||
FROM {schema}.{table}
|
||||
) sub
|
||||
"""
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(query)
|
||||
if row and row['xmin'] is not None:
|
||||
return {
|
||||
'table': f'{schema}.{table}',
|
||||
'column': column,
|
||||
'bbox': {
|
||||
'xmin': float(row['xmin']),
|
||||
'ymin': float(row['ymin']),
|
||||
'xmax': float(row['xmax']),
|
||||
'ymax': float(row['ymax'])
|
||||
}
|
||||
}
|
||||
return {
|
||||
'table': f'{schema}.{table}',
|
||||
'column': column,
|
||||
'bbox': None,
|
||||
'message': 'No geometries found or all NULL'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"st_extent failed: {e}")
|
||||
return {'error': str(e)}
|
||||
|
||||
async def close(self):
|
||||
"""Close connection pool"""
|
||||
if self.pool:
|
||||
await self.pool.close()
|
||||
self.pool = None
|
||||
|
||||
|
||||
def check_connection() -> None:
|
||||
"""
|
||||
Check PostgreSQL connection for SessionStart hook.
|
||||
Prints warning to stderr if connection fails.
|
||||
"""
|
||||
import sys
|
||||
|
||||
config = load_config()
|
||||
if not config.get('postgres_url'):
|
||||
print(
|
||||
"[data-platform] PostgreSQL not configured (POSTGRES_URL not set)",
|
||||
file=sys.stderr
|
||||
)
|
||||
return
|
||||
|
||||
async def test():
|
||||
try:
|
||||
if not ASYNCPG_AVAILABLE:
|
||||
print(
|
||||
"[data-platform] asyncpg not installed - PostgreSQL tools unavailable",
|
||||
file=sys.stderr
|
||||
)
|
||||
return
|
||||
|
||||
conn = await asyncpg.connect(config['postgres_url'], timeout=5)
|
||||
await conn.close()
|
||||
print("[data-platform] PostgreSQL connection OK", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[data-platform] PostgreSQL connection failed: {e}",
|
||||
file=sys.stderr
|
||||
)
|
||||
|
||||
asyncio.run(test())
|
||||
795
mcp-servers/data-platform/mcp_server/server.py
Normal file
795
mcp-servers/data-platform/mcp_server/server.py
Normal file
@@ -0,0 +1,795 @@
|
||||
"""
|
||||
MCP Server entry point for Data Platform integration.
|
||||
|
||||
Provides pandas, PostgreSQL/PostGIS, and dbt tools to Claude Code via JSON-RPC 2.0 over stdio.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
from .config import DataPlatformConfig
|
||||
from .data_store import DataStore
|
||||
from .pandas_tools import PandasTools
|
||||
from .postgres_tools import PostgresTools
|
||||
from .dbt_tools import DbtTools
|
||||
|
||||
# Suppress noisy MCP validation warnings on stderr
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("root").setLevel(logging.ERROR)
|
||||
logging.getLogger("mcp").setLevel(logging.ERROR)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DataPlatformMCPServer:
|
||||
"""MCP Server for data platform integration"""
|
||||
|
||||
def __init__(self):
|
||||
self.server = Server("data-platform-mcp")
|
||||
self.config = None
|
||||
self.pandas_tools = None
|
||||
self.postgres_tools = None
|
||||
self.dbt_tools = None
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize server and load configuration."""
|
||||
try:
|
||||
config_loader = DataPlatformConfig()
|
||||
self.config = config_loader.load()
|
||||
|
||||
self.pandas_tools = PandasTools()
|
||||
self.postgres_tools = PostgresTools()
|
||||
self.dbt_tools = DbtTools()
|
||||
|
||||
# Log available capabilities
|
||||
caps = []
|
||||
caps.append("pandas")
|
||||
if self.config.get('postgres_available'):
|
||||
caps.append("PostgreSQL")
|
||||
if self.config.get('dbt_available'):
|
||||
caps.append("dbt")
|
||||
|
||||
logger.info(f"Data Platform MCP Server initialized with: {', '.join(caps)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize: {e}")
|
||||
raise
|
||||
|
||||
def setup_tools(self):
|
||||
"""Register all available tools with the MCP server"""
|
||||
|
||||
@self.server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
"""Return list of available tools"""
|
||||
tools = [
|
||||
# pandas tools - always available
|
||||
Tool(
|
||||
name="read_csv",
|
||||
description="Load CSV file into DataFrame",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to CSV file"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional name for data_ref"
|
||||
},
|
||||
"chunk_size": {
|
||||
"type": "integer",
|
||||
"description": "Process in chunks of this size"
|
||||
}
|
||||
},
|
||||
"required": ["file_path"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="read_parquet",
|
||||
description="Load Parquet file into DataFrame",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to Parquet file"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional name for data_ref"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of columns to load"
|
||||
}
|
||||
},
|
||||
"required": ["file_path"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="read_json",
|
||||
description="Load JSON/JSONL file into DataFrame",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to JSON file"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional name for data_ref"
|
||||
},
|
||||
"lines": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Read as JSON Lines format"
|
||||
}
|
||||
},
|
||||
"required": ["file_path"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="to_csv",
|
||||
description="Export DataFrame to CSV file",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to stored DataFrame"
|
||||
},
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Output file path"
|
||||
},
|
||||
"index": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Include index column"
|
||||
}
|
||||
},
|
||||
"required": ["data_ref", "file_path"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="to_parquet",
|
||||
description="Export DataFrame to Parquet file",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to stored DataFrame"
|
||||
},
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Output file path"
|
||||
},
|
||||
"compression": {
|
||||
"type": "string",
|
||||
"default": "snappy",
|
||||
"description": "Compression codec"
|
||||
}
|
||||
},
|
||||
"required": ["data_ref", "file_path"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="describe",
|
||||
description="Get statistical summary of DataFrame",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to stored DataFrame"
|
||||
}
|
||||
},
|
||||
"required": ["data_ref"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="head",
|
||||
description="Get first N rows of DataFrame",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to stored DataFrame"
|
||||
},
|
||||
"n": {
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"description": "Number of rows"
|
||||
}
|
||||
},
|
||||
"required": ["data_ref"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="tail",
|
||||
description="Get last N rows of DataFrame",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to stored DataFrame"
|
||||
},
|
||||
"n": {
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"description": "Number of rows"
|
||||
}
|
||||
},
|
||||
"required": ["data_ref"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="filter",
|
||||
description="Filter DataFrame rows by condition",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to stored DataFrame"
|
||||
},
|
||||
"condition": {
|
||||
"type": "string",
|
||||
"description": "pandas query string (e.g., 'age > 30 and city == \"NYC\"')"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional name for result data_ref"
|
||||
}
|
||||
},
|
||||
"required": ["data_ref", "condition"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="select",
|
||||
description="Select specific columns from DataFrame",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to stored DataFrame"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of column names to select"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional name for result data_ref"
|
||||
}
|
||||
},
|
||||
"required": ["data_ref", "columns"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="groupby",
|
||||
description="Group DataFrame and aggregate",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to stored DataFrame"
|
||||
},
|
||||
"by": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}}
|
||||
],
|
||||
"description": "Column(s) to group by"
|
||||
},
|
||||
"agg": {
|
||||
"type": "object",
|
||||
"description": "Aggregation dict (e.g., {\"sales\": \"sum\", \"count\": \"mean\"})"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional name for result data_ref"
|
||||
}
|
||||
},
|
||||
"required": ["data_ref", "by", "agg"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="join",
|
||||
description="Join two DataFrames",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"left_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to left DataFrame"
|
||||
},
|
||||
"right_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to right DataFrame"
|
||||
},
|
||||
"on": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}}
|
||||
],
|
||||
"description": "Column(s) to join on (if same name in both)"
|
||||
},
|
||||
"left_on": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}}
|
||||
],
|
||||
"description": "Left join column(s)"
|
||||
},
|
||||
"right_on": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}}
|
||||
],
|
||||
"description": "Right join column(s)"
|
||||
},
|
||||
"how": {
|
||||
"type": "string",
|
||||
"enum": ["inner", "left", "right", "outer"],
|
||||
"default": "inner",
|
||||
"description": "Join type"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional name for result data_ref"
|
||||
}
|
||||
},
|
||||
"required": ["left_ref", "right_ref"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="list_data",
|
||||
description="List all stored DataFrames",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="drop_data",
|
||||
description="Remove a DataFrame from storage",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data_ref": {
|
||||
"type": "string",
|
||||
"description": "Reference to drop"
|
||||
}
|
||||
},
|
||||
"required": ["data_ref"]
|
||||
}
|
||||
),
|
||||
# PostgreSQL tools
|
||||
Tool(
|
||||
name="pg_connect",
|
||||
description="Test PostgreSQL connection and return status",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="pg_query",
|
||||
description="Execute SELECT query and return results as data_ref",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "SQL SELECT query"
|
||||
},
|
||||
"params": {
|
||||
"type": "array",
|
||||
"items": {},
|
||||
"description": "Query parameters (use $1, $2, etc.)"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional name for result data_ref"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="pg_execute",
|
||||
description="Execute INSERT/UPDATE/DELETE query",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "SQL DML query"
|
||||
},
|
||||
"params": {
|
||||
"type": "array",
|
||||
"items": {},
|
||||
"description": "Query parameters"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="pg_tables",
|
||||
description="List all tables in schema",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "public",
|
||||
"description": "Schema name"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="pg_columns",
|
||||
description="Get column information for a table",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"table": {
|
||||
"type": "string",
|
||||
"description": "Table name"
|
||||
},
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "public",
|
||||
"description": "Schema name"
|
||||
}
|
||||
},
|
||||
"required": ["table"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="pg_schemas",
|
||||
description="List all schemas in database",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
),
|
||||
# PostGIS tools
|
||||
Tool(
|
||||
name="st_tables",
|
||||
description="List PostGIS-enabled tables",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "public",
|
||||
"description": "Schema name"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="st_geometry_type",
|
||||
description="Get geometry type of a column",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"table": {
|
||||
"type": "string",
|
||||
"description": "Table name"
|
||||
},
|
||||
"column": {
|
||||
"type": "string",
|
||||
"description": "Geometry column name"
|
||||
},
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "public",
|
||||
"description": "Schema name"
|
||||
}
|
||||
},
|
||||
"required": ["table", "column"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="st_srid",
|
||||
description="Get SRID of geometry column",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"table": {
|
||||
"type": "string",
|
||||
"description": "Table name"
|
||||
},
|
||||
"column": {
|
||||
"type": "string",
|
||||
"description": "Geometry column name"
|
||||
},
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "public",
|
||||
"description": "Schema name"
|
||||
}
|
||||
},
|
||||
"required": ["table", "column"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="st_extent",
|
||||
description="Get bounding box of all geometries",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"table": {
|
||||
"type": "string",
|
||||
"description": "Table name"
|
||||
},
|
||||
"column": {
|
||||
"type": "string",
|
||||
"description": "Geometry column name"
|
||||
},
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"default": "public",
|
||||
"description": "Schema name"
|
||||
}
|
||||
},
|
||||
"required": ["table", "column"]
|
||||
}
|
||||
),
|
||||
# dbt tools
|
||||
Tool(
|
||||
name="dbt_parse",
|
||||
description="Validate dbt project (pre-flight check)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="dbt_run",
|
||||
description="Run dbt models with pre-validation",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"select": {
|
||||
"type": "string",
|
||||
"description": "Model selection (e.g., 'model_name', '+model_name', 'tag:daily')"
|
||||
},
|
||||
"exclude": {
|
||||
"type": "string",
|
||||
"description": "Models to exclude"
|
||||
},
|
||||
"full_refresh": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Rebuild incremental models"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="dbt_test",
|
||||
description="Run dbt tests",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"select": {
|
||||
"type": "string",
|
||||
"description": "Test selection"
|
||||
},
|
||||
"exclude": {
|
||||
"type": "string",
|
||||
"description": "Tests to exclude"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="dbt_build",
|
||||
description="Run dbt build (run + test) with pre-validation",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"select": {
|
||||
"type": "string",
|
||||
"description": "Model/test selection"
|
||||
},
|
||||
"exclude": {
|
||||
"type": "string",
|
||||
"description": "Resources to exclude"
|
||||
},
|
||||
"full_refresh": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Rebuild incremental models"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="dbt_compile",
|
||||
description="Compile dbt models to SQL without executing",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"select": {
|
||||
"type": "string",
|
||||
"description": "Model selection"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="dbt_ls",
|
||||
description="List dbt resources",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"select": {
|
||||
"type": "string",
|
||||
"description": "Resource selection"
|
||||
},
|
||||
"resource_type": {
|
||||
"type": "string",
|
||||
"enum": ["model", "test", "seed", "snapshot", "source"],
|
||||
"description": "Filter by type"
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"enum": ["name", "path", "json"],
|
||||
"default": "name",
|
||||
"description": "Output format"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="dbt_docs_generate",
|
||||
description="Generate dbt documentation",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="dbt_lineage",
|
||||
description="Get model dependencies and lineage",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model name to analyze"
|
||||
}
|
||||
},
|
||||
"required": ["model"]
|
||||
}
|
||||
)
|
||||
]
|
||||
return tools
|
||||
|
||||
@self.server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
"""Handle tool invocation."""
|
||||
try:
|
||||
# Route to appropriate tool handler
|
||||
# pandas tools
|
||||
if name == "read_csv":
|
||||
result = await self.pandas_tools.read_csv(**arguments)
|
||||
elif name == "read_parquet":
|
||||
result = await self.pandas_tools.read_parquet(**arguments)
|
||||
elif name == "read_json":
|
||||
result = await self.pandas_tools.read_json(**arguments)
|
||||
elif name == "to_csv":
|
||||
result = await self.pandas_tools.to_csv(**arguments)
|
||||
elif name == "to_parquet":
|
||||
result = await self.pandas_tools.to_parquet(**arguments)
|
||||
elif name == "describe":
|
||||
result = await self.pandas_tools.describe(**arguments)
|
||||
elif name == "head":
|
||||
result = await self.pandas_tools.head(**arguments)
|
||||
elif name == "tail":
|
||||
result = await self.pandas_tools.tail(**arguments)
|
||||
elif name == "filter":
|
||||
result = await self.pandas_tools.filter(**arguments)
|
||||
elif name == "select":
|
||||
result = await self.pandas_tools.select(**arguments)
|
||||
elif name == "groupby":
|
||||
result = await self.pandas_tools.groupby(**arguments)
|
||||
elif name == "join":
|
||||
result = await self.pandas_tools.join(**arguments)
|
||||
elif name == "list_data":
|
||||
result = await self.pandas_tools.list_data()
|
||||
elif name == "drop_data":
|
||||
result = await self.pandas_tools.drop_data(**arguments)
|
||||
# PostgreSQL tools
|
||||
elif name == "pg_connect":
|
||||
result = await self.postgres_tools.pg_connect()
|
||||
elif name == "pg_query":
|
||||
result = await self.postgres_tools.pg_query(**arguments)
|
||||
elif name == "pg_execute":
|
||||
result = await self.postgres_tools.pg_execute(**arguments)
|
||||
elif name == "pg_tables":
|
||||
result = await self.postgres_tools.pg_tables(**arguments)
|
||||
elif name == "pg_columns":
|
||||
result = await self.postgres_tools.pg_columns(**arguments)
|
||||
elif name == "pg_schemas":
|
||||
result = await self.postgres_tools.pg_schemas()
|
||||
# PostGIS tools
|
||||
elif name == "st_tables":
|
||||
result = await self.postgres_tools.st_tables(**arguments)
|
||||
elif name == "st_geometry_type":
|
||||
result = await self.postgres_tools.st_geometry_type(**arguments)
|
||||
elif name == "st_srid":
|
||||
result = await self.postgres_tools.st_srid(**arguments)
|
||||
elif name == "st_extent":
|
||||
result = await self.postgres_tools.st_extent(**arguments)
|
||||
# dbt tools
|
||||
elif name == "dbt_parse":
|
||||
result = await self.dbt_tools.dbt_parse()
|
||||
elif name == "dbt_run":
|
||||
result = await self.dbt_tools.dbt_run(**arguments)
|
||||
elif name == "dbt_test":
|
||||
result = await self.dbt_tools.dbt_test(**arguments)
|
||||
elif name == "dbt_build":
|
||||
result = await self.dbt_tools.dbt_build(**arguments)
|
||||
elif name == "dbt_compile":
|
||||
result = await self.dbt_tools.dbt_compile(**arguments)
|
||||
elif name == "dbt_ls":
|
||||
result = await self.dbt_tools.dbt_ls(**arguments)
|
||||
elif name == "dbt_docs_generate":
|
||||
result = await self.dbt_tools.dbt_docs_generate()
|
||||
elif name == "dbt_lineage":
|
||||
result = await self.dbt_tools.dbt_lineage(**arguments)
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2, default=str)
|
||||
)]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Tool {name} failed: {e}")
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": str(e)}, indent=2)
|
||||
)]
|
||||
|
||||
async def run(self):
|
||||
"""Run the MCP server"""
|
||||
await self.initialize()
|
||||
self.setup_tools()
|
||||
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await self.server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
self.server.create_initialization_options()
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point"""
|
||||
server = DataPlatformMCPServer()
|
||||
await server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
49
mcp-servers/data-platform/pyproject.toml
Normal file
49
mcp-servers/data-platform/pyproject.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "data-platform-mcp"
|
||||
version = "1.0.0"
|
||||
description = "MCP Server for data engineering with pandas, PostgreSQL/PostGIS, and dbt"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{name = "Leo Miranda"}
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"mcp>=0.9.0",
|
||||
"pandas>=2.0.0",
|
||||
"pyarrow>=14.0.0",
|
||||
"asyncpg>=0.29.0",
|
||||
"geoalchemy2>=0.14.0",
|
||||
"shapely>=2.0.0",
|
||||
"dbt-core>=1.9.0",
|
||||
"dbt-postgres>=1.9.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"pydantic>=2.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.4.3",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["mcp_server*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
23
mcp-servers/data-platform/requirements.txt
Normal file
23
mcp-servers/data-platform/requirements.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
# MCP SDK
|
||||
mcp>=0.9.0
|
||||
|
||||
# Data Processing
|
||||
pandas>=2.0.0
|
||||
pyarrow>=14.0.0
|
||||
|
||||
# PostgreSQL/PostGIS
|
||||
asyncpg>=0.29.0
|
||||
geoalchemy2>=0.14.0
|
||||
shapely>=2.0.0
|
||||
|
||||
# dbt
|
||||
dbt-core>=1.9.0
|
||||
dbt-postgres>=1.9.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
pydantic>=2.5.0
|
||||
|
||||
# Testing
|
||||
pytest>=7.4.3
|
||||
pytest-asyncio>=0.23.0
|
||||
21
mcp-servers/data-platform/run.sh
Executable file
21
mcp-servers/data-platform/run.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Capture original working directory before any cd operations
|
||||
# This should be the user's project directory when launched by Claude Code
|
||||
export CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CACHE_VENV="$HOME/.cache/claude-mcp-venvs/leo-claude-mktplace/data-platform/.venv"
|
||||
LOCAL_VENV="$SCRIPT_DIR/.venv"
|
||||
|
||||
if [[ -f "$CACHE_VENV/bin/python" ]]; then
|
||||
PYTHON="$CACHE_VENV/bin/python"
|
||||
elif [[ -f "$LOCAL_VENV/bin/python" ]]; then
|
||||
PYTHON="$LOCAL_VENV/bin/python"
|
||||
else
|
||||
echo "ERROR: No venv found. Run: ./scripts/setup-venvs.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
export PYTHONPATH="$SCRIPT_DIR"
|
||||
exec "$PYTHON" -m mcp_server.server "$@"
|
||||
3
mcp-servers/data-platform/tests/__init__.py
Normal file
3
mcp-servers/data-platform/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Tests for Data Platform MCP Server.
|
||||
"""
|
||||
239
mcp-servers/data-platform/tests/test_config.py
Normal file
239
mcp-servers/data-platform/tests/test_config.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Unit tests for configuration loader.
|
||||
"""
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
|
||||
def test_load_system_config(tmp_path, monkeypatch):
|
||||
"""Test loading system-level PostgreSQL configuration"""
|
||||
# Import here to avoid import errors before setup
|
||||
from mcp_server.config import DataPlatformConfig
|
||||
|
||||
# Mock home directory
|
||||
config_dir = tmp_path / '.config' / 'claude'
|
||||
config_dir.mkdir(parents=True)
|
||||
|
||||
config_file = config_dir / 'postgres.env'
|
||||
config_file.write_text(
|
||||
"POSTGRES_URL=postgresql://user:pass@localhost:5432/testdb\n"
|
||||
)
|
||||
|
||||
monkeypatch.setenv('HOME', str(tmp_path))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
config = DataPlatformConfig()
|
||||
result = config.load()
|
||||
|
||||
assert result['postgres_url'] == 'postgresql://user:pass@localhost:5432/testdb'
|
||||
assert result['postgres_available'] is True
|
||||
|
||||
|
||||
def test_postgres_optional(tmp_path, monkeypatch):
|
||||
"""Test that PostgreSQL configuration is optional"""
|
||||
from mcp_server.config import DataPlatformConfig
|
||||
|
||||
# No postgres.env file
|
||||
monkeypatch.setenv('HOME', str(tmp_path))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
# Clear any existing env vars
|
||||
monkeypatch.delenv('POSTGRES_URL', raising=False)
|
||||
|
||||
config = DataPlatformConfig()
|
||||
result = config.load()
|
||||
|
||||
assert result['postgres_url'] is None
|
||||
assert result['postgres_available'] is False
|
||||
|
||||
|
||||
def test_project_config_override(tmp_path, monkeypatch):
|
||||
"""Test that project config overrides system config"""
|
||||
from mcp_server.config import DataPlatformConfig
|
||||
|
||||
# Set up system config
|
||||
system_config_dir = tmp_path / '.config' / 'claude'
|
||||
system_config_dir.mkdir(parents=True)
|
||||
|
||||
system_config = system_config_dir / 'postgres.env'
|
||||
system_config.write_text(
|
||||
"POSTGRES_URL=postgresql://system:pass@localhost:5432/systemdb\n"
|
||||
)
|
||||
|
||||
# Set up project config
|
||||
project_dir = tmp_path / 'project'
|
||||
project_dir.mkdir()
|
||||
|
||||
project_config = project_dir / '.env'
|
||||
project_config.write_text(
|
||||
"POSTGRES_URL=postgresql://project:pass@localhost:5432/projectdb\n"
|
||||
"DBT_PROJECT_DIR=/path/to/dbt\n"
|
||||
)
|
||||
|
||||
monkeypatch.setenv('HOME', str(tmp_path))
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
config = DataPlatformConfig()
|
||||
result = config.load()
|
||||
|
||||
# Project config should override
|
||||
assert result['postgres_url'] == 'postgresql://project:pass@localhost:5432/projectdb'
|
||||
assert result['dbt_project_dir'] == '/path/to/dbt'
|
||||
|
||||
|
||||
def test_max_rows_config(tmp_path, monkeypatch):
|
||||
"""Test max rows configuration"""
|
||||
from mcp_server.config import DataPlatformConfig
|
||||
|
||||
project_dir = tmp_path / 'project'
|
||||
project_dir.mkdir()
|
||||
|
||||
project_config = project_dir / '.env'
|
||||
project_config.write_text("DATA_PLATFORM_MAX_ROWS=50000\n")
|
||||
|
||||
monkeypatch.setenv('HOME', str(tmp_path))
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
config = DataPlatformConfig()
|
||||
result = config.load()
|
||||
|
||||
assert result['max_rows'] == 50000
|
||||
|
||||
|
||||
def test_default_max_rows(tmp_path, monkeypatch):
|
||||
"""Test default max rows value"""
|
||||
from mcp_server.config import DataPlatformConfig
|
||||
|
||||
monkeypatch.setenv('HOME', str(tmp_path))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
# Clear any existing env vars
|
||||
monkeypatch.delenv('DATA_PLATFORM_MAX_ROWS', raising=False)
|
||||
|
||||
config = DataPlatformConfig()
|
||||
result = config.load()
|
||||
|
||||
assert result['max_rows'] == 100_000 # Default value
|
||||
|
||||
|
||||
def test_dbt_auto_detection(tmp_path, monkeypatch):
|
||||
"""Test automatic dbt project detection"""
|
||||
from mcp_server.config import DataPlatformConfig
|
||||
|
||||
# Create project with dbt_project.yml
|
||||
project_dir = tmp_path / 'project'
|
||||
project_dir.mkdir()
|
||||
(project_dir / 'dbt_project.yml').write_text("name: test_project\n")
|
||||
|
||||
monkeypatch.setenv('HOME', str(tmp_path))
|
||||
monkeypatch.chdir(project_dir)
|
||||
# Clear PWD and DBT_PROJECT_DIR to ensure auto-detection
|
||||
monkeypatch.delenv('PWD', raising=False)
|
||||
monkeypatch.delenv('DBT_PROJECT_DIR', raising=False)
|
||||
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
|
||||
|
||||
config = DataPlatformConfig()
|
||||
result = config.load()
|
||||
|
||||
assert result['dbt_project_dir'] == str(project_dir)
|
||||
assert result['dbt_available'] is True
|
||||
|
||||
|
||||
def test_dbt_subdirectory_detection(tmp_path, monkeypatch):
|
||||
"""Test dbt project detection in subdirectory"""
|
||||
from mcp_server.config import DataPlatformConfig
|
||||
|
||||
# Create project with dbt in subdirectory
|
||||
project_dir = tmp_path / 'project'
|
||||
project_dir.mkdir()
|
||||
# Need a marker file for _find_project_directory to find the project
|
||||
(project_dir / '.git').mkdir()
|
||||
dbt_dir = project_dir / 'transform'
|
||||
dbt_dir.mkdir()
|
||||
(dbt_dir / 'dbt_project.yml').write_text("name: test_project\n")
|
||||
|
||||
monkeypatch.setenv('HOME', str(tmp_path))
|
||||
monkeypatch.chdir(project_dir)
|
||||
# Clear env vars to ensure auto-detection
|
||||
monkeypatch.delenv('PWD', raising=False)
|
||||
monkeypatch.delenv('DBT_PROJECT_DIR', raising=False)
|
||||
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
|
||||
|
||||
config = DataPlatformConfig()
|
||||
result = config.load()
|
||||
|
||||
assert result['dbt_project_dir'] == str(dbt_dir)
|
||||
assert result['dbt_available'] is True
|
||||
|
||||
|
||||
def test_no_dbt_project(tmp_path, monkeypatch):
|
||||
"""Test when no dbt project exists"""
|
||||
from mcp_server.config import DataPlatformConfig
|
||||
|
||||
project_dir = tmp_path / 'project'
|
||||
project_dir.mkdir()
|
||||
|
||||
monkeypatch.setenv('HOME', str(tmp_path))
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
# Clear any existing env vars
|
||||
monkeypatch.delenv('DBT_PROJECT_DIR', raising=False)
|
||||
|
||||
config = DataPlatformConfig()
|
||||
result = config.load()
|
||||
|
||||
assert result['dbt_project_dir'] is None
|
||||
assert result['dbt_available'] is False
|
||||
|
||||
|
||||
def test_find_project_directory_from_env(tmp_path, monkeypatch):
|
||||
"""Test finding project directory from CLAUDE_PROJECT_DIR env var"""
|
||||
from mcp_server.config import DataPlatformConfig
|
||||
|
||||
project_dir = tmp_path / 'my-project'
|
||||
project_dir.mkdir()
|
||||
(project_dir / '.git').mkdir()
|
||||
|
||||
monkeypatch.setenv('CLAUDE_PROJECT_DIR', str(project_dir))
|
||||
|
||||
config = DataPlatformConfig()
|
||||
result = config._find_project_directory()
|
||||
|
||||
assert result == project_dir
|
||||
|
||||
|
||||
def test_find_project_directory_from_cwd(tmp_path, monkeypatch):
|
||||
"""Test finding project directory from cwd with .env file"""
|
||||
from mcp_server.config import DataPlatformConfig
|
||||
|
||||
project_dir = tmp_path / 'project'
|
||||
project_dir.mkdir()
|
||||
(project_dir / '.env').write_text("TEST=value")
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
|
||||
monkeypatch.delenv('PWD', raising=False)
|
||||
|
||||
config = DataPlatformConfig()
|
||||
result = config._find_project_directory()
|
||||
|
||||
assert result == project_dir
|
||||
|
||||
|
||||
def test_find_project_directory_none_when_no_markers(tmp_path, monkeypatch):
|
||||
"""Test returns None when no project markers found"""
|
||||
from mcp_server.config import DataPlatformConfig
|
||||
|
||||
empty_dir = tmp_path / 'empty'
|
||||
empty_dir.mkdir()
|
||||
|
||||
monkeypatch.chdir(empty_dir)
|
||||
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
|
||||
monkeypatch.delenv('PWD', raising=False)
|
||||
monkeypatch.delenv('DBT_PROJECT_DIR', raising=False)
|
||||
|
||||
config = DataPlatformConfig()
|
||||
result = config._find_project_directory()
|
||||
|
||||
assert result is None
|
||||
240
mcp-servers/data-platform/tests/test_data_store.py
Normal file
240
mcp-servers/data-platform/tests/test_data_store.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Unit tests for Arrow IPC DataFrame registry.
|
||||
"""
|
||||
import pytest
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
|
||||
|
||||
def test_store_pandas_dataframe():
|
||||
"""Test storing pandas DataFrame"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
# Create fresh instance for test
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
df = pd.DataFrame({'a': [1, 2, 3], 'b': ['x', 'y', 'z']})
|
||||
data_ref = store.store(df, name='test_df')
|
||||
|
||||
assert data_ref == 'test_df'
|
||||
assert 'test_df' in store._dataframes
|
||||
assert store._metadata['test_df'].rows == 3
|
||||
assert store._metadata['test_df'].columns == 2
|
||||
|
||||
|
||||
def test_store_arrow_table():
|
||||
"""Test storing Arrow Table directly"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
table = pa.table({'x': [1, 2, 3], 'y': [4, 5, 6]})
|
||||
data_ref = store.store(table, name='arrow_test')
|
||||
|
||||
assert data_ref == 'arrow_test'
|
||||
assert store._dataframes['arrow_test'].num_rows == 3
|
||||
|
||||
|
||||
def test_store_auto_name():
|
||||
"""Test auto-generated data_ref names"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
df = pd.DataFrame({'a': [1, 2]})
|
||||
data_ref = store.store(df)
|
||||
|
||||
assert data_ref.startswith('df_')
|
||||
assert len(data_ref) == 11 # df_ + 8 hex chars
|
||||
|
||||
|
||||
def test_get_dataframe():
|
||||
"""Test retrieving stored DataFrame"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
df = pd.DataFrame({'a': [1, 2, 3]})
|
||||
store.store(df, name='get_test')
|
||||
|
||||
result = store.get('get_test')
|
||||
assert result is not None
|
||||
assert result.num_rows == 3
|
||||
|
||||
|
||||
def test_get_pandas():
|
||||
"""Test retrieving as pandas DataFrame"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
df = pd.DataFrame({'a': [1, 2, 3], 'b': ['x', 'y', 'z']})
|
||||
store.store(df, name='pandas_test')
|
||||
|
||||
result = store.get_pandas('pandas_test')
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert list(result.columns) == ['a', 'b']
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
def test_get_nonexistent():
|
||||
"""Test getting nonexistent data_ref returns None"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
assert store.get('nonexistent') is None
|
||||
assert store.get_pandas('nonexistent') is None
|
||||
|
||||
|
||||
def test_list_refs():
|
||||
"""Test listing all stored DataFrames"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
store.store(pd.DataFrame({'a': [1, 2]}), name='df1')
|
||||
store.store(pd.DataFrame({'b': [3, 4, 5]}), name='df2')
|
||||
|
||||
refs = store.list_refs()
|
||||
|
||||
assert len(refs) == 2
|
||||
ref_names = [r['ref'] for r in refs]
|
||||
assert 'df1' in ref_names
|
||||
assert 'df2' in ref_names
|
||||
|
||||
|
||||
def test_drop_dataframe():
|
||||
"""Test dropping a DataFrame"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
store.store(pd.DataFrame({'a': [1]}), name='drop_test')
|
||||
assert store.get('drop_test') is not None
|
||||
|
||||
result = store.drop('drop_test')
|
||||
assert result is True
|
||||
assert store.get('drop_test') is None
|
||||
|
||||
|
||||
def test_drop_nonexistent():
|
||||
"""Test dropping nonexistent data_ref"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
result = store.drop('nonexistent')
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_clear():
|
||||
"""Test clearing all DataFrames"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
store.store(pd.DataFrame({'a': [1]}), name='df1')
|
||||
store.store(pd.DataFrame({'b': [2]}), name='df2')
|
||||
|
||||
store.clear()
|
||||
|
||||
assert len(store.list_refs()) == 0
|
||||
|
||||
|
||||
def test_get_info():
|
||||
"""Test getting DataFrame metadata"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
df = pd.DataFrame({'a': [1, 2, 3], 'b': ['x', 'y', 'z']})
|
||||
store.store(df, name='info_test', source='test source')
|
||||
|
||||
info = store.get_info('info_test')
|
||||
|
||||
assert info.ref == 'info_test'
|
||||
assert info.rows == 3
|
||||
assert info.columns == 2
|
||||
assert info.column_names == ['a', 'b']
|
||||
assert info.source == 'test source'
|
||||
assert info.memory_bytes > 0
|
||||
|
||||
|
||||
def test_total_memory():
|
||||
"""Test total memory calculation"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
store.store(pd.DataFrame({'a': range(100)}), name='df1')
|
||||
store.store(pd.DataFrame({'b': range(200)}), name='df2')
|
||||
|
||||
total = store.total_memory_bytes()
|
||||
assert total > 0
|
||||
|
||||
total_mb = store.total_memory_mb()
|
||||
assert total_mb >= 0
|
||||
|
||||
|
||||
def test_check_row_limit():
|
||||
"""Test row limit checking"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._max_rows = 100
|
||||
|
||||
# Under limit
|
||||
result = store.check_row_limit(50)
|
||||
assert result['exceeded'] is False
|
||||
|
||||
# Over limit
|
||||
result = store.check_row_limit(150)
|
||||
assert result['exceeded'] is True
|
||||
assert 'suggestion' in result
|
||||
|
||||
|
||||
def test_metadata_dtypes():
|
||||
"""Test that dtypes are correctly recorded"""
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
store = DataStore()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
df = pd.DataFrame({
|
||||
'int_col': [1, 2, 3],
|
||||
'float_col': [1.1, 2.2, 3.3],
|
||||
'str_col': ['a', 'b', 'c']
|
||||
})
|
||||
store.store(df, name='dtype_test')
|
||||
|
||||
info = store.get_info('dtype_test')
|
||||
|
||||
assert 'int_col' in info.dtypes
|
||||
assert 'float_col' in info.dtypes
|
||||
assert 'str_col' in info.dtypes
|
||||
318
mcp-servers/data-platform/tests/test_dbt_tools.py
Normal file
318
mcp-servers/data-platform/tests/test_dbt_tools.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Unit tests for dbt MCP tools.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
import subprocess
|
||||
import json
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(tmp_path):
|
||||
"""Mock configuration with dbt project"""
|
||||
dbt_dir = tmp_path / 'dbt_project'
|
||||
dbt_dir.mkdir()
|
||||
(dbt_dir / 'dbt_project.yml').write_text('name: test_project\n')
|
||||
|
||||
return {
|
||||
'dbt_project_dir': str(dbt_dir),
|
||||
'dbt_profiles_dir': str(tmp_path / '.dbt')
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbt_tools(mock_config):
|
||||
"""Create DbtTools instance with mocked config"""
|
||||
with patch('mcp_server.dbt_tools.load_config', return_value=mock_config):
|
||||
from mcp_server.dbt_tools import DbtTools
|
||||
|
||||
tools = DbtTools()
|
||||
tools.project_dir = mock_config['dbt_project_dir']
|
||||
tools.profiles_dir = mock_config['dbt_profiles_dir']
|
||||
return tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_parse_success(dbt_tools):
|
||||
"""Test successful dbt parse"""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = 'Parsed successfully'
|
||||
mock_result.stderr = ''
|
||||
|
||||
with patch('subprocess.run', return_value=mock_result):
|
||||
result = await dbt_tools.dbt_parse()
|
||||
|
||||
assert result['valid'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_parse_failure(dbt_tools):
|
||||
"""Test dbt parse with errors"""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 1
|
||||
mock_result.stdout = ''
|
||||
mock_result.stderr = 'Compilation error: deprecated syntax'
|
||||
|
||||
with patch('subprocess.run', return_value=mock_result):
|
||||
result = await dbt_tools.dbt_parse()
|
||||
|
||||
assert result['valid'] is False
|
||||
assert 'deprecated' in str(result.get('details', '')).lower() or len(result.get('errors', [])) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_run_with_prevalidation(dbt_tools):
|
||||
"""Test dbt run includes pre-validation"""
|
||||
# First call is parse, second is run
|
||||
mock_parse = MagicMock()
|
||||
mock_parse.returncode = 0
|
||||
mock_parse.stdout = 'OK'
|
||||
mock_parse.stderr = ''
|
||||
|
||||
mock_run = MagicMock()
|
||||
mock_run.returncode = 0
|
||||
mock_run.stdout = 'Completed successfully'
|
||||
mock_run.stderr = ''
|
||||
|
||||
with patch('subprocess.run', side_effect=[mock_parse, mock_run]):
|
||||
result = await dbt_tools.dbt_run()
|
||||
|
||||
assert result['success'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_run_fails_validation(dbt_tools):
|
||||
"""Test dbt run fails if validation fails"""
|
||||
mock_parse = MagicMock()
|
||||
mock_parse.returncode = 1
|
||||
mock_parse.stdout = ''
|
||||
mock_parse.stderr = 'Parse error'
|
||||
|
||||
with patch('subprocess.run', return_value=mock_parse):
|
||||
result = await dbt_tools.dbt_run()
|
||||
|
||||
assert 'error' in result
|
||||
assert 'Pre-validation failed' in result['error']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_run_with_selection(dbt_tools):
|
||||
"""Test dbt run with model selection"""
|
||||
mock_parse = MagicMock()
|
||||
mock_parse.returncode = 0
|
||||
mock_parse.stdout = 'OK'
|
||||
mock_parse.stderr = ''
|
||||
|
||||
mock_run = MagicMock()
|
||||
mock_run.returncode = 0
|
||||
mock_run.stdout = 'Completed'
|
||||
mock_run.stderr = ''
|
||||
|
||||
calls = []
|
||||
|
||||
def track_calls(*args, **kwargs):
|
||||
calls.append(args[0] if args else kwargs.get('args', []))
|
||||
if len(calls) == 1:
|
||||
return mock_parse
|
||||
return mock_run
|
||||
|
||||
with patch('subprocess.run', side_effect=track_calls):
|
||||
result = await dbt_tools.dbt_run(select='dim_customers')
|
||||
|
||||
# Verify --select was passed
|
||||
assert any('--select' in str(call) for call in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_test(dbt_tools):
|
||||
"""Test dbt test"""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = 'All tests passed'
|
||||
mock_result.stderr = ''
|
||||
|
||||
with patch('subprocess.run', return_value=mock_result):
|
||||
result = await dbt_tools.dbt_test()
|
||||
|
||||
assert result['success'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_build(dbt_tools):
|
||||
"""Test dbt build with pre-validation"""
|
||||
mock_parse = MagicMock()
|
||||
mock_parse.returncode = 0
|
||||
mock_parse.stdout = 'OK'
|
||||
mock_parse.stderr = ''
|
||||
|
||||
mock_build = MagicMock()
|
||||
mock_build.returncode = 0
|
||||
mock_build.stdout = 'Build complete'
|
||||
mock_build.stderr = ''
|
||||
|
||||
with patch('subprocess.run', side_effect=[mock_parse, mock_build]):
|
||||
result = await dbt_tools.dbt_build()
|
||||
|
||||
assert result['success'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_compile(dbt_tools):
|
||||
"""Test dbt compile"""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = 'Compiled'
|
||||
mock_result.stderr = ''
|
||||
|
||||
with patch('subprocess.run', return_value=mock_result):
|
||||
result = await dbt_tools.dbt_compile()
|
||||
|
||||
assert result['success'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_ls(dbt_tools):
|
||||
"""Test dbt ls"""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = 'dim_customers\ndim_products\nfct_orders\n'
|
||||
mock_result.stderr = ''
|
||||
|
||||
with patch('subprocess.run', return_value=mock_result):
|
||||
result = await dbt_tools.dbt_ls()
|
||||
|
||||
assert result['success'] is True
|
||||
assert result['count'] == 3
|
||||
assert 'dim_customers' in result['resources']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_docs_generate(dbt_tools, tmp_path):
|
||||
"""Test dbt docs generate"""
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = 'Done'
|
||||
mock_result.stderr = ''
|
||||
|
||||
# Create fake target directory
|
||||
target_dir = tmp_path / 'dbt_project' / 'target'
|
||||
target_dir.mkdir(parents=True)
|
||||
(target_dir / 'catalog.json').write_text('{}')
|
||||
(target_dir / 'manifest.json').write_text('{}')
|
||||
|
||||
dbt_tools.project_dir = str(tmp_path / 'dbt_project')
|
||||
|
||||
with patch('subprocess.run', return_value=mock_result):
|
||||
result = await dbt_tools.dbt_docs_generate()
|
||||
|
||||
assert result['success'] is True
|
||||
assert result['catalog_generated'] is True
|
||||
assert result['manifest_generated'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_lineage(dbt_tools, tmp_path):
|
||||
"""Test dbt lineage"""
|
||||
# Create manifest
|
||||
target_dir = tmp_path / 'dbt_project' / 'target'
|
||||
target_dir.mkdir(parents=True)
|
||||
|
||||
manifest = {
|
||||
'nodes': {
|
||||
'model.test.dim_customers': {
|
||||
'name': 'dim_customers',
|
||||
'resource_type': 'model',
|
||||
'schema': 'public',
|
||||
'database': 'testdb',
|
||||
'description': 'Customer dimension',
|
||||
'tags': ['daily'],
|
||||
'config': {'materialized': 'table'},
|
||||
'depends_on': {
|
||||
'nodes': ['model.test.stg_customers']
|
||||
}
|
||||
},
|
||||
'model.test.stg_customers': {
|
||||
'name': 'stg_customers',
|
||||
'resource_type': 'model',
|
||||
'depends_on': {'nodes': []}
|
||||
},
|
||||
'model.test.fct_orders': {
|
||||
'name': 'fct_orders',
|
||||
'resource_type': 'model',
|
||||
'depends_on': {
|
||||
'nodes': ['model.test.dim_customers']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(target_dir / 'manifest.json').write_text(json.dumps(manifest))
|
||||
|
||||
dbt_tools.project_dir = str(tmp_path / 'dbt_project')
|
||||
|
||||
result = await dbt_tools.dbt_lineage('dim_customers')
|
||||
|
||||
assert result['model'] == 'dim_customers'
|
||||
assert 'model.test.stg_customers' in result['upstream']
|
||||
assert 'model.test.fct_orders' in result['downstream']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_lineage_model_not_found(dbt_tools, tmp_path):
|
||||
"""Test dbt lineage with nonexistent model"""
|
||||
target_dir = tmp_path / 'dbt_project' / 'target'
|
||||
target_dir.mkdir(parents=True)
|
||||
|
||||
manifest = {
|
||||
'nodes': {
|
||||
'model.test.dim_customers': {
|
||||
'name': 'dim_customers',
|
||||
'resource_type': 'model'
|
||||
}
|
||||
}
|
||||
}
|
||||
(target_dir / 'manifest.json').write_text(json.dumps(manifest))
|
||||
|
||||
dbt_tools.project_dir = str(tmp_path / 'dbt_project')
|
||||
|
||||
result = await dbt_tools.dbt_lineage('nonexistent_model')
|
||||
|
||||
assert 'error' in result
|
||||
assert 'not found' in result['error'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_no_project():
|
||||
"""Test dbt tools when no project configured"""
|
||||
with patch('mcp_server.dbt_tools.load_config', return_value={'dbt_project_dir': None}):
|
||||
from mcp_server.dbt_tools import DbtTools
|
||||
|
||||
tools = DbtTools()
|
||||
tools.project_dir = None
|
||||
|
||||
result = await tools.dbt_run()
|
||||
|
||||
assert 'error' in result
|
||||
assert 'not found' in result['error'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_timeout(dbt_tools):
|
||||
"""Test dbt command timeout handling"""
|
||||
with patch('subprocess.run', side_effect=subprocess.TimeoutExpired('dbt', 300)):
|
||||
result = await dbt_tools.dbt_parse()
|
||||
|
||||
assert 'error' in result
|
||||
assert 'timed out' in result['error'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dbt_not_installed(dbt_tools):
|
||||
"""Test handling when dbt is not installed"""
|
||||
with patch('subprocess.run', side_effect=FileNotFoundError()):
|
||||
result = await dbt_tools.dbt_parse()
|
||||
|
||||
assert 'error' in result
|
||||
assert 'not found' in result['error'].lower()
|
||||
301
mcp-servers/data-platform/tests/test_pandas_tools.py
Normal file
301
mcp-servers/data-platform/tests/test_pandas_tools.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
Unit tests for pandas MCP tools.
|
||||
"""
|
||||
import pytest
|
||||
import pandas as pd
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_csv(tmp_path):
|
||||
"""Create a temporary CSV file for testing"""
|
||||
csv_path = tmp_path / 'test.csv'
|
||||
df = pd.DataFrame({
|
||||
'id': [1, 2, 3, 4, 5],
|
||||
'name': ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'],
|
||||
'value': [10.5, 20.0, 30.5, 40.0, 50.5]
|
||||
})
|
||||
df.to_csv(csv_path, index=False)
|
||||
return str(csv_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_parquet(tmp_path):
|
||||
"""Create a temporary Parquet file for testing"""
|
||||
parquet_path = tmp_path / 'test.parquet'
|
||||
df = pd.DataFrame({
|
||||
'id': [1, 2, 3],
|
||||
'data': ['a', 'b', 'c']
|
||||
})
|
||||
df.to_parquet(parquet_path)
|
||||
return str(parquet_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_json(tmp_path):
|
||||
"""Create a temporary JSON file for testing"""
|
||||
json_path = tmp_path / 'test.json'
|
||||
df = pd.DataFrame({
|
||||
'x': [1, 2],
|
||||
'y': [3, 4]
|
||||
})
|
||||
df.to_json(json_path, orient='records')
|
||||
return str(json_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pandas_tools():
|
||||
"""Create PandasTools instance with fresh store"""
|
||||
from mcp_server.pandas_tools import PandasTools
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
# Reset store for test isolation
|
||||
store = DataStore.get_instance()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
return PandasTools()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_csv(pandas_tools, temp_csv):
|
||||
"""Test reading CSV file"""
|
||||
result = await pandas_tools.read_csv(temp_csv, name='csv_test')
|
||||
|
||||
assert 'data_ref' in result
|
||||
assert result['data_ref'] == 'csv_test'
|
||||
assert result['rows'] == 5
|
||||
assert 'id' in result['columns']
|
||||
assert 'name' in result['columns']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_csv_nonexistent(pandas_tools):
|
||||
"""Test reading nonexistent CSV file"""
|
||||
result = await pandas_tools.read_csv('/nonexistent/path.csv')
|
||||
|
||||
assert 'error' in result
|
||||
assert 'not found' in result['error'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_parquet(pandas_tools, temp_parquet):
|
||||
"""Test reading Parquet file"""
|
||||
result = await pandas_tools.read_parquet(temp_parquet, name='parquet_test')
|
||||
|
||||
assert 'data_ref' in result
|
||||
assert result['rows'] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_json(pandas_tools, temp_json):
|
||||
"""Test reading JSON file"""
|
||||
result = await pandas_tools.read_json(temp_json, name='json_test')
|
||||
|
||||
assert 'data_ref' in result
|
||||
assert result['rows'] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_to_csv(pandas_tools, temp_csv, tmp_path):
|
||||
"""Test exporting to CSV"""
|
||||
# First load some data
|
||||
await pandas_tools.read_csv(temp_csv, name='export_test')
|
||||
|
||||
# Export to new file
|
||||
output_path = str(tmp_path / 'output.csv')
|
||||
result = await pandas_tools.to_csv('export_test', output_path)
|
||||
|
||||
assert result['success'] is True
|
||||
assert os.path.exists(output_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_to_parquet(pandas_tools, temp_csv, tmp_path):
|
||||
"""Test exporting to Parquet"""
|
||||
await pandas_tools.read_csv(temp_csv, name='parquet_export')
|
||||
|
||||
output_path = str(tmp_path / 'output.parquet')
|
||||
result = await pandas_tools.to_parquet('parquet_export', output_path)
|
||||
|
||||
assert result['success'] is True
|
||||
assert os.path.exists(output_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_describe(pandas_tools, temp_csv):
|
||||
"""Test describe statistics"""
|
||||
await pandas_tools.read_csv(temp_csv, name='describe_test')
|
||||
|
||||
result = await pandas_tools.describe('describe_test')
|
||||
|
||||
assert 'data_ref' in result
|
||||
assert 'shape' in result
|
||||
assert result['shape']['rows'] == 5
|
||||
assert 'statistics' in result
|
||||
assert 'null_counts' in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head(pandas_tools, temp_csv):
|
||||
"""Test getting first N rows"""
|
||||
await pandas_tools.read_csv(temp_csv, name='head_test')
|
||||
|
||||
result = await pandas_tools.head('head_test', n=3)
|
||||
|
||||
assert result['returned_rows'] == 3
|
||||
assert len(result['data']) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail(pandas_tools, temp_csv):
|
||||
"""Test getting last N rows"""
|
||||
await pandas_tools.read_csv(temp_csv, name='tail_test')
|
||||
|
||||
result = await pandas_tools.tail('tail_test', n=2)
|
||||
|
||||
assert result['returned_rows'] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter(pandas_tools, temp_csv):
|
||||
"""Test filtering rows"""
|
||||
await pandas_tools.read_csv(temp_csv, name='filter_test')
|
||||
|
||||
result = await pandas_tools.filter('filter_test', 'value > 25')
|
||||
|
||||
assert 'data_ref' in result
|
||||
assert result['rows'] == 3 # 30.5, 40.0, 50.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_invalid_condition(pandas_tools, temp_csv):
|
||||
"""Test filter with invalid condition"""
|
||||
await pandas_tools.read_csv(temp_csv, name='filter_error')
|
||||
|
||||
result = await pandas_tools.filter('filter_error', 'invalid_column > 0')
|
||||
|
||||
assert 'error' in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_select(pandas_tools, temp_csv):
|
||||
"""Test selecting columns"""
|
||||
await pandas_tools.read_csv(temp_csv, name='select_test')
|
||||
|
||||
result = await pandas_tools.select('select_test', ['id', 'name'])
|
||||
|
||||
assert 'data_ref' in result
|
||||
assert result['columns'] == ['id', 'name']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_select_invalid_column(pandas_tools, temp_csv):
|
||||
"""Test select with invalid column"""
|
||||
await pandas_tools.read_csv(temp_csv, name='select_error')
|
||||
|
||||
result = await pandas_tools.select('select_error', ['id', 'nonexistent'])
|
||||
|
||||
assert 'error' in result
|
||||
assert 'available_columns' in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_groupby(pandas_tools, tmp_path):
|
||||
"""Test groupby aggregation"""
|
||||
# Create test data with groups
|
||||
csv_path = tmp_path / 'groupby.csv'
|
||||
df = pd.DataFrame({
|
||||
'category': ['A', 'A', 'B', 'B'],
|
||||
'value': [10, 20, 30, 40]
|
||||
})
|
||||
df.to_csv(csv_path, index=False)
|
||||
|
||||
await pandas_tools.read_csv(str(csv_path), name='groupby_test')
|
||||
|
||||
result = await pandas_tools.groupby(
|
||||
'groupby_test',
|
||||
by='category',
|
||||
agg={'value': 'sum'}
|
||||
)
|
||||
|
||||
assert 'data_ref' in result
|
||||
assert result['rows'] == 2 # Two groups: A, B
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_join(pandas_tools, tmp_path):
|
||||
"""Test joining DataFrames"""
|
||||
# Create left table
|
||||
left_path = tmp_path / 'left.csv'
|
||||
pd.DataFrame({
|
||||
'id': [1, 2, 3],
|
||||
'name': ['A', 'B', 'C']
|
||||
}).to_csv(left_path, index=False)
|
||||
|
||||
# Create right table
|
||||
right_path = tmp_path / 'right.csv'
|
||||
pd.DataFrame({
|
||||
'id': [1, 2, 4],
|
||||
'value': [100, 200, 400]
|
||||
}).to_csv(right_path, index=False)
|
||||
|
||||
await pandas_tools.read_csv(str(left_path), name='left')
|
||||
await pandas_tools.read_csv(str(right_path), name='right')
|
||||
|
||||
result = await pandas_tools.join('left', 'right', on='id', how='inner')
|
||||
|
||||
assert 'data_ref' in result
|
||||
assert result['rows'] == 2 # Only id 1 and 2 match
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_data(pandas_tools, temp_csv):
|
||||
"""Test listing all DataFrames"""
|
||||
await pandas_tools.read_csv(temp_csv, name='list_test1')
|
||||
await pandas_tools.read_csv(temp_csv, name='list_test2')
|
||||
|
||||
result = await pandas_tools.list_data()
|
||||
|
||||
assert result['count'] == 2
|
||||
refs = [df['ref'] for df in result['dataframes']]
|
||||
assert 'list_test1' in refs
|
||||
assert 'list_test2' in refs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_data(pandas_tools, temp_csv):
|
||||
"""Test dropping DataFrame"""
|
||||
await pandas_tools.read_csv(temp_csv, name='drop_test')
|
||||
|
||||
result = await pandas_tools.drop_data('drop_test')
|
||||
|
||||
assert result['success'] is True
|
||||
|
||||
# Verify it's gone
|
||||
list_result = await pandas_tools.list_data()
|
||||
refs = [df['ref'] for df in list_result['dataframes']]
|
||||
assert 'drop_test' not in refs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_nonexistent(pandas_tools):
|
||||
"""Test dropping nonexistent DataFrame"""
|
||||
result = await pandas_tools.drop_data('nonexistent')
|
||||
|
||||
assert 'error' in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operations_on_nonexistent(pandas_tools):
|
||||
"""Test operations on nonexistent data_ref"""
|
||||
result = await pandas_tools.describe('nonexistent')
|
||||
assert 'error' in result
|
||||
|
||||
result = await pandas_tools.head('nonexistent')
|
||||
assert 'error' in result
|
||||
|
||||
result = await pandas_tools.filter('nonexistent', 'x > 0')
|
||||
assert 'error' in result
|
||||
338
mcp-servers/data-platform/tests/test_postgres_tools.py
Normal file
338
mcp-servers/data-platform/tests/test_postgres_tools.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
Unit tests for PostgreSQL MCP tools.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch, MagicMock
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Mock configuration"""
|
||||
return {
|
||||
'postgres_url': 'postgresql://test:test@localhost:5432/testdb',
|
||||
'max_rows': 100000
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def postgres_tools(mock_config):
|
||||
"""Create PostgresTools instance with mocked config"""
|
||||
with patch('mcp_server.postgres_tools.load_config', return_value=mock_config):
|
||||
from mcp_server.postgres_tools import PostgresTools
|
||||
from mcp_server.data_store import DataStore
|
||||
|
||||
# Reset store
|
||||
store = DataStore.get_instance()
|
||||
store._dataframes = {}
|
||||
store._metadata = {}
|
||||
|
||||
tools = PostgresTools()
|
||||
tools.config = mock_config
|
||||
return tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_connect_no_config():
|
||||
"""Test pg_connect when no PostgreSQL configured"""
|
||||
with patch('mcp_server.postgres_tools.load_config', return_value={'postgres_url': None}):
|
||||
from mcp_server.postgres_tools import PostgresTools
|
||||
|
||||
tools = PostgresTools()
|
||||
tools.config = {'postgres_url': None}
|
||||
|
||||
result = await tools.pg_connect()
|
||||
|
||||
assert result['connected'] is False
|
||||
assert 'not configured' in result['error'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_connect_success(postgres_tools):
|
||||
"""Test successful pg_connect"""
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchval = AsyncMock(side_effect=[
|
||||
'PostgreSQL 15.1', # version
|
||||
'testdb', # database name
|
||||
'testuser', # user
|
||||
None # PostGIS check fails
|
||||
])
|
||||
mock_conn.close = AsyncMock()
|
||||
|
||||
# Create proper async context manager
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
||||
|
||||
# Use AsyncMock for create_pool since it's awaited
|
||||
with patch('asyncpg.create_pool', new=AsyncMock(return_value=mock_pool)):
|
||||
postgres_tools.pool = None
|
||||
result = await postgres_tools.pg_connect()
|
||||
|
||||
assert result['connected'] is True
|
||||
assert result['database'] == 'testdb'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_query_success(postgres_tools):
|
||||
"""Test successful pg_query"""
|
||||
mock_rows = [
|
||||
{'id': 1, 'name': 'Alice'},
|
||||
{'id': 2, 'name': 'Bob'}
|
||||
]
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetch = AsyncMock(return_value=mock_rows)
|
||||
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.acquire = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_conn),
|
||||
__aexit__=AsyncMock()
|
||||
))
|
||||
|
||||
postgres_tools.pool = mock_pool
|
||||
|
||||
result = await postgres_tools.pg_query('SELECT * FROM users', name='users_data')
|
||||
|
||||
assert 'data_ref' in result
|
||||
assert result['rows'] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_query_empty_result(postgres_tools):
|
||||
"""Test pg_query with no results"""
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetch = AsyncMock(return_value=[])
|
||||
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.acquire = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_conn),
|
||||
__aexit__=AsyncMock()
|
||||
))
|
||||
|
||||
postgres_tools.pool = mock_pool
|
||||
|
||||
result = await postgres_tools.pg_query('SELECT * FROM empty_table')
|
||||
|
||||
assert result['data_ref'] is None
|
||||
assert result['rows'] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_execute_success(postgres_tools):
|
||||
"""Test successful pg_execute"""
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute = AsyncMock(return_value='INSERT 0 3')
|
||||
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.acquire = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_conn),
|
||||
__aexit__=AsyncMock()
|
||||
))
|
||||
|
||||
postgres_tools.pool = mock_pool
|
||||
|
||||
result = await postgres_tools.pg_execute('INSERT INTO users VALUES (1, 2, 3)')
|
||||
|
||||
assert result['success'] is True
|
||||
assert result['affected_rows'] == 3
|
||||
assert result['command'] == 'INSERT'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_tables(postgres_tools):
|
||||
"""Test listing tables"""
|
||||
mock_rows = [
|
||||
{'table_name': 'users', 'table_type': 'BASE TABLE', 'column_count': 5},
|
||||
{'table_name': 'orders', 'table_type': 'BASE TABLE', 'column_count': 8}
|
||||
]
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetch = AsyncMock(return_value=mock_rows)
|
||||
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.acquire = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_conn),
|
||||
__aexit__=AsyncMock()
|
||||
))
|
||||
|
||||
postgres_tools.pool = mock_pool
|
||||
|
||||
result = await postgres_tools.pg_tables(schema='public')
|
||||
|
||||
assert result['schema'] == 'public'
|
||||
assert result['count'] == 2
|
||||
assert len(result['tables']) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_columns(postgres_tools):
|
||||
"""Test getting column info"""
|
||||
mock_rows = [
|
||||
{
|
||||
'column_name': 'id',
|
||||
'data_type': 'integer',
|
||||
'udt_name': 'int4',
|
||||
'is_nullable': 'NO',
|
||||
'column_default': "nextval('users_id_seq'::regclass)",
|
||||
'character_maximum_length': None,
|
||||
'numeric_precision': 32
|
||||
},
|
||||
{
|
||||
'column_name': 'name',
|
||||
'data_type': 'character varying',
|
||||
'udt_name': 'varchar',
|
||||
'is_nullable': 'YES',
|
||||
'column_default': None,
|
||||
'character_maximum_length': 255,
|
||||
'numeric_precision': None
|
||||
}
|
||||
]
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetch = AsyncMock(return_value=mock_rows)
|
||||
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.acquire = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_conn),
|
||||
__aexit__=AsyncMock()
|
||||
))
|
||||
|
||||
postgres_tools.pool = mock_pool
|
||||
|
||||
result = await postgres_tools.pg_columns(table='users')
|
||||
|
||||
assert result['table'] == 'public.users'
|
||||
assert result['column_count'] == 2
|
||||
assert result['columns'][0]['name'] == 'id'
|
||||
assert result['columns'][0]['nullable'] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_schemas(postgres_tools):
|
||||
"""Test listing schemas"""
|
||||
mock_rows = [
|
||||
{'schema_name': 'public'},
|
||||
{'schema_name': 'app'}
|
||||
]
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetch = AsyncMock(return_value=mock_rows)
|
||||
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.acquire = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_conn),
|
||||
__aexit__=AsyncMock()
|
||||
))
|
||||
|
||||
postgres_tools.pool = mock_pool
|
||||
|
||||
result = await postgres_tools.pg_schemas()
|
||||
|
||||
assert result['count'] == 2
|
||||
assert 'public' in result['schemas']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_st_tables(postgres_tools):
|
||||
"""Test listing PostGIS tables"""
|
||||
mock_rows = [
|
||||
{
|
||||
'table_name': 'locations',
|
||||
'geometry_column': 'geom',
|
||||
'geometry_type': 'POINT',
|
||||
'srid': 4326,
|
||||
'coord_dimension': 2
|
||||
}
|
||||
]
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetch = AsyncMock(return_value=mock_rows)
|
||||
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.acquire = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_conn),
|
||||
__aexit__=AsyncMock()
|
||||
))
|
||||
|
||||
postgres_tools.pool = mock_pool
|
||||
|
||||
result = await postgres_tools.st_tables()
|
||||
|
||||
assert result['count'] == 1
|
||||
assert result['postgis_tables'][0]['table'] == 'locations'
|
||||
assert result['postgis_tables'][0]['srid'] == 4326
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_st_tables_no_postgis(postgres_tools):
|
||||
"""Test st_tables when PostGIS not installed"""
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetch = AsyncMock(side_effect=Exception("relation \"geometry_columns\" does not exist"))
|
||||
|
||||
# Create proper async context manager
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
||||
|
||||
postgres_tools.pool = mock_pool
|
||||
|
||||
result = await postgres_tools.st_tables()
|
||||
|
||||
assert 'error' in result
|
||||
assert 'PostGIS' in result['error']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_st_extent(postgres_tools):
|
||||
"""Test getting geometry bounding box"""
|
||||
mock_row = {
|
||||
'xmin': -122.5,
|
||||
'ymin': 37.5,
|
||||
'xmax': -122.0,
|
||||
'ymax': 38.0
|
||||
}
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetchrow = AsyncMock(return_value=mock_row)
|
||||
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.acquire = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_conn),
|
||||
__aexit__=AsyncMock()
|
||||
))
|
||||
|
||||
postgres_tools.pool = mock_pool
|
||||
|
||||
result = await postgres_tools.st_extent(table='locations', column='geom')
|
||||
|
||||
assert result['bbox']['xmin'] == -122.5
|
||||
assert result['bbox']['ymax'] == 38.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handling(postgres_tools):
|
||||
"""Test error handling for database errors"""
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetch = AsyncMock(side_effect=Exception("Connection refused"))
|
||||
|
||||
# Create proper async context manager
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
||||
|
||||
postgres_tools.pool = mock_pool
|
||||
|
||||
result = await postgres_tools.pg_query('SELECT 1')
|
||||
|
||||
assert 'error' in result
|
||||
assert 'Connection refused' in result['error']
|
||||
@@ -389,25 +389,24 @@ def list_issues(self, state='open', labels=None, repo=None):
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Part of the Claude Code Marketplace project.
|
||||
MIT License - Part of the Leo Claude Marketplace project.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **MCP Specification**: `docs/references/MCP-GITEA.md`
|
||||
- **Project Summary**: `docs/references/PROJECT-SUMMARY.md`
|
||||
- **Implementation Plan**: `docs/reference-material/projman-implementation-plan.md`
|
||||
- **Projman Documentation**: `plugins/projman/README.md`
|
||||
- **Configuration Guide**: `plugins/projman/CONFIGURATION.md`
|
||||
- **Testing Guide**: `TESTING.md`
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check [TESTING.md](./TESTING.md) troubleshooting section
|
||||
2. Review [MCP-GITEA.md](../../docs/references/MCP-GITEA.md) specification
|
||||
2. Review [plugins/projman/README.md](../../README.md) for plugin documentation
|
||||
3. Create an issue in the project repository
|
||||
|
||||
---
|
||||
|
||||
**Built for**: Claude Code Marketplace - Project Management Plugins
|
||||
**Built for**: Leo Claude Marketplace - Project Management Plugins
|
||||
**Phase**: 1 (Complete)
|
||||
**Status**: ✅ Production Ready
|
||||
**Last Updated**: 2025-01-06
|
||||
@@ -574,8 +574,8 @@ After completing testing:
|
||||
|
||||
- **MCP Documentation**: https://docs.anthropic.com/claude/docs/mcp
|
||||
- **Gitea API Documentation**: https://docs.gitea.io/en-us/api-usage/
|
||||
- **Project Documentation**: `docs/references/MCP-GITEA.md`
|
||||
- **Implementation Plan**: `docs/references/PROJECT-SUMMARY.md`
|
||||
- **Projman Documentation**: `plugins/projman/README.md`
|
||||
- **Configuration Guide**: `plugins/projman/CONFIGURATION.md`
|
||||
|
||||
---
|
||||
|
||||
227
mcp-servers/gitea/mcp_server/config.py
Normal file
227
mcp-servers/gitea/mcp_server/config.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Configuration loader for Gitea MCP Server.
|
||||
|
||||
Implements hybrid configuration system:
|
||||
- System-level: ~/.config/claude/gitea.env (credentials)
|
||||
- Project-level: .env (repository specification)
|
||||
- Auto-detection: Falls back to git remote URL parsing
|
||||
"""
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GiteaConfig:
|
||||
"""Hybrid configuration loader with mode detection"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url: Optional[str] = None
|
||||
self.api_token: Optional[str] = None
|
||||
self.repo: Optional[str] = None
|
||||
self.mode: str = 'project'
|
||||
|
||||
def load(self) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Load configuration from system and project levels.
|
||||
Project-level configuration overrides system-level.
|
||||
|
||||
Returns:
|
||||
Dict containing api_url, api_token, repo, mode
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If system config is missing
|
||||
ValueError: If required configuration is missing
|
||||
"""
|
||||
# Load system config
|
||||
system_config = Path.home() / '.config' / 'claude' / 'gitea.env'
|
||||
if system_config.exists():
|
||||
load_dotenv(system_config)
|
||||
logger.info(f"Loaded system configuration from {system_config}")
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"System config not found: {system_config}\n"
|
||||
"Create it with: mkdir -p ~/.config/claude && "
|
||||
"cat > ~/.config/claude/gitea.env"
|
||||
)
|
||||
|
||||
# Find project directory (MCP server cwd is plugin dir, not project dir)
|
||||
project_dir = self._find_project_directory()
|
||||
|
||||
# Load project config (overrides system)
|
||||
if project_dir:
|
||||
project_config = project_dir / '.env'
|
||||
if project_config.exists():
|
||||
load_dotenv(project_config, override=True)
|
||||
logger.info(f"Loaded project configuration from {project_config}")
|
||||
|
||||
# Extract values
|
||||
self.api_url = os.getenv('GITEA_API_URL')
|
||||
self.api_token = os.getenv('GITEA_API_TOKEN')
|
||||
self.repo = os.getenv('GITEA_REPO') # Optional, must be owner/repo format
|
||||
|
||||
# Auto-detect repo from git remote if not specified
|
||||
if not self.repo and project_dir:
|
||||
self.repo = self._detect_repo_from_git(project_dir)
|
||||
if self.repo:
|
||||
logger.info(f"Auto-detected repository from git remote: {self.repo}")
|
||||
|
||||
# Detect mode
|
||||
if self.repo:
|
||||
self.mode = 'project'
|
||||
logger.info(f"Running in project mode: {self.repo}")
|
||||
else:
|
||||
self.mode = 'company'
|
||||
logger.info("Running in company-wide mode (PMO)")
|
||||
|
||||
# Validate required variables
|
||||
self._validate()
|
||||
|
||||
return {
|
||||
'api_url': self.api_url,
|
||||
'api_token': self.api_token,
|
||||
'repo': self.repo,
|
||||
'mode': self.mode
|
||||
}
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""
|
||||
Validate that required configuration is present.
|
||||
|
||||
Raises:
|
||||
ValueError: If required configuration is missing
|
||||
"""
|
||||
required = {
|
||||
'GITEA_API_URL': self.api_url,
|
||||
'GITEA_API_TOKEN': self.api_token
|
||||
}
|
||||
|
||||
missing = [key for key, value in required.items() if not value]
|
||||
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"Missing required configuration: {', '.join(missing)}\n"
|
||||
"Check your ~/.config/claude/gitea.env file"
|
||||
)
|
||||
|
||||
def _find_project_directory(self) -> Optional[Path]:
|
||||
"""
|
||||
Find the user's project directory.
|
||||
|
||||
The MCP server runs with cwd set to the plugin directory, not the
|
||||
user's project. We need to find the actual project directory using
|
||||
various heuristics.
|
||||
|
||||
Returns:
|
||||
Path to project directory, or None if not found
|
||||
"""
|
||||
# Strategy 1: Check CLAUDE_PROJECT_DIR environment variable
|
||||
project_dir = os.getenv('CLAUDE_PROJECT_DIR')
|
||||
if project_dir:
|
||||
path = Path(project_dir)
|
||||
if path.exists():
|
||||
logger.info(f"Found project directory from CLAUDE_PROJECT_DIR: {path}")
|
||||
return path
|
||||
|
||||
# Strategy 2: Check PWD (original working directory before cwd override)
|
||||
pwd = os.getenv('PWD')
|
||||
if pwd:
|
||||
path = Path(pwd)
|
||||
# Verify it has .git or .env (indicates a project)
|
||||
if path.exists() and ((path / '.git').exists() or (path / '.env').exists()):
|
||||
logger.info(f"Found project directory from PWD: {path}")
|
||||
return path
|
||||
|
||||
# Strategy 3: Check current working directory
|
||||
# This handles test scenarios and cases where cwd is actually the project
|
||||
cwd = Path.cwd()
|
||||
if (cwd / '.git').exists() or (cwd / '.env').exists():
|
||||
logger.info(f"Found project directory from cwd: {cwd}")
|
||||
return cwd
|
||||
|
||||
# Strategy 4: Check if GITEA_REPO is already set (user configured it)
|
||||
# If so, we don't need to find the project directory for git detection
|
||||
if os.getenv('GITEA_REPO'):
|
||||
logger.debug("GITEA_REPO already set, skipping project directory detection")
|
||||
return None
|
||||
|
||||
logger.debug("Could not determine project directory")
|
||||
return None
|
||||
|
||||
def _detect_repo_from_git(self, project_dir: Optional[Path] = None) -> Optional[str]:
|
||||
"""
|
||||
Auto-detect repository from git remote origin URL.
|
||||
|
||||
Args:
|
||||
project_dir: Directory to run git command from (defaults to cwd)
|
||||
|
||||
Supports URL formats:
|
||||
- SSH: ssh://git@host:port/owner/repo.git
|
||||
- SSH short: git@host:owner/repo.git
|
||||
- HTTPS: https://host/owner/repo.git
|
||||
- HTTP: http://host/owner/repo.git
|
||||
|
||||
Returns:
|
||||
Repository in 'owner/repo' format, or None if detection fails
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', 'remote', 'get-url', 'origin'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
cwd=str(project_dir) if project_dir else None
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.debug("No git remote 'origin' found")
|
||||
return None
|
||||
|
||||
url = result.stdout.strip()
|
||||
return self._parse_git_url(url)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Git command timed out")
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
logger.debug("Git not available")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to detect repo from git: {e}")
|
||||
return None
|
||||
|
||||
def _parse_git_url(self, url: str) -> Optional[str]:
|
||||
"""
|
||||
Parse git URL to extract owner/repo.
|
||||
|
||||
Args:
|
||||
url: Git remote URL
|
||||
|
||||
Returns:
|
||||
Repository in 'owner/repo' format, or None if parsing fails
|
||||
"""
|
||||
# Remove .git suffix if present
|
||||
url = re.sub(r'\.git$', '', url)
|
||||
|
||||
# SSH format: ssh://git@host:port/owner/repo
|
||||
ssh_match = re.match(r'ssh://[^/]+/(.+/.+)$', url)
|
||||
if ssh_match:
|
||||
return ssh_match.group(1)
|
||||
|
||||
# SSH short format: git@host:owner/repo
|
||||
ssh_short_match = re.match(r'git@[^:]+:(.+/.+)$', url)
|
||||
if ssh_short_match:
|
||||
return ssh_short_match.group(1)
|
||||
|
||||
# HTTPS/HTTP format: https://host/owner/repo
|
||||
http_match = re.match(r'https?://[^/]+/(.+/.+)$', url)
|
||||
if http_match:
|
||||
return http_match.group(1)
|
||||
|
||||
logger.warning(f"Could not parse git URL: {url}")
|
||||
return None
|
||||
@@ -110,8 +110,14 @@ class GiteaClient:
|
||||
|
||||
def _resolve_label_ids(self, label_names: List[str], owner: str, repo: str) -> List[int]:
|
||||
"""Convert label names to label IDs."""
|
||||
full_repo = f"{owner}/{repo}"
|
||||
|
||||
# Only fetch org labels if repo belongs to an organization
|
||||
org_labels = []
|
||||
if self.is_org_repo(full_repo):
|
||||
org_labels = self.get_org_labels(owner)
|
||||
repo_labels = self.get_labels(f"{owner}/{repo}")
|
||||
|
||||
repo_labels = self.get_labels(full_repo)
|
||||
all_labels = org_labels + repo_labels
|
||||
label_map = {label['name']: label['id'] for label in all_labels}
|
||||
label_ids = []
|
||||
@@ -129,9 +135,24 @@ class GiteaClient:
|
||||
body: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
labels: Optional[List[str]] = None,
|
||||
milestone: Optional[int] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Update existing issue. Repo must be 'owner/repo' format."""
|
||||
"""
|
||||
Update existing issue.
|
||||
|
||||
Args:
|
||||
issue_number: Issue number to update
|
||||
title: New title (optional)
|
||||
body: New body (optional)
|
||||
state: New state - 'open' or 'closed' (optional)
|
||||
labels: New labels (optional)
|
||||
milestone: Milestone ID to assign (optional)
|
||||
repo: Repository in 'owner/repo' format
|
||||
|
||||
Returns:
|
||||
Updated issue dictionary
|
||||
"""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{issue_number}"
|
||||
data = {}
|
||||
@@ -143,6 +164,8 @@ class GiteaClient:
|
||||
data['state'] = state
|
||||
if labels is not None:
|
||||
data['labels'] = labels
|
||||
if milestone is not None:
|
||||
data['milestone'] = milestone
|
||||
logger.info(f"Updating issue #{issue_number} in {owner}/{target_repo}")
|
||||
response = self.session.patch(url, json=data)
|
||||
response.raise_for_status()
|
||||
@@ -233,8 +256,11 @@ class GiteaClient:
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Get a specific wiki page by name."""
|
||||
from urllib.parse import quote
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}"
|
||||
# URL-encode the page_name to handle special characters like ':'
|
||||
encoded_page_name = quote(page_name, safe='')
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{encoded_page_name}"
|
||||
logger.info(f"Getting wiki page '{page_name}' from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
@@ -265,9 +291,13 @@ class GiteaClient:
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Update an existing wiki page."""
|
||||
from urllib.parse import quote
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}"
|
||||
# URL-encode the page_name to handle special characters like ':'
|
||||
encoded_page_name = quote(page_name, safe='')
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{encoded_page_name}"
|
||||
data = {
|
||||
'title': page_name, # CRITICAL: include title to preserve page name
|
||||
'content_base64': self._encode_base64(content)
|
||||
}
|
||||
logger.info(f"Updating wiki page '{page_name}' in {owner}/{target_repo}")
|
||||
@@ -281,8 +311,11 @@ class GiteaClient:
|
||||
repo: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Delete a wiki page."""
|
||||
from urllib.parse import quote
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}"
|
||||
# URL-encode the page_name to handle special characters like ':'
|
||||
encoded_page_name = quote(page_name, safe='')
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{encoded_page_name}"
|
||||
logger.info(f"Deleting wiki page '{page_name}' from {owner}/{target_repo}")
|
||||
response = self.session.delete(url)
|
||||
response.raise_for_status()
|
||||
@@ -548,10 +581,33 @@ class GiteaClient:
|
||||
return response.json()
|
||||
|
||||
def is_org_repo(self, repo: Optional[str] = None) -> bool:
|
||||
"""Check if repository belongs to an organization (not a user)."""
|
||||
info = self.get_repo_info(repo)
|
||||
owner_type = info.get('owner', {}).get('type', '')
|
||||
return owner_type.lower() == 'organization'
|
||||
"""
|
||||
Check if repository belongs to an organization (not a user).
|
||||
|
||||
Uses the /orgs/{owner} endpoint to reliably detect organizations,
|
||||
as the owner.type field in repo info may be null in some Gitea versions.
|
||||
"""
|
||||
owner, _ = self._parse_repo(repo)
|
||||
return self._is_organization(owner)
|
||||
|
||||
def _is_organization(self, owner: str) -> bool:
|
||||
"""
|
||||
Check if an owner is an organization by querying the orgs endpoint.
|
||||
|
||||
Args:
|
||||
owner: The owner name to check
|
||||
|
||||
Returns:
|
||||
True if owner is an organization, False if user or unknown
|
||||
"""
|
||||
url = f"{self.base_url}/orgs/{owner}"
|
||||
try:
|
||||
response = self.session.get(url)
|
||||
# 200 = organization exists, 404 = not an organization (user account)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check if {owner} is organization: {e}")
|
||||
return False
|
||||
|
||||
def get_branch_protection(
|
||||
self,
|
||||
@@ -591,3 +647,199 @@ class GiteaClient:
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def create_org_label(
|
||||
self,
|
||||
org: str,
|
||||
name: str,
|
||||
color: str,
|
||||
description: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a new label at the organization level.
|
||||
|
||||
Organization labels are shared across all repositories in the org.
|
||||
Use this for workflow labels (Type, Priority, Complexity, Effort, etc.)
|
||||
|
||||
Args:
|
||||
org: Organization name
|
||||
name: Label name (e.g., 'Type/Bug', 'Priority/High')
|
||||
color: Hex color code (with or without #)
|
||||
description: Optional label description
|
||||
|
||||
Returns:
|
||||
Created label dictionary
|
||||
"""
|
||||
url = f"{self.base_url}/orgs/{org}/labels"
|
||||
data = {
|
||||
'name': name,
|
||||
'color': color.lstrip('#') # Remove # if present
|
||||
}
|
||||
if description:
|
||||
data['description'] = description
|
||||
logger.info(f"Creating organization label '{name}' in {org}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# ========================================
|
||||
# PULL REQUEST OPERATIONS
|
||||
# ========================================
|
||||
|
||||
def list_pull_requests(
|
||||
self,
|
||||
state: str = 'open',
|
||||
sort: str = 'recentupdate',
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
List pull requests from Gitea repository.
|
||||
|
||||
Args:
|
||||
state: PR state (open, closed, all)
|
||||
sort: Sort order (oldest, recentupdate, leastupdate, mostcomment, leastcomment, priority)
|
||||
labels: Filter by labels
|
||||
repo: Repository in 'owner/repo' format
|
||||
|
||||
Returns:
|
||||
List of pull request dictionaries
|
||||
"""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls"
|
||||
params = {'state': state, 'sort': sort}
|
||||
if labels:
|
||||
params['labels'] = ','.join(labels)
|
||||
logger.info(f"Listing PRs from {owner}/{target_repo} with state={state}")
|
||||
response = self.session.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_pull_request(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Get specific pull request details."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls/{pr_number}"
|
||||
logger.info(f"Getting PR #{pr_number} from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get_pr_diff(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> str:
|
||||
"""Get the diff for a pull request."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls/{pr_number}.diff"
|
||||
logger.info(f"Getting diff for PR #{pr_number} from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
def get_pr_comments(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""Get comments on a pull request (uses issue comments endpoint)."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
# PRs share comment endpoint with issues in Gitea
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{pr_number}/comments"
|
||||
logger.info(f"Getting comments for PR #{pr_number} from {owner}/{target_repo}")
|
||||
response = self.session.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def create_pr_review(
|
||||
self,
|
||||
pr_number: int,
|
||||
body: str,
|
||||
event: str = 'COMMENT',
|
||||
comments: Optional[List[Dict]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a review on a pull request.
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
body: Review body/summary
|
||||
event: Review action (APPROVE, REQUEST_CHANGES, COMMENT)
|
||||
comments: Optional list of inline comments with path, position, body
|
||||
repo: Repository in 'owner/repo' format
|
||||
|
||||
Returns:
|
||||
Created review dictionary
|
||||
"""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls/{pr_number}/reviews"
|
||||
data = {
|
||||
'body': body,
|
||||
'event': event
|
||||
}
|
||||
if comments:
|
||||
data['comments'] = comments
|
||||
logger.info(f"Creating review on PR #{pr_number} in {owner}/{target_repo}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def add_pr_comment(
|
||||
self,
|
||||
pr_number: int,
|
||||
body: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""Add a general comment to a pull request (uses issue comment endpoint)."""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
# PRs share comment endpoint with issues in Gitea
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{pr_number}/comments"
|
||||
data = {'body': body}
|
||||
logger.info(f"Adding comment to PR #{pr_number} in {owner}/{target_repo}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def create_pull_request(
|
||||
self,
|
||||
title: str,
|
||||
body: str,
|
||||
head: str,
|
||||
base: str,
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a new pull request.
|
||||
|
||||
Args:
|
||||
title: PR title
|
||||
body: PR description/body
|
||||
head: Source branch name (the branch with changes)
|
||||
base: Target branch name (the branch to merge into)
|
||||
labels: Optional list of label names
|
||||
repo: Repository in 'owner/repo' format
|
||||
|
||||
Returns:
|
||||
Created pull request dictionary
|
||||
"""
|
||||
owner, target_repo = self._parse_repo(repo)
|
||||
url = f"{self.base_url}/repos/{owner}/{target_repo}/pulls"
|
||||
data = {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'head': head,
|
||||
'base': base
|
||||
}
|
||||
if labels:
|
||||
label_ids = self._resolve_label_ids(labels, owner, target_repo)
|
||||
data['labels'] = label_ids
|
||||
logger.info(f"Creating PR '{title}' in {owner}/{target_repo}: {head} -> {base}")
|
||||
response = self.session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -17,6 +17,7 @@ from .tools.labels import LabelTools
|
||||
from .tools.wiki import WikiTools
|
||||
from .tools.milestones import MilestoneTools
|
||||
from .tools.dependencies import DependencyTools
|
||||
from .tools.pull_requests import PullRequestTools
|
||||
|
||||
# Suppress noisy MCP validation warnings on stderr
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -37,6 +38,7 @@ class GiteaMCPServer:
|
||||
self.wiki_tools = None
|
||||
self.milestone_tools = None
|
||||
self.dependency_tools = None
|
||||
self.pr_tools = None
|
||||
|
||||
async def initialize(self):
|
||||
"""
|
||||
@@ -55,6 +57,7 @@ class GiteaMCPServer:
|
||||
self.wiki_tools = WikiTools(self.client)
|
||||
self.milestone_tools = MilestoneTools(self.client)
|
||||
self.dependency_tools = DependencyTools(self.client)
|
||||
self.pr_tools = PullRequestTools(self.client)
|
||||
|
||||
logger.info(f"Gitea MCP Server initialized in {self.config['mode']} mode")
|
||||
except Exception as e:
|
||||
@@ -165,6 +168,10 @@ class GiteaMCPServer:
|
||||
"items": {"type": "string"},
|
||||
"description": "New labels"
|
||||
},
|
||||
"milestone": {
|
||||
"type": "integer",
|
||||
"description": "Milestone ID to assign"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (for PMO mode)"
|
||||
@@ -217,6 +224,10 @@ class GiteaMCPServer:
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "Issue title + description or sprint context"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["context"]
|
||||
@@ -615,13 +626,13 @@ class GiteaMCPServer:
|
||||
),
|
||||
Tool(
|
||||
name="create_label",
|
||||
description="Create a new label in the repository",
|
||||
description="Create a new label in the repository (for repo-specific labels like Component/*, Tech/*)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Label name"
|
||||
"description": "Label name (e.g., 'Component/Backend', 'Tech/Python')"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
@@ -638,6 +649,240 @@ class GiteaMCPServer:
|
||||
},
|
||||
"required": ["name", "color"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="create_org_label",
|
||||
description="Create a new label at organization level (for workflow labels like Type/*, Priority/*, Complexity/*, Effort/*)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"org": {
|
||||
"type": "string",
|
||||
"description": "Organization name"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Label name (e.g., 'Type/Bug', 'Priority/High')"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "Label color (hex code)"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Label description"
|
||||
}
|
||||
},
|
||||
"required": ["org", "name", "color"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="create_label_smart",
|
||||
description="Create a label at the appropriate level (org or repo) based on category. Org: Type/*, Priority/*, Complexity/*, Effort/*, Risk/*, Source/*, Agent/*. Repo: Component/*, Tech/*",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Label name (e.g., 'Type/Bug', 'Component/Backend')"
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "Label color (hex code)"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Label description"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["name", "color"]
|
||||
}
|
||||
),
|
||||
# Pull Request Tools
|
||||
Tool(
|
||||
name="list_pull_requests",
|
||||
description="List pull requests from repository",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["open", "closed", "all"],
|
||||
"default": "open",
|
||||
"description": "PR state filter"
|
||||
},
|
||||
"sort": {
|
||||
"type": "string",
|
||||
"enum": ["oldest", "recentupdate", "leastupdate", "mostcomment", "leastcomment", "priority"],
|
||||
"default": "recentupdate",
|
||||
"description": "Sort order"
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Filter by labels"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_pull_request",
|
||||
description="Get specific pull request details",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["pr_number"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_pr_diff",
|
||||
description="Get the diff for a pull request",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["pr_number"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="get_pr_comments",
|
||||
description="Get comments on a pull request",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["pr_number"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="create_pr_review",
|
||||
description="Create a review on a pull request (approve, request changes, or comment)",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Review body/summary"
|
||||
},
|
||||
"event": {
|
||||
"type": "string",
|
||||
"enum": ["APPROVE", "REQUEST_CHANGES", "COMMENT"],
|
||||
"default": "COMMENT",
|
||||
"description": "Review action"
|
||||
},
|
||||
"comments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"position": {"type": "integer"},
|
||||
"body": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"description": "Optional inline comments"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["pr_number", "body"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="add_pr_comment",
|
||||
description="Add a general comment to a pull request",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Comment text"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["pr_number", "body"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="create_pull_request",
|
||||
description="Create a new pull request",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "PR title"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "PR description/body"
|
||||
},
|
||||
"head": {
|
||||
"type": "string",
|
||||
"description": "Source branch name (the branch with changes)"
|
||||
},
|
||||
"base": {
|
||||
"type": "string",
|
||||
"description": "Target branch name (the branch to merge into)"
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional list of label names"
|
||||
},
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Repository name (owner/repo format)"
|
||||
}
|
||||
},
|
||||
"required": ["title", "body", "head", "base"]
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
@@ -726,6 +971,35 @@ class GiteaMCPServer:
|
||||
arguments.get('description'),
|
||||
arguments.get('repo')
|
||||
)
|
||||
elif name == "create_org_label":
|
||||
result = self.client.create_org_label(
|
||||
arguments['org'],
|
||||
arguments['name'],
|
||||
arguments['color'],
|
||||
arguments.get('description')
|
||||
)
|
||||
elif name == "create_label_smart":
|
||||
result = await self.label_tools.create_label_smart(
|
||||
arguments['name'],
|
||||
arguments['color'],
|
||||
arguments.get('description'),
|
||||
arguments.get('repo')
|
||||
)
|
||||
# Pull Request tools
|
||||
elif name == "list_pull_requests":
|
||||
result = await self.pr_tools.list_pull_requests(**arguments)
|
||||
elif name == "get_pull_request":
|
||||
result = await self.pr_tools.get_pull_request(**arguments)
|
||||
elif name == "get_pr_diff":
|
||||
result = await self.pr_tools.get_pr_diff(**arguments)
|
||||
elif name == "get_pr_comments":
|
||||
result = await self.pr_tools.get_pr_comments(**arguments)
|
||||
elif name == "create_pr_review":
|
||||
result = await self.pr_tools.create_pr_review(**arguments)
|
||||
elif name == "add_pr_comment":
|
||||
result = await self.pr_tools.add_pr_comment(**arguments)
|
||||
elif name == "create_pull_request":
|
||||
result = await self.pr_tools.create_pull_request(**arguments)
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
@@ -4,4 +4,8 @@ MCP tools for Gitea integration.
|
||||
This package provides MCP tool implementations for:
|
||||
- Issue operations (issues.py)
|
||||
- Label management (labels.py)
|
||||
- Wiki operations (wiki.py)
|
||||
- Milestone management (milestones.py)
|
||||
- Issue dependencies (dependencies.py)
|
||||
- Pull request operations (pull_requests.py)
|
||||
"""
|
||||
@@ -7,6 +7,7 @@ Provides async wrappers for issue CRUD operations with:
|
||||
- Comprehensive error handling
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
@@ -27,19 +28,34 @@ class IssueTools:
|
||||
"""
|
||||
self.gitea = gitea_client
|
||||
|
||||
def _get_project_directory(self) -> Optional[str]:
|
||||
"""
|
||||
Get the user's project directory from environment.
|
||||
|
||||
Returns:
|
||||
Project directory path or None if not set
|
||||
"""
|
||||
return os.environ.get('CLAUDE_PROJECT_DIR')
|
||||
|
||||
def _get_current_branch(self) -> str:
|
||||
"""
|
||||
Get current git branch.
|
||||
Get current git branch from user's project directory.
|
||||
|
||||
Uses CLAUDE_PROJECT_DIR environment variable to determine the correct
|
||||
directory for git operations, avoiding the bug where git runs from
|
||||
the installed plugin directory instead of the user's project.
|
||||
|
||||
Returns:
|
||||
Current branch name or 'unknown' if not in a git repo
|
||||
"""
|
||||
try:
|
||||
project_dir = self._get_project_directory()
|
||||
result = subprocess.run(
|
||||
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
check=True,
|
||||
cwd=project_dir # Run git in project directory, not plugin directory
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -66,7 +82,13 @@ class IssueTools:
|
||||
return operation in ['list_issues', 'get_issue', 'get_labels', 'create_issue']
|
||||
|
||||
# Development branches (full access)
|
||||
if branch in ['development', 'develop'] or branch.startswith(('feat/', 'feature/', 'dev/')):
|
||||
# Include all common feature/fix branch patterns
|
||||
dev_prefixes = (
|
||||
'feat/', 'feature/', 'dev/',
|
||||
'fix/', 'bugfix/', 'hotfix/',
|
||||
'chore/', 'refactor/', 'docs/', 'test/'
|
||||
)
|
||||
if branch in ['development', 'develop'] or branch.startswith(dev_prefixes):
|
||||
return True
|
||||
|
||||
# Unknown branch - be restrictive
|
||||
@@ -178,6 +200,7 @@ class IssueTools:
|
||||
body: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
labels: Optional[List[str]] = None,
|
||||
milestone: Optional[int] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
@@ -189,6 +212,7 @@ class IssueTools:
|
||||
body: New body (optional)
|
||||
state: New state - 'open' or 'closed' (optional)
|
||||
labels: New labels (optional)
|
||||
milestone: Milestone ID to assign (optional)
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
@@ -207,7 +231,7 @@ class IssueTools:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.update_issue(issue_number, title, body, state, labels, repo)
|
||||
lambda: self.gitea.update_issue(issue_number, title, body, state, labels, milestone, repo)
|
||||
)
|
||||
|
||||
async def add_comment(
|
||||
377
mcp-servers/gitea/mcp_server/tools/labels.py
Normal file
377
mcp-servers/gitea/mcp_server/tools/labels.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
Label management tools for MCP server.
|
||||
|
||||
Provides async wrappers for label operations with:
|
||||
- Label taxonomy retrieval
|
||||
- Intelligent label suggestion
|
||||
- Dynamic label detection
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LabelTools:
|
||||
"""Async wrappers for Gitea label operations"""
|
||||
|
||||
def __init__(self, gitea_client):
|
||||
"""
|
||||
Initialize label tools.
|
||||
|
||||
Args:
|
||||
gitea_client: GiteaClient instance
|
||||
"""
|
||||
self.gitea = gitea_client
|
||||
|
||||
async def get_labels(self, repo: Optional[str] = None) -> Dict[str, List[Dict]]:
|
||||
"""Get all labels (org + repo if org-owned, repo-only if user-owned)."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
target_repo = repo or self.gitea.repo
|
||||
if not target_repo or '/' not in target_repo:
|
||||
raise ValueError("Use 'owner/repo' format (e.g. 'org/repo-name')")
|
||||
|
||||
# Check if repo belongs to an organization or user
|
||||
is_org = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.is_org_repo(target_repo)
|
||||
)
|
||||
|
||||
org_labels = []
|
||||
if is_org:
|
||||
org = target_repo.split('/')[0]
|
||||
org_labels = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_org_labels(org)
|
||||
)
|
||||
|
||||
repo_labels = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_labels(target_repo)
|
||||
)
|
||||
|
||||
return {
|
||||
'organization': org_labels,
|
||||
'repository': repo_labels,
|
||||
'total_count': len(org_labels) + len(repo_labels)
|
||||
}
|
||||
|
||||
async def suggest_labels(self, context: str, repo: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
Analyze context and suggest appropriate labels from repository's actual labels.
|
||||
|
||||
This method fetches actual labels from the repository and matches them
|
||||
dynamically, supporting any label naming convention (slash, colon-space, etc.).
|
||||
|
||||
Args:
|
||||
context: Issue title + description or sprint context
|
||||
repo: Repository in 'owner/repo' format (optional, uses default if not provided)
|
||||
|
||||
Returns:
|
||||
List of suggested label names that exist in the repository
|
||||
"""
|
||||
# Fetch actual labels from repository
|
||||
target_repo = repo or self.gitea.repo
|
||||
if not target_repo:
|
||||
logger.warning("No repository specified, returning empty suggestions")
|
||||
return []
|
||||
|
||||
try:
|
||||
labels_data = await self.get_labels(target_repo)
|
||||
all_labels = labels_data.get('organization', []) + labels_data.get('repository', [])
|
||||
label_names = [label['name'] for label in all_labels]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch labels: {e}. Using fallback suggestions.")
|
||||
label_names = []
|
||||
|
||||
# Build label lookup for dynamic matching
|
||||
label_lookup = self._build_label_lookup(label_names)
|
||||
|
||||
suggested = []
|
||||
context_lower = context.lower()
|
||||
|
||||
# Type detection (exclusive - only one)
|
||||
type_label = None
|
||||
if any(word in context_lower for word in ['bug', 'error', 'fix', 'broken', 'crash', 'fail']):
|
||||
type_label = self._find_label(label_lookup, 'type', 'bug')
|
||||
elif any(word in context_lower for word in ['refactor', 'extract', 'restructure', 'architecture', 'service extraction']):
|
||||
type_label = self._find_label(label_lookup, 'type', 'refactor')
|
||||
elif any(word in context_lower for word in ['feature', 'add', 'implement', 'new', 'create']):
|
||||
type_label = self._find_label(label_lookup, 'type', 'feature')
|
||||
elif any(word in context_lower for word in ['docs', 'documentation', 'readme', 'guide']):
|
||||
type_label = self._find_label(label_lookup, 'type', 'documentation')
|
||||
elif any(word in context_lower for word in ['test', 'testing', 'spec', 'coverage']):
|
||||
type_label = self._find_label(label_lookup, 'type', 'test')
|
||||
elif any(word in context_lower for word in ['chore', 'maintenance', 'update', 'upgrade']):
|
||||
type_label = self._find_label(label_lookup, 'type', 'chore')
|
||||
if type_label:
|
||||
suggested.append(type_label)
|
||||
|
||||
# Priority detection
|
||||
priority_label = None
|
||||
if any(word in context_lower for word in ['critical', 'urgent', 'blocker', 'blocking', 'emergency']):
|
||||
priority_label = self._find_label(label_lookup, 'priority', 'critical')
|
||||
elif any(word in context_lower for word in ['high', 'important', 'asap', 'soon']):
|
||||
priority_label = self._find_label(label_lookup, 'priority', 'high')
|
||||
elif any(word in context_lower for word in ['low', 'nice-to-have', 'optional', 'later']):
|
||||
priority_label = self._find_label(label_lookup, 'priority', 'low')
|
||||
else:
|
||||
priority_label = self._find_label(label_lookup, 'priority', 'medium')
|
||||
if priority_label:
|
||||
suggested.append(priority_label)
|
||||
|
||||
# Complexity detection
|
||||
complexity_label = None
|
||||
if any(word in context_lower for word in ['simple', 'trivial', 'easy', 'quick']):
|
||||
complexity_label = self._find_label(label_lookup, 'complexity', 'simple')
|
||||
elif any(word in context_lower for word in ['complex', 'difficult', 'challenging', 'intricate']):
|
||||
complexity_label = self._find_label(label_lookup, 'complexity', 'complex')
|
||||
else:
|
||||
complexity_label = self._find_label(label_lookup, 'complexity', 'medium')
|
||||
if complexity_label:
|
||||
suggested.append(complexity_label)
|
||||
|
||||
# Effort detection (supports both "Effort" and "Efforts" naming)
|
||||
effort_label = None
|
||||
if any(word in context_lower for word in ['xs', 'tiny', '1 hour', '2 hours']):
|
||||
effort_label = self._find_label(label_lookup, 'effort', 'xs')
|
||||
elif any(word in context_lower for word in ['small', 's ', '1 day', 'half day']):
|
||||
effort_label = self._find_label(label_lookup, 'effort', 's')
|
||||
elif any(word in context_lower for word in ['medium', 'm ', '2 days', '3 days']):
|
||||
effort_label = self._find_label(label_lookup, 'effort', 'm')
|
||||
elif any(word in context_lower for word in ['large', 'l ', '1 week', '5 days']):
|
||||
effort_label = self._find_label(label_lookup, 'effort', 'l')
|
||||
elif any(word in context_lower for word in ['xl', 'extra large', '2 weeks', 'sprint']):
|
||||
effort_label = self._find_label(label_lookup, 'effort', 'xl')
|
||||
if effort_label:
|
||||
suggested.append(effort_label)
|
||||
|
||||
# Component detection (based on keywords)
|
||||
component_mappings = {
|
||||
'backend': ['backend', 'server', 'api', 'database', 'service'],
|
||||
'frontend': ['frontend', 'ui', 'interface', 'react', 'vue', 'component'],
|
||||
'api': ['api', 'endpoint', 'rest', 'graphql', 'route'],
|
||||
'database': ['database', 'db', 'sql', 'migration', 'schema', 'postgres'],
|
||||
'auth': ['auth', 'authentication', 'login', 'oauth', 'token', 'session'],
|
||||
'deploy': ['deploy', 'deployment', 'docker', 'kubernetes', 'ci/cd'],
|
||||
'testing': ['test', 'testing', 'spec', 'jest', 'pytest', 'coverage'],
|
||||
'docs': ['docs', 'documentation', 'readme', 'guide', 'wiki']
|
||||
}
|
||||
|
||||
for component, keywords in component_mappings.items():
|
||||
if any(keyword in context_lower for keyword in keywords):
|
||||
label = self._find_label(label_lookup, 'component', component)
|
||||
if label and label not in suggested:
|
||||
suggested.append(label)
|
||||
|
||||
# Tech stack detection
|
||||
tech_mappings = {
|
||||
'python': ['python', 'fastapi', 'django', 'flask', 'pytest'],
|
||||
'javascript': ['javascript', 'js', 'node', 'npm', 'yarn'],
|
||||
'docker': ['docker', 'dockerfile', 'container', 'compose'],
|
||||
'postgresql': ['postgres', 'postgresql', 'psql', 'sql'],
|
||||
'redis': ['redis', 'cache', 'session store'],
|
||||
'vue': ['vue', 'vuejs', 'nuxt'],
|
||||
'fastapi': ['fastapi', 'pydantic', 'starlette']
|
||||
}
|
||||
|
||||
for tech, keywords in tech_mappings.items():
|
||||
if any(keyword in context_lower for keyword in keywords):
|
||||
label = self._find_label(label_lookup, 'tech', tech)
|
||||
if label and label not in suggested:
|
||||
suggested.append(label)
|
||||
|
||||
# Source detection (based on git branch or context)
|
||||
source_label = None
|
||||
if 'development' in context_lower or 'dev/' in context_lower:
|
||||
source_label = self._find_label(label_lookup, 'source', 'development')
|
||||
elif 'staging' in context_lower or 'stage/' in context_lower:
|
||||
source_label = self._find_label(label_lookup, 'source', 'staging')
|
||||
elif 'production' in context_lower or 'prod' in context_lower:
|
||||
source_label = self._find_label(label_lookup, 'source', 'production')
|
||||
if source_label:
|
||||
suggested.append(source_label)
|
||||
|
||||
# Risk detection
|
||||
risk_label = None
|
||||
if any(word in context_lower for word in ['breaking', 'breaking change', 'major', 'risky']):
|
||||
risk_label = self._find_label(label_lookup, 'risk', 'high')
|
||||
elif any(word in context_lower for word in ['safe', 'low risk', 'minor']):
|
||||
risk_label = self._find_label(label_lookup, 'risk', 'low')
|
||||
if risk_label:
|
||||
suggested.append(risk_label)
|
||||
|
||||
logger.info(f"Suggested {len(suggested)} labels based on context and {len(label_names)} available labels")
|
||||
return suggested
|
||||
|
||||
def _build_label_lookup(self, label_names: List[str]) -> Dict[str, Dict[str, str]]:
|
||||
"""
|
||||
Build a lookup dictionary for label matching.
|
||||
|
||||
Supports various label formats:
|
||||
- Slash format: Type/Bug, Priority/High
|
||||
- Colon-space format: Type: Bug, Priority: High
|
||||
- Colon format: Type:Bug
|
||||
|
||||
Args:
|
||||
label_names: List of actual label names from repository
|
||||
|
||||
Returns:
|
||||
Nested dict: {category: {value: actual_label_name}}
|
||||
"""
|
||||
lookup: Dict[str, Dict[str, str]] = {}
|
||||
|
||||
for label in label_names:
|
||||
# Try different separator patterns
|
||||
# Pattern: Category<separator>Value
|
||||
# Separators: /, : , :
|
||||
match = re.match(r'^([^/:]+)(?:/|:\s*|:)(.+)$', label)
|
||||
if match:
|
||||
category = match.group(1).lower().rstrip('s') # Normalize: "Efforts" -> "effort"
|
||||
value = match.group(2).lower()
|
||||
|
||||
if category not in lookup:
|
||||
lookup[category] = {}
|
||||
lookup[category][value] = label
|
||||
|
||||
return lookup
|
||||
|
||||
def _find_label(self, lookup: Dict[str, Dict[str, str]], category: str, value: str) -> Optional[str]:
|
||||
"""
|
||||
Find actual label name from lookup.
|
||||
|
||||
Args:
|
||||
lookup: Label lookup dictionary
|
||||
category: Category to search (e.g., 'type', 'priority')
|
||||
value: Value to find (e.g., 'bug', 'high')
|
||||
|
||||
Returns:
|
||||
Actual label name if found, None otherwise
|
||||
"""
|
||||
category_lower = category.lower().rstrip('s') # Normalize
|
||||
value_lower = value.lower()
|
||||
|
||||
if category_lower in lookup and value_lower in lookup[category_lower]:
|
||||
return lookup[category_lower][value_lower]
|
||||
|
||||
return None
|
||||
|
||||
# Organization-level label categories (workflow labels shared across repos)
|
||||
ORG_LABEL_CATEGORIES = {'agent', 'complexity', 'effort', 'efforts', 'priority', 'risk', 'source', 'type'}
|
||||
|
||||
# Repository-level label categories (project-specific labels)
|
||||
REPO_LABEL_CATEGORIES = {'component', 'tech'}
|
||||
|
||||
async def create_label_smart(
|
||||
self,
|
||||
name: str,
|
||||
color: str,
|
||||
description: Optional[str] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a label at the appropriate level (org or repo) based on category.
|
||||
Skips if label already exists (checks both org and repo levels).
|
||||
|
||||
Organization labels: Agent, Complexity, Effort, Priority, Risk, Source, Type
|
||||
Repository labels: Component, Tech
|
||||
|
||||
Args:
|
||||
name: Label name (e.g., 'Type/Bug', 'Component/Backend')
|
||||
color: Hex color code
|
||||
description: Optional label description
|
||||
repo: Repository in 'owner/repo' format
|
||||
|
||||
Returns:
|
||||
Created label dictionary with 'level' key, or 'skipped' if already exists
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
target_repo = repo or self.gitea.repo
|
||||
if not target_repo or '/' not in target_repo:
|
||||
raise ValueError("Use 'owner/repo' format (e.g. 'org/repo-name')")
|
||||
|
||||
owner = target_repo.split('/')[0]
|
||||
is_org = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.is_org_repo(target_repo)
|
||||
)
|
||||
|
||||
# Fetch existing labels to check for duplicates
|
||||
existing_labels = await self.get_labels(target_repo)
|
||||
all_existing = existing_labels.get('organization', []) + existing_labels.get('repository', [])
|
||||
existing_names = [label['name'].lower() for label in all_existing]
|
||||
|
||||
# Normalize the new label name for comparison
|
||||
name_normalized = name.lower()
|
||||
|
||||
# Also check for format variations (Type/Bug vs Type: Bug)
|
||||
name_variations = [name_normalized]
|
||||
if '/' in name:
|
||||
name_variations.append(name.replace('/', ': ').lower())
|
||||
name_variations.append(name.replace('/', ':').lower())
|
||||
elif ': ' in name:
|
||||
name_variations.append(name.replace(': ', '/').lower())
|
||||
elif ':' in name:
|
||||
name_variations.append(name.replace(':', '/').lower())
|
||||
|
||||
# Check if label already exists in any format
|
||||
for variation in name_variations:
|
||||
if variation in existing_names:
|
||||
logger.info(f"Label '{name}' already exists (found as '{variation}'), skipping")
|
||||
return {
|
||||
'name': name,
|
||||
'skipped': True,
|
||||
'reason': f"Label already exists",
|
||||
'level': 'existing'
|
||||
}
|
||||
|
||||
# Parse category from label name
|
||||
category = None
|
||||
if '/' in name:
|
||||
category = name.split('/')[0].lower().rstrip('s')
|
||||
elif ':' in name:
|
||||
category = name.split(':')[0].strip().lower().rstrip('s')
|
||||
|
||||
# If it's an org repo and the category is an org-level category, create at org level
|
||||
if is_org and category in self.ORG_LABEL_CATEGORIES:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.create_org_label(owner, name, color, description)
|
||||
)
|
||||
# Handle unexpected response types (API may return list or non-dict)
|
||||
if not isinstance(result, dict):
|
||||
logger.error(f"Unexpected API response type for org label: {type(result)} - {result}")
|
||||
return {
|
||||
'name': name,
|
||||
'error': True,
|
||||
'reason': f"API returned {type(result).__name__} instead of dict: {result}",
|
||||
'level': 'organization'
|
||||
}
|
||||
result['level'] = 'organization'
|
||||
result['skipped'] = False
|
||||
logger.info(f"Created organization label '{name}' in {owner}")
|
||||
else:
|
||||
# Create at repo level
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.create_label(name, color, description, target_repo)
|
||||
)
|
||||
# Handle unexpected response types (API may return list or non-dict)
|
||||
if not isinstance(result, dict):
|
||||
logger.error(f"Unexpected API response type for repo label: {type(result)} - {result}")
|
||||
return {
|
||||
'name': name,
|
||||
'error': True,
|
||||
'reason': f"API returned {type(result).__name__} instead of dict: {result}",
|
||||
'level': 'repository'
|
||||
}
|
||||
result['level'] = 'repository'
|
||||
result['skipped'] = False
|
||||
logger.info(f"Created repository label '{name}' in {target_repo}")
|
||||
|
||||
return result
|
||||
335
mcp-servers/gitea/mcp_server/tools/pull_requests.py
Normal file
335
mcp-servers/gitea/mcp_server/tools/pull_requests.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Pull request management tools for MCP server.
|
||||
|
||||
Provides async wrappers for PR operations with:
|
||||
- Branch-aware security
|
||||
- PMO multi-repo support
|
||||
- Comprehensive error handling
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PullRequestTools:
|
||||
"""Async wrappers for Gitea pull request operations with branch detection"""
|
||||
|
||||
def __init__(self, gitea_client):
|
||||
"""
|
||||
Initialize pull request tools.
|
||||
|
||||
Args:
|
||||
gitea_client: GiteaClient instance
|
||||
"""
|
||||
self.gitea = gitea_client
|
||||
|
||||
def _get_project_directory(self) -> Optional[str]:
|
||||
"""
|
||||
Get the user's project directory from environment.
|
||||
|
||||
Returns:
|
||||
Project directory path or None if not set
|
||||
"""
|
||||
return os.environ.get('CLAUDE_PROJECT_DIR')
|
||||
|
||||
def _get_current_branch(self) -> str:
|
||||
"""
|
||||
Get current git branch from user's project directory.
|
||||
|
||||
Uses CLAUDE_PROJECT_DIR environment variable to determine the correct
|
||||
directory for git operations, avoiding the bug where git runs from
|
||||
the installed plugin directory instead of the user's project.
|
||||
|
||||
Returns:
|
||||
Current branch name or 'unknown' if not in a git repo
|
||||
"""
|
||||
try:
|
||||
project_dir = self._get_project_directory()
|
||||
result = subprocess.run(
|
||||
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=project_dir # Run git in project directory, not plugin directory
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return "unknown"
|
||||
|
||||
def _check_branch_permissions(self, operation: str) -> bool:
|
||||
"""
|
||||
Check if operation is allowed on current branch.
|
||||
|
||||
Args:
|
||||
operation: Operation name (list_prs, create_review, etc.)
|
||||
|
||||
Returns:
|
||||
True if operation is allowed, False otherwise
|
||||
"""
|
||||
branch = self._get_current_branch()
|
||||
|
||||
# Read-only operations allowed everywhere
|
||||
read_ops = ['list_pull_requests', 'get_pull_request', 'get_pr_diff', 'get_pr_comments']
|
||||
|
||||
# Production branches (read-only)
|
||||
if branch in ['main', 'master'] or branch.startswith('prod/'):
|
||||
return operation in read_ops
|
||||
|
||||
# Staging branches (read-only for PRs, can comment)
|
||||
if branch == 'staging' or branch.startswith('stage/'):
|
||||
return operation in read_ops + ['add_pr_comment']
|
||||
|
||||
# Development branches (full access)
|
||||
# Include all common feature/fix branch patterns
|
||||
dev_prefixes = (
|
||||
'feat/', 'feature/', 'dev/',
|
||||
'fix/', 'bugfix/', 'hotfix/',
|
||||
'chore/', 'refactor/', 'docs/', 'test/'
|
||||
)
|
||||
if branch in ['development', 'develop'] or branch.startswith(dev_prefixes):
|
||||
return True
|
||||
|
||||
# Unknown branch - be restrictive
|
||||
return operation in read_ops
|
||||
|
||||
async def list_pull_requests(
|
||||
self,
|
||||
state: str = 'open',
|
||||
sort: str = 'recentupdate',
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
List pull requests from repository (async wrapper).
|
||||
|
||||
Args:
|
||||
state: PR state (open, closed, all)
|
||||
sort: Sort order
|
||||
labels: Filter by labels
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
List of pull request dictionaries
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('list_pull_requests'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot list PRs on branch '{branch}'. "
|
||||
f"Switch to a development branch."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.list_pull_requests(state, sort, labels, repo)
|
||||
)
|
||||
|
||||
async def get_pull_request(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Get specific pull request details (async wrapper).
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Pull request dictionary
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('get_pull_request'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot get PR on branch '{branch}'. "
|
||||
f"Switch to a development branch."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_pull_request(pr_number, repo)
|
||||
)
|
||||
|
||||
async def get_pr_diff(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Get pull request diff (async wrapper).
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Diff as string
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('get_pr_diff'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot get PR diff on branch '{branch}'. "
|
||||
f"Switch to a development branch."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_pr_diff(pr_number, repo)
|
||||
)
|
||||
|
||||
async def get_pr_comments(
|
||||
self,
|
||||
pr_number: int,
|
||||
repo: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get comments on a pull request (async wrapper).
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
List of comment dictionaries
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('get_pr_comments'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot get PR comments on branch '{branch}'. "
|
||||
f"Switch to a development branch."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.get_pr_comments(pr_number, repo)
|
||||
)
|
||||
|
||||
async def create_pr_review(
|
||||
self,
|
||||
pr_number: int,
|
||||
body: str,
|
||||
event: str = 'COMMENT',
|
||||
comments: Optional[List[Dict]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a review on a pull request (async wrapper with branch check).
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
body: Review body/summary
|
||||
event: Review action (APPROVE, REQUEST_CHANGES, COMMENT)
|
||||
comments: Optional list of inline comments
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Created review dictionary
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('create_pr_review'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot create PR review on branch '{branch}'. "
|
||||
f"Switch to a development branch to review PRs."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.create_pr_review(pr_number, body, event, comments, repo)
|
||||
)
|
||||
|
||||
async def add_pr_comment(
|
||||
self,
|
||||
pr_number: int,
|
||||
body: str,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Add a general comment to a pull request (async wrapper with branch check).
|
||||
|
||||
Args:
|
||||
pr_number: Pull request number
|
||||
body: Comment text
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Created comment dictionary
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('add_pr_comment'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot add PR comment on branch '{branch}'. "
|
||||
f"Switch to a development or staging branch to comment on PRs."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.add_pr_comment(pr_number, body, repo)
|
||||
)
|
||||
|
||||
async def create_pull_request(
|
||||
self,
|
||||
title: str,
|
||||
body: str,
|
||||
head: str,
|
||||
base: str,
|
||||
labels: Optional[List[str]] = None,
|
||||
repo: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Create a new pull request (async wrapper with branch check).
|
||||
|
||||
Args:
|
||||
title: PR title
|
||||
body: PR description/body
|
||||
head: Source branch name (the branch with changes)
|
||||
base: Target branch name (the branch to merge into)
|
||||
labels: Optional list of label names
|
||||
repo: Override configured repo (for PMO multi-repo)
|
||||
|
||||
Returns:
|
||||
Created pull request dictionary
|
||||
|
||||
Raises:
|
||||
PermissionError: If operation not allowed on current branch
|
||||
"""
|
||||
if not self._check_branch_permissions('create_pull_request'):
|
||||
branch = self._get_current_branch()
|
||||
raise PermissionError(
|
||||
f"Cannot create PR on branch '{branch}'. "
|
||||
f"Switch to a development or feature branch to create PRs."
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.gitea.create_pull_request(title, body, head, base, labels, repo)
|
||||
)
|
||||
21
mcp-servers/gitea/run.sh
Executable file
21
mcp-servers/gitea/run.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Capture original working directory before any cd operations
|
||||
# This should be the user's project directory when launched by Claude Code
|
||||
export CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CACHE_VENV="$HOME/.cache/claude-mcp-venvs/leo-claude-mktplace/gitea/.venv"
|
||||
LOCAL_VENV="$SCRIPT_DIR/.venv"
|
||||
|
||||
if [[ -f "$CACHE_VENV/bin/python" ]]; then
|
||||
PYTHON="$CACHE_VENV/bin/python"
|
||||
elif [[ -f "$LOCAL_VENV/bin/python" ]]; then
|
||||
PYTHON="$LOCAL_VENV/bin/python"
|
||||
else
|
||||
echo "ERROR: No venv found. Run: ./scripts/setup-venvs.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
export PYTHONPATH="$SCRIPT_DIR"
|
||||
exec "$PYTHON" -m mcp_server.server "$@"
|
||||
@@ -149,3 +149,112 @@ def test_mode_detection_company(tmp_path, monkeypatch):
|
||||
|
||||
assert result['mode'] == 'company'
|
||||
assert result['repo'] is None
|
||||
|
||||
|
||||
# ========================================
|
||||
# GIT URL PARSING TESTS
|
||||
# ========================================
|
||||
|
||||
def test_parse_git_url_ssh_format():
|
||||
"""Test parsing SSH format git URL"""
|
||||
config = GiteaConfig()
|
||||
|
||||
# SSH with port: ssh://git@host:port/owner/repo.git
|
||||
url = "ssh://git@hotserv.tailc9b278.ts.net:2222/personal-projects/personal-portfolio.git"
|
||||
result = config._parse_git_url(url)
|
||||
assert result == "personal-projects/personal-portfolio"
|
||||
|
||||
|
||||
def test_parse_git_url_ssh_short_format():
|
||||
"""Test parsing SSH short format git URL"""
|
||||
config = GiteaConfig()
|
||||
|
||||
# SSH short: git@host:owner/repo.git
|
||||
url = "git@github.com:owner/repo.git"
|
||||
result = config._parse_git_url(url)
|
||||
assert result == "owner/repo"
|
||||
|
||||
|
||||
def test_parse_git_url_https_format():
|
||||
"""Test parsing HTTPS format git URL"""
|
||||
config = GiteaConfig()
|
||||
|
||||
# HTTPS: https://host/owner/repo.git
|
||||
url = "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git"
|
||||
result = config._parse_git_url(url)
|
||||
assert result == "personal-projects/leo-claude-mktplace"
|
||||
|
||||
|
||||
def test_parse_git_url_http_format():
|
||||
"""Test parsing HTTP format git URL"""
|
||||
config = GiteaConfig()
|
||||
|
||||
# HTTP: http://host/owner/repo.git
|
||||
url = "http://gitea.hotserv.cloud/personal-projects/repo.git"
|
||||
result = config._parse_git_url(url)
|
||||
assert result == "personal-projects/repo"
|
||||
|
||||
|
||||
def test_parse_git_url_without_git_suffix():
|
||||
"""Test parsing git URL without .git suffix"""
|
||||
config = GiteaConfig()
|
||||
|
||||
url = "https://github.com/owner/repo"
|
||||
result = config._parse_git_url(url)
|
||||
assert result == "owner/repo"
|
||||
|
||||
|
||||
def test_parse_git_url_invalid_format():
|
||||
"""Test parsing invalid git URL returns None"""
|
||||
config = GiteaConfig()
|
||||
|
||||
url = "not-a-valid-url"
|
||||
result = config._parse_git_url(url)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_find_project_directory_from_env(tmp_path, monkeypatch):
|
||||
"""Test finding project directory from CLAUDE_PROJECT_DIR env var"""
|
||||
project_dir = tmp_path / 'my-project'
|
||||
project_dir.mkdir()
|
||||
(project_dir / '.git').mkdir()
|
||||
|
||||
monkeypatch.setenv('CLAUDE_PROJECT_DIR', str(project_dir))
|
||||
|
||||
config = GiteaConfig()
|
||||
result = config._find_project_directory()
|
||||
|
||||
assert result == project_dir
|
||||
|
||||
|
||||
def test_find_project_directory_from_cwd(tmp_path, monkeypatch):
|
||||
"""Test finding project directory from cwd with .env file"""
|
||||
project_dir = tmp_path / 'project'
|
||||
project_dir.mkdir()
|
||||
(project_dir / '.env').write_text("GITEA_REPO=test/repo")
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
# Clear env vars that might interfere
|
||||
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
|
||||
monkeypatch.delenv('PWD', raising=False)
|
||||
|
||||
config = GiteaConfig()
|
||||
result = config._find_project_directory()
|
||||
|
||||
assert result == project_dir
|
||||
|
||||
|
||||
def test_find_project_directory_none_when_no_markers(tmp_path, monkeypatch):
|
||||
"""Test returns None when no project markers found"""
|
||||
empty_dir = tmp_path / 'empty'
|
||||
empty_dir.mkdir()
|
||||
|
||||
monkeypatch.chdir(empty_dir)
|
||||
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
|
||||
monkeypatch.delenv('PWD', raising=False)
|
||||
monkeypatch.delenv('GITEA_REPO', raising=False)
|
||||
|
||||
config = GiteaConfig()
|
||||
result = config._find_project_directory()
|
||||
|
||||
assert result is None
|
||||
@@ -222,3 +222,47 @@ def test_no_repo_specified_error(gitea_client):
|
||||
client.list_issues()
|
||||
|
||||
assert "Repository not specified" in str(exc_info.value)
|
||||
|
||||
|
||||
# ========================================
|
||||
# ORGANIZATION DETECTION TESTS
|
||||
# ========================================
|
||||
|
||||
def test_is_organization_true(gitea_client):
|
||||
"""Test _is_organization returns True for valid organization"""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(gitea_client.session, 'get', return_value=mock_response):
|
||||
result = gitea_client._is_organization('personal-projects')
|
||||
|
||||
assert result is True
|
||||
gitea_client.session.get.assert_called_once_with(
|
||||
'https://test.com/api/v1/orgs/personal-projects'
|
||||
)
|
||||
|
||||
|
||||
def test_is_organization_false(gitea_client):
|
||||
"""Test _is_organization returns False for user account"""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 404
|
||||
|
||||
with patch.object(gitea_client.session, 'get', return_value=mock_response):
|
||||
result = gitea_client._is_organization('lmiranda')
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_is_org_repo_uses_orgs_endpoint(gitea_client):
|
||||
"""Test is_org_repo uses /orgs endpoint instead of owner.type"""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(gitea_client.session, 'get', return_value=mock_response):
|
||||
result = gitea_client.is_org_repo('personal-projects/repo')
|
||||
|
||||
assert result is True
|
||||
# Should call /orgs/personal-projects, not /repos/.../
|
||||
gitea_client.session.get.assert_called_once_with(
|
||||
'https://test.com/api/v1/orgs/personal-projects'
|
||||
)
|
||||
478
mcp-servers/gitea/tests/test_labels.py
Normal file
478
mcp-servers/gitea/tests/test_labels.py
Normal file
@@ -0,0 +1,478 @@
|
||||
"""
|
||||
Unit tests for label tools with suggestion logic.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from mcp_server.tools.labels import LabelTools
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gitea_client():
|
||||
"""Fixture providing mocked Gitea client"""
|
||||
client = Mock()
|
||||
client.repo = 'test_org/test_repo'
|
||||
client.is_org_repo = Mock(return_value=True)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def label_tools(mock_gitea_client):
|
||||
"""Fixture providing LabelTools instance"""
|
||||
return LabelTools(mock_gitea_client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_labels(label_tools):
|
||||
"""Test getting all labels (org + repo)"""
|
||||
label_tools.gitea.get_org_labels = Mock(return_value=[
|
||||
{'name': 'Type/Bug'},
|
||||
{'name': 'Type/Feature'}
|
||||
])
|
||||
label_tools.gitea.get_labels = Mock(return_value=[
|
||||
{'name': 'Component/Backend'},
|
||||
{'name': 'Component/Frontend'}
|
||||
])
|
||||
|
||||
result = await label_tools.get_labels()
|
||||
|
||||
assert len(result['organization']) == 2
|
||||
assert len(result['repository']) == 2
|
||||
assert result['total_count'] == 4
|
||||
|
||||
|
||||
# ========================================
|
||||
# LABEL LOOKUP TESTS (NEW)
|
||||
# ========================================
|
||||
|
||||
def test_build_label_lookup_slash_format():
|
||||
"""Test building label lookup with slash format labels"""
|
||||
mock_client = Mock()
|
||||
mock_client.repo = 'test/repo'
|
||||
tools = LabelTools(mock_client)
|
||||
|
||||
labels = ['Type/Bug', 'Type/Feature', 'Priority/High', 'Priority/Low']
|
||||
lookup = tools._build_label_lookup(labels)
|
||||
|
||||
assert 'type' in lookup
|
||||
assert 'bug' in lookup['type']
|
||||
assert lookup['type']['bug'] == 'Type/Bug'
|
||||
assert lookup['type']['feature'] == 'Type/Feature'
|
||||
assert 'priority' in lookup
|
||||
assert lookup['priority']['high'] == 'Priority/High'
|
||||
|
||||
|
||||
def test_build_label_lookup_colon_space_format():
|
||||
"""Test building label lookup with colon-space format labels"""
|
||||
mock_client = Mock()
|
||||
mock_client.repo = 'test/repo'
|
||||
tools = LabelTools(mock_client)
|
||||
|
||||
labels = ['Type: Bug', 'Type: Feature', 'Priority: High', 'Effort: M']
|
||||
lookup = tools._build_label_lookup(labels)
|
||||
|
||||
assert 'type' in lookup
|
||||
assert 'bug' in lookup['type']
|
||||
assert lookup['type']['bug'] == 'Type: Bug'
|
||||
assert lookup['type']['feature'] == 'Type: Feature'
|
||||
assert 'priority' in lookup
|
||||
assert lookup['priority']['high'] == 'Priority: High'
|
||||
# Test singular "Effort" (not "Efforts")
|
||||
assert 'effort' in lookup
|
||||
assert lookup['effort']['m'] == 'Effort: M'
|
||||
|
||||
|
||||
def test_build_label_lookup_efforts_normalization():
|
||||
"""Test that 'Efforts' is normalized to 'effort' for matching"""
|
||||
mock_client = Mock()
|
||||
mock_client.repo = 'test/repo'
|
||||
tools = LabelTools(mock_client)
|
||||
|
||||
labels = ['Efforts/XS', 'Efforts/S', 'Efforts/M']
|
||||
lookup = tools._build_label_lookup(labels)
|
||||
|
||||
# 'Efforts' should be normalized to 'effort'
|
||||
assert 'effort' in lookup
|
||||
assert lookup['effort']['xs'] == 'Efforts/XS'
|
||||
|
||||
|
||||
def test_find_label():
|
||||
"""Test finding labels from lookup"""
|
||||
mock_client = Mock()
|
||||
mock_client.repo = 'test/repo'
|
||||
tools = LabelTools(mock_client)
|
||||
|
||||
lookup = {
|
||||
'type': {'bug': 'Type: Bug', 'feature': 'Type: Feature'},
|
||||
'priority': {'high': 'Priority: High', 'low': 'Priority: Low'}
|
||||
}
|
||||
|
||||
assert tools._find_label(lookup, 'type', 'bug') == 'Type: Bug'
|
||||
assert tools._find_label(lookup, 'priority', 'high') == 'Priority: High'
|
||||
assert tools._find_label(lookup, 'type', 'nonexistent') is None
|
||||
assert tools._find_label(lookup, 'nonexistent', 'bug') is None
|
||||
|
||||
|
||||
# ========================================
|
||||
# SUGGEST LABELS WITH DYNAMIC FORMAT TESTS
|
||||
# ========================================
|
||||
|
||||
def _create_tools_with_labels(labels):
|
||||
"""Helper to create LabelTools with mocked labels"""
|
||||
import asyncio
|
||||
mock_client = Mock()
|
||||
mock_client.repo = 'test/repo'
|
||||
mock_client.is_org_repo = Mock(return_value=False)
|
||||
mock_client.get_labels = Mock(return_value=[{'name': l} for l in labels])
|
||||
return LabelTools(mock_client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_with_slash_format():
|
||||
"""Test label suggestion with slash format labels"""
|
||||
labels = [
|
||||
'Type/Bug', 'Type/Feature', 'Type/Refactor',
|
||||
'Priority/Critical', 'Priority/High', 'Priority/Medium', 'Priority/Low',
|
||||
'Complexity/Simple', 'Complexity/Medium', 'Complexity/Complex',
|
||||
'Component/Auth'
|
||||
]
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
context = "Fix critical bug in login authentication"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
|
||||
assert 'Type/Bug' in suggestions
|
||||
assert 'Priority/Critical' in suggestions
|
||||
assert 'Component/Auth' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_with_colon_space_format():
|
||||
"""Test label suggestion with colon-space format labels"""
|
||||
labels = [
|
||||
'Type: Bug', 'Type: Feature', 'Type: Refactor',
|
||||
'Priority: Critical', 'Priority: High', 'Priority: Medium', 'Priority: Low',
|
||||
'Complexity: Simple', 'Complexity: Medium', 'Complexity: Complex',
|
||||
'Effort: XS', 'Effort: S', 'Effort: M', 'Effort: L', 'Effort: XL'
|
||||
]
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
context = "Fix critical bug for tiny 1 hour fix"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
|
||||
# Should return colon-space format labels
|
||||
assert 'Type: Bug' in suggestions
|
||||
assert 'Priority: Critical' in suggestions
|
||||
assert 'Effort: XS' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_bug():
|
||||
"""Test label suggestion for bug context"""
|
||||
labels = [
|
||||
'Type/Bug', 'Type/Feature',
|
||||
'Priority/Critical', 'Priority/High', 'Priority/Medium', 'Priority/Low',
|
||||
'Complexity/Simple', 'Complexity/Medium', 'Complexity/Complex',
|
||||
'Component/Auth'
|
||||
]
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
context = "Fix critical bug in login authentication"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
|
||||
assert 'Type/Bug' in suggestions
|
||||
assert 'Priority/Critical' in suggestions
|
||||
assert 'Component/Auth' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_feature():
|
||||
"""Test label suggestion for feature context"""
|
||||
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium']
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
context = "Add new feature to implement user dashboard"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
|
||||
assert 'Type/Feature' in suggestions
|
||||
assert any('Priority' in label for label in suggestions)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_refactor():
|
||||
"""Test label suggestion for refactor context"""
|
||||
labels = ['Type/Refactor', 'Priority/Medium', 'Complexity/Medium', 'Component/Backend']
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
context = "Refactor architecture to extract service layer"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
|
||||
assert 'Type/Refactor' in suggestions
|
||||
assert 'Component/Backend' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_documentation():
|
||||
"""Test label suggestion for documentation context"""
|
||||
labels = ['Type/Documentation', 'Priority/Medium', 'Complexity/Medium', 'Component/API', 'Component/Docs']
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
context = "Update documentation for API endpoints"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
|
||||
assert 'Type/Documentation' in suggestions
|
||||
assert 'Component/API' in suggestions or 'Component/Docs' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_priority():
|
||||
"""Test priority detection in suggestions"""
|
||||
labels = ['Type/Feature', 'Priority/Critical', 'Priority/High', 'Priority/Medium', 'Priority/Low', 'Complexity/Medium']
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
# Critical priority
|
||||
context = "Urgent blocker in production"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Priority/Critical' in suggestions
|
||||
|
||||
# High priority
|
||||
context = "Important feature needed asap"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Priority/High' in suggestions
|
||||
|
||||
# Low priority
|
||||
context = "Nice-to-have optional improvement"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Priority/Low' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_complexity():
|
||||
"""Test complexity detection in suggestions"""
|
||||
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Simple', 'Complexity/Medium', 'Complexity/Complex']
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
# Simple complexity
|
||||
context = "Simple quick fix for typo"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Complexity/Simple' in suggestions
|
||||
|
||||
# Complex complexity
|
||||
context = "Complex challenging architecture redesign"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Complexity/Complex' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_efforts():
|
||||
"""Test efforts detection in suggestions"""
|
||||
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Efforts/XS', 'Efforts/S', 'Efforts/M', 'Efforts/L', 'Efforts/XL']
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
# XS effort
|
||||
context = "Tiny fix that takes 1 hour"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Efforts/XS' in suggestions
|
||||
|
||||
# L effort
|
||||
context = "Large feature taking 1 week"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Efforts/L' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_components():
|
||||
"""Test component detection in suggestions"""
|
||||
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Component/Backend', 'Component/Frontend', 'Component/API', 'Component/Database']
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
# Backend component
|
||||
context = "Update backend API service"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Component/Backend' in suggestions
|
||||
assert 'Component/API' in suggestions
|
||||
|
||||
# Frontend component
|
||||
context = "Fix frontend UI component"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Component/Frontend' in suggestions
|
||||
|
||||
# Database component
|
||||
context = "Add database migration for schema"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Component/Database' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_tech_stack():
|
||||
"""Test tech stack detection in suggestions"""
|
||||
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Tech/Python', 'Tech/FastAPI', 'Tech/Docker', 'Tech/PostgreSQL']
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
# Python
|
||||
context = "Update Python FastAPI endpoint"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Tech/Python' in suggestions
|
||||
assert 'Tech/FastAPI' in suggestions
|
||||
|
||||
# Docker
|
||||
context = "Fix Dockerfile configuration"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Tech/Docker' in suggestions
|
||||
|
||||
# PostgreSQL
|
||||
context = "Optimize PostgreSQL query"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Tech/PostgreSQL' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_source():
|
||||
"""Test source detection in suggestions"""
|
||||
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Source/Development', 'Source/Staging', 'Source/Production']
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
# Development
|
||||
context = "Issue found in development environment"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Source/Development' in suggestions
|
||||
|
||||
# Production
|
||||
context = "Critical production issue"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Source/Production' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_risk():
|
||||
"""Test risk detection in suggestions"""
|
||||
labels = ['Type/Feature', 'Priority/Medium', 'Complexity/Medium', 'Risk/High', 'Risk/Low']
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
# High risk
|
||||
context = "Breaking change to major API"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Risk/High' in suggestions
|
||||
|
||||
# Low risk
|
||||
context = "Safe minor update with low risk"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
assert 'Risk/Low' in suggestions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_multiple_categories():
|
||||
"""Test that suggestions span multiple categories"""
|
||||
labels = [
|
||||
'Type/Bug', 'Type/Feature',
|
||||
'Priority/Critical', 'Priority/Medium',
|
||||
'Complexity/Complex', 'Complexity/Medium',
|
||||
'Component/Backend', 'Component/API', 'Component/Auth',
|
||||
'Tech/FastAPI', 'Tech/PostgreSQL',
|
||||
'Source/Production'
|
||||
]
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
context = """
|
||||
Urgent critical bug in production backend API service.
|
||||
Need to fix broken authentication endpoint.
|
||||
This is a complex issue requiring FastAPI and PostgreSQL expertise.
|
||||
"""
|
||||
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
|
||||
# Should have Type
|
||||
assert any('Type/' in label for label in suggestions)
|
||||
|
||||
# Should have Priority
|
||||
assert any('Priority/' in label for label in suggestions)
|
||||
|
||||
# Should have Component
|
||||
assert any('Component/' in label for label in suggestions)
|
||||
|
||||
# Should have Tech
|
||||
assert any('Tech/' in label for label in suggestions)
|
||||
|
||||
# Should have Source
|
||||
assert any('Source/' in label for label in suggestions)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_empty_repo():
|
||||
"""Test suggestions when no repo specified and no labels available"""
|
||||
mock_client = Mock()
|
||||
mock_client.repo = None
|
||||
tools = LabelTools(mock_client)
|
||||
|
||||
context = "Fix a bug"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
|
||||
# Should return empty list when no repo
|
||||
assert suggestions == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_labels_no_matching_labels():
|
||||
"""Test suggestions return empty when no matching labels exist"""
|
||||
labels = ['Custom/Label', 'Other/Thing'] # No standard labels
|
||||
tools = _create_tools_with_labels(labels)
|
||||
|
||||
context = "Fix a bug"
|
||||
suggestions = await tools.suggest_labels(context)
|
||||
|
||||
# Should return empty list since no Type/Bug or similar exists
|
||||
assert len(suggestions) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_labels_org_owned_repo():
|
||||
"""Test getting labels for organization-owned repository"""
|
||||
mock_client = Mock()
|
||||
mock_client.repo = 'myorg/myrepo'
|
||||
mock_client.is_org_repo = Mock(return_value=True)
|
||||
mock_client.get_org_labels = Mock(return_value=[
|
||||
{'name': 'Type/Bug', 'id': 1},
|
||||
{'name': 'Type/Feature', 'id': 2}
|
||||
])
|
||||
mock_client.get_labels = Mock(return_value=[
|
||||
{'name': 'Component/Backend', 'id': 3}
|
||||
])
|
||||
|
||||
tools = LabelTools(mock_client)
|
||||
result = await tools.get_labels()
|
||||
|
||||
# Should fetch both org and repo labels
|
||||
mock_client.is_org_repo.assert_called_once_with('myorg/myrepo')
|
||||
mock_client.get_org_labels.assert_called_once_with('myorg')
|
||||
mock_client.get_labels.assert_called_once_with('myorg/myrepo')
|
||||
|
||||
assert len(result['organization']) == 2
|
||||
assert len(result['repository']) == 1
|
||||
assert result['total_count'] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_labels_user_owned_repo():
|
||||
"""Test getting labels for user-owned repository (no org labels)"""
|
||||
mock_client = Mock()
|
||||
mock_client.repo = 'lmiranda/personal-portfolio'
|
||||
mock_client.is_org_repo = Mock(return_value=False)
|
||||
mock_client.get_labels = Mock(return_value=[
|
||||
{'name': 'bug', 'id': 1},
|
||||
{'name': 'enhancement', 'id': 2}
|
||||
])
|
||||
|
||||
tools = LabelTools(mock_client)
|
||||
result = await tools.get_labels()
|
||||
|
||||
# Should check if org repo
|
||||
mock_client.is_org_repo.assert_called_once_with('lmiranda/personal-portfolio')
|
||||
|
||||
# Should NOT call get_org_labels for user-owned repos
|
||||
mock_client.get_org_labels.assert_not_called()
|
||||
|
||||
# Should still get repo labels
|
||||
mock_client.get_labels.assert_called_once_with('lmiranda/personal-portfolio')
|
||||
|
||||
assert len(result['organization']) == 0
|
||||
assert len(result['repository']) == 2
|
||||
assert result['total_count'] == 2
|
||||
@@ -294,4 +294,4 @@ logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Part of the Claude Code Marketplace (`support-claude-mktplace`).
|
||||
MIT License - Part of the Leo Claude Marketplace.
|
||||
@@ -4,6 +4,7 @@ NetBox API client for interacting with NetBox REST API.
|
||||
Provides a generic HTTP client with methods for all standard REST operations.
|
||||
Individual tool modules use this client for their specific endpoints.
|
||||
"""
|
||||
import json
|
||||
import requests
|
||||
import logging
|
||||
from typing import List, Dict, Optional, Any, Union
|
||||
@@ -83,7 +84,20 @@ class NetBoxClient:
|
||||
if response.status_code == 204 or not response.content:
|
||||
return None
|
||||
|
||||
# Parse JSON with diagnostic error handling
|
||||
try:
|
||||
return response.json()
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(
|
||||
f"JSON decode failed. Status: {response.status_code}, "
|
||||
f"Content-Length: {len(response.content)}, "
|
||||
f"Content preview: {response.content[:200]!r}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Invalid JSON response from NetBox: {e}. "
|
||||
f"Status code: {response.status_code}, "
|
||||
f"Content length: {len(response.content)} bytes"
|
||||
) from e
|
||||
|
||||
def list(
|
||||
self,
|
||||
@@ -103,7 +103,19 @@ TOOL_DEFINITIONS = {
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Site ID'},
|
||||
'name': {'type': 'string', 'description': 'New name'},
|
||||
'status': {'type': 'string', 'description': 'New status'}
|
||||
'slug': {'type': 'string', 'description': 'New slug'},
|
||||
'status': {'type': 'string', 'description': 'Status'},
|
||||
'region': {'type': 'integer', 'description': 'Region ID'},
|
||||
'group': {'type': 'integer', 'description': 'Site group ID'},
|
||||
'tenant': {'type': 'integer', 'description': 'Tenant ID'},
|
||||
'facility': {'type': 'string', 'description': 'Facility name'},
|
||||
'time_zone': {'type': 'string', 'description': 'Time zone'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'physical_address': {'type': 'string', 'description': 'Physical address'},
|
||||
'shipping_address': {'type': 'string', 'description': 'Shipping address'},
|
||||
'latitude': {'type': 'number', 'description': 'Latitude'},
|
||||
'longitude': {'type': 'number', 'description': 'Longitude'},
|
||||
'comments': {'type': 'string', 'description': 'Comments'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
@@ -136,7 +148,14 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'dcim_update_location': {
|
||||
'description': 'Update an existing location',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Location ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Location ID'},
|
||||
'name': {'type': 'string', 'description': 'New name'},
|
||||
'slug': {'type': 'string', 'description': 'New slug'},
|
||||
'site': {'type': 'integer', 'description': 'Site ID'},
|
||||
'parent': {'type': 'integer', 'description': 'Parent location ID'},
|
||||
'description': {'type': 'string', 'description': 'Description'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'dcim_delete_location': {
|
||||
@@ -171,7 +190,18 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'dcim_update_rack': {
|
||||
'description': 'Update an existing rack',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Rack ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Rack ID'},
|
||||
'name': {'type': 'string', 'description': 'New name'},
|
||||
'site': {'type': 'integer', 'description': 'Site ID'},
|
||||
'location': {'type': 'integer', 'description': 'Location ID'},
|
||||
'status': {'type': 'string', 'description': 'Status'},
|
||||
'role': {'type': 'integer', 'description': 'Role ID'},
|
||||
'tenant': {'type': 'integer', 'description': 'Tenant ID'},
|
||||
'u_height': {'type': 'integer', 'description': 'Rack height in U'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'comments': {'type': 'string', 'description': 'Comments'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'dcim_delete_rack': {
|
||||
@@ -198,7 +228,12 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'dcim_update_manufacturer': {
|
||||
'description': 'Update an existing manufacturer',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Manufacturer ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Manufacturer ID'},
|
||||
'name': {'type': 'string', 'description': 'New name'},
|
||||
'slug': {'type': 'string', 'description': 'New slug'},
|
||||
'description': {'type': 'string', 'description': 'Description'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'dcim_delete_manufacturer': {
|
||||
@@ -230,7 +265,16 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'dcim_update_device_type': {
|
||||
'description': 'Update an existing device type',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Device type ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Device type ID'},
|
||||
'manufacturer': {'type': 'integer', 'description': 'Manufacturer ID'},
|
||||
'model': {'type': 'string', 'description': 'Model name'},
|
||||
'slug': {'type': 'string', 'description': 'New slug'},
|
||||
'u_height': {'type': 'number', 'description': 'Height in rack units'},
|
||||
'is_full_depth': {'type': 'boolean', 'description': 'Is full depth'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'comments': {'type': 'string', 'description': 'Comments'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'dcim_delete_device_type': {
|
||||
@@ -259,7 +303,14 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'dcim_update_device_role': {
|
||||
'description': 'Update an existing device role',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Device role ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Device role ID'},
|
||||
'name': {'type': 'string', 'description': 'New name'},
|
||||
'slug': {'type': 'string', 'description': 'New slug'},
|
||||
'color': {'type': 'string', 'description': 'Hex color code'},
|
||||
'vm_role': {'type': 'boolean', 'description': 'Can be assigned to VMs'},
|
||||
'description': {'type': 'string', 'description': 'Description'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'dcim_delete_device_role': {
|
||||
@@ -290,7 +341,13 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'dcim_update_platform': {
|
||||
'description': 'Update an existing platform',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Platform ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Platform ID'},
|
||||
'name': {'type': 'string', 'description': 'New name'},
|
||||
'slug': {'type': 'string', 'description': 'New slug'},
|
||||
'manufacturer': {'type': 'integer', 'description': 'Manufacturer ID'},
|
||||
'description': {'type': 'string', 'description': 'Description'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'dcim_delete_platform': {
|
||||
@@ -326,7 +383,13 @@ TOOL_DEFINITIONS = {
|
||||
'status': {'type': 'string', 'description': 'Device status'},
|
||||
'rack': {'type': 'integer', 'description': 'Rack ID'},
|
||||
'position': {'type': 'number', 'description': 'Position in rack'},
|
||||
'serial': {'type': 'string', 'description': 'Serial number'}
|
||||
'serial': {'type': 'string', 'description': 'Serial number'},
|
||||
'platform': {'type': 'integer', 'description': 'Platform ID'},
|
||||
'primary_ip4': {'type': 'integer', 'description': 'Primary IPv4 address ID'},
|
||||
'primary_ip6': {'type': 'integer', 'description': 'Primary IPv6 address ID'},
|
||||
'asset_tag': {'type': 'string', 'description': 'Asset tag'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'comments': {'type': 'string', 'description': 'Comments'}
|
||||
},
|
||||
'required': ['name', 'device_type', 'role', 'site']
|
||||
},
|
||||
@@ -335,7 +398,17 @@ TOOL_DEFINITIONS = {
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Device ID'},
|
||||
'name': {'type': 'string', 'description': 'New name'},
|
||||
'status': {'type': 'string', 'description': 'New status'}
|
||||
'status': {'type': 'string', 'description': 'New status'},
|
||||
'platform': {'type': 'integer', 'description': 'Platform ID'},
|
||||
'primary_ip4': {'type': 'integer', 'description': 'Primary IPv4 address ID'},
|
||||
'primary_ip6': {'type': 'integer', 'description': 'Primary IPv6 address ID'},
|
||||
'serial': {'type': 'string', 'description': 'Serial number'},
|
||||
'asset_tag': {'type': 'string', 'description': 'Asset tag'},
|
||||
'site': {'type': 'integer', 'description': 'Site ID'},
|
||||
'rack': {'type': 'integer', 'description': 'Rack ID'},
|
||||
'position': {'type': 'number', 'description': 'Position in rack'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'comments': {'type': 'string', 'description': 'Comments'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
@@ -370,7 +443,18 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'dcim_update_interface': {
|
||||
'description': 'Update an existing interface',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Interface ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Interface ID'},
|
||||
'name': {'type': 'string', 'description': 'New name'},
|
||||
'type': {'type': 'string', 'description': 'Interface type'},
|
||||
'enabled': {'type': 'boolean', 'description': 'Interface enabled'},
|
||||
'mtu': {'type': 'integer', 'description': 'MTU'},
|
||||
'mac_address': {'type': 'string', 'description': 'MAC address'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'mode': {'type': 'string', 'description': 'VLAN mode'},
|
||||
'untagged_vlan': {'type': 'integer', 'description': 'Untagged VLAN ID'},
|
||||
'tagged_vlans': {'type': 'array', 'description': 'Tagged VLAN IDs'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'dcim_delete_interface': {
|
||||
@@ -404,7 +488,15 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'dcim_update_cable': {
|
||||
'description': 'Update an existing cable',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Cable ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Cable ID'},
|
||||
'type': {'type': 'string', 'description': 'Cable type'},
|
||||
'status': {'type': 'string', 'description': 'Cable status'},
|
||||
'label': {'type': 'string', 'description': 'Cable label'},
|
||||
'color': {'type': 'string', 'description': 'Cable color'},
|
||||
'length': {'type': 'number', 'description': 'Cable length'},
|
||||
'length_unit': {'type': 'string', 'description': 'Length unit'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'dcim_delete_cable': {
|
||||
@@ -492,7 +584,15 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'ipam_update_vrf': {
|
||||
'description': 'Update an existing VRF',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'VRF ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'VRF ID'},
|
||||
'name': {'type': 'string', 'description': 'New name'},
|
||||
'rd': {'type': 'string', 'description': 'Route distinguisher'},
|
||||
'tenant': {'type': 'integer', 'description': 'Tenant ID'},
|
||||
'enforce_unique': {'type': 'boolean', 'description': 'Enforce unique IPs'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'comments': {'type': 'string', 'description': 'Comments'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'ipam_delete_vrf': {
|
||||
@@ -531,7 +631,19 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'ipam_update_prefix': {
|
||||
'description': 'Update an existing prefix',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Prefix ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Prefix ID'},
|
||||
'prefix': {'type': 'string', 'description': 'Prefix in CIDR notation'},
|
||||
'status': {'type': 'string', 'description': 'Status'},
|
||||
'site': {'type': 'integer', 'description': 'Site ID'},
|
||||
'vrf': {'type': 'integer', 'description': 'VRF ID'},
|
||||
'vlan': {'type': 'integer', 'description': 'VLAN ID'},
|
||||
'role': {'type': 'integer', 'description': 'Role ID'},
|
||||
'tenant': {'type': 'integer', 'description': 'Tenant ID'},
|
||||
'is_pool': {'type': 'boolean', 'description': 'Is a pool'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'comments': {'type': 'string', 'description': 'Comments'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'ipam_delete_prefix': {
|
||||
@@ -582,7 +694,18 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'ipam_update_ip_address': {
|
||||
'description': 'Update an existing IP address',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'IP address ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'IP address ID'},
|
||||
'address': {'type': 'string', 'description': 'IP address with prefix length'},
|
||||
'status': {'type': 'string', 'description': 'Status'},
|
||||
'vrf': {'type': 'integer', 'description': 'VRF ID'},
|
||||
'tenant': {'type': 'integer', 'description': 'Tenant ID'},
|
||||
'dns_name': {'type': 'string', 'description': 'DNS name'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'comments': {'type': 'string', 'description': 'Comments'},
|
||||
'assigned_object_type': {'type': 'string', 'description': 'Object type to assign to'},
|
||||
'assigned_object_id': {'type': 'integer', 'description': 'Object ID to assign to'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'ipam_delete_ip_address': {
|
||||
@@ -647,7 +770,18 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'ipam_update_vlan': {
|
||||
'description': 'Update an existing VLAN',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'VLAN ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'VLAN ID'},
|
||||
'vid': {'type': 'integer', 'description': 'VLAN ID number'},
|
||||
'name': {'type': 'string', 'description': 'VLAN name'},
|
||||
'status': {'type': 'string', 'description': 'Status'},
|
||||
'site': {'type': 'integer', 'description': 'Site ID'},
|
||||
'group': {'type': 'integer', 'description': 'VLAN group ID'},
|
||||
'tenant': {'type': 'integer', 'description': 'Tenant ID'},
|
||||
'role': {'type': 'integer', 'description': 'Role ID'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'comments': {'type': 'string', 'description': 'Comments'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'ipam_delete_vlan': {
|
||||
@@ -757,16 +891,17 @@ TOOL_DEFINITIONS = {
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Provider ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'circuits_list_circuit_types': {
|
||||
# NOTE: circuit_types tools shortened to meet 28-char limit
|
||||
'circ_list_types': {
|
||||
'description': 'List all circuit types in NetBox',
|
||||
'properties': {'name': {'type': 'string', 'description': 'Filter by name'}}
|
||||
},
|
||||
'circuits_get_circuit_type': {
|
||||
'circ_get_type': {
|
||||
'description': 'Get a specific circuit type by ID',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Circuit type ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'circuits_create_circuit_type': {
|
||||
'circ_create_type': {
|
||||
'description': 'Create a new circuit type',
|
||||
'properties': {
|
||||
'name': {'type': 'string', 'description': 'Type name'},
|
||||
@@ -809,19 +944,20 @@ TOOL_DEFINITIONS = {
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Circuit ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'circuits_list_circuit_terminations': {
|
||||
# NOTE: circuit_terminations tools shortened to meet 28-char limit
|
||||
'circ_list_terminations': {
|
||||
'description': 'List all circuit terminations in NetBox',
|
||||
'properties': {
|
||||
'circuit_id': {'type': 'integer', 'description': 'Filter by circuit ID'},
|
||||
'site_id': {'type': 'integer', 'description': 'Filter by site ID'}
|
||||
}
|
||||
},
|
||||
'circuits_get_circuit_termination': {
|
||||
'circ_get_termination': {
|
||||
'description': 'Get a specific circuit termination by ID',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Termination ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'circuits_create_circuit_termination': {
|
||||
'circ_create_termination': {
|
||||
'description': 'Create a new circuit termination',
|
||||
'properties': {
|
||||
'circuit': {'type': 'integer', 'description': 'Circuit ID'},
|
||||
@@ -832,16 +968,18 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
|
||||
# ==================== Virtualization Tools ====================
|
||||
'virtualization_list_cluster_types': {
|
||||
# NOTE: Tool names shortened from 'virtualization_' to 'virt_' to meet
|
||||
# 28-char limit (Claude API 64-char limit minus 36-char prefix)
|
||||
'virt_list_cluster_types': {
|
||||
'description': 'List all cluster types in NetBox',
|
||||
'properties': {'name': {'type': 'string', 'description': 'Filter by name'}}
|
||||
},
|
||||
'virtualization_get_cluster_type': {
|
||||
'virt_get_cluster_type': {
|
||||
'description': 'Get a specific cluster type by ID',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Cluster type ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'virtualization_create_cluster_type': {
|
||||
'virt_create_cluster_type': {
|
||||
'description': 'Create a new cluster type',
|
||||
'properties': {
|
||||
'name': {'type': 'string', 'description': 'Type name'},
|
||||
@@ -849,16 +987,16 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'required': ['name', 'slug']
|
||||
},
|
||||
'virtualization_list_cluster_groups': {
|
||||
'virt_list_cluster_groups': {
|
||||
'description': 'List all cluster groups in NetBox',
|
||||
'properties': {'name': {'type': 'string', 'description': 'Filter by name'}}
|
||||
},
|
||||
'virtualization_get_cluster_group': {
|
||||
'virt_get_cluster_group': {
|
||||
'description': 'Get a specific cluster group by ID',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Cluster group ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'virtualization_create_cluster_group': {
|
||||
'virt_create_cluster_group': {
|
||||
'description': 'Create a new cluster group',
|
||||
'properties': {
|
||||
'name': {'type': 'string', 'description': 'Group name'},
|
||||
@@ -866,7 +1004,7 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'required': ['name', 'slug']
|
||||
},
|
||||
'virtualization_list_clusters': {
|
||||
'virt_list_clusters': {
|
||||
'description': 'List all clusters in NetBox',
|
||||
'properties': {
|
||||
'name': {'type': 'string', 'description': 'Filter by name'},
|
||||
@@ -875,12 +1013,12 @@ TOOL_DEFINITIONS = {
|
||||
'site_id': {'type': 'integer', 'description': 'Filter by site ID'}
|
||||
}
|
||||
},
|
||||
'virtualization_get_cluster': {
|
||||
'virt_get_cluster': {
|
||||
'description': 'Get a specific cluster by ID',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Cluster ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'virtualization_create_cluster': {
|
||||
'virt_create_cluster': {
|
||||
'description': 'Create a new cluster',
|
||||
'properties': {
|
||||
'name': {'type': 'string', 'description': 'Cluster name'},
|
||||
@@ -891,17 +1029,27 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'required': ['name', 'type']
|
||||
},
|
||||
'virtualization_update_cluster': {
|
||||
'virt_update_cluster': {
|
||||
'description': 'Update an existing cluster',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Cluster ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'Cluster ID'},
|
||||
'name': {'type': 'string', 'description': 'New name'},
|
||||
'type': {'type': 'integer', 'description': 'Cluster type ID'},
|
||||
'group': {'type': 'integer', 'description': 'Cluster group ID'},
|
||||
'site': {'type': 'integer', 'description': 'Site ID'},
|
||||
'status': {'type': 'string', 'description': 'Status'},
|
||||
'tenant': {'type': 'integer', 'description': 'Tenant ID'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'comments': {'type': 'string', 'description': 'Comments'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'virtualization_delete_cluster': {
|
||||
'virt_delete_cluster': {
|
||||
'description': 'Delete a cluster',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Cluster ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'virtualization_list_virtual_machines': {
|
||||
'virt_list_vms': {
|
||||
'description': 'List all virtual machines in NetBox',
|
||||
'properties': {
|
||||
'name': {'type': 'string', 'description': 'Filter by name'},
|
||||
@@ -910,12 +1058,12 @@ TOOL_DEFINITIONS = {
|
||||
'status': {'type': 'string', 'description': 'Filter by status'}
|
||||
}
|
||||
},
|
||||
'virtualization_get_virtual_machine': {
|
||||
'virt_get_vm': {
|
||||
'description': 'Get a specific virtual machine by ID',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'VM ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'virtualization_create_virtual_machine': {
|
||||
'virt_create_vm': {
|
||||
'description': 'Create a new virtual machine',
|
||||
'properties': {
|
||||
'name': {'type': 'string', 'description': 'VM name'},
|
||||
@@ -928,29 +1076,45 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'required': ['name']
|
||||
},
|
||||
'virtualization_update_virtual_machine': {
|
||||
'virt_update_vm': {
|
||||
'description': 'Update an existing virtual machine',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'VM ID'}},
|
||||
'properties': {
|
||||
'id': {'type': 'integer', 'description': 'VM ID'},
|
||||
'name': {'type': 'string', 'description': 'New name'},
|
||||
'status': {'type': 'string', 'description': 'Status'},
|
||||
'cluster': {'type': 'integer', 'description': 'Cluster ID'},
|
||||
'site': {'type': 'integer', 'description': 'Site ID'},
|
||||
'role': {'type': 'integer', 'description': 'Role ID'},
|
||||
'tenant': {'type': 'integer', 'description': 'Tenant ID'},
|
||||
'platform': {'type': 'integer', 'description': 'Platform ID'},
|
||||
'vcpus': {'type': 'number', 'description': 'Number of vCPUs'},
|
||||
'memory': {'type': 'integer', 'description': 'Memory in MB'},
|
||||
'disk': {'type': 'integer', 'description': 'Disk in GB'},
|
||||
'primary_ip4': {'type': 'integer', 'description': 'Primary IPv4 address ID'},
|
||||
'primary_ip6': {'type': 'integer', 'description': 'Primary IPv6 address ID'},
|
||||
'description': {'type': 'string', 'description': 'Description'},
|
||||
'comments': {'type': 'string', 'description': 'Comments'}
|
||||
},
|
||||
'required': ['id']
|
||||
},
|
||||
'virtualization_delete_virtual_machine': {
|
||||
'virt_delete_vm': {
|
||||
'description': 'Delete a virtual machine',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'VM ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'virtualization_list_vm_interfaces': {
|
||||
'virt_list_vm_ifaces': {
|
||||
'description': 'List all VM interfaces in NetBox',
|
||||
'properties': {
|
||||
'virtual_machine_id': {'type': 'integer', 'description': 'Filter by VM ID'},
|
||||
'name': {'type': 'string', 'description': 'Filter by name'}
|
||||
}
|
||||
},
|
||||
'virtualization_get_vm_interface': {
|
||||
'virt_get_vm_iface': {
|
||||
'description': 'Get a specific VM interface by ID',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Interface ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'virtualization_create_vm_interface': {
|
||||
'virt_create_vm_iface': {
|
||||
'description': 'Create a new VM interface',
|
||||
'properties': {
|
||||
'virtual_machine': {'type': 'integer', 'description': 'VM ID'},
|
||||
@@ -1088,16 +1252,18 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
|
||||
# ==================== Wireless Tools ====================
|
||||
'wireless_list_wireless_lan_groups': {
|
||||
# NOTE: Tool names shortened from 'wireless_' to 'wlan_' to meet
|
||||
# 28-char limit (Claude API 64-char limit minus 36-char prefix)
|
||||
'wlan_list_groups': {
|
||||
'description': 'List all wireless LAN groups in NetBox',
|
||||
'properties': {'name': {'type': 'string', 'description': 'Filter by name'}}
|
||||
},
|
||||
'wireless_get_wireless_lan_group': {
|
||||
'wlan_get_group': {
|
||||
'description': 'Get a specific wireless LAN group by ID',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'WLAN group ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'wireless_create_wireless_lan_group': {
|
||||
'wlan_create_group': {
|
||||
'description': 'Create a new wireless LAN group',
|
||||
'properties': {
|
||||
'name': {'type': 'string', 'description': 'Group name'},
|
||||
@@ -1105,7 +1271,7 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'required': ['name', 'slug']
|
||||
},
|
||||
'wireless_list_wireless_lans': {
|
||||
'wlan_list_lans': {
|
||||
'description': 'List all wireless LANs in NetBox',
|
||||
'properties': {
|
||||
'ssid': {'type': 'string', 'description': 'Filter by SSID'},
|
||||
@@ -1113,12 +1279,12 @@ TOOL_DEFINITIONS = {
|
||||
'status': {'type': 'string', 'description': 'Filter by status'}
|
||||
}
|
||||
},
|
||||
'wireless_get_wireless_lan': {
|
||||
'wlan_get_lan': {
|
||||
'description': 'Get a specific wireless LAN by ID',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'WLAN ID'}},
|
||||
'required': ['id']
|
||||
},
|
||||
'wireless_create_wireless_lan': {
|
||||
'wlan_create_lan': {
|
||||
'description': 'Create a new wireless LAN',
|
||||
'properties': {
|
||||
'ssid': {'type': 'string', 'description': 'SSID'},
|
||||
@@ -1128,14 +1294,14 @@ TOOL_DEFINITIONS = {
|
||||
},
|
||||
'required': ['ssid']
|
||||
},
|
||||
'wireless_list_wireless_links': {
|
||||
'wlan_list_links': {
|
||||
'description': 'List all wireless links in NetBox',
|
||||
'properties': {
|
||||
'ssid': {'type': 'string', 'description': 'Filter by SSID'},
|
||||
'status': {'type': 'string', 'description': 'Filter by status'}
|
||||
}
|
||||
},
|
||||
'wireless_get_wireless_link': {
|
||||
'wlan_get_link': {
|
||||
'description': 'Get a specific wireless link by ID',
|
||||
'properties': {'id': {'type': 'integer', 'description': 'Link ID'}},
|
||||
'required': ['id']
|
||||
@@ -1241,6 +1407,52 @@ TOOL_DEFINITIONS = {
|
||||
}
|
||||
|
||||
|
||||
# Map shortened tool names to (category, method_name) for routing.
|
||||
# This is necessary because tool names were shortened to meet the 28-character
|
||||
# limit imposed by Claude API's 64-character tool name limit minus the
|
||||
# 36-character prefix used by Claude Code for MCP tools.
|
||||
TOOL_NAME_MAP = {
|
||||
# Virtualization tools (virt_ -> virtualization category)
|
||||
'virt_list_cluster_types': ('virtualization', 'list_cluster_types'),
|
||||
'virt_get_cluster_type': ('virtualization', 'get_cluster_type'),
|
||||
'virt_create_cluster_type': ('virtualization', 'create_cluster_type'),
|
||||
'virt_list_cluster_groups': ('virtualization', 'list_cluster_groups'),
|
||||
'virt_get_cluster_group': ('virtualization', 'get_cluster_group'),
|
||||
'virt_create_cluster_group': ('virtualization', 'create_cluster_group'),
|
||||
'virt_list_clusters': ('virtualization', 'list_clusters'),
|
||||
'virt_get_cluster': ('virtualization', 'get_cluster'),
|
||||
'virt_create_cluster': ('virtualization', 'create_cluster'),
|
||||
'virt_update_cluster': ('virtualization', 'update_cluster'),
|
||||
'virt_delete_cluster': ('virtualization', 'delete_cluster'),
|
||||
'virt_list_vms': ('virtualization', 'list_virtual_machines'),
|
||||
'virt_get_vm': ('virtualization', 'get_virtual_machine'),
|
||||
'virt_create_vm': ('virtualization', 'create_virtual_machine'),
|
||||
'virt_update_vm': ('virtualization', 'update_virtual_machine'),
|
||||
'virt_delete_vm': ('virtualization', 'delete_virtual_machine'),
|
||||
'virt_list_vm_ifaces': ('virtualization', 'list_vm_interfaces'),
|
||||
'virt_get_vm_iface': ('virtualization', 'get_vm_interface'),
|
||||
'virt_create_vm_iface': ('virtualization', 'create_vm_interface'),
|
||||
|
||||
# Circuits tools (circ_ -> circuits category, for shortened names only)
|
||||
'circ_list_types': ('circuits', 'list_circuit_types'),
|
||||
'circ_get_type': ('circuits', 'get_circuit_type'),
|
||||
'circ_create_type': ('circuits', 'create_circuit_type'),
|
||||
'circ_list_terminations': ('circuits', 'list_circuit_terminations'),
|
||||
'circ_get_termination': ('circuits', 'get_circuit_termination'),
|
||||
'circ_create_termination': ('circuits', 'create_circuit_termination'),
|
||||
|
||||
# Wireless tools (wlan_ -> wireless category)
|
||||
'wlan_list_groups': ('wireless', 'list_wireless_lan_groups'),
|
||||
'wlan_get_group': ('wireless', 'get_wireless_lan_group'),
|
||||
'wlan_create_group': ('wireless', 'create_wireless_lan_group'),
|
||||
'wlan_list_lans': ('wireless', 'list_wireless_lans'),
|
||||
'wlan_get_lan': ('wireless', 'get_wireless_lan'),
|
||||
'wlan_create_lan': ('wireless', 'create_wireless_lan'),
|
||||
'wlan_list_links': ('wireless', 'list_wireless_links'),
|
||||
'wlan_get_link': ('wireless', 'get_wireless_link'),
|
||||
}
|
||||
|
||||
|
||||
class NetBoxMCPServer:
|
||||
"""MCP Server for NetBox integration"""
|
||||
|
||||
@@ -1314,11 +1526,20 @@ class NetBoxMCPServer:
|
||||
)]
|
||||
|
||||
async def _route_tool(self, name: str, arguments: dict):
|
||||
"""Route tool call to appropriate handler."""
|
||||
"""Route tool call to appropriate handler.
|
||||
|
||||
Tool names may be shortened (e.g., 'virt_list_vms' instead of
|
||||
'virtualization_list_virtual_machines') to meet the 28-character
|
||||
limit. TOOL_NAME_MAP handles the translation to actual method names.
|
||||
"""
|
||||
# Check if this is a mapped short name
|
||||
if name in TOOL_NAME_MAP:
|
||||
category, method_name = TOOL_NAME_MAP[name]
|
||||
else:
|
||||
# Fall back to original logic for unchanged tools
|
||||
parts = name.split('_', 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Invalid tool name format: {name}")
|
||||
|
||||
category, method_name = parts[0], parts[1]
|
||||
|
||||
# Map category to tool class
|
||||
21
mcp-servers/netbox/run.sh
Executable file
21
mcp-servers/netbox/run.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Capture original working directory before any cd operations
|
||||
# This should be the user's project directory when launched by Claude Code
|
||||
export CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CACHE_VENV="$HOME/.cache/claude-mcp-venvs/leo-claude-mktplace/netbox/.venv"
|
||||
LOCAL_VENV="$SCRIPT_DIR/.venv"
|
||||
|
||||
if [[ -f "$CACHE_VENV/bin/python" ]]; then
|
||||
PYTHON="$CACHE_VENV/bin/python"
|
||||
elif [[ -f "$LOCAL_VENV/bin/python" ]]; then
|
||||
PYTHON="$LOCAL_VENV/bin/python"
|
||||
else
|
||||
echo "ERROR: No venv found. Run: ./scripts/setup-venvs.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
export PYTHONPATH="$SCRIPT_DIR"
|
||||
exec "$PYTHON" -m mcp_server.server "$@"
|
||||
115
mcp-servers/viz-platform/README.md
Normal file
115
mcp-servers/viz-platform/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# viz-platform MCP Server
|
||||
|
||||
Model Context Protocol (MCP) server for Dash Mantine Components validation and visualization tools.
|
||||
|
||||
## Overview
|
||||
|
||||
This MCP server provides 21 tools for:
|
||||
- **DMC Validation**: Version-locked component registry prevents Claude from hallucinating invalid props
|
||||
- **Chart Creation**: Plotly-based visualization with theme integration
|
||||
- **Layout Composition**: Dashboard layouts with responsive grids
|
||||
- **Theme Management**: Design token-based theming system
|
||||
- **Page Structure**: Multi-page Dash app generation
|
||||
|
||||
## Tools
|
||||
|
||||
### DMC Tools (3)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_components` | List available DMC components by category |
|
||||
| `get_component_props` | Get valid props, types, and defaults for a component |
|
||||
| `validate_component` | Validate component definition before use |
|
||||
|
||||
### Chart Tools (2)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `chart_create` | Create Plotly chart (line, bar, scatter, pie, histogram, area, heatmap) |
|
||||
| `chart_configure_interaction` | Configure chart interactions (zoom, pan, hover) |
|
||||
|
||||
### Layout Tools (5)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `layout_create` | Create dashboard layout structure |
|
||||
| `layout_add_filter` | Add filter components to layout |
|
||||
| `layout_set_grid` | Configure responsive grid settings |
|
||||
| `layout_get` | Retrieve layout configuration |
|
||||
| `layout_add_section` | Add sections to layout |
|
||||
|
||||
### Theme Tools (6)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `theme_create` | Create new theme with design tokens |
|
||||
| `theme_extend` | Extend existing theme with overrides |
|
||||
| `theme_validate` | Validate theme completeness |
|
||||
| `theme_export_css` | Export theme as CSS custom properties |
|
||||
| `theme_list` | List available themes |
|
||||
| `theme_activate` | Set active theme for visualizations |
|
||||
|
||||
### Page Tools (5)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `page_create` | Create new page structure |
|
||||
| `page_add_navbar` | Add navigation bar to page |
|
||||
| `page_set_auth` | Configure page authentication |
|
||||
| `page_list` | List available pages |
|
||||
| `page_get_app_config` | Get full app configuration |
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `DMC_VERSION` | No | Dash Mantine Components version (auto-detected if installed) |
|
||||
| `VIZ_DEFAULT_THEME` | No | Default theme name |
|
||||
| `CLAUDE_PROJECT_DIR` | No | Project directory for theme storage |
|
||||
|
||||
### Theme Storage
|
||||
|
||||
Themes can be stored at two levels:
|
||||
- **User-level**: `~/.config/claude/themes/`
|
||||
- **Project-level**: `{project}/.viz-platform/themes/`
|
||||
|
||||
Project-level themes take precedence.
|
||||
|
||||
## Component Registry
|
||||
|
||||
The server uses a static JSON registry for DMC component validation:
|
||||
- Pre-generated from DMC source code
|
||||
- Version-tagged (e.g., `dmc_2_5.json`)
|
||||
- Prevents hallucination of non-existent props
|
||||
- Fast, deterministic validation
|
||||
|
||||
Registry files are stored in `registry/` directory.
|
||||
|
||||
## Tests
|
||||
|
||||
94 tests with coverage:
|
||||
- `test_config.py`: 82% coverage
|
||||
- `test_component_registry.py`: 92% coverage
|
||||
- `test_dmc_tools.py`: 88% coverage
|
||||
- `test_chart_tools.py`: 68% coverage
|
||||
- `test_theme_tools.py`: 99% coverage
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
cd mcp-servers/viz-platform
|
||||
source .venv/bin/activate
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Python 3.10+
|
||||
- FastMCP
|
||||
- plotly
|
||||
- dash-mantine-components (optional, for version detection)
|
||||
|
||||
## Usage
|
||||
|
||||
This MCP server is used by the `viz-platform` plugin. See [plugins/viz-platform/README.md](../../plugins/viz-platform/README.md) for usage instructions.
|
||||
7
mcp-servers/viz-platform/mcp_server/__init__.py
Normal file
7
mcp-servers/viz-platform/mcp_server/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
viz-platform MCP Server package.
|
||||
|
||||
Provides Dash Mantine Components validation and visualization tools to Claude Code.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
479
mcp-servers/viz-platform/mcp_server/accessibility_tools.py
Normal file
479
mcp-servers/viz-platform/mcp_server/accessibility_tools.py
Normal file
@@ -0,0 +1,479 @@
|
||||
"""
|
||||
Accessibility validation tools for color blindness and WCAG compliance.
|
||||
|
||||
Provides tools for validating color palettes against color blindness
|
||||
simulations and WCAG contrast requirements.
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Color-blind safe palettes
|
||||
SAFE_PALETTES = {
|
||||
"categorical": {
|
||||
"name": "Paul Tol's Qualitative",
|
||||
"colors": ["#4477AA", "#EE6677", "#228833", "#CCBB44", "#66CCEE", "#AA3377", "#BBBBBB"],
|
||||
"description": "Distinguishable for all types of color blindness"
|
||||
},
|
||||
"ibm": {
|
||||
"name": "IBM Design",
|
||||
"colors": ["#648FFF", "#785EF0", "#DC267F", "#FE6100", "#FFB000"],
|
||||
"description": "IBM's accessible color palette"
|
||||
},
|
||||
"okabe_ito": {
|
||||
"name": "Okabe-Ito",
|
||||
"colors": ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7", "#000000"],
|
||||
"description": "Optimized for all color vision deficiencies"
|
||||
},
|
||||
"tableau_colorblind": {
|
||||
"name": "Tableau Colorblind 10",
|
||||
"colors": ["#006BA4", "#FF800E", "#ABABAB", "#595959", "#5F9ED1",
|
||||
"#C85200", "#898989", "#A2C8EC", "#FFBC79", "#CFCFCF"],
|
||||
"description": "Industry-standard accessible palette"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Simulation matrices for color blindness (LMS color space transformation)
|
||||
# These approximate how colors appear to people with different types of color blindness
|
||||
SIMULATION_MATRICES = {
|
||||
"deuteranopia": {
|
||||
# Green-blind (most common)
|
||||
"severity": "common",
|
||||
"population": "6% males, 0.4% females",
|
||||
"description": "Difficulty distinguishing red from green (green-blind)",
|
||||
"matrix": [
|
||||
[0.625, 0.375, 0.0],
|
||||
[0.700, 0.300, 0.0],
|
||||
[0.0, 0.300, 0.700]
|
||||
]
|
||||
},
|
||||
"protanopia": {
|
||||
# Red-blind
|
||||
"severity": "common",
|
||||
"population": "2.5% males, 0.05% females",
|
||||
"description": "Difficulty distinguishing red from green (red-blind)",
|
||||
"matrix": [
|
||||
[0.567, 0.433, 0.0],
|
||||
[0.558, 0.442, 0.0],
|
||||
[0.0, 0.242, 0.758]
|
||||
]
|
||||
},
|
||||
"tritanopia": {
|
||||
# Blue-blind (rare)
|
||||
"severity": "rare",
|
||||
"population": "0.01% total",
|
||||
"description": "Difficulty distinguishing blue from yellow",
|
||||
"matrix": [
|
||||
[0.950, 0.050, 0.0],
|
||||
[0.0, 0.433, 0.567],
|
||||
[0.0, 0.475, 0.525]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AccessibilityTools:
|
||||
"""
|
||||
Color accessibility validation tools.
|
||||
|
||||
Validates colors for WCAG compliance and color blindness accessibility.
|
||||
"""
|
||||
|
||||
def __init__(self, theme_store=None):
|
||||
"""
|
||||
Initialize accessibility tools.
|
||||
|
||||
Args:
|
||||
theme_store: Optional ThemeStore for theme color extraction
|
||||
"""
|
||||
self.theme_store = theme_store
|
||||
|
||||
def _hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]:
|
||||
"""Convert hex color to RGB tuple."""
|
||||
hex_color = hex_color.lstrip('#')
|
||||
if len(hex_color) == 3:
|
||||
hex_color = ''.join([c * 2 for c in hex_color])
|
||||
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
|
||||
def _rgb_to_hex(self, rgb: Tuple[int, int, int]) -> str:
|
||||
"""Convert RGB tuple to hex color."""
|
||||
return '#{:02x}{:02x}{:02x}'.format(
|
||||
max(0, min(255, int(rgb[0]))),
|
||||
max(0, min(255, int(rgb[1]))),
|
||||
max(0, min(255, int(rgb[2])))
|
||||
)
|
||||
|
||||
def _get_relative_luminance(self, rgb: Tuple[int, int, int]) -> float:
|
||||
"""
|
||||
Calculate relative luminance per WCAG 2.1.
|
||||
|
||||
https://www.w3.org/WAI/GL/wiki/Relative_luminance
|
||||
"""
|
||||
def channel_luminance(value: int) -> float:
|
||||
v = value / 255
|
||||
return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
|
||||
|
||||
r, g, b = rgb
|
||||
return (
|
||||
0.2126 * channel_luminance(r) +
|
||||
0.7152 * channel_luminance(g) +
|
||||
0.0722 * channel_luminance(b)
|
||||
)
|
||||
|
||||
def _get_contrast_ratio(self, color1: str, color2: str) -> float:
|
||||
"""
|
||||
Calculate contrast ratio between two colors per WCAG 2.1.
|
||||
|
||||
Returns ratio between 1:1 and 21:1.
|
||||
"""
|
||||
rgb1 = self._hex_to_rgb(color1)
|
||||
rgb2 = self._hex_to_rgb(color2)
|
||||
|
||||
l1 = self._get_relative_luminance(rgb1)
|
||||
l2 = self._get_relative_luminance(rgb2)
|
||||
|
||||
lighter = max(l1, l2)
|
||||
darker = min(l1, l2)
|
||||
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
def _simulate_color_blindness(
|
||||
self,
|
||||
hex_color: str,
|
||||
deficiency_type: str
|
||||
) -> str:
|
||||
"""
|
||||
Simulate how a color appears with a specific color blindness type.
|
||||
|
||||
Uses linear RGB transformation approximation.
|
||||
"""
|
||||
if deficiency_type not in SIMULATION_MATRICES:
|
||||
return hex_color
|
||||
|
||||
rgb = self._hex_to_rgb(hex_color)
|
||||
matrix = SIMULATION_MATRICES[deficiency_type]["matrix"]
|
||||
|
||||
# Apply transformation matrix
|
||||
r = rgb[0] * matrix[0][0] + rgb[1] * matrix[0][1] + rgb[2] * matrix[0][2]
|
||||
g = rgb[0] * matrix[1][0] + rgb[1] * matrix[1][1] + rgb[2] * matrix[1][2]
|
||||
b = rgb[0] * matrix[2][0] + rgb[1] * matrix[2][1] + rgb[2] * matrix[2][2]
|
||||
|
||||
return self._rgb_to_hex((r, g, b))
|
||||
|
||||
def _get_color_distance(self, color1: str, color2: str) -> float:
|
||||
"""
|
||||
Calculate perceptual color distance (CIE76 approximation).
|
||||
|
||||
Returns a value where < 20 means colors may be hard to distinguish.
|
||||
"""
|
||||
rgb1 = self._hex_to_rgb(color1)
|
||||
rgb2 = self._hex_to_rgb(color2)
|
||||
|
||||
# Simple Euclidean distance in RGB space (approximation)
|
||||
# For production, should use CIEDE2000
|
||||
return math.sqrt(
|
||||
(rgb1[0] - rgb2[0]) ** 2 +
|
||||
(rgb1[1] - rgb2[1]) ** 2 +
|
||||
(rgb1[2] - rgb2[2]) ** 2
|
||||
)
|
||||
|
||||
async def accessibility_validate_colors(
|
||||
self,
|
||||
colors: List[str],
|
||||
check_types: Optional[List[str]] = None,
|
||||
min_contrast_ratio: float = 4.5
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate a list of colors for accessibility.
|
||||
|
||||
Args:
|
||||
colors: List of hex colors to validate
|
||||
check_types: Color blindness types to check (default: all)
|
||||
min_contrast_ratio: Minimum WCAG contrast ratio (default: 4.5 for AA)
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- issues: List of accessibility issues found
|
||||
- simulations: How colors appear under each deficiency
|
||||
- recommendations: Suggestions for improvement
|
||||
- safe_palettes: Color-blind safe palette suggestions
|
||||
"""
|
||||
check_types = check_types or list(SIMULATION_MATRICES.keys())
|
||||
issues = []
|
||||
simulations = {}
|
||||
|
||||
# Normalize colors
|
||||
normalized_colors = [c.upper() if c.startswith('#') else f'#{c.upper()}' for c in colors]
|
||||
|
||||
# Simulate each color blindness type
|
||||
for deficiency in check_types:
|
||||
if deficiency not in SIMULATION_MATRICES:
|
||||
continue
|
||||
|
||||
simulated = [self._simulate_color_blindness(c, deficiency) for c in normalized_colors]
|
||||
simulations[deficiency] = {
|
||||
"original": normalized_colors,
|
||||
"simulated": simulated,
|
||||
"info": SIMULATION_MATRICES[deficiency]
|
||||
}
|
||||
|
||||
# Check if any color pairs become indistinguishable
|
||||
for i in range(len(normalized_colors)):
|
||||
for j in range(i + 1, len(normalized_colors)):
|
||||
distance = self._get_color_distance(simulated[i], simulated[j])
|
||||
if distance < 30: # Threshold for distinguishability
|
||||
issues.append({
|
||||
"type": "distinguishability",
|
||||
"severity": "warning" if distance > 15 else "error",
|
||||
"colors": [normalized_colors[i], normalized_colors[j]],
|
||||
"affected_by": [deficiency],
|
||||
"simulated_colors": [simulated[i], simulated[j]],
|
||||
"distance": round(distance, 1),
|
||||
"message": f"Colors may be hard to distinguish for {deficiency} ({SIMULATION_MATRICES[deficiency]['description']})"
|
||||
})
|
||||
|
||||
# Check contrast ratios against white and black backgrounds
|
||||
for color in normalized_colors:
|
||||
white_contrast = self._get_contrast_ratio(color, "#FFFFFF")
|
||||
black_contrast = self._get_contrast_ratio(color, "#000000")
|
||||
|
||||
if white_contrast < min_contrast_ratio and black_contrast < min_contrast_ratio:
|
||||
issues.append({
|
||||
"type": "contrast_ratio",
|
||||
"severity": "error",
|
||||
"colors": [color],
|
||||
"white_contrast": round(white_contrast, 2),
|
||||
"black_contrast": round(black_contrast, 2),
|
||||
"required": min_contrast_ratio,
|
||||
"message": f"Insufficient contrast against both white ({white_contrast:.1f}:1) and black ({black_contrast:.1f}:1) backgrounds"
|
||||
})
|
||||
|
||||
# Generate recommendations
|
||||
recommendations = self._generate_recommendations(issues)
|
||||
|
||||
# Calculate overall score
|
||||
error_count = sum(1 for i in issues if i["severity"] == "error")
|
||||
warning_count = sum(1 for i in issues if i["severity"] == "warning")
|
||||
|
||||
if error_count == 0 and warning_count == 0:
|
||||
score = "A"
|
||||
elif error_count == 0 and warning_count <= 2:
|
||||
score = "B"
|
||||
elif error_count <= 2:
|
||||
score = "C"
|
||||
else:
|
||||
score = "D"
|
||||
|
||||
return {
|
||||
"colors_checked": normalized_colors,
|
||||
"overall_score": score,
|
||||
"issue_count": len(issues),
|
||||
"issues": issues,
|
||||
"simulations": simulations,
|
||||
"recommendations": recommendations,
|
||||
"safe_palettes": SAFE_PALETTES
|
||||
}
|
||||
|
||||
async def accessibility_validate_theme(
|
||||
self,
|
||||
theme_name: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate a theme's colors for accessibility.
|
||||
|
||||
Args:
|
||||
theme_name: Theme name to validate
|
||||
|
||||
Returns:
|
||||
Dict with accessibility validation results
|
||||
"""
|
||||
if not self.theme_store:
|
||||
return {
|
||||
"error": "Theme store not configured",
|
||||
"theme_name": theme_name
|
||||
}
|
||||
|
||||
theme = self.theme_store.get_theme(theme_name)
|
||||
if not theme:
|
||||
available = self.theme_store.list_themes()
|
||||
return {
|
||||
"error": f"Theme '{theme_name}' not found. Available: {available}",
|
||||
"theme_name": theme_name
|
||||
}
|
||||
|
||||
# Extract colors from theme
|
||||
colors = []
|
||||
tokens = theme.get("tokens", {})
|
||||
color_tokens = tokens.get("colors", {})
|
||||
|
||||
def extract_colors(obj, prefix=""):
|
||||
"""Recursively extract color values."""
|
||||
if isinstance(obj, str) and (obj.startswith('#') or len(obj) == 6):
|
||||
colors.append(obj if obj.startswith('#') else f'#{obj}')
|
||||
elif isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
extract_colors(value, f"{prefix}.{key}")
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
extract_colors(item, prefix)
|
||||
|
||||
extract_colors(color_tokens)
|
||||
|
||||
# Validate extracted colors
|
||||
result = await self.accessibility_validate_colors(colors)
|
||||
result["theme_name"] = theme_name
|
||||
|
||||
# Add theme-specific checks
|
||||
primary = color_tokens.get("primary")
|
||||
background = color_tokens.get("background", {})
|
||||
text = color_tokens.get("text", {})
|
||||
|
||||
if primary and background:
|
||||
bg_color = background.get("base") if isinstance(background, dict) else background
|
||||
if bg_color:
|
||||
contrast = self._get_contrast_ratio(primary, bg_color)
|
||||
if contrast < 4.5:
|
||||
result["issues"].append({
|
||||
"type": "primary_contrast",
|
||||
"severity": "error",
|
||||
"colors": [primary, bg_color],
|
||||
"ratio": round(contrast, 2),
|
||||
"required": 4.5,
|
||||
"message": f"Primary color has insufficient contrast ({contrast:.1f}:1) against background"
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
async def accessibility_suggest_alternative(
|
||||
self,
|
||||
color: str,
|
||||
deficiency_type: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Suggest accessible alternative colors.
|
||||
|
||||
Args:
|
||||
color: Original hex color
|
||||
deficiency_type: Type of color blindness to optimize for
|
||||
|
||||
Returns:
|
||||
Dict with alternative color suggestions
|
||||
"""
|
||||
rgb = self._hex_to_rgb(color)
|
||||
|
||||
suggestions = []
|
||||
|
||||
# Suggest shifting hue while maintaining saturation and brightness
|
||||
# For red-green deficiency, shift toward blue or yellow
|
||||
if deficiency_type in ["deuteranopia", "protanopia"]:
|
||||
# Shift toward blue
|
||||
blue_shift = self._rgb_to_hex((
|
||||
max(0, rgb[0] - 50),
|
||||
max(0, rgb[1] - 30),
|
||||
min(255, rgb[2] + 80)
|
||||
))
|
||||
suggestions.append({
|
||||
"color": blue_shift,
|
||||
"description": "Blue-shifted alternative",
|
||||
"preserves": "approximate brightness"
|
||||
})
|
||||
|
||||
# Shift toward yellow/orange
|
||||
yellow_shift = self._rgb_to_hex((
|
||||
min(255, rgb[0] + 50),
|
||||
min(255, rgb[1] + 30),
|
||||
max(0, rgb[2] - 80)
|
||||
))
|
||||
suggestions.append({
|
||||
"color": yellow_shift,
|
||||
"description": "Yellow-shifted alternative",
|
||||
"preserves": "approximate brightness"
|
||||
})
|
||||
|
||||
elif deficiency_type == "tritanopia":
|
||||
# For blue-yellow deficiency, shift toward red or green
|
||||
red_shift = self._rgb_to_hex((
|
||||
min(255, rgb[0] + 60),
|
||||
max(0, rgb[1] - 20),
|
||||
max(0, rgb[2] - 40)
|
||||
))
|
||||
suggestions.append({
|
||||
"color": red_shift,
|
||||
"description": "Red-shifted alternative",
|
||||
"preserves": "approximate brightness"
|
||||
})
|
||||
|
||||
# Add safe palette suggestions
|
||||
for palette_name, palette in SAFE_PALETTES.items():
|
||||
# Find closest color in safe palette
|
||||
min_distance = float('inf')
|
||||
closest = None
|
||||
for safe_color in palette["colors"]:
|
||||
distance = self._get_color_distance(color, safe_color)
|
||||
if distance < min_distance:
|
||||
min_distance = distance
|
||||
closest = safe_color
|
||||
|
||||
if closest:
|
||||
suggestions.append({
|
||||
"color": closest,
|
||||
"description": f"From {palette['name']} palette",
|
||||
"palette": palette_name
|
||||
})
|
||||
|
||||
return {
|
||||
"original_color": color,
|
||||
"deficiency_type": deficiency_type,
|
||||
"suggestions": suggestions[:5] # Limit to 5 suggestions
|
||||
}
|
||||
|
||||
def _generate_recommendations(self, issues: List[Dict[str, Any]]) -> List[str]:
|
||||
"""Generate actionable recommendations based on issues."""
|
||||
recommendations = []
|
||||
|
||||
# Check for distinguishability issues
|
||||
distinguishability_issues = [i for i in issues if i["type"] == "distinguishability"]
|
||||
if distinguishability_issues:
|
||||
affected_types = set()
|
||||
for issue in distinguishability_issues:
|
||||
affected_types.update(issue.get("affected_by", []))
|
||||
|
||||
if "deuteranopia" in affected_types or "protanopia" in affected_types:
|
||||
recommendations.append(
|
||||
"Avoid using red and green as the only differentiators - "
|
||||
"add patterns, shapes, or labels"
|
||||
)
|
||||
|
||||
recommendations.append(
|
||||
"Consider using a color-blind safe palette like Okabe-Ito or IBM Design"
|
||||
)
|
||||
|
||||
# Check for contrast issues
|
||||
contrast_issues = [i for i in issues if i["type"] in ["contrast_ratio", "primary_contrast"]]
|
||||
if contrast_issues:
|
||||
recommendations.append(
|
||||
"Increase contrast by darkening colors for light backgrounds "
|
||||
"or lightening for dark backgrounds"
|
||||
)
|
||||
recommendations.append(
|
||||
"Use WCAG contrast checker tools to verify text readability"
|
||||
)
|
||||
|
||||
# General recommendations
|
||||
if len(issues) > 0:
|
||||
recommendations.append(
|
||||
"Add secondary visual cues (icons, patterns, labels) "
|
||||
"to not rely solely on color"
|
||||
)
|
||||
|
||||
if not recommendations:
|
||||
recommendations.append(
|
||||
"Color palette appears accessible! Consider adding patterns "
|
||||
"for additional distinguishability"
|
||||
)
|
||||
|
||||
return recommendations
|
||||
533
mcp-servers/viz-platform/mcp_server/chart_tools.py
Normal file
533
mcp-servers/viz-platform/mcp_server/chart_tools.py
Normal file
@@ -0,0 +1,533 @@
|
||||
"""
|
||||
Chart creation tools using Plotly.
|
||||
|
||||
Provides tools for creating data visualizations with automatic theme integration.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check for kaleido availability
|
||||
KALEIDO_AVAILABLE = False
|
||||
try:
|
||||
import kaleido
|
||||
KALEIDO_AVAILABLE = True
|
||||
except ImportError:
|
||||
logger.debug("kaleido not installed - chart export will be unavailable")
|
||||
|
||||
|
||||
# Default color palette based on Mantine theme
|
||||
DEFAULT_COLORS = [
|
||||
"#228be6", # blue
|
||||
"#40c057", # green
|
||||
"#fa5252", # red
|
||||
"#fab005", # yellow
|
||||
"#7950f2", # violet
|
||||
"#fd7e14", # orange
|
||||
"#20c997", # teal
|
||||
"#f783ac", # pink
|
||||
"#868e96", # gray
|
||||
"#15aabf", # cyan
|
||||
]
|
||||
|
||||
|
||||
class ChartTools:
|
||||
"""
|
||||
Plotly-based chart creation tools.
|
||||
|
||||
Creates charts that integrate with DMC theming system.
|
||||
"""
|
||||
|
||||
def __init__(self, theme_store=None):
|
||||
"""
|
||||
Initialize chart tools.
|
||||
|
||||
Args:
|
||||
theme_store: Optional ThemeStore for theme token resolution
|
||||
"""
|
||||
self.theme_store = theme_store
|
||||
self._active_theme = None
|
||||
|
||||
def set_theme(self, theme: Dict[str, Any]) -> None:
|
||||
"""Set the active theme for chart styling."""
|
||||
self._active_theme = theme
|
||||
|
||||
def _get_color_palette(self) -> List[str]:
|
||||
"""Get color palette from theme or defaults."""
|
||||
if self._active_theme and 'colors' in self._active_theme:
|
||||
colors = self._active_theme['colors']
|
||||
# Extract primary colors from theme
|
||||
palette = []
|
||||
for key in ['primary', 'secondary', 'success', 'warning', 'error']:
|
||||
if key in colors:
|
||||
palette.append(colors[key])
|
||||
if palette:
|
||||
return palette + DEFAULT_COLORS[len(palette):]
|
||||
return DEFAULT_COLORS
|
||||
|
||||
def _resolve_color(self, color: Optional[str]) -> str:
|
||||
"""Resolve a color token to actual color value."""
|
||||
if not color:
|
||||
return self._get_color_palette()[0]
|
||||
|
||||
# Check if it's a theme token
|
||||
if self._active_theme and 'colors' in self._active_theme:
|
||||
colors = self._active_theme['colors']
|
||||
if color in colors:
|
||||
return colors[color]
|
||||
|
||||
# Check if it's already a valid color
|
||||
if color.startswith('#') or color.startswith('rgb'):
|
||||
return color
|
||||
|
||||
# Map common color names to palette
|
||||
color_map = {
|
||||
'blue': DEFAULT_COLORS[0],
|
||||
'green': DEFAULT_COLORS[1],
|
||||
'red': DEFAULT_COLORS[2],
|
||||
'yellow': DEFAULT_COLORS[3],
|
||||
'violet': DEFAULT_COLORS[4],
|
||||
'orange': DEFAULT_COLORS[5],
|
||||
'teal': DEFAULT_COLORS[6],
|
||||
'pink': DEFAULT_COLORS[7],
|
||||
'gray': DEFAULT_COLORS[8],
|
||||
'cyan': DEFAULT_COLORS[9],
|
||||
}
|
||||
return color_map.get(color, color)
|
||||
|
||||
async def chart_create(
|
||||
self,
|
||||
chart_type: str,
|
||||
data: Dict[str, Any],
|
||||
options: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a Plotly chart.
|
||||
|
||||
Args:
|
||||
chart_type: Type of chart (line, bar, scatter, pie, heatmap, histogram, area)
|
||||
data: Data specification with x, y values or labels/values for pie
|
||||
options: Optional chart options (title, color, layout settings)
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- figure: Plotly figure JSON
|
||||
- chart_type: Type of chart created
|
||||
- error: Error message if creation failed
|
||||
"""
|
||||
options = options or {}
|
||||
|
||||
# Validate chart type
|
||||
valid_types = ['line', 'bar', 'scatter', 'pie', 'heatmap', 'histogram', 'area']
|
||||
if chart_type not in valid_types:
|
||||
return {
|
||||
"error": f"Invalid chart_type '{chart_type}'. Must be one of: {valid_types}",
|
||||
"chart_type": chart_type,
|
||||
"figure": None
|
||||
}
|
||||
|
||||
try:
|
||||
# Build trace based on chart type
|
||||
trace = self._build_trace(chart_type, data, options)
|
||||
if 'error' in trace:
|
||||
return trace
|
||||
|
||||
# Build layout
|
||||
layout = self._build_layout(options)
|
||||
|
||||
# Create figure structure
|
||||
figure = {
|
||||
"data": [trace],
|
||||
"layout": layout
|
||||
}
|
||||
|
||||
return {
|
||||
"figure": figure,
|
||||
"chart_type": chart_type,
|
||||
"trace_count": 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chart creation failed: {e}")
|
||||
return {
|
||||
"error": str(e),
|
||||
"chart_type": chart_type,
|
||||
"figure": None
|
||||
}
|
||||
|
||||
def _build_trace(
|
||||
self,
|
||||
chart_type: str,
|
||||
data: Dict[str, Any],
|
||||
options: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Build Plotly trace for the chart type."""
|
||||
color = self._resolve_color(options.get('color'))
|
||||
palette = self._get_color_palette()
|
||||
|
||||
# Common trace properties
|
||||
trace: Dict[str, Any] = {}
|
||||
|
||||
if chart_type == 'line':
|
||||
trace = {
|
||||
"type": "scatter",
|
||||
"mode": "lines+markers",
|
||||
"x": data.get('x', []),
|
||||
"y": data.get('y', []),
|
||||
"line": {"color": color},
|
||||
"marker": {"color": color}
|
||||
}
|
||||
if 'name' in data:
|
||||
trace['name'] = data['name']
|
||||
|
||||
elif chart_type == 'bar':
|
||||
trace = {
|
||||
"type": "bar",
|
||||
"x": data.get('x', []),
|
||||
"y": data.get('y', []),
|
||||
"marker": {"color": color}
|
||||
}
|
||||
if options.get('horizontal'):
|
||||
trace['orientation'] = 'h'
|
||||
trace['x'], trace['y'] = trace['y'], trace['x']
|
||||
if 'name' in data:
|
||||
trace['name'] = data['name']
|
||||
|
||||
elif chart_type == 'scatter':
|
||||
trace = {
|
||||
"type": "scatter",
|
||||
"mode": "markers",
|
||||
"x": data.get('x', []),
|
||||
"y": data.get('y', []),
|
||||
"marker": {
|
||||
"color": color,
|
||||
"size": options.get('marker_size', 10)
|
||||
}
|
||||
}
|
||||
if 'size' in data:
|
||||
trace['marker']['size'] = data['size']
|
||||
if 'name' in data:
|
||||
trace['name'] = data['name']
|
||||
|
||||
elif chart_type == 'pie':
|
||||
labels = data.get('labels', data.get('x', []))
|
||||
values = data.get('values', data.get('y', []))
|
||||
trace = {
|
||||
"type": "pie",
|
||||
"labels": labels,
|
||||
"values": values,
|
||||
"marker": {"colors": palette[:len(labels)]}
|
||||
}
|
||||
if options.get('donut'):
|
||||
trace['hole'] = options.get('hole', 0.4)
|
||||
|
||||
elif chart_type == 'heatmap':
|
||||
trace = {
|
||||
"type": "heatmap",
|
||||
"z": data.get('z', data.get('values', [])),
|
||||
"x": data.get('x', []),
|
||||
"y": data.get('y', []),
|
||||
"colorscale": options.get('colorscale', 'Blues')
|
||||
}
|
||||
|
||||
elif chart_type == 'histogram':
|
||||
trace = {
|
||||
"type": "histogram",
|
||||
"x": data.get('x', data.get('values', [])),
|
||||
"marker": {"color": color}
|
||||
}
|
||||
if 'nbins' in options:
|
||||
trace['nbinsx'] = options['nbins']
|
||||
|
||||
elif chart_type == 'area':
|
||||
trace = {
|
||||
"type": "scatter",
|
||||
"mode": "lines",
|
||||
"x": data.get('x', []),
|
||||
"y": data.get('y', []),
|
||||
"fill": "tozeroy",
|
||||
"line": {"color": color},
|
||||
"fillcolor": color.replace(')', ', 0.3)').replace('rgb', 'rgba') if color.startswith('rgb') else color + '4D'
|
||||
}
|
||||
if 'name' in data:
|
||||
trace['name'] = data['name']
|
||||
|
||||
else:
|
||||
return {"error": f"Unsupported chart type: {chart_type}"}
|
||||
|
||||
return trace
|
||||
|
||||
def _build_layout(self, options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build Plotly layout from options."""
|
||||
layout: Dict[str, Any] = {
|
||||
"autosize": True,
|
||||
"margin": {"l": 50, "r": 30, "t": 50, "b": 50}
|
||||
}
|
||||
|
||||
# Title
|
||||
if 'title' in options:
|
||||
layout['title'] = {
|
||||
"text": options['title'],
|
||||
"x": 0.5,
|
||||
"xanchor": "center"
|
||||
}
|
||||
|
||||
# Axis labels
|
||||
if 'x_label' in options:
|
||||
layout['xaxis'] = layout.get('xaxis', {})
|
||||
layout['xaxis']['title'] = options['x_label']
|
||||
|
||||
if 'y_label' in options:
|
||||
layout['yaxis'] = layout.get('yaxis', {})
|
||||
layout['yaxis']['title'] = options['y_label']
|
||||
|
||||
# Theme-based styling
|
||||
if self._active_theme:
|
||||
colors = self._active_theme.get('colors', {})
|
||||
bg = colors.get('background', {})
|
||||
|
||||
if isinstance(bg, dict):
|
||||
layout['paper_bgcolor'] = bg.get('base', '#ffffff')
|
||||
layout['plot_bgcolor'] = bg.get('subtle', '#f8f9fa')
|
||||
elif isinstance(bg, str):
|
||||
layout['paper_bgcolor'] = bg
|
||||
layout['plot_bgcolor'] = bg
|
||||
|
||||
text_color = colors.get('text', {})
|
||||
if isinstance(text_color, dict):
|
||||
layout['font'] = {'color': text_color.get('primary', '#212529')}
|
||||
elif isinstance(text_color, str):
|
||||
layout['font'] = {'color': text_color}
|
||||
|
||||
# Additional layout options
|
||||
if 'showlegend' in options:
|
||||
layout['showlegend'] = options['showlegend']
|
||||
|
||||
if 'height' in options:
|
||||
layout['height'] = options['height']
|
||||
|
||||
if 'width' in options:
|
||||
layout['width'] = options['width']
|
||||
|
||||
return layout
|
||||
|
||||
async def chart_configure_interaction(
|
||||
self,
|
||||
figure: Dict[str, Any],
|
||||
interactions: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Configure interactions for a chart.
|
||||
|
||||
Args:
|
||||
figure: Plotly figure JSON to modify
|
||||
interactions: Interaction configuration:
|
||||
- hover_template: Custom hover text template
|
||||
- click_data: Enable click data capture
|
||||
- selection: Enable selection (box, lasso)
|
||||
- zoom: Enable/disable zoom
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- figure: Updated figure JSON
|
||||
- interactions_added: List of interactions configured
|
||||
- error: Error message if configuration failed
|
||||
"""
|
||||
if not figure or 'data' not in figure:
|
||||
return {
|
||||
"error": "Invalid figure: must contain 'data' key",
|
||||
"figure": figure,
|
||||
"interactions_added": []
|
||||
}
|
||||
|
||||
try:
|
||||
interactions_added = []
|
||||
|
||||
# Process each trace
|
||||
for i, trace in enumerate(figure['data']):
|
||||
# Hover template
|
||||
if 'hover_template' in interactions:
|
||||
trace['hovertemplate'] = interactions['hover_template']
|
||||
if i == 0:
|
||||
interactions_added.append('hover_template')
|
||||
|
||||
# Custom hover info
|
||||
if 'hover_info' in interactions:
|
||||
trace['hoverinfo'] = interactions['hover_info']
|
||||
if i == 0:
|
||||
interactions_added.append('hover_info')
|
||||
|
||||
# Layout-level interactions
|
||||
layout = figure.get('layout', {})
|
||||
|
||||
# Click data (Dash callback integration)
|
||||
if interactions.get('click_data', False):
|
||||
layout['clickmode'] = 'event+select'
|
||||
interactions_added.append('click_data')
|
||||
|
||||
# Selection mode
|
||||
if 'selection' in interactions:
|
||||
sel_mode = interactions['selection']
|
||||
if sel_mode in ['box', 'lasso', 'box+lasso']:
|
||||
layout['dragmode'] = 'select' if sel_mode == 'box' else sel_mode
|
||||
interactions_added.append(f'selection:{sel_mode}')
|
||||
|
||||
# Zoom configuration
|
||||
if 'zoom' in interactions:
|
||||
if not interactions['zoom']:
|
||||
layout['xaxis'] = layout.get('xaxis', {})
|
||||
layout['yaxis'] = layout.get('yaxis', {})
|
||||
layout['xaxis']['fixedrange'] = True
|
||||
layout['yaxis']['fixedrange'] = True
|
||||
interactions_added.append('zoom:disabled')
|
||||
else:
|
||||
interactions_added.append('zoom:enabled')
|
||||
|
||||
# Modebar configuration
|
||||
if 'modebar' in interactions:
|
||||
layout['modebar'] = interactions['modebar']
|
||||
interactions_added.append('modebar')
|
||||
|
||||
figure['layout'] = layout
|
||||
|
||||
return {
|
||||
"figure": figure,
|
||||
"interactions_added": interactions_added
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Interaction configuration failed: {e}")
|
||||
return {
|
||||
"error": str(e),
|
||||
"figure": figure,
|
||||
"interactions_added": []
|
||||
}
|
||||
|
||||
async def chart_export(
|
||||
self,
|
||||
figure: Dict[str, Any],
|
||||
format: str = "png",
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
scale: float = 2.0,
|
||||
output_path: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Export a Plotly chart to a static image format.
|
||||
|
||||
Args:
|
||||
figure: Plotly figure JSON to export
|
||||
format: Output format - png, svg, or pdf
|
||||
width: Image width in pixels (default: from figure or 1200)
|
||||
height: Image height in pixels (default: from figure or 800)
|
||||
scale: Resolution scale factor (default: 2 for retina)
|
||||
output_path: Optional file path to save the image
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- image_data: Base64-encoded image (if no output_path)
|
||||
- file_path: Path to saved file (if output_path provided)
|
||||
- format: Export format used
|
||||
- dimensions: {width, height, scale}
|
||||
- error: Error message if export failed
|
||||
"""
|
||||
# Validate format
|
||||
valid_formats = ['png', 'svg', 'pdf']
|
||||
format = format.lower()
|
||||
if format not in valid_formats:
|
||||
return {
|
||||
"error": f"Invalid format '{format}'. Must be one of: {valid_formats}",
|
||||
"format": format,
|
||||
"image_data": None
|
||||
}
|
||||
|
||||
# Check kaleido availability
|
||||
if not KALEIDO_AVAILABLE:
|
||||
return {
|
||||
"error": "kaleido package not installed. Install with: pip install kaleido",
|
||||
"format": format,
|
||||
"image_data": None,
|
||||
"install_hint": "pip install kaleido"
|
||||
}
|
||||
|
||||
# Validate figure
|
||||
if not figure or 'data' not in figure:
|
||||
return {
|
||||
"error": "Invalid figure: must contain 'data' key",
|
||||
"format": format,
|
||||
"image_data": None
|
||||
}
|
||||
|
||||
try:
|
||||
import plotly.graph_objects as go
|
||||
import plotly.io as pio
|
||||
|
||||
# Create Plotly figure object
|
||||
fig = go.Figure(figure)
|
||||
|
||||
# Determine dimensions
|
||||
layout = figure.get('layout', {})
|
||||
export_width = width or layout.get('width') or 1200
|
||||
export_height = height or layout.get('height') or 800
|
||||
|
||||
# Export to bytes
|
||||
image_bytes = pio.to_image(
|
||||
fig,
|
||||
format=format,
|
||||
width=export_width,
|
||||
height=export_height,
|
||||
scale=scale
|
||||
)
|
||||
|
||||
result = {
|
||||
"format": format,
|
||||
"dimensions": {
|
||||
"width": export_width,
|
||||
"height": export_height,
|
||||
"scale": scale,
|
||||
"effective_width": int(export_width * scale),
|
||||
"effective_height": int(export_height * scale)
|
||||
}
|
||||
}
|
||||
|
||||
# Save to file or return base64
|
||||
if output_path:
|
||||
# Ensure directory exists
|
||||
output_dir = os.path.dirname(output_path)
|
||||
if output_dir and not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Add extension if missing
|
||||
if not output_path.endswith(f'.{format}'):
|
||||
output_path = f"{output_path}.{format}"
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
result["file_path"] = output_path
|
||||
result["file_size_bytes"] = len(image_bytes)
|
||||
else:
|
||||
# Return as base64
|
||||
result["image_data"] = base64.b64encode(image_bytes).decode('utf-8')
|
||||
result["data_uri"] = f"data:image/{format};base64,{result['image_data']}"
|
||||
|
||||
return result
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Chart export failed - missing dependency: {e}")
|
||||
return {
|
||||
"error": f"Missing dependency for export: {e}",
|
||||
"format": format,
|
||||
"image_data": None,
|
||||
"install_hint": "pip install plotly kaleido"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Chart export failed: {e}")
|
||||
return {
|
||||
"error": str(e),
|
||||
"format": format,
|
||||
"image_data": None
|
||||
}
|
||||
301
mcp-servers/viz-platform/mcp_server/component_registry.py
Normal file
301
mcp-servers/viz-platform/mcp_server/component_registry.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
DMC Component Registry for viz-platform.
|
||||
|
||||
Provides version-locked component definitions to prevent Claude from
|
||||
hallucinating invalid props. Uses static JSON registries pre-generated
|
||||
from DMC source.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ComponentRegistry:
|
||||
"""
|
||||
Version-locked registry of Dash Mantine Components.
|
||||
|
||||
Loads component definitions from static JSON files and provides
|
||||
lookup methods for validation tools.
|
||||
"""
|
||||
|
||||
def __init__(self, dmc_version: Optional[str] = None):
|
||||
"""
|
||||
Initialize the component registry.
|
||||
|
||||
Args:
|
||||
dmc_version: Installed DMC version (e.g., "0.14.7").
|
||||
If None, will try to detect or use fallback.
|
||||
"""
|
||||
self.dmc_version = dmc_version
|
||||
self.registry_dir = Path(__file__).parent.parent / 'registry'
|
||||
self.components: Dict[str, Dict[str, Any]] = {}
|
||||
self.categories: Dict[str, List[str]] = {}
|
||||
self.loaded_version: Optional[str] = None
|
||||
|
||||
def load(self) -> bool:
|
||||
"""
|
||||
Load the component registry for the configured DMC version.
|
||||
|
||||
Returns:
|
||||
True if registry loaded successfully, False otherwise
|
||||
"""
|
||||
registry_file = self._find_registry_file()
|
||||
|
||||
if not registry_file:
|
||||
logger.warning(
|
||||
f"No registry found for DMC {self.dmc_version}. "
|
||||
"Component validation will be limited."
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(registry_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
self.loaded_version = data.get('version')
|
||||
self.components = data.get('components', {})
|
||||
self.categories = data.get('categories', {})
|
||||
|
||||
logger.info(
|
||||
f"Loaded component registry v{self.loaded_version} "
|
||||
f"with {len(self.components)} components"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load registry: {e}")
|
||||
return False
|
||||
|
||||
def _find_registry_file(self) -> Optional[Path]:
|
||||
"""
|
||||
Find the best matching registry file for the DMC version.
|
||||
|
||||
Strategy:
|
||||
1. Exact major.minor match (e.g., dmc_0_14.json for 0.14.7)
|
||||
2. Fallback to latest available registry
|
||||
|
||||
Returns:
|
||||
Path to registry file, or None if not found
|
||||
"""
|
||||
if not self.registry_dir.exists():
|
||||
logger.warning(f"Registry directory not found: {self.registry_dir}")
|
||||
return None
|
||||
|
||||
# Try exact major.minor match
|
||||
if self.dmc_version:
|
||||
parts = self.dmc_version.split('.')
|
||||
if len(parts) >= 2:
|
||||
major_minor = f"{parts[0]}_{parts[1]}"
|
||||
exact_match = self.registry_dir / f"dmc_{major_minor}.json"
|
||||
if exact_match.exists():
|
||||
return exact_match
|
||||
|
||||
# Fallback: find latest registry
|
||||
registry_files = list(self.registry_dir.glob("dmc_*.json"))
|
||||
if registry_files:
|
||||
# Sort by version and return latest
|
||||
registry_files.sort(reverse=True)
|
||||
fallback = registry_files[0]
|
||||
if self.dmc_version:
|
||||
logger.warning(
|
||||
f"No exact match for DMC {self.dmc_version}, "
|
||||
f"using fallback: {fallback.name}"
|
||||
)
|
||||
return fallback
|
||||
|
||||
return None
|
||||
|
||||
def get_component(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get component definition by name.
|
||||
|
||||
Args:
|
||||
name: Component name (e.g., "Button", "TextInput")
|
||||
|
||||
Returns:
|
||||
Component definition dict, or None if not found
|
||||
"""
|
||||
return self.components.get(name)
|
||||
|
||||
def get_component_props(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get props schema for a component.
|
||||
|
||||
Args:
|
||||
name: Component name
|
||||
|
||||
Returns:
|
||||
Props dict with type info, or None if component not found
|
||||
"""
|
||||
component = self.get_component(name)
|
||||
if component:
|
||||
return component.get('props', {})
|
||||
return None
|
||||
|
||||
def list_components(self, category: Optional[str] = None) -> Dict[str, List[str]]:
|
||||
"""
|
||||
List available components, optionally filtered by category.
|
||||
|
||||
Args:
|
||||
category: Optional category filter (e.g., "inputs", "buttons")
|
||||
|
||||
Returns:
|
||||
Dict of category -> component names
|
||||
"""
|
||||
if category:
|
||||
if category in self.categories:
|
||||
return {category: self.categories[category]}
|
||||
return {}
|
||||
return self.categories
|
||||
|
||||
def get_categories(self) -> List[str]:
|
||||
"""
|
||||
Get list of available component categories.
|
||||
|
||||
Returns:
|
||||
List of category names
|
||||
"""
|
||||
return list(self.categories.keys())
|
||||
|
||||
def validate_prop(
|
||||
self,
|
||||
component: str,
|
||||
prop_name: str,
|
||||
prop_value: Any
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate a single prop value against the registry.
|
||||
|
||||
Args:
|
||||
component: Component name
|
||||
prop_name: Prop name
|
||||
prop_value: Value to validate
|
||||
|
||||
Returns:
|
||||
Dict with valid: bool, error: Optional[str]
|
||||
"""
|
||||
props = self.get_component_props(component)
|
||||
if props is None:
|
||||
return {
|
||||
'valid': False,
|
||||
'error': f"Unknown component: {component}"
|
||||
}
|
||||
|
||||
if prop_name not in props:
|
||||
# Check for similar prop names (typo detection)
|
||||
similar = self._find_similar_props(prop_name, props.keys())
|
||||
if similar:
|
||||
return {
|
||||
'valid': False,
|
||||
'error': f"Unknown prop '{prop_name}' for {component}. Did you mean '{similar}'?"
|
||||
}
|
||||
return {
|
||||
'valid': False,
|
||||
'error': f"Unknown prop '{prop_name}' for {component}"
|
||||
}
|
||||
|
||||
prop_schema = props[prop_name]
|
||||
return self._validate_value(prop_value, prop_schema, prop_name)
|
||||
|
||||
def _validate_value(
|
||||
self,
|
||||
value: Any,
|
||||
schema: Dict[str, Any],
|
||||
prop_name: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate a value against a prop schema.
|
||||
|
||||
Args:
|
||||
value: Value to validate
|
||||
schema: Prop schema from registry
|
||||
prop_name: Prop name (for error messages)
|
||||
|
||||
Returns:
|
||||
Dict with valid: bool, error: Optional[str]
|
||||
"""
|
||||
prop_type = schema.get('type', 'any')
|
||||
|
||||
# Any type always valid
|
||||
if prop_type == 'any':
|
||||
return {'valid': True}
|
||||
|
||||
# Check enum values
|
||||
if 'enum' in schema:
|
||||
if value not in schema['enum']:
|
||||
return {
|
||||
'valid': False,
|
||||
'error': f"Prop '{prop_name}' expects one of {schema['enum']}, got '{value}'"
|
||||
}
|
||||
return {'valid': True}
|
||||
|
||||
# Type checking
|
||||
type_checks = {
|
||||
'string': lambda v: isinstance(v, str),
|
||||
'number': lambda v: isinstance(v, (int, float)),
|
||||
'integer': lambda v: isinstance(v, int),
|
||||
'boolean': lambda v: isinstance(v, bool),
|
||||
'array': lambda v: isinstance(v, list),
|
||||
'object': lambda v: isinstance(v, dict),
|
||||
}
|
||||
|
||||
checker = type_checks.get(prop_type)
|
||||
if checker and not checker(value):
|
||||
return {
|
||||
'valid': False,
|
||||
'error': f"Prop '{prop_name}' expects type '{prop_type}', got '{type(value).__name__}'"
|
||||
}
|
||||
|
||||
return {'valid': True}
|
||||
|
||||
def _find_similar_props(
|
||||
self,
|
||||
prop_name: str,
|
||||
available_props: List[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Find a similar prop name for typo suggestions.
|
||||
|
||||
Uses simple edit distance heuristic.
|
||||
|
||||
Args:
|
||||
prop_name: The (possibly misspelled) prop name
|
||||
available_props: List of valid prop names
|
||||
|
||||
Returns:
|
||||
Most similar prop name, or None if no close match
|
||||
"""
|
||||
prop_lower = prop_name.lower()
|
||||
|
||||
for prop in available_props:
|
||||
# Exact match after lowercase
|
||||
if prop.lower() == prop_lower:
|
||||
return prop
|
||||
# Common typos: extra/missing letter
|
||||
if abs(len(prop) - len(prop_name)) == 1:
|
||||
if prop_lower.startswith(prop.lower()[:3]):
|
||||
return prop
|
||||
|
||||
return None
|
||||
|
||||
def is_loaded(self) -> bool:
|
||||
"""Check if registry is loaded."""
|
||||
return len(self.components) > 0
|
||||
|
||||
|
||||
def load_registry(dmc_version: Optional[str] = None) -> ComponentRegistry:
|
||||
"""
|
||||
Convenience function to load and return a component registry.
|
||||
|
||||
Args:
|
||||
dmc_version: Optional DMC version string
|
||||
|
||||
Returns:
|
||||
Loaded ComponentRegistry instance
|
||||
"""
|
||||
registry = ComponentRegistry(dmc_version)
|
||||
registry.load()
|
||||
return registry
|
||||
172
mcp-servers/viz-platform/mcp_server/config.py
Normal file
172
mcp-servers/viz-platform/mcp_server/config.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Configuration loader for viz-platform MCP Server.
|
||||
|
||||
Implements hybrid configuration system:
|
||||
- System-level: ~/.config/claude/viz-platform.env (theme preferences)
|
||||
- Project-level: .env (DMC version overrides)
|
||||
- Auto-detection: DMC package version from installed package
|
||||
"""
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VizPlatformConfig:
|
||||
"""Hybrid configuration loader for viz-platform tools"""
|
||||
|
||||
def __init__(self):
|
||||
self.dmc_version: Optional[str] = None
|
||||
self.theme_dir_user: Path = Path.home() / '.config' / 'claude' / 'themes'
|
||||
self.theme_dir_project: Optional[Path] = None
|
||||
self.default_theme: Optional[str] = None
|
||||
|
||||
def load(self) -> Dict[str, any]:
|
||||
"""
|
||||
Load configuration from system and project levels.
|
||||
|
||||
Returns:
|
||||
Dict containing dmc_version, theme directories, and availability flags
|
||||
"""
|
||||
# Load system config
|
||||
system_config = Path.home() / '.config' / 'claude' / 'viz-platform.env'
|
||||
if system_config.exists():
|
||||
load_dotenv(system_config)
|
||||
logger.info(f"Loaded system configuration from {system_config}")
|
||||
|
||||
# Find project directory
|
||||
project_dir = self._find_project_directory()
|
||||
|
||||
# Load project config (overrides system)
|
||||
if project_dir:
|
||||
project_config = project_dir / '.env'
|
||||
if project_config.exists():
|
||||
load_dotenv(project_config, override=True)
|
||||
logger.info(f"Loaded project configuration from {project_config}")
|
||||
|
||||
# Set project theme directory
|
||||
self.theme_dir_project = project_dir / '.viz-platform' / 'themes'
|
||||
|
||||
# Get DMC version (from env or auto-detect)
|
||||
self.dmc_version = os.getenv('DMC_VERSION') or self._detect_dmc_version()
|
||||
self.default_theme = os.getenv('VIZ_DEFAULT_THEME')
|
||||
|
||||
# Ensure user theme directory exists
|
||||
self.theme_dir_user.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return {
|
||||
'dmc_version': self.dmc_version,
|
||||
'dmc_available': self.dmc_version is not None,
|
||||
'theme_dir_user': str(self.theme_dir_user),
|
||||
'theme_dir_project': str(self.theme_dir_project) if self.theme_dir_project else None,
|
||||
'default_theme': self.default_theme,
|
||||
'project_dir': str(project_dir) if project_dir else None
|
||||
}
|
||||
|
||||
def _detect_dmc_version(self) -> Optional[str]:
|
||||
"""
|
||||
Auto-detect installed Dash Mantine Components version.
|
||||
|
||||
Returns:
|
||||
Version string (e.g., "0.14.7") or None if not installed
|
||||
"""
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
dmc_version = version('dash-mantine-components')
|
||||
logger.info(f"Detected DMC version: {dmc_version}")
|
||||
return dmc_version
|
||||
except ImportError:
|
||||
logger.warning("dash-mantine-components not installed - using registry fallback")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not detect DMC version: {e}")
|
||||
return None
|
||||
|
||||
def _find_project_directory(self) -> Optional[Path]:
|
||||
"""
|
||||
Find the user's project directory.
|
||||
|
||||
Returns:
|
||||
Path to project directory, or None if not found
|
||||
"""
|
||||
# Strategy 1: Check CLAUDE_PROJECT_DIR environment variable
|
||||
project_dir = os.getenv('CLAUDE_PROJECT_DIR')
|
||||
if project_dir:
|
||||
path = Path(project_dir)
|
||||
if path.exists():
|
||||
logger.info(f"Found project directory from CLAUDE_PROJECT_DIR: {path}")
|
||||
return path
|
||||
|
||||
# Strategy 2: Check PWD
|
||||
pwd = os.getenv('PWD')
|
||||
if pwd:
|
||||
path = Path(pwd)
|
||||
if path.exists() and (
|
||||
(path / '.git').exists() or
|
||||
(path / '.env').exists() or
|
||||
(path / '.viz-platform').exists()
|
||||
):
|
||||
logger.info(f"Found project directory from PWD: {path}")
|
||||
return path
|
||||
|
||||
# Strategy 3: Check current working directory
|
||||
cwd = Path.cwd()
|
||||
if (cwd / '.git').exists() or (cwd / '.env').exists() or (cwd / '.viz-platform').exists():
|
||||
logger.info(f"Found project directory from cwd: {cwd}")
|
||||
return cwd
|
||||
|
||||
logger.debug("Could not determine project directory")
|
||||
return None
|
||||
|
||||
|
||||
def load_config() -> Dict[str, any]:
|
||||
"""
|
||||
Convenience function to load configuration.
|
||||
|
||||
Returns:
|
||||
Configuration dictionary
|
||||
"""
|
||||
config = VizPlatformConfig()
|
||||
return config.load()
|
||||
|
||||
|
||||
def check_dmc_version() -> Dict[str, any]:
|
||||
"""
|
||||
Check DMC installation status for SessionStart hook.
|
||||
|
||||
Returns:
|
||||
Dict with installation status and version info
|
||||
"""
|
||||
config = load_config()
|
||||
|
||||
if not config.get('dmc_available'):
|
||||
return {
|
||||
'installed': False,
|
||||
'message': 'dash-mantine-components not installed. Run: pip install dash-mantine-components'
|
||||
}
|
||||
|
||||
version = config.get('dmc_version', 'unknown')
|
||||
|
||||
# Check for registry compatibility
|
||||
registry_path = Path(__file__).parent.parent / 'registry'
|
||||
major_minor = '.'.join(version.split('.')[:2]) if version else None
|
||||
registry_file = registry_path / f'dmc_{major_minor.replace(".", "_")}.json' if major_minor else None
|
||||
|
||||
if registry_file and registry_file.exists():
|
||||
return {
|
||||
'installed': True,
|
||||
'version': version,
|
||||
'registry_available': True,
|
||||
'message': f'DMC {version} ready with component registry'
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'installed': True,
|
||||
'version': version,
|
||||
'registry_available': False,
|
||||
'message': f'DMC {version} installed but no matching registry. Validation may be limited.'
|
||||
}
|
||||
306
mcp-servers/viz-platform/mcp_server/dmc_tools.py
Normal file
306
mcp-servers/viz-platform/mcp_server/dmc_tools.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
DMC (Dash Mantine Components) validation tools.
|
||||
|
||||
Provides component constraint layer to prevent Claude from hallucinating invalid props.
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from .component_registry import ComponentRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DMCTools:
|
||||
"""
|
||||
DMC component validation tools.
|
||||
|
||||
These tools provide the "constraint layer" that validates component usage
|
||||
against a version-locked registry of DMC components.
|
||||
"""
|
||||
|
||||
def __init__(self, registry: Optional[ComponentRegistry] = None):
|
||||
"""
|
||||
Initialize DMC tools with component registry.
|
||||
|
||||
Args:
|
||||
registry: ComponentRegistry instance. If None, creates one.
|
||||
"""
|
||||
self.registry = registry
|
||||
self._initialized = False
|
||||
|
||||
def initialize(self, dmc_version: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Initialize the registry if not already provided.
|
||||
|
||||
Args:
|
||||
dmc_version: DMC version to load registry for
|
||||
|
||||
Returns:
|
||||
True if initialized successfully
|
||||
"""
|
||||
if self.registry is None:
|
||||
self.registry = ComponentRegistry(dmc_version)
|
||||
|
||||
if not self.registry.is_loaded():
|
||||
self.registry.load()
|
||||
|
||||
self._initialized = self.registry.is_loaded()
|
||||
return self._initialized
|
||||
|
||||
async def list_components(
|
||||
self,
|
||||
category: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
List available DMC components, optionally filtered by category.
|
||||
|
||||
Args:
|
||||
category: Optional category filter (e.g., "inputs", "buttons", "navigation")
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- components: Dict[category -> [component names]]
|
||||
- categories: List of available categories
|
||||
- version: Loaded DMC registry version
|
||||
- total_count: Total number of components
|
||||
"""
|
||||
if not self._initialized:
|
||||
return {
|
||||
"error": "Registry not initialized",
|
||||
"components": {},
|
||||
"categories": [],
|
||||
"version": None,
|
||||
"total_count": 0
|
||||
}
|
||||
|
||||
components = self.registry.list_components(category)
|
||||
all_categories = self.registry.get_categories()
|
||||
|
||||
# Count total components
|
||||
total = sum(len(comps) for comps in components.values())
|
||||
|
||||
return {
|
||||
"components": components,
|
||||
"categories": all_categories if not category else [category],
|
||||
"version": self.registry.loaded_version,
|
||||
"total_count": total
|
||||
}
|
||||
|
||||
async def get_component_props(self, component: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get props schema for a specific component.
|
||||
|
||||
Args:
|
||||
component: Component name (e.g., "Button", "TextInput")
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- component: Component name
|
||||
- description: Component description
|
||||
- props: Dict of prop name -> {type, default, enum, description}
|
||||
- prop_count: Number of props
|
||||
- required: List of required prop names
|
||||
Or error dict if component not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
return {
|
||||
"error": "Registry not initialized",
|
||||
"component": component,
|
||||
"props": {},
|
||||
"prop_count": 0
|
||||
}
|
||||
|
||||
comp_def = self.registry.get_component(component)
|
||||
if not comp_def:
|
||||
# Try to suggest similar component name
|
||||
similar = self._find_similar_component(component)
|
||||
error_msg = f"Component '{component}' not found in registry"
|
||||
if similar:
|
||||
error_msg += f". Did you mean '{similar}'?"
|
||||
|
||||
return {
|
||||
"error": error_msg,
|
||||
"component": component,
|
||||
"props": {},
|
||||
"prop_count": 0
|
||||
}
|
||||
|
||||
props = comp_def.get('props', {})
|
||||
|
||||
# Extract required props
|
||||
required = [
|
||||
name for name, schema in props.items()
|
||||
if schema.get('required', False)
|
||||
]
|
||||
|
||||
return {
|
||||
"component": component,
|
||||
"description": comp_def.get('description', ''),
|
||||
"props": props,
|
||||
"prop_count": len(props),
|
||||
"required": required,
|
||||
"version": self.registry.loaded_version
|
||||
}
|
||||
|
||||
async def validate_component(
|
||||
self,
|
||||
component: str,
|
||||
props: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate component props against registry.
|
||||
|
||||
Args:
|
||||
component: Component name
|
||||
props: Props dict to validate
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- valid: bool - True if all props are valid
|
||||
- errors: List of error messages
|
||||
- warnings: List of warning messages
|
||||
- validated_props: Number of props validated
|
||||
- component: Component name for reference
|
||||
"""
|
||||
if not self._initialized:
|
||||
return {
|
||||
"valid": False,
|
||||
"errors": ["Registry not initialized"],
|
||||
"warnings": [],
|
||||
"validated_props": 0,
|
||||
"component": component
|
||||
}
|
||||
|
||||
errors: List[str] = []
|
||||
warnings: List[str] = []
|
||||
|
||||
# Check if component exists
|
||||
comp_def = self.registry.get_component(component)
|
||||
if not comp_def:
|
||||
similar = self._find_similar_component(component)
|
||||
error_msg = f"Unknown component: {component}"
|
||||
if similar:
|
||||
error_msg += f". Did you mean '{similar}'?"
|
||||
errors.append(error_msg)
|
||||
|
||||
return {
|
||||
"valid": False,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"validated_props": 0,
|
||||
"component": component
|
||||
}
|
||||
|
||||
comp_props = comp_def.get('props', {})
|
||||
|
||||
# Check for required props
|
||||
for prop_name, prop_schema in comp_props.items():
|
||||
if prop_schema.get('required', False) and prop_name not in props:
|
||||
errors.append(f"Missing required prop: '{prop_name}'")
|
||||
|
||||
# Validate each provided prop
|
||||
for prop_name, prop_value in props.items():
|
||||
# Skip special props that are always allowed
|
||||
if prop_name in ('id', 'children', 'className', 'style', 'key'):
|
||||
continue
|
||||
|
||||
result = self.registry.validate_prop(component, prop_name, prop_value)
|
||||
|
||||
if not result.get('valid', True):
|
||||
error = result.get('error', f"Invalid prop: {prop_name}")
|
||||
# Distinguish between typos/unknown props and type errors
|
||||
if "Unknown prop" in error:
|
||||
errors.append(f"❌ {error}")
|
||||
elif "expects one of" in error:
|
||||
errors.append(f"❌ {error}")
|
||||
elif "expects type" in error:
|
||||
warnings.append(f"⚠️ {error}")
|
||||
else:
|
||||
errors.append(f"❌ {error}")
|
||||
|
||||
# Check for props that exist but might have common mistakes
|
||||
self._check_common_mistakes(component, props, warnings)
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"validated_props": len(props),
|
||||
"component": component,
|
||||
"version": self.registry.loaded_version
|
||||
}
|
||||
|
||||
def _find_similar_component(self, component: str) -> Optional[str]:
|
||||
"""
|
||||
Find a similar component name for suggestions.
|
||||
|
||||
Args:
|
||||
component: The (possibly misspelled) component name
|
||||
|
||||
Returns:
|
||||
Similar component name, or None if no close match
|
||||
"""
|
||||
if not self.registry:
|
||||
return None
|
||||
|
||||
comp_lower = component.lower()
|
||||
all_components = []
|
||||
for comps in self.registry.categories.values():
|
||||
all_components.extend(comps)
|
||||
|
||||
for comp in all_components:
|
||||
# Exact match after lowercase
|
||||
if comp.lower() == comp_lower:
|
||||
return comp
|
||||
# Check if it's a prefix match
|
||||
if comp.lower().startswith(comp_lower) or comp_lower.startswith(comp.lower()):
|
||||
return comp
|
||||
# Check for common typos
|
||||
if abs(len(comp) - len(component)) <= 2:
|
||||
if comp_lower[:4] == comp.lower()[:4]:
|
||||
return comp
|
||||
|
||||
return None
|
||||
|
||||
def _check_common_mistakes(
|
||||
self,
|
||||
component: str,
|
||||
props: Dict[str, Any],
|
||||
warnings: List[str]
|
||||
) -> None:
|
||||
"""
|
||||
Check for common prop usage mistakes and add warnings.
|
||||
|
||||
Args:
|
||||
component: Component name
|
||||
props: Props being used
|
||||
warnings: List to append warnings to
|
||||
"""
|
||||
# Common mistake: using 'onclick' instead of callback pattern
|
||||
if 'onclick' in [p.lower() for p in props.keys()]:
|
||||
warnings.append(
|
||||
"⚠️ Dash uses callback patterns, not inline event handlers. "
|
||||
"Use 'n_clicks' prop with a callback instead."
|
||||
)
|
||||
|
||||
# Common mistake: using 'class' instead of 'className'
|
||||
if 'class' in props:
|
||||
warnings.append(
|
||||
"⚠️ Use 'className' instead of 'class' for CSS classes."
|
||||
)
|
||||
|
||||
# Button-specific checks
|
||||
if component == 'Button':
|
||||
if 'href' in props and 'component' not in props:
|
||||
warnings.append(
|
||||
"⚠️ Button with 'href' should also set 'component=\"a\"' for proper anchor behavior."
|
||||
)
|
||||
|
||||
# Input-specific checks
|
||||
if 'Input' in component:
|
||||
if 'value' in props and 'onChange' in [p for p in props.keys()]:
|
||||
warnings.append(
|
||||
"⚠️ Dash uses 'value' prop with callbacks, not 'onChange'. "
|
||||
"The value updates automatically through Dash callbacks."
|
||||
)
|
||||
553
mcp-servers/viz-platform/mcp_server/layout_tools.py
Normal file
553
mcp-servers/viz-platform/mcp_server/layout_tools.py
Normal file
@@ -0,0 +1,553 @@
|
||||
"""
|
||||
Layout composition tools for dashboard building.
|
||||
|
||||
Provides tools for creating structured layouts with grids, filters, and sections.
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from uuid import uuid4
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Standard responsive breakpoints (Mantine/Bootstrap-aligned)
|
||||
DEFAULT_BREAKPOINTS = {
|
||||
"xs": {
|
||||
"min_width": "0px",
|
||||
"max_width": "575px",
|
||||
"cols": 1,
|
||||
"spacing": "xs",
|
||||
"description": "Extra small devices (phones, portrait)"
|
||||
},
|
||||
"sm": {
|
||||
"min_width": "576px",
|
||||
"max_width": "767px",
|
||||
"cols": 2,
|
||||
"spacing": "sm",
|
||||
"description": "Small devices (phones, landscape)"
|
||||
},
|
||||
"md": {
|
||||
"min_width": "768px",
|
||||
"max_width": "991px",
|
||||
"cols": 6,
|
||||
"spacing": "md",
|
||||
"description": "Medium devices (tablets)"
|
||||
},
|
||||
"lg": {
|
||||
"min_width": "992px",
|
||||
"max_width": "1199px",
|
||||
"cols": 12,
|
||||
"spacing": "md",
|
||||
"description": "Large devices (desktops)"
|
||||
},
|
||||
"xl": {
|
||||
"min_width": "1200px",
|
||||
"max_width": None,
|
||||
"cols": 12,
|
||||
"spacing": "lg",
|
||||
"description": "Extra large devices (large desktops)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Layout templates
|
||||
TEMPLATES = {
|
||||
"dashboard": {
|
||||
"sections": ["header", "filters", "main", "footer"],
|
||||
"default_grid": {"cols": 12, "spacing": "md"},
|
||||
"description": "Standard dashboard with header, filters, main content, and footer"
|
||||
},
|
||||
"report": {
|
||||
"sections": ["title", "summary", "content", "appendix"],
|
||||
"default_grid": {"cols": 1, "spacing": "lg"},
|
||||
"description": "Report layout with title, summary, and content sections"
|
||||
},
|
||||
"form": {
|
||||
"sections": ["header", "fields", "actions"],
|
||||
"default_grid": {"cols": 2, "spacing": "md"},
|
||||
"description": "Form layout with header, fields, and action buttons"
|
||||
},
|
||||
"blank": {
|
||||
"sections": ["main"],
|
||||
"default_grid": {"cols": 12, "spacing": "md"},
|
||||
"description": "Blank canvas for custom layouts"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Filter type definitions
|
||||
FILTER_TYPES = {
|
||||
"dropdown": {
|
||||
"component": "Select",
|
||||
"props": ["label", "data", "placeholder", "clearable", "searchable", "value"]
|
||||
},
|
||||
"multi_select": {
|
||||
"component": "MultiSelect",
|
||||
"props": ["label", "data", "placeholder", "clearable", "searchable", "value"]
|
||||
},
|
||||
"date_range": {
|
||||
"component": "DateRangePicker",
|
||||
"props": ["label", "placeholder", "value", "minDate", "maxDate"]
|
||||
},
|
||||
"date": {
|
||||
"component": "DatePicker",
|
||||
"props": ["label", "placeholder", "value", "minDate", "maxDate"]
|
||||
},
|
||||
"search": {
|
||||
"component": "TextInput",
|
||||
"props": ["label", "placeholder", "value", "icon"]
|
||||
},
|
||||
"checkbox_group": {
|
||||
"component": "CheckboxGroup",
|
||||
"props": ["label", "children", "value"]
|
||||
},
|
||||
"radio_group": {
|
||||
"component": "RadioGroup",
|
||||
"props": ["label", "children", "value"]
|
||||
},
|
||||
"slider": {
|
||||
"component": "Slider",
|
||||
"props": ["label", "min", "max", "step", "value", "marks"]
|
||||
},
|
||||
"range_slider": {
|
||||
"component": "RangeSlider",
|
||||
"props": ["label", "min", "max", "step", "value", "marks"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LayoutTools:
|
||||
"""
|
||||
Dashboard layout composition tools.
|
||||
|
||||
Creates layouts that map to DMC Grid and AppShell components.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize layout tools."""
|
||||
self._layouts: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
async def layout_create(
|
||||
self,
|
||||
name: str,
|
||||
template: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new layout container.
|
||||
|
||||
Args:
|
||||
name: Unique name for the layout
|
||||
template: Optional template (dashboard, report, form, blank)
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- layout_ref: Reference to use in other tools
|
||||
- template: Template used
|
||||
- sections: Available sections
|
||||
- grid: Default grid configuration
|
||||
"""
|
||||
# Validate template
|
||||
template = template or "blank"
|
||||
if template not in TEMPLATES:
|
||||
return {
|
||||
"error": f"Invalid template '{template}'. Must be one of: {list(TEMPLATES.keys())}",
|
||||
"layout_ref": None
|
||||
}
|
||||
|
||||
# Check for name collision
|
||||
if name in self._layouts:
|
||||
return {
|
||||
"error": f"Layout '{name}' already exists. Use a different name or modify existing.",
|
||||
"layout_ref": name
|
||||
}
|
||||
|
||||
template_config = TEMPLATES[template]
|
||||
|
||||
# Create layout structure
|
||||
layout = {
|
||||
"id": str(uuid4()),
|
||||
"name": name,
|
||||
"template": template,
|
||||
"sections": {section: {"items": []} for section in template_config["sections"]},
|
||||
"grid": template_config["default_grid"].copy(),
|
||||
"filters": [],
|
||||
"metadata": {
|
||||
"description": template_config["description"]
|
||||
}
|
||||
}
|
||||
|
||||
self._layouts[name] = layout
|
||||
|
||||
return {
|
||||
"layout_ref": name,
|
||||
"template": template,
|
||||
"sections": template_config["sections"],
|
||||
"grid": layout["grid"],
|
||||
"description": template_config["description"]
|
||||
}
|
||||
|
||||
async def layout_add_filter(
|
||||
self,
|
||||
layout_ref: str,
|
||||
filter_type: str,
|
||||
options: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a filter control to a layout.
|
||||
|
||||
Args:
|
||||
layout_ref: Layout name to add filter to
|
||||
filter_type: Type of filter (dropdown, date_range, search, checkbox_group, etc.)
|
||||
options: Filter options (label, data for dropdown, placeholder, position)
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- filter_id: Unique ID for the filter
|
||||
- component: DMC component that will be used
|
||||
- props: Props that were set
|
||||
- position: Where filter was placed
|
||||
"""
|
||||
# Validate layout exists
|
||||
if layout_ref not in self._layouts:
|
||||
return {
|
||||
"error": f"Layout '{layout_ref}' not found. Create it first with layout_create.",
|
||||
"filter_id": None
|
||||
}
|
||||
|
||||
# Validate filter type
|
||||
if filter_type not in FILTER_TYPES:
|
||||
return {
|
||||
"error": f"Invalid filter_type '{filter_type}'. Must be one of: {list(FILTER_TYPES.keys())}",
|
||||
"filter_id": None
|
||||
}
|
||||
|
||||
filter_config = FILTER_TYPES[filter_type]
|
||||
layout = self._layouts[layout_ref]
|
||||
|
||||
# Generate filter ID
|
||||
filter_id = f"filter_{filter_type}_{len(layout['filters'])}"
|
||||
|
||||
# Extract relevant props
|
||||
props = {"id": filter_id}
|
||||
for prop in filter_config["props"]:
|
||||
if prop in options:
|
||||
props[prop] = options[prop]
|
||||
|
||||
# Determine position
|
||||
position = options.get("position", "filters")
|
||||
if position not in layout["sections"]:
|
||||
# Default to first available section
|
||||
position = list(layout["sections"].keys())[0]
|
||||
|
||||
# Create filter definition
|
||||
filter_def = {
|
||||
"id": filter_id,
|
||||
"type": filter_type,
|
||||
"component": filter_config["component"],
|
||||
"props": props,
|
||||
"position": position
|
||||
}
|
||||
|
||||
layout["filters"].append(filter_def)
|
||||
layout["sections"][position]["items"].append({
|
||||
"type": "filter",
|
||||
"ref": filter_id
|
||||
})
|
||||
|
||||
return {
|
||||
"filter_id": filter_id,
|
||||
"component": filter_config["component"],
|
||||
"props": props,
|
||||
"position": position,
|
||||
"layout_ref": layout_ref
|
||||
}
|
||||
|
||||
async def layout_set_grid(
|
||||
self,
|
||||
layout_ref: str,
|
||||
grid: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Configure the grid system for a layout.
|
||||
|
||||
Args:
|
||||
layout_ref: Layout name to configure
|
||||
grid: Grid configuration:
|
||||
- cols: Number of columns (default 12)
|
||||
- spacing: Gap between items (xs, sm, md, lg, xl)
|
||||
- breakpoints: Responsive breakpoints {xs: cols, sm: cols, ...}
|
||||
- gutter: Gutter size
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- grid: Updated grid configuration
|
||||
- layout_ref: Layout reference
|
||||
"""
|
||||
# Validate layout exists
|
||||
if layout_ref not in self._layouts:
|
||||
return {
|
||||
"error": f"Layout '{layout_ref}' not found. Create it first with layout_create.",
|
||||
"grid": None
|
||||
}
|
||||
|
||||
layout = self._layouts[layout_ref]
|
||||
|
||||
# Validate spacing if provided
|
||||
valid_spacing = ["xs", "sm", "md", "lg", "xl"]
|
||||
if "spacing" in grid and grid["spacing"] not in valid_spacing:
|
||||
return {
|
||||
"error": f"Invalid spacing '{grid['spacing']}'. Must be one of: {valid_spacing}",
|
||||
"grid": layout["grid"]
|
||||
}
|
||||
|
||||
# Validate cols
|
||||
if "cols" in grid:
|
||||
cols = grid["cols"]
|
||||
if not isinstance(cols, int) or cols < 1 or cols > 24:
|
||||
return {
|
||||
"error": f"Invalid cols '{cols}'. Must be integer between 1 and 24.",
|
||||
"grid": layout["grid"]
|
||||
}
|
||||
|
||||
# Update grid configuration
|
||||
layout["grid"].update(grid)
|
||||
|
||||
# Process breakpoints if provided
|
||||
if "breakpoints" in grid:
|
||||
bp = grid["breakpoints"]
|
||||
layout["grid"]["breakpoints"] = bp
|
||||
|
||||
return {
|
||||
"grid": layout["grid"],
|
||||
"layout_ref": layout_ref
|
||||
}
|
||||
|
||||
async def layout_get(self, layout_ref: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get a layout's full configuration.
|
||||
|
||||
Args:
|
||||
layout_ref: Layout name to retrieve
|
||||
|
||||
Returns:
|
||||
Full layout configuration or error
|
||||
"""
|
||||
if layout_ref not in self._layouts:
|
||||
return {
|
||||
"error": f"Layout '{layout_ref}' not found.",
|
||||
"layout": None
|
||||
}
|
||||
|
||||
layout = self._layouts[layout_ref]
|
||||
|
||||
return {
|
||||
"layout": layout,
|
||||
"filter_count": len(layout["filters"]),
|
||||
"sections": list(layout["sections"].keys())
|
||||
}
|
||||
|
||||
async def layout_add_section(
|
||||
self,
|
||||
layout_ref: str,
|
||||
section_name: str,
|
||||
position: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Add a custom section to a layout.
|
||||
|
||||
Args:
|
||||
layout_ref: Layout name
|
||||
section_name: Name for the new section
|
||||
position: Optional position index (appends if not specified)
|
||||
|
||||
Returns:
|
||||
Dict with sections list and the new section name
|
||||
"""
|
||||
if layout_ref not in self._layouts:
|
||||
return {
|
||||
"error": f"Layout '{layout_ref}' not found.",
|
||||
"sections": []
|
||||
}
|
||||
|
||||
layout = self._layouts[layout_ref]
|
||||
|
||||
if section_name in layout["sections"]:
|
||||
return {
|
||||
"error": f"Section '{section_name}' already exists.",
|
||||
"sections": list(layout["sections"].keys())
|
||||
}
|
||||
|
||||
# Add new section
|
||||
layout["sections"][section_name] = {"items": []}
|
||||
|
||||
return {
|
||||
"section_name": section_name,
|
||||
"sections": list(layout["sections"].keys()),
|
||||
"layout_ref": layout_ref
|
||||
}
|
||||
|
||||
def get_available_templates(self) -> Dict[str, Any]:
|
||||
"""Get list of available layout templates."""
|
||||
return {
|
||||
name: {
|
||||
"sections": config["sections"],
|
||||
"description": config["description"]
|
||||
}
|
||||
for name, config in TEMPLATES.items()
|
||||
}
|
||||
|
||||
def get_available_filter_types(self) -> Dict[str, Any]:
|
||||
"""Get list of available filter types."""
|
||||
return {
|
||||
name: {
|
||||
"component": config["component"],
|
||||
"props": config["props"]
|
||||
}
|
||||
for name, config in FILTER_TYPES.items()
|
||||
}
|
||||
|
||||
async def layout_set_breakpoints(
|
||||
self,
|
||||
layout_ref: str,
|
||||
breakpoints: Dict[str, Dict[str, Any]],
|
||||
mobile_first: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Configure responsive breakpoints for a layout.
|
||||
|
||||
Args:
|
||||
layout_ref: Layout name to configure
|
||||
breakpoints: Breakpoint configuration dict:
|
||||
{
|
||||
"xs": {"cols": 1, "spacing": "xs"},
|
||||
"sm": {"cols": 2, "spacing": "sm"},
|
||||
"md": {"cols": 6, "spacing": "md"},
|
||||
"lg": {"cols": 12, "spacing": "md"},
|
||||
"xl": {"cols": 12, "spacing": "lg"}
|
||||
}
|
||||
mobile_first: If True, use min-width media queries (default)
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- breakpoints: Complete breakpoint configuration
|
||||
- css_media_queries: Generated CSS media queries
|
||||
- mobile_first: Whether mobile-first approach is used
|
||||
"""
|
||||
# Validate layout exists
|
||||
if layout_ref not in self._layouts:
|
||||
return {
|
||||
"error": f"Layout '{layout_ref}' not found. Create it first with layout_create.",
|
||||
"breakpoints": None
|
||||
}
|
||||
|
||||
layout = self._layouts[layout_ref]
|
||||
|
||||
# Validate breakpoint names
|
||||
valid_breakpoints = ["xs", "sm", "md", "lg", "xl"]
|
||||
for bp_name in breakpoints.keys():
|
||||
if bp_name not in valid_breakpoints:
|
||||
return {
|
||||
"error": f"Invalid breakpoint '{bp_name}'. Must be one of: {valid_breakpoints}",
|
||||
"breakpoints": layout.get("breakpoints")
|
||||
}
|
||||
|
||||
# Merge with defaults
|
||||
merged_breakpoints = {}
|
||||
for bp_name in valid_breakpoints:
|
||||
default = DEFAULT_BREAKPOINTS[bp_name].copy()
|
||||
if bp_name in breakpoints:
|
||||
default.update(breakpoints[bp_name])
|
||||
merged_breakpoints[bp_name] = default
|
||||
|
||||
# Validate spacing values
|
||||
valid_spacing = ["xs", "sm", "md", "lg", "xl"]
|
||||
for bp_name, bp_config in merged_breakpoints.items():
|
||||
if "spacing" in bp_config and bp_config["spacing"] not in valid_spacing:
|
||||
return {
|
||||
"error": f"Invalid spacing '{bp_config['spacing']}' for breakpoint '{bp_name}'. Must be one of: {valid_spacing}",
|
||||
"breakpoints": layout.get("breakpoints")
|
||||
}
|
||||
|
||||
# Validate column counts
|
||||
for bp_name, bp_config in merged_breakpoints.items():
|
||||
if "cols" in bp_config:
|
||||
cols = bp_config["cols"]
|
||||
if not isinstance(cols, int) or cols < 1 or cols > 24:
|
||||
return {
|
||||
"error": f"Invalid cols '{cols}' for breakpoint '{bp_name}'. Must be integer between 1 and 24.",
|
||||
"breakpoints": layout.get("breakpoints")
|
||||
}
|
||||
|
||||
# Generate CSS media queries
|
||||
css_queries = self._generate_media_queries(merged_breakpoints, mobile_first)
|
||||
|
||||
# Store in layout
|
||||
layout["breakpoints"] = merged_breakpoints
|
||||
layout["mobile_first"] = mobile_first
|
||||
layout["responsive_css"] = css_queries
|
||||
|
||||
return {
|
||||
"layout_ref": layout_ref,
|
||||
"breakpoints": merged_breakpoints,
|
||||
"mobile_first": mobile_first,
|
||||
"css_media_queries": css_queries
|
||||
}
|
||||
|
||||
def _generate_media_queries(
|
||||
self,
|
||||
breakpoints: Dict[str, Dict[str, Any]],
|
||||
mobile_first: bool
|
||||
) -> List[str]:
|
||||
"""Generate CSS media queries for breakpoints."""
|
||||
queries = []
|
||||
bp_order = ["xs", "sm", "md", "lg", "xl"]
|
||||
|
||||
if mobile_first:
|
||||
# Use min-width queries (mobile-first)
|
||||
for bp_name in bp_order[1:]: # Skip xs (base styles)
|
||||
bp = breakpoints[bp_name]
|
||||
min_width = bp.get("min_width", DEFAULT_BREAKPOINTS[bp_name]["min_width"])
|
||||
if min_width and min_width != "0px":
|
||||
queries.append(f"@media (min-width: {min_width}) {{ /* {bp_name} styles */ }}")
|
||||
else:
|
||||
# Use max-width queries (desktop-first)
|
||||
for bp_name in reversed(bp_order[:-1]): # Skip xl (base styles)
|
||||
bp = breakpoints[bp_name]
|
||||
max_width = bp.get("max_width", DEFAULT_BREAKPOINTS[bp_name]["max_width"])
|
||||
if max_width:
|
||||
queries.append(f"@media (max-width: {max_width}) {{ /* {bp_name} styles */ }}")
|
||||
|
||||
return queries
|
||||
|
||||
async def layout_get_breakpoints(self, layout_ref: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the breakpoint configuration for a layout.
|
||||
|
||||
Args:
|
||||
layout_ref: Layout name
|
||||
|
||||
Returns:
|
||||
Dict with breakpoint configuration
|
||||
"""
|
||||
if layout_ref not in self._layouts:
|
||||
return {
|
||||
"error": f"Layout '{layout_ref}' not found.",
|
||||
"breakpoints": None
|
||||
}
|
||||
|
||||
layout = self._layouts[layout_ref]
|
||||
|
||||
return {
|
||||
"layout_ref": layout_ref,
|
||||
"breakpoints": layout.get("breakpoints", DEFAULT_BREAKPOINTS.copy()),
|
||||
"mobile_first": layout.get("mobile_first", True),
|
||||
"css_media_queries": layout.get("responsive_css", [])
|
||||
}
|
||||
|
||||
def get_default_breakpoints(self) -> Dict[str, Any]:
|
||||
"""Get the default breakpoint configuration."""
|
||||
return {
|
||||
"breakpoints": DEFAULT_BREAKPOINTS.copy(),
|
||||
"description": "Standard responsive breakpoints aligned with Mantine/Bootstrap",
|
||||
"mobile_first": True
|
||||
}
|
||||
366
mcp-servers/viz-platform/mcp_server/page_tools.py
Normal file
366
mcp-servers/viz-platform/mcp_server/page_tools.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Multi-page app tools for viz-platform.
|
||||
|
||||
Provides tools for building complete Dash applications with routing and navigation.
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from uuid import uuid4
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Navigation position options
|
||||
NAV_POSITIONS = ["top", "left", "right"]
|
||||
|
||||
# Auth types supported
|
||||
AUTH_TYPES = ["none", "basic", "oauth", "custom"]
|
||||
|
||||
|
||||
class PageTools:
|
||||
"""
|
||||
Multi-page Dash application tools.
|
||||
|
||||
Creates page definitions, navigation, and auth configuration.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize page tools."""
|
||||
self._pages: Dict[str, Dict[str, Any]] = {}
|
||||
self._navbars: Dict[str, Dict[str, Any]] = {}
|
||||
self._app_config: Dict[str, Any] = {
|
||||
"title": "Dash App",
|
||||
"suppress_callback_exceptions": True
|
||||
}
|
||||
|
||||
async def page_create(
|
||||
self,
|
||||
name: str,
|
||||
path: str,
|
||||
layout_ref: Optional[str] = None,
|
||||
title: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new page definition.
|
||||
|
||||
Args:
|
||||
name: Unique page name (used as identifier)
|
||||
path: URL path for the page (e.g., "/", "/settings")
|
||||
layout_ref: Optional layout reference to use for the page
|
||||
title: Optional page title (defaults to name)
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- page_ref: Reference to use in other tools
|
||||
- path: URL path
|
||||
- registered: Whether page was registered
|
||||
"""
|
||||
# Validate path format
|
||||
if not path.startswith('/'):
|
||||
return {
|
||||
"error": f"Path must start with '/'. Got: {path}",
|
||||
"page_ref": None
|
||||
}
|
||||
|
||||
# Check for name collision
|
||||
if name in self._pages:
|
||||
return {
|
||||
"error": f"Page '{name}' already exists. Use a different name.",
|
||||
"page_ref": name
|
||||
}
|
||||
|
||||
# Check for path collision
|
||||
for page_name, page_data in self._pages.items():
|
||||
if page_data['path'] == path:
|
||||
return {
|
||||
"error": f"Path '{path}' already used by page '{page_name}'.",
|
||||
"page_ref": None
|
||||
}
|
||||
|
||||
# Create page definition
|
||||
page = {
|
||||
"id": str(uuid4()),
|
||||
"name": name,
|
||||
"path": path,
|
||||
"title": title or name,
|
||||
"layout_ref": layout_ref,
|
||||
"auth": None,
|
||||
"metadata": {}
|
||||
}
|
||||
|
||||
self._pages[name] = page
|
||||
|
||||
return {
|
||||
"page_ref": name,
|
||||
"path": path,
|
||||
"title": page["title"],
|
||||
"layout_ref": layout_ref,
|
||||
"registered": True
|
||||
}
|
||||
|
||||
async def page_add_navbar(
|
||||
self,
|
||||
pages: List[str],
|
||||
options: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a navigation component linking pages.
|
||||
|
||||
Args:
|
||||
pages: List of page names to include in navigation
|
||||
options: Navigation options:
|
||||
- position: "top", "left", or "right"
|
||||
- style: Style variant
|
||||
- brand: Brand/logo text or config
|
||||
- collapsible: Whether to collapse on mobile
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- navbar_id: Navigation ID
|
||||
- pages: List of page links generated
|
||||
- component: DMC component structure
|
||||
"""
|
||||
options = options or {}
|
||||
|
||||
# Validate pages exist
|
||||
missing_pages = [p for p in pages if p not in self._pages]
|
||||
if missing_pages:
|
||||
return {
|
||||
"error": f"Pages not found: {missing_pages}. Create them first.",
|
||||
"navbar_id": None
|
||||
}
|
||||
|
||||
# Validate position
|
||||
position = options.get("position", "top")
|
||||
if position not in NAV_POSITIONS:
|
||||
return {
|
||||
"error": f"Invalid position '{position}'. Must be one of: {NAV_POSITIONS}",
|
||||
"navbar_id": None
|
||||
}
|
||||
|
||||
# Generate navbar ID
|
||||
navbar_id = f"navbar_{len(self._navbars)}"
|
||||
|
||||
# Build page links
|
||||
page_links = []
|
||||
for page_name in pages:
|
||||
page = self._pages[page_name]
|
||||
page_links.append({
|
||||
"label": page["title"],
|
||||
"href": page["path"],
|
||||
"page_ref": page_name
|
||||
})
|
||||
|
||||
# Build DMC component structure
|
||||
if position == "top":
|
||||
component = self._build_top_navbar(page_links, options)
|
||||
else:
|
||||
component = self._build_side_navbar(page_links, options, position)
|
||||
|
||||
# Store navbar config
|
||||
self._navbars[navbar_id] = {
|
||||
"id": navbar_id,
|
||||
"position": position,
|
||||
"pages": pages,
|
||||
"options": options,
|
||||
"component": component
|
||||
}
|
||||
|
||||
return {
|
||||
"navbar_id": navbar_id,
|
||||
"position": position,
|
||||
"pages": page_links,
|
||||
"component": component
|
||||
}
|
||||
|
||||
async def page_set_auth(
|
||||
self,
|
||||
page_ref: str,
|
||||
auth_config: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Configure authentication for a page.
|
||||
|
||||
Args:
|
||||
page_ref: Page name to configure
|
||||
auth_config: Authentication configuration:
|
||||
- type: "none", "basic", "oauth", "custom"
|
||||
- required: Whether auth is required (default True)
|
||||
- roles: List of required roles (optional)
|
||||
- redirect: Redirect path for unauthenticated users
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- page_ref: Page reference
|
||||
- auth_type: Type of auth configured
|
||||
- protected: Whether page is protected
|
||||
"""
|
||||
# Validate page exists
|
||||
if page_ref not in self._pages:
|
||||
available = list(self._pages.keys())
|
||||
return {
|
||||
"error": f"Page '{page_ref}' not found. Available: {available}",
|
||||
"page_ref": page_ref
|
||||
}
|
||||
|
||||
# Validate auth type
|
||||
auth_type = auth_config.get("type", "basic")
|
||||
if auth_type not in AUTH_TYPES:
|
||||
return {
|
||||
"error": f"Invalid auth type '{auth_type}'. Must be one of: {AUTH_TYPES}",
|
||||
"page_ref": page_ref
|
||||
}
|
||||
|
||||
# Build auth config
|
||||
auth = {
|
||||
"type": auth_type,
|
||||
"required": auth_config.get("required", True),
|
||||
"roles": auth_config.get("roles", []),
|
||||
"redirect": auth_config.get("redirect", "/login")
|
||||
}
|
||||
|
||||
# Handle OAuth-specific config
|
||||
if auth_type == "oauth":
|
||||
auth["provider"] = auth_config.get("provider", "generic")
|
||||
auth["scopes"] = auth_config.get("scopes", [])
|
||||
|
||||
# Update page
|
||||
self._pages[page_ref]["auth"] = auth
|
||||
|
||||
return {
|
||||
"page_ref": page_ref,
|
||||
"auth_type": auth_type,
|
||||
"protected": auth["required"],
|
||||
"roles": auth["roles"],
|
||||
"redirect": auth["redirect"]
|
||||
}
|
||||
|
||||
async def page_list(self) -> Dict[str, Any]:
|
||||
"""
|
||||
List all registered pages.
|
||||
|
||||
Returns:
|
||||
Dict with pages and their configurations
|
||||
"""
|
||||
pages_info = {}
|
||||
for name, page in self._pages.items():
|
||||
pages_info[name] = {
|
||||
"path": page["path"],
|
||||
"title": page["title"],
|
||||
"layout_ref": page["layout_ref"],
|
||||
"protected": page["auth"] is not None and page["auth"].get("required", False)
|
||||
}
|
||||
|
||||
return {
|
||||
"pages": pages_info,
|
||||
"count": len(pages_info),
|
||||
"navbars": list(self._navbars.keys())
|
||||
}
|
||||
|
||||
async def page_get_app_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the complete app configuration for Dash.
|
||||
|
||||
Returns:
|
||||
Dict with app config including pages, navbars, and settings
|
||||
"""
|
||||
# Build pages config
|
||||
pages_config = []
|
||||
for name, page in self._pages.items():
|
||||
pages_config.append({
|
||||
"name": name,
|
||||
"path": page["path"],
|
||||
"title": page["title"],
|
||||
"layout_ref": page["layout_ref"]
|
||||
})
|
||||
|
||||
# Build routing config
|
||||
routes = {page["path"]: name for name, page in self._pages.items()}
|
||||
|
||||
return {
|
||||
"app": self._app_config,
|
||||
"pages": pages_config,
|
||||
"routes": routes,
|
||||
"navbars": list(self._navbars.values()),
|
||||
"page_count": len(self._pages)
|
||||
}
|
||||
|
||||
def _build_top_navbar(
|
||||
self,
|
||||
page_links: List[Dict[str, str]],
|
||||
options: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a top navigation bar component."""
|
||||
brand = options.get("brand", "App")
|
||||
|
||||
# Build nav links
|
||||
nav_items = []
|
||||
for link in page_links:
|
||||
nav_items.append({
|
||||
"component": "NavLink",
|
||||
"props": {
|
||||
"label": link["label"],
|
||||
"href": link["href"]
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"component": "AppShell.Header",
|
||||
"children": [
|
||||
{
|
||||
"component": "Group",
|
||||
"props": {"justify": "space-between", "h": "100%", "px": "md"},
|
||||
"children": [
|
||||
{
|
||||
"component": "Text",
|
||||
"props": {"size": "lg", "fw": 700},
|
||||
"children": brand
|
||||
},
|
||||
{
|
||||
"component": "Group",
|
||||
"props": {"gap": "sm"},
|
||||
"children": nav_items
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def _build_side_navbar(
|
||||
self,
|
||||
page_links: List[Dict[str, str]],
|
||||
options: Dict[str, Any],
|
||||
position: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a side navigation bar component."""
|
||||
brand = options.get("brand", "App")
|
||||
|
||||
# Build nav links
|
||||
nav_items = []
|
||||
for link in page_links:
|
||||
nav_items.append({
|
||||
"component": "NavLink",
|
||||
"props": {
|
||||
"label": link["label"],
|
||||
"href": link["href"]
|
||||
}
|
||||
})
|
||||
|
||||
navbar_component = "AppShell.Navbar" if position == "left" else "AppShell.Aside"
|
||||
|
||||
return {
|
||||
"component": navbar_component,
|
||||
"props": {"p": "md"},
|
||||
"children": [
|
||||
{
|
||||
"component": "Text",
|
||||
"props": {"size": "lg", "fw": 700, "mb": "md"},
|
||||
"children": brand
|
||||
},
|
||||
{
|
||||
"component": "Stack",
|
||||
"props": {"gap": "xs"},
|
||||
"children": nav_items
|
||||
}
|
||||
]
|
||||
}
|
||||
928
mcp-servers/viz-platform/mcp_server/server.py
Normal file
928
mcp-servers/viz-platform/mcp_server/server.py
Normal file
@@ -0,0 +1,928 @@
|
||||
"""
|
||||
MCP Server entry point for viz-platform integration.
|
||||
|
||||
Provides Dash Mantine Components validation, charting, layout, theming, and page tools
|
||||
to Claude Code via JSON-RPC 2.0 over stdio.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
from .config import VizPlatformConfig
|
||||
from .dmc_tools import DMCTools
|
||||
from .chart_tools import ChartTools
|
||||
from .layout_tools import LayoutTools
|
||||
from .theme_tools import ThemeTools
|
||||
from .page_tools import PageTools
|
||||
from .accessibility_tools import AccessibilityTools
|
||||
|
||||
# Suppress noisy MCP validation warnings on stderr
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("root").setLevel(logging.ERROR)
|
||||
logging.getLogger("mcp").setLevel(logging.ERROR)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VizPlatformMCPServer:
|
||||
"""MCP Server for visualization platform integration"""
|
||||
|
||||
def __init__(self):
|
||||
self.server = Server("viz-platform-mcp")
|
||||
self.config = None
|
||||
self.dmc_tools = DMCTools()
|
||||
self.chart_tools = ChartTools()
|
||||
self.layout_tools = LayoutTools()
|
||||
self.theme_tools = ThemeTools()
|
||||
self.page_tools = PageTools()
|
||||
self.accessibility_tools = AccessibilityTools(theme_store=self.theme_tools.store)
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize server and load configuration."""
|
||||
try:
|
||||
config_loader = VizPlatformConfig()
|
||||
self.config = config_loader.load()
|
||||
|
||||
# Initialize DMC tools with detected version
|
||||
dmc_version = self.config.get('dmc_version')
|
||||
self.dmc_tools.initialize(dmc_version)
|
||||
|
||||
# Log available capabilities
|
||||
caps = []
|
||||
if self.config.get('dmc_available'):
|
||||
caps.append(f"DMC {dmc_version}")
|
||||
if self.dmc_tools._initialized:
|
||||
caps.append(f"Registry loaded ({self.dmc_tools.registry.loaded_version})")
|
||||
else:
|
||||
caps.append("DMC (not installed)")
|
||||
|
||||
logger.info(f"viz-platform MCP Server initialized with: {', '.join(caps)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize: {e}")
|
||||
raise
|
||||
|
||||
def setup_tools(self):
|
||||
"""Register all available tools with the MCP server"""
|
||||
|
||||
@self.server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
"""Return list of available tools"""
|
||||
tools = []
|
||||
|
||||
# DMC validation tools (Issue #172)
|
||||
tools.append(Tool(
|
||||
name="list_components",
|
||||
description=(
|
||||
"List available Dash Mantine Components. "
|
||||
"Returns components grouped by category with version info. "
|
||||
"Use this to discover what components are available before building UI."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional category filter. Available categories: "
|
||||
"buttons, inputs, navigation, feedback, overlays, "
|
||||
"typography, layout, data_display, charts, dates"
|
||||
)
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="get_component_props",
|
||||
description=(
|
||||
"Get the props schema for a specific DMC component. "
|
||||
"Returns all available props with types, defaults, and allowed values. "
|
||||
"ALWAYS use this before creating a component to ensure valid props."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component": {
|
||||
"type": "string",
|
||||
"description": "Component name (e.g., 'Button', 'TextInput', 'Select')"
|
||||
}
|
||||
},
|
||||
"required": ["component"]
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="validate_component",
|
||||
description=(
|
||||
"Validate component props before use. "
|
||||
"Checks for invalid props, type mismatches, and common mistakes. "
|
||||
"Returns errors and warnings with suggestions for fixes."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"component": {
|
||||
"type": "string",
|
||||
"description": "Component name to validate"
|
||||
},
|
||||
"props": {
|
||||
"type": "object",
|
||||
"description": "Props object to validate"
|
||||
}
|
||||
},
|
||||
"required": ["component", "props"]
|
||||
}
|
||||
))
|
||||
|
||||
# Chart tools (Issue #173)
|
||||
tools.append(Tool(
|
||||
name="chart_create",
|
||||
description=(
|
||||
"Create a Plotly chart for data visualization. "
|
||||
"Supports line, bar, scatter, pie, heatmap, histogram, and area charts. "
|
||||
"Automatically applies theme colors when a theme is active."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chart_type": {
|
||||
"type": "string",
|
||||
"enum": ["line", "bar", "scatter", "pie", "heatmap", "histogram", "area"],
|
||||
"description": "Type of chart to create"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Data for the chart. For most charts: {x: [], y: []}. "
|
||||
"For pie: {labels: [], values: []}. "
|
||||
"For heatmap: {x: [], y: [], z: [[]]}"
|
||||
)
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Optional settings: title, x_label, y_label, color, "
|
||||
"showlegend, height, width, horizontal (for bar)"
|
||||
)
|
||||
}
|
||||
},
|
||||
"required": ["chart_type", "data"]
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="chart_configure_interaction",
|
||||
description=(
|
||||
"Configure interactions on an existing chart. "
|
||||
"Add hover templates, enable click data capture, selection modes, "
|
||||
"and zoom behavior for Dash callback integration."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"figure": {
|
||||
"type": "object",
|
||||
"description": "Plotly figure JSON to modify"
|
||||
},
|
||||
"interactions": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Interaction config: hover_template (string), "
|
||||
"click_data (bool), selection ('box'|'lasso'), zoom (bool)"
|
||||
)
|
||||
}
|
||||
},
|
||||
"required": ["figure", "interactions"]
|
||||
}
|
||||
))
|
||||
|
||||
# Chart export tool (Issue #247)
|
||||
tools.append(Tool(
|
||||
name="chart_export",
|
||||
description=(
|
||||
"Export a Plotly chart to static image format (PNG, SVG, PDF). "
|
||||
"Requires kaleido package. Returns base64 image data or saves to file."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"figure": {
|
||||
"type": "object",
|
||||
"description": "Plotly figure JSON to export"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["png", "svg", "pdf"],
|
||||
"description": "Output format (default: png)"
|
||||
},
|
||||
"width": {
|
||||
"type": "integer",
|
||||
"description": "Image width in pixels (default: 1200)"
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"description": "Image height in pixels (default: 800)"
|
||||
},
|
||||
"scale": {
|
||||
"type": "number",
|
||||
"description": "Resolution scale factor (default: 2 for retina)"
|
||||
},
|
||||
"output_path": {
|
||||
"type": "string",
|
||||
"description": "Optional file path to save image"
|
||||
}
|
||||
},
|
||||
"required": ["figure"]
|
||||
}
|
||||
))
|
||||
|
||||
# Layout tools (Issue #174)
|
||||
tools.append(Tool(
|
||||
name="layout_create",
|
||||
description=(
|
||||
"Create a new dashboard layout container. "
|
||||
"Templates available: dashboard, report, form, blank. "
|
||||
"Returns layout reference for use with other layout tools."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Unique name for the layout"
|
||||
},
|
||||
"template": {
|
||||
"type": "string",
|
||||
"enum": ["dashboard", "report", "form", "blank"],
|
||||
"description": "Layout template to use (default: blank)"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="layout_add_filter",
|
||||
description=(
|
||||
"Add a filter control to a layout. "
|
||||
"Filter types: dropdown, multi_select, date_range, date, search, "
|
||||
"checkbox_group, radio_group, slider, range_slider."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"layout_ref": {
|
||||
"type": "string",
|
||||
"description": "Layout name to add filter to"
|
||||
},
|
||||
"filter_type": {
|
||||
"type": "string",
|
||||
"enum": ["dropdown", "multi_select", "date_range", "date",
|
||||
"search", "checkbox_group", "radio_group", "slider", "range_slider"],
|
||||
"description": "Type of filter control"
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Filter options: label, data (for dropdown), placeholder, "
|
||||
"position (section name), value, etc."
|
||||
)
|
||||
}
|
||||
},
|
||||
"required": ["layout_ref", "filter_type", "options"]
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="layout_set_grid",
|
||||
description=(
|
||||
"Configure the grid system for a layout. "
|
||||
"Uses DMC Grid component patterns with 12 or 24 column system."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"layout_ref": {
|
||||
"type": "string",
|
||||
"description": "Layout name to configure"
|
||||
},
|
||||
"grid": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Grid config: cols (1-24), spacing (xs|sm|md|lg|xl), "
|
||||
"breakpoints ({xs: cols, sm: cols, ...}), gutter"
|
||||
)
|
||||
}
|
||||
},
|
||||
"required": ["layout_ref", "grid"]
|
||||
}
|
||||
))
|
||||
|
||||
# Responsive breakpoints tool (Issue #249)
|
||||
tools.append(Tool(
|
||||
name="layout_set_breakpoints",
|
||||
description=(
|
||||
"Configure responsive breakpoints for a layout. "
|
||||
"Supports xs, sm, md, lg, xl breakpoints with mobile-first approach. "
|
||||
"Each breakpoint can define cols, spacing, and other grid properties."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"layout_ref": {
|
||||
"type": "string",
|
||||
"description": "Layout name to configure"
|
||||
},
|
||||
"breakpoints": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Breakpoint config: {xs: {cols, spacing}, sm: {...}, md: {...}, lg: {...}, xl: {...}}"
|
||||
)
|
||||
},
|
||||
"mobile_first": {
|
||||
"type": "boolean",
|
||||
"description": "Use mobile-first (min-width) media queries (default: true)"
|
||||
}
|
||||
},
|
||||
"required": ["layout_ref", "breakpoints"]
|
||||
}
|
||||
))
|
||||
|
||||
# Theme tools (Issue #175)
|
||||
tools.append(Tool(
|
||||
name="theme_create",
|
||||
description=(
|
||||
"Create a new design theme with tokens. "
|
||||
"Tokens include colors, spacing, typography, radii. "
|
||||
"Missing tokens are filled from defaults."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Unique theme name"
|
||||
},
|
||||
"tokens": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Design tokens: colors (primary, background, text), "
|
||||
"spacing (xs-xl), typography (fontFamily, fontSize), radii (sm-xl)"
|
||||
)
|
||||
}
|
||||
},
|
||||
"required": ["name", "tokens"]
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="theme_extend",
|
||||
description=(
|
||||
"Create a new theme by extending an existing one. "
|
||||
"Only specify the tokens you want to override."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_theme": {
|
||||
"type": "string",
|
||||
"description": "Theme to extend (e.g., 'default')"
|
||||
},
|
||||
"overrides": {
|
||||
"type": "object",
|
||||
"description": "Token overrides to apply"
|
||||
},
|
||||
"new_name": {
|
||||
"type": "string",
|
||||
"description": "Name for the new theme (optional)"
|
||||
}
|
||||
},
|
||||
"required": ["base_theme", "overrides"]
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="theme_validate",
|
||||
description=(
|
||||
"Validate a theme for completeness. "
|
||||
"Checks for required tokens and common issues."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_name": {
|
||||
"type": "string",
|
||||
"description": "Theme to validate"
|
||||
}
|
||||
},
|
||||
"required": ["theme_name"]
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="theme_export_css",
|
||||
description=(
|
||||
"Export a theme as CSS custom properties. "
|
||||
"Generates :root CSS variables for all tokens."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_name": {
|
||||
"type": "string",
|
||||
"description": "Theme to export"
|
||||
}
|
||||
},
|
||||
"required": ["theme_name"]
|
||||
}
|
||||
))
|
||||
|
||||
# Page tools (Issue #176)
|
||||
tools.append(Tool(
|
||||
name="page_create",
|
||||
description=(
|
||||
"Create a new page for a multi-page Dash application. "
|
||||
"Defines page routing and can link to a layout."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Unique page name (identifier)"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "URL path (e.g., '/', '/settings')"
|
||||
},
|
||||
"layout_ref": {
|
||||
"type": "string",
|
||||
"description": "Optional layout reference to use"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Page title (defaults to name)"
|
||||
}
|
||||
},
|
||||
"required": ["name", "path"]
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="page_add_navbar",
|
||||
description=(
|
||||
"Generate navigation component linking pages. "
|
||||
"Creates top or side navigation with DMC components."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of page names to include"
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Navigation options: position (top|left|right), "
|
||||
"brand (app name), collapsible (bool)"
|
||||
)
|
||||
}
|
||||
},
|
||||
"required": ["pages"]
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="page_set_auth",
|
||||
description=(
|
||||
"Configure authentication for a page. "
|
||||
"Sets auth requirements, roles, and redirect behavior."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page_ref": {
|
||||
"type": "string",
|
||||
"description": "Page name to configure"
|
||||
},
|
||||
"auth_config": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Auth config: type (none|basic|oauth|custom), "
|
||||
"required (bool), roles (array), redirect (path)"
|
||||
)
|
||||
}
|
||||
},
|
||||
"required": ["page_ref", "auth_config"]
|
||||
}
|
||||
))
|
||||
|
||||
# Accessibility tools (Issue #248)
|
||||
tools.append(Tool(
|
||||
name="accessibility_validate_colors",
|
||||
description=(
|
||||
"Validate colors for color blind accessibility. "
|
||||
"Checks contrast ratios for deuteranopia, protanopia, tritanopia. "
|
||||
"Returns issues, simulations, and accessible palette suggestions."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"colors": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of hex colors to validate (e.g., ['#228be6', '#40c057'])"
|
||||
},
|
||||
"check_types": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Color blindness types to check: deuteranopia, protanopia, tritanopia (default: all)"
|
||||
},
|
||||
"min_contrast_ratio": {
|
||||
"type": "number",
|
||||
"description": "Minimum WCAG contrast ratio (default: 4.5 for AA)"
|
||||
}
|
||||
},
|
||||
"required": ["colors"]
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="accessibility_validate_theme",
|
||||
description=(
|
||||
"Validate a theme's colors for accessibility. "
|
||||
"Extracts all colors from theme tokens and checks for color blind safety."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_name": {
|
||||
"type": "string",
|
||||
"description": "Theme name to validate"
|
||||
}
|
||||
},
|
||||
"required": ["theme_name"]
|
||||
}
|
||||
))
|
||||
|
||||
tools.append(Tool(
|
||||
name="accessibility_suggest_alternative",
|
||||
description=(
|
||||
"Suggest accessible alternative colors for a given color. "
|
||||
"Provides alternatives optimized for specific color blindness types."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "Hex color to find alternatives for"
|
||||
},
|
||||
"deficiency_type": {
|
||||
"type": "string",
|
||||
"enum": ["deuteranopia", "protanopia", "tritanopia"],
|
||||
"description": "Color blindness type to optimize for"
|
||||
}
|
||||
},
|
||||
"required": ["color", "deficiency_type"]
|
||||
}
|
||||
))
|
||||
|
||||
return tools
|
||||
|
||||
@self.server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
"""Handle tool invocation."""
|
||||
try:
|
||||
# DMC validation tools
|
||||
if name == "list_components":
|
||||
result = await self.dmc_tools.list_components(
|
||||
category=arguments.get('category')
|
||||
)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "get_component_props":
|
||||
component = arguments.get('component')
|
||||
if not component:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "component is required"}, indent=2)
|
||||
)]
|
||||
result = await self.dmc_tools.get_component_props(component)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "validate_component":
|
||||
component = arguments.get('component')
|
||||
props = arguments.get('props', {})
|
||||
if not component:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "component is required"}, indent=2)
|
||||
)]
|
||||
result = await self.dmc_tools.validate_component(component, props)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
# Chart tools
|
||||
elif name == "chart_create":
|
||||
chart_type = arguments.get('chart_type')
|
||||
data = arguments.get('data', {})
|
||||
options = arguments.get('options', {})
|
||||
if not chart_type:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "chart_type is required"}, indent=2)
|
||||
)]
|
||||
result = await self.chart_tools.chart_create(chart_type, data, options)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "chart_configure_interaction":
|
||||
figure = arguments.get('figure')
|
||||
interactions = arguments.get('interactions', {})
|
||||
if not figure:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "figure is required"}, indent=2)
|
||||
)]
|
||||
result = await self.chart_tools.chart_configure_interaction(figure, interactions)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "chart_export":
|
||||
figure = arguments.get('figure')
|
||||
if not figure:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "figure is required"}, indent=2)
|
||||
)]
|
||||
result = await self.chart_tools.chart_export(
|
||||
figure=figure,
|
||||
format=arguments.get('format', 'png'),
|
||||
width=arguments.get('width'),
|
||||
height=arguments.get('height'),
|
||||
scale=arguments.get('scale', 2.0),
|
||||
output_path=arguments.get('output_path')
|
||||
)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
# Layout tools
|
||||
elif name == "layout_create":
|
||||
layout_name = arguments.get('name')
|
||||
template = arguments.get('template')
|
||||
if not layout_name:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "name is required"}, indent=2)
|
||||
)]
|
||||
result = await self.layout_tools.layout_create(layout_name, template)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "layout_add_filter":
|
||||
layout_ref = arguments.get('layout_ref')
|
||||
filter_type = arguments.get('filter_type')
|
||||
options = arguments.get('options', {})
|
||||
if not layout_ref or not filter_type:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "layout_ref and filter_type are required"}, indent=2)
|
||||
)]
|
||||
result = await self.layout_tools.layout_add_filter(layout_ref, filter_type, options)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "layout_set_grid":
|
||||
layout_ref = arguments.get('layout_ref')
|
||||
grid = arguments.get('grid', {})
|
||||
if not layout_ref:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "layout_ref is required"}, indent=2)
|
||||
)]
|
||||
result = await self.layout_tools.layout_set_grid(layout_ref, grid)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "layout_set_breakpoints":
|
||||
layout_ref = arguments.get('layout_ref')
|
||||
breakpoints = arguments.get('breakpoints', {})
|
||||
mobile_first = arguments.get('mobile_first', True)
|
||||
if not layout_ref:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "layout_ref is required"}, indent=2)
|
||||
)]
|
||||
result = await self.layout_tools.layout_set_breakpoints(
|
||||
layout_ref, breakpoints, mobile_first
|
||||
)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
# Theme tools
|
||||
elif name == "theme_create":
|
||||
theme_name = arguments.get('name')
|
||||
tokens = arguments.get('tokens', {})
|
||||
if not theme_name:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "name is required"}, indent=2)
|
||||
)]
|
||||
result = await self.theme_tools.theme_create(theme_name, tokens)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "theme_extend":
|
||||
base_theme = arguments.get('base_theme')
|
||||
overrides = arguments.get('overrides', {})
|
||||
new_name = arguments.get('new_name')
|
||||
if not base_theme:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "base_theme is required"}, indent=2)
|
||||
)]
|
||||
result = await self.theme_tools.theme_extend(base_theme, overrides, new_name)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "theme_validate":
|
||||
theme_name = arguments.get('theme_name')
|
||||
if not theme_name:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "theme_name is required"}, indent=2)
|
||||
)]
|
||||
result = await self.theme_tools.theme_validate(theme_name)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "theme_export_css":
|
||||
theme_name = arguments.get('theme_name')
|
||||
if not theme_name:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "theme_name is required"}, indent=2)
|
||||
)]
|
||||
result = await self.theme_tools.theme_export_css(theme_name)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
# Page tools
|
||||
elif name == "page_create":
|
||||
page_name = arguments.get('name')
|
||||
path = arguments.get('path')
|
||||
layout_ref = arguments.get('layout_ref')
|
||||
title = arguments.get('title')
|
||||
if not page_name or not path:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "name and path are required"}, indent=2)
|
||||
)]
|
||||
result = await self.page_tools.page_create(page_name, path, layout_ref, title)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "page_add_navbar":
|
||||
pages = arguments.get('pages', [])
|
||||
options = arguments.get('options', {})
|
||||
if not pages:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "pages list is required"}, indent=2)
|
||||
)]
|
||||
result = await self.page_tools.page_add_navbar(pages, options)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "page_set_auth":
|
||||
page_ref = arguments.get('page_ref')
|
||||
auth_config = arguments.get('auth_config', {})
|
||||
if not page_ref:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "page_ref is required"}, indent=2)
|
||||
)]
|
||||
result = await self.page_tools.page_set_auth(page_ref, auth_config)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
# Accessibility tools
|
||||
elif name == "accessibility_validate_colors":
|
||||
colors = arguments.get('colors')
|
||||
if not colors:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "colors list is required"}, indent=2)
|
||||
)]
|
||||
result = await self.accessibility_tools.accessibility_validate_colors(
|
||||
colors=colors,
|
||||
check_types=arguments.get('check_types'),
|
||||
min_contrast_ratio=arguments.get('min_contrast_ratio', 4.5)
|
||||
)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "accessibility_validate_theme":
|
||||
theme_name = arguments.get('theme_name')
|
||||
if not theme_name:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "theme_name is required"}, indent=2)
|
||||
)]
|
||||
result = await self.accessibility_tools.accessibility_validate_theme(theme_name)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "accessibility_suggest_alternative":
|
||||
color = arguments.get('color')
|
||||
deficiency_type = arguments.get('deficiency_type')
|
||||
if not color or not deficiency_type:
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": "color and deficiency_type are required"}, indent=2)
|
||||
)]
|
||||
result = await self.accessibility_tools.accessibility_suggest_alternative(
|
||||
color, deficiency_type
|
||||
)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Tool {name} failed: {e}")
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps({"error": str(e)}, indent=2)
|
||||
)]
|
||||
|
||||
async def run(self):
|
||||
"""Run the MCP server"""
|
||||
await self.initialize()
|
||||
self.setup_tools()
|
||||
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await self.server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
self.server.create_initialization_options()
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point"""
|
||||
server = VizPlatformMCPServer()
|
||||
await server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
259
mcp-servers/viz-platform/mcp_server/theme_store.py
Normal file
259
mcp-servers/viz-platform/mcp_server/theme_store.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Theme storage and persistence for viz-platform.
|
||||
|
||||
Handles saving/loading themes from user and project locations.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Default theme based on Mantine defaults
|
||||
DEFAULT_THEME = {
|
||||
"name": "default",
|
||||
"version": "1.0.0",
|
||||
"tokens": {
|
||||
"colors": {
|
||||
"primary": "#228be6",
|
||||
"secondary": "#868e96",
|
||||
"success": "#40c057",
|
||||
"warning": "#fab005",
|
||||
"error": "#fa5252",
|
||||
"info": "#15aabf",
|
||||
"background": {
|
||||
"base": "#ffffff",
|
||||
"subtle": "#f8f9fa",
|
||||
"dark": "#212529"
|
||||
},
|
||||
"text": {
|
||||
"primary": "#212529",
|
||||
"secondary": "#495057",
|
||||
"muted": "#868e96",
|
||||
"inverse": "#ffffff"
|
||||
},
|
||||
"border": "#dee2e6"
|
||||
},
|
||||
"spacing": {
|
||||
"xs": "4px",
|
||||
"sm": "8px",
|
||||
"md": "16px",
|
||||
"lg": "24px",
|
||||
"xl": "32px"
|
||||
},
|
||||
"typography": {
|
||||
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif",
|
||||
"fontFamilyMono": "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace",
|
||||
"fontSize": {
|
||||
"xs": "12px",
|
||||
"sm": "14px",
|
||||
"md": "16px",
|
||||
"lg": "18px",
|
||||
"xl": "20px"
|
||||
},
|
||||
"fontWeight": {
|
||||
"normal": 400,
|
||||
"medium": 500,
|
||||
"semibold": 600,
|
||||
"bold": 700
|
||||
},
|
||||
"lineHeight": {
|
||||
"tight": 1.25,
|
||||
"normal": 1.5,
|
||||
"relaxed": 1.75
|
||||
}
|
||||
},
|
||||
"radii": {
|
||||
"none": "0px",
|
||||
"sm": "4px",
|
||||
"md": "8px",
|
||||
"lg": "16px",
|
||||
"xl": "24px",
|
||||
"full": "9999px"
|
||||
},
|
||||
"shadows": {
|
||||
"none": "none",
|
||||
"sm": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
"md": "0 4px 6px -1px rgb(0 0 0 / 0.1)",
|
||||
"lg": "0 10px 15px -3px rgb(0 0 0 / 0.1)",
|
||||
"xl": "0 20px 25px -5px rgb(0 0 0 / 0.1)"
|
||||
},
|
||||
"transitions": {
|
||||
"fast": "150ms",
|
||||
"normal": "300ms",
|
||||
"slow": "500ms"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Required token categories for validation
|
||||
REQUIRED_TOKEN_CATEGORIES = ["colors", "spacing", "typography", "radii"]
|
||||
|
||||
|
||||
class ThemeStore:
|
||||
"""
|
||||
Store and manage design themes.
|
||||
|
||||
Handles persistence to user-level and project-level locations.
|
||||
"""
|
||||
|
||||
def __init__(self, project_dir: Optional[Path] = None):
|
||||
"""
|
||||
Initialize theme store.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory for project-level themes
|
||||
"""
|
||||
self.project_dir = project_dir
|
||||
self._themes: Dict[str, Dict[str, Any]] = {}
|
||||
self._active_theme: Optional[str] = None
|
||||
|
||||
# Load default theme
|
||||
self._themes["default"] = DEFAULT_THEME.copy()
|
||||
|
||||
@property
|
||||
def user_themes_dir(self) -> Path:
|
||||
"""User-level themes directory."""
|
||||
return Path.home() / ".config" / "claude" / "themes"
|
||||
|
||||
@property
|
||||
def project_themes_dir(self) -> Optional[Path]:
|
||||
"""Project-level themes directory."""
|
||||
if self.project_dir:
|
||||
return self.project_dir / ".viz-platform" / "themes"
|
||||
return None
|
||||
|
||||
def load_themes(self) -> int:
|
||||
"""
|
||||
Load themes from user and project directories.
|
||||
|
||||
Project themes take precedence over user themes.
|
||||
|
||||
Returns:
|
||||
Number of themes loaded
|
||||
"""
|
||||
count = 0
|
||||
|
||||
# Load user themes
|
||||
if self.user_themes_dir.exists():
|
||||
for theme_file in self.user_themes_dir.glob("*.json"):
|
||||
try:
|
||||
with open(theme_file, 'r') as f:
|
||||
theme = json.load(f)
|
||||
name = theme.get('name', theme_file.stem)
|
||||
self._themes[name] = theme
|
||||
count += 1
|
||||
logger.debug(f"Loaded user theme: {name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load theme {theme_file}: {e}")
|
||||
|
||||
# Load project themes (override user themes)
|
||||
if self.project_themes_dir and self.project_themes_dir.exists():
|
||||
for theme_file in self.project_themes_dir.glob("*.json"):
|
||||
try:
|
||||
with open(theme_file, 'r') as f:
|
||||
theme = json.load(f)
|
||||
name = theme.get('name', theme_file.stem)
|
||||
self._themes[name] = theme
|
||||
count += 1
|
||||
logger.debug(f"Loaded project theme: {name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load theme {theme_file}: {e}")
|
||||
|
||||
return count
|
||||
|
||||
def save_theme(
|
||||
self,
|
||||
theme: Dict[str, Any],
|
||||
location: str = "project"
|
||||
) -> Path:
|
||||
"""
|
||||
Save a theme to disk.
|
||||
|
||||
Args:
|
||||
theme: Theme dict to save
|
||||
location: "user" or "project"
|
||||
|
||||
Returns:
|
||||
Path where theme was saved
|
||||
"""
|
||||
name = theme.get('name', 'unnamed')
|
||||
|
||||
if location == "user":
|
||||
target_dir = self.user_themes_dir
|
||||
else:
|
||||
target_dir = self.project_themes_dir
|
||||
if not target_dir:
|
||||
target_dir = self.user_themes_dir
|
||||
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
theme_path = target_dir / f"{name}.json"
|
||||
|
||||
with open(theme_path, 'w') as f:
|
||||
json.dump(theme, f, indent=2)
|
||||
|
||||
# Update in-memory store
|
||||
self._themes[name] = theme
|
||||
|
||||
return theme_path
|
||||
|
||||
def get_theme(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a theme by name."""
|
||||
return self._themes.get(name)
|
||||
|
||||
def list_themes(self) -> List[str]:
|
||||
"""List all available theme names."""
|
||||
return list(self._themes.keys())
|
||||
|
||||
def set_active_theme(self, name: str) -> bool:
|
||||
"""
|
||||
Set the active theme.
|
||||
|
||||
Args:
|
||||
name: Theme name to activate
|
||||
|
||||
Returns:
|
||||
True if theme was activated
|
||||
"""
|
||||
if name in self._themes:
|
||||
self._active_theme = name
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_active_theme(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get the currently active theme."""
|
||||
if self._active_theme:
|
||||
return self._themes.get(self._active_theme)
|
||||
return None
|
||||
|
||||
def delete_theme(self, name: str) -> bool:
|
||||
"""
|
||||
Delete a theme.
|
||||
|
||||
Args:
|
||||
name: Theme name to delete
|
||||
|
||||
Returns:
|
||||
True if theme was deleted
|
||||
"""
|
||||
if name == "default":
|
||||
return False # Cannot delete default theme
|
||||
|
||||
if name in self._themes:
|
||||
del self._themes[name]
|
||||
|
||||
# Remove file if exists
|
||||
for themes_dir in [self.user_themes_dir, self.project_themes_dir]:
|
||||
if themes_dir and themes_dir.exists():
|
||||
theme_path = themes_dir / f"{name}.json"
|
||||
if theme_path.exists():
|
||||
theme_path.unlink()
|
||||
|
||||
if self._active_theme == name:
|
||||
self._active_theme = None
|
||||
|
||||
return True
|
||||
return False
|
||||
391
mcp-servers/viz-platform/mcp_server/theme_tools.py
Normal file
391
mcp-servers/viz-platform/mcp_server/theme_tools.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
Theme management tools for viz-platform.
|
||||
|
||||
Provides design token-based theming system for consistent visual styling.
|
||||
"""
|
||||
import copy
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from .theme_store import ThemeStore, DEFAULT_THEME, REQUIRED_TOKEN_CATEGORIES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThemeTools:
|
||||
"""
|
||||
Design token-based theming tools.
|
||||
|
||||
Creates and manages themes that integrate with DMC and Plotly.
|
||||
"""
|
||||
|
||||
def __init__(self, store: Optional[ThemeStore] = None):
|
||||
"""
|
||||
Initialize theme tools.
|
||||
|
||||
Args:
|
||||
store: Optional ThemeStore for persistence
|
||||
"""
|
||||
self.store = store or ThemeStore()
|
||||
|
||||
async def theme_create(
|
||||
self,
|
||||
name: str,
|
||||
tokens: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new theme with design tokens.
|
||||
|
||||
Args:
|
||||
name: Unique theme name
|
||||
tokens: Design tokens dict with colors, spacing, typography, radii
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- name: Theme name
|
||||
- tokens: Full token set (merged with defaults)
|
||||
- validation: Validation results
|
||||
"""
|
||||
# Check for name collision
|
||||
if self.store.get_theme(name) and name != "default":
|
||||
return {
|
||||
"error": f"Theme '{name}' already exists. Use theme_extend to modify it.",
|
||||
"name": name
|
||||
}
|
||||
|
||||
# Start with default tokens and merge provided ones
|
||||
theme_tokens = copy.deepcopy(DEFAULT_THEME["tokens"])
|
||||
theme_tokens = self._deep_merge(theme_tokens, tokens)
|
||||
|
||||
# Create theme object
|
||||
theme = {
|
||||
"name": name,
|
||||
"version": "1.0.0",
|
||||
"tokens": theme_tokens
|
||||
}
|
||||
|
||||
# Validate the theme
|
||||
validation = self._validate_tokens(theme_tokens)
|
||||
|
||||
# Save to store
|
||||
self.store._themes[name] = theme
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"tokens": theme_tokens,
|
||||
"validation": validation,
|
||||
"complete": validation["complete"]
|
||||
}
|
||||
|
||||
async def theme_extend(
|
||||
self,
|
||||
base_theme: str,
|
||||
overrides: Dict[str, Any],
|
||||
new_name: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new theme by extending an existing one.
|
||||
|
||||
Args:
|
||||
base_theme: Name of theme to extend
|
||||
overrides: Token overrides to apply
|
||||
new_name: Optional name for the new theme (defaults to base_theme_extended)
|
||||
|
||||
Returns:
|
||||
Dict with the new theme or error
|
||||
"""
|
||||
# Get base theme
|
||||
base = self.store.get_theme(base_theme)
|
||||
if not base:
|
||||
available = self.store.list_themes()
|
||||
return {
|
||||
"error": f"Base theme '{base_theme}' not found. Available: {available}",
|
||||
"name": None
|
||||
}
|
||||
|
||||
# Determine new name
|
||||
name = new_name or f"{base_theme}_extended"
|
||||
|
||||
# Check for collision
|
||||
if self.store.get_theme(name) and name != base_theme:
|
||||
return {
|
||||
"error": f"Theme '{name}' already exists. Choose a different name.",
|
||||
"name": name
|
||||
}
|
||||
|
||||
# Merge tokens
|
||||
theme_tokens = copy.deepcopy(base.get("tokens", {}))
|
||||
theme_tokens = self._deep_merge(theme_tokens, overrides)
|
||||
|
||||
# Create theme
|
||||
theme = {
|
||||
"name": name,
|
||||
"version": "1.0.0",
|
||||
"extends": base_theme,
|
||||
"tokens": theme_tokens
|
||||
}
|
||||
|
||||
# Validate
|
||||
validation = self._validate_tokens(theme_tokens)
|
||||
|
||||
# Save to store
|
||||
self.store._themes[name] = theme
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"extends": base_theme,
|
||||
"tokens": theme_tokens,
|
||||
"validation": validation,
|
||||
"complete": validation["complete"]
|
||||
}
|
||||
|
||||
async def theme_validate(self, theme_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate a theme for completeness.
|
||||
|
||||
Args:
|
||||
theme_name: Theme name to validate
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- valid: bool
|
||||
- complete: bool (all optional tokens present)
|
||||
- missing: List of missing required tokens
|
||||
- warnings: List of warnings
|
||||
"""
|
||||
theme = self.store.get_theme(theme_name)
|
||||
if not theme:
|
||||
available = self.store.list_themes()
|
||||
return {
|
||||
"error": f"Theme '{theme_name}' not found. Available: {available}",
|
||||
"valid": False
|
||||
}
|
||||
|
||||
tokens = theme.get("tokens", {})
|
||||
validation = self._validate_tokens(tokens)
|
||||
|
||||
return {
|
||||
"theme_name": theme_name,
|
||||
"valid": validation["valid"],
|
||||
"complete": validation["complete"],
|
||||
"missing_required": validation["missing_required"],
|
||||
"missing_optional": validation["missing_optional"],
|
||||
"warnings": validation["warnings"]
|
||||
}
|
||||
|
||||
async def theme_export_css(self, theme_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Export a theme as CSS custom properties.
|
||||
|
||||
Args:
|
||||
theme_name: Theme name to export
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- css: CSS custom properties string
|
||||
- variables: List of variable names
|
||||
"""
|
||||
theme = self.store.get_theme(theme_name)
|
||||
if not theme:
|
||||
available = self.store.list_themes()
|
||||
return {
|
||||
"error": f"Theme '{theme_name}' not found. Available: {available}",
|
||||
"css": None
|
||||
}
|
||||
|
||||
tokens = theme.get("tokens", {})
|
||||
css_vars = []
|
||||
var_names = []
|
||||
|
||||
# Convert tokens to CSS custom properties
|
||||
css_vars.append(f"/* Theme: {theme_name} */")
|
||||
css_vars.append(":root {")
|
||||
|
||||
# Colors
|
||||
colors = tokens.get("colors", {})
|
||||
css_vars.append(" /* Colors */")
|
||||
for key, value in self._flatten_tokens(colors, "color").items():
|
||||
var_name = f"--{key}"
|
||||
css_vars.append(f" {var_name}: {value};")
|
||||
var_names.append(var_name)
|
||||
|
||||
# Spacing
|
||||
spacing = tokens.get("spacing", {})
|
||||
css_vars.append("\n /* Spacing */")
|
||||
for key, value in spacing.items():
|
||||
var_name = f"--spacing-{key}"
|
||||
css_vars.append(f" {var_name}: {value};")
|
||||
var_names.append(var_name)
|
||||
|
||||
# Typography
|
||||
typography = tokens.get("typography", {})
|
||||
css_vars.append("\n /* Typography */")
|
||||
for key, value in self._flatten_tokens(typography, "font").items():
|
||||
var_name = f"--{key}"
|
||||
css_vars.append(f" {var_name}: {value};")
|
||||
var_names.append(var_name)
|
||||
|
||||
# Radii
|
||||
radii = tokens.get("radii", {})
|
||||
css_vars.append("\n /* Border Radius */")
|
||||
for key, value in radii.items():
|
||||
var_name = f"--radius-{key}"
|
||||
css_vars.append(f" {var_name}: {value};")
|
||||
var_names.append(var_name)
|
||||
|
||||
# Shadows
|
||||
shadows = tokens.get("shadows", {})
|
||||
if shadows:
|
||||
css_vars.append("\n /* Shadows */")
|
||||
for key, value in shadows.items():
|
||||
var_name = f"--shadow-{key}"
|
||||
css_vars.append(f" {var_name}: {value};")
|
||||
var_names.append(var_name)
|
||||
|
||||
# Transitions
|
||||
transitions = tokens.get("transitions", {})
|
||||
if transitions:
|
||||
css_vars.append("\n /* Transitions */")
|
||||
for key, value in transitions.items():
|
||||
var_name = f"--transition-{key}"
|
||||
css_vars.append(f" {var_name}: {value};")
|
||||
var_names.append(var_name)
|
||||
|
||||
css_vars.append("}")
|
||||
|
||||
css_content = "\n".join(css_vars)
|
||||
|
||||
return {
|
||||
"theme_name": theme_name,
|
||||
"css": css_content,
|
||||
"variable_count": len(var_names),
|
||||
"variables": var_names
|
||||
}
|
||||
|
||||
async def theme_list(self) -> Dict[str, Any]:
|
||||
"""
|
||||
List all available themes.
|
||||
|
||||
Returns:
|
||||
Dict with theme names and active theme
|
||||
"""
|
||||
themes = self.store.list_themes()
|
||||
active = self.store._active_theme
|
||||
|
||||
theme_info = {}
|
||||
for name in themes:
|
||||
theme = self.store.get_theme(name)
|
||||
theme_info[name] = {
|
||||
"extends": theme.get("extends"),
|
||||
"version": theme.get("version", "1.0.0")
|
||||
}
|
||||
|
||||
return {
|
||||
"themes": theme_info,
|
||||
"active_theme": active,
|
||||
"count": len(themes)
|
||||
}
|
||||
|
||||
async def theme_activate(self, theme_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Set the active theme.
|
||||
|
||||
Args:
|
||||
theme_name: Theme to activate
|
||||
|
||||
Returns:
|
||||
Dict with activation status
|
||||
"""
|
||||
if self.store.set_active_theme(theme_name):
|
||||
return {
|
||||
"active_theme": theme_name,
|
||||
"success": True
|
||||
}
|
||||
return {
|
||||
"error": f"Theme '{theme_name}' not found.",
|
||||
"success": False
|
||||
}
|
||||
|
||||
def _validate_tokens(self, tokens: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Validate token structure and completeness."""
|
||||
missing_required = []
|
||||
missing_optional = []
|
||||
warnings = []
|
||||
|
||||
# Check required categories
|
||||
for category in REQUIRED_TOKEN_CATEGORIES:
|
||||
if category not in tokens:
|
||||
missing_required.append(category)
|
||||
|
||||
# Check colors structure
|
||||
colors = tokens.get("colors", {})
|
||||
required_colors = ["primary", "background", "text"]
|
||||
for color in required_colors:
|
||||
if color not in colors:
|
||||
missing_required.append(f"colors.{color}")
|
||||
|
||||
# Check spacing
|
||||
spacing = tokens.get("spacing", {})
|
||||
required_spacing = ["xs", "sm", "md", "lg"]
|
||||
for size in required_spacing:
|
||||
if size not in spacing:
|
||||
missing_optional.append(f"spacing.{size}")
|
||||
|
||||
# Check typography
|
||||
typography = tokens.get("typography", {})
|
||||
if "fontFamily" not in typography:
|
||||
missing_optional.append("typography.fontFamily")
|
||||
if "fontSize" not in typography:
|
||||
missing_optional.append("typography.fontSize")
|
||||
|
||||
# Check radii
|
||||
radii = tokens.get("radii", {})
|
||||
if "sm" not in radii and "md" not in radii:
|
||||
missing_optional.append("radii.sm or radii.md")
|
||||
|
||||
# Warnings for common issues
|
||||
if "shadows" not in tokens:
|
||||
warnings.append("No shadows defined - components may have no elevation")
|
||||
if "transitions" not in tokens:
|
||||
warnings.append("No transitions defined - animations will use defaults")
|
||||
|
||||
return {
|
||||
"valid": len(missing_required) == 0,
|
||||
"complete": len(missing_required) == 0 and len(missing_optional) == 0,
|
||||
"missing_required": missing_required,
|
||||
"missing_optional": missing_optional,
|
||||
"warnings": warnings
|
||||
}
|
||||
|
||||
def _deep_merge(
|
||||
self,
|
||||
base: Dict[str, Any],
|
||||
override: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Deep merge two dictionaries."""
|
||||
result = copy.deepcopy(base)
|
||||
|
||||
for key, value in override.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
result[key] = self._deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
def _flatten_tokens(
|
||||
self,
|
||||
tokens: Dict[str, Any],
|
||||
prefix: str
|
||||
) -> Dict[str, str]:
|
||||
"""Flatten nested token dict for CSS export."""
|
||||
result = {}
|
||||
|
||||
for key, value in tokens.items():
|
||||
if isinstance(value, dict):
|
||||
nested = self._flatten_tokens(value, f"{prefix}-{key}")
|
||||
result.update(nested)
|
||||
else:
|
||||
result[f"{prefix}-{key}"] = str(value)
|
||||
|
||||
return result
|
||||
45
mcp-servers/viz-platform/pyproject.toml
Normal file
45
mcp-servers/viz-platform/pyproject.toml
Normal file
@@ -0,0 +1,45 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "viz-platform-mcp"
|
||||
version = "1.0.0"
|
||||
description = "MCP Server for visualization with Dash Mantine Components validation and theming"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{name = "Leo Miranda"}
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"mcp>=0.9.0",
|
||||
"plotly>=5.18.0",
|
||||
"dash>=2.14.0",
|
||||
"dash-mantine-components>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"pydantic>=2.5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.4.3",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["mcp_server*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
668
mcp-servers/viz-platform/registry/dmc_2_5.json
Normal file
668
mcp-servers/viz-platform/registry/dmc_2_5.json
Normal file
@@ -0,0 +1,668 @@
|
||||
{
|
||||
"version": "2.5.1",
|
||||
"generated": "2026-01-26",
|
||||
"mantine_version": "7.x",
|
||||
"categories": {
|
||||
"buttons": ["Button", "ButtonGroup", "ActionIcon", "ActionIconGroup", "CopyButton", "CloseButton", "UnstyledButton"],
|
||||
"inputs": [
|
||||
"TextInput", "PasswordInput", "NumberInput", "Textarea", "Select", "MultiSelect",
|
||||
"Checkbox", "CheckboxGroup", "CheckboxCard", "Switch", "Radio", "RadioGroup", "RadioCard",
|
||||
"Slider", "RangeSlider", "ColorInput", "ColorPicker", "Autocomplete", "TagsInput",
|
||||
"PinInput", "Rating", "SegmentedControl", "Chip", "ChipGroup", "JsonInput",
|
||||
"NativeSelect", "FileInput", "Combobox"
|
||||
],
|
||||
"navigation": ["Anchor", "Breadcrumbs", "Burger", "NavLink", "Pagination", "Stepper", "Tabs", "TabsList", "TabsTab", "TabsPanel"],
|
||||
"feedback": ["Alert", "Loader", "Notification", "NotificationContainer", "Progress", "RingProgress", "Skeleton"],
|
||||
"overlays": ["Modal", "Drawer", "DrawerStack", "Popover", "HoverCard", "Tooltip", "FloatingTooltip", "Menu", "MenuTarget", "MenuDropdown", "MenuItem", "Affix"],
|
||||
"typography": ["Text", "Title", "Highlight", "Mark", "Code", "CodeHighlight", "Blockquote", "List", "ListItem", "Kbd"],
|
||||
"layout": [
|
||||
"AppShell", "AppShellHeader", "AppShellNavbar", "AppShellAside", "AppShellFooter", "AppShellMain", "AppShellSection",
|
||||
"Container", "Center", "Stack", "Group", "Flex", "Grid", "GridCol", "SimpleGrid",
|
||||
"Paper", "Card", "CardSection", "Box", "Space", "Divider", "AspectRatio", "ScrollArea"
|
||||
],
|
||||
"data_display": [
|
||||
"Accordion", "AccordionItem", "AccordionControl", "AccordionPanel",
|
||||
"Avatar", "AvatarGroup", "Badge", "Image", "BackgroundImage",
|
||||
"Indicator", "Spoiler", "Table", "ThemeIcon", "Timeline", "TimelineItem", "Tree"
|
||||
],
|
||||
"charts": ["AreaChart", "BarChart", "LineChart", "PieChart", "DonutChart", "RadarChart", "ScatterChart", "BubbleChart", "CompositeChart", "Sparkline"],
|
||||
"dates": ["DatePicker", "DateTimePicker", "DateInput", "DatePickerInput", "MonthPicker", "YearPicker", "TimePicker", "TimeInput", "Calendar", "MiniCalendar", "DatesProvider"]
|
||||
},
|
||||
"components": {
|
||||
"Button": {
|
||||
"description": "Button component for user interactions",
|
||||
"props": {
|
||||
"children": {"type": "any", "description": "Button content"},
|
||||
"variant": {"type": "string", "enum": ["filled", "light", "outline", "transparent", "white", "subtle", "default", "gradient"], "default": "filled"},
|
||||
"color": {"type": "string", "default": "blue", "description": "Key of theme.colors or CSS color"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl", "compact-xs", "compact-sm", "compact-md", "compact-lg", "compact-xl"], "default": "sm"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"disabled": {"type": "boolean", "default": false},
|
||||
"loading": {"type": "boolean", "default": false},
|
||||
"loaderProps": {"type": "object"},
|
||||
"leftSection": {"type": "any", "description": "Content on the left side of label"},
|
||||
"rightSection": {"type": "any", "description": "Content on the right side of label"},
|
||||
"fullWidth": {"type": "boolean", "default": false},
|
||||
"gradient": {"type": "object", "description": "Gradient for gradient variant"},
|
||||
"justify": {"type": "string", "enum": ["center", "start", "end", "space-between"], "default": "center"},
|
||||
"autoContrast": {"type": "boolean", "default": false},
|
||||
"n_clicks": {"type": "integer", "default": 0, "description": "Dash callback trigger"}
|
||||
}
|
||||
},
|
||||
"ActionIcon": {
|
||||
"description": "Icon button without text label",
|
||||
"props": {
|
||||
"children": {"type": "any", "required": true, "description": "Icon element"},
|
||||
"variant": {"type": "string", "enum": ["filled", "light", "outline", "transparent", "white", "subtle", "default", "gradient"], "default": "subtle"},
|
||||
"color": {"type": "string", "default": "gray"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"disabled": {"type": "boolean", "default": false},
|
||||
"loading": {"type": "boolean", "default": false},
|
||||
"autoContrast": {"type": "boolean", "default": false},
|
||||
"n_clicks": {"type": "integer", "default": 0}
|
||||
}
|
||||
},
|
||||
"TextInput": {
|
||||
"description": "Text input field",
|
||||
"props": {
|
||||
"value": {"type": "string", "default": ""},
|
||||
"placeholder": {"type": "string"},
|
||||
"label": {"type": "any"},
|
||||
"description": {"type": "any"},
|
||||
"error": {"type": "any"},
|
||||
"disabled": {"type": "boolean", "default": false},
|
||||
"required": {"type": "boolean", "default": false},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"variant": {"type": "string", "enum": ["default", "filled", "unstyled"], "default": "default"},
|
||||
"leftSection": {"type": "any"},
|
||||
"rightSection": {"type": "any"},
|
||||
"withAsterisk": {"type": "boolean", "default": false},
|
||||
"debounce": {"type": "integer", "description": "Debounce delay in ms"},
|
||||
"leftSectionPointerEvents": {"type": "string", "enum": ["none", "all"], "default": "none"},
|
||||
"rightSectionPointerEvents": {"type": "string", "enum": ["none", "all"], "default": "none"}
|
||||
}
|
||||
},
|
||||
"NumberInput": {
|
||||
"description": "Numeric input with optional controls",
|
||||
"props": {
|
||||
"value": {"type": "number"},
|
||||
"placeholder": {"type": "string"},
|
||||
"label": {"type": "any"},
|
||||
"description": {"type": "any"},
|
||||
"error": {"type": "any"},
|
||||
"disabled": {"type": "boolean", "default": false},
|
||||
"required": {"type": "boolean", "default": false},
|
||||
"min": {"type": "number"},
|
||||
"max": {"type": "number"},
|
||||
"step": {"type": "number", "default": 1},
|
||||
"hideControls": {"type": "boolean", "default": false},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"allowNegative": {"type": "boolean", "default": true},
|
||||
"allowDecimal": {"type": "boolean", "default": true},
|
||||
"clampBehavior": {"type": "string", "enum": ["strict", "blur", "none"], "default": "blur"},
|
||||
"decimalScale": {"type": "integer"},
|
||||
"fixedDecimalScale": {"type": "boolean", "default": false},
|
||||
"thousandSeparator": {"type": "string"},
|
||||
"decimalSeparator": {"type": "string"},
|
||||
"prefix": {"type": "string"},
|
||||
"suffix": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"Select": {
|
||||
"description": "Dropdown select input",
|
||||
"props": {
|
||||
"value": {"type": "string"},
|
||||
"data": {"type": "array", "required": true, "description": "Array of options: strings or {value, label} objects"},
|
||||
"placeholder": {"type": "string"},
|
||||
"label": {"type": "any"},
|
||||
"description": {"type": "any"},
|
||||
"error": {"type": "any"},
|
||||
"disabled": {"type": "boolean", "default": false},
|
||||
"required": {"type": "boolean", "default": false},
|
||||
"searchable": {"type": "boolean", "default": false},
|
||||
"clearable": {"type": "boolean", "default": false},
|
||||
"nothingFoundMessage": {"type": "string"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"maxDropdownHeight": {"type": "number", "default": 250},
|
||||
"allowDeselect": {"type": "boolean", "default": true},
|
||||
"checkIconPosition": {"type": "string", "enum": ["left", "right"], "default": "left"},
|
||||
"comboboxProps": {"type": "object"},
|
||||
"withScrollArea": {"type": "boolean", "default": true}
|
||||
}
|
||||
},
|
||||
"MultiSelect": {
|
||||
"description": "Multiple selection dropdown",
|
||||
"props": {
|
||||
"value": {"type": "array", "default": []},
|
||||
"data": {"type": "array", "required": true},
|
||||
"placeholder": {"type": "string"},
|
||||
"label": {"type": "any"},
|
||||
"description": {"type": "any"},
|
||||
"error": {"type": "any"},
|
||||
"disabled": {"type": "boolean", "default": false},
|
||||
"required": {"type": "boolean", "default": false},
|
||||
"searchable": {"type": "boolean", "default": false},
|
||||
"clearable": {"type": "boolean", "default": false},
|
||||
"maxValues": {"type": "integer"},
|
||||
"hidePickedOptions": {"type": "boolean", "default": false},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"maxDropdownHeight": {"type": "number", "default": 250},
|
||||
"withCheckIcon": {"type": "boolean", "default": true}
|
||||
}
|
||||
},
|
||||
"Checkbox": {
|
||||
"description": "Checkbox input",
|
||||
"props": {
|
||||
"checked": {"type": "boolean", "default": false},
|
||||
"label": {"type": "any"},
|
||||
"description": {"type": "any"},
|
||||
"error": {"type": "any"},
|
||||
"disabled": {"type": "boolean", "default": false},
|
||||
"indeterminate": {"type": "boolean", "default": false},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"color": {"type": "string", "default": "blue"},
|
||||
"labelPosition": {"type": "string", "enum": ["left", "right"], "default": "right"},
|
||||
"autoContrast": {"type": "boolean", "default": false},
|
||||
"icon": {"type": "any"},
|
||||
"iconColor": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"Switch": {
|
||||
"description": "Toggle switch input",
|
||||
"props": {
|
||||
"checked": {"type": "boolean", "default": false},
|
||||
"label": {"type": "any"},
|
||||
"description": {"type": "any"},
|
||||
"error": {"type": "any"},
|
||||
"disabled": {"type": "boolean", "default": false},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xl"},
|
||||
"color": {"type": "string", "default": "blue"},
|
||||
"onLabel": {"type": "any"},
|
||||
"offLabel": {"type": "any"},
|
||||
"thumbIcon": {"type": "any"},
|
||||
"labelPosition": {"type": "string", "enum": ["left", "right"], "default": "right"}
|
||||
}
|
||||
},
|
||||
"Slider": {
|
||||
"description": "Slider input for numeric values",
|
||||
"props": {
|
||||
"value": {"type": "number"},
|
||||
"min": {"type": "number", "default": 0},
|
||||
"max": {"type": "number", "default": 100},
|
||||
"step": {"type": "number", "default": 1},
|
||||
"label": {"type": "any"},
|
||||
"disabled": {"type": "boolean", "default": false},
|
||||
"marks": {"type": "array"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xl"},
|
||||
"color": {"type": "string", "default": "blue"},
|
||||
"showLabelOnHover": {"type": "boolean", "default": true},
|
||||
"labelAlwaysOn": {"type": "boolean", "default": false},
|
||||
"thumbLabel": {"type": "string"},
|
||||
"precision": {"type": "integer", "default": 0},
|
||||
"inverted": {"type": "boolean", "default": false},
|
||||
"thumbSize": {"type": "number"},
|
||||
"restrictToMarks": {"type": "boolean", "default": false}
|
||||
}
|
||||
},
|
||||
"Alert": {
|
||||
"description": "Alert component for feedback messages",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"title": {"type": "any"},
|
||||
"color": {"type": "string", "default": "blue"},
|
||||
"variant": {"type": "string", "enum": ["filled", "light", "outline", "default", "transparent", "white"], "default": "light"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"icon": {"type": "any"},
|
||||
"withCloseButton": {"type": "boolean", "default": false},
|
||||
"closeButtonLabel": {"type": "string"},
|
||||
"autoContrast": {"type": "boolean", "default": false}
|
||||
}
|
||||
},
|
||||
"Loader": {
|
||||
"description": "Loading indicator",
|
||||
"props": {
|
||||
"color": {"type": "string", "default": "blue"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"type": {"type": "string", "enum": ["oval", "bars", "dots"], "default": "oval"}
|
||||
}
|
||||
},
|
||||
"Progress": {
|
||||
"description": "Progress bar",
|
||||
"props": {
|
||||
"value": {"type": "number", "required": true},
|
||||
"color": {"type": "string", "default": "blue"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"striped": {"type": "boolean", "default": false},
|
||||
"animated": {"type": "boolean", "default": false},
|
||||
"autoContrast": {"type": "boolean", "default": false},
|
||||
"transitionDuration": {"type": "number", "default": 100}
|
||||
}
|
||||
},
|
||||
"Modal": {
|
||||
"description": "Modal dialog overlay",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"opened": {"type": "boolean", "required": true},
|
||||
"title": {"type": "any"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl", "auto"], "default": "md"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"centered": {"type": "boolean", "default": false},
|
||||
"fullScreen": {"type": "boolean", "default": false},
|
||||
"withCloseButton": {"type": "boolean", "default": true},
|
||||
"closeOnClickOutside": {"type": "boolean", "default": true},
|
||||
"closeOnEscape": {"type": "boolean", "default": true},
|
||||
"overlayProps": {"type": "object"},
|
||||
"padding": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"transitionProps": {"type": "object"},
|
||||
"zIndex": {"type": "number", "default": 200},
|
||||
"trapFocus": {"type": "boolean", "default": true},
|
||||
"returnFocus": {"type": "boolean", "default": true},
|
||||
"lockScroll": {"type": "boolean", "default": true}
|
||||
}
|
||||
},
|
||||
"Drawer": {
|
||||
"description": "Sliding panel drawer",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"opened": {"type": "boolean", "required": true},
|
||||
"title": {"type": "any"},
|
||||
"position": {"type": "string", "enum": ["left", "right", "top", "bottom"], "default": "left"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
|
||||
"withCloseButton": {"type": "boolean", "default": true},
|
||||
"closeOnClickOutside": {"type": "boolean", "default": true},
|
||||
"closeOnEscape": {"type": "boolean", "default": true},
|
||||
"overlayProps": {"type": "object"},
|
||||
"padding": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"zIndex": {"type": "number", "default": 200},
|
||||
"offset": {"type": "number", "default": 0},
|
||||
"trapFocus": {"type": "boolean", "default": true},
|
||||
"returnFocus": {"type": "boolean", "default": true},
|
||||
"lockScroll": {"type": "boolean", "default": true}
|
||||
}
|
||||
},
|
||||
"Tooltip": {
|
||||
"description": "Tooltip on hover",
|
||||
"props": {
|
||||
"children": {"type": "any", "required": true},
|
||||
"label": {"type": "any", "required": true},
|
||||
"position": {"type": "string", "enum": ["top", "right", "bottom", "left", "top-start", "top-end", "right-start", "right-end", "bottom-start", "bottom-end", "left-start", "left-end"], "default": "top"},
|
||||
"color": {"type": "string"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"withArrow": {"type": "boolean", "default": false},
|
||||
"arrowSize": {"type": "number", "default": 4},
|
||||
"arrowOffset": {"type": "number", "default": 5},
|
||||
"offset": {"type": "number", "default": 5},
|
||||
"multiline": {"type": "boolean", "default": false},
|
||||
"disabled": {"type": "boolean", "default": false},
|
||||
"openDelay": {"type": "number", "default": 0},
|
||||
"closeDelay": {"type": "number", "default": 0},
|
||||
"transitionProps": {"type": "object"},
|
||||
"zIndex": {"type": "number", "default": 300}
|
||||
}
|
||||
},
|
||||
"Text": {
|
||||
"description": "Text component with styling",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
|
||||
"c": {"type": "string", "description": "Color"},
|
||||
"fw": {"type": "number", "description": "Font weight"},
|
||||
"fs": {"type": "string", "enum": ["normal", "italic"], "description": "Font style"},
|
||||
"td": {"type": "string", "enum": ["none", "underline", "line-through"], "description": "Text decoration"},
|
||||
"tt": {"type": "string", "enum": ["none", "capitalize", "uppercase", "lowercase"], "description": "Text transform"},
|
||||
"ta": {"type": "string", "enum": ["left", "center", "right", "justify"], "description": "Text align"},
|
||||
"lineClamp": {"type": "integer"},
|
||||
"truncate": {"type": "boolean", "default": false},
|
||||
"inherit": {"type": "boolean", "default": false},
|
||||
"gradient": {"type": "object"},
|
||||
"span": {"type": "boolean", "default": false},
|
||||
"lh": {"type": "string", "description": "Line height"}
|
||||
}
|
||||
},
|
||||
"Title": {
|
||||
"description": "Heading component",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"order": {"type": "integer", "enum": [1, 2, 3, 4, 5, 6], "default": 1},
|
||||
"size": {"type": "string"},
|
||||
"c": {"type": "string", "description": "Color"},
|
||||
"ta": {"type": "string", "enum": ["left", "center", "right", "justify"]},
|
||||
"td": {"type": "string", "enum": ["none", "underline", "line-through"]},
|
||||
"tt": {"type": "string", "enum": ["none", "capitalize", "uppercase", "lowercase"]},
|
||||
"lineClamp": {"type": "integer"},
|
||||
"truncate": {"type": "boolean", "default": false}
|
||||
}
|
||||
},
|
||||
"Stack": {
|
||||
"description": "Vertical stack layout",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"gap": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"align": {"type": "string", "enum": ["stretch", "center", "flex-start", "flex-end"], "default": "stretch"},
|
||||
"justify": {"type": "string", "enum": ["flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly"], "default": "flex-start"}
|
||||
}
|
||||
},
|
||||
"Group": {
|
||||
"description": "Horizontal group layout",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"gap": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"align": {"type": "string", "enum": ["stretch", "center", "flex-start", "flex-end"], "default": "center"},
|
||||
"justify": {"type": "string", "enum": ["flex-start", "flex-end", "center", "space-between", "space-around"], "default": "flex-start"},
|
||||
"grow": {"type": "boolean", "default": false},
|
||||
"wrap": {"type": "string", "enum": ["wrap", "nowrap", "wrap-reverse"], "default": "wrap"},
|
||||
"preventGrowOverflow": {"type": "boolean", "default": true}
|
||||
}
|
||||
},
|
||||
"Flex": {
|
||||
"description": "Flexbox container",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"gap": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
|
||||
"rowGap": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
|
||||
"columnGap": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
|
||||
"align": {"type": "string", "enum": ["stretch", "center", "flex-start", "flex-end", "baseline"]},
|
||||
"justify": {"type": "string", "enum": ["flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly"]},
|
||||
"wrap": {"type": "string", "enum": ["wrap", "nowrap", "wrap-reverse"], "default": "nowrap"},
|
||||
"direction": {"type": "string", "enum": ["row", "column", "row-reverse", "column-reverse"], "default": "row"}
|
||||
}
|
||||
},
|
||||
"Grid": {
|
||||
"description": "Grid layout component",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"columns": {"type": "integer", "default": 12},
|
||||
"gutter": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"grow": {"type": "boolean", "default": false},
|
||||
"justify": {"type": "string", "enum": ["flex-start", "flex-end", "center", "space-between", "space-around"], "default": "flex-start"},
|
||||
"align": {"type": "string", "enum": ["stretch", "center", "flex-start", "flex-end"], "default": "stretch"},
|
||||
"overflow": {"type": "string", "enum": ["visible", "hidden"], "default": "visible"}
|
||||
}
|
||||
},
|
||||
"SimpleGrid": {
|
||||
"description": "Simple grid with equal columns",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"cols": {"type": "integer", "default": 1},
|
||||
"spacing": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"verticalSpacing": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]}
|
||||
}
|
||||
},
|
||||
"Container": {
|
||||
"description": "Centered container with max-width",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"fluid": {"type": "boolean", "default": false}
|
||||
}
|
||||
},
|
||||
"Paper": {
|
||||
"description": "Paper surface component",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"shadow": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"p": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "description": "Padding"},
|
||||
"withBorder": {"type": "boolean", "default": false}
|
||||
}
|
||||
},
|
||||
"Card": {
|
||||
"description": "Card container",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"shadow": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"padding": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"withBorder": {"type": "boolean", "default": false}
|
||||
}
|
||||
},
|
||||
"Tabs": {
|
||||
"description": "Tabbed interface",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"value": {"type": "string"},
|
||||
"defaultValue": {"type": "string"},
|
||||
"orientation": {"type": "string", "enum": ["horizontal", "vertical"], "default": "horizontal"},
|
||||
"variant": {"type": "string", "enum": ["default", "outline", "pills"], "default": "default"},
|
||||
"color": {"type": "string", "default": "blue"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"placement": {"type": "string", "enum": ["left", "right"], "default": "left"},
|
||||
"grow": {"type": "boolean", "default": false},
|
||||
"inverted": {"type": "boolean", "default": false},
|
||||
"keepMounted": {"type": "boolean", "default": true},
|
||||
"activateTabWithKeyboard": {"type": "boolean", "default": true},
|
||||
"allowTabDeactivation": {"type": "boolean", "default": false},
|
||||
"autoContrast": {"type": "boolean", "default": false}
|
||||
}
|
||||
},
|
||||
"Accordion": {
|
||||
"description": "Collapsible content panels",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"value": {"type": "any"},
|
||||
"defaultValue": {"type": "any"},
|
||||
"multiple": {"type": "boolean", "default": false},
|
||||
"variant": {"type": "string", "enum": ["default", "contained", "filled", "separated"], "default": "default"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"chevronPosition": {"type": "string", "enum": ["left", "right"], "default": "right"},
|
||||
"disableChevronRotation": {"type": "boolean", "default": false},
|
||||
"transitionDuration": {"type": "number", "default": 200},
|
||||
"chevronSize": {"type": "any"},
|
||||
"order": {"type": "integer", "enum": [2, 3, 4, 5, 6]}
|
||||
}
|
||||
},
|
||||
"Badge": {
|
||||
"description": "Badge for status or labels",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"color": {"type": "string", "default": "blue"},
|
||||
"variant": {"type": "string", "enum": ["filled", "light", "outline", "dot", "gradient", "default", "transparent", "white"], "default": "filled"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xl"},
|
||||
"fullWidth": {"type": "boolean", "default": false},
|
||||
"leftSection": {"type": "any"},
|
||||
"rightSection": {"type": "any"},
|
||||
"autoContrast": {"type": "boolean", "default": false},
|
||||
"circle": {"type": "boolean", "default": false}
|
||||
}
|
||||
},
|
||||
"Avatar": {
|
||||
"description": "User avatar image",
|
||||
"props": {
|
||||
"src": {"type": "string"},
|
||||
"alt": {"type": "string"},
|
||||
"children": {"type": "any", "description": "Fallback content"},
|
||||
"color": {"type": "string", "default": "gray"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xl"},
|
||||
"variant": {"type": "string", "enum": ["filled", "light", "outline", "gradient", "default", "transparent", "white"], "default": "filled"},
|
||||
"autoContrast": {"type": "boolean", "default": false}
|
||||
}
|
||||
},
|
||||
"Image": {
|
||||
"description": "Image with fallback",
|
||||
"props": {
|
||||
"src": {"type": "string"},
|
||||
"alt": {"type": "string"},
|
||||
"w": {"type": "any", "description": "Width"},
|
||||
"h": {"type": "any", "description": "Height"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
|
||||
"fit": {"type": "string", "enum": ["contain", "cover", "fill", "none", "scale-down"], "default": "cover"},
|
||||
"fallbackSrc": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"Table": {
|
||||
"description": "Data table component",
|
||||
"props": {
|
||||
"children": {"type": "any"},
|
||||
"data": {"type": "object", "description": "Table data object with head, body, foot"},
|
||||
"striped": {"type": "boolean", "default": false},
|
||||
"highlightOnHover": {"type": "boolean", "default": false},
|
||||
"withTableBorder": {"type": "boolean", "default": false},
|
||||
"withColumnBorders": {"type": "boolean", "default": false},
|
||||
"withRowBorders": {"type": "boolean", "default": true},
|
||||
"verticalSpacing": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xs"},
|
||||
"horizontalSpacing": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xs"},
|
||||
"captionSide": {"type": "string", "enum": ["top", "bottom"], "default": "bottom"},
|
||||
"stickyHeader": {"type": "boolean", "default": false},
|
||||
"stickyHeaderOffset": {"type": "number", "default": 0}
|
||||
}
|
||||
},
|
||||
"AreaChart": {
|
||||
"description": "Area chart for time series data",
|
||||
"props": {
|
||||
"data": {"type": "array", "required": true},
|
||||
"dataKey": {"type": "string", "required": true, "description": "X-axis data key"},
|
||||
"series": {"type": "array", "required": true, "description": "Array of {name, color} objects"},
|
||||
"h": {"type": "any", "description": "Chart height"},
|
||||
"w": {"type": "any", "description": "Chart width"},
|
||||
"curveType": {"type": "string", "enum": ["bump", "linear", "natural", "monotone", "step", "stepBefore", "stepAfter"], "default": "monotone"},
|
||||
"connectNulls": {"type": "boolean", "default": true},
|
||||
"withDots": {"type": "boolean", "default": true},
|
||||
"withGradient": {"type": "boolean", "default": true},
|
||||
"withLegend": {"type": "boolean", "default": false},
|
||||
"withTooltip": {"type": "boolean", "default": true},
|
||||
"withXAxis": {"type": "boolean", "default": true},
|
||||
"withYAxis": {"type": "boolean", "default": true},
|
||||
"gridAxis": {"type": "string", "enum": ["x", "y", "xy", "none"], "default": "x"},
|
||||
"tickLine": {"type": "string", "enum": ["x", "y", "xy", "none"], "default": "y"},
|
||||
"strokeDasharray": {"type": "string"},
|
||||
"fillOpacity": {"type": "number", "default": 0.2},
|
||||
"splitColors": {"type": "array"},
|
||||
"areaChartProps": {"type": "object"},
|
||||
"type": {"type": "string", "enum": ["default", "stacked", "percent", "split"], "default": "default"}
|
||||
}
|
||||
},
|
||||
"BarChart": {
|
||||
"description": "Bar chart for categorical data",
|
||||
"props": {
|
||||
"data": {"type": "array", "required": true},
|
||||
"dataKey": {"type": "string", "required": true},
|
||||
"series": {"type": "array", "required": true},
|
||||
"h": {"type": "any"},
|
||||
"w": {"type": "any"},
|
||||
"orientation": {"type": "string", "enum": ["horizontal", "vertical"], "default": "vertical"},
|
||||
"withLegend": {"type": "boolean", "default": false},
|
||||
"withTooltip": {"type": "boolean", "default": true},
|
||||
"withXAxis": {"type": "boolean", "default": true},
|
||||
"withYAxis": {"type": "boolean", "default": true},
|
||||
"gridAxis": {"type": "string", "enum": ["x", "y", "xy", "none"], "default": "x"},
|
||||
"tickLine": {"type": "string", "enum": ["x", "y", "xy", "none"], "default": "y"},
|
||||
"barProps": {"type": "object"},
|
||||
"type": {"type": "string", "enum": ["default", "stacked", "percent", "waterfall"], "default": "default"}
|
||||
}
|
||||
},
|
||||
"LineChart": {
|
||||
"description": "Line chart for trends",
|
||||
"props": {
|
||||
"data": {"type": "array", "required": true},
|
||||
"dataKey": {"type": "string", "required": true},
|
||||
"series": {"type": "array", "required": true},
|
||||
"h": {"type": "any"},
|
||||
"w": {"type": "any"},
|
||||
"curveType": {"type": "string", "enum": ["bump", "linear", "natural", "monotone", "step", "stepBefore", "stepAfter"], "default": "monotone"},
|
||||
"connectNulls": {"type": "boolean", "default": true},
|
||||
"withDots": {"type": "boolean", "default": true},
|
||||
"withLegend": {"type": "boolean", "default": false},
|
||||
"withTooltip": {"type": "boolean", "default": true},
|
||||
"withXAxis": {"type": "boolean", "default": true},
|
||||
"withYAxis": {"type": "boolean", "default": true},
|
||||
"gridAxis": {"type": "string", "enum": ["x", "y", "xy", "none"], "default": "x"},
|
||||
"strokeWidth": {"type": "number", "default": 2}
|
||||
}
|
||||
},
|
||||
"PieChart": {
|
||||
"description": "Pie chart for proportions",
|
||||
"props": {
|
||||
"data": {"type": "array", "required": true, "description": "Array of {name, value, color} objects"},
|
||||
"h": {"type": "any"},
|
||||
"w": {"type": "any"},
|
||||
"withLabels": {"type": "boolean", "default": false},
|
||||
"withLabelsLine": {"type": "boolean", "default": true},
|
||||
"withTooltip": {"type": "boolean", "default": true},
|
||||
"labelsPosition": {"type": "string", "enum": ["inside", "outside"], "default": "outside"},
|
||||
"labelsType": {"type": "string", "enum": ["value", "percent"], "default": "value"},
|
||||
"strokeWidth": {"type": "number", "default": 1},
|
||||
"strokeColor": {"type": "string"},
|
||||
"startAngle": {"type": "number", "default": 0},
|
||||
"endAngle": {"type": "number", "default": 360}
|
||||
}
|
||||
},
|
||||
"DonutChart": {
|
||||
"description": "Donut chart (pie with hole)",
|
||||
"props": {
|
||||
"data": {"type": "array", "required": true},
|
||||
"h": {"type": "any"},
|
||||
"w": {"type": "any"},
|
||||
"withLabels": {"type": "boolean", "default": false},
|
||||
"withLabelsLine": {"type": "boolean", "default": true},
|
||||
"withTooltip": {"type": "boolean", "default": true},
|
||||
"thickness": {"type": "number", "default": 20},
|
||||
"chartLabel": {"type": "any"},
|
||||
"strokeWidth": {"type": "number", "default": 1},
|
||||
"strokeColor": {"type": "string"},
|
||||
"startAngle": {"type": "number", "default": 0},
|
||||
"endAngle": {"type": "number", "default": 360},
|
||||
"paddingAngle": {"type": "number", "default": 0}
|
||||
}
|
||||
},
|
||||
"DatePicker": {
|
||||
"description": "Date picker calendar",
|
||||
"props": {
|
||||
"value": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["default", "range", "multiple"], "default": "default"},
|
||||
"defaultValue": {"type": "any"},
|
||||
"allowDeselect": {"type": "boolean", "default": false},
|
||||
"allowSingleDateInRange": {"type": "boolean", "default": false},
|
||||
"numberOfColumns": {"type": "integer", "default": 1},
|
||||
"columnsToScroll": {"type": "integer", "default": 1},
|
||||
"ariaLabels": {"type": "object"},
|
||||
"hideOutsideDates": {"type": "boolean", "default": false},
|
||||
"hideWeekdays": {"type": "boolean", "default": false},
|
||||
"weekendDays": {"type": "array", "default": [0, 6]},
|
||||
"renderDay": {"type": "any"},
|
||||
"minDate": {"type": "string"},
|
||||
"maxDate": {"type": "string"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"}
|
||||
}
|
||||
},
|
||||
"DatePickerInput": {
|
||||
"description": "Date picker input field",
|
||||
"props": {
|
||||
"value": {"type": "string"},
|
||||
"label": {"type": "any"},
|
||||
"description": {"type": "any"},
|
||||
"error": {"type": "any"},
|
||||
"placeholder": {"type": "string"},
|
||||
"clearable": {"type": "boolean", "default": false},
|
||||
"type": {"type": "string", "enum": ["default", "range", "multiple"], "default": "default"},
|
||||
"valueFormat": {"type": "string", "default": "MMMM D, YYYY"},
|
||||
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
|
||||
"disabled": {"type": "boolean", "default": false},
|
||||
"required": {"type": "boolean", "default": false},
|
||||
"minDate": {"type": "string"},
|
||||
"maxDate": {"type": "string"},
|
||||
"popoverProps": {"type": "object"},
|
||||
"dropdownType": {"type": "string", "enum": ["popover", "modal"], "default": "popover"}
|
||||
}
|
||||
},
|
||||
"DatesProvider": {
|
||||
"description": "Provider for date localization settings",
|
||||
"props": {
|
||||
"children": {"type": "any", "required": true},
|
||||
"settings": {"type": "object", "description": "Locale and formatting settings"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
mcp-servers/viz-platform/requirements.txt
Normal file
16
mcp-servers/viz-platform/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
# MCP SDK
|
||||
mcp>=0.9.0
|
||||
|
||||
# Visualization
|
||||
plotly>=5.18.0
|
||||
dash>=2.14.0
|
||||
dash-mantine-components>=2.0.0
|
||||
kaleido>=0.2.1 # For chart export (PNG, SVG, PDF)
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
pydantic>=2.5.0
|
||||
|
||||
# Testing
|
||||
pytest>=7.4.3
|
||||
pytest-asyncio>=0.23.0
|
||||
21
mcp-servers/viz-platform/run.sh
Executable file
21
mcp-servers/viz-platform/run.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# Capture original working directory before any cd operations
|
||||
# This should be the user's project directory when launched by Claude Code
|
||||
export CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CACHE_VENV="$HOME/.cache/claude-mcp-venvs/leo-claude-mktplace/viz-platform/.venv"
|
||||
LOCAL_VENV="$SCRIPT_DIR/.venv"
|
||||
|
||||
if [[ -f "$CACHE_VENV/bin/python" ]]; then
|
||||
PYTHON="$CACHE_VENV/bin/python"
|
||||
elif [[ -f "$LOCAL_VENV/bin/python" ]]; then
|
||||
PYTHON="$LOCAL_VENV/bin/python"
|
||||
else
|
||||
echo "ERROR: No venv found. Run: ./scripts/setup-venvs.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
export PYTHONPATH="$SCRIPT_DIR"
|
||||
exec "$PYTHON" -m mcp_server.server "$@"
|
||||
262
mcp-servers/viz-platform/scripts/generate-dmc-registry.py
Normal file
262
mcp-servers/viz-platform/scripts/generate-dmc-registry.py
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate DMC Component Registry from installed dash-mantine-components package.
|
||||
|
||||
This script introspects the installed DMC package and generates a JSON registry
|
||||
file containing component definitions, props, types, and defaults.
|
||||
|
||||
Usage:
|
||||
python generate-dmc-registry.py [--output registry/dmc_X_Y.json]
|
||||
|
||||
Requirements:
|
||||
- dash-mantine-components must be installed
|
||||
- Run from the mcp-servers/viz-platform directory
|
||||
"""
|
||||
import argparse
|
||||
import inspect
|
||||
import json
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, get_type_hints
|
||||
|
||||
|
||||
def get_dmc_version() -> Optional[str]:
|
||||
"""Get installed DMC version."""
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
return version('dash-mantine-components')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_component_categories() -> Dict[str, List[str]]:
|
||||
"""Define component categories."""
|
||||
return {
|
||||
"buttons": ["Button", "ActionIcon", "CopyButton", "FileButton", "UnstyledButton"],
|
||||
"inputs": [
|
||||
"TextInput", "PasswordInput", "NumberInput", "Textarea",
|
||||
"Select", "MultiSelect", "Checkbox", "Switch", "Radio",
|
||||
"Slider", "RangeSlider", "ColorInput", "ColorPicker",
|
||||
"DateInput", "DatePicker", "TimeInput"
|
||||
],
|
||||
"navigation": ["Anchor", "Breadcrumbs", "Burger", "NavLink", "Pagination", "Stepper", "Tabs"],
|
||||
"feedback": ["Alert", "Loader", "Notification", "Progress", "RingProgress", "Skeleton"],
|
||||
"overlays": ["Dialog", "Drawer", "HoverCard", "Menu", "Modal", "Popover", "Tooltip"],
|
||||
"typography": ["Blockquote", "Code", "Highlight", "Mark", "Text", "Title"],
|
||||
"layout": [
|
||||
"AppShell", "AspectRatio", "Center", "Container", "Flex",
|
||||
"Grid", "Group", "Paper", "SimpleGrid", "Space", "Stack"
|
||||
],
|
||||
"data": [
|
||||
"Accordion", "Avatar", "Badge", "Card", "Image",
|
||||
"Indicator", "Kbd", "Spoiler", "Table", "ThemeIcon", "Timeline"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def extract_prop_type(prop_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract prop type information from Dash component prop."""
|
||||
result = {"type": "any"}
|
||||
|
||||
if 'type' not in prop_info:
|
||||
return result
|
||||
|
||||
prop_type = prop_info['type']
|
||||
|
||||
if isinstance(prop_type, dict):
|
||||
type_name = prop_type.get('name', 'any')
|
||||
|
||||
# Map Dash types to JSON schema types
|
||||
type_mapping = {
|
||||
'string': 'string',
|
||||
'number': 'number',
|
||||
'bool': 'boolean',
|
||||
'boolean': 'boolean',
|
||||
'array': 'array',
|
||||
'object': 'object',
|
||||
'node': 'any',
|
||||
'element': 'any',
|
||||
'any': 'any',
|
||||
'func': 'any',
|
||||
}
|
||||
|
||||
result['type'] = type_mapping.get(type_name, 'any')
|
||||
|
||||
# Handle enums
|
||||
if type_name == 'enum' and 'value' in prop_type:
|
||||
values = prop_type['value']
|
||||
if isinstance(values, list):
|
||||
enum_values = []
|
||||
for v in values:
|
||||
if isinstance(v, dict) and 'value' in v:
|
||||
# Remove quotes from string values
|
||||
val = v['value'].strip("'\"")
|
||||
enum_values.append(val)
|
||||
elif isinstance(v, str):
|
||||
enum_values.append(v.strip("'\""))
|
||||
if enum_values:
|
||||
result['enum'] = enum_values
|
||||
result['type'] = 'string'
|
||||
|
||||
# Handle union types
|
||||
elif type_name == 'union' and 'value' in prop_type:
|
||||
# For unions, just mark as any for simplicity
|
||||
result['type'] = 'any'
|
||||
|
||||
elif isinstance(prop_type, str):
|
||||
result['type'] = prop_type
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def extract_component_props(component_class) -> Dict[str, Any]:
|
||||
"""Extract props from a Dash component class."""
|
||||
props = {}
|
||||
|
||||
# Try to get _prop_names or similar
|
||||
if hasattr(component_class, '_prop_names'):
|
||||
prop_names = component_class._prop_names
|
||||
else:
|
||||
prop_names = []
|
||||
|
||||
# Try to get _type attribute for prop definitions
|
||||
if hasattr(component_class, '_type'):
|
||||
prop_types = getattr(component_class, '_type', {})
|
||||
else:
|
||||
prop_types = {}
|
||||
|
||||
# Get default values
|
||||
if hasattr(component_class, '_default_props'):
|
||||
defaults = component_class._default_props
|
||||
else:
|
||||
defaults = {}
|
||||
|
||||
# Try to extract from _prop_descriptions
|
||||
if hasattr(component_class, '_prop_descriptions'):
|
||||
descriptions = component_class._prop_descriptions
|
||||
else:
|
||||
descriptions = {}
|
||||
|
||||
for prop_name in prop_names:
|
||||
if prop_name.startswith('_'):
|
||||
continue
|
||||
|
||||
prop_info = {}
|
||||
|
||||
# Get type info if available
|
||||
if prop_name in prop_types:
|
||||
prop_info = extract_prop_type({'type': prop_types[prop_name]})
|
||||
else:
|
||||
prop_info = {'type': 'any'}
|
||||
|
||||
# Add default if exists
|
||||
if prop_name in defaults:
|
||||
prop_info['default'] = defaults[prop_name]
|
||||
|
||||
# Add description if exists
|
||||
if prop_name in descriptions:
|
||||
prop_info['description'] = descriptions[prop_name]
|
||||
|
||||
props[prop_name] = prop_info
|
||||
|
||||
return props
|
||||
|
||||
|
||||
def generate_registry() -> Dict[str, Any]:
|
||||
"""Generate the component registry from installed DMC."""
|
||||
try:
|
||||
import dash_mantine_components as dmc
|
||||
except ImportError:
|
||||
print("ERROR: dash-mantine-components not installed")
|
||||
print("Install with: pip install dash-mantine-components")
|
||||
sys.exit(1)
|
||||
|
||||
version = get_dmc_version()
|
||||
categories = get_component_categories()
|
||||
|
||||
registry = {
|
||||
"version": version,
|
||||
"generated": date.today().isoformat(),
|
||||
"categories": categories,
|
||||
"components": {}
|
||||
}
|
||||
|
||||
# Get all components from categories
|
||||
all_components = set()
|
||||
for comp_list in categories.values():
|
||||
all_components.update(comp_list)
|
||||
|
||||
# Extract props for each component
|
||||
for comp_name in sorted(all_components):
|
||||
if hasattr(dmc, comp_name):
|
||||
comp_class = getattr(dmc, comp_name)
|
||||
try:
|
||||
props = extract_component_props(comp_class)
|
||||
if props:
|
||||
registry["components"][comp_name] = {
|
||||
"description": comp_class.__doc__ or f"{comp_name} component",
|
||||
"props": props
|
||||
}
|
||||
print(f" Extracted: {comp_name} ({len(props)} props)")
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to extract {comp_name}: {e}")
|
||||
else:
|
||||
print(f" Warning: Component not found: {comp_name}")
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate DMC component registry from installed package"
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output', '-o',
|
||||
type=str,
|
||||
help='Output file path (default: auto-generated based on version)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Print to stdout instead of writing file'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("Generating DMC Component Registry...")
|
||||
print("=" * 50)
|
||||
|
||||
registry = generate_registry()
|
||||
|
||||
print("=" * 50)
|
||||
print(f"Generated registry for DMC {registry['version']}")
|
||||
print(f"Total components: {len(registry['components'])}")
|
||||
|
||||
if args.dry_run:
|
||||
print(json.dumps(registry, indent=2))
|
||||
return
|
||||
|
||||
# Determine output path
|
||||
if args.output:
|
||||
output_path = Path(args.output)
|
||||
else:
|
||||
version = registry['version']
|
||||
if version:
|
||||
major_minor = '_'.join(version.split('.')[:2])
|
||||
output_path = Path(__file__).parent.parent / 'registry' / f'dmc_{major_minor}.json'
|
||||
else:
|
||||
output_path = Path(__file__).parent.parent / 'registry' / 'dmc_unknown.json'
|
||||
|
||||
# Create directory if needed
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write registry
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump(registry, indent=2, fp=f)
|
||||
|
||||
print(f"Registry written to: {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user