Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton 4374eebf0e refactor(httpapi): simplify file search bridge 2026-04-24 01:14:46 -04:00
Kit Langton ad02a76122 feat(httpapi): bridge file search endpoints 2026-04-24 01:07:34 -04:00
Kit Langton 83ea1e0ce1 feat(httpapi): bridge mcp status endpoint 2026-04-24 01:03:31 -04:00
Kit Langton ca38a26b2f feat(httpapi): bridge file read endpoints 2026-04-24 01:00:56 -04:00
12 changed files with 538 additions and 186 deletions
+8 -5
View File
@@ -412,10 +412,11 @@ Current instance route inventory:
- `workspace` - `bridged`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `later`
good JSON-only candidate set, but larger than the current first-wave slices
- `mcp` - `later`
has JSON-only endpoints, but interactive OAuth/auth flows make it a worse early fit
- `file` - `bridged` (partial)
bridged endpoints: `GET /find`, `GET /find/file`, `GET /find/symbol`, `GET /file`, `GET /file/content`, `GET /file/status`
- `mcp` - `bridged` (partial)
bridged endpoints: `GET /mcp`
defer interactive OAuth/auth flows first
- `session` - `defer`
large, stateful, mixes CRUD with prompt/shell/command/share/revert flows and a streaming route
- `event` - `defer`
@@ -449,7 +450,9 @@ Recommended near-term sequence:
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [x] port `GET /config` full read endpoint
- [x] port `workspace` read endpoints
- [ ] port `file` JSON read endpoints
- [x] port `file` JSON read endpoints
- [x] port `file` search read endpoints
- [x] port `mcp` status read endpoint
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb
+48 -54
View File
@@ -9,69 +9,63 @@ import { formatPatch, structuredPatch } from "diff"
import fuzzysort from "fuzzysort"
import ignore from "ignore"
import path from "path"
import z from "zod"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Log } from "../util"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
import { zod } from "@/util/effect-zod"
import { type DeepMutable, withStatics } from "@/util/schema"
export const Info = z
.object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export const Info = Schema.Struct({
path: Schema.String,
added: Schema.Int,
removed: Schema.Int,
status: Schema.Literals(["added", "deleted", "modified"]),
})
.annotate({ identifier: "File" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
export type Info = z.infer<typeof Info>
export const Node = Schema.Struct({
name: Schema.String,
path: Schema.String,
absolute: Schema.String,
type: Schema.Literals(["file", "directory"]),
ignored: Schema.Boolean,
})
.annotate({ identifier: "FileNode" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
export const Node = z
.object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
const Hunk = Schema.Struct({
oldStart: Schema.Number,
oldLines: Schema.Number,
newStart: Schema.Number,
newLines: Schema.Number,
lines: Schema.Array(Schema.String),
})
export const Content = z
.object({
type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
const Patch = Schema.Struct({
oldFileName: Schema.String,
newFileName: Schema.String,
oldHeader: Schema.optional(Schema.String),
newHeader: Schema.optional(Schema.String),
hunks: Schema.Array(Hunk),
index: Schema.optional(Schema.String),
})
export const Content = Schema.Struct({
type: Schema.Literals(["text", "binary"]),
content: Schema.String,
diff: Schema.optional(Schema.String),
patch: Schema.optional(Patch),
encoding: Schema.optional(Schema.Literal("base64")),
mimeType: Schema.optional(Schema.String),
})
.annotate({ identifier: "FileContent" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Content = DeepMutable<Schema.Schema.Type<typeof Content>>
export const Event = {
Edited: BusEvent.define(
+61 -62
View File
@@ -1,7 +1,6 @@
import path from "path"
import z from "zod"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Cause, Context, Effect, Fiber, Layer, Queue, Stream } from "effect"
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
@@ -12,6 +11,8 @@ import { Global } from "@/global"
import { Log } from "@/util"
import { sanitizedProcessEnv } from "@/util/opencode-process"
import { which } from "@/util/which"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0"
@@ -25,83 +26,81 @@ const PLATFORM = {
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
const Stats = z.object({
elapsed: z.object({
secs: z.number(),
nanos: z.number(),
human: z.string(),
}),
searches: z.number(),
searches_with_match: z.number(),
bytes_searched: z.number(),
bytes_printed: z.number(),
matched_lines: z.number(),
matches: z.number(),
const TimeStats = Schema.Struct({
secs: Schema.Number,
nanos: Schema.Number,
human: Schema.String,
})
const Begin = z.object({
type: z.literal("begin"),
data: z.object({
path: z.object({
text: z.string(),
}),
const Stats = Schema.Struct({
elapsed: TimeStats,
searches: Schema.Number,
searches_with_match: Schema.Number,
bytes_searched: Schema.Number,
bytes_printed: Schema.Number,
matched_lines: Schema.Number,
matches: Schema.Number,
})
const PathText = Schema.Struct({
text: Schema.String,
})
const Begin = Schema.Struct({
type: Schema.Literal("begin"),
data: Schema.Struct({
path: PathText,
}),
})
export const Match = z.object({
type: z.literal("match"),
data: z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
export const SearchMatch = Schema.Struct({
path: PathText,
lines: Schema.Struct({
text: Schema.String,
}),
line_number: Schema.Number,
absolute_offset: Schema.Number,
submatches: Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
),
}),
start: Schema.Number,
end: Schema.Number,
}),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Match = Schema.Struct({
type: Schema.Literal("match"),
data: SearchMatch,
})
const End = z.object({
type: z.literal("end"),
data: z.object({
path: z.object({
text: z.string(),
}),
binary_offset: z.number().nullable(),
const End = Schema.Struct({
type: Schema.Literal("end"),
data: Schema.Struct({
path: PathText,
binary_offset: Schema.NullOr(Schema.Number),
stats: Stats,
}),
})
const Summary = z.object({
type: z.literal("summary"),
data: z.object({
elapsed_total: z.object({
human: z.string(),
nanos: z.number(),
secs: z.number(),
}),
const Summary = Schema.Struct({
type: Schema.Literal("summary"),
data: Schema.Struct({
elapsed_total: TimeStats,
stats: Stats,
}),
})
const Result = z.union([Begin, Match, End, Summary])
const Result = Schema.Union([Begin, Match, End, Summary]).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match>
export type Result = Schema.Schema.Type<typeof Result>
export type Match = Schema.Schema.Type<typeof Match>
export type Item = Match["data"]
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>
export type Begin = Schema.Schema.Type<typeof Begin>
export type End = Schema.Schema.Type<typeof End>
export type Summary = Schema.Schema.Type<typeof Summary>
export type Row = Match["data"]
export interface SearchResult {
@@ -188,7 +187,7 @@ function row(data: Row): Row {
function parse(line: string) {
return Effect.try({
try: () => Result.parse(JSON.parse(line)),
try: () => Result.zod.parse(JSON.parse(line)),
catch: (cause) => new Error("invalid ripgrep output", { cause }),
})
}
+29 -44
View File
@@ -30,6 +30,8 @@ import { EffectBridge } from "@/effect"
import { InstanceState } from "@/effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { zod as effectZod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
@@ -69,50 +71,33 @@ export const Failed = NamedError.create(
type MCPClient = Client
export const Status = z
.discriminatedUnion("status", [
z
.object({
status: z.literal("connected"),
})
.meta({
ref: "MCPStatusConnected",
}),
z
.object({
status: z.literal("disabled"),
})
.meta({
ref: "MCPStatusDisabled",
}),
z
.object({
status: z.literal("failed"),
error: z.string(),
})
.meta({
ref: "MCPStatusFailed",
}),
z
.object({
status: z.literal("needs_auth"),
})
.meta({
ref: "MCPStatusNeedsAuth",
}),
z
.object({
status: z.literal("needs_client_registration"),
error: z.string(),
})
.meta({
ref: "MCPStatusNeedsClientRegistration",
}),
])
.meta({
ref: "MCPStatus",
})
export type Status = z.infer<typeof Status>
const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
identifier: "MCPStatusConnected",
})
const StatusDisabled = Schema.Struct({ status: Schema.Literal("disabled") }).annotate({
identifier: "MCPStatusDisabled",
})
const StatusFailed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }).annotate({
identifier: "MCPStatusFailed",
})
const StatusNeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
identifier: "MCPStatusNeedsAuth",
})
const StatusNeedsClientRegistration = Schema.Struct({
status: Schema.Literal("needs_client_registration"),
error: Schema.String,
}).annotate({ identifier: "MCPStatusNeedsClientRegistration" })
export const Status = Schema.Union([
StatusConnected,
StatusDisabled,
StatusFailed,
StatusNeedsAuth,
StatusNeedsClientRegistration,
])
.annotate({ identifier: "MCPStatus", discriminator: "status" })
.pipe(withStatics((s) => ({ zod: effectZod(s) })))
export type Status = Schema.Schema.Type<typeof Status>
// Store transports for OAuth servers to allow finishing auth
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
@@ -21,7 +21,7 @@ export const FileRoutes = lazy(() =>
description: "Matches",
content: {
"application/json": {
schema: resolver(Ripgrep.Match.shape.data.array()),
schema: resolver(Ripgrep.SearchMatch.zod.array()),
},
},
},
@@ -117,7 +117,7 @@ export const FileRoutes = lazy(() =>
description: "Files and directories",
content: {
"application/json": {
schema: resolver(File.Node.array()),
schema: resolver(File.Node.zod.array()),
},
},
},
@@ -146,7 +146,7 @@ export const FileRoutes = lazy(() =>
description: "File content",
content: {
"application/json": {
schema: resolver(File.Content),
schema: resolver(File.Content.zod),
},
},
},
@@ -175,7 +175,7 @@ export const FileRoutes = lazy(() =>
description: "File status",
content: {
"application/json": {
schema: resolver(File.Info.array()),
schema: resolver(File.Info.zod.array()),
},
},
},
@@ -0,0 +1,171 @@
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import * as InstanceState from "@/effect/instance-state"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LSP } from "@/lsp"
const FileQuery = Schema.Struct({
path: Schema.String,
})
const FindTextQuery = Schema.Struct({
pattern: Schema.String,
})
const FindFileQuery = Schema.Struct({
query: Schema.String,
dirs: Schema.optional(Schema.Literals(["true", "false"])),
type: Schema.optional(Schema.Literals(["file", "directory"])),
limit: Schema.optional(
Schema.NumberFromString.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(1)).check(
Schema.isLessThanOrEqualTo(200),
),
),
})
const FindSymbolQuery = Schema.Struct({
query: Schema.String,
})
export const FilePaths = {
find: "/find",
findFile: "/find/file",
findSymbol: "/find/symbol",
list: "/file",
content: "/file/content",
status: "/file/status",
} as const
export const FileApi = HttpApi.make("file")
.add(
HttpApiGroup.make("file")
.add(
HttpApiEndpoint.get("find", FilePaths.find, {
query: FindTextQuery,
success: Schema.Array(Ripgrep.SearchMatch),
}).annotateMerge(
OpenApi.annotations({
identifier: "find.text",
summary: "Find text",
description: "Search for text patterns across files in the project using ripgrep.",
}),
),
HttpApiEndpoint.get("findFile", FilePaths.findFile, {
query: FindFileQuery,
success: Schema.Array(Schema.String),
}).annotateMerge(
OpenApi.annotations({
identifier: "find.files",
summary: "Find files",
description: "Search for files or directories by name or pattern in the project directory.",
}),
),
HttpApiEndpoint.get("findSymbol", FilePaths.findSymbol, {
query: FindSymbolQuery,
success: Schema.Array(LSP.Symbol),
}).annotateMerge(
OpenApi.annotations({
identifier: "find.symbols",
summary: "Find symbols",
description: "Search for workspace symbols like functions, classes, and variables using LSP.",
}),
),
HttpApiEndpoint.get("list", FilePaths.list, {
query: FileQuery,
success: Schema.Array(File.Node),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.list",
summary: "List files",
description: "List files and directories in a specified path.",
}),
),
HttpApiEndpoint.get("content", FilePaths.content, {
query: FileQuery,
success: File.Content,
}).annotateMerge(
OpenApi.annotations({
identifier: "file.read",
summary: "Read file",
description: "Read the content of a specified file.",
}),
),
HttpApiEndpoint.get("status", FilePaths.status, {
success: Schema.Array(File.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "file.status",
summary: "Get file status",
description: "Get the git status of all files in the project.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "file",
description: "Experimental HttpApi file routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const fileHandlers = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* File.Service
const ripgrep = yield* Ripgrep.Service
const lsp = yield* LSP.Service
const find = Effect.fn("FileHttpApi.find")(function* (ctx: { query: { pattern: string } }) {
return yield* ripgrep
.search({ cwd: yield* InstanceState.directory, pattern: ctx.query.pattern, limit: 10 })
.pipe(
Effect.map((result) => result.items),
Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))),
)
})
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
}) {
return yield* svc.search({
query: ctx.query.query,
limit: ctx.query.limit ?? 10,
dirs: ctx.query.dirs !== "false",
type: ctx.query.type,
})
})
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* (ctx: { query: { query: string } }) {
return yield* lsp.workspaceSymbol(ctx.query.query)
})
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
return yield* svc.list(ctx.query.path)
})
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
return yield* svc.read(ctx.query.path)
})
const status = Effect.fn("FileHttpApi.status")(function* () {
return yield* svc.status()
})
return HttpApiBuilder.group(FileApi, "file", (handlers) =>
handlers
.handle("find", find)
.handle("findFile", findFile)
.handle("findSymbol", findSymbol)
.handle("list", list)
.handle("content", content)
.handle("status", status),
)
}),
).pipe(Layer.provide(File.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(LSP.defaultLayer))
@@ -0,0 +1,48 @@
import { MCP } from "@/mcp"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const McpPaths = {
status: "/mcp",
} as const
export const McpApi = HttpApi.make("mcp")
.add(
HttpApiGroup.make("mcp")
.add(
HttpApiEndpoint.get("status", McpPaths.status, {
success: Schema.Record(Schema.String, MCP.Status),
}).annotateMerge(
OpenApi.annotations({
identifier: "mcp.status",
summary: "Get MCP status",
description: "Get the status of all Model Context Protocol (MCP) servers.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "mcp",
description: "Experimental HttpApi MCP routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const mcpHandlers = Layer.unwrap(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const status = Effect.fn("McpHttpApi.status")(function* () {
return yield* mcp.status()
})
return HttpApiBuilder.group(McpApi, "mcp", (handlers) => handlers.handle("status", status))
}),
).pipe(Layer.provide(MCP.defaultLayer))
@@ -10,6 +10,8 @@ import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util"
import { ConfigApi, configHandlers } from "./config"
import { FileApi, fileHandlers } from "./file"
import { McpApi, mcpHandlers } from "./mcp"
import { PermissionApi, permissionHandlers } from "./permission"
import { ProjectApi, projectHandlers } from "./project"
import { ProviderApi, providerHandlers } from "./provider"
@@ -114,9 +116,13 @@ const ProjectSecured = ProjectApi.middleware(Authorization)
const ProviderSecured = ProviderApi.middleware(Authorization)
const ConfigSecured = ConfigApi.middleware(Authorization)
const WorkspaceSecured = WorkspaceApi.middleware(Authorization)
const FileSecured = FileApi.middleware(Authorization)
const McpSecured = McpApi.middleware(Authorization)
export const routes = Layer.mergeAll(
HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)),
HttpApiBuilder.layer(FileSecured).pipe(Layer.provide(fileHandlers)),
HttpApiBuilder.layer(McpSecured).pipe(Layer.provide(mcpHandlers)),
HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)),
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
@@ -16,6 +16,8 @@ import { QuestionRoutes } from "./question"
import { PermissionRoutes } from "./permission"
import { Flag } from "@/flag/flag"
import { ExperimentalHttpApiServer } from "./httpapi/server"
import { FilePaths } from "./httpapi/file"
import { McpPaths } from "./httpapi/mcp"
import { ProjectRoutes } from "./project"
import { SessionRoutes } from "./session"
import { PtyRoutes } from "./pty"
@@ -35,19 +37,38 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
const handler = ExperimentalHttpApiServer.webHandler().handler
const context = Context.empty() as Context.Context<unknown>
app.get("/question", (c) => handler(c.req.raw, context))
app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context))
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
app.get("/permission", (c) => handler(c.req.raw, context))
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
app.get("/config", (c) => handler(c.req.raw, context))
app.get("/config/providers", (c) => handler(c.req.raw, context))
app.get("/provider", (c) => handler(c.req.raw, context))
app.get("/provider/auth", (c) => handler(c.req.raw, context))
app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context))
app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
app.get("/project", (c) => handler(c.req.raw, context))
app.get("/project/current", (c) => handler(c.req.raw, context))
app.on(
"GET",
[
"/question",
"/permission",
"/config",
"/config/providers",
"/provider",
"/provider/auth",
"/project",
"/project/current",
FilePaths.find,
FilePaths.findFile,
FilePaths.findSymbol,
FilePaths.list,
FilePaths.content,
FilePaths.status,
McpPaths.status,
],
(c) => handler(c.req.raw, context),
)
app.on(
"POST",
[
"/question/:requestID/reply",
"/question/:requestID/reject",
"/permission/:requestID/reply",
"/provider/:providerID/oauth/authorize",
"/provider/:providerID/oauth/callback",
],
(c) => handler(c.req.raw, context),
)
}
return app
@@ -21,7 +21,7 @@ export const McpRoutes = lazy(() =>
description: "MCP server status",
content: {
"application/json": {
schema: resolver(z.record(z.string(), MCP.Status)),
schema: resolver(z.record(z.string(), MCP.Status.zod)),
},
},
},
@@ -44,7 +44,7 @@ export const McpRoutes = lazy(() =>
description: "MCP server added successfully",
content: {
"application/json": {
schema: resolver(z.record(z.string(), MCP.Status)),
schema: resolver(z.record(z.string(), MCP.Status.zod)),
},
},
},
@@ -121,7 +121,7 @@ export const McpRoutes = lazy(() =>
description: "OAuth authentication completed",
content: {
"application/json": {
schema: resolver(MCP.Status),
schema: resolver(MCP.Status.zod),
},
},
},
@@ -153,7 +153,7 @@ export const McpRoutes = lazy(() =>
description: "OAuth authentication completed",
content: {
"application/json": {
schema: resolver(MCP.Status),
schema: resolver(MCP.Status.zod),
},
},
},
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Context } from "effect"
import path from "path"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { FilePaths } from "../../src/server/routes/instance/httpapi/file"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const context = Context.empty() as Context.Context<unknown>
function request(route: string, directory: string, query?: Record<string, string>) {
const url = new URL(`http://localhost${route}`)
for (const [key, value] of Object.entries(query ?? {})) {
url.searchParams.set(key, value)
}
return ExperimentalHttpApiServer.webHandler().handler(
new Request(url, {
headers: {
"x-opencode-directory": directory,
},
}),
context,
)
}
afterEach(async () => {
await Instance.disposeAll()
await resetDatabase()
})
describe("file HttpApi", () => {
test("serves read endpoints", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(path.join(tmp.path, "hello.txt"), "hello")
const [list, content, status] = await Promise.all([
request(FilePaths.list, tmp.path, { path: "." }),
request(FilePaths.content, tmp.path, { path: "hello.txt" }),
request(FilePaths.status, tmp.path),
])
expect(list.status).toBe(200)
expect(await list.json()).toContainEqual(
expect.objectContaining({ name: "hello.txt", path: "hello.txt", type: "file" }),
)
expect(content.status).toBe(200)
expect(await content.json()).toMatchObject({ type: "text", content: "hello" })
expect(status.status).toBe(200)
expect(await status.json()).toContainEqual({ path: "hello.txt", added: 1, removed: 0, status: "added" })
})
test("serves search endpoints", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(path.join(tmp.path, "hello.txt"), "hello search target")
const [find, findFile, findSymbol] = await Promise.all([
request(FilePaths.find, tmp.path, { pattern: "search target" }),
request(FilePaths.findFile, tmp.path, { query: "hello", limit: "10" }),
request(FilePaths.findSymbol, tmp.path, { query: "anything" }),
])
expect(find.status).toBe(200)
expect(await find.json()).toContainEqual(expect.objectContaining({ path: { text: "hello.txt" } }))
expect(findFile.status).toBe(200)
expect(await findFile.json()).toContain("hello.txt")
expect(findSymbol.status).toBe(200)
expect(await findSymbol.json()).toEqual([])
})
})
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Context } from "effect"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { McpPaths } from "../../src/server/routes/instance/httpapi/mcp"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const context = Context.empty() as Context.Context<unknown>
function request(route: string, directory: string) {
return ExperimentalHttpApiServer.webHandler().handler(
new Request(`http://localhost${route}`, {
headers: {
"x-opencode-directory": directory,
},
}),
context,
)
}
afterEach(async () => {
await Instance.disposeAll()
await resetDatabase()
})
describe("mcp HttpApi", () => {
test("serves status endpoint", async () => {
await using tmp = await tmpdir({
config: {
mcp: {
demo: {
type: "local",
command: ["echo", "demo"],
enabled: false,
},
},
},
})
const response = await request(McpPaths.status, tmp.path)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ demo: { status: "disabled" } })
})
})