Compare commits

...

25 Commits

Author SHA1 Message Date
Aiden Cline dd8e44ad8c Merge remote-tracking branch 'origin/v2' into mcp-prompts
# Conflicts:
#	packages/core/src/mcp/index.ts
2026-06-30 14:21:12 -05:00
Aiden Cline 38299a7e35 refactor(core): hide mcp prompt command source 2026-06-30 14:20:35 -05:00
Aiden Cline 118bf05f32 Merge remote-tracking branch 'origin/v2' into mcp-prompts
# Conflicts:
#	packages/core/src/mcp/client.ts
#	packages/core/src/mcp/index.ts
2026-06-30 10:32:27 -05:00
Aiden Cline 1c5dda1bc6 feat(core): register mcp prompt commands 2026-06-29 23:35:27 -05:00
Aiden Cline 7d4965cb29 Revert "refactor(core): trim mcp event surface"
This reverts commit 1982e43f85.
2026-06-29 22:56:48 -05:00
Aiden Cline 1982e43f85 refactor(core): trim mcp event surface 2026-06-29 20:53:23 -05:00
Aiden Cline 1d78a0ab95 refactor(core): defer mcp resource events 2026-06-29 20:49:36 -05:00
Aiden Cline 75819f2792 refactor(core): keep mcp prompts static 2026-06-29 20:12:33 -05:00
Aiden Cline e1fa90e514 refactor(core): defer mcp prompt commands 2026-06-29 19:53:44 -05:00
Aiden Cline 14dc46f0d0 chore(sdk): remove unrelated generated drift 2026-06-29 19:06:17 -05:00
Aiden Cline 9b7ad8f97e refactor(core): keep mcp command metadata minimal 2026-06-29 19:00:12 -05:00
Aiden Cline 1245420e53 feat(core): expose mcp prompts as commands 2026-06-29 18:53:36 -05:00
Aiden Cline 4ef19f2e6a feat(core): track mcp prompt changes 2026-06-29 18:47:57 -05:00
Aiden Cline f725b48508 feat(core): support mcp prompts 2026-06-29 18:31:01 -05:00
Aiden Cline 931323005c feat(core): log mcp server messages (#34529) 2026-06-29 18:28:17 -05:00
Aiden Cline d66c5f3bfa fix(core): clean up mcp stdio descendants (#34525) 2026-06-29 18:07:43 -05:00
Aiden Cline 319be994b6 fix(core): migrate bare v1-shaped mcp config blocks
isV1 only triggered on a fixed set of top-level keys, so a config with just
$schema and a v1-shaped mcp block (servers directly under mcp, with enabled)
was parsed as v2 and silently produced zero servers. Detect the v1 mcp shape
(no servers wrapper, entries with a type) so these files migrate correctly.
2026-06-29 16:57:25 -05:00
Aiden Cline 73eb6ee473 feat(core): invoke and register mcp server tools
Add MCP.callTool plus a dynamic jsonSchema mode for Tool.make, and register
each MCP server's tools as canonical Location-scoped tools via an McpTool
producer that reconciles on tools-changed notifications. Content-first tool
results preserve image/audio blocks; remove the arbitrary pagination page cap;
log mcp connection outcomes.

Tool naming keeps v1 parity (server_tool) so existing deny rules apply.
2026-06-29 16:57:19 -05:00
Aiden Cline 5619595abc feat(core): list mcp server tools with change tracking
Add tools() and onToolsChanged() to the MCPClient.Connection abstraction:
list is capability-gated, paginated with cursor-dedup, and tolerates
unresolvable outputSchemas via a per-page fallback. Tool listing is folded
into connect so a listing failure marks the server failed rather than leaving
it connected with a silently empty list. Cache MCP.Tool values per server,
refresh on tools/list_changed notifications, and publish McpEvent.ToolsChanged.
MCP.tools() aggregates the cached tools across all connected servers.
2026-06-29 13:00:51 -05:00
Aiden Cline d4e00c061d feat(core): surface mcp server instructions in the system prompt
Capture each server's initialize instructions on the Connection at connect
time and expose them via MCP.instructions(). Add McpGuidance, a system-context
source modeled on SkillGuidance, that renders the <mcp_instructions> block and
hides servers whose contributed tools are all denied for the agent.

instructions() returns just { server, instructions }; the guidance layer
fetches tools() separately and does the agent-aware permission correlation,
mirroring how SkillGuidance composes raw data with agent permissions.
2026-06-29 12:26:50 -05:00
Aiden Cline baf3f5e5af feat(core): start mcp servers at boot with readiness-gated queries
Connect configured MCP servers in per-server child scopes at layer boot,
tracking status and a startup deferred per server. Query methods wait for
the relevant server(s) to finish startup before serving.

Drop the in-memory connect/disconnect/add overrides; runtime enable/disable
will come later through config updates. List methods (tools/instructions/
prompts/resourceCatalog) are aggregate across all servers with per-item
provenance; prompt/readResource stay server-scoped for routing.

Add a Connection abstraction in client.ts so the SDK Client never leaks
into the rest of core.
2026-06-29 11:00:14 -05:00
Aiden Cline 644b90d975 client.ts 2026-06-29 10:13:13 -05:00
Aiden Cline 3e1022406d feat(core): move mcp to folder, parse config, add mcp sdk dep 2026-06-29 09:54:20 -05:00
Aiden Cline 135e04dddd read configs 2026-06-29 09:48:40 -05:00
Aiden Cline 0c1124bb94 mcp skeleton 2026-06-29 09:14:10 -05:00
4 changed files with 191 additions and 3 deletions
+64
View File
@@ -46,6 +46,27 @@ export interface ToolDefinition {
readonly inputSchema: unknown
}
export interface PromptArgument {
readonly name: string
readonly description: string | undefined
readonly required: boolean | undefined
}
export interface PromptDefinition {
readonly name: string
readonly description: string | undefined
readonly arguments: ReadonlyArray<PromptArgument> | undefined
}
export interface PromptMessage {
readonly role: string
readonly content: unknown
}
export interface PromptResult {
readonly messages: ReadonlyArray<PromptMessage>
}
export type CallToolContent =
| { readonly type: "text"; readonly text: string }
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
@@ -66,6 +87,13 @@ export interface LogMessage {
export interface Connection {
/** Server-supplied usage instructions from the initialize result, if any. */
readonly instructions: string | undefined
/** Lists the server's prompts; returns [] when the server doesn't advertise prompt support. */
readonly prompts: () => Effect.Effect<PromptDefinition[], Error>
/** Gets a server prompt; returns undefined when the server doesn't advertise prompt support. */
readonly prompt: (input: {
readonly name: string
readonly args?: Record<string, string>
}) => Effect.Effect<PromptResult | undefined, Error>
/** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
/** Invokes a tool on the server. Interruption aborts the in-flight request. */
@@ -137,6 +165,42 @@ export const connect = Effect.fnUntraced(function* (
const requestTimeout = config.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT
return {
instructions: client.getInstructions()?.trim() || undefined,
prompts: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.prompts) return []
const prompts = yield* Effect.tryPromise({
try: () =>
paginate(
(cursor) =>
client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout: requestTimeout }),
(result) => result.prompts,
),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe(
Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })),
)
return prompts.map((prompt) => ({
name: prompt.name,
description: prompt.description,
arguments: prompt.arguments?.map((argument) => ({
name: argument.name,
description: argument.description,
required: argument.required,
})),
}))
}),
prompt: (input) =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.prompts) return undefined
const result = yield* Effect.tryPromise({
try: (signal) =>
client.getPrompt({ name: input.name, arguments: input.args }, { timeout: requestTimeout, signal }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
return {
messages: result.messages.map((message) => ({ role: message.role, content: message.content })),
}
}),
tools: () =>
Effect.gen(function* () {
if (!client.getServerCapabilities()?.tools) return []
+53 -3
View File
@@ -309,6 +309,21 @@ export const layer = Layer.effect(
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
new Prompt({
server,
name: def.name,
description: def.description,
arguments: def.arguments?.map(
(argument) =>
new PromptArgument({
name: argument.name,
description: argument.description,
required: argument.required,
}),
),
})
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
connection.tools().pipe(
Effect.map((defs) => {
@@ -490,11 +505,46 @@ export const layer = Layer.effect(
}),
prompts: Effect.fn("MCP.prompts")(function* () {
yield* whenAllReady
return []
return yield* Effect.forEach(
Array.from(runtime),
([server, entry]) => {
if (!entry.client || entry.status.status !== "connected") return Effect.succeed([])
return entry.client.prompts().pipe(
Effect.map((defs) => defs.map((def) => toPrompt(server, def))),
Effect.orElseSucceed(() => []),
)
},
{ concurrency: "unbounded" },
).pipe(
Effect.map((items) =>
items.flat().toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name)),
),
)
}),
prompt: Effect.fn("MCP.prompt")(function* (input) {
yield* gate(input.server)
return undefined
const target = yield* requireServer(input.server)
yield* Deferred.await(target.entry.startup)
if (!target.entry.client) return undefined
const result = yield* target.entry.client
.prompt({ name: input.name, args: input.args })
.pipe(
Effect.tapError((error) =>
Effect.logError("failed to get MCP prompt", {
server: target.name,
prompt: input.name,
error: error.message,
}),
),
Effect.orElseSucceed(() => undefined),
)
if (!result) return undefined
return new PromptResult({
server: target.name,
name: input.name,
messages: result.messages.map(
(message) => new PromptMessage({ role: message.role, content: message.content }),
),
})
}),
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
yield* whenAllReady
+7
View File
@@ -22,6 +22,7 @@ import { Integration } from "../integration"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { MCP } from "../mcp"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { PluginRuntime } from "../plugin/runtime"
@@ -36,6 +37,7 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { MCPCommandPlugin } from "./mcp-command"
import { ProviderPlugins } from "./provider"
import { SdkPlugins } from "./sdk"
import { SkillPlugin } from "./skill"
@@ -57,6 +59,7 @@ export type Requirements =
| Location.Service
| LocationMutation.Service
| ModelsDev.Service
| MCP.Service
| Npm.Service
| PermissionV2.Service
| PluginRuntime.Service
@@ -85,6 +88,7 @@ const layer = Layer.effectDiscard(
const config = yield* Config.Service
const location = yield* Location.Service
const modelsDev = yield* ModelsDev.Service
const mcp = yield* MCP.Service
const npm = yield* Npm.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
@@ -112,6 +116,7 @@ const layer = Layer.effectDiscard(
Effect.provideService(Config.Service, config),
Effect.provideService(Location.Service, location),
Effect.provideService(ModelsDev.Service, modelsDev),
Effect.provideService(MCP.Service, mcp),
Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(FSUtil.Service, fs),
@@ -142,6 +147,7 @@ const layer = Layer.effectDiscard(
yield* add(SubagentTool.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(MCPCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigProviderPlugin.Plugin)
@@ -173,6 +179,7 @@ export const node = makeLocationNode({
Location.node,
LocationMutation.node,
ModelsDev.node,
MCP.node,
Npm.node,
EventV2.node,
FSUtil.node,
+67
View File
@@ -0,0 +1,67 @@
export * as MCPCommandPlugin from "./mcp-command"
import { Effect } from "effect"
import { MCP } from "../mcp"
import { define } from "./internal"
export const Plugin = define({
id: "mcp-command",
effect: Effect.fn(function* (ctx) {
const mcp = yield* MCP.Service
yield* Effect.gen(function* () {
const prompts = yield* mcp.prompts()
const commands = yield* Effect.forEach(
prompts,
(prompt) =>
mcp
.prompt({
server: prompt.server,
name: prompt.name,
args: Object.fromEntries(prompt.arguments?.map((argument, index) => [argument.name, `$${index + 1}`]) ?? []),
})
.pipe(
Effect.map((result) => ({
name: `${sanitize(prompt.server)}:${sanitize(prompt.name)}`,
description: prompt.description,
template: promptText(result),
})),
),
{ concurrency: "unbounded" },
)
yield* ctx.command.transform((draft) => {
for (const command of commands) {
const template = command.template
if (!template || draft.get(command.name)) continue
draft.update(command.name, (item) => {
item.template = template
if (command.description !== undefined) item.description = command.description
})
}
})
}).pipe(
Effect.tapError((error) =>
Effect.logWarning("failed to register MCP prompt commands", {
error: error instanceof Error ? error.message : String(error),
}),
),
Effect.ignore,
Effect.forkScoped,
)
}),
})
function promptText(result: MCP.PromptResult | undefined) {
const text = result?.messages
.flatMap((message) => {
const content = message.content
if (!content || typeof content !== "object") return []
if (!("type" in content) || content.type !== "text") return []
if (!("text" in content) || typeof content.text !== "string") return []
return [content.text]
})
.join("\n")
.trim()
return text || undefined
}
const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_")