0.2.17
[0.2.17] - 2026-07-25
Added
- MCP client — new
enable_mcp_clientflag (off by default, PydanticAI + PostgreSQL only): end users connect external Model Context Protocol servers from Settings → Integrations and the agent picks up their tools. Ships a curated marketplace catalog (Notion, Linear, Jira/Confluence, Stripe, GitHub, Zapier, Exa, CoinGecko, …) with brand logos baked in as data URIs, per-user connections with Fernet-encrypted tokens, a connectivity test that lists the server's tools so the user can pick which ones the agent may call, and a per-chat "Plugins" tab to toggle them. Auth is either a static bearer token or one-click OAuth 2.1 (authorization-code + PKCE, RFC 9728/8414/7591 discovery and dynamic client registration), over both streamable-HTTP and SSE transports. Deployment-wide servers are pinned viaMCP_SERVERS. Every URL the flow touches — including each redirect hop and every endpoint the remote server advertises — goes through the same SSRF policy as webhooks. Tools are prefixed per server, and an unreachable or unauthorized server is skipped for that turn instead of failing the chat. CLI:--mcp-clientoncreate+ wizard step (#124) - Version-upgrade tooling —
fastapi-fullstack upgradepulls template improvements into an already-generated project without losing local edits. It renders the template at both the generated-from and target versions using the answers stored in.fastapi-fullstack.json, normalizes all three trees the way generation did (ruff check --fix+ruff format, Prettier on the frontend) so formatting can't read as an edit, and runs a real 3-way merge onto atemplate-upgrade/v<version>branch. Structural changes between releases (renames, removals, breaking notes, manual steps) are declared inUPGRADES.yaml, with aRename guardCI job that fails any PR moving a template file without recording it.upgrade recoverbootstraps a manifest for projects generated before this existed. Documented indocs/guides/version-upgrade.md(#114)
Fixed
-
MCP tools were missing on the channel path —
agent_invocation.pyis the second place the PydanticAI agent is built (Slack/Telegram), and it never received the user's MCP toolsets. A connection configured in Settings worked in web chat and silently did nothing in a channel, with no error on either side.build_toolsets_for_usernow takes an optionaluser_id, so channel traffic with no mapped account still gets the deployment-managedMCP_SERVERS(#130) -
Two chat turns could both spend the same OAuth refresh token — providers that rotate refresh tokens invalidate whichever copy is redeemed second, after which the connection stops working with nothing to point at. The refresh now happens under
SELECT ... FOR UPDATE; the losing turn re-reads the row and uses the token the winner stored (#130) -
An abandoned OAuth consent redirect stayed redeemable forever — the
statetoken is the only thing authenticating the callback, and it travels through the provider and the browser's history. A pending flow now expires afterFLOW_TTL_SECS, and a pending payload that can't be decrypted (rotatedSECRET_KEY) asks the user to start again instead of raising out of the route as a 500 (#130) -
A bad or duplicated
MCP_SERVERSname silently dropped a server from every chat turn — the name is that server's tool prefix, so two servers sharing one prefix meant only the first was ever attached, visible nowhere but a log line. Names are now validated as slugs and checked for duplicates at startup, and the.env.examplesuggestsgithub-internal, which can't collide with the marketplace'sgithub(#130) -
The MCP client had no CI job — the integration matrix ran only
ruff+tyagainst an MCP render, so the generated project's MCP test suite and the frontendtsc/eslintnever executed for that configuration. AddsTemplate - PostgreSQL + MCP Client, which renders with--slack(the only build containingagent_invocation.py) and runs both the backend and frontend suites (#130) -
The plugin marketplace fetched 14 favicons from Google on every settings page view — the logos were already baked in as data URIs for the offline demo export; the Settings UI now uses those, so the app doesn't tell a third party which plugins its users are browsing. A catalog entry whose name a workspace server already claims now reads "Provided by your workspace" instead of offering a Connect button that would create a connection dropped on every turn, and editing a connection sends only the fields that changed, so renaming one no longer resets its last-checked status (#130)
-
A file replacing an untracked symlink landed on disk but never on the branch — the untracked-symlink exemption exists so a stray link where the template now ships a file doesn't abort the run, but it was subtracted from the staging set too. The link was replaced, the file was written, and
git addskipped it: a silently partial upgrade, with the new file showing as untracked on a branch that claimed to be complete.materializenow keeps the two sets apart — tracked symlinks are the "never deleted, never restaged" guarantee, untracked ones only waive the collision guard -
tar -xread$TAPEinstead of the merged tree — the extraction ran with no-f, so tar falls back to the archive named by$TAPE(or a compiled-in default device). With that variable exported the whole extraction became a silent no-op — exit 0, nothing written — and the upgrade then staged the old content as if the merge had produced it. Nowtar -x -f - -
A broken symlink hid an untracked-file collision — the guard used
exists(), which follows the link, so a dangling one read as absent and was overwritten without warning..gitignorekeeps such a link out ofls-files --otherstoo, so nothing else caught it either. The guard now useslexists -
The rename guard still failed open on one path — a 404 was treated as "no published baseline yet" for either PyPI call, so a 404 fetching the wheel of a version PyPI says exists skipped the guard entirely and an unrecorded rename shipped on a green build. Only failing to resolve the latest version can mean "nothing published"; a failed template fetch is now exit 2
-
The rename guard printed a block its own stale-check would reject — the suggested
- version:came fromget_generator_version(), which mid-cycle is the baseline, and the half-open(from, to]range filters those straight back out. Pasting the suggestion turned an "uncovered" failure into a "stale" one. It now emits a<next-release>placeholder plus the reason whenever the working tree isn't already bumped -
A transient PyPI blip silently downgraded the wheel download to unverified — the digest was fetched in a second metadata request, and any failure there returned
None, which the caller reads as "no digest known". One request now carries both the URL and its sha256 -
Prettier's exit code is checked, like ruff's. Prettier formats what it can parse and exits non-zero on the rest, so one tree hitting an unparseable file came out formatted differently from the other two — and the "did it run" return value couldn't carry that, so the evenness warning stayed silent
-
A malformed
UPGRADES.yamlblock is rejected where the file is read, naming the file and the offending entry. Three consumers indexr["from"]/r["to"]directly, so a hand-edit typo used to surface as a bareKeyErrorfrom whichever release script happened to run -
read_manifestrejects a non-objectcontext. It is the one key every consumer indexes into, so a hand-edit that turned it into a list got past the presence check and failed several modules downstream -
Recovery lost the generation timestamp, so a legacy project's first upgrade conflicted on files nobody touched —
generated_atis stamped intobackend/pyproject.tomland into every alembicCreate Date:header, andnormalizeblanks it in all three trees so it cancels out — but only when it knows the value. A recovered manifest never carried one, sostrip_generated_atwas a no-op, BASE and THEIRS rendered the stamp empty while the client's files carried the real one, and every stamped file read as an edit they never made. Measured on a minimal project: 3 of 208 files differed with zero client edits, one of thembackend/pyproject.toml— which moves on nearly every release, so the guide's flagship "recover a legacy project" flow opened with a conflict. Alone among the value variables this one leaves a trace, so recovery now reads it back out ofbackend/pyproject.toml, and warns loudly when it can't -
Prettier skipped the one tree that already had it —
format_frontendbailed out whenever the tree already containedfrontend/node_modules, which is exactly OURS when the client committed theirs and never the freshly rendered BASE/THEIRS. That is the uneven-formatting case the previous fix added a warning for; the install sitting in the tree is a perfectly good one, so it is now used in place (and left alone afterwards) instead of being a reason to skip -
Directories left empty by a removal are pruned. git tracks files, not directories, so a release that dropped a whole subtree unlinked its files and left the folders behind for good —
git checkoutprunes them, hand-rolled deletion has to say so. A directory still holding an untracked or ignored file survives -
_iter_text_filesprunes skipped directories instead of filtering them one entry at a time.rglobcannot prune, andrestore_generated_atruns over the client's whole repo, wherenode_modulesalone is routinely six figures of entries -
Upgrading a project that wasn't its own git repo deleted the project — nothing checked that the target directory was the repository root, and the two halves of the merge disagree about what a path means anywhere else:
checkout-indexbuilds OURS from index paths (repo-root-relative) whilels-treerun throughgit -C <subdir>emits cwd-relative ones. For a project atmonorepo/myappthe rendered BASE/THEIRS therefore shared no path with OURS, every template file read as a client deletion, andmaterializeunlinked the client's real files and staged the deletions — with no error and a report claiming the template had removed them.upgradenow refuses up front (dry runs included, since the preview is wrong the same way) -
The rename guard could fabricate a rename and then pass itself — CI honoured the
removed:/waived:waivers recorded inUPGRADES.yaml, butscripts/record_renames.pyonly looked at its own--waiveflag. A maintainer's intentional delete+add was therefore re-detected and written back as a rename, after which CI went green because the move was "covered" — and the client's next upgrade moved their copy of a deleted file onto an unrelated path. Both halves now read the samerecorded_waivers() -
Stale directory renames slipped past the release guard — the stale-version check only matched exact
(from, to)entries, so a file covered by a directory renamea/ → b/was neither uncovered nor stale. If that entry was recorded under a version<=the baseline, the half-open range dropped it and the whole subtree degraded to delete+add — the highest-stakes case, since one entry covers many files. The check now resolves the covering entry and reads the version from it -
Uneven formatting across the three trees was undetectable — the merge is only sound if BASE, OURS and THEIRS are formatted identically, and both formatters already returned whether they had run, but
normalize_treediscarded the answer.format_frontendbails out when a tree already containsfrontend/node_modules— true for OURS whenever the client committed theirs, never for the freshly rendered BASE/THEIRS — so Prettier ran on two trees out of three and every.tsxfile read as a client edit.normalize_treenow returns aFormattersRunand the upgrade warns when the three disagree -
Two harmless, common outcomes were reported as "Other changes (review on the branch)" — a file the client deleted while the template still ships it, and a file both sides deleted. Neither needs any action, and the label read as "the upgrade lost my file". They now have their own report line ("You deleted these") and are folded into "Already converged" respectively
-
upgrade finalizeraised a bareKeyErroron a hand-edited.fastapi-fullstack.json.pendingthat was missingpackage_version; it now fails with a message that says what to delete. A corrupt (non-JSON) pending file gets the same treatment -
The "invalid version" error from the template fetcher said target version even when it came from the manifest's
package_version— which is"UNKNOWN"after a recovery that couldn't read the README footer, sending the user to look at a--tothey never passed -
make upgrade-finalizedidn't pass$(ARGS)through, so--pathwas unreachable frommake -
.fastapi-fullstack.json.pending/.candidateare now gitignored in generated projects, so the "commit your resolved conflicts" step can't sweep the pending manifest into the commit -
Upgrade read a third of the backend as edits you never made — the post-gen hook runs
ruff check --fixandruff formatat generation time, but the upgrade's normalization ran onlyformat. OURS therefore arrived with unused imports stripped and# ruff: noqaheaders removed while the freshly rendered BASE/THEIRS still carried them, so 36 of 127 backend files in a stock PostgreSQL project differed with zero client edits in them. The report labelled them "Kept your changes (template unchanged)", and the next release touching any of their import blocks would have conflicted instead of auto-updating.normalize_treenow takesrendered=Truefor BASE/THEIRS and runs the autofix pass on them only — never on OURS, whose code must not be rewritten into the merged result. Measured on the same project: 36 spurious differences → 1 (the real edit), and the report goes from 30 false "Kept your changes" to 8 honest "Auto-updates" -
Generation and upgrade crashed under a non-UTF-8 locale — the post-gen empty-file sweep,
is_stub_file, and the__init__.pycheck all read rendered files with the platform default encoding. The template ships 209.md/.mdx/.ts/.tsxand 163.pyfiles with non-ASCII content, so on Windows cp125x or aLC_ALL=Ccontainer generation died withUnicodeDecodeError— and so did rendering BASE/THEIRS during an upgrade. All three now read as UTF-8 through a sharedread_texthelper -
upgrade --path X finalizetold the user to do the thing that had just failed — the misplaced-flag guard advised "put it before the subcommand", which is exactly the form it rejects.finalizeandrecovercarry their own--path, so the message now points atupgrade finalize --path ...; flags with no subcommand equivalent still say to drop it -
RAG disabled left a broken frontend — generating a project with RAG off and Teams on kept the org "Integrations" screen (
orgs/[id]/integrations) and its API proxy, while the post-gen hook removed every module they import (@/lib/rag-api,@/components/rag/*,use-org-integrations), sonext buildfailed on four missing modules. The page and proxy are now removed with the rest of the RAG frontend, and the button linking to them is gated inorgs/page.tsx— the backend drops/api/v1/org/integrationsin that configuration anyway (#128) -
Code-execution tool broken against pydantic-monty 0.0.19 — the sandbox dependency was pinned
>=0.0.18, and 0.0.19 replaced the one-shotMonty.acreate()/run_async()API with a worker pool (AsyncMonty→checkout()→session.feed_run()) and dropped themax_allocationsresource limit. Generated projects withenable_code_executiontherefore failedty checkand would have raisedAttributeErroron the firstrun_pythoncall.code_execution.pynow uses the session API, the pin is>=0.0.19, andCODE_EXECUTION_MAX_ALLOCATIONSis replaced byCODE_EXECUTION_MAX_MEMORY_MB(default 256, mapped to themax_memorylimit in bytes) -
Empty feature-gated files shipped in generated projects — the post-gen stub sweep only walked
backend/app/**/*.py, so a doc or frontend module whose whole body sits behind a feature conditional was written out as an empty file instead of being removed:docs/howto/add-rag-source.md,add-sync-connector.md,configure-sync-sources.mdwith RAG off, plus four dead frontend modules. The sweep now covers.md,.mdx,.ts, and.tsxas well (#128)