# 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.

Source: https://x3dstudios.com/docs/api/mcp  ·  Updated: 2026-11-08

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**

```text
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**

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

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

> **Never commit the key itself** — A project file is committed, and a key in it is then in your git history and on every machine that clones the repo, where rotating it is the only fix. Commit the URL and nothing else, and let each person add their own key in their own user-level config. If your client expands environment variables, ${X3D_API_KEY} in the header is the safer spelling.

**Claude Code (.mcp.json)**

```json
{
  "mcpServers": {
    "x3d": {
      "type": "http",
      "url": "https://x3dstudios.com/api/mcp",
      "headers": {
        "Authorization": "Bearer x3d_live_YOUR_KEY_HERE"
      }
    }
  }
}
```

**Cursor (.cursor/mcp.json)**

```json
{
  "mcpServers": {
    "x3d": {
      "url": "https://x3dstudios.com/api/mcp",
      "headers": {
        "Authorization": "Bearer x3d_live_YOUR_KEY_HERE"
      }
    }
  }
}
```

- 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/mcp`

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

Auth: public

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**

```text
Authorization: Bearer x3d_live_YOUR_KEY_HERE
```

- 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, the website's two print speeds 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. |

*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.

Its shipping object carries domestic (standard and express, with rate, transit and freeOver), international (rate, freeOver and the list of countries), handlingDaysBeforeDispatch, and printSpeeds: normal and priority, each with its fee and shipsInBusinessDays. The print speeds describe the website checkout, where Priority print is 10% of the items, at least $10. An order placed through create_print_order cannot buy it and is queued as Normal print, so quote the normal figure, or better, call get_lead_time for the live one.

**estimate_price**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `grams` | number | yes | Filament weight of ONE part, not the batch. 0.1 to 50000. |
| `material` | string (pla \\| petg \\| abs \\| asa \\| tpu) | no, default `pla` | Stock material. |
| `quality` | string (standard \\| premium) | no, default `standard` | Layer height: standard is 0.2mm layers; premium is 0.1mm and costs 1.2x the per-gram rate. |
| `nozzle` | string (standard \\| premium) | no, default `standard` | Nozzle size: standard is 0.4mm; premium is 0.2mm and costs 2.2x the per-gram rate, 2.5x with premium layers. |
| `quantity` | integer | no, default `1` | 1 to 10000. This tool is arithmetic, so it will price a run far larger than one order accepts. |
| `color` | string | no | Colour name or hex. A colour we do not stock is $0.20/g: call list_materials for the shelf. |

**check_printability**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `model_url` | string | yes | 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**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `grams` | number | no | Weight of one part. Heavier parts hold a printer longer. Omit it and you get the farm's standard order. |
| `quantity` | integer | no, default `1` | Parts in one order. |

**track_order**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `order_code` | string | yes | An order number such as 2610014 (or #2610014) from the website, X3D-XXXXXXXX from the print API or /farm/submit, or an older CART-, ORD- or STORE- code from the web checkout. |
| `email` | string | yes | The email the order was placed with, or one of the addresses the order copies on its emails. Both are required: a code with the wrong email is deliberately indistinguishable from a code that does not exist. After ten lookups that find nothing for one email in an hour, the tool answers not found without looking, unless your key belongs to that email. |

> **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. The stage is one of placed, queued, printed, shipped, delivered, picked_up (an order the customer collected) or cancelled. A print API order at ACCEPTED or PRINTING reads as queued, and one that was REJECTED as cancelled.

### Account tools

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

**quote_print**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `model_url` | string | yes | 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. |
| `material` | string (pla \\| petg \\| abs \\| asa \\| tpu) | no, default `pla` | Stock material. |
| `color` | string | no | Filament colour. An off-palette colour is $0.20/g. |
| `quality` | string (standard \\| premium) | no, default `standard` | Layer height: standard is 0.2mm layers; premium is 0.1mm (1.2x). |
| `nozzle` | string (standard \\| premium) | no, default `standard` | Nozzle size: standard is 0.4mm; premium is 0.2mm (2.2x, 2.5x with premium layers). A sliced file prints on its own nozzle at the standard price. |
| `quantity` | integer | no, default `1` | 1 to 50, which is the cap on one order. |
| `infill` | integer | no, default `20` | 10 to 100 percent. |
| `country` | string | no, default `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**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `quote_id` | string | yes | 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. |
| `confirm` | boolean | yes | Must be exactly true, and only after the person has seen that price and agreed to be charged. |
| `recipient_name` | string | yes | Who the parcel is addressed to. |
| `email` | string | yes | The order's main contact, one address. It gets the confirmation and every update after it: printed, shipped with tracking, and delivered. |
| `notify_emails` | string[] | no | Up to 4 more addresses, one per entry, that get a copy of every email about the order. Use it when someone besides the main contact is waiting on the part. Repeats and the address already in email are dropped. An entry that is not a valid address, or more than 4 entries, is refused before anything is charged. |
| `street1` | string | yes | Street address. |
| `city` | string | yes | City. |
| `state` | string | yes | State or province. |
| `zip` | string | yes | Postal code. |
| `street2` | string | no | Second address line. |
| `phone` | string | no | Contact number for the carrier. |
| `country` | string | yes | 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. |
| `note` | string | no | Anything the operator should know, up to 1000 characters. |

**generate_model**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `prompt` | string | yes | What to build, in up to 500 characters. |
| `confirm` | boolean | yes | Must be exactly true. Spending a credit needs the person's yes. |
| `quality` | string (draft \\| hifi) | no, default `draft` | draft is fast and cheap; hifi runs the full pipeline. |

**get_order and get_generation**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `code` | string | yes | get_order: the order code. An order belonging to anyone else reports as not found. |
| `job_id` | string | yes | 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. **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. **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. A .gcode.3mf whose plate uses five or more filaments also carries price.second_ams_fee, 25, once per order and already inside price.total. Postage is always the standard service: the MCP server does not sell express shipping or Priority print.

```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. **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. **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. Here ada@example.com is the main contact and sam@example.com gets a copy of every email about the order. Leave notify_emails out when only one person needs them.

```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": "ada@example.com",
        "notify_emails": ["sam@example.com"],
        "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. **Store the order code** — The result carries order_code, paid, amount, charged, status, status_url and notify_emails, the addresses copied on the order emails. 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 charge_outcome says what is known about the money. When it is "not-charged" the card was provably not charged, and payment_url is where the person pays or updates the card. When it is "unknown" the charge may have gone through with only its confirmation lost: payment_url is null, and you check status_url or get_order before saying anything about the money. Either way, 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,
  "notify_emails": ["sam@example.com"]
}
```

> **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.

| 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. |

*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 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. |

*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**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `io.modelcontextprotocol/protocolVersion` | string | yes | 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/clientCapabilities` | object | yes | 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/clientInfo` | object | no | 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**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `MCP-Protocol-Version` | string | yes | 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-Method` | string | yes | Must equal the body's method exactly. |
| `(on every era)` | note | no | 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-Name` | string | no | 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. |
| `Accept` | string | no | 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. |

*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.

- [Authentication](https://x3dstudios.com/docs/api/authentication) — Where the x3d_live_ key comes from, how to rotate it, and what happens when one is lost.
- [AI agents](https://x3dstudios.com/docs/integrations/agents) — The same job over the REST API, for a tool-calling loop you wrote yourself.
- [Pricing model](https://x3dstudios.com/docs/pricing) — The rate card the quote comes out of: per-gram rates, quality factors, minimums and bulk tiers.
- [Order status](https://x3dstudios.com/docs/api/order-status) — The nine stages an order moves through and how tracking appears.

[Get an API key](https://x3dstudios.com/profile)

## Frequently asked

**Does X3D Studios have an MCP server?**

Yes. POST https://x3dstudios.com/api/mcp. It is a Streamable HTTP MCP server on one endpoint, POST only, and it answers both the stateless 2026-07-28 revision and the older initialize handshake that most clients still send.

**Do I need an API key to use the X3D MCP server?**

Not to start. Materials and rates, price arithmetic on a weight you already know, whether a file will print, the current lead time and looking up an order by its code all answer with no key at all. A real quote needs one, because it downloads the model and slices it to weigh it, and so does anything that reads or changes one account's orders, models or credits: an x3d_live_ key sent as Authorization: Bearer, on an account with a card on file.

**Can a tool call charge my card without asking?**

Ordering takes two calls, and the second one needs a quote id from the first plus confirm set to true. There is no single call that goes from a file to a charge, and the ordering tool is marked destructive so a well-behaved client asks a person first.

**Can I add the X3D MCP server to claude.ai as a connector?**

You can add the URL, and the public tools work. The custom connector form asks for a server URL and optional OAuth credentials, not an arbitrary header, so there is nowhere to put an x3d_live_ key and the account tools stay out of reach. Use Claude Code or Cursor for those: both send headers.

**Is there a sandbox for the MCP server?**

No. There is one key prefix, x3d_live_, and one farm. An order placed through a tool call is a real order on a real printer charged to a real card. Quoting costs nothing, so quote as often as the hourly cap allows; only the order spends.
