Mcp.SkillLint
0.2.0
dotnet tool install --global Mcp.SkillLint --version 0.2.0
dotnet new tool-manifest
dotnet tool install --local Mcp.SkillLint --version 0.2.0
#tool dotnet:?package=Mcp.SkillLint&version=0.2.0
nuke :add-package Mcp.SkillLint --version 0.2.0
mcp-tooling
π Language: English | Π ΡΡΡΠΊΠΈΠΉ
Shared tooling for our .NET Model Context Protocol servers.
Mcp.ToolsDoc β tool reference generator
A config-driven .NET tool that generates a Markdown tool reference for an MCP server
from its [McpServerToolType] / [McpServerTool] / [Description] attributes (Roslyn,
syntax-only β no build, runs in <1s), and a --check mode that fails CI when the committed
doc drifts from the code. Reusable across every .NET ModelContextProtocol MCP server.
Install (per consuming repo)
Add it to a local tool manifest:
dotnet new tool-manifest # if you don't have .config/dotnet-tools.json yet
dotnet tool install Mcp.ToolsDoc
Configure β toolsdoc.json at the repo root
{
// One section per MCP server. toolsDir is repo-relative.
"servers": [
{
"id": "my-mcp",
"displayName": "my-mcp",
"toolsDir": "src/MyMcp.Server/Tools",
"blurb": "Example MCP server."
}
],
"generatedOutput": "docs/TOOLS.generated.md",
// optional: keep N markers in sync
// Convention: include each plugin's SKILL.md here so its headline tool-count
// stays accurate. = sum across all servers;
// = a single server's count.
"markerFiles": [
"README.md",
"docs/INSTALL.md",
"plugins/<plugin>/skills/<skill>/SKILL.md"
],
// optional: a hand-curated cheatsheet that must mention every tool by name
"cheatsheet": "docs/TOOLS.md"
}
Only servers is required. markerFiles and cheatsheet are opt-in. The
cross-repo convention is that every plugin SKILL.md whose body mentions a
tool count is listed in markerFiles, so its headline stays accurate
automatically and the --check CI gate fails on drift. SKILL.md files
hand-maintain agent-trigger keywords and per-tool intent tables; the tool-count
headline is the one piece that can be auto-substituted, and now is.
Run
dotnet tool run mcp-toolsdoc # --write (default): (re)generate docs in place
dotnet tool run mcp-toolsdoc --check # CI: exit non-zero if anything is out of sync
Options: --config <path> (default <repo-root>/toolsdoc.json), --repo-root <path>
(default: the git root found from the current directory).
CI integration
# .github/workflows/docs-codegen.yml
on: { push: { branches: [main] }, pull_request: { branches: [main] } }
jobs:
toolsdoc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- run: dotnet tool restore
- run: dotnet tool run mcp-toolsdoc --check
The generated TOOLS.generated.md is English-only; if your repo enforces a bilingual-docs
gate, list it in that gate's ignore file.
Mcp.I18nCheck β bilingual-docs gate
A config-less .NET tool that enforces our bilingual-docs convention in CI: English is canonical; every English doc must have its Russian counterpart and the Russian file must be non-stub (β₯ 200 bytes).
docs/<path>.mdβdocs/ru/<path>.md(mirror subtree).X.mdβX.ru.md(suffix) for the repo root,plugins/*,examples/**,servers/*,infra/.
Exemptions: a repo-root .i18nignore (English-only / generated / agent files, one
repo-relative path per line). Pairs outside the conventional locations: .i18npairs
(en:ru per line).
dotnet tool install Mcp.I18nCheck
dotnet tool run mcp-i18ncheck # exit non-zero if any pair is missing/stub
# .github/workflows/docs-i18n.yml
on: { push: { branches: [main] }, pull_request: { branches: [main] } }
jobs:
bilingual-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- run: dotnet tool restore
- run: dotnet tool run mcp-i18ncheck
Mcp.LinkCheck β markdown link integrity gate
A config-less .NET tool that validates every [text](path) and [text](path#anchor)
link in the repo's .md files against the live filesystem and GitHub-flavored heading
slugs. Catches stale relative paths, mis-typed anchors after a heading rename, broken
cross-repo references via https://github.com/<owner>/<repo>/blob/main/... URLs.
- Internal paths:
[..](docs/INSTALL.md)β asserts the file exists relative to the containing markdown's directory. - Anchors:
[..](#Π±ΡΡΡΡΡΠΉ-ΡΡΠ°ΡΡ)and[..](docs/X.md#section)β slugs target headings with the GitHub algorithm (text.downcase.gsub(/[^\p{Word}\- ]/u, '').tr(' ', '-')). HTML<a name>/id=anchors are also recognised. - Same-repo GitHub URLs: auto-detected from
git remote get-url origin(configurable). - External
http(s)://: skipped by default. Opt-in vialinkcheck.json:checkExternalLinks=true.
Optional linkcheck.json at the repo root:
{
// Glob patterns of .md files to skip. bin/, obj/, node_modules/, .git/, and
// docs/TOOLS.generated.md are skipped automatically.
"excludePaths": ["docs/historical/**/*.md"],
"checkExternalLinks": false,
// Anchor IDs to accept even when no heading matches β for legacy HTML anchors that
// don't slugify cleanly. Use sparingly.
"allowedAnchors": ["legacy-id"]
}
dotnet tool install Mcp.LinkCheck
dotnet tool run mcp-linkcheck # write-mode: lists broken links + summary
dotnet tool run mcp-linkcheck --check # CI: exit non-zero on any broken link
# .github/workflows/docs-links.yml β thin caller of the reusable workflow
on: { push: { branches: [main] }, pull_request: { branches: [main] } }
jobs:
linkcheck:
uses: Platonenkov/mcp-tooling/.github/workflows/docs-links.yml@main
Mcp.FleetLint β cross-repo consistency gate
A .NET tool that validates each MCP repo against the canonical fleet inventory
(fleet-lint.json at this repo's root). Catches the class of typos and
config drift that local tests cannot see β they cross repo boundaries.
The inventory is the single source of truth for: each MCP's OAuth scope, the canonical
https://<host>/mcp hostname, the OAuth callback port number used by its Claude Code
plugin, and the authorization-server hostname. Downstream consumers fetch this file at
CI time via https://raw.githubusercontent.com/Platonenkov/mcp-tooling/main/fleet-lint.json.
Per-repo checks (each MCP's own consistency vs. the inventory):
- Hostname consistency β every
*.staticbit.iostring in committed files must match the repo's own canonical host, the AS host, or another MCP in the fleet. Anything else is flagged with a Levenshtein-based "did you mean?" suggestion. - callbackPort β Claude Code plugin
.mcp.jsonmanifests'oauth.callbackPortmust match the canonical port for the repo's MCP. - OAuth scope β
appsettings*.jsonOAuth.RequiredScopemust match the inventory. - AS hostname typos β any
auth.*reference within edit distance 3 of the canonical AS hostname but not exactly equal to it is flagged. Third-partyauth.example.comreferences are left alone.
Repos NOT in the inventory (mcp-tooling, mcp-auth, or any third-party consumer) get
a clean pass: every check is a no-op.
dotnet tool install Mcp.FleetLint
dotnet tool run mcp-fleetlint # write-mode: lists issues + summary
dotnet tool run mcp-fleetlint --check # CI: exit non-zero on any issue
# .github/workflows/fleet-lint.yml β thin caller of the reusable workflow
on: { push: { branches: [main] }, pull_request: { branches: [main] } }
jobs:
fleetlint:
uses: Platonenkov/mcp-tooling/.github/workflows/fleet-lint.yml@main
Mcp.SkillLint β cross-plugin SKILL.md trigger overlap gate
A .NET tool that walks plugins/*/skills/*/SKILL.md in the calling repo, extracts the
quoted trigger phrases from the YAML-frontmatter description field, and flags overlaps
across plugins. Catches the class of bug where the Claude Code plugin loader picks the wrong
plugin (or both) for a query because two plugins in the same repo β typically a cloud-vs-local
pair like xrpl-cloud / xrpl-local, telegram-bot / telegram-user,
x-mcp-cloud / x-mcp-local β declare the same natural-language trigger.
Checks:
- Conflicts (errors) β identical trigger keyword (case-insensitive, whitespace-collapsed) in two or more plugins' SKILL.md, unless explicitly whitelisted.
- Near-overlaps (warnings) β Levenshtein distance β€ 2 between triggers from different
plugins (default
nearOverlapMinLength= 5 chars). Surfaces typo-level duplicates and near-misses where the author probably meant to share. Warnings never fail CI.
Repos with no plugins/*/skills/*/SKILL.md (e.g. mcp-tooling itself, third-party consumers)
pass clean β every check is a no-op.
Optional skilllint.json at the repo root:
{
// Trigger keywords intentionally shared across two or more plugins. Suppresses the
// conflict error when the trigger appears exactly in the listed plugins (and only there).
"sharedTriggers": [
{ "trigger": "telegram", "plugins": ["telegram-bot", "telegram-user"] }
],
// Plugin directory names to skip entirely. Use for archived plugins.
"excludePlugins": [],
// Max Levenshtein distance for near-overlap warnings (default 2).
"nearOverlapDistance": 2,
// Min trigger length to even consider for near-overlap (default 5; shorter is noise).
"nearOverlapMinLength": 5
}
dotnet tool install Mcp.SkillLint
dotnet tool run mcp-skilllint # write-mode: lists conflicts + warnings + summary
dotnet tool run mcp-skilllint --check # CI: exit non-zero on any unwhitelisted conflict
# .github/workflows/skill-lint.yml β thin caller of the reusable workflow
on: { push: { branches: [main] }, pull_request: { branches: [main] } }
jobs:
skilllint:
uses: Platonenkov/mcp-tooling/.github/workflows/skill-lint.yml@main
Mcp.InjectionGuard β Roslyn prompt-injection defence gate
A .NET tool that statically scans every [McpServerTool] method in the calling repo
and asserts that user-generated content (HTTP bodies, JSON from third-party APIs, tool
output) is wrapped through UntrustedContent.Wrap(...) or UntrustedContent.WrapJson(...)
before being returned. Pairs with the UntrustedContent helper that ships in the
Mcp.Auth.ResourceServer SDK β the gate is syntax-only and pattern-matches the call
regardless of where the helper lives, so it works during the rollout window when not every
repo has wired the SDK yet.
Classification rules (in order):
- Opt-in β
[ExternalContent("origin-hint")]on the method β must wrap. - Opt-out β
[NotExternalContent]on the method β exempt (e.g. status / config tools). - Per-method exemption β
injectionguard.json:exempt: ["Method", "Type.Method"]. - Heuristic (when no attribute fires) β conservative; method-name prefix
(
Get|Read|Search|List|Find|Fetch|ResolveplusextraNamePrefixes) AND non-scalar return type, OR return type itself is a typical external carrier (string/JObject/JArray/IReadOnlyList<...>/object), OR body invokes a known external API fragment (*.GetAsync,*.SendRequestAsync,*.ExecuteAsync,*.InvokeAsync,*.QueryAsync, plusextraInvocationFragments). Tools that returnTask<bool>/Task<int>/ other scalars are never classified as external.
Per-return audit accepts: throw, null, constant literals, returns inside a catch
block, returns rooted in a method parameter. Everything else must syntactically descend
into a wrap call β directly, through a wrapped local, a wrapped object initializer, a
wrapped ternary, or a wrapped null-coalesce.
Repos with no src/**/Tools/*.cs (e.g. mcp-tooling itself, third-party consumers)
pass clean β every check is a no-op.
Optional injectionguard.json at the repo root:
{
// Glob patterns relative to the repo root. Default: src/**/Tools/*.cs.
"include": ["src/**/Tools/*.cs", "servers/*/src/**/Tools/*.cs"],
// Per-method exemption list. Matches either the simple method name or Type.Method.
"exempt": ["GetStatus", "AuthTool.WhoAmI"],
// Extra reader-style method-name prefixes (built-ins always honoured).
"extraNamePrefixes": ["Dump", "Export"],
// Extra invocation-expression substrings that classify the method as external.
"extraInvocationFragments": ["ResolveSecretAsync"]
}
dotnet tool install Mcp.InjectionGuard
dotnet tool run mcp-injectionguard # write-mode: lists findings + summary
dotnet tool run mcp-injectionguard --check # CI: exit non-zero on any unwrapped return
# .github/workflows/injection-guard.yml β thin caller of the reusable workflow
on: { push: { branches: [main] }, pull_request: { branches: [main] } }
jobs:
injectionguard:
uses: Platonenkov/mcp-tooling/.github/workflows/injection-guard.yml@main
security-audit β zizmor + actionlint Actions-security gate
A tool-less reusable workflow that statically audits a repo's own .github/workflows/ for the
pwn-request / Actions-injection / supply-chain class (GitHub Security Lab's "untrusted input"
family): unpinned third-party actions, github.event.* template injection, dangerous triggers,
over-broad permissions. Runs zizmor (--min-severity high,
purpose-built for this class) plus actionlint.
The canonical policy is .github/zizmor.yml, copied verbatim into every repo (same model as
fleet-lint.json): GitHub-owned (actions/*, github/*) and our own reusables
(Platonenkov/mcp-tooling/*) may pin to a tag/ref; every third-party action must pin to a full
commit SHA. shellcheck is intentionally disabled (actionlint -shellcheck=) β it flags
pre-existing shell-style nits in run: blocks that are out of scope for an Actions-security gate.
Third-party actions are SHA-pinned and kept current by a github-actions Dependabot config.
# .github/workflows/security-audit.yml β thin caller of the reusable workflow
on:
workflow_dispatch:
pull_request: { paths: ['.github/workflows/**', '.github/zizmor.yml'] }
push: { branches: [main], paths: ['.github/workflows/**', '.github/zizmor.yml'] }
permissions: { contents: read }
jobs:
audit:
permissions: { contents: read }
uses: Platonenkov/mcp-tooling/.github/workflows/security-audit.yml@main
Reusable CI/CD workflows
Two reusable GitHub Actions workflows
let every consuming repo share one definition of how images are built, published, and deployed β
callers stay thin and never drift apart. They live under .github/workflows/ here and are
referenced with uses: <owner>/mcp-tooling/.github/workflows/<file>@main.
docker-build-push.yml β build + push ghcr.io images
Builds one or more multi-arch images from a JSON image matrix and pushes each as
ghcr.io/<owner-lowercase>/<image_suffix> with :<version> and :latest.
# .github/workflows/docker.yml in the consuming repo
on:
push: { tags: ['v*.*.*'] }
workflow_dispatch:
inputs:
version: { description: 'Version without leading v', required: true, type: string }
jobs:
build-push:
permissions: { contents: read, packages: write }
uses: <owner>/mcp-tooling/.github/workflows/docker-build-push.yml@main
with:
version: ${{ inputs.version != '' && inputs.version || github.ref_name }}
images: |
[
{ "name": "my-mcp", "image_suffix": "my-mcp",
"dockerfile": "./Dockerfile", "context": ".", "cache_scope": "my-mcp" }
]
# use_gh_packages_secret: true # when the Dockerfile restores a private GH Packages feed
# secrets:
# gh_packages_token: ${{ secrets.GITHUB_TOKEN }}
| input | meaning |
|---|---|
version |
image tag; a leading v is stripped; :latest is also pushed |
images |
JSON array of {name, image_suffix, dockerfile, context, cache_scope} (one entry per image) |
platforms |
buildx platforms (default linux/amd64,linux/arm64) |
use_gh_packages_secret |
mount github_token as a build-secret for private NuGet restore |
deploy-vps.yml β ship an image to a VPS over SSH
Ships a published image to a host without any registry login on the host: the runner pulls
the image, docker save | ssh streams the tarball into a forced-command deploy.sh, the tag
arrives as the SSH command (re-validated server-side), then the runner smoke-tests a healthz URL.
# .github/workflows/deploy.yml in the consuming repo
on:
workflow_dispatch:
inputs:
tag: { description: 'Image tag (semver or latest)', required: true, type: string, default: latest }
jobs:
deploy:
uses: <owner>/mcp-tooling/.github/workflows/deploy-vps.yml@main
with:
image_suffix: my-mcp
tag: ${{ inputs.tag }}
healthz_url: https://my-mcp.example.com/healthz
secrets:
deploy_ssh_key: ${{ secrets.DEPLOY_SSH_KEY }}
deploy_host: ${{ secrets.DEPLOY_HOST }}
deploy_user: ${{ secrets.DEPLOY_USER }}
deploy_known_hosts: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
Host prerequisite (per service): a CI deploy key locked to the forced command in the host's
~/.ssh/authorized_keys, so the key can only run the deploy script and nothing else:
command="/opt/<name>/deploy.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA... ci-deploy
deploy.sh validates the tag, docker loads the piped tarball, pins it in the compose .env,
recreates the container, and waits for the healthcheck. A reference script is kept in each
consuming repo under deploy/deploy.sh.
Releasing
Each tool's version lives in its csproj (src/Mcp.ToolsDoc, src/Mcp.I18nCheck,
src/Mcp.LinkCheck, src/Mcp.FleetLint, src/Mcp.SkillLint, src/Mcp.InjectionGuard).
Pushing a v X.Y.Z tag packs all and publishes them to nuget.org via .github/workflows/publish.yml
(--skip-duplicate, so unchanged versions are no-ops; requires the repo secret
NUGET_API_KEY). Bump the relevant csproj <Version> before tagging.
License: MIT.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.