Inside Dockyard: How Stacklok + Cisco AI Defense are securing MCP servers and skills

Most enterprises adopting AI agents hit the same wall: the MCP server or skill they need doesn’t come as a container. Instead, it’s an npm package, a PyPI module, or a folder of Markdown files, and it’s unscanned, unsigned, and carrying unknown risk. Stacklok built Dockyard to close that gap.

Here’s what you’ll learn in this post:

  • Why the current MCP server distribution landscape creates real security risk for enterprises
  • How Dockyard automatically repackages npm, PyPI, and Go-based MCP servers into hardened, signed containers
  • Why Cisco’s mcp-scanner and skill-scanner run on every server before it’s published
  • How you can verify security attestations end-to-end as a downstream consumer

The problem: most MCP servers aren’t production-ready

You want to use Upstash’s Context7 MCP server. You go looking for a container image and it’s not there. The package lives on npm as @upstash/context7-mcp, and the README tells you to invoke it as npx -y @upstash/context7-mcp. The Supabase MCP server? PyPI run it through uvx. The Cloudflare Agents SDK skill you wanted to install? It’s some Markdown and shell snippets in a GitHub repo folder.

None of these are containers. None of them have signatures, software bills of materials (SBOMs), or provenance. None of them have been scanned for the risks that matter most for MCP servers: prompt injection in tool descriptions, toxic flows between tools, and the patterns that can get your agent to exfiltrate your customer database when it thought it was just answering a docs lookup.

The honest answer most teams land on: ship it anyway and hope. We thought we could help, so we built Dockyard.

What’s already there: toolhive-catalog

Before Dockyard Stacklok’s toolhive-catalog is a curated registry of MCP servers that are already containerized, plus a list of remote MCP endpoints. People open PRs that drop a server.json into registries/toolhive/servers/<name>/ pointing at ghcr.io/foo/bar:1.2.3 or https://mcp.canva.com/mcp. Maintainers review, the catalog gets republished daily as both a Go module and downloadable JSON, and there’s a periodic job that refreshes GitHub stars and pull counts on the oldest entries.

That works great when the upstream ships a container, or when they run a hosted endpoint. But most of npm and PyPI don’t. The catalog is an index of stuff that already exists, and most MCP servers don’t exist as containers yet. Same story for Skills, where the unit of distribution is a folder with a SKILL.md plus assets (there isn’t even a notion of “container” in the spec… yet).

So we needed something different for the long tail.

Enter Dockyard

Dockyard is a repackaging pipeline. Drop a spec.yaml in the right directory, send a PR, and CI does the rest: scans, builds, signs, attests, and publishes a container or OCI artifact to ghcr.io/stacklok/dockyard/.

The mental model is three protocol tracks plus skills:

TrackSourceOutput
npx://npm packageMulti-arch container
uvx://PyPI packageMulti-arch container
go://Go moduleMulti-arch container
skills/Git repo + pathOCI artifact (ORAS)

Each spec.yaml is small enough to fit on screen. Here’s the one for context7:

metadata:
  name: context7
  description: "Upstash Context7 MCP server for vector search and context management"
  protocol: npx

spec:
  package: "@upstash/context7-mcp"
  version: "2.2.0"

provenance:
  repository_uri: "https://github.com/upstash/context7"
  repository_ref: "refs/tags/v1.0.17"

security:
  mock_env:
    - name: CONTEXT7_API_KEY
      value: "mock-context7-api-key-for-scanning"
      description: "Context7 API key - mock value for security scanning"
  allowed_issues:
    - code: "AITech-1.1"
      reason: |
        YARA flags tool descriptions for coercive language patterns like
        "You MUST call" and "Do not call this tool more than 3 times".
        These are legitimate operational instructions for rate-limiting and
        proper API usage workflow, not prompt injection. LLM analyzer
        confirms SAFE.

That’s enough information for our dockhand Go binary to generate a multi-stage Dockerfile, build it for linux/amd64 and linux/arm64 with docker buildx, boot the resulting image up under the Cisco AI Defense mcp-scanner, and if it passes… sign and publish.

Dockyard doesn’t reinvent the Dockerfile. The whole “treat npm/PyPI/Go as protocol schemes that get rendered into multi-stage Dockerfiles” idea comes from ToolHive itself. When you thv run npx://@upstash/context7-mcp locally, ToolHive generates an equivalent Dockerfile, builds it, and runs it for you. Dockyard takes the same approach and wraps it in a hardened CI pipeline that adds SBOM, signing, attestation, and the scanners we’ll get to in a moment.

For skills the build target is a bit different. A skill isn’t runnable code, it’s a bundle of Markdown and resources that an agent loads to learn how to do something. To make them portable, we package skills as OCI artifacts. The dockhand build-skill command pulls the source from the upstream Git ref, runs through toolhive-core‘s skill packager (deterministic tar.gz with PAX headers and pinned timestamps, multi-platform OCI index, dev.toolhive.skills.v1 artifact type) and pushes via ORAS to ghcr.io/stacklok/dockyard/skills/<name>:<version>. Same registry, same OCI plumbing, same signing path… different artifact type. Clients distinguish by ArtifactType and the skill-specific labels we write into the OCI image config.

OK, so far this is just packaging. The interesting part is what happens before we sign anything.

Three scanners between you and a sketchy MCP server

This is the part we care about most. Three scanners gate every release: two from Cisco AI Defense, plus the classic CVE scanner Grype from Anchore. They look for very different things, and they all have to pass.

Of those three, Cisco’s mcp-scanner and skill-scanner deserve real time on stage. They are non-trivial pieces of security engineering, and the whole security story in this post leans on the quality of their work. These are not regex packs over tool descriptions. They are full security tools with their own threat taxonomies, their own LLM-driven analyzers, and active research behind them. mcp-scanner is figuring out how you safely interrogate a live MCP server to discover what its tools really do, and how dangerous they could be in combination. skill-scanner is tackling a problem that didn’t really exist a year ago: doing meaningful security analysis on something whose “code” is mostly natural-language Markdown.

The Cisco AI Defense team open-sourced both of these tools and has been a generous collaborator as we wired them into our pipeline. Big thanks. If you build anything in the AI-agent space, those repos are worth watching closely.

OK, on to the scanners themselves.

Scanner 1: mcp-scanner against the live server

For MCP servers, the scanner we run is cisco-ai-mcp-scanner. It is not a static analyzer. It actually starts the MCP server, does the MCP initialize handshake, enumerates the tools, and then classifies each one against an internal taxonomy of risks, including:

  • Prompt injection in tool descriptions (“ignore previous instructions and…”)
  • Toxic flows: combinations of tools that together can do bad things (read-private + write-public = data exfiltration)
  • Tool poisoning: tool descriptions that subtly redirect agent behavior
  • Cross-origin escalation: tools that escalate between trust boundaries
  • Rug pull patterns: tool descriptions that change after the server is installed

Each finding has a severity. Our pipeline blocks on HIGH or above (set via MCP_SCANNER_BLOCK_SEVERITY). Findings below that surface as warnings in the PR comment but do not fail CI.

Booting the server is the tricky part. Most MCP servers expect environment variables: API keys, tokens, that kind of thing. We don’t want to ship real credentials to a CI scanner, and we don’t want the scanner to silently scan a half-broken server that crashed on startup. So the spec.yaml has a mock_env block:

security:
  mock_env:
    - name: CONTEXT7_API_KEY
      value: "mock-context7-api-key-for-scanning"

The mock value is shaped enough to pass the server’s startup validation but doesn’t grant access to anything real. The scanner gets to enumerate tools and inspect descriptions; nothing actually calls Upstash’s API.

There’s also an optional LLM analyzer mode (MCP_SCANNER_ENABLE_LLM). When enabled, the scanner uses an LLM to do deeper semantic classification of tool descriptions, so it catches things a static regex can’t, at the cost of API spend. We have it gated on a repo variable so we can flip it on for the PRs that need it.

Scanner 2: skill-scanner against the skill source

Skills are different beasts. The “code” of a skill is mostly natural language, so static analysis doesn’t get you very far. The cisco-ai-skill-scanner leans hard on the LLM analyzer to detect prompt injection, instructions to exfiltrate data, and license issues.

By default we run it with the LLM enabled (SKILL_SCANNER_USE_LLM=true) and the same provider key we use for mcp-scanner (one secret across both pipelines, one rotation). There’s an optional consensus mode (SKILL_SCANNER_LLM_CONSENSUS_RUNS) where you run the analyzer N times and require agreement, which trades cost for false-positive resistance. We use the same blocking severity threshold (HIGH) as we do for MCP servers.

The allowlist pattern is identical to the mcp-scanner. If the scanner trips on something we know is fine, you justify it in the spec:

security:
  allowed_issues:
    - rule_id: MANIFEST_MISSING_LICENSE
      reason: "trailofbits/skills is licensed CC-BY-SA-4.0 at the repository root; upstream does not embed a license field in per-skill SKILL.md frontmatter."

The reason field matters. We don’t want the allowlist to become the place where suppressed findings go to die without a trace. Every entry has a written justification, and PR review puts a human in the loop on it. Yes, you can game it. No, that’s not the point.

Scanner 3: Grype on the built image

After the MCP scanner passes and the container is built, we run Grype on the resulting image. SARIF results go to GitHub Security so they’re searchable across the org and visible in the Security tab. We block on HIGH severity, --only-fixed (because there’s no point breaking a build on a CVE that has no fix yet, the author can’t do anything about it).

The Grype config is where we make a deliberate developer-experience tradeoff. From .grype.yaml:

ignore:
  # Base image: Debian OS packages
  - package:
      type: deb
  # Base image: Alpine OS packages
  - package:
      type: apk
  # Build-time only: bundled npm CLI in node base image
  - package:
      location: "/usr/local/lib/node_modules/npm/**"
  # Base image binaries
  - package:
      location: "/usr/local/bin/python*"
  - package:
      location: "/usr/local/bin/node"

The reasoning here is important. A CVE in a .deb package shipped by upstream Debian isn’t something we can fix in Dockyard. The fix path is a base-image rebuild, which happens on the next ToolHive base bump. Same with the bundled npm CLI; it gets used at image build time to install the MCP package, and is never invoked from the running server. Suppressing those is honest.

What we do not suppress is anything under /app/node_modules or /opt/uv-tools. Those are the actual runtime dependencies of the MCP server you’re running. Findings there reflect real risk from the upstream package itself, and they should fail the build. The fix path is the upstream bumping their dependency.

We’re striking a balance; don’t block developers with issues they can’t fix, but avoid suppressing real risk. The .grype.yaml philosophy is: ignore findings whose fix path isn’t in the PR author’s hands; surface everything else.

Beyond CI, Grype runs weekly against every published image (the periodic-security-scan.yml workflow). New CRITICALs auto-file a GitHub issue tagged security,grype,critical. So even after a container ships, we keep watching.

What lands in the registry

Assuming all three scanners pass, the build job produces:

  • A multi-arch container at ghcr.io/stacklok/dockyard/<protocol>/<name>:<version> (or the OCI skill artifact)
  • A Sigstore/Cosign keyless signature, identity-bound to the build workflow via GitHub OIDC
  • An SBOM attestation in SPDX format (actions/attest-sbom for skills, baked in via docker buildx for containers)
  • A SLSA build provenance attestation (actions/attest-build-provenance)
  • A SCAI security-scan attestation, predicate type https://in-toto.io/attestation/scai/v0.3, recording the scanner version, the configuration, and whether the scan passed

That last one is worth pausing on. SCAI is an in-toto attestation predicate for “Supply Chain Attribute Integrity” claims, and it lets us record the result of the security scan itself as a verifiable attestation. So if you’re running a Kyverno policy that says “only admit images that have a passing mcp-scanner result attested by Stacklok’s CI workflow”, you can. The scan isn’t just a CI gate, it’s a piece of metadata that travels with the image forever.

To verify everything as a downstream user looks like this:

# Pull
docker pull ghcr.io/stacklok/dockyard/npx/context7:2.1.0

# Verify Sigstore signature
cosign verify \
  --certificate-identity-regexp \
    "https://github.com/stacklok/dockyard/.github/workflows/build-containers.yml@refs/heads/.*" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/stacklok/dockyard/npx/context7:2.1.0

# Verify the security scan attestation
cosign verify-attestation \
  --type https://in-toto.io/attestation/scai/v0.3 \
  --certificate-identity-regexp \
    "https://github.com/stacklok/dockyard/.github/workflows/build-containers.yml@refs/heads/.*" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/stacklok/dockyard/npx/context7:2.1.0

And that’s it! You have a containerized MCP server, packaged from npm, scanned for behavioral and CVE issues, signed by an OIDC-bound CI workflow, with full provenance.

Something to consider…

The scanners are not perfect. The mcp-scanner taxonomy will keep evolving as MCP attack patterns evolve. The skill-scanner is LLM-driven and inherits the LLM’s failure modes… false negatives on subtly-phrased malicious instructions are entirely possible. We use consensus runs to mitigate. We’re not done.

Allowlisting requires human judgment. A reason field doesn’t auto-validate itself. Reviewers have to push back on weak justifications. Look at the context7 AITech-1.1 allowlist for an honest example: YARA’s static rules flag “You MUST call this tool” in a tool description as a coercive prompt-injection pattern, but in context7’s case it’s a legitimate rate-limit instruction, and the LLM analyzer separately confirmed SAFE. Without the human-written reason (and a reviewer who actually reads it), that suppression is just noise. With it, you have a story you can defend.

Package provenance verification is informational right now. We run npm provenance and PEP 740 attestation checks on every build, but a missing or invalid provenance only generates a warning. We will flip this to blocking once enough of the npm and PyPI ecosystems have caught up. If you maintain an MCP server, please ship it with provenance.

npx / uvx / go doesn’t cover everything. Some MCP servers ship as streamable-http or sse remote endpoints. Those aren’t repackagable; they live on someone else’s infrastructure. Those go in toolhive-catalog as remote-server entries and don’t get the same security guarantees. There’s not much we can do about that other than be transparent.

What’s next

The whole pipeline is open in stacklok/dockyard. Spec files for around 25 npx servers, 20-some uvx servers, plus a growing pile of skills. PRs are welcome if you want your favorite MCP server packaged.

One more sincere thank-you to the Cisco AI Defense team for mcp-scanner and skill-scanner. The pipeline plumbing in this post is the easy part. The scanners are the work, and we’ve been impressed by both the engineering and the responsiveness as we’ve integrated them. We’re enthusiastic users, and we’d love to see more people kicking the tires on these tools. Same warm thanks to Anchore for Grype, and to Sigstore for the signing and attestation primitives that make all of this verifiable end-to-end.

We have a few things teed up next: blocking on package provenance, scanner coverage for SSE/streamable-http remotes, richer SCAI attestations that include per-finding dispositions. Stay tuned!

Get scanning, and happy hacking!


Want to see what Stacklok can do for your organization? Book a demo or get started right away with ToolHive, our open source project. Join the conversation and engage directly with our team on Discord.

May 06, 2026

Last modified on May 05, 2026

Integrations

Juan Antonio Osorio

Principal Engineer

Ozz is a Principal Engineer at Stacklok based in Helsinki, Finland, and the founder of the ToolHive project, an open-source platform designed to make deploying MCP servers easy and secure.

More by Juan Antonio Osorio