secop-mcp
Summary: This MCP server lets an LLM answer Colombian public-procurement questions from SECOP II contracts via safe, typed SoQL queries and local entity lookup.
describe_dataset: list queryable columns and known data pitfalls; intended first call.find_entity: resolve an entity name to its NIT using a local, case- and accent-insensitive directory, withplace_onlyfallback and snapshot limits.query_contracts: select allowlisted columns; filter with AND-combined ops (=,!=,>,>=,<,<=,contains); group by columns; aggregate (count,sum,avg,min,max); order and cap at 100 rows.Run grouped totals such as top suppliers for an entity/year, excluding drafts/cancellations via
estado_contrato.Get safe query construction: columns restricted by enum; values treated as literals; ill-typed/private columns rejected before HTTP.
Receive numeric rows plus code-formatted Spanish money text and audit-logged calls.
Limits: AND-only filters, no OR/nesting/joins; substring search does not fold accents; source outliers/duplicates pass through; SECOP II totals are a floor.
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?"chat.py calls the model through model-gateway: every turn of the SDK's tool runner is recorded in .gateway/ledger.jsonl, and the cost it prints at the end is the sum of those records. That path has run against a fake model only, not yet against the API.
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, cost 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.
Available Tools
3 toolsdescribe_datasetA
Explain the SECOP II contracts dataset: every queryable column and the known data pitfalls. Call this first.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
No annotations are provided, so the description carries the full burden. It usefully discloses the output content (queryable columns and data pitfalls) and the recommended call ordering, but says nothing about the read-only nature, cost, or stability of the response. Adequate but not rich.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two short, front-loaded sentences with no filler. The core purpose is stated first, with the actionable ordering instruction at the end where a reader will see it.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a zero-param, read-only documentation tool with no output schema, the description fully covers what the agent needs: what it returns (columns and pitfalls) and when to call it (first). Nothing material is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The tool takes zero parameters, so there is nothing for the description to disambiguate. Baseline of 4 applies since the empty schema is trivially complete.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb (Explain) and resource (the SECOP II contracts dataset), and specifies the payload (every queryable column, known data pitfalls). This clearly distinguishes it from the query-oriented siblings query_contracts and find_entity without needing their schemas.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
"Call this first" gives explicit ordering guidance that tells the agent when to invoke it relative to the query tools. It stops short of naming the alternatives by name or stating when-not to call it, so it is clear context without full exclusion rules.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
find_entityA
Find a state entity by name and get its NIT. Immediate: it searches a local directory, not the dataset.
Case and accents are ignored, and every word must appear in one of the entity's names. Entities
are registered under their legal name: a city government ("Alcaldía de X") is often MUNICIPIO DE X
or DISTRITO ... DE X, and a "Gobernación" is often DEPARTAMENTO DE X. So when nothing has every
word, the search is repeated without the kind of entity and match says "place_only": check the
names before choosing. The directory is a snapshot; an entity that started publishing recently
may be missing.
| Name | Required | Description | Default |
|---|---|---|---|
| name | Yes | ||
| limit | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full behavioral burden and does well: it discloses case/accent insensitivity, the all-words matching rule, legal-name conventions, fallback behavior with 'place_only' matches, and snapshot limitations. It omits the effect of the 'limit' parameter, which is a minor gap.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is front-loaded with the core action, then adds dense but relevant detail about matching rules and naming conventions. It is somewhat long but every sentence contributes useful context.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a two-parameter lookup tool with no annotations and no output schema, the description is nearly complete: it covers search behavior, edge cases, and limitations. The only notable omission is the semantics of the 'limit' parameter.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0%, so the description must compensate. It adds meaningful matching semantics for the 'name' parameter (case/accent ignoring, all-words requirement, legal-name conventions) but says nothing about the 'limit' parameter's purpose or default.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The opening sentence states a specific verb and resource: 'Find a state entity by name and get its NIT.' It also distinguishes its scope from the dataset ('it searches a local directory, not the dataset'), which differentiates it from sibling tools like query_contracts.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description explains when the fallback search triggers ('when nothing has every word') and advises checking names before choosing. It clarifies the search is against a local directory rather than the dataset, but does not explicitly name alternative tools or state when to prefer them.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
query_contractsA
Query SECOP II contracts. Returns at most 100 rows.
With aggregates and columns together, rows are grouped by the columns (for example
columns=[proveedor_adjudicado, documento_proveedor] with sum of valor_del_contrato gives the
total per supplier). Filters are combined with AND; 'contains' is a case-insensitive
substring match on a text column. Dates are AAAA-MM-DD. order_by takes a requested column
or an aggregate alias such as sum_valor_del_contrato or count.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | ||
| columns | No | ||
| filters | No | ||
| order_by | No | ||
| aggregates | No | ||
| descending | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations provided, the description carries the full behavioral burden and does so well: it discloses the 100-row cap, that aggregates+columns cause grouping by those columns, that filters combine with AND, that 'contains' is a case-insensitive substring match, the date format, and how order_by resolves to aggregate aliases. It omits pagination/offset behavior and auth expectations, but the core semantics are unusually well surfaced.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Front-loaded with purpose and row cap before semantics, and each sentence earns its place by documenting a distinct behavior. The prose is dense but not padded; only the trailing order_by example overlaps slightly with the aggregate/grouping point.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a six-parameter tool with no output schema and no annotations, the description covers result shape (≤100 rows), filter semantics, grouping, dates, and ordering adequately. Remaining gaps are pagination behavior and default column selection, which are minor relative to what is provided.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description must compensate, and it does for most parameters: filters (AND combination, 'contains' semantics), dates (AAAA-MM-DD), order_by (column or aggregate alias like sum_valor_del_contrato/count), aggregates+columns grouping, and limit via the 100-row cap. 'descending' is never explained, which is the one meaningful gap.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb+resource ('Query SECOP II contracts') and immediately bounds the result ('at most 100 rows'), so the agent knows exactly what operation this is. It does not distinguish itself from the siblings describe_dataset and find_entity, leaving the agent to infer the boundary between querying and those two tools.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
There is no explicit when-to-use or when-not guidance, and no mention of the sibling tools (find_entity, describe_dataset) that an agent would need to choose between. Usage is only implied by the description of query mechanics, so the agent gets no routing help.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
3 tool updates
v0.1.0- First observed
describe_dataset - First observed
find_entity - First observed
query_contracts
TDQS
Scored across 3 tools
The three tools serve clearly separate roles: describe_dataset is schema discovery, find_entity resolves an entity name to a NIT against a local directory, and query_contracts runs the actual data query. The find_entity description explicitly clarifies it does not touch the dataset, removing any overlap with query_contracts.
All three names follow a clean verb_noun snake_case pattern (describe_dataset, find_entity, query_contracts) with no stylistic deviations or mixing of conventions.
Three focused tools for a single-domain dataset server is well-scoped—each one (understand schema, resolve entity, query) earns its place with no redundancy. It leans slightly minimal, but the workflow is coherent.
The surface covers the core lifecycle for a data query server: understand the schema, resolve entities, and query with filters/aggregates/ordering. A few gaps remain, such as no distinct-values helper or paging beyond the 100-row cap, but agents can work around them.
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.
Query Brazil's public procurement portal (PNCP): tenders, contracts and price records.
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.
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.1037 PyPI2MIT
- 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.18 npmMIT
- AlicenseNot gradedqualityBmaintenanceEnables querying Hong Kong Government procurement contracts awarded data through natural language, with no API key required.12 npmMIT