Skip to main content
Glama
samarpassey

maple-procure

by samarpassey

MapleProcure

An MCP server that gives Claude access to Canada's public procurement data.

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 built from official CanadaBuys records, dated and traceable to the source file, and linked to the government notice wherever the source publishes one.

Status: deployed and serving. All five tools work over stdio and over streamable HTTP, behind a scoped bearer token, with a structured log line per call. Driven end to end from Claude Desktop on 2026-08-16 and deployed the same day — the transcript under Try it live is real output from the running service, not an illustration. There is no demo recording yet. This README will not describe a capability until it exists in the code. Current state is in PROGRESS.md; accepted limitations are in KNOWN_LIMITATIONS.md.

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).

A read-only demo token is published here on purpose. It can search tenders and awards; it cannot export reports or read buyer contact details.

# no auth needed
curl https://maple-procure.onrender.com/version

# list the tools (public read-only token)
curl -X POST https://maple-procure.onrender.com/mcp \
  -H "Authorization: Bearer 1e33c26cdaaecddb2fe6b4aa73d2a758bb2f6c56" \
  -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; the only edit is trimming tools/list to the tool names, which is what the jq filter does.

The five tools are live. Responses come back as SSE, so data: is stripped before jq:

$ curl -s -X POST https://maple-procure.onrender.com/mcp \
    -H "Authorization: Bearer 1e33c26cdaaecddb2fe6b4aa73d2a758bb2f6c56" \
    -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:

$ 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 published 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:

$ ... -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:

$ ... -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.

Related MCP server: govtenders-mcp

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.

Quick start

make install      # dependencies into .venv
make ingest       # download CanadaBuys data, build data/maple.sqlite (~6s)
make test
make serve-stdio  # or: make serve-http

Requires Python 3.12+ and uv. No data is committed to this repository — a clean clone reproduces the database from the official source with make ingest.

Connect a client

Point a client at the stdio server. For Claude Desktop or Claude Code:

{
  "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:

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.

Over HTTP

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:

$ 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.

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.

{"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 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) — 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, with the log evidence.

Built on

  • mcp 2.0.0, targeting MCP spec revision 2026-07-28

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

Data

Tender and award notices published by Public Services and Procurement Canada via CanadaBuys, used under the 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.

Licence

MIT — see LICENSE. The licence covers this software, not the government data it indexes; the terms for that data are in NOTICE.

Install Server
A
license - permissive license
A
quality
C
maintenance

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    The most comprehensive keyless federal-data MCP server. 36 tools for SAM.gov + USAspending + Federal Register + eCFR + Grants.gov. No API key, no registration, no signup. Works in Claude Desktop, Claude Code, Codex CLI, Cursor, Continue, Gemini CLI, and any MCP-aware host.
    30
    100
    133
    4
    MIT
  • F
    license
    A
    quality
    D
    maintenance
    MCP server for searching government tenders from CanadaBuys and SAM.gov with free stats and paid search, latest, and AI matching tools using x402 micropayments.
    4
  • A
    license
    -
    quality
    B
    maintenance
    MCP server for analyzing Canadian federal spending data, offering tools for contract search, NLP, semantic search, anomaly detection, and money-flow tracing.
    MIT
  • F
    license
    -
    quality
    B
    maintenance
    MCP server for Canadian procurement intelligence, enabling unified search of federal and Alberta tender opportunities, deadline tracking, profile-based matching, daily briefs, and AI-assisted bid analysis.

View all related MCP servers

Related MCP Connectors

  • Canada Government Procurement MCP — CanadaBuys open data (keyless).

  • MCP server for French (BOAMP) + EU (TED) public procurement data via TenderAPI.

  • This MCP server provides seamless access to Malaysia's government open data, including datasets, w…

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/samarpassey/maple-procure'

If you have feedback or need assistance with the MCP directory API, please join our Discord server