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.
POST https://x3dstudios.com/api/mcpOne 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.
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.
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#
Authentication#
/api/mcpNo authThe 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.
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.
| Tool | Key | Cost | What it does |
|---|---|---|---|
| list_materials | no | free | Every 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_price | no | free | Arithmetic on a weight you already know. Grams, material, quality, quantity in, a full breakdown out. It never looks at a model. |
| check_printability | no | free | Downloads a mesh and measures it: dimensions, watertightness, holes, non-manifold and degenerate geometry, plate fit, shallowest overhang. Keeps nothing. |
| get_lead_time | no | free | How soon work placed now would ship, from the state of the fleet this minute, for 1, 10, 100 and 1000 orders. |
| track_order | no | free | The stage and tracking number of an existing order, given its code and the email it was placed with. |
| quote_print | yes | free | The 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_order | yes | money | Places the order and charges the card on file. Needs a quote_id and confirm: true. |
| list_my_orders | yes | free | Up to 20 of this account's most recent orders, newest first, from every X3D order store. |
| get_order | yes | free | One order on this account by its code. |
| generate_model | yes | credits | Turns a text prompt into a printable model. Spends one AI generation credit and needs confirm: true. Asynchronous: returns a job_id. |
| get_generation | yes | free | Progress of a generate_model job, and when it is done the model_url to hand to quote_print plus the printability verdict. |
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.
gramsnumberrequired- Filament weight of ONE part, not the batch. 0.1 to 50000.
materialstringoptionaldefaultpla- Stock material.
plapetgabsasatpu qualitystringoptionaldefaultstandard- standard is 0.2mm layers; premium is 0.1mm and costs 1.75x the per-gram rate.
standardpremium quantityintegeroptionaldefault1- 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.
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.
gramsnumberoptional- Weight of one part. Heavier parts hold a printer longer. Omit it and you get the farm's standard order.
quantityintegeroptionaldefault1- Parts in one 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.
Account tools#
Every tool in this group needs the Authorization header. list_my_orders takes no arguments; the rest are below.
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.
materialstringoptionaldefaultpla- Stock material.
plapetgabsasatpu colorstringoptional- Filament colour. Off-palette colours cost more per gram.
qualitystringoptionaldefaultstandard- standard is 0.2mm layers; premium is 0.1mm.
standardpremium quantityintegeroptionaldefault1- 1 to 50, which is the cap on one order.
infillintegeroptionaldefault20- 10 to 100 percent.
countrystringoptionaldefaultUS- 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.
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.
promptstringrequired- What to build, in up to 500 characters.
confirmbooleanrequired- Must be exactly true. Spending a credit needs the person's yes.
qualitystringoptionaldefaultdraft- draft is fast and cheap; hifi runs the full pipeline.
drafthifi
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.
- 1Quote 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.
bashcurl -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": {} } } }' - 2Read 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." } } - 3Show 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.
- 4Place 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.
bashcurl -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": {} } } }' - 5Store 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 }
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.
| Allowance | Calls an hour | Which tools |
|---|---|---|
| reads | 300 | Everything 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 fetches | 40 | check_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. |
| credits | 20 | generate_model. |
| money | 10 | create_print_order. No honest agent needs to place a hundred orders in an hour. |
| the endpoint | 600 | Every 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. |
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 send | Era | What comes back |
|---|---|---|
| params._meta["io.modelcontextprotocol/protocolVersion"] exactly equal to 2026-07-28 | modern | Results 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 revision | legacy | Taken 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" | legacy | protocolVersion, capabilities and serverInfo, with no resultType on anything afterwards. No Mcp-* header is required, and any that arrives is still compared against the body. |
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.
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.
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.
| Status | Code | When |
|---|---|---|
| 400 | -32020 | A required Mcp-* header is missing on a modern request, or any Mcp-* header on any request disagrees with the body. |
| 400 | -32022 | A protocol version we do not speak. data carries supported and requested. |
| 400 | -32600 | Not a JSON-RPC object, wrong jsonrpc value, no method, or a batch array. |
| 400 | -32700 | The body did not parse as JSON. |
| 400 | -32602 | tools/call named a tool that does not exist. data.available lists the real names. |
| 400 | -32602 | A modern request with no params._meta["io.modelcontextprotocol/clientCapabilities"], or one that is not an object. Send {}. |
| 413 | -32600 | A 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 | -32600 | More 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 | -32601 | A method this server does not implement. We advertise tools only. |
| 403 | none | An Origin header that is not on our allowlist. A missing Origin is fine: that is the server-to-server case. |
| 405 | -32600 | GET or DELETE. There is no stream to open and no session to delete. |
| 202 | none | An accepted notification, meaning a request with no id. The body is empty. |
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.
Where the x3d_live_ key comes from, how to rotate it, and what happens when one is lost.
The same job over the REST API, for a tool-calling loop you wrote yourself.
The rate card the quote comes out of: per-gram rates, quality factors, minimums and bulk tiers.
The nine stages an order moves through and how tracking appears.