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..3dc0a49e8bf2 --- /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 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 new file mode 100644 index 000000000000..d0aaeadb4446 --- /dev/null +++ b/python/ql/lib/semmle/python/frameworks/fastmcp.model.yml @@ -0,0 +1,22 @@ +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 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'] + # 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.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..f62b1925399f --- /dev/null +++ b/python/ql/lib/semmle/python/frameworks/mcp.model.yml @@ -0,0 +1,24 @@ +extensions: + - addsTo: + pack: codeql/python-all + extensible: sourceModel + data: + # `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,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'] + - ['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 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/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..6b068331ba90 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/fastmcp/test.py @@ -0,0 +1,234 @@ +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.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") +module_app = ModuleFastMCP("test") + + +@app.tool +def bare(command): # $ threatModelSource[ai]=command + return command + + +@server_app.tool(name="exec") +def called(command): # $ threatModelSource[ai]=command + return command + + +@module_app.resource("files://{path}") +def resource(path): # $ threatModelSource[ai]=path + return path + + +@app.prompt +def bare_prompt(topic): # $ threatModelSource[ai]=topic + return topic + + +@app.prompt() +def called_prompt(topic, style="short"): # $ threatModelSource[ai]=topic threatModelSource[ai]=style + return topic + style + + +def added(command): # $ threatModelSource[ai]=command + return command + + +app.add_tool(added) + + +def added_prompt(topic): # $ threatModelSource[ai]=topic + return topic + + +app.add_prompt(prompt=added_prompt) + + +# 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_module_function_tool(url): # $ threatModelSource[ai]=url + return url + + +def via_tool_module_tool(url): # $ threatModelSource[ai]=url + return url + + +def via_tool_module_function_tool(url): # $ threatModelSource[ai]=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) +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): # $ threatModelSource[ai]=command + return command + + +@tool() +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() + + +@app.tool +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 + + +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/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/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..2a3146563fb7 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/mcp/fastmcp_v1.py @@ -0,0 +1,59 @@ +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): # $ threatModelSource[ai]=command + return command + + +@other.resource("notes://{name}") +def note(name): # $ threatModelSource[ai]=name + return name + + +@server.prompt() +def summarize(topic, style="short"): # $ threatModelSource[ai]=topic threatModelSource[ai]=style + return topic + style + + +def fetch(url): # $ threatModelSource[ai]=url + return url + + +other.add_tool(fn=fetch) + + +def register(app: FastMCP): + @app.tool() + def remove(path): # $ threatModelSource[ai]=path + return path + + +class Service: + def __init__(self): + self.mcp = FastMCP("test") + + def register(self): + @self.mcp.tool() + 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 new file mode 100644 index 000000000000..027cef8e84a7 --- /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__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 threatModelSource[ai]=uri + return uri + + +@module_server.get_prompt() +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 new file mode 100644 index 000000000000..b5d664006774 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/mcp/lowlevel_v2.py @@ -0,0 +1,26 @@ +from mcp.server import Server + + +async def handle_call_tool(ctx, params): + 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 threatModelSource[ai]=params.uri + + +async def handle_get_prompt(ctx, params): + 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): + 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..37a5624e5a86 --- /dev/null +++ b/python/ql/test/library-tests/frameworks/mcp/mcpserver_v2.py @@ -0,0 +1,42 @@ +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") +package_server = PackageMCPServer("test") +module_server = ModuleMCPServer("test") + + +@server.tool() +def run(command): # $ threatModelSource[ai]=command + return command + + +@package_server.resource("notes://{name}") +def note(name): # $ threatModelSource[ai]=name + return name + + +@module_server.prompt() +def summarize(topic): # $ threatModelSource[ai]=topic + return topic + + +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