Skip to content

Commit d2dcf24

Browse files
committed
Scope the stale catalog sync to the requested integration; harden the narrowed-read and lost-claim tests; release fumadb
1 parent 01e2943 commit d2dcf24

4 files changed

Lines changed: 176 additions & 128 deletions

File tree

‎.changeset/mcp-passthrough-mode.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
"@executor-js/sdk": minor
3+
"@executor-js/fumadb": minor
34
"@executor-js/plugin-openapi": patch
45
"@executor-js/plugin-graphql": patch
56
"@executor-js/plugin-mcp": patch

‎packages/core/sdk/src/executor.test.ts‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ const addr = (tool: string): ToolAddress => ToolAddress.make(`tools.${INTEG}.org
5454
// resolveTools (with shared $defs), and supports ctx.transaction rollback.
5555
// ---------------------------------------------------------------------------
5656

57+
/** Toggled by a test so the demo plugin's next discovery differs from what
58+
* is persisted. Module-level because the plugin closure is created once. */
59+
const demoDiscoversExtra = { value: false };
60+
5761
const demoPlugin = definePlugin(() => ({
5862
id: "demo" as const,
5963
credentialProviders: [memoryProvider()],
@@ -68,6 +72,11 @@ const demoPlugin = definePlugin(() => ({
6872
resolveTools: () =>
6973
Effect.succeed({
7074
tools: [
75+
// A test may flip this to make one discovery differ from the last
76+
// persisted catalog (see the lost-claim rebuild test).
77+
...(demoDiscoversExtra.value
78+
? [{ name: ToolName.make("extra"), description: "extra" }]
79+
: []),
7180
{
7281
name: ToolName.make("inspect"),
7382
description: "inspect",
@@ -1020,13 +1029,15 @@ describe("createExecutor", () => {
10201029
config.db.findMany("tool", { where: (b) => b("integration", "=", String(INTEG)) }),
10211030
);
10221031

1023-
// "A" rebuilds; the proxy plays "B" between A's claim and A's unit.
1032+
// "A" rebuilds — and discovers something NEW (`extra`) that the
1033+
// persisted catalog does not have, so a caller handed A's discovery
1034+
// instead of the persisted rows is distinguishable. The proxy plays
1035+
// "B" between A's claim and A's unit.
1036+
demoDiscoversExtra.value = true;
10241037
raceState.armed = true;
1025-
const reported = yield* executor.connections.refresh({
1026-
owner: "org",
1027-
integration: INTEG,
1028-
name: CONN,
1029-
});
1038+
const reported = yield* executor.connections
1039+
.refresh({ owner: "org", integration: INTEG, name: CONN })
1040+
.pipe(Effect.ensuring(Effect.sync(() => void (demoDiscoversExtra.value = false))));
10301041
expect(raceState.armed, "B interleaved").toBe(false);
10311042
expect(raceState.applied, "A's fenced unit was discarded").toBe(false);
10321043

@@ -1040,9 +1051,14 @@ describe("createExecutor", () => {
10401051
);
10411052
// And A reported the persisted rows, not the ones it discovered and
10421053
// failed to write — so its caller cannot disagree with the next list.
1054+
// A discovered `extra`; persisted has no `extra`; the report must not.
10431055
expect(
1044-
reported.map((tool) => String(tool.address)).sort(),
1056+
reported.map((tool) => String(tool.name)).sort(),
10451057
"refresh reports what is persisted",
1058+
).toEqual(["inspect", "run"]);
1059+
expect(
1060+
reported.map((tool) => String(tool.address)).sort(),
1061+
"refresh reports the persisted addresses",
10461062
).toEqual(
10471063
rowsAfter
10481064
.map((row) => `tools.${row.integration}.${row.owner}.${row.connection}.${row.name}`)

‎packages/core/sdk/src/executor.ts‎

Lines changed: 138 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -5555,123 +5555,137 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
55555555
// is older than the freshness TTL.
55565556
// Best-effort: a failed rebuild leaves the stale-but-working catalog in
55575557
// place and retries on the next read.
5558-
const syncStaleConnectionTools = Effect.gen(function* () {
5559-
// The platform view can never persist a rebuilt catalog (writes are
5560-
// denied at the storage boundary), so attempting the sync would only
5561-
// fire upstream `resolveTools` calls whose results are thrown away —
5562-
// network side effects on a read-only credential. Skip it entirely:
5563-
// read-only-ness of the platform read path is a stated invariant here,
5564-
// not an accident of the best-effort catch below.
5565-
if (config.platformView === true) return;
5566-
const integrations = yield* core.findMany("integration", {});
5567-
if (integrations.length === 0) return;
5568-
const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const));
5569-
// The TTL only matters when a loaded plugin actually lists a live remote
5570-
// catalog; otherwise skip it so age alone never widens the stale query.
5571-
const anyRemoteCatalog = Array.from(runtimes.values()).some(
5572-
(runtime) => runtime.plugin.remoteToolCatalog === true,
5573-
);
5574-
const cutoff =
5575-
toolsSyncTtlMs == null || !anyRemoteCatalog ? null : Date.now() - toolsSyncTtlMs;
5576-
5577-
// Bound the scan to potentially-stale rows: stale-marked (NULL stamp) or
5578-
// synced before the latest instant any trigger could fire at (the TTL
5579-
// cutoff / the newest config revision). Per-row trigger checks below
5580-
// re-verify against each row's own integration; in steady state this
5581-
// query returns nothing and the read pays one indexed lookup.
5582-
const latestRevision = integrations.reduce<number | null>(
5583-
(max, row) =>
5584-
row.config_revised_at == null
5585-
? max
5586-
: Math.max(max ?? Number(row.config_revised_at), Number(row.config_revised_at)),
5587-
null,
5588-
);
5589-
const staleBefore =
5590-
cutoff === null && latestRevision === null
5591-
? null
5592-
: Math.max(cutoff ?? Number.MIN_SAFE_INTEGER, latestRevision ?? Number.MIN_SAFE_INTEGER);
5593-
5594-
// A connection with no catalog manifest (built before the manifest
5595-
// existed) is stale too: `tools.describeAll` refuses it until a rebuild
5596-
// stamps one, so the read that first sees it is the read that fixes it.
5597-
const connections = yield* core.findMany("connection", {
5598-
where: (b: AnyCb) =>
5599-
staleBefore === null
5600-
? b.or(b.isNull("tools_synced_at"), b.isNull("tools_manifest"))
5601-
: b.or(
5602-
b.isNull("tools_synced_at"),
5603-
b.isNull("tools_manifest"),
5604-
b("tools_synced_at", "<", staleBefore),
5605-
),
5606-
});
5607-
// Each rebuild is an independent upstream listing, so they run together
5608-
// rather than one after another: a host with many stale remote-catalog
5609-
// connections otherwise pays the sum of every server's latency on the
5610-
// read that trips the TTL. Only the listings overlap — `persistCatalog`
5611-
// keeps the catalog writes in a single-file queue, so this fan-out never
5612-
// opens two transactions on a one-connection database.
5613-
const rebuilds: Effect.Effect<readonly Tool[]>[] = [];
5614-
for (const connection of connections) {
5615-
const integrationRow = integrationBySlug.get(connection.integration);
5616-
if (!integrationRow) continue;
5617-
const runtime = runtimes.get(integrationRow.plugin_id);
5618-
// Only re-produce catalogs this executor can actually re-list —
5619-
// rebuilding under an unloaded plugin would clear a working catalog.
5620-
// (A loaded plugin without `resolveTools` still flows through:
5621-
// `produceConnectionTools` runs its clear-and-stamp cleanup path.)
5622-
if (!runtime) continue;
5623-
5624-
const syncedAt =
5625-
connection.tools_synced_at == null ? null : Number(connection.tools_synced_at);
5626-
const revisedTime =
5627-
integrationRow.config_revised_at == null
5558+
// `scope` narrows the sync to one integration's connections. A read that
5559+
// only asked for that integration (`describeAll({ integration })`) must not
5560+
// scan, dial, or wait on every other stale connection in the workspace.
5561+
const syncStaleConnectionToolsScoped = (scope?: { readonly integration: IntegrationSlug }) =>
5562+
Effect.gen(function* () {
5563+
// The platform view can never persist a rebuilt catalog (writes are
5564+
// denied at the storage boundary), so attempting the sync would only
5565+
// fire upstream `resolveTools` calls whose results are thrown away —
5566+
// network side effects on a read-only credential. Skip it entirely:
5567+
// read-only-ness of the platform read path is a stated invariant here,
5568+
// not an accident of the best-effort catch below.
5569+
if (config.platformView === true) return;
5570+
const integrations = yield* core.findMany("integration", {
5571+
where: (b: AnyCb) =>
5572+
scope === undefined ? true : b("slug", "=", String(scope.integration)),
5573+
});
5574+
if (integrations.length === 0) return;
5575+
const integrationBySlug = new Map(integrations.map((row) => [row.slug, row] as const));
5576+
// The TTL only matters when a loaded plugin actually lists a live remote
5577+
// catalog; otherwise skip it so age alone never widens the stale query.
5578+
const anyRemoteCatalog = Array.from(runtimes.values()).some(
5579+
(runtime) => runtime.plugin.remoteToolCatalog === true,
5580+
);
5581+
const cutoff =
5582+
toolsSyncTtlMs == null || !anyRemoteCatalog ? null : Date.now() - toolsSyncTtlMs;
5583+
5584+
// Bound the scan to potentially-stale rows: stale-marked (NULL stamp) or
5585+
// synced before the latest instant any trigger could fire at (the TTL
5586+
// cutoff / the newest config revision). Per-row trigger checks below
5587+
// re-verify against each row's own integration; in steady state this
5588+
// query returns nothing and the read pays one indexed lookup.
5589+
const latestRevision = integrations.reduce<number | null>(
5590+
(max, row) =>
5591+
row.config_revised_at == null
5592+
? max
5593+
: Math.max(max ?? Number(row.config_revised_at), Number(row.config_revised_at)),
5594+
null,
5595+
);
5596+
const staleBefore =
5597+
cutoff === null && latestRevision === null
56285598
? null
5629-
: Number(integrationRow.config_revised_at);
5630-
5631-
const staleMarked = syncedAt === null || connection.tools_manifest == null;
5632-
const configRevised = revisedTime !== null && (syncedAt ?? 0) < revisedTime;
5633-
const expired =
5634-
cutoff !== null &&
5635-
runtime.plugin.remoteToolCatalog === true &&
5636-
syncedAt !== null &&
5637-
syncedAt < cutoff;
5638-
if (!staleMarked && !configRevised && !expired) continue;
5599+
: Math.max(
5600+
cutoff ?? Number.MIN_SAFE_INTEGER,
5601+
latestRevision ?? Number.MIN_SAFE_INTEGER,
5602+
);
56395603

5640-
rebuilds.push(
5641-
produceConnectionTools(
5642-
integrationRow,
5643-
{
5644-
owner: connection.owner as Owner,
5645-
integration: IntegrationSlug.make(connection.integration),
5646-
name: ConnectionName.make(connection.name),
5647-
},
5648-
"background",
5649-
).pipe(
5650-
// Best-effort, but never silent: the read still succeeds on the
5651-
// stale-but-working catalog and the peer rebuilds still finish,
5652-
// while the operator gets the connection that failed and why.
5653-
// Without this a connection whose upstream is permanently broken
5654-
// re-fails on every read and leaves no trace anywhere.
5655-
Effect.catch((error) =>
5656-
Effect.logWarning("executor stale tool sync failed", {
5657-
integration: connection.integration,
5658-
connection: connection.name,
5659-
error: describeSyncFailure(error),
5660-
}).pipe(Effect.as([] as readonly Tool[])),
5604+
// A connection with no catalog manifest (built before the manifest
5605+
// existed) is stale too: `tools.describeAll` refuses it until a rebuild
5606+
// stamps one, so the read that first sees it is the read that fixes it.
5607+
const connections = yield* core.findMany("connection", {
5608+
where: (b: AnyCb) =>
5609+
b.and(
5610+
scope === undefined ? true : b("integration", "=", String(scope.integration)),
5611+
staleBefore === null
5612+
? b.or(b.isNull("tools_synced_at"), b.isNull("tools_manifest"))
5613+
: b.or(
5614+
b.isNull("tools_synced_at"),
5615+
b.isNull("tools_manifest"),
5616+
b("tools_synced_at", "<", staleBefore),
5617+
),
56615618
),
5662-
Effect.withSpan("executor.tools.sync_stale", {
5663-
attributes: {
5664-
"executor.integration": connection.integration,
5665-
"executor.connection": connection.name,
5619+
});
5620+
// Each rebuild is an independent upstream listing, so they run together
5621+
// rather than one after another: a host with many stale remote-catalog
5622+
// connections otherwise pays the sum of every server's latency on the
5623+
// read that trips the TTL. Only the listings overlap — `persistCatalog`
5624+
// keeps the catalog writes in a single-file queue, so this fan-out never
5625+
// opens two transactions on a one-connection database.
5626+
const rebuilds: Effect.Effect<readonly Tool[]>[] = [];
5627+
for (const connection of connections) {
5628+
const integrationRow = integrationBySlug.get(connection.integration);
5629+
if (!integrationRow) continue;
5630+
const runtime = runtimes.get(integrationRow.plugin_id);
5631+
// Only re-produce catalogs this executor can actually re-list —
5632+
// rebuilding under an unloaded plugin would clear a working catalog.
5633+
// (A loaded plugin without `resolveTools` still flows through:
5634+
// `produceConnectionTools` runs its clear-and-stamp cleanup path.)
5635+
if (!runtime) continue;
5636+
5637+
const syncedAt =
5638+
connection.tools_synced_at == null ? null : Number(connection.tools_synced_at);
5639+
const revisedTime =
5640+
integrationRow.config_revised_at == null
5641+
? null
5642+
: Number(integrationRow.config_revised_at);
5643+
5644+
const staleMarked = syncedAt === null || connection.tools_manifest == null;
5645+
const configRevised = revisedTime !== null && (syncedAt ?? 0) < revisedTime;
5646+
const expired =
5647+
cutoff !== null &&
5648+
runtime.plugin.remoteToolCatalog === true &&
5649+
syncedAt !== null &&
5650+
syncedAt < cutoff;
5651+
if (!staleMarked && !configRevised && !expired) continue;
5652+
5653+
rebuilds.push(
5654+
produceConnectionTools(
5655+
integrationRow,
5656+
{
5657+
owner: connection.owner as Owner,
5658+
integration: IntegrationSlug.make(connection.integration),
5659+
name: ConnectionName.make(connection.name),
56665660
},
5667-
}),
5668-
),
5669-
);
5670-
}
5671-
yield* Effect.all(rebuilds, {
5672-
concurrency: STALE_TOOLS_SYNC_CONCURRENCY,
5661+
"background",
5662+
).pipe(
5663+
// Best-effort, but never silent: the read still succeeds on the
5664+
// stale-but-working catalog and the peer rebuilds still finish,
5665+
// while the operator gets the connection that failed and why.
5666+
// Without this a connection whose upstream is permanently broken
5667+
// re-fails on every read and leaves no trace anywhere.
5668+
Effect.catch((error) =>
5669+
Effect.logWarning("executor stale tool sync failed", {
5670+
integration: connection.integration,
5671+
connection: connection.name,
5672+
error: describeSyncFailure(error),
5673+
}).pipe(Effect.as([] as readonly Tool[])),
5674+
),
5675+
Effect.withSpan("executor.tools.sync_stale", {
5676+
attributes: {
5677+
"executor.integration": connection.integration,
5678+
"executor.connection": connection.name,
5679+
},
5680+
}),
5681+
),
5682+
);
5683+
}
5684+
yield* Effect.all(rebuilds, {
5685+
concurrency: STALE_TOOLS_SYNC_CONCURRENCY,
5686+
});
56735687
});
5674-
});
5688+
const syncStaleConnectionTools = syncStaleConnectionToolsScoped();
56755689

56765690
// How long a tools read waits for the stale sync before answering from
56775691
// the persisted rows (`ExecutorConfig.toolsSyncGraceMs`; `null` blocks
@@ -5689,10 +5703,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
56895703
// failures log and the stale-but-working catalog stays — so the fork
56905704
// swallows its scan errors the same way each rebuild already swallows its
56915705
// own.
5692-
const awaitStaleSyncWithinGrace = (graceMs: number) =>
5706+
const awaitStaleSyncWithinGrace = (
5707+
graceMs: number,
5708+
scope?: { readonly integration: IntegrationSlug },
5709+
) =>
56935710
Effect.gen(function* () {
56945711
const fiber = yield* Effect.forkDetach(
5695-
syncStaleConnectionTools.pipe(
5712+
syncStaleConnectionToolsScoped(scope).pipe(
56965713
Effect.catch((error) =>
56975714
Effect.logWarning("executor stale tool sync scan failed", {
56985715
error: describeSyncFailure(error),
@@ -5909,10 +5926,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
59095926
filter?: ToolListFilter,
59105927
): Effect.Effect<readonly ToolProjection[], StorageFailure> =>
59115928
Effect.gen(function* () {
5929+
// The stale sync is scoped to the requested integration, so a
5930+
// narrowed read never scans, dials, or waits on unrelated
5931+
// connections.
5932+
const scope =
5933+
filter?.integration === undefined ? undefined : { integration: filter.integration };
59125934
if (toolsSyncGraceMs === null) {
5913-
yield* syncStaleConnectionTools;
5935+
yield* syncStaleConnectionToolsScoped(scope);
59145936
} else {
5915-
yield* awaitStaleSyncWithinGrace(toolsSyncGraceMs);
5937+
yield* awaitStaleSyncWithinGrace(toolsSyncGraceMs, scope);
59165938
}
59175939
const integrationWhere = (b: AnyCb) =>
59185940
b.and(

‎packages/hosts/mcp/src/passthrough-tools.test.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -595,25 +595,34 @@ describe("passthrough mode server", () => {
595595

596596
it("narrows to the requested integrations and says which were not connected", async () => {
597597
const { engine } = makeRecordingEngine();
598+
// The filter is pushed into the READ: the port is asked once per slug and
599+
// never for the whole workspace. Recording the calls is what proves it —
600+
// a stub that merely honoured the filter would also pass an unfiltered
601+
// read followed by host-side filtering.
602+
const reads: (string | undefined)[] = [];
598603
await withClient(
599604
{
600605
engine,
601606
mode: "passthrough",
602607
passthroughIntegrations: ["linear", "notion"],
603-
// The filter is pushed into the READ: the port is asked per slug, so
604-
// a real catalog never describes tools the session will not serve.
605608
tools: {
606-
describeAll: (filter) =>
607-
Effect.succeed(
609+
describeAll: (filter) => {
610+
reads.push(filter?.integration === undefined ? undefined : String(filter.integration));
611+
return Effect.succeed(
608612
CATALOG.filter(
609613
(tool) =>
610614
filter?.integration === undefined || tool.integration === filter.integration,
611615
),
612-
),
616+
);
617+
},
613618
},
614619
},
615620
async (client) => {
616621
const listed = await client.listTools();
622+
expect(reads.sort(), "one read per requested slug, none unfiltered").toEqual([
623+
"linear",
624+
"notion",
625+
]);
617626
expect(listed.tools.map((tool) => tool.name)).toEqual(["linear__issueCreate"]);
618627
const instructions = client.getInstructions() ?? "";
619628
expect(instructions).toContain("1 integration tool");

0 commit comments

Comments
 (0)