MCP server — let a coding agent read your feedback
Markup exposes its feedback threads over the Model Context Protocol, so a coding agent working in your repository can read the comments people left on your running app — with the route, the CSS selector, the viewport, and the whole thread history intact.
The point is to stop the copy-paste step. Feedback like "this button is misaligned on /settings" is already anchored to a route and an element; pasting it into a prompt by hand throws exactly that structure away.
Setup
There is no key to generate, copy, or store. You register a URL, then sign in with your Markup account in the browser.
1. Register the Markup MCP server
claude mcp add markup --scope user --transport http https://<your-deployment>.convex.site/mcp--scope user puts it in your own Claude Code config, so it's available in
every repo. Deliberately not --scope project, which writes .mcp.json
into the working tree — that file gets committed, and while it holds no secret,
it would point every clone of the repo at a server their own account has to
authorize.
2. Open the MCP menu
Start a Claude Code session and list your MCP servers. Select markup.
claude /mcp3. Authorize
Select Authenticate. A browser window opens automatically. Sign in with your Markup account and approve the access it asks for.
That's the whole credential story. The token lives in your client's own credential store, expires on its own, and refreshes without asking you again.
4. Check it connected
claude mcp listmarkup should be listed as connected, with six tools available — four if you
approved read-only.
Opening the server URL in a browser is not a useful check. It answers
405to a plain visit and refuses any request carrying anOriginheader, on purpose (see Why a browser can't reach it). Only a real MCP client can talk to it, so a browser error there tells you nothing about whether setup worked.
5. You're set
Ask it something that needs real feedback:
- "What feedback is open on /settings?"
- "Read the Markup threads for this page and fix what's valid."
Other clients
.mcp.json (Cursor, VS Code, and most other clients)
{
"mcpServers": {
"markup": {
"type": "http",
"url": "https://<your-deployment>.convex.site/mcp"
}
}
}There's no credential in that file, so it's safe to commit — every user who opens the repo authorizes as themselves.
Clients that only speak stdio can bridge with
mcp-remote. There is no npm
package to install for the normal HTTP path — the server is the deployment.
Clients that don't support OAuth can't connect. The server has no alternative credential to fall back on: authorization is the only way in.
What you're granting
The consent screen names two permissions:
| Scope | What the agent can do |
|---|---|
comments:read | Read threads and comments on every project you're a member of, including screenshots. |
comments:write | Post replies and resolve threads under your name, marked as agent-written. It cannot notify or mention anyone. |
Two things worth knowing about the shape of that grant:
- It covers every project you're a member of, not one. The authorization flow has no project picker, so there's nowhere to express a narrower grant.
- Access follows your membership, live. An agent can never see a project you can't, and losing access to a project revokes it for your agents on their very next call.
- You can disconnect an agent at any time from Settings → Agent access, which lists everything you've connected. That deletes its tokens and the recorded approval, so reconnecting asks you again.
Tools
| Tool | What it does |
|---|---|
list_projects | The projects you can read, with a count of open threads in each. Call this first — the slug + workspaceSlug it returns are what the others want. |
list_threads | Threads for a project, newest first. route is the filter that matters — see the note below on what a route actually looks like. |
get_thread | One thread in full — every comment in order, plus the anchor context that says which element the feedback is about. |
search_comments | Substring search across a project's comment bodies. Returns an excerpt plus the thread to open. |
reply_to_thread | Write scope only. Post a reply, attributed to you and marked as agent-written. |
set_thread_resolved | Write scope only. Resolve or reopen a thread. |
list_threads returns comment previews; get_thread has the full text.
Both default to open threads, matching the dashboard.
Naming a project
Every tool but list_projects takes a project. Pass projectSlug with
workspaceSlug alongside it — project slugs are only unique inside their
workspace, so two workspaces you belong to can each own an acme, and the
pair is the only thing that always identifies one. list_projects reports
both for every project, and every tool response echoes them back.
workspaceSlug is optional: if only one project you can read has that
slug, projectSlug on its own resolves fine, so a config that predates
this keeps working. If two match, the call is refused and the error names
the workspaces to choose from — it never picks one for you, because the
wrong guess would mean reading, or commenting into, the wrong company's
project.
projectId also works, if you have one and no slugs. It's exact, so it wins
— but don't pass it with a slug that names a different project or
workspace. Either contradiction is refused rather than silently resolved to
the projectId's project, so a copy-paste slip between two namesake
projects can't quietly act on the wrong one.
What a route looks like
A thread's route is host + pathname as the browser saw it —
app.acme.com/dashboard/settings, not /dashboard/settings. The host is
part of it on purpose: the same path on staging and on production are
different routes, so feedback from one doesn't bleed into the other.
route is an exact match, so a bare path matches nothing. If you don't
know the host, call list_threads without route and read the real values
off the results.
Filtering list_threads
Beyond route: urlContains (case-insensitive substring of the full URL —
handy when you have a URL rather than a route, or for narrowing to one
environment, e.g. "staging."), since (epoch ms, for "what came in since
I last looked"), and status.
urlContains is also the practical way to filter by path alone, since
route needs the host.
One behaviour worth knowing, because it looks like a bug and isn't. When any
filter other than route is active, the response sets filtered: true and a
page can come back with fewer results than limit — even zero — while
hasMore is still true. Pages are fixed windows over the underlying data
that then get filtered, so a sparse page means "nothing matched in this
window", not "nothing matched". Keep paging with nextCursor until hasMore
is false. scannedThreads tells you how many rows that page actually read.
The alternative — widening the scan and returning the first N matches — would hand back a cursor that skips everything past the Nth, so a list would look complete while quietly dropping matches. Sparse pages are the honest shape.
Searching
search_comments is a bounded scan, not a search index. It reads up to
the project's 150 most recent threads, and up to 100 comments from each, and
scans them in memory. Three fields in the response tell you how much to
trust the answer:
scannedThreads— how many threads it actually read the comments of. This is often fewer than 150: the scan stops once it has enough matches.capped—truewhen the read stopped short, either because more threads existed than were scanned or because some thread had more comments than were read. Acappedresult with no matches does not mean nobody mentioned the thing.moreMatches—truewhen more matches existed thanlimitallowed.
Deleted comments are never searched, so search can't become a second route to text a human removed.
If you need exhaustive search, list_threads with route plus get_thread is
the reliable path — it's paginated, so it has no cap.
A useful pattern
Working on a page, ask your agent to check the feedback for it before it changes anything:
Before editing the settings page, call list_threads for project "acme"
with urlContains "/settings", and read anything open.What it can and can't see
Can: thread route, URL, CSS anchor selector, anchor position, viewport size, resolved state, every comment in order with author display names, edit and delete state, reaction counts, and whether a screenshot exists.
Cannot, by construction (a write-scoped grant adds replying and resolving to the "can" list, and nothing else):
- Anything you can't see. A grant is a delegation of your access, never an escalation of it. Access is re-resolved against your live project membership on every single call — so if you're removed from a project, your agents lose it immediately, with no credential to revoke and no window in between. Archived projects are invisible.
- Commenter email addresses. They exist on the record; no tool returns them.
- Anonymous visitor IDs, user agents, or raw internal user IDs.
@mentionsresolve to display names. - Text a human deleted. Deleted comments come back as empty tombstones.
- Project configuration. No tool reads or writes members, API keys, domains, limits, or the audit log. Not "restricted to admins" — there is no tool, and no argument shape that reaches them.
- Screenshots, unless asked.
get_threadtells you a screenshot exists; it returns a URL only when you passincludeScreenshotUrl: true. The URL is unauthenticated, so requesting it means sending a picture of your app to your model provider. That should be a decision, not a default.
Letting an agent reply
By default a grant is read-only, and the two write tools are not merely
refused — they aren't offered. An agent holding a read-only grant doesn't see
them in tools/list at all, so it can't try, and can't infer they exist.
Approve the "Reply and resolve threads" permission on the consent screen to
grant comments:write. A client that never requested it gets read-only, so
approving is a deliberate act rather than a default.
The agent then gets:
reply_to_thread— posts a reply, attributed to you.set_thread_resolved— resolves a thread once the change is actually made, or reopens one. Idempotent: setting the state it's already in reportschanged: falserather than failing, so a retry after a dropped response is harmless.
Three things hold for every agent write:
- It's marked. The reply carries your name and user ID, so without a
marker it would read as something you typed. Both the dashboard and the
widget render an agent badge next to it, and
authoredVia: 'mcp'appears in every read shape. Whoever left the feedback can tell that a machine answered them. - It's attributable. The audit log records it under your user ID with
via: 'mcp'and the OAuth client id — traceable to the human who delegated, and distinguishable from that human's own replies. - It notifies nobody. See below.
An agent cannot ping anyone
Mentions are addressed by internal user ID, and no tool returns those, so
there is nothing valid an agent could put in such a field. Rather than
accept one and drop it — which would have the tool report success while
notifying nobody — there is no mentions argument at all, on the tool
or on the mutation behind it.
Since a mention is the only thing that creates a notification, an agent's
reply puts nothing in anyone's inbox and sends nothing to Slack. Writing a
name in the body is plain text. Writing the raw @[Name](id) marker format
doesn't work either: both clients only render a marker as a mention chip
when its ID appears in the server-validated mention list, so a fabricated
one shows up as ordinary text.
If a notification for an agent-written comment ever does become possible, it arrives already marked: the inbox row and the Slack message both carry the provenance marker described next. That's deliberate belt-and-braces — the guarantee above shouldn't be the only thing standing between an agent and an unmarked ping.
Where the marker shows up
An agent's reply carries your name and user ID, so every surface that renders it has to say a machine wrote it:
| Surface | How it's marked |
|---|---|
| Dashboard thread view | agent badge next to the name |
| Widget thread popover | agent badge next to the name |
| Dashboard inbox + bell | agent badge next to the actor |
| Widget notifications panel | agent badge next to the actor |
| Slack mention message | Alice (via agent) mentioned @Bob… |
| MCP read shapes | authoredVia: 'mcp' |
| Audit log | via: 'mcp' + OAuth client id |
Slack gets words rather than a badge because a channel message has no badge to hover — and the marker is in the notification preview text too, not just the message body, since a phone notification is exactly the read-at-a-glance case it exists for.
Writes have their own tighter budget: 30/minute with a burst of 10, charged on top of the read budget. A loop that posts comments hits this well before a loop that reads them would hit anything.
Everything else is read-only. Nothing an agent does through this server can change your project's configuration — members, keys, domains and limits have no tool at all.
Credentials
There is no credential for you to paste, which is the point. An access token is issued to your client by the authorization flow, stored wherever that client keeps secrets, expires on its own, and refreshes without prompting you.
What that changes compared with a key you'd paste into a config file:
- Nothing to leak in a config file or a commit.
.mcp.jsonholds a URL. - Nothing to forget. There's no long-lived secret sitting in a dotfile months later, still valid.
There is still a grant, though, and it is worth being clear that a grant is a thing you can and sometimes should revoke. Access tokens are short-lived, but a client refreshes them on its own, so a grant stays alive until something ends it. Two ways to end one:
- Disconnect the agent in Settings → Agent access. Specific, immediate, and the right lever if a machine is lost or you're done with a client.
- Lose project membership. Coarser, and it only removes access to that one project. It is not a substitute for disconnecting, because it also removes your access.
The trade-off of the whole approach is that a client which can't do OAuth can't connect at all. There is no fallback credential.
Rate limits
Per authorization: 120 requests/minute sustained, with a burst of 30. Exceeding
it returns 429 with Retry-After. Deliberately separate from your widget's
limits — a runaway agent loop shouldn't throttle real users on your site.
Client registration is also budgeted, at 5/hour per caller. A client registers once per machine and reuses the result forever, so this is invisible in normal use; it exists because registration is necessarily open to callers who have no credential yet.
Protocol notes
Streamable HTTP at POST /mcp, implementing MCP revision 2026-07-28.
The 2025-era protocol is served on the same endpoint, so clients that haven't
migrated still work; that support will be dropped once the clients we care
about have moved.
Authorization follows the MCP Authorization framework: OAuth 2.1 with PKCE
(S256 only), RFC 9728 protected-resource metadata, RFC 8414 authorization-server
metadata, and RFC 7591 dynamic client registration. The protected-resource
document names this endpoint as its RFC 8707 resource, so a client can bind its
request to it — note that a token is validated by lookup in this deployment's own
token store rather than by checking an audience claim, which is what actually
makes a token from another server useless here.
The authorization-server document also carries an agent_auth block, the
Auth.md extension, pointing at
/auth.md and at the
registration endpoint. Its identity_types_supported is deliberately empty:
Markup implements none of that profile's agent-identity registration methods —
no anonymous registration, no identity assertions, no claim ceremony — because
an agent here never has standing of its own. It acts for a person who signed in
and approved it, and it holds exactly what that person can see, re-checked on
every call.
An unauthenticated request gets a 401 whose WWW-Authenticate header names the
protected-resource document, which is the entry point a client follows to
register itself and open a browser:
WWW-Authenticate: Bearer realm="markup",
resource_metadata="https://<your-deployment>.convex.site/.well-known/oauth-protected-resource"Both discovery documents are served at the origin root:
GET /.well-known/oauth-protected-resource
GET /.well-known/oauth-authorization-serverWhy a browser can't reach it
One intentional deviation worth stating plainly: any request carrying an
Origin header is rejected with 403, and no CORS headers are ever returned.
Legitimate MCP clients are server-side and send no Origin; a browser always
does. So this satisfies the spec's Origin-validation requirement and means a
malicious web page can't reach the endpoint even if it has somehow obtained a
token.
The browser steps of the authorization flow are unaffected — they happen on the
/api/auth/* routes, never on /mcp.
GET and DELETE return 405 — they were the 2025-era session operations,
and this server is stateless in both eras.
Teaching your agent the workflow
Tools alone tend to go unused: an agent that can read feedback doesn't necessarily know it should. Markup publishes an agent skill that teaches the workflow — resolve the route you're editing, read what people reported about it, then change code — along with how to read the truncation flags honestly.
It's discoverable per the Agent Skills RFC:
https://markup.pixelmatters.dev/.well-known/agent-skills/index.jsonor fetch it directly:
https://markup.pixelmatters.dev/.well-known/agent-skills/read-markup-feedback/SKILL.mdIf your agent doesn't do skill discovery, the short version is worth putting in
your AGENTS.md or CLAUDE.md by hand:
This project uses Markup for in-page feedback, exposed over MCP.
Before changing any UI: work out the route of the page you're editing,
call `list_threads` for that route, and read anything open. Check
`hasMore` and `capped` before concluding there's no feedback.Troubleshooting
401 on every call. The authorization is unknown, disconnected, or
expired — all three answer identically on purpose, so the response won't tell you
which. Re-run claude /mcp and authenticate again.
"Authenticate" never appears. The client couldn't complete discovery. Check
that GET /.well-known/oauth-protected-resource on your deployment returns JSON,
and that nothing between you and it strips the WWW-Authenticate header off the
401 — that header is the only thing that tells a client where to authorize.
The browser window opens, you sign in, and nothing happens. The client is
listening on a loopback port for the redirect. A proxy or VPN that intercepts
http://localhost:* will break the last hop.
It connected, but only four tools are listed. The grant is read-only. Either write access wasn't approved, or the client didn't request it. Disconnect it in Settings → Agent access, then reconnect and approve both permissions.
It asks me to approve every time I connect. That's deliberate. The approval screen is forced on every authorization rather than left to the client to request, because otherwise a client could be granted read and write with nothing shown to you at all.
403. Something added an Origin header. You're likely calling through a
browser or a proxy that injects one.
Project not found. Either you're not a member of that project, its slug
is wrong, or it's archived. The message is deliberately the same for all three.
Call list_projects to see what you actually reach.
More than one project you can read is called …. Two of your workspaces
own that project slug, so it doesn't name one project. The message lists the
workspaces — pass one as workspaceSlug alongside projectSlug.
That projectId belongs to workspace … not … or
That projectId is the project … not …. You passed a projectId and a
slug that disagree with it. Drop whichever one is wrong; the projectId
alone is enough.
An empty thread list. Check status — the default is 'open', so
resolved feedback needs status: 'resolved' or 'all'. And if hasMore is
true, there are more pages behind nextCursor.