mtender-mcp-server
Production-grade Model Context Protocol server for Moldova's MTender public procurement data, modeled on Open Contracting Data Standard 1.1.5.
Lets an AI agent (Claude Desktop, Cursor, Continue, custom MCP clients, etc.)
read, search, audit, and summarize every public procurement in the
Republic of Moldova from public.mtender.gov.md. Tiered document extraction
delegates scanned-PDF OCR to the host's vision LLM — language-agnostic
(Romanian / Russian / English / mixed) without local OCR infrastructure.
Table of contents
- What you can ask an agent
- Install
- Use with an MCP host
- Configuration
- Capabilities
- Document extraction
- Architecture
- Security
- Releases & provenance
- Test
- Docker
- Known upstream limitations
- Contributing & support
- License & acknowledgements
What you can ask an agent
Install
From npm (recommended for MCP host configs — no clone, no build):
# one-shot, no install
npx -y mtender-mcp-server
# or globally
npm install -g mtender-mcp-server
mtender-mcp # stdio
MCP_TRANSPORT=http mtender-mcp # Streamable HTTP
From source (for contributing):
git clone [email protected]:nalyk/mtender-mcp-server.git
cd mtender-mcp-server
npm install
npm run build
npm test
Use with an MCP host
Claude Desktop / Claude Code
Add to your MCP config (~/Library/Application Support/Claude/claude_desktop_config.json
on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows):
{
"mcpServers": {
"mtender": {
"command": "npx",
"args": ["-y", "mtender-mcp-server"]
}
}
}
Cursor / Continue / Cline
Same shape — most MCP-aware editors support stdio servers via the same
command + args JSON config.
Generic Streamable HTTP host
Run it once as a service, point the host at the URL:
MCP_TRANSPORT=http PORT=8787 HOST=127.0.0.1 npx -y mtender-mcp-server
# host config: { "url": "http://127.0.0.1:8787/mcp" }
Configuration
When MCP_AUTH_MODE=bearer is on, the server also publishes RFC 9728
Protected Resource Metadata at /.well-known/oauth-protected-resource{path}
so unauthenticated clients can discover the AS to obtain a token from. The
/healthz endpoint stays public (liveness probes have no credentials).
Refusing without bearer while bound to a non-localhost interface emits a
warning — defense in depth for accidental public exposure.
Capabilities
Resources (5 static + 4 OCID-templated)
All {ocid} templates support typeahead completion from the live latest list.
Tools (17)
All read tools annotate readOnlyHint: true, idempotentHint: true, openWorldHint: true. Slow tools emit notifications/progress. Every fetch
honors AbortSignal for cancellation.
Prompts (8)
OCID arguments are autocompleted from the live mtender://tenders/latest list.
Document extraction pipeline
fetch_tender_document is tiered for the realities of Moldovan procurement
docs (most are scanned by Canon multi-functions):
Detection combines: char-per-byte density (< 0.005 is almost certainly
scanned), scanner-producer keywords in PDF metadata (canon, hp scan,
scanjet, scansnap, epson, xerox, kyocera, ricoh, brother,
konica, lexmark, gimp, imagemagick, tiff, kodak), and absent
Romanian diacritics in long extracted text. The mode: auto | text | image
argument lets callers force a strategy. Page-image cap: 20 pages per call.
Document size cap: 25 MiB.
Architecture
src/
├── index.ts entry: dual-transport (stdio | streamable HTTP)
├── server.ts McpServer + capabilities + instructions
├── tools.ts 17 tools with structured I/O + progress
├── resources.ts 5 static + 4 templated resources, all completable
├── prompts.ts 8 procurement-investigation workflows
├── api/mtender.ts undici keep-alive client, retry, multi-package
│ compile, TTL+LRU caches, listing endpoints
├── ssrf.ts URL parse + DNS lookup + private-IP block
├── document.ts unpdf + mammoth + sharp tiered extraction
├── cache.ts tiny TTL+LRU
├── concurrency.ts bounded fan-out helper
├── schemas.ts OCDS-aligned Zod types
└── logger.ts pino → fd 2 (stderr)
- MCP protocol revision 2025-11-25, SDK
@modelcontextprotocol/[email protected] - Node.js 22+, TypeScript strict, ESM only
- 6 runtime deps +
expressfor the HTTP transport. Distroless multi-stage Docker image (gcr.io/distroless/nodejs22-debian12:nonroot) - Compiles a real OCDS record by fanning out to upstream
packages[]URIs and merging by id-union —compiledReleasefrom MTender is sparse, so awards/items/parties have to be reassembled
Security
- Streamable HTTP binds to
127.0.0.1by default and refuses requests whoseHostheader isn't in the allow-list (DNS-rebinding mitigation per the MCP 2025-11-25 security best practices) - Document fetch validates URL with
new URL(), assertshostname === "storage.mtender.gov.md", then resolves DNS and rejects any RFC1918 / loopback / link-local /169.254.169.254(AWS/GCP IMDS) / IPv6 ULA result before issuing the actual request - Stateless sessions (
sessionIdGenerator: undefined) — no session ID to hijack. Per spec: "MCP servers MUST NOT use sessions for authentication." - Logs to stderr; stdout is reserved for JSON-RPC
- CodeQL (
security-and-qualityquery suite) runs on every push and PR - Dependabot weekly + on-CVE auto-PRs
- No bundled secrets;
.env*in.gitignore
For vulnerability reports see SECURITY.md. Use GitHub's private advisory form, not public issues.
Releases & provenance
This package is published to npm via trusted publishers
— GitHub Actions authenticates to the npm registry directly via OIDC, no
static NPM_TOKEN secret. Every release after the v3.1.0 bootstrap is
attested with Sigstore provenance
proving the tarball was built in this GitHub workflow from this commit.
Verify the chain locally:
npm view mtender-mcp-server versions --json
npm view mtender-mcp-server@latest dist.attestations
npm audit signatures # in any project that depends on it
Release flow (one command):
npm version patch -m "Release v%s" # bumps + commits + tags
git push origin main --follow-tags # triggers OIDC publish + GitHub release
The publish workflow has built-in guards: tag↔version drift fails the run; re-running on an already-published version skips the publish + release-create steps idempotently.
Test
npm test
20 tests against the live MTender API (resource read + tool calls +
completion + aggregation + listings + lots/enquiries + scanned-PDF detection
regression) plus the SSRF guard, using the SDK's InMemoryTransport for
in-process client/server pairing. Runs in ~30 seconds.
Docker
docker build -t mtender-mcp .
docker run --rm -p 8787:8787 mtender-mcp
Distroless gcr.io/distroless/nodejs22-debian12:nonroot, runs
MCP_TRANSPORT=http by default. The CI pipeline rebuilds the image on
every push to confirm it still bakes cleanly.
Known upstream limitations
These are out of our control — MTender publishes what MTender publishes:
- No server-side text search. Upstream
/tenders/accepts onlyoffset.search_tenders_deepdoes client-side filter after fetching the latest page — the only viable approach. - No descending pagination. The API is ascending-by-date only; "latest"
requires passing
offset≈now, which this server does by default. - Implementation/transactions section sparse. MTender doesn't track
contract execution stage in this dataset. Reflected in
TenderSummary. - Romanian-only content. No English / Russian translations of fields.
Upstream Spring Boot version + status is surfaced at
mtender://upstream/health for ops visibility.
Contributing & support
- CONTRIBUTING.md — project shape, contribution norms, how to add a tool / resource / prompt
- CHANGELOG.md — Keep-a-Changelog entries per version
- SECURITY.md — private vulnerability reporting + scoped threat model
- Issues — bug reports and feature requests use structured templates
- Discussions — questions, design conversations
License & acknowledgements
ISC © Ion (Nalyk) Calmîș.
Built on:
- Model Context Protocol and the TypeScript SDK by Anthropic + the MCP community
- Open Contracting Data Standard 1.1.5
- public.mtender.gov.md — Moldova's e-Procurement public data point
- unpdf, mammoth, sharp, undici, pino, zod