Skip to main content
Glama
MatchAwards MCP server

MatchAwards MCP server

Official
README.md
# MatchAwards MCP server

<!-- mcp-name: io.github.matchawards/matchawards-mcp -->

*MatchAwards gives AI agents direct access to continuously updated government and business contracts, grants, jobs, awards and collaboration opportunities.*

This is the [Model Context Protocol](https://modelcontextprotocol.io) server for [MatchAwards](https://matchawards.com). It lets Claude, Cursor, VS Code and other MCP clients search US government contract opportunities, grants and jobs, and past contract and grant awards. It only reads data, and it needs no account and no API key.

## Tools

| Tool | What it returns | Example prompt |
|---|---|---|
| `search_contracts` | Federal and state contract opportunities. Filters: NAICS (up to 10 codes), state, keyword, set-aside, federal or state only, posted within N days, open only, agency (by name), 1 to 50 results, paging. | "Find open roofing contracts in Virginia posted in the last week." |
| `search_grants` | Federal grant and funding opportunities. Filters: NAICS, keyword, posted within N days, open only, 1 to 50 results, paging (grant notices carry no state). | "Are there open broadband grants?" |
| `search_jobs` | Job postings from the last 30 days. Filters: NAICS, state, keyword, posted within N days, 1 to 50 results, paging. | "Show electrician jobs in Texas from the last 7 days." |
| `get_opportunity` | The full record of one opportunity: the search row plus its description, published points of contact (name and title) and, where MatchAwards has them, AI-generated notes (`ai`: fit, reasoning, subcontracting and large-prime angles, summary), labelled as AI-generated. | "Who do I contact about the second result?" |
| `lookup_naics` | NAICS codes from words ("cybersecurity") or from a 2 to 6 digit code prefix, with each code's page on matchawards.com. 1 to 25 codes. | "What NAICS code covers cybersecurity consulting?" |
| `search_awards` | Past federal contract awards and grant awards: awardee (name, UEI, DUNS), amount, date, agency, NAICS, set-aside, place, solicitation, contract and CFDA numbers, links to matchawards.com and to SAM.gov or USAspending. Filters: type, NAICS, agency (by name), state, awardee name, UEI, keyword, awarded within N days (up to 1,095), 1 to 50 results, paging. | "Who won VA contracts under NAICS 541512 in the last year?" |
| `get_company` | One firm's award history by UEI: names, location (city, state, country), contract and grant totals, first and last award dates, top NAICS codes and agencies, the 10 most recent awards. No contact details. | "Show the award history of UEI C3KXNLTAAY98." |
| `assess_opportunity` | A qualification brief on one opportunity: days left (US Eastern), open or closed, set-aside and NAICS (and whether they match the user's `my_set_aside` and `my_naics`), the AI-generated notes, the 5 newest similar awards (same NAICS at the same agency, or all agencies when that agency has none; the newest 150 at most from the last 3 years, and `sample` says which dates were read and whether that was all of them), the top 5 firms that won similar work (by count, then total), the quartiles of their award amounts and a coverage note. | "Should we bid on the first result? We're NAICS 336413, small business." |
| `find_teaming_partners` | Up to 10 firms that won similar awards, from the newest records read (`sample`; same NAICS in the opportunity's state, then at its agency; the NAICS anywhere when neither returns any and award requests are left), de-duplicated by UEI, ranked by count, then the most recent, then the total, each with a `why` line written from the records. Firms without a UEI in the records are listed with `uei: null`, and the note says their history can't be looked up. `role` (`sub`, `prime`, `any`) puts smaller or larger typical awards first among firms with the same count. Public award data only: no contacts, no profile links. | "Who could we team with on that Air Force notice?" |

The `agency` filter takes a name. The server looks it up among MatchAwards' agencies (exact name first, then names that start with it, then the rest in the API's order) and filters by the best match only, for now; the result's `note` names that agency and lists up to three other matches (one department can have several agency accounts), and when nothing matches, the search covers all agencies and the note says so. Lookups are cached for 10 minutes.

Award records may be incomplete (federal awards especially), so every award result says so. The two brief tools describe firms as having won similar work, never as the incumbent; they make at most 1 opportunity request and 6 award requests per call (at most 3 per query, shared by the queries), within 6 seconds, and when either limit cuts the lookup short they answer with what they read and a `warning`. Their `sample` says how many award records the figures rest on, their dates, and whether every matching record was read. A grant award's title is its recipient's name, so the model is asked to head it with the awardee name and CFDA number instead.

Every tool is read-only and needs no account (except `my_matches`, a hosted tool that exists only with account linking on; see Linked accounts): each declares `readOnlyHint`, `idempotentHint`, a title and `securitySchemes: [{"type": "noauth"}]` (in `_meta`). Results are structured: each tool publishes an `outputSchema` and returns `structuredContent`, with the same JSON as text for clients that read only text. Every result carries a `url` on matchawards.com (and a `sam_url` for federal notices), and the server asks the model to show them as markdown links. Links to matchawards.com carry `utm_source`, `utm_medium` and `utm_campaign` parameters naming the AI host the request came from (for example `utm_source=chatgpt`); links to other sites are left unchanged.

### Changes in 1.1.6

- Rate limits (hosted): only tool calls count in the per-client limit, now 60 per minute and 20 per 5 seconds (the MatchAwards API's own per-address limit; was 30 and 10 for every request), 240 per minute per IPv6 /48. Handshake and listing requests, which coding agents such as Codex repeat before every call, have their own budget of 600 per minute and 120 per 5 seconds (`MATCHAWARDS_PROTOCOL_PER_MIN`, `MATCHAWARDS_PROTOCOL_BURST`), with 6000 per minute for all clients together (`MATCHAWARDS_PROTOCOL_GLOBAL_PER_MIN`, the only one that applies to ChatGPT's addresses). Rejections count as `rate_limit_protocol` and `rate_limit_protocol_global`. A body that does not arrive within 10 seconds gets 408 (`timeout`).
- The Codex CLI (User-Agent `codex-mcp-client/...`) is its own family, `codex`, in the usage metrics. Its links keep `utm_source=mcp`.

### Changes in 1.1.5

- Go-links (hosted, `MATCHAWARDS_GO_LINKS=1`): an opportunity's `sam_url` and an award's `source_url` are given as the record's `https://matchawards.com/go/<utm_source>/<id>` link, which redirects to the same SAM.gov or USAspending page and counts the hand-off. A record without a MatchAwards id or page keeps its plain link. Off by default.

### Changes in 1.1.4

- `search_contracts`: the first page of a NAICS search that found something tells the model it may offer, once per conversation, a free MatchAwards account that sends new matches for those codes by email, with a sign-up link carrying the host's UTM parameters. `my_matches` never offers it.
- `MATCHAWARDS_CHATGPT_SIGNIN=0` (hosted, linking on): while ChatGPT's connection offers no sign-in, `my_matches` answers ChatGPT without the challenge (ChatGPT shows one only as an internal error): sign-in isn't available there yet, search by NAICS codes instead. Counted as outcome `signin_unavailable`.

### Changes in 1.1.3

- `my_matches` without a linked account: when the app gives the user no way to sign in, the model says plainly that signing in to MatchAwards isn't available there yet (not an error), asks what the business does, finds the codes with `lookup_naics` and searches with `search_contracts`.

### Changes in 1.1.2

- No NAICS codes given: the instructions have the model ask what the user's business does (or its codes) and find the codes with `lookup_naics`, in every mode. With linking on, `my_matches` on a profile without codes asks the same, searches with the codes found (keeping the profile's state and set-aside) and offers the match-settings link to save them.
- "Nothing here needs an account" is in the instructions only with linking off; with linking on it contradicted `my_matches`.
- The keyword hint says keyword search matches whole words in titles, and to try another form of the word.

### Changes in 1.1.1

- Hosted server: `MATCHAWARDS_API_MAX_IN_FLIGHT` (default 16) caps the requests to the public API in flight at once for the whole server. A request waits up to 5 seconds for a free slot (less when the tool's own time limit is nearer), then the tool answers "busy, retry" without calling the API. The per-minute API budget is spent only once a slot is held.

### Changes in 1.1.0

- Tool choice: `assess_opportunity` is described as the tool for fit, bid/no-bid and bid-brief questions, and `get_opportunity` points those questions to it (contracts and grants only).
- Keyword hint: a contract, grant or award search by keyword with no NAICS code and only a few rows says in its note that keyword search matches titles only and points to `lookup_naics`.
- Hosted server metrics: tool calls by User-Agent family, rows returned per list tool, MatchAwards API timing per endpoint and outcome, API requests per tool call, distinct ChatGPT conversations and first-seen ChatGPT users. Stats show the empty-result rate per tool; the ledger gains a `rows` column, added at open.
- Hosted server: optional account linking and the `my_matches` tool (see Linked accounts). Off unless `MATCHAWARDS_AUTH_ISSUER` is set; with it off nothing else changes.

### Changes in 1.0.4

- The result card renders in Claude: its `_meta.ui.domain` is left out for Claude, which accepts only its own sandbox domain; other hosts still get `https://matchawards.com`.

### Changes in 1.0.3

- Hosted server: usage-stat rows are kept 180 days by default (was 365), set by `MATCHAWARDS_USAGE_RETENTION_DAYS`.

### Changes in 1.0.2

- Cards come from a new tool, `show_results`, which the model calls once with the ids it picked; the search tools return data only. The card (`results-v2.html`) expands a row in place and has an "Ask about this" button.
- Results filtered by `naics` say which requested codes matched: `matched_naics` (the record's own codes) and, on contract notices, `ai_matched_naics` (codes MatchAwards' AI suggested).
- Links to matchawards.com carry UTM parameters naming the AI host.
- Hosted server: the shared limit is 1200 requests per minute, and `/metrics` adds tool latency, distinct-caller and client counts.

### Changes in 1.0

- `search_by_naics` is removed: `search_contracts`, `search_grants` and `search_jobs` take the same `naics` filter on its own.
- `find_contacts` is removed: `get_opportunity` returns the published contacts with the rest of the record.
- Results are structured output (`outputSchema` plus `structuredContent`). Fields the API adds later appear in results only once the schema declares them. `get_opportunity` returns `ai` (AI-generated notes, labelled as such) where MatchAwards has them, else null.
- Tool annotations are closed-world (`openWorldHint: false`): the tools read the MatchAwards catalog only.
- New tools: `lookup_naics`, `search_awards`, `get_company`, `assess_opportunity` and `find_teaming_partners`; `search_contracts` takes an `agency` name.

## Cards

Hosts that render [MCP Apps](https://modelcontextprotocol.io/extensions/apps) (ChatGPT, Claude) get one inline card per answer: after searching, the model calls `show_results` with up to 10 contract or grant ids it picked, and the card lists up to 8 of them with title, agency, place, a federal/state/grant badge, the deadline (days left, closed, or no fixed deadline), set-aside and NAICS code. Ids it could not show (not found, a job, or not fetched because MatchAwards was busy) are counted under the list. The search tools return data only, with no card. Clicking a row expands it (posted date, solicitation, office, NAICS, set-aside); its "Ask about this" button asks the model for that opportunity's details, and its "Open on MatchAwards" link opens its page. The card is one self-contained HTML resource, `ui://matchawards/results-v2.html` (`text/html;profile=mcp-app`): no scripts or styles from elsewhere and no network requests, so its CSP allows nothing. It renders only the tool's `structuredContent`, and never calls a tool itself.

The other tools return their structured results without a card.

Cards are off by default. Set `MATCHAWARDS_WIDGETS=1` to list the resource and `show_results`, which points at it (`_meta.ui.resourceUri`); the other tools' results are the same either way. `tests/widget_harness/` holds a minimal host page with sample results for checking the card in a browser.

## Hosted

A hosted endpoint runs at `https://matchawards.com/mcp`. Add it as a remote MCP server (connector) by URL, with nothing to install and no key:

- **ChatGPT**: add a custom connector with the URL `https://matchawards.com/mcp`.
- **Claude** (claude.ai or Claude Desktop): Settings, Connectors, add a custom connector with the same URL. Claude Code: `claude mcp add --transport http matchawards https://matchawards.com/mcp`.
- **Cursor**: in `mcp.json`, `{"mcpServers": {"matchawards": {"url": "https://matchawards.com/mcp"}}}`.

The hosted endpoint serves the same tools. Tool calls are limited per client address (IPv6: per /64) to 60 per minute and 20 per 5 seconds, per IPv6 /48 to 240 per minute, and to 1200 per minute for all clients together (ChatGPT's shared pool: 600). Handshake and listing requests (`initialize`, `tools/list`, `prompts/list`, `resources/list`, `resources/read`, `ping`, notifications) have their own budget of 600 per minute and 120 per 5 seconds per client (per IPv6 /48: 2400 per minute; all clients together: 6000 per minute), so a client that reconnects before every call is not stopped by them. Over a limit it answers HTTP 429 with `Retry-After`.

## Install

The server runs with [`uvx`](https://docs.astral.sh/uv/), which comes with uv.

### Claude Desktop

Add this to `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`, Windows: `%APPDATA%\Claude\claude_desktop_config.json`), then restart Claude Desktop:

```json
{
  "mcpServers": {
    "matchawards": {
      "command": "uvx",
      "args": ["matchawards-mcp"]
    }
  }
}
```

### Claude Code

```bash
claude mcp add matchawards -- uvx matchawards-mcp
```

### Cursor

Add this to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project):

```json
{
  "mcpServers": {
    "matchawards": {
      "command": "uvx",
      "args": ["matchawards-mcp"]
    }
  }
}
```

### VS Code

Add this to `.vscode/mcp.json` in your workspace:

```json
{
  "servers": {
    "matchawards": {
      "type": "stdio",
      "command": "uvx",
      "args": ["matchawards-mcp"]
    }
  }
}
```

### Other clients

Any MCP client that can start a stdio server works. The command is `uvx matchawards-mcp`.

### OpenClaw

[`integrations/openclaw-plugin`](integrations/openclaw-plugin) holds a native OpenClaw plugin (`@matchawards/openclaw-plugin` on ClawHub) whose nine tools call the hosted endpoint, and a ClawHub skill, `matchawards`, that teaches an agent the public REST API with curl. Both are read-only and need no account. The plugin's `tools.json` is generated from this server's tool list (`integrations/openclaw-plugin/scripts/gen_tools.py`), and the test suite fails when it is out of date.

### Run the HTTP server yourself

`matchawards-mcp --http` serves the same tools over stateless Streamable HTTP (JSON replies, no sessions, no SSE) at `http://127.0.0.1:8765/mcp`:

```bash
uvx matchawards-mcp --http
curl -s http://127.0.0.1:8765/mcp -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' -H 'MCP-Protocol-Version: 2025-06-18' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

How requests are handled, in order:

- Repeated and trailing slashes are folded, so `/mcp/` and `//mcp` count as `/mcp`. Any path other than `/mcp` and `/healthz` gets 404 (with account linking on, `/.well-known/oauth-protected-resource` and `/.well-known/oauth-protected-resource/mcp` serve the protected-resource metadata, like `/healthz` on any host and not rate-limited).
- `GET /healthz` returns `ok` and is not rate-limited.
- A `Host` header not in `MATCHAWARDS_ALLOWED_HOSTS` (or `127.0.0.1`, `localhost`) gets 421, whatever the method.
- Only `POST /mcp` is served. `GET`, `DELETE` and `OPTIONS` get 405 (no SSE stream, no sessions, no CORS).
- A POST needs `Content-Length` (411 without it), and bodies over 64 KB get 413.
- Rate limit per client (per ChatGPT user from OpenAI's addresses), then the server-wide cap: 429 with `Retry-After`. The body is read first (it must arrive within 10 seconds, else 408), to tell a handshake or listing request (`initialize`, `ping`, `server/discover`, `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read`, any notification) from the rest, by the method in its first 8 KB: those count only in the protocol budgets (per client: `MATCHAWARDS_PROTOCOL_PER_MIN` / `_BURST`, per IPv6 /48 four times the per-minute one; all together: `MATCHAWARDS_PROTOCOL_GLOBAL_PER_MIN`), and from OpenAI's addresses only in the all-together one. Anything else (a tool call, another or unknown method, a batch, a body that is not JSON or longer than 8 KB) counts in the tool-call limits.
- With account linking on, a `tools/call` of a tool that needs a linked account, without a usable token, gets 401 (403 when the token lacks the scope) with `WWW-Authenticate`; ChatGPT gets the same challenge as a tool result. Nothing else is checked.

The client is the socket address, or the `X-Real-IP` header when the socket address is in `MATCHAWARDS_TRUSTED_PROXIES` (IPv6 is keyed per /64; all /64s inside one /48 also share `MATCHAWARDS_RATE_PER_PREFIX_MIN`). Run it behind a reverse proxy that sets `X-Real-IP`, and do not expose the port directly. The limits are kept in memory per process. A bad value in any of the variables below stops the server at startup. The log has one line per request (method, path, status, duration, a hashed client key), never headers or bodies.

ChatGPT calls come from OpenAI's shared egress addresses ([published list](https://openai.com/chatgpt-connectors.json), bundled with the package and refreshed daily; a failed refresh keeps the last good list), so one address carries many users and a per-IP limit would throttle them together. From those addresses only, a request is limited per `x-openai-subject` (ChatGPT's per-user id, kept only as a keyed hash) and all ChatGPT traffic shares `MATCHAWARDS_EGRESS_PER_MIN`; a request without the header counts in that pool only. The header from any other address is ignored, and the User-Agent is never used.

In Docker, nginx on the host reaches the container from the compose network's gateway, not from loopback, so name that address exactly. Read it with `docker network inspect <network> --format '{{(index .IPAM.Config 0).Gateway}}'` and set, for example:

```bash
MATCHAWARDS_HTTP_HOST=0.0.0.0
MATCHAWARDS_TRUSTED_PROXIES=127.0.0.1/32,::1/128,172.18.0.1/32
```

Do not trust a broad private range such as `172.16.0.0/12`: it may overlap your internal network, and any host in it could then pick its own rate-limit key.

## Configuration

| Variable | Default | Purpose |
|---|---|---|
| `MATCHAWARDS_API_BASE` | `https://matchawards.com` | API base URL. Change it only for development. |
| `MATCHAWARDS_HTTP_HOST` | `127.0.0.1` | `--http` only: address to listen on. |
| `MATCHAWARDS_HTTP_PORT` | `8765` | `--http` only: port to listen on. |
| `MATCHAWARDS_RATE_PER_MIN` | `60` | `--http` only: tool calls per minute per client (the MatchAwards API's own per-address limit). |
| `MATCHAWARDS_RATE_BURST` | `20` | `--http` only: tool calls per 5 seconds per client. |
| `MATCHAWARDS_RATE_PER_PREFIX_MIN` | `240` | `--http` only: tool calls per minute for all IPv6 /64s inside one /48 together. |
| `MATCHAWARDS_PROTOCOL_PER_MIN` | `600` | `--http` only: handshake and listing requests per minute per client (all /64s of one IPv6 /48 together: four times this). |
| `MATCHAWARDS_PROTOCOL_BURST` | `120` | `--http` only: handshake and listing requests per 5 seconds per client. |
| `MATCHAWARDS_PROTOCOL_GLOBAL_PER_MIN` | `6000` | `--http` only: handshake and listing requests per minute for all clients together, ChatGPT's included. |
| `MATCHAWARDS_GLOBAL_PER_MIN` | `1200` | `--http` only: tool calls per minute for all clients together. Must be above `MATCHAWARDS_EGRESS_PER_MIN` (otherwise the server will not start). |
| `MATCHAWARDS_RATE_PER_SUBJECT_MIN` | `30` | `--http` only: tool calls per minute per ChatGPT user (`x-openai-subject`, from OpenAI's egress addresses only). |
| `MATCHAWARDS_RATE_SUBJECT_BURST` | `10` | `--http` only: tool calls per 5 seconds per ChatGPT user. |
| `MATCHAWARDS_EGRESS_PER_MIN` | `600` | `--http` only: tool calls per minute for all ChatGPT traffic together. |
| `MATCHAWARDS_GLOBAL_API_PER_MIN` | `900` | `--http` only: API calls per minute for the whole server (one search can make up to 3). Over it, a tool answers "MatchAwards is busy, retry in N seconds" without calling the API. |
| `MATCHAWARDS_API_MAX_IN_FLIGHT` | `16` | `--http` only: requests to the public API in flight at once for the whole server (the linked-account profile check is not counted). Slow searches each hold a database connection on the API side, so this, more than the per-minute budget, bounds the load. A request waits up to 5 seconds for a free slot (less when the tool's own time limit is nearer); past that the tool answers "MatchAwards is busy, retry in 5 seconds" without calling the API or spending `MATCHAWARDS_GLOBAL_API_PER_MIN`. |
| `MATCHAWARDS_TRUSTED_PROXIES` | `127.0.0.1/32,::1/128` | `--http` only: proxies (CIDRs, comma-separated) whose `X-Real-IP` header is trusted. Loopback only by default. |
| `MATCHAWARDS_ALLOWED_HOSTS` | `matchawards.com,staging.matchawards.com` | `--http` only: accepted `Host` headers, comma-separated (`127.0.0.1` and `localhost` are always accepted). |
| `MATCHAWARDS_USAGE_DB` | `/data/usage.db` | `--http` only: SQLite file for the usage stats. If its directory is missing or not writable, the server logs one warning and records nothing. |
| `MATCHAWARDS_USAGE_SALT` | unset | `--http` only: secret for the ChatGPT caller fingerprint. Unset: no fingerprint is stored. |
| `MATCHAWARDS_METRICS_TOKEN` | unset | `--http` only: Bearer token for `/metrics`. Unset: the metrics listener does not start. |
| `MATCHAWARDS_METRICS_HOST` | `127.0.0.1` | `--http` only: address of the metrics listener. |
| `MATCHAWARDS_METRICS_PORT` | `9765` | `--http` only: port of the metrics listener. |
| `MATCHAWARDS_USAGE_RETENTION_DAYS` | `180` | `--http` only: days usage-stat rows are kept. |
| `MATCHAWARDS_AUTH_ISSUER` | unset | `--http` only: the OAuth authorization server, an https URL (http only on `localhost`, `127.0.0.1` or `[::1]`, for a local run; e.g. `https://matchawards.com`). Set: account linking is on. Unset: no linking, and nothing else changes. |
| `MATCHAWARDS_RESOURCE` | `<issuer>/mcp` | `--http` only, linking on: this server's public https URL (http only on a loopback host), the resource tokens are issued for. |
| `MATCHAWARDS_CHATGPT_SIGNIN` | `1` | `--http` only, linking on: `0` when ChatGPT's connection to this server offers no sign-in (an app published with No authentication). ChatGPT shows a challenge result only as an internal error, so `my_matches` then answers ChatGPT without one: sign-in isn't available there yet, search by NAICS codes instead (outcome `signin_unavailable`). A linked ChatGPT user and other hosts are unaffected. |
| `MATCHAWARDS_GO_LINKS` | `0` | `1`: a record's source link (an opportunity's `sam_url`, an award's `source_url`) is given as `https://matchawards.com/go/<utm_source>/<record id>` (the host of the record's own matchawards.com page), which redirects to that source and counts the hand-off. A record without a numeric MatchAwards id or a matchawards.com page keeps its plain link. Turn it on only where the site serves `/go/<via>/<id>`. |
| `MATCHAWARDS_AGENT_API_BASE` | `MATCHAWARDS_API_BASE` | `--http` only, linking on: base URL of the account API that checks a linked user's token. |
| `MATCHAWARDS_WIDGETS` | unset | `1`: serve the result card (see Cards). Unset: no card resource and no `show_results`. |

## Linked accounts

Hosted mode (`--http`) only, and off unless `MATCHAWARDS_AUTH_ISSUER` is set. Off, the tool list, the instructions and every result are the same as without it.

On, a user can link their MatchAwards account (sign in with Google or email on matchawards.com) and gets one more tool, `my_matches`: open contracts matching the NAICS codes and state on their MatchAwards profile, with the rows, paging and links of `search_contracts` (`type` narrows to federal or state, `ignore_state` drops the state filter, which skips federal notices without a state). Notices open to all firms are included; `only_my_set_aside` keeps only those set aside for the profile's set-aside (federal notices only: a state-only search never takes a set-aside), and otherwise the note names it so the model can point out the set-aside ones. `profile_used` says which filters came from the profile (the set-aside's code and name). The search takes the first 10 NAICS codes on the profile, and the note says so when it has more. A profile without NAICS codes gets no search, and a `settings_url` to its match settings instead; its note asks the model to ask the user what their business does, find the codes with `lookup_naics`, search with them and offer the link to save them. Every other tool still needs no account and never reads a token.

- matchawards.com is the OAuth authorization server; this server is only the resource server, and serves its protected-resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource/mcp`. The only scope is `profile:read`. `my_matches` declares `securitySchemes` `[{"type": "noauth"}, {"type": "oauth2", "scopes": ["profile:read"]}]`; every other tool keeps `noauth` only.
- A `tools/call` of `my_matches` without a usable token gets a challenge naming the metadata and the scope. Claude and other MCP clients get HTTP 401 (403 when the token lacks `profile:read`) with `WWW-Authenticate`, before the tool runs, and start linking on it. ChatGPT (User-Agent `openai-mcp`) links on a tool result instead, so it gets a 200 result with `isError` and the same header value in `_meta["mcp/www_authenticate"]`. When MatchAwards does not answer the token check, the call fails with a "busy, retry" error, and when it refuses the account itself (a disabled or suspended account, or a token of an app that is not an agent app) with a plain error: neither is a challenge, since linking again would not help.
- The token check is one `GET {MATCHAWARDS_AGENT_API_BASE}/api/agent/v1/me` with the token. Only a token issued for this server (`MATCHAWARDS_RESOURCE`, RFC 8707) is taken (scheme and host compared case-insensitively, the path exactly), and not one whose `token_expires_at` has passed. From the answer only the account's opaque id (`sub`), its NAICS codes, set-aside, state, UEI, organization name and match-settings link (only an https link on matchawards.com) are read. The answer is kept in memory for 60 seconds (a rejected token for 30), under a keyed hash of the token.
- What is kept: the ledger's `linked` flag on each tool call (whether it ran with a linked account), and in memory a salted hash of the account's `sub` for the distinct-account gauge. Tokens, the `Authorization` header and account ids are never stored or logged.

## Usage stats (hosted mode)

With `--http` the server keeps usage stats in a SQLite file (`MATCHAWARDS_USAGE_DB`, WAL mode). stdio mode records nothing. Writes run on two background threads and are best-effort: a failed write is counted in `mcp_ledger_errors_total` and never fails a request. Rows older than `MATCHAWARDS_USAGE_RETENTION_DAYS` days (default 180) are deleted, at most once a day.

What is recorded:

- **Tool calls**: one row per call that reached a tool: time, tool name, outcome (`ok`, `invalid_input`, `upstream_error`, `bad_response` (a reply the tools cannot read), `upstream_timeout` (the API ran out of time; retry), `partial` (`assess_opportunity` or `find_teaming_partners` answered, but its request or time limit cut the award lookup short), `rate_limited_upstream`, `busy`, `internal_error`, `link_required` (`my_matches` answered with the linking challenge), `forbidden` (`my_matches` for an account MatchAwards refuses)), whether it ran with a linked account (`linked`, 0 or 1; never which account), the rows a list-returning tool answered with (`rows`: `search_contracts`, `search_grants`, `search_jobs`, `search_awards`, `lookup_naics`, `find_teaming_partners` (firms), `show_results` (ids shown) and `my_matches`, on an `ok` or `partial` call; NULL for other tools and other outcomes; added to an older ledger at open), the client network (IPv4 /24 or IPv6 /48), the User-Agent (first 200 characters), its family (`openai-mcp`, `codex`, `claude-user`, `claude`, `cursor`, ..., `prober` or `other`) and the latency. Calls stopped by the rate limit are counted in a metric, with no row. Arguments the SDK rejects before the tool runs leave no row either; they still count in the transport table as `tools/call`.
- **Transport, per day**: one counter per client network, JSON-RPC method (known MCP methods only, anything else is `_other`) and `clientInfo` name and version, with the last User-Agent seen. `clientInfo` is read from the first 8 KiB of an `initialize` body, or from `params._meta` (2026-07-28 clients), as the body streams past; the body is not buffered. At most 50 counters per client network and day; past that they fold into `_other`.
- **ChatGPT callers**: when `MATCHAWARDS_USAGE_SALT` is set and a request from ChatGPT's egress addresses carries `x-openai-subject` (ChatGPT's anonymous per-user id; from any other address it is ignored), the tool-call row stores a 16-hex HMAC-SHA256 of it, to count distinct ChatGPT callers (`openai-mcp` family only in the stats). `x-openai-session` changes per conversation, so it is never written to the ledger; it only feeds the in-memory `chatgpt_session` gauge (see Metrics), under the same conditions as `x-openai-subject`.

Never recorded: tool arguments (search terms, ids), request or response bodies, full client addresses, raw header values, tokens and account ids.

Read the stats with:

```bash
matchawards-mcp stats --days 7          # --db PATH to read another file
```

It prints tool calls per tool and outcome, calls per family, real calls vs probers vs our own tests (`matchawards-test/` UA), distinct client networks (probers and own tests left out), distinct ChatGPT fingerprints, tool calls with a linked account, the empty-result rate per list-returning tool (`rows` = 0 over calls with `rows` set), transport requests per method and the top `clientInfo` names. In Docker: `docker exec <container> matchawards-mcp stats --days 1`.

### Metrics

When `MATCHAWARDS_METRICS_TOKEN` is set, a second listener in the same process (`MATCHAWARDS_METRICS_HOST:MATCHAWARDS_METRICS_PORT`, default `127.0.0.1:9765`) serves `GET /metrics` in the Prometheus text format, with `Authorization: Bearer <token>` (401 without it). The `/mcp` port never serves `/metrics` (404): it trusts `X-Real-IP` from the proxy, so publish only the metrics port to the network Prometheus scrapes from. All metrics are in memory and reset on restart; every label value comes from a fixed set, never a client address, subject or argument.

- `mcp_tool_calls_total{tool,outcome,family}` (counter): tool calls (the ledger writes one row for each), by User-Agent `family` (the ledger's fixed set: `openai-mcp`, `claude-user`, `claude`, `cursor`, ..., `prober` or `other`; never the raw User-Agent).
- `mcp_tool_duration_seconds{tool}` (histogram, buckets 0.1 0.25 0.5 1 2.5 5 10 20 30 s): tool call latency, for the same calls.
- `mcp_tool_result_rows{tool}` (histogram, buckets 0 1 5 10 25 50): rows a list-returning tool answered with, once per `ok` or `partial` call (the ledger's `rows`).
- `mcp_tool_api_pages{tool}` (histogram, buckets 0 1 2 3 5): MatchAwards API requests one tool call made: search pages (the cursor followed while filling `limit`), the agency-name lookup (not when cached), the detail and award lookups of the composite tools, `show_results`' fetches. 0 when the server-wide API budget answered `busy` before any request.
- `mcp_api_duration_seconds{endpoint,outcome}` (histogram, the buckets of `mcp_tool_duration_seconds`): latency of every MatchAwards API request, to tell API time from the rest of a tool call. `endpoint`: `search` (opportunity search), `awards`, `opportunity` (one record), `company`, `naics`, `agencies`, `me` (the account-linking profile check) or `other`; `outcome`: `ok` (an answer below HTTP 400), `client_error` (any other 4xx: a rejected parameter, an unknown id, a 401 or 403 from the profile check, a 429), `error` (5xx, or no connection), or `timeout` (no answer within 10 s, the API's own timeout answer, or cut off by a tool's time limit). A request cancelled for any other reason (the client went away, shutdown) is not recorded.
- `mcp_requests_total{method}` (counter): requests served on `/mcp` by JSON-RPC method.
- `mcp_rejected_total{reason}` (counter): `rate_limit_client`, `rate_limit_protocol`, `rate_limit_protocol_global`, `rate_limit_prefix`, `rate_limit_subject`, `rate_limit_egress`, `rate_limit_global`, `size`, `timeout`, `host`, `path`, `method`.
- `mcp_agent_requests_total{family}` (counter): requests served by User-Agent family.
- `mcp_clients_total{client}` (counter): `initialize` requests by `clientInfo.name`, folded into `chatgpt`, `claude`, `cursor`, `vscode`, `n8n`, `matchawards`, `mcp-inspector`, `other` or `unknown`.
- `mcp_distinct_callers{kind,window}` (gauge, `window` = `1h` or `24h`): distinct `client` networks (/24 or /48, probers and own tests left out) distinct ChatGPT users (`chatgpt_subject`: `x-openai-subject` on requests from ChatGPT's egress addresses only), distinct ChatGPT conversations (`chatgpt_session`: `x-openai-session`, accepted exactly as `x-openai-subject` is; in memory only, never in the ledger) and distinct linked accounts (`account`, listed only with account linking on). Kept as per-process salted hashes, at most 100,000 per kind. The distinct-caller gauges reset on restart, so a 24h value is only complete 24 hours after a start.
- `mcp_distinct_evictions_total{kind}` (counter): distinct-caller keys dropped to stay under that cap (non-zero only under a flood of new networks or subjects). While it is above zero the distinct counts are a lower bound: a caller rotating subjects or networks can push others out.
- `mcp_ledger_errors_total` (counter): usage ledger writes that failed.
- `mcp_chatgpt_subjects_new_total` (counter): tool calls from a ChatGPT user whose fingerprint has no earlier row in the ledger (first seen within the retention window), checked by the ledger writer and counted once that write has committed. Needs `MATCHAWARDS_USAGE_SALT`, like the fingerprints; best-effort like other ledger writes (a failed write counts in `mcp_ledger_errors_total`, never in this).
- `mcp_link_challenges_total{host,error}` (counter, listed only with account linking on): challenges sent, by `host` (`chatgpt`, `claude`, `other`) and `error` (`invalid_token`, `insufficient_scope`).
- `mcp_linked_calls_total{tool}` (counter, listed only with account linking on): tool calls that ran with a linked account.

### Ops notes

- Mount a volume at `/data` (or set `MATCHAWARDS_USAGE_DB`) so the file survives a container rebuild.
- For ChatGPT fingerprints, nginx must pass `x-openai-subject` through to the container (it does by default unless headers are cleared), and `MATCHAWARDS_USAGE_SALT` must be set and kept stable: a new salt starts new fingerprints.
- For Docker, set `MATCHAWARDS_METRICS_HOST=0.0.0.0` and publish the metrics port only on the internal network.

## Data

The data is US federal and state contract opportunities, grants, jobs, federal contract awards and grant awards from [matchawards.com](https://matchawards.com). Only public posts are returned, and each result links back to its page on matchawards.com.

- Contract and grant searches return open items by default: a response deadline of today or later, US Eastern. An item with no deadline counts as closed.
- Job search covers the last 30 days.
- Results come newest first. When `has_more` is true, the model can ask for the next page with `cursor`.
- A keyword search (contracts, grants or awards) without `naics` whose first page is all there is (no `cursor` passed, no `has_more`, no `warning`) and holds fewer than 5 rows, and fewer than its `limit`, says in its `note` that the keyword matches titles only, and that `lookup_naics` gives the codes for a line of work.
- A narrow filter (an agency, a rare name) can scan the newest records without a match while more exist further back; the result's `warning` then says so and gives the `cursor` to continue with.
- If the first request of a search fails, the tool returns an error. If a later page fails, the tool returns the rows it already has, with a `warning` that says why it stopped.
- A result row in an unexpected format is left out and counted in `warning`; the rest of the search is returned.
- Each API call times out after 10 seconds.
- The API is rate-limited per IP: 60 requests per minute, plus a burst limit of 20 requests per 5 seconds. One search call can use up to three requests when the filters are narrow and results are sparse, plus one to look up an agency name (cached for 10 minutes). When the limit is reached, the tool returns an error that tells the model how many seconds to wait.

## Development

```bash
python -m venv .venv
.venv/bin/pip install -e ".[test]"
.venv/bin/pytest
```

The tests make no network calls.

`tests/golden/prompts.json` lists prompts with the tool a model should pick (or none), for manual checks of tool selection in a client.

## License

MIT. See [LICENSE](LICENSE).

TDQS

A4.6/5.0

Scored across 9 tools

Disambiguation5/5

Each tool targets a distinct resource-and-action pair (search_contracts vs search_grants vs search_jobs vs search_awards, plus single-record get_opportunity/get_company). Descriptions explicitly include 'Do not use for...' routing hints that name the correct alternative tool, eliminating overlap.

Naming Consistency5/5

All nine tools follow a consistent snake_case verb_noun pattern (search_*, get_*, lookup_naics, assess_opportunity, find_teaming_partners). Verbs are used predictably: search_ for filtered lists, get_ for single records by id/UEI.

Tool Count5/5

Nine tools is well within the ideal 3-15 range, and each earns its place in a coherent workflow (find codes, search each opportunity type, inspect details, evaluate fit, find partners). No tool appears redundant or padded.

Completeness4/5

The surface covers the full discovery lifecycle: NAICS resolution, search across contracts/grants/jobs/awards, record details, firm history, bid fit, and teaming partners. Minor gaps remain, such as no per-award detail tool, no saved searches or deadline/alerts view, and no agency lookup beyond name matching.

Maintenance

ActivityMaintained
ResponsivenessNo recent community issues