@opengolfapi/mcp-server 2.1.0 → 2.2.1
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.
- package/README.md +32 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +179 -94
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# @opengolfapi/mcp-server
|
|
2
2
|
|
|
3
|
-
Open MCP server for AI agents to query the OpenGolfAPI dataset (
|
|
3
|
+
Open MCP server for AI agents to query the OpenGolfAPI dataset (14,708 US golf courses). All data is ODbL licensed and open.
|
|
4
|
+
|
|
5
|
+
All requests go through the public API at https://api.opengolfapi.org. With an optional `OPENGOLFAPI_KEY`, requests authenticate as your tier and unlock higher rate limits.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
@@ -22,6 +24,35 @@ Add to your MCP client config:
|
|
|
22
24
|
}
|
|
23
25
|
```
|
|
24
26
|
|
|
27
|
+
## API keys (optional)
|
|
28
|
+
|
|
29
|
+
### Optional: higher rate limits with a free key
|
|
30
|
+
|
|
31
|
+
Without a key, the MCP server uses anonymous access (1,000 requests/day per IP).
|
|
32
|
+
|
|
33
|
+
Get a free key at https://courses.opengolfapi.org/api-keys (~30 seconds, no card),
|
|
34
|
+
then set:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export OPENGOLFAPI_KEY=ogapi_yourkeyhere
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
In Claude Desktop's MCP config, add the env var to the server entry:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"mcpServers": {
|
|
45
|
+
"opengolfapi": {
|
|
46
|
+
"command": "npx",
|
|
47
|
+
"args": ["@opengolfapi/mcp-server"],
|
|
48
|
+
"env": { "OPENGOLFAPI_KEY": "ogapi_yourkeyhere" }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Donor tiers raise the daily limit further (10k / 50k / 250k / 1M).
|
|
55
|
+
|
|
25
56
|
## Tools
|
|
26
57
|
|
|
27
58
|
- `search_courses(query, state?, lat?, lng?, radius_mi?)` — find courses by name, state, or location
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* Tools: search_courses, get_course, get_tees, get_climate, get_nearby
|
|
6
6
|
* All data is open — ODbL licensed.
|
|
7
7
|
*
|
|
8
|
+
* All tools call the public REST API at https://api.opengolfapi.org.
|
|
9
|
+
* No direct database access. With an optional OPENGOLFAPI_KEY env var,
|
|
10
|
+
* requests authenticate as your tier and unlock higher rate limits.
|
|
11
|
+
*
|
|
8
12
|
* Install: npx @opengolfapi/mcp-server
|
|
9
13
|
*/
|
|
10
14
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -5,23 +5,67 @@
|
|
|
5
5
|
* Tools: search_courses, get_course, get_tees, get_climate, get_nearby
|
|
6
6
|
* All data is open — ODbL licensed.
|
|
7
7
|
*
|
|
8
|
+
* All tools call the public REST API at https://api.opengolfapi.org.
|
|
9
|
+
* No direct database access. With an optional OPENGOLFAPI_KEY env var,
|
|
10
|
+
* requests authenticate as your tier and unlock higher rate limits.
|
|
11
|
+
*
|
|
8
12
|
* Install: npx @opengolfapi/mcp-server
|
|
9
13
|
*/
|
|
10
14
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
15
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
16
|
import { z } from 'zod';
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Package version — used in User-Agent so the API can identify MCP traffic.
|
|
18
|
+
const PKG_VERSION = '2.2.0';
|
|
19
|
+
const API_BASE = process.env.OPENGOLFAPI_BASE ?? 'https://api.opengolfapi.org';
|
|
20
|
+
// Optional API key for higher rate limits. Anonymous (no key) still works
|
|
21
|
+
// at 1k req/day per IP. With a free key from courses.opengolfapi.org/api-keys,
|
|
22
|
+
// the limit jumps to 10k+/day depending on donor tier.
|
|
23
|
+
const OPENGOLFAPI_KEY = process.env.OPENGOLFAPI_KEY;
|
|
24
|
+
const userAgent = `opengolfapi-mcp-server/${PKG_VERSION}`;
|
|
25
|
+
const SOURCE = 'OpenGolfAPI (opengolfapi.org) — ODbL licensed';
|
|
26
|
+
// Wrap fetch so every outbound request carries our User-Agent and, when
|
|
27
|
+
// OPENGOLFAPI_KEY is set, an Authorization: Bearer header so the API can
|
|
28
|
+
// apply the donor-tier rate limit.
|
|
29
|
+
const customFetch = (input, init) => {
|
|
30
|
+
const headers = new Headers(init?.headers);
|
|
31
|
+
headers.set('User-Agent', userAgent);
|
|
32
|
+
headers.set('Accept', 'application/json');
|
|
33
|
+
if (OPENGOLFAPI_KEY) {
|
|
34
|
+
headers.set('Authorization', `Bearer ${OPENGOLFAPI_KEY}`);
|
|
35
|
+
}
|
|
36
|
+
return fetch(input, { ...init, headers });
|
|
37
|
+
};
|
|
38
|
+
async function apiGet(path) {
|
|
39
|
+
const url = `${API_BASE}${path}`;
|
|
40
|
+
const res = await customFetch(url);
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
throw new Error(`API ${res.status} ${res.statusText} for ${path}`);
|
|
43
|
+
}
|
|
44
|
+
return res.json();
|
|
45
|
+
}
|
|
46
|
+
function summarizeCourse(c) {
|
|
47
|
+
return {
|
|
48
|
+
id: c.id,
|
|
49
|
+
name: c.course_name,
|
|
50
|
+
city: c.city,
|
|
51
|
+
state: c.state,
|
|
52
|
+
lat: c.latitude,
|
|
53
|
+
lng: c.longitude,
|
|
54
|
+
type: c.course_type,
|
|
55
|
+
par: c.par_total,
|
|
56
|
+
total_yardage: c.total_yardage,
|
|
57
|
+
phone: c.phone,
|
|
58
|
+
website: c.website,
|
|
59
|
+
architect: c.architect,
|
|
60
|
+
year_built: c.year_built,
|
|
61
|
+
address: c.address,
|
|
62
|
+
postal_code: c.postal_code,
|
|
63
|
+
};
|
|
19
64
|
}
|
|
20
|
-
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
|
|
21
65
|
const server = new McpServer({
|
|
22
66
|
name: 'opengolfapi',
|
|
23
|
-
version:
|
|
24
|
-
description: 'Open database of
|
|
67
|
+
version: PKG_VERSION,
|
|
68
|
+
description: 'Open database of US golf courses. ODbL licensed. opengolfapi.org',
|
|
25
69
|
});
|
|
26
70
|
// ── Tool: search_courses ──
|
|
27
71
|
server.tool('search_courses', 'Search golf courses by name, state, or location. Returns full course info. ODbL licensed data from OpenGolfAPI.', {
|
|
@@ -32,44 +76,58 @@ server.tool('search_courses', 'Search golf courses by name, state, or location.
|
|
|
32
76
|
state: z.string().optional().describe('2-letter US state code'),
|
|
33
77
|
limit: z.number().optional().default(10).describe('Max results'),
|
|
34
78
|
}, async ({ lat, lng, radius_mi, query: q, state, limit }) => {
|
|
35
|
-
|
|
79
|
+
const max = limit ?? 10;
|
|
80
|
+
const r = radius_mi ?? 25;
|
|
81
|
+
// Build a server-side query. The public search supports `q`, `state`,
|
|
82
|
+
// and `limit`. Geo (lat/lng/radius_mi) is filtered client-side after
|
|
83
|
+
// fetch — see https://github.com/opengolfapi/api/issues for tracking
|
|
84
|
+
// native geo search support.
|
|
85
|
+
const params = new URLSearchParams();
|
|
36
86
|
if (q)
|
|
37
|
-
|
|
87
|
+
params.set('q', q);
|
|
38
88
|
if (state)
|
|
39
|
-
|
|
89
|
+
params.set('state', state.toUpperCase());
|
|
90
|
+
let pool = [];
|
|
91
|
+
let geoFilterApplied = false;
|
|
40
92
|
if (lat !== undefined && lng !== undefined) {
|
|
41
|
-
|
|
93
|
+
// Geo filtering: pull a wider net so the local bbox filter has data
|
|
94
|
+
// to chew on. If a state is given, prefer the state listing
|
|
95
|
+
// (returns up to 100 per page) since it's higher recall than search.
|
|
96
|
+
params.set('limit', '100');
|
|
97
|
+
if (state && !q) {
|
|
98
|
+
const data = await apiGet(`/v1/courses/state/${state.toUpperCase()}`);
|
|
99
|
+
pool = data.courses ?? [];
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const data = await apiGet(`/v1/courses/search?${params.toString()}`);
|
|
103
|
+
pool = data.courses ?? [];
|
|
104
|
+
}
|
|
42
105
|
const latDelta = r / 69.0;
|
|
43
106
|
const lngDelta = r / (69.0 * Math.cos(lat * Math.PI / 180));
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
|
|
107
|
+
pool = pool.filter(c => {
|
|
108
|
+
if (c.latitude == null || c.longitude == null)
|
|
109
|
+
return false;
|
|
110
|
+
return c.latitude >= lat - latDelta
|
|
111
|
+
&& c.latitude <= lat + latDelta
|
|
112
|
+
&& c.longitude >= lng - lngDelta
|
|
113
|
+
&& c.longitude <= lng + lngDelta;
|
|
114
|
+
});
|
|
115
|
+
geoFilterApplied = true;
|
|
47
116
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
lat: c.latitude,
|
|
55
|
-
lng: c.longitude,
|
|
56
|
-
type: c.course_type,
|
|
57
|
-
par: c.par_total,
|
|
58
|
-
total_yardage: c.total_yardage,
|
|
59
|
-
phone: c.phone,
|
|
60
|
-
website: c.website,
|
|
61
|
-
architect: c.architect,
|
|
62
|
-
year_built: c.year_built,
|
|
63
|
-
address: c.address,
|
|
64
|
-
postal_code: c.postal_code,
|
|
65
|
-
}));
|
|
117
|
+
else {
|
|
118
|
+
params.set('limit', String(max));
|
|
119
|
+
const data = await apiGet(`/v1/courses/search?${params.toString()}`);
|
|
120
|
+
pool = data.courses ?? [];
|
|
121
|
+
}
|
|
122
|
+
const courses = pool.slice(0, max).map(summarizeCourse);
|
|
66
123
|
return {
|
|
67
124
|
content: [{
|
|
68
125
|
type: 'text',
|
|
69
126
|
text: JSON.stringify({
|
|
70
127
|
courses,
|
|
71
128
|
total: courses.length,
|
|
72
|
-
|
|
129
|
+
...(geoFilterApplied ? { geo_filter: { lat, lng, radius_mi: r } } : {}),
|
|
130
|
+
source: SOURCE,
|
|
73
131
|
}, null, 2),
|
|
74
132
|
}],
|
|
75
133
|
};
|
|
@@ -78,15 +136,21 @@ server.tool('search_courses', 'Search golf courses by name, state, or location.
|
|
|
78
136
|
server.tool('get_course', 'Get detailed golf course info including full scorecard with par and handicap index per hole. ODbL licensed.', {
|
|
79
137
|
course_id: z.string().describe('Course UUID from search results'),
|
|
80
138
|
}, async ({ course_id }) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
139
|
+
let c;
|
|
140
|
+
let holes;
|
|
141
|
+
try {
|
|
142
|
+
[c, { holes }] = await Promise.all([
|
|
143
|
+
apiGet(`/v1/courses/${course_id}`),
|
|
144
|
+
apiGet(`/v1/courses/${course_id}/holes`),
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
86
148
|
return { content: [{ type: 'text', text: 'Course not found' }] };
|
|
87
149
|
}
|
|
88
|
-
const
|
|
89
|
-
|
|
150
|
+
const scorecard = (holes ?? [])
|
|
151
|
+
.filter(h => h.par != null)
|
|
152
|
+
.sort((a, b) => a.hole_number - b.hole_number)
|
|
153
|
+
.map(h => ({
|
|
90
154
|
hole: h.hole_number,
|
|
91
155
|
par: h.par,
|
|
92
156
|
handicap_index: h.handicap_index,
|
|
@@ -95,24 +159,10 @@ server.tool('get_course', 'Get detailed golf course info including full scorecar
|
|
|
95
159
|
content: [{
|
|
96
160
|
type: 'text',
|
|
97
161
|
text: JSON.stringify({
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
city: c.city,
|
|
101
|
-
state: c.state,
|
|
102
|
-
lat: c.latitude,
|
|
103
|
-
lng: c.longitude,
|
|
104
|
-
type: c.course_type,
|
|
105
|
-
par: c.par_total,
|
|
106
|
-
total_yardage: c.total_yardage,
|
|
107
|
-
holes: scorecard.length,
|
|
108
|
-
phone: c.phone,
|
|
109
|
-
website: c.website,
|
|
110
|
-
architect: c.architect,
|
|
111
|
-
year_built: c.year_built,
|
|
112
|
-
address: c.address,
|
|
113
|
-
postal_code: c.postal_code,
|
|
162
|
+
...summarizeCourse(c),
|
|
163
|
+
holes: scorecard.length || c.holes_count,
|
|
114
164
|
scorecard,
|
|
115
|
-
source:
|
|
165
|
+
source: SOURCE,
|
|
116
166
|
}, null, 2),
|
|
117
167
|
}],
|
|
118
168
|
};
|
|
@@ -121,62 +171,97 @@ server.tool('get_course', 'Get detailed golf course info including full scorecar
|
|
|
121
171
|
server.tool('get_tees', 'Get all tee sets for a course including ratings, slopes, and yardages per tee. ODbL licensed.', {
|
|
122
172
|
course_id: z.string().describe('Course UUID'),
|
|
123
173
|
}, async ({ course_id }) => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
.
|
|
127
|
-
.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
174
|
+
try {
|
|
175
|
+
const { tees } = await apiGet(`/v1/courses/${course_id}/tees`);
|
|
176
|
+
// Sort longest-yardage first to match prior behavior.
|
|
177
|
+
const sorted = [...(tees ?? [])].sort((a, b) => {
|
|
178
|
+
const ay = a.total_yardage ?? -1;
|
|
179
|
+
const by = b.total_yardage ?? -1;
|
|
180
|
+
return by - ay;
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
content: [{
|
|
184
|
+
type: 'text',
|
|
185
|
+
text: JSON.stringify({ tees: sorted, source: SOURCE }, null, 2),
|
|
186
|
+
}],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
191
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }] };
|
|
131
192
|
}
|
|
132
|
-
return {
|
|
133
|
-
content: [{
|
|
134
|
-
type: 'text',
|
|
135
|
-
text: JSON.stringify({ tees: data ?? [], source: 'OpenGolfAPI (opengolfapi.org) — ODbL licensed' }, null, 2),
|
|
136
|
-
}],
|
|
137
|
-
};
|
|
138
193
|
});
|
|
139
194
|
// ── Tool: get_climate ──
|
|
140
195
|
server.tool('get_climate', 'Get monthly climate normals for a course (temperature, precipitation, playability). ODbL licensed.', {
|
|
141
196
|
course_id: z.string().describe('Course UUID'),
|
|
142
197
|
}, async ({ course_id }) => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
198
|
+
try {
|
|
199
|
+
const climate = await apiGet(`/v1/courses/${course_id}/climate`);
|
|
200
|
+
return {
|
|
201
|
+
content: [{
|
|
202
|
+
type: 'text',
|
|
203
|
+
text: JSON.stringify({ climate, source: SOURCE }, null, 2),
|
|
204
|
+
}],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
209
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }] };
|
|
150
210
|
}
|
|
151
|
-
return {
|
|
152
|
-
content: [{
|
|
153
|
-
type: 'text',
|
|
154
|
-
text: JSON.stringify({ climate: data, source: 'OpenGolfAPI (opengolfapi.org) — ODbL licensed' }, null, 2),
|
|
155
|
-
}],
|
|
156
|
-
};
|
|
157
211
|
});
|
|
158
212
|
// ── Tool: get_nearby ──
|
|
159
213
|
server.tool('get_nearby', 'Get nearby points of interest for a course (hotels, restaurants, airports) within ~20 miles. ODbL licensed.', {
|
|
160
214
|
course_id: z.string().describe('Course UUID'),
|
|
161
215
|
}, async ({ course_id }) => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return {
|
|
216
|
+
try {
|
|
217
|
+
const { nearby } = await apiGet(`/v1/courses/${course_id}/nearby`);
|
|
218
|
+
const sorted = [...(nearby ?? [])].sort((a, b) => {
|
|
219
|
+
const ad = a.distance_miles ?? Number.POSITIVE_INFINITY;
|
|
220
|
+
const bd = b.distance_miles ?? Number.POSITIVE_INFINITY;
|
|
221
|
+
return ad - bd;
|
|
222
|
+
}).slice(0, 20);
|
|
223
|
+
return {
|
|
224
|
+
content: [{
|
|
225
|
+
type: 'text',
|
|
226
|
+
text: JSON.stringify({ nearby: sorted, source: SOURCE }, null, 2),
|
|
227
|
+
}],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
232
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }] };
|
|
170
233
|
}
|
|
234
|
+
});
|
|
235
|
+
// ── Tool: about ──
|
|
236
|
+
server.tool('about', 'Information about OpenGolfAPI: dataset size, license, how to contribute, how to contact the maintainers. Useful when an AI agent or user wants to know who built this and how to reach them.', {}, async () => {
|
|
171
237
|
return {
|
|
172
238
|
content: [{
|
|
173
239
|
type: 'text',
|
|
174
|
-
text: JSON.stringify({
|
|
240
|
+
text: JSON.stringify({
|
|
241
|
+
name: 'OpenGolfAPI',
|
|
242
|
+
courses: 14708,
|
|
243
|
+
license: 'ODbL-1.0',
|
|
244
|
+
docs: 'https://opengolfapi.org',
|
|
245
|
+
api_docs: 'https://api.opengolfapi.org',
|
|
246
|
+
api_keys: 'https://courses.opengolfapi.org/api-keys',
|
|
247
|
+
pricing: 'https://courses.opengolfapi.org/pricing',
|
|
248
|
+
donate: 'https://opencollective.com/opengolfapi',
|
|
249
|
+
github: 'https://github.com/opengolfapi',
|
|
250
|
+
developers: {
|
|
251
|
+
message: 'Building something on top of OpenGolfAPI? We want to know about it. Tell us what you\'re working on, what data you wish we had, and where the API or MCP server falls short.',
|
|
252
|
+
contact: 'hello@opengolfapi.org',
|
|
253
|
+
},
|
|
254
|
+
}, null, 2),
|
|
175
255
|
}],
|
|
176
256
|
};
|
|
177
257
|
});
|
|
178
258
|
// ── Start ──
|
|
179
259
|
async function main() {
|
|
260
|
+
// Greet developers in stderr — visible in Claude Desktop / Cursor MCP logs.
|
|
261
|
+
// Helps anyone debugging or evaluating the server know how to reach us.
|
|
262
|
+
console.error('OpenGolfAPI MCP server — 14,708 US golf courses, ODbL.');
|
|
263
|
+
console.error('Building something? We want to hear about it: hello@opengolfapi.org');
|
|
264
|
+
console.error('Free key for higher rate limits: https://courses.opengolfapi.org/api-keys');
|
|
180
265
|
const transport = new StdioServerTransport();
|
|
181
266
|
await server.connect(transport);
|
|
182
267
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengolfapi/mcp-server",
|
|
3
|
-
"version": "2.1
|
|
4
|
-
"description": "Open MCP server for AI agents to query the OpenGolfAPI dataset",
|
|
3
|
+
"version": "2.2.1",
|
|
4
|
+
"description": "Open MCP server for AI agents to query the OpenGolfAPI dataset (14,708 US golf courses, ODbL)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"opengolfapi-mcp": "dist/index.js"
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
"homepage": "https://opengolfapi.org",
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
36
|
-
"@supabase/supabase-js": "^2.0.0",
|
|
37
36
|
"zod": "^3.22.0"
|
|
38
37
|
},
|
|
39
38
|
"devDependencies": {
|