bring-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@bring-mcpAdd milk, eggs, and butter to my shopping list."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 |
| Python MCP server (resource server) |
| 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-credentialsTools
Tool | Purpose |
| Add items, optionally with a quantity and a target list |
| Show open and completed items |
| Name the available lists |
| 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 .env2. 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 -dThe 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.json4. 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=onfor/mcp, otherwise the proxy buffers the open Streamable HTTP connection and Claude waits for a response that never arrives in one piece.mod_deflatebuffers for the same reason, so/mcpis excluded from compression as well.RequestHeader set X-Forwarded-Proto "https".mod_proxy_httpforwardsX-Forwarded-Forand-Hostbut not-Proto, and without it Symfony writeshttp://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 -dMigrations 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.shAdd 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 --onA run pulls, and stops there unless something actually moved:
Compare the image digests before and after the pull. Unchanged means no deployment, and the run is over.
Check that all images carry the same
org.opencontainers.image.revision. WithIMAGE_TAG=latestthe 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.Raise the maintenance page and let in-flight requests finish.
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.
docker compose up -d, then pollhttp://127.0.0.1:8000/health.jsondirectly — through the proxy everything is a 503 at that moment. It covers both containers, because the app's health page calls the MCP server.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.timer04: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 nightMerging 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/backupsVACUUM 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/backupsThe 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:backupdocker compose cp bring-cron:/app/backups ./backupsRestoring
Stop the app first — restoring under a running server means writing the file out from under open connections:
docker compose stop app crondocker 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 cronAdding 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-connectorLegal texts and the mail signature
PRIVACY_POLICY_URL and IMPRINT_URL each take one of two things:
Value | What happens |
| the footer links straight there |
| the Markdown is rendered and served from |
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 |
| a readable page |
| 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 cronThe 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-runThe 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-credentialsbelongs 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.txtset -a && source .env && set +a && .venv/bin/python mcp/server.pyThe 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.localInstall dependencies and create the schema:
composer -d app installphp app/bin/console doctrine:migrations:migrateSet 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-keyGenerate the OAuth signing keypair:
php app/bin/console league:oauth2-server:generate-keypairThen run the app and the Tailwind watcher side by side:
symfony server:start -d --dir=appphp app/bin/console tailwind:build --watchThe 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 --fixA 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/phpunitThey 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.txtcd mcp && pytestNo 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 fixEnd-to-end tests
Playwright drives the running containers — the same images that get deployed, not a dev server:
docker compose up -d --buildcd tests && npm ci && npx playwright install chromium && npx playwright testEverything 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 testOne 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_limiterA 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-passwordSigning 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-credentialsTool 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.
This server cannot be installed
Maintenance
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
Hosted MCP server connecting claude.ai, ChatGPT and other AI apps to your own computer
Augments MCP Server - A comprehensive framework documentation provider for Claude Code
MCP server unifying ERPs, CRMs, APIs and knowledge base for Claude, ChatGPT and Gemini.
Use AI models for chat, image, and video generation from Claude Code and other MCP hosts.
Related MCP Servers
- FlicenseNot gradedqualityAmaintenanceMCP server that integrates with AnyList for managing shopping lists, recipes, and meal planning via natural language.29-
- AlicenseNot gradedqualityFmaintenanceMCP 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.90MIT
- FlicenseNot gradedqualityCmaintenanceMCP server for the Bring! shopping list API, enabling management of shopping lists via natural language.-
- AlicenseNot gradedqualityDmaintenanceMCP server for Frisco.pl — lets Claude add groceries to your cart, search products, get nutritional info, and manage recipes, all via natural language.7MIT
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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