Skip to content

Commit f215487

Browse files
committed
Python: Model MCP handler parameters in QL
`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`.
1 parent 8933961 commit f215487

14 files changed

Lines changed: 378 additions & 61 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
---
22
category: minorAnalysis
33
---
4-
* Added modeling of the `mcp` (the official Model Context Protocol SDK) and `fastmcp` PyPI packages. The parameters of MCP tool, resource and prompt handlers are now sources of the opt-in `ai` threat model, as their values are produced by a language model. The HTTP headers that `fastmcp` handlers read through `get_http_headers()` or `get_http_request()`, and the bearer token that token verifiers (`verify_token`) and `get_access_token()` receive, are sources of the `remote` threat model.
4+
* Added modeling of the `mcp` (the official Model Context Protocol SDK) and `fastmcp` PyPI packages. The parameters of MCP tool, resource and prompt handlers are now sources of the opt-in `ai` threat model, as their values are produced by a language model; the `Context` parameter that the SDKs inject is not a source. The HTTP headers that `fastmcp` handlers read through `get_http_headers()` or `get_http_request()`, and the bearer token that token verifiers (`verify_token`) and `get_access_token()` receive, are sources of the `remote` threat model.

‎python/ql/lib/semmle/python/Frameworks.qll‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ private import semmle.python.frameworks.Libxml2
5050
private import semmle.python.frameworks.Lxml
5151
private import semmle.python.frameworks.Mako
5252
private import semmle.python.frameworks.MarkupSafe
53+
private import semmle.python.frameworks.Mcp
5354
private import semmle.python.frameworks.Multidict
5455
private import semmle.python.frameworks.Mysql
5556
private import semmle.python.frameworks.MySQLdb
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* Provides classes modeling security-relevant aspects of the `mcp` and `fastmcp` PyPI packages,
3+
* which implement Model Context Protocol (MCP) servers.
4+
*
5+
* See
6+
* - https://pypi.org/project/mcp/
7+
* - https://pypi.org/project/fastmcp/
8+
*/
9+
10+
private import python
11+
private import semmle.python.dataflow.new.DataFlow
12+
private import semmle.python.dataflow.new.internal.DataFlowDispatch
13+
private import semmle.python.Concepts
14+
private import semmle.python.ApiGraphs
15+
16+
/**
17+
* INTERNAL: Do not use.
18+
*
19+
* Provides models for the `mcp` and `fastmcp` PyPI packages.
20+
*
21+
* The parameters of tool, resource and prompt handlers are chosen by the MCP client, that is by a
22+
* language model or by anyone who can reach the server. The low-level server of `mcp`, the HTTP
23+
* headers and the bearer tokens are modeled as data extensions (`mcp.model.yml`, `fastmcp.model.yml`).
24+
*/
25+
module Mcp {
26+
/** Gets a reference to a server class whose instances register tool, resource and prompt handlers. */
27+
private API::Node serverClass() {
28+
exists(API::Node mcpServer | mcpServer = API::moduleImport("mcp").getMember("server") |
29+
// `FastMCP` in 1.x of `mcp`, renamed to `MCPServer` in 2.x
30+
result = mcpServer.getMember("fastmcp").getMember("FastMCP")
31+
or
32+
result = mcpServer.getMember("fastmcp").getMember("server").getMember("FastMCP")
33+
or
34+
result = mcpServer.getMember("MCPServer")
35+
or
36+
result = mcpServer.getMember("mcpserver").getMember("MCPServer")
37+
or
38+
result = mcpServer.getMember("mcpserver").getMember("server").getMember("MCPServer")
39+
)
40+
or
41+
exists(API::Node fastmcp | fastmcp = API::moduleImport("fastmcp") |
42+
result = fastmcp.getMember("FastMCP")
43+
or
44+
result = fastmcp.getMember("server").getMember("FastMCP")
45+
or
46+
result = fastmcp.getMember("server").getMember("server").getMember("FastMCP")
47+
)
48+
}
49+
50+
/** Gets a reference to a tool or prompt class of `fastmcp` that has a `from_function` method. */
51+
private API::Node functionComponentClass() {
52+
exists(API::Node tools | tools = API::moduleImport("fastmcp").getMember("tools") |
53+
result = tools.getMember(["Tool", "FunctionTool"])
54+
or
55+
// 2.x defines both classes in `fastmcp.tools.tool`, later versions split them up
56+
result = tools.getMember("tool").getMember(["Tool", "FunctionTool"])
57+
or
58+
result = tools.getMember("base").getMember("Tool")
59+
or
60+
result = tools.getMember("function_tool").getMember("FunctionTool")
61+
)
62+
or
63+
exists(API::Node prompts | prompts = API::moduleImport("fastmcp").getMember("prompts") |
64+
result = prompts.getMember(["Prompt", "FunctionPrompt"])
65+
or
66+
result = prompts.getMember("prompt").getMember(["Prompt", "FunctionPrompt"])
67+
or
68+
result = prompts.getMember("base").getMember("Prompt")
69+
or
70+
result = prompts.getMember("function_prompt").getMember("FunctionPrompt")
71+
)
72+
}
73+
74+
/**
75+
* Gets a reference to a standalone decorator of `fastmcp` (3.x and later) that marks a function
76+
* as a tool or prompt.
77+
*/
78+
private API::Node standaloneDecorator() {
79+
exists(API::Node fastmcp | fastmcp = API::moduleImport("fastmcp") |
80+
result = fastmcp.getMember("tools").getMember("tool")
81+
or
82+
result = fastmcp.getMember("tools").getMember("function_tool").getMember("tool")
83+
or
84+
result = fastmcp.getMember("prompts").getMember("prompt")
85+
or
86+
result = fastmcp.getMember("prompts").getMember("function_prompt").getMember("prompt")
87+
)
88+
}
89+
90+
/** Gets an API node for a function that is registered as a tool, resource or prompt handler. */
91+
private API::Node handlerNode() {
92+
exists(API::Node server | server = serverClass().getAnInstance() |
93+
// `@server.tool()`, `@server.resource(uri)`, `@server.prompt()`
94+
result = server.getMember(["tool", "resource", "prompt"]).getReturn().getParameter(0)
95+
or
96+
// `@server.tool` and `@server.prompt` without parentheses (`fastmcp`)
97+
result = server.getMember(["tool", "prompt"]).getParameter(0)
98+
or
99+
result = server.getMember(["tool", "prompt"]).getKeywordParameter("name_or_fn")
100+
or
101+
result = server.getMember(["add_tool", "add_prompt"]).getParameter(0)
102+
or
103+
result = server.getMember("add_tool").getKeywordParameter(["fn", "tool"])
104+
or
105+
result = server.getMember("add_prompt").getKeywordParameter("prompt")
106+
)
107+
or
108+
exists(API::Node fromFunction |
109+
fromFunction = functionComponentClass().getMember("from_function")
110+
|
111+
result = fromFunction.getParameter(0)
112+
or
113+
result = fromFunction.getKeywordParameter("fn")
114+
)
115+
or
116+
exists(API::Node decorator | decorator = standaloneDecorator() |
117+
result = decorator.getParameter(0)
118+
or
119+
result = decorator.getKeywordParameter("name_or_fn")
120+
or
121+
result = decorator.getReturn().getParameter(0)
122+
)
123+
}
124+
125+
/** Gets a reference to the `Context` class whose instances the SDKs inject into handlers. */
126+
private API::Node contextClass() {
127+
exists(API::Node mcpServer | mcpServer = API::moduleImport("mcp").getMember("server") |
128+
result = mcpServer.getMember("fastmcp").getMember("Context")
129+
or
130+
result = mcpServer.getMember("fastmcp").getMember("server").getMember("Context")
131+
or
132+
result = mcpServer.getMember("mcpserver").getMember("Context")
133+
or
134+
result = mcpServer.getMember("mcpserver").getMember("context").getMember("Context")
135+
)
136+
or
137+
exists(API::Node fastmcp | fastmcp = API::moduleImport("fastmcp") |
138+
result = fastmcp.getMember("Context")
139+
or
140+
result = fastmcp.getMember("server").getMember("context").getMember("Context")
141+
)
142+
}
143+
144+
/**
145+
* Holds if the annotation `e` refers to the injected `Context` class, possibly as
146+
* `Context[...]`, `Context | None`, `Optional[Context]`, `Union[Context, ...]`,
147+
* `Annotated[Context, ...]` or as a string such as `"Context"`.
148+
*/
149+
private predicate isContextAnnotation(Expr e) {
150+
e = contextClass().getAValueReachableFromSource().asExpr()
151+
or
152+
isContextAnnotation(e.(Subscript).getObject())
153+
or
154+
isContextAnnotation(e.(Subscript).getIndex())
155+
or
156+
isContextAnnotation(e.(Tuple).getAnElt())
157+
or
158+
exists(BinaryExpr union | union = e and union.getOp() instanceof BitOr |
159+
isContextAnnotation(union.getLeft()) or isContextAnnotation(union.getRight())
160+
)
161+
or
162+
// The SDKs resolve string annotations, so `"Context"` counts in a module that imports it.
163+
e.(StringLiteral).getText().regexpMatch(".*\\bContext\\b.*") and
164+
contextClass().getAValueReachableFromSource().asExpr().getEnclosingModule() =
165+
e.getEnclosingModule()
166+
}
167+
168+
/** Holds if `p` is a parameter of `handler` whose value is chosen by the MCP client. */
169+
private predicate isClientParameter(Function handler, Parameter p) {
170+
handler = handlerNode().getAValueReachingSink().asExpr().(CallableExpr).getInnerScope() and
171+
(
172+
p = handler.getAnArg()
173+
or
174+
p = handler.getAKeywordOnlyArg()
175+
or
176+
p = handler.getVararg()
177+
or
178+
p = handler.getKwarg()
179+
) and
180+
not (
181+
handler.isMethod() and
182+
not isStaticmethod(handler) and
183+
p = handler.getArg(0)
184+
) and
185+
not isContextAnnotation(p.getAnnotation())
186+
}
187+
188+
/** A parameter of an MCP tool, resource or prompt handler, seen as a source of the `ai` threat model. */
189+
private class HandlerParameter extends ThreatModelSource::Range, DataFlow::ParameterNode {
190+
HandlerParameter() { isClientParameter(_, this.getParameter()) }
191+
192+
override string getThreatModel() { result = "ai" }
193+
194+
override string getSourceType() { result = "MCP handler parameter" }
195+
}
196+
}

‎python/ql/lib/semmle/python/frameworks/fastmcp.model.yml‎

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,7 @@ extensions:
44
extensible: sourceModel
55
data:
66
# `fastmcp` (PyPI), a standalone MCP server framework. The parameters of tool, resource and
7-
# prompt handlers are chosen by the MCP client; `tool` and `prompt` also work without parentheses.
8-
- ['fastmcp.FastMCP', 'Member[tool,prompt].Argument[0,name_or_fn:].Parameter[any]', 'ai']
9-
- ['fastmcp.FastMCP', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'ai']
10-
- ['fastmcp.FastMCP', 'Member[add_tool].Argument[0,tool:].Parameter[any]', 'ai']
11-
- ['fastmcp.FastMCP', 'Member[add_prompt].Argument[0,prompt:].Parameter[any]', 'ai']
12-
- ['fastmcp.tools.Tool!', 'Member[from_function].Argument[0,fn:].Parameter[any]', 'ai']
13-
- ['fastmcp', 'Member[tools].Member[tool].Argument[0,name_or_fn:].Parameter[any]', 'ai']
14-
- ['fastmcp', 'Member[tools].Member[tool].ReturnValue.Argument[0].Parameter[any]', 'ai']
7+
# prompt handlers are modeled in `Mcp.qll`, which leaves out the injected `Context` parameter.
158
# The HTTP headers of the transport.
169
- ['fastmcp', 'Member[server].Member[dependencies].Member[get_http_headers].ReturnValue', 'remote']
1710
- ['fastmcp', 'Member[server].Member[dependencies].Member[get_http_request].ReturnValue.Member[headers]', 'remote']
@@ -25,11 +18,5 @@ extensions:
2518
extensible: typeModel
2619
data:
2720
# The classes are re-exported; instances of any import path are instances of the class.
28-
- ['fastmcp.FastMCP!', 'fastmcp', 'Member[server].Member[FastMCP]']
29-
- ['fastmcp.FastMCP!', 'fastmcp', 'Member[server].Member[server].Member[FastMCP]']
30-
- ['fastmcp.FastMCP', 'fastmcp.FastMCP!', 'Instance']
31-
- ['fastmcp.tools.Tool!', 'fastmcp', 'Member[tools].Member[FunctionTool]']
32-
- ['fastmcp.tools.Tool!', 'fastmcp', 'Member[tools].Member[base].Member[Tool]']
33-
- ['fastmcp.tools.Tool!', 'fastmcp', 'Member[tools].Member[function_tool].Member[FunctionTool]']
3421
- ['fastmcp.server.auth.TokenVerifier!', 'fastmcp', 'Member[server].Member[auth].Member[auth].Member[TokenVerifier]']
3522
- ['fastmcp.server.auth.AuthProvider!', 'fastmcp', 'Member[server].Member[auth].Member[auth].Member[AuthProvider]']

‎python/ql/lib/semmle/python/frameworks/mcp.model.yml‎

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,12 @@ extensions:
33
pack: codeql/python-all
44
extensible: sourceModel
55
data:
6-
# `mcp`, the official Model Context Protocol SDK. The parameters of tool, resource and prompt
7-
# handlers are chosen by the MCP client, that is by an LLM or by anyone who can reach the server.
8-
# `FastMCP` (1.x) was renamed to `MCPServer` (2.x).
9-
- ['mcp.server.fastmcp.FastMCP', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'ai']
10-
- ['mcp.server.fastmcp.FastMCP', 'Member[add_tool].Argument[0,fn:].Parameter[any]', 'ai']
11-
- ['mcp.server.mcpserver.MCPServer', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'ai']
12-
- ['mcp.server.mcpserver.MCPServer', 'Member[add_tool].Argument[0,fn:].Parameter[any]', 'ai']
6+
# `mcp`, the official Model Context Protocol SDK. The handlers of `FastMCP` (1.x) and `MCPServer`
7+
# (2.x) are modeled in `Mcp.qll`, which leaves out the `Context` parameter that the SDK injects.
138
# The low-level server: decorators in 1.x, constructor handlers taking `(ctx, params)` in 2.x.
9+
# Its handlers take no `Context`; their parameters are chosen by the MCP client.
1410
- ['mcp.server.lowlevel.Server', 'Member[call_tool,read_resource,get_prompt].ReturnValue.Argument[0].Parameter[any]', 'ai']
15-
- ['mcp.server.lowlevel.Server!', 'Argument[on_call_tool:,on_get_prompt:].Parameter[1].Member[arguments]', 'ai']
11+
- ['mcp.server.lowlevel.Server!', 'Argument[on_call_tool:,on_get_prompt:].Parameter[1].Member[arguments,name]', 'ai']
1612
- ['mcp.server.lowlevel.Server!', 'Argument[on_read_resource:].Parameter[1].Member[uri]', 'ai']
1713
# The bearer token of the Authorization header reaches token verifiers and the access-token helper.
1814
- ['mcp.server.auth.provider.TokenVerifier!', 'Subclass.Member[verify_token].Parameter[0,token:]', 'remote']
@@ -22,12 +18,7 @@ extensions:
2218
pack: codeql/python-all
2319
extensible: typeModel
2420
data:
25-
# The classes are re-exported; instances of any import path are instances of the class.
26-
- ['mcp.server.fastmcp.FastMCP!', 'mcp', 'Member[server].Member[fastmcp].Member[server].Member[FastMCP]']
27-
- ['mcp.server.fastmcp.FastMCP', 'mcp.server.fastmcp.FastMCP!', 'Instance']
28-
- ['mcp.server.mcpserver.MCPServer!', 'mcp', 'Member[server].Member[MCPServer]']
29-
- ['mcp.server.mcpserver.MCPServer!', 'mcp', 'Member[server].Member[mcpserver].Member[server].Member[MCPServer]']
30-
- ['mcp.server.mcpserver.MCPServer', 'mcp.server.mcpserver.MCPServer!', 'Instance']
21+
# The class is re-exported; instances of any import path are instances of the class.
3122
- ['mcp.server.lowlevel.Server!', 'mcp', 'Member[server].Member[Server]']
3223
- ['mcp.server.lowlevel.Server!', 'mcp', 'Member[server].Member[lowlevel].Member[server].Member[Server]']
3324
- ['mcp.server.lowlevel.Server', 'mcp.server.lowlevel.Server!', 'Instance']

‎python/ql/test/library-tests/frameworks/fastmcp/ConceptsTest.expected‎

Whitespace-only changes.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import python
2+
import experimental.meta.ConceptsTest

0 commit comments

Comments
 (0)