holiplan 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.
@@ -0,0 +1,99 @@
1
+ name: publish
2
+
3
+ # Run it by hand for a dry run: build, upload to TestPyPI and check the package
4
+ # installs and starts. Push a tag like v0.1.0 to do the same and then publish
5
+ # to PyPI. Uploads use trusted publishing (OIDC), so there are no tokens.
6
+ on:
7
+ workflow_dispatch:
8
+ push:
9
+ tags: ["v*"]
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ build:
16
+ runs-on: ubuntu-latest
17
+ outputs:
18
+ version: ${{ steps.version.outputs.version }}
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.13"
24
+ - name: Read the version
25
+ id: version
26
+ run: |
27
+ version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
28
+ echo "version=$version" >> "$GITHUB_OUTPUT"
29
+ - name: Check the tag matches the version
30
+ if: startsWith(github.ref, 'refs/tags/')
31
+ run: |
32
+ if [ "${GITHUB_REF_NAME}" != "v${{ steps.version.outputs.version }}" ]; then
33
+ echo "Tag ${GITHUB_REF_NAME} does not match pyproject version ${{ steps.version.outputs.version }}"
34
+ exit 1
35
+ fi
36
+ - name: Build
37
+ run: |
38
+ python -m pip install build twine
39
+ python -m build
40
+ python -m twine check --strict dist/*
41
+ - uses: actions/upload-artifact@v4
42
+ with:
43
+ name: dist
44
+ path: dist/
45
+
46
+ testpypi:
47
+ needs: build
48
+ runs-on: ubuntu-latest
49
+ environment: testpypi
50
+ permissions:
51
+ id-token: write
52
+ steps:
53
+ - uses: actions/download-artifact@v4
54
+ with:
55
+ name: dist
56
+ path: dist/
57
+ - uses: pypa/gh-action-pypi-publish@release/v1
58
+ with:
59
+ repository-url: https://test.pypi.org/legacy/
60
+ # A dry run and the tagged release upload the same version.
61
+ skip-existing: true
62
+
63
+ check-testpypi:
64
+ needs: [build, testpypi]
65
+ runs-on: ubuntu-latest
66
+ steps:
67
+ - uses: actions/setup-python@v5
68
+ with:
69
+ python-version: "3.13"
70
+ - name: Install from TestPyPI and start the server
71
+ run: |
72
+ # Dependencies come from PyPI; only holiplan comes from TestPyPI.
73
+ for attempt in 1 2 3 4 5 6; do
74
+ pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ \
75
+ "holiplan==${{ needs.build.outputs.version }}" && break
76
+ echo "Not on the index yet; retrying"; sleep 20
77
+ done
78
+ python - <<'EOF'
79
+ import asyncio
80
+ from holiplan import server
81
+ tools = asyncio.run(server.mcp.list_tools())
82
+ schema = server.profile_schema()
83
+ assert len(tools) == 9, [t.name for t in tools]
84
+ print(f"Installed holiplan serves {len(tools)} tools; profile schema {len(schema)} chars")
85
+ EOF
86
+
87
+ pypi:
88
+ if: startsWith(github.ref, 'refs/tags/v')
89
+ needs: check-testpypi
90
+ runs-on: ubuntu-latest
91
+ environment: pypi
92
+ permissions:
93
+ id-token: write
94
+ steps:
95
+ - uses: actions/download-artifact@v4
96
+ with:
97
+ name: dist
98
+ path: dist/
99
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,29 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ name: Python ${{ matrix.python }} on ${{ matrix.os }}
14
+ runs-on: ${{ matrix.os }}
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ os: [ubuntu-latest, windows-latest]
19
+ python: ["3.11", "3.12", "3.13", "3.14"]
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python }}
25
+ - name: Install
26
+ run: python -m pip install -e ".[dev]"
27
+ # Offline: OpenHolidays is stubbed or replayed from tests/fixtures throughout.
28
+ - name: Test
29
+ run: python -m unittest discover -s tests -v
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ my-ledger.json
@@ -0,0 +1,150 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ `holiplan` is a stateless MCP server that helps an assistant plan a family's year around school
8
+ holidays and a limited leave allowance. The server does the arithmetic (leave cost, budget,
9
+ carryover expiry, bridge days, overlaps, deadlines, .ics export); the assistant does the judgement
10
+ (destinations). Holiday data comes from the OpenHolidays API (CC BY 4.0, no key). Attribution is
11
+ required wherever that data is shown, which is why tool results carry a `source` field.
12
+
13
+ This is Release 1 of a two-release product (MCP server, then a web app on the same engine). The
14
+ ledger schema is the contract between them.
15
+
16
+ ## Commands
17
+
18
+ A venv already exists at `.venv` (Windows layout: `.venv/Scripts/python`).
19
+
20
+ ```bash
21
+ pip install -e ".[dev]" # install with pytest
22
+ python -m unittest discover -s tests # full suite, offline
23
+ python -m unittest tests.test_engine.EffectiveWindowTests # one class
24
+ python -m unittest tests.test_engine.BudgetTests.<test_name> # one test
25
+ python -m holiplan.server # run the server over stdio (or the `holiplan` script)
26
+ ```
27
+
28
+ Tests prepend `src/` to `sys.path`, so the engine tests run without installing the package. Tests
29
+ that import the server need `mcp` installed and skip themselves otherwise; schema validation tests
30
+ need `jsonschema` (a dependency of `mcp`). `pytest` also works. There is no linter or formatter
31
+ configured.
32
+
33
+ ## Architecture
34
+
35
+ Two layers with a hard boundary:
36
+
37
+ - **`src/holiplan/engine/`**: pure, offline, deterministic. `models.py` (dependency-free dataclasses,
38
+ `migrate`, and a `_build` helper every `from_dict` goes through); `leave.py` (`leave_cost`,
39
+ `year_budget`); `checks.py` (`validate_ledger`, `deadlines`, `effective_window`,
40
+ `describe_window`); `bridges.py` (`bridge_days`); `summary.py` (`summarise`); `ics.py` (RFC 5545
41
+ export). Public holidays are always **passed in**: a `set[str]` of full-day ISO dates, plus an
42
+ optional `half_holidays` set. The engine never does I/O.
43
+ - **`src/holiplan/holidays.py`**: the OpenHolidays client, with an in-process one-week cache. On a
44
+ failed fetch it serves the expired cache entry if there is one, otherwise raises
45
+ `HolidayDataUnavailable` with a message an assistant can relay; a 4xx becomes a `ValueError`
46
+ about the input. It also owns the data-shaped logic: `resolve_region`, `is_local`, `covers`,
47
+ `locate`, `is_half_day`, and `holiday_dates` (which filters by all of them).
48
+ - **`src/holiplan/server.py`**: the MCP surface. Each tool resolves its region, fetches what it
49
+ needs, parses the ledger into models, calls the engine and returns plain dicts. Shared helpers:
50
+ `_holiday_set` returns `(full, half)` holiday sets filtered by the profile's local-holiday
51
+ settings; `_padded` widens a year range by a month either side, because windows stretch over
52
+ holidays just past them (Christmas into January); `_school_windows` merges the per-school-type
53
+ repeats the data contains; `_described_windows` adds effective dates and sizes; `_validate`
54
+ backs `check_ledger` and `upsert_trip`, and falls back to date-only checks without a profile.
55
+
56
+ The server is built on **MCP SDK v2** (`mcp>=2,<3`): `from mcp.server.mcpserver import MCPServer`.
57
+ Do not use the v1 `FastMCP` import. Tools are registered with `@_tool`, not `@mcp.tool()`: SDK v2
58
+ shows the model an exception's text only for `ToolError`, and reports anything else as a bare
59
+ "Error executing tool X". `_tool` re-raises `ValueError` and `HolidayDataUnavailable` as
60
+ `ToolError`, so **errors meant for the user must be one of those two**; any other exception is
61
+ treated as a crash and its message stays on the server. Tool functions stay plain callables, so
62
+ tests call them directly (and see `ToolError`).
63
+
64
+ ### The ledger contract
65
+
66
+ The ledger is a JSON document owned by the user. The server stores nothing: every tool takes the
67
+ ledger as input, and mutating tools (`upsert_trip`) return a new ledger for the assistant to hand
68
+ back. The server instructions and every prompt tell the assistant to pass the ledger everywhere
69
+ (including the optional `ledger` of `calculate_leave_cost` and `find_bridge_days`) and hand back
70
+ the whole ledger after changes.
71
+
72
+ The shape is defined twice: `engine/models.py` and `src/holiplan/schemas/ledger.schema.json`
73
+ (profile and trip in `$defs`, also served on their own as `holiplan://schema/profile` and
74
+ `/trip`). `tests/test_contract.py` fails if their fields or choices drift apart, and validates
75
+ round-tripped, starter and example ledgers against the schema. `to_dict` writes `null` for unset
76
+ optional fields, so the schema's optional fields are nullable. Both refuse unknown fields.
77
+
78
+ **Changing the ledger shape:** a new optional field with a default needs no migration. Anything
79
+ that would make an older ledger read differently needs `SCHEMA_VERSION` bumped and a
80
+ `MIGRATIONS[old_version]` entry in `models.py`; `Ledger.from_dict` runs `migrate` first.
81
+ `examples/ledger.example.json` is the sample; `my-ledger.json` is gitignored.
82
+
83
+ ### OpenHolidays quirks the code relies on
84
+
85
+ - **Region codes** are OpenHolidays' own where they differ from ISO 3166-2 (Vienna `AT-WI`, not
86
+ `AT-9`), and an unknown code silently returns only nationwide data. `resolve_region` accepts
87
+ either form and raises on unknown ones. It reads `/Subdivisions`, so server tests patch
88
+ `holiplan.holidays.subdivisions` (see `StubbedHolidays` in `tests/test_server.py`).
89
+ - **`nationwide` is not "applies to the whole region"**: Epiphany is `nationwide: false` yet covers
90
+ all of DE-BY. `is_local` checks whether the `subdivisions` include the region or a parent.
91
+ - **`temporalScope: "HalfDay"`** marks half-day holidays (Zürich's Sechseläuten). They are kept out
92
+ of the full-day set and cost at most half a day of leave; they don't stretch windows.
93
+ - **School holidays repeat per school-type group** where types differ (Zürich: `CH-ZH-VS` primary,
94
+ `-MS`, `-BS`). Identical repeats are merged and their `groups` listed; no groups means every
95
+ school. There is no per-child school type in the profile yet.
96
+ - **Single-day school closures** exist as short school-holiday entries (All Souls' Day in Austria).
97
+ `describe_window` marks anything with `CLOSURE_MAX_SCHOOL_DAYS` (2) or fewer school days as a
98
+ closure; `get_holiday_windows` lists those separately. Checks treat closures like any window.
99
+
100
+ ### Domain rules that span files
101
+
102
+ - **Intent decides leave.** `Trip.consumes_leave` is true for intents `trip`/`obligation`, false for
103
+ `home`/`camp`, and `takes_leave` overrides it. Budgeting skips non-consuming and `cancelled` trips.
104
+ A `home` week never gets an "unbooked" deadline.
105
+ - **Leave cost**: weekends (per `DayPattern.workdays`, so part-time patterns work) and public
106
+ holidays on workdays are free. Personal `half_days` (`"MM-DD"`) and half-day public holidays
107
+ cost 0.5.
108
+ - **Local holidays**: profile `local_holidays` defaults to `auto`: `_home_locality` resolves `home`
109
+ against the subdivision tree (`locate`, names in any language, strictly below the region) and
110
+ keeps only local holidays that `covers` that town. A home the tree doesn't know (Munich) keeps
111
+ none; no home or bare coordinates keeps all. `include`/`exclude` override, and
112
+ `ignore_holidays` (`"MM-DD"`, for region-wide entries like Assumption Day that are really local)
113
+ applies on top.
114
+ - **Budgets are per calendar year.** `year_budget` clips each trip to the year, so a trip over New
115
+ Year costs each year only its own days. A trip that takes leave in a year with no `LeaveAccount`
116
+ gets a `no_leave_account` warning.
117
+ - **Carryover** is spent first, but only by trips ending on or before `carryover_expires`. Anything
118
+ left becomes `carryover_at_risk`. `validate_ledger` turns budget warnings into issues by string
119
+ prefix (`"Over budget"` means error `over_budget`, anything else is warning `carryover_at_risk`),
120
+ so changing warning text in `year_budget` changes issue classification.
121
+ - **`effective_window`** stretches an official school window outwards over adjacent weekends and
122
+ public holidays (Whit Monday before Bavaria's Pentecost window), capped at `_MAX_STRETCH` days.
123
+ It drives the `outside_school_window` warning, which applies only to intent `trip`.
124
+ - **Bridge days** are gaps of workdays between free runs; an option is one gap or several
125
+ neighbouring ones within `max_leave`, and must contain a public holiday on a workday. Options
126
+ overlap by design, ranked by days off per leave day.
127
+ - **Privacy**: children are identified by `alias`, birth year and interests only, never names.
128
+ - The server never books anything. Bookings (`todo`/`booked`, `book_by`, `cancel_by`) only drive
129
+ `list_deadlines` and the VTODOs in the .ics export. VTODO UIDs are hashes of the deadline's
130
+ content, so re-importing a calendar updates entries rather than duplicating them. Lines are folded
131
+ at 75 UTF-8 octets.
132
+
133
+ ### Tests
134
+
135
+ Expected figures in the golden tests are worked out by hand from the calendar, with the reasoning
136
+ in a comment. A failure means arithmetic a family relies on has broken, so fix the code, not the
137
+ expectation, unless the calendar reasoning itself was wrong.
138
+
139
+ - `test_engine.py`: the engine over real Bavaria 2027 public holidays and a realistic ledger.
140
+ - `test_holidays.py`: region resolution, local and half-day filtering, and the HTTP layer via
141
+ httpx's `MockTransport` (caching, outages, stale cache).
142
+ - `test_server.py`: the tools, with the three OpenHolidays calls patched (`StubbedHolidays`).
143
+ - `test_golden_dach.py`: Vienna and Zürich plans against real OpenHolidays responses recorded in
144
+ `tests/fixtures/` and replayed offline. If you re-record them, re-check every expectation.
145
+ - `test_models.py`: migrations and parse errors. `test_contract.py`: schema versus models,
146
+ resources, prompts and the tool list.
147
+ - `test_protocol.py`: the server as a subprocess over real MCP stdio (handshake, a scenario across
148
+ every tool, errors, resources, prompts), launched through `tests/fixture_server.py`, which swaps
149
+ OpenHolidays for a recorded fixture. About 25 seconds; it's the only slow file. Run the rest
150
+ with `python -m unittest tests.test_engine tests.test_server ...` when iterating.
holiplan-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 adekola
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.5
2
+ Name: holiplan
3
+ Version: 0.1.0
4
+ Summary: MCP server for planning a family's year around school holidays and a limited leave allowance
5
+ Project-URL: Homepage, https://github.com/adekola/holiplan
6
+ Project-URL: Issues, https://github.com/adekola/holiplan/issues
7
+ Project-URL: Privacy, https://github.com/adekola/holiplan/blob/main/PRIVACY.md
8
+ Author: adekola
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: annual leave,bridge days,family,mcp,model-context-protocol,school holidays
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Office/Business :: Scheduling
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: mcp<3,>=2
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # holiplan
25
+
26
+ An MCP server for planning a family's year around school holidays and a limited leave allowance.
27
+
28
+ <!-- mcp-name: io.github.adekola/holiplan -->
29
+
30
+ Assistants are good at suggesting destinations and bad at remembering that Whit Monday is a
31
+ public holiday, that five carried-over days expire in June, and that you already said no
32
+ drive over three and a half hours. This server does the parts that should be calculated;
33
+ the assistant does the parts that need judgement.
34
+
35
+ Holiday data comes from [OpenHolidays](https://openholidaysapi.org) (CC BY 4.0), which covers
36
+ public and school holidays for a growing list of countries, with no API key.
37
+
38
+ ## Status
39
+
40
+ Pre-release (0.1). Stateless: your ledger is a JSON file you keep, and the server stores nothing.
41
+ See [PRIVACY.md](https://github.com/adekola/holiplan/blob/main/PRIVACY.md) for exactly what goes where, and
42
+ [docs/examples.md](https://github.com/adekola/holiplan/blob/main/docs/examples.md) for what a conversation looks like.
43
+
44
+ ## Use it in Claude Desktop
45
+
46
+ Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then add holiplan to
47
+ `claude_desktop_config.json` and restart Claude:
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "holiplan": {
53
+ "command": "uvx",
54
+ "args": ["holiplan"]
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ `uvx` fetches holiplan from PyPI and runs it; there is nothing else to install. Without uv,
61
+ `pip install holiplan` and use `"command": "holiplan"` with no `args`.
62
+
63
+ Start with the **Set up my family profile** prompt, which builds a ledger with you one
64
+ question at a time, or copy [`examples/ledger.example.json`](https://github.com/adekola/holiplan/blob/main/examples/ledger.example.json) to `my-ledger.json` and edit it.
65
+ Tell Claude where the file is, and save the updated ledger whenever Claude hands it back.
66
+ A conversation then looks like:
67
+
68
+ > Here's my ledger. What do the 2027 school holidays look like, and what can I afford?
69
+
70
+ > Add two weeks on the Danish coast from 17 July.
71
+
72
+ > What needs booking next?
73
+
74
+ [docs/examples.md](https://github.com/adekola/holiplan/blob/main/docs/examples.md) has these conversations in full, with real figures.
75
+
76
+ ## Tools
77
+
78
+ | Tool | Purpose |
79
+ | --- | --- |
80
+ | `get_holiday_windows` | School holidays (official and effective dates), single-day closures, and public holidays marked local, half-day and observed |
81
+ | `calculate_leave_cost` | Leave days a given trip needs, with the holidays inside it listed |
82
+ | `find_bridge_days` | The leave days that buy the most time off, and whether the kids are off too |
83
+ | `get_year_budget` | Planned versus available leave, including carryover expiry |
84
+ | `upsert_trip` | Add or update a trip, then re-check the ledger |
85
+ | `check_ledger` | Overlaps, budget breaches, bad dates, trips outside school holidays |
86
+ | `list_deadlines` | Booking deadlines and cancel-by dates, soonest first |
87
+ | `summarise_plan` | The whole plan at a glance, for a brief to your partner |
88
+ | `export_ics` | The plan as a calendar file |
89
+
90
+ Resources: JSON schemas for the ledger, profile and trip (`holiplan://schema/...`), and a blank
91
+ starter ledger (`holiplan://ledger/starter`).
92
+
93
+ Prompts: *Set up my family profile*, *Plan my year*, *Plan this holiday window*,
94
+ *What needs doing next?*, *Make a brief for my partner*.
95
+
96
+ ## Design notes
97
+
98
+ - **The ledger is the source of truth.** Tools take it in and hand it back, so the plan
99
+ doesn't drift between conversations.
100
+ - **Intent decides leave.** A week with intent `home` or `camp` occupies a school holiday
101
+ window without costing leave. Override with `takes_leave`.
102
+ - **Carryover is spent first**, but only by trips ending before the expiry date, so
103
+ "these days expire in June" shows up as a warning while you can still act on it.
104
+ - **A trip over New Year** costs each year only its own days.
105
+ - **Local holidays follow your home town.** Some holidays cover only part of a region, like
106
+ Augsburg's Peace Festival in Bavaria. With the profile's `home` set to a town, only that
107
+ town's local holidays count as days off; without a home they all do. Set `local_holidays`
108
+ to `"include"` or `"exclude"` to override. `ignore_holidays` (`"MM-DD"`) covers what the
109
+ data can't tell apart, such as Assumption Day (`"08-15"`), which is only a holiday in
110
+ Catholic-majority Bavarian towns.
111
+ - **School windows include the free days around them.** The official Monday-to-Friday dates
112
+ are stretched over adjacent weekends and public holidays before trips are checked against them.
113
+ School holidays of two school days or fewer, like All Souls' Day in Austria, are listed
114
+ separately as closures.
115
+ - **Half-day holidays cost half a day.** Zürich's Sechseläuten afternoon is a half day off,
116
+ not a full one.
117
+ - **Regions accept ISO codes.** OpenHolidays uses its own codes where they differ (Vienna is
118
+ `AT-WI`, not `AT-9`); either works, and an unknown region is an error rather than a quietly
119
+ incomplete year.
120
+ - **Ledgers are versioned.** Older ledgers are migrated when read, a newer one is refused, and
121
+ a malformed one gets an error naming the trip and field at fault.
122
+ - **When OpenHolidays is down**, the server serves the last copy it fetched, or says plainly
123
+ that it can't check dates right now. It never guesses.
124
+ - **No real names.** Children are identified by alias and interests only.
125
+ - **The server never books anything.** It knows what needs booking and by when; you book it
126
+ and record the result.
127
+
128
+ ## Roadmap
129
+
130
+ - [ ] Drive-time filtering (OpenRouteService or self-hosted OSRM)
131
+ - [ ] Remote deployment over Streamable HTTP
132
+ - [ ] Publish to the MCP registry
133
+ - [ ] School holiday windows by school type, where the data supports it (Zürich splits
134
+ primary, secondary and vocational; windows already list the types they apply to)
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ git clone https://github.com/adekola/holiplan.git
140
+ cd holiplan
141
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
142
+ pip install -e ".[dev]"
143
+ python -m unittest discover -s tests # offline
144
+ ```
145
+
146
+ To run your working copy in Claude Desktop, point `command` at the venv's Python
147
+ (`.venv/bin/python`, or `.venv\Scripts\python.exe` on Windows) with
148
+ `"args": ["-m", "holiplan.server"]`.
149
+
150
+ ## Licence
151
+
152
+ MIT, see [LICENSE](https://github.com/adekola/holiplan/blob/main/LICENSE). Holiday data: [OpenHolidays](https://openholidaysapi.org), CC BY 4.0.
@@ -0,0 +1,55 @@
1
+ # Privacy
2
+
3
+ holiplan is built to know as little about your family as it can and to keep nothing.
4
+
5
+ This note covers the server as you install and run it yourself (stdio, for example in Claude
6
+ Desktop). There is no hosted holiplan endpoint yet; this note will be updated before one exists.
7
+
8
+ ## What holiplan stores
9
+
10
+ Nothing. The server has no database and writes no files. Your plan lives in a ledger file that
11
+ you keep. Each tool receives the ledger from your assistant, works on it in memory, and hands
12
+ the result back. When the server stops, nothing about you remains in it.
13
+
14
+ ## What the ledger contains
15
+
16
+ Only what you or your assistant put in it. The format is designed to need very little:
17
+
18
+ - **Children** are identified by an alias you choose ("older", "A"), and optionally a birth year
19
+ and interests. Never a name, photo or school.
20
+ - **Where you live** is a region code (such as `DE-BY`) and optionally a home town, used to work
21
+ out which local holidays apply to you.
22
+ - **Your plan**: trip dates, destinations, booking status and deadlines, and your leave
23
+ allowance.
24
+
25
+ ## Who sees what
26
+
27
+ | Recipient | What it receives |
28
+ | --- | --- |
29
+ | Your AI assistant (e.g. Claude) | The whole ledger, because it passes the ledger to the tools. Its provider's privacy terms apply to your conversations. |
30
+ | [OpenHolidays](https://openholidaysapi.org) | Only a country code, a region code, date ranges and a language, to fetch holiday dates. Never your ledger, home town, children or trips. |
31
+ | Anyone else | Nothing. holiplan calls no other service. |
32
+
33
+ Your home town is matched against OpenHolidays' public list of towns on your own machine; the
34
+ town itself is never sent anywhere.
35
+
36
+ ## Logs
37
+
38
+ The server writes logs to its standard error, which your assistant app may save (Claude Desktop
39
+ keeps them in its logs folder). They contain:
40
+
41
+ - the OpenHolidays requests made (country, region, dates), and
42
+ - when a tool call fails, the error message, which can name a trip id or quote a value that was
43
+ invalid, such as a malformed date.
44
+
45
+ They never contain the ledger itself. They stay on your machine.
46
+
47
+ ## Booking and payments
48
+
49
+ holiplan never books anything and never sees payment details or provider accounts. It only
50
+ tracks what you tell it needs booking and by when.
51
+
52
+ ## Holiday data
53
+
54
+ Holiday data comes from OpenHolidays under CC BY 4.0. It is fetched as needed and cached in
55
+ memory for up to a week.
@@ -0,0 +1,129 @@
1
+ # holiplan
2
+
3
+ An MCP server for planning a family's year around school holidays and a limited leave allowance.
4
+
5
+ <!-- mcp-name: io.github.adekola/holiplan -->
6
+
7
+ Assistants are good at suggesting destinations and bad at remembering that Whit Monday is a
8
+ public holiday, that five carried-over days expire in June, and that you already said no
9
+ drive over three and a half hours. This server does the parts that should be calculated;
10
+ the assistant does the parts that need judgement.
11
+
12
+ Holiday data comes from [OpenHolidays](https://openholidaysapi.org) (CC BY 4.0), which covers
13
+ public and school holidays for a growing list of countries, with no API key.
14
+
15
+ ## Status
16
+
17
+ Pre-release (0.1). Stateless: your ledger is a JSON file you keep, and the server stores nothing.
18
+ See [PRIVACY.md](https://github.com/adekola/holiplan/blob/main/PRIVACY.md) for exactly what goes where, and
19
+ [docs/examples.md](https://github.com/adekola/holiplan/blob/main/docs/examples.md) for what a conversation looks like.
20
+
21
+ ## Use it in Claude Desktop
22
+
23
+ Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then add holiplan to
24
+ `claude_desktop_config.json` and restart Claude:
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "holiplan": {
30
+ "command": "uvx",
31
+ "args": ["holiplan"]
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ `uvx` fetches holiplan from PyPI and runs it; there is nothing else to install. Without uv,
38
+ `pip install holiplan` and use `"command": "holiplan"` with no `args`.
39
+
40
+ Start with the **Set up my family profile** prompt, which builds a ledger with you one
41
+ question at a time, or copy [`examples/ledger.example.json`](https://github.com/adekola/holiplan/blob/main/examples/ledger.example.json) to `my-ledger.json` and edit it.
42
+ Tell Claude where the file is, and save the updated ledger whenever Claude hands it back.
43
+ A conversation then looks like:
44
+
45
+ > Here's my ledger. What do the 2027 school holidays look like, and what can I afford?
46
+
47
+ > Add two weeks on the Danish coast from 17 July.
48
+
49
+ > What needs booking next?
50
+
51
+ [docs/examples.md](https://github.com/adekola/holiplan/blob/main/docs/examples.md) has these conversations in full, with real figures.
52
+
53
+ ## Tools
54
+
55
+ | Tool | Purpose |
56
+ | --- | --- |
57
+ | `get_holiday_windows` | School holidays (official and effective dates), single-day closures, and public holidays marked local, half-day and observed |
58
+ | `calculate_leave_cost` | Leave days a given trip needs, with the holidays inside it listed |
59
+ | `find_bridge_days` | The leave days that buy the most time off, and whether the kids are off too |
60
+ | `get_year_budget` | Planned versus available leave, including carryover expiry |
61
+ | `upsert_trip` | Add or update a trip, then re-check the ledger |
62
+ | `check_ledger` | Overlaps, budget breaches, bad dates, trips outside school holidays |
63
+ | `list_deadlines` | Booking deadlines and cancel-by dates, soonest first |
64
+ | `summarise_plan` | The whole plan at a glance, for a brief to your partner |
65
+ | `export_ics` | The plan as a calendar file |
66
+
67
+ Resources: JSON schemas for the ledger, profile and trip (`holiplan://schema/...`), and a blank
68
+ starter ledger (`holiplan://ledger/starter`).
69
+
70
+ Prompts: *Set up my family profile*, *Plan my year*, *Plan this holiday window*,
71
+ *What needs doing next?*, *Make a brief for my partner*.
72
+
73
+ ## Design notes
74
+
75
+ - **The ledger is the source of truth.** Tools take it in and hand it back, so the plan
76
+ doesn't drift between conversations.
77
+ - **Intent decides leave.** A week with intent `home` or `camp` occupies a school holiday
78
+ window without costing leave. Override with `takes_leave`.
79
+ - **Carryover is spent first**, but only by trips ending before the expiry date, so
80
+ "these days expire in June" shows up as a warning while you can still act on it.
81
+ - **A trip over New Year** costs each year only its own days.
82
+ - **Local holidays follow your home town.** Some holidays cover only part of a region, like
83
+ Augsburg's Peace Festival in Bavaria. With the profile's `home` set to a town, only that
84
+ town's local holidays count as days off; without a home they all do. Set `local_holidays`
85
+ to `"include"` or `"exclude"` to override. `ignore_holidays` (`"MM-DD"`) covers what the
86
+ data can't tell apart, such as Assumption Day (`"08-15"`), which is only a holiday in
87
+ Catholic-majority Bavarian towns.
88
+ - **School windows include the free days around them.** The official Monday-to-Friday dates
89
+ are stretched over adjacent weekends and public holidays before trips are checked against them.
90
+ School holidays of two school days or fewer, like All Souls' Day in Austria, are listed
91
+ separately as closures.
92
+ - **Half-day holidays cost half a day.** Zürich's Sechseläuten afternoon is a half day off,
93
+ not a full one.
94
+ - **Regions accept ISO codes.** OpenHolidays uses its own codes where they differ (Vienna is
95
+ `AT-WI`, not `AT-9`); either works, and an unknown region is an error rather than a quietly
96
+ incomplete year.
97
+ - **Ledgers are versioned.** Older ledgers are migrated when read, a newer one is refused, and
98
+ a malformed one gets an error naming the trip and field at fault.
99
+ - **When OpenHolidays is down**, the server serves the last copy it fetched, or says plainly
100
+ that it can't check dates right now. It never guesses.
101
+ - **No real names.** Children are identified by alias and interests only.
102
+ - **The server never books anything.** It knows what needs booking and by when; you book it
103
+ and record the result.
104
+
105
+ ## Roadmap
106
+
107
+ - [ ] Drive-time filtering (OpenRouteService or self-hosted OSRM)
108
+ - [ ] Remote deployment over Streamable HTTP
109
+ - [ ] Publish to the MCP registry
110
+ - [ ] School holiday windows by school type, where the data supports it (Zürich splits
111
+ primary, secondary and vocational; windows already list the types they apply to)
112
+
113
+ ## Development
114
+
115
+ ```bash
116
+ git clone https://github.com/adekola/holiplan.git
117
+ cd holiplan
118
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
119
+ pip install -e ".[dev]"
120
+ python -m unittest discover -s tests # offline
121
+ ```
122
+
123
+ To run your working copy in Claude Desktop, point `command` at the venv's Python
124
+ (`.venv/bin/python`, or `.venv\Scripts\python.exe` on Windows) with
125
+ `"args": ["-m", "holiplan.server"]`.
126
+
127
+ ## Licence
128
+
129
+ MIT, see [LICENSE](https://github.com/adekola/holiplan/blob/main/LICENSE). Holiday data: [OpenHolidays](https://openholidaysapi.org), CC BY 4.0.