feat: kan mcp server (#485)
* feat: kan mcp server initial attempt * fix: card was missing options and added default fallbacks * fix: label creating with better information for colors and presets
This commit is contained in:
29
packages/mcp/package.json
Normal file
29
packages/mcp/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@kan/mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP server for Kan — control workspaces, boards, lists, and cards via AI",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"kan-mcp": "./dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit false --declaration false --emitDeclarationOnly false --outDir dist",
|
||||
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
64
packages/mcp/src/client.ts
Normal file
64
packages/mcp/src/client.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export interface KanConfig {
|
||||
baseUrl: string;
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
function getConfig(): KanConfig {
|
||||
const baseUrl = process.env["KAN_BASE_URL"];
|
||||
const apiToken = process.env["KAN_API_TOKEN"];
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error("KAN_BASE_URL environment variable is required");
|
||||
}
|
||||
if (!apiToken) {
|
||||
throw new Error("KAN_API_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl.replace(/\/$/, ""),
|
||||
apiToken,
|
||||
};
|
||||
}
|
||||
|
||||
export class KanApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly statusText: string,
|
||||
public readonly body: unknown,
|
||||
) {
|
||||
super(`Kan API error ${status} ${statusText}: ${JSON.stringify(body)}`);
|
||||
this.name = "KanApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function kanRequest<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const config = getConfig();
|
||||
const url = `${config.baseUrl}/api/v1${path}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiToken}`,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
let data: unknown;
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
data = await res.json();
|
||||
} else {
|
||||
data = await res.text();
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new KanApiError(res.status, res.statusText, data);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
27
packages/mcp/src/index.ts
Normal file
27
packages/mcp/src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
|
||||
import { registerWorkspaceTools } from "./tools/workspace.js";
|
||||
import { registerBoardTools } from "./tools/board.js";
|
||||
import { registerListTools } from "./tools/list.js";
|
||||
import { registerCardTools } from "./tools/card.js";
|
||||
import { registerChecklistTools } from "./tools/checklist.js";
|
||||
import { registerLabelTools } from "./tools/label.js";
|
||||
import { registerMemberTools } from "./tools/member.js";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "kan",
|
||||
version: "0.1.0",
|
||||
});
|
||||
|
||||
registerWorkspaceTools(server);
|
||||
registerBoardTools(server);
|
||||
registerListTools(server);
|
||||
registerCardTools(server);
|
||||
registerChecklistTools(server);
|
||||
registerLabelTools(server);
|
||||
registerMemberTools(server);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
144
packages/mcp/src/tools/board.ts
Normal file
144
packages/mcp/src/tools/board.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { kanRequest } from "../client.js";
|
||||
|
||||
export function registerBoardTools(server: McpServer): void {
|
||||
server.tool(
|
||||
"list_boards",
|
||||
"List all boards in a workspace. Requires the workspace publicId — use find_workspace_by_name first if you only know the workspace name.",
|
||||
{ workspacePublicId: z.string().min(12).describe("The workspace's 12-character public ID (not the name). Get it from list_workspaces or find_workspace_by_name first.") },
|
||||
async ({ workspacePublicId }) => {
|
||||
const data = await kanRequest("GET", `/workspaces/${workspacePublicId}/boards`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"find_board_by_name",
|
||||
"Find a board by workspace name and board name (both case-insensitive). Resolves workspace name → publicId, then board name → publicId automatically. Use this when you only know names.",
|
||||
{
|
||||
workspaceName: z.string().describe("The workspace name (e.g. 'UC Roleplay')"),
|
||||
boardName: z.string().describe("The board name (e.g. 'Mechanics Rework')"),
|
||||
},
|
||||
async ({ workspaceName, boardName }) => {
|
||||
const workspaces = await kanRequest<{ publicId: string; name: string }[]>("GET", "/workspaces");
|
||||
const workspace = workspaces.find(
|
||||
(w) => w.name.toLowerCase() === workspaceName.toLowerCase(),
|
||||
);
|
||||
if (!workspace) {
|
||||
const names = workspaces.map((w) => w.name).join(", ");
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `No workspace found with name "${workspaceName}". Available: ${names}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const boards = await kanRequest<{ publicId: string; name: string }[]>(
|
||||
"GET",
|
||||
`/workspaces/${workspace.publicId}/boards`,
|
||||
);
|
||||
const board = boards.find(
|
||||
(b) => b.name.toLowerCase() === boardName.toLowerCase(),
|
||||
);
|
||||
if (!board) {
|
||||
const names = boards.map((b) => b.name).join(", ");
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `No board found with name "${boardName}" in workspace "${workspaceName}". Available boards: ${names}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(board, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_board",
|
||||
"Get a board by its public ID, including its lists and cards",
|
||||
{
|
||||
boardPublicId: z.string().describe("The board's public ID"),
|
||||
labelPublicId: z.string().optional().describe("Filter cards by label public ID"),
|
||||
memberPublicId: z.string().optional().describe("Filter cards by member public ID"),
|
||||
},
|
||||
async ({ boardPublicId, labelPublicId, memberPublicId }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (labelPublicId) params.set("labelPublicId", labelPublicId);
|
||||
if (memberPublicId) params.set("memberPublicId", memberPublicId);
|
||||
const qs = params.toString() ? `?${params}` : "";
|
||||
const data = await kanRequest("GET", `/boards/${boardPublicId}${qs}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_board_by_slug",
|
||||
"Get a board by workspace slug and board slug",
|
||||
{
|
||||
workspaceSlug: z.string().describe("The workspace slug"),
|
||||
boardSlug: z.string().describe("The board slug"),
|
||||
},
|
||||
async ({ workspaceSlug, boardSlug }) => {
|
||||
const data = await kanRequest("GET", `/workspaces/${workspaceSlug}/boards/${boardSlug}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"create_board",
|
||||
"Create a new board in a workspace",
|
||||
{
|
||||
workspacePublicId: z.string().describe("The workspace's public ID"),
|
||||
name: z.string().describe("Board name"),
|
||||
slug: z.string().optional().describe("URL-friendly slug (auto-generated if omitted)"),
|
||||
visibility: z
|
||||
.enum(["public", "private"])
|
||||
.optional()
|
||||
.describe("Board visibility (default: private)"),
|
||||
},
|
||||
async ({ workspacePublicId, name, slug, visibility }) => {
|
||||
const data = await kanRequest("POST", `/workspaces/${workspacePublicId}/boards`, {
|
||||
name,
|
||||
slug,
|
||||
visibility,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"update_board",
|
||||
"Update a board's name, slug, visibility, or favorite status",
|
||||
{
|
||||
boardPublicId: z.string().describe("The board's public ID"),
|
||||
name: z.string().optional().describe("New board name"),
|
||||
slug: z.string().optional().describe("New board slug"),
|
||||
visibility: z.enum(["public", "private"]).optional().describe("New visibility"),
|
||||
isFavorite: z.boolean().optional().describe("Whether the board is favorited"),
|
||||
},
|
||||
async ({ boardPublicId, name, slug, visibility, isFavorite }) => {
|
||||
const data = await kanRequest("PUT", `/boards/${boardPublicId}`, {
|
||||
name,
|
||||
slug,
|
||||
visibility,
|
||||
isFavorite,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"delete_board",
|
||||
"Delete a board (soft delete)",
|
||||
{ boardPublicId: z.string().describe("The board's public ID") },
|
||||
async ({ boardPublicId }) => {
|
||||
const data = await kanRequest("DELETE", `/boards/${boardPublicId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
192
packages/mcp/src/tools/card.ts
Normal file
192
packages/mcp/src/tools/card.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { kanRequest } from "../client.js";
|
||||
|
||||
export function registerCardTools(server: McpServer): void {
|
||||
server.tool(
|
||||
"create_card",
|
||||
"Create a new card in a list",
|
||||
{
|
||||
listPublicId: z.string().describe("The list's public ID"),
|
||||
title: z.string().describe("Card title"),
|
||||
description: z.string().optional().describe("Card description (markdown supported)"),
|
||||
dueDate: z.string().optional().describe("Due date in ISO 8601 format"),
|
||||
labelPublicIds: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Public IDs of labels to attach"),
|
||||
memberPublicIds: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Public IDs of workspace members to assign"),
|
||||
position: z
|
||||
.enum(["start", "end"])
|
||||
.optional()
|
||||
.describe("Where to insert the card in the list (default: end)"),
|
||||
},
|
||||
async ({ listPublicId, title, description, dueDate, labelPublicIds, memberPublicIds, position }) => {
|
||||
const data = await kanRequest("POST", "/cards", {
|
||||
listPublicId,
|
||||
title,
|
||||
description,
|
||||
dueDate,
|
||||
labelPublicIds: labelPublicIds ?? [],
|
||||
memberPublicIds: memberPublicIds ?? [],
|
||||
position: position ?? "end",
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_card",
|
||||
"Get full details of a card including comments, checklists, labels and members",
|
||||
{ cardPublicId: z.string().describe("The card's public ID") },
|
||||
async ({ cardPublicId }) => {
|
||||
const data = await kanRequest("GET", `/cards/${cardPublicId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"update_card",
|
||||
"Update a card's title, description, due date, or move it to another list",
|
||||
{
|
||||
cardPublicId: z.string().describe("The card's public ID"),
|
||||
title: z.string().optional().describe("New card title"),
|
||||
description: z.string().optional().describe("New description"),
|
||||
dueDate: z.string().nullable().optional().describe("Due date in ISO 8601, or null to clear"),
|
||||
listPublicId: z.string().optional().describe("Move card to this list (public ID)"),
|
||||
},
|
||||
async ({ cardPublicId, title, description, dueDate, listPublicId }) => {
|
||||
const data = await kanRequest("PUT", `/cards/${cardPublicId}`, {
|
||||
title,
|
||||
description,
|
||||
dueDate,
|
||||
listPublicId,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"delete_card",
|
||||
"Delete a card (soft delete)",
|
||||
{ cardPublicId: z.string().describe("The card's public ID") },
|
||||
async ({ cardPublicId }) => {
|
||||
const data = await kanRequest("DELETE", `/cards/${cardPublicId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"duplicate_card",
|
||||
"Duplicate a card to the same or a different list",
|
||||
{
|
||||
cardPublicId: z.string().describe("The card's public ID to duplicate"),
|
||||
targetListPublicId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Target list public ID (defaults to same list)"),
|
||||
},
|
||||
async ({ cardPublicId, targetListPublicId }) => {
|
||||
const data = await kanRequest("POST", `/cards/${cardPublicId}/duplicate`, {
|
||||
targetListPublicId,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_card_activities",
|
||||
"Get the activity history of a card",
|
||||
{
|
||||
cardPublicId: z.string().describe("The card's public ID"),
|
||||
cursor: z.string().optional().describe("Pagination cursor from a previous response"),
|
||||
},
|
||||
async ({ cardPublicId, cursor }) => {
|
||||
const params = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
|
||||
const data = await kanRequest("GET", `/cards/${cardPublicId}/activities${params}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"add_card_comment",
|
||||
"Add a comment to a card",
|
||||
{
|
||||
cardPublicId: z.string().describe("The card's public ID"),
|
||||
content: z.string().describe("Comment text"),
|
||||
},
|
||||
async ({ cardPublicId, content }) => {
|
||||
const data = await kanRequest("POST", `/cards/${cardPublicId}/comments`, { content });
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"update_card_comment",
|
||||
"Update the text of an existing comment",
|
||||
{
|
||||
cardPublicId: z.string().describe("The card's public ID"),
|
||||
commentPublicId: z.string().describe("The comment's public ID"),
|
||||
content: z.string().describe("New comment text"),
|
||||
},
|
||||
async ({ cardPublicId, commentPublicId, content }) => {
|
||||
const data = await kanRequest(
|
||||
"PUT",
|
||||
`/cards/${cardPublicId}/comments/${commentPublicId}`,
|
||||
{ content },
|
||||
);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"delete_card_comment",
|
||||
"Delete a comment from a card",
|
||||
{
|
||||
cardPublicId: z.string().describe("The card's public ID"),
|
||||
commentPublicId: z.string().describe("The comment's public ID"),
|
||||
},
|
||||
async ({ cardPublicId, commentPublicId }) => {
|
||||
const data = await kanRequest(
|
||||
"DELETE",
|
||||
`/cards/${cardPublicId}/comments/${commentPublicId}`,
|
||||
);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"toggle_card_label",
|
||||
"Add or remove a label on a card (toggles if already present)",
|
||||
{
|
||||
cardPublicId: z.string().describe("The card's public ID"),
|
||||
labelPublicId: z.string().describe("The label's public ID"),
|
||||
},
|
||||
async ({ cardPublicId, labelPublicId }) => {
|
||||
const data = await kanRequest(
|
||||
"PUT",
|
||||
`/cards/${cardPublicId}/labels/${labelPublicId}`,
|
||||
);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"toggle_card_member",
|
||||
"Add or remove a member assignment on a card (toggles if already assigned)",
|
||||
{
|
||||
cardPublicId: z.string().describe("The card's public ID"),
|
||||
workspaceMemberPublicId: z.string().describe("The workspace member's public ID"),
|
||||
},
|
||||
async ({ cardPublicId, workspaceMemberPublicId }) => {
|
||||
const data = await kanRequest(
|
||||
"PUT",
|
||||
`/cards/${cardPublicId}/members/${workspaceMemberPublicId}`,
|
||||
);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
83
packages/mcp/src/tools/checklist.ts
Normal file
83
packages/mcp/src/tools/checklist.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { kanRequest } from "../client.js";
|
||||
|
||||
export function registerChecklistTools(server: McpServer): void {
|
||||
server.tool(
|
||||
"create_checklist",
|
||||
"Add a checklist to a card",
|
||||
{
|
||||
cardPublicId: z.string().describe("The card's public ID"),
|
||||
name: z.string().describe("Checklist name"),
|
||||
},
|
||||
async ({ cardPublicId, name }) => {
|
||||
const data = await kanRequest("POST", `/cards/${cardPublicId}/checklists`, { name });
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"update_checklist",
|
||||
"Rename a checklist",
|
||||
{
|
||||
checklistPublicId: z.string().describe("The checklist's public ID"),
|
||||
name: z.string().describe("New checklist name"),
|
||||
},
|
||||
async ({ checklistPublicId, name }) => {
|
||||
const data = await kanRequest("PUT", `/checklists/${checklistPublicId}`, { name });
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"delete_checklist",
|
||||
"Delete a checklist and all its items",
|
||||
{ checklistPublicId: z.string().describe("The checklist's public ID") },
|
||||
async ({ checklistPublicId }) => {
|
||||
const data = await kanRequest("DELETE", `/checklists/${checklistPublicId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"create_checklist_item",
|
||||
"Add an item to a checklist",
|
||||
{
|
||||
checklistPublicId: z.string().describe("The checklist's public ID"),
|
||||
title: z.string().describe("Item title"),
|
||||
},
|
||||
async ({ checklistPublicId, title }) => {
|
||||
const data = await kanRequest("POST", `/checklists/${checklistPublicId}/items`, { title });
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"update_checklist_item",
|
||||
"Update a checklist item's title, completion status, or position",
|
||||
{
|
||||
checklistItemPublicId: z.string().describe("The checklist item's public ID"),
|
||||
title: z.string().optional().describe("New item title"),
|
||||
isCompleted: z.boolean().optional().describe("Mark item as completed or not"),
|
||||
index: z.number().int().optional().describe("New position index"),
|
||||
},
|
||||
async ({ checklistItemPublicId, title, isCompleted, index }) => {
|
||||
const data = await kanRequest("PATCH", `/checklists/items/${checklistItemPublicId}`, {
|
||||
title,
|
||||
isCompleted,
|
||||
index,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"delete_checklist_item",
|
||||
"Delete a checklist item",
|
||||
{ checklistItemPublicId: z.string().describe("The checklist item's public ID") },
|
||||
async ({ checklistItemPublicId }) => {
|
||||
const data = await kanRequest("DELETE", `/checklists/items/${checklistItemPublicId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
101
packages/mcp/src/tools/label.ts
Normal file
101
packages/mcp/src/tools/label.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { kanRequest } from "../client.js";
|
||||
|
||||
// Preset colour palette supported by the Kan UI.
|
||||
// Mirrors packages/shared/src/constants/colours.ts. Keep in sync.
|
||||
const COLOUR_PRESETS = {
|
||||
Teal: "#0d9488",
|
||||
Green: "#65a30d",
|
||||
Blue: "#0284c7",
|
||||
Purple: "#4f46e5",
|
||||
Yellow: "#ca8a04",
|
||||
Orange: "#ea580c",
|
||||
Red: "#dc2626",
|
||||
Pink: "#db2777",
|
||||
} as const;
|
||||
|
||||
const colourNames = Object.keys(COLOUR_PRESETS) as [
|
||||
keyof typeof COLOUR_PRESETS,
|
||||
...(keyof typeof COLOUR_PRESETS)[],
|
||||
];
|
||||
const colourNameSchema = z.enum(colourNames);
|
||||
const presetDescription = `One of the preset colour names: ${colourNames.join(", ")}`;
|
||||
|
||||
function resolveColourCode(
|
||||
colour: keyof typeof COLOUR_PRESETS | undefined,
|
||||
colourCode: string | undefined,
|
||||
): string | undefined {
|
||||
if (colourCode) return colourCode;
|
||||
if (colour) return COLOUR_PRESETS[colour];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function registerLabelTools(server: McpServer): void {
|
||||
server.tool(
|
||||
"get_label",
|
||||
"Get a label by its public ID",
|
||||
{ labelPublicId: z.string().describe("The label's public ID") },
|
||||
async ({ labelPublicId }) => {
|
||||
const data = await kanRequest("GET", `/labels/${labelPublicId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"create_label",
|
||||
`Create a label for a board. Pick a colour by preset name (${colourNames.join(", ")}) or pass an explicit 7-char hex via colourCode. Defaults to Teal.`,
|
||||
{
|
||||
boardPublicId: z.string().describe("The board's public ID"),
|
||||
name: z.string().describe("Label name"),
|
||||
colour: colourNameSchema.optional().describe(presetDescription),
|
||||
colourCode: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.optional()
|
||||
.describe("Explicit 7-char hex colour (e.g. #0d9488). Overrides `colour` if both are set."),
|
||||
},
|
||||
async ({ boardPublicId, name, colour, colourCode }) => {
|
||||
const resolved = resolveColourCode(colour, colourCode) ?? COLOUR_PRESETS.Teal;
|
||||
const data = await kanRequest("POST", "/labels", {
|
||||
boardPublicId,
|
||||
name,
|
||||
colourCode: resolved,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"update_label",
|
||||
"Update a label's name or colour. Pick a colour by preset name or pass an explicit 7-char hex.",
|
||||
{
|
||||
labelPublicId: z.string().describe("The label's public ID"),
|
||||
name: z.string().optional().describe("New label name"),
|
||||
colour: colourNameSchema.optional().describe(presetDescription),
|
||||
colourCode: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.optional()
|
||||
.describe("Explicit 7-char hex colour. Overrides `colour` if both are set."),
|
||||
},
|
||||
async ({ labelPublicId, name, colour, colourCode }) => {
|
||||
const resolved = resolveColourCode(colour, colourCode);
|
||||
const body: Record<string, unknown> = {};
|
||||
if (name !== undefined) body.name = name;
|
||||
if (resolved !== undefined) body.colourCode = resolved;
|
||||
const data = await kanRequest("PUT", `/labels/${labelPublicId}`, body);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"delete_label",
|
||||
"Delete a label",
|
||||
{ labelPublicId: z.string().describe("The label's public ID") },
|
||||
async ({ labelPublicId }) => {
|
||||
const data = await kanRequest("DELETE", `/labels/${labelPublicId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
42
packages/mcp/src/tools/list.ts
Normal file
42
packages/mcp/src/tools/list.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { kanRequest } from "../client.js";
|
||||
|
||||
export function registerListTools(server: McpServer): void {
|
||||
server.tool(
|
||||
"create_list",
|
||||
"Create a new list inside a board",
|
||||
{
|
||||
boardPublicId: z.string().describe("The board's public ID"),
|
||||
name: z.string().describe("List name"),
|
||||
},
|
||||
async ({ boardPublicId, name }) => {
|
||||
const data = await kanRequest("POST", "/lists", { boardPublicId, name });
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"update_list",
|
||||
"Update a list's name or position",
|
||||
{
|
||||
listPublicId: z.string().describe("The list's public ID"),
|
||||
name: z.string().optional().describe("New list name"),
|
||||
index: z.number().int().optional().describe("New position index"),
|
||||
},
|
||||
async ({ listPublicId, name, index }) => {
|
||||
const data = await kanRequest("PUT", `/lists/${listPublicId}`, { name, index });
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"delete_list",
|
||||
"Delete a list and all its cards",
|
||||
{ listPublicId: z.string().describe("The list's public ID") },
|
||||
async ({ listPublicId }) => {
|
||||
const data = await kanRequest("DELETE", `/lists/${listPublicId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
90
packages/mcp/src/tools/member.ts
Normal file
90
packages/mcp/src/tools/member.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { kanRequest } from "../client.js";
|
||||
|
||||
export function registerMemberTools(server: McpServer): void {
|
||||
server.tool(
|
||||
"invite_member",
|
||||
"Invite a user to a workspace by email",
|
||||
{
|
||||
workspacePublicId: z.string().describe("The workspace's public ID"),
|
||||
email: z.string().email().describe("Email address to invite"),
|
||||
role: z
|
||||
.enum(["admin", "member", "guest"])
|
||||
.optional()
|
||||
.describe("Role to assign (default: member)"),
|
||||
},
|
||||
async ({ workspacePublicId, email, role }) => {
|
||||
const data = await kanRequest(
|
||||
"POST",
|
||||
`/workspaces/${workspacePublicId}/members/invite`,
|
||||
{ email, role },
|
||||
);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"remove_member",
|
||||
"Remove a member from a workspace",
|
||||
{
|
||||
workspacePublicId: z.string().describe("The workspace's public ID"),
|
||||
memberPublicId: z.string().describe("The workspace member's public ID"),
|
||||
},
|
||||
async ({ workspacePublicId, memberPublicId }) => {
|
||||
const data = await kanRequest(
|
||||
"DELETE",
|
||||
`/workspaces/${workspacePublicId}/members/${memberPublicId}`,
|
||||
);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"update_member_role",
|
||||
"Change the role of a workspace member",
|
||||
{
|
||||
workspacePublicId: z.string().describe("The workspace's public ID"),
|
||||
memberPublicId: z.string().describe("The workspace member's public ID"),
|
||||
role: z.enum(["admin", "member", "guest"]).describe("New role"),
|
||||
},
|
||||
async ({ workspacePublicId, memberPublicId, role }) => {
|
||||
const data = await kanRequest(
|
||||
"PUT",
|
||||
`/workspaces/${workspacePublicId}/members/${memberPublicId}/role`,
|
||||
{ role },
|
||||
);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_workspace_invite_link",
|
||||
"Get the active invite link for a workspace",
|
||||
{ workspacePublicId: z.string().describe("The workspace's public ID") },
|
||||
async ({ workspacePublicId }) => {
|
||||
const data = await kanRequest("GET", `/workspaces/${workspacePublicId}/invite`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"create_workspace_invite_link",
|
||||
"Create a new invite link for a workspace (7-day expiry)",
|
||||
{ workspacePublicId: z.string().describe("The workspace's public ID") },
|
||||
async ({ workspacePublicId }) => {
|
||||
const data = await kanRequest("POST", `/workspaces/${workspacePublicId}/invites`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"deactivate_workspace_invite_links",
|
||||
"Deactivate all active invite links for a workspace",
|
||||
{ workspacePublicId: z.string().describe("The workspace's public ID") },
|
||||
async ({ workspacePublicId }) => {
|
||||
const data = await kanRequest("DELETE", `/workspaces/${workspacePublicId}/invites`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
121
packages/mcp/src/tools/workspace.ts
Normal file
121
packages/mcp/src/tools/workspace.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { kanRequest } from "../client.js";
|
||||
|
||||
export function registerWorkspaceTools(server: McpServer): void {
|
||||
server.tool(
|
||||
"list_workspaces",
|
||||
"List all workspaces the authenticated user belongs to. Call this first to resolve a workspace name to its publicId before calling any other workspace-scoped tool.",
|
||||
{},
|
||||
async () => {
|
||||
const data = await kanRequest("GET", "/workspaces");
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"find_workspace_by_name",
|
||||
"Find a workspace by its name (case-insensitive). Returns the matching workspace including its publicId. Use this whenever you only know the workspace name and need its publicId.",
|
||||
{ name: z.string().describe("Workspace name to search for") },
|
||||
async ({ name }) => {
|
||||
const workspaces = await kanRequest<{ publicId: string; name: string }[]>("GET", "/workspaces");
|
||||
const match = workspaces.find(
|
||||
(w) => w.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
if (!match) {
|
||||
const names = workspaces.map((w) => w.name).join(", ");
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `No workspace found with name "${name}". Available workspaces: ${names}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify(match, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_workspace",
|
||||
"Get a workspace by its public ID, including its members",
|
||||
{ workspacePublicId: z.string().describe("The workspace's public ID") },
|
||||
async ({ workspacePublicId }) => {
|
||||
const data = await kanRequest("GET", `/workspaces/${workspacePublicId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_workspace_by_slug",
|
||||
"Get a workspace by its slug, including its boards",
|
||||
{ workspaceSlug: z.string().describe("The workspace slug") },
|
||||
async ({ workspaceSlug }) => {
|
||||
const data = await kanRequest("GET", `/workspaces/${workspaceSlug}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"create_workspace",
|
||||
"Create a new workspace",
|
||||
{
|
||||
name: z.string().describe("Workspace name"),
|
||||
slug: z.string().optional().describe("URL-friendly slug (auto-generated if omitted)"),
|
||||
},
|
||||
async ({ name, slug }) => {
|
||||
const data = await kanRequest("POST", "/workspaces", { name, slug });
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"update_workspace",
|
||||
"Update a workspace's name or slug",
|
||||
{
|
||||
workspacePublicId: z.string().describe("The workspace's public ID"),
|
||||
name: z.string().optional().describe("New workspace name"),
|
||||
slug: z.string().optional().describe("New workspace slug"),
|
||||
},
|
||||
async ({ workspacePublicId, name, slug }) => {
|
||||
const data = await kanRequest("PUT", `/workspaces/${workspacePublicId}`, { name, slug });
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"delete_workspace",
|
||||
"Permanently delete a workspace",
|
||||
{ workspacePublicId: z.string().describe("The workspace's public ID") },
|
||||
async ({ workspacePublicId }) => {
|
||||
const data = await kanRequest("DELETE", `/workspaces/${workspacePublicId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"search_workspace",
|
||||
"Search for boards and cards by title within a workspace",
|
||||
{
|
||||
workspacePublicId: z.string().describe("The workspace's public ID"),
|
||||
query: z.string().describe("Search query string"),
|
||||
},
|
||||
async ({ workspacePublicId, query }) => {
|
||||
const params = new URLSearchParams({ query });
|
||||
const data = await kanRequest("GET", `/workspaces/${workspacePublicId}/search?${params}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"check_workspace_slug_availability",
|
||||
"Check whether a workspace slug is available",
|
||||
{ slug: z.string().describe("Slug to check") },
|
||||
async ({ slug }) => {
|
||||
const params = new URLSearchParams({ slug });
|
||||
const data = await kanRequest("GET", `/workspaces/check-slug-availability?${params}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
14
packages/mcp/tsconfig.json
Normal file
14
packages/mcp/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@kan/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"noEmit": false,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"declaration": false,
|
||||
"tsBuildInfoFile": ".cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user