maple-procure
by samarpassey
README.md
# MapleProcure
This lets an AI assistant answer questions about Canadian government contracts: what is open for
bid right now, who won past work, and for how much. Every answer is built from the official
CanadaBuys records and carries the date and the source file it came from.
Ask *"which IT tenders close in the next 30 days?"* or *"who has won snow-removal contracts in
Ontario, and for how much?"* and get an answer traceable to the government notice wherever the
source publishes one.
## How it works
- **An MCP server in Python, so any MCP client can call it.** Five tools over stdio and over
streamable HTTP. FastAPI hosts the HTTP transport, SQLite holds the data, and `make ingest`
rebuilds that database from the CanadaBuys open-data CSVs at runtime. No data is committed here.
- **Every caller has scopes, and the boundary is enforced.** A bearer token carries scopes, and
reading a tender, reading a buyer's contact details and writing an export are three different
permissions. A missing credential and a wrong one return different errors, per RFC 6750.
- **The only tool that writes takes two calls.** `export_report` renders a preview and writes
nothing. Writing needs a second call carrying a sealed handle bound to a hash of the rendered
document, so what a person approved was the document, not an argument list.
**Status: deployed and serving.** Driven end to end from Claude Desktop on 2026-08-16 and
deployed the same day. Live at [maple-procure.onrender.com](https://maple-procure.onrender.com/version)
(the root path answers `401` by design; `/version` and `/readyz` are public). The transcript under
[Try it live](#try-it-live) is real output from the running service, and a 62-second recording of
it driven by curl is in [`docs/demo.gif`](docs/demo.gif). This README does not describe a
capability until it exists in the code. Current state is in [`PROGRESS.md`](PROGRESS.md); accepted
limitations are in [`KNOWN_LIMITATIONS.md`](KNOWN_LIMITATIONS.md).
<!-- TODO: screenshot here - Claude Desktop answering a tender question, tool call expanded. -->
## Run it
Requires Python 3.12 or newer and [uv](https://docs.astral.sh/uv/). From a clean clone:
```bash
make install # dependencies into .venv
make test # 373 tests
make ingest # download the CanadaBuys CSVs (about 17 MB) and build data/maple.sqlite
make serve-stdio # or: make serve-http
```
`make test` needs no network and no database: the suite runs against the fixtures in
`tests/fixtures/`. `make ingest` is the step that reaches the network, and how long it takes is
download time, not processing. `make lint` runs ruff.
A clean clone reproduces the database from the official source, so nothing here has to be trusted
as a snapshot. Row counts move as CanadaBuys publishes; the counts quoted in
[`docs/DATA.md`](docs/DATA.md) are what a full load showed on 2026-08-16.
## Try it live
The server is deployed at `https://maple-procure.onrender.com` (Render free tier: the
first request after idle triggers a fresh ingest from CanadaBuys, so give a cold start up to a
minute, measured at 41s from fully idle).
`/version` and `/readyz` need no credential:
```bash
curl https://maple-procure.onrender.com/version
```
Everything else needs a bearer token, and there are two ways to get one.
**A read-only demo token for the deployed service is available on request.** Email
samarpartapp@gmail.com. It holds `read` and nothing else, so it can search tenders and awards but
cannot export reports or read buyer contact details.
**Or run your own and mint your own tokens.** One environment variable,
`MAPLE_PROCURE_TOKENS`, holds them all, each entry `subject:secret:scope,scope`. The three scopes
are `read`, `pii` and `export`, and an unknown scope name aborts at startup rather than surfacing
later as a permission error. See [Over HTTP](#over-http) for the whole setup.
Every command below reads the token from `$TOKEN`:
```bash
export TOKEN=... # the demo token, or one of your own
curl -X POST https://maple-procure.onrender.com/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```
### Verified in production
The session below was run against `https://maple-procure.onrender.com` on 2026-08-16.
Commands and responses are copied verbatim, with two edits: `tools/list` is trimmed to the tool
names, which is what the `jq` filter does, and the bearer token is shown as `$TOKEN`.
**The five tools are live.** Responses come back as SSE, so `data:` is stripped before `jq`:
```console
$ curl -s -X POST https://maple-procure.onrender.com/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
| sed -n 's/^data: //p' | jq -r '.result.tools[].name'
search_tenders
get_tender
search_awards
summarize_spend
export_report
```
**The auth boundary is real, not decorative.** Both failures are RFC 6750 challenges, and
they are distinguishable. A missing credential is not the same error as a bad one:
```console
$ curl -si -X POST .../mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -i 'HTTP/\|www-authenticate'
HTTP/2 401
www-authenticate: Bearer error="invalid_request", error_description="an Authorization header is required"
$ curl -si -X POST .../mcp -H "Authorization: Bearer not-a-real-token" ... | grep -i 'HTTP/\|www-authenticate'
HTTP/2 401
www-authenticate: Bearer error="invalid_token", error_description="the bearer token is not recognised"
```
**The demo token really is read-only.** It holds `read` and nothing else, so the two
tools that reach past reading are refused at the scope check. The export never previews, and
the contact fields are never assembled:
```console
$ ... -d '{... "name":"export_report","arguments":{"tender_refs":["MX-2026-00001"]}}}'
{"code":-32002,"message":"this request needs the 'export' scope, which this caller does not
hold. Held scopes: read.","data":{"remedy":"this needs a token carrying the missing scope;
do not retry as-is"}}
$ ... -d '{... "name":"get_tender","arguments":{"reference_number":"...","include_contact":true}}}'
{"code":-32002,"message":"this request needs the 'pii' scope, which this caller does not
hold. Held scopes: read.","data":{"remedy":"this needs a token carrying the missing scope;
do not retry as-is"}}
```
**Every result is dated and traceable.** A live `search_tenders` for `software`, limited to
two rows. Note that the provenance block and `notes` are part of the payload, not added here:
```console
$ ... -d '{... "name":"search_tenders","arguments":{"keywords":"software","limit":2}}}'
{
"rows": [
{
"reference_number": "SSC-26-00034447:T",
"title": "VIRTANA HARDWARE & SOFTWARE MAINTENANCE AND SUPPORT",
"buyer_name": "Shared Services Canada",
"closing_date": "2026-08-17T14:00:00",
"category": "Services related to goods",
"regions_of_delivery": "Ontario (except NCR)",
"notice_url": null
},
{
"reference_number": "WS3971158100-Doc3971158168",
"title": "Software Licensing Supply Arrangement (SLSA) RFSA",
"buyer_name": "Department of Public Works and Government Services (PSPC)",
"closing_date": "2028-09-29T10:00:00",
"category": "Goods",
"regions_of_delivery": "Canada",
"notice_url": "https://canadabuys.canada.ca/en/tender-opportunities/tender-notice/WS3971158100-Doc3971158168"
}
],
"row_count": 2,
"total_matches": 53,
"withheld": 51,
"truncated": true,
"as_of": "2026-08-16T21:13:41+00:00",
"source_file": "https://canadabuys.canada.ca/opendata/pub/openTenderNotice-ouvertAvisAppelOffres.csv",
"source_last_modified": "Sun, 16 Aug 2026 10:20:15 GMT",
"source_row_count": 937,
"notes": [
"Data as of 2026-08-16T21:13:41+00:00, from the last ingest of ... — a snapshot, not a
live feed. Newer notices may exist.",
"Showing 2 of 53 matches; 51 withheld. Narrow the search or raise `limit` (max 100) —
do not describe these rows as the complete set."
]
}
```
The first row is the honest case for the caveat above: `notice_url` is null because its
legacy `SSC-` reference does not resolve on the modern CanadaBuys site, so the row carries no
URL rather than a broken one. That is 5% of open tenders, and 64% of awards, which is why
neither tool promises a link on every row. See
[`KNOWN_LIMITATIONS.md`](KNOWN_LIMITATIONS.md#correctness).
## Tools
| Tool | Question it answers | |
|---|---|---|
| `search_tenders` | What's open right now, by keyword, category, region, or closing date? | ✅ |
| `get_tender` | Everything on one notice. | ✅ |
| `search_awards` | Who won what, when, and for how much? | ✅ |
| `summarize_spend` | Totals by buyer, supplier, category or period. | ✅ |
| `export_report` | Write a brief. **Only after a human confirms.** | ✅ |
Every result carries the source file it came from and when that file was last ingested, so an
answer can always be dated and traced back to the official notice.
## Connect a client
Point a client at the stdio server. For Claude Desktop or Claude Code:
```json
{
"mcpServers": {
"maple-procure": {
"command": "uv",
"args": ["run", "--directory", "/absolute/path/to/maple-procure",
"python", "-m", "maple_procure.server"]
}
}
}
```
Over stdio there is no transport that could carry a credential, so scopes come from the
environment and default to `read` alone. Buyer contact details and exports each need theirs
turned on explicitly:
```bash
MAPLE_PROCURE_SCOPES=read,pii,export make serve-stdio
```
An unknown scope name aborts at startup rather than surfacing later as a permission error. This
variable is the stdio fallback only. Over HTTP, scopes come from the token.
## The write needs two calls
`export_report` is the only tool that writes, and it cannot be made to write on a single call,
on any client, under any argument combination. The first call renders the exact document,
returns it as a preview, and writes nothing. Writing needs a second call carrying the
`confirm_handle` the preview minted.
A handle is sealed with the SDK's AES-256-GCM `request_state` codec, so a client cannot read or
forge one. It expires in five minutes, works exactly once, and is bound to a hash of the
rendered content. Change the notices or the format and the old handle stops matching, because
what a human approved was a document, not an argument list.
Clients that declare form elicitation get a real prompt instead (MRTR: the server returns
`input_required` and the client retries with the answer). Both paths redeem the same handle, so
the optional one is not a weaker gate. Today's Claude Desktop and Claude Code negotiate
`2025-11-25`, so in practice you will see the handle.
What this does **not** prove is that a human read the preview. On the handle path, a model that
passes the handle straight back has satisfied the mechanism, and in live testing one did
exactly that when told to "export it again". So state the guarantee precisely:
> What gets written is exactly what was previewed: content-bound, single-use, tamper-evident.
> **Not**: a human read the preview.
Only the elicitation path can make the confirmation terminate at a person. The full live-session
account is in [`KNOWN_LIMITATIONS.md`](KNOWN_LIMITATIONS.md).
## Over HTTP
```bash
export MAPLE_PROCURE_TOKENS="analyst:$(openssl rand -hex 16):read,pii"
make serve-http # HOST and PORT are overridable
```
`POST /mcp` is the MCP endpoint. `GET /healthz`, `GET /readyz` and `GET /version` need no
credential. A platform health check arrives without one, and a health endpoint that 401s marks
a deploy unhealthy forever.
**`/healthz` and `/readyz` answer different questions, and the difference matters here.**
`/healthz` is liveness and never opens the database, because this deploy rebuilds it at boot and
a check that failed during the rebuild would keep the service from ever coming up. The cost of
that is a blind spot: it still returns `ok` when an ingest has failed and every tool call is
about to return `-32003`. `/readyz` closes it: it reads each source's ingest provenance and
returns `503` with a reason when a source is missing or loaded no rows:
```console
$ curl -s https://maple-procure.onrender.com/readyz | jq -c '{status, tenders: .sources.tenders.source_row_count}'
{"status":"ready","tenders":937}
```
Leave `/healthz` as the platform health check and point external monitoring at `/readyz`.
Tokens are configured as space-separated `subject:secret:scopes` entries. The server refuses to
start if none are set, rather than serving 401 to everyone including its owner. `subject` is a
label for logs, not a credential.
`MAPLE_PROCURE_CONFIRM_KEYS` (space-separated, each ≥32 bytes) keeps export confirmations valid
across restarts. Leaving it unset is the safer default and what the deploy below does: a key is
then generated at startup, so a restart invalidates every outstanding handle. Persisting the key
without also sharing the spent-handle set does the opposite. A restart clears that set in
memory while already-issued handles stay cryptographically valid, opening a five-minute window
for a replay. Set a key only when spent handles are shared too.
### The auth model, stated plainly
**These are static bearer tokens, not OAuth.** The spec's authorization model is OAuth 2.1 with
this server as a resource server; there is no authorization server here, no rotation and no
per-user identity. What is real is the boundary: a token carries scopes and they are enforced.
This is a stand-in and should not be described as OAuth-compliant.
Refusals come at two layers, because they are different answers:
| | Meaning | Response |
|---|---|---|
| No `Authorization` header | We do not know who you are | `401`, `error="invalid_request"` |
| Unrecognised token | That credential is wrong | `401`, `error="invalid_token"` |
| Token without `read` | Known, but not for this endpoint | `403`, `error="insufficient_scope"` |
| Token without `pii` asking for contacts | Known, allowed here, not for *that* | `200` carrying JSON-RPC `-32002` |
The last row is deliberate. Once a caller is authenticated, a per-tool refusal is returned
inside the protocol so the model reads an error with a remedy and can explain it, rather than a
transport failure it cannot interpret.
### REST routes for pipelines
`/mcp` answers a model. `GET /v1/tenders/search` and `GET /v1/tenders/{reference_number}` answer a
pipeline: something that polls on a schedule, branches on what comes back, and cannot ask a
follow-up question.
**They exist because JSON-RPC hides failure from an automation client.** Over `/mcp`, a tool-level
error arrives inside an HTTP `200`. A workflow engine reads that `200` as success, and a `-32001` is
something it can neither branch on nor retry correctly. These routes return every failure as a
status, divided on the one question an automated caller asks: can retrying ever work?
| Status | Meaning | Retry? |
|---|---|---|
| `401` | No credential, or one we do not know | No, fix the token |
| `403` | Known credential, insufficient scope | No, never |
| `422` | The arguments cannot be served as written | No, fix the request |
| `503` | The server is fine, its data is not. A cold start re-ingesting | Yes |
**Both transports share one query layer.** The routes call `tenders.py`, the module the MCP tools
call. They take the same arguments under the same names and return the same envelope, provenance
and `notes` included. Without a model reading the prose, a triage decision has to be explainable
from the payload alone. The bearer token is checked by the same `auth.resolve` on both surfaces, so
they cannot answer differently for the same credential.
**The token is scoped the same way.** `read` reaches both routes. `include_contact=true` also needs
`pii`, and is refused with `403` rather than `-32002`: the gate is the same `scopes.require` the
tool calls, and only the shape of the refusal differs, because a pipeline can branch on a status but
cannot read a remedy.
Two further differences are deliberate. `region` is refused with `422` rather than offered: the
filter drops every notice that states no region, which a pipeline has no reader to notice, and
ignoring the parameter silently would hide that the filter never ran. An unknown reference is
`200` with empty `rows`, as the tool answers it, so the provenance still says which snapshot was
searched. The reasoning is in [`docs/DECISIONS.md`](docs/DECISIONS.md).
**Verified in production.** Run against `https://maple-procure.onrender.com` on 2026-09-14 with a
read-only token. Commands and responses are copied verbatim; the token is shown as `$TOKEN`, and
`...` stands for the host and the token after the first call. The `503` is not shown: a warm
instance has its data.
```console
$ curl -s "https://maple-procure.onrender.com/v1/tenders/search?keywords=software&limit=2" \
-H "Authorization: Bearer $TOKEN" | jq
{
"rows": [
{
"reference_number": "WS3971158100-Doc3971158168",
"title": "Software Licensing Supply Arrangement (SLSA) RFSA",
"buyer_name": "Department of Public Works and Government Services (PSPC)",
"closing_date": "2028-09-29T10:00:00",
"category": "Goods",
"regions_of_delivery": "Canada",
"notice_url": "https://canadabuys.canada.ca/en/tender-opportunities/tender-notice/WS3971158100-Doc3971158168"
},
{
"reference_number": "cb-901-3543961",
"title": "Spectrum Auction Verification Services for the mmWave Auction",
"buyer_name": "Department of Industry (ISED)",
"closing_date": "2026-09-21T14:00:00",
"category": "Services",
"regions_of_delivery": "National Capital Region (NCR); Canada",
"notice_url": "https://canadabuys.canada.ca/en/tender-opportunities/tender-notice/cb-901-3543961"
}
],
"row_count": 2,
"total_matches": 47,
"withheld": 45,
"truncated": true,
"as_of": "2026-09-14T00:19:57+00:00",
"source_file": "https://canadabuys.canada.ca/opendata/pub/openTenderNotice-ouvertAvisAppelOffres.csv",
"source_last_modified": "Sun, 13 Sep 2026 10:20:16 GMT",
"source_row_count": 901,
"coverage": {},
"notes": [
"Data as of 2026-09-14T00:19:57+00:00, from the last ingest of https://canadabuys.canada.ca/opendata/pub/openTenderNotice-ouvertAvisAppelOffres.csv — a snapshot, not a live feed. Newer notices may exist.",
"Showing 2 of 47 matches; 45 withheld. Narrow the search or raise `limit` (max 100) — do not describe these rows as the complete set."
]
}
$ curl -si ".../v1/tenders/search?keywords=software" | grep -i 'HTTP/\|www-authenticate'
HTTP/2 401
www-authenticate: Bearer error="invalid_request", error_description="an Authorization header is required"
$ curl -si ".../v1/tenders/WS3971158100-Doc3971158168?include_contact=true" -H "Authorization: Bearer ..." | grep -i 'HTTP/\|^{'
HTTP/2 403
{"error":"insufficient_scope","error_description":"this request needs the 'pii' scope, which this caller does not hold. Held scopes: read."}
$ curl -s -o /dev/null -w "%{http_code}\n" ".../v1/tenders/search?keywords=software®ion=Ontario" -H "Authorization: Bearer ..."
422
$ curl -s ".../v1/tenders/cb-000-00000000" -H "Authorization: Bearer ..." | jq -c '{row_count, as_of, notes: .notes[1]}'
{"row_count":0,"as_of":"2026-09-14T00:19:57+00:00","notes":"No such reference number in the open tender notices."}
```
### What gets logged
One JSON line per tool call, on stderr. MCP's own Logging feature is deprecated as of
`2026-07-28` and the named migration is stderr and OpenTelemetry.
```json
{"event": "tool_call", "tool": "get_tender", "subject": "analyst", "transport": "http",
"arguments": {"include_contact": true, "reference_number": "str(14)"},
"outcome": "ok", "rows": 1, "duration_ms": 0.41, "request_id": "3", "ts": "..."}
```
**Argument values are redacted by name, not by type.** `reference_number` becomes `str(14)`;
only names on an allowlist of closed-domain arguments (`limit`, `group_by`, `currency`,
`include_contact`, the dates) are recorded as themselves. The default is redaction, so a tool
added later is safe before anyone thinks about it, and users type contact details into search
boxes, because this dataset puts them in free-text descriptions.
`include_contact` is logged deliberately: it is a bool, so it discloses nothing, and it is the
one field a reviewer asking *who pulled contact details* needs. Rejected credentials are never
logged. Mistyping a hostname sends a valid token somewhere it shouldn't go, and that is exactly
how logs come to hold secrets.
Spans come free: the SDK installs `OpenTelemetryMiddleware` on every server and it already reads
`traceparent` from `_meta`. It is inert until you configure an exporter, and once you do, each
log line carries the `trace_id` that joins it to its span.
### Deploying
[`render.yaml`](render.yaml) is a Render blueprint: connect this repo as a Blueprint in the
Render dashboard and it builds the service described there. The one thing it cannot carry is the
token list, which is set in the dashboard as `MAPLE_PROCURE_TOKENS` and never committed.
Two properties of the deploy are deliberate, and both are argued in the blueprint's own comments:
- **The database is rebuilt at boot**, because Render's disk is ephemeral. A cold start
downloads ~16 MB from CanadaBuys and loads it in about six seconds, which serves *fresher*
data than a persisted volume would. The costs are a slow first request after a spin-down and a
dependency on CanadaBuys being up at boot. Its WAF returns transient 403s to repeated bulk
downloads, so a service that cold-starts constantly may occasionally fail to start.
- **No confirmation key is configured**, so a restart invalidates outstanding export handles
rather than leaving replayable ones behind. See the note above.
`PORT` comes from the platform and the Makefile defers to it; `HOST` is `0.0.0.0`; and
`MAPLE_PROCURE_HOST` is filled from Render's own `RENDER_EXTERNAL_HOSTNAME`, because the SDK
only auto-enables DNS-rebinding protection for localhost.
## Why the tool descriptions read the way they do
A tool description is the only thing a model sees when it decides which tool to call, so in
this repo they are treated as code and snapshot-tested
([`tests/snapshots/tools.json`](tests/snapshots/tools.json)). An accidental edit is a
reviewable diff, not a silent behaviour change.
Three problems shaped the current wording, and each is a limit of the data rather than of the
code:
**A model will summarise the rows in front of it and never mention the ones that are missing.**
About 30% of award notices publish no dollar figure, so a "total spend" from this data is
always a floor. Reporting `unvalued_rows: 810` next to the rows was not enough. A number in a
payload gets used for arithmetic, not repeated in prose. So every result also carries a `notes`
list of plain sentences ("These totals are a floor, not a complete figure… Say this when
reporting a total"), and both the tool description and the server instructions tell the model
to pass them on. Sentences get repeated; fields do not.
**Two tools a human can't tell apart, a model can't either.** `search_tenders` and
`search_awards` both take keywords and both return procurement notices. Each description
therefore names the other and says what it is *not* for: open bids versus completed
contracts, and `summarize_spend` and `search_awards` point at each other across the same
line, totals versus individual contracts.
**The search is dumber than it looks.** It is SQLite FTS5: all words must match, and there are
no synonyms. Left unsaid, a model responds to an empty result by adding more words, which can
only narrow it further. So the `keywords` description states that adding words narrows the
search and to try fewer, and that `OR` and `NEAR` are ordinary words here, not operators.
**A half-finished flow reads like a bug worth working around.** `export_report` returning a
preview instead of a file looks, to a model, like a step it should complete. So its description
opens by saying the first call never writes *by design*, and both the description and the
preview's own `notes` say to ask the user before sending the handle back. The mechanism blocks
a single-call write on its own; this wording is what stops the second call being automatic.
### What a live session actually showed
First run against Claude Desktop, 2026-08-16. Two of the bets above were finally testable, and
they did not both come out the same way.
**The `notes` bet held.** Unprompted and in its own prose, the model relayed that spend totals
were a floor rather than a total, that keyword coverage was limited, that the data was a
snapshot rather than a live feed, and that individual notices might be stale. Sentences in the
payload get repeated where numbers in a field do not. The wording stands as written.
**The confirmation wording held only under ambiguity.** Asked for "the three tenders closing
this week", the model got a preview listing four, stopped, and asked which set was wanted before
confirming, the instruction working exactly as intended. Told "export it again", it ran a fresh
preview and passed the new handle back to itself in the same turn, writing without showing
anyone the new preview.
That second behaviour is defensible given an explicit instruction, and it is the honest limit of
this design: on the handle path the checkpoint is a norm the model observes, not a wall it
cannot cross. Recorded rather than papered over in
[`KNOWN_LIMITATIONS.md`](KNOWN_LIMITATIONS.md), with the log evidence.
## Built on
- [`mcp`](https://pypi.org/project/mcp/) 2.0.0, targeting MCP spec revision
[`2026-07-28`](https://modelcontextprotocol.io/specification/2026-07-28/changelog)
- SQLite with FTS5
- FastAPI, hosting the streamable-HTTP transport and its auth boundary
Both stdio and streamable HTTP work. Notes on what the `2026-07-28` revision changed, and how
those changes shaped this design, are in [`docs/SPEC-NOTES.md`](docs/SPEC-NOTES.md).
## Data
Tender and award notices published by Public Services and Procurement Canada via
[CanadaBuys](https://canadabuys.canada.ca/en/procurement-and-contracting-data), used under the
[Open Government Licence – Canada](https://open.canada.ca/en/open-government-licence-canada).
Contains information licensed under the Open Government Licence – Canada.
This project is not affiliated with or endorsed by the Government of Canada. Source files,
refresh cadence and the column map are documented in [`docs/DATA.md`](docs/DATA.md).
## Licence
MIT. See [LICENSE](LICENSE). The licence covers this software, not the government data it
indexes; the terms for that data are in [NOTICE](NOTICE).
TDQS
A4.8/5.0
Scored across 5 tools
Disambiguation5/5
Each tool has a distinct role: searching open tenders, fetching tender details, searching awarded contracts, aggregating spend, and exporting reports. No overlap between any pair.
Naming Consistency5/5
All tools follow a consistent verb_noun pattern with snake_case: search_tenders, get_tender, search_awards, summarize_spend, export_report. The naming is uniform and predictable.
Tool Count5/5
Five tools is well-scoped for a procurement data access server, covering search, detail retrieval, aggregation, and export without unnecessary redundancy.
Completeness5/5
The tool set provides complete coverage for the domain: search open tenders, get full tender details, search awards, summarize spend, and export reports. The workflow from discovery to export is fully supported.
Maintenance
ActivityMaintained
ResponsivenessNo issues