2 Commits

Author SHA1 Message Date
3227d2d618 chore: release v3.2.0
Version 3.2.0 includes:
- New features: netbox device params, claude-config-maintainer auto-enforce
- Bug fixes: cmdb-assistant schemas, netbox tool names, API URLs
- All hooks converted to command type
- Versioning workflow with release script

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 13:08:45 -05:00
1dfbffcf38 feat: add versioning workflow with release script
- Add scripts/release.sh for consistent version releases
- Fix CHANGELOG.md: consolidate all changes under [Unreleased]
- Update CLAUDE.md with comprehensive versioning documentation
- Include all commits since v3.1.1 in [Unreleased] section

The release script ensures version consistency across:
- Git tags
- README.md title
- marketplace.json version
- CHANGELOG.md sections

Addresses #143

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 13:08:06 -05:00
234 changed files with 401 additions and 28595 deletions

View File

@@ -6,12 +6,12 @@
}, },
"metadata": { "metadata": {
"description": "Project management plugins with Gitea and NetBox integrations", "description": "Project management plugins with Gitea and NetBox integrations",
"version": "5.3.0" "version": "3.2.0"
}, },
"plugins": [ "plugins": [
{ {
"name": "projman", "name": "projman",
"version": "3.3.0", "version": "3.1.0",
"description": "Sprint planning and project management with Gitea integration", "description": "Sprint planning and project management with Gitea integration",
"source": "./plugins/projman", "source": "./plugins/projman",
"author": { "author": {
@@ -27,7 +27,7 @@
}, },
{ {
"name": "doc-guardian", "name": "doc-guardian",
"version": "1.1.0", "version": "1.0.0",
"description": "Automatic documentation drift detection and synchronization", "description": "Automatic documentation drift detection and synchronization",
"source": "./plugins/doc-guardian", "source": "./plugins/doc-guardian",
"author": { "author": {
@@ -75,8 +75,8 @@
}, },
{ {
"name": "cmdb-assistant", "name": "cmdb-assistant",
"version": "1.2.0", "version": "1.0.0",
"description": "NetBox CMDB integration with data quality validation and machine registration", "description": "NetBox CMDB integration for infrastructure management",
"source": "./plugins/cmdb-assistant", "source": "./plugins/cmdb-assistant",
"author": { "author": {
"name": "Leo Miranda", "name": "Leo Miranda",
@@ -86,12 +86,12 @@
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git", "repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
"mcpServers": ["./.mcp.json"], "mcpServers": ["./.mcp.json"],
"category": "infrastructure", "category": "infrastructure",
"tags": ["cmdb", "netbox", "dcim", "ipam", "data-quality", "validation"], "tags": ["cmdb", "netbox", "dcim", "ipam"],
"license": "MIT" "license": "MIT"
}, },
{ {
"name": "claude-config-maintainer", "name": "claude-config-maintainer",
"version": "1.1.0", "version": "1.0.0",
"description": "CLAUDE.md optimization and maintenance for Claude Code projects", "description": "CLAUDE.md optimization and maintenance for Claude Code projects",
"source": "./plugins/claude-config-maintainer", "source": "./plugins/claude-config-maintainer",
"author": { "author": {
@@ -106,7 +106,7 @@
}, },
{ {
"name": "clarity-assist", "name": "clarity-assist",
"version": "1.2.0", "version": "1.0.0",
"description": "Prompt optimization and requirement clarification with ND-friendly accommodations", "description": "Prompt optimization and requirement clarification with ND-friendly accommodations",
"source": "./plugins/clarity-assist", "source": "./plugins/clarity-assist",
"author": { "author": {
@@ -121,7 +121,7 @@
}, },
{ {
"name": "git-flow", "name": "git-flow",
"version": "1.2.0", "version": "1.0.0",
"description": "Git workflow automation with intelligent commit messages and branch management", "description": "Git workflow automation with intelligent commit messages and branch management",
"source": "./plugins/git-flow", "source": "./plugins/git-flow",
"author": { "author": {
@@ -136,7 +136,7 @@
}, },
{ {
"name": "pr-review", "name": "pr-review",
"version": "1.1.0", "version": "1.0.0",
"description": "Multi-agent pull request review with confidence scoring and actionable feedback", "description": "Multi-agent pull request review with confidence scoring and actionable feedback",
"source": "./plugins/pr-review", "source": "./plugins/pr-review",
"author": { "author": {
@@ -149,54 +149,6 @@
"category": "development", "category": "development",
"tags": ["code-review", "pull-requests", "security", "quality"], "tags": ["code-review", "pull-requests", "security", "quality"],
"license": "MIT" "license": "MIT"
},
{
"name": "data-platform",
"version": "1.2.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.1.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.2.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
View File

@@ -31,8 +31,6 @@ venv/
ENV/ ENV/
env/ env/
.venv/ .venv/
.venv
**/.venv
# PyCharm # PyCharm
.idea/ .idea/

View File

@@ -6,335 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added *Changes staged for the next release*
#### Sprint 7: Multi-Model Agent Support
Configurable model selection for agents with inheritance chain.
**Model Configuration:**
- Agent-level `model` field in YAML frontmatter (opus|sonnet|haiku)
- Plugin-level `defaultModel` in plugin.json
- Inheritance: Agent → Plugin → System default (sonnet)
**Recommended Model Assignments:**
| Model | Use Case | Agents |
|-------|----------|--------|
| **Opus** | Complex reasoning, security analysis | planner, code-reviewer, security-reviewer, data-analysis |
| **Sonnet** | Implementation, coordination | orchestrator, executor, layout-builder, data-ingestion |
| **Haiku** | Quick validation | component-check, agent-check |
**Documentation:**
- `docs/MODEL-RECOMMENDATIONS.md` - Central model selection guide
- `docs/CONFIGURATION.md` - Added agent model configuration section
- `CLAUDE.md` - Added model config quick reference
**Agent Updates (7 files):**
- Opus: planner, code-reviewer (projman), security-reviewer (pr-review, code-sentinel), data-analysis
- Haiku: component-check (viz-platform), agent-check (contract-validator)
**Plugin Manifest Updates (6 files):**
- All plugins with agents now have `defaultModel: sonnet`
- Version bumps: projman 3.3.0, pr-review 1.1.0, data-platform 1.1.0, viz-platform 1.1.0, code-sentinel 1.0.1, contract-validator 1.1.0
**Validation:**
- `scripts/validate-marketplace.sh` - Added model field validation (v5.4.0+)
**Sprint Completed:**
- Milestone: Sprint 7 - Multi-Model Agent Support
- Issues: #302, #303, #304, #305, #306
- PRs: #307, #308
- Wiki: [Change V5.4.0: Multi-Model Support (Sprint 7 Implementation)](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/Change-V5.4.0%3A-Multi-Model-Support-%28Sprint-7-Implementation%29)
---
## [5.3.0] - 2026-01-28
### Added
#### Sprint 6: Visual Branding Overhaul
Consistent visual headers and progress tracking across all plugins.
**Visual Output Headers (109 files):**
- **Projman**: Double-line headers (╔═╗) with phase indicators (🎯 PLANNING, ⚡ EXECUTION, 🏁 CLOSING)
- **Other Plugins**: Single-line headers (┌─┐) with plugin icons
- **All 23 agents** updated with Visual Output Requirements section
- **All 86 commands** updated with Visual Output section and header templates
**Plugin Icon Registry:**
| Plugin | Icon |
|--------|------|
| projman | 📋 |
| code-sentinel | 🔒 |
| doc-guardian | 📝 |
| pr-review | 🔍 |
| clarity-assist | 💬 |
| git-flow | 🔀 |
| cmdb-assistant | 🖥️ |
| data-platform | 📊 |
| viz-platform | 🎨 |
| contract-validator | ✅ |
| claude-config-maintainer | ⚙️ |
**Wiki Branding Specification (4 pages):**
- [branding/visual-spec](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/branding%2Fvisual-spec) - Central specification
- [branding/plugin-registry](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/branding%2Fplugin-registry) - Icons and styles
- [branding/header-templates](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/branding%2Fheader-templates) - Copy-paste templates
- [branding/progress-templates](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/branding%2Fprogress-templates) - Sprint progress blocks
### Fixed
- **Docs:** Version sync - CLAUDE.md, marketplace.json, README.md now consistent
- **Docs:** Added 18 missing commands from Sprint 4 & 5 to README.md and COMMANDS-CHEATSHEET.md
- **MCP:** Registered `/sprint-diagram` as invokable skill
**Sprint Completed:**
- Milestone: Sprint 6 - Visual Branding Overhaul (closed 2026-01-28)
- Issues: #272, #273, #274, #275, #276, #277, #278
- PRs: #284, #285
- Wiki: [Sprint 6 Lessons](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/lessons/sprints/sprint-6---visual-branding-and-documentation-maintenance)
---
## [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
--- ---

170
CLAUDE.md
View File

@@ -1,44 +1,43 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude 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
## ⛔ RULES - READ FIRST **These rules are NON-NEGOTIABLE. Violating them wastes the user's time and money.**
### Behavioral Rules ### 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**
| Rule | Summary | ### 2. WHEN USER SAYS SOMETHING IS WRONG - BELIEVE THEM
|------|---------| - The user knows their system better than you
| **Check everything** | Search cache (`~/.claude/plugins/cache/`), installed (`~/.claude/plugins/marketplaces/`), and source (`~/claude-plugins-work/`) | - Investigate thoroughly before disagreeing
| **Believe the user** | User knows their system. Investigate before disagreeing. | - If user suspects cache, CHECK THE CACHE
| **Verify before "done"** | Run commands, show output, check all locations. "Done" = verified working. | - If user suspects a file, READ THE FILE
| **Show what's asked** | Don't interpret or summarize unless asked. | - **Your confidence is often wrong. User's instincts are often right.**
### After Plugin Updates ### 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"**
Run `./scripts/verify-hooks.sh`. If changes affect MCP servers or hooks, inform user to restart session. ### 4. SHOW EXACTLY WHAT USER ASKS FOR
**DO NOT clear cache mid-session** - breaks loaded MCP tools. - 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**
### NEVER USE CLI TOOLS FOR EXTERNAL SERVICES ### 5. AFTER PLUGIN UPDATES - ALWAYS CLEAR CACHE
- **FORBIDDEN:** `gh`, `tea`, `curl` to APIs, any CLI that talks to Gitea/GitHub/external services ```bash
- **REQUIRED:** Use MCP tools exclusively (`mcp__plugin_projman_gitea__*`, `mcp__plugin_pr-review_gitea__*`) rm -rf ~/.claude/plugins/cache/leo-claude-mktplace/
- **NO EXCEPTIONS.** Don't try CLI first. Don't fall back to CLI. MCP ONLY. ./scripts/verify-hooks.sh
```
### NEVER PUSH DIRECTLY TO PROTECTED BRANCHES **FAILURE TO FOLLOW THESE RULES = WASTED USER TIME = UNACCEPTABLE**
- **FORBIDDEN:** `git push origin development`, `git push origin main`, `git push origin master`
- **REQUIRED:** Create feature branch → push feature branch → create PR via MCP
- If you accidentally commit to a protected branch locally: `git checkout -b fix/branch-name` then reset the protected branch
### Repository Rules
| Rule | Details |
|------|---------|
| **File creation** | Only in allowed paths. Use `.scratch/` for temp work. Verify against `docs/CANONICAL-PATHS.md` |
| **plugin.json location** | Must be in `.claude-plugin/` directory |
| **Hooks** | Use `hooks/hooks.json` (auto-discovered). Never inline in plugin.json |
| **MCP servers** | Shared at root with symlinks. Use MCP tools, never CLI (`tea`, `gh`) |
| **Allowed root files** | `CLAUDE.md`, `README.md`, `LICENSE`, `CHANGELOG.md`, `.gitignore`, `.env.example` |
**Valid hook events:** `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `SessionEnd`, `Notification`, `Stop`, `SubagentStop`, `PreCompact`
--- ---
@@ -46,24 +45,21 @@ Run `./scripts/verify-hooks.sh`. If changes affect MCP servers or hooks, inform
## Project Overview ## Project Overview
**Repository:** leo-claude-mktplace **Repository:** leo-claude-mktplace
**Version:** 5.3.0 **Version:** 3.1.2
**Status:** Production Ready **Status:** Production Ready
A plugin marketplace for Claude Code containing: A plugin marketplace for Claude Code containing:
| Plugin | Description | Version | | Plugin | Description | Version |
|--------|-------------|---------| |--------|-------------|---------|
| `projman` | Sprint planning and project management with Gitea integration | 3.3.0 | | `projman` | Sprint planning and project management with Gitea integration | 3.1.0 |
| `git-flow` | Git workflow automation with smart commits and branch management | 1.0.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.1.0 | | `pr-review` | Multi-agent PR review with confidence scoring | 1.0.0 |
| `clarity-assist` | Prompt optimization with ND-friendly accommodations | 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 | | `doc-guardian` | Automatic documentation drift detection and synchronization | 1.0.0 |
| `code-sentinel` | Security scanning and code refactoring tools | 1.0.1 | | `code-sentinel` | Security scanning and code refactoring tools | 1.0.0 |
| `claude-config-maintainer` | CLAUDE.md optimization and maintenance | 1.0.0 | | `claude-config-maintainer` | CLAUDE.md optimization and maintenance | 1.0.0 |
| `cmdb-assistant` | NetBox CMDB integration for infrastructure management | 1.2.0 | | `cmdb-assistant` | NetBox CMDB integration for infrastructure management | 1.0.0 |
| `data-platform` | pandas, PostgreSQL, and dbt integration for data engineering | 1.1.0 |
| `viz-platform` | DMC validation, Plotly charts, and theming for dashboards | 1.1.0 |
| `contract-validator` | Cross-plugin compatibility validation and agent verification | 1.1.0 |
| `project-hygiene` | Post-task cleanup automation via hooks | 0.1.0 | | `project-hygiene` | Post-task cleanup automation via hooks | 0.1.0 |
## Quick Start ## Quick Start
@@ -81,17 +77,12 @@ A plugin marketplace for Claude Code containing:
| Category | Commands | | Category | Commands |
|----------|----------| |----------|----------|
| **Setup** | `/initial-setup`, `/project-init`, `/project-sync` | | **Setup** | `/initial-setup`, `/project-init`, `/project-sync` |
| **Sprint** | `/sprint-plan`, `/sprint-start`, `/sprint-status`, `/sprint-close`, `/sprint-diagram` | | **Sprint** | `/sprint-plan`, `/sprint-start`, `/sprint-status`, `/sprint-close` |
| **Quality** | `/review`, `/test-check`, `/test-gen` | | **Quality** | `/review`, `/test-check`, `/test-gen` |
| **Versioning** | `/suggest-version` | | **PR Review** | `/pr-review:initial-setup`, `/pr-review:project-init` |
| **PR Review** | `/pr-review`, `/pr-summary`, `/pr-findings`, `/pr-diff` | | **Docs** | `/doc-audit`, `/doc-sync` |
| **Docs** | `/doc-audit`, `/doc-sync`, `/changelog-gen`, `/doc-coverage`, `/stale-docs` |
| **Security** | `/security-scan`, `/refactor`, `/refactor-dry` | | **Security** | `/security-scan`, `/refactor`, `/refactor-dry` |
| **Config** | `/config-analyze`, `/config-optimize`, `/config-diff`, `/config-lint` | | **Config** | `/config-analyze`, `/config-optimize` |
| **Data** | `/ingest`, `/profile`, `/schema`, `/explain`, `/lineage`, `/lineage-viz`, `/run`, `/dbt-test`, `/data-quality` |
| **Visualization** | `/component`, `/chart`, `/chart-export`, `/dashboard`, `/theme`, `/theme-new`, `/theme-css`, `/accessibility-check`, `/breakpoints` |
| **Validation** | `/validate-contracts`, `/check-agent`, `/list-interfaces`, `/dependency-graph` |
| **CMDB** | `/cmdb-search`, `/cmdb-device`, `/cmdb-ip`, `/cmdb-site`, `/cmdb-audit`, `/cmdb-register`, `/cmdb-sync`, `/cmdb-topology`, `/change-audit`, `/ip-conflicts` |
| **Debug** | `/debug-report`, `/debug-review` | | **Debug** | `/debug-report`, `/debug-review` |
## Repository Structure ## Repository Structure
@@ -102,16 +93,14 @@ leo-claude-mktplace/
│ └── marketplace.json # Marketplace manifest │ └── marketplace.json # Marketplace manifest
├── mcp-servers/ # SHARED MCP servers (v3.0.0+) ├── mcp-servers/ # SHARED MCP servers (v3.0.0+)
│ ├── gitea/ # Gitea MCP (issues, PRs, wiki) │ ├── gitea/ # Gitea MCP (issues, PRs, wiki)
── netbox/ # NetBox MCP (CMDB) ── netbox/ # NetBox MCP (CMDB)
│ ├── data-platform/ # pandas, PostgreSQL, dbt
│ └── viz-platform/ # DMC validation, charts, themes
├── plugins/ ├── plugins/
│ ├── projman/ # Sprint management │ ├── projman/ # Sprint management
│ │ ├── .claude-plugin/plugin.json │ │ ├── .claude-plugin/plugin.json
│ │ ├── .mcp.json │ │ ├── .mcp.json
│ │ ├── mcp-servers/gitea -> ../../../mcp-servers/gitea # SYMLINK │ │ ├── mcp-servers/gitea -> ../../../mcp-servers/gitea # SYMLINK
│ │ ├── commands/ # 14 commands (incl. setup, debug, suggest-version) │ │ ├── commands/ # 13 commands (incl. setup, debug)
│ │ ├── hooks/ # SessionStart: mismatch detection + sprint suggestions │ │ ├── hooks/ # SessionStart mismatch detection
│ │ ├── agents/ # 4 agents │ │ ├── agents/ # 4 agents
│ │ └── skills/label-taxonomy/ │ │ └── skills/label-taxonomy/
│ ├── git-flow/ # Git workflow automation │ ├── git-flow/ # Git workflow automation
@@ -125,24 +114,10 @@ leo-claude-mktplace/
│ │ ├── commands/ # 6 commands (incl. setup) │ │ ├── commands/ # 6 commands (incl. setup)
│ │ ├── hooks/ # SessionStart mismatch detection │ │ ├── hooks/ # SessionStart mismatch detection
│ │ └── agents/ # 5 agents │ │ └── agents/ # 5 agents
│ ├── clarity-assist/ # Prompt optimization │ ├── clarity-assist/ # Prompt optimization (NEW v3.0.0)
│ │ ├── .claude-plugin/plugin.json │ │ ├── .claude-plugin/plugin.json
│ │ ├── commands/ # 2 commands │ │ ├── commands/ # 2 commands
│ │ └── agents/ │ │ └── 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 │ ├── doc-guardian/ # Documentation drift detection
│ ├── code-sentinel/ # Security scanning & refactoring │ ├── code-sentinel/ # Security scanning & refactoring
│ ├── claude-config-maintainer/ │ ├── claude-config-maintainer/
@@ -150,14 +125,38 @@ leo-claude-mktplace/
│ └── project-hygiene/ │ └── project-hygiene/
├── scripts/ ├── scripts/
│ ├── setup.sh, post-update.sh │ ├── setup.sh, post-update.sh
── validate-marketplace.sh # Marketplace compliance validation ── validate-marketplace.sh # Marketplace compliance validation
│ ├── verify-hooks.sh # Verify all hooks are command type
│ └── check-venv.sh # Check MCP server venvs exist
└── docs/ └── docs/
├── CANONICAL-PATHS.md # Single source of truth for paths ├── CANONICAL-PATHS.md # Single source of truth for paths
└── CONFIGURATION.md # Centralized configuration guide └── CONFIGURATION.md # Centralized configuration guide
``` ```
## CRITICAL: Rules You MUST Follow
### 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 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 (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 ## Architecture
### Four-Agent Model (projman) ### Four-Agent Model (projman)
@@ -173,12 +172,12 @@ leo-claude-mktplace/
| Category | Tools | | Category | Tools |
|----------|-------| |----------|-------|
| Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `add_comment`, `aggregate_issues` | | Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `add_comment` |
| Labels | `get_labels`, `suggest_labels`, `create_label`, `create_label_smart` | | Labels | `get_labels`, `suggest_labels`, `create_label` |
| Milestones | `list_milestones`, `get_milestone`, `create_milestone`, `update_milestone`, `delete_milestone` | | Milestones | `list_milestones`, `get_milestone`, `create_milestone`, `update_milestone` |
| Dependencies | `list_issue_dependencies`, `create_issue_dependency`, `remove_issue_dependency`, `get_execution_order` | | Dependencies | `list_issue_dependencies`, `create_issue_dependency`, `get_execution_order` |
| Wiki | `list_wiki_pages`, `get_wiki_page`, `create_wiki_page`, `update_wiki_page`, `create_lesson`, `search_lessons` | | Wiki | `list_wiki_pages`, `get_wiki_page`, `create_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` | | **Pull Requests** | `list_pull_requests`, `get_pull_request`, `get_pr_diff`, `get_pr_comments`, `create_pr_review`, `add_pr_comment` *(NEW v3.0.0)* |
| Validation | `validate_repo_org`, `get_branch_protection` | | Validation | `validate_repo_org`, `get_branch_protection` |
### Hybrid Configuration ### Hybrid Configuration
@@ -190,21 +189,6 @@ leo-claude-mktplace/
**Note:** `GITEA_ORG` is at project level since different projects may belong to different organizations. **Note:** `GITEA_ORG` is at project level since different projects may belong to different organizations.
### Agent Model Configuration
Agents can specify preferred Claude models for cost/performance optimization:
| Model | Use For | Agents |
|-------|---------|--------|
| `opus` | Complex reasoning, security | planner, code-reviewer, security-reviewer |
| `sonnet` | Implementation, coordination | orchestrator, executor, most agents |
| `haiku` | Simple validation | component-check, agent-check |
**Configuration:** Add `model: opus|sonnet|haiku` to agent frontmatter, or `defaultModel` to plugin.json.
**Inheritance:** Agent → Plugin default → System default (sonnet)
See `docs/MODEL-RECOMMENDATIONS.md` for detailed guidance.
### Branch-Aware Security ### Branch-Aware Security
| Branch Pattern | Mode | Capabilities | | Branch Pattern | Mode | Capabilities |
@@ -354,4 +338,4 @@ The script will:
--- ---
**Last Updated:** 2026-01-28 **Last Updated:** 2026-01-24

106
README.md
View File

@@ -1,4 +1,4 @@
# Leo Claude Marketplace - v5.3.0 # Leo Claude Marketplace - v3.2.0
A collection of Claude Code plugins for project management, infrastructure automation, and development workflows. A collection of Claude Code plugins for project management, infrastructure automation, and development workflows.
@@ -19,7 +19,7 @@ AI-guided sprint planning with full Gitea integration. Transforms a proven 15-sp
- Branch-aware security (development/staging/production) - Branch-aware security (development/staging/production)
- Pre-sprint-close code quality review and test verification - Pre-sprint-close code quality review and test verification
**Commands:** `/sprint-plan`, `/sprint-start`, `/sprint-status`, `/sprint-close`, `/sprint-diagram`, `/labels-sync`, `/initial-setup`, `/project-init`, `/project-sync`, `/review`, `/test-check`, `/test-gen`, `/debug-report`, `/debug-review`, `/suggest-version`, `/proposal-status` **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`
#### [git-flow](./plugins/git-flow/README.md) *NEW in v3.0.0* #### [git-flow](./plugins/git-flow/README.md) *NEW in v3.0.0*
**Git Workflow Automation** **Git Workflow Automation**
@@ -44,27 +44,14 @@ Comprehensive pull request review using specialized agents.
- Actionable feedback with suggested fixes - Actionable feedback with suggested fixes
- Gitea integration for automated review submission - Gitea integration for automated review submission
**Commands:** `/pr-review`, `/pr-summary`, `/pr-findings`, `/pr-diff`, `/initial-setup`, `/project-init`, `/project-sync` **Commands:** `/pr-review`, `/pr-summary`, `/pr-findings`, `/initial-setup`, `/project-init`, `/project-sync`
#### [claude-config-maintainer](./plugins/claude-config-maintainer/README.md) #### [claude-config-maintainer](./plugins/claude-config-maintainer/README.md)
**CLAUDE.md Optimization and Maintenance** **CLAUDE.md Optimization and Maintenance**
Analyze, optimize, and create CLAUDE.md configuration files for Claude Code projects. Analyze, optimize, and create CLAUDE.md configuration files for Claude Code projects.
**Commands:** `/config-analyze`, `/config-optimize`, `/config-init`, `/config-diff`, `/config-lint` **Commands:** `/config-analyze`, `/config-optimize`, `/config-init`
#### [contract-validator](./plugins/contract-validator/README.md) *NEW in v5.0.0*
**Cross-Plugin Compatibility Validation**
Validate plugin marketplaces for command conflicts, tool overlaps, and broken agent references.
- 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:** `/validate-contracts`, `/check-agent`, `/list-interfaces`, `/dependency-graph`, `/initial-setup`
### Productivity ### Productivity
@@ -84,7 +71,7 @@ Transform vague requests into clear specifications using structured methodology.
Automatic documentation drift detection and synchronization. Automatic documentation drift detection and synchronization.
**Commands:** `/doc-audit`, `/doc-sync`, `/changelog-gen`, `/doc-coverage`, `/stale-docs` **Commands:** `/doc-audit`, `/doc-sync`
#### [project-hygiene](./plugins/project-hygiene/README.md) #### [project-hygiene](./plugins/project-hygiene/README.md)
**Post-Task Cleanup Automation** **Post-Task Cleanup Automation**
@@ -107,38 +94,7 @@ Security vulnerability detection and code refactoring tools.
Full CRUD operations for network infrastructure management directly from Claude Code. 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`, `/cmdb-topology`, `/change-audit`, `/ip-conflicts` **Commands:** `/initial-setup`, `/cmdb-search`, `/cmdb-device`, `/cmdb-ip`, `/cmdb-site`
### 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`, `/lineage-viz`, `/run`, `/dbt-test`, `/data-quality`, `/initial-setup`
### 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`, `/chart-export`, `/dashboard`, `/theme`, `/theme-new`, `/theme-css`, `/component`, `/accessibility-check`, `/breakpoints`, `/initial-setup`
## MCP Servers ## MCP Servers
@@ -170,39 +126,6 @@ Comprehensive NetBox REST API integration for infrastructure management.
| Virtualization | Clusters, VMs, Interfaces | | Virtualization | Clusters, VMs, Interfaces |
| Extras | Tags, Custom Fields, Audit Log | | 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 ## Installation
### Prerequisites ### Prerequisites
@@ -299,9 +222,6 @@ After installing plugins, the `/plugin` command may show `(no content)` - this i
| code-sentinel | `/code-sentinel:security-scan` | | code-sentinel | `/code-sentinel:security-scan` |
| claude-config-maintainer | `/claude-config-maintainer:config-analyze` | | claude-config-maintainer | `/claude-config-maintainer:config-analyze` |
| cmdb-assistant | `/cmdb-assistant:cmdb-search` | | cmdb-assistant | `/cmdb-assistant:cmdb-search` |
| data-platform | `/data-platform:ingest` |
| viz-platform | `/viz-platform:chart` |
| contract-validator | `/contract-validator:validate-contracts` |
## Repository Structure ## Repository Structure
@@ -311,18 +231,12 @@ leo-claude-mktplace/
│ └── marketplace.json │ └── marketplace.json
├── mcp-servers/ # SHARED MCP servers (v3.0.0+) ├── mcp-servers/ # SHARED MCP servers (v3.0.0+)
│ ├── gitea/ # Gitea MCP (issues, PRs, wiki) │ ├── gitea/ # Gitea MCP (issues, PRs, wiki)
── netbox/ # NetBox MCP (CMDB) ── 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 ├── plugins/ # All plugins
│ ├── projman/ # Sprint management │ ├── projman/ # Sprint management
│ ├── git-flow/ # Git workflow automation │ ├── git-flow/ # Git workflow automation (NEW)
│ ├── pr-review/ # PR review │ ├── pr-review/ # PR review (NEW)
│ ├── clarity-assist/ # Prompt optimization │ ├── clarity-assist/ # Prompt optimization (NEW)
│ ├── data-platform/ # Data engineering
│ ├── viz-platform/ # Visualization
│ ├── contract-validator/ # Cross-plugin validation (NEW)
│ ├── claude-config-maintainer/ # CLAUDE.md optimization │ ├── claude-config-maintainer/ # CLAUDE.md optimization
│ ├── cmdb-assistant/ # NetBox CMDB integration │ ├── cmdb-assistant/ # NetBox CMDB integration
│ ├── doc-guardian/ # Documentation drift detection │ ├── doc-guardian/ # Documentation drift detection

View File

@@ -2,7 +2,7 @@
**This file defines ALL valid paths in this repository. No exceptions. No inference. No assumptions.** **This file defines ALL valid paths in this repository. No exceptions. No inference. No assumptions.**
Last Updated: 2026-01-27 (v5.1.0) Last Updated: 2026-01-23 (v3.1.2)
--- ---
@@ -37,40 +37,8 @@ leo-claude-mktplace/
│ │ │ └── pull_requests.py # NEW in v3.0.0 │ │ │ └── pull_requests.py # NEW in v3.0.0
│ │ ├── requirements.txt │ │ ├── requirements.txt
│ │ └── .venv/ │ │ └── .venv/
── netbox/ # NetBox MCP server ── 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/ │ ├── 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 │ ├── requirements.txt
│ └── .venv/ │ └── .venv/
├── plugins/ # ALL plugins ├── plugins/ # ALL plugins
@@ -126,50 +94,20 @@ leo-claude-mktplace/
│ │ ├── agents/ │ │ ├── agents/
│ │ ├── skills/ │ │ ├── skills/
│ │ └── claude-md-integration.md │ │ └── claude-md-integration.md
── pr-review/ # NEW in v3.0.0 ── 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/ │ ├── .claude-plugin/
│ ├── .mcp.json │ ├── .mcp.json
│ ├── mcp-servers/ │ ├── mcp-servers/
│ │ └── viz-platform -> ../../../mcp-servers/viz-platform # SYMLINK │ │ └── gitea -> ../../../mcp-servers/gitea # SYMLINK
│ ├── commands/ │ ├── commands/
│ ├── agents/ │ ├── agents/
│ ├── hooks/ │ ├── skills/
│ └── claude-md-integration.md │ └── claude-md-integration.md
├── scripts/ # Setup and maintenance scripts ├── scripts/ # Setup and maintenance scripts
│ ├── setup.sh # Initial setup (create venvs, config templates) │ ├── setup.sh # Initial setup (create venvs, config templates)
│ ├── post-update.sh # Post-update (rebuild venvs, verify symlinks) │ ├── post-update.sh # Post-update (rebuild venvs, verify symlinks)
│ ├── check-venv.sh # Check if venvs exist (for hooks) │ ├── check-venv.sh # Check if venvs exist (for hooks)
── validate-marketplace.sh # Marketplace compliance validation ── 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 ├── CLAUDE.md
├── README.md ├── README.md
├── LICENSE ├── LICENSE
@@ -288,9 +226,6 @@ MCP servers are now **shared at repository root** with **symlinks** from plugins
plugins/projman/mcp-servers/gitea -> ../../../mcp-servers/gitea plugins/projman/mcp-servers/gitea -> ../../../mcp-servers/gitea
plugins/cmdb-assistant/mcp-servers/netbox -> ../../../mcp-servers/netbox plugins/cmdb-assistant/mcp-servers/netbox -> ../../../mcp-servers/netbox
plugins/pr-review/mcp-servers/gitea -> ../../../mcp-servers/gitea 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
``` ```
--- ---
@@ -299,9 +234,6 @@ plugins/contract-validator/mcp-servers/contract-validator -> ../../../mcp-server
| Date | Change | By | | Date | Change | By |
|------|--------|-----| |------|--------|-----|
| 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: 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 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: Added docs/CONFIGURATION.md | Claude Code |

View File

@@ -22,9 +22,6 @@ Quick reference for all commands in the Leo Claude Marketplace.
| **projman** | `/test-gen` | | X | Generate comprehensive tests for specified code | | **projman** | `/test-gen` | | X | Generate comprehensive tests for specified code |
| **projman** | `/debug-report` | | X | Run diagnostics and create structured issue in marketplace | | **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** | `/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 |
| **projman** | `/sprint-diagram` | | X | Generate Mermaid diagram of sprint issues with dependencies |
| **git-flow** | `/commit` | | X | Create commit with auto-generated conventional message | | **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-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-merge` | | X | Commit current changes, then merge into target branch |
@@ -40,14 +37,10 @@ Quick reference for all commands in the Leo Claude Marketplace.
| **pr-review** | `/pr-review` | | X | Full multi-agent PR review with confidence scoring | | **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-summary` | | X | Quick summary of PR changes |
| **pr-review** | `/pr-findings` | | X | List and filter review findings by category/severity | | **pr-review** | `/pr-findings` | | X | List and filter review findings by category/severity |
| **pr-review** | `/pr-diff` | | X | Formatted diff with inline review comments and annotations |
| **clarity-assist** | `/clarify` | | X | Full 4-D prompt optimization with ND accommodations | | **clarity-assist** | `/clarify` | | X | Full 4-D prompt optimization with ND accommodations |
| **clarity-assist** | `/quick-clarify` | | X | Rapid single-pass clarification for simple requests | | **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-audit` | | X | Full documentation audit - scans for doc drift |
| **doc-guardian** | `/doc-sync` | | X | Synchronize pending documentation updates | | **doc-guardian** | `/doc-sync` | | X | Synchronize pending documentation updates |
| **doc-guardian** | `/changelog-gen` | | X | Generate changelog from conventional commits |
| **doc-guardian** | `/doc-coverage` | | X | Documentation coverage metrics by function/class |
| **doc-guardian** | `/stale-docs` | | X | Flag documentation behind code changes |
| **doc-guardian** | *PostToolUse hook* | X | | Silently detects doc drift on Write/Edit | | **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** | `/security-scan` | | X | Full security audit (SQL injection, XSS, secrets, etc.) |
| **code-sentinel** | `/refactor` | | X | Apply refactoring patterns to improve code | | **code-sentinel** | `/refactor` | | X | Apply refactoring patterns to improve code |
@@ -56,47 +49,12 @@ Quick reference for all commands in the Leo Claude Marketplace.
| **claude-config-maintainer** | `/config-analyze` | | X | Analyze CLAUDE.md for optimization opportunities | | **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-optimize` | | X | Optimize CLAUDE.md structure with preview/backup |
| **claude-config-maintainer** | `/config-init` | | X | Initialize new CLAUDE.md for a project | | **claude-config-maintainer** | `/config-init` | | X | Initialize new CLAUDE.md for a project |
| **claude-config-maintainer** | `/config-diff` | | X | Track CLAUDE.md changes over time with behavioral impact |
| **claude-config-maintainer** | `/config-lint` | | X | Lint CLAUDE.md for anti-patterns and best practices |
| **cmdb-assistant** | `/initial-setup` | | X | Setup wizard for NetBox MCP server | | **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-search` | | X | Search NetBox for devices, IPs, sites |
| **cmdb-assistant** | `/cmdb-device` | | X | Manage network devices (create, view, update, delete) | | **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-ip` | | X | Manage IP addresses and prefixes |
| **cmdb-assistant** | `/cmdb-site` | | X | Manage sites, locations, racks, and regions | | **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) |
| **cmdb-assistant** | `/cmdb-topology` | | X | Infrastructure topology diagrams (rack, network, site views) |
| **cmdb-assistant** | `/change-audit` | | X | NetBox audit trail queries with filtering |
| **cmdb-assistant** | `/ip-conflicts` | | X | Detect IP conflicts and overlapping prefixes |
| **project-hygiene** | *PostToolUse hook* | X | | Removes temp files, warns about unexpected root files | | **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** | `/lineage-viz` | | X | dbt lineage visualization as Mermaid diagrams |
| **data-platform** | `/dbt-test` | | X | Formatted dbt test runner with summary and failure details |
| **data-platform** | `/data-quality` | | X | DataFrame quality checks (nulls, duplicates, types, outliers) |
| **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** | `/chart-export` | | X | Export charts to PNG, SVG, PDF via kaleido |
| **viz-platform** | `/accessibility-check` | | X | Color blind validation (WCAG contrast ratios) |
| **viz-platform** | `/breakpoints` | | X | Configure responsive layout breakpoints |
| **viz-platform** | *SessionStart hook* | X | | Checks DMC version (non-blocking warning) |
| **contract-validator** | `/validate-contracts` | | X | Full marketplace compatibility validation |
| **contract-validator** | `/check-agent` | | X | Validate single agent definition |
| **contract-validator** | `/list-interfaces` | | X | Show all plugin interfaces |
| **contract-validator** | `/dependency-graph` | | X | Mermaid visualization of plugin dependencies |
| **contract-validator** | `/initial-setup` | | X | Setup wizard for contract-validator MCP |
--- ---
@@ -104,15 +62,12 @@ Quick reference for all commands in the Leo Claude Marketplace.
| Category | Plugins | Primary Use | | Category | Plugins | Primary Use |
|----------|---------|-------------| |----------|---------|-------------|
| **Setup** | projman, pr-review, cmdb-assistant, data-platform | `/initial-setup`, `/project-init` | | **Setup** | projman, pr-review, cmdb-assistant | `/initial-setup`, `/project-init` |
| **Task Planning** | projman, clarity-assist | Sprint management, requirement clarification | | **Task Planning** | projman, clarity-assist | Sprint management, requirement clarification |
| **Code Quality** | code-sentinel, pr-review | Security scanning, PR reviews | | **Code Quality** | code-sentinel, pr-review | Security scanning, PR reviews |
| **Documentation** | doc-guardian, claude-config-maintainer | Doc sync, CLAUDE.md maintenance | | **Documentation** | doc-guardian, claude-config-maintainer | Doc sync, CLAUDE.md maintenance |
| **Git Operations** | git-flow | Commits, branches, workflow automation | | **Git Operations** | git-flow | Commits, branches, workflow automation |
| **Infrastructure** | cmdb-assistant | NetBox CMDB management | | **Infrastructure** | cmdb-assistant | NetBox CMDB management |
| **Data Engineering** | data-platform | pandas, PostgreSQL, dbt operations |
| **Visualization** | viz-platform | DMC validation, Plotly charts, theming |
| **Validation** | contract-validator | Cross-plugin compatibility checks |
| **Maintenance** | project-hygiene | Automatic cleanup | | **Maintenance** | project-hygiene | Automatic cleanup |
--- ---
@@ -121,13 +76,11 @@ Quick reference for all commands in the Leo Claude Marketplace.
| Plugin | Hook Event | Behavior | | Plugin | Hook Event | Behavior |
|--------|------------|----------| |--------|------------|----------|
| **projman** | SessionStart | Checks git remote vs .env; warns if mismatch detected; suggests sprint planning if issues exist | | **projman** | SessionStart | Checks git remote vs .env; warns if mismatch detected |
| **pr-review** | SessionStart | Checks git remote vs .env; warns if mismatch detected | | **pr-review** | SessionStart | Checks git remote vs .env; warns if mismatch detected |
| **doc-guardian** | PostToolUse (Write/Edit) | Tracks documentation drift; auto-updates dependent docs | | **doc-guardian** | PostToolUse (Write/Edit) | Silently tracks documentation drift |
| **code-sentinel** | PreToolUse (Write/Edit) | Scans for security issues; blocks critical vulnerabilities | | **code-sentinel** | PreToolUse (Write/Edit) | Scans for security issues; blocks critical vulnerabilities |
| **project-hygiene** | PostToolUse (Write/Edit) | Cleans temp files, warns about misplaced files | | **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 |
--- ---
@@ -209,19 +162,6 @@ Managing infrastructure with CMDB:
4. /cmdb-site view Y # Check site info 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) ### Example 7: First-Time Setup (New Machine)
Setting up the marketplace for the first time: Setting up the marketplace for the first time:
@@ -269,12 +209,9 @@ Some plugins require MCP server connectivity:
| projman | Gitea | Issues, PRs, wiki, labels, milestones | | projman | Gitea | Issues, PRs, wiki, labels, milestones |
| pr-review | Gitea | PR operations and reviews | | pr-review | Gitea | PR operations and reviews |
| cmdb-assistant | NetBox | Infrastructure CMDB | | 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 |
| contract-validator | contract-validator | Plugin interface parsing, compatibility validation |
Ensure credentials are configured in `~/.config/claude/gitea.env`, `~/.config/claude/netbox.env`, or `~/.config/claude/postgres.env`. Ensure credentials are configured in `~/.config/claude/gitea.env` or `~/.config/claude/netbox.env`.
--- ---
*Last Updated: 2026-01-28* *Last Updated: 2026-01-22*

View File

@@ -171,7 +171,8 @@ This marketplace uses a **hybrid configuration** approach:
│ PROJECT-LEVEL (once per project) │ │ PROJECT-LEVEL (once per project) │
│ <project-root>/.env │ │ <project-root>/.env │
├─────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────┤
│ GITEA_REPORepository as owner/repo format │ GITEA_ORG Organization for this project
│ GITEA_REPO │ Repository name for this project │
│ GIT_WORKFLOW_STYLE │ (optional) Override system default │ │ GIT_WORKFLOW_STYLE │ (optional) Override system default │
│ PR_REVIEW_* │ (optional) PR review settings │ │ PR_REVIEW_* │ (optional) PR review settings │
└─────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────┘
@@ -261,7 +262,8 @@ In each project root:
```bash ```bash
cat > .env << 'EOF' cat > .env << 'EOF'
GITEA_REPO=your-organization/your-repo-name GITEA_ORG=your-organization
GITEA_REPO=your-repo-name
EOF EOF
``` ```
@@ -305,7 +307,7 @@ GITEA_API_TOKEN=your_gitea_token_here
| `GITEA_API_URL` | Gitea API endpoint (with `/api/v1`) | `https://gitea.example.com/api/v1` | | `GITEA_API_URL` | Gitea API endpoint (with `/api/v1`) | `https://gitea.example.com/api/v1` |
| `GITEA_API_TOKEN` | Personal access token | `abc123...` | | `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. **Note:** `GITEA_ORG` is configured at the project level (see below) since different projects may belong to different organizations.
**Generating a Gitea Token:** **Generating a Gitea Token:**
1. Log into Gitea → **User Icon** → **Settings** 1. Log into Gitea → **User Icon** → **Settings**
@@ -360,8 +362,9 @@ GIT_CO_AUTHOR=true
Create `.env` in each project root: Create `.env` in each project root:
```bash ```bash
# Required for projman, pr-review (use owner/repo format) # Required for projman, pr-review
GITEA_REPO=your-organization/your-repo-name GITEA_ORG=your-organization
GITEA_REPO=your-repo-name
# Optional: Override git-flow defaults # Optional: Override git-flow defaults
GIT_WORKFLOW_STYLE=pr-required GIT_WORKFLOW_STYLE=pr-required
@@ -374,7 +377,8 @@ PR_REVIEW_AUTO_SUBMIT=false
| Variable | Required | Description | | Variable | Required | Description |
|----------|----------|-------------| |----------|----------|-------------|
| `GITEA_REPO` | Yes | Repository in `owner/repo` format (e.g., `my-org/my-repo`) | | `GITEA_ORG` | Yes | Gitea organization for this project |
| `GITEA_REPO` | Yes | Repository name (must match Gitea exactly) |
| `GIT_WORKFLOW_STYLE` | No | Override system default | | `GIT_WORKFLOW_STYLE` | No | Override system default |
| `PR_REVIEW_*` | No | PR review settings | | `PR_REVIEW_*` | No | PR review settings |
@@ -384,13 +388,11 @@ PR_REVIEW_AUTO_SUBMIT=false
| Plugin | System Config | Project Config | Setup Commands | | Plugin | System Config | Project Config | Setup Commands |
|--------|---------------|----------------|----------------| |--------|---------------|----------------|----------------|
| **projman** | gitea.env | .env (GITEA_REPO=owner/repo) | `/initial-setup`, `/project-init`, `/project-sync` | | **projman** | gitea.env | .env (GITEA_ORG, GITEA_REPO) | `/initial-setup`, `/project-init`, `/project-sync` |
| **pr-review** | gitea.env | .env (GITEA_REPO=owner/repo) | `/initial-setup`, `/project-init`, `/project-sync` | | **pr-review** | gitea.env | .env (GITEA_ORG, GITEA_REPO) | `/initial-setup`, `/project-init`, `/project-sync` |
| **git-flow** | git-flow.env (optional) | .env (optional) | None needed | | **git-flow** | git-flow.env (optional) | .env (optional) | None needed |
| **clarity-assist** | None | None | None needed | | **clarity-assist** | None | None | None needed |
| **cmdb-assistant** | netbox.env | None | `/initial-setup` | | **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 | | **doc-guardian** | None | None | None needed |
| **code-sentinel** | None | None | None needed | | **code-sentinel** | None | None | None needed |
| **project-hygiene** | None | None | None needed | | **project-hygiene** | None | None | None needed |
@@ -437,7 +439,7 @@ This catches typos and permission issues before saving configuration.
When you start a Claude Code session, a hook automatically: When you start a Claude Code session, a hook automatically:
1. Reads `GITEA_REPO` (in `owner/repo` format) from `.env` 1. Reads `GITEA_ORG` and `GITEA_REPO` from `.env`
2. Compares with current `git remote get-url origin` 2. Compares with current `git remote get-url origin`
3. **Warns** if mismatch detected: "Repository location mismatch. Run `/project-sync` to update." 3. **Warns** if mismatch detected: "Repository location mismatch. Run `/project-sync` to update."
@@ -516,62 +518,11 @@ deactivate
# Check project .env # Check project .env
cat .env cat .env
# Verify GITEA_REPO is in owner/repo format and matches Gitea exactly # Verify GITEA_REPO matches the Gitea repository name exactly
# Example: GITEA_REPO=my-org/my-repo
``` ```
--- ---
## Agent Model Configuration
Agents can specify which Claude model to use for optimal cost/performance.
### Model Options
| Model | Use For | Cost |
|-------|---------|------|
| `opus` | Complex reasoning, security analysis | Highest |
| `sonnet` | Implementation, coordination (default) | Medium |
| `haiku` | Simple validation, quick checks | Lowest |
### Configuration Levels
**1. Agent-Level (highest priority)**
Add to agent frontmatter in `agents/*.md`:
```yaml
---
name: planner
description: Sprint planning agent
model: opus
---
```
**2. Plugin-Level (fallback)**
Add to `plugin.json`:
```json
{
"defaultModel": "sonnet"
}
```
**3. System Default**
If neither is specified, agents use `sonnet`.
### Inheritance Chain
```
Agent model → Plugin defaultModel → System default (sonnet)
```
See [Model Recommendations](MODEL-RECOMMENDATIONS.md) for detailed guidance on model selection by task type.
---
## Security Best Practices ## Security Best Practices
1. **Never commit tokens** 1. **Never commit tokens**

View File

@@ -2,7 +2,7 @@
**Purpose:** Systematic approach to diagnose and fix plugin loading issues. **Purpose:** Systematic approach to diagnose and fix plugin loading issues.
Last Updated: 2026-01-28 Last Updated: 2026-01-22
--- ---
@@ -128,7 +128,7 @@ cat ~/.config/claude/netbox.env
# Project-level config (in target project) # Project-level config (in target project)
cat /path/to/project/.env cat /path/to/project/.env
# Should contain: GITEA_REPO=owner/repo (e.g., my-org/my-repo) # Should contain: GITEA_ORG, GITEA_REPO
``` ```
--- ---
@@ -186,47 +186,6 @@ echo -e "\n=== Config Files ==="
| Wrong path edits | Changes don't take effect | Edit installed path or reinstall after source changes | | 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 | | 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) | | 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
--- ---
@@ -238,51 +197,6 @@ echo -e "\n=== Config Files ==="
--- ---
## 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 ## Automated Diagnostics
Use these commands for automated checking: Use these commands for automated checking:

View File

@@ -1,149 +0,0 @@
# Model Recommendations
Guidelines for selecting Claude models (opus, sonnet, haiku) for plugin agents.
---
## Model Overview
| Model | Best For | Cost | Speed |
|-------|----------|------|-------|
| **Opus** | Complex reasoning, architecture decisions, security analysis | Highest | Slower |
| **Sonnet** | Implementation, coordination, standard tasks | Medium | Balanced |
| **Haiku** | Simple validation, quick checks, status queries | Lowest | Fastest |
---
## Task-Type Recommendations
| Task Type | Model | Rationale |
|-----------|-------|-----------|
| Architecture decisions | Opus | Requires deep reasoning, trade-off analysis |
| Security analysis | Opus | Critical thinking, vulnerability pattern recognition |
| Code review (quality) | Opus | Thorough analysis, edge case detection |
| Sprint planning | Opus | Strategic thinking, dependency analysis |
| Complex data analysis | Opus | Multi-step reasoning, insight generation |
| Code implementation | Sonnet | Fast, capable code generation |
| Coordination/dispatch | Sonnet | Task management, status tracking |
| Data transformation | Sonnet | ETL operations, query building |
| Documentation | Sonnet | Clear writing, structure |
| Simple validation | Haiku | Fast prop checks, schema validation |
| Status checks | Haiku | Quick queries, cost-effective |
| Quick verification | Haiku | Simple pass/fail checks |
---
## Agent Model Assignments
### projman (Sprint Management)
| Agent | Model | Rationale |
|-------|-------|-----------|
| `planner` | opus | Architecture decisions, issue structuring |
| `orchestrator` | sonnet | Coordination, parallel execution |
| `executor` | sonnet | Code implementation |
| `code-reviewer` | opus | Quality review, security analysis |
### pr-review (PR Analysis)
| Agent | Model | Rationale |
|-------|-------|-----------|
| `coordinator` | sonnet | Task dispatch, result aggregation |
| `security-reviewer` | opus | Security vulnerability detection |
| `performance-analyst` | sonnet | Pattern recognition |
| `maintainability-auditor` | sonnet | Code quality checks |
| `test-validator` | sonnet | Test coverage analysis |
### code-sentinel (Security & Refactoring)
| Agent | Model | Rationale |
|-------|-------|-----------|
| `security-reviewer` | opus | Security scanning |
| `refactor-advisor` | sonnet | Refactoring suggestions |
### data-platform (Data Engineering)
| Agent | Model | Rationale |
|-------|-------|-----------|
| `data-analysis` | opus | Complex data insights |
| `data-ingestion` | sonnet | ETL operations |
### viz-platform (Visualization)
| Agent | Model | Rationale |
|-------|-------|-----------|
| `component-check` | haiku | Simple prop validation |
| `layout-builder` | sonnet | UI construction |
| `theme-setup` | sonnet | Design configuration |
### contract-validator (Compatibility)
| Agent | Model | Rationale |
|-------|-------|-----------|
| `full-validation` | sonnet | Contract checking |
| `agent-check` | haiku | Quick verification |
---
## Configuration Schema
### Agent-Level (Frontmatter)
Add `model` field to agent YAML frontmatter:
```yaml
---
name: planner
description: Sprint planning agent
model: opus
---
```
**Valid values:** `opus`, `sonnet`, `haiku`
### Plugin-Level (plugin.json)
Add `defaultModel` for plugin-wide fallback:
```json
{
"name": "projman",
"version": "3.4.0",
"defaultModel": "sonnet"
}
```
---
## Inheritance Chain
Model selection follows this precedence:
```
1. Agent model field (highest priority)
↓ if not specified
2. Plugin defaultModel (plugin.json)
↓ if not specified
3. System default: sonnet
```
**Example:**
- Agent has `model: opus` → Uses opus
- Agent has no model, plugin has `defaultModel: sonnet` → Uses sonnet
- Neither specified → Uses sonnet (system default)
---
## Cost Optimization Tips
1. **Default to Sonnet** - Good balance for most tasks
2. **Reserve Opus** for critical decisions (security, architecture)
3. **Use Haiku** for validation and quick checks
4. **Batch simple tasks** - Use haiku for parallel validation
---
## See Also
- [Configuration Guide](CONFIGURATION.md) - Full configuration reference
- [Plugin Development](../README.md) - Adding new plugins

View File

@@ -1,3 +0,0 @@
"""Contract Validator MCP Server - Cross-plugin compatibility validation."""
__version__ = "1.0.0"

View File

@@ -1,415 +0,0 @@
"""
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

View File

@@ -1,337 +0,0 @@
"""
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
}

View File

@@ -1,274 +0,0 @@
"""
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())

View File

@@ -1,338 +0,0 @@
"""
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()

View File

@@ -1,41 +0,0 @@
[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"]

View File

@@ -1,9 +0,0 @@
# MCP SDK
mcp>=0.9.0
# Utilities
pydantic>=2.5.0
# Testing
pytest>=7.4.3
pytest-asyncio>=0.23.0

View File

@@ -1,21 +0,0 @@
#!/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 "$@"

View File

@@ -1 +0,0 @@
# Tests for contract-validator MCP server

View File

@@ -1,193 +0,0 @@
"""
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"

View File

@@ -1,261 +0,0 @@
"""
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

View File

@@ -1,256 +0,0 @@
"""
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

View File

@@ -1,131 +0,0 @@
# 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
```

View File

@@ -1,7 +0,0 @@
"""
Data Platform MCP Server.
Provides pandas, PostgreSQL/PostGIS, and dbt tools to Claude Code via MCP.
"""
__version__ = "1.0.0"

View File

@@ -1,195 +0,0 @@
"""
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())

View File

@@ -1,219 +0,0 @@
"""
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}

View File

@@ -1,387 +0,0 @@
"""
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)}

View File

@@ -1,500 +0,0 @@
"""
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}'}

View File

@@ -1,538 +0,0 @@
"""
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())

View File

@@ -1,795 +0,0 @@
"""
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())

View File

@@ -1,49 +0,0 @@
[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"]

View File

@@ -1,23 +0,0 @@
# 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

View File

@@ -1,21 +0,0 @@
#!/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 "$@"

View File

@@ -1,3 +0,0 @@
"""
Tests for Data Platform MCP Server.
"""

View File

@@ -1,239 +0,0 @@
"""
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

View File

@@ -1,240 +0,0 @@
"""
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

View File

@@ -1,318 +0,0 @@
"""
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()

View File

@@ -1,301 +0,0 @@
"""
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

View File

@@ -1,338 +0,0 @@
"""
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']

View File

@@ -53,7 +53,6 @@ class GiteaClient:
self, self,
state: str = 'open', state: str = 'open',
labels: Optional[List[str]] = None, labels: Optional[List[str]] = None,
milestone: Optional[str] = None,
repo: Optional[str] = None repo: Optional[str] = None
) -> List[Dict]: ) -> List[Dict]:
""" """
@@ -62,7 +61,6 @@ class GiteaClient:
Args: Args:
state: Issue state (open, closed, all) state: Issue state (open, closed, all)
labels: Filter by labels labels: Filter by labels
milestone: Filter by milestone title (exact match)
repo: Repository in 'owner/repo' format repo: Repository in 'owner/repo' format
Returns: Returns:
@@ -73,8 +71,6 @@ class GiteaClient:
params = {'state': state} params = {'state': state}
if labels: if labels:
params['labels'] = ','.join(labels) params['labels'] = ','.join(labels)
if milestone:
params['milestones'] = milestone
logger.info(f"Listing issues from {owner}/{target_repo} with state={state}") logger.info(f"Listing issues from {owner}/{target_repo} with state={state}")
response = self.session.get(url, params=params) response = self.session.get(url, params=params)
response.raise_for_status() response.raise_for_status()
@@ -139,24 +135,9 @@ class GiteaClient:
body: Optional[str] = None, body: Optional[str] = None,
state: Optional[str] = None, state: Optional[str] = None,
labels: Optional[List[str]] = None, labels: Optional[List[str]] = None,
milestone: Optional[int] = None,
repo: Optional[str] = None repo: Optional[str] = None
) -> Dict: ) -> 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) owner, target_repo = self._parse_repo(repo)
url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{issue_number}" url = f"{self.base_url}/repos/{owner}/{target_repo}/issues/{issue_number}"
data = {} data = {}
@@ -168,8 +149,6 @@ class GiteaClient:
data['state'] = state data['state'] = state
if labels is not None: if labels is not None:
data['labels'] = labels data['labels'] = labels
if milestone is not None:
data['milestone'] = milestone
logger.info(f"Updating issue #{issue_number} in {owner}/{target_repo}") logger.info(f"Updating issue #{issue_number} in {owner}/{target_repo}")
response = self.session.patch(url, json=data) response = self.session.patch(url, json=data)
response.raise_for_status() response.raise_for_status()
@@ -260,11 +239,8 @@ class GiteaClient:
repo: Optional[str] = None repo: Optional[str] = None
) -> Dict: ) -> Dict:
"""Get a specific wiki page by name.""" """Get a specific wiki page by name."""
from urllib.parse import quote
owner, target_repo = self._parse_repo(repo) owner, target_repo = self._parse_repo(repo)
# URL-encode the page_name to handle special characters like ':' url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}"
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}") logger.info(f"Getting wiki page '{page_name}' from {owner}/{target_repo}")
response = self.session.get(url) response = self.session.get(url)
response.raise_for_status() response.raise_for_status()
@@ -295,13 +271,9 @@ class GiteaClient:
repo: Optional[str] = None repo: Optional[str] = None
) -> Dict: ) -> Dict:
"""Update an existing wiki page.""" """Update an existing wiki page."""
from urllib.parse import quote
owner, target_repo = self._parse_repo(repo) owner, target_repo = self._parse_repo(repo)
# URL-encode the page_name to handle special characters like ':' url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}"
encoded_page_name = quote(page_name, safe='')
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{encoded_page_name}"
data = { data = {
'title': page_name, # CRITICAL: include title to preserve page name
'content_base64': self._encode_base64(content) 'content_base64': self._encode_base64(content)
} }
logger.info(f"Updating wiki page '{page_name}' in {owner}/{target_repo}") logger.info(f"Updating wiki page '{page_name}' in {owner}/{target_repo}")
@@ -315,11 +287,8 @@ class GiteaClient:
repo: Optional[str] = None repo: Optional[str] = None
) -> bool: ) -> bool:
"""Delete a wiki page.""" """Delete a wiki page."""
from urllib.parse import quote
owner, target_repo = self._parse_repo(repo) owner, target_repo = self._parse_repo(repo)
# URL-encode the page_name to handle special characters like ':' url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}"
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}") logger.info(f"Deleting wiki page '{page_name}' from {owner}/{target_repo}")
response = self.session.delete(url) response = self.session.delete(url)
response.raise_for_status() response.raise_for_status()
@@ -808,42 +777,3 @@ class GiteaClient:
response = self.session.post(url, json=data) response = self.session.post(url, json=data)
response.raise_for_status() response.raise_for_status()
return response.json() 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()

View File

@@ -26,44 +26,6 @@ logging.getLogger("mcp").setLevel(logging.ERROR)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _coerce_types(arguments: dict) -> dict:
"""
Coerce argument types to handle MCP serialization quirks.
MCP sometimes passes integers as strings and arrays as JSON strings.
This function normalizes them to the expected Python types.
"""
coerced = {}
for key, value in arguments.items():
if value is None:
coerced[key] = value
continue
# Coerce integer fields
int_fields = {'issue_number', 'milestone_id', 'pr_number', 'depends_on', 'milestone', 'limit'}
if key in int_fields and isinstance(value, str):
try:
coerced[key] = int(value)
continue
except ValueError:
pass
# Coerce array fields that might be JSON strings
array_fields = {'labels', 'tags', 'issue_numbers', 'comments'}
if key in array_fields and isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, list):
coerced[key] = parsed
continue
except json.JSONDecodeError:
pass
coerced[key] = value
return coerced
class GiteaMCPServer: class GiteaMCPServer:
"""MCP Server for Gitea integration""" """MCP Server for Gitea integration"""
@@ -126,10 +88,6 @@ class GiteaMCPServer:
"items": {"type": "string"}, "items": {"type": "string"},
"description": "Filter by labels" "description": "Filter by labels"
}, },
"milestone": {
"type": "string",
"description": "Filter by milestone title (exact match)"
},
"repo": { "repo": {
"type": "string", "type": "string",
"description": "Repository name (for PMO mode)" "description": "Repository name (for PMO mode)"
@@ -210,10 +168,6 @@ class GiteaMCPServer:
"items": {"type": "string"}, "items": {"type": "string"},
"description": "New labels" "description": "New labels"
}, },
"milestone": {
"type": "integer",
"description": "Milestone ID to assign"
},
"repo": { "repo": {
"type": "string", "type": "string",
"description": "Repository name (for PMO mode)" "description": "Repository name (for PMO mode)"
@@ -890,41 +844,6 @@ class GiteaMCPServer:
}, },
"required": ["pr_number", "body"] "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"]
}
) )
] ]
@@ -941,9 +860,6 @@ class GiteaMCPServer:
List of TextContent with results List of TextContent with results
""" """
try: try:
# Coerce types to handle MCP serialization quirks
arguments = _coerce_types(arguments)
# Route to appropriate tool handler # Route to appropriate tool handler
if name == "list_issues": if name == "list_issues":
result = await self.issue_tools.list_issues(**arguments) result = await self.issue_tools.list_issues(**arguments)
@@ -1043,8 +959,6 @@ class GiteaMCPServer:
result = await self.pr_tools.create_pr_review(**arguments) result = await self.pr_tools.create_pr_review(**arguments)
elif name == "add_pr_comment": elif name == "add_pr_comment":
result = await self.pr_tools.add_pr_comment(**arguments) result = await self.pr_tools.add_pr_comment(**arguments)
elif name == "create_pull_request":
result = await self.pr_tools.create_pull_request(**arguments)
else: else:
raise ValueError(f"Unknown tool: {name}") raise ValueError(f"Unknown tool: {name}")

View File

@@ -7,7 +7,6 @@ Provides async wrappers for issue CRUD operations with:
- Comprehensive error handling - Comprehensive error handling
""" """
import asyncio import asyncio
import os
import subprocess import subprocess
import logging import logging
from typing import List, Dict, Optional from typing import List, Dict, Optional
@@ -28,34 +27,19 @@ class IssueTools:
""" """
self.gitea = gitea_client 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: def _get_current_branch(self) -> str:
""" """
Get current git branch from user's project directory. Get current git branch.
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: Returns:
Current branch name or 'unknown' if not in a git repo Current branch name or 'unknown' if not in a git repo
""" """
try: try:
project_dir = self._get_project_directory()
result = subprocess.run( result = subprocess.run(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'], ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
capture_output=True, capture_output=True,
text=True, text=True,
check=True, check=True
cwd=project_dir # Run git in project directory, not plugin directory
) )
return result.stdout.strip() return result.stdout.strip()
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
@@ -82,13 +66,7 @@ class IssueTools:
return operation in ['list_issues', 'get_issue', 'get_labels', 'create_issue'] return operation in ['list_issues', 'get_issue', 'get_labels', 'create_issue']
# Development branches (full access) # Development branches (full access)
# Include all common feature/fix branch patterns if branch in ['development', 'develop'] or branch.startswith(('feat/', 'feature/', 'dev/')):
dev_prefixes = (
'feat/', 'feature/', 'dev/',
'fix/', 'bugfix/', 'hotfix/',
'chore/', 'refactor/', 'docs/', 'test/'
)
if branch in ['development', 'develop'] or branch.startswith(dev_prefixes):
return True return True
# Unknown branch - be restrictive # Unknown branch - be restrictive
@@ -98,7 +76,6 @@ class IssueTools:
self, self,
state: str = 'open', state: str = 'open',
labels: Optional[List[str]] = None, labels: Optional[List[str]] = None,
milestone: Optional[str] = None,
repo: Optional[str] = None repo: Optional[str] = None
) -> List[Dict]: ) -> List[Dict]:
""" """
@@ -107,7 +84,6 @@ class IssueTools:
Args: Args:
state: Issue state (open, closed, all) state: Issue state (open, closed, all)
labels: Filter by labels labels: Filter by labels
milestone: Filter by milestone title (exact match)
repo: Override configured repo (for PMO multi-repo) repo: Override configured repo (for PMO multi-repo)
Returns: Returns:
@@ -126,7 +102,7 @@ class IssueTools:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.run_in_executor( return await loop.run_in_executor(
None, None,
lambda: self.gitea.list_issues(state, labels, milestone, repo) lambda: self.gitea.list_issues(state, labels, repo)
) )
async def get_issue( async def get_issue(
@@ -202,7 +178,6 @@ class IssueTools:
body: Optional[str] = None, body: Optional[str] = None,
state: Optional[str] = None, state: Optional[str] = None,
labels: Optional[List[str]] = None, labels: Optional[List[str]] = None,
milestone: Optional[int] = None,
repo: Optional[str] = None repo: Optional[str] = None
) -> Dict: ) -> Dict:
""" """
@@ -214,7 +189,6 @@ class IssueTools:
body: New body (optional) body: New body (optional)
state: New state - 'open' or 'closed' (optional) state: New state - 'open' or 'closed' (optional)
labels: New labels (optional) labels: New labels (optional)
milestone: Milestone ID to assign (optional)
repo: Override configured repo (for PMO multi-repo) repo: Override configured repo (for PMO multi-repo)
Returns: Returns:
@@ -233,7 +207,7 @@ class IssueTools:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.run_in_executor( return await loop.run_in_executor(
None, None,
lambda: self.gitea.update_issue(issue_number, title, body, state, labels, milestone, repo) lambda: self.gitea.update_issue(issue_number, title, body, state, labels, repo)
) )
async def add_comment( async def add_comment(

View File

@@ -7,7 +7,6 @@ Provides async wrappers for PR operations with:
- Comprehensive error handling - Comprehensive error handling
""" """
import asyncio import asyncio
import os
import subprocess import subprocess
import logging import logging
from typing import List, Dict, Optional from typing import List, Dict, Optional
@@ -28,34 +27,19 @@ class PullRequestTools:
""" """
self.gitea = gitea_client 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: def _get_current_branch(self) -> str:
""" """
Get current git branch from user's project directory. Get current git branch.
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: Returns:
Current branch name or 'unknown' if not in a git repo Current branch name or 'unknown' if not in a git repo
""" """
try: try:
project_dir = self._get_project_directory()
result = subprocess.run( result = subprocess.run(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'], ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
capture_output=True, capture_output=True,
text=True, text=True,
check=True, check=True
cwd=project_dir # Run git in project directory, not plugin directory
) )
return result.stdout.strip() return result.stdout.strip()
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
@@ -85,13 +69,7 @@ class PullRequestTools:
return operation in read_ops + ['add_pr_comment'] return operation in read_ops + ['add_pr_comment']
# Development branches (full access) # Development branches (full access)
# Include all common feature/fix branch patterns if branch in ['development', 'develop'] or branch.startswith(('feat/', 'feature/', 'dev/')):
dev_prefixes = (
'feat/', 'feature/', 'dev/',
'fix/', 'bugfix/', 'hotfix/',
'chore/', 'refactor/', 'docs/', 'test/'
)
if branch in ['development', 'develop'] or branch.startswith(dev_prefixes):
return True return True
# Unknown branch - be restrictive # Unknown branch - be restrictive
@@ -294,42 +272,3 @@ class PullRequestTools:
None, None,
lambda: self.gitea.add_pr_comment(pr_number, body, repo) 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)
)

View File

@@ -1,21 +0,0 @@
#!/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 "$@"

View File

@@ -1,21 +0,0 @@
#!/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 "$@"

View File

@@ -1,115 +0,0 @@
# 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.

View File

@@ -1,7 +0,0 @@
"""
viz-platform MCP Server package.
Provides Dash Mantine Components validation and visualization tools to Claude Code.
"""
__version__ = "1.0.0"

View File

@@ -1,479 +0,0 @@
"""
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

View File

@@ -1,533 +0,0 @@
"""
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
}

View File

@@ -1,301 +0,0 @@
"""
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

View File

@@ -1,172 +0,0 @@
"""
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.'
}

View File

@@ -1,306 +0,0 @@
"""
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."
)

View File

@@ -1,553 +0,0 @@
"""
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
}

View File

@@ -1,366 +0,0 @@
"""
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
}
]
}

View File

@@ -1,928 +0,0 @@
"""
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())

View File

@@ -1,259 +0,0 @@
"""
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

View File

@@ -1,391 +0,0 @@
"""
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

View File

@@ -1,45 +0,0 @@
[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"]

View File

@@ -1,668 +0,0 @@
{
"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"}
}
}
}
}

View File

@@ -1,16 +0,0 @@
# 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

View File

@@ -1,21 +0,0 @@
#!/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 "$@"

View File

@@ -1,262 +0,0 @@
#!/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()

View File

@@ -1 +0,0 @@
"""viz-platform MCP Server tests."""

View File

@@ -1,195 +0,0 @@
"""
Tests for accessibility validation tools.
"""
import pytest
from mcp_server.accessibility_tools import AccessibilityTools
@pytest.fixture
def tools():
"""Create AccessibilityTools instance."""
return AccessibilityTools()
class TestHexToRgb:
"""Tests for _hex_to_rgb method."""
def test_hex_to_rgb_6_digit(self, tools):
"""Test 6-digit hex conversion."""
assert tools._hex_to_rgb("#FF0000") == (255, 0, 0)
assert tools._hex_to_rgb("#00FF00") == (0, 255, 0)
assert tools._hex_to_rgb("#0000FF") == (0, 0, 255)
def test_hex_to_rgb_3_digit(self, tools):
"""Test 3-digit hex conversion."""
assert tools._hex_to_rgb("#F00") == (255, 0, 0)
assert tools._hex_to_rgb("#0F0") == (0, 255, 0)
assert tools._hex_to_rgb("#00F") == (0, 0, 255)
def test_hex_to_rgb_lowercase(self, tools):
"""Test lowercase hex conversion."""
assert tools._hex_to_rgb("#ff0000") == (255, 0, 0)
class TestContrastRatio:
"""Tests for _get_contrast_ratio method."""
def test_black_white_contrast(self, tools):
"""Test black on white has maximum contrast."""
ratio = tools._get_contrast_ratio("#000000", "#FFFFFF")
assert ratio == pytest.approx(21.0, rel=0.01)
def test_same_color_contrast(self, tools):
"""Test same color has minimum contrast."""
ratio = tools._get_contrast_ratio("#FF0000", "#FF0000")
assert ratio == pytest.approx(1.0, rel=0.01)
def test_symmetric_contrast(self, tools):
"""Test contrast ratio is symmetric."""
ratio1 = tools._get_contrast_ratio("#228be6", "#FFFFFF")
ratio2 = tools._get_contrast_ratio("#FFFFFF", "#228be6")
assert ratio1 == pytest.approx(ratio2, rel=0.01)
class TestColorBlindnessSimulation:
"""Tests for _simulate_color_blindness method."""
def test_deuteranopia_simulation(self, tools):
"""Test deuteranopia (green-blind) simulation."""
# Red and green should appear more similar
original_red = "#FF0000"
original_green = "#00FF00"
simulated_red = tools._simulate_color_blindness(original_red, "deuteranopia")
simulated_green = tools._simulate_color_blindness(original_green, "deuteranopia")
# They should be different from originals
assert simulated_red != original_red or simulated_green != original_green
def test_protanopia_simulation(self, tools):
"""Test protanopia (red-blind) simulation."""
simulated = tools._simulate_color_blindness("#FF0000", "protanopia")
# Should return a modified color
assert simulated.startswith("#")
assert len(simulated) == 7
def test_tritanopia_simulation(self, tools):
"""Test tritanopia (blue-blind) simulation."""
simulated = tools._simulate_color_blindness("#0000FF", "tritanopia")
# Should return a modified color
assert simulated.startswith("#")
assert len(simulated) == 7
def test_unknown_deficiency_returns_original(self, tools):
"""Test unknown deficiency type returns original color."""
color = "#FF0000"
simulated = tools._simulate_color_blindness(color, "unknown")
assert simulated == color
class TestAccessibilityValidateColors:
"""Tests for accessibility_validate_colors method."""
@pytest.mark.asyncio
async def test_validate_single_color(self, tools):
"""Test validating a single color."""
result = await tools.accessibility_validate_colors(["#228be6"])
assert "colors_checked" in result
assert "overall_score" in result
assert "issues" in result
assert "safe_palettes" in result
@pytest.mark.asyncio
async def test_validate_problematic_colors(self, tools):
"""Test similar colors trigger warnings."""
# Use colors that are very close in hue, which should be harder to distinguish
result = await tools.accessibility_validate_colors(["#FF5555", "#FF6666"])
# Similar colors should trigger distinguishability warnings
assert "issues" in result
# The validation should at least run without errors
assert "colors_checked" in result
assert len(result["colors_checked"]) == 2
@pytest.mark.asyncio
async def test_validate_contrast_issue(self, tools):
"""Test low contrast colors trigger contrast warnings."""
# Yellow on white has poor contrast
result = await tools.accessibility_validate_colors(["#FFFF00"])
# Check for contrast issues (yellow may have issues with both black and white)
assert "issues" in result
@pytest.mark.asyncio
async def test_validate_with_specific_types(self, tools):
"""Test validating for specific color blindness types."""
result = await tools.accessibility_validate_colors(
["#FF0000", "#00FF00"],
check_types=["deuteranopia"]
)
assert "simulations" in result
assert "deuteranopia" in result["simulations"]
assert "protanopia" not in result["simulations"]
@pytest.mark.asyncio
async def test_overall_score(self, tools):
"""Test overall score is calculated."""
result = await tools.accessibility_validate_colors(["#228be6", "#ffffff"])
assert result["overall_score"] in ["A", "B", "C", "D"]
@pytest.mark.asyncio
async def test_recommendations_generated(self, tools):
"""Test recommendations are generated for issues."""
result = await tools.accessibility_validate_colors(["#FF0000", "#00FF00"])
assert "recommendations" in result
assert len(result["recommendations"]) > 0
class TestAccessibilitySuggestAlternative:
"""Tests for accessibility_suggest_alternative method."""
@pytest.mark.asyncio
async def test_suggest_alternative_deuteranopia(self, tools):
"""Test suggesting alternatives for deuteranopia."""
result = await tools.accessibility_suggest_alternative("#FF0000", "deuteranopia")
assert "original_color" in result
assert result["deficiency_type"] == "deuteranopia"
assert "suggestions" in result
assert len(result["suggestions"]) > 0
@pytest.mark.asyncio
async def test_suggest_alternative_tritanopia(self, tools):
"""Test suggesting alternatives for tritanopia."""
result = await tools.accessibility_suggest_alternative("#0000FF", "tritanopia")
assert "suggestions" in result
assert len(result["suggestions"]) > 0
@pytest.mark.asyncio
async def test_suggestions_include_safe_palettes(self, tools):
"""Test suggestions include colors from safe palettes."""
result = await tools.accessibility_suggest_alternative("#FF0000", "deuteranopia")
palette_suggestions = [
s for s in result["suggestions"]
if "palette" in s
]
assert len(palette_suggestions) > 0
class TestSafePalettes:
"""Tests for safe palette constants."""
def test_safe_palettes_exist(self, tools):
"""Test that safe palettes are defined."""
from mcp_server.accessibility_tools import SAFE_PALETTES
assert "categorical" in SAFE_PALETTES
assert "ibm" in SAFE_PALETTES
assert "okabe_ito" in SAFE_PALETTES
assert "tableau_colorblind" in SAFE_PALETTES
def test_safe_palettes_have_colors(self, tools):
"""Test that safe palettes have color lists."""
from mcp_server.accessibility_tools import SAFE_PALETTES
for palette_name, palette in SAFE_PALETTES.items():
assert "colors" in palette
assert len(palette["colors"]) > 0
# All colors should be valid hex
for color in palette["colors"]:
assert color.startswith("#")

View File

@@ -1,271 +0,0 @@
"""
Unit tests for chart creation tools.
"""
import pytest
from unittest.mock import MagicMock, patch
@pytest.fixture
def chart_tools():
"""Create ChartTools instance"""
from mcp_server.chart_tools import ChartTools
return ChartTools()
@pytest.fixture
def chart_tools_with_theme():
"""Create ChartTools instance with a theme"""
from mcp_server.chart_tools import ChartTools
tools = ChartTools()
tools.set_theme({
"colors": {
"primary": "#ff0000",
"secondary": "#00ff00",
"success": "#0000ff"
}
})
return tools
def test_chart_tools_init():
"""Test chart tools initialization"""
from mcp_server.chart_tools import ChartTools
tools = ChartTools()
assert tools.theme_store is None
assert tools._active_theme is None
def test_set_theme(chart_tools):
"""Test setting active theme"""
theme = {"colors": {"primary": "#123456"}}
chart_tools.set_theme(theme)
assert chart_tools._active_theme == theme
def test_get_color_palette_default(chart_tools):
"""Test getting default color palette"""
from mcp_server.chart_tools import DEFAULT_COLORS
palette = chart_tools._get_color_palette()
assert palette == DEFAULT_COLORS
def test_get_color_palette_with_theme(chart_tools_with_theme):
"""Test getting color palette from theme"""
palette = chart_tools_with_theme._get_color_palette()
# Should start with theme colors
assert palette[0] == "#ff0000"
assert palette[1] == "#00ff00"
assert palette[2] == "#0000ff"
def test_resolve_color_from_theme(chart_tools_with_theme):
"""Test resolving color token from theme"""
color = chart_tools_with_theme._resolve_color("primary")
assert color == "#ff0000"
def test_resolve_color_hex(chart_tools):
"""Test resolving hex color"""
color = chart_tools._resolve_color("#abcdef")
assert color == "#abcdef"
def test_resolve_color_rgb(chart_tools):
"""Test resolving rgb color"""
color = chart_tools._resolve_color("rgb(255, 0, 0)")
assert color == "rgb(255, 0, 0)"
def test_resolve_color_named(chart_tools):
"""Test resolving named color"""
color = chart_tools._resolve_color("blue")
assert color == "#228be6" # DEFAULT_COLORS[0]
def test_resolve_color_none(chart_tools):
"""Test resolving None color defaults to first palette color"""
from mcp_server.chart_tools import DEFAULT_COLORS
color = chart_tools._resolve_color(None)
assert color == DEFAULT_COLORS[0]
@pytest.mark.asyncio
async def test_chart_create_line(chart_tools):
"""Test creating a line chart"""
data = {
"x": [1, 2, 3, 4, 5],
"y": [10, 20, 15, 25, 30]
}
result = await chart_tools.chart_create("line", data)
assert "figure" in result
assert result["chart_type"] == "line"
assert "error" not in result or result["error"] is None
@pytest.mark.asyncio
async def test_chart_create_bar(chart_tools):
"""Test creating a bar chart"""
data = {
"x": ["A", "B", "C"],
"y": [10, 20, 15]
}
result = await chart_tools.chart_create("bar", data)
assert "figure" in result
assert result["chart_type"] == "bar"
@pytest.mark.asyncio
async def test_chart_create_scatter(chart_tools):
"""Test creating a scatter chart"""
data = {
"x": [1, 2, 3, 4, 5],
"y": [10, 20, 15, 25, 30]
}
result = await chart_tools.chart_create("scatter", data)
assert "figure" in result
assert result["chart_type"] == "scatter"
@pytest.mark.asyncio
async def test_chart_create_pie(chart_tools):
"""Test creating a pie chart"""
data = {
"labels": ["A", "B", "C"],
"values": [30, 50, 20]
}
result = await chart_tools.chart_create("pie", data)
assert "figure" in result
assert result["chart_type"] == "pie"
@pytest.mark.asyncio
async def test_chart_create_histogram(chart_tools):
"""Test creating a histogram"""
data = {
"x": [1, 1, 2, 2, 2, 3, 3, 4, 5, 5, 5, 5]
}
result = await chart_tools.chart_create("histogram", data)
assert "figure" in result
assert result["chart_type"] == "histogram"
@pytest.mark.asyncio
async def test_chart_create_area(chart_tools):
"""Test creating an area chart"""
data = {
"x": [1, 2, 3, 4, 5],
"y": [10, 20, 15, 25, 30]
}
result = await chart_tools.chart_create("area", data)
assert "figure" in result
assert result["chart_type"] == "area"
@pytest.mark.asyncio
async def test_chart_create_heatmap(chart_tools):
"""Test creating a heatmap"""
data = {
"z": [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
"x": ["A", "B", "C"],
"y": ["X", "Y", "Z"]
}
result = await chart_tools.chart_create("heatmap", data)
assert "figure" in result
assert result["chart_type"] == "heatmap"
@pytest.mark.asyncio
async def test_chart_create_invalid_type(chart_tools):
"""Test creating chart with invalid type"""
data = {"x": [1, 2, 3], "y": [10, 20, 30]}
result = await chart_tools.chart_create("invalid_type", data)
assert "error" in result
assert "invalid" in result["error"].lower()
@pytest.mark.asyncio
async def test_chart_create_with_options(chart_tools):
"""Test creating chart with options"""
data = {
"x": [1, 2, 3],
"y": [10, 20, 30]
}
options = {
"title": "My Chart",
"color": "red"
}
result = await chart_tools.chart_create("line", data, options=options)
assert "figure" in result
# The title should be applied to the figure
@pytest.mark.asyncio
async def test_chart_create_with_theme(chart_tools_with_theme):
"""Test that theme colors are applied to chart"""
data = {
"x": [1, 2, 3],
"y": [10, 20, 30]
}
result = await chart_tools_with_theme.chart_create("line", data)
assert "figure" in result
# Chart should use theme colors
@pytest.mark.asyncio
async def test_chart_configure_interaction(chart_tools):
"""Test configuring chart interaction"""
# Create a simple figure first
data = {"x": [1, 2, 3], "y": [10, 20, 30]}
chart_result = await chart_tools.chart_create("line", data)
figure = chart_result.get("figure", {})
if hasattr(chart_tools, 'chart_configure_interaction'):
result = await chart_tools.chart_configure_interaction(
figure=figure,
interactions={"zoom": True, "pan": True}
)
# Just verify it doesn't crash
assert result is not None
def test_default_colors_defined():
"""Test that DEFAULT_COLORS is properly defined"""
from mcp_server.chart_tools import DEFAULT_COLORS
assert len(DEFAULT_COLORS) == 10
assert all(c.startswith("#") for c in DEFAULT_COLORS)

View File

@@ -1,292 +0,0 @@
"""
Unit tests for DMC component registry.
"""
import pytest
import json
from pathlib import Path
from unittest.mock import patch, MagicMock
@pytest.fixture
def sample_registry_data():
"""Sample registry data for testing"""
return {
"version": "2.5.1",
"categories": {
"buttons": ["Button", "ActionIcon"],
"inputs": ["TextInput", "NumberInput", "Select"]
},
"components": {
"Button": {
"description": "Button component",
"props": {
"variant": {
"type": "string",
"enum": ["filled", "outline", "light"],
"default": "filled"
},
"color": {
"type": "string",
"default": "blue"
},
"size": {
"type": "string",
"enum": ["xs", "sm", "md", "lg", "xl"],
"default": "sm"
},
"disabled": {
"type": "boolean",
"default": False
}
}
},
"TextInput": {
"description": "Text input field",
"props": {
"value": {"type": "string", "default": ""},
"placeholder": {"type": "string"},
"disabled": {"type": "boolean", "default": False},
"required": {"type": "boolean", "default": False}
}
}
}
}
@pytest.fixture
def registry_file(tmp_path, sample_registry_data):
"""Create a temporary registry file"""
registry_dir = tmp_path / "registry"
registry_dir.mkdir()
registry_file = registry_dir / "dmc_2_5.json"
registry_file.write_text(json.dumps(sample_registry_data))
return registry_file
@pytest.fixture
def registry(registry_file):
"""Create a ComponentRegistry with mock registry directory"""
from mcp_server.component_registry import ComponentRegistry
reg = ComponentRegistry(dmc_version="2.5.1")
reg.registry_dir = registry_file.parent
reg.load()
return reg
def test_registry_init():
"""Test registry initialization"""
from mcp_server.component_registry import ComponentRegistry
reg = ComponentRegistry(dmc_version="2.5.1")
assert reg.dmc_version == "2.5.1"
assert reg.components == {}
assert reg.categories == {}
assert reg.loaded_version is None
def test_registry_load_success(registry, sample_registry_data):
"""Test successful registry loading"""
assert registry.is_loaded()
assert registry.loaded_version == "2.5.1"
assert len(registry.components) == 2
assert "Button" in registry.components
assert "TextInput" in registry.components
def test_registry_load_no_file():
"""Test registry loading when no file exists"""
from mcp_server.component_registry import ComponentRegistry
reg = ComponentRegistry(dmc_version="99.99.99")
reg.registry_dir = Path("/nonexistent/path")
result = reg.load()
assert result is False
assert not reg.is_loaded()
def test_get_component(registry):
"""Test getting a component by name"""
button = registry.get_component("Button")
assert button is not None
assert button["description"] == "Button component"
assert "props" in button
def test_get_component_not_found(registry):
"""Test getting a nonexistent component"""
result = registry.get_component("NonexistentComponent")
assert result is None
def test_get_component_props(registry):
"""Test getting component props"""
props = registry.get_component_props("Button")
assert props is not None
assert "variant" in props
assert "color" in props
assert props["variant"]["type"] == "string"
assert props["variant"]["enum"] == ["filled", "outline", "light"]
def test_get_component_props_not_found(registry):
"""Test getting props for nonexistent component"""
props = registry.get_component_props("Nonexistent")
assert props is None
def test_list_components_all(registry):
"""Test listing all components"""
result = registry.list_components()
assert "buttons" in result
assert "inputs" in result
assert "Button" in result["buttons"]
assert "TextInput" in result["inputs"]
def test_list_components_by_category(registry):
"""Test listing components by category"""
result = registry.list_components(category="buttons")
assert len(result) == 1
assert "buttons" in result
assert "Button" in result["buttons"]
def test_list_components_invalid_category(registry):
"""Test listing components with invalid category"""
result = registry.list_components(category="nonexistent")
assert result == {}
def test_get_categories(registry):
"""Test getting available categories"""
categories = registry.get_categories()
assert "buttons" in categories
assert "inputs" in categories
def test_validate_prop_valid_enum(registry):
"""Test validating a valid enum prop"""
result = registry.validate_prop("Button", "variant", "filled")
assert result["valid"] is True
def test_validate_prop_invalid_enum(registry):
"""Test validating an invalid enum prop"""
result = registry.validate_prop("Button", "variant", "invalid_variant")
assert result["valid"] is False
assert "expects one of" in result["error"]
def test_validate_prop_valid_type(registry):
"""Test validating a valid type"""
result = registry.validate_prop("Button", "disabled", True)
assert result["valid"] is True
def test_validate_prop_invalid_type(registry):
"""Test validating an invalid type"""
result = registry.validate_prop("Button", "disabled", "not_a_boolean")
assert result["valid"] is False
assert "expects type" in result["error"]
def test_validate_prop_unknown_component(registry):
"""Test validating prop for unknown component"""
result = registry.validate_prop("Nonexistent", "prop", "value")
assert result["valid"] is False
assert "Unknown component" in result["error"]
def test_validate_prop_unknown_prop(registry):
"""Test validating an unknown prop"""
result = registry.validate_prop("Button", "unknownProp", "value")
assert result["valid"] is False
assert "Unknown prop" in result["error"]
def test_validate_prop_typo_detection(registry):
"""Test typo detection for similar prop names"""
# colour vs color
result = registry.validate_prop("Button", "colour", "blue")
assert result["valid"] is False
# Should suggest 'color'
assert "color" in result.get("error", "").lower()
def test_find_similar_props(registry):
"""Test finding similar prop names"""
available = ["color", "variant", "size", "disabled"]
# Should match despite case difference
similar = registry._find_similar_props("Color", available)
assert similar == "color"
# Should match with slight typo
similar = registry._find_similar_props("colours", ["color", "variant"])
# May or may not match depending on heuristic
def test_load_registry_convenience_function(registry_file):
"""Test the convenience function"""
from mcp_server.component_registry import load_registry, ComponentRegistry
with patch.object(ComponentRegistry, '__init__', return_value=None) as mock_init:
with patch.object(ComponentRegistry, 'load', return_value=True):
mock_init.return_value = None
# Can't easily test this without mocking more - just ensure it doesn't crash
pass
def test_find_registry_file_exact_match(tmp_path):
"""Test finding exact registry file match"""
from mcp_server.component_registry import ComponentRegistry
# Create registry files
registry_dir = tmp_path / "registry"
registry_dir.mkdir()
(registry_dir / "dmc_2_5.json").write_text('{"version": "2.5.0"}')
reg = ComponentRegistry(dmc_version="2.5.1")
reg.registry_dir = registry_dir
result = reg._find_registry_file()
assert result is not None
assert result.name == "dmc_2_5.json"
def test_find_registry_file_fallback(tmp_path):
"""Test fallback to latest registry when no exact match"""
from mcp_server.component_registry import ComponentRegistry
# Create registry files
registry_dir = tmp_path / "registry"
registry_dir.mkdir()
(registry_dir / "dmc_0_14.json").write_text('{"version": "0.14.0"}')
reg = ComponentRegistry(dmc_version="2.5.1") # No exact match
reg.registry_dir = registry_dir
result = reg._find_registry_file()
assert result is not None
assert result.name == "dmc_0_14.json" # Falls back to available

View File

@@ -1,156 +0,0 @@
"""
Unit tests for viz-platform configuration loader.
"""
import pytest
import os
from pathlib import Path
from unittest.mock import patch, MagicMock
@pytest.fixture
def clean_env():
"""Clean environment variables before test"""
env_vars = ['DMC_VERSION', 'CLAUDE_PROJECT_DIR', 'VIZ_DEFAULT_THEME']
saved = {k: os.environ.get(k) for k in env_vars}
for k in env_vars:
if k in os.environ:
del os.environ[k]
yield
# Restore after test
for k, v in saved.items():
if v is not None:
os.environ[k] = v
elif k in os.environ:
del os.environ[k]
@pytest.fixture
def config():
"""Create VizPlatformConfig instance"""
from mcp_server.config import VizPlatformConfig
return VizPlatformConfig()
def test_config_init(config):
"""Test config initialization"""
assert config.dmc_version is None
assert config.theme_dir_user == Path.home() / '.config' / 'claude' / 'themes'
assert config.theme_dir_project is None
assert config.default_theme is None
def test_config_load_returns_dict(config, clean_env):
"""Test config.load() returns expected structure"""
result = config.load()
assert isinstance(result, dict)
assert 'dmc_version' in result
assert 'dmc_available' in result
assert 'theme_dir_user' in result
assert 'theme_dir_project' in result
assert 'default_theme' in result
assert 'project_dir' in result
def test_config_respects_env_dmc_version(config, clean_env):
"""Test that DMC_VERSION env var is respected"""
os.environ['DMC_VERSION'] = '0.14.7'
result = config.load()
assert result['dmc_version'] == '0.14.7'
assert result['dmc_available'] is True
def test_config_respects_default_theme_env(config, clean_env):
"""Test that VIZ_DEFAULT_THEME env var is respected"""
os.environ['VIZ_DEFAULT_THEME'] = 'my-dark-theme'
result = config.load()
assert result['default_theme'] == 'my-dark-theme'
def test_detect_dmc_version_not_installed(config):
"""Test DMC version detection when not installed"""
with patch('importlib.metadata.version', side_effect=ImportError("not installed")):
version = config._detect_dmc_version()
assert version is None
def test_detect_dmc_version_installed(config):
"""Test DMC version detection when installed"""
with patch('importlib.metadata.version', return_value='0.14.7'):
version = config._detect_dmc_version()
assert version == '0.14.7'
def test_find_project_directory_from_env(config, clean_env, tmp_path):
"""Test project directory detection from CLAUDE_PROJECT_DIR"""
os.environ['CLAUDE_PROJECT_DIR'] = str(tmp_path)
result = config._find_project_directory()
assert result == tmp_path
def test_find_project_directory_with_git(config, clean_env, tmp_path):
"""Test project directory detection with .git folder"""
git_dir = tmp_path / '.git'
git_dir.mkdir()
with patch.dict(os.environ, {'PWD': str(tmp_path)}):
result = config._find_project_directory()
assert result == tmp_path
def test_find_project_directory_with_env_file(config, clean_env, tmp_path):
"""Test project directory detection with .env file"""
env_file = tmp_path / '.env'
env_file.touch()
with patch.dict(os.environ, {'PWD': str(tmp_path)}):
result = config._find_project_directory()
assert result == tmp_path
def test_load_config_convenience_function(clean_env):
"""Test the convenience function load_config()"""
from mcp_server.config import load_config
result = load_config()
assert isinstance(result, dict)
assert 'dmc_version' in result
def test_check_dmc_version_not_installed(clean_env):
"""Test check_dmc_version when DMC not installed"""
from mcp_server.config import check_dmc_version
with patch('mcp_server.config.load_config', return_value={'dmc_available': False}):
result = check_dmc_version()
assert result['installed'] is False
assert 'not installed' in result['message'].lower()
def test_check_dmc_version_installed_with_registry(clean_env, tmp_path):
"""Test check_dmc_version when DMC installed with matching registry"""
from mcp_server.config import check_dmc_version
mock_config = {
'dmc_available': True,
'dmc_version': '2.5.1'
}
with patch('mcp_server.config.load_config', return_value=mock_config):
with patch('pathlib.Path.exists', return_value=True):
result = check_dmc_version()
assert result['installed'] is True
assert result['version'] == '2.5.1'

View File

@@ -1,283 +0,0 @@
"""
Unit tests for DMC validation tools.
"""
import pytest
from unittest.mock import MagicMock, patch
@pytest.fixture
def mock_registry():
"""Create a mock component registry"""
registry = MagicMock()
registry.is_loaded.return_value = True
registry.loaded_version = "2.5.1"
registry.categories = {
"buttons": ["Button", "ActionIcon"],
"inputs": ["TextInput", "Select"]
}
registry.list_components.return_value = registry.categories
registry.get_categories.return_value = ["buttons", "inputs"]
# Mock Button component
registry.get_component.side_effect = lambda name: {
"Button": {
"description": "Button component",
"props": {
"variant": {"type": "string", "enum": ["filled", "outline"], "default": "filled"},
"color": {"type": "string", "default": "blue"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg"], "default": "sm"},
"disabled": {"type": "boolean", "default": False, "required": False}
}
},
"TextInput": {
"description": "Text input",
"props": {
"value": {"type": "string", "required": True},
"placeholder": {"type": "string"}
}
}
}.get(name)
registry.get_component_props.side_effect = lambda name: {
"Button": {
"variant": {"type": "string", "enum": ["filled", "outline"], "default": "filled"},
"color": {"type": "string", "default": "blue"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg"], "default": "sm"},
"disabled": {"type": "boolean", "default": False}
},
"TextInput": {
"value": {"type": "string", "required": True},
"placeholder": {"type": "string"}
}
}.get(name)
registry.validate_prop.side_effect = lambda comp, prop, val: (
{"valid": True} if prop in ["variant", "color", "size", "disabled", "value", "placeholder"]
else {"valid": False, "error": f"Unknown prop '{prop}'"}
)
return registry
@pytest.fixture
def dmc_tools(mock_registry):
"""Create DMCTools instance with mock registry"""
from mcp_server.dmc_tools import DMCTools
tools = DMCTools(registry=mock_registry)
tools._initialized = True
return tools
@pytest.fixture
def uninitialized_tools():
"""Create uninitialized DMCTools instance"""
from mcp_server.dmc_tools import DMCTools
return DMCTools()
@pytest.mark.asyncio
async def test_list_components_all(dmc_tools):
"""Test listing all components"""
result = await dmc_tools.list_components()
assert "components" in result
assert "categories" in result
assert "version" in result
assert result["version"] == "2.5.1"
@pytest.mark.asyncio
async def test_list_components_by_category(dmc_tools, mock_registry):
"""Test listing components by category"""
mock_registry.list_components.return_value = {"buttons": ["Button", "ActionIcon"]}
result = await dmc_tools.list_components(category="buttons")
assert "buttons" in result["components"]
mock_registry.list_components.assert_called_with("buttons")
@pytest.mark.asyncio
async def test_list_components_not_initialized(uninitialized_tools):
"""Test listing components when not initialized"""
result = await uninitialized_tools.list_components()
assert "error" in result
assert result["total_count"] == 0
@pytest.mark.asyncio
async def test_get_component_props_success(dmc_tools):
"""Test getting component props"""
result = await dmc_tools.get_component_props("Button")
assert result["component"] == "Button"
assert "props" in result
assert result["prop_count"] > 0
@pytest.mark.asyncio
async def test_get_component_props_not_found(dmc_tools, mock_registry):
"""Test getting props for nonexistent component"""
mock_registry.get_component.return_value = None
result = await dmc_tools.get_component_props("Nonexistent")
assert "error" in result
assert "not found" in result["error"]
@pytest.mark.asyncio
async def test_get_component_props_not_initialized(uninitialized_tools):
"""Test getting props when not initialized"""
result = await uninitialized_tools.get_component_props("Button")
assert "error" in result
assert result["prop_count"] == 0
@pytest.mark.asyncio
async def test_validate_component_valid(dmc_tools, mock_registry):
"""Test validating valid component props"""
props = {
"variant": "filled",
"color": "blue",
"size": "md"
}
result = await dmc_tools.validate_component("Button", props)
assert result["valid"] is True
assert len(result["errors"]) == 0
assert result["component"] == "Button"
@pytest.mark.asyncio
async def test_validate_component_invalid_prop(dmc_tools, mock_registry):
"""Test validating with invalid prop name"""
mock_registry.validate_prop.side_effect = lambda comp, prop, val: (
{"valid": False, "error": f"Unknown prop '{prop}'"} if prop == "unknownProp"
else {"valid": True}
)
props = {"unknownProp": "value"}
result = await dmc_tools.validate_component("Button", props)
assert result["valid"] is False
assert len(result["errors"]) > 0
@pytest.mark.asyncio
async def test_validate_component_missing_required(dmc_tools, mock_registry):
"""Test validating with missing required prop"""
# TextInput has required value prop
mock_registry.get_component.return_value = {
"props": {
"value": {"type": "string", "required": True}
}
}
result = await dmc_tools.validate_component("TextInput", {})
assert result["valid"] is False
assert any("required" in e.lower() for e in result["errors"])
@pytest.mark.asyncio
async def test_validate_component_not_found(dmc_tools, mock_registry):
"""Test validating nonexistent component"""
mock_registry.get_component.return_value = None
result = await dmc_tools.validate_component("Nonexistent", {"prop": "value"})
assert result["valid"] is False
assert "Unknown component" in result["errors"][0]
@pytest.mark.asyncio
async def test_validate_component_not_initialized(uninitialized_tools):
"""Test validating when not initialized"""
result = await uninitialized_tools.validate_component("Button", {})
assert result["valid"] is False
assert "not initialized" in result["errors"][0].lower()
@pytest.mark.asyncio
async def test_validate_component_skips_special_props(dmc_tools, mock_registry):
"""Test that special props (id, children, etc) are skipped"""
props = {
"id": "my-button",
"children": "Click me",
"className": "my-class",
"style": {"color": "red"},
"key": "btn-1"
}
result = await dmc_tools.validate_component("Button", props)
# Should not error on special props
assert result["valid"] is True
def test_find_similar_component(dmc_tools, mock_registry):
"""Test finding similar component names"""
# Should find Button when given 'button' (case mismatch)
similar = dmc_tools._find_similar_component("button")
assert similar == "Button"
def test_find_similar_component_prefix(dmc_tools, mock_registry):
"""Test finding similar component with prefix match"""
similar = dmc_tools._find_similar_component("Butt")
assert similar == "Button"
def test_check_common_mistakes_onclick(dmc_tools):
"""Test detection of onclick event handler mistake"""
warnings = []
dmc_tools._check_common_mistakes("Button", {"onClick": "handler"}, warnings)
assert len(warnings) > 0
assert any("callback" in w.lower() for w in warnings)
def test_check_common_mistakes_class(dmc_tools):
"""Test detection of 'class' instead of 'className'"""
warnings = []
dmc_tools._check_common_mistakes("Button", {"class": "my-class"}, warnings)
assert len(warnings) > 0
assert any("classname" in w.lower() for w in warnings)
def test_check_common_mistakes_button_href(dmc_tools):
"""Test detection of Button with href but no component prop"""
warnings = []
dmc_tools._check_common_mistakes("Button", {"href": "/link"}, warnings)
assert len(warnings) > 0
assert any("component" in w.lower() for w in warnings)
def test_initialize_with_version():
"""Test initializing tools with DMC version"""
from mcp_server.dmc_tools import DMCTools
tools = DMCTools()
with patch('mcp_server.dmc_tools.ComponentRegistry') as MockRegistry:
mock_instance = MagicMock()
mock_instance.is_loaded.return_value = True
MockRegistry.return_value = mock_instance
result = tools.initialize(dmc_version="2.5.1")
MockRegistry.assert_called_once_with("2.5.1")
assert result is True

View File

@@ -1,304 +0,0 @@
"""
Unit tests for theme management tools.
"""
import pytest
from unittest.mock import MagicMock, patch
@pytest.fixture
def theme_store():
"""Create a fresh ThemeStore instance"""
from mcp_server.theme_store import ThemeStore
store = ThemeStore()
store._themes = {} # Clear any existing themes
return store
@pytest.fixture
def theme_tools(theme_store):
"""Create ThemeTools instance with fresh store"""
from mcp_server.theme_tools import ThemeTools
return ThemeTools(store=theme_store)
def test_theme_store_init():
"""Test theme store initialization"""
from mcp_server.theme_store import ThemeStore
store = ThemeStore()
# Should have default theme
assert store.get_theme("default") is not None
def test_default_theme_structure():
"""Test default theme has required structure"""
from mcp_server.theme_store import DEFAULT_THEME
assert "name" in DEFAULT_THEME
assert "tokens" in DEFAULT_THEME
assert "colors" in DEFAULT_THEME["tokens"]
assert "spacing" in DEFAULT_THEME["tokens"]
assert "typography" in DEFAULT_THEME["tokens"]
assert "radii" in DEFAULT_THEME["tokens"]
def test_default_theme_colors():
"""Test default theme has required color tokens"""
from mcp_server.theme_store import DEFAULT_THEME
colors = DEFAULT_THEME["tokens"]["colors"]
assert "primary" in colors
assert "secondary" in colors
assert "success" in colors
assert "warning" in colors
assert "error" in colors
assert "background" in colors
assert "text" in colors
@pytest.mark.asyncio
async def test_theme_create(theme_tools):
"""Test creating a new theme"""
tokens = {
"colors": {
"primary": "#ff0000"
}
}
result = await theme_tools.theme_create("my-theme", tokens)
assert result["name"] == "my-theme"
assert "tokens" in result
assert result["tokens"]["colors"]["primary"] == "#ff0000"
@pytest.mark.asyncio
async def test_theme_create_merges_with_defaults(theme_tools):
"""Test that new theme merges with default tokens"""
tokens = {
"colors": {
"primary": "#ff0000"
}
}
result = await theme_tools.theme_create("partial-theme", tokens)
# Should have primary from our tokens
assert result["tokens"]["colors"]["primary"] == "#ff0000"
# Should inherit secondary from defaults
assert "secondary" in result["tokens"]["colors"]
@pytest.mark.asyncio
async def test_theme_create_duplicate_name(theme_tools, theme_store):
"""Test creating theme with existing name fails"""
# Create first theme
await theme_tools.theme_create("existing", {"colors": {}})
# Try to create with same name
result = await theme_tools.theme_create("existing", {"colors": {}})
assert "error" in result
assert "already exists" in result["error"]
@pytest.mark.asyncio
async def test_theme_extend(theme_tools, theme_store):
"""Test extending an existing theme"""
# Create base theme
await theme_tools.theme_create("base", {
"colors": {"primary": "#0000ff"}
})
# Extend it
result = await theme_tools.theme_extend(
base_theme="base",
overrides={"colors": {"secondary": "#00ff00"}},
new_name="extended"
)
assert result["name"] == "extended"
# Should have base primary
assert result["tokens"]["colors"]["primary"] == "#0000ff"
# Should have override secondary
assert result["tokens"]["colors"]["secondary"] == "#00ff00"
@pytest.mark.asyncio
async def test_theme_extend_nonexistent_base(theme_tools):
"""Test extending nonexistent theme fails"""
result = await theme_tools.theme_extend(
base_theme="nonexistent",
overrides={},
new_name="new"
)
assert "error" in result
assert "not found" in result["error"]
@pytest.mark.asyncio
async def test_theme_extend_default_name(theme_tools, theme_store):
"""Test extending creates default name if not provided"""
await theme_tools.theme_create("base", {"colors": {}})
result = await theme_tools.theme_extend(
base_theme="base",
overrides={}
# No new_name provided
)
assert result["name"] == "base_extended"
@pytest.mark.asyncio
async def test_theme_validate(theme_tools, theme_store):
"""Test theme validation"""
await theme_tools.theme_create("test-theme", {
"colors": {"primary": "#ff0000"},
"spacing": {"md": "16px"}
})
result = await theme_tools.theme_validate("test-theme")
assert "complete" in result or "validation" in result
@pytest.mark.asyncio
async def test_theme_validate_nonexistent(theme_tools):
"""Test validating nonexistent theme"""
result = await theme_tools.theme_validate("nonexistent")
assert "error" in result
@pytest.mark.asyncio
async def test_theme_export_css(theme_tools, theme_store):
"""Test exporting theme as CSS"""
await theme_tools.theme_create("css-theme", {
"colors": {"primary": "#ff0000"},
"spacing": {"md": "16px"}
})
result = await theme_tools.theme_export_css("css-theme")
assert "css" in result
# CSS should contain custom properties
assert "--" in result["css"]
@pytest.mark.asyncio
async def test_theme_export_css_nonexistent(theme_tools):
"""Test exporting nonexistent theme"""
result = await theme_tools.theme_export_css("nonexistent")
assert "error" in result
@pytest.mark.asyncio
async def test_theme_list(theme_tools, theme_store):
"""Test listing themes"""
await theme_tools.theme_create("theme1", {"colors": {}})
await theme_tools.theme_create("theme2", {"colors": {}})
result = await theme_tools.theme_list()
assert "themes" in result
assert "theme1" in result["themes"]
assert "theme2" in result["themes"]
@pytest.mark.asyncio
async def test_theme_activate(theme_tools, theme_store):
"""Test activating a theme"""
await theme_tools.theme_create("active-theme", {"colors": {}})
result = await theme_tools.theme_activate("active-theme")
assert result.get("active_theme") == "active-theme" or result.get("success") is True
@pytest.mark.asyncio
async def test_theme_activate_nonexistent(theme_tools):
"""Test activating nonexistent theme"""
result = await theme_tools.theme_activate("nonexistent")
assert "error" in result
def test_theme_store_get_theme(theme_store):
"""Test getting theme from store"""
from mcp_server.theme_store import DEFAULT_THEME
# Add a theme first, then retrieve it
theme_store._themes["test-theme"] = {"name": "test-theme", "tokens": {}}
result = theme_store.get_theme("test-theme")
assert result is not None
assert result["name"] == "test-theme"
def test_theme_store_list_themes(theme_store):
"""Test listing themes from store"""
result = theme_store.list_themes()
assert isinstance(result, list)
def test_deep_merge(theme_tools):
"""Test deep merging of token dicts"""
base = {
"colors": {
"primary": "#000",
"secondary": "#111"
},
"spacing": {"sm": "8px"}
}
override = {
"colors": {
"primary": "#fff"
}
}
result = theme_tools._deep_merge(base, override)
# primary should be overridden
assert result["colors"]["primary"] == "#fff"
# secondary should remain
assert result["colors"]["secondary"] == "#111"
# spacing should remain
assert result["spacing"]["sm"] == "8px"
def test_validate_tokens(theme_tools):
"""Test token validation"""
from mcp_server.theme_store import REQUIRED_TOKEN_CATEGORIES
tokens = {
"colors": {"primary": "#000"},
"spacing": {"md": "16px"},
"typography": {"fontFamily": "Inter"},
"radii": {"md": "8px"}
}
result = theme_tools._validate_tokens(tokens)
assert "complete" in result
# Check for either "missing" or "missing_required" key
assert "missing" in result or "missing_required" in result or "missing_optional" in result
def test_validate_tokens_incomplete(theme_tools):
"""Test validation of incomplete tokens"""
tokens = {
"colors": {"primary": "#000"}
# Missing spacing, typography, radii
}
result = theme_tools._validate_tokens(tokens)
# Should flag missing categories
assert result["complete"] is False or len(result.get("missing", [])) > 0

View File

@@ -90,10 +90,6 @@ After clarification, you receive a structured specification:
[List of assumptions] [List of assumptions]
``` ```
## Documentation
- [Neurodivergent Support Guide](docs/ND-SUPPORT.md) - How clarity-assist supports ND users with executive function challenges and cognitive differences
## Integration ## Integration
For CLAUDE.md integration instructions, see `claude-md-integration.md`. For CLAUDE.md integration instructions, see `claude-md-integration.md`.

View File

@@ -1,15 +1,5 @@
# Clarity Coach Agent # Clarity Coach Agent
## Visual Output Requirements
**MANDATORY: Display header at start of every response.**
```
┌──────────────────────────────────────────────────────────────────┐
│ 💬 CLARITY-ASSIST · Clarity Coach │
└──────────────────────────────────────────────────────────────────┘
```
## Role ## Role
You are a patient, structured coach specializing in helping users articulate their requirements clearly. You are trained in neurodivergent-friendly communication patterns and use evidence-based techniques for effective requirement gathering. You are a patient, structured coach specializing in helping users articulate their requirements clearly. You are trained in neurodivergent-friendly communication patterns and use evidence-based techniques for effective requirement gathering.

View File

@@ -1,17 +1,5 @@
# /clarify - Full Prompt Optimization # /clarify - Full Prompt Optimization
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 💬 CLARITY-ASSIST · Prompt Optimization │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the workflow.
## Purpose ## Purpose
Transform vague, incomplete, or ambiguous requests into clear, actionable specifications using the 4-D methodology with neurodivergent-friendly accommodations. Transform vague, incomplete, or ambiguous requests into clear, actionable specifications using the 4-D methodology with neurodivergent-friendly accommodations.

View File

@@ -1,17 +1,5 @@
# /quick-clarify - Rapid Clarification Mode # /quick-clarify - Rapid Clarification Mode
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 💬 CLARITY-ASSIST · Quick Clarify │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the workflow.
## Purpose ## Purpose
Single-pass clarification for requests that are mostly clear but need minor disambiguation. Single-pass clarification for requests that are mostly clear but need minor disambiguation.

View File

@@ -1,328 +0,0 @@
# Neurodivergent Support in clarity-assist
This document describes how clarity-assist is designed to support users with neurodivergent traits, including ADHD, autism, anxiety, and other conditions that affect executive function, sensory processing, or cognitive style.
## Overview
### Purpose
clarity-assist exists to help all users transform vague or incomplete requests into clear, actionable specifications. For neurodivergent users specifically, it addresses common challenges:
- **Executive function difficulties** - Breaking down complex tasks, getting started, managing scope
- **Working memory limitations** - Keeping track of context across long conversations
- **Decision fatigue** - Facing too many open-ended choices
- **Processing style differences** - Preferring structured, predictable interactions
- **Anxiety around uncertainty** - Needing clear expectations and explicit confirmation
### Philosophy
Our design philosophy centers on three principles:
1. **Reduce cognitive load** - Never force the user to hold too much in their head at once
2. **Provide structure** - Use consistent, predictable patterns for all interactions
3. **Respect different communication styles** - Accommodate rather than assume one "right" way to think
## Features for ND Users
### 1. Reduced Cognitive Load
**Prompt Simplification**
- The 4-D methodology (Deconstruct, Diagnose, Develop, Deliver) breaks down complex requests into manageable phases
- Users never need to specify everything upfront - clarification happens incrementally
**Task Breakdown**
- Large requests are decomposed into explicit components
- Dependencies and relationships are surfaced rather than left implicit
- Scope boundaries are clearly defined (in-scope vs. out-of-scope)
### 2. Structured Output
**Consistent Formatting**
- Every clarification session produces the same structured specification:
- Summary (1-2 sentences)
- Scope (In/Out)
- Requirements table (numbered, prioritized)
- Assumptions list
- This predictability reduces the mental effort of parsing responses
**Predictable Patterns**
- Questions always follow the same format
- Progress summaries appear at regular intervals
- Escalation (simple to complex) is always offered, never forced
**Bulleted Lists Over Prose**
- Requirements are presented as scannable lists, not paragraphs
- Options are numbered for easy reference
- Key information is highlighted with bold labels
### 3. Customizable Verbosity
**Detail Levels**
- `/clarify` - Full methodology for complex requests (more thorough, more questions)
- `/quick-clarify` - Rapid mode for simple disambiguation (fewer questions, faster)
**User Control**
- Users can always say "that's enough detail" to end questioning early
- The plugin offers to break sessions into smaller parts
- "Good enough for now" is explicitly validated as an acceptable outcome
### 4. Vagueness Detection
The `UserPromptSubmit` hook automatically detects prompts that might benefit from clarification and gently suggests using `/clarify`.
**Detection Signals**
- Short prompts (< 10 words) without specific technical terms
- Vague action phrases: "help me", "fix this", "make it better"
- Ambiguous scope words: "somehow", "something", "stuff", "etc."
- Open questions without context
**Non-Blocking Approach**
- The hook never prevents you from proceeding
- It provides a suggestion with a vagueness score (percentage)
- You can disable auto-suggestions entirely via environment variable
### 5. Focus Aids
**Task Prioritization**
- Requirements are tagged as Must/Should/Could/Won't (MoSCoW)
- Critical items are separated from nice-to-haves
- Scope creep is explicitly called out and deferred
**Context Switching Warnings**
- When questions touch multiple areas, the plugin acknowledges the complexity
- Offers to focus on one aspect at a time
- Summarizes frequently to rebuild context after interruptions
## How It Works
### The UserPromptSubmit Hook
When you submit a prompt, the vagueness detection hook (`hooks/vagueness-check.sh`) runs automatically:
```
User submits prompt
|
v
Hook reads prompt from stdin
|
v
Skip if: empty, starts with /, or contains file paths
|
v
Calculate vagueness score (0.0 - 1.0)
- Short prompts: +0.3
- Vague action phrases: +0.2
- Ambiguous scope words: +0.15
- Missing technical specifics: +0.2
- Short questions without context: +0.15
|
v
If score >= threshold (default 0.6):
- Output gentle suggestion with [clarity-assist] prefix
- Show vagueness percentage
|
v
Exit 0 (always non-blocking)
```
### Example Hook Output
```
[clarity-assist] Your prompt could benefit from more clarity.
[clarity-assist] Consider running /clarity-assist to refine your request.
[clarity-assist] (Vagueness score: 65% - this is a suggestion, not a block)
```
### The 4-D Methodology
| Phase | What Happens | ND Benefit |
|-------|--------------|------------|
| **Deconstruct** | Break request into components | Shows the full picture without overwhelming |
| **Diagnose** | Identify gaps and conflicts | Surfaces hidden assumptions |
| **Develop** | Ask structured questions | Option-based, chunked, contextual |
| **Deliver** | Produce specification | Consistent, scannable format |
## Usage Examples
### Before: Vague Request
```
User: Make the app faster
```
This triggers vagueness detection because:
- Short (4 words)
- Vague action ("make")
- No specific technical context
- No measurable outcome
### After: Clarified with /clarify
```
User: /clarify Make the app faster
Claude: Let me help clarify what kind of performance improvements you're looking for.
**What type of slowness are you experiencing?**
1. Initial page load takes too long
2. Interactions feel sluggish (clicking, typing)
3. API calls are slow
4. All of the above
5. Other
[I'm asking because each has very different solutions]
```
```
User: 1
Claude: Initial page load - got it.
**What's an acceptable load time for you?**
1. Under 1 second (aggressive)
2. Under 2 seconds (standard)
3. Under 3 seconds (relaxed)
4. I don't have a specific target
[This helps me know whether to suggest quick wins or a major optimization effort]
```
After 2-3 more questions, the user receives:
```markdown
## Clarified Request
### Summary
Optimize initial page load time to under 2 seconds by addressing bundle size and initial render blocking resources.
### Scope
**In Scope:**
- Bundle analysis and splitting
- Lazy loading non-critical components
- Image optimization
- CSS/JS minification check
**Out of Scope:**
- Backend API optimization
- Database query performance
- CDN setup
### Requirements
| # | Requirement | Priority | Notes |
|---|-------------|----------|-------|
| 1 | Page loads in < 2s on 4G | Must | Measured via Lighthouse |
| 2 | Largest contentful paint < 1.5s | Should | Core Web Vital |
| 3 | No layout shift after load | Should | CLS < 0.1 |
### Assumptions
- Current load time is > 3 seconds (based on "too long" description)
- Target browsers: modern evergreen (Chrome, Firefox, Safari)
- Build tooling already configured (webpack/vite)
```
### Common Patterns Detected
| Pattern | Example | What It Triggers |
|---------|---------|------------------|
| Missing context | "Fix the bug" | "Which bug? In which file or feature?" |
| Ambiguous scope | "Update the user stuff" | "What specifically needs updating?" |
| Unclear goals | "Make it better" | "Better in what way? Faster? Cleaner? More features?" |
| Implicit knowledge | "Do it like last time" | "I don't have context from previous sessions" |
| Conflicting requests | "Keep it simple but add everything" | "Which matters more: simplicity or completeness?" |
### Quick-Clarify Example
For simpler requests that just need minor disambiguation:
```
User: /quick-clarify Add a delete button to the user list
Claude: I'll add a delete button to each row in the user list.
**Should deletion require confirmation?**
1. Yes - show confirmation dialog (Recommended)
2. No - delete immediately
3. Soft delete - mark as deleted but keep data
[Asking because accidental deletions can be problematic]
```
```
User: 1
Claude: Got it. I'll implement a delete button with confirmation dialog. Starting now.
```
## Configuration Options
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CLARITY_ASSIST_AUTO_SUGGEST` | `true` | Enable/disable automatic vagueness detection |
| `CLARITY_ASSIST_VAGUENESS_THRESHOLD` | `0.6` | Score threshold to trigger suggestion (0.0-1.0) |
### Disabling Auto-Suggestions
If you find the vagueness detection unhelpful, disable it in your shell profile or `.env`:
```bash
export CLARITY_ASSIST_AUTO_SUGGEST=false
```
### Adjusting Sensitivity
To make detection more or less sensitive:
```bash
# More sensitive (suggests more often)
export CLARITY_ASSIST_VAGUENESS_THRESHOLD=0.4
# Less sensitive (only very vague prompts)
export CLARITY_ASSIST_VAGUENESS_THRESHOLD=0.8
```
## Tips for ND Users
### If You're Feeling Overwhelmed
- Use `/quick-clarify` instead of `/clarify` for faster interactions
- Say "let's focus on just one thing" to narrow scope
- Ask to "pause and summarize" at any point
- It's OK to say "I don't know" - the plugin will offer concrete alternatives
### If You Have Executive Function Challenges
- Start with `/clarify` even for tasks you think are simple - it helps with planning
- The structured specification can serve as a checklist
- Use the scope boundaries to prevent scope creep
### If You Prefer Detailed Structure
- The 4-D methodology provides a predictable framework
- All output follows consistent formatting
- Questions always offer numbered options
### If You Have Anxiety About Getting It Right
- The plugin validates "good enough for now" as acceptable
- You can always revisit and change earlier answers
- Assumptions are explicitly listed - nothing is hidden
## Accessibility Notes
- All output uses standard markdown that works with screen readers
- No time pressure - take as long as you need between responses
- Questions are designed to be answerable without deep context retrieval
- Visual patterns (bold, bullets, tables) create scannable structure
## Feedback
If you have suggestions for improving neurodivergent support in clarity-assist, please open an issue at:
https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/issues
Include the label `clarity-assist` and describe:
- What challenge you faced
- What would have helped
- Any specific accommodations you'd like to see

View File

@@ -1,10 +0,0 @@
{
"hooks": {
"UserPromptSubmit": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/vagueness-check.sh"
}
]
}
}

View File

@@ -1,216 +0,0 @@
#!/bin/bash
# clarity-assist vagueness detection hook
# Analyzes user prompts for vagueness and suggests /clarity-assist when beneficial
# All output MUST have [clarity-assist] prefix
# This is a NON-BLOCKING hook - always exits 0
PREFIX="[clarity-assist]"
# Check if auto-suggest is enabled (default: true)
AUTO_SUGGEST="${CLARITY_ASSIST_AUTO_SUGGEST:-true}"
if [[ "$AUTO_SUGGEST" != "true" ]]; then
exit 0
fi
# Threshold for vagueness score (default: 0.6)
THRESHOLD="${CLARITY_ASSIST_VAGUENESS_THRESHOLD:-0.6}"
# Read user prompt from stdin
PROMPT=""
if [[ -t 0 ]]; then
# No stdin available
exit 0
else
PROMPT=$(cat)
fi
# Skip empty prompts
if [[ -z "$PROMPT" ]]; then
exit 0
fi
# Skip if prompt is a command (starts with /)
if [[ "$PROMPT" =~ ^[[:space:]]*/[a-zA-Z] ]]; then
exit 0
fi
# Skip if prompt mentions specific files or paths
if [[ "$PROMPT" =~ \.(py|js|ts|sh|md|json|yaml|yml|txt|css|html|go|rs|java|c|cpp|h)([[:space:]]|$|[^a-zA-Z]) ]] || \
[[ "$PROMPT" =~ [/\\][a-zA-Z0-9_-]+[/\\] ]] || \
[[ "$PROMPT" =~ (src|lib|test|docs|plugins|hooks|commands)/ ]]; then
exit 0
fi
# Initialize vagueness score
SCORE=0
# Count words in the prompt
WORD_COUNT=$(echo "$PROMPT" | wc -w | tr -d ' ')
# ============================================================================
# Vagueness Signal Detection
# ============================================================================
# Signal 1: Very short prompts (< 10 words) are often vague
if [[ "$WORD_COUNT" -lt 10 ]]; then
# But very short specific commands are OK
if [[ "$WORD_COUNT" -lt 3 ]]; then
# Extremely short - probably intentional or a command
:
else
SCORE=$(echo "$SCORE + 0.3" | bc)
fi
fi
# Signal 2: Vague action phrases (no specific outcome)
VAGUE_ACTIONS=(
"help me"
"help with"
"do something"
"work on"
"look at"
"check this"
"fix it"
"fix this"
"make it better"
"make this better"
"improve it"
"improve this"
"update this"
"update it"
"change it"
"change this"
"can you"
"could you"
"would you"
"please help"
)
PROMPT_LOWER=$(echo "$PROMPT" | tr '[:upper:]' '[:lower:]')
for phrase in "${VAGUE_ACTIONS[@]}"; do
if [[ "$PROMPT_LOWER" == *"$phrase"* ]]; then
SCORE=$(echo "$SCORE + 0.2" | bc)
break
fi
done
# Signal 3: Ambiguous scope indicators
AMBIGUOUS_SCOPE=(
"somehow"
"something"
"somewhere"
"anything"
"whatever"
"stuff"
"things"
"etc"
"and so on"
)
for word in "${AMBIGUOUS_SCOPE[@]}"; do
if [[ "$PROMPT_LOWER" == *"$word"* ]]; then
SCORE=$(echo "$SCORE + 0.15" | bc)
break
fi
done
# Signal 4: Missing context indicators (no reference to what/where)
# Check if prompt lacks specificity markers
HAS_SPECIFICS=false
# Specific technical terms suggest clarity
SPECIFIC_MARKERS=(
"function"
"class"
"method"
"variable"
"error"
"bug"
"test"
"api"
"endpoint"
"database"
"query"
"component"
"module"
"service"
"config"
"install"
"deploy"
"build"
"run"
"execute"
"create"
"delete"
"add"
"remove"
"implement"
"refactor"
"migrate"
"upgrade"
"debug"
"log"
"exception"
"stack"
"memory"
"performance"
"security"
"auth"
"token"
"session"
"route"
"controller"
"model"
"view"
"template"
"schema"
"migration"
"commit"
"branch"
"merge"
"pull"
"push"
)
for marker in "${SPECIFIC_MARKERS[@]}"; do
if [[ "$PROMPT_LOWER" == *"$marker"* ]]; then
HAS_SPECIFICS=true
break
fi
done
if [[ "$HAS_SPECIFICS" == false ]] && [[ "$WORD_COUNT" -gt 3 ]]; then
SCORE=$(echo "$SCORE + 0.2" | bc)
fi
# Signal 5: Question without context
if [[ "$PROMPT" =~ \?$ ]] && [[ "$WORD_COUNT" -lt 8 ]]; then
# Short questions without specifics are often vague
if [[ "$HAS_SPECIFICS" == false ]]; then
SCORE=$(echo "$SCORE + 0.15" | bc)
fi
fi
# Cap score at 1.0
if (( $(echo "$SCORE > 1.0" | bc -l) )); then
SCORE="1.0"
fi
# ============================================================================
# Output suggestion if score exceeds threshold
# ============================================================================
# Compare score to threshold using bc
if (( $(echo "$SCORE >= $THRESHOLD" | bc -l) )); then
# Format score as percentage for display
SCORE_PCT=$(echo "$SCORE * 100" | bc | cut -d'.' -f1)
# Gentle, non-blocking suggestion
echo "$PREFIX Your prompt could benefit from more clarity."
echo "$PREFIX Consider running /clarity-assist to refine your request."
echo "$PREFIX (Vagueness score: ${SCORE_PCT}% - this is a suggestion, not a block)"
fi
# Always exit 0 - this hook is non-blocking
exit 0

View File

@@ -37,33 +37,6 @@ Create a new CLAUDE.md tailored to your project.
/config-init /config-init
``` ```
### `/config-diff`
Show differences between current CLAUDE.md and previous versions.
```
/config-diff # Compare working copy vs last commit
/config-diff --commit=abc1234 # Compare against specific commit
/config-diff --from=v1.0 --to=v2.0 # Compare two commits
/config-diff --section="Critical Rules" # Focus on specific section
```
### `/config-lint`
Lint CLAUDE.md for common anti-patterns and best practices.
```
/config-lint # Run all lint checks
/config-lint --fix # Auto-fix fixable issues
/config-lint --rules=security # Check only security rules
/config-lint --severity=error # Show only errors
```
**Lint Rule Categories:**
- **Security (SEC)** - Hardcoded secrets, paths, credentials
- **Structure (STR)** - Header hierarchy, required sections
- **Content (CNT)** - Contradictions, duplicates, vague rules
- **Format (FMT)** - Consistency, code blocks, whitespace
- **Best Practice (BPR)** - Missing Quick Start, Critical Rules sections
## Best Practices ## Best Practices
A good CLAUDE.md should be: A good CLAUDE.md should be:

View File

@@ -7,16 +7,6 @@ description: CLAUDE.md optimization and maintenance agent
You are the **Maintainer Agent** - a specialist in creating and optimizing CLAUDE.md configuration files for Claude Code projects. Your role is to ensure CLAUDE.md files are clear, concise, well-structured, and follow best practices. You are the **Maintainer Agent** - a specialist in creating and optimizing CLAUDE.md configuration files for Claude Code projects. Your role is to ensure CLAUDE.md files are clear, concise, well-structured, and follow best practices.
## Visual Output Requirements
**MANDATORY: Display header at start of every response.**
```
┌──────────────────────────────────────────────────────────────────┐
│ ⚙️ CONFIG-MAINTAINER · CLAUDE.md Optimization │
└──────────────────────────────────────────────────────────────────┘
```
## Your Personality ## Your Personality
**Optimization-Focused:** **Optimization-Focused:**

View File

@@ -6,18 +6,6 @@ description: Analyze CLAUDE.md for optimization opportunities and plugin integra
This command analyzes your project's CLAUDE.md file and provides a detailed report on optimization opportunities and plugin integration status. This command analyzes your project's CLAUDE.md file and provides a detailed report on optimization opportunities and plugin integration status.
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ ⚙️ CONFIG-MAINTAINER · CLAUDE.md Analysis │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the analysis.
## What This Command Does ## What This Command Does
1. **Read CLAUDE.md** - Locates and reads the project's CLAUDE.md file 1. **Read CLAUDE.md** - Locates and reads the project's CLAUDE.md file

View File

@@ -1,251 +0,0 @@
---
description: Show diff between current CLAUDE.md and last commit
---
# Compare CLAUDE.md Changes
This command shows differences between your current CLAUDE.md file and previous versions, helping track configuration drift and review changes before committing.
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ ⚙️ CONFIG-MAINTAINER · CLAUDE.md Diff │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the diff.
## What This Command Does
1. **Detect CLAUDE.md Location** - Finds the project's CLAUDE.md file
2. **Compare Versions** - Shows diff against last commit or specified revision
3. **Highlight Sections** - Groups changes by affected sections
4. **Summarize Impact** - Explains what the changes mean for Claude's behavior
## Usage
```
/config-diff
```
Compare against a specific commit:
```
/config-diff --commit=abc1234
/config-diff --commit=HEAD~3
```
Compare two specific commits:
```
/config-diff --from=abc1234 --to=def5678
```
Show only specific sections:
```
/config-diff --section="Critical Rules"
/config-diff --section="Quick Start"
```
## Comparison Modes
### Default: Working vs Last Commit
Shows uncommitted changes to CLAUDE.md:
```
/config-diff
```
### Working vs Specific Commit
Shows changes since a specific point:
```
/config-diff --commit=v1.0.0
```
### Commit to Commit
Shows changes between two historical versions:
```
/config-diff --from=v1.0.0 --to=v2.0.0
```
### Branch Comparison
Shows CLAUDE.md differences between branches:
```
/config-diff --branch=main
/config-diff --from=feature-branch --to=main
```
## Expected Output
```
CLAUDE.md Diff Report
=====================
File: /path/to/project/CLAUDE.md
Comparing: Working copy vs HEAD (last commit)
Commit: abc1234 "Update build commands" (2 days ago)
Summary:
- Lines added: 12
- Lines removed: 5
- Net change: +7 lines
- Sections affected: 3
Section Changes:
----------------
## Quick Start [MODIFIED]
- Added new environment variable requirement
- Updated test command with coverage flag
## Critical Rules [ADDED CONTENT]
+ New rule: "Never modify database migrations directly"
## Architecture [UNCHANGED]
## Common Operations [MODIFIED]
- Removed deprecated deployment command
- Added new Docker workflow
Detailed Diff:
--------------
--- CLAUDE.md (HEAD)
+++ CLAUDE.md (working)
@@ -15,7 +15,10 @@
## Quick Start
```bash
+export DATABASE_URL=postgres://... # Required
pip install -r requirements.txt
-pytest
+pytest --cov=src # Run with coverage
uvicorn main:app --reload
```
@@ -45,6 +48,7 @@
## Critical Rules
- Never modify `.env` files directly
+- Never modify database migrations directly
- Always run tests before committing
Behavioral Impact:
------------------
These changes will affect Claude's behavior:
1. [NEW REQUIREMENT] Claude will now export DATABASE_URL before running
2. [MODIFIED] Test command now includes coverage reporting
3. [NEW RULE] Claude will avoid direct migration modifications
Review: Do these changes reflect your intended configuration?
```
## Section-Focused View
When using `--section`, output focuses on specific areas:
```
/config-diff --section="Critical Rules"
CLAUDE.md Section Diff: Critical Rules
======================================
--- HEAD
+++ Working
## Critical Rules
- Never modify `.env` files directly
+- Never modify database migrations directly
+- Always use type hints in Python code
- Always run tests before committing
-- Keep functions under 50 lines
Changes:
+ 2 rules added
- 1 rule removed
Impact: Claude will follow 2 new constraints and no longer enforce
the 50-line function limit.
```
## Options
| Option | Description |
|--------|-------------|
| `--commit=REF` | Compare working copy against specific commit/tag |
| `--from=REF` | Starting point for comparison |
| `--to=REF` | Ending point for comparison (default: HEAD) |
| `--branch=NAME` | Compare against branch tip |
| `--section=NAME` | Show only changes to specific section |
| `--stat` | Show only statistics, no detailed diff |
| `--no-color` | Disable colored output |
| `--context=N` | Lines of context around changes (default: 3) |
## Understanding the Output
### Change Indicators
| Symbol | Meaning |
|--------|---------|
| `+` | Line added |
| `-` | Line removed |
| `@@` | Location marker showing line numbers |
| `[MODIFIED]` | Section has changes |
| `[ADDED]` | New section created |
| `[REMOVED]` | Section deleted |
| `[UNCHANGED]` | No changes to section |
### Impact Categories
- **NEW REQUIREMENT** - Claude will now need to do something new
- **REMOVED REQUIREMENT** - Claude no longer needs to do something
- **MODIFIED** - Existing behavior changed
- **NEW RULE** - New constraint added
- **RELAXED RULE** - Constraint removed or softened
## When to Use
Run `/config-diff` when:
- Before committing CLAUDE.md changes
- Reviewing what changed after pulling updates
- Debugging unexpected Claude behavior
- Auditing configuration changes over time
- Comparing configurations across branches
## Integration with Other Commands
| Workflow | Commands |
|----------|----------|
| Review before commit | `/config-diff` then `git commit` |
| After optimization | `/config-optimize` then `/config-diff` |
| Audit history | `/config-diff --from=v1.0.0 --to=HEAD` |
| Branch comparison | `/config-diff --branch=main` |
## Tips
1. **Review before committing** - Always check what changed
2. **Track behavioral changes** - Focus on rules and requirements sections
3. **Use section filtering** - Large files benefit from focused diffs
4. **Compare across releases** - Use tags to track major changes
5. **Check after merges** - Ensure CLAUDE.md didn't get conflict artifacts
## Troubleshooting
### "No changes detected"
- CLAUDE.md matches the comparison target
- Check if you're comparing the right commits
### "File not found in commit"
- CLAUDE.md didn't exist at that commit
- Use `git log -- CLAUDE.md` to find when it was created
### "Not a git repository"
- This command requires git history
- Initialize git or use file backup comparison instead

View File

@@ -1,346 +0,0 @@
---
description: Lint CLAUDE.md for common anti-patterns and best practices
---
# Lint CLAUDE.md
This command checks your CLAUDE.md file against best practices and detects common anti-patterns that can cause issues with Claude Code.
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ ⚙️ CONFIG-MAINTAINER · CLAUDE.md Lint │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the linting.
## What This Command Does
1. **Parse Structure** - Validates markdown structure and hierarchy
2. **Check Security** - Detects hardcoded paths, secrets, and sensitive data
3. **Validate Content** - Identifies anti-patterns and problematic instructions
4. **Verify Format** - Ensures consistent formatting and style
5. **Generate Report** - Provides actionable findings with fix suggestions
## Usage
```
/config-lint
```
Lint with auto-fix:
```
/config-lint --fix
```
Check specific rules only:
```
/config-lint --rules=security,structure
```
## Linting Rules
### Security Rules (SEC)
| Rule | Description | Severity |
|------|-------------|----------|
| SEC001 | Hardcoded absolute paths | Warning |
| SEC002 | Potential secrets/API keys | Error |
| SEC003 | Hardcoded IP addresses | Warning |
| SEC004 | Exposed credentials patterns | Error |
| SEC005 | Hardcoded URLs with tokens | Error |
| SEC006 | Environment variable values (not names) | Warning |
### Structure Rules (STR)
| Rule | Description | Severity |
|------|-------------|----------|
| STR001 | Missing required sections | Error |
| STR002 | Invalid header hierarchy (h3 before h2) | Warning |
| STR003 | Orphaned content (text before first header) | Info |
| STR004 | Excessive nesting depth (>4 levels) | Warning |
| STR005 | Empty sections | Warning |
| STR006 | Missing section content | Warning |
### Content Rules (CNT)
| Rule | Description | Severity |
|------|-------------|----------|
| CNT001 | Contradictory instructions | Error |
| CNT002 | Vague or ambiguous rules | Warning |
| CNT003 | Overly long sections (>100 lines) | Info |
| CNT004 | Duplicate content | Warning |
| CNT005 | TODO/FIXME in production config | Warning |
| CNT006 | Outdated version references | Info |
| CNT007 | Broken internal links | Warning |
### Format Rules (FMT)
| Rule | Description | Severity |
|------|-------------|----------|
| FMT001 | Inconsistent header styles | Info |
| FMT002 | Inconsistent list markers | Info |
| FMT003 | Missing code block language | Info |
| FMT004 | Trailing whitespace | Info |
| FMT005 | Missing blank lines around headers | Info |
| FMT006 | Inconsistent indentation | Info |
### Best Practice Rules (BPR)
| Rule | Description | Severity |
|------|-------------|----------|
| BPR001 | No Quick Start section | Warning |
| BPR002 | No Critical Rules section | Warning |
| BPR003 | Instructions without examples | Info |
| BPR004 | Commands without explanation | Info |
| BPR005 | Rules without rationale | Info |
| BPR006 | Missing plugin integration docs | Info |
## Expected Output
```
CLAUDE.md Lint Report
=====================
File: /path/to/project/CLAUDE.md
Rules checked: 25
Time: 0.3s
Summary:
Errors: 2
Warnings: 5
Info: 3
Findings:
---------
[ERROR] SEC002: Potential secret detected (line 45)
│ api_key = "sk-1234567890abcdef"
│ ^^^^^^^^^^^^^^^^^^^^^^
└─ Hardcoded API key found. Use environment variable reference instead.
Suggested fix:
- api_key = "sk-1234567890abcdef"
+ api_key = $OPENAI_API_KEY # Set in environment
[ERROR] CNT001: Contradictory instructions (lines 23, 67)
│ Line 23: "Always run tests before committing"
│ Line 67: "Skip tests for documentation-only changes"
└─ These rules conflict. Clarify the exception explicitly.
Suggested fix:
+ "Always run tests before committing, except for documentation-only
+ changes (files in docs/ directory)"
[WARNING] SEC001: Hardcoded absolute path (line 12)
│ Database location: /home/user/data/myapp.db
│ ^^^^^^^^^^^^^^^^^^^^^^^^
└─ Absolute paths break portability. Use relative or variable.
Suggested fix:
- Database location: /home/user/data/myapp.db
+ Database location: ./data/myapp.db # Or $DATA_DIR/myapp.db
[WARNING] STR002: Invalid header hierarchy (line 34)
│ ### Subsection
│ (no preceding ## header)
└─ H3 header without parent H2. Add H2 or promote to H2.
[WARNING] CNT004: Duplicate content (lines 45-52, 89-96)
│ Same git workflow documented twice
└─ Remove duplicate or consolidate into single section.
[WARNING] STR005: Empty section (line 78)
│ ## Troubleshooting
│ (no content)
└─ Add content or remove empty section.
[WARNING] BPR002: No Critical Rules section
│ Missing "Critical Rules" or "Important Rules" section
└─ Add a section highlighting must-follow rules for Claude.
[INFO] FMT003: Missing code block language (line 56)
│ ```
│ npm install
│ ```
└─ Specify language for syntax highlighting: ```bash
[INFO] CNT003: Overly long section (lines 100-215)
│ "Architecture" section is 115 lines
└─ Consider breaking into subsections or condensing.
[INFO] FMT001: Inconsistent header styles
│ Line 10: "## Quick Start"
│ Line 25: "## Architecture:"
│ (colon suffix inconsistent)
└─ Standardize header format throughout document.
---
Auto-fixable: 4 issues (run with --fix)
Manual review required: 6 issues
Run `/config-lint --fix` to apply automatic fixes.
```
## Options
| Option | Description |
|--------|-------------|
| `--fix` | Automatically fix auto-fixable issues |
| `--rules=LIST` | Check only specified rule categories |
| `--ignore=LIST` | Skip specified rules (e.g., `--ignore=FMT001,FMT002`) |
| `--severity=LEVEL` | Show only issues at or above level (error/warning/info) |
| `--format=FORMAT` | Output format: `text` (default), `json`, `sarif` |
| `--config=FILE` | Use custom lint configuration |
| `--strict` | Treat warnings as errors |
## Rule Categories
Use `--rules` to focus on specific areas:
```
/config-lint --rules=security # Only security checks
/config-lint --rules=structure # Only structure checks
/config-lint --rules=security,content # Multiple categories
```
Available categories:
- `security` - SEC rules
- `structure` - STR rules
- `content` - CNT rules
- `format` - FMT rules
- `bestpractice` - BPR rules
## Custom Configuration
Create `.claude-lint.json` in project root:
```json
{
"rules": {
"SEC001": "warning",
"FMT001": "off",
"CNT003": {
"severity": "warning",
"maxLines": 150
}
},
"ignore": [
"FMT*"
],
"requiredSections": [
"Quick Start",
"Critical Rules",
"Project Overview"
]
}
```
## Anti-Pattern Examples
### Hardcoded Secrets (SEC002)
```markdown
# BAD
API_KEY=sk-1234567890abcdef
# GOOD
API_KEY=$OPENAI_API_KEY # Set via environment
```
### Hardcoded Paths (SEC001)
```markdown
# BAD
Config file: /home/john/projects/myapp/config.yml
# GOOD
Config file: ./config.yml
Config file: $PROJECT_ROOT/config.yml
```
### Contradictory Rules (CNT001)
```markdown
# BAD
- Always use TypeScript
- JavaScript files are acceptable for scripts
# GOOD
- Always use TypeScript for source code
- JavaScript (.js) is acceptable only for config files and scripts
```
### Vague Instructions (CNT002)
```markdown
# BAD
- Be careful with the database
# GOOD
- Never run DELETE without WHERE clause
- Always backup before migrations
```
### Invalid Hierarchy (STR002)
```markdown
# BAD
# Main Title
### Skipped Level
# GOOD
# Main Title
## Section
### Subsection
```
## When to Use
Run `/config-lint` when:
- Before committing CLAUDE.md changes
- During code review for CLAUDE.md modifications
- Setting up CI/CD checks for configuration files
- After major edits to catch introduced issues
- Periodically as maintenance check
## Integration with CI/CD
Add to your CI pipeline:
```yaml
# GitHub Actions example
- name: Lint CLAUDE.md
run: claude /config-lint --strict --format=sarif > lint-results.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: lint-results.sarif
```
## Tips
1. **Start with errors** - Fix errors before warnings
2. **Use --fix carefully** - Review auto-fixes before committing
3. **Configure per-project** - Different projects have different needs
4. **Integrate in CI** - Catch issues before they reach main
5. **Review periodically** - Run lint check monthly as maintenance
## Related Commands
| Command | Relationship |
|---------|--------------|
| `/config-analyze` | Deeper content analysis (complements lint) |
| `/config-optimize` | Applies fixes and improvements |
| `/config-diff` | Shows what changed (run lint before commit) |

View File

@@ -6,18 +6,6 @@ description: Initialize a new CLAUDE.md file for a project
This command creates a new CLAUDE.md file tailored to your project, gathering context and generating appropriate content. This command creates a new CLAUDE.md file tailored to your project, gathering context and generating appropriate content.
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ ⚙️ CONFIG-MAINTAINER · CLAUDE.md Initialization │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the initialization.
## What This Command Does ## What This Command Does
1. **Gather Context** - Analyzes project structure and asks clarifying questions 1. **Gather Context** - Analyzes project structure and asks clarifying questions

View File

@@ -6,18 +6,6 @@ description: Optimize CLAUDE.md structure and content
This command automatically optimizes your project's CLAUDE.md file based on best practices and identified issues. This command automatically optimizes your project's CLAUDE.md file based on best practices and identified issues.
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ ⚙️ CONFIG-MAINTAINER · CLAUDE.md Optimization │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the optimization.
## What This Command Does ## What This Command Does
1. **Analyze Current File** - Identifies all optimization opportunities 1. **Analyze Current File** - Identifies all optimization opportunities

View File

@@ -1,7 +1,7 @@
{ {
"name": "cmdb-assistant", "name": "cmdb-assistant",
"version": "1.2.0", "version": "1.0.0",
"description": "NetBox CMDB integration with data quality validation - query, create, update, and manage network devices, IP addresses, sites, and more with best practices enforcement", "description": "NetBox CMDB integration for infrastructure management - query, create, update, and manage network devices, IP addresses, sites, and more",
"author": { "author": {
"name": "Leo Miranda", "name": "Leo Miranda",
"email": "leobmiranda@gmail.com" "email": "leobmiranda@gmail.com"
@@ -15,9 +15,7 @@
"infrastructure", "infrastructure",
"network", "network",
"ipam", "ipam",
"dcim", "dcim"
"data-quality",
"validation"
], ],
"commands": ["./commands/"], "commands": ["./commands/"],
"mcpServers": ["./.mcp.json"] "mcpServers": ["./.mcp.json"]

View File

@@ -1,8 +1,12 @@
{ {
"mcpServers": { "mcpServers": {
"netbox": { "netbox": {
"command": "${CLAUDE_PLUGIN_ROOT}/mcp-servers/netbox/run.sh", "command": "${CLAUDE_PLUGIN_ROOT}/mcp-servers/netbox/.venv/bin/python",
"args": [] "args": ["-m", "mcp_server.server"],
"cwd": "${CLAUDE_PLUGIN_ROOT}/mcp-servers/netbox",
"env": {
"PYTHONPATH": "${CLAUDE_PLUGIN_ROOT}/mcp-servers/netbox"
}
} }
} }
} }

View File

@@ -2,20 +2,6 @@
A Claude Code plugin for NetBox CMDB integration - query, create, update, and manage your network infrastructure directly from Claude Code. A Claude Code plugin for NetBox CMDB integration - query, create, update, and manage your network infrastructure directly from Claude Code.
## What's New in v1.2.0
- **`/cmdb-topology`**: Generate Mermaid diagrams showing infrastructure topology (rack view, network view, site overview)
- **`/change-audit`**: Query and analyze NetBox audit log for change tracking and compliance
- **`/ip-conflicts`**: Detect IP address conflicts and overlapping prefixes
## What's New in v1.1.0
- **Data Quality Validation**: Hooks for SessionStart and PreToolUse that check data quality and warn about missing fields
- **Best Practices Skill**: Reference documentation for NetBox patterns (naming conventions, dependency order, role management)
- **`/cmdb-audit`**: Analyze data quality across VMs, devices, naming conventions, and roles
- **`/cmdb-register`**: Register the current machine into NetBox with all running applications (Docker containers, systemd services)
- **`/cmdb-sync`**: Synchronize existing machine state with NetBox (detect drift, update with confirmation)
## Features ## Features
- **Full CRUD Operations**: Create, read, update, and delete across all NetBox modules - **Full CRUD Operations**: Create, read, update, and delete across all NetBox modules
@@ -23,9 +9,6 @@ A Claude Code plugin for NetBox CMDB integration - query, create, update, and ma
- **IP Management**: Allocate IPs, manage prefixes, track VLANs - **IP Management**: Allocate IPs, manage prefixes, track VLANs
- **Infrastructure Documentation**: Document servers, network devices, and connections - **Infrastructure Documentation**: Document servers, network devices, and connections
- **Audit Trail**: Review changes and maintain infrastructure history - **Audit Trail**: Review changes and maintain infrastructure history
- **Data Quality Validation**: Proactive checks for missing site, tenant, platform assignments
- **Machine Registration**: Auto-discover and register servers with running applications
- **Drift Detection**: Sync machine state and detect changes over time
## Installation ## Installation
@@ -57,17 +40,10 @@ Add to your Claude Code plugins or marketplace configuration.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `/initial-setup` | Interactive setup wizard for NetBox MCP server |
| `/cmdb-search <query>` | Search for devices, IPs, sites, or any CMDB object | | `/cmdb-search <query>` | Search for devices, IPs, sites, or any CMDB object |
| `/cmdb-device <action>` | Manage network devices (list, create, update, delete) | | `/cmdb-device <action>` | Manage network devices (list, create, update, delete) |
| `/cmdb-ip <action>` | Manage IP addresses and prefixes | | `/cmdb-ip <action>` | Manage IP addresses and prefixes |
| `/cmdb-site <action>` | Manage sites and locations | | `/cmdb-site <action>` | Manage sites and locations |
| `/cmdb-audit [scope]` | Data quality analysis (all, vms, devices, naming, roles) |
| `/cmdb-register` | Register current machine into NetBox with running apps |
| `/cmdb-sync` | Sync machine state with NetBox (detect drift, update) |
| `/cmdb-topology <view>` | Generate Mermaid diagrams (rack, network, site, full) |
| `/change-audit [filters]` | Query NetBox audit log for change tracking |
| `/ip-conflicts [scope]` | Detect IP conflicts and overlapping prefixes |
## Agent ## Agent
@@ -127,15 +103,6 @@ This plugin provides access to the full NetBox API:
- **Wireless**: WLANs, Wireless Links - **Wireless**: WLANs, Wireless Links
- **Extras**: Tags, Custom Fields, Journal Entries, Audit Log - **Extras**: Tags, Custom Fields, Journal Entries, Audit Log
## Hooks
| Event | Purpose |
|-------|---------|
| `SessionStart` | Test NetBox connectivity, report data quality issues |
| `PreToolUse` | Validate VM/device parameters before create/update |
Hooks are **non-blocking** - they emit warnings but never prevent operations.
## Architecture ## Architecture
``` ```
@@ -148,26 +115,13 @@ cmdb-assistant/
│ ├── cmdb-search.md # Search command │ ├── cmdb-search.md # Search command
│ ├── cmdb-device.md # Device management │ ├── cmdb-device.md # Device management
│ ├── cmdb-ip.md # IP management │ ├── cmdb-ip.md # IP management
── cmdb-site.md # Site management ── cmdb-site.md # Site management
│ ├── cmdb-audit.md # Data quality audit
│ ├── cmdb-register.md # Machine registration
│ ├── cmdb-sync.md # Machine sync
│ ├── cmdb-topology.md # Topology visualization (NEW)
│ ├── change-audit.md # Change audit log (NEW)
│ └── ip-conflicts.md # IP conflict detection (NEW)
├── hooks/
│ ├── hooks.json # Hook configuration
│ ├── startup-check.sh # SessionStart validation
│ └── validate-input.sh # PreToolUse validation
├── skills/
│ └── netbox-patterns/
│ └── SKILL.md # NetBox best practices reference
├── agents/ ├── agents/
│ └── cmdb-assistant.md # Main assistant agent │ └── cmdb-assistant.md # Main assistant agent
└── README.md └── README.md
``` ```
The plugin uses the shared NetBox MCP server at `mcp-servers/netbox/`. The plugin uses the shared NetBox MCP server at `../mcp-servers/netbox/`.
## Configuration ## Configuration

View File

@@ -2,16 +2,6 @@
You are an infrastructure management assistant specialized in NetBox CMDB operations. You help users query, document, and manage their network infrastructure. You are an infrastructure management assistant specialized in NetBox CMDB operations. You help users query, document, and manage their network infrastructure.
## Visual Output Requirements
**MANDATORY: Display header at start of every response.**
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · Infrastructure Management │
└──────────────────────────────────────────────────────────────────┘
```
## Capabilities ## Capabilities
You have full access to NetBox via MCP tools covering: You have full access to NetBox via MCP tools covering:
@@ -86,132 +76,3 @@ When presenting data:
- Suggest corrective actions - Suggest corrective actions
- For permission errors, note what access is needed - For permission errors, note what access is needed
- For validation errors, explain required fields/formats - For validation errors, explain required fields/formats
## Data Quality Validation
**IMPORTANT:** Load the `netbox-patterns` skill for best practice reference.
Before ANY create or update operation, validate against NetBox best practices:
### VM Operations
**Required checks before `virt_create_vm` or `virt_update_vm`:**
1. **Cluster/Site Assignment** - VMs must have either cluster or site
2. **Tenant Assignment** - Recommend if not provided
3. **Platform Assignment** - Recommend for OS tracking
4. **Naming Convention** - Check against `{env}-{app}-{number}` pattern
5. **Role Assignment** - Recommend appropriate role
**If user provides no site/tenant, ASK:**
> "This VM has no site or tenant assigned. NetBox best practices recommend:
> - **Site**: For location-based queries and power budgeting
> - **Tenant**: For resource isolation and ownership tracking
>
> Would you like me to:
> 1. Assign to an existing site/tenant (list available)
> 2. Create new site/tenant first
> 3. Proceed without (not recommended for production use)"
### Device Operations
**Required checks before `dcim_create_device` or `dcim_update_device`:**
1. **Site is REQUIRED** - Fail without it
2. **Platform Assignment** - Recommend for OS tracking
3. **Naming Convention** - Check against `{role}-{location}-{number}` pattern
4. **Role Assignment** - Ensure appropriate role selected
5. **After Creation** - Offer to set primary IP
### Cluster Operations
**Required checks before `virt_create_cluster`:**
1. **Site Scope** - Recommend assigning to site
2. **Cluster Type** - Ensure appropriate type selected
3. **Device Association** - Recommend linking to host device
### Role Management
**Before creating a new device role:**
1. List existing roles with `dcim_list_device_roles`
2. Check if a more general role already exists
3. Recommend role consolidation if >10 specific roles exist
**Example guidance:**
> "You're creating role 'nginx-web-server'. An existing 'web-server' role exists.
> Consider using 'web-server' and tracking nginx via the platform field instead.
> This reduces role fragmentation and improves maintainability."
## Dependency Order Enforcement
When creating multiple objects, follow this order:
```
1. Regions → Sites → Locations → Racks
2. Tenant Groups → Tenants
3. Manufacturers → Device Types
4. Device Roles, Platforms
5. Devices (with site, role, type)
6. Clusters (with type, optional site)
7. VMs (with cluster)
8. Interfaces → IP Addresses → Primary IP assignment
```
**CRITICAL Rules:**
- NEVER create a VM before its cluster exists
- NEVER create a device before its site exists
- NEVER create an interface before its device exists
- NEVER create an IP before its interface exists (if assigning)
## Naming Convention Enforcement
When user provides a name, check against patterns:
| Object Type | Pattern | Example |
|-------------|---------|---------|
| Device | `{role}-{site}-{number}` | `web-dc1-01` |
| VM | `{env}-{app}-{number}` or `{prefix}_{service}` | `prod-api-01` |
| Cluster | `{site}-{type}` | `dc1-vmware`, `home-docker` |
| Prefix | Include purpose in description | "Production /24 for web tier" |
**If name doesn't match patterns, warn:**
> "The name 'HotServ' doesn't follow naming conventions.
> Suggested: `prod-hotserv-01` or `hotserv-cloud-01`.
> Consistent naming improves searchability and automation compatibility.
> Proceed with original name? [Y/n]"
## Duplicate Prevention
Before creating objects, always check for existing duplicates:
```
# Before creating device
dcim_list_devices name=<proposed-name>
# Before creating VM
virt_list_vms name=<proposed-name>
# Before creating prefix
ipam_list_prefixes prefix=<proposed-prefix>
```
If duplicate found, inform user and suggest update instead of create.
## Available Commands
Users can invoke these commands for structured workflows:
| Command | Purpose |
|---------|---------|
| `/cmdb-search <query>` | Search across all CMDB objects |
| `/cmdb-device <action>` | Device CRUD operations |
| `/cmdb-ip <action>` | IP address and prefix management |
| `/cmdb-site <action>` | Site and location management |
| `/cmdb-audit [scope]` | Data quality analysis |
| `/cmdb-register` | Register current machine |
| `/cmdb-sync` | Sync machine state with NetBox |

View File

@@ -1,175 +0,0 @@
---
description: Audit NetBox changes with filtering by date, user, or object type
---
# CMDB Change Audit
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · Change Audit │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the audit.
Query and analyze the NetBox audit log for change tracking and compliance.
## Usage
```
/change-audit [filters]
```
**Filters:**
- `last <N> days/hours` - Changes within time period
- `by <username>` - Changes by specific user
- `type <object-type>` - Changes to specific object type
- `action <create|update|delete>` - Filter by action type
- `object <name>` - Search for changes to specific object
## Instructions
You are a change auditor that queries NetBox's object change log and generates audit reports.
### MCP Tools
Use these tools to query the audit log:
- `extras_list_object_changes` - List changes with filters:
- `user_id` - Filter by user ID
- `changed_object_type` - Filter by object type (e.g., "dcim.device", "ipam.ipaddress")
- `action` - Filter by action: "create", "update", "delete"
- `extras_get_object_change` - Get detailed change record by ID
### Common Object Types
| Category | Object Types |
|----------|--------------|
| DCIM | `dcim.device`, `dcim.interface`, `dcim.site`, `dcim.rack`, `dcim.cable` |
| IPAM | `ipam.ipaddress`, `ipam.prefix`, `ipam.vlan`, `ipam.vrf` |
| Virtualization | `virtualization.virtualmachine`, `virtualization.cluster` |
| Tenancy | `tenancy.tenant`, `tenancy.contact` |
### Workflow
1. **Parse user request** to determine filters
2. **Query object changes** using `extras_list_object_changes`
3. **Enrich data** by fetching detailed records if needed
4. **Analyze patterns** in the changes
5. **Generate report** in structured format
### Report Format
```markdown
## NetBox Change Audit Report
**Generated:** [timestamp]
**Period:** [date range or "All time"]
**Filters:** [applied filters]
### Summary
| Metric | Count |
|--------|-------|
| Total Changes | X |
| Creates | Y |
| Updates | Z |
| Deletes | W |
| Unique Users | N |
| Object Types | M |
### Changes by Action
#### Created Objects (Y)
| Time | User | Object Type | Object | Details |
|------|------|-------------|--------|---------|
| 2024-01-15 14:30 | admin | dcim.device | server-01 | Created device |
| ... | ... | ... | ... | ... |
#### Updated Objects (Z)
| Time | User | Object Type | Object | Changed Fields |
|------|------|-------------|--------|----------------|
| 2024-01-15 15:00 | john | ipam.ipaddress | 10.0.1.50/24 | status, description |
| ... | ... | ... | ... | ... |
#### Deleted Objects (W)
| Time | User | Object Type | Object | Details |
|------|------|-------------|--------|---------|
| 2024-01-14 09:00 | admin | dcim.interface | eth2 | Removed from server-01 |
| ... | ... | ... | ... | ... |
### Changes by User
| User | Creates | Updates | Deletes | Total |
|------|---------|---------|---------|-------|
| admin | 5 | 10 | 2 | 17 |
| john | 3 | 8 | 0 | 11 |
### Changes by Object Type
| Object Type | Creates | Updates | Deletes | Total |
|-------------|---------|---------|---------|-------|
| dcim.device | 2 | 5 | 0 | 7 |
| ipam.ipaddress | 4 | 3 | 1 | 8 |
### Timeline
```
2024-01-15: ████████ 8 changes
2024-01-14: ████ 4 changes
2024-01-13: ██ 2 changes
```
### Notable Patterns
- **Bulk operations:** [Identify if many changes happened in short time]
- **Unusual activity:** [Flag unexpected deletions or after-hours changes]
- **Missing audit trail:** [Note if expected changes are not logged]
### Recommendations
1. [Any security or process recommendations based on findings]
```
### Time Period Handling
When user specifies "last N days":
- The NetBox API may not have direct date filtering in `extras_list_object_changes`
- Fetch recent changes and filter client-side by the `time` field
- Note any limitations in the report
### Enriching Change Details
For detailed audit, use `extras_get_object_change` with the change ID to see:
- `prechange_data` - Object state before change
- `postchange_data` - Object state after change
- `request_id` - Links related changes in same request
### Security Audit Mode
If user asks for "security audit" or "compliance report":
1. Focus on deletions and permission-sensitive changes
2. Highlight changes to critical objects (firewalls, VRFs, prefixes)
3. Flag changes outside business hours
4. Identify users with high change counts
## Examples
- `/change-audit` - Show recent changes (last 24 hours)
- `/change-audit last 7 days` - Changes in past week
- `/change-audit by admin` - All changes by admin user
- `/change-audit type dcim.device` - Device changes only
- `/change-audit action delete` - All deletions
- `/change-audit object server-01` - Changes to server-01
## User Request
$ARGUMENTS

View File

@@ -1,207 +0,0 @@
---
description: Audit NetBox data quality and identify consistency issues
---
# CMDB Data Quality Audit
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · Data Quality Audit │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the audit.
Analyze NetBox data for quality issues and best practice violations.
## Usage
```
/cmdb-audit [scope]
```
**Scopes:**
- `all` (default) - Full audit across all categories
- `vms` - Virtual machines only
- `devices` - Physical devices only
- `naming` - Naming convention analysis
- `roles` - Role fragmentation analysis
## Instructions
You are a data quality auditor for NetBox. Your job is to identify consistency issues and best practice violations.
**IMPORTANT:** Load the `netbox-patterns` skill for best practice reference.
### Phase 1: Data Collection
Run these MCP tool calls to gather data for analysis:
```
1. virt_list_vms (no filters - get all)
2. dcim_list_devices (no filters - get all)
3. virt_list_clusters (no filters)
4. dcim_list_sites
5. tenancy_list_tenants
6. dcim_list_device_roles
7. dcim_list_platforms
```
Store the results for analysis.
### Phase 2: Quality Checks
Analyze collected data for these issues by severity:
#### CRITICAL Issues (must fix immediately)
| Check | Detection |
|-------|-----------|
| VMs without cluster | `cluster` field is null AND `site` field is null |
| Devices without site | `site` field is null |
| Active devices without primary IP | `status=active` AND `primary_ip4` is null AND `primary_ip6` is null |
#### HIGH Issues (should fix soon)
| Check | Detection |
|-------|-----------|
| VMs without site | VM has no site (neither direct nor via cluster.site) |
| VMs without tenant | `tenant` field is null |
| Devices without platform | `platform` field is null |
| Clusters not scoped to site | `site` field is null on cluster |
| VMs without role | `role` field is null |
#### MEDIUM Issues (plan to address)
| Check | Detection |
|-------|-----------|
| Inconsistent naming | Names don't match patterns: devices=`{role}-{site}-{num}`, VMs=`{env}-{app}-{num}` |
| Role fragmentation | More than 10 device roles with <3 assignments each |
| Missing tags on production | Active resources without any tags |
| Mixed naming separators | Some names use `_`, others use `-` |
#### LOW Issues (informational)
| Check | Detection |
|-------|-----------|
| Docker containers as VMs | Cluster type is "Docker Compose" - document this modeling choice |
| VMs without description | `description` field is empty |
| Sites without physical address | `physical_address` is empty |
| Devices without serial | `serial` field is empty |
### Phase 3: Naming Convention Analysis
For naming scope, analyze patterns:
1. **Extract naming patterns** from existing objects
2. **Identify dominant patterns** (most common conventions)
3. **Flag outliers** that don't match dominant patterns
4. **Suggest standardization** based on best practices
**Expected Patterns:**
- Devices: `{role}-{location}-{number}` (e.g., `web-dc1-01`)
- VMs: `{prefix}_{service}` or `{env}-{app}-{number}` (e.g., `prod-api-01`)
- Clusters: `{site}-{type}` (e.g., `home-docker`)
### Phase 4: Role Analysis
For roles scope, analyze fragmentation:
1. **List all device roles** with assignment counts
2. **Identify single-use roles** (only 1 device/VM)
3. **Identify similar roles** that could be consolidated
4. **Suggest consolidation** based on patterns
**Red Flags:**
- More than 15 highly specific roles
- Roles with technology in name (use platform instead)
- Roles that duplicate functionality
### Phase 5: Report Generation
Present findings in this structure:
```markdown
## CMDB Data Quality Audit Report
**Generated:** [timestamp]
**Scope:** [scope parameter]
### Summary
| Metric | Count |
|--------|-------|
| Total VMs | X |
| Total Devices | Y |
| Total Clusters | Z |
| **Total Issues** | **N** |
| Severity | Count |
|----------|-------|
| Critical | A |
| High | B |
| Medium | C |
| Low | D |
### Critical Issues
[List each with specific object names and IDs]
**Example:**
- VM `HotServ` (ID: 1) - No cluster or site assignment
- Device `server-01` (ID: 5) - No site assignment
### High Issues
[List each with specific object names]
### Medium Issues
[Grouped by category with counts]
### Recommendations
1. **[Most impactful fix]** - affects N objects
2. **[Second priority]** - affects M objects
...
### Quick Fixes
Commands to fix common issues:
```
# Assign site to VM
virt_update_vm id=X site=Y
# Assign platform to device
dcim_update_device id=X platform=Y
```
### Next Steps
- Run `/cmdb-register` to properly register new machines
- Use `/cmdb-sync` to update existing registrations
- Consider bulk updates via NetBox web UI for >10 items
```
## Scope-Specific Instructions
### For `vms` scope:
Focus only on Virtual Machine checks. Skip device and role analysis.
### For `devices` scope:
Focus only on Device checks. Skip VM and cluster analysis.
### For `naming` scope:
Focus on naming convention analysis across all objects. Generate detailed pattern report.
### For `roles` scope:
Focus on role fragmentation analysis. Generate consolidation recommendations.
## User Request
$ARGUMENTS

View File

@@ -1,17 +1,5 @@
# CMDB Device Management # CMDB Device Management
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · Device Management │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the operation.
Manage network devices in NetBox - create, view, update, or delete. Manage network devices in NetBox - create, view, update, or delete.
## Usage ## Usage

View File

@@ -1,17 +1,5 @@
# CMDB IP Management # CMDB IP Management
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · IP Management │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the operation.
Manage IP addresses and prefixes in NetBox. Manage IP addresses and prefixes in NetBox.
## Usage ## Usage

View File

@@ -1,334 +0,0 @@
---
description: Register the current machine into NetBox with all running applications
---
# CMDB Machine Registration
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · Machine Registration │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the registration.
Register the current machine into NetBox, including hardware info, network interfaces, and running applications (Docker containers, services).
## Usage
```
/cmdb-register [--site <site-name>] [--tenant <tenant-name>] [--role <role-name>]
```
**Options:**
- `--site <name>`: Site to assign (will prompt if not provided)
- `--tenant <name>`: Tenant for resource isolation (optional)
- `--role <name>`: Device role (default: auto-detect based on services)
## Instructions
You are registering the current machine into NetBox. This is a multi-phase process that discovers local system information and creates corresponding NetBox objects.
**IMPORTANT:** Load the `netbox-patterns` skill for best practice reference.
### Phase 1: System Discovery (via Bash)
Gather system information using these commands:
#### 1.1 Basic Device Info
```bash
# Hostname
hostname
# OS/Platform info
cat /etc/os-release 2>/dev/null || uname -a
# Hardware model (varies by system)
# Raspberry Pi:
cat /proc/device-tree/model 2>/dev/null || echo "Unknown"
# x86 systems:
cat /sys/class/dmi/id/product_name 2>/dev/null || echo "Unknown"
# Serial number
# Raspberry Pi:
cat /proc/device-tree/serial-number 2>/dev/null || cat /proc/cpuinfo | grep Serial | cut -d: -f2 | tr -d ' ' 2>/dev/null
# x86 systems:
cat /sys/class/dmi/id/product_serial 2>/dev/null || echo "Unknown"
# CPU info
nproc
# Memory (MB)
free -m | awk '/Mem:/ {print $2}'
# Disk (GB, root filesystem)
df -BG / | awk 'NR==2 {print $2}' | tr -d 'G'
```
#### 1.2 Network Interfaces
```bash
# Get interfaces with IPs (JSON format)
ip -j addr show 2>/dev/null || ip addr show
# Get default gateway interface
ip route | grep default | awk '{print $5}' | head -1
# Get MAC addresses
ip -j link show 2>/dev/null || ip link show
```
#### 1.3 Running Applications
```bash
# Docker containers (if docker available)
docker ps --format '{"name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","ports":"{{.Ports}}"}' 2>/dev/null || echo "Docker not available"
# Docker Compose projects (check common locations)
find ~/apps /home/*/apps -name "docker-compose.yml" -o -name "docker-compose.yaml" 2>/dev/null | head -20
# Systemd services (running)
systemctl list-units --type=service --state=running --no-pager --plain 2>/dev/null | grep -v "^UNIT" | head -30
```
### Phase 2: Pre-Registration Checks (via MCP)
Before creating objects, verify prerequisites:
#### 2.1 Check if Device Already Exists
```
dcim_list_devices name=<hostname>
```
**If device exists:**
- Inform user and suggest `/cmdb-sync` instead
- Ask if they want to proceed with re-registration (will update existing)
#### 2.2 Verify/Create Site
If `--site` provided:
```
dcim_list_sites name=<site-name>
```
If site doesn't exist, ask user if they want to create it.
If no site provided, list available sites and ask user to choose:
```
dcim_list_sites
```
#### 2.3 Verify/Create Platform
Based on OS detected, check if platform exists:
```
dcim_list_platforms name=<platform-name>
```
**Platform naming:**
- `Raspberry Pi OS (Bookworm)` for Raspberry Pi
- `Ubuntu 24.04 LTS` for Ubuntu
- `Debian 12` for Debian
- Use format: `{OS Name} {Version}`
If platform doesn't exist, create it:
```
dcim_create_platform name=<platform-name> slug=<slug>
```
#### 2.4 Verify/Create Device Role
Based on detected services:
- If Docker containers found → `Docker Host`
- If only basic services → `Server`
- If specific role specified → Use that
```
dcim_list_device_roles name=<role-name>
```
### Phase 3: Device Registration (via MCP)
#### 3.1 Get/Create Manufacturer and Device Type
For Raspberry Pi:
```
dcim_list_manufacturers name="Raspberry Pi Foundation"
dcim_list_device_types manufacturer_id=X model="Raspberry Pi 4 Model B"
```
Create if not exists.
For generic x86:
```
dcim_list_manufacturers name=<detected-manufacturer>
```
#### 3.2 Create Device
```
dcim_create_device
name=<hostname>
device_type=<device_type_id>
role=<role_id>
site=<site_id>
platform=<platform_id>
tenant=<tenant_id> # if provided
serial=<serial>
description="Registered via cmdb-assistant"
```
#### 3.3 Create Interfaces
For each network interface discovered:
```
dcim_create_interface
device=<device_id>
name=<interface_name> # eth0, wlan0, tailscale0, etc.
type=<type> # 1000base-t, virtual, other
mac_address=<mac>
enabled=true
```
**Interface type mapping:**
- `eth*`, `enp*``1000base-t`
- `wlan*``ieee802.11ax` (or appropriate wifi type)
- `tailscale*`, `docker*`, `br-*``virtual`
- `lo` → skip (loopback)
#### 3.4 Create IP Addresses
For each IP on each interface:
```
ipam_create_ip_address
address=<ip/prefix> # e.g., "192.168.1.100/24"
assigned_object_type="dcim.interface"
assigned_object_id=<interface_id>
status="active"
description="Discovered via cmdb-register"
```
#### 3.5 Set Primary IP
Identify primary IP (interface with default route):
```
dcim_update_device
id=<device_id>
primary_ip4=<primary_ip_id>
```
### Phase 4: Container Registration (via MCP)
If Docker containers were discovered:
#### 4.1 Create/Get Cluster Type
```
virt_list_cluster_types name="Docker Compose"
```
Create if not exists:
```
virt_create_cluster_type name="Docker Compose" slug="docker-compose"
```
#### 4.2 Create Cluster
For each Docker Compose project directory found:
```
virt_create_cluster
name=<project-name> # e.g., "apps-hotport"
type=<cluster_type_id>
site=<site_id>
description="Docker Compose stack on <hostname>"
```
#### 4.3 Create VMs for Containers
For each running container:
```
virt_create_vm
name=<container_name>
cluster=<cluster_id>
site=<site_id>
role=<role_id> # Map container function to role
status="active"
vcpus=<cpu_shares> # Default 1.0 if unknown
memory=<memory_mb> # Default 256 if unknown
disk=<disk_gb> # Default 5 if unknown
description=<container purpose>
comments=<image, ports, volumes info>
```
**Container role mapping:**
- `*caddy*`, `*nginx*`, `*traefik*` → "Reverse Proxy"
- `*db*`, `*postgres*`, `*mysql*`, `*redis*` → "Database"
- `*webui*`, `*frontend*` → "Web Application"
- Others → Infer from image name or use generic "Container"
### Phase 5: Documentation
#### 5.1 Add Journal Entry
```
extras_create_journal_entry
assigned_object_type="dcim.device"
assigned_object_id=<device_id>
comments="Device registered via /cmdb-register command\n\nDiscovered:\n- X network interfaces\n- Y IP addresses\n- Z Docker containers"
```
### Phase 6: Summary Report
Present registration summary:
```markdown
## Machine Registration Complete
### Device Created
- **Name:** <hostname>
- **Site:** <site>
- **Platform:** <platform>
- **Role:** <role>
- **ID:** <device_id>
- **URL:** https://netbox.example.com/dcim/devices/<id>/
### Network Interfaces
| Interface | Type | MAC | IP Address |
|-----------|------|-----|------------|
| eth0 | 1000base-t | aa:bb:cc:dd:ee:ff | 192.168.1.100/24 |
| tailscale0 | virtual | - | 100.x.x.x/32 |
### Primary IP: 192.168.1.100
### Docker Containers Registered (if applicable)
**Cluster:** <cluster_name> (ID: <cluster_id>)
| Container | Role | vCPUs | Memory | Status |
|-----------|------|-------|--------|--------|
| media_jellyfin | Media Server | 2.0 | 2048MB | Active |
| media_sonarr | Media Management | 1.0 | 512MB | Active |
### Next Steps
- Run `/cmdb-sync` periodically to keep data current
- Run `/cmdb-audit` to check data quality
- Add tags for classification (env:*, team:*, etc.)
```
## Error Handling
- **Device already exists:** Suggest `/cmdb-sync` or ask to proceed
- **Site not found:** List available sites, offer to create new
- **Docker not available:** Skip container registration, note in summary
- **Permission denied:** Note which operations failed, suggest fixes
## User Request
$ARGUMENTS

View File

@@ -1,17 +1,5 @@
# CMDB Search # CMDB Search
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · Search │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the search.
Search NetBox for devices, IPs, sites, or any CMDB object. Search NetBox for devices, IPs, sites, or any CMDB object.
## Usage ## Usage

View File

@@ -1,17 +1,5 @@
# CMDB Site Management # CMDB Site Management
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · Site Management │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the operation.
Manage sites and locations in NetBox. Manage sites and locations in NetBox.
## Usage ## Usage

View File

@@ -1,348 +0,0 @@
---
description: Synchronize current machine state with existing NetBox record
---
# CMDB Machine Sync
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · Machine Sync │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the synchronization.
Update an existing NetBox device record with the current machine state. Compares local system information with NetBox and applies changes.
## Usage
```
/cmdb-sync [--full] [--dry-run]
```
**Options:**
- `--full`: Force refresh all fields, even unchanged ones
- `--dry-run`: Show what would change without applying updates
## Instructions
You are synchronizing the current machine's state with its NetBox record. This involves comparing current system state with stored data and updating differences.
**IMPORTANT:** Load the `netbox-patterns` skill for best practice reference.
### Phase 1: Device Lookup (via MCP)
First, find the existing device record:
```bash
# Get current hostname
hostname
```
```
dcim_list_devices name=<hostname>
```
**If device not found:**
- Inform user: "Device '<hostname>' not found in NetBox"
- Suggest: "Run `/cmdb-register` to register this machine first"
- Exit sync
**If device found:**
- Store device ID and all current field values
- Fetch interfaces: `dcim_list_interfaces device_id=<device_id>`
- Fetch IPs: `ipam_list_ip_addresses device_id=<device_id>`
Also check for associated clusters/VMs:
```
virt_list_clusters # Look for cluster associated with this device
virt_list_vms cluster=<cluster_id> # If cluster found
```
### Phase 2: Current State Discovery (via Bash)
Gather current system information (same as `/cmdb-register`):
```bash
# Device info
hostname
cat /etc/os-release 2>/dev/null || uname -a
nproc
free -m | awk '/Mem:/ {print $2}'
df -BG / | awk 'NR==2 {print $2}' | tr -d 'G'
# Network interfaces with IPs
ip -j addr show 2>/dev/null || ip addr show
# Docker containers
docker ps --format '{"name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}"}' 2>/dev/null || echo "[]"
```
### Phase 3: Comparison
Compare discovered state with NetBox record:
#### 3.1 Device Attributes
| Field | Compare |
|-------|---------|
| Platform | OS version changed? |
| Status | Still active? |
| Serial | Match? |
| Description | Keep existing |
#### 3.2 Network Interfaces
| Change Type | Detection |
|-------------|-----------|
| New interface | Interface exists locally but not in NetBox |
| Removed interface | Interface in NetBox but not locally |
| Changed MAC | MAC address different |
| Interface type | Type mismatch |
#### 3.3 IP Addresses
| Change Type | Detection |
|-------------|-----------|
| New IP | IP exists locally but not in NetBox |
| Removed IP | IP in NetBox but not locally (on this device) |
| Primary IP changed | Default route interface changed |
#### 3.4 Docker Containers
| Change Type | Detection |
|-------------|-----------|
| New container | Container running locally but no VM in cluster |
| Stopped container | VM exists but container not running |
| Resource change | vCPUs/memory different (if trackable) |
### Phase 4: Diff Report
Present changes to user:
```markdown
## Sync Diff Report
**Device:** <hostname> (ID: <device_id>)
**NetBox URL:** https://netbox.example.com/dcim/devices/<id>/
### Device Attributes
| Field | NetBox Value | Current Value | Action |
|-------|--------------|---------------|--------|
| Platform | Ubuntu 22.04 | Ubuntu 24.04 | UPDATE |
| Status | active | active | - |
### Network Interfaces
#### New Interfaces (will create)
| Interface | Type | MAC | IPs |
|-----------|------|-----|-----|
| tailscale0 | virtual | - | 100.x.x.x/32 |
#### Removed Interfaces (will mark offline)
| Interface | Type | Reason |
|-----------|------|--------|
| eth1 | 1000base-t | Not found locally |
#### Changed Interfaces
| Interface | Field | Old | New |
|-----------|-------|-----|-----|
| eth0 | mac_address | aa:bb:cc:00:00:00 | aa:bb:cc:11:11:11 |
### IP Addresses
#### New IPs (will create)
- 192.168.1.150/24 on eth0
#### Removed IPs (will unassign)
- 192.168.1.100/24 from eth0
### Docker Containers
#### New Containers (will create VMs)
| Container | Image | Role |
|-----------|-------|------|
| media_lidarr | linuxserver/lidarr | Media Management |
#### Stopped Containers (will mark offline)
| Container | Last Status |
|-----------|-------------|
| media_bazarr | Exited |
### Summary
- **Updates:** X
- **Creates:** Y
- **Removals/Offline:** Z
```
### Phase 5: User Confirmation
If not `--dry-run`:
```
The following changes will be applied:
- Update device platform to "Ubuntu 24.04"
- Create interface "tailscale0"
- Create IP "100.x.x.x/32" on tailscale0
- Create VM "media_lidarr" in cluster
- Mark VM "media_bazarr" as offline
Proceed with sync? [Y/n]
```
**Use AskUserQuestion** to get confirmation.
### Phase 6: Apply Updates (via MCP)
Only if user confirms (or `--full` specified):
#### 6.1 Device Updates
```
dcim_update_device
id=<device_id>
platform=<new_platform_id>
# ... other changed fields
```
#### 6.2 Interface Updates
**For new interfaces:**
```
dcim_create_interface
device=<device_id>
name=<interface_name>
type=<type>
mac_address=<mac>
enabled=true
```
**For removed interfaces:**
```
dcim_update_interface
id=<interface_id>
enabled=false
description="Marked offline by cmdb-sync - interface no longer present"
```
**For changed interfaces:**
```
dcim_update_interface
id=<interface_id>
mac_address=<new_mac>
```
#### 6.3 IP Address Updates
**For new IPs:**
```
ipam_create_ip_address
address=<ip/prefix>
assigned_object_type="dcim.interface"
assigned_object_id=<interface_id>
status="active"
```
**For removed IPs:**
```
ipam_update_ip_address
id=<ip_id>
assigned_object_type=null
assigned_object_id=null
description="Unassigned by cmdb-sync"
```
#### 6.4 Primary IP Update
If primary IP changed:
```
dcim_update_device
id=<device_id>
primary_ip4=<new_primary_ip_id>
```
#### 6.5 Container/VM Updates
**For new containers:**
```
virt_create_vm
name=<container_name>
cluster=<cluster_id>
status="active"
# ... other fields
```
**For stopped containers:**
```
virt_update_vm
id=<vm_id>
status="offline"
description="Container stopped - detected by cmdb-sync"
```
### Phase 7: Journal Entry
Document the sync:
```
extras_create_journal_entry
assigned_object_type="dcim.device"
assigned_object_id=<device_id>
comments="Device synced via /cmdb-sync command\n\nChanges applied:\n- <list of changes>"
```
### Phase 8: Summary Report
```markdown
## Sync Complete
**Device:** <hostname>
**Sync Time:** <timestamp>
### Changes Applied
- Updated platform: Ubuntu 22.04 → Ubuntu 24.04
- Created interface: tailscale0 (ID: X)
- Created IP: 100.x.x.x/32 (ID: Y)
- Created VM: media_lidarr (ID: Z)
- Marked VM offline: media_bazarr (ID: W)
### Current State
- **Interfaces:** 4 (3 active, 1 offline)
- **IP Addresses:** 5
- **Containers/VMs:** 8 (7 active, 1 offline)
### Next Sync
Run `/cmdb-sync` again after:
- Adding/removing Docker containers
- Changing network configuration
- OS upgrades
```
## Dry Run Mode
If `--dry-run` specified:
- Complete Phase 1-4 (lookup, discovery, compare, diff report)
- Skip Phase 5-8 (no confirmation, no updates, no journal)
- End with: "Dry run complete. No changes applied. Run without --dry-run to apply."
## Full Sync Mode
If `--full` specified:
- Skip user confirmation
- Update all fields even if unchanged (force refresh)
- Useful for ensuring NetBox matches current state exactly
## Error Handling
- **Device not found:** Suggest `/cmdb-register`
- **Permission denied on updates:** Note which failed, continue with others
- **Cluster not found:** Offer to create or skip container sync
- **API errors:** Log error, continue with remaining updates
## User Request
$ARGUMENTS

View File

@@ -1,194 +0,0 @@
---
description: Generate infrastructure topology diagrams from NetBox data
---
# CMDB Topology Visualization
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · Topology │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the visualization.
Generate Mermaid diagrams showing infrastructure topology from NetBox.
## Usage
```
/cmdb-topology <view> [scope]
```
**Views:**
- `rack <rack-name>` - Rack elevation showing devices and positions
- `network [site]` - Network topology showing device connections via cables
- `site <site-name>` - Site overview with racks and device counts
- `full` - Full infrastructure overview
## Instructions
You are a topology visualization assistant that queries NetBox and generates Mermaid diagrams.
### View: Rack Elevation
Generate a rack view showing devices and their positions.
**Data Collection:**
1. Use `dcim_list_racks` to find the rack by name
2. Use `dcim_list_devices` with `rack_id` filter to get devices in rack
3. For each device, note: `position`, `u_height`, `face`, `name`, `role`
**Mermaid Output:**
```mermaid
graph TB
subgraph rack["Rack: <rack-name> (U<height>)"]
direction TB
u42["U42: empty"]
u41["U41: empty"]
u40["U40: server-01 (Server)"]
u39["U39: server-01 (cont.)"]
u38["U38: switch-01 (Switch)"]
%% ... continue for all units
end
```
**For devices spanning multiple U:**
- Mark the top U with device name and role
- Mark subsequent Us as "(cont.)" for the same device
- Empty Us should show "empty"
### View: Network Topology
Generate a network diagram showing device connections.
**Data Collection:**
1. Use `dcim_list_sites` if no site specified (get all)
2. Use `dcim_list_devices` with optional `site_id` filter
3. Use `dcim_list_cables` to get all connections
4. Use `dcim_list_interfaces` for each device to understand port names
**Mermaid Output:**
```mermaid
graph TD
subgraph site1["Site: Home"]
router1[("core-router-01<br/>Router")]
switch1[["dist-switch-01<br/>Switch"]]
server1["web-server-01<br/>Server"]
server2["db-server-01<br/>Server"]
end
router1 -->|"eth0 - eth1"| switch1
switch1 -->|"gi0/1 - eth0"| server1
switch1 -->|"gi0/2 - eth0"| server2
```
**Node shapes by role:**
- Router: `[(" ")]` (cylinder/database shape)
- Switch: `[[ ]]` (double brackets)
- Server: `[ ]` (rectangle)
- Firewall: `{{ }}` (hexagon)
- Other: `[ ]` (rectangle)
**Edge labels:** Show interface names on both ends (A-side - B-side)
### View: Site Overview
Generate a site-level view showing racks and summary counts.
**Data Collection:**
1. Use `dcim_get_site` to get site details
2. Use `dcim_list_racks` with `site_id` filter
3. Use `dcim_list_devices` with `site_id` filter for counts per rack
**Mermaid Output:**
```mermaid
graph TB
subgraph site["Site: Headquarters"]
subgraph row1["Row 1"]
rack1["Rack A1<br/>12/42 U used<br/>5 devices"]
rack2["Rack A2<br/>20/42 U used<br/>8 devices"]
end
subgraph row2["Row 2"]
rack3["Rack B1<br/>8/42 U used<br/>3 devices"]
end
end
```
### View: Full Infrastructure
Generate a high-level view of all sites and their relationships.
**Data Collection:**
1. Use `dcim_list_regions` to get hierarchy
2. Use `dcim_list_sites` to get all sites
3. Use `dcim_list_devices` with status filter for counts
**Mermaid Output:**
```mermaid
graph TB
subgraph region1["Region: Americas"]
site1["Headquarters<br/>3 racks, 25 devices"]
site2["Branch Office<br/>1 rack, 5 devices"]
end
subgraph region2["Region: Europe"]
site3["EU Datacenter<br/>10 racks, 100 devices"]
end
site1 -.->|"WAN Link"| site3
```
### Output Format
Always provide:
1. **Summary** - Brief description of what the diagram shows
2. **Mermaid Code Block** - The diagram code in a fenced code block
3. **Legend** - Explanation of shapes and colors used
4. **Data Notes** - Any data quality issues (e.g., devices without position, missing cables)
**Example Output:**
```markdown
## Network Topology: Home Site
This diagram shows the network connections between 4 devices at the Home site.
```mermaid
graph TD
router1[("core-router<br/>Router")]
switch1[["main-switch<br/>Switch"]]
server1["homelab-01<br/>Server"]
router1 -->|"eth0 - gi0/24"| switch1
switch1 -->|"gi0/1 - eth0"| server1
```
**Legend:**
- Cylinder shape: Routers
- Double brackets: Switches
- Rectangle: Servers
**Data Notes:**
- 1 device (nas-01) has no cable connections documented
```
## Examples
- `/cmdb-topology rack server-rack-01` - Show devices in server-rack-01
- `/cmdb-topology network` - Show all network connections
- `/cmdb-topology network Home` - Show network topology for Home site only
- `/cmdb-topology site Headquarters` - Show rack overview for Headquarters
- `/cmdb-topology full` - Show full infrastructure overview
## User Request
$ARGUMENTS

View File

@@ -4,18 +4,6 @@ description: Interactive setup wizard for cmdb-assistant plugin - configures Net
# CMDB Assistant Setup Wizard # CMDB Assistant Setup Wizard
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · Setup Wizard │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the setup.
This command sets up the cmdb-assistant plugin with NetBox integration. This command sets up the cmdb-assistant plugin with NetBox integration.
## Important Context ## Important Context

View File

@@ -1,238 +0,0 @@
---
description: Detect IP address conflicts and overlapping prefixes in NetBox
---
# CMDB IP Conflict Detection
## Visual Output
When executing this command, display the plugin header:
```
┌──────────────────────────────────────────────────────────────────┐
│ 🖥️ CMDB-ASSISTANT · IP Conflict Detection │
└──────────────────────────────────────────────────────────────────┘
```
Then proceed with the analysis.
Scan NetBox IPAM data to identify IP address conflicts and overlapping prefixes.
## Usage
```
/ip-conflicts [scope]
```
**Scopes:**
- `all` (default) - Full scan of all IP data
- `addresses` - Check for duplicate IP addresses only
- `prefixes` - Check for overlapping prefixes only
- `vrf <name>` - Scan specific VRF only
- `prefix <cidr>` - Scan within specific prefix
## Instructions
You are an IP conflict detection specialist that analyzes NetBox IPAM data for conflicts and issues.
### Conflict Types to Detect
#### 1. Duplicate IP Addresses
Multiple IP address records with the same address (within same VRF).
**Detection:**
1. Use `ipam_list_ip_addresses` to get all addresses
2. Group by address + VRF combination
3. Flag groups with more than one record
**Exception:** Anycast addresses may legitimately appear multiple times - check the `role` field for "anycast".
#### 2. Overlapping Prefixes
Prefixes that contain the same address space (within same VRF).
**Detection:**
1. Use `ipam_list_prefixes` to get all prefixes
2. For each prefix pair in the same VRF, check if one contains the other
3. Legitimate hierarchies should have proper parent-child relationships
**Legitimate Overlaps:**
- Parent/child prefix hierarchy (e.g., 10.0.0.0/8 contains 10.0.1.0/24)
- Different VRFs (isolated routing tables)
- Marked as "container" status
#### 3. IPs Outside Their Prefix
IP addresses that don't fall within any defined prefix.
**Detection:**
1. For each IP address, find the most specific prefix that contains it
2. Flag IPs with no matching prefix
#### 4. Prefix Overlap Across VRFs (Informational)
Same prefix appearing in multiple VRFs - not necessarily a conflict, but worth noting.
### MCP Tools
- `ipam_list_ip_addresses` - Get all IP addresses with filters:
- `address` - Filter by specific address
- `vrf_id` - Filter by VRF
- `parent` - Filter by parent prefix
- `status` - Filter by status
- `ipam_list_prefixes` - Get all prefixes with filters:
- `prefix` - Filter by prefix CIDR
- `vrf_id` - Filter by VRF
- `within` - Find prefixes within a parent
- `contains` - Find prefixes containing an address
- `ipam_list_vrfs` - List VRFs for context
- `ipam_get_ip_address` - Get detailed IP info including assigned device/interface
- `ipam_get_prefix` - Get detailed prefix info
### Workflow
1. **Data Collection**
- Fetch all IP addresses (or filtered set)
- Fetch all prefixes (or filtered set)
- Fetch VRFs for context
2. **Duplicate Detection**
- Build address map: `{address+vrf: [records]}`
- Filter for entries with >1 record
3. **Overlap Detection**
- For each VRF, compare prefixes pairwise
- Check using CIDR math: does prefix A contain prefix B or vice versa?
- Ignore legitimate hierarchies (status=container)
4. **Orphan IP Detection**
- For each IP, find containing prefix
- Flag IPs with no prefix match
5. **Generate Report**
### Report Format
```markdown
## IP Conflict Detection Report
**Generated:** [timestamp]
**Scope:** [scope parameter]
### Summary
| Check | Status | Count |
|-------|--------|-------|
| Duplicate IPs | [PASS/FAIL] | X |
| Overlapping Prefixes | [PASS/FAIL] | Y |
| Orphan IPs | [PASS/FAIL] | Z |
| Total Issues | - | N |
### Critical Issues
#### Duplicate IP Addresses
| Address | VRF | Count | Assigned To |
|---------|-----|-------|-------------|
| 10.0.1.50/24 | Global | 2 | server-01 (eth0), server-02 (eth0) |
| 192.168.1.100/24 | Global | 2 | router-01 (gi0/1), switch-01 (vlan10) |
**Impact:** IP conflicts cause network connectivity issues. Devices will have intermittent connectivity.
**Resolution:**
- Determine which device should have the IP
- Update or remove the duplicate assignment
- Consider IP reservation to prevent future conflicts
#### Overlapping Prefixes
| Prefix 1 | Prefix 2 | VRF | Type |
|----------|----------|-----|------|
| 10.0.0.0/24 | 10.0.0.0/25 | Global | Unstructured overlap |
| 192.168.0.0/16 | 192.168.1.0/24 | Production | Missing container flag |
**Impact:** Overlapping prefixes can cause routing ambiguity and IP management confusion.
**Resolution:**
- For legitimate hierarchies: Mark parent prefix as status="container"
- For accidental overlaps: Consolidate or re-address one prefix
### Warnings
#### IPs Without Prefix
| Address | VRF | Assigned To | Nearest Prefix |
|---------|-----|-------------|----------------|
| 172.16.5.10/24 | Global | server-03 (eth0) | None found |
**Impact:** IPs without a prefix bypass IPAM allocation controls.
**Resolution:**
- Create appropriate prefix to contain the IP
- Or update IP to correct address within existing prefix
### Informational
#### Same Prefix in Multiple VRFs
| Prefix | VRFs | Purpose |
|--------|------|---------|
| 10.0.0.0/24 | Global, DMZ, Internal | [Check if intentional] |
### Statistics
| Metric | Value |
|--------|-------|
| Total IP Addresses | X |
| Total Prefixes | Y |
| Total VRFs | Z |
| Utilization (IPs/Prefix space) | W% |
### Remediation Commands
```
# Remove duplicate IP (keep server-01's assignment)
ipam_delete_ip_address id=123
# Mark prefix as container
ipam_update_prefix id=456 status=container
# Create missing prefix for orphan IP
ipam_create_prefix prefix=172.16.5.0/24 status=active
```
```
### CIDR Math Reference
For overlap detection, use these rules:
- Prefix A **contains** Prefix B if: A.network <= B.network AND A.broadcast >= B.broadcast
- Two prefixes **overlap** if: A.network <= B.broadcast AND B.network <= A.broadcast
**Example:**
- 10.0.0.0/8 contains 10.0.1.0/24 (legitimate hierarchy)
- 10.0.0.0/24 and 10.0.0.128/25 overlap (10.0.0.128/25 is within 10.0.0.0/24)
### Severity Levels
| Issue | Severity | Description |
|-------|----------|-------------|
| Duplicate IP (same interface type) | CRITICAL | Active conflict, causes outages |
| Duplicate IP (different roles) | HIGH | Potential conflict |
| Overlapping prefixes (same status) | HIGH | IPAM management issue |
| Overlapping prefixes (container ok) | LOW | May need status update |
| Orphan IP | MEDIUM | Bypasses IPAM controls |
## Examples
- `/ip-conflicts` - Full scan for all conflicts
- `/ip-conflicts addresses` - Check only for duplicate IPs
- `/ip-conflicts prefixes` - Check only for overlapping prefixes
- `/ip-conflicts vrf Production` - Scan only Production VRF
- `/ip-conflicts prefix 10.0.0.0/8` - Scan within specific prefix range
## User Request
$ARGUMENTS

View File

@@ -1,21 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/startup-check.sh"
}
],
"PreToolUse": [
{
"matcher": "mcp__plugin_cmdb-assistant_netbox__virt_create|mcp__plugin_cmdb-assistant_netbox__virt_update|mcp__plugin_cmdb-assistant_netbox__dcim_create|mcp__plugin_cmdb-assistant_netbox__dcim_update",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/validate-input.sh"
}
]
}
]
}
}

View File

@@ -1,66 +0,0 @@
#!/bin/bash
# cmdb-assistant SessionStart hook
# Tests NetBox API connectivity and checks for data quality issues
# All output MUST have [cmdb-assistant] prefix
# Non-blocking: always exits 0
set -euo pipefail
PREFIX="[cmdb-assistant]"
# Load NetBox configuration
NETBOX_CONFIG="$HOME/.config/claude/netbox.env"
if [[ ! -f "$NETBOX_CONFIG" ]]; then
echo "$PREFIX NetBox not configured - run /cmdb-assistant:initial-setup"
exit 0
fi
# Source config
source "$NETBOX_CONFIG"
# Validate required variables
if [[ -z "${NETBOX_API_URL:-}" ]] || [[ -z "${NETBOX_API_TOKEN:-}" ]]; then
echo "$PREFIX Missing NETBOX_API_URL or NETBOX_API_TOKEN in config"
exit 0
fi
# Quick API connectivity test (5s timeout)
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -m 5 \
-H "Authorization: Token $NETBOX_API_TOKEN" \
-H "Accept: application/json" \
"${NETBOX_API_URL}/" 2>/dev/null || echo "000")
if [[ "$HTTP_CODE" == "000" ]]; then
echo "$PREFIX NetBox API unreachable (timeout/connection error)"
exit 0
elif [[ "$HTTP_CODE" != "200" ]]; then
echo "$PREFIX NetBox API returned HTTP $HTTP_CODE - check credentials"
exit 0
fi
# Check for VMs without site assignment (data quality)
VMS_RESPONSE=$(curl -s -m 5 \
-H "Authorization: Token $NETBOX_API_TOKEN" \
-H "Accept: application/json" \
"${NETBOX_API_URL}/virtualization/virtual-machines/?site__isnull=true&limit=1" 2>/dev/null || echo '{"count":0}')
VMS_NO_SITE=$(echo "$VMS_RESPONSE" | grep -o '"count":[0-9]*' | cut -d: -f2 || echo "0")
if [[ "$VMS_NO_SITE" -gt 0 ]]; then
echo "$PREFIX $VMS_NO_SITE VMs without site assignment - run /cmdb-audit for details"
fi
# Check for devices without platform
DEVICES_RESPONSE=$(curl -s -m 5 \
-H "Authorization: Token $NETBOX_API_TOKEN" \
-H "Accept: application/json" \
"${NETBOX_API_URL}/dcim/devices/?platform__isnull=true&limit=1" 2>/dev/null || echo '{"count":0}')
DEVICES_NO_PLATFORM=$(echo "$DEVICES_RESPONSE" | grep -o '"count":[0-9]*' | cut -d: -f2 || echo "0")
if [[ "$DEVICES_NO_PLATFORM" -gt 0 ]]; then
echo "$PREFIX $DEVICES_NO_PLATFORM devices without platform - consider updating"
fi
exit 0

Some files were not shown because too many files have changed in this diff Show More