refactor(core): clarify MCP config reload

This commit is contained in:
Aiden Cline 2026-07-23 16:37:55 +00:00
parent 6a756f8721
commit 473d16ac12
2 changed files with 75 additions and 67 deletions

View file

@ -182,7 +182,7 @@ export const layer = (options?: Options) => Layer.effect(
const fork = yield* FiberSet.makeRuntime<never, void, never>() const fork = yield* FiberSet.makeRuntime<never, void, never>()
yield* Effect.addFinalizer((exit) => Scope.close(root, exit)) yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
const load = Effect.fnUntraced(function* () { const loadConfig = Effect.fnUntraced(function* () {
const documents = (yield* config.entries()).filter( const documents = (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document", (entry): entry is Config.Document => entry.type === "document",
) )
@ -199,8 +199,8 @@ export const layer = (options?: Options) => Layer.effect(
} }
return { timeout, servers } return { timeout, servers }
}) })
const initial = yield* load() const initial = yield* loadConfig()
const configured = { names: new Set(initial.servers.keys()), timeout: initial.timeout } const configState = { names: new Set(initial.servers.keys()), timeout: initial.timeout }
// Later config files win for duplicate server names; per-server timeout overrides globals. // Later config files win for duplicate server names; per-server timeout overrides globals.
const runtime = new Map<ServerName, ServerEntry>() const runtime = new Map<ServerName, ServerEntry>()
// Serializes lifecycle operations per server. Anything taking this lock from a connection // Serializes lifecycle operations per server. Anything taking this lock from a connection
@ -616,6 +616,22 @@ export const layer = (options?: Options) => Layer.effect(
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
}) })
const reloadConfig = Effect.fnUntraced(function* () {
const next = yield* loadConfig()
configState.timeout = next.timeout
const names = new Set([...configState.names, ...next.servers.keys()])
for (const name of names) {
const updated = next.servers.get(name)
if (!updated) {
yield* removeServer(name).pipe(locks.withLock(name))
continue
}
if (isDeepStrictEqual(runtime.get(name)?.config, updated)) continue
yield* replaceServer(name, updated).pipe(locks.withLock(name))
}
configState.names = new Set(next.servers.keys())
})
// Disabled servers settle their startup immediately so queries never block on them. // Disabled servers settle their startup immediately so queries never block on them.
for (const [name, entry] of runtime) { for (const [name, entry] of runtime) {
if (entry.config.disabled) { if (entry.config.disabled) {
@ -653,21 +669,7 @@ export const layer = (options?: Options) => Layer.effect(
fork( fork(
events.subscribe(Event.Updated).pipe( events.subscribe(Event.Updated).pipe(
Stream.runForEach(() => Stream.runForEach(() =>
Effect.gen(function* () { reloadConfig().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload MCP config", { cause }))),
const next = yield* load()
configured.timeout = next.timeout
const names = new Set([...configured.names, ...next.servers.keys()])
for (const name of names) {
const updated = next.servers.get(name)
if (updated) {
if (isDeepStrictEqual(runtime.get(name)?.config, updated)) continue
yield* replaceServer(name, updated).pipe(locks.withLock(name))
continue
}
yield* removeServer(name).pipe(locks.withLock(name))
}
configured.names = new Set(next.servers.keys())
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload MCP config", { cause }))),
), ),
Effect.ignore, Effect.ignore,
), ),
@ -694,7 +696,7 @@ export const layer = (options?: Options) => Layer.effect(
}), }),
add: Effect.fn("MCP.add")(function* (server, config) { add: Effect.fn("MCP.add")(function* (server, config) {
const name = ServerName.make(server) const name = ServerName.make(server)
yield* replaceServer(name, { ...config, timeout: { ...configured.timeout, ...config.timeout } }).pipe( yield* replaceServer(name, { ...config, timeout: { ...configState.timeout, ...config.timeout } }).pipe(
locks.withLock(name), locks.withLock(name),
) )
}), }),

View file

@ -150,26 +150,17 @@ function resourceServer(
) )
} }
function resourceMcpLayer( function resourceMcpLayer(input: {
server: string | typeof ConfigMCP.Server.Type, server: string | typeof ConfigMCP.Server.Type
onFormCreated?: (form: Form.Info) => Effect.Effect<void>, onFormCreated?: (form: Form.Info) => Effect.Effect<void>
options?: MCP.Options, options?: MCP.Options
overrides?: {
entries?: Config.Interface["entries"] entries?: Config.Interface["entries"]
subscribe?: EventV2.Interface["subscribe"] subscribe?: EventV2.Interface["subscribe"]
}, }) {
) {
const directory = AbsolutePath.make(import.meta.dir) const directory = AbsolutePath.make(import.meta.dir)
const unusedIntegration = () => Effect.die("unused integration service") const unusedIntegration = () => Effect.die("unused integration service")
return MCP.layer(options).pipe( const entries =
Layer.provideMerge(Form.layer), input.entries ??
Layer.provide(
Layer.mergeAll(
Layer.succeed(
Config.Service,
Config.Service.of({
entries:
overrides?.entries ??
(() => (() =>
Effect.succeed([ Effect.succeed([
new Config.Document({ new Config.Document({
@ -178,27 +169,35 @@ function resourceMcpLayer(
mcp: new ConfigMCP.Info({ mcp: new ConfigMCP.Info({
servers: { servers: {
resources: resources:
typeof server === "string" typeof input.server === "string"
? new ConfigMCP.Remote({ type: "remote", url: server, oauth: false }) ? new ConfigMCP.Remote({ type: "remote", url: input.server, oauth: false })
: server, : input.server,
}, },
}), }),
}), }),
}), }),
])), ]))
}), return MCP.layer(input.options).pipe(
Layer.provideMerge(Form.layer),
Layer.provide(
Layer.mergeAll(
Layer.succeed(
Config.Service,
Config.Service.of({ entries }),
), ),
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))), Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
Layer.mock(EventV2.Service, { Layer.mock(EventV2.Service, {
subscribe: overrides?.subscribe ?? (() => Stream.never), subscribe: input.subscribe ?? (() => Stream.never),
publish: (definition, data) => { publish: (definition, data) => {
const event = { const event = {
id: EventV2.ID.create(), id: EventV2.ID.create(),
type: definition.type, type: definition.type,
data, data,
} as EventV2.Payload<typeof definition> } as EventV2.Payload<typeof definition>
if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event) if (event.type !== Form.Event.Created.type || !input.onFormCreated) return Effect.succeed(event)
return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event)) return input
.onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form)
.pipe(Effect.as(event))
}, },
}), }),
Layer.mock(Integration.Service, { Layer.mock(Integration.Service, {
@ -575,7 +574,7 @@ test("accepts empty MCP elicitations without creating forms", async () => {
const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" }) const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
expect(yield* forms.list()).toEqual([]) expect(yield* forms.list()).toEqual([])
return result return result
}).pipe(Effect.provide(resourceMcpLayer(server.url))) }).pipe(Effect.provide(resourceMcpLayer({ server: server.url })))
expect(result.structured).toEqual({ action: "accept", content: {} }) expect(result.structured).toEqual({ action: "accept", content: {} })
}), }),
@ -602,7 +601,12 @@ test("acknowledges completed MCP URL elicitations without returning internal con
expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } }) expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } })
return result return result
}).pipe( }).pipe(
Effect.provide(resourceMcpLayer(server.url, (form) => Deferred.succeed(created, form).pipe(Effect.asVoid))), Effect.provide(
resourceMcpLayer({
server: server.url,
onFormCreated: (form) => Deferred.succeed(created, form).pipe(Effect.asVoid),
}),
),
) )
expect(result.structured).toEqual({ action: "accept" }) expect(result.structured).toEqual({ action: "accept" })
@ -655,7 +659,10 @@ test("loads and reads MCP resources", async () => {
expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" }) expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" })
}).pipe( }).pipe(
Effect.provide( Effect.provide(
resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } }), resourceMcpLayer({
server: server.url,
options: { clientInfo: { name: "sdk", version: "1.2.3" } },
}),
), ),
) )
}), }),
@ -718,13 +725,13 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError) expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
}).pipe( }).pipe(
Effect.provide( Effect.provide(
resourceMcpLayer( resourceMcpLayer({
new ConfigMCP.Local({ server: new ConfigMCP.Local({
type: "local", type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")], command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
disabled: true, disabled: true,
}), }),
), }),
), ),
) )
}), }),
@ -789,12 +796,11 @@ test("reconciles MCP servers when config updates", async () => {
expect(replaced[0]?.status).toEqual({ status: "disabled" }) expect(replaced[0]?.status).toEqual({ status: "disabled" })
}).pipe( }).pipe(
Effect.provide( Effect.provide(
resourceMcpLayer( resourceMcpLayer({
new ConfigMCP.Local({ type: "local", command, disabled: true }), server: new ConfigMCP.Local({ type: "local", command, disabled: true }),
undefined, entries,
undefined, subscribe,
{ entries, subscribe }, }),
),
), ),
) )
}), }),
@ -833,13 +839,13 @@ test("serializes concurrent MCP lifecycle operations", async () => {
expect((yield* service.tools()).length).toBeGreaterThan(0) expect((yield* service.tools()).length).toBeGreaterThan(0)
}).pipe( }).pipe(
Effect.provide( Effect.provide(
resourceMcpLayer( resourceMcpLayer({
new ConfigMCP.Local({ server: new ConfigMCP.Local({
type: "local", type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")], command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
disabled: true, disabled: true,
}), }),
), }),
), ),
) )
}), }),