Skip to content

feat: add mark_read MCP tool that syncs read state to Google Messages - #186

Open
bwonmp wants to merge 1 commit into
MaxGhenis:mainfrom
bwonmp:upstream/mark-read-tool
Open

bwonmp wants to merge 1 commit into
MaxGhenis:mainfrom
bwonmp:upstream/mark-read-tool

Conversation

@bwonmp

@bwonmp bwonmp commented Sep 23, 2026

Copy link
Copy Markdown

Today POST /api/mark-read zeroes the local unread_count and stops there, so a thread cleared in OpenMessage still shows unread on the phone. The Google adapter already has MarkRead (internal/bridgeadapters/google/send.go), but it is only reachable through the v2 outbox, which is off wherever the v2 flags are not set.

This wires that up and exposes it as an MCP tool.

What it adds

app.SyncConversationReadWith — clears the local flag, then advances the remote Google read cursor, defaulting to the newest incoming message in the thread. Remote failure is deliberately non-fatal: clearing a badge should not start returning 500s whenever Google happens to be disconnected. The result reports local and remote outcomes separately, so a caller can be honest about which actually happened.

mark_read tool — accepts one conversation id or a list, throttled at 250ms between remote calls so a stale-backlog cleanup does not hammer Google. It ships with a daemon handler routing through POST /api/mark-read, without which the tool is dead in serve --mcp-stdio — a transportless client with a nil GetClient, which is the shape MCP hosts actually spawn.

The web handler routes through the same helper, so the UI clears the phone badge too.

Two things worth a second look in review

getClient(), not the bare cli parameter. Resolving the client from APIHandlerWithOptions' cli argument silently means "no client" — every caller passes nil for it (cmd/serve.go, cmd/e2e-server, every test) and supplies the live client via opts.Client. I shipped it that way first and the symptom was perfect: the local flag cleared, the endpoint returned 200, and the phone was never touched.

A remote skip logs at Warn, not Debug. At Debug it is invisible at the default level, and a mark-read that never reached the phone looks exactly like a successful one from the caller's side — the only symptom is a badge that stays unread on the device. It now says so: Marked read locally only; the phone was NOT updated.

Note for callers

On RCS this sends a visible read receipt to the other party, so it is worth treating as a user-initiated action rather than something an agent runs speculatively. That is stated in the tool description.

Scope

Google Messages only. WhatsApp's adapter has MarkRead too and could follow the same path; Signal intentionally declares no read receipts.

Tests

$ gofmt -l <files touched>   # clean
$ go vet ./internal/app/ ./internal/tools/ ./internal/web/ ./internal/localapi/   # clean
$ go test ./internal/app/... ./internal/tools/... ./internal/web/... ./internal/localapi/...
ok  github.com/maxghenis/openmessage/internal/app        1.156s
ok  github.com/maxghenis/openmessage/internal/tools      2.714s
ok  github.com/maxghenis/openmessage/internal/web        4.768s
ok  github.com/maxghenis/openmessage/internal/localapi   1.276s

Exercised in anger as well: a bulk run over 491 stale unread threads at 1/second completed with zero rate-limit errors, 449 advancing the remote cursor and 42 correctly reporting local-only (outgoing-only threads with no incoming message to advance a cursor to).

Happy to split the tool and the web wiring into separate commits, or to drop the 250ms throttle to a parameter, if you'd prefer.

The web UI's mark-read only zeroed the local unread flag, so a thread cleared
in OpenMessage still showed unread on the phone. Google read receipts already
existed in the Google adapter but were reachable only through the v2 outbox,
which is off wherever the v2 flags are not set.

Adds app.SyncConversationReadWith: clears the local flag, then advances the
remote Google read cursor, defaulting to the newest incoming message in the
thread. Remote failure is deliberately non-fatal, so clearing a badge cannot
start returning 500s whenever Google is disconnected; the result reports the
local and remote outcomes separately so a caller can be honest about which
actually happened.

The new mark_read tool accepts one conversation id or a list, throttled at
250ms between remote calls so a stale-backlog cleanup does not hammer Google.
It ships with a daemon handler that routes through POST /api/mark-read --
without that the tool is dead in `serve --mcp-stdio`, which is the shape MCP
hosts actually spawn: a transportless client with a nil GetClient.

The web handler is routed through the same helper, so the UI clears the phone
badge too. Resolving the client there uses getClient() rather than the bare
`cli` parameter of APIHandlerWithOptions -- every caller passes nil for that
(cmd/serve.go, cmd/e2e-server and every test) and supplies the live client via
opts.Client, so reading `cli` silently meant "no client": the local flag
cleared, the endpoint returned 200, and the phone was never touched.

A remote skip now logs at Warn rather than Debug ("Marked read locally only;
the phone was NOT updated"). At Debug it was invisible at the default level,
and a mark-read that never reached the phone is indistinguishable from a
successful one from the caller's side -- the only symptom is a badge that stays
unread on the device.

Note for callers: on RCS this sends a visible read receipt to the other party,
so it is worth treating as a user-initiated action rather than something to
run speculatively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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