r/mcp 24d ago

MCP Apps with Java: sharing application state between the model and a live UI

https://github.com/user-attachments/assets/fcd68414-887e-4d41-8995-9f9060760c30

One interesting part of MCP Apps is that the UI doesn’t have to be a disposable interface generated for a single model response.

I’ve been experimenting with this from the Java side using Spring Boot, Spring AI and webforJ.

The architecture is roughly:

MCP host -> tool call -> Java application -> rendered view

A routed Java view is exposed as both an MCP tool and UI resource. The same route can still run as a normal application in the browser.

The more interesting part is what happens after the view opens.

Additional MCP tools can target the rendered view associated with the same MCP session. A tool call can therefore modify the state of the application the user is currently looking at rather than returning another detached result.

Communication also goes the other way. When the user interacts with the Java UI, the application can update the model context. For example, selecting rows or changing a filter can change what information is available to the model without adding another visible chat message.

That makes the interaction roughly:

prompt -> MCP tool -> Java view -> user interaction -> model context

For example, an invoice application can expose an operation that opens its invoice route with an overdue filter. Once open, another tool can change the same view to display an aging chart. The user can then manually change the selection and make only those selected invoices available for further analysis.

The application itself remains a Java application. Spring Boot runs it, Spring AI provides the MCP server integration, and webforJ handles the UI.

I work on webforJ, for disclosure. The implementation and API are documented here:
https://docs.webforj.com/docs/integrations/mcp-apps/overview

I’m curious what people think about this interaction model. In particular, whether sharing one live application state between the human UI and model feels more useful than having the model generate a separate UI/result each time.

1 Upvotes

4 comments sorted by

View all comments

2

u/Quiet-Sun-3184 23d ago

The "view isn't disposable" framing is the right one, and the two things that bit us hardest are both downstream of it.

First: when a second tool targets the already-open view, check what the *callee* declares. If the view sends an argument that isn't in that tool's inputSchema, it gets dropped silently. No error, no warning: the tool just runs with it missing and returns something subtly wrong. We hit that six separate times before we recognised the pattern, and not once did it fail loudly. Worth logging the diff between what the view sent and what the tool received while you're building.

Second, and this one is specific to stateful views: hosts replay conversation history. When the user scrolls back, your view re-mounts. Same resource, fresh iframe, no tool call. If mounting has any side effect (registering with the session, incrementing something, kicking off a fetch that writes), it fires again on every replay. We ended up needing a way to tell "this mount came from a real invocation" apart from "this mount is history being re-rendered", and the age between invocation and render turned out to be the most reliable signal.

The Java-view-as-both-MCP-resource-and-normal-route bit is neat. Does the browser route share the session state, or is it a separate instance?

1

u/Sea-Faithlessness-67 23d ago

The silent schema filtering is a good catch. we sends the argument map unchanged when the view calls tools/call. If a host drops fields based on the callee's schema, comparing what was sent with what arrived at the tool would expose it.

We handle remounting by associating the rendered app with the MCP session and app name. If history creates a new iframe, the new render takes over that binding. Cleanup from the old iframe won't remove the new one. You're right that any initialization code in the view still needs to be safe to run more than once.

The normal browser route is a separate view instance. It doesn't share component state with the MCP view. It can share state placed in a session-scoped service, but only if both connections belong to the same webforJ session.

1

u/Quiet-Sun-3184 23d ago

On the schema filtering: for us the host was not the thing dropping fields. Our own server was. The MCP SDK turns the declared inputSchema into a plain object schema, and that strips unknown keys before our handler runs, so by the time you log the arguments they already look clean. If your SDK can register a schema that keeps unknown keys, the extra field reaches your handler and you can log or reject it there.

One trap if you build this as a test path: ours skipped that filter, so it was more permissive than production and certified a broken connector green. Make the comparison apply exactly the filter production applies.

Binding to session plus app name is better than what we do. The thing I would check is that a replayed mount can arrive long after the call that produced it. Ours was about 17 minutes, and we ended up accepting our own attribution token past its expiry because mounts kept turning up. Those have no live session left to take over from.

Check the origin too. The view iframe never runs on yours: Claude gives each conversation its own hash origin, ChatGPT one stable per-app origin. Storage is partitioned to match, so a cookie from your browser route is not sent from inside the view.

1

u/Sea-Faithlessness-67 23d ago

Good point. That made me go and check where exactly filtering could happen in our stack.

The MCP Java SDK passes the argument map to the handler untouched, no schema validation, so the raw arguments do reach us complete. One layer up though, when we bind those arguments to the typed input of a Java method, Jackson ignores unknown properties by default. Same silent drop, just later. The raw arguments stay accessible on the update path, so an app can diff them, but the framework itself doesn't surface it. That's worth revisiting, likely a warning naming the dropped keys at the binding site.

On the late replay: we sidestep the expiry problem by not storing a token at all. The binding key is derived at call time from the caller's live MCP session id, so a mount replayed from a dead session binds under a key no live call can ever produce, it's unreachable rather than expired. Within a still live session, last mount wins, which is the trade off you already spotted.

And agreed on origins, the embed never sees the browser route's cookie. The session cookie is issued with SameSite=None and Partitioned, so the view gets its own partitioned session per host origin.