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.
- matchawards_mcp-0.1.0/.github/workflows/publish.yml +66 -0
- matchawards_mcp-0.1.0/.github/workflows/test.yml +58 -0
- matchawards_mcp-0.1.0/.gitignore +11 -0
- matchawards_mcp-0.1.0/Dockerfile +7 -0
- matchawards_mcp-0.1.0/LICENSE +21 -0
- matchawards_mcp-0.1.0/PKG-INFO +134 -0
- matchawards_mcp-0.1.0/README.md +110 -0
- matchawards_mcp-0.1.0/glama.json +4 -0
- matchawards_mcp-0.1.0/pyproject.toml +47 -0
- matchawards_mcp-0.1.0/server.json +31 -0
- matchawards_mcp-0.1.0/smithery.yaml +8 -0
- matchawards_mcp-0.1.0/src/matchawards_mcp/__init__.py +3 -0
- matchawards_mcp-0.1.0/src/matchawards_mcp/server.py +330 -0
- matchawards_mcp-0.1.0/tests/test_server.py +264 -0
|
@@ -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,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,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,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
|