From ce125fcb1019c82428f5c5816a242bff60af24cc Mon Sep 17 00:00:00 2001 From: cjimti Date: Thu, 7 May 2026 00:13:45 -0700 Subject: [PATCH 1/3] docs: v1.2 followups (cards, mermaid, k8s example, table-to-card sweep) Bundles the post-PR-#13 documentation polish that came out of reviewing the v1.2.0 docs end to end on the live site. Card layout for identifier-shaped tables: Markdown table renderers hyphenate backtick'd identifiers mid-character to fit narrow columns (`auth.allow_anonymous` becomes `auth.all` / `ow_anony` / `mous`; `00-namespace.yaml` becomes `00-namespace.yam` / `l`). Horizontal-scroll is not the answer. Three new card components in docs/stylesheets/extra.css (.api-endpoint, .config-key, .def-card, ~430 lines) take the identifier as a hero on its own line at full row width, with type/default/required chips on a second line that anchors in the same horizontal slot on every card. Components share a vocabulary: 1px border, --plex-radius-md, copper hover, 3px left strip color-coded by category (GET copper-400, POST copper-700, DELETE coral; required-config in deep-copper). Pages converted: - docs/configuration/reference.md (43 cards across 8 sections) - docs/configuration/environment.md (11 env-var cards) - docs/reference/http-api.md (30 endpoint cards + JSONB filter cards) - docs/reference/mcp.md (5 method cards) - docs/operations/audit.md (column cards + endpoint cards) - docs/operations/portal.md (route cards) - docs/getting-started/overview.md (surface cards) - docs/getting-started/quickstart.md (URL cards) Kubernetes example: New self-contained installation under examples/kubernetes/. One file per Kubernetes resource (Namespace + Postgres Service/Secret/StatefulSet + mcp-test Secret/ConfigMap/Service/Deployment/Ingress). Bash installer (install.sh) generates secrets in-place on first run, applies manifests with [N/M] progress, gates on Postgres readiness mid-sequence, patches config-hash/secret-hash annotations so config edits trigger rollouts. Documented in docs/operations/kubernetes.md with each manifest's full YAML embedded inline (verbatim from disk: a python check enforces no drift between doc embeds and source files). Cross-linked from deployment.md, added to mkdocs.yml nav under Operations. Mermaid for the gateway-testing diagrams: Two ASCII box-drawing diagrams in docs/operations/gateway-testing.md replaced with mermaid (flowchart LR for the client/gateway/mcp-test/ audit topology, sequenceDiagram with autonumber for the identity forwarding contrast). mkdocs-material has mermaid wired in via pymdownx.superfences. Pre-commit gate: 1 review round, 4 findings (1 MAJOR YAML drift between kubernetes.md embeds and on-disk manifests; 2 MINOR em-dash hard-rule violations in CSS comments and install.sh header; 1 NIT indent inconsistency in a reduced-motion block). All resolved before commit; strict mkdocs build clean, make verify green. --- docs/configuration/environment.md | 157 +++- docs/configuration/reference.md | 642 ++++++++++++++-- docs/getting-started/overview.md | 61 +- docs/getting-started/quickstart.md | 29 +- docs/operations/audit.md | 154 +++- docs/operations/deployment.md | 4 + docs/operations/gateway-testing.md | 32 +- docs/operations/kubernetes.md | 712 ++++++++++++++++++ docs/operations/portal.md | 65 +- docs/reference/http-api.md | 409 ++++++++-- docs/reference/mcp.md | 41 +- docs/stylesheets/extra.css | 430 +++++++++++ examples/kubernetes/00-namespace.yaml | 8 + examples/kubernetes/10-postgres-service.yaml | 25 + examples/kubernetes/12-postgres-secret.yaml | 23 + .../kubernetes/14-postgres-statefulset.yaml | 92 +++ examples/kubernetes/20-mcp-test-secret.yaml | 34 + .../kubernetes/25-mcp-test-configmap.yaml | 91 +++ examples/kubernetes/30-mcp-test-service.yaml | 19 + .../kubernetes/40-mcp-test-deployment.yaml | 102 +++ examples/kubernetes/50-mcp-test-ingress.yaml | 53 ++ examples/kubernetes/README.md | 96 +++ examples/kubernetes/install.sh | 248 ++++++ mkdocs.yml | 1 + 24 files changed, 3341 insertions(+), 187 deletions(-) create mode 100644 docs/operations/kubernetes.md create mode 100644 examples/kubernetes/00-namespace.yaml create mode 100644 examples/kubernetes/10-postgres-service.yaml create mode 100644 examples/kubernetes/12-postgres-secret.yaml create mode 100644 examples/kubernetes/14-postgres-statefulset.yaml create mode 100644 examples/kubernetes/20-mcp-test-secret.yaml create mode 100644 examples/kubernetes/25-mcp-test-configmap.yaml create mode 100644 examples/kubernetes/30-mcp-test-service.yaml create mode 100644 examples/kubernetes/40-mcp-test-deployment.yaml create mode 100644 examples/kubernetes/50-mcp-test-ingress.yaml create mode 100644 examples/kubernetes/README.md create mode 100755 examples/kubernetes/install.sh diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 78219ba..bfc3374 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -16,19 +16,150 @@ plus a few un-prefixed standard names (`LOG_LEVEL`). ## Common overrides -| Variable | Used by | Example | -| --- | --- | --- | -| `LOG_LEVEL` | Binary | `debug`, `info` (default), `warn`, `error`. | -| `MCPTEST_PORT` | `server.address` | `8080` | -| `MCPTEST_BASE_URL` | `server.base_url` | `https://mcp-test.example.com` | -| `MCPTEST_DATABASE_URL` | `database.url` | `postgres://mcp:mcp@localhost:5432/mcp_test?sslmode=disable` | -| `MCPTEST_DEV_KEY` | `api_keys.file[0].key` | `devkey-please-change` | -| `MCPTEST_OIDC_ISSUER` | `oidc.issuer` | `http://localhost:8081/realms/mcp-test` | -| `MCPTEST_OIDC_AUDIENCE` | `oidc.audience` | `mcp-test` | -| `MCPTEST_OIDC_CLIENT_ID` | `oidc.client_id` | `mcp-test-portal` | -| `MCPTEST_OIDC_CLIENT_SECRET` | `oidc.client_secret` | (confidential clients only) | -| `MCPTEST_COOKIE_SECRET` | `portal.cookie_secret` | 32+ bytes, base64 | -| `MCPTEST_INSECURE` | OIDC skip-signature gate | `1` to allow `oidc.skip_signature_verification` | +
+ +
+
+LOG_LEVEL +
+used bybinary +
+
+
+`debug`, `info` (default), `warn`, `error`. Sets the slog log level for the whole process. +
+
+ +
+
+MCPTEST_PORT +
+maps toserver.address +example8080 +
+
+
+The live config interpolates as `:${MCPTEST_PORT:-8080}`. +
+
+ +
+
+MCPTEST_BASE_URL +
+maps toserver.base_url +examplehttps://mcp-test.example.com +
+
+
+The public origin clients reach the server at. Used in OIDC redirect URIs and the protected-resource metadata document. +
+
+ +
+
+MCPTEST_DATABASE_URL +
+maps todatabase.url +examplepostgres://mcp:mcp@localhost:5432/mcp_test?sslmode=disable +
+
+
+A pgx-compatible Postgres DSN. +
+
+ +
+
+MCPTEST_DEV_KEY +
+maps toapi_keys.file[0].key +exampledevkey-please-change +
+
+
+The bootstrap file API key. Sent as `X-API-Key`. +
+
+ +
+
+MCPTEST_OIDC_ISSUER +
+maps tooidc.issuer +examplehttp://localhost:8081/realms/mcp-test +
+
+
+Required when OIDC is enabled. +
+
+ +
+
+MCPTEST_OIDC_AUDIENCE +
+maps tooidc.audience +examplemcp-test +
+
+
+Required `aud` claim value. +
+
+ +
+
+MCPTEST_OIDC_CLIENT_ID +
+maps tooidc.client_id +examplemcp-test-portal +
+
+
+The OIDC client ID used by the browser PKCE login flow. +
+
+ +
+
+MCPTEST_OIDC_CLIENT_SECRET +
+maps tooidc.client_secret +
+
+
+Confidential clients only; public PKCE clients leave it empty. +
+
+ +
+
+MCPTEST_COOKIE_SECRET +
+maps toportal.cookie_secret +example32+ bytes, base64 +
+
+
+HMAC key for the portal session cookie. Generate with `openssl rand -base64 32`. +
+
+ +
+
+MCPTEST_INSECURE +
+used byOIDC skip-signature gate +example1 +
+
+
+Set to `1` to allow `oidc.skip_signature_verification` (refused otherwise). +
+
+ +
## Interpolation rules diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index 98de33c..dc3df21 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -36,21 +36,178 @@ server: json_response: false ``` -| Key | Type | Default | Notes | -| --- | --- | --- | --- | -| `server.name` | string | `mcp-test` | Reported in the MCP `initialize` response and in the audit log. | -| `server.address` | string | `:8080` | Listen address; `MCPTEST_PORT` env interpolation common. | -| `server.base_url` | string | `http://localhost:` | Public origin used for the protected-resource metadata document and OIDC redirect URIs. Set this to your real hostname behind TLS terminators. | -| `server.instructions` | string | (project default, see [Server Instructions](instructions.md)) | Returned to MCP clients via `initialize.result.instructions`. Most clients pass this to the LLM as system context. | -| `server.read_header_timeout` | duration | `10s` | Standard `http.Server.ReadHeaderTimeout`. | -| `server.shutdown.grace_period` | duration | `25s` | Maximum time `http.Server.Shutdown` waits for in-flight requests during drain. | -| `server.shutdown.pre_shutdown_delay` | duration | `2s` | After SIGINT/SIGTERM, the server flips `/readyz` to 503 and sleeps this long before starting the shutdown so load balancers notice. | -| `server.tls.enabled` | bool | `false` | If true, listens with TLS using the cert/key files below. Most deployments terminate TLS upstream and leave this false. | -| `server.tls.cert_file` | string | `""` | PEM-encoded certificate path. | -| `server.tls.key_file` | string | `""` | PEM-encoded private key path. | -| `server.streamable.session_timeout` | duration | `30m` | Idle MCP session timeout passed to `mcp.StreamableHTTPOptions`. | -| `server.streamable.stateless` | bool | `false` | If true, the SDK does not validate `Mcp-Session-Id` and uses ephemeral sessions. Useful behind external session stores; we don't ship one. | -| `server.streamable.json_response` | bool | `false` | If true, responses are `application/json` instead of `text/event-stream`. | +
+ +
+
+server.name +
+typestring +defaultmcp-test +
+
+
+Reported in the MCP `initialize` response and in the audit log. +
+
+ +
+
+server.address +
+typestring +default:8080 +
+
+
+Listen address; `MCPTEST_PORT` env interpolation common. +
+
+ +
+
+server.base_url +
+typestring +defaulthttp://localhost:<port> +
+
+
+Public origin used for the protected-resource metadata document and OIDC redirect URIs. Set this to your real hostname behind TLS terminators. +
+
+ +
+
+server.instructions +
+typestring +defaultproject default +
+
+
+Returned to MCP clients via `initialize.result.instructions`. Most clients pass this to the LLM as system context. See [Server Instructions](instructions.md) for the shipped default. +
+
+ +
+
+server.read_header_timeout +
+typeduration +default10s +
+
+
+Standard `http.Server.ReadHeaderTimeout`. +
+
+ +
+
+server.shutdown.grace_period +
+typeduration +default25s +
+
+
+Maximum time `http.Server.Shutdown` waits for in-flight requests during drain. +
+
+ +
+
+server.shutdown.pre_shutdown_delay +
+typeduration +default2s +
+
+
+After SIGINT / SIGTERM, the server flips `/readyz` to 503 and sleeps this long before starting the shutdown so load balancers notice. +
+
+ +
+
+server.tls.enabled +
+typebool +defaultfalse +
+
+
+If true, listens with TLS using the cert / key files below. Most deployments terminate TLS upstream and leave this false. +
+
+ +
+
+server.tls.cert_file +
+typestring +default"" +
+
+
+PEM-encoded certificate path. +
+
+ +
+
+server.tls.key_file +
+typestring +default"" +
+
+
+PEM-encoded private key path. +
+
+ +
+
+server.streamable.session_timeout +
+typeduration +default30m +
+
+
+Idle MCP session timeout passed to `mcp.StreamableHTTPOptions`. +
+
+ +
+
+server.streamable.stateless +
+typebool +defaultfalse +
+
+
+If true, the SDK does not validate `Mcp-Session-Id` and uses ephemeral sessions. Useful behind external session stores; we don't ship one. +
+
+ +
+
+server.streamable.json_response +
+typebool +defaultfalse +
+
+
+If true, responses are `application/json` instead of `text/event-stream`. +
+
+ +
## oidc @@ -70,17 +227,126 @@ oidc: skip_signature_verification: false ``` -| Key | Type | Default | Notes | -| --- | --- | --- | --- | -| `oidc.enabled` | bool | `false` | Master toggle. With it off, only API keys (file/DB) and anonymous mode (if enabled) authenticate. | -| `oidc.issuer` | string | `""` | Required when enabled. The IdP's issuer URL; mcp-test fetches `/.well-known/openid-configuration` to find the JWKS and authorization endpoints. | -| `oidc.audience` | string | `""` | Required `aud` claim value. Tokens that don't carry this audience are rejected. | -| `oidc.client_id` | string | `""` | The OIDC client ID used by the browser PKCE login flow. | -| `oidc.client_secret` | string | `""` | Optional. Confidential clients should set this; public PKCE clients leave it empty. | -| `oidc.allowed_clients` | []string | `[]` | Optional `azp` / `client_id` allowlist. Empty means any client of the issuer is accepted. | -| `oidc.clock_skew_seconds` | int | `30` | Leeway applied when validating `exp` / `iat` / `nbf`. | -| `oidc.jwks_cache_ttl` | duration | `1h` | How long fetched JWKS keys are cached. The cache transparently refreshes on any unknown `kid`. | -| `oidc.skip_signature_verification` | bool | `false` | Trust the IdP's TLS without verifying JWT signatures. Refused unless `MCPTEST_INSECURE=1` is set in the environment. | +
+ +
+
+oidc.enabled +
+typebool +defaultfalse +
+
+
+Master toggle. With it off, only API keys (file / DB) and anonymous mode (if enabled) authenticate. +
+
+ +
+
+oidc.issuer +
+typestring +required whenoidc.enabled +
+
+
+The IdP's issuer URL. mcp-test fetches `/.well-known/openid-configuration` to find the JWKS and authorization endpoints. +
+
+ +
+
+oidc.audience +
+typestring +required whenoidc.enabled +
+
+
+Required `aud` claim value. Tokens that don't carry this audience are rejected. +
+
+ +
+
+oidc.client_id +
+typestring +default"" +
+
+
+The OIDC client ID used by the browser PKCE login flow. +
+
+ +
+
+oidc.client_secret +
+typestring +default"" +
+
+
+Optional. Confidential clients should set this; public PKCE clients leave it empty. +
+
+ +
+
+oidc.allowed_clients +
+type[]string +default[] +
+
+
+Optional `azp` / `client_id` allowlist. Empty means any client of the issuer is accepted. +
+
+ +
+
+oidc.clock_skew_seconds +
+typeint +default30 +
+
+
+Leeway applied when validating `exp` / `iat` / `nbf`. +
+
+ +
+
+oidc.jwks_cache_ttl +
+typeduration +default1h +
+
+
+How long fetched JWKS keys are cached. The cache transparently refreshes on any unknown `kid`. +
+
+ +
+
+oidc.skip_signature_verification +
+typebool +defaultfalse +
+
+
+Trust the IdP's TLS without verifying JWT signatures. Refused unless `MCPTEST_INSECURE=1` is set in the environment. +
+
+ +
See [Authentication](auth.md) for the full identity model and auth-chain semantics. @@ -99,10 +365,35 @@ api_keys: enabled: true ``` -| Key | Type | Default | Notes | -| --- | --- | --- | --- | -| `api_keys.file` | []entry | `[]` | List of `{name, key, description}` entries. The plaintext `key` is constant-time compared against the inbound `X-API-Key` header. Empty `key` values are skipped (so an unset env var doesn't enable an empty credential). | -| `api_keys.db.enabled` | bool | `false` | If true, the binary opens the bcrypt-hashed `api_keys` Postgres table for read+write. The portal's API Keys page manages entries. | +
+ +
+
+api_keys.file +
+type[]entry +default[] +
+
+
+List of `{name, key, description}` entries. The plaintext `key` is constant-time compared against the inbound `X-API-Key` header. Empty `key` values are skipped (so an unset env var doesn't enable an empty credential). +
+
+ +
+
+api_keys.db.enabled +
+typebool +defaultfalse +
+
+
+If true, the binary opens the bcrypt-hashed `api_keys` Postgres table for read+write. The portal's API Keys page manages entries. +
+
+ +
Both sources contribute to the same auth chain: an inbound API key is matched against file entries first (cheap O(N) constant-time compare), @@ -119,11 +410,48 @@ auth: require_for_portal: true ``` -| Key | Type | Default | Notes | -| --- | --- | --- | --- | -| `auth.allow_anonymous` | bool | `false` | If true, missing credentials on `/mcp` resolve to a synthetic Anonymous identity. Useful for some gateway tests where you want to validate header pass-through without auth in the way. The portal still requires a credential. | -| `auth.require_for_mcp` | bool | `true` | Gate the `/` endpoint. Currently the auth gateway checks for credential *presence* and 401s without one (unless anonymous is allowed). | -| `auth.require_for_portal` | bool | `true` | Gate every `/portal/*` and `/api/v1/*` route. The portal auth middleware is independent of `allow_anonymous`. | +
+ +
+
+auth.allow_anonymous +
+typebool +defaultfalse +
+
+
+If true, missing credentials on `/mcp` resolve to a synthetic Anonymous identity. Useful for some gateway tests where you want to validate header pass-through without auth in the way. The portal still requires a credential. +
+
+ +
+
+auth.require_for_mcp +
+typebool +defaulttrue +
+
+
+Gate the `/` endpoint. Currently the auth gateway checks for credential *presence* and 401s without one (unless anonymous is allowed). +
+
+ +
+
+auth.require_for_portal +
+typebool +defaulttrue +
+
+
+Gate every `/portal/*` and `/api/v1/*` route. The portal auth middleware is independent of `allow_anonymous`. +
+
+ +
## database @@ -138,12 +466,61 @@ database: conn_max_lifetime: 1h ``` -| Key | Type | Default | Notes | -| --- | --- | --- | --- | -| `database.url` | string | (required) | A PostgreSQL DSN that the pgx driver understands. The migration runner rewrites `postgres://` to `pgx5://` internally so the golang-migrate driver picks it up. | -| `database.max_open_conns` | int | `25` | Max active connections in the pgxpool. | -| `database.max_idle_conns` | int | `5` | Min idle connections held open. | -| `database.conn_max_lifetime` | duration | `1h` | Recycle connections older than this. | +
+ +
+
+database.url +
+typestring +requiredalways +
+
+
+A PostgreSQL DSN that the pgx driver understands. The migration runner rewrites `postgres://` to `pgx5://` internally so the golang-migrate driver picks it up. +
+
+ +
+
+database.max_open_conns +
+typeint +default25 +
+
+
+Max active connections in the pgxpool. +
+
+ +
+
+database.max_idle_conns +
+typeint +default5 +
+
+
+Min idle connections held open. +
+
+ +
+
+database.conn_max_lifetime +
+typeduration +default1h +
+
+
+Recycle connections older than this. +
+
+ +
See [Database & Migrations](database.md) for the schema details. @@ -158,11 +535,48 @@ audit: redact_keys: [password, token, secret, authorization, cookie, api_key, credentials] ``` -| Key | Type | Default | Notes | -| --- | --- | --- | --- | -| `audit.enabled` | bool | `true` | Disables the audit pipeline entirely when false (no rows written, no portal data). | -| `audit.retention_days` | int | `30` | Documented retention target. mcp-test does not currently auto-prune; deploy a cron job against the `audit_events` table if you need it. | -| `audit.redact_keys` | []string | `[password, token, secret, authorization, api_key, credentials]` | Case-insensitive substring match. Any tool-call argument key matching one of these gets its value replaced with `[redacted]` before the row is written. | +
+ +
+
+audit.enabled +
+typebool +defaulttrue +
+
+
+Disables the audit pipeline entirely when false (no rows written, no portal data). +
+
+ +
+
+audit.retention_days +
+typeint +default30 +
+
+
+Documented retention target. mcp-test does not currently auto-prune; deploy a cron job against the `audit_events` table if you need it. +
+
+ +
+
+audit.redact_keys +
+type[]string +default[password, token, secret, authorization, api_key, credentials] +
+
+
+Case-insensitive substring match. Any tool-call argument key matching one of these gets its value replaced with `[redacted]` before the row is written. +
+
+ +
## portal @@ -177,13 +591,74 @@ portal: oidc_redirect_path: /portal/auth/callback ``` -| Key | Type | Default | Notes | -| --- | --- | --- | --- | -| `portal.enabled` | bool | `false` | Master toggle. Disabling skips loading the session store and mounting the portal/admin APIs and SPA. | -| `portal.cookie_name` | string | `mcp_test_session` | Name of the HMAC-signed session cookie. | -| `portal.cookie_secret` | string | (required when enabled) | At least 16 bytes, 32+ recommended. HMAC key for cookie signing. | -| `portal.cookie_secure` | bool | `true` | Sets the `Secure` cookie attribute. Leave on in production; turn off for local HTTP-only dev. | -| `portal.oidc_redirect_path` | string | `/portal/auth/callback` | OIDC redirect URI path; whatever you set must match what the IdP has registered for the portal client. | +
+ +
+
+portal.enabled +
+typebool +defaultfalse +
+
+
+Master toggle. Disabling skips loading the session store and mounting the portal / admin APIs and SPA. +
+
+ +
+
+portal.cookie_name +
+typestring +defaultmcp_test_session +
+
+
+Name of the HMAC-signed session cookie. +
+
+ +
+
+portal.cookie_secret +
+typestring +required whenportal.enabled +
+
+
+At least 16 bytes, 32+ recommended. HMAC key for cookie signing. +
+
+ +
+
+portal.cookie_secure +
+typebool +defaulttrue +
+
+
+Sets the `Secure` cookie attribute. Leave on in production; turn off for local HTTP-only dev. +
+
+ +
+
+portal.oidc_redirect_path +
+typestring +default/portal/auth/callback +
+
+
+OIDC redirect URI path. Whatever you set must match what the IdP has registered for the portal client. +
+
+ +
## tools @@ -197,12 +672,61 @@ tools: streaming: { enabled: true } ``` -| Key | Type | Default | Notes | -| --- | --- | --- | --- | -| `tools.identity.enabled` | bool | `false` | `whoami`, `echo`, `headers`. | -| `tools.data.enabled` | bool | `false` | `fixed_response`, `sized_response`, `lorem`. | -| `tools.failure.enabled` | bool | `false` | `error`, `slow`, `flaky`. | -| `tools.streaming.enabled` | bool | `false` | `progress`, `long_output`, `chatty`. | +
+ +
+
+tools.identity.enabled +
+typebool +defaultfalse +
+
+
+`whoami`, `echo`, `headers`. +
+
+ +
+
+tools.data.enabled +
+typebool +defaultfalse +
+
+
+`fixed_response`, `sized_response`, `lorem`. +
+
+ +
+
+tools.failure.enabled +
+typebool +defaultfalse +
+
+
+`error`, `slow`, `flaky`. +
+
+ +
+
+tools.streaming.enabled +
+typebool +defaultfalse +
+
+
+`progress`, `long_output`, `chatty`. +
+
+ +
A toolkit must be enabled in config for its tools to be registered with the MCP server. The example and dev configs enable all four. diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index 997a21c..524f5d5 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -11,15 +11,58 @@ fronted by an OIDC provider (for bearer-token auth and portal login). ## What you get -| Surface | What it is | -| --- | --- | -| `/` (HTTP) | The MCP streamable HTTP endpoint. Browsers hitting this URL are redirected to `/portal/`; MCP clients pass through. | -| `/portal/` | An embedded React 19 portal: dashboard, tools (with a per-tool Try-It form), audit log browser, API-key management, server config viewer, gateway-discovery surface. | -| `/api/v1/portal/*` | Read-only REST endpoints behind cookie or API-key auth. Useful for scripting against a running server. | -| `/api/v1/admin/*` | Mutating REST endpoints (key CRUD, Try-It proxy). | -| `/.well-known/oauth-protected-resource` | RFC 9728 metadata advertising the issuer. The MCP auth gateway points 401 challenges at it. | -| `/.well-known/oauth-authorization-server` | Stub pointing at the upstream issuer's metadata. | -| `/healthz` and `/readyz` | Liveness and readiness probes. | +
+ +
+
/
+
+The MCP streamable HTTP endpoint. Browsers hitting this URL are redirected to `/portal/`; MCP clients pass through. +
+
+ +
+
/portal/
+
+An embedded React 19 portal: dashboard, tools (with a per-tool Try-It form), audit log browser, API-key management, server config viewer, gateway-discovery surface. +
+
+ +
+
/api/v1/portal/*
+
+Read-only REST endpoints behind cookie or API-key auth. Useful for scripting against a running server. +
+
+ +
+
/api/v1/admin/*
+
+Mutating REST endpoints (key CRUD, Try-It proxy). +
+
+ +
+
/.well-known/oauth-protected-resource
+
+RFC 9728 metadata advertising the issuer. The MCP auth gateway points 401 challenges at it. +
+
+ +
+
/.well-known/oauth-authorization-server
+
+Stub pointing at the upstream issuer's metadata. +
+
+ +
+
/healthz and /readyz
+
+Liveness and readiness probes. +
+
+ +
## Components in front of the binary diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 292b435..49c4932 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -26,12 +26,29 @@ make dev When it's up: -| URL | What | -| --- | --- | -| | Portal — sign in with `dev`/`dev` (OIDC) or paste an API key. | -| | MCP streamable HTTP endpoint. Browsers redirect to the portal; MCP clients pass through. | -| | Keycloak admin console (`admin`/`admin`). | -| | Liveness. | +
+ +
+
http://localhost:8080/portal/
+
Portal. Sign in with `dev` / `dev` (OIDC) or paste an API key.
+
+ +
+
http://localhost:8080/
+
MCP streamable HTTP endpoint. Browsers redirect to the portal; MCP clients pass through.
+
+ +
+
http://localhost:8081/
+
Keycloak admin console (`admin` / `admin`).
+
+ +
+
http://localhost:8080/healthz
+
Liveness.
+
+ +
The dev API key is `devkey-please-change`. Override it via `MCPTEST_DEV_KEY` if you want something else. diff --git a/docs/operations/audit.md b/docs/operations/audit.md index 85ea9ff..d102674 100644 --- a/docs/operations/audit.md +++ b/docs/operations/audit.md @@ -21,21 +21,74 @@ Cascade delete on the foreign key keeps retention atomic: deleting an `audit_eve ## What gets recorded -| Column | Source | -| --- | --- | -| `id` | UUID generated server-side. | -| `ts` | UTC timestamp at the start of the call. | -| `duration_ms` | End-to-end time the tool took, including the auth chain. | -| `request_id` | UUID generated per call; useful for correlating across logs. | -| `session_id` | The MCP session ID the SDK assigned at initialize. | -| `user_subject` / `user_email` / `auth_type` / `api_key_name` | Resolved identity from the auth chain. | -| `tool_name` / `tool_group` | Which tool, and which category. | -| `parameters` | Sanitized arguments (JSONB). Keys matching `audit.redact_keys` have their values replaced with `"[redacted]"`. | -| `success` / `error_message` / `error_category` | Outcome. `error_category` is a short label (`auth`, `tool`, `protocol`, etc.) for filtering. | -| `request_chars` / `response_chars` / `content_blocks` | Sizing of input args and the result. Useful for spotting size-cap issues. | -| `transport` | Always `"http"` today. | -| `source` | `"mcp"` for real client calls, `"portal-tryit"` for portal-driven invocations. | -| `remote_addr` / `user_agent` | From the inbound HTTP headers. | +
+ +
+
id
+
UUID generated server-side.
+
+ +
+
ts
+
UTC timestamp at the start of the call.
+
+ +
+
duration_ms
+
End-to-end time the tool took, including the auth chain.
+
+ +
+
request_id
+
UUID generated per call; useful for correlating across logs.
+
+ +
+
session_id
+
The MCP session ID the SDK assigned at initialize.
+
+ +
+
user_subject / user_email / auth_type / api_key_name
+
Resolved identity from the auth chain.
+
+ +
+
tool_name / tool_group
+
Which tool, and which category.
+
+ +
+
parameters
+
Sanitized arguments (JSONB). Keys matching `audit.redact_keys` have their values replaced with `"[redacted]"`.
+
+ +
+
success / error_message / error_category
+
Outcome. `error_category` is a short label (`auth`, `tool`, `protocol`, etc.) for filtering.
+
+ +
+
request_chars / response_chars / content_blocks
+
Sizing of input args and the result. Useful for spotting size-cap issues.
+
+ +
+
transport
+
Always `"http"` today.
+
+ +
+
source
+
`"mcp"` for real client calls, `"portal-tryit"` for portal-driven invocations.
+
+ +
+
remote_addr / user_agent
+
From the inbound HTTP headers.
+
+ +
See [Database & Migrations](../configuration/database.md#schema) for the exact DDL and indexes. @@ -56,14 +109,69 @@ p50 / p95 latency, unique users / tools, and a recent-activity table. ## REST endpoints -| Endpoint | Returns | -| --- | --- | -| `GET /api/v1/portal/audit/events` | Paginated event list. Query params: `from`, `to` (RFC 3339), `tool`, `user`, `session`, `success` (`true`/`false`), `q` (free text), `limit`, `offset`. Plus the JSONB filters in the next section. | -| `GET /api/v1/portal/audit/events/{id}` | Single event with captured payload (when present). | -| `GET /api/v1/portal/audit/export` | NDJSON stream of summary rows. Same filter surface as `/events`. Capped at 100,000 rows per request. | -| `GET /api/v1/portal/audit/timeseries` | Bucketed counts. Query params: `from`, `to`, `bucket` (Go duration like `1m`, `5m`). Returns `[{time, count, errors, avg_duration_ms}]`. | -| `GET /api/v1/portal/audit/breakdown` | Group-by aggregations. Query param: `by` (one of `tool`, `user`, `success`, `auth_type`). Returns `[{key, count, errors}]`. | -| `GET /api/v1/portal/dashboard` | The 1-hour summary. | +
+ +
+
+GET +/api/v1/portal/audit/events +
+
+Paginated event list. Query params: `from`, `to` (RFC 3339), `tool`, `user`, `session`, `success` (`true` / `false`), `q` (free text), `limit`, `offset`. Plus the JSONB filters in the next section. +
+
+ +
+
+GET +/api/v1/portal/audit/events/{id} +
+
+Single event with captured payload (when present). +
+
+ +
+
+GET +/api/v1/portal/audit/export +
+
+NDJSON stream of summary rows. Same filter surface as `/events`. Capped at 100,000 rows per request. +
+
+ +
+
+GET +/api/v1/portal/audit/timeseries +
+
+Bucketed counts. Query params: `from`, `to`, `bucket` (Go duration like `1m`, `5m`). Returns `[{time, count, errors, avg_duration_ms}]`. +
+
+ +
+
+GET +/api/v1/portal/audit/breakdown +
+
+Group-by aggregations. Query param: `by` (one of `tool`, `user`, `success`, `auth_type`). Returns `[{key, count, errors}]`. +
+
+ +
+
+GET +/api/v1/portal/dashboard +
+
+The 1-hour summary. +
+
+ +
### JSONB filters diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index b255400..393072c 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -51,6 +51,10 @@ Mount the config at `/app/configs/mcp-test.yaml` and run with ## Kubernetes +For a self-contained, ready-to-run installation (manifests + bash installer with progress, Postgres bundled in-cluster), see [Kubernetes example](kubernetes.md). The notes below cover the building blocks if you're wiring mcp-test into existing cluster infrastructure. + +### Building blocks + A minimal Deployment / Service / Ingress: ```yaml diff --git a/docs/operations/gateway-testing.md b/docs/operations/gateway-testing.md index 4311367..95621b4 100644 --- a/docs/operations/gateway-testing.md +++ b/docs/operations/gateway-testing.md @@ -11,16 +11,14 @@ This page collects the patterns. ## The setup -``` -┌────────┐ ┌─────────┐ ┌──────────┐ -│ client │ ──▶ │ gateway │ ──▶ │ mcp-test │ -└────────┘ └─────────┘ └──────────┘ - │ - ▼ - ┌─────────┐ - │ audit │ - │ (Postgres) │ - └─────────┘ +```mermaid +flowchart LR + client[client] + gateway[gateway] + mcptest[mcp-test] + audit[(audit
Postgres)] + + client --> gateway --> mcptest --> audit ``` The client makes calls. The gateway transforms them (auth, @@ -34,10 +32,16 @@ differ is what the gateway did. **Question:** does the gateway forward the original caller's identity, or does it re-authenticate and substitute its own? -``` -client → gateway: Authorization: Bearer - │ - └─→ mcp-test: Authorization: Bearer +```mermaid +sequenceDiagram + autonumber + participant Client as client + participant Gateway as gateway + participant Server as mcp-test + + Client->>Gateway: Authorization: Bearer + Note right of Gateway: forward verbatim,
or re-auth and substitute? + Gateway->>Server: Authorization: Bearer ``` Call `whoami`. The returned `subject` and `email` should match the diff --git a/docs/operations/kubernetes.md b/docs/operations/kubernetes.md new file mode 100644 index 0000000..961add2 --- /dev/null +++ b/docs/operations/kubernetes.md @@ -0,0 +1,712 @@ +--- +title: Kubernetes example +description: Self-contained Kubernetes manifests + install script for mcp-test and a single-replica Postgres. One file per resource, applied with progress. +--- + +# Kubernetes example + +A working installation of mcp-test on any Kubernetes cluster with an `nginx` ingress controller. One file per Kubernetes resource, applied with a small bash installer that prints `[N/M]` progress and waits on readiness between steps. + +The manifests live in [`examples/kubernetes/`](https://github.com/plexara/mcp-test/tree/main/examples/kubernetes) on the source repo. Clone the repo, edit the placeholders, run `./install.sh`. + +## What gets installed + +A single namespace with two workloads: + +```mermaid +flowchart LR + client[client] + ingress["Ingress
nginx + cert-manager"] + server["Deployment
mcp-test"] + db[("StatefulSet
postgres")] + + client -->|"https://mcp-test-server.example.com/"| ingress + ingress --> server + server -->|"DATABASE_URL"| db +``` + +## Manifests + +The numeric file prefixes are the apply order; the installer (below) gates on Postgres readiness halfway through. Each file is small enough to read end-to-end. Replace the `REPLACE_ME_*` placeholders manually or let `install.sh` generate values for you on first run. + +### Namespace + +The namespace everything else lands in. Standard `app.kubernetes.io/*` labels for tooling discovery (Lens, k9s, ArgoCD). + +```yaml title="examples/kubernetes/00-namespace.yaml" +apiVersion: v1 +kind: Namespace +metadata: + name: mcp-test + labels: + app.kubernetes.io/name: mcp-test + app.kubernetes.io/part-of: mcp-test + app.kubernetes.io/managed-by: manifest +``` + +### Postgres Service + +Headless (`clusterIP: None`) so the StatefulSet gets a stable per-pod DNS record (`postgres-0.postgres.mcp-test.svc.cluster.local`). The mcp-test connection string uses the bare `postgres` host name; in-namespace DNS resolves it. + +```yaml title="examples/kubernetes/10-postgres-service.yaml" +# Headless ClusterIP for the Postgres StatefulSet. mcp-test reaches the +# DB via DNS name `postgres.mcp-test.svc.cluster.local:5432`. Headless +# (clusterIP: None) gives the StatefulSet a stable per-pod DNS record +# (`postgres-0.postgres...`); the connection string in 20-mcp-test-secret +# uses the unqualified `postgres` host because both pods share a +# namespace and DNS resolves it. +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: mcp-test + labels: + app: postgres + app.kubernetes.io/name: postgres + app.kubernetes.io/component: db + app.kubernetes.io/part-of: mcp-test +spec: + clusterIP: None + selector: + app: postgres + ports: + - name: postgres + port: 5432 + targetPort: postgres + protocol: TCP +``` + +### Postgres Secret + +DB / user / password. The `POSTGRES_PASSWORD` placeholder is filled in-place by `install.sh` with `openssl rand -base64 24`. Pre-fill it manually if you want stable creds across reinstalls (a private fork pattern). + +```yaml title="examples/kubernetes/12-postgres-secret.yaml" +# Postgres credentials. Used by both the postgres container (POSTGRES_* +# env) and built into the mcp-test connection string in +# 20-mcp-test-secret. The install.sh script will fill the empty fields +# below with a random password on first run; pre-filling them here lets +# you commit a fixed password to a private fork if you want stable creds +# across reinstalls. +apiVersion: v1 +kind: Secret +metadata: + name: postgres + namespace: mcp-test + labels: + app: postgres + app.kubernetes.io/name: postgres + app.kubernetes.io/component: db + app.kubernetes.io/part-of: mcp-test +type: Opaque +stringData: + POSTGRES_DB: mcp_test + POSTGRES_USER: mcp + # REPLACE: 24+ char random string (e.g. `openssl rand -base64 24`). + # install.sh writes one for you on a fresh install. + POSTGRES_PASSWORD: "REPLACE_ME_POSTGRES_PASSWORD" +``` + +### Postgres StatefulSet + +Single replica with a 5Gi PVC. `pg_isready` probes for readiness and liveness. The `securityContext` runs as the `postgres` UID baked into the alpine image. Bump the storage request before scale-out demos. + +```yaml title="examples/kubernetes/14-postgres-statefulset.yaml" +# Single-replica Postgres for the mcp-test audit log + DB-backed API +# keys. 5Gi PVC sized for ~50M audit_events rows at the default +# 30-day retention with payloads on. Bump the storage request before +# scale-out demos. +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: mcp-test + labels: + app: postgres + app.kubernetes.io/name: postgres + app.kubernetes.io/component: db + app.kubernetes.io/part-of: mcp-test +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + app.kubernetes.io/name: postgres + app.kubernetes.io/component: db + app.kubernetes.io/part-of: mcp-test + spec: + terminationGracePeriodSeconds: 30 + securityContext: + runAsUser: 70 # postgres uid in the alpine image + runAsGroup: 70 + fsGroup: 70 + containers: + - name: postgres + image: postgres:17-alpine + imagePullPolicy: IfNotPresent + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + env: + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: postgres + key: POSTGRES_DB + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: postgres + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgres + key: POSTGRES_PASSWORD + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: ["pg_isready", "-U", "mcp", "-d", "mcp_test"] + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + exec: + command: ["pg_isready", "-U", "mcp", "-d", "mcp_test"] + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 5Gi + # storageClassName: REPLACE_ME (omit to use cluster default) +``` + +### mcp-test Secret + +Three application secrets: the full Postgres `DATABASE_URL` (built from the postgres password by the installer to keep them in sync), the portal cookie HMAC key, and the bootstrap `X-API-Key` value. All three are `REPLACE_ME` placeholders that `install.sh` fills with `openssl rand` output on first run. + +```yaml title="examples/kubernetes/20-mcp-test-secret.yaml" +# Application secrets for mcp-test: +# - DATABASE_URL: full Postgres connection string. Built from the +# postgres credentials so this manifest is self-contained for +# installs that bundle Postgres in-cluster (the default). For an +# external Postgres, replace this with your own URL. +# - COOKIE_SECRET: HMAC key for the portal's session cookie. ANY +# 32+ random bytes; rotating invalidates all live sessions. +# - DEV_KEY: bootstrap API key. Sent as `X-API-Key: ` to MCP +# and admin endpoints; matches the `api_keys.file` entry in the +# ConfigMap. +# +# install.sh fills the empty fields below with `openssl rand` output +# on a fresh install. The DATABASE_URL also references the postgres +# password; the script regenerates the URL whenever it touches the +# postgres secret to keep them in sync. +apiVersion: v1 +kind: Secret +metadata: + name: mcp-test + namespace: mcp-test + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test +type: Opaque +stringData: + # postgres://USER:PASSWORD@HOST:5432/DB?sslmode=disable + DATABASE_URL: "REPLACE_ME_DATABASE_URL" + # 32 bytes base64 (e.g. `openssl rand -base64 32`) + COOKIE_SECRET: "REPLACE_ME_COOKIE_SECRET" + # API key sent as `X-API-Key`. Use a long random value or the + # mcptest_-prefixed format the dev tooling produces. + DEV_KEY: "REPLACE_ME_DEV_API_KEY" +``` + +### mcp-test ConfigMap + +The full `mcp-test.yaml` runtime config. Defaults: API-key auth (OIDC commented out), audit on with payloads + headers + 30-day retention, all four toolkits enabled. The container reads `${MCPTEST_DATABASE_URL}`, `${MCPTEST_COOKIE_SECRET}`, and `${MCPTEST_DEV_KEY}` via env interpolation; those env vars come from the Secret above. + +REPLACE: `server.base_url` if your ingress host isn't `mcp-test-server.example.com`. + +```yaml title="examples/kubernetes/25-mcp-test-configmap.yaml" +# mcp-test runtime config. The container reads this from +# /etc/mcp-test/mcp-test.yaml; see 40-mcp-test-deployment.yaml. +# +# Defaults below ship a working installation with: +# - API-key auth only (OIDC disabled). Operators authenticate by +# pasting the dev key on the portal login screen, and MCP clients +# send `X-API-Key: `. To turn on OIDC instead, set +# `oidc.enabled: true` and supply issuer + client credentials via +# additional secret keys + env mappings in the Deployment. +# - Audit log fully on, payloads + headers captured, 30-day retention. +# - Portal at /portal/, MCP at /, behind the same Ingress. +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-test + namespace: mcp-test + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test +data: + mcp-test.yaml: | + server: + name: mcp-test + address: ":8080" + # REPLACE: must match the public URL clients reach this server at. + # Used in OIDC redirects (when enabled) and RFC 9728 metadata. + base_url: "https://mcp-test-server.example.com" + read_header_timeout: 10s + shutdown: + grace_period: 25s + pre_shutdown_delay: 2s + tls: + enabled: false + streamable: + session_timeout: 30m + stateless: false + json_response: false + + oidc: + enabled: false + # When you flip enabled to true, supply these via env from a + # secret you add yourself. The defaults below match the dev + # Keycloak; replace with your real issuer. + # issuer: "https://your-idp.example.com/realms/mcp-test" + # audience: "mcp-test" + # client_id: "${MCPTEST_OIDC_CLIENT_ID}" + # client_secret: "${MCPTEST_OIDC_CLIENT_SECRET}" + + api_keys: + file: + - name: bootstrap + key: "${MCPTEST_DEV_KEY}" + description: "Bootstrap API key for portal admin and MCP smoke tests" + db: + enabled: true + + auth: + allow_anonymous: false + require_for_mcp: true + require_for_portal: true + + database: + url: "${MCPTEST_DATABASE_URL}" + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 1h + + audit: + enabled: true + capture_payloads: true + capture_headers: true + max_payload_bytes: 65536 + retention_days: 30 + redact_keys: [password, token, secret, authorization, api_key, credentials, cookie] + + portal: + enabled: true + cookie_name: mcp_test_session + cookie_secret: "${MCPTEST_COOKIE_SECRET}" + # cookie_secure must be false when serving over plain HTTP locally; + # leave true for the Ingress + cert-manager flow this example uses. + cookie_secure: true + oidc_redirect_path: /portal/auth/callback + + tools: + identity: { enabled: true } + data: { enabled: true } + failure: { enabled: true } + streaming: { enabled: true } +``` + +### mcp-test Service + +ClusterIP, port 8080. The Ingress points at this Service. + +```yaml title="examples/kubernetes/30-mcp-test-service.yaml" +apiVersion: v1 +kind: Service +metadata: + name: mcp-test + namespace: mcp-test + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test +spec: + type: ClusterIP + selector: + app: mcp-test + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: http +``` + +### mcp-test Deployment + +Single replica (mcp-test is stateless once Postgres is reachable; bump for HA). Reads its config from `/etc/mcp-test/mcp-test.yaml` via the mounted ConfigMap; reads three env vars from the Secret. Tight `securityContext` (non-root, read-only root FS, all capabilities dropped). The `config-hash` / `secret-hash` annotations are placeholders that `install.sh` patches to a SHA256 of the on-disk file contents so a config change triggers a rollout automatically. + +```yaml title="examples/kubernetes/40-mcp-test-deployment.yaml" +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mcp-test + namespace: mcp-test + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test +spec: + replicas: 1 + revisionHistoryLimit: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app: mcp-test + template: + metadata: + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test + annotations: + # Force a rollout when ConfigMap or Secret content changes. + # install.sh patches these to a SHA256 of the on-disk file + # contents; replace manually with `kubectl rollout restart` if + # you edit the manifests outside the script. + config-hash: "0" + secret-hash: "0" + spec: + terminationGracePeriodSeconds: 30 + containers: + - name: mcp-test + image: ghcr.io/plexara/mcp-test:v1.2.0 + imagePullPolicy: IfNotPresent + args: + - --config + - /etc/mcp-test/mcp-test.yaml + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: MCPTEST_DATABASE_URL + valueFrom: + secretKeyRef: + name: mcp-test + key: DATABASE_URL + - name: MCPTEST_COOKIE_SECRET + valueFrom: + secretKeyRef: + name: mcp-test + key: COOKIE_SECRET + - name: MCPTEST_DEV_KEY + valueFrom: + secretKeyRef: + name: mcp-test + key: DEV_KEY + volumeMounts: + - name: config + mountPath: /etc/mcp-test + readOnly: true + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + runAsNonRoot: true + runAsUser: 65532 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: config + configMap: + name: mcp-test +``` + +### mcp-test Ingress + +cert-manager automation for TLS, CORS for browser-side MCP gateways, and the long-timeout / no-buffer nginx annotations the streamable HTTP transport needs (live tail and SSE responses can't tolerate proxy buffering). + +REPLACE: `mcp-test-server.example.com` (3 occurrences) with your DNS name. `install.sh` rewrites them in-place when you set `INGRESS_HOST`. + +```yaml title="examples/kubernetes/50-mcp-test-ingress.yaml" +# Ingress for mcp-test. Assumes: +# - ingress-nginx as the controller (annotations are nginx-specific). +# Adjust for traefik / aws-load-balancer-controller / etc. +# - cert-manager with a `letsencrypt-production` ClusterIssuer for +# automated TLS. Remove the `cert-manager.io/cluster-issuer` +# annotation and the `tls:` block if you handle TLS at a different +# layer (terminating LB, service mesh, etc.). +# +# REPLACE: `mcp-test-server.example.com` (3 occurrences below) with the +# DNS name your Ingress controller routes to this service. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mcp-test + namespace: mcp-test + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test + annotations: + cert-manager.io/cluster-issuer: letsencrypt-production + # CORS: lets a browser-side MCP gateway call this server. Tighten + # the allow-origin list to the gateway hosts you actually use. + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, DELETE, OPTIONS" + nginx.ingress.kubernetes.io/cors-allow-origin: "*" + nginx.ingress.kubernetes.io/cors-allow-credentials: "true" + # Streamable HTTP / SSE: long-lived connections for live tail and + # MCP server-initiated messages. Disable buffering so the client + # sees frames as they're written. + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/proxy-buffer-size: "8k" + nginx.ingress.kubernetes.io/service-upstream: "true" +spec: + ingressClassName: nginx + rules: + - host: mcp-test-server.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: mcp-test + port: + number: 8080 + tls: + - hosts: + - mcp-test-server.example.com + secretName: mcp-test-tls +``` + +## Install + +```sh +git clone https://github.com/plexara/mcp-test.git +cd mcp-test/examples/kubernetes + +INGRESS_HOST=mcp.example.com ./install.sh +``` + +The full source of [`install.sh`](https://github.com/plexara/mcp-test/blob/main/examples/kubernetes/install.sh) is in the repo. The two interesting blocks are the placeholder-fill (generates secrets in-place) and the apply loop (gates on Postgres readiness): + +```bash title="examples/kubernetes/install.sh (excerpt: placeholder fill)" +# Postgres password +if grep -q 'REPLACE_ME_POSTGRES_PASSWORD' "$PG_SECRET"; then + pgpw="$(openssl rand -base64 24 | tr -d '\n=+/' | head -c 28)" + sed -i.bak "s|REPLACE_ME_POSTGRES_PASSWORD|${pgpw}|" "$PG_SECRET" && rm -f "$PG_SECRET.bak" +fi + +# Application secrets +if grep -q 'REPLACE_ME_COOKIE_SECRET' "$APP_SECRET"; then + cookie="$(openssl rand -base64 32 | tr -d '\n')" + sed -i.bak "s|REPLACE_ME_COOKIE_SECRET|${cookie}|" "$APP_SECRET" && rm -f "$APP_SECRET.bak" +fi +if grep -q 'REPLACE_ME_DEV_API_KEY' "$APP_SECRET"; then + devkey="mcptest_$(openssl rand -base64 24 | tr -d '\n=+/' | head -c 32)" + sed -i.bak "s|REPLACE_ME_DEV_API_KEY|${devkey}|" "$APP_SECRET" && rm -f "$APP_SECRET.bak" +fi +if grep -q 'REPLACE_ME_DATABASE_URL' "$APP_SECRET"; then + dburl="postgres://mcp:${pgpw}@postgres:5432/mcp_test?sslmode=disable" + sed -i.bak "s|REPLACE_ME_DATABASE_URL|${dburl}|" "$APP_SECRET" && rm -f "$APP_SECRET.bak" +fi +``` + +```bash title="examples/kubernetes/install.sh (excerpt: apply loop with readiness gates)" +for f in "${MANIFESTS[@]}"; do + case "$f" in + 14-postgres-statefulset.yaml) + step "$i" "applying $f" + apply "$SCRIPT_DIR/$f" + step "$i" "waiting for Postgres pod ready..." + kubectl -n "$NAMESPACE" wait --for=condition=Ready pod/postgres-0 \ + --timeout=180s + ;; + 40-mcp-test-deployment.yaml) + step "$i" "applying $f (config-hash=${config_hash}, secret-hash=${secret_hash})" + sed -e "s|config-hash: \"0\"|config-hash: \"${config_hash}\"|" \ + -e "s|secret-hash: \"0\"|secret-hash: \"${secret_hash}\"|" \ + "$SCRIPT_DIR/$f" | kubectl apply -f - + step "$i" "waiting for mcp-test rollout..." + kubectl -n "$NAMESPACE" rollout status deploy/mcp-test --timeout=180s + ;; + *) + step "$i" "applying $f" + apply "$SCRIPT_DIR/$f" + ;; + esac +done +``` + +The installer: + +1. Confirms the current `kubectl` context interactively before applying. +2. Generates the postgres password, portal cookie secret, and dev API key in-place where the manifests still hold `REPLACE_ME` placeholders. +3. Rewrites `mcp-test-server.example.com` to `$INGRESS_HOST` in `50-mcp-test-ingress.yaml`. +4. Applies manifests in numeric order with `[N/M]` progress. +5. Waits for `pod/postgres-0` to be `Ready` (180s) before applying the mcp-test side. +6. Waits for the mcp-test Deployment rollout (180s). +7. Patches a `config-hash` / `secret-hash` annotation on the Deployment so a re-run after a config edit triggers a new rollout without `kubectl rollout restart`. +8. Prints the URLs and the dev API key. + +Variables it honors: + +
+ +
+
+INGRESS_HOST +
+defaultprompts +examplemcp.example.com +
+
+
+DNS name your ingress controller routes to mcp-test. Rewrites `50-mcp-test-ingress.yaml` in place. Also update `server.base_url` in `25-mcp-test-configmap.yaml` to match. +
+
+ +
+
+KUBE_CONTEXT +
+defaultcurrent +examplestaging +
+
+
+kubectl context to install into. The installer confirms it interactively before applying. +
+
+ +
+
+--dry-run +
+flagoff +
+
+
+Renders manifests, doesn't apply. Useful for previewing the placeholders the installer would fill. +
+
+ +
+ +## Smoke test + +Once the install finishes: + +```sh +HOST="https://${INGRESS_HOST}" +KEY="" + +# Liveness +curl -i "${HOST}/healthz" + +# Identity through the portal API +curl -i -H "X-API-Key: ${KEY}" "${HOST}/api/v1/portal/me" + +# Initialize an MCP session, then call a tool. +curl -i -X POST "${HOST}/" \ + -H "X-API-Key: ${KEY}" \ + -H "Accept: application/json, text/event-stream" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"smoketest","version":"0.1"},"capabilities":{}}}' +``` + +Then open `https://${INGRESS_HOST}/portal/` and paste the same `X-API-Key` value into the login screen. + +## Variations + +### External Postgres + +Drop `10-postgres-service.yaml`, `12-postgres-secret.yaml`, and `14-postgres-statefulset.yaml`. Replace the `DATABASE_URL` field in `20-mcp-test-secret.yaml` with your own connection string. If you use `sslmode=verify-full`, make sure the mcp-test pod trusts your CA chain (volume-mount it into `/etc/ssl/certs/` and set `SSL_CERT_DIR`). + +### OIDC instead of API keys + +In `25-mcp-test-configmap.yaml`, set `oidc.enabled: true` and fill `oidc.issuer` / `oidc.audience`. Add an `MCPTEST_OIDC_CLIENT_ID` / `MCPTEST_OIDC_CLIENT_SECRET` env mapping in `40-mcp-test-deployment.yaml` from a secret you create. The full identity model is documented in [Authentication](../configuration/auth.md). + +### Different ingress controller + +The annotations in `50-mcp-test-ingress.yaml` are nginx-specific. For traefik, aws-load-balancer-controller, or GKE ingress, swap them for the equivalent timeout / CORS / buffer-disable annotations and update `ingressClassName` to match. + +### Plain HTTP for local clusters + +Drop the `tls:` block in `50-mcp-test-ingress.yaml` and set `portal.cookie_secure: false` in the ConfigMap. Cookies won't survive a session over plain HTTP otherwise. + +### Higher replicas + +mcp-test is stateless once Postgres is reachable. Bump `replicas` on the Deployment; the StatefulSet stays at 1. The audit log uses Postgres advisory locks for the serial migration step on startup, so concurrent rollouts converge correctly. + +## Uninstall + +```sh +kubectl delete namespace mcp-test +``` + +The PVC bound by the Postgres StatefulSet is in the namespace and goes with it. Preserve audit data across reinstalls by adding a finalizer to the PVC before deleting the namespace, or by switching to an external Postgres. diff --git a/docs/operations/portal.md b/docs/operations/portal.md index add3510..04ebb61 100644 --- a/docs/operations/portal.md +++ b/docs/operations/portal.md @@ -10,18 +10,59 @@ binary via `go:embed all:dist`. There's no separate frontend server. ## Routes -| Route | What | -| --- | --- | -| `/portal/login` | Sign in with OIDC or paste an API key. | -| `/portal/` | Dashboard: 1-hour stats and recent activity. | -| `/portal/tools` | Tool catalog grouped by category. | -| `/portal/tools/` | Per-tool detail with Overview / Try It tabs. | -| `/portal/audit` | Filterable event browser with pagination, click-to-expand drawer, JSONB filters, SSE live tail. | -| `/portal/audit/compare` | Side-by-side structural diff of two events; staged from the drawer's Compare button (`?a=&b=`). | -| `/portal/keys` | DB-backed API key management. | -| `/portal/config` | Read-only JSON view of the running config (secrets redacted). | -| `/portal/wellknown` | Pretty-print of the protected-resource and authorization-server metadata that gateways read. | -| `/portal/about` | Description of the server, the categories, and the live `server.instructions`. | +
+ +
+
/portal/login
+
Sign in with OIDC or paste an API key.
+
+ +
+
/portal/
+
Dashboard: 1-hour stats and recent activity.
+
+ +
+
/portal/tools
+
Tool catalog grouped by category.
+
+ +
+
/portal/tools/<name>
+
Per-tool detail with Overview / Try It tabs.
+
+ +
+
/portal/audit
+
Filterable event browser with pagination, click-to-expand drawer, JSONB filters, SSE live tail.
+
+ +
+
/portal/audit/compare
+
Side-by-side structural diff of two events; staged from the drawer's Compare button (`?a=&b=`).
+
+ +
+
/portal/keys
+
DB-backed API key management.
+
+ +
+
/portal/config
+
Read-only JSON view of the running config (secrets redacted).
+
+ +
+
/portal/wellknown
+
Pretty-print of the protected-resource and authorization-server metadata that gateways read.
+
+ +
+
/portal/about
+
Description of the server, the categories, and the live `server.instructions`.
+
+ +
## Authentication diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 5636f25..dc80a4d 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -11,16 +11,89 @@ Every HTTP route mcp-test exposes, beyond the MCP transport itself. These are reachable without auth. -| Method | Path | Returns | -| --- | --- | --- | -| `GET` | `/healthz` | `200 OK` text body. Liveness. | -| `GET` | `/readyz` | `200 OK` while ready, `503` during shutdown drain. | -| `GET` | `/.well-known/oauth-protected-resource` | RFC 9728 metadata: resource identifier, authorization servers, supported bearer methods. | -| `GET` | `/.well-known/oauth-authorization-server` | Lightweight stub pointing at the upstream OIDC issuer's metadata URL. | -| `GET` | `/portal/` and subpaths | The embedded React SPA (or a placeholder if `make ui` wasn't run). | -| `GET` | `/portal/auth/login` | Starts the OIDC PKCE flow. Redirects to the IdP. | -| `GET` | `/portal/auth/callback` | OIDC redirect URI. Exchanges the auth code for tokens, validates the id_token, sets the session cookie. | -| `POST` | `/portal/auth/logout` | Clears the session cookie. | +
+ +
+
+GET +/healthz +
+
+`200 OK` text body. Liveness. +
+
+ +
+
+GET +/readyz +
+
+`200 OK` while ready, `503` during shutdown drain. +
+
+ +
+
+GET +/.well-known/oauth-protected-resource +
+
+RFC 9728 metadata: resource identifier, authorization servers, supported bearer methods. +
+
+ +
+
+GET +/.well-known/oauth-authorization-server +
+
+Lightweight stub pointing at the upstream OIDC issuer's metadata URL. +
+
+ +
+
+GET +/portal/ +
+
+The embedded React SPA (or a placeholder if `make ui` wasn't run). Subpaths route to the SPA's client-side router. +
+
+ +
+
+GET +/portal/auth/login +
+
+Starts the OIDC PKCE flow. Redirects to the IdP. +
+
+ +
+
+GET +/portal/auth/callback +
+
+OIDC redirect URI. Exchanges the auth code for tokens, validates the id_token, sets the session cookie. +
+
+ +
+
+POST +/portal/auth/logout +
+
+Clears the session cookie. +
+
+ +
## MCP endpoint @@ -28,11 +101,39 @@ Mounted at `/`. Browser GETs hitting `/` get a 302 to `/portal/`; MCP clients (which send `Accept: application/json` or `text/event-stream`) pass through. -| Method | Path | Returns | -| --- | --- | --- | -| `POST` | `/` | JSON-RPC requests (initialize, tools/list, tools/call, etc.). | -| `GET` | `/` | SSE stream for server-initiated messages (after a session is established). | -| `DELETE` | `/` | Tear down the MCP session. | +
+ +
+
+POST +/ +
+
+JSON-RPC requests (`initialize`, `tools/list`, `tools/call`, etc.). +
+
+ +
+
+GET +/ +
+
+SSE stream for server-initiated messages (after a session is established). +
+
+ +
+
+DELETE +/ +
+
+Tear down the MCP session. +
+
+ +
All require `X-API-Key` or `Authorization: Bearer ` unless `auth.allow_anonymous` is true. 401s carry a `WWW-Authenticate: Bearer @@ -42,34 +143,203 @@ realm="mcp-test", resource_metadata="..."` header. Behind the cookie or `X-API-Key` / `Authorization: Bearer`. -| Method | Path | Returns | -| --- | --- | --- | -| `GET` | `/api/v1/portal/me` | Resolved Identity object. | -| `GET` | `/api/v1/portal/server` | Build version + sanitized config (secrets redacted). | -| `GET` | `/api/v1/portal/instructions` | The `server.instructions` text the MCP server hands to clients at initialize time. | -| `GET` | `/api/v1/portal/tools` | List of `{name, group, description, input_schema}` for every registered tool. | -| `GET` | `/api/v1/portal/tools/{name}` | Same shape, single tool. | -| `GET` | `/api/v1/portal/audit/meta` | Filter contract surface: `{has_keys, json_sources, replay: {burst, refill_secs, sustained_min}, export: {max_rows}}`. Lets a UI build its filter editor against the server's source of truth without duplicating allow-lists. | -| `GET` | `/api/v1/portal/audit/events` | Paginated audit events. Query: `from`, `to` (RFC 3339), `tool`, `user`, `session`, `success`, `q`, `limit`, `offset`, plus the JSONB filters described below. | -| `GET` | `/api/v1/portal/audit/events/{id}` | Single event by id (UUID); includes the captured payload row when present. 400 on a non-UUID id, 404 when the event isn't recorded. | -| `POST` | `/api/v1/portal/audit/events/{id}/replay` | Re-invokes the captured tool call through an in-process MCP client. Writes a new audit event tagged `source=portal-replay` with `replayed_from` pointing at `{id}`. Per-identity rate limited (5 burst, 1 token / 12s); returns `429 Too Many Requests` with `Retry-After` when exhausted. Tokens are consumed *after* validation passes, so a click on a non-replayable row (no payload, redacted params, missing tool) returns `400` without burning the operator's budget. CSRF-gated via `X-Requested-With`. | -| `GET` | `/api/v1/portal/audit/export` | NDJSON stream of summary rows for a filter. `format=jsonl` (default) is the only supported format. Same filter surface as `/events`. Capped at 100,000 rows per request. | -| `GET` | `/api/v1/portal/audit/stream` | SSE live tail of new audit events. One `event: audit\ndata: ` per write; opening comment `: connected` confirms the connection; `: keepalive` every 30 seconds. Sets `X-Accel-Buffering: no` for nginx-fronted deployments. | -| `GET` | `/api/v1/portal/audit/timeseries` | Bucketed counts. Query: `from`, `to`, `bucket` (Go duration). | -| `GET` | `/api/v1/portal/audit/breakdown` | Group-by aggregations. Query: `by` (`tool`/`user`/`success`/`auth_type`). | -| `GET` | `/api/v1/portal/dashboard` | 1-hour stats + recent activity. | -| `GET` | `/api/v1/portal/wellknown` | Pretty rendering of the protected-resource metadata. | +
+ +
+
+GET +/api/v1/portal/me +
+
+Resolved Identity object. +
+
+ +
+
+GET +/api/v1/portal/server +
+
+Build version + sanitized config (secrets redacted). +
+
+ +
+
+GET +/api/v1/portal/instructions +
+
+The `server.instructions` text the MCP server hands to clients at initialize time. +
+
+ +
+
+GET +/api/v1/portal/tools +
+
+List of `{name, group, description, input_schema}` for every registered tool. +
+
+ +
+
+GET +/api/v1/portal/tools/{name} +
+
+Same shape, single tool. +
+
+ +
+
+GET +/api/v1/portal/audit/meta +
+
+Filter contract surface: `{has_keys, json_sources, replay: {burst, refill_secs, sustained_min}, export: {max_rows}}`. Lets a UI build its filter editor against the server's source of truth without duplicating allow-lists. +
+
+ +
+
+GET +/api/v1/portal/audit/events +
+
+Paginated audit events. Query: `from`, `to` (RFC 3339), `tool`, `user`, `session`, `success`, `q`, `limit`, `offset`, plus the JSONB filters described below. +
+
+ +
+
+GET +/api/v1/portal/audit/events/{id} +
+
+Single event by id (UUID); includes the captured payload row when present. `400` on a non-UUID id, `404` when the event isn't recorded. +
+
+ +
+
+POST +/api/v1/portal/audit/events/{id}/replay +
+
+Re-invokes the captured tool call through an in-process MCP client. Writes a new audit event tagged `source=portal-replay` with `replayed_from` pointing at `{id}`. Per-identity rate limited (5 burst, 1 token / 12s); returns `429 Too Many Requests` with `Retry-After` when exhausted. Tokens are consumed *after* validation passes, so a click on a non-replayable row (no payload, redacted params, missing tool) returns `400` without burning the operator's budget. CSRF-gated via `X-Requested-With`. +
+
+ +
+
+GET +/api/v1/portal/audit/export +
+
+NDJSON stream of summary rows for a filter. `format=jsonl` (default) is the only supported format. Same filter surface as `/events`. Capped at 100,000 rows per request. +
+
+ +
+
+GET +/api/v1/portal/audit/stream +
+
+SSE live tail of new audit events. One `event: audit\ndata: ` per write; opening comment `: connected` confirms the connection; `: keepalive` every 30 seconds. Sets `X-Accel-Buffering: no` for nginx-fronted deployments. +
+
+ +
+
+GET +/api/v1/portal/audit/timeseries +
+
+Bucketed counts. Query: `from`, `to`, `bucket` (Go duration). +
+
+ +
+
+GET +/api/v1/portal/audit/breakdown +
+
+Group-by aggregations. Query: `by` (`tool` / `user` / `success` / `auth_type`). +
+
+ +
+
+GET +/api/v1/portal/dashboard +
+
+1-hour stats + recent activity. +
+
+ +
+
+GET +/api/v1/portal/wellknown +
+
+Pretty rendering of the protected-resource metadata. +
+
+ +
### JSONB path filters `/audit/events` and `/audit/export` accept additional query parameters that compile to JSONB containment predicates against the `audit_payloads` sibling row. Filters are AND-combined with each other and with the indexed-column filters above. -| Syntax | Compiles to | Example | -| --- | --- | --- | -| `param.=` | `audit_payloads.request_params @> {"": }` | `?param.user.id=alice` | -| `response.=` | `audit_payloads.response_result @> {"": }` | `?response.isError=true` | -| `header.=` | `audit_payloads.request_headers @> {"": [""]}` (single-segment name only) | `?header.User-Agent=curl/8.0` | -| `has=` | `audit_payloads.` is `IS NOT NULL` and the column's text representation is not one of `'{}'`, `'[]'`, `'null'`, or `''` | `?has=response_error` | +
+ +
+
param.<dotted.path>=<value>
+
+Compiles to `audit_payloads.request_params @> {"": }`. + +Example: `?param.user.id=alice` +
+
+ +
+
response.<dotted.path>=<value>
+
+Compiles to `audit_payloads.response_result @> {"": }`. + +Example: `?response.isError=true` +
+
+ +
+
header.<name>=<value>
+
+Compiles to `audit_payloads.request_headers @> {"": [""]}` (single-segment name only). + +Example: `?header.User-Agent=curl/8.0` +
+
+ +
+
has=<column>
+
+`audit_payloads.` is `IS NOT NULL` and the column's text representation is not one of `'{}'`, `'[]'`, `'null'`, or `''`. + +Example: `?has=response_error` +
+
+ +
Allowed `has=` columns: `request_params`, `request_headers`, `response_result`, `response_error`, `notifications`, `replayed_from`. Anything else is silently dropped. Note: a JSONB column literally storing the JSON string `""` (rendered as `'""'::text`, four characters) does pass the filter; the exclusion list is canonical empty containers and an empty TEXT column, not "all logically empty values." @@ -88,12 +358,63 @@ Allowed `has=` columns: `request_params`, `request_headers`, `response_result`, Same auth requirements. Per the project decision, any authenticated caller can call these. -| Method | Path | Body | Returns | -| --- | --- | --- | --- | -| `POST` | `/api/v1/admin/keys` | `{ "name": "...", "description": "..." }` | `{ "key": {...}, "plaintext": "mt_..." }` (plaintext shown once). | -| `GET` | `/api/v1/admin/keys` | — | `{ "keys": [...] }` (no plaintext). | -| `DELETE` | `/api/v1/admin/keys/{name}` | — | `204 No Content`. | -| `POST` | `/api/v1/admin/tryit/{name}` | `{ "arguments": { ... } }` | The MCP `CallToolResult` (content + structuredContent + isError). | +
+ +
+
+POST +/api/v1/admin/keys +
+
+Mint a new DB-backed API key. +
+
Body
{ "name": "...", "description": "..." }
+
Returns
{ "key": {...}, "plaintext": "mt_..." } (plaintext shown once).
+
+
+
+ +
+
+GET +/api/v1/admin/keys +
+
+List all DB-backed API keys. +
+
Returns
{ "keys": [...] } (no plaintext).
+
+
+
+ +
+
+DELETE +/api/v1/admin/keys/{name} +
+
+Revoke a DB-backed API key by name. +
+
Returns
204 No Content.
+
+
+
+ +
+
+POST +/api/v1/admin/tryit/{name} +
+
+Invoke a registered tool through the in-process MCP client; the call lands in the audit log tagged `source=portal-tryit`. +
+
Body
{ "arguments": { ... } }
+
Returns
The MCP CallToolResult (content + structuredContent + isError).
+
+
+
+ +
`/api/v1/admin/tryit/{name}` invokes the named tool through an in-process MCP client connected to the running server. It writes its diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index e2ce811..6126eba 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -43,13 +43,40 @@ implemented; they're not relevant to a gateway test fixture.) The SDK handles every standard MCP method. The relevant ones for mcp-test: -| Method | Notes | -| --- | --- | -| `initialize` | Returns `serverInfo` (name, version), `capabilities`, `protocolVersion`, and `instructions` (see [Server Instructions](../configuration/instructions.md)). | -| `initialized` (notification) | Marks the session as ready. | -| `tools/list` | Returns every registered tool with its JSON schema (derived from the Go input struct). | -| `tools/call` | Invokes the named tool. Subject to the audit middleware. | -| `notifications/progress` | Server → client. Emitted by the `progress` tool when the caller supplies a `progressToken`. | +
+ +
+
initialize
+
Returns `serverInfo` (name, version), `capabilities`, `protocolVersion`, and `instructions` (see [Server Instructions](../configuration/instructions.md)).
+
+ +
+
+initialized +
kindnotification
+
+
Marks the session as ready.
+
+ +
+
tools/list
+
Returns every registered tool with its JSON schema (derived from the Go input struct).
+
+ +
+
tools/call
+
Invokes the named tool. Subject to the audit middleware.
+
+ +
+
+notifications/progress +
directionserver → client
+
+
Emitted by the `progress` tool when the caller supplies a `progressToken`.
+
+ +
## Tool schema diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index e1b73ff..cfeb8ee 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1960,3 +1960,433 @@ html.plex-lightbox-open, html.plex-lightbox-open body { overflow: hidden; } + +/* ───────────────────────────────────────────────────────────────────────── + API endpoint cards: the HTTP-API reference layout. + + Aesthetic: each endpoint is a horizontal card. Method pill on the left, + full mono path on a single no-wrap line so 50-char paths read as one + token instead of getting hyphenated mid-segment by a narrow Path + column. Description sits below in DM Sans body type, indented to align + with the path. The 3px left strip color-codes the verb in copper / + coral so the eye can scan a long list and pick out the POST / DELETE + rows without parsing each pill. + + Vocabulary borrowed from the carousel slides + lightbox shell so the + docs site reads as one designed system, not a stack of unrelated + components. + ───────────────────────────────────────────────────────────────────── */ + +/* Wrap the whole stack so the children share margins and we have a hook + for narrow-viewport tweaks. md_in_html requires `markdown` attr to + pass through; we render this in the source markdown. */ +.md-typeset .api-endpoints { + margin: 1.25rem 0 1.5rem; + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.md-typeset .api-endpoint { + display: grid; + grid-template-columns: 1fr; + gap: 0.45rem; + margin: 0; + padding: 0.85rem 1rem 0.9rem; + border: 1px solid var(--md-default-fg-color--lightest); + border-left: 3px solid color-mix(in srgb, var(--copper-500) 35%, transparent); + border-radius: var(--plex-radius-md); + background: var(--md-default-bg-color); + transition: border-color 200ms ease, box-shadow 200ms ease, + background-color 200ms ease; +} +.md-typeset .api-endpoint:hover { + border-color: var(--copper-500); + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 14px 28px -18px rgba(20, 184, 171, 0.22); + background: color-mix(in srgb, var(--md-default-bg-color) 96%, var(--copper-500)); +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint { + background: var(--midnight-900); + border-color: var(--midnight-800); + border-left-color: color-mix(in srgb, var(--copper-400) 45%, transparent); +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint:hover { + border-color: var(--copper-400); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.4), + 0 14px 28px -18px rgba(45, 211, 203, 0.28); + background: color-mix(in srgb, var(--midnight-900) 92%, var(--copper-700)); +} + +/* Per-method left-strip accent. The color choice keeps GET in the brand + teal (read, frequent), POST one step deeper (write), DELETE in a + muted coral (destructive but not traffic-light red). Same accents + inform the method pill below. */ +.md-typeset .api-endpoint--get { border-left-color: var(--copper-400); } +.md-typeset .api-endpoint--post { border-left-color: var(--copper-700); } +.md-typeset .api-endpoint--delete { border-left-color: #b04829; } +.md-typeset .api-endpoint--put { border-left-color: var(--copper-600); } +.md-typeset .api-endpoint--patch { border-left-color: var(--copper-600); } + +[data-md-color-scheme="slate"] .md-typeset .api-endpoint--get { border-left-color: var(--copper-300); } +[data-md-color-scheme="slate"] .md-typeset .api-endpoint--post { border-left-color: var(--copper-400); } +[data-md-color-scheme="slate"] .md-typeset .api-endpoint--delete { border-left-color: #e8836a; } +[data-md-color-scheme="slate"] .md-typeset .api-endpoint--put { border-left-color: var(--copper-300); } +[data-md-color-scheme="slate"] .md-typeset .api-endpoint--patch { border-left-color: var(--copper-300); } + +/* The header row: method pill + path on one line. flex-nowrap so the + path takes the remaining width and overflows horizontally rather + than dropping below the pill. */ +.md-typeset .api-endpoint__head { + display: flex; + align-items: center; + gap: 0.6rem; + min-width: 0; +} + +.md-typeset .api-endpoint__method { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3.6em; + padding: 0.18rem 0.6rem; + border-radius: 999px; + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + font-feature-settings: "tnum" 1; + border: 1px solid transparent; + /* Override Material's default badge / code styling that .md-typeset + might apply via cascade. */ + background: transparent; + color: var(--md-default-fg-color); +} + +.md-typeset .api-endpoint__method--get { + background: color-mix(in srgb, var(--copper-500) 12%, transparent); + color: var(--copper-800); + border-color: color-mix(in srgb, var(--copper-500) 35%, transparent); +} +.md-typeset .api-endpoint__method--post { + background: color-mix(in srgb, var(--copper-700) 16%, transparent); + color: var(--copper-900); + border-color: color-mix(in srgb, var(--copper-700) 45%, transparent); +} +.md-typeset .api-endpoint__method--delete { + background: rgba(176, 72, 41, 0.12); + color: #8b3a23; + border-color: rgba(176, 72, 41, 0.40); +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__method--get { + background: color-mix(in srgb, var(--copper-400) 18%, transparent); + color: var(--copper-200); + border-color: color-mix(in srgb, var(--copper-400) 50%, transparent); +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__method--post { + background: color-mix(in srgb, var(--copper-500) 22%, transparent); + color: var(--copper-100); + border-color: color-mix(in srgb, var(--copper-500) 55%, transparent); +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__method--delete { + background: rgba(232, 131, 106, 0.18); + color: #f0a896; + border-color: rgba(232, 131, 106, 0.45); +} + +/* The path. Critical no-wrap behavior: white-space:nowrap + + overflow-x:auto so a 60-char path is readable as a single token, + horizontally scrollable on narrow viewports rather than broken. */ +.md-typeset .api-endpoint__path { + flex: 1 1 auto; + min-width: 0; + font-family: var(--md-code-font, ui-monospace, "SF Mono", "Menlo", monospace); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: -0.005em; + color: var(--md-default-fg-color); + background: transparent; + padding: 0; + border: 0; + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: var(--copper-500) transparent; +} +.md-typeset .api-endpoint__path::-webkit-scrollbar { height: 4px; } +.md-typeset .api-endpoint__path::-webkit-scrollbar-track { background: transparent; } +.md-typeset .api-endpoint__path::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--copper-500) 60%, transparent); + border-radius: 2px; +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__path { color: #fff; } + +/* Body: description text in the same body type as the rest of the site, + indented to align under the path's left edge so the eye doesn't have + to reset. Keeps inline code, emphasis, and links rendering naturally + via md_in_html. */ +.md-typeset .api-endpoint__body { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.875rem; + line-height: 1.6; + color: var(--md-default-fg-color--light); + /* Indent under the method pill so the description aligns with where + the path text starts (3.6em min-width pill + 0.6rem gap ≈ 4.5rem). */ + padding-left: calc(3.6em + 0.6rem); + margin: 0; +} +.md-typeset .api-endpoint__body > :first-child { margin-top: 0; } +.md-typeset .api-endpoint__body > :last-child { margin-bottom: 0; } +.md-typeset .api-endpoint__body p { + margin: 0 0 0.4rem; +} +.md-typeset .api-endpoint__body p:last-child { margin-bottom: 0; } +.md-typeset .api-endpoint__body code { + /* Material's default inline-code chrome stays; just ensure it doesn't + collide visually with the path mono. */ + font-size: 0.82rem; +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__body { color: var(--midnight-200); } + +/* Inline meta block for endpoints with extra structured info (Body + + Returns columns from the old Admin table). Renders as a small + key/value pair line under the description. */ +.md-typeset .api-endpoint__meta { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem 0.85rem; + margin-top: 0.45rem; + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.82rem; + line-height: 1.55; +} +.md-typeset .api-endpoint__meta dt { + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--copper-700); + padding-top: 0.18rem; + margin: 0; +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__meta dt { color: var(--copper-300); } +.md-typeset .api-endpoint__meta dd { + margin: 0; + color: var(--md-default-fg-color--light); + min-width: 0; +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__meta dd { color: var(--midnight-200); } + +/* Narrow-viewport: drop the body indent so the description gets the + full row width. The horizontal-scroll path stays the same. */ +@media (max-width: 720px) { + .md-typeset .api-endpoint__body { padding-left: 0; } + .md-typeset .api-endpoint__head { gap: 0.5rem; } +} + +@media (prefers-reduced-motion: reduce) { + .md-typeset .api-endpoint { transition: none; } +} + +/* ───────────────────────────────────────────────────────────────────────── + Config-key cards: same vocabulary as the API endpoint cards, used + for the YAML reference and the env-var listings. The key (e.g. + `server.streamable.session_timeout`, `MCPTEST_OIDC_CLIENT_SECRET`) + gets the top line on its own so a 40-char dotted path doesn't + hyphenate mid-segment into a narrow Key column. Type / default chips + on the right of the head; notes prose below. + ───────────────────────────────────────────────────────────────────── */ + +.md-typeset .config-keys, +.md-typeset .def-cards { + margin: 1.25rem 0 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.md-typeset .config-key, +.md-typeset .def-card { + display: grid; + grid-template-columns: 1fr; + gap: 0.45rem; + margin: 0; + padding: 0.85rem 1rem 0.9rem; + border: 1px solid var(--md-default-fg-color--lightest); + border-left: 3px solid color-mix(in srgb, var(--copper-500) 35%, transparent); + border-radius: var(--plex-radius-md); + background: var(--md-default-bg-color); + transition: border-color 200ms ease, box-shadow 200ms ease, + background-color 200ms ease; +} +.md-typeset .config-key:hover, +.md-typeset .def-card:hover { + border-color: var(--copper-500); + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 14px 28px -18px rgba(20, 184, 171, 0.22); + background: color-mix(in srgb, var(--md-default-bg-color) 96%, var(--copper-500)); +} +[data-md-color-scheme="slate"] .md-typeset .config-key, +[data-md-color-scheme="slate"] .md-typeset .def-card { + background: var(--midnight-900); + border-color: var(--midnight-800); + border-left-color: color-mix(in srgb, var(--copper-400) 45%, transparent); +} +[data-md-color-scheme="slate"] .md-typeset .config-key:hover, +[data-md-color-scheme="slate"] .md-typeset .def-card:hover { + border-color: var(--copper-400); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.4), + 0 14px 28px -18px rgba(45, 211, 203, 0.28); + background: color-mix(in srgb, var(--midnight-900) 92%, var(--copper-700)); +} + +/* Required vs optional left strips. Most keys are optional; mark a + handful as required (e.g. `oidc.issuer` when oidc.enabled). */ +.md-typeset .config-key--required { border-left-color: var(--copper-700); } +[data-md-color-scheme="slate"] .md-typeset .config-key--required { border-left-color: var(--copper-300); } + +/* The head is a fixed two-row stack: key on row 1, chips on row 2. + Forcing a column (rather than flex-wrap) means the chips sit in the + same place on every card regardless of the key's length, which is + what the eye expects when scanning a long list. */ +.md-typeset .config-key__head, +.md-typeset .def-card__head { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.45rem; + min-width: 0; +} + +/* Key name: bold mono, copper accent. Allowed to wrap at any character + when a single segment exceeds the row, but in practice every key + here fits on one line at the standard mkdocs content width. */ +.md-typeset code.config-key__name, +.md-typeset code.def-card__name { + display: block; + width: 100%; + font-family: var(--md-code-font, ui-monospace, "SF Mono", "Menlo", monospace); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.005em; + color: var(--copper-800); + background: transparent; + padding: 0; + border: 0; + word-break: break-word; + overflow-wrap: anywhere; + white-space: normal; +} +[data-md-color-scheme="slate"] .md-typeset code.config-key__name, +[data-md-color-scheme="slate"] .md-typeset code.def-card__name { color: var(--copper-200); } + +/* Chip row: always on its own line below the key so type/default + anchor in the same horizontal slot on every card. */ +.md-typeset .config-key__chips, +.md-typeset .def-card__chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.md-typeset .config-key__chip, +.md-typeset .def-card__chip { + display: inline-flex; + align-items: baseline; + gap: 0.35rem; + padding: 0.18rem 0.55rem 0.2rem; + border-radius: 999px; + border: 1px solid var(--md-default-fg-color--lightest); + background: color-mix(in srgb, var(--md-default-bg-color) 92%, var(--copper-500)); + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.72rem; + line-height: 1.2; + color: var(--md-default-fg-color); +} +[data-md-color-scheme="slate"] .md-typeset .config-key__chip, +[data-md-color-scheme="slate"] .md-typeset .def-card__chip { + background: color-mix(in srgb, var(--midnight-900) 88%, var(--copper-700)); + border-color: var(--midnight-800); + color: #fff; +} + +.md-typeset .config-key__chip-label, +.md-typeset .def-card__chip-label { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--copper-700); +} +[data-md-color-scheme="slate"] .md-typeset .config-key__chip-label, +[data-md-color-scheme="slate"] .md-typeset .def-card__chip-label { color: var(--copper-300); } + +.md-typeset .config-key__chip-value, +.md-typeset .config-key__chip code, +.md-typeset .def-card__chip-value, +.md-typeset .def-card__chip code { + font-family: var(--md-code-font, ui-monospace, "SF Mono", "Menlo", monospace); + font-size: 0.78rem; + font-weight: 500; + letter-spacing: -0.005em; + color: var(--md-default-fg-color); + background: transparent; + padding: 0; + border: 0; +} +[data-md-color-scheme="slate"] .md-typeset .config-key__chip-value, +[data-md-color-scheme="slate"] .md-typeset .config-key__chip code, +[data-md-color-scheme="slate"] .md-typeset .def-card__chip-value, +[data-md-color-scheme="slate"] .md-typeset .def-card__chip code { color: #fff; } + +/* `default` chip carries an emphasized copper border so the eye finds + the default value in a long list. */ +.md-typeset .config-key__chip--default { + border-color: color-mix(in srgb, var(--copper-500) 50%, transparent); + background: color-mix(in srgb, var(--md-default-bg-color) 88%, var(--copper-500)); +} +[data-md-color-scheme="slate"] .md-typeset .config-key__chip--default { + border-color: color-mix(in srgb, var(--copper-400) 55%, transparent); + background: color-mix(in srgb, var(--midnight-900) 78%, var(--copper-700)); +} + +/* `required` chip: distinct rust tone so missing-required errors map + visually to the keys that produce them. */ +.md-typeset .config-key__chip--required { + border-color: rgba(176, 72, 41, 0.50); + background: rgba(176, 72, 41, 0.10); +} +.md-typeset .config-key__chip--required .config-key__chip-label { color: #8b3a23; } +[data-md-color-scheme="slate"] .md-typeset .config-key__chip--required { + border-color: rgba(232, 131, 106, 0.55); + background: rgba(232, 131, 106, 0.16); +} +[data-md-color-scheme="slate"] .md-typeset .config-key__chip--required .config-key__chip-label { color: #f0a896; } + +.md-typeset .config-key__body, +.md-typeset .def-card__body { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.875rem; + line-height: 1.6; + color: var(--md-default-fg-color--light); + margin: 0; +} +.md-typeset .config-key__body > :first-child { margin-top: 0; } +.md-typeset .config-key__body > :last-child { margin-bottom: 0; } +.md-typeset .config-key__body p { margin: 0 0 0.4rem; } +.md-typeset .config-key__body p:last-child { margin-bottom: 0; } +.md-typeset .config-key__body code { font-size: 0.82rem; } +[data-md-color-scheme="slate"] .md-typeset .config-key__body, +[data-md-color-scheme="slate"] .md-typeset .def-card__body { color: var(--midnight-200); } + +@media (prefers-reduced-motion: reduce) { + .md-typeset .config-key, + .md-typeset .def-card { transition: none; } +} diff --git a/examples/kubernetes/00-namespace.yaml b/examples/kubernetes/00-namespace.yaml new file mode 100644 index 0000000..b1eec31 --- /dev/null +++ b/examples/kubernetes/00-namespace.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mcp-test + labels: + app.kubernetes.io/name: mcp-test + app.kubernetes.io/part-of: mcp-test + app.kubernetes.io/managed-by: manifest diff --git a/examples/kubernetes/10-postgres-service.yaml b/examples/kubernetes/10-postgres-service.yaml new file mode 100644 index 0000000..55e473f --- /dev/null +++ b/examples/kubernetes/10-postgres-service.yaml @@ -0,0 +1,25 @@ +# Headless ClusterIP for the Postgres StatefulSet. mcp-test reaches the +# DB via DNS name `postgres.mcp-test.svc.cluster.local:5432`. Headless +# (clusterIP: None) gives the StatefulSet a stable per-pod DNS record +# (`postgres-0.postgres...`); the connection string in 20-mcp-test-secret +# uses the unqualified `postgres` host because both pods share a +# namespace and DNS resolves it. +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: mcp-test + labels: + app: postgres + app.kubernetes.io/name: postgres + app.kubernetes.io/component: db + app.kubernetes.io/part-of: mcp-test +spec: + clusterIP: None + selector: + app: postgres + ports: + - name: postgres + port: 5432 + targetPort: postgres + protocol: TCP diff --git a/examples/kubernetes/12-postgres-secret.yaml b/examples/kubernetes/12-postgres-secret.yaml new file mode 100644 index 0000000..f922d1e --- /dev/null +++ b/examples/kubernetes/12-postgres-secret.yaml @@ -0,0 +1,23 @@ +# Postgres credentials. Used by both the postgres container (POSTGRES_* +# env) and built into the mcp-test connection string in +# 20-mcp-test-secret. The install.sh script will fill the empty fields +# below with a random password on first run; pre-filling them here lets +# you commit a fixed password to a private fork if you want stable creds +# across reinstalls. +apiVersion: v1 +kind: Secret +metadata: + name: postgres + namespace: mcp-test + labels: + app: postgres + app.kubernetes.io/name: postgres + app.kubernetes.io/component: db + app.kubernetes.io/part-of: mcp-test +type: Opaque +stringData: + POSTGRES_DB: mcp_test + POSTGRES_USER: mcp + # REPLACE: 24+ char random string (e.g. `openssl rand -base64 24`). + # install.sh writes one for you on a fresh install. + POSTGRES_PASSWORD: "REPLACE_ME_POSTGRES_PASSWORD" diff --git a/examples/kubernetes/14-postgres-statefulset.yaml b/examples/kubernetes/14-postgres-statefulset.yaml new file mode 100644 index 0000000..f2415fc --- /dev/null +++ b/examples/kubernetes/14-postgres-statefulset.yaml @@ -0,0 +1,92 @@ +# Single-replica Postgres for the mcp-test audit log + DB-backed API +# keys. 5Gi PVC sized for ~50M audit_events rows at the default +# 30-day retention with payloads on. Bump the storage request before +# scale-out demos. +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: mcp-test + labels: + app: postgres + app.kubernetes.io/name: postgres + app.kubernetes.io/component: db + app.kubernetes.io/part-of: mcp-test +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + app.kubernetes.io/name: postgres + app.kubernetes.io/component: db + app.kubernetes.io/part-of: mcp-test + spec: + terminationGracePeriodSeconds: 30 + securityContext: + runAsUser: 70 # postgres uid in the alpine image + runAsGroup: 70 + fsGroup: 70 + containers: + - name: postgres + image: postgres:17-alpine + imagePullPolicy: IfNotPresent + ports: + - name: postgres + containerPort: 5432 + protocol: TCP + env: + - name: POSTGRES_DB + valueFrom: + secretKeyRef: + name: postgres + key: POSTGRES_DB + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: postgres + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgres + key: POSTGRES_PASSWORD + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: ["pg_isready", "-U", "mcp", "-d", "mcp_test"] + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + exec: + command: ["pg_isready", "-U", "mcp", "-d", "mcp_test"] + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 5Gi + # storageClassName: REPLACE_ME (omit to use cluster default) diff --git a/examples/kubernetes/20-mcp-test-secret.yaml b/examples/kubernetes/20-mcp-test-secret.yaml new file mode 100644 index 0000000..f952f13 --- /dev/null +++ b/examples/kubernetes/20-mcp-test-secret.yaml @@ -0,0 +1,34 @@ +# Application secrets for mcp-test: +# - DATABASE_URL: full Postgres connection string. Built from the +# postgres credentials so this manifest is self-contained for +# installs that bundle Postgres in-cluster (the default). For an +# external Postgres, replace this with your own URL. +# - COOKIE_SECRET: HMAC key for the portal's session cookie. ANY +# 32+ random bytes; rotating invalidates all live sessions. +# - DEV_KEY: bootstrap API key. Sent as `X-API-Key: ` to MCP +# and admin endpoints; matches the `api_keys.file` entry in the +# ConfigMap. +# +# install.sh fills the empty fields below with `openssl rand` output +# on a fresh install. The DATABASE_URL also references the postgres +# password; the script regenerates the URL whenever it touches the +# postgres secret to keep them in sync. +apiVersion: v1 +kind: Secret +metadata: + name: mcp-test + namespace: mcp-test + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test +type: Opaque +stringData: + # postgres://USER:PASSWORD@HOST:5432/DB?sslmode=disable + DATABASE_URL: "REPLACE_ME_DATABASE_URL" + # 32 bytes base64 (e.g. `openssl rand -base64 32`) + COOKIE_SECRET: "REPLACE_ME_COOKIE_SECRET" + # API key sent as `X-API-Key`. Use a long random value or the + # mcptest_-prefixed format the dev tooling produces. + DEV_KEY: "REPLACE_ME_DEV_API_KEY" diff --git a/examples/kubernetes/25-mcp-test-configmap.yaml b/examples/kubernetes/25-mcp-test-configmap.yaml new file mode 100644 index 0000000..3927792 --- /dev/null +++ b/examples/kubernetes/25-mcp-test-configmap.yaml @@ -0,0 +1,91 @@ +# mcp-test runtime config. The container reads this from +# /etc/mcp-test/mcp-test.yaml; see 40-mcp-test-deployment.yaml. +# +# Defaults below ship a working installation with: +# - API-key auth only (OIDC disabled). Operators authenticate by +# pasting the dev key on the portal login screen, and MCP clients +# send `X-API-Key: `. To turn on OIDC instead, set +# `oidc.enabled: true` and supply issuer + client credentials via +# additional secret keys + env mappings in the Deployment. +# - Audit log fully on, payloads + headers captured, 30-day retention. +# - Portal at /portal/, MCP at /, behind the same Ingress. +apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-test + namespace: mcp-test + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test +data: + mcp-test.yaml: | + server: + name: mcp-test + address: ":8080" + # REPLACE: must match the public URL clients reach this server at. + # Used in OIDC redirects (when enabled) and RFC 9728 metadata. + base_url: "https://mcp-test-server.example.com" + read_header_timeout: 10s + shutdown: + grace_period: 25s + pre_shutdown_delay: 2s + tls: + enabled: false + streamable: + session_timeout: 30m + stateless: false + json_response: false + + oidc: + enabled: false + # When you flip enabled to true, supply these via env from a + # secret you add yourself. The defaults below match the dev + # Keycloak; replace with your real issuer. + # issuer: "https://your-idp.example.com/realms/mcp-test" + # audience: "mcp-test" + # client_id: "${MCPTEST_OIDC_CLIENT_ID}" + # client_secret: "${MCPTEST_OIDC_CLIENT_SECRET}" + + api_keys: + file: + - name: bootstrap + key: "${MCPTEST_DEV_KEY}" + description: "Bootstrap API key for portal admin and MCP smoke tests" + db: + enabled: true + + auth: + allow_anonymous: false + require_for_mcp: true + require_for_portal: true + + database: + url: "${MCPTEST_DATABASE_URL}" + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 1h + + audit: + enabled: true + capture_payloads: true + capture_headers: true + max_payload_bytes: 65536 + retention_days: 30 + redact_keys: [password, token, secret, authorization, api_key, credentials, cookie] + + portal: + enabled: true + cookie_name: mcp_test_session + cookie_secret: "${MCPTEST_COOKIE_SECRET}" + # cookie_secure must be false when serving over plain HTTP locally; + # leave true for the Ingress + cert-manager flow this example uses. + cookie_secure: true + oidc_redirect_path: /portal/auth/callback + + tools: + identity: { enabled: true } + data: { enabled: true } + failure: { enabled: true } + streaming: { enabled: true } diff --git a/examples/kubernetes/30-mcp-test-service.yaml b/examples/kubernetes/30-mcp-test-service.yaml new file mode 100644 index 0000000..b9178e5 --- /dev/null +++ b/examples/kubernetes/30-mcp-test-service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: mcp-test + namespace: mcp-test + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test +spec: + type: ClusterIP + selector: + app: mcp-test + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: http diff --git a/examples/kubernetes/40-mcp-test-deployment.yaml b/examples/kubernetes/40-mcp-test-deployment.yaml new file mode 100644 index 0000000..ed66465 --- /dev/null +++ b/examples/kubernetes/40-mcp-test-deployment.yaml @@ -0,0 +1,102 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mcp-test + namespace: mcp-test + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test +spec: + replicas: 1 + revisionHistoryLimit: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app: mcp-test + template: + metadata: + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test + annotations: + # Force a rollout when ConfigMap or Secret content changes. + # install.sh patches these to a SHA256 of the on-disk file + # contents; replace manually with `kubectl rollout restart` if + # you edit the manifests outside the script. + config-hash: "0" + secret-hash: "0" + spec: + terminationGracePeriodSeconds: 30 + containers: + - name: mcp-test + image: ghcr.io/plexara/mcp-test:v1.2.0 + imagePullPolicy: IfNotPresent + args: + - --config + - /etc/mcp-test/mcp-test.yaml + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: MCPTEST_DATABASE_URL + valueFrom: + secretKeyRef: + name: mcp-test + key: DATABASE_URL + - name: MCPTEST_COOKIE_SECRET + valueFrom: + secretKeyRef: + name: mcp-test + key: COOKIE_SECRET + - name: MCPTEST_DEV_KEY + valueFrom: + secretKeyRef: + name: mcp-test + key: DEV_KEY + volumeMounts: + - name: config + mountPath: /etc/mcp-test + readOnly: true + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readyz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + runAsNonRoot: true + runAsUser: 65532 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumes: + - name: config + configMap: + name: mcp-test diff --git a/examples/kubernetes/50-mcp-test-ingress.yaml b/examples/kubernetes/50-mcp-test-ingress.yaml new file mode 100644 index 0000000..d93ab8c --- /dev/null +++ b/examples/kubernetes/50-mcp-test-ingress.yaml @@ -0,0 +1,53 @@ +# Ingress for mcp-test. Assumes: +# - ingress-nginx as the controller (annotations are nginx-specific). +# Adjust for traefik / aws-load-balancer-controller / etc. +# - cert-manager with a `letsencrypt-production` ClusterIssuer for +# automated TLS. Remove the `cert-manager.io/cluster-issuer` +# annotation and the `tls:` block if you handle TLS at a different +# layer (terminating LB, service mesh, etc.). +# +# REPLACE: `mcp-test-server.example.com` (3 occurrences below) with the +# DNS name your Ingress controller routes to this service. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mcp-test + namespace: mcp-test + labels: + app: mcp-test + app.kubernetes.io/name: mcp-test + app.kubernetes.io/component: server + app.kubernetes.io/part-of: mcp-test + annotations: + cert-manager.io/cluster-issuer: letsencrypt-production + # CORS: lets a browser-side MCP gateway call this server. Tighten + # the allow-origin list to the gateway hosts you actually use. + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, DELETE, OPTIONS" + nginx.ingress.kubernetes.io/cors-allow-origin: "*" + nginx.ingress.kubernetes.io/cors-allow-credentials: "true" + # Streamable HTTP / SSE: long-lived connections for live tail and + # MCP server-initiated messages. Disable buffering so the client + # sees frames as they're written. + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/proxy-buffer-size: "8k" + nginx.ingress.kubernetes.io/service-upstream: "true" +spec: + ingressClassName: nginx + rules: + - host: mcp-test-server.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: mcp-test + port: + number: 8080 + tls: + - hosts: + - mcp-test-server.example.com + secretName: mcp-test-tls diff --git a/examples/kubernetes/README.md b/examples/kubernetes/README.md new file mode 100644 index 0000000..82bce4c --- /dev/null +++ b/examples/kubernetes/README.md @@ -0,0 +1,96 @@ +# Kubernetes example + +A self-contained installation of `mcp-test` and a single-replica Postgres on any Kubernetes cluster with an `nginx` ingress controller and a working `cert-manager` `letsencrypt-production` ClusterIssuer. One file per resource, numeric ordering, applied with `install.sh` (or any GitOps tool that consumes plain `kubectl apply -f` input). + +## What lands + +| File | Resource | +| --- | --- | +| `00-namespace.yaml` | `Namespace mcp-test` | +| `10-postgres-service.yaml` | `Service postgres` (headless ClusterIP) | +| `12-postgres-secret.yaml` | `Secret postgres` (DB / user / password) | +| `14-postgres-statefulset.yaml` | `StatefulSet postgres` (single replica, 5Gi PVC, `postgres:17-alpine`) | +| `20-mcp-test-secret.yaml` | `Secret mcp-test` (`DATABASE_URL` / `COOKIE_SECRET` / `DEV_KEY`) | +| `25-mcp-test-configmap.yaml`| `ConfigMap mcp-test` (full `mcp-test.yaml`; OIDC off, API-key auth, audit on) | +| `30-mcp-test-service.yaml` | `Service mcp-test` (ClusterIP, port 8080) | +| `40-mcp-test-deployment.yaml`| `Deployment mcp-test` (`ghcr.io/plexara/mcp-test:v1.2.0`) | +| `50-mcp-test-ingress.yaml` | `Ingress mcp-test` (TLS via cert-manager, CORS for browser MCP gateways) | + +`install.sh` wires the secrets to each other (the postgres password is reused inside the `DATABASE_URL`) and patches a `config-hash` / `secret-hash` annotation on the Deployment so a re-run after a config edit rolls the pod automatically. + +## Placeholders + +Three things you replace before this is yours: + +1. **Ingress host.** `mcp-test-server.example.com` appears once in `50-mcp-test-ingress.yaml` (and in `25-mcp-test-configmap.yaml` as `server.base_url`). `install.sh` will prompt for `INGRESS_HOST` if you haven't set it; it rewrites the ingress in-place. Update `base_url` in the ConfigMap by hand if you change it. +2. **Postgres password.** `REPLACE_ME_POSTGRES_PASSWORD` in `12-postgres-secret.yaml`. `install.sh` writes a random 28-char value on first run. +3. **Application secrets.** `REPLACE_ME_COOKIE_SECRET`, `REPLACE_ME_DEV_API_KEY`, `REPLACE_ME_DATABASE_URL` in `20-mcp-test-secret.yaml`. `install.sh` generates and prints the dev API key on first run. + +Everything else is functional defaults. + +## Install + +```sh +# from this directory +./install.sh +``` + +The script will: + +1. Confirm the current `kubectl` context. +2. Generate the secret values in-place where they're still placeholders. +3. Apply manifests in numeric order with `[N/M]` progress. +4. Wait for `pod/postgres-0` to be Ready before applying the mcp-test side. +5. Wait for the mcp-test Deployment rollout. +6. Print the URLs and the dev API key. + +Variables it honors: + +| Var | Default | Purpose | +| --- | --- | --- | +| `INGRESS_HOST` | prompts | DNS name your ingress controller routes to mcp-test (e.g. `mcp.example.com`). | +| `KUBE_CONTEXT` | current | kubectl context to install into. | +| `--dry-run` | off | Renders manifests, doesn't apply. | + +## Smoke test + +Once the install finishes: + +```sh +HOST="https://${INGRESS_HOST:-mcp-test-server.example.com}" +KEY="" + +curl -i "${HOST}/healthz" + +curl -i -H "X-API-Key: ${KEY}" \ + "${HOST}/api/v1/portal/me" + +# Initialize the MCP session, then call a tool. +curl -i -X POST "${HOST}/" \ + -H "X-API-Key: ${KEY}" \ + -H "Accept: application/json, text/event-stream" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"smoketest","version":"0.1"},"capabilities":{}}}' +``` + +Then open the portal at `https://${INGRESS_HOST}/portal/` and paste the same `X-API-Key` value into the login screen. + +## Variations + +**External Postgres.** Drop `10-postgres-service.yaml`, `12-postgres-secret.yaml`, `14-postgres-statefulset.yaml`, and replace the `DATABASE_URL` field in `20-mcp-test-secret.yaml` with your own connection string. Make sure your CA chain is trusted by the mcp-test pod if you use `sslmode=verify-full`. + +**OIDC instead of API keys.** In `25-mcp-test-configmap.yaml`, set `oidc.enabled: true`, fill `oidc.issuer` and `oidc.audience`, and add an `MCPTEST_OIDC_CLIENT_ID` / `MCPTEST_OIDC_CLIENT_SECRET` env mapping in the Deployment from a secret you create. The `mcp-test` repo's OIDC docs at `docs/configuration/auth.md` walk the full flow. + +**Different ingress controller.** The annotations in `50-mcp-test-ingress.yaml` are nginx-specific. For traefik / aws-load-balancer-controller / gke ingress, swap them for the equivalent timeout / CORS / buffer-disable annotations. The `ingressClassName` line also needs to match. + +**Plain HTTP for local clusters.** Drop the `tls:` block in the ingress and set `portal.cookie_secure: false` in the ConfigMap. Cookies won't survive a session otherwise. + +**Higher replicas.** mcp-test is stateless once Postgres is reachable. Bump `replicas` on the Deployment; the StatefulSet stays at 1. The audit log uses Postgres advisory locks for the serial migration step on startup, so concurrent rollouts converge correctly. + +## Uninstall + +```sh +kubectl delete namespace mcp-test +``` + +The PVC bound by the Postgres StatefulSet is in the namespace and goes with it. To preserve audit data across reinstalls, change `volumeClaimTemplates.metadata.annotations` to add `helm.sh/resource-policy: keep` or apply a finalizer; out of scope for this example. diff --git a/examples/kubernetes/install.sh b/examples/kubernetes/install.sh new file mode 100755 index 0000000..d53523a --- /dev/null +++ b/examples/kubernetes/install.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# +# install.sh: apply the mcp-test example to a Kubernetes cluster with +# progress output. Idempotent: re-running safely re-applies every +# manifest and patches a fresh config-hash / secret-hash so a config +# change rolls the Deployment. +# +# What it does: +# 1. Sanity checks (kubectl reachable, current context confirmed). +# 2. Generates secrets in-place if the manifests still hold REPLACE_ME +# placeholders (postgres password, mcp-test cookie secret, dev API +# key, and the full DATABASE_URL built from the postgres password). +# 3. Replaces `mcp-test-server.example.com` with $INGRESS_HOST in the +# Ingress manifest. +# 4. Applies the manifests in numeric order with [N/M] progress. +# 5. Waits for Postgres readiness before applying mcp-test, then waits +# for the Deployment rollout. +# 6. Prints next steps with the URLs and the dev API key. +# +# Usage: +# ./install.sh # interactive: confirms context, prompts for INGRESS_HOST +# INGRESS_HOST=mcp.example.com ./install.sh +# INGRESS_HOST=mcp.example.com KUBE_CONTEXT=staging ./install.sh +# ./install.sh --dry-run # render manifests, don't apply +# +# This script is intentionally simple bash; no helm, no kustomize. The +# manifests are valid `kubectl apply -f` input on their own if you'd +# rather wire them into your existing GitOps tool. + +set -euo pipefail + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +NAMESPACE="mcp-test" +DRY_RUN="false" + +# --------------------------------------------------------------------- +# tiny progress helpers +# --------------------------------------------------------------------- + +c_reset='\033[0m'; c_bold='\033[1m'; c_dim='\033[2m' +c_ok='\033[32m'; c_warn='\033[33m'; c_err='\033[31m'; c_step='\033[36m' + +step() { printf "${c_step}[%d/%d]${c_reset} %b\n" "$1" "$TOTAL_STEPS" "$2"; } +ok() { printf " ${c_ok}✓${c_reset} %b\n" "$1"; } +warn() { printf " ${c_warn}!${c_reset} %b\n" "$1"; } +fail() { printf " ${c_err}✗${c_reset} %b\n" "$1" >&2; exit 1; } +hdr() { printf "\n${c_bold}%s${c_reset}\n" "$1"; } +muted() { printf "${c_dim}%s${c_reset}\n" "$1"; } + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN="true" ;; + -h|--help) + sed -n '/^# install.sh/,/^# This script is/p' "$0" | sed 's/^# //; s/^#//' + exit 0 ;; + esac +done + +# --------------------------------------------------------------------- +# preflight +# --------------------------------------------------------------------- + +hdr "mcp-test Kubernetes installer" + +command -v kubectl >/dev/null || fail "kubectl is not on PATH" +command -v openssl >/dev/null || fail "openssl is not on PATH (used for secret generation)" + +if [[ -n "${KUBE_CONTEXT:-}" ]]; then + KCTX_ARGS=(--context "$KUBE_CONTEXT") +else + KCTX_ARGS=() +fi +KCTX="$(kubectl "${KCTX_ARGS[@]}" config current-context 2>/dev/null || true)" +[[ -n "$KCTX" ]] || fail "could not determine current kubectl context (try setting KUBE_CONTEXT=...)" + +printf " context : %s\n" "$KCTX" +printf " namespace: %s\n" "$NAMESPACE" + +if [[ "$DRY_RUN" != "true" ]]; then + read -r -p " apply to this context? [y/N] " confirm + [[ "$confirm" =~ ^[Yy]$ ]] || fail "aborted" +fi + +# --------------------------------------------------------------------- +# placeholder fill +# --------------------------------------------------------------------- + +PG_SECRET="$SCRIPT_DIR/12-postgres-secret.yaml" +APP_SECRET="$SCRIPT_DIR/20-mcp-test-secret.yaml" +INGRESS="$SCRIPT_DIR/50-mcp-test-ingress.yaml" + +# Postgres password +if grep -q 'REPLACE_ME_POSTGRES_PASSWORD' "$PG_SECRET"; then + pgpw="$(openssl rand -base64 24 | tr -d '\n=+/' | head -c 28)" + sed -i.bak "s|REPLACE_ME_POSTGRES_PASSWORD|${pgpw}|" "$PG_SECRET" && rm -f "$PG_SECRET.bak" + ok "generated POSTGRES_PASSWORD in 12-postgres-secret.yaml" +else + pgpw="$(awk '/POSTGRES_PASSWORD:/ {gsub(/"/,"",$2); print $2; exit}' "$PG_SECRET")" + ok "POSTGRES_PASSWORD already set" +fi + +# Application secrets +if grep -q 'REPLACE_ME_COOKIE_SECRET' "$APP_SECRET"; then + cookie="$(openssl rand -base64 32 | tr -d '\n')" + sed -i.bak "s|REPLACE_ME_COOKIE_SECRET|${cookie}|" "$APP_SECRET" && rm -f "$APP_SECRET.bak" + ok "generated COOKIE_SECRET in 20-mcp-test-secret.yaml" +fi +if grep -q 'REPLACE_ME_DEV_API_KEY' "$APP_SECRET"; then + devkey="mcptest_$(openssl rand -base64 24 | tr -d '\n=+/' | head -c 32)" + sed -i.bak "s|REPLACE_ME_DEV_API_KEY|${devkey}|" "$APP_SECRET" && rm -f "$APP_SECRET.bak" + ok "generated DEV_KEY in 20-mcp-test-secret.yaml" +else + devkey="$(awk '/DEV_KEY:/ {gsub(/"/,"",$2); print $2; exit}' "$APP_SECRET")" +fi +if grep -q 'REPLACE_ME_DATABASE_URL' "$APP_SECRET"; then + dburl="postgres://mcp:${pgpw}@postgres:5432/mcp_test?sslmode=disable" + # `|` is the sed delim so we don't fight the / in the URL. + sed -i.bak "s|REPLACE_ME_DATABASE_URL|${dburl}|" "$APP_SECRET" && rm -f "$APP_SECRET.bak" + ok "wrote DATABASE_URL in 20-mcp-test-secret.yaml" +fi + +# Ingress host +if grep -q 'mcp-test-server.example.com' "$INGRESS"; then + if [[ -z "${INGRESS_HOST:-}" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + INGRESS_HOST="mcp-test-server.example.com" + warn "INGRESS_HOST not set; --dry-run keeps the placeholder" + else + read -r -p " ingress host (e.g. mcp.example.com): " INGRESS_HOST + [[ -n "$INGRESS_HOST" ]] || fail "INGRESS_HOST required" + fi + fi + if [[ "$INGRESS_HOST" != "mcp-test-server.example.com" ]]; then + sed -i.bak "s|mcp-test-server.example.com|${INGRESS_HOST}|g" "$INGRESS" && rm -f "$INGRESS.bak" + ok "set ingress host to ${INGRESS_HOST}" + fi +fi + +# Compute hashes for the rollout-on-config-change annotations. +config_hash="$(shasum -a 256 "$SCRIPT_DIR/25-mcp-test-configmap.yaml" | awk '{print substr($1,1,12)}')" +secret_hash="$(shasum -a 256 "$APP_SECRET" | awk '{print substr($1,1,12)}')" + +# --------------------------------------------------------------------- +# apply +# --------------------------------------------------------------------- + +# Order matters: namespace first, then postgres up + ready, then +# mcp-test secrets/configmap, then deployment, then ingress. +MANIFESTS=( + "00-namespace.yaml" + "10-postgres-service.yaml" + "12-postgres-secret.yaml" + "14-postgres-statefulset.yaml" + "20-mcp-test-secret.yaml" + "25-mcp-test-configmap.yaml" + "30-mcp-test-service.yaml" + "40-mcp-test-deployment.yaml" + "50-mcp-test-ingress.yaml" +) +TOTAL_STEPS=$(( ${#MANIFESTS[@]} + 2 )) # +2 for the postgres-ready and rollout waits + +apply() { + if [[ "$DRY_RUN" == "true" ]]; then + kubectl "${KCTX_ARGS[@]}" apply --dry-run=client -f "$1" >/dev/null + else + kubectl "${KCTX_ARGS[@]}" apply -f "$1" >/dev/null + fi +} + +i=0 +for f in "${MANIFESTS[@]}"; do + i=$(( i + 1 )) + case "$f" in + 14-postgres-statefulset.yaml) + step "$i" "applying ${c_bold}$f${c_reset}" + apply "$SCRIPT_DIR/$f" + ok "Postgres StatefulSet applied" + i=$(( i + 1 )) + step "$i" "waiting for Postgres pod ready..." + if [[ "$DRY_RUN" != "true" ]]; then + kubectl "${KCTX_ARGS[@]}" -n "$NAMESPACE" wait --for=condition=Ready pod/postgres-0 \ + --timeout=180s >/dev/null 2>&1 || fail "postgres-0 did not become Ready in 180s" + ok "postgres-0 Ready" + else + muted " (skipped in dry-run)" + fi + ;; + 40-mcp-test-deployment.yaml) + # Patch the rollout-on-content-change annotations so a config or + # secret edit takes effect on the next install.sh. + step "$i" "applying ${c_bold}$f${c_reset} (config-hash=${config_hash}, secret-hash=${secret_hash})" + tmp="$(mktemp)" + sed -e "s|config-hash: \"0\"|config-hash: \"${config_hash}\"|" \ + -e "s|secret-hash: \"0\"|secret-hash: \"${secret_hash}\"|" \ + "$SCRIPT_DIR/$f" > "$tmp" + apply "$tmp" + rm -f "$tmp" + ok "mcp-test Deployment applied" + i=$(( i + 1 )) + step "$i" "waiting for mcp-test rollout..." + if [[ "$DRY_RUN" != "true" ]]; then + kubectl "${KCTX_ARGS[@]}" -n "$NAMESPACE" rollout status deploy/mcp-test \ + --timeout=180s >/dev/null || fail "mcp-test rollout did not complete" + ok "mcp-test Ready" + else + muted " (skipped in dry-run)" + fi + ;; + *) + step "$i" "applying ${c_bold}$f${c_reset}" + apply "$SCRIPT_DIR/$f" + ok "applied" + ;; + esac +done + +# --------------------------------------------------------------------- +# done +# --------------------------------------------------------------------- + +hdr "Installed." + +if [[ "$DRY_RUN" == "true" ]]; then + muted "(dry-run only; no resources were created)" + exit 0 +fi + +host="${INGRESS_HOST:-mcp-test-server.example.com}" + +cat < Date: Thu, 7 May 2026 00:38:45 -0700 Subject: [PATCH 2/3] docs: SEO follow-up (llms.txt v1.2 entries, social cards in CI) llms.txt: add the two v1.2 Operations pages that landed in PR #12 and on this branch (inspection workflow + Kubernetes example) so LLM consumers indexing the project don't miss them. mkdocs-material social plugin: per-page 1200x630 OG cards baked with the page title, description, and Plexara colors. Without these, every page falls back to the 378x338 logo.png, which downgrades Twitter / X to a small `summary` card instead of `summary_large_image`. Slack and LinkedIn share previews render the same fallback. Plugin is gated `enabled: !ENV [CI, false]` so a dev `mkdocs build` without the imaging deps still works; production exports CI=true and runs. docs.yml workflow: install the cairo / freetype / libpng / libjpeg / libffi / libz dev packages so cairosvg + Pillow can compile during the social-card render step, and bump the pip install to mkdocs-material[imaging] (which pulls in those Python bindings). Sitemap (auto-generated by mkdocs-material) already picks up the new Operations pages from this branch's nav additions; no changes needed there. robots.txt, canonical URL, OG, Twitter Card, JSON-LD (SoftwareSourceCode + Organization) all confirmed in place. Pre-commit gate: 1 review round, 1 minor (missing libz-dev in the system-deps step, parity with mkdocs-material's upstream guidance); fixed in-tree before commit. --- .github/workflows/docs.yml | 15 ++++++++++++++- docs/llms.txt | 2 ++ mkdocs.yml | 12 ++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 940c85c..a598fbf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -32,10 +32,23 @@ jobs: with: python-version: "3.12" + - name: Install system deps for mkdocs-material social cards + # The social plugin renders per-page OG images (1200x630) via + # cairosvg + Pillow. cairosvg needs the system libcairo, libffi, + # and libjpeg libraries. + run: | + sudo apt-get update + sudo apt-get install -y \ + libcairo2-dev libfreetype6-dev libffi-dev libjpeg-dev libpng-dev libz-dev + - name: Install dependencies + env: + # mkdocs-material[imaging] pulls in cairosvg + pillow so the + # `social` plugin can run. + PIP_DISABLE_PIP_VERSION_CHECK: "1" run: | pip install \ - mkdocs-material \ + 'mkdocs-material[imaging]' \ 'pymdown-extensions>=10.0' - name: Build documentation diff --git a/docs/llms.txt b/docs/llms.txt index 2993fe4..2571cdf 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -32,8 +32,10 @@ mcp-test ships 12 tools across 4 categories. Each is a thin, deterministic shim ## Operations - [Audit log](https://mcp-test.plexara.io/operations/audit/): Postgres schema, retention, redaction, and the JSON shape returned by the portal API. +- [Inspection workflow](https://mcp-test.plexara.io/operations/inspection/): End-to-end operator walkthrough of the v1.2 audit drawer, comparison page, replay endpoint, JSONB filters, live tail, and NDJSON export. - [Portal](https://mcp-test.plexara.io/operations/portal/): React 19 SPA embedded into the binary; pages, Try-It proxy, dashboard. - [Deployment](https://mcp-test.plexara.io/operations/deployment/): Docker, Kubernetes, distroless image, healthcheck, graceful shutdown. +- [Kubernetes example](https://mcp-test.plexara.io/operations/kubernetes/): Self-contained manifests + bash installer for an mcp-test + Postgres install on any cluster with nginx ingress and cert-manager. One file per resource, full YAML embedded inline. - [Testing a gateway](https://mcp-test.plexara.io/operations/gateway-testing/): Patterns for asserting on a gateway's identity-forwarding, redaction, enrichment, and progress-pass-through using mcp-test as the upstream. ## Reference diff --git a/mkdocs.yml b/mkdocs.yml index 5a0885e..8897723 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -148,3 +148,15 @@ markdown_extensions: plugins: - search: separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])' + # Social plugin: generates 1200x630 PNG cards per page baked with the + # page title, eyebrow, and Plexara logo. Twitter/X picks `summary_large_image` + # when the OG image is large; without these cards every page falls back + # to the 378x338 logo.png and gets a small `summary` card. Gated on the + # CI env var so a local build without `mkdocs-material[imaging]` (and + # the cairo system deps) still works; the deploy workflow exports + # CI=true so production renders cards. + - social: + enabled: !ENV [CI, false] + cards_layout_options: + background_color: "#020617" + color: "#f8fafc" From c0d88d0d13f046d3e108fd746b181f0a89a67cd5 Mon Sep 17 00:00:00 2001 From: cjimti Date: Thu, 7 May 2026 00:56:47 -0700 Subject: [PATCH 3/3] docs: brand the social cards with Plexara typography Pin mkdocs-material's social plugin to Outfit (the same display font used in the carousel headings and the lightbox title) instead of mkdocs-material's default Roboto. The midnight-950 / slate-50 palette already matches the rest of the site; this lines up the typography too, so a tweet of any docs page reads as part of the same designed artifact. Google Fonts is reachable from the GitHub Actions runner, so the plugin fetches the Outfit woff2 at render time without extra CI deps beyond the imaging install added in the previous commit. --- mkdocs.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 8897723..3085a3e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -158,5 +158,11 @@ plugins: - social: enabled: !ENV [CI, false] cards_layout_options: + # Match the rest of the site: midnight-950 background, slate-50 + # foreground, Outfit display (the same display font used in the + # carousel headings and lightbox title) for the page-title text. + # Google Fonts is reachable from the GitHub Actions runner, so + # the plugin can fetch the woff2 at render time. background_color: "#020617" color: "#f8fafc" + font_family: Outfit