Two-way conversation between an MCP App and its agent
Questions about a passage, answered next to it, without anyone typing in the chat. The agent is GitHub Copilot in VS Code; the video is real time, not sped up.
The MCP protocol lets a server call back into the host’s LLM through sampling, but hardly anyone supports it: according to this compatibility matrix, not even Anthropic, the creators of MCP, support it in Claude Desktop or Claude Code, and the 2026-07-28 version of the protocol deprecates it. Sampling would not give the server the host’s chat history either. The MCP Apps extension gives the app two ways to talk to the chat: updateModelContext() and sendMessage(). Neither one gives the agent a turn. The context update waits for the user’s next message, and in the hosts I have tried, the message lands in the user’s input box and waits for them to press send. Even then, the agent’s answer goes into the chat, not back into the app.
Given those limits, is an MCP App stuck with a fixed set of behaviors? Not if you think outside the box for a moment. In this post I show how the app in the video above holds a seamless conversation with the agent. For a reference implementation, ask lustereczko-mcp to build you one; how lustereczko generates apps on the fly is a topic for another post.
Many tutorials jump right into implementation without explaining the key concepts, and we will not make that mistake here. The concepts are simple, and they are built right on top of existing MCP functionality.
The idea
The one layer that both the agent and the app can reach is the MCP server’s tools. The agent calls tools as it always does, through the host. The app can call the same server’s tools with app.callServerTool(), which the host relays without involving the LLM. So if one side writes a message through a tool and the other side reads it through another tool, they can talk.
The app can poll for messages on a timer. The agent cannot: it only acts during a turn, and something has to give it one. For that, the agent sets up either a cron job or a watcher, a small background command that exits when the app has posted a message. The host notices the exit and wakes the agent.
There is one more wrinkle. A local MCP server runs over stdio: the host launches it as a subprocess, restarts it when it dies, and owns its lifecycle. The spec calls the protocol stateless, and some hosts, Claude Desktop among them, run several copies of the server at once. The agent’s call and the app’s call may land in different processes, so nothing can live in a process’s memory. The two sides need a named channel, and storage that every process shares.
That is the whole trick. Here it is in one picture, numbered in the order of one question and its answer:
- 1 The user highlights a passage in the app and asks about it.
- 2 The app calls
notify_agent, which appends the question to the channel's app-to-agent file. - 3 The watcher sees the file is no longer empty and exits. The host tells the agent, which gives it a turn.
- 4 The agent calls
poll_agent_messages, which returns the question and clears the file. - 5 The agent answers with
notify_ui, which appends to the agent-to-app file, and starts the watcher again. - 6 The app's next
poll_ui_messagesreturns the answer, and the app shows it next to the passage.
The same round trip as a sequence
The four tools
In lustereczko, a channel is four tools and two files, one file per direction (source). You could back a channel with a proper database, but a file is the default and it is enough.
| Tool | Called by | What it does |
|---|---|---|
notify_ui |
agent | Appends {event, data} to ui_<channel>.json |
poll_ui_messages |
app, every 1-1.5 s | Returns everything in ui_<channel>.json and empties it |
notify_agent |
app | Appends {event, data} to agent_<channel>.json |
poll_agent_messages |
agent, when woken | Returns everything in agent_<channel>.json and empties it |
The two tools the app uses are declared with app-only visibility, so the host leaves them out of the agent’s tool list, and the agent cannot drain the app’s queue by mistake.
The agent picks the channel id before it renders, for example ch-1749312345678, and bakes it into the HTML as a constant. Both sides know it from the start, so there is no handshake, and every render gets a fresh channel. An old copy of the app that is still running keeps polling its own channel and receives nothing meant for the new one. The id doubles as part of the file name, so the server only accepts letters, digits, hyphens and underscores (_CHANNEL_RE), which keeps a channel id from turning into a path.
Why it is safe across processes
Every append and every drain opens the file and takes an exclusive flock on it before reading, then writes the new contents and lets go when the file closes (_queue_append, _queue_drain). The lock belongs to the operating system, not to a process, so it does not matter which copy of the server a call lands on: two appends cannot interleave, and an append cannot slip in between a drain’s read and its clear. Reading and clearing in one locked step means each message is handed out once. That assumes one reader per direction, which is the case: one app polls one channel, and one agent drains it.
The watcher is the exception that proves the rule. It does not go through MCP at all: it reads the agent’s file straight off the disk, without the lock, and it never writes. If it catches the file in the middle of a write, it sees an empty or half-written file, treats that as nothing, and looks again half a second later. Because it removes nothing, the worst it can do is wake the agent half a second late. The real read still goes through poll_agent_messages, under the lock.
The drain and the watcher side by side
Both are trimmed to their shape. The watcher is not part of the server: the agent writes it from the bidirectional events recipe and runs it as a background command.
def _queue_drain(queue_file):
with open(queue_file, "r+") as f:
fcntl.flock(f, fcntl.LOCK_EX)
items = json.loads(f.read() or "[]")
f.seek(0)
f.truncate()
json.dump([], f)
# closing the file releases the lock
return items
q = CHANNELS / f"agent_{channel}.json"
while time.monotonic() < deadline:
try:
items = json.loads(q.read_text()
or "[]")
except (FileNotFoundError,
json.JSONDecodeError):
items = [] # mid-write: look again
if items:
sys.exit(0) # the exit wakes the agent
time.sleep(0.5)
sys.exit(2)
fcntl.flock exists on macOS and Linux. On Windows the server would need a different lock, or a database.
Waking the agent
The app’s half is easy: it polls poll_ui_messages on a timer for as long as it is open. The agent’s half depends on what the host offers.
- A watcher. If the host tells the agent when a background command finishes, the agent starts the watcher before it renders the app, and starts it again after every answer. GitHub Copilot in VS Code does this with background terminal commands, and so does Claude Code with background Bash commands and its Monitor tool, which only interrupts the agent when the script prints a line. Waiting costs no tokens, and the chat stays free for the user.
- A cron job. Hosts with scheduled prompts, such as Claude Code’s
/loopand scheduled tasks, can runpoll_agent_messageson a schedule instead. It is simpler, but the schedule fires a prompt, not a script: every run is a turn for the model, even when there is no mail, and a reply can take up to a full interval, at least a minute.
Background wake-ups are not instant either; in Copilot they can take 10 to 30 seconds. For the video, the agent stayed in its turn and ran the watcher in the foreground, answering each question as soon as it arrived. More detail on both options is in the bidirectional events recipe, and the reader itself follows the margin comments recipe.
Try it
If you would like to try this yourself, install lustereczko-mcp as a local MCP server, and ask your agent to build you the Declaration of Independence interactive reader. It is tested in the Claude desktop app, where MCP App support has moved from Cowork to Code, so make sure you are in the Code tab, and in GitHub Copilot in VS Code. Please let me know if you get it working in any other MCP host.
Thank you for reading, and let me know your thoughts.
Comments and reactions