Skip to content

Commit 37a43f3

Browse files
authored
Merge pull request #185 from denis1011101/docs/api-and-mcp
Docs/api and mcp
2 parents fbe59f8 + b1a112c commit 37a43f3

12 files changed

Lines changed: 478 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ GetCourt is a Ruby on Rails app for organizing local racket-sport games:
1515
- **Tournaments** and **matches** (stats/extensible JSON), **player statistics** and ratings
1616
- **Tennis Life** feed, **featured matches** and game **photos/clips**
1717
- **AI** assistant, opponent matching and score recognition on top of RubyLLM (Gemini)
18-
- Public games JSON API (`/api/v1/games`) and an **MCP** endpoint (`/mcp`) over it
18+
- Public games JSON API (`/api/v1/games`) and an **MCP** endpoint (`/mcp`) over it — see `docs/api-and-mcp.md`
1919

2020
Main technologies:
2121
- Ruby 4.0 / Rails 8.1

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Main features
2222
- Share photos and clips from a game and follow the Tennis Life feed
2323
- Telegram bot integration for notifications and registration tokens
2424
- Mobile-friendly UI with map integration and simple search
25+
- Public JSON API over upcoming games and an MCP server on top of it
2526

2627
Main technologies
2728
- Ruby 4.0 / Ruby on Rails 8.1
@@ -33,6 +34,18 @@ Main technologies
3334
- RubyLLM (Gemini) for the AI features
3435
- Google / Nominatim geocoding for addresses, Google Maps for the pickers
3536

37+
## Public API and MCP
38+
39+
Upcoming games are readable from outside the app in two ways:
40+
41+
- `GET /api/v1/games` and `GET /api/v1/games/:id` — public JSON, no key needed
42+
- `POST /mcp` — an MCP server (JSON-RPC 2.0 over Streamable HTTP) with `search_games` and
43+
`get_game` tools, closed behind a `MCP_TOKEN` bearer token
44+
45+
Both are read-only and expose only what a game page already shows in public. See
46+
[docs/api-and-mcp.md](docs/api-and-mcp.md) for parameters, examples and limits, or the same
47+
documentation for users at [getcourt.co/api-and-mcp](https://getcourt.co/api-and-mcp).
48+
3649
## Tests and coverage
3750

3851
```bash

app/controllers/pages_controller.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
class PagesController < ApplicationController
22
# public pages
3-
skip_before_action :authenticate_user!, only: %i[contacts mission partnership partnership_inquiry tennis_formats_and_rules ntrp_level_guide privacy_policy]
3+
skip_before_action :authenticate_user!, only: %i[contacts mission partnership partnership_inquiry tennis_formats_and_rules ntrp_level_guide api_and_mcp privacy_policy]
44

55
def contacts
66
end
@@ -55,4 +55,7 @@ def tennis_formats_and_rules
5555

5656
def ntrp_level_guide
5757
end
58+
59+
def api_and_mcp
60+
end
5861
end

app/views/layouts/application.html.erb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@
120120
<%= link_to t("layout.nav.partnership"), partnership_path, class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:text-slate-300 dark:hover:bg-white/5" %>
121121
<%= link_to t("layout.nav.formats_rules"), tennis_formats_and_rules_path, class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:text-slate-300 dark:hover:bg-white/5" %>
122122
<%= link_to t("layout.nav.ntrp_guide"), ntrp_level_guide_path, class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:text-slate-300 dark:hover:bg-white/5" %>
123+
<%= link_to t("layout.nav.api"), api_and_mcp_path, class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:text-slate-300 dark:hover:bg-white/5" %>
123124
<%= link_to t("layout.nav.mission"), mission_path, class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 dark:text-slate-300 dark:hover:bg-white/5" %>
124125
</div>
125126
</details>
@@ -271,6 +272,7 @@
271272
<%= link_to t("layout.nav.partnership"), partnership_path, class: "block px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 dark:text-slate-300 dark:hover:bg-white/5" %>
272273
<%= link_to t("layout.nav.formats_rules"), tennis_formats_and_rules_path, class: "block px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 dark:text-slate-300 dark:hover:bg-white/5" %>
273274
<%= link_to t("layout.nav.ntrp_guide"), ntrp_level_guide_path, class: "block px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 dark:text-slate-300 dark:hover:bg-white/5" %>
275+
<%= link_to t("layout.nav.api"), api_and_mcp_path, class: "block px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 dark:text-slate-300 dark:hover:bg-white/5" %>
274276
<%= link_to t("layout.nav.mission"), mission_path, class: "block px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 dark:text-slate-300 dark:hover:bg-white/5" %>
275277
<% if user_signed_in? %>
276278
<hr class="my-1 dark:border-white/10">
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
<% content_for :meta_title, t("pages.api.meta_title") %>
2+
<% content_for :meta_description, t("pages.api.meta_description") %>
3+
4+
<% pre_class = "overflow-x-auto rounded-md bg-gray-900 text-slate-100 p-4 text-xs leading-relaxed dark:bg-slate-900/80" %>
5+
<% host = ENV.fetch("APP_HOST", "https://getcourt.co") %>
6+
7+
<div class="max-w-5xl mx-auto px-4 py-6">
8+
<div class="bg-white dark:bg-slate-800/80 rounded-lg border dark:border-white/10 p-6 shadow-sm mb-6">
9+
<h1 class="text-2xl font-semibold mb-2"><%= t("pages.api.title") %></h1>
10+
<p class="text-gray-600 dark:text-slate-400 mb-6"><%= t("pages.api.subtitle") %></p>
11+
12+
<p class="text-gray-700 dark:text-slate-300 mb-8"><%= t("pages.api.intro") %></p>
13+
14+
<%# ── REST ── %>
15+
<div class="mb-8">
16+
<h2 class="text-lg font-medium text-gray-900 dark:text-slate-100 mb-2"><%= t("pages.api.rest_title") %></h2>
17+
<p class="text-gray-700 dark:text-slate-300 mb-3"><%= t("pages.api.rest_text") %></p>
18+
19+
<pre class="<%= pre_class %> mb-4">GET <%= host %>/api/v1/games
20+
GET <%= host %>/api/v1/games/:id</pre>
21+
22+
<h3 class="text-sm font-medium text-gray-900 dark:text-slate-100 mb-2"><%= t("pages.api.params_title") %></h3>
23+
<div class="overflow-x-auto mb-4">
24+
<table class="min-w-full text-sm border-collapse">
25+
<thead>
26+
<tr class="bg-gray-50 dark:bg-slate-700/50">
27+
<th class="border dark:border-white/10 px-3 py-2 text-left font-medium text-gray-700 dark:text-slate-300 w-40"><%= t("pages.api.param") %></th>
28+
<th class="border dark:border-white/10 px-3 py-2 text-left font-medium text-gray-700 dark:text-slate-300"><%= t("pages.api.param_meaning") %></th>
29+
</tr>
30+
</thead>
31+
<tbody class="text-gray-700 dark:text-slate-300">
32+
<% [
33+
[ "city", t("pages.api.param_city") ],
34+
[ "sport", t("pages.api.param_sport") ],
35+
[ "skill_level", t("pages.api.param_skill_level") ],
36+
[ "with_spots", t("pages.api.param_with_spots") ],
37+
[ "urgent", t("pages.api.param_urgent") ],
38+
[ "from", t("pages.api.param_from") ],
39+
[ "to", t("pages.api.param_to") ],
40+
[ "upcoming", t("pages.api.param_upcoming") ],
41+
[ "limit", t("pages.api.param_limit") ]
42+
].each_with_index do |(name, meaning), index| %>
43+
<tr class="<%= "bg-gray-50 dark:bg-slate-700/50" if index.odd? %>">
44+
<td class="border dark:border-white/10 px-3 py-2 font-mono text-xs"><%= name %></td>
45+
<td class="border dark:border-white/10 px-3 py-2"><%= meaning %></td>
46+
</tr>
47+
<% end %>
48+
</tbody>
49+
</table>
50+
</div>
51+
<p class="text-sm text-gray-600 dark:text-slate-400 mb-4"><%= t("pages.api.recurring_note") %></p>
52+
53+
<h3 class="text-sm font-medium text-gray-900 dark:text-slate-100 mb-2"><%= t("pages.api.example_title") %></h3>
54+
<pre class="<%= pre_class %> mb-4">curl "<%= host %>/api/v1/games?city=Belgrade&sport=Tennis&with_spots=true&limit=2"</pre>
55+
56+
<h3 class="text-sm font-medium text-gray-900 dark:text-slate-100 mb-2"><%= t("pages.api.response_title") %></h3>
57+
<pre class="<%= pre_class %>">{
58+
"games": [
59+
{
60+
"id": 1042,
61+
"date": "2026-09-12",
62+
"time": "19:00",
63+
"duration_minutes": 90,
64+
"recurring": false,
65+
"sport": "Tennis",
66+
"skill_level": "Intermediate",
67+
"surface": "Hard",
68+
"environment": "outdoor",
69+
"kind": "game",
70+
"with_coach": false,
71+
"urgent_player_search": true,
72+
"comment": "Doubles, bring a spare ball",
73+
"players": { "taken": 3, "total": 4, "spots_left": 1 },
74+
"court": {
75+
"id": 17,
76+
"name": "Tennis Club Ada",
77+
"city": "Belgrade",
78+
"country_code": "RS",
79+
"latitude": 44.79,
80+
"longitude": 20.41,
81+
"indoor": false,
82+
"outdoor": true,
83+
"free": false,
84+
"url": "<%= host %>/courts/17"
85+
},
86+
"url": "<%= host %>/games/1042"
87+
}
88+
]
89+
}</pre>
90+
<p class="text-sm text-gray-600 dark:text-slate-400 mt-3"><%= t("pages.api.privacy_note") %></p>
91+
</div>
92+
93+
<%# ── MCP ── %>
94+
<div class="mb-8">
95+
<h2 class="text-lg font-medium text-gray-900 dark:text-slate-100 mb-2"><%= t("pages.api.mcp_title") %></h2>
96+
<p class="text-gray-700 dark:text-slate-300 mb-3"><%= t("pages.api.mcp_text") %></p>
97+
98+
<ul class="list-disc pl-5 text-gray-700 dark:text-slate-300 text-sm space-y-1 mb-4">
99+
<li><%= t("pages.api.mcp_endpoint_html") %></li>
100+
<li><%= t("pages.api.mcp_protocol_html") %></li>
101+
<li><%= t("pages.api.mcp_auth_html") %></li>
102+
</ul>
103+
104+
<h3 class="text-sm font-medium text-gray-900 dark:text-slate-100 mb-2"><%= t("pages.api.tools_title") %></h3>
105+
<div class="overflow-x-auto mb-4">
106+
<table class="min-w-full text-sm border-collapse">
107+
<thead>
108+
<tr class="bg-gray-50 dark:bg-slate-700/50">
109+
<th class="border dark:border-white/10 px-3 py-2 text-left font-medium text-gray-700 dark:text-slate-300 w-40"><%= t("pages.api.tool") %></th>
110+
<th class="border dark:border-white/10 px-3 py-2 text-left font-medium text-gray-700 dark:text-slate-300"><%= t("pages.api.param_meaning") %></th>
111+
</tr>
112+
</thead>
113+
<tbody class="text-gray-700 dark:text-slate-300">
114+
<tr>
115+
<td class="border dark:border-white/10 px-3 py-2 font-mono text-xs">search_games</td>
116+
<td class="border dark:border-white/10 px-3 py-2"><%= t("pages.api.tool_search") %></td>
117+
</tr>
118+
<tr class="bg-gray-50 dark:bg-slate-700/50">
119+
<td class="border dark:border-white/10 px-3 py-2 font-mono text-xs">get_game</td>
120+
<td class="border dark:border-white/10 px-3 py-2"><%= t("pages.api.tool_get") %></td>
121+
</tr>
122+
</tbody>
123+
</table>
124+
</div>
125+
126+
<h3 class="text-sm font-medium text-gray-900 dark:text-slate-100 mb-2"><%= t("pages.api.client_title") %></h3>
127+
<p class="text-gray-700 dark:text-slate-300 mb-3"><%= t("pages.api.client_text") %></p>
128+
<pre class="<%= pre_class %> mb-4">{
129+
"mcpServers": {
130+
"getcourt": {
131+
"type": "http",
132+
"url": "<%= host %>/mcp",
133+
"headers": { "Authorization": "Bearer YOUR_TOKEN" }
134+
}
135+
}
136+
}</pre>
137+
138+
<h3 class="text-sm font-medium text-gray-900 dark:text-slate-100 mb-2"><%= t("pages.api.raw_title") %></h3>
139+
<pre class="<%= pre_class %>">curl -X POST <%= host %>/mcp \
140+
-H "Content-Type: application/json" \
141+
-H "Authorization: Bearer YOUR_TOKEN" \
142+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
143+
"params":{"name":"search_games","arguments":{"city":"Belgrade","with_spots":true}}}'</pre>
144+
</div>
145+
146+
<%# ── Limits ── %>
147+
<div class="mb-8">
148+
<h2 class="text-lg font-medium text-gray-900 dark:text-slate-100 mb-2"><%= t("pages.api.limits_title") %></h2>
149+
<ul class="list-disc pl-5 text-gray-700 dark:text-slate-300 text-sm space-y-1">
150+
<li><%= t("pages.api.limits_rest") %></li>
151+
<li><%= t("pages.api.limits_mcp") %></li>
152+
<li><%= t("pages.api.limits_read_only") %></li>
153+
</ul>
154+
</div>
155+
156+
<div class="flex flex-wrap gap-3">
157+
<%= link_to t("pages.api.get_token"), contacts_path, class: "inline-flex items-center rounded-md bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-500" %>
158+
<%= link_to t("pages.api.source_code"), "https://github.com/denis1011101/getcourt/blob/main/docs/api-and-mcp.md", target: "_blank", rel: "noopener",
159+
class: "inline-flex items-center rounded-md border dark:border-white/10 px-4 py-2 text-gray-700 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-white/5" %>
160+
</div>
161+
</div>
162+
163+
<%= render "shared/pages_nav" %>
164+
</div>

app/views/shared/_pages_nav.html.erb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
[ t("layout.nav.ntrp_guide"), ntrp_level_guide_path ],
77
[ t("layout.nav.formats_rules"), tennis_formats_and_rules_path ],
88
[ t("layout.nav.partnership"), partnership_path ],
9+
[ t("layout.nav.api"), api_and_mcp_path ],
910
[ t("layout.nav.privacy_policy"), privacy_policy_path ],
1011
[ t("layout.nav.mission"), mission_path ],
1112
].each do |label, path| %>

config/locales/en.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ en:
126126
partnership: "Partnership"
127127
formats_rules: "Formats & Rules"
128128
ntrp_guide: "NTRP Guide"
129+
api: "API & MCP"
129130
mission: "Mission"
130131
privacy_policy: "Privacy Policy"
131132
account: "Account"
@@ -772,6 +773,48 @@ en:
772773
chart_title: "NTRP rating chart"
773774
cta_text: "Know your level? Find players and games that match your skill on GetCourt."
774775
cta: "Browse games"
776+
api:
777+
meta_title: "Public API & MCP server — GetCourt"
778+
meta_description: "Read GetCourt games from your own code or from an AI assistant: a public JSON API over upcoming games and an MCP endpoint with search_games and get_game tools."
779+
title: "API & MCP"
780+
subtitle: "Two read-only ways into GetCourt data: a plain JSON API and an MCP server for AI assistants."
781+
intro: "Both return exactly what an upcoming game already shows on the site — sport, level, time, court and how many spots are left. Games on courts that are still in moderation are never returned."
782+
rest_title: "JSON API"
783+
rest_text: "No key and no session needed — the games list is public."
784+
params_title: "Query parameters"
785+
param: "Parameter"
786+
param_meaning: "Meaning"
787+
param_city: "City name as it appears on the court, e.g. Belgrade. Can be repeated."
788+
param_sport: "Sport name, e.g. Tennis, Padel, Squash."
789+
param_skill_level: "Skill level, e.g. Beginner."
790+
param_with_spots: "true — only games that still have a free spot."
791+
param_urgent: "true — only games with an urgent player search."
792+
param_from: "Earliest date, ISO 8601 (YYYY-MM-DD)."
793+
param_to: "Latest date, ISO 8601 (YYYY-MM-DD)."
794+
param_upcoming: "false — include games that have already been played. Upcoming only by default."
795+
param_limit: "How many games to return: 1–100, 25 by default."
796+
recurring_note: "Recurring games always pass the date filters: their next occurrence is computed on the fly rather than stored, so an answer can contain a game outside the requested range."
797+
example_title: "Request"
798+
response_title: "Response"
799+
privacy_note: "Participants never leave the app: the answer says how many spots are taken, never who took them."
800+
mcp_title: "MCP server"
801+
mcp_text: "The same data as a Model Context Protocol server, so an assistant can look for games itself instead of reading pages."
802+
mcp_endpoint_html: "Endpoint: <code>POST /mcp</code>, Streamable HTTP over JSON-RPC 2.0, batches included."
803+
mcp_protocol_html: "Protocol versions: <code>2025-06-18</code>, <code>2025-03-26</code>, <code>2024-11-05</code>."
804+
mcp_auth_html: "Authorisation: <code>Authorization: Bearer &lt;token&gt;</code>. Ask us for a token — a missing or wrong one answers 401. While no token is configured on the server, the endpoint is off entirely and answers 404."
805+
tools_title: "Tools"
806+
tool: "Tool"
807+
tool_search: "Search upcoming games by city, sport, level, date range, free spots and urgent search."
808+
tool_get: "Fetch one game by its numeric id."
809+
client_title: "Client configuration"
810+
client_text: "Most MCP clients take a JSON config like this:"
811+
raw_title: "Or call it directly"
812+
limits_title: "Limits"
813+
limits_rest: "JSON API: 60 requests per minute per IP."
814+
limits_mcp: "MCP: 120 requests per minute per IP — one question usually costs several calls."
815+
limits_read_only: "Both are read-only: nothing here creates a game or joins one."
816+
get_token: "Ask for a token"
817+
source_code: "Docs on GitHub"
775818
users:
776819
account_deleted: "Your account and all personal data have been permanently deleted."
777820
delete_account_title: "Delete Account"

0 commit comments

Comments
 (0)