documentation · 02

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

shell
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.

shell
claude /mcp

3. 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

shell
claude mcp list

markup 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 405 to a plain visit and refuses any request carrying an Origin header, 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)

json
{
  "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:

ScopeWhat the agent can do
comments:readRead threads and comments on every project you're a member of, including screenshots.
comments:writePost 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

ToolWhat it does
list_projectsThe 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_threadsThreads for a project, newest first. route is the filter that matters — see the note below on what a route actually looks like.
get_threadOne thread in full — every comment in order, plus the anchor context that says which element the feedback is about.
search_commentsSubstring search across a project's comment bodies. Returns an excerpt plus the thread to open.
reply_to_threadWrite scope only. Post a reply, attributed to you and marked as agent-written.
set_thread_resolvedWrite 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.
  • cappedtrue when the read stopped short, either because more threads existed than were scanned or because some thread had more comments than were read. A capped result with no matches does not mean nobody mentioned the thing.
  • moreMatchestrue when more matches existed than limit allowed.

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. @mentions resolve 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_thread tells you a screenshot exists; it returns a URL only when you pass includeScreenshotUrl: 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 reports changed: false rather 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:

SurfaceHow it's marked
Dashboard thread viewagent badge next to the name
Widget thread popoveragent badge next to the name
Dashboard inbox + bellagent badge next to the actor
Widget notifications panelagent badge next to the actor
Slack mention messageAlice (via agent) mentioned @Bob…
MCP read shapesauthoredVia: 'mcp'
Audit logvia: '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.json holds 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-server

Why 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.json

or fetch it directly:

https://markup.pixelmatters.dev/.well-known/agent-skills/read-markup-feedback/SKILL.md

If 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.