{"id":50491811,"url":"https://github.com/nalyk/ansc-mcp-server","last_synced_at":"2026-06-02T03:05:16.312Z","repository":{"id":355104377,"uuid":"1226731181","full_name":"nalyk/ansc-mcp-server","owner":"nalyk","description":"Modern MCP server for Moldova's ANSC public-procurement appeals — search, decisions, hearing schedule, multi-modal PDF (text + vision-OCR fallback), MCP spec 2025-11-25.","archived":false,"fork":false,"pushed_at":"2026-05-01T21:55:10.000Z","size":489,"stargazers_count":0,"open_issues_count":5,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-01T22:26:13.120Z","etag":null,"topics":["ansc","mcp","mcp-server","model-context-protocol","moldova","oauth2","public-procurement","streamable-http","typescript","web-scraping"],"latest_commit_sha":null,"homepage":"https://www.ansc.md","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/nalyk.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-01T19:09:07.000Z","updated_at":"2026-05-01T21:55:14.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/nalyk/ansc-mcp-server","commit_stats":null,"previous_names":["nalyk/ansc-mcp-server"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/nalyk/ansc-mcp-server","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nalyk%2Fansc-mcp-server","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nalyk%2Fansc-mcp-server/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nalyk%2Fansc-mcp-server/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nalyk%2Fansc-mcp-server/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/nalyk","download_url":"https://codeload.github.com/nalyk/ansc-mcp-server/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nalyk%2Fansc-mcp-server/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33803772,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-02T02:00:07.132Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ansc","mcp","mcp-server","model-context-protocol","moldova","oauth2","public-procurement","streamable-http","typescript","web-scraping"],"created_at":"2026-06-02T03:05:12.937Z","updated_at":"2026-06-02T03:05:16.299Z","avatar_url":"https://github.com/nalyk.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# mcp-ansc-server\n\n[![Listed on Yoda Digital Open Source](https://img.shields.io/badge/listed%20on-opensource.yoda.digital-af9568?style=flat-square)](https://opensource.yoda.digital/en/projects/ansc-mcp-server/)\n[![npm version](https://img.shields.io/npm/v/mcp-ansc-server.svg?label=npm)](https://www.npmjs.com/package/mcp-ansc-server)\n[![npm downloads](https://img.shields.io/npm/dm/mcp-ansc-server.svg)](https://www.npmjs.com/package/mcp-ansc-server)\n[![GitHub release](https://img.shields.io/github/v/release/nalyk/ansc-mcp-server?display_name=tag\u0026sort=semver)](https://github.com/nalyk/ansc-mcp-server/releases)\n[![CI](https://github.com/nalyk/ansc-mcp-server/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/nalyk/ansc-mcp-server/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Node](https://img.shields.io/badge/node-%E2%89%A522-brightgreen.svg)](.nvmrc)\n[![MCP](https://img.shields.io/badge/MCP-2025--11--25-orange.svg)](https://spec.modelcontextprotocol.io/specification/2025-11-25)\n[![SDK](https://img.shields.io/badge/sdk-1.29.x-blue.svg)](https://www.npmjs.com/package/@modelcontextprotocol/sdk)\n\nA Model Context Protocol (MCP) server that exposes Moldova's National\nAgency for Solving Complaints (ANSC, *Agenția Națională pentru\nSoluționarea Contestațiilor*) public-procurement data — appeals,\ndecisions, hearing schedule, and PDF documents — to LLMs and other MCP\nclients.\n\nConformant with **MCP spec 2025-11-25** and the **TypeScript SDK 1.29.x**.\nPublished with **Sigstore provenance** via npm OIDC trusted publishing.\n\n---\n\n## Quickstart\n\n```bash\nnpx -y mcp-ansc-server\n```\n\nThat's it — no install needed. The server speaks stdio and is ready for any\nMCP client to spawn it.\n\nFor Claude Desktop, drop this into your config:\n\n```jsonc\n// ~/.config/claude-desktop/config.json (Linux/macOS) or %APPDATA%\\Claude\\config.json (Windows)\n{\n  \"mcpServers\": {\n    \"ansc\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"mcp-ansc-server\"]\n    }\n  }\n}\n```\n\nSame shape works in Cursor, Continue, Zed, and any other stdio-MCP host.\n\n---\n\n## Table of contents\n\n- [What it ships](#what-it-ships)\n  - [Tools (12)](#tools-12)\n  - [Prompts (3)](#prompts-3)\n  - [Resources](#resources)\n- [Transports](#transports)\n- [Authentication (optional)](#authentication-optional)\n- [Configuration](#configuration)\n- [Running it](#running-it)\n- [Wiring into MCP clients](#wiring-into-mcp-clients)\n- [Operational notes](#operational-notes)\n- [Project layout](#project-layout)\n- [Releasing](#releasing)\n- [Project meta](#project-meta)\n\n---\n\n## What it ships\n\n### Tools (12)\n\n#### Search (4)\n\n| name | what it does |\n|---|---|\n| `search_appeals` | Filter appeals by year / contracting authority / challenger / OCDS procedure ID / status. Paginated, 30 items per page. |\n| `search_decisions` | Filter decisions by year / authority / challenger / procurement object / decision status / decision content / appeal grounds (the 42-ground catalog) / complaint object / appeal number. Paginated. |\n| `search_orders` | Procedural orders (\"încheieri\") issued during a case. `kind=\"general\"` (default) or `kind=\"suspension\"` (incheieri-de-suspendare). |\n| `search_suspended_decisions` | Court-suspended decisions (`decizii-suspendate-{year}`). The authoritative signal that a court has paused enforcement of an ANSC ruling. |\n\n#### Direct lookup (3)\n\n| name | what it does |\n|---|---|\n| `get_appeal_by_registration` | Direct lookup by `02/1245/24`-style number. Year is parsed from the suffix; pages are scanned with bounded concurrency (cached). |\n| `get_decision_by_number` | Direct lookup by `03D-962-24`-style number. |\n| `get_procurement_history` | Given an OCDS procurement ID, return *every* appeal and *every* decision tied to that tender. The OCDS timestamp seeds the year range we scan. |\n\n#### Hearing schedule (3)\n\n| name | what it does |\n|---|---|\n| `list_upcoming_hearings` | All days for which ANSC has published a hearing agenda. |\n| `get_hearings_for_day` | The agenda for a specific day (by URL or ISO date) — list of cases with time, parties, registration number, object, panel. |\n| `find_hearing_for_appeal` | The \"when is my hearing?\" feature — scans every published agenda day for a matching appeal registration number. |\n\n#### Documents (2)\n\n| name | what it does |\n|---|---|\n| `check_decision_court_status` | Looks up a decision and cross-checks the suspended-decisions listing. Returns `{ decision, suspension, isSuspended }`. **Closes a real correctness gap**: `search_decisions` alone can report `decisionStatus: \"În vigoare\"` for items a court has since suspended. |\n| `fetch_ansc_decision` | Download an ANSC decision PDF and return its content. Native-text PDFs return extracted text. Scanned PDFs (Canon/HP/etc., common for older filings — typically with broken Unicode CMap that maps Romanian to garbled Cyrillic) return per-page JPEG `image` content blocks for the host vision-LLM to OCR — language-agnostic, no local Tesseract install. Force a path with `mode: 'auto' \\| 'text' \\| 'image'`. Uses `unpdf.extractImages` (raster bytes already embedded — no canvas backend needed) + `sharp` for re-encoding. |\n\n#### What every tool guarantees\n\n- declares both `inputSchema` (Zod) and `outputSchema` (Zod), so clients\n  receive validated `structuredContent` alongside the human-readable `text`;\n- carries the right tool annotations (`readOnlyHint: true`,\n  `idempotentHint: true`, `openWorldHint: true|false`, `title`);\n- honors cancellation via the `AbortSignal` from the SDK;\n- normalizes Romanian dates to ISO 8601 (`entryDateIso`, `dateIso`)\n  alongside the original `dd/mm/yyyy`;\n- strips trailing punctuation from `appealNumber` / `registrationNumber`;\n- emits `notifications/progress` for long PDF downloads.\n\n### Prompts (3)\n\nPre-canned LLM workflows that clients surface as slash-commands:\n\n| name | args | what it sets the LLM up to do |\n|---|---|---|\n| `summarize_ansc_decision` | `identifier` (decision number or ELO URL) | Fetch the decision and produce a structured Romanian/English summary: parties / procurement / grounds / ruling / legal basis / status. |\n| `procurement_audit` | `procedureNumber` (OCDS ID) | Walk through `get_procurement_history` and produce a chronological narrative of every appeal + decision (with PDF reads when needed). |\n| `compare_appeals` | `firstRegistration`, `secondRegistration` | Side-by-side comparison: parties, grounds, outcomes, divergences. |\n\n### Resources\n\nStatic shortcuts:\n\n- `ansc://appeals/current` — current-year appeals, page 0\n- `ansc://decisions/current` — current-year decisions, page 0\n\nRFC 6570 templates with `complete` callbacks for `year` (2014→current) and\n`page` (0→20):\n\n- `ansc://appeals/{year}`\n- `ansc://appeals/{year}/page/{page}`\n- `ansc://decisions/{year}`\n- `ansc://decisions/{year}/page/{page}`\n\n---\n\n## Transports\n\nTwo transports, chosen via `MCP_TRANSPORT`:\n\n- **`stdio`** *(default)* — for desktop / IDE clients (Claude Desktop, Cursor,\n  Continue, Zed, …) that spawn the server as a subprocess.\n- **`http`** — Streamable HTTP per spec 2025-03-26+, with stateful sessions\n  (`Mcp-Session-Id` header), SSE streaming on `GET /mcp`, session\n  resumability via `Last-Event-ID`. DNS-rebinding protection is enabled\n  automatically when binding to localhost.\n\n---\n\n## Authentication (optional)\n\nANSC's data is **public** — anyone can browse `https://www.ansc.md`. The\ndefault deployment shape is `MCP_TRANSPORT=stdio` (or `http` with\n`AUTH_MODE=none` behind a trusted reverse proxy / Tailscale).\n\nOAuth here only protects the *server itself* from abuse, not the data. If\nyou deploy this on the open internet and want per-principal rate limiting\nand audit logs, set `MCP_TRANSPORT=http` and `AUTH_MODE=oauth`. The server\nthen runs as an **OAuth 2.1 Resource Server** per spec 2025-11-25:\n\n- publishes RFC 9728 Protected Resource Metadata at\n  `/.well-known/oauth-protected-resource`;\n- validates Bearer JWTs with **`jose`** against the issuer's JWKS;\n- enforces the **`aud`** claim per RFC 8707 (the audience must be\n  `HTTP_PUBLIC_URL`) — token passthrough is forbidden by spec and rejected\n  here;\n- on missing/invalid token, returns `401` with\n  `WWW-Authenticate: Bearer resource_metadata=\"…\"`.\n\nThe MCP server itself is *only* a Resource Server. Bring any OAuth 2.1\nAuthorization Server (Auth0, Keycloak, Logto, Hanko, Cognito with static\nclient IDs, …).\n\n### Testing the OAuth integration\n\n```bash\n# Inspect the published metadata\ncurl -fsSL https://mcp.example.com/.well-known/oauth-protected-resource | jq\n\n# Successful call\nTOKEN=…\ncurl -X POST -H \"Content-Type: application/json\" -H \"Authorization: Bearer $TOKEN\" \\\n  --data '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{},\"clientInfo\":{\"name\":\"curl\",\"version\":\"0\"}}}' \\\n  https://mcp.example.com/mcp\n\n# Hit /mcp without a token to see the spec-compliant 401 + WWW-Authenticate\ncurl -i https://mcp.example.com/mcp\n```\n\n---\n\n## Configuration\n\nAll env vars are documented in [`.env.example`](.env.example). Highlights:\n\n```\nMCP_TRANSPORT=stdio|http\nHTTP_HOST=127.0.0.1\nHTTP_PORT=3030\nHTTP_PUBLIC_URL=https://mcp.example.com   # required when AUTH_MODE=oauth\nAUTH_MODE=none|oauth\nOAUTH_ISSUER=https://auth.example.com     # required when AUTH_MODE=oauth\nOAUTH_REQUIRED_SCOPES=mcp:read\n\nANSC_HTTP_TIMEOUT_MS=30000\nANSC_TLS_BYPASS_HOSTS=www.ansc.md,elo.ansc.md   # ANSC's cert is invalid\nANSC_USER_AGENT=\"…\"\n\nCACHE_TTL_CURRENT_S=300       # 5 min\nCACHE_TTL_HISTORICAL_S=86400  # 24 h\nCACHE_MAX_ENTRIES=500\n\nLOG_LEVEL=info\n```\n\nConfig is parsed by Zod at startup; misconfigured envs fail fast.\n\n---\n\n## Running it\n\n### From npm (recommended)\n\n```bash\nnpx -y mcp-ansc-server                # one-shot, no install\n# or\nnpm install -g mcp-ansc-server\nmcp-ansc-server                        # uses the bin\n# or\nMCP_TRANSPORT=http npx -y mcp-ansc-server   # Streamable HTTP on :3030\n```\n\n### From source\n\n```bash\ngit clone git@github.com:nalyk/ansc-mcp-server.git\ncd ansc-mcp-server\nnvm use                                # honors .nvmrc (Node 24)\nnpm ci\nnpm run build\nnpm start                              # stdio\nnpm run start:http                     # MCP_TRANSPORT=http\nnpm run inspect                        # MCP Inspector against the built server\n```\n\n### In Docker\n\n```bash\ndocker build -t mcp-ansc-server .\ndocker run -i --rm mcp-ansc-server     # stdio (typical MCP usage)\ndocker run --rm -p 3030:3030 \\\n  -e MCP_TRANSPORT=http -e HTTP_HOST=0.0.0.0 \\\n  mcp-ansc-server                      # Streamable HTTP\n```\n\nThe image is multi-stage `node:24-alpine`, runs as a non-root `mcp` user.\n\n---\n\n## Wiring into MCP clients\n\n### Claude Desktop / Cursor / Continue / Zed (stdio)\n\n```jsonc\n{\n  \"mcpServers\": {\n    \"ansc\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"mcp-ansc-server\"]\n    }\n  }\n}\n```\n\nFor a pinned version (recommended for stability):\n\n```jsonc\n\"args\": [\"-y\", \"mcp-ansc-server@1.0.1\"]\n```\n\n### As a remote (Streamable HTTP)\n\nPut the server behind TLS (caddy / nginx / Cloudflare Tunnel / fly.io). Point\nthe MCP client at `https://mcp.example.com/mcp`. If `AUTH_MODE=oauth`, the\nclient must include the `resource` parameter (RFC 8707) when exchanging\ntokens, with value `https://mcp.example.com`.\n\n---\n\n## Operational notes\n\n- **Logs** are JSON on **stderr** (pino, level via `LOG_LEVEL`). Stdout is\n  reserved for the MCP wire. Any code path that writes to stdout in stdio\n  mode is a bug — please file it.\n- **Outbound HTTP** uses **undici** with a single hostname-pinned TLS-bypass\n  agent (only `ANSC_TLS_BYPASS_HOSTS` skip cert validation; everything else\n  is verified normally). Retries on 5xx / `ECONNRESET` / socket / header /\n  body timeouts with exponential backoff + jitter, capped at 3 attempts;\n  honors `Retry-After` on 429.\n- **Caching** — HTML responses are cached in-process via `lru-cache`, year-\n  tiered: 5 min TTL for the current year (active data), 24 h for historical\n  years (rarely changes). Configurable.\n- **PDF extraction** — for native-text PDFs, returns extracted text. For\n  scanned PDFs, returns embedded page images for the host vision-LLM to\n  OCR. The heuristic detector triggers on scanner-brand producer strings,\n  low char/byte density, zero Romanian diacritics in a multi-page body, or\n  per-page text under 80 chars. No local Tesseract install required.\n- **HTML parser** matches columns by `\u003cth\u003e` text first (resilient to ANSC\n  reordering); falls back to positional with `parserMode: 'partial'` flag\n  in tool output, so callers can detect when ANSC's layout has drifted.\n\n---\n\n## Project layout\n\n```\nsrc/\n  index.ts                    # bootstrap — picks transport, wires shutdown\n  config.ts                   # Zod env schema\n  logging.ts                  # pino on stderr (+ errMsg helper)\n  api/\n    ansc-client.ts            # undici + retries + lru-cache + lookups\n    pdf-fetcher.ts            # PDF download + unpdf text/image extract + sharp JPEG\n  handlers/\n    tools.ts                  # 12 tools — Zod input/output, annotations\n    resources.ts              # RFC 6570 templates + completions\n    prompts.ts                # 3 LLM workflow templates\n  http/\n    server.ts                 # Express + StreamableHTTPServerTransport (stateful)\n    auth.ts                   # JoseTokenVerifier + PRM router (opt-in)\n  models/\n    appeals.ts                # AppealStatus + Zod\n    decisions.ts              # Decision enums + Zod\n    orders.ts                 # Order (încheieri) + Zod\n    suspended.ts              # SuspendedDecision + Zod\n    hearings.ts               # Hearing / HearingDay + Zod\n    pagination.ts             # Pagination + Zod\n  utils/\n    html-parser.ts            # parseTable\u003cT,F\u003e generic — header-name + positional\n    identifiers.ts            # Romanian id + date helpers (incl. OCDS timestamp)\n    retry.ts                  # exponential backoff with jitter\n__tests__/\n  fixtures/*.html             # synthetic ANSC pages for parser tests\n  *.test.ts                   # html-parser, identifiers, config — 16 tests\n.github/workflows/\n  ci.yml                      # tsc + jest + audit + Dockerfile smoke\n  publish.yml                 # tag-driven OIDC publish to npm + GitHub Release\n  dependabot.yml              # weekly npm + actions updates\nDockerfile                    # node:24-alpine, multi-stage, non-root\n```\n\n---\n\n## Releasing\n\nReleases are tag-driven and fully automated via OIDC trusted publishing —\nno static `NPM_TOKEN` involved.\n\n```bash\nnpm version patch -m \"Release v%s\"     # bumps package.json + lockfile, creates v* tag\ngit push origin main --follow-tags     # triggers .github/workflows/publish.yml\n```\n\nThe workflow runs tsc, tests, build, then publishes to npm with auto-Sigstore\nprovenance and creates the matching GitHub Release with auto-generated notes.\nThe tag↔package.json version drift is caught up front; re-runs are idempotent.\n\n---\n\n## Project meta\n\n- **npm:** [`mcp-ansc-server`](https://www.npmjs.com/package/mcp-ansc-server)\n- **Releases:** [github.com/…/releases](https://github.com/nalyk/ansc-mcp-server/releases)\n- **Changelog:** [CHANGELOG.md](CHANGELOG.md)\n- **Security policy:** [SECURITY.md](SECURITY.md) — please report\n  vulnerabilities via [GitHub private vulnerability reporting](https://github.com/nalyk/ansc-mcp-server/security/advisories/new), not public issues.\n- **License:** [MIT](LICENSE) © 2026 Ion Nalyk Calmis\n- **MCP spec:** [2025-11-25](https://spec.modelcontextprotocol.io/specification/2025-11-25)\n- **TypeScript SDK:** [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) ≥ 1.29.0\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnalyk%2Fansc-mcp-server","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fnalyk%2Fansc-mcp-server","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnalyk%2Fansc-mcp-server/lists"}