Skip to content

Commit 70450e1

Browse files
committed
fix: make MCP list-change scheduling deterministic
1 parent 67d45d2 commit 70450e1

3 files changed

Lines changed: 60 additions & 14 deletions

File tree

.changeset/tidy-clocks-notify.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
MCP servers now coalesce registration list-change notifications deterministically so delayed setup events do not leak into later subscriptions.

packages/effect/src/unstable/ai/McpServer.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -303,33 +303,32 @@ export class McpServer extends Context.Service<McpServer, {
303303
readonly annotations: Context.Context<never>
304304
}> = []
305305
const notificationsQueue = yield* Queue.make<QueuedServerNotification>()
306-
const listChangedHandles = new Map<string, any>()
306+
const pendingListChanged = new Set<string>()
307307
const notifications = yield* RpcClient.makeNoSerialization(BroadcastServerNotificationRpcs, {
308308
spanPrefix: "McpServer/Notifications",
309-
onFromClient: (options) =>
310-
Effect.suspend((): Effect.Effect<void> => {
309+
onFromClient: (options): Effect.Effect<void> =>
310+
Effect.gen(function*() {
311311
const message = options.message
312312
if (message._tag !== "Request") {
313-
return Effect.void
313+
return
314314
}
315315
const notification = toInternalServerNotification(message)
316316
if (notification === undefined) {
317-
return Effect.void
317+
return
318318
}
319319
if (message.tag.includes("list_changed")) {
320-
if (!listChangedHandles.has(message.tag)) {
321-
listChangedHandles.set(
322-
message.tag,
323-
setTimeout(() => {
324-
Queue.offerUnsafe(notificationsQueue, { notification })
325-
listChangedHandles.delete(message.tag)
326-
}, 0)
320+
if (!pendingListChanged.has(message.tag)) {
321+
pendingListChanged.add(message.tag)
322+
yield* Effect.sleep(0).pipe(
323+
Effect.andThen(Queue.offer(notificationsQueue, { notification })),
324+
Effect.ensuring(Effect.sync(() => pendingListChanged.delete(message.tag))),
325+
Effect.forkDetach
327326
)
328327
}
329328
} else {
330-
Queue.offerUnsafe(notificationsQueue, { notification })
329+
yield* Queue.offer(notificationsQueue, { notification })
331330
}
332-
return notifications.write({
331+
yield* notifications.write({
333332
clientId: 0,
334333
requestId: message.id,
335334
_tag: "Exit",

packages/effect/test/unstable/ai/McpServer/McpServer.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type * as RpcMessage from "effect/unstable/rpc/RpcMessage"
2626
import * as RpcServer from "effect/unstable/rpc/RpcServer"
2727
import { makeHttpHarness } from "./TestUtils/McpHttpHarness.ts"
2828
import { makeServerLayer } from "./TestUtils/McpServerLayer.ts"
29+
import { makeMcpStdioHarness } from "./TestUtils/McpStdioHarness.ts"
2930

3031
const OptionalStringTool = Tool.make("OptionalStringTool", {
3132
parameters: Schema.Struct({ signature: Schema.optional(Schema.String) }),
@@ -583,6 +584,47 @@ describe("McpServer", () => {
583584
}))
584585
})
585586

587+
describe("list-change notification scheduling", () => {
588+
it.effect("should coalesce notifications when one registration kind changes repeatedly in a scheduling window", () =>
589+
Effect.gen(function*() {
590+
const fixture = yield* makeMcpStdioHarness(McpProtocol.v2026_07_28)
591+
const makeTool = (name: string) => ({
592+
tool: new McpSchema.Tool({ name, inputSchema: { type: "object", properties: {} } }),
593+
annotations: Context.empty(),
594+
handle: () => Effect.succeed(new McpSchema.CallToolResult({ content: [] }))
595+
})
596+
const makePrompt = (name: string) => ({
597+
prompt: new McpSchema.Prompt({ name }),
598+
annotations: Context.empty(),
599+
completions: {},
600+
handle: () =>
601+
Effect.succeed(
602+
new McpSchema.GetPromptResult({
603+
messages: [{ role: "user", content: { type: "text", text: name } }]
604+
})
605+
)
606+
})
607+
608+
yield* fixture.server.addTool(makeTool("baseline-tool"))
609+
yield* fixture.server.addPrompt(makePrompt("baseline-prompt"))
610+
yield* fixture.flushListChanged
611+
yield* fixture.initialize()
612+
const subscription = yield* fixture.startRequest("subscriptions/listen", {
613+
notifications: { toolsListChanged: true, promptsListChanged: true }
614+
}, "coalesced-list-change")
615+
assert.strictEqual((yield* fixture.takeMessage).method, "notifications/subscriptions/acknowledged")
616+
617+
yield* fixture.server.addTool(makeTool("coalesced-tool-first"))
618+
yield* fixture.server.addTool(makeTool("coalesced-tool-second"))
619+
yield* fixture.server.addPrompt(makePrompt("coalescing-sentinel"))
620+
yield* fixture.flushListChanged
621+
622+
assert.strictEqual((yield* fixture.takeMessage).method, "notifications/tools/list_changed")
623+
assert.strictEqual((yield* fixture.takeMessage).method, "notifications/prompts/list_changed")
624+
yield* subscription.cancel()
625+
}))
626+
})
627+
586628
describe("resource subscriptions", () => {
587629
it.effect("should isolate resource subscriptions and clear disconnected sessions", () =>
588630
Effect.gen(function*() {

0 commit comments

Comments
 (0)