Skip to main content
Glama
cpy-pst-gmbh

bring-mcp

by cpy-pst-gmbh

bring-mcp

MCP server that writes items to a Bring! shopping list. Meant as a custom connector for Claude: recipe ingredients land on the list instead of being copied by hand.

Built on bring-api (unofficial, not supported by Bring! Labs AG) and FastMCP.

SaaS

We have a production system that makes it easy to use the connector: https://bring.app.cpy-pst.de

However, the password for Bring must be saved. If you don't want to trust us, you should install it yourself.

Related MCP server: @mealmastery/mcp-server

Layout

Path

Contents

mcp/

Python MCP server (resource server)

app/

Symfony app — OAuth2 authorization server and web UI

Each half has its own Dockerfile. GitHub Actions builds both on every push to main and pushes them to ghcr.io/<owner>/<repo>/app and .../mcp.

How the two halves fit together

Users sign in to the Symfony app with their Bring! credentials — there is no separate account and no sign-up. Symfony checks the password with Bring! itself, creates the account on first sign-in, and keeps an encrypted copy of the password so the connector can act on the user's behalf.

When Claude connects, it runs an authorization code flow with PKCE against Symfony and receives an access token. The MCP server verifies that token against Symfony's JWKS and then asks Symfony — over the internal network, with the user's own token — for that user's Bring! credentials. The credentials are never part of the token, so they never travel through Anthropic.

If Bring! rejects the password or cannot be reached, the login page offers a one-time sign-in link by email, which leads to the account page where the stored password can be corrected.

Claude ──► https://bring.example.de/authorize   (sign in, consent)
       ──► https://bring.example.de/token       (code + PKCE → access token)
       ──► https://bring-mcp.example.de/mcp     (Bearer token)
                     │
                     └──► http://symfony/internal/bring-credentials

Tools

Tool

Purpose

add_items

Add items, optionally with a quantity and a target list

get_shopping_list

Show open and completed items

list_shopping_lists

Name the available lists

complete_item

Tick an item off

Without an explicit list, tools write to the list chosen for that connector, falling back to the account's first one.

Deploying

The server needs two files and nothing else — no checkout, no build step. Both images are public, so no registry login either.

1. Put the stack somewhere

Copy deploy/docker-compose.yml and deploy/.env.example from this repository into a directory on the server, for example /opt/bring-connector, then:

cp .env.example .env

2. Fill in the secrets

docker run --rm --entrypoint php ghcr.io/cpy-pst-gmbh/bring-api-mcp-connector/app:latest bin/console app:credentials:generate-key

--entrypoint php skips the startup checks — one of them refuses to run without the very key this command produces.

APP_SECRET, OAUTH_PASSPHRASE and OAUTH_ENCRYPTION_KEY are any random strings, openssl rand -hex 16 each. BASE_URL and MCP_BASE_URL are the two public HTTPS addresses.

Set MAILER_DSN to a real transport. The sign-in link is the only way back in when Bring! is unreachable or someone changed their password there, and null://null discards it silently.

Pin IMAGE_TAG to a released tag rather than latest, so both halves always move together and a restart cannot pick up a different version.

3. Start it

docker compose pull && docker compose up -d

The first start generates the OAuth keypair into a volume and applies the migrations. Both containers publish to 127.0.0.1 only, so the reverse proxy is the sole way in:

curl -s localhost:8000/health.json

4. Point the proxy at them

deploy/apache-vhosts.conf.example holds both vhosts. Two things they have to get right:

  • ProxyPass /internal ! on the app vhost. That endpoint hands decrypted Bring! passwords to whoever holds a valid token. The MCP container reaches it over the compose network and needs no public route.

  • flushpackets=on for /mcp, otherwise the proxy buffers the open Streamable HTTP connection and Claude waits for a response that never arrives in one piece. mod_deflate buffers for the same reason, so /mcp is excluded from compression as well.

  • RequestHeader set X-Forwarded-Proto "https". mod_proxy_http forwards X-Forwarded-For and -Host but not -Proto, and without it Symfony writes http:// into the discovery documents and the sign-in links.

The MCP server takes no notice of forwarded headers at all — its metadata comes straight from BASE_URL and AUTH_SERVER_URL, so those have to carry the public addresses.

Both hostnames need real certificates — Anthropic calls the MCP endpoint from their own infrastructure and will not accept plain HTTP.

5. Check it end to end

Open BASE_URL, sign in with a Bring! account, create a connector, and put the endpoint and client ID into Claude as described below. /health should stay green throughout.

Updating

docker compose pull && docker compose up -d

Migrations run on start. The database and the keypair live in the app-data and app-jwt volumes and survive the replacement — a new keypair would invalidate every token Claude still holds.

The database file is bring.db. An installation that predates that name keeps its app.db sitting in the volume, unused: the app creates an empty bring.db beside it and starts from there. Accounts are recreated on the next sign-in, because signing in is what creates them — but existing connectors are not, and have to be added again in Claude. Delete the old file once you are satisfied nothing is missing.

Updating unattended

deploy/update.sh is the manual command above with the three things around it that make it safe to run while nobody is watching: a maintenance page, a backup taken before the migrations, and a health check that decides whether the page comes down again.

install -d /var/www/maintenance
install -m 644 deploy/maintenance/index.html /var/www/maintenance/
install -m 755 deploy/update.sh /opt/bring-connector/update.sh

Add the maintenance blocks from deploy/apache-vhosts.conf.example to both vhosts and reload Apache. While /var/www/maintenance/maintenance.flag exists, every request to either host gets a 503 with Retry-After; the app host serves the page above with it, the MCP host only the status line, which is all Claude acts on. The flag is the entire mechanism, so a window can also be opened by hand:

/opt/bring-connector/update.sh --on

A run pulls, and stops there unless something actually moved:

  1. Compare the image digests before and after the pull. Unchanged means no deployment, and the run is over.

  2. Check that all images carry the same org.opencontainers.image.revision. With IMAGE_TAG=latest the two build jobs push independently, and a run that lands between them would pair a new app with an old MCP server. A mismatch is skipped rather than deployed — the next run finds them matched.

  3. Raise the maintenance page and let in-flight requests finish.

  4. Back up the database, in the cron container, which owns that volume. A failure here aborts before anything is replaced: a migration is the one step in an update that can lose data, and the copy is worthless afterwards.

  5. docker compose up -d, then poll http://127.0.0.1:8000/health.json directly — through the proxy everything is a 503 at that moment. It covers both containers, because the app's health page calls the MCP server.

  6. Lower the page. If health never comes back the page stays up and the script exits non-zero. Migrations only go forwards, so there is no rollback to fall back to, and "in maintenance until somebody looks" beats "open and broken".

Roll back by pinning IMAGE_TAG in .env to an older sha-<commit> tag and pulling that — every build is published under one. A schema change is not undone by it.

Then a timer for it:

cp deploy/bring-update.{service,timer} /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now bring-update.timer

04:30 UTC, jittered by up to twenty minutes. That is after the cron container's own maintenance hour, so the nightly backup and prune have run, and restarting that container afterwards moves its next slot to tomorrow instead of skipping a day. journalctl -u bring-update.service holds the log of every run; a failed deploy is a failed unit, so OnFailure= in the service file is where mail belongs.

Where the new images come from

The unattended update is the last link of a chain that starts at Dependabot:

dependabot.yml        weekly PRs for composer, pip, both base images,
                          Playwright and the actions
  -> app-checks, mcp-checks, e2e     on the pull request
  -> merged by hand                  a push to main
  -> build-image.yml                 pushes app and mcp to GHCR
  -> bring-update.timer              pulls them onto the server that night

Merging is the only manual step, and nothing has to happen after it: the merge commit is a push to main, which is what build-image.yml triggers on. Watch it with gh run watch, or rebuild any time from the Actions tab — workflow_dispatch is there for it.

Both Dockerfiles pin a floating base tag, and upstream rebuilds those under the same name whenever a system library is patched. Dependabot reports a tag that changed, so a rebuilt base produces no pull request, no commit and therefore no image. build-image.yml runs on a weekly schedule for exactly that case, with pull: true so the base is resolved fresh. GitHub disables scheduled workflows in a repository that sees no activity for 60 days; the weekly dependency PRs keep this one awake, but it is worth knowing why the images would quietly stop refreshing.

Major bumps deserve reading rather than waving through — Symfony 9, or a new bring-api especially, since it is reverse-engineered and nothing but tools/check-bring-constants.py and the end-to-end suite stand between a changed endpoint and a connector that silently stops adding items.

Backing up

The cron container copies the database every night, before it prunes anything, and keeps the last seven copies:

docker compose exec cron ls -l /app/backups

VACUUM INTO takes a consistent copy through SQLite itself while the app keeps serving. Copying the file from outside does not: with WAL enabled the data lives in two files, and a copy of one without the other restores to whatever the last checkpoint left behind.

BACKUP_KEEP changes how many copies survive, MAINTENANCE_HOUR when they are taken. They land in the app-backups volume, which is deliberately not the one holding the database — replace it with a bind mount to have them written into a directory this machine already syncs off-site:

      - /srv/backups/bring:/app/backups

The database is the only part that cannot be recreated. A lost OAuth keypair costs every connector one reconnect; a lost BRING_CREDENTIALS_KEY makes every copy above unreadable, so keep it wherever the copies go.

To take one out by hand, or to run the copy off-schedule:

docker compose exec cron php bin/console app:database:backup
docker compose cp bring-cron:/app/backups ./backups

Restoring

Stop the app first — restoring under a running server means writing the file out from under open connections:

docker compose stop app cron
docker compose run --rm --entrypoint sh cron -c 'cp /app/backups/bring-20260101-040000.db /app/data/bring.db && rm -f /app/data/bring.db-wal /app/data/bring.db-shm'

The -wal and -shm files have to go with it. A leftover write-ahead log belongs to the database that was just replaced, and SQLite will happily replay it over the copy you restored.

docker compose start app cron

Adding it to Claude

Sign in at <BASE_URL>, create a connector, then in Claude: Settings → Connectors → Add Custom Connector<MCP_BASE_URL>/mcp, and enter the client ID under Advanced Settings. Claude discovers the authorization server from the MCP server's metadata, sends the user to Symfony to sign in, and lands on the consent screen.

Clients created from the console have no owner and stay invisible in the web UI — useful for an MCP Inspector client:

docker compose exec app php bin/console league:oauth2-server:create-client --public --redirect-uri="https://claude.ai/api/mcp/auth_callback" --grant-type=authorization_code --grant-type=refresh_token --scope=bring "Claude" claude-connector

PRIVACY_POLICY_URL and IMPRINT_URL each take one of two things:

Value

What happens

https://example.com/privacy

the footer links straight there

legal/privacy.md

the Markdown is rendered and served from /privacy

empty

no link at all

Anything starting with http:// or https:// is treated as an address elsewhere; everything else is a path, read relative to the application directory. The two routes exist either way and answer 404 while the matching variable names something else, so a typo shows up as a missing page rather than a missing feature.

The legal/ directory next to docker-compose.yml is mounted read-only into the container, which is where legal/privacy.md resolves. Documents are GitHub-flavoured Markdown — tables and autolinks included — and are re-read when the file changes, with no restart and no cache to clear.

MAIL_SIGNATURE takes a path in the same way and appends the rendered Markdown to the end of every outgoing email, separated by a rule. It is attached in the mailer rather than in a shared email template, so a message added later cannot go out unsigned by forgetting to extend something.

A file any of the three name but cannot be read fails the documents check on /health, naming the variable. Nothing else would say so: the footer link is simply left out, and the email simply goes unsigned.

docs/privacy-policy.skeleton.md inventories what this application actually stores and who else receives it, as a starting point. It is not legal advice and not a finished document.

Running from a checkout rather than the container, paths are relative to app/, so the same directory is ../legal/privacy.md.

Monitoring

/health reports the database (including write access), the MCP server, the OAuth keypair and the credential encryption key. It comes in two shapes:

URL

For

/health

a readable page

/health.json

a monitor

Both answer 200 when everything passes and 503 when any check fails, so an uptime check can watch the status code and ignore the body.

Reasons are deliberately terse — the full message goes to the log, because this endpoint needs no authentication. Set MCP_HEALTH_URL to reach the MCP container directly; otherwise it is derived from MCP_ENDPOINT.

Dormant accounts

An account holds a Bring! password. An abandoned one is a stored secret nobody is watching, so it does not stay forever:

Silence

What happens

11 months

an email says the account will be deleted in a month

12 months

the account, the stored password and every connector are deleted

Silence means no sign-in and no connector activity — the MCP server fetches credentials on every call, and each one counts as use. Signing in once resets the clock and withdraws an outstanding notice.

Both halves are one command, on purpose: nothing is deleted that was not warned a month earlier, and running the warning without the deletion — or the other way round — would break that promise.

The cron service in the compose file runs it. It is the same image as the app with the entrypoint replaced by a loop that wakes once a day, prunes accounts and clears expired OAuth tokens. Nothing to add to the host crontab, and no docker permissions to hand to a cron user. MAINTENANCE_HOUR moves the slot, whole hours UTC, default 4.

docker compose logs cron

The log names the time of the next run. To see what a run would do without doing it:

docker compose exec cron php bin/console app:accounts:prune-inactive --dry-run

The cron container deliberately does not migrate — the app container does that on start, and two processes migrating one SQLite file at once is a race.

A notice is only recorded as sent once the mail transport accepted it. With a broken MAILER_DSN the account comes up again on the next run rather than being deleted over a warning nobody received, so no account is ever removed without notice — including on an instance where this command had never run.

Security notes

  • The MCP server must be reachable from the internet — Anthropic calls it from their own infrastructure, not from your device. mTLS at the proxy therefore does not work.

  • /internal/bring-credentials belongs on the Docker network only.

  • Bring! passwords are stored encrypted with libsodium secretbox. They have to be recoverable in plaintext because Bring! has no token login, so the key is as sensitive as the database. There is no local password to leak: the only credential this app knows is the Bring! one. Keep it in the secrets vault, not next to the SQLite file, and back both up.

  • There is no official Bring! API. If Bring changes its endpoints, the server breaks. Not a setup for critical dependencies.

Local development

MCP server

python -m venv .venv && .venv/bin/pip install -r mcp/requirements.txt
set -a && source .env && set +a && .venv/bin/python mcp/server.py

The MCP Inspector is a good way to test without Claude.

Symfony app

The committed app/.env holds no secrets — every slot for one is empty, and the environment fills them in. For a checkout that means writing them into app/.env.local, which is ignored by git and left out of the image:

printf 'APP_SECRET=%s\nOAUTH_PASSPHRASE=%s\nOAUTH_ENCRYPTION_KEY=%s\n' "$(openssl rand -hex 16)" "$(openssl rand -hex 16)" "$(openssl rand -hex 16)" >> app/.env.local

Install dependencies and create the schema:

composer -d app install
php app/bin/console doctrine:migrations:migrate

Set MAILER_DSN to a real transport, otherwise the sign-in link cannot be delivered. Then generate the key that encrypts stored Bring! passwords and put it in app/.env.local as BRING_CREDENTIALS_KEY:

php app/bin/console app:credentials:generate-key

Generate the OAuth signing keypair:

php app/bin/console league:oauth2-server:generate-keypair

Then run the app and the Tailwind watcher side by side:

symfony server:start -d --dir=app
php app/bin/console tailwind:build --watch

The root is the login — there is no landing page. Sign in with your Bring! credentials and the first sign-in creates the account. Login and setup are one flow: the same page walks through signing in, adding a connector and connecting Claude, with settings below it. Someone who is already set up lands on the last step.

Keeping the Bring! constants in sync

Symfony talks to Bring! directly — it has to, since signing in happens before any MCP token exists — so the base URL and request headers live both in bring_api.const and in app/.env. Bumping bring-api can move them apart:

.venv/bin/python tools/check-bring-constants.py
.venv/bin/python tools/check-bring-constants.py --fix

A GitHub Actions workflow runs the check whenever mcp/requirements.txt changes. It only guards the constants — a changed endpoint path or response shape still surfaces as a failing login.

Unit tests

PHPUnit covers what the browser suite cannot see from the outside: the credential cipher, the dormant-account policy and its two deadlines, the Doctrine listener that decides whether a stored password is rewritten, and the paths that have to return null instead of throwing.

php app/bin/phpunit

They need no database, no container and no Bring! account — the repository tests run their queries against an in-memory SQLite. app-checks.yml runs them on every pull request that touches app/.

MCP server tests

pytest covers the parts of mcp/server.py that have nothing to do with Bring! being reachable: the credential exchange with Symfony and each way it can fail, the per-subject session cache, and which list an item ends up on.

pip install -r mcp/requirements.txt -r mcp/requirements-dev.txt
cd mcp && pytest

No network, no Bring! account, no running Symfony — the credential endpoint, the Bring client and FastMCP's request context are all stood in for.

Coding standard

@Symfony plus a handful of rules, configured in app/.php-cs-fixer.dist.php. The same workflow that runs the unit tests checks it on every pull request touching app/, so it is worth running before pushing:

cd app && vendor/bin/php-cs-fixer fix

End-to-end tests

Playwright drives the running containers — the same images that get deployed, not a dev server:

docker compose up -d --build
cd tests && npm ci && npx playwright install chromium && npx playwright test

Everything past the login form needs a real Bring! account, because an account here only exists once Bring! has confirmed a password. Those tests skip themselves unless you provide one:

BRING_TEST_EMAIL=you@example.com BRING_TEST_PASSWORD=… npx playwright test

One test deliberately exhausts the login rate limit. A fresh container has room for it; after repeated local runs the per-IP budget can run dry and block valid sign-ins for fifteen minutes:

docker compose exec app php bin/console cache:pool:clear cache.rate_limiter

A GitHub Actions workflow runs the suite on every pull request. Set the same two values as repository secrets to have the authenticated half run there too.

Exercising the OAuth and MCP chain

tools/dev-token.sh walks the whole authorization code + PKCE flow against a running Symfony app and prints the access token, so the MCP server can be tested without Claude:

tools/dev-token.sh http://127.0.0.1:8000 you@bring-account.example your-bring-password

Signing in goes through Bring!, so these have to be real credentials. Feed the token to the MCP server as a bearer token, for example:

curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/internal/bring-credentials

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    F
    maintenance
    MCP server for MealMastery AI meal planning that enables users to manage meal plans, recipes, and grocery lists through natural language conversation with AI agents like Claude.
    90
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    MCP server for Frisco.pl — lets Claude add groceries to your cart, search products, get nutritional info, and manage recipes, all via natural language.
    7
    MIT

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cpy-pst-gmbh/bring-api-mcp-connector'

If you have feedback or need assistance with the MCP directory API, please join our Discord server