parcelapp-mcp 0.2.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.
- parcelapp_mcp-0.2.0/.dockerignore +20 -0
- parcelapp_mcp-0.2.0/.github/workflows/ci.yml +50 -0
- parcelapp_mcp-0.2.0/.github/workflows/publish.yml +156 -0
- parcelapp_mcp-0.2.0/.gitignore +7 -0
- parcelapp_mcp-0.2.0/CHANGELOG.md +42 -0
- parcelapp_mcp-0.2.0/CLAUDE.md +180 -0
- parcelapp_mcp-0.2.0/Dockerfile +72 -0
- parcelapp_mcp-0.2.0/LICENSE +21 -0
- parcelapp_mcp-0.2.0/PKG-INFO +342 -0
- parcelapp_mcp-0.2.0/README.md +310 -0
- parcelapp_mcp-0.2.0/cliff.toml +60 -0
- parcelapp_mcp-0.2.0/pyproject.toml +121 -0
- parcelapp_mcp-0.2.0/scripts/http_test.py +65 -0
- parcelapp_mcp-0.2.0/scripts/smoke_test.py +33 -0
- parcelapp_mcp-0.2.0/scripts/stdio_test.py +75 -0
- parcelapp_mcp-0.2.0/src/parcel_mcp/__init__.py +12 -0
- parcelapp_mcp-0.2.0/src/parcel_mcp/client.py +312 -0
- parcelapp_mcp-0.2.0/src/parcel_mcp/py.typed +0 -0
- parcelapp_mcp-0.2.0/src/parcel_mcp/server.py +486 -0
- parcelapp_mcp-0.2.0/tests/__init__.py +1 -0
- parcelapp_mcp-0.2.0/tests/conftest.py +120 -0
- parcelapp_mcp-0.2.0/tests/test_client.py +255 -0
- parcelapp_mcp-0.2.0/tests/test_tools.py +685 -0
- parcelapp_mcp-0.2.0/uv.lock +1395 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Allowlist: exclude everything, then admit only what the build needs.
|
|
2
|
+
#
|
|
3
|
+
# Chosen over a blocklist because the set is five entries and stable, while
|
|
4
|
+
# the working tree accumulates things that must never reach a build context:
|
|
5
|
+
# .venv/, .omc/ state, .agent.lock, editor droppings. A blocklist protects
|
|
6
|
+
# only against what someone remembered to list.
|
|
7
|
+
#
|
|
8
|
+
# Adding a source directory means adding its `!` line below.
|
|
9
|
+
|
|
10
|
+
*
|
|
11
|
+
|
|
12
|
+
!pyproject.toml
|
|
13
|
+
!uv.lock
|
|
14
|
+
!README.md
|
|
15
|
+
!LICENSE
|
|
16
|
+
!src/
|
|
17
|
+
|
|
18
|
+
# Re-exclude what would otherwise ride along inside src/.
|
|
19
|
+
**/__pycache__/
|
|
20
|
+
**/*.py[co]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
# The test suite mocks the upstream API and needs no credentials: PARCEL_TOKEN
|
|
9
|
+
# is set per-test by a fixture. Nothing here ever reaches api.parcel.app, which
|
|
10
|
+
# matters because add_delivery is capped at 20 requests per day upstream.
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
lint:
|
|
14
|
+
name: Lint and type-check
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v7
|
|
18
|
+
with:
|
|
19
|
+
# hatch-vcs reads the version from the nearest tag, and uv sync
|
|
20
|
+
# builds the project. A shallow clone has no tags and yields a
|
|
21
|
+
# meaningless 0.1.devN.
|
|
22
|
+
fetch-depth: 0
|
|
23
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
24
|
+
with:
|
|
25
|
+
enable-cache: true
|
|
26
|
+
- run: uv sync --locked
|
|
27
|
+
- run: uv run ruff check
|
|
28
|
+
- run: uv run ruff format --check
|
|
29
|
+
- run: uv run mypy
|
|
30
|
+
|
|
31
|
+
test:
|
|
32
|
+
name: Tests (Python ${{ matrix.python-version }})
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
strategy:
|
|
35
|
+
fail-fast: false
|
|
36
|
+
matrix:
|
|
37
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v7
|
|
40
|
+
with:
|
|
41
|
+
# hatch-vcs reads the version from the nearest tag, and uv sync
|
|
42
|
+
# builds the project. A shallow clone has no tags and yields a
|
|
43
|
+
# meaningless 0.1.devN.
|
|
44
|
+
fetch-depth: 0
|
|
45
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
46
|
+
with:
|
|
47
|
+
enable-cache: true
|
|
48
|
+
python-version: ${{ matrix.python-version }}
|
|
49
|
+
- run: uv sync --locked
|
|
50
|
+
- run: uv run pytest
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
# One tag drives the whole release: PyPI, then the GitHub release, then the
|
|
8
|
+
# changelog commit. It is deliberately not split across two workflows, because
|
|
9
|
+
# a release created with the GITHUB_TOKEN does not trigger anything else --
|
|
10
|
+
# GitHub suppresses that cascade on purpose -- so a `release: published` hook
|
|
11
|
+
# would simply never fire.
|
|
12
|
+
#
|
|
13
|
+
# Publishing is irreversible: PyPI never lets a version number be reused, even
|
|
14
|
+
# after a yank. So the build job runs the full check suite first, and only a
|
|
15
|
+
# later job gets an OIDC token.
|
|
16
|
+
|
|
17
|
+
permissions: {}
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
build:
|
|
21
|
+
name: Build and verify
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
permissions:
|
|
24
|
+
contents: read # the workflow-level default is nothing, and checkout needs this
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v7
|
|
27
|
+
with:
|
|
28
|
+
# hatch-vcs reads the version from the nearest tag and git-cliff walks
|
|
29
|
+
# the history for the notes. A shallow checkout has neither, and would
|
|
30
|
+
# quietly produce a dev version instead of failing.
|
|
31
|
+
fetch-depth: 0
|
|
32
|
+
|
|
33
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
34
|
+
with:
|
|
35
|
+
enable-cache: true
|
|
36
|
+
|
|
37
|
+
- run: uv sync --locked
|
|
38
|
+
- run: uv run ruff check
|
|
39
|
+
- run: uv run ruff format --check
|
|
40
|
+
- run: uv run mypy
|
|
41
|
+
- run: uv run pytest
|
|
42
|
+
|
|
43
|
+
- name: Build the sdist and the wheel
|
|
44
|
+
run: uv build
|
|
45
|
+
|
|
46
|
+
# With hatch-vcs the version is derived from the tag, so this normally
|
|
47
|
+
# agrees by construction. It stays because the ways it can disagree are
|
|
48
|
+
# exactly the ways hatch-vcs fails quietly: a shallow fetch, or a tag that
|
|
49
|
+
# is not on the commit being built, both of which yield a .devN version.
|
|
50
|
+
- name: Check the release tag against the built version
|
|
51
|
+
env:
|
|
52
|
+
TAG: ${{ github.ref_name }}
|
|
53
|
+
run: |
|
|
54
|
+
version="${TAG#v}"
|
|
55
|
+
if [ ! -f "dist/parcelapp_mcp-${version}.tar.gz" ]; then
|
|
56
|
+
echo "Tag ${TAG} implies version ${version}, but uv build produced:" >&2
|
|
57
|
+
ls -1 dist >&2
|
|
58
|
+
exit 1
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
- name: Check the metadata renders on PyPI
|
|
62
|
+
run: uvx twine check --strict dist/*
|
|
63
|
+
|
|
64
|
+
# The same cliff.toml that generates CHANGELOG.md, so the release page and
|
|
65
|
+
# the file cannot drift. The leading "## <version> - <date>" heading is
|
|
66
|
+
# dropped: the release is already titled with its tag.
|
|
67
|
+
- name: Generate the release notes
|
|
68
|
+
run: |
|
|
69
|
+
# sed, in order: drop the leading "## <version> - <date>" heading,
|
|
70
|
+
# then drop the blank lines it leaves behind at the top.
|
|
71
|
+
uvx git-cliff --latest --strip all \
|
|
72
|
+
| sed -e '1{/^## /d;}' -e '/./,$!d' > release-notes.md
|
|
73
|
+
cat release-notes.md
|
|
74
|
+
|
|
75
|
+
- uses: actions/upload-artifact@v7
|
|
76
|
+
with:
|
|
77
|
+
name: release
|
|
78
|
+
path: |
|
|
79
|
+
dist/
|
|
80
|
+
release-notes.md
|
|
81
|
+
if-no-files-found: error
|
|
82
|
+
|
|
83
|
+
pypi:
|
|
84
|
+
name: Publish to PyPI
|
|
85
|
+
needs: build
|
|
86
|
+
runs-on: ubuntu-latest
|
|
87
|
+
# Configured at https://pypi.org/manage/project/parcelapp-mcp/settings/publishing/
|
|
88
|
+
# as a GitHub publisher: obeone/parcelapp-mcp, workflow publish.yml,
|
|
89
|
+
# environment pypi. No API token lives in this repository.
|
|
90
|
+
environment:
|
|
91
|
+
name: pypi
|
|
92
|
+
url: https://pypi.org/p/parcelapp-mcp
|
|
93
|
+
permissions:
|
|
94
|
+
id-token: write # mints the short-lived OIDC token Trusted Publishing exchanges
|
|
95
|
+
steps:
|
|
96
|
+
- uses: actions/download-artifact@v8
|
|
97
|
+
with:
|
|
98
|
+
name: release
|
|
99
|
+
- uses: pypa/gh-action-pypi-publish@v1.14.2
|
|
100
|
+
|
|
101
|
+
release:
|
|
102
|
+
name: GitHub release and changelog
|
|
103
|
+
# Last on purpose: a release must not announce a package that failed to
|
|
104
|
+
# upload. The reverse order leaves a dangling release; this order at worst
|
|
105
|
+
# leaves a published package with no release notes, which is fixable.
|
|
106
|
+
needs: pypi
|
|
107
|
+
runs-on: ubuntu-latest
|
|
108
|
+
permissions:
|
|
109
|
+
contents: write # creates the release and pushes the changelog commit
|
|
110
|
+
steps:
|
|
111
|
+
- uses: actions/checkout@v7
|
|
112
|
+
with:
|
|
113
|
+
# main, not the tag: the changelog commit belongs on the branch. The
|
|
114
|
+
# full history still gives git-cliff the tag it needs.
|
|
115
|
+
ref: main
|
|
116
|
+
fetch-depth: 0
|
|
117
|
+
|
|
118
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
119
|
+
|
|
120
|
+
- uses: actions/download-artifact@v8
|
|
121
|
+
with:
|
|
122
|
+
name: release
|
|
123
|
+
# Outside the checkout: the release notes are a build artifact, not
|
|
124
|
+
# something that should show up as an untracked file in the commit
|
|
125
|
+
# this job is about to make.
|
|
126
|
+
path: artifacts
|
|
127
|
+
|
|
128
|
+
- name: Create the GitHub release
|
|
129
|
+
env:
|
|
130
|
+
GH_TOKEN: ${{ github.token }}
|
|
131
|
+
TAG: ${{ github.ref_name }}
|
|
132
|
+
run: |
|
|
133
|
+
gh release create "$TAG" \
|
|
134
|
+
--title "$TAG" \
|
|
135
|
+
--notes-file artifacts/release-notes.md \
|
|
136
|
+
artifacts/dist/*
|
|
137
|
+
|
|
138
|
+
# Regenerated rather than appended, so a reworded history stays reflected.
|
|
139
|
+
# The commit is a chore(release), which cliff.toml skips, so it never shows
|
|
140
|
+
# up in the next changelog.
|
|
141
|
+
- name: Update CHANGELOG.md on main
|
|
142
|
+
env:
|
|
143
|
+
TAG: ${{ github.ref_name }}
|
|
144
|
+
run: |
|
|
145
|
+
uvx git-cliff -o CHANGELOG.md
|
|
146
|
+
if git diff --quiet -- CHANGELOG.md; then
|
|
147
|
+
echo "CHANGELOG.md already up to date."
|
|
148
|
+
exit 0
|
|
149
|
+
fi
|
|
150
|
+
git config user.name "github-actions[bot]"
|
|
151
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
152
|
+
git add CHANGELOG.md
|
|
153
|
+
git commit -m "chore(release): update the changelog for ${TAG}"
|
|
154
|
+
# main may have moved while the package was uploading.
|
|
155
|
+
git pull --rebase --autostash
|
|
156
|
+
git push
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Generated from the Conventional Commits in the git history. Do not edit by
|
|
4
|
+
hand: run `uvx git-cliff -o CHANGELOG.md` instead.
|
|
5
|
+
|
|
6
|
+
## Unreleased
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- Per-request API key, rate-limit budget, filtering and a resource
|
|
11
|
+
- Containerise the HTTP transport
|
|
12
|
+
|
|
13
|
+
### Bug fixes
|
|
14
|
+
|
|
15
|
+
- Pin setup-uv to an existing ref
|
|
16
|
+
|
|
17
|
+
### Refactoring
|
|
18
|
+
|
|
19
|
+
- Adopt src layout, rename to parcelapp-mcp, move live scripts
|
|
20
|
+
- Split the HTTP client out of the server module
|
|
21
|
+
|
|
22
|
+
### Documentation
|
|
23
|
+
|
|
24
|
+
- Add CLAUDE.md with architecture and rate-limit invariants
|
|
25
|
+
- Add the MIT licence and rewrite the README for publication
|
|
26
|
+
- Rewrite the README for the two transports
|
|
27
|
+
- Record the HTTP-transport invariants in CLAUDE.md
|
|
28
|
+
|
|
29
|
+
### Tests
|
|
30
|
+
|
|
31
|
+
- Add a pytest suite mocking the upstream API with respx
|
|
32
|
+
|
|
33
|
+
### Build and CI
|
|
34
|
+
|
|
35
|
+
- Publish to PyPI on a released tag
|
|
36
|
+
|
|
37
|
+
### Chores
|
|
38
|
+
|
|
39
|
+
- Add ruff, strict mypy and a CI workflow
|
|
40
|
+
- Untrack the design brief
|
|
41
|
+
- Fill in the package, image and repository metadata
|
|
42
|
+
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
An MCP server wrapping the [Parcel](https://parcelapp.net) delivery-tracking API,
|
|
8
|
+
which premium users of the macOS/iOS app get access to. Two upstream endpoints plus a
|
|
9
|
+
public carrier-code list, exposed as three MCP tools and one resource template.
|
|
10
|
+
|
|
11
|
+
It serves two transports. stdio reads the key from the environment. HTTP takes it
|
|
12
|
+
per request, which turns process-global state into a leak and drives most of the
|
|
13
|
+
design below.
|
|
14
|
+
|
|
15
|
+
The upstream API is documented only by two help pages:
|
|
16
|
+
|
|
17
|
+
- <https://parcelapp.net/help/api-view-deliveries.html>
|
|
18
|
+
- <https://parcelapp.net/help/api-add-delivery.html>
|
|
19
|
+
|
|
20
|
+
Anything beyond those pages is undocumented. When behaviour is unclear, handle it
|
|
21
|
+
defensively and note the uncertainty in a comment rather than inventing a contract.
|
|
22
|
+
|
|
23
|
+
`PROMPT.md` holds the original project brief (restructuring roadmap and feature
|
|
24
|
+
backlog). Read it before starting work that touches project layout or scope.
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
The project uses `uv`. The API key comes from `envchain parcel` locally.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv sync # install deps and the dev group
|
|
32
|
+
uv run pytest # 80 tests, no network
|
|
33
|
+
uv run pytest tests/test_tools.py::test_add_delivery_demands_a_postcode_without_spending_a_request
|
|
34
|
+
uv run ruff check && uv run ruff format # lint, then format
|
|
35
|
+
uv run mypy # strict, and currently clean
|
|
36
|
+
uvx git-cliff --unreleased # preview the next changelog section
|
|
37
|
+
envchain parcel uv run parcelapp-mcp # run the server over stdio
|
|
38
|
+
envchain parcel uv run scripts/smoke_test.py # live, read-only check (in-process)
|
|
39
|
+
envchain parcel uv run scripts/stdio_test.py # live, end-to-end MCP client over stdio
|
|
40
|
+
envchain parcel uv run scripts/http_test.py # live, against a running HTTP server
|
|
41
|
+
docker build --build-arg VERSION=$(git describe --tags --abbrev=0 | sed s/^v//) \
|
|
42
|
+
-t parcelapp-mcp . && docker run -p 8000:8000 parcelapp-mcp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`uv run pytest` is the loop to work in: it never touches the network, so it costs
|
|
46
|
+
nothing from either rate-limit budget. The two `scripts/` entries do hit the real API
|
|
47
|
+
and are manual checks, not part of the suite.
|
|
48
|
+
|
|
49
|
+
The suite mocks the upstream with `respx`. Every test runs under `@respx.mock`, so an
|
|
50
|
+
unmocked request fails the test rather than escaping to the real API.
|
|
51
|
+
|
|
52
|
+
CI (`.github/workflows/ci.yml`) runs those same three commands: lint and types once,
|
|
53
|
+
tests across 3.10 to 3.13. It uses `uv sync --locked`, so a dependency change means
|
|
54
|
+
committing the refreshed `uv.lock` alongside it.
|
|
55
|
+
|
|
56
|
+
## Releasing
|
|
57
|
+
|
|
58
|
+
A tag is the whole trigger. `git tag v0.3.0 && git push --tags` runs
|
|
59
|
+
`.github/workflows/publish.yml`, which checks, builds, uploads to PyPI through
|
|
60
|
+
Trusted Publishing (no API token in the repository), creates the GitHub release with
|
|
61
|
+
generated notes, and commits the refreshed `CHANGELOG.md` back to `main`.
|
|
62
|
+
|
|
63
|
+
Nothing is versioned by hand. `hatch-vcs` derives the version from the nearest git
|
|
64
|
+
tag, which has three consequences worth knowing before debugging a wrong number:
|
|
65
|
+
|
|
66
|
+
- **Every checkout that builds this package needs `fetch-depth: 0`.** A shallow clone
|
|
67
|
+
has no tags, so the build quietly produces a `0.1.devN` version instead of failing.
|
|
68
|
+
The build job's tag check exists to catch exactly that.
|
|
69
|
+
- **A build with no `.git` at all fails outright.** `.dockerignore` keeps `.git` out of
|
|
70
|
+
the build context on purpose, so the Dockerfile passes the version in through
|
|
71
|
+
`--build-arg VERSION=…`, which it forwards as `SETUPTOOLS_SCM_PRETEND_VERSION`. A
|
|
72
|
+
`docker build` without that argument reports `0.0.0` rather than lying. Beware
|
|
73
|
+
the warm uv cache mount: it can hand back a wheel built under a previous
|
|
74
|
+
`VERSION`, so pass `--no-cache` when the number in the image actually matters.
|
|
75
|
+
- **A dirty working tree taints the version** with a `.dYYYYMMDD` suffix. Harmless
|
|
76
|
+
locally, but it means a release must be built from a clean checkout.
|
|
77
|
+
|
|
78
|
+
The changelog is generated from the Conventional Commits by `git-cliff`, configured in
|
|
79
|
+
`cliff.toml`. The same config produces `CHANGELOG.md` and the body of each GitHub
|
|
80
|
+
release, so the two cannot drift. Preview it locally with `uvx git-cliff --unreleased`.
|
|
81
|
+
|
|
82
|
+
The workflow is deliberately one file rather than a `release.yml` feeding a
|
|
83
|
+
`publish.yml`: GitHub suppresses the event cascade from anything the `GITHUB_TOKEN`
|
|
84
|
+
creates, so a release made by CI would never fire a `release: published` trigger.
|
|
85
|
+
|
|
86
|
+
## Rate limits drive everything
|
|
87
|
+
|
|
88
|
+
| Endpoint | Limit |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `deliveries/` | 20 per hour |
|
|
91
|
+
| `add-delivery/` | 20 per **day**, failed attempts included |
|
|
92
|
+
| `supported_carriers.json` | none |
|
|
93
|
+
|
|
94
|
+
Two consequences the code depends on:
|
|
95
|
+
|
|
96
|
+
- **`add_delivery` validates locally before touching the network.** It checks the
|
|
97
|
+
carrier code against the cached carrier list and enforces the carrier's
|
|
98
|
+
`extra_required` field (postcode or email) *before* issuing the POST. A
|
|
99
|
+
preventable error must never consume one of the 20 daily requests.
|
|
100
|
+
- **`add_delivery` must not be called live during development.** Test it against a
|
|
101
|
+
mock. `smoke_test.py` deliberately never touches it; `stdio_test.py` only exercises
|
|
102
|
+
the local guard rails, which fail before any request is sent.
|
|
103
|
+
|
|
104
|
+
## Architecture
|
|
105
|
+
|
|
106
|
+
Two modules, split along one line: `client.py` knows nothing about MCP, `server.py`
|
|
107
|
+
knows nothing about HTTP. Resist adding a third for three tools.
|
|
108
|
+
|
|
109
|
+
`src/parcel_mcp/client.py` — the network:
|
|
110
|
+
|
|
111
|
+
- `request()` is the single HTTP chokepoint. It injects the `api-key` header,
|
|
112
|
+
maps 401 and 429 to explicit messages, and unwraps the upstream
|
|
113
|
+
`{"success": bool, "error_message": str}` envelope. Every upstream failure mode is
|
|
114
|
+
normalised here, so tools never inspect status codes themselves.
|
|
115
|
+
- `_cache` is a module-level `dict[str, tuple[float, Any]]`, with per-call TTLs via
|
|
116
|
+
`cached()` / `store()` and eviction via `clear_cache()`. Deliveries: 180 s (upstream
|
|
117
|
+
serves a cached view anyway, so this costs no freshness and protects the hourly
|
|
118
|
+
budget). Carriers: 24 h. It is process-global state, so tests must call
|
|
119
|
+
`clear_cache()` between cases.
|
|
120
|
+
- `_requests` holds the rate-limit counters, per bucket and per caller, pruned on
|
|
121
|
+
read. `clear_rate_limits()` resets them for tests.
|
|
122
|
+
- `ParcelError` lives here, next to what raises it, though it reaches into the SDK.
|
|
123
|
+
See the invariant below.
|
|
124
|
+
|
|
125
|
+
`src/parcel_mcp/server.py` — the MCP surface:
|
|
126
|
+
|
|
127
|
+
- The `MCPServer` instance, the three tools, the resource template, the argparse
|
|
128
|
+
transport selection, `main()`.
|
|
129
|
+
- `resolve_api_key(ctx)` decides where the key comes from: an `X-Parcel-Token` or
|
|
130
|
+
`Authorization: Bearer` header first, the environment as fallback. That fallback
|
|
131
|
+
is what lets one code path serve both transports.
|
|
132
|
+
- The two translation tables, `STATUS_CODES` and `EXTRA_REQUIRED`, exist because the
|
|
133
|
+
upstream API speaks in integers. Resolving them, along with carrier codes to names,
|
|
134
|
+
is the entire value this server adds. Do not strip it in favour of raw passthrough.
|
|
135
|
+
- `add_delivery`'s local validation stays here, beside the error messages it produces.
|
|
136
|
+
|
|
137
|
+
### Non-obvious invariants
|
|
138
|
+
|
|
139
|
+
- **`ParcelError` subclasses the SDK's `ToolError`.** If it does not, mcp 2.x
|
|
140
|
+
replaces the message with a generic "Error executing tool" and the model learns
|
|
141
|
+
nothing. Do not reparent it to `Exception`.
|
|
142
|
+
- **mcp 2.x API: `MCPServer`, not `FastMCP`.** The v1 spelling is gone. Do not
|
|
143
|
+
reintroduce it from memory; check the installed version (`mcp` 2.1.x) when unsure.
|
|
144
|
+
- **Docstrings are the tool contracts.** They are the text the model actually reads.
|
|
145
|
+
Keep them describing behaviour, arguments and rate limits, not implementation.
|
|
146
|
+
- **Errors name the fix.** "Bpost requires a postcode; pass `postcode`" beats
|
|
147
|
+
"invalid request". Any new error path follows that shape.
|
|
148
|
+
- `carrier_name()` swallows `ParcelError` and falls back to the raw code, so a
|
|
149
|
+
carrier-list fetch failure degrades `list_deliveries` instead of breaking it.
|
|
150
|
+
- **`@mcp.tool()` returns the function unchanged.** It registers and hands back `fn`,
|
|
151
|
+
so tests and scripts can call `list_deliveries(...)` directly without going through
|
|
152
|
+
`mcp.call_tool`.
|
|
153
|
+
- **`ToolAnnotations` fields are snake_case in Python.** `read_only_hint`, not
|
|
154
|
+
`readOnlyHint`. The camelCase spelling is the wire alias; pydantic accepts it, but
|
|
155
|
+
mypy cannot check it. The serialised JSON is identical either way.
|
|
156
|
+
- **Everything cached or counted is keyed by `key_fingerprint(key)`.** Over HTTP one
|
|
157
|
+
process serves several people; an unpartitioned cache would hand one caller's
|
|
158
|
+
parcels to another. The carrier catalogue is the deliberate exception, being
|
|
159
|
+
public. Never introduce a cache key without the fingerprint in it.
|
|
160
|
+
- **`ctx: Context | None = None`, never a bare `Context`.** The SDK unwraps the union
|
|
161
|
+
and still injects it, while the default keeps the tools directly callable from the
|
|
162
|
+
tests and scripts. Verified, not assumed.
|
|
163
|
+
- **A static resource cannot receive a `Context`.** The SDK refuses it outright, which
|
|
164
|
+
is why `parcel://deliveries/{filter_mode}` is a URI template: only templated
|
|
165
|
+
resources get a context, and without one a resource cannot read a per-request key.
|
|
166
|
+
- **The client factory is `streamable_http_client`, and headers go through an
|
|
167
|
+
`httpx2.AsyncClient` passed as `http_client`.** Not `streamablehttp_client`, and not
|
|
168
|
+
a `headers=` argument; both are v1 spellings that no longer exist.
|
|
169
|
+
|
|
170
|
+
## Auth
|
|
171
|
+
|
|
172
|
+
`PARCEL_TOKEN`, falling back to `PARCEL_API_KEY`, sent as the `api-key` header. Keys
|
|
173
|
+
are generated at <https://web.parcelapp.net>. Never put a realistic-looking key in a
|
|
174
|
+
test fixture.
|
|
175
|
+
|
|
176
|
+
## Dependencies
|
|
177
|
+
|
|
178
|
+
Standard library plus `httpx` and `mcp`. Justify any third dependency before adding
|
|
179
|
+
it; `respx` or `httpx.MockTransport` for the future test suite is the expected
|
|
180
|
+
exception.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
|
|
3
|
+
# Serves the streamable-http transport. No credential is baked in or expected
|
|
4
|
+
# at runtime: each request carries its own Parcel key in an X-Parcel-Token or
|
|
5
|
+
# Authorization: Bearer header, so one container can serve several people.
|
|
6
|
+
|
|
7
|
+
FROM python:3.13-slim
|
|
8
|
+
|
|
9
|
+
# uv is pinned like any other dependency.
|
|
10
|
+
COPY --from=ghcr.io/astral-sh/uv:0.12.9 /uv /uvx /bin/
|
|
11
|
+
|
|
12
|
+
WORKDIR /app
|
|
13
|
+
|
|
14
|
+
# Created first: a --chown naming a user this stage does not know yet is
|
|
15
|
+
# silently dropped, and the files land as root.
|
|
16
|
+
RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app app
|
|
17
|
+
|
|
18
|
+
# Dependencies as root, in their own cached layer. /app/.venv stays root-owned
|
|
19
|
+
# on purpose: the app runs it but cannot rewrite it.
|
|
20
|
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
21
|
+
--mount=type=bind,source=uv.lock,target=uv.lock \
|
|
22
|
+
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
|
23
|
+
uv sync --locked --no-install-project --no-dev
|
|
24
|
+
|
|
25
|
+
# The package version comes from the nearest git tag, but .dockerignore keeps
|
|
26
|
+
# .git out of the build context on purpose, so hatchling cannot read it here.
|
|
27
|
+
# This ARG is the only way in, and a build without it honestly reports 0.0.0
|
|
28
|
+
# rather than a stale hardcoded number:
|
|
29
|
+
# docker build --build-arg VERSION=$(git describe --tags --abbrev=0 | sed s/^v//) .
|
|
30
|
+
# It feeds both the install below and the OCI label at the end of this stage.
|
|
31
|
+
ARG VERSION=0.0.0
|
|
32
|
+
|
|
33
|
+
COPY --chown=app:app . .
|
|
34
|
+
RUN --mount=type=cache,target=/root/.cache/uv \
|
|
35
|
+
SETUPTOOLS_SCM_PRETEND_VERSION=${VERSION} uv sync --locked --no-dev
|
|
36
|
+
|
|
37
|
+
# Hand over the workdir, not the dependency tree, then drop privileges last.
|
|
38
|
+
RUN chown app:app /app
|
|
39
|
+
USER app
|
|
40
|
+
|
|
41
|
+
ENV PATH="/app/.venv/bin:$PATH" \
|
|
42
|
+
PARCEL_TRANSPORT=streamable-http \
|
|
43
|
+
PARCEL_HOST=0.0.0.0 \
|
|
44
|
+
PARCEL_PORT=8000 \
|
|
45
|
+
PARCEL_PATH=/mcp
|
|
46
|
+
|
|
47
|
+
EXPOSE 8000
|
|
48
|
+
|
|
49
|
+
LABEL org.opencontainers.image.title="parcelapp-mcp" \
|
|
50
|
+
org.opencontainers.image.description="MCP server for the Parcel delivery tracking app: read and add deliveries over stdio or HTTP" \
|
|
51
|
+
org.opencontainers.image.source="https://github.com/obeone/parcelapp-mcp" \
|
|
52
|
+
org.opencontainers.image.documentation="https://github.com/obeone/parcelapp-mcp#readme" \
|
|
53
|
+
org.opencontainers.image.url="https://github.com/obeone/parcelapp-mcp" \
|
|
54
|
+
org.opencontainers.image.authors="Grégoire Compagnon <obeone@obeone.org>" \
|
|
55
|
+
org.opencontainers.image.licenses="MIT" \
|
|
56
|
+
org.opencontainers.image.version="${VERSION}"
|
|
57
|
+
|
|
58
|
+
# There is no /health endpoint, and a GET on /mcp opens an SSE stream that
|
|
59
|
+
# never returns, so neither would work. This performs a real MCP initialize
|
|
60
|
+
# handshake instead: it proves the session manager and the tool registry are
|
|
61
|
+
# alive, not merely that a socket is open. urlopen raises on a non-2xx, and the
|
|
62
|
+
# assert catches a 200 that is not a protocol response.
|
|
63
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
64
|
+
CMD python -c "import json,urllib.request as u; \
|
|
65
|
+
r=u.Request('http://127.0.0.1:8000/mcp', \
|
|
66
|
+
data=json.dumps({'jsonrpc':'2.0','id':1,'method':'initialize','params':{'protocolVersion':'2025-06-18','capabilities':{},'clientInfo':{'name':'healthcheck','version':'0'}}}).encode(), \
|
|
67
|
+
headers={'Content-Type':'application/json','Accept':'application/json, text/event-stream'}); \
|
|
68
|
+
assert b'\"result\"' in u.urlopen(r, timeout=4).read()" || exit 1
|
|
69
|
+
|
|
70
|
+
# Exec form, so the server is PID 1 and receives SIGTERM directly. uvicorn
|
|
71
|
+
# installs its own handler and drains, so no init shim is needed.
|
|
72
|
+
CMD ["parcelapp-mcp"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Grégoire Compagnon
|
|
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.
|