X3DStudios

MCP server

X3D speaks Model Context Protocol at https://x3dstudios.com/api/mcp, so Claude, Claude Code and Cursor can quote a model, order a print and track it as tool calls instead of reading this page.

MCP (Model Context Protocol) is the standard way an AI model uses a service: the client asks the server what tools it has, gets typed schemas back, and calls them. X3D speaks it, which means an agent can price a model, place a print order and read the tracking number without a browser, without scraping this site and without anyone hand-writing tool definitions against our REST API.

Endpoint
POST https://x3dstudios.com/api/mcp

One URL. POST only, because that is what Streamable HTTP is: GET (the old event stream) and DELETE (the old session teardown) both answer 405, and there is no session id to carry. If your client wants to open a GET stream, it is speaking an older transport than this server does.

One tool here spends real money
create_print_order charges the card on the account and starts a print that cannot be un-printed. It refuses to run without a quote id from a previous quoting call and confirm set to true, so it cannot happen in one step by accident. Show the quote to a person and get a yes before you call it.

Add X3D to your client#

Two things go into the config: the URL, and your key as an Authorization header. You can leave the header out entirely and still read the materials and rates, price a weight you already know, check whether a file will print, read the current lead time and look up an order by its code, which is a reasonable way to try the server before signing up for anything.

Claude Code#

One command adds it. The name x3d is yours to choose; it is what the tools are prefixed with in the client.

From the terminal
claude mcp add --transport http x3d https://x3dstudios.com/api/mcp \
  --header "Authorization: Bearer $X3D_API_KEY"

Or commit it to the project, which is the better move for a repo where more than one person wants it. Both files use the same shape.

{
  "mcpServers": {
    "x3d": {
      "type": "http",
      "url": "https://x3dstudios.com/api/mcp",
      "headers": {
        "Authorization": "Bearer x3d_live_8f2b41c07d9e5a3610bc7f4d82e91a05c36bd7f2ae04915c"
      }
    }
  }
}
  • Claude Code requires "type": "http" on a remote server. An entry with a url and no type is treated as a configuration error and skipped.
  • Cursor identifies a remote server by the url alone and needs no type. The file is .cursor/mcp.json in a project, or ~/.cursor/mcp.json for every project.
  • Both files end up holding a live credential. Put them where you would put a .env, not in a public repository.

Claude on the web and in the desktop app#

The connector form has nowhere to put a key
Customize > Connectors > Add custom connector asks for a remote MCP server URL, and optionally an OAuth client id and secret. It does not take an arbitrary header. Add https://x3dstudios.com/api/mcp there and the public tools work; the account tools will keep answering that they need a key, because there is no way to send one. We do not support OAuth yet. Until we do, use Claude Code or Cursor for anything keyed.

Authentication#

POST/api/mcpNo auth

The MCP endpoint itself is public. Individual tools decide whether they need a key.

The key is the same x3d_live_ key the REST print API uses. Generate it at https://x3dstudios.com/profile, under Developer API key, once a card is on file. It is shown exactly once and stored only as a hash, so put it in your secret manager at the moment it is issued.

The header, on every request
Authorization: Bearer x3d_live_8f2b41c07d9e5a3610bc7f4d82e91a05c36bd7f2ae04915c
  • A missing or unrecognised key is not a 401. The request succeeds, the caller is simply anonymous, and only the account tools notice.
  • An account tool called without a key returns a normal result with isError set and a sentence pointing at /profile. That is deliberate: as a protocol error the model would see "the connection failed" and give up, and the person would never learn what to do.
  • One key per account. Generating a new one at /profile invalidates the old one immediately, which is the fastest kill switch you have if an agent gets loose.
  • The key identifies the account whose card is charged and whose orders you can read. There are no scopes and no read-only key.

The tools#

Eleven tools. Five need no key and six take one. Nine of the eleven are free: only two spend anything, and both of them refuse to run without an explicit confirmation. tools/list returns all eleven tools to everybody, keyed or not, so an agent can tell its user what a key would unlock instead of finding out by failing.

ToolKeyCostWhat it does
list_materialsnofreeEvery stock material and its per-gram rate, the twelve shelf colours, what an off-palette colour costs, quality multipliers, quantity discounts, the per-part minimum, postage and the largest build plate. Read live from the tables that charge the card.
estimate_pricenofreeArithmetic on a weight you already know. Grams, material, quality, quantity in, a full breakdown out. It never looks at a model.
check_printabilitynofreeDownloads a mesh and measures it: dimensions, watertightness, holes, non-manifold and degenerate geometry, plate fit, shallowest overhang. Keeps nothing.
get_lead_timenofreeHow soon work placed now would ship, from the state of the fleet this minute, for 1, 10, 100 and 1000 orders.
track_ordernofreeThe stage and tracking number of an existing order, given its code and the email it was placed with.
quote_printyesfreeThe real quote. Downloads the model, slices it to weigh it, returns the measurement, the full price and a quote_id that holds the price for 24 hours.
create_print_orderyesmoneyPlaces the order and charges the card on file. Needs a quote_id and confirm: true.
list_my_ordersyesfreeUp to 20 of this account's most recent orders, newest first, from every X3D order store.
get_orderyesfreeOne order on this account by its code.
generate_modelyescreditsTurns a text prompt into a printable model. Spends one AI generation credit and needs confirm: true. Asynchronous: returns a job_id.
get_generationyesfreeProgress of a generate_model job, and when it is done the model_url to hand to quote_print plus the printability verdict.
Order is fixed: the keyless tools first, then the keyed ones, and it never reshuffles between calls. Each entry also carries com.x3dstudios/cost and com.x3dstudios/auth in its _meta.

Public tools#

list_materials takes no arguments at all. Every tool on this page rejects an argument it does not know rather than ignoring it, so a misspelled field is an error you can see instead of a default you cannot.

estimate_price
gramsnumberrequired
Filament weight of ONE part, not the batch. 0.1 to 50000.
materialstringoptionaldefault pla
Stock material.plapetgabsasatpu
qualitystringoptionaldefault standard
standard is 0.2mm layers; premium is 0.1mm and costs 1.75x the per-gram rate.standardpremium
quantityintegeroptionaldefault 1
1 to 10000. This tool is arithmetic, so it will price a run far larger than one order accepts.
colorstringoptional
Colour name or hex. A colour we do not stock costs more per gram: call list_materials for the shelf.
check_printability
model_urlstringrequired
Direct https link to an .stl, .obj, .glb or .gltf file, 25 MB or under. It has to be a real download, not a viewer page and not anything behind a sign-in.
get_lead_time
gramsnumberoptional
Weight of one part. Heavier parts hold a printer longer. Omit it and you get the farm's standard order.
quantityintegeroptionaldefault 1
Parts in one order.
track_order
order_codestringrequired
X3D-XXXXXXXX from the print API or /farm/submit, or a CART-, ORD- or STORE- code from the web checkout.
emailstringrequired
The email the order was placed with. Both are required: a code with the wrong email is deliberately indistinguishable from a code that does not exist.
track_order never returns an address
It answers with the stage, a one-line description of what was ordered, and tracking. Nothing else. It is the one tool that reads a real order with no key at all, so it hands back the least it can while still being useful.

Account tools#

Every tool in this group needs the Authorization header. list_my_orders takes no arguments; the rest are below.

quote_print
model_urlstringrequired
Direct https link to an .stl, .glb or .gcode.3mf file, or a model_url X3D itself returned from get_generation. Unlike check_printability this one only accepts a link from a host we allow: a quote leads to a charge, so where a billable model may come from is the operator's decision. The refusal names the hosts we do take.
materialstringoptionaldefault pla
Stock material.plapetgabsasatpu
colorstringoptional
Filament colour. Off-palette colours cost more per gram.
qualitystringoptionaldefault standard
standard is 0.2mm layers; premium is 0.1mm.standardpremium
quantityintegeroptionaldefault 1
1 to 50, which is the cap on one order.
infillintegeroptionaldefault 20
10 to 100 percent.
countrystringoptionaldefault US
Two-letter ISO code of the delivery address. Postage is part of the total, $7 in the US and a flat $30 abroad, so the quote is only valid for the country it was priced for. Leave it out and you get a US price, said out loud in the result; create_print_order then refuses an address anywhere else against that quote rather than shipping abroad at the US rate.
create_print_order
quote_idstringrequired
From quote_print. It fixes the price, the file and every print option, so none of them is passed again here. A quote works once, belongs to one account, and expires 24 hours after it was made.
confirmbooleanrequired
Must be exactly true, and only after the person has seen that price and agreed to be charged.
recipient_namestringrequired
Who the parcel is addressed to.
emailstringrequired
Where the order confirmation goes.
street1stringrequired
Street address.
citystringrequired
City.
statestringrequired
State or province.
zipstringrequired
Postal code.
street2stringoptional
Second address line.
phonestringoptional
Contact number for the carrier.
countrystringrequired
Two-letter ISO code, and it must be the country the quote was priced for. There is deliberately no default: defaulting to US billed US postage on parcels that went abroad, and an agent that forgot the field had no way to find out. A country the quote was not priced for is refused, with the price difference explained, rather than charged at the wrong rate.
notestringoptional
Anything the operator should know, up to 1000 characters.
generate_model
promptstringrequired
What to build, in up to 500 characters.
confirmbooleanrequired
Must be exactly true. Spending a credit needs the person's yes.
qualitystringoptionaldefault draft
draft is fast and cheap; hifi runs the full pipeline.drafthifi
get_order and get_generation
codestringrequired
get_order: the order code. An order belonging to anyone else reports as not found.
job_idstringrequired
get_generation: the id generate_model returned. Free, and safe to poll.

Worked example: quote a file, then order it#

Two tool calls, with a person in between. Written out as curl so you can see exactly what a client sends; in Claude or Cursor you would just ask.

  1. 1
    Quote the file

    quote_print downloads the model, slices it for real, and prices it. Nothing is charged. Note the three Mcp-* headers: Mcp-Name has to equal params.name or the server answers 400.

    bash
    curl -s -X POST https://x3dstudios.com/api/mcp \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -H "Authorization: Bearer $X3D_API_KEY" \
      -H "MCP-Protocol-Version: 2026-07-28" \
      -H "Mcp-Method: tools/call" \
      -H "Mcp-Name: quote_print" \
      -d '{
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {
          "name": "quote_print",
          "arguments": {
            "model_url": "https://raw.githubusercontent.com/acme/parts/main/bracket.stl",
            "material": "pla",
            "color": "Black",
            "quality": "standard",
            "quantity": 1,
            "country": "US"
          },
          "_meta": {
            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
            "io.modelcontextprotocol/clientInfo": { "name": "acme-agent", "version": "1.4.0" },
            "io.modelcontextprotocol/clientCapabilities": {}
          }
        }
      }'
  2. 2
    Read the price out of the result

    content[0].text is the sentence to show a person. structuredContent is the same answer in fields: quote_id, expires_at, the measurement and the price breakdown. price.total is the whole thing including postage to price.ships_to, which is the country you asked for or US if you did not. Ordering to anywhere else needs a fresh quote, so if you do not have the address yet, get it before you quote.

    json
    {
      "quote_id": "q_7f3c1a9e42",
      "expires_at": "2026-09-30T18:22:41.000Z",
      "model": { "file_name": "bracket.stl", "dimensions_mm": [62, 41, 18], "volume_cm3": 21.7 },
      "measurement": { "source": "slicer", "grams": 27.4, "print_hours": 1.31, "layers": 91 },
      "options": { "material": "pla", "color": "Black", "quality": "standard", "quantity": 1, "infill": 20 },
      "price": {
        "currency": "USD",
        "ships_to": "US",
        "print": 3.36,
        "shipping": 7,
        "total": 10.36,
        "billed_grams": 28,
        "note": "This total includes postage to US. create_print_order will only ship to US against this quote."
      }
    }
  3. 3
    Show it to the person and wait

    This is the only confirmation step that exists. Show the total, the material, the colour and the full delivery address as it will be printed, and wait for a yes. Nothing downstream asks again, and there is no cancel call.

  4. 4
    Place the order

    The price, the file and every print option come from the quote, so none of them is repeated. confirm: true is the record that somebody agreed. This charges the card on file.

    bash
    curl -s -X POST https://x3dstudios.com/api/mcp \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -H "Authorization: Bearer $X3D_API_KEY" \
      -H "MCP-Protocol-Version: 2026-07-28" \
      -H "Mcp-Method: tools/call" \
      -H "Mcp-Name: create_print_order" \
      -d '{
        "jsonrpc": "2.0",
        "id": 2,
        "method": "tools/call",
        "params": {
          "name": "create_print_order",
          "arguments": {
            "quote_id": "q_7f3c1a9e42",
            "confirm": true,
            "recipient_name": "Ada Lovelace",
            "email": "[email protected]",
            "street1": "1 Main St",
            "city": "Austin",
            "state": "TX",
            "zip": "78750",
            "country": "US"
          },
          "_meta": {
            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
            "io.modelcontextprotocol/clientInfo": { "name": "acme-agent", "version": "1.4.0" },
            "io.modelcontextprotocol/clientCapabilities": {}
          }
        }
      }'
  5. 5
    Store the order code

    The result carries order_code, paid, amount, charged, status and status_url. Write the code down before doing anything else, and give the person the status URL. Read paid before you tell anyone the order went through: a declined card is a successful call and not an error, because the order row is real and waiting. On that result paid is false, charged is null, amount is what the order is for, and payment_url is where the person pays or updates the card. Do not call create_print_order again and do not quote again. The print is already ordered, and re-ordering would buy it twice.

    json
    {
      "order_code": "X3D-K7M2QP",
      "paid": true,
      "charged": 10.36,
      "amount": 10.36,
      "currency": "USD",
      "status": "RECEIVED",
      "status_url": "https://x3dstudios.com/print/order/X3D-K7M2QP",
      "payment_url": null,
      "quoted_total": 10.36
    }
Do not retry an order on a timeout
create_print_order is marked idempotentHint: false because it is not idempotent. There is no idempotency key, and a retry that succeeds is a second part and a second charge. A quote can only be spent once, which limits the damage to one retry, but the safe move on an ambiguous failure is to stop and call list_my_orders to see whether the order exists.

Rate limits#

Every tool call is counted against an hourly allowance for the caller. A keyed caller is counted on their account, so one key's allowance follows it across machines; an anonymous caller is counted on the address we saw, which is the one thing in the request they cannot choose. The allowance is set by what the call costs us to serve, not by which tool it is.

AllowanceCalls an hourWhich tools
reads300Everything that only reads: list_materials, estimate_price, get_lead_time, track_order, list_my_orders, get_order, get_generation. High enough to be invisible to anything but a loop.
file fetches40check_printability and quote_print. Both pull a file off the internet and slice it, which is CPU bound and the cheapest way to hurt us. quote_print also has its own cap of 30 quotes an hour per account, and its refusal reminds you a quote_id you already hold is good for 24 hours.
credits20generate_model.
money10create_print_order. No honest agent needs to place a hundred orders in an hour.
the endpoint600Every request of every kind, tools/list and ping included, counted before anything else runs. It exists so a caller cannot make us work for free by never calling a tool at all.
A refusal is HTTP 200 and a normal result with isError, naming the seconds to wait. Never a bare 429: most clients hide a 429 from the model, so the agent reports that the integration is broken instead of waiting. And unlike a timeout, "nothing was charged" is literally true here, because the call was refused before the handler ran.

Writing your own client#

Skip this section if you are using Claude or Cursor, which handle all of it. It matters if you are talking to the endpoint directly.

The current MCP revision, 2026-07-28, is stateless. There is no initialize call, no session id and no stream: protocol version, client info and client capabilities ride in params._meta on every request, and server/discover replaces the handshake. Most clients in the field still send initialize instead, so this server answers both and decides which era a request belongs to from the request itself.

What you sendEraWhat comes back
params._meta["io.modelcontextprotocol/protocolVersion"] exactly equal to 2026-07-28modernResults carry resultType: "complete". server/discover works. The Mcp-* headers are required, and so is params._meta["io.modelcontextprotocol/clientCapabilities"].
params._meta["io.modelcontextprotocol/protocolVersion"] naming an older revisionlegacyTaken at its word. Those revisions never defined resultType, so nothing adds one, and no Mcp-* header is required. One sent anyway is still compared against the body.
method: "initialize"legacyprotocolVersion, capabilities and serverInfo, with no resultType on anything afterwards. No Mcp-* header is required, and any that arrives is still compared against the body.
The era is read off each request, and the modern era is an equality test against 2026-07-28, not merely "a version arrived in _meta". Nothing is remembered between requests.

Supported versions: 2026-07-28, the stateless one, plus the handshake revisions 2025-11-25, 2025-06-18 and 2025-03-26. A request that states no version anywhere is treated as 2025-03-26 rather than refused, because the revisions up to 2025-06-18 made the header optional. A version outside that list is a 400 with code -32022 and a data object listing what we do speak.

What a modern request must carry in params._meta
io.modelcontextprotocol/protocolVersionstringrequired
2026-07-28, and it must equal the MCP-Protocol-Version header. Anything else here is read as an older revision and answered as legacy, which means no resultType on the result.
io.modelcontextprotocol/clientCapabilitiesobjectrequired
Required on every modern request, not just the first. Send {} if your client has none. A server must never rely on a capability the client did not declare, and an absent field is indistinguishable from an empty one unless it is refused, so a modern request without it is 400 with code -32602. Notifications are exempt: they are not requests.
io.modelcontextprotocol/clientInfoobjectoptional
Your client's name and version. Not enforced, and worth sending anyway: it is what appears in our logs when something about your calls needs explaining.
Headers a modern request must carry
MCP-Protocol-Versionstringrequired
Must equal the version in params._meta. The point of repeating it is that a proxy can read it without opening the body, so a disagreement is a hard failure rather than a shrug.
Mcp-Methodstringrequired
Must equal the body's method exactly.
(on every era)noteoptional
Only 2026-07-28 makes these headers mandatory, so only a 2026-07-28 request is refused for leaving one out. A header that does arrive is compared against the body whatever era the sender claims, because the proxy this check exists for does not care which revision the sender claims either. The one exemption is initialize: there the body's protocolVersion is a proposal to be negotiated rather than a statement of what is in use, so a client that also sends the header with its own preference is not contradicting itself.
Mcp-Namestringoptional
Required only for the methods that name a subject: params.name for tools/call and prompts/get, params.uri for resources/read. A non-ASCII value arrives wrapped as =?base64?VALUE?= and is decoded before it is compared.
Acceptstringoptional
Send application/json, text/event-stream. This server always answers with a single JSON object, but the spec asks clients to accept either.
StatusCodeWhen
400-32020A required Mcp-* header is missing on a modern request, or any Mcp-* header on any request disagrees with the body.
400-32022A protocol version we do not speak. data carries supported and requested.
400-32600Not a JSON-RPC object, wrong jsonrpc value, no method, or a batch array.
400-32700The body did not parse as JSON.
400-32602tools/call named a tool that does not exist. data.available lists the real names.
400-32602A modern request with no params._meta["io.modelcontextprotocol/clientCapabilities"], or one that is not an object. Send {}.
413-32600A body over 1 MB. The declared Content-Length is refused before a byte is read, and the read is capped as well, so a chunked body that declares nothing gets no further.
429-32600More than 30 unreadable bodies in an hour from one caller. A client with a bug still gets a readable error; something looping on garbage gets cut off.
404-32601A method this server does not implement. We advertise tools only.
403noneAn Origin header that is not on our allowlist. A missing Origin is fine: that is the server-to-server case.
405-32600GET or DELETE. There is no stream to open and no session to delete.
202noneAn accepted notification, meaning a request with no id. The body is empty.
Every response is sent with cache-control: no-store. A cached MCP response would serve one account's order to another.
A failed tool call is a success at the protocol level
A file that will not print, an expired quote, a missing key: these come back as HTTP 200 with a normal result carrying isError: true and the reason in content[0].text, not as a JSON-RPC error. Read the text and fix the argument. JSON-RPC errors are reserved for the protocol itself, and most clients hide them from the model, so a tool that reported a bad dimension that way would just look broken.

Each tool also carries our own facts in _meta on its tools/list entry: com.x3dstudios/cost is free, credits or money, and com.x3dstudios/auth is public or apiKey. MCP has no field for what a call costs, and a consent prompt is exactly where that belongs. Every modern result carries _meta["io.modelcontextprotocol/serverInfo"] as well, because with no handshake there is otherwise no request in which a stateless client learns who answered it.

How long a call gets depends on the work it does, not on its name. A tool that only reads, and generate_model, each get 25 seconds. create_print_order gets 45 seconds, because it is the one call where giving up early is worse than waiting: the expensive half has already committed. check_printability and quote_print get 90 seconds, because they pull a file off the internet and slice it for real, and the download alone is allowed 60. The route itself is allowed 120, so there is always room to send you a real answer instead of your own client timing out with nothing to show. One caveat for a caller coming through our public name: Cloudflare gives up at about 100 seconds, so a worst case slice answers only on the direct service host.

A timeout on a spending call is an unknown, not a failure
We stopped waiting; that does not stop the work. The result says so in as many words and tells you not to call it again. Where it sends you next depends on what the call could spend: create_print_order sends you to list_my_orders, or get_order if you already have a code; generate_model sends you to get_generation with the job id, or to your account page if no id came back, because no order tool can see a generation. Report it to the person as unconfirmed. Saying "it failed" about an order that exists is worse than saying you do not know.