refactor(core): clarify MCP config reload
This commit is contained in:
parent
6a756f8721
commit
473d16ac12
2 changed files with 75 additions and 67 deletions
|
|
@ -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),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue