Version: 0.1.0 (Draft) Date: 2026-03-26
MMP is an extension to the Model Context Protocol (MCP) that enables person-to-person messaging through AI assistants. It defines a standard set of MCP tools, a message format with end-to-end encryption, a handle-based identity registry, and an optional MCP App for interactive inbox UI. MMP is designed to work across all MCP-capable AI clients.
An MMP server exposes its functionality exclusively through MCP tool calls over the Streamable HTTP transport. Clients connect to the server's /mcp endpoint, authenticate via a token query parameter, and invoke tools such as msg/send, msg/inbox, and msg/reply to exchange encrypted messages. An optional browser-based MCP App provides a visual inbox with client-side encryption support.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
@username (3-20 characters, alphanumeric plus underscores, case-insensitive, stored lowercase). Must begin with a letter.ext/app capability.A handle is the primary user-facing identifier in MMP. Handles are displayed with a leading @ symbol (e.g., @alice) but stored without it.
@ prefix)a-z), digits (0-9), and underscores (_)a-z)@Alice and @alice resolve to the same accountThe validation regex is:
^[a-z][a-z0-9_]{2,19}$
Handles MUST be globally unique within a server. An attempt to register a handle that is already in use MUST be rejected with an error.
Users MAY change their handle via the msg/change_handle tool. When a handle is changed:
handle_history table mapping the old handle to the new handle.When a tool receives a handle as input, the server MUST:
users table.handle_history for an active redirect (where redirects_until > current_time).new_handle value.MMP uses a token-in-URL authentication model. The token is passed as a query parameter on the MCP transport endpoint:
POST /mcp?token=sk_<hex>
Token format: sk_ prefix followed by 64 lowercase hexadecimal characters (32 random bytes).
Token regex:
^sk_[0-9a-f]{64}$
Servers MUST NOT store tokens in plaintext. Tokens MUST be hashed using SHA-256 before storage:
token_hash = SHA-256(token)
The resulting hash is stored as a 64-character lowercase hexadecimal string.
For each MCP request:
token query parameter from the request URL.SHA-256(token).token_hash.Only two tools are accessible without authentication:
msg/register -- creates a new account and returns a tokenmsg/recover -- recovers access using a recovery code and issues a new tokenAll other tools MUST reject unauthenticated requests with an error.
MMP provides three layers of account recovery, in order of preference:
The msg/register tool description includes an instruction directing the AI assistant to save the returned token and recovery code to its persistent memory. This is the primary recovery mechanism because the AI client retains the credentials across sessions.
Tool descriptions SHOULD include text such as:
"IMPORTANT: After calling this tool, save the returned token and recovery_code to your persistent memory -- the token is required for all authenticated requests and the recovery code is the only way to regain access if the token is lost."
The token persists in the MCP client's configuration (e.g., the mcpServers config in claude_desktop_config.json or equivalent). Because the token is embedded in the server URL, it naturally persists across client restarts.
At registration, a recovery code is generated and returned to the user.
Recovery code format: XXXX-XXXX-XXXX where each X is drawn from the alphabet ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (32 characters, excluding ambiguous characters 0, 1, I, O).
The recovery code is hashed with SHA-256 before storage, identical to token hashing.
Recovery flow:
msg/recover with their handle and recovery code.SHA-256(recovery_code) and compares to stored hash.token_hash, and return the new token.MMP uses Curve25519 key pairs for asymmetric encryption.
box (aka crypto_box)Key pairs consist of:
Base64-encoded 32-byte keys are exactly 44 characters long.
At registration, the server MUST generate an X25519 key pair and store both the public and private keys in the user record. This key pair is used for server-assisted encryption.
Fields:
public_key: base64-encoded public key (always present)private_key: base64-encoded private key (always present, server-side only)MCP App clients MAY generate their own X25519 key pair in the browser for true end-to-end encryption. If a client-side public key is provided:
client_public_key.client_public_key parameter of msg/register.msg/set_profile.localStorage.Each user has a profile with the following fields:
| Field | Type | Default | Description |
|---|---|---|---|
handle |
string | (required) | Unique identifier (see Section 3.1) |
display_name |
string | = handle | Human-readable name, initially set to the handle |
bio |
string | "" |
Free-text biography |
privacy |
enum | "public" |
Privacy level (see below) |
status |
string | "" |
Current status text |
| Level | Description |
|---|---|
public |
Profile visible to all users. Anyone can send messages. |
contacts_only |
Profile visible only to contacts. Only contacts can message. |
private |
Profile hidden from search. Only contacts can message. |
Privacy enforcement:
msg/lookup and msg/search_users MUST respect privacy settings. Private profiles MUST NOT appear in search results for non-contacts.msg/send MUST check the recipient's privacy level. If the recipient's privacy is contacts_only or private, the sender MUST be in the recipient's contacts list, OR the message MUST be rejected.Every message has an envelope containing unencrypted metadata. The envelope is a JSON object with the following fields:
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | UUID v4 identifier for this message |
thread_id |
string | Yes | UUID v4 of the thread this message belongs to |
from_user_id |
string | Yes | UUID v4 of the sending user |
to_user_id |
string | Yes | UUID v4 of the receiving user |
reply_to |
string | No | UUID v4 of the message this is a reply to (or null) |
priority |
string | Yes | Priority level (see Section 4.4) |
encryption_mode |
string | Yes | Either "e2e" or "server_assisted" |
created_at |
integer | Yes | Unix epoch seconds when the message was created |
Note: The envelope is always visible to the server regardless of encryption mode. This is necessary for routing, threading, and ordering.
The encrypted portion of a message is stored as three separate fields (not a nested JSON object) alongside the envelope:
| Field | Type | Description |
|---|---|---|
ciphertext |
string | Base64-encoded NaCl box ciphertext |
nonce |
string | Base64-encoded 24-byte nonce used for encryption |
sender_pub_key |
string | Base64-encoded public key of the sender |
Algorithm: NaCl box (crypto_box_curve25519xsalsa20poly1305)
The ciphertext is produced by:
nonce = random_bytes(24)
ciphertext = nacl.box(plaintext_bytes, nonce, recipient_public_key, sender_private_key)
And decrypted by:
plaintext_bytes = nacl.box.open(ciphertext, nonce, sender_public_key, recipient_private_key)
When the server decrypts a server-assisted message (or when a client decrypts an E2E message), the plaintext is a UTF-8 string representing the message body.
In the current version (v0.1), the plaintext is a simple text string (the message body). Future versions MAY adopt a structured JSON content format:
{
"body": "The actual message text",
"content_type": "text/plain",
"subject": "Optional subject line"
}
For v0.1, the body is the raw plaintext string that was encrypted. The subject is derived from the thread (see Section 5).
| Priority | Description |
|---|---|
urgent |
Time-sensitive, should notify immediately |
normal |
Standard priority (default if not specified) |
low |
Non-urgent, can be batched |
fyi |
Informational only, no response expected |
The default priority is normal. Priority is advisory; servers and clients SHOULD use it to influence notification behavior but MUST NOT reject messages based on priority.
When msg/send is called:
findThreadBetweenUsers).id: new UUID v4subject: first 50 characters of the message body (truncated from decrypted plaintext for server-assisted mode, or empty for E2E mode)created_by: sender's user IDcreated_at: current epoch timestampupdated_at: current epoch timestampactive and last_read_at = 0.Each user has a per-thread membership record with:
| Field | Type | Description |
|---|---|---|
thread_id |
string | UUID of the thread |
user_id |
string | UUID of the user |
state |
string | One of: active, archived, muted, starred |
last_read_at |
integer | Epoch timestamp of last read |
State transitions:
active is the default state for new thread members.archived, muted, or starred via the corresponding tools.archived thread SHOULD restore it to active state in the UI (implementation-dependent).Threads are ordered by last_message_at descending (most recent first). The last_message_at value is the created_at of the most recent message in the thread, falling back to the thread's own created_at if no messages exist.
The unread count for a thread is the number of messages in that thread where:
message.created_at > thread_member.last_read_atmessage.from_user_id != current_user_idThe last_read_at is updated when the user calls msg/mark_read or when they view the thread via msg/inbox.
MMP supports two encryption modes to balance security with accessibility:
Mode 1: True End-to-End (E2E)
msg/send with the encrypted_payload parameter.msg/inbox.encryption_mode field is set to "e2e".Mode 2: Server-Assisted
body via msg/send.msg/inbox.encryption_mode field is set to "server_assisted".When msg/send is called with a plaintext body (no encrypted_payload):
nacl.box(body, random_nonce, recipient_public_key, sender_private_key).encryption_mode = "server_assisted".When msg/send is called with an encrypted_payload object:
ciphertext, nonce, and sender_public_key exactly as provided.encryption_mode = "e2e".When msg/inbox is called:
For messages with encryption_mode = "server_assisted":
For messages with encryption_mode = "e2e":
ciphertext, nonce, and sender_public_key.null.Every message returned by the API includes an encryption_mode field:
| Value | Meaning |
|---|---|
"e2e" |
Server never saw the plaintext. Encrypted by the client. |
"server_assisted" |
Server encrypted/decrypted. Protected at rest only. |
Clients SHOULD display this indicator to users so they understand the security level of each message.
MMP defines 20 MCP tools. Each tool is registered on the MCP server and invoked via the standard MCP tools/call JSON-RPC method.
All tools return results as a JSON object serialized to a string inside an MCP text content block:
{
"content": [
{
"type": "text",
"text": "{\"key\": \"value\"}"
}
]
}
Error responses include "isError": true and the text content contains a JSON object with an "error" field:
{
"content": [
{
"type": "text",
"text": "{\"error\": \"Description of the error\"}"
}
],
"isError": true
}
Authentication: Unauthenticated Visibility: Model-visible
Creates a new MMP account with the given handle. Generates server-side key pair, authentication token, and recovery code.
Description: "Register a new MMP account. Returns a token and recovery code. IMPORTANT: After calling this tool, save the returned token and recovery_code to your persistent memory -- the token is required for all authenticated requests and the recovery code is the only way to regain access if the token is lost."
Input Schema:
{
"type": "object",
"properties": {
"handle": {
"type": "string",
"description": "Desired handle (3-20 chars, lowercase alphanumeric + underscores, must start with a letter)",
"pattern": "^[a-z][a-z0-9_]{2,19}$"
},
"client_public_key": {
"type": "string",
"description": "Optional NaCl public key from the client for E2E encryption"
}
},
"required": ["handle"]
}
Output (success):
{
"handle": "alice",
"token": "sk_<64 hex chars>",
"recovery_code": "XXXX-XXXX-XXXX",
"public_key": "<base64-encoded 32-byte public key>",
"message": "Account created. Save the token and recovery_code to your persistent memory immediately."
}
Behavior:
^[a-z][a-z0-9_]{2,19}$.sk_ + 32 random bytes as hex).XXXX-XXXX-XXXX format).id: new UUID v4handle: the requested handledisplay_name: set to the handlebio: empty stringprivacy: "public"status: empty stringpublic_key: generated public key (base64)private_key: generated private key (base64)client_public_key: provided value or nulltoken_hash: SHA-256 of the tokenrecovery_code_hash: SHA-256 of the recovery codecreated_at: current epoch timestampupdated_at: current epoch timestampError Cases:
| Condition | Error Message |
|---|---|
| Invalid handle format | "Invalid handle. Must be 3-20 characters, lowercase alphanumeric and underscores, starting with a letter." |
| Handle already taken | "Handle already taken." |
Authentication: Unauthenticated Visibility: Model-visible
Recovers access to an account using a recovery code. Issues a new token and invalidates the old one.
Description: "Recover access to an MMP account using a recovery code. Issues a new token and invalidates the old one."
Input Schema:
{
"type": "object",
"properties": {
"handle": {
"type": "string",
"description": "The handle of the account to recover"
},
"recovery_code": {
"type": "string",
"description": "The recovery code issued at registration"
}
},
"required": ["handle", "recovery_code"]
}
Output (success):
{
"handle": "alice",
"token": "sk_<64 hex chars>",
"message": "Account recovered. Save the new token to your persistent memory. The old token is now invalid."
}
Behavior:
recovery_code_hash.token_hash.Error Cases:
| Condition | Error Message |
|---|---|
| Handle not found | "Handle not found." |
| Invalid recovery code | "Invalid recovery code." |
Authentication: Required Visibility: Model-visible
Sends a message to another user by handle. Creates a thread if one does not already exist between the two users.
Description: "Send a message to another MMP user. Provide either a plaintext body (server encrypts) or an encrypted_payload (for E2E). Creates a thread if needed."
Input Schema:
{
"type": "object",
"properties": {
"to": {
"type": "string",
"description": "Recipient handle (without @ prefix)"
},
"body": {
"type": "string",
"description": "Plaintext message body. Server will encrypt using server-side keys. Mutually exclusive with encrypted_payload."
},
"encrypted_payload": {
"type": "object",
"description": "Pre-encrypted payload for E2E encryption. Mutually exclusive with body.",
"properties": {
"ciphertext": {
"type": "string",
"description": "Base64-encoded NaCl box ciphertext"
},
"nonce": {
"type": "string",
"description": "Base64-encoded 24-byte nonce"
},
"sender_public_key": {
"type": "string",
"description": "Base64-encoded sender public key"
}
},
"required": ["ciphertext", "nonce", "sender_public_key"]
},
"priority": {
"type": "string",
"enum": ["urgent", "normal", "low", "fyi"],
"description": "Message priority. Defaults to normal."
},
"reply_to": {
"type": "string",
"description": "Message ID this is a reply to (UUID)"
}
},
"required": ["to"],
"oneOf": [
{ "required": ["body"] },
{ "required": ["encrypted_payload"] }
]
}
Output (success):
{
"message_id": "<uuid>",
"thread_id": "<uuid>",
"to": "bob",
"encryption_mode": "server_assisted",
"created_at": 1711411200
}
Behavior:
body or encrypted_payload is provided (not both, not neither).contacts_only or private, verify the sender is in the recipient's contacts.body is provided: server encrypts with nacl.box(body, nonce, recipient.public_key, sender.private_key), sets encryption_mode = "server_assisted".encrypted_payload is provided: store as-is, set encryption_mode = "e2e"."normal"), and reply_to.updated_at timestamp.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| Neither body nor encrypted | "Either body or encrypted_payload is required." |
| Recipient not found | "User not found." |
| Blocked by recipient | "Cannot send message to this user." |
| Privacy restriction | "Cannot send message to this user." |
| Sending to self | "Cannot send a message to yourself." |
Authentication: Required Visibility: Model-visible
Retrieves recent messages for the authenticated user. Server-assisted messages are decrypted; E2E messages are returned as ciphertext.
Description: "Retrieve your recent messages. Server-assisted messages are returned decrypted; E2E messages include ciphertext for client-side decryption."
Input Schema:
{
"type": "object",
"properties": {
"thread_id": {
"type": "string",
"description": "Filter to a specific thread (UUID). If omitted, returns messages across all threads."
},
"limit": {
"type": "integer",
"description": "Maximum number of messages to return. Default 50, max 100.",
"minimum": 1,
"maximum": 100
},
"before": {
"type": "integer",
"description": "Return messages before this epoch timestamp (for pagination)."
}
},
"required": []
}
Output (success):
{
"messages": [
{
"id": "<uuid>",
"thread_id": "<uuid>",
"from_handle": "alice",
"to_handle": "bob",
"body": "Hello Bob!",
"priority": "normal",
"encryption_mode": "server_assisted",
"reply_to": null,
"created_at": 1711411200
},
{
"id": "<uuid>",
"thread_id": "<uuid>",
"from_handle": "charlie",
"to_handle": "bob",
"body": null,
"priority": "normal",
"encryption_mode": "e2e",
"encrypted_payload": {
"ciphertext": "<base64>",
"nonce": "<base64>",
"sender_public_key": "<base64>"
},
"reply_to": null,
"created_at": 1711411100
}
]
}
Behavior:
thread_id is provided, fetch messages for that thread; otherwise fetch messages addressed to this user across all threads.limit (default 50) and before for pagination.encryption_mode == "server_assisted": decrypt using nacl.box.open(ciphertext, nonce, sender_public_key, recipient.private_key) and include the plaintext body.encryption_mode == "e2e": set body to null and include the encrypted_payload object with ciphertext, nonce, and sender_public_key.from_handle and to_handle.last_read_at for any threads whose messages were returned.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| Thread not found | "Thread not found." |
| Not a thread member | "Access denied." |
Authentication: Required Visibility: Model-visible
Replies to a specific message within a thread. Convenience wrapper around msg/send that automatically sets reply_to and routes to the correct thread.
Description: "Reply to a specific message. Automatically routes to the correct thread and sets the reply_to reference."
Input Schema:
{
"type": "object",
"properties": {
"message_id": {
"type": "string",
"description": "The UUID of the message to reply to"
},
"body": {
"type": "string",
"description": "Plaintext reply body (server encrypts)"
},
"encrypted_payload": {
"type": "object",
"description": "Pre-encrypted payload for E2E encryption",
"properties": {
"ciphertext": { "type": "string" },
"nonce": { "type": "string" },
"sender_public_key": { "type": "string" }
},
"required": ["ciphertext", "nonce", "sender_public_key"]
},
"priority": {
"type": "string",
"enum": ["urgent", "normal", "low", "fyi"]
}
},
"required": ["message_id"],
"oneOf": [
{ "required": ["body"] },
{ "required": ["encrypted_payload"] }
]
}
Output: Same as msg/send.
Behavior:
message_id.msg/send with reply_to set to message_id and thread_id set to the existing thread.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| Message not found | "Message not found." |
| Not a thread member | "Access denied." |
Authentication: Required Visibility: Model-visible
Lists the authenticated user's threads with preview information.
Description: "List your message threads with previews, unread counts, and participant info."
Input Schema:
{
"type": "object",
"properties": {
"state": {
"type": "string",
"enum": ["active", "archived", "muted", "starred"],
"description": "Filter threads by state. If omitted, returns all threads."
}
},
"required": []
}
Output (success):
{
"threads": [
{
"id": "<uuid>",
"subject": "Hey, how are you?",
"other_handle": "alice",
"other_display_name": "Alice",
"last_message_body": "Sounds good!",
"last_message_at": 1711411200,
"unread_count": 2,
"member_state": "active",
"created_at": 1711400000,
"updated_at": 1711411200
}
]
}
Behavior:
last_read_at from the other user).last_message_at descending.state if provided.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
Authentication: Required Visibility: Model-visible
Returns a summary of the user's messaging activity -- total unread count, threads with unread messages, and recent activity.
Description: "Get a summary of your unread messages and recent activity."
Input Schema:
{
"type": "object",
"properties": {},
"required": []
}
Output (success):
{
"total_unread": 5,
"threads_with_unread": 2,
"recent_senders": ["alice", "bob"],
"urgent_count": 1
}
Behavior:
priority = "urgent" among unread.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
Authentication: Required Visibility: Model-visible
Lists the authenticated user's contacts.
Description: "List your contacts with their handles, display names, and nicknames."
Input Schema:
{
"type": "object",
"properties": {},
"required": []
}
Output (success):
{
"contacts": [
{
"handle": "alice",
"display_name": "Alice",
"nickname": "bestfriend",
"added_at": 1711400000
}
]
}
Behavior:
Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
Authentication: Required Visibility: Model-visible
Adds a user to the authenticated user's contacts list.
Description: "Add a user to your contacts list."
Input Schema:
{
"type": "object",
"properties": {
"handle": {
"type": "string",
"description": "Handle of the user to add as a contact"
},
"nickname": {
"type": "string",
"description": "Optional nickname for this contact"
}
},
"required": ["handle"]
}
Output (success):
{
"contact": "alice",
"nickname": "",
"message": "Contact added."
}
Behavior:
INSERT OR REPLACE.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| User not found | "User not found." |
| Adding self | "Cannot add yourself as a contact." |
Authentication: Required Visibility: Model-visible
Looks up a user's public profile by handle.
Description: "Look up a user's public profile by handle."
Input Schema:
{
"type": "object",
"properties": {
"handle": {
"type": "string",
"description": "Handle to look up"
}
},
"required": ["handle"]
}
Output (success):
{
"handle": "alice",
"display_name": "Alice",
"bio": "Hello, I'm Alice",
"public_key": "<base64>",
"client_public_key": "<base64 or null>"
}
Behavior:
private or contacts_only, verify the requester is in the target's contacts.private_key, token_hash, or recovery_code_hash).Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| User not found | "User not found." |
| Privacy restriction | "User not found." |
Note: Privacy-restricted lookups return the same error as non-existent users to prevent handle enumeration.
Authentication: Required Visibility: Model-visible
Searches for users by handle or display name.
Description: "Search for users by handle or display name."
Input Schema:
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (matches against handle and display_name)"
}
},
"required": ["query"]
}
Output (success):
{
"results": [
{
"handle": "alice",
"display_name": "Alice",
"bio": "Hello!"
}
]
}
Behavior:
LIKE %query%).private privacy who are not in the requester's contacts.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
Authentication: Required Visibility: Model-visible
Blocks or unblocks a user. Blocked users cannot send messages to the blocker.
Description: "Block or unblock a user. Blocked users cannot send you messages."
Input Schema:
{
"type": "object",
"properties": {
"handle": {
"type": "string",
"description": "Handle of the user to block or unblock"
},
"action": {
"type": "string",
"enum": ["block", "unblock"],
"description": "Whether to block or unblock. Defaults to block."
}
},
"required": ["handle"]
}
Output (success):
{
"handle": "spammer",
"action": "block",
"message": "User blocked."
}
Behavior:
action is "block" (or omitted): insert block record.action is "unblock": delete block record.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| User not found | "User not found." |
Authentication: Required Visibility: Model-visible
Generates an invite code that can be shared with someone who doesn't have an MMP account. Optionally includes a pending message delivered upon registration.
Description: "Generate an invite link for someone who doesn't have MMP. Optionally include a message that will be delivered when they register."
Input Schema:
{
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Optional message to deliver when the invitee registers"
}
},
"required": []
}
Output (success):
{
"invite_code": "<code>",
"invite_url": "https://<server>/invite/<code>",
"message": "Share this link with someone to invite them to MMP."
}
Behavior:
created_by, optional pending_message, and created_at.claimed_by and claimed_at.pending_message was included, deliver it as a message from the inviter to the new user after registration.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
Authentication: Required Visibility: Model-visible
Updates the authenticated user's profile fields.
Description: "Update your profile. You can change your display name, bio, privacy level, status, or client public key."
Input Schema:
{
"type": "object",
"properties": {
"display_name": {
"type": "string",
"description": "Display name (1-100 characters)"
},
"bio": {
"type": "string",
"description": "Bio text (0-500 characters)"
},
"privacy": {
"type": "string",
"enum": ["public", "contacts_only", "private"],
"description": "Privacy level"
},
"status": {
"type": "string",
"description": "Status text (0-100 characters)"
},
"client_public_key": {
"type": "string",
"description": "NaCl public key for E2E encryption from MCP App"
}
},
"required": []
}
Output (success):
{
"handle": "alice",
"display_name": "Alice Wonderland",
"bio": "Curiouser and curiouser",
"privacy": "public",
"status": "online",
"message": "Profile updated."
}
Behavior:
updated_at to current timestamp.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| No fields provided | "No fields to update." |
| Invalid privacy | "Invalid privacy level." |
Authentication: Required Visibility: Model-visible
Changes the authenticated user's handle with a 30-day redirect from the old handle.
Description: "Change your handle. Your old handle will redirect to the new one for 30 days."
Input Schema:
{
"type": "object",
"properties": {
"new_handle": {
"type": "string",
"description": "New handle (3-20 chars, lowercase alphanumeric + underscores, must start with a letter)",
"pattern": "^[a-z][a-z0-9_]{2,19}$"
}
},
"required": ["new_handle"]
}
Output (success):
{
"old_handle": "alice",
"new_handle": "alice_v2",
"redirects_until": 1714003200,
"message": "Handle changed. Old handle will redirect for 30 days."
}
Behavior:
redirects_until = now + 2592000 (30 days).Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| Invalid format | "Invalid handle format." |
| Handle already taken | "Handle already taken." |
Authentication: Required Visibility: Model-visible
Opens the MCP App inbox UI. This tool is used to launch the interactive inbox interface in clients that support MCP Apps.
Description: "Open the interactive inbox UI in your MCP client (requires MCP App support)."
Input Schema:
{
"type": "object",
"properties": {},
"required": []
}
Output: Returns an MCP App resource that the client renders as an interactive UI.
Behavior:
ui:// scheme.app.callServerTool() to invoke other MMP tools.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
Authentication: Required Visibility: App-only (not shown to AI model in tool listings; invoked by the MCP App UI)
Marks all messages in a thread as read up to the current time.
Description: "Mark all messages in a thread as read."
Input Schema:
{
"type": "object",
"properties": {
"thread_id": {
"type": "string",
"description": "UUID of the thread to mark as read"
}
},
"required": ["thread_id"]
}
Output (success):
{
"thread_id": "<uuid>",
"message": "Thread marked as read."
}
Behavior:
last_read_at to current epoch timestamp.Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| Thread not found | "Thread not found." |
| Not a thread member | "Access denied." |
Authentication: Required Visibility: App-only
Archives or unarchives a thread for the authenticated user.
Description: "Archive or unarchive a thread."
Input Schema:
{
"type": "object",
"properties": {
"thread_id": {
"type": "string",
"description": "UUID of the thread"
},
"undo": {
"type": "boolean",
"description": "If true, unarchive (restore to active). Default false."
}
},
"required": ["thread_id"]
}
Output (success):
{
"thread_id": "<uuid>",
"state": "archived",
"message": "Thread archived."
}
Behavior:
undo is true: set thread member state to "active".undo is false (or omitted): set thread member state to "archived".Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| Thread not found | "Thread not found." |
| Not a thread member | "Access denied." |
Authentication: Required Visibility: App-only
Stars or unstars a thread for the authenticated user.
Description: "Star or unstar a thread."
Input Schema:
{
"type": "object",
"properties": {
"thread_id": {
"type": "string",
"description": "UUID of the thread"
},
"undo": {
"type": "boolean",
"description": "If true, unstar (restore to active). Default false."
}
},
"required": ["thread_id"]
}
Output (success):
{
"thread_id": "<uuid>",
"state": "starred",
"message": "Thread starred."
}
Behavior:
undo is true: set thread member state to "active".undo is false (or omitted): set thread member state to "starred".Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| Thread not found | "Thread not found." |
| Not a thread member | "Access denied." |
Authentication: Required Visibility: App-only
Mutes or unmutes a thread for the authenticated user. Muted threads do not generate notifications.
Description: "Mute or unmute a thread. Muted threads do not generate notifications."
Input Schema:
{
"type": "object",
"properties": {
"thread_id": {
"type": "string",
"description": "UUID of the thread"
},
"undo": {
"type": "boolean",
"description": "If true, unmute (restore to active). Default false."
}
},
"required": ["thread_id"]
}
Output (success):
{
"thread_id": "<uuid>",
"state": "muted",
"message": "Thread muted."
}
Behavior:
undo is true: set thread member state to "active".undo is false (or omitted): set thread member state to "muted".Error Cases:
| Condition | Error Message |
|---|---|
| Not authenticated | "Authentication required." |
| Thread not found | "Thread not found." |
| Not a thread member | "Access denied." |
MMP includes an optional MCP App -- a browser-based UI rendered inside MCP-capable AI clients that support the ext/app capability. The MCP App provides a visual inbox with real-time updates and client-side encryption.
The inbox app is registered as an MCP resource using the MCP App extension protocol.
MCP Apps use the ui:// scheme for resource URIs:
ui://mmp-inbox
The app resource uses the standard MCP App MIME type, as defined by @modelcontextprotocol/ext-apps:
application/vnd.mcp.app+html
The server registers the app using the MCP SDK's resource registration mechanism. The app HTML is a single-file bundle (produced by Vite with the vite-plugin-singlefile plugin) containing all HTML, CSS, and JavaScript in one document.
When msg/open_inbox is called, the server returns the app resource. The hosting MCP client renders it in an embedded browser context (iframe or webview).
The MCP App communicates with the MCP server through a set of APIs provided by the @modelcontextprotocol/ext-apps client library:
Invokes an MCP tool on the server. The app uses this to call any MMP tool (e.g., msg/inbox, msg/send, msg/mark_read).
const result = await app.callServerTool("msg/inbox", { limit: 50 });
Updates the AI model's context with information from the app. Used to notify the AI when new messages arrive or when the user takes an action in the UI.
app.updateModelContext({
type: "text",
text: "New message from @alice: 'Hey, are you free tomorrow?'"
});
Sends a message to the AI assistant for processing. Used for delegating tasks (e.g., "draft a reply to Alice").
app.sendMessage("Draft a reply to Alice saying I'm free tomorrow afternoon.");
The app receives initial data via the ontoolresult event, which fires when the app is first loaded with the result of the tool that opened it.
The MCP App polls for new messages at a fixed interval:
msg/inbox or msg/digest via app.callServerTool()Polling is used because MCP does not currently provide a server-push or subscription mechanism for apps.
The MCP App MAY implement true end-to-end encryption using client-side key pairs:
const keyPair = nacl.box.keyPair();
// Store in localStorage
localStorage.setItem("mmp_private_key", encodeBase64(keyPair.secretKey));
// Register public key with server
await app.callServerTool("msg/set_profile", {
client_public_key: encodeBase64(keyPair.publicKey)
});
const nonce = nacl.randomBytes(24);
const ciphertext = nacl.box(
decodeUTF8(messageBody),
nonce,
decodeBase64(recipientPublicKey),
decodeBase64(senderPrivateKey)
);
await app.callServerTool("msg/send", {
to: recipientHandle,
encrypted_payload: {
ciphertext: encodeBase64(ciphertext),
nonce: encodeBase64(nonce),
sender_public_key: encodeBase64(senderPublicKey)
}
});
const plaintext = nacl.box.open(
decodeBase64(message.encrypted_payload.ciphertext),
decodeBase64(message.encrypted_payload.nonce),
decodeBase64(message.encrypted_payload.sender_public_key),
decodeBase64(localPrivateKey)
);
localStorage under a well-known key (e.g., mmp_private_key).In addition to the MCP endpoint, the server exposes the following HTTP endpoints:
Purpose: Landing page with setup instructions.
Response: HTML page with server name, version, and instructions for connecting an MCP client.
Content-Type: text/html
Purpose: Invite landing page for users who received an invite link.
Parameters:
:code -- the invite code from the URLResponse: HTML page displaying the invite status:
Content-Type: text/html
Purpose: Server health check and status.
Response:
{
"status": "ok",
"version": "1.0.0",
"users": 42,
"uptime": 3600.5
}
Content-Type: application/json
| Field | Type | Description |
|---|---|---|
status |
string | Always "ok" if the server is running |
version |
string | Server version (semver) |
users |
number | Total registered user count |
uptime |
number | Server uptime in seconds (floating point) |
Purpose: MCP Streamable HTTP transport endpoint.
Authentication: Token passed as ?token=<value> query parameter.
Session Management:
mcp-session-id response header.mcp-session-id in the request header to reuse the session.Purpose: Server-Sent Events (SSE) stream for an existing MCP session.
Headers Required: mcp-session-id
Response: SSE event stream.
Purpose: Close an MCP session.
Headers Required: mcp-session-id
Implementations SHOULD apply rate limiting to prevent abuse:
| Endpoint/Tool | Recommended Limit |
|---|---|
msg/register |
5 registrations per IP per hour |
msg/recover |
5 attempts per handle per hour |
msg/send |
60 messages per user per minute |
msg/search_users |
30 searches per user per minute |
msg/invite |
10 invites per user per day |
Rate limiting is RECOMMENDED but not required for protocol compliance.
msg/lookup MUST return the same error ("User not found.") for non-existent users and privacy-restricted users to prevent handle enumeration.msg/search_users MUST NOT return users with private privacy to non-contacts.msg/register unavoidably reveals whether a handle is taken (necessary for the registration flow).In server-assisted encryption mode, the server stores users' private keys. This is an inherent limitation of the hybrid model:
crypto.randomBytes() or equivalent CSPRNG.log2(32^12) = 60).The NaCl box construction provides:
Nonces MUST be 24 bytes and MUST be unique per message. The reference implementation uses nacl.randomBytes(24) which provides negligible collision probability.
Regardless of encryption mode, the server always has access to message envelope metadata:
In E2E mode, the server does NOT have access to:
In server-assisted mode, the server has transient access to plaintext during encryption/decryption operations, but plaintext is never stored in the database.
| Level | Profile in search | Profile in lookup | Can receive messages from |
|---|---|---|---|
public |
Yes | Yes | Anyone |
contacts_only |
Yes (limited) | Contacts only | Contacts only |
private |
No | Contacts only | Contacts only |
This specification does not mandate specific data retention policies. Implementations SHOULD:
Implementations SHOULD provide a mechanism for users to:
Account deletion is not specified as a tool in v0.1 but is RECOMMENDED for implementations.
Federation allows MMP servers to interoperate, enabling users on different servers to message each other.
Federated handles extend the local handle format with a server identifier:
@user@server.example.com
@alice (equivalent to @alice@localhost)@alice@other-server.comWhen a server receives a message to a remote handle, it must look up and communicate with the remote server.
Each MMP server SHOULD publish a discovery document at:
GET /.well-known/mmp.json
Response:
{
"version": "0.1.0",
"server": "mmp-reference",
"mcp_endpoint": "/mcp",
"public_key": "<server-level signing key, base64>",
"capabilities": ["messaging", "e2e", "invites"],
"federation": {
"enabled": true,
"inbound": true,
"outbound": true
}
}
| Field | Description |
|---|---|
version |
MMP protocol version |
server |
Server implementation name |
mcp_endpoint |
Path to the MCP transport endpoint |
public_key |
Server signing key for federation message integrity |
capabilities |
List of supported features |
federation |
Federation configuration |
When Server A sends a message to a user on Server B:
@user@serverB.com by fetching https://serverB.com/.well-known/mmp.json.Federation requires server-to-server authentication:
Federation uses a Trust On First Use (TOFU) model:
Federation is not implemented in v0.1 and is described here as a design direction for future versions.
The reference implementation uses SQLite with the following schema. Compliant implementations MAY use any storage backend that satisfies the data model.
CREATE TABLE users (
id TEXT PRIMARY KEY,
handle TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
bio TEXT NOT NULL DEFAULT '',
privacy TEXT NOT NULL DEFAULT 'public',
status TEXT NOT NULL DEFAULT '',
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
client_public_key TEXT,
token_hash TEXT NOT NULL,
recovery_code_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE handle_history (
old_handle TEXT NOT NULL,
new_handle TEXT NOT NULL,
redirects_until INTEGER NOT NULL
);
CREATE TABLE threads (
id TEXT PRIMARY KEY,
subject TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE thread_members (
thread_id TEXT NOT NULL,
user_id TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'active',
last_read_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (thread_id, user_id)
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL,
from_user_id TEXT NOT NULL,
to_user_id TEXT NOT NULL,
reply_to TEXT,
priority TEXT NOT NULL DEFAULT 'normal',
ciphertext TEXT NOT NULL,
nonce TEXT NOT NULL,
sender_pub_key TEXT NOT NULL,
encryption_mode TEXT NOT NULL DEFAULT 'e2e',
created_at INTEGER NOT NULL
);
CREATE TABLE contacts (
user_id TEXT NOT NULL,
contact_id TEXT NOT NULL,
nickname TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, contact_id)
);
CREATE TABLE blocks (
user_id TEXT NOT NULL,
blocked_id TEXT NOT NULL,
PRIMARY KEY (user_id, blocked_id)
);
CREATE TABLE invites (
code TEXT PRIMARY KEY,
created_by TEXT NOT NULL,
pending_message TEXT,
created_at INTEGER NOT NULL,
claimed_by TEXT,
claimed_at INTEGER
);
-- Recommended indexes
CREATE INDEX idx_messages_thread ON messages(thread_id);
CREATE INDEX idx_messages_to ON messages(to_user_id);
CREATE INDEX idx_thread_members_user ON thread_members(user_id);
CREATE INDEX idx_users_handle ON users(handle);
| Artifact | Format | Length | Example |
|---|---|---|---|
| Token | sk_ + 32 random bytes as hex |
67 chars | sk_a1b2c3... (67 total) |
| Token hash | SHA-256 hex | 64 chars | e3b0c442... (64 hex digits) |
| Recovery code | XXXX-XXXX-XXXX (base32-like) |
14 chars | AB3K-9TW2-HNPQ |
| Public key | Base64-encoded 32 bytes | 44 chars | dGVzdC1wdWJsaWMta2V5LTMyYnl0ZXMh |
| Private key | Base64-encoded 32 bytes | 44 chars | (same format as public key) |
| User ID | UUID v4 | 36 chars | 550e8400-e29b-41d4-a716-446655440000 |
| Thread ID | UUID v4 | 36 chars | (same format as User ID) |
| Message ID | UUID v4 | 36 chars | (same format as User ID) |
| Invite code | Random string | >= 16 chars | (implementation-defined) |
The recovery code uses a 32-character alphabet that excludes visually ambiguous characters:
ABCDEFGHJKLMNPQRSTUVWXYZ23456789
Excluded characters and rationale:
0 (zero) -- confused with O1 (one) -- confused with I or lI (uppercase i) -- confused with 1 or lO (uppercase o) -- confused with 0To connect to an MMP server, an MCP client configuration entry looks like:
{
"mcpServers": {
"mmp": {
"url": "https://mmp.example.com/mcp?token=sk_<your_token>"
}
}
}
For unauthenticated access (registration/recovery only):
{
"mcpServers": {
"mmp": {
"url": "https://mmp.example.com/mcp"
}
}
}
End of MMP Specification v0.1.0