Skip to content

Python: Add models for the MCP server SDKs (mcp, fastmcp) - #22703

Open
Alex-Hofer wants to merge 5 commits into
github:mainfrom
Alex-Hofer:python-mcp-models
Open

Alex-Hofer wants to merge 5 commits into
github:mainfrom
Alex-Hofer:python-mcp-models

Conversation

@Alex-Hofer

@Alex-Hofer Alex-Hofer commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Implements the proposal in #22702. The observation there about py/xxe, py/xml-bomb and
py/nosql-injection taking only RemoteFlowSource has since been fixed by #22822.

MCP servers expose tools to LLM agents. Every parameter of a tool, resource or prompt handler is
chosen by the client, that is by a model that prompt injection can steer, or by anyone who can
reach the server over HTTP. This PR models that input for the two Python SDKs as Models-as-Data
next to the existing Stdlib.model.yml sources: the handler parameters as sources of the opt-in
ai threat model (#22810), the transport headers and the bearer token as sources of the remote
threat model.

Covered:

  • mcp 1.x FastMCP and 2.x MCPServer: tool(), resource(), prompt(), add_tool(fn).
  • The low-level Server: the 1.x decorators call_tool(), read_resource(), get_prompt(), and
    the 2.x constructor handlers on_call_tool, on_read_resource, on_get_prompt, where
    params.arguments / params.uri is the source.
  • fastmcp 2.x to 4.x: tool and prompt bare, called or as a plain call, resource(),
    add_tool, add_prompt, Tool.from_function, fastmcp.tools.tool, the transport headers via
    get_http_headers() and get_http_request().headers.
  • Both SDKs: the bearer token of the Authorization header, in verify_token of TokenVerifier
    (and AuthProvider) subclasses and from get_access_token().token.
  • typeModel rows for the re-exported import paths of the classes.

Tests: library-tests/frameworks/mcp and library-tests/frameworks/fastmcp use MaDTest
(mad-source__ai, mad-source__remote); every row has a handler with an inline expectation, and classes with the
same method name but no SDK base class have none.

Evidence: with these rows (as a model pack) CodeQL 2.27.1 finds 13 of 28 real, publicly disclosed
MCP server vulnerabilities in mcp-vulnbench
instead of 1; on the half of the cases that was held out while writing the rows, 9 of 16 instead
of 0, at 0.27 to 1.09 alarms per KLOC. Known limits of this form (handlers behind project
decorators, Pydantic-typed parameters, headers read through the Context object) are documented
there and in the issue. The measurement used the rows as remote, which is on by default; with
ai the same sources apply once that threat model is enabled.

Handler parameters are produced by a language model, so they are sources of the opt-in ai threat model added in github#22810. HTTP headers and bearer tokens come from the HTTP client and stay sources of the remote threat model.
@Alex-Hofer

Copy link
Copy Markdown
Contributor Author

Following the suggestion in #22749, the handler parameters are now sources of the opt-in ai threat model (#22810); the HTTP headers and bearer tokens stay remote, since they come from the HTTP client. Rebased on main. With #22822 merged, the note in the description about py/xxe, py/xml-bomb and py/nosql-injection is settled: these queries now see sources from data extensions.

@Alex-Hofer

Copy link
Copy Markdown
Contributor Author

I have merged main into the branch to pick up the ai source kind in the model validation from #22749; without it, library-tests/frameworks/data fails on these rows with Invalid kind "ai" in source model. The diff of this PR is unchanged. Locally (CodeQL 2.27.1), frameworks/data, frameworks/mcp and frameworks/fastmcp pass.

Comment on lines +8 to +12
- ['fastmcp.FastMCP', 'Member[tool,prompt].Argument[0,name_or_fn:].Parameter[any]', 'ai']
- ['fastmcp.FastMCP', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'ai']
- ['fastmcp.FastMCP', 'Member[add_tool].Argument[0,tool:].Parameter[any]', 'ai']
- ['fastmcp.FastMCP', 'Member[add_prompt].Argument[0,prompt:].Parameter[any]', 'ai']
- ['fastmcp.tools.Tool!', 'Member[from_function].Argument[0,fn:].Parameter[any]', 'ai']

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parameter[any] includes ctx: Context, although the SDK injects this rather than taking it from client arguments. I think the only fix is to use QL for these models.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f215487: the handlers of FastMCP, MCPServer and fastmcp are now modeled in Mcp.qll, which leaves out a parameter annotated with Context (also as Context[...], Context | None, Optional[...], Union[...], Annotated[...], or a string annotation in a module that imports Context) and the receiver of non-static methods; the tests have a negative case for each. The low-level server keeps its rows, since its handlers take (name, arguments) or (ctx, params) and no Context. #22750 also adds a Mcp.qll, for handlers behind a project decorator; I used the same name so that both end up in one module, and I'm happy to do that merge on whichever side lands second.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: following the Copilot review, 3c696d2 no longer leaves out the receiver (a handler decorated in a class body is registered unbound, so the client supplies self too), and it only looks at type positions of an annotation (not the metadata of Annotated or the values of Literal). frameworks/fastmcp, frameworks/mcp and frameworks/data pass locally.

Comment on lines +9 to +12
- ['mcp.server.fastmcp.FastMCP', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'ai']
- ['mcp.server.fastmcp.FastMCP', 'Member[add_tool].Argument[0,fn:].Parameter[any]', 'ai']
- ['mcp.server.mcpserver.MCPServer', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'ai']
- ['mcp.server.mcpserver.MCPServer', 'Member[add_tool].Argument[0,fn:].Parameter[any]', 'ai']

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parameter[any] includes ctx: Context, although the SDK injects this rather than taking it from client arguments. I think the only fix is to use QL for these models.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same change, see Mcp.qll; mcp.model.yml now only has the low-level server and the bearer token.

`Parameter[any]` also matched the `Context` parameter that the SDKs inject,
so the handlers of `FastMCP`, `MCPServer` and `fastmcp` are now modeled in
`Mcp.qll`, which leaves it out (also as `Context | None`, `Optional[...]`,
`Annotated[...]` or a string annotation) as well as the receiver of methods.
This also covers `Prompt.from_function`, `FunctionPrompt.from_function`, the
`Tool` classes in `fastmcp.tools.tool` and the standalone decorators in
`fastmcp.tools.function_tool` and `fastmcp.prompts`. The low-level server
keeps its data extensions and now also models `params.name`.
Copilot AI auto review requested due to automatic review settings November 4, 2026 18:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

MCP parameter classification incorrectly excludes client-controlled inputs and includes aliased SDK-injected context, risking both missed security findings and false positives.

3 open issues
What changed in this PR

Adds Python CodeQL models for the mcp and fastmcp server SDKs so existing security queries can track client-controlled handler input, headers, and bearer tokens.

Changes:

  • Models high-level handler parameters in QL under the opt-in ai threat model, excluding injected context parameters.
  • Adds data-extension sources for low-level handlers, transport headers, and authentication tokens.
  • Registers the framework model and adds regression tests and a change note.
File Description
python/​ql/​test/​library-tests/​frameworks/​mcp/​mcpserver_v2.py Tests MCPServer handlers and context exclusions.
python/​ql/​test/​library-tests/​frameworks/​mcp/​MaDTest.ql Runs data-extension source checks.
python/​ql/​test/​library-tests/​frameworks/​mcp/​MaDTest.expected Records expected data-extension results.
python/​ql/​test/​library-tests/​frameworks/​mcp/​lowlevel_v2.py Tests constructor-based low-level handlers.
python/​ql/​test/​library-tests/​frameworks/​mcp/​lowlevel_v1.py Tests decorator-based low-level handlers.
python/​ql/​test/​library-tests/​frameworks/​mcp/​fastmcp_v1.py Tests official SDK FastMCP registrations.
python/​ql/​test/​library-tests/​frameworks/​mcp/​ConceptsTest.ql Runs framework source-concept checks.
python/​ql/​test/​library-tests/​frameworks/​mcp/​ConceptsTest.expected Records expected framework source results.
python/​ql/​test/​library-tests/​frameworks/​mcp/​auth.py Tests token-verifier and access-token sources.
python/​ql/​test/​library-tests/​frameworks/​fastmcp/​test.py Tests standalone FastMCP handlers, context, headers, and authentication.
python/​ql/​test/​library-tests/​frameworks/​fastmcp/​MaDTest.ql Runs FastMCP data-extension checks.
python/​ql/​test/​library-tests/​frameworks/​fastmcp/​MaDTest.expected Records expected data-extension results.
python/​ql/​test/​library-tests/​frameworks/​fastmcp/​ConceptsTest.ql Runs FastMCP source-concept checks.
python/​ql/​test/​library-tests/​frameworks/​fastmcp/​ConceptsTest.expected Records expected handler-source results.
python/​ql/​lib/​semmle/​python/​frameworks/​Mcp.qll Identifies handler parameters and excludes injected context.
python/​ql/​lib/​semmle/​python/​frameworks/​mcp.model.yml Models low-level MCP handlers and token sources.
python/​ql/​lib/​semmle/​python/​frameworks/​fastmcp.model.yml Models FastMCP headers, tokens, and authentication aliases.
python/​ql/​lib/​semmle/​python/​Frameworks.qll Loads the MCP framework model.
python/​ql/​lib/​change-notes/​2026-09-30-mcp-models.md Documents SDK coverage and threat-model behavior.

Review effort: Max (auto)

@Alex-Hofer
Alex-Hofer requested a review from owen-mc November 4, 2026 22:47
Only type positions of an annotation refer to the injected `Context`
class: the metadata of `Annotated` and the values of `Literal` do not.
String annotations also match the names under which a module imports
the class, such as `Ctx` in `from fastmcp import Context as Ctx`.

A handler decorated in a class body is registered unbound, so the
client supplies its first parameter too; it is no longer left out.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved MCP modeling gaps in annotations, supported imports, dependency injection, and unbound callbacks incorrectly include server-injected parameters as ai sources while omitting client-controlled sources.

No open issues 🎉

8 previously missed issues

In code that hasn't changed since last review

Medium severity Model the mcp.server FastMCP re-export

python/​ql/​lib/​semmle/​python/​frameworks/​Mcp.qll:29

Handlers using from mcp.server import FastMCP are not modeled because this supported 1.x re-export is missing from serverClass(). All tool, resource and prompt parameters registered through that import therefore lose their ai sources. Include mcpServer.getMember("FastMCP") and add a regression handler using this import.

Medium severity Recognize Context from fastmcp.server

python/​ql/​lib/​semmle/​python/​frameworks/​Mcp.qll:139

A handler using from fastmcp.server import Context is not recognized as receiving injected context. This is a supported re-export, so its SDK-provided ctx incorrectly becomes an ai source and can produce false positives. Add this path to contextClass() and cover the import in the fixture.

Medium severity Resolve qualified string Context annotations

python/​ql/​lib/​semmle/​python/​frameworks/​Mcp.qll:147

For import fastmcp followed by a handler parameter ctx: "fastmcp.Context", there need not be any non-string reference to the Context class in this module. Consequently this exists has no matching ref, contextName returns nothing, and the SDK-injected ctx is incorrectly reported as an ai source. Resolve qualified string annotations through imported module bindings as well as direct class imports, and cover this module-import form in the tests.

Medium severity Recognize aliased Annotated and Literal annotations

python/​ql/​lib/​semmle/​python/​frameworks/​Mcp.qll:159

With from typing import Annotated as A or Literal as L, annotations such as A[str, "Context"] and L["Context", "Global"] enter the generic-subscript branch instead of the type-position checks. Their metadata or value strings then cause client-controlled parameters to be excluded from ai sources. Resolve these wrappers through their typing/typing_extensions bindings and add aliased-form regression cases.

Medium severity Recognize Context subclasses as injected parameters

python/​ql/​lib/​semmle/​python/​frameworks/​Mcp.qll:167

MCP 2.x's find_context_parameter also injects parameters annotated with subclasses of Context. For class CustomContext(Context): ... and a handler parameter ctx: CustomContext, this check only follows values from the imported base class, so ctx incorrectly becomes an ai source. Include transitive subclasses when recognizing context annotations and add a subclass-annotated regression case.

Medium severity Apply type-position checks to quoted annotations

python/​ql/​lib/​semmle/​python/​frameworks/​Mcp.qll:188

Fully quoted annotations such as command: "Annotated[str, 'Context']" and scope: "Literal['Context', 'Global']" still match this regex when the module imports Context. These parameters are client-controlled, but matching their metadata or literal values removes their ai sources. Apply type-position checks within quoted annotations too, and add quoted counterparts to the existing regression cases.

Medium severity Exclude FastMCP dependency-injected parameters

python/​ql/​lib/​semmle/​python/​frameworks/​Mcp.qll:208

FastMCP excludes parameters with Depends(...) defaults from the client-visible schema and resolves them on the server (https://gofastmcp.com/servers/dependency-injection). Here, command: str = Depends(get_fixed_command) still becomes an ai source even when the dependency returns a constant, which can produce a false command-injection finding. Exclude recognized dependency defaults for FastMCP handlers and add a negative regression case alongside the ordinary default-argument case.

Medium severity Use declared parameters for unbound class callbacks

python/​ql/​lib/​semmle/​python/​frameworks/​mcp.model.yml:12

Low-level handlers declared in a class body are registered unbound, but these paths use API-graph indexing that drops the first declared parameter. This loses the URI in @server.read_resource() on async def read(uri) and misses params for an unbound constructor callback declared as handle(ctx, params). Select the actual declared parameters for unbound callbacks, including the first one for decorators, and add class-body cases for both registration styles.

3 resolved since last review

Review effort: Max (auto)

*/

private import python
private import semmle.python.dataflow.new.DataFlow

@owen-mc owen-mc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good. Just two more issues.

)
or
// The arguments of `Literal` are values, not types.
exists(Subscript s | s = e and not subscripts(s, ["Annotated", "Literal"]) |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata/Literal fix still fails with aliased typing imports and quoted annotations. With Context imported as SDKContext, these client-controlled parameters are incorrectly excluded:

from typing import Annotated as A, Literal as L

command: A[str, "SDKContext"]
scope: L["SDKContext", "Global"]
command: "Annotated[str, 'SDKContext']"
scope: "Literal['SDKContext', 'Global']"

The wrapper check only recognizes the spelling Annotated/Literal, while the string fallback searches metadata and literal values too. Could we recognize the imported typing symbols, preserve type-position checks for quoted annotations, and add regression tests?

ref = contextClass().getAValueReachableFromSource().asExpr() and
m = ref.getEnclosingModule()
|
result = "Context"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning "Context" unconditionally is unsafe when the SDK class was imported under another name. For example:

from fastmcp import Context as SDKContext, FastMCP
from pydantic import BaseModel

app = FastMCP("test")

class Context(BaseModel):
    command: str

@app.tool()
def run(data: "Context"):
    return data.command

Here data is client-controlled, but the model excludes it because its annotation contains "Context". Could we require the quoted name to resolve to the SDK class rather than merely matching a name associated with the module? Please add this as a regression test.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants