eqsl-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.
- eqsl_mcp-0.1.0/.github/workflows/publish.yml +29 -0
- eqsl_mcp-0.1.0/.gitignore +6 -0
- eqsl_mcp-0.1.0/LICENSE +22 -0
- eqsl_mcp-0.1.0/PKG-INFO +209 -0
- eqsl_mcp-0.1.0/README.md +183 -0
- eqsl_mcp-0.1.0/pyproject.toml +43 -0
- eqsl_mcp-0.1.0/src/eqsl_mcp/__init__.py +10 -0
- eqsl_mcp-0.1.0/src/eqsl_mcp/ag_cache.py +72 -0
- eqsl_mcp-0.1.0/src/eqsl_mcp/client.py +155 -0
- eqsl_mcp-0.1.0/src/eqsl_mcp/parser.py +144 -0
- eqsl_mcp-0.1.0/src/eqsl_mcp/server.py +162 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
name: Build and publish to PyPI
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
environment: pypi
|
|
13
|
+
permissions:
|
|
14
|
+
id-token: write # Required for trusted publishing (OIDC)
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
|
|
22
|
+
- name: Install build dependencies
|
|
23
|
+
run: pip install build
|
|
24
|
+
|
|
25
|
+
- name: Build package
|
|
26
|
+
run: python -m build
|
|
27
|
+
|
|
28
|
+
- name: Publish to PyPI
|
|
29
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
eqsl_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
GNU GENERAL PUBLIC LICENSE
|
|
2
|
+
Version 3, 29 June 2007
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
5
|
+
Everyone is permitted to copy and distribute verbatim copies
|
|
6
|
+
of this license document, but changing it is not allowed.
|
|
7
|
+
|
|
8
|
+
Preamble
|
|
9
|
+
|
|
10
|
+
The GNU General Public License is a free, copyleft license for
|
|
11
|
+
software and other kinds of works.
|
|
12
|
+
|
|
13
|
+
The licenses for most software and other practical works are designed
|
|
14
|
+
to take away your freedom to share and change the works. By contrast,
|
|
15
|
+
the GNU General Public License is intended to guarantee your freedom to
|
|
16
|
+
share and change all versions of a program--to make sure it remains free
|
|
17
|
+
software for all its users. We, the Free Software Foundation, use the
|
|
18
|
+
GNU General Public License for most of our software; it applies also to
|
|
19
|
+
any other work released this way by its authors. You can apply it to
|
|
20
|
+
your programs, too.
|
|
21
|
+
|
|
22
|
+
For the full license text, see <https://www.gnu.org/licenses/gpl-3.0.html>
|
eqsl_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eqsl-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for eQSL.cc — inbox download, QSO verification, AG status checks
|
|
5
|
+
Project-URL: Homepage, https://qso-graph.io/
|
|
6
|
+
Project-URL: Repository, https://github.com/qso-graph/eqsl-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/qso-graph/eqsl-mcp/issues
|
|
8
|
+
Author: Greg Beam, KI7MT
|
|
9
|
+
License: GPL-3.0-or-later
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: amateur-radio,confirmation,eqsl,ham-radio,mcp,model-context-protocol,qsl,qso
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Communications :: Ham Radio
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: adif-mcp>=0.6.2
|
|
24
|
+
Requires-Dist: fastmcp>=3.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# eqsl-mcp
|
|
28
|
+
|
|
29
|
+
MCP server for [eQSL.cc](https://www.eqsl.cc/) — download incoming eQSLs, verify QSOs, check AG status, and query upload history through any MCP-compatible AI assistant.
|
|
30
|
+
|
|
31
|
+
Part of the [qso-graph](https://qso-graph.io/) project. Depends on [adif-mcp](https://pypi.org/project/adif-mcp/) for persona and credential management.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install eqsl-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Tools
|
|
40
|
+
|
|
41
|
+
| Tool | Auth | Description |
|
|
42
|
+
|------|------|-------------|
|
|
43
|
+
| `eqsl_inbox` | Yes | Download incoming eQSLs with date/confirmation filters |
|
|
44
|
+
| `eqsl_verify` | No | Check if a specific QSO exists in eQSL |
|
|
45
|
+
| `eqsl_ag_check` | No | Check if a callsign has AG (Authenticity Guaranteed) status |
|
|
46
|
+
| `eqsl_last_upload` | No | When did a persona last upload to eQSL |
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
### 1. Set up credentials
|
|
51
|
+
|
|
52
|
+
eqsl-mcp uses adif-mcp personas for credential management:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Install adif-mcp if you haven't
|
|
56
|
+
pip install adif-mcp
|
|
57
|
+
|
|
58
|
+
# Create a persona and add eQSL credentials
|
|
59
|
+
adif-mcp persona create ki7mt --callsign KI7MT
|
|
60
|
+
adif-mcp persona provider ki7mt eqsl --username KI7MT
|
|
61
|
+
adif-mcp persona secret ki7mt eqsl
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 2. Configure your MCP client
|
|
65
|
+
|
|
66
|
+
eqsl-mcp works with any MCP-compatible client. Add the server config and restart — tools appear automatically.
|
|
67
|
+
|
|
68
|
+
#### Claude Desktop
|
|
69
|
+
|
|
70
|
+
Add to `claude_desktop_config.json` (`~/Library/Application Support/Claude/` on macOS, `%APPDATA%\Claude\` on Windows):
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"mcpServers": {
|
|
75
|
+
"eqsl": {
|
|
76
|
+
"command": "eqsl-mcp"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
#### Claude Code
|
|
83
|
+
|
|
84
|
+
Add to `.claude/settings.json`:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"mcpServers": {
|
|
89
|
+
"eqsl": {
|
|
90
|
+
"command": "eqsl-mcp"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
#### ChatGPT Desktop
|
|
97
|
+
|
|
98
|
+
ChatGPT supports MCP via the [OpenAI Agents SDK](https://developers.openai.com/api/docs/mcp/). Add under Settings > Apps & Connectors, or configure in your agent definition:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"mcpServers": {
|
|
103
|
+
"eqsl": {
|
|
104
|
+
"command": "eqsl-mcp"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
#### Cursor
|
|
111
|
+
|
|
112
|
+
Add to `.cursor/mcp.json` (project-level) or `~/.cursor/mcp.json` (global):
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"mcpServers": {
|
|
117
|
+
"eqsl": {
|
|
118
|
+
"command": "eqsl-mcp"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
#### VS Code / GitHub Copilot
|
|
125
|
+
|
|
126
|
+
Add to `.vscode/mcp.json` in your workspace:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"servers": {
|
|
131
|
+
"eqsl": {
|
|
132
|
+
"command": "eqsl-mcp"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
#### Gemini CLI
|
|
139
|
+
|
|
140
|
+
Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project):
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"mcpServers": {
|
|
145
|
+
"eqsl": {
|
|
146
|
+
"command": "eqsl-mcp"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### 3. Ask questions
|
|
153
|
+
|
|
154
|
+
> "Show me all eQSLs received this week"
|
|
155
|
+
|
|
156
|
+
> "How many unconfirmed eQSLs do I have on 20m FT8?"
|
|
157
|
+
|
|
158
|
+
> "Does W1AW have AG status on eQSL?"
|
|
159
|
+
|
|
160
|
+
> "Verify my QSO with KI7MT on 20m on March 1, 2026"
|
|
161
|
+
|
|
162
|
+
## Testing Without Credentials
|
|
163
|
+
|
|
164
|
+
The two public tools (`eqsl_verify` and `eqsl_ag_check`) work without any credentials.
|
|
165
|
+
|
|
166
|
+
For `eqsl_inbox` testing, set the mock environment variable:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
EQSL_MCP_MOCK=1 eqsl-mcp
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Or point to a local ADIF file:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
EQSL_MCP_MOCK=1 EQSL_MCP_ADIF=/path/to/test.adi eqsl-mcp
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## MCP Inspector
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
eqsl-mcp --transport streamable-http --port 8001
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Then open the MCP Inspector at `http://localhost:8001`.
|
|
185
|
+
|
|
186
|
+
## Development
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
git clone https://github.com/qso-graph/eqsl-mcp.git
|
|
190
|
+
cd eqsl-mcp
|
|
191
|
+
pip install -e .
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Date Formats
|
|
195
|
+
|
|
196
|
+
eQSL uses different date formats across endpoints. eqsl-mcp normalizes everything — you always use `YYYY-MM-DD`:
|
|
197
|
+
|
|
198
|
+
| You provide | eqsl-mcp sends | Endpoint |
|
|
199
|
+
|-------------|----------------|----------|
|
|
200
|
+
| `2026-03-01` | `202603010000` | DownloadInBox (RcvdSince) |
|
|
201
|
+
| `2026-03-01` | `03/01/2026` | VerifyQSO (QSODate) |
|
|
202
|
+
|
|
203
|
+
## Mode Matching
|
|
204
|
+
|
|
205
|
+
eQSL requires exact mode matching. `SSB` won't match `USB`/`LSB`. `PSK` won't match `PSK31`. Use the exact mode logged by the other station.
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
GPL-3.0-or-later
|
eqsl_mcp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# eqsl-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [eQSL.cc](https://www.eqsl.cc/) — download incoming eQSLs, verify QSOs, check AG status, and query upload history through any MCP-compatible AI assistant.
|
|
4
|
+
|
|
5
|
+
Part of the [qso-graph](https://qso-graph.io/) project. Depends on [adif-mcp](https://pypi.org/project/adif-mcp/) for persona and credential management.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install eqsl-mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
| Tool | Auth | Description |
|
|
16
|
+
|------|------|-------------|
|
|
17
|
+
| `eqsl_inbox` | Yes | Download incoming eQSLs with date/confirmation filters |
|
|
18
|
+
| `eqsl_verify` | No | Check if a specific QSO exists in eQSL |
|
|
19
|
+
| `eqsl_ag_check` | No | Check if a callsign has AG (Authenticity Guaranteed) status |
|
|
20
|
+
| `eqsl_last_upload` | No | When did a persona last upload to eQSL |
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
### 1. Set up credentials
|
|
25
|
+
|
|
26
|
+
eqsl-mcp uses adif-mcp personas for credential management:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# Install adif-mcp if you haven't
|
|
30
|
+
pip install adif-mcp
|
|
31
|
+
|
|
32
|
+
# Create a persona and add eQSL credentials
|
|
33
|
+
adif-mcp persona create ki7mt --callsign KI7MT
|
|
34
|
+
adif-mcp persona provider ki7mt eqsl --username KI7MT
|
|
35
|
+
adif-mcp persona secret ki7mt eqsl
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 2. Configure your MCP client
|
|
39
|
+
|
|
40
|
+
eqsl-mcp works with any MCP-compatible client. Add the server config and restart — tools appear automatically.
|
|
41
|
+
|
|
42
|
+
#### Claude Desktop
|
|
43
|
+
|
|
44
|
+
Add to `claude_desktop_config.json` (`~/Library/Application Support/Claude/` on macOS, `%APPDATA%\Claude\` on Windows):
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"eqsl": {
|
|
50
|
+
"command": "eqsl-mcp"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
#### Claude Code
|
|
57
|
+
|
|
58
|
+
Add to `.claude/settings.json`:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"mcpServers": {
|
|
63
|
+
"eqsl": {
|
|
64
|
+
"command": "eqsl-mcp"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
#### ChatGPT Desktop
|
|
71
|
+
|
|
72
|
+
ChatGPT supports MCP via the [OpenAI Agents SDK](https://developers.openai.com/api/docs/mcp/). Add under Settings > Apps & Connectors, or configure in your agent definition:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"mcpServers": {
|
|
77
|
+
"eqsl": {
|
|
78
|
+
"command": "eqsl-mcp"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### Cursor
|
|
85
|
+
|
|
86
|
+
Add to `.cursor/mcp.json` (project-level) or `~/.cursor/mcp.json` (global):
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"mcpServers": {
|
|
91
|
+
"eqsl": {
|
|
92
|
+
"command": "eqsl-mcp"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### VS Code / GitHub Copilot
|
|
99
|
+
|
|
100
|
+
Add to `.vscode/mcp.json` in your workspace:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"servers": {
|
|
105
|
+
"eqsl": {
|
|
106
|
+
"command": "eqsl-mcp"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
#### Gemini CLI
|
|
113
|
+
|
|
114
|
+
Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project):
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"mcpServers": {
|
|
119
|
+
"eqsl": {
|
|
120
|
+
"command": "eqsl-mcp"
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 3. Ask questions
|
|
127
|
+
|
|
128
|
+
> "Show me all eQSLs received this week"
|
|
129
|
+
|
|
130
|
+
> "How many unconfirmed eQSLs do I have on 20m FT8?"
|
|
131
|
+
|
|
132
|
+
> "Does W1AW have AG status on eQSL?"
|
|
133
|
+
|
|
134
|
+
> "Verify my QSO with KI7MT on 20m on March 1, 2026"
|
|
135
|
+
|
|
136
|
+
## Testing Without Credentials
|
|
137
|
+
|
|
138
|
+
The two public tools (`eqsl_verify` and `eqsl_ag_check`) work without any credentials.
|
|
139
|
+
|
|
140
|
+
For `eqsl_inbox` testing, set the mock environment variable:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
EQSL_MCP_MOCK=1 eqsl-mcp
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Or point to a local ADIF file:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
EQSL_MCP_MOCK=1 EQSL_MCP_ADIF=/path/to/test.adi eqsl-mcp
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## MCP Inspector
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
eqsl-mcp --transport streamable-http --port 8001
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Then open the MCP Inspector at `http://localhost:8001`.
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
git clone https://github.com/qso-graph/eqsl-mcp.git
|
|
164
|
+
cd eqsl-mcp
|
|
165
|
+
pip install -e .
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Date Formats
|
|
169
|
+
|
|
170
|
+
eQSL uses different date formats across endpoints. eqsl-mcp normalizes everything — you always use `YYYY-MM-DD`:
|
|
171
|
+
|
|
172
|
+
| You provide | eqsl-mcp sends | Endpoint |
|
|
173
|
+
|-------------|----------------|----------|
|
|
174
|
+
| `2026-03-01` | `202603010000` | DownloadInBox (RcvdSince) |
|
|
175
|
+
| `2026-03-01` | `03/01/2026` | VerifyQSO (QSODate) |
|
|
176
|
+
|
|
177
|
+
## Mode Matching
|
|
178
|
+
|
|
179
|
+
eQSL requires exact mode matching. `SSB` won't match `USB`/`LSB`. `PSK` won't match `PSK31`. Use the exact mode logged by the other station.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
GPL-3.0-or-later
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "eqsl-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for eQSL.cc — inbox download, QSO verification, AG status checks"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "GPL-3.0-or-later"}
|
|
7
|
+
authors = [{name = "Greg Beam, KI7MT"}]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"adif-mcp>=0.6.2",
|
|
11
|
+
"fastmcp>=3.0",
|
|
12
|
+
]
|
|
13
|
+
keywords = [
|
|
14
|
+
"amateur-radio", "ham-radio", "eqsl", "qsl", "qso",
|
|
15
|
+
"mcp", "model-context-protocol", "confirmation",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Topic :: Communications :: Ham Radio",
|
|
21
|
+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Operating System :: OS Independent",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://qso-graph.io/"
|
|
32
|
+
Repository = "https://github.com/qso-graph/eqsl-mcp"
|
|
33
|
+
Issues = "https://github.com/qso-graph/eqsl-mcp/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
eqsl-mcp = "eqsl_mcp.server:main"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/eqsl_mcp"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""AG (Authenticity Guaranteed) member list with file-based cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
_AG_URL = "https://www.eqsl.cc/qslcard/DownloadedFiles/AGMemberList.txt"
|
|
10
|
+
_CACHE_TTL = 4 * 3600 # 4 hours — list updated ~6x daily
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _cache_path() -> str:
|
|
14
|
+
"""Return OS-appropriate cache file path."""
|
|
15
|
+
if os.name == "nt":
|
|
16
|
+
base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
|
|
17
|
+
else:
|
|
18
|
+
base = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
|
|
19
|
+
d = os.path.join(base, "eqsl-mcp")
|
|
20
|
+
os.makedirs(d, exist_ok=True)
|
|
21
|
+
return os.path.join(d, "ag_members.txt")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_fresh(path: str) -> bool:
|
|
25
|
+
"""True if the cache file exists and is younger than TTL."""
|
|
26
|
+
try:
|
|
27
|
+
return (time.time() - os.path.getmtime(path)) < _CACHE_TTL
|
|
28
|
+
except OSError:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _download_ag_list() -> set[str]:
|
|
33
|
+
"""Fetch the AG member list from eQSL and cache it."""
|
|
34
|
+
path = _cache_path()
|
|
35
|
+
req = urllib.request.Request(_AG_URL, method="GET")
|
|
36
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
37
|
+
data = resp.read().decode("utf-8", errors="replace")
|
|
38
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
39
|
+
f.write(data)
|
|
40
|
+
return _parse_ag(data)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_ag(text: str) -> set[str]:
|
|
44
|
+
"""Parse the AG list into a set of uppercase callsigns."""
|
|
45
|
+
calls: set[str] = set()
|
|
46
|
+
for line in text.splitlines():
|
|
47
|
+
line = line.strip()
|
|
48
|
+
# Skip header/blank lines — callsigns are alphanumeric+slash
|
|
49
|
+
if not line or line.startswith("#") or line.startswith("List"):
|
|
50
|
+
continue
|
|
51
|
+
# Each line is one callsign
|
|
52
|
+
call = line.split()[0].upper()
|
|
53
|
+
if call.isascii() and len(call) >= 3:
|
|
54
|
+
calls.add(call)
|
|
55
|
+
return calls
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_ag_set() -> set[str]:
|
|
59
|
+
"""Return the AG member set, using cache if fresh."""
|
|
60
|
+
path = _cache_path()
|
|
61
|
+
if _is_fresh(path):
|
|
62
|
+
try:
|
|
63
|
+
with open(path, encoding="utf-8") as f:
|
|
64
|
+
return _parse_ag(f.read())
|
|
65
|
+
except OSError:
|
|
66
|
+
pass
|
|
67
|
+
return _download_ag_list()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_ag(callsign: str) -> bool:
|
|
71
|
+
"""Check if a callsign has AG status."""
|
|
72
|
+
return callsign.upper() in load_ag_set()
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""HTTP client layer for eQSL.cc endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from adif_mcp.identity import PersonaManager
|
|
12
|
+
|
|
13
|
+
from .parser import FetchResult, QsoRecord, parse_adif, to_mmddyyyy, to_qso, to_yyyymmddhhmm
|
|
14
|
+
|
|
15
|
+
_BASE = "https://www.eqsl.cc"
|
|
16
|
+
|
|
17
|
+
# Mock ADIF for testing without credentials
|
|
18
|
+
_MOCK_ADIF = (
|
|
19
|
+
"<CALL:5>KI7MT<QSO_DATE:8>20250901<TIME_ON:6>010203<BAND:3>20M<MODE:3>FT8"
|
|
20
|
+
"<EQSL_QSL_RCVD:1>Y<EQSL_QSLRDATE:8>20250902<APP_EQSL_AG:1>Y<EOR>"
|
|
21
|
+
"<CALL:5>K7ABC<QSO_DATE:8>20250901<TIME_ON:6>040506<BAND:3>40M<MODE:2>CW"
|
|
22
|
+
"<EQSL_QSL_RCVD:1>N<EOR>"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Regex to find the .adi download link in the HTML response
|
|
26
|
+
_ADI_LINK_RE = re.compile(r'href="([^"]*\.adi)"', re.IGNORECASE)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_mock() -> bool:
|
|
30
|
+
return os.getenv("EQSL_MCP_MOCK") == "1"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get(url: str, query: dict[str, Any] | None = None,
|
|
34
|
+
timeout: float = 15.0) -> tuple[int, str]:
|
|
35
|
+
"""HTTP GET, return (status, text)."""
|
|
36
|
+
if query:
|
|
37
|
+
qs = urllib.parse.urlencode(query, doseq=True)
|
|
38
|
+
url = f"{url}?{qs}"
|
|
39
|
+
req = urllib.request.Request(url, method="GET")
|
|
40
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
41
|
+
return resp.status, resp.read().decode("utf-8", errors="replace")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# eqsl_inbox
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def download_inbox(
|
|
49
|
+
pm: PersonaManager,
|
|
50
|
+
persona: str,
|
|
51
|
+
since: str | None = None,
|
|
52
|
+
qth_nickname: str | None = None,
|
|
53
|
+
) -> FetchResult:
|
|
54
|
+
"""Download incoming eQSLs for a persona.
|
|
55
|
+
|
|
56
|
+
Two-step flow: GET DownloadInBox.cfm → parse HTML for .adi link → fetch ADIF.
|
|
57
|
+
Fallback: if the response body contains <EOH> or <EOR>, treat as direct ADIF.
|
|
58
|
+
"""
|
|
59
|
+
if _is_mock():
|
|
60
|
+
sample = os.getenv("EQSL_MCP_ADIF")
|
|
61
|
+
if sample and os.path.exists(sample):
|
|
62
|
+
text = open(sample, encoding="utf-8").read()
|
|
63
|
+
else:
|
|
64
|
+
text = _MOCK_ADIF
|
|
65
|
+
return FetchResult(records=[to_qso(r) for r in parse_adif(text)])
|
|
66
|
+
|
|
67
|
+
username, password = pm.require(persona, "eqsl")
|
|
68
|
+
|
|
69
|
+
query: dict[str, Any] = {
|
|
70
|
+
"UserName": username,
|
|
71
|
+
"Password": password,
|
|
72
|
+
"RcvdSince": to_yyyymmddhhmm(since),
|
|
73
|
+
}
|
|
74
|
+
if qth_nickname:
|
|
75
|
+
query["QTHNickname"] = qth_nickname
|
|
76
|
+
|
|
77
|
+
status, body = _get(f"{_BASE}/qslcard/DownloadInBox.cfm", query)
|
|
78
|
+
if status != 200:
|
|
79
|
+
return FetchResult(records=[])
|
|
80
|
+
|
|
81
|
+
# Check if this is already ADIF (some responses come direct)
|
|
82
|
+
upper = body.upper()
|
|
83
|
+
if "<EOH>" in upper or "<EOR>" in upper:
|
|
84
|
+
return FetchResult(records=[to_qso(r) for r in parse_adif(body)])
|
|
85
|
+
|
|
86
|
+
# Two-step: extract .adi link from HTML
|
|
87
|
+
m = _ADI_LINK_RE.search(body)
|
|
88
|
+
if not m:
|
|
89
|
+
return FetchResult(records=[])
|
|
90
|
+
|
|
91
|
+
adi_url = m.group(1)
|
|
92
|
+
if not adi_url.startswith("http"):
|
|
93
|
+
adi_url = f"{_BASE}{adi_url}" if adi_url.startswith("/") else f"{_BASE}/{adi_url}"
|
|
94
|
+
|
|
95
|
+
_, adif_text = _get(adi_url)
|
|
96
|
+
return FetchResult(records=[to_qso(r) for r in parse_adif(adif_text)])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# eqsl_verify
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def verify_qso(
|
|
104
|
+
from_call: str,
|
|
105
|
+
to_call: str,
|
|
106
|
+
band: str,
|
|
107
|
+
qso_date: str,
|
|
108
|
+
mode: str | None = None,
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
"""Verify a QSO exists in eQSL (public, no auth).
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
qso_date: YYYY-MM-DD format.
|
|
114
|
+
"""
|
|
115
|
+
if _is_mock():
|
|
116
|
+
return {"verified": True, "ag": True, "message": "Result - QSO on file for AG member"}
|
|
117
|
+
|
|
118
|
+
query: dict[str, Any] = {
|
|
119
|
+
"Callsign": from_call.upper(),
|
|
120
|
+
"HisCall": to_call.upper(),
|
|
121
|
+
"QSOBand": band.upper(),
|
|
122
|
+
"QSODate": to_mmddyyyy(qso_date),
|
|
123
|
+
}
|
|
124
|
+
if mode:
|
|
125
|
+
query["QSOMode"] = mode.upper()
|
|
126
|
+
|
|
127
|
+
_, body = _get(f"{_BASE}/qslcard/VerifyQSO.cfm", query)
|
|
128
|
+
|
|
129
|
+
text = body.strip()
|
|
130
|
+
verified = "qso on file" in text.lower()
|
|
131
|
+
ag = "ag member" in text.lower()
|
|
132
|
+
return {"verified": verified, "ag": ag, "message": text}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# eqsl_last_upload
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def last_upload_date(pm: PersonaManager, persona: str) -> dict[str, Any]:
|
|
140
|
+
"""Get the last upload date for a persona."""
|
|
141
|
+
if _is_mock():
|
|
142
|
+
return {"persona": persona, "last_upload": "2026-03-01 12:00:00"}
|
|
143
|
+
|
|
144
|
+
username, password = pm.require(persona, "eqsl")
|
|
145
|
+
|
|
146
|
+
query: dict[str, Any] = {
|
|
147
|
+
"UserName": username,
|
|
148
|
+
"Password": password,
|
|
149
|
+
}
|
|
150
|
+
status, body = _get(f"{_BASE}/qslcard/DisplayLastUploadDate.cfm", query)
|
|
151
|
+
if status != 200:
|
|
152
|
+
return {"persona": persona, "last_upload": None, "error": f"HTTP {status}"}
|
|
153
|
+
|
|
154
|
+
# The response is typically plain text with the date
|
|
155
|
+
return {"persona": persona, "last_upload": body.strip()}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Minimal ADIF parser and QSO record types for eQSL responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from typing import TypedDict
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Types
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class QsoRecord(TypedDict, total=False):
|
|
16
|
+
"""Normalized QSO record from eQSL inbox."""
|
|
17
|
+
|
|
18
|
+
call: str
|
|
19
|
+
qso_date: str # YYYYMMDD
|
|
20
|
+
time_on: str # HHMM or HHMMSS
|
|
21
|
+
band: str | None
|
|
22
|
+
mode: str | None
|
|
23
|
+
freq: float | None # MHz
|
|
24
|
+
rst_sent: str | None
|
|
25
|
+
rst_rcvd: str | None
|
|
26
|
+
gridsquare: str | None
|
|
27
|
+
eqsl_qsl_rcvd: str | None # Y/N/I/…
|
|
28
|
+
eqsl_qslrdate: str | None # YYYYMMDD
|
|
29
|
+
app_eqsl_ag: str | None # Y/N
|
|
30
|
+
qslmsg: str | None
|
|
31
|
+
adif: dict[str, str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class FetchResult:
|
|
36
|
+
"""Container for parsed inbox records."""
|
|
37
|
+
|
|
38
|
+
records: list[QsoRecord]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# ADIF parser
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
_FIELD_RE = re.compile(
|
|
46
|
+
r"<([A-Za-z0-9_]+):(\d+)(?::[A-Za-z])?>([^<]*)", re.IGNORECASE
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_adif(text: str) -> list[dict[str, str]]:
|
|
51
|
+
"""Extract tag→value pairs per <EOR> from raw ADIF text.
|
|
52
|
+
|
|
53
|
+
Not a full ADIF parser — sufficient for eQSL response payloads.
|
|
54
|
+
"""
|
|
55
|
+
out: list[dict[str, str]] = []
|
|
56
|
+
current: dict[str, str] = {}
|
|
57
|
+
i = 0
|
|
58
|
+
n = len(text)
|
|
59
|
+
while i < n:
|
|
60
|
+
if text[i : i + 5].upper() == "<EOR>":
|
|
61
|
+
if current:
|
|
62
|
+
out.append(current)
|
|
63
|
+
current = {}
|
|
64
|
+
i += 5
|
|
65
|
+
continue
|
|
66
|
+
m = _FIELD_RE.match(text, i)
|
|
67
|
+
if not m:
|
|
68
|
+
i += 1
|
|
69
|
+
continue
|
|
70
|
+
tag, length_s, value = m.group(1), m.group(2), m.group(3)
|
|
71
|
+
try:
|
|
72
|
+
length = int(length_s)
|
|
73
|
+
except ValueError:
|
|
74
|
+
length = len(value)
|
|
75
|
+
if len(value) < length:
|
|
76
|
+
end = m.end()
|
|
77
|
+
need = length - len(value)
|
|
78
|
+
value = value + text[end : end + need]
|
|
79
|
+
i = end + need
|
|
80
|
+
else:
|
|
81
|
+
i = m.end()
|
|
82
|
+
current[tag.upper()] = value
|
|
83
|
+
if current:
|
|
84
|
+
out.append(current)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def to_qso(rec: dict[str, str]) -> QsoRecord:
|
|
89
|
+
"""Convert a raw ADIF tag dict into a normalized QsoRecord."""
|
|
90
|
+
|
|
91
|
+
def _float(s: str | None) -> float | None:
|
|
92
|
+
try:
|
|
93
|
+
return float(s) if s else None
|
|
94
|
+
except Exception:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
return QsoRecord(
|
|
98
|
+
call=rec.get("CALL", "").upper(),
|
|
99
|
+
qso_date=rec.get("QSO_DATE", ""),
|
|
100
|
+
time_on=rec.get("TIME_ON", ""),
|
|
101
|
+
band=rec.get("BAND"),
|
|
102
|
+
mode=rec.get("MODE"),
|
|
103
|
+
freq=_float(rec.get("FREQ")),
|
|
104
|
+
rst_sent=rec.get("RST_SENT"),
|
|
105
|
+
rst_rcvd=rec.get("RST_RCVD"),
|
|
106
|
+
gridsquare=rec.get("GRIDSQUARE"),
|
|
107
|
+
eqsl_qsl_rcvd=rec.get("EQSL_QSL_RCVD"),
|
|
108
|
+
eqsl_qslrdate=rec.get("EQSL_QSLRDATE"),
|
|
109
|
+
app_eqsl_ag=rec.get("APP_EQSL_AG"),
|
|
110
|
+
qslmsg=rec.get("QSLMSG"),
|
|
111
|
+
adif=rec,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Date helpers
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def to_yyyymmddhhmm(d: str | date | None) -> str:
|
|
121
|
+
"""Convert a date to YYYYMMDDHHMM (eQSL RcvdSince format).
|
|
122
|
+
|
|
123
|
+
Accepts ISO date strings (YYYY-MM-DD), date objects, or None (→ 30 days ago).
|
|
124
|
+
"""
|
|
125
|
+
if d is None:
|
|
126
|
+
dt = datetime.utcnow()
|
|
127
|
+
from datetime import timedelta
|
|
128
|
+
dt = dt - timedelta(days=30)
|
|
129
|
+
return dt.strftime("%Y%m%d0000")
|
|
130
|
+
if isinstance(d, date):
|
|
131
|
+
return d.strftime("%Y%m%d0000")
|
|
132
|
+
try:
|
|
133
|
+
return date.fromisoformat(d).strftime("%Y%m%d0000")
|
|
134
|
+
except Exception:
|
|
135
|
+
return datetime.utcnow().strftime("%Y%m%d0000")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def to_mmddyyyy(d: str) -> str:
|
|
139
|
+
"""Convert YYYY-MM-DD to MM/DD/YYYY (eQSL VerifyQSO format)."""
|
|
140
|
+
try:
|
|
141
|
+
dt = date.fromisoformat(d)
|
|
142
|
+
return dt.strftime("%m/%d/%Y")
|
|
143
|
+
except Exception:
|
|
144
|
+
return d
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""eqsl-mcp: MCP server for eQSL.cc confirmation data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from adif_mcp.identity import PersonaManager
|
|
10
|
+
from adif_mcp.identity.errors import CredentialError
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .ag_cache import is_ag
|
|
14
|
+
from .client import download_inbox, last_upload_date, verify_qso
|
|
15
|
+
|
|
16
|
+
mcp = FastMCP(
|
|
17
|
+
"eqsl-mcp",
|
|
18
|
+
version=__version__,
|
|
19
|
+
instructions="MCP server for eQSL.cc — inbox, verification, AG status",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _pm() -> PersonaManager:
|
|
24
|
+
return PersonaManager()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Tools
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@mcp.tool()
|
|
33
|
+
def eqsl_inbox(
|
|
34
|
+
persona: str,
|
|
35
|
+
since: str | None = None,
|
|
36
|
+
confirmed_only: bool = False,
|
|
37
|
+
unconfirmed_only: bool = False,
|
|
38
|
+
qth_nickname: str | None = None,
|
|
39
|
+
) -> dict:
|
|
40
|
+
"""Download incoming eQSLs (confirmations others have sent you).
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
persona: Persona name configured in adif-mcp.
|
|
44
|
+
since: Only records added since this date (YYYY-MM-DD). Default: last 30 days.
|
|
45
|
+
confirmed_only: Only return records you have confirmed back.
|
|
46
|
+
unconfirmed_only: Only return records you have NOT confirmed.
|
|
47
|
+
qth_nickname: QTH profile name (for multi-QTH callsigns).
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Total count, confirmed count, breakdown by band, and QSO records.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
result = download_inbox(_pm(), persona, since=since, qth_nickname=qth_nickname)
|
|
54
|
+
except CredentialError as e:
|
|
55
|
+
return {"error": str(e)}
|
|
56
|
+
|
|
57
|
+
records = result.records
|
|
58
|
+
|
|
59
|
+
if confirmed_only:
|
|
60
|
+
records = [r for r in records if (r.get("eqsl_qsl_rcvd") or "").upper() == "Y"]
|
|
61
|
+
elif unconfirmed_only:
|
|
62
|
+
records = [r for r in records if (r.get("eqsl_qsl_rcvd") or "").upper() != "Y"]
|
|
63
|
+
|
|
64
|
+
# Band tally
|
|
65
|
+
tally: dict[str, int] = {}
|
|
66
|
+
confirmed = 0
|
|
67
|
+
for r in records:
|
|
68
|
+
band = (r.get("band") or "UNKNOWN").upper()
|
|
69
|
+
tally[band] = tally.get(band, 0) + 1
|
|
70
|
+
if (r.get("eqsl_qsl_rcvd") or "").upper() == "Y":
|
|
71
|
+
confirmed += 1
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"total": len(records),
|
|
75
|
+
"confirmed": confirmed,
|
|
76
|
+
"by_band": [{"band": k, "count": v} for k, v in sorted(tally.items())],
|
|
77
|
+
"records": records,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@mcp.tool()
|
|
82
|
+
def eqsl_verify(
|
|
83
|
+
from_call: str,
|
|
84
|
+
to_call: str,
|
|
85
|
+
band: str,
|
|
86
|
+
qso_date: str,
|
|
87
|
+
mode: str | None = None,
|
|
88
|
+
) -> dict:
|
|
89
|
+
"""Check if a specific QSO exists in eQSL (public, no auth required).
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
from_call: Sender's callsign.
|
|
93
|
+
to_call: Receiver's callsign.
|
|
94
|
+
band: Band (e.g., '20m').
|
|
95
|
+
qso_date: QSO date in YYYY-MM-DD format.
|
|
96
|
+
mode: Mode (exact match — use 'USB' not 'SSB', 'PSK31' not 'PSK').
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Whether the QSO is verified, AG status, and the raw message.
|
|
100
|
+
"""
|
|
101
|
+
return verify_qso(from_call, to_call, band, qso_date, mode)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@mcp.tool()
|
|
105
|
+
def eqsl_ag_check(callsign: str) -> dict:
|
|
106
|
+
"""Check if a callsign has Authenticity Guaranteed (AG) status on eQSL.
|
|
107
|
+
|
|
108
|
+
Public, no auth required. Uses a cached copy of the AG member list
|
|
109
|
+
(refreshed every 4 hours).
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
callsign: The callsign to check.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Callsign and AG status.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
ag = is_ag(callsign)
|
|
119
|
+
return {"callsign": callsign.upper(), "ag": ag}
|
|
120
|
+
except Exception as e:
|
|
121
|
+
return {"callsign": callsign.upper(), "ag": None, "error": str(e)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@mcp.tool()
|
|
125
|
+
def eqsl_last_upload(persona: str) -> dict:
|
|
126
|
+
"""Check when a persona last uploaded QSOs to eQSL.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
persona: Persona name configured in adif-mcp.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Persona name and last upload timestamp.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
return last_upload_date(_pm(), persona)
|
|
136
|
+
except CredentialError as e:
|
|
137
|
+
return {"error": str(e)}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Entry point
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main() -> None:
|
|
146
|
+
"""Run the eqsl-mcp server."""
|
|
147
|
+
transport = "stdio"
|
|
148
|
+
port = 8001
|
|
149
|
+
for i, arg in enumerate(sys.argv[1:], 1):
|
|
150
|
+
if arg == "--transport" and i < len(sys.argv) - 1:
|
|
151
|
+
transport = sys.argv[i + 1]
|
|
152
|
+
if arg == "--port" and i < len(sys.argv) - 1:
|
|
153
|
+
port = int(sys.argv[i + 1])
|
|
154
|
+
|
|
155
|
+
if transport == "streamable-http":
|
|
156
|
+
mcp.run(transport=transport, port=port)
|
|
157
|
+
else:
|
|
158
|
+
mcp.run(transport="stdio")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
main()
|