pse-edge-mcp 0.16.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.
Files changed (111) hide show
  1. pse_edge_mcp-0.16.0/.dockerignore +18 -0
  2. pse_edge_mcp-0.16.0/.env.example +106 -0
  3. pse_edge_mcp-0.16.0/.github/workflows/ci.yml +71 -0
  4. pse_edge_mcp-0.16.0/.github/workflows/release.yml +308 -0
  5. pse_edge_mcp-0.16.0/.gitignore +18 -0
  6. pse_edge_mcp-0.16.0/CHANGELOG.md +407 -0
  7. pse_edge_mcp-0.16.0/CLAUDE.md +242 -0
  8. pse_edge_mcp-0.16.0/Caddyfile +34 -0
  9. pse_edge_mcp-0.16.0/Dockerfile +54 -0
  10. pse_edge_mcp-0.16.0/LICENSE +21 -0
  11. pse_edge_mcp-0.16.0/PKG-INFO +385 -0
  12. pse_edge_mcp-0.16.0/README.md +358 -0
  13. pse_edge_mcp-0.16.0/alembic.ini +40 -0
  14. pse_edge_mcp-0.16.0/compose.nas.yaml +289 -0
  15. pse_edge_mcp-0.16.0/compose.prod.yaml +250 -0
  16. pse_edge_mcp-0.16.0/compose.tunnel.yaml +60 -0
  17. pse_edge_mcp-0.16.0/compose.yaml +78 -0
  18. pse_edge_mcp-0.16.0/docs/deploy.md +511 -0
  19. pse_edge_mcp-0.16.0/docs/endpoints.md +246 -0
  20. pse_edge_mcp-0.16.0/docs/plan.md +267 -0
  21. pse_edge_mcp-0.16.0/docs/reference-card.html +735 -0
  22. pse_edge_mcp-0.16.0/docs/walkthrough.md +1071 -0
  23. pse_edge_mcp-0.16.0/docs/walkthrough.pdf +0 -0
  24. pse_edge_mcp-0.16.0/examples/langgraph_client.py +209 -0
  25. pse_edge_mcp-0.16.0/migrations/env.py +62 -0
  26. pse_edge_mcp-0.16.0/migrations/script.py.mako +25 -0
  27. pse_edge_mcp-0.16.0/migrations/versions/0001_initial_schema.py +79 -0
  28. pse_edge_mcp-0.16.0/migrations/versions/0002_auth_users_tokens.py +58 -0
  29. pse_edge_mcp-0.16.0/migrations/versions/0003_oauth_passkeys.py +100 -0
  30. pse_edge_mcp-0.16.0/migrations/versions/0004_usage_events.py +39 -0
  31. pse_edge_mcp-0.16.0/migrations/versions/0005_machine_clients.py +40 -0
  32. pse_edge_mcp-0.16.0/pyproject.toml +79 -0
  33. pse_edge_mcp-0.16.0/scripts/check_image.py +207 -0
  34. pse_edge_mcp-0.16.0/scripts/render_doc_pdf.py +67 -0
  35. pse_edge_mcp-0.16.0/server.json +27 -0
  36. pse_edge_mcp-0.16.0/src/pse_edge_mcp/__init__.py +3 -0
  37. pse_edge_mcp-0.16.0/src/pse_edge_mcp/__main__.py +113 -0
  38. pse_edge_mcp-0.16.0/src/pse_edge_mcp/accounts.py +196 -0
  39. pse_edge_mcp-0.16.0/src/pse_edge_mcp/admin.py +485 -0
  40. pse_edge_mcp-0.16.0/src/pse_edge_mcp/archive.py +49 -0
  41. pse_edge_mcp-0.16.0/src/pse_edge_mcp/archive_postgres.py +91 -0
  42. pse_edge_mcp-0.16.0/src/pse_edge_mcp/asgi.py +253 -0
  43. pse_edge_mcp-0.16.0/src/pse_edge_mcp/auth.py +190 -0
  44. pse_edge_mcp-0.16.0/src/pse_edge_mcp/auth_app.py +1228 -0
  45. pse_edge_mcp-0.16.0/src/pse_edge_mcp/auth_middleware.py +138 -0
  46. pse_edge_mcp-0.16.0/src/pse_edge_mcp/auth_store.py +69 -0
  47. pse_edge_mcp-0.16.0/src/pse_edge_mcp/cache.py +41 -0
  48. pse_edge_mcp-0.16.0/src/pse_edge_mcp/canary.py +316 -0
  49. pse_edge_mcp-0.16.0/src/pse_edge_mcp/client.py +284 -0
  50. pse_edge_mcp-0.16.0/src/pse_edge_mcp/config.py +132 -0
  51. pse_edge_mcp-0.16.0/src/pse_edge_mcp/db.py +255 -0
  52. pse_edge_mcp-0.16.0/src/pse_edge_mcp/email.py +89 -0
  53. pse_edge_mcp-0.16.0/src/pse_edge_mcp/errors.py +94 -0
  54. pse_edge_mcp-0.16.0/src/pse_edge_mcp/health.py +89 -0
  55. pse_edge_mcp-0.16.0/src/pse_edge_mcp/logging_config.py +145 -0
  56. pse_edge_mcp-0.16.0/src/pse_edge_mcp/market_calendar.py +91 -0
  57. pse_edge_mcp-0.16.0/src/pse_edge_mcp/memo.py +70 -0
  58. pse_edge_mcp-0.16.0/src/pse_edge_mcp/models.py +360 -0
  59. pse_edge_mcp-0.16.0/src/pse_edge_mcp/notifications.py +141 -0
  60. pse_edge_mcp-0.16.0/src/pse_edge_mcp/oauth.py +758 -0
  61. pse_edge_mcp-0.16.0/src/pse_edge_mcp/parsers.py +752 -0
  62. pse_edge_mcp-0.16.0/src/pse_edge_mcp/passkeys.py +424 -0
  63. pse_edge_mcp-0.16.0/src/pse_edge_mcp/ratelimit.py +137 -0
  64. pse_edge_mcp-0.16.0/src/pse_edge_mcp/repositories.py +623 -0
  65. pse_edge_mcp-0.16.0/src/pse_edge_mcp/server.py +646 -0
  66. pse_edge_mcp-0.16.0/src/pse_edge_mcp/service.py +208 -0
  67. pse_edge_mcp-0.16.0/src/pse_edge_mcp/sources.py +79 -0
  68. pse_edge_mcp-0.16.0/src/pse_edge_mcp/storage_postgres.py +77 -0
  69. pse_edge_mcp-0.16.0/src/pse_edge_mcp/usage.py +139 -0
  70. pse_edge_mcp-0.16.0/src/pse_edge_mcp/usage_postgres.py +56 -0
  71. pse_edge_mcp-0.16.0/src/pse_edge_mcp/validation.py +97 -0
  72. pse_edge_mcp-0.16.0/tests/conftest.py +178 -0
  73. pse_edge_mcp-0.16.0/tests/fixtures/announcements_empty.html +56 -0
  74. pse_edge_mcp-0.16.0/tests/fixtures/announcements_search.html +418 -0
  75. pse_edge_mcp-0.16.0/tests/fixtures/announcements_short_page.html +311 -0
  76. pse_edge_mcp-0.16.0/tests/fixtures/autocomplete.json +1 -0
  77. pse_edge_mcp-0.16.0/tests/fixtures/chart.json +1 -0
  78. pse_edge_mcp-0.16.0/tests/fixtures/company_disclosures_last_page.html +361 -0
  79. pse_edge_mcp-0.16.0/tests/fixtures/company_disclosures_search.html +410 -0
  80. pse_edge_mcp-0.16.0/tests/fixtures/company_profile.html +300 -0
  81. pse_edge_mcp-0.16.0/tests/fixtures/disclosure_viewer.html +115 -0
  82. pse_edge_mcp-0.16.0/tests/fixtures/dividends.html +72 -0
  83. pse_edge_mcp-0.16.0/tests/fixtures/financial_reports.html +461 -0
  84. pse_edge_mcp-0.16.0/tests/fixtures/homepage.html +831 -0
  85. pse_edge_mcp-0.16.0/tests/fixtures/keyword_search.html +168 -0
  86. pse_edge_mcp-0.16.0/tests/fixtures/rights_empty.html +62 -0
  87. pse_edge_mcp-0.16.0/tests/fixtures/stock_data.html +30 -0
  88. pse_edge_mcp-0.16.0/tests/test_admin_postgres.py +129 -0
  89. pse_edge_mcp-0.16.0/tests/test_auth.py +251 -0
  90. pse_edge_mcp-0.16.0/tests/test_auth_journey.py +1226 -0
  91. pse_edge_mcp-0.16.0/tests/test_auth_middleware.py +126 -0
  92. pse_edge_mcp-0.16.0/tests/test_calendar.py +56 -0
  93. pse_edge_mcp-0.16.0/tests/test_canary.py +209 -0
  94. pse_edge_mcp-0.16.0/tests/test_cli.py +171 -0
  95. pse_edge_mcp-0.16.0/tests/test_client.py +97 -0
  96. pse_edge_mcp-0.16.0/tests/test_company_market.py +343 -0
  97. pse_edge_mcp-0.16.0/tests/test_deploy.py +446 -0
  98. pse_edge_mcp-0.16.0/tests/test_disclosures.py +310 -0
  99. pse_edge_mcp-0.16.0/tests/test_end_to_end.py +296 -0
  100. pse_edge_mcp-0.16.0/tests/test_notifications.py +169 -0
  101. pse_edge_mcp-0.16.0/tests/test_oauth.py +466 -0
  102. pse_edge_mcp-0.16.0/tests/test_packaging.py +29 -0
  103. pse_edge_mcp-0.16.0/tests/test_parsers.py +54 -0
  104. pse_edge_mcp-0.16.0/tests/test_privacy.py +298 -0
  105. pse_edge_mcp-0.16.0/tests/test_repositories.py +505 -0
  106. pse_edge_mcp-0.16.0/tests/test_scaling.py +308 -0
  107. pse_edge_mcp-0.16.0/tests/test_server_disclosures.py +409 -0
  108. pse_edge_mcp-0.16.0/tests/test_service.py +242 -0
  109. pse_edge_mcp-0.16.0/tests/test_storage_postgres.py +358 -0
  110. pse_edge_mcp-0.16.0/tests/test_validation.py +103 -0
  111. pse_edge_mcp-0.16.0/uv.lock +1347 -0
@@ -0,0 +1,18 @@
1
+ # Keep the build context minimal and secret-free.
2
+ .git
3
+ .github
4
+ .env*
5
+ !.env.example
6
+ tests
7
+ docs
8
+ **/__pycache__
9
+ **/*.pyc
10
+ .venv
11
+ .pytest_cache
12
+ .mypy_cache
13
+ .ruff_cache
14
+ compose*.yaml
15
+ Dockerfile
16
+ CHANGELOG.md
17
+ *.md
18
+ !README.md
@@ -0,0 +1,106 @@
1
+ # Copy to .env for docker compose. NEVER commit .env.
2
+ POSTGRES_PASSWORD=change-me
3
+ # DATABASE_URL is set automatically inside compose; for bare-metal runs:
4
+ # DATABASE_URL=postgresql+asyncpg://pse:change-me@localhost:5432/pse_edge
5
+
6
+ # Market session overrides (defaults shown)
7
+ # PSE_MARKET_OPEN=09:30
8
+ # PSE_MARKET_CLOSE=15:00
9
+ # PSE_THROTTLE_RPS=1.0
10
+
11
+ # Auth — opt-in, requires DATABASE_URL. Users self-serve via OAuth 2.1 + passkeys; the CLI
12
+ # is for clients that do not speak OAuth, and is the only route on a plain-http deployment.
13
+ # Provision accounts with: pse-edge-admin create-user EMAIL && pse-edge-admin issue-token EMAIL
14
+ # PSE_AUTH_REQUIRED=1
15
+ # PSE_TOKEN_CACHE_TTL=60 # seconds; this is the revocation-latency budget, nothing else
16
+ # PSE_QUOTA_PER_MIN=60
17
+ # PSE_QUOTA_PER_DAY=2000
18
+
19
+ # DB connection pool (auth adds a potential lookup per request; tune under load)
20
+ # PSE_DB_POOL_SIZE=5
21
+ # PSE_DB_MAX_OVERFLOW=10
22
+
23
+ # Email (signup verification). The ZeptoMail key arrives at runtime only, never committed.
24
+ # ZEPTOMAIL_API_KEY=
25
+ #
26
+ # PSE_EMAIL_FROM must be an address on a domain ZeptoMail has VERIFIED, and it is the most
27
+ # common thing to get wrong. ZeptoMail verifies EXACT domains: a verified example.com does
28
+ # NOT cover mcp.example.com. Both compose files default this to no-reply@$PSE_DOMAIN, which
29
+ # is therefore wrong whenever your hostname is a subdomain. An unverified sender is refused
30
+ # with a bare HTTP 500 and an empty body, so signup fails in a way that looks like the
31
+ # server is broken rather than misconfigured. Test it before trusting it:
32
+ # curl -sS -X POST https://api.zeptomail.com/v1.1/email \
33
+ # -H "Authorization: Zoho-enczapikey $ZEPTOMAIL_API_KEY" \
34
+ # -H 'Content-Type: application/json' \
35
+ # -d '{"from":{"address":"noreply@example.com"},
36
+ # "to":[{"email_address":{"address":"you@example.com"}}],
37
+ # "subject":"test","htmlbody":"<p>test</p>"}'
38
+ # PSE_EMAIL_FROM=noreply@example.com
39
+ #
40
+ # Where the nightly schema canary reports failures. It emails ONLY on failure — a nightly
41
+ # "all fine" message is filtered within a week, and a filtered alert feels like coverage
42
+ # while providing none. Unset, the canary still runs and still logs, but nobody is told.
43
+ # PSE_OPERATOR_EMAIL=ops@example.com
44
+ #
45
+ # Accounts allowed to provision machine clients (headless/agent credentials) from the
46
+ # /account web page — the way to grant agent access on a NAS with no shell. Comma-separated,
47
+ # matched against the signed-in account's email. This is the SAME authority as the
48
+ # pse-edge-admin CLI: keep it to yourself. Empty = web provisioning off, CLI only.
49
+ # PSE_ADMIN_EMAILS=you@example.com
50
+ #
51
+ # PSE_PUBLIC_URL=https://mcp.example.com # drives WebAuthn rp_id, email links, OAuth issuer
52
+ # PSE_USAGE_RETENTION_DAYS=90 # the privacy page states 90 — changing this
53
+ # # changes what users were told
54
+
55
+ # --- VPS or host reachable on 80/443 (compose.yaml + compose.prod.yaml) — docs/deploy.md ---
56
+ # Caddy publishes high by default (8280/8243) to stay clear of a NAS's own web UI. Only the
57
+ # published side moves — Caddy still listens on 80/443 inside the container. YOUR ROUTER MUST
58
+ # FORWARD 80 -> 8280 AND 443 -> 8243, because certificate authorities always validate on 80
59
+ # (HTTP-01) or 443 (TLS-ALPN-01) and no setting changes that. If you cannot forward, use the
60
+ # NAS + tunnel path below, which needs no inbound ports at all.
61
+ # PSE_HTTP_PORT=8280
62
+ # PSE_HTTPS_PORT=8243
63
+ # PSE_DOMAIN must be the real public hostname: it becomes PSE_PUBLIC_URL, which drives the
64
+ # WebAuthn rp_id, the email links and the OAuth issuer. Wrong value = passkeys silently
65
+ # break, because credentials are bound to the origin they were enrolled under.
66
+ # PSE_DOMAIN=mcp.example.com
67
+ # PSE_ACME_EMAIL=ops@example.com
68
+ # PSE_IMAGE_TAG=0.11.0 # PIN THIS — production pulls, it does not build.
69
+ # BACKUP_RETENTION_DAYS=14
70
+ # PSE_LOG_JSON=1
71
+ # PSE_LOG_LEVEL=INFO
72
+
73
+ # --- NAS stage 1: LAN only (compose.nas.yaml) — see docs/deploy.md ---
74
+ # Needs nothing from Cloudflare. Reachable at http://<nas-ip>:8200; auth is on, and passkeys
75
+ # cannot work over plain http, so mint a token with `pse-edge-admin issue-token`.
76
+ # PSE_IMAGE_TAG=0.11.0 # PIN THIS. :latest may predate a flag this file uses.
77
+ # PSE_LAN_PORT=8200 # host side only; the app always listens on 8000 inside
78
+ # PSE_WORKERS=1 # each worker multiplies the in-memory quota ceiling
79
+ # APP_MEMORY_LIMIT=768m
80
+ # DB_MEMORY_LIMIT=1g
81
+ #
82
+ # Put the database and its dumps on NAS directories you can see and snapshot. Create both
83
+ # first — Docker will not, and the mount error does not name the missing path. Keep backups
84
+ # OUTSIDE the database directory: a backup stored inside the thing it protects is not one.
85
+ #
86
+ # No chown needed. Postgres runs as uid 999 and a bind mount would otherwise arrive
87
+ # root-owned, crash-looping on "mkdir: cannot create directory '/var/lib/postgresql':
88
+ # Permission denied" — so a `pgdata-owner` container fixes the ownership before the
89
+ # database starts. That matters on a NAS UI, which may give you no shell at all.
90
+ #
91
+ # PGDATA must be the NAS's OWN internal volume, never SMB/NFS/USB: Postgres needs POSIX
92
+ # locking and honest fsync. Dumps are just files, so a network share is fine for backups.
93
+ # SETTING PSE_PGDATA_PATH ON AN EXISTING DEPLOYMENT STARTS AN EMPTY DATABASE — dump first,
94
+ # see docs/deploy.md "Moving the database onto a NAS share".
95
+ # PSE_PGDATA_PATH=/volume1/docker/pse-mcp/pgdata
96
+ # PSE_BACKUP_PATH=/volume1/docker/pse-mcp/backups
97
+
98
+ # --- NAS stage 2: public hostname (+ compose.tunnel.yaml) — see docs/deploy.md ---
99
+ # cloudflared dials out; Cloudflare terminates TLS, so no ACME here. Set PSE_LAN_BIND to
100
+ # close the stage 1 LAN port — an overlay cannot remove a port, because Compose merges
101
+ # `ports` additively, so binding it to loopback is how it stops being reachable.
102
+ # PSE_LAN_BIND=127.0.0.1
103
+ # PSE_DOMAIN=mcp.example.com
104
+ # Set PSE_EMAIL_FROM in the Email section above — signup goes live at this stage, and the
105
+ # default derived from PSE_DOMAIN is wrong if this hostname is a subdomain.
106
+ # CLOUDFLARE_TUNNEL_TOKEN= # Zero Trust -> Networks -> Tunnels
@@ -0,0 +1,71 @@
1
+ name: CI
2
+
3
+ on:
4
+ # develop is the integration branch; main is covered by release.yml, which reruns
5
+ # these same gates before it publishes anything.
6
+ push:
7
+ branches: [develop]
8
+ pull_request:
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v7
15
+ - uses: astral-sh/setup-uv@v9.0.0
16
+ with:
17
+ python-version: "3.14"
18
+ - run: uv sync --all-extras
19
+ - run: uv run ruff check .
20
+ - run: uv run pytest -q
21
+
22
+ # Both published architectures are gated here, on native runners (free for public
23
+ # repos), so arm64 breakage is caught at review time rather than at publish time —
24
+ # otherwise a green PR could merge and then leave main with no image at all.
25
+ # `name:` is pinned so the required-check contexts stay exactly "image (amd64)" and
26
+ # "image (arm64)"; without it GitHub would name them after every matrix value.
27
+ image:
28
+ name: image (${{ matrix.arch }})
29
+ strategy:
30
+ fail-fast: false
31
+ matrix:
32
+ include:
33
+ - arch: amd64
34
+ runner: ubuntu-latest
35
+ - arch: arm64
36
+ runner: ubuntu-24.04-arm
37
+ runs-on: ${{ matrix.runner }}
38
+ steps:
39
+ - uses: actions/checkout@v7
40
+ - uses: astral-sh/setup-uv@v9.0.0
41
+ with:
42
+ python-version: "3.14"
43
+ # Each runner is native for its own arch, so a plain build needs no buildx or QEMU.
44
+ - name: Build image
45
+ run: docker build -t pse-edge-mcp:ci .
46
+ # Enforces necessity, not a megabyte number: installed distributions must equal
47
+ # the resolved runtime closure, with no toolchain, package manager, dev deps,
48
+ # caches, or stray source tree. Size is reported for information only.
49
+ - name: Check image contains only what the app needs
50
+ run: |
51
+ # pipefail matters: without it the pipeline returns tee's status and a failed
52
+ # check would pass silently.
53
+ set -eo pipefail
54
+ uv sync --all-extras --quiet
55
+ uv run python scripts/check_image.py pse-edge-mcp:ci --extra postgres --extra auth | tee -a "$GITHUB_STEP_SUMMARY"
56
+ - name: Scan image for secrets & vulns
57
+ uses: aquasecurity/trivy-action@v0.36.0
58
+ with:
59
+ image-ref: pse-edge-mcp:ci
60
+ scanners: secret
61
+ exit-code: "1"
62
+ - name: Smoke-test the image
63
+ run: |
64
+ ARCH=$(docker image inspect pse-edge-mcp:ci --format '{{.Architecture}}')
65
+ test "$ARCH" = "${{ matrix.arch }}"
66
+ TOOLS=$(docker run --rm --entrypoint python pse-edge-mcp:ci -c '
67
+ import asyncio
68
+ from pse_edge_mcp.server import build_server
69
+ print(len(asyncio.run(build_server().list_tools())))')
70
+ echo "Tools registered on $ARCH: $TOOLS"
71
+ test "$TOOLS" -ge 6
@@ -0,0 +1,308 @@
1
+ name: Release
2
+
3
+ # Merges to main publish a multi-arch container image to GHCR:
4
+ # docker pull ghcr.io/phdwight/pse-edge-mcp:latest # linux/amd64 + linux/arm64
5
+ #
6
+ # A merge that bumps `version` in pyproject.toml also cuts a GitHub Release and a
7
+ # matching immutable image tag; merges that don't bump only move :latest and :sha-*.
8
+ #
9
+ # Each architecture is built on its OWN NATIVE RUNNER (free for public repos) rather
10
+ # than emulated under QEMU, which keeps builds fast and — more importantly — lets the
11
+ # gates run natively per arch. Invariant #5 (thin, secret-free images) is enforced on
12
+ # every architecture BEFORE anything reaches the registry, so a bloated or broken
13
+ # arm64 image can't ride along behind a passing amd64 one.
14
+ #
15
+ # Publishing is two-phase: each arch pushes an untagged blob addressed by digest, then
16
+ # the merge job stitches those digests into one manifest list under the real tags. That
17
+ # way a tag never exists in a half-published state.
18
+
19
+ on:
20
+ push:
21
+ branches: [main]
22
+ workflow_dispatch: # lets you re-publish the current main on demand
23
+
24
+ permissions:
25
+ contents: write # create the release + tag
26
+ packages: write # push to GHCR
27
+
28
+ # Never let two main merges publish concurrently, and never cancel a half-done
29
+ # publish: an interrupted push can leave :latest pointing at nothing useful.
30
+ concurrency:
31
+ group: release-main
32
+ cancel-in-progress: false
33
+
34
+ env:
35
+ IMAGE: ghcr.io/${{ github.repository }}
36
+
37
+ jobs:
38
+ version:
39
+ runs-on: ubuntu-latest
40
+ outputs:
41
+ version: ${{ steps.version.outputs.version }}
42
+ bumped: ${{ steps.version.outputs.bumped }}
43
+ tags: ${{ steps.version.outputs.tags }}
44
+ steps:
45
+ - uses: actions/checkout@v7
46
+ with:
47
+ fetch-depth: 2 # need HEAD~1 to tell whether the version changed
48
+
49
+ - name: Resolve version and detect a bump
50
+ id: version
51
+ run: |
52
+ read_version() { grep -m1 '^version = ' "$1" | cut -d'"' -f2; }
53
+ VERSION=$(read_version pyproject.toml)
54
+ if [ -z "$VERSION" ]; then
55
+ echo "::error::could not read version from pyproject.toml"
56
+ exit 1
57
+ fi
58
+ git show HEAD~1:pyproject.toml > /tmp/prev-pyproject.toml 2>/dev/null || : > /tmp/prev-pyproject.toml
59
+ PREVIOUS=$(read_version /tmp/prev-pyproject.toml || true)
60
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
61
+ if [ "$VERSION" != "$PREVIOUS" ]; then
62
+ BUMPED=true
63
+ echo "Version bump: ${PREVIOUS:-none} -> $VERSION (will cut a release)"
64
+ else
65
+ BUMPED=false
66
+ echo "Version unchanged at $VERSION (no release; :latest still moves)"
67
+ fi
68
+ echo "bumped=$BUMPED" >> "$GITHUB_OUTPUT"
69
+ # Assembled here, not with an inline conditional at the point of use, so a
70
+ # no-bump build can never emit an empty tag.
71
+ TAGS="${IMAGE}:latest ${IMAGE}:sha-${GITHUB_SHA}"
72
+ if [ "$BUMPED" = "true" ]; then TAGS="$TAGS ${IMAGE}:${VERSION}"; fi
73
+ echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
74
+ echo "Tags: $TAGS"
75
+
76
+ - name: Fail if this release tag already exists
77
+ if: steps.version.outputs.bumped == 'true'
78
+ env:
79
+ GH_TOKEN: ${{ github.token }}
80
+ run: |
81
+ TAG="v${{ steps.version.outputs.version }}"
82
+ if gh release view "$TAG" >/dev/null 2>&1; then
83
+ echo "::error::release $TAG already exists — bump the version in pyproject.toml"
84
+ exit 1
85
+ fi
86
+
87
+ test:
88
+ runs-on: ubuntu-latest
89
+ steps:
90
+ - uses: actions/checkout@v7
91
+ - uses: astral-sh/setup-uv@v9.0.0
92
+ with:
93
+ python-version: "3.14"
94
+ - run: uv sync --all-extras
95
+ - run: uv run ruff check .
96
+ - run: uv run pytest -q
97
+
98
+ build:
99
+ needs: [test, version]
100
+ strategy:
101
+ fail-fast: false # report both arches' problems in one run, not just the first
102
+ matrix:
103
+ include:
104
+ - arch: amd64
105
+ platform: linux/amd64
106
+ runner: ubuntu-latest
107
+ - arch: arm64
108
+ platform: linux/arm64
109
+ runner: ubuntu-24.04-arm
110
+ runs-on: ${{ matrix.runner }}
111
+ steps:
112
+ - uses: actions/checkout@v7
113
+ - uses: astral-sh/setup-uv@v9.0.0
114
+ with:
115
+ python-version: "3.14"
116
+ - uses: docker/setup-buildx-action@v4
117
+
118
+ # Load into the local daemon first so the gates below run on the exact bits that
119
+ # get pushed. Native runner => no QEMU, and `docker run` works for the smoke test.
120
+ - name: Build ${{ matrix.platform }}
121
+ uses: docker/build-push-action@v7
122
+ with:
123
+ context: .
124
+ platforms: ${{ matrix.platform }}
125
+ load: true
126
+ tags: pse-edge-mcp:candidate
127
+ cache-from: type=gha,scope=${{ matrix.arch }}
128
+ cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
129
+
130
+ # Enforces necessity, not a megabyte number — see scripts/check_image.py.
131
+ - name: Check image contains only what the app needs
132
+ run: |
133
+ # pipefail matters: without it the pipeline returns tee's status and a failed
134
+ # check would pass silently.
135
+ set -eo pipefail
136
+ uv sync --all-extras --quiet
137
+ {
138
+ echo "### \`${{ matrix.arch }}\` image"
139
+ echo '```'
140
+ uv run python scripts/check_image.py pse-edge-mcp:candidate --extra postgres --extra auth
141
+ echo '```'
142
+ } | tee -a "$GITHUB_STEP_SUMMARY"
143
+
144
+ - name: Scan image for secrets
145
+ uses: aquasecurity/trivy-action@v0.36.0
146
+ with:
147
+ image-ref: pse-edge-mcp:candidate
148
+ scanners: secret
149
+ exit-code: "1"
150
+
151
+ - name: Smoke-test the image
152
+ run: |
153
+ # A published image that cannot build the server is worse than no publish.
154
+ ARCH=$(docker image inspect pse-edge-mcp:candidate --format '{{.Architecture}}')
155
+ echo "Image architecture: $ARCH (expected ${{ matrix.arch }})"
156
+ test "$ARCH" = "${{ matrix.arch }}"
157
+ TOOLS=$(docker run --rm --entrypoint python pse-edge-mcp:candidate -c '
158
+ import asyncio
159
+ from pse_edge_mcp.server import build_server
160
+ print(len(asyncio.run(build_server().list_tools())))')
161
+ echo "Tools registered: $TOOLS"
162
+ test "$TOOLS" -ge 6
163
+
164
+ - name: Log in to GHCR
165
+ uses: docker/login-action@v4
166
+ with:
167
+ registry: ghcr.io
168
+ username: ${{ github.actor }}
169
+ password: ${{ github.token }}
170
+
171
+ # Push untagged, addressed only by digest. The merge job gives these real tags.
172
+ # provenance/sbom off so the manifest list holds exactly one entry per arch.
173
+ - name: Push ${{ matrix.platform }} by digest
174
+ id: push
175
+ uses: docker/build-push-action@v7
176
+ with:
177
+ context: .
178
+ platforms: ${{ matrix.platform }}
179
+ provenance: false
180
+ sbom: false
181
+ outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
182
+ cache-from: type=gha,scope=${{ matrix.arch }}
183
+
184
+ - name: Export digest
185
+ run: |
186
+ DIGEST="${{ steps.push.outputs.digest }}"
187
+ if [ -z "$DIGEST" ]; then
188
+ echo "::error::push produced no digest"
189
+ exit 1
190
+ fi
191
+ echo "Pushed ${{ matrix.arch }} as $DIGEST"
192
+ mkdir -p /tmp/digests
193
+ touch "/tmp/digests/${DIGEST#sha256:}"
194
+
195
+ - uses: actions/upload-artifact@v7
196
+ with:
197
+ name: digest-${{ matrix.arch }}
198
+ path: /tmp/digests/*
199
+ if-no-files-found: error
200
+ retention-days: 1
201
+
202
+ pypi:
203
+ # Publishes the sdist+wheel on every version bump via PyPI Trusted Publishing
204
+ # (OIDC — no long-lived secret in this repo). One-time setup on pypi.org: add a
205
+ # GitHub publisher for project pse-edge-mcp (owner phdwight, repo pse-edge-mcp,
206
+ # workflow release.yml, environment pypi). Until that exists this job fails at
207
+ # the upload step — visibly, without blocking the image release (nothing needs it).
208
+ needs: [test, version]
209
+ if: needs.version.outputs.bumped == 'true'
210
+ runs-on: ubuntu-latest
211
+ environment: pypi
212
+ permissions:
213
+ contents: read
214
+ id-token: write # OIDC token for Trusted Publishing
215
+ steps:
216
+ - uses: actions/checkout@v7
217
+ - uses: astral-sh/setup-uv@v9.0.0
218
+ - run: uv build
219
+ - uses: pypa/gh-action-pypi-publish@release/v1
220
+
221
+ merge:
222
+ needs: [build, version]
223
+ runs-on: ubuntu-latest
224
+ steps:
225
+ - uses: actions/download-artifact@v8
226
+ with:
227
+ path: /tmp/digests
228
+ pattern: digest-*
229
+ merge-multiple: true
230
+
231
+ - uses: docker/setup-buildx-action@v4
232
+ - name: Log in to GHCR
233
+ uses: docker/login-action@v4
234
+ with:
235
+ registry: ghcr.io
236
+ username: ${{ github.actor }}
237
+ password: ${{ github.token }}
238
+
239
+ - name: Create the multi-arch manifest list
240
+ env:
241
+ TAGS: ${{ needs.version.outputs.tags }}
242
+ run: |
243
+ cd /tmp/digests
244
+ COUNT=$(ls -1 | wc -l)
245
+ echo "Digests to combine: $COUNT"
246
+ test "$COUNT" -eq 2 # one per architecture; fewer means an arch silently dropped
247
+ TAG_ARGS=""
248
+ for t in $TAGS; do TAG_ARGS="$TAG_ARGS -t $t"; done
249
+ # shellcheck disable=SC2086
250
+ docker buildx imagetools create $TAG_ARGS \
251
+ $(printf "${IMAGE}@sha256:%s " *)
252
+
253
+ - name: Verify both architectures are published
254
+ run: |
255
+ PLATFORMS=$(docker buildx imagetools inspect "${IMAGE}:latest" --format '{{json .Manifest}}' \
256
+ | jq -r '.manifests[]? | select(.platform.architecture != "unknown")
257
+ | .platform.os + "/" + .platform.architecture' | sort -u)
258
+ echo "Published platforms:"; echo "$PLATFORMS"
259
+ echo "$PLATFORMS" | grep -qx "linux/amd64" || { echo "::error::linux/amd64 missing"; exit 1; }
260
+ echo "$PLATFORMS" | grep -qx "linux/arm64" || { echo "::error::linux/arm64 missing"; exit 1; }
261
+
262
+ - name: Create release
263
+ if: needs.version.outputs.bumped == 'true'
264
+ env:
265
+ GH_TOKEN: ${{ github.token }}
266
+ VERSION: ${{ needs.version.outputs.version }}
267
+ run: |
268
+ gh release create "v${VERSION}" \
269
+ --repo "${{ github.repository }}" \
270
+ --target "${{ github.sha }}" \
271
+ --title "v${VERSION}" \
272
+ --notes "$(cat <<EOF
273
+ Multi-arch container image (\`linux/amd64\`, \`linux/arm64\`) for v${VERSION}:
274
+
275
+ \`\`\`bash
276
+ docker pull ${IMAGE}:${VERSION}
277
+ \`\`\`
278
+
279
+ Also published as \`${IMAGE}:latest\`.
280
+
281
+ Serve streamable HTTP on :8000 (the image default):
282
+
283
+ \`\`\`bash
284
+ docker run --rm -p 8000:8000 ${IMAGE}:${VERSION}
285
+ \`\`\`
286
+
287
+ Or stdio, for Claude Desktop/Code (\`--entrypoint\` clears the default HTTP args):
288
+
289
+ \`\`\`bash
290
+ docker run --rm -i --entrypoint pse-edge-mcp ${IMAGE}:${VERSION}
291
+ \`\`\`
292
+
293
+ See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/v${VERSION}/CHANGELOG.md) for what changed.
294
+ EOF
295
+ )"
296
+
297
+ - name: Summarise the download
298
+ run: |
299
+ {
300
+ echo "## Published (linux/amd64 + linux/arm64)"
301
+ echo
302
+ echo '```bash'
303
+ echo "docker pull ${IMAGE}:latest"
304
+ if [ "${{ needs.version.outputs.bumped }}" = "true" ]; then
305
+ echo "docker pull ${IMAGE}:${{ needs.version.outputs.version }}"
306
+ fi
307
+ echo '```'
308
+ } >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .env
5
+ .env.*
6
+ !.env.example
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ dist/
11
+ *.egg-info/
12
+
13
+ # Operator-local files that must never enter public history. ugreen.yaml is the
14
+ # operator's personal compose fork and carries a tunnel token inline; a blanket
15
+ # `git add -A` once swept it into pushed commits, forcing a token rotation and a
16
+ # history rewrite of develop. Never commit these.
17
+ ugreen.yaml
18
+ .DS_Store