Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .github/workflows/fork-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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'
10 changes: 10 additions & 0 deletions e2e/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
209 changes: 209 additions & 0 deletions e2e/tests/mcp-search-concise.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
}),
Expand All @@ -98,6 +101,212 @@ const Executed = <A, I>(value: Schema.Codec<A, I>) =>
});

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,
Expand Down
49 changes: 28 additions & 21 deletions packages/mcp/src/implementation/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type ToolRouter,
type ToolSearchItem,
type ToolSearchListing,
type ToolSearchPage,
type ToolSearchScope,
type ToolSearchTarget,
} from "@executor-js/sdk/core";
Expand Down Expand Up @@ -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 = <E extends Error>(
searchable: Searchable<E>,
Expand Down Expand Up @@ -509,26 +511,31 @@ const rank = <E extends Error>(
...(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<SearchTarget | undefined>(
(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<SearchTarget | undefined>(
(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 }
Expand Down
Loading