Connect to Litmus over MCP
How to let your users connect their Litmus account to your product, and call Litmus's tools on their behalf over the Model Context Protocol.
Calling the MCP server
You've already done the OAuth work in steps A–D — your backend holds a real access token for this user. The snippets below just attach it as a bearer header — no auth machinery needed here.
Python — fastmcpShow codeHide code
from fastmcp import Client
from fastmcp.client.auth import BearerAuth
async with Client(
"https://api.litmus.science/mcp",
auth=BearerAuth(access_token),
) as client:
result = await client.call_tool("whoami", {})
print(result)TypeScript — @modelcontextprotocol/sdkShow codeHide code
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport }
from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const transport = new StreamableHTTPClientTransport(
new URL("https://api.litmus.science/mcp"),
{ requestInit: { headers: { Authorization: `Bearer ${accessToken}` } } },
);
const client = new Client({ name: "your-app", version: "1.0.0" });
await client.connect(transport);
const result = await client.callTool({ name: "whoami", arguments: {} });
console.log(result);We recommend fastmcp over the lower-level official mcp Python package — it wraps the same protocol with a much smaller surface (BearerAuth above is one line; the raw SDK wants you to wire up a streaming transport by hand). On the TypeScript side, @modelcontextprotocol/ sdk is the one to use — there isn't an equivalent higher-level wrapper with the same ecosystem weight behind it.
A 401 from either snippet means the access token expired — that's step D: refresh it, or if the refresh fails, send the user back through step A, then reconnect with the new token. Neither snippet retries that for you.
Server instructions
Your agent receives this text in the initialize response, before it calls any tool. It's shown here exactly as the server sends it.
Litmus gets laboratory experiments run by external labs. A user describes what they want to find out; Litmus matches it to the assays it offers, works out what information a lab needs to run them, and once that is complete takes the request and handles lab matching from there.
Use this server when the user wants an experiment run, outsourced, sent to a lab or CRO, or quoted. Litmus covers protein production and characterization, binding and target engagement, immunoassays, cell-based and phenotypic assays, nucleic acid and molecular biology, cell-line QC, biochemical and ADME assays, structural biology, in-vivo pharmacology, gene and cell therapy, omics and mass spectrometry, flow cytometry, and histology and imaging. The user does not need to know Litmus's terms or name an assay: their own description of the experiment is the input.
How to use it: 1. Call create_order_request with the objective in the user's own words, plus anything that constrains the work (the molecule or system, material in hand, timelines, prior results) as context. Litmus suggests the assays the objective needs. 2. Show the user the suggested assays, with each one's purpose and typical market pricing (an estimate, not a quote: labs quote after submission; give each amount with its unit, such as per sample), and ask which to keep. Don't ask about the assays' requirements yet: Litmus lists them once the assays are confirmed, with what the user already said filled in. Send the ones they keep as confirm_assays with update_order_request; the rest are dropped. The assays are confirmed once: sending the same confirm_assays again changes nothing, and to change them after that, start a new order request. 3. Read readiness.missing on the response: each entry is one thing still needed, an order field, an assay's lab location, budget or turnaround, or an assay's requirement. Litmus has already filled in whatever the user said. Ask the user for each rather than inventing or assuming an answer, then send the answers with update_order_request. "No constraint" and "I don't know" are answers too. Repeat until status is "submittable". 4. Summarize the request for the user and confirm before calling submit_order_request. 5. After submitting, tell the user what happens next. They should receive a copy of the request by email. Litmus reviews it and asks labs that can run it for quotes. Litmus emails the user when the first quote arrives; on the order request's page (url) they compare the labs' quotes and pick a lab, then pay and sign the standard agreements so the work can start. How soon quotes arrive depends on the labs, so don't promise a date, and don't present an assay's pricing as what the work will cost. A budget below what labs quote doesn't stop the order request: it still goes through lab matching and quoting, and if no lab can do the work within the budget, the user can decide whether to raise it.
To pick up an order request later, call get_order_request with its order_request_id.
Order request lifecycle
An order request is a draft your agent builds up on the user's behalf until Litmus has everything a lab needs, then submits.
Create it.
Call create_order_request with the user's objective in their own words, plus anything that constrains the work (the molecule or system, material in hand, timelines, prior results) as context. Litmus suggests the assays the objective needs and returns the order request. Store the order_request_id, or pass a conversation_key of your own so a retried call returns the same order request instead of creating another.
Confirm the assays with the user.
Show the user the suggested assays, with each one's purpose and pricing, and ask which to keep. pricing is typical market pricing, an estimate to help the user plan: not a quote, and separate from the budget they set. Give each amount with its unit. Send the ids of the ones they keep as confirm_assays with update_order_request; the rest are dropped. Until then no assay carries its requirements, and readiness.missing lists the order fields and the assays' confirmation only, so don't ask the user about requirements yet.
Fill in what's missing.
Once the assays are confirmed, readiness.missing lists each thing still needed: an order field, an assay's lab_location, budget or max_turnaround, or an assay's requirement, by its key. Litmus has already filled in whatever the user said. Ask the user for each rather than inventing or assuming an answer, and send the answers with update_order_request, as many as you have in one call. “No constraint” and “I don't know” are answers too. Repeat until status is "submittable".
Check with the user, then submit it.
Summarize the order request for the user and get their confirmation, then call submit_order_request. status: "submitted" means Litmus has the order and is matching it to labs, and url now opens the order request's page. The server instructions say what to tell the user happens next.
- A refusal is not an error. Submitting an order request that isn't ready succeeds and returns it unchanged with
status: "draft";readiness.missinglists exactly what's still needed. Checkstatuson every response rather than waiting for an exception. - The assays are confirmed once. After
confirm_assays, no assay can be added or removed: to change them, start a new order request. Sending the sameconfirm_assaysagain changes nothing, so a retried call is safe; naming a different set is refused. Before it, an assay's constraints and requirements can't be set. Oneupdate_order_requestcall can confirm the assays and answer for them: the confirmation is applied first. - What you send is what Litmus stores. Every value is stored as given, normalised, never re-assessed: an order request only changes when a tool call changes it.
- An update applies its changes in order. If
update_order_requestrefuses one change, the error says why; the changes before it in that call are saved and the ones after it are not applied. Read the current state withget_order_requestbefore sending the rest again. - Retrying an update is safe. Sending the same
update_order_requestcall again leaves the order request as the first call left it: confirming the same assays, setting a value it already has, adding a requirement with the same key and label as one the assay has, and removing a requirement that is already gone each change nothing. Adding a requirement with a different label under a key the assay already has is still refused. - Re-submitting is a no-op. Calling
submit_order_requeston an order request that was already submitted returns its current state. It doesn't create a second order. - Retrying a create is safe with a key. A
create_order_requestcall with aconversation_keyyou already used returns the order request created under it, as it is now, instead of creating another. A key is unique per integration and user: the same key from another integration is a different order request. - Your agent resumes by id; the user resumes by link. To pick an order request back up, call
get_order_requestwith theorder_request_idyou stored.urlis for the human, and its target followsstatus: while the order request is a draft, it opens the draft in Litmus so they can review, complete and submit it themselves. Once it is submitted, it opens the order request's page, where they follow its progress, compare the labs' quotes and pick a lab, then pay and sign the standard agreements. - A submit that returns an error is safe to retry. If
submit_order_requestreturns a tool error, call it again. An order request that wasn't submitted is submitted afresh, and one that was returns its current state. While an order request is still being submitted, the call returns an error saying so; call it again for the outcome. - A failure on Litmus's side says whether to retry. If a call fails because of a temporary problem on Litmus's side, the tool error says so and says whether retrying that tool is safe. For
create_order_request, a retry is safe with the sameconversation_key; without one, a retry may create a second order request. - Only drafts can be changed. Once an order request is submitted,
update_order_requestreturns an error. - Only the connected user's own order requests. Every tool that takes an
order_request_idreaches an order request only if it belongs to the user who authorized this connection. Anorder_request_idthat belongs to anyone else returns the same not-found error as one that doesn't exist.
Tool reference
Every tool Litmus's MCP server lists, generated from the server's own tools/list. Your agent sees the same descriptions.
Identify this connection
Permission whoami — granted on connect
Identify this connection: the integration the access token was issued to, the Litmus user who authorized it, and the token's scopes. Takes no input. Granted automatically when the user connects.
Returns
integrator_idLitmus's id for your integration.
integrator_nameYour integration's name, as registered with Litmus.
client_idThe OAuth client id this access token was issued to.
scopesThe OAuth scopes this access token carries.
user_idThe Litmus user who authorized this connection.
user_emailThat user's email address.
user_nameThat user's name, if they have set one.
Start an experiment request
Permission order_requests — consent screen: “Create and submit order requests for you”
Start here when a user wants an experiment run by a lab: getting an assay or study done, outsourcing work to a CRO, or getting it quoted. Pass what they are trying to find out in their own words; they do not need to name an assay.
Litmus suggests the assays the objective needs and returns the new draft order request. Show the user the suggested assays and confirm the ones they keep with update_order_request's confirm_assays. Each assay's pricing is typical market pricing, an estimate to help the user plan: not a quote, and separate from the budget the user sets. Give each amount with its unit. Each assay's requirements are listed once the assays are confirmed, with whatever the objective or context already states filled in, so don't ask the user about them before then. readiness.missing lists what is still needed.
Keep the returned order_request_id: every other order request tool takes it. To make the call safe to retry, pass a conversation_key of your own: a call with a key you already used returns the order request created under it, as it is now, instead of creating another. A key is unique per integration and user. Without a key, every call creates a new order request.
Input
objectiveWhat the caller is trying to find out, in their own words - a goal, a hypothesis, or a protocol. Text only.
Up to 20,000 characters.
contextAnything else that constrains the work: the molecule or system, material already in hand, timelines, prior results. Text only.
Up to 20,000 characters.
conversation_keyOptional key of your own, such as your conversation's id, that makes this call safe to retry: a call with a key you already used returns the order request created under it instead of creating another. A key is unique per integration and user: the same key from another integration, or for another user, is a different order request.
Returns
Check an experiment request
Permission order_requests — consent screen: “Create and submit order requests for you”
Read an order request's current state by its order_request_id, without changing anything. This is how you resume an order request you created earlier.
Only the connected user's own order requests can be reached. An order_request_id that belongs to anyone else returns the same not-found error as one that does not exist.
Input
order_request_idThe order request's id, as returned by an earlier call.
Returns
Change the details of an experiment request
Permission order_requests — consent screen: “Create and submit order requests for you”
Fill in or change what a draft order request says. The answers come from the user: ask them for each entry in readiness.missing rather than guessing or inventing one. Every value is stored as given, normalised, never re-assessed.
First confirm the assays: confirm_assays lists the ids of the suggested assays the user keeps, and every other assay is dropped. The assays are confirmed once, and no assay can be added or removed after that: to change them, start a new order request. Sending the same confirm_assays again changes nothing, so a retried call is safe; naming a different set is refused. Before the assays are confirmed, an assay's constraints and requirements can't be set.
Set any of the order's title, objective and requester_country at any time, and, once the assays are confirmed, for each assay you list by its assay_id from the latest response, any of its lab_location, budget and max_turnaround, answers to its requirements (set_requirements, by key), new requirements (add_requirements) and requirements to drop (remove_requirements). Omit whatever you are not changing. A constraint the user has none on is kind 'no_constraint', and one they do not know is kind 'unknown'; a requirement answered 'lab_standard', 'not_applicable' or 'unknown' is no longer missing. A requirement marked shared is the same material on every assay that has it: ask for it once, and answering it on one assay fills it in on the others where it is still missing.
Changes are applied in this order: the order's fields, the assays' confirmation, then for each assay its constraints, removed requirements, added requirements and answered requirements, so one call can confirm the assays and answer for them. If one is refused, the call returns an error saying why: the changes before it are saved and the ones after it are not applied.
Retrying a call is safe: sending the same changes again leaves the order request as the first call left it. Confirming the same assays, setting a value it already has, adding a requirement with the same key and label as one the assay has, and removing a requirement that is already gone each change nothing.
Only a draft can be changed: an order request that has been submitted returns an error. Only the connected user's own order requests can be reached. An order_request_id that belongs to anyone else returns the same not-found error as one that does not exist.
Input
order_request_idThe order request's id, as returned by an earlier call.
titleA short title for the order request.
objectiveWhat the requester is trying to find out, in their own words.
requester_countryWhere the requester is based.
requester_country. kindOne of:specifiedunknown
requester_country. countryISO 3166-1 alpha-2 country code, e.g. US, GB, DE.
confirm_assaysThe ids of the suggested assays the user keeps, from the latest response. Every other assay is dropped. Sent once, before any change to an assay; sending the same ids again changes nothing, and a different set is refused.
assaysChanges to the order request's assays, one entry per assay, once the assays are confirmed.
assays[]. assay_idThe id of the assay to change, from the latest response.
assays[]. lab_locationWhere the lab must be.
assays[]. lab_location. kindOne of:specifiedno_constraintunknown
assays[]. lab_location. scopeOne of:countryregion
assays[]. lab_location. nameFor scope 'country': an ISO 3166-1 alpha-2 code. For scope 'region': the region or bloc name, e.g. European Union.
assays[]. budgetThe USD budget for this assay.
assays[]. budget. kindOne of:specifiedno_constraintunknown
assays[]. budget. min_usdWhole US dollars; 0 when only a maximum was given.
assays[]. budget. max_usdWhole US dollars.
assays[]. max_turnaroundThe longest acceptable turnaround.
assays[]. max_turnaround. kindOne of:specifiedno_constraintunknown
assays[]. max_turnaround. daysWhole calendar days, at least 1.
assays[]. remove_requirementsKeys of requirements to remove from this assay. A key the assay has no requirement under changes nothing, so a retried removal is safe.
assays[]. add_requirementsNew requirements for this assay. One with the same key and label as a requirement the assay already has changes nothing, so a retried addition is safe; one with a different label under a key the assay already has is refused. A requirement added here is never shared: to answer a shared requirement, answer it once with set_requirements.
assays[]. add_requirements[]. keyShort snake_case id, unique within its assay. Design fields use these fixed keys and labels: top_concentration = 'Top concentration'; concentration_points = 'Concentration points'; dilution_factor = 'Dilution factor'; replicates = 'Replicates (technical / biological)'; scientist_supplied_controls = 'Scientist-supplied controls'; lab_standard_controls = 'Lab standard controls'; timepoints = 'Timepoints'; endpoint_readout = 'Endpoint / readout'.
assays[]. add_requirements[]. labelWhat is needed, in plain language.
assays[]. add_requirements[]. valueThe supplied value when status is 'provided', otherwise null.
assays[]. add_requirements[]. statusOne of:providedlab_standardnot_applicableunknownmissing
assays[]. set_requirementsAnswers to requirements the assay already has. A shared requirement is asked once: its answer fills in the same requirement on the other assays where it is still missing.
assays[]. set_requirements[]. keyThe requirement's key, from the assay's requirements.
assays[]. set_requirements[]. status'provided' with the value the user gave; 'lab_standard' when the user leaves the choice to the lab; 'not_applicable' when it does not apply; 'unknown' when the user does not know; 'missing' to mark it as still needed.
One of:providedlab_standardnot_applicableunknownmissing
assays[]. set_requirements[]. valueThe user's answer, exactly as they gave it, when status is 'provided'; otherwise omitted or null.
Returns
Submit an experiment request to Litmus
Permission order_requests — consent screen: “Create and submit order requests for you”
Submit an order request to Litmus for lab matching once its status is "submittable". Confirm with the user before calling this: it sends their request to Litmus.
A refusal is not an error. If anything is still missing, the call succeeds and returns the order request unchanged with status "draft"; readiness.missing lists exactly what is still needed. Check status on every response rather than relying on the call raising.
"submitted" means Litmus received the order. Submitting an order request that was already submitted is a no-op that returns its current state and does not submit it again. If the call returns an error, it is safe to call submit_order_request again: an order request that was not submitted is submitted afresh, and one that was returns its current state. While an order request is still being submitted, the call returns an error saying so; call it again for the outcome.
After submitting, tell the user what happens next. They should receive a copy of the request by email. Litmus reviews it and asks labs that can run it for quotes. Litmus emails the user when the first quote arrives; on the order request's page (url) they compare the labs' quotes and pick a lab, then pay and sign the standard agreements so the work can start. How soon quotes arrive depends on the labs, so don't promise a date, and don't present an assay's pricing as what the work will cost. A budget below what labs quote doesn't stop the order request: it still goes through lab matching and quoting, and if no lab can do the work within the budget, the user can decide whether to raise it.
Only the connected user's own order requests can be reached. An order_request_id that belongs to anyone else returns the same not-found error as one that does not exist.
Input
order_request_idThe order request's id, as returned by an earlier call.
Returns
The order request object
What create_order_request, get_order_request, update_order_request, and submit_order_request all return.
order_request_idThe order request's id. Keep it: every other order request tool takes it, and calling get_order_request with it is how you resume this order request later.
statusWhere the order request is in its lifecycle. 'draft': something is still missing (see readiness.missing). 'submittable': nothing is missing, so submit_order_request will accept it. 'submitted': Litmus received the order for lab matching; it can no longer be changed.
One of:draftsubmittablesubmitted
orderThe order request's content.
order. titleA short title for the order request; null until set.
order. objectiveWhat the requester is trying to find out; null until set.
order. requester_countryWhere the requester is based; null until asked.
order. requester_country. kindOne of:specifiedunknown
order. requester_country. countryISO 3166-1 alpha-2 country code, e.g. US, GB, DE.
order. assaysThe assays this order request covers.
order. assays[]. idThe assay's id. Pass it to update_order_request in confirm_assays, and as assay_id to change the assay once the assays are confirmed.
order. assays[]. nameorder. assays[]. purposeorder. assays[]. lab_locationWhere the lab must be; null until asked.
order. assays[]. lab_location. kindOne of:specifiedno_constraintunknown
order. assays[]. lab_location. scopeOne of:countryregion
order. assays[]. lab_location. nameFor scope 'country': an ISO 3166-1 alpha-2 code. For scope 'region': the region or bloc name, e.g. European Union.
order. assays[]. budgetUSD budget; null until asked.
order. assays[]. budget. kindOne of:specifiedno_constraintunknown
order. assays[]. budget. min_usdWhole US dollars; 0 when only a maximum was given.
order. assays[]. budget. max_usdWhole US dollars.
order. assays[]. max_turnaroundLongest acceptable turnaround; null until asked.
order. assays[]. max_turnaround. kindOne of:specifiedno_constraintunknown
order. assays[]. max_turnaround. daysWhole calendar days, at least 1.
order. assays[]. requirementsWhat a lab needs to know to run this assay, each with its answer so far. Empty until the assays are confirmed: confirming them lists them, with whatever the objective and context already answered filled in.
order. assays[]. requirements[]. keyShort snake_case id, unique within its assay. Design fields use these fixed keys and labels: top_concentration = 'Top concentration'; concentration_points = 'Concentration points'; dilution_factor = 'Dilution factor'; replicates = 'Replicates (technical / biological)'; scientist_supplied_controls = 'Scientist-supplied controls'; lab_standard_controls = 'Lab standard controls'; timepoints = 'Timepoints'; endpoint_readout = 'Endpoint / readout'.
order. assays[]. requirements[]. labelWhat is needed, in plain language.
order. assays[]. requirements[]. valueThe supplied value when status is 'provided', otherwise null.
order. assays[]. requirements[]. statusOne of:providedlab_standardnot_applicableunknownmissing
order. assays[]. requirements[]. sharedWhether this is the same material as the requirement with the same key on the order request's other assays. Ask for it once: answering it on one assay fills it in on the others where it is still missing.
order. assays[]. flagsReview notes for the lab that requirement statuses cannot express, e.g. non-natural amino acids, GMP. Empty when there are none.
order. assays[]. pricingTypical market pricing for this assay, an estimate to help the user plan. It is not a quote or a firm price, even when basis is 'fixed': labs quote once the order request is submitted, and their quotes can differ. Separate from the assay's budget, which is what the user is prepared to spend.
order. assays[]. pricing. basisOne of:fixedestimatequote_on_request
order. assays[]. pricing. unitWhat one price covers, e.g. per sample. Give it with every amount you quote from this pricing.
order. assays[]. pricing. min_usdWhole US dollars.
order. assays[]. pricing. max_usdWhole US dollars.
order. assays[]. pricing. estimated_turnaround_daysLitmus's estimated turnaround, in calendar days.
order. assays_confirmedWhether the user has confirmed the assays. Until then the assays are Litmus's suggestion: confirm the ones the user keeps with update_order_request's confirm_assays. Once confirmed, they can't be changed.
readinessWhat is still missing before the order request can be submitted. Each entry in missing names one gap: an order field, the absence of any assay, the assays' confirmation, one assay's constraint, or one assay's requirement. No assay's constraint or requirement is listed until the assays are confirmed.
readiness. missingreadiness. missing[]. kindOne of:order_fieldno_assaysassay_confirmationassay_constraintrequirement
readiness. missing[]. fieldOne of:titleobjectiverequester_countrylab_locationbudgetmax_turnaround
readiness. missing[]. assay_idreadiness. missing[]. requirement_keyreadiness. submittableurlA link to this order request in Litmus, for the human user, not for your agent. Its target follows status. While the order request is 'draft' or 'submittable', it opens the draft, where the user can review it, complete it and submit it themselves. Once it is 'submitted', it opens the order request's page, where the user follows its progress, compares the labs' quotes and picks a lab, then pays and signs the standard agreements so the work can start. To resume programmatically, call get_order_request with order_request_id.
Worked example
Create an order request, confirm its assays, fill it in and submit it, using the same client as “Calling the MCP server” above.
Python — fastmcpShow codeHide code
from fastmcp import Client
from fastmcp.client.auth import BearerAuth
MAX_ROUNDS = 5 # rounds of questions before handing off to the user
async with Client(
"https://api.litmus.science/mcp",
auth=BearerAuth(access_token),
) as client:
result = await client.call_tool(
"create_order_request",
{
"objective": "Confirm our VHH binds its target in live cells.",
"context": "2 mg of purified VHH in hand. Results needed within 6 weeks.",
"conversation_key": conversation_id, # makes a retry safe
},
)
order = result.structured_content
order_request_id = order["order_request_id"] # store this
# Ask about whatever readiness.missing lists, reading it from the latest
# response each round. The first round asks which suggested assays to
# keep: ask_user_which_assays_to_keep shows the user order["order"]["assays"]
# and returns the ids of the ones they keep. ask_user_for asks the user and
# returns the answer in the field's own shape: a string for a title, a
# {"kind": ...} object for requester_country or a constraint, and
# {"key", "status", "value"} for a requirement. confirm_with_user and
# show_user_link stand in for however your product talks to the user.
for _ in range(MAX_ROUNDS):
missing = order["readiness"]["missing"]
if order["status"] != "draft" or not missing:
break
update = {"order_request_id": order_request_id}
assays = {}
for item in missing:
if item["kind"] == "assay_confirmation":
update["confirm_assays"] = ask_user_which_assays_to_keep(order)
continue
answer = ask_user_for(item, order)
if item["kind"] == "order_field":
update[item["field"]] = answer
elif item["kind"] == "assay_constraint":
assays.setdefault(item["assay_id"], {})[item["field"]] = answer
elif item["kind"] == "requirement":
change = assays.setdefault(item["assay_id"], {})
change.setdefault("set_requirements", []).append(answer)
update["assays"] = [
{"assay_id": assay_id, **change} for assay_id, change in assays.items()
]
result = await client.call_tool("update_order_request", update)
order = result.structured_content
if order["status"] == "submittable" and confirm_with_user(order):
result = await client.call_tool(
"submit_order_request", {"order_request_id": order_request_id}
)
order = result.structured_content
# A refusal comes back as a normal result, not an exception. If the
# order request is still a draft, hand it to the user to finish in Litmus.
if order["status"] == "draft":
show_user_link(order["url"])call_tool raises on a tool error — an order_request_id that doesn't exist or isn't the connected user's, a change to an order request that isn't a draft, a change Litmus refuses (an assay_id the order request doesn't have, an answer to a requirement key the assay doesn't have, a placeholder such as "N/A" sent as a value instead of the matching status, an assay's constraint or requirement sent before the assays are confirmed, or a confirm_assays naming a different set of assays once they are confirmed), a submission that didn't go through, a temporary failure on Litmus's side, or a permission the user didn't grant (see Consent and permissions). It's safe to call submit_order_request again after that error. Pass raise_on_error=False to inspect result.is_error instead.