@opengolfapi/mcp-server 2.0.0 → 2.2.0
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 +156 -99
- package/package.json +16 -6
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,59 +171,66 @@ 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
|
}
|
|
171
|
-
return {
|
|
172
|
-
content: [{
|
|
173
|
-
type: 'text',
|
|
174
|
-
text: JSON.stringify({ nearby: data ?? [], source: 'OpenGolfAPI (opengolfapi.org) — ODbL licensed' }, null, 2),
|
|
175
|
-
}],
|
|
176
|
-
};
|
|
177
234
|
});
|
|
178
235
|
// ── Start ──
|
|
179
236
|
async function main() {
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengolfapi/mcp-server",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Open MCP server for AI agents to query the OpenGolfAPI dataset",
|
|
3
|
+
"version": "2.2.0",
|
|
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"
|
|
8
8
|
},
|
|
9
|
-
"files": [
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
10
14
|
"scripts": {
|
|
11
15
|
"build": "tsc",
|
|
12
16
|
"prepublishOnly": "npm run build"
|
|
@@ -15,14 +19,20 @@
|
|
|
15
19
|
"type": "git",
|
|
16
20
|
"url": "git+https://github.com/opengolfapi/mcp-server.git"
|
|
17
21
|
},
|
|
18
|
-
"keywords": [
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"golf",
|
|
25
|
+
"opengolfapi",
|
|
26
|
+
"ai"
|
|
27
|
+
],
|
|
19
28
|
"author": "OpenGolfAPI",
|
|
20
29
|
"license": "MIT",
|
|
21
|
-
"bugs": {
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/opengolfapi/mcp-server/issues"
|
|
32
|
+
},
|
|
22
33
|
"homepage": "https://opengolfapi.org",
|
|
23
34
|
"dependencies": {
|
|
24
35
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
25
|
-
"@supabase/supabase-js": "^2.0.0",
|
|
26
36
|
"zod": "^3.22.0"
|
|
27
37
|
},
|
|
28
38
|
"devDependencies": {
|