300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
/**
|
|
* mcp/deck.ts — Nextcloud Deck board/card management tools
|
|
*/
|
|
|
|
const DECK_BASE = (process.env.DECK_BASE_URL ?? "https://next.jqxp.org") + "/index.php/apps/deck/api/v1";
|
|
const OCS_BASE = (process.env.DECK_BASE_URL ?? "https://next.jqxp.org") + "/ocs/v2.php/apps/deck/api/v1.0";
|
|
const AUTH = "Basic " + btoa(`${process.env.DECK_USER ?? "claude"}:${process.env.DECK_TOKEN ?? "AH25o-cBZxD-8tMZ6-yWDKt-q9874"}`);
|
|
|
|
const HEADERS: Record<string, string> = {
|
|
Authorization: AUTH,
|
|
"OCS-APIRequest": "true",
|
|
"Content-Type": "application/json",
|
|
};
|
|
|
|
async function deckFetch(path: string, method = "GET", body?: any) {
|
|
const res = await fetch(`${DECK_BASE}${path}`, {
|
|
method,
|
|
headers: HEADERS,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Deck ${method} ${path}: ${res.status} ${text.slice(0, 200)}`);
|
|
}
|
|
const text = await res.text();
|
|
if (!text) return {};
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
async function ocsFetch(path: string, method = "GET", body?: any) {
|
|
const res = await fetch(`${OCS_BASE}${path}`, {
|
|
method,
|
|
headers: { ...HEADERS, Accept: "application/json" },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`OCS ${method} ${path}: ${res.status} ${text.slice(0, 200)}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
function sanitize(text: string): string {
|
|
return text
|
|
.replace(/\u2014/g, "--")
|
|
.replace(/\u2013/g, "-")
|
|
.replace(/[\u201C\u201D]/g, '"')
|
|
.replace(/[\u2018\u2019]/g, "'");
|
|
}
|
|
|
|
export const tools = [
|
|
{
|
|
name: "deck_get_stacks",
|
|
description: "Get all stacks and cards from a Deck board.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
boardId: { type: "number", description: "Board ID" },
|
|
},
|
|
required: ["boardId"],
|
|
},
|
|
},
|
|
{
|
|
name: "deck_get_cards",
|
|
description: "Get cards from a board, optionally filtered by label name.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
boardId: { type: "number", description: "Board ID" },
|
|
label: { type: "string", description: "Filter by label name (e.g. 'smoke')" },
|
|
},
|
|
required: ["boardId"],
|
|
},
|
|
},
|
|
{
|
|
name: "deck_create_board",
|
|
description: "Create a new Deck board. Auto-shared with nico.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
title: { type: "string", description: "Board title" },
|
|
color: { type: "string", description: "Hex color without # (default: 28a745)" },
|
|
},
|
|
required: ["title"],
|
|
},
|
|
},
|
|
{
|
|
name: "deck_create_stack",
|
|
description: "Create a stack (column) on a board.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
boardId: { type: "number" },
|
|
title: { type: "string" },
|
|
order: { type: "number" },
|
|
},
|
|
required: ["boardId", "title", "order"],
|
|
},
|
|
},
|
|
{
|
|
name: "deck_create_card",
|
|
description: "Create a card in a stack.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
boardId: { type: "number" },
|
|
stackId: { type: "number" },
|
|
title: { type: "string" },
|
|
description: { type: "string" },
|
|
order: { type: "number" },
|
|
},
|
|
required: ["boardId", "stackId", "title"],
|
|
},
|
|
},
|
|
{
|
|
name: "deck_move_card",
|
|
description: "Move a card to a different stack.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
boardId: { type: "number" },
|
|
cardId: { type: "number" },
|
|
targetStackId: { type: "number" },
|
|
order: { type: "number" },
|
|
},
|
|
required: ["boardId", "cardId", "targetStackId"],
|
|
},
|
|
},
|
|
{
|
|
name: "deck_add_comment",
|
|
description: "Add a comment to a card. Sanitizes special characters.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
cardId: { type: "number" },
|
|
text: { type: "string" },
|
|
},
|
|
required: ["cardId", "text"],
|
|
},
|
|
},
|
|
{
|
|
name: "deck_delete_board",
|
|
description: "Delete a Deck board permanently.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
boardId: { type: "number", description: "Board ID to delete" },
|
|
},
|
|
required: ["boardId"],
|
|
},
|
|
},
|
|
{
|
|
name: "deck_delete_card",
|
|
description: "Delete a card from a board.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
boardId: { type: "number" },
|
|
stackId: { type: "number" },
|
|
cardId: { type: "number" },
|
|
},
|
|
required: ["boardId", "stackId", "cardId"],
|
|
},
|
|
},
|
|
{
|
|
name: "deck_create_label",
|
|
description: "Create a label on a board. Returns the label with its ID.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
boardId: { type: "number" },
|
|
title: { type: "string", description: "Label name (e.g. 'smoke')" },
|
|
color: { type: "string", description: "Hex color without # (e.g. 'e67e22')" },
|
|
},
|
|
required: ["boardId", "title", "color"],
|
|
},
|
|
},
|
|
{
|
|
name: "deck_assign_label",
|
|
description: "Assign a label to a card.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
boardId: { type: "number" },
|
|
stackId: { type: "number" },
|
|
cardId: { type: "number" },
|
|
labelId: { type: "number" },
|
|
},
|
|
required: ["boardId", "stackId", "cardId", "labelId"],
|
|
},
|
|
},
|
|
];
|
|
|
|
export async function handle(name: string, args: any): Promise<any> {
|
|
switch (name) {
|
|
case "deck_get_stacks": {
|
|
const stacks = await deckFetch(`/boards/${args.boardId}/stacks`);
|
|
const slim = stacks.map((s: any) => ({
|
|
id: s.id, title: s.title, order: s.order,
|
|
cards: (s.cards || []).map((c: any) => ({
|
|
id: c.id, title: c.title, stackId: c.stackId, order: c.order,
|
|
labels: (c.labels || []).map((l: any) => l.title),
|
|
description: c.description?.slice(0, 200),
|
|
})),
|
|
}));
|
|
return { content: [{ type: "text", text: JSON.stringify(slim, null, 2) }] };
|
|
}
|
|
|
|
case "deck_get_cards": {
|
|
const stacks = await deckFetch(`/boards/${args.boardId}/stacks`);
|
|
let cards: any[] = [];
|
|
for (const stack of stacks) {
|
|
if (stack.cards) cards.push(...stack.cards.map((c: any) => ({
|
|
id: c.id, title: c.title, stackId: c.stackId, stackTitle: stack.title,
|
|
labels: (c.labels || []).map((l: any) => l.title),
|
|
description: c.description,
|
|
})));
|
|
}
|
|
if (args.label) {
|
|
cards = cards.filter((c: any) =>
|
|
c.labels?.some((l: string) => l.toLowerCase() === args.label.toLowerCase())
|
|
);
|
|
}
|
|
return { content: [{ type: "text", text: JSON.stringify(cards, null, 2) }] };
|
|
}
|
|
|
|
case "deck_create_board": {
|
|
const board = await deckFetch("/boards", "POST", {
|
|
title: args.title,
|
|
color: args.color || "28a745",
|
|
});
|
|
await deckFetch(`/boards/${board.id}/acl`, "POST", {
|
|
type: 0, participant: "nico",
|
|
permissionEdit: true, permissionShare: false, permissionManage: true,
|
|
});
|
|
return { content: [{ type: "text", text: JSON.stringify(board, null, 2) }] };
|
|
}
|
|
|
|
case "deck_create_stack": {
|
|
const stack = await deckFetch(`/boards/${args.boardId}/stacks`, "POST", {
|
|
title: args.title, order: args.order,
|
|
});
|
|
return { content: [{ type: "text", text: JSON.stringify(stack, null, 2) }] };
|
|
}
|
|
|
|
case "deck_create_card": {
|
|
const card = await deckFetch(`/boards/${args.boardId}/stacks/${args.stackId}/cards`, "POST", {
|
|
title: args.title, type: "plain", order: args.order ?? 0,
|
|
description: args.description || "",
|
|
});
|
|
return { content: [{ type: "text", text: JSON.stringify(card, null, 2) }] };
|
|
}
|
|
|
|
case "deck_move_card": {
|
|
const result = await deckFetch(
|
|
`/boards/${args.boardId}/stacks/${args.targetStackId}/cards/${args.cardId}/reorder`,
|
|
"PUT",
|
|
{ order: args.order ?? 0, stackId: args.targetStackId },
|
|
);
|
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
}
|
|
|
|
case "deck_add_comment": {
|
|
const result = await ocsFetch(`/cards/${args.cardId}/comments`, "POST", {
|
|
message: sanitize(args.text),
|
|
});
|
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
}
|
|
|
|
case "deck_delete_board": {
|
|
const result = await deckFetch(`/boards/${args.boardId}`, "DELETE");
|
|
return { content: [{ type: "text", text: `Board ${args.boardId} deleted` }] };
|
|
}
|
|
|
|
case "deck_delete_card": {
|
|
const result = await deckFetch(`/boards/${args.boardId}/stacks/${args.stackId}/cards/${args.cardId}`, "DELETE");
|
|
return { content: [{ type: "text", text: `Card ${args.cardId} deleted` }] };
|
|
}
|
|
|
|
case "deck_create_label": {
|
|
const label = await deckFetch(`/boards/${args.boardId}/labels`, "POST", {
|
|
title: args.title, color: args.color,
|
|
});
|
|
return { content: [{ type: "text", text: JSON.stringify(label, null, 2) }] };
|
|
}
|
|
|
|
case "deck_assign_label": {
|
|
await deckFetch(
|
|
`/boards/${args.boardId}/stacks/${args.stackId}/cards/${args.cardId}/assignLabel`,
|
|
"PUT",
|
|
{ labelId: args.labelId },
|
|
);
|
|
return { content: [{ type: "text", text: `Label ${args.labelId} assigned to card ${args.cardId}` }] };
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unknown deck tool: ${name}`);
|
|
}
|
|
}
|