Skip to main content
Glama
SkilledDragonCorp

SolarScope Arrays

solarscope-detected-arrays

An MCP server and CLI for solar prospecting with the Google Solar API detected arrays insight. Give it a CSV of addresses, a list of points or a bounding box. For each building it reports whether rooftop PV is already detected, the capture date of that imagery and its age, and the roof's solar potential. It then writes a ranked prospect list (CSV + Markdown + JSON): roofs with no PV detected, recent imagery and good potential come first, roofs that already have panels are skipped, and stale or missing evidence is flagged for review. Every run has a call budget and a list-price cost estimate.

SolarScope v2: it extends solarscope-mcp (one roof, one payback proposal) and reuses its Google client, transport, schema check and fixture pattern.

The problem

Solar installers and retrofit sellers waste outreach on homes that already have panels. Since May 11, 2026 the Solar API's buildingInsights:findClosest can return detectedArrays (whether PV was detected on the building, and when that imagery was captured), but an agent still has to geocode a list, call the API once per building, read the detection status, check the capture date's age, drop duplicates and rank what is left. This lab does that in one tool call, under a budget, and states Google's terms next to the output.

Related MCP server: google-maps-leads

Quickstart

bun install --frozen-lockfile

# Keyless demo: synthetic buildings, fictional addresses (coordinates in the open Atlantic)
bun run src/cli.ts prospect --csv examples/addresses.csv --as-of 2026-10-21 --fixtures --stdout md
bun run src/cli.ts prospect --bbox=30.1,-40.1,30.1008,-40.099 --step-m 40 --fixtures --stdout csv
bun run src/cli.ts check --address "14 Example Ave, Sampletown" --fixtures

# Before a live run: how many calls, how much at list price (no key, no Google call)
bun run src/cli.ts plan --csv my-addresses.csv --max-calls 200

# Live: put GOOGLE_MAPS_API_KEY in .env (see .env.example), then
bun run src/cli.ts prospect --csv my-addresses.csv --max-calls 200 --out prospects-out/today
bun run src/cli.ts prospect --points="37.40,-122.08;37.41,-122.09"
bun run src/cli.ts check --at=37.4,-122.1 --min-quality MEDIUM

# MCP server (stdio)
bun run src/cli.ts serve            # live
bun run src/cli.ts serve --fixtures # keyless demo

CSV input: a header row with address, or lat + lng (aliases latitude, lon, longitude), and an optional id. Other columns are ignored and never echoed back. Outputs go to prospects-out/ (gitignored) as prospects.csv, prospects.md and prospects.json.

MCP client config (Claude Code .mcp.json, Cursor mcp.json):

{
  "mcpServers": {
    "solarscope-arrays": {
      "command": "bun",
      "args": ["run", "/path/to/solarscope-detected-arrays/src/cli.ts", "serve"],
      "env": { "GOOGLE_MAPS_API_KEY": "${GOOGLE_MAPS_API_KEY}", "SOLARSCOPE_BUDGET": "100" }
    }
  }
}

The key's Cloud project needs the Solar API (solar.googleapis.com) with billing on, plus the Geocoding API for address rows. Restrict the key to those two APIs.

Tools

Tool

What it does

Google calls

has_existing_pv

One building: address or lat+lng; as_of, stale_months, min_quality, exact_quality, format

2 for an address, 1 for a point

prospect_list

csv text, items[] (addresses/points) or bbox + step_m; thresholds stale_months, min_panels, min_sunshine_hours; min_quality, exact_quality, as_of, max_calls (default 50); format json/markdown/csv/all

1 Geocoding per unique address + 1 Solar per unique query, at most max_calls

plan_prospect_run

Same inputs as prospect_list: rows, unique queries, planned calls, fits-budget, list-price upper bound

none

prospect_method

Rules, lab defaults, Google doc facts with URLs, prices, terms

none

session_status

Session budget used/remaining, successful calls per API, Solar 404s

none

CLI: solarscope-arrays check | prospect | plan | method | serve. Exit codes: 0 ok, 2 error (or a run stopped by a Google 401/403), 64 usage.

How a building is classified

Each unique query is one GET buildingInsights:findClosest?location...&requiredQuality=<min>&additionalInsights=DETECTED_ARRAYS (after a Geocoding API v4 call for address rows).

Field used

Meaning in the output

detectedArrays.detectionStatus

DETECTION_STATUS_ARRAYS_DETECTED = PV yes; ..._NO_ARRAYS_DETECTED = no; ..._DATA_UNAVAILABLE, ..._UNSPECIFIED or missing = unknown

detectedArrays.latestCaptureDate

PV evidence date; age = as-of date minus capture date, in days and months (days / 30.4375); month-only dates are marked approximate

imageryDate, imageryQuality

Roof imagery date and quality, reported separately (Google: the two datasets use different imagery)

solarPotential.maxArrayPanelsCount, panelCapacityWatts, maxSunshineHoursPerYear, last solarPanelConfigs[].yearlyEnergyDcKwh

Max panels, max kW, sunshine hours, max-layout yearly DC kWh

center, name

Distance from the query point; buildings/{place_id} (place IDs may be stored indefinitely)

Status, in this order: skip-existing-pv (PV detected) -> skip-low-potential (fewer than min_panels panels or fewer than min_sunshine_hours sunshine hours) -> review (PV unknown, no capture date, capture older than stale_months, or building center more than 30 m from the query point) -> prospect. Rows that repeat a query reuse the first answer (no call); rows that land on an already-listed building become same-building; plus no-building (Solar 404), not-geocoded, error, not-run (budget, or the run stopped).

Ranking: prospect, review, skip-low-potential, skip-existing-pv, same-building, no-building, not-geocoded, error, not-run. Inside prospect and review: most max-layout yearly DC kWh first, then the most recent PV capture.

Lab defaults (ours, not Google guidance; every output lists them with their source): stale_months 24, min_panels 8, min_sunshine_hours 1000, far-from-point 30 m, max_calls 50 per run, grid step 40 m, 1,000 rows and 400 grid points per run.

Budget and cost

  • plan / plan_prospect_run counts the calls before anything is spent.

  • A row runs only if all of its calls fit in what is left of the run budget (--max-calls / max_calls) and the session budget (SOLARSCOPE_BUDGET, default 100). Rows that do not fit are not-run, never half-run.

  • A 401/403 from Google (API disabled, key blocked) stops the run so the other rows do not each spend a failing call.

  • Cost estimate = successful calls x list price; upper bound = every planned call at list price (see the price table below). Solar 404 answers are not charged; the monthly free caps are not applied.

Measured

Keyless run of the real MCP server (in-memory MCP client) over fixtures/synthetic/: invented buildings with fictional addresses, in the response shape documented in Google's API reference, as of 2026-10-21. The numbers show what the tool does with that input, not anything about real roofs. "MCP = service" checks that the tool's JSON, Markdown and CSV equal the service output for the same input. Produced by bun run report; full output in reports/solarscope-detected-arrays.md and .json.

Scenario

Input

Rows

Calls planned / used

Prospects

Review

Skip: PV

Skip: low potential

Same building

No building / not geocoded

Error / not run

Buildings

Prospects (rank order)

Est. USD (upper bound)

MCP = service

address CSV

examples/addresses.csv (12 rows), defaults

12

20 / 19

2

3

1

1

2

2 / 1

0 / 0

7

A6, A1

0.125 (0.155)

true

address CSV, MEDIUM imagery

same, min_quality MEDIUM

12

20 / 19

3

3

1

1

2

1 / 1

0 / 0

8

A6, A7, A1

0.135 (0.155)

true

address CSV, 60-month window

same, stale_months 60

12

20 / 19

3

2

1

1

2

2 / 1

0 / 0

7

A6, A1, A3

0.125 (0.155)

true

address CSV, small budget

same, max_calls 8

12

20 / 8

1

2

1

0

1

0 / 0

0 / 7

4

A1

0.060 (0.155)

true

bounding box

3 x 3 grid, 40 m

9

9 / 9

2

1

1

0

3

2 / 0

0 / 0

4

g3-2, g1-1

0.070 (0.090)

true

Solar API disabled

3 points, first answers 403 SERVICE_DISABLED

3

3 / 1

0

0

0

0

0

0 / 0

1 / 2

0

-

0.000 (0.030)

true

  • has_existing_pv on 14 Example Ave (synthetic): skip-existing-pv, PV detected, capture 2026-02-02 (8.6 months before 2026-10-21), 2 calls.

  • plan_prospect_run on the CSV with max_calls 10: 11 unique queries, 20 planned calls, fits budget: false, upper bound USD 0.155; session calls after check + plan: 2.

  • Est. USD = successful calls x list price (Solar API Building Insights USD 10.00 / 1,000, Geocoding USD 5.00 / 1,000, first paid tier, free caps not applied); Solar 404s are not charged. Upper bound = every planned call at list price.

  • Every output row carries Source: Includes solar data from Google and a delete-by date 30 days after the as-of date (2026-10-21 + 30 = 2026-11-20).

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 with additionalInsights=DETECTED_ARRAYS

HTTP 403, reason SERVICE_DISABLED: the Solar API is not enabled in that project, and we did not enable it. tests/live prints a skipped: line

So no real detected-arrays response has been observed by this lab. Everything Solar-side is tested 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

GA date

Release note dated May 11, 2026: "Detected arrays data generally available"; the API "detects existing rooftop solar installations based on satellite imagery"

Solar API release notes (last updated 2026-10-20)

Request

additionalInsights[] on findClosest; enum ADDITIONAL_INSIGHTS_UNSPECIFIED, DETECTED_ARRAYS ("Determines whether the response will include the detected arrays")

findClosest reference (last updated 2026-05-12)

Response

detectedArrays is "only populated if DETECTED_ARRAYS is included" in the request. DetectedArrays has exactly two fields: detectionStatus (DETECTION_STATUS_UNSPECIFIED, _DATA_UNAVAILABLE, _ARRAYS_DETECTED, _NO_ARRAYS_DETECTED) and latestCaptureDate (Date). No confidence score, array count, panel area or geometry is documented

same

Imagery

"The satellite imagery used for building insights data and detected arrays data are different." Coverage: "see the coverage map" (no separate detected-arrays coverage list)

Building insights guide (last updated 2026-10-20)

Price

"Solar API Building Insights" (Essentials, SKU 1856-4940-856A): free cap 10,000 events/month, then USD 10.00 per 1,000 up to 100,000 (lower tiers above). "Geocoding" (Essentials, SKU BAC8-4E68-E261): free cap 10,000, then USD 5.00 per 1,000

Pricing list (last updated 2026-10-20)

Billing event

Building Insights bills a "Successful request"; requests that return NOT_FOUND "aren't charged, but do count against your usage limits". Nothing on whether DETECTED_ARRAYS changes the price

SKU details (last updated 2026-10-20)

Geocoding SKU

"Geocoding API requests are billed using the SKU for Geocoding"; no separate v4 SKU named

Geocoding usage and billing (last updated 2026-10-20)

Caching

Service Specific Terms section 20.2: Building Insights and Data Layers may be cached "for up to 30 consecutive calendar days", then deleted, except Solar Data incorporated into fixed media (feasibility study, proposal, marketing materials) for a Downstream Transaction. Policies page: caching "generally restricted"; place IDs may be stored indefinitely. Geocoding lat/lng: up to 30 days (section 6.3.1)

Service Specific Terms (version listed as last modified 2026-10-15), Solar API policies (last updated 2026-10-20)

Permitted use

Section 20.1: only to determine the feasibility of installing energy systems for an address or geographic area, to design or install one, or for a Downstream Transaction; marketing materials count only "where a user has opted-in to receiving such materials". Nothing specific to detected arrays

Service Specific Terms

Attribution

"Source: Includes solar data from Google"; "Includes data from Google Maps" for new content created from it

Solar API policies

Doc inconsistencies we found:

  • The release note and the guide say to set additionalInsights "in your request body", but findClosest is a GET whose body must be empty, and the guide's own example passes additionalInsights=DETECTED_ARRAYS as a query parameter. This lab sends it as a query parameter.

  • The guide's detected-arrays example sends required_quality=BASE, and a September 4, 2025 release note announces a BASE quality tier, but the ImageryQuality reference (last updated 2025-08-27) lists only HIGH, MEDIUM, LOW. This lab accepts all four and has not tested BASE.

Terms and data handling

  • What this tool keeps: nothing between runs. Inside one run, a repeated query reuses the first answer and rows on the same building are merged. The MCP server holds no state except call counters.

  • What you keep: the CSV/Markdown/JSON files contain Solar API data. Each output carries a solar_data_delete_by date (as-of date + 30 days, per section 20.2) and the attribution line on every row. Delete the files by then unless they became part of a feasibility study or proposal for a Downstream Transaction. prospects-out/ and .recorded-local/ are gitignored.

  • Permitted use: a prospect list is a feasibility screen for an area or a list of addresses. Section 20.1 only counts marketing materials as a Downstream Transaction when the recipient opted in. This tool does not send anything, and it does not make outreach to the people at these addresses permitted. EEA billing addresses have different Solar API terms.

  • This is our reading of Google's pages, not legal advice. Your agreement with Google governs.

Limits

  • Detected arrays unverified live. See Live status: our project gets 403 SERVICE_DISABLED, so the real detectedArrays shape, value formats (e.g. whether latestCaptureDate ever omits the day) and coverage are untested. The synthetic fixtures follow the reference field by field and pass a strict schema check.

  • Building-level only. The API says whether arrays were detected on a building and when the imagery was captured. It gives no array size, panel count, position or confidence, so the tool cannot say how much of the roof is covered or whether there is room for more. The docs do not say whether solar thermal collectors or ground-mounted arrays count as detected arrays.

  • Detection accuracy unknown. Google does not publish precision/recall for detected arrays; a no can be wrong. The stale flag only says the evidence is old, not that panels were added.

  • Thresholds are ours. 24 months, 8 panels, 1000 sunshine hours and 30 m are lab defaults, adjustable per run.

  • Closest building. findClosest returns the building nearest the point; geocoding gives one point per address, which can land on a neighbour. Rows more than 30 m away go to review. Apartments at one address share a building.

  • Bounding boxes are sampled as a grid; buildings between grid points are missed, and one building can take several calls (merged in the output, but each call is spent). Use a step near the typical lot width.

  • Cost is an estimate at the first paid tier with no free cap; whether DETECTED_ARRAYS changes the Building Insights price is not documented. Geocoding answers with no result are counted as billable (conservative).

  • Budget scope. One session budget per process / MCP connection, plus a per-run budget; not per key or per day. Use Cloud quotas and budget alerts.

  • 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.

  • Not verified: the idea's claim that nobody packages detected arrays as an agent-ready prospecting tool.

  • Not included: contact data of any kind, CRM export, maps (Solar results shown on a map must use a Google Map), dataLayers, financial analysis (see solarscope-mcp).

Development

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 and examples/addresses.csv 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. Questions: hello@skilleddragon.com. Apache-2.0 (see LICENSE and NOTICE).

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables solar energy potential assessment by converting parcel centroid and acreage into annual/monthly generation estimates using the NREL PVWatts v8 API, suitable for revenue modeling and infographic headlines.
    -
  • F
    license
    Not graded
    quality
    B
    maintenance
    Enables AI assistants to discover target companies, research decision-makers, enrich existing prospect sheets with sourced company and contact data, and manage editable, exportable research lists.
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables rooftop solar installers to turn a CSV of tomorrow's booked jobs into a ranked crew schedule in Markdown and JSON, joining per-address geocoding, roof pitch/azimuth from the Google Solar API, hourly forecast checks against crew-safety thresholds, and estimated plane-of-array irradiance. Each job is returned as KEEP, MOVE (with a new same-day start) or SLIP, together with the specific rule that decided it.
    Apache 2.0