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/mcpAuthentication 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/call — list_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/call — get_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/call — calculate_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
| Field | Type | Description |
|---|---|---|
| stateSlug | string | Lowercase state slug, e.g. california, texas, new-york. Use list_states to see all 50. |
| grossPerPeriod | number | Gross pay per period (USD) for salary, or hourly wage for hourly. |
| frequency | enum | weekly · biweekly · semimonthly · monthly · annual (salary only) |
| payType | enum | salary or hourly |
| filingStatus | enum | single · married (filing jointly) · hoh (head of household) |
| hoursPerWeek | number? | Only for hourly. 0–168, defaults to 40. |
| pretax401kPct | number? | 401(k) contribution as a fraction 0–0.25. Defaults to 0. |
| healthPretax | number? | Pre-tax health premium per period. |
| hsaPretax | number? | Pre-tax HSA contribution per period. |
| otherPretax | number? | 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.
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
| Field | Type | Required | Notes |
|---|---|---|---|
| protocolVersion | string | required | MCP protocol version, e.g. '2025-06-18'. |
| capabilities | object | required | Client capabilities object; pass {} if none. |
| clientInfo | object | required | Client identity: { name: string, version: string }. |
tools/list · params
| Field | Type | Required | Notes |
|---|---|---|---|
No parameters — send an empty arguments: {} object. | |||
tools/call · params
| Field | Type | Required | Notes |
|---|---|---|---|
| name | string (enum) | required | 'list_states' | 'get_state_tax_info' | 'calculate_paycheck'. |
| arguments | object | required | Tool-specific arguments — see tables below. |
tools/call · list_states · arguments
| Field | Type | Required | Notes |
|---|---|---|---|
No parameters — send an empty arguments: {} object. | |||
tools/call · get_state_tax_info · arguments
| Field | Type | Required | Notes |
|---|---|---|---|
| stateSlug | string | required | Lowercase state slug (letters and hyphens), e.g. 'california', 'new-york'. |
tools/call · calculate_paycheck · arguments
| Field | Type | Required | Notes |
|---|---|---|---|
| stateSlug | string | required | Lowercase state slug. Use list_states to discover valid values. |
| grossPerPeriod | number | required | USD. 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' | required | Determines how grossPerPeriod is interpreted. |
| filingStatus | 'single'|'married'|'hoh' | required | Federal filing status; 'hoh' is head of household. |
| hoursPerWeek | number | optional | Only used when payType='hourly'. Range 0–168. Defaults to 40. |
| pretax401kPct | number | optional | Fraction 0–0.25 (e.g. 0.06 for 6%). Defaults to 0. |
| healthPretax | number | optional | USD pre-tax health premium per period. Defaults to 0. |
| hsaPretax | number | optional | USD pre-tax HSA contribution per period. Defaults to 0. |
| otherPretax | number | optional | USD 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
- “What's my take-home pay on a $95,000 salary in New York, married filing jointly, with 10% into 401(k)?”
- “Compare biweekly net pay between Texas and California for $3,000 gross per period, single.”
- “For an hourly wage of $28 in Florida, 40 hours a week, head of household — what do I take home?”
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/call — list_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/call — get_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/call — calculate_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
- Check
result.isError. Ontrue, do not retry the same payload — it will fail identically. - Read
content[0].text. Every- field: reasonline names one argument that must change. - Apply the fixes:
Unknown state slug→ calllist_statesand 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 changepayTypetosalary.hourly wage… looks too high→ treatgrossPerPeriodas an hourly rate, not a salary.pretax401kPct must be between 0 and 0.25→ pass a fraction (e.g.0.06for 6%), not a percent.hoursPerWeek must be between 0 and 168→ clamp to a realistic value; defaults to 40 if omitted.
- Re-send
tools/callwith the corrected arguments. A fresh JSON-RPCidis recommended but not required. - 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
- Retry only transport-level failures: HTTP 408, 429, and 5xx, or network errors. HTTP 200 with
result.isError: trueis a validation failure — fix the inputs and re-send, do not retry the same payload. - Honor
Retry-Afterexactly. If the header is present, wait at least that many seconds before the next attempt. - Exponential backoff with jitter when no
Retry-Afteris set: start at ~500 ms and double each attempt, capped at ~30 s, plus 0–250 ms of random jitter to avoid thundering herds. - Cap attempts at 3–5. Give up and surface the error to the caller after that; do not retry indefinitely.
- Refresh the OAuth token on 401. Do not count a 401 → refresh → retry cycle against the retry budget.
- Reuse the same JSON-RPC
idon a retry so the client can correlate responses; only bump theidwhen sending a fresh request.
// 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 }')"
donecurl — 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
fiSSE 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
- Buffer bytes, split on
\n\n:reader.read()may hand back a partial frame or two frames in one chunk. Parse only complete records. - Handle multi-line
data:: concatenate consecutivedata:lines within a frame with\n, then JSON-parse. - Detect completion explicitly: stop on
event: done/data: [DONE]. A closed connection with nodoneevent was interrupted — reconnect and reissue with the sameid. - Retry only transport failures: network drops, HTTP 408/429/5xx. Do NOT retry after HTTP 401 (refresh the token instead) or after a JSON-RPC frame with
result.isError: true— fix inputs first. - Honor
Retry-Afterwhen present on 429/503; otherwise exponential backoff (start ~500 ms, cap ~30 s) plus 0–250 ms of jitter, and cap attempts at 3–5. - Ignore keep-alives: lines starting with
:are SSE comments; emptydata:lines are heartbeats — don't treat them as frames.
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
| Code | Name | Meaning | Action |
|---|---|---|---|
| -32700 | Parse error | Request body was not valid JSON. | Fix the JSON serializer; do not retry the same bytes. |
| -32600 | Invalid Request | Envelope is malformed — missing jsonrpc: "2.0", missing method, or wrong types. | Rebuild the envelope; do not retry as-is. |
| -32601 | Method not found | Method 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. |
| -32602 | Invalid params | params is missing or wrong shape (e.g. tools/call without a name). | Fix the params to match the method's schema; do not retry. |
| -32603 | Internal error | Unexpected server-side failure inside JSON-RPC handling. | Retry once with backoff; if it persists, surface to the caller. |
| -32000 | Server error — rate limited | Paired with HTTP 429. error.data.retryAfterSeconds gives the wait. | Wait the Retry-After duration, then retry the same payload. |
| -32001 | Server error — upstream timeout | Paired with HTTP 504 (or 408 if the client hung up). | Retry with exponential backoff; the tools are idempotent. |
| n/a | Tool validation error | HTTP 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
| Status | Meaning | Action |
|---|---|---|
| 200 | Transport succeeded. Payload may still be a tool validation error — check result.isError. | Process result; branch on isError. |
| 400 | Malformed HTTP request (bad JSON, wrong Content-Type, missing Accept). | Fix the request; do not retry. |
| 401 | Missing, 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. |
| 403 | Token is valid but lacks permission for the tool. | Surface to the caller; do not retry. |
| 404 | Wrong endpoint path — MCP lives at /mcp. | Fix the URL; do not retry. |
| 405 | Wrong HTTP method — MCP requires POST. | Switch to POST; do not retry as-is. |
| 408 | Client-side request timeout. | Retry with exponential backoff and jitter. |
| 429 | Rate limited. Server sets Retry-After (seconds). | Wait Retry-After exactly, then retry the same payload. |
| 500 | Unexpected server error. | Retry once with backoff; escalate if it persists. |
| 502 / 503 | Upstream unavailable or overloaded. | Retry with exponential backoff (cap 3–5 attempts). |
| 504 | Upstream timeout — same as JSON-RPC -32001. | Retry with exponential backoff. |