Skip to main content
Glama

secop-mcp

CI

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 as x' 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 request max and count alongside every sum and 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_entity resolves 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

describe_dataset

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, sum + max + count by supplier, top 5

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_dataset now tells the model not to look for entities with contains.

  • 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 network

Connect it to a client:

claude mcp add secop -- uv run --directory /absolute/path/to/secop-mcp python server.py

Or 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 AND only. There is no OR, 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.py has been run against the live model on one question (above), before find_entity and 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 test

License

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 tools
describe_datasetA

Explain the SECOP II contracts dataset: every queryable column and the known data pitfalls. Call this first.

ParametersJSON Schema
NameRequiredDescriptionDefault

No parameters

TDQS

A4.3/5.0
Behavior3/5

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.

Conciseness5/5

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.

Completeness5/5

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.

Parameters4/5

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.

Purpose5/5

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.

Usage Guidelines4/5

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.
ParametersJSON Schema
NameRequiredDescriptionDefault
nameYes
limitNo

TDQS

A4.1/5.0
Behavior4/5

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.

Conciseness4/5

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.

Completeness4/5

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.

Parameters3/5

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.

Purpose5/5

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.

Usage Guidelines4/5

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.
ParametersJSON Schema
NameRequiredDescriptionDefault
limitNo
columnsNo
filtersNo
order_byNo
aggregatesNo
descendingNo

TDQS

A3.6/5.0
Behavior4/5

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.

Conciseness4/5

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.

Completeness4/5

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.

Parameters4/5

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.

Purpose4/5

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.

Usage Guidelines2/5

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.

  1. 3 tool updatesv0.1.0
    • First observeddescribe_dataset
    • First observedfind_entity
    • First observedquery_contracts

TDQS

A4/5.0

Scored across 3 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count4/5

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.

Completeness4/5

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

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers