Skip to content

Commit d0358bd

Browse files
gsmlgGSMLG-BOT
andauthored
feat(mcp): implement public API for package information (#16)
Co-authored-by: Jonathan Gao <gsmlg.com@gmail.com>
1 parent 6772500 commit d0358bd

File tree

19 files changed

+2122
-36
lines changed

19 files changed

+2122
-36
lines changed

.dialyzer_ignore.exs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,8 @@
1515
# These are available at runtime but not during dialyzer analysis
1616
~r/lib\/mix\/tasks\/test\.e2e\.ex.*unknown_function/,
1717
~r/test\/support\/conn_case\.ex.*unknown_function/,
18-
~r/test\/support\/admin_conn_case\.ex.*unknown_function/
18+
~r/test\/support\/admin_conn_case\.ex.*unknown_function/,
19+
# Ignore pattern match coverage warning in packages.ex normalize_meta/1
20+
# This is a defensive clause for handling non-map meta values at runtime
21+
~r/lib\/hex_hub\/mcp\/tools\/packages\.ex.*pattern_match_cov/
1922
]

.github/workflows/ci.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,19 @@ jobs:
121121

122122
- name: Restore Dialyzer PLT cache
123123
uses: actions/cache@v4
124+
id: dialyzer-cache
124125
with:
125126
path: priv/plts
126-
key: ${{ runner.os }}-dialyzer-${{ hashFiles('**/mix.lock') }}
127-
restore-keys: ${{ runner.os }}-dialyzer-
127+
key: ${{ runner.os }}-otp28-elixir1.18-dialyzer-${{ hashFiles('**/mix.lock') }}
128+
restore-keys: |
129+
${{ runner.os }}-otp28-elixir1.18-dialyzer-
130+
131+
- name: Create PLT directory
132+
run: mkdir -p priv/plts
133+
134+
- name: Build PLT if needed
135+
if: steps.dialyzer-cache.outputs.cache-hit != 'true'
136+
run: mix dialyzer --plt
128137

129138
- name: Run Dialyzer
130139
run: mix dialyzer --halt-exit-status

CLAUDE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,25 @@ The MCP (Model Context Protocol) server provides AI clients with comprehensive p
402402
- `lib/hex_hub/mcp/transport.ex` - HTTP/WebSocket transport layer
403403
- `lib/hex_hub/mcp/tools/` - MCP tool implementations
404404

405+
**Public MCP API**:
406+
The MCP API can be configured for public (unauthenticated) access to allow AI clients to query package information:
407+
408+
```bash
409+
# Enable public access (no authentication required for read-only operations)
410+
export MCP_REQUIRE_AUTH=false
411+
export MCP_RATE_LIMIT=100 # requests per hour per IP
412+
```
413+
414+
Public endpoints:
415+
- `GET /mcp/health` - Health check
416+
- `GET /mcp/tools` - List available tools
417+
- `GET /mcp/server-info` - Server capabilities
418+
- `POST /mcp` - JSON-RPC requests for package queries
419+
420+
Rate limiting: IP-based rate limiting protects against abuse. Returns HTTP 429 with `retry-after` header when exceeded.
421+
422+
See `specs/008-mcp-public-api/quickstart.md` for detailed usage examples.
423+
405424
### Development Patterns
406425

407426
**Always Use Storage Abstraction**: Never access storage directly, use `HexHub.Storage`
@@ -464,6 +483,8 @@ Logger.info("Package #{name} published")
464483
- Mnesia (`:system_settings` or `:publish_configs` table for setting, existing `:users` table for anonymous user) (006-anonymous-publish-config)
465484
- Elixir 1.15+ / OTP 26+ + Phoenix 1.8+, Mnesia (built-in), :erl_tar (Erlang stdlib) (007-admin-backup)
466485
- Mnesia for metadata, HexHub.Storage for package tarballs, local filesystem for backup archives (007-admin-backup)
486+
- Elixir 1.15+ / OTP 26+ + Phoenix 1.8+, Mnesia, `:telemetry` (008-mcp-public-api)
487+
- Mnesia (existing `:packages`, `:package_releases` tables) (008-mcp-public-api)
467488

468489
## Recent Changes
469490
- 001-telemetry-logging: Added Elixir 1.15+ / OTP 26+ + `:telemetry` (already in project), `Logger` (Elixir stdlib)

config/config.exs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,27 @@ config :hex_hub, HexHubAdminWeb.Endpoint,
3333
config :hex_hub, HexHub.Mailer, adapter: Swoosh.Adapters.Local
3434

3535
# MCP (Model Context Protocol) configuration
36+
#
37+
# MCP provides a JSON-RPC 2.0 interface for AI clients to interact with HexHub.
38+
#
39+
# Environment variables:
40+
# MCP_ENABLED - Enable/disable MCP server (default: "false")
41+
# MCP_REQUIRE_AUTH - Require API key authentication (default: "true")
42+
# Set to "false" for public read-only access to package info
43+
# MCP_RATE_LIMIT - Requests per hour per IP (default: "1000")
44+
# Rate limiting protects against abuse when auth is disabled
45+
# MCP_DEBUG - Enable debug logging (default: "false")
46+
#
47+
# Public deployment example:
48+
# MCP_ENABLED=true MCP_REQUIRE_AUTH=false MCP_RATE_LIMIT=100 ./bin/hex_hub start
49+
#
50+
# Endpoints (when enabled):
51+
# GET /mcp/health - Health check
52+
# GET /mcp/tools - List available tools
53+
# GET /mcp/server-info - Server capabilities
54+
# POST /mcp - JSON-RPC requests
55+
# WS /mcp/ws - WebSocket transport
56+
#
3657
config :hex_hub, :mcp,
3758
enabled: System.get_env("MCP_ENABLED", "false") == "true",
3859
websocket_path: System.get_env("MCP_WEBSOCKET_PATH", "/mcp/ws"),

config/runtime.exs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ if config_env() == :prod do
6060
# S3 configuration (when STORAGE_TYPE=s3)
6161
if storage_type == :s3 do
6262
s3_config = [
63-
scheme:
64-
if(System.get_env("AWS_S3_SCHEME") == "http", do: "http://", else: "https://"),
63+
scheme: if(System.get_env("AWS_S3_SCHEME") == "http", do: "http://", else: "https://"),
6564
host: System.get_env("AWS_S3_HOST"),
6665
port: String.to_integer(System.get_env("AWS_S3_PORT", "443"))
6766
]

lib/hex_hub/mcp/tools/packages.ex

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ defmodule HexHub.MCP.Tools.Packages do
6060
releases: formatted_releases,
6161
total_releases: length(releases),
6262
latest_version: get_latest_version(releases),
63-
repository: get_repository_info(package.repository)
63+
repository: get_repository_info(package.repository_name)
6464
}
6565

6666
{:ok, result}
@@ -124,15 +124,16 @@ defmodule HexHub.MCP.Tools.Packages do
124124

125125
case Packages.get_release(name, version) do
126126
{:ok, release} ->
127-
metadata = Jason.decode!(release.metadata || "{}")
127+
# Release has `meta` field which can be a map or JSON string
128+
metadata = normalize_meta(release.meta)
128129

129130
result = %{
130131
name: name,
131132
version: release.version,
132133
metadata: metadata,
133134
requirements: parse_requirements(release.requirements),
134135
has_docs: release.has_docs,
135-
retirement_info: get_retirement_info(release)
136+
retirement_info: format_retirement_info(release.retired)
136137
}
137138

138139
{:ok, result}
@@ -170,7 +171,7 @@ defmodule HexHub.MCP.Tools.Packages do
170171
defp format_package(package) do
171172
%{
172173
name: package.name,
173-
repository: package.repository,
174+
repository: package.repository_name,
174175
description: get_meta_field(package.meta, "description"),
175176
licenses: get_meta_field(package.meta, "licenses", []),
176177
links: get_meta_field(package.meta, "links", %{}),
@@ -187,19 +188,42 @@ defmodule HexHub.MCP.Tools.Packages do
187188
version: release.version,
188189
has_docs: release.has_docs,
189190
inserted_at: release.inserted_at,
190-
retirement_info: get_retirement_info(release),
191+
retirement_info: format_retirement_info(release.retired),
191192
url: build_release_url(release),
192193
html_url: build_release_html_url(release)
193194
}
194195
end
195196

196197
defp get_meta_field(meta, field, default \\ nil) do
197-
case Jason.decode(meta || "{}") do
198-
{:ok, decoded} -> Map.get(decoded, field, default)
199-
{:error, _} -> default
198+
cond do
199+
# Meta is already a map (most common case in HexHub)
200+
is_map(meta) ->
201+
Map.get(meta, field) || Map.get(meta, String.to_atom(field)) || default
202+
203+
# Meta is a JSON string
204+
is_binary(meta) ->
205+
case Jason.decode(meta || "{}") do
206+
{:ok, decoded} -> Map.get(decoded, field, default)
207+
{:error, _} -> default
208+
end
209+
210+
# Meta is nil or other
211+
true ->
212+
default
200213
end
201214
end
202215

216+
defp normalize_meta(meta) when is_map(meta), do: meta
217+
218+
defp normalize_meta(meta) when is_binary(meta) do
219+
case Jason.decode(meta) do
220+
{:ok, decoded} -> decoded
221+
{:error, _} -> %{}
222+
end
223+
end
224+
225+
defp normalize_meta(_), do: %{}
226+
203227
defp get_latest_version([]), do: nil
204228

205229
defp get_latest_version(releases) do
@@ -229,17 +253,19 @@ defmodule HexHub.MCP.Tools.Packages do
229253

230254
defp parse_requirements(requirements) when is_map(requirements), do: requirements
231255

232-
defp get_retirement_info(release) do
233-
case release.retirement do
234-
nil ->
235-
nil
256+
# Format retirement info from release.retired (boolean or map)
257+
defp format_retirement_info(false), do: nil
258+
defp format_retirement_info(nil), do: nil
236259

237-
retirement ->
238-
%{
239-
reason: retirement.reason,
240-
message: retirement.message
241-
}
242-
end
260+
defp format_retirement_info(true) do
261+
%{reason: "unknown", message: "This release has been retired"}
262+
end
263+
264+
defp format_retirement_info(retirement) when is_map(retirement) do
265+
%{
266+
reason: Map.get(retirement, :reason) || Map.get(retirement, "reason"),
267+
message: Map.get(retirement, :message) || Map.get(retirement, "message")
268+
}
243269
end
244270

245271
# URL builders

lib/hex_hub/mcp/transport.ex

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,123 @@ defmodule HexHub.MCP.Transport do
9191
end
9292

9393
@doc """
94-
Apply rate limiting to requests.
94+
Apply rate limiting to MCP requests based on client IP.
95+
96+
Uses Mnesia rate limit tables with key prefix "mcp:ip:" for MCP-specific tracking.
97+
Returns :ok if within limits, {:error, :rate_limited, remaining_seconds} if exceeded.
9598
"""
96-
def check_rate_limit(_conn, _opts) do
97-
# Implement rate limiting based on client IP or API key
98-
# For now, just return :ok
99-
:ok
99+
def check_rate_limit(conn, _opts) do
100+
ip = get_client_ip(conn)
101+
key = "mcp:ip:#{ip}"
102+
# MCP rate limit: requests per minute (from config, default 100)
103+
{limit, window} = get_mcp_rate_limit()
104+
105+
case do_check_rate_limit(key, limit, window) do
106+
:ok ->
107+
increment_rate_limit_counter(key)
108+
:ok
109+
110+
{:error, remaining} ->
111+
# Emit telemetry event for rate limiting
112+
:telemetry.execute(
113+
[:hex_hub, :mcp, :rate_limited],
114+
%{count: 1},
115+
%{ip: ip, remaining_seconds: remaining}
116+
)
117+
118+
Telemetry.log(:warning, :mcp, "MCP rate limit exceeded", %{
119+
ip: ip,
120+
key: key,
121+
retry_after: remaining
122+
})
123+
124+
{:error, :rate_limited, remaining}
125+
end
126+
end
127+
128+
defp get_client_ip(conn) do
129+
# Try to get real IP from headers first (for proxied requests)
130+
forwarded_for = Plug.Conn.get_req_header(conn, "x-forwarded-for")
131+
132+
case forwarded_for do
133+
[ips | _] ->
134+
ips
135+
|> String.split(",")
136+
|> List.first()
137+
|> String.trim()
138+
139+
[] ->
140+
conn.remote_ip
141+
|> Tuple.to_list()
142+
|> Enum.join(".")
143+
end
144+
end
145+
146+
defp get_mcp_rate_limit do
147+
# Get rate limit from config, default to 100 requests per minute
148+
rate_per_hour = HexHub.MCP.rate_limit()
149+
# Convert to per-minute for more granular control
150+
rate_per_minute = max(div(rate_per_hour, 60), 1)
151+
# limit, window_in_seconds
152+
{rate_per_minute, 60}
153+
end
154+
155+
defp do_check_rate_limit(key, limit, window) do
156+
now = System.system_time(:second)
157+
window_start = now - window
158+
159+
case :mnesia.transaction(fn ->
160+
case :mnesia.read({:rate_limit, key}) do
161+
[{:rate_limit, ^key, _type, _id, count, start, _updated}]
162+
when start > window_start ->
163+
if count >= limit do
164+
# Calculate remaining time until window resets
165+
remaining = start + window - now
166+
{:error, remaining}
167+
else
168+
:ok
169+
end
170+
171+
_ ->
172+
# No recent activity or window expired
173+
:ok
174+
end
175+
end) do
176+
{:atomic, result} -> result
177+
# Allow request on Mnesia error (fail-open)
178+
{:aborted, _reason} -> :ok
179+
end
180+
end
181+
182+
defp increment_rate_limit_counter(key) do
183+
now = System.system_time(:second)
184+
185+
:mnesia.transaction(fn ->
186+
case :mnesia.read({:rate_limit, key}) do
187+
[{:rate_limit, ^key, type, id, count, start, _updated}] ->
188+
:mnesia.write({
189+
:rate_limit,
190+
key,
191+
type,
192+
id,
193+
count + 1,
194+
start,
195+
DateTime.utc_now()
196+
})
197+
198+
[] ->
199+
# Create new counter with MCP type
200+
:mnesia.write({
201+
:rate_limit,
202+
key,
203+
:mcp,
204+
key,
205+
1,
206+
now,
207+
DateTime.utc_now()
208+
})
209+
end
210+
end)
100211
end
101212

102213
# Private functions

lib/hex_hub/users.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ defmodule HexHub.Users do
106106
case :mnesia.transaction(fn ->
107107
case :mnesia.read(@table, username_or_email) do
108108
[
109-
{@table, username, email, password_hash, totp_secret, totp_enabled,
110-
recovery_codes, service_account, deactivated_at, inserted_at, updated_at}
109+
{@table, username, email, password_hash, totp_secret, totp_enabled, recovery_codes,
110+
service_account, deactivated_at, inserted_at, updated_at}
111111
] ->
112112
{:ok,
113113
{username, email, password_hash, totp_secret, totp_enabled, recovery_codes,

lib/hex_hub_admin_web/components/layouts/app.html.heex

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818

1919
<div class="drawer lg:drawer-open">
2020
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
21-
2221
<!-- Sidebar -->
2322
<div class="drawer-side lg:drawer-open">
2423
<label for="my-drawer-2" class="drawer-overlay"></label>
@@ -252,12 +251,11 @@
252251
</li>
253252
</ul>
254253
</div>
255-
256254
<!-- Main content -->
257255
<div class="drawer-content flex flex-col">
258256
<main class="p-4">
259257
<div class="mx-auto space-y-4">
260-
<%= if assigns[:inner_content], do: @inner_content, else: render_slot(@inner_block) %>
258+
{if assigns[:inner_content], do: @inner_content, else: render_slot(@inner_block)}
261259
</div>
262260
</main>
263261
</div>

0 commit comments

Comments
 (0)