matchawards-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of matchawards-mcp might be problematic. Click here for more details.

@@ -0,0 +1,66 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ pypi:
10
+ runs-on: ubuntu-latest
11
+ environment:
12
+ name: pypi
13
+ url: https://pypi.org/p/matchawards-mcp
14
+ permissions:
15
+ id-token: write # PyPI trusted publishing (OIDC)
16
+ contents: read
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Tag must equal the package version and both server.json versions
21
+ run: |
22
+ tag="${GITHUB_REF_NAME#v}"
23
+ pkg=$(python3 -c "import re; print(re.search(r'__version__ = \"([^\"]+)\"', open('src/matchawards_mcp/__init__.py').read())[1])")
24
+ listing=$(python3 -c "import json; d = json.load(open('server.json')); print(d['version'], d['packages'][0]['version'])")
25
+ echo "tag=$GITHUB_REF_NAME package=$pkg server.json=$listing"
26
+ [ "$GITHUB_REF_NAME" = "v$tag" ] && [ "$pkg" = "$tag" ] && [ "$listing" = "$tag $tag" ]
27
+
28
+ - uses: actions/setup-python@v5
29
+ with:
30
+ python-version: "3.12"
31
+
32
+ - name: Build sdist and wheel
33
+ run: |
34
+ python -m pip install --upgrade pip build twine
35
+ python -m build
36
+
37
+ - name: Check metadata
38
+ run: python -m twine check dist/*
39
+
40
+ - name: Publish to PyPI
41
+ uses: pypa/gh-action-pypi-publish@release/v1
42
+
43
+ mcp-registry:
44
+ needs: pypi
45
+ runs-on: ubuntu-latest
46
+ permissions:
47
+ id-token: write # mcp-publisher login github-oidc
48
+ contents: read
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+
52
+ - name: Install mcp-publisher (pinned, sha256 from the release's checksums file)
53
+ env:
54
+ MCP_PUBLISHER_VERSION: v1.8.1
55
+ MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc
56
+ run: |
57
+ curl -fsSL -o mcp-publisher.tar.gz "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz"
58
+ echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum -c -
59
+ tar -xzf mcp-publisher.tar.gz mcp-publisher
60
+ chmod +x mcp-publisher
61
+
62
+ - name: Log in with GitHub OIDC
63
+ run: ./mcp-publisher login github-oidc
64
+
65
+ - name: Publish server.json to the official MCP Registry
66
+ run: ./mcp-publisher publish
@@ -0,0 +1,58 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ python-version: ["3.10", "3.12"]
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+
20
+ - run: pip install -e ".[test]"
21
+
22
+ - run: pytest -q
23
+
24
+ wheel-smoke:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+
29
+ - uses: actions/setup-python@v5
30
+ with:
31
+ python-version: "3.12"
32
+
33
+ - name: Build and check
34
+ run: |
35
+ python -m pip install --upgrade build twine
36
+ python -m build
37
+ python -m twine check dist/*
38
+
39
+ - name: Install the wheel in a fresh venv and run the stdio discover handshake
40
+ run: |
41
+ python -m venv "$RUNNER_TEMP/smoke"
42
+ "$RUNNER_TEMP/smoke/bin/pip" install dist/*.whl
43
+ cd "$RUNNER_TEMP"
44
+ "$RUNNER_TEMP/smoke/bin/python" - <<'EOF'
45
+ import os
46
+ import anyio
47
+ from mcp import Client, StdioServerParameters
48
+
49
+ async def main():
50
+ server = StdioServerParameters(command=os.path.join(os.environ["RUNNER_TEMP"], "smoke/bin/matchawards-mcp"))
51
+ async with Client(server) as client:
52
+ names = [t.name for t in (await client.list_tools()).tools]
53
+ assert client.protocol_version == "2026-07-28", client.protocol_version
54
+ assert len(names) == 6, names
55
+ print("smoke ok:", client.server_info.version, names)
56
+
57
+ anyio.run(main)
58
+ EOF
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .DS_Store
@@ -0,0 +1,7 @@
1
+ # The server speaks MCP over stdio, so run it with -i: docker run -i --rm matchawards-mcp
2
+ FROM python:3.12-slim
3
+ WORKDIR /app
4
+ COPY pyproject.toml README.md LICENSE ./
5
+ COPY src ./src
6
+ RUN pip install --no-cache-dir .
7
+ CMD ["matchawards-mcp"]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MatchAwards
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.5
2
+ Name: matchawards-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for MatchAwards: search US government contracts, grants and jobs from matchawards.com.
5
+ Project-URL: Homepage, https://matchawards.com
6
+ Project-URL: Repository, https://github.com/matchawards/matchawards-mcp
7
+ Project-URL: Issues, https://github.com/matchawards/matchawards-mcp/issues
8
+ Author: MatchAwards
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agent,claude,cursor,federal-contracts,government-contracts,grants,jobs,matchawards,mcp,model-context-protocol,naics,sam-gov
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: mcp[cli]==2.3.0
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=8; extra == 'test'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # MatchAwards MCP server
26
+
27
+ <!-- mcp-name: io.github.matchawards/matchawards-mcp -->
28
+
29
+ *MatchAwards gives AI agents direct access to continuously updated government and business contracts, grants, jobs, awards and collaboration opportunities.*
30
+
31
+ 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. It only reads data, and it needs no account and no API key.
32
+
33
+ ## Tools
34
+
35
+ | Tool | What it returns | Example prompt |
36
+ |---|---|---|
37
+ | `search_contracts` | Federal and state contract opportunities. Filters: NAICS, state, keyword, set-aside, federal or state only, posted within N days, open only, paging. | "Find open roofing contracts in Virginia posted in the last week." |
38
+ | `search_grants` | Federal grant and funding opportunities. Filters: NAICS, keyword, posted within N days, open only, paging (grant notices carry no state). | "Are there open broadband grants?" |
39
+ | `search_jobs` | Job postings from the last 30 days. Filters: NAICS, state, keyword, posted within N days, paging. | "Show electrician jobs in Texas from the last 7 days." |
40
+ | `search_by_naics` | The newest opportunities for up to 10 NAICS codes, of one type: contract, federal, state, grant or job. | "What is new for NAICS 541511 and 541512 at the federal level?" |
41
+ | `get_opportunity` | The full record of one opportunity: description, deadline, agency and contacts (name and title). | "Tell me more about the second result." |
42
+ | `find_contacts` | Only the published points of contact for one opportunity. | "Who do I contact about that solicitation?" |
43
+
44
+ Every result carries a `url` on matchawards.com, and the server asks the model to show it to you as a link.
45
+
46
+ ## Install
47
+
48
+ The server runs with [`uvx`](https://docs.astral.sh/uv/), which comes with uv.
49
+
50
+ ### Claude Desktop
51
+
52
+ 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:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "matchawards": {
58
+ "command": "uvx",
59
+ "args": ["matchawards-mcp"]
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ### Claude Code
66
+
67
+ ```bash
68
+ claude mcp add matchawards -- uvx matchawards-mcp
69
+ ```
70
+
71
+ ### Cursor
72
+
73
+ Add this to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project):
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "matchawards": {
79
+ "command": "uvx",
80
+ "args": ["matchawards-mcp"]
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ ### VS Code
87
+
88
+ Add this to `.vscode/mcp.json` in your workspace:
89
+
90
+ ```json
91
+ {
92
+ "servers": {
93
+ "matchawards": {
94
+ "type": "stdio",
95
+ "command": "uvx",
96
+ "args": ["matchawards-mcp"]
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ ### Other clients
103
+
104
+ Any MCP client that can start a stdio server works. The command is `uvx matchawards-mcp`.
105
+
106
+ ## Configuration
107
+
108
+ | Variable | Default | Purpose |
109
+ |---|---|---|
110
+ | `MATCHAWARDS_API_BASE` | `https://matchawards.com` | API base URL. Change it only for development. |
111
+
112
+ ## Data
113
+
114
+ The data is US federal and state contract opportunities, grants and jobs from [matchawards.com](https://matchawards.com). Only public posts are returned, and each result links back to its page on matchawards.com.
115
+
116
+ - 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.
117
+ - Job search covers the last 30 days.
118
+ - Results come newest first. When `has_more` is true, the model can ask for the next page with `cursor`.
119
+ - 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.
120
+ - 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. When the limit is reached, the tool returns an error that tells the model how many seconds to wait.
121
+
122
+ ## Development
123
+
124
+ ```bash
125
+ python -m venv .venv
126
+ .venv/bin/pip install -e ".[test]"
127
+ .venv/bin/pytest
128
+ ```
129
+
130
+ The tests make no network calls.
131
+
132
+ ## License
133
+
134
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,110 @@
1
+ # MatchAwards MCP server
2
+
3
+ <!-- mcp-name: io.github.matchawards/matchawards-mcp -->
4
+
5
+ *MatchAwards gives AI agents direct access to continuously updated government and business contracts, grants, jobs, awards and collaboration opportunities.*
6
+
7
+ 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. It only reads data, and it needs no account and no API key.
8
+
9
+ ## Tools
10
+
11
+ | Tool | What it returns | Example prompt |
12
+ |---|---|---|
13
+ | `search_contracts` | Federal and state contract opportunities. Filters: NAICS, state, keyword, set-aside, federal or state only, posted within N days, open only, paging. | "Find open roofing contracts in Virginia posted in the last week." |
14
+ | `search_grants` | Federal grant and funding opportunities. Filters: NAICS, keyword, posted within N days, open only, paging (grant notices carry no state). | "Are there open broadband grants?" |
15
+ | `search_jobs` | Job postings from the last 30 days. Filters: NAICS, state, keyword, posted within N days, paging. | "Show electrician jobs in Texas from the last 7 days." |
16
+ | `search_by_naics` | The newest opportunities for up to 10 NAICS codes, of one type: contract, federal, state, grant or job. | "What is new for NAICS 541511 and 541512 at the federal level?" |
17
+ | `get_opportunity` | The full record of one opportunity: description, deadline, agency and contacts (name and title). | "Tell me more about the second result." |
18
+ | `find_contacts` | Only the published points of contact for one opportunity. | "Who do I contact about that solicitation?" |
19
+
20
+ Every result carries a `url` on matchawards.com, and the server asks the model to show it to you as a link.
21
+
22
+ ## Install
23
+
24
+ The server runs with [`uvx`](https://docs.astral.sh/uv/), which comes with uv.
25
+
26
+ ### Claude Desktop
27
+
28
+ 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:
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "matchawards": {
34
+ "command": "uvx",
35
+ "args": ["matchawards-mcp"]
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ ### Claude Code
42
+
43
+ ```bash
44
+ claude mcp add matchawards -- uvx matchawards-mcp
45
+ ```
46
+
47
+ ### Cursor
48
+
49
+ Add this to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project):
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "matchawards": {
55
+ "command": "uvx",
56
+ "args": ["matchawards-mcp"]
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ### VS Code
63
+
64
+ Add this to `.vscode/mcp.json` in your workspace:
65
+
66
+ ```json
67
+ {
68
+ "servers": {
69
+ "matchawards": {
70
+ "type": "stdio",
71
+ "command": "uvx",
72
+ "args": ["matchawards-mcp"]
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ ### Other clients
79
+
80
+ Any MCP client that can start a stdio server works. The command is `uvx matchawards-mcp`.
81
+
82
+ ## Configuration
83
+
84
+ | Variable | Default | Purpose |
85
+ |---|---|---|
86
+ | `MATCHAWARDS_API_BASE` | `https://matchawards.com` | API base URL. Change it only for development. |
87
+
88
+ ## Data
89
+
90
+ The data is US federal and state contract opportunities, grants and jobs from [matchawards.com](https://matchawards.com). Only public posts are returned, and each result links back to its page on matchawards.com.
91
+
92
+ - 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.
93
+ - Job search covers the last 30 days.
94
+ - Results come newest first. When `has_more` is true, the model can ask for the next page with `cursor`.
95
+ - 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.
96
+ - 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. When the limit is reached, the tool returns an error that tells the model how many seconds to wait.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ python -m venv .venv
102
+ .venv/bin/pip install -e ".[test]"
103
+ .venv/bin/pytest
104
+ ```
105
+
106
+ The tests make no network calls.
107
+
108
+ ## License
109
+
110
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://glama.ai/mcp/schemas/server.json",
3
+ "maintainers": ["matchawards"]
4
+ }
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "matchawards-mcp"
3
+ dynamic = ["version"]
4
+ description = "MCP server for MatchAwards: search US government contracts, grants and jobs from matchawards.com."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "MatchAwards" }]
10
+ keywords = [
11
+ "mcp", "model-context-protocol", "ai-agent", "claude", "cursor",
12
+ "matchawards", "government-contracts", "federal-contracts", "grants",
13
+ "jobs", "sam-gov", "naics",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ ]
23
+ dependencies = [
24
+ "mcp[cli]==2.3.0",
25
+ "httpx>=0.27",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ test = ["pytest>=8"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://matchawards.com"
33
+ Repository = "https://github.com/matchawards/matchawards-mcp"
34
+ Issues = "https://github.com/matchawards/matchawards-mcp/issues"
35
+
36
+ [project.scripts]
37
+ matchawards-mcp = "matchawards_mcp.server:main"
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.version]
44
+ path = "src/matchawards_mcp/__init__.py"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/matchawards_mcp"]
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.matchawards/matchawards-mcp",
4
+ "title": "MatchAwards",
5
+ "description": "Search US government contracts, grants and jobs from matchawards.com. Read-only, no key.",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://matchawards.com",
8
+ "repository": {
9
+ "url": "https://github.com/matchawards/matchawards-mcp",
10
+ "source": "github"
11
+ },
12
+ "packages": [
13
+ {
14
+ "registryType": "pypi",
15
+ "registryBaseUrl": "https://pypi.org",
16
+ "identifier": "matchawards-mcp",
17
+ "version": "0.1.0",
18
+ "runtimeHint": "uvx",
19
+ "transport": { "type": "stdio" },
20
+ "environmentVariables": [
21
+ {
22
+ "name": "MATCHAWARDS_API_BASE",
23
+ "description": "Base URL of the MatchAwards API. Leave unset to use https://matchawards.com; set only for development.",
24
+ "isRequired": false,
25
+ "isSecret": false,
26
+ "default": "https://matchawards.com"
27
+ }
28
+ ]
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,8 @@
1
+ startCommand:
2
+ type: stdio
3
+ configSchema:
4
+ type: object
5
+ properties: {}
6
+ commandFunction: |-
7
+ () => ({ command: "uvx", args: ["matchawards-mcp"] })
8
+ exampleConfig: {}
@@ -0,0 +1,3 @@
1
+ """MatchAwards MCP server: US government contracts, grants and jobs for AI agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,330 @@
1
+ """MatchAwards MCP server: read-only search over the public matchawards.com API.
2
+
3
+ Six tools, no auth, no state. Each call is a GET to `{MATCHAWARDS_API_BASE}/api/public/v1/opportunities`
4
+ (a search tool may follow the cursor for up to MAX_PAGES requests) or to `.../opportunities/{id}`.
5
+ """
6
+
7
+ import inspect
8
+ import os
9
+ from typing import Annotated, Any, Literal
10
+
11
+ import httpx
12
+ from mcp.server.caching import CacheHint
13
+ from mcp.server.mcpserver import MCPServer
14
+ from mcp.server.mcpserver.exceptions import ToolError
15
+ from mcp.types import ToolAnnotations
16
+ from pydantic import Field
17
+
18
+ from . import __version__
19
+
20
+ API_BASE = os.environ.get("MATCHAWARDS_API_BASE", "https://matchawards.com").rstrip("/")
21
+ USER_AGENT = f"matchawards-mcp/{__version__} (+https://github.com/matchawards/matchawards-mcp)"
22
+ TIMEOUT_S = 20.0
23
+ SEARCH_PATH = "/api/public/v1/opportunities"
24
+ MAX_PAGES = 3 # API requests one search tool call may make while filling `limit`
25
+
26
+ LINK_RULE = (
27
+ "Show each item's url to the user verbatim as a markdown link, [title](url); "
28
+ "it opens the full posting on matchawards.com."
29
+ )
30
+ INSTRUCTIONS = (
31
+ "Search US government contract opportunities (federal and state), grants and jobs from matchawards.com. "
32
+ f"Read-only. Every result row has a matchawards.com `url`. {LINK_RULE}"
33
+ )
34
+
35
+ # Tests swap this for an httpx.AsyncClient on a MockTransport.
36
+ client = httpx.AsyncClient(
37
+ base_url=API_BASE,
38
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
39
+ timeout=TIMEOUT_S,
40
+ )
41
+
42
+ mcp = MCPServer(
43
+ name="matchawards",
44
+ title="MatchAwards",
45
+ instructions=INSTRUCTIONS,
46
+ website_url="https://matchawards.com",
47
+ version=__version__,
48
+ # The tool list is static: let clients cache it for a day, shared across users.
49
+ cache_hints={"tools/list": CacheHint(ttl_ms=86_400_000, scope="public")},
50
+ # No server-initiated notifications, so do not serve subscriptions/listen.
51
+ subscriptions=False,
52
+ )
53
+
54
+ READ_ONLY = ToolAnnotations(
55
+ read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=True
56
+ )
57
+
58
+
59
+ def _tool(fn):
60
+ """Register a read-only tool; its cleaned docstring is the description the model reads."""
61
+ return mcp.tool(annotations=READ_ONLY, description=inspect.cleandoc(fn.__doc__))(fn)
62
+
63
+
64
+ # Optional filters are Annotated[X | None, Field(...)] so the description sits at the top level of
65
+ # the JSON schema while the constraint still applies to the non-null value.
66
+ _NAICS = Field(
67
+ pattern=r"^\s*\d{6}(\s*,\s*\d{6}){0,9}\s*$",
68
+ description="Up to 10 six-digit NAICS codes, comma-separated, e.g. '236220' or '541511,541512'. "
69
+ "Unknown codes are ignored.",
70
+ )
71
+ Naics = Annotated[str, _NAICS]
72
+ OptNaics = Annotated[str | None, _NAICS]
73
+ State = Annotated[
74
+ str | None,
75
+ Field(pattern=r"^[A-Za-z]{2}$", description="Two-letter US state code, e.g. 'VA'. Many federal notices "
76
+ "have no state, so this filter skips them."),
77
+ ]
78
+ Keyword = Annotated[
79
+ str | None, Field(min_length=2, max_length=100, description="2 to 100 characters matched against the title only.")
80
+ ]
81
+ SetAside = Annotated[str | None, Field(description="Set-aside code, e.g. 'SBA' or 'SDVOSBC'. Federal contracts only.")]
82
+ PostedWithin = Annotated[
83
+ int | None, Field(ge=1, le=180, description="Only items posted in the last N days, 1 to 180 (default 180).")
84
+ ]
85
+ JobsPostedWithin = Annotated[int, Field(ge=1, le=30, description="Only jobs posted in the last N days, 1 to 30.")]
86
+ Open = Annotated[
87
+ bool | None,
88
+ Field(description="true (the default): only items with a response deadline of today or later, US Eastern; "
89
+ "an item with no deadline counts as closed. false: include closed ones."),
90
+ ]
91
+ Limit = Annotated[int, Field(ge=1, le=50, description="Number of results to return, 1 to 50.")]
92
+ Cursor = Annotated[
93
+ str | None,
94
+ Field(description="next_cursor from the previous response. Pass it with the same filters to get the next page."),
95
+ ]
96
+ OppId = Annotated[
97
+ str, Field(pattern=r"^[A-Za-z0-9_-]{1,128}$", description="The id field of a search result.")
98
+ ]
99
+ NaicsType = Annotated[
100
+ Literal["contract", "federal", "state", "grant", "job"],
101
+ Field(description="contract = federal and state contracts; federal or state = one level only; grant; job."),
102
+ ]
103
+
104
+
105
+ def _error_message(r: httpx.Response) -> str:
106
+ """Turn an API error response ({error, parameter?} JSON) into advice the model can act on."""
107
+ try:
108
+ body = r.json()
109
+ except ValueError:
110
+ body = None
111
+ body = body if isinstance(body, dict) else {}
112
+ error = body.get("message") or body.get("error") or r.text.strip()[:200] or r.reason_phrase
113
+ if r.status_code == 429:
114
+ wait = r.headers.get("Retry-After") or body.get("retry_after") or 60
115
+ return (
116
+ f"MatchAwards rate limit reached (per IP: 60 requests per minute, 20 per 5 seconds). "
117
+ f"Wait {wait} seconds, then retry."
118
+ )
119
+ if body.get("error") == "grants_unavailable":
120
+ return "Grant search is temporarily unavailable on MatchAwards. Try again later; contracts and jobs still work."
121
+ if r.status_code >= 500:
122
+ return f"MatchAwards server error (HTTP {r.status_code}). It is temporary; retry in a minute."
123
+ if r.status_code == 400 and body.get("parameter"):
124
+ return f"MatchAwards rejected the parameter '{body['parameter']}': {error}. Fix it and call again."
125
+ if r.status_code == 404:
126
+ return f"Not found on MatchAwards ({error}). Use an id from a search result."
127
+ return f"MatchAwards rejected the request (HTTP {r.status_code}): {error}"
128
+
129
+
130
+ async def _get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
131
+ """GET one API path; turn every failure into a ToolError the model can act on."""
132
+ query = {k: v for k, v in (params or {}).items() if v is not None and v != ""} # "" = unset, e.g. cursor=""
133
+ try:
134
+ r = await client.get(path, params=query)
135
+ except httpx.TimeoutException:
136
+ raise ToolError(
137
+ f"MatchAwards did not answer within {TIMEOUT_S:.0f} seconds. Retry once; if it fails again, "
138
+ "narrow the search (fewer NAICS codes, a shorter posted_within_days) or try later."
139
+ ) from None
140
+ except httpx.RequestError as e:
141
+ raise ToolError(
142
+ f"Could not reach MatchAwards at {API_BASE} ({type(e).__name__}). Check the network and retry later."
143
+ ) from None
144
+ if r.status_code >= 400:
145
+ raise ToolError(_error_message(r))
146
+ try:
147
+ data = r.json()
148
+ except ValueError:
149
+ data = None
150
+ if not isinstance(data, dict):
151
+ raise ToolError(
152
+ f"MatchAwards returned an unexpected response (HTTP {r.status_code}, not a JSON object). Retry later."
153
+ )
154
+ return data
155
+
156
+
157
+ async def _get_one(id: str) -> dict[str, Any]:
158
+ """One opportunity: the API answers {as_of, result: {row..., description, contacts}}; return the row with as_of."""
159
+ data = await _get(f"{SEARCH_PATH}/{id}")
160
+ result = data.get("result")
161
+ if not isinstance(result, dict):
162
+ raise ToolError("MatchAwards returned an unexpected API response (no result object).")
163
+ return {**result, "as_of": data.get("as_of")}
164
+
165
+
166
+ async def _search(type_: str, limit: int = 20, keyword: str | None = None, **filters: Any) -> dict[str, Any]:
167
+ """Collect up to `limit` unique rows. A sparse filter can return a short page with has_more, so
168
+ follow next_cursor with the same filters, for at most MAX_PAGES requests."""
169
+ if filters.get("naics"):
170
+ filters["naics"] = ",".join(c.strip() for c in filters["naics"].split(","))
171
+ if filters.get("state"):
172
+ filters["state"] = filters["state"].upper()
173
+ params = {"type": type_, "q": keyword, **filters}
174
+ rows: dict[Any, dict[str, Any]] = {}
175
+ page: dict[str, Any] = {}
176
+ as_of = warning = None
177
+ for _ in range(MAX_PAGES):
178
+ try:
179
+ # Ask only for what is still missing, so no row is read past the cursor and then dropped.
180
+ next_page = await _get(SEARCH_PATH, {**params, "limit": limit - len(rows)})
181
+ results = next_page.get("results")
182
+ if not isinstance(results, list) or not all(isinstance(r, dict) for r in results):
183
+ raise ToolError("MatchAwards returned an unexpected API response (results is not a list of objects).")
184
+ except ToolError as e:
185
+ if not page:
186
+ raise # first page: nothing to return, so the call fails
187
+ # Follow-up page: keep what we have; the last good page's next_cursor still points at the rest.
188
+ warning = f"Stopped after {len(rows)} rows: {e} More results may exist; call again with next_cursor."
189
+ break
190
+ page = next_page
191
+ as_of = as_of or page.get("as_of")
192
+ for row in results:
193
+ rows.setdefault(row.get("id"), row)
194
+ if len(rows) >= limit or not (page.get("has_more") and page.get("next_cursor")):
195
+ break
196
+ params["cursor"] = page["next_cursor"]
197
+ out = {
198
+ "as_of": as_of,
199
+ "results": list(rows.values()),
200
+ "has_more": bool(page.get("has_more")),
201
+ "next_cursor": page.get("next_cursor"),
202
+ "note": LINK_RULE,
203
+ }
204
+ return {**out, "warning": warning} if warning else out
205
+
206
+
207
+ @_tool
208
+ async def search_contracts(
209
+ naics: OptNaics = None,
210
+ state: State = None,
211
+ keyword: Keyword = None,
212
+ set_aside: SetAside = None,
213
+ type: Annotated[
214
+ Literal["federal", "state"] | None,
215
+ Field(description="Narrow to federal or to state contracts. Leave empty for both."),
216
+ ] = None,
217
+ posted_within_days: PostedWithin = None,
218
+ open: Open = None,
219
+ limit: Limit = 20,
220
+ cursor: Cursor = None,
221
+ ) -> dict[str, Any]:
222
+ """Search US federal and state government contract opportunities (bids, RFPs, solicitations).
223
+
224
+ Use this when the user wants contracts to bid on. Returns {as_of, results, has_more, next_cursor};
225
+ each result has id, type, title, agency, office, naics {code, title}, set_aside, state, city,
226
+ posted_at, response_deadline, url (matchawards.com), sam_url and solicitation_number.
227
+ Filters: naics, state, keyword, set_aside (federal only), type (federal or state), posted_within_days,
228
+ open (default true), limit, cursor. If has_more is true, call again with cursor=next_cursor and the
229
+ same filters. Example: naics="238160", state="VA", keyword="roof".
230
+ For grants use search_grants, for jobs search_jobs.
231
+ """
232
+ return await _search(
233
+ type or "contract", naics=naics, state=state, keyword=keyword, set_aside=set_aside,
234
+ posted_within_days=posted_within_days, open=open, limit=limit, cursor=cursor,
235
+ )
236
+
237
+
238
+ @_tool
239
+ async def search_grants(
240
+ naics: OptNaics = None,
241
+ keyword: Keyword = None,
242
+ posted_within_days: PostedWithin = None,
243
+ open: Open = None,
244
+ limit: Limit = 20,
245
+ cursor: Cursor = None,
246
+ ) -> dict[str, Any]:
247
+ """Search US federal grant and funding opportunities.
248
+
249
+ Use this when the user wants grants or funding, not contracts. Returns {as_of, results, has_more,
250
+ next_cursor}; each result has id, title, agency, naics, posted_at, response_deadline and a matchawards.com
251
+ url. Filters: naics, keyword, posted_within_days, open (default true: open grants of any age), limit,
252
+ cursor. Grant notices carry no state, so there is no state filter. If has_more is true, call again with
253
+ cursor=next_cursor and the same filters. Example: keyword="broadband" or naics="541715".
254
+ """
255
+ return await _search(
256
+ "grant", naics=naics, keyword=keyword, posted_within_days=posted_within_days, open=open, limit=limit,
257
+ cursor=cursor,
258
+ )
259
+
260
+
261
+ @_tool
262
+ async def search_jobs(
263
+ naics: OptNaics = None,
264
+ state: State = None,
265
+ keyword: Keyword = None,
266
+ posted_within_days: JobsPostedWithin = 30,
267
+ limit: Limit = 20,
268
+ cursor: Cursor = None,
269
+ ) -> dict[str, Any]:
270
+ """Search job postings from the last 30 days listed on MatchAwards.
271
+
272
+ Use this when the user is looking for a job or for hiring signals, not for contracts or grants.
273
+ Returns {as_of, results, has_more, next_cursor}; each result has id, title, agency (the employer),
274
+ state, city, posted_at and a matchawards.com url; other fields are null. Filters: naics, state,
275
+ keyword, posted_within_days (up to 30), limit, cursor. If has_more is true, call again with
276
+ cursor=next_cursor and the same filters. Example: keyword="electrician", state="TX", posted_within_days=7.
277
+ """
278
+ return await _search(
279
+ "job", naics=naics, state=state, keyword=keyword,
280
+ posted_within_days=posted_within_days, limit=limit, cursor=cursor,
281
+ )
282
+
283
+
284
+ @_tool
285
+ async def search_by_naics(naics: Naics, type: NaicsType = "contract", cursor: Cursor = None) -> dict[str, Any]:
286
+ """List the newest opportunities for one or more NAICS industry codes.
287
+
288
+ Use this when the user gives NAICS codes and nothing else. Returns up to 20 results, {as_of, results,
289
+ has_more, next_cursor}, each with a matchawards.com url. Filters: naics (required), type (contract,
290
+ federal, state, grant or job), cursor (next_cursor of the previous call, same naics and type). For
291
+ keyword, state or other filters use search_contracts, search_grants or search_jobs.
292
+ Example: naics="541511,541512", type="federal".
293
+ """
294
+ return await _search(type, naics=naics, cursor=cursor)
295
+
296
+
297
+ @_tool
298
+ async def get_opportunity(id: OppId) -> dict[str, Any]:
299
+ """Get the full record of one opportunity by its id from a search result.
300
+
301
+ Use this when the user wants details on one result. Returns the search row plus description
302
+ (up to 2000 characters) and contacts [{name, title}], with the matchawards.com url. Example: id="abc123".
303
+ """
304
+ return {**await _get_one(id), "note": LINK_RULE}
305
+
306
+
307
+ @_tool
308
+ async def find_contacts(id: OppId) -> dict[str, Any]:
309
+ """Get only the published points of contact for one opportunity by its id.
310
+
311
+ Use this when the user asks who to contact about a result. Returns {id, title, url, contacts}, where
312
+ contacts is [{name, title}] as the public matchawards.com page shows them; an empty list means none
313
+ are published. Example: id="abc123".
314
+ """
315
+ data = await _get_one(id)
316
+ return {
317
+ "id": data.get("id", id),
318
+ "title": data.get("title"),
319
+ "url": data.get("url"),
320
+ "contacts": data.get("contacts") or [],
321
+ }
322
+
323
+
324
+ def main() -> None:
325
+ """Console entry point. v0.1.0 serves stdio only.
326
+
327
+ The tools live on the module-level `mcp`, so a later `--http` flag can serve the same set with
328
+ `mcp.streamable_http_app(stateless_http=True, json_response=True)` from here.
329
+ """
330
+ mcp.run(transport="stdio")
@@ -0,0 +1,264 @@
1
+ import json
2
+ import sys
3
+ from importlib.metadata import version
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import httpx
8
+ import pytest
9
+ from mcp import Client, StdioServerParameters
10
+ from mcp.server.mcpserver.exceptions import ToolError
11
+
12
+ from matchawards_mcp import __version__, server
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ PATH = "/api/public/v1/opportunities"
16
+ TOOLS = ["search_contracts", "search_grants", "search_jobs", "search_by_naics", "get_opportunity", "find_contacts"]
17
+ NOTE = server.LINK_RULE
18
+
19
+
20
+ def row(i):
21
+ return {"id": str(i), "type": "contract", "title": f"Notice {i}", "url": f"https://matchawards.com/a/posts/{i}"}
22
+
23
+
24
+ def page(ids, has_more=False, cursor=None):
25
+ return {"as_of": "2026-10-07T12:00:00Z", "results": [row(i) for i in ids], "has_more": has_more,
26
+ "next_cursor": cursor}
27
+
28
+
29
+ DETAIL = {**row(7), "description": "Replace the roof.", "contacts": [{"name": "Jo Smith", "title": "Contracting Officer"}]}
30
+
31
+
32
+ @pytest.fixture
33
+ def anyio_backend():
34
+ return "asyncio"
35
+
36
+
37
+ @pytest.fixture
38
+ def api(monkeypatch):
39
+ """Point the server's HTTP client at a mock. api.replies is a queue; the last reply repeats."""
40
+ # The default reply suits both shapes: a search page and a single opportunity ({result: ...}).
41
+ api = SimpleNamespace(requests=[], replies=[lambda req: httpx.Response(200, json={**page([1]), "result": row(1)})])
42
+
43
+ def handler(req):
44
+ api.requests.append(req)
45
+ reply = api.replies.pop(0) if len(api.replies) > 1 else api.replies[0]
46
+ return reply(req)
47
+
48
+ monkeypatch.setattr(server, "client", httpx.AsyncClient(
49
+ transport=httpx.MockTransport(handler), base_url="https://api.test",
50
+ headers=dict(server.client.headers),
51
+ ))
52
+ return api
53
+
54
+
55
+ def ok(body):
56
+ return lambda req: httpx.Response(200, json=body)
57
+
58
+
59
+ @pytest.mark.anyio
60
+ @pytest.mark.parametrize("call, path, params", [
61
+ (lambda: server.search_contracts(), PATH, {"type": "contract", "limit": "20"}),
62
+ (lambda: server.search_contracts(
63
+ naics="236220, 238210", state="va", keyword="roof", set_aside="SBA", type="federal",
64
+ posted_within_days=7, open=False, limit=5, cursor="abc=",
65
+ ), PATH, {"type": "federal", "naics": "236220,238210", "state": "VA", "q": "roof", "set_aside": "SBA",
66
+ "posted_within_days": "7", "open": "false", "limit": "5", "cursor": "abc="}),
67
+ (lambda: server.search_grants(keyword="broadband", naics="541715", open=True),
68
+ PATH, {"type": "grant", "naics": "541715", "q": "broadband", "open": "true", "limit": "20"}),
69
+ (lambda: server.search_by_naics("541715", type="grant"),
70
+ PATH, {"type": "grant", "naics": "541715", "limit": "20"}),
71
+ (lambda: server.search_jobs(), PATH, {"type": "job", "posted_within_days": "30", "limit": "20"}),
72
+ (lambda: server.search_jobs(keyword="electrician", posted_within_days=7),
73
+ PATH, {"type": "job", "q": "electrician", "posted_within_days": "7", "limit": "20"}),
74
+ (lambda: server.search_contracts(cursor="", set_aside=""), PATH, {"type": "contract", "limit": "20"}),
75
+ (lambda: server.search_by_naics("541511", cursor=""), PATH, {"type": "contract", "naics": "541511", "limit": "20"}),
76
+ (lambda: server.search_by_naics("541511,541512", type="federal", cursor="c1"),
77
+ PATH, {"type": "federal", "naics": "541511,541512", "limit": "20", "cursor": "c1"}),
78
+ (lambda: server.get_opportunity("abc123"), f"{PATH}/abc123", {}),
79
+ (lambda: server.find_contacts("abc-123_x"), f"{PATH}/abc-123_x", {}),
80
+ ])
81
+ async def test_query_building(api, call, path, params):
82
+ await call()
83
+ [req] = api.requests
84
+ assert req.method == "GET"
85
+ assert req.url.path == path
86
+ assert dict(req.url.params) == params
87
+ assert req.headers["User-Agent"] == f"matchawards-mcp/{__version__} (+https://github.com/matchawards/matchawards-mcp)"
88
+
89
+
90
+ @pytest.mark.anyio
91
+ async def test_search_returns_rows_cursor_and_link_note(api):
92
+ api.replies = [ok(page([1, 2], has_more=True, cursor="c1"))]
93
+ assert await server.search_contracts(limit=2) == {**page([1, 2], has_more=True, cursor="c1"), "note": NOTE}
94
+
95
+
96
+ @pytest.mark.anyio
97
+ async def test_sparse_pages_are_followed_with_same_filters_and_deduplicated(api):
98
+ api.replies = [
99
+ ok(page([1, 2], has_more=True, cursor="c1")),
100
+ ok(page([2, 3], has_more=True, cursor="c2")), # 2 repeats across pages
101
+ ok(page([4, 5], has_more=True, cursor="c3")),
102
+ ]
103
+ out = await server.search_contracts(state="TX", limit=5)
104
+ assert [r["id"] for r in out["results"]] == ["1", "2", "3", "4", "5"]
105
+ assert (out["has_more"], out["next_cursor"]) == (True, "c3")
106
+ sent = [dict(r.url.params) for r in api.requests]
107
+ assert [(p.get("cursor"), p["limit"]) for p in sent] == [(None, "5"), ("c1", "3"), ("c2", "2")]
108
+ assert all(p["state"] == "TX" and p["type"] == "contract" for p in sent)
109
+
110
+
111
+ @pytest.mark.anyio
112
+ async def test_following_stops_after_three_requests(api):
113
+ api.replies = [ok(page([], has_more=True, cursor="c"))]
114
+ out = await server.search_grants(limit=10)
115
+ assert len(api.requests) == 3
116
+ assert (out["results"], out["has_more"], out["next_cursor"]) == ([], True, "c")
117
+
118
+
119
+ @pytest.mark.anyio
120
+ async def test_following_stops_when_the_api_has_no_more(api):
121
+ api.replies = [ok(page([1], has_more=False))]
122
+ out = await server.search_jobs()
123
+ assert len(api.requests) == 1
124
+ assert (out["has_more"], out["next_cursor"]) == (False, None)
125
+
126
+
127
+ @pytest.mark.anyio
128
+ async def test_failed_follow_up_keeps_the_rows_already_collected(api):
129
+ api.replies = [ok(page([1], has_more=True, cursor="c1")), lambda req: httpx.Response(429, headers={"Retry-After": "5"})]
130
+ out = await server.search_contracts(limit=5)
131
+ assert [r["id"] for r in out["results"]] == ["1"]
132
+ assert (out["has_more"], out["next_cursor"]) == (True, "c1")
133
+ assert out["warning"].startswith("Stopped after 1 rows: MatchAwards rate limit reached")
134
+ assert "Wait 5 seconds" in out["warning"] and out["warning"].endswith("call again with next_cursor.")
135
+
136
+
137
+ @pytest.mark.anyio
138
+ @pytest.mark.parametrize("body", [
139
+ {"results": ["1", "2"], "has_more": False},
140
+ {"results": {"id": "1"}, "has_more": False},
141
+ {"has_more": False},
142
+ ])
143
+ async def test_malformed_results_are_a_tool_error(api, body):
144
+ api.replies = [ok(body)]
145
+ with pytest.raises(ToolError, match="unexpected API response"):
146
+ await server.search_contracts()
147
+
148
+
149
+ @pytest.mark.anyio
150
+ async def test_malformed_follow_up_page_keeps_rows_with_warning(api):
151
+ api.replies = [ok(page([1], has_more=True, cursor="c1")), ok({"results": [None], "has_more": False})]
152
+ out = await server.search_contracts(limit=5)
153
+ assert [r["id"] for r in out["results"]] == ["1"]
154
+ assert "unexpected API response" in out["warning"]
155
+
156
+
157
+ @pytest.mark.anyio
158
+ async def test_complete_search_has_no_warning(api):
159
+ api.replies = [ok(page([1, 2], has_more=True, cursor="c1"))]
160
+ assert "warning" not in await server.search_contracts(limit=2)
161
+
162
+
163
+ @pytest.mark.anyio
164
+ async def test_get_opportunity_passes_json_through_with_link_note(api):
165
+ api.replies = [ok({"as_of": "2026-10-07T12:00:00Z", "result": DETAIL})]
166
+ assert await server.get_opportunity("7") == {**DETAIL, "as_of": "2026-10-07T12:00:00Z", "note": NOTE}
167
+
168
+
169
+ @pytest.mark.anyio
170
+ async def test_find_contacts_returns_only_id_title_url_and_contacts(api):
171
+ api.replies = [ok({"as_of": "x", "result": DETAIL})]
172
+ assert await server.find_contacts("7") == {
173
+ "id": "7", "title": "Notice 7", "url": "https://matchawards.com/a/posts/7", "contacts": DETAIL["contacts"],
174
+ }
175
+ api.replies = [ok({"as_of": "x", "result": row(8)})]
176
+ assert (await server.find_contacts("8"))["contacts"] == []
177
+ api.replies = [ok(row(9))] # old top-level shape: not accepted
178
+ with pytest.raises(ToolError, match="no result object"):
179
+ await server.find_contacts("9")
180
+
181
+
182
+ def _raise(exc_type):
183
+ def reply(req):
184
+ raise exc_type("boom", request=req)
185
+ return reply
186
+
187
+
188
+ @pytest.mark.anyio
189
+ @pytest.mark.parametrize("reply, message", [
190
+ (lambda req: httpx.Response(429, headers={"Retry-After": "30"}, json={"error": "rate_limited", "retry_after": 30}),
191
+ "Wait 30 seconds"),
192
+ (lambda req: httpx.Response(429, json={"error": "rate_limited", "retry_after": 12}), "Wait 12 seconds"),
193
+ (lambda req: httpx.Response(429), "Wait 60 seconds"),
194
+ (lambda req: httpx.Response(400, json={"error": "unsupported_filter", "parameter": "set_aside",
195
+ "message": "set_aside is only for contract or federal"}),
196
+ "parameter 'set_aside': set_aside is only for contract or federal"),
197
+ (lambda req: httpx.Response(400, json={"error": "invalid_parameter", "parameter": "state"}),
198
+ "parameter 'state': invalid_parameter"),
199
+ (lambda req: httpx.Response(404, json={"error": "not_found"}), "Not found on MatchAwards"),
200
+ (lambda req: httpx.Response(503, json={"error": "grants_unavailable", "message": "open grant list is stale"}),
201
+ "Grant search is temporarily unavailable"),
202
+ (lambda req: httpx.Response(500, json={"error": "internal_error"}), "server error \\(HTTP 500\\)"),
203
+ (lambda req: httpx.Response(502, text="<html>bad gateway</html>"), "server error \\(HTTP 502\\)"),
204
+ (_raise(httpx.ReadTimeout), "did not answer within 20 seconds"),
205
+ (_raise(httpx.ConnectError), "Could not reach MatchAwards"),
206
+ (lambda req: httpx.Response(200, text="<html>maintenance</html>"), "not a JSON object"),
207
+ (lambda req: httpx.Response(200, json=[1, 2]), "not a JSON object"),
208
+ ])
209
+ async def test_http_failures_become_tool_errors(api, reply, message):
210
+ api.replies = [reply]
211
+ with pytest.raises(ToolError, match=message):
212
+ await server.search_contracts()
213
+ with pytest.raises(ToolError, match=message):
214
+ await server.get_opportunity("7")
215
+
216
+
217
+ @pytest.mark.anyio
218
+ @pytest.mark.parametrize("tool, args", [
219
+ ("search_contracts", {"naics": "12345"}),
220
+ ("search_contracts", {"naics": ",".join(["541511"] * 11)}),
221
+ ("search_contracts", {"type": "grant"}),
222
+ ("search_contracts", {"posted_within_days": 181}),
223
+ ("search_grants", {"keyword": "a"}),
224
+ ("search_jobs", {"posted_within_days": 31}),
225
+ ("search_jobs", {"limit": 0}),
226
+ ("search_jobs", {"limit": 51}),
227
+ ("search_by_naics", {"naics": "541511", "type": "award"}),
228
+ ("get_opportunity", {"id": "../admin"}),
229
+ ("find_contacts", {"id": ""}),
230
+ ])
231
+ async def test_bad_input_is_rejected_before_any_request(api, tool, args):
232
+ with pytest.raises(ToolError):
233
+ await server.mcp.call_tool(tool, args)
234
+ assert api.requests == []
235
+
236
+
237
+ def test_version_is_the_same_everywhere():
238
+ listing = json.loads((ROOT / "server.json").read_text())
239
+ assert version("matchawards-mcp") == __version__ # pyproject reads it from __init__
240
+ assert listing["version"] == listing["packages"][0]["version"] == __version__
241
+
242
+
243
+ @pytest.mark.anyio
244
+ @pytest.mark.parametrize("mode", ["auto", "legacy"]) # auto = 2026-07-28 server/discover; legacy = initialize
245
+ async def test_stdio_handshake(mode):
246
+ params = StdioServerParameters(
247
+ command=sys.executable,
248
+ args=["-c", "from matchawards_mcp.server import main; main()"],
249
+ env={"MATCHAWARDS_API_BASE": "http://127.0.0.1:9"}, # never reached: only a bad-input call is made
250
+ )
251
+ async with Client(params, mode=mode) as client:
252
+ assert (client.server_info.name, client.server_info.version) == ("matchawards", __version__)
253
+ assert client.instructions == server.INSTRUCTIONS
254
+ if mode == "auto":
255
+ assert client.protocol_version == "2026-07-28"
256
+ listed = await client.list_tools()
257
+ assert [t.name for t in listed.tools] == TOOLS
258
+ for t in listed.tools:
259
+ a = t.annotations
260
+ assert (a.read_only_hint, a.destructive_hint, a.idempotent_hint, a.open_world_hint) == (True, False, True, True)
261
+ if mode == "auto":
262
+ assert (listed.ttl_ms, listed.cache_scope) == (86_400_000, "public")
263
+ bad = await client.call_tool("search_contracts", {"naics": "12"})
264
+ assert bad.is_error