Skip to content

feat: serve yoink over MCP with yoink mcp - #12

Merged
machado144 merged 5 commits into
mainfrom
feat/mcp-server
Jul 27, 2026
Merged

feat: serve yoink over MCP with yoink mcp#12
machado144 merged 5 commits into
mainfrom
feat/mcp-server

Conversation

@machado144

@machado144 machado144 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Serves yoink over the Model Context Protocol so agents manage background processes as native tools instead of shelling out and parsing terminal output.

Why

Agents drive yoink today by running the CLI and reading the text back. Three problems:

  1. State comes back as a table. Deciding "is the build done?" means string surgery on a display format built for humans.
  2. The useful behaviors are invisible. log --new (incremental polling) and snapshot (rendered TUI screen) are what make yoink worth using from an agent, and both only get discovered if a skill file mentions them.
  3. No equivalent of wait. Blocking on N processes with a timeout is the one thing an agent can't express as a shell call.

What

yoink mcp serves the daemon over MCP on stdio. It is a client of the daemon, not a second process manager — each tool call becomes the same DaemonRequest the CLI sends.

That means the CLI and an agent share one process table:

agent (MCP client) <-stdio-> yoink mcp --> yoinkd <-- yoink ls (your terminal)

An agent's yoink_ls lists the dev server you started by hand, and you can yoink attach to something an agent spawned.

Eight tools: yoink_run, yoink_ls, yoink_log, yoink_send, yoink_snapshot, yoink_wait, yoink_kill, yoink_clean.

Not exposed: attach (needs a controlling TTY), daemon (implementation detail), version (in the handshake). send-redacted and --type are flags on yoink_send rather than separate tools.

Install

Nothing new to download — it compiles into the existing binary:

claude mcp add --scope user yoink -- yoink mcp

yoink_wait is bounded and resumable

MCP clients cap tool-call duration (commonly ~60s), so a naive wait --timeout 300 would be killed by the client and surface as a transport error.

Instead timeoutSeconds defaults to 30, clamps to 55, and hitting it is not an error:

{ "done": false, "timedOut": true, "processes": [
    { "process": "build", "state": "running", "output": "compiling...\n" } ] }

The agent calls again to keep waiting. Draining uses the same cursor as log --new, so resuming never re-reads output it already saw. A failed process is likewise a normal result (state: "failed" with exitCode), not a tool error — errors are reserved for malformed requests and an unreachable daemon.

Also in here

A data race fix in ProcessManager (4f4b4de), found by the new tests under -race. monitorProcess writes info.State from its own goroutine the instant a process exits, but SendInput, Kill and GetPTY read it after releasing the read lock. Any client acting on a process that is exiting concurrently raced. All three now go through a lookupRunning helper that does the lookup and state check under one lock.

This is in the shared core, so it fixes yoink send/kill/attach for CLI users too, not just MCP.

Lint cleanup (8d657f8): 33 unchecked error returns made explicit. Nearly all were deliberate (stdout writes, cleanup-path Close, fire-and-forget test calls). One was a latent bug — yoink ls discarded the tabwriter Flush error, silently printing truncated output on failure. golangci-lint run is now clean.

Tests

Everything runs real — real daemon, real PTYs, real processes, no mocks.

  • services/mcp_service_test.go — MCP server over an in-memory transport against a real daemon: tool surface, run/log/ls round-trip, log --new returning each line exactly once, send reaching a process blocked on read, send --key ctrl+c, snapshot rendering, wait timeout/failure/multi-process/clamping, kill + clean, and 12 negative cases (unknown alias, mutually exclusive args, unknown key name).
  • integration/mcp_test.go — builds the binary and speaks MCP over stdio to yoink mcp: handshake, tool list, run/wait round-trip, CLI and MCP sharing one daemon in both directions, and a stdout-cleanliness regression check (stdout is the protocol stream).
  • services/process_manager_test.go — race regression test that trips -race before the fix.

go test -race ./... — 215 passing, run 4x for flakiness. gofmt, go vet, golangci-lint all clean.

monitorProcess writes info.State from its own goroutine the moment a
process exits, but SendInput, Kill and GetPTY read it after releasing
the read lock — a data race whenever a client acts on a process that is
exiting at the same time.

Route all three through a lookupRunning helper that does the map lookup
and the state check under one read lock, matching what CleanAll already
does. Adds a regression test that trips the race detector before the fix.
Agents drive yoink by shelling out and parsing `yoink ls` output, which
means string surgery on a display format built for humans. The two
behaviors that make yoink useful to an agent — `log --new` for
incremental polling and `snapshot` for TUIs — are also invisible unless
someone loads a skill file that mentions them.

`yoink mcp` serves the daemon over the Model Context Protocol on stdio,
exposing eight tools that return structured state. It is a client of the
daemon rather than a second process manager, so the CLI and an agent
share one process table: an agent's `yoink_ls` lists the dev server you
started by hand, and you can `yoink attach` to what an agent spawned.

Nothing new to install — it compiles into the existing binary:

    claude mcp add --scope user yoink -- yoink mcp

`yoink_wait` is bounded and resumable, since MCP clients cap tool-call
duration: hitting timeoutSeconds returns done=false/timedOut=true with
the drained output rather than an error, and the agent calls again.

`attach` is not exposed (it needs a controlling TTY); send-redacted and
--type are flags on `yoink_send` rather than separate tools.
golangci-lint reported 33 unchecked error returns across the repo.
Nearly all are deliberate — writes to stdout, Close/Remove on cleanup
paths, fire-and-forget calls in tests — so they are now marked as such
rather than left ambiguous.

One was a latent bug: `yoink ls` discarded the tabwriter Flush error, so
a failed flush would silently print truncated output. That error is now
returned.

golangci-lint run is clean.
@github-actions

Copy link
Copy Markdown

✅ PR title follows the required format

Current title: feat: serve yoink over MCP with 'yoink mcp'

dupehound flagged the eight near-identical AddTool blocks in
registerTools. The SDK's AddTool is generic over each tool's input and
output types, so the handlers cannot go in a table; a thin addTool
wrapper gets each registration down to one call instead.

Also extracts connectMCPTo so the shared-daemon test reuses the standard
connection setup rather than repeating it.
The rationale that mattered already lives where readers look for it:
wait semantics and the not-exposed commands in docs/COMMANDS.md, the
daemon-client design in docs/README.md.
@machado144
machado144 merged commit e7705ab into main Jul 27, 2026
8 checks passed
@machado144
machado144 deleted the feat/mcp-server branch July 27, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant