MCP ScopeCheck
Inspect before you connect. MCP ScopeCheck is a pre-install static auditor that compares a Python MCP tool's declared contract with security-relevant behavior reachable from its source—without importing or running the server.
Quick start
Python 3.11 or newer is required; CI tests 3.11, 3.12, and 3.13. Install the dependency-free scanner from PyPI:
python -m pip install mcp-scopecheck
For an isolated CLI installation, pipx is also supported:
pipx install mcp-scopecheck
Audit a local Python file or directory:
mcp-scopecheck audit path/to/server.py
Emit SARIF 2.1.0 JSON for code-scanning integrations:
mcp-scopecheck audit path/to/server.py --format sarif > scopecheck.sarif
From this repository checkout, the bundled unsafe fixture provides a reproducible first audit:
mcp-scopecheck audit examples/unsafe_docs_server
$ mcp-scopecheck audit examples/unsafe_docs_server
[CRITICAL] MSC001 Agent-directed instruction in tool description
[CRITICAL] MSC105 Environment data reaches network egress
[HIGH] MSC101 Read-only claim conflicts with reachable behavior
[HIGH] MSC102 External network egress requires review
The paired hardened fixture retains its intended filesystem-read capability but returns Findings (0) and exits 0.
Exit codes are stable for local and CI use:
| Code | Meaning |
|---|---|
0 |
Analysis complete within the documented model; no finding met the threshold |
1 |
Analysis complete; one or more findings met the configured threshold |
2 |
Analysis partial or failed, whether or not findings were also reported |
Use --fail-on high (or low, medium, or critical) to set the exit-1 threshold.
GitHub Action
Audit a server on every push:
permissions:
contents: read
security-events: write
jobs:
scopecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: iamudayrathore/mcp-scopecheck@741838a0934bc18dabc44043ad8af32d918cbab8
with:
target: path/to/python/server
fail-on: high
version: "0.2.3" # scanner version, independent of the pin above
- uses: github/codeql-action/upload-sarif@<pinned-sha>
if: always()
with:
sarif_file: scopecheck.sarif
Pin every action by full commit SHA, including this one. Branch refs are
mutable, and git tags are mutable too - a tag can be moved to point at different
code, which is how the tj-actions/changed-files compromise reached thousands of
workflows in March 2025. A 40-character SHA is content-addressed and cannot be
repointed. ScopeCheck enforces the same rule on itself: a test fails the build if
any action used by this repository's workflows or by this action is pinned by
anything other than a full SHA.
Release tags in this repository are additionally protected against updates and deletions, but pin the SHA regardless - do not extend trust to a tag in any repository whose protection you have not verified.
The pinned SHA selects the action code; the version input selects the
scanner. Keep them separate. A commit's action.yml carries whatever scanner
version was current when it was written, so relying on that default silently ties
you to an older scanner every time you pin an older commit - which, for a security
tool, means running one with known-missed detections. Setting version explicitly
makes the scanner you run visible in your own workflow file and reviewable in your
own diffs.
The action writes SARIF by default and fails the step on a nonzero audit,
including exit 2, because a partial or failed analysis is not a clean result.
Set fail-build: false and branch on the exit-code output to handle the exit
codes yourself. The version input pins the scanner independently of the action
ref; the default tracks the release the action shipped with.
SARIF upload is intentionally not bundled, so the action does not pull a second
third-party action into your supply chain or require security-events write
where you do not want it.
Why this exists
An MCP tool description and its annotations are claims. They do not enforce a permission boundary. A tool named search_project_docs can still contain code that reads /, accesses environment values, starts a process, or sends data over the network.
ScopeCheck asks a narrow, evidence-backed question:
Does the tool's declared contract agree with behavior reachable from the tool's source?
That focus complements manifest scanners and runtime testing tools. It does not replace either.
Security invariant
ScopeCheck reads bounded source bytes, decodes them strictly using Python's PEP
263 encoding rules, and parses the resulting text with Python's ast module.
Decode failures make the audit incomplete rather than substituting replacement
characters. Target modules are never imported, decorators are never invoked,
and MCP servers are never started. A regression test places a real top-level
side effect in a fixture and proves it does not execute during an audit.
What v0.2 detects
| Rule | Severity | What it means |
|---|---|---|
MSC001 |
High/Critical | Deterministic indicator families find agent-directed wording. Unambiguous directives (override, concealment, covert transfer, hidden-token markers, privileged-role impersonation) are Critical; ambiguous credential-handling and cross-call sequencing wording is High |
MSC101 |
High/Critical | readOnlyHint=true conflicts with justified state-changing behavior; process and dynamic code conflicts are Critical |
MSC102 |
High | Mandatory external-egress review for modeled network sinks: every modeled external and dynamic/computed destination is flagged, since neither prose nor a matching service hostname proves the destination; only local/loopback/private destinations are exempt. Specialized subtypes report explicit-denial contradictions and destination mismatches |
MSC105 |
Critical | Environment-derived data reaches a supported module or proven client-instance network sink in the same reachable function |
MSC106 |
Critical | Process or shell execution is reachable, for the modeled process-launching APIs (allowlist; see limitations) |
MSC107 |
Critical | Dynamic code execution is reachable, for the modeled APIs: eval, exec, compile, runpy, code, and types.FunctionType (allowlist; see limitations) |
MSC108 |
High | openWorldHint=false conflicts with reachable external network interaction |
Filesystem containment is not analyzed
MSC103 and MSC104 are withdrawn as of 0.2.2. They attempted to decide
whether caller-controlled filesystem access was constrained, and across four
consecutive release candidates they got that wrong in alternating directions — the
last of them still deciding the outcome by whether a parameter happened to be named
path rather than title.
ScopeCheck still reports that a tool reaches a filesystem operation, with the call path to it, because that is decided by the call graph. It does not report whether the path is contained. If you need that judgement, read the evidence trace and make it yourself; the 5-S pre-install checklist covers what to look for.
A rule that cannot decide a property reliably should not claim to. Restoring these requires path-aware dataflow rather than the token-set model that failed, and that is a design change, not a patch.
Observed capabilities are reported separately from findings. Filesystem reads are not automatically vulnerabilities; the contract comparison determines whether the behavior is inconsistent or insufficiently constrained.
The 5-S report
Every audit is organized around:
- Source — what local source was inspected.
- Surface — which MCP tools were discovered.
- Scope — parameters and declared annotations.
- Side effects — filesystem, environment, network, process, and dynamic-code capabilities reachable from each tool.
- Completeness — supported registrations and resolved or unresolved reachable local call edges.
- Snapshot — a deterministic SHA-256 digest of the extracted contract and capabilities.
For a broader manual review, use the 5-S pre-install checklist.
Current boundaries
v0.2 intentionally supports:
- Local Python files/directories
- Module-level
@mcp.tool,@mcp.tool(), and equivalent.tooldecorators - Exact decorated-function identity when a later definition reuses the same Python name
- Direct same-file module and nested sync/async helper-call reachability
- Static relative and absolute in-root Python imports
- Direct imported-function calls, import aliases, qualified local-module function
calls, and one explicit
__init__.pyre-export hop - Cross-module filesystem, environment, network, process, and dynamic-code capability reachability with shortest source paths
- Module-level and function-local import aliases with statement-order shadowing
- Static
True/Falsemodule and function branches plus fail-closed unresolved edges when compound control flow leaves a called import, path, client, or nested function binding ambiguous, including abrupt loop exits, exception prefixes, suppressing context managers, guarded match cases, short-circuit expressions, conditional values, and enclosing-scope assignment expressions - Definition-time tool defaults, decorators, and non-deferred annotations; calls through definition-time local helper names fail closed rather than guessing which function object existed at that point
- Explicit module-level
httpx,requests, andrequests.apirequest functions,urllib.request.urlopen/urlretrieve,socket.create_connection, and request methods on flow-provenhttpx.Client,httpx.AsyncClient,requests.Session,requests.sessions.Session,aiohttp.ClientSession,urllib3.PoolManager,urllib3.HTTPConnectionPool,http.client.HTTP(S)Connection, andsocket.socketvalues - Qualified builtin,
pathlib,os, andshutilfilesystem operations with static open-mode/flag handling - Modeled process-launching APIs:
subprocess.*,asyncio.create_subprocess_*,os.system/popen/startfile,os.exec*,os.spawn*,os.posix_spawn*,os.fork/forkpty,pty.spawn/fork/openpty, andmultiprocessing.Process - Modeled dynamic-code APIs:
eval,exec,compile,runpy.run_path/run_module,code.interact/InteractiveInterpreter, andtypes.FunctionType
It does not prove:
- Callback, function-alias, lambda, partial, wrapper, higher-order, class, or instance-method call paths
- Dynamic or wildcard import resolution, installed-package behavior, or more than one explicit package re-export hop
- Runtime-only tool registration
httpx.streamoraiohttp.requestcontext-manager factories, or egress via clients outside the recognized set (for examplepycurl,smtplib,ftplib,websockets)- Process or dynamic-code execution through an API outside the modeled sets above. Network, filesystem, process, and dynamic-code sink coverage are all allowlists; an unmodeled sink is not reported, and no rule infers one
- TypeScript/JavaScript behavior
- Authorization correctness
- Whether all observed data actually leaves the process, except the narrow same-function flow implemented by
MSC105 - Safety of a running MCP server
Unsupported reachable local behavior is listed in the completeness ledger and makes the audit partial. Ordinary calls proven to target the standard library or an external package do not by themselves make an audit partial.
MSC001 is a deterministic description check, not semantic or LLM analysis.
Its families are pattern-based and are not equally precise. The
credential-handling instruction and cross-call instruction families match a
verb near a credential noun and sequencing wording respectively, both of which
also appear in accurate self-descriptions - a secrets manager that says it "reads
credentials from the configured keychain", or a tool documenting a prerequisite
"before any request". Those families report High so they read as evidence for
a human decision rather than as proof of poisoning. The concealment instruction
family remains Critical and can still fire on a safety claim phrased as a
prohibition (for example "never reveal the user password in logs"); treat a lone
MSC001 finding as a prompt to read the description, not as a verdict. Precision
and recall have not yet been measured against a large corpus of real MCP tool
descriptions; the bundled corpus is a small regression fixture, not a benchmark.
MSC102 compares the statically resolved egress destination against the
description and never lets prose suppress a finding; an unresolved destination is
always flagged. MSC105 follows direct
environment reads, simple value assignments, and proven local HTTP-client
bindings in lexical order within one function. Reassignment and deletion kill a
client binding. ScopeCheck does not model complete Python control flow, general
points-to relationships, or interprocedural environment taint. Cross-module
environment-to-network taint is explicitly outside v0.2.
MSC101, MSC102, MSC106, MSC107, and MSC108 may consume unambiguous
cross-module capability reachability. MSC105 remains same-function.
Audits fail with exit 2 when fixed safety limits are exceeded:
1 MB per file, 5,000 Python files, 20 MB total source, 500,000 AST nodes, 200 AST
levels, 100 retained diagnostics, 2,000 participating local modules, 20,000
resolved local edges, 256 reachable functions per tool, 32 cross-module hops per
tool, 1,000 capability paths per tool, 1,000 unresolved edges, or 1,000 potential
registrations, or 250,000 binding-state work entries per module or reachable
function. A symlink supplied as the target is rejected;
symlinked files and directories encountered inside a directory target are
skipped without following them.
A clean report means complete within this bounded model and no threshold-matching finding; it is not proof of safe runtime behavior. See architecture and threat model and limitations.
For SARIF field semantics and a SHA-pinned GitHub code-scanning example, see SARIF output.
Develop
For an editable source installation, create a virtual environment and install the pinned development tools. The test suite uses only the Python standard library, and the installed scanner still has no runtime dependencies.
python3 -m venv .venv
.venv/bin/python -m pip install --disable-pip-version-check -r requirements-dev.txt
.venv/bin/python -m pip install --no-deps -e .
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -v
PYTHONPATH=src .venv/bin/python -m mcp_scopecheck audit examples/unsafe_docs_server
PYTHONPATH=src .venv/bin/python -m mcp_scopecheck audit examples/hardened_docs_server
Before a public release, activate that environment and run scripts/preflight.sh. It runs the same test, compile, Ruff, strict mypy, and build gates as CI; it additionally requires Gitleaks and fails closed if the scanner is unavailable.
For the source-evidence, unsafe/hardened, and exit-code demo:
scripts/demo.sh
Deliberate non-goals for v0.2
- General interprocedural, field-sensitive, or points-to data flow
- Cross-module taint
- Dynamic execution or import of audited targets
- Installed-package inspection
- TypeScript/JavaScript analysis
- LLM-assisted analysis
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mcp_scopecheck-0.2.3.tar.gz.
File metadata
- Download URL: mcp_scopecheck-0.2.3.tar.gz
- Upload date:
- Size: 140.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
471660e361ac7b787e352c80abdba4f2cfc79bfa5ed9d9ad5da95a472d9ce182
|
|
| MD5 |
f07dfe0a703d033bcccdbbd37c61e644
|
|
| BLAKE2b-256 |
0c0fa5b9ea4c412c7a4e65d77dc2b8e621ff4f45d74fd2527081aa4accd07701
|
Provenance
The following attestation bundles were made for mcp_scopecheck-0.2.3.tar.gz:
Publisher:
release.yml on iamudayrathore/mcp-scopecheck
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mcp_scopecheck-0.2.3.tar.gz -
Subject digest:
471660e361ac7b787e352c80abdba4f2cfc79bfa5ed9d9ad5da95a472d9ce182 - Sigstore transparency entry: 2536986472
- Sigstore integration time:
-
Permalink:
iamudayrathore/mcp-scopecheck@c5835b5a896ed9cf06b89eb1acbec76bb7594142 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/iamudayrathore
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c5835b5a896ed9cf06b89eb1acbec76bb7594142 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file mcp_scopecheck-0.2.3-py3-none-any.whl.
File metadata
- Download URL: mcp_scopecheck-0.2.3-py3-none-any.whl
- Upload date:
- Size: 57.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4542e84892279d96f189dfbc1cbf76017e9f41dbf3e7abe8e6bac4c7620f679a
|
|
| MD5 |
f0452c2fd7ae67b16125d7efbb13c9c9
|
|
| BLAKE2b-256 |
a128dffd2dc62464ac4dd7aa018658e16958e172d982cdd0dc219ae157f0450e
|
Provenance
The following attestation bundles were made for mcp_scopecheck-0.2.3-py3-none-any.whl:
Publisher:
release.yml on iamudayrathore/mcp-scopecheck
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mcp_scopecheck-0.2.3-py3-none-any.whl -
Subject digest:
4542e84892279d96f189dfbc1cbf76017e9f41dbf3e7abe8e6bac4c7620f679a - Sigstore transparency entry: 2536986583
- Sigstore integration time:
-
Permalink:
iamudayrathore/mcp-scopecheck@c5835b5a896ed9cf06b89eb1acbec76bb7594142 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/iamudayrathore
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c5835b5a896ed9cf06b89eb1acbec76bb7594142 -
Trigger Event:
workflow_dispatch
-
Statement type: