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.
Files changed (23) hide show
  1. solar_data_mcp-0.1.0/.gitignore +20 -0
  2. solar_data_mcp-0.1.0/PKG-INFO +94 -0
  3. solar_data_mcp-0.1.0/README.md +69 -0
  4. solar_data_mcp-0.1.0/pyproject.toml +47 -0
  5. solar_data_mcp-0.1.0/src/solar_data_mcp/__init__.py +3 -0
  6. solar_data_mcp-0.1.0/src/solar_data_mcp/prompts.py +77 -0
  7. solar_data_mcp-0.1.0/src/solar_data_mcp/py.typed +0 -0
  8. solar_data_mcp-0.1.0/src/solar_data_mcp/server.py +164 -0
  9. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/__init__.py +107 -0
  10. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-data-conventions.md +67 -0
  11. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-data-sync.md +49 -0
  12. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-market-brief.md +69 -0
  13. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-performance-check.md +44 -0
  14. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-policy-incentive-scan.md +40 -0
  15. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-pricing-analysis.md +42 -0
  16. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-proposal-builder.md +79 -0
  17. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-quote-review.md +69 -0
  18. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-site-assessment.md +76 -0
  19. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-territory-expansion.md +45 -0
  20. solar_data_mcp-0.1.0/src/solar_data_mcp/skills/solar-utility-scale-scout.md +41 -0
  21. solar_data_mcp-0.1.0/tests/test_combined_server.py +231 -0
  22. solar_data_mcp-0.1.0/tests/test_prompts.py +53 -0
  23. solar_data_mcp-0.1.0/tests/test_skills.py +109 -0
@@ -0,0 +1,20 @@
1
+ # Environments & secrets
2
+ .env
3
+ .venv/
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+
12
+ # Tooling
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ coverage.xml
18
+
19
+ # OS
20
+ .DS_Store
@@ -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,3 @@
1
+ """All four solar-data-mcp domain servers' tools on one MCP server."""
2
+
3
+ __version__ = "0.1.0"
@@ -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.