Project: Baize — a team-facing AI assistant runtime (Go 1.25+, MIT)
Repo: https://github.com/rebornace/baize
1. A very real pain point
When a team rolls out AI, one thing you can't avoid: everyone uses a different Agent client.
One person codes in Cursor, another uses Claude Desktop, another uses something else. Business API docs, login credentials, callback URLs — if you reconfigure them in every client, you end up wiring the same legacy system once in Cursor, then again in Claude Desktop, with credentials scattered everywhere. The day that legacy system changes its auth, you have to go fix each one.
Baize sits on the "wired up the business system" side of this problem. The OpenAPI doc is imported into Baize once, login is completed in Baize's console once, and write-approval is audited in Baize once. The remaining question is: how do you let the Agent clients your team already uses reuse this wired-up capability, instead of reconfiguring it all over again?
That's what MCP server export is for.
2. Where the decision happens, and where it doesn't
One thing to get out of the way: once Baize exports the tools, the decision is not on Baize's side.
The Cursor model in Cursor, Claude in Claude Desktop — they still decide for themselves "which tool to call this turn, with what arguments." What Baize exports is only two things: a tool catalog (name, description, parameter schema), and an execution channel for when those tools are called. Baize's own main model, ReAct loop, decision layer, memory, and context compression are all not involved in this export path.
The payoff is direct: the decision layer stays free to pick on the client side — whichever model you use in Cursor is none of Baize's business — while the execution layer stays centralized in Baize. Business API integration, auth, and approval are maintained in exactly one place.
3. Transport and auth
The export channel runs over the standard MCP Streamable HTTP, stateless. When a client connects, it sends a Bearer key in the request header.
On the server side:
- Wrong key, or missing key → 401.
- Keys are stored as hashes only, never in plaintext.
- Each key maps to a separate export identity (e.g. "the one for Cursor" and "the one for Claude Desktop" are two different keys).
- Keys can be revoked at any time; revoking one immediately disconnects that client.
- The whole export feature has a master toggle; when off, all requests return 503.
"One key per identity" isn't ceremony — it's what makes the later login-state and isolation behavior possible.
4. "Read-only" isn't a slogan — it's four layers of hard constraints
The most common objection to exposing business tools to external clients is: what about writes? Baize doesn't just say "read-only" in marketing copy. It stacks four layers in the export policy:
Layer 1: HTTP tools only expose GET/HEAD by default. Before a tool is exported, Baize looks at its HTTP method. Anything that isn't GET or HEAD isn't exported, unless the tool is explicitly marked force_allow.
Layer 2: MCP-sourced write tools are hard-rejected. Baize itself also connects to external MCP servers as a client. Tools coming out of those servers — if their name or description contains insert, update, delete, drop, truncate, execute_write, and similar — are never exported, even if someone tries to force_allow them.
Layer 3: Tools that require approval are not exported. Write operations flagged in Baize as "need human confirmation before calling" (e.g. creating a record) simply don't appear on the export surface — because the export channel has no approval card, and exposing one would mean bypassing human review.
Layer 4: Policy is re-checked at call time. Tools pass policy once when they're registered into the MCP catalog, and again when they're actually invoked. That means if you disable or un-export a tool in Baize's backend, the next external client call fails immediately — no "catalog still lists it but it's already off" gap.
5. How login state is handled
This is the subtlest part of the export path. Legacy APIs require login. Credentials are configured in Baize's console, but when an external client (say, Cursor) calls a tool, whose login state does the call go out with?
Baize's approach: each export identity is bridged to a separate internal identity. It has its own session namespace (prefixed with mcp-export-id: plus the export identity ID), and every call is forced through that identity. Concretely:
- You log in once inside Baize for the "Cursor identity" and configure its credentials.
- When Cursor calls any tool with that key, Baize uses that identity to reach the legacy system.
- The "Claude Desktop key" goes through a different identity, a different login state — the two never cross.
The user inside Cursor never sees the legacy system's login page; credentials are already maintained on Baize's side.
6. The actual usage path
Three steps:
- Wire up the business system in Baize's console (OpenAPI doc import, login done, approval rules configured).
- Create an export identity in the export settings and generate a key.
- Plug Baize's MCP Streamable HTTP endpoint and the Bearer key into your Agent client (Cursor, Claude Desktop — any MCP-supporting client works the same way).
After that, say something in the client; the client model picks which already-wired Baize tool to call, and Baize handles execution and returns the result.
7. How this relates to the previous article
In the earlier post ("How I wired a ten-year-old Java legacy system into AI with zero changes"), MCP only got a short paragraph — the focus was how to bring a legacy system in without touching it. This article zooms in on the MCP server side: Baize doesn't just turn legacy systems into its own tools, it can also share those tools with whatever Agent clients the team already uses.
Putting bidirectional MCP together, Baize's position in the ecosystem is clear: on the way in, it can connect to external MCP tool servers and pull others' tools in; on the way out, it can expose its own wired catalog over MCP so the team doesn't reconfigure anything twice.
8. Closing
"Wire the business API once, let every Agent client on the team use it" sounds like just "sharing a tool catalog." Doing it for real means a pile of concrete questions: how to block writes, how to manage keys, how to isolate login state, how to make config changes take effect immediately. Baize converges those into three areas: export policy, key management, and identity bridging. The default is conservative (read-only, approval-gated things stay hidden, revocable); opening anything up takes an explicit action at each step.
This export capability is still evolving, and different clients support MCP at different depths. Try it out and open an issue — or tell me in the comments how your team connects business tools to your own clients. I'll keep iterating.
Top comments (11)
The call-time re-check (layer 4) is the part most export layers skip, nice to see it. Two places I'd expect the other layers to leak with real legacy systems:
GET /orders/approve?id=...orGET /export/runthat change state. An OpenAPI import can't tell you that, so a per-operation "this GET has side effects" flag (default off for anything with a verb in the path) is cheaper than finding out from an audit log.archive_record,set_status,send,sync. Recent servers also declarereadOnlyHint/destructiveHintannotations, which help, but they are self-reported by the server you're trying to guard against. Default-deny plus an explicit allowlist per export key holds up better than a denylist of words.One more thing worth spelling out in the doc: whether the bridged identity's session can expire mid-call and what the client sees then. Cursor and Claude Desktop both surface a tool error to the model, and the model will happily retry or "work around" it unless the error text says plainly that a human has to re-login in Baize.
The systems that never got an OpenAPI doc at all (old desktop apps, admin panels) are the slice we work on at Auten, driving the screen instead. Where an API exists, a centralized layer like this is the better path.
Thanks — this is exactly the kind of legacy-system detail worth being precise about. Point by point against the current code:
GETs with side effects. There's a per-operation export flag with three states —
default,force_allow,force_deny— soGET /orders/approvecan be pinnedforce_denyand disappears from the catalog (and the call-time re-check means it fails immediately if flipped later). What we don't have yet is your heuristic of default-flagging anything with a verb in the path. Today that's manual, so it's only as good as the person doing the import — auto-suggestingforce_denyfor verb-in-path GETs during OpenAPI import is on the list.The write-word list is a denylist, and you're right that it's the weaker link. The hard-deny list is currently
insert/update/delete/drop/truncate/execute_write, and it only applies to tools coming from upstream MCP servers (HTTP-imported tools go through the verb layer instead).archive_record,set_status,send,syncwould indeed slip through today. We don't readreadOnlyHint/destructiveHintyet — and I agree with your caveat: annotations from the server you're guarding against are a signal, not a verdict, so at most they'd raise suspicion, never grant passage. The end state you describe — default-deny plus an explicit allowlist per export key — is strictly stronger than maintaining a word list; that's the direction I'd rather move. Noted and tracked.Session expiry mid-call — fair catch, and the honest answer is it isn't as explicit as it should be. Today a call against an expired bridged session surfaces the underlying executor error verbatim as an MCP tool error; there's no dedicated "identity invalid, human re-login required in Baize" error class. So the exact failure you describe — the model retries or tries to work around it — is open. The fix is fairly contained: classify auth/session failures into one fixed error string that tells the client model to stop and hand back to a human, instead of passing through whatever the upstream returned. I'll track it.
Agreed on the boundary too: where an API exists, even a rough one, a centralized execution and identity layer is cheaper to operate; screen driving is the only sane path for desktop apps and admin panels that never grew one. They meet in the middle once those panels get even a thin wrapper.
Once any of these land — the verb-in-path suggestion, the per-key allowlist, or the explicit re-login error — I'll come back and leave a note here so you can see how it actually behaves.
Thanks for going through it point by point, the force_deny state already covers the worst case.
On the re-login error, one detail worth trying: phrase it as an instruction to the model, not a status. Something like "Session for expired. Do not retry this or other Baize tools. Ask the user to re-login in Baize, then continue." and return it with isError set. Models treat a bare 401 as a puzzle to solve; they mostly stop when the error tells them what the next step is and who owns it.
Looking forward to the note when it lands.
Back with the note I promised — all three points from your thread are in v0.6.0 (and main).
1) Side-effect GETs
OpenAPI import now defaults verb-in-path GET/HEAD (approve, export, run, delete, create, update, trigger, execute, sync, send, …) to export=force_deny. Manual force_allow / force_deny is preserved on re-import. force_deny still wins even if someone puts the tool on a key allowlist. Plain GETs stay on the default rule.
2) Default-deny + per-key allowlist
Each export key has an explicit allowed_tools list; empty means no tools (fail-closed). Two keys can share an identity and expose different tool sets. list and call both re-check. readOnlyHint / destructiveHint are stored as signals only — they never grant passage by themselves. The old “word denylist as the hard gate for upstream MCP writes” is no longer the main control plane; the key allowlist is.
3) Mid-call auth / session expiry
Auth failures return isError with instructional copy (do not treat it as a puzzle; do not paste passwords into chat; do not retry Baize export tools until the user confirms sign-in). When hosted login is configured, the result includes a public hosted_login_url (and URL elicitation when the client supports it). Clients without elicitation get that URL immediately in the tool error — we no longer await login completion before returning the link.
One deliberate change vs “re-login in Baize”: the primary path is the hosted login page on a public URL, so operators don’t have to reach an intranet settings UI from Cursor / other agents. Credentials still never go through the LLM; they land on the export identity / slot.
Release: github.com/rebornace/baize/release...
Thanks again for pushing on the leaky layers — the allowlist + instructional error path especially.
That's a big step, thanks for coming back with it. Empty allowlist = no tools, and annotations as signals only, is exactly the line we'd want as a client.
One thing worth checking on the hosted login path: since hosted_login_url now shows up in a tool error, it ends up in the model transcript (and in any logs or shared chats). If the link is short-lived, single-use and bound to that export key's slot, a copied transcript can't be used later to sign someone else's account into that slot. Is it scoped that way already?
Good catch — worth being precise about what the URL is and isn’t.
What it is today:
What a copied transcript can still do:
If we tighten further, the natural next steps would be one-time open (burn on first GET), shorter TTL for MCP-issued links, and/or binding the session to the originating client/key presentation — happy to take that as follow-up if you see a threat model that needs it.
That's a clear answer, thanks. Expiry + single successful use + slot binding covers the case I had in mind: transcripts that get logged or pasted into a ticket after the fact.
One caution if you look at burn-on-first-GET: chat clients and link scanners often prefetch URLs (Slack/Teams unfurls, mail security scanners), so the link can get burned before the user ever clicks it. Keeping GET idempotent and only consuming the session on the CSRF-checked POST, like you do now, avoids that. A shorter TTL for MCP-issued links seems like the cheaper win.
Agreed — burn-on-first-GET is a bad fit here. Prefetch/unfurl scanners would eat the session before a human ever opens it, so we’ll keep GET idempotent and only consume the session on the CSRF-checked successful POST (as today).
Shorter TTL for MCP-issued hosted links is the follow-up I’ll take: leave the console/test-session path on the normal window, tighten the auth-failure / elicitation links that land in tool errors and transcripts. Thanks for the caution.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.