FleetlessFleetlessdocs
Recipes/The MCP Server

Recipe: the MCP server — putting a robot in someone’s AI tool

The two catalogs are fixed lists, and one function produces both the live robot_describe answer and the console’s per-role preview — so what a developer previews is what a model is told, by construction rather than by agreement.

What this is, and what it deliberately is not

There is one MCP server for the whole platform, and no app publishes it. Hand out https://mcp.fleetless.dev — pathless, no client id. https://api.fleetless.dev/mcp is the canonical resource identifier for the same server (it is the base URL a token’s audience is checked against) and works too, but it is the longer thing to type. Anyone in your org can paste either into an AI tool (Claude Desktop, or anything else that speaks MCP), sign in with their email address, and get exactly the tools their roles permit — no more.

No app publishes an MCP endpoint of its own, and no tool is generated per robot. Everyone is offered the same fourteen robot tools, and an org admin is offered seventy-nine console_* tools on top; what a person’s roles permit decides what those tools answer, never which of them appear. The robots a caller reaches are the union across every app they are assigned to — for an org admin, every robot in the org, since admins hold no app assignments. See the MCP concept page for both catalogs and the access rules in full.

Three things the MCP server deliberately does not do, so you don’t go looking for them:

  • No live camera as a tool. A camera becomes one tool: take a snapshot now. Not a persistent video session — that’s what the SDK’s live-3d / camera surfaces are for, in your own app.
  • No MCP “resources” or “prompts”. Only tools. The protocol has more primitives; building them because they exist would be scope nobody asked for.
  • No login of its own. An AI tool connecting to /mcp goes through the same Fleetless-hosted login page as every other sign-in. It is identifier-first: the page asks for an email, resolves it to the person and therefore to their group, and either hands off to that group’s IdP or asks for a Fleetless password. See the hosted-login recipe. One thing that is different for MCP: a brand-new federated user cannot provision here, because at an address-first door there is no user row yet to say which provider to use. They sign into one of your apps once first.

A real constraint for whoever implements the client side of this, named here because right now it exists nowhere else: do not retry a token request while the first one for the same authorization code is still in flight. A second presentation of a code revokes the whole token family by design (RFC 6819 §5.2.1.1, the platform’s standing defence against a stolen code) — and that check cannot tell a benign concurrent retry from an attacker replaying an intercepted code, on purpose, because doing so would make the two indistinguishable. A client that fires the request twice (a timeout-and-retry, a double-submit) gets one live token back and one silently-dead refresh token, and will not find out until that refresh token’s first use fails. It is an accepted cost of the defence, not a bug to work around from this side — the fix is in the client: exactly one in-flight token request per code.

For developers: what you actually configure

There is nothing to turn on, anywhere. The server is always there, and neither the catalog nor any tool in it is switched on per app, per robot or per exposure. Two things decide what a person can do with it, and both of them already existed for other reasons:

  1. Who may reach the server at all — the group flag and the per-user override below.
  2. What their role grants — the slugs on the role, exactly as they gate REST and the SDK. A slug on the role is an exposure robot_describe lists and the robot tools will act on; a slug the role does not grant is not listed, and calling a tool on it is a forbidden naming the robot and the slug. Two role capabilities gate whole tools rather than slugs: action_history for the history half of jobs_list, and assets for assets_list, asset_get and urdf_get. There is no second permission model for MCP, and no MCP-specific field to remember to set.

What you control for reach: each customer group carries an MCP enabled flag, under Settings → Groups → the group, and each user in one carries an override of default, allowed or denied — on that user’s own detail page, reached from Team or Users depending on the group — that wins over the group in both directions. Org admins are always allowed in, whatever either says. Access is re-decided on every call, so switching it off bites at a caller’s next request rather than their next token refresh.

There is no MCP switch in App Settings. Nothing on an app gates or configures MCP; the gate is the group’s mcp_enabled flag and the per-user override above, and nothing else.

Every app does still have an MCP tab, and it is worth using: pick a role in its role selector and it shows, per robot, exactly what robot_describe would answer for that role — the granted exposures, their descriptions and their parameter schemas, produced by the same function the live server calls. Check it before telling anyone the URL. It is where you see an exposure you meant to grant and didn’t, and where a missing description shows up as No description rather than as a silent absence.

How a model uses the catalog

Worth knowing because it is what your descriptions are read inside of. The protocol hands a client a block of instructions once, at initialize, before any tool is called — the only place an ordering can be stated, since a tool description says what one tool does and nothing in a fourteen-tool list says “start here, then that”. What Fleetless sends, in substance:

  1. Start at robots_list, then robot_describe on the robot you want. An empty robots_list is a normal answer and not an error — it means no app assignment of yours carries a robot yet.
  2. robot_describe is the reference for every later call: it lists the datapoints, services, actions, publishers and cameras the role grants, with units and parameter schemas.
  3. Call services with service_call and actions with action_start, args shaped by that schema. A validation_error or parameter_invalid names the field and the rule — consult robot_describe again and retry rather than guessing a second time.
  4. An action that outlives patience_ms comes back as a running job. Poll it with job_get, stop it with job_cancel.
  5. Signed links expire. asset_get and urdf_get return links that need no sign-in and last 15 minutes; fetch them promptly.
  6. For an org admin, additionally: read console_introspection_get before editing a configuration and refresh it while the bridge is online if it is stale; console_config_draft_put replaces the whole fleetless.yaml; publishing pushes it to the robot; and secrets come back once.

Two consequences for how you write. The model is told to re-read robot_describe after a refusal, so a description that explains a precondition converts a retry loop into one corrected call. And the ordering is stated once, in prose the model reads before your text, so you never need to write “call this after X” into a description — say what the thing does and what it costs.

For developers: writing a description a model can actually use

This is the part that is the developer’s job, and it’s a genuinely different skill from naming a slug or picking a ROS type. Here is what the mechanism does with what you type.

Where it lives, and the trade that comes with it

One description per service, in the robot’s own fleetless.yaml — the same place as unit, not a separate MCP-only screen. Every exposed kind takes a description: key: datapoints, actions, services, publishers, cameras, and — separately — each individual parameter of an action/service/publisher.

The cost, stated rather than discovered: because the description lives on the robot’s configuration and not on the app, it is the same text for every app that reaches that robot. If two apps expose the same robot to two different audiences (say, an internal ops tool and a public demo), they share one description. You can’t write “internal jargon is fine” for one and “explain like I’m five” for the other. Write for the least specialized audience you expect any app to have.

A missing description costs you something real, and it is not the tool

A description is recommended and not required. An exposure without one is listed by robot_describe with description: null, and every tool that acts on it works exactly as it would with one. The console’s MCP tab shows it as No description.

This is a deliberate reversal. Until the fixed catalog, a missing description removed the whole tool, silently — the reasoning being that a tool a model cannot understand is worse than no tool. With one catalog for everybody there is no per-slug tool left to withhold, and hiding the exposure instead would have left the developer who granted the slug with no way to see that it had arrived. So nothing is hidden.

What you lose is the only sentence that says what the thing means. The tool works and the model guesses: it sees a slug, a kind, a unit and a parameter schema, and has to infer the rest from a name you chose for yourself. On a read that is a wrong reading of a number. On an action it is a robot doing something a model thought was harmless. Write the description.

What a description should actually say

The model calling a tool on your robot has never seen it. From robot_describe it knows the robot’s name, the slug, the kind, a datapoint’s unit, a parameter’s type and rules — and whatever you write here. Nothing else. So a good description states what the model cannot infer from the slug or the type:

  • What the value or action physically means — not “battery,” but what reading it or calling it does to (or tells you about) the real robot.
  • Units, if they matter and aren’t already covered. A datapoint’s unit travels as a field of its own (see below) — you don’t need to repeat “in percent” if you’ve already set unit: %. Parameters don’t get this for free; say the unit in the parameter’s own description.
  • Side effects and irreversibility, especially for actions. “Drives to a named waypoint” is fine for a mapped floor; “docks the robot, which cannot be interrupted once contact is made” tells a model something it would otherwise only discover by trying it on your actual robot.
  • Preconditions a human operator would know and a model wouldn’t — e.g. a service that only succeeds while a specific mode is active.

Worked examples, by kind:

Kind Where it goes A description worth writing
Datapoint datapoints.<slug>.description Remaining battery charge as a percentage of a full charge. Below 15, the robot will refuse a new docking-departure action. (don’t repeat “in percent” if unit: % is already set — the unit travels on its own)
Action / Service / Publisher actions.<slug>.description, and the same key under services and publishers Drives the robot to a named waypoint on the current map and returns once it arrives or the goal is cancelled. The robot will not move through closed doors — a blocked path times out rather than waiting indefinitely.
Camera cameras.<slug>.description Forward-facing camera mounted above the base, roughly human eye height. Useful for checking what's directly ahead before issuing a drive action.
Parameter description on the parameter itself Target linear speed in m/s. Values above 0.8 are accepted but the robot's own safety controller will cap actual speed near obstacles regardless of what's requested.

A parameter’s description is the one that is easiest to skip and the one a model most often needs: the parameter already carries its type and its enum/min/max/pattern, so those say what the value is, and this is the only place that says what it does.

The sentence that makes a tool usable is also the sentence that goes stale

There’s a real tension in “be concrete.” A sentence that commits to a fact is the useful kind and the perishable kind at once: it earns its place the day it is written and becomes a lie the day the fact changes, with nothing in the system to notice. The vaguer version that would have aged fine helps nobody, so “write it vaguely” is not the answer.

Your service descriptions have exactly the same shape, on a shorter clock. “Drives to the dock at 0.4 m/s” is a sentence a model can act on today and a sentence that becomes a lie the moment someone retunes the robot’s docking speed — and nobody reviews a robot configuration for staleness on any schedule. The reason robot_describe carries unit and every parameter’s min/max/enum as fields of its own, instead of asking you to type them into the description, is precisely this: those numbers are read from the configuration, so they cannot go stale independently of the thing they describe. Your prose can.

So: describe what a service does and what it costs — “docks the robot; cannot be interrupted once contact is made,” “drives to a named waypoint; times out rather than pushing through a blocked path” — and let the datasheet’s own fields carry the numbers. A description that would need editing every time someone retunes a PID gain or changes a speed limit is a description that’s about to be wrong for however long nobody notices.

What travels on its own, so you don’t have to write it

robot_describe answers one entry per granted exposure, and three of its fields come from the configuration without passing through your prose:

  • A datapoint’s unit is its own field on the entry, verbatim. Don’t restate it in the description.
  • Every parameter’s min/max/enum/pattern/required becomes a real JSON Schema constraint in the entry’s input_schema — an enum parameter is offered to the model as an enum, not merely described as one.
  • The kind — datapoint, service, action, publisher, camera — tells the model which tool to reach for, so the description does not have to say “call this with service_call”.

What a camera returns is stated once, in camera_snapshot’s own tool description, rather than appended to every camera you write: you are describing what the camera shows, not how the tool mechanically responds.

For end users: what to paste where

The URL is the same for everyone: https://mcp.fleetless.dev. (https://api.fleetless.dev/mcp is the same server under its canonical name, if a tool insists on a full path.) There is no per-app URL to look up any more — what you do need from your developer is that somebody has given your group MCP access. The URL is not a secret: it alone gets you to a login page, nothing more.

Add it as a remote MCP server in your AI tool. The exact steps depend on the tool — most (Claude Desktop and others speaking the Model Context Protocol’s stateless Streamable HTTP transport, revision 2025-11-25) offer an “Add remote MCP server” or “Add connector” option that asks for a URL and nothing else; the discovery, authorization-server negotiation and token exchange all happen automatically once you provide it. There is no API key to copy and no client-side setup beyond pasting the URL.

If a tool asks about OAuth: auto-register (DCR) is supported, and there is no client id to enter. The server advertises an RFC 7591 registration endpoint, so the tool registers itself. It issues no client secret and refuses a registration that asks for one. A tool that cannot register itself can be configured by hand with the client id fleetless-central-mcp — that fallback is unchanged.

Pasting the URL into a browser by mistake is fine: https://mcp.fleetless.dev answers a person with a short page explaining what the endpoint is, rather than an error.

What happens next:

  1. The AI tool opens a login page — the same Fleetless-hosted page you’d see signing into an app directly. It asks for your email address first and works out the rest from it: if your group has an identity provider you are handed off to it, otherwise you get Fleetless’s own password step.
  2. A tool that registered itself (RFC 7591, the common case) shows you a consent screen naming the client before anything is issued. It is asked every time that client connects, and nothing about it is remembered — one question per connection, rather than a stored grant nobody can withdraw. The well-known fleetless-central-mcp client is Fleetless’s own and skips it.
  3. Back in your AI tool, the tools appear: fourteen for a robot, plus the console_* family if you are an org admin.

The tool list is the same for everyone, so a tool being there is not a promise that it will work. What your roles permit decides what the tools answer. If robots_list comes back empty, no app assignment of yours carries a robot yet. If robot_describe does not list something you expected, that slug is not on your role — ask whoever administers the app. A refusal names the robot and the slug, so there is nothing to mistake for “the platform is broken”.

The MCP endpoint accepts an OAuth access token bound to the MCP resource, and nothing else. A first-party /api/client/login session token is completely valid — for the REST API — and gets a flat 401 here. That is not a bug; it is RFC 8707 doing its job, refusing a token whose aud was never the MCP resource. There is no shortcut around the login flow above to reach this endpoint, by design.