Affinity Design
Agency Guide

Building Custom MCP Clients

Integrate your own applications with the platform's MCP server

Overview

Any application that speaks MCP can connect to your platform. This guide covers the protocol details, authentication options, and implementation patterns for building custom MCP clients.

Protocol Basics

Your platform implements MCP spec 2025-03-26 over two transports:

  • Endpoint: POST /mcp/http
  • Body limit: 20 MB (supports base64 image uploads)
  • CORS: Enabled (Access-Control-Allow-Origin: *)
  • Session handling: Stateful via Mcp-Session-Id header, or stateless for single calls

Request flow:

POST /mcp/http
Headers:
  X-API-Key: afk_YOUR_KEY
  Content-Type: application/json
  (optional) Mcp-Session-Id: SESSION_UUID

Body (JSON-RPC 2.0):
  {
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/list",
    "params": {}
  }

Response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      { "name": "get_time", "description": "...", "inputSchema": {...} }
    ]
  }
}

SSE Transport

  • Endpoint: GET /mcp/sse (opens stream)
  • Messages: POST /mcp/messages?sessionId=XXX
  • Best for: Older MCP clients that don't support HTTP transport

SSE flow:

  1. Client opens GET /mcp/sse with auth headers
  2. Server responds with SSE stream and sends endpoint event
  3. Client POSTs JSON-RPC to the endpoint URL
  4. Server responds via SSE message events

Authentication Methods

Method 1: API Key (Simplest)

Add the X-API-Key header with a client-scoped key:

curl -X POST https://api.v2.affinitydesign.ca/mcp/http \
  -H "X-API-Key: afk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"my-app","version":"1.0"}}}'

Pros: Simple, no token management, works everywhere
Cons: Key must be stored securely; no automatic expiry

Method 2: OAuth 2.1 (For User-Facing Apps)

If you're building an app that users log into:

  1. Register your client:

    POST /oauth/register
    {
      "client_name": "My Custom Client",
      "redirect_uris": ["https://myapp.com/callback"],
      "token_endpoint_auth_method": "none"
    }
    
  2. Initiate authorization:

    GET /oauth/authorize?response_type=code&client_id=oauth_xxx&
      redirect_uri=https://myapp.com/callback&
      code_challenge=BASE64URL(SHA256(verifier))&
      code_challenge_method=S256&
      state=random_state
    
  3. User authenticates: They paste their afk_... API key in the form

  4. Exchange code for tokens:

    POST /oauth/token
    {
      "grant_type": "authorization_code",
      "code": "auth_code_from_callback",
      "redirect_uri": "https://myapp.com/callback",
      "code_verifier": "original_pkce_verifier",
      "client_id": "oauth_xxx"
    }
    
  5. Use the access token:

    curl -X POST /mcp/http \
      -H "Authorization: Bearer ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{...}'
    

Method 3: Admin JWT (Internal Tools)

For internal dashboards or admin scripts:

curl -X POST "https://api.v2.affinitydesign.ca/mcp/http?clientId=CLIENT_ID" \
  -H "Authorization: Bearer ADMIN_JWT" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Pass ?clientId=... to scope operations to a specific client.

JSON-RPC Methods

Your platform supports these methods:

MethodDescription
initializeCapability handshake. Must be first call on a session.
notifications/initializedClient ack (no response)
tools/listList all available tools
tools/callExecute a tool
resources/listList browsable resources
resources/readRead a resource by URI
pingKeep-alive probe

Initialize Example

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-03-26",
    "clientInfo": {
      "name": "my-custom-client",
      "version": "1.0.0"
    },
    "capabilities": {
      "tools": {},
      "resources": {}
    }
  }
}

Response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-03-26",
    "capabilities": {
      "tools": { "listChanged": false },
      "resources": { "listChanged": false, "subscribe": false }
    },
    "serverInfo": {
      "name": "af-gemini-connect",
      "version": "1.0.0"
    }
  }
}

Tool Call Example

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "get_client_info",
    "arguments": {}
  }
}

Response:

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"clientId\":\"acme\",\"businessName\":\"Acme Corp\",...}"
      }
    ],
    "isError": false
  }
}

Client Libraries

You don't need a library — MCP is just JSON-RPC over HTTP. But if you prefer:

  • TypeScript: @modelcontextprotocol/sdk — official SDK
  • Python: mcp package on PyPI
  • Go: github.com/mark3labs/mcp-go

Asset Uploads

For large binary data (images, videos), don't base64-encode in tool arguments. Instead:

Option A: Direct Upload Endpoint

POST /mcp/assets/direct-upload
Headers:
  X-API-Key: afk_YOUR_KEY
  Content-Type: application/json

Body:
  {
    "filename": "hero-image.png",
    "base64": "iVBORw0KGgo...",
    "mimeType": "image/png",
    "altText": "Homepage hero"
  }

Response:

{
  "data": {
    "assetRef": "asset_abc123",
    "canonicalFilename": "hero-image.png",
    "mimeType": "image/png",
    "sizeBytes": 45231
  }
}

Then reference asset_abc123 in tool calls like wordpress_upload_image or github_upload_image.

Call get_uploader_link to get a magic URL that opens the asset uploader pre-authenticated. Give this to users; they drop a file and get an assetRef back.

Error Handling

HTTP StatusMeaning
200Success (HTTP transport)
202Accepted, no body (notifications)
204Session terminated (DELETE)
400Invalid JSON-RPC request
401Missing or invalid auth
404Session not found (SSE)
500Tool execution error (check result.content for details)

JSON-RPC errors use standard codes:

CodeMeaning
-32600Invalid request
-32601Method not found
-32602Invalid params
-32700Parse error

Session Management

HTTP sessions are evicted after 2 hours of inactivity. You can:

  • Reuse the same Mcp-Session-Id across requests
  • Skip initialize for stateless calls (the server creates a transient session)
  • Explicitly terminate with DELETE /mcp/http + Mcp-Session-Id header

Testing Your Client

Use these curl commands to verify connectivity:

# Initialize
export KEY="afk_YOUR_KEY"
export URL="https://api.v2.affinitydesign.ca/mcp/http"

INIT=$(curl -s -X POST "$URL" \
  -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"test","version":"1.0"}}}')

SESSION=$(echo "$INIT" | grep -o '"Mcp-Session-Id": "[^"]*"' | cut -d'"' -f4)

# List tools
curl -s -X POST "$URL" \
  -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" \
  -H "Mcp-Session-Id: $SESSION" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq '.result.tools | map(.name)'

# Get client info
curl -s -X POST "$URL" \
  -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" \
  -H "Mcp-Session-Id: $SESSION" \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_client_info","arguments":{}}}' | jq '.result.content[0].text | fromjson'

Security Checklist

Before deploying a custom client:

  • Store API keys in environment variables, never in source code
  • Use HTTPS only (enforced by server for OAuth redirects)
  • Validate the origin in OAuth flows
  • Handle token expiry gracefully (refresh or re-auth)
  • Log MCP operations for audit purposes
  • Restrict plugin access via key-level permissions
  • Rotate keys regularly (every 90 days)

On this page