feat(core): add runtime MCP controls (#37308)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
co-authored by
Aiden Cline
Aiden Cline
parent
08a7080e11
commit
cd3cca0006
+153
-70
@@ -13,8 +13,10 @@ import { EventV2 } from "../event"
|
|||||||
import { Form } from "../form"
|
import { Form } from "../form"
|
||||||
import { Integration } from "../integration"
|
import { Integration } from "../integration"
|
||||||
import { IntegrationConnection } from "../integration/connection"
|
import { IntegrationConnection } from "../integration/connection"
|
||||||
|
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { waitForAbort } from "../process"
|
import { waitForAbort } from "../process"
|
||||||
|
import { State } from "../state"
|
||||||
import { MCPClient } from "./client"
|
import { MCPClient } from "./client"
|
||||||
import { MCPOAuth } from "./oauth"
|
import { MCPOAuth } from "./oauth"
|
||||||
|
|
||||||
@@ -118,6 +120,7 @@ type ServerEntry = {
|
|||||||
prompts?: ReadonlyArray<Prompt>
|
prompts?: ReadonlyArray<Prompt>
|
||||||
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
||||||
integrationID?: Integration.ID
|
integrationID?: Integration.ID
|
||||||
|
registration?: State.Registration
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a
|
// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a
|
||||||
@@ -127,6 +130,10 @@ const URL_ELICITATION_FIELD_KEY = "elicitation"
|
|||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||||
|
readonly add: (server: ServerName | string, config: typeof ConfigMCP.Server.Type) => Effect.Effect<void>
|
||||||
|
readonly connect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
|
||||||
|
readonly disconnect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
|
||||||
|
readonly remove: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
|
||||||
readonly tools: () => Effect.Effect<Tool[]>
|
readonly tools: () => Effect.Effect<Tool[]>
|
||||||
readonly callTool: (input: {
|
readonly callTool: (input: {
|
||||||
readonly server: ServerName | string
|
readonly server: ServerName | string
|
||||||
@@ -170,6 +177,9 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
// 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
|
||||||
|
// callback must stay forked: lifecycle operations close scopes while holding it, firing onClose.
|
||||||
|
const locks = KeyedMutex.makeUnsafe<ServerName>()
|
||||||
const urlElicitations = new Map<string, Form.ID>()
|
const urlElicitations = new Map<string, Form.ID>()
|
||||||
for (const entry of documents) {
|
for (const entry of documents) {
|
||||||
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
||||||
@@ -183,14 +193,9 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
// Register every remote server as an OAuth integration so credentials live in the global store
|
// Register every remote server as an OAuth integration so credentials live in the global store
|
||||||
// rather than in committed config. Servers that connect anonymously simply never use the method.
|
// rather than in committed config. Servers that connect anonymously simply never use the method.
|
||||||
const registrations: Array<{
|
const owned = new Set<Integration.ID>()
|
||||||
readonly name: ServerName
|
const register = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||||
readonly remote: typeof ConfigMCP.Remote.Type
|
if (entry.config.type !== "remote" || entry.config.oauth === false) return
|
||||||
readonly integrationID: Integration.ID
|
|
||||||
readonly methodID: Integration.MethodID
|
|
||||||
}> = []
|
|
||||||
for (const [name, entry] of runtime) {
|
|
||||||
if (entry.config.type !== "remote" || entry.config.oauth === false) continue
|
|
||||||
const remote = entry.config
|
const remote = entry.config
|
||||||
// Key identity on name + url, not url alone: two configs for the same url under different names are
|
// Key identity on name + url, not url alone: two configs for the same url under different names are
|
||||||
// distinct logical servers that may hold different accounts, so they must not share a credential row.
|
// distinct logical servers that may hold different accounts, so they must not share a credential row.
|
||||||
@@ -200,27 +205,24 @@ export const layer = Layer.effect(
|
|||||||
.update(name + "\u0000" + remote.url)
|
.update(name + "\u0000" + remote.url)
|
||||||
.digest("hex")
|
.digest("hex")
|
||||||
.slice(0, 16)
|
.slice(0, 16)
|
||||||
entry.integrationID = Integration.ID.make(suffix)
|
const integrationID = Integration.ID.make(suffix)
|
||||||
registrations.push({
|
entry.integrationID = integrationID
|
||||||
name,
|
owned.add(integrationID)
|
||||||
remote,
|
const methodID = Integration.MethodID.make(suffix)
|
||||||
integrationID: entry.integrationID,
|
entry.registration = yield* integration
|
||||||
methodID: Integration.MethodID.make(suffix),
|
.transform((draft) => {
|
||||||
})
|
draft.update(integrationID, (ref) => {
|
||||||
}
|
ref.name = name
|
||||||
if (registrations.length > 0)
|
|
||||||
yield* integration.transform((draft) => {
|
|
||||||
for (const reg of registrations) {
|
|
||||||
draft.update(reg.integrationID, (ref) => {
|
|
||||||
ref.name = reg.name
|
|
||||||
})
|
})
|
||||||
draft.method.update({
|
draft.method.update({
|
||||||
integrationID: reg.integrationID,
|
integrationID,
|
||||||
method: { id: reg.methodID, type: "oauth", label: reg.name },
|
method: { id: methodID, type: "oauth", label: name },
|
||||||
authorize: () => MCPOAuth.authorize({ name: reg.name, config: reg.remote, methodID: reg.methodID }),
|
authorize: () => MCPOAuth.authorize({ name, config: remote, methodID }),
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
})
|
.pipe(Scope.provide(root))
|
||||||
|
})
|
||||||
|
yield* Effect.forEach(runtime, ([name, entry]) => register(name, entry), { discard: true })
|
||||||
|
|
||||||
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
|
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
|
||||||
const name = ServerName.make(server)
|
const name = ServerName.make(server)
|
||||||
@@ -420,36 +422,44 @@ export const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
// Runs a connection callback under the server lock, dropping it if the connection is no longer
|
||||||
connection.onClose(() => {
|
// the entry's live client, so late SDK callbacks cannot commit obsolete state.
|
||||||
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new
|
const whenLive =
|
||||||
// connection is already assigned; ignore the stale close so it can't null out the live client.
|
(name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||||
if (entry.client !== connection) return
|
<E>(effect: Effect.Effect<void, E>) =>
|
||||||
entry.client = undefined
|
|
||||||
entry.tools = undefined
|
|
||||||
entry.prompts = undefined
|
|
||||||
entry.status = { status: "failed", error: "Connection closed" }
|
|
||||||
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
|
||||||
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
|
||||||
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
|
|
||||||
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
|
||||||
})
|
|
||||||
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
|
||||||
connection.onToolsChanged(() => {
|
|
||||||
fork(
|
fork(
|
||||||
refreshTools(name, entry, connection).pipe(
|
Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
|
||||||
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
|
locks.withLock(name),
|
||||||
Effect.ignore,
|
Effect.ignore,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
connection.onPromptsChanged(() => {
|
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||||
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
|
const live = whenLive(name, entry, connection)
|
||||||
})
|
connection.onClose(() =>
|
||||||
connection.onResourcesChanged(() => {
|
live(
|
||||||
if (entry.client !== connection) return
|
Effect.gen(function* () {
|
||||||
fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore))
|
entry.client = undefined
|
||||||
})
|
entry.tools = undefined
|
||||||
|
entry.prompts = undefined
|
||||||
|
entry.status = { status: "failed", error: "Connection closed" }
|
||||||
|
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
||||||
|
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
||||||
|
connection.onToolsChanged(() =>
|
||||||
|
live(
|
||||||
|
refreshTools(name, entry, connection).pipe(
|
||||||
|
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.onPromptsChanged(() => live(refreshPrompts(name, entry, connection)))
|
||||||
|
connection.onResourcesChanged(() => live(events.publish(McpEvent.ResourcesChanged, { server: name })))
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
||||||
@@ -495,7 +505,7 @@ export const layer = Layer.effect(
|
|||||||
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||||
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||||
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
|
whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* Scope.close(scope, Exit.void)
|
yield* Scope.close(scope, Exit.void)
|
||||||
@@ -509,6 +519,19 @@ export const layer = Layer.effect(
|
|||||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||||
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
|
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
|
||||||
|
|
||||||
|
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||||
|
const scope = entry.scope
|
||||||
|
if (!scope) return
|
||||||
|
entry.scope = undefined
|
||||||
|
entry.client = undefined
|
||||||
|
entry.tools = undefined
|
||||||
|
entry.prompts = undefined
|
||||||
|
yield* Scope.close(scope, Exit.void)
|
||||||
|
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
||||||
|
})
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -516,27 +539,24 @@ export const layer = Layer.effect(
|
|||||||
Deferred.doneUnsafe(entry.startup, Exit.void)
|
Deferred.doneUnsafe(entry.startup, Exit.void)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fork(startServer(name, entry))
|
fork(startServer(name, entry).pipe(locks.withLock(name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
|
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
|
||||||
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
|
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
|
||||||
const owned = new Set(registrations.map((reg) => reg.integrationID))
|
|
||||||
const reconnect = (integrationID: Integration.ID) =>
|
const reconnect = (integrationID: Integration.ID) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
|
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
|
||||||
if (!match) return
|
if (!match) return
|
||||||
const [name, entry] = match
|
const name = match[0]
|
||||||
if (entry.config.disabled) return
|
yield* Effect.gen(function* () {
|
||||||
if (entry.scope) {
|
// add() or remove() may have replaced or deleted the entry while we waited for the lock.
|
||||||
yield* Scope.close(entry.scope, Exit.void)
|
const entry = runtime.get(name)
|
||||||
entry.scope = undefined
|
if (!entry || entry.integrationID !== integrationID) return
|
||||||
entry.client = undefined
|
if (entry.status.status === "disabled") return
|
||||||
entry.tools = undefined
|
yield* stopServer(name, entry)
|
||||||
entry.prompts = undefined
|
yield* startServer(name, entry)
|
||||||
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
}).pipe(locks.withLock(name))
|
||||||
}
|
|
||||||
yield* startServer(name, entry)
|
|
||||||
})
|
})
|
||||||
fork(
|
fork(
|
||||||
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
@@ -546,10 +566,13 @@ export const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const whenAllReady = Effect.forEach(runtime.values(), (entry) => Deferred.await(entry.startup), {
|
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
|
||||||
concurrency: "unbounded",
|
const whenAllReady = Effect.suspend(() =>
|
||||||
discard: true,
|
Effect.forEach(Array.from(runtime.values()), (entry) => Deferred.await(entry.startup), {
|
||||||
})
|
concurrency: "unbounded",
|
||||||
|
discard: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
return Service.of({
|
return Service.of({
|
||||||
servers: Effect.fn("MCP.servers")(function* () {
|
servers: Effect.fn("MCP.servers")(function* () {
|
||||||
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
||||||
@@ -562,6 +585,66 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
add: Effect.fn("MCP.add")(function* (server, config) {
|
||||||
|
const name = ServerName.make(server)
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const previous = runtime.get(name)
|
||||||
|
if (previous) {
|
||||||
|
yield* stopServer(name, previous)
|
||||||
|
if (previous.integrationID) owned.delete(previous.integrationID)
|
||||||
|
if (previous.registration) yield* previous.registration.dispose
|
||||||
|
}
|
||||||
|
const entry: ServerEntry = {
|
||||||
|
config: { ...config, timeout: { ...timeout, ...config.timeout } },
|
||||||
|
status: { status: "pending" },
|
||||||
|
startup: Deferred.makeUnsafe<void>(),
|
||||||
|
}
|
||||||
|
runtime.set(name, entry)
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
yield* register(name, entry)
|
||||||
|
if (config.disabled) {
|
||||||
|
entry.status = { status: "disabled" }
|
||||||
|
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yield* startServer(name, entry)
|
||||||
|
}).pipe(
|
||||||
|
// Settle startup even when register fails or add is interrupted, so an entry that made it
|
||||||
|
// into runtime can never hang readers awaiting its startup.
|
||||||
|
Effect.ensuring(Effect.sync(() => Deferred.doneUnsafe(entry.startup, Exit.void))),
|
||||||
|
)
|
||||||
|
}).pipe(locks.withLock(name))
|
||||||
|
}),
|
||||||
|
connect: Effect.fn("MCP.connect")(function* (server) {
|
||||||
|
const name = ServerName.make(server)
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const target = yield* requireServer(name)
|
||||||
|
yield* stopServer(name, target.entry)
|
||||||
|
yield* startServer(name, target.entry)
|
||||||
|
}).pipe(locks.withLock(name))
|
||||||
|
}),
|
||||||
|
disconnect: Effect.fn("MCP.disconnect")(function* (server) {
|
||||||
|
const name = ServerName.make(server)
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const target = yield* requireServer(name)
|
||||||
|
yield* stopServer(name, target.entry)
|
||||||
|
target.entry.status = { status: "disabled" }
|
||||||
|
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
}).pipe(locks.withLock(name))
|
||||||
|
}),
|
||||||
|
remove: Effect.fn("MCP.remove")(function* (server) {
|
||||||
|
const name = ServerName.make(server)
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const target = yield* requireServer(name)
|
||||||
|
yield* stopServer(name, target.entry)
|
||||||
|
if (target.entry.integrationID) owned.delete(target.entry.integrationID)
|
||||||
|
if (target.entry.registration) yield* target.entry.registration.dispose
|
||||||
|
// Credentials are kept: they are keyed by name + url, so re-adding the same server
|
||||||
|
// reuses them without forcing re-auth, matching add()'s replacement semantics.
|
||||||
|
runtime.delete(name)
|
||||||
|
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||||
|
}).pipe(locks.withLock(name))
|
||||||
|
}),
|
||||||
tools: Effect.fn("MCP.tools")(function* () {
|
tools: Effect.fn("MCP.tools")(function* () {
|
||||||
yield* whenAllReady
|
yield* whenAllReady
|
||||||
return Array.from(runtime.values())
|
return Array.from(runtime.values())
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const emptyMcpLayer = Layer.succeed(
|
|||||||
MCP.Service,
|
MCP.Service,
|
||||||
MCP.Service.of({
|
MCP.Service.of({
|
||||||
servers: () => Effect.succeed([]),
|
servers: () => Effect.succeed([]),
|
||||||
|
add: () => Effect.die("unused mcp.add"),
|
||||||
|
connect: () => Effect.die("unused mcp.connect"),
|
||||||
|
disconnect: () => Effect.die("unused mcp.disconnect"),
|
||||||
|
remove: () => Effect.die("unused mcp.remove"),
|
||||||
tools: () => Effect.succeed([]),
|
tools: () => Effect.succeed([]),
|
||||||
callTool: () => Effect.die("unused mcp.callTool"),
|
callTool: () => Effect.die("unused mcp.callTool"),
|
||||||
instructions: () => Effect.succeed([]),
|
instructions: () => Effect.succeed([]),
|
||||||
|
|||||||
@@ -148,7 +148,10 @@ function resourceServer(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effect.Effect<void>) {
|
function resourceMcpLayer(
|
||||||
|
server: string | typeof ConfigMCP.Server.Type,
|
||||||
|
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
|
||||||
|
) {
|
||||||
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.pipe(
|
return MCP.layer.pipe(
|
||||||
@@ -164,7 +167,12 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe
|
|||||||
type: "document",
|
type: "document",
|
||||||
info: new Config.Info({
|
info: new Config.Info({
|
||||||
mcp: new ConfigMCP.Info({
|
mcp: new ConfigMCP.Info({
|
||||||
servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) },
|
servers: {
|
||||||
|
resources:
|
||||||
|
typeof server === "string"
|
||||||
|
? new ConfigMCP.Remote({ type: "remote", url: server, oauth: false })
|
||||||
|
: server,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -634,6 +642,120 @@ test("loads and reads MCP resources", async () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const service = yield* MCP.Service
|
||||||
|
|
||||||
|
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||||
|
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||||
|
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||||
|
yield* service.add(
|
||||||
|
"dynamic",
|
||||||
|
new ConfigMCP.Local({
|
||||||
|
type: "local",
|
||||||
|
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||||
|
status: "connected",
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* service.add(
|
||||||
|
"dynamic",
|
||||||
|
new ConfigMCP.Local({
|
||||||
|
type: "local",
|
||||||
|
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||||
|
disabled: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||||
|
status: "disabled",
|
||||||
|
})
|
||||||
|
expect(yield* service.tools()).toEqual([])
|
||||||
|
|
||||||
|
yield* service.connect("dynamic")
|
||||||
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||||
|
status: "connected",
|
||||||
|
})
|
||||||
|
yield* service.disconnect("dynamic")
|
||||||
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||||
|
status: "disabled",
|
||||||
|
})
|
||||||
|
expect(yield* service.tools()).toEqual([])
|
||||||
|
|
||||||
|
yield* service.connect("dynamic")
|
||||||
|
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||||
|
status: "connected",
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* service.remove("dynamic")
|
||||||
|
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
||||||
|
expect(yield* service.tools()).toEqual([])
|
||||||
|
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
resourceMcpLayer(
|
||||||
|
new ConfigMCP.Local({
|
||||||
|
type: "local",
|
||||||
|
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||||
|
disabled: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("serializes concurrent MCP lifecycle operations", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
const service = yield* MCP.Service
|
||||||
|
|
||||||
|
// Whatever order the racing operations land in, the resulting state must be consistent.
|
||||||
|
yield* Effect.all(
|
||||||
|
[
|
||||||
|
service.connect("resources"),
|
||||||
|
service.connect("resources"),
|
||||||
|
service.disconnect("resources"),
|
||||||
|
service.connect("resources"),
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded", discard: true },
|
||||||
|
)
|
||||||
|
const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
|
||||||
|
const tools = yield* service.tools()
|
||||||
|
expect(status?.status === "connected" || status?.status === "disabled").toBe(true)
|
||||||
|
if (status?.status === "disabled") expect(tools).toEqual([])
|
||||||
|
if (status?.status === "connected") expect(tools.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
yield* service.disconnect("resources")
|
||||||
|
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||||
|
expect(yield* service.tools()).toEqual([])
|
||||||
|
yield* service.connect("resources")
|
||||||
|
expect((yield* service.servers())[0]?.status).toEqual({ status: "connected" })
|
||||||
|
expect((yield* service.tools()).length).toBeGreaterThan(0)
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
resourceMcpLayer(
|
||||||
|
new ConfigMCP.Local({
|
||||||
|
type: "local",
|
||||||
|
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||||
|
disabled: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
|
|||||||
Reference in New Issue
Block a user