DEV Community

Cover image for I embedded an MCP server inside my macOS app so agents could talk back
Kirill Lukyanov
Kirill Lukyanov

Posted on Originally published at klukyanov.ru

I embedded an MCP server inside my macOS app so agents could talk back

My dictation app for macOS was one-way. I would speak a thought into it, it would clean up the text, add the role and project context, and drop a polished prompt into the clipboard. Then came the humiliating part of the loop: switch to the terminal, paste, hit Enter, and read the output in small type.

The asymmetry was obvious from day one. Outbound: voice. Inbound: a wall of text. The app could talk to a human but couldn't listen to an agent; the agent could do the work but had no way to say anything back except print it.

That loop is closed now. The app runs its own MCP server inside itself, and Claude Code or OpenCode connect to it like any other tool. I dictate a task and it lands in the agent's session on its own. The agent works, and along the way it can ask me something out loud and wait for a spoken answer — and when it's done, it reads the summary aloud. Hands on the coffee, eyes anywhere.

Why the server lives inside the app

The obvious first design is the other way around: let the app spawn claude as a child process, write to stdin, read stdout. That's how dozens of CLI wrappers work.

That path is closed for me. PromtsMaker ships on the Mac App Store, which means App Sandbox, where Process and NSTask are forbidden outright. A sandboxed app cannot launch someone else's binary, and there is no entitlement that buys you out of it.

So the initiative in the protocol is inverted. The app stands up a local HTTP server on 127.0.0.1 and speaks MCP. The agent connects to it like it would to any MCP server and asks, on its own: "got a task for me?" One line:

claude mcp add --transport http promtsmaker http://127.0.0.1:8765/mcp
Enter fullscreen mode Exit fullscreen mode

The app probes ports upward from 8765 and takes the first free one. A Network.framework subtlety worth knowing: NWListener only reports a busy port asynchronously through stateUpdateHandler, so starting the server has to be async throws rather than a quick synchronous check.

To the user none of this is visible — they dictate and press a button. But in the protocol it's the agent that asks and the app that answers, and the entire design downstream grows out of that inversion.

Four tools, and that's it

The temptation was to ship twenty. I resisted: the fewer tools you expose, the more reliably a model picks the right one.

Tool What it does
wait_for_prompt Long-poll for the next dictated task. Hangs up to 300s (55 by default), then returns timeout and the agent calls again. This is the heart of the loop.
say Speak a short line out loud mid-task: "starting", "tests are green". Always audible — there is no toggle, that's the entire point of the tool.
ask_user Ask a question by voice and wait for a spoken answer, up to three minutes. The app starts dictation, transcribes, and hands the answer back as a string.
report_result The outcome: a short summary (read aloud) plus optional details — the full text that stays on screen.

No "read file" or "run command" tools — the agent already has those, and the app has no business duplicating them. In this design the app isn't the agent's assistant. It's the agent's mouth and ears.

Long-polling instead of a "check" button

The most interesting mechanic in the module lives inside wait_for_prompt. When the agent calls it and the prompt queue is empty, no HTTP response is written: the call suspends on a CheckedContinuation that goes into a dictionary of waiters. The moment I press "Send to agent," the action reaches the store, the store pulls the right waiter out and resumes it — and the dictated text flies out through that very HTTP request still hanging open.

If nothing happens within 55 seconds, the waiter resolves with timeout, the connection closes, and the agent immediately calls again. Turning the bridge off needed explicit care: bridgeToggled(false) has to resume every pending waiter across every project at once, or flipping the switch leaves agent sessions hanging forever.

The same construct backs ask_user — except it resolves on my spoken answer, or on cancel.

Who trusts whom

A server inside your app is a hole exactly as big as you make it. What stands between it and the world:

  • Loopback only. The listener comes up with requiredInterfaceType = .loopback; the port is never exposed outward. Exactly one new entitlement was needed: com.apple.security.network.server.
  • Bearer token. On first enable, 32 bytes from SecRandomCopyBytes, base64url-encoded, stored in UserDefaults. Without the Authorization header the server returns 401 even for tools/list. Rotation is a button, with confirmation.
  • Origin check. An empty header (a plain HTTP client, which is what agents are) passes; a non-empty one must start with http://127.0.0.1 or localhost, otherwise 403. That's the MCP spec's requirement for local servers — protection against DNS rebinding, where a page in your browser tries to reach your local port.

The rule that isn't in the code

The first live version of the bridge was unbearable. The agent dutifully called say — and started reading the output of git diff out loud. All of it. A speech synthesizer cannot skim.

The fix isn't code. The instructions string the server returns from initialize spells out an explicit policy: keep anything spoken to one or two sentences — a longer text is faster to read than to sit through. If the result is bulky, keep summary to one line, put the full text in details, and only read the details after asking via ask_user: "the result is long — read it all out?"

The code neither checks nor truncates any of this. It relies on the agent following the system text — and the agent does. That's a genuinely odd shift in engineering practice: part of the system's behavior now lives in an English paragraph rather than in branches, and you debug it by rewriting sentences.

Several projects at once

The first version kept a single state for the whole app — and fell apart the moment I opened two project tabs with an agent session attached to each: the second session clobbered the first one's prompt, question and result.

The routing key became project_path, the agent's working directory — the same identifier the app already uses to tell its own tabs apart. All four tools take it as an optional argument, and the prompt queue, in-flight prompt, pending questions and last result all split into dictionaries under that key. The conversation feed stayed shared across projects; each message just carries the folder name as a label.

Onboarding through the agent's memory, not a project file

One awkward seam remained: the agent has to know the rules of the loop — live inside wait_for_prompt, always pass project_path, don't read walls of text aloud. The obvious solution is to have the user paste an instruction block into CLAUDE.md or AGENTS.md. That is, redo it by hand in every new project.

Instead, instructions asks the agent, on first connect, to save that note into its own persistent memory — the loop, the project_path requirement, the speech-length rule. The next session in the same project already knows the rules, nothing needs pasting, and the project's own files stay clean of clutter about a tool only one developer uses.

What broke during live acceptance

452 unit tests being green (about forty-five of them written specifically for the bridge: HTTP byte parsing, the JSON-RPC router, the reducer, side effects) guarantees nothing about a live run. Acceptance against a real Claude Code produced a plot twist you don't invent at a desk.

The very first ask_user on a fresh machine starts dictation — and runs straight into the macOS system alert "Allow access to speech recognition". While that alert sits there waiting for a human, the question's timeout is ticking. The agent, meanwhile, is patiently waiting for an answer that physically cannot arrive. It's a one-time story — the permission is granted once and forever — but on a clean machine your first encounter with the feature looks like "it doesn't work".

The bridge that finished writing itself

The best part is the one I didn't plan. The last round of work on this bridge was dictated through the bridge itself.

I was sitting there using the app, noticing rough edges, and saying them out loud: "the screen has grown, let me hide the panels I don't use" — and the agent built a settings window with four checkboxes. "The environment grid is truncating labels, move it to its own row" — and the agent reworked the layout. "I've got two tabs and two sessions and they're fighting" — and project_path routing appeared, the thing described above.

None of these were on a roadmap. All of them came out of live use, and every one reached the code by voice, without a single switch to the terminal. A tool that grows far enough to take part in its own development is a rare feeling, and in hindsight that's what the whole thing was for.

Where to try it

The bridge runs on my machine every day, but it isn't in the Mac App Store yet: the latest published version of PromtsMaker is 1.3, which shipped before this story. The bridge goes out in the next update, 1.4. I won't promise a date — Apple's review queue has been noticeably longer lately, and that part isn't up to me.

The app itself is free on the Mac App Store.

Originally published at klukyanov.ru.

Top comments (0)