MCP server

Bidlo runs a Model Context Protocol server at https://lite.bidlo.ai/api/mcp. This page is its reference: how to connect a client, how the transport behaves, what each of the five tools takes and returns, and where the caps sit. It reads a Bidlo team’s own data, so it needs a Bidlo account, and anyone can get one by booking a call.

Open in ChatGPTOpen in Claudellms.txtllms-full.txtopenapi.json

Contents

What is the Bidlo MCP server?

The Bidlo MCP server is a read-only Model Context Protocol server at https://lite.bidlo.ai/api/mcp that gives an agent five tools for reading a Bidlo team's jobs, bid items, contractors and plants. It is the same data your team sees in the app, read by an agent rather than by a person.

The server registers five tools, two resources and one prompt, and no write tools at all. Its name is bidlo-mcp. One key carries the whole connection: the key names the team, so no tool takes a team parameter and there is nothing else to configure. The same key reads the REST API, and Querying data describes the collections, fields and filters both surfaces share.

How do I connect a client?

Point the client at https://lite.bidlo.ai/api/mcp and send your API key in an Authorization header; the three configs below are that one connection in the three shapes clients ask for.

A Bidlo account is required. Anyone can get one by booking a call at https://cal.com/matt-wolfe-yecrho/30min — Bidlo sets the team up, and a team owner or admin then mints the API key in Settings → Features → API. There is no public tier and no self-serve signup.

Settings → Features → MCP mints the same key labelled Bidlo MCP with read access and prints a finished config under it. Access and keys covers minting, rotating and revoking one.

Cursor

Cursor installs from a deeplink. The config inside the link is base64, so re-encode it with a real key in place of YOUR_API_KEY rather than editing the string.

Cursor deeplink
cursor://anysphere.cursor-deeplink/mcp/install?name=bidlo&config=eyJ1cmwiOiJodHRwczovL2xpdGUuYmlkbG8uYWkvYXBpL21jcCIsImhlYWRlcnMiOnsiQXV0aG9yaXphdGlvbiI6IkJlYXJlciBZT1VSX0FQSV9LRVkifX0=

Streamable HTTP

A client that speaks streamable HTTP takes the URL and the header directly. Claude Code and Cursor both do.

Client config
{
  "url": "https://lite.bidlo.ai/api/mcp",
  "headers": {
    "Authorization": "Bearer YOUR_API_KEY"
  }
}

mcp-remote

A client that speaks only stdio runs the mcp-remote shim over npx, which holds the HTTP connection for it. This is the block the app's own MCP settings page prints.

Client config
{
  "mcpServers": {
    "bidlo": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://lite.bidlo.ai/api/mcp",
        "--header",
        "Authorization: Bearer YOUR_API_KEY"
      ]
    }
  }
}

How does the transport work?

The server speaks streamable HTTP and is stateless, so every request carries the key and everything else it needs, and a POST is answered with one JSON body.

MethodWhat it does
POSTCarries JSON-RPC and is answered with one JSON body. Accept must list both application/json and text/event-stream or the answer is a 406, and Content-Type must be application/json or it is a 415. A body holding only notifications is answered 202 with no content.
GETOpens an event stream when Accept is text/event-stream. Nothing is pushed on it and there is no replay, so a client does not need to open one.
DELETEAnswers 200. There is no session to end.
OPTIONSAnswers 204 with the CORS headers, before the key is read.
A tool call over HTTP
curl -X POST https://lite.bidlo.ai/api/mcp \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": { "name": "get_team_collections", "arguments": {} }
  }'

Stateless means what it says. No session id is issued, a fresh server answers every request, and nothing set on one request carries to the next, so send the key and the full arguments every time. Protocol versions from 2024-11-05 to 2025-11-25 are accepted on the Mcp-Protocol-Version header; a version the server does not know is a 400.

There is no OAuth handshake and nothing to register. The discovery paths a client probes under the server answer 404 on purpose, so the client falls through to the bearer token it was given. Responses carry Access-Control-Allow-Origin: *, and authorization, content-type, mcp-session-id, mcp-protocol-version and last-event-id are the allowed request headers.

Which tools can a client call?

There are five tools, all of them read-only, and each one answers with a single block of text holding pretty-printed JSON. In the shapes below an angle-bracket name stands for a value that is yours to look up and an ellipsis stands for a value left out.

ToolWhat it does
get_team_collectionsLists the collections this key can read.
get_collection_fieldsLists the fields on one or more collections, with the operators each field will take.
resolve_documents_by_nameTurns a name into the id a filter needs.
query_database_dataReads a page of documents: filters, sorts, a limit and a page.
query_mentioned_documentReads one document by id.

get_team_collections

Lists the collections this key can read. It takes no arguments. The key names the team, so there is nothing to scope and no team parameter on this or any other tool.

Output shape
{
  "collections": [
    {
      "collection_id": "<collection_id>",
      "name": "Project",
      "description": "…",
      "mutable": true
    }
  ]
}

Collections come back sorted by name. mutable says whether your team can edit the collection in the app; it has no bearing on this server, which never writes.

Nothing here fails on its own. A missing or bad key is the 401 in the errors section below.

get_collection_fields

Lists the fields on one or more collections, with the operators each field will take. Give it collection ids, not names. Read the ids off get_team_collections first; the first 20 ids in the list are used and the rest are dropped.

ParameterTypeRequiredNotes
collection_idsarray of stringsYesCollection ids. The first 20 are used.
Output shape
{
  "collections": [
    {
      "collection_id": "<collection_id>",
      "collection_name": "Project",
      "field_count": <number>,
      "fields": [
        {
          "field_id": "<field_id>",
          "name": "Bid Date",
          "field_type": "date",
          "filter_field_type": "date",
          "primary_key": false,
          "reference_collection_id": null,
          "description": "…",
          "aka": ["…"]
        }
      ]
    }
  ],
  "unknown_collection_ids": [],
  "operators_by_filter_field_type": { "date": ["eq", "gt", "lt", "between", "…"] },
  "filter_value_shapes": { "date_between": { "start": "ISO date string", "end": "ISO date string" } },
  "formula_expression_syntax": "…"
}
Arguments
{
  "collection_ids": ["<collection_id>", "<collection_id>"]
}

field_id is how the app tells two fields apart, and it is what a REST filter names. name is what your team calls the field: a built-in field can be renamed, so two teams can hold different names for the same field, which is why no id and no field name is printed as fact anywhere on this page. filter_field_type is the type the field is filtered as, and it is the key the operator table is written against. reference_collection_id names the collection a relation points at, which is the collection to search with resolve_documents_by_name. The response also restates the operators and the value shapes, so a client can read them at run time instead of carrying a copy.

A collection id that is not on your team comes back under unknown_collection_ids rather than failing the call. A collection whose fields are all hidden fails with Collection has no visible fields.

resolve_documents_by_name

Turns a name into the id a filter needs. A filter on a contractor, a county or an agency wants the document id of the thing, not its name. This tool searches one collection by name and hands back the ids.

ParameterTypeRequiredNotes
collection_idstringYesThe collection to search, by id.
querystringNoFree text. When it is set, names is ignored.
namesarray of stringsNoThe first 10 are used.
limitintegerNoMatches per input. 1 to 25, default 10.
Output shape
{
  "collection_id": "<collection_id>",
  "collection_name": "Counties",
  "lookups": [
    {
      "input": "Travis County",
      "exactMatches": [
        { "id": "<document_id>", "name": "Travis County", "document_mention": "…" }
      ],
      "candidates": [],
      "unresolved": false
    }
  ],
  "unresolved_names": [],
  "summary": "…"
}
Arguments
{
  "collection_id": "<collection_id>",
  "names": ["Travis County", "Bexar County"],
  "limit": 10
}

An exact match is one whose name matches after case and spacing are normalised. Everything else that came back is a candidate, and a name that matched nothing is listed under unresolved_names. document_mention is a link into the Bidlo app, not a public URL.

Send one of query or names, or the call fails with the message Provide `query` or `names` for document lookup. A collection id that is not on your team fails with Collection is not available for this team.

query_database_data

Reads a page of documents: filters, sorts, a limit and a page. This is the tool that reads data. It takes a collection by name and field references by name, filters and sorts them, and hands back one page of documents.

ParameterTypeRequiredNotes
collection_namestringYesA name, not an id. Matching ignores case, spacing and a trailing plural.
filtersarrayNoEach one a field name, an operator and a value. They are ANDed.
sortsarrayNoEach one a field name and a direction, applied in array order.
return_fieldsarray of stringsNoField names. Ask for the fields you want.
limitintegerNo1 to 100, default 25.
pageintegerNoFrom 1, capped at 20.
querystringNoFree text searched across the collection.
Output shape
{
  "collection_id": "<collection_id>",
  "collection_name": "Project",
  "page": 1,
  "query": null,
  "grouped": false,
  "selected_fields": [
    { "field_id": "<field_id>", "field_name": "Bid Date" }
  ],
  "documents": [
    {
      "document_id": "<document_id>",
      "document_name": "…",
      "document_mention": "…",
      "fields": {
        "Bid Date": "…",
        "County": { "id": "<document_id>", "name": "…", "document_mention": "…" },
        "Engineer's Estimate": "…"
      }
    }
  ],
  "returned_row_count": <number>,
  "has_more": true,
  "next_page": 2,
  "warnings": ["…"]
}
Arguments
{
  "collection_name": "Project",
  "filters": [
    {
      "field_name": "Bid Date",
      "operator": "between",
      "value": { "start": "2026-10-01", "end": "2026-12-31" }
    },
    { "field_name": "County", "operator": "contains", "value": "Travis County" }
  ],
  "sorts": [{ "field_name": "Bid Date", "direction": "asc" }],
  "return_fields": ["Name", "Bid Date", "County", "Agency", "Engineer's Estimate"],
  "limit": 25,
  "page": 1
}

The operators are is_null, is_not_null, eq, ne, contains, not_contains, starts_with, ends_with, gt, lt, gte, lte, between, relative_range, within_distance, outside_distance. Which of them a given field takes depends on the type it is filtered as, and Querying data has that table along with the value shape each one wants. Four behaviours are worth knowing before the first call. Without return_fields a capped default set of up to 8 fields comes back with a warning saying so, so name the fields you want. A second filter on a field that already has one replaces it rather than failing, which is the opposite of the REST behaviour on the REST API. A relation or tag value given as a name is looked up for you, and a value given as an id is used as it stands. And there is no total count in the response: has_more and next_page are how you learn there is another page.

Input the schema rejects fails the call: a limit over 100, an operator outside the list above, a direction that is not asc or desc. A collection, return field, filter field or sort field the server cannot find does not fail the call; it comes back as an ordinary result whose JSON holds an error key, and the message names the tool to call next. So does an ambiguous name in a relation filter, which points at resolve_documents_by_name.

query_mentioned_document

Reads one document by id. Use it when you already hold one document id and want that job, contractor or plant in full rather than a page of rows.

ParameterTypeRequiredNotes
document_idstringYesFrom resolve_documents_by_name, or from document_id in a query result.
fields_to_fetcharray of stringsNoField names or field ids.
Output shape
{
  "_document_id": "<document_id>",
  "_document_name": "…",
  "document_mention": "…",
  "_collection": "Project",
  "document_profile": "…",
  "Bid Date": "…",
  "County": { "id": "<document_id>", "name": "…", "document_mention": "…" },
  "Engineer's Estimate": "…",
  "omitted_fields": { "field_names": ["…"], "note": "…" }
}
Arguments
{
  "document_id": "<document_id>",
  "fields_to_fetch": ["Name", "Bid Date", "Agency", "Low Bid"]
}

The field values sit at the top level of the object under their own names, so a client reads them by name. Without fields_to_fetch, empty fields are dropped and at most 40 fields come back; the rest are named under omitted_fields, and a second call naming them loads them.

A document id that is not on your team comes back as an ordinary result holding error and the message Document <document_id> not found or access denied.

Coming. Market tools are coming: search_projects, search_contractors, search_facilities, and comparable unit prices for an item in a county. None of them is live today. Compose the same reads with query_database_data until they are.

What is the recommended order of calls?

List the collections, read the fields on the one you want, resolve any names to ids, then query it, and read a single document by id when you need everything on it.

  1. get_team_collections for the collections this key can read, with their ids.
  2. get_collection_fields for the fields on the collection you want, with the field names to filter by and the collection a relation points at.
  3. resolve_documents_by_name for any human name that will be a filter value, such as a county or a contractor. Skip it when you already hold the id.
  4. query_database_data for the page of jobs, bid items or contractors itself.
  5. query_mentioned_document when one of those rows is the answer and you want everything on it.

The server ships that recipe as a prompt, query-bidlo-data, so a client can load it rather than be told it. Two resources save a call at step one.

ResourceWhat it holds
bidlo://schemaA plain-text summary of the collections on your team and the fields on each.
bidlo://collectionsThe same JSON get_team_collections returns.

How are errors reported?

On two channels: input the server cannot accept fails the call and is marked as an error, and a request it understood but could not answer comes back as an ordinary result whose JSON holds an error key.

What went wrongHow it comes backWhat to do
A missing, malformed or unknown keyHTTP 401 with the JSON-RPC error -32001 and a message opening Unauthorized:Check the header reads Authorization: Bearer sk_live_… and mint a new key if the old one was deleted.
A lapsed team subscriptionThe same 401, with Unauthorized: Team subscription expiredThe key is fine. The team needs its subscription back.
Input the schema rejectsThe call fails and the result is marked as an error, with text opening Input validation error:Read the message: it names the argument. An over-size limit is rejected, not trimmed.
A tool name the server does not haveThe call fails and the result is marked as an error, with Tool <name> not foundCall the five above.
An unknown collection, field or sort fieldAn ordinary result whose JSON holds an error keyThe message names the tool to call next, and lists what is available.
An ambiguous name in a relation filterAn ordinary result whose JSON holds an error keyResolve the name with resolve_documents_by_name and filter on the id.
Anything failing behind the serverHTTP 500 with the JSON-RPC error -32000 and MCP request failedRetry, then write to support.

The second channel is the one that catches clients out. A result that is not marked as an error is not necessarily an answer, so parse the JSON in the text block and look for an error key before you read the rows. Those messages are written to be acted on: each one names the tool that will fix it.

What are the limits?

A query returns at most 100 rows a page and stops at page 20, and every other cap is in the table below.

CapValueWhat it means
limit on query_database_data1 to 100, default 25Rows in one page.
page on query_database_data1 to 20Narrow the filters rather than paging past the end.
collection_ids on get_collection_fields20Ids past the cap are dropped.
names on resolve_documents_by_name10Names past the cap are dropped.
limit on resolve_documents_by_name1 to 25, default 10Matches per name.
Fields without return_fields8A default set, with a warning. Name the fields you want.
Fields without fields_to_fetch40Empty fields are dropped first; the rest are named under omitted_fields.
A text value in a result400 charactersLonger text is cut, with an ellipsis on the end.

No response carries a total count. Page until has_more is false, or filter harder.

Where do I go next?

Read Querying data for the operators and value shapes both surfaces share, read the REST API page if you want plain HTTP instead of MCP, and mail support@bidlo.ai with anything neither answers.

  • Querying data. The collections, the field types, the operators each one takes and the shape of every value.
  • REST API. The same data over GET and POST, for three collections.
  • Access and keys. Getting a key, storing it, rotating it.
  • /openapi.json. The machine-readable spec.

Anything these do not answer, mail support@bidlo.ai.