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
4 changes: 4 additions & 0 deletions javascript/ql/lib/change-notes/2026-10-03-mcp-models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
category: minorAnalysis
---
* Added modeling of the `@modelcontextprotocol/sdk` and `@modelcontextprotocol/server` (the official Model Context Protocol SDK) and `fastmcp` npm packages. The arguments of MCP tool, resource and prompt callbacks and the requests that handlers of the low-level `Server` receive are now sources of the opt-in `ai` threat model, as their values are produced by a language model. The request headers and the bearer token that the SDK passes to a callback are sources of the `remote` threat model.
4 changes: 4 additions & 0 deletions javascript/ql/lib/change-notes/2026-10-03-zod.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
category: minorAnalysis
---
* Added flow summaries for schema parsing with the `zod` npm package: data now flows from the argument of `parse`, `safeParse`, `parseAsync` and `safeParseAsync` to the parsed result.
15 changes: 15 additions & 0 deletions javascript/ql/lib/ext/fastmcp.model.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Sources for MCP servers built with the npm package "fastmcp": the arguments of tools, resource
# templates and prompts.
extensions:
- addsTo:
pack: codeql/javascript-all
extensible: typeModel
data:
- ["fastmcp.FastMCP", "fastmcp", "Member[FastMCP].Instance"]

- addsTo:
pack: codeql/javascript-all
extensible: sourceModel
data:
- ["fastmcp.FastMCP", "Member[addTool].Argument[0].Member[execute].Parameter[0]", "ai"]
- ["fastmcp.FastMCP", "Member[addResourceTemplate,addPrompt].Argument[0].Member[load].Parameter[0]", "ai"]
36 changes: 36 additions & 0 deletions javascript/ql/lib/ext/mcp.model.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Sources for MCP servers built with the TypeScript SDK: "@modelcontextprotocol/sdk" (1.x) and
# "@modelcontextprotocol/server" (2.x). The arguments of tool, prompt and resource callbacks are
# input the server receives through MCP, and so are the transport headers and the bearer token the
# SDK hands to a callback.
#
# A package name that contains a dot must be quoted with single quotes; otherwise CodeQL reads the
# text after the first dot as a type name.
extensions:
- addsTo:
pack: codeql/javascript-all
extensible: typeModel
data:
- ["@modelcontextprotocol/sdk.McpServer", "'@modelcontextprotocol/sdk/server/mcp.js'", "Member[McpServer].Instance"]
- ["@modelcontextprotocol/sdk.McpServer", "@modelcontextprotocol/sdk/server/mcp", "Member[McpServer].Instance"]
- ["@modelcontextprotocol/sdk.McpServer", "@modelcontextprotocol/server", "Member[McpServer].Instance"]
- ["@modelcontextprotocol/sdk.McpServer", "'@modelcontextprotocol/sdk/server/mcp.js'.McpServer", ""]
- ["@modelcontextprotocol/sdk.Server", "'@modelcontextprotocol/sdk/server/index.js'", "Member[Server].Instance"]
- ["@modelcontextprotocol/sdk.Server", "@modelcontextprotocol/sdk/server/index", "Member[Server].Instance"]
- ["@modelcontextprotocol/sdk.Server", "@modelcontextprotocol/server", "Member[Server].Instance"]
- ["@modelcontextprotocol/sdk.Server", "@modelcontextprotocol/sdk.McpServer", "Member[server]"]

- addsTo:
pack: codeql/javascript-all
extensible: sourceModel
data:
# High-level server: the callback is the last argument; its first parameter holds the arguments
- ["@modelcontextprotocol/sdk.McpServer", "Member[tool,registerTool,prompt,registerPrompt].Argument[1..].Parameter[0]", "ai"]
- ["@modelcontextprotocol/sdk.McpServer", "Member[resource,registerResource].Argument[1..].Parameter[0,1]", "ai"]
# Transport headers and the bearer token: `extra` in 1.x, `ctx` in 2.x
- ["@modelcontextprotocol/sdk.McpServer", "Member[tool,registerTool,prompt,registerPrompt].Argument[1..].Parameter[1,2].Member[requestInfo].Member[headers]", "remote"]
- ["@modelcontextprotocol/sdk.McpServer", "Member[tool,registerTool,prompt,registerPrompt].Argument[1..].Parameter[1,2].Member[authInfo].Member[token]", "remote"]
- ["@modelcontextprotocol/sdk.McpServer", "Member[tool,registerTool,prompt,registerPrompt].Argument[1..].Parameter[1,2].Member[http].Member[req].Member[headers].Member[get].ReturnValue", "remote"]
- ["@modelcontextprotocol/sdk.McpServer", "Member[tool,registerTool,prompt,registerPrompt].Argument[1..].Parameter[1,2].Member[http].Member[authInfo].Member[token]", "remote"]
# Low-level server: request handlers receive the request; custom methods (2.x) its params
- ["@modelcontextprotocol/sdk.Server", "Member[setRequestHandler].Argument[1].Parameter[0].Member[params]", "ai"]
- ["@modelcontextprotocol/sdk.Server", "Member[setRequestHandler].Argument[2].Parameter[0]", "ai"]
16 changes: 16 additions & 0 deletions javascript/ql/lib/ext/zod.model.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
extensions:
- addsTo:
pack: codeql/javascript-all
extensible: typeModel
data:
- ["zod", "zod/v3", ""]
- ["zod", "zod/v4", ""]

- addsTo:
pack: codeql/javascript-all
extensible: summaryModel
data:
- ["zod", "Fuzzy.Member[parse]", "Argument[0]", "ReturnValue", "value"]
- ["zod", "Fuzzy.Member[safeParse]", "Argument[0]", "ReturnValue.Member[data]", "value"]
- ["zod", "Fuzzy.Member[parseAsync]", "Argument[0]", "ReturnValue.Awaited", "value"]
- ["zod", "Fuzzy.Member[safeParseAsync]", "Argument[0]", "ReturnValue.Awaited.Member[data]", "value"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { FastMCP } from "fastmcp";
import { z } from "zod";

declare function SINK(value: unknown): void;

const server = new FastMCP({ name: "test", version: "1.0.0" });

server.addTool({
name: "run",
parameters: z.object({ command: z.string() }),
execute: async (args) => { // $ threat-source=ai
SINK(args.command); // $ hasFlow
return "";
},
});

server.addResourceTemplate({
uriTemplate: "note://{name}",
name: "note",
arguments: [{ name: "name" }],
async load({ name }) { // $ threat-source=ai
SINK(name); // $ hasFlow
return { text: "" };
},
});

server.addPrompt({
name: "summarize",
arguments: [{ name: "topic" }],
load: async ({ topic }) => { // $ threat-source=ai
SINK(topic); // $ hasFlow
return "";
},
});

// A static resource takes no arguments: no source.
server.addResource({
uri: "file:///log",
name: "log",
async load() {
return { text: "" };
},
});
112 changes: 112 additions & 0 deletions javascript/ql/test/library-tests/threat-models/sources/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { McpServer as McpServerNoSuffix } from "@modelcontextprotocol/sdk/server/mcp";
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { McpServer as McpServerV2, Server as ServerV2 } from "@modelcontextprotocol/server";
import { z } from "zod";

declare function SINK(value: unknown): void;

const server = new McpServer({ name: "test", version: "1.0.0" });

// Tools and prompts: the first parameter of the callback holds the arguments.
server.tool("run", { command: z.string() }, async ({ command }) => { // $ threat-source=ai
SINK(command); // $ hasFlow
return { content: [] };
});

server.tool("read", "Read a file", { path: z.string() }, async (args) => { // $ threat-source=ai
SINK(args.path); // $ hasFlow
return { content: [] };
});

server.registerTool("fetch", { inputSchema: { url: z.string() } }, async ({ url }) => { // $ threat-source=ai
SINK(url); // $ hasFlow
return { content: [] };
});

async function namedHandler({ target }: { target: string }) { // $ threat-source=ai
SINK(target); // $ hasFlow
return { content: [] };
}
server.tool("remove", { target: z.string() }, namedHandler);

server.prompt("summarize", { topic: z.string() }, ({ topic }) => { // $ threat-source=ai
SINK(topic); // $ hasFlow
return { messages: [] };
});

server.registerPrompt("explain", { argsSchema: { file: z.string() } }, ({ file }) => { // $ threat-source=ai
SINK(file); // $ hasFlow
return { messages: [] };
});

// Resources: the URI and the variables of the template.
server.resource("note", new ResourceTemplate("note://{name}", { list: undefined }), async (uri, { name }) => { // $ threat-source=ai
SINK(uri.href); // $ hasFlow
SINK(name); // $ hasFlow
return { contents: [] };
});

server.registerResource("page", new ResourceTemplate("page://{host}", { list: undefined }), {}, async (uri, variables) => { // $ threat-source=ai
SINK(variables.host); // $ hasFlow
return { contents: [] };
});

// The request headers and the bearer token that the SDK hands to a callback.
server.tool("forward", { id: z.string() }, async (_args, extra) => { // $ threat-source=ai
SINK(extra.requestInfo?.headers["x-target"]); // $ hasFlow threat-source=remote
SINK(extra.authInfo?.token); // $ hasFlow threat-source=remote
SINK(extra.sessionId);
return { content: [] };
});

// The low-level server: handlers receive the request.
const lowLevel = new Server({ name: "test", version: "1.0.0" }, { capabilities: { tools: {} } });
lowLevel.setRequestHandler(CallToolRequestSchema, async (request) => {
SINK(request.params.arguments?.command); // $ hasFlow threat-source=ai
return { content: [] };
});

server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
SINK(request.params.name); // $ hasFlow threat-source=ai
return { content: [] };
});

// An import path without the file suffix, and a server that arrives as a typed parameter.
new McpServerNoSuffix({ name: "test", version: "1.0.0" }).tool("run", { command: z.string() }, async ({ command }) => { // $ threat-source=ai
SINK(command); // $ hasFlow
return { content: [] };
});

export function register(target: McpServer) {
target.tool("run", { command: z.string() }, async ({ command }) => { // $ threat-source=ai
SINK(command); // $ hasFlow
return { content: [] };
});
}

// Version 2 of the SDK: one package, and a context object instead of `extra`.
const serverV2 = new McpServerV2({ name: "test", version: "2.0.0" });
serverV2.registerTool("fetch", { inputSchema: z.object({ url: z.string() }) }, async ({ url }, ctx) => { // $ threat-source=ai
SINK(url); // $ hasFlow
SINK(ctx.http?.req.headers.get("x-target")); // $ hasFlow threat-source=remote
SINK(ctx.http?.authInfo?.token); // $ hasFlow threat-source=remote
return { content: [] };
});

const lowLevelV2 = new ServerV2({ name: "test", version: "2.0.0" });
lowLevelV2.setRequestHandler("tools/call", async (request) => {
SINK(request.params.arguments); // $ hasFlow threat-source=ai
return { content: [] };
});

lowLevelV2.setRequestHandler("acme/search", { params: z.object({ query: z.string() }), result: z.object({}) }, async (params) => { // $ threat-source=ai
SINK(params.query); // $ hasFlow
return {};
});

// Not registered with a server: no source.
export function notAHandler(command: string) {
SINK(command);
}
30 changes: 30 additions & 0 deletions javascript/ql/test/library-tests/threat-models/sources/zod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { z } from "zod";
import * as z4 from "zod/v4";

declare function SINK(value: unknown): void;

const raw = JSON.parse(process.argv[2]); // $ threat-source=commandargs

const Options = z.object({ command: z.string() });
const NextOptions = z4.object({ command: z4.string() });

SINK(Options.parse(raw).command); // $ hasFlow
SINK(NextOptions.parse(raw).command); // $ hasFlow

const checked = Options.safeParse(raw);
if (checked.success) {
SINK(checked.data.command); // $ hasFlow
}
SINK(checked.error);

async function later() {
SINK((await Options.parseAsync(raw)).command); // $ hasFlow

const result = await Options.safeParseAsync(raw);
if (result.success) {
SINK(result.data.command); // $ hasFlow
}
}

// A value that did not come from a source stays clean.
SINK(Options.parse({ command: "ls" }).command);