Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/codeql/reusables/supported-frameworks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ and the CodeQL library pack ``codeql/python-all`` (`changelog <https://github.co
Tornado, Web framework
Twisted, Web framework
Gradio, Web framework
fastmcp, MCP server framework
mcp, MCP server framework
starlette, Asynchronous Server Gateway Interface (ASGI)
ldap3, Lightweight Directory Access Protocol (LDAP)
python-ldap, Lightweight Directory Access Protocol (LDAP)
Expand Down
4 changes: 4 additions & 0 deletions python/ql/lib/change-notes/2026-10-04-mcp-remote-sources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
category: minorAnalysis
---
* Added complementary **`ai`** threat-model sources for MCP/`fastmcp` handlers that Models-as-Data misses: stacked custom decorators, instance registrations such as `@self.mcp.tool()`, and `@staticmethod` handler parameters.
92 changes: 92 additions & 0 deletions python/ql/lib/semmle/python/frameworks/Mcp.qll
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
*/

private import python
private import semmle.python.dataflow.new.DataFlow
private import semmle.python.dataflow.new.internal.DataFlowDispatch
private import semmle.python.Concepts
private import semmle.python.ApiGraphs

Expand All @@ -19,6 +21,9 @@ private import semmle.python.ApiGraphs
* The parameters of tool, resource and prompt handlers are chosen by the MCP client, that is by a
* language model or by anyone who can reach the server. The low-level server of `mcp`, the HTTP
* headers and the bearer tokens are modeled as data extensions (`mcp.model.yml`, `fastmcp.model.yml`).
*
* Complementary QL AST modeling (`McpHandlerParameter`) covers stacked custom decorators and
* instance registrations such as `@self.mcp.tool()` that the API-graph baseline does not reach.
*/
module Mcp {
/** Gets a reference to a server class whose instances register tool, resource and prompt handlers. */
Expand Down Expand Up @@ -299,4 +304,91 @@ module Mcp {

override string getSourceType() { result = "MCP handler parameter" }
}

private string registrationMethod() { result in ["tool", "prompt", "resource"] }

/** An MCP `@tool`/`@prompt`/`@resource` decorator expression on `handler`. */
private predicate mcpRegistrationDecorator(Function handler, Expr dec) {
dec = handler.getADecorator() and
exists(API::Node cls | cls = serverClass() |
exists(API::CallNode call |
call = cls.getAnInstance().getMember(registrationMethod()).getACall() and
call.getNode().getNode() = dec
)
or
exists(string name | name = registrationMethod() |
dec = cls.getAnInstance().getMember(name).getAValueReachableFromSource().asExpr()
)
)
}

/**
* Holds if `handler` is an MCP registration wrapped by another decorator.
* The API-graph baseline does not reach these stacked/custom project wrappers.
*/
private predicate isWrappedMcpHandler(Function handler) {
exists(Expr mcpDec, Expr extra |
mcpRegistrationDecorator(handler, mcpDec) and
extra = handler.getADecorator() and
extra != mcpDec
)
}

/**
* Holds if `handler` is registered via a nested instance lookup such as `@self.mcp.tool()`.
* The callee object is itself an `Attribute`, not a simple name (`mcp_app.tool()`).
*/
private predicate isInstanceLookupHandler(Function handler) {
exists(Expr dec | mcpRegistrationDecorator(handler, dec) |
exists(Attribute callee |
callee = dec.(Call).getFunc()
or
callee = dec
|
callee.getObject() instanceof Attribute
)
)
}

private predicate isComplementaryMcpHandler(Function handler) {
isWrappedMcpHandler(handler)
or
isInstanceLookupHandler(handler)
}

private predicate isComplementaryHandlerParameter(Function handler, Parameter p) {
(
p = handler.getAnArg()
or
p = handler.getAKeywordOnlyArg()
or
p = handler.getVararg()
or
p = handler.getKwarg()
) and
not (
handler.isMethod() and
not isStaticmethod(handler) and
p = handler.getArg(0)
) and
not isContextAnnotation(p.getAnnotation())
}

/**
* A handler parameter modeled by AST shape when the baseline `HandlerParameter` does not apply,
* seen as a source of the `ai` threat model.
*/
private class McpHandlerParameter extends ThreatModelSource::Range, DataFlow::ParameterNode {
McpHandlerParameter() {
exists(Function handler |
isComplementaryMcpHandler(handler) and
isComplementaryHandlerParameter(handler, this.getParameter()) and
not isClientParameter(handler, this.getParameter())
)
}

override string getThreatModel() { result = "ai" }

override string getSourceType() { result = "MCP server handler parameter" }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
| mcp_test.py:93:25:93:28 | ControlFlowNode for host | MCP server handler parameter |
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import python
import semmle.python.Concepts

from ThreatModelSource src
where
src.getThreatModel() = "ai" and
src.getSourceType() = "MCP server handler parameter" and
exists(src.getLocation().getFile().getRelativePath())
select src, src.getSourceType()
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from fastmcp import Context, FastMCP
from fastmcp import tool as standalone_tool
from mcp.server.fastmcp import FastMCP as McpFastMCP
from mcp.server import MCPServer

mcp_app = FastMCP("test")
mcp_sdk = McpFastMCP("test")
mcp_v2 = MCPServer("test")


def other_decorator(fn):
return fn
Comment on lines +11 to +12


@mcp_app.tool()
def sync_tool(command: str) -> str:
return command


@mcp_app.tool
def bare_tool(command: str) -> str:
return command


@mcp_sdk.prompt()
def sdk_prompt(topic: str, *, style: str = "short") -> str:
return topic + style


@mcp_v2.resource("notes://{name}")
def v2_resource(name: str) -> str:
return name


async def async_fetch(url: str) -> str:
return url


mcp_app.add_tool(async_fetch)


def added_via_add(url: str) -> str:
return url


mcp_sdk.add_tool(fn=added_via_add)


@standalone_tool
def module_tool(cmd: str) -> str:
return cmd


@standalone_tool()
def module_tool_called(cmd: str) -> str:
return cmd


@other_decorator
@mcp_app.tool()
def stacked_decorator(cmd: str) -> str:
return cmd


def not_registered(value: str) -> str:
return value


@mcp_app.tool()
def with_context(ctx: Context, data: str) -> str:
return data


class Service:
def __init__(self):
self.mcp = FastMCP("svc")

def register(self):
@self.mcp.tool()
def instance_lookup(host: str) -> str:
return host


@other_decorator
@mcp_app.tool()
def stacked_with_context(ctx: Context, payload: str) -> str:
return payload


class StaticMcpHandlers:
@mcp_app.tool()
@staticmethod
def static_mcp_tool(host: str) -> str:
return host