diff --git a/Dockerfile.web b/Dockerfile.web index afae4f91b5..801009cccd 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -13,6 +13,7 @@ COPY packages/ui/package.json packages/ui/ COPY packages/views/package.json packages/views/ COPY packages/tsconfig/package.json packages/tsconfig/ COPY packages/eslint-config/package.json packages/eslint-config/ +COPY packages/mcp/package.json packages/mcp/ RUN pnpm install --frozen-lockfile @@ -43,6 +44,15 @@ ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION ENV STANDALONE=true +# Build the MCP server first and stage it as a downloadable static asset +# under the web app's public/ directory. Next.js' standalone output ships +# public/ as-is, so this produces /multica-mcp.js on the served frontend +# and lets users grab the server with a single curl from the settings UI +# instead of having to clone the repo and pnpm-build themselves. +RUN pnpm --filter @multica/mcp build && \ + mkdir -p apps/web/public && \ + cp packages/mcp/dist/index.js apps/web/public/multica-mcp.js + # Build the web app (standalone output for minimal runtime) RUN pnpm --filter @multica/web build diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000000..3b440bae28 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,159 @@ +# @multica/mcp + +A [Model Context Protocol](https://modelcontextprotocol.io) server for Multica. +Exposes Multica resources — issues, agents, channels, projects, autopilots — as +MCP tools so any MCP-aware AI assistant (Claude Desktop, Claude Code, Cursor, +etc.) can orchestrate Multica directly from chat. + +The server is **self-contained**: it depends on the published MCP SDK and `zod`, +nothing else from the Multica monorepo. It talks to Multica over the same HTTP +API the web/desktop apps use; the local `multica` CLI does **not** need to be +installed for the server to run. + +## What's exposed + +| Group | Tools | +| --- | --- | +| Issues | `multica_issue_list`, `multica_issue_search`, `multica_issue_get`, `multica_issue_create`, `multica_issue_update`, `multica_issue_status`, `multica_issue_assign`, `multica_issue_comment_add`, `multica_issue_comment_list`, `multica_issue_runs` | +| Agents | `multica_agent_list`, `multica_agent_get`, `multica_agent_tasks` | +| Channels | `multica_channel_list`, `multica_channel_get`, `multica_channel_history`, `multica_channel_post`, `multica_channel_members`, `multica_channel_mark_read` | +| Projects | `multica_project_list`, `multica_project_get`, `multica_project_search`, `multica_project_create` | +| Labels | `multica_label_list`, `multica_label_attach`, `multica_label_detach` | +| Autopilots | `multica_autopilot_list`, `multica_autopilot_get`, `multica_autopilot_runs`, `multica_autopilot_trigger` | +| Workspace | `multica_workspace_get`, `multica_workspace_members` | + +Read tools are exposed broadly. Mutating tools err on the side of caution: agent +configuration, autopilot creation, label CRUD, and workspace-membership changes +are deliberately **not** exposed because they have wider blast radius and are +better-driven from the form-based UIs. + +## Requirements + +- Node.js 20 or newer. +- A Multica personal access token (`mul_…`) and the workspace UUID you want the + server to operate against. + +## Installation + +From the monorepo: + +```bash +pnpm install +pnpm --filter @multica/mcp build +``` + +The build produces a single bundled file at `packages/mcp/dist/index.js` with a +`#!/usr/bin/env node` shebang and the `multica-mcp` bin entry pointed at it. + +## Configuration + +Two sources, first-wins: + +1. **Environment variables** (recommended for MCP client configs): + - `MULTICA_API_URL` — HTTP base URL, e.g. `https://multica.example.com` or + `http://localhost:8080`. **No** trailing slash, **no** `/ws` suffix. + - `MULTICA_TOKEN` — bearer token (`mul_…`). Generate one with `multica auth + token create` or in the workspace settings UI. + - `MULTICA_WORKSPACE_ID` — default workspace UUID. Tools that don't take an + explicit `workspace_id` argument operate against this one. +2. **`~/.multica/config.json`** (the file the `multica` CLI writes on `multica + login`). Used as a fallback when the env vars above are unset. The CLI stores + a WebSocket URL like `wss://api.example/ws`; the MCP server derives the HTTP + base by swapping the scheme and stripping the `/ws` suffix. + +## Wiring it into an MCP client + +### Claude Desktop + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json` +(macOS): + +```json +{ + "mcpServers": { + "multica": { + "command": "node", + "args": ["/absolute/path/to/multica/packages/mcp/dist/index.js"], + "env": { + "MULTICA_API_URL": "https://your-multica.example.com", + "MULTICA_TOKEN": "mul_…", + "MULTICA_WORKSPACE_ID": "00000000-0000-0000-0000-000000000000" + } + } + } +} +``` + +Restart Claude Desktop after editing the file. + +### Claude Code + +```bash +claude mcp add multica \ + --env MULTICA_API_URL=https://your-multica.example.com \ + --env MULTICA_TOKEN=mul_… \ + --env MULTICA_WORKSPACE_ID=00000000-0000-0000-0000-000000000000 \ + -- node /absolute/path/to/multica/packages/mcp/dist/index.js +``` + +(Or omit `--env` flags entirely if the running shell has those variables set +and you'd rather inherit them. The new MCP server is available the next time you +start a Claude Code session.) + +### Cursor / Windsurf / other MCP clients + +Any client that speaks the standard MCP stdio transport works. The command is +`node /absolute/path/to/dist/index.js` plus the three env vars above. + +## Output format + +Tool results are returned as a single text content block containing pretty- +printed JSON of the underlying Multica API response. The model is expected to +parse it; the server does **not** strip or reshape fields. This keeps the +contract one-to-one with Multica's REST API and lets future server changes flow +through without an MCP-side translation layer. + +Errors (HTTP 4xx/5xx, validation failures, network errors) come back as `is +Error: true` content blocks with the upstream error body included so the model +can recover instead of hard-stopping. + +## Development + +```bash +pnpm --filter @multica/mcp typecheck # tsc --noEmit +pnpm --filter @multica/mcp test # vitest +pnpm --filter @multica/mcp dev # tsup --watch +``` + +The dev build is a single bundled `dist/index.js` you can `node` against to +smoke-test outside of an MCP client. + +## Layout + +``` +packages/mcp/ +├── README.md +├── package.json # name: @multica/mcp, bin: multica-mcp +├── tsconfig.json # extends @multica/tsconfig/base.json +├── tsup.config.ts # bundle to dist/index.js with a node20 shebang +└── src/ + ├── index.ts # CLI entry — parses env, connects stdio transport + ├── server.ts # Builds Server, registers tools, dispatches calls + ├── client.ts # Tiny self-contained Multica HTTP client + ├── config.ts # Env / ~/.multica/config.json loader + ├── tool.ts # ToolDefinition contract + defineTool helper + └── tools/ + ├── index.ts # Aggregates all tool modules into `allTools` + ├── workspace.ts + ├── issues.ts + ├── agents.ts + ├── channels.ts + ├── projects.ts + ├── labels.ts + └── autopilots.ts +``` + +Adding a new tool is a matter of dropping a new file under `src/tools/`, +exporting an array of `defineTool({ … })` instances, and listing the array in +`tools/index.ts`. No other wiring required — the server picks them up via +`allTools`. diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000000..0f48088804 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,42 @@ +{ + "name": "@multica/mcp", + "version": "0.1.0", + "description": "Model Context Protocol server for Multica — exposes Multica resources (issues, agents, channels, projects, autopilots) as MCP tools so any MCP-aware AI assistant can orchestrate Multica from chat.", + "private": true, + "type": "module", + "license": "MIT", + "bin": { + "multica-mcp": "./dist/index.js" + }, + "main": "./dist/index.js", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "start": "node ./dist/index.js", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "zod": "^3.24.1" + }, + "devDependencies": { + "@multica/tsconfig": "workspace:*", + "@types/node": "catalog:", + "tsup": "^8.3.5", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=20" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts new file mode 100644 index 0000000000..9edfd17b3c --- /dev/null +++ b/packages/mcp/src/client.ts @@ -0,0 +1,125 @@ +// Minimal HTTP client for the Multica REST API. Self-contained — no +// imports from @multica/core. We deliberately pay the cost of duplicating +// a few request shapes here so the MCP package can ship as a standalone +// monorepo node, and a future PR could lift it into its own repo with +// a single `pnpm tsup` invocation. +// +// Authentication: every request carries `Authorization: Bearer `. +// `X-Workspace-ID` is included on workspace-scoped endpoints; the caller +// passes per-request overrides so a tool like `multica_issue_create` can +// optionally target a non-default workspace. +// +// Errors: API 4xx/5xx responses are surfaced as `ApiError` with the body +// captured for the model. Network failures (DNS, connection) bubble up +// untouched so the MCP runtime sees the original exception. + +import type { MulticaConfig } from "./config.js"; + +export class ApiError extends Error { + readonly status: number; + readonly body: unknown; + constructor(status: number, message: string, body: unknown) { + super(message); + this.name = "ApiError"; + this.status = status; + this.body = body; + } +} + +export interface RequestOptions { + /** Workspace override for this single call (defaults to config.defaultWorkspaceId). */ + workspaceId?: string | null; + /** Path is appended to apiUrl as-is. Include leading slash. */ + query?: Record; + body?: unknown; + headers?: Record; + /** Per-call timeout in ms. Default 30s. */ + timeoutMs?: number; +} + +export class MulticaClient { + constructor(private readonly cfg: MulticaConfig) {} + + get apiUrl(): string { + return this.cfg.apiUrl; + } + + get defaultWorkspaceId(): string | null { + return this.cfg.defaultWorkspaceId; + } + + async get(path: string, opts: RequestOptions = {}): Promise { + return this.request("GET", path, opts); + } + async post(path: string, body: unknown, opts: RequestOptions = {}): Promise { + return this.request("POST", path, { ...opts, body }); + } + async patch(path: string, body: unknown, opts: RequestOptions = {}): Promise { + return this.request("PATCH", path, { ...opts, body }); + } + async delete(path: string, opts: RequestOptions = {}): Promise { + return this.request("DELETE", path, opts); + } + + private async request( + method: string, + path: string, + opts: RequestOptions, + ): Promise { + const url = new URL(this.cfg.apiUrl + path); + if (opts.query) { + for (const [k, v] of Object.entries(opts.query)) { + if (v === undefined) continue; + url.searchParams.set(k, String(v)); + } + } + + const headers: Record = { + Authorization: `Bearer ${this.cfg.token}`, + Accept: "application/json", + ...(opts.headers ?? {}), + }; + const wsId = opts.workspaceId ?? this.cfg.defaultWorkspaceId; + if (wsId) headers["X-Workspace-ID"] = wsId; + if (opts.body !== undefined) headers["Content-Type"] = "application/json"; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 30_000); + let res: Response; + try { + res = await fetch(url.toString(), { + method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + // 204 / empty body — common for DELETE and write endpoints that + // don't echo. Resolve with `null` cast to T so callers that expect + // void don't have to special-case. + if (res.status === 204) return null as T; + + const text = await res.text(); + let parsed: unknown = text; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + // Non-JSON response — leave as text and let the caller cope. + } + } + + if (!res.ok) { + const message = + (parsed && typeof parsed === "object" && "error" in parsed + ? String((parsed as { error: unknown }).error) + : null) ?? + (typeof parsed === "string" && parsed ? parsed : `${method} ${path} failed`); + throw new ApiError(res.status, `${res.status} ${message}`, parsed); + } + return parsed as T; + } +} diff --git a/packages/mcp/src/config.ts b/packages/mcp/src/config.ts new file mode 100644 index 0000000000..95f055382b --- /dev/null +++ b/packages/mcp/src/config.ts @@ -0,0 +1,121 @@ +// Loads connection config (API URL, auth token, default workspace) for +// the MCP server. Two sources, first-wins: +// +// 1. Environment variables — `MULTICA_API_URL`, `MULTICA_TOKEN`, +// `MULTICA_WORKSPACE_ID`. Takes priority because operators may want +// to point a single MCP install at a different workspace than the +// CLI is configured for (e.g. one Claude Desktop, two workspaces). +// 2. `~/.multica/config.json` — the same file the multica CLI writes +// on `multica login`. Lets users skip env-var setup entirely if +// their CLI is already authenticated. The JSON has a WebSocket +// `server_url` (e.g. `wss://api.example/ws`); we derive the HTTP +// base by swapping the scheme and stripping the trailing `/ws`. +// +// Throws TokenRequiredError when neither source yields a token — +// without one every API call would 401 and the MCP tools would be +// useless. + +import { homedir } from "node:os"; +import { join } from "node:path"; +import { readFileSync } from "node:fs"; + +export interface MulticaConfig { + /** HTTP base URL, e.g. https://multica-api.example.com (no trailing slash). */ + apiUrl: string; + /** Bearer token (`mul_...`). Sent as Authorization: Bearer . */ + token: string; + /** Default workspace UUID for tools that don't take an explicit override. */ + defaultWorkspaceId: string | null; +} + +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +interface RawCliConfig { + server_url?: string; + app_url?: string; + token?: string; + workspace_id?: string; +} + +export interface LoadConfigOptions { + /** Overrides for testing — when set, env + file lookups are skipped for that field. */ + apiUrl?: string; + token?: string; + workspaceId?: string; + /** Override the location of ~/.multica/config.json (test seam). */ + cliConfigPath?: string; + /** Mock `process.env` (test seam). Defaults to the real process.env. */ + env?: Record; +} + +const DEFAULT_CLI_CONFIG = join(homedir(), ".multica", "config.json"); + +export function loadConfig(opts: LoadConfigOptions = {}): MulticaConfig { + const env = opts.env ?? process.env; + const cliPath = opts.cliConfigPath ?? DEFAULT_CLI_CONFIG; + + // Best-effort read of the CLI config; missing/bad JSON is fine — + // env vars or explicit overrides may still satisfy us. + let cli: RawCliConfig = {}; + try { + const raw = readFileSync(cliPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object") { + cli = parsed as RawCliConfig; + } + } catch { + // File missing or unreadable — fall through to env. + } + + const apiUrl = + opts.apiUrl ?? + env.MULTICA_API_URL ?? + deriveHttpBase(cli.server_url) ?? + ""; + + const token = opts.token ?? env.MULTICA_TOKEN ?? cli.token ?? ""; + const defaultWorkspaceId = + opts.workspaceId ?? env.MULTICA_WORKSPACE_ID ?? cli.workspace_id ?? null; + + if (!apiUrl) { + throw new ConfigError( + "Multica API URL is not configured. Set MULTICA_API_URL or run `multica login` to populate ~/.multica/config.json.", + ); + } + if (!token) { + throw new ConfigError( + "Multica auth token is not configured. Set MULTICA_TOKEN or run `multica login` to populate ~/.multica/config.json.", + ); + } + + return { + apiUrl: stripTrailingSlash(apiUrl), + token, + defaultWorkspaceId, + }; +} + +// The CLI stores a WebSocket URL like `wss://api.example.com/ws`. The +// HTTP API lives at the same host with the matching scheme and no +// trailing `/ws`. Returns null on missing/bad input so the caller can +// fall through to env-only setups. +export function deriveHttpBase(serverUrl: string | undefined): string | null { + if (!serverUrl) return null; + let url = serverUrl.trim(); + if (!url) return null; + if (url.startsWith("wss://")) url = "https://" + url.slice("wss://".length); + else if (url.startsWith("ws://")) url = "http://" + url.slice("ws://".length); + // Strip a trailing `/ws` (and optionally a `/`) so the CLI's WebSocket + // suffix doesn't end up on every REST path. + url = url.replace(/\/ws\/?$/, ""); + return stripTrailingSlash(url); +} + +function stripTrailingSlash(s: string): string { + return s.endsWith("/") ? s.slice(0, -1) : s; +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 0000000000..d498fc3969 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,42 @@ +// CLI entry point. MCP clients spawn this binary with stdio inherited +// and speak JSON-RPC over the pipe. Anything we log to stdout would +// corrupt the protocol — every message goes to stderr instead. + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { MulticaClient } from "./client.js"; +import { ConfigError, loadConfig } from "./config.js"; +import { createServer } from "./server.js"; + +async function main(): Promise { + let config; + try { + config = loadConfig(); + } catch (err) { + if (err instanceof ConfigError) { + process.stderr.write(`multica-mcp: ${err.message}\n`); + process.exit(2); + } + throw err; + } + + const client = new MulticaClient(config); + const { server, tools } = createServer({ client }); + + process.stderr.write( + `multica-mcp: starting (api=${config.apiUrl}, workspace=${config.defaultWorkspaceId ?? ""}, tools=${tools.length})\n`, + ); + + const transport = new StdioServerTransport(); + await server.connect(transport); + + // Stay alive until the parent closes stdio. The transport handles the + // shutdown signal; we just keep the event loop pinned. + process.on("SIGINT", () => process.exit(0)); + process.on("SIGTERM", () => process.exit(0)); +} + +main().catch((err) => { + process.stderr.write(`multica-mcp: fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 0000000000..6a34509543 --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,184 @@ +// MCP server wiring. Builds the `Server` instance, registers every tool +// from `tools/index.ts`, and dispatches CallTool requests by name. Pure +// orchestration — the actual API logic lives in each tool module. + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +import type { MulticaClient } from "./client.js"; +import type { RegisteredTool, ToolContext } from "./tool.js"; +import { allTools } from "./tools/index.js"; + +export interface CreateServerOptions { + client: MulticaClient; + /** Override the tool registry — useful for tests that want to inject one fake tool. */ + tools?: RegisteredTool[]; +} + +const SERVER_NAME = "multica"; +// Hard-coded version string. Bumped manually with each release; keep in +// sync with package.json. +const SERVER_VERSION = "0.1.0"; + +export function createServer(opts: CreateServerOptions): { + server: Server; + tools: RegisteredTool[]; +} { + const tools = opts.tools ?? allTools; + // Quick duplicate-name guard. A typo here would let later tools shadow + // earlier ones silently and we'd ship a broken server. + const seen = new Set(); + for (const t of tools) { + if (seen.has(t.name)) { + throw new Error(`Duplicate MCP tool name: ${t.name}`); + } + seen.add(t.name); + } + + const ctx: ToolContext = { client: opts.client }; + const server = new Server( + { name: SERVER_NAME, version: SERVER_VERSION }, + { capabilities: { tools: {} } }, + ); + + // List: stable order matches `tools` so the picker UI doesn't shuffle + // between sessions. Each tool's input schema is converted from zod to + // JSON schema via `zodToJsonSchema` shimmed below — the SDK accepts a + // raw JSON schema object. + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: tools.map((t) => ({ + name: t.name, + title: t.title ?? t.name, + description: t.description, + inputSchema: zodToJsonSchema(t.inputSchema), + })), + }; + }); + + // Call: lookup → validate input → run handler → serialize result. + // Errors thrown by handlers (network, ApiError, validation) are + // converted to an MCP error content block so the model can recover + // instead of crashing the whole session. + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const tool = tools.find((t) => t.name === req.params.name); + if (!tool) { + return { + isError: true, + content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }], + }; + } + + let parsed: unknown; + try { + parsed = tool.inputSchema.parse(req.params.arguments ?? {}); + } catch (err) { + const detail = err instanceof z.ZodError ? formatZodIssues(err) : String(err); + return { + isError: true, + content: [{ type: "text", text: `Invalid input for ${tool.name}: ${detail}` }], + }; + } + + try { + const result = await tool.handler(parsed, ctx); + return { + content: [{ type: "text", text: serialize(result) }], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ type: "text", text: message }], + }; + } + }); + + return { server, tools }; +} + +function serialize(value: unknown): string { + if (typeof value === "string") return value; + // Pretty-print so the model can read structure when it inspects raw + // tool results in long conversation transcripts. + return JSON.stringify(value, null, 2); +} + +function formatZodIssues(err: z.ZodError): string { + return err.issues + .map((i) => `${i.path.length ? i.path.join(".") + ": " : ""}${i.message}`) + .join("; "); +} + +// The MCP SDK accepts JSON Schema objects directly. Zod 3 ships a +// `toJSONSchema`-style helper as a separate package, but to avoid the +// extra dependency we walk a minimal subset of zod types we actually +// use (object / string / number / boolean / array / enum / optional / +// nullable). For everything else we fall through to `{ type: "object" }` +// so the SDK doesn't choke; the handler still validates with the real +// zod schema before running. +function zodToJsonSchema(schema: z.ZodTypeAny): Record { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const def = (schema as any)._def; + if (!def) return { type: "object" }; + + const typeName = def.typeName as string; + switch (typeName) { + case "ZodObject": { + const shape = def.shape() as Record; + const properties: Record = {}; + const required: string[] = []; + for (const [key, child] of Object.entries(shape)) { + properties[key] = zodToJsonSchema(child); + if (!isOptional(child)) required.push(key); + } + const out: Record = { + type: "object", + properties, + }; + if (required.length > 0) out.required = required; + const description = (schema as { description?: string }).description; + if (description) out.description = description; + return out; + } + case "ZodString": { + const out: Record = { type: "string" }; + const description = (schema as { description?: string }).description; + if (description) out.description = description; + return out; + } + case "ZodNumber": + return { type: "number" }; + case "ZodBoolean": + return { type: "boolean" }; + case "ZodArray": + return { type: "array", items: zodToJsonSchema(def.type) }; + case "ZodEnum": + return { type: "string", enum: def.values }; + case "ZodLiteral": + return { const: def.value }; + case "ZodOptional": + case "ZodNullable": + case "ZodDefault": + return zodToJsonSchema(def.innerType); + case "ZodUnion": { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options = def.options as any[]; + return { anyOf: options.map((o: z.ZodTypeAny) => zodToJsonSchema(o)) }; + } + case "ZodRecord": + return { type: "object", additionalProperties: zodToJsonSchema(def.valueType) }; + default: + return { type: "object" }; + } +} + +function isOptional(schema: z.ZodTypeAny): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const typeName = (schema as any)._def?.typeName; + return typeName === "ZodOptional" || typeName === "ZodDefault"; +} diff --git a/packages/mcp/src/test/client.test.ts b/packages/mcp/src/test/client.test.ts new file mode 100644 index 0000000000..5701e88820 --- /dev/null +++ b/packages/mcp/src/test/client.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError, MulticaClient } from "../client.js"; + +interface CapturedRequest { + url: string; + init: RequestInit; +} + +function mockFetch(handler: (req: CapturedRequest) => Response | Promise) { + const captured: CapturedRequest[] = []; + const fetchSpy = vi.fn(async (url: string, init: RequestInit) => { + const req = { url, init }; + captured.push(req); + return handler(req); + }); + vi.stubGlobal("fetch", fetchSpy); + return { captured }; +} + +const baseConfig = { + apiUrl: "https://api.example.com", + token: "mul_test", + defaultWorkspaceId: "ws-1", +}; + +describe("MulticaClient", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("sends Authorization + X-Workspace-ID by default and parses JSON responses", async () => { + const { captured } = mockFetch(() => new Response(JSON.stringify({ ok: true }), { status: 200 })); + const client = new MulticaClient(baseConfig); + + const result = await client.get<{ ok: boolean }>("/api/foo"); + + expect(result).toEqual({ ok: true }); + expect(captured).toHaveLength(1); + const headers = captured[0]!.init.headers as Record; + expect(headers["Authorization"]).toBe("Bearer mul_test"); + expect(headers["X-Workspace-ID"]).toBe("ws-1"); + expect(captured[0]!.url).toBe("https://api.example.com/api/foo"); + }); + + it("merges query params into the URL", async () => { + const { captured } = mockFetch(() => new Response("[]", { status: 200 })); + const client = new MulticaClient(baseConfig); + + await client.get("/api/issues", { query: { limit: 10, status: "todo", skip: undefined } }); + + const url = new URL(captured[0]!.url); + expect(url.pathname).toBe("/api/issues"); + expect(url.searchParams.get("limit")).toBe("10"); + expect(url.searchParams.get("status")).toBe("todo"); + expect(url.searchParams.has("skip")).toBe(false); + }); + + it("includes JSON body and Content-Type on POST", async () => { + const { captured } = mockFetch(() => new Response("{}", { status: 200 })); + const client = new MulticaClient(baseConfig); + + await client.post("/api/issues", { title: "Hello" }); + + expect(captured[0]!.init.method).toBe("POST"); + const headers = captured[0]!.init.headers as Record; + expect(headers["Content-Type"]).toBe("application/json"); + expect(captured[0]!.init.body).toBe(JSON.stringify({ title: "Hello" })); + }); + + it("returns null on 204 No Content", async () => { + mockFetch(() => new Response(null, { status: 204 })); + const client = new MulticaClient(baseConfig); + await expect(client.delete("/api/issues/x")).resolves.toBeNull(); + }); + + it("wraps non-2xx responses in ApiError with the body attached", async () => { + mockFetch( + () => new Response(JSON.stringify({ error: "not found" }), { status: 404 }), + ); + const client = new MulticaClient(baseConfig); + await expect(client.get("/api/issues/missing")).rejects.toMatchObject({ + name: "ApiError", + status: 404, + body: { error: "not found" }, + }); + }); + + it("preserves raw text bodies that aren't JSON", async () => { + mockFetch(() => new Response("plain string", { status: 500 })); + const client = new MulticaClient(baseConfig); + try { + await client.get("/api/oops"); + throw new Error("expected ApiError"); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + const e = err as ApiError; + expect(e.status).toBe(500); + expect(e.body).toBe("plain string"); + } + }); + + it("respects per-call workspace override", async () => { + const { captured } = mockFetch(() => new Response("{}", { status: 200 })); + const client = new MulticaClient(baseConfig); + + await client.get("/api/foo", { workspaceId: "ws-2" }); + const headers = captured[0]!.init.headers as Record; + expect(headers["X-Workspace-ID"]).toBe("ws-2"); + }); + + it("omits X-Workspace-ID when caller passes null and no default is set", async () => { + const { captured } = mockFetch(() => new Response("{}", { status: 200 })); + const client = new MulticaClient({ ...baseConfig, defaultWorkspaceId: null }); + + await client.get("/api/foo", { workspaceId: null }); + const headers = captured[0]!.init.headers as Record; + expect(headers["X-Workspace-ID"]).toBeUndefined(); + }); +}); diff --git a/packages/mcp/src/test/config.test.ts b/packages/mcp/src/test/config.test.ts new file mode 100644 index 0000000000..41f7414023 --- /dev/null +++ b/packages/mcp/src/test/config.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { writeFileSync, mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { ConfigError, deriveHttpBase, loadConfig } from "../config.js"; + +describe("deriveHttpBase", () => { + it("converts wss:// to https:// and strips /ws suffix", () => { + expect(deriveHttpBase("wss://api.example.com/ws")).toBe("https://api.example.com"); + }); + it("converts ws:// to http:// and strips /ws/ suffix", () => { + expect(deriveHttpBase("ws://localhost:8080/ws/")).toBe("http://localhost:8080"); + }); + it("leaves plain https:// alone", () => { + expect(deriveHttpBase("https://api.example.com")).toBe("https://api.example.com"); + }); + it("returns null for empty / undefined input", () => { + expect(deriveHttpBase(undefined)).toBeNull(); + expect(deriveHttpBase("")).toBeNull(); + expect(deriveHttpBase(" ")).toBeNull(); + }); +}); + +describe("loadConfig", () => { + it("reads env vars when present", () => { + const cfg = loadConfig({ + env: { + MULTICA_API_URL: "https://api.example.com/", + MULTICA_TOKEN: "mul_test", + MULTICA_WORKSPACE_ID: "ws-1", + }, + cliConfigPath: "/nonexistent/path/config.json", + }); + expect(cfg).toEqual({ + apiUrl: "https://api.example.com", + token: "mul_test", + defaultWorkspaceId: "ws-1", + }); + }); + + it("falls back to ~/.multica/config.json when env is empty", () => { + const dir = mkdtempSync(join(tmpdir(), "multica-mcp-test-")); + const path = join(dir, "config.json"); + writeFileSync( + path, + JSON.stringify({ + server_url: "wss://api.example.com/ws", + token: "mul_cli", + workspace_id: "ws-cli", + }), + ); + try { + const cfg = loadConfig({ env: {}, cliConfigPath: path }); + expect(cfg).toEqual({ + apiUrl: "https://api.example.com", + token: "mul_cli", + defaultWorkspaceId: "ws-cli", + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("env wins over CLI config when both are set", () => { + const dir = mkdtempSync(join(tmpdir(), "multica-mcp-test-")); + const path = join(dir, "config.json"); + writeFileSync( + path, + JSON.stringify({ token: "from_cli", workspace_id: "ws-cli", server_url: "wss://cli.example/ws" }), + ); + try { + const cfg = loadConfig({ + env: { MULTICA_API_URL: "https://env.example", MULTICA_TOKEN: "from_env" }, + cliConfigPath: path, + }); + expect(cfg.apiUrl).toBe("https://env.example"); + expect(cfg.token).toBe("from_env"); + // Workspace falls back since env didn't set one. + expect(cfg.defaultWorkspaceId).toBe("ws-cli"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("throws ConfigError when no token is available anywhere", () => { + expect(() => + loadConfig({ + env: { MULTICA_API_URL: "https://api.example.com" }, + cliConfigPath: "/nonexistent/config.json", + }), + ).toThrow(ConfigError); + }); + + it("throws ConfigError when no API URL is available anywhere", () => { + expect(() => + loadConfig({ + env: { MULTICA_TOKEN: "mul_test" }, + cliConfigPath: "/nonexistent/config.json", + }), + ).toThrow(ConfigError); + }); + + it("ignores a malformed CLI config without crashing", () => { + const dir = mkdtempSync(join(tmpdir(), "multica-mcp-test-")); + const path = join(dir, "config.json"); + writeFileSync(path, "{ this is : not json"); + try { + // Should fall through to env-based loading. + const cfg = loadConfig({ + env: { MULTICA_API_URL: "https://api.example.com", MULTICA_TOKEN: "mul_env" }, + cliConfigPath: path, + }); + expect(cfg.apiUrl).toBe("https://api.example.com"); + expect(cfg.token).toBe("mul_env"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/mcp/src/test/tools.test.ts b/packages/mcp/src/test/tools.test.ts new file mode 100644 index 0000000000..e8db45aae4 --- /dev/null +++ b/packages/mcp/src/test/tools.test.ts @@ -0,0 +1,157 @@ +// Sanity-level coverage for the tool registry. We're deliberately not +// testing every handler against a mock server (that's mostly testing +// the HTTP client which has its own suite); instead we assert the +// registry contract and that a few representative tools forward +// arguments to the right endpoint with the right shape. + +import { describe, expect, it, vi } from "vitest"; +import { allTools } from "../tools/index.js"; +import type { MulticaClient } from "../client.js"; +import type { ToolContext } from "../tool.js"; +import { + issueCreateTool, + issueListTool, + issueStatusTool, +} from "../tools/issues.js"; +import { channelHistoryTool } from "../tools/channels.js"; + +function fakeClient(): { client: MulticaClient; calls: { method: string; path: string; body?: unknown; opts?: unknown }[] } { + const calls: { method: string; path: string; body?: unknown; opts?: unknown }[] = []; + const client = { + apiUrl: "https://api.example", + defaultWorkspaceId: "ws-1", + get: vi.fn(async (path: string, opts?: unknown) => { + calls.push({ method: "GET", path, opts }); + return { stub: "get" }; + }), + post: vi.fn(async (path: string, body: unknown, opts?: unknown) => { + calls.push({ method: "POST", path, body, opts }); + return { stub: "post" }; + }), + patch: vi.fn(async (path: string, body: unknown, opts?: unknown) => { + calls.push({ method: "PATCH", path, body, opts }); + return { stub: "patch" }; + }), + delete: vi.fn(async (path: string, opts?: unknown) => { + calls.push({ method: "DELETE", path, opts }); + return { stub: "delete" }; + }), + } as unknown as MulticaClient; + return { client, calls }; +} + +describe("tool registry", () => { + it("has no duplicate tool names", () => { + const seen = new Set(); + for (const t of allTools) { + expect(seen.has(t.name), `duplicate tool name: ${t.name}`).toBe(false); + seen.add(t.name); + } + }); + + it("every tool name uses the multica_ prefix", () => { + for (const t of allTools) { + expect(t.name.startsWith("multica_")).toBe(true); + } + }); + + it("every tool has a non-empty description and an inputSchema", () => { + for (const t of allTools) { + expect(t.description.length).toBeGreaterThan(10); + expect(t.inputSchema).toBeDefined(); + } + }); +}); + +describe("representative handlers", () => { + it("issueListTool forwards filters as query params", async () => { + const { client, calls } = fakeClient(); + const ctx: ToolContext = { client }; + await issueListTool.handler( + { status: "todo", priority: "high", limit: 25 }, + ctx, + ); + expect(calls).toEqual([ + { + method: "GET", + path: "/api/issues", + opts: { + query: { + status: "todo", + priority: "high", + assignee: undefined, + project: undefined, + limit: 25, + offset: undefined, + }, + }, + }, + ]); + }); + + it("issueCreateTool POSTs the canonical create payload", async () => { + const { client, calls } = fakeClient(); + const ctx: ToolContext = { client }; + await issueCreateTool.handler( + { title: "Hello", description: "world" }, + ctx, + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.method).toBe("POST"); + expect(calls[0]!.path).toBe("/api/issues"); + expect(calls[0]!.body).toEqual({ + title: "Hello", + description: "world", + status: "todo", + priority: "none", + assignee_type: null, + assignee_id: null, + parent_issue_id: null, + project_id: null, + due_date: null, + }); + }); + + it("issueStatusTool URL-encodes the id", async () => { + const { client, calls } = fakeClient(); + const ctx: ToolContext = { client }; + await issueStatusTool.handler({ id: "MUL-123", status: "in_progress" }, ctx); + expect(calls[0]!.method).toBe("PATCH"); + expect(calls[0]!.path).toBe("/api/issues/MUL-123"); + expect(calls[0]!.body).toEqual({ status: "in_progress" }); + }); + + it("channelHistoryTool passes pagination params through", async () => { + const { client, calls } = fakeClient(); + const ctx: ToolContext = { client }; + await channelHistoryTool.handler( + { + channel_id: "11111111-2222-3333-4444-555555555555", + limit: 100, + before: "2026-04-30T12:00:00Z", + include_threaded: true, + }, + ctx, + ); + expect(calls[0]!.method).toBe("GET"); + expect(calls[0]!.path).toBe( + "/api/channels/11111111-2222-3333-4444-555555555555/messages", + ); + expect(calls[0]!.opts).toEqual({ + query: { + limit: 100, + before: "2026-04-30T12:00:00Z", + include_threaded: true, + }, + }); + }); + + it("rejects malformed input via the inputSchema", () => { + expect(() => + issueCreateTool.inputSchema.parse({ title: "" }), + ).toThrow(); + expect(() => + channelHistoryTool.inputSchema.parse({ channel_id: "not-a-uuid" }), + ).toThrow(); + }); +}); diff --git a/packages/mcp/src/tool.ts b/packages/mcp/src/tool.ts new file mode 100644 index 0000000000..d877705b26 --- /dev/null +++ b/packages/mcp/src/tool.ts @@ -0,0 +1,59 @@ +// Internal tool registration shape. Each `tools/*.ts` module exports an +// array of `RegisteredTool` objects via `defineTool({…})`; the server +// collects them all and dispatches CallTool requests by name. +// +// The shape is split into two layers: +// +// - `ToolDefinition` — the *authored* shape, generic over the +// input zod schema so handlers get statically-typed `input` from +// `z.infer`. Used inside `defineTool`. +// - `RegisteredTool` — the *registry* shape, with the schema typed as +// plain `ZodTypeAny` and the handler as `(unknown, ctx) => …`. This +// is what the server iterates so an array of tools-with-different- +// schemas unifies cleanly into one collection. +// +// `defineTool` is the bridge: callers write strongly-typed handlers; the +// returned value is erased to `RegisteredTool` so the registry can hold +// a heterogeneous list without type-variance complaints. The server +// re-validates input against the schema before invoking the handler, so +// erasing the type here is safe at runtime. + +import type { z } from "zod"; +import type { MulticaClient } from "./client.js"; + +export interface ToolContext { + client: MulticaClient; +} + +export interface ToolDefinition { + /** Tool name as exposed to the model. Convention: `multica__`. */ + name: string; + /** Short description shown in the tool picker. ~1–2 sentences max. */ + title?: string; + /** Long-form description used by the model to decide when to call this tool. */ + description: string; + /** Zod schema for input validation. The MCP SDK converts this to JSON schema. */ + inputSchema: TInput; + /** Handler — return a JSON-serializable value. The server wraps the response into MCP `content`. */ + handler: (input: z.infer, ctx: ToolContext) => Promise; +} + +export interface RegisteredTool { + name: string; + title?: string; + description: string; + inputSchema: z.ZodTypeAny; + handler: (input: unknown, ctx: ToolContext) => Promise; +} + +/** + * Promotes a strongly-typed `ToolDefinition` into the registry shape. + * The cast is sound because the server validates input against the same + * `inputSchema` before calling `handler` — by the time the handler runs, + * `input` matches `z.infer` even though we lost the type. + */ +export function defineTool( + def: ToolDefinition, +): RegisteredTool { + return def as unknown as RegisteredTool; +} diff --git a/packages/mcp/src/tools/agents.ts b/packages/mcp/src/tools/agents.ts new file mode 100644 index 0000000000..240c2c11a9 --- /dev/null +++ b/packages/mcp/src/tools/agents.ts @@ -0,0 +1,56 @@ +// Agent tools — read-only surface for picking the right agent to assign +// or @-mention. Mutating operations (create / archive / set skills) are +// intentionally not exposed: agent configuration changes ripple into +// task dispatch and shouldn't happen from a chat-side LLM by default. + +import { z } from "zod"; +import { defineTool } from "../tool.js"; + +export const agentListTool = defineTool({ + name: "multica_agent_list", + title: "List agents", + description: + "Return agents in the workspace (id, name, description, runtime status, archived). Use to find the right agent before creating or assigning an issue.", + inputSchema: z.object({ + include_archived: z + .boolean() + .optional() + .describe("Include archived agents. Default false."), + }), + handler: async (input, ctx) => { + return ctx.client.get("/api/agents", { + query: { include_archived: input.include_archived }, + }); + }, +}); + +export const agentGetTool = defineTool({ + name: "multica_agent_get", + title: "Get agent", + description: + "Fetch full details for one agent (instructions, skills, runtime, custom env). Use to verify an agent is reachable before assigning to it.", + inputSchema: z.object({ + id: z.string().uuid(), + }), + handler: async (input, ctx) => { + return ctx.client.get(`/api/agents/${input.id}`); + }, +}); + +export const agentTasksTool = defineTool({ + name: "multica_agent_tasks", + title: "List agent's recent tasks", + description: + "Return the most recent tasks dispatched to an agent (status, issue, started_at, completed_at). Useful to check whether the agent is currently busy.", + inputSchema: z.object({ + id: z.string().uuid(), + limit: z.number().int().min(1).max(100).optional().describe("Default 20."), + }), + handler: async (input, ctx) => { + return ctx.client.get(`/api/agents/${input.id}/tasks`, { + query: { limit: input.limit }, + }); + }, +}); + +export const agentTools = [agentListTool, agentGetTool, agentTasksTool]; diff --git a/packages/mcp/src/tools/autopilots.ts b/packages/mcp/src/tools/autopilots.ts new file mode 100644 index 0000000000..17316e06ef --- /dev/null +++ b/packages/mcp/src/tools/autopilots.ts @@ -0,0 +1,71 @@ +// Autopilot tools — read-only. Triggering an autopilot run IS exposed +// because that's the high-leverage chat-side workflow ("kick off the +// nightly cleanup"); creating new autopilots / triggers is left for the +// UI where the form-driven wizard makes the contract obvious. + +import { z } from "zod"; +import { defineTool } from "../tool.js"; + +export const autopilotListTool = defineTool({ + name: "multica_autopilot_list", + title: "List autopilots", + description: + "Return autopilots in the workspace (id, title, status, agent, source). Use to find an autopilot before triggering or inspecting runs.", + inputSchema: z.object({}), + handler: async (_input, ctx) => { + return ctx.client.get("/api/autopilots"); + }, +}); + +export const autopilotGetTool = defineTool({ + name: "multica_autopilot_get", + title: "Get autopilot", + description: "Full autopilot details including triggers and instructions.", + inputSchema: z.object({ + id: z.string().uuid(), + }), + handler: async (input, ctx) => { + return ctx.client.get(`/api/autopilots/${input.id}`); + }, +}); + +export const autopilotRunsTool = defineTool({ + name: "multica_autopilot_runs", + title: "List autopilot runs", + description: "Recent execution history for an autopilot (status, started, completed, error).", + inputSchema: z.object({ + id: z.string().uuid(), + limit: z.number().int().min(1).max(100).optional().describe("Default 20."), + }), + handler: async (input, ctx) => { + return ctx.client.get(`/api/autopilots/${input.id}/runs`, { + query: { limit: input.limit }, + }); + }, +}); + +export const autopilotTriggerTool = defineTool({ + name: "multica_autopilot_trigger", + title: "Trigger autopilot run", + description: + "Manually start an autopilot run. Optional payload is forwarded to the agent as the run's trigger context.", + inputSchema: z.object({ + id: z.string().uuid(), + payload: z + .record(z.unknown()) + .optional() + .describe("Free-form JSON payload available to the agent during the run."), + }), + handler: async (input, ctx) => { + return ctx.client.post(`/api/autopilots/${input.id}/trigger`, { + payload: input.payload ?? null, + }); + }, +}); + +export const autopilotTools = [ + autopilotListTool, + autopilotGetTool, + autopilotRunsTool, + autopilotTriggerTool, +]; diff --git a/packages/mcp/src/tools/channels.ts b/packages/mcp/src/tools/channels.ts new file mode 100644 index 0000000000..194ddaf50c --- /dev/null +++ b/packages/mcp/src/tools/channels.ts @@ -0,0 +1,128 @@ +// Channels tools — read + post for the multi-participant chat surface. +// Posting a message that @-mentions an agent (or in a DM with an agent) +// dispatches a task on the server, so the LLM can use this surface to +// chat with agents in addition to the issue board. + +import { z } from "zod"; +import { defineTool } from "../tool.js"; + +export const channelListTool = defineTool({ + name: "multica_channel_list", + title: "List channels", + description: + "Return channels and DMs the active actor belongs to, including per-channel unread counts. Use before posting to look up channel ids.", + inputSchema: z.object({}), + handler: async (_input, ctx) => { + return ctx.client.get("/api/channels"); + }, +}); + +export const channelGetTool = defineTool({ + name: "multica_channel_get", + title: "Get channel", + description: "Return one channel's metadata by id.", + inputSchema: z.object({ + id: z.string().uuid(), + }), + handler: async (input, ctx) => { + return ctx.client.get(`/api/channels/${input.id}`); + }, +}); + +export const channelHistoryTool = defineTool({ + name: "multica_channel_history", + title: "List channel messages", + description: + "Return messages in a channel, newest first, paginated by created_at. Use the oldest 'created_at' you've already seen as 'before' to fetch the next older page.", + inputSchema: z.object({ + channel_id: z.string().uuid(), + limit: z + .number() + .int() + .min(1) + .max(200) + .optional() + .describe("Default 50, capped at 200."), + before: z + .string() + .optional() + .describe( + "RFC3339 timestamp; only messages strictly older are returned. Pass the oldest 'created_at' from a prior page to paginate.", + ), + include_threaded: z + .boolean() + .optional() + .describe( + "Include thread replies inline. Default false — top-level only.", + ), + }), + handler: async (input, ctx) => { + return ctx.client.get(`/api/channels/${input.channel_id}/messages`, { + query: { + limit: input.limit, + before: input.before, + include_threaded: input.include_threaded, + }, + }); + }, +}); + +export const channelPostTool = defineTool({ + name: "multica_channel_post", + title: "Post a channel message", + description: + "Post a message to a channel. To @-mention an agent (which dispatches a task to it), include the canonical mention markup: '[@AgentName](mention://agent/)'. In a DM with an agent, every member message implicitly addresses the agent — no @mention required.", + inputSchema: z.object({ + channel_id: z.string().uuid(), + content: z.string().min(1), + parent_message_id: z + .string() + .uuid() + .optional() + .describe("Reply in a thread instead of the main timeline."), + }), + handler: async (input, ctx) => { + return ctx.client.post(`/api/channels/${input.channel_id}/messages`, { + content: input.content, + parent_message_id: input.parent_message_id ?? null, + }); + }, +}); + +export const channelMembersTool = defineTool({ + name: "multica_channel_members", + title: "List channel members", + description: + "Return the channel's member list (members + agents). Use to verify an agent is in a channel before @-mentioning it (mentions of non-members render but don't dispatch tasks).", + inputSchema: z.object({ + channel_id: z.string().uuid(), + }), + handler: async (input, ctx) => { + return ctx.client.get(`/api/channels/${input.channel_id}/members`); + }, +}); + +export const channelMarkReadTool = defineTool({ + name: "multica_channel_mark_read", + title: "Mark channel as read", + description: + "Update the read cursor for the active actor up to the given message id. Clears the unread badge.", + inputSchema: z.object({ + channel_id: z.string().uuid(), + message_id: z.string().uuid(), + }), + handler: async (input, ctx) => { + return ctx.client.post(`/api/channels/${input.channel_id}/read`, { + message_id: input.message_id, + }); + }, +}); + +export const channelTools = [ + channelListTool, + channelGetTool, + channelHistoryTool, + channelPostTool, + channelMembersTool, + channelMarkReadTool, +]; diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts new file mode 100644 index 0000000000..52cef6b9d3 --- /dev/null +++ b/packages/mcp/src/tools/index.ts @@ -0,0 +1,23 @@ +// Aggregated tool registry. Order here is the order the MCP picker +// shows tools to the model — front-load the high-leverage ones (issues, +// channels, agents) and put admin/configuration further down so the +// orchestration surface is the first thing the model sees. + +import type { RegisteredTool } from "../tool.js"; +import { workspaceTools } from "./workspace.js"; +import { issueTools } from "./issues.js"; +import { agentTools } from "./agents.js"; +import { channelTools } from "./channels.js"; +import { projectTools } from "./projects.js"; +import { labelTools } from "./labels.js"; +import { autopilotTools } from "./autopilots.js"; + +export const allTools: RegisteredTool[] = [ + ...issueTools, + ...agentTools, + ...channelTools, + ...projectTools, + ...labelTools, + ...autopilotTools, + ...workspaceTools, +]; diff --git a/packages/mcp/src/tools/issues.ts b/packages/mcp/src/tools/issues.ts new file mode 100644 index 0000000000..763fb8686c --- /dev/null +++ b/packages/mcp/src/tools/issues.ts @@ -0,0 +1,255 @@ +// Issue lifecycle tools. The high-leverage surface for orchestrating +// agent work from chat: create → assign to an agent → check progress → +// post comments. Pagination params follow the server's conventions +// (limit/offset, default 50, capped at 200 by the handler). + +import { z } from "zod"; +import { defineTool } from "../tool.js"; + +const statusEnum = z + .enum([ + "todo", + "in_progress", + "in_review", + "done", + "blocked", + "backlog", + "cancelled", + ]) + .describe("Issue status. Default for new issues is 'todo'."); + +const priorityEnum = z + .enum(["urgent", "high", "medium", "low", "none"]) + .describe("Issue priority. Use 'none' (or omit) for no priority."); + +const assigneeTypeEnum = z + .enum(["member", "agent"]) + .describe("'member' for a human, 'agent' for an AI agent."); + +export const issueListTool = defineTool({ + name: "multica_issue_list", + title: "List issues", + description: + "List issues in the active workspace with optional filters. Use small limits (e.g. 20) when scanning so the response stays under a few KB.", + inputSchema: z.object({ + status: statusEnum.optional(), + priority: priorityEnum.optional(), + assignee_id: z + .string() + .uuid() + .optional() + .describe("Filter by assignee (member OR agent UUID)."), + project_id: z.string().uuid().optional(), + limit: z.number().int().min(1).max(200).optional().describe("Default 50."), + offset: z.number().int().min(0).optional(), + }), + handler: async (input, ctx) => { + return ctx.client.get("/api/issues", { + query: { + status: input.status, + priority: input.priority, + assignee: input.assignee_id, + project: input.project_id, + limit: input.limit, + offset: input.offset, + }, + }); + }, +}); + +export const issueSearchTool = defineTool({ + name: "multica_issue_search", + title: "Search issues", + description: + "Full-text search across issues by title, description, and identifier (e.g. 'MUL-123'). Returns the top matches.", + inputSchema: z.object({ + q: z.string().min(1).describe("Free-text query."), + limit: z.number().int().min(1).max(50).optional().describe("Default 10."), + }), + handler: async (input, ctx) => { + return ctx.client.get("/api/issues/search", { + query: { q: input.q, limit: input.limit }, + }); + }, +}); + +export const issueGetTool = defineTool({ + name: "multica_issue_get", + title: "Get issue", + description: + "Fetch full issue details by UUID or human identifier (e.g. 'MUL-123'). Includes title, description, status, priority, assignee, project, parent, due date.", + inputSchema: z.object({ + id: z.string().min(1).describe("Issue UUID or 'PREFIX-NNN' identifier."), + }), + handler: async (input, ctx) => { + return ctx.client.get(`/api/issues/${encodeURIComponent(input.id)}`); + }, +}); + +export const issueCreateTool = defineTool({ + name: "multica_issue_create", + title: "Create issue", + description: + "Create a new issue. Title is required; everything else is optional. Pass 'assignee_type'+'assignee_id' to assign at creation (most common: assign to an agent so it picks up immediately).", + inputSchema: z.object({ + title: z.string().min(1), + description: z.string().optional(), + status: statusEnum.optional(), + priority: priorityEnum.optional(), + assignee_type: assigneeTypeEnum.optional(), + assignee_id: z.string().uuid().optional(), + parent_issue_id: z.string().uuid().optional(), + project_id: z.string().uuid().optional(), + due_date: z + .string() + .optional() + .describe("RFC3339 timestamp, e.g. '2026-12-31T00:00:00Z'."), + }), + handler: async (input, ctx) => { + return ctx.client.post("/api/issues", { + title: input.title, + description: input.description ?? null, + status: input.status ?? "todo", + priority: input.priority ?? "none", + assignee_type: input.assignee_type ?? null, + assignee_id: input.assignee_id ?? null, + parent_issue_id: input.parent_issue_id ?? null, + project_id: input.project_id ?? null, + due_date: input.due_date ?? null, + }); + }, +}); + +export const issueUpdateTool = defineTool({ + name: "multica_issue_update", + title: "Update issue", + description: + "Patch one or more issue fields. Only fields you pass are updated. Use 'multica_issue_status' for the common status-only flip.", + inputSchema: z.object({ + id: z.string().min(1), + title: z.string().optional(), + description: z.string().optional(), + status: statusEnum.optional(), + priority: priorityEnum.optional(), + assignee_type: assigneeTypeEnum.optional(), + assignee_id: z + .string() + .uuid() + .nullable() + .optional() + .describe("Pass null to unassign."), + parent_issue_id: z.string().uuid().nullable().optional(), + project_id: z.string().uuid().nullable().optional(), + due_date: z.string().nullable().optional(), + }), + handler: async (input, ctx) => { + const { id, ...rest } = input; + return ctx.client.patch(`/api/issues/${encodeURIComponent(id)}`, rest); + }, +}); + +export const issueStatusTool = defineTool({ + name: "multica_issue_status", + title: "Change issue status", + description: "Convenience wrapper for status-only updates.", + inputSchema: z.object({ + id: z.string().min(1), + status: statusEnum, + }), + handler: async (input, ctx) => { + return ctx.client.patch(`/api/issues/${encodeURIComponent(input.id)}`, { + status: input.status, + }); + }, +}); + +export const issueAssignTool = defineTool({ + name: "multica_issue_assign", + title: "Assign issue", + description: + "Assign an issue to a member or agent. Pass assignee_id=null to unassign. Assigning to an agent dispatches a task immediately if the agent has a runtime attached.", + inputSchema: z.object({ + id: z.string().min(1), + assignee_type: assigneeTypeEnum.nullable(), + assignee_id: z.string().uuid().nullable(), + }), + handler: async (input, ctx) => { + return ctx.client.patch(`/api/issues/${encodeURIComponent(input.id)}`, { + assignee_type: input.assignee_type, + assignee_id: input.assignee_id, + }); + }, +}); + +export const issueCommentAddTool = defineTool({ + name: "multica_issue_comment_add", + title: "Add issue comment", + description: + "Post a comment on an issue. Comments can @-mention agents to trigger them — see workspace agent list for valid IDs. Markdown is supported.", + inputSchema: z.object({ + issue_id: z.string().min(1), + content: z.string().min(1), + parent_comment_id: z + .string() + .uuid() + .optional() + .describe("Reply to an existing comment instead of starting a new thread."), + }), + handler: async (input, ctx) => { + return ctx.client.post( + `/api/issues/${encodeURIComponent(input.issue_id)}/comments`, + { + content: input.content, + parent_id: input.parent_comment_id ?? null, + }, + ); + }, +}); + +export const issueCommentListTool = defineTool({ + name: "multica_issue_comment_list", + title: "List issue comments", + description: + "Return comments on an issue (oldest first, paginated). Includes author identity, content, and parent_id for threading.", + inputSchema: z.object({ + issue_id: z.string().min(1), + limit: z.number().int().min(1).max(200).optional().describe("Default 50."), + offset: z.number().int().min(0).optional(), + }), + handler: async (input, ctx) => { + return ctx.client.get( + `/api/issues/${encodeURIComponent(input.issue_id)}/comments`, + { + query: { limit: input.limit, offset: input.offset }, + }, + ); + }, +}); + +export const issueRunsTool = defineTool({ + name: "multica_issue_runs", + title: "List issue task runs", + description: + "Return all task runs for an issue (status, dispatched_at, completed_at, error). Use to check whether an assigned agent is making progress.", + inputSchema: z.object({ + issue_id: z.string().min(1), + }), + handler: async (input, ctx) => { + return ctx.client.get( + `/api/issues/${encodeURIComponent(input.issue_id)}/task-runs`, + ); + }, +}); + +export const issueTools = [ + issueListTool, + issueSearchTool, + issueGetTool, + issueCreateTool, + issueUpdateTool, + issueStatusTool, + issueAssignTool, + issueCommentAddTool, + issueCommentListTool, + issueRunsTool, +]; diff --git a/packages/mcp/src/tools/labels.ts b/packages/mcp/src/tools/labels.ts new file mode 100644 index 0000000000..e12cf01bda --- /dev/null +++ b/packages/mcp/src/tools/labels.ts @@ -0,0 +1,49 @@ +// Label tools — list + attach/detach on issues. Create/update isn't +// exposed: label taxonomy is a deliberate workspace decision and chat- +// driven creation tends to fragment categories. + +import { z } from "zod"; +import { defineTool } from "../tool.js"; + +export const labelListTool = defineTool({ + name: "multica_label_list", + title: "List labels", + description: "Return all labels in the active workspace (id, name, color).", + inputSchema: z.object({}), + handler: async (_input, ctx) => { + return ctx.client.get("/api/labels"); + }, +}); + +export const labelAttachTool = defineTool({ + name: "multica_label_attach", + title: "Attach a label to an issue", + description: "Attach an existing label (by id) to an issue. No-op if already attached.", + inputSchema: z.object({ + issue_id: z.string().min(1), + label_id: z.string().uuid(), + }), + handler: async (input, ctx) => { + return ctx.client.post( + `/api/issues/${encodeURIComponent(input.issue_id)}/labels`, + { label_id: input.label_id }, + ); + }, +}); + +export const labelDetachTool = defineTool({ + name: "multica_label_detach", + title: "Detach a label from an issue", + description: "Remove a label from an issue. No-op if it wasn't attached.", + inputSchema: z.object({ + issue_id: z.string().min(1), + label_id: z.string().uuid(), + }), + handler: async (input, ctx) => { + return ctx.client.delete( + `/api/issues/${encodeURIComponent(input.issue_id)}/labels/${input.label_id}`, + ); + }, +}); + +export const labelTools = [labelListTool, labelAttachTool, labelDetachTool]; diff --git a/packages/mcp/src/tools/projects.ts b/packages/mcp/src/tools/projects.ts new file mode 100644 index 0000000000..237dd7231c --- /dev/null +++ b/packages/mcp/src/tools/projects.ts @@ -0,0 +1,77 @@ +// Project tools — read-mostly. Project creation IS exposed because +// chat-driven workflows often want to spin up a "project" for a new +// initiative; deletion / resource attachment isn't exposed by default +// because they have wider blast radius. + +import { z } from "zod"; +import { defineTool } from "../tool.js"; + +export const projectListTool = defineTool({ + name: "multica_project_list", + title: "List projects", + description: "Return all projects in the active workspace (id, name, status, lead).", + inputSchema: z.object({}), + handler: async (_input, ctx) => { + return ctx.client.get("/api/projects"); + }, +}); + +export const projectGetTool = defineTool({ + name: "multica_project_get", + title: "Get project", + description: "Fetch one project's metadata + attached resources.", + inputSchema: z.object({ + id: z.string().uuid(), + }), + handler: async (input, ctx) => { + return ctx.client.get(`/api/projects/${input.id}`); + }, +}); + +export const projectSearchTool = defineTool({ + name: "multica_project_search", + title: "Search projects", + description: "Fuzzy search projects by name. Useful when the user names a project loosely.", + inputSchema: z.object({ + q: z.string().min(1), + limit: z.number().int().min(1).max(50).optional(), + }), + handler: async (input, ctx) => { + return ctx.client.get("/api/projects/search", { + query: { q: input.q, limit: input.limit }, + }); + }, +}); + +export const projectCreateTool = defineTool({ + name: "multica_project_create", + title: "Create project", + description: + "Create a new project. Title is required; description, status, target_date are optional.", + inputSchema: z.object({ + title: z.string().min(1), + description: z.string().optional(), + status: z + .enum(["backlog", "planned", "in_progress", "completed", "cancelled"]) + .optional(), + target_date: z + .string() + .optional() + .describe("RFC3339 due date, e.g. '2026-12-31T00:00:00Z'."), + }), + handler: async (input, ctx) => { + return ctx.client.post("/api/projects", { + title: input.title, + description: input.description ?? null, + status: input.status ?? "backlog", + target_date: input.target_date ?? null, + }); + }, +}); + +export const projectTools = [ + projectListTool, + projectGetTool, + projectSearchTool, + projectCreateTool, +]; diff --git a/packages/mcp/src/tools/workspace.ts b/packages/mcp/src/tools/workspace.ts new file mode 100644 index 0000000000..871950b6ad --- /dev/null +++ b/packages/mcp/src/tools/workspace.ts @@ -0,0 +1,52 @@ +// Workspace + member tools. The "current workspace" is whichever the +// MCP server was started against (env / config); tools that need a +// different workspace can pass an explicit `workspace_id` override. + +import { z } from "zod"; +import { defineTool } from "../tool.js"; + +const workspaceIdInput = z.object({ + workspace_id: z + .string() + .uuid() + .optional() + .describe( + "Workspace UUID. Defaults to the workspace this MCP server was started against (MULTICA_WORKSPACE_ID).", + ), +}); + +export const workspaceGetTool = defineTool({ + name: "multica_workspace_get", + title: "Get workspace details", + description: + "Return the active workspace's id, name, slug, issue prefix, and feature flags. Use as a sanity check before workspace-scoped operations.", + inputSchema: workspaceIdInput, + handler: async (input, ctx) => { + const wsId = input.workspace_id ?? ctx.client.defaultWorkspaceId; + if (!wsId) { + throw new Error( + "No workspace id available. Pass workspace_id explicitly or set MULTICA_WORKSPACE_ID.", + ); + } + return ctx.client.get(`/api/workspaces/${wsId}`); + }, +}); + +export const workspaceMembersTool = defineTool({ + name: "multica_workspace_members", + title: "List workspace members", + description: + "Return the human members of the workspace (id, name, email, role). Use for assignee resolution when the user names someone.", + inputSchema: workspaceIdInput, + handler: async (input, ctx) => { + const wsId = input.workspace_id ?? ctx.client.defaultWorkspaceId; + if (!wsId) { + throw new Error( + "No workspace id available. Pass workspace_id explicitly or set MULTICA_WORKSPACE_ID.", + ); + } + return ctx.client.get(`/api/workspaces/${wsId}/members`); + }, +}); + +export const workspaceTools = [workspaceGetTool, workspaceMembersTool]; diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 0000000000..d445534a38 --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@multica/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "moduleResolution": "bundler", + "lib": ["ES2023"], + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/mcp/tsup.config.ts b/packages/mcp/tsup.config.ts new file mode 100644 index 0000000000..06fd5692bf --- /dev/null +++ b/packages/mcp/tsup.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from "tsup"; + +// Bundle everything into a single executable JS file. The shebang is +// added so the published binary works under `npx` / direct exec, and +// node20+ ESM is the only target — MCP clients that spawn this server +// always have Node available. +// +// `noExternal` is critical: tsup's default behavior is to externalize +// every package in `dependencies`, which produces a bundle full of +// `import "@modelcontextprotocol/sdk/..."` lines that fail at runtime +// when the binary is run outside the worktree's node_modules. We ship +// the binary as a downloadable static asset (apps/web/public) and +// users land it at ~/.local/bin/multica-mcp — there's no node_modules +// next to it, so every npm dependency MUST be inlined. +// +// We list the deps explicitly rather than `noExternal: [/.*/]` so the +// failure mode for adding a new dep is "cannot resolve at runtime" +// (loud and immediate) rather than "silently bundles whatever node +// built-ins exist" (subtle). +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + outDir: "dist", + target: "node20", + platform: "node", + bundle: true, + splitting: false, + clean: true, + sourcemap: true, + dts: false, + banner: { js: "#!/usr/bin/env node" }, + // Node built-ins must NEVER be inlined. + external: [/^node:/], + // Force-bundle every npm dep so the output JS runs standalone. + noExternal: ["@modelcontextprotocol/sdk", "zod"], +}); diff --git a/packages/views/settings/components/settings-page.tsx b/packages/views/settings/components/settings-page.tsx index 5ac94af2cc..3b8c36fe3c 100644 --- a/packages/views/settings/components/settings-page.tsx +++ b/packages/views/settings/components/settings-page.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { User, Palette, Key, Settings, Users, FolderGit2, FlaskConical, Bell } from "lucide-react"; +import { User, Palette, Plug, Settings, Users, FolderGit2, FlaskConical, Bell } from "lucide-react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs"; import { useCurrentWorkspace } from "@multica/core/paths"; import { AccountTab } from "./account-tab"; @@ -17,7 +17,7 @@ const accountTabs = [ { value: "profile", label: "Profile", icon: User }, { value: "appearance", label: "Appearance", icon: Palette }, { value: "notifications", label: "Notifications", icon: Bell }, - { value: "tokens", label: "API Tokens", icon: Key }, + { value: "api", label: "API & MCP", icon: Plug }, ]; const workspaceTabs = [ @@ -84,7 +84,7 @@ export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) { - + diff --git a/packages/views/settings/components/tokens-tab.tsx b/packages/views/settings/components/tokens-tab.tsx index adc77ad8dc..ac7cfccbd6 100644 --- a/packages/views/settings/components/tokens-tab.tsx +++ b/packages/views/settings/components/tokens-tab.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; -import { Key, Trash2, Copy, Check } from "lucide-react"; +import { useEffect, useMemo, useState, useCallback } from "react"; +import { Key, Trash2, Copy, Check, Plug, Sparkles, ExternalLink, Code2 } from "lucide-react"; import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; import type { PersonalAccessToken } from "@multica/core/types"; import { Input } from "@multica/ui/components/ui/input"; @@ -35,7 +35,21 @@ import { import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { toast } from "sonner"; import { api } from "@multica/core/api"; +import { useCurrentWorkspace } from "@multica/core/paths"; +/** + * "API & MCP" settings tab. Exposes three things: + * + * 1. Personal access tokens — same list/create/revoke flow that lived in + * the old "API Tokens" tab. + * 2. Connection details — the API base URL and current workspace ID, + * with one-click copy. These are the values users paste into env vars + * (CLI, MCP, custom integrations). + * 3. MCP server setup — what the @multica/mcp server exposes plus + * copy-paste config snippets for Claude Code and Claude Desktop, with + * the workspace's actual API URL + workspace ID prefilled so users + * don't have to hand-edit placeholders. + */ export function TokensTab() { const [tokens, setTokens] = useState([]); const [tokenName, setTokenName] = useState(""); @@ -47,6 +61,29 @@ export function TokensTab() { const [revokeConfirmId, setRevokeConfirmId] = useState(null); const [tokensLoading, setTokensLoading] = useState(true); + const workspace = useCurrentWorkspace(); + const apiBaseUrl = useMemo(() => { + // The web app builds with an empty `apiBaseUrl` because Next.js + // rewrites proxy /api/* to the backend on the same origin — no + // absolute URL is needed at runtime. For the settings panel we DO + // want to show users something real to paste into env vars, so we + // fall back to the page's own origin: requests to that host will + // hit the same Next.js rewrite path the in-app calls use, which + // means `MULTICA_API_URL=` works for the MCP + // server, the CLI, and any other client. + let fromClient = ""; + try { + fromClient = api.getBaseUrl() ?? ""; + } catch { + fromClient = ""; + } + if (fromClient) return fromClient; + if (typeof window !== "undefined" && window.location?.origin) { + return window.location.origin; + } + return ""; + }, []); + const loadTokens = useCallback(async () => { try { const list = await api.listPersonalAccessTokens(); @@ -97,24 +134,32 @@ export function TokensTab() { }; return ( -
+
+ {/* ─── Section 1: Tokens ─────────────────────────────────────── */}
-

API Tokens

+

Personal access tokens

- Personal access tokens allow the CLI and external integrations to authenticate with your account. + One token works for everything: the{" "} + REST API, the{" "} + MCP server, and + the Multica CLI{" "} + all use the same personal access token. Pair the token with + the connection details below. Treat them like passwords — + they're shown once on creation and can't be retrieved + later.

setTokenName(e.target.value)} - placeholder="Token name (e.g. My CLI)" + placeholder="Token name (e.g. MCP server)" />