diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml
index 0822748f0a..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. 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..8738f7f92f 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,212 @@ 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();
+ }
+
+ // 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", {
+ 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 }