diff --git a/bun.lock b/bun.lock index 1360d8e94..ae9ff69bf 100644 --- a/bun.lock +++ b/bun.lock @@ -39,14 +39,54 @@ "@opentui/core-win32-x64": "0.5.1", }, }, + "packages/exa-mcp": { + "name": "@corbits/exa-mcp", + "version": "0.1.0", + "dependencies": { + "@corbits/mcp-adapter": "0.1.0", + "@intx/agent": "0.2.2", + }, + }, "packages/first-class-providers": { "name": "@corbits/first-class-providers", "version": "0.1.0", }, + "packages/granola-mcp": { + "name": "@corbits/granola-mcp", + "version": "0.1.0", + "dependencies": { + "@corbits/mcp-adapter": "0.1.0", + "@intx/agent": "0.2.2", + }, + }, + "packages/linear-mcp": { + "name": "@corbits/linear-mcp", + "version": "0.1.0", + "dependencies": { + "@corbits/mcp-adapter": "0.1.0", + "@intx/agent": "0.2.2", + }, + }, + "packages/mcp-adapter": { + "name": "@corbits/mcp-adapter", + "version": "0.1.0", + "dependencies": { + "@intx/agent": "0.2.2", + "@modelcontextprotocol/sdk": "^1.29.0", + }, + }, "packages/opencode-go": { "name": "@corbits/provider-opencode-go", "version": "0.1.0", }, + "packages/slack-mcp": { + "name": "@corbits/slack-mcp", + "version": "0.1.0", + "dependencies": { + "@corbits/mcp-adapter": "0.1.0", + "@intx/agent": "0.2.2", + }, + }, "vendor/intx-inference": { "name": "@intx/inference", "version": "0.2.2", @@ -161,10 +201,20 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@corbits/exa-mcp": ["@corbits/exa-mcp@workspace:packages/exa-mcp"], + "@corbits/first-class-providers": ["@corbits/first-class-providers@workspace:packages/first-class-providers"], + "@corbits/granola-mcp": ["@corbits/granola-mcp@workspace:packages/granola-mcp"], + + "@corbits/linear-mcp": ["@corbits/linear-mcp@workspace:packages/linear-mcp"], + + "@corbits/mcp-adapter": ["@corbits/mcp-adapter@workspace:packages/mcp-adapter"], + "@corbits/provider-opencode-go": ["@corbits/provider-opencode-go@workspace:packages/opencode-go"], + "@corbits/slack-mcp": ["@corbits/slack-mcp@workspace:packages/slack-mcp"], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@intx/agent": ["@intx/agent@0.2.2", "", { "dependencies": { "@intx/inference": "0.2.2", "@intx/log": "0.2.2", "@intx/mime": "0.2.2", "@intx/types": "0.2.2", "arktype": "^2.1.29" } }, "sha512-tlsFL0bjYQ7MimcgUIfx7WJgXpNX5YA2v0EVOsZaNXPubjx+ifnh/Ewlol5gf0pADvLG7yiXryuJ7DTUFg/bTw=="], diff --git a/packages/exa-mcp/package.json b/packages/exa-mcp/package.json new file mode 100644 index 000000000..244ce0284 --- /dev/null +++ b/packages/exa-mcp/package.json @@ -0,0 +1,20 @@ +{ + "name": "@corbits/exa-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "SEE LICENSE IN LICENSE.md", + "interchange": { + "tools": "./src/tools.ts" + }, + "exports": { + ".": { + "types": "./src/tools.ts", + "default": "./src/tools.ts" + } + }, + "dependencies": { + "@corbits/mcp-adapter": "0.1.0", + "@intx/agent": "0.2.2" + } +} diff --git a/packages/exa-mcp/src/tools.ts b/packages/exa-mcp/src/tools.ts new file mode 100644 index 000000000..79b48e6b0 --- /dev/null +++ b/packages/exa-mcp/src/tools.ts @@ -0,0 +1,52 @@ +import { defineMcpToolFactory } from "@corbits/mcp-adapter"; + +// Static declaration of Exa's hosted MCP tool surface, including real +// per-tool argument schemas -- see the package README for what happens +// when it drifts from the server's real tool list. +// +// EXA_API_KEY is optional: unset, the server is used at its default rate +// limit; set, the key is passed through so Exa raises the caller's limit. +// The package installs, connects, and works fully with it unset. +export const exa = defineMcpToolFactory({ + id: "@corbits/exa-mcp/exa", + serverName: "exa", + url: "https://mcp.exa.ai/mcp", + clientName: "corbits-code", + apiKeyEnvVar: "EXA_API_KEY", + apiKeyQueryParam: "exaApiKey", + toolDeclarations: [ + { + name: "web_search", + description: "Search the web via Exa.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query." }, + numResults: { type: "number", description: "Maximum results to return." }, + }, + required: ["query"], + }, + }, + { + name: "get_contents", + description: "Fetch page contents for a URL via Exa.", + inputSchema: { + type: "object", + properties: { url: { type: "string", description: "URL to fetch contents for." } }, + required: ["url"], + }, + }, + { + name: "find_similar", + description: "Find pages similar to a URL via Exa.", + inputSchema: { + type: "object", + properties: { + url: { type: "string", description: "URL to find similar pages for." }, + numResults: { type: "number", description: "Maximum results to return." }, + }, + required: ["url"], + }, + }, + ], +}); diff --git a/packages/granola-mcp/package.json b/packages/granola-mcp/package.json new file mode 100644 index 000000000..f8c031b71 --- /dev/null +++ b/packages/granola-mcp/package.json @@ -0,0 +1,20 @@ +{ + "name": "@corbits/granola-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "SEE LICENSE IN LICENSE.md", + "interchange": { + "tools": "./src/tools.ts" + }, + "exports": { + ".": { + "types": "./src/tools.ts", + "default": "./src/tools.ts" + } + }, + "dependencies": { + "@corbits/mcp-adapter": "0.1.0", + "@intx/agent": "0.2.2" + } +} diff --git a/packages/granola-mcp/src/tools.ts b/packages/granola-mcp/src/tools.ts new file mode 100644 index 000000000..7992805b1 --- /dev/null +++ b/packages/granola-mcp/src/tools.ts @@ -0,0 +1,39 @@ +import { defineMcpToolFactory } from "@corbits/mcp-adapter"; + +// Static declaration of Granola's hosted MCP tool surface, including real +// per-tool argument schemas -- see the package README for what happens +// when it drifts from the server's real tool list. +export const granola = defineMcpToolFactory({ + id: "@corbits/granola-mcp/granola", + serverName: "granola", + url: "https://mcp.granola.ai/mcp", + clientName: "corbits-code", + toolDeclarations: [ + { + name: "list_notes", + description: "List Granola meeting notes.", + inputSchema: { + type: "object", + properties: { limit: { type: "number", description: "Maximum notes to return." } }, + }, + }, + { + name: "get_note", + description: "Get a single Granola meeting note.", + inputSchema: { + type: "object", + properties: { id: { type: "string", description: "Note ID." } }, + required: ["id"], + }, + }, + { + name: "search_notes", + description: "Search Granola meeting notes.", + inputSchema: { + type: "object", + properties: { query: { type: "string", description: "Free-text search query." } }, + required: ["query"], + }, + }, + ], +}); diff --git a/packages/linear-mcp/package.json b/packages/linear-mcp/package.json new file mode 100644 index 000000000..7f04969a2 --- /dev/null +++ b/packages/linear-mcp/package.json @@ -0,0 +1,20 @@ +{ + "name": "@corbits/linear-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "SEE LICENSE IN LICENSE.md", + "interchange": { + "tools": "./src/tools.ts" + }, + "exports": { + ".": { + "types": "./src/tools.ts", + "default": "./src/tools.ts" + } + }, + "dependencies": { + "@corbits/mcp-adapter": "0.1.0", + "@intx/agent": "0.2.2" + } +} diff --git a/packages/linear-mcp/src/tools.ts b/packages/linear-mcp/src/tools.ts new file mode 100644 index 000000000..add88dbec --- /dev/null +++ b/packages/linear-mcp/src/tools.ts @@ -0,0 +1,96 @@ +import { defineMcpToolFactory } from "@corbits/mcp-adapter"; + +// Static declaration of Linear's hosted MCP tool surface, including real +// per-tool argument schemas (not a bare open object) -- an empty schema +// gives the model no signal on what arguments a tool takes, which +// defeats the point of declaring named tools instead of a single +// dispatch-proxy tool. This list is a snapshot, not a live query -- see +// the package README for what happens when it drifts from the server's +// real tool list. +export const linear = defineMcpToolFactory({ + id: "@corbits/linear-mcp/linear", + serverName: "linear", + url: "https://mcp.linear.app/mcp", + clientName: "corbits-code", + toolDeclarations: [ + { + name: "list_issues", + description: "List issues in the Linear workspace.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Free-text search over issue title/description." }, + team: { type: "string", description: "Team name or ID to filter by." }, + project: { type: "string", description: "Project name or ID to filter by." }, + assignee: { type: "string", description: 'User ID, name, email, or "me".' }, + state: { type: "string", description: "Workflow state type or name to filter by." }, + limit: { type: "number", description: "Maximum results to return." }, + }, + }, + }, + { + name: "get_issue", + description: "Get a single Linear issue by id.", + inputSchema: { + type: "object", + properties: { id: { type: "string", description: "Issue ID or identifier (e.g. ENG-123)." } }, + required: ["id"], + }, + }, + { + name: "create_issue", + description: "Create a Linear issue.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Issue title." }, + team: { type: "string", description: "Team name or ID to create the issue under." }, + description: { type: "string", description: "Issue description as Markdown." }, + assignee: { type: "string", description: 'User ID, name, email, or "me".' }, + project: { type: "string", description: "Project name or ID to add the issue to." }, + }, + required: ["title", "team"], + }, + }, + { + name: "update_issue", + description: "Update an existing Linear issue.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Issue ID or identifier to update." }, + title: { type: "string", description: "New issue title." }, + description: { type: "string", description: "New issue description as Markdown." }, + state: { type: "string", description: "Workflow state type or name to move the issue to." }, + assignee: { type: "string", description: 'User ID, name, email, or "me".' }, + }, + required: ["id"], + }, + }, + { + name: "list_projects", + description: "List projects in the Linear workspace.", + inputSchema: { + type: "object", + properties: { + team: { type: "string", description: "Team name or ID to filter by." }, + query: { type: "string", description: "Free-text search over project name." }, + }, + }, + }, + { + name: "list_teams", + description: "List teams in the Linear workspace.", + inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query." } } }, + }, + { + name: "search_documentation", + description: "Search Linear's help documentation.", + inputSchema: { + type: "object", + properties: { query: { type: "string", description: "Free-text search query." } }, + required: ["query"], + }, + }, + ], +}); diff --git a/packages/mcp-adapter/README.md b/packages/mcp-adapter/README.md new file mode 100644 index 000000000..2fe8e6c4f --- /dev/null +++ b/packages/mcp-adapter/README.md @@ -0,0 +1,46 @@ +# @corbits/mcp-adapter + +Shared logic for exposing a hosted, OAuth-protected MCP server as an +`interchange.tools` tool package. Per-server packages (`@corbits/linear-mcp`, +`@corbits/slack-mcp`, `@corbits/granola-mcp`, `@corbits/exa-mcp`) are thin +configuration over `defineMcpToolFactory` -- they should not duplicate +connection, OAuth, or dispatch logic. + +## Static tool declarations + +`interchange.tools` factories must declare their tool names at +construction time (`ToolFactoryMeta.definitions`), before any connection to +the MCP server exists -- the deploy-time capability walk enumerates a +package's tools without instantiating it. That forces a choice, since an +MCP server's real tool list is only known after connecting: + +- **Hardcoded static list per server (chosen).** Matches the constraint + above directly, keeps the model's tool menu meaningful (named tools with + real descriptions) instead of one opaque dispatch tool, and costs + nothing until the upstream server's tool list drifts. +- **Single dispatch-proxy tool.** Always correct regardless of upstream + changes, but pushes tool selection into a single tool's freeform + arguments the model has to get right blind -- worse ergonomics for no + correctness win in the common case. +- **Cache the list from a prior connection.** Accurate after first use, + but the very first install has nothing to declare (there is no prior + connection yet), and it reintroduces a locally-written file the loader + has to trust as if it were the package author's declaration. + +**What happens when the real list drifts.** A tool the server has since +removed fails at `run()` with the server's own "unknown tool" error, +surfaced as a normal tool-call failure -- visible, not silent. A tool the +server has since added is invisible to the model until this package's +`toolDeclarations` is updated and republished. Keeping each server +package's declared list current with its upstream server is an ongoing +maintenance cost of this choice. + +## Lazy connection + +`defineMcpToolFactory` returns an `AnnotatedToolFactory` whose bundle +construction is synchronous and does no I/O. The first `run()` call lazily +connects over streamable HTTP and, if the server requires it, drives an +OAuth authorization round trip using an adapter-local callback server and +token store (`~/.corbits/mcp-auth/.json`) -- the same pattern +Corbits Code's own TUI-facing MCP client uses, ported here so this package +has no import back into the parent repository's `src/` tree. diff --git a/packages/mcp-adapter/package.json b/packages/mcp-adapter/package.json new file mode 100644 index 000000000..e0f4cda2d --- /dev/null +++ b/packages/mcp-adapter/package.json @@ -0,0 +1,17 @@ +{ + "name": "@corbits/mcp-adapter", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "SEE LICENSE IN LICENSE.md", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "dependencies": { + "@intx/agent": "0.2.2", + "@modelcontextprotocol/sdk": "^1.29.0" + } +} diff --git a/packages/mcp-adapter/src/callback-server.ts b/packages/mcp-adapter/src/callback-server.ts new file mode 100644 index 000000000..e1b681241 --- /dev/null +++ b/packages/mcp-adapter/src/callback-server.ts @@ -0,0 +1,139 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +export type CallbackServer = { + // The redirect_uri to register with the authorization server. + redirectUrl: string; + expectState: (state: string) => void; + // Resolves once the authorization server redirects back with a code, or + // rejects if the signal aborts or the server reports an error. + waitForCode: (signal: AbortSignal) => Promise; + close: () => void; +}; + +type CallbackResult = { code: string } | { error: Error }; +type CallbackWaiter = { resolve: (code: string) => void; reject: (error: Error) => void }; + +const CALLBACK_PATH = "/callback"; + +function callbackPageHtml(opts: { subject?: string; error?: string }): string { + const heading = opts.error !== undefined ? "Authorization failed" : "Authorization complete"; + const body = + opts.error !== undefined + ? `

${escapeHtml(opts.error)}

` + : `

You can close this tab and return to your terminal${opts.subject !== undefined ? ` (${escapeHtml(opts.subject)})` : ""}.

`; + return `${heading}

${heading}

${body}`; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (ch) => { + switch (ch) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +// Start an ephemeral loopback server to receive the OAuth redirect. Binds to +// a random port on 127.0.0.1 so it never collides with anything and is only +// reachable locally. +export async function startCallbackServer(serverName?: string): Promise { + let expectedState: string | undefined; + let pendingResult: CallbackResult | undefined; + let waiter: CallbackWaiter | undefined; + + const clearAuthorization = (): void => { + expectedState = undefined; + waiter = undefined; + }; + + const deliver = (result: CallbackResult): void => { + if (waiter === undefined) { + pendingResult = result; + return; + } + const activeWaiter = waiter; + clearAuthorization(); + if ("code" in result) activeWaiter.resolve(result.code); + else activeWaiter.reject(result.error); + }; + + const server: Server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (url.pathname !== CALLBACK_PATH) { + res.statusCode = 404; + res.end("Not found"); + return; + } + if (expectedState === undefined || url.searchParams.get("state") !== expectedState) { + res.statusCode = 400; + res.end("Authorization callback state did not match."); + return; + } + + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + const failure = error ?? (code === null ? "the redirect carried no code" : undefined); + res.statusCode = failure === undefined ? 200 : 400; + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end( + callbackPageHtml({ + ...(serverName !== undefined ? { subject: serverName } : {}), + ...(failure !== undefined ? { error: failure } : {}), + }), + ); + if (error !== null) deliver({ error: new Error(`Authorization failed: ${error}`) }); + else if (code === null) deliver({ error: new Error("Authorization redirect carried no code.") }); + else deliver({ code }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address() as AddressInfo; + const redirectUrl = `http://127.0.0.1:${String(address.port)}${CALLBACK_PATH}`; + + return { + redirectUrl, + expectState: (state) => { + expectedState = state; + pendingResult = undefined; + }, + waitForCode: (signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new Error("aborted")); + return; + } + if (pendingResult !== undefined) { + const result = pendingResult; + pendingResult = undefined; + clearAuthorization(); + if ("code" in result) resolve(result.code); + else reject(result.error); + return; + } + waiter = { resolve, reject }; + signal.addEventListener( + "abort", + () => { + if (waiter === undefined || waiter.resolve !== resolve) return; + clearAuthorization(); + reject(new Error("aborted")); + }, + { once: true }, + ); + }), + close: () => server.close(), + }; +} diff --git a/packages/mcp-adapter/src/connect.ts b/packages/mcp-adapter/src/connect.ts new file mode 100644 index 000000000..5f16ba3ea --- /dev/null +++ b/packages/mcp-adapter/src/connect.ts @@ -0,0 +1,129 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; +import { createOAuthProvider, type McpOAuthProvider } from "./oauth-provider.js"; +import { startCallbackServer, type CallbackServer } from "./callback-server.js"; + +export type McpTool = { name: string; description: string; inputSchema: Record }; +export type McpConnection = { + serverName: string; + tools: McpTool[]; + call(toolName: string, args: Record, signal: AbortSignal): Promise; + close(): Promise; +}; + +export type ConnectOptions = { + onAuthURL: (serverName: string, authorizationUrl: string) => void; + clientName: string; + home?: string; + signal?: AbortSignal; +}; + +export function unwrapToolContent(content: unknown): string { + if (!Array.isArray(content) || content.length === 0) return ""; + return content + .map((block) => { + if (block !== null && typeof block === "object" && (block as { type?: unknown }).type === "text") { + return String((block as { text?: unknown }).text ?? ""); + } + return JSON.stringify(block); + }) + .join("\n"); +} + +type AuthContext = { url: URL; authProvider: McpOAuthProvider; callback: CallbackServer; signal?: AbortSignal }; + +function isRecoverableAuthError(err: unknown): boolean { + return err instanceof UnauthorizedError || err instanceof OAuthError; +} + +async function completeInteractiveAuth(context: AuthContext): Promise { + const code = await context.callback.waitForCode(context.signal ?? new AbortController().signal); + await new StreamableHTTPClientTransport(context.url, { authProvider: context.authProvider }).finishAuth(code); +} + +async function recoverAuthorization(err: unknown, context: AuthContext, operation: () => Promise): Promise { + if (!isRecoverableAuthError(err)) throw err; + let lastErr: unknown = err; + for (let attempt = 0; attempt < 2; attempt += 1) { + if (lastErr instanceof OAuthError) await context.authProvider.resetAuthorization(); + if (lastErr instanceof UnauthorizedError) { + await completeInteractiveAuth(context); + return operation(); + } + try { + return await operation(); + } catch (nextErr) { + if (!isRecoverableAuthError(nextErr)) throw nextErr; + lastErr = nextErr; + } + } + throw lastErr; +} + +async function withAuthRecovery(context: AuthContext, operation: () => Promise): Promise { + try { + return await operation(); + } catch (err) { + return recoverAuthorization(err, context, operation); + } +} + +// Connects to a hosted, OAuth-protected MCP server over streamable HTTP. +// Owns its own callback server and OAuth provider end to end -- nothing +// here depends on being driven by a TUI; the caller only supplies where +// to surface the authorization URL. +export async function connectHostedMcpServer( + serverName: string, + url: string, + options: ConnectOptions, +): Promise { + const target = new URL(url); + const callback = await startCallbackServer(serverName); + const authProvider = await createOAuthProvider({ + clientName: options.clientName, + serverName, + redirectUrl: callback.redirectUrl, + onAuthURL: options.onAuthURL, + onAuthorizationState: callback.expectState, + ...(options.home !== undefined ? { home: options.home } : {}), + }); + const authContext: AuthContext = { + url: target, + authProvider, + callback, + ...(options.signal !== undefined ? { signal: options.signal } : {}), + }; + const client = new Client({ name: options.clientName, version: "1.0.0" }); + try { + await withAuthRecovery(authContext, () => + client.connect(new StreamableHTTPClientTransport(target, { authProvider }) as never), + ); + const result = await withAuthRecovery(authContext, () => client.listTools()); + const tools: McpTool[] = result.tools.map((t) => ({ + name: t.name, + description: t.description ?? "", + inputSchema: (t.inputSchema as Record) ?? { type: "object", properties: {} }, + })); + return { + serverName, + tools, + async call(toolName, args, signal) { + const context = { ...authContext, signal }; + const result = await withAuthRecovery(context, () => + client.callTool({ name: toolName, arguments: args }, undefined, { signal }), + ); + return unwrapToolContent(result.content); + }, + async close() { + callback.close(); + await client.close().catch(() => undefined); + }, + }; + } catch (err) { + await client.close().catch(() => undefined); + callback.close(); + throw err; + } +} diff --git a/packages/mcp-adapter/src/factory.test.ts b/packages/mcp-adapter/src/factory.test.ts new file mode 100644 index 000000000..94dbe7d7b --- /dev/null +++ b/packages/mcp-adapter/src/factory.test.ts @@ -0,0 +1,201 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import * as connectModule from "./connect.js"; + +const realConnect = { ...connectModule }; +const SEARCH_DEF = { name: "search", description: "Search.", inputSchema: { type: "object", properties: {} } }; + +afterEach(() => { + mock.restore(); +}); + +describe("defineMcpToolFactory", () => { + test("is structurally an AnnotatedToolFactory: callable, id, requires", async () => { + const { defineMcpToolFactory } = await import("./factory.js"); + const factory = defineMcpToolFactory({ + id: "@corbits/test-mcp/test", + serverName: "test-seam", + url: "https://example.invalid/mcp", + toolDeclarations: [SEARCH_DEF], + clientName: "corbits-code-test", + }); + expect(typeof factory).toBe("function"); + expect(factory.id).toBe("@corbits/test-mcp/test"); + expect(Array.isArray(factory.requires)).toBe(true); + expect(factory.requires.every((r) => typeof r === "string")).toBe(true); + }); + + test("constructing the bundle does not connect; definitions are populated synchronously", async () => { + const connectSpy = mock(() => new Promise(() => undefined)); + mock.module("./connect.js", () => ({ ...realConnect, connectHostedMcpServer: connectSpy })); + const { defineMcpToolFactory } = await import("./factory.js"); + + const factory = defineMcpToolFactory({ + id: "@corbits/test-mcp/test", + serverName: "test-seam", + url: "https://example.invalid/mcp", + toolDeclarations: [SEARCH_DEF], + clientName: "corbits-code-test", + }); + + const bundle = factory({} as never); + expect(bundle.definitions).toEqual([SEARCH_DEF]); + expect(connectSpy).not.toHaveBeenCalled(); + }); + + // This exercises factory.ts's contract with connect.ts (call onAuthURL, + // then reject -> factory surfaces the URL in the tool result), not + // connect.ts's real OAuth behavior. In production, a first-time + // authorization blocks this call until the browser round-trip + // completes or the tool call's signal aborts it (see the comment on + // `ensureConnected(signal)` in factory.ts); connect.ts/oauth- + // provider.ts/callback-server.ts's real network and OAuth paths have + // no test coverage yet. + test("first run() triggers a lazy connect and surfaces the auth URL when required", async () => { + const connectSpy = mock( + (_serverName: string, _url: string, options: { onAuthURL: (name: string, url: string) => void }) => { + options.onAuthURL("test-seam", "https://example.invalid/authorize?state=fake-state"); + return Promise.reject(new Error("Unauthorized")); + }, + ); + mock.module("./connect.js", () => ({ ...realConnect, connectHostedMcpServer: connectSpy })); + const { defineMcpToolFactory } = await import("./factory.js"); + + const factory = defineMcpToolFactory({ + id: "@corbits/test-mcp/test", + serverName: "test-seam", + url: "https://example.invalid/mcp", + toolDeclarations: [SEARCH_DEF], + clientName: "corbits-code-test", + }); + const bundle = factory({} as never); + + expect(connectSpy).not.toHaveBeenCalled(); + const result = await bundle.run({ id: "call-1", name: "search", arguments: {} }, new AbortController().signal); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(result.isError).toBe(true); + expect(String(result.content)).toContain("https://example.invalid/authorize"); + }); + + test("run() threads its own abort signal through to the connect call", async () => { + let seenSignal: AbortSignal | undefined; + const connectSpy = mock((_serverName: string, _url: string, options: { signal?: AbortSignal }) => { + seenSignal = options.signal; + return Promise.reject(new Error("stop after capturing signal")); + }); + mock.module("./connect.js", () => ({ ...realConnect, connectHostedMcpServer: connectSpy })); + const { defineMcpToolFactory } = await import("./factory.js"); + + const factory = defineMcpToolFactory({ + id: "@corbits/test-mcp/test", + serverName: "test-seam", + url: "https://example.invalid/mcp", + toolDeclarations: [SEARCH_DEF], + clientName: "corbits-code-test", + }); + const controller = new AbortController(); + await factory({} as never).run({ id: "1", name: "search", arguments: {} }, controller.signal); + expect(seenSignal).toBe(controller.signal); + }); + + test("optional API key is appended as a query param only when the env var is set", async () => { + let seenUrl: string | undefined; + const connectSpy = mock((_serverName: string, url: string) => { + seenUrl = url; + return Promise.reject(new Error("stop after capturing url")); + }); + mock.module("./connect.js", () => ({ ...realConnect, connectHostedMcpServer: connectSpy })); + const { defineMcpToolFactory } = await import("./factory.js"); + + const withoutKey = defineMcpToolFactory({ + id: "@corbits/test-mcp/test", + serverName: "test-seam", + url: "https://example.invalid/mcp", + toolDeclarations: [SEARCH_DEF], + clientName: "corbits-code-test", + apiKeyEnvVar: "CORBITS_TEST_MCP_API_KEY", + apiKeyQueryParam: "apiKey", + }); + await withoutKey({} as never).run({ id: "1", name: "search", arguments: {} }, new AbortController().signal); + expect(seenUrl).toBe("https://example.invalid/mcp"); + + process.env.CORBITS_TEST_MCP_API_KEY = "fake-test-key-not-real"; + try { + const withKey = defineMcpToolFactory({ + id: "@corbits/test-mcp/test", + serverName: "test-seam", + url: "https://example.invalid/mcp", + toolDeclarations: [SEARCH_DEF], + clientName: "corbits-code-test", + apiKeyEnvVar: "CORBITS_TEST_MCP_API_KEY", + apiKeyQueryParam: "apiKey", + }); + await withKey({} as never).run({ id: "2", name: "search", arguments: {} }, new AbortController().signal); + expect(seenUrl).toBe("https://example.invalid/mcp?apiKey=fake-test-key-not-real"); + } finally { + delete process.env.CORBITS_TEST_MCP_API_KEY; + } + }); + + test("a run() after a failed connect retries instead of replaying the stale rejection", async () => { + let callCount = 0; + const connectSpy = mock(() => { + callCount += 1; + return Promise.reject(new Error(`connect failure #${callCount}`)); + }); + mock.module("./connect.js", () => ({ ...realConnect, connectHostedMcpServer: connectSpy })); + const { defineMcpToolFactory } = await import("./factory.js"); + + const factory = defineMcpToolFactory({ + id: "@corbits/test-mcp/test", + serverName: "test-seam", + url: "https://example.invalid/mcp", + toolDeclarations: [SEARCH_DEF], + clientName: "corbits-code-test", + }); + const bundle = factory({} as never); + + const result1 = await bundle.run({ id: "1", name: "search", arguments: {} }, new AbortController().signal); + expect(String(result1.content)).toContain("connect failure #1"); + + const result2 = await bundle.run({ id: "2", name: "search", arguments: {} }, new AbortController().signal); + expect(callCount).toBe(2); + expect(String(result2.content)).toContain("connect failure #2"); + }); + + test("dispose() racing an in-flight connect still closes it once it resolves", async () => { + let closeCalled = 0; + let resolveConnect: (v: unknown) => void = () => undefined; + const connectSpy = mock( + () => + new Promise((resolve) => { + resolveConnect = resolve; + }), + ); + mock.module("./connect.js", () => ({ ...realConnect, connectHostedMcpServer: connectSpy })); + const { defineMcpToolFactory } = await import("./factory.js"); + + const factory = defineMcpToolFactory({ + id: "@corbits/test-mcp/test", + serverName: "test-seam", + url: "https://example.invalid/mcp", + toolDeclarations: [SEARCH_DEF], + clientName: "corbits-code-test", + }); + const bundle = factory({} as never); + + const runPromise = bundle.run({ id: "1", name: "search", arguments: {} }, new AbortController().signal); + await bundle.dispose?.(); + resolveConnect({ + serverName: "test-seam", + tools: [], + call: async () => "", + close: async () => { + closeCalled += 1; + }, + }); + await runPromise; + + expect(closeCalled).toBe(1); + }); +}); + diff --git a/packages/mcp-adapter/src/factory.ts b/packages/mcp-adapter/src/factory.ts new file mode 100644 index 000000000..b38abdb20 --- /dev/null +++ b/packages/mcp-adapter/src/factory.ts @@ -0,0 +1,139 @@ +import { defineTool, type AnnotatedToolFactory } from "@intx/agent"; +import type { ToolDefinition } from "@intx/types/runtime"; +import { connectHostedMcpServer, type McpConnection } from "./connect.js"; + +export type McpToolPackageConfig = { + /** Package-namespaced id, e.g. "@corbits/linear-mcp/linear". */ + readonly id: string; + /** Human-readable server name; also used as the OAuth token-store key. */ + readonly serverName: string; + /** Hosted MCP endpoint, e.g. "https://mcp.linear.app/mcp". */ + readonly url: string; + /** + * Static tool definitions this package declares. The pinned + * `@intx/agent` this package builds against exposes tool definitions + * only on the constructed `ToolBundle`, not as pre-construction + * factory metadata -- so these are set once, synchronously, in + * `factory()`, before any connection exists. See the package README + * for the tradeoff this implies when the upstream server's real tool + * list changes: a removed tool fails at call time with the server's + * own "unknown tool" error; a newly added tool is invisible until + * this list is updated and republished. + */ + readonly toolDeclarations: readonly ToolDefinition[]; + /** + * Optional environment variable holding a credential the server + * accepts (e.g. an API key that raises rate limits). Never required: + * when unset, the URL is used exactly as given. When set, its value + * is appended as `apiKeyQueryParam` on the connection URL -- the + * mechanism the hosted server documents for optional keys. + */ + readonly apiKeyEnvVar?: string; + /** Query parameter name the server expects the optional key under. */ + readonly apiKeyQueryParam?: string; + /** Name reported to the MCP server and used in the OAuth client_name. */ + readonly clientName: string; +}; + +// Builds an AnnotatedToolFactory for a hosted MCP server. The returned +// bundle's `definitions` are set synchronously and statically inside +// `factory()`, with no I/O; the real connection -- and any OAuth flow it +// requires -- happens lazily inside the bundle's `run()`, on first call. +export function defineMcpToolFactory(config: McpToolPackageConfig): AnnotatedToolFactory { + return defineTool({ + id: config.id, + factory: () => { + let connection: McpConnection | undefined; + let connecting: Promise | undefined; + let pendingAuthURL: string | undefined; + + const connectUrl = ((): string => { + const apiKey = config.apiKeyEnvVar !== undefined ? process.env[config.apiKeyEnvVar] : undefined; + if (apiKey === undefined || config.apiKeyQueryParam === undefined) return config.url; + const withKey = new URL(config.url); + withKey.searchParams.set(config.apiKeyQueryParam, apiKey); + return withKey.toString(); + })(); + + let disposed = false; + + const ensureConnected = async (signal: AbortSignal): Promise => { + if (connection !== undefined) return connection; + if (connecting === undefined) { + connecting = connectHostedMcpServer(config.serverName, connectUrl, { + clientName: config.clientName, + signal, + onAuthURL: (_name, url) => { + pendingAuthURL = url; + }, + }).then( + (result) => { + connection = result; + // A dispose() that raced this connect while it was still in + // flight had nothing to close yet; close it now so a + // connection that resolves after teardown does not leak its + // transport and callback server. + if (disposed) void result.close(); + return result; + }, + (err: unknown) => { + // Clear the cached rejection so the next run() retries the + // connection instead of replaying a stale failure forever -- + // the underlying cause (network blip, user completing OAuth + // through a channel we didn't observe) may no longer hold. + connecting = undefined; + throw err; + }, + ); + } + return connecting; + }; + + return { + definitions: config.toolDeclarations.map((decl) => ({ ...decl })), + async run(call, signal) { + pendingAuthURL = undefined; + let client: McpConnection; + try { + // The first connect on a hosted, OAuth-protected server blocks + // this call until the browser round-trip completes (or fails) + // -- there is no way to hand control back to the caller mid- + // authorization and resume later. Wiring the tool call's own + // signal through means the caller can still bound or cancel + // that wait instead of it hanging indefinitely; `onAuthURL` + // additionally records the URL so a signal-driven abort still + // reports where to authorize. + client = await ensureConnected(signal); + } catch (err) { + if (pendingAuthURL !== undefined) { + return { + callId: call.id, + content: `Authorization required. Open this URL to connect ${config.serverName}: ${pendingAuthURL}`, + isError: true, + }; + } + return { + callId: call.id, + content: err instanceof Error ? err.message : String(err), + isError: true, + }; + } + try { + const text = await client.call(call.name, call.arguments, signal); + return { callId: call.id, content: text, isError: false }; + } catch (err) { + return { + callId: call.id, + content: err instanceof Error ? err.message : String(err), + isError: true, + }; + } + }, + async dispose() { + disposed = true; + await connection?.close(); + }, + }; + }, + }); +} diff --git a/packages/mcp-adapter/src/index.ts b/packages/mcp-adapter/src/index.ts new file mode 100644 index 000000000..6152e61ef --- /dev/null +++ b/packages/mcp-adapter/src/index.ts @@ -0,0 +1,3 @@ +export { defineMcpToolFactory, type McpToolPackageConfig } from "./factory.js"; +export { connectHostedMcpServer, type McpConnection, type McpTool } from "./connect.js"; +export { loadAuthState, saveAuthState, mcpAuthDir, type McpAuthState } from "./token-store.js"; diff --git a/packages/mcp-adapter/src/oauth-provider.ts b/packages/mcp-adapter/src/oauth-provider.ts new file mode 100644 index 000000000..f7a8b3a67 --- /dev/null +++ b/packages/mcp-adapter/src/oauth-provider.ts @@ -0,0 +1,76 @@ +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientInformationFull, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { loadAuthState, saveAuthState, type McpAuthState } from "./token-store.js"; + +export type OAuthProviderOptions = { + clientName: string; + serverName: string; + redirectUrl: string; + onAuthURL: (serverName: string, authorizationUrl: string) => void; + onAuthorizationState?: (state: string) => void; + home?: string; +}; + +export type McpOAuthProvider = OAuthClientProvider & { resetAuthorization(): Promise }; + +export async function createOAuthProvider(opts: OAuthProviderOptions): Promise { + const stored: McpAuthState = await loadAuthState(opts.serverName, opts.home); + const persist = (): Promise => saveAuthState(opts.serverName, stored, opts.home); + let oauthState: string | undefined; + return { + get redirectUrl(): string { + return opts.redirectUrl; + }, + state(): string { + if (oauthState === undefined) oauthState = crypto.randomUUID(); + return oauthState; + }, + get clientMetadata(): OAuthClientMetadata { + return { + client_name: opts.clientName, + redirect_uris: [opts.redirectUrl], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }; + }, + clientInformation(): OAuthClientInformationMixed | undefined { + return stored.clientInformation; + }, + saveClientInformation(info: OAuthClientInformationMixed): Promise { + stored.clientInformation = info as OAuthClientInformationFull; + return persist(); + }, + tokens(): OAuthTokens | undefined { + return stored.tokens; + }, + saveTokens(tokens: OAuthTokens): Promise { + stored.tokens = tokens; + return persist(); + }, + redirectToAuthorization(authorizationUrl: URL): void { + const state = authorizationUrl.searchParams.get("state"); + if (state !== null) opts.onAuthorizationState?.(state); + opts.onAuthURL(opts.serverName, authorizationUrl.toString()); + }, + saveCodeVerifier(codeVerifier: string): Promise { + stored.codeVerifier = codeVerifier; + return persist(); + }, + codeVerifier(): string { + if (stored.codeVerifier === undefined) throw new Error("No PKCE code verifier saved for this authorization."); + return stored.codeVerifier; + }, + resetAuthorization(): Promise { + delete stored.tokens; + delete stored.codeVerifier; + oauthState = undefined; + return persist(); + }, + }; +} diff --git a/packages/mcp-adapter/src/token-store.test.ts b/packages/mcp-adapter/src/token-store.test.ts new file mode 100644 index 000000000..f2b9396fb --- /dev/null +++ b/packages/mcp-adapter/src/token-store.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadAuthState, saveAuthState } from "./token-store.js"; + +let home: string | undefined; + +afterEach(async () => { + if (home !== undefined) await rm(home, { recursive: true, force: true }); + home = undefined; +}); + +describe("token-store", () => { + test("round-trips fake tokens through the auth file", async () => { + home = await mkdtemp(join(tmpdir(), "mcp-adapter-auth-")); + await saveAuthState( + "test-seam", + { tokens: { access_token: "fake-access-token-not-real", token_type: "bearer" } }, + home, + ); + const reloaded = await loadAuthState("test-seam", home); + expect(reloaded.tokens?.access_token).toBe("fake-access-token-not-real"); + }); + + test("auth directory is created with owner-only permissions", async () => { + home = await mkdtemp(join(tmpdir(), "mcp-adapter-auth-")); + await saveAuthState("test-seam", { codeVerifier: "fake-verifier" }, home); + const info = await stat(join(home, ".corbits", "mcp-auth")); + expect(info.mode & 0o777).toBe(0o700); + }); + + test("server names are slugged so they cannot escape the auth directory", async () => { + home = await mkdtemp(join(tmpdir(), "mcp-adapter-auth-")); + await saveAuthState("../../evil", { codeVerifier: "fake" }, home); + const reloaded = await loadAuthState("../../evil", home); + expect(reloaded.codeVerifier).toBe("fake"); + const escaped = await stat(join(home, "..", "..", "evil.json")).catch(() => undefined); + expect(escaped).toBeUndefined(); + }); + + test("missing auth file returns empty state instead of throwing", async () => { + home = await mkdtemp(join(tmpdir(), "mcp-adapter-auth-")); + const state = await loadAuthState("never-connected", home); + expect(state).toEqual({}); + }); +}); diff --git a/packages/mcp-adapter/src/token-store.ts b/packages/mcp-adapter/src/token-store.ts new file mode 100644 index 000000000..e59349721 --- /dev/null +++ b/packages/mcp-adapter/src/token-store.ts @@ -0,0 +1,55 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; + +// Per-server OAuth state persisted between processes. Holding the PKCE +// verifier is necessary because authorization spans a process boundary +// (browser round-trip); tokens and dynamically-registered client info let +// later connections reconnect without any user interaction. +export type McpAuthState = { + clientInformation?: OAuthClientInformationFull; + tokens?: OAuthTokens; + codeVerifier?: string; +}; + +export function mcpAuthDir(home: string = homedir()): string { + return join(home, ".corbits", "mcp-auth"); +} + +// Server names are caller-controlled and may contain path separators or +// other unsafe characters; reduce to a flat slug so the file path can +// never escape the auth directory. +function authFilePath(serverName: string, home: string): string { + const slug = serverName.replace(/[^a-zA-Z0-9_-]/g, "_"); + return join(mcpAuthDir(home), `${slug}.json`); +} + +export async function loadAuthState(serverName: string, home: string = homedir()): Promise { + let raw: string; + try { + raw = await readFile(authFilePath(serverName, home), "utf8"); + } catch (err) { + if (typeof err === "object" && err !== null && "code" in err && (err as { code?: unknown }).code === "ENOENT") { + return {}; + } + throw err; + } + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed === "object" && parsed !== null) return parsed as McpAuthState; + } catch { + // A corrupt auth file should not wedge the connection; treat it as no + // state and let a fresh authorization overwrite it. + } + return {}; +} + +// Tokens are credentials, so the directory and file are restricted to the owner. +export async function saveAuthState(serverName: string, state: McpAuthState, home: string = homedir()): Promise { + const path = authFilePath(serverName, home); + await mkdir(mcpAuthDir(home), { recursive: true, mode: 0o700 }); + const tmp = `${path}.${process.pid}.tmp`; + await writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); + await rename(tmp, path); +} diff --git a/packages/slack-mcp/package.json b/packages/slack-mcp/package.json new file mode 100644 index 000000000..a7feb9fbc --- /dev/null +++ b/packages/slack-mcp/package.json @@ -0,0 +1,20 @@ +{ + "name": "@corbits/slack-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "SEE LICENSE IN LICENSE.md", + "interchange": { + "tools": "./src/tools.ts" + }, + "exports": { + ".": { + "types": "./src/tools.ts", + "default": "./src/tools.ts" + } + }, + "dependencies": { + "@corbits/mcp-adapter": "0.1.0", + "@intx/agent": "0.2.2" + } +} diff --git a/packages/slack-mcp/src/tools.ts b/packages/slack-mcp/src/tools.ts new file mode 100644 index 000000000..2acaa9b32 --- /dev/null +++ b/packages/slack-mcp/src/tools.ts @@ -0,0 +1,70 @@ +import { defineMcpToolFactory } from "@corbits/mcp-adapter"; + +// Slack's official remote MCP server (general availability February 2026): +// hosted at https://mcp.slack.com/mcp, OAuth 2.0 behind an admin approval +// flow. Confirmed hosted, not stdio, before this package was written -- +// see https://mcpservers.org/remote-mcp-servers/slack. +// +// Static declaration of Slack's hosted MCP tool surface, including real +// per-tool argument schemas -- see the package README for what happens +// when it drifts from the server's real tool list. +export const slack = defineMcpToolFactory({ + id: "@corbits/slack-mcp/slack", + serverName: "slack", + url: "https://mcp.slack.com/mcp", + clientName: "corbits-code", + toolDeclarations: [ + { + name: "search_messages", + description: "Search Slack messages.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Free-text search query." }, + channel: { type: "string", description: "Channel name or ID to restrict the search to." }, + }, + required: ["query"], + }, + }, + { + name: "list_channels", + description: "List Slack channels.", + inputSchema: { + type: "object", + properties: { query: { type: "string", description: "Free-text search over channel name." } }, + }, + }, + { + name: "get_channel_history", + description: "Get recent messages from a Slack channel.", + inputSchema: { + type: "object", + properties: { + channel: { type: "string", description: "Channel name or ID." }, + limit: { type: "number", description: "Maximum messages to return." }, + }, + required: ["channel"], + }, + }, + { + name: "post_message", + description: "Post a message to a Slack channel.", + inputSchema: { + type: "object", + properties: { + channel: { type: "string", description: "Channel name or ID to post to." }, + text: { type: "string", description: "Message text." }, + }, + required: ["channel", "text"], + }, + }, + { + name: "list_users", + description: "List users in the Slack workspace.", + inputSchema: { + type: "object", + properties: { query: { type: "string", description: "Free-text search over user name." } }, + }, + }, + ], +});