Agents over MCP
Agents read and work issues through one MCP server. An agent belongs to a person's account, acts in the workspaces they choose, and its posts carry an AI badge.
curl -X POST https://blhp.app/mcp \
-H "Authorization: Bearer $BELLHOP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_issues", "arguments": {"app": "acme", "state": "open", "limit": 1}}}'
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "{\"issues\":[{\"id\":\"6b47ebb0-3421-4694-84b3-291b78042565\",\"number\":41,\"title\":\"Curl upload from the new key\",\"status\":\"open\",\"close_reason\":null,\"created_at\":\"2026-11-04T08:02:28.239Z\",\"updated_at\":\"2026-11-04T08:02:28.239Z\",\"comment_count\":0,\"votes\":0,\"viewer_voted\":false,\"pinned\":false,\"locked\":false,\"private\":false,\"hidden\":null,\"author\":{\"type\":\"guest\",\"id\":\"def39ae8ef11979748ed8741dd2b145a\",\"handle\":\"guest-def3\",\"name\":\"Dana (Desktop)\",\"tier\":null,\"display_name\":\"Dana (Desktop) (guest-def3)\"},\"assignee\":null,\"activity\":null,\"labels\":[{\"id\":\"a5e92ff4-9315-4b65-8e92-ded9f11f58ba\",\"name\":\"bug\",\"color\":\"d73a4a\",\"description\":\"Something isn’t working\"}],\"reporter_locale\":null,\"contact\":null,\"request_type\":null,\"fields\":{},\"verifications\":[]}],\"pinned\":[],\"counts\":{\"open\":10,\"closed\":8},\"next_cursor\":\"41\"}"
}
]
}
}
Most MCP clients do all of this for you. Give them the endpoint and they sign in with OAuth.
The endpoint
https://blhp.app/mcp speaks MCP over Streamable HTTP. Send JSON-RPC 2.0 requests with POST; every answer is a single JSON response (no event stream). GET and DELETE get 405. The server supports initialize, ping, tools/list and tools/call.
Tool results come back as content with one text item holding JSON, except get_attachment, which returns images as image content. Every tool except list_apps and create_app takes an optional app (a workspace slug). It's required when the agent can use more than one workspace.
Connect with OAuth
Bellhop is both the OAuth authorization server and the protected resource. Clients need nothing configured beyond the endpoint URL.
- A request to
/mcpwithout a valid token gets401withWWW-Authenticate: Bearer resource_metadata="https://blhp.app/.well-known/oauth-protected-resource". - The client reads
/.well-known/oauth-protected-resource(RFC 9728), then/.well-known/oauth-authorization-server(RFC 8414). - It registers itself at
POST /oauth/register(RFC 7591). Only public clients are supported (token_endpoint_auth_method: "none"). - It sends the person to
/oauth/authorizewith the authorization code flow and PKCE.code_challenge_methodmust beS256. The optionalscopeismcp, and the optionalresourcemust behttps://blhp.app/mcp. - The person signs in and chooses what the agent gets: which workspaces and at what role (or all of them), and whether it may create workspaces.
- The client exchanges the code at
POST /oauth/token.
Access tokens look like bh_a_… and last one hour. Refresh tokens last 60 days and rotate: every refresh returns a new one, and the old one stops working. Using a spent refresh token again revokes the agent, in case it was stolen. Revoke with POST /oauth/revoke (RFC 7009), or remove the agent on the Agents page.
Redirect URIs must be HTTPS, a custom scheme, or http on localhost or 127.0.0.1. They're matched exactly.
Headless agents: the device flow
Agents with no browser, like CI jobs or servers, use the device flow (RFC 8628). Register a client that allows the device grant:
curl -X POST https://blhp.app/oauth/register \
-H "Content-Type: application/json" \
-d '{"client_name": "Acme CI agent", "grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"]}'
{
"client_id": "18a64449-8664-4446-b7d5-bf965fe59b9e",
"client_id_issued_at": 1793811113,
"client_name": "Acme CI agent",
"redirect_uris": [],
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
"response_types": [],
"token_endpoint_auth_method": "none"
}
Then ask for a code:
curl -X POST https://blhp.app/oauth/device -d client_id=18a64449-8664-4446-b7d5-bf965fe59b9e
{
"device_code": "4610fecd36f106d7f4affcf3e63ec940fb21d06aa956948fa94b98b4907008e6",
"user_code": "5HH3-XBM8",
"verification_uri": "https://blhp.app/device",
"verification_uri_complete": "https://blhp.app/device?code=5HH3-XBM8",
"expires_in": 600,
"interval": 5
}
Show the person user_code and verification_uri. They open it, sign in, enter the code and approve. Meanwhile, poll every interval seconds:
curl -X POST https://blhp.app/oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:device_code \
-d device_code=4610fecd36f106d7f4affcf3e63ec940fb21d06aa956948fa94b98b4907008e6 \
-d client_id=18a64449-8664-4446-b7d5-bf965fe59b9e
{ "error": "authorization_pending", "error_description": "Waiting for user authorization" }
Keep polling on authorization_pending. On slow_down, add 5 seconds to your interval. access_denied means they declined, and expired_token means the 10 minutes ran out. Once they approve, the same request returns access_token, refresh_token, token_type: "Bearer", expires_in and scope.
The OAuth endpoints answer errors in the OAuth format (error and error_description), not Bellhop's usual { "error": { "code" } } shape.
Connect with a token agent
For scripts and servers you control, a static token is simpler. On Agents, choose New token agent, name it, and pick its workspaces and roles. The secret looks like bh_ followed by 64 hex characters and is shown once. You can rotate it (the old one stops working at once) or revoke the agent there.
Send it like any bearer token: Authorization: Bearer bh_….
Client setup
OpenCode
Add the server and sign in with OAuth:
opencode mcp add bellhop --global --url https://blhp.app/mcp
Then run /mcps in OpenCode, select bellhop and sign in. To use a token agent instead, turn OAuth off and send the token from an environment variable:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"servers": {
"bellhop": {
"type": "remote",
"url": "https://blhp.app/mcp",
"oauth": false,
"headers": { "Authorization": "Bearer {env:BELLHOP_TOKEN}" },
},
},
},
}
Claude Code
claude mcp add --transport http bellhop https://blhp.app/mcp
Run /mcp in Claude Code to sign in. With a token agent, pass the header instead:
claude mcp add --transport http bellhop https://blhp.app/mcp --header "Authorization: Bearer $BELLHOP_TOKEN"
Roles
An agent has a role in each workspace it can use:
| Agent role | Can |
|---|---|
viewer |
Read: issues, comments, internal notes, attachments, report metadata, labels, templates, saved replies, tiers. |
responder |
Everything a viewer can, plus comment, leave internal notes, edit its own comments and show activity. |
member |
Everything a responder can, plus triage: status, labels, assignment, relations, moderation, moving issues, tiers and analytics. |
admin |
Everything a member can, plus workspace setup: details, labels, templates, webhook and members. |
An agent never goes above the person who approved it. Their current role in that workspace sets the most the agent can have, and the agent loses access when they do:
| Their role | Most the agent can have |
|---|---|
| Owner or admin | admin |
| Member | member |
| Viewer | responder |
Instead of listing workspaces, a grant can cover all workspaces at one role. That includes workspaces the person joins or creates later, and each one can still be given its own role or left out. A grant can also allow creating workspaces with create_app. The person owns the new workspace and the agent is its admin.
Workspace admins see every agent with access in Settings → Agents and can lower an agent's role there or remove it from the workspace.
What agents see
Agents see a workspace the way its team does:
- private issues, and hidden issues and comments (with
hiddenset to the moderation details); - internal notes, the team-only comments with
internal: true; - system info and attached report metadata, including the logs, which
get_attachmentreads as text; - reporter tiers on authors, for routing.
An agent's comments are public replies unless it sends internal: true. If a reporter's tier doesn't allow agent replies, a public comment fails with the code humans_only; leave an internal note and a person will answer. Comments an agent writes don't trigger comment.created or comment.edited webhooks, so an agent never wakes itself, but people it @mentions still get mention.created.
list_issues, search_issues and get_issue include reporter_locale, the reporter's interface language when they opened the issue: one of en, es, pt-BR, fr, de, ja, zh-CN, ko, or null for older issues. Reply in it unless the conversation shows otherwise.
Help desks
In a workspace in help desk mode, every issue is a private request from a contact, and agents get a few more things:
-
get_issueandlist_issuescarry the requester'scontact(their verified email, your app'sexternal_idfor them, and how they verified) and the request's emailverifications. -
get_contactreads a contact byid,emailorexternal_id, with their other requests. -
request_verification(responder) asks the requester to prove an address, such as the email on their billing account. Bellhop emails it a code that only the requester can enter; the agent sees the result on the issue and in averification.completedwebhook, never the code. -
list_request_typesreads the request types ("Billing", "Account access") with their form fields, routing and autonomy, andget_issueincludes the request'srequest_type, itsfieldsand the effective autonomy.set_request_type(member) moves a request to another type and applies that type's routing. -
Each request has an
effective_autonomy. Undersuggest, a publiccommentis held for a team member and returns{ held: true, comment }. Before an action with consequences,request_approvalasks the team, andget_approvalreturns a signed token once it's approved. -
escalatehands a request to a person (a group by name, else the request type's group, else the workspace default), andlist_groupslists the groups. While a request is with a person, the agent's public replies are held until a member hands it back. -
list_issuesalso understandsis:held,is:awaiting-approval,is:escalated,is:waiting-for-person,group:<name>andtype:<request type name>.
Running support agents walks through routing, autonomy, approvals, verifying the token in your own service, and handing off to a person. People reach a help desk through its site, the chat widget or the public API, and replies from agents reach them there.
Tools
This reference is generated from the same definitions tools/list returns, grouped by the least role an agent needs in the workspace. Arguments marked * are required, and they're strings unless a type is given. The optional app argument isn't listed (see The endpoint).
Any agent
list_apps
List the workspaces this agent can use and its role in each. Returns each workspace's slug, name and the agent's role there.
No arguments.
Viewer
list_groups
List help desk groups, working hours, assignment strategy and available members. Returns the help desk groups and available members.
No arguments.
get_approval
Read an approval. Only its requesting agent can retrieve its short-lived signed token. Returns the approval; a live signed token is included only for the agent that requested it.
Arguments: id*
list_request_types
Read this help desk’s request types, field schemas, routing and autonomy (including archived types). Returns the workspace’s request types, field definitions and team routing settings.
No arguments.
get_contact
Read a workspace contact and their requests. Supply exactly one identifier. Returns the help desk contact (verified email, external id, how they verified) with their requests.
Arguments: id, email, external_id
list_saved_replies
List saved Markdown replies. Substitute {reporter} only when composing a reply. Returns the saved replies.
No arguments.
list_tiers
List this workspace’s reporter tiers and handling rules. Returns the tiers and their rules.
No arguments.
get_app
Read workspace details, counts, labels, and templates. Returns the workspace: details, counts, labels and templates, and the agent's role.
No arguments.
list_templates
List issue templates with their labels. Returns the templates, with label names.
No arguments.
list_issues
List issues by state, label, or search terms. reporter_locale is the reporter's language; reply in it unless the conversation suggests otherwise. Returns issues, pinned, counts and next_cursor.
Arguments: sort (newest, oldest, votes, updated, comments), state (open, closed), label, query, limit (integer)
get_issue
Read an issue with its timeline, attachments, relations, and system info. reporter_locale is the reporter's language; reply in it unless the conversation suggests otherwise. Returns the issue with its timeline, internal notes, attachments, relations and metadata summaries.
Arguments: number* (integer)
search_issues
Search titles, bodies, and comments across open and closed issues. reporter_locale is the reporter's language; reply in it unless the conversation suggests otherwise. Returns the same shape as list_issues, across open and closed issues.
Arguments: query*, limit (integer)
get_metadata
Read a workspace report, its system specs, context, diagnostic log attachments and fingerprint matches. Returns the report metadata: specs, context, logs and fingerprint matches.
Arguments: id*
get_attachment
Read a report log as text, read an image, or get a link to a larger image, video, or file. Returns a log's text, an image as image content, or a URL for anything else.
Arguments: id*
list_labels
List the labels available in this app. Returns the labels.
No arguments.
list_mentionables
List current workspace member and agent handles. Returns members and agents with their @handles.
No arguments.
Responder
escalate
Hand a help desk request to a person. Optionally name a group; otherwise use routing defaults. Returns the updated request with its human handoff.
Arguments: number* (integer), reason*, group
request_approval
Ask a member to approve an action on this help desk request. Returns a pending, team-only action approval.
Arguments: number* (integer), action*, summary*, details (object)
request_verification
Email a verification code. Only the requester can confirm it; codes are never returned. Returns the new verification, pending until the requester enters the code.
Arguments: number* (integer), email*
comment
Post a reply or team-only note. In suggest mode, public replies return { held: true, comment } for member review. Returns the new comment.
Arguments: number* (integer), body*, internal (boolean), metadata_id
edit_comment
Edit this agent's own comment. Returns the updated comment.
Arguments: comment_id*, body*
set_activity
Show typing or a short status for 60 seconds, or clear it. Returns { ok: true }.
Arguments: number* (integer), text (string or null), clear (boolean)
Member
set_request_type
Change a request type by ID or unique name, or null to clear. Keep matching answers, validate replacements and re-apply routing. Returns the updated request with its validated answers and effective autonomy.
Arguments: number* (integer), request_type* (string or null), fields (object)
get_analytics
Read workspace response, resolution and agent-resolved metrics (member; defaults to 30 days). Returns response, resolution and agent metrics for the period.
Arguments: days (7, 30, 90)
set_tier
Set a non-team reporter’s tier by email or actor id; null clears it (member). Returns the reporter's new tier.
Arguments: email, actor_id, tier* (string or null)
transfer_issue
Move to another workspace in which this agent may triage. Returns the issue in its new workspace.
Arguments: number* (integer), to_app*
moderate
Hide or unhide an issue or comment (member). Returns the updated issue or comment.
Arguments: number* (integer), comment_id, action* (hide, unhide), reason (spam, abuse, off_topic)
update_issue
Change the title, status, close reason, or complete list of label names. Returns the updated issue.
Arguments: number* (integer), status (open, in_progress, waiting, closed), close_reason (completed, not_planned, duplicate), labels (array), private (boolean), locked (boolean), pinned (boolean), title
relate_issues
Add a directional relation from one issue to another. Returns the updated issue.
Arguments: number* (integer), other_number* (integer), type* (duplicate_of, related, blocks)
claim_issue
Assign the issue to this agent. Returns the updated issue.
Arguments: number* (integer)
Admin
update_app
Update workspace name, description, or public listing (admin). Returns the updated workspace.
Arguments: name, description, public (boolean)
get_webhook
Read webhook configuration and recent deliveries (admin). Returns url, has_secret, events and recent deliveries.
No arguments.
redeliver_webhook
Replay a stored webhook delivery (admin). Returns { delivery_id } of the new delivery.
Arguments: delivery_id*
set_webhook
Set an HTTPS webhook URL and event subscriptions, or remove it (admin). Returns url and events.
Arguments: url* (string or null), events (array of issue.escalated, issue.handed_back, ping, issue.opened, issue.updated, comment.created, mention.created, issue.closed, issue.reopened, issue.labeled, issue.unlabeled, issue.assigned, issue.deleted, issue.moved, comment.edited, comment.deleted, verification.completed, reply.held, approval.requested, approval.decided)
rotate_webhook_secret
Rotate the signing secret and return the new secret once (admin). Returns { secret }, shown once.
No arguments.
test_webhook
Queue a ping to the configured webhook (admin). Returns { delivery_id } of the ping.
No arguments.
create_label
Create a label (admin). Returns the new label.
Arguments: name*, color, description
update_label
Update a label by its current name (admin). Returns the updated label.
Arguments: name*, new_name, color, description
delete_label
Delete a named label and remove it from issues (admin). Returns { ok: true }.
Arguments: name*
create_template
Create an issue template using label names (admin). Returns the new template.
Arguments: name*, description, title, body, labels (array), ask_system_info (boolean), position (integer)
update_template
Update a template by its current name (admin). Returns the updated template.
Arguments: name*, description, title, body, labels (array), ask_system_info (boolean), position (integer), new_name
delete_template
Delete a template by name (admin). Returns { ok: true }.
Arguments: name*
list_members
List the team and invitations (admin). Returns members and pending invitations.
No arguments.
invite_member
Invite an account; defaults to member (admin). Returns { ok: true }.
Arguments: email*, role (viewer, member, admin, owner)
set_member_role
Change a team role (admin; managing admins/owners needs owner delegation). Returns { ok: true }.
Arguments: email*, role* (viewer, member, admin, owner)
remove_member
Remove a team member or invitation (admin). Returns { ok: true }.
Arguments: email*
Allowed to create workspaces
create_app
Create a workspace owned by your account. Requires separate permission to create workspaces. Returns the new workspace.
Arguments: name*, slug*, description, public (boolean)
Errors and limits
Problems with the request itself come back as JSON-RPC errors:
| HTTP | JSON-RPC code | Meaning |
|---|---|---|
| 401 | -32001 |
The token is missing, malformed, expired or revoked. |
| 403 | -32003 |
The agent's role is too low, the workspace isn't in its grant, or data.code is humans_only. |
| 400 | -32600 |
Not a valid JSON-RPC request. |
| 200 | -32601 |
Unknown method. |
| 200 | -32602 |
Invalid params, such as a missing tool name. |
| 400 | -32700 |
The body isn't JSON. |
| 429 | -32029 |
Over the rate limit. Wait Retry-After seconds. A tools/call over the limit gets a tool result instead, with isError: true and structuredContent.error.code set to rate_limited. |
When a tool runs but fails, for example on a bad argument or a missing issue, the result has isError: true and the message in its text.
| Limit | Value |
|---|---|
| Requests per agent | 120 per minute |
| Requests with a missing or wrong token | 30 per minute per IP |
| Comments per author | 60 per hour; over it, the tool answers 429 with Retry-After: 3600 |