From 3d073294fe22001a5df32056f7d3c40a689dc4b3 Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Sun, 15 Nov 2026 12:10:33 +0100 Subject: [PATCH 1/4] fix: do not report RegExp.prototype.exec as command injection --- internal/analyzers/taint/regexp_exec_test.go | 63 +++++++++ internal/analyzers/taint/walk.go | 128 +++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 internal/analyzers/taint/regexp_exec_test.go diff --git a/internal/analyzers/taint/regexp_exec_test.go b/internal/analyzers/taint/regexp_exec_test.go new file mode 100644 index 0000000..d282098 --- /dev/null +++ b/internal/analyzers/taint/regexp_exec_test.go @@ -0,0 +1,63 @@ +package taint + +import ( + "testing" +) + +// The bare "exec" sink exists for `import { exec } from "child_process"`, but whole-identifier +// matching also caught RegExp.prototype.exec, so `PATTERN.exec(path)` on a tool argument was +// reported as HIGH command injection. A receiver this file binds to a regular expression is +// not a process API; everything else still matches. +func TestRegExpExecIsNotCommandInjection(t *testing.T) { + const header = `import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +` + register := func(body string) string { + return ` +export function register(server: McpServer): void { + server.tool("check", "Checks a path", async ({ path }: { path: string }) => { +` + body + ` + }); +} +` + } + + cases := []struct { + name string + src string + want int + }{ + {"regex literal constant", header + "const DRIVE = /^[A-Za-z]:/;\n" + register(" const m = DRIVE.exec(path);\n return m;"), 0}, + {"regex literal constant on its own line", header + "const UNC =\n /^\\\\\\\\[^\\\\]+/;\n" + register(" return UNC.exec(path);"), 0}, + {"inline regex literal", header + register(" return /^a/.exec(path);"), 0}, + {"new RegExp", header + "const RE = new RegExp(\"^a\");\n" + register(" return RE.exec(path);"), 0}, + {"RegExp call", header + "const RE = RegExp(\"^a\");\n" + register(" return RE.exec(path);"), 0}, + {"optional chain on a regex", header + "const RE = /^a/;\n" + register(" return RE?.exec(path);"), 0}, + + {"bare imported exec still fires", header + "import { exec } from \"child_process\";\n" + register(" exec(\"ls \" + path);"), 1}, + {"child_process namespace still fires", header + "import * as child_process from \"child_process\";\n" + register(" child_process.exec(\"ls \" + path);"), 1}, + {"required module still fires", header + "const cp = require(\"child_process\");\n" + register(" cp.exec(\"ls \" + path);"), 1}, + {"unknown receiver still fires", header + "import shell from \"shelljs\";\n" + register(" shell.exec(\"ls \" + path);"), 1}, + {"name rebound away from a regex still fires", header + "let runner = /^a/;\nrunner = require(\"child_process\");\n" + register(" runner.exec(\"ls \" + path);"), 1}, + + // A parameter, loop variable or catch binding with the same name shadows the + // module-level regex, so a file-wide regex binding must not speak for it + {"parameter shadowing a regex still fires", header + "import * as child_process from \"child_process\";\nconst RE = /^a/;\nfunction run(RE: any, p: string) { RE.exec(\"ls \" + p); }\n" + register(" run(child_process, path);"), 1}, + {"arrow parameter shadowing a regex still fires", header + "import * as child_process from \"child_process\";\nconst RE = /^a/;\nconst run = RE => (p: string) => RE.exec(\"ls \" + p);\n" + register(" run(child_process)(path);"), -1}, + {"loop variable shadowing a regex still fires", header + "import * as child_process from \"child_process\";\nconst RE = /^a/;\n" + register(" for (const RE of [child_process]) { RE.exec(\"ls \" + path); }"), 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := mustWriteDir(t, map[string]string{"server.ts": tc.src}) + res := analyze(t, dir) + got := categoryFindings(res, "command_injection") + if tc.want < 0 { + // The walker does not follow taint through a returned closure; this case only + // pins that the shadowing check does not panic on an unparenthesized parameter + return + } + if len(got) != tc.want { + t.Fatalf("want %d command_injection findings, got %d: %+v", tc.want, len(got), res.Findings) + } + }) + } +} diff --git a/internal/analyzers/taint/walk.go b/internal/analyzers/taint/walk.go index f1aedf0..ce1bab6 100644 --- a/internal/analyzers/taint/walk.go +++ b/internal/analyzers/taint/walk.go @@ -225,6 +225,11 @@ type ctx struct { // read at the call site that passes it objects map[fileAlias]string + // regexps holds JavaScript/TypeScript names this file binds only to a regular expression - + // a literal, new RegExp(...) or RegExp(...) - so the bare "exec" sink can tell + // RegExp.prototype.exec from child_process.exec + regexps map[fileAlias]bool + findings []model.Finding // seeded records which capability handlers actually received a @@ -249,6 +254,7 @@ func newCtx(runCtx context.Context, g *graph.Graph) *ctx { aliases: map[fileAlias]string{}, moduleAliases: map[fileAlias]string{}, objects: map[fileAlias]string{}, + regexps: map[fileAlias]bool{}, } if g.Root != "" { if r, err := os.OpenRoot(g.Root); err == nil { @@ -736,6 +742,11 @@ func (c *ctx) recordFileAliases(pf *parsedFile, file string) { moduleConflict := map[string]bool{} objects := map[string]string{} objectConflict := map[string]bool{} + // regexBind is whether each assignment to a name was a regular expression; a name also + // assigned anything else is not one + regexBind := map[string]bool{} + regexConflict := map[string]bool{} + tracksRegExps := pf.adapter.ID == language.JavaScript || pf.adapter.ID == language.TypeScript for _, imp := range c.g.Files[file].Imports { local := imp.Alias if local == "" { @@ -769,6 +780,15 @@ func (c *ctx) recordFileAliases(pf *parsedFile, file string) { case slices.Contains(pf.adapter.Grammar.AssignKinds, n.Kind()): targets := parser.AssignTargets(n, pf.src) rhs := parser.AssignValue(n, pf.src) + if tracksRegExps { + isRegExp := rhs != nil && len(targets) == 1 && isRegExpValue(pf, rhs) + for _, t := range targets { + if prev, seen := regexBind[t]; seen && prev != isRegExp { + regexConflict[t] = true + } + regexBind[t] = isRegExp + } + } // `const { exec: run } = require("node:child_process")` is // the CommonJS spelling of a renaming import, and the only // place the original name appears is the destructuring key @@ -813,6 +833,11 @@ func (c *ctx) recordFileAliases(pf *parsedFile, file string) { c.objects[fileAlias{file: file, name: name}] = text } } + for name, isRegExp := range regexBind { + if isRegExp && !regexConflict[name] && !conflict[name] { + c.regexps[fileAlias{file: file, name: name}] = true + } + } for name, path := range modules { // A name bound to a sink outright wins: it is the more specific // answer about the same identifier @@ -823,6 +848,105 @@ func (c *ctx) recordFileAliases(pf *parsedFile, file string) { } } +// isRegExpValue reports whether a JavaScript/TypeScript expression evaluates to a regular +// expression: a literal, new RegExp(...), or RegExp(...) +func isRegExpValue(pf *parsedFile, n *sitter.Node) bool { + n = unwrapParens(n) + switch n.Kind() { + case "regex": + return true + case "new_expression": + ctor := n.ChildByFieldName("constructor") + return ctor != nil && isRegExpConstructor(ctor.Utf8Text(pf.src)) + case "call_expression": + fn := n.ChildByFieldName("function") + return fn != nil && isRegExpConstructor(fn.Utf8Text(pf.src)) + } + return false +} + +func isRegExpConstructor(text string) bool { + text = strings.TrimSpace(text) + return text == "RegExp" || text == "globalThis.RegExp" +} + +func unwrapParens(n *sitter.Node) *sitter.Node { + for n.Kind() == "parenthesized_expression" && n.NamedChildCount() == 1 { + n = n.NamedChild(0) + } + return n +} + +// regExpReceiver reports whether call is `.exec(...)` on a value known to be a regular +// expression: an inline literal or constructor, or a name this file binds only to one. Any +// receiver it cannot place - a parameter, a property, an import - is left to the sink tables. +func (c *ctx) regExpReceiver(pf *parsedFile, file string, call *sitter.Node) bool { + if pf.adapter.ID != language.JavaScript && pf.adapter.ID != language.TypeScript { + return false + } + fn := call.ChildByFieldName("function") + if fn == nil || fn.Kind() != "member_expression" { + return false + } + prop := fn.ChildByFieldName("property") + if prop == nil || prop.Utf8Text(pf.src) != "exec" { + return false + } + obj := fn.ChildByFieldName("object") + if obj == nil { + return false + } + obj = unwrapParens(obj) + if isRegExpValue(pf, obj) { + return true + } + if obj.Kind() != "identifier" { + return false + } + name := obj.Utf8Text(pf.src) + return c.regexps[fileAlias{file: file, name: name}] && !shadowedAt(call, name, pf.src) +} + +// shadowedAt reports whether a function parameter, loop variable or catch binding around n +// declares name. Assignments are already folded into the file-wide binding; these bindings are +// not assignments, and one of them can hold anything, child_process included. +func shadowedAt(n *sitter.Node, name string, src []byte) bool { + for a := n.Parent(); a != nil; a = a.Parent() { + var bindings []*sitter.Node + switch a.Kind() { + case "function_declaration", "function_expression", "function", "arrow_function", + "method_definition", "generator_function_declaration", "generator_function": + bindings = append(bindings, a.ChildByFieldName("parameters"), a.ChildByFieldName("parameter")) + case "for_in_statement": + bindings = append(bindings, a.ChildByFieldName("left")) + case "catch_clause": + bindings = append(bindings, a.ChildByFieldName("parameter")) + } + for _, b := range bindings { + if b != nil && declaresIdent(b, name, src) { + return true + } + } + } + return false +} + +// declaresIdent reports whether any identifier in a binding subtree is name. It also visits +// default values and type annotations, which can only make it report a shadow that is not +// there - the safe direction, since a shadow keeps the sink. +func declaresIdent(n *sitter.Node, name string, src []byte) bool { + switch n.Kind() { + case "identifier", "shorthand_property_identifier_pattern": + return n.Utf8Text(src) == name + } + for i := uint(0); i < n.NamedChildCount(); i++ { + if ch := n.NamedChild(i); ch != nil && declaresIdent(ch, name, src) { + return true + } + } + return false +} + // isRequireCall reports whether n is `require()` func isRequireCall(pf *parsedFile, n *sitter.Node) bool { if !slices.Contains(pf.adapter.Grammar.CallKinds, n.Kind()) { @@ -1119,6 +1243,10 @@ func (c *ctx) handleCall(pf *parsedFile, file string, tainted map[string]taintIn } sink, sinkFound := matchSink(sinkText) + if sinkFound && sink.text == "exec" && sinkText == calleeText && c.regExpReceiver(pf, file, n) { + // RegExp.prototype.exec matches a string against a pattern; it runs nothing + sinkFound = false + } if !sinkFound && !resolvedInRepo { // methodSinkPatterns matches by method name alone, which a plain in-repo function sharing // that name would also match - a helper named execute() is not cursor.execute just because From 4c6212b32806840a8b72a21664575c5a4e839b87 Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Sun, 15 Nov 2026 12:10:33 +0100 Subject: [PATCH 2/4] fix: judge each occurrence of a word when reading a negated description --- internal/analyzers/behavioral/catalog.go | 49 ++++++++---- .../analyzers/behavioral/negation_test.go | 78 +++++++++++++++++++ 2 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 internal/analyzers/behavioral/negation_test.go diff --git a/internal/analyzers/behavioral/catalog.go b/internal/analyzers/behavioral/catalog.go index fadf569..a0b577f 100644 --- a/internal/analyzers/behavioral/catalog.go +++ b/internal/analyzers/behavioral/catalog.go @@ -171,13 +171,10 @@ func sentences(text string) []string { }) } -// negatedNear reports whether a negator appears in the negationWindow -// words immediately before word's occurrence in sentence -func negatedNear(sentence, word string) bool { - head, _, ok := strings.Cut(sentence, word) - if !ok { - return false - } +// negatedBefore reports whether a negator appears in the negationWindow +// words at the end of head, the sentence text before one occurrence of a +// category word +func negatedBefore(head string) bool { before := strings.Fields(head) if len(before) > negationWindow { before = before[len(before)-negationWindow:] @@ -206,20 +203,30 @@ func containsWordPrefix(text, word string) bool { } func matchWord(text, word string, prefixOnly bool) bool { + return len(wordOccurrences(text, word, prefixOnly)) > 0 +} + +// wordOccurrences returns the byte offset of every occurrence of word in text that matchWord +// would accept: whole words, or with prefixOnly, words that begin with it +func wordOccurrences(text, word string, prefixOnly bool) []int { + if word == "" { + return nil + } + var out []int for idx := strings.Index(text, word); idx != -1; { before := idx == 0 || !isWordByte(text[idx-1]) end := idx + len(word) after := prefixOnly || end >= len(text) || !isWordByte(text[end]) if before && after { - return true + out = append(out, idx) } next := strings.Index(text[idx+1:], word) if next == -1 { - return false + break } idx += 1 + next } - return false + return out } func isWordByte(b byte) bool { @@ -229,17 +236,27 @@ func isWordByte(b byte) bool { // matchCategory reports whether any of words appears in description as a plain, undenied // claim (declared), and separately whether any appears explicitly denied (contradicted) - // "never touches the network" and "touches the network" both mention the word, but only one -// of them is declaring the capability +// of them is declaring the capability. +// +// Every occurrence is judged at the position it was matched. A word that a sentence both +// negates and uses plainly is not a denial there: "you did not have internet access, ... this +// tool now grants you internet access" is about having it. Other words of the same category do +// not lift a denial, so "never contacts the network or any web api" still denies the network. func matchCategory(description string, words []string, stems bool) (declared, contradicted bool) { for _, sentence := range sentences(strings.ToLower(description)) { for _, w := range words { - if !matchWord(sentence, w, stems) { - continue + var negated, plain bool + for _, idx := range wordOccurrences(sentence, w, stems) { + if negatedBefore(sentence[:idx]) { + negated = true + } else { + plain = true + } } - if negatedNear(sentence, w) { - contradicted = true - } else { + if plain { declared = true + } else if negated { + contradicted = true } } } diff --git a/internal/analyzers/behavioral/negation_test.go b/internal/analyzers/behavioral/negation_test.go new file mode 100644 index 0000000..679ecc6 --- /dev/null +++ b/internal/analyzers/behavioral/negation_test.go @@ -0,0 +1,78 @@ +package behavioral + +import ( + "testing" + + "github.com/devlab-group/mcp-scanner/internal/testutil" +) + +// The modelcontextprotocol fetch server's description, which declares network access three +// times and was reported as "explicitly says the handler does not use the network" because one +// clause, about the model, reads "you did not have internet access" +const fetchDescription = "Fetches a URL from the internet and optionally extracts its contents as markdown.\n\n" + + "Although originally you did not have internet access, and were advised to refuse and tell the user this, " + + "this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that." + +func TestDeclaredCapabilities_Negation(t *testing.T) { + cases := []struct { + name string + desc string + declared, contradicted bool + }{ + {"plain denial", "Counts words. This tool has no network access.", false, true}, + {"denial beside an affirmative sentence", "Counts the words of a web page you paste. This tool has no network access.", true, true}, + {"every mention in the sentence denied", "This tool never sends requests to any remote server.", false, true}, + + // negatedNear used strings.Cut, a plain substring search, while the word itself was + // matched on word boundaries: "api" was looked up inside "rapid", right after "not" + {"category word inside a longer word", "Not rapid at all, this tool pulls each record from the remote catalog api one by one.", true, false}, + // Only the first occurrence was checked for a negator; the second, affirmative one in the + // same sentence was never looked at + {"same word denied and affirmed in one sentence", fetchDescription, true, false}, + // Another category word used plainly does not lift a denial: "api" here is past the + // negation window, but "network" is still explicitly denied + {"denial kept beside another plain category word", "Reads a local note; never contacts the network or any web api.", true, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + declared, contradicted := matchCategory(tc.desc, declaredWords[catNetwork], false) + if declared != tc.declared || contradicted != tc.contradicted { + t.Fatalf("declared=%v contradicted=%v, want declared=%v contradicted=%v", declared, contradicted, tc.declared, tc.contradicted) + } + }) + } +} + +func TestDeclaredCapabilities_StemNegationUsesTheMatchedWord(t *testing.T) { + // "run" is a stem: matched as a word prefix, it must not be looked up inside "truncate" + desc := "Does not truncate anything at all in the output; it can run the configured formatter." + declared, contradicted := matchCategory(desc, declaredWords[catProcess], true) + if !declared || contradicted { + t.Fatalf("declared=%v contradicted=%v, want declared=true contradicted=false", declared, contradicted) + } +} + +func TestMismatch_FetchDescriptionIsNotAContradiction(t *testing.T) { + dir := testutil.WriteTree(t, map[string]string{ + "handler.go": `package main + +import "net/http" + +func FetchHandler(ctx Context, args FetchArgs) (string, error) { + http.Get(args.URL) + return "", nil +} +`, + "register.go": "package main\n\nfunc Register(s *mcp.Server) {\n\ts.AddTool(&mcp.Tool{Name: \"fetch\", Description: `" + fetchDescription + "`}, FetchHandler)\n}\n", + }) + res := run(t, dir) + requireCompared(t, "fetch description", res) + for _, f := range res.Findings { + if f.RuleID == "behavioral.contradicted_network" { + t.Fatalf("a description that declares network access must not be read as denying it, got %+v", f) + } + } + if len(res.Findings) != 0 { + t.Fatalf("want no findings for a handler that does what its description says, got %+v", res.Findings) + } +} From b60ae3c809666f74dd4aeed5ae0022cd9c37b470 Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Sun, 15 Nov 2026 12:10:33 +0100 Subject: [PATCH 3/4] fix: do not register a project's own function named tool as an MCP tool --- .../source/local_helper_registration_test.go | 158 ++++++++++++++++++ internal/source/registration/match.go | 45 ++++- internal/source/registration/registration.go | 2 +- 3 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 internal/source/local_helper_registration_test.go diff --git a/internal/source/local_helper_registration_test.go b/internal/source/local_helper_registration_test.go new file mode 100644 index 0000000..2e5c2e9 --- /dev/null +++ b/internal/source/local_helper_registration_test.go @@ -0,0 +1,158 @@ +package source + +import ( + "context" + "slices" + "testing" + + "github.com/devlab-group/mcp-scanner/internal/testutil" +) + +const fastMCPServer = `from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("fetch") + + +@mcp.tool() +def fetch(url: str) -> str: + """Fetches a URL.""" + return url +` + +// A tree that imports an MCP SDK lets a receiver-less tool(...) call with two or more arguments +// count as a registration. A project's own function that happens to be named tool - the fetch +// server's test helper tool(bindir, "node", body), which writes a fake executable - is not one, +// and each of its call sites was reported as a separate, unanalyzable MCP tool. +func TestDiscover_LocalHelperNamedToolIsNotARegistration(t *testing.T) { + cases := []struct { + name string + files map[string]string + want []string + // contains checks only that every name in want was found, for a case whose other + // capabilities this change does not touch + contains bool + }{ + { + name: "helper defined and called in the same file", + files: map[string]string{ + "server.py": fastMCPServer, + "tests/test_subprocess.py": `from pathlib import Path + + +def tool(bindir: Path, name: str, body: str) -> Path: + path = bindir / name + path.write_text(body) + return path + + +def test_node(tmp_path): + tool(tmp_path, "node", "echo v20") + tool(tmp_path, "npm", "exit 1") +`, + }, + want: []string{"fetch"}, + }, + { + name: "helper defined in another file", + files: map[string]string{ + "server.py": fastMCPServer, + "tests/helpers.py": "def tool(bindir, name, body):\n return bindir / name\n", + "tests/test_one.py": "from tests.helpers import *\n\n\ndef test_one(tmp_path):\n tool(tmp_path, \"node\", \"echo\")\n", + }, + want: []string{"fetch"}, + }, + { + name: "server method still registers when a local function shares its name", + files: map[string]string{ + "server.ts": `import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +export const server = new McpServer({ name: "x", version: "1.0.0" }); +server.tool("echo", "Echoes text back", async ({ text }) => ({ content: [{ type: "text", text }] })); +`, + "util.ts": "export function tool(a: string, b: string) { return a + b; }\ntool(\"node\", \"npm\");\n", + }, + want: []string{"echo"}, + }, + { + name: "a local tool wrapper that registers is still found", + files: map[string]string{ + "server.ts": `import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +export const server = new McpServer({ name: "x", version: "1.0.0" }); +function tool(name: string, description: string, handler: any) { + server.tool(name, description, handler); +} +tool("wipe", "Deletes the workspace", async () => ({ content: [] })); +`, + }, + want: []string{"wipe"}, + contains: true, + }, + { + name: "a hand-rolled registry with an inline handler still registers", + files: map[string]string{ + "server.ts": `import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { readFileSync } from "fs"; +const tools: any[] = []; +function tool(name: string, description: string, handler: any) { + tools.push({ name, description, handler }); +} +tool("read_note", "Reads a note", async ({ path }: { path: string }) => readFileSync(path, "utf8")); +`, + }, + want: []string{"read_note"}, + }, + { + name: "a hand-rolled registry with a function reference still registers", + files: map[string]string{ + "server.py": `from mcp.server.lowlevel import Server + +TOOLS = {} + + +def tool(name, description, handler): + TOOLS[name] = (description, handler) + + +def lookup(q): + return q + + +tool("lookup", "Looks a record up", lookup) +`, + }, + want: []string{"lookup"}, + }, + { + name: "an imported tool function still registers", + files: map[string]string{ + "server.py": "from mcp.server.fastmcp import FastMCP\nfrom sdk.registry import tool\n\n\ndef lookup(q):\n return q\n\n\ntool(\"lookup\", \"Looks a record up\", lookup)\n", + "helpers.py": "def tool(bindir, name, body):\n return bindir / name\n", + }, + want: []string{"lookup"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := testutil.WriteTree(t, tc.files) + g, _, err := Build(context.Background(), dir) + if err != nil { + t.Fatalf("Build: %v", err) + } + var got []string + for _, c := range g.Capabilities { + got = append(got, c.Name) + } + slices.Sort(got) + if tc.contains { + for _, w := range tc.want { + if !slices.Contains(got, w) { + t.Fatalf("capabilities = %q, want %q among them", got, w) + } + } + return + } + if !slices.Equal(got, tc.want) { + t.Fatalf("capabilities = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/source/registration/match.go b/internal/source/registration/match.go index 4018494..1c3a189 100644 --- a/internal/source/registration/match.go +++ b/internal/source/registration/match.go @@ -68,10 +68,51 @@ func hasServerishReceiver(calleeText string) bool { return false } +// callsLocalHelper reports whether c is a receiver-less call - `tool(...)`, not `server.tool(...)` - +// to a function the project defines itself, with no function among its arguments. A test helper +// named tool(bindir, "node", body) would otherwise register a "node" tool at every call site. +// +// parser.Resolve matches on the final identifier alone, so a resolved call through a receiver +// says nothing about which tool method runs; a bare name does. A file that imports that bare name +// from a package (not a relative path) keeps the call, since the import, not the project's own +// definition, may be what the name refers to there. A call that passes a function - inline, or +// one defined in this tree - also keeps it: that is the shape of a hand-rolled registry, +// `tool("read_note", "...", async (args) => ...)`, whose handler must still be analyzed. +func callsLocalHelper(g *graph.Graph, idx index, c graph.Call) bool { + if !c.Resolved || c.CalleeText != parser.LastSegment(c.CalleeText) { + return false + } + sym := g.Symbols[c.Callee] + if sym == nil || sym.Name != c.CalleeText { + return false + } + if f := g.Files[c.File]; f != nil { + for _, imp := range f.Imports { + local := imp.Alias + if local == "" { + local = parser.LastSegment(imp.Path) + } + if local == c.CalleeText && !strings.HasPrefix(imp.Path, ".") { + return false + } + } + } + if h := classifyArgs(c.Args).Handler; h != nil { + if h.HandlerID != "" { + return false + } + if _, ok := idx.resolve(h.Text); ok { + return false + } + } + return true +} + // classifyCall recognizes direct SDK registration calls. Generic lowercase methods require // registration arity plus either an MCP import anywhere in the tree or a server-like receiver; // the receiver signal covers helpers handed a server object without importing its SDK. -func classifyCall(c graph.Call, inMCPTree bool) (graph.CapabilityKind, bool) { +// localHelper (see callsLocalHelper) rules the generic names out for the project's own functions. +func classifyCall(c graph.Call, inMCPTree, localHelper bool) (graph.CapabilityKind, bool) { seg := parser.LastSegment(c.CalleeText) switch normalize(seg) { case "addtool", "registertool": @@ -81,7 +122,7 @@ func classifyCall(c graph.Call, inMCPTree bool) (graph.CapabilityKind, bool) { case "addresource", "registerresource", "addresourcetemplate", "registerresourcetemplate": return graph.CapabilityResource, true } - if !bareRegistration(inMCPTree, c.CalleeText, len(c.Args)) { + if localHelper || !bareRegistration(inMCPTree, c.CalleeText, len(c.Args)) { return "", false } switch seg { diff --git a/internal/source/registration/registration.go b/internal/source/registration/registration.go index aa7cc4c..985224e 100644 --- a/internal/source/registration/registration.go +++ b/internal/source/registration/registration.go @@ -215,7 +215,7 @@ func Discover(g *graph.Graph) { continue } reqHandlers.addCall(g, idx, c) - kind, ok := classifyCall(c, mcpTree) + kind, ok := classifyCall(c, mcpTree, callsLocalHelper(g, idx, c)) if !ok { continue } From fd43dbd36f3e649ab90def7c626b83a3618b9acd Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Sun, 15 Nov 2026 13:50:27 +0100 Subject: [PATCH 4/4] docs: link the v1.1.0 release notes and stop naming a placeholder path --- .github/release-notes.sh | 2 +- Makefile | 2 +- README.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/release-notes.sh b/.github/release-notes.sh index 8183a10..dc562c0 100644 --- a/.github/release-notes.sh +++ b/.github/release-notes.sh @@ -1,6 +1,6 @@ #!/bin/sh # release-notes.sh TAG prints the GitHub release notes for TAG: the optional -# introduction in docs/release-notes/TAG.md, the commits since the previous tag +# introduction in docs/release-notes/.md, the commits since the previous tag # grouped by their conventional-commit type, and how to verify the binaries. # The introduction is read from the tagged tree, which means it is committed # before the tag is pushed. diff --git a/Makefile b/Makefile index b2455ca..c7b87a9 100644 --- a/Makefile +++ b/Makefile @@ -124,7 +124,7 @@ dist: cd dist && $(SHA256) $(BIN)_* > SHA256SUMS # Print the notes the release job publishes for TAG, with the introduction from -# docs/release-notes/TAG.md when that file exists +# docs/release-notes/.md when that file exists release-notes: @sh .github/release-notes.sh $(TAG) diff --git a/README.md b/README.md index 3c81524..22989e3 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ its sandbox. - [Result schema](docs/result-schema.md) - [Command line](docs/cli.md) - [HTTP API](docs/api.md) +- [Release notes for v1.1.0](docs/release-notes/v1.1.0.md) ## Development