From f11eccd15d1090c549ffae7dbda36c3fb3012436 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Wed, 11 Nov 2026 16:17:07 -0800 Subject: [PATCH 1/2] fix(mcp): resolve dotted search paths relative to namespaces --- .github/workflows/fork-ci.yml | 5 +- e2e/test-plan.ts | 10 ++ e2e/tests/mcp-search-concise.spec.ts | 163 +++++++++++++++++++++ packages/mcp/src/implementation/execute.ts | 49 ++++--- 4 files changed, 205 insertions(+), 22 deletions(-) diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index 0822748f0a..33bdd8f603 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -44,7 +44,7 @@ jobs: done # Self-host scenarios for scoped-connection widening, OAuth registration, app - # User-Agent, and load-failure containment. Motel's collector build requires + # User-Agent, and load-failure containment, plus Local MCP search. Motel's collector build requires # Bun 1.4.4, so this job does not share check's 1.3.11 pin. No extra secrets: # the runner mints its own API key. CI disables the e2e prepare cache. security-e2e: @@ -67,3 +67,6 @@ jobs: - name: Security scenarios run: | bun run e2e:self-host -- --test-name 'application_type|fetch without a User-Agent names Executor|cannot create an all-apps connection|cannot widen a connection or mint|fails to load fails only its own reads' + - name: MCP search scenarios + run: | + bun run e2e:local --test-name 'MCP search resolves namespace-relative dotted paths|A broad MCP tool search returns concise pages' diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index abbc1630f8..a17f0142e8 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -1050,6 +1050,16 @@ export const scenarios = plan({ local: na("Hosted version API scenario"), }, }, + mcpSearchDottedPaths: { + file: "mcp-search-concise.spec.ts", + title: + "MCP search resolves namespace-relative dotted paths without changing ranked queries or scope", + targets: { + local: scheduled, + "self-host": na("Shared MCP search; covered on local"), + cloud: na("Shared MCP search; covered on local"), + }, + }, mcpSearchConcise: { file: "mcp-search-concise.spec.ts", title: diff --git a/e2e/tests/mcp-search-concise.spec.ts b/e2e/tests/mcp-search-concise.spec.ts index 34c55eeb34..ea13ce0b71 100644 --- a/e2e/tests/mcp-search-concise.spec.ts +++ b/e2e/tests/mcp-search-concise.spec.ts @@ -10,6 +10,8 @@ import { Target } from "../support/platform.ts"; import { connectLocalAccount } from "../support/app-accounts.ts"; import { scenarios } from "../test-plan.ts"; import { appsManifest } from "../support/apps-release.ts"; +import { pairLocalOperator } from "../support/mcp-consent.ts"; +import { ConnectionView, consentTo, revokeClientGrants } from "../support/mcp-connections.ts"; /** UTF-8 bytes of JSON one search page holds at most. */ const pageBytes = 25_000; @@ -74,6 +76,7 @@ const Page = Schema.Struct({ next: Schema.NullOr( Schema.Struct({ namespace: Schema.optional(Schema.String), + query: Schema.optional(Schema.String), limit: Schema.optional(Schema.Number), offset: Schema.Number, }), @@ -98,6 +101,166 @@ const Executed = (value: Schema.Codec) => }); layer(TestLive, { excludeTestServices: true })("MCP search concise", (it) => { + it.effect(scenarios.mcpSearchDottedPaths.title, (context) => + withCase( + context, + Effect.gen(function* () { + const target = yield* Target, + api = yield* Api, + mcp = yield* McpClient; + const operator = yield* pairLocalOperator; + const headers = { authorization: `Bearer ${Redacted.value(target.apiKey)}` }; + const deployed = yield* operator.send( + "POST", + "/v1/apps/deploy", + { + owner: "local", + name: `Dotted search ${randomUUID().slice(0, 8)}`, + files: [ + { + path: "index.ts", + content: ` +import { defineApp, object, query, router } from "apps"; +const list = () => query({ description: "List tenants and their history.", input: object({}) }, async () => []); +export default defineApp({}, async () => ({ tools: router({ + tenants: router({ list: list(), history: list(), listAll: list() }), + graph: router(Object.fromEntries(Array.from({ length: 6 }, (_, n) => ["list" + n, list()]))), +}) })); +`, + }, + appsManifest, + ], + }, + headers, + ); + expect(deployed.status, JSON.stringify(deployed.body)).toBe(200); + const { app } = yield* body( + Schema.Struct({ app: Schema.Struct({ id: Schema.String, slug: Schema.String }) }), + deployed, + ); + yield* Effect.addFinalizer(() => + operator.send("DELETE", `/v1/apps/${app.id}`, undefined, headers).pipe(Effect.orDie), + ); + const client = yield* mcp.connect(target.apiKey, "dotted-search"); + const search = + (connection: typeof client) => + (input: { namespace?: string; query?: string; limit?: number; offset?: number }) => + connection + .use(`Search ${JSON.stringify(input)}`, (client, signal) => + client.callTool({ name: "search", arguments: input }, undefined, { signal }), + ) + .pipe( + Effect.flatMap((result) => + Schema.decodeUnknownEffect(Page)(result.structuredContent), + ), + ); + const full = search(client); + const root = `tools[${JSON.stringify(app.slug)}]`; + const exactPath = `${root}.tenants.list`; + const tenantPaths = ["list", "history", "listAll"] + .map((name) => `${root}.tenants.${name}`) + .sort(); + const allPaths = [ + ...tenantPaths, + ...Array.from({ length: 6 }, (_, n) => `${root}.graph.list${n}`), + ].sort(); + + // Both public discovery entry points resolve the relative path, despite noisy list tools. + for (const namespace of [app.slug, root]) { + for (const query of ["tenants.list", `${app.slug}.tenants.list`, exactPath]) { + const page = yield* full({ namespace, query, limit: 5 }); + expect(page.items.map((item) => item.path)).toEqual([exactPath]); + expect(page.remaining).toBe(0); + expect(page.next).toBeNull(); + const exhausted = yield* full({ namespace, query, offset: 1, limit: 5 }); + expect(exhausted.items).toEqual([]); + expect(exhausted.remaining).toBe(0); + expect(exhausted.next).toBeNull(); + } + } + const executed = yield* client + .use("Relative dotted search inside execute", (client, signal) => + client.callTool( + { + name: "execute", + arguments: { + code: `return await tools.search({ namespace: ${JSON.stringify(app.slug)}, query: "tenants.list", limit: 5 });`, + }, + }, + undefined, + { signal }, + ), + ) + .pipe( + Effect.flatMap((result) => + Schema.decodeUnknownEffect(Executed(Page))(result.structuredContent), + ), + ); + expect(executed.execution.value.items.map((item) => item.path)).toEqual([exactPath]); + expect(executed.execution.value.remaining).toBe(0); + expect(executed.execution.value.next).toBeNull(); + + // Bare words and prose still rank every match, even when a leaf has that exact name. + // An absent dotted path also falls back to that same ranked, paginated search. + for (const [namespace, query, expected] of [ + [`${app.slug}.tenants`, "list", tenantPaths], + [`${app.slug}.tenants`, "history", tenantPaths], + [app.slug, "tenants list", allPaths], + [app.slug, "missing.list", allPaths], + [app.slug, "tenants..list", allPaths], + ] as const) { + const first = yield* full({ namespace, query, limit: 1 }); + expect(first.items).toHaveLength(1); + expect(expected).toContain(first.items[0]?.path); + expect(first.remaining).toBe(expected.length - 1); + expect(first.next).toEqual({ namespace, query, limit: 1, offset: 1 }); + const rest = yield* full({ namespace, query, limit: 100, offset: 1 }); + expect([...first.items, ...rest.items].map((item) => item.path).sort()).toEqual(expected); + expect(rest.remaining).toBe(0); + expect(rest.next).toBeNull(); + } + // Neither a full path nor a relative candidate can escape the nested router's `below`. + for (const query of ["tenants.list", `${app.slug}.tenants.list`, exactPath]) { + const page = yield* full({ namespace: `${app.slug}.graph`, query, limit: 100 }); + expect(page.items.map((item) => item.path).sort()).toEqual( + allPaths.filter((path) => path.startsWith(`${root}.graph.`)), + ); + expect(page.remaining).toBe(0); + expect(page.next).toBeNull(); + } + + const clients: string[] = []; + yield* revokeClientGrants(operator, () => clients); + const created = yield* api.request(operator, "POST", "/dashboard/api/mcp/connections", { + id: randomUUID(), + name: "Only tenant history", + apps: [ + { + app: app.id, + runsAs: [{ kind: "app" }], + tools: { kind: "selected", names: ["tenants.history"] }, + }, + ], + }); + expect(created.status, JSON.stringify(created.body)).toBe(200); + const connection = yield* body(ConnectionView, created); + const consent = yield* consentTo(operator, connection.url); + clients.push(consent.clientId); + expect(consent.status).toBe(200); + const scoped = search( + yield* mcp.connect(yield* consent.tokens, "dotted-search-scoped", { + connection: connection.id, + }), + ); + for (const query of ["tenants.list", `${app.slug}.tenants.list`, exactPath]) { + const page = yield* scoped({ namespace: app.slug, query, limit: 5 }); + expect(page.items.map((item) => item.path)).toEqual([`${root}.tenants.history`]); + expect(page.remaining).toBe(0); + expect(page.next).toBeNull(); + } + }).pipe(Effect.provide(McpClient.layer)), + ), + ); it.effect(scenarios.mcpSearchConcise.title, (context) => withCase( context, diff --git a/packages/mcp/src/implementation/execute.ts b/packages/mcp/src/implementation/execute.ts index 50fbb52240..5433ddb460 100644 --- a/packages/mcp/src/implementation/execute.ts +++ b/packages/mcp/src/implementation/execute.ts @@ -23,6 +23,7 @@ import { type ToolRouter, type ToolSearchItem, type ToolSearchListing, + type ToolSearchPage, type ToolSearchScope, type ToolSearchTarget, } from "@executor-js/sdk/core"; @@ -472,7 +473,8 @@ type Found = { readonly target: SearchTarget; readonly path: string }; /** * Ranked tools for one search. The backend ranks the rows of every target the namespace reaches, * so no listing is loaded to search. A query that is a tool's exact path, or its tool expression, - * returns only that tool; a query without words lists every tool in target and name order. + * returns only that tool. Dotted paths may also be relative to an explicit namespace; + * a query without words lists every tool in target and name order. */ const rank = ( searchable: Searchable, @@ -509,26 +511,31 @@ const rank = ( ...(below === undefined ? {} : { below }), })); const query = (input.query ?? "").trim(); - const path = canonical(query); - // The target an exact path names a tool of: the one with the longest path it lies below. - const named = targets - .filter(({ target }) => path.startsWith(`${target.path}.`)) - .reduce( - (best, { target }) => - best === undefined || target.path.length > best.path.length ? target : best, - undefined, - ); - const name = named === undefined ? undefined : toolName(path.slice(named.path.length + 1)); - const exact = !Schema.is(ToolName)(name) - ? undefined - : yield* search({ - targets: searched((target) => [ - { readOnly: false, names: target === named ? [name] : [] }, - ]), - query: "", - offset: 0, - limit: 1, - }); + // Full paths and tool expressions take precedence. Only dotted identifiers get a relative + // candidate: a leaf word or prose must keep the backend's ranked-list behavior. + const paths = [canonical(query)]; + if (namespace !== "" && /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(query)) + paths.push(`${namespace}.${query}`); + let exact: ToolSearchPage | undefined; + for (const path of paths) { + // The target an exact path names a tool of: the one with the longest path it lies below. + const named = targets + .filter(({ target }) => path.startsWith(`${target.path}.`)) + .reduce( + (best, { target }) => + best === undefined || target.path.length > best.path.length ? target : best, + undefined, + ); + const name = named === undefined ? undefined : toolName(path.slice(named.path.length + 1)); + if (!Schema.is(ToolName)(name)) continue; + exact = yield* search({ + targets: searched((target) => [{ readOnly: false, names: target === named ? [name] : [] }]), + query: "", + offset: 0, + limit: 1, + }); + if (exact.items.length > 0) break; + } const ranked = exact !== undefined && exact.items.length > 0 ? { items: offset === 0 ? exact.items : [], remaining: 0, missing: exact.missing } From c36daacb73f5b177889293d567fd250bbc1b7e32 Mon Sep 17 00:00:00 2001 From: shuv1337 Date: Wed, 11 Nov 2026 16:21:43 -0800 Subject: [PATCH 2/2] test(mcp): distinguish full-path search precedence --- .github/workflows/fork-ci.yml | 2 +- e2e/tests/mcp-search-concise.spec.ts | 46 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index 33bdd8f603..9ef4c4ac6a 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -44,7 +44,7 @@ jobs: done # Self-host scenarios for scoped-connection widening, OAuth registration, app - # User-Agent, and load-failure containment, plus Local MCP search. Motel's collector build requires + # User-Agent, and load-failure containment, plus local MCP search. Motel's collector build requires # Bun 1.4.4, so this job does not share check's 1.3.11 pin. No extra secrets: # the runner mints its own API key. CI disables the e2e prepare cache. security-e2e: diff --git a/e2e/tests/mcp-search-concise.spec.ts b/e2e/tests/mcp-search-concise.spec.ts index ea13ce0b71..8738f7f92f 100644 --- a/e2e/tests/mcp-search-concise.spec.ts +++ b/e2e/tests/mcp-search-concise.spec.ts @@ -229,6 +229,52 @@ export default defineApp({}, async () => ({ tools: router({ expect(page.next).toBeNull(); } + // An identifier-only app slug can name both a full path and a relative router. + // Distinct tools at both paths make reversing the candidate order observable. + const precedenceName = `precedence${randomUUID().slice(0, 8)}`; + const precedenceDeployed = yield* operator.send( + "POST", + "/v1/apps/deploy", + { + owner: "local", + name: precedenceName, + files: [ + { + path: "index.ts", + content: ` +import { defineApp, object, query, router } from "apps"; +const tenants = () => router({ list: query({ input: object({}) }, async () => []) }); +export default defineApp({}, async () => ({ tools: router({ + tenants: tenants(), + ${precedenceName}: router({ tenants: tenants() }), +}) })); +`, + }, + appsManifest, + ], + }, + headers, + ); + expect(precedenceDeployed.status, JSON.stringify(precedenceDeployed.body)).toBe(200); + const { app: precedenceApp } = yield* body( + Schema.Struct({ app: Schema.Struct({ id: Schema.String, slug: Schema.String }) }), + precedenceDeployed, + ); + yield* Effect.addFinalizer(() => + operator + .send("DELETE", `/v1/apps/${precedenceApp.id}`, undefined, headers) + .pipe(Effect.orDie), + ); + expect(precedenceApp.slug).toBe(precedenceName); + const precedenceRoot = `tools.${precedenceApp.slug}`; + for (const path of ["tenants.list", `${precedenceApp.slug}.tenants.list`]) { + const query = `${precedenceApp.slug}.${path}`; + const page = yield* full({ namespace: precedenceApp.slug, query }); + expect(page.items.map((item) => item.path)).toEqual([`${precedenceRoot}.${path}`]); + expect(page.remaining).toBe(0); + expect(page.next).toBeNull(); + } + const clients: string[] = []; yield* revokeClientGrants(operator, () => clients); const created = yield* api.request(operator, "POST", "/dashboard/api/mcp/connections", {