Skip to content

Commit 641caec

Browse files
rekram1-node𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
authored andcommitted
feat(core): add runtime MCP servers
1 parent 0d2b166 commit 641caec

3 files changed

Lines changed: 97 additions & 62 deletions

File tree

packages/core/src/mcp/index.ts

Lines changed: 60 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ const URL_ELICITATION_FIELD_KEY = "elicitation"
127127

128128
export interface Interface {
129129
readonly servers: () => Effect.Effect<ServerInfo[]>
130+
readonly add: (server: ServerName | string, config: typeof ConfigMCP.Server.Type) => Effect.Effect<void>
130131
readonly connect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
131132
readonly disconnect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
132133
readonly tools: () => Effect.Effect<Tool[]>
@@ -185,14 +186,9 @@ export const layer = Layer.effect(
185186

186187
// Register every remote server as an OAuth integration so credentials live in the global store
187188
// rather than in committed config. Servers that connect anonymously simply never use the method.
188-
const registrations: Array<{
189-
readonly name: ServerName
190-
readonly remote: typeof ConfigMCP.Remote.Type
191-
readonly integrationID: Integration.ID
192-
readonly methodID: Integration.MethodID
193-
}> = []
194-
for (const [name, entry] of runtime) {
195-
if (entry.config.type !== "remote" || entry.config.oauth === false) continue
189+
const owned = new Set<Integration.ID>()
190+
const register = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
191+
if (entry.config.type !== "remote" || entry.config.oauth === false) return
196192
const remote = entry.config
197193
// Key identity on name + url, not url alone: two configs for the same url under different names are
198194
// distinct logical servers that may hold different accounts, so they must not share a credential row.
@@ -202,27 +198,24 @@ export const layer = Layer.effect(
202198
.update(name + "\u0000" + remote.url)
203199
.digest("hex")
204200
.slice(0, 16)
205-
entry.integrationID = Integration.ID.make(suffix)
206-
registrations.push({
207-
name,
208-
remote,
209-
integrationID: entry.integrationID,
210-
methodID: Integration.MethodID.make(suffix),
211-
})
212-
}
213-
if (registrations.length > 0)
214-
yield* integration.transform((draft) => {
215-
for (const reg of registrations) {
216-
draft.update(reg.integrationID, (ref) => {
217-
ref.name = reg.name
201+
const integrationID = Integration.ID.make(suffix)
202+
entry.integrationID = integrationID
203+
owned.add(integrationID)
204+
const methodID = Integration.MethodID.make(suffix)
205+
yield* integration
206+
.transform((draft) => {
207+
draft.update(integrationID, (ref) => {
208+
ref.name = name
218209
})
219210
draft.method.update({
220-
integrationID: reg.integrationID,
221-
method: { id: reg.methodID, type: "oauth", label: reg.name },
222-
authorize: () => MCPOAuth.authorize({ name: reg.name, config: reg.remote, methodID: reg.methodID }),
211+
integrationID,
212+
method: { id: methodID, type: "oauth", label: name },
213+
authorize: () => MCPOAuth.authorize({ name, config: remote, methodID }),
223214
})
224-
}
225-
})
215+
})
216+
.pipe(Scope.provide(root))
217+
})
218+
yield* Effect.forEach(runtime, ([name, entry]) => register(name, entry), { discard: true })
226219

227220
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
228221
const name = ServerName.make(server)
@@ -511,6 +504,18 @@ export const layer = Layer.effect(
511504
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
512505
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
513506

507+
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
508+
const scope = entry.scope
509+
entry.scope = undefined
510+
entry.client = undefined
511+
entry.tools = undefined
512+
entry.prompts = undefined
513+
if (scope) yield* Scope.close(scope, Exit.void)
514+
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
515+
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
516+
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
517+
})
518+
514519
// Disabled servers settle their startup immediately so queries never block on them.
515520
for (const [name, entry] of runtime) {
516521
if (entry.config.disabled) {
@@ -523,21 +528,13 @@ export const layer = Layer.effect(
523528

524529
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
525530
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
526-
const owned = new Set(registrations.map((reg) => reg.integrationID))
527531
const reconnect = (integrationID: Integration.ID) =>
528532
Effect.gen(function* () {
529533
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
530534
if (!match) return
531535
const [name, entry] = match
532536
if (entry.status.status === "disabled") return
533-
if (entry.scope) {
534-
yield* Scope.close(entry.scope, Exit.void)
535-
entry.scope = undefined
536-
entry.client = undefined
537-
entry.tools = undefined
538-
entry.prompts = undefined
539-
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
540-
}
537+
if (entry.scope) yield* stopServer(name, entry)
541538
yield* startServer(name, entry)
542539
})
543540
fork(
@@ -564,33 +561,41 @@ export const layer = Layer.effect(
564561
}),
565562
)
566563
}),
564+
add: Effect.fn("MCP.add")(function* (server, config) {
565+
const name = ServerName.make(server)
566+
const previous = runtime.get(name)
567+
if (previous) {
568+
yield* stopServer(name, previous)
569+
if (previous.integrationID) {
570+
const integrationID = previous.integrationID
571+
owned.delete(integrationID)
572+
yield* integration.transform((draft) => draft.remove(integrationID)).pipe(Scope.provide(root))
573+
}
574+
}
575+
const entry: ServerEntry = {
576+
config: { ...config, timeout: { ...timeout, ...config.timeout } },
577+
status: { status: "pending" },
578+
startup: Deferred.makeUnsafe<void>(),
579+
}
580+
runtime.set(name, entry)
581+
yield* register(name, entry)
582+
if (config.disabled) {
583+
entry.status = { status: "disabled" }
584+
Deferred.doneUnsafe(entry.startup, Exit.void)
585+
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
586+
return
587+
}
588+
yield* startServer(name, entry)
589+
}),
567590
connect: Effect.fn("MCP.connect")(function* (server) {
568591
const target = yield* requireServer(server)
569-
if (target.entry.scope) {
570-
const scope = target.entry.scope
571-
target.entry.scope = undefined
572-
target.entry.client = undefined
573-
target.entry.tools = undefined
574-
target.entry.prompts = undefined
575-
yield* Scope.close(scope, Exit.void)
576-
yield* events.publish(McpEvent.ToolsChanged, { server: target.name }).pipe(Effect.ignore)
577-
yield* events.publish(McpEvent.ResourcesChanged, { server: target.name }).pipe(Effect.ignore)
578-
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
579-
}
592+
if (target.entry.scope) yield* stopServer(target.name, target.entry)
580593
yield* startServer(target.name, target.entry)
581594
}),
582595
disconnect: Effect.fn("MCP.disconnect")(function* (server) {
583596
const target = yield* requireServer(server)
584-
const scope = target.entry.scope
585-
target.entry.scope = undefined
586-
target.entry.client = undefined
587-
target.entry.tools = undefined
588-
target.entry.prompts = undefined
589-
if (scope) yield* Scope.close(scope, Exit.void)
597+
yield* stopServer(target.name, target.entry)
590598
target.entry.status = { status: "disabled" }
591-
yield* events.publish(McpEvent.ToolsChanged, { server: target.name }).pipe(Effect.ignore)
592-
yield* events.publish(McpEvent.ResourcesChanged, { server: target.name }).pipe(Effect.ignore)
593-
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
594599
yield* events.publish(McpEvent.StatusChanged, { server: target.name }).pipe(Effect.ignore)
595600
}),
596601
tools: Effect.fn("MCP.tools")(function* () {

packages/core/test/fixture/mcp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const emptyMcpLayer = Layer.succeed(
99
MCP.Service,
1010
MCP.Service.of({
1111
servers: () => Effect.succeed([]),
12+
add: () => Effect.die("unused mcp.add"),
1213
connect: () => Effect.die("unused mcp.connect"),
1314
disconnect: () => Effect.die("unused mcp.disconnect"),
1415
tools: () => Effect.succeed([]),

packages/core/test/mcp.test.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -638,23 +638,52 @@ test("loads and reads MCP resources", async () => {
638638
)
639639
})
640640

641-
test("disconnects and reconnects MCP servers at runtime", async () => {
641+
test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
642642
await Effect.runPromise(
643643
Effect.scoped(
644644
Effect.gen(function* () {
645645
yield* Effect.gen(function* () {
646646
const service = yield* MCP.Service
647647

648648
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
649-
yield* service.connect("resources")
650-
expect((yield* service.servers())[0]?.status).toEqual({ status: "connected" })
649+
yield* service.add(
650+
"dynamic",
651+
new ConfigMCP.Local({
652+
type: "local",
653+
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
654+
}),
655+
)
656+
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
657+
status: "connected",
658+
})
651659

652-
yield* service.disconnect("resources")
653-
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
660+
yield* service.add(
661+
"dynamic",
662+
new ConfigMCP.Local({
663+
type: "local",
664+
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
665+
disabled: true,
666+
}),
667+
)
668+
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
669+
status: "disabled",
670+
})
671+
expect(yield* service.tools()).toEqual([])
672+
673+
yield* service.connect("dynamic")
674+
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
675+
status: "connected",
676+
})
677+
yield* service.disconnect("dynamic")
678+
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
679+
status: "disabled",
680+
})
654681
expect(yield* service.tools()).toEqual([])
655682

656-
yield* service.connect("resources")
657-
expect((yield* service.servers())[0]?.status).toEqual({ status: "connected" })
683+
yield* service.connect("dynamic")
684+
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
685+
status: "connected",
686+
})
658687
}).pipe(
659688
Effect.provide(
660689
resourceMcpLayer(

0 commit comments

Comments
 (0)