Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Dockerfile.web
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
159 changes: 159 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
@@ -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`.
42 changes: 42 additions & 0 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
125 changes: 125 additions & 0 deletions packages/mcp/src/client.ts
Original file line number Diff line number Diff line change
@@ -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 <token>`.
// `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<string, string | number | boolean | undefined>;
body?: unknown;
headers?: Record<string, string>;
/** 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<T>(path: string, opts: RequestOptions = {}): Promise<T> {
return this.request<T>("GET", path, opts);
}
async post<T>(path: string, body: unknown, opts: RequestOptions = {}): Promise<T> {
return this.request<T>("POST", path, { ...opts, body });
}
async patch<T>(path: string, body: unknown, opts: RequestOptions = {}): Promise<T> {
return this.request<T>("PATCH", path, { ...opts, body });
}
async delete<T>(path: string, opts: RequestOptions = {}): Promise<T> {
return this.request<T>("DELETE", path, opts);
}

private async request<T>(
method: string,
path: string,
opts: RequestOptions,
): Promise<T> {
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<string, string> = {
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;
}
}
Loading
Loading