Imported from partymola/bosch-flow-mcp (
AGENTS.md). Install upstream withnpx skills add partymola/bosch-flow-mcp. Copyright stays with the author.
bosch-flow-mcp - agent guide
CLAUDE.md symlinks to this file. It orients AI agents and contributors working in the code, and deliberately does not repeat the user-facing docs:
- What it is, install, auth, sync, tools, config, usage -> README.md
- Dev environment, running tests, pre-commit hook, PR & security process -> CONTRIBUTING.md
This is a public open-source repository. Read the Data Safety Rules before committing.
Data Safety Rules
The scripts/check-no-data.sh pre-commit hook blocks database files, real files under config/, and anything over 100KB (install per CONTRIBUTING.md). It does not grep for secrets, and it matches config/ by path, not tokens.json by name - a credential file staged anywhere else passes. Use the list below regardless:
config/bosch_tokens.jsonandbosch_flow.dbmust stay gitignored - verifygit statusshows no token/db files- Test fixtures (
tests/conftest.py) use fictional UUIDs (00000000-0000-0000-0000-000000000001) and round numbers only - no real bike data ever enters tests config/bosch_config.example.json(committed) holds only the public EUDA client ID and blank fields - confirm no real credentials
Architecture
- Entry point:
src/bosch_flow_mcp/cli.py- routesauth/syncsubcommands or starts the MCP stdio server - MCP server:
mcp_instance.pycreates the sharedMCPServerinstance - Auth:
auth.py- two flows. The defaultone-bike-appPKCE flow (iOS deep-link redirect, DevTools copy-paste). Ifconfig/bosch_config.jsonholds a EUDAclient_id, auth switches to the EUDA flow (plainlocalhost:4200callback).token_is_euda()reads the token file fresh each call, so routing follows the current sign-in without a restart. The EUDA flow's listener is_CallbackServer, not a bareHTTPServer, and that matters on Windows.HTTPServersetsallow_reuse_address, which on POSIX only waives TIME_WAIT. On Windows it is consent to be displaced: per Microsoft's same-user table for a specific-address bind, a first socket holding the address withSO_REUSEADDRis bound over by a second one asking for the same, so another process could take the authorisation code. Not asking is what closes it - the same table shows a first bind with neither option refusing that second bind.SO_EXCLUSIVEADDRUSEis asked for as well because Microsoft recommends it for server sockets and because it is the half that would still hold for a wildcard bind, which this listener is not. Never ask for both: reuse requested after exclusive use is the configuration Microsoft calls insecure, andallow_reuse_port(the POSIX-side equivalent hazard) is pinned off for the same reason. The cost is that a Windows port stays held until the previous connection finishes closing, normally a couple of minutes._setup_euda_authcatches the resultingOSErrorand binds before opening the browser.TestTheCallbackPortIsNotShareddrives the Windows branch on a POSIX runner against a recording socket rather than reading the source for it - source assertions here let the option be set after the bind, at the wrong level, on the wrong socket or with a value of 0, all of which pass a check that only looks for the name.test_a_busy_port_is_reported_before_the_browser_opensis the only test that reaches the flow itself, and it patches the name the flow constructs; patchHTTPServerthere instead and it guards nothing while still passing - API:
api.py- GET wrapper with thread-safe token refresh (5-min expiry buffer) and typed exceptions:BoschAuthError,BoschRateLimitError,BoschAPIError,BoschForbiddenError. Three are siblings offException;BoschForbiddenErroris the one subclass, ofBoschAPIError. Soexcept BoschAPIErrorcatches a 403 but not a 429 - which is both why a 403's message reachedsync_log(it was caught, and carried the request path) and why every layer that handles failures has to nameBoschRateLimitErrorseparately - Failure classification:
refresh_tokenis a boundary over_refresh_tokenand raises exactly two types.TokenRefusedonly where the server or the credential files judged the credentials unusable;RefreshNetworkErrorfor everything else, via a catch-all, so an unanticipated failure lands there by construction rather than by listing exception types. Never widenTokenRefusedto a condition that can clear on its own (a rate limit, a 403 from bot protection, an unreadable response): re-authorising rewrites the token file and spends a refresh token that was still working. Pinned byTestTheRefreshBoundaryandTestRefusalsAndNetworkConditionsintests/test_auth.py - Client routing:
current_client_idandtoken_is_eudaare called outside any handler and must never raise. They fall back to the configured client, not the hardcoded one - a EUDA user with a half-written token file would otherwise route as non-EUDA and be told to register a client they already have._get_client_idcarries its own guard for that promise;_is_eudais deliberately left unguarded, because its only caller is the interactive auth command where a malformed config should stop the user rather than send them through a browser login that yields the wrong client. Pinned byTestTheFallbackKeepsTheConfiguredClientandTestTheRoutingHelpersNeverRaise - Sync:
tools/sync_tools.py- routes each data type by the token's client. Standard mobile sign-in reads bikes/batteries/components/firmware/SoC;service/software_updates/capacityneed the EUDA client and otherwise reportunavailable(not a silent empty). Non-EU EUDA accounts reportempty/euda_emptywith remedy text - DB:
db.py- SQLite, defaultbosch_flow.dbin the package root (BOSCH_FLOW_MCP_DB_PATHto override;BOSCH_FLOW_MCP_CONFIG_DIRfor the config dir). Besides the per-domain data tables, async_logtable records each sync's timestamp, data type, status, and rows added - query it when data looks stale - Tools:
tools/-@mcp.tooldefinitions grouped by domain (bike, battery, component, service, analysis, activity, sync). Cachedget_*tools auto-sync if stale;bosch_get_soc,bosch_get_activities, andbosch_get_activity_detailare live reads
Key invariants
- Capacity sync depends on components - it needs part + serial numbers, so
componentsmust be synced first - Route by token client, never call both hosts blindly - the sync layer picks the host from the authenticated client_id
- The same rule governs what
bosch-flow-mcp authprints, and that is a terminal rather than a stored note._exchange_codereports a refused exchange as its status code, a transport failure astype(e).__name__, an unreadable body and a response carrying no access token as fixed text. None may carry the server's own words: an error body can name an account,str(e)carries text the peer influences (a TLS name mismatch echoes an identity string out of the handshake; measured, a refused connection and a DNS failure name no path, so do not justify this one by paths), and the no-access-token branch is reached by exactly the response shape that carries arefresh_token, so printing the dict to explain the failure puts a live credential on screen. It also has to reach its own message, which is why theOSError,ValueErrorandisinstanceguards are there:urlopenwraps only connect-phase failures, and a read timeout, a non-JSON body or a JSON scalar otherwise escapes as a traceback naming this install's absolute paths. Pinned byTestTheCodeExchangeReportsNoResponseContentintests/test_auth.py, which asserts the whole message rather than the absence of a secret, since appending the HTTP reason phrase, the response headers or anerror_descriptionpasses any "is this string absent" check. It asserts all three channels: stdout, stderr andcaplog. stdout because every other print here goes there, so a bareprintis the likeliest reintroduction;caplogbecause aloggercall is invisible tocapsysunder pytest and lands on the terminal in production - Nothing an exception carries reaches a stored note or a tool result - not the response body, and not the request path.
run_syncwrites its note intosync_log, andhelpers.empty_data_notereads that row back and returns it asnoteon every emptybosch_get_*result, so anything stored is repeated to the model indefinitely. The capacity request path carries a part number and a battery serial, which is why the messages are the fixedAUTH_FAILED_MSG/RATE_LIMITED_MSG/API_FAILED_MSGplus at most an exception type name.api.getraises a status code and a path, never the body. Pinned byTestNoRequestPathReachesTheSyncLogOrAModelandtest_the_api_layer_never_puts_a_response_body_in_its_messageintests/test_sync.py require_authis the backstop every tool has.BoschRateLimitErroris a sibling ofBoschAPIErrorrather than a kind of it, soexcept BoschAPIErrordoes not catch a 429. The three live reads catchBoschAuthError,BoschForbiddenErrorandBoschAPIErrorthemselves and keep their own wording; the nine cached tools catch nothing.BoschRateLimitErroris the one type nothing caught, so before the gate had its owntrya rate limit reached the MCP client as a transport error rather than a tool result.run_syncneeds its own handlers for the same reason, including a trailing catch-all.InvalidDateErrorpasses through with its message intact - it is the server's own text and it tells the model how to retry, which is why it is a distinct type rather than a bareValueErrora JSON decode could also raise. Pinned byTestTheAuthGateintests/test_helpers.py, which also pins the refusal when there are no credentials, and bytest_every_tool_is_gated; nothing covered any of it before. Because the gate returns a result rather than re-raising,mcp2.1's masking does not apply to this server - that release keeps only aToolError's text and replaces every other exception's withError executing tool <name>, which costs a server whose tools raise. Measured over a real stdio handshake on 2.1.1: a bad date still arrives asInvalid date '...'. Use YYYY-MM-DD.... So do not port the sibling repos'ToolErrorconversion here; it would convert what this gate has already turned into an answer. What keeps that true istest_an_unanticipated_failure_becomes_a_tool_resultandtest_a_bad_date_keeps_its_message_so_the_model_can_retry, so a change that let an exception escape the gate would fail those before it reached a client- The one refusal the gate never sees is a schema refusal, because it happens above every decorator: an argument constrained by its annotation is rejected before the tool body is entered, and
mcpraises that as aToolError, which is the one type whose text 2.1 keeps. Measured over a real stdio handshake on 2.1.1: it reaches the client as a result carryingisError: trueand the whole validation message,Input should be 'weekly', 'monthly' or 'quarterly'. Both halves of that matter and they have different causes. Sitting above the gate is why the gate never sees it. TheToolErrortype is why the message survives, so do not read this as a general licence for anything raised above the gate, and it is not a reason to add aToolErrorconversion inside one
- The one refusal the gate never sees is a schema refusal, because it happens above every decorator: an argument constrained by its annotation is rejected before the tool body is entered, and
bosch_battery_trends'periodis constrained in the annotation, so the server refuses before any body runs. The class this closes is a wrong answer rather than bad input: an unrecognisedperiodfell through the key-function chain's trailingelseto monthly, and the response echoesperiodback, so the buckets were monthly and the label was whatever had been sent. Real figures, no error, nothing empty.TrendPeriodis unpacked from_PERIOD_KEY_FNS(Literal[*_PERIOD_KEY_FNS]), so the schema offers exactly the periods there is a key function for. Two residues stay open and both relabel exactly as quietly: two dispatch entries pointing at the same key function, and the call site ignoring the dispatch and calling a key function by name. Neither is visible from the echoed label, which is whytest_every_accepted_period_still_answers_under_its_own_labelasserts the shape of every bucket key rather than only the label, andtest_a_period_separates_dates_that_belong_to_different_bucketscovers the third case, a key function that collapses its own family. The docstring'sOptions:list and its(default)marker are two more copies, read back bytest_the_documented_periods_are_the_accepted_onesandtest_the_default_is_a_value_the_schema_accepts. All of it intests/test_argument_validation.py, whose calls go throughmcp.call_tool, the only layer that applies the schema, so a test calling the function directly passes whether the constraint is there or not.bosch_get_components'component_typeis checked against the cache, because Bosch defines those values and this server does not. An annotation is the wrong mechanism for an open set: aLiteralwould refuse a component type a future bike registers. So the filter is resolved against the types thecomponentstable holds, ignoring case, and a value none of them match is refused with those types named. A type held under more than one spelling answers under all of them. A bike's profile keys and its registrations describe the same part, sobatteryandBatterycan both be stored, and resolving to whichever sorts first drops the other's rows, which is a confident partial answer in place of a correct one. The fold iscasefoldon both sides rather than SQLCOLLATE NOCASE, which is ASCII-only: mixing the two lets a value the membership test accepted come back empty from the query. An empty cache refuses nothing, because with no rows there is nothing to name in a refusal; that reply stays thin, since a sync recordingokwith nothing to store leavesempty_data_notesilent as well. And the vocabulary is account-wide, never per bike: a type one bike does not carry is a true empty result, so scoping the known set bybike_idwould report a real component type as unknown. Each of those is pinned intests/test_component_type_filter.py, the fold by a stored type outside ASCII asked in another case: that is the only input that tells the two folds apart, and without it a rewrite moving the comparison into SQL passes the whole suite while answering nothing. The thin reply is pinned by asserting the absence of a note, so an explanation cannot be added to that path while this says there is none.bike_idis not checked here at all, so an id no bike has is still answered with an empty list, wherebosch_get_bikerefuses one; nothing pins that, it is a residue
Test conventions
Tests are fully offline - no real API calls or tokens - and use temporary SQLite databases (tmp_path fixture), never the real DB. See CONTRIBUTING.md for how to run them.