From c0a5b0828f5f465915959650d421704ddcd482dc Mon Sep 17 00:00:00 2001 From: ehsanmsb Date: Tue, 27 Oct 2026 23:40:39 +0330 Subject: [PATCH 1/2] refactor!: use sites for all documentation sources Simplify Helm to one values file and one replica; remove HPA, PDB, and NetworkPolicy templates. --- .env.example | 7 +- .github/workflows/release.yml | 2 +- README.md | 17 ++-- charts/mkdocs-mcp/README.md | 92 ++++++------------ charts/mkdocs-mcp/templates/configmap.yaml | 4 +- charts/mkdocs-mcp/templates/deployment.yaml | 11 --- charts/mkdocs-mcp/templates/hpa.yaml | 21 ----- .../mkdocs-mcp/templates/networkpolicy.yaml | 28 ------ charts/mkdocs-mcp/templates/pdb.yaml | 11 --- charts/mkdocs-mcp/values-production.yaml | 22 ----- charts/mkdocs-mcp/values-staging.yaml | 12 --- charts/mkdocs-mcp/values.yaml | 51 ++-------- docs/CONFIGURATION.md | 94 +++++++++---------- docs/CONTRIBUTING.md | 2 +- docs/USAGE.md | 2 +- src/mkdocs_mcp/config.py | 13 +-- src/mkdocs_mcp/indexing/indexer.py | 8 +- src/mkdocs_mcp/ui.py | 8 +- tests/conftest.py | 4 +- tests/test_multi_site.py | 6 ++ tests/test_published_source.py | 15 ++- tests/test_search_golden.py | 6 +- tests/test_tools.py | 8 +- 23 files changed, 125 insertions(+), 319 deletions(-) delete mode 100644 charts/mkdocs-mcp/templates/hpa.yaml delete mode 100644 charts/mkdocs-mcp/templates/networkpolicy.yaml delete mode 100644 charts/mkdocs-mcp/templates/pdb.yaml delete mode 100644 charts/mkdocs-mcp/values-production.yaml delete mode 100644 charts/mkdocs-mcp/values-staging.yaml diff --git a/.env.example b/.env.example index cb5df9e..20eb56e 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,6 @@ # Copy to .env for local development. -MKDOCS_MCP_BASE_URL=https://docs.example.com -# Override only if the published index is not at BASE_URL/search/search_index.json. -# MKDOCS_MCP_SEARCH_INDEX_URL=https://docs.example.com/custom/search_index.json -# For multiple sites, use JSON. This replaces BASE_URL and SEARCH_INDEX_URL. -# MKDOCS_MCP_SITES=[{"name":"atlas","base_url":"https://atlas.example.com"},{"name":"team","base_url":"https://team.example.com"}] +MKDOCS_MCP_SITES=[{"name":"docs","base_url":"https://docs.example.com"}] +# Add more objects to the list for more sites. A site may include search_index_url. MKDOCS_MCP_REINDEX_INTERVAL_MINUTES=30 MKDOCS_MCP_LOG_JSON=false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf0cd5f..18fd39d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: - name: Check Helm chart run: | helm lint charts/mkdocs-mcp - helm template mkdocs-mcp charts/mkdocs-mcp -f charts/mkdocs-mcp/values-production.yaml > /dev/null + helm template mkdocs-mcp charts/mkdocs-mcp > /dev/null - name: Check Docker build on pull requests if: github.event_name == 'pull_request' run: docker build --tag mkdocs-mcp:ci . diff --git a/README.md b/README.md index 103702a..440fa39 100644 --- a/README.md +++ b/README.md @@ -19,23 +19,22 @@ Requires Python 3.14 and `uv`. ```bash uv sync --locked --python 3.14 source .venv/bin/activate -export MKDOCS_MCP_BASE_URL=https://docs.example.com +export MKDOCS_MCP_SITES='[{"name":"docs","base_url":"https://docs.example.com"}]' mkdocs-mcp ``` -Replace the example URL with the public root of your MkDocs site. By default, -the server fetches `/search/search_index.json` on startup and serves -MCP at `http://localhost:8000/mcp`. Set `MKDOCS_MCP_SEARCH_INDEX_URL` if your -site publishes its search index elsewhere. The index must be reachable when -the server starts. +Replace the example URL with the public root of your MkDocs site. The server +fetches `/search/search_index.json` on startup and serves MCP at +`http://localhost:8000/mcp`. If the index is elsewhere, add `search_index_url` +to that site's object. The index must be reachable when the server starts. Opening `/` in a browser shows the server status and MCP connection URL. Opening `/mcp` in a browser shows the same page; MCP clients continue to use `/mcp` for protocol requests. -To search several MkDocs sites through one endpoint, set `MKDOCS_MCP_SITES` to -a JSON list of site names and published URLs. Search results identify their -site and keep links to the original pages: +To search several sites through the same endpoint, add more entries to +`MKDOCS_MCP_SITES`. Search results identify their site and link to the original +pages: ```bash export MKDOCS_MCP_SITES='[{"name":"atlas","base_url":"https://atlas.example.com"},{"name":"team","base_url":"https://team.example.com"}]' diff --git a/charts/mkdocs-mcp/README.md b/charts/mkdocs-mcp/README.md index be39043..472c9be 100644 --- a/charts/mkdocs-mcp/README.md +++ b/charts/mkdocs-mcp/README.md @@ -1,48 +1,34 @@ -# Deploying the MkDocs MCP server on OKD / OpenShift +# Deploying MkDocs MCP on OKD / OpenShift -A generic, configurable MCP server for any MkDocs documentation site. All -site-specific values (URLs and server name) are supplied via Helm values -/ env — nothing is hardcoded. +This chart uses one [`values.yaml`](values.yaml) for every deployment. Edit +`config.sites` to list one or more public MkDocs sites and set `route.host` to +your MCP hostname. Each site normally serves its index at +`/search/search_index.json`; add `search_index_url` to an entry if its +index lives elsewhere. Replace the example addresses before deployment. -## Prerequisites -- An OKD/OpenShift project (namespace). -- The image pushed to your registry (see root `Dockerfile` / `.github/workflows/release.yml`, - which publishes to `ghcr.io//`). -- Prometheus Operator if you want the `ServiceMonitor`. +The pod needs DNS and HTTPS access to every configured site. MCP clients do not +need a token. A Prometheus Operator is needed only if `serviceMonitor.enabled` +is true. -## 1. Configure the documentation source -Copy `values-production.yaml` to `values-production.local.yaml` and set -`config.baseUrl` and `route.host` there. Set `config.searchIndexUrl` only if -the index is not at `/search/search_index.json`. The local -file is ignored by Git. The checked-in values are examples and must not -contain private deployment addresses. MCP clients do not need a token. The -pod must be able to fetch the published search index over HTTPS. +## Install or upgrade -For one MCP endpoint serving several MkDocs sites, set `config.sites` to a -list of `{name, base_url}` entries as shown in -[configuration](../../docs/CONFIGURATION.md). Each site's search index is read -from its own URL; `config.baseUrl` is used only when the list is empty. The -pod needs DNS and HTTPS egress to every configured site. - -## 2. Install / upgrade -The chart in this checkout defaults to the moving `main` image. Pin -`image.tag=sha-` for a repeatable deployment, as shown below. A -versioned chart attached to a GitHub release defaults to its matching image tag. +Pin `image.tag` to a built image version. For a checkout using the GitHub +workflow's commit image: ```bash -helm upgrade --install mkdocs-mcp charts/mkdocs-mcp \ - -n docs-mcp-staging --create-namespace \ - -f charts/mkdocs-mcp/values-staging.yaml \ - --set image.tag=sha-$(git rev-parse HEAD) \ - --set config.baseUrl=https://docs.example.com - helm upgrade --install mkdocs-mcp charts/mkdocs-mcp \ -n docs-mcp --create-namespace \ - -f charts/mkdocs-mcp/values-production.local.yaml \ --set image.tag=sha-$(git rev-parse HEAD) ``` -## 3. Verify +The chart defaults to one replica, with CPU request/limit of 250m/500m and +memory request/limit of 512Mi/1Gi. It does not create an HPA, PDB, or +NetworkPolicy. Readiness gates traffic until a documentation index has loaded. +The container uses a non-root, read-only filesystem with writable `/tmp`. +OpenShift assigns the UID through its SCC. + +## Verify + ```bash oc -n docs-mcp rollout status deploy/mkdocs-mcp oc -n docs-mcp get route mkdocs-mcp @@ -50,35 +36,13 @@ curl -fsS https:///health curl -fsS https:///ready ``` -Open `https:///` in a browser for the connection page and current -index status. A browser request to `/mcp` shows that page too; MCP clients use -`/mcp` as before. - -## 4. Connect a client -```json -{ "mcpServers": { "mkdocs-docs": { - "type": "http", - "url": "https:///mcp" } } } -``` - -## Operational notes -- **Indexing**: each replica reads the published search index on startup and - checks `ETag` or `Last-Modified` every `config.reindexIntervalMinutes`. - No Git clone, Secret, or persistent volume is required. -- **Scaling/HA**: `replicaCount>=2`, pod anti-affinity, `PodDisruptionBudget`, - optional HPA. Readiness gates traffic until the index is loaded. -- **Security**: non-root, read-only rootfs (writable `/tmp` only), - dropped caps, `NetworkPolicy` limiting egress to DNS + HTTPS. OpenShift - assigns the UID via SCC — do not set `runAsUser`. -- **Access**: anyone who can reach `/mcp` can use its read-only documentation - tools. The server reads only the public MkDocs search index. +Open `https:///` for the UI. MCP clients connect to +`https:///mcp`. -## Troubleshooting -| Symptom | Check | -|---|---| -| Pod not ready | `oc logs` — search index URL or HTTPS egress failing? | -| Empty results | Check the published `search_index.json` and `get_statistics` | -| Wrong links | Check `config.baseUrl`, or each `config.sites[].base_url` in multi-site mode | +The server holds the published search indexes in memory and periodically checks +for changes using HTTP `ETag` or `Last-Modified` when available. No Git clone or +persistent volume is needed. Memory use grows with the combined index size of +the sites; monitor it before adding many large sites. -See [logs and metrics](../../docs/OBSERVABILITY.md) for the available metrics -and example dashboard and alert expressions. +See [configuration](../../docs/CONFIGURATION.md) and +[observability](../../docs/OBSERVABILITY.md) for more detail. diff --git a/charts/mkdocs-mcp/templates/configmap.yaml b/charts/mkdocs-mcp/templates/configmap.yaml index dd9a08f..d7e79f6 100644 --- a/charts/mkdocs-mcp/templates/configmap.yaml +++ b/charts/mkdocs-mcp/templates/configmap.yaml @@ -6,9 +6,7 @@ metadata: data: MKDOCS_MCP_HOST: {{ .Values.config.host | quote }} MKDOCS_MCP_PORT: {{ .Values.config.port | quote }} - MKDOCS_MCP_SEARCH_INDEX_URL: {{ .Values.config.searchIndexUrl | quote }} - MKDOCS_MCP_SITES: {{ .Values.config.sites | toJson | quote }} - MKDOCS_MCP_BASE_URL: {{ .Values.config.baseUrl | quote }} + MKDOCS_MCP_SITES: {{ required "config.sites must contain at least one MkDocs site" .Values.config.sites | toJson | quote }} MKDOCS_MCP_REINDEX_INTERVAL_MINUTES: {{ .Values.config.reindexIntervalMinutes | quote }} MKDOCS_MCP_RATE_LIMIT_ENABLED: {{ .Values.config.rateLimitEnabled | quote }} MKDOCS_MCP_RATE_LIMIT_PER_SECOND: {{ .Values.config.rateLimitPerSecond | quote }} diff --git a/charts/mkdocs-mcp/templates/deployment.yaml b/charts/mkdocs-mcp/templates/deployment.yaml index 2d0e9d0..2ff1d33 100644 --- a/charts/mkdocs-mcp/templates/deployment.yaml +++ b/charts/mkdocs-mcp/templates/deployment.yaml @@ -4,9 +4,7 @@ metadata: name: {{ include "mkdocs-mcp.fullname" . }} labels: {{- include "mkdocs-mcp.labels" . | nindent 4 }} spec: - {{- if not .Values.autoscaling.enabled }} replicas: {{ .Values.replicaCount }} - {{- end }} selector: matchLabels: {{- include "mkdocs-mcp.selectorLabels" . | nindent 6 }} template: @@ -19,15 +17,6 @@ spec: imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} - {{- with .Values.affinity }} - affinity: {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.nodeSelector }} - nodeSelector: {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: {{- toYaml . | nindent 8 }} - {{- end }} containers: - name: mkdocs-mcp image: {{ include "mkdocs-mcp.image" . }} diff --git a/charts/mkdocs-mcp/templates/hpa.yaml b/charts/mkdocs-mcp/templates/hpa.yaml deleted file mode 100644 index f04e132..0000000 --- a/charts/mkdocs-mcp/templates/hpa.yaml +++ /dev/null @@ -1,21 +0,0 @@ -{{- if .Values.autoscaling.enabled }} -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "mkdocs-mcp.fullname" . }} - labels: {{- include "mkdocs-mcp.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "mkdocs-mcp.fullname" . }} - minReplicas: {{ .Values.autoscaling.minReplicas }} - maxReplicas: {{ .Values.autoscaling.maxReplicas }} - metrics: - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} -{{- end }} diff --git a/charts/mkdocs-mcp/templates/networkpolicy.yaml b/charts/mkdocs-mcp/templates/networkpolicy.yaml deleted file mode 100644 index cf5569d..0000000 --- a/charts/mkdocs-mcp/templates/networkpolicy.yaml +++ /dev/null @@ -1,28 +0,0 @@ -{{- if .Values.networkPolicy.enabled }} -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: {{ include "mkdocs-mcp.fullname" . }} - labels: {{- include "mkdocs-mcp.labels" . | nindent 4 }} -spec: - podSelector: - matchLabels: {{- include "mkdocs-mcp.selectorLabels" . | nindent 6 }} - policyTypes: [Ingress, Egress] - ingress: - - ports: - - { port: {{ .Values.config.port }}, protocol: TCP } - {{- if .Values.config.metricsEnabled }} - - { port: 9000, protocol: TCP } - {{- end }} - {{- with .Values.networkPolicy.ingressFrom }} - from: {{- toYaml . | nindent 8 }} - {{- end }} - egress: - # DNS - - ports: - - { port: 53, protocol: UDP } - - { port: 53, protocol: TCP } - # Published MkDocs search index over HTTPS - - ports: - - { port: 443, protocol: TCP } -{{- end }} diff --git a/charts/mkdocs-mcp/templates/pdb.yaml b/charts/mkdocs-mcp/templates/pdb.yaml deleted file mode 100644 index 1467519..0000000 --- a/charts/mkdocs-mcp/templates/pdb.yaml +++ /dev/null @@ -1,11 +0,0 @@ -{{- if .Values.podDisruptionBudget.enabled }} -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: - name: {{ include "mkdocs-mcp.fullname" . }} - labels: {{- include "mkdocs-mcp.labels" . | nindent 4 }} -spec: - minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} - selector: - matchLabels: {{- include "mkdocs-mcp.selectorLabels" . | nindent 6 }} -{{- end }} diff --git a/charts/mkdocs-mcp/values-production.yaml b/charts/mkdocs-mcp/values-production.yaml deleted file mode 100644 index 904cd0f..0000000 --- a/charts/mkdocs-mcp/values-production.yaml +++ /dev/null @@ -1,22 +0,0 @@ -replicaCount: 3 - -image: - repository: ghcr.io/ehsanmsb/mkdocs-mcp - -config: - serverName: MkDocs Docs - baseUrl: https://docs.example.com - reindexIntervalMinutes: 30 - logLevel: INFO - -route: - host: docs-mcp.example.com - -autoscaling: - enabled: true - minReplicas: 3 - maxReplicas: 6 - -podDisruptionBudget: - enabled: true - minAvailable: 2 diff --git a/charts/mkdocs-mcp/values-staging.yaml b/charts/mkdocs-mcp/values-staging.yaml deleted file mode 100644 index dc18991..0000000 --- a/charts/mkdocs-mcp/values-staging.yaml +++ /dev/null @@ -1,12 +0,0 @@ -replicaCount: 1 - -config: - baseUrl: https://docs.staging.example.com - reindexIntervalMinutes: 15 - logLevel: DEBUG - -route: - host: mkdocs-mcp.apps.staging.example.com - -podDisruptionBudget: - enabled: false diff --git a/charts/mkdocs-mcp/values.yaml b/charts/mkdocs-mcp/values.yaml index 240547d..c13f36b 100644 --- a/charts/mkdocs-mcp/values.yaml +++ b/charts/mkdocs-mcp/values.yaml @@ -1,5 +1,5 @@ # Default values for mkdocs-mcp. -replicaCount: 2 +replicaCount: 1 image: repository: ghcr.io/ehsanmsb/mkdocs-mcp @@ -15,9 +15,9 @@ config: serverName: MkDocs Docs # surfaced to MCP clients host: 0.0.0.0 port: 8000 - searchIndexUrl: "" # defaults to baseUrl/search/search_index.json - sites: [] # [{name: team, base_url: https://docs.example.com}] - baseUrl: https://docs.example.com + sites: + - name: docs + base_url: https://docs.example.com reindexIntervalMinutes: 30 rateLimitEnabled: true rateLimitPerSecond: 5 @@ -40,40 +40,18 @@ route: termination: edge insecureEdgeTerminationPolicy: Redirect -ingress: - enabled: false - className: "" - host: "" - annotations: {} - tls: [] - resources: requests: - cpu: 100m - memory: 192Mi - limits: - cpu: "1" + cpu: 250m memory: 512Mi - -autoscaling: - enabled: false - minReplicas: 2 - maxReplicas: 5 - targetCPUUtilizationPercentage: 75 - -podDisruptionBudget: - enabled: true - minAvailable: 1 + limits: + cpu: 500m + memory: 1Gi serviceMonitor: enabled: true interval: 30s -networkPolicy: - enabled: true - # CIDRs / selectors allowed to reach the MCP port (e.g. the ingress controller). - ingressFrom: [] - # OpenShift-friendly: do NOT pin runAsUser; let the SCC assign the UID. podSecurityContext: runAsNonRoot: true @@ -84,16 +62,3 @@ securityContext: readOnlyRootFilesystem: true capabilities: drop: ["ALL"] - -nodeSelector: {} -tolerations: [] -# Spread replicas across nodes for HA. -affinity: - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - weight: 100 - podAffinityTerm: - topologyKey: kubernetes.io/hostname - labelSelector: - matchLabels: - app.kubernetes.io/name: mkdocs-mcp diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2ce3e9c..9a73410 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,69 +1,65 @@ # Configuration -The server reads the published MkDocs search index. Set the public site URL -with `MKDOCS_MCP_BASE_URL`; the index defaults to -`/search/search_index.json`. All settings below can be passed as -environment variables or placed in a local `.env` file. Their defaults match -[`config.py`](../src/mkdocs_mcp/config.py). +Set `MKDOCS_MCP_SITES` to a JSON list containing at least one published MkDocs +site. Use the same setting for one site or many. Each entry needs a unique +lowercase `name` and an absolute HTTP(S) `base_url`. The optional +`search_index_url` points to a nonstandard index location; otherwise the server +fetches `/search/search_index.json`. -| Variable | Default | When to set it | +```bash +export MKDOCS_MCP_SITES='[{"name":"docs","base_url":"https://docs.example.com"}]' +``` + +Add another object to the list to search more sites. Page IDs always include +the site name, for example `docs:guides/install`. The server keeps the last good +index for a site if a later refresh fails. Startup requires at least one site +to load. + +All settings can be environment variables or entries in a local `.env` file. +Defaults match [`config.py`](../src/mkdocs_mcp/config.py). + +| Variable | Default | Purpose | | --- | --- | --- | -| `MKDOCS_MCP_BASE_URL` | `http://localhost:8000` | **Set for your site.** This is the documentation URL, used for links and the default search index URL. It is not the MCP server URL. | -| `MKDOCS_MCP_SEARCH_INDEX_URL` | empty | Only when the published index is at another URL. Empty uses `/search/search_index.json`. | -| `MKDOCS_MCP_SITES` | `[]` | JSON list of `{name, base_url, search_index_url?}` objects. When nonempty, it replaces the single-site `BASE_URL` and `SEARCH_INDEX_URL` settings. Names must be unique lowercase slugs. | -| `MKDOCS_MCP_REINDEX_INTERVAL_MINUTES` | `30` | Change how often the server checks for a new index. `0` disables periodic checks, but startup still loads it. HTTP `304` keeps the current index; refresh errors keep the last good index. | -| `MKDOCS_MCP_SERVER_NAME` | `MkDocs Docs` | Change the name shown to MCP clients. | -| `MKDOCS_MCP_HOST` | `0.0.0.0` | Change the listening interface; use `127.0.0.1` to allow local connections only. | -| `MKDOCS_MCP_PORT` | `8000` | Change the HTTP listening port. The MCP endpoint is `/mcp`. | -| `MKDOCS_MCP_RATE_LIMIT_ENABLED` | `true` | Turn the FastMCP request limiter on or off. This is not authentication. | -| `MKDOCS_MCP_RATE_LIMIT_PER_SECOND` | `5.0` | Change the sustained request rate. | -| `MKDOCS_MCP_RATE_LIMIT_BURST` | `20` | Change the short burst capacity. | -| `MKDOCS_MCP_LOG_LEVEL` | `INFO` | Change logging detail, for example to `DEBUG` while diagnosing an issue. | -| `MKDOCS_MCP_LOG_JSON` | `true` | Use structured JSON logs; set false for easier local reading. | -| `MKDOCS_MCP_METRICS_ENABLED` | `true` | Enable Prometheus metrics on port `9000`; disable if unused. | +| `MKDOCS_MCP_SITES` | Required | JSON list of `{name, base_url, search_index_url?}` objects. Names must be unique lowercase slugs. | +| `MKDOCS_MCP_REINDEX_INTERVAL_MINUTES` | `30` | How often to check for changed indexes. `0` disables periodic checks. | +| `MKDOCS_MCP_SERVER_NAME` | `MkDocs Docs` | Name shown to MCP clients and in the UI. | +| `MKDOCS_MCP_HOST` | `0.0.0.0` | HTTP listening interface. Use `127.0.0.1` for local-only access. | +| `MKDOCS_MCP_PORT` | `8000` | HTTP port; MCP endpoint is `/mcp`. | +| `MKDOCS_MCP_RATE_LIMIT_ENABLED` | `true` | Enables the request limiter; it is not authentication. | +| `MKDOCS_MCP_RATE_LIMIT_PER_SECOND` | `5.0` | Sustained request rate. | +| `MKDOCS_MCP_RATE_LIMIT_BURST` | `20` | Short burst capacity. | +| `MKDOCS_MCP_LOG_LEVEL` | `INFO` | Log detail; use `DEBUG` while diagnosing issues. | +| `MKDOCS_MCP_LOG_JSON` | `true` | Structured JSON logs; use `false` for readable local logs. | +| `MKDOCS_MCP_METRICS_ENABLED` | `true` | Prometheus metrics on port `9000`. | + +`/health` and `/ready` are served on the HTTP port. `/ready` succeeds after at +least one index loads and reports degraded status if another site fails. MCP +clients connect without authentication, so control access at the network layer +if needed. -The server exposes `/health` and `/ready` on its HTTP port. `/ready` succeeds -only after the index has loaded. MCP clients connect without authentication, -so control access through the network where you deploy it. +## Helm chart -For multiple sites, use the same list in an environment variable or Helm: +The chart has one [`values.yaml`](../charts/mkdocs-mcp/values.yaml). Set +`config.sites` to one or more entries: ```yaml config: sites: - - name: atlas - base_url: https://atlas.example.com + - name: docs + base_url: https://docs.example.com - name: team base_url: https://team.example.com - # search_index_url: https://team.example.com/custom/search_index.json + search_index_url: https://team.example.com/custom/search_index.json ``` -Each site must publish a readable search index. Names must be unique and use -lowercase letters, digits, hyphens, or underscores. Page IDs include the site -name, for example `atlas:guides/install`. When at least one site loads, the -server becomes ready; a failed site is reported by `health_check` and `/ready` -as degraded. Later refresh failures keep that site's last good content. If no -site loads at startup, the server cannot start. - -## Helm chart - -The chart maps application settings from `config` in -[`values.yaml`](../charts/mkdocs-mcp/values.yaml) to environment variables. -For one site, set `config.baseUrl` to the published site's root and leave -`config.searchIndexUrl` empty for the standard MkDocs location. For several -sites, set `config.sites`; the single-site URLs are then ignored. The chart -fixes the application ports at `8000` and `9000`, matching its container and -Service configuration. Every application -environment variable has a value in the chart: +The chart maps all application settings through its ConfigMap: | Environment variable | Helm value | | --- | --- | +| `MKDOCS_MCP_SITES` | `config.sites` (JSON encoded) | | `MKDOCS_MCP_SERVER_NAME` | `config.serverName` | | `MKDOCS_MCP_HOST` | `config.host` | | `MKDOCS_MCP_PORT` | `config.port` | -| `MKDOCS_MCP_SEARCH_INDEX_URL` | `config.searchIndexUrl` | -| `MKDOCS_MCP_SITES` | `config.sites` (JSON encoded) | -| `MKDOCS_MCP_BASE_URL` | `config.baseUrl` | | `MKDOCS_MCP_REINDEX_INTERVAL_MINUTES` | `config.reindexIntervalMinutes` | | `MKDOCS_MCP_RATE_LIMIT_ENABLED` | `config.rateLimitEnabled` | | `MKDOCS_MCP_RATE_LIMIT_PER_SECOND` | `config.rateLimitPerSecond` | @@ -72,7 +68,5 @@ environment variable has a value in the chart: | `MKDOCS_MCP_LOG_JSON` | `config.logJson` | | `MKDOCS_MCP_METRICS_ENABLED` | `config.metricsEnabled` | -The chart uses `config.port` for the container and network policy too. Keep -`config.host: 0.0.0.0` in Kubernetes so the Service can reach the pod. Metrics -are exposed on the fixed port `9000`. See [logs and metrics](OBSERVABILITY.md) -for dashboard and alert queries. +Keep `config.host: 0.0.0.0` in Kubernetes so the Service can reach the pod. +Metrics are exposed on port `9000`. See [logs and metrics](OBSERVABILITY.md). diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 5619d22..abc3670 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -14,7 +14,7 @@ uv sync --locked --extra dev --python 3.14 Run the server locally against a published MkDocs site: ```bash -export MKDOCS_MCP_BASE_URL=https://docs.example.com +export MKDOCS_MCP_SITES='[{"name":"docs","base_url":"https://docs.example.com"}]' mkdocs-mcp ``` diff --git a/docs/USAGE.md b/docs/USAGE.md index 44538cb..e9e420f 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -13,7 +13,7 @@ Start with `search_docs`, then pass a returned `page_id` to `get_page` or `summarize_page` supplies text and an outline to the MCP client; the server itself does not run a language model. -With multiple sites configured, `search_docs` ranks all sections in one corpus. +`search_docs` ranks all configured sites in one corpus. Each hit includes its site name and a page ID such as `atlas:guides/install`. Pass that ID to `get_page` or `search_section`; use the optional `site` filter to search only one site. `health_check` lists sites that failed their latest diff --git a/src/mkdocs_mcp/config.py b/src/mkdocs_mcp/config.py index abab83d..e8c1430 100644 --- a/src/mkdocs_mcp/config.py +++ b/src/mkdocs_mcp/config.py @@ -1,7 +1,7 @@ """Configuration via environment variables / ConfigMap / Secret. All settings are 12-factor and typed. Every env var is prefixed ``MKDOCS_MCP_``, -e.g. ``MKDOCS_MCP_BASE_URL=https://docs.example.com``. +e.g. ``MKDOCS_MCP_SITES=[{"name":"docs","base_url":"https://docs.example.com"}]``. The server is generic: no organization-, site-, or project-specific values are hardcoded. Site-specific URLs and server identity are supplied here. @@ -12,7 +12,7 @@ import re from urllib.parse import urlsplit -from pydantic import BaseModel, ValidationInfo, field_validator, model_validator +from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -58,12 +58,7 @@ class Settings(BaseSettings): port: int = 8000 # ---- Source -------------------------------------------------------- - search_index_url: str = "" # defaults to {base_url}/search/search_index.json - sites: list[SiteConfig] = [] # JSON list; empty keeps the single-site settings above - - # ---- Links --------------------------------------------------------- - # Canonical base URL used to build deep-links returned to clients. - base_url: str = "http://localhost:8000" + sites: list[SiteConfig] = Field(min_length=1) # ---- Indexing ------------------------------------------------------ reindex_interval_minutes: int = 30 @@ -92,5 +87,5 @@ def unique_sites(self) -> Settings: def get_settings() -> Settings: global _settings if _settings is None: - _settings = Settings() + _settings = Settings() # type: ignore[call-arg] # SITES is supplied by the environment. return _settings diff --git a/src/mkdocs_mcp/indexing/indexer.py b/src/mkdocs_mcp/indexing/indexer.py index e4e2c94..85822b8 100644 --- a/src/mkdocs_mcp/indexing/indexer.py +++ b/src/mkdocs_mcp/indexing/indexer.py @@ -12,7 +12,7 @@ from markdown_it import MarkdownIt -from ..config import Settings, SiteConfig +from ..config import Settings from ..models import CodeBlock, Document, Section from ..obs import metrics from ..obs.logging import get_logger @@ -79,9 +79,7 @@ class Indexer: def __init__(self, cfg: Settings, engine: SearchEngine) -> None: self.cfg = cfg self.engine = engine - self.sites = cfg.sites or [ - SiteConfig(name="default", base_url=cfg.base_url, search_index_url=cfg.search_index_url) - ] + self.sites = cfg.sites self.sources = { site.name: SearchIndexSource(site.base_url, site.search_index_url) for site in self.sites @@ -148,7 +146,7 @@ async def _build(self, start: float) -> IndexStore: continue for page in corpus.pages: rel_id = page_id_from_relpath(page.rel_path) - page_id = f"{site.name}:{rel_id}" if self.cfg.sites else rel_id + page_id = f"{site.name}:{rel_id}" title, sections, headings, content, published_url = _published_page( page, site.base_url, page_id, site.name ) diff --git a/src/mkdocs_mcp/ui.py b/src/mkdocs_mcp/ui.py index 638845c..aa8d2dd 100644 --- a/src/mkdocs_mcp/ui.py +++ b/src/mkdocs_mcp/ui.py @@ -66,18 +66,14 @@ def home_page(cfg: Settings, ctx: AppContext) -> str: else "Loading documentation" ) status_class = "" if loaded and not ctx.indexer.failed_sites else " wait" - links = [(site.name, site.base_url) for site in cfg.sites] or [("", cfg.base_url)] + links = [(site.name, site.base_url) for site in cfg.sites] docs_link = "".join( f'' f"Open {escape(name + ' ' if name else '')}documentation ↗" for name, url in links if urlsplit(url).scheme in {"http", "https"} and urlsplit(url).netloc ) - index_label = ( - f"{len(ctx.indexer.corpora)}/{len(cfg.sites)} published MkDocs sites" - if cfg.sites - else "Published MkDocs search index" - ) + index_label = f"{len(ctx.indexer.corpora)}/{len(cfg.sites)} published MkDocs sites" body = f"""
Documentation · MCP

{escape(cfg.server_name)}

diff --git a/tests/conftest.py b/tests/conftest.py index e78b911..a44bfa3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ import httpx import pytest -from mkdocs_mcp.config import Settings +from mkdocs_mcp.config import Settings, SiteConfig from mkdocs_mcp.context import AppContext from mkdocs_mcp.sources import search_index_source @@ -14,7 +14,7 @@ @pytest.fixture def cfg(): return Settings( - base_url="https://docs.test", + sites=[SiteConfig(name="docs", base_url="https://docs.test")], reindex_interval_minutes=0, metrics_enabled=False, rate_limit_enabled=False, diff --git a/tests/test_multi_site.py b/tests/test_multi_site.py index f2631a4..b900ef4 100644 --- a/tests/test_multi_site.py +++ b/tests/test_multi_site.py @@ -127,6 +127,12 @@ def handler(request): def test_sites_parse_from_environment_and_reject_duplicate_names(monkeypatch): + monkeypatch.delenv("MKDOCS_MCP_SITES", raising=False) + with pytest.raises(ValidationError, match="sites"): + Settings(_env_file=None) + monkeypatch.setenv("MKDOCS_MCP_SITES", "[]") + with pytest.raises(ValidationError, match="at least 1"): + Settings(_env_file=None) monkeypatch.setenv( "MKDOCS_MCP_SITES", json.dumps([{"name": "atlas", "base_url": "https://atlas.example.com"}]), diff --git a/tests/test_published_source.py b/tests/test_published_source.py index 66b337f..04d724e 100644 --- a/tests/test_published_source.py +++ b/tests/test_published_source.py @@ -5,7 +5,7 @@ from fastmcp import Client, FastMCP from prometheus_client import REGISTRY -from mkdocs_mcp.config import Settings +from mkdocs_mcp.config import Settings, SiteConfig from mkdocs_mcp.context import AppContext from mkdocs_mcp.sources import search_index_source from mkdocs_mcp.tools import register_all @@ -14,8 +14,7 @@ @pytest.fixture def published_cfg(): return Settings( - base_url="https://docs.example.com", - search_index_url="https://docs.example.com/search/search_index.json", + sites=[SiteConfig(name="docs", base_url="https://docs.example.com")], reindex_interval_minutes=0, metrics_enabled=False, rate_limit_enabled=False, @@ -68,10 +67,10 @@ def handler(request): first = await ctx.indexer.build() assert len(first.documents) == 2 assert len(first.sections) == 3 - page = first.get_doc("guides/install") + page = first.get_doc("docs:guides/install") assert page.url == "https://docs.example.com/guides/install/" assert "apiVersion: v1" in page.content - section = first.sections_of("guides/install")[1] + section = first.sections_of("docs:guides/install")[1] assert section.url == "https://docs.example.com/guides/install/#run-it" assert section.heading == "Run it" assert section.code_blocks[0].language == "yaml" @@ -82,10 +81,10 @@ def handler(request): mcp = FastMCP(name="published") register_all(mcp, ctx) async with Client(mcp) as client: - result = await client.call_tool("get_page", {"page_id": "guides/install"}) + result = await client.call_tool("get_page", {"page_id": "docs:guides/install"}) assert result.structured_content["result"]["url"] == page.url assert "apiVersion: v1" in result.structured_content["result"]["content"] - outline = await client.call_tool("summarize_page", {"page_id": "guides/install"}) + outline = await client.call_tool("summarize_page", {"page_id": "docs:guides/install"}) assert outline.structured_content["result"]["sections"][1]["heading"] == "Run it" second = await ctx.indexer.build() @@ -142,7 +141,7 @@ def handler(_request): first = await ctx.indexer.build() await ctx.indexer.safe_build() assert ctx.store is first - assert first.get_doc("index").url == "https://docs.example.com/" + assert first.get_doc("docs:index").url == "https://docs.example.com/" assert ( REGISTRY.get_sample_value("mkdocs_mcp_index_refresh_total", {"result": "error"}) == errors_before + 1 diff --git a/tests/test_search_golden.py b/tests/test_search_golden.py index afdb787..fe59ed6 100644 --- a/tests/test_search_golden.py +++ b/tests/test_search_golden.py @@ -8,9 +8,9 @@ from mkdocs_mcp.search.engine import Filters GOLDEN = [ - ("expose an http service with a hostname", "platform/networking/expose-service"), - ("argocd gitops continuous delivery", "platform/argocd/user-guide"), - ("migrate repository runbook", "platform/argocd/runbooks/migrate-repository"), + ("expose an http service with a hostname", "docs:platform/networking/expose-service"), + ("argocd gitops continuous delivery", "docs:platform/argocd/user-guide"), + ("migrate repository runbook", "docs:platform/argocd/runbooks/migrate-repository"), ] diff --git a/tests/test_tools.py b/tests/test_tools.py index a058b66..49e73b6 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -37,7 +37,7 @@ async def test_tools_registered(client): async def test_search_docs_tool(client): async with client: r = await client.call_tool("search_docs", {"query": "expose http service"}) - assert r.structured_content["result"][0]["page_id"] == "platform/networking/expose-service" + assert r.structured_content["result"][0]["page_id"] == "docs:platform/networking/expose-service" async def test_no_match_has_no_placeholder_result(client): @@ -50,7 +50,7 @@ async def test_search_section_returns_multiple_sections_from_one_page(client): async with client: r = await client.call_tool( "search_section", - {"page_id": "platform/argocd/user-guide", "query": "argo", "limit": 5}, + {"page_id": "docs:platform/argocd/user-guide", "query": "argo", "limit": 5}, ) hits = r.structured_content["result"] assert {"Overview", "CLI Login"} <= {hit["heading"] for hit in hits} @@ -58,7 +58,7 @@ async def test_search_section_returns_multiple_sections_from_one_page(client): async def test_get_page_tool(client): async with client: - r = await client.call_tool("get_page", {"page_id": "platform/argocd/user-guide"}) + r = await client.call_tool("get_page", {"page_id": "docs:platform/argocd/user-guide"}) page = r.structured_content["result"] # optional return wrapped under "result" assert page["title"].startswith("Argo") assert "GitOps" in page["content"] @@ -69,7 +69,7 @@ async def test_find_runbooks(client): async with client: r = await client.call_tool("find_runbooks", {}) ids = {p["page_id"] for p in r.structured_content["result"]} - assert "platform/argocd/runbooks/migrate-repository" in ids + assert "docs:platform/argocd/runbooks/migrate-repository" in ids async def test_find_examples_by_language(client): From 77501c7bd7e095075c0dfdd8260658e4b6f9e4b6 Mon Sep 17 00:00:00 2001 From: ehsanmsb Date: Tue, 27 Oct 2026 23:44:17 +0330 Subject: [PATCH 2/2] fix(chart): keep optional resource templates disabled by default --- charts/mkdocs-mcp/README.md | 5 ++-- charts/mkdocs-mcp/templates/deployment.yaml | 2 ++ charts/mkdocs-mcp/templates/hpa.yaml | 21 ++++++++++++++ .../mkdocs-mcp/templates/networkpolicy.yaml | 28 +++++++++++++++++++ charts/mkdocs-mcp/templates/pdb.yaml | 11 ++++++++ charts/mkdocs-mcp/values.yaml | 14 ++++++++++ 6 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 charts/mkdocs-mcp/templates/hpa.yaml create mode 100644 charts/mkdocs-mcp/templates/networkpolicy.yaml create mode 100644 charts/mkdocs-mcp/templates/pdb.yaml diff --git a/charts/mkdocs-mcp/README.md b/charts/mkdocs-mcp/README.md index 472c9be..9ee21f0 100644 --- a/charts/mkdocs-mcp/README.md +++ b/charts/mkdocs-mcp/README.md @@ -22,8 +22,9 @@ helm upgrade --install mkdocs-mcp charts/mkdocs-mcp \ ``` The chart defaults to one replica, with CPU request/limit of 250m/500m and -memory request/limit of 512Mi/1Gi. It does not create an HPA, PDB, or -NetworkPolicy. Readiness gates traffic until a documentation index has loaded. +memory request/limit of 512Mi/1Gi. HPA, PDB, and NetworkPolicy templates are +available but disabled by default in `values.yaml`. Readiness gates traffic +until a documentation index has loaded. The container uses a non-root, read-only filesystem with writable `/tmp`. OpenShift assigns the UID through its SCC. diff --git a/charts/mkdocs-mcp/templates/deployment.yaml b/charts/mkdocs-mcp/templates/deployment.yaml index 2ff1d33..589f26a 100644 --- a/charts/mkdocs-mcp/templates/deployment.yaml +++ b/charts/mkdocs-mcp/templates/deployment.yaml @@ -4,7 +4,9 @@ metadata: name: {{ include "mkdocs-mcp.fullname" . }} labels: {{- include "mkdocs-mcp.labels" . | nindent 4 }} spec: + {{- if not .Values.autoscaling.enabled }} replicas: {{ .Values.replicaCount }} + {{- end }} selector: matchLabels: {{- include "mkdocs-mcp.selectorLabels" . | nindent 6 }} template: diff --git a/charts/mkdocs-mcp/templates/hpa.yaml b/charts/mkdocs-mcp/templates/hpa.yaml new file mode 100644 index 0000000..f04e132 --- /dev/null +++ b/charts/mkdocs-mcp/templates/hpa.yaml @@ -0,0 +1,21 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "mkdocs-mcp.fullname" . }} + labels: {{- include "mkdocs-mcp.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "mkdocs-mcp.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/charts/mkdocs-mcp/templates/networkpolicy.yaml b/charts/mkdocs-mcp/templates/networkpolicy.yaml new file mode 100644 index 0000000..cf5569d --- /dev/null +++ b/charts/mkdocs-mcp/templates/networkpolicy.yaml @@ -0,0 +1,28 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "mkdocs-mcp.fullname" . }} + labels: {{- include "mkdocs-mcp.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: {{- include "mkdocs-mcp.selectorLabels" . | nindent 6 }} + policyTypes: [Ingress, Egress] + ingress: + - ports: + - { port: {{ .Values.config.port }}, protocol: TCP } + {{- if .Values.config.metricsEnabled }} + - { port: 9000, protocol: TCP } + {{- end }} + {{- with .Values.networkPolicy.ingressFrom }} + from: {{- toYaml . | nindent 8 }} + {{- end }} + egress: + # DNS + - ports: + - { port: 53, protocol: UDP } + - { port: 53, protocol: TCP } + # Published MkDocs search index over HTTPS + - ports: + - { port: 443, protocol: TCP } +{{- end }} diff --git a/charts/mkdocs-mcp/templates/pdb.yaml b/charts/mkdocs-mcp/templates/pdb.yaml new file mode 100644 index 0000000..1467519 --- /dev/null +++ b/charts/mkdocs-mcp/templates/pdb.yaml @@ -0,0 +1,11 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "mkdocs-mcp.fullname" . }} + labels: {{- include "mkdocs-mcp.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: {{- include "mkdocs-mcp.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/mkdocs-mcp/values.yaml b/charts/mkdocs-mcp/values.yaml index c13f36b..0ad27b3 100644 --- a/charts/mkdocs-mcp/values.yaml +++ b/charts/mkdocs-mcp/values.yaml @@ -48,10 +48,24 @@ resources: cpu: 500m memory: 1Gi +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 75 + +podDisruptionBudget: + enabled: false + minAvailable: 1 + serviceMonitor: enabled: true interval: 30s +networkPolicy: + enabled: false + ingressFrom: [] + # OpenShift-friendly: do NOT pin runAsUser; let the SCC assign the UID. podSecurityContext: runAsNonRoot: true