secop-mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@secop-mcp¿Quiénes fueron los mayores contratistas de Medellín en 2024?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
secop-mcp
An MCP server that lets an LLM answer questions about Colombian public procurement from the official open data: the SECOP II electronic contracts dataset on datos.gov.co, more than six million contracts, updated daily.
"¿Quiénes fueron los mayores contratistas de la Alcaldía de Medellín en 2024?"
│
Claude (MCP host) ──MCP/stdio──▶ server.py ──HTTPS──▶ datos.gov.co (Socrata API)
│ │
│ └──▶ audit.jsonl
▼
describe_dataset → find_entity (local directory: the entity's NIT) → query_contracts (total per supplier)Anyone can install it: there is no database to set up and no key to request.
The design decision: the model never writes a query
The dataset is queried with SoQL, a SQL-like language passed in the URL. The obvious tool is "here is a where string, run it". This server does not offer that. query_contracts takes a typed query:
{
"columns": ["proveedor_adjudicado", "documento_proveedor"],
"aggregates": [{"function": "sum", "column": "valor_del_contrato"},
{"function": "max", "column": "valor_del_contrato"},
{"function": "count"}],
"filters": [{"column": "nit_entidad", "op": "=", "value": 890905211},
{"column": "anio_firma", "op": "=", "value": 2024},
{"column": "estado_contrato", "op": "!=", "value": "Cancelado"}],
"limit": 5
}and the server builds the SoQL from it. Three things follow:
Column allowlist by construction. Column names are an enum in the tool's JSON Schema, so a request for anything else is rejected before any HTTP call. Nothing has to be parsed or filtered afterwards.
Values are always literals. Text is quoted and escaped, numbers must parse as finite numbers, dates must be
YYYY-MM-DD. A value such asx' OR 1=1 --ends up inside a string.Privacy by default. The published dataset has 95 columns, and some hold personal data: bank account numbers, home addresses and ID numbers of legal representatives, supervisors and payment officers. Being public does not make it appropriate to hand to a model. The allowlist holds 25 of the published columns plus one derived column (the signing year), covering the entity, the supplier, the contract, its dates and its amounts. The other 70, the personal ones among them, cannot be reached.
Related MCP server: Caseware Procurement Document MCP Server
The data is the hard part
describe_dataset returns the columns and, more importantly, the traps. These are the ones I hit while building it:
Typos of several orders of magnitude. The largest contract signed since 2024 is recorded as 6.4 × 10¹⁵ pesos, more than the entire national budget. One such row ruins any
SUM. The server tells the model to requestmaxandcountalongside everysumand to say so when one contract explains the total.Names are not identifiers. A search for "Medellín" returns the sports institute, the district, a library and a hospital, and the city government is not registered as "Alcaldía" at all.
find_entityresolves a name to a NIT from a local directory of the 5,800 entities, and the model filters by NIT afterwards.Drafts and cancellations are in the data. "How much was contracted" has to exclude them.
SECOP II is not all of public procurement. SECOP I and the state's online store are separate datasets, so every total is a floor.
An example, checked against the API
Top suppliers of the Distrito de Medellín (NIT 890905211) for contracts signed in 2024, excluding drafts and cancellations, as returned on 1 October 2026:
Supplier | Total (COP) | Largest single contract | Contracts |
Bancolombia | 491,372,098,658 | 491,372,098,658 | 1 |
ESE Metrosalud | 342,731,922,079 | 45,000,000,000 | 28 |
Institución Universitaria ITM | 283,678,557,488 | 17,305,351,117 | 63 |
The first row is why max and count travel with sum: the top "supplier" is a single contract, which a careful answer should point out rather than rank next to 63 separate ones. The dataset changes daily, so these figures will drift.
The same question, asked through the model
uv run python chat.py with its default question, "¿Cuáles fueron los cinco mayores contratistas de la Alcaldía de Medellín en 2024?", run on 1 October 2026 with claude-sonnet-5-5. Five tool calls in about two minutes, from the audit log:
Call | Result |
| columns and traps |
name contains "medell", year 2024, grouped by entity | timeout after 60 s: a substring match over a year of contracts |
name contains "alcaldía de medell", year 2024 | 0 rows: SECOP II does not call it "Alcaldía" |
name and city contain "medell", year 2024 | 15 entities, which is how it found the official name |
exact entity name, year 2024, drafts and cancellations excluded, | 5 rows |
The five suppliers and amounts in the answer are the ones in the table above, and it did what the server asks for: it listed the filters it used, said the first place is a single contract that should be checked at the source, said that most of the list is contracts between public bodies, and said SECOP II totals are a floor. It also noticed that the Concejo and the Personería share the district's NIT and filtered by the exact entity name instead, which the NIT-first advice in describe_dataset does not anticipate.
Two things went wrong, and both are worth more than the success:
One unit slip in the prose. The table says "491.372 mil millones", which is right. A sentence below it calls the same contract "unos 491 billones de pesos", which in Spanish is a thousand times more. The tool returned the right number; the model mislabelled it once while writing. Amounts should be formatted by code, not by the model.
Finding the entity cost a timeout and two extra calls. Name search is the slow path on this dataset: a substring match over a year of contracts.
What that session changed
Both problems were fixed in the tool, not in the prompt.
find_entity, a third tool. The server now ships the entity directory that secop-api builds: one entry per NIT with its other spellings. The search runs in memory, ignores case and accents, and never touches the dataset. "Alcaldía de Medellín" matches nothing word for word, because the dataset calls it a district; the tool then searches by the place alone, says so ("match": "place_only") and returns the district with the council and the ombudsman listed under the same NIT.describe_datasetnow tells the model not to look for entities withcontains.Amounts are written out by code. Every money value in a result comes with a twin field,
sum_valor_del_contrato_texto: "491.372 millones de pesos", and the model is told to quote it as it is. The rule that a Spanish billón is a million millions lives in one tested function instead of in the model's arithmetic.
The session above is the one before these changes. I have not run the model against the new tools yet, so what they do to the number of calls and to the answer is still to be measured; the tests below check the tools themselves.
Run it
uv sync
uv run pytest # 24 tests; 23 run offline, 1 calls datos.gov.co and is skipped without networkConnect it to a client:
claude mcp add secop -- uv run --directory /absolute/path/to/secop-mcp python server.pyOr ask through the included host (needs ANTHROPIC_API_KEY):
uv run python chat.py "¿Cuánto contrató la Gobernación de Antioquia por licitación pública en 2023?"An optional SOCRATA_APP_TOKEN environment variable raises the anonymous rate limit.
What the tests prove
The SoQL generated for a grouped total is exactly the expected one.
Hostile values stay inside string literals; ill-typed values (a non-number for a NIT,
inf, a malformed date) are rejected.Personal-data columns and made-up column names cannot be selected, filtered or sorted on, both when calling the function directly and through the MCP protocol, where the schema rejects them before any request is made.
The row limit is capped at 100, rows come back with real numbers instead of strings, and every call is written to the audit log.
An entity is found by name without any request to the dataset, whatever the accents, and a name people use ("Alcaldía de Medellín") falls back to the place.
Amounts are written out as a Colombian reader expects, 10¹² is a billón, and a count is never formatted as money.
Against the live API, the generated SoQL is accepted and returns the expected shape.
Limits, stated plainly
Filters are combined with
ANDonly. There is noOR, no nesting and no joins, because there is one dataset.Text search is a case-insensitive substring match. It does not fold accents, and the source is inconsistent about them.
The server reports the data; it cannot repair it. Outliers, duplicates and late updates in SECOP II flow straight through.
Response time depends on datos.gov.co: usually under a second for filtered queries, 10 seconds or more for scans over all six million rows, and occasionally a timeout.
The entity directory is a snapshot (
entities.json, copied from secop-api). An entity that started publishing after it was built is not found by name until the file is refreshed.chat.pyhas been run against the live model on one question (above), beforefind_entityand the formatted amounts existed. That is an example, not an evaluation: there is no golden set of questions for this server.
Layout
server.py the MCP server: the column allowlist, the query builder, three tools, the audit log
entities.json the entity directory find_entity searches: one entry per NIT with its other spellings
chat.py Claude as MCP host, answering in Spanish; prints the tool calls, tokens and seconds a session took
test_server.py query-builder, privacy and protocol tests, plus one live testLicense
MIT for the code. entities.json is derived from Colombia's open data on datos.gov.co and stays under that portal's terms.
This server cannot be deployed
Maintenance
Related MCP Connectors
Colombian company and public-procurement data by NIT: RUES commercial registry, SECOP contracts, Supersociedades financials, sanctions and OFAC/UN lists, each field with its official source URL and capture date. No key needed to connect or for radicadouno_comprobar (free, 10/day). Buy a signed report per company (89,900 COP) or a Business key from within the server: no human in the loop. Companies only: no natural persons (Law 1581/2012). Not a credit score.
SAM.gov contract opportunities and entity lookup (BYOK) plus USASpending federal award data.
Search US grants + federal contracts (Grants.gov + SAM.gov) from any LLM.
MCP access to the U.S. federal procurement graph: contracts, opportunities, entities, and more.
Related MCP Servers
- AlicenseAqualityDmaintenanceAn MCP server to query Colombian public procurement data from SECOP I and II in real time, enabling contract research, transparency oversight, and data journalism.102MIT
- FlicenseNot gradedqualityDmaintenanceEnables AI agents to search, retrieve, and answer grounded questions over procurement documents (invoices, purchase orders, contracts, etc.) using hybrid SQL and vector retrieval.-
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to query Colombian government procurement data via MCP tools or natural language questions.238 npmMIT
- AlicenseNot gradedqualityBmaintenanceEnables querying Hong Kong Government procurement contracts awarded data through natural language, with no API key required.395 npmMIT