diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index dfc3ce4cbe..a60d6bc413 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -1161,6 +1161,27 @@ it("retains direct and literal computed call names", () => { ]); }); +it("requires WebMCP receiver and method names to occupy exact member segments", () => { + const result = analyzeCapturedWebBundle( + inspection(` + navigator.modelContext.registerTool({ name: "navigator" }); + local.modelContext.registerTool({ name: "exact nested" }); + const appmodelContext = { registerTool() {} }; + appmodelContext.registerTool({ name: "receiver suffix" }); + const app = { analyticsmodelContext: { registerTool() {} } }; + app.analyticsmodelContext.registerTool({ name: "nested suffix" }); + window["modelContext.registerTool"]({ name: "dotted property" }); + window["modelContext"].registerTool({ name: "computed receiver" }); + `), + ); + + expect(result.observations.webmcp_declarations).toEqual([ + expect.objectContaining({ name: "navigator" }), + expect.objectContaining({ name: "exact nested" }), + expect.objectContaining({ name: "computed receiver" }), + ]); +}); + it("recognizes tRPC procedures and named GraphQL operations in captured scripts", () => { const result = analyzeCapturedWebBundle( inspection(` diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index d28bf76e12..a54eac2997 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -11,6 +11,7 @@ import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemantic import { semanticStaticPropertyKey, semanticStaticPropertyName, + unwrapJavaScriptExpression, } from "./javascript/javascriptAstValues.js"; import { calleeName, @@ -208,13 +209,31 @@ const inspectCall = ( node, accumulator, }); - if ( - name.endsWith("modelContext.registerTool") || - name === "modelContext.registerTool" - ) + if (isWebMcpRegistrationCallee(node.callee)) addWebMcpDeclaration(script, node, accumulator); }; +/** Match exact WebMCP receiver and method properties without flattening keys. */ +const isWebMcpRegistrationCallee = (callee: t.Node): boolean => { + const method = unwrapJavaScriptExpression(callee).node; + if (!t.isMemberExpression(method) && !t.isOptionalMemberExpression(method)) + return false; + if ( + semanticStaticPropertyKey(method.property, method.computed) !== + "registerTool" + ) + return false; + + const receiver = unwrapJavaScriptExpression(method.object).node; + if (t.isIdentifier(receiver, { name: "modelContext" })) return true; + return ( + (t.isMemberExpression(receiver) || + t.isOptionalMemberExpression(receiver)) && + semanticStaticPropertyKey(receiver.property, receiver.computed) === + "modelContext" + ); +}; + const inspectRouteProperty = ( script: IncludedScript, node: t.ObjectProperty, diff --git a/tests/acceptance/browser/webMcpReceiverCli.test.ts b/tests/acceptance/browser/webMcpReceiverCli.test.ts new file mode 100644 index 0000000000..fb0a7e65c1 --- /dev/null +++ b/tests/acceptance/browser/webMcpReceiverCli.test.ts @@ -0,0 +1,80 @@ +import { expect } from "vitest"; + +import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "keeps WebMCP receiver segments exact through the public CLI", + async ({ cli }) => { + const source = ` + navigator.modelContext.registerTool({ name: "navigator" }); + const app = { analyticsmodelContext: { registerTool() {} } }; + app.analyticsmodelContext.registerTool({ name: "nested suffix" }); + window["modelContext.registerTool"]({ name: "dotted property" }); + `; + const browser = await startFakeCdpBrowser({ + commandResult: ({ method }) => + method === "Debugger.getScriptSource" + ? { scriptSource: source } + : undefined, + }); + try { + const result = await cli.run({ + arguments: [ + "analyze-web-bundle", + browser.endpoint, + "allowed-page", + "--allowed-origins", + browser.allowedOrigin, + "--observation-ms", + "0", + "--json", + ], + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + observations: { + webmcp_declarations: [ + expect.objectContaining({ + name: "navigator", + trust: "page-declared-untrusted", + }), + ], + }, + }, + }); + + const inspected = await cli.run({ + arguments: [ + "inspect-web-page", + browser.endpoint, + "allowed-page", + "--allowed-origins", + browser.allowedOrigin, + "--observation-ms", + "0", + "--include-script-sources", + "--json", + ], + }); + expect(inspected.exitCode, inspected.stdout).toBe(0); + expect(inspected.json).toMatchObject({ + normalized_result: { + scripts: { + items: expect.arrayContaining([ + expect.objectContaining({ + source: expect.objectContaining({ + artifact: expect.objectContaining({ text: source }), + }), + }), + ]), + }, + }, + }); + expect(browser.activeConnectionCount()).toBe(0); + } finally { + await browser.close(); + } + }, +);