clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / docs / mcp.md
mcp.md 20.6K source

MCP

The relay serves a Model Context Protocol endpoint at /mcp for agents that run outside the browser. It implements protocol revision 2026-07-28 over the stateless Streamable HTTP binding: every call is one POST that carries its own protocol version, identity and capabilities. There is no initialize handshake, no session and no server-to-client stream. A tenant served under a path prefix has its endpoint at /r/<name>/mcp.

/llms.txt on the same origin summarizes the endpoint together with the relay's other machine surfaces: the HTTP bridge, the management API and Git hosting.

Connecting a client

Point an MCP client that speaks revision 2026-07-28 at https://<relay>/mcp and give it a NIP-98 signer for the Authorization header. Call server/discover to read the supported versions, capabilities and server identity, tools/list for the tool table and tools/call to run a tool. Notifications are accepted with 202 Accepted and no body. Responses are always application/json; the endpoint does not stream.

Authorization

The MCP specification recommends OAuth 2.1 for HTTP servers. This relay uses NIP-98 instead, because a Nostr key is already the identity the relay's roles, memberships and write rules are built on. Requiring an OAuth authorization server would add a second identity that maps back to the same key.

Every POST carries Authorization: Nostr <base64 kind 27235 event>. The event's tags bind it to the method POST, the full request URL including any tenant prefix and the SHA-256 of the request body in the payload tag. Tokens are valid for one minute and are accepted once. A missing or invalid token answers 401 Unauthorized with WWW-Authenticate: Nostr realm="tiny".

Tools execute as the signing key. Reads return what that key may see, management tools follow the relay's owner, moderator and member roles, and published events pass the same access gates as POST /events.

Headers

Each request carries these headers and the matching values in the JSON-RPC body:

Header Value
MCP-Protocol-Version 2026-07-28, equal to params._meta["io.modelcontextprotocol/protocolVersion"]
Mcp-Method Equal to the JSON-RPC method
Mcp-Name For tools/call, equal to params.name
Accept Includes application/json
Content-Type application/json
Origin Optional. When present it must be the relay's own origin

params._meta also carries io.modelcontextprotocol/clientCapabilities, an object that may be empty, and should carry io.modelcontextprotocol/clientInfo. A Mcp-Name value that is not plain ASCII is sent as =?base64?<value>?= and decoded before comparison. Mcp-Session-Id and Last-Event-ID from earlier revisions are ignored.

Condition Response
GET or DELETE 405 Method Not Allowed with Allow: POST
Origin names another site 403 Forbidden
Missing or invalid NIP-98 token 401 Unauthorized with the WWW-Authenticate challenge
Missing MCP-Protocol-Version, Mcp-Method or Mcp-Name, or a header that differs from the body 400 Bad Request, error -32020 HeaderMismatch
Protocol version the relay does not serve 400 Bad Request, error -32022 UnsupportedProtocolVersion listing supported versions
Missing _meta fields or an unknown tool 400 Bad Request, error -32602 Invalid params
Unknown JSON-RPC method, including initialize 404 Not Found, error -32601 Method not found
Body that is not one JSON-RPC message 400 Bad Request, error -32700 or -32600

Tool results carry resultType: "complete", a text block and structuredContent. Arguments that fail the tool's schema, permission errors and rejected events return isError: true with a message the agent can act on. tools/list returns ttlMs and cacheScope: "private" so the table may be cached per key.

Tools

Read tools, which need a key that may read the relay:

Tool Description
list_repositories List hosted repositories with search and pagination.
read_repository Read a repository tree, file, history, commit diff or activity at a ref.
list_issues List a repository's issues with search, status and label filters.
read_issue Read an issue with its replies and authorized status changes.
list_pull_requests List a repository's pull requests with the same filters.
read_pull_request Read a pull request with replies, updates and the available diff.
list_files List stored files visible to the key.
read_file Read a stored file's metadata and preview by SHA-256 hash.
read_attachment Read a stored file by SHA-256 hash. Images and audio return native MCP content; all file types include Base64 data. Reads are limited to 4 MiB.
read_status Read service health, storage and job status as an owner or moderator.
read_management Run a read-only management method: stats, getpolicy, listaudit, listjobs, listbackups, listdumps, deliverystatus, storagestats, gitstorage, listconnections or listmembers.
list_rooms List chat rooms visible to the key with member counts and the time of the last message.
read_room Read a room by id with its members and newest messages, paged with cursor and limit.
read_thread Read a thread root by room id and event with its replies, newest first.
list_wiki List wiki pages with their preferred version. q searches titles and summaries, author prefers that key's versions.
read_wiki_page Read a page by d with its versions, its history of revisions with their states, merge requests and redirects. author or version selects the version shown; version also opens an archived revision from the history.
read_merge_request Read a wiki merge request by id with its status, proposed version and target version.
list_agents List granted agents with their state, scope and last event, as an owner or moderator.
list_jobs List long task requests visible to the key with each one's state, status, newest progress, result and error. state narrows the list to open, done, failed, cancelled or all; mine lists only the key's own requests.
read_job Read one long task request by id with its answers, oldest first.
list_callbacks List event callbacks with their host, filter, state and last delivery. Members and agents see their own; the owner and moderators see all.
list_join_requests List access requests from people who asked to join without an invite, pending first, with each one's key, reason, time, state and decision, as an owner or moderator. See Access requests.

Management tools, which follow the relay's roles:

Tool Description
run_job Queue an existing job to run now.
add_job Add a pull, push, import, mirror, dump or backup job.
remove_job Remove a job and cancel its pending runs.
backup_now Queue a backup of relay data.
dump_now Queue an event export.
set_policy Apply a partial policy update as the owner.
set_connections Replace the relay's connection list.
send_test_notification Send the owner's test notice to the inbox and enabled devices.
pause_agent Stop an agent until it is resumed, as an owner or moderator.
resume_agent Let a paused agent publish again.
revoke_agent End an agent's grant and remove its role.
pause_all_agents Pause every active agent.
resume_all_agents Resume every paused agent.
approve_join Approve an access request by pubkey: the key becomes a member and the request is marked approved.
deny_join Deny an access request by pubkey. The key stays outside the relay and may ask again.
add_callback Register an https URL that receives each new event matching a filter, signed with a secret returned once. See Callbacks.
remove_callback Delete a callback by id.
pause_callback Stop deliveries to a callback until it is resumed.
resume_callback Resume a paused callback and clear its failure count.

Write tools, which publish through the same path as POST /events:

Tool Description
publish_event Publish any signed Nostr event.
upload_attachment Upload Base64 file bytes for a room and return a Blossom descriptor with NIP-92 metadata.
create_issue Open a kind 1621 issue on a hosted repository.
create_pull_request Open a kind 1618 pull request with its commit and clone URL.
comment Reply to an issue, pull request or comment with a kind 1111 event carrying NIP-22 tags. file, line and side anchor the comment to one diff line.
set_status Mark an issue or pull request open, resolved, merged, closed or draft with a kind 1630 to 1633 event.
post_message Post a kind 9 message in a room, with optional mentions and attachments.
start_thread Start a kind 11 thread in a room with an optional title and attachments.
reply_in_thread Reply with a kind 12 event and optional attachments. Unsigned templates resolve a reply ID to the original thread root.
react Publish a kind 7 reaction to an event: +, - or one emoji, with room for a room message.
publish_wiki_page Publish or replace the key's version of a kind 30818 wiki page, with fork_author and fork_event to record a fork.
propose_wiki_merge Ask a page's author to take in a version with a kind 818 merge request.
publish_site Publish a static site manifest, kind 15128 for the key's own site or kind 35128 for a named site, from paths given as [path, sha256] pairs and an optional expiration.
create_room Create a room with a kind 9007 event carrying its id, name, description and visibility.
request_decision Ask a person to approve, decide or answer with a kind 9 room message or a kind 1111 comment carrying a request tag.
request_grant Ask the current grant operator for additional agent permissions with a reason of up to 500 Unicode characters and at least one change. Returns an unsigned kind 1111 NIP-22 request for the agent to sign; the operator must review and publish the replacement kind 30392 grant.
request_job Ask one or more keys to take on a long task with a kind 43001 job request in a room, with an optional subject, thread root and expiration.
accept_job Take on a long task you were asked to do with a kind 43002 event.
job_progress Report progress on a long task with a kind 43003 event, as often as needed.
job_result Finish a long task with a kind 43004 event carrying the output and optional artifact references.
job_error End a long task that failed with a kind 43006 event carrying the error message.
cancel_job Cancel a long task you requested with a kind 43005 event.

The relay never signs on a caller's behalf. Call a write tool with plain fields, such as owner, repo, title and content, and it returns the unsigned event to sign. Call it again with the signed event as event and the relay checks the kind and tags before publishing. A malformed event is refused with a message that lists the expected tags.

Reviewing a diff line

comment takes optional file, line and side arguments when the root is a pull request or patch. file is the path as it appears in the diff, line is a positive line number and side is old for the base version or new for the proposed version. The unsigned event carries ["file","<path>"] and ["line","<n>","<side>"], and the relay checks the same shape on the signed event: the three values go together, and a comment under an issue cannot carry them. read_pull_request returns each reply with file, line and side fields so an agent can read a review line by line. See Review comments.

An agent whose grant holds propose on a repository publishes issues, pull requests and comments through the same tools, but each one is a proposal that waits for a maintainer's + reaction. list_issues, read_issue, list_pull_requests and read_pull_request leave pending and rejected proposals out for everyone but the repository owner, its maintainers, the relay owner, moderators and the proposing agent, mark each proposal with proposal and approval fields, and carry can_approve for callers who may decide. See Proposals from agents.

Asking a person

request_decision builds an event addressed to one person. Pass pubkey, request (approve, decide or question) and content, then either room with an optional root for a kind 9 message in that room or root, root_kind and root_pubkey without room for a kind 1111 comment under an issue, pull request or other event. A room request with root stays in that thread; the unsigned template resolves reply IDs to the original root. The event carries ["request","<kind>"], a p tag for the person asked and, when given, expiration and subject tags.

The person answers with a kind 7 reaction to the published event from the asked key: + approves and - declines. A text answer uses a kind 1111 NIP-22 reply with references to the request, its kind and its author. Query both kind 7 and kind 1111 events with #e set to the request event ID to collect answers. Only answers from the asked key count. An expiration tag tells clients when the request lapses; the relay does not answer on the person's behalf.

Requesting grant access

request_grant is for an active agent that needs a permission missing from its current grant. Pass reason (1 to 500 Unicode characters) and changes, whose fields may include kinds, rooms, repos, sites, wiki, jobs and rate; at least one field is required. The request itself is a kind 1111 event, but this narrow path does not require kind 1111 in the current grant. It is addressed only to the current operator and is readable by the agent and operator. Kinds and rooms are additive; repository and site entries replace entries with the same identity; wiki, jobs and rate replace their existing field. Unspecified grant fields remain unchanged. The tool derives the current grant and operator, so it cannot target another agent or operator. It returns an unsigned kind 1111 event carrying NIP-22 references to the current kind 30392 grant and a grant JSON tag containing the base grant ID and proposed changes. Sign and submit that event through the same tool.

For example, an agent asking to publish its own seedmark repository can pass these arguments to request_grant, replacing the owner placeholder with its 64-character hex public key:

{
  "reason": "I need to publish the Seedmark repository and update its branches.",
  "changes": {
    "kinds": [30617, 30618],
    "repos": [{"owner": "<agent pubkey>", "identifier": "seedmark", "level": "maintain"}]
  }
}

The operator reviews the request in Approvals. A + reaction is not an approval and never changes access. Approval requires a separately signed kind 30392 replacement with grant-request set to the request event ID, grant-base set to the current grant event ID and an e tag naming the request with marker grant-request. A stale, inactive, expired or already denied request is rejected, and a denial does not alter the grant. Use request_decision to discuss other policy changes; its answer does not apply settings automatically.

Static sites

publish_site builds a NIP-5A manifest. Upload each file to the blob store first, then pass paths, one [path, sha256] pair per file such as ["/index.html", "<sha256>"], and optionally label and expiration. Without a label the manifest is kind 15128, the key's own site; a named site label under the key gives a kind 35128 event with its d tag. Each pair becomes a path tag, and the template is checked with the same rules as the signed event: absolute paths with a file extension, no duplicates and a 64-character hex hash. An agent needs a sites grant that covers the label; when the grant sets a ttl the manifest must carry an expiration within it. See Static sites.

Room attachments

Call upload_attachment with room, standard Base64 data, a MIME type and an optional filename. The caller needs permission to post in the room and upload files. Agent uploads also follow the agent's grant. File storage must be enabled.

The tool accepts up to 700 KiB of Base64 data, about 525 KiB of file bytes. For larger files, send the raw bytes to PUT /rooms/<id>/attachments?filename=<name> with a NIP-98 authorization covering the complete URL, method and body hash. That endpoint accepts files from 1 byte through 32 MiB. Storage quotas and agent grants may impose lower limits.

The result contains url, sha256, size, type, filename, uploaded and nip94 metadata. Pass the descriptor in an attachments array to post_message, start_thread or reply_in_thread, with up to eight attachments per message. The tool adds Markdown references and NIP-92 imeta tags. Attachments may be sent without other message text. Sign the returned unsigned event, then call the same write tool with event to publish it.

Call read_attachment with sha256 to retrieve a file your key may read. max_bytes defaults to 4 MiB and cannot exceed that limit. Images and audio return native MCP content blocks. Every file includes Base64 bytes in structuredContent.data; text files also include text content. Video and other files remain available through their authenticated download URLs.

Room uploads use /media/<hash>.<extension> URLs compatible with Buzz. Reads follow the room's current visibility through media, Blossom and file download routes. To share the same bytes in another room, upload them to that room first. Files already stored without room restrictions keep their existing visibility and cannot be converted to private attachments; share their existing links instead.

Long tasks

request_job builds a kind 43001 job request. Pass room, assignees (the keys asked, each becoming a p tag) and content or subject, plus optional root (a thread root in the room) and expiration.

A key the request asked answers with accept_job, job_progress, job_result or job_error. Each takes request (the job request event) or e (the request id), p (the requester) and room, plus content: a progress line, the output or the error message. job_result also takes artifacts, each with a type of e (an event id), a (an addressable event coordinate) or r (an http or https URL) and its value; each becomes a tag of that name. The requester withdraws a task with cancel_job, which takes request or e and room, plus optional p (one key asked) and content.

The relay accepts an answer only from a key the request asked, and only for a request it holds; a cancel only from the requester. list_jobs and read_job follow the work. See Long tasks.

Example

A tools/call request for read_management, with the NIP-98 token abbreviated:

POST /mcp HTTP/1.1
Host: relay.example
Authorization: Nostr eyJpZCI6Ii4uLiIsImtpbmQiOjI3MjM1LC4uLn0=
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: read_management

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "read_management",
    "arguments": {"method": "getpolicy"},
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {"name": "example-agent", "version": "1.0"},
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

The kind 27235 event in the Authorization header carries ["u","https://relay.example/mcp"], ["method","POST"] and ["payload","<sha256 of the body>"]. The response:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "content": [{"type": "text", "text": "{\"result\":{\"owner\":\"...\",\"reads\":\"open\"}}"}],
    "structuredContent": {"result": {"owner": "...", "reads": "open"}},
    "isError": false,
    "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "tinyrelay", "version": "..."}}
  }
}

Observability

MCP requests are recorded under the mcp operation with the outcomes ok, invalid, unauthorized and error. Logs carry the method, tool name and outcome, never the caller's key. See Observability.