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:
HTTP Transport (Recommended)
- Endpoint:
POST /mcp/http - Body limit: 20 MB (supports base64 image uploads)
- CORS: Enabled (
Access-Control-Allow-Origin: *) - Session handling: Stateful via
Mcp-Session-Idheader, 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:
- Client opens
GET /mcp/ssewith auth headers - Server responds with SSE stream and sends
endpointevent - Client POSTs JSON-RPC to the endpoint URL
- Server responds via SSE
messageevents
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:
-
Register your client:
POST /oauth/register { "client_name": "My Custom Client", "redirect_uris": ["https://myapp.com/callback"], "token_endpoint_auth_method": "none" } -
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 -
User authenticates: They paste their
afk_...API key in the form -
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" } -
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:
| Method | Description |
|---|---|
initialize | Capability handshake. Must be first call on a session. |
notifications/initialized | Client ack (no response) |
tools/list | List all available tools |
tools/call | Execute a tool |
resources/list | List browsable resources |
resources/read | Read a resource by URI |
ping | Keep-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:
mcppackage 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.
Option B: Uploader Link
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. This option needs a human present to open the link and drop the file.
Option C: Agent-Driven Upload
When no human is present to drop a file — an agent working from a file URL or its own generated bytes — use asset_create_upload_url and asset_finalize_upload instead:
- Call
asset_create_upload_urlwithfilenameandmimeType(and optionallysizeBytes,folderPath). It returns a presigned PUT URL. - Upload the file's bytes directly to that URL. Bytes must never be base64-encoded into a tool call — send them straight to storage.
- Call
asset_finalize_uploadwith theuploadTokenfrom step 1 (plus optionaltitle,altText,tags,folderPath) to register the asset. This checks the file actually landed before handing back anassetRef, so a failed upload can't produce a broken reference.
Error Handling
| HTTP Status | Meaning |
|---|---|
| 200 | Success (HTTP transport) |
| 202 | Accepted, no body (notifications) |
| 204 | Session terminated (DELETE) |
| 400 | Invalid JSON-RPC request |
| 401 | Missing or invalid auth |
| 404 | Session not found (SSE) |
| 500 | Tool execution error (check result.content for details) |
JSON-RPC errors use standard codes:
| Code | Meaning |
|---|---|
| -32600 | Invalid request |
| -32601 | Method not found |
| -32602 | Invalid params |
| -32700 | Parse error |
Session Management
HTTP sessions are evicted after 2 hours of inactivity. You can:
- Reuse the same
Mcp-Session-Idacross requests - Skip
initializefor stateless calls (the server creates a transient session) - Explicitly terminate with
DELETE /mcp/http+Mcp-Session-Idheader
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)
