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:
Morfixx
2026-06-10 11:10:22 +03:00
committed by GitHub
parent 4f3b49716d
commit 81b67df9e3
14 changed files with 1517 additions and 55 deletions

29
packages/mcp/package.json Normal file
View 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:"
}
}

View 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
View 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);

View 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) }] };
},
);
}

View 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) }] };
},
);
}

View 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) }] };
},
);
}

View 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) }] };
},
);
}

View 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) }] };
},
);
}

View 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) }] };
},
);
}

View 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) }] };
},
);
}

View 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"]
}