MCP API

Connect ChatGPT, Claude, Cursor, or any Model Context Protocol client to this app and let it compute US take-home pay in a conversation. The server exposes three tools: list_states, get_state_tax_info, and calculate_paycheck.

Endpoint

The MCP server is available at:

https://usstatepaycheckcalculator.com/mcp

Authentication uses OAuth 2.1 with dynamic client registration. Most MCP clients handle the sign-in flow automatically — just paste the URL above.

Add to an MCP client

Example configuration for a generic MCP client:

{
  "mcpServers": {
    "us-paycheck": {
      "url": "https://usstatepaycheckcalculator.com/mcp"
    }
  }
}

curl commands

Ready-to-run curl commands for each JSON-RPC method. Export your OAuth access token first: export MCP_TOKEN=<your-token>. The Accept header must include both application/json and text/event-stream — the MCP server picks the response format based on the request.

initialize

curl -X POST https://usstatepaycheckcalculator.com/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 0,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-06-18",
      "capabilities": {},
      "clientInfo": { "name": "curl", "version": "1.0" }
    }
  }'

tools/list

curl -X POST https://usstatepaycheckcalculator.com/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/list"
  }'

tools/calllist_states

curl -X POST https://usstatepaycheckcalculator.com/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": { "name": "list_states", "arguments": {} }
  }'

tools/callget_state_tax_info

curl -X POST https://usstatepaycheckcalculator.com/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 3,
    "method": "tools/call",
    "params": {
      "name": "get_state_tax_info",
      "arguments": { "stateSlug": "california" }
    }
  }'

tools/callcalculate_paycheck

curl -X POST https://usstatepaycheckcalculator.com/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 4,
    "method": "tools/call",
    "params": {
      "name": "calculate_paycheck",
      "arguments": {
        "stateSlug": "california",
        "grossPerPeriod": 3000,
        "frequency": "biweekly",
        "payType": "salary",
        "filingStatus": "single",
        "pretax401kPct": 0.06,
        "healthPretax": 120
      }
    }
  }'

Tool: calculate_paycheck

Estimates 2026 take-home pay after federal tax, state tax, Social Security, and Medicare.

Inputs

FieldTypeDescription
stateSlugstringLowercase state slug, e.g. california, texas, new-york. Use list_states to see all 50.
grossPerPeriodnumberGross pay per period (USD) for salary, or hourly wage for hourly.
frequencyenumweekly · biweekly · semimonthly · monthly · annual (salary only)
payTypeenumsalary or hourly
filingStatusenumsingle · married (filing jointly) · hoh (head of household)
hoursPerWeeknumber?Only for hourly. 0–168, defaults to 40.
pretax401kPctnumber?401(k) contribution as a fraction 0–0.25. Defaults to 0.
healthPretaxnumber?Pre-tax health premium per period.
hsaPretaxnumber?Pre-tax HSA contribution per period.
otherPretaxnumber?Any other pre-tax deductions per period.

Example — salaried, California, biweekly

POST https://usstatepaycheckcalculator.com/mcp
Authorization: Bearer <oauth-access-token>
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "calculate_paycheck",
    "arguments": {
      "stateSlug": "california",
      "grossPerPeriod": 3000,
      "frequency": "biweekly",
      "payType": "salary",
      "filingStatus": "single",
      "pretax401kPct": 0.06,
      "healthPretax": 120
    }
  }
}

Example — hourly, Texas, head of household

{
  "name": "calculate_paycheck",
  "arguments": {
    "stateSlug": "texas",
    "grossPerPeriod": 28,
    "frequency": "weekly",
    "payType": "hourly",
    "hoursPerWeek": 40,
    "filingStatus": "hoh"
  }
}

Example — annual salary, New York, married

{
  "name": "calculate_paycheck",
  "arguments": {
    "stateSlug": "new-york",
    "grossPerPeriod": 95000,
    "frequency": "annual",
    "payType": "salary",
    "filingStatus": "married",
    "pretax401kPct": 0.1,
    "hsaPretax": 150
  }
}

Try it: calculate_paycheck playground

Fill in the inputs and click Run to see the JSON-RPC request an MCP client would send, along with the response envelope this server returns.

JSON-RPC request

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "calculate_paycheck",
    "arguments": {
      "stateSlug": "california",
      "grossPerPeriod": 3000,
      "frequency": "biweekly",
      "payType": "salary",
      "filingStatus": "single",
      "pretax401kPct": 0.06,
      "healthPretax": 120
    }
  }
}

JSON-RPC response

// Click “Run calculate_paycheck” to see the response.

The playground runs the same calculation locally in your browser so you can iterate without OAuth. A real MCP client sends the request above to /mcp with an OAuth bearer token.

Live tester — send a real request

Paste an OAuth bearer token, pick a preset (or edit the JSON), and send the request to the live /mcp endpoint. The response panel shows the exact JSON returned by the server — including isError messages and HTTP 401 responses when a token is missing or expired.

Preset

Sends Accept: text/event-stream to force the SSE variant. Frames appear in the Response panel as they arrive, split on \n\n, ending on event: done. If the stream drops mid-flight, the tester auto-reconnects using the settings below and resumes the stream at the last successfully parsed frame — sending Last-Event-ID when the server assigns SSE id: fields, otherwise Range: bytes=<offset>- as a fallback — while keeping the same JSON-RPC id. Each attempt, wait, and resume cursor is shown in the retry log.

Shows each SSE record exactly as it arrived on the wire (before parsing) in a second panel next to the parsed JSON-RPC response.

Honors Retry-After when present; otherwise exponential backoff with jitter. Validation errors (HTTP 200 with result.isError) and 401 are never retried.

Response

// Send the request to see the live response from /mcp.
// A missing or expired token returns HTTP 401 with WWW-Authenticate.

Requests go to the same origin at /mcp. The token is only sent in the Authorization header of this call and is never logged or stored.

Parameter reference

Every request goes to /mcp as JSON-RPC 2.0. Each entry below lists the method and the exact shape of params. Fields marked required must be present; optional fields fall back to the defaults noted.

initialize · params

FieldTypeRequiredNotes
protocolVersionstringrequiredMCP protocol version, e.g. '2025-06-18'.
capabilitiesobjectrequiredClient capabilities object; pass {} if none.
clientInfoobjectrequiredClient identity: { name: string, version: string }.

tools/list · params

FieldTypeRequiredNotes
No parameters — send an empty arguments: {} object.

tools/call · params

FieldTypeRequiredNotes
namestring (enum)required'list_states' | 'get_state_tax_info' | 'calculate_paycheck'.
argumentsobjectrequiredTool-specific arguments — see tables below.

tools/call · list_states · arguments

FieldTypeRequiredNotes
No parameters — send an empty arguments: {} object.

tools/call · get_state_tax_info · arguments

FieldTypeRequiredNotes
stateSlugstringrequiredLowercase state slug (letters and hyphens), e.g. 'california', 'new-york'.

tools/call · calculate_paycheck · arguments

FieldTypeRequiredNotes
stateSlugstringrequiredLowercase state slug. Use list_states to discover valid values.
grossPerPeriodnumberrequiredUSD. Salary: gross per period. Hourly: hourly wage. 0 < value ≤ 1,000,000.
frequency'weekly'|'biweekly'|'semimonthly'|'monthly'|'annual'required'annual' is only valid when payType='salary'.
payType'salary'|'hourly'requiredDetermines how grossPerPeriod is interpreted.
filingStatus'single'|'married'|'hoh'requiredFederal filing status; 'hoh' is head of household.
hoursPerWeeknumberoptionalOnly used when payType='hourly'. Range 0–168. Defaults to 40.
pretax401kPctnumberoptionalFraction 0–0.25 (e.g. 0.06 for 6%). Defaults to 0.
healthPretaxnumberoptionalUSD pre-tax health premium per period. Defaults to 0.
hsaPretaxnumberoptionalUSD pre-tax HSA contribution per period. Defaults to 0.
otherPretaxnumberoptionalUSD other pre-tax deductions per period. Defaults to 0.

Tool: get_state_tax_info

Returns income tax rules for a single US state — kind (none, flat, or brackets), the flat rate or bracket table, and notes. Filing status is not required for this tool; it applies only to calculate_paycheck.

Example — California (bracketed)

POST https://usstatepaycheckcalculator.com/mcp
Authorization: Bearer <oauth-access-token>
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 10,
  "method": "tools/call",
  "params": {
    "name": "get_state_tax_info",
    "arguments": { "stateSlug": "california" }
  }
}

Example — Texas (no state income tax)

{
  "jsonrpc": "2.0",
  "id": 11,
  "method": "tools/call",
  "params": {
    "name": "get_state_tax_info",
    "arguments": { "stateSlug": "texas" }
  }
}

Example — Illinois (flat rate)

{
  "jsonrpc": "2.0",
  "id": 12,
  "method": "tools/call",
  "params": {
    "name": "get_state_tax_info",
    "arguments": { "stateSlug": "illinois" }
  }
}

Example — validation error (bad slug)

// Request
{
  "jsonrpc": "2.0",
  "id": 13,
  "method": "tools/call",
  "params": {
    "name": "get_state_tax_info",
    "arguments": { "stateSlug": "Puerto Rico!" }
  }
}

// Response
{
  "jsonrpc": "2.0",
  "id": 13,
  "result": {
    "isError": true,
    "content": [
      {
        "type": "text",
        "text": "Invalid input for get_state_tax_info: stateSlug must be lowercase letters and hyphens, e.g. 'new-york'"
      }
    ]
  }
}

calculate_paycheck by filing status

filingStatus accepts single, married (filing jointly), or hoh (head of household). Sample requests:

Single — California biweekly salary

{
  "name": "calculate_paycheck",
  "arguments": {
    "stateSlug": "california",
    "grossPerPeriod": 3000,
    "frequency": "biweekly",
    "payType": "salary",
    "filingStatus": "single"
  }
}

Married filing jointly — New York annual salary

{
  "name": "calculate_paycheck",
  "arguments": {
    "stateSlug": "new-york",
    "grossPerPeriod": 95000,
    "frequency": "annual",
    "payType": "salary",
    "filingStatus": "married"
  }
}

Head of household — Texas hourly

{
  "name": "calculate_paycheck",
  "arguments": {
    "stateSlug": "texas",
    "grossPerPeriod": 28,
    "frequency": "weekly",
    "payType": "hourly",
    "hoursPerWeek": 40,
    "filingStatus": "hoh"
  }
}

Prompts to try

Successful responses

Every successful JSON-RPC call returns HTTP 200 with a jsonrpc / id envelope and a result object. For tools/call, always check result.isError before treating the payload as success — validation failures also arrive as HTTP 200. Realistic examples for each method follow; numeric values are illustrative and depend on inputs and the current tax year.

initialize — success

Echoes the negotiated protocolVersion, server capabilities (this server only advertises tools), and serverInfo. Use instructions as the system-prompt hint for the tools.

HTTP/1.1 200 OK
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "tools": { "listChanged": false }
    },
    "serverInfo": {
      "name": "us-state-paycheck-calculator-mcp",
      "title": "US State Paycheck Calculator",
      "version": "0.1.0"
    },
    "instructions": "Tools for the US State Paycheck Calculator. Use `list_states` to discover available states, `get_state_tax_info` for a state's tax details, and `calculate_paycheck` to estimate 2026 take-home pay after federal tax, state tax, Social Security, and Medicare."
  }
}

tools/list — success

Returns the full tool catalog with name, title, description, inputSchema (JSON Schema), and annotations. All three tools here are read-only and idempotent.

HTTP/1.1 200 OK
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "calculate_paycheck",
        "title": "Calculate paycheck",
        "description": "Estimate 2026 US take-home pay for any state. Computes federal tax, state tax, Social Security, Medicare, and net pay from gross wages and deductions.",
        "inputSchema": {
          "type": "object",
          "required": ["stateSlug", "grossPerPeriod", "frequency", "payType", "filingStatus"],
          "properties": {
            "stateSlug": { "type": "string" },
            "grossPerPeriod": { "type": "number", "exclusiveMinimum": 0 },
            "frequency": { "type": "string", "enum": ["weekly", "biweekly", "semimonthly", "monthly", "annual"] },
            "payType": { "type": "string", "enum": ["salary", "hourly"] },
            "filingStatus": { "type": "string", "enum": ["single", "married", "hoh"] }
          }
        },
        "annotations": { "readOnlyHint": true, "idempotentHint": true, "openWorldHint": false }
      },
      {
        "name": "list_states",
        "title": "List US states",
        "description": "List all 50 US states plus DC available in the paycheck calculator, with slug, abbreviation, and state income tax kind.",
        "inputSchema": { "type": "object", "properties": {} },
        "annotations": { "readOnlyHint": true, "idempotentHint": true, "openWorldHint": false }
      },
      {
        "name": "get_state_tax_info",
        "title": "Get state tax info",
        "description": "Return state income tax details for a single US state (kind, flat rate or brackets, notes).",
        "inputSchema": {
          "type": "object",
          "required": ["stateSlug"],
          "properties": { "stateSlug": { "type": "string" } }
        },
        "annotations": { "readOnlyHint": true, "idempotentHint": true, "openWorldHint": false }
      }
    ]
  }
}

tools/calllist_states

Returns a text block with the full JSON array of states and the same data in structuredContent.states. Trimmed here for readability.

HTTP/1.1 200 OK
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "[\n  { \"slug\": \"alabama\", \"name\": \"Alabama\", \"abbr\": \"AL\", \"taxKind\": \"brackets\", \"noStateIncomeTax\": false, \"flatRate\": null, \"notes\": null },\n  { \"slug\": \"alaska\", \"name\": \"Alaska\", \"abbr\": \"AK\", \"taxKind\": \"none\", \"noStateIncomeTax\": true, \"flatRate\": null, \"notes\": \"No state income tax\" },\n  { \"slug\": \"california\", \"name\": \"California\", \"abbr\": \"CA\", \"taxKind\": \"brackets\", \"noStateIncomeTax\": false, \"flatRate\": null, \"notes\": null },\n  /* … 48 more entries … */\n]"
      }
    ],
    "structuredContent": {
      "states": [
        { "slug": "alabama", "name": "Alabama", "abbr": "AL", "taxKind": "brackets", "noStateIncomeTax": false, "flatRate": null, "notes": null },
        { "slug": "alaska", "name": "Alaska", "abbr": "AK", "taxKind": "none", "noStateIncomeTax": true, "flatRate": null, "notes": "No state income tax" },
        { "slug": "california", "name": "California", "abbr": "CA", "taxKind": "brackets", "noStateIncomeTax": false, "flatRate": null, "notes": null }
      ]
    }
  }
}

tools/callget_state_tax_info

Returns state metadata plus tax details in both a text block and structuredContent.state. Bracket arrays are truncated in this example.

HTTP/1.1 200 OK
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\n  \"slug\": \"california\",\n  \"name\": \"California\",\n  \"abbr\": \"CA\",\n  \"kind\": \"brackets\",\n  \"noTax\": false,\n  \"brackets\": {\n    \"single\": [\n      { \"upTo\": 10756, \"rate\": 0.01 },\n      { \"upTo\": 25499, \"rate\": 0.02 },\n      { \"upTo\": 40245, \"rate\": 0.04 },\n      /* … */\n      { \"upTo\": null, \"rate\": 0.123 }\n    ]\n  }\n}"
      }
    ],
    "structuredContent": {
      "state": {
        "slug": "california",
        "name": "California",
        "abbr": "CA",
        "kind": "brackets",
        "noTax": false
      }
    }
  }
}

tools/callcalculate_paycheck

Returns a human-readable summary in content[0].text and machine-readable numbers in structuredContent.result. Example is for California, $3,000 biweekly salary, single, 10% 401(k), with rounded example figures.

HTTP/1.1 200 OK
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "State: California (CA)\nGross (annual): $78,000.00\nFederal tax: $8,341.00\nState tax: $2,431.20\nSocial Security: $4,836.00\nMedicare: $1,131.00\nNet (annual): $56,140.80\nNet per period: $2,159.26\nEffective tax rate: 21.5%"
      }
    ],
    "structuredContent": {
      "result": {
        "grossAnnual": 78000,
        "grossPerPeriod": 3000,
        "pretaxAnnual": 7800,
        "taxableFederalAnnual": 55350,
        "federalAnnual": 8341,
        "stateAnnual": 2431.2,
        "socialSecurityAnnual": 4836,
        "medicareAnnual": 1131,
        "totalTaxAnnual": 16739.2,
        "netAnnual": 56140.8,
        "netPerPeriod": 2159.26,
        "marginalFederal": 0.22,
        "effectiveRate": 0.2146,
        "perPeriod": {
          "federal": 320.81,
          "state": 93.51,
          "socialSecurity": 186,
          "medicare": 43.5,
          "pretax": 300,
          "net": 2159.26
        }
      },
      "state": { "slug": "california", "name": "California", "abbr": "CA" }
    }
  }
}

Error handling

Tool errors are not JSON-RPC transport errors — the call still returns HTTP 200 and a normal result object, with isError: true and a human-readable message in content[0].text. Clients should inspect result.isError before treating a response as success.

Response shape for validation errors

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "isError": true,
    "content": [
      { "type": "text", "text": "Invalid input for <tool>:\n- <field>: <message>\n- <field>: <message>" }
    ]
  }
}

The message lists every field that failed, one per line, in the form - <field>: <reason>. Parse those lines to build a fix, then retry.

Example — multiple invalid fields

// Request
{
  "name": "calculate_paycheck",
  "arguments": {
    "stateSlug": "california",
    "grossPerPeriod": -50,
    "frequency": "annual",
    "payType": "hourly",
    "filingStatus": "single"
  }
}

// Response
{
  "jsonrpc": "2.0",
  "id": 20,
  "result": {
    "isError": true,
    "content": [
      {
        "type": "text",
        "text": "Invalid input for calculate_paycheck:\n- grossPerPeriod: grossPerPeriod must be greater than 0\n- frequency: frequency 'annual' is not valid when payType='hourly'"
      }
    ]
  }
}

// Fix
// - Set grossPerPeriod to a positive hourly wage (e.g. 28)
// - Use frequency 'weekly', 'biweekly', 'semimonthly', or 'monthly' with payType='hourly'
// - Then retry the same tools/call.

Example — unknown state slug

// Request
{
  "name": "calculate_paycheck",
  "arguments": {
    "stateSlug": "puertorico",
    "grossPerPeriod": 3000,
    "frequency": "biweekly",
    "payType": "salary",
    "filingStatus": "single"
  }
}

// Response
{
  "jsonrpc": "2.0",
  "id": 21,
  "result": {
    "isError": true,
    "content": [
      { "type": "text", "text": "Unknown state slug: 'puertorico'. Call list_states for the full set of valid slugs." }
    ]
  }
}

// Fix
// - Call the list_states tool to get every valid slug (all 50 US states).
// - Reissue calculate_paycheck with a slug from that list, e.g. 'new-york'.

Example — hourly wage that looks like a salary

// Request
{
  "name": "calculate_paycheck",
  "arguments": {
    "stateSlug": "texas",
    "grossPerPeriod": 95000,
    "frequency": "weekly",
    "payType": "hourly",
    "hoursPerWeek": 40,
    "filingStatus": "hoh"
  }
}

// Response
{
  "jsonrpc": "2.0",
  "id": 22,
  "result": {
    "isError": true,
    "content": [
      { "type": "text", "text": "Invalid input for calculate_paycheck:\n- grossPerPeriod: grossPerPeriod is the hourly wage when payType='hourly'; value looks too high" }
    ]
  }
}

// Fix
// - When payType='hourly', grossPerPeriod is the hourly rate (e.g. 28), not annual salary.
// - Either switch payType to 'salary' with frequency 'annual', or divide the salary to a realistic hourly rate.

How clients should recover

  1. Check result.isError. On true, do not retry the same payload — it will fail identically.
  2. Read content[0].text. Every - field: reason line names one argument that must change.
  3. Apply the fixes:
    • Unknown state slug → call list_states and pick a slug from the returned list.
    • must be greater than 0 / cannot be negative → replace the value with a positive number.
    • frequency 'annual' is not valid when payType='hourly' → switch to a periodic frequency (weekly, biweekly, semimonthly, monthly) or change payType to salary.
    • hourly wage… looks too high → treat grossPerPeriod as an hourly rate, not a salary.
    • pretax401kPct must be between 0 and 0.25 → pass a fraction (e.g. 0.06 for 6%), not a percent.
    • hoursPerWeek must be between 0 and 168 → clamp to a realistic value; defaults to 40 if omitted.
  4. Re-send tools/call with the corrected arguments. A fresh JSON-RPC id is recommended but not required.
  5. Only retry the exact same payload on transient transport failures (network reset, HTTP 5xx). Validation errors are deterministic.

Authentication errors

Missing, expired, or revoked bearer tokens return HTTP 401 with a WWW-Authenticate header pointing at the OAuth resource metadata. Refresh the token or restart the OAuth flow, then retry the request — do not surface a 401 to the model as a tool error.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://usstatepaycheckcalculator.com/.well-known/oauth-protected-resource"

{ "error": "invalid_token", "error_description": "Missing or expired access token" }

Rate limiting (HTTP 429)

When a client exceeds the request rate, the server returns HTTP 429 with a Retry-After header (seconds) and a JSON-RPC error envelope. Wait for the duration in Retry-After, then retry the same payload — the failure is transient, not a validation issue.

HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 30,
  "error": {
    "code": -32000,
    "message": "Too Many Requests",
    "data": {
      "retryAfterSeconds": 12,
      "reason": "rate_limited"
    }
  }
}

Timeouts (HTTP 408 / 504)

Long calls that exceed the upstream deadline return HTTP 504 (or 408 if the client hung up). These are also transient. Retry the same request with exponential backoff — the tools are idempotent (read-only), so a repeated call cannot cause double writes.

HTTP/1.1 504 Gateway Timeout
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": 31,
  "error": {
    "code": -32001,
    "message": "Upstream timeout",
    "data": { "reason": "timeout" }
  }
}

Retry / backoff guidance

// Exponential backoff with jitter for JSON-RPC over HTTP.
// Retry only transport-level failures (HTTP 408/429/5xx and network errors).
// Do NOT retry validation errors (result.isError=true, HTTP 200) — fix inputs first.
async function callMcp(body, token, { maxAttempts = 5 } = {}) {
  let attempt = 0;
  while (true) {
    attempt++;
    const res = await fetch("/mcp", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${token}`,
        "Content-Type": "application/json",
        "Accept": "application/json, text/event-stream",
      },
      body: JSON.stringify(body),
    });

    // Success — return JSON-RPC envelope (may still contain result.isError).
    if (res.ok) return res.json();

    // 401 — refresh the OAuth token; do not count this against retries.
    if (res.status === 401) throw new Error("unauthorized");

    const retryable = res.status === 408 || res.status === 429 || res.status >= 500;
    if (!retryable || attempt >= maxAttempts) {
      throw new Error(`MCP call failed: HTTP ${res.status}`);
    }

    // Honor Retry-After when present; otherwise exponential backoff with jitter.
    const header = res.headers.get("Retry-After");
    const base = header ? Number(header) * 1000 : Math.min(30_000, 500 * 2 ** (attempt - 1));
    const jitter = Math.random() * 250;
    await new Promise((r) => setTimeout(r, base + jitter));
  }
}

curl — retry loop with Retry-After + exponential backoff

Copy-ready Bash script. Retries only on HTTP 408, 429, and 5xx; honors Retry-After exactly when the server sets it, otherwise doubles the delay each attempt (capped at 30 s) with 0–250 ms of jitter. Export MCP_TOKEN first.

#!/usr/bin/env bash
# Retry a JSON-RPC call to https://usstatepaycheckcalculator.com/mcp on 408 / 429 / 5xx.
# Honors Retry-After when the server sets it; otherwise exponential backoff + jitter.
# Requires: bash, curl, awk. Export MCP_TOKEN first.
set -euo pipefail

ENDPOINT="https://usstatepaycheckcalculator.com/mcp"
MAX_ATTEMPTS=5
BASE_MS=500       # initial delay
MAX_MS=30000      # cap per-attempt wait
JITTER_MS=250     # random 0..JITTER_MS added each attempt

PAYLOAD='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_states","arguments":{}}}'

for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
  # -D dumps response headers so we can read Retry-After.
  HDR=$(mktemp) ; BODY=$(mktemp)
  STATUS=$(curl -sS -o "$BODY" -D "$HDR" -w "%{http_code}" \
    -X POST "$ENDPOINT" \
    -H "Authorization: Bearer $MCP_TOKEN" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json, text/event-stream" \
    --data "$PAYLOAD")

  echo "attempt $attempt/$MAX_ATTEMPTS -> HTTP $STATUS"

  # Success or non-retryable error -> emit body and stop.
  if [ "$STATUS" -lt 400 ] || { [ "$STATUS" -ne 408 ] && [ "$STATUS" -ne 429 ] && [ "$STATUS" -lt 500 ]; }; then
    cat "$BODY"; rm -f "$HDR" "$BODY"; exit 0
  fi

  if [ "$attempt" -eq "$MAX_ATTEMPTS" ]; then
    echo "giving up after $MAX_ATTEMPTS attempts" >&2
    cat "$BODY" >&2; rm -f "$HDR" "$BODY"; exit 1
  fi

  # Prefer server-supplied Retry-After (seconds).
  RETRY_AFTER=$(awk 'BEGIN{IGNORECASE=1} /^retry-after:/ {gsub(/\r/,""); print $2; exit}' "$HDR" || true)
  if [ -n "$RETRY_AFTER" ]; then
    WAIT_MS=$(( RETRY_AFTER * 1000 ))
    echo "  Retry-After: ${RETRY_AFTER}s -> sleeping $WAIT_MS ms"
  else
    # Exponential backoff: BASE * 2^(attempt-1), capped at MAX_MS.
    BACKOFF=$(( BASE_MS * (1 << (attempt - 1)) ))
    [ "$BACKOFF" -gt "$MAX_MS" ] && BACKOFF=$MAX_MS
    JITTER=$(( RANDOM % (JITTER_MS + 1) ))
    WAIT_MS=$(( BACKOFF + JITTER ))
    echo "  exponential backoff -> sleeping $WAIT_MS ms"
  fi

  rm -f "$HDR" "$BODY"
  sleep "$(awk -v ms="$WAIT_MS" 'BEGIN { printf "%.3f", ms/1000 }')"
done

curl — one-shot 429 recovery

Minimal paste-and-run version: send the request once, and if the server returns HTTP 429, sleep for exactly the Retry-After duration and resend the same payload.

# Wait exactly Retry-After (seconds) once, then re-send. Copy/paste into a shell.
RESP=$(curl -sS -D - -o /tmp/mcp.body -w "\n%{http_code}" \
  -X POST https://usstatepaycheckcalculator.com/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}')

STATUS=$(printf "%s" "$RESP" | tail -n1)
if [ "$STATUS" = "429" ]; then
  WAIT=$(printf "%s" "$RESP" | awk 'BEGIN{IGNORECASE=1} /^retry-after:/ {gsub(/\r/,""); print $2; exit}')
  echo "rate limited; sleeping ${WAIT:-1}s"
  sleep "${WAIT:-1}"
  curl -sS -X POST https://usstatepaycheckcalculator.com/mcp \
    -H "Authorization: Bearer $MCP_TOKEN" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json, text/event-stream" \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
else
  cat /tmp/mcp.body
fi

SSE streaming (tools/call)

The MCP endpoint speaks Streamable HTTP: send Accept: application/json, text/event-stream to let the server pick, or Accept: text/event-stream to force SSE. Each SSE record is separated by a blank line (\n\n); the terminal frame is event: done with a [DONE] payload.

Force an SSE response with curl

# Force the SSE variant by requesting only text/event-stream.
# curl -N disables output buffering so events print as they arrive.
curl -N -X POST https://usstatepaycheckcalculator.com/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 7,
    "method": "tools/call",
    "params": {
      "name": "calculate_paycheck",
      "arguments": {
        "stateSlug": "california",
        "grossPerPeriod": 3000,
        "frequency": "biweekly",
        "payType": "salary",
        "filingStatus": "single"
      }
    }
  }'

Example wire format

One JSON-RPC frame per data: line, followed by a done sentinel. A frame may span multiple data: lines — join them with \n before parsing. Blank data: lines and lines starting with : are keep-alives; ignore them.

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

event: message
data: {"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"State: California (CA)\nGross (annual): $78,000.00\nFederal tax: $8,341.00\nState tax: $2,431.20\nSocial Security: $4,836.00\nMedicare: $1,131.00\nNet (annual): $56,140.80\nNet per period: $2,159.26\nEffective tax rate: 21.5%"}],"structuredContent":{"result":{"grossAnnual":78000,"netAnnual":56140.8,"effectiveRate":0.2146}}}}

event: done
data: [DONE]

Client with partial-event handling and reconnect

Async generator that buffers incoming bytes, splits on the SSE record delimiter, yields each parsed JSON-RPC frame, ends cleanly on event: done, and reconnects with exponential backoff if the stream drops mid-flight. Reuse the same JSON-RPC id on retry so the server correlates the resumed call. Never retry after HTTP 401 or a result.isError: true frame — those are terminal.

// Streamable HTTP + SSE consumer for /mcp with automatic reconnect.
// - Buffers bytes and splits on the SSE record delimiter (\n\n).
// - Emits parsed JSON-RPC frames as they arrive.
// - On network interruption, retries with exponential backoff + jitter,
//   reusing the SAME JSON-RPC id so the server can correlate a resumed call.
// - Do NOT retry after result.isError:true (validation) or HTTP 401.
async function* streamMcp(body, token, { maxAttempts = 5 } = {}) {
  let attempt = 0;
  while (true) {
    attempt++;
    const res = await fetch("https://usstatepaycheckcalculator.com/mcp", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
        Accept: "text/event-stream",
      },
      body: JSON.stringify(body),
    });

    if (res.status === 401) throw new Error("unauthorized"); // refresh + retry outside
    const retryable = res.status === 408 || res.status === 429 || res.status >= 500;
    if (!res.ok && !retryable) throw new Error(`MCP stream failed: HTTP ${res.status}`);
    if (!res.ok || !res.body) {
      if (attempt >= maxAttempts) throw new Error(`giving up after ${attempt} attempts`);
      await sleep(backoffMs(res.headers.get("Retry-After"), attempt));
      continue;
    }

    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";
    try {
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });

        // SSE frames are separated by a blank line.
        let sep;
        while ((sep = buffer.indexOf("\n\n")) !== -1) {
          const frame = buffer.slice(0, sep);
          buffer = buffer.slice(sep + 2);

          let event = "message";
          const dataLines = [];
          for (const line of frame.split(/\r?\n/)) {
            if (line.startsWith("event:")) event = line.slice(6).trim();
            else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
            // 'id:' and 'retry:' lines are ignored here — add them if you need resume.
          }
          const data = dataLines.join("\n");
          if (event === "done" || data === "[DONE]") return; // clean end-of-stream
          if (!data) continue;                               // keep-alive / comment
          try { yield JSON.parse(data); }                    // partial JSON-RPC frame
          catch { yield { raw: data }; }                     // non-JSON payload
        }
      }
      return; // stream ended without an explicit 'done' — treat as complete
    } catch (err) {
      // Network drop mid-stream. Retry with backoff, reusing the same request body/id.
      if (attempt >= maxAttempts) throw err;
      await sleep(backoffMs(null, attempt));
      // loop to reconnect
    }
  }
}

function backoffMs(retryAfterHeader, attempt) {
  if (retryAfterHeader) return Number(retryAfterHeader) * 1000;
  return Math.min(30_000, 500 * 2 ** (attempt - 1)) + Math.random() * 250;
}
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }

// Usage:
for await (const frame of streamMcp(
  { jsonrpc: "2.0", id: 7, method: "tools/call",
    params: { name: "calculate_paycheck", arguments: { stateSlug: "california",
      grossPerPeriod: 3000, frequency: "biweekly", payType: "salary", filingStatus: "single" } } },
  token,
)) {
  if (frame.error) { console.error("JSON-RPC error", frame.error); break; }
  if (frame.result?.isError) { console.warn("validation error — fix inputs, do NOT retry"); break; }
  console.log("frame", frame);
}

Stream-handling checklist

Error code glossary

Quick reference for the JSON-RPC error codes and HTTP status codes this server can return, with the action a client should take for each. JSON-RPC codes live in the error.code field of the envelope; HTTP codes come from the transport.

JSON-RPC error codes

CodeNameMeaningAction
-32700Parse errorRequest body was not valid JSON.Fix the JSON serializer; do not retry the same bytes.
-32600Invalid RequestEnvelope is malformed — missing jsonrpc: "2.0", missing method, or wrong types.Rebuild the envelope; do not retry as-is.
-32601Method not foundMethod is not initialize, tools/list, or tools/call, or the tool name is unknown.Call tools/list and use a name from the result. Do not retry.
-32602Invalid paramsparams is missing or wrong shape (e.g. tools/call without a name).Fix the params to match the method's schema; do not retry.
-32603Internal errorUnexpected server-side failure inside JSON-RPC handling.Retry once with backoff; if it persists, surface to the caller.
-32000Server error — rate limitedPaired with HTTP 429. error.data.retryAfterSeconds gives the wait.Wait the Retry-After duration, then retry the same payload.
-32001Server error — upstream timeoutPaired with HTTP 504 (or 408 if the client hung up).Retry with exponential backoff; the tools are idempotent.
n/aTool validation errorHTTP 200 with result.isError: true — NOT a JSON-RPC error.Parse the field/reason lines in content[0].text, fix inputs, re-send. Never retry unchanged.

HTTP status codes

StatusMeaningAction
200Transport succeeded. Payload may still be a tool validation error — check result.isError.Process result; branch on isError.
400Malformed HTTP request (bad JSON, wrong Content-Type, missing Accept).Fix the request; do not retry.
401Missing, expired, or revoked bearer token. WWW-Authenticate points at OAuth metadata.Refresh the token or restart OAuth, then retry. Do not count against the retry budget.
403Token is valid but lacks permission for the tool.Surface to the caller; do not retry.
404Wrong endpoint path — MCP lives at /mcp.Fix the URL; do not retry.
405Wrong HTTP method — MCP requires POST.Switch to POST; do not retry as-is.
408Client-side request timeout.Retry with exponential backoff and jitter.
429Rate limited. Server sets Retry-After (seconds).Wait Retry-After exactly, then retry the same payload.
500Unexpected server error.Retry once with backoff; escalate if it persists.
502 / 503Upstream unavailable or overloaded.Retry with exponential backoff (cap 3–5 attempts).
504Upstream timeout — same as JSON-RPC -32001.Retry with exponential backoff.