@winstonsayno/mcp-gateway 0.3.0 → 1.0.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +280 -19
  3. package/dashboard/index.html +79 -22
  4. package/dist/auth/middleware.d.ts +21 -2
  5. package/dist/auth/middleware.d.ts.map +1 -1
  6. package/dist/auth/middleware.js +63 -13
  7. package/dist/auth/middleware.js.map +1 -1
  8. package/dist/auth/ratelimit.d.ts +16 -1
  9. package/dist/auth/ratelimit.d.ts.map +1 -1
  10. package/dist/auth/ratelimit.js +15 -8
  11. package/dist/auth/ratelimit.js.map +1 -1
  12. package/dist/auth/scopes.d.ts +40 -0
  13. package/dist/auth/scopes.d.ts.map +1 -0
  14. package/dist/auth/scopes.js +76 -0
  15. package/dist/auth/scopes.js.map +1 -0
  16. package/dist/config/loader.d.ts.map +1 -1
  17. package/dist/config/loader.js +99 -10
  18. package/dist/config/loader.js.map +1 -1
  19. package/dist/gateway/api.d.ts +30 -0
  20. package/dist/gateway/api.d.ts.map +1 -1
  21. package/dist/gateway/api.js +308 -11
  22. package/dist/gateway/api.js.map +1 -1
  23. package/dist/gateway/index.d.ts +4 -0
  24. package/dist/gateway/index.d.ts.map +1 -1
  25. package/dist/gateway/index.js +55 -2
  26. package/dist/gateway/index.js.map +1 -1
  27. package/dist/gateway/supervisor.d.ts +1 -0
  28. package/dist/gateway/supervisor.d.ts.map +1 -1
  29. package/dist/gateway/supervisor.js +12 -1
  30. package/dist/gateway/supervisor.js.map +1 -1
  31. package/dist/index.d.ts +14 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +8 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/mcp/catalog.d.ts +30 -0
  36. package/dist/mcp/catalog.d.ts.map +1 -0
  37. package/dist/mcp/catalog.js +78 -0
  38. package/dist/mcp/catalog.js.map +1 -0
  39. package/dist/mcp/endpoint.d.ts +132 -0
  40. package/dist/mcp/endpoint.d.ts.map +1 -0
  41. package/dist/mcp/endpoint.js +714 -0
  42. package/dist/mcp/endpoint.js.map +1 -0
  43. package/dist/mcp/llm-schemas.d.ts +36 -0
  44. package/dist/mcp/llm-schemas.d.ts.map +1 -0
  45. package/dist/mcp/llm-schemas.js +69 -0
  46. package/dist/mcp/llm-schemas.js.map +1 -0
  47. package/dist/mcp/naming.d.ts +53 -0
  48. package/dist/mcp/naming.d.ts.map +1 -0
  49. package/dist/mcp/naming.js +71 -0
  50. package/dist/mcp/naming.js.map +1 -0
  51. package/dist/middleware/cors.d.ts +2 -0
  52. package/dist/middleware/cors.d.ts.map +1 -1
  53. package/dist/middleware/cors.js +25 -19
  54. package/dist/middleware/cors.js.map +1 -1
  55. package/dist/monitor/audit.d.ts +60 -0
  56. package/dist/monitor/audit.d.ts.map +1 -0
  57. package/dist/monitor/audit.js +155 -0
  58. package/dist/monitor/audit.js.map +1 -0
  59. package/dist/monitor/index.d.ts +19 -0
  60. package/dist/monitor/index.d.ts.map +1 -1
  61. package/dist/monitor/index.js +88 -0
  62. package/dist/monitor/index.js.map +1 -1
  63. package/dist/proxy/index.d.ts +25 -2
  64. package/dist/proxy/index.d.ts.map +1 -1
  65. package/dist/proxy/index.js +171 -16
  66. package/dist/proxy/index.js.map +1 -1
  67. package/dist/registry/index.d.ts +16 -2
  68. package/dist/registry/index.d.ts.map +1 -1
  69. package/dist/registry/index.js +37 -1
  70. package/dist/registry/index.js.map +1 -1
  71. package/dist/utils/tool-filter.d.ts +24 -0
  72. package/dist/utils/tool-filter.d.ts.map +1 -0
  73. package/dist/utils/tool-filter.js +48 -0
  74. package/dist/utils/tool-filter.js.map +1 -0
  75. package/dist/utils/types.d.ts +116 -2
  76. package/dist/utils/types.d.ts.map +1 -1
  77. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.0.0] - 2026-10-07
13
+
14
+ First stable release. From here on mcp-gateway follows semver: `/api/v1`, `/mcp`, configuration keys, CLI and
15
+ root library exports only change in backward-compatible ways within 1.x (see
16
+ [docs/api-reference.md#stability-and-versioning](docs/api-reference.md#stability-and-versioning)).
17
+ This release contains everything developed as v0.5 – v0.8.
18
+
19
+ ### Added
20
+ - **Documentation** in `docs/`: API reference (REST, `/mcp`, error codes, stability policy), configuration reference and deployment guide (Docker, Kubernetes manifests, reverse proxy, security checklist, systemd).
21
+ - **Container image** workflow `.github/workflows/docker.yml`: on release publish, builds `linux/amd64` + `linux/arm64` and pushes `ghcr.io/<owner>/mcp-gateway` tagged `<version>`, `<major>.<minor>`, `<major>` and `latest` (with provenance + SBOM).
22
+ - Dependabot config (npm root + JS client, Gradle Kotlin client, GitHub Actions, Docker), pull-request template, issue-template links, `SECURITY.md`.
23
+ - **Resources & prompts passthrough**: resources, resource templates and prompts of servers announcing those capabilities are listed at connect time and refreshed on `notifications/resources|prompts/list_changed`. REST: `GET /api/v1/resources`, `GET /api/v1/resources/templates`, `POST /api/v1/resources/read`, `GET /api/v1/prompts`, `POST /api/v1/prompts/get` (auto-routing, `409` on ambiguous prompt names, `403` out of scope, `502` / `503` / `504` like tool calls). `/mcp`: `resources/list`, `resources/templates/list`, `resources/read`, `prompts/list`, `prompts/get` (paginated; prompt names follow `toolNaming`; duplicate resource URIs collapsed, lowest server id wins; reads routed by URI, then template, then the only resource server), `resources` / `prompts` capabilities with `list_changed` notifications. Scopes apply by server. Rate limited and recorded with `kind: "resource" | "prompt"`.
24
+ - **Persistent audit log** (`audit: { enabled, path, retentionDays }`, default off): every request record is also written to SQLite through the built-in `node:sqlite` (Node 22.5+, no new dependency; clear startup error on older Node). Metadata only, never arguments or results. Hourly retention pruning.
25
+ - `GET /api/v1/requests` filters (`server`, `tool`, `client`, `success`, `via`, `kind`, `since`, `until`) and cursor paging (`nextCursor`), from the audit log when enabled, else the in-memory log; responses carry `source`. The dashboard's *Request History* panel has filters and *Load older*.
26
+ - JS client: `history()` (filters + cursor), `listResources`, `listResourceTemplates`, `readResource`, `listPrompts`, `getPrompt`.
27
+ - `SessionInfo.capabilities`; `McpProxy#getCatalog`, `#hasCapability`; registry `setCatalog` / `getAllResources` / `getAllResourceTemplates` / `getAllPrompts`; `MetricsCollector#setAuditStore` / `#queryRequests`; exports `SqliteAuditStore`, `sqliteAvailable`, `AuditStore`, catalog helpers and resource / prompt types.
28
+ - **LLM tool schemas**: `GET /api/v1/tools?format=openai|openai-responses|anthropic` returns function-calling definitions (`tools`) plus a `mapping` from LLM tool name to `{ server, tool }`. Names follow `mcp.toolNaming`, are sanitised to `^[a-zA-Z0-9_-]{1,64}$` and de-duplicated; scopes and `?server=` / `?tag=` apply. Exports `toLlmToolSchemas`, `sanitizeToolName`, `LLM_SCHEMA_FORMATS`.
29
+ - **TypeScript client** `@winstonsayno/mcp-gateway-client` in `clients/js` (not published): zero dependencies, `fetch`-based (browser + Node 18+), typed `health`, `ready`, `metrics`, `servers`, `server`, `reconnect`, `listTools`, `toolSchemas`, `callTool`, `callLlmTool`, `requests`, `GatewayError`; `connectMcp()` / `McpSession` helper for `/mcp` (pagination, SSE replies, cancellation). Own tests + an integration test against a real gateway.
30
+ - **Kotlin client** in `clients/kotlin` (not published): OkHttp 4.12 + kotlinx.serialization 1.6, Java 11 bytecode (Android-friendly), same API surface, `McpSession`; Gradle 8.7 wrapper and MockWebServer tests.
31
+ - CI jobs for both clients.
32
+ - **Per-key scopes**: `auth.apiKeys` entries may be objects `{ key, name?, servers?, tools?, rateLimit? }` (plain strings still mean full access). `servers` / `tools` are glob allow-lists (`tools` patterns containing `/` match `<serverId>/<tool>`); `rateLimit` gives the key its own bucket; `name` makes the client id `key:<name>`; `${VAR}` is expanded in object keys. JWTs carry scopes in the `mcp_servers` / `mcp_tools` claims. Enforced on REST (`/tools`, `/servers`, `/servers/:id` hide; `/tools/call`, `/servers/:id/reconnect` → `403`; auto-routing only among allowed servers; restricted keys see only their own `/requests`) and on `/mcp` (`tools/list` hides, `tools/call` → `-32003`, key rate limits). Hot reloadable: open `/mcp` sessions are notified, sessions of removed keys closed. Exports: `isServerInScope`, `isToolInScope`, `filterToolsByScope`, `scopeFromJwt`, types `AccessScope`, `ApiKeyConfig`.
33
+ - **Downstream MCP endpoint** `POST/GET/DELETE /mcp`: the gateway is now an MCP server over Streamable HTTP (protocol `2025-06-18`, `2025-03-26` accepted). Sessions via `Mcp-Session-Id` (bound to the authenticated key / JWT subject, idle expiry, LRU eviction at `maxSessions`), `initialize`, `ping`, aggregated and paginated `tools/list`, `tools/call` routed upstream, `notifications/tools/list_changed` on the `GET` SSE stream whenever the aggregated list changes, and `notifications/cancelled` propagated to the upstream server. JSON-RPC batches are accepted. Reuses auth, the rate limiter (per `tools/call`), `maxConcurrency`, timeouts, metrics and the request log. Origin validation (`mcp.allowedOrigins`, default `corsOrigins`) against DNS rebinding.
34
+ - `mcp` config block: `enabled`, `path`, `toolNaming` (`auto` — prefix `<serverId>__` only on name collisions — or `prefix`), `pageSize`, `sessionIdleTimeoutSeconds`, `maxSessions`, `allowedOrigins`, `instructions`. Everything except `enabled` / `path` hot reloads.
35
+ - `McpProxy.request()` for arbitrary upstream methods and an optional `AbortSignal` on `callTool()` (`ERR_CANCELLED`).
36
+ - Tool `title`, `outputSchema` and `annotations` are kept from upstream `tools/list` and exposed on `/api/v1/tools` and `/mcp`.
37
+ - Request records carry `via: "rest" | "mcp"`.
38
+ - Library exports: `McpEndpoint`, `buildToolIndex`, `prefixedName`, `DOWNSTREAM_PROTOCOL_VERSIONS`, `ERR_RATE_LIMITED`, types `McpEndpointConfig`, `ToolNaming`, `McpSessionSummary`; `Gateway#getMcpEndpoint()`.
39
+ - Conformance tests with the official `@modelcontextprotocol/sdk` client (list, call, ping, `list_changed`, cancellation, session termination, auth).
40
+ - CORS allows the `Mcp-Session-Id`, `MCP-Protocol-Version` and `Last-Event-ID` request headers and exposes `Mcp-Session-Id`.
41
+
42
+ ### Changed
43
+ - Docker image is based on `node:22-alpine` (was 20) so the optional audit log works; it has a writable `/app/data` volume and OCI labels. The npm package still supports Node 20+.
44
+ - README: npm badge points at `@winstonsayno/mcp-gateway`, CI badge, ghcr image name lowercased (`ghcr.io/harrisoncn/mcp-gateway`), library import uses the scoped package name, API-stability section.
45
+ - `AuthConfig.apiKeys` is typed `Array<string | ApiKeyConfig>` (was `string[]`); existing configs are unchanged. `createAuthMiddleware()` returns an `AuthMiddleware` (a `RequestHandler` with an optional `resolveClient`).
46
+
47
+ ## [0.4.0] - 2026-10-07
48
+
49
+ ### Added
50
+ - **Readiness probe** `GET /api/v1/health/ready`: always public, `200` when every enabled server is connected and not `degraded` (or at least `?min=N`), otherwise `503`; `503 shutting_down` during graceful shutdown. Body carries only counts. `computeReadiness()` is exported for library use. README documents liveness vs. readiness with a Kubernetes example.
51
+ - **Per-server tool filtering**: `servers[].tools.allow` / `servers[].tools.deny` glob patterns (`*`, `?`; deny wins). Hidden tools are removed from discovery, counts and routing; calling one with an explicit `server` returns `403`. Applied to `tools/list_changed` updates and on hot reload. `isToolAllowed` / `filterTools` are exported for library use.
52
+
12
53
  ## [0.3.0] - 2026-10-07
13
54
 
14
55
  ### Added
package/README.md CHANGED
@@ -11,10 +11,11 @@ Route · Authenticate · Rate-limit · Monitor — all your [Model Context Proto
11
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
12
12
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg)](https://nodejs.org)
13
13
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue.svg)](https://www.typescriptlang.org)
14
- [![npm version](https://img.shields.io/badge/npm-v0.2.0-blue.svg)](https://www.npmjs.com/package/mcp-gateway)
15
- [![Docker](https://img.shields.io/badge/docker-ghcr.io-blue.svg)](https://ghcr.io/HarrisonCN/mcp-gateway)
14
+ [![npm version](https://img.shields.io/npm/v/@winstonsayno/mcp-gateway.svg)](https://www.npmjs.com/package/@winstonsayno/mcp-gateway)
15
+ [![CI](https://github.com/HarrisonCN/mcp-gateway/actions/workflows/ci.yml/badge.svg)](https://github.com/HarrisonCN/mcp-gateway/actions/workflows/ci.yml)
16
+ [![Docker](https://img.shields.io/badge/docker-ghcr.io-blue.svg)](https://github.com/HarrisonCN/mcp-gateway/pkgs/container/mcp-gateway)
16
17
 
17
- [English](#) · [中文](docs/README.zh-CN.md) · [Docs](docs/) · [Examples](examples/)
18
+ [English](#) · [中文](docs/README.zh-CN.md) · [API reference](docs/api-reference.md) · [Configuration](docs/configuration.md) · [Deployment](docs/deployment.md) · [Examples](examples/)
18
19
 
19
20
  </div>
20
21
 
@@ -62,19 +63,26 @@ As [MCP](https://modelcontextprotocol.io) becomes the standard protocol for AI a
62
63
  ## Features
63
64
 
64
65
  - **Unified API endpoint** — one URL for all your MCP tools, auto-routed by tool name
66
+ - **MCP endpoint for clients** — `/mcp` speaks MCP Streamable HTTP (2025-06-18 / 2025-03-26), so Claude Code, Cursor or any MCP client sees every upstream tool through one server, with the same auth, limits and metrics
65
67
  - **Every MCP transport** — `stdio`, `streamable-http` (current spec), legacy `sse` (HTTP+SSE) and `websocket` upstream servers, with per-server headers for upstream auth
66
68
  - **Automatic reconnect** — crashed or disconnected servers are reconnected with exponential backoff + jitter; state is visible in `/servers`, `/health`, the dashboard and Prometheus
67
69
  - **Authentication** — API key (constant-time compare), JWT (HS256/384/512), or no-auth; misconfiguration fails closed
68
70
  - **Rate limiting** — per-key sliding-window counter, with standard `X-RateLimit-*` headers
71
+ - **Per-key scopes** — restrict an API key (or a JWT via claims) to some servers / tools and give it its own rate limit; enforced on REST and `/mcp`
69
72
  - **Concurrency limits** — per-server `maxConcurrency`, queued requests count against `timeout`
70
73
  - **Health monitoring** — periodic MCP `ping` health checks with latency (every 30 s, configurable)
71
74
  - **Metrics** — Prometheus-compatible `/metrics` endpoint (monotonic counters) + JSON aggregation
72
75
  - **Config hot reload** — servers, API keys / auth, rate limits, CORS and reconnect policy apply without a restart (disable with `--no-watch`)
73
76
  - **Optional auth for health & metrics** — keep `/health` and `/metrics` public (default) or put them behind auth; the dashboard asks for a key
74
77
  - **Tool discovery** — `GET /api/v1/tools` lists all tools across all servers
78
+ - **Resources & prompts** — `resources/*` and `prompts/*` from every server, aggregated on REST and `/mcp`
79
+ - **Persistent audit log** — optional SQLite history (built-in `node:sqlite`, no dependency) queryable via `GET /api/v1/requests` and the dashboard
80
+ - **Tool filtering** — per-server `tools.allow` / `tools.deny` glob patterns hide tools you don't want exposed (and block calls to them)
75
81
  - **YAML/JSON config** — simple, declarative configuration with env var overrides
76
82
  - **Docker-ready** — official Docker image, Compose examples included
77
83
  - **TypeScript SDK** — embed the gateway as a library in your own project
84
+ - **Client libraries** — a dependency-free TypeScript client ([`clients/js`](clients/js), browser + Node) and a Kotlin/JVM/Android client ([`clients/kotlin`](clients/kotlin))
85
+ - **LLM tool schemas** — `GET /api/v1/tools?format=openai|anthropic` returns ready-to-use function-calling definitions
78
86
 
79
87
  ## Quick Start
80
88
 
@@ -142,6 +150,65 @@ curl -X POST http://localhost:4000/api/v1/tools/call \
142
150
  -d '{"tool": "create_issue", "server": "github", "arguments": {"title": "Bug report", "body": "..."}}'
143
151
  ```
144
152
 
153
+ ## Use the gateway as an MCP server (`/mcp`)
154
+
155
+ The gateway is itself an MCP server: `http://<host>:4000/mcp` implements the
156
+ [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports)
157
+ (protocol `2025-06-18`, `2025-03-26` accepted). Clients get one aggregated, filtered tool list; calls are
158
+ routed to the right upstream server with the gateway's auth, rate limit, `maxConcurrency`, timeouts,
159
+ metrics and request log.
160
+
161
+ **Claude Code**
162
+
163
+ ```bash
164
+ claude mcp add --transport http gateway http://localhost:4000/mcp \
165
+ --header "Authorization: Bearer your-secret-key"
166
+ ```
167
+
168
+ **Cursor** (`~/.cursor/mcp.json` or `.cursor/mcp.json`)
169
+
170
+ ```json
171
+ {
172
+ "mcpServers": {
173
+ "gateway": {
174
+ "url": "http://localhost:4000/mcp",
175
+ "headers": { "Authorization": "Bearer your-secret-key" }
176
+ }
177
+ }
178
+ }
179
+ ```
180
+
181
+ **Clients that only speak stdio** (e.g. older Claude Desktop builds) can bridge with
182
+ [`mcp-remote`](https://www.npmjs.com/package/mcp-remote):
183
+ `npx mcp-remote http://localhost:4000/mcp --header "Authorization: Bearer your-secret-key"`.
184
+
185
+ **Any SDK client**
186
+
187
+ ```ts
188
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
189
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
190
+
191
+ const client = new Client({ name: 'my-app', version: '1.0.0' });
192
+ await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp'), {
193
+ requestInit: { headers: { Authorization: 'Bearer your-secret-key' } },
194
+ }));
195
+ const { tools } = await client.listTools();
196
+ ```
197
+
198
+ What the endpoint does:
199
+
200
+ | | |
201
+ |---|---|
202
+ | `POST /mcp` | JSON-RPC: `initialize`, `ping`, `tools/list` (paginated), `tools/call`, `resources/list`, `resources/templates/list`, `resources/read`, `prompts/list`, `prompts/get`, notifications (incl. `notifications/cancelled`). Batches are accepted. Responses are `application/json`. |
203
+ | `GET /mcp` | SSE stream for server→client notifications: `notifications/tools/list_changed`, `notifications/resources/list_changed` and `notifications/prompts/list_changed` are sent when the aggregated list a session sees changes (a server announces changes, connects, is removed by hot reload, …). |
204
+ | Resources & prompts | Resource URIs are passed through unchanged; when two servers list the same URI the lowest server id wins. `resources/read` is routed by exact URI, then by resource template, then to the only server with resources. Prompt names follow `toolNaming` like tools. `resources/subscribe` is not offered. |
205
+ | `DELETE /mcp` | Ends the session. |
206
+ | Sessions | `initialize` returns `Mcp-Session-Id`; later requests must send it (`400` if missing, `404` if unknown or expired). A session is bound to the API key / JWT subject that created it. Idle sessions expire after `mcp.sessionIdleTimeoutSeconds`. |
207
+ | Tool names | `toolNaming: auto` (default) keeps a tool's name unless two servers expose the same name; then every copy becomes `<serverId>__<tool>`. `prefix` always uses `<serverId>__<tool>`. Ordering is deterministic (server id, then tool name). In `auto` mode the prefixed form is also accepted by `tools/call`. |
208
+ | Errors | Unknown tool / bad params → JSON-RPC `-32602`; rate limit → `-32029` with `data.retryAfter`; server offline or timed out → a normal result with `isError: true` (so the model sees it); upstream JSON-RPC errors are forwarded unchanged; cancelled → `-32800`. |
209
+ | Cancellation | `notifications/cancelled` (or the client dropping the HTTP request) cancels the upstream call, which receives its own `notifications/cancelled`. |
210
+ | Security | Same `auth` as the REST API (`Authorization: Bearer …` or `X-API-Key`). Requests with an `Origin` header are rejected (`403`) unless it matches `mcp.allowedOrigins` (default: `corsOrigins`) — set this when the gateway listens on a reachable address. |
211
+
145
212
  ## API Reference
146
213
 
147
214
  | Method | Path | Description |
@@ -149,25 +216,62 @@ curl -X POST http://localhost:4000/api/v1/tools/call \
149
216
  | `GET` | `/api/v1/health` | Gateway health and server summary |
150
217
  | `GET` | `/api/v1/servers` | List all registered servers |
151
218
  | `GET` | `/api/v1/servers/:id` | Get server details and tools |
152
- | `GET` | `/api/v1/tools` | List all tools (filterable by `?server=` or `?tag=`) |
219
+ | `GET` | `/api/v1/tools` | List all tools (filterable by `?server=` or `?tag=`; `?format=openai\|openai-responses\|anthropic` for LLM schemas) |
153
220
  | `POST` | `/api/v1/tools/call` | Invoke a tool |
154
221
  | `POST` | `/api/v1/servers/:id/reconnect` | Reconnect a server now (resets backoff) |
155
222
  | `GET` | `/api/v1/health/live` | Liveness probe — always public, returns only `{"status":"ok"}` |
223
+ | `GET` | `/api/v1/health/ready` | Readiness probe — always public; `200` when servers are ready, else `503` (`?min=N`) |
156
224
  | `GET` | `/api/v1/metrics` | Aggregated metrics (JSON or Prometheus) |
157
- | `GET` | `/api/v1/requests` | Recent request log (`?limit=`, max 500) |
225
+ | `GET` | `/api/v1/resources` | Resources of all servers (`?server=`), duplicate URIs collapsed |
226
+ | `GET` | `/api/v1/resources/templates` | Resource templates (`?server=`) |
227
+ | `POST` | `/api/v1/resources/read` | Read a resource: `{"uri": "...", "server"?: "..."}` |
228
+ | `GET` | `/api/v1/prompts` | Prompts of all servers (`?server=`) |
229
+ | `POST` | `/api/v1/prompts/get` | Get a prompt: `{"name": "...", "server"?: "...", "arguments"?: {...}}` |
230
+ | `GET` | `/api/v1/requests` | Request history, newest first (`?limit=` max 500, `server`, `tool`, `client`, `success`, `via`, `kind`, `since`, `until`, `cursor`) |
231
+ | `POST` `GET` `DELETE` | `/mcp` | MCP Streamable HTTP endpoint (see [above](#use-the-gateway-as-an-mcp-server-mcp)) |
158
232
 
159
233
  `/health` and `/metrics` are unauthenticated by default; set `auth.protect.health` / `auth.protect.metrics`
160
- to require auth for them too (`/health/live` always stays public for Docker / Kubernetes probes).
234
+ to require auth for them too (`/health/live` and `/health/ready` always stay public for Docker / Kubernetes probes).
161
235
  Every other route requires auth when it is enabled.
162
236
  `/metrics` returns JSON by default (`?window=<ms>`); with `monitor.prometheus: true` it returns the
163
237
  Prometheus text format when the client asks for `text/plain` (as Prometheus does) or passes `?format=prometheus`.
164
238
 
239
+ ### LLM tool schemas
240
+
241
+ `GET /api/v1/tools?format=openai` (Chat Completions), `openai-responses` (Responses API) or `anthropic` (Messages API)
242
+ returns the tools the caller may use as function-calling definitions, plus a `mapping` from each LLM tool
243
+ name back to the gateway server and tool:
244
+
245
+ ```json
246
+ {
247
+ "format": "anthropic",
248
+ "tools": [{ "name": "github__create_issue", "description": "…", "input_schema": { "type": "object", "properties": { … } } }],
249
+ "mapping": { "github__create_issue": { "server": "github", "tool": "create_issue" } },
250
+ "total": 1
251
+ }
252
+ ```
253
+
254
+ Pass `tools` straight to the provider; when the model calls a tool, look it up in `mapping` and
255
+ `POST /api/v1/tools/call` with that `server` / `tool` (the clients' `callLlmTool()` does this).
256
+ Names follow `mcp.toolNaming`, are sanitised to `^[a-zA-Z0-9_-]{1,64}$` and de-duplicated; `$schema` is stripped and
257
+ `parameters` is always an object schema. Scopes and `?server=` / `?tag=` filters apply.
258
+
259
+ ### Client libraries
260
+
261
+ | | |
262
+ |---|---|
263
+ | **TypeScript / JavaScript** — [`clients/js`](clients/js) | `@winstonsayno/mcp-gateway-client`: zero dependencies, `fetch`-based (browser, Node 18+, Deno, Bun, React Native), typed `health`, `servers`, `listTools`, `toolSchemas`, `callTool`, `callLlmTool`, plus a small MCP-over-`/mcp` session helper |
264
+ | **Kotlin / JVM / Android** — [`clients/kotlin`](clients/kotlin) | OkHttp + kotlinx.serialization, Java 11 bytecode; same API surface, `McpSession` for `/mcp` |
265
+
266
+ Both are in this repository and not yet published to npm / Maven Central.
267
+
165
268
  `POST /api/v1/tools/call` responses:
166
269
 
167
270
  | Status | Meaning |
168
271
  |--------|---------|
169
272
  | `200` | Tool returned a result |
170
273
  | `400` | Invalid body (`tool` must be a string, `arguments` an object) or malformed JSON |
274
+ | `403` | The tool is hidden by the server's `tools` filter (also when `"server"` is passed explicitly), or outside the caller's scope |
171
275
  | `404` | Unknown tool or server |
172
276
  | `409` | Tool name is exposed by several servers — pass `"server"` to choose |
173
277
  | `429` | Rate limited (see `Retry-After`) |
@@ -185,7 +289,12 @@ logLevel: info # debug | info | warn | error
185
289
  auth:
186
290
  strategy: api-key # none | api-key | jwt (oauth2 is not implemented and is rejected)
187
291
  apiKeys:
188
- - "your-secret-key"
292
+ - "your-secret-key" # full access
293
+ - key: "${APP_KEY}" # scoped key (see "Per-key scopes")
294
+ name: app
295
+ servers: ["github"]
296
+ tools: ["read_*"]
297
+ rateLimit: { limit: 30, windowSeconds: 60 }
189
298
  protect:
190
299
  health: false # true → /api/v1/health requires auth
191
300
  metrics: false # true → /api/v1/metrics requires auth (configure your scraper)
@@ -215,6 +324,21 @@ monitor:
215
324
  corsOrigins:
216
325
  - "https://your-app.com"
217
326
 
327
+ audit: # persistent request history (SQLite, Node 22.5+; default off)
328
+ enabled: false
329
+ path: mcp-gateway-audit.db
330
+ retentionDays: 30 # 0 = keep forever
331
+
332
+ mcp: # downstream MCP endpoint (Streamable HTTP)
333
+ enabled: true # restart required to change
334
+ path: /mcp # restart required to change; not "/" or under /api, /dashboard
335
+ toolNaming: auto # auto | prefix ("<serverId>__<tool>")
336
+ pageSize: 500 # tools per tools/list page
337
+ sessionIdleTimeoutSeconds: 1800
338
+ maxSessions: 1000 # least recently used idle session is evicted beyond this
339
+ # allowedOrigins: ["https://your-app.com"] # browser origins allowed on /mcp (default: corsOrigins)
340
+ # instructions: "Tools for the ACME workspace" # returned from initialize
341
+
218
342
  servers:
219
343
  - id: my-server # Unique identifier
220
344
  name: My Server # Display name
@@ -227,6 +351,9 @@ servers:
227
351
  enabled: true
228
352
  timeout: 30000 # ms, includes time queued behind maxConcurrency
229
353
  maxConcurrency: 10 # max in-flight tool calls for this server
354
+ tools: # optional: expose only some tools (globs: * and ?, deny wins)
355
+ allow: ["read_*", "list_*"]
356
+ deny: ["*_secret"]
230
357
  reconnect: # optional per-server override of the reconnect block
231
358
  maxAttempts: 5
232
359
 
@@ -259,10 +386,104 @@ With `mcp-gateway start` the config file is watched (disable with `--no-watch`).
259
386
  | `auth` (strategy, keys, JWT secret, `protect`) | `monitor.retentionHours` |
260
387
  | `rateLimit` (counters reset when it changes) | `healthCheckIntervalMs` |
261
388
  | `corsOrigins`, `monitor.requestLog`, `monitor.prometheus` | `dashboard` |
262
- | `reconnect`, `logLevel` | |
389
+ | `reconnect`, `logLevel` | `mcp.enabled`, `mcp.path`, `audit` |
390
+ | `mcp.toolNaming`, `mcp.pageSize`, session limits, `mcp.allowedOrigins` | |
263
391
 
264
392
  An invalid file is rejected and the running config is kept. `MCP_GATEWAY_*` env overrides keep precedence.
265
393
 
394
+ ### Per-key scopes
395
+
396
+ Give each app its own key and only the tools it needs. Plain string keys keep full access;
397
+ object entries can be restricted (all fields except `key` are optional):
398
+
399
+ ```yaml
400
+ auth:
401
+ strategy: api-key
402
+ apiKeys:
403
+ - "admin-key" # unrestricted
404
+ - key: ${AURA_GATEWAY_KEY} # ${VAR} is expanded in object keys
405
+ name: aura # client id "key:aura" in logs, metrics and sessions (unique)
406
+ servers: ["github", "fs-*"] # server id globs
407
+ tools: ["read_*", "github/create_issue"] # tool globs; "server/tool" when the pattern has a "/"
408
+ rateLimit: { limit: 30, windowSeconds: 60 } # own bucket instead of the global rateLimit
409
+ ```
410
+
411
+ - A tool must pass the server's own `tools` filter **and** the key's `servers` **and** `tools` lists.
412
+ An absent list means no restriction; an empty list allows nothing.
413
+ - **Discovery hides** what a key may not use: `GET /tools`, `GET /servers`, `GET /servers/:id` (→ `404`), `/mcp` `tools/list`.
414
+ - **Calls are refused**: `POST /tools/call` and `POST /servers/:id/reconnect` → `403`; `/mcp` `tools/call` → JSON-RPC error `-32003`.
415
+ Auto-routing only considers servers the key may use, so a name that collides elsewhere can still be called without `"server"`.
416
+ - On `/mcp`, collision prefixes are computed from what the key can see (a key scoped to one server sees bare names).
417
+ - Restricted keys only see their own entries in `GET /requests`.
418
+ - **JWT**: put globs in the `mcp_servers` / `mcp_tools` claims (array, or a space/comma-separated string), e.g.
419
+ `{"sub": "user-1", "mcp_servers": ["github"], "mcp_tools": "read_* github/create_issue"}`. A malformed claim allows nothing.
420
+ - Hot reloadable: changing scopes applies to the next request; open `/mcp` sessions get `notifications/tools/list_changed`,
421
+ and sessions of removed keys are closed.
422
+
423
+ ### Resources & prompts
424
+
425
+ Servers that announce the `resources` / `prompts` capabilities have their resources, resource templates and
426
+ prompts listed at connect time (and refreshed on `notifications/*/list_changed`). They are available on REST
427
+ (`/api/v1/resources`, `/resources/templates`, `/resources/read`, `/prompts`, `/prompts/get`) and on `/mcp`.
428
+ Reads and gets are forwarded live with the server's `timeout`, counted against the rate limit and recorded in
429
+ metrics / history with `kind: "resource"` or `"prompt"`. Key scopes apply by **server** (`servers` globs);
430
+ `tools` globs and `servers[].tools` filters only concern tools.
431
+
432
+ ```bash
433
+ curl -s localhost:4000/api/v1/resources
434
+ curl -s -X POST localhost:4000/api/v1/resources/read -H 'content-type: application/json' -d '{"uri":"file:///notes/todo.md"}'
435
+ curl -s -X POST localhost:4000/api/v1/prompts/get -H 'content-type: application/json' \
436
+ -d '{"name":"review-code","server":"github","arguments":{"pr":"42"}}'
437
+ ```
438
+
439
+ ### Persistent audit log
440
+
441
+ By default request history lives in memory (`monitor.retentionHours`). Enable the audit log to keep it in SQLite
442
+ across restarts:
443
+
444
+ ```yaml
445
+ audit:
446
+ enabled: true
447
+ path: ./data/mcp-gateway-audit.db # default mcp-gateway-audit.db (WAL mode)
448
+ retentionDays: 30 # pruned hourly; 0 = keep forever
449
+ ```
450
+
451
+ - Uses Node's built-in [`node:sqlite`](https://nodejs.org/api/sqlite.html) (**Node 22.5+**): no extra dependency and
452
+ nothing native to compile. On Node 20 the gateway refuses to start with `audit.enabled: true` and says why. Node may
453
+ print an `ExperimentalWarning` for `node:sqlite`.
454
+ - Only metadata is stored: time, server, tool / URI / prompt, kind, duration, success, error message, client id, `via`
455
+ (`rest` / `mcp`). Arguments and results are never stored.
456
+ - `GET /api/v1/requests` then reads from the database (`"source": "audit"`) and supports filters
457
+ (`server`, `tool`, `client`, `success=true|false`, `via=rest|mcp`, `kind=tool|resource|prompt`, `since` / `until` as ISO
458
+ or epoch ms) and paging (`nextCursor` → `?cursor=`). Restricted keys only ever see their own records.
459
+ The dashboard's *Request History* panel has the same filters and a *Load older* button.
460
+ - Library users can plug in any store: `metrics.setAuditStore(myStore)` with the `AuditStore` interface.
461
+ - Changing `audit` requires a restart.
462
+
463
+ ### Tool filtering
464
+
465
+ Expose only part of a server's tools — e.g. make a filesystem server read-only, or drop tools that
466
+ clash with another server's names:
467
+
468
+ ```yaml
469
+ servers:
470
+ - id: filesystem
471
+ name: Filesystem (read-only)
472
+ transport: stdio
473
+ command: npx
474
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
475
+ tools:
476
+ allow: ["read_*", "list_*", "search_files", "get_file_info"]
477
+ deny: ["*_media_file"]
478
+ ```
479
+
480
+ - Patterns are globs matched against the whole tool name, case-sensitive: `*` = any characters, `?` = one character.
481
+ - A tool is exposed when it matches an `allow` pattern (or `allow` is absent / empty) **and** no `deny` pattern. Deny wins.
482
+ - Hidden tools are absent from `GET /tools`, `GET /servers/:id`, tool counts and metrics, and don't cause
483
+ name conflicts with other servers. Calling one returns `404` (auto-routing) or `403` (explicit `"server"`).
484
+ - The filter is applied to every tool list, including updates via `notifications/tools/list_changed`.
485
+ Changing it in the config file is applied by hot reload (the server is reconnected).
486
+
266
487
  ### Reconnect & server status
267
488
 
268
489
  Each server's `health.status` is one of `online`, `degraded` (connected, but the health ping failed),
@@ -280,7 +501,7 @@ Prometheus series added: `mcp_gateway_server_up`, `mcp_gateway_server_status{sta
280
501
  docker run -p 4000:4000 \
281
502
  -v $(pwd)/mcp-gateway.yml:/app/mcp-gateway.yml \
282
503
  -e GITHUB_TOKEN=ghp_... \
283
- ghcr.io/harrisonCN/mcp-gateway:latest
504
+ ghcr.io/harrisoncn/mcp-gateway:latest
284
505
 
285
506
  # Or with Docker Compose (gateway + Prometheus; see examples/docker)
286
507
  cd examples/docker
@@ -290,6 +511,31 @@ GATEWAY_API_KEY=change-me docker compose up
290
511
  The image's `HEALTHCHECK` uses the always-public `/api/v1/health/live`, so it keeps working when
291
512
  `auth.protect.health` is on.
292
513
 
514
+ ### Liveness vs. readiness (Kubernetes, load balancers)
515
+
516
+ | Probe | Answers | Use it for |
517
+ |-------|---------|------------|
518
+ | `GET /api/v1/health/live` | always `200 {"status":"ok"}` while the process serves HTTP | restart a hung container |
519
+ | `GET /api/v1/health/ready` | `200` when the upstream servers are ready, otherwise `503` | only route traffic to gateways that can serve tool calls |
520
+
521
+ A server counts as ready when it is enabled, connected and not `degraded` (failing health pings).
522
+ By default **every** enabled server must be ready; `?min=N` requires at least `N` instead (useful when
523
+ some servers are optional). With no servers configured the gateway is ready. While shutting down it
524
+ answers `503 {"status":"shutting_down"}` so load balancers drain it first. The body only carries counts:
525
+
526
+ ```json
527
+ { "status": "not_ready", "servers": { "ready": 1, "total": 2, "required": 2 } }
528
+ ```
529
+
530
+ ```yaml
531
+ # Kubernetes
532
+ livenessProbe:
533
+ httpGet: { path: /api/v1/health/live, port: 4000 }
534
+ readinessProbe:
535
+ httpGet: { path: /api/v1/health/ready, port: 4000 } # or /api/v1/health/ready?min=1
536
+ periodSeconds: 10
537
+ ```
538
+
293
539
  ## Dashboard
294
540
 
295
541
  Open `http://localhost:4000/dashboard`. When auth is enabled, paste an API key (or JWT) in the header:
@@ -300,7 +546,7 @@ to stop serving it.
300
546
  ## Embed as a Library
301
547
 
302
548
  ```typescript
303
- import { Gateway, loadConfig } from 'mcp-gateway';
549
+ import { Gateway, loadConfig } from '@winstonsayno/mcp-gateway';
304
550
 
305
551
  const config = await loadConfig('./mcp-gateway.yml');
306
552
  const gateway = new Gateway(config);
@@ -312,16 +558,18 @@ await gateway.start();
312
558
  process.on('SIGTERM', () => gateway.stop());
313
559
  ```
314
560
 
315
- ## What's New (unreleased)
561
+ ## What's New in v1.0
316
562
 
317
563
  | Feature | Description |
318
564
  |---------|-------------|
319
- | **Remote transports** | `streamable-http`, `sse` and `websocket` servers are now routable (checked against the official MCP SDK servers in the test suite) |
320
- | **Auto reconnect** | Exponential backoff with jitter, `reconnecting` status, manual `POST /servers/:id/reconnect`, Prometheus series |
321
- | **Health pings** | Real MCP `ping` health checks with latency; `degraded` when a connected server stops answering |
322
- | **Tool list updates** | `notifications/tools/list_changed` refreshes the tool registry |
323
- | **Protected health/metrics** | `auth.protect.health` / `auth.protect.metrics`, public `/health/live`, dashboard API-key support |
324
- | **More hot reload** | Auth, API keys, rate limits, CORS, reconnect policy |
565
+ | **Stable API** | Semver from 1.0: `/api/v1`, `/mcp`, config keys, CLI and root exports are stable (see [stability](#api-stability)) |
566
+ | **Docs** | [API reference](docs/api-reference.md), [configuration reference](docs/configuration.md), [deployment guide](docs/deployment.md) (Docker, Kubernetes, reverse proxy) |
567
+ | **Container image** | Multi-arch `ghcr.io/harrisoncn/mcp-gateway` built on every release (Node 22) |
568
+ | **Resources & prompts** | `resources/list`, `resources/templates/list`, `resources/read`, `prompts/list`, `prompts/get` aggregated on REST and `/mcp`, with list-changed notifications and scopes |
569
+ | **Audit log** | Optional persistent request history in SQLite (`node:sqlite`), filterable / pageable `GET /api/v1/requests`, dashboard history |
570
+ | **Clients & LLM schemas** | TypeScript client (`clients/js`), Kotlin client (`clients/kotlin`), `GET /api/v1/tools?format=openai\|openai-responses\|anthropic` |
571
+ | **Per-key scopes** | API keys can carry `servers` / `tools` globs and their own `rateLimit`; JWTs carry `mcp_servers` / `mcp_tools` claims. Enforced on REST and `/mcp`, hot reloadable |
572
+ | **`/mcp` endpoint** | The gateway is an MCP server (Streamable HTTP, 2025-06-18): sessions, aggregated + paginated `tools/list`, deterministic collision naming, routed `tools/call`, `list_changed` notifications, cancellation |
325
573
 
326
574
  ## What's New in v0.2.0
327
575
 
@@ -335,6 +583,14 @@ process.on('SIGTERM', () => gateway.stop());
335
583
  | **Web Dashboard** | Live monitoring UI at `/dashboard` |
336
584
  | **4 Bug Fixes** | Concurrency, id collision, handle leaks, timeouts |
337
585
 
586
+ ## API stability
587
+
588
+ mcp-gateway follows [Semantic Versioning](https://semver.org/) since **1.0.0**. Within 1.x the REST API under
589
+ `/api/v1`, the `/mcp` endpoint behaviour, configuration keys, CLI commands / flags, root library exports and Prometheus
590
+ metric names only change in backward-compatible ways (new fields, endpoints and options may be added — ignore
591
+ unknown fields). Deep imports, log format, the dashboard and the audit database schema are not covered. Details:
592
+ [docs/api-reference.md#stability-and-versioning](docs/api-reference.md#stability-and-versioning).
593
+
338
594
  ## Roadmap
339
595
 
340
596
  | Feature | Status |
@@ -346,10 +602,15 @@ process.on('SIGTERM', () => gateway.stop());
346
602
  | Automatic reconnect with backoff | ✅ Done |
347
603
  | Config hot reload | ✅ Done (v0.2.0) |
348
604
  | Web dashboard UI | ✅ Done (v0.2.0) |
605
+ | Downstream MCP endpoint (`/mcp`) | ✅ Done (v1.0) |
606
+ | Per-key scopes and limits | ✅ Done (v1.0) |
607
+ | JS / Kotlin clients, OpenAI / Anthropic tool schemas | ✅ Done (v1.0) |
608
+ | Resources & prompts passthrough, persistent audit log | ✅ Done (v1.0) |
609
+ | Stable API, docs, container image | ✅ Done (v1.0) |
349
610
  | Redis-backed rate limiting | 📋 Planned |
350
611
  | OAuth2 / OIDC auth | 📋 Planned |
351
- | Tool-level access control (RBAC) | 📋 Planned |
352
- | Request replay & debugging | 📋 Planned |
612
+ | Tool-level access control | ✅ Done via per-key scopes (v0.6) |
613
+ | Request replay & debugging | 📋 Planned (history is available via the audit log) |
353
614
  | Multi-tenant mode | 📋 Planned |
354
615
  | OpenTelemetry tracing | 📋 Planned |
355
616
 
@@ -94,6 +94,13 @@
94
94
  .method { font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; font-family: monospace; }
95
95
  .method.post { background: rgba(63,185,80,.15); color: var(--green); }
96
96
  .method.get { background: rgba(88,166,255,.15); color: var(--accent); }
97
+ .filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; font-size: 12px; color: var(--muted); }
98
+ .filters input, .filters select { background: var(--surface, #161b22); color: inherit; border: 1px solid var(--border, #30363d); border-radius: 6px; padding: 4px 8px; font-size: 12px; }
99
+ .filters .source { margin-left: auto; }
100
+ .filters button, .more { background: var(--surface); border: 1px solid var(--border); color: var(--text); padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; }
101
+ .filters button:hover, .more:hover { border-color: var(--accent); color: var(--accent); }
102
+ .more { display: block; margin: 10px auto 0; }
103
+ .more[hidden] { display: none; }
97
104
 
98
105
  /* ── Code ── */
99
106
  code { font-family: "SFMono-Regular", Consolas, monospace; font-size: 12px; color: var(--purple); }
@@ -189,15 +196,25 @@
189
196
  </div>
190
197
  </div>
191
198
 
192
- <!-- Recent Requests -->
199
+ <!-- Request history -->
193
200
  <div class="section">
194
- <div class="section-title">Recent Requests</div>
201
+ <div class="section-title">Request History</div>
202
+ <form class="filters" id="req-filters" onsubmit="event.preventDefault(); loadRequests(true)">
203
+ <input id="f-server" placeholder="server id" aria-label="Filter by server" size="12" />
204
+ <input id="f-tool" placeholder="tool / URI / prompt" aria-label="Filter by tool" size="16" />
205
+ <select id="f-status" aria-label="Filter by status"><option value="">any status</option><option value="true">ok</option><option value="false">error</option></select>
206
+ <select id="f-via" aria-label="Filter by interface"><option value="">REST + MCP</option><option value="rest">REST</option><option value="mcp">MCP</option></select>
207
+ <select id="f-since" aria-label="Time range"><option value="">all time</option><option value="3600000">last hour</option><option value="86400000">last 24 h</option><option value="604800000">last 7 days</option></select>
208
+ <button class="refresh-btn" type="submit">Apply</button>
209
+ <span class="source" id="req-source"></span>
210
+ </form>
195
211
  <div class="table-wrap">
196
212
  <table>
197
- <thead><tr><th>Time</th><th>Method</th><th>Tool</th><th>Server</th><th>Duration</th><th>Status</th></tr></thead>
198
- <tbody id="requests-tbody"><tr><td colspan="6" class="loader"><span class="spinner"></span></td></tr></tbody>
213
+ <thead><tr><th>Time</th><th>Via</th><th>Tool / resource / prompt</th><th>Server</th><th>Client</th><th>Duration</th><th>Status</th></tr></thead>
214
+ <tbody id="requests-tbody"><tr><td colspan="7" class="loader"><span class="spinner"></span></td></tr></tbody>
199
215
  </table>
200
216
  </div>
217
+ <button class="refresh-btn more" id="req-more" type="button" hidden onclick="loadRequests(false)">Load older</button>
201
218
  </div>
202
219
  </main>
203
220
 
@@ -373,36 +390,76 @@
373
390
  } catch { /* shown via other panels */ }
374
391
  }
375
392
 
376
- async function loadRequests() {
393
+ // History comes from the persistent audit log when it is enabled
394
+ // (audit.enabled), otherwise from the in-memory log; paged with a cursor.
395
+ let reqCursor;
396
+ let reqPaged = false;
397
+
398
+ function requestQuery() {
399
+ const q = new URLSearchParams({ limit: '25' });
400
+ const v = (id) => document.getElementById(id).value.trim();
401
+ if (v('f-server')) q.set('server', v('f-server'));
402
+ if (v('f-tool')) q.set('tool', v('f-tool'));
403
+ if (v('f-status')) q.set('success', v('f-status'));
404
+ if (v('f-via')) q.set('via', v('f-via'));
405
+ if (v('f-since')) q.set('since', String(Date.now() - Number(v('f-since'))));
406
+ return q;
407
+ }
408
+
409
+ function requestRow(r) {
410
+ const color = r.success ? 'green' : 'red';
411
+ const err = r.errorMessage ? ` title="${esc(r.errorMessage)}"` : '';
412
+ const kind = r.kind && r.kind !== 'tool' ? `<span style="color:var(--muted)">${esc(r.kind)}</span> ` : '';
413
+ const via = (r.via ?? 'rest') === 'mcp' ? '<span class="method get">MCP</span>' : '<span class="method post">REST</span>';
414
+ const t = new Date(r.timestamp);
415
+ const today = t.toDateString() === new Date().toDateString();
416
+ return `<tr>
417
+ <td style="color:var(--muted)" title="${esc(t.toISOString())}">${esc(today ? t.toLocaleTimeString() : t.toLocaleString())}</td>
418
+ <td>${via}</td>
419
+ <td>${kind}<code>${esc(r.toolName ?? '—')}</code></td>
420
+ <td>${esc(r.serverId ?? '—')}</td>
421
+ <td style="color:var(--muted)">${esc(r.clientId ?? '—')}</td>
422
+ <td>${fmtDuration(r.durationMs)}</td>
423
+ <td style="color:var(--${color})"${err}>${r.success ? 'ok' : 'error'}</td>
424
+ </tr>`;
425
+ }
426
+
427
+ async function loadRequests(reset = true) {
377
428
  const tbody = document.getElementById('requests-tbody');
429
+ const more = document.getElementById('req-more');
430
+ if (reset) {
431
+ reqCursor = undefined;
432
+ reqPaged = false;
433
+ } else {
434
+ reqPaged = true;
435
+ }
378
436
  try {
379
- const data = await fetchJSON('/api/v1/requests?limit=20');
437
+ const q = requestQuery();
438
+ if (!reset && reqCursor) q.set('cursor', reqCursor);
439
+ const data = await fetchJSON('/api/v1/requests?' + q.toString());
380
440
  const reqs = data.requests ?? [];
381
- if (!reqs.length) {
382
- tbody.innerHTML = '<tr><td colspan="6" class="empty">No requests yet</td></tr>';
441
+ document.getElementById('req-source').textContent =
442
+ data.source === 'audit' ? 'source: persistent audit log' : data.source === 'memory' ? 'source: in-memory (since start)' : '';
443
+ reqCursor = data.nextCursor;
444
+ more.hidden = !reqCursor;
445
+ if (reset && !reqs.length) {
446
+ tbody.innerHTML = '<tr><td colspan="7" class="empty">No requests</td></tr>';
383
447
  return;
384
448
  }
385
- tbody.innerHTML = reqs.map(r => {
386
- const color = r.success ? 'green' : 'red';
387
- const err = r.errorMessage ? ` title="${esc(r.errorMessage)}"` : '';
388
- return `<tr>
389
- <td style="color:var(--muted)">${esc(new Date(r.timestamp).toLocaleTimeString())}</td>
390
- <td><span class="method post">CALL</span></td>
391
- <td><code>${esc(r.toolName ?? '—')}</code></td>
392
- <td>${esc(r.serverId ?? '—')}</td>
393
- <td>${fmtDuration(r.durationMs)}</td>
394
- <td style="color:var(--${color})"${err}>${r.success ? 'ok' : 'error'}</td>
395
- </tr>`;
396
- }).join('');
449
+ const html = reqs.map(requestRow).join('');
450
+ if (reset) tbody.innerHTML = html;
451
+ else tbody.insertAdjacentHTML('beforeend', html);
397
452
  } catch (e) {
398
- tbody.innerHTML = `<tr><td colspan="6" class="empty">${e instanceof AuthError ? 'API key required' : 'Failed to load requests'}</td></tr>`;
453
+ more.hidden = true;
454
+ tbody.innerHTML = `<tr><td colspan="7" class="empty">${e instanceof AuthError ? 'API key required' : 'Failed to load requests'}</td></tr>`;
399
455
  }
400
456
  }
401
457
 
402
458
  async function loadAll() {
403
459
  authMissing = false;
404
460
  await loadMetrics(); // tool call counts feed the tools table
405
- await Promise.allSettled([loadHealth(), loadServers(), loadTools(), loadRequests()]);
461
+ // Don't throw away older pages the user loaded on auto-refresh.
462
+ await Promise.allSettled([loadHealth(), loadServers(), loadTools(), reqPaged ? Promise.resolve() : loadRequests(true)]);
406
463
  notice.classList.toggle('show', authMissing);
407
464
  keyInput.classList.toggle('needed', authMissing);
408
465
  }