Skip to main content
Glama
borovkov-d

BuildWindow

by borovkov-d
README.md
# 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

A4.6/5.0

Scored across 4 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness5/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues