SMCP Wire Protocol (v3.0)
This is the normative specification for the SMCP transport protocol, sufficient
to implement an interoperable client or server in any language. The Python
reference is smcp_core.py; a Rust reference lives in malgra-tunnel
(src/protocol.rs). Conformance test vectors are in tests/conformance_vectors.json.
Protocol version: 3.0. Peers exchange protocol_version in the handshake; a
mismatched major version is rejected (see §6).
1. Message envelope
Every message is a JSON object with exactly these fields:
{
"id": "<uuid4 string>",
"type": "handshake|auth|capability_discovery|tool_invoke|tool_response|error|heartbeat",
"timestamp": <float, unix seconds>,
"payload": { ... },
"encrypted": <bool>,
"signature": "<hex string | null>"
}
id— unique per message (UUIDv4). Also the replay key (§5).type— one of the MessageType values above.timestamp— unix seconds as a JSON number (see §4 for its canonical string form).payload— object; whenencryptedis true it is{"encrypted_data": "<token>"}(§3).encrypted— whetherpayloadcarries ciphertext.signature— lowercase hex HMAC over the canonical signing input (§4), or null.
2. Key schedule (v3)
All symmetric keys derive from a per-deployment secret_key and kdf_salt,
shared by peers in the same trust domain:
salt = kdf_salt bytes, or the constant b"malgra-tunnel-v3" if unset
master = PBKDF2-HMAC-SHA256(secret_key, salt, iterations=600000, dklen=32)
cipher_key= HKDF-SHA256(master, salt=None, info=b"malgra-tunnel-v3-cipher", L=32)
mac_key = HKDF-SHA256(master, salt=None, info=b"malgra-tunnel-v3-mac", L=32)
The raw secret_key is never used directly as a key. cipher_key feeds the
payload cipher (§3); mac_key keys the message signature (§4).
3. Payload encryption
Payload encryption uses Fernet (AES-128-CBC + HMAC-SHA256, the standard
cryptography Fernet construction) with the key base64url(cipher_key):
encrypted_data = Fernet(base64url(cipher_key)).encrypt(utf8(json(payload)))
Decryption MAY enforce a TTL (the receiver rejects Fernet tokens older than the
replay window, §5). When encrypted is true and payload is non-empty, payload
MUST be exactly {"encrypted_data": "<fernet token>"}. The encrypted flag MUST
agree with the presence of encrypted_data (a mismatch is rejected).
4. Message signature
The signature is hex(HMAC-SHA256(mac_key, signing_input)) where the signing
input is the concatenation, in order, of:
signing_input = id ++ type ++ ts_str ++ canonical(payload)
id— the message id string.type— the message type string value.ts_str— the timestamp rendered so integer seconds become"<n>.0"(e.g.1700000000→"1700000000.0"); non-integers use the shortest round-tripping decimal. This matches the Rust f64 serialization.canonical(payload)— JSON of the payload with sorted keys and compact separators (","and":"), matching serde_json’s compact form.
Signatures are compared in constant time. Note the encrypted boolean is NOT
part of the signing input; receivers instead require it to agree with the
payload shape (§3).
Reference vector: HMAC with mac_key derived from
secret=interop-secret-value-32-bytes-xx, jwt=jwt, salt=interop-salt, over a
message id="fixedid", type="auth", timestamp=1700000000.0, payload={"x":1}
yields signature
661c3041e7cc52690927113ca9bc39c08899613f94e7d770addecd349374f8ca.
5. Replay & freshness
Receivers keep a window (default 300s). A message is accepted only if
|now - timestamp| <= window and its id has not been seen before within the
window; ids are recorded and pruned as they age. Encrypted payloads additionally
carry a Fernet TTL as defence in depth.
6. Session state machine
HANDSHAKE -> mutual auth: client sends {client_id, protocol_version, nonce};
server verifies major version, echoes client_nonce in a signed reply.
AUTH -> client sends {api_key[, client_id]}; server constant-time compares,
mints a session JWT (§7) with least-privilege scopes, returns {token}.
CAPABILITY_DISCOVERY -> client sends {token}; server returns advertised capabilities.
TOOL_INVOKE -> client sends {token, tool_name, parameters}; server checks scope,
validates parameters against the capability schema, dispatches,
returns {tool_name, result, status}.
HEARTBEAT -> liveness.
ERROR -> {request_id, error, timestamp}; a signed message like any other.
Version negotiation: the handshake compares the major component of
protocol_version. A different major (e.g. 4.x) is refused with an ERROR.
7. Session tokens (JWT)
On AUTH the server mints a JWT bound to iss="smcp", aud="smcp", with exp
(1h) and iat. Two algorithms:
- HS256 (default): signed and verified with the shared
jwt_secret. - RS256 (recommended for multi-party): the server holds the private key and
mints; clients verify with the public key and cannot forge.
exp/iatare required;iss/audare enforced.
Authorization scopes: tool:<name> authorizes a specific tool; tool_invoke
authorizes any tool; discovery authorizes capability discovery. Capabilities
with auth_required=false may be called without a token.
8. Transport
Messages are framed one-per-WebSocket-message (JSON text). TLS (wss://) is
required to non-loopback hosts unless an explicit insecure-transit opt-in is set
for a loopback target. Servers enforce a max message size and per-IP rate limits.
9. Interoperability notes
SMCP is a standalone secure agent protocol, not JSON-RPC/MCP on the wire. It
interoperates with MCP through the bridge (smcp_mcp_bridge.py), which adapts
SMCP tool invocations to external MCP servers over their native transport. To
implement a conformant peer, reproduce §2–§7 exactly and check against the
vectors in tests/conformance_vectors.json.