Skip to content

Developers

ModernMail MCP server

Give your AI assistant the power to post letters. Claude Code, Cursor, VS Code and other MCP clients can quote, upload and order with the same keys, scopes and daily caps as the REST API.

Model Context Protocol (MCP) is the open standard that lets AI tools call outside services. modelcontextprotocol.io

Illustration: an AI assistant window connects to ModernMail, is asked to post a letter, runs the upload, address check and quote tools, asks for confirmation and places the order.

Hover or tap to replay

Overview

One endpoint, ten tools, three resources. The server is stateless: every request stands alone and carries your key.
Endpoint
POST https://modernmail.co.uk/api/mcp
Transport
Streamable HTTP with JSON responses. No sessions, no server-sent event stream; GET and DELETE return 405.
Authentication
Authorization: Bearer mm_live_…
Server
modernmail 1.0.0
Payment
Orders are paid from the account's prepaid credit, within the key's daily cap.
Keys
Account → Developers (no account yet? create one)

The MCP server and the REST API share one service layer. A tool call does exactly what the matching endpoint does, checks the same scope, counts against the same rate limits and spend cap, and shows up in the same request log and order history (orders carry an MCP badge).

Cross-origin browser requests are refused; the server is for agent hosts and command-line clients, not web pages.

Connect a client

Works with any MCP client that can talk to a remote Streamable HTTP server and send a custom header. Create a key first, then pick your client.

Claude Code

One command. Add --scope user to make it available in every project rather than just the current one.

bash
claude mcp add --transport http modernmail https://modernmail.co.uk/api/mcp \  --header "Authorization: Bearer mm_live_…"

Cursor

.cursor/mcp.json in the project, or ~/.cursor/mcp.json for every project.

json
{  "mcpServers": {    "modernmail": {      "url": "https://modernmail.co.uk/api/mcp",      "headers": {        "Authorization": "Bearer mm_live_…"      }    }  }}

VS Code

.vscode/mcp.json. The inputs entry asks for the key once and keeps it out of the file.

json
{  "inputs": [    {      "id": "modernmail-key",      "type": "promptString",      "description": "ModernMail API key",      "password": true    }  ],  "servers": {    "modernmail": {      "type": "http",      "url": "https://modernmail.co.uk/api/mcp",      "headers": {        "Authorization": "Bearer ${input:modernmail-key}"      }    }  }}

Claude Desktop

Claude Desktop's config file takes local commands, so bridge to the remote server with mcp-remote. The header goes through an environment variable because some hosts split arguments on spaces.

claude_desktop_config.json
{  "mcpServers": {    "modernmail": {      "command": "npx",      "args": [        "-y",        "mcp-remote",        "https://modernmail.co.uk/api/mcp",        "--transport",        "http-only",        "--header",        "Authorization:${AUTH_HEADER}"      ],      "env": {        "AUTH_HEADER": "Bearer mm_live_…"      }    }  }}

Not supported yet: connectors that need OAuth or allow no authentication at all, such as the claude.ai web app's custom connectors and ChatGPT connectors. The server authenticates with a static bearer key only. If you need OAuth, email info@modernmail.co.uk.

Safety

An agent that can spend money needs guard rails. These are built in and cannot be switched off by the agent.
  • Read-only by default. A new key has account:read, files:write, orders:read: it can price, upload and read, but not order. Grant orders:write only to a key you want to spend.
  • Daily spend cap per key (default £50). Orders that would pass it are refused with spend_cap_exceeded.
  • Prepaid credit only. There is no card on file to run up. A short balance is refused with insufficient_credit and a top-up link for the human.
  • Confirm before spending. The create_order tool's description tells the assistant to quote first and confirm the total and recipients with you before calling it, and to pass the quote's expectedTotalPence so a price change is refused rather than charged. A fresh idempotencyKey per attempt means a retry can never pay twice.
  • One destructive tool, clearly marked. cancel_order refunds a whole order to credit and is the only tool that undoes anything; it is marked destructive and only works while nothing on the order has been started. There is no tool to delete files or change account settings. Every tool carries MCP annotations (readOnlyHint, destructiveHint, idempotentHint) so hosts can ask for your approval appropriately.
  • Visible. Every call is logged against the key (no bodies, no keys) and shown under Account → Developers → Activity; orders show an MCP badge. Revoke a key there at any time and it stops working at once.

Tools

What the assistant can call. Names, descriptions and arguments below are the live definitions the server advertises.

get_pricing

Any keyRead-only

Get pricing

The current rate card: postage services with prices and delivery times, envelope and paper options, colour print cost, extra page cost and volume discount tiers. All amounts are integer pence (GBP).

Arguments

None.

get_account

account:readRead-only

Get account

Who this API key acts as (person or business), the prepaid credit balance, the key's scopes and its daily spend cap with today's usage. Check the balance before creating an order.

Arguments

None.

upload_document

files:writeWritesFetches URLs you give it

Upload a document

Upload a PDF or Word document to be printed. Give either a public https URL (up to 50 MB) or base64 bytes (up to 4 MB). The file type is detected from its bytes; PDFs with scripts, actions or embedded files are refused. Returns the file id to use in letters and its page count.

Also limited to 10 per minute per key (file ingests bucket).

Arguments

upload_document arguments
FieldTypeNotes
urlstring (uri)Public https URL of the document (http is refused).
base64stringBase64 of the document bytes (standard or URL-safe).
filenamestringName to show on the order, e.g. invoice-123.pdf. max length 200.
url
Typestring (uri)
NotesPublic https URL of the document (http is refused).
base64
Typestring
NotesBase64 of the document bytes (standard or URL-safe).
filename
Typestring
NotesName to show on the order, e.g. invoice-123.pdf. max length 200.

get_document

files:writeRead-only

Get a document

Status and page count of an uploaded file.

Arguments

get_document arguments
FieldTypeNotes
fileId*stringmax length 64.
fileId*
Typestring
Notesmax length 64.

validate_address

addresses:validateRead-only

Check a UK address

Check a UK recipient address against Royal Mail's address file before ordering. Pass the recipient fields (at least recipientPostcode). Returns status verified (as typed), corrected (use the returned address, written as Royal Mail has it), unmatched (nothing at that postcode fits; candidates lists what is there, ask the user to choose), lookup (postcode only: candidates is every address there) or postcode_unknown. Each call is a metered lookup capped per key per day; do not call it in a loop.

Also limited to 30 per minute per key (address checks bucket).

Arguments

validate_address arguments
FieldTypeNotes
recipientNamestringmax length 200.
recipientAddressLine1stringmax length 200.
recipientAddressLine2stringmax length 200.
recipientAddressLine3stringmax length 200.
recipientCitystringmax length 100.
recipientPostcode*stringA UK postcode. min length 5, max length 8.
recipientName
Typestring
Notesmax length 200.
recipientAddressLine1
Typestring
Notesmax length 200.
recipientAddressLine2
Typestring
Notesmax length 200.
recipientAddressLine3
Typestring
Notesmax length 200.
recipientCity
Typestring
Notesmax length 100.
recipientPostcode*
Typestring
NotesA UK postcode. min length 5, max length 8.

quote_order

orders:readRead-only

Quote an order

Price an order without creating it. One entry per recipient: name and address, postageType (see get_pricing), colourPrint (bw|colour), paperQuality (standard|premium), envelopeStyle, quantity and the uploaded file ids (uploadIds). Returns per-letter prices, discounts, the total, whether credit covers it and the expectedTotalPence to pass to create_order.

Arguments

quote_order arguments
FieldTypeNotes
letters*object[]min 1 item, max 500 items.
letters*
Typeobject[]
Notesmin 1 item, max 500 items.

Each entry in letters is a letter object: recipient, postage, print options and the uploaded file ids.

create_order

orders:writeWritesIdempotent

Create and pay for an order

Create an order and pay for it from the account's prepaid credit. Spends money: confirm the total and recipients with the user first, and quote_order before calling. Pass the quote's expectedTotalPence so a price change is refused rather than charged. idempotencyKey must be a fresh unique string (UUID) per order attempt; reuse it only when retrying the same order. Pass postOn (a UK working day, YYYY-MM-DD, tomorrow to 12 months ahead) to pay now and have the order held until that day; confirm the day with the user first. Or pass deliverBy (YYYY-MM-DD, a day Royal Mail delivers on) to have the posting day worked out from each letter's postage so it arrives by then; refused with deliver_by_unreachable when a chosen service can't make it. Fails with insufficient_credit (402) when the balance is short, spend_cap_exceeded when the key's daily cap would be passed, post_on_invalid for a day we don't post on and scheduling_unavailable when scheduling is switched off.

Also limited to 5 per minute per key (order creations bucket).

Arguments

create_order arguments
FieldTypeNotes
letters*object[]min 1 item, max 500 items.
expectedTotalPenceintegerFrom quote_order. min 0.
referencestringYour own reference, shown on the order. max length 120.
postOnstringPost on this UK working day (YYYY-MM-DD), tomorrow to 12 months ahead. Omit to post on the next posting day.
deliverBystringArrive by this UK date (YYYY-MM-DD; Mon–Sat, not a bank holiday). The posting day is worked out from each letter's postage; Royal Mail's aim, not a guarantee. Not together with postOn.
idempotencyKey*stringUnique per order attempt, e.g. a UUID. max length 200.
letters*
Typeobject[]
Notesmin 1 item, max 500 items.
expectedTotalPence
Typeinteger
NotesFrom quote_order. min 0.
reference
Typestring
NotesYour own reference, shown on the order. max length 120.
postOn
Typestring
NotesPost on this UK working day (YYYY-MM-DD), tomorrow to 12 months ahead. Omit to post on the next posting day.
deliverBy
Typestring
NotesArrive by this UK date (YYYY-MM-DD; Mon–Sat, not a bank holiday). The posting day is worked out from each letter's postage; Royal Mail's aim, not a guarantee. Not together with postOn.
idempotencyKey*
Typestring
NotesUnique per order attempt, e.g. a UUID. max length 200.

Each entry in letters is a letter object: recipient, postage, print options and the uploaded file ids.

cancel_order

orders:writeWritesIdempotent

Cancel an order

Cancel a whole order for a full refund to the account's credit (or the card it was paid with). Only possible while the order is paid and nothing on it has been started: once printing or labelling has begun it is refused with order_not_cancellable, and the user should ask support instead. The returned order has every letter cancelled and status refunded (money returned) or cancelled. Confirm with the user before calling. Cannot be undone.

Arguments

cancel_order arguments
FieldTypeNotes
orderId*stringmax length 64.
orderId*
Typestring
Notesmax length 64.

get_order

orders:readRead-only

Get an order

One order with its status, amounts and letters (dispatch dates and Royal Mail tracking numbers).

Arguments

get_order arguments
FieldTypeNotes
orderId*stringmax length 64.
orderId*
Typestring
Notesmax length 64.

list_orders

orders:readRead-only

List orders

The account's orders, newest first, with a cursor for the next page.

Arguments

list_orders arguments
FieldTypeNotes
statusstringOne of created, paid, processing, completed, cancelled, refunded
referencestringExact match on the reference given at creation. max length 120.
onlyThisKeybooleanOnly orders placed with this API key.
limitintegermin 1, max 100.
cursorstring
status
Typestring
NotesOne of created, paid, processing, completed, cancelled, refunded
reference
Typestring
NotesExact match on the reference given at creation. max length 120.
onlyThisKey
Typeboolean
NotesOnly orders placed with this API key.
limit
Typeinteger
Notesmin 1, max 100.
cursor
Typestring

Resources

Documents the assistant can read for context without calling a tool.
MCP resources
URITitleContentsScope
modernmail://docs/orderingHow to order lettersStep-by-step guide to uploading, quoting and ordering. text/markdownAny key
modernmail://pricingCurrent pricingPostage, envelope, print and paper prices and volume discounts. application/jsonAny key
modernmail://accountAccountWho this key acts as, credit balance and today's spend. application/jsonaccount:read
modernmail://docs/ordering
TitleHow to order letters
ContentsStep-by-step guide to uploading, quoting and ordering. text/markdown
ScopeAny key
modernmail://pricing
TitleCurrent pricing
ContentsPostage, envelope, print and paper prices and volume discounts. application/json
ScopeAny key
modernmail://account
TitleAccount
ContentsWho this key acts as, credit balance and today's spend. application/json
Scopeaccount:read

The ordering guide

What the assistant reads from modernmail://docs/ordering. Useful to know, because it is the script your agent will follow.

Ordering letters through ModernMail

  1. Upload each document with upload_document (a public URL or base64 of a PDF or Word file). Note the returned file id and pageCount.
  2. Quote with quote_order: one entry per recipient with their address, the postage service, print options and the file ids. The quote returns expectedTotalPence and whether the account's credit covers it.
  3. Confirm with the user before spending: show the total, recipients and service. Orders are paid from prepaid credit. They can be cancelled with cancel_order for a full refund only while nothing has been started on them (usually the same day, before printing); after that the user asks support.
  4. Create with create_order, passing the same letters, expectedTotalPence from the quote and a fresh idempotencyKey (a UUID) you keep for retries. The order is printed and posted by ModernMail; use get_order to follow its status and tracking.

To post on a later day, pass postOn to create_order: a UK working day (Mon–Fri, not a bank holiday) in YYYY-MM-DD, from tomorrow up to 12 months ahead. The order is paid now and held in the account until that morning; until then the user can change the day or cancel it for a full refund from their account. Confirm the day with the user before creating the order. A weekend or bank holiday is refused with post_on_invalid and details.nextWorkingDay.

To have a letter arrive by a date instead, pass deliverBy (YYYY-MM-DD, a day Royal Mail delivers on: Mon–Sat, not a bank holiday) in place of postOn. ModernMail works back from Royal Mail's usual aim for each letter's postage service to the last working day that still makes it and holds the order until then; the response's postOn is that day. Only services that can reach the date are accepted: a 1st Class, Tracked 24 or Special Delivery letter can arrive the next working day, 2nd Class and Tracked 48 the day after next (1st Class, 1st Class Signed For, Tracked 24 and Tracked 48 are delivered on Saturdays too; 2nd Class and Special Delivery are not). A service that can't make it is refused with deliver_by_unreachable; details.services lists each service's earliest date and details.suggestedDeliverBy the first date they all meet, so either move the date or change the service. These are Royal Mail's aims, not guarantees; Special Delivery is the guaranteed service.

Addresses: UK recipients need a valid postcode and recipientCountry: "GB" (the default); non-UK recipients must use postageType: "international_standard" with their ISO country code. When the key has the addresses:validate scope, run validate_address on each UK recipient before quoting: use a corrected address as returned, and ask the user to choose when it is unmatched. It is a metered lookup, so once per address, not in a loop. Postage services and prices are in get_pricing. Amounts are integer pence.

If create_order fails with insufficient_credit, tell the user the shortfall and give them the top-up link; do not retry until they have added credit.

A key starting mm_test_ is a sandbox key: every tool works the same, but orders are simulated (livemode: false), nothing is charged, printed or posted, and the credit shown is a daily sandbox allowance. Tell the user when you are in the sandbox.

Limits and errors

Every MCP request counts against the key's writes bucket (30 per minute). upload_document also uses the file ingests bucket (10 per minute) create_order the order creations bucket (5 per minute) and validate_address the address checks bucket (30 per minute, plus a daily cap per key), the same as the REST endpoints they mirror.

A bad or missing key is a plain HTTP 401. Once authenticated, a refused tool call is still HTTP 200: the result has isError: true and carries the REST error body, so insufficient_scope, insufficient_credit, spend_cap_exceeded, price_changed, not_found and rate_limited (with retryAfterSeconds) mean exactly what they do over REST.

A refused tool call
{  "isError": true,  "structuredContent": {    "error": {      "code": "insufficient_scope",      "message": "This API key does not have the orders:write scope. Ask the account owner to grant it under Account → Developers.",      "details": {        "required": "orders:write"      }    }  }}

Prefer plain HTTP?

Everything here is also a JSON endpoint. The REST API reference covers the ordering flow, idempotency, webhooks and every object in detail, with an OpenAPI 3.1 spec.

Read the API reference →