feat(httpapi): bridge mcp oauth endpoints (#24405)
This commit is contained in:
@@ -178,8 +178,8 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
|
|||||||
| `config` | `bridged` | read, providers, update |
|
| `config` | `bridged` | read, providers, update |
|
||||||
| `project` | `bridged` | list, current, git init, update |
|
| `project` | `bridged` | list, current, git init, update |
|
||||||
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
|
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
|
||||||
| `mcp` | `bridged` partial | status, add, connect/disconnect; OAuth remains |
|
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
||||||
| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
|
| `workspace` | `bridged` partial | adaptor/list/status; create/remove/session-restore remain |
|
||||||
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
||||||
| experimental JSON routes | `bridged` partial | console reads, tool ids, worktree list/mutations, resource list; global session list remains later |
|
| experimental JSON routes | `bridged` partial | console reads, tool ids, worktree list/mutations, resource list; global session list remains later |
|
||||||
| `session` | `later/special` | large stateful surface plus streaming |
|
| `session` | `later/special` | large stateful surface plus streaming |
|
||||||
@@ -248,10 +248,10 @@ This checklist tracks bridge parity only. Checked routes are available through t
|
|||||||
|
|
||||||
- [x] `GET /mcp` - MCP status.
|
- [x] `GET /mcp` - MCP status.
|
||||||
- [x] `POST /mcp` - add MCP server at runtime.
|
- [x] `POST /mcp` - add MCP server at runtime.
|
||||||
- [ ] `POST /mcp/:name/auth` - start MCP OAuth.
|
- [x] `POST /mcp/:name/auth` - start MCP OAuth.
|
||||||
- [ ] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback.
|
- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback.
|
||||||
- [ ] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow.
|
- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow.
|
||||||
- [ ] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials.
|
- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials.
|
||||||
- [x] `POST /mcp/:name/connect` - connect MCP server.
|
- [x] `POST /mcp/:name/connect` - connect MCP server.
|
||||||
- [x] `POST /mcp/:name/disconnect` - disconnect MCP server.
|
- [x] `POST /mcp/:name/disconnect` - disconnect MCP server.
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
|
|||||||
|
|
||||||
1. [x] Bridge `PATCH /project/:projectID`.
|
1. [x] Bridge `PATCH /project/:projectID`.
|
||||||
2. [x] Bridge MCP add/connect/disconnect routes.
|
2. [x] Bridge MCP add/connect/disconnect routes.
|
||||||
3. [ ] Bridge MCP OAuth routes: start, callback, authenticate, remove.
|
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
|
||||||
4. [ ] Bridge experimental console switch and tool list routes.
|
4. [ ] Bridge experimental console switch and tool list routes.
|
||||||
5. [ ] Bridge experimental global session list.
|
5. [ ] Bridge experimental global session list.
|
||||||
6. [ ] Bridge workspace create/remove/session-restore routes.
|
6. [ ] Bridge workspace create/remove/session-restore routes.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MCP } from "@/mcp"
|
import { MCP } from "@/mcp"
|
||||||
import { ConfigMCP } from "@/config/mcp"
|
import { ConfigMCP } from "@/config/mcp"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
const AddPayload = Schema.Struct({
|
const AddPayload = Schema.Struct({
|
||||||
@@ -10,9 +10,22 @@ const AddPayload = Schema.Struct({
|
|||||||
}).annotate({ identifier: "McpAddInput" })
|
}).annotate({ identifier: "McpAddInput" })
|
||||||
|
|
||||||
const StatusMap = Schema.Record(Schema.String, MCP.Status)
|
const StatusMap = Schema.Record(Schema.String, MCP.Status)
|
||||||
|
const AuthStartResponse = Schema.Struct({
|
||||||
|
authorizationUrl: Schema.String,
|
||||||
|
oauthState: Schema.String,
|
||||||
|
}).annotate({ identifier: "McpAuthStartResponse" })
|
||||||
|
const AuthCallbackPayload = Schema.Struct({
|
||||||
|
code: Schema.String,
|
||||||
|
}).annotate({ identifier: "McpAuthCallbackInput" })
|
||||||
|
const AuthRemoveResponse = Schema.Struct({
|
||||||
|
success: Schema.Literal(true),
|
||||||
|
}).annotate({ identifier: "McpAuthRemoveResponse" })
|
||||||
|
|
||||||
export const McpPaths = {
|
export const McpPaths = {
|
||||||
status: "/mcp",
|
status: "/mcp",
|
||||||
|
auth: "/mcp/:name/auth",
|
||||||
|
authCallback: "/mcp/:name/auth/callback",
|
||||||
|
authAuthenticate: "/mcp/:name/auth/authenticate",
|
||||||
connect: "/mcp/:name/connect",
|
connect: "/mcp/:name/connect",
|
||||||
disconnect: "/mcp/:name/disconnect",
|
disconnect: "/mcp/:name/disconnect",
|
||||||
} as const
|
} as const
|
||||||
@@ -40,6 +53,47 @@ export const McpApi = HttpApi.make("mcp")
|
|||||||
description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
|
description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
HttpApiEndpoint.post("authStart", McpPaths.auth, {
|
||||||
|
params: { name: Schema.String },
|
||||||
|
success: AuthStartResponse,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "mcp.auth.start",
|
||||||
|
summary: "Start MCP OAuth",
|
||||||
|
description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
|
||||||
|
params: { name: Schema.String },
|
||||||
|
payload: AuthCallbackPayload,
|
||||||
|
success: MCP.Status,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "mcp.auth.callback",
|
||||||
|
summary: "Complete MCP OAuth",
|
||||||
|
description: "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
|
||||||
|
params: { name: Schema.String },
|
||||||
|
success: MCP.Status,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "mcp.auth.authenticate",
|
||||||
|
summary: "Authenticate MCP OAuth",
|
||||||
|
description: "Start OAuth flow and wait for callback (opens browser).",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
|
||||||
|
params: { name: Schema.String },
|
||||||
|
success: AuthRemoveResponse,
|
||||||
|
}).annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "mcp.auth.remove",
|
||||||
|
summary: "Remove MCP OAuth",
|
||||||
|
description: "Remove OAuth credentials for an MCP server.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
HttpApiEndpoint.post("connect", McpPaths.connect, {
|
HttpApiEndpoint.post("connect", McpPaths.connect, {
|
||||||
params: { name: Schema.String },
|
params: { name: Schema.String },
|
||||||
success: Schema.Boolean,
|
success: Schema.Boolean,
|
||||||
@@ -89,6 +143,28 @@ export const mcpHandlers = Layer.unwrap(
|
|||||||
return Schema.decodeUnknownSync(StatusMap)("status" in result ? { [payload.name]: result } : result)
|
return Schema.decodeUnknownSync(StatusMap)("status" in result ? { [payload.name]: result } : result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
|
||||||
|
if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({})
|
||||||
|
return yield* mcp.startAuth(ctx.params.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
|
||||||
|
params: { name: string }
|
||||||
|
payload: typeof AuthCallbackPayload.Type
|
||||||
|
}) {
|
||||||
|
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
|
||||||
|
})
|
||||||
|
|
||||||
|
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
|
||||||
|
if (!(yield* mcp.supportsOAuth(ctx.params.name))) return yield* new HttpApiError.BadRequest({})
|
||||||
|
return yield* mcp.authenticate(ctx.params.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
|
||||||
|
yield* mcp.removeAuth(ctx.params.name)
|
||||||
|
return { success: true as const }
|
||||||
|
})
|
||||||
|
|
||||||
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
|
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
|
||||||
yield* mcp.connect(ctx.params.name)
|
yield* mcp.connect(ctx.params.name)
|
||||||
return true
|
return true
|
||||||
@@ -100,7 +176,15 @@ export const mcpHandlers = Layer.unwrap(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
|
return HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
|
||||||
handlers.handle("status", status).handle("add", add).handle("connect", connect).handle("disconnect", disconnect),
|
handlers
|
||||||
|
.handle("status", status)
|
||||||
|
.handle("add", add)
|
||||||
|
.handle("authStart", authStart)
|
||||||
|
.handle("authCallback", authCallback)
|
||||||
|
.handle("authAuthenticate", authAuthenticate)
|
||||||
|
.handle("authRemove", authRemove)
|
||||||
|
.handle("connect", connect)
|
||||||
|
.handle("disconnect", disconnect),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(MCP.defaultLayer))
|
).pipe(Layer.provide(MCP.defaultLayer))
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
|||||||
app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
|
app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
|
||||||
app.get(McpPaths.status, (c) => handler(c.req.raw, context))
|
app.get(McpPaths.status, (c) => handler(c.req.raw, context))
|
||||||
app.post(McpPaths.status, (c) => handler(c.req.raw, context))
|
app.post(McpPaths.status, (c) => handler(c.req.raw, context))
|
||||||
|
app.post(McpPaths.auth, (c) => handler(c.req.raw, context))
|
||||||
|
app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context))
|
||||||
|
app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context))
|
||||||
|
app.delete(McpPaths.auth, (c) => handler(c.req.raw, context))
|
||||||
app.post(McpPaths.connect, (c) => handler(c.req.raw, context))
|
app.post(McpPaths.connect, (c) => handler(c.req.raw, context))
|
||||||
app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context))
|
app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,4 +83,28 @@ describe("mcp HttpApi", () => {
|
|||||||
expect(disconnected.status).toBe(200)
|
expect(disconnected.status).toBe(200)
|
||||||
expect(await disconnected.json()).toBe(true)
|
expect(await disconnected.json()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("serves deterministic OAuth endpoints", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
config: {
|
||||||
|
mcp: {
|
||||||
|
demo: {
|
||||||
|
type: "local",
|
||||||
|
command: ["echo", "demo"],
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const start = await request("/mcp/demo/auth", tmp.path, { method: "POST" })
|
||||||
|
expect(start.status).toBe(400)
|
||||||
|
|
||||||
|
const authenticate = await request("/mcp/demo/auth/authenticate", tmp.path, { method: "POST" })
|
||||||
|
expect(authenticate.status).toBe(400)
|
||||||
|
|
||||||
|
const removed = await request("/mcp/demo/auth", tmp.path, { method: "DELETE" })
|
||||||
|
expect(removed.status).toBe(200)
|
||||||
|
expect(await removed.json()).toEqual({ success: true })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user