Fortnight wrap: Studio becomes the MCP client

Beer in hand. This fortnight ran on two rails in parallel.
One rail was new capability: the MCP server got rebuilt, Studio became the client for it, reports started rendering as artifacts inside the chat. That's the visible surface.
The other rail was structural. We've been carrying a category of debt that doesn't block any individual feature but compounds across every sprint - files that everyone touches, bypass hatches in the auth layer, CI feedback loops measured in tens of minutes. The structural rail cleared a lot of that.
They're connected: you don't get away with shipping fast if the ground under you is moving.
1. MCP and Studio
The previous post ended with an autonomous client able to authenticate, route a question, and get data back from an adapter chain. The auth ran itself. The routing ran itself. This fortnight, the actual interface caught up with that.
The tool surface went from 20 to 4. Four tools now:
ask_demiton: natural-language entry point. Ask a question in English. The classifier picks the domain, the context assembler builds the query, the right adapter chain fires.list_reports: returns the report templates available to the connected org, filtered by what connectors are live.run_report: executes a template against live data and returns a structured artifact.get_report(via MCP App iframe viewer): opens a rendered report inline in the chat.
The consolidation happened in three phases. Phase 0 activated the MCP loopback in Studio - requests from Studio started routing through the MCP server instead of a parallel handler chain. Phase 1 deleted the old tool surface. Phase 2 made ask_demiton parallel: the context assembler now fires multiple providers simultaneously, so wall-clock time is the slowest single fetch, not the sum.
The old dispatch layer - tool_handlers/ - got deleted entirely. 1,581 lines across 19 files.
The chat UI got a full redesign. The bubble metaphor is gone. It implied a conversation between equals; this is not that. The new layout is a document surface: full-width, violet accent for the assistant, tool calls rendered inline as they fire. You can watch the classifier run, see the context assembler build its query, and read the result stream before it settles.
The session sidebar landed. Studio is now a two-pane layout: session history on the left, active conversation on the right. We resisted this longer than we should have because we were treating Studio as a feature rather than a surface. It's a surface now.
Reports became artifacts. When you run a report through Studio, the result is no longer a block of text in the chat. It's an artifact with a download_url, a content_type, and an MCP App iframe viewer that opens inline. The fuel-claim workflow now produces a structured HTML table - cost matrices, plant tables with hours-source badges, light-vehicle rows - all rendered inside the conversation. The viewer stays up after streaming ends because the done event carries the mcp_app field.
Entitlements went vendor-agnostic. Data access through ask_demiton was previously gated per named adapter. That made the Connected tier feel brittle. The entitlement layer now checks capability class instead of connector identity. Finance queries work for anything that looks like a finance system. Scheduling queries work for anything that looks like a scheduling system.
2. The collision zones
A file that gets edited 67 times in 90 days isn't a file. It's a merge conflict waiting to happen to every engineer who touches anything near it.
queryKeys.ts in the main app was that file. 392 lines, a single flat export containing every TanStack Query cache key used anywhere in the product. Add a new feature, edit queryKeys.ts. Fix a stale query, edit queryKeys.ts. Every PR that touched the frontend touched this file, which meant every concurrent PR had a merge conflict with every other concurrent PR.
services/index.ts had the same problem. 55 commits in 90 days. It was the barrel export for every API service call in the app - auth, billing, connectors, vendors, workers, scheduling, payroll, memory, all of it.
Both got split this fortnight into domain files.
queryKeys.ts is now a queryKeys/ directory: connectors.ts, workflows.ts, workspace.ts, operations.ts, memory.ts, platform.ts, and a legacy.ts for the few keys that don't belong cleanly anywhere yet. The central index.ts assembles the same exported shape, so all 70 existing imports resolve unchanged via TypeScript directory module resolution. No call sites changed.
services/index.ts is now a domain barrel pattern: _clients.ts for shared singletons, core.ts for auth/user/org/billing, operations.ts for connectors/vendors/projects/workers/scheduling/payroll, platform.ts for platform admin surfaces. Adding a new feature now means touching one domain file. Not the hub.
The god files. Three large files got structurally split alongside.
memory.py was 1,202 lines. It's now a package: schemas.py (all 35 Pydantic response schemas), _helpers.py (shared DB helpers), workers.py, projects.py, assets.py, and an __init__.py that assembles the sub-routers at the same URL paths. Same mount point, same endpoints.
MemoryPage.tsx was 1,038 lines. It's now 98 lines plus extracted modules: formatters, summary cards, the plant allocation display, the records panel. WorkflowsListPage.tsx went from 1,056 to 314 lines. WorkflowRunDetailsPage.tsx from 654 to 379.
TanStack Query migration. Six hooks that were still using raw fetch + useState/useEffect got moved to proper useQuery/useMutation. Four new service files landed in shared-services: supplier alerts (with optimistic cache updates via setQueryData), personalization (5 min stale, no retry), public data (supplier search with a 2-character guard and keepPreviousData), and form suggestions (0 stale time, not re-fetched on window focus). The watchlist toggle is optimistic.
A CI grep guard script landed alongside - check-query-key-hygiene.mjs - that fails if any raw queryKey array is added to the app without going through the factory. 49 remaining inline arrays across 32 files were replaced before the guard went in.
3. The auth bypass
is_superuser was a boolean column on the User model. It was also a bypass hatch - six places in the stack where standard permission checks were skipped if that flag was true: rbac.py, billing.py, context.py, entitlements.py, envelope.py, and the MCP tool constructors.
Boolean bypass flags in auth systems accumulate call sites faster than you remove them. Every time a new thing needed platform-level access, the path of least resistance was if user.is_superuser.
The column is gone. The replacement is a PLATFORM_ADMIN role that goes through the standard RBAC permission check path. The Alembic migration backfills the role for any existing is_superuser=True accounts before dropping the column - no data loss, no manual cleanup. Every bypass site in the backend now routes through platform:* permission codes. The frontend isSuperuser in usePermissions and useEntitlements is now isPlatformAdmin checking platform:manage_workflows.
Forward-only migrations. Related: 42 Alembic migration files had an empty downgrade(): pass stub. They were harmless until someone ran alembic downgrade on the wrong environment, at which point they were catastrophic and silent - the command would succeed, nothing would happen, and the schema state would be wrong. All 42 are now raise NotImplementedError("Forward-only migration").
The rename that ate the word "construction". The Blueprint-to-Workflow rename was done with a broad find-and-replace. It was too broad. The replacement touched word boundaries it shouldn't have: reconstruction became rebusiness objectuction, constructing became business objecting. Eighteen files in the domain we serve - civil construction - had the word "construction" corrupted. The follow-up commit reverted the damage and did the rename properly. The vocabulary of the product shouldn't eat the vocabulary of the industry.
4. CI and developer experience
The E2E suite was taking about 20 minutes to run on every PR. That's long enough to context-switch away and lose the thread.
The suite got split into two lanes. A smoke lane covers 5 critical areas - health, auth guard, navigation, projects, workflows - tagged @smoke and run on every PR. The smoke lane runs in about 5 minutes. The full 3-shard sweep still exists but is now opt-in: triggered by the e2e-full PR label or a manual dispatch. PR feedback loop: 20 minutes to 5.
OpenAPI drift detection. apiClient.d.ts is a 25,000-line generated file. Until this fortnight it was possible to ship a backend schema change without regenerating the TypeScript client - the drift would only surface at runtime. A new scripts/export_openapi.py generates the spec from the FastAPI app without a running server. The test-api CI job uploads it as a PR artifact. The test-ui job downloads it and runs the drift check. If the spec and the client are out of sync, the PR fails.
apiClient.d.ts is now marked linguist-generated=true in .gitattributes. It was appearing in every PR diff at 25,000 lines. It's still in git, but hidden from reviews.
Skip count guard. A check_skip_count.py script and a skip-baseline.json were added. The CI job fails if the number of hard @pytest.mark.skip markers increases. Adding a new permanent skip requires removing an existing one.
.env.example. The API package now has a 145-line .env.example derived directly from the pydantic-settings fields in app/core/config.py. Every setting is documented. New contributors can now cp .env.example .env.local instead of reverse-engineering which variables exist from runtime errors.
Docker WeasyPrint gap. The worker Dockerfile was missing eight system libraries that WeasyPrint needs for PDF rendering. The container was running without error but producing degraded PDFs silently. The libraries are in, a healthcheck was added to the worker, and docker-compose.yml now waits on service_healthy before starting dependents.
Where this lands
The things that were separate are the same thing now. When a Connected customer opens Studio and asks about a project, and when Claude Desktop connects to the MCP server and asks the same question, they go through the same classifier, the same context assembler, the same adapter chain, and produce the same artifact. The only difference is which OAuth client is holding the token.
The structural work is why this was possible to ship cleanly. The collision zones are broken up. The bypass hatch is closed. The CI loop is four times faster.
Cheers.
- Justin
Ask Claude about your projects.
Demiton's MCP server puts your project financials, worker schedules, and vendor data behind 20+ tools your AI assistant can query directly. Connected tier includes it.