Skip to main content

Overview

The Handshake Architecture enables MCP tools to request user interaction for actions that require explicit approval:
  • Signature Requests — EIP-712 typed data signing (Hyperliquid, dYdX)
  • Transaction Proposals — Direct on-chain transactions (Uniswap, NFT mints)
  • OAuth Requests — External service authentication (Discord, Twitter)
Why “Handshake”? The tool proposes an action, the user approves it, and the result is returned to the tool — a secure handshake between AI and human.

When to Use Handshakes

Action TypeUse CaseExample
signature_requestPlatforms with proxy walletsPlace order on Hyperliquid
transaction_proposalDirect on-chain actionsSwap on Uniswap, mint NFT
auth_requiredExternal service authConnect Discord bot
Prefer signatures over transactions when possible. Signatures are gasless, don’t require network switching, and work seamlessly with Privy embedded wallets.

Quick Start (TypeScript)

1. Install the SDK

pnpm add @ctxprotocol/sdk

2. Import Handshake Helpers

import {
  createSignatureRequest,
  wrapHandshakeResponse,
} from "@ctxprotocol/sdk";

3. Return a Handshake from Your Tool

// In your MCP tool handler
async function handlePlaceOrder(args) {
  const { coin, isBuy, size, price } = args;

  // Build EIP-712 typed data for the order
  const signatureRequest = createSignatureRequest({
    domain: {
      name: "HyperliquidSignTransaction",
      version: "1",
      chainId: 42161, // Arbitrum (informational only)
    },
    types: {
      Order: [
        { name: "asset", type: "uint32" },
        { name: "isBuy", type: "bool" },
        { name: "limitPx", type: "uint64" },
        { name: "sz", type: "uint64" },
      ],
    },
    primaryType: "Order",
    message: {
      asset: assetIndex,
      isBuy,
      limitPx: Math.round(price * 1e8),
      sz: Math.round(size * 1e8),
    },
    meta: {
      description: `${isBuy ? "Buy" : "Sell"} ${size} ${coin} at $${price}`,
      protocol: "Hyperliquid",
      action: isBuy ? "Buy Order" : "Sell Order",
      tokenSymbol: coin,
      tokenAmount: size.toString(),
    },
  });

  // Wrap in MCP response format
  return wrapHandshakeResponse(signatureRequest);
}

Quick Start (Python)

1. Install the SDK

pip install ctxprotocol

2. Import Handshake Helpers

from ctxprotocol import (
    create_signature_request,
    wrap_handshake_response,
)

3. Return a Handshake from Your Tool

def handle_place_order(args):
    coin = args["coin"]
    is_buy = args["isBuy"]
    size = args["size"]
    price = args["price"]

    signature_request = create_signature_request(
        domain={
            "name": "HyperliquidSignTransaction",
            "version": "1",
            "chainId": 42161,
        },
        types={
            "Order": [
                {"name": "asset", "type": "uint32"},
                {"name": "isBuy", "type": "bool"},
                {"name": "limitPx", "type": "uint64"},
                {"name": "sz", "type": "uint64"},
            ],
        },
        primary_type="Order",
        message={
            "asset": asset_index,
            "isBuy": is_buy,
            "limitPx": int(price * 1e8),
            "sz": int(size * 1e8),
        },
        meta={
            "description": f"{'Buy' if is_buy else 'Sell'} {size} {coin} at ${price}",
            "protocol": "Hyperliquid",
            "action": "Buy Order" if is_buy else "Sell Order",
            "token_symbol": coin,
            "token_amount": str(size),
        },
    )

    return wrap_handshake_response(signature_request)

How It Works

┌─────────────────────────────────────────────────────────────────────┐
│  1. User: "Buy 0.1 ETH at $3000 on Hyperliquid"                    │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  2. AI Agent calls your place_order tool                           │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  3. Your tool returns SignatureRequest in _meta.handshakeAction    │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  4. Context Platform intercepts, shows approval card to user       │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  5. User clicks "Sign" → wallet signs EIP-712 data (gasless!)      │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  6. Signature returned to your callback tool for order submission  │
└─────────────────────────────────────────────────────────────────────┘

Action Types

Signature Request (EIP-712)

Best for platforms with proxy wallets that accept EIP-712 signatures directly (Hyperliquid, dYdX).
type SignatureRequest = {
  _action: "signature_request";
  domain: {
    name: string;      // e.g., "Hyperliquid"
    version: string;   // e.g., "1"
    chainId: number;   // Informational only
    verifyingContract?: `0x${string}`;
  };
  types: Record<string, Array<{ name: string; type: string }>>;
  primaryType: string;
  message: Record<string, unknown>;
  meta?: {
    description: string;
    protocol?: string;
    action?: string;
    tokenSymbol?: string;
    tokenAmount?: string;
    warningLevel?: "info" | "caution" | "danger";
  };
  callbackToolName?: string;  // Tool to call with signature result
};
Gasless & Chain-Agnostic: EIP-712 signatures don’t require gas or network switching. The chainId in the domain is purely informational — signing works from any network.

Transaction Proposal

For direct on-chain actions (Uniswap swaps, NFT mints, etc.).
type TransactionProposal = {
  _action: "transaction_proposal";
  chainId: number;        // Required: target chain
  to: `0x${string}`;      // Contract address
  data: `0x${string}`;    // Encoded calldata
  value?: string;         // Wei to send (default "0")
  meta?: {
    description: string;
    protocol?: string;
    estimatedGas?: string;
    explorerUrl?: string;
    warningLevel?: "info" | "caution" | "danger";
  };
};
Network Switching: Transaction proposals may require the user to switch networks. Use signatures when possible for better UX.

Auth Required (OAuth)

For external service authentication.
type AuthRequired = {
  _action: "auth_required";
  provider: string;       // e.g., "discord", "twitter"
  authUrl: string;        // Your OAuth endpoint (MUST be HTTPS)
  meta?: {
    displayName?: string;
    scopes?: string[];
    description?: string;
    iconUrl?: string;
    expiresIn?: string;
  };
};
Security: The authUrl domain must match your tool’s endpoint domain. Context appends the user’s DID as ?context_did=... for you to associate the auth with the user.

Response Format

Handshake actions must be placed in structuredContent._meta.handshakeAction:
return {
  content: [
    { type: "text", text: "Handshake required: signature request" }
  ],
  structuredContent: {
    _meta: {
      handshakeAction: signatureRequest,  // 👈 Required location
    },
    status: "handshake_required",
    message: "Please approve the order",
  },
};
Why _meta? The MCP SDK strips unknown top-level fields. Placing the action in _meta ensures it’s preserved and detected by the Context platform.

Helper Functions

TypeScript SDK

import {
  // Creators
  createSignatureRequest,
  createTransactionProposal,
  createAuthRequired,
  wrapHandshakeResponse,
  
  // Type guards
  isHandshakeAction,
  isSignatureRequest,
  isTransactionProposal,
  isAuthRequired,
} from "@ctxprotocol/sdk";

Python SDK

from ctxprotocol import (
    # Creators
    create_signature_request,
    create_transaction_proposal,
    create_auth_required,
    wrap_handshake_response,
    
    # Type guards
    is_handshake_action,
    is_signature_request,
    is_transaction_proposal,
    is_auth_required,
)

Complete Example: Hyperliquid Order Tool

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
  createSignatureRequest,
  wrapHandshakeResponse,
  createContextMiddleware,
} from "@ctxprotocol/sdk";

const TOOLS = [
  {
    name: "place_order",
    description: "Place a perpetual order on Hyperliquid",
    inputSchema: {
      type: "object",
      properties: {
        coin: { type: "string", description: "e.g., ETH, BTC" },
        isBuy: { type: "boolean" },
        size: { type: "number" },
        price: { type: "number" },
      },
      required: ["coin", "isBuy", "size", "price"],
    },
    outputSchema: {
      type: "object",
      properties: {
        status: { type: "string" },
        message: { type: "string" },
        _meta: { type: "object" },
      },
    },
    _meta: {
      contextRequirements: ["hyperliquid"],  // Inject user's portfolio
    },
  },
];

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "place_order") {
    const { coin, isBuy, size, price } = request.params.arguments;
    
    // Get asset index from market metadata
    const assetIndex = await getAssetIndex(coin);
    
    const signatureRequest = createSignatureRequest({
      domain: {
        name: "HyperliquidSignTransaction",
        version: "1",
        chainId: 42161,
      },
      types: {
        Order: [
          { name: "asset", type: "uint32" },
          { name: "isBuy", type: "bool" },
          { name: "limitPx", type: "uint64" },
          { name: "sz", type: "uint64" },
          { name: "reduceOnly", type: "bool" },
        ],
      },
      primaryType: "Order",
      message: {
        asset: assetIndex,
        isBuy,
        limitPx: Math.round(price * 1e8),
        sz: Math.round(size * 1e8),
        reduceOnly: false,
      },
      meta: {
        description: `${isBuy ? "Buy" : "Sell"} ${size} ${coin} at $${price}`,
        protocol: "Hyperliquid",
        action: isBuy ? "Long" : "Short",
        tokenSymbol: coin,
        tokenAmount: size.toString(),
        warningLevel: size * price > 10000 ? "caution" : "info",
      },
      callbackToolName: "submit_signed_order",
    });
    
    return wrapHandshakeResponse(signatureRequest);
  }
});

Complete Example: Discord Notifications (OAuth)

This example shows how to build a tool that posts messages to a user’s Discord channel, requiring OAuth authentication.

Tool Definition

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
  createAuthRequired,
  wrapHandshakeResponse,
  createContextMiddleware,
} from "@ctxprotocol/sdk";

// In-memory token store (use Redis/DB in production)
const userTokens = new Map<string, { accessToken: string; channelId: string }>();

const TOOLS = [
  {
    name: "post_to_discord",
    description: "Post a message to your connected Discord channel",
    inputSchema: {
      type: "object",
      properties: {
        message: { type: "string", description: "Message to post" },
      },
      required: ["message"],
    },
    outputSchema: {
      type: "object",
      properties: {
        status: { type: "string" },
        messageId: { type: "string" },
        channelName: { type: "string" },
      },
    },
  },
];

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "post_to_discord") {
    const { message } = request.params.arguments;
    
    // Get user's Context DID from the authenticated request
    const contextDid = request.params._context?.userDid;
    if (!contextDid) {
      return errorResult("Authentication required");
    }
    
    // Check if user has connected Discord
    const tokens = userTokens.get(contextDid);
    
    if (!tokens) {
      // User hasn't authenticated - return OAuth handshake
      const authRequired = createAuthRequired({
        provider: "discord",
        authUrl: "https://your-mcp-server.com/auth/discord",
        meta: {
          displayName: "Discord",
          description: "Connect your Discord account to post messages",
          scopes: ["identify", "guilds", "messages.write"],
          iconUrl: "https://your-mcp-server.com/icons/discord.png",
          expiresIn: "30 days",
        },
      });
      
      return wrapHandshakeResponse(authRequired);
    }
    
    // User is authenticated - post the message
    const response = await fetch(
      `https://discord.com/api/v10/channels/${tokens.channelId}/messages`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${tokens.accessToken}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ content: message }),
      }
    );
    
    const data = await response.json();
    
    return {
      content: [{ type: "text", text: `Posted to Discord: ${message}` }],
      structuredContent: {
        status: "success",
        messageId: data.id,
        channelName: data.channel_id,
      },
    };
  }
});

OAuth Endpoint (Your Backend)

You must host OAuth endpoints on your MCP server’s domain:
import express from "express";

const app = express();

// Step 1: Redirect to Discord OAuth
app.get("/auth/discord", (req, res) => {
  // Context appends the user's DID automatically
  const contextDid = req.query.context_did as string;
  
  if (!contextDid) {
    return res.status(400).send("Missing context_did");
  }
  
  // Store DID in state for callback
  const state = Buffer.from(JSON.stringify({ contextDid })).toString("base64");
  
  const discordAuthUrl = new URL("https://discord.com/api/oauth2/authorize");
  discordAuthUrl.searchParams.set("client_id", process.env.DISCORD_CLIENT_ID);
  discordAuthUrl.searchParams.set("redirect_uri", "https://your-mcp-server.com/auth/discord/callback");
  discordAuthUrl.searchParams.set("response_type", "code");
  discordAuthUrl.searchParams.set("scope", "identify guilds messages.write");
  discordAuthUrl.searchParams.set("state", state);
  
  res.redirect(discordAuthUrl.toString());
});

// Step 2: Handle Discord callback
app.get("/auth/discord/callback", async (req, res) => {
  const { code, state } = req.query;
  
  // Decode the Context DID from state
  const { contextDid } = JSON.parse(Buffer.from(state as string, "base64").toString());
  
  // Exchange code for tokens
  const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: process.env.DISCORD_CLIENT_ID,
      client_secret: process.env.DISCORD_CLIENT_SECRET,
      grant_type: "authorization_code",
      code: code as string,
      redirect_uri: "https://your-mcp-server.com/auth/discord/callback",
    }),
  });
  
  const tokens = await tokenResponse.json();
  
  // Store tokens mapped to Context DID
  // In production: encrypt and store in database
  userTokens.set(contextDid, {
    accessToken: tokens.access_token,
    channelId: "default-channel-id", // Or let user select
  });
  
  // Notify the Context popup that auth succeeded
  res.send(`
    <html>
      <body>
        <script>
          window.opener.postMessage({
            type: "context_oauth_complete",
            success: true,
          }, "*");
          window.close();
        </script>
        <p>Connected! You can close this window.</p>
      </body>
    </html>
  `);
});

How It Works

┌─────────────────────────────────────────────────────────────────────┐
│  1. User: "Post 'Hello!' to my Discord"                             │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  2. Tool checks: Does this user have Discord tokens?                │
│     → NO: Return auth_required handshake                            │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  3. Context shows "Connect to Discord" card                         │
│     User clicks → Popup opens to your /auth/discord                 │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  4. User authorizes in Discord → Callback stores tokens             │
│     Popup sends postMessage → Context detects success               │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│  5. Tool is called again (auto-retry)                               │
│     → YES, user has tokens: Post message to Discord                 │
└─────────────────────────────────────────────────────────────────────┘
Token Security: Never expose user OAuth tokens. Store them encrypted on your backend, mapped to the user’s Context DID. Your tool receives the DID on every request, allowing you to look up the stored tokens.
Domain Requirement: Your authUrl must be on the same domain as your MCP endpoint. This prevents phishing attacks where a malicious tool redirects to a fake auth page.

UI Preview

When a handshake is detected, the Context app shows an approval card: The card displays:
  • Protocol name and action type
  • Human-readable description
  • Token amount (if applicable)
  • Gas status (gasless for signatures)
  • Sign/Reject buttons

Best Practices

The meta field powers the approval UI. Include:
  • description: What the user is approving
  • protocol: Which platform (builds trust)
  • tokenAmount + tokenSymbol: For financial actions
  • warningLevel: “caution” for large amounts, “danger” for irreversible actions
EIP-712 signatures are:
  • Gasless (no ETH needed)
  • Chain-agnostic (no network switching)
  • Faster (no block confirmation)
Many DeFi platforms (Hyperliquid, dYdX) use proxy wallets that accept signatures directly.
Don’t ask users to sign invalid orders. Validate:
  • Asset exists on the platform
  • Price is reasonable (not 100x above/below market)
  • Size is within platform limits
  • User has sufficient balance (if portfolio context available)
For flows that need the signature result:
callbackToolName: "submit_signed_order"
The platform will call this tool with { signature, originalParams } after signing.

Security Considerations

Domain Validation: For auth_required, the authUrl domain must match your tool’s endpoint domain. This prevents phishing attacks where a malicious tool redirects users to fake auth pages.
No Private Keys: The user’s wallet signs the data client-side. Your tool never sees private keys — only the resulting signature.

Platform Philosophy

The Context marketplace supports authentication flows that are generalizable — where solving the problem once enables infinite integrations.
Flow TypeSupportRationale
EIP-712 Signatures✅ FullUniversal standard. Hyperliquid, dYdX, and many DeFi protocols accept signatures directly. One implementation, infinite platforms.
OAuth 2.0✅ FullIndustry standard. Discord, Twitter, and thousands of services use OAuth. Standardized token exchange.
API Key Auth❌ RedirectPlatform-specific. Each API has different auth schemes, key derivation, HMAC formats, and storage requirements. Not scalable.
Why no API key support? Platforms like Polymarket require API keys derived from wallet signatures on their site, with custom HMAC authentication for every request. This cannot be delegated through the handshake architecture.If your tool requires API keys, you have two options:
  1. Provide read-only features with a redirect link for write actions
  2. Accept user-provided keys as input parameters (user manages their own credentials)
Want API key support? We’re monitoring demand. If you’re building a tool that needs this pattern, open an issue describing your use case.