61 Commits

Author SHA1 Message Date
48ce693bb5 Merge pull request 'development' (#184) from development into main
Reviewed-on: #184
2026-01-26 18:59:23 +00:00
74531e06d0 Merge pull request 'feat(viz-platform): Complete Sprint 1 - Plugin Structure and Tests' (#183) from feat/viz-platform-sprint1-completion into development
Reviewed-on: #183
2026-01-26 18:58:58 +00:00
20458add3f feat(viz-platform): complete Sprint 1 - plugin structure and tests
Sprint 1 - viz-platform Plugin completed (13/13 issues):
- Commands: 7 files (initial-setup, chart, dashboard, theme, theme-new, theme-css, component)
- Agents: 3 files (theme-setup, layout-builder, component-check)
- Documentation: README.md, claude-md-integration.md
- Tests: 94 tests passing (68-99% coverage)
- CHANGELOG updated with completion status

Closes: #178, #179, #180, #181, #182

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 13:53:03 -05:00
45b899b093 feat(viz-platform): create plugin structure (#177)
- Add plugins/viz-platform/ directory structure
- Create .claude-plugin/plugin.json with metadata
- Create .mcp.json pointing to viz-platform MCP server
- Create hooks/hooks.json with SessionStart hook
- Create symlink to mcp-servers/viz-platform
- Add plugin to marketplace.json

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 12:00:54 -05:00
12133f698e feat(viz-platform): implement page tools (#176)
- Add page_create tool: create pages with routing
- Add page_add_navbar tool: generate top/side navigation
- Add page_set_auth tool: configure page authentication
- Support DMC AppShell component structure
- Auth types: none, basic, oauth, custom

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:56:05 -05:00
797b3064c9 feat(viz-platform): implement theme tools (#175)
- Add theme_create tool: create themes with design tokens
- Add theme_extend tool: extend existing themes with overrides
- Add theme_validate tool: validate theme completeness
- Add theme_export_css tool: export as CSS custom properties
- Add ThemeStore for theme persistence (user and project level)
- Default theme based on Mantine defaults with 47 CSS variables

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:53:21 -05:00
057c61fb16 feat(viz-platform): implement layout tools (#174)
- Add layout_create tool: create layouts with templates (dashboard, report, form, blank)
- Add layout_add_filter tool: add filter controls (dropdown, date_range, search, etc.)
- Add layout_set_grid tool: configure responsive grid system (1-24 cols, breakpoints)
- 4 templates and 9 filter types supported
- Maps to DMC Grid and AppShell component patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:50:10 -05:00
c91f21f3d1 feat(viz-platform): implement chart tools (#173)
- Add chart_create tool: create line, bar, scatter, pie, heatmap, histogram, area charts
- Add chart_configure_interaction tool: hover templates, click data, selection, zoom
- Support theme color integration when theme is active
- Default color palette based on Mantine theme

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:47:54 -05:00
67fae6a93d feat(viz-platform): implement DMC validation tools (#172)
- Add list_components tool: list DMC components by category
- Add get_component_props tool: get props schema with types/defaults/enums
- Add validate_component tool: validate props with error/warning messages
- Includes typo detection and common mistake warnings
- All tools registered in MCP server with proper JSON schemas

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:45:40 -05:00
9e4b3d5a91 feat(viz-platform): implement DMC 2.x component registry (#171)
- Add component_registry.py with version-locked registry loading
- Create dmc_2_5.json with 39 components for DMC 2.5.1/Mantine 7
- Add generate-dmc-registry.py script for future registry generation
- Update dependencies to DMC >=2.0.0 (installs 2.5.1)
- Includes prop validation with typo detection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:41:42 -05:00
bf0745cc94 feat(viz-platform): create MCP server foundation (#170)
Add viz-platform MCP server structure at mcp-servers/viz-platform/:
- mcp_server/server.py: Main MCP server entry point with async initialization
- mcp_server/config.py: Hybrid config loader with DMC version detection
- mcp_server/dmc_tools.py: Placeholder for DMC validation tools
- pyproject.toml and requirements.txt for dependencies
- tests/ directory structure

Server starts without errors with empty tool list.
Config detects DMC installation status via importlib.metadata.

Closes #170

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:31:44 -05:00
72049c2518 docs(projman): document Sprint 1 viz-platform planning
Add [Unreleased] section with Sprint 1 planning for viz-platform plugin:
- 15 MCP tools across 5 categories (DMC, Charts, Layouts, Themes, Pages)
- 7 commands and 3 agents
- Links to Gitea milestone, issues #170-#182, and wiki implementation plan

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:25:45 -05:00
bc0282b5f8 Merge pull request 'Merge development into main (v4.1.0 release)' (#169) from development into main 2026-01-26 15:57:59 +00:00
4c262a7227 Merge pull request 'fix(gitea-mcp): URL-encode wiki page names and include title in updates' (#168) from fix/160-wiki-page-unnamed-bug into development 2026-01-26 15:55:16 +00:00
530c5f4aa0 fix(gitea-mcp): URL-encode wiki page names and include title in updates
Fixes #160: update_wiki_page was renaming pages to "unnamed"

Root causes:
1. page_name wasn't URL-encoded, breaking pages with special chars like ':'
2. PATCH request was missing 'title' field, causing Gitea to use default name

Changes:
- Add URL encoding (urllib.parse.quote) to get_wiki_page, update_wiki_page, delete_wiki_page
- Add 'title': page_name to update_wiki_page payload to preserve page name

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 10:54:33 -05:00
61a3b4611f Merge pull request 'chore: release v4.1.0 with versioning workflow fix' (#167) from release/v4.1.0 into development 2026-01-26 15:41:35 +00:00
834cf3ac56 chore: release v4.1.0 2026-01-26 10:38:19 -05:00
28c9552d1d fix(projman): add mandatory CHANGELOG and versioning to sprint-close workflow
Problem: Version workflow was documented but not enforced in sprint-close.
After completing sprints, CHANGELOG wasn't updated and releases weren't created.

Solution:
- Add "Update CHANGELOG" as mandatory step 7 in sprint-close
- Add "Version Check" as step 8 with /suggest-version and release.sh
- Update orchestrator agent with CHANGELOG and version reminders
- Add V04.1.0 wiki workflow changes to CHANGELOG [Unreleased]
- Document MCP bug #160 in Known Issues

This ensures versioning workflow is part of the sprint close process,
not just documented in CLAUDE.md.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 10:38:19 -05:00
db67d3cc76 Merge pull request 'development' (#166) from development into main
Reviewed-on: #166
2026-01-26 15:29:35 +00:00
896cdcfa0f Merge pull request '[V04.1.0] feat: Wiki-Based Planning Workflow Enhancement' (#165) from feat/v04.1.0-wiki-planning-workflow into development
Reviewed-on: #165
2026-01-26 15:28:00 +00:00
34de0e4e82 [Sprint V04.1.0] feat: Add /proposal-status command for proposal tree view
- Create new proposal-status.md command
- Shows all proposals with status (Pending, In Progress, Implemented, Abandoned)
- Tree view of implementations under each proposal
- Links to issues and lessons learned
- Update README with new command documentation

Phase 4 of V04.1.0 Wiki-Based Planning Enhancement.
Closes #164

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 10:23:53 -05:00
1a0f3aa779 [Sprint V04.1.0] feat: Add cross-linking between issues, wiki, and lessons
- Add Metadata section to lesson structure with Implementation link
- Update lesson examples to include metadata with wiki reference
- Enable bidirectional traceability: lessons ↔ implementation pages

Phase 3 of V04.1.0 Wiki-Based Planning Enhancement.
Closes #163

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 10:21:53 -05:00
30b379b68c [Sprint V04.1.0] feat: Add wiki status updates to sprint-close workflow
- Add step 5: Update wiki implementation page status (Implemented/Partial/Failed)
- Add step 6: Update wiki proposal page when all implementations complete
- Add update_wiki_page to MCP tools in both sprint-close.md and orchestrator.md
- Add critical reminders for wiki status updates

Implements Phase 2 of V04.1.0 Wiki-Based Planning Enhancement.
Closes #162

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 10:15:08 -05:00
842ce3f6bc feat(projman): add wiki-based planning workflow to sprint-plan
Phase 1 implementation for Change V04.1.0:
- Add flexible input source detection (file, wiki, conversation)
- Add wiki proposal and implementation page creation
- Add wiki reference to created issues
- Add cleanup step to delete local files after migration
- Update planner agent with wiki workflow responsibilities

Closes #161

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 10:01:13 -05:00
a933edeef1 Merge pull request 'development' (#159) from development into main
Reviewed-on: #159
2026-01-25 23:46:30 +00:00
74ff305b67 Merge pull request 'fix(data-platform): use separate hooks.json for development' (#158) from fix/data-platform-hooks-dev into development
Reviewed-on: #158
2026-01-25 20:52:19 +00:00
f22d49ed07 docs: correct plugin.json hooks format rules
Hooks should be in separate hooks/hooks.json file (auto-discovered),
NOT inline in plugin.json.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:48:58 -05:00
3f79288b54 fix(data-platform): use separate hooks.json file (not inline)
Cherry-pick fix from hotfix/data-platform-hooks to development.
Hooks must be in separate hooks/hooks.json file (auto-discovered),
NOT inline in plugin.json.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:48:41 -05:00
1df9573f7a Merge pull request 'hotfix(data-platform): use separate hooks.json file' (#157) from hotfix/data-platform-hooks into main
Reviewed-on: #157
2026-01-25 20:46:14 +00:00
35d5f14003 docs: correct plugin.json hooks format rules
Hooks should be in separate hooks/hooks.json file (auto-discovered),
NOT inline in plugin.json. Previous documentation was wrong.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:42:13 -05:00
5321b2929e fix(data-platform): use separate hooks.json file (not inline)
Previous fix was wrong. Hooks should be in separate hooks/hooks.json
file (auto-discovered), NOT inline in plugin.json.

Pattern matches working plugins: projman, pr-review, claude-config-maintainer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:41:46 -05:00
05aa50d409 Merge pull request 'development' (#156) from development into main
Reviewed-on: #156
2026-01-25 20:37:15 +00:00
a910e9327d Merge pull request 'fix(data-platform): correct invalid plugin.json manifest format' (#155) from fix/data-platform-manifest into development
Reviewed-on: #155
2026-01-25 20:36:53 +00:00
9d1fedd3a5 docs: add plugin.json format rules to CLAUDE.md
Add critical warning about hooks and agents field formats
to prevent future manifest validation failures.

References lesson: plugin-manifest-validation---hooks-and-agents-format-requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:33:49 -05:00
320ea6b72b fix(data-platform): correct invalid plugin.json manifest format
- Remove invalid "agents": ["./agents/"] - agent .md files don't need registration
- Inline hooks instead of external reference "hooks/hooks.json"
- Delete orphaned hooks.json file (content now in plugin.json)

This fixes "invalid input" validation errors for hooks and agents fields.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:32:54 -05:00
54e8e694b1 Merge pull request 'development' (#154) from development into main
Reviewed-on: #154
2026-01-25 20:24:29 +00:00
69bbffd9cc Merge pull request 'fix: proactive documentation and sprint planning automation' (#153) from fix/proactive-automation-docs into development
Reviewed-on: #153
2026-01-25 20:23:56 +00:00
6b666bff46 fix: proactive documentation and sprint planning automation
- doc-guardian: Hook now tracks documentation dependencies and outputs
  specific files needing updates (e.g., commands → COMMANDS-CHEATSHEET.md)
- projman: SessionStart hook now suggests /sprint-plan when open issues
  exist without milestone, and warns about unreleased CHANGELOG entries
- projman: Add /suggest-version command for semantic version recommendations
- docs: Update COMMANDS-CHEATSHEET.md with data-platform plugin (was missing)
- docs: Update CLAUDE.md with data-platform and version 4.0.0

Fixes documentation drift and lack of proactive workflow suggestions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:18:30 -05:00
8ec7cbb1e9 Merge pull request 'development' (#152) from development into main
Reviewed-on: #152
2026-01-25 19:37:53 +00:00
a77b8ee123 Merge pull request 'chore: release v4.0.0' (#151) from chore/release-v4.0.0 into development
Reviewed-on: #151
2026-01-25 19:36:25 +00:00
498dac5230 chore: release v4.0.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:33:47 -05:00
3519a96d06 Merge pull request 'development' (#150) from development into main
Reviewed-on: #150
2026-01-25 19:28:43 +00:00
af0b92461a Merge pull request 'feat: add data-platform plugin (v4.0.0)' (#149) from feat/data-platform-plugin-v4.0.0 into development
Reviewed-on: #149
2026-01-25 19:27:26 +00:00
89f0354ccc feat: add data-platform plugin (v4.0.0)
Add new data-platform plugin for data engineering workflows with:

MCP Server (32 tools):
- pandas operations (14 tools): read_csv, read_parquet, read_json,
  to_csv, to_parquet, describe, head, tail, filter, select, groupby,
  join, list_data, drop_data
- PostgreSQL/PostGIS (10 tools): pg_connect, pg_query, pg_execute,
  pg_tables, pg_columns, pg_schemas, st_tables, st_geometry_type,
  st_srid, st_extent
- dbt integration (8 tools): dbt_parse, dbt_run, dbt_test, dbt_build,
  dbt_compile, dbt_ls, dbt_docs_generate, dbt_lineage

Plugin Features:
- Arrow IPC data_ref system for DataFrame persistence across tool calls
- Pre-execution validation for dbt with `dbt parse`
- SessionStart hook for PostgreSQL connectivity check (non-blocking)
- Hybrid configuration (system ~/.config/claude/postgres.env + project .env)
- Memory management with 100k row limit and chunking support

Commands: /initial-setup, /ingest, /profile, /schema, /explain, /lineage, /run
Agents: data-ingestion, data-analysis

Test suite: 71 tests covering config, data store, pandas, postgres, dbt tools

Addresses data workflow issues from personal-portfolio project:
- Lost data after multiple interactions (solved by Arrow IPC data_ref)
- dbt 1.9+ syntax deprecation (solved by pre-execution validation)
- Ungraceful PostgreSQL error handling (solved by SessionStart hook)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:24:03 -05:00
f809c672b5 Merge pull request 'development' (#148) from development into main
Reviewed-on: #148
2026-01-24 18:16:29 +00:00
6a267d074b Merge pull request 'feat: add versioning workflow with release script + release v3.2.0' (#147) from fix/issue-143-versioning-workflow into development
Reviewed-on: #147
2026-01-24 18:16:05 +00:00
bcde33c7d0 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:15:30 -05:00
ee3268fbe0 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:15:30 -05:00
f6a38ffaa8 Merge pull request 'fix: remove destructive cache-clear instruction from CLAUDE.md' (#146) from fix/issue-145-cache-clear-instructions into development
Reviewed-on: #146
2026-01-24 18:14:03 +00:00
b13ffce0a0 fix: remove destructive cache-clear instruction from CLAUDE.md
The CLAUDE.md mandatory rule instructed clearing the plugin cache
mid-session, which breaks all MCP tools that were already loaded.
MCP tools are loaded with absolute paths to the venv, and deleting
the cache removes the venv while the session still references it.

Changes:
- CLAUDE.md: Replace "ALWAYS CLEAR CACHE" with "VERIFY AND RESTART"
- verify-hooks.sh: Change cache existence from error to info message
- DEBUGGING-CHECKLIST.md: Add section explaining cache timing

Fixes #145

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 12:57:32 -05:00
5d205c9c13 Merge pull request 'development' (#142) from development into main
Reviewed-on: #142
2026-01-24 17:36:43 +00:00
b39e01efd7 Merge pull request 'feat(projman): add user-reported issue mode to /debug-report' (#141) from feat/issue-139-debug-report-user-mode into development
Reviewed-on: #141
2026-01-24 17:36:16 +00:00
98eea5b6f9 Merge pull request 'chore: remove unreleased v3.1.2 changelog entry' (#140) from chore/remove-unreleased-changelog into development
Reviewed-on: #140
2026-01-24 17:35:58 +00:00
fe36ed91f2 feat(projman): add user-reported issue mode to /debug-report
Adds a new "user-reported" mode alongside the existing automated
diagnostics mode. Users can now choose to:

1. Run automated diagnostics (existing behavior)
2. Report an issue they experienced while using any plugin command

User-reported mode:
- Step 0: Mode selection via AskUserQuestion
- Step 0.1: Structured feedback gathering
  - Which plugin/command was affected
  - What the user was trying to do
  - What went wrong (error, missing feature, unexpected behavior, docs)
  - Expected vs actual behavior
  - Any workarounds found
- Step 5.1: Smart label generation based on problem type
- Step 6.1: User-friendly issue template with investigation hints

This allows capturing UX issues, missing features, and documentation
problems that wouldn't be caught by automated MCP tool tests.

Fixes #139

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 12:30:37 -05:00
8c85f9ca5f chore: remove unreleased v3.1.2 changelog entry
Tag v3.1.2 was never created. Remove the changelog entry to
avoid confusion between documented and actual releases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 12:26:08 -05:00
efb83e0f28 Merge pull request 'development' (#135) from development into main
Reviewed-on: #135
2026-01-24 16:48:40 +00:00
7bedfa2c65 Merge pull request 'development' (#133) from development into main
Reviewed-on: #133
2026-01-23 22:54:42 +00:00
42ab4f13cf Merge pull request 'development' (#125) from development into main
Reviewed-on: #125
2026-01-23 21:48:13 +00:00
77dc122079 Merge pull request 'development' (#122) from development into main
Reviewed-on: #122
2026-01-23 21:08:10 +00:00
ce774bcc6f Merge pull request 'development' (#119) from development into main
Reviewed-on: #119
2026-01-23 19:47:39 +00:00
b3abe863af Merge pull request 'development' (#117) from development into main
Reviewed-on: #117
2026-01-23 17:50:25 +00:00
91 changed files with 14024 additions and 165 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": "3.1.0" "version": "4.1.0"
}, },
"plugins": [ "plugins": [
{ {
"name": "projman", "name": "projman",
"version": "3.1.0", "version": "3.2.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": {
@@ -149,6 +149,38 @@
"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.0.0",
"description": "Data engineering tools with pandas, PostgreSQL/PostGIS, and dbt integration",
"source": "./plugins/data-platform",
"author": {
"name": "Leo Miranda",
"email": "leobmiranda@gmail.com"
},
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/data-platform/README.md",
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
"mcpServers": ["./.mcp.json"],
"category": "data",
"tags": ["pandas", "postgresql", "postgis", "dbt", "data-engineering", "etl"],
"license": "MIT"
},
{
"name": "viz-platform",
"version": "1.0.0",
"description": "Visualization tools with Dash Mantine Components validation, Plotly charts, and theming",
"source": "./plugins/viz-platform",
"author": {
"name": "Leo Miranda",
"email": "leobmiranda@gmail.com"
},
"homepage": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/src/branch/main/plugins/viz-platform/README.md",
"repository": "https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git",
"mcpServers": ["./.mcp.json"],
"category": "visualization",
"tags": ["dash", "plotly", "mantine", "charts", "dashboards", "theming", "dmc"],
"license": "MIT"
} }
] ]
} }

View File

@@ -4,14 +4,118 @@ All notable changes to the Leo Claude Marketplace will be documented in this fil
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [3.1.2] - 2026-01-23 ## [Unreleased]
### 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** (3 tools): `layout_create`, `layout_add_filter`, `layout_set_grid`
- Dashboard composition with responsive grid system
- **Theme Tools** (4 tools): `theme_create`, `theme_extend`, `theme_validate`, `theme_export_css`
- Design token-based theming system
- Dual storage: user-level (`~/.config/claude/themes/`) and project-level
- **Page Tools** (3 tools): `page_create`, `page_add_navbar`, `page_set_auth`
- Multi-page Dash app structure generation
- **Commands**: `/chart`, `/dashboard`, `/theme`, `/theme-new`, `/theme-css`, `/component`, `/initial-setup`
- **Agents**: `theme-setup`, `layout-builder`, `component-check`
- **SessionStart Hook**: DMC version check (non-blocking)
- **Tests**: 94 tests passing
- config.py: 82% coverage
- component_registry.py: 92% coverage
- dmc_tools.py: 88% coverage
- chart_tools.py: 68% coverage
- theme_tools.py: 99% coverage
**Sprint Completed:**
- Milestone: Sprint 1 - viz-platform Plugin (closed 2026-01-26)
- Issues: #170-#182 (13/13 closed)
- Wiki: [Sprint-1-viz-platform-Implementation-Plan](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/Sprint-1-viz-platform-Implementation-Plan)
- Lessons: [sprint-1---viz-platform-plugin-implementation](https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace/wiki/lessons/sprints/sprint-1---viz-platform-plugin-implementation)
- Reference: `docs/changes/CHANGE_V04_0_0_PROPOSAL_ORIGINAL.md` (Phases 4-5)
---
## [4.1.0] - 2026-01-26
### Added
- **projman:** Wiki-based planning workflow enhancement (V04.1.0)
- Flexible input source detection in `/sprint-plan` (file, wiki, or conversation)
- Wiki proposal and implementation page creation during sprint planning
- Wiki reference linking in created issues
- Wiki status updates in `/sprint-close` (Implemented/Partial/Failed)
- Metadata section in lessons learned with implementation link for traceability
- New `/proposal-status` command for viewing proposal/implementation tree
- **projman:** `/suggest-version` command - Analyzes CHANGELOG and recommends semantic version bump
- **projman:** SessionStart hook now suggests sprint planning when open issues exist without milestone
- **projman:** SessionStart hook now warns about unreleased CHANGELOG entries
### Changed
- **doc-guardian:** Hook now tracks documentation dependencies and queues specific files needing updates
- Outputs which specific docs need updating (e.g., "commands changed → update needed: docs/COMMANDS-CHEATSHEET.md README.md")
- Maintains queue file (`.doc-guardian-queue`) for batch processing
- **docs:** COMMANDS-CHEATSHEET.md updated with data-platform plugin (7 commands + hook)
### Fixed
- Documentation drift: COMMANDS-CHEATSHEET.md was missing data-platform plugin added in v4.0.0
- Proactive sprint planning: projman now suggests `/sprint-plan` at session start when unplanned issues exist
### Known Issues
- **MCP Bug #160:** `update_wiki_page` tool renames pages to "unnamed" when page_name contains URL-encoded characters (`:``%3A`). Workaround: use `create_wiki_page` to overwrite instead.
---
## [4.0.0] - 2026-01-25
### Added
#### New Plugin: data-platform v1.0.0
- **pandas MCP Tools** (14 tools): DataFrame operations with Arrow IPC data_ref persistence
- `read_csv`, `read_parquet`, `read_json` - Load data with chunking support
- `to_csv`, `to_parquet` - Export to various formats
- `describe`, `head`, `tail` - Data exploration
- `filter`, `select`, `groupby`, `join` - Data transformation
- `list_data`, `drop_data` - Memory management
- **PostgreSQL MCP Tools** (10 tools): Database operations with asyncpg connection pooling
- `pg_connect`, `pg_query`, `pg_execute` - Core database operations
- `pg_tables`, `pg_columns`, `pg_schemas` - Schema exploration
- `st_tables`, `st_geometry_type`, `st_srid`, `st_extent` - PostGIS spatial support
- **dbt MCP Tools** (8 tools): Build tool wrapper with pre-execution validation
- `dbt_parse` - Pre-flight validation (catches dbt 1.9+ deprecations)
- `dbt_run`, `dbt_test`, `dbt_build` - Execution with auto-validation
- `dbt_compile`, `dbt_ls`, `dbt_docs_generate`, `dbt_lineage` - Analysis tools
- **Commands**: `/ingest`, `/profile`, `/schema`, `/explain`, `/lineage`, `/run`
- **Agents**: `data-ingestion` (loading/transformation), `data-analysis` (exploration/profiling)
- **SessionStart Hook**: Graceful PostgreSQL connection check (non-blocking warning)
- **Key Features**:
- data_ref system for DataFrame persistence across tool calls
- 100k row limit with chunking support for large datasets
- Hybrid configuration (system: `~/.config/claude/postgres.env`, project: `.env`)
- Auto-detection of dbt projects
- Arrow IPC format for efficient memory management
---
## [3.2.0] - 2026-01-24
### Added ### Added
- **git-flow:** `/commit` now detects protected branches before committing - **git-flow:** `/commit` now detects protected branches before committing
- Warns when on protected branch (main, master, development, staging, production) - Warns when on protected branch (main, master, development, staging, production)
- Offers to create feature branch automatically instead of committing directly - Offers to create feature branch automatically instead of committing directly
- Configurable via `GIT_PROTECTED_BRANCHES` environment variable - Configurable via `GIT_PROTECTED_BRANCHES` environment variable
- Resolves issue where commits to protected branches would fail on push - **netbox:** Platform and primary_ip parameters added to device update tools
- **claude-config-maintainer:** Auto-enforce mandatory behavior rules via SessionStart hook
- **scripts:** `release.sh` - Versioning workflow script for consistent releases
- **scripts:** `verify-hooks.sh` - Verify all hooks are command type
### Changed ### Changed
- **doc-guardian:** Hook switched from `prompt` type to `command` type - **doc-guardian:** Hook switched from `prompt` type to `command` type
@@ -19,14 +123,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- New `notify.sh` bash script guarantees exact output behavior - New `notify.sh` bash script guarantees exact output behavior
- Only notifies for config file changes (commands/, agents/, skills/, hooks/) - Only notifies for config file changes (commands/, agents/, skills/, hooks/)
- Silent exit for all other files - no blocking possible - Silent exit for all other files - no blocking possible
- **All hooks:** Stricter plugin prefix enforcement - **All hooks:** Converted to command type with stricter plugin prefix enforcement
- All prompts now mandate `[plugin-name]` prefix with "NO EXCEPTIONS" rule - All hooks now mandate `[plugin-name]` prefix with "NO EXCEPTIONS" rule
- Simplified output formats with word limits - Simplified output formats with word limits
- Consistent structure across projman, pr-review, code-sentinel, doc-guardian - Consistent structure across projman, pr-review, code-sentinel, doc-guardian
- **CLAUDE.md:** Replaced destructive "ALWAYS CLEAR CACHE" rule with "VERIFY AND RESTART"
- Cache clearing mid-session breaks MCP tools
- Added guidance for proper plugin development workflow
### Fixed ### Fixed
- Protected branch workflow: Claude no longer commits directly to protected branches and then fails on push (fixes #109) - **cmdb-assistant:** Complete MCP tool schemas for update operations (#138)
- doc-guardian hook no longer blocks workflow - switched to command hook that can't be overridden by model (fixes #110) - **netbox:** Shorten tool names to meet 64-char API limit (#134)
- **cmdb-assistant:** Correct NetBox API URL format in setup wizard (#132)
- **gitea/projman:** Type safety for `create_label_smart`, curl-based debug-report (#124)
- **netbox:** Add diagnostic logging for JSON parse errors (#121)
- **labels:** Add duplicate check before creating labels (#116)
- **hooks:** Convert ALL hooks to command type with proper prefixes (#114)
- Protected branch workflow: Claude no longer commits directly to protected branches (fixes #109)
- doc-guardian hook no longer blocks workflow (fixes #110)
--- ---

124
CLAUDE.md
View File

@@ -31,11 +31,16 @@ This file provides guidance to Claude Code when working with code in this reposi
- If user asks for output, show the OUTPUT - If user asks for output, show the OUTPUT
- **Don't interpret or summarize unless asked** - **Don't interpret or summarize unless asked**
### 5. AFTER PLUGIN UPDATES - ALWAYS CLEAR CACHE ### 5. AFTER PLUGIN UPDATES - VERIFY AND RESTART
```bash
rm -rf ~/.claude/plugins/cache/leo-claude-mktplace/ **⚠️ DO NOT clear cache mid-session** - this breaks MCP tools that are already loaded.
./scripts/verify-hooks.sh
``` 1. Run `./scripts/verify-hooks.sh` to check hook types
2. If changes affect MCP servers or hooks, inform the user:
> "Plugin changes require a session restart to take effect. Please restart Claude Code."
3. Cache clearing is ONLY safe **before** starting a new session (not during)
See `docs/DEBUGGING-CHECKLIST.md` for details on cache timing.
**FAILURE TO FOLLOW THESE RULES = WASTED USER TIME = UNACCEPTABLE** **FAILURE TO FOLLOW THESE RULES = WASTED USER TIME = UNACCEPTABLE**
@@ -45,7 +50,7 @@ rm -rf ~/.claude/plugins/cache/leo-claude-mktplace/
## Project Overview ## Project Overview
**Repository:** leo-claude-mktplace **Repository:** leo-claude-mktplace
**Version:** 3.1.2 **Version:** 4.0.0
**Status:** Production Ready **Status:** Production Ready
A plugin marketplace for Claude Code containing: A plugin marketplace for Claude Code containing:
@@ -60,6 +65,8 @@ A plugin marketplace for Claude Code containing:
| `code-sentinel` | Security scanning and code refactoring tools | 1.0.0 | | `code-sentinel` | Security scanning and code refactoring tools | 1.0.0 |
| `claude-config-maintainer` | CLAUDE.md optimization and maintenance | 1.0.0 | | `claude-config-maintainer` | CLAUDE.md optimization and maintenance | 1.0.0 |
| `cmdb-assistant` | NetBox CMDB integration for infrastructure management | 1.0.0 | | `cmdb-assistant` | NetBox CMDB integration for infrastructure management | 1.0.0 |
| `data-platform` | pandas, PostgreSQL, and dbt integration for data engineering | 1.0.0 |
| `viz-platform` | DMC validation, Plotly charts, and theming for dashboards | 1.0.0 |
| `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
@@ -79,10 +86,13 @@ A plugin marketplace for Claude Code containing:
| **Setup** | `/initial-setup`, `/project-init`, `/project-sync` | | **Setup** | `/initial-setup`, `/project-init`, `/project-sync` |
| **Sprint** | `/sprint-plan`, `/sprint-start`, `/sprint-status`, `/sprint-close` | | **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:initial-setup`, `/pr-review:project-init` |
| **Docs** | `/doc-audit`, `/doc-sync` | | **Docs** | `/doc-audit`, `/doc-sync` |
| **Security** | `/security-scan`, `/refactor`, `/refactor-dry` | | **Security** | `/security-scan`, `/refactor`, `/refactor-dry` |
| **Config** | `/config-analyze`, `/config-optimize` | | **Config** | `/config-analyze`, `/config-optimize` |
| **Data** | `/ingest`, `/profile`, `/schema`, `/explain`, `/lineage`, `/run` |
| **Visualization** | `/component`, `/chart`, `/dashboard`, `/theme`, `/theme-new`, `/theme-css` |
| **Debug** | `/debug-report`, `/debug-review` | | **Debug** | `/debug-report`, `/debug-review` |
## Repository Structure ## Repository Structure
@@ -93,14 +103,15 @@ 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)
│ └── 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/ # 13 commands (incl. setup, debug) │ │ ├── commands/ # 14 commands (incl. setup, debug, suggest-version)
│ │ ├── hooks/ # SessionStart mismatch detection │ │ ├── hooks/ # SessionStart: mismatch detection + sprint suggestions
│ │ ├── agents/ # 4 agents │ │ ├── agents/ # 4 agents
│ │ └── skills/label-taxonomy/ │ │ └── skills/label-taxonomy/
│ ├── git-flow/ # Git workflow automation │ ├── git-flow/ # Git workflow automation
@@ -114,10 +125,24 @@ 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 (NEW v3.0.0) │ ├── clarity-assist/ # Prompt optimization
│ │ ├── .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/
@@ -125,7 +150,9 @@ 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
@@ -146,6 +173,14 @@ leo-claude-mktplace/
- **MCP server venv path**: `${CLAUDE_PLUGIN_ROOT}/mcp-servers/{name}/.venv/bin/python` - **MCP server venv path**: `${CLAUDE_PLUGIN_ROOT}/mcp-servers/{name}/.venv/bin/python`
- **CLI tools forbidden** - Use MCP tools exclusively (never `tea`, `gh`, etc.) - **CLI tools forbidden** - Use MCP tools exclusively (never `tea`, `gh`, etc.)
#### ⚠️ plugin.json Format Rules (CRITICAL)
- **Hooks in separate file** - Use `hooks/hooks.json` (auto-discovered), NOT inline in plugin.json
- **NEVER reference hooks** - Don't add `"hooks": "..."` field to plugin.json at all
- **Agents auto-discover** - NEVER add `"agents": ["./agents/"]` - .md files found automatically
- **Always validate** - Run `./scripts/validate-marketplace.sh` before committing
- **Working examples:** projman, pr-review, claude-config-maintainer all use `hooks/hooks.json`
- See lesson: `lessons/patterns/plugin-manifest-validation---hooks-and-agents-format-requirements`
### Hooks (Valid Events Only) ### Hooks (Valid Events Only)
`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `SessionEnd`, `Notification`, `Stop`, `SubagentStop`, `PreCompact` `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `SessionEnd`, `Notification`, `Stop`, `SubagentStop`, `PreCompact`
@@ -172,12 +207,12 @@ leo-claude-mktplace/
| Category | Tools | | Category | Tools |
|----------|-------| |----------|-------|
| Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `add_comment` | | Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `add_comment`, `aggregate_issues` |
| Labels | `get_labels`, `suggest_labels`, `create_label` | | Labels | `get_labels`, `suggest_labels`, `create_label`, `create_label_smart` |
| Milestones | `list_milestones`, `get_milestone`, `create_milestone`, `update_milestone` | | Milestones | `list_milestones`, `get_milestone`, `create_milestone`, `update_milestone`, `delete_milestone` |
| Dependencies | `list_issue_dependencies`, `create_issue_dependency`, `get_execution_order` | | Dependencies | `list_issue_dependencies`, `create_issue_dependency`, `remove_issue_dependency`, `get_execution_order` |
| Wiki | `list_wiki_pages`, `get_wiki_page`, `create_wiki_page`, `create_lesson`, `search_lessons` | | Wiki | `list_wiki_pages`, `get_wiki_page`, `create_wiki_page`, `update_wiki_page`, `create_lesson`, `search_lessons` |
| **Pull Requests** | `list_pull_requests`, `get_pull_request`, `get_pr_diff`, `get_pr_comments`, `create_pr_review`, `add_pr_comment` *(NEW v3.0.0)* | | **Pull Requests** | `list_pull_requests`, `get_pull_request`, `get_pr_diff`, `get_pr_comments`, `create_pr_review`, `add_pr_comment` |
| Validation | `validate_repo_org`, `get_branch_protection` | | Validation | `validate_repo_org`, `get_branch_protection` |
### Hybrid Configuration ### Hybrid Configuration
@@ -286,13 +321,56 @@ See `docs/DEBUGGING-CHECKLIST.md` for systematic troubleshooting.
- `/debug-report` - Run full diagnostics, create issue if needed - `/debug-report` - Run full diagnostics, create issue if needed
- `/debug-review` - Investigate and propose fixes - `/debug-review` - Investigate and propose fixes
## Versioning Rules ## Versioning Workflow
- Version displayed ONLY in main `README.md` title: `# Leo Claude Marketplace - vX.Y.Z` This project follows [SemVer](https://semver.org/) and [Keep a Changelog](https://keepachangelog.com).
- `CHANGELOG.md` is authoritative for version history
- Follow [SemVer](https://semver.org/): MAJOR.MINOR.PATCH ### Version Locations (must stay in sync)
- On release: Update README title → CHANGELOG → marketplace.json → plugin.json files
| Location | Format | Example |
|----------|--------|---------|
| Git tags | `vX.Y.Z` | `v3.2.0` |
| README.md title | `# Leo Claude Marketplace - vX.Y.Z` | `v3.2.0` |
| marketplace.json | `"version": "X.Y.Z"` | `3.2.0` |
| CHANGELOG.md | `## [X.Y.Z] - YYYY-MM-DD` | `[3.2.0] - 2026-01-24` |
### During Development
**All changes go under `[Unreleased]` in CHANGELOG.md.** Never create a versioned section until release time.
```markdown
## [Unreleased]
### Added
- New feature description
### Fixed
- Bug fix description
```
### Creating a Release
Use the release script to ensure consistency:
```bash
./scripts/release.sh 3.2.0
```
The script will:
1. Validate `[Unreleased]` section has content
2. Replace `[Unreleased]` with `[3.2.0] - YYYY-MM-DD`
3. Update README.md title
4. Update marketplace.json version
5. Commit and create git tag
### SemVer Guidelines
| Change Type | Version Bump | Example |
|-------------|--------------|---------|
| Bug fixes only | PATCH (x.y.**Z**) | 3.1.1 → 3.1.2 |
| New features (backwards compatible) | MINOR (x.**Y**.0) | 3.1.2 → 3.2.0 |
| Breaking changes | MAJOR (**X**.0.0) | 3.2.0 → 4.0.0 |
--- ---
**Last Updated:** 2026-01-23 **Last Updated:** 2026-01-24

View File

@@ -1,4 +1,4 @@
# Leo Claude Marketplace - v3.1.1 # Leo Claude Marketplace - v4.1.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.
@@ -96,6 +96,21 @@ Full CRUD operations for network infrastructure management directly from Claude
**Commands:** `/initial-setup`, `/cmdb-search`, `/cmdb-device`, `/cmdb-ip`, `/cmdb-site` **Commands:** `/initial-setup`, `/cmdb-search`, `/cmdb-device`, `/cmdb-ip`, `/cmdb-site`
### Data Engineering
#### [data-platform](./plugins/data-platform/README.md) *NEW*
**pandas, PostgreSQL/PostGIS, and dbt Integration**
Comprehensive data engineering toolkit with persistent DataFrame storage.
- 14 pandas tools with Arrow IPC data_ref system
- 10 PostgreSQL/PostGIS tools with connection pooling
- 8 dbt tools with automatic pre-validation
- 100k row limit with chunking support
- Auto-detection of dbt projects
**Commands:** `/ingest`, `/profile`, `/schema`, `/explain`, `/lineage`, `/run`
## MCP Servers ## MCP Servers
MCP servers are **shared at repository root** with **symlinks** from plugins that use them. MCP servers are **shared at repository root** with **symlinks** from plugins that use them.
@@ -106,11 +121,11 @@ Full Gitea API integration for project management.
| Category | Tools | | Category | Tools |
|----------|-------| |----------|-------|
| Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `add_comment` | | Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `add_comment`, `aggregate_issues` |
| Labels | `get_labels`, `suggest_labels`, `create_label` | | Labels | `get_labels`, `suggest_labels`, `create_label`, `create_label_smart` |
| Wiki | `list_wiki_pages`, `get_wiki_page`, `create_wiki_page`, `create_lesson`, `search_lessons` | | Wiki | `list_wiki_pages`, `get_wiki_page`, `create_wiki_page`, `update_wiki_page`, `create_lesson`, `search_lessons` |
| Milestones | `list_milestones`, `get_milestone`, `create_milestone`, `update_milestone` | | Milestones | `list_milestones`, `get_milestone`, `create_milestone`, `update_milestone`, `delete_milestone` |
| Dependencies | `list_issue_dependencies`, `create_issue_dependency`, `get_execution_order` | | Dependencies | `list_issue_dependencies`, `create_issue_dependency`, `remove_issue_dependency`, `get_execution_order` |
| **Pull Requests** | `list_pull_requests`, `get_pull_request`, `get_pr_diff`, `get_pr_comments`, `create_pr_review`, `add_pr_comment` *(NEW in v3.0.0)* | | **Pull Requests** | `list_pull_requests`, `get_pull_request`, `get_pr_diff`, `get_pr_comments`, `create_pr_review`, `add_pr_comment` *(NEW in v3.0.0)* |
| Validation | `validate_repo_org`, `get_branch_protection` | | Validation | `validate_repo_org`, `get_branch_protection` |
@@ -126,6 +141,17 @@ 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*
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` |
## Installation ## Installation
### Prerequisites ### Prerequisites
@@ -222,6 +248,7 @@ 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` |
## Repository Structure ## Repository Structure
@@ -231,12 +258,14 @@ 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)
├── plugins/ # All plugins ├── plugins/ # All plugins
│ ├── projman/ # Sprint management │ ├── projman/ # Sprint management
│ ├── git-flow/ # Git workflow automation (NEW) │ ├── git-flow/ # Git workflow automation
│ ├── pr-review/ # PR review (NEW) │ ├── pr-review/ # PR review
│ ├── clarity-assist/ # Prompt optimization (NEW) │ ├── clarity-assist/ # Prompt optimization
│ ├── data-platform/ # Data engineering (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
@@ -245,7 +274,8 @@ leo-claude-mktplace/
├── docs/ # Documentation ├── docs/ # Documentation
│ ├── CANONICAL-PATHS.md # Path reference │ ├── CANONICAL-PATHS.md # Path reference
│ └── CONFIGURATION.md # Setup guide │ └── CONFIGURATION.md # Setup guide
── scripts/ # Setup scripts ── scripts/ # Setup scripts
└── CHANGELOG.md # Version history
``` ```
## Documentation ## Documentation

View File

@@ -22,6 +22,7 @@ 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 |
| **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 |
@@ -55,6 +56,14 @@ Quick reference for all commands in the Leo Claude Marketplace.
| **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 |
| **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** | `/initial-setup` | | X | Setup wizard for data-platform MCP servers |
| **data-platform** | *SessionStart hook* | X | | Checks PostgreSQL connection (non-blocking warning) |
--- ---
@@ -62,12 +71,13 @@ Quick reference for all commands in the Leo Claude Marketplace.
| Category | Plugins | Primary Use | | Category | Plugins | Primary Use |
|----------|---------|-------------| |----------|---------|-------------|
| **Setup** | projman, pr-review, cmdb-assistant | `/initial-setup`, `/project-init` | | **Setup** | projman, pr-review, cmdb-assistant, data-platform | `/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 |
| **Maintenance** | project-hygiene | Automatic cleanup | | **Maintenance** | project-hygiene | Automatic cleanup |
--- ---
@@ -76,11 +86,12 @@ 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 | | **projman** | SessionStart | Checks git remote vs .env; warns if mismatch detected; suggests sprint planning if issues exist |
| **pr-review** | SessionStart | Checks git remote vs .env; warns if mismatch detected | | **pr-review** | SessionStart | Checks git remote vs .env; warns if mismatch detected |
| **doc-guardian** | PostToolUse (Write/Edit) | Silently tracks documentation drift | | **doc-guardian** | PostToolUse (Write/Edit) | Tracks documentation drift; auto-updates dependent docs |
| **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 |
--- ---
@@ -162,6 +173,19 @@ 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:
@@ -209,9 +233,10 @@ 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 |
Ensure credentials are configured in `~/.config/claude/gitea.env` or `~/.config/claude/netbox.env`. Ensure credentials are configured in `~/.config/claude/gitea.env`, `~/.config/claude/netbox.env`, or `~/.config/claude/postgres.env`.
--- ---
*Last Updated: 2026-01-22* *Last Updated: 2026-01-25*

View File

@@ -197,6 +197,51 @@ 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

@@ -0,0 +1,131 @@
# Data Platform MCP Server
MCP Server providing pandas, PostgreSQL/PostGIS, and dbt tools for Claude Code.
## Features
- **pandas Tools**: DataFrame operations with Arrow IPC data_ref persistence
- **PostgreSQL Tools**: Database queries with asyncpg connection pooling
- **PostGIS Tools**: Spatial data operations
- **dbt Tools**: Build tool wrapper with pre-execution validation
## Installation
```bash
cd mcp-servers/data-platform
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install -r requirements.txt
```
## Configuration
### System-Level (PostgreSQL credentials)
Create `~/.config/claude/postgres.env`:
```env
POSTGRES_URL=postgresql://user:password@host:5432/database
```
### Project-Level (dbt paths)
Create `.env` in your project root:
```env
DBT_PROJECT_DIR=/path/to/dbt/project
DBT_PROFILES_DIR=/path/to/.dbt
DATA_PLATFORM_MAX_ROWS=100000
```
## Tools
### pandas Tools (14 tools)
| Tool | Description |
|------|-------------|
| `read_csv` | Load CSV file into DataFrame |
| `read_parquet` | Load Parquet file into DataFrame |
| `read_json` | Load JSON/JSONL file into DataFrame |
| `to_csv` | Export DataFrame to CSV file |
| `to_parquet` | Export DataFrame to Parquet file |
| `describe` | Get statistical summary of DataFrame |
| `head` | Get first N rows of DataFrame |
| `tail` | Get last N rows of DataFrame |
| `filter` | Filter DataFrame rows by condition |
| `select` | Select specific columns from DataFrame |
| `groupby` | Group DataFrame and aggregate |
| `join` | Join two DataFrames |
| `list_data` | List all stored DataFrames |
| `drop_data` | Remove a DataFrame from storage |
### PostgreSQL Tools (6 tools)
| Tool | Description |
|------|-------------|
| `pg_connect` | Test connection and return status |
| `pg_query` | Execute SELECT, return as data_ref |
| `pg_execute` | Execute INSERT/UPDATE/DELETE |
| `pg_tables` | List all tables in schema |
| `pg_columns` | Get column info for table |
| `pg_schemas` | List all schemas |
### PostGIS Tools (4 tools)
| Tool | Description |
|------|-------------|
| `st_tables` | List PostGIS-enabled tables |
| `st_geometry_type` | Get geometry type of column |
| `st_srid` | Get SRID of geometry column |
| `st_extent` | Get bounding box of geometries |
### dbt Tools (8 tools)
| Tool | Description |
|------|-------------|
| `dbt_parse` | Validate project (pre-execution) |
| `dbt_run` | Run models with selection |
| `dbt_test` | Run tests |
| `dbt_build` | Run + test |
| `dbt_compile` | Compile SQL without executing |
| `dbt_ls` | List resources |
| `dbt_docs_generate` | Generate documentation |
| `dbt_lineage` | Get model dependencies |
## data_ref System
All DataFrame operations use a `data_ref` system to persist data across tool calls:
1. **Load data**: Returns a `data_ref` string (e.g., `"df_a1b2c3d4"`)
2. **Use data_ref**: Pass to other tools (filter, join, export)
3. **List data**: Use `list_data` to see all stored DataFrames
4. **Clean up**: Use `drop_data` when done
### Example Flow
```
read_csv("data.csv") → {"data_ref": "sales_data", "rows": 1000}
filter("sales_data", "amount > 100") → {"data_ref": "sales_data_filtered"}
describe("sales_data_filtered") → {statistics}
to_parquet("sales_data_filtered", "output.parquet") → {success}
```
## Memory Management
- Default row limit: 100,000 rows per DataFrame
- Configure via `DATA_PLATFORM_MAX_ROWS` environment variable
- Use chunked processing for large files (`chunk_size` parameter)
- Monitor with `list_data` tool (shows memory usage)
## Running
```bash
python -m mcp_server.server
```
## Development
```bash
pip install -e ".[dev]"
pytest
```

View File

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

View File

@@ -0,0 +1,195 @@
"""
Configuration loader for Data Platform MCP Server.
Implements hybrid configuration system:
- System-level: ~/.config/claude/postgres.env (credentials)
- Project-level: .env (dbt project paths, overrides)
- Auto-detection: dbt_project.yml discovery
"""
from pathlib import Path
from dotenv import load_dotenv
import os
import logging
from typing import Dict, Optional
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class DataPlatformConfig:
"""Hybrid configuration loader for data platform tools"""
def __init__(self):
self.postgres_url: Optional[str] = None
self.dbt_project_dir: Optional[str] = None
self.dbt_profiles_dir: Optional[str] = None
self.max_rows: int = 100_000
def load(self) -> Dict[str, Optional[str]]:
"""
Load configuration from system and project levels.
Returns:
Dict containing postgres_url, dbt_project_dir, dbt_profiles_dir, max_rows
Note:
PostgreSQL credentials are optional - server can run in pandas-only mode.
"""
# Load system config (PostgreSQL credentials)
system_config = Path.home() / '.config' / 'claude' / 'postgres.env'
if system_config.exists():
load_dotenv(system_config)
logger.info(f"Loaded system configuration from {system_config}")
else:
logger.info(
f"System config not found: {system_config} - "
"PostgreSQL tools will be unavailable"
)
# Find project directory
project_dir = self._find_project_directory()
# Load project config (overrides system)
if project_dir:
project_config = project_dir / '.env'
if project_config.exists():
load_dotenv(project_config, override=True)
logger.info(f"Loaded project configuration from {project_config}")
# Extract values
self.postgres_url = os.getenv('POSTGRES_URL')
self.dbt_project_dir = os.getenv('DBT_PROJECT_DIR')
self.dbt_profiles_dir = os.getenv('DBT_PROFILES_DIR')
self.max_rows = int(os.getenv('DATA_PLATFORM_MAX_ROWS', '100000'))
# Auto-detect dbt project if not specified
if not self.dbt_project_dir and project_dir:
self.dbt_project_dir = self._find_dbt_project(project_dir)
if self.dbt_project_dir:
logger.info(f"Auto-detected dbt project: {self.dbt_project_dir}")
# Default dbt profiles dir to ~/.dbt
if not self.dbt_profiles_dir:
default_profiles = Path.home() / '.dbt'
if default_profiles.exists():
self.dbt_profiles_dir = str(default_profiles)
return {
'postgres_url': self.postgres_url,
'dbt_project_dir': self.dbt_project_dir,
'dbt_profiles_dir': self.dbt_profiles_dir,
'max_rows': self.max_rows,
'postgres_available': self.postgres_url is not None,
'dbt_available': self.dbt_project_dir is not None
}
def _find_project_directory(self) -> Optional[Path]:
"""
Find the user's project directory.
Returns:
Path to project directory, or None if not found
"""
# Strategy 1: Check CLAUDE_PROJECT_DIR environment variable
project_dir = os.getenv('CLAUDE_PROJECT_DIR')
if project_dir:
path = Path(project_dir)
if path.exists():
logger.info(f"Found project directory from CLAUDE_PROJECT_DIR: {path}")
return path
# Strategy 2: Check PWD
pwd = os.getenv('PWD')
if pwd:
path = Path(pwd)
if path.exists() and (
(path / '.git').exists() or
(path / '.env').exists() or
(path / 'dbt_project.yml').exists()
):
logger.info(f"Found project directory from PWD: {path}")
return path
# Strategy 3: Check current working directory
cwd = Path.cwd()
if (cwd / '.git').exists() or (cwd / '.env').exists() or (cwd / 'dbt_project.yml').exists():
logger.info(f"Found project directory from cwd: {cwd}")
return cwd
logger.debug("Could not determine project directory")
return None
def _find_dbt_project(self, start_dir: Path) -> Optional[str]:
"""
Find dbt_project.yml in the project or its subdirectories.
Args:
start_dir: Directory to start searching from
Returns:
Path to dbt project directory, or None if not found
"""
# Check root
if (start_dir / 'dbt_project.yml').exists():
return str(start_dir)
# Check common subdirectories
for subdir in ['dbt', 'transform', 'analytics', 'models']:
candidate = start_dir / subdir
if (candidate / 'dbt_project.yml').exists():
return str(candidate)
# Search one level deep
for item in start_dir.iterdir():
if item.is_dir() and not item.name.startswith('.'):
if (item / 'dbt_project.yml').exists():
return str(item)
return None
def load_config() -> Dict[str, Optional[str]]:
"""
Convenience function to load configuration.
Returns:
Configuration dictionary
"""
config = DataPlatformConfig()
return config.load()
def check_postgres_connection() -> Dict[str, any]:
"""
Check PostgreSQL connection status for SessionStart hook.
Returns:
Dict with connection status and message
"""
import asyncio
config = load_config()
if not config.get('postgres_url'):
return {
'connected': False,
'message': 'PostgreSQL not configured (POSTGRES_URL not set)'
}
async def test_connection():
try:
import asyncpg
conn = await asyncpg.connect(config['postgres_url'], timeout=5)
version = await conn.fetchval('SELECT version()')
await conn.close()
return {
'connected': True,
'message': f'Connected to PostgreSQL',
'version': version.split(',')[0] if version else 'Unknown'
}
except Exception as e:
return {
'connected': False,
'message': f'PostgreSQL connection failed: {str(e)}'
}
return asyncio.run(test_connection())

View File

@@ -0,0 +1,219 @@
"""
Arrow IPC DataFrame Registry.
Provides persistent storage for DataFrames across tool calls using Apache Arrow
for efficient memory management and serialization.
"""
import pyarrow as pa
import pandas as pd
import uuid
import logging
from typing import Dict, Optional, List, Union
from dataclasses import dataclass
from datetime import datetime
logger = logging.getLogger(__name__)
@dataclass
class DataFrameInfo:
"""Metadata about a stored DataFrame"""
ref: str
rows: int
columns: int
column_names: List[str]
dtypes: Dict[str, str]
memory_bytes: int
created_at: datetime
source: Optional[str] = None
class DataStore:
"""
Singleton registry for Arrow Tables (DataFrames).
Uses Arrow IPC format for efficient memory usage and supports
data_ref based retrieval across multiple tool calls.
"""
_instance = None
_dataframes: Dict[str, pa.Table] = {}
_metadata: Dict[str, DataFrameInfo] = {}
_max_rows: int = 100_000
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._dataframes = {}
cls._metadata = {}
return cls._instance
@classmethod
def get_instance(cls) -> 'DataStore':
"""Get the singleton instance"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
@classmethod
def set_max_rows(cls, max_rows: int):
"""Set the maximum rows limit"""
cls._max_rows = max_rows
def store(
self,
data: Union[pa.Table, pd.DataFrame],
name: Optional[str] = None,
source: Optional[str] = None
) -> str:
"""
Store a DataFrame and return its reference.
Args:
data: Arrow Table or pandas DataFrame
name: Optional name for the reference (auto-generated if not provided)
source: Optional source description (e.g., file path, query)
Returns:
data_ref string to retrieve the DataFrame later
"""
# Convert pandas to Arrow if needed
if isinstance(data, pd.DataFrame):
table = pa.Table.from_pandas(data)
else:
table = data
# Generate reference
data_ref = name or f"df_{uuid.uuid4().hex[:8]}"
# Ensure unique reference
if data_ref in self._dataframes and name is None:
data_ref = f"{data_ref}_{uuid.uuid4().hex[:4]}"
# Store table
self._dataframes[data_ref] = table
# Store metadata
schema = table.schema
self._metadata[data_ref] = DataFrameInfo(
ref=data_ref,
rows=table.num_rows,
columns=table.num_columns,
column_names=[f.name for f in schema],
dtypes={f.name: str(f.type) for f in schema},
memory_bytes=table.nbytes,
created_at=datetime.now(),
source=source
)
logger.info(f"Stored DataFrame '{data_ref}': {table.num_rows} rows, {table.num_columns} cols")
return data_ref
def get(self, data_ref: str) -> Optional[pa.Table]:
"""
Retrieve an Arrow Table by reference.
Args:
data_ref: Reference string from store()
Returns:
Arrow Table or None if not found
"""
return self._dataframes.get(data_ref)
def get_pandas(self, data_ref: str) -> Optional[pd.DataFrame]:
"""
Retrieve a DataFrame as pandas.
Args:
data_ref: Reference string from store()
Returns:
pandas DataFrame or None if not found
"""
table = self.get(data_ref)
if table is not None:
return table.to_pandas()
return None
def get_info(self, data_ref: str) -> Optional[DataFrameInfo]:
"""
Get metadata about a stored DataFrame.
Args:
data_ref: Reference string
Returns:
DataFrameInfo or None if not found
"""
return self._metadata.get(data_ref)
def list_refs(self) -> List[Dict]:
"""
List all stored DataFrame references with metadata.
Returns:
List of dicts with ref, rows, columns, memory info
"""
result = []
for ref, info in self._metadata.items():
result.append({
'ref': ref,
'rows': info.rows,
'columns': info.columns,
'column_names': info.column_names,
'memory_mb': round(info.memory_bytes / (1024 * 1024), 2),
'source': info.source,
'created_at': info.created_at.isoformat()
})
return result
def drop(self, data_ref: str) -> bool:
"""
Remove a DataFrame from the store.
Args:
data_ref: Reference string
Returns:
True if removed, False if not found
"""
if data_ref in self._dataframes:
del self._dataframes[data_ref]
del self._metadata[data_ref]
logger.info(f"Dropped DataFrame '{data_ref}'")
return True
return False
def clear(self):
"""Remove all stored DataFrames"""
count = len(self._dataframes)
self._dataframes.clear()
self._metadata.clear()
logger.info(f"Cleared {count} DataFrames from store")
def total_memory_bytes(self) -> int:
"""Get total memory used by all stored DataFrames"""
return sum(info.memory_bytes for info in self._metadata.values())
def total_memory_mb(self) -> float:
"""Get total memory in MB"""
return round(self.total_memory_bytes() / (1024 * 1024), 2)
def check_row_limit(self, row_count: int) -> Dict:
"""
Check if row count exceeds limit.
Args:
row_count: Number of rows
Returns:
Dict with 'exceeded' bool and 'message' if exceeded
"""
if row_count > self._max_rows:
return {
'exceeded': True,
'message': f"Row count ({row_count:,}) exceeds limit ({self._max_rows:,})",
'suggestion': f"Use chunked processing or filter data first",
'limit': self._max_rows
}
return {'exceeded': False}

View File

@@ -0,0 +1,387 @@
"""
dbt MCP Tools.
Provides dbt CLI wrapper with pre-execution validation.
"""
import subprocess
import json
import logging
import os
from pathlib import Path
from typing import Dict, List, Optional, Any
from .config import load_config
logger = logging.getLogger(__name__)
class DbtTools:
"""dbt CLI wrapper tools with pre-validation"""
def __init__(self):
self.config = load_config()
self.project_dir = self.config.get('dbt_project_dir')
self.profiles_dir = self.config.get('dbt_profiles_dir')
def _get_dbt_command(self, cmd: List[str]) -> List[str]:
"""Build dbt command with project and profiles directories"""
base = ['dbt']
if self.project_dir:
base.extend(['--project-dir', self.project_dir])
if self.profiles_dir:
base.extend(['--profiles-dir', self.profiles_dir])
base.extend(cmd)
return base
def _run_dbt(
self,
cmd: List[str],
timeout: int = 300,
capture_json: bool = False
) -> Dict:
"""
Run dbt command and return result.
Args:
cmd: dbt subcommand and arguments
timeout: Command timeout in seconds
capture_json: If True, parse JSON output
Returns:
Dict with command result
"""
if not self.project_dir:
return {
'error': 'dbt project not found',
'suggestion': 'Set DBT_PROJECT_DIR in project .env or ensure dbt_project.yml exists'
}
full_cmd = self._get_dbt_command(cmd)
logger.info(f"Running: {' '.join(full_cmd)}")
try:
env = os.environ.copy()
# Disable dbt analytics/tracking
env['DBT_SEND_ANONYMOUS_USAGE_STATS'] = 'false'
result = subprocess.run(
full_cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=self.project_dir,
env=env
)
output = {
'success': result.returncode == 0,
'command': ' '.join(cmd),
'stdout': result.stdout,
'stderr': result.stderr if result.returncode != 0 else None
}
if capture_json and result.returncode == 0:
try:
output['data'] = json.loads(result.stdout)
except json.JSONDecodeError:
pass
return output
except subprocess.TimeoutExpired:
return {
'error': f'Command timed out after {timeout}s',
'command': ' '.join(cmd)
}
except FileNotFoundError:
return {
'error': 'dbt not found in PATH',
'suggestion': 'Install dbt: pip install dbt-core dbt-postgres'
}
except Exception as e:
logger.error(f"dbt command failed: {e}")
return {'error': str(e)}
async def dbt_parse(self) -> Dict:
"""
Validate dbt project without executing (pre-flight check).
Returns:
Dict with validation result and any errors
"""
result = self._run_dbt(['parse'])
# Check if _run_dbt returned an error (e.g., project not found, timeout, dbt not installed)
if 'error' in result:
return result
if not result.get('success'):
# Extract useful error info from stderr
stderr = result.get('stderr', '') or result.get('stdout', '')
errors = []
# Look for common dbt 1.9+ deprecation warnings
if 'deprecated' in stderr.lower():
errors.append({
'type': 'deprecation',
'message': 'Deprecated syntax found - check dbt 1.9+ migration guide'
})
# Look for compilation errors
if 'compilation error' in stderr.lower():
errors.append({
'type': 'compilation',
'message': 'SQL compilation error - check model syntax'
})
return {
'valid': False,
'errors': errors,
'details': stderr[:2000] if stderr else None,
'suggestion': 'Fix issues before running dbt models'
}
return {
'valid': True,
'message': 'dbt project validation passed'
}
async def dbt_run(
self,
select: Optional[str] = None,
exclude: Optional[str] = None,
full_refresh: bool = False
) -> Dict:
"""
Run dbt models with pre-validation.
Args:
select: Model selection (e.g., "model_name", "+model_name", "tag:daily")
exclude: Models to exclude
full_refresh: If True, rebuild incremental models
Returns:
Dict with run result
"""
# ALWAYS validate first
parse_result = await self.dbt_parse()
if not parse_result.get('valid'):
return {
'error': 'Pre-validation failed',
**parse_result
}
cmd = ['run']
if select:
cmd.extend(['--select', select])
if exclude:
cmd.extend(['--exclude', exclude])
if full_refresh:
cmd.append('--full-refresh')
return self._run_dbt(cmd)
async def dbt_test(
self,
select: Optional[str] = None,
exclude: Optional[str] = None
) -> Dict:
"""
Run dbt tests.
Args:
select: Test selection
exclude: Tests to exclude
Returns:
Dict with test results
"""
cmd = ['test']
if select:
cmd.extend(['--select', select])
if exclude:
cmd.extend(['--exclude', exclude])
return self._run_dbt(cmd)
async def dbt_build(
self,
select: Optional[str] = None,
exclude: Optional[str] = None,
full_refresh: bool = False
) -> Dict:
"""
Run dbt build (run + test) with pre-validation.
Args:
select: Model/test selection
exclude: Resources to exclude
full_refresh: If True, rebuild incremental models
Returns:
Dict with build result
"""
# ALWAYS validate first
parse_result = await self.dbt_parse()
if not parse_result.get('valid'):
return {
'error': 'Pre-validation failed',
**parse_result
}
cmd = ['build']
if select:
cmd.extend(['--select', select])
if exclude:
cmd.extend(['--exclude', exclude])
if full_refresh:
cmd.append('--full-refresh')
return self._run_dbt(cmd)
async def dbt_compile(
self,
select: Optional[str] = None
) -> Dict:
"""
Compile dbt models to SQL without executing.
Args:
select: Model selection
Returns:
Dict with compiled SQL info
"""
cmd = ['compile']
if select:
cmd.extend(['--select', select])
return self._run_dbt(cmd)
async def dbt_ls(
self,
select: Optional[str] = None,
resource_type: Optional[str] = None,
output: str = 'name'
) -> Dict:
"""
List dbt resources.
Args:
select: Resource selection
resource_type: Filter by type (model, test, seed, snapshot, source)
output: Output format ('name', 'path', 'json')
Returns:
Dict with list of resources
"""
cmd = ['ls', '--output', output]
if select:
cmd.extend(['--select', select])
if resource_type:
cmd.extend(['--resource-type', resource_type])
result = self._run_dbt(cmd)
if result.get('success') and result.get('stdout'):
lines = [l.strip() for l in result['stdout'].split('\n') if l.strip()]
result['resources'] = lines
result['count'] = len(lines)
return result
async def dbt_docs_generate(self) -> Dict:
"""
Generate dbt documentation.
Returns:
Dict with generation result
"""
result = self._run_dbt(['docs', 'generate'])
if result.get('success') and self.project_dir:
# Check for generated catalog
catalog_path = Path(self.project_dir) / 'target' / 'catalog.json'
manifest_path = Path(self.project_dir) / 'target' / 'manifest.json'
result['catalog_generated'] = catalog_path.exists()
result['manifest_generated'] = manifest_path.exists()
return result
async def dbt_lineage(self, model: str) -> Dict:
"""
Get model dependencies and lineage.
Args:
model: Model name to analyze
Returns:
Dict with upstream and downstream dependencies
"""
if not self.project_dir:
return {'error': 'dbt project not found'}
manifest_path = Path(self.project_dir) / 'target' / 'manifest.json'
# Generate manifest if not exists
if not manifest_path.exists():
compile_result = await self.dbt_compile(select=model)
if not compile_result.get('success'):
return {
'error': 'Failed to compile manifest',
'details': compile_result
}
if not manifest_path.exists():
return {
'error': 'Manifest not found',
'suggestion': 'Run dbt compile first'
}
try:
with open(manifest_path) as f:
manifest = json.load(f)
# Find the model node
model_key = None
for key in manifest.get('nodes', {}):
if key.endswith(f'.{model}') or manifest['nodes'][key].get('name') == model:
model_key = key
break
if not model_key:
return {
'error': f'Model not found: {model}',
'available_models': [
n.get('name') for n in manifest.get('nodes', {}).values()
if n.get('resource_type') == 'model'
][:20]
}
node = manifest['nodes'][model_key]
# Get upstream (depends_on)
upstream = node.get('depends_on', {}).get('nodes', [])
# Get downstream (find nodes that depend on this one)
downstream = []
for key, other_node in manifest.get('nodes', {}).items():
deps = other_node.get('depends_on', {}).get('nodes', [])
if model_key in deps:
downstream.append(key)
return {
'model': model,
'unique_id': model_key,
'materialization': node.get('config', {}).get('materialized'),
'schema': node.get('schema'),
'database': node.get('database'),
'upstream': upstream,
'downstream': downstream,
'description': node.get('description'),
'tags': node.get('tags', [])
}
except Exception as e:
logger.error(f"dbt_lineage failed: {e}")
return {'error': str(e)}

View File

@@ -0,0 +1,500 @@
"""
pandas MCP Tools.
Provides DataFrame operations with Arrow IPC data_ref persistence.
"""
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import json
import logging
from pathlib import Path
from typing import Dict, List, Optional, Any, Union
from .data_store import DataStore
from .config import load_config
logger = logging.getLogger(__name__)
class PandasTools:
"""pandas data manipulation tools with data_ref persistence"""
def __init__(self):
self.store = DataStore.get_instance()
config = load_config()
self.max_rows = config.get('max_rows', 100_000)
self.store.set_max_rows(self.max_rows)
def _check_and_store(
self,
df: pd.DataFrame,
name: Optional[str] = None,
source: Optional[str] = None
) -> Dict:
"""Check row limit and store DataFrame if within limits"""
check = self.store.check_row_limit(len(df))
if check['exceeded']:
return {
'error': 'row_limit_exceeded',
**check,
'preview': df.head(100).to_dict(orient='records')
}
data_ref = self.store.store(df, name=name, source=source)
return {
'data_ref': data_ref,
'rows': len(df),
'columns': list(df.columns),
'dtypes': {col: str(dtype) for col, dtype in df.dtypes.items()}
}
async def read_csv(
self,
file_path: str,
name: Optional[str] = None,
chunk_size: Optional[int] = None,
**kwargs
) -> Dict:
"""
Load CSV file into DataFrame.
Args:
file_path: Path to CSV file
name: Optional name for data_ref
chunk_size: If provided, process in chunks
**kwargs: Additional pandas read_csv arguments
Returns:
Dict with data_ref or error info
"""
path = Path(file_path)
if not path.exists():
return {'error': f'File not found: {file_path}'}
try:
if chunk_size:
# Chunked processing - return iterator info
chunks = []
for i, chunk in enumerate(pd.read_csv(path, chunksize=chunk_size, **kwargs)):
chunk_ref = self.store.store(chunk, name=f"{name or 'chunk'}_{i}", source=file_path)
chunks.append({'ref': chunk_ref, 'rows': len(chunk)})
return {
'chunked': True,
'chunks': chunks,
'total_chunks': len(chunks)
}
df = pd.read_csv(path, **kwargs)
return self._check_and_store(df, name=name, source=file_path)
except Exception as e:
logger.error(f"read_csv failed: {e}")
return {'error': str(e)}
async def read_parquet(
self,
file_path: str,
name: Optional[str] = None,
columns: Optional[List[str]] = None
) -> Dict:
"""
Load Parquet file into DataFrame.
Args:
file_path: Path to Parquet file
name: Optional name for data_ref
columns: Optional list of columns to load
Returns:
Dict with data_ref or error info
"""
path = Path(file_path)
if not path.exists():
return {'error': f'File not found: {file_path}'}
try:
table = pq.read_table(path, columns=columns)
df = table.to_pandas()
return self._check_and_store(df, name=name, source=file_path)
except Exception as e:
logger.error(f"read_parquet failed: {e}")
return {'error': str(e)}
async def read_json(
self,
file_path: str,
name: Optional[str] = None,
lines: bool = False,
**kwargs
) -> Dict:
"""
Load JSON/JSONL file into DataFrame.
Args:
file_path: Path to JSON file
name: Optional name for data_ref
lines: If True, read as JSON Lines format
**kwargs: Additional pandas read_json arguments
Returns:
Dict with data_ref or error info
"""
path = Path(file_path)
if not path.exists():
return {'error': f'File not found: {file_path}'}
try:
df = pd.read_json(path, lines=lines, **kwargs)
return self._check_and_store(df, name=name, source=file_path)
except Exception as e:
logger.error(f"read_json failed: {e}")
return {'error': str(e)}
async def to_csv(
self,
data_ref: str,
file_path: str,
index: bool = False,
**kwargs
) -> Dict:
"""
Export DataFrame to CSV file.
Args:
data_ref: Reference to stored DataFrame
file_path: Output file path
index: Whether to include index
**kwargs: Additional pandas to_csv arguments
Returns:
Dict with success status
"""
df = self.store.get_pandas(data_ref)
if df is None:
return {'error': f'DataFrame not found: {data_ref}'}
try:
df.to_csv(file_path, index=index, **kwargs)
return {
'success': True,
'file_path': file_path,
'rows': len(df),
'size_bytes': Path(file_path).stat().st_size
}
except Exception as e:
logger.error(f"to_csv failed: {e}")
return {'error': str(e)}
async def to_parquet(
self,
data_ref: str,
file_path: str,
compression: str = 'snappy'
) -> Dict:
"""
Export DataFrame to Parquet file.
Args:
data_ref: Reference to stored DataFrame
file_path: Output file path
compression: Compression codec
Returns:
Dict with success status
"""
table = self.store.get(data_ref)
if table is None:
return {'error': f'DataFrame not found: {data_ref}'}
try:
pq.write_table(table, file_path, compression=compression)
return {
'success': True,
'file_path': file_path,
'rows': table.num_rows,
'size_bytes': Path(file_path).stat().st_size
}
except Exception as e:
logger.error(f"to_parquet failed: {e}")
return {'error': str(e)}
async def describe(self, data_ref: str) -> Dict:
"""
Get statistical summary of DataFrame.
Args:
data_ref: Reference to stored DataFrame
Returns:
Dict with statistical summary
"""
df = self.store.get_pandas(data_ref)
if df is None:
return {'error': f'DataFrame not found: {data_ref}'}
try:
desc = df.describe(include='all')
info = self.store.get_info(data_ref)
return {
'data_ref': data_ref,
'shape': {'rows': len(df), 'columns': len(df.columns)},
'columns': list(df.columns),
'dtypes': {col: str(dtype) for col, dtype in df.dtypes.items()},
'memory_mb': info.memory_bytes / (1024 * 1024) if info else None,
'null_counts': df.isnull().sum().to_dict(),
'statistics': desc.to_dict()
}
except Exception as e:
logger.error(f"describe failed: {e}")
return {'error': str(e)}
async def head(self, data_ref: str, n: int = 10) -> Dict:
"""
Get first N rows of DataFrame.
Args:
data_ref: Reference to stored DataFrame
n: Number of rows
Returns:
Dict with rows as records
"""
df = self.store.get_pandas(data_ref)
if df is None:
return {'error': f'DataFrame not found: {data_ref}'}
try:
head_df = df.head(n)
return {
'data_ref': data_ref,
'total_rows': len(df),
'returned_rows': len(head_df),
'columns': list(df.columns),
'data': head_df.to_dict(orient='records')
}
except Exception as e:
logger.error(f"head failed: {e}")
return {'error': str(e)}
async def tail(self, data_ref: str, n: int = 10) -> Dict:
"""
Get last N rows of DataFrame.
Args:
data_ref: Reference to stored DataFrame
n: Number of rows
Returns:
Dict with rows as records
"""
df = self.store.get_pandas(data_ref)
if df is None:
return {'error': f'DataFrame not found: {data_ref}'}
try:
tail_df = df.tail(n)
return {
'data_ref': data_ref,
'total_rows': len(df),
'returned_rows': len(tail_df),
'columns': list(df.columns),
'data': tail_df.to_dict(orient='records')
}
except Exception as e:
logger.error(f"tail failed: {e}")
return {'error': str(e)}
async def filter(
self,
data_ref: str,
condition: str,
name: Optional[str] = None
) -> Dict:
"""
Filter DataFrame rows by condition.
Args:
data_ref: Reference to stored DataFrame
condition: pandas query string (e.g., "age > 30 and city == 'NYC'")
name: Optional name for result data_ref
Returns:
Dict with new data_ref for filtered result
"""
df = self.store.get_pandas(data_ref)
if df is None:
return {'error': f'DataFrame not found: {data_ref}'}
try:
filtered = df.query(condition)
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

@@ -0,0 +1,538 @@
"""
PostgreSQL/PostGIS MCP Tools.
Provides database operations with connection pooling and PostGIS support.
"""
import asyncio
import logging
from typing import Dict, List, Optional, Any
import json
from .data_store import DataStore
from .config import load_config
logger = logging.getLogger(__name__)
# Optional imports - gracefully handle missing dependencies
try:
import asyncpg
ASYNCPG_AVAILABLE = True
except ImportError:
ASYNCPG_AVAILABLE = False
logger.warning("asyncpg not available - PostgreSQL tools will be disabled")
try:
import pandas as pd
PANDAS_AVAILABLE = True
except ImportError:
PANDAS_AVAILABLE = False
class PostgresTools:
"""PostgreSQL/PostGIS database tools"""
def __init__(self):
self.store = DataStore.get_instance()
self.config = load_config()
self.pool: Optional[Any] = None
self.max_rows = self.config.get('max_rows', 100_000)
async def _get_pool(self):
"""Get or create connection pool"""
if not ASYNCPG_AVAILABLE:
raise RuntimeError("asyncpg not installed - run: pip install asyncpg")
if self.pool is None:
postgres_url = self.config.get('postgres_url')
if not postgres_url:
raise RuntimeError(
"PostgreSQL not configured. Set POSTGRES_URL in "
"~/.config/claude/postgres.env"
)
self.pool = await asyncpg.create_pool(postgres_url, min_size=1, max_size=5)
return self.pool
async def pg_connect(self) -> Dict:
"""
Test PostgreSQL connection and return status.
Returns:
Dict with connection status, version, and database info
"""
if not ASYNCPG_AVAILABLE:
return {
'connected': False,
'error': 'asyncpg not installed',
'suggestion': 'pip install asyncpg'
}
postgres_url = self.config.get('postgres_url')
if not postgres_url:
return {
'connected': False,
'error': 'POSTGRES_URL not configured',
'suggestion': 'Create ~/.config/claude/postgres.env with POSTGRES_URL=postgresql://...'
}
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
version = await conn.fetchval('SELECT version()')
db_name = await conn.fetchval('SELECT current_database()')
user = await conn.fetchval('SELECT current_user')
# Check for PostGIS
postgis_version = None
try:
postgis_version = await conn.fetchval('SELECT PostGIS_Version()')
except Exception:
pass
return {
'connected': True,
'database': db_name,
'user': user,
'version': version.split(',')[0] if version else 'Unknown',
'postgis_version': postgis_version,
'postgis_available': postgis_version is not None
}
except Exception as e:
logger.error(f"pg_connect failed: {e}")
return {
'connected': False,
'error': str(e)
}
async def pg_query(
self,
query: str,
params: Optional[List] = None,
name: Optional[str] = None
) -> Dict:
"""
Execute SELECT query and return results as data_ref.
Args:
query: SQL SELECT query
params: Query parameters (positional, use $1, $2, etc.)
name: Optional name for result data_ref
Returns:
Dict with data_ref for results or error
"""
if not PANDAS_AVAILABLE:
return {'error': 'pandas not available'}
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
if params:
rows = await conn.fetch(query, *params)
else:
rows = await conn.fetch(query)
if not rows:
return {
'data_ref': None,
'rows': 0,
'message': 'Query returned no results'
}
# Convert to DataFrame
df = pd.DataFrame([dict(r) for r in rows])
# Check row limit
check = self.store.check_row_limit(len(df))
if check['exceeded']:
return {
'error': 'row_limit_exceeded',
**check,
'preview': df.head(100).to_dict(orient='records')
}
# Store result
data_ref = self.store.store(df, name=name, source=f"pg_query: {query[:100]}...")
return {
'data_ref': data_ref,
'rows': len(df),
'columns': list(df.columns)
}
except Exception as e:
logger.error(f"pg_query failed: {e}")
return {'error': str(e)}
async def pg_execute(
self,
query: str,
params: Optional[List] = None
) -> Dict:
"""
Execute INSERT/UPDATE/DELETE query.
Args:
query: SQL DML query
params: Query parameters
Returns:
Dict with affected rows count
"""
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
if params:
result = await conn.execute(query, *params)
else:
result = await conn.execute(query)
# Parse result (e.g., "INSERT 0 1" or "UPDATE 5")
parts = result.split()
affected = int(parts[-1]) if parts else 0
return {
'success': True,
'command': parts[0] if parts else 'UNKNOWN',
'affected_rows': affected
}
except Exception as e:
logger.error(f"pg_execute failed: {e}")
return {'error': str(e)}
async def pg_tables(self, schema: str = 'public') -> Dict:
"""
List all tables in schema.
Args:
schema: Schema name (default: public)
Returns:
Dict with list of tables
"""
query = """
SELECT
table_name,
table_type,
(SELECT count(*) FROM information_schema.columns c
WHERE c.table_schema = t.table_schema
AND c.table_name = t.table_name) as column_count
FROM information_schema.tables t
WHERE table_schema = $1
ORDER BY table_name
"""
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(query, schema)
tables = [
{
'name': r['table_name'],
'type': r['table_type'],
'columns': r['column_count']
}
for r in rows
]
return {
'schema': schema,
'count': len(tables),
'tables': tables
}
except Exception as e:
logger.error(f"pg_tables failed: {e}")
return {'error': str(e)}
async def pg_columns(self, table: str, schema: str = 'public') -> Dict:
"""
Get column information for a table.
Args:
table: Table name
schema: Schema name (default: public)
Returns:
Dict with column details
"""
query = """
SELECT
column_name,
data_type,
udt_name,
is_nullable,
column_default,
character_maximum_length,
numeric_precision
FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2
ORDER BY ordinal_position
"""
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(query, schema, table)
columns = [
{
'name': r['column_name'],
'type': r['data_type'],
'udt': r['udt_name'],
'nullable': r['is_nullable'] == 'YES',
'default': r['column_default'],
'max_length': r['character_maximum_length'],
'precision': r['numeric_precision']
}
for r in rows
]
return {
'table': f'{schema}.{table}',
'column_count': len(columns),
'columns': columns
}
except Exception as e:
logger.error(f"pg_columns failed: {e}")
return {'error': str(e)}
async def pg_schemas(self) -> Dict:
"""
List all schemas in database.
Returns:
Dict with list of schemas
"""
query = """
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
ORDER BY schema_name
"""
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(query)
schemas = [r['schema_name'] for r in rows]
return {
'count': len(schemas),
'schemas': schemas
}
except Exception as e:
logger.error(f"pg_schemas failed: {e}")
return {'error': str(e)}
async def st_tables(self, schema: str = 'public') -> Dict:
"""
List PostGIS-enabled tables.
Args:
schema: Schema name (default: public)
Returns:
Dict with list of tables with geometry columns
"""
query = """
SELECT
f_table_name as table_name,
f_geometry_column as geometry_column,
type as geometry_type,
srid,
coord_dimension
FROM geometry_columns
WHERE f_table_schema = $1
ORDER BY f_table_name
"""
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(query, schema)
tables = [
{
'table': r['table_name'],
'geometry_column': r['geometry_column'],
'geometry_type': r['geometry_type'],
'srid': r['srid'],
'dimensions': r['coord_dimension']
}
for r in rows
]
return {
'schema': schema,
'count': len(tables),
'postgis_tables': tables
}
except Exception as e:
if 'geometry_columns' in str(e):
return {
'error': 'PostGIS not installed or extension not enabled',
'suggestion': 'Run: CREATE EXTENSION IF NOT EXISTS postgis;'
}
logger.error(f"st_tables failed: {e}")
return {'error': str(e)}
async def st_geometry_type(self, table: str, column: str, schema: str = 'public') -> Dict:
"""
Get geometry type of a column.
Args:
table: Table name
column: Geometry column name
schema: Schema name
Returns:
Dict with geometry type information
"""
query = f"""
SELECT DISTINCT ST_GeometryType({column}) as geom_type
FROM {schema}.{table}
WHERE {column} IS NOT NULL
LIMIT 10
"""
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(query)
types = [r['geom_type'] for r in rows]
return {
'table': f'{schema}.{table}',
'column': column,
'geometry_types': types
}
except Exception as e:
logger.error(f"st_geometry_type failed: {e}")
return {'error': str(e)}
async def st_srid(self, table: str, column: str, schema: str = 'public') -> Dict:
"""
Get SRID of geometry column.
Args:
table: Table name
column: Geometry column name
schema: Schema name
Returns:
Dict with SRID information
"""
query = f"""
SELECT DISTINCT ST_SRID({column}) as srid
FROM {schema}.{table}
WHERE {column} IS NOT NULL
LIMIT 1
"""
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(query)
srid = row['srid'] if row else None
# Get SRID description
srid_info = None
if srid:
srid_query = """
SELECT srtext, proj4text
FROM spatial_ref_sys
WHERE srid = $1
"""
srid_row = await conn.fetchrow(srid_query, srid)
if srid_row:
srid_info = {
'description': srid_row['srtext'][:200] if srid_row['srtext'] else None,
'proj4': srid_row['proj4text']
}
return {
'table': f'{schema}.{table}',
'column': column,
'srid': srid,
'info': srid_info
}
except Exception as e:
logger.error(f"st_srid failed: {e}")
return {'error': str(e)}
async def st_extent(self, table: str, column: str, schema: str = 'public') -> Dict:
"""
Get bounding box of all geometries.
Args:
table: Table name
column: Geometry column name
schema: Schema name
Returns:
Dict with bounding box coordinates
"""
query = f"""
SELECT
ST_XMin(extent) as xmin,
ST_YMin(extent) as ymin,
ST_XMax(extent) as xmax,
ST_YMax(extent) as ymax
FROM (
SELECT ST_Extent({column}) as extent
FROM {schema}.{table}
) sub
"""
try:
pool = await self._get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(query)
if row and row['xmin'] is not None:
return {
'table': f'{schema}.{table}',
'column': column,
'bbox': {
'xmin': float(row['xmin']),
'ymin': float(row['ymin']),
'xmax': float(row['xmax']),
'ymax': float(row['ymax'])
}
}
return {
'table': f'{schema}.{table}',
'column': column,
'bbox': None,
'message': 'No geometries found or all NULL'
}
except Exception as e:
logger.error(f"st_extent failed: {e}")
return {'error': str(e)}
async def close(self):
"""Close connection pool"""
if self.pool:
await self.pool.close()
self.pool = None
def check_connection() -> None:
"""
Check PostgreSQL connection for SessionStart hook.
Prints warning to stderr if connection fails.
"""
import sys
config = load_config()
if not config.get('postgres_url'):
print(
"[data-platform] PostgreSQL not configured (POSTGRES_URL not set)",
file=sys.stderr
)
return
async def test():
try:
if not ASYNCPG_AVAILABLE:
print(
"[data-platform] asyncpg not installed - PostgreSQL tools unavailable",
file=sys.stderr
)
return
conn = await asyncpg.connect(config['postgres_url'], timeout=5)
await conn.close()
print("[data-platform] PostgreSQL connection OK", file=sys.stderr)
except Exception as e:
print(
f"[data-platform] PostgreSQL connection failed: {e}",
file=sys.stderr
)
asyncio.run(test())

View File

@@ -0,0 +1,795 @@
"""
MCP Server entry point for Data Platform integration.
Provides pandas, PostgreSQL/PostGIS, and dbt tools to Claude Code via JSON-RPC 2.0 over stdio.
"""
import asyncio
import logging
import json
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
from .config import DataPlatformConfig
from .data_store import DataStore
from .pandas_tools import PandasTools
from .postgres_tools import PostgresTools
from .dbt_tools import DbtTools
# Suppress noisy MCP validation warnings on stderr
logging.basicConfig(level=logging.INFO)
logging.getLogger("root").setLevel(logging.ERROR)
logging.getLogger("mcp").setLevel(logging.ERROR)
logger = logging.getLogger(__name__)
class DataPlatformMCPServer:
"""MCP Server for data platform integration"""
def __init__(self):
self.server = Server("data-platform-mcp")
self.config = None
self.pandas_tools = None
self.postgres_tools = None
self.dbt_tools = None
async def initialize(self):
"""Initialize server and load configuration."""
try:
config_loader = DataPlatformConfig()
self.config = config_loader.load()
self.pandas_tools = PandasTools()
self.postgres_tools = PostgresTools()
self.dbt_tools = DbtTools()
# Log available capabilities
caps = []
caps.append("pandas")
if self.config.get('postgres_available'):
caps.append("PostgreSQL")
if self.config.get('dbt_available'):
caps.append("dbt")
logger.info(f"Data Platform MCP Server initialized with: {', '.join(caps)}")
except Exception as e:
logger.error(f"Failed to initialize: {e}")
raise
def setup_tools(self):
"""Register all available tools with the MCP server"""
@self.server.list_tools()
async def list_tools() -> list[Tool]:
"""Return list of available tools"""
tools = [
# pandas tools - always available
Tool(
name="read_csv",
description="Load CSV file into DataFrame",
inputSchema={
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to CSV file"
},
"name": {
"type": "string",
"description": "Optional name for data_ref"
},
"chunk_size": {
"type": "integer",
"description": "Process in chunks of this size"
}
},
"required": ["file_path"]
}
),
Tool(
name="read_parquet",
description="Load Parquet file into DataFrame",
inputSchema={
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to Parquet file"
},
"name": {
"type": "string",
"description": "Optional name for data_ref"
},
"columns": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of columns to load"
}
},
"required": ["file_path"]
}
),
Tool(
name="read_json",
description="Load JSON/JSONL file into DataFrame",
inputSchema={
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to JSON file"
},
"name": {
"type": "string",
"description": "Optional name for data_ref"
},
"lines": {
"type": "boolean",
"default": False,
"description": "Read as JSON Lines format"
}
},
"required": ["file_path"]
}
),
Tool(
name="to_csv",
description="Export DataFrame to CSV file",
inputSchema={
"type": "object",
"properties": {
"data_ref": {
"type": "string",
"description": "Reference to stored DataFrame"
},
"file_path": {
"type": "string",
"description": "Output file path"
},
"index": {
"type": "boolean",
"default": False,
"description": "Include index column"
}
},
"required": ["data_ref", "file_path"]
}
),
Tool(
name="to_parquet",
description="Export DataFrame to Parquet file",
inputSchema={
"type": "object",
"properties": {
"data_ref": {
"type": "string",
"description": "Reference to stored DataFrame"
},
"file_path": {
"type": "string",
"description": "Output file path"
},
"compression": {
"type": "string",
"default": "snappy",
"description": "Compression codec"
}
},
"required": ["data_ref", "file_path"]
}
),
Tool(
name="describe",
description="Get statistical summary of DataFrame",
inputSchema={
"type": "object",
"properties": {
"data_ref": {
"type": "string",
"description": "Reference to stored DataFrame"
}
},
"required": ["data_ref"]
}
),
Tool(
name="head",
description="Get first N rows of DataFrame",
inputSchema={
"type": "object",
"properties": {
"data_ref": {
"type": "string",
"description": "Reference to stored DataFrame"
},
"n": {
"type": "integer",
"default": 10,
"description": "Number of rows"
}
},
"required": ["data_ref"]
}
),
Tool(
name="tail",
description="Get last N rows of DataFrame",
inputSchema={
"type": "object",
"properties": {
"data_ref": {
"type": "string",
"description": "Reference to stored DataFrame"
},
"n": {
"type": "integer",
"default": 10,
"description": "Number of rows"
}
},
"required": ["data_ref"]
}
),
Tool(
name="filter",
description="Filter DataFrame rows by condition",
inputSchema={
"type": "object",
"properties": {
"data_ref": {
"type": "string",
"description": "Reference to stored DataFrame"
},
"condition": {
"type": "string",
"description": "pandas query string (e.g., 'age > 30 and city == \"NYC\"')"
},
"name": {
"type": "string",
"description": "Optional name for result data_ref"
}
},
"required": ["data_ref", "condition"]
}
),
Tool(
name="select",
description="Select specific columns from DataFrame",
inputSchema={
"type": "object",
"properties": {
"data_ref": {
"type": "string",
"description": "Reference to stored DataFrame"
},
"columns": {
"type": "array",
"items": {"type": "string"},
"description": "List of column names to select"
},
"name": {
"type": "string",
"description": "Optional name for result data_ref"
}
},
"required": ["data_ref", "columns"]
}
),
Tool(
name="groupby",
description="Group DataFrame and aggregate",
inputSchema={
"type": "object",
"properties": {
"data_ref": {
"type": "string",
"description": "Reference to stored DataFrame"
},
"by": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
],
"description": "Column(s) to group by"
},
"agg": {
"type": "object",
"description": "Aggregation dict (e.g., {\"sales\": \"sum\", \"count\": \"mean\"})"
},
"name": {
"type": "string",
"description": "Optional name for result data_ref"
}
},
"required": ["data_ref", "by", "agg"]
}
),
Tool(
name="join",
description="Join two DataFrames",
inputSchema={
"type": "object",
"properties": {
"left_ref": {
"type": "string",
"description": "Reference to left DataFrame"
},
"right_ref": {
"type": "string",
"description": "Reference to right DataFrame"
},
"on": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
],
"description": "Column(s) to join on (if same name in both)"
},
"left_on": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
],
"description": "Left join column(s)"
},
"right_on": {
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
],
"description": "Right join column(s)"
},
"how": {
"type": "string",
"enum": ["inner", "left", "right", "outer"],
"default": "inner",
"description": "Join type"
},
"name": {
"type": "string",
"description": "Optional name for result data_ref"
}
},
"required": ["left_ref", "right_ref"]
}
),
Tool(
name="list_data",
description="List all stored DataFrames",
inputSchema={
"type": "object",
"properties": {}
}
),
Tool(
name="drop_data",
description="Remove a DataFrame from storage",
inputSchema={
"type": "object",
"properties": {
"data_ref": {
"type": "string",
"description": "Reference to drop"
}
},
"required": ["data_ref"]
}
),
# PostgreSQL tools
Tool(
name="pg_connect",
description="Test PostgreSQL connection and return status",
inputSchema={
"type": "object",
"properties": {}
}
),
Tool(
name="pg_query",
description="Execute SELECT query and return results as data_ref",
inputSchema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "SQL SELECT query"
},
"params": {
"type": "array",
"items": {},
"description": "Query parameters (use $1, $2, etc.)"
},
"name": {
"type": "string",
"description": "Optional name for result data_ref"
}
},
"required": ["query"]
}
),
Tool(
name="pg_execute",
description="Execute INSERT/UPDATE/DELETE query",
inputSchema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "SQL DML query"
},
"params": {
"type": "array",
"items": {},
"description": "Query parameters"
}
},
"required": ["query"]
}
),
Tool(
name="pg_tables",
description="List all tables in schema",
inputSchema={
"type": "object",
"properties": {
"schema": {
"type": "string",
"default": "public",
"description": "Schema name"
}
}
}
),
Tool(
name="pg_columns",
description="Get column information for a table",
inputSchema={
"type": "object",
"properties": {
"table": {
"type": "string",
"description": "Table name"
},
"schema": {
"type": "string",
"default": "public",
"description": "Schema name"
}
},
"required": ["table"]
}
),
Tool(
name="pg_schemas",
description="List all schemas in database",
inputSchema={
"type": "object",
"properties": {}
}
),
# PostGIS tools
Tool(
name="st_tables",
description="List PostGIS-enabled tables",
inputSchema={
"type": "object",
"properties": {
"schema": {
"type": "string",
"default": "public",
"description": "Schema name"
}
}
}
),
Tool(
name="st_geometry_type",
description="Get geometry type of a column",
inputSchema={
"type": "object",
"properties": {
"table": {
"type": "string",
"description": "Table name"
},
"column": {
"type": "string",
"description": "Geometry column name"
},
"schema": {
"type": "string",
"default": "public",
"description": "Schema name"
}
},
"required": ["table", "column"]
}
),
Tool(
name="st_srid",
description="Get SRID of geometry column",
inputSchema={
"type": "object",
"properties": {
"table": {
"type": "string",
"description": "Table name"
},
"column": {
"type": "string",
"description": "Geometry column name"
},
"schema": {
"type": "string",
"default": "public",
"description": "Schema name"
}
},
"required": ["table", "column"]
}
),
Tool(
name="st_extent",
description="Get bounding box of all geometries",
inputSchema={
"type": "object",
"properties": {
"table": {
"type": "string",
"description": "Table name"
},
"column": {
"type": "string",
"description": "Geometry column name"
},
"schema": {
"type": "string",
"default": "public",
"description": "Schema name"
}
},
"required": ["table", "column"]
}
),
# dbt tools
Tool(
name="dbt_parse",
description="Validate dbt project (pre-flight check)",
inputSchema={
"type": "object",
"properties": {}
}
),
Tool(
name="dbt_run",
description="Run dbt models with pre-validation",
inputSchema={
"type": "object",
"properties": {
"select": {
"type": "string",
"description": "Model selection (e.g., 'model_name', '+model_name', 'tag:daily')"
},
"exclude": {
"type": "string",
"description": "Models to exclude"
},
"full_refresh": {
"type": "boolean",
"default": False,
"description": "Rebuild incremental models"
}
}
}
),
Tool(
name="dbt_test",
description="Run dbt tests",
inputSchema={
"type": "object",
"properties": {
"select": {
"type": "string",
"description": "Test selection"
},
"exclude": {
"type": "string",
"description": "Tests to exclude"
}
}
}
),
Tool(
name="dbt_build",
description="Run dbt build (run + test) with pre-validation",
inputSchema={
"type": "object",
"properties": {
"select": {
"type": "string",
"description": "Model/test selection"
},
"exclude": {
"type": "string",
"description": "Resources to exclude"
},
"full_refresh": {
"type": "boolean",
"default": False,
"description": "Rebuild incremental models"
}
}
}
),
Tool(
name="dbt_compile",
description="Compile dbt models to SQL without executing",
inputSchema={
"type": "object",
"properties": {
"select": {
"type": "string",
"description": "Model selection"
}
}
}
),
Tool(
name="dbt_ls",
description="List dbt resources",
inputSchema={
"type": "object",
"properties": {
"select": {
"type": "string",
"description": "Resource selection"
},
"resource_type": {
"type": "string",
"enum": ["model", "test", "seed", "snapshot", "source"],
"description": "Filter by type"
},
"output": {
"type": "string",
"enum": ["name", "path", "json"],
"default": "name",
"description": "Output format"
}
}
}
),
Tool(
name="dbt_docs_generate",
description="Generate dbt documentation",
inputSchema={
"type": "object",
"properties": {}
}
),
Tool(
name="dbt_lineage",
description="Get model dependencies and lineage",
inputSchema={
"type": "object",
"properties": {
"model": {
"type": "string",
"description": "Model name to analyze"
}
},
"required": ["model"]
}
)
]
return tools
@self.server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
"""Handle tool invocation."""
try:
# Route to appropriate tool handler
# pandas tools
if name == "read_csv":
result = await self.pandas_tools.read_csv(**arguments)
elif name == "read_parquet":
result = await self.pandas_tools.read_parquet(**arguments)
elif name == "read_json":
result = await self.pandas_tools.read_json(**arguments)
elif name == "to_csv":
result = await self.pandas_tools.to_csv(**arguments)
elif name == "to_parquet":
result = await self.pandas_tools.to_parquet(**arguments)
elif name == "describe":
result = await self.pandas_tools.describe(**arguments)
elif name == "head":
result = await self.pandas_tools.head(**arguments)
elif name == "tail":
result = await self.pandas_tools.tail(**arguments)
elif name == "filter":
result = await self.pandas_tools.filter(**arguments)
elif name == "select":
result = await self.pandas_tools.select(**arguments)
elif name == "groupby":
result = await self.pandas_tools.groupby(**arguments)
elif name == "join":
result = await self.pandas_tools.join(**arguments)
elif name == "list_data":
result = await self.pandas_tools.list_data()
elif name == "drop_data":
result = await self.pandas_tools.drop_data(**arguments)
# PostgreSQL tools
elif name == "pg_connect":
result = await self.postgres_tools.pg_connect()
elif name == "pg_query":
result = await self.postgres_tools.pg_query(**arguments)
elif name == "pg_execute":
result = await self.postgres_tools.pg_execute(**arguments)
elif name == "pg_tables":
result = await self.postgres_tools.pg_tables(**arguments)
elif name == "pg_columns":
result = await self.postgres_tools.pg_columns(**arguments)
elif name == "pg_schemas":
result = await self.postgres_tools.pg_schemas()
# PostGIS tools
elif name == "st_tables":
result = await self.postgres_tools.st_tables(**arguments)
elif name == "st_geometry_type":
result = await self.postgres_tools.st_geometry_type(**arguments)
elif name == "st_srid":
result = await self.postgres_tools.st_srid(**arguments)
elif name == "st_extent":
result = await self.postgres_tools.st_extent(**arguments)
# dbt tools
elif name == "dbt_parse":
result = await self.dbt_tools.dbt_parse()
elif name == "dbt_run":
result = await self.dbt_tools.dbt_run(**arguments)
elif name == "dbt_test":
result = await self.dbt_tools.dbt_test(**arguments)
elif name == "dbt_build":
result = await self.dbt_tools.dbt_build(**arguments)
elif name == "dbt_compile":
result = await self.dbt_tools.dbt_compile(**arguments)
elif name == "dbt_ls":
result = await self.dbt_tools.dbt_ls(**arguments)
elif name == "dbt_docs_generate":
result = await self.dbt_tools.dbt_docs_generate()
elif name == "dbt_lineage":
result = await self.dbt_tools.dbt_lineage(**arguments)
else:
raise ValueError(f"Unknown tool: {name}")
return [TextContent(
type="text",
text=json.dumps(result, indent=2, default=str)
)]
except Exception as e:
logger.error(f"Tool {name} failed: {e}")
return [TextContent(
type="text",
text=json.dumps({"error": str(e)}, indent=2)
)]
async def run(self):
"""Run the MCP server"""
await self.initialize()
self.setup_tools()
async with stdio_server() as (read_stream, write_stream):
await self.server.run(
read_stream,
write_stream,
self.server.create_initialization_options()
)
async def main():
"""Main entry point"""
server = DataPlatformMCPServer()
await server.run()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,49 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "data-platform-mcp"
version = "1.0.0"
description = "MCP Server for data engineering with pandas, PostgreSQL/PostGIS, and dbt"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
{name = "Leo Miranda"}
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"mcp>=0.9.0",
"pandas>=2.0.0",
"pyarrow>=14.0.0",
"asyncpg>=0.29.0",
"geoalchemy2>=0.14.0",
"shapely>=2.0.0",
"dbt-core>=1.9.0",
"dbt-postgres>=1.9.0",
"python-dotenv>=1.0.0",
"pydantic>=2.5.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.3",
"pytest-asyncio>=0.23.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["mcp_server*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

View File

@@ -0,0 +1,23 @@
# MCP SDK
mcp>=0.9.0
# Data Processing
pandas>=2.0.0
pyarrow>=14.0.0
# PostgreSQL/PostGIS
asyncpg>=0.29.0
geoalchemy2>=0.14.0
shapely>=2.0.0
# dbt
dbt-core>=1.9.0
dbt-postgres>=1.9.0
# Utilities
python-dotenv>=1.0.0
pydantic>=2.5.0
# Testing
pytest>=7.4.3
pytest-asyncio>=0.23.0

View File

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

View File

@@ -0,0 +1,239 @@
"""
Unit tests for configuration loader.
"""
import pytest
from pathlib import Path
import os
def test_load_system_config(tmp_path, monkeypatch):
"""Test loading system-level PostgreSQL configuration"""
# Import here to avoid import errors before setup
from mcp_server.config import DataPlatformConfig
# Mock home directory
config_dir = tmp_path / '.config' / 'claude'
config_dir.mkdir(parents=True)
config_file = config_dir / 'postgres.env'
config_file.write_text(
"POSTGRES_URL=postgresql://user:pass@localhost:5432/testdb\n"
)
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(tmp_path)
config = DataPlatformConfig()
result = config.load()
assert result['postgres_url'] == 'postgresql://user:pass@localhost:5432/testdb'
assert result['postgres_available'] is True
def test_postgres_optional(tmp_path, monkeypatch):
"""Test that PostgreSQL configuration is optional"""
from mcp_server.config import DataPlatformConfig
# No postgres.env file
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(tmp_path)
# Clear any existing env vars
monkeypatch.delenv('POSTGRES_URL', raising=False)
config = DataPlatformConfig()
result = config.load()
assert result['postgres_url'] is None
assert result['postgres_available'] is False
def test_project_config_override(tmp_path, monkeypatch):
"""Test that project config overrides system config"""
from mcp_server.config import DataPlatformConfig
# Set up system config
system_config_dir = tmp_path / '.config' / 'claude'
system_config_dir.mkdir(parents=True)
system_config = system_config_dir / 'postgres.env'
system_config.write_text(
"POSTGRES_URL=postgresql://system:pass@localhost:5432/systemdb\n"
)
# Set up project config
project_dir = tmp_path / 'project'
project_dir.mkdir()
project_config = project_dir / '.env'
project_config.write_text(
"POSTGRES_URL=postgresql://project:pass@localhost:5432/projectdb\n"
"DBT_PROJECT_DIR=/path/to/dbt\n"
)
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(project_dir)
config = DataPlatformConfig()
result = config.load()
# Project config should override
assert result['postgres_url'] == 'postgresql://project:pass@localhost:5432/projectdb'
assert result['dbt_project_dir'] == '/path/to/dbt'
def test_max_rows_config(tmp_path, monkeypatch):
"""Test max rows configuration"""
from mcp_server.config import DataPlatformConfig
project_dir = tmp_path / 'project'
project_dir.mkdir()
project_config = project_dir / '.env'
project_config.write_text("DATA_PLATFORM_MAX_ROWS=50000\n")
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(project_dir)
config = DataPlatformConfig()
result = config.load()
assert result['max_rows'] == 50000
def test_default_max_rows(tmp_path, monkeypatch):
"""Test default max rows value"""
from mcp_server.config import DataPlatformConfig
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(tmp_path)
# Clear any existing env vars
monkeypatch.delenv('DATA_PLATFORM_MAX_ROWS', raising=False)
config = DataPlatformConfig()
result = config.load()
assert result['max_rows'] == 100_000 # Default value
def test_dbt_auto_detection(tmp_path, monkeypatch):
"""Test automatic dbt project detection"""
from mcp_server.config import DataPlatformConfig
# Create project with dbt_project.yml
project_dir = tmp_path / 'project'
project_dir.mkdir()
(project_dir / 'dbt_project.yml').write_text("name: test_project\n")
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(project_dir)
# Clear PWD and DBT_PROJECT_DIR to ensure auto-detection
monkeypatch.delenv('PWD', raising=False)
monkeypatch.delenv('DBT_PROJECT_DIR', raising=False)
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
config = DataPlatformConfig()
result = config.load()
assert result['dbt_project_dir'] == str(project_dir)
assert result['dbt_available'] is True
def test_dbt_subdirectory_detection(tmp_path, monkeypatch):
"""Test dbt project detection in subdirectory"""
from mcp_server.config import DataPlatformConfig
# Create project with dbt in subdirectory
project_dir = tmp_path / 'project'
project_dir.mkdir()
# Need a marker file for _find_project_directory to find the project
(project_dir / '.git').mkdir()
dbt_dir = project_dir / 'transform'
dbt_dir.mkdir()
(dbt_dir / 'dbt_project.yml').write_text("name: test_project\n")
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(project_dir)
# Clear env vars to ensure auto-detection
monkeypatch.delenv('PWD', raising=False)
monkeypatch.delenv('DBT_PROJECT_DIR', raising=False)
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
config = DataPlatformConfig()
result = config.load()
assert result['dbt_project_dir'] == str(dbt_dir)
assert result['dbt_available'] is True
def test_no_dbt_project(tmp_path, monkeypatch):
"""Test when no dbt project exists"""
from mcp_server.config import DataPlatformConfig
project_dir = tmp_path / 'project'
project_dir.mkdir()
monkeypatch.setenv('HOME', str(tmp_path))
monkeypatch.chdir(project_dir)
# Clear any existing env vars
monkeypatch.delenv('DBT_PROJECT_DIR', raising=False)
config = DataPlatformConfig()
result = config.load()
assert result['dbt_project_dir'] is None
assert result['dbt_available'] is False
def test_find_project_directory_from_env(tmp_path, monkeypatch):
"""Test finding project directory from CLAUDE_PROJECT_DIR env var"""
from mcp_server.config import DataPlatformConfig
project_dir = tmp_path / 'my-project'
project_dir.mkdir()
(project_dir / '.git').mkdir()
monkeypatch.setenv('CLAUDE_PROJECT_DIR', str(project_dir))
config = DataPlatformConfig()
result = config._find_project_directory()
assert result == project_dir
def test_find_project_directory_from_cwd(tmp_path, monkeypatch):
"""Test finding project directory from cwd with .env file"""
from mcp_server.config import DataPlatformConfig
project_dir = tmp_path / 'project'
project_dir.mkdir()
(project_dir / '.env').write_text("TEST=value")
monkeypatch.chdir(project_dir)
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
monkeypatch.delenv('PWD', raising=False)
config = DataPlatformConfig()
result = config._find_project_directory()
assert result == project_dir
def test_find_project_directory_none_when_no_markers(tmp_path, monkeypatch):
"""Test returns None when no project markers found"""
from mcp_server.config import DataPlatformConfig
empty_dir = tmp_path / 'empty'
empty_dir.mkdir()
monkeypatch.chdir(empty_dir)
monkeypatch.delenv('CLAUDE_PROJECT_DIR', raising=False)
monkeypatch.delenv('PWD', raising=False)
monkeypatch.delenv('DBT_PROJECT_DIR', raising=False)
config = DataPlatformConfig()
result = config._find_project_directory()
assert result is None

View File

@@ -0,0 +1,240 @@
"""
Unit tests for Arrow IPC DataFrame registry.
"""
import pytest
import pandas as pd
import pyarrow as pa
def test_store_pandas_dataframe():
"""Test storing pandas DataFrame"""
from mcp_server.data_store import DataStore
# Create fresh instance for test
store = DataStore()
store._dataframes = {}
store._metadata = {}
df = pd.DataFrame({'a': [1, 2, 3], 'b': ['x', 'y', 'z']})
data_ref = store.store(df, name='test_df')
assert data_ref == 'test_df'
assert 'test_df' in store._dataframes
assert store._metadata['test_df'].rows == 3
assert store._metadata['test_df'].columns == 2
def test_store_arrow_table():
"""Test storing Arrow Table directly"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
table = pa.table({'x': [1, 2, 3], 'y': [4, 5, 6]})
data_ref = store.store(table, name='arrow_test')
assert data_ref == 'arrow_test'
assert store._dataframes['arrow_test'].num_rows == 3
def test_store_auto_name():
"""Test auto-generated data_ref names"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
df = pd.DataFrame({'a': [1, 2]})
data_ref = store.store(df)
assert data_ref.startswith('df_')
assert len(data_ref) == 11 # df_ + 8 hex chars
def test_get_dataframe():
"""Test retrieving stored DataFrame"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
df = pd.DataFrame({'a': [1, 2, 3]})
store.store(df, name='get_test')
result = store.get('get_test')
assert result is not None
assert result.num_rows == 3
def test_get_pandas():
"""Test retrieving as pandas DataFrame"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
df = pd.DataFrame({'a': [1, 2, 3], 'b': ['x', 'y', 'z']})
store.store(df, name='pandas_test')
result = store.get_pandas('pandas_test')
assert isinstance(result, pd.DataFrame)
assert list(result.columns) == ['a', 'b']
assert len(result) == 3
def test_get_nonexistent():
"""Test getting nonexistent data_ref returns None"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
assert store.get('nonexistent') is None
assert store.get_pandas('nonexistent') is None
def test_list_refs():
"""Test listing all stored DataFrames"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
store.store(pd.DataFrame({'a': [1, 2]}), name='df1')
store.store(pd.DataFrame({'b': [3, 4, 5]}), name='df2')
refs = store.list_refs()
assert len(refs) == 2
ref_names = [r['ref'] for r in refs]
assert 'df1' in ref_names
assert 'df2' in ref_names
def test_drop_dataframe():
"""Test dropping a DataFrame"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
store.store(pd.DataFrame({'a': [1]}), name='drop_test')
assert store.get('drop_test') is not None
result = store.drop('drop_test')
assert result is True
assert store.get('drop_test') is None
def test_drop_nonexistent():
"""Test dropping nonexistent data_ref"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
result = store.drop('nonexistent')
assert result is False
def test_clear():
"""Test clearing all DataFrames"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
store.store(pd.DataFrame({'a': [1]}), name='df1')
store.store(pd.DataFrame({'b': [2]}), name='df2')
store.clear()
assert len(store.list_refs()) == 0
def test_get_info():
"""Test getting DataFrame metadata"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
df = pd.DataFrame({'a': [1, 2, 3], 'b': ['x', 'y', 'z']})
store.store(df, name='info_test', source='test source')
info = store.get_info('info_test')
assert info.ref == 'info_test'
assert info.rows == 3
assert info.columns == 2
assert info.column_names == ['a', 'b']
assert info.source == 'test source'
assert info.memory_bytes > 0
def test_total_memory():
"""Test total memory calculation"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
store.store(pd.DataFrame({'a': range(100)}), name='df1')
store.store(pd.DataFrame({'b': range(200)}), name='df2')
total = store.total_memory_bytes()
assert total > 0
total_mb = store.total_memory_mb()
assert total_mb >= 0
def test_check_row_limit():
"""Test row limit checking"""
from mcp_server.data_store import DataStore
store = DataStore()
store._max_rows = 100
# Under limit
result = store.check_row_limit(50)
assert result['exceeded'] is False
# Over limit
result = store.check_row_limit(150)
assert result['exceeded'] is True
assert 'suggestion' in result
def test_metadata_dtypes():
"""Test that dtypes are correctly recorded"""
from mcp_server.data_store import DataStore
store = DataStore()
store._dataframes = {}
store._metadata = {}
df = pd.DataFrame({
'int_col': [1, 2, 3],
'float_col': [1.1, 2.2, 3.3],
'str_col': ['a', 'b', 'c']
})
store.store(df, name='dtype_test')
info = store.get_info('dtype_test')
assert 'int_col' in info.dtypes
assert 'float_col' in info.dtypes
assert 'str_col' in info.dtypes

View File

@@ -0,0 +1,318 @@
"""
Unit tests for dbt MCP tools.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
import subprocess
import json
import tempfile
import os
@pytest.fixture
def mock_config(tmp_path):
"""Mock configuration with dbt project"""
dbt_dir = tmp_path / 'dbt_project'
dbt_dir.mkdir()
(dbt_dir / 'dbt_project.yml').write_text('name: test_project\n')
return {
'dbt_project_dir': str(dbt_dir),
'dbt_profiles_dir': str(tmp_path / '.dbt')
}
@pytest.fixture
def dbt_tools(mock_config):
"""Create DbtTools instance with mocked config"""
with patch('mcp_server.dbt_tools.load_config', return_value=mock_config):
from mcp_server.dbt_tools import DbtTools
tools = DbtTools()
tools.project_dir = mock_config['dbt_project_dir']
tools.profiles_dir = mock_config['dbt_profiles_dir']
return tools
@pytest.mark.asyncio
async def test_dbt_parse_success(dbt_tools):
"""Test successful dbt parse"""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = 'Parsed successfully'
mock_result.stderr = ''
with patch('subprocess.run', return_value=mock_result):
result = await dbt_tools.dbt_parse()
assert result['valid'] is True
@pytest.mark.asyncio
async def test_dbt_parse_failure(dbt_tools):
"""Test dbt parse with errors"""
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = ''
mock_result.stderr = 'Compilation error: deprecated syntax'
with patch('subprocess.run', return_value=mock_result):
result = await dbt_tools.dbt_parse()
assert result['valid'] is False
assert 'deprecated' in str(result.get('details', '')).lower() or len(result.get('errors', [])) > 0
@pytest.mark.asyncio
async def test_dbt_run_with_prevalidation(dbt_tools):
"""Test dbt run includes pre-validation"""
# First call is parse, second is run
mock_parse = MagicMock()
mock_parse.returncode = 0
mock_parse.stdout = 'OK'
mock_parse.stderr = ''
mock_run = MagicMock()
mock_run.returncode = 0
mock_run.stdout = 'Completed successfully'
mock_run.stderr = ''
with patch('subprocess.run', side_effect=[mock_parse, mock_run]):
result = await dbt_tools.dbt_run()
assert result['success'] is True
@pytest.mark.asyncio
async def test_dbt_run_fails_validation(dbt_tools):
"""Test dbt run fails if validation fails"""
mock_parse = MagicMock()
mock_parse.returncode = 1
mock_parse.stdout = ''
mock_parse.stderr = 'Parse error'
with patch('subprocess.run', return_value=mock_parse):
result = await dbt_tools.dbt_run()
assert 'error' in result
assert 'Pre-validation failed' in result['error']
@pytest.mark.asyncio
async def test_dbt_run_with_selection(dbt_tools):
"""Test dbt run with model selection"""
mock_parse = MagicMock()
mock_parse.returncode = 0
mock_parse.stdout = 'OK'
mock_parse.stderr = ''
mock_run = MagicMock()
mock_run.returncode = 0
mock_run.stdout = 'Completed'
mock_run.stderr = ''
calls = []
def track_calls(*args, **kwargs):
calls.append(args[0] if args else kwargs.get('args', []))
if len(calls) == 1:
return mock_parse
return mock_run
with patch('subprocess.run', side_effect=track_calls):
result = await dbt_tools.dbt_run(select='dim_customers')
# Verify --select was passed
assert any('--select' in str(call) for call in calls)
@pytest.mark.asyncio
async def test_dbt_test(dbt_tools):
"""Test dbt test"""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = 'All tests passed'
mock_result.stderr = ''
with patch('subprocess.run', return_value=mock_result):
result = await dbt_tools.dbt_test()
assert result['success'] is True
@pytest.mark.asyncio
async def test_dbt_build(dbt_tools):
"""Test dbt build with pre-validation"""
mock_parse = MagicMock()
mock_parse.returncode = 0
mock_parse.stdout = 'OK'
mock_parse.stderr = ''
mock_build = MagicMock()
mock_build.returncode = 0
mock_build.stdout = 'Build complete'
mock_build.stderr = ''
with patch('subprocess.run', side_effect=[mock_parse, mock_build]):
result = await dbt_tools.dbt_build()
assert result['success'] is True
@pytest.mark.asyncio
async def test_dbt_compile(dbt_tools):
"""Test dbt compile"""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = 'Compiled'
mock_result.stderr = ''
with patch('subprocess.run', return_value=mock_result):
result = await dbt_tools.dbt_compile()
assert result['success'] is True
@pytest.mark.asyncio
async def test_dbt_ls(dbt_tools):
"""Test dbt ls"""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = 'dim_customers\ndim_products\nfct_orders\n'
mock_result.stderr = ''
with patch('subprocess.run', return_value=mock_result):
result = await dbt_tools.dbt_ls()
assert result['success'] is True
assert result['count'] == 3
assert 'dim_customers' in result['resources']
@pytest.mark.asyncio
async def test_dbt_docs_generate(dbt_tools, tmp_path):
"""Test dbt docs generate"""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = 'Done'
mock_result.stderr = ''
# Create fake target directory
target_dir = tmp_path / 'dbt_project' / 'target'
target_dir.mkdir(parents=True)
(target_dir / 'catalog.json').write_text('{}')
(target_dir / 'manifest.json').write_text('{}')
dbt_tools.project_dir = str(tmp_path / 'dbt_project')
with patch('subprocess.run', return_value=mock_result):
result = await dbt_tools.dbt_docs_generate()
assert result['success'] is True
assert result['catalog_generated'] is True
assert result['manifest_generated'] is True
@pytest.mark.asyncio
async def test_dbt_lineage(dbt_tools, tmp_path):
"""Test dbt lineage"""
# Create manifest
target_dir = tmp_path / 'dbt_project' / 'target'
target_dir.mkdir(parents=True)
manifest = {
'nodes': {
'model.test.dim_customers': {
'name': 'dim_customers',
'resource_type': 'model',
'schema': 'public',
'database': 'testdb',
'description': 'Customer dimension',
'tags': ['daily'],
'config': {'materialized': 'table'},
'depends_on': {
'nodes': ['model.test.stg_customers']
}
},
'model.test.stg_customers': {
'name': 'stg_customers',
'resource_type': 'model',
'depends_on': {'nodes': []}
},
'model.test.fct_orders': {
'name': 'fct_orders',
'resource_type': 'model',
'depends_on': {
'nodes': ['model.test.dim_customers']
}
}
}
}
(target_dir / 'manifest.json').write_text(json.dumps(manifest))
dbt_tools.project_dir = str(tmp_path / 'dbt_project')
result = await dbt_tools.dbt_lineage('dim_customers')
assert result['model'] == 'dim_customers'
assert 'model.test.stg_customers' in result['upstream']
assert 'model.test.fct_orders' in result['downstream']
@pytest.mark.asyncio
async def test_dbt_lineage_model_not_found(dbt_tools, tmp_path):
"""Test dbt lineage with nonexistent model"""
target_dir = tmp_path / 'dbt_project' / 'target'
target_dir.mkdir(parents=True)
manifest = {
'nodes': {
'model.test.dim_customers': {
'name': 'dim_customers',
'resource_type': 'model'
}
}
}
(target_dir / 'manifest.json').write_text(json.dumps(manifest))
dbt_tools.project_dir = str(tmp_path / 'dbt_project')
result = await dbt_tools.dbt_lineage('nonexistent_model')
assert 'error' in result
assert 'not found' in result['error'].lower()
@pytest.mark.asyncio
async def test_dbt_no_project():
"""Test dbt tools when no project configured"""
with patch('mcp_server.dbt_tools.load_config', return_value={'dbt_project_dir': None}):
from mcp_server.dbt_tools import DbtTools
tools = DbtTools()
tools.project_dir = None
result = await tools.dbt_run()
assert 'error' in result
assert 'not found' in result['error'].lower()
@pytest.mark.asyncio
async def test_dbt_timeout(dbt_tools):
"""Test dbt command timeout handling"""
with patch('subprocess.run', side_effect=subprocess.TimeoutExpired('dbt', 300)):
result = await dbt_tools.dbt_parse()
assert 'error' in result
assert 'timed out' in result['error'].lower()
@pytest.mark.asyncio
async def test_dbt_not_installed(dbt_tools):
"""Test handling when dbt is not installed"""
with patch('subprocess.run', side_effect=FileNotFoundError()):
result = await dbt_tools.dbt_parse()
assert 'error' in result
assert 'not found' in result['error'].lower()

View File

@@ -0,0 +1,301 @@
"""
Unit tests for pandas MCP tools.
"""
import pytest
import pandas as pd
import tempfile
import os
from pathlib import Path
@pytest.fixture
def temp_csv(tmp_path):
"""Create a temporary CSV file for testing"""
csv_path = tmp_path / 'test.csv'
df = pd.DataFrame({
'id': [1, 2, 3, 4, 5],
'name': ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'],
'value': [10.5, 20.0, 30.5, 40.0, 50.5]
})
df.to_csv(csv_path, index=False)
return str(csv_path)
@pytest.fixture
def temp_parquet(tmp_path):
"""Create a temporary Parquet file for testing"""
parquet_path = tmp_path / 'test.parquet'
df = pd.DataFrame({
'id': [1, 2, 3],
'data': ['a', 'b', 'c']
})
df.to_parquet(parquet_path)
return str(parquet_path)
@pytest.fixture
def temp_json(tmp_path):
"""Create a temporary JSON file for testing"""
json_path = tmp_path / 'test.json'
df = pd.DataFrame({
'x': [1, 2],
'y': [3, 4]
})
df.to_json(json_path, orient='records')
return str(json_path)
@pytest.fixture
def pandas_tools():
"""Create PandasTools instance with fresh store"""
from mcp_server.pandas_tools import PandasTools
from mcp_server.data_store import DataStore
# Reset store for test isolation
store = DataStore.get_instance()
store._dataframes = {}
store._metadata = {}
return PandasTools()
@pytest.mark.asyncio
async def test_read_csv(pandas_tools, temp_csv):
"""Test reading CSV file"""
result = await pandas_tools.read_csv(temp_csv, name='csv_test')
assert 'data_ref' in result
assert result['data_ref'] == 'csv_test'
assert result['rows'] == 5
assert 'id' in result['columns']
assert 'name' in result['columns']
@pytest.mark.asyncio
async def test_read_csv_nonexistent(pandas_tools):
"""Test reading nonexistent CSV file"""
result = await pandas_tools.read_csv('/nonexistent/path.csv')
assert 'error' in result
assert 'not found' in result['error'].lower()
@pytest.mark.asyncio
async def test_read_parquet(pandas_tools, temp_parquet):
"""Test reading Parquet file"""
result = await pandas_tools.read_parquet(temp_parquet, name='parquet_test')
assert 'data_ref' in result
assert result['rows'] == 3
@pytest.mark.asyncio
async def test_read_json(pandas_tools, temp_json):
"""Test reading JSON file"""
result = await pandas_tools.read_json(temp_json, name='json_test')
assert 'data_ref' in result
assert result['rows'] == 2
@pytest.mark.asyncio
async def test_to_csv(pandas_tools, temp_csv, tmp_path):
"""Test exporting to CSV"""
# First load some data
await pandas_tools.read_csv(temp_csv, name='export_test')
# Export to new file
output_path = str(tmp_path / 'output.csv')
result = await pandas_tools.to_csv('export_test', output_path)
assert result['success'] is True
assert os.path.exists(output_path)
@pytest.mark.asyncio
async def test_to_parquet(pandas_tools, temp_csv, tmp_path):
"""Test exporting to Parquet"""
await pandas_tools.read_csv(temp_csv, name='parquet_export')
output_path = str(tmp_path / 'output.parquet')
result = await pandas_tools.to_parquet('parquet_export', output_path)
assert result['success'] is True
assert os.path.exists(output_path)
@pytest.mark.asyncio
async def test_describe(pandas_tools, temp_csv):
"""Test describe statistics"""
await pandas_tools.read_csv(temp_csv, name='describe_test')
result = await pandas_tools.describe('describe_test')
assert 'data_ref' in result
assert 'shape' in result
assert result['shape']['rows'] == 5
assert 'statistics' in result
assert 'null_counts' in result
@pytest.mark.asyncio
async def test_head(pandas_tools, temp_csv):
"""Test getting first N rows"""
await pandas_tools.read_csv(temp_csv, name='head_test')
result = await pandas_tools.head('head_test', n=3)
assert result['returned_rows'] == 3
assert len(result['data']) == 3
@pytest.mark.asyncio
async def test_tail(pandas_tools, temp_csv):
"""Test getting last N rows"""
await pandas_tools.read_csv(temp_csv, name='tail_test')
result = await pandas_tools.tail('tail_test', n=2)
assert result['returned_rows'] == 2
@pytest.mark.asyncio
async def test_filter(pandas_tools, temp_csv):
"""Test filtering rows"""
await pandas_tools.read_csv(temp_csv, name='filter_test')
result = await pandas_tools.filter('filter_test', 'value > 25')
assert 'data_ref' in result
assert result['rows'] == 3 # 30.5, 40.0, 50.5
@pytest.mark.asyncio
async def test_filter_invalid_condition(pandas_tools, temp_csv):
"""Test filter with invalid condition"""
await pandas_tools.read_csv(temp_csv, name='filter_error')
result = await pandas_tools.filter('filter_error', 'invalid_column > 0')
assert 'error' in result
@pytest.mark.asyncio
async def test_select(pandas_tools, temp_csv):
"""Test selecting columns"""
await pandas_tools.read_csv(temp_csv, name='select_test')
result = await pandas_tools.select('select_test', ['id', 'name'])
assert 'data_ref' in result
assert result['columns'] == ['id', 'name']
@pytest.mark.asyncio
async def test_select_invalid_column(pandas_tools, temp_csv):
"""Test select with invalid column"""
await pandas_tools.read_csv(temp_csv, name='select_error')
result = await pandas_tools.select('select_error', ['id', 'nonexistent'])
assert 'error' in result
assert 'available_columns' in result
@pytest.mark.asyncio
async def test_groupby(pandas_tools, tmp_path):
"""Test groupby aggregation"""
# Create test data with groups
csv_path = tmp_path / 'groupby.csv'
df = pd.DataFrame({
'category': ['A', 'A', 'B', 'B'],
'value': [10, 20, 30, 40]
})
df.to_csv(csv_path, index=False)
await pandas_tools.read_csv(str(csv_path), name='groupby_test')
result = await pandas_tools.groupby(
'groupby_test',
by='category',
agg={'value': 'sum'}
)
assert 'data_ref' in result
assert result['rows'] == 2 # Two groups: A, B
@pytest.mark.asyncio
async def test_join(pandas_tools, tmp_path):
"""Test joining DataFrames"""
# Create left table
left_path = tmp_path / 'left.csv'
pd.DataFrame({
'id': [1, 2, 3],
'name': ['A', 'B', 'C']
}).to_csv(left_path, index=False)
# Create right table
right_path = tmp_path / 'right.csv'
pd.DataFrame({
'id': [1, 2, 4],
'value': [100, 200, 400]
}).to_csv(right_path, index=False)
await pandas_tools.read_csv(str(left_path), name='left')
await pandas_tools.read_csv(str(right_path), name='right')
result = await pandas_tools.join('left', 'right', on='id', how='inner')
assert 'data_ref' in result
assert result['rows'] == 2 # Only id 1 and 2 match
@pytest.mark.asyncio
async def test_list_data(pandas_tools, temp_csv):
"""Test listing all DataFrames"""
await pandas_tools.read_csv(temp_csv, name='list_test1')
await pandas_tools.read_csv(temp_csv, name='list_test2')
result = await pandas_tools.list_data()
assert result['count'] == 2
refs = [df['ref'] for df in result['dataframes']]
assert 'list_test1' in refs
assert 'list_test2' in refs
@pytest.mark.asyncio
async def test_drop_data(pandas_tools, temp_csv):
"""Test dropping DataFrame"""
await pandas_tools.read_csv(temp_csv, name='drop_test')
result = await pandas_tools.drop_data('drop_test')
assert result['success'] is True
# Verify it's gone
list_result = await pandas_tools.list_data()
refs = [df['ref'] for df in list_result['dataframes']]
assert 'drop_test' not in refs
@pytest.mark.asyncio
async def test_drop_nonexistent(pandas_tools):
"""Test dropping nonexistent DataFrame"""
result = await pandas_tools.drop_data('nonexistent')
assert 'error' in result
@pytest.mark.asyncio
async def test_operations_on_nonexistent(pandas_tools):
"""Test operations on nonexistent data_ref"""
result = await pandas_tools.describe('nonexistent')
assert 'error' in result
result = await pandas_tools.head('nonexistent')
assert 'error' in result
result = await pandas_tools.filter('nonexistent', 'x > 0')
assert 'error' in result

View File

@@ -0,0 +1,338 @@
"""
Unit tests for PostgreSQL MCP tools.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock
import pandas as pd
@pytest.fixture
def mock_config():
"""Mock configuration"""
return {
'postgres_url': 'postgresql://test:test@localhost:5432/testdb',
'max_rows': 100000
}
@pytest.fixture
def postgres_tools(mock_config):
"""Create PostgresTools instance with mocked config"""
with patch('mcp_server.postgres_tools.load_config', return_value=mock_config):
from mcp_server.postgres_tools import PostgresTools
from mcp_server.data_store import DataStore
# Reset store
store = DataStore.get_instance()
store._dataframes = {}
store._metadata = {}
tools = PostgresTools()
tools.config = mock_config
return tools
@pytest.mark.asyncio
async def test_pg_connect_no_config():
"""Test pg_connect when no PostgreSQL configured"""
with patch('mcp_server.postgres_tools.load_config', return_value={'postgres_url': None}):
from mcp_server.postgres_tools import PostgresTools
tools = PostgresTools()
tools.config = {'postgres_url': None}
result = await tools.pg_connect()
assert result['connected'] is False
assert 'not configured' in result['error'].lower()
@pytest.mark.asyncio
async def test_pg_connect_success(postgres_tools):
"""Test successful pg_connect"""
mock_conn = AsyncMock()
mock_conn.fetchval = AsyncMock(side_effect=[
'PostgreSQL 15.1', # version
'testdb', # database name
'testuser', # user
None # PostGIS check fails
])
mock_conn.close = AsyncMock()
# Create proper async context manager
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
mock_cm.__aexit__ = AsyncMock(return_value=None)
mock_pool = MagicMock()
mock_pool.acquire = MagicMock(return_value=mock_cm)
# Use AsyncMock for create_pool since it's awaited
with patch('asyncpg.create_pool', new=AsyncMock(return_value=mock_pool)):
postgres_tools.pool = None
result = await postgres_tools.pg_connect()
assert result['connected'] is True
assert result['database'] == 'testdb'
@pytest.mark.asyncio
async def test_pg_query_success(postgres_tools):
"""Test successful pg_query"""
mock_rows = [
{'id': 1, 'name': 'Alice'},
{'id': 2, 'name': 'Bob'}
]
mock_conn = AsyncMock()
mock_conn.fetch = AsyncMock(return_value=mock_rows)
mock_pool = AsyncMock()
mock_pool.acquire = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_conn),
__aexit__=AsyncMock()
))
postgres_tools.pool = mock_pool
result = await postgres_tools.pg_query('SELECT * FROM users', name='users_data')
assert 'data_ref' in result
assert result['rows'] == 2
@pytest.mark.asyncio
async def test_pg_query_empty_result(postgres_tools):
"""Test pg_query with no results"""
mock_conn = AsyncMock()
mock_conn.fetch = AsyncMock(return_value=[])
mock_pool = AsyncMock()
mock_pool.acquire = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_conn),
__aexit__=AsyncMock()
))
postgres_tools.pool = mock_pool
result = await postgres_tools.pg_query('SELECT * FROM empty_table')
assert result['data_ref'] is None
assert result['rows'] == 0
@pytest.mark.asyncio
async def test_pg_execute_success(postgres_tools):
"""Test successful pg_execute"""
mock_conn = AsyncMock()
mock_conn.execute = AsyncMock(return_value='INSERT 0 3')
mock_pool = AsyncMock()
mock_pool.acquire = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_conn),
__aexit__=AsyncMock()
))
postgres_tools.pool = mock_pool
result = await postgres_tools.pg_execute('INSERT INTO users VALUES (1, 2, 3)')
assert result['success'] is True
assert result['affected_rows'] == 3
assert result['command'] == 'INSERT'
@pytest.mark.asyncio
async def test_pg_tables(postgres_tools):
"""Test listing tables"""
mock_rows = [
{'table_name': 'users', 'table_type': 'BASE TABLE', 'column_count': 5},
{'table_name': 'orders', 'table_type': 'BASE TABLE', 'column_count': 8}
]
mock_conn = AsyncMock()
mock_conn.fetch = AsyncMock(return_value=mock_rows)
mock_pool = AsyncMock()
mock_pool.acquire = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_conn),
__aexit__=AsyncMock()
))
postgres_tools.pool = mock_pool
result = await postgres_tools.pg_tables(schema='public')
assert result['schema'] == 'public'
assert result['count'] == 2
assert len(result['tables']) == 2
@pytest.mark.asyncio
async def test_pg_columns(postgres_tools):
"""Test getting column info"""
mock_rows = [
{
'column_name': 'id',
'data_type': 'integer',
'udt_name': 'int4',
'is_nullable': 'NO',
'column_default': "nextval('users_id_seq'::regclass)",
'character_maximum_length': None,
'numeric_precision': 32
},
{
'column_name': 'name',
'data_type': 'character varying',
'udt_name': 'varchar',
'is_nullable': 'YES',
'column_default': None,
'character_maximum_length': 255,
'numeric_precision': None
}
]
mock_conn = AsyncMock()
mock_conn.fetch = AsyncMock(return_value=mock_rows)
mock_pool = AsyncMock()
mock_pool.acquire = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_conn),
__aexit__=AsyncMock()
))
postgres_tools.pool = mock_pool
result = await postgres_tools.pg_columns(table='users')
assert result['table'] == 'public.users'
assert result['column_count'] == 2
assert result['columns'][0]['name'] == 'id'
assert result['columns'][0]['nullable'] is False
@pytest.mark.asyncio
async def test_pg_schemas(postgres_tools):
"""Test listing schemas"""
mock_rows = [
{'schema_name': 'public'},
{'schema_name': 'app'}
]
mock_conn = AsyncMock()
mock_conn.fetch = AsyncMock(return_value=mock_rows)
mock_pool = AsyncMock()
mock_pool.acquire = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_conn),
__aexit__=AsyncMock()
))
postgres_tools.pool = mock_pool
result = await postgres_tools.pg_schemas()
assert result['count'] == 2
assert 'public' in result['schemas']
@pytest.mark.asyncio
async def test_st_tables(postgres_tools):
"""Test listing PostGIS tables"""
mock_rows = [
{
'table_name': 'locations',
'geometry_column': 'geom',
'geometry_type': 'POINT',
'srid': 4326,
'coord_dimension': 2
}
]
mock_conn = AsyncMock()
mock_conn.fetch = AsyncMock(return_value=mock_rows)
mock_pool = AsyncMock()
mock_pool.acquire = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_conn),
__aexit__=AsyncMock()
))
postgres_tools.pool = mock_pool
result = await postgres_tools.st_tables()
assert result['count'] == 1
assert result['postgis_tables'][0]['table'] == 'locations'
assert result['postgis_tables'][0]['srid'] == 4326
@pytest.mark.asyncio
async def test_st_tables_no_postgis(postgres_tools):
"""Test st_tables when PostGIS not installed"""
mock_conn = AsyncMock()
mock_conn.fetch = AsyncMock(side_effect=Exception("relation \"geometry_columns\" does not exist"))
# Create proper async context manager
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
mock_cm.__aexit__ = AsyncMock(return_value=None)
mock_pool = MagicMock()
mock_pool.acquire = MagicMock(return_value=mock_cm)
postgres_tools.pool = mock_pool
result = await postgres_tools.st_tables()
assert 'error' in result
assert 'PostGIS' in result['error']
@pytest.mark.asyncio
async def test_st_extent(postgres_tools):
"""Test getting geometry bounding box"""
mock_row = {
'xmin': -122.5,
'ymin': 37.5,
'xmax': -122.0,
'ymax': 38.0
}
mock_conn = AsyncMock()
mock_conn.fetchrow = AsyncMock(return_value=mock_row)
mock_pool = AsyncMock()
mock_pool.acquire = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_conn),
__aexit__=AsyncMock()
))
postgres_tools.pool = mock_pool
result = await postgres_tools.st_extent(table='locations', column='geom')
assert result['bbox']['xmin'] == -122.5
assert result['bbox']['ymax'] == 38.0
@pytest.mark.asyncio
async def test_error_handling(postgres_tools):
"""Test error handling for database errors"""
mock_conn = AsyncMock()
mock_conn.fetch = AsyncMock(side_effect=Exception("Connection refused"))
# Create proper async context manager
mock_cm = AsyncMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
mock_cm.__aexit__ = AsyncMock(return_value=None)
mock_pool = MagicMock()
mock_pool.acquire = MagicMock(return_value=mock_cm)
postgres_tools.pool = mock_pool
result = await postgres_tools.pg_query('SELECT 1')
assert 'error' in result
assert 'Connection refused' in result['error']

View File

@@ -239,8 +239,11 @@ 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 = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}" # URL-encode the page_name to handle special characters like ':'
encoded_page_name = quote(page_name, safe='')
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{encoded_page_name}"
logger.info(f"Getting wiki page '{page_name}' from {owner}/{target_repo}") 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()
@@ -271,9 +274,13 @@ 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 = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}" # URL-encode the page_name to handle special characters like ':'
encoded_page_name = quote(page_name, safe='')
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{encoded_page_name}"
data = { 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}")
@@ -287,8 +294,11 @@ 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 = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{page_name}" # URL-encode the page_name to handle special characters like ':'
encoded_page_name = quote(page_name, safe='')
url = f"{self.base_url}/repos/{owner}/{target_repo}/wiki/page/{encoded_page_name}"
logger.info(f"Deleting wiki page '{page_name}' from {owner}/{target_repo}") 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()

View File

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

View File

@@ -0,0 +1,397 @@
"""
Chart creation tools using Plotly.
Provides tools for creating data visualizations with automatic theme integration.
"""
import logging
from typing import Dict, List, Optional, Any, Union
logger = logging.getLogger(__name__)
# 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": []
}

View File

@@ -0,0 +1,301 @@
"""
DMC Component Registry for viz-platform.
Provides version-locked component definitions to prevent Claude from
hallucinating invalid props. Uses static JSON registries pre-generated
from DMC source.
"""
import json
import logging
from pathlib import Path
from typing import Dict, List, Optional, Any
logger = logging.getLogger(__name__)
class ComponentRegistry:
"""
Version-locked registry of Dash Mantine Components.
Loads component definitions from static JSON files and provides
lookup methods for validation tools.
"""
def __init__(self, dmc_version: Optional[str] = None):
"""
Initialize the component registry.
Args:
dmc_version: Installed DMC version (e.g., "0.14.7").
If None, will try to detect or use fallback.
"""
self.dmc_version = dmc_version
self.registry_dir = Path(__file__).parent.parent / 'registry'
self.components: Dict[str, Dict[str, Any]] = {}
self.categories: Dict[str, List[str]] = {}
self.loaded_version: Optional[str] = None
def load(self) -> bool:
"""
Load the component registry for the configured DMC version.
Returns:
True if registry loaded successfully, False otherwise
"""
registry_file = self._find_registry_file()
if not registry_file:
logger.warning(
f"No registry found for DMC {self.dmc_version}. "
"Component validation will be limited."
)
return False
try:
with open(registry_file, 'r') as f:
data = json.load(f)
self.loaded_version = data.get('version')
self.components = data.get('components', {})
self.categories = data.get('categories', {})
logger.info(
f"Loaded component registry v{self.loaded_version} "
f"with {len(self.components)} components"
)
return True
except Exception as e:
logger.error(f"Failed to load registry: {e}")
return False
def _find_registry_file(self) -> Optional[Path]:
"""
Find the best matching registry file for the DMC version.
Strategy:
1. Exact major.minor match (e.g., dmc_0_14.json for 0.14.7)
2. Fallback to latest available registry
Returns:
Path to registry file, or None if not found
"""
if not self.registry_dir.exists():
logger.warning(f"Registry directory not found: {self.registry_dir}")
return None
# Try exact major.minor match
if self.dmc_version:
parts = self.dmc_version.split('.')
if len(parts) >= 2:
major_minor = f"{parts[0]}_{parts[1]}"
exact_match = self.registry_dir / f"dmc_{major_minor}.json"
if exact_match.exists():
return exact_match
# Fallback: find latest registry
registry_files = list(self.registry_dir.glob("dmc_*.json"))
if registry_files:
# Sort by version and return latest
registry_files.sort(reverse=True)
fallback = registry_files[0]
if self.dmc_version:
logger.warning(
f"No exact match for DMC {self.dmc_version}, "
f"using fallback: {fallback.name}"
)
return fallback
return None
def get_component(self, name: str) -> Optional[Dict[str, Any]]:
"""
Get component definition by name.
Args:
name: Component name (e.g., "Button", "TextInput")
Returns:
Component definition dict, or None if not found
"""
return self.components.get(name)
def get_component_props(self, name: str) -> Optional[Dict[str, Any]]:
"""
Get props schema for a component.
Args:
name: Component name
Returns:
Props dict with type info, or None if component not found
"""
component = self.get_component(name)
if component:
return component.get('props', {})
return None
def list_components(self, category: Optional[str] = None) -> Dict[str, List[str]]:
"""
List available components, optionally filtered by category.
Args:
category: Optional category filter (e.g., "inputs", "buttons")
Returns:
Dict of category -> component names
"""
if category:
if category in self.categories:
return {category: self.categories[category]}
return {}
return self.categories
def get_categories(self) -> List[str]:
"""
Get list of available component categories.
Returns:
List of category names
"""
return list(self.categories.keys())
def validate_prop(
self,
component: str,
prop_name: str,
prop_value: Any
) -> Dict[str, Any]:
"""
Validate a single prop value against the registry.
Args:
component: Component name
prop_name: Prop name
prop_value: Value to validate
Returns:
Dict with valid: bool, error: Optional[str]
"""
props = self.get_component_props(component)
if props is None:
return {
'valid': False,
'error': f"Unknown component: {component}"
}
if prop_name not in props:
# Check for similar prop names (typo detection)
similar = self._find_similar_props(prop_name, props.keys())
if similar:
return {
'valid': False,
'error': f"Unknown prop '{prop_name}' for {component}. Did you mean '{similar}'?"
}
return {
'valid': False,
'error': f"Unknown prop '{prop_name}' for {component}"
}
prop_schema = props[prop_name]
return self._validate_value(prop_value, prop_schema, prop_name)
def _validate_value(
self,
value: Any,
schema: Dict[str, Any],
prop_name: str
) -> Dict[str, Any]:
"""
Validate a value against a prop schema.
Args:
value: Value to validate
schema: Prop schema from registry
prop_name: Prop name (for error messages)
Returns:
Dict with valid: bool, error: Optional[str]
"""
prop_type = schema.get('type', 'any')
# Any type always valid
if prop_type == 'any':
return {'valid': True}
# Check enum values
if 'enum' in schema:
if value not in schema['enum']:
return {
'valid': False,
'error': f"Prop '{prop_name}' expects one of {schema['enum']}, got '{value}'"
}
return {'valid': True}
# Type checking
type_checks = {
'string': lambda v: isinstance(v, str),
'number': lambda v: isinstance(v, (int, float)),
'integer': lambda v: isinstance(v, int),
'boolean': lambda v: isinstance(v, bool),
'array': lambda v: isinstance(v, list),
'object': lambda v: isinstance(v, dict),
}
checker = type_checks.get(prop_type)
if checker and not checker(value):
return {
'valid': False,
'error': f"Prop '{prop_name}' expects type '{prop_type}', got '{type(value).__name__}'"
}
return {'valid': True}
def _find_similar_props(
self,
prop_name: str,
available_props: List[str]
) -> Optional[str]:
"""
Find a similar prop name for typo suggestions.
Uses simple edit distance heuristic.
Args:
prop_name: The (possibly misspelled) prop name
available_props: List of valid prop names
Returns:
Most similar prop name, or None if no close match
"""
prop_lower = prop_name.lower()
for prop in available_props:
# Exact match after lowercase
if prop.lower() == prop_lower:
return prop
# Common typos: extra/missing letter
if abs(len(prop) - len(prop_name)) == 1:
if prop_lower.startswith(prop.lower()[:3]):
return prop
return None
def is_loaded(self) -> bool:
"""Check if registry is loaded."""
return len(self.components) > 0
def load_registry(dmc_version: Optional[str] = None) -> ComponentRegistry:
"""
Convenience function to load and return a component registry.
Args:
dmc_version: Optional DMC version string
Returns:
Loaded ComponentRegistry instance
"""
registry = ComponentRegistry(dmc_version)
registry.load()
return registry

View File

@@ -0,0 +1,172 @@
"""
Configuration loader for viz-platform MCP Server.
Implements hybrid configuration system:
- System-level: ~/.config/claude/viz-platform.env (theme preferences)
- Project-level: .env (DMC version overrides)
- Auto-detection: DMC package version from installed package
"""
from pathlib import Path
from dotenv import load_dotenv
import os
import logging
from typing import Dict, Optional
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class VizPlatformConfig:
"""Hybrid configuration loader for viz-platform tools"""
def __init__(self):
self.dmc_version: Optional[str] = None
self.theme_dir_user: Path = Path.home() / '.config' / 'claude' / 'themes'
self.theme_dir_project: Optional[Path] = None
self.default_theme: Optional[str] = None
def load(self) -> Dict[str, any]:
"""
Load configuration from system and project levels.
Returns:
Dict containing dmc_version, theme directories, and availability flags
"""
# Load system config
system_config = Path.home() / '.config' / 'claude' / 'viz-platform.env'
if system_config.exists():
load_dotenv(system_config)
logger.info(f"Loaded system configuration from {system_config}")
# Find project directory
project_dir = self._find_project_directory()
# Load project config (overrides system)
if project_dir:
project_config = project_dir / '.env'
if project_config.exists():
load_dotenv(project_config, override=True)
logger.info(f"Loaded project configuration from {project_config}")
# Set project theme directory
self.theme_dir_project = project_dir / '.viz-platform' / 'themes'
# Get DMC version (from env or auto-detect)
self.dmc_version = os.getenv('DMC_VERSION') or self._detect_dmc_version()
self.default_theme = os.getenv('VIZ_DEFAULT_THEME')
# Ensure user theme directory exists
self.theme_dir_user.mkdir(parents=True, exist_ok=True)
return {
'dmc_version': self.dmc_version,
'dmc_available': self.dmc_version is not None,
'theme_dir_user': str(self.theme_dir_user),
'theme_dir_project': str(self.theme_dir_project) if self.theme_dir_project else None,
'default_theme': self.default_theme,
'project_dir': str(project_dir) if project_dir else None
}
def _detect_dmc_version(self) -> Optional[str]:
"""
Auto-detect installed Dash Mantine Components version.
Returns:
Version string (e.g., "0.14.7") or None if not installed
"""
try:
from importlib.metadata import version
dmc_version = version('dash-mantine-components')
logger.info(f"Detected DMC version: {dmc_version}")
return dmc_version
except ImportError:
logger.warning("dash-mantine-components not installed - using registry fallback")
return None
except Exception as e:
logger.warning(f"Could not detect DMC version: {e}")
return None
def _find_project_directory(self) -> Optional[Path]:
"""
Find the user's project directory.
Returns:
Path to project directory, or None if not found
"""
# Strategy 1: Check CLAUDE_PROJECT_DIR environment variable
project_dir = os.getenv('CLAUDE_PROJECT_DIR')
if project_dir:
path = Path(project_dir)
if path.exists():
logger.info(f"Found project directory from CLAUDE_PROJECT_DIR: {path}")
return path
# Strategy 2: Check PWD
pwd = os.getenv('PWD')
if pwd:
path = Path(pwd)
if path.exists() and (
(path / '.git').exists() or
(path / '.env').exists() or
(path / '.viz-platform').exists()
):
logger.info(f"Found project directory from PWD: {path}")
return path
# Strategy 3: Check current working directory
cwd = Path.cwd()
if (cwd / '.git').exists() or (cwd / '.env').exists() or (cwd / '.viz-platform').exists():
logger.info(f"Found project directory from cwd: {cwd}")
return cwd
logger.debug("Could not determine project directory")
return None
def load_config() -> Dict[str, any]:
"""
Convenience function to load configuration.
Returns:
Configuration dictionary
"""
config = VizPlatformConfig()
return config.load()
def check_dmc_version() -> Dict[str, any]:
"""
Check DMC installation status for SessionStart hook.
Returns:
Dict with installation status and version info
"""
config = load_config()
if not config.get('dmc_available'):
return {
'installed': False,
'message': 'dash-mantine-components not installed. Run: pip install dash-mantine-components'
}
version = config.get('dmc_version', 'unknown')
# Check for registry compatibility
registry_path = Path(__file__).parent.parent / 'registry'
major_minor = '.'.join(version.split('.')[:2]) if version else None
registry_file = registry_path / f'dmc_{major_minor.replace(".", "_")}.json' if major_minor else None
if registry_file and registry_file.exists():
return {
'installed': True,
'version': version,
'registry_available': True,
'message': f'DMC {version} ready with component registry'
}
else:
return {
'installed': True,
'version': version,
'registry_available': False,
'message': f'DMC {version} installed but no matching registry. Validation may be limited.'
}

View File

@@ -0,0 +1,306 @@
"""
DMC (Dash Mantine Components) validation tools.
Provides component constraint layer to prevent Claude from hallucinating invalid props.
"""
import logging
from typing import Dict, List, Optional, Any
from .component_registry import ComponentRegistry
logger = logging.getLogger(__name__)
class DMCTools:
"""
DMC component validation tools.
These tools provide the "constraint layer" that validates component usage
against a version-locked registry of DMC components.
"""
def __init__(self, registry: Optional[ComponentRegistry] = None):
"""
Initialize DMC tools with component registry.
Args:
registry: ComponentRegistry instance. If None, creates one.
"""
self.registry = registry
self._initialized = False
def initialize(self, dmc_version: Optional[str] = None) -> bool:
"""
Initialize the registry if not already provided.
Args:
dmc_version: DMC version to load registry for
Returns:
True if initialized successfully
"""
if self.registry is None:
self.registry = ComponentRegistry(dmc_version)
if not self.registry.is_loaded():
self.registry.load()
self._initialized = self.registry.is_loaded()
return self._initialized
async def list_components(
self,
category: Optional[str] = None
) -> Dict[str, Any]:
"""
List available DMC components, optionally filtered by category.
Args:
category: Optional category filter (e.g., "inputs", "buttons", "navigation")
Returns:
Dict with:
- components: Dict[category -> [component names]]
- categories: List of available categories
- version: Loaded DMC registry version
- total_count: Total number of components
"""
if not self._initialized:
return {
"error": "Registry not initialized",
"components": {},
"categories": [],
"version": None,
"total_count": 0
}
components = self.registry.list_components(category)
all_categories = self.registry.get_categories()
# Count total components
total = sum(len(comps) for comps in components.values())
return {
"components": components,
"categories": all_categories if not category else [category],
"version": self.registry.loaded_version,
"total_count": total
}
async def get_component_props(self, component: str) -> Dict[str, Any]:
"""
Get props schema for a specific component.
Args:
component: Component name (e.g., "Button", "TextInput")
Returns:
Dict with:
- component: Component name
- description: Component description
- props: Dict of prop name -> {type, default, enum, description}
- prop_count: Number of props
- required: List of required prop names
Or error dict if component not found
"""
if not self._initialized:
return {
"error": "Registry not initialized",
"component": component,
"props": {},
"prop_count": 0
}
comp_def = self.registry.get_component(component)
if not comp_def:
# Try to suggest similar component name
similar = self._find_similar_component(component)
error_msg = f"Component '{component}' not found in registry"
if similar:
error_msg += f". Did you mean '{similar}'?"
return {
"error": error_msg,
"component": component,
"props": {},
"prop_count": 0
}
props = comp_def.get('props', {})
# Extract required props
required = [
name for name, schema in props.items()
if schema.get('required', False)
]
return {
"component": component,
"description": comp_def.get('description', ''),
"props": props,
"prop_count": len(props),
"required": required,
"version": self.registry.loaded_version
}
async def validate_component(
self,
component: str,
props: Dict[str, Any]
) -> Dict[str, Any]:
"""
Validate component props against registry.
Args:
component: Component name
props: Props dict to validate
Returns:
Dict with:
- valid: bool - True if all props are valid
- errors: List of error messages
- warnings: List of warning messages
- validated_props: Number of props validated
- component: Component name for reference
"""
if not self._initialized:
return {
"valid": False,
"errors": ["Registry not initialized"],
"warnings": [],
"validated_props": 0,
"component": component
}
errors: List[str] = []
warnings: List[str] = []
# Check if component exists
comp_def = self.registry.get_component(component)
if not comp_def:
similar = self._find_similar_component(component)
error_msg = f"Unknown component: {component}"
if similar:
error_msg += f". Did you mean '{similar}'?"
errors.append(error_msg)
return {
"valid": False,
"errors": errors,
"warnings": warnings,
"validated_props": 0,
"component": component
}
comp_props = comp_def.get('props', {})
# Check for required props
for prop_name, prop_schema in comp_props.items():
if prop_schema.get('required', False) and prop_name not in props:
errors.append(f"Missing required prop: '{prop_name}'")
# Validate each provided prop
for prop_name, prop_value in props.items():
# Skip special props that are always allowed
if prop_name in ('id', 'children', 'className', 'style', 'key'):
continue
result = self.registry.validate_prop(component, prop_name, prop_value)
if not result.get('valid', True):
error = result.get('error', f"Invalid prop: {prop_name}")
# Distinguish between typos/unknown props and type errors
if "Unknown prop" in error:
errors.append(f"{error}")
elif "expects one of" in error:
errors.append(f"{error}")
elif "expects type" in error:
warnings.append(f"⚠️ {error}")
else:
errors.append(f"{error}")
# Check for props that exist but might have common mistakes
self._check_common_mistakes(component, props, warnings)
return {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings,
"validated_props": len(props),
"component": component,
"version": self.registry.loaded_version
}
def _find_similar_component(self, component: str) -> Optional[str]:
"""
Find a similar component name for suggestions.
Args:
component: The (possibly misspelled) component name
Returns:
Similar component name, or None if no close match
"""
if not self.registry:
return None
comp_lower = component.lower()
all_components = []
for comps in self.registry.categories.values():
all_components.extend(comps)
for comp in all_components:
# Exact match after lowercase
if comp.lower() == comp_lower:
return comp
# Check if it's a prefix match
if comp.lower().startswith(comp_lower) or comp_lower.startswith(comp.lower()):
return comp
# Check for common typos
if abs(len(comp) - len(component)) <= 2:
if comp_lower[:4] == comp.lower()[:4]:
return comp
return None
def _check_common_mistakes(
self,
component: str,
props: Dict[str, Any],
warnings: List[str]
) -> None:
"""
Check for common prop usage mistakes and add warnings.
Args:
component: Component name
props: Props being used
warnings: List to append warnings to
"""
# Common mistake: using 'onclick' instead of callback pattern
if 'onclick' in [p.lower() for p in props.keys()]:
warnings.append(
"⚠️ Dash uses callback patterns, not inline event handlers. "
"Use 'n_clicks' prop with a callback instead."
)
# Common mistake: using 'class' instead of 'className'
if 'class' in props:
warnings.append(
"⚠️ Use 'className' instead of 'class' for CSS classes."
)
# Button-specific checks
if component == 'Button':
if 'href' in props and 'component' not in props:
warnings.append(
"⚠️ Button with 'href' should also set 'component=\"a\"' for proper anchor behavior."
)
# Input-specific checks
if 'Input' in component:
if 'value' in props and 'onChange' in [p for p in props.keys()]:
warnings.append(
"⚠️ Dash uses 'value' prop with callbacks, not 'onChange'. "
"The value updates automatically through Dash callbacks."
)

View File

@@ -0,0 +1,367 @@
"""
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__)
# 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()
}

View File

@@ -0,0 +1,366 @@
"""
Multi-page app tools for viz-platform.
Provides tools for building complete Dash applications with routing and navigation.
"""
import logging
from typing import Dict, List, Optional, Any
from uuid import uuid4
logger = logging.getLogger(__name__)
# Navigation position options
NAV_POSITIONS = ["top", "left", "right"]
# Auth types supported
AUTH_TYPES = ["none", "basic", "oauth", "custom"]
class PageTools:
"""
Multi-page Dash application tools.
Creates page definitions, navigation, and auth configuration.
"""
def __init__(self):
"""Initialize page tools."""
self._pages: Dict[str, Dict[str, Any]] = {}
self._navbars: Dict[str, Dict[str, Any]] = {}
self._app_config: Dict[str, Any] = {
"title": "Dash App",
"suppress_callback_exceptions": True
}
async def page_create(
self,
name: str,
path: str,
layout_ref: Optional[str] = None,
title: Optional[str] = None
) -> Dict[str, Any]:
"""
Create a new page definition.
Args:
name: Unique page name (used as identifier)
path: URL path for the page (e.g., "/", "/settings")
layout_ref: Optional layout reference to use for the page
title: Optional page title (defaults to name)
Returns:
Dict with:
- page_ref: Reference to use in other tools
- path: URL path
- registered: Whether page was registered
"""
# Validate path format
if not path.startswith('/'):
return {
"error": f"Path must start with '/'. Got: {path}",
"page_ref": None
}
# Check for name collision
if name in self._pages:
return {
"error": f"Page '{name}' already exists. Use a different name.",
"page_ref": name
}
# Check for path collision
for page_name, page_data in self._pages.items():
if page_data['path'] == path:
return {
"error": f"Path '{path}' already used by page '{page_name}'.",
"page_ref": None
}
# Create page definition
page = {
"id": str(uuid4()),
"name": name,
"path": path,
"title": title or name,
"layout_ref": layout_ref,
"auth": None,
"metadata": {}
}
self._pages[name] = page
return {
"page_ref": name,
"path": path,
"title": page["title"],
"layout_ref": layout_ref,
"registered": True
}
async def page_add_navbar(
self,
pages: List[str],
options: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Generate a navigation component linking pages.
Args:
pages: List of page names to include in navigation
options: Navigation options:
- position: "top", "left", or "right"
- style: Style variant
- brand: Brand/logo text or config
- collapsible: Whether to collapse on mobile
Returns:
Dict with:
- navbar_id: Navigation ID
- pages: List of page links generated
- component: DMC component structure
"""
options = options or {}
# Validate pages exist
missing_pages = [p for p in pages if p not in self._pages]
if missing_pages:
return {
"error": f"Pages not found: {missing_pages}. Create them first.",
"navbar_id": None
}
# Validate position
position = options.get("position", "top")
if position not in NAV_POSITIONS:
return {
"error": f"Invalid position '{position}'. Must be one of: {NAV_POSITIONS}",
"navbar_id": None
}
# Generate navbar ID
navbar_id = f"navbar_{len(self._navbars)}"
# Build page links
page_links = []
for page_name in pages:
page = self._pages[page_name]
page_links.append({
"label": page["title"],
"href": page["path"],
"page_ref": page_name
})
# Build DMC component structure
if position == "top":
component = self._build_top_navbar(page_links, options)
else:
component = self._build_side_navbar(page_links, options, position)
# Store navbar config
self._navbars[navbar_id] = {
"id": navbar_id,
"position": position,
"pages": pages,
"options": options,
"component": component
}
return {
"navbar_id": navbar_id,
"position": position,
"pages": page_links,
"component": component
}
async def page_set_auth(
self,
page_ref: str,
auth_config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Configure authentication for a page.
Args:
page_ref: Page name to configure
auth_config: Authentication configuration:
- type: "none", "basic", "oauth", "custom"
- required: Whether auth is required (default True)
- roles: List of required roles (optional)
- redirect: Redirect path for unauthenticated users
Returns:
Dict with:
- page_ref: Page reference
- auth_type: Type of auth configured
- protected: Whether page is protected
"""
# Validate page exists
if page_ref not in self._pages:
available = list(self._pages.keys())
return {
"error": f"Page '{page_ref}' not found. Available: {available}",
"page_ref": page_ref
}
# Validate auth type
auth_type = auth_config.get("type", "basic")
if auth_type not in AUTH_TYPES:
return {
"error": f"Invalid auth type '{auth_type}'. Must be one of: {AUTH_TYPES}",
"page_ref": page_ref
}
# Build auth config
auth = {
"type": auth_type,
"required": auth_config.get("required", True),
"roles": auth_config.get("roles", []),
"redirect": auth_config.get("redirect", "/login")
}
# Handle OAuth-specific config
if auth_type == "oauth":
auth["provider"] = auth_config.get("provider", "generic")
auth["scopes"] = auth_config.get("scopes", [])
# Update page
self._pages[page_ref]["auth"] = auth
return {
"page_ref": page_ref,
"auth_type": auth_type,
"protected": auth["required"],
"roles": auth["roles"],
"redirect": auth["redirect"]
}
async def page_list(self) -> Dict[str, Any]:
"""
List all registered pages.
Returns:
Dict with pages and their configurations
"""
pages_info = {}
for name, page in self._pages.items():
pages_info[name] = {
"path": page["path"],
"title": page["title"],
"layout_ref": page["layout_ref"],
"protected": page["auth"] is not None and page["auth"].get("required", False)
}
return {
"pages": pages_info,
"count": len(pages_info),
"navbars": list(self._navbars.keys())
}
async def page_get_app_config(self) -> Dict[str, Any]:
"""
Get the complete app configuration for Dash.
Returns:
Dict with app config including pages, navbars, and settings
"""
# Build pages config
pages_config = []
for name, page in self._pages.items():
pages_config.append({
"name": name,
"path": page["path"],
"title": page["title"],
"layout_ref": page["layout_ref"]
})
# Build routing config
routes = {page["path"]: name for name, page in self._pages.items()}
return {
"app": self._app_config,
"pages": pages_config,
"routes": routes,
"navbars": list(self._navbars.values()),
"page_count": len(self._pages)
}
def _build_top_navbar(
self,
page_links: List[Dict[str, str]],
options: Dict[str, Any]
) -> Dict[str, Any]:
"""Build a top navigation bar component."""
brand = options.get("brand", "App")
# Build nav links
nav_items = []
for link in page_links:
nav_items.append({
"component": "NavLink",
"props": {
"label": link["label"],
"href": link["href"]
}
})
return {
"component": "AppShell.Header",
"children": [
{
"component": "Group",
"props": {"justify": "space-between", "h": "100%", "px": "md"},
"children": [
{
"component": "Text",
"props": {"size": "lg", "fw": 700},
"children": brand
},
{
"component": "Group",
"props": {"gap": "sm"},
"children": nav_items
}
]
}
]
}
def _build_side_navbar(
self,
page_links: List[Dict[str, str]],
options: Dict[str, Any],
position: str
) -> Dict[str, Any]:
"""Build a side navigation bar component."""
brand = options.get("brand", "App")
# Build nav links
nav_items = []
for link in page_links:
nav_items.append({
"component": "NavLink",
"props": {
"label": link["label"],
"href": link["href"]
}
})
navbar_component = "AppShell.Navbar" if position == "left" else "AppShell.Aside"
return {
"component": navbar_component,
"props": {"p": "md"},
"children": [
{
"component": "Text",
"props": {"size": "lg", "fw": 700, "mb": "md"},
"children": brand
},
{
"component": "Stack",
"props": {"gap": "xs"},
"children": nav_items
}
]
}

View File

@@ -0,0 +1,701 @@
"""
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
# 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()
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"]
}
))
# 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"]
}
))
# 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"]
}
))
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)
)]
# 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)
)]
# 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)
)]
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

@@ -0,0 +1,259 @@
"""
Theme storage and persistence for viz-platform.
Handles saving/loading themes from user and project locations.
"""
import json
import logging
from pathlib import Path
from typing import Dict, List, Optional, Any
logger = logging.getLogger(__name__)
# Default theme based on Mantine defaults
DEFAULT_THEME = {
"name": "default",
"version": "1.0.0",
"tokens": {
"colors": {
"primary": "#228be6",
"secondary": "#868e96",
"success": "#40c057",
"warning": "#fab005",
"error": "#fa5252",
"info": "#15aabf",
"background": {
"base": "#ffffff",
"subtle": "#f8f9fa",
"dark": "#212529"
},
"text": {
"primary": "#212529",
"secondary": "#495057",
"muted": "#868e96",
"inverse": "#ffffff"
},
"border": "#dee2e6"
},
"spacing": {
"xs": "4px",
"sm": "8px",
"md": "16px",
"lg": "24px",
"xl": "32px"
},
"typography": {
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif",
"fontFamilyMono": "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace",
"fontSize": {
"xs": "12px",
"sm": "14px",
"md": "16px",
"lg": "18px",
"xl": "20px"
},
"fontWeight": {
"normal": 400,
"medium": 500,
"semibold": 600,
"bold": 700
},
"lineHeight": {
"tight": 1.25,
"normal": 1.5,
"relaxed": 1.75
}
},
"radii": {
"none": "0px",
"sm": "4px",
"md": "8px",
"lg": "16px",
"xl": "24px",
"full": "9999px"
},
"shadows": {
"none": "none",
"sm": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"md": "0 4px 6px -1px rgb(0 0 0 / 0.1)",
"lg": "0 10px 15px -3px rgb(0 0 0 / 0.1)",
"xl": "0 20px 25px -5px rgb(0 0 0 / 0.1)"
},
"transitions": {
"fast": "150ms",
"normal": "300ms",
"slow": "500ms"
}
}
}
# Required token categories for validation
REQUIRED_TOKEN_CATEGORIES = ["colors", "spacing", "typography", "radii"]
class ThemeStore:
"""
Store and manage design themes.
Handles persistence to user-level and project-level locations.
"""
def __init__(self, project_dir: Optional[Path] = None):
"""
Initialize theme store.
Args:
project_dir: Project directory for project-level themes
"""
self.project_dir = project_dir
self._themes: Dict[str, Dict[str, Any]] = {}
self._active_theme: Optional[str] = None
# Load default theme
self._themes["default"] = DEFAULT_THEME.copy()
@property
def user_themes_dir(self) -> Path:
"""User-level themes directory."""
return Path.home() / ".config" / "claude" / "themes"
@property
def project_themes_dir(self) -> Optional[Path]:
"""Project-level themes directory."""
if self.project_dir:
return self.project_dir / ".viz-platform" / "themes"
return None
def load_themes(self) -> int:
"""
Load themes from user and project directories.
Project themes take precedence over user themes.
Returns:
Number of themes loaded
"""
count = 0
# Load user themes
if self.user_themes_dir.exists():
for theme_file in self.user_themes_dir.glob("*.json"):
try:
with open(theme_file, 'r') as f:
theme = json.load(f)
name = theme.get('name', theme_file.stem)
self._themes[name] = theme
count += 1
logger.debug(f"Loaded user theme: {name}")
except Exception as e:
logger.warning(f"Failed to load theme {theme_file}: {e}")
# Load project themes (override user themes)
if self.project_themes_dir and self.project_themes_dir.exists():
for theme_file in self.project_themes_dir.glob("*.json"):
try:
with open(theme_file, 'r') as f:
theme = json.load(f)
name = theme.get('name', theme_file.stem)
self._themes[name] = theme
count += 1
logger.debug(f"Loaded project theme: {name}")
except Exception as e:
logger.warning(f"Failed to load theme {theme_file}: {e}")
return count
def save_theme(
self,
theme: Dict[str, Any],
location: str = "project"
) -> Path:
"""
Save a theme to disk.
Args:
theme: Theme dict to save
location: "user" or "project"
Returns:
Path where theme was saved
"""
name = theme.get('name', 'unnamed')
if location == "user":
target_dir = self.user_themes_dir
else:
target_dir = self.project_themes_dir
if not target_dir:
target_dir = self.user_themes_dir
target_dir.mkdir(parents=True, exist_ok=True)
theme_path = target_dir / f"{name}.json"
with open(theme_path, 'w') as f:
json.dump(theme, f, indent=2)
# Update in-memory store
self._themes[name] = theme
return theme_path
def get_theme(self, name: str) -> Optional[Dict[str, Any]]:
"""Get a theme by name."""
return self._themes.get(name)
def list_themes(self) -> List[str]:
"""List all available theme names."""
return list(self._themes.keys())
def set_active_theme(self, name: str) -> bool:
"""
Set the active theme.
Args:
name: Theme name to activate
Returns:
True if theme was activated
"""
if name in self._themes:
self._active_theme = name
return True
return False
def get_active_theme(self) -> Optional[Dict[str, Any]]:
"""Get the currently active theme."""
if self._active_theme:
return self._themes.get(self._active_theme)
return None
def delete_theme(self, name: str) -> bool:
"""
Delete a theme.
Args:
name: Theme name to delete
Returns:
True if theme was deleted
"""
if name == "default":
return False # Cannot delete default theme
if name in self._themes:
del self._themes[name]
# Remove file if exists
for themes_dir in [self.user_themes_dir, self.project_themes_dir]:
if themes_dir and themes_dir.exists():
theme_path = themes_dir / f"{name}.json"
if theme_path.exists():
theme_path.unlink()
if self._active_theme == name:
self._active_theme = None
return True
return False

View File

@@ -0,0 +1,391 @@
"""
Theme management tools for viz-platform.
Provides design token-based theming system for consistent visual styling.
"""
import copy
import logging
from typing import Dict, List, Optional, Any
from .theme_store import ThemeStore, DEFAULT_THEME, REQUIRED_TOKEN_CATEGORIES
logger = logging.getLogger(__name__)
class ThemeTools:
"""
Design token-based theming tools.
Creates and manages themes that integrate with DMC and Plotly.
"""
def __init__(self, store: Optional[ThemeStore] = None):
"""
Initialize theme tools.
Args:
store: Optional ThemeStore for persistence
"""
self.store = store or ThemeStore()
async def theme_create(
self,
name: str,
tokens: Dict[str, Any]
) -> Dict[str, Any]:
"""
Create a new theme with design tokens.
Args:
name: Unique theme name
tokens: Design tokens dict with colors, spacing, typography, radii
Returns:
Dict with:
- name: Theme name
- tokens: Full token set (merged with defaults)
- validation: Validation results
"""
# Check for name collision
if self.store.get_theme(name) and name != "default":
return {
"error": f"Theme '{name}' already exists. Use theme_extend to modify it.",
"name": name
}
# Start with default tokens and merge provided ones
theme_tokens = copy.deepcopy(DEFAULT_THEME["tokens"])
theme_tokens = self._deep_merge(theme_tokens, tokens)
# Create theme object
theme = {
"name": name,
"version": "1.0.0",
"tokens": theme_tokens
}
# Validate the theme
validation = self._validate_tokens(theme_tokens)
# Save to store
self.store._themes[name] = theme
return {
"name": name,
"tokens": theme_tokens,
"validation": validation,
"complete": validation["complete"]
}
async def theme_extend(
self,
base_theme: str,
overrides: Dict[str, Any],
new_name: Optional[str] = None
) -> Dict[str, Any]:
"""
Create a new theme by extending an existing one.
Args:
base_theme: Name of theme to extend
overrides: Token overrides to apply
new_name: Optional name for the new theme (defaults to base_theme_extended)
Returns:
Dict with the new theme or error
"""
# Get base theme
base = self.store.get_theme(base_theme)
if not base:
available = self.store.list_themes()
return {
"error": f"Base theme '{base_theme}' not found. Available: {available}",
"name": None
}
# Determine new name
name = new_name or f"{base_theme}_extended"
# Check for collision
if self.store.get_theme(name) and name != base_theme:
return {
"error": f"Theme '{name}' already exists. Choose a different name.",
"name": name
}
# Merge tokens
theme_tokens = copy.deepcopy(base.get("tokens", {}))
theme_tokens = self._deep_merge(theme_tokens, overrides)
# Create theme
theme = {
"name": name,
"version": "1.0.0",
"extends": base_theme,
"tokens": theme_tokens
}
# Validate
validation = self._validate_tokens(theme_tokens)
# Save to store
self.store._themes[name] = theme
return {
"name": name,
"extends": base_theme,
"tokens": theme_tokens,
"validation": validation,
"complete": validation["complete"]
}
async def theme_validate(self, theme_name: str) -> Dict[str, Any]:
"""
Validate a theme for completeness.
Args:
theme_name: Theme name to validate
Returns:
Dict with:
- valid: bool
- complete: bool (all optional tokens present)
- missing: List of missing required tokens
- warnings: List of warnings
"""
theme = self.store.get_theme(theme_name)
if not theme:
available = self.store.list_themes()
return {
"error": f"Theme '{theme_name}' not found. Available: {available}",
"valid": False
}
tokens = theme.get("tokens", {})
validation = self._validate_tokens(tokens)
return {
"theme_name": theme_name,
"valid": validation["valid"],
"complete": validation["complete"],
"missing_required": validation["missing_required"],
"missing_optional": validation["missing_optional"],
"warnings": validation["warnings"]
}
async def theme_export_css(self, theme_name: str) -> Dict[str, Any]:
"""
Export a theme as CSS custom properties.
Args:
theme_name: Theme name to export
Returns:
Dict with:
- css: CSS custom properties string
- variables: List of variable names
"""
theme = self.store.get_theme(theme_name)
if not theme:
available = self.store.list_themes()
return {
"error": f"Theme '{theme_name}' not found. Available: {available}",
"css": None
}
tokens = theme.get("tokens", {})
css_vars = []
var_names = []
# Convert tokens to CSS custom properties
css_vars.append(f"/* Theme: {theme_name} */")
css_vars.append(":root {")
# Colors
colors = tokens.get("colors", {})
css_vars.append(" /* Colors */")
for key, value in self._flatten_tokens(colors, "color").items():
var_name = f"--{key}"
css_vars.append(f" {var_name}: {value};")
var_names.append(var_name)
# Spacing
spacing = tokens.get("spacing", {})
css_vars.append("\n /* Spacing */")
for key, value in spacing.items():
var_name = f"--spacing-{key}"
css_vars.append(f" {var_name}: {value};")
var_names.append(var_name)
# Typography
typography = tokens.get("typography", {})
css_vars.append("\n /* Typography */")
for key, value in self._flatten_tokens(typography, "font").items():
var_name = f"--{key}"
css_vars.append(f" {var_name}: {value};")
var_names.append(var_name)
# Radii
radii = tokens.get("radii", {})
css_vars.append("\n /* Border Radius */")
for key, value in radii.items():
var_name = f"--radius-{key}"
css_vars.append(f" {var_name}: {value};")
var_names.append(var_name)
# Shadows
shadows = tokens.get("shadows", {})
if shadows:
css_vars.append("\n /* Shadows */")
for key, value in shadows.items():
var_name = f"--shadow-{key}"
css_vars.append(f" {var_name}: {value};")
var_names.append(var_name)
# Transitions
transitions = tokens.get("transitions", {})
if transitions:
css_vars.append("\n /* Transitions */")
for key, value in transitions.items():
var_name = f"--transition-{key}"
css_vars.append(f" {var_name}: {value};")
var_names.append(var_name)
css_vars.append("}")
css_content = "\n".join(css_vars)
return {
"theme_name": theme_name,
"css": css_content,
"variable_count": len(var_names),
"variables": var_names
}
async def theme_list(self) -> Dict[str, Any]:
"""
List all available themes.
Returns:
Dict with theme names and active theme
"""
themes = self.store.list_themes()
active = self.store._active_theme
theme_info = {}
for name in themes:
theme = self.store.get_theme(name)
theme_info[name] = {
"extends": theme.get("extends"),
"version": theme.get("version", "1.0.0")
}
return {
"themes": theme_info,
"active_theme": active,
"count": len(themes)
}
async def theme_activate(self, theme_name: str) -> Dict[str, Any]:
"""
Set the active theme.
Args:
theme_name: Theme to activate
Returns:
Dict with activation status
"""
if self.store.set_active_theme(theme_name):
return {
"active_theme": theme_name,
"success": True
}
return {
"error": f"Theme '{theme_name}' not found.",
"success": False
}
def _validate_tokens(self, tokens: Dict[str, Any]) -> Dict[str, Any]:
"""Validate token structure and completeness."""
missing_required = []
missing_optional = []
warnings = []
# Check required categories
for category in REQUIRED_TOKEN_CATEGORIES:
if category not in tokens:
missing_required.append(category)
# Check colors structure
colors = tokens.get("colors", {})
required_colors = ["primary", "background", "text"]
for color in required_colors:
if color not in colors:
missing_required.append(f"colors.{color}")
# Check spacing
spacing = tokens.get("spacing", {})
required_spacing = ["xs", "sm", "md", "lg"]
for size in required_spacing:
if size not in spacing:
missing_optional.append(f"spacing.{size}")
# Check typography
typography = tokens.get("typography", {})
if "fontFamily" not in typography:
missing_optional.append("typography.fontFamily")
if "fontSize" not in typography:
missing_optional.append("typography.fontSize")
# Check radii
radii = tokens.get("radii", {})
if "sm" not in radii and "md" not in radii:
missing_optional.append("radii.sm or radii.md")
# Warnings for common issues
if "shadows" not in tokens:
warnings.append("No shadows defined - components may have no elevation")
if "transitions" not in tokens:
warnings.append("No transitions defined - animations will use defaults")
return {
"valid": len(missing_required) == 0,
"complete": len(missing_required) == 0 and len(missing_optional) == 0,
"missing_required": missing_required,
"missing_optional": missing_optional,
"warnings": warnings
}
def _deep_merge(
self,
base: Dict[str, Any],
override: Dict[str, Any]
) -> Dict[str, Any]:
"""Deep merge two dictionaries."""
result = copy.deepcopy(base)
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = self._deep_merge(result[key], value)
else:
result[key] = value
return result
def _flatten_tokens(
self,
tokens: Dict[str, Any],
prefix: str
) -> Dict[str, str]:
"""Flatten nested token dict for CSS export."""
result = {}
for key, value in tokens.items():
if isinstance(value, dict):
nested = self._flatten_tokens(value, f"{prefix}-{key}")
result.update(nested)
else:
result[f"{prefix}-{key}"] = str(value)
return result

View File

@@ -0,0 +1,45 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "viz-platform-mcp"
version = "1.0.0"
description = "MCP Server for visualization with Dash Mantine Components validation and theming"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
{name = "Leo Miranda"}
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"mcp>=0.9.0",
"plotly>=5.18.0",
"dash>=2.14.0",
"dash-mantine-components>=2.0.0",
"python-dotenv>=1.0.0",
"pydantic>=2.5.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.3",
"pytest-asyncio>=0.23.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["mcp_server*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

View File

@@ -0,0 +1,668 @@
{
"version": "2.5.1",
"generated": "2026-01-26",
"mantine_version": "7.x",
"categories": {
"buttons": ["Button", "ButtonGroup", "ActionIcon", "ActionIconGroup", "CopyButton", "CloseButton", "UnstyledButton"],
"inputs": [
"TextInput", "PasswordInput", "NumberInput", "Textarea", "Select", "MultiSelect",
"Checkbox", "CheckboxGroup", "CheckboxCard", "Switch", "Radio", "RadioGroup", "RadioCard",
"Slider", "RangeSlider", "ColorInput", "ColorPicker", "Autocomplete", "TagsInput",
"PinInput", "Rating", "SegmentedControl", "Chip", "ChipGroup", "JsonInput",
"NativeSelect", "FileInput", "Combobox"
],
"navigation": ["Anchor", "Breadcrumbs", "Burger", "NavLink", "Pagination", "Stepper", "Tabs", "TabsList", "TabsTab", "TabsPanel"],
"feedback": ["Alert", "Loader", "Notification", "NotificationContainer", "Progress", "RingProgress", "Skeleton"],
"overlays": ["Modal", "Drawer", "DrawerStack", "Popover", "HoverCard", "Tooltip", "FloatingTooltip", "Menu", "MenuTarget", "MenuDropdown", "MenuItem", "Affix"],
"typography": ["Text", "Title", "Highlight", "Mark", "Code", "CodeHighlight", "Blockquote", "List", "ListItem", "Kbd"],
"layout": [
"AppShell", "AppShellHeader", "AppShellNavbar", "AppShellAside", "AppShellFooter", "AppShellMain", "AppShellSection",
"Container", "Center", "Stack", "Group", "Flex", "Grid", "GridCol", "SimpleGrid",
"Paper", "Card", "CardSection", "Box", "Space", "Divider", "AspectRatio", "ScrollArea"
],
"data_display": [
"Accordion", "AccordionItem", "AccordionControl", "AccordionPanel",
"Avatar", "AvatarGroup", "Badge", "Image", "BackgroundImage",
"Indicator", "Spoiler", "Table", "ThemeIcon", "Timeline", "TimelineItem", "Tree"
],
"charts": ["AreaChart", "BarChart", "LineChart", "PieChart", "DonutChart", "RadarChart", "ScatterChart", "BubbleChart", "CompositeChart", "Sparkline"],
"dates": ["DatePicker", "DateTimePicker", "DateInput", "DatePickerInput", "MonthPicker", "YearPicker", "TimePicker", "TimeInput", "Calendar", "MiniCalendar", "DatesProvider"]
},
"components": {
"Button": {
"description": "Button component for user interactions",
"props": {
"children": {"type": "any", "description": "Button content"},
"variant": {"type": "string", "enum": ["filled", "light", "outline", "transparent", "white", "subtle", "default", "gradient"], "default": "filled"},
"color": {"type": "string", "default": "blue", "description": "Key of theme.colors or CSS color"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl", "compact-xs", "compact-sm", "compact-md", "compact-lg", "compact-xl"], "default": "sm"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"disabled": {"type": "boolean", "default": false},
"loading": {"type": "boolean", "default": false},
"loaderProps": {"type": "object"},
"leftSection": {"type": "any", "description": "Content on the left side of label"},
"rightSection": {"type": "any", "description": "Content on the right side of label"},
"fullWidth": {"type": "boolean", "default": false},
"gradient": {"type": "object", "description": "Gradient for gradient variant"},
"justify": {"type": "string", "enum": ["center", "start", "end", "space-between"], "default": "center"},
"autoContrast": {"type": "boolean", "default": false},
"n_clicks": {"type": "integer", "default": 0, "description": "Dash callback trigger"}
}
},
"ActionIcon": {
"description": "Icon button without text label",
"props": {
"children": {"type": "any", "required": true, "description": "Icon element"},
"variant": {"type": "string", "enum": ["filled", "light", "outline", "transparent", "white", "subtle", "default", "gradient"], "default": "subtle"},
"color": {"type": "string", "default": "gray"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"disabled": {"type": "boolean", "default": false},
"loading": {"type": "boolean", "default": false},
"autoContrast": {"type": "boolean", "default": false},
"n_clicks": {"type": "integer", "default": 0}
}
},
"TextInput": {
"description": "Text input field",
"props": {
"value": {"type": "string", "default": ""},
"placeholder": {"type": "string"},
"label": {"type": "any"},
"description": {"type": "any"},
"error": {"type": "any"},
"disabled": {"type": "boolean", "default": false},
"required": {"type": "boolean", "default": false},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"variant": {"type": "string", "enum": ["default", "filled", "unstyled"], "default": "default"},
"leftSection": {"type": "any"},
"rightSection": {"type": "any"},
"withAsterisk": {"type": "boolean", "default": false},
"debounce": {"type": "integer", "description": "Debounce delay in ms"},
"leftSectionPointerEvents": {"type": "string", "enum": ["none", "all"], "default": "none"},
"rightSectionPointerEvents": {"type": "string", "enum": ["none", "all"], "default": "none"}
}
},
"NumberInput": {
"description": "Numeric input with optional controls",
"props": {
"value": {"type": "number"},
"placeholder": {"type": "string"},
"label": {"type": "any"},
"description": {"type": "any"},
"error": {"type": "any"},
"disabled": {"type": "boolean", "default": false},
"required": {"type": "boolean", "default": false},
"min": {"type": "number"},
"max": {"type": "number"},
"step": {"type": "number", "default": 1},
"hideControls": {"type": "boolean", "default": false},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"allowNegative": {"type": "boolean", "default": true},
"allowDecimal": {"type": "boolean", "default": true},
"clampBehavior": {"type": "string", "enum": ["strict", "blur", "none"], "default": "blur"},
"decimalScale": {"type": "integer"},
"fixedDecimalScale": {"type": "boolean", "default": false},
"thousandSeparator": {"type": "string"},
"decimalSeparator": {"type": "string"},
"prefix": {"type": "string"},
"suffix": {"type": "string"}
}
},
"Select": {
"description": "Dropdown select input",
"props": {
"value": {"type": "string"},
"data": {"type": "array", "required": true, "description": "Array of options: strings or {value, label} objects"},
"placeholder": {"type": "string"},
"label": {"type": "any"},
"description": {"type": "any"},
"error": {"type": "any"},
"disabled": {"type": "boolean", "default": false},
"required": {"type": "boolean", "default": false},
"searchable": {"type": "boolean", "default": false},
"clearable": {"type": "boolean", "default": false},
"nothingFoundMessage": {"type": "string"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"maxDropdownHeight": {"type": "number", "default": 250},
"allowDeselect": {"type": "boolean", "default": true},
"checkIconPosition": {"type": "string", "enum": ["left", "right"], "default": "left"},
"comboboxProps": {"type": "object"},
"withScrollArea": {"type": "boolean", "default": true}
}
},
"MultiSelect": {
"description": "Multiple selection dropdown",
"props": {
"value": {"type": "array", "default": []},
"data": {"type": "array", "required": true},
"placeholder": {"type": "string"},
"label": {"type": "any"},
"description": {"type": "any"},
"error": {"type": "any"},
"disabled": {"type": "boolean", "default": false},
"required": {"type": "boolean", "default": false},
"searchable": {"type": "boolean", "default": false},
"clearable": {"type": "boolean", "default": false},
"maxValues": {"type": "integer"},
"hidePickedOptions": {"type": "boolean", "default": false},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"maxDropdownHeight": {"type": "number", "default": 250},
"withCheckIcon": {"type": "boolean", "default": true}
}
},
"Checkbox": {
"description": "Checkbox input",
"props": {
"checked": {"type": "boolean", "default": false},
"label": {"type": "any"},
"description": {"type": "any"},
"error": {"type": "any"},
"disabled": {"type": "boolean", "default": false},
"indeterminate": {"type": "boolean", "default": false},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"color": {"type": "string", "default": "blue"},
"labelPosition": {"type": "string", "enum": ["left", "right"], "default": "right"},
"autoContrast": {"type": "boolean", "default": false},
"icon": {"type": "any"},
"iconColor": {"type": "string"}
}
},
"Switch": {
"description": "Toggle switch input",
"props": {
"checked": {"type": "boolean", "default": false},
"label": {"type": "any"},
"description": {"type": "any"},
"error": {"type": "any"},
"disabled": {"type": "boolean", "default": false},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xl"},
"color": {"type": "string", "default": "blue"},
"onLabel": {"type": "any"},
"offLabel": {"type": "any"},
"thumbIcon": {"type": "any"},
"labelPosition": {"type": "string", "enum": ["left", "right"], "default": "right"}
}
},
"Slider": {
"description": "Slider input for numeric values",
"props": {
"value": {"type": "number"},
"min": {"type": "number", "default": 0},
"max": {"type": "number", "default": 100},
"step": {"type": "number", "default": 1},
"label": {"type": "any"},
"disabled": {"type": "boolean", "default": false},
"marks": {"type": "array"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xl"},
"color": {"type": "string", "default": "blue"},
"showLabelOnHover": {"type": "boolean", "default": true},
"labelAlwaysOn": {"type": "boolean", "default": false},
"thumbLabel": {"type": "string"},
"precision": {"type": "integer", "default": 0},
"inverted": {"type": "boolean", "default": false},
"thumbSize": {"type": "number"},
"restrictToMarks": {"type": "boolean", "default": false}
}
},
"Alert": {
"description": "Alert component for feedback messages",
"props": {
"children": {"type": "any"},
"title": {"type": "any"},
"color": {"type": "string", "default": "blue"},
"variant": {"type": "string", "enum": ["filled", "light", "outline", "default", "transparent", "white"], "default": "light"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"icon": {"type": "any"},
"withCloseButton": {"type": "boolean", "default": false},
"closeButtonLabel": {"type": "string"},
"autoContrast": {"type": "boolean", "default": false}
}
},
"Loader": {
"description": "Loading indicator",
"props": {
"color": {"type": "string", "default": "blue"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"type": {"type": "string", "enum": ["oval", "bars", "dots"], "default": "oval"}
}
},
"Progress": {
"description": "Progress bar",
"props": {
"value": {"type": "number", "required": true},
"color": {"type": "string", "default": "blue"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"striped": {"type": "boolean", "default": false},
"animated": {"type": "boolean", "default": false},
"autoContrast": {"type": "boolean", "default": false},
"transitionDuration": {"type": "number", "default": 100}
}
},
"Modal": {
"description": "Modal dialog overlay",
"props": {
"children": {"type": "any"},
"opened": {"type": "boolean", "required": true},
"title": {"type": "any"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl", "auto"], "default": "md"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"centered": {"type": "boolean", "default": false},
"fullScreen": {"type": "boolean", "default": false},
"withCloseButton": {"type": "boolean", "default": true},
"closeOnClickOutside": {"type": "boolean", "default": true},
"closeOnEscape": {"type": "boolean", "default": true},
"overlayProps": {"type": "object"},
"padding": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"transitionProps": {"type": "object"},
"zIndex": {"type": "number", "default": 200},
"trapFocus": {"type": "boolean", "default": true},
"returnFocus": {"type": "boolean", "default": true},
"lockScroll": {"type": "boolean", "default": true}
}
},
"Drawer": {
"description": "Sliding panel drawer",
"props": {
"children": {"type": "any"},
"opened": {"type": "boolean", "required": true},
"title": {"type": "any"},
"position": {"type": "string", "enum": ["left", "right", "top", "bottom"], "default": "left"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
"withCloseButton": {"type": "boolean", "default": true},
"closeOnClickOutside": {"type": "boolean", "default": true},
"closeOnEscape": {"type": "boolean", "default": true},
"overlayProps": {"type": "object"},
"padding": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"zIndex": {"type": "number", "default": 200},
"offset": {"type": "number", "default": 0},
"trapFocus": {"type": "boolean", "default": true},
"returnFocus": {"type": "boolean", "default": true},
"lockScroll": {"type": "boolean", "default": true}
}
},
"Tooltip": {
"description": "Tooltip on hover",
"props": {
"children": {"type": "any", "required": true},
"label": {"type": "any", "required": true},
"position": {"type": "string", "enum": ["top", "right", "bottom", "left", "top-start", "top-end", "right-start", "right-end", "bottom-start", "bottom-end", "left-start", "left-end"], "default": "top"},
"color": {"type": "string"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"withArrow": {"type": "boolean", "default": false},
"arrowSize": {"type": "number", "default": 4},
"arrowOffset": {"type": "number", "default": 5},
"offset": {"type": "number", "default": 5},
"multiline": {"type": "boolean", "default": false},
"disabled": {"type": "boolean", "default": false},
"openDelay": {"type": "number", "default": 0},
"closeDelay": {"type": "number", "default": 0},
"transitionProps": {"type": "object"},
"zIndex": {"type": "number", "default": 300}
}
},
"Text": {
"description": "Text component with styling",
"props": {
"children": {"type": "any"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
"c": {"type": "string", "description": "Color"},
"fw": {"type": "number", "description": "Font weight"},
"fs": {"type": "string", "enum": ["normal", "italic"], "description": "Font style"},
"td": {"type": "string", "enum": ["none", "underline", "line-through"], "description": "Text decoration"},
"tt": {"type": "string", "enum": ["none", "capitalize", "uppercase", "lowercase"], "description": "Text transform"},
"ta": {"type": "string", "enum": ["left", "center", "right", "justify"], "description": "Text align"},
"lineClamp": {"type": "integer"},
"truncate": {"type": "boolean", "default": false},
"inherit": {"type": "boolean", "default": false},
"gradient": {"type": "object"},
"span": {"type": "boolean", "default": false},
"lh": {"type": "string", "description": "Line height"}
}
},
"Title": {
"description": "Heading component",
"props": {
"children": {"type": "any"},
"order": {"type": "integer", "enum": [1, 2, 3, 4, 5, 6], "default": 1},
"size": {"type": "string"},
"c": {"type": "string", "description": "Color"},
"ta": {"type": "string", "enum": ["left", "center", "right", "justify"]},
"td": {"type": "string", "enum": ["none", "underline", "line-through"]},
"tt": {"type": "string", "enum": ["none", "capitalize", "uppercase", "lowercase"]},
"lineClamp": {"type": "integer"},
"truncate": {"type": "boolean", "default": false}
}
},
"Stack": {
"description": "Vertical stack layout",
"props": {
"children": {"type": "any"},
"gap": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"align": {"type": "string", "enum": ["stretch", "center", "flex-start", "flex-end"], "default": "stretch"},
"justify": {"type": "string", "enum": ["flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly"], "default": "flex-start"}
}
},
"Group": {
"description": "Horizontal group layout",
"props": {
"children": {"type": "any"},
"gap": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"align": {"type": "string", "enum": ["stretch", "center", "flex-start", "flex-end"], "default": "center"},
"justify": {"type": "string", "enum": ["flex-start", "flex-end", "center", "space-between", "space-around"], "default": "flex-start"},
"grow": {"type": "boolean", "default": false},
"wrap": {"type": "string", "enum": ["wrap", "nowrap", "wrap-reverse"], "default": "wrap"},
"preventGrowOverflow": {"type": "boolean", "default": true}
}
},
"Flex": {
"description": "Flexbox container",
"props": {
"children": {"type": "any"},
"gap": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
"rowGap": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
"columnGap": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
"align": {"type": "string", "enum": ["stretch", "center", "flex-start", "flex-end", "baseline"]},
"justify": {"type": "string", "enum": ["flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly"]},
"wrap": {"type": "string", "enum": ["wrap", "nowrap", "wrap-reverse"], "default": "nowrap"},
"direction": {"type": "string", "enum": ["row", "column", "row-reverse", "column-reverse"], "default": "row"}
}
},
"Grid": {
"description": "Grid layout component",
"props": {
"children": {"type": "any"},
"columns": {"type": "integer", "default": 12},
"gutter": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"grow": {"type": "boolean", "default": false},
"justify": {"type": "string", "enum": ["flex-start", "flex-end", "center", "space-between", "space-around"], "default": "flex-start"},
"align": {"type": "string", "enum": ["stretch", "center", "flex-start", "flex-end"], "default": "stretch"},
"overflow": {"type": "string", "enum": ["visible", "hidden"], "default": "visible"}
}
},
"SimpleGrid": {
"description": "Simple grid with equal columns",
"props": {
"children": {"type": "any"},
"cols": {"type": "integer", "default": 1},
"spacing": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"verticalSpacing": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]}
}
},
"Container": {
"description": "Centered container with max-width",
"props": {
"children": {"type": "any"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"fluid": {"type": "boolean", "default": false}
}
},
"Paper": {
"description": "Paper surface component",
"props": {
"children": {"type": "any"},
"shadow": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"p": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "description": "Padding"},
"withBorder": {"type": "boolean", "default": false}
}
},
"Card": {
"description": "Card container",
"props": {
"children": {"type": "any"},
"shadow": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"padding": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"withBorder": {"type": "boolean", "default": false}
}
},
"Tabs": {
"description": "Tabbed interface",
"props": {
"children": {"type": "any"},
"value": {"type": "string"},
"defaultValue": {"type": "string"},
"orientation": {"type": "string", "enum": ["horizontal", "vertical"], "default": "horizontal"},
"variant": {"type": "string", "enum": ["default", "outline", "pills"], "default": "default"},
"color": {"type": "string", "default": "blue"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"placement": {"type": "string", "enum": ["left", "right"], "default": "left"},
"grow": {"type": "boolean", "default": false},
"inverted": {"type": "boolean", "default": false},
"keepMounted": {"type": "boolean", "default": true},
"activateTabWithKeyboard": {"type": "boolean", "default": true},
"allowTabDeactivation": {"type": "boolean", "default": false},
"autoContrast": {"type": "boolean", "default": false}
}
},
"Accordion": {
"description": "Collapsible content panels",
"props": {
"children": {"type": "any"},
"value": {"type": "any"},
"defaultValue": {"type": "any"},
"multiple": {"type": "boolean", "default": false},
"variant": {"type": "string", "enum": ["default", "contained", "filled", "separated"], "default": "default"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"chevronPosition": {"type": "string", "enum": ["left", "right"], "default": "right"},
"disableChevronRotation": {"type": "boolean", "default": false},
"transitionDuration": {"type": "number", "default": 200},
"chevronSize": {"type": "any"},
"order": {"type": "integer", "enum": [2, 3, 4, 5, 6]}
}
},
"Badge": {
"description": "Badge for status or labels",
"props": {
"children": {"type": "any"},
"color": {"type": "string", "default": "blue"},
"variant": {"type": "string", "enum": ["filled", "light", "outline", "dot", "gradient", "default", "transparent", "white"], "default": "filled"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xl"},
"fullWidth": {"type": "boolean", "default": false},
"leftSection": {"type": "any"},
"rightSection": {"type": "any"},
"autoContrast": {"type": "boolean", "default": false},
"circle": {"type": "boolean", "default": false}
}
},
"Avatar": {
"description": "User avatar image",
"props": {
"src": {"type": "string"},
"alt": {"type": "string"},
"children": {"type": "any", "description": "Fallback content"},
"color": {"type": "string", "default": "gray"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "md"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xl"},
"variant": {"type": "string", "enum": ["filled", "light", "outline", "gradient", "default", "transparent", "white"], "default": "filled"},
"autoContrast": {"type": "boolean", "default": false}
}
},
"Image": {
"description": "Image with fallback",
"props": {
"src": {"type": "string"},
"alt": {"type": "string"},
"w": {"type": "any", "description": "Width"},
"h": {"type": "any", "description": "Height"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"]},
"fit": {"type": "string", "enum": ["contain", "cover", "fill", "none", "scale-down"], "default": "cover"},
"fallbackSrc": {"type": "string"}
}
},
"Table": {
"description": "Data table component",
"props": {
"children": {"type": "any"},
"data": {"type": "object", "description": "Table data object with head, body, foot"},
"striped": {"type": "boolean", "default": false},
"highlightOnHover": {"type": "boolean", "default": false},
"withTableBorder": {"type": "boolean", "default": false},
"withColumnBorders": {"type": "boolean", "default": false},
"withRowBorders": {"type": "boolean", "default": true},
"verticalSpacing": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xs"},
"horizontalSpacing": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "xs"},
"captionSide": {"type": "string", "enum": ["top", "bottom"], "default": "bottom"},
"stickyHeader": {"type": "boolean", "default": false},
"stickyHeaderOffset": {"type": "number", "default": 0}
}
},
"AreaChart": {
"description": "Area chart for time series data",
"props": {
"data": {"type": "array", "required": true},
"dataKey": {"type": "string", "required": true, "description": "X-axis data key"},
"series": {"type": "array", "required": true, "description": "Array of {name, color} objects"},
"h": {"type": "any", "description": "Chart height"},
"w": {"type": "any", "description": "Chart width"},
"curveType": {"type": "string", "enum": ["bump", "linear", "natural", "monotone", "step", "stepBefore", "stepAfter"], "default": "monotone"},
"connectNulls": {"type": "boolean", "default": true},
"withDots": {"type": "boolean", "default": true},
"withGradient": {"type": "boolean", "default": true},
"withLegend": {"type": "boolean", "default": false},
"withTooltip": {"type": "boolean", "default": true},
"withXAxis": {"type": "boolean", "default": true},
"withYAxis": {"type": "boolean", "default": true},
"gridAxis": {"type": "string", "enum": ["x", "y", "xy", "none"], "default": "x"},
"tickLine": {"type": "string", "enum": ["x", "y", "xy", "none"], "default": "y"},
"strokeDasharray": {"type": "string"},
"fillOpacity": {"type": "number", "default": 0.2},
"splitColors": {"type": "array"},
"areaChartProps": {"type": "object"},
"type": {"type": "string", "enum": ["default", "stacked", "percent", "split"], "default": "default"}
}
},
"BarChart": {
"description": "Bar chart for categorical data",
"props": {
"data": {"type": "array", "required": true},
"dataKey": {"type": "string", "required": true},
"series": {"type": "array", "required": true},
"h": {"type": "any"},
"w": {"type": "any"},
"orientation": {"type": "string", "enum": ["horizontal", "vertical"], "default": "vertical"},
"withLegend": {"type": "boolean", "default": false},
"withTooltip": {"type": "boolean", "default": true},
"withXAxis": {"type": "boolean", "default": true},
"withYAxis": {"type": "boolean", "default": true},
"gridAxis": {"type": "string", "enum": ["x", "y", "xy", "none"], "default": "x"},
"tickLine": {"type": "string", "enum": ["x", "y", "xy", "none"], "default": "y"},
"barProps": {"type": "object"},
"type": {"type": "string", "enum": ["default", "stacked", "percent", "waterfall"], "default": "default"}
}
},
"LineChart": {
"description": "Line chart for trends",
"props": {
"data": {"type": "array", "required": true},
"dataKey": {"type": "string", "required": true},
"series": {"type": "array", "required": true},
"h": {"type": "any"},
"w": {"type": "any"},
"curveType": {"type": "string", "enum": ["bump", "linear", "natural", "monotone", "step", "stepBefore", "stepAfter"], "default": "monotone"},
"connectNulls": {"type": "boolean", "default": true},
"withDots": {"type": "boolean", "default": true},
"withLegend": {"type": "boolean", "default": false},
"withTooltip": {"type": "boolean", "default": true},
"withXAxis": {"type": "boolean", "default": true},
"withYAxis": {"type": "boolean", "default": true},
"gridAxis": {"type": "string", "enum": ["x", "y", "xy", "none"], "default": "x"},
"strokeWidth": {"type": "number", "default": 2}
}
},
"PieChart": {
"description": "Pie chart for proportions",
"props": {
"data": {"type": "array", "required": true, "description": "Array of {name, value, color} objects"},
"h": {"type": "any"},
"w": {"type": "any"},
"withLabels": {"type": "boolean", "default": false},
"withLabelsLine": {"type": "boolean", "default": true},
"withTooltip": {"type": "boolean", "default": true},
"labelsPosition": {"type": "string", "enum": ["inside", "outside"], "default": "outside"},
"labelsType": {"type": "string", "enum": ["value", "percent"], "default": "value"},
"strokeWidth": {"type": "number", "default": 1},
"strokeColor": {"type": "string"},
"startAngle": {"type": "number", "default": 0},
"endAngle": {"type": "number", "default": 360}
}
},
"DonutChart": {
"description": "Donut chart (pie with hole)",
"props": {
"data": {"type": "array", "required": true},
"h": {"type": "any"},
"w": {"type": "any"},
"withLabels": {"type": "boolean", "default": false},
"withLabelsLine": {"type": "boolean", "default": true},
"withTooltip": {"type": "boolean", "default": true},
"thickness": {"type": "number", "default": 20},
"chartLabel": {"type": "any"},
"strokeWidth": {"type": "number", "default": 1},
"strokeColor": {"type": "string"},
"startAngle": {"type": "number", "default": 0},
"endAngle": {"type": "number", "default": 360},
"paddingAngle": {"type": "number", "default": 0}
}
},
"DatePicker": {
"description": "Date picker calendar",
"props": {
"value": {"type": "string"},
"type": {"type": "string", "enum": ["default", "range", "multiple"], "default": "default"},
"defaultValue": {"type": "any"},
"allowDeselect": {"type": "boolean", "default": false},
"allowSingleDateInRange": {"type": "boolean", "default": false},
"numberOfColumns": {"type": "integer", "default": 1},
"columnsToScroll": {"type": "integer", "default": 1},
"ariaLabels": {"type": "object"},
"hideOutsideDates": {"type": "boolean", "default": false},
"hideWeekdays": {"type": "boolean", "default": false},
"weekendDays": {"type": "array", "default": [0, 6]},
"renderDay": {"type": "any"},
"minDate": {"type": "string"},
"maxDate": {"type": "string"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"}
}
},
"DatePickerInput": {
"description": "Date picker input field",
"props": {
"value": {"type": "string"},
"label": {"type": "any"},
"description": {"type": "any"},
"error": {"type": "any"},
"placeholder": {"type": "string"},
"clearable": {"type": "boolean", "default": false},
"type": {"type": "string", "enum": ["default", "range", "multiple"], "default": "default"},
"valueFormat": {"type": "string", "default": "MMMM D, YYYY"},
"size": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"radius": {"type": "string", "enum": ["xs", "sm", "md", "lg", "xl"], "default": "sm"},
"disabled": {"type": "boolean", "default": false},
"required": {"type": "boolean", "default": false},
"minDate": {"type": "string"},
"maxDate": {"type": "string"},
"popoverProps": {"type": "object"},
"dropdownType": {"type": "string", "enum": ["popover", "modal"], "default": "popover"}
}
},
"DatesProvider": {
"description": "Provider for date localization settings",
"props": {
"children": {"type": "any", "required": true},
"settings": {"type": "object", "description": "Locale and formatting settings"}
}
}
}
}

View File

@@ -0,0 +1,15 @@
# MCP SDK
mcp>=0.9.0
# Visualization
plotly>=5.18.0
dash>=2.14.0
dash-mantine-components>=2.0.0
# Utilities
python-dotenv>=1.0.0
pydantic>=2.5.0
# Testing
pytest>=7.4.3
pytest-asyncio>=0.23.0

View File

@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""
Generate DMC Component Registry from installed dash-mantine-components package.
This script introspects the installed DMC package and generates a JSON registry
file containing component definitions, props, types, and defaults.
Usage:
python generate-dmc-registry.py [--output registry/dmc_X_Y.json]
Requirements:
- dash-mantine-components must be installed
- Run from the mcp-servers/viz-platform directory
"""
import argparse
import inspect
import json
import sys
from datetime import date
from pathlib import Path
from typing import Any, Dict, List, Optional, get_type_hints
def get_dmc_version() -> Optional[str]:
"""Get installed DMC version."""
try:
from importlib.metadata import version
return version('dash-mantine-components')
except Exception:
return None
def get_component_categories() -> Dict[str, List[str]]:
"""Define component categories."""
return {
"buttons": ["Button", "ActionIcon", "CopyButton", "FileButton", "UnstyledButton"],
"inputs": [
"TextInput", "PasswordInput", "NumberInput", "Textarea",
"Select", "MultiSelect", "Checkbox", "Switch", "Radio",
"Slider", "RangeSlider", "ColorInput", "ColorPicker",
"DateInput", "DatePicker", "TimeInput"
],
"navigation": ["Anchor", "Breadcrumbs", "Burger", "NavLink", "Pagination", "Stepper", "Tabs"],
"feedback": ["Alert", "Loader", "Notification", "Progress", "RingProgress", "Skeleton"],
"overlays": ["Dialog", "Drawer", "HoverCard", "Menu", "Modal", "Popover", "Tooltip"],
"typography": ["Blockquote", "Code", "Highlight", "Mark", "Text", "Title"],
"layout": [
"AppShell", "AspectRatio", "Center", "Container", "Flex",
"Grid", "Group", "Paper", "SimpleGrid", "Space", "Stack"
],
"data": [
"Accordion", "Avatar", "Badge", "Card", "Image",
"Indicator", "Kbd", "Spoiler", "Table", "ThemeIcon", "Timeline"
]
}
def extract_prop_type(prop_info: Dict[str, Any]) -> Dict[str, Any]:
"""Extract prop type information from Dash component prop."""
result = {"type": "any"}
if 'type' not in prop_info:
return result
prop_type = prop_info['type']
if isinstance(prop_type, dict):
type_name = prop_type.get('name', 'any')
# Map Dash types to JSON schema types
type_mapping = {
'string': 'string',
'number': 'number',
'bool': 'boolean',
'boolean': 'boolean',
'array': 'array',
'object': 'object',
'node': 'any',
'element': 'any',
'any': 'any',
'func': 'any',
}
result['type'] = type_mapping.get(type_name, 'any')
# Handle enums
if type_name == 'enum' and 'value' in prop_type:
values = prop_type['value']
if isinstance(values, list):
enum_values = []
for v in values:
if isinstance(v, dict) and 'value' in v:
# Remove quotes from string values
val = v['value'].strip("'\"")
enum_values.append(val)
elif isinstance(v, str):
enum_values.append(v.strip("'\""))
if enum_values:
result['enum'] = enum_values
result['type'] = 'string'
# Handle union types
elif type_name == 'union' and 'value' in prop_type:
# For unions, just mark as any for simplicity
result['type'] = 'any'
elif isinstance(prop_type, str):
result['type'] = prop_type
return result
def extract_component_props(component_class) -> Dict[str, Any]:
"""Extract props from a Dash component class."""
props = {}
# Try to get _prop_names or similar
if hasattr(component_class, '_prop_names'):
prop_names = component_class._prop_names
else:
prop_names = []
# Try to get _type attribute for prop definitions
if hasattr(component_class, '_type'):
prop_types = getattr(component_class, '_type', {})
else:
prop_types = {}
# Get default values
if hasattr(component_class, '_default_props'):
defaults = component_class._default_props
else:
defaults = {}
# Try to extract from _prop_descriptions
if hasattr(component_class, '_prop_descriptions'):
descriptions = component_class._prop_descriptions
else:
descriptions = {}
for prop_name in prop_names:
if prop_name.startswith('_'):
continue
prop_info = {}
# Get type info if available
if prop_name in prop_types:
prop_info = extract_prop_type({'type': prop_types[prop_name]})
else:
prop_info = {'type': 'any'}
# Add default if exists
if prop_name in defaults:
prop_info['default'] = defaults[prop_name]
# Add description if exists
if prop_name in descriptions:
prop_info['description'] = descriptions[prop_name]
props[prop_name] = prop_info
return props
def generate_registry() -> Dict[str, Any]:
"""Generate the component registry from installed DMC."""
try:
import dash_mantine_components as dmc
except ImportError:
print("ERROR: dash-mantine-components not installed")
print("Install with: pip install dash-mantine-components")
sys.exit(1)
version = get_dmc_version()
categories = get_component_categories()
registry = {
"version": version,
"generated": date.today().isoformat(),
"categories": categories,
"components": {}
}
# Get all components from categories
all_components = set()
for comp_list in categories.values():
all_components.update(comp_list)
# Extract props for each component
for comp_name in sorted(all_components):
if hasattr(dmc, comp_name):
comp_class = getattr(dmc, comp_name)
try:
props = extract_component_props(comp_class)
if props:
registry["components"][comp_name] = {
"description": comp_class.__doc__ or f"{comp_name} component",
"props": props
}
print(f" Extracted: {comp_name} ({len(props)} props)")
except Exception as e:
print(f" Warning: Failed to extract {comp_name}: {e}")
else:
print(f" Warning: Component not found: {comp_name}")
return registry
def main():
parser = argparse.ArgumentParser(
description="Generate DMC component registry from installed package"
)
parser.add_argument(
'--output', '-o',
type=str,
help='Output file path (default: auto-generated based on version)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Print to stdout instead of writing file'
)
args = parser.parse_args()
print("Generating DMC Component Registry...")
print("=" * 50)
registry = generate_registry()
print("=" * 50)
print(f"Generated registry for DMC {registry['version']}")
print(f"Total components: {len(registry['components'])}")
if args.dry_run:
print(json.dumps(registry, indent=2))
return
# Determine output path
if args.output:
output_path = Path(args.output)
else:
version = registry['version']
if version:
major_minor = '_'.join(version.split('.')[:2])
output_path = Path(__file__).parent.parent / 'registry' / f'dmc_{major_minor}.json'
else:
output_path = Path(__file__).parent.parent / 'registry' / 'dmc_unknown.json'
# Create directory if needed
output_path.parent.mkdir(parents=True, exist_ok=True)
# Write registry
with open(output_path, 'w') as f:
json.dump(registry, indent=2, fp=f)
print(f"Registry written to: {output_path}")
if __name__ == "__main__":
main()

View File

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

View File

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

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

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

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

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

@@ -0,0 +1,23 @@
{
"name": "data-platform",
"version": "1.0.0",
"description": "Data engineering tools with pandas, PostgreSQL/PostGIS, and dbt integration",
"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",
"license": "MIT",
"keywords": [
"pandas",
"postgresql",
"postgis",
"dbt",
"data-engineering",
"etl",
"dataframe"
],
"commands": ["./commands/"],
"mcpServers": ["./.mcp.json"]
}

View File

@@ -0,0 +1,10 @@
{
"mcpServers": {
"data-platform": {
"type": "stdio",
"command": "${CLAUDE_PLUGIN_ROOT}/mcp-servers/data-platform/.venv/bin/python",
"args": ["-m", "mcp_server.server"],
"cwd": "${CLAUDE_PLUGIN_ROOT}/mcp-servers/data-platform"
}
}
}

View File

@@ -0,0 +1,119 @@
# data-platform Plugin
Data engineering tools with pandas, PostgreSQL/PostGIS, and dbt integration for Claude Code.
## Features
- **pandas Operations**: Load, transform, and export DataFrames with persistent data_ref system
- **PostgreSQL/PostGIS**: Database queries with connection pooling and spatial data support
- **dbt Integration**: Build tool wrapper with pre-execution validation
## Installation
This plugin is part of the leo-claude-mktplace. Install via:
```bash
# From marketplace
claude plugins install leo-claude-mktplace/data-platform
# Setup MCP server venv
cd ~/.claude/plugins/marketplaces/leo-claude-mktplace/mcp-servers/data-platform
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
## Configuration
### PostgreSQL (Optional)
Create `~/.config/claude/postgres.env`:
```env
POSTGRES_URL=postgresql://user:password@host:5432/database
```
### dbt (Optional)
Add to project `.env`:
```env
DBT_PROJECT_DIR=/path/to/dbt/project
DBT_PROFILES_DIR=~/.dbt
```
## Commands
| Command | Description |
|---------|-------------|
| `/initial-setup` | Interactive setup wizard for PostgreSQL and dbt configuration |
| `/ingest` | Load data from files or database |
| `/profile` | Generate data profile and statistics |
| `/schema` | Show database/DataFrame schema |
| `/explain` | Explain dbt model lineage |
| `/lineage` | Visualize data dependencies |
| `/run` | Execute dbt models |
## Agents
| Agent | Description |
|-------|-------------|
| `data-ingestion` | Data loading and transformation specialist |
| `data-analysis` | Exploration and profiling specialist |
## data_ref System
All DataFrame operations use a `data_ref` system for persistence:
```
# Load returns a reference
read_csv("data.csv") → {"data_ref": "sales_data"}
# Use reference in subsequent operations
filter("sales_data", "amount > 100") → {"data_ref": "sales_data_filtered"}
describe("sales_data_filtered") → {statistics}
```
## Example Workflow
```
/ingest data/sales.csv
# → Loaded 50,000 rows as "sales_data"
/profile sales_data
# → Statistical summary, null counts, quality assessment
/schema orders
# → Column names, types, constraints
/lineage fct_orders
# → Dependency graph showing upstream/downstream models
/run dim_customers
# → Pre-validates then executes dbt model
```
## Tools Summary
### pandas (14 tools)
`read_csv`, `read_parquet`, `read_json`, `to_csv`, `to_parquet`, `describe`, `head`, `tail`, `filter`, `select`, `groupby`, `join`, `list_data`, `drop_data`
### PostgreSQL (6 tools)
`pg_connect`, `pg_query`, `pg_execute`, `pg_tables`, `pg_columns`, `pg_schemas`
### PostGIS (4 tools)
`st_tables`, `st_geometry_type`, `st_srid`, `st_extent`
### dbt (8 tools)
`dbt_parse`, `dbt_run`, `dbt_test`, `dbt_build`, `dbt_compile`, `dbt_ls`, `dbt_docs_generate`, `dbt_lineage`
## Memory Management
- Default limit: 100,000 rows per DataFrame
- Configure via `DATA_PLATFORM_MAX_ROWS` environment variable
- Use `chunk_size` parameter for large files
- Monitor with `list_data` tool
## SessionStart Hook
On session start, the plugin checks PostgreSQL connectivity and displays a warning if unavailable. This is non-blocking - pandas and dbt tools remain available.

View File

@@ -0,0 +1,98 @@
# Data Analysis Agent
You are a data analysis specialist. Your role is to help users explore, profile, and understand their data.
## Capabilities
- Profile datasets with statistical summaries
- Explore database schemas and structures
- Analyze dbt model lineage and dependencies
- Provide data quality assessments
- Generate insights and recommendations
## Available Tools
### Data Exploration
- `describe` - Statistical summary
- `head` - Preview first rows
- `tail` - Preview last rows
- `list_data` - List available DataFrames
### Database Exploration
- `pg_connect` - Check database connection
- `pg_tables` - List all tables
- `pg_columns` - Get column details
- `pg_schemas` - List schemas
### PostGIS Exploration
- `st_tables` - List spatial tables
- `st_geometry_type` - Get geometry type
- `st_srid` - Get coordinate system
- `st_extent` - Get bounding box
### dbt Analysis
- `dbt_lineage` - Model dependencies
- `dbt_ls` - List resources
- `dbt_compile` - View compiled SQL
- `dbt_docs_generate` - Generate docs
## Workflow Guidelines
1. **Understand the question**:
- What does the user want to know?
- What data is available?
- What level of detail is needed?
2. **Explore the data**:
- Start with `list_data` or `pg_tables`
- Get schema info with `describe` or `pg_columns`
- Preview with `head` to understand content
3. **Profile thoroughly**:
- Use `describe` for statistics
- Check for nulls, outliers, patterns
- Note data quality issues
4. **Analyze dependencies** (for dbt):
- Use `dbt_lineage` to trace data flow
- Understand transformations
- Identify critical paths
5. **Provide insights**:
- Summarize findings clearly
- Highlight potential issues
- Recommend next steps
## Analysis Patterns
### Data Quality Check
1. `describe` - Get statistics
2. Check null percentages
3. Identify outliers (min/max vs mean)
4. Flag suspicious patterns
### Schema Comparison
1. `pg_columns` - Get table A schema
2. `pg_columns` - Get table B schema
3. Compare column names, types
4. Identify mismatches
### Lineage Analysis
1. `dbt_lineage` - Get model graph
2. Trace upstream sources
3. Identify downstream impact
4. Document critical path
## Example Interactions
**User**: What's in the sales_data DataFrame?
**Agent**: Uses `describe`, `head`, explains columns, statistics, patterns
**User**: What tables are in the database?
**Agent**: Uses `pg_tables`, shows list with column counts
**User**: How does the dim_customers model work?
**Agent**: Uses `dbt_lineage`, `dbt_compile`, explains dependencies and SQL
**User**: Is there any spatial data?
**Agent**: Uses `st_tables`, shows PostGIS tables with geometry types

View File

@@ -0,0 +1,81 @@
# Data Ingestion Agent
You are a data ingestion specialist. Your role is to help users load, transform, and prepare data for analysis.
## Capabilities
- Load data from CSV, Parquet, JSON files
- Query PostgreSQL databases
- Transform data using filter, select, groupby, join operations
- Export data to various formats
- Handle large datasets with chunking
## Available Tools
### File Operations
- `read_csv` - Load CSV files with optional chunking
- `read_parquet` - Load Parquet files
- `read_json` - Load JSON/JSONL files
- `to_csv` - Export to CSV
- `to_parquet` - Export to Parquet
### Data Transformation
- `filter` - Filter rows by condition
- `select` - Select specific columns
- `groupby` - Group and aggregate
- `join` - Join two DataFrames
### Database Operations
- `pg_query` - Execute SELECT queries
- `pg_execute` - Execute INSERT/UPDATE/DELETE
- `pg_tables` - List available tables
### Management
- `list_data` - List all stored DataFrames
- `drop_data` - Remove DataFrame from store
## Workflow Guidelines
1. **Understand the data source**:
- Ask about file location/format
- For database, understand table structure
- Clarify any filters or transformations needed
2. **Load data efficiently**:
- Use appropriate reader for file format
- For large files (>100k rows), use chunking
- Name DataFrames meaningfully
3. **Transform as needed**:
- Apply filters early to reduce data size
- Select only needed columns
- Join related datasets
4. **Validate results**:
- Check row counts after transformations
- Verify data types are correct
- Preview results with `head`
5. **Store with meaningful names**:
- Use descriptive data_ref names
- Document the source and transformations
## Memory Management
- Default row limit: 100,000 rows
- For larger datasets, suggest:
- Filtering before loading
- Using chunk_size parameter
- Aggregating to reduce size
- Storing to Parquet for efficient retrieval
## Example Interactions
**User**: Load the sales data from data/sales.csv
**Agent**: Uses `read_csv` to load, reports data_ref, row count, columns
**User**: Filter to only Q4 2024 sales
**Agent**: Uses `filter` with date condition, stores filtered result
**User**: Join with customer data
**Agent**: Uses `join` to combine, validates result counts

View File

@@ -0,0 +1,90 @@
# data-platform Plugin - CLAUDE.md Integration
Add this section to your project's CLAUDE.md to enable data-platform plugin features.
## Suggested CLAUDE.md Section
```markdown
## Data Platform Integration
This project uses the data-platform plugin for data engineering workflows.
### Configuration
**PostgreSQL**: Credentials in `~/.config/claude/postgres.env`
**dbt**: Project path auto-detected from `dbt_project.yml`
### Available Commands
| Command | Purpose |
|---------|---------|
| `/ingest` | Load data from files or database |
| `/profile` | Generate statistical profile |
| `/schema` | Show schema information |
| `/explain` | Explain dbt model |
| `/lineage` | Show data lineage |
| `/run` | Execute dbt models |
### data_ref Convention
DataFrames are stored with references. Use meaningful names:
- `raw_*` for source data
- `stg_*` for staged/cleaned data
- `dim_*` for dimension tables
- `fct_*` for fact tables
- `rpt_*` for reports
### dbt Workflow
1. Always validate before running: `/run` includes automatic `dbt_parse`
2. For dbt 1.9+, check for deprecated syntax before commits
3. Use `/lineage` to understand impact of changes
### Database Access
PostgreSQL tools require POSTGRES_URL configuration:
- Read-only queries: `pg_query`
- Write operations: `pg_execute`
- Schema exploration: `pg_tables`, `pg_columns`
PostGIS spatial data:
- List spatial tables: `st_tables`
- Check geometry: `st_geometry_type`, `st_srid`, `st_extent`
```
## Environment Variables
Add to project `.env` if needed:
```env
# dbt configuration
DBT_PROJECT_DIR=./transform
DBT_PROFILES_DIR=~/.dbt
# Memory limits
DATA_PLATFORM_MAX_ROWS=100000
```
## Typical Workflows
### Data Exploration
```
/ingest data/raw_customers.csv
/profile raw_customers
/schema
```
### ETL Development
```
/schema orders # Understand source
/explain stg_orders # Understand transformation
/run stg_orders # Test the model
/lineage fct_orders # Check downstream impact
```
### Database Analysis
```
/schema # List all tables
pg_columns orders # Detailed schema
st_tables # Find spatial data
```

View File

@@ -0,0 +1,44 @@
# /explain - dbt Model Explanation
Explain a dbt model's purpose, dependencies, and SQL logic.
## Usage
```
/explain <model_name>
```
## Workflow
1. **Get model info**:
- Use `dbt_lineage` to get model metadata
- Extract description, tags, materialization
2. **Analyze dependencies**:
- Show upstream models (what this depends on)
- Show downstream models (what depends on this)
- Visualize as dependency tree
3. **Compile SQL**:
- Use `dbt_compile` to get rendered SQL
- Explain key transformations
4. **Report**:
- Model purpose (from description)
- Materialization strategy
- Dependency graph
- Key SQL logic explained
## Examples
```
/explain dim_customers
/explain fct_orders
```
## Available Tools
Use these MCP tools:
- `dbt_lineage` - Get model dependencies
- `dbt_compile` - Get compiled SQL
- `dbt_ls` - List related resources

View File

@@ -0,0 +1,44 @@
# /ingest - Data Ingestion
Load data from files or database into the data platform.
## Usage
```
/ingest [source]
```
## Workflow
1. **Identify data source**:
- If source is a file path, determine format (CSV, Parquet, JSON)
- If source is "db" or a table name, query PostgreSQL
2. **Load data**:
- For files: Use `read_csv`, `read_parquet`, or `read_json`
- For database: Use `pg_query` with appropriate SELECT
3. **Validate**:
- Check row count against limits
- If exceeds 100k rows, suggest chunking or filtering
4. **Report**:
- Show data_ref, row count, columns, and memory usage
- Preview first few rows
## Examples
```
/ingest data/sales.csv
/ingest data/customers.parquet
/ingest "SELECT * FROM orders WHERE created_at > '2024-01-01'"
```
## Available Tools
Use these MCP tools:
- `read_csv` - Load CSV files
- `read_parquet` - Load Parquet files
- `read_json` - Load JSON/JSONL files
- `pg_query` - Query PostgreSQL database
- `list_data` - List loaded DataFrames

View File

@@ -0,0 +1,231 @@
---
description: Interactive setup wizard for data-platform plugin - configures MCP server and optional PostgreSQL/dbt
---
# Data Platform Setup Wizard
This command sets up the data-platform plugin with pandas, PostgreSQL, and dbt integration.
## Important Context
- **This command uses Bash, Read, Write, and AskUserQuestion tools** - NOT MCP tools
- **MCP tools won't work until after setup + session restart**
- **PostgreSQL and dbt are optional** - pandas tools work without them
---
## Phase 1: Environment Validation
### Step 1.1: Check Python Version
```bash
python3 --version
```
Requires Python 3.10+. If below, stop setup and inform user.
### Step 1.2: Check for Required Libraries
```bash
python3 -c "import sys; print(f'Python {sys.version_info.major}.{sys.version_info.minor}')"
```
---
## Phase 2: MCP Server Setup
### Step 2.1: Locate Data Platform MCP Server
The MCP server should be at the marketplace root:
```bash
# If running from installed marketplace
ls -la ~/.claude/plugins/marketplaces/leo-claude-mktplace/mcp-servers/data-platform/ 2>/dev/null || echo "NOT_FOUND_INSTALLED"
# If running from source
ls -la ~/claude-plugins-work/mcp-servers/data-platform/ 2>/dev/null || echo "NOT_FOUND_SOURCE"
```
Determine the correct path based on which exists.
### Step 2.2: Check Virtual Environment
```bash
ls -la /path/to/mcp-servers/data-platform/.venv/bin/python 2>/dev/null && echo "VENV_EXISTS" || echo "VENV_MISSING"
```
### Step 2.3: Create Virtual Environment (if missing)
```bash
cd /path/to/mcp-servers/data-platform && python3 -m venv .venv && source .venv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt && deactivate
```
**Note:** This may take a few minutes due to pandas, pyarrow, and dbt dependencies.
---
## Phase 3: PostgreSQL Configuration (Optional)
### Step 3.1: Ask About PostgreSQL
Use AskUserQuestion:
- Question: "Do you want to configure PostgreSQL database access?"
- Header: "PostgreSQL"
- Options:
- "Yes, I have a PostgreSQL database"
- "No, I'll only use pandas/dbt tools"
**If user chooses "No":** Skip to Phase 4.
### Step 3.2: Create Config Directory
```bash
mkdir -p ~/.config/claude
```
### Step 3.3: Check PostgreSQL Configuration
```bash
cat ~/.config/claude/postgres.env 2>/dev/null || echo "FILE_NOT_FOUND"
```
**If file exists with valid URL:** Skip to Step 3.6.
**If missing or has placeholders:** Continue.
### Step 3.4: Gather PostgreSQL Information
Use AskUserQuestion:
- Question: "What is your PostgreSQL connection URL format?"
- Header: "DB Format"
- Options:
- "Standard: postgresql://user:pass@host:5432/db"
- "PostGIS: postgresql://user:pass@host:5432/db (with PostGIS extension)"
- "Other (I'll provide the full URL)"
Ask user to provide the connection URL.
### Step 3.5: Create Configuration File
```bash
cat > ~/.config/claude/postgres.env << 'EOF'
# PostgreSQL Configuration
# Generated by data-platform /initial-setup
POSTGRES_URL=<USER_PROVIDED_URL>
EOF
chmod 600 ~/.config/claude/postgres.env
```
### Step 3.6: Test PostgreSQL Connection (if configured)
```bash
source ~/.config/claude/postgres.env && python3 -c "
import asyncio
import asyncpg
async def test():
try:
conn = await asyncpg.connect('$POSTGRES_URL', timeout=5)
ver = await conn.fetchval('SELECT version()')
await conn.close()
print(f'SUCCESS: {ver.split(\",\")[0]}')
except Exception as e:
print(f'FAILED: {e}')
asyncio.run(test())
"
```
Report result:
- SUCCESS: Connection works
- FAILED: Show error and suggest fixes
---
## Phase 4: dbt Configuration (Optional)
### Step 4.1: Ask About dbt
Use AskUserQuestion:
- Question: "Do you use dbt for data transformations in your projects?"
- Header: "dbt"
- Options:
- "Yes, I have dbt projects"
- "No, I don't use dbt"
**If user chooses "No":** Skip to Phase 5.
### Step 4.2: dbt Discovery
dbt configuration is **project-level** (not system-level). The plugin auto-detects dbt projects by looking for `dbt_project.yml`.
Inform user:
```
dbt projects are detected automatically when you work in a directory
containing dbt_project.yml.
If your dbt project is in a subdirectory, you can set DBT_PROJECT_DIR
in your project's .env file:
DBT_PROJECT_DIR=./transform
DBT_PROFILES_DIR=~/.dbt
```
### Step 4.3: Check dbt Installation
```bash
dbt --version 2>/dev/null || echo "DBT_NOT_FOUND"
```
**If not found:** Inform user that dbt CLI tools require dbt-core to be installed globally or in the project.
---
## Phase 5: Validation
### Step 5.1: Verify MCP Server
```bash
cd /path/to/mcp-servers/data-platform && .venv/bin/python -c "from mcp_server.server import DataPlatformMCPServer; print('MCP Server OK')"
```
### Step 5.2: Summary
```
╔════════════════════════════════════════════════════════════╗
║ DATA-PLATFORM SETUP COMPLETE ║
╠════════════════════════════════════════════════════════════╣
║ MCP Server: ✓ Ready ║
║ pandas Tools: ✓ Available (14 tools) ║
║ PostgreSQL Tools: [✓/✗] [Status based on config] ║
║ PostGIS Tools: [✓/✗] [Status based on PostGIS] ║
║ dbt Tools: [✓/✗] [Status based on discovery] ║
╚════════════════════════════════════════════════════════════╝
```
### Step 5.3: Session Restart Notice
---
**⚠️ Session Restart Required**
Restart your Claude Code session for MCP tools to become available.
**After restart, you can:**
- Run `/ingest` to load data from files or database
- Run `/profile` to analyze DataFrame statistics
- Run `/schema` to explore database/DataFrame schema
- Run `/run` to execute dbt models (if configured)
- Run `/lineage` to view dbt model dependencies
---
## Memory Limits
The data-platform plugin has a default row limit of 100,000 rows per DataFrame. For larger datasets:
- Use chunked processing (`chunk_size` parameter)
- Filter data before loading
- Store to Parquet for efficient re-loading
You can override the limit by setting in your project `.env`:
```
DATA_PLATFORM_MAX_ROWS=500000
```

View File

@@ -0,0 +1,60 @@
# /lineage - Data Lineage Visualization
Show data lineage for dbt models or database tables.
## Usage
```
/lineage <model_name> [--depth N]
```
## Workflow
1. **Get lineage data**:
- Use `dbt_lineage` for dbt models
- For database tables, trace through dbt manifest
2. **Build lineage graph**:
- Identify all upstream sources
- Identify all downstream consumers
- Note materialization at each node
3. **Visualize**:
- ASCII art dependency tree
- List format with indentation
- Show depth levels
4. **Report**:
- Full dependency chain
- Critical path identification
- Refresh implications
## Examples
```
/lineage dim_customers
/lineage fct_orders --depth 3
```
## Output Format
```
Sources:
└── raw_customers (source)
└── raw_orders (source)
dim_customers (table)
├── upstream:
│ └── stg_customers (view)
│ └── raw_customers (source)
└── downstream:
└── fct_orders (incremental)
└── rpt_customer_lifetime (table)
```
## Available Tools
Use these MCP tools:
- `dbt_lineage` - Get model dependencies
- `dbt_ls` - List dbt resources
- `dbt_docs_generate` - Generate full manifest

View File

@@ -0,0 +1,44 @@
# /profile - Data Profiling
Generate statistical profile and quality report for a DataFrame.
## Usage
```
/profile <data_ref>
```
## Workflow
1. **Get data reference**:
- If no data_ref provided, use `list_data` to show available options
- Validate the data_ref exists
2. **Generate profile**:
- Use `describe` for statistical summary
- Analyze null counts, unique values, data types
3. **Quality assessment**:
- Identify columns with high null percentage
- Flag potential data quality issues
- Suggest cleaning operations if needed
4. **Report**:
- Summary statistics per column
- Data type distribution
- Memory usage
- Quality score
## Examples
```
/profile sales_data
/profile df_a1b2c3d4
```
## Available Tools
Use these MCP tools:
- `describe` - Get statistical summary
- `head` - Preview first rows
- `list_data` - List available DataFrames

View File

@@ -0,0 +1,55 @@
# /run - Execute dbt Models
Run dbt models with automatic pre-validation.
## Usage
```
/run [model_selection] [--full-refresh]
```
## Workflow
1. **Pre-validation** (MANDATORY):
- Use `dbt_parse` to validate project
- Check for deprecated syntax (dbt 1.9+)
- If validation fails, show errors and STOP
2. **Execute models**:
- Use `dbt_run` with provided selection
- Monitor progress and capture output
3. **Report results**:
- Success/failure status per model
- Execution time
- Row counts where available
- Any warnings or errors
## Examples
```
/run # Run all models
/run dim_customers # Run specific model
/run +fct_orders # Run model and its upstream
/run tag:daily # Run models with tag
/run --full-refresh # Rebuild incremental models
```
## Selection Syntax
| Pattern | Meaning |
|---------|---------|
| `model_name` | Run single model |
| `+model_name` | Run model and upstream |
| `model_name+` | Run model and downstream |
| `+model_name+` | Run model with all deps |
| `tag:name` | Run by tag |
| `path:models/staging` | Run by path |
## Available Tools
Use these MCP tools:
- `dbt_parse` - Pre-validation (ALWAYS RUN FIRST)
- `dbt_run` - Execute models
- `dbt_build` - Run + test
- `dbt_test` - Run tests only

View File

@@ -0,0 +1,48 @@
# /schema - Schema Exploration
Display schema information for database tables or DataFrames.
## Usage
```
/schema [table_name | data_ref]
```
## Workflow
1. **Determine target**:
- If argument is a loaded data_ref, show DataFrame schema
- If argument is a table name, query database schema
- If no argument, list all available tables and DataFrames
2. **For DataFrames**:
- Use `describe` to get column info
- Show dtypes, null counts, sample values
3. **For database tables**:
- Use `pg_columns` for column details
- Use `st_tables` to check for PostGIS columns
- Show constraints and indexes if available
4. **Report**:
- Column name, type, nullable, default
- For PostGIS: geometry type, SRID
- For DataFrames: pandas dtype, null percentage
## Examples
```
/schema # List all tables and DataFrames
/schema customers # Show table schema
/schema sales_data # Show DataFrame schema
```
## Available Tools
Use these MCP tools:
- `pg_tables` - List database tables
- `pg_columns` - Get column info
- `pg_schemas` - List schemas
- `st_tables` - List PostGIS tables
- `describe` - Get DataFrame info
- `list_data` - List DataFrames

View File

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

View File

@@ -0,0 +1,54 @@
#!/bin/bash
# data-platform startup check hook
# Checks for common issues at session start
# All output MUST have [data-platform] prefix
PREFIX="[data-platform]"
# Check if MCP venv exists
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$(realpath "$0")")")}"
VENV_PATH="$PLUGIN_ROOT/mcp-servers/data-platform/.venv/bin/python"
if [[ ! -f "$VENV_PATH" ]]; then
echo "$PREFIX MCP venv missing - run /initial-setup or setup.sh"
exit 0
fi
# Check PostgreSQL configuration (optional - just warn if configured but failing)
POSTGRES_CONFIG="$HOME/.config/claude/postgres.env"
if [[ -f "$POSTGRES_CONFIG" ]]; then
source "$POSTGRES_CONFIG"
if [[ -n "${POSTGRES_URL:-}" ]]; then
# Quick connection test (5 second timeout)
RESULT=$("$VENV_PATH" -c "
import asyncio
import sys
async def test():
try:
import asyncpg
conn = await asyncpg.connect('$POSTGRES_URL', timeout=5)
await conn.close()
return 'OK'
except Exception as e:
return f'FAIL: {e}'
print(asyncio.run(test()))
" 2>/dev/null || echo "FAIL: asyncpg not installed")
if [[ "$RESULT" == "OK" ]]; then
# PostgreSQL OK - say nothing
:
elif [[ "$RESULT" == *"FAIL"* ]]; then
echo "$PREFIX PostgreSQL connection failed - check POSTGRES_URL"
fi
fi
fi
# Check dbt project (if in a project with dbt_project.yml)
if [[ -f "dbt_project.yml" ]] || [[ -f "transform/dbt_project.yml" ]]; then
if ! command -v dbt &> /dev/null; then
echo "$PREFIX dbt CLI not found - dbt tools unavailable"
fi
fi
# All checks passed - say nothing
exit 0

View File

@@ -0,0 +1 @@
../../../mcp-servers/data-platform

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# doc-guardian notification hook # doc-guardian notification hook
# Outputs a single notification for config file changes, nothing otherwise # Tracks documentation dependencies and queues updates
# This is a command hook - guaranteed not to block workflow # This is a command hook - guaranteed not to block workflow
# Read tool input from stdin (JSON with file_path) # Read tool input from stdin (JSON with file_path)
@@ -14,10 +14,45 @@ if [ -z "$FILE_PATH" ]; then
exit 0 exit 0
fi fi
# Check if file is in a config directory (commands/, agents/, skills/, hooks/) # Define documentation dependency mappings
if echo "$FILE_PATH" | grep -qE '/(commands|agents|skills|hooks)/'; then # When these directories change, these docs need updating
echo "[doc-guardian] Config file modified. Run /doc-sync when ready." declare -A DOC_DEPS
DOC_DEPS["commands"]="docs/COMMANDS-CHEATSHEET.md README.md"
DOC_DEPS["agents"]="README.md CLAUDE.md"
DOC_DEPS["hooks"]="docs/COMMANDS-CHEATSHEET.md README.md"
DOC_DEPS["skills"]="README.md"
DOC_DEPS[".claude-plugin"]="CLAUDE.md .claude-plugin/marketplace.json"
DOC_DEPS["mcp-servers"]="docs/COMMANDS-CHEATSHEET.md CLAUDE.md"
# Check which config directory was modified
MODIFIED_TYPE=""
for dir in commands agents hooks skills .claude-plugin mcp-servers; do
if echo "$FILE_PATH" | grep -qE "/${dir}/|^${dir}/"; then
MODIFIED_TYPE="$dir"
break
fi
done
# Exit silently if not a tracked config directory
if [ -z "$MODIFIED_TYPE" ]; then
exit 0
fi fi
# Exit silently for all other files (no output = no blocking) # Get the dependent docs
DEPENDENT_DOCS="${DOC_DEPS[$MODIFIED_TYPE]}"
# Queue file for tracking pending updates
QUEUE_FILE="${CLAUDE_PROJECT_ROOT:-.}/.doc-guardian-queue"
# Add to queue (create if doesn't exist, append if does)
{
echo "$(date +%Y-%m-%dT%H:%M:%S) | $MODIFIED_TYPE | $FILE_PATH | $DEPENDENT_DOCS"
} >> "$QUEUE_FILE" 2>/dev/null || true
# Count pending updates
PENDING_COUNT=$(wc -l < "$QUEUE_FILE" 2>/dev/null | tr -d ' ' || echo "1")
# Output notification with specific docs that need updating
echo "[doc-guardian] $MODIFIED_TYPE changed → update needed: $DEPENDENT_DOCS (${PENDING_COUNT} pending)"
exit 0 exit 0

View File

@@ -114,6 +114,17 @@ Check current sprint progress.
**When to use:** Daily standup, progress check, deciding what to work on next **When to use:** Daily standup, progress check, deciding what to work on next
### `/proposal-status`
View proposal and implementation hierarchy.
**What it does:**
- Lists all change proposals from Gitea Wiki
- Shows implementations under each proposal with status
- Displays linked issues and lessons learned
- Tree-style formatted output
**When to use:** Review progress on multi-sprint features, track proposal lifecycle
### `/sprint-close` ### `/sprint-close`
Complete sprint and capture lessons learned. Complete sprint and capture lessons learned.
@@ -468,6 +479,7 @@ projman/
│ ├── sprint-start.md │ ├── sprint-start.md
│ ├── sprint-status.md │ ├── sprint-status.md
│ ├── sprint-close.md │ ├── sprint-close.md
│ ├── proposal-status.md
│ ├── labels-sync.md │ ├── labels-sync.md
│ ├── initial-setup.md │ ├── initial-setup.md
│ ├── project-init.md │ ├── project-init.md

View File

@@ -357,6 +357,11 @@ Let's capture lessons learned. I'll ask some questions:
```markdown ```markdown
# Sprint {N} - {Clear Title} # Sprint {N} - {Clear Title}
## Metadata
- **Implementation:** [Change VXX.X.X (Impl N)](wiki-link)
- **Issues:** #XX, #XX
- **Sprint:** Sprint N
## Context ## Context
Brief background - what were you doing? Brief background - what were you doing?
@@ -373,17 +378,131 @@ How can future sprints avoid this or optimize it?
technology, component, issue-type, pattern technology, component, issue-type, pattern
``` ```
**IMPORTANT:** Always include the Metadata section with implementation link for traceability.
**D. Save to Gitea Wiki** **D. Save to Gitea Wiki**
Include the implementation reference in lesson content:
``` ```
create_lesson( create_lesson(
title="Sprint 18 - Claude Code Infinite Loop on Validation Errors", title="Sprint 18 - Claude Code Infinite Loop on Validation Errors",
content="[Full lesson content]", content="""
# Sprint 18 - Claude Code Infinite Loop on Validation Errors
## Metadata
- **Implementation:** [Change V1.2.0 (Impl 1)](wiki-link)
- **Issues:** #45, #46
- **Sprint:** Sprint 18
## Context
[Lesson context...]
## Problem
[What went wrong...]
## Solution
[How it was solved...]
## Prevention
[How to avoid in future...]
## Tags
testing, claude-code, validation, python
""",
tags=["testing", "claude-code", "validation", "python"], tags=["testing", "claude-code", "validation", "python"],
category="sprints" category="sprints"
) )
``` ```
**E. Git Operations** **E. Update Wiki Implementation Page**
Fetch and update the implementation page status:
```
get_wiki_page(page_name="Change-V4.1.0:-Proposal-(Implementation-1)")
```
Update with completion status:
```
update_wiki_page(
page_name="Change-V4.1.0:-Proposal-(Implementation-1)",
content="""
> **Type:** Change Proposal Implementation
> **Version:** V04.1.0
> **Status:** Implemented ✅
> **Date:** 2026-01-26
> **Completed:** 2026-01-28
> **Origin:** [Proposal](wiki-link)
> **Sprint:** Sprint 17
# Implementation Details
[Original content...]
## Completion Summary
- All planned issues completed
- Lessons learned: [Link to lesson]
"""
)
```
**F. Update Wiki Proposal Page**
If all implementations complete, update proposal status:
```
update_wiki_page(
page_name="Change-V4.1.0:-Proposal",
content="""
> **Type:** Change Proposal
> **Version:** V04.1.0
> **Status:** Implemented ✅
> **Date:** 2026-01-26
# Feature Title
[Original content...]
## Implementations
- [Implementation 1](link) - ✅ Completed (Sprint 17)
"""
)
```
**G. Update CHANGELOG (MANDATORY)**
Add all sprint changes to `[Unreleased]` section:
```markdown
## [Unreleased]
### Added
- **projman:** New feature description
- **plugin-name:** Another feature
### Changed
- **projman:** Modified behavior
### Fixed
- **plugin-name:** Bug fix description
```
**IMPORTANT:** Never skip this step. Every sprint must update CHANGELOG.
**H. Version Check**
Run `/suggest-version` to analyze CHANGELOG and recommend version bump:
```
/suggest-version
```
If release is warranted:
```bash
./scripts/release.sh X.Y.Z
```
This ensures version numbers stay in sync:
- README.md title
- .claude-plugin/marketplace.json
- Git tags
- CHANGELOG.md section header
**I. Git Operations**
Offer to handle git cleanup: Offer to handle git cleanup:
``` ```
@@ -418,7 +537,9 @@ Would you like me to handle git operations?
**Lessons Learned Tools (Gitea Wiki):** **Lessons Learned Tools (Gitea Wiki):**
- `search_lessons(query, tags, limit)` - Find relevant past lessons - `search_lessons(query, tags, limit)` - Find relevant past lessons
- `create_lesson(title, content, tags, category)` - Save new lesson - `create_lesson(title, content, tags, category)` - Save new lesson
- `get_wiki_page(page_name)` - Fetch specific pages - `get_wiki_page(page_name)` - Fetch implementation/proposal pages
- `update_wiki_page(page_name, content)` - Update implementation/proposal status
- `list_wiki_pages()` - List all wiki pages
**Validation Tools:** **Validation Tools:**
- `get_branch_protection(branch)` - Check merge rules - `get_branch_protection(branch)` - Check merge rules
@@ -455,6 +576,10 @@ Would you like me to handle git operations?
8. **Auto-check subtasks** - Mark issue subtasks complete on close 8. **Auto-check subtasks** - Mark issue subtasks complete on close
9. **Track meticulously** - Update issues immediately, document blockers 9. **Track meticulously** - Update issues immediately, document blockers
10. **Capture lessons** - At sprint close, interview thoroughly 10. **Capture lessons** - At sprint close, interview thoroughly
11. **Update wiki status** - At sprint close, update implementation and proposal pages
12. **Link lessons to wiki** - Include lesson links in implementation completion summary
13. **Update CHANGELOG** - MANDATORY at sprint close, never skip
14. **Run suggest-version** - Check if release is needed after CHANGELOG update
## Your Mission ## Your Mission

View File

@@ -122,23 +122,34 @@ get_labels(repo="owner/repo")
- Use `create_label` to create them - Use `create_label` to create them
- Report which labels were created - Report which labels were created
### 4. docs/changes/ Folder Check ### 4. Input Source Detection
Verify the project has a `docs/changes/` folder for sprint input files. Detect where the planning input is coming from:
**If folder exists:** | Source | Detection | Action |
- Check for relevant change files for current sprint |--------|-----------|--------|
- Reference these files during planning | **Local file** | `docs/changes/*.md` exists | Parse frontmatter, migrate to wiki, delete local |
| **Existing wiki** | `Change VXX.X.X: Proposal` exists | Use as-is, create new implementation page |
| **Conversation** | Neither file nor wiki exists | Create wiki from discussion context |
**If folder does NOT exist:** **Input File Format** (if using local file):
- Prompt user: "Your project doesn't have a `docs/changes/` folder. This folder stores sprint planning inputs and decisions. Would you like me to create it?" ```yaml
- If user agrees, create the folder structure ---
version: "4.1.0" # or "sprint-17" for internal work
title: "Feature Name"
plugin: plugin-name # optional
type: feature # feature | bugfix | refactor | infra
---
**If sprint starts with discussion but no input file:** # Feature Description
- Capture the discussion outputs [Free-form content...]
- Create a change file: `docs/changes/sprint-XX-description.md` ```
- Structure the file to meet Claude Code standards (concise, focused, actionable)
- Then proceed with sprint planning using that file **Detection Steps:**
1. Check for `docs/changes/*.md` files with valid frontmatter
2. Use `list_wiki_pages()` to check for existing proposal
3. If neither found, use conversation context
4. If ambiguous (multiple sources), ask user which to use
## Your Responsibilities ## Your Responsibilities
@@ -161,7 +172,30 @@ Great! Let me ask a few questions to understand the scope:
5. Should this integrate with existing systems? 5. Should this integrate with existing systems?
``` ```
### 2. Search Relevant Lessons Learned ### 2. Detect Input Source
Before proceeding, identify where the planning input is:
```
# Check for local files
ls docs/changes/*.md
# Check for existing wiki proposal
list_wiki_pages() → filter for "Change V" prefix
```
**Report to user:**
```
Input source detected:
✓ Found: docs/changes/v4.1.0-wiki-planning.md
- Version: 4.1.0
- Title: Wiki-Based Planning Workflow
- Type: feature
I'll use this as the planning input. Proceed? (y/n)
```
### 3. Search Relevant Lessons Learned
**ALWAYS search for past lessons** before planning: **ALWAYS search for past lessons** before planning:
@@ -192,7 +226,59 @@ I searched previous sprint lessons and found these relevant insights:
I'll keep these in mind while planning this sprint. I'll keep these in mind while planning this sprint.
``` ```
### 3. Architecture Analysis ### 4. Create Wiki Proposal and Implementation Pages
After detecting input and searching lessons, create the wiki structure:
**Create/Update Proposal Page:**
```
# If no proposal exists for this version:
create_wiki_page(
title="Change V4.1.0: Proposal",
content="""
> **Type:** Change Proposal
> **Version:** V04.1.0
> **Plugin:** projman
> **Status:** In Progress
> **Date:** 2026-01-26
# Feature Title
[Content migrated from input source]
## Implementations
- [Implementation 1](link) - Current sprint
"""
)
```
**Create Implementation Page:**
```
create_wiki_page(
title="Change V4.1.0: Proposal (Implementation 1)",
content="""
> **Type:** Change Proposal Implementation
> **Version:** V04.1.0
> **Status:** In Progress
> **Date:** 2026-01-26
> **Origin:** [Proposal](wiki-link)
> **Sprint:** Sprint 17
# Implementation Details
[Technical details, scope, approach]
"""
)
```
**Update Proposal with Implementation Link:**
- Add link to new implementation in the Implementations section
**Cleanup Local File:**
- If input came from `docs/changes/*.md`, delete the file
- Wiki is now the single source of truth
### 5. Architecture Analysis
Think through the technical approach: Think through the technical approach:
@@ -204,7 +290,7 @@ Think through the technical approach:
- What's the data flow? - What's the data flow?
- What are potential risks? - What are potential risks?
### 4. Create Gitea Issues with Proper Naming ### 6. Create Gitea Issues with Wiki Reference
**Issue Title Format (MANDATORY):** **Issue Title Format (MANDATORY):**
``` ```
@@ -233,17 +319,29 @@ Think through the technical approach:
**If a task is too large, break it down into smaller tasks.** **If a task is too large, break it down into smaller tasks.**
Use the `create_issue` and `suggest_labels` MCP tools: **IMPORTANT: Include wiki implementation reference in issue body:**
``` ```
create_issue( create_issue(
title="[Sprint 17] feat: Implement JWT token generation", title="[Sprint 17] feat: Implement JWT token generation",
body="## Description\n\n...\n\n## Acceptance Criteria\n\n...", body="""## Description
[Description here]
## Implementation
**Wiki:** [Change V4.1.0 (Implementation 1)](https://gitea.example.com/org/repo/wiki/Change-V4.1.0%3A-Proposal-(Implementation-1))
## Acceptance Criteria
- [ ] Criteria 1
- [ ] Criteria 2
""",
labels=["Type/Feature", "Priority/High", "Component/Auth", "Tech/Python"] labels=["Type/Feature", "Priority/High", "Component/Auth", "Tech/Python"]
) )
``` ```
### 5. Set Up Dependencies ### 7. Set Up Dependencies
After creating issues, establish dependencies using native Gitea dependencies: After creating issues, establish dependencies using native Gitea dependencies:
@@ -256,7 +354,7 @@ create_issue_dependency(
This creates a relationship where issue #46 depends on #45 completing first. This creates a relationship where issue #46 depends on #45 completing first.
### 6. Create or Select Milestone ### 8. Create or Select Milestone
Use milestones to group sprint issues: Use milestones to group sprint issues:
@@ -270,7 +368,7 @@ create_milestone(
Then assign issues to the milestone when creating them. Then assign issues to the milestone when creating them.
### 7. Generate Planning Document ### 9. Generate Planning Document
Summarize the sprint plan: Summarize the sprint plan:
@@ -338,11 +436,13 @@ Sprint 17 - User Authentication (Due: 2025-02-01)
- `create_issue_dependency(issue_number, depends_on)` - Create dependency - `create_issue_dependency(issue_number, depends_on)` - Create dependency
- `get_execution_order(issue_numbers)` - Get parallel execution order - `get_execution_order(issue_numbers)` - Get parallel execution order
**Lessons Learned Tools (Gitea Wiki):** **Lessons Learned & Wiki Tools:**
- `search_lessons(query, tags, limit)` - Search lessons learned - `search_lessons(query, tags, limit)` - Search lessons learned
- `create_lesson(title, content, tags, category)` - Create lesson - `create_lesson(title, content, tags, category)` - Create lesson
- `list_wiki_pages()` - List wiki pages - `list_wiki_pages()` - List wiki pages
- `get_wiki_page(page_name)` - Get wiki page content - `get_wiki_page(page_name)` - Get wiki page content
- `create_wiki_page(title, content)` - Create new wiki page (proposals, implementations)
- `update_wiki_page(page_name, content)` - Update wiki page content
## Communication Style ## Communication Style
@@ -370,11 +470,14 @@ Sprint 17 - User Authentication (Due: 2025-02-01)
2. **Always check branch first** - No planning on production! 2. **Always check branch first** - No planning on production!
3. **Always validate repo is under organization** - Fail fast if not 3. **Always validate repo is under organization** - Fail fast if not
4. **Always validate labels exist** - Create missing ones 4. **Always validate labels exist** - Create missing ones
5. **Always check for docs/changes/ folder** - Create if missing 5. **Always detect input source** - Check file, wiki, or use conversation
6. **Always search lessons learned** - Prevent repeated mistakes 6. **Always create wiki proposal and implementation** - Before creating issues
7. **Always use proper naming** - `[Sprint XX] <type>: <description>` 7. **Always search lessons learned** - Prevent repeated mistakes
8. **Always set up dependencies** - Use native Gitea dependencies 8. **Always use proper naming** - `[Sprint XX] <type>: <description>`
9. **Always use suggest_labels** - Don't guess labels 9. **Always include wiki reference** - Add implementation link to issues
10. **Always think through architecture** - Consider edge cases 10. **Always set up dependencies** - Use native Gitea dependencies
11. **Always use suggest_labels** - Don't guess labels
12. **Always think through architecture** - Consider edge cases
13. **Always cleanup local files** - Delete after migrating to wiki
You are the thoughtful planner who ensures sprints are well-prepared, architecturally sound, and learn from past experiences. Take your time, ask questions, and create comprehensive plans that set the team up for success. You are the thoughtful planner who ensures sprints are well-prepared, architecturally sound, and learn from past experiences. Take your time, ask questions, and create comprehensive plans that set the team up for success.

View File

@@ -4,7 +4,7 @@ description: Run diagnostics and create structured issue in marketplace reposito
# Debug Report # Debug Report
Run diagnostic checks on projman MCP tools and create a structured issue in the marketplace repository for investigation. Create structured issues in the marketplace repository - either from automated diagnostic tests OR from user-reported problems.
## Prerequisites ## Prerequisites
@@ -20,6 +20,101 @@ If not configured, ask the user for the marketplace repository path.
You MUST follow these steps in order. Do NOT skip any step. You MUST follow these steps in order. Do NOT skip any step.
### Step 0: Select Report Mode
Use AskUserQuestion to determine what the user wants to report:
```
What would you like to report?
[ ] Run automated diagnostics - Test MCP tools and report failures
[ ] Report an issue I experienced - Describe a problem with any plugin command
```
Store the selection as `REPORT_MODE`:
- "automated" → Continue to Step 1
- "user-reported" → Skip to Step 0.1
---
### Step 0.1: Gather User Feedback (User-Reported Mode Only)
If `REPORT_MODE` is "user-reported", gather structured feedback.
**Question 1: What were you trying to do?**
Use AskUserQuestion:
```
Which plugin/command were you using?
[ ] projman (sprint planning, issues, labels)
[ ] git-flow (commits, branches)
[ ] pr-review (pull request review)
[ ] cmdb-assistant (NetBox integration)
[ ] doc-guardian (documentation)
[ ] code-sentinel (security, refactoring)
[ ] Other - I'll describe it
```
Store as `AFFECTED_PLUGIN`.
Then ask for the specific command (free text):
```
What command or tool were you using? (e.g., /sprint-plan, virt_update_vm)
```
Store as `AFFECTED_COMMAND`.
**Question 2: What was your goal?**
```
Briefly describe what you were trying to accomplish:
```
Store as `USER_GOAL`.
**Question 3: What went wrong?**
Use AskUserQuestion:
```
What type of problem did you encounter?
[ ] Error message - Command failed with an error
[ ] Missing feature - Tool doesn't support what I need
[ ] Unexpected behavior - It worked but did the wrong thing
[ ] Documentation issue - Instructions were unclear or wrong
[ ] Other - I'll describe it
```
Store as `PROBLEM_TYPE`.
Then ask for details (free text):
```
Describe what happened. Include any error messages if applicable:
```
Store as `PROBLEM_DESCRIPTION`.
**Question 4: Expected vs Actual**
```
What did you expect to happen?
```
Store as `EXPECTED_BEHAVIOR`.
**Question 5: Workaround (optional)**
```
Did you find a workaround? If so, describe it (or skip):
```
Store as `WORKAROUND` (may be empty).
After gathering feedback, continue to Step 1 for context gathering, then skip to Step 5.1.
---
### Step 1: Gather Project Context ### Step 1: Gather Project Context
Run these Bash commands to capture project information: Run these Bash commands to capture project information:
@@ -91,7 +186,9 @@ grep PROJMAN_MARKETPLACE_REPO .env
Store as `MARKETPLACE_REPO`. If not found, ask the user. Store as `MARKETPLACE_REPO`. If not found, ask the user.
### Step 3: Run Diagnostic Suite ### Step 3: Run Diagnostic Suite (Automated Mode Only)
**Skip this step if `REPORT_MODE` is "user-reported"** → Go to Step 5.1
Run each MCP tool with explicit `repo` parameter. Record success/failure and full response. Run each MCP tool with explicit `repo` parameter. Record success/failure and full response.
@@ -131,7 +228,9 @@ For each test, record:
- Status: PASS or FAIL - Status: PASS or FAIL
- Response or error message - Response or error message
### Step 4: Analyze Results ### Step 4: Analyze Results (Automated Mode Only)
**Skip this step if `REPORT_MODE` is "user-reported"** → Go to Step 5.1
Count failures and categorize errors: Count failures and categorize errors:
@@ -145,7 +244,9 @@ Count failures and categorize errors:
For each failure, write a hypothesis about the likely cause. For each failure, write a hypothesis about the likely cause.
### Step 5: Generate Smart Labels ### Step 5: Generate Smart Labels (Automated Mode Only)
**Skip this step if `REPORT_MODE` is "user-reported"** → Go to Step 5.1
Generate appropriate labels based on the diagnostic results. Generate appropriate labels based on the diagnostic results.
@@ -180,7 +281,53 @@ The final label set should include:
- **Always**: `Type: Bug`, `Source: Diagnostic`, `Agent: Claude` - **Always**: `Type: Bug`, `Source: Diagnostic`, `Agent: Claude`
- **If detected**: `Component: *`, `Complexity: *`, `Risk: *`, `Priority: *` - **If detected**: `Component: *`, `Complexity: *`, `Risk: *`, `Priority: *`
### Step 6: Generate Issue Content After generating labels, continue to Step 6.
---
### Step 5.1: Generate Labels (User-Reported Mode Only)
**Only execute this step if `REPORT_MODE` is "user-reported"**
**1. Map problem type to labels:**
| PROBLEM_TYPE | Labels |
|--------------|--------|
| Error message | `Type: Bug` |
| Missing feature | `Type: Enhancement` |
| Unexpected behavior | `Type: Bug` |
| Documentation issue | `Type: Documentation` |
| Other | `Type: Bug` (default) |
**2. Map plugin to component:**
| AFFECTED_PLUGIN | Component Label |
|-----------------|-----------------|
| projman | `Component: Commands` |
| git-flow | `Component: Commands` |
| pr-review | `Component: Commands` |
| cmdb-assistant | `Component: API` |
| doc-guardian | `Component: Commands` |
| code-sentinel | `Component: Commands` |
| Other | *(no component label)* |
**3. Build final labels:**
```
BASE_LABELS = ["Source: User-Reported", "Agent: Claude"]
TYPE_LABEL = [mapped from PROBLEM_TYPE]
COMPONENT_LABEL = [mapped from AFFECTED_PLUGIN, if any]
FINAL_LABELS = BASE_LABELS + TYPE_LABEL + COMPONENT_LABEL
```
After generating labels, continue to Step 6.1.
---
### Step 6: Generate Issue Content (Automated Mode Only)
**Skip this step if `REPORT_MODE` is "user-reported"** → Go to Step 6.1
Use this exact template: Use this exact template:
@@ -254,9 +401,86 @@ Use this exact template:
--- ---
*Generated by /debug-report - Labels: Type: Bug, Source: Diagnostic, Agent: Claude* *Generated by /debug-report (automated) - Labels: Type: Bug, Source: Diagnostic, Agent: Claude*
``` ```
After generating content, continue to Step 7.
---
### Step 6.1: Generate Issue Content (User-Reported Mode Only)
**Only execute this step if `REPORT_MODE` is "user-reported"**
Use this template for user-reported issues:
```markdown
## User-Reported Issue
**Reported**: [ISO timestamp]
**Reporter**: Claude Code via /debug-report (user feedback)
## Context
| Field | Value |
|-------|-------|
| Plugin | `[AFFECTED_PLUGIN]` |
| Command/Tool | `[AFFECTED_COMMAND]` |
| Repository | `[PROJECT_REPO]` |
| Working Directory | `[WORKING_DIR]` |
| Branch | `[CURRENT_BRANCH]` |
## Problem Description
### Goal
[USER_GOAL]
### What Happened
**Problem Type**: [PROBLEM_TYPE]
[PROBLEM_DESCRIPTION]
### Expected Behavior
[EXPECTED_BEHAVIOR]
## Workaround
[WORKAROUND if provided, otherwise "None identified"]
## Investigation Hints
Based on the affected plugin/command, relevant files to check:
[Generate based on AFFECTED_PLUGIN:]
**projman:**
- `plugins/projman/commands/[AFFECTED_COMMAND].md`
- `mcp-servers/gitea/mcp_server/tools/*.py`
**git-flow:**
- `plugins/git-flow/commands/[AFFECTED_COMMAND].md`
**pr-review:**
- `plugins/pr-review/commands/[AFFECTED_COMMAND].md`
- `mcp-servers/gitea/mcp_server/tools/pull_requests.py`
**cmdb-assistant:**
- `plugins/cmdb-assistant/commands/[AFFECTED_COMMAND].md`
- `mcp-servers/netbox/mcp_server/tools/*.py`
- `mcp-servers/netbox/mcp_server/server.py` (tool schemas)
**doc-guardian / code-sentinel:**
- `plugins/[plugin]/commands/[AFFECTED_COMMAND].md`
- `plugins/[plugin]/hooks/*.md`
---
*Generated by /debug-report (user feedback) - Labels: [FINAL_LABELS]*
```
After generating content, continue to Step 7.
---
### Step 7: Create Issue in Marketplace ### Step 7: Create Issue in Marketplace
**IMPORTANT:** Always use curl to create issues in the marketplace repo. This avoids branch protection restrictions and MCP context issues that can block issue creation when working on protected branches. **IMPORTANT:** Always use curl to create issues in the marketplace repo. This avoids branch protection restrictions and MCP context issues that can block issue creation when working on protected branches.
@@ -274,46 +498,57 @@ fi
**2. Fetch label IDs from marketplace repo:** **2. Fetch label IDs from marketplace repo:**
The diagnostic labels to apply are: Labels depend on `REPORT_MODE`:
**Automated mode:**
- `Source/Diagnostic` (always) - `Source/Diagnostic` (always)
- `Type/Bug` (always) - `Type/Bug` (always)
**User-reported mode:**
- `Source/User-Reported` (always)
- Type label from Step 5.1 (Bug, Enhancement, or Documentation)
- Component label from Step 5.1 (if applicable)
```bash ```bash
# Fetch all labels and extract IDs for our target labels # Fetch all labels from marketplace repo
LABELS_JSON=$(curl -s "${GITEA_API_URL}/repos/${MARKETPLACE_REPO}/labels" \ LABELS_JSON=$(curl -s "${GITEA_API_URL}/repos/${MARKETPLACE_REPO}/labels" \
-H "Authorization: token ${GITEA_API_TOKEN}") -H "Authorization: token ${GITEA_API_TOKEN}")
# Extract label IDs (handles both org and repo labels) # Extract label IDs based on FINAL_LABELS from Step 5 or 5.1
SOURCE_DIAG_ID=$(echo "$LABELS_JSON" | jq -r '.[] | select(.name == "Source/Diagnostic") | .id') # Build LABEL_IDS array with IDs of labels that exist in the repo
TYPE_BUG_ID=$(echo "$LABELS_JSON" | jq -r '.[] | select(.name == "Type/Bug") | .id') # Example for automated mode:
SOURCE_ID=$(echo "$LABELS_JSON" | jq -r '.[] | select(.name == "Source/Diagnostic") | .id')
TYPE_ID=$(echo "$LABELS_JSON" | jq -r '.[] | select(.name == "Type/Bug") | .id')
# Build label array (only include IDs that were found) # Example for user-reported mode (adjust based on FINAL_LABELS):
LABEL_IDS="[]" # SOURCE_ID=$(echo "$LABELS_JSON" | jq -r '.[] | select(.name == "Source/User-Reported") | .id')
if [[ -n "$SOURCE_DIAG_ID" && -n "$TYPE_BUG_ID" ]]; then # TYPE_ID=$(echo "$LABELS_JSON" | jq -r '.[] | select(.name == "[TYPE_LABEL]") | .id')
LABEL_IDS="[$SOURCE_DIAG_ID, $TYPE_BUG_ID]"
elif [[ -n "$SOURCE_DIAG_ID" ]]; then
LABEL_IDS="[$SOURCE_DIAG_ID]"
elif [[ -n "$TYPE_BUG_ID" ]]; then
LABEL_IDS="[$TYPE_BUG_ID]"
fi
# Build label array from found IDs
LABEL_IDS="[$(echo "$SOURCE_ID,$TYPE_ID" | sed 's/,,*/,/g; s/^,//; s/,$//')]"
echo "Label IDs to apply: $LABEL_IDS" echo "Label IDs to apply: $LABEL_IDS"
``` ```
**3. Create issue with labels via curl:** **3. Create issue with labels via curl:**
**Title format depends on `REPORT_MODE`:**
- Automated: `[Diagnostic] [summary of main failure]`
- User-reported: `[AFFECTED_PLUGIN] [brief summary of PROBLEM_DESCRIPTION]`
```bash ```bash
# Create temp files with restrictive permissions # Create temp files with restrictive permissions
DIAG_TITLE=$(mktemp -t diag-title.XXXXXX) DIAG_TITLE=$(mktemp -t diag-title.XXXXXX)
DIAG_BODY=$(mktemp -t diag-body.XXXXXX) DIAG_BODY=$(mktemp -t diag-body.XXXXXX)
DIAG_PAYLOAD=$(mktemp -t diag-payload.XXXXXX) DIAG_PAYLOAD=$(mktemp -t diag-payload.XXXXXX)
# Save title # Save title (format depends on REPORT_MODE)
echo "[Diagnostic] [summary of main failure]" > "$DIAG_TITLE" # Automated: "[Diagnostic] [summary of main failure]"
# User-reported: "[AFFECTED_PLUGIN] [brief summary]"
echo "[Title based on REPORT_MODE]" > "$DIAG_TITLE"
# Save body (paste Step 6 content) - heredoc delimiter prevents shell expansion # Save body (paste Step 6 or 6.1 content) - heredoc delimiter prevents shell expansion
cat > "$DIAG_BODY" << 'DIAGNOSTIC_EOF' cat > "$DIAG_BODY" << 'DIAGNOSTIC_EOF'
[Paste the full issue content from Step 6 here] [Paste the full issue content from Step 6 or 6.1 here]
DIAGNOSTIC_EOF DIAGNOSTIC_EOF
# Build JSON payload with labels using jq # Build JSON payload with labels using jq
@@ -360,8 +595,9 @@ To create the issue manually:
### Step 8: Report to User ### Step 8: Report to User
Display summary: Display summary based on `REPORT_MODE`:
**Automated Mode:**
``` ```
Debug Report Complete Debug Report Complete
===================== =====================
@@ -383,18 +619,38 @@ Next Steps:
3. Select issue #[N] to investigate 3. Select issue #[N] to investigate
``` ```
**User-Reported Mode:**
```
Issue Report Complete
=====================
Plugin: [AFFECTED_PLUGIN]
Command: [AFFECTED_COMMAND]
Problem: [PROBLEM_TYPE]
Issue Created: [issue URL]
Your feedback has been captured. The development team will
investigate and may follow up with questions.
Next Steps:
1. Switch to marketplace repo: cd [marketplace path]
2. Run: /debug-review
3. Select issue #[N] to investigate
```
## DO NOT ## DO NOT
- **DO NOT** attempt to fix anything - only report - **DO NOT** attempt to fix anything - only report
- **DO NOT** create issues if all tests pass (just report success) - **DO NOT** create issues if all automated tests pass (unless in user-reported mode)
- **DO NOT** skip any diagnostic test - **DO NOT** skip any diagnostic test in automated mode
- **DO NOT** call MCP tools without the `repo` parameter - **DO NOT** call MCP tools without the `repo` parameter
- **DO NOT** ask user questions during execution - run autonomously - **DO NOT** skip user questions in user-reported mode - gather complete feedback
- **DO NOT** use MCP tools to create issues in the marketplace - always use curl (avoids branch restrictions) - **DO NOT** use MCP tools to create issues in the marketplace - always use curl (avoids branch restrictions)
## If All Tests Pass ## If All Tests Pass (Automated Mode Only)
If all 5 tests pass, report success without creating an issue: If all 5 tests pass in automated mode, report success without creating an issue:
``` ```
Debug Report Complete Debug Report Complete
@@ -407,8 +663,8 @@ Failed: 0
All diagnostics passed. No issues to report. All diagnostics passed. No issues to report.
If you're experiencing a specific problem, please describe it If you're experiencing a specific problem, run /debug-report again
and I can create a manual bug report. and select "Report an issue I experienced" to describe it.
``` ```
## Troubleshooting ## Troubleshooting

View File

@@ -0,0 +1,121 @@
---
description: View proposal and implementation hierarchy with status tracking
---
# Proposal Status
View the status of all change proposals and their implementations in the Gitea Wiki.
## Overview
This command provides a tree view of:
- All change proposals (`Change VXX.X.X: Proposal`)
- Their implementations (`Change VXX.X.X: Proposal (Implementation N)`)
- Linked issues and lessons learned
## Workflow
1. **Fetch All Wiki Pages**
- Use `list_wiki_pages()` to get all wiki pages
- Filter for pages matching `Change V*: Proposal*` pattern
2. **Parse Proposal Structure**
- Group implementations under their parent proposals
- Extract status from page metadata (In Progress, Implemented, Abandoned)
3. **Fetch Linked Artifacts**
- For each implementation, search for issues referencing it
- Search lessons learned that link to the implementation
4. **Display Tree View**
```
Change V04.1.0: Proposal [In Progress]
├── Implementation 1 [In Progress] - 2026-01-26
│ ├── Issues: #161, #162, #163, #164
│ └── Lessons: (pending)
└── Implementation 2 [Not Started]
Change V04.0.0: Proposal [Implemented]
└── Implementation 1 [Implemented] - 2026-01-20
├── Issues: #150, #151
└── Lessons: v4.0.0-impl-1-lessons
```
## MCP Tools Used
- `list_wiki_pages()` - List all wiki pages
- `get_wiki_page(page_name)` - Get page content for status extraction
- `list_issues(state, labels)` - Find linked issues
- `search_lessons(query, tags)` - Find linked lessons
## Status Definitions
| Status | Meaning |
|--------|---------|
| **Pending** | Proposal created but no implementation started |
| **In Progress** | At least one implementation is active |
| **Implemented** | All planned implementations complete |
| **Abandoned** | Proposal was cancelled or superseded |
## Filtering Options
The command accepts optional filters:
```
/proposal-status # Show all proposals
/proposal-status --version V04.1.0 # Show specific version
/proposal-status --status "In Progress" # Filter by status
```
## Example Output
```
Proposal Status Report
======================
Change V04.1.0: Wiki-Based Planning Workflow [In Progress]
├── Implementation 1 [In Progress] - Started: 2026-01-26
│ ├── Issues: #161 (closed), #162 (closed), #163 (closed), #164 (open)
│ └── Lessons: (pending - sprint not closed)
└── (No additional implementations planned)
Change V04.0.0: MCP Server Consolidation [Implemented]
├── Implementation 1 [Implemented] - 2026-01-15 to 2026-01-20
│ ├── Issues: #150 (closed), #151 (closed), #152 (closed)
│ └── Lessons:
│ • Sprint 15 - MCP Server Symlink Best Practices
│ • Sprint 15 - Venv Path Resolution in Plugins
└── (Complete)
Change V03.2.0: Label Taxonomy Sync [Implemented]
└── Implementation 1 [Implemented] - 2026-01-10 to 2026-01-12
├── Issues: #140 (closed), #141 (closed)
└── Lessons:
• Sprint 14 - Organization vs Repository Labels
Summary:
- Total Proposals: 3
- In Progress: 1
- Implemented: 2
- Pending: 0
```
## Implementation Notes
**Page Name Parsing:**
- Proposals: `Change VXX.X.X: Proposal` or `Change Sprint-NN: Proposal`
- Implementations: `Change VXX.X.X: Proposal (Implementation N)`
**Status Extraction:**
- Parse the `> **Status:**` line from page metadata
- Default to "Unknown" if not found
**Issue Linking:**
- Search for issues containing wiki link in body
- Or search for issues with `[Sprint XX]` prefix matching implementation
**Lesson Linking:**
- Search lessons with implementation link in metadata
- Or search by version/sprint tags

View File

@@ -41,13 +41,36 @@ The orchestrator agent will guide you through:
- Create lessons in project wiki under `lessons-learned/sprints/` - Create lessons in project wiki under `lessons-learned/sprints/`
- Make lessons searchable for future sprints - Make lessons searchable for future sprints
5. **Git Operations** 5. **Update Wiki Implementation Page**
- Commit any remaining work - Use `get_wiki_page` to fetch the current implementation page
- Update status from "In Progress" to "Implemented" (or "Partial"/"Failed")
- Add completion date
- Link to lessons learned created in step 4
- Use `update_wiki_page` to save changes
6. **Update Wiki Proposal Page**
- Check if all implementations for this proposal are complete
- If all complete: Update proposal status to "Implemented"
- If partial: Keep status as "In Progress", note completed implementations
- Add summary of what was accomplished
7. **Update CHANGELOG** (MANDATORY)
- Add all sprint changes to `[Unreleased]` section in CHANGELOG.md
- Categorize: Added, Changed, Fixed, Removed, Deprecated
- Include plugin prefix (e.g., `- **projman:** New feature`)
8. **Version Check**
- Run `/suggest-version` to analyze changes and recommend version bump
- If release warranted: run `./scripts/release.sh X.Y.Z`
- Ensures version numbers stay in sync across files
9. **Git Operations**
- Commit any remaining work (including CHANGELOG updates)
- Merge feature branches if needed - Merge feature branches if needed
- Clean up merged branches - Clean up merged branches
- Tag sprint completion - Tag sprint completion (if release created)
6. **Close Milestone** 10. **Close Milestone**
- Use `update_milestone` to close the sprint milestone - Use `update_milestone` to close the sprint milestone
- Document final completion status - Document final completion status
@@ -66,7 +89,8 @@ The orchestrator agent will guide you through:
- `create_lesson` - Create lessons learned entry - `create_lesson` - Create lessons learned entry
- `search_lessons` - Check for similar existing lessons - `search_lessons` - Check for similar existing lessons
- `list_wiki_pages` - Check existing lessons learned - `list_wiki_pages` - Check existing lessons learned
- `get_wiki_page` - Read existing lessons - `get_wiki_page` - Read existing lessons or implementation pages
- `update_wiki_page` - Update implementation/proposal status
## Lesson Structure ## Lesson Structure
@@ -75,6 +99,11 @@ Lessons should follow this structure:
```markdown ```markdown
# Sprint X - [Lesson Title] # Sprint X - [Lesson Title]
## Metadata
- **Implementation:** [Change VXX.X.X (Impl N)](wiki-link)
- **Issues:** #45, #46, #47
- **Sprint:** Sprint X
## Context ## Context
[What were you trying to do? What was the sprint goal?] [What were you trying to do? What was the sprint goal?]
@@ -91,12 +120,19 @@ Lessons should follow this structure:
[Comma-separated tags for search: technology, component, type] [Comma-separated tags for search: technology, component, type]
``` ```
**IMPORTANT:** Always include the Implementation link in the Metadata section. This enables bidirectional traceability between lessons and the work that generated them.
## Example Lessons Learned ## Example Lessons Learned
**Example 1: Technical Gotcha** **Example 1: Technical Gotcha**
```markdown ```markdown
# Sprint 16 - Claude Code Infinite Loop on Validation Errors # Sprint 16 - Claude Code Infinite Loop on Validation Errors
## Metadata
- **Implementation:** [Change V1.2.0 (Impl 1)](https://gitea.example.com/org/repo/wiki/Change-V1.2.0%3A-Proposal-(Implementation-1))
- **Issues:** #45, #46
- **Sprint:** Sprint 16
## Context ## Context
Implementing input validation for authentication API endpoints. Implementing input validation for authentication API endpoints.
@@ -123,6 +159,11 @@ testing, claude-code, validation, python, pytest, debugging
```markdown ```markdown
# Sprint 14 - Extracting Services Too Early # Sprint 14 - Extracting Services Too Early
## Metadata
- **Implementation:** [Change V2.0.0 (Impl 1)](https://gitea.example.com/org/repo/wiki/Change-V2.0.0%3A-Proposal-(Implementation-1))
- **Issues:** #32, #33, #34
- **Sprint:** Sprint 14
## Context ## Context
Planning to extract Intuit Engine service from monolith. Planning to extract Intuit Engine service from monolith.

View File

@@ -47,15 +47,34 @@ Verify all required labels exist using `get_labels`:
**If labels are missing:** Use `create_label` to create them. **If labels are missing:** Use `create_label` to create them.
### 4. docs/changes/ Folder Check ### 4. Input Source Detection
Verify the project has a `docs/changes/` folder for sprint input files. The planner supports flexible input sources for sprint planning:
**If folder does NOT exist:** Prompt user to create it. | Source | Detection | Action |
|--------|-----------|--------|
| **Local file** | `docs/changes/*.md` exists | Parse frontmatter, migrate to wiki, delete local |
| **Existing wiki** | `Change VXX.X.X: Proposal` exists | Use as-is, create new implementation page |
| **Conversation** | Neither file nor wiki exists | Create wiki from discussion context |
**If sprint starts with discussion but no input file:** **Input File Format** (if using local file):
- Capture the discussion outputs ```yaml
- Create a change file: `docs/changes/sprint-XX-description.md` ---
version: "4.1.0" # or "sprint-17" for internal work
title: "Feature Name"
plugin: plugin-name # optional
type: feature # feature | bugfix | refactor | infra
---
# Feature Description
[Free-form content...]
```
**Detection Logic:**
1. Check for `docs/changes/*.md` files
2. Check for existing wiki proposal matching version
3. If neither found, use conversation context
4. If ambiguous, ask user which input to use
## Planning Workflow ## Planning Workflow
@@ -66,36 +85,56 @@ The planner agent will:
- Understand scope, priorities, and constraints - Understand scope, priorities, and constraints
- Never rush - take time to understand requirements fully - Never rush - take time to understand requirements fully
2. **Search Relevant Lessons Learned** 2. **Detect Input Source**
- Check for `docs/changes/*.md` files
- Check for existing wiki proposal by version
- If neither: use conversation context
- Ask user if multiple sources found
3. **Search Relevant Lessons Learned**
- Use the `search_lessons` MCP tool to find past experiences - Use the `search_lessons` MCP tool to find past experiences
- Search by keywords and tags relevant to the sprint work - Search by keywords and tags relevant to the sprint work
- Review patterns and preventable mistakes from previous sprints - Review patterns and preventable mistakes from previous sprints
3. **Architecture Analysis** 4. **Create/Update Wiki Proposal**
- If local file: migrate content to wiki, create proposal page
- If conversation: create proposal from discussion
- If existing wiki: skip creation, use as-is
- **Page naming:** `Change VXX.X.X: Proposal` or `Change Sprint-NN: Proposal`
5. **Create Wiki Implementation Page**
- Create `Change VXX.X.X: Proposal (Implementation N)`
- Include tags: Type, Version, Status=In Progress, Date, Origin
- Update proposal page with link to this implementation
- This page tracks THIS sprint's work on the proposal
6. **Architecture Analysis**
- Think through technical approach and edge cases - Think through technical approach and edge cases
- Identify architectural decisions needed - Identify architectural decisions needed
- Consider dependencies and integration points - Consider dependencies and integration points
- Review existing codebase architecture - Review existing codebase architecture
4. **Create Gitea Issues** 7. **Create Gitea Issues**
- Use the `create_issue` MCP tool for each planned task - Use the `create_issue` MCP tool for each planned task
- Apply appropriate labels using `suggest_labels` tool - Apply appropriate labels using `suggest_labels` tool
- **Issue Title Format (MANDATORY):** `[Sprint XX] <type>: <description>` - **Issue Title Format (MANDATORY):** `[Sprint XX] <type>: <description>`
- **Include wiki reference:** `Implementation: [Change VXX.X.X (Impl N)](wiki-link)`
- Include acceptance criteria and technical notes - Include acceptance criteria and technical notes
5. **Set Up Dependencies** 8. **Set Up Dependencies**
- Use `create_issue_dependency` to establish task dependencies - Use `create_issue_dependency` to establish task dependencies
- This enables parallel execution planning - This enables parallel execution planning
6. **Create or Select Milestone** 9. **Create or Select Milestone**
- Use `create_milestone` to group sprint issues - Use `create_milestone` to group sprint issues
- Assign issues to the milestone - Assign issues to the milestone
7. **Generate Planning Document** 10. **Cleanup & Summary**
- Delete local input file (wiki is now source of truth)
- Summarize architectural decisions - Summarize architectural decisions
- List created issues with labels - List created issues with labels
- Document dependency graph - Document dependency graph
- Provide sprint overview - Provide sprint overview with wiki links
## Issue Title Format (MANDATORY) ## Issue Title Format (MANDATORY)
@@ -152,6 +191,8 @@ The planner agent will:
- `search_lessons` - Search lessons learned from previous sprints - `search_lessons` - Search lessons learned from previous sprints
- `list_wiki_pages` - List wiki pages - `list_wiki_pages` - List wiki pages
- `get_wiki_page` - Fetch specific documentation page - `get_wiki_page` - Fetch specific documentation page
- `create_wiki_page` - Create new wiki page (proposals, implementations)
- `update_wiki_page` - Update wiki page content
## Label Taxonomy ## Label Taxonomy
@@ -181,16 +222,17 @@ The planner agent is thoughtful and methodical:
User: I want to plan a sprint for user authentication User: I want to plan a sprint for user authentication
Planner: Great! Let me first run pre-planning validations... Planner: Great! Let me first run pre-planning validations...
[Checks branch, repo org, labels, docs/changes folder] [Checks branch, repo org, labels]
All validations passed.
All validations passed. Now let me ask a few questions: [Checks for input sources]
Found: docs/changes/v1.2.0-user-authentication.md
I'll use this file as the planning input. Let me ask a few questions:
1. What authentication method are you planning? (JWT, OAuth, session-based?) 1. What authentication method are you planning? (JWT, OAuth, session-based?)
2. Are there any specific security requirements or compliance needs? 2. Are there any specific security requirements or compliance needs?
3. Should this integrate with existing user management? 3. Should this integrate with existing user management?
4. What's the priority level for this sprint?
Let me also search for relevant lessons learned about authentication...
[Uses search_lessons to find past authentication work] [Uses search_lessons to find past authentication work]
@@ -198,22 +240,23 @@ Based on previous experience, I found these relevant lessons:
- Sprint 12: JWT token expiration handling edge cases - Sprint 12: JWT token expiration handling edge cases
- Sprint 8: OAuth integration pitfalls with third-party providers - Sprint 8: OAuth integration pitfalls with third-party providers
Now, let me analyze the architecture... [Creates wiki proposal and implementation pages]
✓ Created: "Change V1.2.0: Proposal" (wiki)
✓ Created: "Change V1.2.0: Proposal (Implementation 1)" (wiki)
✓ Deleted: docs/changes/v1.2.0-user-authentication.md (migrated to wiki)
[Creates issues with appropriate labels and dependencies] Now, let me analyze the architecture and create issues...
[Creates issues with wiki references]
Created 5 issues for the authentication sprint: Created 5 issues for the authentication sprint:
- Issue #45: [Sprint 17] feat: Implement JWT token generation - Issue #45: [Sprint 17] feat: Implement JWT token generation
Labels: Type/Feature, Priority/High, Component/Auth, Tech/Python Labels: Type/Feature, Priority/High, Component/Auth, Tech/Python
Dependencies: None Implementation: [Change V1.2.0 (Impl 1)](wiki-link)
- Issue #46: [Sprint 17] feat: Build user login endpoint - Issue #46: [Sprint 17] feat: Build user login endpoint
Labels: Type/Feature, Priority/High, Component/API, Tech/FastAPI Labels: Type/Feature, Priority/High, Component/API, Tech/FastAPI
Dependencies: #45 Implementation: [Change V1.2.0 (Impl 1)](wiki-link)
- Issue #47: [Sprint 17] feat: Create user registration form
Labels: Type/Feature, Priority/Medium, Component/Frontend, Tech/Vue
Dependencies: #46
Dependency Graph: Dependency Graph:
#45 -> #46 -> #47 #45 -> #46 -> #47
@@ -222,20 +265,28 @@ Dependency Graph:
#48 #48
Milestone: Sprint 17 - User Authentication (Due: 2025-02-01) Milestone: Sprint 17 - User Authentication (Due: 2025-02-01)
Wiki: https://gitea.example.com/org/repo/wiki/Change-V1.2.0%3A-Proposal
``` ```
## Getting Started ## Getting Started
Invoke the planner agent by providing your sprint goals. The agent will guide you through the planning process. Invoke the planner agent by providing your sprint goals. The agent will guide you through the planning process.
**Input Options:**
1. Create `docs/changes/vX.Y.Z-feature-name.md` with frontmatter before running
2. Create wiki proposal page manually, then run `/sprint-plan`
3. Just start a conversation - the planner will capture context and create wiki pages
**Example:** **Example:**
> "I want to plan a sprint for extracting the Intuit Engine service from the monolith" > "I want to plan a sprint for extracting the Intuit Engine service from the monolith"
The planner will then: The planner will then:
1. Run pre-planning validations 1. Run pre-planning validations
2. Ask clarifying questions 2. Detect input source (file, wiki, or conversation)
3. Search lessons learned 3. Ask clarifying questions
4. Create issues with proper naming and labels 4. Search lessons learned
5. Set up dependencies 5. Create wiki proposal and implementation pages
6. Create milestone 6. Create issues with wiki references
7. Generate planning summary 7. Set up dependencies
8. Create milestone
9. Cleanup and generate planning summary

View File

@@ -0,0 +1,78 @@
# /suggest-version
Analyze CHANGELOG.md and suggest appropriate semantic version bump.
## Behavior
1. **Read current state**:
- Read `CHANGELOG.md` to find current version and [Unreleased] content
- Read `.claude-plugin/marketplace.json` for current marketplace version
- Check individual plugin versions in `plugins/*/. claude-plugin/plugin.json`
2. **Analyze [Unreleased] section**:
- Extract all entries under `### Added`, `### Changed`, `### Fixed`, `### Removed`, `### Deprecated`
- Categorize changes by impact
3. **Apply SemVer rules**:
| Change Type | Version Bump | Indicators |
|-------------|--------------|------------|
| **MAJOR** (X.0.0) | Breaking changes | `### Removed`, `### Changed` with "BREAKING:", renamed/removed APIs |
| **MINOR** (x.Y.0) | New features, backwards compatible | `### Added` with new commands/plugins/features |
| **PATCH** (x.y.Z) | Bug fixes only | `### Fixed` only, `### Changed` for non-breaking tweaks |
4. **Output recommendation**:
```
## Version Analysis
**Current version:** X.Y.Z
**[Unreleased] summary:**
- Added: N entries (new features/plugins)
- Changed: N entries (M breaking)
- Fixed: N entries
- Removed: N entries
**Recommendation:** MINOR bump → X.(Y+1).0
**Reason:** New features added without breaking changes
**To release:** ./scripts/release.sh X.Y.Z
```
5. **Check version sync**:
- Compare marketplace version with individual plugin versions
- Warn if plugins are out of sync (e.g., marketplace 4.0.0 but projman 3.1.0)
## Examples
**Output when MINOR bump needed:**
```
## Version Analysis
**Current version:** 4.0.0
**[Unreleased] summary:**
- Added: 3 entries (new command, hook improvement, workflow example)
- Changed: 1 entry (0 breaking)
- Fixed: 2 entries
**Recommendation:** MINOR bump → 4.1.0
**Reason:** New features (Added section) without breaking changes
**To release:** ./scripts/release.sh 4.1.0
```
**Output when nothing to release:**
```
## Version Analysis
**Current version:** 4.0.0
**[Unreleased] summary:** Empty - no pending changes
**Recommendation:** No release needed
```
## Integration
This command helps maintain proper versioning workflow:
- Run after completing a sprint to determine version bump
- Run before `/sprint-close` to ensure version is updated
- Integrates with `./scripts/release.sh` for actual release execution

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# projman startup check hook # projman startup check hook
# Checks for common issues at session start # Checks for common issues AND suggests sprint planning proactively
# All output MUST have [projman] prefix # All output MUST have [projman] prefix
PREFIX="[projman]" PREFIX="[projman]"
@@ -26,5 +26,41 @@ if [[ -f ".env" ]]; then
fi fi
fi fi
# All checks passed - say nothing # Check for open issues (suggests sprint planning)
# Only if .env exists with valid GITEA config
if [[ -f ".env" ]]; then
GITEA_API_URL=$(grep -E "^GITEA_API_URL=" ~/.config/claude/gitea.env 2>/dev/null | cut -d'=' -f2 | tr -d '"' || true)
GITEA_API_TOKEN=$(grep -E "^GITEA_API_TOKEN=" ~/.config/claude/gitea.env 2>/dev/null | cut -d'=' -f2 | tr -d '"' || true)
GITEA_REPO=$(grep -E "^GITEA_REPO=" .env 2>/dev/null | cut -d'=' -f2 | tr -d '"' || true)
if [[ -n "$GITEA_API_URL" && -n "$GITEA_API_TOKEN" && -n "$GITEA_REPO" ]]; then
# Quick check for open issues without milestone (unplanned work)
OPEN_ISSUES=$(curl -s -m 5 \
-H "Authorization: token $GITEA_API_TOKEN" \
"${GITEA_API_URL}/repos/${GITEA_REPO}/issues?state=open&milestone=none&limit=1" 2>/dev/null | \
grep -c '"number"' || echo "0")
if [[ "$OPEN_ISSUES" -gt 0 ]]; then
# Count total unplanned issues
TOTAL_UNPLANNED=$(curl -s -m 5 \
-H "Authorization: token $GITEA_API_TOKEN" \
"${GITEA_API_URL}/repos/${GITEA_REPO}/issues?state=open&milestone=none" 2>/dev/null | \
grep -c '"number"' || echo "?")
echo "$PREFIX ${TOTAL_UNPLANNED} open issues without milestone - consider /sprint-plan"
fi
fi
fi
# Check for CHANGELOG.md [Unreleased] content (version management)
if [[ -f "CHANGELOG.md" ]]; then
# Check if there's content under [Unreleased] that hasn't been released
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md 2>/dev/null | grep -E '^### (Added|Changed|Fixed|Removed|Deprecated)' | head -1 || true)
if [[ -n "$UNRELEASED_CONTENT" ]]; then
UNRELEASED_LINES=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md 2>/dev/null | grep -E '^- ' | wc -l | tr -d ' ')
if [[ "$UNRELEASED_LINES" -gt 0 ]]; then
echo "$PREFIX ${UNRELEASED_LINES} unreleased changes in CHANGELOG - consider version bump"
fi
fi
fi
exit 0 exit 0

View File

@@ -0,0 +1,24 @@
{
"name": "viz-platform",
"version": "1.0.0",
"description": "Visualization tools with Dash Mantine Components validation, Plotly charts, and theming",
"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",
"license": "MIT",
"keywords": [
"dash",
"plotly",
"mantine",
"charts",
"dashboards",
"theming",
"visualization",
"dmc"
],
"commands": ["./commands/"],
"mcpServers": ["./.mcp.json"]
}

View File

@@ -0,0 +1,10 @@
{
"mcpServers": {
"viz-platform": {
"type": "stdio",
"command": "${CLAUDE_PLUGIN_ROOT}/mcp-servers/viz-platform/.venv/bin/python",
"args": ["-m", "mcp_server.server"],
"cwd": "${CLAUDE_PLUGIN_ROOT}/mcp-servers/viz-platform"
}
}
}

View File

@@ -0,0 +1,196 @@
# viz-platform Plugin
Visualization tools with Dash Mantine Components validation, Plotly charts, and theming for Claude Code.
## Features
- **DMC Validation**: Prevent prop hallucination with version-locked component registry
- **Chart Creation**: Plotly charts with automatic theme token application
- **Layout Builder**: Dashboard layouts with filters, grids, and responsive design
- **Theme System**: Create, extend, and export design tokens
## Installation
This plugin is part of the leo-claude-mktplace. Install via:
```bash
# From marketplace
claude plugins install leo-claude-mktplace/viz-platform
# Setup MCP server venv
cd ~/.claude/plugins/marketplaces/leo-claude-mktplace/mcp-servers/viz-platform
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
## Configuration
### System-Level (Optional)
Create `~/.config/claude/viz-platform.env` for default theme preferences:
```env
VIZ_PLATFORM_COLOR_SCHEME=light
VIZ_PLATFORM_PRIMARY_COLOR=blue
```
### Project-Level (Optional)
Add to project `.env` for project-specific settings:
```env
VIZ_PLATFORM_THEME=my-custom-theme
DMC_VERSION=0.14.7
```
## Commands
| Command | Description |
|---------|-------------|
| `/initial-setup` | Interactive setup wizard for DMC and theme preferences |
| `/component {name}` | Inspect component props and validation |
| `/chart {type}` | Create a Plotly chart |
| `/dashboard {template}` | Create a dashboard layout |
| `/theme {name}` | Apply an existing theme |
| `/theme-new {name}` | Create a new custom theme |
| `/theme-css {name}` | Export theme as CSS |
## Agents
| Agent | Description |
|-------|-------------|
| `theme-setup` | Design-focused theme creation specialist |
| `layout-builder` | Dashboard layout and filter specialist |
| `component-check` | Strict component validation specialist |
## Tool Categories
### DMC Validation (3 tools)
Prevent invalid component props before runtime.
| Tool | Description |
|------|-------------|
| `list_components` | List available components by category |
| `get_component_props` | Get detailed prop specifications |
| `validate_component` | Validate a component configuration |
### Charts (2 tools)
Create Plotly charts with theme integration.
| Tool | Description |
|------|-------------|
| `chart_create` | Create a chart (line, bar, scatter, pie, etc.) |
| `chart_configure_interaction` | Configure chart interactivity |
### Layouts (5 tools)
Build dashboard structures with filters and grids.
| Tool | Description |
|------|-------------|
| `layout_create` | Create a layout structure |
| `layout_add_filter` | Add filter components |
| `layout_set_grid` | Configure responsive grid |
| `layout_add_section` | Add content sections |
| `layout_get` | Retrieve layout details |
### Themes (6 tools)
Manage design tokens and styling.
| Tool | Description |
|------|-------------|
| `theme_create` | Create a new theme |
| `theme_extend` | Extend an existing theme |
| `theme_validate` | Validate theme configuration |
| `theme_export_css` | Export as CSS custom properties |
| `theme_list` | List available themes |
| `theme_activate` | Set the active theme |
### Pages (5 tools)
Create full Dash app configurations.
| Tool | Description |
|------|-------------|
| `page_create` | Create a page structure |
| `page_add_navbar` | Add navigation bar |
| `page_set_auth` | Configure authentication |
| `page_list` | List pages |
| `page_get_app_config` | Get full app configuration |
## Component Validation
The key differentiator of viz-platform is the component registry system:
```python
# Before writing component code
get_component_props("Button")
# Returns: all valid props with types, enums, defaults
# After writing code
validate_component("Button", {"variant": "filled", "color": "blue"})
# Returns: {valid: true} or {valid: false, errors: [...]}
```
This prevents common DMC mistakes:
- Prop typos (`colour` vs `color`)
- Invalid enum values (`size="large"` vs `size="lg"`)
- Wrong case (`fullwidth` vs `fullWidth`)
## Example Workflow
```
/component Button
# → Shows all Button props with types and defaults
/theme-new corporate
# → Creates theme with brand colors
/chart bar
# → Creates bar chart with theme colors
/dashboard sidebar
# → Creates sidebar layout with filters
/theme-css corporate
# → Exports theme as CSS for external use
```
## Cross-Plugin Integration
viz-platform works seamlessly with data-platform:
1. **Load data** with data-platform: `/ingest sales.csv`
2. **Create chart** with viz-platform: `/chart line` using the data_ref
3. **Build layout** with viz-platform: `/dashboard` with filters
4. **Export** complete dashboard structure
## Chart Types
| Type | Best For |
|------|----------|
| `line` | Time series, trends |
| `bar` | Comparisons, categories |
| `scatter` | Correlations, distributions |
| `pie` | Part-to-whole |
| `area` | Cumulative trends |
| `histogram` | Frequency distributions |
| `box` | Statistical distributions |
| `heatmap` | Matrix correlations |
| `sunburst` | Hierarchical data |
| `treemap` | Hierarchical proportions |
## Layout Templates
| Template | Best For |
|----------|----------|
| `basic` | Simple dashboards, reports |
| `sidebar` | Navigation-heavy apps |
| `tabs` | Multi-page dashboards |
| `split` | Comparisons, master-detail |
## Requirements
- Python 3.10+
- dash-mantine-components >= 0.14.0
- plotly >= 5.18.0
- dash >= 2.14.0

View File

@@ -0,0 +1,145 @@
# Component Check Agent
You are a strict component validation specialist. Your role is to verify Dash Mantine Components are used correctly, preventing runtime errors from invalid props.
## Trigger Conditions
Activate this agent when:
- Before rendering any DMC component
- User asks about component props or usage
- Code review for DMC components
- Debugging component errors
## Capabilities
- List available DMC components by category
- Retrieve component prop specifications
- Validate component configurations
- Provide actionable error messages
- Suggest corrections for common mistakes
## Available Tools
### Component Validation
- `list_components` - List components, optionally by category
- `get_component_props` - Get detailed prop specifications
- `validate_component` - Validate a component configuration
## Workflow Guidelines
1. **Before any DMC component usage**:
- Call `get_component_props` to understand available props
- Verify prop types match expected values
- Check enum constraints
2. **After writing component code**:
- Extract component name and props
- Call `validate_component` with the configuration
- Fix any errors before proceeding
3. **When errors occur**:
- Identify the invalid prop or value
- Provide specific correction
- Offer to re-validate after fix
## Validation Strictness
This agent is intentionally strict because:
- Invalid props cause runtime errors
- Typos in prop names fail silently
- Wrong enum values break styling
- Type mismatches cause crashes
**Always validate before rendering.**
## Error Message Format
Provide clear, actionable errors:
```
❌ Invalid prop 'colour' for Button. Did you mean 'color'?
❌ Prop 'size' expects one of ['xs', 'sm', 'md', 'lg', 'xl'], got 'huge'
⚠️ Prop 'fullwidth' should be 'fullWidth' (camelCase)
⚠️ Unknown prop 'onClick' - use 'n_clicks' for Dash callbacks
```
## Component Categories
| Category | Description | Examples |
|----------|-------------|----------|
| `inputs` | User input components | Button, TextInput, Select, Checkbox |
| `navigation` | Navigation elements | NavLink, Tabs, Breadcrumbs |
| `feedback` | User feedback | Alert, Notification, Progress |
| `overlays` | Modal/popup elements | Modal, Drawer, Tooltip |
| `typography` | Text display | Text, Title, Code |
| `layout` | Structure components | Container, Grid, Stack |
| `data` | Data display | Table, Badge, Card |
## Common Mistakes
### Prop Name Typos
```python
# Wrong
dmc.Button(colour="blue") # 'colour' vs 'color'
# Correct
dmc.Button(color="blue")
```
### Invalid Enum Values
```python
# Wrong
dmc.Button(size="large") # 'large' not valid
# Correct
dmc.Button(size="lg") # Use 'lg'
```
### Wrong Case
```python
# Wrong
dmc.Button(fullwidth=True) # lowercase
# Correct
dmc.Button(fullWidth=True) # camelCase
```
### React vs Dash Props
```python
# Wrong (React pattern)
dmc.Button(onClick=handler)
# Correct (Dash pattern)
dmc.Button(id="my-button", n_clicks=0)
# Then use callback with Input("my-button", "n_clicks")
```
## Example Interactions
**User**: I want to use a Button component
**Agent**:
- Uses `get_component_props("Button")`
- Shows available props with types
- Explains common usage patterns
**User**: Check this code: `dmc.Button(variant="primary", colour="red")`
**Agent**:
- Uses `validate_component`
- Reports errors:
- 'colour' should be 'color'
- 'variant' expects ['filled', 'outline', ...], not 'primary'
- Suggests: `dmc.Button(variant="filled", color="red")`
**User**: What input components are available?
**Agent**:
- Uses `list_components(category="inputs")`
- Lists all input components with brief descriptions
## Integration with Other Agents
When layout-builder or theme-setup create components:
1. They should call component-check first
2. Validate all props before finalizing
3. Ensure theme tokens are valid color references
This creates a validation layer that prevents invalid components from reaching the user's code.

View File

@@ -0,0 +1,151 @@
# Layout Builder Agent
You are a practical dashboard layout specialist. Your role is to help users create well-structured dashboard layouts with proper filtering, grid systems, and responsive design.
## Trigger Conditions
Activate this agent when:
- User wants to create a dashboard structure
- User mentions layout, grid, or responsive design
- User needs filter components for their dashboard
- User wants to organize dashboard sections
## Capabilities
- Create base layouts (basic, sidebar, tabs, split)
- Add filter components (dropdowns, date pickers, sliders)
- Configure responsive grid settings
- Add content sections
- Retrieve and inspect layouts
## Available Tools
### Layout Management
- `layout_create` - Create a new layout structure
- `layout_add_filter` - Add filter components
- `layout_set_grid` - Configure grid settings
- `layout_add_section` - Add content sections
- `layout_get` - Retrieve layout details
## Workflow Guidelines
1. **Understand the purpose**:
- What data will the dashboard display?
- Who is the target audience?
- What actions do users need to take?
2. **Choose the template**:
- Basic: Simple content display
- Sidebar: Navigation-heavy dashboards
- Tabs: Multi-page or multi-view
- Split: Comparison or detail views
3. **Add filters**:
- What dimensions can users filter by?
- Date ranges? Categories? Search?
- Position filters appropriately
4. **Configure the grid**:
- How many columns?
- Mobile responsiveness?
- Spacing between components?
5. **Add sections**:
- Group related content
- Name sections clearly
- Consider visual hierarchy
## Conversation Style
Be practical and suggest common patterns:
- "For a sales dashboard, I'd recommend a sidebar layout with date range and product category filters at the top."
- "Since you're comparing metrics, a split-pane layout would work well - left for current period, right for comparison."
- "A tabbed layout lets you separate overview, details, and settings without overwhelming users."
## Template Reference
### Basic Layout
Best for: Simple dashboards, reports, single-purpose views
```
┌─────────────────────────────┐
│ Header │
├─────────────────────────────┤
│ Filters │
├─────────────────────────────┤
│ Content │
└─────────────────────────────┘
```
### Sidebar Layout
Best for: Navigation-heavy apps, multi-section dashboards
```
┌────────┬────────────────────┐
│ │ Header │
│ Nav ├────────────────────┤
│ │ Filters │
│ ├────────────────────┤
│ │ Content │
└────────┴────────────────────┘
```
### Tabs Layout
Best for: Multi-page apps, view switching
```
┌─────────────────────────────┐
│ Header │
├──────┬──────┬──────┬────────┤
│ Tab1 │ Tab2 │ Tab3 │ │
├──────┴──────┴──────┴────────┤
│ Tab Content │
└─────────────────────────────┘
```
### Split Layout
Best for: Comparisons, master-detail views
```
┌─────────────────────────────┐
│ Header │
├──────────────┬──────────────┤
│ Left │ Right │
│ Pane │ Pane │
└──────────────┴──────────────┘
```
## Filter Types
| Type | Use Case | Example |
|------|----------|---------|
| `dropdown` | Category selection | Product category, region |
| `date_range` | Time filtering | Report period |
| `slider` | Numeric range | Price range, quantity |
| `checkbox` | Multi-select options | Status flags |
| `search` | Text search | Customer lookup |
## Example Interactions
**User**: I need a dashboard for sales data
**Agent**: I'll create a sales dashboard layout.
- Asks about key metrics to display
- Suggests sidebar layout for navigation
- Adds date range and category filters
- Creates layout with `layout_create`
- Adds filters with `layout_add_filter`
- Returns complete layout structure
**User**: Can you add a filter for product category?
**Agent**:
- Uses `layout_add_filter` with dropdown type
- Specifies position and options
- Returns updated layout
## Error Handling
If layout creation fails:
1. Check if layout name already exists
2. Validate template type
3. Verify grid configuration values
Common issues:
- Invalid template → show valid options
- Invalid filter type → list available types
- Grid column count mismatch → suggest fixes

View File

@@ -0,0 +1,93 @@
# Theme Setup Agent
You are a design-focused theme setup specialist. Your role is to help users create consistent, brand-aligned themes for their Dash Mantine Components applications.
## Trigger Conditions
Activate this agent when:
- User starts a new project and needs theme setup
- User mentions brand colors, design system, or theming
- User wants consistent styling across components
- User asks about color schemes or typography
## Capabilities
- Create new themes with brand colors
- Configure typography settings
- Set up consistent spacing and radius
- Validate theme configurations
- Export themes as CSS for external use
## Available Tools
### Theme Management
- `theme_create` - Create a new theme with design tokens
- `theme_extend` - Extend an existing theme with overrides
- `theme_validate` - Validate a theme configuration
- `theme_export_css` - Export theme as CSS custom properties
- `theme_list` - List available themes
- `theme_activate` - Set the active theme
## Workflow Guidelines
1. **Understand the brand**:
- What colors represent the brand?
- Light mode, dark mode, or both?
- Any specific font preferences?
- Rounded or sharp corners?
2. **Gather requirements**:
- Ask about primary brand color
- Ask about color scheme preference
- Ask about font family
- Ask about border radius preference
3. **Create the theme**:
- Use `theme_create` with gathered preferences
- Validate with `theme_validate`
- Fix any issues
4. **Verify and demonstrate**:
- Show the created theme settings
- Offer to export as CSS
- Activate the theme for immediate use
## Conversation Style
Be design-focused and ask about visual preferences:
- "What's your brand's primary color? I can use any Mantine color like blue, indigo, violet, or a custom hex code."
- "Do you prefer light mode, dark mode, or should the app follow system preference?"
- "What corner style fits your brand better - rounded (friendly) or sharp (professional)?"
## Example Interactions
**User**: I need to set up theming for my dashboard
**Agent**: I'll help you create a theme. Let me ask a few questions about your brand...
- Uses AskUserQuestion for color preference
- Uses AskUserQuestion for color scheme
- Uses theme_create with answers
- Uses theme_validate to verify
- Activates the new theme
**User**: Our brand uses #1890ff as the primary color
**Agent**:
- Creates custom color palette from the hex
- Uses theme_create with custom colors
- Validates and activates
**User**: Can you export my theme as CSS?
**Agent**:
- Uses theme_export_css
- Returns CSS custom properties
## Error Handling
If validation fails:
1. Show the specific errors clearly
2. Suggest fixes based on the error
3. Offer to recreate with corrections
Common issues:
- Invalid color names → suggest valid Mantine colors
- Invalid enum values → show allowed options
- Missing required fields → provide defaults

View File

@@ -0,0 +1,144 @@
# viz-platform CLAUDE.md Integration
Add this snippet to your project's CLAUDE.md to enable viz-platform capabilities.
## Integration Snippet
```markdown
## Visualization (viz-platform)
This project uses viz-platform for Dash Mantine Components dashboards.
### Available Commands
- `/component {name}` - Inspect DMC component props
- `/chart {type}` - Create Plotly charts (line, bar, scatter, pie, area, histogram, box, heatmap, sunburst, treemap)
- `/dashboard {template}` - Create layouts (basic, sidebar, tabs, split)
- `/theme {name}` - Apply a theme
- `/theme-new {name}` - Create custom theme
- `/theme-css {name}` - Export theme as CSS
### MCP Tools Available
- **DMC**: list_components, get_component_props, validate_component
- **Charts**: chart_create, chart_configure_interaction
- **Layouts**: layout_create, layout_add_filter, layout_set_grid, layout_add_section, layout_get
- **Themes**: theme_create, theme_extend, theme_validate, theme_export_css, theme_list, theme_activate
- **Pages**: page_create, page_add_navbar, page_set_auth, page_list, page_get_app_config
### Component Validation
ALWAYS validate DMC components before use:
1. Check props with `get_component_props(component_name)`
2. Validate usage with `validate_component(component_name, props)`
3. Fix any errors before proceeding
### Project Theme
Theme: [YOUR_THEME_NAME or "default"]
Color scheme: [light/dark]
Primary color: [color name]
```
## Cross-Plugin Configuration
If using with data-platform, add this section:
```markdown
## Data + Visualization Workflow
### Data Loading (data-platform)
- `/ingest {file}` - Load CSV, Parquet, or JSON
- `/schema {table}` - View database schema
- `/profile {data_ref}` - Statistical summary
### Visualization (viz-platform)
- `/chart {type}` - Create charts from loaded data
- `/dashboard {template}` - Build dashboard layouts
### Workflow Pattern
1. Load data: `read_csv("data.csv")` → returns `data_ref`
2. Create chart: `chart_create(data_ref="data_ref", ...)`
3. Add to layout: `layout_add_section(chart_ref="...")`
4. Apply theme: `theme_activate("my-theme")`
```
## Agent Configuration
### Using theme-setup agent
When user mentions theming or brand colors:
```markdown
Use the theme-setup agent for:
- Creating new themes with brand colors
- Configuring typography and spacing
- Exporting themes as CSS
```
### Using layout-builder agent
When user wants dashboard structure:
```markdown
Use the layout-builder agent for:
- Creating dashboard layouts
- Adding filter components
- Configuring responsive grids
```
### Using component-check agent
For code review and debugging:
```markdown
Use the component-check agent for:
- Validating DMC component usage
- Fixing prop errors
- Understanding component APIs
```
## Example Project CLAUDE.md
```markdown
# Project: Sales Dashboard
## Tech Stack
- Backend: FastAPI
- Frontend: Dash with Mantine Components
- Data: PostgreSQL + pandas
## Data (data-platform)
- Database: PostgreSQL with sales data
- Key tables: orders, customers, products
## Visualization (viz-platform)
- Theme: corporate (indigo primary, light mode)
- Layout: sidebar with date and category filters
- Charts: line (trends), bar (comparisons), pie (breakdown)
### Component Validation
ALWAYS use component-check before rendering:
- get_component_props first
- validate_component after
### Dashboard Structure
```
Sidebar: Navigation links
Header: Title + date range filter
Main:
- Row 1: KPI cards
- Row 2: Line chart (sales over time)
- Row 3: Bar chart (by category) + Pie chart (by region)
```
```
## Troubleshooting
### MCP tools not available
1. Check venv exists: `ls mcp-servers/viz-platform/.venv/`
2. Rebuild if needed: `cd mcp-servers/viz-platform && python -m venv .venv && pip install -r requirements.txt`
3. Restart Claude Code session
### Component validation fails
1. Check DMC version matches registry
2. Use `list_components()` to see available components
3. Verify prop names are camelCase
### Charts not rendering
1. Verify data_ref exists with `list_data()`
2. Check column names match data
3. Validate theme is active

View File

@@ -0,0 +1,86 @@
---
description: Create a Plotly chart with theme integration
---
# Create Chart
Create a Plotly chart with automatic theme token application.
## Usage
```
/chart {type}
```
## Arguments
- `type` (required): Chart type - one of: line, bar, scatter, pie, area, histogram, box, heatmap, sunburst, treemap
## Examples
```
/chart line
/chart bar
/chart scatter
/chart pie
```
## Tool Mapping
This command uses the `chart_create` MCP tool:
```python
chart_create(
chart_type="line",
data_ref="df_sales", # Reference to loaded DataFrame
x="date", # X-axis column
y="revenue", # Y-axis column
color=None, # Optional: column for color grouping
title="Sales Over Time", # Optional: chart title
theme=None # Optional: theme name to apply
)
```
## Workflow
1. **User invokes**: `/chart line`
2. **Agent asks**: Which DataFrame to use? (list available with `list_data` from data-platform)
3. **Agent asks**: Which columns for X and Y axes?
4. **Agent asks**: Any grouping/color column?
5. **Agent creates**: Chart with `chart_create` tool
6. **Agent returns**: Plotly figure JSON ready for rendering
## Chart Types
| Type | Best For |
|------|----------|
| `line` | Time series, trends |
| `bar` | Comparisons, categories |
| `scatter` | Correlations, distributions |
| `pie` | Part-to-whole relationships |
| `area` | Cumulative trends |
| `histogram` | Frequency distributions |
| `box` | Statistical distributions |
| `heatmap` | Matrix correlations |
| `sunburst` | Hierarchical data |
| `treemap` | Hierarchical proportions |
## Theme Integration
Charts automatically inherit colors from the active theme:
- Primary color for main data
- Color palette for multi-series
- Font family and sizes
- Background colors
Override with explicit theme:
```python
chart_create(chart_type="bar", ..., theme="my-dark-theme")
```
## Output
Returns Plotly figure JSON that can be:
- Rendered in a Dash app
- Saved as HTML/PNG
- Embedded in a layout component

View File

@@ -0,0 +1,161 @@
---
description: Inspect Dash Mantine Component props and validation
---
# Inspect Component
Inspect a Dash Mantine Component's available props, types, and defaults.
## Usage
```
/component {name}
```
## Arguments
- `name` (required): DMC component name (e.g., Button, Card, TextInput)
## Examples
```
/component Button
/component TextInput
/component Select
/component Card
```
## Tool Mapping
This command uses the `get_component_props` MCP tool:
```python
get_component_props(component="Button")
```
## Output Example
```json
{
"component": "Button",
"category": "inputs",
"props": {
"children": {
"type": "any",
"required": false,
"description": "Button content"
},
"variant": {
"type": "string",
"enum": ["filled", "outline", "light", "subtle", "default", "gradient"],
"default": "filled",
"description": "Button appearance variant"
},
"color": {
"type": "string",
"default": "blue",
"description": "Button color from theme"
},
"size": {
"type": "string",
"enum": ["xs", "sm", "md", "lg", "xl"],
"default": "sm",
"description": "Button size"
},
"radius": {
"type": "string",
"enum": ["xs", "sm", "md", "lg", "xl"],
"default": "sm",
"description": "Border radius"
},
"disabled": {
"type": "boolean",
"default": false,
"description": "Disable button"
},
"loading": {
"type": "boolean",
"default": false,
"description": "Show loading indicator"
},
"fullWidth": {
"type": "boolean",
"default": false,
"description": "Button takes full width"
}
}
}
```
## Listing All Components
To see all available components:
```python
list_components(category=None) # All components
list_components(category="inputs") # Just input components
```
### Component Categories
| Category | Components |
|----------|------------|
| `inputs` | Button, TextInput, Select, Checkbox, Radio, Switch, Slider, etc. |
| `navigation` | NavLink, Tabs, Breadcrumbs, Pagination, Stepper |
| `feedback` | Alert, Notification, Progress, Loader, Skeleton |
| `overlays` | Modal, Drawer, Tooltip, Popover, Menu |
| `typography` | Text, Title, Code, Blockquote, List |
| `layout` | Container, Grid, Stack, Group, Space, Divider |
| `data` | Table, Badge, Card, Paper, Timeline |
## Validating Component Usage
After inspecting props, validate your usage:
```python
validate_component(
component="Button",
props={
"variant": "filled",
"color": "blue",
"size": "lg",
"children": "Click me"
}
)
```
Returns:
```json
{
"valid": true,
"errors": [],
"warnings": []
}
```
Or with errors:
```json
{
"valid": false,
"errors": [
"Invalid prop 'colour' for Button. Did you mean 'color'?",
"Prop 'size' expects one of ['xs', 'sm', 'md', 'lg', 'xl'], got 'huge'"
],
"warnings": [
"Prop 'fullwidth' should be 'fullWidth' (camelCase)"
]
}
```
## Why This Matters
DMC components have many props with specific type constraints. This tool:
- Prevents hallucinated prop names
- Validates enum values
- Catches typos before runtime
- Documents available options
## Related Commands
- `/chart {type}` - Create charts
- `/dashboard {template}` - Create layouts

View File

@@ -0,0 +1,115 @@
---
description: Create a dashboard layout with the layout-builder agent
---
# Create Dashboard
Create a dashboard layout structure with filters, grids, and sections.
## Usage
```
/dashboard {template}
```
## Arguments
- `template` (optional): Layout template - one of: basic, sidebar, tabs, split
## Examples
```
/dashboard # Interactive layout builder
/dashboard basic # Simple single-column layout
/dashboard sidebar # Layout with sidebar navigation
/dashboard tabs # Tabbed multi-page layout
/dashboard split # Split-pane layout
```
## Agent Mapping
This command activates the **layout-builder** agent which orchestrates multiple tools:
1. `layout_create` - Create the base layout structure
2. `layout_add_filter` - Add filter components (dropdowns, date pickers)
3. `layout_set_grid` - Configure responsive grid settings
4. `layout_add_section` - Add content sections
## Workflow
1. **User invokes**: `/dashboard sidebar`
2. **Agent asks**: What is the dashboard purpose?
3. **Agent asks**: What filters are needed?
4. **Agent creates**: Base layout with `layout_create`
5. **Agent adds**: Filters with `layout_add_filter`
6. **Agent configures**: Grid with `layout_set_grid`
7. **Agent returns**: Complete layout structure
## Templates
### Basic
Single-column layout with header and content area.
```
┌─────────────────────────────┐
│ Header │
├─────────────────────────────┤
│ │
│ Content │
│ │
└─────────────────────────────┘
```
### Sidebar
Layout with collapsible sidebar navigation.
```
┌────────┬────────────────────┐
│ │ Header │
│ Nav ├────────────────────┤
│ │ │
│ │ Content │
│ │ │
└────────┴────────────────────┘
```
### Tabs
Tabbed layout for multi-page dashboards.
```
┌─────────────────────────────┐
│ Header │
├──────┬──────┬──────┬────────┤
│ Tab1 │ Tab2 │ Tab3 │ │
├──────┴──────┴──────┴────────┤
│ │
│ Tab Content │
│ │
└─────────────────────────────┘
```
### Split
Split-pane layout for comparisons.
```
┌─────────────────────────────┐
│ Header │
├──────────────┬──────────────┤
│ │ │
│ Left │ Right │
│ Pane │ Pane │
│ │ │
└──────────────┴──────────────┘
```
## Filter Types
Available filter components:
- `dropdown` - Single/multi-select dropdown
- `date_range` - Date range picker
- `slider` - Numeric range slider
- `checkbox` - Checkbox group
- `search` - Text search input
## Output
Returns a layout structure that can be:
- Used with page tools to create full app
- Rendered as a Dash layout
- Combined with chart components

View File

@@ -0,0 +1,166 @@
---
description: Interactive setup wizard for viz-platform plugin - configures MCP server and theme preferences
---
# Viz-Platform Setup Wizard
This command sets up the viz-platform plugin with Dash Mantine Components validation and theming.
## Important Context
- **This command uses Bash, Read, Write, and AskUserQuestion tools** - NOT MCP tools
- **MCP tools won't work until after setup + session restart**
- **DMC version detection is automatic** based on installed package
---
## Phase 1: Environment Validation
### Step 1.1: Check Python Version
```bash
python3 --version
```
Requires Python 3.10+. If below, stop setup and inform user.
### Step 1.2: Check DMC Installation
```bash
python3 -c "import dash_mantine_components as dmc; print(f'DMC {dmc.__version__}')" 2>/dev/null || echo "DMC_NOT_INSTALLED"
```
If DMC is not installed, inform user:
```
Dash Mantine Components is not installed. Install it with:
pip install dash-mantine-components>=0.14.0
```
---
## Phase 2: MCP Server Setup
### Step 2.1: Locate Viz-Platform MCP Server
```bash
# If running from installed marketplace
ls -la ~/.claude/plugins/marketplaces/leo-claude-mktplace/mcp-servers/viz-platform/ 2>/dev/null || echo "NOT_FOUND_INSTALLED"
# If running from source
ls -la ~/claude-plugins-work/mcp-servers/viz-platform/ 2>/dev/null || echo "NOT_FOUND_SOURCE"
```
### Step 2.2: Check Virtual Environment
```bash
ls -la /path/to/mcp-servers/viz-platform/.venv/bin/python 2>/dev/null && echo "VENV_EXISTS" || echo "VENV_MISSING"
```
### Step 2.3: Create Virtual Environment (if missing)
```bash
cd /path/to/mcp-servers/viz-platform && python3 -m venv .venv && source .venv/bin/activate && pip install --upgrade pip && pip install -r requirements.txt && deactivate
```
---
## Phase 3: Theme Preferences (Optional)
### Step 3.1: Ask About Theme Setup
Use AskUserQuestion:
- Question: "Do you want to configure a default theme for your projects?"
- Header: "Theme"
- Options:
- "Yes, set up a custom theme"
- "No, use Mantine defaults"
**If user chooses "No":** Skip to Phase 4.
### Step 3.2: Choose Base Theme
Use AskUserQuestion:
- Question: "Which base color scheme do you prefer?"
- Header: "Colors"
- Options:
- "Light mode (default)"
- "Dark mode"
- "System preference (auto)"
### Step 3.3: Choose Primary Color
Use AskUserQuestion:
- Question: "What primary color should be used for buttons and accents?"
- Header: "Primary"
- Options:
- "Blue (default)"
- "Indigo"
- "Violet"
- "Other (I'll specify)"
### Step 3.4: Create System Theme Config
```bash
mkdir -p ~/.config/claude
cat > ~/.config/claude/viz-platform.env << 'EOF'
# Viz-Platform Configuration
# Generated by viz-platform /initial-setup
VIZ_PLATFORM_COLOR_SCHEME=<SELECTED_SCHEME>
VIZ_PLATFORM_PRIMARY_COLOR=<SELECTED_COLOR>
EOF
chmod 600 ~/.config/claude/viz-platform.env
```
---
## Phase 4: Validation
### Step 4.1: Verify MCP Server
```bash
cd /path/to/mcp-servers/viz-platform && .venv/bin/python -c "from mcp_server.server import VizPlatformMCPServer; print('MCP Server OK')"
```
### Step 4.2: Summary
```
╔════════════════════════════════════════════════════════════╗
║ VIZ-PLATFORM SETUP COMPLETE ║
╠════════════════════════════════════════════════════════════╣
║ MCP Server: ✓ Ready ║
║ DMC Version: [Detected version] ║
║ DMC Tools: ✓ Available (3 tools) ║
║ Chart Tools: ✓ Available (2 tools) ║
║ Layout Tools: ✓ Available (5 tools) ║
║ Theme Tools: ✓ Available (6 tools) ║
║ Page Tools: ✓ Available (5 tools) ║
╚════════════════════════════════════════════════════════════╝
```
### Step 4.3: Session Restart Notice
---
**⚠️ Session Restart Required**
Restart your Claude Code session for MCP tools to become available.
**After restart, you can:**
- Run `/component {name}` to inspect component props
- Run `/chart {type}` to create a chart
- Run `/dashboard {template}` to create a dashboard layout
- Run `/theme {name}` to apply a theme
- Run `/theme-new {name}` to create a custom theme
---
## Tool Summary
| Category | Tools |
|----------|-------|
| DMC Validation | list_components, get_component_props, validate_component |
| Charts | chart_create, chart_configure_interaction |
| Layouts | layout_create, layout_add_filter, layout_set_grid, layout_get, layout_add_section |
| Themes | theme_create, theme_extend, theme_validate, theme_export_css, theme_list, theme_activate |
| Pages | page_create, page_add_navbar, page_set_auth, page_list, page_get_app_config |

View File

@@ -0,0 +1,111 @@
---
description: Export a theme as CSS custom properties
---
# Export Theme as CSS
Export a theme's design tokens as CSS custom properties for use outside Dash.
## Usage
```
/theme-css {name}
```
## Arguments
- `name` (required): Theme name to export
## Examples
```
/theme-css dark
/theme-css corporate
/theme-css my-brand
```
## Tool Mapping
This command uses the `theme_export_css` MCP tool:
```python
theme_export_css(theme_name="corporate")
```
## Output Example
```css
:root {
/* Colors */
--mantine-color-scheme: light;
--mantine-primary-color: indigo;
--mantine-color-primary-0: #edf2ff;
--mantine-color-primary-1: #dbe4ff;
--mantine-color-primary-2: #bac8ff;
--mantine-color-primary-3: #91a7ff;
--mantine-color-primary-4: #748ffc;
--mantine-color-primary-5: #5c7cfa;
--mantine-color-primary-6: #4c6ef5;
--mantine-color-primary-7: #4263eb;
--mantine-color-primary-8: #3b5bdb;
--mantine-color-primary-9: #364fc7;
/* Typography */
--mantine-font-family: Inter, sans-serif;
--mantine-heading-font-family: Inter, sans-serif;
--mantine-font-size-xs: 0.75rem;
--mantine-font-size-sm: 0.875rem;
--mantine-font-size-md: 1rem;
--mantine-font-size-lg: 1.125rem;
--mantine-font-size-xl: 1.25rem;
/* Spacing */
--mantine-spacing-xs: 0.625rem;
--mantine-spacing-sm: 0.75rem;
--mantine-spacing-md: 1rem;
--mantine-spacing-lg: 1.25rem;
--mantine-spacing-xl: 2rem;
/* Border Radius */
--mantine-radius-xs: 0.125rem;
--mantine-radius-sm: 0.25rem;
--mantine-radius-md: 0.5rem;
--mantine-radius-lg: 1rem;
--mantine-radius-xl: 2rem;
/* Shadows */
--mantine-shadow-xs: 0 1px 3px rgba(0, 0, 0, 0.05);
--mantine-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--mantine-shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--mantine-shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--mantine-shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
}
```
## Use Cases
### External CSS Files
Include the exported CSS in non-Dash projects:
```html
<link rel="stylesheet" href="theme-tokens.css">
```
### Design Handoff
Share design tokens with designers or other teams.
### Documentation
Generate theme documentation for style guides.
### Other Frameworks
Use Mantine-compatible tokens in React, Vue, or other projects.
## Workflow
1. **User invokes**: `/theme-css corporate`
2. **Tool exports**: Theme tokens as CSS
3. **User can**: Save to file or copy to clipboard
## Related Commands
- `/theme {name}` - Apply a theme
- `/theme-new {name}` - Create a new theme

View File

@@ -0,0 +1,117 @@
---
description: Create a new custom theme with design tokens
---
# Create New Theme
Create a new custom theme with specified design tokens.
## Usage
```
/theme-new {name}
```
## Arguments
- `name` (required): Name for the new theme
## Examples
```
/theme-new corporate
/theme-new dark-blue
/theme-new brand-theme
```
## Tool Mapping
This command uses the `theme_create` MCP tool:
```python
theme_create(
name="corporate",
primary_color="indigo",
color_scheme="light",
font_family="Inter, sans-serif",
heading_font_family=None, # Optional: separate heading font
border_radius="md", # xs, sm, md, lg, xl
spacing_scale=1.0, # Multiplier for spacing
colors=None # Optional: custom color palette
)
```
## Workflow
1. **User invokes**: `/theme-new corporate`
2. **Agent asks**: Primary color preference?
3. **Agent asks**: Light or dark color scheme?
4. **Agent asks**: Font family preference?
5. **Agent creates**: Theme with `theme_create`
6. **Agent validates**: Theme with `theme_validate`
7. **Agent activates**: New theme is ready to use
## Theme Properties
### Colors
- `primary_color`: Main accent color (blue, indigo, violet, etc.)
- `color_scheme`: "light" or "dark"
- `colors`: Custom color palette override
### Typography
- `font_family`: Body text font
- `heading_font_family`: Optional heading font
### Spacing
- `border_radius`: Component corner rounding
- `spacing_scale`: Multiply default spacing values
## Mantine Color Palette
Available primary colors:
- blue, cyan, teal, green, lime
- yellow, orange, red, pink, grape
- violet, indigo, gray, dark
## Custom Color Example
```python
theme_create(
name="brand",
primary_color="custom",
colors={
"custom": [
"#e6f7ff", # 0 - lightest
"#bae7ff", # 1
"#91d5ff", # 2
"#69c0ff", # 3
"#40a9ff", # 4
"#1890ff", # 5 - primary
"#096dd9", # 6
"#0050b3", # 7
"#003a8c", # 8
"#002766" # 9 - darkest
]
}
)
```
## Extending Themes
To create a theme based on another:
```python
theme_extend(
base_theme="dark",
name="dark-corporate",
overrides={
"primary_color": "indigo",
"font_family": "Roboto, sans-serif"
}
)
```
## Related Commands
- `/theme {name}` - Apply a theme
- `/theme-css {name}` - Export theme as CSS

View File

@@ -0,0 +1,69 @@
---
description: Apply an existing theme to the current context
---
# Apply Theme
Apply an existing theme to activate its design tokens.
## Usage
```
/theme {name}
```
## Arguments
- `name` (required): Theme name to activate
## Examples
```
/theme dark
/theme corporate-blue
/theme my-custom-theme
```
## Tool Mapping
This command uses the `theme_activate` MCP tool:
```python
theme_activate(theme_name="dark")
```
## Workflow
1. **User invokes**: `/theme dark`
2. **Tool activates**: Theme becomes active for subsequent operations
3. **Charts/layouts**: Automatically use active theme tokens
## Built-in Themes
| Theme | Description |
|-------|-------------|
| `light` | Mantine default light mode |
| `dark` | Mantine default dark mode |
## Listing Available Themes
To see all available themes:
```python
theme_list()
```
Returns both built-in and custom themes.
## Theme Effects
When a theme is activated:
- New charts inherit theme colors
- New layouts use theme spacing
- Components use theme typography
- Callbacks can read active theme tokens
## Related Commands
- `/theme-new {name}` - Create a new theme
- `/theme-css {name}` - Export theme as CSS

View File

@@ -0,0 +1,10 @@
{
"hooks": [
{
"event": "SessionStart",
"type": "command",
"command": "echo 'viz-platform plugin loaded'",
"timeout": 5000
}
]
}

View File

@@ -0,0 +1 @@
2026-01-26T11:59:05 | .claude-plugin | /home/lmiranda/claude-plugins-work/.claude-plugin/marketplace.json | CLAUDE.md .claude-plugin/marketplace.json

View File

@@ -0,0 +1 @@
../../../mcp-servers/viz-platform

View File

@@ -67,6 +67,7 @@ main() {
# Shared MCP servers at repository root (v3.0.0+) # Shared MCP servers at repository root (v3.0.0+)
update_mcp_server "gitea" update_mcp_server "gitea"
update_mcp_server "netbox" update_mcp_server "netbox"
update_mcp_server "data-platform"
check_changelog check_changelog

172
scripts/release.sh Executable file
View File

@@ -0,0 +1,172 @@
#!/bin/bash
# release.sh - Create a new release with version consistency
#
# Usage: ./scripts/release.sh X.Y.Z
#
# This script ensures all version references are updated consistently:
# 1. CHANGELOG.md - [Unreleased] becomes [X.Y.Z] - YYYY-MM-DD
# 2. README.md - Title updated to vX.Y.Z
# 3. marketplace.json - version field updated
# 4. Git commit and tag created
#
# Prerequisites:
# - Clean working directory (no uncommitted changes)
# - [Unreleased] section in CHANGELOG.md with content
# - On development branch
set -e
VERSION=$1
DATE=$(date +%Y-%m-%d)
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
error() { echo -e "${RED}ERROR: $1${NC}" >&2; exit 1; }
warn() { echo -e "${YELLOW}WARNING: $1${NC}"; }
success() { echo -e "${GREEN}$1${NC}"; }
info() { echo -e "$1"; }
# Validate arguments
if [ -z "$VERSION" ]; then
echo "Usage: ./scripts/release.sh X.Y.Z"
echo ""
echo "Example: ./scripts/release.sh 3.2.0"
echo ""
echo "This will:"
echo " 1. Update CHANGELOG.md [Unreleased] -> [X.Y.Z] - $(date +%Y-%m-%d)"
echo " 2. Update README.md title to vX.Y.Z"
echo " 3. Update marketplace.json version to X.Y.Z"
echo " 4. Commit with message 'chore: release vX.Y.Z'"
echo " 5. Create git tag vX.Y.Z"
exit 1
fi
# Validate version format
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
error "Invalid version format. Use X.Y.Z (e.g., 3.2.0)"
fi
# Check we're in the right directory
if [ ! -f "CHANGELOG.md" ] || [ ! -f "README.md" ] || [ ! -f ".claude-plugin/marketplace.json" ]; then
error "Must run from repository root (CHANGELOG.md, README.md, .claude-plugin/marketplace.json must exist)"
fi
# Check for clean working directory
if [ -n "$(git status --porcelain)" ]; then
warn "Working directory has uncommitted changes"
echo ""
git status --short
echo ""
read -p "Continue anyway? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# Check current branch
BRANCH=$(git branch --show-current)
if [ "$BRANCH" != "development" ] && [ "$BRANCH" != "main" ]; then
warn "Not on development or main branch (current: $BRANCH)"
read -p "Continue anyway? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# Check [Unreleased] section has content
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
error "CHANGELOG.md missing [Unreleased] section"
fi
# Check if tag already exists
if git tag -l | grep -q "^v$VERSION$"; then
error "Tag v$VERSION already exists"
fi
info ""
info "=== Release v$VERSION ==="
info ""
# Show what will change
info "Changes to be made:"
info " CHANGELOG.md: [Unreleased] -> [$VERSION] - $DATE"
info " README.md: title -> v$VERSION"
info " marketplace.json: version -> $VERSION"
info " Git: commit + tag v$VERSION"
info ""
# Preview CHANGELOG [Unreleased] content
info "Current [Unreleased] content:"
info "---"
sed -n '/^## \[Unreleased\]/,/^## \[/p' CHANGELOG.md | head -30
info "---"
info ""
read -p "Proceed with release? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
info "Aborted"
exit 0
fi
info ""
info "Updating files..."
# 1. Update CHANGELOG.md
# Replace [Unreleased] with [X.Y.Z] - DATE and add new [Unreleased] section
sed -i "s/^## \[Unreleased\]$/## [Unreleased]\n\n*Changes staged for the next release*\n\n---\n\n## [$VERSION] - $DATE/" CHANGELOG.md
# Remove the placeholder text if it exists after the new [Unreleased]
sed -i '/^\*Changes staged for the next release\*$/d' CHANGELOG.md
# Clean up any double blank lines
sed -i '/^$/N;/^\n$/d' CHANGELOG.md
success " CHANGELOG.md updated"
# 2. Update README.md title
sed -i "s/^# Leo Claude Marketplace - v[0-9]\+\.[0-9]\+\.[0-9]\+$/# Leo Claude Marketplace - v$VERSION/" README.md
success " README.md updated"
# 3. Update marketplace.json version
sed -i "s/\"version\": \"[0-9]\+\.[0-9]\+\.[0-9]\+\"/\"version\": \"$VERSION\"/" .claude-plugin/marketplace.json
success " marketplace.json updated"
info ""
info "Files updated. Review changes:"
info ""
git diff --stat
info ""
git diff CHANGELOG.md | head -40
info ""
read -p "Commit and tag? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
warn "Changes made but not committed. Run 'git checkout -- .' to revert."
exit 0
fi
# Commit
git add CHANGELOG.md README.md .claude-plugin/marketplace.json
git commit -m "chore: release v$VERSION"
success " Committed"
# Tag
git tag "v$VERSION"
success " Tagged v$VERSION"
info ""
success "=== Release v$VERSION created ==="
info ""
info "Next steps:"
info " 1. Review the commit: git show HEAD"
info " 2. Push to remote: git push && git push --tags"
info " 3. Merge to main if on development branch"
info ""

View File

@@ -116,6 +116,15 @@ verify_symlinks() {
log_error "pr-review/gitea symlink missing" log_error "pr-review/gitea symlink missing"
log_todo "Run: ln -s ../../../mcp-servers/gitea plugins/pr-review/mcp-servers/gitea" log_todo "Run: ln -s ../../../mcp-servers/gitea plugins/pr-review/mcp-servers/gitea"
fi fi
# Check data-platform -> data-platform symlink
local dataplatform_link="$REPO_ROOT/plugins/data-platform/mcp-servers/data-platform"
if [[ -L "$dataplatform_link" ]]; then
log_success "data-platform symlink exists"
else
log_error "data-platform symlink missing"
log_todo "Run: ln -s ../../../mcp-servers/data-platform plugins/data-platform/mcp-servers/data-platform"
fi
} }
# --- Section 3: Config File Templates --- # --- Section 3: Config File Templates ---
@@ -178,6 +187,22 @@ EOF
chmod 600 "$config_dir/git-flow.env" chmod 600 "$config_dir/git-flow.env"
log_success "git-flow.env template created" log_success "git-flow.env template created"
fi fi
# PostgreSQL config (for data-platform, optional)
if [[ -f "$config_dir/postgres.env" ]]; then
log_skip "postgres.env already exists"
else
cat > "$config_dir/postgres.env" << 'EOF'
# PostgreSQL Configuration (for data-platform plugin)
# Update with your PostgreSQL connection URL
# This is OPTIONAL - pandas tools work without it
POSTGRES_URL=postgresql://user:password@localhost:5432/database
EOF
chmod 600 "$config_dir/postgres.env"
log_success "postgres.env template created"
log_todo "Edit ~/.config/claude/postgres.env with your PostgreSQL credentials (optional)"
fi
} }
# --- Section 4: Validate Configuration --- # --- Section 4: Validate Configuration ---
@@ -283,6 +308,7 @@ main() {
# Shared MCP servers at repository root # Shared MCP servers at repository root
setup_shared_mcp "gitea" setup_shared_mcp "gitea"
setup_shared_mcp "netbox" setup_shared_mcp "netbox"
setup_shared_mcp "data-platform"
# Verify symlinks from plugins to shared MCP servers # Verify symlinks from plugins to shared MCP servers
verify_symlinks verify_symlinks

View File

@@ -168,6 +168,12 @@ if [[ ! -d "$ROOT_DIR/mcp-servers/netbox" ]]; then
fi fi
echo "✓ Shared netbox MCP server exists" echo "✓ Shared netbox MCP server exists"
if [[ ! -d "$ROOT_DIR/mcp-servers/data-platform" ]]; then
echo "ERROR: Shared data-platform MCP server not found at mcp-servers/data-platform/"
exit 1
fi
echo "✓ Shared data-platform MCP server exists"
# Check symlinks for plugins that use MCP servers # Check symlinks for plugins that use MCP servers
check_mcp_symlink() { check_mcp_symlink() {
local plugin_name="$1" local plugin_name="$1"
@@ -195,5 +201,8 @@ check_mcp_symlink "pr-review" "gitea"
# Plugins with netbox MCP dependency # Plugins with netbox MCP dependency
check_mcp_symlink "cmdb-assistant" "netbox" check_mcp_symlink "cmdb-assistant" "netbox"
# Plugins with data-platform MCP dependency
check_mcp_symlink "data-platform" "data-platform"
echo "" echo ""
echo "=== All validations passed ===" echo "=== All validations passed ==="

View File

@@ -15,15 +15,15 @@ for f in $(find ~/.claude -name "hooks.json" 2>/dev/null); do
fi fi
done done
# Check cache specifically # Note about cache (informational only - do NOT clear mid-session)
if [ -d ~/.claude/plugins/cache/leo-claude-mktplace ]; then if [ -d ~/.claude/plugins/cache/leo-claude-mktplace ]; then
echo "❌ CACHE EXISTS: ~/.claude/plugins/cache/leo-claude-mktplace" echo " Cache exists: ~/.claude/plugins/cache/leo-claude-mktplace"
echo " Run: rm -rf ~/.claude/plugins/cache/leo-claude-mktplace/" echo " (This is normal - do NOT clear mid-session or MCP tools will break)"
FAILED=1 echo " To apply plugin changes: restart Claude Code session"
fi fi
# Verify installed hooks are command type # Verify installed hooks are command type
for plugin in doc-guardian code-sentinel projman pr-review project-hygiene; do for plugin in doc-guardian code-sentinel projman pr-review project-hygiene data-platform; do
HOOK_FILE=~/.claude/plugins/marketplaces/leo-claude-mktplace/plugins/$plugin/hooks/hooks.json HOOK_FILE=~/.claude/plugins/marketplaces/leo-claude-mktplace/plugins/$plugin/hooks/hooks.json
if [ -f "$HOOK_FILE" ]; then if [ -f "$HOOK_FILE" ]; then
if grep -q '"type": "command"' "$HOOK_FILE" || grep -q '"type":"command"' "$HOOK_FILE"; then if grep -q '"type": "command"' "$HOOK_FILE" || grep -q '"type":"command"' "$HOOK_FILE"; then