Solarscope
README.md
# solarscope-mcp
A small MCP server and CLI that answers **"is this roof worth solar?"** for an address. It geocodes the address,
asks the **Google Solar API** (`buildingInsights:findClosest`) what fits on the closest building's roof, and turns
that into a **transparent proposal**: a few system sizes with yearly energy, cost, incentives, year-1 savings and
payback computed from **your** tariff, install cost per watt and incentives. When the API returns its own
`financialAnalyses`, they are shown **side by side** with yours, never mixed in.
## The problem
Solar installers and homeowners want a fast, credible first answer for one roof: how big a system fits, how much
it produces, and whether it pays back at local prices. The Solar API has the roof data (panel layouts, yearly DC
energy per layout, roof segments) and, in some areas, its own financial analysis, but an agent still has to
geocode, pick layouts, convert DC to AC, apply prices and keep track of which numbers are assumptions. This lab does
that in one tool call and labels the source of every input.
## Quickstart
```bash
bun install --frozen-lockfile
# Keyless demo: synthetic building in the documented response shape (fictional address, invented values)
bun run src/cli.ts roof --address "100 Example Ave, Sampletown" --example-inputs --fixtures
bun run src/cli.ts roof --address "100 Example Ave, Sampletown" --tariff 0.25 --cost-per-watt 2.8 \
--incentive-pct 10 --monthly-bill 120 --format json --fixtures
bun run src/cli.ts roof --address "7 Sample Plaza, Calmtown" --min-quality MEDIUM --example-inputs --fixtures
# Live: put GOOGLE_MAPS_API_KEY in .env (see .env.example), then
bun run src/cli.ts roof --address "<address>" --tariff <your price per kWh> --cost-per-watt <installer quote per W> \
[--incentive-pct <percent>] [--incentive-amount <rebate>] [--annual-kwh <usage> | --monthly-bill <bill>]
bun run src/cli.ts roof --at=37.4,-122.1 --min-quality MEDIUM # negative numbers need --at=...
# Methodology (formulas, constants, example inputs, references)
bun run src/cli.ts method
# MCP server (stdio)
bun run src/cli.ts serve # live
bun run src/cli.ts serve --fixtures # keyless demo
```
MCP client config (Claude Code `.mcp.json`, Cursor `mcp.json`):
```json
{
"mcpServers": {
"solarscope": {
"command": "bun",
"args": ["run", "/path/to/solarscope-mcp/src/cli.ts", "serve"],
"env": { "GOOGLE_MAPS_API_KEY": "${GOOGLE_MAPS_API_KEY}", "SOLARSCOPE_BUDGET": "10" }
}
}
}
```
The key's Cloud project must have the Solar API (`solar.googleapis.com`) enabled with billing on, plus the
Geocoding API for `address` lookups. Restrict the key to those APIs.
## Tools
| Tool | What it does | Google calls |
| --- | --- | --- |
| `roof_potential` | `address` or `lat`+`lng`; prices `tariff_per_kwh`, `install_cost_per_watt`, `incentive_percent`, `incentive_amount`, `export_rate_per_kwh`; usage `annual_kwh` or `monthly_bill`; `lifetime_years`, `currency`, `panel_counts[]`, `use_example_inputs`, `min_quality` (HIGH/MEDIUM/LOW, default HIGH), `exact_quality`, `format` (`json`/`markdown`/`both`) | 1 Geocoding v4 call for an address, plus 1 `findClosest` call |
| `proposal_method` | Formulas, input sources, Google's US method constants, the example inputs, verdict rule, attribution, reference URLs | none |
| `session_status` | Budget used/remaining, refused calls | none |
CLI: `solarscope roof | method | serve`. Exit codes: 0 ok, 2 error, 64 usage.
### How the proposal is computed
1. **Layouts.** From `solarPotential.solarPanelConfigs` (ordered by panel count; config N uses the first N panels):
25%, 50% and 75% of the largest layout, the largest, the largest layout whose first-year AC output stays within
your yearly usage (if given), the layout the API's own analysis picked for its default bill (if any), and any
`panel_counts` you ask for.
2. **Size and energy.** kW = `panelsCount x panelCapacityWatts / 1000`. Year-1 AC kWh = `yearlyEnergyDcKwh x 0.85`.
Output declines by factor 0.995 per year.
3. **Cost.** `watts x install_cost_per_watt`; incentives = `cost x incentive_percent / 100 + incentive_amount`,
capped at the cost.
4. **Savings.** Year n = (self-consumed kWh x tariff + exported kWh x export rate) x 1.022^(n-1). Without usage, every
kWh is valued at the tariff. With usage and no export rate, exports are credited at the tariff (labelled as an
assumption).
5. **Payback.** First (fractional) year when cumulative savings reach the net cost; simple payback = net cost /
year-1 savings; NPV discounts year n by 1.04^(n-1). Recommended option = highest NPV.
6. **Verdict** on the recommended option: `pays-back-early` (within half the lifetime), `pays-back-late` (within the
lifetime), `does-not-pay-back`, `needs-prices` (no tariff or install cost), `no-usable-layout` (the API returned
no layouts: fewer than 4 panels fit).
The constants 0.85 (DC to AC derate), 0.995 (yearly output factor), 1.022 (yearly price increase), 1.04 (discount
rate) and the 20-year lifespan are the values Google's
[US costs and savings page](https://developers.google.com/maps/documentation/solar/calculate-costs-us) lists for
its own US analysis. They are labelled `google-us-method` in every output and can be overridden. The lifetime
defaults to the response's `panelLifetimeYears` when present.
### No invented prices
Tariff, install cost and incentives have **no hidden defaults**. Without them you get sizes and energy and the
verdict `needs-prices`. `use_example_inputs` / `--example-inputs` fills the missing prices with round placeholder
values (0.15 per kWh, 3.00 per W, no incentive) that this lab chose for demos; every such input carries
`source: "example"` and the note "EXAMPLE INPUT: placeholder chosen by this lab, not a market price or quote", and
the verdict headline starts with "On EXAMPLE inputs (not real prices)".
### Output
JSON (`proposal`): `location`, `building` (name, imagery quality and dates, distance from the query point), `roof`
(max panels and kW, panel model, sunshine, segments with facing/pitch/area), `inputs` (each `{value, source, note}`),
`options[]` (size, DC/AC energy, kWh/kW, CO2 offset from `carbonOffsetFactorKgPerMwh`, self-consumed/exported kWh,
`finance`), `recommended`, `verdict`, `apiFinancials` (compacted analyses per bill: cash, financed, lease,
incentives, implied price per kWh and per watt; plus a comparison table for one bill), `assumptions`, `notes`,
`attribution`, `references`, `source`. Markdown: verdict, roof table, options table, the API comparison, an inputs
table with sources, and the attribution line. A full sample is in
[`reports/solarscope-mcp.md`](reports/solarscope-mcp.md).
## Measured
Keyless run of the real MCP server (in-memory MCP client) over `fixtures/synthetic/`: invented buildings and
invented API financial analyses in the response shape documented in Google's API reference. The numbers show what
the tool does with that input, not anything about real roofs or prices. "MCP = service" checks that the tool's
JSON and Markdown equal the service output for the same input. Produced by `bun run report`; full output in
`reports/solarscope-mcp.md` and `reports/solarscope-mcp.json`.
<!-- measured:start -->
| Scenario | Input | Calls | Imagery | Layouts | Options | Recommended | Yr-1 AC kWh | Net cost | Payback (yrs) | API payback, same bill (yrs) | Verdict | Solar response bytes | Proposal JSON bytes | Markdown bytes | MCP = service |
| --- | --- | ---: | --- | ---: | ---: | --- | ---: | ---: | ---: | ---: | --- | ---: | ---: | ---: | --- |
| sizes only | address, no prices | 2 | HIGH | 33 | 5 | 36 panels / 14.4 kW | 14,680 | - | - | 11.3 (20 panels) | needs-prices | 37402 | 14922 | 5702 | true |
| example inputs | address, use_example_inputs | 2 | HIGH | 33 | 5 | 9 panels / 3.6 kW | 4,208 | 10,800 | 15.2 | 11.3 (20 panels) | pays-back-late | 37402 | 16616 | 6181 | true |
| typed prices | address, 0.25/kWh, 2.80/W, 10% | 2 | HIGH | 33 | 5 | 36 panels / 14.4 kW | 14,680 | 36,288 | 9.2 | 11.3 (20 panels) | pays-back-early | 37402 | 16218 | 5789 | true |
| typed prices + usage | same + 6000 kWh/yr, export 0.05 | 2 | HIGH | 33 | 6 | 12 panels / 4.8 kW | 5,572 | 12,096 | 8.2 | 11.3 (20 panels) | pays-back-early | 37402 | 16635 | 5686 | true |
| flat roof, MEDIUM imagery | address, min_quality MEDIUM, example inputs | 2 | MEDIUM | 15 | 4 | 18 panels / 7.2 kW | 9,360 | 21,600 | 13.8 | - | pays-back-late | 7971 | 7912 | 4495 | true |
| shed (3 panels fit) | lat/lng, typed prices | 1 | HIGH | 0 | 0 | - | - | - | - | - | no-usable-layout | 1726 | 4190 | 3177 | true |
- Google's US method page says the 20-year cost without solar is `204.35 x monthlyBill`; `src/finance.ts` with the page's constants (1.022 / 1.04, 20 years) gives **204.35**.
- Flat-roof address at the default `min_quality` HIGH: **google_NOT_FOUND** (synthetic 404); with MEDIUM it answers (row above).
- 403 SERVICE_DISABLED (synthetic copy of the error shape): tool error **google_PERMISSION_DENIED**, reason **SERVICE_DISABLED**.
- Point with no building: **google_NOT_FOUND**.
- Budget 3, two address checks (2 calls each): ok, budget_exceeded.
- "example inputs" rows: 4 inputs carry `source: "example"`; "typed prices" rows: 0.
<!-- measured:end -->
### Live status (2026-10-21)
Calls made with our Maps Platform key in project `dev-test-461117` while building this lab (`bun test tests/live`):
| Call | Result |
| --- | --- |
| Geocoding API v4 `geocode/address` (public address of Google's HQ) | HTTP 200, coordinates in the expected box |
| Solar API `buildingInsights:findClosest` | HTTP 403, reason `SERVICE_DISABLED`: the API is not enabled in that project, and we did not enable it. `tests/live` prints a `skipped:` line |
So **no real Solar API response has been observed by this lab**. The Solar path is tested only against synthetic
responses built from the reference. When the API is enabled, `bun run record` saves one real response to the
gitignored `.recorded-local/`, and `tests/live` then checks it against `src/schema.ts`.
## What we checked in Google's docs
Checked on 2026-10-21.
| Item | What the docs say | Source |
| --- | --- | --- |
| Endpoint | `GET https://solar.googleapis.com/v1/buildingInsights:findClosest`, empty body; `NOT_FOUND` when no building is within about 50 m | [findClosest reference](https://developers.google.com/maps/documentation/solar/reference/rest/v1/buildingInsights/findClosest) (last updated 2026-05-12) |
| Parameters | `location` (required), `requiredQuality` (minimum; omitted = HIGH only), `exactQualityRequired`, `experiments[]` (pre-GA), `additionalInsights[]` (`DETECTED_ARRAYS`) | same |
| Imagery quality | `HIGH`, `MEDIUM`, `LOW` (plus `IMAGERY_QUALITY_UNSPECIFIED`); if only higher quality exists the API returns it, if nothing meets the minimum it answers 404 | [ImageryQuality](https://developers.google.com/maps/documentation/solar/reference/rest/v1/ImageryQuality), [building insights guide](https://developers.google.com/maps/documentation/solar/building-insights) |
| Response | `name`, `center`, `boundingBox`, `imageryDate`, `imageryProcessedDate`, `postalCode`, `administrativeArea`, `statisticalArea`, `regionCode`, `solarPotential`, `imageryQuality`, `detectedArrays` | findClosest reference |
| `solarPotential` | `maxArrayPanelsCount`, `panelCapacityWatts`, `panelHeightMeters`, `panelWidthMeters`, `panelLifetimeYears`, `maxArrayAreaMeters2`, `maxSunshineHoursPerYear`, `carbonOffsetFactorKgPerMwh`, `wholeRoofStats`, `buildingStats`, `roofSegmentStats[]`, `solarPanels[]`, `solarPanelConfigs[]` (only when at least 4 panels fit; config N = first N panels), `financialAnalyses[]` (empty where the API lacks data) | same |
| `financialAnalyses` | per `monthlyBill`: `defaultBill`, `averageKwhPerMonth`, `panelConfigIndex` (-1 = no layout), `financialDetails`, `cashPurchaseSavings` (`outOfPocketCost`, `upfrontCost`, `rebateValue`, `paybackYears`, negative = never), `financedPurchaseSavings`, `leasingSavings`, all money as `Money` (`units` int64 string + `nanos`) | same |
| US method constants | DC to AC 0.85, efficiency depreciation 0.995, cost increase 1.022, discount 1.04, 20-year lifespan; cost without solar = 204.35 x monthlyBill (reproduced in `tests/unit/finance.test.ts`) | [Calculate costs and savings (US only)](https://developers.google.com/maps/documentation/solar/calculate-costs-us) |
| Attribution | "Source: Includes solar data from Google" next to the data; "Includes data from Google Maps" for new content created from Environment API data | [Solar API policies](https://developers.google.com/maps/documentation/solar/policies) |
| "Installers lack fast, credible rooftop proposals" | Not verified: the idea's market claim, not measured | none |
Doc inconsistencies we found: the building insights guide's TypeScript sample types `imageryQuality` as
`'HIGH' | 'MEDIUM' | 'BASE'` and sends `required_quality: 'BASE'`, while the reference enum lists `LOW`. This lab
follows the reference (`LOW`); we could not test which spelling the live API accepts. The US method page's
`LifetimeProductionAcKwh` formula multiplies by `dcToAcDerate` although `initialAcKwhPerYear` already includes it;
this lab applies the derate once. The guide's examples pass the key as `key=`; this lab sends `X-Goog-Api-Key`
instead (the Solar API answered `SERVICE_DISABLED` for our project with that header, so it identified the project,
but we have not seen a successful response with it).
## Attribution and usage requirements
From the [Solar API policies](https://developers.google.com/maps/documentation/solar/policies):
- **Attribution.** If you use data or imagery from the Solar API you must show **"Source: Includes solar data from
Google"** on or next to it. Content you create from Environment API data uses **"Includes data from Google
Maps"** (text attribution rules: font, size, color, placement, `translate="no"` in HTML). Every JSON proposal
carries both (`attribution`, `derivedAttribution`) and every Markdown proposal ends with both. Keep them next to
the numbers wherever you show them.
- **No caching.** Pre-fetching, caching or storing Solar content is generally restricted (place IDs are exempt).
This server keeps nothing between calls, and the repo commits no Google responses.
- **Maps.** If you show Solar results on a map, it must be a Google Map.
- **Terms.** Your app needs public Terms of Use and a Privacy Policy that meet Google's requirements; use is
governed by your agreement with Google. EEA billing addresses get different terms, and the docs say certain Solar
API content is no longer returned for them.
- **Billing.** Building insights is the "Solar API Building Insights" SKU (Essentials category) per the
[usage and billing page](https://developers.google.com/maps/documentation/solar/usage-and-billing). No prices are
quoted here; set Cloud quotas and budget alerts.
## Limits
- **Solar responses unverified live.** See Live status. Synthetic fixtures follow the reference field by field
(`src/types.ts`, strict check in `src/schema.ts`), but real responses may carry fields or value formats we have
not seen.
- **Not a quote, not financial advice.** The proposal is arithmetic on the API's energy figures and your inputs:
one flat tariff, one cost per watt, your incentives. No time-of-use rates, tiered tariffs, export caps, loans,
maintenance, inverter replacement, taxes, shading changes or panel model choice. The API's own analysis uses its
own data and models; differences between the two columns are expected.
- **No tariff data.** The idea mentioned a local tariff table; there is no verified open data source for it in this
MVP, so the tariff is a user input.
- **No weather.** The idea's original title mentions the Weather API. It is not enabled in our project and is not
needed for yearly energy (the Solar API already returns yearly DC kWh per layout), so it is left out.
- **Closest building.** The API returns the building nearest the point; the output reports the distance and adds a
note above 30 m. Geocoding returns one point per address, which can land on the wrong building.
- **Budget scope.** One upstream call budget per process / MCP connection (default 10), not per key or per day.
- **Keyless mode answers only the demo inputs** in `fixtures/synthetic/INDEX.json`; anything else returns
`google_NO_FIXTURE`. Fixture output is never a real building and says so in the Markdown footer.
- **Not included (separate ideas):** detected arrays (`additionalInsights=DETECTED_ARRAYS`), roof geometry views,
the `dataLayers` endpoint (imagery GeoTIFFs), the pre-GA `EXPANDED_COVERAGE` experiment, batch address lists.
`findClosestUrl` already accepts `additionalInsights` and `experiments`, and `src/types.ts` types
`detectedArrays`, so these can be added as new tools.
## Development
```bash
bun run check # install, skills layout, strict YAML, biome, tsc, unit + e2e tests, live tests (skip
# cleanly), report, bun audit, actionlint (if installed), gitleaks
bun test tests/unit tests/e2e
bun test tests/live # needs GOOGLE_MAPS_API_KEY; prints "skipped: ..." otherwise (SOLARSCOPE_SKIP_LIVE=1 forces skip)
bun run fixtures:synthetic # regenerate fixtures/synthetic from src/synthetic.ts
bun run record # live: save real responses to gitignored .recorded-local/ for the shape check
```
## Not affiliated
Not affiliated with, endorsed by, or sponsored by Google LLC. Google Maps Platform, Solar API and Geocoding API are
trademarks of Google LLC.
---
Built by [SkilledDragon LLC](https://skilleddragon.com). Questions: hello@skilleddragon.com. Apache-2.0 (see LICENSE
and NOTICE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues