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/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/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/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 <