Skip to content

Commit e7030da

Browse files
committed
fix(local): reuse database for toolkit MCP sessions
1 parent 60ad50c commit e7030da

4 files changed

Lines changed: 88 additions & 32 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Fix toolkit MCP routes in the local daemon by reusing its owned SQLite database instead of trying to acquire the same data directory a second time.

apps/local/src/executor.ts

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
runSqliteDataMigrations,
1212
type AnyPlugin,
1313
type Executor,
14+
type FumaDb,
1415
} from "@executor-js/sdk";
1516
import { collectTables } from "@executor-js/api/server";
1617
import { loadPluginsFromJsonc } from "@executor-js/config";
@@ -92,6 +93,7 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) =>
9293
interface LocalExecutorBundle {
9394
readonly executor: Executor<LocalPlugins>;
9495
readonly plugins: LocalPlugins;
96+
readonly db: FumaDb;
9597
/** Where this daemon's web UI is reachable, resolved once at boot. Surfaced
9698
* so callers building user-facing links (MCP artifact deep links) use the
9799
* same origin the executor itself was configured with. */
@@ -142,6 +144,42 @@ const handleOrNull = (promise: ReturnType<typeof createExecutorHandle>) =>
142144
),
143145
);
144146

147+
const createExecutorBundleForDb = (db: FumaDb, cwd: string, plugins: LocalPlugins) =>
148+
Effect.gen(function* () {
149+
const tenantId = makeTenantId(cwd);
150+
// webBaseUrl is where the executor's web UI listens - same port as the
151+
// daemon API since the daemon serves both. Mirrors serve.ts's port
152+
// resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL
153+
// overrides entirely for deployments where the UI is on a different host.
154+
const webBaseUrl =
155+
process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`;
156+
157+
const executor = yield* createExecutor({
158+
tenant: Tenant.make(tenantId),
159+
subject: Subject.make(LOCAL_SUBJECT),
160+
db,
161+
plugins,
162+
onIntegrationChange: (event) =>
163+
localAnalytics.record(
164+
event.kind === "added" ? "integration_added" : "integration_removed",
165+
{ plugin_key: event.pluginKey },
166+
),
167+
onElicitation: "accept-all",
168+
oauthEndpointUrlPolicy: { allowHttp: true },
169+
// EXPLICIT OAuth callback - the daemon serves the v2 `/api/oauth/callback`
170+
// route on the same origin as the web UI. Derived from `webBaseUrl`
171+
// (loopback localhost is correct + intended for the local CLI, but it
172+
// is wired explicitly here rather than relying on a hidden default).
173+
redirectUri: new URL("/api/oauth/callback", webBaseUrl).toString(),
174+
// Built-in agent-facing tools (integrations / connections / policies).
175+
coreTools: {
176+
webBaseUrl,
177+
},
178+
});
179+
180+
return { executor, plugins, db, webBaseUrl };
181+
});
182+
145183
const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => {
146184
const storage = resolveStorage();
147185

@@ -184,35 +222,8 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => {
184222
),
185223
);
186224

187-
// webBaseUrl is where the executor's web UI listens — same port as the
188-
// daemon API since the daemon serves both. Mirrors serve.ts's port
189-
// resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL
190-
// overrides entirely for deployments where the UI is on a different host.
191-
const webBaseUrl =
192-
process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`;
193-
194-
const executor = yield* createExecutor({
195-
tenant: Tenant.make(tenantId),
196-
subject: Subject.make(LOCAL_SUBJECT),
197-
db: sqlite.db,
198-
plugins,
199-
onIntegrationChange: (event) =>
200-
localAnalytics.record(
201-
event.kind === "added" ? "integration_added" : "integration_removed",
202-
{ plugin_key: event.pluginKey },
203-
),
204-
onElicitation: "accept-all",
205-
oauthEndpointUrlPolicy: { allowHttp: true },
206-
// EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback`
207-
// route on the same origin as the web UI. Derived from `webBaseUrl`
208-
// (loopback localhost is correct + intended for the local CLI, but it
209-
// is wired explicitly here rather than relying on a hidden default).
210-
redirectUri: new URL("/api/oauth/callback", webBaseUrl).toString(),
211-
// Built-in agent-facing tools (integrations / connections / policies).
212-
coreTools: {
213-
webBaseUrl,
214-
},
215-
});
225+
const bundle = yield* createExecutorBundleForDb(sqlite.db, cwd, plugins);
226+
const executor = bundle.executor;
216227

217228
if (migration.migrated) {
218229
console.warn(
@@ -243,7 +254,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => {
243254
);
244255
}
245256

246-
return { executor, plugins, webBaseUrl };
257+
return bundle;
247258
}),
248259
);
249260
};
@@ -257,6 +268,7 @@ export const createExecutorHandle = async (options: LocalExecutorOptions = {}) =
257268
executor: bundle.executor,
258269
plugins: bundle.plugins,
259270
webBaseUrl: bundle.webBaseUrl,
271+
db: bundle.db,
260272
dispose: async () => {
261273
await Effect.runPromise(Effect.ignore(bundle.executor.close()));
262274
await ignorePromiseFailure("disposeRuntime", () => runtime.dispose());
@@ -310,6 +322,20 @@ const loadSharedHandle = (): Promise<ExecutorHandle> => {
310322
export const getExecutor = () => loadSharedHandle().then((handle) => handle.executor);
311323
export const getExecutorBundle = () => loadSharedHandle();
312324

325+
export const createScopedExecutorHandle = async (options: LocalExecutorOptions = {}) => {
326+
const shared = await getExecutorBundle();
327+
const { cwd, plugins } = await Effect.runPromise(loadLocalPlugins(options));
328+
const bundle = await Effect.runPromise(createExecutorBundleForDb(shared.db, cwd, plugins));
329+
330+
return {
331+
executor: bundle.executor,
332+
plugins: bundle.plugins,
333+
dispose: async () => {
334+
await Effect.runPromise(Effect.ignore(bundle.executor.close()));
335+
},
336+
};
337+
};
338+
313339
export const disposeExecutor = async (): Promise<void> => {
314340
const currentHandlePromise = sharedHandlePromise;
315341
sharedHandlePromise = null;

apps/local/src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render";
88
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
99
import { localAnalytics } from "./analytics";
1010
import { makeLocalApiHandler } from "./app";
11-
import { createExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor";
11+
import { createScopedExecutorHandle, disposeExecutor, getExecutorBundle } from "./executor";
1212
import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
1313

1414
// ---------------------------------------------------------------------------
@@ -137,7 +137,7 @@ export const createServerHandlers = async (token: string): Promise<ServerHandler
137137
},
138138
};
139139
}
140-
const handle = await createExecutorHandle({
140+
const handle = await createScopedExecutorHandle({
141141
activeToolkitSlug: resource.slug,
142142
});
143143
const toolkitEngine = withExecutionAnalytics(

apps/local/src/serve.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,31 @@ describe("startServer static/SPA routing (unauthenticated)", () => {
8585
});
8686

8787
describe("startServer startup cleanup", () => {
88+
it("opens a toolkit MCP session without reacquiring database ownership", async () => {
89+
server = await startServer({ port: 0, clientDir, authToken: TOKEN });
90+
const response = await fetch(`http://127.0.0.1:${server.port}/mcp/toolkits/missing-toolkit`, {
91+
method: "POST",
92+
headers: {
93+
authorization: `Bearer ${TOKEN}`,
94+
accept: "application/json, text/event-stream",
95+
"content-type": "application/json",
96+
},
97+
body: JSON.stringify({
98+
jsonrpc: "2.0",
99+
id: 1,
100+
method: "initialize",
101+
params: {
102+
protocolVersion: "2025-03-26",
103+
capabilities: {},
104+
clientInfo: { name: "local-toolkit-test", version: "1.0.0" },
105+
},
106+
}),
107+
});
108+
109+
expect(response.status).toBe(200);
110+
await response.body?.cancel();
111+
});
112+
88113
it("releases the owned DB when a default-handler server stops", async () => {
89114
server = await startServer({ port: 0, clientDir, authToken: TOKEN });
90115
await server.stop();

0 commit comments

Comments
 (0)