Skip to content

Commit 21a5a92

Browse files
committed
Python: model MCP and FastMCP server handler parameters as remote flow sources
1 parent f7617ab commit 21a5a92

7 files changed

Lines changed: 243 additions & 0 deletions

File tree

‎docs/codeql/reusables/supported-frameworks.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ and the CodeQL library pack ``codeql/python-all`` (`changelog <https://github.co
226226
Tornado, Web framework
227227
Twisted, Web framework
228228
Gradio, Web framework
229+
fastmcp, MCP server framework
230+
mcp, MCP server framework
229231
starlette, Asynchronous Server Gateway Interface (ASGI)
230232
ldap3, Lightweight Directory Access Protocol (LDAP)
231233
python-ldap, Lightweight Directory Access Protocol (LDAP)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* Added `RemoteFlowSource` modeling for parameters of MCP server handlers registered via the `mcp` and `fastmcp` PyPI packages (`tool`, `prompt`, `resource`, and `add_tool`).

‎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: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Provides classes modeling security-relevant aspects of the `mcp` and `fastmcp` PyPI packages
3+
* (Model Context Protocol server SDKs).
4+
*/
5+
6+
private import python
7+
private import semmle.python.dataflow.new.DataFlow
8+
private import semmle.python.dataflow.new.RemoteFlowSources
9+
private import semmle.python.ApiGraphs
10+
11+
/**
12+
* Provides models for MCP server handler parameters as remote flow sources.
13+
*/
14+
module Mcp {
15+
private string registrationMethod() { result in ["tool", "prompt", "resource"] }
16+
17+
/** Gets API nodes for MCP / FastMCP server classes used to register handlers. */
18+
private API::Node mcpServerClass() {
19+
result =
20+
[
21+
API::moduleImport("fastmcp").getMember("FastMCP"),
22+
API::moduleImport("fastmcp").getMember("server").getMember("FastMCP"),
23+
API::moduleImport("fastmcp").getMember("server").getMember("server").getMember("FastMCP"),
24+
API::moduleImport("mcp").getMember("server").getMember("fastmcp").getMember("FastMCP"),
25+
API::moduleImport("mcp")
26+
.getMember("server")
27+
.getMember("fastmcp")
28+
.getMember("server")
29+
.getMember("FastMCP"),
30+
API::moduleImport("mcp").getMember("server").getMember("MCPServer"),
31+
API::moduleImport("mcp").getMember("server").getMember("mcpserver").getMember("MCPServer"),
32+
API::moduleImport("mcp")
33+
.getMember("server")
34+
.getMember("mcpserver")
35+
.getMember("server")
36+
.getMember("MCPServer"),
37+
]
38+
}
39+
40+
/** Gets API nodes for module-level `fastmcp` registration decorators (`@tool`, etc.). */
41+
private API::Node fastmcpModuleRegistration(string name) {
42+
name = registrationMethod() and result = API::moduleImport("fastmcp").getMember(name)
43+
or
44+
name = "tool" and result = API::moduleImport("fastmcp").getMember("tools").getMember("tool")
45+
}
46+
47+
private predicate isMcpHandler(Function handler) {
48+
exists(API::Node cls | cls = mcpServerClass() |
49+
exists(API::CallNode call |
50+
call = cls.getAnInstance().getMember(registrationMethod()).getACall() and
51+
call.getNode().getNode() = handler.getADecorator()
52+
)
53+
or
54+
exists(string name | name = registrationMethod() |
55+
handler.getADecorator() =
56+
cls.getAnInstance().getMember(name).getAValueReachableFromSource().asExpr()
57+
)
58+
or
59+
exists(API::CallNode addCall |
60+
addCall = cls.getAnInstance().getMember("add_tool").getACall() and
61+
(
62+
exists(DataFlow::LocalSourceNode funcSrc |
63+
(
64+
funcSrc.flowsTo(addCall.getArg(0)) or
65+
funcSrc.flowsTo(addCall.getArgByName("fn")) or
66+
funcSrc.flowsTo(addCall.getArgByName("tool"))
67+
) and
68+
funcSrc.asExpr() = handler.getDefinition()
69+
)
70+
or
71+
exists(DataFlow::Node arg |
72+
arg in [addCall.getArg(0), addCall.getArgByName("fn"), addCall.getArgByName("tool")] and
73+
arg.asExpr().(Name).getId() = handler.getName() and
74+
arg.getScope() = handler.getScope()
75+
)
76+
)
77+
)
78+
)
79+
or
80+
exists(string name | name = registrationMethod() |
81+
exists(API::Node reg | reg = fastmcpModuleRegistration(name) |
82+
exists(API::CallNode call |
83+
call = reg.getACall() and call.getNode().getNode() = handler.getADecorator()
84+
)
85+
or
86+
handler.getADecorator() = reg.getAValueReachableFromSource().asExpr()
87+
)
88+
)
89+
}
90+
91+
private predicate isContextAnnotation(Expr ann) {
92+
exists(API::Node ctx |
93+
ctx in [
94+
API::moduleImport("fastmcp").getMember("Context"),
95+
API::moduleImport("mcp").getMember("server").getMember("fastmcp").getMember("Context"),
96+
] and
97+
ann = ctx.getAValueReachableFromSource().asExpr()
98+
)
99+
}
100+
101+
private predicate isMcpContextParameter(Parameter p) {
102+
exists(Expr ann | ann = p.getAnnotation() |
103+
isContextAnnotation(ann)
104+
or
105+
exists(Subscript sub | sub = ann | isContextAnnotation(sub.getValue()))
106+
or
107+
exists(BinaryExpr bin | bin = ann and bin.getOp() instanceof BitOr |
108+
isContextAnnotation(bin.getLeft()) or isContextAnnotation(bin.getRight())
109+
)
110+
)
111+
}
112+
113+
private predicate isHandlerParameter(Function handler, Parameter p) {
114+
(
115+
p = handler.getAnArg()
116+
or
117+
p = handler.getAKeywordOnlyArg()
118+
or
119+
p = handler.getVararg()
120+
or
121+
p = handler.getKwarg()
122+
) and
123+
not (handler.isMethod() and p = handler.getArg(0)) and
124+
not isMcpContextParameter(p)
125+
}
126+
127+
private class McpHandlerParameter extends RemoteFlowSource::Range, DataFlow::ParameterNode {
128+
McpHandlerParameter() {
129+
exists(Function handler |
130+
isMcpHandler(handler) and
131+
isHandlerParameter(handler, this.getParameter())
132+
)
133+
}
134+
135+
override string getSourceType() { result = "MCP server handler parameter" }
136+
}
137+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
| mcp_test.py:16:15:16:21 | ControlFlowNode for command | MCP server handler parameter |
2+
| mcp_test.py:21:15:21:21 | ControlFlowNode for command | MCP server handler parameter |
3+
| mcp_test.py:26:16:26:20 | ControlFlowNode for topic | MCP server handler parameter |
4+
| mcp_test.py:26:31:26:35 | ControlFlowNode for style | MCP server handler parameter |
5+
| mcp_test.py:31:17:31:20 | ControlFlowNode for name | MCP server handler parameter |
6+
| mcp_test.py:35:23:35:25 | ControlFlowNode for url | MCP server handler parameter |
7+
| mcp_test.py:42:19:42:21 | ControlFlowNode for url | MCP server handler parameter |
8+
| mcp_test.py:50:17:50:19 | ControlFlowNode for cmd | MCP server handler parameter |
9+
| mcp_test.py:55:24:55:26 | ControlFlowNode for cmd | MCP server handler parameter |
10+
| mcp_test.py:61:23:61:25 | ControlFlowNode for cmd | MCP server handler parameter |
11+
| mcp_test.py:70:32:70:35 | ControlFlowNode for data | MCP server handler parameter |
12+
| mcp_test.py:80:29:80:32 | ControlFlowNode for host | MCP server handler parameter |
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import python
2+
import semmle.python.dataflow.new.RemoteFlowSources
3+
4+
from RemoteFlowSource src
5+
where exists(src.getLocation().getFile().getRelativePath())
6+
select src, src.getSourceType()
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from fastmcp import Context, FastMCP
2+
from fastmcp import tool as standalone_tool
3+
from mcp.server.fastmcp import FastMCP as McpFastMCP
4+
from mcp.server import MCPServer
5+
6+
mcp_app = FastMCP("test")
7+
mcp_sdk = McpFastMCP("test")
8+
mcp_v2 = MCPServer("test")
9+
10+
11+
def other_decorator(fn):
12+
return fn
13+
14+
15+
@mcp_app.tool()
16+
def sync_tool(command: str) -> str:
17+
return command
18+
19+
20+
@mcp_app.tool
21+
def bare_tool(command: str) -> str:
22+
return command
23+
24+
25+
@mcp_sdk.prompt()
26+
def sdk_prompt(topic: str, *, style: str = "short") -> str:
27+
return topic + style
28+
29+
30+
@mcp_v2.resource("notes://{name}")
31+
def v2_resource(name: str) -> str:
32+
return name
33+
34+
35+
async def async_fetch(url: str) -> str:
36+
return url
37+
38+
39+
mcp_app.add_tool(async_fetch)
40+
41+
42+
def added_via_add(url: str) -> str:
43+
return url
44+
45+
46+
mcp_sdk.add_tool(fn=added_via_add)
47+
48+
49+
@standalone_tool
50+
def module_tool(cmd: str) -> str:
51+
return cmd
52+
53+
54+
@standalone_tool()
55+
def module_tool_called(cmd: str) -> str:
56+
return cmd
57+
58+
59+
@other_decorator
60+
@mcp_app.tool()
61+
def stacked_decorator(cmd: str) -> str:
62+
return cmd
63+
64+
65+
def not_registered(value: str) -> str:
66+
return value
67+
68+
69+
@mcp_app.tool()
70+
def with_context(ctx: Context, data: str) -> str:
71+
return data
72+
73+
74+
class Service:
75+
def __init__(self):
76+
self.mcp = FastMCP("svc")
77+
78+
def register(self):
79+
@self.mcp.tool()
80+
def instance_lookup(host: str) -> str:
81+
return host

0 commit comments

Comments
 (0)