Derive toolkit MCP executors over the daemon's open database#1409
Open
jackulau wants to merge 1 commit into
Open
Derive toolkit MCP executors over the daemon's open database#1409jackulau wants to merge 1 commit into
jackulau wants to merge 1 commit into
Conversation
The daemon's boot bundle holds the data dir's ownership lock (a BEGIN
EXCLUSIVE held open on `data.db.owner-lock`, per-connection, with
`busy_timeout = 0`) for its whole lifetime. The toolkit MCP resource built
its executor with `createExecutorHandle({ activeToolkitSlug })`, which opens
a second owned database and so deadlocks against the lock this very process
holds. Every `/mcp/toolkits/<slug>` request failed with SQLITE_BUSY,
surfaced as a 500 (-32603), while the default `/mcp` resource kept working.
`activeToolkitSlug` only varies the plugin set: it adds a tool policy
provider to the toolkits plugin, while tenant, subject, and data dir are
fixed for the process. The second open was never needed. Extract the
`makeExecutor(plugins)` seam over the bundle's already-open handle and
derive toolkit executors from it, mirroring how self-host pairs a
long-lived database with per-request plugins.
The executor is built over the inner FumaDB handle rather than the owning
`{ db, close }` wrapper, so a scoped executor's `close()` tears down its own
plugins and never the shared database.
Cover both invariants in `apps/local` unit tests, which run on every PR, and
add the toolkits scenario to the e2e job that previously ran only the stdio
one. That gap is why this shipped unnoticed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1406.
The bug
The daemon's boot bundle holds the data dir's ownership lock (a
BEGIN EXCLUSIVEheld open ondata.db.owner-lock, per-connection,busy_timeout = 0) for its whole lifetime.The toolkit MCP resource did not reuse that bundle. It built its executor with:
which goes
createLocalExecutorLayer->openOwnedLocalDatabase->acquireDataDirOwnership, taking the same exclusive lock the process already holds. That can never succeed while the daemon runs, so every/mcp/toolkits/<slug>request hit SQLITE_BUSY and returned a 500 (-32603), while the default/mcpresource kept working.The fix
activeToolkitSlugonly varies the plugin set (it adds a tool policy provider to the toolkits plugin). Tenant, subject, and data dir are fixed for the process, so the second database open was never needed.This extracts the
makeExecutor(plugins)seam over the bundle's already-open handle and derives toolkit executors from it, mirroring how self-host pairs a long-lived database with per-request plugins (SelfHostPluginsProvider).The seam builds over the inner FumaDB handle (
sqlite.db), not the owning{ db, close }wrapper. That is load-bearing:createExecutoronly closes the database when it was handed a wrapper, so a scoped executor'sclose()tears down its own plugins and never the shared database.Verification
The reproduction is
e2e/local/toolkits-mcp.test.ts, which boots a real server and drives/mcp/toolkits/<slug>:{"error":{"code":-32603,"message":"Internal server error"}}, HTTP 500.Gates:
format:checkclean,lint0 warnings / 0 errors,typecheck43/43,test37/37.Why this shipped unnoticed, and what now guards it
The
e2e-localjob ran onlystdio-mcp.test.ts, so the toolkit path had no coverage at all. This addstoolkits-mcp.test.tsto that job (neither scenario drives a browser, so the job's browser-flakiness rationale does not apply to them).That job is gated
if: github.event_name != 'pull_request'though, so on its own it would only catch a regression after merge. The more useful guard is two new cases inapps/local/src/executor.test.ts, which run in the normaltestchain on every PR:The second is the one worth having. I verified it by mutation: changing
db: sqlite.dbtodb: sqliteinapps/local/src/executor.tsstill passestypecheck(the type system does not catch it, sinceSqliteFumaDbstructurally satisfiesExecutorDb) but fails the new test withLibsqlError: CLIENT_CLOSED: The client is closed. Without that test, a future refactor could silently make every/mcpand/apirequest fail the moment any toolkit session ended.Notes
@executor-js/localis in the changesetsignorelist and is private.e2e-localruns only headless scenarios, theif: github.event_name != 'pull_request'skip may be worth revisiting. Flipping it makes every PR pay the job, so I left it alone.