UNPKG

@fredrika/mcp-mochi

Version:

MCP server for Mochi flashcard integration

611 lines (601 loc) 21.2 kB
#!/usr/bin/env node import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import axios from "axios"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import dotenv from "dotenv"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; dotenv.config(); /** * Custom error class for Mochi API errors * * Handles both array and object error responses from the API: * - Array: ["Error message 1", "Error message 2"] * - Object: { "field": "Error message" } */ class MochiError extends Error { errors; statusCode; constructor(errors, statusCode) { super(Array.isArray(errors) ? errors.join(", ") : Object.values(errors).join(", ")); this.errors = errors; this.statusCode = statusCode; this.name = "MochiError"; } } // Zod schemas for request validation const CreateCardFieldSchema = z.object({ id: z.string().describe("Unique identifier for the field"), value: z.string().describe("Value of the field"), }); const CreateCardRequestSchema = z.object({ content: z .string() .min(1) .describe("Markdown content of the card. Separate front and back using a horizontal rule (---) or use brackets for {{cloze deletion}}."), "deck-id": z.string().min(1).describe("ID of the deck to create the card in"), "template-id": z .string() .optional() .nullable() .default(null) .describe("Optional template ID to use for the card. Defaults to null if not set."), "manual-tags": z .array(z.string()) .optional() .describe("Optional array of tags to add to the card"), fields: z .record(z.string(), CreateCardFieldSchema) .optional() .describe("Map of field IDs to field values. Required only when using a template"), }); const UpdateCardRequestSchema = z.object({ content: z .string() .optional() .describe("Updated markdown content of the card"), "deck-id": z .string() .optional() .describe("ID of the deck to move the card to"), "template-id": z .string() .optional() .describe("Template ID to use for the card"), "archived?": z.boolean().optional().describe("Whether the card is archived"), "trashed?": z.string().optional().describe("Whether the card is trashed"), fields: z .record(z.string(), CreateCardFieldSchema) .optional() .describe("Updated map of field IDs to field values"), }); const ListDecksParamsSchema = z.object({ bookmark: z .string() .optional() .describe("Pagination bookmark for fetching next page of results"), }); const ListCardsParamsSchema = z.object({ "deck-id": z.string().optional().describe("Get cards from deck ID"), limit: z .number() .min(1) .max(100) .optional() .describe("Number of cards to return per page (1-100)"), bookmark: z .string() .optional() .describe("Pagination bookmark for fetching next page of results"), }); const ListTemplatesParamsSchema = z.object({ bookmark: z .string() .optional() .describe("Pagination bookmark for fetching next page of results"), }); const TemplateFieldSchema = z.object({ id: z.string().describe("Unique identifier for the template field"), name: z.string().describe("Display name of the field"), pos: z.string().describe("Position of the field in the template"), options: z .object({ "multi-line?": z .boolean() .optional() .describe("Whether the field supports multiple lines of text"), }) .optional() .describe("Additional options for the field"), }); const TemplateSchema = z .object({ id: z.string().describe("Unique identifier for the template"), name: z.string().describe("Display name of the template"), content: z.string().describe("Template content in markdown format"), pos: z.string().describe("Position of the template in the list"), fields: z .record(z.string(), TemplateFieldSchema) .describe("Map of field IDs to field definitions"), }) .strip(); const ListTemplatesResponseSchema = z .object({ bookmark: z.string().describe("Pagination bookmark for fetching next page"), docs: z.array(TemplateSchema).describe("Array of templates"), }) .strip(); // Response Zod schemas const CardSchema = z .object({ id: z.string().describe("Unique identifier for the card"), tags: z .array(z.string()) .describe("Array of tags associated with the card"), content: z .string() .describe('Markdown content of the card. Separate front and back of card with "---"'), name: z.string().describe("Display name of the card"), "deck-id": z.string().describe("ID of the deck containing the card"), fields: z .record(z.unknown()) .optional() .describe("Map of field IDs to field values. Need to match the field IDs in the template"), }) .strip(); const CreateCardResponseSchema = CardSchema.strip(); const ListCardsResponseSchema = z .object({ bookmark: z.string().describe("Pagination bookmark for fetching next page"), docs: z.array(CardSchema).describe("Array of cards"), }) .strip(); const DeckSchema = z .object({ id: z.string().describe("Unique identifier for the deck"), sort: z.number().describe("Sort order of the deck"), name: z.string().describe("Display name of the deck"), archived: z.boolean().optional().describe("Whether the deck is archived"), }) .strip(); const ListDecksResponseSchema = z .object({ bookmark: z.string().describe("Pagination bookmark for fetching next page"), docs: z.array(DeckSchema).describe("Array of decks"), }) .strip(); function getApiKey() { const apiKey = process.env.MOCHI_API_KEY; if (!apiKey) { console.error("MOCHI_API_KEY environment variable is not set"); process.exit(1); } return apiKey; } const MOCHI_API_KEY = getApiKey(); export class MochiClient { api; token; constructor(token) { this.token = token; this.api = axios.create({ baseURL: "https://app.mochi.cards/api/", headers: { Authorization: `Basic ${Buffer.from(`${this.token}:`).toString("base64")}`, "Content-Type": "application/json", }, }); } async createCard(request) { const response = await this.api.post("/cards", request); return CreateCardResponseSchema.parse(response.data); } async updateCard(cardId, request) { const response = await this.api.post(`/cards/${cardId}`, request); return CreateCardResponseSchema.parse(response.data); } async listDecks(params) { const validatedParams = params ? ListDecksParamsSchema.parse(params) : undefined; const response = await this.api.get("/decks", { params: validatedParams }); return ListDecksResponseSchema.parse(response.data).docs.filter((deck) => !deck.archived); } async listCards(params) { const validatedParams = params ? ListCardsParamsSchema.parse(params) : undefined; const response = await this.api.get("/cards", { params: validatedParams }); return ListCardsResponseSchema.parse(response.data); } async listTemplates(params) { const validatedParams = params ? ListTemplatesParamsSchema.parse(params) : undefined; const response = await this.api.get("/templates", { params: validatedParams, }); return ListTemplatesResponseSchema.parse(response.data); } } // Server setup const server = new Server({ name: "mcp-server/mochi", version: "1.0.3", }, { capabilities: { tools: {}, resources: {}, prompts: {}, }, }); // Set up request handlers server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "mochi_create_card", description: `Create a new flashcard in Mochi. Use this whenever I ask questions about something that is interesting to remember. E.g. if I ask "What is the capital of France?", you should create a new flashcard with the content "What is the capital of France?\n---\nParis". ## Parameters ### deck-id (required) ALWAYS look up deck-id with the mochi_list_decks tool. ### content (required) The markdown content of the card. Separate front and back using a horizontal rule (---). ### template-id (optional) When using a template, the field ids MUST match the template ones. If not using a template, omit this field. ### fields (optional) A map of field IDs (keyword) to field values. Only required when using a template. The field IDs must correspond to the fields defined on the template. ## Example without template { "content": "What is the capital of France?\n---\nParis", "deck-id": "btmZUXWM" } ## Example with template { "content": "New card from API. ![](@media/foobar03.png)", "deck-id": "btmZUXWM", "template-id": "8BtaEAXe", "fields": { "name": { "id": "name", "value": "Hello," }, "JNEnw1e7": { "id": "JNEnw1e7", "value": "World!" } } } ## Properties of good flashcards: - **focused:** A question or answer involving too much detail will dull your concentration and stimulate incomplete retrievals, leaving some bulbs unlit. - **precise** about what they're asking for. Vague questions will elicit vague answers, which won't reliably light the bulbs you're targeting. - **consistent** answers, lighting the same bulbs each time you perform the task. - **tractable**: Write prompts which you can almost always answer correctly. This often means breaking the task down, or adding cues - **effortful**: You shouldn't be able to trivially infer the answer. `, inputSchema: zodToJsonSchema(CreateCardRequestSchema), annotations: { title: "Create flashcard on Mochi", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, { name: "mochi_update_card", description: `Update or delete an existing flashcard in Mochi. To delete set trashed to true.`, inputSchema: zodToJsonSchema(z.object({ "card-id": z.string(), ...UpdateCardRequestSchema.shape, })), annotations: { title: "Update flashcard on Mochi", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, }, { name: "mochi_list_cards", description: "List cards in pages of 10 cards per page", inputSchema: zodToJsonSchema(ListCardsParamsSchema), annotations: { title: "List flashcards on Mochi", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, { name: "mochi_list_decks", description: "List all decks", inputSchema: zodToJsonSchema(ListDecksParamsSchema), annotations: { title: "List decks on Mochi", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, { name: "mochi_list_templates", description: `Templates can be used to create cards with pre-defined fields using the template_id field. Example response: { "bookmark": "g1AAAABAeJzLYWBgYMpgSmHgKy5JLCrJTq2MT8lPzkzJBYpzVBn4JgaaVZiC5Dlg8igyWQAxwRHd", "docs": [ { "id": "YDELNZSu", "name": "Simple flashcard", "content": "# << Front >>\n---\n<< Back >>", "pos": "s", "fields": { "name": { "id": "name", "name": "Front", "pos": "a" }, "Ysrde7Lj": { "id": "Ysrde7Lj", "name": "Back", "pos": "m", "options": { "multi-line?": true } } } }, ... ] }`, inputSchema: zodToJsonSchema(ListTemplatesParamsSchema), annotations: { title: "List templates on Mochi", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }, ], })); // Create Mochi client const mochiClient = new MochiClient(MOCHI_API_KEY); // Add resource handlers server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [ { uri: `mochi://decks`, name: "All Mochi Decks", description: `List of all decks in Mochi.`, mimeType: "application/json", }, { uri: `mochi://templates`, name: "All Mochi Templates", description: `List of all templates in Mochi.`, mimeType: "application/json", }, ], }; }); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; switch (uri) { case "mochi://decks": { const decks = await mochiClient.listDecks(); return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify(decks.map((deck) => ({ id: deck.id, name: deck.name, archived: deck.archived, })), null, 2), }, ], }; } case "mochi://templates": { const templates = await mochiClient.listTemplates(); return { contents: [ { uri, mimeType: "application/json", text: JSON.stringify(templates, null, 2), }, ], }; } default: { throw new Error("Invalid resource URI"); } } }); const CreateFlashcardPromptSchema = z.object({ input: z .string() .describe("The information to base the flashcard on.") .optional(), }); server.setRequestHandler(ListPromptsRequestSchema, async () => { return { prompts: [ { name: "write-flashcard", description: "Write a flashcard based on user-provided information.", arguments: [ { name: "input", description: "The information to base the flashcard on.", }, ], }, ], }; }); server.setRequestHandler(GetPromptRequestSchema, async (request) => { const params = CreateFlashcardPromptSchema.parse(request.params.arguments); const { input } = params; return { messages: [ { role: "user", content: { type: "text", text: `Create a flashcard using the info below while adhering to these principles: - Keep questions and answers atomic. - Utilize cloze prompts when applicable, like "This is a text with {{hidden}} part. Then don't use '---' separator.". - Focus on effective retrieval practice by being concise and clear. - Make it just challenging enough to reinforce specific facts. Input: ${input} `, }, }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { switch (request.params.name) { case "mochi_create_card": { const validatedArgs = CreateCardRequestSchema.parse(request.params.arguments); const response = await mochiClient.createCard(validatedArgs); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], isError: false, }; } case "mochi_update_card": { const { "card-id": cardId, ...updateArgs } = z .object({ "card-id": z.string(), ...UpdateCardRequestSchema.shape, }) .parse(request.params.arguments); const response = await mochiClient.updateCard(cardId, updateArgs); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], isError: false, }; } case "mochi_list_decks": { const validatedArgs = ListDecksParamsSchema.parse(request.params.arguments); const response = await mochiClient.listDecks(validatedArgs); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], isError: false, }; } case "mochi_list_cards": { const validatedArgs = ListCardsParamsSchema.parse(request.params.arguments); const response = await mochiClient.listCards(validatedArgs); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], isError: false, }; } case "mochi_list_templates": { const validatedArgs = ListTemplatesParamsSchema.parse(request.params.arguments); const response = await mochiClient.listTemplates(validatedArgs); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], isError: false, }; } default: return { content: [ { type: "text", text: `Unknown tool: ${request.params.name}`, }, ], isError: true, }; } } catch (error) { if (error instanceof z.ZodError) { const formattedErrors = error.errors.map((err) => { const path = err.path.join("."); const message = err.code === "invalid_type" && err.message.includes("Required") ? `Required field '${path}' is missing` : err.message; return `${path ? `${path}: ` : ""}${message}`; }); return { content: [ { type: "text", text: `Validation error:\n${formattedErrors.join("\n")}`, }, ], isError: true, }; } if (error instanceof MochiError) { return { content: [ { type: "text", text: `Mochi API error (${error.statusCode}): ${error.message}`, }, ], isError: true, }; } return { content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); async function runServer() { const transport = new StdioServerTransport(); await server.connect(transport); } runServer().catch((error) => { console.error("Fatal error running server:", error); process.exit(1); });