mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 07:26:47 -04:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd8e44ad8c | |||
| 38299a7e35 | |||
| 118bf05f32 | |||
| 1c5dda1bc6 | |||
| 7d4965cb29 | |||
| 1982e43f85 | |||
| 1d78a0ab95 | |||
| 75819f2792 | |||
| e1fa90e514 | |||
| 14dc46f0d0 | |||
| 9b7ad8f97e | |||
| 1245420e53 | |||
| 4ef19f2e6a | |||
| f725b48508 | |||
| 931323005c | |||
| d66c5f3bfa | |||
| 319be994b6 | |||
| 73eb6ee473 | |||
| 5619595abc | |||
| d4e00c061d | |||
| baf3f5e5af | |||
| 644b90d975 | |||
| 3e1022406d | |||
| 135e04dddd | |||
| 0c1124bb94 |
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "_")
|
||||
Reference in New Issue
Block a user