BuildWindow
# BuildWindow
MCP lab project: an agent plans construction work against a real weather
forecast using two MCP servers.
This is a course assignment for the KSE AI Agentic School (an MCP
integration assignment): build a custom MCP server for a real domain
problem, then connect it — alongside an existing third-party MCP server —
to an agent that uses both together to do something a single tool
couldn't.
## Overview
BuildWindow is an MCP (Model Context Protocol) lab project built around a
concrete scheduling problem: given a list of construction works with
dependencies between them, and a weather forecast for a city, produce a
schedule that respects both. The agent that does this planning holds two
separate MCP connections at once. The first is the external, Go-based
OpenWeather MCP server (github.com/mschneider82/mcp-openweather), which the
agent calls once per run to get live current conditions and a 5-day
forecast for the requested city — this is the only place in the whole
project where a network call happens. The second is this repository's own
BuildWindow MCP server: a local, fully deterministic server with no network
calls at runtime, backed by a local JSON dataset of construction work
types and their weather limits, that exposes four tools encoding the
construction-domain rules (weather-suitability verdicts, curing-time
estimation, and multi-work scheduling).
The two servers deliberately do not overlap in responsibility. OpenWeather
MCP is the only source of anything that changes day to day — the weather
itself. BuildWindow MCP owns everything that is a fixed rule instead: what
temperature, wind, humidity and precipitation a given work type tolerates,
how long concrete takes to cure at a given temperature, and how to place
several dependent works into the earliest non-prohibited windows across a
multi-day forecast. The BuildWindow server is built with the official
Python MCP SDK (package `mcp`, v2.0.0+), using its `MCPServer` class — note
that this class was named `FastMCP` in older SDK versions and was renamed
to `MCPServer` as of SDK v2.0.0. The agent that drives both connections is
built with the Claude Agent SDK (`claude-agent-sdk` on PyPI).
The *schedule-critical* OpenWeather call is deliberately **not** made by
the LLM. The upstream tool's actual output (confirmed by reading its
source — see `docs/tool-contracts.md`) is a plain-text report, not JSON,
and the only signal it gives for *any* failure (bad key, unrecognized
city, unreachable provider) is a syntactically successful but empty
response — there is no error text to react to. So `agent/main.py` calls
it directly via a low-level MCP client, parses it with a small,
unit-tested function (`agent/normalize.py`), and only then starts an LLM
session — handing the model already-clean daily figures instead of
asking it to interpret raw provider text. The LLM session is connected to
*both* MCP servers (`get_mcp_status()` discovery shows both connections),
and the model genuinely *is* allowed to call the weather tool itself
(`allowed_tools` lists it explicitly) — but only for one current-
conditions sentence in its final report, per the system prompt; the daily
forecast that drives `plan_work_schedule` always comes from the
deterministic pre-session fetch, never from the model's own call. Both
servers are genuinely used in the agent's own flow, not just visible.
```
engineer input (city + work list)
-> agent/main.py calls OpenWeather MCP directly (not via the LLM) for
the schedule-critical daily forecast
-> agent/normalize.py parses the plain-text response into daily figures
-> (if no usable forecast: report plainly, stop -- no LLM session started)
-> LLM session starts, connected to BOTH MCP servers; may itself call
the weather tool once for current-conditions color commentary only
-> given the daily forecast + works as plain JSON (the only input that
ever drives scheduling)
-> BuildWindow MCP (plan_work_schedule, validate_work_window,
estimate_curing_time, ...)
-> schedule + explanation
```
## Prerequisites
- **Python 3.12+** — this repo was built and tested against 3.12.3.
- **[uv](https://docs.astral.sh/uv/)** — used as the dependency manager
for this project.
- **Go 1.24+** — only needed if you want to build the OpenWeather MCP
server yourself (installed here via `winget install --id GoLang.Go`,
currently Go 1.26.7). Not required to use the BuildWindow server or to
run its tests.
- **An OpenWeather API key** — only needed for a live agent run against
real weather. Free tier available at
[openweathermap.org/api](https://openweathermap.org/api).
## Installation
From the repo root:
```powershell
uv sync
```
This creates a `.venv` and installs both the runtime dependencies (`mcp`,
`pydantic`, `claude-agent-sdk`, `python-dotenv`) and the dev dependencies
(`pytest`, `ruff`, `black`).
## Configuration
Copy the example environment file and fill in your key:
```powershell
Copy-Item .env.example .env
```
bash: `cp .env.example .env`
Then edit `.env` and set `OWM_API_KEY` to a real key from
[openweathermap.org/api](https://openweathermap.org/api) (free tier).
`.env` is gitignored — it is never committed.
`agent/mcp_config.json` is the single source of truth for both MCP server
configurations. Its `openweather` entry references `${OWM_API_KEY}` as a
placeholder, which `agent/main.py` substitutes from the process
environment at startup. Note that `agent/main.py` does not read `.env`
itself — its `main()` calls `python-dotenv`'s `load_dotenv()` first, and
that call is what actually makes the values in `.env` reach the process
environment before the substitution happens.
Its `openweather.command` field is itself a placeholder,
`${MCP_OPENWEATHER_PATH}` — `agent/main.py` resolves it from the
`MCP_OPENWEATHER_PATH` environment variable if set, or falls back to the
bare command `mcp-openweather` (relying on `PATH`) if not. Set
`MCP_OPENWEATHER_PATH` in `.env` (see `.env.example`) to the binary's
absolute path if you don't want to add its directory to `PATH` — both
were verified to work live.
**Building the OpenWeather MCP server** (only needed if you want a live
run against real weather). These are the exact commands used to build and
verify it in this repo's own development environment:
```powershell
winget install --id GoLang.Go -e --accept-source-agreements --accept-package-agreements
# open a new shell so PATH picks up the Go toolchain, then:
go install github.com/mschneider82/mcp-openweather@main
```
This installs to `$(go env GOPATH)\bin\mcp-openweather.exe` — on Windows
that's typically `%USERPROFILE%\go\bin\mcp-openweather.exe`. **Important:**
the Go MSI installer adds the Go *toolchain* (`C:\Program Files\Go\bin`)
to `PATH`, but does **not** add `%USERPROFILE%\go\bin` — where `go
install` actually places built binaries. Either add that directory to
`PATH` yourself, or set `MCP_OPENWEATHER_PATH` to the binary's full path
(see above) — this repo's own setup uses the latter.
**Why `@main` and not `@latest`:** `go install ...@latest` resolves to
tag `v1.0.0`, which is one real commit ("Fix #5") behind the repository's
`main` branch. Both were built and compared live this project: `v1.0.0`
reads the optional `units`/`lang` arguments with no fallback when
they're omitted entirely, so an omitted `lang` fails with `language
unavailable` even though the tool's own schema declares a default;
`main`'s "Fix #5" commit adds defensive handling and the same call
succeeds. The forecast template itself is otherwise identical between
the two (confirmed by reading both versions' source) — building from
`main` doesn't add per-day wind/humidity/precipitation, it only fixes
the argument bug. `agent/main.py` always passes `city`, `units="c"`, and
`lang="en"` explicitly regardless, so this bug can't actually surface
through this project either way — but `main` is the more robust binary
to depend on if you ever call the tool a different way.
Do **not** use the `-o mcp-weather` flag shown in some of the upstream
README's own examples — that produces a binary name inconsistent with its
own config example. Build with the default name, `mcp-openweather`.
## Running the MCP server
```powershell
uv run python -m server.main
```
This runs the BuildWindow MCP server over stdio, independent of the agent
process — it can be started and exercised entirely on its own. On success
it prints exactly this line to stderr:
```
BuildWindow MCP server ready: 4 tools, 12 work types loaded
```
## Running the agent
```powershell
uv run python -m agent.main
```
With no arguments, this uses a built-in demo city ("Kyiv") and a built-in
demo work list: `excavation`, then `concrete_pour` (depending on it), then
`concrete_finishing` (depending on that).
Both can be overridden:
```powershell
uv run python -m agent.main "CityName"
uv run python -m agent.main "CityName" '[{"work_code": "excavation", "duration_days": 1, "depends_on": []}]'
```
The optional second argument is a JSON array of works in the same shape as
the demo list.
**Replay mode** — `--forecast-from-file <path>` is fully offline: it
replaces *both* the daily forecast and the current-conditions note with
data read from a recorded file, via the same deterministic parsers
(`normalize_forecast`, `parse_current_conditions`) used for a live call.
`openweather` is not connected at all in this mode (confirmed via
`get_mcp_status()` — only `buildwindow` appears), so a run needs no
network access and no API key whatsoever — verified live with a
deliberately broken `OWM_API_KEY` and an unreachable
`MCP_OPENWEATHER_PATH` at the same time; the run still completed
normally:
```powershell
uv run python -m agent.main "Longyearbyen" '[{"work_code": "excavation", "duration_days": 1, "depends_on": []}, {"work_code": "exterior_painting", "duration_days": 2, "depends_on": []}]' --forecast-from-file fixtures/weather_longyearbyen.txt
```
`fixtures/weather_kyiv.txt` and `fixtures/weather_longyearbyen.txt` are
real responses captured live this project (trimmed to 3 full real
calendar days each, no key inside either) — not synthetic, not
fabricated examples. Useful if the real weather in a demo city has
changed by the time you're running this, or if there's no network at
all at demo time.
A full live run against real weather requires a genuinely valid
`OWM_API_KEY` (see Configuration above) — confirmed working in this
repo's own development environment: `uv run python -m agent.main "Kyiv"`
produces a real schedule from a real forecast, and
`uv run python -m agent.main "Longyearbyen" '[...]'` demonstrates real
weather actually forcing a reschedule (see `docs/demo-checklist.md` step
4). Without a working key, `agent/main.py` fetches the forecast directly
(not via the LLM), gets an empty result, prints
`Forecast unavailable for '<city>' (...)`, and exits *before* starting
any LLM session — no wasted model call, no fabricated schedule. This was
verified with three real failure modes: the `mcp-openweather` binary
unreachable at all, an invalid `OWM_API_KEY`, and an invalid city name —
the last two are actually indistinguishable through this upstream tool
(see `docs/tool-contracts.md` for why) and were both confirmed to fail
the same clean way, even with a genuinely valid key active elsewhere in
the same environment.
**OpenWeather rate limits:** one successful live run makes exactly
**two** real calls to the `weather` tool (confirmed live, by counting) —
the deterministic pre-session fetch, plus the model's own single
current-conditions call (see Overview above). A failed live run (no
usable forecast) makes exactly one, since the LLM session never starts.
Replay mode (`--forecast-from-file`) makes **zero** — both the daily
forecast and the current-conditions note come from the recorded file,
and `openweather` isn't connected at all in this mode (confirmed live:
`get_mcp_status()` shows only `buildwindow`). OpenWeather's free tier is
documented at 60 calls/minute and 1,000,000 calls/month — comfortably
enough for any number of manual demo runs; this project doesn't
stress-test that published figure itself.
## Project structure
```
.
├── README.md, DECISIONS.md, pyproject.toml, uv.lock, .env.example, .gitignore
├── docs/
│ ├── tool-contracts.md
│ ├── design-rationale.md
│ └── demo-checklist.md
├── scripts/
│ └── list_tools.py # proves both MCP connections discover fine offline
├── server/
│ ├── main.py # MCP server entry point, registers the 4 tools
│ ├── schemas.py # Pydantic input/output models
│ ├── rules.py # deterministic verdict/curing/planner logic
│ ├── dataset.py # loads and validates work_types.json
│ ├── errors.py # domain exceptions and error codes
│ └── data/work_types.json
├── agent/
│ ├── main.py # agent entry point (Claude Agent SDK)
│ ├── normalize.py # deterministic OpenWeather text -> daily figures
│ └── mcp_config.json # config for both MCP servers
├── fixtures/
│ ├── weather_kyiv.txt # real captured response, for --forecast-from-file
│ └── weather_longyearbyen.txt # real captured response, for --forecast-from-file
└── tests/
├── conftest.py
├── test_dataset.py, test_lookup.py, test_validate.py
├── test_curing.py, test_planner.py, test_errors.py
└── test_normalization.py
```
## Tool overview
| Tool | Summary |
|---|---|
| `lookup_work_requirements` | Look up a work type's, or an entire category's, weather limits. |
| `validate_work_window` | Check one work type against one day of weather and get back an itemized verdict. |
| `estimate_curing_time` | Estimate when a curing work type will actually be ready, given a sequence of daily temperatures. |
| `plan_work_schedule` | Place several dependent works across a multi-day forecast in one call. |
The full contracts — exact JSON Schemas and real captured examples for
every tool, including the external `weather` tool as used by this project
— live in [`docs/tool-contracts.md`](docs/tool-contracts.md).
## Testing
```powershell
uv run pytest -v
uv run ruff check .
uv run black --check .
```
All three currently pass cleanly in this repo: 51 tests pass (covering
the 38 spec-required cases, a few supplementary assertions, and 8 tests
for the `agent/normalize.py` weather-parsing module, including the two
real-fixture and two current-conditions cases), and both `ruff` and
`black` report no issues.
## Limitations
The full reasoning behind each of these lives in
[`docs/design-rationale.md`](docs/design-rationale.md) — this list is
intentionally brief:
- Dataset thresholds are illustrative, not derived from real ДБН/ДСТУ
standards.
- The planner has no resource/crew constraints — works can overlap dates.
- The real planning horizon is capped at 5 days by the OpenWeather
provider.
- Curing time uses a simplified Nurse-Saul maturity model.
- One work occupies one continuous block — there is no split scheduling.
- The OpenWeather MCP server's `weather` tool (confirmed by reading its
source, not assumed) exposes only temperature per 3-hour forecast
entry — wind speed and humidity are only available in a single
current-conditions snapshot, applied here as a constant across every
forecast day, and precipitation isn't exposed at all, so
`precipitation_mm` is always `0.0` through this integration. **This
means BuildWindow's precipitation rule (a work with
`precipitation_allowed=false` gets a hard violation if
`precipitation_mm > 0`) can never actually trigger from a live run
through this integration** — it's real, correct code, covered by unit
tests against constructed data (`tests/test_validate.py`, spec cases
#16-17), but not something a live demo can show, since there is no live
path to non-zero precipitation. This project does not simulate or
inject fake rain data to manufacture that demo. The same upstream tool
also can't distinguish a bad API key from an unrecognized city from an
unreachable provider — all three come back as the same
syntactically-successful-but-empty response, which is why
`agent/main.py` can only report "no forecast available," not a specific
cause, for any of those three cases. See `docs/tool-contracts.md` for
the full, source-verified detail.
- A genuinely valid `OWM_API_KEY` is now confirmed working: a full live
run against real Kyiv weather produces a real schedule end-to-end, and
a real cold-weather city (Longyearbyen) was found where the live
forecast actually forces a work `unschedulable` and `validate_work_window` fires
with real numbers — see `docs/demo-checklist.md` step 4. Everything
described in this README has now been verified against a real,
working key, not just an absent one; see `DECISIONS.md` for what that
live run against real data did and didn't change in the code.
## Documentation
- [`docs/tool-contracts.md`](docs/tool-contracts.md) — the exact JSON
Schema contracts for all four BuildWindow tools, and for the external
OpenWeather `weather` tool as used by this project, each with a real
captured example.
- [`docs/design-rationale.md`](docs/design-rationale.md) — why each tool
exists, how the tool set maps onto the workflow, the boundaries between
components, the trade-offs made, and the project's limitations in full.
- [`docs/demo-checklist.md`](docs/demo-checklist.md) — a step-by-step
checklist for running a live demo of the project.
- [`DECISIONS.md`](DECISIONS.md) — a dated log of implementation
decisions, each with its rationale and the alternative that was
rejected.
TDQS
Scored across 4 tools
Each tool serves a distinct purpose: querying requirements, validating a single day, estimating curing, and planning schedules. No overlap in functionality; an agent can easily select the right tool for the task.
All tool names follow a consistent verb_noun pattern: lookup_work_requirements, validate_work_window, estimate_curing_time, plan_work_schedule. The naming style is uniform and predictable.
With only 4 tools, the server is well-scoped for its construction weather planning domain. Each tool is essential and there is no bloat or missing core functionality.
The server covers the full workflow from looking up constraints to validating individual days, estimating curing, and planning multi-work schedules. It also handles edge cases like capping and failures gracefully, indicating thorough domain coverage.