Repository navigation
Python: Add models for the MCP server SDKs (mcp, fastmcp) - #22703
Alex-Hofer wants to merge 4 commits into
Conversation
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.
f298591 to
1c9b296
Compare
|
Following the suggestion in #22749, the handler parameters are now sources of the opt-in |
|
I have merged main into the branch to pick up the |
| - ['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'] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| - ['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'] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Same change, see Mcp.qll; mcp.model.yml now only has the low-level server and the bearer token.
| - ['mcp.server.mcpserver.MCPServer', 'Member[add_tool].Argument[0,fn:].Parameter[any]', 'ai'] | ||
| # The low-level server: decorators in 1.x, constructor handlers taking `(ctx, params)` in 2.x. | ||
| - ['mcp.server.lowlevel.Server', 'Member[call_tool,read_resource,get_prompt].ReturnValue.Argument[0].Parameter[any]', 'ai'] | ||
| - ['mcp.server.lowlevel.Server!', 'Argument[on_call_tool:,on_get_prompt:].Parameter[1].Member[arguments]', 'ai'] |
There was a problem hiding this comment.
| - ['mcp.server.lowlevel.Server!', 'Argument[on_call_tool:,on_get_prompt:].Parameter[1].Member[arguments]', 'ai'] | |
| - ['mcp.server.lowlevel.Server!', 'Argument[on_call_tool:,on_get_prompt:].Parameter[1].Member[arguments,name]', 'ai'] |
And add tests for `name.
There was a problem hiding this comment.
Applied, with tests for params.name in lowlevel_v2.py.
| - ['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'] |
There was a problem hiding this comment.
Currently we will not track flow through code like app.add_prompt(Prompt.from_function(handler)). Add models for Prompt.from_function and FunctionPrompt.from_function.
There was a problem hiding this comment.
Added Prompt.from_function and FunctionPrompt.from_function for the import paths of 2.x to 4.x (fastmcp.prompts, fastmcp.prompts.prompt, fastmcp.prompts.base, fastmcp.prompts.function_prompt), with a test for app.add_prompt(Prompt.from_function(handler)). I also added the standalone prompt decorator, the counterpart of tool.
| - ['fastmcp.tools.Tool!', 'fastmcp', 'Member[tools].Member[FunctionTool]'] | ||
| - ['fastmcp.tools.Tool!', 'fastmcp', 'Member[tools].Member[base].Member[Tool]'] | ||
| - ['fastmcp.tools.Tool!', 'fastmcp', 'Member[tools].Member[function_tool].Member[FunctionTool]'] |
There was a problem hiding this comment.
The tool-class aliases omit fastmcp.tools.tool.Tool and fastmcp.tools.tool.FunctionTool. Add aliases here.
There was a problem hiding this comment.
Added fastmcp.tools.tool.Tool and fastmcp.tools.tool.FunctionTool, with tests.
| - ['fastmcp', 'Member[tools].Member[tool].Argument[0,name_or_fn:].Parameter[any]', 'ai'] | ||
| - ['fastmcp', 'Member[tools].Member[tool].ReturnValue.Argument[0].Parameter[any]', 'ai'] |
There was a problem hiding this comment.
The standalone decorator models cover fastmcp.tools.tool, but not the defining-module import fastmcp.tools.function_tool.tool. Add coverage for that path.
There was a problem hiding this comment.
Added fastmcp.tools.function_tool.tool, with and without parentheses.
`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`.
There was a problem hiding this comment.
🟡 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
aithreat 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)
| isContextAnnotation(e.(Subscript).getIndex()) | ||
| or | ||
| isContextAnnotation(e.(Tuple).getAnElt()) |
| e.(StringLiteral).getText().regexpMatch(".*\\bContext\\b.*") and | ||
| contextClass().getAValueReachableFromSource().asExpr().getEnclosingModule() = | ||
| e.getEnclosingModule() |
| handler.isMethod() and | ||
| not isStaticmethod(handler) and | ||
| p = handler.getArg(0) |

Implements the proposal in #22702. The observation there about
py/xxe,py/xml-bombandpy/nosql-injectiontaking onlyRemoteFlowSourcehas 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.ymlsources: the handler parameters as sources of the opt-inaithreat model (#22810), the transport headers and the bearer token as sources of theremotethreat model.
Covered:
mcp1.xFastMCPand 2.xMCPServer:tool(),resource(),prompt(),add_tool(fn).Server: the 1.x decoratorscall_tool(),read_resource(),get_prompt(), andthe 2.x constructor handlers
on_call_tool,on_read_resource,on_get_prompt, whereparams.arguments/params.uriis the source.fastmcp2.x to 4.x:toolandpromptbare, called or as a plain call,resource(),add_tool,add_prompt,Tool.from_function,fastmcp.tools.tool, the transport headers viaget_http_headers()andget_http_request().headers.Authorizationheader, inverify_tokenofTokenVerifier(and
AuthProvider) subclasses and fromget_access_token().token.typeModelrows for the re-exported import paths of the classes.Tests:
library-tests/frameworks/mcpandlibrary-tests/frameworks/fastmcpuseMaDTest(
mad-source__ai,mad-source__remote); every row has a handler with an inline expectation, and classes with thesame 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
Contextobject) are documentedthere and in the issue. The measurement used the rows as
remote, which is on by default; withaithe same sources apply once that threat model is enabled.