solar-data-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solar_data_mcp-0.1.0/.gitignore +20 -0
- solar_data_mcp-0.1.0/PKG-INFO +94 -0
- solar_data_mcp-0.1.0/README.md +69 -0
- solar_data_mcp-0.1.0/pyproject.toml +47 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/__init__.py +3 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/prompts.py +77 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/py.typed +0 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/server.py +164 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/__init__.py +107 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-data-conventions.md +67 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-data-sync.md +49 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-market-brief.md +69 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-performance-check.md +44 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-policy-incentive-scan.md +40 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-pricing-analysis.md +42 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-proposal-builder.md +79 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-quote-review.md +69 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-site-assessment.md +76 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-territory-expansion.md +45 -0
- solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-utility-scale-scout.md +41 -0
- solar_data_mcp-0.1.0/tests/test_combined_server.py +231 -0
- solar_data_mcp-0.1.0/tests/test_prompts.py +53 -0
- solar_data_mcp-0.1.0/tests/test_skills.py +109 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: solar-data-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: All US open solar data in one MCP server: PVWatts production, tariffs & ROI, market intelligence, forecasts
|
|
5
|
+
Project-URL: Homepage, https://github.com/hoodsy/solar-data-mcp
|
|
6
|
+
Author: Logan Bernard
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: eia,forecast,mcp,nrel,open-data,pvwatts,solar,solartrace,urdb,uspvdb
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: duckdb<2,>=1.1
|
|
16
|
+
Requires-Dist: httpx<1,>=0.27
|
|
17
|
+
Requires-Dist: mcp<2,>=1.26
|
|
18
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
19
|
+
Requires-Dist: solar-data-mcp-core<0.2,>=0.1
|
|
20
|
+
Requires-Dist: solar-data-mcp-economics<0.2,>=0.1
|
|
21
|
+
Requires-Dist: solar-data-mcp-forecast<0.2,>=0.1
|
|
22
|
+
Requires-Dist: solar-data-mcp-market<0.2,>=0.1
|
|
23
|
+
Requires-Dist: solar-data-mcp-nrel<0.2,>=0.1
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# solar-data-mcp
|
|
27
|
+
|
|
28
|
+
All US open solar data in **one MCP server**: NREL PVWatts production modeling,
|
|
29
|
+
tariffs & ROI economics, market intelligence, and generation forecasts — 18
|
|
30
|
+
tools, every result carrying data + units + source + assumptions + warnings.
|
|
31
|
+
Part of [solar-data-mcp](https://github.com/hoodsy/solar-data-mcp), which also ships
|
|
32
|
+
each domain as a standalone server.
|
|
33
|
+
|
|
34
|
+
## Add it to your agent
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"mcpServers": {
|
|
39
|
+
"solar-data": {
|
|
40
|
+
"command": "uvx",
|
|
41
|
+
"args": ["solar-data-mcp"],
|
|
42
|
+
"env": {
|
|
43
|
+
"NREL_API_KEY": "YOUR_KEY",
|
|
44
|
+
"OPENEI_API_KEY": "YOUR_KEY",
|
|
45
|
+
"EIA_API_KEY": "YOUR_KEY"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Snippets for Claude Code, Codex, OpenCode, and Hermes live in the
|
|
53
|
+
[repo README](https://github.com/hoodsy/solar-data-mcp#add-it-to-your-agent).
|
|
54
|
+
|
|
55
|
+
## Keys (all free; every one optional to start)
|
|
56
|
+
|
|
57
|
+
| Env var | Unlocks | Get one |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `NREL_API_KEY` | estimate_production, get_solar_resource, compare_orientations, size_system_for_target, estimate_roi, compare_forecast_to_model | <https://developer.nlr.gov/signup/> (`DEMO_KEY` works for ~10 req/hr) |
|
|
60
|
+
| `OPENEI_API_KEY` | lookup_tariffs | <https://openei.org/services/api/signup/> |
|
|
61
|
+
| `EIA_API_KEY` | get_electricity_prices | <https://www.eia.gov/opendata/register.php> |
|
|
62
|
+
| `AHJ_REGISTRY_TOKEN` (optional) | identify_ahj | email <support@sunspec.org> |
|
|
63
|
+
|
|
64
|
+
Market tools (USPVDB, Tracking the Sun, SolarTRACE) and forecasts need no key.
|
|
65
|
+
The server starts with zero keys set — tools missing a key return setup
|
|
66
|
+
instructions instead of failing silently. Check your setup any time:
|
|
67
|
+
|
|
68
|
+
```console
|
|
69
|
+
$ uvx solar-data-mcp doctor
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Skills
|
|
73
|
+
|
|
74
|
+
The server also ships eleven **skills** — procedural instructions that teach an agent
|
|
75
|
+
how to chain the tools for common question shapes (site assessment, quote review,
|
|
76
|
+
market briefs, …), including sync prerequisites and reporting rules. They are served
|
|
77
|
+
as MCP resources: `skill://solar/index` is the routing table (match the question
|
|
78
|
+
against it, load the referenced `skill://solar/<name>`), and the server instructions
|
|
79
|
+
point agents there automatically. Catalog and design rationale:
|
|
80
|
+
[`docs/skills.md`](https://github.com/hoodsy/solar-data-mcp/blob/main/docs/skills.md).
|
|
81
|
+
|
|
82
|
+
Four of the skills produce **reports** with a fixed document shape, and each is also
|
|
83
|
+
exposed as an MCP prompt your host surfaces natively — `market_brief(state)`,
|
|
84
|
+
`site_assessment(location, usage)`, `quote_review(quote_details)`, and
|
|
85
|
+
`proposal_builder(customer_details)` — e.g. `/mcp__solar-data__market_brief` in
|
|
86
|
+
Claude Code.
|
|
87
|
+
|
|
88
|
+
## Forecast model note
|
|
89
|
+
|
|
90
|
+
`forecast_generation` uses the open Quartz model, whose package pins an old
|
|
91
|
+
pydantic and cannot live in an ephemeral `uvx` environment. Forecast tools
|
|
92
|
+
degrade to an actionable install message without it; for real forecasts use a
|
|
93
|
+
persistent install (`uv tool install solar-data-mcp`) plus the steps in the
|
|
94
|
+
[solar-data-mcp-forecast README](https://github.com/hoodsy/solar-data-mcp/tree/main/packages/solar-forecast).
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# solar-data-mcp
|
|
2
|
+
|
|
3
|
+
All US open solar data in **one MCP server**: NREL PVWatts production modeling,
|
|
4
|
+
tariffs & ROI economics, market intelligence, and generation forecasts — 18
|
|
5
|
+
tools, every result carrying data + units + source + assumptions + warnings.
|
|
6
|
+
Part of [solar-data-mcp](https://github.com/hoodsy/solar-data-mcp), which also ships
|
|
7
|
+
each domain as a standalone server.
|
|
8
|
+
|
|
9
|
+
## Add it to your agent
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"solar-data": {
|
|
15
|
+
"command": "uvx",
|
|
16
|
+
"args": ["solar-data-mcp"],
|
|
17
|
+
"env": {
|
|
18
|
+
"NREL_API_KEY": "YOUR_KEY",
|
|
19
|
+
"OPENEI_API_KEY": "YOUR_KEY",
|
|
20
|
+
"EIA_API_KEY": "YOUR_KEY"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Snippets for Claude Code, Codex, OpenCode, and Hermes live in the
|
|
28
|
+
[repo README](https://github.com/hoodsy/solar-data-mcp#add-it-to-your-agent).
|
|
29
|
+
|
|
30
|
+
## Keys (all free; every one optional to start)
|
|
31
|
+
|
|
32
|
+
| Env var | Unlocks | Get one |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `NREL_API_KEY` | estimate_production, get_solar_resource, compare_orientations, size_system_for_target, estimate_roi, compare_forecast_to_model | <https://developer.nlr.gov/signup/> (`DEMO_KEY` works for ~10 req/hr) |
|
|
35
|
+
| `OPENEI_API_KEY` | lookup_tariffs | <https://openei.org/services/api/signup/> |
|
|
36
|
+
| `EIA_API_KEY` | get_electricity_prices | <https://www.eia.gov/opendata/register.php> |
|
|
37
|
+
| `AHJ_REGISTRY_TOKEN` (optional) | identify_ahj | email <support@sunspec.org> |
|
|
38
|
+
|
|
39
|
+
Market tools (USPVDB, Tracking the Sun, SolarTRACE) and forecasts need no key.
|
|
40
|
+
The server starts with zero keys set — tools missing a key return setup
|
|
41
|
+
instructions instead of failing silently. Check your setup any time:
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
$ uvx solar-data-mcp doctor
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Skills
|
|
48
|
+
|
|
49
|
+
The server also ships eleven **skills** — procedural instructions that teach an agent
|
|
50
|
+
how to chain the tools for common question shapes (site assessment, quote review,
|
|
51
|
+
market briefs, …), including sync prerequisites and reporting rules. They are served
|
|
52
|
+
as MCP resources: `skill://solar/index` is the routing table (match the question
|
|
53
|
+
against it, load the referenced `skill://solar/<name>`), and the server instructions
|
|
54
|
+
point agents there automatically. Catalog and design rationale:
|
|
55
|
+
[`docs/skills.md`](https://github.com/hoodsy/solar-data-mcp/blob/main/docs/skills.md).
|
|
56
|
+
|
|
57
|
+
Four of the skills produce **reports** with a fixed document shape, and each is also
|
|
58
|
+
exposed as an MCP prompt your host surfaces natively — `market_brief(state)`,
|
|
59
|
+
`site_assessment(location, usage)`, `quote_review(quote_details)`, and
|
|
60
|
+
`proposal_builder(customer_details)` — e.g. `/mcp__solar-data__market_brief` in
|
|
61
|
+
Claude Code.
|
|
62
|
+
|
|
63
|
+
## Forecast model note
|
|
64
|
+
|
|
65
|
+
`forecast_generation` uses the open Quartz model, whose package pins an old
|
|
66
|
+
pydantic and cannot live in an ephemeral `uvx` environment. Forecast tools
|
|
67
|
+
degrade to an actionable install message without it; for real forecasts use a
|
|
68
|
+
persistent install (`uv tool install solar-data-mcp`) plus the steps in the
|
|
69
|
+
[solar-data-mcp-forecast README](https://github.com/hoodsy/solar-data-mcp/tree/main/packages/solar-forecast).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "solar-data-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "All US open solar data in one MCP server: PVWatts production, tariffs & ROI, market intelligence, forecasts"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "Logan Bernard" }]
|
|
9
|
+
keywords = ["solar", "mcp", "nrel", "pvwatts", "urdb", "eia", "uspvdb", "solartrace", "forecast", "open-data"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3.11",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Topic :: Scientific/Engineering",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"solar-data-mcp-core>=0.1,<0.2",
|
|
19
|
+
"solar-data-mcp-nrel>=0.1,<0.2",
|
|
20
|
+
"solar-data-mcp-economics>=0.1,<0.2",
|
|
21
|
+
"solar-data-mcp-market>=0.1,<0.2",
|
|
22
|
+
"solar-data-mcp-forecast>=0.1,<0.2",
|
|
23
|
+
"mcp>=1.26,<2",
|
|
24
|
+
"httpx>=0.27,<1",
|
|
25
|
+
"pydantic>=2.7,<3",
|
|
26
|
+
"duckdb>=1.1,<2",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/hoodsy/solar-data-mcp"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
solar-data-mcp = "solar_data_mcp.server:main"
|
|
34
|
+
|
|
35
|
+
[tool.uv.sources]
|
|
36
|
+
solar-data-mcp-core = { workspace = true }
|
|
37
|
+
solar-data-mcp-nrel = { workspace = true }
|
|
38
|
+
solar-data-mcp-economics = { workspace = true }
|
|
39
|
+
solar-data-mcp-market = { workspace = true }
|
|
40
|
+
solar-data-mcp-forecast = { workspace = true }
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["hatchling"]
|
|
44
|
+
build-backend = "hatchling.build"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/solar_data_mcp"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""MCP prompts: user-facing entry points to the report-shaped skills.
|
|
2
|
+
|
|
3
|
+
Skills route agents by question shape; prompts let a *user* pick a deliverable
|
|
4
|
+
from their host's UI (Claude Desktop / Claude Code surface these natively) and
|
|
5
|
+
parameterize it. Each prompt expands to the same instruction: load the skill,
|
|
6
|
+
run its workflow for the given inputs, render its report template. Nothing is
|
|
7
|
+
duplicated — the skill file stays the single source of procedure.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from mcp.server.fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
# prompt name -> skill name; tests cross-check both directions.
|
|
13
|
+
PROMPT_SKILLS = {
|
|
14
|
+
"market_brief": "solar-market-brief",
|
|
15
|
+
"site_assessment": "solar-site-assessment",
|
|
16
|
+
"quote_review": "solar-quote-review",
|
|
17
|
+
"proposal_builder": "solar-proposal-builder",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _expand(skill: str, task: str) -> str:
|
|
22
|
+
return (
|
|
23
|
+
f"Read the resource skill://solar/{skill} and follow it exactly: run its "
|
|
24
|
+
f"Workflow for the inputs below, honor its Sharp edges, and render its "
|
|
25
|
+
f"Report template as the final answer (including the assumptions and "
|
|
26
|
+
f"data-gap sections). Also apply skill://solar/solar-data-conventions.\n\n"
|
|
27
|
+
f"{task}"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register(mcp: FastMCP) -> None:
|
|
32
|
+
@mcp.prompt(
|
|
33
|
+
name="market_brief",
|
|
34
|
+
title="Solar market brief",
|
|
35
|
+
description="Standardized state solar-market report: adoption, pricing, policy, "
|
|
36
|
+
"utility-scale infrastructure, permitting friction.",
|
|
37
|
+
)
|
|
38
|
+
def market_brief(state: str) -> str:
|
|
39
|
+
return _expand("solar-market-brief", f"State to brief: {state}")
|
|
40
|
+
|
|
41
|
+
@mcp.prompt(
|
|
42
|
+
name="site_assessment",
|
|
43
|
+
title="Solar site assessment",
|
|
44
|
+
description="Would solar pay off here? Sizing, production, incentives, and "
|
|
45
|
+
"screening ROI for one location.",
|
|
46
|
+
)
|
|
47
|
+
def site_assessment(location: str, annual_usage_or_bill: str) -> str:
|
|
48
|
+
return _expand(
|
|
49
|
+
"solar-site-assessment",
|
|
50
|
+
f"Location: {location}\nAnnual usage or bill: {annual_usage_or_bill}",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@mcp.prompt(
|
|
54
|
+
name="quote_review",
|
|
55
|
+
title="Solar quote review",
|
|
56
|
+
description="Audit an installer's quote against market prices, modeled "
|
|
57
|
+
"production, and the incentive schedule.",
|
|
58
|
+
)
|
|
59
|
+
def quote_review(quote_details: str) -> str:
|
|
60
|
+
return _expand(
|
|
61
|
+
"solar-quote-review",
|
|
62
|
+
"Quote to review (size, price, promised production/payback, location): "
|
|
63
|
+
f"{quote_details}",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@mcp.prompt(
|
|
67
|
+
name="proposal_builder",
|
|
68
|
+
title="Solar proposal builder",
|
|
69
|
+
description="Turn a customer address, usage, roof planes, and your cost basis "
|
|
70
|
+
"into a full proposal package.",
|
|
71
|
+
)
|
|
72
|
+
def proposal_builder(customer_details: str) -> str:
|
|
73
|
+
return _expand(
|
|
74
|
+
"solar-proposal-builder",
|
|
75
|
+
"Customer inputs (location, annual kWh, roof planes, cost per watt): "
|
|
76
|
+
f"{customer_details}",
|
|
77
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""The umbrella server: all four domain servers' tools on one FastMCP over
|
|
2
|
+
stdio, so consumers install one `uvx solar-data-mcp` instead of four servers.
|
|
3
|
+
|
|
4
|
+
Each domain package exposes register_tools() typed against a Protocol of the
|
|
5
|
+
context fields its tools read; CompositeContext provides the union, and mypy
|
|
6
|
+
proves the wiring at every register call. The sharing is load-bearing, not
|
|
7
|
+
just convenient: one NREL client means one token bucket for the 1,000 req/hr
|
|
8
|
+
quota that nrel, economics, and forecast tools all draw from, and one
|
|
9
|
+
BulkStore handle sidesteps DuckDB's one-process-per-file lock that separate
|
|
10
|
+
economics and market processes fight over.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import AsyncIterator, Callable
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from mcp.server.fastmcp import FastMCP
|
|
19
|
+
from solar_mcp_core.bulk import BulkStore
|
|
20
|
+
from solar_mcp_core.cli import main as core_cli_main
|
|
21
|
+
from solar_mcp_core.config import AHJ, EIA, NREL, OPENEI, USPVDB, api_key_for
|
|
22
|
+
from solar_mcp_core.http import SolarHttpClient, configure_debug_logging
|
|
23
|
+
from solar_mcp_economics import resources as economics_resources
|
|
24
|
+
from solar_mcp_economics import server as economics_server
|
|
25
|
+
from solar_mcp_forecast import resources as forecast_resources
|
|
26
|
+
from solar_mcp_forecast import server as forecast_server
|
|
27
|
+
from solar_mcp_forecast.predictor import Predictor, quartz_predictor
|
|
28
|
+
from solar_mcp_market import resources as market_resources
|
|
29
|
+
from solar_mcp_market import server as market_server
|
|
30
|
+
from solar_mcp_nrel import resources as nrel_resources
|
|
31
|
+
from solar_mcp_nrel import server as nrel_server
|
|
32
|
+
|
|
33
|
+
from solar_data_mcp import prompts, skills
|
|
34
|
+
|
|
35
|
+
INSTRUCTIONS = (
|
|
36
|
+
"All US open solar data in one server: NREL production modeling "
|
|
37
|
+
"(estimate_production, get_solar_resource, compare_orientations, "
|
|
38
|
+
"size_system_for_target), economics (lookup_tariffs, get_electricity_prices, "
|
|
39
|
+
"get_incentives, sync_incentives, estimate_roi), market intelligence "
|
|
40
|
+
"(query_installed_systems, get_permitting_timelines, "
|
|
41
|
+
"find_utility_scale_projects, identify_ahj, market_snapshot, sync_* loaders), "
|
|
42
|
+
"and forecasts (forecast_generation, compare_forecast_to_model). Every tool "
|
|
43
|
+
"returns data + units + source + assumptions + warnings; read the "
|
|
44
|
+
"assumptions before quoting numbers. Keys (env vars): NREL_API_KEY unlocks "
|
|
45
|
+
"the production, ROI, and forecast-vs-model tools; OPENEI_API_KEY unlocks "
|
|
46
|
+
"lookup_tariffs; EIA_API_KEY unlocks get_electricity_prices; "
|
|
47
|
+
"AHJ_REGISTRY_TOKEN (optional) unlocks identify_ahj. Market and forecast "
|
|
48
|
+
"tools need no key. Run `solar-data-mcp doctor` to check setup. "
|
|
49
|
+
"Before any multi-tool workflow, read skill://solar/index and load the "
|
|
50
|
+
"matching skill://solar/<name> resource — skills encode tool ordering, "
|
|
51
|
+
"sync prerequisites, and reporting rules for common question shapes."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class CompositeContext:
|
|
57
|
+
"""The union of the four domain packages' Deps protocols.
|
|
58
|
+
|
|
59
|
+
client and nrel are the SAME SolarHttpClient in default_context: nrel
|
|
60
|
+
tools read .client while economics/forecast tools read .nrel, and all of
|
|
61
|
+
them share one NREL token bucket only if the instance is shared.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
client: SolarHttpClient
|
|
65
|
+
nrel: SolarHttpClient
|
|
66
|
+
openei: SolarHttpClient
|
|
67
|
+
eia: SolarHttpClient
|
|
68
|
+
uspvdb: SolarHttpClient
|
|
69
|
+
ahj: SolarHttpClient
|
|
70
|
+
store: BulkStore
|
|
71
|
+
predictor: Predictor
|
|
72
|
+
|
|
73
|
+
async def aclose(self) -> None:
|
|
74
|
+
# Dedupe by identity: client is normally the same object as nrel.
|
|
75
|
+
seen: set[int] = set()
|
|
76
|
+
for client in (self.client, self.nrel, self.openei, self.eia, self.uspvdb, self.ahj):
|
|
77
|
+
if id(client) not in seen:
|
|
78
|
+
seen.add(id(client))
|
|
79
|
+
await client.aclose()
|
|
80
|
+
self.store.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def default_context() -> CompositeContext:
|
|
84
|
+
nrel = SolarHttpClient(NREL)
|
|
85
|
+
return CompositeContext(
|
|
86
|
+
client=nrel,
|
|
87
|
+
nrel=nrel,
|
|
88
|
+
openei=SolarHttpClient(OPENEI),
|
|
89
|
+
eia=SolarHttpClient(EIA),
|
|
90
|
+
uspvdb=SolarHttpClient(USPVDB),
|
|
91
|
+
ahj=SolarHttpClient(AHJ),
|
|
92
|
+
store=BulkStore(),
|
|
93
|
+
predictor=quartz_predictor,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_server(context_factory: Callable[[], CompositeContext] | None = None) -> FastMCP:
|
|
98
|
+
factory = context_factory if context_factory is not None else default_context
|
|
99
|
+
|
|
100
|
+
@asynccontextmanager
|
|
101
|
+
async def lifespan(_server: FastMCP) -> AsyncIterator[CompositeContext]:
|
|
102
|
+
context = factory()
|
|
103
|
+
try:
|
|
104
|
+
yield context
|
|
105
|
+
finally:
|
|
106
|
+
await context.aclose()
|
|
107
|
+
|
|
108
|
+
mcp: FastMCP[CompositeContext] = FastMCP(
|
|
109
|
+
"solar-data",
|
|
110
|
+
instructions=INSTRUCTIONS,
|
|
111
|
+
lifespan=lifespan,
|
|
112
|
+
)
|
|
113
|
+
nrel_server.register_tools(mcp)
|
|
114
|
+
economics_server.register_tools(mcp)
|
|
115
|
+
market_server.register_tools(mcp)
|
|
116
|
+
forecast_server.register_tools(mcp)
|
|
117
|
+
for resources in (nrel_resources, economics_resources, market_resources, forecast_resources):
|
|
118
|
+
resources.register(mcp)
|
|
119
|
+
skills.register(mcp)
|
|
120
|
+
prompts.register(mcp)
|
|
121
|
+
return mcp
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def missing_key_note() -> str | None:
|
|
125
|
+
"""One startup line naming unset keys, or None when everything is set.
|
|
126
|
+
|
|
127
|
+
Startup must stay fast and offline, so this only reads the environment —
|
|
128
|
+
`solar-data-mcp doctor` is the live check.
|
|
129
|
+
"""
|
|
130
|
+
required = [config for config in (NREL, OPENEI, EIA) if api_key_for(config) is None]
|
|
131
|
+
optional = [config for config in (AHJ,) if api_key_for(config) is None]
|
|
132
|
+
if not required and not optional:
|
|
133
|
+
return None
|
|
134
|
+
parts = []
|
|
135
|
+
if required:
|
|
136
|
+
keys = ", ".join(f"{c.api_key_env} (get one: {c.signup_url})" for c in required)
|
|
137
|
+
parts.append(f"missing keys: {keys}")
|
|
138
|
+
if optional:
|
|
139
|
+
keys = ", ".join(f"{c.api_key_env} ({c.signup_url})" for c in optional)
|
|
140
|
+
parts.append(f"optional: {keys}")
|
|
141
|
+
return (
|
|
142
|
+
"solar-data-mcp: "
|
|
143
|
+
+ "; ".join(parts)
|
|
144
|
+
+ ". Tools needing a missing key return setup instructions; run "
|
|
145
|
+
"`solar-data-mcp doctor` to verify your setup."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(argv: list[str] | None = None) -> None:
|
|
150
|
+
configure_debug_logging()
|
|
151
|
+
args = sys.argv[1:] if argv is None else argv
|
|
152
|
+
if args == ["doctor"]:
|
|
153
|
+
raise SystemExit(core_cli_main(["doctor"]))
|
|
154
|
+
if args:
|
|
155
|
+
print("usage: solar-data-mcp [doctor]", file=sys.stderr)
|
|
156
|
+
raise SystemExit(2)
|
|
157
|
+
note = missing_key_note()
|
|
158
|
+
if note is not None:
|
|
159
|
+
print(note, file=sys.stderr) # stderr: stdout belongs to the stdio transport
|
|
160
|
+
create_server().run()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
main()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Agent skills: procedural instructions served as MCP resources.
|
|
2
|
+
|
|
3
|
+
Tools are capability; skills are procedure. Each *.md file in this package
|
|
4
|
+
teaches an agent how to orchestrate the tools for one shape of question —
|
|
5
|
+
tool ordering, sync prerequisites, defaults to override, reporting rules.
|
|
6
|
+
skill://solar/index is the routing table: the server instructions point hosts
|
|
7
|
+
at it, and hosts with a native skill concept can lift the frontmatter
|
|
8
|
+
descriptions directly.
|
|
9
|
+
|
|
10
|
+
Frontmatter is deliberately minimal (name, description, tools as one
|
|
11
|
+
comma-separated line) so it parses without a YAML dependency while remaining
|
|
12
|
+
valid YAML for hosts that repackage skills as plugins.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from importlib import resources
|
|
18
|
+
|
|
19
|
+
from mcp.server.fastmcp import FastMCP
|
|
20
|
+
|
|
21
|
+
INDEX_URI = "skill://solar/index"
|
|
22
|
+
|
|
23
|
+
_INDEX_PREAMBLE = """\
|
|
24
|
+
Solar skill routing table.
|
|
25
|
+
|
|
26
|
+
Skills are procedures: each teaches the tool ordering, sync prerequisites,
|
|
27
|
+
defaults to override, and reporting rules for one shape of question. Route by
|
|
28
|
+
what is being asked, never by who is asking — a homeowner, an installer, and
|
|
29
|
+
an analyst asking the same question get the same skill.
|
|
30
|
+
|
|
31
|
+
How to route:
|
|
32
|
+
1. Match the question against the descriptions below and read that
|
|
33
|
+
skill://solar/<name> resource before calling tools.
|
|
34
|
+
2. Always apply skill://solar/solar-data-conventions — the contract for
|
|
35
|
+
reading every tool's result envelope (assumptions, warnings, provenance).
|
|
36
|
+
3. If a tool fails with "snapshot not synced", switch to
|
|
37
|
+
skill://solar/solar-data-sync, run the named sync, then resume.
|
|
38
|
+
|
|
39
|
+
Skills:
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Skill:
|
|
45
|
+
"""One parsed *.md skill file; text is the full raw file, served as-is."""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
description: str
|
|
49
|
+
tools: tuple[str, ...]
|
|
50
|
+
text: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_skill(stem: str, text: str) -> Skill:
|
|
54
|
+
"""Parse frontmatter; strict, so a malformed skill fails at load, not at read."""
|
|
55
|
+
lines = text.splitlines()
|
|
56
|
+
if not lines or lines[0] != "---":
|
|
57
|
+
raise ValueError(f"skill {stem}: file must start with '---' frontmatter")
|
|
58
|
+
try:
|
|
59
|
+
end = lines.index("---", 1)
|
|
60
|
+
except ValueError:
|
|
61
|
+
raise ValueError(f"skill {stem}: unterminated frontmatter") from None
|
|
62
|
+
fields: dict[str, str] = {}
|
|
63
|
+
for line in lines[1:end]:
|
|
64
|
+
key, sep, value = line.partition(":")
|
|
65
|
+
if not sep:
|
|
66
|
+
raise ValueError(f"skill {stem}: bad frontmatter line {line!r}")
|
|
67
|
+
fields[key.strip()] = value.strip()
|
|
68
|
+
name = fields.get("name", "")
|
|
69
|
+
description = fields.get("description", "")
|
|
70
|
+
if not name or not description:
|
|
71
|
+
raise ValueError(f"skill {stem}: frontmatter must set name and description")
|
|
72
|
+
if name != stem:
|
|
73
|
+
raise ValueError(f"skill {stem}: frontmatter name {name!r} does not match filename")
|
|
74
|
+
tools = tuple(tool.strip() for tool in fields.get("tools", "").split(",") if tool.strip())
|
|
75
|
+
return Skill(name=name, description=description, tools=tools, text=text)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_skills() -> list[Skill]:
|
|
79
|
+
skills_dir = resources.files(__name__)
|
|
80
|
+
return [
|
|
81
|
+
parse_skill(entry.name.removesuffix(".md"), entry.read_text(encoding="utf-8"))
|
|
82
|
+
for entry in sorted(skills_dir.iterdir(), key=lambda entry: entry.name)
|
|
83
|
+
if entry.name.endswith(".md")
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_index(skills: Sequence[Skill]) -> str:
|
|
88
|
+
entries = "\n".join(f"- skill://solar/{s.name} — {s.description}" for s in skills)
|
|
89
|
+
return _INDEX_PREAMBLE + entries + "\n"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def register(mcp: FastMCP) -> None:
|
|
93
|
+
skills = load_skills()
|
|
94
|
+
|
|
95
|
+
@mcp.resource(INDEX_URI, title="Solar skills: routing table")
|
|
96
|
+
def index() -> str:
|
|
97
|
+
return build_index(skills)
|
|
98
|
+
|
|
99
|
+
for skill in skills:
|
|
100
|
+
_register_one(mcp, skill)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _register_one(mcp: FastMCP, skill: Skill) -> None:
|
|
104
|
+
# Separate function so the closure binds this skill, not the loop variable.
|
|
105
|
+
@mcp.resource(f"skill://solar/{skill.name}", title=f"Skill: {skill.name}")
|
|
106
|
+
def read_skill() -> str:
|
|
107
|
+
return skill.text
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: solar-data-conventions
|
|
3
|
+
description: How to read and report every tool's result envelope — assumptions, warnings, provenance, error recovery, API keys, and quota. Use alongside every other solar skill; it is the contract the whole server follows.
|
|
4
|
+
tools:
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Data conventions
|
|
8
|
+
|
|
9
|
+
Every tool returns the same envelope: `data` + `units` + `source` +
|
|
10
|
+
`assumptions` + `warnings`. These rules apply to every workflow.
|
|
11
|
+
|
|
12
|
+
## Reporting rules
|
|
13
|
+
|
|
14
|
+
- When a number matters, surface its `assumptions` and `warnings` — every
|
|
15
|
+
default the tools injected on the caller's behalf is listed there by
|
|
16
|
+
design. A parameter you passed explicitly never appears.
|
|
17
|
+
- Cite `source.name` and `retrieved_at`. Units for any field are in `units`,
|
|
18
|
+
keyed by field path (`ranked[].ac_annual_kwh`).
|
|
19
|
+
- Composites (`estimate_roi`, `market_snapshot`,
|
|
20
|
+
`compare_forecast_to_model`) carry a placeholder top-level source; the real
|
|
21
|
+
per-component provenance is in `data.audit_trail` — cite from there.
|
|
22
|
+
- A freshness warning means a stale cache entry was served because quota ran
|
|
23
|
+
out: the number is real but old. Say so.
|
|
24
|
+
- License and attribution text for any dataset is served at
|
|
25
|
+
`source://<name>/license`; coverage limits at `source://<name>/coverage`.
|
|
26
|
+
|
|
27
|
+
## Error recovery
|
|
28
|
+
|
|
29
|
+
- **Input errors** name the exact field and allowed range — correct the
|
|
30
|
+
parameter and retry once.
|
|
31
|
+
- **Quota errors**: back off; the client never retries a 429, so hammering
|
|
32
|
+
only burns the next hour's budget. Cached repeats of earlier calls are
|
|
33
|
+
free.
|
|
34
|
+
- **Source-unavailable errors** name the fix: an unset env var (with its
|
|
35
|
+
signup URL) or a missing `sync_*` prerequisite (see solar-data-sync).
|
|
36
|
+
|
|
37
|
+
## Setup
|
|
38
|
+
|
|
39
|
+
- Keys (all free): NREL_API_KEY (production, ROI, forecast-vs-model),
|
|
40
|
+
OPENEI_API_KEY (tariffs), EIA_API_KEY (electricity prices);
|
|
41
|
+
AHJ_REGISTRY_TOKEN is optional (email-issued). Market and forecast tools
|
|
42
|
+
need no key. Verify with `solar-data-mcp doctor`.
|
|
43
|
+
- DEMO_KEY works for NREL at ~10 requests/hour — expect real 429s on sweeps,
|
|
44
|
+
and note the one NREL quota is shared across production, ROI, and
|
|
45
|
+
forecast-vs-model tools.
|
|
46
|
+
- The HTTP cache and bulk store live under `~/.cache/solar-data-mcp`
|
|
47
|
+
(relocatable via SOLAR_DATA_MCP_CACHE_DIR).
|
|
48
|
+
|
|
49
|
+
## Formatted data (exports)
|
|
50
|
+
|
|
51
|
+
When the user asks for spreadsheet/CSV-shaped output:
|
|
52
|
+
|
|
53
|
+
- One column per relevant `data` field, with the unit from the envelope's
|
|
54
|
+
`units` map in the header: `median_price_per_watt (USD/W)`.
|
|
55
|
+
- One leading comment row citing provenance:
|
|
56
|
+
`# source: <source.name> · retrieved <retrieved_at> · vintage <vintage>`.
|
|
57
|
+
- Aggregates only — never promise row-level Tracking the Sun records; the
|
|
58
|
+
tools do not return them.
|
|
59
|
+
- List-shaped data (`ranked[]`, `projects[]`, `trend[]`) becomes one row per
|
|
60
|
+
element; scalar summary fields go in a separate single-row table rather
|
|
61
|
+
than being repeated down a column.
|
|
62
|
+
|
|
63
|
+
## What not to promise
|
|
64
|
+
|
|
65
|
+
TOU tariff simulation, REopt-style optimization, PVDAQ measured production,
|
|
66
|
+
and non-US coverage are out of scope. Decline gracefully and name the closest
|
|
67
|
+
tool family instead of improvising.
|