@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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # @opengolfapi/mcp-server
2
2
 
3
- Open MCP server for AI agents to query the OpenGolfAPI dataset (16,908 US golf courses). All data is ODbL licensed and open.
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
- import { createClient } from '@supabase/supabase-js';
14
- const SUPABASE_URL = process.env.SUPABASE_URL ?? 'https://ysbzokxixabqrdogdvqc.supabase.co';
15
- const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY ?? '';
16
- if (!SUPABASE_KEY) {
17
- console.error('SUPABASE_SERVICE_ROLE_KEY required');
18
- process.exit(1);
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: '2.0.0',
24
- description: 'Open database of 17,000+ US golf courses. ODbL licensed. opengolfapi.org',
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
- let query = supabase.from('golf_courses').select('*');
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
- query = query.ilike('course_name', `%${q}%`);
87
+ params.set('q', q);
38
88
  if (state)
39
- query = query.eq('state', state.toUpperCase());
89
+ params.set('state', state.toUpperCase());
90
+ let pool = [];
91
+ let geoFilterApplied = false;
40
92
  if (lat !== undefined && lng !== undefined) {
41
- const r = radius_mi ?? 25;
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
- query = query
45
- .gte('latitude', lat - latDelta).lte('latitude', lat + latDelta)
46
- .gte('longitude', lng - lngDelta).lte('longitude', lng + lngDelta);
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
- const { data } = await query.limit(limit ?? 10);
49
- const courses = (data ?? []).map((c) => ({
50
- id: c.id,
51
- name: c.course_name,
52
- city: c.city,
53
- state: c.state,
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
- source: 'OpenGolfAPI (opengolfapi.org) ODbL licensed',
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
- const [courseRes, holesRes] = await Promise.all([
82
- supabase.from('golf_courses').select('*').eq('id', course_id).single(),
83
- supabase.from('golf_course_holes').select('hole_number, par, handicap_index').eq('course_id', course_id).not('par', 'is', null).order('hole_number'),
84
- ]);
85
- if (courseRes.error || !courseRes.data) {
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 c = courseRes.data;
89
- const scorecard = (holesRes.data ?? []).map(h => ({
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
- id: c.id,
99
- name: c.course_name,
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: 'OpenGolfAPI (opengolfapi.org) — ODbL licensed',
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
- const { data, error } = await supabase
125
- .from('golf_course_tees')
126
- .select('*')
127
- .eq('course_id', course_id)
128
- .order('total_yardage', { ascending: false });
129
- if (error) {
130
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
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
- const { data, error } = await supabase
144
- .from('golf_course_climate')
145
- .select('*')
146
- .eq('course_id', course_id)
147
- .maybeSingle();
148
- if (error) {
149
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
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
- const { data, error } = await supabase
163
- .from('golf_course_nearby')
164
- .select('*')
165
- .eq('course_id', course_id)
166
- .order('distance_miles')
167
- .limit(20);
168
- if (error) {
169
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
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.0.0",
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": ["dist", "README.md", "LICENSE"],
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": ["mcp", "golf", "opengolfapi", "ai"],
22
+ "keywords": [
23
+ "mcp",
24
+ "golf",
25
+ "opengolfapi",
26
+ "ai"
27
+ ],
19
28
  "author": "OpenGolfAPI",
20
29
  "license": "MIT",
21
- "bugs": { "url": "https://github.com/opengolfapi/mcp-server/issues" },
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": {