Nico ccee249618 v0.6.42: Hermes chat UI — Vue3/TS/Vite, audio STT/TTS, sidebar rail, MCP event loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:35:10 +02:00

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}`);
}
}