From c971b0147f2a817da73137ae604f3eb62c7153b8 Mon Sep 17 00:00:00 2001 From: Alex Hofer <62021660+Alex-Hofer@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:49:49 +0200 Subject: [PATCH 1/3] Python: Add models for the MCP server SDKs (mcp, fastmcp) --- .../lib/change-notes/2026-09-30-mcp-models.md | 4 + .../python/frameworks/fastmcp.model.yml | 35 +++++ .../semmle/python/frameworks/mcp.model.yml | 33 +++++ .../frameworks/fastmcp/MaDTest.expected | 0 .../frameworks/fastmcp/MaDTest.ql | 2 + .../library-tests/frameworks/fastmcp/test.py | 122 ++++++++++++++++++ .../frameworks/mcp/MaDTest.expected | 0 .../library-tests/frameworks/mcp/MaDTest.ql | 2 + .../test/library-tests/frameworks/mcp/auth.py | 16 +++ .../frameworks/mcp/fastmcp_v1.py | 47 +++++++ .../frameworks/mcp/lowlevel_v1.py | 22 ++++ .../frameworks/mcp/lowlevel_v2.py | 25 ++++ .../frameworks/mcp/mcpserver_v2.py | 29 +++++ 13 files changed, 337 insertions(+) create mode 100644 python/ql/lib/change-notes/2026-09-30-mcp-models.md create mode 100644 python/ql/lib/semmle/python/frameworks/fastmcp.model.yml create mode 100644 python/ql/lib/semmle/python/frameworks/mcp.model.yml create mode 100644 python/ql/test/library-tests/frameworks/fastmcp/MaDTest.expected create mode 100644 python/ql/test/library-tests/frameworks/fastmcp/MaDTest.ql create mode 100644 python/ql/test/library-tests/frameworks/fastmcp/test.py create mode 100644 python/ql/test/library-tests/frameworks/mcp/MaDTest.expected create mode 100644 python/ql/test/library-tests/frameworks/mcp/MaDTest.ql create mode 100644 python/ql/test/library-tests/frameworks/mcp/auth.py create mode 100644 python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py create mode 100644 python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py create mode 100644 python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py create mode 100644 python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py diff --git a/python/ql/lib/change-notes/2026-09-30-mcp-models.md b/python/ql/lib/change-notes/2026-09-30-mcp-models.md new file mode 100644 index 000000000000..d85cb4d6a06d --- /dev/null +++ b/python/ql/lib/change-notes/2026-09-30-mcp-models.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* Added modeling of the `mcp` (the official Model Context Protocol SDK) and `fastmcp` PyPI packages. The parameters of MCP tool, resource and prompt handlers, 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 now sources of the `remote` threat model. diff --git a/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml b/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml new file mode 100644 index 000000000000..6045d15a8a17 --- /dev/null +++ b/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml @@ -0,0 +1,35 @@ +extensions: + - addsTo: + pack: codeql/python-all + extensible: sourceModel + data: + # `fastmcp` (PyPI), a standalone MCP server framework. The parameters of tool, resource and + # prompt handlers are chosen by the MCP client; `tool` and `prompt` also work without parentheses. + - ['fastmcp.FastMCP', 'Member[tool,prompt].Argument[0,name_or_fn:].Parameter[any]', 'remote'] + - ['fastmcp.FastMCP', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'remote'] + - ['fastmcp.FastMCP', 'Member[add_tool].Argument[0,tool:].Parameter[any]', 'remote'] + - ['fastmcp.FastMCP', 'Member[add_prompt].Argument[0,prompt:].Parameter[any]', 'remote'] + - ['fastmcp.tools.Tool!', 'Member[from_function].Argument[0,fn:].Parameter[any]', 'remote'] + - ['fastmcp', 'Member[tools].Member[tool].Argument[0,name_or_fn:].Parameter[any]', 'remote'] + - ['fastmcp', 'Member[tools].Member[tool].ReturnValue.Argument[0].Parameter[any]', 'remote'] + # The HTTP headers of the transport. + - ['fastmcp', 'Member[server].Member[dependencies].Member[get_http_headers].ReturnValue', 'remote'] + - ['fastmcp', 'Member[server].Member[dependencies].Member[get_http_request].ReturnValue.Member[headers]', 'remote'] + # The bearer token of the Authorization header reaches token verifiers and the access-token helper. + - ['fastmcp.server.auth.TokenVerifier!', 'Subclass.Member[verify_token].Parameter[0,token:]', 'remote'] + - ['fastmcp.server.auth.AuthProvider!', 'Subclass.Member[verify_token].Parameter[0,token:]', 'remote'] + - ['fastmcp', 'Member[server].Member[dependencies].Member[get_access_token].ReturnValue.Member[token]', 'remote'] + + - addsTo: + pack: codeql/python-all + extensible: typeModel + data: + # The classes are re-exported; instances of any import path are instances of the class. + - ['fastmcp.FastMCP!', 'fastmcp', 'Member[server].Member[FastMCP]'] + - ['fastmcp.FastMCP!', 'fastmcp', 'Member[server].Member[server].Member[FastMCP]'] + - ['fastmcp.FastMCP', 'fastmcp.FastMCP!', 'Instance'] + - ['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]'] + - ['fastmcp.server.auth.TokenVerifier!', 'fastmcp', 'Member[server].Member[auth].Member[auth].Member[TokenVerifier]'] + - ['fastmcp.server.auth.AuthProvider!', 'fastmcp', 'Member[server].Member[auth].Member[auth].Member[AuthProvider]'] diff --git a/python/ql/lib/semmle/python/frameworks/mcp.model.yml b/python/ql/lib/semmle/python/frameworks/mcp.model.yml new file mode 100644 index 000000000000..3adffeaa24d5 --- /dev/null +++ b/python/ql/lib/semmle/python/frameworks/mcp.model.yml @@ -0,0 +1,33 @@ +extensions: + - addsTo: + pack: codeql/python-all + extensible: sourceModel + data: + # `mcp`, the official Model Context Protocol SDK. The parameters of tool, resource and prompt + # handlers are chosen by the MCP client, that is by an LLM or by anyone who can reach the server. + # `FastMCP` (1.x) was renamed to `MCPServer` (2.x). + - ['mcp.server.fastmcp.FastMCP', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'remote'] + - ['mcp.server.fastmcp.FastMCP', 'Member[add_tool].Argument[0,fn:].Parameter[any]', 'remote'] + - ['mcp.server.mcpserver.MCPServer', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'remote'] + - ['mcp.server.mcpserver.MCPServer', 'Member[add_tool].Argument[0,fn:].Parameter[any]', 'remote'] + # 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]', 'remote'] + - ['mcp.server.lowlevel.Server!', 'Argument[on_call_tool:,on_get_prompt:].Parameter[1].Member[arguments]', 'remote'] + - ['mcp.server.lowlevel.Server!', 'Argument[on_read_resource:].Parameter[1].Member[uri]', 'remote'] + # The bearer token of the Authorization header reaches token verifiers and the access-token helper. + - ['mcp.server.auth.provider.TokenVerifier!', 'Subclass.Member[verify_token].Parameter[0,token:]', 'remote'] + - ['mcp', 'Member[server].Member[auth].Member[middleware].Member[auth_context].Member[get_access_token].ReturnValue.Member[token]', 'remote'] + + - addsTo: + pack: codeql/python-all + extensible: typeModel + data: + # The classes are re-exported; instances of any import path are instances of the class. + - ['mcp.server.fastmcp.FastMCP!', 'mcp', 'Member[server].Member[fastmcp].Member[server].Member[FastMCP]'] + - ['mcp.server.fastmcp.FastMCP', 'mcp.server.fastmcp.FastMCP!', 'Instance'] + - ['mcp.server.mcpserver.MCPServer!', 'mcp', 'Member[server].Member[MCPServer]'] + - ['mcp.server.mcpserver.MCPServer!', 'mcp', 'Member[server].Member[mcpserver].Member[server].Member[MCPServer]'] + - ['mcp.server.mcpserver.MCPServer', 'mcp.server.mcpserver.MCPServer!', 'Instance'] + - ['mcp.server.lowlevel.Server!', 'mcp', 'Member[server].Member[Server]'] + - ['mcp.server.lowlevel.Server!', 'mcp', 'Member[server].Member[lowlevel].Member[server].Member[Server]'] + - ['mcp.server.lowlevel.Server', 'mcp.server.lowlevel.Server!', 'Instance'] diff --git a/python/ql/test/library-tests/frameworks/fastmcp/MaDTest.expected b/python/ql/test/library-tests/frameworks/fastmcp/MaDTest.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/frameworks/fastmcp/MaDTest.ql b/python/ql/test/library-tests/frameworks/fastmcp/MaDTest.ql new file mode 100644 index 000000000000..fef4356ab35e --- /dev/null +++ b/python/ql/test/library-tests/frameworks/fastmcp/MaDTest.ql @@ -0,0 +1,2 @@ +import python +import experimental.meta.MaDTest diff --git a/python/ql/test/library-tests/frameworks/fastmcp/test.py b/python/ql/test/library-tests/frameworks/fastmcp/test.py new file mode 100644 index 000000000000..22e26e021cef --- /dev/null +++ b/python/ql/test/library-tests/frameworks/fastmcp/test.py @@ -0,0 +1,122 @@ +from fastmcp import FastMCP +from fastmcp.server import FastMCP as ServerFastMCP +from fastmcp.server.server import FastMCP as ModuleFastMCP +from fastmcp.server.auth import AuthProvider, TokenVerifier +from fastmcp.server.auth.auth import TokenVerifier as ModuleTokenVerifier +from fastmcp.server.dependencies import get_access_token, get_http_headers, get_http_request +from fastmcp.tools import FunctionTool, Tool, tool +from fastmcp.tools.base import Tool as BaseTool +from fastmcp.tools.function_tool import FunctionTool as ModuleFunctionTool + +app = FastMCP("test") +server_app = ServerFastMCP("test") +module_app = ModuleFastMCP("test") + + +@app.tool +def bare(command): # $ mad-source__remote=command + return command + + +@server_app.tool(name="exec") +def called(command): # $ mad-source__remote=command + return command + + +@module_app.resource("files://{path}") +def resource(path): # $ mad-source__remote=path + return path + + +@app.prompt +def bare_prompt(topic): # $ mad-source__remote=topic + return topic + + +@app.prompt() +def called_prompt(topic, style="short"): # $ mad-source__remote=topic mad-source__remote=style + return topic + style + + +def added(command): # $ mad-source__remote=command + return command + + +app.add_tool(added) + + +def added_prompt(topic): # $ mad-source__remote=topic + return topic + + +app.add_prompt(prompt=added_prompt) + + +def via_tool(url): # $ mad-source__remote=url + return url + + +def via_function_tool(url): # $ mad-source__remote=url + return url + + +def via_base_tool(url): # $ mad-source__remote=url + return url + + +def via_module_function_tool(url): # $ mad-source__remote=url + return url + + +Tool.from_function(via_tool) +FunctionTool.from_function(via_function_tool) +BaseTool.from_function(fn=via_base_tool) +ModuleFunctionTool.from_function(via_module_function_tool) + + +@tool +def standalone(command): # $ mad-source__remote=command + return command + + +@tool() +def standalone_called(command): # $ mad-source__remote=command + return command + + +@app.tool +def forward_header(): + return get_http_headers()["x-target"] # $ mad-source__remote=get_http_headers() + + +@app.tool +def forward_request_header(): + return get_http_request().headers["x-target"] # $ mad-source__remote=get_http_request().headers + + +def not_registered(value): + return value + + +class Verifier(TokenVerifier): + async def verify_token(self, token): # $ mad-source__remote=token + return token + + +class ModuleVerifier(ModuleTokenVerifier): + async def verify_token(self, token): # $ mad-source__remote=token + return token + + +class Provider(AuthProvider): + async def verify_token(self, token): # $ mad-source__remote=token + return token + + +class NotAVerifier: + async def verify_token(self, token): + return token + + +def session(): + return get_access_token().token # $ mad-source__remote=get_access_token().token diff --git a/python/ql/test/library-tests/frameworks/mcp/MaDTest.expected b/python/ql/test/library-tests/frameworks/mcp/MaDTest.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/frameworks/mcp/MaDTest.ql b/python/ql/test/library-tests/frameworks/mcp/MaDTest.ql new file mode 100644 index 000000000000..fef4356ab35e --- /dev/null +++ b/python/ql/test/library-tests/frameworks/mcp/MaDTest.ql @@ -0,0 +1,2 @@ +import python +import experimental.meta.MaDTest diff --git a/python/ql/test/library-tests/frameworks/mcp/auth.py b/python/ql/test/library-tests/frameworks/mcp/auth.py new file mode 100644 index 000000000000..59ca724d9733 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/mcp/auth.py @@ -0,0 +1,16 @@ +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import TokenVerifier + + +class Verifier(TokenVerifier): + async def verify_token(self, token): # $ mad-source__remote=token + return token + + +class NotAVerifier: + async def verify_token(self, token): + return token + + +def session(): + return get_access_token().token # $ mad-source__remote=get_access_token().token diff --git a/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py b/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py new file mode 100644 index 000000000000..347d92e6005d --- /dev/null +++ b/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py @@ -0,0 +1,47 @@ +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.server import FastMCP as ModuleFastMCP + +server = FastMCP("test") +other = ModuleFastMCP("test") + + +@server.tool() +def run(command): # $ mad-source__remote=command + return command + + +@other.resource("notes://{name}") +def note(name): # $ mad-source__remote=name + return name + + +@server.prompt() +def summarize(topic, style="short"): # $ mad-source__remote=topic mad-source__remote=style + return topic + style + + +def fetch(url): # $ mad-source__remote=url + return url + + +other.add_tool(fn=fetch) + + +def register(app: FastMCP): + @app.tool() + def remove(path): # $ mad-source__remote=path + return path + + +class Service: + def __init__(self): + self.mcp = FastMCP("test") + + def register(self): + @self.mcp.tool() + def lookup(host): # $ mad-source__remote=host + return host + + +def not_registered(value): + return value diff --git a/python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py new file mode 100644 index 000000000000..2ff4e3929250 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py @@ -0,0 +1,22 @@ +from mcp.server import Server +from mcp.server.lowlevel import Server as LowLevelServer +from mcp.server.lowlevel.server import Server as ModuleServer + +server = Server("test") +lowlevel_server = LowLevelServer("test") +module_server = ModuleServer("test") + + +@server.call_tool() +async def call_tool(name, arguments): # $ mad-source__remote=name mad-source__remote=arguments + return arguments + + +@lowlevel_server.read_resource() +async def read_resource(uri): # $ mad-source__remote=uri + return uri + + +@module_server.get_prompt() +async def get_prompt(name, arguments): # $ mad-source__remote=name mad-source__remote=arguments + return arguments diff --git a/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py new file mode 100644 index 000000000000..c39b248627fc --- /dev/null +++ b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py @@ -0,0 +1,25 @@ +from mcp.server import Server + + +async def handle_call_tool(ctx, params): + return params.arguments # $ mad-source__remote=params.arguments + + +async def handle_read_resource(ctx, params): + return params.uri # $ mad-source__remote=params.uri + + +async def handle_get_prompt(ctx, params): + return params.arguments # $ mad-source__remote=params.arguments + + +async def not_registered(ctx, params): + return params.arguments + + +server = Server( + "test", + on_call_tool=handle_call_tool, + on_read_resource=handle_read_resource, + on_get_prompt=handle_get_prompt, +) diff --git a/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py b/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py new file mode 100644 index 000000000000..e1122d6b4730 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py @@ -0,0 +1,29 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import MCPServer as PackageMCPServer +from mcp.server.mcpserver.server import MCPServer as ModuleMCPServer + +server = MCPServer("test") +package_server = PackageMCPServer("test") +module_server = ModuleMCPServer("test") + + +@server.tool() +def run(command): # $ mad-source__remote=command + return command + + +@package_server.resource("notes://{name}") +def note(name): # $ mad-source__remote=name + return name + + +@module_server.prompt() +def summarize(topic): # $ mad-source__remote=topic + return topic + + +def fetch(url): # $ mad-source__remote=url + return url + + +server.add_tool(fetch) From 1c9b2968ede3ed7455c15f90d93f4b87d9a50b58 Mon Sep 17 00:00:00 2001 From: Alex Hofer <62021660+Alex-Hofer@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:00:21 +0200 Subject: [PATCH 2/3] Python: Use the ai threat model for MCP handler input Handler parameters are produced by a language model, so they are sources of the opt-in ai threat model added in #22810. HTTP headers and bearer tokens come from the HTTP client and stay sources of the remote threat model. --- .../lib/change-notes/2026-09-30-mcp-models.md | 2 +- .../python/frameworks/fastmcp.model.yml | 14 +++++----- .../semmle/python/frameworks/mcp.model.yml | 14 +++++----- .../library-tests/frameworks/fastmcp/test.py | 26 +++++++++---------- .../frameworks/mcp/fastmcp_v1.py | 12 ++++----- .../frameworks/mcp/lowlevel_v1.py | 6 ++--- .../frameworks/mcp/lowlevel_v2.py | 6 ++--- .../frameworks/mcp/mcpserver_v2.py | 8 +++--- 8 files changed, 44 insertions(+), 44 deletions(-) diff --git a/python/ql/lib/change-notes/2026-09-30-mcp-models.md b/python/ql/lib/change-notes/2026-09-30-mcp-models.md index d85cb4d6a06d..3b6bfbce8806 100644 --- a/python/ql/lib/change-notes/2026-09-30-mcp-models.md +++ b/python/ql/lib/change-notes/2026-09-30-mcp-models.md @@ -1,4 +1,4 @@ --- category: minorAnalysis --- -* Added modeling of the `mcp` (the official Model Context Protocol SDK) and `fastmcp` PyPI packages. The parameters of MCP tool, resource and prompt handlers, 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 now sources of the `remote` threat model. +* 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. diff --git a/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml b/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml index 6045d15a8a17..46a968405c7d 100644 --- a/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml +++ b/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml @@ -5,13 +5,13 @@ extensions: data: # `fastmcp` (PyPI), a standalone MCP server framework. The parameters of tool, resource and # prompt handlers are chosen by the MCP client; `tool` and `prompt` also work without parentheses. - - ['fastmcp.FastMCP', 'Member[tool,prompt].Argument[0,name_or_fn:].Parameter[any]', 'remote'] - - ['fastmcp.FastMCP', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'remote'] - - ['fastmcp.FastMCP', 'Member[add_tool].Argument[0,tool:].Parameter[any]', 'remote'] - - ['fastmcp.FastMCP', 'Member[add_prompt].Argument[0,prompt:].Parameter[any]', 'remote'] - - ['fastmcp.tools.Tool!', 'Member[from_function].Argument[0,fn:].Parameter[any]', 'remote'] - - ['fastmcp', 'Member[tools].Member[tool].Argument[0,name_or_fn:].Parameter[any]', 'remote'] - - ['fastmcp', 'Member[tools].Member[tool].ReturnValue.Argument[0].Parameter[any]', 'remote'] + - ['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'] + - ['fastmcp', 'Member[tools].Member[tool].Argument[0,name_or_fn:].Parameter[any]', 'ai'] + - ['fastmcp', 'Member[tools].Member[tool].ReturnValue.Argument[0].Parameter[any]', 'ai'] # The HTTP headers of the transport. - ['fastmcp', 'Member[server].Member[dependencies].Member[get_http_headers].ReturnValue', 'remote'] - ['fastmcp', 'Member[server].Member[dependencies].Member[get_http_request].ReturnValue.Member[headers]', 'remote'] diff --git a/python/ql/lib/semmle/python/frameworks/mcp.model.yml b/python/ql/lib/semmle/python/frameworks/mcp.model.yml index 3adffeaa24d5..08d3cf31eb12 100644 --- a/python/ql/lib/semmle/python/frameworks/mcp.model.yml +++ b/python/ql/lib/semmle/python/frameworks/mcp.model.yml @@ -6,14 +6,14 @@ extensions: # `mcp`, the official Model Context Protocol SDK. The parameters of tool, resource and prompt # handlers are chosen by the MCP client, that is by an LLM or by anyone who can reach the server. # `FastMCP` (1.x) was renamed to `MCPServer` (2.x). - - ['mcp.server.fastmcp.FastMCP', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'remote'] - - ['mcp.server.fastmcp.FastMCP', 'Member[add_tool].Argument[0,fn:].Parameter[any]', 'remote'] - - ['mcp.server.mcpserver.MCPServer', 'Member[tool,resource,prompt].ReturnValue.Argument[0].Parameter[any]', 'remote'] - - ['mcp.server.mcpserver.MCPServer', 'Member[add_tool].Argument[0,fn:].Parameter[any]', 'remote'] + - ['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'] # 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]', 'remote'] - - ['mcp.server.lowlevel.Server!', 'Argument[on_call_tool:,on_get_prompt:].Parameter[1].Member[arguments]', 'remote'] - - ['mcp.server.lowlevel.Server!', 'Argument[on_read_resource:].Parameter[1].Member[uri]', 'remote'] + - ['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'] + - ['mcp.server.lowlevel.Server!', 'Argument[on_read_resource:].Parameter[1].Member[uri]', 'ai'] # The bearer token of the Authorization header reaches token verifiers and the access-token helper. - ['mcp.server.auth.provider.TokenVerifier!', 'Subclass.Member[verify_token].Parameter[0,token:]', 'remote'] - ['mcp', 'Member[server].Member[auth].Member[middleware].Member[auth_context].Member[get_access_token].ReturnValue.Member[token]', 'remote'] diff --git a/python/ql/test/library-tests/frameworks/fastmcp/test.py b/python/ql/test/library-tests/frameworks/fastmcp/test.py index 22e26e021cef..8cabe710a84a 100644 --- a/python/ql/test/library-tests/frameworks/fastmcp/test.py +++ b/python/ql/test/library-tests/frameworks/fastmcp/test.py @@ -14,57 +14,57 @@ @app.tool -def bare(command): # $ mad-source__remote=command +def bare(command): # $ mad-source__ai=command return command @server_app.tool(name="exec") -def called(command): # $ mad-source__remote=command +def called(command): # $ mad-source__ai=command return command @module_app.resource("files://{path}") -def resource(path): # $ mad-source__remote=path +def resource(path): # $ mad-source__ai=path return path @app.prompt -def bare_prompt(topic): # $ mad-source__remote=topic +def bare_prompt(topic): # $ mad-source__ai=topic return topic @app.prompt() -def called_prompt(topic, style="short"): # $ mad-source__remote=topic mad-source__remote=style +def called_prompt(topic, style="short"): # $ mad-source__ai=topic mad-source__ai=style return topic + style -def added(command): # $ mad-source__remote=command +def added(command): # $ mad-source__ai=command return command app.add_tool(added) -def added_prompt(topic): # $ mad-source__remote=topic +def added_prompt(topic): # $ mad-source__ai=topic return topic app.add_prompt(prompt=added_prompt) -def via_tool(url): # $ mad-source__remote=url +def via_tool(url): # $ mad-source__ai=url return url -def via_function_tool(url): # $ mad-source__remote=url +def via_function_tool(url): # $ mad-source__ai=url return url -def via_base_tool(url): # $ mad-source__remote=url +def via_base_tool(url): # $ mad-source__ai=url return url -def via_module_function_tool(url): # $ mad-source__remote=url +def via_module_function_tool(url): # $ mad-source__ai=url return url @@ -75,12 +75,12 @@ def via_module_function_tool(url): # $ mad-source__remote=url @tool -def standalone(command): # $ mad-source__remote=command +def standalone(command): # $ mad-source__ai=command return command @tool() -def standalone_called(command): # $ mad-source__remote=command +def standalone_called(command): # $ mad-source__ai=command return command diff --git a/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py b/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py index 347d92e6005d..76753ef13bc4 100644 --- a/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py +++ b/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py @@ -6,21 +6,21 @@ @server.tool() -def run(command): # $ mad-source__remote=command +def run(command): # $ mad-source__ai=command return command @other.resource("notes://{name}") -def note(name): # $ mad-source__remote=name +def note(name): # $ mad-source__ai=name return name @server.prompt() -def summarize(topic, style="short"): # $ mad-source__remote=topic mad-source__remote=style +def summarize(topic, style="short"): # $ mad-source__ai=topic mad-source__ai=style return topic + style -def fetch(url): # $ mad-source__remote=url +def fetch(url): # $ mad-source__ai=url return url @@ -29,7 +29,7 @@ def fetch(url): # $ mad-source__remote=url def register(app: FastMCP): @app.tool() - def remove(path): # $ mad-source__remote=path + def remove(path): # $ mad-source__ai=path return path @@ -39,7 +39,7 @@ def __init__(self): def register(self): @self.mcp.tool() - def lookup(host): # $ mad-source__remote=host + def lookup(host): # $ mad-source__ai=host return host diff --git a/python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py index 2ff4e3929250..f301dc95e678 100644 --- a/python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py +++ b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py @@ -8,15 +8,15 @@ @server.call_tool() -async def call_tool(name, arguments): # $ mad-source__remote=name mad-source__remote=arguments +async def call_tool(name, arguments): # $ mad-source__ai=name mad-source__ai=arguments return arguments @lowlevel_server.read_resource() -async def read_resource(uri): # $ mad-source__remote=uri +async def read_resource(uri): # $ mad-source__ai=uri return uri @module_server.get_prompt() -async def get_prompt(name, arguments): # $ mad-source__remote=name mad-source__remote=arguments +async def get_prompt(name, arguments): # $ mad-source__ai=name mad-source__ai=arguments return arguments diff --git a/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py index c39b248627fc..c66066bb86fc 100644 --- a/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py +++ b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py @@ -2,15 +2,15 @@ async def handle_call_tool(ctx, params): - return params.arguments # $ mad-source__remote=params.arguments + return params.arguments # $ mad-source__ai=params.arguments async def handle_read_resource(ctx, params): - return params.uri # $ mad-source__remote=params.uri + return params.uri # $ mad-source__ai=params.uri async def handle_get_prompt(ctx, params): - return params.arguments # $ mad-source__remote=params.arguments + return params.arguments # $ mad-source__ai=params.arguments async def not_registered(ctx, params): diff --git a/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py b/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py index e1122d6b4730..f3637a2dc1d3 100644 --- a/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py +++ b/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py @@ -8,21 +8,21 @@ @server.tool() -def run(command): # $ mad-source__remote=command +def run(command): # $ mad-source__ai=command return command @package_server.resource("notes://{name}") -def note(name): # $ mad-source__remote=name +def note(name): # $ mad-source__ai=name return name @module_server.prompt() -def summarize(topic): # $ mad-source__remote=topic +def summarize(topic): # $ mad-source__ai=topic return topic -def fetch(url): # $ mad-source__remote=url +def fetch(url): # $ mad-source__ai=url return url From f215487ce76132a8426f573a89a2184e0a3d33f2 Mon Sep 17 00:00:00 2001 From: Alex Hofer <62021660+Alex-Hofer@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:10:20 +0100 Subject: [PATCH 3/3] 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`. --- .../lib/change-notes/2026-09-30-mcp-models.md | 2 +- python/ql/lib/semmle/python/Frameworks.qll | 1 + .../ql/lib/semmle/python/frameworks/Mcp.qll | 196 ++++++++++++++++++ .../python/frameworks/fastmcp.model.yml | 15 +- .../semmle/python/frameworks/mcp.model.yml | 19 +- .../frameworks/fastmcp/ConceptsTest.expected | 0 .../frameworks/fastmcp/ConceptsTest.ql | 2 + .../library-tests/frameworks/fastmcp/test.py | 142 +++++++++++-- .../frameworks/mcp/ConceptsTest.expected | 0 .../frameworks/mcp/ConceptsTest.ql | 2 + .../frameworks/mcp/fastmcp_v1.py | 26 ++- .../frameworks/mcp/lowlevel_v1.py | 6 +- .../frameworks/mcp/lowlevel_v2.py | 7 +- .../frameworks/mcp/mcpserver_v2.py | 21 +- 14 files changed, 378 insertions(+), 61 deletions(-) create mode 100644 python/ql/lib/semmle/python/frameworks/Mcp.qll create mode 100644 python/ql/test/library-tests/frameworks/fastmcp/ConceptsTest.expected create mode 100644 python/ql/test/library-tests/frameworks/fastmcp/ConceptsTest.ql create mode 100644 python/ql/test/library-tests/frameworks/mcp/ConceptsTest.expected create mode 100644 python/ql/test/library-tests/frameworks/mcp/ConceptsTest.ql diff --git a/python/ql/lib/change-notes/2026-09-30-mcp-models.md b/python/ql/lib/change-notes/2026-09-30-mcp-models.md index 3b6bfbce8806..3dc0a49e8bf2 100644 --- a/python/ql/lib/change-notes/2026-09-30-mcp-models.md +++ b/python/ql/lib/change-notes/2026-09-30-mcp-models.md @@ -1,4 +1,4 @@ --- category: minorAnalysis --- -* 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. +* 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. diff --git a/python/ql/lib/semmle/python/Frameworks.qll b/python/ql/lib/semmle/python/Frameworks.qll index 1c2f0a6e6d41..51efee7b48be 100644 --- a/python/ql/lib/semmle/python/Frameworks.qll +++ b/python/ql/lib/semmle/python/Frameworks.qll @@ -50,6 +50,7 @@ private import semmle.python.frameworks.Libxml2 private import semmle.python.frameworks.Lxml private import semmle.python.frameworks.Mako private import semmle.python.frameworks.MarkupSafe +private import semmle.python.frameworks.Mcp private import semmle.python.frameworks.Multidict private import semmle.python.frameworks.Mysql private import semmle.python.frameworks.MySQLdb diff --git a/python/ql/lib/semmle/python/frameworks/Mcp.qll b/python/ql/lib/semmle/python/frameworks/Mcp.qll new file mode 100644 index 000000000000..45a700a70f1b --- /dev/null +++ b/python/ql/lib/semmle/python/frameworks/Mcp.qll @@ -0,0 +1,196 @@ +/** + * Provides classes modeling security-relevant aspects of the `mcp` and `fastmcp` PyPI packages, + * which implement Model Context Protocol (MCP) servers. + * + * See + * - https://pypi.org/project/mcp/ + * - https://pypi.org/project/fastmcp/ + */ + +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 + +/** + * INTERNAL: Do not use. + * + * Provides models for the `mcp` and `fastmcp` PyPI packages. + * + * 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`). + */ +module Mcp { + /** Gets a reference to a server class whose instances register tool, resource and prompt handlers. */ + private API::Node serverClass() { + exists(API::Node mcpServer | mcpServer = API::moduleImport("mcp").getMember("server") | + // `FastMCP` in 1.x of `mcp`, renamed to `MCPServer` in 2.x + result = mcpServer.getMember("fastmcp").getMember("FastMCP") + or + result = mcpServer.getMember("fastmcp").getMember("server").getMember("FastMCP") + or + result = mcpServer.getMember("MCPServer") + or + result = mcpServer.getMember("mcpserver").getMember("MCPServer") + or + result = mcpServer.getMember("mcpserver").getMember("server").getMember("MCPServer") + ) + or + exists(API::Node fastmcp | fastmcp = API::moduleImport("fastmcp") | + result = fastmcp.getMember("FastMCP") + or + result = fastmcp.getMember("server").getMember("FastMCP") + or + result = fastmcp.getMember("server").getMember("server").getMember("FastMCP") + ) + } + + /** Gets a reference to a tool or prompt class of `fastmcp` that has a `from_function` method. */ + private API::Node functionComponentClass() { + exists(API::Node tools | tools = API::moduleImport("fastmcp").getMember("tools") | + result = tools.getMember(["Tool", "FunctionTool"]) + or + // 2.x defines both classes in `fastmcp.tools.tool`, later versions split them up + result = tools.getMember("tool").getMember(["Tool", "FunctionTool"]) + or + result = tools.getMember("base").getMember("Tool") + or + result = tools.getMember("function_tool").getMember("FunctionTool") + ) + or + exists(API::Node prompts | prompts = API::moduleImport("fastmcp").getMember("prompts") | + result = prompts.getMember(["Prompt", "FunctionPrompt"]) + or + result = prompts.getMember("prompt").getMember(["Prompt", "FunctionPrompt"]) + or + result = prompts.getMember("base").getMember("Prompt") + or + result = prompts.getMember("function_prompt").getMember("FunctionPrompt") + ) + } + + /** + * Gets a reference to a standalone decorator of `fastmcp` (3.x and later) that marks a function + * as a tool or prompt. + */ + private API::Node standaloneDecorator() { + exists(API::Node fastmcp | fastmcp = API::moduleImport("fastmcp") | + result = fastmcp.getMember("tools").getMember("tool") + or + result = fastmcp.getMember("tools").getMember("function_tool").getMember("tool") + or + result = fastmcp.getMember("prompts").getMember("prompt") + or + result = fastmcp.getMember("prompts").getMember("function_prompt").getMember("prompt") + ) + } + + /** Gets an API node for a function that is registered as a tool, resource or prompt handler. */ + private API::Node handlerNode() { + exists(API::Node server | server = serverClass().getAnInstance() | + // `@server.tool()`, `@server.resource(uri)`, `@server.prompt()` + result = server.getMember(["tool", "resource", "prompt"]).getReturn().getParameter(0) + or + // `@server.tool` and `@server.prompt` without parentheses (`fastmcp`) + result = server.getMember(["tool", "prompt"]).getParameter(0) + or + result = server.getMember(["tool", "prompt"]).getKeywordParameter("name_or_fn") + or + result = server.getMember(["add_tool", "add_prompt"]).getParameter(0) + or + result = server.getMember("add_tool").getKeywordParameter(["fn", "tool"]) + or + result = server.getMember("add_prompt").getKeywordParameter("prompt") + ) + or + exists(API::Node fromFunction | + fromFunction = functionComponentClass().getMember("from_function") + | + result = fromFunction.getParameter(0) + or + result = fromFunction.getKeywordParameter("fn") + ) + or + exists(API::Node decorator | decorator = standaloneDecorator() | + result = decorator.getParameter(0) + or + result = decorator.getKeywordParameter("name_or_fn") + or + result = decorator.getReturn().getParameter(0) + ) + } + + /** Gets a reference to the `Context` class whose instances the SDKs inject into handlers. */ + private API::Node contextClass() { + exists(API::Node mcpServer | mcpServer = API::moduleImport("mcp").getMember("server") | + result = mcpServer.getMember("fastmcp").getMember("Context") + or + result = mcpServer.getMember("fastmcp").getMember("server").getMember("Context") + or + result = mcpServer.getMember("mcpserver").getMember("Context") + or + result = mcpServer.getMember("mcpserver").getMember("context").getMember("Context") + ) + or + exists(API::Node fastmcp | fastmcp = API::moduleImport("fastmcp") | + result = fastmcp.getMember("Context") + or + result = fastmcp.getMember("server").getMember("context").getMember("Context") + ) + } + + /** + * Holds if the annotation `e` refers to the injected `Context` class, possibly as + * `Context[...]`, `Context | None`, `Optional[Context]`, `Union[Context, ...]`, + * `Annotated[Context, ...]` or as a string such as `"Context"`. + */ + private predicate isContextAnnotation(Expr e) { + e = contextClass().getAValueReachableFromSource().asExpr() + or + isContextAnnotation(e.(Subscript).getObject()) + or + isContextAnnotation(e.(Subscript).getIndex()) + or + isContextAnnotation(e.(Tuple).getAnElt()) + or + exists(BinaryExpr union | union = e and union.getOp() instanceof BitOr | + isContextAnnotation(union.getLeft()) or isContextAnnotation(union.getRight()) + ) + or + // The SDKs resolve string annotations, so `"Context"` counts in a module that imports it. + e.(StringLiteral).getText().regexpMatch(".*\\bContext\\b.*") and + contextClass().getAValueReachableFromSource().asExpr().getEnclosingModule() = + e.getEnclosingModule() + } + + /** Holds if `p` is a parameter of `handler` whose value is chosen by the MCP client. */ + private predicate isClientParameter(Function handler, Parameter p) { + handler = handlerNode().getAValueReachingSink().asExpr().(CallableExpr).getInnerScope() and + ( + 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 parameter of an MCP tool, resource or prompt handler, seen as a source of the `ai` threat model. */ + private class HandlerParameter extends ThreatModelSource::Range, DataFlow::ParameterNode { + HandlerParameter() { isClientParameter(_, this.getParameter()) } + + override string getThreatModel() { result = "ai" } + + override string getSourceType() { result = "MCP handler parameter" } + } +} diff --git a/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml b/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml index 46a968405c7d..d0aaeadb4446 100644 --- a/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml +++ b/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml @@ -4,14 +4,7 @@ extensions: extensible: sourceModel data: # `fastmcp` (PyPI), a standalone MCP server framework. The parameters of tool, resource and - # prompt handlers are chosen by the MCP client; `tool` and `prompt` also work without parentheses. - - ['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'] - - ['fastmcp', 'Member[tools].Member[tool].Argument[0,name_or_fn:].Parameter[any]', 'ai'] - - ['fastmcp', 'Member[tools].Member[tool].ReturnValue.Argument[0].Parameter[any]', 'ai'] + # prompt handlers are modeled in `Mcp.qll`, which leaves out the injected `Context` parameter. # The HTTP headers of the transport. - ['fastmcp', 'Member[server].Member[dependencies].Member[get_http_headers].ReturnValue', 'remote'] - ['fastmcp', 'Member[server].Member[dependencies].Member[get_http_request].ReturnValue.Member[headers]', 'remote'] @@ -25,11 +18,5 @@ extensions: extensible: typeModel data: # The classes are re-exported; instances of any import path are instances of the class. - - ['fastmcp.FastMCP!', 'fastmcp', 'Member[server].Member[FastMCP]'] - - ['fastmcp.FastMCP!', 'fastmcp', 'Member[server].Member[server].Member[FastMCP]'] - - ['fastmcp.FastMCP', 'fastmcp.FastMCP!', 'Instance'] - - ['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]'] - ['fastmcp.server.auth.TokenVerifier!', 'fastmcp', 'Member[server].Member[auth].Member[auth].Member[TokenVerifier]'] - ['fastmcp.server.auth.AuthProvider!', 'fastmcp', 'Member[server].Member[auth].Member[auth].Member[AuthProvider]'] diff --git a/python/ql/lib/semmle/python/frameworks/mcp.model.yml b/python/ql/lib/semmle/python/frameworks/mcp.model.yml index 08d3cf31eb12..f62b1925399f 100644 --- a/python/ql/lib/semmle/python/frameworks/mcp.model.yml +++ b/python/ql/lib/semmle/python/frameworks/mcp.model.yml @@ -3,16 +3,12 @@ extensions: pack: codeql/python-all extensible: sourceModel data: - # `mcp`, the official Model Context Protocol SDK. The parameters of tool, resource and prompt - # handlers are chosen by the MCP client, that is by an LLM or by anyone who can reach the server. - # `FastMCP` (1.x) was renamed to `MCPServer` (2.x). - - ['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'] + # `mcp`, the official Model Context Protocol SDK. The handlers of `FastMCP` (1.x) and `MCPServer` + # (2.x) are modeled in `Mcp.qll`, which leaves out the `Context` parameter that the SDK injects. # The low-level server: decorators in 1.x, constructor handlers taking `(ctx, params)` in 2.x. + # Its handlers take no `Context`; their parameters are chosen by the MCP client. - ['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'] + - ['mcp.server.lowlevel.Server!', 'Argument[on_call_tool:,on_get_prompt:].Parameter[1].Member[arguments,name]', 'ai'] - ['mcp.server.lowlevel.Server!', 'Argument[on_read_resource:].Parameter[1].Member[uri]', 'ai'] # The bearer token of the Authorization header reaches token verifiers and the access-token helper. - ['mcp.server.auth.provider.TokenVerifier!', 'Subclass.Member[verify_token].Parameter[0,token:]', 'remote'] @@ -22,12 +18,7 @@ extensions: pack: codeql/python-all extensible: typeModel data: - # The classes are re-exported; instances of any import path are instances of the class. - - ['mcp.server.fastmcp.FastMCP!', 'mcp', 'Member[server].Member[fastmcp].Member[server].Member[FastMCP]'] - - ['mcp.server.fastmcp.FastMCP', 'mcp.server.fastmcp.FastMCP!', 'Instance'] - - ['mcp.server.mcpserver.MCPServer!', 'mcp', 'Member[server].Member[MCPServer]'] - - ['mcp.server.mcpserver.MCPServer!', 'mcp', 'Member[server].Member[mcpserver].Member[server].Member[MCPServer]'] - - ['mcp.server.mcpserver.MCPServer', 'mcp.server.mcpserver.MCPServer!', 'Instance'] + # The class is re-exported; instances of any import path are instances of the class. - ['mcp.server.lowlevel.Server!', 'mcp', 'Member[server].Member[Server]'] - ['mcp.server.lowlevel.Server!', 'mcp', 'Member[server].Member[lowlevel].Member[server].Member[Server]'] - ['mcp.server.lowlevel.Server', 'mcp.server.lowlevel.Server!', 'Instance'] diff --git a/python/ql/test/library-tests/frameworks/fastmcp/ConceptsTest.expected b/python/ql/test/library-tests/frameworks/fastmcp/ConceptsTest.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/frameworks/fastmcp/ConceptsTest.ql b/python/ql/test/library-tests/frameworks/fastmcp/ConceptsTest.ql new file mode 100644 index 000000000000..b557a0bccb69 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/fastmcp/ConceptsTest.ql @@ -0,0 +1,2 @@ +import python +import experimental.meta.ConceptsTest diff --git a/python/ql/test/library-tests/frameworks/fastmcp/test.py b/python/ql/test/library-tests/frameworks/fastmcp/test.py index 8cabe710a84a..6b068331ba90 100644 --- a/python/ql/test/library-tests/frameworks/fastmcp/test.py +++ b/python/ql/test/library-tests/frameworks/fastmcp/test.py @@ -1,12 +1,24 @@ -from fastmcp import FastMCP +from typing import Annotated, Optional + +from fastmcp import Context, FastMCP +from fastmcp.prompts import FunctionPrompt, Prompt, prompt +from fastmcp.prompts.base import Prompt as BasePrompt +from fastmcp.prompts.function_prompt import FunctionPrompt as ModuleFunctionPrompt +from fastmcp.prompts.function_prompt import prompt as module_prompt +from fastmcp.prompts.prompt import FunctionPrompt as PromptModuleFunctionPrompt +from fastmcp.prompts.prompt import Prompt as PromptModulePrompt from fastmcp.server import FastMCP as ServerFastMCP -from fastmcp.server.server import FastMCP as ModuleFastMCP from fastmcp.server.auth import AuthProvider, TokenVerifier from fastmcp.server.auth.auth import TokenVerifier as ModuleTokenVerifier +from fastmcp.server.context import Context as ModuleContext from fastmcp.server.dependencies import get_access_token, get_http_headers, get_http_request +from fastmcp.server.server import FastMCP as ModuleFastMCP from fastmcp.tools import FunctionTool, Tool, tool from fastmcp.tools.base import Tool as BaseTool from fastmcp.tools.function_tool import FunctionTool as ModuleFunctionTool +from fastmcp.tools.function_tool import tool as module_tool +from fastmcp.tools.tool import FunctionTool as ToolModuleFunctionTool +from fastmcp.tools.tool import Tool as ToolModuleTool app = FastMCP("test") server_app = ServerFastMCP("test") @@ -14,57 +26,91 @@ @app.tool -def bare(command): # $ mad-source__ai=command +def bare(command): # $ threatModelSource[ai]=command return command @server_app.tool(name="exec") -def called(command): # $ mad-source__ai=command +def called(command): # $ threatModelSource[ai]=command return command @module_app.resource("files://{path}") -def resource(path): # $ mad-source__ai=path +def resource(path): # $ threatModelSource[ai]=path return path @app.prompt -def bare_prompt(topic): # $ mad-source__ai=topic +def bare_prompt(topic): # $ threatModelSource[ai]=topic return topic @app.prompt() -def called_prompt(topic, style="short"): # $ mad-source__ai=topic mad-source__ai=style +def called_prompt(topic, style="short"): # $ threatModelSource[ai]=topic threatModelSource[ai]=style return topic + style -def added(command): # $ mad-source__ai=command +def added(command): # $ threatModelSource[ai]=command return command app.add_tool(added) -def added_prompt(topic): # $ mad-source__ai=topic +def added_prompt(topic): # $ threatModelSource[ai]=topic return topic app.add_prompt(prompt=added_prompt) -def via_tool(url): # $ mad-source__ai=url +# The SDK injects the `Context` parameter itself, so it is not chosen by the client. +@app.tool +def with_context(command, ctx: Context): # $ threatModelSource[ai]=command + return command + + +@app.tool +async def with_optional_context(command, ctx: Context | None = None): # $ threatModelSource[ai]=command + return command + + +@app.tool +async def with_typing_context(command, ctx: Optional[ModuleContext] = None): # $ threatModelSource[ai]=command + return command + + +@app.tool +async def with_annotated_context(command, ctx: Annotated[Context, "injected"]): # $ threatModelSource[ai]=command + return command + + +@app.tool +async def with_string_context(command, ctx: "Context"): # $ threatModelSource[ai]=command + return command + + +def via_tool(url): # $ threatModelSource[ai]=url + return url + + +def via_function_tool(url): # $ threatModelSource[ai]=url + return url + + +def via_base_tool(url): # $ threatModelSource[ai]=url return url -def via_function_tool(url): # $ mad-source__ai=url +def via_module_function_tool(url): # $ threatModelSource[ai]=url return url -def via_base_tool(url): # $ mad-source__ai=url +def via_tool_module_tool(url): # $ threatModelSource[ai]=url return url -def via_module_function_tool(url): # $ mad-source__ai=url +def via_tool_module_function_tool(url): # $ threatModelSource[ai]=url return url @@ -72,18 +118,72 @@ def via_module_function_tool(url): # $ mad-source__ai=url FunctionTool.from_function(via_function_tool) BaseTool.from_function(fn=via_base_tool) ModuleFunctionTool.from_function(via_module_function_tool) +ToolModuleTool.from_function(via_tool_module_tool) +ToolModuleFunctionTool.from_function(via_tool_module_function_tool) + + +def via_prompt(topic): # $ threatModelSource[ai]=topic + return topic + + +def via_function_prompt(topic): # $ threatModelSource[ai]=topic + return topic + + +def via_base_prompt(topic): # $ threatModelSource[ai]=topic + return topic + + +def via_module_function_prompt(topic): # $ threatModelSource[ai]=topic + return topic + + +def via_prompt_module_prompt(topic): # $ threatModelSource[ai]=topic + return topic + + +def via_prompt_module_function_prompt(topic): # $ threatModelSource[ai]=topic + return topic + + +app.add_prompt(Prompt.from_function(via_prompt)) +FunctionPrompt.from_function(via_function_prompt) +BasePrompt.from_function(fn=via_base_prompt) +ModuleFunctionPrompt.from_function(via_module_function_prompt) +PromptModulePrompt.from_function(via_prompt_module_prompt) +PromptModuleFunctionPrompt.from_function(via_prompt_module_function_prompt) @tool -def standalone(command): # $ mad-source__ai=command +def standalone(command): # $ threatModelSource[ai]=command return command @tool() -def standalone_called(command): # $ mad-source__ai=command +def standalone_called(command): # $ threatModelSource[ai]=command return command +@module_tool +def standalone_module(command): # $ threatModelSource[ai]=command + return command + + +@module_tool(name="exec") +def standalone_module_called(command): # $ threatModelSource[ai]=command + return command + + +@prompt +def standalone_prompt(topic): # $ threatModelSource[ai]=topic + return topic + + +@module_prompt() +def standalone_module_prompt(topic): # $ threatModelSource[ai]=topic + return topic + + @app.tool def forward_header(): return get_http_headers()["x-target"] # $ mad-source__remote=get_http_headers() @@ -94,6 +194,18 @@ def forward_request_header(): return get_http_request().headers["x-target"] # $ mad-source__remote=get_http_request().headers +class Handlers: + # The receiver of a method is not chosen by the client, the first parameter of a static method is. + @app.tool + def method(self, command): # $ threatModelSource[ai]=command + return command + + @staticmethod + @app.tool + def static(command): # $ threatModelSource[ai]=command + return command + + def not_registered(value): return value diff --git a/python/ql/test/library-tests/frameworks/mcp/ConceptsTest.expected b/python/ql/test/library-tests/frameworks/mcp/ConceptsTest.expected new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/ql/test/library-tests/frameworks/mcp/ConceptsTest.ql b/python/ql/test/library-tests/frameworks/mcp/ConceptsTest.ql new file mode 100644 index 000000000000..b557a0bccb69 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/mcp/ConceptsTest.ql @@ -0,0 +1,2 @@ +import python +import experimental.meta.ConceptsTest diff --git a/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py b/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py index 76753ef13bc4..2a3146563fb7 100644 --- a/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py +++ b/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py @@ -1,26 +1,27 @@ -from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp.server import FastMCP as ModuleFastMCP +from mcp.server.session import ServerSession server = FastMCP("test") other = ModuleFastMCP("test") @server.tool() -def run(command): # $ mad-source__ai=command +def run(command): # $ threatModelSource[ai]=command return command @other.resource("notes://{name}") -def note(name): # $ mad-source__ai=name +def note(name): # $ threatModelSource[ai]=name return name @server.prompt() -def summarize(topic, style="short"): # $ mad-source__ai=topic mad-source__ai=style +def summarize(topic, style="short"): # $ threatModelSource[ai]=topic threatModelSource[ai]=style return topic + style -def fetch(url): # $ mad-source__ai=url +def fetch(url): # $ threatModelSource[ai]=url return url @@ -29,7 +30,7 @@ def fetch(url): # $ mad-source__ai=url def register(app: FastMCP): @app.tool() - def remove(path): # $ mad-source__ai=path + def remove(path): # $ threatModelSource[ai]=path return path @@ -39,9 +40,20 @@ def __init__(self): def register(self): @self.mcp.tool() - def lookup(host): # $ mad-source__ai=host + def lookup(host): # $ threatModelSource[ai]=host return host def not_registered(value): return value + + +# The SDK injects the `Context` parameter itself, so it is not chosen by the client. +@server.tool() +async def with_context(command, ctx: Context): # $ threatModelSource[ai]=command + return command + + +@server.tool() +async def with_generic_context(command, ctx: Context[ServerSession, None]): # $ threatModelSource[ai]=command + return command diff --git a/python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py index f301dc95e678..027cef8e84a7 100644 --- a/python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py +++ b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v1.py @@ -8,15 +8,15 @@ @server.call_tool() -async def call_tool(name, arguments): # $ mad-source__ai=name mad-source__ai=arguments +async def call_tool(name, arguments): # $ mad-source__ai=name threatModelSource[ai]=name mad-source__ai=arguments threatModelSource[ai]=arguments return arguments @lowlevel_server.read_resource() -async def read_resource(uri): # $ mad-source__ai=uri +async def read_resource(uri): # $ mad-source__ai=uri threatModelSource[ai]=uri return uri @module_server.get_prompt() -async def get_prompt(name, arguments): # $ mad-source__ai=name mad-source__ai=arguments +async def get_prompt(name, arguments): # $ mad-source__ai=name threatModelSource[ai]=name mad-source__ai=arguments threatModelSource[ai]=arguments return arguments diff --git a/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py index c66066bb86fc..b5d664006774 100644 --- a/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py +++ b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py @@ -2,15 +2,16 @@ async def handle_call_tool(ctx, params): - return params.arguments # $ mad-source__ai=params.arguments + if params.name == "run": # $ mad-source__ai=params.name threatModelSource[ai]=params.name + return params.arguments # $ mad-source__ai=params.arguments threatModelSource[ai]=params.arguments async def handle_read_resource(ctx, params): - return params.uri # $ mad-source__ai=params.uri + return params.uri # $ mad-source__ai=params.uri threatModelSource[ai]=params.uri async def handle_get_prompt(ctx, params): - return params.arguments # $ mad-source__ai=params.arguments + return params.name, params.arguments # $ mad-source__ai=params.name threatModelSource[ai]=params.name mad-source__ai=params.arguments threatModelSource[ai]=params.arguments async def not_registered(ctx, params): diff --git a/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py b/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py index f3637a2dc1d3..37a5624e5a86 100644 --- a/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py +++ b/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py @@ -1,5 +1,7 @@ from mcp.server import MCPServer from mcp.server.mcpserver import MCPServer as PackageMCPServer +from mcp.server.mcpserver import Context +from mcp.server.mcpserver.context import Context as ModuleContext from mcp.server.mcpserver.server import MCPServer as ModuleMCPServer server = MCPServer("test") @@ -8,22 +10,33 @@ @server.tool() -def run(command): # $ mad-source__ai=command +def run(command): # $ threatModelSource[ai]=command return command @package_server.resource("notes://{name}") -def note(name): # $ mad-source__ai=name +def note(name): # $ threatModelSource[ai]=name return name @module_server.prompt() -def summarize(topic): # $ mad-source__ai=topic +def summarize(topic): # $ threatModelSource[ai]=topic return topic -def fetch(url): # $ mad-source__ai=url +def fetch(url): # $ threatModelSource[ai]=url return url server.add_tool(fetch) + + +# The SDK injects the `Context` parameter itself, so it is not chosen by the client. +@server.tool() +async def with_context(command, ctx: Context): # $ threatModelSource[ai]=command + return command + + +@server.tool() +async def with_module_context(command, ctx: ModuleContext | None = None): # $ threatModelSource[ai]=command + return command