handle-mcp 1.5.1__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.
Files changed (125) hide show
  1. handle_mcp-1.5.1/.github/FUNDING.yml +2 -0
  2. handle_mcp-1.5.1/.github/badges/make.py +40 -0
  3. handle_mcp-1.5.1/.github/dependabot.yml +10 -0
  4. handle_mcp-1.5.1/.github/workflows/badges.yml +72 -0
  5. handle_mcp-1.5.1/.github/workflows/ci.yml +21 -0
  6. handle_mcp-1.5.1/.github/workflows/claude-review.yml +67 -0
  7. handle_mcp-1.5.1/.github/workflows/claude.yml +38 -0
  8. handle_mcp-1.5.1/.github/workflows/release.yml +54 -0
  9. handle_mcp-1.5.1/.gitignore +21 -0
  10. handle_mcp-1.5.1/CLAUDE.md +117 -0
  11. handle_mcp-1.5.1/CONTRIBUTING.md +36 -0
  12. handle_mcp-1.5.1/CREDITS.md +8 -0
  13. handle_mcp-1.5.1/LICENSE +21 -0
  14. handle_mcp-1.5.1/PKG-INFO +126 -0
  15. handle_mcp-1.5.1/README.md +110 -0
  16. handle_mcp-1.5.1/SECURITY.md +11 -0
  17. handle_mcp-1.5.1/docs/adr/0001-the-human-always-pays.md +12 -0
  18. handle_mcp-1.5.1/docs/adr/0002-money-has-two-decimals.md +13 -0
  19. handle_mcp-1.5.1/docs/adr/0003-save-the-session-not-the-password.md +20 -0
  20. handle_mcp-1.5.1/docs/adr/0004-windows-has-no-file-modes.md +13 -0
  21. handle_mcp-1.5.1/docs/adr/0005-anonymous-usage-counts.md +20 -0
  22. handle_mcp-1.5.1/docs/adr/0006-pickup-is-a-delivery-mode.md +34 -0
  23. handle_mcp-1.5.1/docs/badges/coffee.svg +1 -0
  24. handle_mcp-1.5.1/docs/hero.svg +94 -0
  25. handle_mcp-1.5.1/docs/research.md +145 -0
  26. handle_mcp-1.5.1/docs/roadmap.md +7 -0
  27. handle_mcp-1.5.1/docs/security.md +11 -0
  28. handle_mcp-1.5.1/docs/setup.md +51 -0
  29. handle_mcp-1.5.1/docs/skills.md +23 -0
  30. handle_mcp-1.5.1/docs/testing.md +15 -0
  31. handle_mcp-1.5.1/docs/tools.md +32 -0
  32. handle_mcp-1.5.1/docs/troubleshooting.md +11 -0
  33. handle_mcp-1.5.1/pyproject.toml +42 -0
  34. handle_mcp-1.5.1/src/handle_mcp/__init__.py +0 -0
  35. handle_mcp-1.5.1/src/handle_mcp/candidates.py +81 -0
  36. handle_mcp-1.5.1/src/handle_mcp/cli.py +153 -0
  37. handle_mcp-1.5.1/src/handle_mcp/compare.py +288 -0
  38. handle_mcp-1.5.1/src/handle_mcp/links.py +39 -0
  39. handle_mcp-1.5.1/src/handle_mcp/match.py +289 -0
  40. handle_mcp-1.5.1/src/handle_mcp/models.py +170 -0
  41. handle_mcp-1.5.1/src/handle_mcp/pricing.py +111 -0
  42. handle_mcp-1.5.1/src/handle_mcp/privacy.py +166 -0
  43. handle_mcp-1.5.1/src/handle_mcp/recipe_cart.py +97 -0
  44. handle_mcp-1.5.1/src/handle_mcp/server.py +336 -0
  45. handle_mcp-1.5.1/src/handle_mcp/stores/__init__.py +202 -0
  46. handle_mcp-1.5.1/src/handle_mcp/stores/_http.py +38 -0
  47. handle_mcp-1.5.1/src/handle_mcp/stores/bilkatogo.py +523 -0
  48. handle_mcp-1.5.1/src/handle_mcp/stores/bilkatogo_recipes.py +350 -0
  49. handle_mcp-1.5.1/src/handle_mcp/stores/nemlig.py +484 -0
  50. handle_mcp-1.5.1/src/handle_mcp/stores/nemlig_delivery.py +97 -0
  51. handle_mcp-1.5.1/src/handle_mcp/stores/nemlig_offers.py +126 -0
  52. handle_mcp-1.5.1/src/handle_mcp/stores/nemlig_recipes.py +252 -0
  53. handle_mcp-1.5.1/src/handle_mcp/telemetry.py +301 -0
  54. handle_mcp-1.5.1/tests/__init__.py +0 -0
  55. handle_mcp-1.5.1/tests/conftest.py +228 -0
  56. handle_mcp-1.5.1/tests/fixtures/bilkatogo/README.md +36 -0
  57. handle_mcp-1.5.1/tests/fixtures/bilkatogo/algolia_offers.json +335 -0
  58. handle_mcp-1.5.1/tests/fixtures/bilkatogo/algolia_products.json +1009 -0
  59. handle_mcp-1.5.1/tests/fixtures/bilkatogo/available_delivery_dates.json +86 -0
  60. handle_mcp-1.5.1/tests/fixtures/bilkatogo/cart_home.json +645 -0
  61. handle_mcp-1.5.1/tests/fixtures/bilkatogo/cart_logged_in.json +645 -0
  62. handle_mcp-1.5.1/tests/fixtures/bilkatogo/change_line_count.json +13 -0
  63. handle_mcp-1.5.1/tests/fixtures/bilkatogo/change_line_count_unavailable.json +7 -0
  64. handle_mcp-1.5.1/tests/fixtures/bilkatogo/change_line_count_warning.json +13 -0
  65. handle_mcp-1.5.1/tests/fixtures/bilkatogo/gigya_jwt.json +9 -0
  66. handle_mcp-1.5.1/tests/fixtures/bilkatogo/gigya_login.json +13 -0
  67. handle_mcp-1.5.1/tests/fixtures/bilkatogo/gigya_login_invalid.json +10 -0
  68. handle_mcp-1.5.1/tests/fixtures/bilkatogo/login_jwt.json +6 -0
  69. handle_mcp-1.5.1/tests/fixtures/bilkatogo/match_fior_di_latte.json +313 -0
  70. handle_mcp-1.5.1/tests/fixtures/bilkatogo/match_hakkede_tomater.json +313 -0
  71. handle_mcp-1.5.1/tests/fixtures/bilkatogo/match_hvidloeg.json +313 -0
  72. handle_mcp-1.5.1/tests/fixtures/bilkatogo/match_makrel_tomat.json +487 -0
  73. handle_mcp-1.5.1/tests/fixtures/bilkatogo/match_mozzarella.json +893 -0
  74. handle_mcp-1.5.1/tests/fixtures/bilkatogo/order_details_plain.json +60 -0
  75. handle_mcp-1.5.1/tests/fixtures/bilkatogo/order_history.json +44 -0
  76. handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/aftensmad.payload.js +1 -0
  77. handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/carbonara.payload.js +1 -0
  78. handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/danske-retter.payload.js +1 -0
  79. handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/karbonader.payload.js +1 -0
  80. handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/madplaner.payload.js +1 -0
  81. handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/opskrifter.payload.js +1 -0
  82. handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/unknown-page.html +9 -0
  83. handle_mcp-1.5.1/tests/fixtures/nemlig/README.md +56 -0
  84. handle_mcp-1.5.1/tests/fixtures/nemlig/basket.json +26 -0
  85. handle_mcp-1.5.1/tests/fixtures/nemlig/delivery_days.json +923 -0
  86. handle_mcp-1.5.1/tests/fixtures/nemlig/delivery_spot.json +10 -0
  87. handle_mcp-1.5.1/tests/fixtures/nemlig/match_broccoli.json +557 -0
  88. handle_mcp-1.5.1/tests/fixtures/nemlig/match_hakkede_tomater.json +399 -0
  89. handle_mcp-1.5.1/tests/fixtures/nemlig/match_hakkede_tomater_30.json +1045 -0
  90. handle_mcp-1.5.1/tests/fixtures/nemlig/match_hvidloeg.json +388 -0
  91. handle_mcp-1.5.1/tests/fixtures/nemlig/match_makrel_tomat.json +451 -0
  92. handle_mcp-1.5.1/tests/fixtures/nemlig/meals_page.json +362 -0
  93. handle_mcp-1.5.1/tests/fixtures/nemlig/offers_page.json +1678 -0
  94. handle_mcp-1.5.1/tests/fixtures/nemlig/order_details.json +6 -0
  95. handle_mcp-1.5.1/tests/fixtures/nemlig/order_details_flex.json +106 -0
  96. handle_mcp-1.5.1/tests/fixtures/nemlig/order_history.json +9 -0
  97. handle_mcp-1.5.1/tests/fixtures/nemlig/order_history_flex.json +34 -0
  98. handle_mcp-1.5.1/tests/fixtures/nemlig/recipe_details.json +380 -0
  99. handle_mcp-1.5.1/tests/fixtures/nemlig/recipe_products.json +2090 -0
  100. handle_mcp-1.5.1/tests/fixtures/nemlig/recipe_search.json +113 -0
  101. handle_mcp-1.5.1/tests/fixtures/nemlig/recipes_by_tag.json +1483 -0
  102. handle_mcp-1.5.1/tests/fixtures/nemlig/search.json +43 -0
  103. handle_mcp-1.5.1/tests/fixtures/nemlig/search_gift.json +67 -0
  104. handle_mcp-1.5.1/tests/fixtures/nemlig/search_kyllingebryst.json +772 -0
  105. handle_mcp-1.5.1/tests/live_list.json +55 -0
  106. handle_mcp-1.5.1/tests/snapshots/tools.json +526 -0
  107. handle_mcp-1.5.1/tests/test_badges.py +42 -0
  108. handle_mcp-1.5.1/tests/test_bilkatogo.py +481 -0
  109. handle_mcp-1.5.1/tests/test_bilkatogo_recipes.py +190 -0
  110. handle_mcp-1.5.1/tests/test_cli.py +234 -0
  111. handle_mcp-1.5.1/tests/test_compare.py +1032 -0
  112. handle_mcp-1.5.1/tests/test_http.py +72 -0
  113. handle_mcp-1.5.1/tests/test_links.py +56 -0
  114. handle_mcp-1.5.1/tests/test_live.py +178 -0
  115. handle_mcp-1.5.1/tests/test_live_list.py +194 -0
  116. handle_mcp-1.5.1/tests/test_nemlig.py +787 -0
  117. handle_mcp-1.5.1/tests/test_nemlig_recipes.py +242 -0
  118. handle_mcp-1.5.1/tests/test_privacy.py +210 -0
  119. handle_mcp-1.5.1/tests/test_protocol.py +113 -0
  120. handle_mcp-1.5.1/tests/test_recipe_cart.py +182 -0
  121. handle_mcp-1.5.1/tests/test_safety.py +143 -0
  122. handle_mcp-1.5.1/tests/test_smoke.py +42 -0
  123. handle_mcp-1.5.1/tests/test_telemetry.py +335 -0
  124. handle_mcp-1.5.1/tests/test_tools.py +220 -0
  125. handle_mcp-1.5.1/uv.lock +960 -0
@@ -0,0 +1,2 @@
1
+ github: NJohansen
2
+ buy_me_a_coffee: niclasjohansen
@@ -0,0 +1,40 @@
1
+ """Write clones.json, the shields.io endpoint for the README clones badge.
2
+
3
+ Reads traffic.json (GitHub API answer) and clones-history.json.
4
+ GitHub keeps traffic for 14 days only. The history keeps the days the API still
5
+ shows, plus one sum for all older days, so no daily numbers older than 14 days are public.
6
+ """
7
+
8
+ import json
9
+ import pathlib
10
+
11
+
12
+ def update(saved: dict, traffic: dict) -> tuple[dict, dict]:
13
+ """Merge one traffic answer into the history. Returns (history, badge)."""
14
+ days = dict(saved["days"])
15
+ before = saved["before"]
16
+ fresh = {d["timestamp"][:10]: d["uniques"] for d in traffic["clones"]}
17
+ if fresh:
18
+ days.update(fresh)
19
+ first = min(fresh)
20
+ before += sum(n for day, n in days.items() if day < first)
21
+ days = {day: n for day, n in days.items() if day >= first}
22
+ days = dict(sorted(days.items()))
23
+ total = before + sum(days.values())
24
+ message = f"{total / 1000:.1f}k".replace(".0k", "k") if total >= 1000 else str(total)
25
+ badge = {"schemaVersion": 1, "label": "clones", "message": message, "color": "brightgreen"}
26
+ return {"before": before, "days": days}, badge
27
+
28
+
29
+ def main() -> None:
30
+ history = pathlib.Path("clones-history.json")
31
+ saved = json.loads(history.read_text()) if history.exists() else {"before": 0, "days": {}}
32
+ traffic = json.loads(pathlib.Path("traffic.json").read_text())
33
+ new, badge = update(saved, traffic)
34
+ history.write_text(json.dumps(new, indent=1) + "\n")
35
+ pathlib.Path("clones.json").write_text(json.dumps(badge) + "\n")
36
+ print(f"{badge['message']} clones")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1,10 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: uv
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ - package-ecosystem: github-actions
8
+ directory: /
9
+ schedule:
10
+ interval: monthly
@@ -0,0 +1,72 @@
1
+ name: badges
2
+ # The README clones badge: shields.io reads clones.json on the `badges` branch.
3
+ # GitHub keeps traffic for 14 days only, so each run saves the days it sees.
4
+ #
5
+ # Needs secret TRAFFIC_TOKEN in the environment `badges` (Settings > Environments,
6
+ # deployment branches: main only). A fine-grained token for this repo only,
7
+ # "Administration: read", with an expiry date. The traffic API needs it;
8
+ # GITHUB_TOKEN cannot get it. Never runs on pull requests, so forks never see it.
9
+ on:
10
+ schedule:
11
+ - cron: "17 3 * * *"
12
+ workflow_dispatch:
13
+ permissions: {}
14
+ concurrency:
15
+ group: badges
16
+ jobs:
17
+ fetch:
18
+ runs-on: ubuntu-latest
19
+ timeout-minutes: 5
20
+ environment: badges
21
+ permissions: {}
22
+ steps:
23
+ - name: Fetch the clone counts
24
+ env:
25
+ GH_TOKEN: ${{ secrets.TRAFFIC_TOKEN }}
26
+ REPO: ${{ github.repository }}
27
+ run: |
28
+ if [ -z "$GH_TOKEN" ]; then
29
+ echo "::error::Secret TRAFFIC_TOKEN is not set in the environment 'badges'."
30
+ exit 1
31
+ fi
32
+ gh api "repos/$REPO/traffic/clones" > traffic.json
33
+ - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
34
+ with:
35
+ name: traffic
36
+ path: traffic.json
37
+ retention-days: 1
38
+ write:
39
+ needs: fetch
40
+ runs-on: ubuntu-latest
41
+ timeout-minutes: 5
42
+ permissions:
43
+ contents: write # push to the badges branch only; main is protected
44
+ steps:
45
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
46
+ - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
47
+ with:
48
+ name: traffic
49
+ path: ${{ runner.temp }}
50
+ - name: Switch to the badges branch
51
+ run: |
52
+ cp .github/badges/make.py "$RUNNER_TEMP/make.py"
53
+ if git fetch -q --depth 1 origin badges; then
54
+ git checkout -q -B badges FETCH_HEAD
55
+ else # first run
56
+ git checkout -q --orphan badges
57
+ git rm -rq --cached .
58
+ git clean -fdq
59
+ fi
60
+ - name: Write the badge
61
+ run: |
62
+ cp "$RUNNER_TEMP/traffic.json" .
63
+ python3 -I "$RUNNER_TEMP/make.py"
64
+ rm traffic.json
65
+ - name: Commit to the badges branch
66
+ run: |
67
+ git config user.name "github-actions[bot]"
68
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
69
+ git add clones.json clones-history.json
70
+ git diff --cached --quiet && exit 0
71
+ git commit -qm "Update clone count"
72
+ git push origin HEAD:badges
@@ -0,0 +1,21 @@
1
+ name: ci
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ permissions:
7
+ contents: read
8
+ jobs:
9
+ test:
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ os: [ubuntu-latest, windows-latest, macos-latest]
14
+ runs-on: ${{ matrix.os }}
15
+ steps:
16
+ - uses: actions/checkout@v7
17
+ - uses: astral-sh/setup-uv@v7
18
+ - run: uv sync --python 3.12
19
+ - run: uv run ruff check .
20
+ - run: uv run ruff format --check .
21
+ - run: uv run pytest -q
@@ -0,0 +1,67 @@
1
+ name: claude-review
2
+ # A fresh Claude reviews every PR. It has no memory of the session that wrote the code.
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize, ready_for_review, reopened]
6
+
7
+ concurrency:
8
+ group: claude-review-${{ github.event.pull_request.number }}
9
+ cancel-in-progress: true
10
+
11
+ jobs:
12
+ review:
13
+ # Only PRs from this repo: forks and Dependabot get no secrets, so the job would fail.
14
+ # Skip drafts.
15
+ if: |
16
+ github.event.pull_request.draft == false &&
17
+ github.actor != 'dependabot[bot]' &&
18
+ github.event.pull_request.head.repo.full_name == github.repository
19
+ runs-on: ubuntu-latest
20
+ timeout-minutes: 20
21
+ permissions:
22
+ contents: read
23
+ pull-requests: write
24
+ issues: write
25
+ id-token: write # the action trades this for its GitHub App token
26
+ actions: read
27
+ steps:
28
+ - uses: actions/checkout@v7
29
+ with:
30
+ fetch-depth: 1
31
+ - uses: anthropics/claude-code-action@c0949c50a78c1ec8ce2647db36629b870e715d0c # v1.0.268
32
+ with:
33
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
34
+ track_progress: true
35
+ prompt: |
36
+ REPO: ${{ github.repository }}
37
+ PR NUMBER: ${{ github.event.pull_request.number }}
38
+
39
+ You review this pull request. You did not write it. Be skeptical.
40
+ Read CLAUDE.md and docs/adr/ first. Then read the full diff.
41
+
42
+ Block the PR (say "BLOCKER") if any of these break:
43
+ 1. The human always pays. No code may place an order, pay, read cards,
44
+ choose a delivery time or change the address. No new store endpoint
45
+ outside search, product, cart quantity and read-only delivery slots.
46
+ 2. Privacy. Every tool answer goes through privacy.scrub() in server._out().
47
+ No name, address, phone, email or raw store JSON leaves the server,
48
+ also not in logs, errors or test fixtures.
49
+ 3. stdout is JSON-RPC. No print(). Logs go to stderr via logging.
50
+ 4. No secrets in git: no tokens, passwords, cookies or real personal data.
51
+ 5. One adapter per store in src/handle_mcp/stores/. A new store must not
52
+ need changes in server.py. One failing store must not stop the others.
53
+ 6. Money is Decimal DKK. Bilka øre are converted in the adapter.
54
+ 7. Tests use no network and no logins (except tests marked live).
55
+
56
+ Then look for real bugs: wrong logic, missing error handling, retries on
57
+ the wrong errors (only timeout, 429 and 5xx), missing tests for new code.
58
+ Skip style nits that ruff catches.
59
+
60
+ Output:
61
+ - Inline comments on the exact lines (mcp__github_inline_comment__create_inline_comment).
62
+ - One short top comment with `gh pr comment`: verdict (OK / changes needed /
63
+ BLOCKER), then max 5 bullets. Short sentences. No praise, no filler.
64
+ - Do not add any footer or attribution.
65
+
66
+ claude_args: |
67
+ --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Read,Glob,Grep"
@@ -0,0 +1,38 @@
1
+ name: claude
2
+ # Write @claude in a PR or issue comment to ask a question or request a change.
3
+ # Never on a PR from a fork: the workspace holds the PR's code, which may hold
4
+ # instructions meant for the agent, and the agent can push.
5
+ on:
6
+ issue_comment:
7
+ types: [created]
8
+ pull_request_review_comment:
9
+ types: [created]
10
+ pull_request_review:
11
+ types: [submitted]
12
+
13
+ jobs:
14
+ claude:
15
+ # Only the repo owner can call @claude.
16
+ if: |
17
+ github.actor == 'NJohansen' && (
18
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
19
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
20
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude'))
21
+ )
22
+ runs-on: ubuntu-latest
23
+ timeout-minutes: 30
24
+ permissions:
25
+ contents: write
26
+ pull-requests: write
27
+ issues: write
28
+ id-token: write # the action trades this for its GitHub App token
29
+ actions: read
30
+ steps:
31
+ - uses: actions/checkout@v7
32
+ with:
33
+ fetch-depth: 1
34
+ - uses: anthropics/claude-code-action@c0949c50a78c1ec8ce2647db36629b870e715d0c # v1.0.268
35
+ with:
36
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
37
+ additional_permissions: |
38
+ actions: read
@@ -0,0 +1,54 @@
1
+ name: release
2
+ # Publishes to PyPI when a version tag (vX.Y.Z) is pushed.
3
+ # Trusted Publishing: PyPI trusts this workflow in the `pypi` environment,
4
+ # so no PyPI token is stored in GitHub.
5
+ on:
6
+ push:
7
+ tags: ["v[0-9]+.[0-9]+.[0-9]+"]
8
+ permissions: {}
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ timeout-minutes: 10
13
+ permissions:
14
+ contents: read
15
+ steps:
16
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
17
+ with:
18
+ persist-credentials: false
19
+ fetch-depth: 0
20
+ - name: Tag must be on main
21
+ run: git merge-base --is-ancestor "$GITHUB_SHA" origin/main
22
+ - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
23
+ with:
24
+ version: "0.11.32"
25
+ - name: Tag must match the version in pyproject.toml
26
+ run: |
27
+ version=$(uv version --short)
28
+ if [ "v$version" != "$GITHUB_REF_NAME" ]; then
29
+ echo "Tag $GITHUB_REF_NAME does not match version $version" >&2
30
+ exit 1
31
+ fi
32
+ - run: uv sync --python 3.12
33
+ - run: uv run pytest -q
34
+ - run: uv build
35
+ - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
36
+ with:
37
+ name: dist
38
+ path: dist/
39
+ retention-days: 7
40
+ publish:
41
+ needs: build
42
+ runs-on: ubuntu-latest
43
+ timeout-minutes: 10
44
+ environment:
45
+ name: pypi
46
+ url: https://pypi.org/p/handle-mcp
47
+ permissions:
48
+ id-token: write # Trusted Publishing
49
+ steps:
50
+ - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
51
+ with:
52
+ name: dist
53
+ path: dist/
54
+ - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
@@ -0,0 +1,21 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+
11
+ # Secrets and local state: never commit logins or sessions
12
+ .env
13
+ .env.*
14
+ *.secret.json
15
+ config/*.json
16
+ captures/
17
+
18
+ # OS / editors
19
+ .DS_Store
20
+ .idea/
21
+ .vscode/
@@ -0,0 +1,117 @@
1
+ # handle-mcp
2
+
3
+ A local MCP server (stdio) for Danish grocery stores with home delivery. Goal and roadmap: the README. Findings about the stores: `docs/research.md`. How to contribute: `CONTRIBUTING.md`.
4
+
5
+ ## Rules (never break)
6
+
7
+ 1. **The human always pays.** No code may order, pay, read cards, choose a delivery time or change the address. See `docs/adr/0001-the-human-always-pays.md`. `tests/test_safety.py` greps for forbidden endpoints.
8
+ 2. **Privacy first.** Every tool answer goes through `privacy.scrub()` in `server._out()`. Never return raw store JSON without it.
9
+ 3. **stdout is JSON-RPC.** Never `print()`. Log to stderr with `logging`.
10
+ 4. **No secrets in git.** Logins only in env vars, the OS keychain (`handle-mcp login <store>`) or `~/.config/handle-mcp/<store>.json` (chmod 600).
11
+ 5. **Small and boring.** < ~550 lines per store file, < ~300 in every other file. No layer until two real users need it. A store that grows past the limit splits into mixins, `stores/<store>_<topic>.py`, never into a second layer.
12
+ 6. **No Claude attribution** in commits, PRs or comments.
13
+
14
+ ## Layout
15
+
16
+ ```
17
+ src/handle_mcp/
18
+ server.py all tools, store selection, error handling
19
+ cli.py `handle-mcp` entry: stdio server or `login <store>` (logins into the OS keychain)
20
+ compare.py compare_basket: best product per item, cheapest store
21
+ candidates.py the products per item the caller picks from, with flags
22
+ match.py words: does this product match the item?
23
+ pricing.py kroner: packs for the amount, offers, delivery day
24
+ recipe_cart.py add_recipe_to_cart: a recipe's products as absolute cart counts
25
+ links.py with_utm(): UTM tags on every cart link the human clicks
26
+ telemetry.py anonymous counts (server_started, first_tool_call, login_ok, cart_filled); off with HANDLE_TELEMETRY=0
27
+ models.py dataclasses; money is Decimal DKK
28
+ privacy.py scrub(): removes personal data
29
+ stores/__init__.py Store protocol, StoreError, load_login(), STORES
30
+ stores/_http.py one request with the retries every store needs
31
+ stores/<store>.py one file per store: login, calls and parsing for that store
32
+ stores/<store>_<topic>.py mixins when a store outgrows one file (nemlig_offers, nemlig_recipes, nemlig_delivery)
33
+ tests/ no network, no logins
34
+ tests/snapshots/tools.json every tool as the model sees it (name, description, args)
35
+ docs/adr/ one short file per hard decision
36
+ docs/tools.md the tool table users read: stores per tool, what writes
37
+ ```
38
+
39
+ ## Language
40
+
41
+ - Code, comments, docs, commits and PRs: English.
42
+ - Everything a human reads in a tool answer or error: Danish. `StoreError("nemlig.com har kø lige nu.")`.
43
+ - Product names, offer texts and store messages pass through as the store wrote them (after `privacy.scrub()`).
44
+
45
+ ## Tools
46
+
47
+ - Every tool has annotations: `READ`, `WRITE` (absolute cart quantities) or `ADD` (adds to the cart). Nothing else.
48
+ - Every write tool says "Never orders or pays" in its description. A tool that adds to the cart says "ASK THE HUMAN FIRST". The tool never asks; the model does.
49
+ - A tool that works in one store says so in its description and in `docs/tools.md`.
50
+ - `tests/snapshots/tools.json` is the contract. A change to a tool's name, description or args is on purpose, in its own commit, with `HANDLE_UPDATE_SNAPSHOT=1`.
51
+ - Tool answers are small, readable JSON: money as strings in kroner with two decimals (`"12.95"`, `"39.00"`, never `"39"` or `"0.0"`; `privacy.jsonable()` formats every Decimal, see `docs/adr/0002-money-has-two-decimals.md`), dates ISO, no raw store fields. A missing fact is `null` with a reason next to it, not a guess.
52
+ - A new rule or a changed one gets a short ADR in `docs/adr/`.
53
+
54
+ ## Add a store
55
+
56
+ 1. Create `src/handle_mcp/stores/<name>.py` with a class that has every method in `stores.Store`.
57
+ 2. Raise `StoreError("clear Danish message")` for expected failures. Raise `NotImplementedError` if the store cannot do something.
58
+ 3. Add one line to `STORES` in `stores/__init__.py`.
59
+ 4. Tests: fixtures in `tests/fixtures/<name>/` are real answers with personal data replaced. Mock HTTP with `httpx.MockTransport`.
60
+
61
+ No change to `server.py` or the tools is needed.
62
+
63
+ ## Store API lessons (see docs/research.md)
64
+
65
+ - Money from stores may be in øre (Bilka). Convert to Decimal kroner in the adapter.
66
+ - Quantities are absolute; 0 removes the line.
67
+ - Bilka answers HTTP 200 on errors. A cart change failed if the applied count differs from the requested one.
68
+ - Bilka: log in without a `cart` cookie, or the login replaces the cart.
69
+ - Retry only on timeout, network errors (e.g. ReadError), 429 and 5xx. Safe because every write sets an absolute quantity.
70
+ - nemlig: the front page and `?GetAsJson=1` land in queue-it. Use `/webapi/` and the productbff API. Never work around a queue or a bot check: raise a clear StoreError and let the human wait.
71
+ - Fixtures are real captures with personal data replaced. `tests/fixtures/<store>/README.md` says what was captured and when, so a stale fixture is easy to spot.
72
+
73
+ ## Test
74
+
75
+ ```
76
+ uv sync
77
+ uv run pytest -q # unit tests, no network
78
+ HANDLE_UPDATE_SNAPSHOT=1 uv run pytest tests/test_protocol.py # after changing a tool's name, description or args on purpose
79
+ uv run ruff check . && uv run ruff format --check .
80
+ HANDLE_LIVE=1 uv run pytest -m live # read-only smoke tests against real stores (from step 3)
81
+ HANDLE_LIVE=1 uv run pytest -m live -s tests/test_live_list.py # 50 fixed items (tests/live_list.json): fails when no candidate fits
82
+ ```
83
+
84
+ ## Workflow
85
+
86
+ One PR per change. Run the tests before you push, show the result, wait for OK.
87
+
88
+ - A change that touches a store file: run the live tests for that store before merge. Stores change without notice.
89
+ - A change to the matcher (`match.py`, `pricing.py`, `candidates.py`): run the live list before merge.
90
+ - A new tool or a changed one: update `docs/tools.md` and the snapshot in the same PR.
91
+ - Squash and merge. The squash message is the PR title.
92
+
93
+ ## Release
94
+
95
+ 1. Bump `version` in `pyproject.toml` (`uv lock` follows) and every pin: `grep -rn "handle-mcp@" README.md docs/` (README, `docs/setup.md`, `docs/security.md`). The website lives in [NJohansen/handlemcp.dk](https://github.com/NJohansen/handlemcp.dk): bump its pin in `index.html` there too.
96
+ 2. Merge, then tag the merge commit on `main`: `git fetch origin main && git tag vX.Y.Z origin/main && git push origin vX.Y.Z`. The tag runs `.github/workflows/release.yml`: tests, build, publish to PyPI (Trusted Publishing, environment `pypi`). It fails if the tag and `version` differ.
97
+ 3. Semver: a tool's args or answer shape changes in a way old skills notice → major. New tool or field → minor. Fix → patch.
98
+
99
+ ## Branches, commits and PRs (gitmoji)
100
+
101
+ Branch name = type + short slug. The type says what the branch solves:
102
+
103
+ | Type | Gitmoji | Use for |
104
+ |---|---|---|
105
+ | `feature/` | ✨ | new tool, store or behaviour |
106
+ | `fix/` | 🐛 | bug fix |
107
+ | `hotfix/` | 🚑️ | urgent fix on main |
108
+ | `refactor/` | ♻️ | same behaviour, better code |
109
+ | `docs/` | 📝 | docs only |
110
+ | `test/` | ✅ | tests only |
111
+ | `chore/` | 🔧 | config, CI, dependencies |
112
+
113
+ Example: branch `feature/compare-basket`, PR title `✨ Compare a basket across stores`.
114
+
115
+ - Start every commit message and PR title with the gitmoji ([gitmoji.dev](https://gitmoji.dev)).
116
+ - No emoji in branch names: they break shells and URLs. The prefix is enough.
117
+ - One type per branch. Split mixed work into two branches.
@@ -0,0 +1,36 @@
1
+ # Contributing
2
+
3
+ Small and boring wins. Read `CLAUDE.md` first: the six rules, the layout and how to add a store.
4
+
5
+ ## Setup
6
+
7
+ ```sh
8
+ uv sync
9
+ uv run pytest -q # no network, no logins
10
+ uv run ruff check . && uv run ruff format --check .
11
+ ```
12
+
13
+ Live tests (`HANDLE_LIVE=1 uv run pytest -m live -s`) call the real stores with **your own** accounts, saved with `handle-mcp login <store>`. They only read. CI never runs them.
14
+
15
+ ## Add a store
16
+
17
+ 1. Create `src/handle_mcp/stores/<name>.py` with a class that has every method in `stores.Store`.
18
+ 2. Raise `StoreError("clear Danish message")` for expected failures, `NotImplementedError` for what the store cannot do.
19
+ 3. Add one line to `STORES` in `src/handle_mcp/stores/__init__.py`.
20
+ 4. Tests: real, redacted answers in `tests/fixtures/<name>/`, HTTP mocked with `httpx.MockTransport`.
21
+
22
+ No change to `server.py` or the tools. Store facts: [docs/research.md](docs/research.md).
23
+
24
+ ## Pull requests
25
+
26
+ - Branch: `<type>/<slug>`, type is one of `feature`, `fix`, `hotfix`, `refactor`, `docs`, `test`, `chore`. One type per branch.
27
+ - Commit messages and PR title start with the matching gitmoji (✨ 🐛 🚑️ ♻️ 📝 ✅ 🔧).
28
+ - Tests for new code. HTTP is mocked with `httpx.MockTransport`.
29
+ - Fixtures are real store answers with personal data replaced. Say so in `tests/fixtures/<store>/README.md`. Never commit a login, a cookie, a token or a real name, address, phone or email.
30
+ - A Claude review runs on PRs from this repo. It is a second pair of eyes, not the decision.
31
+
32
+ ## Never
33
+
34
+ - An endpoint that orders, pays, reads cards, chooses a delivery time or changes the address. `tests/test_safety.py` fails if one appears.
35
+ - `print()`. stdout is the MCP stream. Log to stderr with `logging`.
36
+ - Raw store JSON in a tool answer. Everything goes through `privacy.scrub()`.
@@ -0,0 +1,8 @@
1
+ # Credits
2
+
3
+ Built on what these projects found out. Thank you.
4
+
5
+ - [eisbaw/nemlig_cli](https://github.com/eisbaw/nemlig_cli): the nemlig.com API map (`nemlig_api.md`). MIT.
6
+ - [kraenhansen/nemlig-mcp](https://github.com/kraenhansen/nemlig-mcp): nemlig MCP server and the privacy-marker idea. MIT.
7
+ - [nickknissen/bilkatogo](https://github.com/nickknissen/bilkatogo): BilkaToGo login, cart and order flow, and the test fixtures in `tests/fixtures/bilkatogo/`. MIT, Copyright (c) 2026 Nick Nissen.
8
+ - [stigvoss/bilkatogo-mcp](https://github.com/stigvoss/bilkatogo-mcp): read for facts about BilkaToGo. No code copied.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niclas Johansen
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,126 @@
1
+ Metadata-Version: 2.5
2
+ Name: handle-mcp
3
+ Version: 1.5.1
4
+ Summary: MCP server for Danish grocery delivery: search, cart and price comparison. You always pay yourself.
5
+ Project-URL: Homepage, https://handlemcp.dk
6
+ Project-URL: Source, https://github.com/NJohansen/handle-mcp
7
+ Project-URL: Issues, https://github.com/NJohansen/handle-mcp/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: keyring>=25
13
+ Requires-Dist: mcp<3,>=2.3
14
+ Requires-Dist: tzdata>=2024.1
15
+ Description-Content-Type: text/markdown
16
+
17
+ <div align="center">
18
+
19
+ <img src="docs/hero.svg" alt="Give AI a budget, fill it cheaply, you pay and they deliver, unpack the week, enjoy dinner" width="800">
20
+
21
+ # handle-mcp
22
+
23
+ <a href="https://github.com/NJohansen/handle-mcp/stargazers"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/github/NJohansen/handle-mcp/stars.svg?mode=dark"><img src="https://shieldcn.dev/github/NJohansen/handle-mcp/stars.svg?mode=light" alt="GitHub stars"></picture></a>
24
+ <a href="https://github.com/NJohansen/handle-mcp/graphs/traffic"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/badge/dynamic/json.svg?url=https%3A%2F%2Fraw.githubusercontent.com%2FNJohansen%2Fhandle-mcp%2Fbadges%2Fclones.json&query=%24.message&label=clones&mode=dark"><img src="https://shieldcn.dev/badge/dynamic/json.svg?url=https%3A%2F%2Fraw.githubusercontent.com%2FNJohansen%2Fhandle-mcp%2Fbadges%2Fclones.json&query=%24.message&label=clones&mode=light" alt="git clones"></picture></a>
25
+ <a href="https://pypi.org/project/handle-mcp/"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/pypi/handle-mcp.svg?mode=dark"><img src="https://shieldcn.dev/pypi/handle-mcp.svg?mode=light" alt="PyPI version"></picture></a>
26
+ <a href="LICENSE"><picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/badge/license-MIT-blue.svg?mode=dark"><img src="https://shieldcn.dev/badge/license-MIT-blue.svg?mode=light" alt="license MIT"></picture></a>
27
+ <br>
28
+ <picture><source media="(prefers-color-scheme: dark)" srcset="https://shieldcn.dev/badge/platforms-macOS_%7C_Windows_%7C_Linux-blue.svg?mode=dark"><img src="https://shieldcn.dev/badge/platforms-macOS_%7C_Windows_%7C_Linux-blue.svg?mode=light" alt="macOS, Windows, Linux"></picture>
29
+
30
+ **Your AI agent fills the cheapest cart at [nemlig.com](https://www.nemlig.com) and [BilkaToGo](https://www.bilkatogo.dk). You pay.**<br>
31
+ An MCP server: it reads the offers, plans the week on your budget and compares both stores.
32
+
33
+ **[Install →](#install)** · [handlemcp.dk](https://handlemcp.dk)
34
+
35
+ </div>
36
+
37
+ **It can never order or pay. You do.** That code does not exist, and a test fails if it shows up ([why](docs/adr/0001-the-human-always-pays.md)).
38
+
39
+ **It counts anonymous usage, and tells you so.** No id, no personal data. Off with `HANDLE_TELEMETRY=0`. [Exactly what is sent](#brugsstatistik).
40
+
41
+ ## How it works
42
+
43
+ 1. **Plan.** Give your agent a budget. It reads this week's offers on nemlig.com and BilkaToGo, and both stores' recipes, and plans the meals around them.
44
+ 2. **Compare.** It prices the whole list in both stores: pack sizes, offers, delivery fee and minimum order.
45
+ 3. **Fill.** It puts the list in the cheapest store's cart, with your login. Only the cart, nothing else.
46
+ 4. **Pay.** You open nemlig.com or BilkaToGo, check the cart, pick a delivery time and pay.
47
+
48
+ Taste, budget and meal rules live in your own prompt or skill. The server finds and counts. [Build your own skill](docs/skills.md).
49
+
50
+ ## Requirements
51
+
52
+ - macOS, Linux or Windows (x64). Windows on ARM is not supported yet: a dependency (`cryptography`) has no ready-made build for it.
53
+ - [uv](https://docs.astral.sh/uv/). It fetches Python 3.12+ if you do not have it.
54
+ - An MCP client, Grok, Claude Code, Claude Desktop or Codex etc.
55
+ - An account at nemlig.com or BilkaToGo with home delivery to your address.
56
+
57
+ ## Install
58
+
59
+ ```sh
60
+ uvx handle-mcp@1.5.1 login nemlig
61
+ claude mcp add handle -- uvx handle-mcp@1.5.1
62
+ ```
63
+
64
+ Then ask:
65
+
66
+ - *"Plan 5 dinners for 4 within 900 kr, built on this week's offers. Fill the cheapest cart."*
67
+ - *"What is on offer this week? Any free gifts?"*
68
+ - *"Which delivery times are cheapest tomorrow?"*
69
+
70
+ BilkaToGo, Claude Desktop, headless machines and AI agents (`handle-mcp login <store> --from-env`): [setup](docs/setup.md). nemlig's offers work with no login at all. No terminal? [Join the waitlist](https://handlemcp.dk).
71
+
72
+ ## Tools
73
+
74
+ | Tool | Writes? | What |
75
+ |---|---|---|
76
+ | `search_products` | no | Price, price per kg/l, offers |
77
+ | `search_recipes` | no | The store's recipes (nemlig) |
78
+ | `list_recipes` | no | Featured recipes (BilkaToGo: the week's madplan), by tag (nemlig) or by category (BilkaToGo) |
79
+ | `get_recipe` | no | The products to buy for a recipe, for any number of persons |
80
+ | `list_offers` | no | This week's offers and free gifts |
81
+ | `compare_basket` | no | Prices a list in every store, fees included, cheapest first |
82
+ | `get_cart` | no | Cart lines, fees, total, in one store or every store |
83
+ | `set_cart_quantities` | **yes** | Sets quantities in the cart. Nothing else |
84
+ | `add_recipe_to_cart` | **yes** | Puts a recipe's products in the cart, after your yes. Nothing else |
85
+ | `get_delivery_info` | no | Chosen time, fees |
86
+ | `list_delivery_slots` | no | Free times, cheapest first |
87
+ | `get_orders` / `get_order` | no | What you usually buy |
88
+
89
+ Full arguments and fields: [docs/tools.md](docs/tools.md).
90
+
91
+ ## Brugsstatistik
92
+
93
+ handle-mcp counts, anonymously, how far people get from install to a filled cart, so we can show nemlig.com and BilkaToGo how many carts and kroner it sends them each week. It is on by default and easy to turn off.
94
+
95
+ What is sent, and nothing else. Every event also has `version` and `date` (day only):
96
+
97
+ | Event | When | Fields |
98
+ |---|---|---|
99
+ | `server_started` | the server starts, at most once per machine per day | `os` (`darwin`, `linux` or `windows`) |
100
+ | `first_tool_call` | the first tool call of the day (a client that only lists the tools sends nothing) | `tool` (e.g. `compare_basket`) |
101
+ | `login_ok` | a store answers with your cart, at most once per store per day | `store` (`nemlig` or `bilka`) |
102
+ | `cart_filled` | once per order: after 30 minutes without changes, only when every line went in. Later edits send nothing until the cart is empty again (after checkout) | `store`, `basket_total_dkk` (rounded to the nearest 50 kr), `item_count` |
103
+
104
+ Never sent: names, addresses, phone, email, logins, product names, order ids or any id for you or your machine. The events go to [Umami Cloud](https://umami.is) over HTTPS. Umami sees your IP address to send the answer back and to guess the country; it does not save the address. Sends run with a 2 second timeout, in the background. A failed send never breaks a tool. The first tool answer on a machine has a `telemetry_notice`, so your AI tells you about it once. The only file is `~/.config/handle-mcp/telemetry.json`: whether the notice was shown, the day each daily event was last sent, and which stores have a counted cart that is not empty yet.
105
+
106
+ Cart links (`url` in the cart) carry `utm_source=handlemcp&utm_medium=mcp&utm_campaign=cart`, so the store sees the visit in its own analytics.
107
+
108
+ Turn it off with any of:
109
+
110
+ ```sh
111
+ HANDLE_TELEMETRY=0 # env var
112
+ DO_NOT_TRACK=1 # the common opt-out
113
+ handle-mcp --no-telemetry # flag
114
+ ```
115
+
116
+ Send the events to your own Umami: `HANDLE_TELEMETRY_URL=https://umami.example.com/api/send` and `HANDLE_TELEMETRY_WEBSITE=<website id>`. Why and how: [docs/adr/0005](docs/adr/0005-anonymous-usage-counts.md).
117
+
118
+ ## More
119
+
120
+ [Setup](docs/setup.md) · [Skills](docs/skills.md) · [Security](docs/security.md) · [Troubleshooting](docs/troubleshooting.md) · [Testing](docs/testing.md) · [Roadmap](docs/roadmap.md) · [Contributing](CONTRIBUTING.md) · [Credits](CREDITS.md)
121
+
122
+ Unofficial. Uses the stores' private website APIs, so it can break any day. Not affiliated with nemlig.com or Salling Group.
123
+
124
+ [MIT](LICENSE)
125
+
126
+ Free, and it stays free. If it saves you time or money: <a href="https://buymeacoffee.com/niclasjohansen"><img src="docs/badges/coffee.svg" alt="buy me a coffee" height="26" align="absmiddle"></a>