Skip to main content
Glama
drafts.ts13.4 kB
import { z } from "zod"; import type { RegisterFn } from "../types.js"; /** * Discourse Draft Tools * * Drafts in Discourse use a key-based system: * - "new_topic" - Draft for creating a new topic * - "topic_<id>" - Draft for replying to topic with ID <id> * - "new_private_message" - Draft for a new private message * * The draft data is stored as a JSON object containing: * - reply: The draft text content * - title: Topic title (for new topics) * - categoryId: Category ID * - tags: Array of tag names * - action: "createTopic", "reply", "edit", etc. * * Drafts use a sequence number for optimistic locking. When updating * a draft, you should use the sequence returned from listing/getting drafts. */ // Rate limiting for draft operations let lastDraftOp = 0; async function rateLimitDraftOp() { const now = Date.now(); if (now - lastDraftOp < 500) { const wait = 500 - (now - lastDraftOp); await new Promise((r) => setTimeout(r, wait)); } lastDraftOp = Date.now(); } /** * List all drafts for the current user */ export const registerListDrafts: RegisterFn = (server, ctx, _opts) => { const schema = z.object({ offset: z.number().int().min(0).optional().describe("Pagination offset (default: 0)"), }); server.registerTool( "discourse_list_drafts", { title: "List Drafts", description: "List all drafts for the current user. Returns draft keys, sequences, and preview content. Use this to find existing drafts before updating them.", inputSchema: schema.shape, }, async (input: unknown, _extra: unknown) => { const { offset } = schema.parse(input); try { const { base, client } = ctx.siteState.ensureSelectedSite(); const params = new URLSearchParams(); if (typeof offset === "number") params.set("offset", String(offset)); const url = `/drafts.json${params.toString() ? `?${params}` : ""}`; const data = (await client.get(url)) as { drafts?: Array<{ draft_key: string; sequence: number; data?: string; title?: string; category_id?: number; created_at?: string; }>; }; const drafts = data?.drafts || []; if (drafts.length === 0) { return { content: [{ type: "text", text: "No drafts found." }] }; } const lines = ["# Drafts\n"]; for (const draft of drafts) { lines.push(`## Draft: \`${draft.draft_key}\` (sequence: ${draft.sequence})`); if (draft.title) lines.push(`**Title:** ${draft.title}`); if (draft.category_id) lines.push(`**Category ID:** ${draft.category_id}`); if (draft.created_at) lines.push(`**Created:** ${draft.created_at}`); // Parse and show preview of reply content if (draft.data) { try { const parsed = JSON.parse(draft.data); if (parsed.reply) { const preview = parsed.reply.length > 200 ? parsed.reply.slice(0, 200) + "..." : parsed.reply; lines.push(`**Preview:**\n> ${preview.replace(/\n/g, "\n> ")}`); } } catch { // Ignore parse errors } } lines.push(""); } lines.push("\n```json"); lines.push(JSON.stringify(drafts, null, 2)); lines.push("```"); return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); return { content: [{ type: "text", text: `Failed to list drafts: ${msg}` }], isError: true }; } } ); }; /** * Get a specific draft by key */ export const registerGetDraft: RegisterFn = (server, ctx, _opts) => { const schema = z.object({ draft_key: z .string() .min(1) .max(40) .describe('Draft key (e.g., "new_topic", "topic_123", "new_private_message")'), sequence: z.number().int().min(0).optional().describe("Expected sequence number (optional)"), }); server.registerTool( "discourse_get_draft", { title: "Get Draft", description: 'Retrieve a specific draft by its key. Common keys: "new_topic" for new topic drafts, "topic_<id>" for reply drafts.', inputSchema: schema.shape, }, async (input: unknown, _extra: unknown) => { const { draft_key, sequence } = schema.parse(input); try { const { client } = ctx.siteState.ensureSelectedSite(); const params = new URLSearchParams(); if (typeof sequence === "number") params.set("sequence", String(sequence)); const url = `/drafts/${encodeURIComponent(draft_key)}.json${params.toString() ? `?${params}` : ""}`; const data = (await client.get(url)) as { draft?: string; draft_sequence?: number; }; if (!data?.draft) { return { content: [{ type: "text", text: `No draft found for key "${draft_key}".` }] }; } let parsedDraft: Record<string, unknown> = {}; try { parsedDraft = JSON.parse(data.draft); } catch { parsedDraft = { raw: data.draft }; } const lines = [`# Draft: \`${draft_key}\`\n`]; lines.push(`**Sequence:** ${data.draft_sequence ?? "unknown"}`); if (parsedDraft.title) lines.push(`**Title:** ${parsedDraft.title}`); if (parsedDraft.categoryId) lines.push(`**Category ID:** ${parsedDraft.categoryId}`); if (parsedDraft.tags && Array.isArray(parsedDraft.tags)) { lines.push(`**Tags:** ${(parsedDraft.tags as string[]).join(", ")}`); } if (parsedDraft.action) lines.push(`**Action:** ${parsedDraft.action}`); if (parsedDraft.reply) { lines.push(`\n**Content:**\n${parsedDraft.reply}`); } lines.push("\n```json"); lines.push( JSON.stringify( { draft_key, draft_sequence: data.draft_sequence, data: parsedDraft, }, null, 2 ) ); lines.push("```"); return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); return { content: [{ type: "text", text: `Failed to get draft: ${msg}` }], isError: true }; } } ); }; /** * Create or update a draft */ export const registerSaveDraft: RegisterFn = (server, ctx, opts) => { if (!opts.allowWrites) return; const schema = z.object({ draft_key: z .string() .min(1) .max(40) .describe('Draft key: "new_topic" for new topics, "topic_<id>" for replies (e.g., "topic_123")'), reply: z.string().min(1).max(50000).describe("The draft content/body text"), title: z.string().min(1).max(300).optional().describe("Topic title (required for new_topic drafts)"), category_id: z.number().int().positive().optional().describe("Category ID for the topic"), tags: z.array(z.string().min(1).max(100)).max(10).optional().describe("Array of tag names"), sequence: z .number() .int() .min(0) .default(0) .describe("Current sequence number (use 0 for new drafts, or the sequence from get/list for updates)"), action: z .enum(["createTopic", "reply", "edit", "privateMessage"]) .optional() .describe('Draft action type (defaults based on draft_key: "createTopic" for new_topic, "reply" for topic_*)'), }); server.registerTool( "discourse_save_draft", { title: "Create/Save Draft", description: "Create a draft topic, create a draft reply, or update an existing draft. Use this when the user wants to draft something without publishing immediately. For new topic drafts, use draft_key='new_topic'. For reply drafts, use draft_key='topic_<id>' (e.g., 'topic_123'). Returns the new sequence number for subsequent updates.", inputSchema: schema.shape, }, async (input: unknown, _extra: unknown) => { const { draft_key, reply, title, category_id, tags, sequence, action } = schema.parse(input); await rateLimitDraftOp(); try { const { base, client } = ctx.siteState.ensureSelectedSite(); // Build the draft data object const draftData: Record<string, unknown> = { reply, }; // Determine action based on draft_key if not explicitly provided let resolvedAction = action; if (!resolvedAction) { if (draft_key === "new_topic") { resolvedAction = "createTopic"; } else if (draft_key === "new_private_message") { resolvedAction = "privateMessage"; } else if (draft_key.startsWith("topic_")) { resolvedAction = "reply"; } } if (resolvedAction) draftData.action = resolvedAction; if (title) draftData.title = title; if (typeof category_id === "number") draftData.categoryId = category_id; if (tags && tags.length > 0) draftData.tags = tags; // For reply drafts, extract topic_id if (draft_key.startsWith("topic_")) { const topicId = parseInt(draft_key.replace("topic_", ""), 10); if (!isNaN(topicId)) { draftData.topic_id = topicId; } } const payload = { draft_key, data: JSON.stringify(draftData), sequence, }; const result = (await client.post("/drafts.json", payload)) as { draft_sequence?: number; conflict_user?: { id: number; username?: string }; }; if (result.conflict_user) { return { content: [ { type: "text", text: `Draft conflict detected! Another user (ID: ${result.conflict_user.id}) has modified the original content. New sequence: ${result.draft_sequence ?? sequence}`, }, ], isError: true, }; } const newSequence = result.draft_sequence ?? sequence; let successMsg = `Draft saved successfully!\n\n`; successMsg += `- **Draft Key:** \`${draft_key}\`\n`; successMsg += `- **New Sequence:** ${newSequence}\n`; if (draft_key === "new_topic" && title) { successMsg += `- **Title:** ${title}\n`; successMsg += `\nTo continue editing this draft, use:\n`; successMsg += `\`discourse_save_draft(draft_key="new_topic", sequence=${newSequence}, ...)\`\n`; successMsg += `\nTo publish this draft as a topic, use:\n`; successMsg += `\`discourse_create_topic(title="${title}", raw="...", ...)\``; } else if (draft_key.startsWith("topic_")) { const topicId = draft_key.replace("topic_", ""); successMsg += `- **Topic ID:** ${topicId}\n`; successMsg += `\nView the topic: ${base}/t/${topicId}\n`; successMsg += `\nTo continue editing this draft, use:\n`; successMsg += `\`discourse_save_draft(draft_key="${draft_key}", sequence=${newSequence}, ...)\`\n`; successMsg += `\nTo publish this draft as a reply, use:\n`; successMsg += `\`discourse_create_post(topic_id=${topicId}, raw="...")\``; } return { content: [{ type: "text", text: successMsg }] }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); return { content: [{ type: "text", text: `Failed to save draft: ${msg}` }], isError: true }; } } ); }; /** * Delete a draft */ export const registerDeleteDraft: RegisterFn = (server, ctx, opts) => { if (!opts.allowWrites) return; const schema = z.object({ draft_key: z.string().min(1).max(40).describe("Draft key to delete"), sequence: z.number().int().min(0).describe("Current sequence number (required for deletion)"), }); server.registerTool( "discourse_delete_draft", { title: "Delete Draft", description: "Delete a draft by its key. Requires the current sequence number from list/get operations to prevent conflicts.", inputSchema: schema.shape, }, async (input: unknown, _extra: unknown) => { const { draft_key, sequence } = schema.parse(input); await rateLimitDraftOp(); try { const { client } = ctx.siteState.ensureSelectedSite(); await client.delete(`/drafts/${encodeURIComponent(draft_key)}.json`, { sequence }); return { content: [{ type: "text", text: `Draft "${draft_key}" deleted successfully.` }], }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); // Check for sequence mismatch if (msg.includes("409") || msg.toLowerCase().includes("conflict") || msg.toLowerCase().includes("sequence")) { return { content: [ { type: "text", text: `Failed to delete draft: Sequence mismatch. The draft may have been modified. Please use discourse_get_draft to get the current sequence and try again.`, }, ], isError: true, }; } return { content: [{ type: "text", text: `Failed to delete draft: ${msg}` }], isError: true }; } } ); };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/discourse/discourse-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server