mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 04:26:11 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d2838630 | ||
|
|
60d5f83ffd | ||
|
|
1455995ac7 | ||
|
|
52c04508a2 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": major
|
||||
---
|
||||
|
||||
Treat MCP as a word in current Core namespace exports: rename `MCP` to `Mcp`, `MCPClient` to `McpClient`, `MCPStdio` to `McpStdio`, `MCPOAuth` to `McpOAuth`, `ConfigMCPPlugin` to `ConfigMcpPlugin`, and `MCPCodeModeExclusionPlugin` to `McpCodeModeExclusionPlugin`. Direct consumers must update their imports. Module paths, Schema contracts, runtime service keys, error tags, and behavior are unchanged.
|
||||
+114
-167
@@ -1,10 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
|
||||
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import * as OpenAI from "../src/providers/openai.js"
|
||||
import { route } from "../src/protocols/openai-chat.js"
|
||||
import { configure } from "../src/providers/openai.js"
|
||||
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
|
||||
import { deltaChunk } from "./lib/openai-chunks.js"
|
||||
import { sseEvents, sseRaw } from "./lib/sse.js"
|
||||
@@ -18,47 +18,6 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
|
||||
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
|
||||
)
|
||||
|
||||
const responsesLayer = (responses: ReadonlyArray<Response>) =>
|
||||
RequestExecutor.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* Ref.make(0)
|
||||
return Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
||||
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArray<Response>) =>
|
||||
RequestExecutor.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* Ref.make(0)
|
||||
return Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(attempts, (value) => value + 1)
|
||||
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
|
||||
return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1])
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const expectAIError = (error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
if (!(error instanceof AIError)) throw new Error("expected AIError")
|
||||
@@ -107,19 +66,17 @@ describe("RequestExecutor", () => {
|
||||
return yield* executor.execute(request).pipe(Effect.flip)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(cause)
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: { "x-request-id": "req_failed_body" },
|
||||
fixedResponse(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(cause)
|
||||
},
|
||||
),
|
||||
]),
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: { "x-request-id": "req_failed_body" },
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -148,15 +105,14 @@ describe("RequestExecutor", () => {
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
|
||||
},
|
||||
}),
|
||||
),
|
||||
]),
|
||||
fixedResponse(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -176,15 +132,14 @@ describe("RequestExecutor", () => {
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
|
||||
},
|
||||
}),
|
||||
),
|
||||
]),
|
||||
fixedResponse(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -200,7 +155,7 @@ describe("RequestExecutor", () => {
|
||||
expect(error.message).toBe("plugin rejected request")
|
||||
expect(error.reason.cause).toBeInstanceOf(Error)
|
||||
expect(error.reason.http).toBeUndefined()
|
||||
}).pipe(Effect.provide(responsesLayer([]))),
|
||||
}).pipe(Effect.provide(dynamicResponse(() => Effect.die(new Error("unexpected HTTP request"))))),
|
||||
)
|
||||
|
||||
it.effect("reports the request sent by middleware", () =>
|
||||
@@ -249,11 +204,9 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
|
||||
status: 400,
|
||||
}),
|
||||
]),
|
||||
fixedResponse('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', {
|
||||
status: 400,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -269,7 +222,7 @@ describe("RequestExecutor", () => {
|
||||
classification: "payload-too-large",
|
||||
})
|
||||
expect(error.reason.http?.status).toBe(413)
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
}).pipe(Effect.provide(fixedResponse("request too large", { status: 413 }))),
|
||||
)
|
||||
|
||||
it.effect("classifies Anthropic request_too_large as context overflow", () =>
|
||||
@@ -285,11 +238,9 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.http?.status).toBe(413)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
|
||||
status: 413,
|
||||
}),
|
||||
]),
|
||||
fixedResponse('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
|
||||
status: 413,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -303,7 +254,7 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error.message).toBe("Provider request failed with HTTP 400")
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
||||
}).pipe(Effect.provide(fixedResponse("invalid parameter", { status: 400 }))),
|
||||
)
|
||||
|
||||
it.effect("preserves structured provider messages from large error bodies", () =>
|
||||
@@ -317,15 +268,13 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.body).toContain(largeProviderMessage)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
model: "gpt-5.6-sol",
|
||||
error: { type: "invalid_request", message: largeProviderMessage },
|
||||
}),
|
||||
{ status: 400 },
|
||||
),
|
||||
]),
|
||||
fixedResponse(
|
||||
JSON.stringify({
|
||||
model: "test-model",
|
||||
error: { type: "invalid_request", message: largeProviderMessage },
|
||||
}),
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -340,7 +289,7 @@ describe("RequestExecutor", () => {
|
||||
_tag: "InvalidRequest",
|
||||
})
|
||||
expect(error.message).toBe("Provider request failed with HTTP 400")
|
||||
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
|
||||
}).pipe(Effect.provide(fixedResponse('{"error":{"message":" "}}', { status: 400 }))),
|
||||
)
|
||||
|
||||
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
|
||||
@@ -352,7 +301,7 @@ describe("RequestExecutor", () => {
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
|
||||
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
|
||||
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
|
||||
|
||||
yield* classify("Request rate increased too quickly")
|
||||
yield* classify('{"type":"error","error":{"type":"too_many_requests"}}')
|
||||
@@ -369,7 +318,7 @@ describe("RequestExecutor", () => {
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
|
||||
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
|
||||
|
||||
yield* classify('{"code":"resource_exhausted"}')
|
||||
yield* classify('{"code":"service_unavailable"}')
|
||||
@@ -399,12 +348,10 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.body).toBe("rate limited")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
|
||||
}),
|
||||
]),
|
||||
fixedResponse("rate limited", {
|
||||
status: 429,
|
||||
headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -417,7 +364,7 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason.http?.headers["x-safe"]).toBe("response-secret")
|
||||
}).pipe(
|
||||
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
|
||||
Effect.provide(fixedResponse("bad", { status: 400, headers: { "x-safe": "response-secret" } })),
|
||||
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
|
||||
),
|
||||
)
|
||||
@@ -437,20 +384,18 @@ describe("RequestExecutor", () => {
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-ratelimit-limit-requests": "500",
|
||||
"x-ratelimit-limit-tokens": "30000",
|
||||
"x-ratelimit-remaining-requests": "499",
|
||||
"x-ratelimit-remaining-tokens": "29900",
|
||||
"x-ratelimit-reset-requests": "1s",
|
||||
"x-ratelimit-reset-tokens": "10s",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
fixedResponse("rate limited", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"x-ratelimit-limit-requests": "500",
|
||||
"x-ratelimit-limit-tokens": "30000",
|
||||
"x-ratelimit-remaining-requests": "499",
|
||||
"x-ratelimit-remaining-tokens": "29900",
|
||||
"x-ratelimit-reset-requests": "1s",
|
||||
"x-ratelimit-reset-tokens": "10s",
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -470,20 +415,18 @@ describe("RequestExecutor", () => {
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"anthropic-ratelimit-requests-limit": "100",
|
||||
"anthropic-ratelimit-requests-remaining": "12",
|
||||
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
|
||||
"anthropic-ratelimit-input-tokens-limit": "10000",
|
||||
"anthropic-ratelimit-input-tokens-remaining": "9000",
|
||||
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
fixedResponse("rate limited", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"anthropic-ratelimit-requests-limit": "100",
|
||||
"anthropic-ratelimit-requests-remaining": "12",
|
||||
"anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z",
|
||||
"anthropic-ratelimit-input-tokens-limit": "10000",
|
||||
"anthropic-ratelimit-input-tokens-remaining": "9000",
|
||||
"anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z",
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -496,10 +439,14 @@ describe("RequestExecutor", () => {
|
||||
return yield* executor.execute(request).pipe(Effect.flip)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
countedResponsesLayer(attempts, [
|
||||
new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }),
|
||||
new Response("ok", { status: 200 }),
|
||||
]),
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
|
||||
return attempt === 0
|
||||
? input.respond("busy", { status: 503, headers: { "retry-after-ms": "0" } })
|
||||
: input.respond("ok", { status: 200 })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -522,12 +469,10 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.http?.status).toBe(status)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("provider failure", {
|
||||
status,
|
||||
headers: { "retry-after-ms": "0" },
|
||||
}),
|
||||
]),
|
||||
fixedResponse("provider failure", {
|
||||
status,
|
||||
headers: { "retry-after-ms": "0" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -538,20 +483,28 @@ describe("RequestExecutor", () => {
|
||||
|
||||
it.effect("preserves large authentication error bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
const attempts = yield* Ref.make(0)
|
||||
const error = yield* Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return yield* executor.execute(request).pipe(Effect.flip)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const attempt = yield* Ref.getAndUpdate(attempts, (value) => value + 1)
|
||||
return attempt === 0
|
||||
? input.respond("x".repeat(20_000), { status: 401 })
|
||||
: input.respond("should not retry", { status: 200 })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "Authentication" })
|
||||
expect(error.reason.body).toHaveLength(20_000)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("x".repeat(20_000), { status: 401 }),
|
||||
new Response("should not retry", { status: 200 }),
|
||||
]),
|
||||
),
|
||||
),
|
||||
expect(yield* Ref.get(attempts)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves response body fields", () =>
|
||||
@@ -563,11 +516,9 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.body).toBe('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}')
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
|
||||
status: 400,
|
||||
}),
|
||||
]),
|
||||
fixedResponse('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', {
|
||||
status: 400,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -581,9 +532,7 @@ describe("RequestExecutor", () => {
|
||||
expect(error.reason.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
|
||||
]),
|
||||
fixedResponse("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -591,9 +540,7 @@ describe("RequestExecutor", () => {
|
||||
it.effect("does not re-execute after a successful response reaches stream parsing", () =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const model = route.with({ endpoint: { baseURL: "https://api.openai.test/v1" } }).model({ id: "gpt-4o-mini" })
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
@@ -624,7 +571,7 @@ describe("RequestExecutor", () => {
|
||||
})
|
||||
|
||||
describe("WebSocket channel execution", () => {
|
||||
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
|
||||
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
|
||||
const request = LLM.request({ model, prompt: "Say hello." })
|
||||
const frames = [
|
||||
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export * as ConfigMCPPlugin from "./mcp.js"
|
||||
export * as ConfigMcpPlugin from "./mcp.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import type { ServerConfig } from "@opencode-ai/schema/mcp"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { MCP } from "../../mcp/index.js"
|
||||
import { Mcp } from "../../mcp/index.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.mcp",
|
||||
@@ -18,7 +18,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
|
||||
events: Stream.Stream<{ readonly type: string }, unknown>,
|
||||
) {
|
||||
const config = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const loaded = { entries: [] as Entry[] }
|
||||
|
||||
yield* events.pipe(
|
||||
@@ -43,7 +43,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
|
||||
{},
|
||||
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
|
||||
)
|
||||
const servers = new Map<string, Mcp.ServerConfig>()
|
||||
const servers = new Map<string, ServerConfig>()
|
||||
for (const document of documents) {
|
||||
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
|
||||
servers.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
|
||||
@@ -227,7 +227,7 @@ export class SQLiteEffectInsertBase<
|
||||
config: SQLiteInsertConfig<TTable>
|
||||
|
||||
constructor(
|
||||
private table: TTable,
|
||||
table: TTable,
|
||||
values: SQLiteInsertConfig["values"],
|
||||
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
|
||||
private effectDialect: SQLiteDialect,
|
||||
|
||||
+33
-28
@@ -177,7 +177,7 @@ const layer = Layer.effect(
|
||||
if (!dotgit) return undefined
|
||||
|
||||
const cwd = path.dirname(dotgit)
|
||||
const result = yield* run(cwd, proc, ["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
|
||||
const result = yield* run(cwd, proc)(["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
|
||||
const [gitDir, commonDir, topLevel] = result.text.split(/\r?\n/)
|
||||
if (!gitDir || !commonDir) return undefined
|
||||
|
||||
@@ -189,13 +189,13 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const remote = Effect.fn("Git.remote.get")(function* (repository: Repository, name = "origin") {
|
||||
const result = yield* run(repository.worktree, proc, ["remote", "get-url", name])
|
||||
const result = yield* run(repository.worktree, proc)(["remote", "get-url", name])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const roots = Effect.fn("Git.history.rootCommits")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc, ["rev-list", "--max-parents=0", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc)(["rev-list", "--max-parents=0", "HEAD"])
|
||||
if (result.exitCode !== 0) return []
|
||||
return result.text
|
||||
.split("\n")
|
||||
@@ -205,13 +205,13 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const head = Effect.fn("Git.history.head")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc, ["rev-parse", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc)(["rev-parse", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const branch = Effect.fn("Git.history.branch")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc, ["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
@@ -220,7 +220,7 @@ const layer = Layer.effect(
|
||||
repository: Repository,
|
||||
remoteName = "origin",
|
||||
) {
|
||||
const result = yield* run(repository.worktree, proc, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || undefined
|
||||
})
|
||||
@@ -230,7 +230,10 @@ const layer = Layer.effect(
|
||||
directory: AbsolutePath,
|
||||
args: string[],
|
||||
) {
|
||||
const result = yield* execute(directory, proc, args).pipe(
|
||||
const result = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(args).pipe(
|
||||
Effect.mapError((cause) => new OperationError({ operation, directory, message: cause.message, cause })),
|
||||
)
|
||||
if (result.exitCode === 0) return
|
||||
@@ -708,29 +711,31 @@ interface Result {
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
function run(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
return execute(cwd, proc, args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
|
||||
function run(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
execute(cwd, proc)(args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
|
||||
}
|
||||
|
||||
function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
return proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map(
|
||||
(result) =>
|
||||
({
|
||||
exitCode: result.exitCode,
|
||||
text: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}) satisfies Result,
|
||||
),
|
||||
)
|
||||
function execute(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map(
|
||||
(result) =>
|
||||
({
|
||||
exitCode: result.exitCode,
|
||||
text: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}) satisfies Result,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
@@ -84,7 +84,7 @@ const locationServiceNodes = [
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
MCP.node,
|
||||
Mcp.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
ToolOutput.node,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as MCPClient from "./client.js"
|
||||
export * as McpClient from "./client.js"
|
||||
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { MCPStdio } from "./stdio.js"
|
||||
import { McpStdio } from "./stdio.js"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
const DEFAULT_CATALOG_TIMEOUT = 30_000
|
||||
@@ -222,7 +222,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
const exit = yield* Effect.gen(function* () {
|
||||
if (config.type === "local") {
|
||||
const [command, ...args] = config.command
|
||||
const transport = yield* MCPStdio.make({
|
||||
const transport = yield* McpStdio.make({
|
||||
server,
|
||||
command,
|
||||
args,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as MCP from "./index.js"
|
||||
export * as Mcp from "./index.js"
|
||||
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
@@ -16,7 +16,7 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
|
||||
import { Location } from "../location.js"
|
||||
import { waitForAbort } from "@opencode-ai/util/process"
|
||||
import { State } from "../state.js"
|
||||
import type { MCPClient } from "./client.js"
|
||||
import type { McpClient } from "./client.js"
|
||||
|
||||
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
|
||||
export const PromptsChanged = ephemeral({ type: "mcp.prompts.changed", schema: { server: Schema.String } })
|
||||
@@ -114,7 +114,7 @@ type ServerEntry = {
|
||||
status: Status
|
||||
readonly startup: Latch.Latch
|
||||
scope?: Scope.Closeable
|
||||
client?: MCPClient.Connection
|
||||
client?: McpClient.Connection
|
||||
tools?: ReadonlyArray<Tool>
|
||||
prompts?: ReadonlyArray<Prompt>
|
||||
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
||||
@@ -235,8 +235,8 @@ export const layer = (options?: Options) =>
|
||||
method: { id: methodID, type: "oauth", label: name },
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
return yield* MCPOAuth.authorize({ name, config: remote, methodID })
|
||||
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
return yield* McpOAuth.authorize({ name, config: remote, methodID })
|
||||
}),
|
||||
})
|
||||
})
|
||||
@@ -254,7 +254,7 @@ export const layer = (options?: Options) =>
|
||||
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
|
||||
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
|
||||
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
|
||||
const { MCPOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
const remote = entry.config
|
||||
const oauth = remote.oauth || undefined
|
||||
const base = {
|
||||
@@ -270,7 +270,7 @@ export const layer = (options?: Options) =>
|
||||
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
|
||||
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
|
||||
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
|
||||
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
|
||||
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
|
||||
const credentialID = found.id
|
||||
const methodID = found.value.methodID
|
||||
const integrationID = entry.integrationID
|
||||
@@ -282,7 +282,7 @@ export const layer = (options?: Options) =>
|
||||
const match = stored.find((credential) => credential.id === credentialID)
|
||||
return match && match.value.type === "oauth" ? match.value : undefined
|
||||
}
|
||||
return MCPOAuth.provider({
|
||||
return McpOAuth.provider({
|
||||
...base,
|
||||
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
|
||||
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
|
||||
@@ -303,22 +303,22 @@ export const layer = (options?: Options) =>
|
||||
const oauth = await readOAuthCredential()
|
||||
if (!oauth) return undefined
|
||||
presented = oauth.refresh
|
||||
return MCPOAuth.toTokens(oauth)
|
||||
return McpOAuth.toTokens(oauth)
|
||||
},
|
||||
saveTokens: async (tokens) => {
|
||||
const previous = await readOAuthCredential()
|
||||
const value = MCPOAuth.toCredential({
|
||||
const value = McpOAuth.toCredential({
|
||||
methodID,
|
||||
serverUrl: remote.url,
|
||||
tokens,
|
||||
client: previous ? MCPOAuth.clientFromCredential(previous) : undefined,
|
||||
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
|
||||
})
|
||||
presented = value.refresh
|
||||
await Effect.runPromise(credentials.update(credentialID, { value }))
|
||||
},
|
||||
clientInformation: async () => {
|
||||
const oauth = await readOAuthCredential()
|
||||
return oauth ? MCPOAuth.clientFromCredential(oauth) : undefined
|
||||
return oauth ? McpOAuth.clientFromCredential(oauth) : undefined
|
||||
},
|
||||
saveClientInformation: async () => {},
|
||||
codeVerifier: async () => undefined,
|
||||
@@ -330,7 +330,7 @@ export const layer = (options?: Options) =>
|
||||
const elicitation = {
|
||||
create: (input: {
|
||||
readonly server: string
|
||||
readonly params: MCPClient.ElicitationParams
|
||||
readonly params: McpClient.ElicitationParams
|
||||
readonly signal: AbortSignal
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -355,7 +355,7 @@ export const layer = (options?: Options) =>
|
||||
Effect.raceFirst(waitForAbort(input.signal)),
|
||||
Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
|
||||
Effect.map(
|
||||
(state): MCPClient.ElicitationResult => ({
|
||||
(state): McpClient.ElicitationResult => ({
|
||||
action: state.status === "answered" ? "accept" : "cancel",
|
||||
}),
|
||||
),
|
||||
@@ -375,13 +375,13 @@ export const layer = (options?: Options) =>
|
||||
})
|
||||
.pipe(
|
||||
Effect.raceFirst(waitForAbort(input.signal)),
|
||||
Effect.map((state): MCPClient.ElicitationResult => {
|
||||
Effect.map((state): McpClient.ElicitationResult => {
|
||||
if (state.status !== "answered") return { action: "cancel" }
|
||||
return {
|
||||
action: "accept",
|
||||
content: Object.fromEntries(
|
||||
Object.entries(state.answer).map(
|
||||
([key, value]): [string, NonNullable<MCPClient.ElicitationResult["content"]>[string]] =>
|
||||
([key, value]): [string, NonNullable<McpClient.ElicitationResult["content"]>[string]] =>
|
||||
typeof value === "object" ? [key, Array.from(value)] : [key, value],
|
||||
),
|
||||
),
|
||||
@@ -395,9 +395,9 @@ export const layer = (options?: Options) =>
|
||||
if (!formID) return
|
||||
yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
|
||||
}),
|
||||
} satisfies MCPClient.ElicitationHandler
|
||||
} satisfies McpClient.ElicitationHandler
|
||||
|
||||
const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) =>
|
||||
const toTool = (server: ServerName, entry: ServerEntry, def: McpClient.ToolDefinition) =>
|
||||
new Tool({
|
||||
server,
|
||||
name: def.name,
|
||||
@@ -407,7 +407,7 @@ export const layer = (options?: Options) =>
|
||||
outputSchema: def.outputSchema,
|
||||
})
|
||||
|
||||
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
|
||||
const toPrompt = (server: ServerName, def: McpClient.PromptDefinition) =>
|
||||
new Prompt({
|
||||
server,
|
||||
name: def.name,
|
||||
@@ -422,7 +422,7 @@ export const layer = (options?: Options) =>
|
||||
),
|
||||
})
|
||||
|
||||
const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) =>
|
||||
const toResource = (server: ServerName, def: McpClient.ResourceDefinition) =>
|
||||
Resource.make({
|
||||
server,
|
||||
name: def.name,
|
||||
@@ -431,7 +431,7 @@ export const layer = (options?: Options) =>
|
||||
mimeType: def.mimeType,
|
||||
})
|
||||
|
||||
const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) =>
|
||||
const toResourceTemplate = (server: ServerName, def: McpClient.ResourceTemplateDefinition) =>
|
||||
ResourceTemplate.make({
|
||||
server,
|
||||
name: def.name,
|
||||
@@ -440,14 +440,14 @@ export const layer = (options?: Options) =>
|
||||
mimeType: def.mimeType,
|
||||
})
|
||||
|
||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
|
||||
connection.tools().pipe(
|
||||
Effect.map((defs) => {
|
||||
entry.tools = defs.map((def) => toTool(name, entry, def))
|
||||
}),
|
||||
)
|
||||
|
||||
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
|
||||
connection.prompts().pipe(
|
||||
Effect.orElseSucceed(() => []),
|
||||
Effect.map((defs) => {
|
||||
@@ -459,7 +459,7 @@ export const layer = (options?: Options) =>
|
||||
// Runs a connection callback under the server lock, dropping it if the connection is no longer
|
||||
// the entry's live client, so late SDK callbacks cannot commit obsolete state.
|
||||
const whenLive =
|
||||
(name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
(name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
|
||||
<E>(effect: Effect.Effect<void, E>) =>
|
||||
fork(
|
||||
Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
|
||||
@@ -468,7 +468,7 @@ export const layer = (options?: Options) =>
|
||||
),
|
||||
)
|
||||
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
|
||||
const live = whenLive(name, entry, connection)
|
||||
connection.onClose(() =>
|
||||
live(
|
||||
@@ -491,7 +491,7 @@ export const layer = (options?: Options) =>
|
||||
connection.onResourcesChanged(() => live(bus.publish(McpEvent.ResourcesChanged, { server: name })))
|
||||
}
|
||||
|
||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
||||
const serverLog = (server: ServerName, message: McpClient.LogMessage) => {
|
||||
const fields = { server, logger: message.logger, level: message.level, data: message.data }
|
||||
switch (message.level) {
|
||||
case "debug":
|
||||
@@ -518,10 +518,10 @@ export const layer = (options?: Options) =>
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.scope = scope
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
const { MCPClient } = yield* Effect.promise(() => import("./client.js"))
|
||||
const { McpClient } = yield* Effect.promise(() => import("./client.js"))
|
||||
// List tools as part of connect so a failure here marks the server failed rather than
|
||||
// leaving it connected with a silently empty tool list and no path to recover.
|
||||
const result = yield* MCPClient.connect(
|
||||
const result = yield* McpClient.connect(
|
||||
name,
|
||||
entry.config,
|
||||
location.directory,
|
||||
@@ -555,7 +555,7 @@ export const layer = (options?: Options) =>
|
||||
entry.scope = undefined
|
||||
const error = Cause.squash(result.cause)
|
||||
entry.status =
|
||||
error instanceof MCPClient.NeedsAuthError
|
||||
error instanceof McpClient.NeedsAuthError
|
||||
? { status: "needs_auth" }
|
||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
||||
@@ -938,4 +938,4 @@ function toElicitationField(key: string, property: ElicitationProperty, required
|
||||
}
|
||||
}
|
||||
|
||||
type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]
|
||||
type ElicitationProperty = McpClient.ElicitationFormParams["requestedSchema"]["properties"][string]
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { MCP } from "./index.js"
|
||||
import { Mcp } from "./index.js"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
@@ -63,7 +63,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mc
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("McpInstructions.load")(function* (selection) {
|
||||
@@ -110,4 +110,4 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Mcp.node] })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as MCPOAuth from "./oauth.js"
|
||||
export * as McpOAuth from "./oauth.js"
|
||||
|
||||
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as MCPStdio from "./stdio.js"
|
||||
export * as McpStdio from "./stdio.js"
|
||||
|
||||
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Command } from "./command.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
import { Location } from "./location.js"
|
||||
import { PluginHost } from "./plugin/host.js"
|
||||
import { PluginRuntime } from "./plugin/runtime.js"
|
||||
@@ -197,7 +197,7 @@ export const node = makeLocationNode({
|
||||
Command.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
MCP.node,
|
||||
Mcp.node,
|
||||
Location.node,
|
||||
Reference.node,
|
||||
Skill.node,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Location } from "../location.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||
import PROMPT_REVIEW from "./command/review.txt"
|
||||
|
||||
@@ -12,20 +12,18 @@ export const Plugin = define({
|
||||
id: "opencode.command",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const bus = yield* Bus.Service
|
||||
const loaded = { prompts: [] as MCP.Prompt[] }
|
||||
yield* bus
|
||||
.subscribe(MCP.PromptsChanged)
|
||||
.pipe(
|
||||
Stream.runForEach(() =>
|
||||
mcp.prompts().pipe(
|
||||
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
),
|
||||
const loaded = { prompts: [] as Mcp.Prompt[] }
|
||||
yield* bus.subscribe(Mcp.PromptsChanged).pipe(
|
||||
Stream.runForEach(() =>
|
||||
mcp.prompts().pipe(
|
||||
Effect.tap((prompts) => Effect.sync(() => (loaded.prompts = prompts))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.prompts = yield* mcp.prompts()
|
||||
yield* ctx.command.transform((draft) => {
|
||||
draft.add({
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as PluginHost from "./host.js"
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { ServerConfig } from "@opencode-ai/schema/mcp"
|
||||
import { App } from "../app.js"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
@@ -16,7 +16,7 @@ import { Integration } from "../integration.js"
|
||||
import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Model } from "../model.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { PluginRuntime } from "./runtime.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { Reference } from "../reference.js"
|
||||
@@ -41,7 +41,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
const bus = yield* Bus.Service
|
||||
const integration = yield* Integration.Service
|
||||
const kv = yield* KV.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const location = yield* Location.Service
|
||||
const reference = yield* Reference.Service
|
||||
const skill = yield* Skill.Service
|
||||
@@ -295,7 +295,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
callback({
|
||||
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
|
||||
get: (name) => mutable(draft.get(name)),
|
||||
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(Mcp.ServerConfig)(config)),
|
||||
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
|
||||
update: draft.update,
|
||||
remove: draft.remove,
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ConfigFormatterPlugin } from "../config/plugin/formatter.js"
|
||||
import { ConfigImagePlugin } from "../config/plugin/image.js"
|
||||
import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
|
||||
import { ConfigMCPPlugin } from "../config/plugin/mcp.js"
|
||||
import { ConfigMcpPlugin } from "../config/plugin/mcp.js"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
|
||||
@@ -44,7 +44,7 @@ import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationMutation } from "../location-mutation.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Reference } from "../reference.js"
|
||||
@@ -78,7 +78,7 @@ import { AgentPlugin } from "./agent.js"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
import { MCPCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
|
||||
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
|
||||
import { ProviderPlugins } from "./provider.js"
|
||||
import { WebSearchPlugins } from "./websearch/index.js"
|
||||
import { PluginRuntime } from "./runtime.js"
|
||||
@@ -114,7 +114,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const location = yield* Location.Service
|
||||
const locationMutation = yield* LocationMutation.Service
|
||||
const models = yield* ModelsDev.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const npm = yield* Npm.Service
|
||||
const permission = yield* Permission.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
@@ -158,7 +158,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Location.Service, location),
|
||||
Context.make(LocationMutation.Service, locationMutation),
|
||||
Context.make(ModelsDev.Service, models),
|
||||
Context.make(MCP.Service, mcp),
|
||||
Context.make(Mcp.Service, mcp),
|
||||
Context.make(Npm.Service, npm),
|
||||
Context.make(Permission.Service, permission),
|
||||
Context.make(PluginRuntime.Service, runtime),
|
||||
@@ -209,7 +209,7 @@ export const requirements = LayerNode.group([
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
MCP.node,
|
||||
Mcp.node,
|
||||
Npm.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
@@ -234,8 +234,8 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
ConfigMCPPlugin.Plugin,
|
||||
MCPCodeModeExclusionPlugin.Plugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpCodeModeExclusionPlugin.Plugin,
|
||||
WellKnownPlugin.Plugin,
|
||||
VcsGitPlugin.Plugin,
|
||||
AgentPlugin.Plugin,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as MCPCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
|
||||
export * as McpCodeModeExclusionPlugin from "./mcp-codemode-exclusion.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Job } from "../job.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Session } from "../session.js"
|
||||
|
||||
export interface Interface {
|
||||
@@ -38,7 +38,7 @@ export interface Interface {
|
||||
readonly mcp: {
|
||||
readonly list: (
|
||||
ref: Location.Ref,
|
||||
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
|
||||
) => Effect.Effect<{ readonly location: Location.Info; readonly data: Mcp.ServerInfo[] }, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export const providerLayerWithCell = (cell: Cell) =>
|
||||
list: (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
return {
|
||||
location: new Location.Info({
|
||||
directory: location.directory,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } fro
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
|
||||
import { MCP } from "../mcp/index.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Tool } from "../tool.js"
|
||||
|
||||
@@ -26,12 +26,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mc
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
const tools = yield* Tool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const permission = yield* Permission.Service
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let discovered: MCP.Tool[] = []
|
||||
let discovered: Mcp.Tool[] = []
|
||||
|
||||
// Register once after initial discovery; only subsequent updates need a debounced reload.
|
||||
const initial = yield* lock
|
||||
@@ -134,5 +134,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
|
||||
deps: [Tool.node, Mcp.node, Bus.node, Permission.node],
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
@@ -43,7 +43,7 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
|
||||
[
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
[ShellSelect.node, shellLayer],
|
||||
@@ -431,9 +431,7 @@ function sourceCases() {
|
||||
{
|
||||
name: "updated",
|
||||
prepare: (directory: string) =>
|
||||
Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first")),
|
||||
),
|
||||
Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), markdown("Review first", "Review first"))),
|
||||
mutate: (directory: string) =>
|
||||
Effect.promise(async () => {
|
||||
const file = path.join(directory, "review.md")
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./location"
|
||||
|
||||
export const emptyMcpLayer = Layer.succeed(
|
||||
MCP.Service,
|
||||
MCP.Service.of({
|
||||
Mcp.Service,
|
||||
Mcp.Service.of({
|
||||
transform: () => Effect.die("unused mcp.transform"),
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
servers: () => Effect.succeed([]),
|
||||
@@ -20,7 +20,7 @@ export const emptyMcpLayer = Layer.succeed(
|
||||
instructions: () => Effect.succeed([]),
|
||||
prompts: () => Effect.succeed([]),
|
||||
prompt: () => Effect.undefined,
|
||||
resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||
resourceCatalog: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||
readResource: () => Effect.undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export * as TestWebSearch from "./websearch"
|
||||
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
|
||||
export interface Interface extends WebSearch.Interface {
|
||||
readonly queries: readonly WebSearch.Input[]
|
||||
/** Waits for query arrivals, not provider execution or query completion. */
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("test/WebSearch") {}
|
||||
|
||||
// No providers are installed: tests register local executors through transform.
|
||||
// The normal Bus and KV implementations use the default in-memory database.
|
||||
export const layer = Layer.effectContext(
|
||||
Effect.gen(function* () {
|
||||
const context = yield* Layer.build(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
|
||||
const websearch = Context.get(context, WebSearch.Service)
|
||||
const queries: WebSearch.Input[] = []
|
||||
let started = yield* Deferred.make<void>()
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
queries.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(() => wait(count))),
|
||||
)
|
||||
const test = Service.of({
|
||||
...websearch,
|
||||
queries,
|
||||
wait,
|
||||
query: Effect.fnUntraced(function* (input: WebSearch.Input) {
|
||||
queries.push({ ...input })
|
||||
const previous = started
|
||||
started = yield* Deferred.make<void>()
|
||||
yield* Deferred.succeed(previous, undefined)
|
||||
return yield* websearch.query(input)
|
||||
}),
|
||||
})
|
||||
return Context.add(context, WebSearch.Service, test).pipe(Context.add(Service, test))
|
||||
}),
|
||||
)
|
||||
@@ -31,7 +31,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -998,7 +998,7 @@ describe("LocationServiceMap", () => {
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
yield* supervisor.flush
|
||||
expect(observed.example).toBe(false)
|
||||
yield* mcp.add("dynamic", {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
@@ -17,15 +17,15 @@ const selection = (permissions: Permission.Ruleset = []) => {
|
||||
}
|
||||
|
||||
const instructions = (server: string, text: string) =>
|
||||
new MCP.ServerInstructions({ server: MCP.ServerName.make(server), instructions: text })
|
||||
new Mcp.ServerInstructions({ server: Mcp.ServerName.make(server), instructions: text })
|
||||
|
||||
const tool = (server: string, name = "search") => new MCP.Tool({ server: MCP.ServerName.make(server), name })
|
||||
const tool = (server: string, name = "search") => new Mcp.Tool({ server: Mcp.ServerName.make(server), name })
|
||||
|
||||
const layer = (catalog: () => MCP.ServerInstructions[], tools: () => MCP.Tool[]) =>
|
||||
const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) =>
|
||||
AppNodeBuilder.build(McpInstructions.node, [
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
Mcp.node,
|
||||
Layer.mock(Mcp.Service, {
|
||||
instructions: () => Effect.succeed(catalog()),
|
||||
tools: () => Effect.succeed(tools()),
|
||||
}),
|
||||
@@ -113,7 +113,7 @@ describe("McpInstructions", () => {
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => [instructions("alpha", "Alpha instructions")],
|
||||
() => [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })],
|
||||
() => [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -125,7 +125,7 @@ describe("McpInstructions", () => {
|
||||
const service = yield* McpInstructions.Service
|
||||
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
tools = [new MCP.Tool({ server: MCP.ServerName.make("alpha"), name: "search", codemode: false })]
|
||||
tools = [new Mcp.Tool({ server: Mcp.ServerName.make("alpha"), name: "search", codemode: false })]
|
||||
const changed = yield* readUpdate(yield* service.load(selection()), initialized)
|
||||
expect(changed.text).toBe(
|
||||
[
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { MCPOAuth } from "@opencode-ai/core/mcp/oauth"
|
||||
import { McpOAuth } from "@opencode-ai/core/mcp/oauth"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const authServer = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
|
||||
@@ -12,7 +12,7 @@ const authorize = (redirect_uri?: string) =>
|
||||
Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const authorization = yield* MCPOAuth.authorize({
|
||||
const authorization = yield* McpOAuth.authorize({
|
||||
name: "test",
|
||||
config: new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigMCPPlugin } from "@opencode-ai/core/config/plugin/mcp"
|
||||
import { ConfigMcpPlugin } from "@opencode-ai/core/config/plugin/mcp"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -27,9 +27,9 @@ import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { MCPClient } from "@opencode-ai/core/mcp/client"
|
||||
import { MCPStdio } from "@opencode-ai/core/mcp/stdio"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { McpClient } from "@opencode-ai/core/mcp/client"
|
||||
import { McpStdio } from "@opencode-ai/core/mcp/stdio"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -180,7 +180,7 @@ function resourceServer(
|
||||
function resourceMcpLayer(
|
||||
server: string | typeof ConfigMCP.Server.Type,
|
||||
onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
|
||||
options?: MCP.Options,
|
||||
options?: Mcp.Options,
|
||||
overrides?: {
|
||||
entries?: Config.Interface["entries"]
|
||||
subscribe?: Bus.Interface["subscribe"]
|
||||
@@ -193,10 +193,10 @@ function resourceMcpLayer(
|
||||
return Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* ConfigMCPPlugin.register(bus.subscribe())
|
||||
yield* ConfigMcpPlugin.register(bus.subscribe())
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provideMerge(MCP.layer(options)),
|
||||
Layer.provideMerge(Mcp.layer(options)),
|
||||
Layer.provideMerge(Form.layer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
@@ -267,13 +267,13 @@ function resourceMcpLayer(
|
||||
}
|
||||
|
||||
const connect = (server: string, config: typeof ConfigMCP.Server.Type, directory: string) =>
|
||||
MCPClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
|
||||
McpClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
|
||||
|
||||
const mcp = Layer.mock(MCP.Service, {
|
||||
const mcp = Layer.mock(Mcp.Service, {
|
||||
tools: () =>
|
||||
Effect.succeed([
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("demo"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: "search",
|
||||
description: "Search",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
@@ -283,28 +283,28 @@ const mcp = Layer.mock(MCP.Service, {
|
||||
required: ["ok"],
|
||||
},
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("demo"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: "status",
|
||||
description: "Status",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("direct"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("direct"),
|
||||
name: "lookup",
|
||||
codemode: false,
|
||||
description: "Lookup",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("direct"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("direct"),
|
||||
name: "fail",
|
||||
codemode: false,
|
||||
description: "Always fails",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("direct"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("direct"),
|
||||
name: "media",
|
||||
codemode: false,
|
||||
description: "Returns text and an image",
|
||||
@@ -315,15 +315,15 @@ const mcp = Layer.mock(MCP.Service, {
|
||||
Effect.sync(() => {
|
||||
calls += 1
|
||||
if (input.name === "fail")
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "search index unavailable" }],
|
||||
})
|
||||
if (input.name === "media")
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [
|
||||
@@ -332,14 +332,14 @@ const mcp = Layer.mock(MCP.Service, {
|
||||
],
|
||||
})
|
||||
if (input.name === "status")
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
structured: { ok: true },
|
||||
@@ -358,7 +358,7 @@ const permissions = Layer.mock(Permission.Service, {
|
||||
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
|
||||
[MCP.node, mcp],
|
||||
[Mcp.node, mcp],
|
||||
[Permission.node, permissions],
|
||||
[Bus.node, events],
|
||||
[Image.node, imagePassthrough],
|
||||
@@ -367,12 +367,12 @@ const it = testEffect(
|
||||
|
||||
describe("MCP errors", () => {
|
||||
test("expose useful messages", () => {
|
||||
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
|
||||
expect(new Mcp.NotFoundError({ server: Mcp.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
|
||||
expect(
|
||||
new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
|
||||
new Mcp.ToolCallError({ server: Mcp.ServerName.make("demo"), tool: "search", message: "failed" }).message,
|
||||
).toBe("failed")
|
||||
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
|
||||
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
||||
expect(new McpClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
|
||||
expect(new McpClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -488,7 +488,7 @@ test("spawns local MCP servers through the location environment", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* MCPClient.connect("environment", config, import.meta.dir)
|
||||
const connection = yield* McpClient.connect("environment", config, import.meta.dir)
|
||||
yield* connection.tools()
|
||||
}),
|
||||
).pipe(Effect.provide(recordingEnvironmentLayer(spawns))),
|
||||
@@ -513,7 +513,7 @@ test("reports a local MCP server as failed when the location has no execution pl
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* service.tools()
|
||||
const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
|
||||
expect(status).toEqual({
|
||||
@@ -528,7 +528,7 @@ test("rejects sends before the stdio transport is started", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* MCPStdio.make({
|
||||
const transport = yield* McpStdio.make({
|
||||
server: "not-started",
|
||||
command: process.execPath,
|
||||
args: [path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
@@ -551,7 +551,7 @@ test("joins concurrent stdio transport closes", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* MCPStdio.make({
|
||||
const transport = yield* McpStdio.make({
|
||||
server: "concurrent-close",
|
||||
command: "unused",
|
||||
args: [],
|
||||
@@ -605,7 +605,7 @@ test("closes a stdio process that finishes spawning after close", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* MCPStdio.make({
|
||||
const transport = yield* McpStdio.make({
|
||||
server: "close-during-spawn",
|
||||
command: "unused",
|
||||
args: [],
|
||||
@@ -816,7 +816,7 @@ for (const entry of [
|
||||
})
|
||||
const error = yield* connect("resources", config, import.meta.dir).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(MCPClient.ConnectError)
|
||||
expect(error).toBeInstanceOf(McpClient.ConnectError)
|
||||
expect(server.state.initializations).toBe(entry.attempts)
|
||||
expect(server.state.urls).toHaveLength(entry.attempts)
|
||||
if (entry.query || entry.codemode === false) expect(server.state.urls).toEqual([config.url])
|
||||
@@ -956,7 +956,7 @@ test("accepts empty MCP elicitations without creating forms", async () => {
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer({ resources: false, emptyElicitation: true })
|
||||
const result = yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
const forms = yield* Form.Service
|
||||
const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
|
||||
expect(yield* forms.list()).toEqual([])
|
||||
@@ -976,7 +976,7 @@ test("acknowledges completed MCP URL elicitations without returning internal con
|
||||
const server = yield* resourceServer({ resources: false, urlElicitation: true })
|
||||
const created = yield* Deferred.make<Form.Info>()
|
||||
const result = yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
const forms = yield* Form.Service
|
||||
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
|
||||
|
||||
@@ -1006,7 +1006,7 @@ test("loads and reads MCP resources", async () => {
|
||||
server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }]
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
expect(yield* service.resourceCatalog()).toEqual({
|
||||
resources: [
|
||||
{
|
||||
@@ -1053,12 +1053,12 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
expect(published).toContain(McpEvent.StatusChanged.type)
|
||||
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(MCP.NotFoundError)
|
||||
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({
|
||||
@@ -1101,7 +1101,7 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
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)
|
||||
expect(yield* service.remove("dynamic").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer(
|
||||
@@ -1125,7 +1125,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
|
||||
"manages live MCP servers entirely through scoped transforms",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -1168,7 +1168,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* service.servers()).map((server) => server.name)).toEqual([MCP.ServerName.make("resources")])
|
||||
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("resources")])
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
}),
|
||||
)
|
||||
@@ -1177,7 +1177,7 @@ test("restores runtime MCP config when a transform is disposed", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
const config = new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url: "https://example.com/mcp",
|
||||
@@ -1220,7 +1220,7 @@ test("isolates nested configured MCP mutations and reconciles them", async () =>
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
expect(published.filter((type) => type === McpEvent.StatusChanged.type)).toHaveLength(1)
|
||||
yield* service.transform((draft) =>
|
||||
draft.update("resources", (server) => {
|
||||
@@ -1263,7 +1263,7 @@ test("reconciles only changed MCP server config", async () => {
|
||||
} satisfies Payload<typeof Event.Updated>)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* service.tools()
|
||||
expect(server.state.toolLists).toBe(1)
|
||||
expect(server.state.initializations).toBe(1)
|
||||
@@ -1330,7 +1330,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
|
||||
// Whatever order the racing operations land in, the resulting state must be consistent.
|
||||
yield* Effect.all(
|
||||
@@ -1373,8 +1373,8 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin transforms through catalog updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const tool = (server: string, name: string, description = name) =>
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make(server),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make(server),
|
||||
name,
|
||||
description,
|
||||
codemode: false,
|
||||
@@ -1498,13 +1498,13 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
Mcp.node,
|
||||
Layer.mock(Mcp.Service, {
|
||||
tools: () => Ref.get(catalog),
|
||||
callTool: (input) =>
|
||||
Effect.succeed(
|
||||
new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "healthy" }],
|
||||
@@ -1541,12 +1541,12 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
Mcp.node,
|
||||
Layer.mock(Mcp.Service, {
|
||||
tools: () =>
|
||||
Effect.sync(() => [
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("demo"),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: `read_${++reads}`,
|
||||
codemode: false,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Command } from "@opencode-ai/core/command"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
@@ -24,8 +24,8 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service.of(location({ directory }, { projectDirectory: project })),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Command.node, MCP.node, Bus.node]), [
|
||||
[MCP.node, emptyMcpLayer],
|
||||
AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node]), [
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Generate } from "@opencode-ai/core/generate"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
@@ -72,7 +72,7 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
Command.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
MCP.node,
|
||||
Mcp.node,
|
||||
PluginRuntime.node,
|
||||
Permission.node,
|
||||
PluginHooks.node,
|
||||
@@ -89,7 +89,7 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
[Location.node, tempLocationLayer],
|
||||
[Npm.node, npmLayer],
|
||||
[Config.node, Config.testLayer()],
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Generate.node, generateLayer],
|
||||
[Permission.node, permissionLayer],
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { MCPCodeModeExclusionPlugin } from "@opencode-ai/core/plugin/mcp-codemode-exclusion"
|
||||
import { McpCodeModeExclusionPlugin } from "@opencode-ai/core/plugin/mcp-codemode-exclusion"
|
||||
import type { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Effect, type Types } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -51,7 +51,7 @@ it.effect("defaults only known Code Mode MCP servers to direct tools", () =>
|
||||
)
|
||||
const base = host()
|
||||
|
||||
yield* MCPCodeModeExclusionPlugin.Plugin.effect(
|
||||
yield* McpCodeModeExclusionPlugin.Plugin.effect(
|
||||
host({
|
||||
mcp: {
|
||||
...base.mcp,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Stream } from "effect"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Document, Info } from "@opencode-ai/schema/config"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
@@ -19,7 +18,7 @@ import { imagePassthrough } from "./lib/image"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
import { webSearchHost } from "./plugin/host"
|
||||
import { produce } from "immer"
|
||||
import { TestWebSearch } from "./lib/websearch"
|
||||
|
||||
const webSearchToolNode = makeLocationNode({
|
||||
name: "test/websearch-tool-plugin",
|
||||
@@ -33,142 +32,78 @@ const webSearchToolNode = makeLocationNode({
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_websearch_test")
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const queries: WebSearch.Input[] = []
|
||||
const formRequests: Form.CreateInput[] = []
|
||||
let selection: WebSearch.ID | "random" | false | undefined
|
||||
const providers = [
|
||||
{ id: WebSearch.ID.make("exa"), name: "Exa" },
|
||||
{ id: WebSearch.ID.make("parallel"), name: "Parallel" },
|
||||
]
|
||||
let providerRequired = false
|
||||
let formResponse: Form.TerminalState = { status: "cancelled" }
|
||||
const formResponses: Form.TerminalState[] = []
|
||||
let queryBarrier: Deferred.Deferred<void> | undefined
|
||||
let synchronizedQueries = 0
|
||||
let queryError: WebSearch.Error | undefined
|
||||
let result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
assertions.length = 0
|
||||
queries.length = 0
|
||||
formRequests.length = 0
|
||||
selection = undefined
|
||||
providerRequired = false
|
||||
formResponse = { status: "cancelled" }
|
||||
formResponses.length = 0
|
||||
queryBarrier = undefined
|
||||
synchronizedQueries = 0
|
||||
queryError = undefined
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
results: [{ url: "https://example.com", title: "Search results", content: "search results", time: {} }],
|
||||
})
|
||||
})
|
||||
class Fixture {
|
||||
assertions: Permission.AssertInput[] = []
|
||||
events: string[] = []
|
||||
formRequests: Form.CreateInput[] = []
|
||||
formResponse: Form.TerminalState = { status: "cancelled" }
|
||||
formResponses: Form.TerminalState[] = []
|
||||
formWait = Effect.void
|
||||
error: HttpClientError.HttpClientError | undefined
|
||||
results: readonly WebSearch.Result[] = [
|
||||
{ url: "https://example.com", title: "Search results", content: "search results", time: {} },
|
||||
]
|
||||
}
|
||||
|
||||
const permission = permissionLayer({
|
||||
assert: (input) => Effect.sync(() => assertions.push(input)),
|
||||
})
|
||||
const websearch = Layer.succeed(
|
||||
WebSearch.Service,
|
||||
WebSearch.Service.of({
|
||||
transform: (transform) =>
|
||||
Effect.sync(() => {
|
||||
transform({
|
||||
add: () => undefined,
|
||||
default: {
|
||||
get: () => selection,
|
||||
set: (next) => (selection = next),
|
||||
},
|
||||
})
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
reload: () => Effect.die("unused"),
|
||||
providers: () => Effect.succeed(providers),
|
||||
default: () =>
|
||||
Effect.gen(function* () {
|
||||
if (selection === false) return yield* new WebSearch.DisabledError()
|
||||
return selection ? providers.find((provider) => provider.id === selection) : undefined
|
||||
}),
|
||||
select: (next) => Effect.sync(() => (selection = next)),
|
||||
query: (input) =>
|
||||
Effect.gen(function* () {
|
||||
queries.push(input)
|
||||
if (queryBarrier && synchronizedQueries < 5) {
|
||||
synchronizedQueries++
|
||||
if (synchronizedQueries === 5) yield* Deferred.succeed(queryBarrier, undefined)
|
||||
yield* Deferred.await(queryBarrier)
|
||||
}
|
||||
if (queryError) return yield* queryError
|
||||
if (providerRequired && !selection) return yield* new WebSearch.ProviderRequiredError()
|
||||
if (selection)
|
||||
return new WebSearch.Response({
|
||||
providerID: selection === "random" ? result.providerID : WebSearch.ID.make(selection),
|
||||
results: result.results,
|
||||
})
|
||||
return result
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const form = Layer.succeed(
|
||||
Form.Service,
|
||||
Form.Service.of({
|
||||
create: () => Effect.die("unused"),
|
||||
ask: (input) =>
|
||||
Effect.sync(() => {
|
||||
formRequests.push(input)
|
||||
return formResponses.shift() ?? formResponse
|
||||
}),
|
||||
get: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
state: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({
|
||||
websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection },
|
||||
const it = testEffect(TestWebSearch.layer)
|
||||
const setup = Effect.gen(function* () {
|
||||
const fixture = new Fixture()
|
||||
const websearch = yield* TestWebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* websearch.transform((draft) =>
|
||||
providers.forEach((provider) =>
|
||||
draft.add({
|
||||
...provider,
|
||||
execute: () =>
|
||||
Effect.gen(function* () {
|
||||
fixture.events.push("query")
|
||||
if (fixture.error) return yield* fixture.error
|
||||
return fixture.results
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, webSearchToolNode]), [
|
||||
[
|
||||
Permission.node,
|
||||
permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
fixture.events.push("permission")
|
||||
fixture.assertions.push(input)
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
update: (update) =>
|
||||
Effect.sync(() => {
|
||||
const info = produce(
|
||||
new Info({
|
||||
websearch: selection === undefined ? undefined : selection === false ? false : { provider: selection },
|
||||
}),
|
||||
update,
|
||||
)
|
||||
selection = info.websearch === false ? false : info.websearch?.provider
|
||||
return info
|
||||
}),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, WebSearch.node, webSearchToolNode]), [
|
||||
[Permission.node, permission],
|
||||
[WebSearch.node, websearch],
|
||||
[Form.node, form],
|
||||
[Config.node, config],
|
||||
[Image.node, imagePassthrough],
|
||||
]),
|
||||
)
|
||||
],
|
||||
[WebSearch.node, Layer.succeed(WebSearch.Service, websearch)],
|
||||
[
|
||||
Form.node,
|
||||
Layer.mock(Form.Service, {
|
||||
ask: (input) =>
|
||||
Effect.gen(function* () {
|
||||
fixture.formRequests.push(input)
|
||||
yield* fixture.formWait
|
||||
return fixture.formResponses.shift() ?? fixture.formResponse
|
||||
}),
|
||||
}),
|
||||
],
|
||||
[Image.node, imagePassthrough],
|
||||
]),
|
||||
)
|
||||
return Object.assign(fixture, { websearch, kv, registry: Context.get(context, Tool.Service) })
|
||||
})
|
||||
|
||||
describe("WebSearchTool registration", () => {
|
||||
it.effect("asserts permission before delegating to WebSearch", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
const registry = fixture.registry
|
||||
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["websearch", "execute"])
|
||||
expect(
|
||||
@@ -186,7 +121,7 @@ describe("WebSearchTool registration", () => {
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "## [Search results](https://example.com)\n\nsearch results" }],
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
expect(fixture.assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "websearch",
|
||||
@@ -195,28 +130,29 @@ describe("WebSearchTool registration", () => {
|
||||
metadata: { query: "effect typescript" },
|
||||
},
|
||||
])
|
||||
expect(queries).toEqual([
|
||||
expect(fixture.websearch.queries).toEqual([
|
||||
{
|
||||
query: "effect typescript",
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
},
|
||||
])
|
||||
expect(fixture.events).toEqual(["permission", "query"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps normalized results in structured output", () =>
|
||||
Effect.gen(function* () {
|
||||
result = new WebSearch.Response({
|
||||
providerID: WebSearch.ID.make("parallel"),
|
||||
results: [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "parallel results",
|
||||
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
|
||||
},
|
||||
],
|
||||
})
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
yield* fixture.websearch.select(WebSearch.ID.make("parallel"))
|
||||
fixture.results = [
|
||||
{
|
||||
url: "https://effect.website",
|
||||
title: "Effect",
|
||||
content: "parallel results",
|
||||
time: { published: Date.parse("2026-07-25T00:00:00.000Z") },
|
||||
},
|
||||
]
|
||||
const registry = fixture.registry
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -250,8 +186,10 @@ describe("WebSearchTool registration", () => {
|
||||
|
||||
it.effect("uses the concise no-results fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
result = new WebSearch.Response({ providerID: WebSearch.ID.make("exa"), results: [] })
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
|
||||
fixture.results = []
|
||||
const registry = fixture.registry
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -268,20 +206,21 @@ describe("WebSearchTool registration", () => {
|
||||
|
||||
it.effect("asks once and uses the default provider when web search is first enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
const registry = fixture.registry
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
|
||||
expect(selection).toBe("random")
|
||||
expect(queries).toHaveLength(2)
|
||||
expect(formRequests).toEqual([
|
||||
const first = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
|
||||
})
|
||||
expect(first.status).toBe("completed")
|
||||
expect(["exa", "parallel"]).toContain(first.metadata?.provider)
|
||||
expect(first.metadata?.provider).toBe(fixture.websearch.queries[1]?.providerID)
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
|
||||
expect(fixture.websearch.queries).toHaveLength(2)
|
||||
expect(fixture.formRequests).toEqual([
|
||||
{
|
||||
sessionID,
|
||||
title: "Web Search",
|
||||
@@ -309,26 +248,27 @@ describe("WebSearchTool registration", () => {
|
||||
},
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
|
||||
expect(formRequests).toHaveLength(1)
|
||||
expect(queries).toHaveLength(3)
|
||||
const second = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-enabled", name: "websearch", input: { query: "effect schema" } },
|
||||
})
|
||||
expect(second.status).toBe("completed")
|
||||
expect(["exa", "parallel"]).toContain(second.metadata?.provider)
|
||||
expect(second.metadata?.provider).toBe(fixture.websearch.queries[2]?.providerID)
|
||||
expect(fixture.formRequests).toHaveLength(1)
|
||||
expect(fixture.websearch.queries).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("asks a second form when choosing another provider", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponses.push(
|
||||
const fixture = yield* setup
|
||||
fixture.formResponses.push(
|
||||
{ status: "answered", answer: { choice: "choose" } },
|
||||
{ status: "answered", answer: { provider: "parallel" } },
|
||||
)
|
||||
const registry = yield* Tool.Service
|
||||
const registry = fixture.registry
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -337,9 +277,10 @@ describe("WebSearchTool registration", () => {
|
||||
call: { type: "tool-call", id: "call-choose", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "parallel" } })
|
||||
expect(selection).toBe(WebSearch.ID.make("parallel"))
|
||||
expect(queries).toHaveLength(2)
|
||||
expect(formRequests[1]).toEqual({
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe(WebSearch.ID.make("parallel"))
|
||||
expect(fixture.websearch.queries).toHaveLength(2)
|
||||
expect(fixture.websearch.queries[1]?.providerID).toBe(WebSearch.ID.make("parallel"))
|
||||
expect(fixture.formRequests[1]).toEqual({
|
||||
sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
@@ -362,10 +303,10 @@ describe("WebSearchTool registration", () => {
|
||||
|
||||
it.effect("shares provider consent across concurrent searches", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
queryBarrier = yield* Deferred.make<void>()
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
fixture.formWait = fixture.websearch.wait(5)
|
||||
const registry = fixture.registry
|
||||
|
||||
const results = yield* Effect.all(
|
||||
Array.from({ length: 5 }, (_, index) =>
|
||||
@@ -384,16 +325,16 @@ describe("WebSearchTool registration", () => {
|
||||
)
|
||||
|
||||
expect(results.every((item) => item.status === "completed")).toBe(true)
|
||||
expect(formRequests).toHaveLength(1)
|
||||
expect(selection).toBe("random")
|
||||
expect(fixture.formRequests).toHaveLength(1)
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists the choice to disable web search", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponse = { status: "answered", answer: { choice: "disable" } }
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
fixture.formResponse = { status: "answered", answer: { choice: "disable" } }
|
||||
const registry = fixture.registry
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -402,16 +343,18 @@ describe("WebSearchTool registration", () => {
|
||||
call: { type: "tool-call", id: "call-disable", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "error" })
|
||||
expect(selection).toBe(false)
|
||||
expect(queries).toHaveLength(1)
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe(false)
|
||||
expect(yield* fixture.websearch.default().pipe(Effect.flip)).toBeInstanceOf(WebSearch.DisabledError)
|
||||
expect(fixture.websearch.queries).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports safe HTTP failures with the attempted provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const fixture = yield* setup
|
||||
const registry = fixture.registry
|
||||
const tools = yield* registry.snapshot()
|
||||
selection = WebSearch.ID.make("exa")
|
||||
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
|
||||
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
@@ -422,14 +365,11 @@ describe("WebSearchTool registration", () => {
|
||||
({ status, message }, index) =>
|
||||
Effect.gen(function* () {
|
||||
const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
|
||||
queryError = new WebSearch.RequestError({
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
cause: new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.StatusCodeError({
|
||||
request,
|
||||
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
|
||||
description: "non 2xx status code",
|
||||
}),
|
||||
fixture.error = new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.StatusCodeError({
|
||||
request,
|
||||
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
|
||||
description: "non 2xx status code",
|
||||
}),
|
||||
})
|
||||
const progress: Tool.Metadata[] = []
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { TestWebSearch } from "./lib/websearch"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
|
||||
const it = testEffect(TestWebSearch.layer)
|
||||
|
||||
const register = (id: string) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -36,11 +35,28 @@ const register = (id: string) =>
|
||||
})
|
||||
|
||||
describe("WebSearch", () => {
|
||||
it.effect("shares the normal and test interfaces without installing live providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const test = yield* TestWebSearch.Service
|
||||
|
||||
expect(websearch).toBe(test)
|
||||
expect(yield* websearch.providers()).toEqual([])
|
||||
expect(test.queries).toEqual([])
|
||||
expect((yield* websearch.query({ query: "unconfigured" }).pipe(Effect.flip))._tag).toBe(
|
||||
"WebSearch.ProviderRequired",
|
||||
)
|
||||
yield* test.wait(1)
|
||||
expect(test.queries).toEqual([{ query: "unconfigured" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes an explicit provider without changing the default", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const test = yield* TestWebSearch.Service
|
||||
|
||||
expect(yield* websearch.query({ query: "effect", providerID: parallel.providerID })).toEqual(
|
||||
new WebSearch.Response({
|
||||
@@ -57,6 +73,7 @@ describe("WebSearch", () => {
|
||||
)
|
||||
expect((yield* websearch.query({ query: "default" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
|
||||
expect(parallel.calls).toEqual([{ query: "effect" }])
|
||||
expect(test.queries).toEqual([{ query: "effect", providerID: parallel.providerID }, { query: "default" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -81,6 +98,23 @@ describe("WebSearch", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reloads active transforms from their current source", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const source = { providerID: exa.providerID }
|
||||
yield* websearch.transform((draft) => draft.default.set(source.providerID))
|
||||
|
||||
expect((yield* websearch.default())?.id).toBe(exa.providerID)
|
||||
source.providerID = parallel.providerID
|
||||
const reload = yield* websearch.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect((yield* websearch.default())?.id).toBe(parallel.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists the selected provider in KV", () =>
|
||||
Effect.gen(function* () {
|
||||
const parallel = yield* register("parallel")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { McpServerNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
const notFound = <A, R>(effect: Effect.Effect<A, MCP.NotFoundError, R>) =>
|
||||
const notFound = <A, R>(effect: Effect.Effect<A, Mcp.NotFoundError, R>) =>
|
||||
effect.pipe(Effect.mapError((error) => new McpServerNotFoundError({ server: error.server, message: error.message })))
|
||||
|
||||
export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
@@ -14,7 +14,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.list",
|
||||
Effect.fn(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
return yield* response(
|
||||
service
|
||||
.servers()
|
||||
@@ -29,7 +29,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.add",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* service.add(ctx.params.server, ctx.payload.config)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
@@ -37,7 +37,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.remove",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* notFound(service.remove(ctx.params.server))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
@@ -45,7 +45,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.connect",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* notFound(service.connect(ctx.params.server))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
@@ -53,7 +53,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.disconnect",
|
||||
Effect.fn(function* (ctx) {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
yield* notFound(service.disconnect(ctx.params.server))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
@@ -61,7 +61,7 @@ export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) =>
|
||||
.handle(
|
||||
"mcp.resource.catalog",
|
||||
Effect.fn(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const service = yield* Mcp.Service
|
||||
return yield* response(service.resourceCatalog())
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
@@ -118,8 +118,8 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
|
||||
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
|
||||
[
|
||||
MCP.node,
|
||||
MCP.configured({
|
||||
Mcp.node,
|
||||
Mcp.configured({
|
||||
clientInfo: {
|
||||
name: options.app?.name ?? "opencode",
|
||||
version: options.app?.version ?? "unknown",
|
||||
|
||||
Reference in New Issue
Block a user