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.
- handle_mcp-1.5.1/.github/FUNDING.yml +2 -0
- handle_mcp-1.5.1/.github/badges/make.py +40 -0
- handle_mcp-1.5.1/.github/dependabot.yml +10 -0
- handle_mcp-1.5.1/.github/workflows/badges.yml +72 -0
- handle_mcp-1.5.1/.github/workflows/ci.yml +21 -0
- handle_mcp-1.5.1/.github/workflows/claude-review.yml +67 -0
- handle_mcp-1.5.1/.github/workflows/claude.yml +38 -0
- handle_mcp-1.5.1/.github/workflows/release.yml +54 -0
- handle_mcp-1.5.1/.gitignore +21 -0
- handle_mcp-1.5.1/CLAUDE.md +117 -0
- handle_mcp-1.5.1/CONTRIBUTING.md +36 -0
- handle_mcp-1.5.1/CREDITS.md +8 -0
- handle_mcp-1.5.1/LICENSE +21 -0
- handle_mcp-1.5.1/PKG-INFO +126 -0
- handle_mcp-1.5.1/README.md +110 -0
- handle_mcp-1.5.1/SECURITY.md +11 -0
- handle_mcp-1.5.1/docs/adr/0001-the-human-always-pays.md +12 -0
- handle_mcp-1.5.1/docs/adr/0002-money-has-two-decimals.md +13 -0
- handle_mcp-1.5.1/docs/adr/0003-save-the-session-not-the-password.md +20 -0
- handle_mcp-1.5.1/docs/adr/0004-windows-has-no-file-modes.md +13 -0
- handle_mcp-1.5.1/docs/adr/0005-anonymous-usage-counts.md +20 -0
- handle_mcp-1.5.1/docs/adr/0006-pickup-is-a-delivery-mode.md +34 -0
- handle_mcp-1.5.1/docs/badges/coffee.svg +1 -0
- handle_mcp-1.5.1/docs/hero.svg +94 -0
- handle_mcp-1.5.1/docs/research.md +145 -0
- handle_mcp-1.5.1/docs/roadmap.md +7 -0
- handle_mcp-1.5.1/docs/security.md +11 -0
- handle_mcp-1.5.1/docs/setup.md +51 -0
- handle_mcp-1.5.1/docs/skills.md +23 -0
- handle_mcp-1.5.1/docs/testing.md +15 -0
- handle_mcp-1.5.1/docs/tools.md +32 -0
- handle_mcp-1.5.1/docs/troubleshooting.md +11 -0
- handle_mcp-1.5.1/pyproject.toml +42 -0
- handle_mcp-1.5.1/src/handle_mcp/__init__.py +0 -0
- handle_mcp-1.5.1/src/handle_mcp/candidates.py +81 -0
- handle_mcp-1.5.1/src/handle_mcp/cli.py +153 -0
- handle_mcp-1.5.1/src/handle_mcp/compare.py +288 -0
- handle_mcp-1.5.1/src/handle_mcp/links.py +39 -0
- handle_mcp-1.5.1/src/handle_mcp/match.py +289 -0
- handle_mcp-1.5.1/src/handle_mcp/models.py +170 -0
- handle_mcp-1.5.1/src/handle_mcp/pricing.py +111 -0
- handle_mcp-1.5.1/src/handle_mcp/privacy.py +166 -0
- handle_mcp-1.5.1/src/handle_mcp/recipe_cart.py +97 -0
- handle_mcp-1.5.1/src/handle_mcp/server.py +336 -0
- handle_mcp-1.5.1/src/handle_mcp/stores/__init__.py +202 -0
- handle_mcp-1.5.1/src/handle_mcp/stores/_http.py +38 -0
- handle_mcp-1.5.1/src/handle_mcp/stores/bilkatogo.py +523 -0
- handle_mcp-1.5.1/src/handle_mcp/stores/bilkatogo_recipes.py +350 -0
- handle_mcp-1.5.1/src/handle_mcp/stores/nemlig.py +484 -0
- handle_mcp-1.5.1/src/handle_mcp/stores/nemlig_delivery.py +97 -0
- handle_mcp-1.5.1/src/handle_mcp/stores/nemlig_offers.py +126 -0
- handle_mcp-1.5.1/src/handle_mcp/stores/nemlig_recipes.py +252 -0
- handle_mcp-1.5.1/src/handle_mcp/telemetry.py +301 -0
- handle_mcp-1.5.1/tests/__init__.py +0 -0
- handle_mcp-1.5.1/tests/conftest.py +228 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/README.md +36 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/algolia_offers.json +335 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/algolia_products.json +1009 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/available_delivery_dates.json +86 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/cart_home.json +645 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/cart_logged_in.json +645 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/change_line_count.json +13 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/change_line_count_unavailable.json +7 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/change_line_count_warning.json +13 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/gigya_jwt.json +9 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/gigya_login.json +13 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/gigya_login_invalid.json +10 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/login_jwt.json +6 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/match_fior_di_latte.json +313 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/match_hakkede_tomater.json +313 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/match_hvidloeg.json +313 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/match_makrel_tomat.json +487 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/match_mozzarella.json +893 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/order_details_plain.json +60 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/order_history.json +44 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/aftensmad.payload.js +1 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/carbonara.payload.js +1 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/danske-retter.payload.js +1 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/karbonader.payload.js +1 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/madplaner.payload.js +1 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/opskrifter.payload.js +1 -0
- handle_mcp-1.5.1/tests/fixtures/bilkatogo/recipes/unknown-page.html +9 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/README.md +56 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/basket.json +26 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/delivery_days.json +923 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/delivery_spot.json +10 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/match_broccoli.json +557 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/match_hakkede_tomater.json +399 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/match_hakkede_tomater_30.json +1045 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/match_hvidloeg.json +388 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/match_makrel_tomat.json +451 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/meals_page.json +362 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/offers_page.json +1678 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/order_details.json +6 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/order_details_flex.json +106 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/order_history.json +9 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/order_history_flex.json +34 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/recipe_details.json +380 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/recipe_products.json +2090 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/recipe_search.json +113 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/recipes_by_tag.json +1483 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/search.json +43 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/search_gift.json +67 -0
- handle_mcp-1.5.1/tests/fixtures/nemlig/search_kyllingebryst.json +772 -0
- handle_mcp-1.5.1/tests/live_list.json +55 -0
- handle_mcp-1.5.1/tests/snapshots/tools.json +526 -0
- handle_mcp-1.5.1/tests/test_badges.py +42 -0
- handle_mcp-1.5.1/tests/test_bilkatogo.py +481 -0
- handle_mcp-1.5.1/tests/test_bilkatogo_recipes.py +190 -0
- handle_mcp-1.5.1/tests/test_cli.py +234 -0
- handle_mcp-1.5.1/tests/test_compare.py +1032 -0
- handle_mcp-1.5.1/tests/test_http.py +72 -0
- handle_mcp-1.5.1/tests/test_links.py +56 -0
- handle_mcp-1.5.1/tests/test_live.py +178 -0
- handle_mcp-1.5.1/tests/test_live_list.py +194 -0
- handle_mcp-1.5.1/tests/test_nemlig.py +787 -0
- handle_mcp-1.5.1/tests/test_nemlig_recipes.py +242 -0
- handle_mcp-1.5.1/tests/test_privacy.py +210 -0
- handle_mcp-1.5.1/tests/test_protocol.py +113 -0
- handle_mcp-1.5.1/tests/test_recipe_cart.py +182 -0
- handle_mcp-1.5.1/tests/test_safety.py +143 -0
- handle_mcp-1.5.1/tests/test_smoke.py +42 -0
- handle_mcp-1.5.1/tests/test_telemetry.py +335 -0
- handle_mcp-1.5.1/tests/test_tools.py +220 -0
- handle_mcp-1.5.1/uv.lock +960 -0
|
@@ -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,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.
|
handle_mcp-1.5.1/LICENSE
ADDED
|
@@ -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>
|