From 86e593f37cc8087e0e46df0707aedfc821e1efb6 Mon Sep 17 00:00:00 2001 From: Felipe Gonzalez Date: Thu, 21 May 2026 17:54:38 -0300 Subject: [PATCH 1/4] feat: Handle skills as resources --- .github/workflows/build_mcp_server.yml | 5 + .github/workflows/check_catalog.yml | 8 + .github/workflows/check_mcp_server.yml | 8 + .github/workflows/test_mcp_server.yml | 8 + catalog/README.md | 84 ++++- catalog/scripts/generate-skill-catalog.mjs | 40 +++ catalog/scripts/publish-catalogs.sh | 16 + catalog/skill-catalog.json | 316 ++++++++++++++++++ catalog/skill-catalog.manifest.json | 316 ++++++++++++++++++ extensions/control-plane/Chart.yaml | 2 +- extensions/control-plane/README.md | 4 + .../stage4-04-statefulset-supernode-mcp.yaml | 8 + extensions/control-plane/values.yaml | 5 + mcp-server/src/config.rs | 92 +++++ mcp-server/src/errors.rs | 8 + mcp-server/src/main.rs | 1 + mcp-server/src/mcp.rs | 9 +- mcp-server/src/prompts/catalog.rs | 34 +- mcp-server/src/resources/router.rs | 107 +++++- mcp-server/src/resources/uri.rs | 31 +- mcp-server/src/server.rs | 3 + mcp-server/src/skills/mod.rs | 221 ++++++++++++ mcp-server/src/skills/oci.rs | 293 ++++++++++++++++ mcp-server/src/skills/source.rs | 26 ++ 24 files changed, 1612 insertions(+), 33 deletions(-) create mode 100644 catalog/scripts/generate-skill-catalog.mjs create mode 100755 catalog/scripts/publish-catalogs.sh create mode 100644 catalog/skill-catalog.json create mode 100644 catalog/skill-catalog.manifest.json create mode 100644 mcp-server/src/skills/mod.rs create mode 100644 mcp-server/src/skills/oci.rs create mode 100644 mcp-server/src/skills/source.rs diff --git a/.github/workflows/build_mcp_server.yml b/.github/workflows/build_mcp_server.yml index 0b2f9d3..b85fc18 100644 --- a/.github/workflows/build_mcp_server.yml +++ b/.github/workflows/build_mcp_server.yml @@ -7,7 +7,9 @@ on: paths: - ".github/image/Dockerfile" - ".github/workflows/build_mcp_server.yml" + - "catalog/**" - "mcp-server/**" + - "skills/**" jobs: build: @@ -42,6 +44,9 @@ jobs: with: toolchain: stable + - name: Generate skill catalog + run: node catalog/scripts/generate-skill-catalog.mjs + - name: Run cargo build run: cargo build --target ${{ matrix.target }} ${{ matrix.args }} working-directory: mcp-server diff --git a/.github/workflows/check_catalog.yml b/.github/workflows/check_catalog.yml index bbe0e51..1a55c33 100644 --- a/.github/workflows/check_catalog.yml +++ b/.github/workflows/check_catalog.yml @@ -6,13 +6,17 @@ on: - main paths: - ".github/workflows/check_catalog.yml" + - "catalog/**" - "frontends/catalog/**" + - "skills/**" pull_request: branches: - main paths: - ".github/workflows/check_catalog.yml" + - "catalog/**" - "frontends/catalog/**" + - "skills/**" jobs: check: @@ -39,6 +43,10 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify generated skill catalog + working-directory: . + run: node catalog/scripts/generate-skill-catalog.mjs + - name: Run typecheck run: pnpm run typecheck diff --git a/.github/workflows/check_mcp_server.yml b/.github/workflows/check_mcp_server.yml index 4fa3f5d..433d635 100644 --- a/.github/workflows/check_mcp_server.yml +++ b/.github/workflows/check_mcp_server.yml @@ -6,13 +6,17 @@ on: - main paths: - ".github/workflows/check_mcp_server.yml" + - "catalog/**" - "mcp-server/**" + - "skills/**" pull_request: branches: - main paths: - ".github/workflows/check_mcp_server.yml" + - "catalog/**" - "mcp-server/**" + - "skills/**" jobs: lint: @@ -24,5 +28,9 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - name: Generate skill catalog + working-directory: . + run: node catalog/scripts/generate-skill-catalog.mjs + - name: Clippy check lints run: cargo clippy -- -D warnings diff --git a/.github/workflows/test_mcp_server.yml b/.github/workflows/test_mcp_server.yml index bbba2f0..8316494 100644 --- a/.github/workflows/test_mcp_server.yml +++ b/.github/workflows/test_mcp_server.yml @@ -6,13 +6,17 @@ on: - main paths: - ".github/workflows/test_mcp_server.yml" + - "catalog/**" - "mcp-server/**" + - "skills/**" pull_request: branches: - main paths: - ".github/workflows/test_mcp_server.yml" + - "catalog/**" - "mcp-server/**" + - "skills/**" jobs: lint: @@ -24,5 +28,9 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - name: Generate skill catalog + working-directory: . + run: node catalog/scripts/generate-skill-catalog.mjs + - name: Test run: cargo test diff --git a/catalog/README.md b/catalog/README.md index e3e8997..de2d753 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -1,12 +1,22 @@ -# Extension Catalog +# MCP Catalogs -This directory contains the Supernode MCP extension catalog. +This directory contains the catalog documents consumed by the Supernode MCP server. -`extension-catalog.json` is the catalog document consumed by the MCP server. It is also the payload that should be published as the official OCI catalog artifact. +- `extension-catalog.json`: installable extension contracts and chart references. +- `skill-catalog.manifest.json`: editable metadata for operational skill guides. +- `skill-catalog.json`: generated, gitignored skill catalog payload with embedded markdown content. -## Contract +Run this after editing `skill-catalog.manifest.json` or `../skills/*.md`: -The catalog document uses this top-level shape: +```sh +node catalog/scripts/generate-skill-catalog.mjs +``` + +`skill-catalog.json` is intentionally not committed. Generate it locally before building MCP from source or publishing the skill catalog artifact. + +## Extension Catalog Contract + +`extension-catalog.json` uses this top-level shape: ```json { @@ -28,6 +38,28 @@ Each extension entry describes the MCP-facing extension contract: - `outputs`: user-facing endpoints provided by workloads of this extension. - `chart`: OCI Helm chart reference used by MCP install and upgrade operations. +## Skill Catalog Contract + +`skill-catalog.json` uses this top-level shape: + +```json +{ + "schemaVersion": "supernode.skillCatalog/v1", + "skills": [] +} +``` + +Each skill entry describes one operational guide: + +- `id`: canonical URI-safe skill ID used by `supernode://skills/{skillId}`. +- `title` and `description`: human-readable metadata for agents and users. +- `tags`: discovery labels. +- `extensions`: related extension IDs, when applicable. +- `tools`: MCP tools used by the guide. +- `content`: markdown guide content embedded from `../skills/*.md`. + +Edit `skill-catalog.manifest.json` rather than `skill-catalog.json` directly. The manifest stores the same metadata plus `contentPath`; the generator embeds the referenced markdown into the publishable JSON document. Because `skill-catalog.json` is generated and gitignored, changes to skill metadata or markdown are published only after regenerating it. + ## Trusted Sources By default, MCP only trusts official Supernode OCI references: @@ -35,45 +67,65 @@ By default, MCP only trusts official Supernode OCI references: - Catalog artifacts must be fetched from `oci.supernode.store`. - Extension charts must be under `oci://oci.supernode.store/extensions/{extensionId}`. -This is intentional. The catalog influences what agents recommend and what MCP can install or execute for metrics collection. Treat it as a supply-chain input, not a cosmetic document. +This is intentional. Extension catalogs influence what MCP can install and execute for metrics collection. Skill catalogs are prompt-bearing operational guidance and can influence agent behavior. Treat both as supply-chain inputs, not cosmetic documents. For local development only, MCP can be started with: ```text MCP_EXTENSION_CATALOG_ALLOW_UNTRUSTED=true +MCP_SKILL_CATALOG_ALLOW_UNTRUSTED=true ``` -Do not enable this in production. An untrusted catalog can point MCP at untrusted charts, mislead agents through descriptions and schemas, or define unsafe metrics collection metadata. +Do not enable these in production. ## Publishing -The official catalog should be published as a standalone OCI artifact containing `extension-catalog.json` as a JSON layer. +The official catalogs should be published as standalone OCI artifacts containing a single JSON layer. Use the publish script rather than invoking `oras` directly; it regenerates `skill-catalog.json` immediately before pushing so the OCI artifact matches the current manifest and markdown. -Recommended layer media type: +Recommended extension catalog layer media type: ```text application/vnd.supernode.extension-catalog.v1+json ``` -Example using `oras`: +Recommended skill catalog layer media type: + +```text +application/vnd.supernode.skill-catalog.v1+json +``` + +Publish both catalogs with: + +```sh +CATALOG_TAG=0.1.0 catalog/scripts/publish-catalogs.sh +``` + +The script runs the equivalent of: ```sh +node catalog/scripts/generate-skill-catalog.mjs + oras push \ - oci.supernode.store/extension-catalog:0.1.0 \ - extension-catalog.json:application/vnd.supernode.extension-catalog.v1+json + oci.supernode.store/extension-catalog:${CATALOG_TAG} \ + catalog/extension-catalog.json:application/vnd.supernode.extension-catalog.v1+json + +oras push \ + oci.supernode.store/skill-catalog:${CATALOG_TAG} \ + catalog/skill-catalog.json:application/vnd.supernode.skill-catalog.v1+json ``` Production deployments should prefer digest-pinned catalog references when practical: ```text oci://oci.supernode.store/extension-catalog@sha256: +oci://oci.supernode.store/skill-catalog@sha256: ``` Tag references are supported and convenient for development or release channels, but digest references are safer because they are immutable. ## Validation -MCP validates the catalog at load time: +MCP validates extension catalogs at load time: - `schemaVersion` must be `supernode.extensionCatalog/v1`. - extension IDs must be unique and non-empty. @@ -83,4 +135,10 @@ MCP validates the catalog at load time: - chart references must be OCI references. - by default, chart references must point to `oci://oci.supernode.store/extensions/{extensionId}`. +MCP validates skill catalogs at load time: + +- `schemaVersion` must be `supernode.skillCatalog/v1`. +- skill IDs must be unique, non-empty, and URI-safe. +- `title`, `description`, and `content` must be non-empty. + Longer term, official catalog artifacts should also be signed and verified before MCP accepts them. diff --git a/catalog/scripts/generate-skill-catalog.mjs b/catalog/scripts/generate-skill-catalog.mjs new file mode 100644 index 0000000..c69d35f --- /dev/null +++ b/catalog/scripts/generate-skill-catalog.mjs @@ -0,0 +1,40 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const catalogDir = resolve(scriptDir, ".."); +const manifestPath = resolve(catalogDir, "skill-catalog.manifest.json"); +const outputPath = resolve(catalogDir, "skill-catalog.json"); + +const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + +if (manifest.schemaVersion !== "supernode.skillCatalogManifest/v1") { + throw new Error( + `unsupported skill catalog manifest schema: ${manifest.schemaVersion}`, + ); +} + +const seen = new Set(); +const skills = manifest.skills.map(({ contentPath, ...skill }) => { + if (!skill.id || seen.has(skill.id)) { + throw new Error(`invalid or duplicate skill id: ${skill.id}`); + } + seen.add(skill.id); + + if (!contentPath) { + throw new Error(`missing contentPath for skill: ${skill.id}`); + } + + return { + ...skill, + content: readFileSync(resolve(catalogDir, contentPath), "utf8"), + }; +}); + +const catalog = { + schemaVersion: "supernode.skillCatalog/v1", + skills, +}; + +writeFileSync(outputPath, `${JSON.stringify(catalog, null, 2)}\n`); diff --git a/catalog/scripts/publish-catalogs.sh b/catalog/scripts/publish-catalogs.sh new file mode 100755 index 0000000..3e64ebd --- /dev/null +++ b/catalog/scripts/publish-catalogs.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +REGISTRY="${REGISTRY:-oci.supernode.store}" +CATALOG_TAG="${CATALOG_TAG:-0.1.0}" + +node "${ROOT_DIR}/catalog/scripts/generate-skill-catalog.mjs" + +oras push \ + "${REGISTRY}/extension-catalog:${CATALOG_TAG}" \ + "${ROOT_DIR}/catalog/extension-catalog.json:application/vnd.supernode.extension-catalog.v1+json" + +oras push \ + "${REGISTRY}/skill-catalog:${CATALOG_TAG}" \ + "${ROOT_DIR}/catalog/skill-catalog.json:application/vnd.supernode.skill-catalog.v1+json" diff --git a/catalog/skill-catalog.json b/catalog/skill-catalog.json new file mode 100644 index 0000000..d8dbc1f --- /dev/null +++ b/catalog/skill-catalog.json @@ -0,0 +1,316 @@ +{ + "schemaVersion": "supernode.skillCatalog/v1", + "skills": [ + { + "id": "apex-fusion-block-producer-deployment", + "title": "Apex Fusion Block Producer Deployment", + "description": "Deploy or update an Apex Fusion block producer.", + "tags": ["apex-fusion", "block-producer", "deploy"], + "extensions": ["apex-fusion-block-producer"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "vault.runtime.metadata.get", + "workloads.install", + "workloads.upgrade", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "content": "# Apex Fusion Block Producer Deployment\n\n## Goal\n\nInstall or upgrade an Apex Fusion block producer with MCP tools only.\n\n## Required Inputs\n\n- release name\n- namespace\n- network: `vector-testnet`, `prime-testnet`, or `prime-mainnet`\n- pool ID\n- producer runtime Vault path\n- producer storage class\n- relay storage class when `relays.count > 0`\n- either managed relay count or explicit trusted relay targets\n\n## Workflow\n\n1. Call `supernode.status.get`.\n2. Call `extensions.catalog.get` with `extensionId=apex-fusion-block-producer`.\n3. Call `cluster.storage_classes.list`.\n4. Call `workloads.list` to inspect same-network relay candidates.\n5. If using existing relays, ask the operator to approve the exact `relays.trusted` addresses.\n6. Call `vault.runtime.metadata.get` for the runtime path when validating existing material.\n7. If material must be written or updated, use `vault.runtime.write` or `vault.runtime.patch`; do not ask for secret values in chat.\n8. Call `workloads.install` or `workloads.upgrade` with `dryRun=true` and direct `apex-fusion-block-producer` chart values.\n9. Review the dry-run result with the operator.\n10. Call the same workload mutation with `dryRun=false` only after approval.\n11. Validate with `workloads.get`, `workloads.logs.get`, `workloads.metrics.get`, and `cluster.events.list`.\n\n## Debug Configuration Pattern\n\n```json\n{\n \"node\": {\n \"network\": \"vector-testnet\"\n },\n \"persistence\": {\n \"storageClass\": \"\",\n \"size\": \"20Gi\"\n },\n \"relayPersistence\": {\n \"storageClass\": \"\",\n \"size\": \"20Gi\"\n },\n \"blockProducer\": {\n \"debug\": true,\n \"poolId\": \"\",\n \"vaultStaticSecret\": {\n \"path\": \"runtime/apex-fusion/-/block-producer\"\n }\n },\n \"relays\": {\n \"count\": 1\n }\n}\n```\n\n## Activation\n\nUse `workloads.upgrade` with `dryRun=true`, changing only `blockProducer.debug` from `true` to `false`. Run live only after operator approval.\n\n## Rules\n\n- MCP does not auto-resolve trusted relays.\n- Do not use non-MCP commands.\n" + }, + { + "id": "apex-fusion-relay-setup", + "title": "Apex Fusion Relay Setup", + "description": "Install and validate an Apex Fusion relay.", + "tags": ["apex-fusion", "relay", "install"], + "extensions": ["apex-fusion-relay"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "workloads.install", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "content": "# Apex Fusion Relay Setup\n\n## Goal\n\nInstall and validate an Apex Fusion relay with MCP tools only.\n\n## Required Inputs\n\n- release name\n- namespace\n- network: `vector-testnet`, `prime-testnet`, or `prime-mainnet`\n- storage class selected from MCP\n- optional direct chart values from the catalog schema\n\n## Workflow\n\n1. Call `supernode.status.get`.\n2. Call `extensions.catalog.get` with `extensionId=apex-fusion-relay`.\n3. Call `cluster.storage_classes.list` and ask the operator to choose one.\n4. Call `workloads.list` to identify same-network workloads and naming conflicts.\n5. Call `workloads.install` with `dryRun=true` and direct `apex-fusion-relay` chart values.\n6. Review the dry-run result with the operator.\n7. Call `workloads.install` with `dryRun=false` only after approval.\n8. Validate with `workloads.get`, `workloads.logs.get`, `workloads.metrics.get`, and `cluster.events.list`.\n\n## Minimal Configuration\n\n```json\n{\n \"node\": {\n \"network\": \"vector-testnet\"\n },\n \"persistence\": {\n \"storageClass\": \"\",\n \"size\": \"20Gi\"\n }\n}\n```\n\n## Rules\n\n- Use `apex-fusion-relay` for new relays.\n- Do not use non-MCP commands.\n" + }, + { + "id": "cardano-block-producer-troubleshooting", + "title": "Cardano Block Producer Troubleshooting", + "description": "Troubleshoot Cardano block producer health.", + "tags": ["cardano", "block-producer", "troubleshooting"], + "extensions": ["cardano-block-producer"], + "tools": [ + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list", + "vault.runtime.metadata.get" + ], + "content": "# Cardano Block Producer Troubleshooting\n\n## Goal\n\nTroubleshoot a Cardano block producer using MCP tools only.\n\n## Workflow\n\n1. Call `workloads.get` for the producer.\n2. Call `workloads.metrics.get` for producer metrics.\n3. Call `workloads.logs.get` for bounded recent logs.\n4. Call `cluster.events.list` for the producer namespace.\n5. Call `workloads.get` and `workloads.metrics.get` for trusted relay workloads.\n6. If Dolos is installed for the same network, call `dolos.snapshot.refresh` when an external chain-view refresh is needed.\n\n## Check From MCP\n\n- producer mode and debug/active state\n- sync health\n- peer health\n- KES and op-cert status\n- schedule fields\n- local forging/adoption fields when exposed\n- relay workload health\n- recent Kubernetes events returned by MCP\n\n## Limits\n\nIf MCP does not expose raw sockets, raw topology files, or canonical block outcome tracking, state that limitation and stop instead of giving shell commands.\n\n## Rules\n\n- Do not exec into pods.\n- Do not query external HTTP APIs directly.\n- Do not use non-MCP commands.\n" + }, + { + "id": "cardano-block-producer-upgrade", + "title": "Cardano Block Producer Upgrade", + "description": "Upgrade a Cardano block producer through catalog-backed MCP workflows.", + "tags": ["cardano", "block-producer", "upgrade"], + "extensions": ["cardano-block-producer"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "workloads.get", + "workloads.metrics.get", + "vault.runtime.metadata.get", + "workloads.upgrade", + "workloads.logs.get", + "cluster.events.list" + ], + "content": "# Cardano Block Producer Upgrade\n\n## Goal\n\nInstall or upgrade a Cardano block producer with MCP tools only.\n\n## Required Inputs\n\n- release name\n- namespace\n- Cardano network\n- pool ID\n- producer runtime Vault path\n- producer storage class\n- relay storage class when `relays.count > 0`\n- either managed relay count or explicit trusted relay targets\n\n## Workflow\n\n1. Call `supernode.status.get`.\n2. Call `extensions.catalog.get` with `extensionId=cardano-block-producer`.\n3. Call `cluster.storage_classes.list`.\n4. Call `workloads.list` to inspect same-network relay candidates.\n5. If using existing relays, ask the operator to approve the exact `relays.trusted` addresses.\n6. Call `vault.runtime.metadata.get` for the runtime path when validating existing material.\n7. If material must be written or updated, use `vault.runtime.write` or `vault.runtime.patch`; do not ask for secret values in chat.\n8. Call `workloads.install` or `workloads.upgrade` with `dryRun=true` and direct `cardano-block-producer` chart values.\n9. Review the dry-run result with the operator.\n10. Call the same workload mutation with `dryRun=false` only after approval.\n11. Validate with `workloads.get`, `workloads.logs.get`, `workloads.metrics.get`, and `cluster.events.list`.\n\n## Debug Configuration Pattern\n\n```json\n{\n \"node\": {\n \"network\": \"preview\"\n },\n \"persistence\": {\n \"storageClass\": \"\",\n \"size\": \"80Gi\"\n },\n \"relayPersistence\": {\n \"storageClass\": \"\",\n \"size\": \"80Gi\"\n },\n \"blockProducer\": {\n \"debug\": true,\n \"poolId\": \"\",\n \"vaultStaticSecret\": {\n \"path\": \"runtime/cardano-node/-/block-producer\"\n }\n },\n \"relays\": {\n \"count\": 1\n }\n}\n```\n\n## Existing Relay Pattern\n\n```json\n{\n \"relays\": {\n \"count\": 0,\n \"trusted\": [\n {\n \"address\": \"..svc.cluster.local\",\n \"port\": 3000,\n \"valency\": 1\n }\n ],\n \"useLedgerAfterSlot\": -1\n }\n}\n```\n\n## Activation\n\nUse `workloads.upgrade` with `dryRun=true`, changing only `blockProducer.debug` from `true` to `false`. Run live only after operator approval.\n\n## Rules\n\n- Do not use old `node.blockProducer.*` fields with `cardano-block-producer`.\n- MCP does not auto-resolve trusted relays.\n- Do not use non-MCP cluster, Helm, Vault CLI, or Cardano CLI commands.\n" + }, + { + "id": "cardano-block-producer-verification", + "title": "Cardano Block Producer Verification", + "description": "Verify Cardano block producer readiness and health.", + "tags": ["cardano", "block-producer", "verify"], + "extensions": ["cardano-block-producer"], + "tools": [ + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "content": "# Cardano Block Producer Verification\n\n## Goal\n\nVerify block-producer readiness using MCP tools only.\n\n## Workflow\n\n1. Call `workloads.get` for the producer.\n2. Call `workloads.metrics.get` for producer metrics.\n3. Call `workloads.logs.get` for recent producer logs if metrics show errors.\n4. Call `cluster.events.list` for namespace events if the workload is not healthy.\n5. If Dolos is installed for the same network, use `dolos.snapshot.refresh` when external chain-view refresh is needed.\n\n## Verify From MCP\n\n- workload is healthy\n- Vault-backed runtime material is mounted according to workload status\n- debug-mode or active forging mode is as expected\n- KES and op-cert fields are present when exposed by metrics\n- peer and sync fields are healthy\n- schedule fields are present when exposed by metrics\n\n## Limits\n\nMCP metrics can show readiness and local producer signals. If MCP does not expose canonical block outcome confirmation, say so and do not claim the pool produced accepted blocks.\n\n## Rules\n\n- Do not use non-MCP commands.\n- Do not overstate what MCP metrics prove.\n" + }, + { + "id": "cardano-node-metrics-access", + "title": "Cardano Node Metrics Access", + "description": "Inspect Cardano node metrics through MCP tools only.", + "tags": ["cardano", "metrics"], + "extensions": ["cardano-relay", "cardano-block-producer"], + "tools": ["workloads.get", "workloads.metrics.get", "workloads.logs.get"], + "content": "# Cardano Node Metrics Access\n\n## Goal\n\nRead Cardano or Apex Fusion workload metrics using MCP tools only.\n\n## Workflow\n\n1. Ask for namespace and workload name if unknown.\n2. Call `workloads.get` to confirm the workload and status.\n3. Call `workloads.metrics.get`.\n4. If metrics are missing or stale, call `workloads.logs.get` and `cluster.events.list`.\n\n## Interpretation\n\n- Use returned sync, resource, peer, KES, op-cert, and forging fields as the MCP source of truth.\n- If raw Prometheus text is needed but `workloads.metrics.get` does not expose it, state that MCP does not currently expose raw metric scraping and stop.\n\n## Rules\n\n- Do not exec into pods.\n- Do not query local container ports.\n- Do not use non-MCP commands.\n" + }, + { + "id": "cardano-relay-setup", + "title": "Cardano Relay Setup", + "description": "Install and validate a Cardano relay.", + "tags": ["cardano", "relay", "install"], + "extensions": ["cardano-relay"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "workloads.install", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "content": "# Cardano Relay Setup\n\n## Goal\n\nInstall and validate a Cardano relay with MCP tools only.\n\n## Required Inputs\n\n- release name\n- namespace\n- Cardano network\n- storage class selected from MCP\n- optional resource, image, service, or topology values from the catalog schema\n\n## Workflow\n\n1. Call `supernode.status.get`.\n2. Call `extensions.catalog.get` with `extensionId=cardano-relay`.\n3. Call `cluster.storage_classes.list` and ask the operator to choose one.\n4. Call `workloads.list` to identify same-network workloads and naming conflicts.\n5. Call `workloads.install` with `dryRun=true` and direct `cardano-relay` chart values.\n6. Review the dry-run result with the operator.\n7. Call `workloads.install` with `dryRun=false` only after approval.\n8. Validate with `workloads.get`, `workloads.logs.get`, `workloads.metrics.get`, and `cluster.events.list`.\n\n## Minimal Configuration\n\n```json\n{\n \"node\": {\n \"network\": \"preview\"\n },\n \"persistence\": {\n \"storageClass\": \"\",\n \"size\": \"80Gi\"\n }\n}\n```\n\n## Rules\n\n- Use `cardano-relay` for new relays.\n- Do not use flat legacy fields such as `network`, `storageClass`, `pvcSize`, or `imageTag`.\n- Do not use non-MCP cluster or Helm commands.\n" + }, + { + "id": "cardano-spo-kes-rotation", + "title": "Cardano SPO KES Rotation", + "description": "Guide MCP-supported checks and deployment steps for KES rotation.", + "tags": ["cardano", "spo", "kes"], + "extensions": ["cardano-block-producer"], + "tools": [ + "workloads.get", + "workloads.metrics.get", + "vault.runtime.metadata.get", + "workloads.upgrade", + "workloads.logs.get", + "cluster.events.list" + ], + "content": "# Cardano SPO KES Rotation\n\n## Goal\n\nHandle only the MCP-supported deployment portion of a KES rotation.\n\n## Boundary\n\nMCP does not currently generate KES keys or issue operational certificates. The operator must complete those steps outside MCP and provide approved runtime material through the MCP client when updating Vault.\n\n## Workflow\n\n1. Call `workloads.get` and `workloads.metrics.get` for the producer.\n2. Call `vault.runtime.metadata.get` for the runtime path.\n3. If the operator is ready to stage new runtime material, call `vault.runtime.patch` or `vault.runtime.write`; do not ask for secret values in chat.\n4. Call `workloads.upgrade` with `dryRun=true` if a workload restart or config change is needed.\n5. Run live `workloads.upgrade` only after approval.\n6. Validate with `workloads.get`, `workloads.logs.get`, `workloads.metrics.get`, and `cluster.events.list`.\n\n## Rules\n\n- Do not generate keys.\n- Do not issue op certs.\n- Do not use non-MCP commands.\n" + }, + { + "id": "cardano-spo-maintenance-overview", + "title": "Cardano SPO Maintenance Overview", + "description": "Summarize MCP-supported and unsupported Cardano SPO maintenance actions.", + "tags": ["cardano", "spo", "maintenance"], + "extensions": ["cardano-relay", "cardano-block-producer"], + "tools": [ + "workloads.list", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list", + "vault.runtime.metadata.get", + "workloads.upgrade", + "workloads.delete" + ], + "content": "# Cardano SPO Maintenance Overview\n\n## Goal\n\nRoute Cardano SPO maintenance work through MCP-supported operations only.\n\n## Supported MCP Actions\n\n- inspect workloads with `workloads.list` and `workloads.get`\n- inspect logs with `workloads.logs.get`\n- inspect metrics with `workloads.metrics.get`\n- inspect events with `cluster.events.list`\n- inspect runtime secret metadata with `vault.runtime.metadata.get`\n- update runtime secret records with `vault.runtime.write` or `vault.runtime.patch`\n- apply workload changes with `workloads.upgrade`\n\n## Not Supported By MCP\n\n- Cardano transaction construction\n- Cardano transaction signing\n- stake pool registration updates\n- stake pool retirement certificate generation\n- KES key generation\n- operational certificate issuance\n- local port-forward sessions\n\nIf the operator requests one of these, say MCP does not currently expose that operation and stop for operator direction.\n\n## Rules\n\n- Start live-changing operations with `dryRun=true` where the tool supports it.\n- Do not ask users to paste signing keys or runtime secret values in chat.\n- Do not use non-MCP commands.\n" + }, + { + "id": "cardano-spo-pool-retirement", + "title": "Cardano SPO Pool Retirement", + "description": "Guide MCP-supported workload checks for Cardano pool retirement.", + "tags": ["cardano", "spo", "retirement"], + "extensions": ["cardano-relay", "cardano-block-producer"], + "tools": [ + "workloads.get", + "workloads.metrics.get", + "workloads.list", + "workloads.logs.get", + "cluster.events.list", + "workloads.delete" + ], + "content": "# Cardano SPO Pool Retirement\n\n## Goal\n\nHandle only MCP-supported checks around a Cardano stake pool retirement.\n\n## Boundary\n\nMCP does not currently build, sign, or submit Cardano retirement transactions. If the operator asks for those steps, state that MCP does not support them and stop.\n\n## MCP Workflow\n\n1. Call `workloads.get` and `workloads.metrics.get` for the producer.\n2. Call `workloads.get` and `workloads.metrics.get` for relays that may remain online after retirement.\n3. Use `workloads.upgrade` with `dryRun=true` for any workload-mode change the operator requests.\n4. Use `workloads.delete` with `dryRun=true` only when the operator explicitly asks to remove a workload.\n5. Apply live changes only after operator approval.\n6. Validate with `workloads.list`, `workloads.get`, `workloads.logs.get`, and `cluster.events.list`.\n\n## Rules\n\n- Do not construct or submit retirement transactions.\n- Do not delete workloads or PVCs unless explicitly approved through MCP dry-run review.\n- Do not use non-MCP commands.\n" + }, + { + "id": "cardano-spo-pool-update", + "title": "Cardano SPO Pool Update", + "description": "Guide MCP-supported checks around Cardano pool publication updates.", + "tags": ["cardano", "spo", "pool-update"], + "extensions": ["cardano-relay", "cardano-block-producer"], + "tools": [ + "workloads.get", + "workloads.metrics.get", + "vault.runtime.metadata.get", + "workloads.logs.get", + "cluster.events.list" + ], + "content": "# Cardano SPO Pool Update\n\n## Goal\n\nHandle only MCP-supported checks around a Cardano stake pool update.\n\n## Boundary\n\nMCP does not currently build, sign, or submit Cardano stake pool update transactions. If the operator asks for those steps, state that MCP does not support them and stop.\n\n## MCP Workflow\n\n1. Call `workloads.get` and `workloads.metrics.get` for the producer.\n2. Call `workloads.get` and `workloads.metrics.get` for relays involved in publication data.\n3. Call `vault.runtime.metadata.get` if runtime material may be affected.\n4. If runtime material must change after the on-chain update, use `vault.runtime.patch` or `vault.runtime.write` through MCP.\n5. Use `workloads.upgrade` with `dryRun=true` for any matching workload configuration change.\n6. Apply live only after operator approval.\n7. Validate with `workloads.get`, `workloads.logs.get`, `workloads.metrics.get`, and `cluster.events.list`.\n\n## Rules\n\n- Do not construct or submit ledger transactions.\n- Do not ask for signing keys.\n- Do not use non-MCP commands.\n" + }, + { + "id": "cardano-stake-pool-from-scratch", + "title": "Cardano Stake Pool From Scratch", + "description": "Guide the MCP-supported Metis deployment portion of a new Cardano stake pool.", + "tags": ["cardano", "stake-pool", "deploy"], + "extensions": ["cardano-relay", "cardano-block-producer"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "workloads.install", + "workloads.upgrade", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list", + "vault.runtime.metadata.get", + "vault.runtime.write" + ], + "content": "# Cardano Stake Pool From Scratch\n\n## Goal\n\nGuide only the MCP-supported Metis deployment portion of a new Cardano stake pool.\n\n## Boundary\n\nMCP does not currently expose Cardano ledger transaction, key-generation, metadata publishing, or offline signing tools. If the operator asks the agent to perform those actions, state that MCP does not support them and stop for operator direction.\n\nThis skill begins once the operator can provide the pool ID and the approved runtime material path, or can provide runtime material through MCP `vault.runtime.write`.\n\n## Required Inputs\n\n- Cardano network\n- relay release name and namespace\n- producer release name and namespace\n- storage class selected from MCP\n- pool ID\n- runtime Vault path for `kes.skey`, `vrf.skey`, and `op.cert`\n- managed relay count or explicit trusted relay targets\n\n## Workflow\n\n1. Call `supernode.status.get`.\n2. Call `extensions.catalog.get` for `cardano-relay` and `cardano-block-producer`.\n3. Call `cluster.storage_classes.list` and ask the operator to choose one.\n4. Call `workloads.list` to inspect existing same-network Cardano workloads.\n5. If no relay exists, install `cardano-relay` through `workloads.install` with `dryRun=true`, then live after approval.\n6. Validate the relay with `workloads.get`, `workloads.logs.get`, and `workloads.metrics.get`.\n7. Call `vault.runtime.metadata.get` for the producer runtime path.\n8. If runtime material must be written, use `vault.runtime.write`; do not ask for secret values in chat.\n9. Install `cardano-block-producer` in debug mode through `workloads.install` with `dryRun=true`.\n10. Review the dry-run result with the operator.\n11. Install live only after approval.\n12. Validate debug mode with `workloads.get`, `workloads.logs.get`, `workloads.metrics.get`, and `cluster.events.list`.\n13. Activate forging with `workloads.upgrade` by changing `blockProducer.debug=false`, first with `dryRun=true`, then live after approval.\n\n## Rules\n\n- Use `cardano-relay` and `cardano-block-producer` for new installs.\n- Use direct chart values from `extensions.catalog.get`.\n- Do not perform Cardano ledger operations from this skill.\n- Do not use non-MCP commands.\n" + }, + { + "id": "dolos-supernode-deployment", + "title": "Dolos Supernode Deployment", + "description": "Install and validate a Dolos supernode.", + "tags": ["dolos", "deploy"], + "extensions": ["dolos"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "workloads.install", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "content": "# Dolos Supernode Deployment\n\n## Goal\n\nDeploy and validate Dolos with MCP tools only.\n\n## Required Inputs\n\n- release name\n- namespace\n- network\n- storage class selected from MCP\n- explicit upstream relay address\n\n## Workflow\n\n1. Call `supernode.status.get`.\n2. Call `extensions.catalog.get` with `extensionId=dolos`.\n3. Call `cluster.storage_classes.list` and ask the operator to choose one.\n4. Call `workloads.list` to inspect same-network relay candidates.\n5. Ask the operator to approve the exact `config.upstreamAddress`.\n6. Call `workloads.install` with `dryRun=true` and direct Dolos chart values.\n7. Review the dry-run result with the operator.\n8. Call `workloads.install` with `dryRun=false` only after approval.\n9. Validate with `workloads.get`, `workloads.logs.get`, `workloads.metrics.get`, and `cluster.events.list`.\n10. Use `dolos.snapshot.refresh` when the operator needs a Dolos snapshot refresh.\n\n## Minimal Configuration\n\n```json\n{\n \"dolos\": {\n \"network\": \"cardano-preview\"\n },\n \"persistence\": {\n \"storageClass\": \"\",\n \"size\": \"50Gi\"\n },\n \"config\": {\n \"upstreamAddress\": \"..svc.cluster.local:3000\"\n }\n}\n```\n\n## Rules\n\n- MCP does not auto-resolve Dolos upstreams.\n- Do not use old flat Dolos fields.\n- Do not use non-MCP commands.\n" + }, + { + "id": "hydra-head-operations", + "title": "Hydra Head Operations", + "description": "Guide MCP-supported inspection for Hydra head operations.", + "tags": ["hydra", "operations"], + "extensions": ["hydra-node"], + "tools": ["workloads.get", "workloads.metrics.get", "workloads.logs.get"], + "content": "# Hydra Head Operations\n\n## Goal\n\nDefine the MCP boundary for Hydra head operations.\n\n## Boundary\n\nMCP currently exposes Hydra deployment, discovery, logs, metrics, events, Vault runtime operations, and `hydra.keys.generate`. It does not expose direct Hydra head lifecycle tools such as init, commit, close, contest, fanout, decommit, or transaction submission.\n\n## Workflow\n\n1. Call `workloads.get` for the Hydra workload.\n2. Call `workloads.metrics.get` for head status and snapshot-related fields.\n3. Call `workloads.logs.get` for recent Hydra logs.\n4. Present the MCP-observed state to the operator.\n5. If the operator asks for a direct head lifecycle action, state that MCP does not currently expose that operation and stop.\n\n## Rules\n\n- Do not call Hydra HTTP or WebSocket APIs directly.\n- Do not generate port-forward commands.\n- Do not use non-MCP commands.\n" + }, + { + "id": "hydra-node-deployment", + "title": "Hydra Node Deployment", + "description": "Install and validate a Hydra node.", + "tags": ["hydra", "deploy"], + "extensions": ["hydra-node"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "vault.runtime.metadata.get", + "workloads.install", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "content": "# Hydra Node Deployment\n\n## Goal\n\nDeploy and validate a Hydra node with MCP tools only.\n\n## Required Inputs\n\n- release name\n- namespace\n- storage class selected from MCP\n- offline or online mode\n- Hydra signing key runtime path\n- Hydra verification key entries\n- online Cardano socket proxy target when online mode is used\n\n## Workflow\n\n1. Call `supernode.status.get`.\n2. Call `extensions.catalog.get` with `extensionId=hydra-node`.\n3. Call `cluster.storage_classes.list`.\n4. Use `hydra.keys.generate` when the operator wants MCP to generate Hydra keys.\n5. If the operator provides existing runtime material through MCP, use `vault.runtime.write`; do not ask for secret values in chat.\n6. For online mode, call `workloads.list` and ask the operator to approve the Cardano socket proxy target.\n7. Call `workloads.install` with `dryRun=true` and direct `hydra-node` chart values.\n8. Review the dry-run result with the operator.\n9. Call `workloads.install` with `dryRun=false` only after approval.\n10. Validate with `workloads.get`, `workloads.logs.get`, `workloads.metrics.get`, and `cluster.events.list`.\n\n## Offline Configuration Pattern\n\n```json\n{\n \"persistence\": {\n \"storageClass\": \"\"\n },\n \"keys\": {\n \"hydraSigning\": {\n \"vaultStaticSecret\": {\n \"path\": \"runtime/hydra/demo/hydra-signing\"\n }\n },\n \"hydraVerification\": {\n \"items\": [\n {\n \"filename\": \"hydra.vk\",\n \"value\": \"\"\n }\n ]\n }\n }\n}\n```\n\n## Online Rules\n\n- Use `node.cardanoSocketProxy` for online Cardano connectivity.\n- MCP does not auto-discover the proxy target.\n- Do not use unsupported manual socket mount values such as `node.extraVolumes` or `node.extraVolumeMounts`.\n\n## Rules\n\n- Do not read or echo signing key values in chat.\n- Do not use non-MCP commands.\n" + }, + { + "id": "hydra-node-troubleshooting", + "title": "Hydra Node Troubleshooting", + "description": "Troubleshoot Hydra node startup, networking, metrics, and head progress.", + "tags": ["hydra", "troubleshooting"], + "extensions": ["hydra-node"], + "tools": [ + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list", + "vault.runtime.metadata.get" + ], + "content": "# Hydra Node Troubleshooting\n\n## Goal\n\nTroubleshoot Hydra node startup, networking, metrics, and head progress with MCP tools only.\n\n## Workflow\n\n1. Call `workloads.get` for the Hydra release.\n2. Call `workloads.logs.get` for recent Hydra logs.\n3. Call `workloads.metrics.get` for the derived Hydra metrics payload.\n4. Call `cluster.events.list` for scheduling, mount, probe, image, or PVC errors.\n5. Call `vault.runtime.metadata.get` for runtime paths when secret sync is suspected.\n\n## Check From MCP\n\n- workload health\n- event errors\n- log errors\n- metric collection errors\n- peer connection counts\n- head status\n- snapshot status\n- configured outputs\n\n## Limits\n\nIf direct Hydra HTTP or WebSocket interaction is required, state that MCP does not currently expose Hydra API operation tools and stop.\n\n## Rules\n\n- Do not call Hydra HTTP or WebSocket APIs directly.\n- Do not generate port-forward commands.\n- Do not use non-MCP commands.\n" + }, + { + "id": "kubernetes-extension-discovery", + "title": "Kubernetes Extension Discovery", + "description": "Discover installed catalog-managed workloads.", + "tags": ["kubernetes", "discovery"], + "extensions": [], + "tools": [ + "supernode.status.get", + "extensions.catalog.list", + "workloads.list", + "workloads.get", + "extensions.catalog.get" + ], + "content": "# Kubernetes Extension Discovery\n\n## Goal\n\nDiscover Metis extensions and installed workloads using MCP tools only.\n\n## Workflow\n\n1. Call `supernode.status.get`.\n2. Call `extensions.catalog.list`.\n3. Call `workloads.list` with `includeControlPlane=true` when control-plane visibility matters.\n4. For a candidate workload, call `workloads.get` with its namespace and name.\n5. For a candidate extension, call `extensions.catalog.get` with the extension ID.\n\n## What To Identify\n\n- release name\n- namespace\n- extension or chart ID\n- workload status\n- outputs exposed by the workload\n- whether it is a relay or block-producer workload\n\n## Current Preferred Extension IDs\n\n- `cardano-relay`\n- `cardano-block-producer`\n- `apex-fusion-relay`\n- `apex-fusion-block-producer`\n- `dolos`\n- `hydra-node`\n\n## Rules\n\n- Use `workloads.list` and `workloads.get`; do not infer state from names alone.\n- For new installs, prefer the split relay and block-producer extension IDs.\n- Do not use non-MCP cluster commands from this skill.\n" + }, + { + "id": "kubernetes-storage-and-prereqs", + "title": "Kubernetes Storage And Prereqs", + "description": "Inspect Kubernetes storage and prerequisites.", + "tags": ["kubernetes", "storage", "prereqs"], + "extensions": [], + "tools": [ + "supernode.status.get", + "cluster.storage_classes.list", + "cluster.events.list" + ], + "content": "# Kubernetes Storage And Prereqs\n\n## Goal\n\nValidate cluster readiness before installs or upgrades using MCP tools only.\n\n## Workflow\n\n1. Call `supernode.status.get`.\n2. Call `cluster.storage_classes.list`.\n3. Call `cluster.events.list` for recent scheduling, provisioning, image, probe, or mount problems.\n4. Call `workloads.list` to understand existing releases.\n5. Call `workloads.get` for any workload involved in the planned operation.\n\n## Checks\n\n- The MCP server can reach Kubernetes.\n- A real storage class is selected from `cluster.storage_classes.list`.\n- Existing workloads are healthy enough to use as dependencies.\n- Recent events do not show unresolved scheduling or PVC failures.\n\n## Rules\n\n- Do not assume storage class names.\n- Do not proceed with install or upgrade when MCP reports unresolved PVC, scheduling, or control-plane failures.\n- Do not use non-MCP cluster commands from this skill.\n" + }, + { + "id": "supernode-dashboard-port-forward", + "title": "Supernode Dashboard Port Forward", + "description": "Describe the current MCP boundary for dashboard access.", + "tags": ["dashboard", "access"], + "extensions": [], + "tools": ["supernode.status.get"], + "content": "# Supernode Dashboard Discovery\n\n## Goal\n\nFind dashboard-related control-plane outputs using MCP tools only.\n\n## Workflow\n\n1. Call `workloads.list` with `includeControlPlane=true`.\n2. Find the `control-plane` workload or dashboard-related workload returned by MCP.\n3. Call `workloads.get` for that workload.\n4. Present any dashboard, Grafana, or Prometheus outputs returned by MCP.\n5. If the user asks to open a local tunnel, explain that MCP does not currently provide a long-running port-forward tool and stop.\n\n## Rules\n\n- Do not generate local port-forward commands.\n- Do not use non-MCP cluster commands.\n" + }, + { + "id": "workload-output-port-forward", + "title": "Workload Output Port Forward", + "description": "Inspect workload outputs and describe current MCP access limits.", + "tags": ["workload", "access"], + "extensions": [], + "tools": ["workloads.get"], + "content": "# Workload Output Discovery\n\n## Goal\n\nDiscover workload outputs using MCP tools only.\n\n## Workflow\n\n1. Ask for namespace and workload name if unknown.\n2. Call `workloads.get`.\n3. Present the returned outputs by name, protocol, scope, service, port, and URL.\n4. If the user asks to expose an output locally, explain that MCP does not currently provide a long-running port-forward tool and stop.\n\n## Rules\n\n- Do not generate local port-forward commands.\n- Do not use non-MCP networking commands.\n- Use only the output data returned by `workloads.get`.\n" + } + ] +} diff --git a/catalog/skill-catalog.manifest.json b/catalog/skill-catalog.manifest.json new file mode 100644 index 0000000..d3cfb2d --- /dev/null +++ b/catalog/skill-catalog.manifest.json @@ -0,0 +1,316 @@ +{ + "schemaVersion": "supernode.skillCatalogManifest/v1", + "skills": [ + { + "id": "apex-fusion-block-producer-deployment", + "title": "Apex Fusion Block Producer Deployment", + "description": "Deploy or update an Apex Fusion block producer.", + "tags": ["apex-fusion", "block-producer", "deploy"], + "extensions": ["apex-fusion-block-producer"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "vault.runtime.metadata.get", + "workloads.install", + "workloads.upgrade", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "contentPath": "../skills/apex-fusion-block-producer-deployment.md" + }, + { + "id": "apex-fusion-relay-setup", + "title": "Apex Fusion Relay Setup", + "description": "Install and validate an Apex Fusion relay.", + "tags": ["apex-fusion", "relay", "install"], + "extensions": ["apex-fusion-relay"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "workloads.install", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "contentPath": "../skills/apex-fusion-relay-setup.md" + }, + { + "id": "cardano-block-producer-troubleshooting", + "title": "Cardano Block Producer Troubleshooting", + "description": "Troubleshoot Cardano block producer health.", + "tags": ["cardano", "block-producer", "troubleshooting"], + "extensions": ["cardano-block-producer"], + "tools": [ + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list", + "vault.runtime.metadata.get" + ], + "contentPath": "../skills/cardano-block-producer-troubleshooting.md" + }, + { + "id": "cardano-block-producer-upgrade", + "title": "Cardano Block Producer Upgrade", + "description": "Upgrade a Cardano block producer through catalog-backed MCP workflows.", + "tags": ["cardano", "block-producer", "upgrade"], + "extensions": ["cardano-block-producer"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "workloads.get", + "workloads.metrics.get", + "vault.runtime.metadata.get", + "workloads.upgrade", + "workloads.logs.get", + "cluster.events.list" + ], + "contentPath": "../skills/cardano-block-producer-upgrade.md" + }, + { + "id": "cardano-block-producer-verification", + "title": "Cardano Block Producer Verification", + "description": "Verify Cardano block producer readiness and health.", + "tags": ["cardano", "block-producer", "verify"], + "extensions": ["cardano-block-producer"], + "tools": [ + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "contentPath": "../skills/cardano-block-producer-verification.md" + }, + { + "id": "cardano-node-metrics-access", + "title": "Cardano Node Metrics Access", + "description": "Inspect Cardano node metrics through MCP tools only.", + "tags": ["cardano", "metrics"], + "extensions": ["cardano-relay", "cardano-block-producer"], + "tools": ["workloads.get", "workloads.metrics.get", "workloads.logs.get"], + "contentPath": "../skills/cardano-node-metrics-access.md" + }, + { + "id": "cardano-relay-setup", + "title": "Cardano Relay Setup", + "description": "Install and validate a Cardano relay.", + "tags": ["cardano", "relay", "install"], + "extensions": ["cardano-relay"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "workloads.install", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "contentPath": "../skills/cardano-relay-setup.md" + }, + { + "id": "cardano-spo-kes-rotation", + "title": "Cardano SPO KES Rotation", + "description": "Guide MCP-supported checks and deployment steps for KES rotation.", + "tags": ["cardano", "spo", "kes"], + "extensions": ["cardano-block-producer"], + "tools": [ + "workloads.get", + "workloads.metrics.get", + "vault.runtime.metadata.get", + "workloads.upgrade", + "workloads.logs.get", + "cluster.events.list" + ], + "contentPath": "../skills/cardano-spo-kes-rotation.md" + }, + { + "id": "cardano-spo-maintenance-overview", + "title": "Cardano SPO Maintenance Overview", + "description": "Summarize MCP-supported and unsupported Cardano SPO maintenance actions.", + "tags": ["cardano", "spo", "maintenance"], + "extensions": ["cardano-relay", "cardano-block-producer"], + "tools": [ + "workloads.list", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list", + "vault.runtime.metadata.get", + "workloads.upgrade", + "workloads.delete" + ], + "contentPath": "../skills/cardano-spo-maintenance-overview.md" + }, + { + "id": "cardano-spo-pool-retirement", + "title": "Cardano SPO Pool Retirement", + "description": "Guide MCP-supported workload checks for Cardano pool retirement.", + "tags": ["cardano", "spo", "retirement"], + "extensions": ["cardano-relay", "cardano-block-producer"], + "tools": [ + "workloads.get", + "workloads.metrics.get", + "workloads.list", + "workloads.logs.get", + "cluster.events.list", + "workloads.delete" + ], + "contentPath": "../skills/cardano-spo-pool-retirement.md" + }, + { + "id": "cardano-spo-pool-update", + "title": "Cardano SPO Pool Update", + "description": "Guide MCP-supported checks around Cardano pool publication updates.", + "tags": ["cardano", "spo", "pool-update"], + "extensions": ["cardano-relay", "cardano-block-producer"], + "tools": [ + "workloads.get", + "workloads.metrics.get", + "vault.runtime.metadata.get", + "workloads.logs.get", + "cluster.events.list" + ], + "contentPath": "../skills/cardano-spo-pool-update.md" + }, + { + "id": "cardano-stake-pool-from-scratch", + "title": "Cardano Stake Pool From Scratch", + "description": "Guide the MCP-supported Metis deployment portion of a new Cardano stake pool.", + "tags": ["cardano", "stake-pool", "deploy"], + "extensions": ["cardano-relay", "cardano-block-producer"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "workloads.install", + "workloads.upgrade", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list", + "vault.runtime.metadata.get", + "vault.runtime.write" + ], + "contentPath": "../skills/cardano-stake-pool-from-scratch.md" + }, + { + "id": "dolos-supernode-deployment", + "title": "Dolos Supernode Deployment", + "description": "Install and validate a Dolos supernode.", + "tags": ["dolos", "deploy"], + "extensions": ["dolos"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "workloads.install", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "contentPath": "../skills/dolos-supernode-deployment.md" + }, + { + "id": "hydra-head-operations", + "title": "Hydra Head Operations", + "description": "Guide MCP-supported inspection for Hydra head operations.", + "tags": ["hydra", "operations"], + "extensions": ["hydra-node"], + "tools": ["workloads.get", "workloads.metrics.get", "workloads.logs.get"], + "contentPath": "../skills/hydra-head-operations.md" + }, + { + "id": "hydra-node-deployment", + "title": "Hydra Node Deployment", + "description": "Install and validate a Hydra node.", + "tags": ["hydra", "deploy"], + "extensions": ["hydra-node"], + "tools": [ + "supernode.status.get", + "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", + "vault.runtime.metadata.get", + "workloads.install", + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list" + ], + "contentPath": "../skills/hydra-node-deployment.md" + }, + { + "id": "hydra-node-troubleshooting", + "title": "Hydra Node Troubleshooting", + "description": "Troubleshoot Hydra node startup, networking, metrics, and head progress.", + "tags": ["hydra", "troubleshooting"], + "extensions": ["hydra-node"], + "tools": [ + "workloads.get", + "workloads.logs.get", + "workloads.metrics.get", + "cluster.events.list", + "vault.runtime.metadata.get" + ], + "contentPath": "../skills/hydra-node-troubleshooting.md" + }, + { + "id": "kubernetes-extension-discovery", + "title": "Kubernetes Extension Discovery", + "description": "Discover installed catalog-managed workloads.", + "tags": ["kubernetes", "discovery"], + "extensions": [], + "tools": [ + "supernode.status.get", + "extensions.catalog.list", + "workloads.list", + "workloads.get", + "extensions.catalog.get" + ], + "contentPath": "../skills/kubernetes-extension-discovery.md" + }, + { + "id": "kubernetes-storage-and-prereqs", + "title": "Kubernetes Storage And Prereqs", + "description": "Inspect Kubernetes storage and prerequisites.", + "tags": ["kubernetes", "storage", "prereqs"], + "extensions": [], + "tools": [ + "supernode.status.get", + "cluster.storage_classes.list", + "cluster.events.list" + ], + "contentPath": "../skills/kubernetes-storage-and-prereqs.md" + }, + { + "id": "supernode-dashboard-port-forward", + "title": "Supernode Dashboard Port Forward", + "description": "Describe the current MCP boundary for dashboard access.", + "tags": ["dashboard", "access"], + "extensions": [], + "tools": ["supernode.status.get"], + "contentPath": "../skills/supernode-dashboard-port-forward.md" + }, + { + "id": "workload-output-port-forward", + "title": "Workload Output Port Forward", + "description": "Inspect workload outputs and describe current MCP access limits.", + "tags": ["workload", "access"], + "extensions": [], + "tools": ["workloads.get"], + "contentPath": "../skills/workload-output-port-forward.md" + } + ] +} diff --git a/extensions/control-plane/Chart.yaml b/extensions/control-plane/Chart.yaml index fc663a9..e2ba2b5 100644 --- a/extensions/control-plane/Chart.yaml +++ b/extensions/control-plane/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: control-plane description: Observability control-plane components derived from Terraform module. type: application -version: 0.2.1-rc5 +version: 0.2.1-rc6 appVersion: "0.2.1" dependencies: - name: vault diff --git a/extensions/control-plane/README.md b/extensions/control-plane/README.md index 0c050e1..b4f35e7 100644 --- a/extensions/control-plane/README.md +++ b/extensions/control-plane/README.md @@ -353,6 +353,10 @@ helm template control-plane . -f examples/aws-values.yaml | kubeconform -strict | `supernodeMcp.extensionCatalog.ociRef` | OCI artifact reference for the MCP extension catalog JSON | `oci://oci.supernode.store/extension-catalog:0.1.0` | | `supernodeMcp.extensionCatalog.maxBytes` | Maximum accepted catalog JSON blob size in bytes | `1048576` | | `supernodeMcp.extensionCatalog.allowUntrusted` | Allows non-`oci.supernode.store` catalog and chart refs. Unsafe; development only | `false` | +| `supernodeMcp.skillCatalog.source` | MCP skill catalog source (`oci` or `bundled`) | `oci` | +| `supernodeMcp.skillCatalog.ociRef` | OCI artifact reference for the MCP skill catalog JSON | `oci://oci.supernode.store/skill-catalog:0.1.0` | +| `supernodeMcp.skillCatalog.maxBytes` | Maximum accepted skill catalog JSON blob size in bytes | `1048576` | +| `supernodeMcp.skillCatalog.allowUntrusted` | Allows non-`oci.supernode.store` skill catalog refs. Unsafe; development only | `false` | | `supernodeMcp.sessionStore.type` | MCP Streamable HTTP session store (`sqlite` or `memory`) | `sqlite` | | `supernodeMcp.sessionStore.sqlitePath` | SQLite database path for restart-resistant MCP sessions | `/var/lib/supernode-mcp/sessions.sqlite3` | | `supernodeMcp.sessionStore.ttlSeconds` | Session restore TTL in seconds | `86400` | diff --git a/extensions/control-plane/templates/stage4-04-statefulset-supernode-mcp.yaml b/extensions/control-plane/templates/stage4-04-statefulset-supernode-mcp.yaml index 42a8120..6081c32 100644 --- a/extensions/control-plane/templates/stage4-04-statefulset-supernode-mcp.yaml +++ b/extensions/control-plane/templates/stage4-04-statefulset-supernode-mcp.yaml @@ -52,6 +52,14 @@ spec: value: {{ .Values.supernodeMcp.extensionCatalog.maxBytes | int | quote }} - name: MCP_EXTENSION_CATALOG_ALLOW_UNTRUSTED value: {{ .Values.supernodeMcp.extensionCatalog.allowUntrusted | quote }} + - name: MCP_SKILL_CATALOG_SOURCE + value: {{ .Values.supernodeMcp.skillCatalog.source | quote }} + - name: MCP_SKILL_CATALOG_OCI_REF + value: {{ .Values.supernodeMcp.skillCatalog.ociRef | quote }} + - name: MCP_SKILL_CATALOG_MAX_BYTES + value: {{ .Values.supernodeMcp.skillCatalog.maxBytes | int | quote }} + - name: MCP_SKILL_CATALOG_ALLOW_UNTRUSTED + value: {{ .Values.supernodeMcp.skillCatalog.allowUntrusted | quote }} - name: MCP_SESSION_STORE value: {{ .Values.supernodeMcp.sessionStore.type | quote }} - name: MCP_SESSION_SQLITE_PATH diff --git a/extensions/control-plane/values.yaml b/extensions/control-plane/values.yaml index ddc425d..3117916 100644 --- a/extensions/control-plane/values.yaml +++ b/extensions/control-plane/values.yaml @@ -178,6 +178,11 @@ supernodeMcp: ociRef: oci://oci.supernode.store/extension-catalog@sha256:623004147b13c18ffa6fdbb701da9001304518d8fd4a1988cc667f8ebd3e0419 maxBytes: 1048576 allowUntrusted: false + skillCatalog: + source: oci + ociRef: oci://oci.supernode.store/skill-catalog:0.1.0 + maxBytes: 1048576 + allowUntrusted: false sessionStore: type: sqlite sqlitePath: /var/lib/supernode-mcp/sessions.sqlite3 diff --git a/mcp-server/src/config.rs b/mcp-server/src/config.rs index 8d78de5..0c9a811 100644 --- a/mcp-server/src/config.rs +++ b/mcp-server/src/config.rs @@ -13,6 +13,7 @@ pub struct Config { pub log_level: String, pub session_store: SessionStoreConfig, pub extension_catalog: ExtensionCatalogConfig, + pub skill_catalog: SkillCatalogConfig, } #[derive(Debug, Clone, Eq, PartialEq)] @@ -38,6 +39,20 @@ pub enum ExtensionCatalogSource { Oci, } +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct SkillCatalogConfig { + pub source: SkillCatalogSource, + pub oci_ref: Option, + pub max_bytes: usize, + pub allow_untrusted: bool, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum SkillCatalogSource { + Bundled, + Oci, +} + impl FromStr for ExtensionCatalogSource { type Err = ConfigError; @@ -52,6 +67,18 @@ impl FromStr for ExtensionCatalogSource { } } +impl FromStr for SkillCatalogSource { + type Err = ConfigError; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "" | "bundled" => Ok(Self::Bundled), + "oci" => Ok(Self::Oci), + other => Err(ConfigError::InvalidSkillCatalogSource(other.to_string())), + } + } +} + impl FromStr for SessionStoreConfig { type Err = ConfigError; @@ -83,6 +110,7 @@ impl Config { .unwrap_or_else(|_| "memory".to_string()) .parse()?; let extension_catalog = extension_catalog_config()?; + let skill_catalog = skill_catalog_config()?; Ok(Self { bind_addr, @@ -90,6 +118,7 @@ impl Config { log_level, session_store, extension_catalog, + skill_catalog, }) } } @@ -113,6 +142,25 @@ fn extension_catalog_config() -> Result { }) } +fn skill_catalog_config() -> Result { + let source = env::var("MCP_SKILL_CATALOG_SOURCE") + .unwrap_or_else(|_| "bundled".to_string()) + .parse()?; + let oci_ref = env::var("MCP_SKILL_CATALOG_OCI_REF") + .ok() + .filter(|value| !value.trim().is_empty()); + if source == SkillCatalogSource::Oci && oci_ref.is_none() { + return Err(ConfigError::MissingSkillCatalogOciRef); + } + + Ok(SkillCatalogConfig { + source, + oci_ref, + max_bytes: skill_catalog_max_bytes()?, + allow_untrusted: skill_catalog_allow_untrusted()?, + }) +} + fn session_sqlite_path() -> PathBuf { env::var("MCP_SESSION_SQLITE_PATH") .map(PathBuf::from) @@ -152,6 +200,31 @@ fn extension_catalog_allow_untrusted() -> Result { } } +fn skill_catalog_max_bytes() -> Result { + env::var("MCP_SKILL_CATALOG_MAX_BYTES") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| { + value + .parse() + .map_err(ConfigError::InvalidSkillCatalogMaxBytes) + }) + .transpose() + .map(|value| value.unwrap_or(1_048_576)) +} + +fn skill_catalog_allow_untrusted() -> Result { + match env::var("MCP_SKILL_CATALOG_ALLOW_UNTRUSTED") { + Ok(value) if value.trim().is_empty() => Ok(false), + Ok(value) => match value.trim().to_ascii_lowercase().as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(ConfigError::InvalidSkillCatalogAllowUntrusted(value)), + }, + Err(_) => Ok(false), + } +} + #[cfg(test)] mod tests { use super::*; @@ -183,6 +256,18 @@ mod tests { ); } + #[test] + fn parses_skill_catalog_sources() { + assert_eq!( + "bundled".parse::().unwrap(), + SkillCatalogSource::Bundled + ); + assert_eq!( + "oci".parse::().unwrap(), + SkillCatalogSource::Oci + ); + } + #[test] fn rejects_unknown_catalog_source() { let error = "file".parse::().unwrap_err(); @@ -192,4 +277,11 @@ mod tests { ConfigError::InvalidExtensionCatalogSource(_) )); } + + #[test] + fn rejects_unknown_skill_catalog_source() { + let error = "file".parse::().unwrap_err(); + + assert!(matches!(error, ConfigError::InvalidSkillCatalogSource(_))); + } } diff --git a/mcp-server/src/errors.rs b/mcp-server/src/errors.rs index 07037e5..b77ac21 100644 --- a/mcp-server/src/errors.rs +++ b/mcp-server/src/errors.rs @@ -20,4 +20,12 @@ pub enum ConfigError { InvalidExtensionCatalogMaxBytes(ParseIntError), #[error("invalid MCP_EXTENSION_CATALOG_ALLOW_UNTRUSTED '{0}', expected 'true' or 'false'")] InvalidExtensionCatalogAllowUntrusted(String), + #[error("invalid MCP_SKILL_CATALOG_SOURCE '{0}', expected 'bundled' or 'oci'")] + InvalidSkillCatalogSource(String), + #[error("MCP_SKILL_CATALOG_OCI_REF is required when MCP_SKILL_CATALOG_SOURCE=oci")] + MissingSkillCatalogOciRef, + #[error("invalid MCP_SKILL_CATALOG_MAX_BYTES: {0}")] + InvalidSkillCatalogMaxBytes(ParseIntError), + #[error("invalid MCP_SKILL_CATALOG_ALLOW_UNTRUSTED '{0}', expected 'true' or 'false'")] + InvalidSkillCatalogAllowUntrusted(String), } diff --git a/mcp-server/src/main.rs b/mcp-server/src/main.rs index 70934c4..e3a7186 100644 --- a/mcp-server/src/main.rs +++ b/mcp-server/src/main.rs @@ -11,6 +11,7 @@ mod prompts; mod resources; mod server; mod session; +mod skills; mod tools; pub mod vault; diff --git a/mcp-server/src/mcp.rs b/mcp-server/src/mcp.rs index b7c9231..4cffa4f 100644 --- a/mcp-server/src/mcp.rs +++ b/mcp-server/src/mcp.rs @@ -34,6 +34,7 @@ use crate::policy::Scope; use crate::prompts::PromptCatalog; use crate::resources::ResourceRouter; use crate::resources::router::ResourceReadError; +use crate::skills::SkillCatalog; use crate::tools::ToolRouter; use crate::tools::dynamic::DynamicToolState; @@ -43,6 +44,7 @@ pub struct SupernodeMcpServer { policy: Policy, audit: Arc, catalog: Arc, + skill_catalog: Arc, resources: ResourceRouter, prompts: PromptCatalog, tools: ToolRouter, @@ -55,8 +57,9 @@ impl SupernodeMcpServer { policy: Policy, audit: Arc, catalog: Arc, + skill_catalog: Arc, ) -> Self { - let resources = ResourceRouter::new(catalog.clone()); + let resources = ResourceRouter::new(catalog.clone(), skill_catalog.clone()); let dynamic_tools = DynamicToolState::new(catalog.clone()); Self { @@ -64,6 +67,7 @@ impl SupernodeMcpServer { policy, audit, catalog, + skill_catalog, resources, prompts: PromptCatalog, tools: ToolRouter::new(), @@ -124,6 +128,7 @@ impl ServerHandler for SupernodeMcpServer { tracing::debug!( supported_approval_classes = ApprovalClass::all().len(), extension_count = self.catalog.len(), + skill_count = self.skill_catalog.len(), listed_extension_count = self.catalog.list().count(), "initialized MCP session" ); @@ -335,6 +340,7 @@ fn audit_target_for_tool( #[cfg(test)] mod tests { use crate::audit::TracingAuditSink; + use crate::skills::SkillCatalog; use super::*; @@ -345,6 +351,7 @@ mod tests { Policy, Arc::new(TracingAuditSink), Arc::new(ExtensionCatalog::testing()), + Arc::new(SkillCatalog::testing()), ); let info = server.server_info(); diff --git a/mcp-server/src/prompts/catalog.rs b/mcp-server/src/prompts/catalog.rs index 49a2849..b87ec33 100644 --- a/mcp-server/src/prompts/catalog.rs +++ b/mcp-server/src/prompts/catalog.rs @@ -45,19 +45,25 @@ const PROMPTS: &[PromptSpec] = &[ name: "bootstrap-and-discovery", title: "Bootstrap And Discovery", description: "Discover an existing Supernode cluster before making changes.", - text: "Inspect the Supernode through read-only resources and discovery tools first. Use supernode://status, supernode://control-plane/status, and supernode://extensions/catalog for extension summaries and outputs. Read a specific supernode://extensions/catalog/{extensionId} entry only when full configuration or metrics schemas are needed. Do not bootstrap infrastructure from MCP, do not shell out, and do not request raw Kubernetes, Vault, or Helm proxy access.", + text: "Inspect the Supernode through read-only MCP resources and discovery tools first. Read supernode://status, supernode://extensions/catalog, and supernode://skills. Select and read a specific supernode://skills/{skillId} guide only when it matches the operator task. Do not bootstrap infrastructure from MCP, do not shell out, and do not request raw Kubernetes, Vault, or Helm proxy access.", }, PromptSpec { - name: "cardano-relay-setup", - title: "Cardano Relay Setup", - description: "Plan a Cardano relay install through the catalog workflow.", - text: "Use the catalog-driven lifecycle workflow for a Cardano relay. Read supernode://extensions/catalog/cardano-relay, validate required extension configuration values, and use workloads.install when tool execution is available. Do not use extension-specific install tools or raw Helm values.", + name: "install-catalog-extension", + title: "Install Catalog Extension", + description: "Plan a catalog-backed workload install with skill guidance.", + text: "Use MCP tools only. Read supernode://skills and select the most relevant skill guide for the requested workload. Read supernode://extensions/catalog/{extensionId} as the source of truth for configuration shape. Start workloads.install with dryRun=true and run live only after operator approval.", + }, + PromptSpec { + name: "troubleshoot-workload", + title: "Troubleshoot Workload", + description: "Troubleshoot catalog-managed workloads with scoped MCP reads.", + text: "Use MCP tools only. Read supernode://skills and select the relevant troubleshooting or verification skill guide. Inspect workload state, logs, metrics, and cluster events through MCP tools. If the required operation is outside MCP capabilities, state that MCP does not currently expose it and stop for operator direction.", }, PromptSpec { name: "dashboard-access", title: "Dashboard Access", description: "Inspect dashboard/control-plane access without broad privileges.", - text: "Inspect control-plane status through read-only MCP resources and typed discovery tools. Keep access scoped to the MCP server permissions; do not reuse the dashboard superadmin cluster role, do not expose bearer tokens, and prefer kubectl port-forward access for the trusted MVP.", + text: "Inspect control-plane status through read-only MCP resources and typed discovery tools. Read supernode://skills/supernode-dashboard-port-forward for the current access boundary. Keep access scoped to the MCP server permissions; do not reuse the dashboard superadmin cluster role and do not expose bearer tokens.", }, ]; @@ -71,7 +77,7 @@ mod tests { let prompts = catalog.list().prompts; - assert_eq!(prompts.len(), 3); + assert_eq!(prompts.len(), 4); assert!( prompts .iter() @@ -80,7 +86,12 @@ mod tests { assert!( prompts .iter() - .any(|prompt| prompt.name == "cardano-relay-setup") + .any(|prompt| prompt.name == "install-catalog-extension") + ); + assert!( + prompts + .iter() + .any(|prompt| prompt.name == "troubleshoot-workload") ); assert!( prompts @@ -90,10 +101,10 @@ mod tests { } #[test] - fn prompts_reference_catalog_driven_workflows() { + fn prompts_reference_skill_driven_workflows() { let catalog = PromptCatalog; - let prompt = catalog.get("cardano-relay-setup").unwrap(); + let prompt = catalog.get("install-catalog-extension").unwrap(); let message = &prompt.messages[0]; assert_eq!(message.role, PromptMessageRole::User); @@ -101,7 +112,8 @@ mod tests { panic!("expected text prompt"); }; assert!(text.contains("workloads.install")); - assert!(text.contains("cardano-relay")); + assert!(text.contains("supernode://skills")); + assert!(text.contains("supernode://extensions/catalog/{extensionId}")); assert!(!text.contains("cardano.relay.install")); } diff --git a/mcp-server/src/resources/router.rs b/mcp-server/src/resources/router.rs index 8320f0f..252ff8d 100644 --- a/mcp-server/src/resources/router.rs +++ b/mcp-server/src/resources/router.rs @@ -11,23 +11,30 @@ use serde_json::json; use crate::auth::AuthContext; use crate::catalog::{ExtensionCatalog, extension_summary}; +use crate::skills::{SkillCatalog, skill_summary}; use super::uri::CONTROL_PLANE_STATUS_URI; use super::uri::EXTENSION_CATALOG_URI; +use super::uri::SKILLS_URI; use super::uri::STATUS_URI; use super::uri::SupernodeResourceUri; use super::uri::extension_catalog_entry_uri; +use super::uri::skill_entry_uri; const JSON_MIME_TYPE: &str = "application/json"; #[derive(Debug, Clone)] pub struct ResourceRouter { catalog: Arc, + skill_catalog: Arc, } impl ResourceRouter { - pub fn new(catalog: Arc) -> Self { - Self { catalog } + pub fn new(catalog: Arc, skill_catalog: Arc) -> Self { + Self { + catalog, + skill_catalog, + } } pub fn list(&self) -> ListResourcesResult { @@ -50,6 +57,12 @@ impl ResourceRouter { "Extension Catalog", "Summary catalog of extensions supported by this MCP server.", ), + resource( + SKILLS_URI, + "skills-catalog", + "Skill Catalog", + "Summary catalog of operational skill guides available through this MCP server.", + ), ]; resources.extend(self.catalog.list().map(|extension| { @@ -61,6 +74,15 @@ impl ResourceRouter { ) })); + resources.extend(self.skill_catalog.list().map(|skill| { + resource( + skill_entry_uri(&skill.id), + format!("skill-{}", skill.id), + skill.title.clone(), + format!("Full operational skill guide for {}.", skill.title), + ) + })); + ListResourcesResult::with_all_items(resources) } @@ -89,6 +111,14 @@ impl ResourceRouter { .get(extension_id) .ok_or(ResourceReadError::NotFound)?, )?, + SupernodeResourceUri::Skills => json!({ + "skills": self.skill_catalog.list().map(skill_summary).collect::>(), + }), + SupernodeResourceUri::SkillEntry { skill_id } => serde_json::to_value( + self.skill_catalog + .get(skill_id) + .ok_or(ResourceReadError::NotFound)?, + )?, }; Ok(text_resource(uri, &value)?) @@ -136,7 +166,10 @@ mod tests { #[test] fn lists_static_and_extension_catalog_resources() { - let router = ResourceRouter::new(Arc::new(ExtensionCatalog::testing())); + let router = ResourceRouter::new( + Arc::new(ExtensionCatalog::testing()), + Arc::new(SkillCatalog::testing()), + ); let resources = router.list().resources; @@ -151,11 +184,20 @@ mod tests { .iter() .any(|resource| { resource.uri == extension_catalog_entry_uri("cardano-relay") }) ); + assert!(resources.iter().any(|resource| resource.uri == SKILLS_URI)); + assert!( + resources + .iter() + .any(|resource| { resource.uri == skill_entry_uri("cardano-relay-setup") }) + ); } #[test] fn reads_catalog_resource_as_json() { - let router = ResourceRouter::new(Arc::new(ExtensionCatalog::testing())); + let router = ResourceRouter::new( + Arc::new(ExtensionCatalog::testing()), + Arc::new(SkillCatalog::testing()), + ); let result = router .read(EXTENSION_CATALOG_URI, &AuthContext::trusted()) @@ -195,7 +237,10 @@ mod tests { #[test] fn reads_one_catalog_entry() { - let router = ResourceRouter::new(Arc::new(ExtensionCatalog::testing())); + let router = ResourceRouter::new( + Arc::new(ExtensionCatalog::testing()), + Arc::new(SkillCatalog::testing()), + ); let result = router .read( @@ -213,7 +258,10 @@ mod tests { #[test] fn unknown_resource_returns_not_found() { - let router = ResourceRouter::new(Arc::new(ExtensionCatalog::testing())); + let router = ResourceRouter::new( + Arc::new(ExtensionCatalog::testing()), + Arc::new(SkillCatalog::testing()), + ); let error = router .read( @@ -224,4 +272,51 @@ mod tests { assert!(matches!(error, ResourceReadError::NotFound)); } + + #[test] + fn reads_skill_catalog_resource_as_json_summary() { + let router = ResourceRouter::new( + Arc::new(ExtensionCatalog::testing()), + Arc::new(SkillCatalog::testing()), + ); + + let result = router.read(SKILLS_URI, &AuthContext::trusted()).unwrap(); + + let ResourceContents::TextResourceContents { text, .. } = &result.contents[0] else { + panic!("expected text resource"); + }; + let value = serde_json::from_str::(text).unwrap(); + let relay = value + .pointer("/skills") + .and_then(Value::as_array) + .unwrap() + .iter() + .find(|skill| { + skill.pointer("/id") == Some(&Value::String("cardano-relay-setup".to_string())) + }) + .unwrap(); + assert!(relay.get("content").is_none()); + assert!(relay.pointer("/tools").is_some()); + } + + #[test] + fn reads_one_skill_entry() { + let router = ResourceRouter::new( + Arc::new(ExtensionCatalog::testing()), + Arc::new(SkillCatalog::testing()), + ); + + let result = router + .read( + &skill_entry_uri("cardano-relay-setup"), + &AuthContext::trusted(), + ) + .unwrap(); + + let ResourceContents::TextResourceContents { text, .. } = &result.contents[0] else { + panic!("expected text resource"); + }; + assert!(text.contains("Cardano Relay Setup")); + assert!(text.contains("\"content\"")); + } } diff --git a/mcp-server/src/resources/uri.rs b/mcp-server/src/resources/uri.rs index 6925b87..7687f87 100644 --- a/mcp-server/src/resources/uri.rs +++ b/mcp-server/src/resources/uri.rs @@ -4,13 +4,17 @@ pub enum SupernodeResourceUri<'a> { ControlPlaneStatus, ExtensionCatalog, ExtensionCatalogEntry { extension_id: &'a str }, + Skills, + SkillEntry { skill_id: &'a str }, } pub const STATUS_URI: &str = "supernode://status"; pub const CONTROL_PLANE_STATUS_URI: &str = "supernode://control-plane/status"; pub const EXTENSION_CATALOG_URI: &str = "supernode://extensions/catalog"; +pub const SKILLS_URI: &str = "supernode://skills"; const EXTENSION_CATALOG_ENTRY_PREFIX: &str = "supernode://extensions/catalog/"; +const SKILL_ENTRY_PREFIX: &str = "supernode://skills/"; impl<'a> SupernodeResourceUri<'a> { pub fn parse(uri: &'a str) -> Option { @@ -18,10 +22,16 @@ impl<'a> SupernodeResourceUri<'a> { STATUS_URI => Some(Self::Status), CONTROL_PLANE_STATUS_URI => Some(Self::ControlPlaneStatus), EXTENSION_CATALOG_URI => Some(Self::ExtensionCatalog), + SKILLS_URI => Some(Self::Skills), _ => uri .strip_prefix(EXTENSION_CATALOG_ENTRY_PREFIX) .filter(|extension_id| !extension_id.is_empty() && !extension_id.contains('/')) - .map(|extension_id| Self::ExtensionCatalogEntry { extension_id }), + .map(|extension_id| Self::ExtensionCatalogEntry { extension_id }) + .or_else(|| { + uri.strip_prefix(SKILL_ENTRY_PREFIX) + .filter(|skill_id| !skill_id.is_empty() && !skill_id.contains('/')) + .map(|skill_id| Self::SkillEntry { skill_id }) + }), } } } @@ -30,6 +40,10 @@ pub fn extension_catalog_entry_uri(extension_id: &str) -> String { format!("{EXTENSION_CATALOG_ENTRY_PREFIX}{extension_id}") } +pub fn skill_entry_uri(skill_id: &str) -> String { + format!("{SKILL_ENTRY_PREFIX}{skill_id}") +} + #[cfg(test)] mod tests { use super::*; @@ -48,12 +62,22 @@ mod tests { SupernodeResourceUri::parse(EXTENSION_CATALOG_URI), Some(SupernodeResourceUri::ExtensionCatalog) ); + assert_eq!( + SupernodeResourceUri::parse(SKILLS_URI), + Some(SupernodeResourceUri::Skills) + ); assert_eq!( SupernodeResourceUri::parse("supernode://extensions/catalog/cardano-relay"), Some(SupernodeResourceUri::ExtensionCatalogEntry { extension_id: "cardano-relay" }) ); + assert_eq!( + SupernodeResourceUri::parse("supernode://skills/cardano-relay-setup"), + Some(SupernodeResourceUri::SkillEntry { + skill_id: "cardano-relay-setup" + }) + ); } #[test] @@ -67,5 +91,10 @@ mod tests { SupernodeResourceUri::parse("supernode://extensions/catalog/cardano-relay/profile"), None ); + assert_eq!(SupernodeResourceUri::parse("supernode://skills/"), None); + assert_eq!( + SupernodeResourceUri::parse("supernode://skills/cardano-relay-setup/profile"), + None + ); } } diff --git a/mcp-server/src/server.rs b/mcp-server/src/server.rs index 2150d1a..eb7dde5 100644 --- a/mcp-server/src/server.rs +++ b/mcp-server/src/server.rs @@ -22,6 +22,7 @@ use crate::config::SessionStoreConfig; use crate::mcp::SupernodeMcpServer; use crate::policy::Policy; use crate::session::SqliteSessionStore; +use crate::skills::source::load_skill_catalog; #[derive(Debug, Serialize)] struct HealthResponse { @@ -55,12 +56,14 @@ async fn router(config: Config, cancellation_token: CancellationToken) -> anyhow }; let session_store = session_store(&config.session_store)?; let catalog = load_catalog(&config.extension_catalog).await?; + let skill_catalog = load_skill_catalog(&config.skill_catalog).await?; let mcp_state = SupernodeMcpServer::new( auth_context.clone(), Policy, Arc::new(TracingAuditSink), Arc::new(catalog), + Arc::new(skill_catalog), ); let mut mcp_config = StreamableHttpServerConfig::default() .with_cancellation_token(cancellation_token) diff --git a/mcp-server/src/skills/mod.rs b/mcp-server/src/skills/mod.rs new file mode 100644 index 0000000..9a516ad --- /dev/null +++ b/mcp-server/src/skills/mod.rs @@ -0,0 +1,221 @@ +mod oci; +pub mod source; + +use std::collections::BTreeMap; +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +const SKILL_CATALOG_SCHEMA_VERSION: &str = "supernode.skillCatalog/v1"; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillCatalogDocument { + pub schema_version: String, + pub skills: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillDefinition { + pub id: String, + pub title: String, + pub description: String, + pub tags: Vec, + pub extensions: Vec, + pub tools: Vec, + pub content: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SkillCatalog { + skills: BTreeMap, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillSummary<'a> { + pub id: &'a str, + pub title: &'a str, + pub description: &'a str, + pub tags: &'a [String], + pub extensions: &'a [String], + pub tools: &'a [String], +} + +pub fn skill_summary(skill: &SkillDefinition) -> SkillSummary<'_> { + SkillSummary { + id: &skill.id, + title: &skill.title, + description: &skill.description, + tags: &skill.tags, + extensions: &skill.extensions, + tools: &skill.tools, + } +} + +impl SkillCatalog { + pub fn bundled() -> Self { + Self::from_json_str(include_str!("../../../catalog/skill-catalog.json")) + .expect("bundled skill catalog must be valid") + } + + pub fn from_skills(skills: impl IntoIterator) -> Self { + let skills = skills + .into_iter() + .map(|skill| (skill.id.clone(), skill)) + .collect(); + Self { skills } + } + + pub fn from_json_str(payload: &str) -> Result { + let document = serde_json::from_str::(payload)?; + Self::from_document(document) + } + + pub fn from_document(document: SkillCatalogDocument) -> Result { + if document.schema_version != SKILL_CATALOG_SCHEMA_VERSION { + return Err(SkillCatalogLoadError::UnsupportedSchemaVersion( + document.schema_version, + )); + } + + validate_skills(&document.skills)?; + Ok(Self::from_skills(document.skills)) + } + + #[cfg(test)] + pub fn testing() -> Self { + Self::bundled() + } + + pub fn list(&self) -> impl Iterator { + self.skills.values() + } + + pub fn get(&self, skill_id: &str) -> Option<&SkillDefinition> { + self.skills.get(skill_id) + } + + pub fn len(&self) -> usize { + self.skills.len() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SkillCatalogLoadError { + #[error("skill catalog JSON is invalid: {0}")] + InvalidJson(#[from] serde_json::Error), + #[error("skill catalog JSON is not valid UTF-8: {0}")] + InvalidUtf8(#[from] std::str::Utf8Error), + #[error("unsupported skill catalog schema version: {0}")] + UnsupportedSchemaVersion(String), + #[error("invalid skill catalog: {0}")] + InvalidCatalog(String), + #[error("missing skill catalog OCI reference")] + MissingOciReference, + #[error("untrusted skill catalog OCI reference: {0}")] + UntrustedCatalogReference(String), + #[error("failed to load skill catalog from OCI: {0}")] + Oci(#[from] oci::OciSkillCatalogError), +} + +fn validate_skills(skills: &[SkillDefinition]) -> Result<(), SkillCatalogLoadError> { + let mut ids = BTreeSet::new(); + for skill in skills { + if skill.id.trim().is_empty() { + return invalid_catalog("skill id must not be empty"); + } + if !is_valid_skill_id(&skill.id) { + return invalid_catalog(format!("skill id is not URI-safe: {}", skill.id)); + } + if !ids.insert(skill.id.as_str()) { + return invalid_catalog(format!("duplicate skill id: {}", skill.id)); + } + if skill.title.trim().is_empty() { + return invalid_catalog(format!("skill title must not be empty: {}", skill.id)); + } + if skill.description.trim().is_empty() { + return invalid_catalog(format!("skill description must not be empty: {}", skill.id)); + } + if skill.content.trim().is_empty() { + return invalid_catalog(format!("skill content must not be empty: {}", skill.id)); + } + } + + Ok(()) +} + +fn is_valid_skill_id(value: &str) -> bool { + value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn invalid_catalog(message: impl Into) -> Result { + Err(SkillCatalogLoadError::InvalidCatalog(message.into())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_catalog_loads_skills() { + let catalog = SkillCatalog::bundled(); + + assert!(catalog.len() >= 20); + assert!(catalog.get("cardano-relay-setup").is_some()); + assert!(catalog.get("hydra-node-troubleshooting").is_some()); + } + + #[test] + fn rejects_duplicate_skill_ids() { + let skill = test_skill("duplicate"); + + let error = SkillCatalog::from_document(SkillCatalogDocument { + schema_version: SKILL_CATALOG_SCHEMA_VERSION.to_string(), + skills: vec![skill.clone(), skill], + }) + .unwrap_err(); + + assert!(matches!(error, SkillCatalogLoadError::InvalidCatalog(_))); + } + + #[test] + fn rejects_unsupported_schema_version() { + let error = SkillCatalog::from_document(SkillCatalogDocument { + schema_version: "not-real".to_string(), + skills: vec![], + }) + .unwrap_err(); + + assert!(matches!( + error, + SkillCatalogLoadError::UnsupportedSchemaVersion(_) + )); + } + + #[test] + fn summaries_omit_content() { + let catalog = SkillCatalog::bundled(); + let skill = catalog.get("cardano-relay-setup").unwrap(); + + let value = serde_json::to_value(skill_summary(skill)).unwrap(); + + assert_eq!(value.pointer("/id").unwrap(), "cardano-relay-setup"); + assert!(value.get("content").is_none()); + } + + fn test_skill(id: &str) -> SkillDefinition { + SkillDefinition { + id: id.to_string(), + title: "Test Skill".to_string(), + description: "Test skill.".to_string(), + tags: vec![], + extensions: vec![], + tools: vec![], + content: "content".to_string(), + } + } +} diff --git a/mcp-server/src/skills/oci.rs b/mcp-server/src/skills/oci.rs new file mode 100644 index 0000000..cd44104 --- /dev/null +++ b/mcp-server/src/skills/oci.rs @@ -0,0 +1,293 @@ +use reqwest::header::ACCEPT; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::time::Duration; + +const SKILL_CATALOG_LAYER_MEDIA_TYPE: &str = "application/vnd.supernode.skill-catalog.v1+json"; +const JSON_MEDIA_TYPE: &str = "application/json"; +const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.manifest.v1+json, application/vnd.oci.artifact.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; +const TRUSTED_CATALOG_REGISTRY: &str = "oci.supernode.store"; + +pub(super) fn is_trusted_catalog_reference(reference: &str) -> Result { + Ok(OciReference::parse(reference)?.registry == TRUSTED_CATALOG_REGISTRY) +} + +pub(super) async fn fetch_catalog_json( + reference: &str, + max_bytes: usize, +) -> Result, OciSkillCatalogError> { + let reference = OciReference::parse(reference)?; + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build()?; + let manifest = fetch_manifest(&client, &reference).await?; + let descriptor = select_catalog_descriptor(&manifest) + .ok_or_else(|| OciSkillCatalogError::MissingCatalogLayer(reference.original.clone()))?; + + if descriptor.size.is_some_and(|size| size > max_bytes) { + return Err(OciSkillCatalogError::CatalogTooLarge { + actual: descriptor.size.unwrap_or_default(), + max: max_bytes, + }); + } + + let payload = fetch_blob(&client, &reference, &descriptor.digest).await?; + if payload.len() > max_bytes { + return Err(OciSkillCatalogError::CatalogTooLarge { + actual: payload.len(), + max: max_bytes, + }); + } + verify_sha256_digest(&descriptor.digest, &payload)?; + + Ok(payload) +} + +async fn fetch_manifest( + client: &reqwest::Client, + reference: &OciReference, +) -> Result { + let response = client + .get(reference.manifest_url()) + .header(ACCEPT, MANIFEST_ACCEPT) + .send() + .await? + .error_for_status() + .map_err(OciSkillCatalogError::HttpStatus)?; + + response.json::().await.map_err(Into::into) +} + +async fn fetch_blob( + client: &reqwest::Client, + reference: &OciReference, + digest: &str, +) -> Result, OciSkillCatalogError> { + let response = client + .get(reference.blob_url(digest)) + .send() + .await? + .error_for_status() + .map_err(OciSkillCatalogError::HttpStatus)?; + + Ok(response.bytes().await?.to_vec()) +} + +fn select_catalog_descriptor(manifest: &OciManifest) -> Option { + manifest + .layers + .iter() + .chain(manifest.blobs.iter()) + .find(|descriptor| descriptor.media_type == SKILL_CATALOG_LAYER_MEDIA_TYPE) + .or_else(|| { + manifest + .layers + .iter() + .chain(manifest.blobs.iter()) + .find(|descriptor| descriptor.media_type == JSON_MEDIA_TYPE) + }) + .cloned() +} + +fn verify_sha256_digest(expected: &str, payload: &[u8]) -> Result<(), OciSkillCatalogError> { + let Some(expected) = expected.strip_prefix("sha256:") else { + return Err(OciSkillCatalogError::UnsupportedDigest( + expected.to_string(), + )); + }; + let actual = hex_lower(&Sha256::digest(payload)); + if actual != expected.to_ascii_lowercase() { + return Err(OciSkillCatalogError::DigestMismatch { + expected: expected.to_string(), + actual, + }); + } + + Ok(()) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OciReference { + original: String, + registry: String, + repository: String, + reference: String, +} + +impl OciReference { + fn parse(value: &str) -> Result { + let value = value.trim(); + let Some(rest) = value.strip_prefix("oci://") else { + return Err(OciSkillCatalogError::InvalidReference(value.to_string())); + }; + let Some((registry, path)) = rest.split_once('/') else { + return Err(OciSkillCatalogError::InvalidReference(value.to_string())); + }; + if registry.is_empty() || path.is_empty() { + return Err(OciSkillCatalogError::InvalidReference(value.to_string())); + } + + let last_slash = path.rfind('/'); + let tag_separator = path.rfind(':').filter(|index| { + last_slash + .map(|last_slash| *index > last_slash) + .unwrap_or(true) + }); + + let (repository, reference) = if let Some((repository, digest)) = path.split_once('@') { + (repository, digest) + } else if let Some(index) = tag_separator { + (&path[..index], &path[index + 1..]) + } else { + return Err(OciSkillCatalogError::MissingReference(value.to_string())); + }; + + if repository.is_empty() || reference.is_empty() { + return Err(OciSkillCatalogError::InvalidReference(value.to_string())); + } + + Ok(Self { + original: value.to_string(), + registry: registry.to_string(), + repository: repository.to_string(), + reference: reference.to_string(), + }) + } + + fn manifest_url(&self) -> String { + format!( + "https://{}/v2/{}/manifests/{}", + self.registry, self.repository, self.reference + ) + } + + fn blob_url(&self, digest: &str) -> String { + format!( + "https://{}/v2/{}/blobs/{}", + self.registry, self.repository, digest + ) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OciManifest { + #[serde(default)] + layers: Vec, + #[serde(default)] + blobs: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct OciDescriptor { + media_type: String, + digest: String, + size: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum OciSkillCatalogError { + #[error("invalid OCI reference: {0}")] + InvalidReference(String), + #[error("OCI reference must include a tag or digest: {0}")] + MissingReference(String), + #[error("OCI registry request failed: {0}")] + Request(#[from] reqwest::Error), + #[error("OCI registry returned an unsuccessful status: {0}")] + HttpStatus(reqwest::Error), + #[error("OCI skill catalog artifact does not contain a skill catalog JSON layer: {0}")] + MissingCatalogLayer(String), + #[error("OCI skill catalog blob is too large: {actual} bytes exceeds {max} bytes")] + CatalogTooLarge { actual: usize, max: usize }, + #[error("unsupported OCI skill catalog digest: {0}")] + UnsupportedDigest(String), + #[error("OCI skill catalog digest mismatch: expected {expected}, got {actual}")] + DigestMismatch { expected: String, actual: String }, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn parses_tagged_oci_references() { + let reference = + OciReference::parse("oci://oci.supernode.store/skill-catalog:0.1.0").unwrap(); + + assert_eq!(reference.registry, "oci.supernode.store"); + assert_eq!(reference.repository, "skill-catalog"); + assert_eq!(reference.reference, "0.1.0"); + assert_eq!( + reference.manifest_url(), + "https://oci.supernode.store/v2/skill-catalog/manifests/0.1.0" + ); + } + + #[test] + fn rejects_references_without_tag_or_digest() { + let error = OciReference::parse("oci://oci.supernode.store/skill-catalog").unwrap_err(); + + assert!(matches!(error, OciSkillCatalogError::MissingReference(_))); + } + + #[test] + fn identifies_trusted_catalog_registry() { + assert!( + is_trusted_catalog_reference("oci://oci.supernode.store/skill-catalog:0.1.0").unwrap() + ); + assert!(!is_trusted_catalog_reference("oci://example.com/skill-catalog:0.1.0").unwrap()); + } + + #[test] + fn selects_skill_catalog_layer_before_generic_json() { + let manifest = OciManifest { + layers: vec![ + descriptor(JSON_MEDIA_TYPE, "sha256:generic", 10), + descriptor(SKILL_CATALOG_LAYER_MEDIA_TYPE, "sha256:specific", 20), + ], + blobs: vec![], + }; + + let selected = select_catalog_descriptor(&manifest).unwrap(); + + assert_eq!(selected.digest, "sha256:specific"); + } + + #[test] + fn verifies_sha256_digest() { + let payload = br#"{"schemaVersion":"supernode.skillCatalog/v1","skills":[]}"#; + let digest = format!("sha256:{}", hex_lower(&Sha256::digest(payload))); + + verify_sha256_digest(&digest, payload).unwrap(); + } + + #[test] + fn detects_digest_mismatch() { + let error = verify_sha256_digest("sha256:deadbeef", b"payload").unwrap_err(); + + assert!(matches!(error, OciSkillCatalogError::DigestMismatch { .. })); + } + + fn descriptor(media_type: &str, digest: &str, size: usize) -> OciDescriptor { + serde_json::from_value(json!({ + "mediaType": media_type, + "digest": digest, + "size": size, + })) + .unwrap() + } +} diff --git a/mcp-server/src/skills/source.rs b/mcp-server/src/skills/source.rs new file mode 100644 index 0000000..a0e652a --- /dev/null +++ b/mcp-server/src/skills/source.rs @@ -0,0 +1,26 @@ +use crate::config::{SkillCatalogConfig, SkillCatalogSource}; + +use super::{SkillCatalog, SkillCatalogLoadError, oci}; + +pub async fn load_skill_catalog( + config: &SkillCatalogConfig, +) -> Result { + match config.source { + SkillCatalogSource::Bundled => Ok(SkillCatalog::bundled()), + SkillCatalogSource::Oci => { + let oci_ref = config + .oci_ref + .as_deref() + .ok_or(SkillCatalogLoadError::MissingOciReference)?; + if !config.allow_untrusted && !oci::is_trusted_catalog_reference(oci_ref)? { + return Err(SkillCatalogLoadError::UntrustedCatalogReference( + oci_ref.to_string(), + )); + } + let payload = oci::fetch_catalog_json(oci_ref, config.max_bytes).await?; + let payload = std::str::from_utf8(&payload)?; + + SkillCatalog::from_json_str(payload) + } + } +} From b8121379f527f292a3ba35256e36e7654a36b13e Mon Sep 17 00:00:00 2001 From: Felipe Gonzalez Date: Thu, 21 May 2026 18:48:38 -0300 Subject: [PATCH 2/4] fixes from review --- .github/workflows/build_mcp_server.yml | 5 + .github/workflows/check_mcp_server.yml | 5 + .github/workflows/test_mcp_server.yml | 5 + catalog/README.md | 6 +- catalog/skill-catalog.json | 237 ++++++++++++++++++++----- catalog/skill-catalog.manifest.json | 49 +++-- 6 files changed, 247 insertions(+), 60 deletions(-) diff --git a/.github/workflows/build_mcp_server.yml b/.github/workflows/build_mcp_server.yml index b85fc18..d8a59ea 100644 --- a/.github/workflows/build_mcp_server.yml +++ b/.github/workflows/build_mcp_server.yml @@ -44,6 +44,11 @@ jobs: with: toolchain: stable + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Generate skill catalog run: node catalog/scripts/generate-skill-catalog.mjs diff --git a/.github/workflows/check_mcp_server.yml b/.github/workflows/check_mcp_server.yml index 433d635..de1cb53 100644 --- a/.github/workflows/check_mcp_server.yml +++ b/.github/workflows/check_mcp_server.yml @@ -28,6 +28,11 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Generate skill catalog working-directory: . run: node catalog/scripts/generate-skill-catalog.mjs diff --git a/.github/workflows/test_mcp_server.yml b/.github/workflows/test_mcp_server.yml index 8316494..9e5ab33 100644 --- a/.github/workflows/test_mcp_server.yml +++ b/.github/workflows/test_mcp_server.yml @@ -28,6 +28,11 @@ jobs: - name: Checkout uses: actions/checkout@v3 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Generate skill catalog working-directory: . run: node catalog/scripts/generate-skill-catalog.mjs diff --git a/catalog/README.md b/catalog/README.md index de2d753..b5e89e9 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -4,7 +4,7 @@ This directory contains the catalog documents consumed by the Supernode MCP serv - `extension-catalog.json`: installable extension contracts and chart references. - `skill-catalog.manifest.json`: editable metadata for operational skill guides. -- `skill-catalog.json`: generated, gitignored skill catalog payload with embedded markdown content. +- `skill-catalog.json`: generated, tracked skill catalog payload with embedded markdown content. Run this after editing `skill-catalog.manifest.json` or `../skills/*.md`: @@ -12,7 +12,7 @@ Run this after editing `skill-catalog.manifest.json` or `../skills/*.md`: node catalog/scripts/generate-skill-catalog.mjs ``` -`skill-catalog.json` is intentionally not committed. Generate it locally before building MCP from source or publishing the skill catalog artifact. +`skill-catalog.json` is committed so MCP builds and catalog consumers have a ready-to-use bundled skill catalog. Regenerate it locally after editing skill metadata or markdown, then commit the updated artifact with the source changes. ## Extension Catalog Contract @@ -58,7 +58,7 @@ Each skill entry describes one operational guide: - `tools`: MCP tools used by the guide. - `content`: markdown guide content embedded from `../skills/*.md`. -Edit `skill-catalog.manifest.json` rather than `skill-catalog.json` directly. The manifest stores the same metadata plus `contentPath`; the generator embeds the referenced markdown into the publishable JSON document. Because `skill-catalog.json` is generated and gitignored, changes to skill metadata or markdown are published only after regenerating it. +Edit `skill-catalog.manifest.json` rather than `skill-catalog.json` directly. The manifest stores the same metadata plus `contentPath`; the generator embeds the referenced markdown into the publishable JSON document. Because `skill-catalog.json` is generated and tracked, changes to skill metadata or markdown should include the regenerated catalog artifact. ## Trusted Sources diff --git a/catalog/skill-catalog.json b/catalog/skill-catalog.json index d8dbc1f..4e62b2e 100644 --- a/catalog/skill-catalog.json +++ b/catalog/skill-catalog.json @@ -5,14 +5,22 @@ "id": "apex-fusion-block-producer-deployment", "title": "Apex Fusion Block Producer Deployment", "description": "Deploy or update an Apex Fusion block producer.", - "tags": ["apex-fusion", "block-producer", "deploy"], - "extensions": ["apex-fusion-block-producer"], + "tags": [ + "apex-fusion", + "block-producer", + "deploy" + ], + "extensions": [ + "apex-fusion-block-producer" + ], "tools": [ "supernode.status.get", "extensions.catalog.get", "cluster.storage_classes.list", "workloads.list", "vault.runtime.metadata.get", + "vault.runtime.write", + "vault.runtime.patch", "workloads.install", "workloads.upgrade", "workloads.get", @@ -26,8 +34,14 @@ "id": "apex-fusion-relay-setup", "title": "Apex Fusion Relay Setup", "description": "Install and validate an Apex Fusion relay.", - "tags": ["apex-fusion", "relay", "install"], - "extensions": ["apex-fusion-relay"], + "tags": [ + "apex-fusion", + "relay", + "install" + ], + "extensions": [ + "apex-fusion-relay" + ], "tools": [ "supernode.status.get", "extensions.catalog.get", @@ -45,14 +59,20 @@ "id": "cardano-block-producer-troubleshooting", "title": "Cardano Block Producer Troubleshooting", "description": "Troubleshoot Cardano block producer health.", - "tags": ["cardano", "block-producer", "troubleshooting"], - "extensions": ["cardano-block-producer"], + "tags": [ + "cardano", + "block-producer", + "troubleshooting" + ], + "extensions": [ + "cardano-block-producer" + ], "tools": [ "workloads.get", "workloads.logs.get", "workloads.metrics.get", "cluster.events.list", - "vault.runtime.metadata.get" + "dolos.snapshot.refresh" ], "content": "# Cardano Block Producer Troubleshooting\n\n## Goal\n\nTroubleshoot a Cardano block producer using MCP tools only.\n\n## Workflow\n\n1. Call `workloads.get` for the producer.\n2. Call `workloads.metrics.get` for producer metrics.\n3. Call `workloads.logs.get` for bounded recent logs.\n4. Call `cluster.events.list` for the producer namespace.\n5. Call `workloads.get` and `workloads.metrics.get` for trusted relay workloads.\n6. If Dolos is installed for the same network, call `dolos.snapshot.refresh` when an external chain-view refresh is needed.\n\n## Check From MCP\n\n- producer mode and debug/active state\n- sync health\n- peer health\n- KES and op-cert status\n- schedule fields\n- local forging/adoption fields when exposed\n- relay workload health\n- recent Kubernetes events returned by MCP\n\n## Limits\n\nIf MCP does not expose raw sockets, raw topology files, or canonical block outcome tracking, state that limitation and stop instead of giving shell commands.\n\n## Rules\n\n- Do not exec into pods.\n- Do not query external HTTP APIs directly.\n- Do not use non-MCP commands.\n" }, @@ -60,14 +80,25 @@ "id": "cardano-block-producer-upgrade", "title": "Cardano Block Producer Upgrade", "description": "Upgrade a Cardano block producer through catalog-backed MCP workflows.", - "tags": ["cardano", "block-producer", "upgrade"], - "extensions": ["cardano-block-producer"], + "tags": [ + "cardano", + "block-producer", + "upgrade" + ], + "extensions": [ + "cardano-block-producer" + ], "tools": [ "supernode.status.get", "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", "workloads.get", "workloads.metrics.get", "vault.runtime.metadata.get", + "vault.runtime.write", + "vault.runtime.patch", + "workloads.install", "workloads.upgrade", "workloads.logs.get", "cluster.events.list" @@ -78,13 +109,20 @@ "id": "cardano-block-producer-verification", "title": "Cardano Block Producer Verification", "description": "Verify Cardano block producer readiness and health.", - "tags": ["cardano", "block-producer", "verify"], - "extensions": ["cardano-block-producer"], + "tags": [ + "cardano", + "block-producer", + "verify" + ], + "extensions": [ + "cardano-block-producer" + ], "tools": [ "workloads.get", "workloads.logs.get", "workloads.metrics.get", - "cluster.events.list" + "cluster.events.list", + "dolos.snapshot.refresh" ], "content": "# Cardano Block Producer Verification\n\n## Goal\n\nVerify block-producer readiness using MCP tools only.\n\n## Workflow\n\n1. Call `workloads.get` for the producer.\n2. Call `workloads.metrics.get` for producer metrics.\n3. Call `workloads.logs.get` for recent producer logs if metrics show errors.\n4. Call `cluster.events.list` for namespace events if the workload is not healthy.\n5. If Dolos is installed for the same network, use `dolos.snapshot.refresh` when external chain-view refresh is needed.\n\n## Verify From MCP\n\n- workload is healthy\n- Vault-backed runtime material is mounted according to workload status\n- debug-mode or active forging mode is as expected\n- KES and op-cert fields are present when exposed by metrics\n- peer and sync fields are healthy\n- schedule fields are present when exposed by metrics\n\n## Limits\n\nMCP metrics can show readiness and local producer signals. If MCP does not expose canonical block outcome confirmation, say so and do not claim the pool produced accepted blocks.\n\n## Rules\n\n- Do not use non-MCP commands.\n- Do not overstate what MCP metrics prove.\n" }, @@ -92,17 +130,34 @@ "id": "cardano-node-metrics-access", "title": "Cardano Node Metrics Access", "description": "Inspect Cardano node metrics through MCP tools only.", - "tags": ["cardano", "metrics"], - "extensions": ["cardano-relay", "cardano-block-producer"], - "tools": ["workloads.get", "workloads.metrics.get", "workloads.logs.get"], + "tags": [ + "cardano", + "metrics" + ], + "extensions": [ + "cardano-relay", + "cardano-block-producer" + ], + "tools": [ + "workloads.get", + "workloads.metrics.get", + "workloads.logs.get", + "cluster.events.list" + ], "content": "# Cardano Node Metrics Access\n\n## Goal\n\nRead Cardano or Apex Fusion workload metrics using MCP tools only.\n\n## Workflow\n\n1. Ask for namespace and workload name if unknown.\n2. Call `workloads.get` to confirm the workload and status.\n3. Call `workloads.metrics.get`.\n4. If metrics are missing or stale, call `workloads.logs.get` and `cluster.events.list`.\n\n## Interpretation\n\n- Use returned sync, resource, peer, KES, op-cert, and forging fields as the MCP source of truth.\n- If raw Prometheus text is needed but `workloads.metrics.get` does not expose it, state that MCP does not currently expose raw metric scraping and stop.\n\n## Rules\n\n- Do not exec into pods.\n- Do not query local container ports.\n- Do not use non-MCP commands.\n" }, { "id": "cardano-relay-setup", "title": "Cardano Relay Setup", "description": "Install and validate a Cardano relay.", - "tags": ["cardano", "relay", "install"], - "extensions": ["cardano-relay"], + "tags": [ + "cardano", + "relay", + "install" + ], + "extensions": [ + "cardano-relay" + ], "tools": [ "supernode.status.get", "extensions.catalog.get", @@ -120,12 +175,20 @@ "id": "cardano-spo-kes-rotation", "title": "Cardano SPO KES Rotation", "description": "Guide MCP-supported checks and deployment steps for KES rotation.", - "tags": ["cardano", "spo", "kes"], - "extensions": ["cardano-block-producer"], + "tags": [ + "cardano", + "spo", + "kes" + ], + "extensions": [ + "cardano-block-producer" + ], "tools": [ "workloads.get", "workloads.metrics.get", "vault.runtime.metadata.get", + "vault.runtime.patch", + "vault.runtime.write", "workloads.upgrade", "workloads.logs.get", "cluster.events.list" @@ -136,8 +199,15 @@ "id": "cardano-spo-maintenance-overview", "title": "Cardano SPO Maintenance Overview", "description": "Summarize MCP-supported and unsupported Cardano SPO maintenance actions.", - "tags": ["cardano", "spo", "maintenance"], - "extensions": ["cardano-relay", "cardano-block-producer"], + "tags": [ + "cardano", + "spo", + "maintenance" + ], + "extensions": [ + "cardano-relay", + "cardano-block-producer" + ], "tools": [ "workloads.list", "workloads.get", @@ -145,8 +215,9 @@ "workloads.metrics.get", "cluster.events.list", "vault.runtime.metadata.get", - "workloads.upgrade", - "workloads.delete" + "vault.runtime.write", + "vault.runtime.patch", + "workloads.upgrade" ], "content": "# Cardano SPO Maintenance Overview\n\n## Goal\n\nRoute Cardano SPO maintenance work through MCP-supported operations only.\n\n## Supported MCP Actions\n\n- inspect workloads with `workloads.list` and `workloads.get`\n- inspect logs with `workloads.logs.get`\n- inspect metrics with `workloads.metrics.get`\n- inspect events with `cluster.events.list`\n- inspect runtime secret metadata with `vault.runtime.metadata.get`\n- update runtime secret records with `vault.runtime.write` or `vault.runtime.patch`\n- apply workload changes with `workloads.upgrade`\n\n## Not Supported By MCP\n\n- Cardano transaction construction\n- Cardano transaction signing\n- stake pool registration updates\n- stake pool retirement certificate generation\n- KES key generation\n- operational certificate issuance\n- local port-forward sessions\n\nIf the operator requests one of these, say MCP does not currently expose that operation and stop for operator direction.\n\n## Rules\n\n- Start live-changing operations with `dryRun=true` where the tool supports it.\n- Do not ask users to paste signing keys or runtime secret values in chat.\n- Do not use non-MCP commands.\n" }, @@ -154,11 +225,19 @@ "id": "cardano-spo-pool-retirement", "title": "Cardano SPO Pool Retirement", "description": "Guide MCP-supported workload checks for Cardano pool retirement.", - "tags": ["cardano", "spo", "retirement"], - "extensions": ["cardano-relay", "cardano-block-producer"], + "tags": [ + "cardano", + "spo", + "retirement" + ], + "extensions": [ + "cardano-relay", + "cardano-block-producer" + ], "tools": [ "workloads.get", "workloads.metrics.get", + "workloads.upgrade", "workloads.list", "workloads.logs.get", "cluster.events.list", @@ -170,12 +249,22 @@ "id": "cardano-spo-pool-update", "title": "Cardano SPO Pool Update", "description": "Guide MCP-supported checks around Cardano pool publication updates.", - "tags": ["cardano", "spo", "pool-update"], - "extensions": ["cardano-relay", "cardano-block-producer"], + "tags": [ + "cardano", + "spo", + "pool-update" + ], + "extensions": [ + "cardano-relay", + "cardano-block-producer" + ], "tools": [ "workloads.get", "workloads.metrics.get", "vault.runtime.metadata.get", + "vault.runtime.patch", + "vault.runtime.write", + "workloads.upgrade", "workloads.logs.get", "cluster.events.list" ], @@ -185,8 +274,15 @@ "id": "cardano-stake-pool-from-scratch", "title": "Cardano Stake Pool From Scratch", "description": "Guide the MCP-supported Metis deployment portion of a new Cardano stake pool.", - "tags": ["cardano", "stake-pool", "deploy"], - "extensions": ["cardano-relay", "cardano-block-producer"], + "tags": [ + "cardano", + "stake-pool", + "deploy" + ], + "extensions": [ + "cardano-relay", + "cardano-block-producer" + ], "tools": [ "supernode.status.get", "extensions.catalog.get", @@ -207,8 +303,13 @@ "id": "dolos-supernode-deployment", "title": "Dolos Supernode Deployment", "description": "Install and validate a Dolos supernode.", - "tags": ["dolos", "deploy"], - "extensions": ["dolos"], + "tags": [ + "dolos", + "deploy" + ], + "extensions": [ + "dolos" + ], "tools": [ "supernode.status.get", "extensions.catalog.get", @@ -218,7 +319,8 @@ "workloads.get", "workloads.logs.get", "workloads.metrics.get", - "cluster.events.list" + "cluster.events.list", + "dolos.snapshot.refresh" ], "content": "# Dolos Supernode Deployment\n\n## Goal\n\nDeploy and validate Dolos with MCP tools only.\n\n## Required Inputs\n\n- release name\n- namespace\n- network\n- storage class selected from MCP\n- explicit upstream relay address\n\n## Workflow\n\n1. Call `supernode.status.get`.\n2. Call `extensions.catalog.get` with `extensionId=dolos`.\n3. Call `cluster.storage_classes.list` and ask the operator to choose one.\n4. Call `workloads.list` to inspect same-network relay candidates.\n5. Ask the operator to approve the exact `config.upstreamAddress`.\n6. Call `workloads.install` with `dryRun=true` and direct Dolos chart values.\n7. Review the dry-run result with the operator.\n8. Call `workloads.install` with `dryRun=false` only after approval.\n9. Validate with `workloads.get`, `workloads.logs.get`, `workloads.metrics.get`, and `cluster.events.list`.\n10. Use `dolos.snapshot.refresh` when the operator needs a Dolos snapshot refresh.\n\n## Minimal Configuration\n\n```json\n{\n \"dolos\": {\n \"network\": \"cardano-preview\"\n },\n \"persistence\": {\n \"storageClass\": \"\",\n \"size\": \"50Gi\"\n },\n \"config\": {\n \"upstreamAddress\": \"..svc.cluster.local:3000\"\n }\n}\n```\n\n## Rules\n\n- MCP does not auto-resolve Dolos upstreams.\n- Do not use old flat Dolos fields.\n- Do not use non-MCP commands.\n" }, @@ -226,23 +328,39 @@ "id": "hydra-head-operations", "title": "Hydra Head Operations", "description": "Guide MCP-supported inspection for Hydra head operations.", - "tags": ["hydra", "operations"], - "extensions": ["hydra-node"], - "tools": ["workloads.get", "workloads.metrics.get", "workloads.logs.get"], + "tags": [ + "hydra", + "operations" + ], + "extensions": [ + "hydra-node" + ], + "tools": [ + "workloads.get", + "workloads.metrics.get", + "workloads.logs.get", + "hydra.keys.generate" + ], "content": "# Hydra Head Operations\n\n## Goal\n\nDefine the MCP boundary for Hydra head operations.\n\n## Boundary\n\nMCP currently exposes Hydra deployment, discovery, logs, metrics, events, Vault runtime operations, and `hydra.keys.generate`. It does not expose direct Hydra head lifecycle tools such as init, commit, close, contest, fanout, decommit, or transaction submission.\n\n## Workflow\n\n1. Call `workloads.get` for the Hydra workload.\n2. Call `workloads.metrics.get` for head status and snapshot-related fields.\n3. Call `workloads.logs.get` for recent Hydra logs.\n4. Present the MCP-observed state to the operator.\n5. If the operator asks for a direct head lifecycle action, state that MCP does not currently expose that operation and stop.\n\n## Rules\n\n- Do not call Hydra HTTP or WebSocket APIs directly.\n- Do not generate port-forward commands.\n- Do not use non-MCP commands.\n" }, { "id": "hydra-node-deployment", "title": "Hydra Node Deployment", "description": "Install and validate a Hydra node.", - "tags": ["hydra", "deploy"], - "extensions": ["hydra-node"], + "tags": [ + "hydra", + "deploy" + ], + "extensions": [ + "hydra-node" + ], "tools": [ "supernode.status.get", "extensions.catalog.get", "cluster.storage_classes.list", + "hydra.keys.generate", "workloads.list", - "vault.runtime.metadata.get", + "vault.runtime.write", "workloads.install", "workloads.get", "workloads.logs.get", @@ -255,8 +373,13 @@ "id": "hydra-node-troubleshooting", "title": "Hydra Node Troubleshooting", "description": "Troubleshoot Hydra node startup, networking, metrics, and head progress.", - "tags": ["hydra", "troubleshooting"], - "extensions": ["hydra-node"], + "tags": [ + "hydra", + "troubleshooting" + ], + "extensions": [ + "hydra-node" + ], "tools": [ "workloads.get", "workloads.logs.get", @@ -270,7 +393,10 @@ "id": "kubernetes-extension-discovery", "title": "Kubernetes Extension Discovery", "description": "Discover installed catalog-managed workloads.", - "tags": ["kubernetes", "discovery"], + "tags": [ + "kubernetes", + "discovery" + ], "extensions": [], "tools": [ "supernode.status.get", @@ -285,12 +411,18 @@ "id": "kubernetes-storage-and-prereqs", "title": "Kubernetes Storage And Prereqs", "description": "Inspect Kubernetes storage and prerequisites.", - "tags": ["kubernetes", "storage", "prereqs"], + "tags": [ + "kubernetes", + "storage", + "prereqs" + ], "extensions": [], "tools": [ "supernode.status.get", "cluster.storage_classes.list", - "cluster.events.list" + "cluster.events.list", + "workloads.list", + "workloads.get" ], "content": "# Kubernetes Storage And Prereqs\n\n## Goal\n\nValidate cluster readiness before installs or upgrades using MCP tools only.\n\n## Workflow\n\n1. Call `supernode.status.get`.\n2. Call `cluster.storage_classes.list`.\n3. Call `cluster.events.list` for recent scheduling, provisioning, image, probe, or mount problems.\n4. Call `workloads.list` to understand existing releases.\n5. Call `workloads.get` for any workload involved in the planned operation.\n\n## Checks\n\n- The MCP server can reach Kubernetes.\n- A real storage class is selected from `cluster.storage_classes.list`.\n- Existing workloads are healthy enough to use as dependencies.\n- Recent events do not show unresolved scheduling or PVC failures.\n\n## Rules\n\n- Do not assume storage class names.\n- Do not proceed with install or upgrade when MCP reports unresolved PVC, scheduling, or control-plane failures.\n- Do not use non-MCP cluster commands from this skill.\n" }, @@ -298,18 +430,29 @@ "id": "supernode-dashboard-port-forward", "title": "Supernode Dashboard Port Forward", "description": "Describe the current MCP boundary for dashboard access.", - "tags": ["dashboard", "access"], + "tags": [ + "dashboard", + "access" + ], "extensions": [], - "tools": ["supernode.status.get"], + "tools": [ + "workloads.list", + "workloads.get" + ], "content": "# Supernode Dashboard Discovery\n\n## Goal\n\nFind dashboard-related control-plane outputs using MCP tools only.\n\n## Workflow\n\n1. Call `workloads.list` with `includeControlPlane=true`.\n2. Find the `control-plane` workload or dashboard-related workload returned by MCP.\n3. Call `workloads.get` for that workload.\n4. Present any dashboard, Grafana, or Prometheus outputs returned by MCP.\n5. If the user asks to open a local tunnel, explain that MCP does not currently provide a long-running port-forward tool and stop.\n\n## Rules\n\n- Do not generate local port-forward commands.\n- Do not use non-MCP cluster commands.\n" }, { "id": "workload-output-port-forward", "title": "Workload Output Port Forward", "description": "Inspect workload outputs and describe current MCP access limits.", - "tags": ["workload", "access"], + "tags": [ + "workload", + "access" + ], "extensions": [], - "tools": ["workloads.get"], + "tools": [ + "workloads.get" + ], "content": "# Workload Output Discovery\n\n## Goal\n\nDiscover workload outputs using MCP tools only.\n\n## Workflow\n\n1. Ask for namespace and workload name if unknown.\n2. Call `workloads.get`.\n3. Present the returned outputs by name, protocol, scope, service, port, and URL.\n4. If the user asks to expose an output locally, explain that MCP does not currently provide a long-running port-forward tool and stop.\n\n## Rules\n\n- Do not generate local port-forward commands.\n- Do not use non-MCP networking commands.\n- Use only the output data returned by `workloads.get`.\n" } ] diff --git a/catalog/skill-catalog.manifest.json b/catalog/skill-catalog.manifest.json index d3cfb2d..60fe7d3 100644 --- a/catalog/skill-catalog.manifest.json +++ b/catalog/skill-catalog.manifest.json @@ -13,6 +13,8 @@ "cluster.storage_classes.list", "workloads.list", "vault.runtime.metadata.get", + "vault.runtime.write", + "vault.runtime.patch", "workloads.install", "workloads.upgrade", "workloads.get", @@ -52,7 +54,7 @@ "workloads.logs.get", "workloads.metrics.get", "cluster.events.list", - "vault.runtime.metadata.get" + "dolos.snapshot.refresh" ], "contentPath": "../skills/cardano-block-producer-troubleshooting.md" }, @@ -65,9 +67,14 @@ "tools": [ "supernode.status.get", "extensions.catalog.get", + "cluster.storage_classes.list", + "workloads.list", "workloads.get", "workloads.metrics.get", "vault.runtime.metadata.get", + "vault.runtime.write", + "vault.runtime.patch", + "workloads.install", "workloads.upgrade", "workloads.logs.get", "cluster.events.list" @@ -84,7 +91,8 @@ "workloads.get", "workloads.logs.get", "workloads.metrics.get", - "cluster.events.list" + "cluster.events.list", + "dolos.snapshot.refresh" ], "contentPath": "../skills/cardano-block-producer-verification.md" }, @@ -94,7 +102,12 @@ "description": "Inspect Cardano node metrics through MCP tools only.", "tags": ["cardano", "metrics"], "extensions": ["cardano-relay", "cardano-block-producer"], - "tools": ["workloads.get", "workloads.metrics.get", "workloads.logs.get"], + "tools": [ + "workloads.get", + "workloads.metrics.get", + "workloads.logs.get", + "cluster.events.list" + ], "contentPath": "../skills/cardano-node-metrics-access.md" }, { @@ -126,6 +139,8 @@ "workloads.get", "workloads.metrics.get", "vault.runtime.metadata.get", + "vault.runtime.patch", + "vault.runtime.write", "workloads.upgrade", "workloads.logs.get", "cluster.events.list" @@ -145,8 +160,9 @@ "workloads.metrics.get", "cluster.events.list", "vault.runtime.metadata.get", - "workloads.upgrade", - "workloads.delete" + "vault.runtime.write", + "vault.runtime.patch", + "workloads.upgrade" ], "contentPath": "../skills/cardano-spo-maintenance-overview.md" }, @@ -159,6 +175,7 @@ "tools": [ "workloads.get", "workloads.metrics.get", + "workloads.upgrade", "workloads.list", "workloads.logs.get", "cluster.events.list", @@ -176,6 +193,9 @@ "workloads.get", "workloads.metrics.get", "vault.runtime.metadata.get", + "vault.runtime.patch", + "vault.runtime.write", + "workloads.upgrade", "workloads.logs.get", "cluster.events.list" ], @@ -218,7 +238,8 @@ "workloads.get", "workloads.logs.get", "workloads.metrics.get", - "cluster.events.list" + "cluster.events.list", + "dolos.snapshot.refresh" ], "contentPath": "../skills/dolos-supernode-deployment.md" }, @@ -228,7 +249,12 @@ "description": "Guide MCP-supported inspection for Hydra head operations.", "tags": ["hydra", "operations"], "extensions": ["hydra-node"], - "tools": ["workloads.get", "workloads.metrics.get", "workloads.logs.get"], + "tools": [ + "workloads.get", + "workloads.metrics.get", + "workloads.logs.get", + "hydra.keys.generate" + ], "contentPath": "../skills/hydra-head-operations.md" }, { @@ -241,8 +267,9 @@ "supernode.status.get", "extensions.catalog.get", "cluster.storage_classes.list", + "hydra.keys.generate", "workloads.list", - "vault.runtime.metadata.get", + "vault.runtime.write", "workloads.install", "workloads.get", "workloads.logs.get", @@ -290,7 +317,9 @@ "tools": [ "supernode.status.get", "cluster.storage_classes.list", - "cluster.events.list" + "cluster.events.list", + "workloads.list", + "workloads.get" ], "contentPath": "../skills/kubernetes-storage-and-prereqs.md" }, @@ -300,7 +329,7 @@ "description": "Describe the current MCP boundary for dashboard access.", "tags": ["dashboard", "access"], "extensions": [], - "tools": ["supernode.status.get"], + "tools": ["workloads.list", "workloads.get"], "contentPath": "../skills/supernode-dashboard-port-forward.md" }, { From 922287067641777df1f7637c4e810556a2b69982 Mon Sep 17 00:00:00 2001 From: Felipe Gonzalez Date: Fri, 22 May 2026 12:42:40 -0300 Subject: [PATCH 3/4] Improve code quality --- mcp-server/Cargo.lock | 39 + mcp-server/Cargo.toml | 1 + mcp-server/src/catalog/oci.rs | 297 +------- mcp-server/src/helm.rs | 126 +++- mcp-server/src/k8s/helm_releases.rs | 26 +- mcp-server/src/main.rs | 1 + mcp-server/src/oci_client.rs | 337 +++++++++ mcp-server/src/session/sqlite.rs | 99 ++- mcp-server/src/skills/oci.rs | 287 +------ mcp-server/src/tools/args.rs | 70 ++ mcp-server/src/tools/hydra.rs | 27 +- mcp-server/src/tools/mod.rs | 2 + mcp-server/src/tools/router.rs | 865 +--------------------- mcp-server/src/tools/schema_validation.rs | 187 +++++ mcp-server/src/tools/vault.rs | 102 +++ mcp-server/src/tools/workloads/delete.rs | 22 +- mcp-server/src/tools/workloads/dolos.rs | 24 +- mcp-server/src/tools/workloads/get.rs | 124 ++++ mcp-server/src/tools/workloads/install.rs | 157 +++- mcp-server/src/tools/workloads/list.rs | 116 +++ mcp-server/src/tools/workloads/logs.rs | 27 +- mcp-server/src/tools/workloads/metrics.rs | 135 ++++ mcp-server/src/tools/workloads/mod.rs | 15 +- mcp-server/src/tools/workloads/upgrade.rs | 246 +----- 24 files changed, 1494 insertions(+), 1838 deletions(-) create mode 100644 mcp-server/src/oci_client.rs create mode 100644 mcp-server/src/tools/args.rs create mode 100644 mcp-server/src/tools/schema_validation.rs create mode 100644 mcp-server/src/tools/workloads/get.rs create mode 100644 mcp-server/src/tools/workloads/list.rs create mode 100644 mcp-server/src/tools/workloads/metrics.rs diff --git a/mcp-server/Cargo.lock b/mcp-server/Cargo.lock index d3ed251..eebac39 100644 --- a/mcp-server/Cargo.lock +++ b/mcp-server/Cargo.lock @@ -464,6 +464,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1109,6 +1115,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1658,6 +1670,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -2042,6 +2067,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "thiserror", "tokio", "tokio-util", @@ -2095,6 +2121,19 @@ dependencies = [ "url", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" diff --git a/mcp-server/Cargo.toml b/mcp-server/Cargo.toml index 65790e4..50ad530 100644 --- a/mcp-server/Cargo.toml +++ b/mcp-server/Cargo.toml @@ -20,6 +20,7 @@ rusqlite = { version = "0.37", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +tempfile = "3" thiserror = "2" tokio = { version = "1", features = ["macros", "process", "rt-multi-thread", "signal", "io-util", "time"] } tokio-util = "0.7" diff --git a/mcp-server/src/catalog/oci.rs b/mcp-server/src/catalog/oci.rs index 484fa6d..4e3486d 100644 --- a/mcp-server/src/catalog/oci.rs +++ b/mcp-server/src/catalog/oci.rs @@ -1,303 +1,16 @@ -use reqwest::header::ACCEPT; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use std::time::Duration; +use crate::oci_client::{self, OciArtifactError}; const CATALOG_LAYER_MEDIA_TYPE: &str = "application/vnd.supernode.extension-catalog.v1+json"; -const JSON_MEDIA_TYPE: &str = "application/json"; -const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.manifest.v1+json, application/vnd.oci.artifact.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; -const TRUSTED_CATALOG_REGISTRY: &str = "oci.supernode.store"; + +pub(crate) type OciCatalogError = OciArtifactError; pub(super) fn is_trusted_catalog_reference(reference: &str) -> Result { - Ok(OciReference::parse(reference)?.registry == TRUSTED_CATALOG_REGISTRY) + oci_client::is_trusted_reference(reference) } pub(super) async fn fetch_catalog_json( reference: &str, max_bytes: usize, ) -> Result, OciCatalogError> { - let reference = OciReference::parse(reference)?; - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(30)) - .build()?; - let manifest = fetch_manifest(&client, &reference).await?; - let descriptor = select_catalog_descriptor(&manifest) - .ok_or_else(|| OciCatalogError::MissingCatalogLayer(reference.original.clone()))?; - - if descriptor.size.is_some_and(|size| size > max_bytes) { - return Err(OciCatalogError::CatalogTooLarge { - actual: descriptor.size.unwrap_or_default(), - max: max_bytes, - }); - } - - let payload = fetch_blob(&client, &reference, &descriptor.digest).await?; - if payload.len() > max_bytes { - return Err(OciCatalogError::CatalogTooLarge { - actual: payload.len(), - max: max_bytes, - }); - } - verify_sha256_digest(&descriptor.digest, &payload)?; - - Ok(payload) -} - -async fn fetch_manifest( - client: &reqwest::Client, - reference: &OciReference, -) -> Result { - let response = client - .get(reference.manifest_url()) - .header(ACCEPT, MANIFEST_ACCEPT) - .send() - .await? - .error_for_status() - .map_err(OciCatalogError::HttpStatus)?; - - response.json::().await.map_err(Into::into) -} - -async fn fetch_blob( - client: &reqwest::Client, - reference: &OciReference, - digest: &str, -) -> Result, OciCatalogError> { - let response = client - .get(reference.blob_url(digest)) - .send() - .await? - .error_for_status() - .map_err(OciCatalogError::HttpStatus)?; - - Ok(response.bytes().await?.to_vec()) -} - -fn select_catalog_descriptor(manifest: &OciManifest) -> Option { - manifest - .layers - .iter() - .chain(manifest.blobs.iter()) - .find(|descriptor| descriptor.media_type == CATALOG_LAYER_MEDIA_TYPE) - .or_else(|| { - manifest - .layers - .iter() - .chain(manifest.blobs.iter()) - .find(|descriptor| descriptor.media_type == JSON_MEDIA_TYPE) - }) - .cloned() -} - -fn verify_sha256_digest(expected: &str, payload: &[u8]) -> Result<(), OciCatalogError> { - let Some(expected) = expected.strip_prefix("sha256:") else { - return Err(OciCatalogError::UnsupportedDigest(expected.to_string())); - }; - let actual = hex_lower(&Sha256::digest(payload)); - if actual != expected.to_ascii_lowercase() { - return Err(OciCatalogError::DigestMismatch { - expected: expected.to_string(), - actual, - }); - } - - Ok(()) -} - -fn hex_lower(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(bytes.len() * 2); - for byte in bytes { - output.push(HEX[(byte >> 4) as usize] as char); - output.push(HEX[(byte & 0x0f) as usize] as char); - } - output -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OciReference { - original: String, - registry: String, - repository: String, - reference: String, -} - -impl OciReference { - fn parse(value: &str) -> Result { - let value = value.trim(); - let Some(rest) = value.strip_prefix("oci://") else { - return Err(OciCatalogError::InvalidReference(value.to_string())); - }; - let Some((registry, path)) = rest.split_once('/') else { - return Err(OciCatalogError::InvalidReference(value.to_string())); - }; - if registry.is_empty() || path.is_empty() { - return Err(OciCatalogError::InvalidReference(value.to_string())); - } - - let last_slash = path.rfind('/'); - let tag_separator = path.rfind(':').filter(|index| { - last_slash - .map(|last_slash| *index > last_slash) - .unwrap_or(true) - }); - - let (repository, reference) = if let Some((repository, digest)) = path.split_once('@') { - (repository, digest) - } else if let Some(index) = tag_separator { - (&path[..index], &path[index + 1..]) - } else { - return Err(OciCatalogError::MissingReference(value.to_string())); - }; - - if repository.is_empty() || reference.is_empty() { - return Err(OciCatalogError::InvalidReference(value.to_string())); - } - - Ok(Self { - original: value.to_string(), - registry: registry.to_string(), - repository: repository.to_string(), - reference: reference.to_string(), - }) - } - - fn manifest_url(&self) -> String { - format!( - "https://{}/v2/{}/manifests/{}", - self.registry, self.repository, self.reference - ) - } - - fn blob_url(&self, digest: &str) -> String { - format!( - "https://{}/v2/{}/blobs/{}", - self.registry, self.repository, digest - ) - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct OciManifest { - #[serde(default)] - layers: Vec, - #[serde(default)] - blobs: Vec, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -struct OciDescriptor { - media_type: String, - digest: String, - size: Option, -} - -#[derive(Debug, thiserror::Error)] -pub enum OciCatalogError { - #[error("invalid OCI reference: {0}")] - InvalidReference(String), - #[error("OCI reference must include a tag or digest: {0}")] - MissingReference(String), - #[error("OCI registry request failed: {0}")] - Request(#[from] reqwest::Error), - #[error("OCI registry returned an unsuccessful status: {0}")] - HttpStatus(reqwest::Error), - #[error("OCI catalog artifact does not contain a catalog JSON layer: {0}")] - MissingCatalogLayer(String), - #[error("OCI catalog blob is too large: {actual} bytes exceeds {max} bytes")] - CatalogTooLarge { actual: usize, max: usize }, - #[error("unsupported OCI catalog digest: {0}")] - UnsupportedDigest(String), - #[error("OCI catalog digest mismatch: expected {expected}, got {actual}")] - DigestMismatch { expected: String, actual: String }, -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn parses_tagged_oci_references() { - let reference = - OciReference::parse("oci://oci.supernode.store/extension-catalog:0.1.0").unwrap(); - - assert_eq!(reference.registry, "oci.supernode.store"); - assert_eq!(reference.repository, "extension-catalog"); - assert_eq!(reference.reference, "0.1.0"); - assert_eq!( - reference.manifest_url(), - "https://oci.supernode.store/v2/extension-catalog/manifests/0.1.0" - ); - } - - #[test] - fn parses_digest_oci_references() { - let reference = - OciReference::parse("oci://oci.supernode.store/extension-catalog@sha256:abc123") - .unwrap(); - - assert_eq!(reference.repository, "extension-catalog"); - assert_eq!(reference.reference, "sha256:abc123"); - } - - #[test] - fn rejects_oci_references_without_tag_or_digest() { - let error = OciReference::parse("oci://oci.supernode.store/extension-catalog").unwrap_err(); - - assert!(matches!(error, OciCatalogError::MissingReference(_))); - } - - #[test] - fn identifies_trusted_catalog_references() { - assert!( - is_trusted_catalog_reference("oci://oci.supernode.store/extension-catalog:0.1.0") - .unwrap() - ); - assert!( - !is_trusted_catalog_reference("oci://evil.example/extension-catalog:0.1.0").unwrap() - ); - } - - #[test] - fn selects_catalog_media_type_before_plain_json() { - let manifest = serde_json::from_value::(json!({ - "layers": [ - { - "mediaType": "application/json", - "digest": "sha256:plain", - "size": 1 - }, - { - "mediaType": CATALOG_LAYER_MEDIA_TYPE, - "digest": "sha256:catalog", - "size": 1 - } - ] - })) - .unwrap(); - - let descriptor = select_catalog_descriptor(&manifest).unwrap(); - - assert_eq!(descriptor.digest, "sha256:catalog"); - } - - #[test] - fn verifies_sha256_digest() { - let payload = b"catalog"; - let digest = format!("sha256:{}", hex_lower(&Sha256::digest(payload))); - - verify_sha256_digest(&digest, payload).unwrap(); - } - - #[test] - fn rejects_sha256_digest_mismatch() { - let error = verify_sha256_digest("sha256:0000", b"catalog").unwrap_err(); - - assert!(matches!(error, OciCatalogError::DigestMismatch { .. })); - } + oci_client::fetch_artifact_json(reference, max_bytes, CATALOG_LAYER_MEDIA_TYPE).await } diff --git a/mcp-server/src/helm.rs b/mcp-server/src/helm.rs index e5a17b5..52ea8e7 100644 --- a/mcp-server/src/helm.rs +++ b/mcp-server/src/helm.rs @@ -1,13 +1,13 @@ use std::path::Path; -use std::path::PathBuf; -use std::time::SystemTime; -use std::time::UNIX_EPOCH; +use std::time::Duration; use serde::Serialize; use serde_json::Value; +use tempfile::NamedTempFile; use tokio::process::Command; const DEFAULT_HELM_BIN: &str = "helm"; +const HELM_TIMEOUT: Duration = Duration::from_secs(300); #[derive(Debug, Clone, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -64,6 +64,8 @@ pub struct HelmUninstallResult { #[derive(Debug, thiserror::Error)] pub enum HelmInstallError { + #[error("MCP_HELM_BIN must be an absolute path, got: {0}")] + InvalidHelmBin(String), #[error("failed to create Helm values directory: {0}")] CreateValuesDir(std::io::Error), #[error("failed to serialize Helm values: {0}")] @@ -72,6 +74,8 @@ pub enum HelmInstallError { WriteValues(std::io::Error), #[error("failed to execute Helm: {0}")] Execute(std::io::Error), + #[error("Helm install timed out after {0} seconds")] + Timeout(u64), #[error("Helm install failed with status {status}")] Failed { status: i32, @@ -82,6 +86,8 @@ pub enum HelmInstallError { #[derive(Debug, thiserror::Error)] pub enum HelmUpgradeError { + #[error("MCP_HELM_BIN must be an absolute path, got: {0}")] + InvalidHelmBin(String), #[error("failed to create Helm values directory: {0}")] CreateValuesDir(std::io::Error), #[error("failed to serialize Helm values: {0}")] @@ -90,6 +96,8 @@ pub enum HelmUpgradeError { WriteValues(std::io::Error), #[error("failed to execute Helm: {0}")] Execute(std::io::Error), + #[error("Helm upgrade timed out after {0} seconds")] + Timeout(u64), #[error("Helm upgrade failed with status {status}")] Failed { status: i32, @@ -100,8 +108,12 @@ pub enum HelmUpgradeError { #[derive(Debug, thiserror::Error)] pub enum HelmUninstallError { + #[error("MCP_HELM_BIN must be an absolute path, got: {0}")] + InvalidHelmBin(String), #[error("failed to execute Helm: {0}")] Execute(std::io::Error), + #[error("Helm uninstall timed out after {0} seconds")] + Timeout(u64), #[error("Helm uninstall failed with status {status}")] Failed { status: i32, @@ -111,13 +123,17 @@ pub enum HelmUninstallError { } pub async fn install(plan: &HelmInstallPlan) -> Result { - let values_path = write_values_file(&plan.values).map_err(HelmInstallError::from)?; - let helm_bin = std::env::var("MCP_HELM_BIN").unwrap_or_else(|_| DEFAULT_HELM_BIN.to_string()); - let args = install_args(plan, &values_path); + let values_file = write_values_file(&plan.values).map_err(HelmInstallError::from)?; + let helm_bin = + resolve_helm_bin().map_err(|bin| HelmInstallError::InvalidHelmBin(bin.to_string()))?; + let args = install_args(plan, values_file.path()); - let output = Command::new(&helm_bin).args(&args).output().await; - let _ = std::fs::remove_file(&values_path); - let output = output.map_err(HelmInstallError::Execute)?; + let output = tokio::time::timeout(HELM_TIMEOUT, Command::new(&helm_bin).args(&args).output()) + .await + .map_err(|_| HelmInstallError::Timeout(HELM_TIMEOUT.as_secs()))? + .map_err(HelmInstallError::Execute)?; + + drop(values_file); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); @@ -141,13 +157,17 @@ pub async fn install(plan: &HelmInstallPlan) -> Result Result { - let values_path = write_values_file(&plan.values).map_err(HelmUpgradeError::from)?; - let helm_bin = std::env::var("MCP_HELM_BIN").unwrap_or_else(|_| DEFAULT_HELM_BIN.to_string()); - let args = upgrade_args(plan, &values_path); + let values_file = write_values_file(&plan.values).map_err(HelmUpgradeError::from)?; + let helm_bin = + resolve_helm_bin().map_err(|bin| HelmUpgradeError::InvalidHelmBin(bin.to_string()))?; + let args = upgrade_args(plan, values_file.path()); + + let output = tokio::time::timeout(HELM_TIMEOUT, Command::new(&helm_bin).args(&args).output()) + .await + .map_err(|_| HelmUpgradeError::Timeout(HELM_TIMEOUT.as_secs()))? + .map_err(HelmUpgradeError::Execute)?; - let output = Command::new(&helm_bin).args(&args).output().await; - let _ = std::fs::remove_file(&values_path); - let output = output.map_err(HelmUpgradeError::Execute)?; + drop(values_file); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); @@ -173,12 +193,13 @@ pub async fn upgrade(plan: &HelmUpgradePlan) -> Result Result { - let helm_bin = std::env::var("MCP_HELM_BIN").unwrap_or_else(|_| DEFAULT_HELM_BIN.to_string()); + let helm_bin = + resolve_helm_bin().map_err(|bin| HelmUninstallError::InvalidHelmBin(bin.to_string()))?; let args = uninstall_args(plan); - let output = Command::new(&helm_bin) - .args(&args) - .output() + + let output = tokio::time::timeout(HELM_TIMEOUT, Command::new(&helm_bin).args(&args).output()) .await + .map_err(|_| HelmUninstallError::Timeout(HELM_TIMEOUT.as_secs()))? .map_err(HelmUninstallError::Execute)?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); @@ -202,21 +223,36 @@ pub async fn uninstall( }) } -fn write_values_file(values: &Value) -> Result { +fn resolve_helm_bin() -> Result { + resolve_helm_bin_value(std::env::var("MCP_HELM_BIN").ok().as_deref()) +} + +fn resolve_helm_bin_value(value: Option<&str>) -> Result { + match value { + Some(bin) if bin.starts_with('/') => Ok(bin.to_string()), + Some(bin) => Err(bin.to_string()), + None => Ok(DEFAULT_HELM_BIN.to_string()), + } +} + +fn write_values_file(values: &Value) -> Result { + use std::io::Write; + let dir = std::env::var("MCP_HELM_VALUES_DIR") - .map(PathBuf::from) + .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::env::temp_dir().join("supernode-mcp-helm")); std::fs::create_dir_all(&dir).map_err(HelmValuesFileError::CreateValuesDir)?; - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let path = dir.join(format!("values-{unique}.json")); + let mut file = tempfile::Builder::new() + .prefix("values-") + .suffix(".json") + .tempfile_in(&dir) + .map_err(HelmValuesFileError::WriteValues)?; let payload = serde_json::to_vec_pretty(values).map_err(HelmValuesFileError::SerializeValues)?; - std::fs::write(&path, payload).map_err(HelmValuesFileError::WriteValues)?; - Ok(path) + file.write_all(&payload) + .map_err(HelmValuesFileError::WriteValues)?; + Ok(file) } #[derive(Debug, thiserror::Error)] @@ -290,6 +326,8 @@ fn uninstall_args(plan: &HelmUninstallPlan) -> Vec { #[cfg(test)] mod tests { + use std::path::PathBuf; + use serde_json::json; use super::*; @@ -357,4 +395,36 @@ mod tests { assert!(!args.contains(&"--install".to_string())); assert!(!args.contains(&"--create-namespace".to_string())); } + + #[test] + fn resolve_helm_bin_defaults_to_helm() { + assert_eq!(resolve_helm_bin_value(None).unwrap(), "helm"); + } + + #[test] + fn resolve_helm_bin_accepts_absolute_path() { + assert_eq!( + resolve_helm_bin_value(Some("/usr/local/bin/helm")).unwrap(), + "/usr/local/bin/helm" + ); + } + + #[test] + fn resolve_helm_bin_rejects_relative_path() { + assert!(resolve_helm_bin_value(Some("relative/helm")).is_err()); + } + + #[test] + fn values_file_uses_tempfile_with_prefix_and_suffix() { + let file = write_values_file(&json!({ "key": "value" })).unwrap(); + let path = file.path().to_path_buf(); + let filename = path.file_name().unwrap().to_string_lossy(); + + assert!(filename.starts_with("values-")); + assert!(filename.ends_with(".json")); + assert!(path.exists()); + + drop(file); + assert!(!path.exists()); + } } diff --git a/mcp-server/src/k8s/helm_releases.rs b/mcp-server/src/k8s/helm_releases.rs index cc6be44..f9a188c 100644 --- a/mcp-server/src/k8s/helm_releases.rs +++ b/mcp-server/src/k8s/helm_releases.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::fmt; use std::io::Read; use base64::Engine; @@ -18,8 +19,9 @@ const HELM_SECRET_TYPE: &str = "helm.sh/release.v1"; const HELM_RELEASE_DATA_KEY: &str = "release"; const HELM_OWNER_LABEL: &str = "owner=helm"; const CONTROL_PLANE_RELEASE_NAME: &str = "control-plane"; +const MAX_INFLATED_RELEASE_SIZE: u64 = 16 * 1024 * 1024; -#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[derive(Clone, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct HelmReleaseSummary { pub name: String, @@ -35,6 +37,23 @@ pub struct HelmReleaseSummary { pub config: Option, } +impl fmt::Debug for HelmReleaseSummary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelmReleaseSummary") + .field("name", &self.name) + .field("namespace", &self.namespace) + .field("revision", &self.revision) + .field("status", &self.status) + .field("chart", &self.chart) + .field("app_version", &self.app_version) + .field("description", &self.description) + .field("updated", &self.updated) + .field("secret_name", &self.secret_name) + .field("config", &self.config.as_ref().map(|_| "")) + .finish() + } +} + #[derive(Debug, Clone, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct HelmChartSummary { @@ -212,9 +231,10 @@ fn decode_release_payload(payload: &ByteString) -> Result Result, std::io::Error> { - let mut decoder = GzDecoder::new(payload); + let decoder = GzDecoder::new(payload); + let mut limited = decoder.take(MAX_INFLATED_RELEASE_SIZE); let mut decoded = Vec::new(); - decoder.read_to_end(&mut decoded)?; + limited.read_to_end(&mut decoded)?; Ok(decoded) } diff --git a/mcp-server/src/main.rs b/mcp-server/src/main.rs index e3a7186..e33030b 100644 --- a/mcp-server/src/main.rs +++ b/mcp-server/src/main.rs @@ -6,6 +6,7 @@ mod errors; mod helm; pub mod k8s; mod mcp; +mod oci_client; mod policy; mod prompts; mod resources; diff --git a/mcp-server/src/oci_client.rs b/mcp-server/src/oci_client.rs new file mode 100644 index 0000000..94e8363 --- /dev/null +++ b/mcp-server/src/oci_client.rs @@ -0,0 +1,337 @@ +use reqwest::header::ACCEPT; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::time::Duration; + +const JSON_MEDIA_TYPE: &str = "application/json"; +const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.manifest.v1+json, application/vnd.oci.artifact.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; +const TRUSTED_CATALOG_REGISTRY: &str = "oci.supernode.store"; +const MAX_MANIFEST_SIZE: usize = 1024 * 1024; + +pub(crate) fn is_trusted_reference(reference: &str) -> Result { + Ok(OciReference::parse(reference)?.registry == TRUSTED_CATALOG_REGISTRY) +} + +// TODO(security): Implement OCI artifact signature verification (cosign/sigstore). +// Current trust model relies on TLS hostname verification and self-consistent digest checks. +// A registry serving valid TLS for the hostname can still ship arbitrary content. +pub(crate) async fn fetch_artifact_json( + reference: &str, + max_bytes: usize, + layer_media_type: &str, +) -> Result, OciArtifactError> { + let reference = OciReference::parse(reference)?; + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build()?; + let manifest = fetch_manifest(&client, &reference).await?; + let descriptor = select_artifact_descriptor(&manifest, layer_media_type) + .ok_or_else(|| OciArtifactError::MissingCatalogLayer(reference.original.clone()))?; + + if descriptor.size.is_some_and(|size| size > max_bytes) { + return Err(OciArtifactError::CatalogTooLarge { + actual: descriptor.size.unwrap_or_default(), + max: max_bytes, + }); + } + + // Use a separate client for blob fetch with redirects disabled to prevent + // a compromised registry from redirecting to an untrusted third-party host. + let blob_client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let payload = fetch_blob(&blob_client, &reference, &descriptor.digest, max_bytes).await?; + verify_sha256_digest(&descriptor.digest, &payload)?; + + Ok(payload) +} + +async fn fetch_manifest( + client: &reqwest::Client, + reference: &OciReference, +) -> Result { + let response = client + .get(reference.manifest_url()) + .header(ACCEPT, MANIFEST_ACCEPT) + .send() + .await? + .error_for_status() + .map_err(OciArtifactError::HttpStatus)?; + + let body = read_bounded_response(response, MAX_MANIFEST_SIZE).await?; + serde_json::from_slice(&body).map_err(OciArtifactError::InvalidManifest) +} + +async fn fetch_blob( + client: &reqwest::Client, + reference: &OciReference, + digest: &str, + max_bytes: usize, +) -> Result, OciArtifactError> { + let response = client + .get(reference.blob_url(digest)) + .send() + .await? + .error_for_status() + .map_err(OciArtifactError::HttpStatus)?; + + read_bounded_response(response, max_bytes).await +} + +async fn read_bounded_response( + mut response: reqwest::Response, + max_bytes: usize, +) -> Result, OciArtifactError> { + if let Some(content_length) = response.content_length() + && content_length as usize > max_bytes + { + return Err(OciArtifactError::CatalogTooLarge { + actual: content_length as usize, + max: max_bytes, + }); + } + + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if body.len() + chunk.len() > max_bytes { + return Err(OciArtifactError::CatalogTooLarge { + actual: body.len() + chunk.len(), + max: max_bytes, + }); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +fn select_artifact_descriptor( + manifest: &OciManifest, + layer_media_type: &str, +) -> Option { + manifest + .layers + .iter() + .chain(manifest.blobs.iter()) + .find(|descriptor| descriptor.media_type == layer_media_type) + .or_else(|| { + manifest + .layers + .iter() + .chain(manifest.blobs.iter()) + .find(|descriptor| descriptor.media_type == JSON_MEDIA_TYPE) + }) + .cloned() +} + +fn verify_sha256_digest(expected: &str, payload: &[u8]) -> Result<(), OciArtifactError> { + let Some(expected) = expected.strip_prefix("sha256:") else { + return Err(OciArtifactError::UnsupportedDigest(expected.to_string())); + }; + let actual = hex_lower(&Sha256::digest(payload)); + if actual != expected.to_ascii_lowercase() { + return Err(OciArtifactError::DigestMismatch { + expected: expected.to_string(), + actual, + }); + } + + Ok(()) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OciReference { + original: String, + registry: String, + repository: String, + reference: String, +} + +impl OciReference { + fn parse(value: &str) -> Result { + let value = value.trim(); + let Some(rest) = value.strip_prefix("oci://") else { + return Err(OciArtifactError::InvalidReference(value.to_string())); + }; + let Some((registry, path)) = rest.split_once('/') else { + return Err(OciArtifactError::InvalidReference(value.to_string())); + }; + if registry.is_empty() || path.is_empty() { + return Err(OciArtifactError::InvalidReference(value.to_string())); + } + + let last_slash = path.rfind('/'); + let tag_separator = path.rfind(':').filter(|index| { + last_slash + .map(|last_slash| *index > last_slash) + .unwrap_or(true) + }); + + let (repository, reference) = if let Some((repository, digest)) = path.split_once('@') { + (repository, digest) + } else if let Some(index) = tag_separator { + (&path[..index], &path[index + 1..]) + } else { + return Err(OciArtifactError::MissingReference(value.to_string())); + }; + + if repository.is_empty() || reference.is_empty() { + return Err(OciArtifactError::InvalidReference(value.to_string())); + } + + Ok(Self { + original: value.to_string(), + registry: registry.to_string(), + repository: repository.to_string(), + reference: reference.to_string(), + }) + } + + fn manifest_url(&self) -> String { + format!( + "https://{}/v2/{}/manifests/{}", + self.registry, self.repository, self.reference + ) + } + + fn blob_url(&self, digest: &str) -> String { + format!( + "https://{}/v2/{}/blobs/{}", + self.registry, self.repository, digest + ) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OciManifest { + #[serde(default)] + layers: Vec, + #[serde(default)] + blobs: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct OciDescriptor { + media_type: String, + digest: String, + size: Option, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum OciArtifactError { + #[error("invalid OCI reference: {0}")] + InvalidReference(String), + #[error("OCI reference must include a tag or digest: {0}")] + MissingReference(String), + #[error("OCI registry request failed: {0}")] + Request(#[from] reqwest::Error), + #[error("OCI registry returned an unsuccessful status: {0}")] + HttpStatus(reqwest::Error), + #[error("OCI manifest JSON is invalid: {0}")] + InvalidManifest(serde_json::Error), + #[error("OCI artifact does not contain a catalog JSON layer: {0}")] + MissingCatalogLayer(String), + #[error("OCI catalog blob is too large: {actual} bytes exceeds {max} bytes")] + CatalogTooLarge { actual: usize, max: usize }, + #[error("unsupported OCI catalog digest: {0}")] + UnsupportedDigest(String), + #[error("OCI catalog digest mismatch: expected {expected}, got {actual}")] + DigestMismatch { expected: String, actual: String }, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + const TEST_LAYER_MEDIA_TYPE: &str = "application/vnd.supernode.test-catalog.v1+json"; + + #[test] + fn parses_tagged_oci_references() { + let reference = + OciReference::parse("oci://oci.supernode.store/test-catalog:0.1.0").unwrap(); + + assert_eq!(reference.registry, "oci.supernode.store"); + assert_eq!(reference.repository, "test-catalog"); + assert_eq!(reference.reference, "0.1.0"); + assert_eq!( + reference.manifest_url(), + "https://oci.supernode.store/v2/test-catalog/manifests/0.1.0" + ); + } + + #[test] + fn parses_digest_oci_references() { + let reference = + OciReference::parse("oci://oci.supernode.store/test-catalog@sha256:abc123").unwrap(); + + assert_eq!(reference.repository, "test-catalog"); + assert_eq!(reference.reference, "sha256:abc123"); + } + + #[test] + fn rejects_references_without_tag_or_digest() { + let error = OciReference::parse("oci://oci.supernode.store/test-catalog").unwrap_err(); + + assert!(matches!(error, OciArtifactError::MissingReference(_))); + } + + #[test] + fn identifies_trusted_catalog_registry() { + assert!(is_trusted_reference("oci://oci.supernode.store/test-catalog:0.1.0").unwrap()); + assert!(!is_trusted_reference("oci://example.com/test-catalog:0.1.0").unwrap()); + } + + #[test] + fn selects_specific_media_type_before_generic_json() { + let manifest = serde_json::from_value::(json!({ + "layers": [ + { + "mediaType": "application/json", + "digest": "sha256:plain", + "size": 1 + }, + { + "mediaType": TEST_LAYER_MEDIA_TYPE, + "digest": "sha256:catalog", + "size": 1 + } + ] + })) + .unwrap(); + + let descriptor = select_artifact_descriptor(&manifest, TEST_LAYER_MEDIA_TYPE).unwrap(); + + assert_eq!(descriptor.digest, "sha256:catalog"); + } + + #[test] + fn verifies_sha256_digest() { + let payload = b"catalog"; + let digest = format!("sha256:{}", hex_lower(&Sha256::digest(payload))); + + verify_sha256_digest(&digest, payload).unwrap(); + } + + #[test] + fn rejects_sha256_digest_mismatch() { + let error = verify_sha256_digest("sha256:0000", b"catalog").unwrap_err(); + + assert!(matches!(error, OciArtifactError::DigestMismatch { .. })); + } +} diff --git a/mcp-server/src/session/sqlite.rs b/mcp-server/src/session/sqlite.rs index eba2341..4010f5f 100644 --- a/mcp-server/src/session/sqlite.rs +++ b/mcp-server/src/session/sqlite.rs @@ -75,57 +75,74 @@ impl SqliteSessionStore { #[async_trait] impl SessionStore for SqliteSessionStore { async fn load(&self, session_id: &str) -> Result, SessionStoreError> { - let now = unix_timestamp(); - self.with_connection(|connection| { - delete_expired(connection, now)?; - let state_json = connection - .query_row( - "SELECT state_json FROM mcp_sessions WHERE session_id = ?1 AND (expires_at IS NULL OR expires_at > ?2)", - params![session_id, now], - |row| row.get::<_, String>(0), - ) - .optional() - .map_err(box_error)?; - - state_json - .map(|value| serde_json::from_str(&value).map_err(box_error)) - .transpose() + let store = self.clone(); + let session_id = session_id.to_string(); + tokio::task::spawn_blocking(move || { + let now = unix_timestamp(); + store.with_connection(|connection| { + delete_expired(connection, now)?; + let state_json = connection + .query_row( + "SELECT state_json FROM mcp_sessions WHERE session_id = ?1 AND (expires_at IS NULL OR expires_at > ?2)", + params![session_id, now], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(box_error)?; + + state_json + .map(|value| serde_json::from_str(&value).map_err(box_error)) + .transpose() + }) }) + .await + .map_err(box_error)? } async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError> { - let now = unix_timestamp(); - let expires_at = self.expires_at(now); + let store = self.clone(); + let session_id = session_id.to_string(); let state_json = serde_json::to_string(state).map_err(box_error)?; - - self.with_connection(|connection| { - connection - .execute( - r#" - INSERT INTO mcp_sessions (session_id, state_json, created_at, updated_at, expires_at) - VALUES (?1, ?2, ?3, ?3, ?4) - ON CONFLICT(session_id) DO UPDATE SET - state_json = excluded.state_json, - updated_at = excluded.updated_at, - expires_at = excluded.expires_at - "#, - params![session_id, state_json, now, expires_at], - ) - .map_err(box_error)?; - Ok(()) + tokio::task::spawn_blocking(move || { + let now = unix_timestamp(); + let expires_at = store.expires_at(now); + store.with_connection(|connection| { + connection + .execute( + r#" + INSERT INTO mcp_sessions (session_id, state_json, created_at, updated_at, expires_at) + VALUES (?1, ?2, ?3, ?3, ?4) + ON CONFLICT(session_id) DO UPDATE SET + state_json = excluded.state_json, + updated_at = excluded.updated_at, + expires_at = excluded.expires_at + "#, + params![session_id, state_json, now, expires_at], + ) + .map_err(box_error)?; + Ok(()) + }) }) + .await + .map_err(box_error)? } async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError> { - self.with_connection(|connection| { - connection - .execute( - "DELETE FROM mcp_sessions WHERE session_id = ?1", - params![session_id], - ) - .map_err(box_error)?; - Ok(()) + let store = self.clone(); + let session_id = session_id.to_string(); + tokio::task::spawn_blocking(move || { + store.with_connection(|connection| { + connection + .execute( + "DELETE FROM mcp_sessions WHERE session_id = ?1", + params![session_id], + ) + .map_err(box_error)?; + Ok(()) + }) }) + .await + .map_err(box_error)? } } diff --git a/mcp-server/src/skills/oci.rs b/mcp-server/src/skills/oci.rs index cd44104..64d89e8 100644 --- a/mcp-server/src/skills/oci.rs +++ b/mcp-server/src/skills/oci.rs @@ -1,293 +1,16 @@ -use reqwest::header::ACCEPT; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use std::time::Duration; +use crate::oci_client::{self, OciArtifactError}; const SKILL_CATALOG_LAYER_MEDIA_TYPE: &str = "application/vnd.supernode.skill-catalog.v1+json"; -const JSON_MEDIA_TYPE: &str = "application/json"; -const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.manifest.v1+json, application/vnd.oci.artifact.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; -const TRUSTED_CATALOG_REGISTRY: &str = "oci.supernode.store"; + +pub(crate) type OciSkillCatalogError = OciArtifactError; pub(super) fn is_trusted_catalog_reference(reference: &str) -> Result { - Ok(OciReference::parse(reference)?.registry == TRUSTED_CATALOG_REGISTRY) + oci_client::is_trusted_reference(reference) } pub(super) async fn fetch_catalog_json( reference: &str, max_bytes: usize, ) -> Result, OciSkillCatalogError> { - let reference = OciReference::parse(reference)?; - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(30)) - .build()?; - let manifest = fetch_manifest(&client, &reference).await?; - let descriptor = select_catalog_descriptor(&manifest) - .ok_or_else(|| OciSkillCatalogError::MissingCatalogLayer(reference.original.clone()))?; - - if descriptor.size.is_some_and(|size| size > max_bytes) { - return Err(OciSkillCatalogError::CatalogTooLarge { - actual: descriptor.size.unwrap_or_default(), - max: max_bytes, - }); - } - - let payload = fetch_blob(&client, &reference, &descriptor.digest).await?; - if payload.len() > max_bytes { - return Err(OciSkillCatalogError::CatalogTooLarge { - actual: payload.len(), - max: max_bytes, - }); - } - verify_sha256_digest(&descriptor.digest, &payload)?; - - Ok(payload) -} - -async fn fetch_manifest( - client: &reqwest::Client, - reference: &OciReference, -) -> Result { - let response = client - .get(reference.manifest_url()) - .header(ACCEPT, MANIFEST_ACCEPT) - .send() - .await? - .error_for_status() - .map_err(OciSkillCatalogError::HttpStatus)?; - - response.json::().await.map_err(Into::into) -} - -async fn fetch_blob( - client: &reqwest::Client, - reference: &OciReference, - digest: &str, -) -> Result, OciSkillCatalogError> { - let response = client - .get(reference.blob_url(digest)) - .send() - .await? - .error_for_status() - .map_err(OciSkillCatalogError::HttpStatus)?; - - Ok(response.bytes().await?.to_vec()) -} - -fn select_catalog_descriptor(manifest: &OciManifest) -> Option { - manifest - .layers - .iter() - .chain(manifest.blobs.iter()) - .find(|descriptor| descriptor.media_type == SKILL_CATALOG_LAYER_MEDIA_TYPE) - .or_else(|| { - manifest - .layers - .iter() - .chain(manifest.blobs.iter()) - .find(|descriptor| descriptor.media_type == JSON_MEDIA_TYPE) - }) - .cloned() -} - -fn verify_sha256_digest(expected: &str, payload: &[u8]) -> Result<(), OciSkillCatalogError> { - let Some(expected) = expected.strip_prefix("sha256:") else { - return Err(OciSkillCatalogError::UnsupportedDigest( - expected.to_string(), - )); - }; - let actual = hex_lower(&Sha256::digest(payload)); - if actual != expected.to_ascii_lowercase() { - return Err(OciSkillCatalogError::DigestMismatch { - expected: expected.to_string(), - actual, - }); - } - - Ok(()) -} - -fn hex_lower(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(bytes.len() * 2); - for byte in bytes { - output.push(HEX[(byte >> 4) as usize] as char); - output.push(HEX[(byte & 0x0f) as usize] as char); - } - output -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OciReference { - original: String, - registry: String, - repository: String, - reference: String, -} - -impl OciReference { - fn parse(value: &str) -> Result { - let value = value.trim(); - let Some(rest) = value.strip_prefix("oci://") else { - return Err(OciSkillCatalogError::InvalidReference(value.to_string())); - }; - let Some((registry, path)) = rest.split_once('/') else { - return Err(OciSkillCatalogError::InvalidReference(value.to_string())); - }; - if registry.is_empty() || path.is_empty() { - return Err(OciSkillCatalogError::InvalidReference(value.to_string())); - } - - let last_slash = path.rfind('/'); - let tag_separator = path.rfind(':').filter(|index| { - last_slash - .map(|last_slash| *index > last_slash) - .unwrap_or(true) - }); - - let (repository, reference) = if let Some((repository, digest)) = path.split_once('@') { - (repository, digest) - } else if let Some(index) = tag_separator { - (&path[..index], &path[index + 1..]) - } else { - return Err(OciSkillCatalogError::MissingReference(value.to_string())); - }; - - if repository.is_empty() || reference.is_empty() { - return Err(OciSkillCatalogError::InvalidReference(value.to_string())); - } - - Ok(Self { - original: value.to_string(), - registry: registry.to_string(), - repository: repository.to_string(), - reference: reference.to_string(), - }) - } - - fn manifest_url(&self) -> String { - format!( - "https://{}/v2/{}/manifests/{}", - self.registry, self.repository, self.reference - ) - } - - fn blob_url(&self, digest: &str) -> String { - format!( - "https://{}/v2/{}/blobs/{}", - self.registry, self.repository, digest - ) - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct OciManifest { - #[serde(default)] - layers: Vec, - #[serde(default)] - blobs: Vec, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -struct OciDescriptor { - media_type: String, - digest: String, - size: Option, -} - -#[derive(Debug, thiserror::Error)] -pub enum OciSkillCatalogError { - #[error("invalid OCI reference: {0}")] - InvalidReference(String), - #[error("OCI reference must include a tag or digest: {0}")] - MissingReference(String), - #[error("OCI registry request failed: {0}")] - Request(#[from] reqwest::Error), - #[error("OCI registry returned an unsuccessful status: {0}")] - HttpStatus(reqwest::Error), - #[error("OCI skill catalog artifact does not contain a skill catalog JSON layer: {0}")] - MissingCatalogLayer(String), - #[error("OCI skill catalog blob is too large: {actual} bytes exceeds {max} bytes")] - CatalogTooLarge { actual: usize, max: usize }, - #[error("unsupported OCI skill catalog digest: {0}")] - UnsupportedDigest(String), - #[error("OCI skill catalog digest mismatch: expected {expected}, got {actual}")] - DigestMismatch { expected: String, actual: String }, -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn parses_tagged_oci_references() { - let reference = - OciReference::parse("oci://oci.supernode.store/skill-catalog:0.1.0").unwrap(); - - assert_eq!(reference.registry, "oci.supernode.store"); - assert_eq!(reference.repository, "skill-catalog"); - assert_eq!(reference.reference, "0.1.0"); - assert_eq!( - reference.manifest_url(), - "https://oci.supernode.store/v2/skill-catalog/manifests/0.1.0" - ); - } - - #[test] - fn rejects_references_without_tag_or_digest() { - let error = OciReference::parse("oci://oci.supernode.store/skill-catalog").unwrap_err(); - - assert!(matches!(error, OciSkillCatalogError::MissingReference(_))); - } - - #[test] - fn identifies_trusted_catalog_registry() { - assert!( - is_trusted_catalog_reference("oci://oci.supernode.store/skill-catalog:0.1.0").unwrap() - ); - assert!(!is_trusted_catalog_reference("oci://example.com/skill-catalog:0.1.0").unwrap()); - } - - #[test] - fn selects_skill_catalog_layer_before_generic_json() { - let manifest = OciManifest { - layers: vec![ - descriptor(JSON_MEDIA_TYPE, "sha256:generic", 10), - descriptor(SKILL_CATALOG_LAYER_MEDIA_TYPE, "sha256:specific", 20), - ], - blobs: vec![], - }; - - let selected = select_catalog_descriptor(&manifest).unwrap(); - - assert_eq!(selected.digest, "sha256:specific"); - } - - #[test] - fn verifies_sha256_digest() { - let payload = br#"{"schemaVersion":"supernode.skillCatalog/v1","skills":[]}"#; - let digest = format!("sha256:{}", hex_lower(&Sha256::digest(payload))); - - verify_sha256_digest(&digest, payload).unwrap(); - } - - #[test] - fn detects_digest_mismatch() { - let error = verify_sha256_digest("sha256:deadbeef", b"payload").unwrap_err(); - - assert!(matches!(error, OciSkillCatalogError::DigestMismatch { .. })); - } - - fn descriptor(media_type: &str, digest: &str, size: usize) -> OciDescriptor { - serde_json::from_value(json!({ - "mediaType": media_type, - "digest": digest, - "size": size, - })) - .unwrap() - } + oci_client::fetch_artifact_json(reference, max_bytes, SKILL_CATALOG_LAYER_MEDIA_TYPE).await } diff --git a/mcp-server/src/tools/args.rs b/mcp-server/src/tools/args.rs new file mode 100644 index 0000000..ec5158b --- /dev/null +++ b/mcp-server/src/tools/args.rs @@ -0,0 +1,70 @@ +use rmcp::model::{CallToolResult, JsonObject}; +use serde_json::{Value, json}; + +use crate::tools::common::tool_error; + +pub(crate) fn required_object( + arguments: Option<&JsonObject>, + name: &str, +) -> Result { + match arguments.and_then(|arguments| arguments.get(name)) { + Some(Value::Object(value)) => Ok(value.clone()), + Some(value) => Err(tool_error( + "invalid_arguments", + format!("expected object argument: {name}"), + json!({ "argument": name, "actualType": value_type_name(value) }), + )), + None => Err(tool_error( + "invalid_arguments", + format!("missing required object argument: {name}"), + json!({ "argument": name }), + )), + } +} + +pub(crate) fn required_string( + arguments: Option<&JsonObject>, + name: &str, +) -> Result { + optional_string(arguments, name).ok_or_else(|| { + tool_error( + "invalid_arguments", + format!("missing required string argument: {name}"), + json!({ "argument": name }), + ) + }) +} + +pub(crate) fn optional_string(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments? + .get(name)? + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) +} + +pub(crate) fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments?.get(name)?.as_bool() +} + +pub(crate) fn optional_i64(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments?.get(name)?.as_i64() +} + +pub(crate) fn optional_u32(arguments: Option<&JsonObject>, name: &str) -> Option { + arguments? + .get(name)? + .as_u64() + .and_then(|value| u32::try_from(value).ok()) +} + +pub(crate) fn value_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} diff --git a/mcp-server/src/tools/hydra.rs b/mcp-server/src/tools/hydra.rs index c4d3c65..49a76c6 100644 --- a/mcp-server/src/tools/hydra.rs +++ b/mcp-server/src/tools/hydra.rs @@ -9,6 +9,7 @@ use crate::vault::{SecretObject, VaultClient, VaultPath, WriteMode}; use super::{ ToolDefinition, + args::{optional_bool, optional_string, required_string}, common::{success, tool_error, vault_error}, }; @@ -26,7 +27,7 @@ pub fn definitions() -> &'static [ToolDefinition] { required_scope: Scope::VaultRuntimeWrite, approval_class: ApprovalClass::RuntimeSecretWrite, read_only: false, - destructive: false, + destructive: true, input_schema: r#"{"type":"object","required":["path","approvalId"],"properties":{"path":{"type":"string","pattern":"^runtime/","description":"Runtime Vault path without kv/data prefix."},"approvalId":{"type":"string"},"signingKeyName":{"type":"string","minLength":1,"default":"hydra.sk","description":"Vault field name for the Hydra signing key text envelope."},"verificationKeyName":{"type":"string","minLength":1,"default":"hydra.vk","description":"Vault field name for the Hydra verification key text envelope."},"overwrite":{"type":"boolean","default":false,"description":"Allow replacing existing signing or verification key fields at the target path."}},"additionalProperties":false}"#, }] } @@ -179,28 +180,6 @@ fn runtime_vault_path(arguments: Option<&JsonObject>) -> Result, name: &str) -> Result { - optional_string(arguments, name).ok_or_else(|| { - tool_error( - "invalid_arguments", - format!("missing required string argument: {name}"), - json!({ "argument": name }), - ) - }) -} - -fn optional_string(arguments: Option<&JsonObject>, name: &str) -> Option { - arguments? - .get(name)? - .as_str() - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) -} - -fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { - arguments?.get(name)?.as_bool() -} - #[derive(Debug, Clone, Eq, PartialEq)] struct HydraKeyPair { signing_key: String, @@ -291,7 +270,7 @@ mod tests { assert_eq!(definition.required_scope, Scope::VaultRuntimeWrite); assert_eq!(definition.approval_class, ApprovalClass::RuntimeSecretWrite); assert!(!definition.read_only); - assert!(!definition.destructive); + assert!(definition.destructive); assert!(definition.input_schema.contains("overwrite")); } diff --git a/mcp-server/src/tools/mod.rs b/mcp-server/src/tools/mod.rs index 375d450..382df68 100644 --- a/mcp-server/src/tools/mod.rs +++ b/mcp-server/src/tools/mod.rs @@ -1,8 +1,10 @@ +pub(crate) mod args; pub(crate) mod common; pub(crate) mod dynamic; pub(crate) mod hydra; pub(crate) mod k8s_summaries; pub mod router; +pub(crate) mod schema_validation; pub mod supernode; pub mod vault; pub mod workloads; diff --git a/mcp-server/src/tools/router.rs b/mcp-server/src/tools/router.rs index 37f3348..ebb265b 100644 --- a/mcp-server/src/tools/router.rs +++ b/mcp-server/src/tools/router.rs @@ -5,15 +5,18 @@ use serde_json::{Value, json}; use crate::{ catalog::{ExtensionCatalog, extension_summary}, - helm::{self, HelmChartRef, HelmInstallPlan}, - k8s::{HelmReleaseDiscovery, KubernetesClient, ResourceListParams}, - vault::{SecretObject, VaultClient, VaultError, VaultPath, WriteMode}, + k8s::{KubernetesClient, ResourceListParams}, + vault::WriteMode, }; use super::{ - ToolDefinition, common::kube_error, common::pod_exec_error, common::success, - common::tool_error, common::vault_error, hydra, k8s_summaries, supernode, vault, workloads, - workloads::install, workloads::logs, workloads::outputs, workloads::registry, + ToolDefinition, + args::{optional_string, optional_u32, required_string}, + common::kube_error, + common::success, + common::tool_error, + hydra, k8s_summaries, supernode, vault, workloads, + workloads::{install, logs}, }; #[derive(Debug, Clone)] @@ -78,15 +81,15 @@ impl ToolRouter { "cluster.events.list" => events_list(arguments).await, "extensions.catalog.list" => catalog_list(catalog), "extensions.catalog.get" => catalog_get(arguments, catalog), - "vault.runtime.metadata.get" => vault_runtime_metadata_get(arguments).await, - "vault.runtime.write" => vault_runtime_write(arguments, WriteMode::Replace).await, - "vault.runtime.patch" => vault_runtime_write(arguments, WriteMode::Patch).await, + "vault.runtime.metadata.get" => vault::runtime_metadata_get(arguments).await, + "vault.runtime.write" => vault::runtime_write(arguments, WriteMode::Replace).await, + "vault.runtime.patch" => vault::runtime_write(arguments, WriteMode::Patch).await, "hydra.keys.generate" => hydra::generate_keys(arguments).await, - "workloads.list" => workloads_list(arguments, catalog).await, - "workloads.get" => workloads_get(arguments, catalog).await, + "workloads.list" => workloads::list::list(arguments, catalog).await, + "workloads.get" => workloads::get::get(arguments, catalog).await, "workloads.logs.get" => logs::get(arguments).await, - "workloads.metrics.get" => workloads_metrics_get(arguments, catalog).await, - "workloads.install" => workloads_install(arguments, catalog).await, + "workloads.metrics.get" => workloads::metrics::get(arguments, catalog).await, + "workloads.install" => install::install(arguments, catalog).await, "workloads.upgrade" => workloads::upgrade::upgrade(arguments, catalog).await, "workloads.delete" => workloads::delete::delete(arguments, catalog).await, "dolos.snapshot.refresh" => workloads::dolos::snapshot_refresh(arguments).await, @@ -95,60 +98,6 @@ impl ToolRouter { } } -async fn vault_runtime_metadata_get(arguments: Option<&JsonObject>) -> CallToolResult { - let path = match runtime_vault_path(arguments) { - Ok(path) => path, - Err(error) => return error, - }; - let client = match VaultClient::from_env() { - Ok(client) => client, - Err(error) => return vault_error("vault.runtime.metadata.get", error), - }; - - match client.runtime_metadata(&path).await { - Ok(metadata) => success(json!({ - "path": metadata.path, - "exists": metadata.exists, - "keyNames": metadata.key_names, - "keyNamesAvailable": metadata.key_names_available, - "currentVersion": metadata.current_version, - })), - Err(error) => vault_error("vault.runtime.metadata.get", error), - } -} - -async fn vault_runtime_write( - arguments: Option<&JsonObject>, - default_mode: WriteMode, -) -> CallToolResult { - let path = match runtime_vault_path(arguments) { - Ok(path) => path, - Err(error) => return error, - }; - let secret = match secret_argument(arguments) { - Ok(secret) => secret, - Err(error) => return error, - }; - let mode = match write_mode(arguments, default_mode) { - Ok(mode) => mode, - Err(error) => return vault_error("vault.runtime.write", error), - }; - let client = match VaultClient::from_env() { - Ok(client) => client, - Err(error) => return vault_error("vault.runtime.write", error), - }; - - match client.write_runtime_secret(&path, &secret, mode).await { - Ok(receipt) => success(json!({ - "path": receipt.path, - "writtenKeys": receipt.written_keys, - "version": receipt.version, - "secretValuesReturned": false, - })), - Err(error) => vault_error("vault.runtime.write", error), - } -} - async fn supernode_status(catalog: &ExtensionCatalog) -> CallToolResult { let kubernetes = match KubernetesClient::try_default().await { Ok(client) => match client @@ -234,451 +183,6 @@ fn catalog_get(arguments: Option<&JsonObject>, catalog: &ExtensionCatalog) -> Ca } } -async fn workloads_install( - arguments: Option<&JsonObject>, - catalog: &ExtensionCatalog, -) -> CallToolResult { - let extension_id = match required_string(arguments, "extensionId") { - Ok(value) => value, - Err(error) => return error, - }; - let release_name = match required_string(arguments, "releaseName") { - Ok(value) => value, - Err(error) => return error, - }; - let namespace = match required_string(arguments, "namespace") { - Ok(value) => value, - Err(error) => return error, - }; - let dry_run = optional_bool(arguments, "dryRun").unwrap_or(true); - - let configuration = match required_object(arguments, "configuration") { - Ok(value) => value, - Err(error) => return error, - }; - let extension = match catalog.get(&extension_id) { - Some(extension) => extension, - None => { - return tool_error( - "unknown_extension", - format!("extension not found: {extension_id}"), - json!({ "extensionId": extension_id }), - ); - } - }; - - if let Err(error) = validate_configuration_schema(&configuration, &extension.configuration) { - return error; - } - - if !matches!( - extension.id.as_str(), - workloads::registry::DOLOS_EXTENSION_ID - | workloads::registry::CARDANO_RELAY_EXTENSION_ID - | workloads::registry::CARDANO_BLOCK_PRODUCER_EXTENSION_ID - | workloads::registry::APEX_FUSION_RELAY_EXTENSION_ID - | workloads::registry::APEX_FUSION_BLOCK_PRODUCER_EXTENSION_ID - | workloads::registry::HYDRA_NODE_EXTENSION_ID - ) && configuration.get("namespace").and_then(Value::as_str) != Some(namespace.as_str()) - { - return tool_error( - "invalid_arguments", - "configuration.namespace must match namespace", - json!({ "namespace": namespace, "configurationNamespace": configuration.get("namespace") }), - ); - } - - let resolved_configuration = install::apply_defaults(extension, Value::Object(configuration)); - let resolution = match install::resolve_configuration( - extension, - &namespace, - resolved_configuration, - dry_run, - ) - .await - { - Ok(resolution) => resolution, - Err(error) => return error, - }; - let helm_values = - install::planned_helm_values(extension, &release_name, &resolution.configuration); - let chart = HelmChartRef { - chart: extension.chart.clone(), - version: extension.default_version.clone(), - }; - - if dry_run { - return success(json!({ - "action": "install", - "dryRun": true, - "wouldMutate": false, - "release": { - "name": release_name, - "namespace": namespace, - }, - "extension": { - "id": extension.id, - "name": extension.name, - "version": extension.default_version, - }, - "chart": chart, - "resolvedConfiguration": resolution.configuration, - "helmValues": helm_values, - "availableStorageClasses": resolution.available_storage_classes, - "recommendedStorageClasses": resolution.recommended_storage_classes, - "notes": [ - "dry-run planning only; no Kubernetes or Helm mutation was performed", - "raw Helm values are rejected by the extension configuration schema" - ], - })); - } - - let plan = HelmInstallPlan { - release_name: release_name.clone(), - namespace: namespace.clone(), - chart: chart.clone(), - values: helm_values.clone(), - }; - let helm_result = match helm::install(&plan).await { - Ok(result) => result, - Err(error) => { - let helm_details = match &error { - helm::HelmInstallError::Failed { - status, - stdout, - stderr, - } => json!({ - "tool": "workloads.install", - "extensionId": extension.id, - "releaseName": release_name, - "namespace": namespace, - "status": status, - "stdout": stdout, - "stderr": stderr, - }), - _ => json!({ - "tool": "workloads.install", - "extensionId": extension.id, - "releaseName": release_name, - "namespace": namespace, - }), - }; - return tool_error("helm_install_failed", error.to_string(), helm_details); - } - }; - - success(json!({ - "action": "install", - "dryRun": false, - "wouldMutate": true, - "release": { - "name": release_name, - "namespace": namespace, - }, - "extension": { - "id": extension.id, - "name": extension.name, - "version": extension.default_version, - }, - "chart": chart, - "resolvedConfiguration": resolution.configuration, - "helmValues": helm_values, - "availableStorageClasses": resolution.available_storage_classes, - "recommendedStorageClasses": resolution.recommended_storage_classes, - "helm": helm_result, - "notes": [ - "Helm upgrade --install completed successfully", - "raw Helm values are rejected by the extension configuration schema" - ], - })) -} - -async fn workloads_list( - arguments: Option<&JsonObject>, - catalog: &ExtensionCatalog, -) -> CallToolResult { - let namespace = optional_string(arguments, "namespace"); - let include_control_plane = optional_bool(arguments, "includeControlPlane").unwrap_or(false); - let client = match KubernetesClient::try_default().await { - Ok(client) => client, - Err(error) => return kube_error("workloads.list", error), - }; - let helm_releases = match HelmReleaseDiscovery::new(client.clone()) - .list_latest(namespace.as_deref(), include_control_plane) - .await - { - Ok(releases) => releases, - Err(error) => { - return tool_error( - "helm_release_discovery_error", - error.to_string(), - json!({ "tool": "workloads.list" }), - ); - } - }; - - let workloads = helm_releases - .iter() - .map(|release| workload_summary(release, catalog)) - .collect::>(); - - success(json!({ - "namespace": namespace, - "source": "kubernetes-api+helm-secrets", - "workloads": workloads, - })) -} - -fn workload_summary(release: &crate::k8s::HelmReleaseSummary, catalog: &ExtensionCatalog) -> Value { - let catalog_extension = - registry::extension_for_release(release, catalog).map(extension_summary); - - json!({ - "namespace": release.namespace, - "name": release.name, - "status": release.status, - "revision": release.revision, - "chart": release.chart, - "appVersion": release.app_version, - "updated": release.updated, - "catalogExtension": catalog_extension, - }) -} - -async fn workloads_get( - arguments: Option<&JsonObject>, - catalog: &ExtensionCatalog, -) -> CallToolResult { - let namespace = match required_string(arguments, "namespace") { - Ok(value) => value, - Err(error) => return error, - }; - let name = match required_string(arguments, "name") { - Ok(value) => value, - Err(error) => return error, - }; - let client = match KubernetesClient::try_default().await { - Ok(client) => client, - Err(error) => return kube_error("workloads.get", error), - }; - let helm_release = match HelmReleaseDiscovery::new(client.clone()) - .get_latest(&namespace, &name) - .await - { - Ok(release) => release, - Err(error) => { - return tool_error( - "helm_release_discovery_error", - error.to_string(), - json!({ "tool": "workloads.get", "namespace": namespace, "name": name }), - ); - } - }; - - let deployment = match get_optional(client.get_deployment(&namespace, &name).await) { - Ok(value) => value.map(|deployment| k8s_summaries::deployment_summary(&deployment)), - Err(error) => return kube_error("workloads.get", error), - }; - let stateful_set = match get_optional(client.get_stateful_set(&namespace, &name).await) { - Ok(value) => value.map(|stateful_set| k8s_summaries::stateful_set_summary(&stateful_set)), - Err(error) => return kube_error("workloads.get", error), - }; - let pod = match get_optional(client.get_pod(&namespace, &name).await) { - Ok(value) => value.map(|pod| k8s_summaries::pod_summary(&pod)), - Err(error) => return kube_error("workloads.get", error), - }; - let services = match client - .list_services( - Some(&namespace), - &ResourceListParams { - label_selector: Some(format!("app.kubernetes.io/instance={name}")), - ..Default::default() - }, - ) - .await - { - Ok(items) => items, - Err(error) => return kube_error("workloads.get", error), - }; - let pod_items = match client - .list_pods( - Some(&namespace), - &ResourceListParams { - label_selector: Some(format!("app.kubernetes.io/instance={name}")), - ..Default::default() - }, - ) - .await - { - Ok(items) => items.items, - Err(error) => return kube_error("workloads.get", error), - }; - let pods = pod_items - .iter() - .map(k8s_summaries::pod_summary) - .collect::>(); - - if deployment.is_none() - && stateful_set.is_none() - && pod.is_none() - && helm_release.is_none() - && pods.is_empty() - && services.items.is_empty() - { - return tool_error( - "not_found", - format!("workload not found: {namespace}/{name}"), - json!({ "namespace": namespace, "name": name }), - ); - } - - success(json!({ - "namespace": namespace, - "name": name, - "source": "kubernetes-api+helm-secrets", - "helmRelease": helm_release, - "deployment": deployment, - "statefulSet": stateful_set, - "pod": pod, - "relatedPods": pods, - "logTargets": k8s_summaries::pod_log_target_summaries(&pod_items), - "relatedServices": k8s_summaries::service_summaries(services.clone(), true), - "outputs": outputs::outputs_for_release( - &namespace, - &name, - helm_release.as_ref(), - &services.items, - catalog, - ), - })) -} - -async fn workloads_metrics_get( - arguments: Option<&JsonObject>, - catalog: &ExtensionCatalog, -) -> CallToolResult { - let namespace = match required_string(arguments, "namespace") { - Ok(value) => value, - Err(error) => return error, - }; - let workload = match required_string(arguments, "workload") { - Ok(value) => value, - Err(error) => return error, - }; - let client = match KubernetesClient::try_default().await { - Ok(client) => client, - Err(error) => return kube_error("workloads.metrics.get", error), - }; - let helm_release = match HelmReleaseDiscovery::new(client.clone()) - .get_latest(&namespace, &workload) - .await - { - Ok(Some(release)) => release, - Ok(None) => { - return tool_error( - "not_found", - format!("workload not found: {namespace}/{workload}"), - json!({ "namespace": namespace, "workload": workload }), - ); - } - Err(error) => { - return tool_error( - "helm_release_discovery_error", - error.to_string(), - json!({ "tool": "workloads.metrics.get", "namespace": namespace, "workload": workload }), - ); - } - }; - let extension = match registry::extension_for_release(&helm_release, catalog) { - Some(extension) => extension, - None => { - return tool_error( - "unsupported_metrics_workload", - "metrics are only supported for catalog-managed workloads", - json!({ - "namespace": namespace, - "workload": workload, - "chart": helm_release.chart, - }), - ); - } - }; - let metrics_collection = match extension.metrics_collection.as_ref() { - Some(metrics_collection) => metrics_collection, - None => { - return tool_error( - "unsupported_metrics_workload", - "this extension does not define metrics collection metadata", - json!({ - "namespace": namespace, - "workload": workload, - "extensionId": extension.id, - "chart": helm_release.chart, - }), - ); - } - }; - let pod_name = match find_workload_pod(&client, &namespace, &workload).await { - Ok(Some(pod_name)) => pod_name, - Ok(None) => { - return tool_error( - "not_found", - format!("no pod found for workload: {namespace}/{workload}"), - json!({ "namespace": namespace, "workload": workload }), - ); - } - Err(error) => return kube_error("workloads.metrics.get", error), - }; - let command = metrics_collection - .command - .iter() - .map(String::as_str) - .collect::>(); - let output = match client - .pod_exec_capture( - &namespace, - &pod_name, - &metrics_collection.container, - &command, - ) - .await - { - Ok(output) => output, - Err(error) => return pod_exec_error("workloads.metrics.get", error), - }; - let metrics = match serde_json::from_str::(output.stdout.trim()) { - Ok(metrics) => metrics, - Err(error) => { - return tool_error( - "invalid_metrics_payload", - format!("metrics script did not return valid JSON: {error}"), - json!({ - "namespace": namespace, - "workload": workload, - "pod": pod_name, - "container": metrics_collection.container, - }), - ); - } - }; - - success(json!({ - "namespace": namespace, - "workload": workload, - "pod": pod_name, - "container": metrics_collection.container, - "source": format!("pod-exec:{}", metrics_collection.command.join(" ")), - "extension": { - "id": extension.id, - "name": extension.name, - "version": extension.default_version, - }, - "helmRelease": helm_release, - "metrics": metrics, - "stderr": if output.stderr.trim().is_empty() { Value::Null } else { Value::String(output.stderr) }, - })) -} - fn tool_from_definition(definition: ToolDefinition) -> Tool { Tool::new( definition.name, @@ -722,58 +226,6 @@ fn tool_meta(definition: ToolDefinition) -> Meta { Meta(meta) } -async fn find_workload_pod( - client: &KubernetesClient, - namespace: &str, - workload: &str, -) -> Result, kube::Error> { - let mut pods = client - .list_pods( - Some(namespace), - &ResourceListParams { - label_selector: Some(format!("app.kubernetes.io/instance={workload}")), - ..Default::default() - }, - ) - .await? - .items; - - if pods.is_empty() - && let Some(pod) = get_optional(client.get_pod(namespace, workload).await)? - { - pods.push(pod); - } - - pods.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name)); - - let running_pod = pods - .iter() - .find(|pod| { - pod.status - .as_ref() - .and_then(|status| status.phase.as_deref()) - == Some("Running") - }) - .and_then(|pod| pod.metadata.name.clone()); - - if running_pod.is_some() { - return Ok(running_pod); - } - - Ok(pods - .iter() - .find(|pod| pod.metadata.deletion_timestamp.is_none()) - .and_then(|pod| pod.metadata.name.clone())) -} - -fn get_optional(result: Result) -> Result, kube::Error> { - match result { - Ok(value) => Ok(Some(value)), - Err(kube::Error::Api(error)) if error.code == 404 => Ok(None), - Err(error) => Err(error), - } -} - fn list_params(arguments: Option<&JsonObject>, default_limit: Option) -> ResourceListParams { ResourceListParams { label_selector: optional_string(arguments, "labelSelector"), @@ -782,289 +234,6 @@ fn list_params(arguments: Option<&JsonObject>, default_limit: Option) -> Re } } -fn required_object( - arguments: Option<&JsonObject>, - name: &str, -) -> Result { - match arguments.and_then(|arguments| arguments.get(name)) { - Some(Value::Object(value)) => Ok(value.clone()), - Some(value) => Err(tool_error( - "invalid_arguments", - format!("expected object argument: {name}"), - json!({ "argument": name, "actualType": value_type_name(value) }), - )), - None => Err(tool_error( - "invalid_arguments", - format!("missing required object argument: {name}"), - json!({ "argument": name }), - )), - } -} - -fn required_string(arguments: Option<&JsonObject>, name: &str) -> Result { - optional_string(arguments, name).ok_or_else(|| { - tool_error( - "invalid_arguments", - format!("missing required string argument: {name}"), - json!({ "argument": name }), - ) - }) -} - -fn validate_configuration_schema( - values: &JsonObject, - schema: &Value, -) -> Result<(), CallToolResult> { - validate_object_value("", values, schema, schema) -} - -fn validate_object_value( - path: &str, - values: &JsonObject, - schema: &Value, - root_schema: &Value, -) -> Result<(), CallToolResult> { - let schema = dereference_schema(schema, root_schema); - let schema = schema.as_object().ok_or_else(|| { - tool_error( - "catalog_schema_error", - "extension configuration schema must be an object schema", - json!({}), - ) - })?; - let properties = schema.get("properties").and_then(Value::as_object); - - let additional_properties = schema.get("additionalProperties"); - if additional_properties.and_then(Value::as_bool) == Some(false) { - for key in values.keys() { - if !properties.is_some_and(|properties| properties.contains_key(key)) { - let field = field_path(path, key); - return Err(tool_error( - "invalid_extension_configuration", - format!("unknown extension configuration value: {field}"), - json!({ "field": field }), - )); - } - } - } - - if let Some(required) = schema.get("required").and_then(Value::as_array) { - for field in required.iter().filter_map(Value::as_str) { - if !values.contains_key(field) { - let field = field_path(path, field); - return Err(tool_error( - "invalid_extension_configuration", - format!("missing required extension configuration value: {field}"), - json!({ "field": field }), - )); - } - } - } - - for (key, value) in values { - let field = field_path(path, key); - if let Some(property_schema) = properties.and_then(|properties| properties.get(key)) { - validate_property_value(&field, value, property_schema, root_schema)?; - } else if let Some(additional_schema) = additional_properties.and_then(Value::as_object) { - validate_property_value( - &field, - value, - &Value::Object(additional_schema.clone()), - root_schema, - )?; - } - } - - Ok(()) -} - -fn validate_property_value( - name: &str, - value: &Value, - schema: &Value, - root_schema: &Value, -) -> Result<(), CallToolResult> { - let schema = dereference_schema(schema, root_schema); - if let Some(expected_type) = schema.get("type") { - let matches = expected_type_matches(value, expected_type); - - if !matches { - return Err(tool_error( - "invalid_extension_configuration", - format!("invalid type for extension configuration value: {name}"), - json!({ - "field": name, - "expectedType": expected_type, - "actualType": value_type_name(value), - }), - )); - } - } - - if let Some(allowed_values) = schema.get("enum").and_then(Value::as_array) - && !allowed_values.iter().any(|allowed| allowed == value) - { - return Err(tool_error( - "invalid_extension_configuration", - format!("unsupported value for extension configuration field: {name}"), - json!({ - "field": name, - "allowedValues": allowed_values, - "actualValue": value, - }), - )); - } - - if let (Some(value), Some(min_length)) = ( - value.as_str(), - schema.get("minLength").and_then(Value::as_u64), - ) && value.chars().count() < min_length as usize - { - return Err(tool_error( - "invalid_extension_configuration", - format!("extension configuration value is too short: {name}"), - json!({ - "field": name, - "minLength": min_length, - }), - )); - } - - if let Some(values) = value.as_object() { - validate_object_value(name, values, schema, root_schema)?; - } else if let (Some(items), Some(item_schema)) = (value.as_array(), schema.get("items")) { - for (index, item) in items.iter().enumerate() { - validate_property_value(&format!("{name}[{index}]"), item, item_schema, root_schema)?; - } - } - - Ok(()) -} - -fn dereference_schema<'a>(schema: &'a Value, root_schema: &'a Value) -> &'a Value { - let Some(reference) = schema.get("$ref").and_then(Value::as_str) else { - return schema; - }; - let Some(name) = reference - .strip_prefix("#/definitions/") - .or_else(|| reference.strip_prefix("#/$defs/")) - else { - return schema; - }; - - root_schema - .get("definitions") - .or_else(|| root_schema.get("$defs")) - .and_then(|definitions| definitions.get(name)) - .unwrap_or(schema) -} - -fn expected_type_matches(value: &Value, expected_type: &Value) -> bool { - if let Some(expected_type) = expected_type.as_str() { - return single_type_matches(value, expected_type); - } - - expected_type.as_array().is_none_or(|types| { - types - .iter() - .filter_map(Value::as_str) - .any(|ty| single_type_matches(value, ty)) - }) -} - -fn single_type_matches(value: &Value, expected_type: &str) -> bool { - match expected_type { - "array" => value.is_array(), - "boolean" => value.is_boolean(), - "integer" => value.as_i64().is_some(), - "null" => value.is_null(), - "number" => value.as_f64().is_some(), - "object" => value.is_object(), - "string" => value.is_string(), - _ => true, - } -} - -fn field_path(parent: &str, child: &str) -> String { - if parent.is_empty() { - child.to_string() - } else { - format!("{parent}.{child}") - } -} - -fn value_type_name(value: &Value) -> &'static str { - match value { - Value::Null => "null", - Value::Bool(_) => "boolean", - Value::Number(_) => "number", - Value::String(_) => "string", - Value::Array(_) => "array", - Value::Object(_) => "object", - } -} - -fn runtime_vault_path(arguments: Option<&JsonObject>) -> Result { - let path = required_string(arguments, "path")?; - VaultPath::runtime(&path).map_err(|error| { - tool_error( - "vault_path_not_allowed", - error.to_string(), - json!({ "path": path }), - ) - }) -} - -fn secret_argument(arguments: Option<&JsonObject>) -> Result { - let value = arguments - .and_then(|arguments| arguments.get("values").or_else(|| arguments.get("data"))) - .cloned() - .ok_or_else(|| { - tool_error( - "invalid_arguments", - "missing required secret object argument: values", - json!({ "argument": "values" }), - ) - })?; - - SecretObject::new(value).map_err(|error| { - tool_error( - "invalid_secret_values", - error.to_string(), - json!({ "argument": "values" }), - ) - }) -} - -fn write_mode( - arguments: Option<&JsonObject>, - default_mode: WriteMode, -) -> Result { - match optional_string(arguments, "mode") { - Some(mode) => WriteMode::parse(Some(&mode)), - None => Ok(default_mode), - } -} - -fn optional_string(arguments: Option<&JsonObject>, name: &str) -> Option { - arguments? - .get(name)? - .as_str() - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) -} - -fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { - arguments?.get(name)?.as_bool() -} - -fn optional_u32(arguments: Option<&JsonObject>, name: &str) -> Option { - arguments? - .get(name)? - .as_u64() - .and_then(|value| u32::try_from(value).ok()) -} - impl Default for ToolRouter { fn default() -> Self { Self::new() @@ -1244,7 +413,7 @@ mod tests { config: None, }; - let summary = workload_summary(&release, &catalog); + let summary = workloads::list::workload_summary(&release, &catalog); assert_eq!(summary.pointer("/name"), Some(&json!("dolos-preview"))); assert_eq!( diff --git a/mcp-server/src/tools/schema_validation.rs b/mcp-server/src/tools/schema_validation.rs new file mode 100644 index 0000000..aed133e --- /dev/null +++ b/mcp-server/src/tools/schema_validation.rs @@ -0,0 +1,187 @@ +use rmcp::model::{CallToolResult, JsonObject}; +use serde_json::{Value, json}; + +use crate::tools::args::value_type_name; +use crate::tools::common::tool_error; + +pub(crate) fn validate_configuration_schema( + values: &JsonObject, + schema: &Value, +) -> Result<(), CallToolResult> { + validate_object_value("", values, schema, schema) +} + +fn validate_object_value( + path: &str, + values: &JsonObject, + schema: &Value, + root_schema: &Value, +) -> Result<(), CallToolResult> { + let schema = dereference_schema(schema, root_schema); + let schema = schema.as_object().ok_or_else(|| { + tool_error( + "catalog_schema_error", + "extension configuration schema must be an object schema", + json!({}), + ) + })?; + let properties = schema.get("properties").and_then(Value::as_object); + + let additional_properties = schema.get("additionalProperties"); + if additional_properties.and_then(Value::as_bool) == Some(false) { + for key in values.keys() { + if !properties.is_some_and(|properties| properties.contains_key(key)) { + let field = field_path(path, key); + return Err(tool_error( + "invalid_extension_configuration", + format!("unknown extension configuration value: {field}"), + json!({ "field": field }), + )); + } + } + } + + if let Some(required) = schema.get("required").and_then(Value::as_array) { + for field in required.iter().filter_map(Value::as_str) { + if !values.contains_key(field) { + let field = field_path(path, field); + return Err(tool_error( + "invalid_extension_configuration", + format!("missing required extension configuration value: {field}"), + json!({ "field": field }), + )); + } + } + } + + for (key, value) in values { + let field = field_path(path, key); + if let Some(property_schema) = properties.and_then(|properties| properties.get(key)) { + validate_property_value(&field, value, property_schema, root_schema)?; + } else if let Some(additional_schema) = additional_properties.and_then(Value::as_object) { + validate_property_value( + &field, + value, + &Value::Object(additional_schema.clone()), + root_schema, + )?; + } + } + + Ok(()) +} + +fn validate_property_value( + name: &str, + value: &Value, + schema: &Value, + root_schema: &Value, +) -> Result<(), CallToolResult> { + let schema = dereference_schema(schema, root_schema); + if let Some(expected_type) = schema.get("type") { + let matches = expected_type_matches(value, expected_type); + + if !matches { + return Err(tool_error( + "invalid_extension_configuration", + format!("invalid type for extension configuration value: {name}"), + json!({ + "field": name, + "expectedType": expected_type, + "actualType": value_type_name(value), + }), + )); + } + } + + if let Some(allowed_values) = schema.get("enum").and_then(Value::as_array) + && !allowed_values.iter().any(|allowed| allowed == value) + { + return Err(tool_error( + "invalid_extension_configuration", + format!("unsupported value for extension configuration field: {name}"), + json!({ + "field": name, + "allowedValues": allowed_values, + "actualValue": value, + }), + )); + } + + if let (Some(value), Some(min_length)) = ( + value.as_str(), + schema.get("minLength").and_then(Value::as_u64), + ) && value.chars().count() < min_length as usize + { + return Err(tool_error( + "invalid_extension_configuration", + format!("extension configuration value is too short: {name}"), + json!({ + "field": name, + "minLength": min_length, + }), + )); + } + + if let Some(values) = value.as_object() { + validate_object_value(name, values, schema, root_schema)?; + } else if let (Some(items), Some(item_schema)) = (value.as_array(), schema.get("items")) { + for (index, item) in items.iter().enumerate() { + validate_property_value(&format!("{name}[{index}]"), item, item_schema, root_schema)?; + } + } + + Ok(()) +} + +fn dereference_schema<'a>(schema: &'a Value, root_schema: &'a Value) -> &'a Value { + let Some(reference) = schema.get("$ref").and_then(Value::as_str) else { + return schema; + }; + let Some(name) = reference + .strip_prefix("#/definitions/") + .or_else(|| reference.strip_prefix("#/$defs/")) + else { + return schema; + }; + + root_schema + .get("definitions") + .or_else(|| root_schema.get("$defs")) + .and_then(|definitions| definitions.get(name)) + .unwrap_or(schema) +} + +fn expected_type_matches(value: &Value, expected_type: &Value) -> bool { + if let Some(expected_type) = expected_type.as_str() { + return single_type_matches(value, expected_type); + } + + expected_type.as_array().is_none_or(|types| { + types + .iter() + .filter_map(Value::as_str) + .any(|ty| single_type_matches(value, ty)) + }) +} + +fn single_type_matches(value: &Value, expected_type: &str) -> bool { + match expected_type { + "array" => value.is_array(), + "boolean" => value.is_boolean(), + "integer" => value.as_i64().is_some(), + "null" => value.is_null(), + "number" => value.as_f64().is_some(), + "object" => value.is_object(), + "string" => value.is_string(), + _ => true, + } +} + +fn field_path(parent: &str, child: &str) -> String { + if parent.is_empty() { + child.to_string() + } else { + format!("{parent}.{child}") + } +} diff --git a/mcp-server/src/tools/vault.rs b/mcp-server/src/tools/vault.rs index 8f9c1a9..a92c61c 100644 --- a/mcp-server/src/tools/vault.rs +++ b/mcp-server/src/tools/vault.rs @@ -1,7 +1,13 @@ +use rmcp::model::{CallToolResult, JsonObject}; +use serde_json::json; + use crate::policy::ApprovalClass; use crate::policy::Scope; +use crate::vault::{SecretObject, VaultClient, VaultError, VaultPath, WriteMode}; use super::ToolDefinition; +use super::args::{optional_string, required_string}; +use super::common::{success, tool_error, vault_error}; pub fn definitions() -> &'static [ToolDefinition] { &[ @@ -87,3 +93,99 @@ pub fn definitions() -> &'static [ToolDefinition] { }, ] } + +pub(crate) async fn runtime_metadata_get(arguments: Option<&JsonObject>) -> CallToolResult { + let path = match runtime_vault_path(arguments) { + Ok(path) => path, + Err(error) => return error, + }; + let client = match VaultClient::from_env() { + Ok(client) => client, + Err(error) => return vault_error("vault.runtime.metadata.get", error), + }; + + match client.runtime_metadata(&path).await { + Ok(metadata) => success(json!({ + "path": metadata.path, + "exists": metadata.exists, + "keyNames": metadata.key_names, + "keyNamesAvailable": metadata.key_names_available, + "currentVersion": metadata.current_version, + })), + Err(error) => vault_error("vault.runtime.metadata.get", error), + } +} + +pub(crate) async fn runtime_write( + arguments: Option<&JsonObject>, + default_mode: WriteMode, +) -> CallToolResult { + let path = match runtime_vault_path(arguments) { + Ok(path) => path, + Err(error) => return error, + }; + let secret = match secret_argument(arguments) { + Ok(secret) => secret, + Err(error) => return error, + }; + let mode = match write_mode(arguments, default_mode) { + Ok(mode) => mode, + Err(error) => return vault_error("vault.runtime.write", error), + }; + let client = match VaultClient::from_env() { + Ok(client) => client, + Err(error) => return vault_error("vault.runtime.write", error), + }; + + match client.write_runtime_secret(&path, &secret, mode).await { + Ok(receipt) => success(json!({ + "path": receipt.path, + "writtenKeys": receipt.written_keys, + "version": receipt.version, + "secretValuesReturned": false, + })), + Err(error) => vault_error("vault.runtime.write", error), + } +} + +fn runtime_vault_path(arguments: Option<&JsonObject>) -> Result { + let path = required_string(arguments, "path")?; + VaultPath::runtime(&path).map_err(|error| { + tool_error( + "vault_path_not_allowed", + error.to_string(), + json!({ "path": path }), + ) + }) +} + +fn secret_argument(arguments: Option<&JsonObject>) -> Result { + let value = arguments + .and_then(|arguments| arguments.get("values").or_else(|| arguments.get("data"))) + .cloned() + .ok_or_else(|| { + tool_error( + "invalid_arguments", + "missing required secret object argument: values", + json!({ "argument": "values" }), + ) + })?; + + SecretObject::new(value).map_err(|error| { + tool_error( + "invalid_secret_values", + error.to_string(), + json!({ "argument": "values" }), + ) + }) +} + +fn write_mode( + arguments: Option<&JsonObject>, + default_mode: WriteMode, +) -> Result { + match optional_string(arguments, "mode") { + Some(mode) => WriteMode::parse(Some(&mode)), + None => Ok(default_mode), + } +} diff --git a/mcp-server/src/tools/workloads/delete.rs b/mcp-server/src/tools/workloads/delete.rs index 3934cb6..b9d00ef 100644 --- a/mcp-server/src/tools/workloads/delete.rs +++ b/mcp-server/src/tools/workloads/delete.rs @@ -8,6 +8,7 @@ use serde_json::{Value, json}; use crate::catalog::ExtensionCatalog; use crate::helm::{self, HelmUninstallPlan}; use crate::k8s::{HelmReleaseDiscovery, KubernetesClient, ResourceListParams}; +use crate::tools::args::{optional_bool, required_string}; use crate::tools::common::{kube_error, success, tool_error}; use super::registry; @@ -244,27 +245,6 @@ fn pvc_name(pvc: &PersistentVolumeClaim) -> Option<&str> { pvc.metadata.name.as_deref() } -fn required_string(arguments: Option<&JsonObject>, name: &str) -> Result { - arguments - .and_then(|arguments| arguments.get(name)) - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - .ok_or_else(|| { - tool_error( - "invalid_arguments", - format!("missing required string argument: {name}"), - json!({ "argument": name }), - ) - }) -} - -fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { - arguments - .and_then(|arguments| arguments.get(name)) - .and_then(Value::as_bool) -} - #[cfg(test)] mod tests { use k8s_openapi::api::apps::v1::StatefulSetSpec; diff --git a/mcp-server/src/tools/workloads/dolos.rs b/mcp-server/src/tools/workloads/dolos.rs index 6b38488..2158d1a 100644 --- a/mcp-server/src/tools/workloads/dolos.rs +++ b/mcp-server/src/tools/workloads/dolos.rs @@ -13,6 +13,7 @@ use crate::k8s::ResourceListParams; use crate::policy::ApprovalClass; use crate::policy::Scope; use crate::tools::ToolDefinition; +use crate::tools::args::{optional_bool, required_string}; use crate::tools::common::kube_error; use crate::tools::common::success; use crate::tools::common::tool_error; @@ -31,7 +32,7 @@ pub(crate) fn definitions() -> &'static [ToolDefinition] { approval_class: ApprovalClass::Destructive, read_only: false, destructive: true, - input_schema: r#"{"type":"object","required":["namespace","releaseName"],"properties":{"namespace":{"type":"string"},"releaseName":{"type":"string"},"dryRun":{"type":"boolean"},"approvalId":{"type":"string"}},"additionalProperties":false}"#, + input_schema: r#"{"type":"object","required":["namespace","releaseName"],"properties":{"namespace":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63},"releaseName":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63},"dryRun":{"type":"boolean"},"approvalId":{"type":"string"}},"additionalProperties":false}"#, }] } @@ -303,27 +304,6 @@ fn pvc_summary(pvc: &PersistentVolumeClaim) -> Value { }) } -fn required_string(arguments: Option<&JsonObject>, name: &str) -> Result { - arguments - .and_then(|arguments| arguments.get(name)) - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - .ok_or_else(|| { - tool_error( - "invalid_arguments", - format!("missing required string argument: {name}"), - json!({ "argument": name }), - ) - }) -} - -fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { - arguments - .and_then(|arguments| arguments.get(name)) - .and_then(Value::as_bool) -} - #[cfg(test)] mod tests { use k8s_openapi::api::core::v1::PersistentVolumeClaim; diff --git a/mcp-server/src/tools/workloads/get.rs b/mcp-server/src/tools/workloads/get.rs new file mode 100644 index 0000000..e307ce2 --- /dev/null +++ b/mcp-server/src/tools/workloads/get.rs @@ -0,0 +1,124 @@ +use rmcp::model::{CallToolResult, JsonObject}; +use serde_json::json; + +use crate::catalog::ExtensionCatalog; +use crate::k8s::{HelmReleaseDiscovery, KubernetesClient, ResourceListParams}; +use crate::tools::args::required_string; +use crate::tools::common::{kube_error, success, tool_error}; +use crate::tools::{k8s_summaries, workloads::outputs}; + +pub(crate) async fn get( + arguments: Option<&JsonObject>, + catalog: &ExtensionCatalog, +) -> CallToolResult { + let namespace = match required_string(arguments, "namespace") { + Ok(value) => value, + Err(error) => return error, + }; + let name = match required_string(arguments, "name") { + Ok(value) => value, + Err(error) => return error, + }; + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error("workloads.get", error), + }; + let helm_release = match HelmReleaseDiscovery::new(client.clone()) + .get_latest(&namespace, &name) + .await + { + Ok(release) => release, + Err(error) => { + return tool_error( + "helm_release_discovery_error", + error.to_string(), + json!({ "tool": "workloads.get", "namespace": namespace, "name": name }), + ); + } + }; + + let deployment = match get_optional(client.get_deployment(&namespace, &name).await) { + Ok(value) => value.map(|deployment| k8s_summaries::deployment_summary(&deployment)), + Err(error) => return kube_error("workloads.get", error), + }; + let stateful_set = match get_optional(client.get_stateful_set(&namespace, &name).await) { + Ok(value) => value.map(|stateful_set| k8s_summaries::stateful_set_summary(&stateful_set)), + Err(error) => return kube_error("workloads.get", error), + }; + let pod = match get_optional(client.get_pod(&namespace, &name).await) { + Ok(value) => value.map(|pod| k8s_summaries::pod_summary(&pod)), + Err(error) => return kube_error("workloads.get", error), + }; + let services = match client + .list_services( + Some(&namespace), + &ResourceListParams { + label_selector: Some(format!("app.kubernetes.io/instance={name}")), + ..Default::default() + }, + ) + .await + { + Ok(items) => items, + Err(error) => return kube_error("workloads.get", error), + }; + let pod_items = match client + .list_pods( + Some(&namespace), + &ResourceListParams { + label_selector: Some(format!("app.kubernetes.io/instance={name}")), + ..Default::default() + }, + ) + .await + { + Ok(items) => items.items, + Err(error) => return kube_error("workloads.get", error), + }; + let pods = pod_items + .iter() + .map(k8s_summaries::pod_summary) + .collect::>(); + + if deployment.is_none() + && stateful_set.is_none() + && pod.is_none() + && helm_release.is_none() + && pods.is_empty() + && services.items.is_empty() + { + return tool_error( + "not_found", + format!("workload not found: {namespace}/{name}"), + json!({ "namespace": namespace, "name": name }), + ); + } + + success(json!({ + "namespace": namespace, + "name": name, + "source": "kubernetes-api+helm-secrets", + "helmRelease": helm_release, + "deployment": deployment, + "statefulSet": stateful_set, + "pod": pod, + "relatedPods": pods, + "logTargets": k8s_summaries::pod_log_target_summaries(&pod_items), + "relatedServices": k8s_summaries::service_summaries(services.clone(), true), + "outputs": outputs::outputs_for_release( + &namespace, + &name, + helm_release.as_ref(), + &services.items, + catalog, + ), + })) +} + +fn get_optional(result: Result) -> Result, kube::Error> { + match result { + Ok(value) => Ok(Some(value)), + Err(kube::Error::Api(error)) if error.code == 404 => Ok(None), + Err(error) => Err(error), + } +} diff --git a/mcp-server/src/tools/workloads/install.rs b/mcp-server/src/tools/workloads/install.rs index 17b9441..6f5e9b8 100644 --- a/mcp-server/src/tools/workloads/install.rs +++ b/mcp-server/src/tools/workloads/install.rs @@ -1,16 +1,167 @@ use k8s_openapi::api::storage::v1::StorageClass; -use rmcp::model::CallToolResult; +use rmcp::model::{CallToolResult, JsonObject}; use serde_json::Value; use serde_json::json; -use crate::catalog::ExtensionDefinition; +use crate::catalog::{ExtensionCatalog, ExtensionDefinition}; +use crate::helm::{self, HelmChartRef, HelmInstallPlan}; use crate::k8s::KubernetesClient; use crate::k8s::ResourceListParams; use super::registry; +use crate::tools::args::{optional_bool, required_object, required_string}; use crate::tools::common::kube_error; +use crate::tools::common::success; use crate::tools::common::tool_error; use crate::tools::k8s_summaries::storage_class_summary; +use crate::tools::schema_validation::validate_configuration_schema; + +const TOOL_NAME: &str = "workloads.install"; + +pub(crate) async fn install( + arguments: Option<&JsonObject>, + catalog: &ExtensionCatalog, +) -> CallToolResult { + let extension_id = match required_string(arguments, "extensionId") { + Ok(value) => value, + Err(error) => return error, + }; + let release_name = match required_string(arguments, "releaseName") { + Ok(value) => value, + Err(error) => return error, + }; + let namespace = match required_string(arguments, "namespace") { + Ok(value) => value, + Err(error) => return error, + }; + let dry_run = optional_bool(arguments, "dryRun").unwrap_or(true); + + let configuration = match required_object(arguments, "configuration") { + Ok(value) => value, + Err(error) => return error, + }; + let extension = match catalog.get(&extension_id) { + Some(extension) => extension, + None => { + return tool_error( + "unknown_extension", + format!("extension not found: {extension_id}"), + json!({ "extensionId": extension_id }), + ); + } + }; + + if let Err(error) = validate_configuration_schema(&configuration, &extension.configuration) { + return error; + } + + if !uses_direct_helm_values(extension) + && configuration.get("namespace").and_then(Value::as_str) != Some(namespace.as_str()) + { + return tool_error( + "invalid_arguments", + "configuration.namespace must match namespace", + json!({ "namespace": namespace, "configurationNamespace": configuration.get("namespace") }), + ); + } + + let resolved_configuration = apply_defaults(extension, Value::Object(configuration)); + let resolution = + match resolve_configuration(extension, &namespace, resolved_configuration, dry_run).await { + Ok(resolution) => resolution, + Err(error) => return error, + }; + let helm_values = planned_helm_values(extension, &release_name, &resolution.configuration); + let chart = HelmChartRef { + chart: extension.chart.clone(), + version: extension.default_version.clone(), + }; + + if dry_run { + return success(json!({ + "action": "install", + "dryRun": true, + "wouldMutate": false, + "release": { + "name": release_name, + "namespace": namespace, + }, + "extension": { + "id": extension.id, + "name": extension.name, + "version": extension.default_version, + }, + "chart": chart, + "resolvedConfiguration": resolution.configuration, + "helmValues": helm_values, + "availableStorageClasses": resolution.available_storage_classes, + "recommendedStorageClasses": resolution.recommended_storage_classes, + "notes": [ + "dry-run planning only; no Kubernetes or Helm mutation was performed", + "raw Helm values are rejected by the extension configuration schema" + ], + })); + } + + let plan = HelmInstallPlan { + release_name: release_name.clone(), + namespace: namespace.clone(), + chart: chart.clone(), + values: helm_values.clone(), + }; + let helm_result = match helm::install(&plan).await { + Ok(result) => result, + Err(error) => { + let helm_details = match &error { + helm::HelmInstallError::Failed { + status, + stdout, + stderr, + } => json!({ + "tool": TOOL_NAME, + "extensionId": extension.id, + "releaseName": release_name, + "namespace": namespace, + "status": status, + "stdout": stdout, + "stderr": stderr, + }), + _ => json!({ + "tool": TOOL_NAME, + "extensionId": extension.id, + "releaseName": release_name, + "namespace": namespace, + }), + }; + return tool_error("helm_install_failed", error.to_string(), helm_details); + } + }; + + success(json!({ + "action": "install", + "dryRun": false, + "wouldMutate": true, + "release": { + "name": release_name, + "namespace": namespace, + }, + "extension": { + "id": extension.id, + "name": extension.name, + "version": extension.default_version, + }, + "chart": chart, + "resolvedConfiguration": resolution.configuration, + "helmValues": helm_values, + "availableStorageClasses": resolution.available_storage_classes, + "recommendedStorageClasses": resolution.recommended_storage_classes, + "helm": helm_result, + "notes": [ + "Helm upgrade --install completed successfully", + "raw Helm values are rejected by the extension configuration schema" + ], + })) +} #[derive(Debug, Clone)] pub(crate) struct InstallResolution { @@ -157,7 +308,7 @@ pub(crate) fn planned_helm_values( Value::Object(helm_values) } -fn uses_direct_helm_values(extension: &ExtensionDefinition) -> bool { +pub(crate) fn uses_direct_helm_values(extension: &ExtensionDefinition) -> bool { matches!( extension.id.as_str(), registry::DOLOS_EXTENSION_ID diff --git a/mcp-server/src/tools/workloads/list.rs b/mcp-server/src/tools/workloads/list.rs new file mode 100644 index 0000000..3afc18b --- /dev/null +++ b/mcp-server/src/tools/workloads/list.rs @@ -0,0 +1,116 @@ +use rmcp::model::{CallToolResult, JsonObject}; +use serde_json::{Value, json}; + +use crate::catalog::{ExtensionCatalog, extension_summary}; +use crate::k8s::{HelmReleaseDiscovery, KubernetesClient, ResourceListParams}; +use crate::tools::args::{optional_bool, optional_string}; +use crate::tools::common::{kube_error, success, tool_error}; + +use super::registry; + +pub(crate) async fn list( + arguments: Option<&JsonObject>, + catalog: &ExtensionCatalog, +) -> CallToolResult { + let namespace = optional_string(arguments, "namespace"); + let include_control_plane = optional_bool(arguments, "includeControlPlane").unwrap_or(false); + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error("workloads.list", error), + }; + let helm_releases = match HelmReleaseDiscovery::new(client.clone()) + .list_latest(namespace.as_deref(), include_control_plane) + .await + { + Ok(releases) => releases, + Err(error) => { + return tool_error( + "helm_release_discovery_error", + error.to_string(), + json!({ "tool": "workloads.list" }), + ); + } + }; + + let workloads = helm_releases + .iter() + .map(|release| workload_summary(release, catalog)) + .collect::>(); + + success(json!({ + "namespace": namespace, + "source": "kubernetes-api+helm-secrets", + "workloads": workloads, + })) +} + +pub(crate) fn workload_summary( + release: &crate::k8s::HelmReleaseSummary, + catalog: &ExtensionCatalog, +) -> Value { + let catalog_extension = + registry::extension_for_release(release, catalog).map(extension_summary); + + json!({ + "namespace": release.namespace, + "name": release.name, + "status": release.status, + "revision": release.revision, + "chart": release.chart, + "appVersion": release.app_version, + "updated": release.updated, + "catalogExtension": catalog_extension, + }) +} + +fn get_optional(result: Result) -> Result, kube::Error> { + match result { + Ok(value) => Ok(Some(value)), + Err(kube::Error::Api(error)) if error.code == 404 => Ok(None), + Err(error) => Err(error), + } +} + +pub(crate) async fn find_workload_pod( + client: &KubernetesClient, + namespace: &str, + workload: &str, +) -> Result, kube::Error> { + let mut pods = client + .list_pods( + Some(namespace), + &ResourceListParams { + label_selector: Some(format!("app.kubernetes.io/instance={workload}")), + ..Default::default() + }, + ) + .await? + .items; + + if pods.is_empty() + && let Some(pod) = get_optional(client.get_pod(namespace, workload).await)? + { + pods.push(pod); + } + + pods.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name)); + + let running_pod = pods + .iter() + .find(|pod| { + pod.status + .as_ref() + .and_then(|status| status.phase.as_deref()) + == Some("Running") + }) + .and_then(|pod| pod.metadata.name.clone()); + + if running_pod.is_some() { + return Ok(running_pod); + } + + Ok(pods + .iter() + .find(|pod| pod.metadata.deletion_timestamp.is_none()) + .and_then(|pod| pod.metadata.name.clone())) +} diff --git a/mcp-server/src/tools/workloads/logs.rs b/mcp-server/src/tools/workloads/logs.rs index 4c53997..706d651 100644 --- a/mcp-server/src/tools/workloads/logs.rs +++ b/mcp-server/src/tools/workloads/logs.rs @@ -6,6 +6,7 @@ use serde_json::json; use crate::k8s::KubernetesClient; use crate::k8s::PodLogParams; use crate::k8s::ResourceListParams; +use crate::tools::args::{optional_bool, optional_i64, optional_string, required_string}; use crate::tools::common::kube_error; use crate::tools::common::success; use crate::tools::common::tool_error; @@ -250,32 +251,6 @@ fn get_optional(result: Result) -> Result, kube::Er } } -fn required_string(arguments: Option<&JsonObject>, name: &str) -> Result { - optional_string(arguments, name).ok_or_else(|| { - tool_error( - "invalid_arguments", - format!("missing required string argument: {name}"), - json!({ "argument": name }), - ) - }) -} - -fn optional_string(arguments: Option<&JsonObject>, name: &str) -> Option { - arguments? - .get(name)? - .as_str() - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) -} - -fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { - arguments?.get(name)?.as_bool() -} - -fn optional_i64(arguments: Option<&JsonObject>, name: &str) -> Option { - arguments?.get(name)?.as_i64() -} - #[cfg(test)] mod tests { use k8s_openapi::api::core::v1::Container; diff --git a/mcp-server/src/tools/workloads/metrics.rs b/mcp-server/src/tools/workloads/metrics.rs new file mode 100644 index 0000000..468be37 --- /dev/null +++ b/mcp-server/src/tools/workloads/metrics.rs @@ -0,0 +1,135 @@ +use rmcp::model::{CallToolResult, JsonObject}; +use serde_json::{Value, json}; + +use crate::catalog::ExtensionCatalog; +use crate::k8s::{HelmReleaseDiscovery, KubernetesClient}; +use crate::tools::args::required_string; +use crate::tools::common::{kube_error, pod_exec_error, success, tool_error}; + +use super::{list::find_workload_pod, registry}; + +pub(crate) async fn get( + arguments: Option<&JsonObject>, + catalog: &ExtensionCatalog, +) -> CallToolResult { + let namespace = match required_string(arguments, "namespace") { + Ok(value) => value, + Err(error) => return error, + }; + let workload = match required_string(arguments, "workload") { + Ok(value) => value, + Err(error) => return error, + }; + let client = match KubernetesClient::try_default().await { + Ok(client) => client, + Err(error) => return kube_error("workloads.metrics.get", error), + }; + let helm_release = match HelmReleaseDiscovery::new(client.clone()) + .get_latest(&namespace, &workload) + .await + { + Ok(Some(release)) => release, + Ok(None) => { + return tool_error( + "not_found", + format!("workload not found: {namespace}/{workload}"), + json!({ "namespace": namespace, "workload": workload }), + ); + } + Err(error) => { + return tool_error( + "helm_release_discovery_error", + error.to_string(), + json!({ "tool": "workloads.metrics.get", "namespace": namespace, "workload": workload }), + ); + } + }; + let extension = match registry::extension_for_release(&helm_release, catalog) { + Some(extension) => extension, + None => { + return tool_error( + "unsupported_metrics_workload", + "metrics are only supported for catalog-managed workloads", + json!({ + "namespace": namespace, + "workload": workload, + "chart": helm_release.chart, + }), + ); + } + }; + let metrics_collection = match extension.metrics_collection.as_ref() { + Some(metrics_collection) => metrics_collection, + None => { + return tool_error( + "unsupported_metrics_workload", + "this extension does not define metrics collection metadata", + json!({ + "namespace": namespace, + "workload": workload, + "extensionId": extension.id, + "chart": helm_release.chart, + }), + ); + } + }; + let pod_name = match find_workload_pod(&client, &namespace, &workload).await { + Ok(Some(pod_name)) => pod_name, + Ok(None) => { + return tool_error( + "not_found", + format!("no pod found for workload: {namespace}/{workload}"), + json!({ "namespace": namespace, "workload": workload }), + ); + } + Err(error) => return kube_error("workloads.metrics.get", error), + }; + let command = metrics_collection + .command + .iter() + .map(String::as_str) + .collect::>(); + let output = match client + .pod_exec_capture( + &namespace, + &pod_name, + &metrics_collection.container, + &command, + ) + .await + { + Ok(output) => output, + Err(error) => return pod_exec_error("workloads.metrics.get", error), + }; + let metrics = match serde_json::from_str::(output.stdout.trim()) { + Ok(metrics) => metrics, + Err(error) => { + return tool_error( + "invalid_metrics_payload", + format!("metrics script did not return valid JSON: {error}"), + json!({ + "namespace": namespace, + "workload": workload, + "pod": pod_name, + "container": metrics_collection.container, + }), + ); + } + }; + + success(json!({ + "namespace": namespace, + "workload": workload, + "pod": pod_name, + "container": metrics_collection.container, + "source": format!("pod-exec:{}", metrics_collection.command.join(" ")), + "extension": { + "id": extension.id, + "name": extension.name, + "version": extension.default_version, + }, + "helmRelease": helm_release, + "metrics": metrics, + "stderr": if output.stderr.trim().is_empty() { Value::Null } else { Value::String(output.stderr) }, + })) +} diff --git a/mcp-server/src/tools/workloads/mod.rs b/mcp-server/src/tools/workloads/mod.rs index 4d07660..357a71a 100644 --- a/mcp-server/src/tools/workloads/mod.rs +++ b/mcp-server/src/tools/workloads/mod.rs @@ -6,8 +6,11 @@ use std::collections::BTreeSet; pub(crate) mod delete; pub(crate) mod dolos; +pub(crate) mod get; pub(crate) mod install; +pub(crate) mod list; pub(crate) mod logs; +pub(crate) mod metrics; pub(crate) mod outputs; pub(crate) mod registry; pub(crate) mod upgrade; @@ -32,7 +35,7 @@ pub fn definitions() -> &'static [ToolDefinition] { approval_class: ApprovalClass::Discovery, read_only: true, destructive: false, - input_schema: r#"{"type":"object","required":["namespace","name"],"properties":{"namespace":{"type":"string"},"name":{"type":"string"}},"additionalProperties":false}"#, + input_schema: r#"{"type":"object","required":["namespace","name"],"properties":{"namespace":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63},"name":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63}},"additionalProperties":false}"#, }, ToolDefinition { name: "workloads.logs.get", @@ -42,7 +45,7 @@ pub fn definitions() -> &'static [ToolDefinition] { approval_class: ApprovalClass::ReadOnlyDebug, read_only: true, destructive: false, - input_schema: r#"{"type":"object","required":["namespace","workload"],"properties":{"namespace":{"type":"string"},"workload":{"type":"string"},"pod":{"type":"string","description":"Exact pod name to read. Required when the workload has multiple active pods."},"container":{"type":"string","description":"Exact container or init-container name to read. Required when the selected pod has multiple loggable containers."},"tailLines":{"type":"integer","minimum":1,"maximum":1000},"sinceSeconds":{"type":"integer","minimum":1},"previous":{"type":"boolean"},"timestamps":{"type":"boolean"}},"additionalProperties":false}"#, + input_schema: r#"{"type":"object","required":["namespace","workload"],"properties":{"namespace":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63},"workload":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63},"pod":{"type":"string","description":"Exact pod name to read. Required when the workload has multiple active pods."},"container":{"type":"string","description":"Exact container or init-container name to read. Required when the selected pod has multiple loggable containers."},"tailLines":{"type":"integer","minimum":1,"maximum":1000},"sinceSeconds":{"type":"integer","minimum":1},"previous":{"type":"boolean"},"timestamps":{"type":"boolean"}},"additionalProperties":false}"#, }, ToolDefinition { name: "workloads.metrics.get", @@ -52,7 +55,7 @@ pub fn definitions() -> &'static [ToolDefinition] { approval_class: ApprovalClass::ReadOnlyDebug, read_only: true, destructive: false, - input_schema: r#"{"type":"object","required":["namespace","workload"],"properties":{"namespace":{"type":"string"},"workload":{"type":"string"}},"additionalProperties":false}"#, + input_schema: r#"{"type":"object","required":["namespace","workload"],"properties":{"namespace":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63},"workload":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63}},"additionalProperties":false}"#, }, ToolDefinition { name: "workloads.install", @@ -62,7 +65,7 @@ pub fn definitions() -> &'static [ToolDefinition] { approval_class: ApprovalClass::Mutation, read_only: false, destructive: false, - input_schema: r#"{"type":"object","required":["extensionId","releaseName","namespace","configuration"],"properties":{"extensionId":{"type":"string","description":"Catalog extension ID to install, for example dolos, cardano-relay, cardano-block-producer, apex-fusion-relay, apex-fusion-block-producer, or hydra-node."},"releaseName":{"type":"string","description":"Helm release name to create or update."},"namespace":{"type":"string","description":"Kubernetes namespace where the workload will be installed."},"configuration":{"type":"object","description":"Required extension-specific configuration object. Use extensions.catalog.get for the selected extensionId and pass values matching that extension configuration schema.","additionalProperties":true},"dryRun":{"type":"boolean","description":"When true, validate and return the install plan without mutating Kubernetes. Defaults to true."}},"additionalProperties":false}"#, + input_schema: r#"{"type":"object","required":["extensionId","releaseName","namespace","configuration"],"properties":{"extensionId":{"type":"string","description":"Catalog extension ID to install, for example dolos, cardano-relay, cardano-block-producer, apex-fusion-relay, apex-fusion-block-producer, or hydra-node."},"releaseName":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63,"description":"Helm release name to create or update."},"namespace":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63,"description":"Kubernetes namespace where the workload will be installed."},"configuration":{"type":"object","description":"Required extension-specific configuration object. Use extensions.catalog.get for the selected extensionId and pass values matching that extension configuration schema.","additionalProperties":true},"dryRun":{"type":"boolean","description":"When true, validate and return the install plan without mutating Kubernetes. Defaults to true."}},"additionalProperties":false}"#, }, ToolDefinition { name: "workloads.upgrade", @@ -72,7 +75,7 @@ pub fn definitions() -> &'static [ToolDefinition] { approval_class: ApprovalClass::Mutation, read_only: false, destructive: false, - input_schema: r#"{"type":"object","required":["namespace","releaseName","configuration"],"properties":{"namespace":{"type":"string"},"releaseName":{"type":"string"},"configuration":{"type":"object","description":"Required extension-specific configuration object. Use extensions.catalog.get for the installed extension and pass values matching that extension configuration schema.","additionalProperties":true},"dryRun":{"type":"boolean","description":"When true, validate and return the upgrade plan without mutating Kubernetes."}},"additionalProperties":false}"#, + input_schema: r#"{"type":"object","required":["namespace","releaseName","configuration"],"properties":{"namespace":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63},"releaseName":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63},"configuration":{"type":"object","description":"Required extension-specific configuration object. Use extensions.catalog.get for the installed extension and pass values matching that extension configuration schema.","additionalProperties":true},"dryRun":{"type":"boolean","description":"When true, validate and return the upgrade plan without mutating Kubernetes."}},"additionalProperties":false}"#, }, ToolDefinition { name: "workloads.delete", @@ -82,7 +85,7 @@ pub fn definitions() -> &'static [ToolDefinition] { approval_class: ApprovalClass::Destructive, read_only: false, destructive: true, - input_schema: r#"{"type":"object","required":["namespace","releaseName"],"properties":{"namespace":{"type":"string"},"releaseName":{"type":"string"},"dryRun":{"type":"boolean","description":"When true, validate and return the delete plan without mutating Kubernetes. Defaults to true."},"deletePvcs":{"type":"boolean","description":"Delete candidate PVCs after Helm uninstall. Defaults to false."},"approvalId":{"type":"string"}},"additionalProperties":false}"#, + input_schema: r#"{"type":"object","required":["namespace","releaseName"],"properties":{"namespace":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63},"releaseName":{"type":"string","pattern":"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$","maxLength":63},"dryRun":{"type":"boolean","description":"When true, validate and return the delete plan without mutating Kubernetes. Defaults to true."},"deletePvcs":{"type":"boolean","description":"Delete candidate PVCs after Helm uninstall. Defaults to false."},"approvalId":{"type":"string"}},"additionalProperties":false}"#, }, ] } diff --git a/mcp-server/src/tools/workloads/upgrade.rs b/mcp-server/src/tools/workloads/upgrade.rs index a99d956..07fb9d4 100644 --- a/mcp-server/src/tools/workloads/upgrade.rs +++ b/mcp-server/src/tools/workloads/upgrade.rs @@ -4,7 +4,9 @@ use serde_json::{Value, json}; use crate::catalog::ExtensionCatalog; use crate::helm::{self, HelmChartRef, HelmUpgradePlan}; use crate::k8s::{HelmReleaseDiscovery, KubernetesClient}; +use crate::tools::args::{optional_bool, required_object, required_string}; use crate::tools::common::{kube_error, success, tool_error}; +use crate::tools::schema_validation::validate_configuration_schema; use super::{install, registry}; @@ -70,15 +72,8 @@ pub(crate) async fn upgrade( if let Err(error) = validate_configuration_schema(&configuration, &extension.configuration) { return error; } - if !matches!( - extension.id.as_str(), - registry::DOLOS_EXTENSION_ID - | registry::CARDANO_RELAY_EXTENSION_ID - | registry::CARDANO_BLOCK_PRODUCER_EXTENSION_ID - | registry::APEX_FUSION_RELAY_EXTENSION_ID - | registry::APEX_FUSION_BLOCK_PRODUCER_EXTENSION_ID - | registry::HYDRA_NODE_EXTENSION_ID - ) && configuration.get("namespace").and_then(Value::as_str) != Some(namespace.as_str()) + if !install::uses_direct_helm_values(extension) + && configuration.get("namespace").and_then(Value::as_str) != Some(namespace.as_str()) { return tool_error( "invalid_arguments", @@ -196,239 +191,6 @@ pub(crate) async fn upgrade( })) } -fn validate_configuration_schema( - values: &JsonObject, - schema: &Value, -) -> Result<(), CallToolResult> { - validate_object_value("", values, schema, schema) -} - -fn validate_object_value( - path: &str, - values: &JsonObject, - schema: &Value, - root_schema: &Value, -) -> Result<(), CallToolResult> { - let schema = dereference_schema(schema, root_schema); - let schema = schema.as_object().ok_or_else(|| { - tool_error( - "catalog_schema_error", - "extension configuration schema must be an object schema", - json!({}), - ) - })?; - let properties = schema.get("properties").and_then(Value::as_object); - - let additional_properties = schema.get("additionalProperties"); - if additional_properties.and_then(Value::as_bool) == Some(false) { - for key in values.keys() { - if !properties.is_some_and(|properties| properties.contains_key(key)) { - let field = field_path(path, key); - return Err(tool_error( - "invalid_extension_configuration", - format!("unknown extension configuration value: {field}"), - json!({ "field": field }), - )); - } - } - } - - if let Some(required) = schema.get("required").and_then(Value::as_array) { - for field in required.iter().filter_map(Value::as_str) { - if !values.contains_key(field) { - let field = field_path(path, field); - return Err(tool_error( - "invalid_extension_configuration", - format!("missing required extension configuration value: {field}"), - json!({ "field": field }), - )); - } - } - } - - for (key, value) in values { - let field = field_path(path, key); - if let Some(property_schema) = properties.and_then(|properties| properties.get(key)) { - validate_property_value(&field, value, property_schema, root_schema)?; - } else if let Some(additional_schema) = additional_properties.and_then(Value::as_object) { - validate_property_value( - &field, - value, - &Value::Object(additional_schema.clone()), - root_schema, - )?; - } - } - - Ok(()) -} - -fn validate_property_value( - name: &str, - value: &Value, - schema: &Value, - root_schema: &Value, -) -> Result<(), CallToolResult> { - let schema = dereference_schema(schema, root_schema); - if let Some(expected_type) = schema.get("type") { - let matches = expected_type_matches(value, expected_type); - - if !matches { - return Err(tool_error( - "invalid_extension_configuration", - format!("invalid type for extension configuration value: {name}"), - json!({ - "field": name, - "expectedType": expected_type, - "actualType": value_type_name(value), - }), - )); - } - } - - if let Some(allowed_values) = schema.get("enum").and_then(Value::as_array) - && !allowed_values.iter().any(|allowed| allowed == value) - { - return Err(tool_error( - "invalid_extension_configuration", - format!("unsupported value for extension configuration field: {name}"), - json!({ - "field": name, - "allowedValues": allowed_values, - "actualValue": value, - }), - )); - } - - if let (Some(value), Some(min_length)) = ( - value.as_str(), - schema.get("minLength").and_then(Value::as_u64), - ) && value.chars().count() < min_length as usize - { - return Err(tool_error( - "invalid_extension_configuration", - format!("extension configuration value is too short: {name}"), - json!({ - "field": name, - "minLength": min_length, - }), - )); - } - - if let Some(values) = value.as_object() { - validate_object_value(name, values, schema, root_schema)?; - } else if let (Some(items), Some(item_schema)) = (value.as_array(), schema.get("items")) { - for (index, item) in items.iter().enumerate() { - validate_property_value(&format!("{name}[{index}]"), item, item_schema, root_schema)?; - } - } - - Ok(()) -} - -fn dereference_schema<'a>(schema: &'a Value, root_schema: &'a Value) -> &'a Value { - let Some(reference) = schema.get("$ref").and_then(Value::as_str) else { - return schema; - }; - let Some(name) = reference - .strip_prefix("#/definitions/") - .or_else(|| reference.strip_prefix("#/$defs/")) - else { - return schema; - }; - - root_schema - .get("definitions") - .or_else(|| root_schema.get("$defs")) - .and_then(|definitions| definitions.get(name)) - .unwrap_or(schema) -} - -fn expected_type_matches(value: &Value, expected_type: &Value) -> bool { - if let Some(expected_type) = expected_type.as_str() { - return single_type_matches(value, expected_type); - } - - expected_type.as_array().is_none_or(|types| { - types - .iter() - .filter_map(Value::as_str) - .any(|ty| single_type_matches(value, ty)) - }) -} - -fn single_type_matches(value: &Value, expected_type: &str) -> bool { - match expected_type { - "array" => value.is_array(), - "boolean" => value.is_boolean(), - "integer" => value.as_i64().is_some(), - "null" => value.is_null(), - "number" => value.as_f64().is_some(), - "object" => value.is_object(), - "string" => value.is_string(), - _ => true, - } -} - -fn field_path(parent: &str, child: &str) -> String { - if parent.is_empty() { - child.to_string() - } else { - format!("{parent}.{child}") - } -} - -fn value_type_name(value: &Value) -> &'static str { - match value { - Value::Null => "null", - Value::Bool(_) => "boolean", - Value::Number(_) => "number", - Value::String(_) => "string", - Value::Array(_) => "array", - Value::Object(_) => "object", - } -} - -fn required_object( - arguments: Option<&JsonObject>, - name: &str, -) -> Result { - match arguments.and_then(|arguments| arguments.get(name)) { - Some(Value::Object(value)) => Ok(value.clone()), - Some(value) => Err(tool_error( - "invalid_arguments", - format!("expected object argument: {name}"), - json!({ "argument": name, "actualType": value_type_name(value) }), - )), - None => Err(tool_error( - "invalid_arguments", - format!("missing required object argument: {name}"), - json!({ "argument": name }), - )), - } -} - -fn required_string(arguments: Option<&JsonObject>, name: &str) -> Result { - arguments - .and_then(|arguments| arguments.get(name)) - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - .ok_or_else(|| { - tool_error( - "invalid_arguments", - format!("missing required string argument: {name}"), - json!({ "argument": name }), - ) - }) -} - -fn optional_bool(arguments: Option<&JsonObject>, name: &str) -> Option { - arguments - .and_then(|arguments| arguments.get(name)) - .and_then(Value::as_bool) -} - #[cfg(test)] mod tests { use crate::policy::{ApprovalClass, Scope}; From c498f4013d065330630813e418e7e184065376f8 Mon Sep 17 00:00:00 2001 From: Felipe Gonzalez Date: Fri, 22 May 2026 13:00:43 -0300 Subject: [PATCH 4/4] fixes from review --- mcp-server/src/oci_client.rs | 1 + mcp-server/src/tools/args.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/mcp-server/src/oci_client.rs b/mcp-server/src/oci_client.rs index 94e8363..d506adb 100644 --- a/mcp-server/src/oci_client.rs +++ b/mcp-server/src/oci_client.rs @@ -24,6 +24,7 @@ pub(crate) async fn fetch_artifact_json( let client = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) .build()?; let manifest = fetch_manifest(&client, &reference).await?; let descriptor = select_artifact_descriptor(&manifest, layer_media_type) diff --git a/mcp-server/src/tools/args.rs b/mcp-server/src/tools/args.rs index ec5158b..3688f14 100644 --- a/mcp-server/src/tools/args.rs +++ b/mcp-server/src/tools/args.rs @@ -39,7 +39,8 @@ pub(crate) fn optional_string(arguments: Option<&JsonObject>, name: &str) -> Opt arguments? .get(name)? .as_str() - .filter(|value| !value.trim().is_empty()) + .map(str::trim) + .filter(|value| !value.is_empty()) .map(str::to_string) }