diff --git a/CHANGELOG.md b/CHANGELOG.md index 53f90d3..6ac9222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [0.2.5](https://github.com/discourse/discourse-mcp/compare/v0.2.4...v0.2.5) (2026-02-03) + +### Features + +* Add Data Explorer plugin integration + - `explorer_schema` resource: database schema in compact text format (core tables by default) + - `explorer_schema_tables` resource: schema for specific or all tables + - `explorer_queries` resource: saved queries with pagination (30/page, sorted by last used) + - `discourse_get_query` tool: get query details including SQL and parameters + - `discourse_run_query` tool: execute query with parameters + - `discourse_create_query` tool: create new saved query + - `discourse_update_query` tool: update existing query + - `discourse_delete_query` tool: delete query + - `sql_query` prompt: guided SQL workflow for schema discovery and query execution + ## [0.2.4](https://github.com/discourse/discourse-mcp/compare/v0.2.3...v0.2.4) (2026-01-20) ### Features diff --git a/package.json b/package.json index 3ae9ac4..30aa6f5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@discourse/mcp", "mcpName": "io.github.discourse/mcp", - "version": "0.2.4", + "version": "0.2.5", "description": "Discourse MCP CLI server (stdio) exposing Discourse tools via MCP", "author": "Discourse", "license": "MIT", diff --git a/src/index.ts b/src/index.ts index c6e5c43..0032e9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { type AuthMode } from "./http/client.js"; import { registerAllTools, type ToolsMode } from "./tools/registry.js"; import { tryRegisterRemoteTools } from "./tools/remote/tool_exec_api.js"; import { registerAllResources } from "./resources/registry.js"; +import { registerAllPrompts } from "./prompts/registry.js"; import { SiteState, type AuthOverride } from "./site/state.js"; const DEFAULT_TIMEOUT_MS = 15000; @@ -222,6 +223,7 @@ async function main() { capabilities: { tools: { listChanged: false }, resources: { listChanged: false }, + prompts: { listChanged: false }, }, } ); @@ -258,7 +260,10 @@ async function main() { }); // Register MCP resources (URI-addressable read-only data) - registerAllResources(server, { siteState, logger }); + registerAllResources(server, { siteState, logger, allowAdminTools }); + + // Register MCP prompts (guided workflows) + registerAllPrompts(server, { siteState, logger, allowAdminTools }); // If tethered and remote tool discovery is enabled, discover now if (config.site && config.tools_mode !== "discourse_api_only") { diff --git a/src/prompts/registry.ts b/src/prompts/registry.ts new file mode 100644 index 0000000..64f903f --- /dev/null +++ b/src/prompts/registry.ts @@ -0,0 +1,68 @@ +/** + * MCP Prompts Registry + * + * Registers prompts that provide guided workflows for common tasks. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { SiteState } from "../site/state.js"; +import type { Logger } from "../util/logger.js"; +import { + sqlQueryPromptName, + sqlQueryPromptSchema, + getSqlQueryPromptContent, +} from "./sql_query.js"; + +/** Narrowed interface for prompt registration */ +export type PromptRegistrar = Pick; + +export interface PromptContext { + siteState: SiteState; + logger: Logger; + allowAdminTools?: boolean; +} + +/** + * Registers all MCP prompts. + */ +export function registerAllPrompts( + server: PromptRegistrar, + ctx: PromptContext +): void { + // Only register SQL query prompt if admin tools allowed + // Default to computed auth if not explicitly provided + const allowAdminTools = ctx.allowAdminTools ?? ctx.siteState.hasAdminAuth(); + if (allowAdminTools) { + registerSqlQueryPrompt(server, ctx); + } +} + +function registerSqlQueryPrompt( + server: PromptRegistrar, + _ctx: PromptContext +): void { + server.registerPrompt( + sqlQueryPromptName, + { + description: + "Guided workflow for database queries: discover schema, write SQL, run queries via Data Explorer", + argsSchema: sqlQueryPromptSchema.shape, + }, + async (args) => { + const parsed = sqlQueryPromptSchema.safeParse(args); + const validArgs = parsed.success ? parsed.data : {}; + + return { + messages: [ + { + role: "user", + content: { + type: "text", + text: getSqlQueryPromptContent(validArgs), + }, + }, + ], + }; + } + ); +} diff --git a/src/prompts/sql_query.ts b/src/prompts/sql_query.ts new file mode 100644 index 0000000..8ae7931 --- /dev/null +++ b/src/prompts/sql_query.ts @@ -0,0 +1,133 @@ +/** + * SQL Query Workflow Prompt + * + * Provides a guided workflow for discovering schema, writing queries, + * and executing them via the Data Explorer plugin. + */ + +import { z } from "zod"; + +export const sqlQueryPromptName = "sql_query"; + +export const sqlQueryPromptSchema = z.object({ + goal: z + .string() + .optional() + .describe("What you want to learn from the data"), +}); + +export type SqlQueryPromptArgs = z.infer; + +export function getSqlQueryPromptContent(args: SqlQueryPromptArgs): string { + const goal = args.goal || "Explore the database"; + + return `# SQL Query Workflow + +Goal: ${goal} + +## Step 1: Discover Schema +Use the \`discourse://explorer/schema\` resource to explore available tables and columns. + +Key tables you may find useful: +- **users** - User accounts (id, username, name, email, trust_level, created_at, last_seen_at) +- **topics** - Forum topics (id, title, user_id, category_id, created_at, views, posts_count) +- **posts** - Individual posts (id, topic_id, user_id, raw, cooked, created_at, post_number) +- **categories** - Topic categories (id, name, slug, parent_category_id) +- **tags** - Topic tags (id, name, topic_count) +- **topic_tags** - Join table for topics and tags +- **user_actions** - User activity log (user_id, action_type, target_topic_id, target_post_id) +- **notifications** - User notifications +- **groups** - User groups +- **group_users** - Group membership + +## Step 2: Check Existing Queries +Use the \`discourse://explorer/queries\` resource to see if a similar query already exists. +This can save time and provide examples of working queries. + +## Step 3: Write or Modify Query +- Use \`discourse_create_query\` to save a new query, or +- Use \`discourse_get_query\` to fetch an existing query's SQL for modification + +### Query Parameter Syntax +Declare parameters in SQL comments at the top of your query: + +\`\`\`sql +-- [params] +-- int :user_id +-- string :username = 'default_value' +-- null date :start_date + +SELECT * FROM users WHERE id = :user_id +\`\`\` + +### Supported Parameter Types +- **int** - Integer value +- **bigint** - Large integer +- **string** - Text value +- **boolean** - true/false +- **date** - Date (YYYY-MM-DD) +- **datetime** - Date and time +- **user_id** - User ID with autocomplete +- **post_id** - Post ID +- **topic_id** - Topic ID +- **category_id** - Category ID with autocomplete +- **group_id** - Group ID with autocomplete +- **badge_id** - Badge ID with autocomplete +- **int_list** - Comma-separated integers +- **string_list** - Comma-separated strings + +Prefix with \`null\` to make a parameter optional: \`-- null int :optional_id\` + +## Step 4: Run Query +Use \`discourse_run_query\` with the query ID and any required parameters. + +Example: +\`\`\`json +{ + "id": 123, + "params": { "user_id": 1 }, + "limit": 100 +} +\`\`\` + +## Safety Notes +- Queries run in **read-only transactions** with a 10-second timeout +- **Sensitive columns** (emails, IPs, tokens) are marked in the schema - handle with care +- Use **LIMIT** to avoid returning too many rows (default is usually fine) +- The \`explain\` option shows the query execution plan for debugging performance + +## Example Queries + +### Recent active users +\`\`\`sql +SELECT username, last_seen_at, trust_level +FROM users +WHERE last_seen_at > CURRENT_DATE - INTERVAL '7 days' +ORDER BY last_seen_at DESC +LIMIT 50 +\`\`\` + +### Posts per category (last 30 days) +\`\`\`sql +SELECT c.name, COUNT(p.id) as post_count +FROM posts p +JOIN topics t ON p.topic_id = t.id +JOIN categories c ON t.category_id = c.id +WHERE p.created_at > CURRENT_DATE - INTERVAL '30 days' +GROUP BY c.id, c.name +ORDER BY post_count DESC +\`\`\` + +### User activity with parameters +\`\`\`sql +-- [params] +-- user_id :user_id + +SELECT action_type, COUNT(*) as count +FROM user_actions +WHERE user_id = :user_id +GROUP BY action_type +ORDER BY count DESC +\`\`\` +`; +} diff --git a/src/resources/data_explorer.ts b/src/resources/data_explorer.ts new file mode 100644 index 0000000..e72541e --- /dev/null +++ b/src/resources/data_explorer.ts @@ -0,0 +1,431 @@ +/** + * Data Explorer MCP Resources + * + * Provides read-only access to database schema and saved queries. + * Requires admin API key authentication. + */ + +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ResourceRegistrar, ResourceContext } from "./registry.js"; +import { requireAdminAccess } from "../util/access.js"; + +/** + * Core Discourse tables - the most commonly needed for queries. + * These are returned by default to keep the schema response compact. + */ +const CORE_TABLES = new Set([ + "users", + "user_emails", + "user_profiles", + "user_stats", + "user_actions", + "topics", + "posts", + "categories", + "tags", + "topic_tags", + "groups", + "group_users", + "notifications", + "uploads", + "badges", + "user_badges", + "bookmarks", + "likes", + "post_actions", + "topic_views", +]); + +/** + * Extracts error message from admin access error response. + * Safely parses JSON and falls back to default message. + */ +function getAdminAccessErrorMessage(accessError: { content: Array<{ text?: string }> }): string { + const errorText = accessError.content[0]?.text || ""; + let message = "Admin API key required"; + try { + message = JSON.parse(errorText)?.error ?? message; + } catch { + // Keep default message if JSON parsing fails + } + return message; +} + +/** + * Formats schema as compact text. + * Format: table: col, col*, col:int, col:ts, col>fk_table + * - No type = text (default, most common) + * - :int = integer, :ts = timestamp, :bool = boolean, :json = json + * - * = sensitive, >table = foreign key + */ +function formatSchemaAsText( + schema: Record, + tablesToInclude: Set | "all" +): { text: string; tableCount: number } { + const lines: string[] = []; + + const sortedTables = Object.keys(schema).sort(); + + for (const tableName of sortedTables) { + // Case-insensitive comparison for requested tables + if (tablesToInclude !== "all" && !tablesToInclude.has(tableName.toLowerCase())) { + continue; + } + + const columns = schema[tableName]; + if (!Array.isArray(columns)) continue; + + const colDefs = columns.map((col: any) => { + const name = col.column_name || col.name || "?"; + let suffix = ""; + + // Skip type for 'id' columns (Rails convention: always numeric PK) + // and for text types (implied default) + if (name !== "id") { + const type = minimalType(col.data_type || col.type || ""); + if (type) { + suffix += `:${type}`; + } + } + + // Mark sensitive columns + if (col.sensitive) { + suffix += "*"; + } + + // Mark foreign keys with >table + const fkey = col.fkey_info || col.fkey; + if (fkey && typeof fkey === "string") { + const fkTable = fkey.split(".")[0]; + suffix += `>${fkTable}`; + } + + return `${name}${suffix}`; + }); + + lines.push(`${tableName}: ${colDefs.join(", ")}`); + } + + return { text: lines.join("\n"), tableCount: lines.length }; +} + +/** + * Returns minimal type indicator, or empty string for text types (implied default). + */ +function minimalType(type: string): string { + const t = type.toLowerCase(); + + // Text types - no indicator needed (default) + if (t.includes("char") || t === "text" || t === "citext") { + return ""; + } + + // Integer types + if (t === "integer" || t === "int" || t === "bigint" || t === "smallint" || t === "int4" || t === "int8" || t === "int2") { + return "int"; + } + + // Timestamp types + if (t.includes("timestamp") || t === "timestamptz") { + return "ts"; + } + + // Boolean + if (t === "boolean" || t === "bool") { + return "bool"; + } + + // JSON + if (t === "json" || t === "jsonb") { + return "json"; + } + + // Date (distinct from timestamp) + if (t === "date") { + return "date"; + } + + // Float/numeric + if (t === "numeric" || t === "decimal" || t === "real" || t === "double precision" || t === "float4" || t === "float8") { + return "num"; + } + + // Keep other types short but visible + if (t === "bytea") return "bytes"; + if (t === "uuid") return "uuid"; + if (t === "inet" || t === "cidr") return "ip"; + if (t === "interval") return "interval"; + + // Unknown/other - show as-is but truncated + return t.length > 8 ? t.slice(0, 8) : t; +} + +/** + * Helper to fetch and format schema. + */ +async function fetchAndFormatSchema( + ctx: ResourceContext, + uri: URL, + tablesParam: string | undefined +): Promise<{ contents: Array<{ uri: string; mimeType: string; text: string }> }> { + const accessError = requireAdminAccess(ctx.siteState); + if (accessError) { + return { + contents: [ + { + uri: uri.href, + mimeType: "text/plain", + text: `Error: ${getAdminAccessErrorMessage(accessError)}`, + }, + ], + }; + } + + const { client } = ctx.siteState.ensureSelectedSite(); + + try { + const data = (await client.getCached( + "/admin/plugins/explorer/schema.json", + 60000 + )) as Record; + + // Determine which tables to include + let tablesToInclude: Set | "all"; + let isExplicitSelection = false; + + // Normalize tablesParam once and filter empty entries + const normalized = tablesParam?.trim(); + if (!normalized) { + // Default: core tables only (already lowercase) + tablesToInclude = CORE_TABLES; + } else if (normalized.toLowerCase() === "all") { + tablesToInclude = "all"; + isExplicitSelection = true; + } else { + // Specific tables requested (normalized to lowercase for case-insensitive matching) + tablesToInclude = new Set( + normalized.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean) + ); + // Fall back to core tables if all entries were empty/whitespace + if (tablesToInclude.size === 0) { + tablesToInclude = CORE_TABLES; + } else { + isExplicitSelection = true; + } + } + + const { text, tableCount } = formatSchemaAsText(data, tablesToInclude); + const totalTables = Object.keys(data).length; + + // Add header with info (use actual tableCount from formatted output) + const header = + tablesToInclude === "all" + ? `-- All ${totalTables} tables | id = PK, no type = text, :int :ts :bool :json | * = sensitive, >t = fkey\n\n` + : isExplicitSelection + ? `-- ${tableCount} tables | id = PK, no type = text, :int :ts :bool :json | * = sensitive, >t = fkey\n\n` + : `-- Core tables (${tableCount}/${totalTables}) | id = PK, no type = text, :int :ts :bool :json | * = sensitive, >t = fkey\n\n`; + + return { + contents: [ + { + uri: uri.href, + mimeType: "text/plain", + text: header + text, + }, + ], + }; + } catch (e: any) { + ctx.logger.error(`Failed to fetch explorer schema: ${e?.message || String(e)}`); + return { + contents: [ + { + uri: uri.href, + mimeType: "text/plain", + text: `Error: Failed to fetch schema: ${e?.message || String(e)}`, + }, + ], + }; + } +} + +/** + * Registers schema resources: + * - discourse://explorer/schema (static, returns core tables) + * - discourse://explorer/schema/{tables} (template, for "all" or specific tables) + */ +export function registerExplorerSchemaResource( + server: ResourceRegistrar, + ctx: ResourceContext +): void { + // Static resource for default (core tables) + server.resource( + "explorer_schema", + "discourse://explorer/schema", + { + description: + "Database schema (core tables). Format: col, col:int, col:ts, col*, col>fk_table. No type = text. Use explorer_schema_tables for all/specific tables.", + }, + async (uri) => fetchAndFormatSchema(ctx, uri, undefined) + ); + + // Template resource for specific tables + const template = new ResourceTemplate( + "discourse://explorer/schema/{tables}", + { list: undefined } + ); + + server.resource( + "explorer_schema_tables", + template, + { + description: + "Database schema for specific tables. Use 'all' for all tables, or comma-separated names (e.g., 'users,topics,posts').", + }, + async (uri, variables) => { + const tablesParam = variables.tables as string; + return fetchAndFormatSchema(ctx, uri, tablesParam); + } + ); +} + +const QUERIES_PER_PAGE = 30; + +/** + * Helper to fetch and format queries with pagination. + */ +async function fetchAndFormatQueries( + ctx: ResourceContext, + uri: URL, + page: number +): Promise<{ contents: Array<{ uri: string; mimeType: string; text: string }> }> { + const accessError = requireAdminAccess(ctx.siteState); + if (accessError) { + return { + contents: [ + { + uri: uri.href, + mimeType: "text/plain", + text: `Error: ${getAdminAccessErrorMessage(accessError)}`, + }, + ], + }; + } + + const { client } = ctx.siteState.ensureSelectedSite(); + + try { + const data = (await client.getCached( + "/admin/plugins/explorer/queries.json", + 30000 + )) as any; + + // Copy array to avoid mutating cached response + const rawQueries: any[] = [...(data?.queries || [])]; + + // Sort by last_run_at descending (most recently used first), nulls last + rawQueries.sort((a, b) => { + if (!a.last_run_at && !b.last_run_at) return 0; + if (!a.last_run_at) return 1; + if (!b.last_run_at) return -1; + return new Date(b.last_run_at).getTime() - new Date(a.last_run_at).getTime(); + }); + + // Handle empty query list + if (rawQueries.length === 0) { + return { + contents: [ + { + uri: uri.href, + mimeType: "text/plain", + text: "-- No queries found", + }, + ], + }; + } + + // Paginate + const totalPages = Math.ceil(rawQueries.length / QUERIES_PER_PAGE); + const safePage = Math.max(1, Math.min(page, totalPages)); + const startIdx = (safePage - 1) * QUERIES_PER_PAGE; + const pageQueries = rawQueries.slice(startIdx, startIdx + QUERIES_PER_PAGE); + + // Format: "id: name - description" (truncate description) + const lines = pageQueries.map((q: any) => { + const name = q.name || "(unnamed)"; + const desc = q.description ? ` - ${truncate(q.description, 80)}` : ""; + return `${q.id}: ${name}${desc}`; + }); + + // Header with pagination info + let header = `-- Queries (${rawQueries.length} total, p${safePage}/${totalPages}, by last used)\n`; + if (safePage < totalPages) { + header += `-- Next: discourse://explorer/queries/${safePage + 1}\n`; + } + header += "\n"; + + return { + contents: [ + { + uri: uri.href, + mimeType: "text/plain", + text: header + lines.join("\n"), + }, + ], + }; + } catch (e: any) { + ctx.logger.error(`Failed to fetch explorer queries: ${e?.message || String(e)}`); + return { + contents: [ + { + uri: uri.href, + mimeType: "text/plain", + text: `Error: Failed to fetch queries: ${e?.message || String(e)}`, + }, + ], + }; + } +} + +function truncate(str: string, maxLen: number): string { + const cleaned = str.replace(/\s+/g, " ").trim(); + if (cleaned.length <= maxLen) return cleaned; + return cleaned.slice(0, maxLen - 3) + "..."; +} + +/** + * discourse://explorer/queries - page 1 (default) + * discourse://explorer/queries/{page} - specific page + */ +export function registerExplorerQueriesResource( + server: ResourceRegistrar, + ctx: ResourceContext +): void { + // Static resource for page 1 + server.resource( + "explorer_queries", + "discourse://explorer/queries", + { + description: + "Saved Data Explorer queries (30/page, by last used). Shows id, name, description. Use explorer_queries_page for other pages.", + }, + async (uri) => fetchAndFormatQueries(ctx, uri, 1) + ); + + // Template resource for pagination + const template = new ResourceTemplate( + "discourse://explorer/queries/{page}", + { list: undefined } + ); + + server.resource( + "explorer_queries_page", + template, + { + description: "Saved Data Explorer queries - specific page number.", + }, + async (uri, variables) => { + const page = parseInt(variables.page as string, 10) || 1; + return fetchAndFormatQueries(ctx, uri, page); + } + ); +} diff --git a/src/resources/registry.ts b/src/resources/registry.ts index 2a21d58..d09ee21 100644 --- a/src/resources/registry.ts +++ b/src/resources/registry.ts @@ -23,6 +23,10 @@ import { type LeanUserChatChannel, type LeanDraft, } from "../util/json_response.js"; +import { + registerExplorerSchemaResource, + registerExplorerQueriesResource, +} from "./data_explorer.js"; /** Narrowed interface for resource registration - only requires resource method */ export type ResourceRegistrar = Pick; @@ -30,6 +34,7 @@ export type ResourceRegistrar = Pick; export interface ResourceContext { siteState: SiteState; logger: Logger; + allowAdminTools?: boolean; } /** @@ -46,6 +51,14 @@ export function registerAllResources( registerChatChannelsResource(server, ctx); registerUserChatChannelsResource(server, ctx); registerUserDraftsResource(server, ctx); + + // Only register Data Explorer resources if admin tools allowed + // Default to computed auth if not explicitly provided + const allowAdminTools = ctx.allowAdminTools ?? ctx.siteState.hasAdminAuth(); + if (allowAdminTools) { + registerExplorerSchemaResource(server, ctx); + registerExplorerQueriesResource(server, ctx); + } } /** diff --git a/src/test/tools.test.ts b/src/test/tools.test.ts index 21be1f5..33ae779 100644 --- a/src/test/tools.test.ts +++ b/src/test/tools.test.ts @@ -3,6 +3,8 @@ import assert from 'node:assert/strict'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { Logger } from '../util/logger.js'; import { registerAllTools, type RegistryOptions } from '../tools/registry.js'; +import { registerAllResources, type ResourceRegistrar } from '../resources/registry.js'; +import { registerAllPrompts, type PromptRegistrar } from '../prompts/registry.js'; import { SiteState } from '../site/state.js'; import type { ToolRegistrar } from '../tools/types.js'; @@ -233,6 +235,8 @@ const READ_ONLY_TOOLS = [ const ADMIN_TOOLS = [ 'discourse_list_users', + 'discourse_get_query', + 'discourse_run_query', ]; const WRITE_TOOLS = [ @@ -247,6 +251,12 @@ const WRITE_TOOLS = [ 'discourse_delete_draft', ]; +const ADMIN_WRITE_TOOLS = [ + 'discourse_create_query', + 'discourse_update_query', + 'discourse_delete_query', +]; + test('read-only mode without admin auth exposes only read tools', async () => { const logger = new Logger('silent'); const siteState = new SiteState({ logger, timeoutMs: 5000, defaultAuth: { type: 'none' } }); @@ -291,7 +301,7 @@ test('write mode with admin auth exposes all tools', async () => { }); const registeredTools = Object.keys(tools).sort(); - const expectedTools = [...READ_ONLY_TOOLS, ...ADMIN_TOOLS, ...WRITE_TOOLS].sort(); + const expectedTools = [...READ_ONLY_TOOLS, ...ADMIN_TOOLS, ...WRITE_TOOLS, ...ADMIN_WRITE_TOOLS].sort(); assert.deepEqual(registeredTools, expectedTools); }); @@ -370,3 +380,93 @@ test('SiteState.hasAdminAuth returns false with no auth', async () => { }); assert.ok(!siteState.hasAdminAuth()); }); + +// ======================== +// Resource registration tests - verify resources are exposed based on auth context +// ======================== + +const BASE_RESOURCES = [ + 'site_categories', + 'site_tags', + 'site_groups', + 'chat_channels', + 'user_chat_channels', + 'user_drafts', +]; + +const ADMIN_RESOURCES = [ + 'explorer_schema', + 'explorer_schema_tables', + 'explorer_queries', + 'explorer_queries_page', +]; + +/** Creates a mock server that captures resource registrations */ +function createMockResourceServer(): { server: ResourceRegistrar; resources: Record } { + const resources: Record = {}; + const server = { + resource(name: string, ...rest: unknown[]) { + resources[name] = rest; + }, + } as ResourceRegistrar; + return { server, resources }; +} + +test('resources without admin auth excludes Data Explorer resources', async () => { + const logger = new Logger('silent'); + const siteState = new SiteState({ logger, timeoutMs: 5000, defaultAuth: { type: 'none' } }); + const { server, resources } = createMockResourceServer(); + + registerAllResources(server, { siteState, logger, allowAdminTools: false }); + + const registeredResources = Object.keys(resources).sort(); + const expectedResources = [...BASE_RESOURCES].sort(); + assert.deepEqual(registeredResources, expectedResources); +}); + +test('resources with admin auth includes Data Explorer resources', async () => { + const logger = new Logger('silent'); + const siteState = new SiteState({ logger, timeoutMs: 5000, defaultAuth: { type: 'api_key', key: 'test' } }); + const { server, resources } = createMockResourceServer(); + + registerAllResources(server, { siteState, logger, allowAdminTools: true }); + + const registeredResources = Object.keys(resources).sort(); + const expectedResources = [...BASE_RESOURCES, ...ADMIN_RESOURCES].sort(); + assert.deepEqual(registeredResources, expectedResources); +}); + +// ======================== +// Prompt registration tests - verify prompts are exposed based on auth context +// ======================== + +/** Creates a mock server that captures prompt registrations */ +function createMockPromptServer(): { server: PromptRegistrar; prompts: Record } { + const prompts: Record = {}; + const server = { + registerPrompt(name: string, ...rest: unknown[]) { + prompts[name] = rest; + }, + } as PromptRegistrar; + return { server, prompts }; +} + +test('prompts without admin auth excludes sql_query prompt', async () => { + const logger = new Logger('silent'); + const siteState = new SiteState({ logger, timeoutMs: 5000, defaultAuth: { type: 'none' } }); + const { server, prompts } = createMockPromptServer(); + + registerAllPrompts(server, { siteState, logger, allowAdminTools: false }); + + assert.deepEqual(Object.keys(prompts), []); +}); + +test('prompts with admin auth includes sql_query prompt', async () => { + const logger = new Logger('silent'); + const siteState = new SiteState({ logger, timeoutMs: 5000, defaultAuth: { type: 'api_key', key: 'test' } }); + const { server, prompts } = createMockPromptServer(); + + registerAllPrompts(server, { siteState, logger, allowAdminTools: true }); + + assert.deepEqual(Object.keys(prompts), ['sql_query']); +}); diff --git a/src/tools/builtin/data_explorer/create_query.ts b/src/tools/builtin/data_explorer/create_query.ts new file mode 100644 index 0000000..02f554c --- /dev/null +++ b/src/tools/builtin/data_explorer/create_query.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import type { RegisterFn } from "../../types.js"; +import { + jsonResponse, + jsonError, + isZodError, + zodError, + rateLimit, + transformQueryDetail, +} from "../../../util/json_response.js"; +import { requireAdminAccess } from "../../../util/access.js"; + +export const registerCreateQuery: RegisterFn = (server, ctx, opts) => { + if (!opts.allowAdminTools || !opts.allowWrites) return; + + const schema = z.object({ + name: z + .string() + .min(1) + .max(255) + .describe("Query name"), + sql: z + .string() + .min(1) + .describe("SQL query. Declare parameters in comments: -- [params]\\n-- int :user_id"), + description: z.string().optional().describe("Query description"), + group_ids: z + .array(z.number().int()) + .optional() + .describe("Group IDs allowed to run this query (empty = admin only)"), + }); + + server.registerTool( + "discourse_create_query", + { + title: "Create Data Explorer Query", + description: + "Create a new saved Data Explorer query. Requires admin API key and write access.", + inputSchema: schema.shape, + }, + async (input: unknown, _extra: unknown) => { + try { + const { name, sql, description, group_ids } = schema.parse(input); + + const accessError = requireAdminAccess(ctx.siteState); + if (accessError) return accessError; + + await rateLimit("query"); + + const { client } = ctx.siteState.ensureSelectedSite(); + + const payload: Record = { + query: { + name, + sql, + description: description || "", + group_ids: group_ids || [], + }, + }; + + const data = (await client.post( + "/admin/plugins/explorer/queries.json", + payload + )) as any; + + const query = data?.query || data; + return jsonResponse(transformQueryDetail(query)); + } catch (e: unknown) { + if (isZodError(e)) return zodError(e); + const err = e as any; + return jsonError(`Failed to create query: ${err?.message || String(e)}`); + } + } + ); +}; diff --git a/src/tools/builtin/data_explorer/delete_query.ts b/src/tools/builtin/data_explorer/delete_query.ts new file mode 100644 index 0000000..c397f6b --- /dev/null +++ b/src/tools/builtin/data_explorer/delete_query.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import type { RegisterFn } from "../../types.js"; +import { + jsonResponse, + jsonError, + isZodError, + zodError, + rateLimit, +} from "../../../util/json_response.js"; +import { requireAdminAccess } from "../../../util/access.js"; + +export const registerDeleteQuery: RegisterFn = (server, ctx, opts) => { + if (!opts.allowAdminTools || !opts.allowWrites) return; + + const schema = z.object({ + id: z.number().int().positive().describe("Query ID to delete"), + }); + + server.registerTool( + "discourse_delete_query", + { + title: "Delete Data Explorer Query", + description: + "Soft-delete a Data Explorer query. The query can be restored by an admin. Requires admin API key and write access.", + inputSchema: schema.shape, + }, + async (input: unknown, _extra: unknown) => { + try { + const { id } = schema.parse(input); + + const accessError = requireAdminAccess(ctx.siteState); + if (accessError) return accessError; + + await rateLimit("query"); + + const { client } = ctx.siteState.ensureSelectedSite(); + + await client.delete(`/admin/plugins/explorer/queries/${id}.json`); + + return jsonResponse({ deleted: true, id }); + } catch (e: unknown) { + if (isZodError(e)) return zodError(e); + const err = e as any; + return jsonError(`Failed to delete query: ${err?.message || String(e)}`); + } + } + ); +}; diff --git a/src/tools/builtin/data_explorer/get_query.ts b/src/tools/builtin/data_explorer/get_query.ts new file mode 100644 index 0000000..f902c48 --- /dev/null +++ b/src/tools/builtin/data_explorer/get_query.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import type { RegisterFn } from "../../types.js"; +import { + jsonResponse, + jsonError, + isZodError, + zodError, + transformQueryDetail, +} from "../../../util/json_response.js"; +import { requireAdminAccess } from "../../../util/access.js"; + +export const registerGetQuery: RegisterFn = (server, ctx, opts) => { + if (!opts.allowAdminTools) return; + const schema = z.object({ + id: z.number().int().positive().describe("Query ID"), + }); + + server.registerTool( + "discourse_get_query", + { + title: "Get Data Explorer Query", + description: + "Get full details of a Data Explorer query including SQL and parameters. Requires admin API key.", + inputSchema: schema.shape, + }, + async (input: unknown, _extra: unknown) => { + try { + const { id } = schema.parse(input); + + const accessError = requireAdminAccess(ctx.siteState); + if (accessError) return accessError; + + const { client } = ctx.siteState.ensureSelectedSite(); + + const data = (await client.get( + `/admin/plugins/explorer/queries/${id}.json` + )) as any; + + const query = data?.query || data; + return jsonResponse(transformQueryDetail(query)); + } catch (e: unknown) { + if (isZodError(e)) return zodError(e); + const err = e as any; + return jsonError(`Failed to get query: ${err?.message || String(e)}`); + } + } + ); +}; diff --git a/src/tools/builtin/data_explorer/index.ts b/src/tools/builtin/data_explorer/index.ts new file mode 100644 index 0000000..2f97823 --- /dev/null +++ b/src/tools/builtin/data_explorer/index.ts @@ -0,0 +1,5 @@ +export { registerGetQuery } from "./get_query.js"; +export { registerRunQuery } from "./run_query.js"; +export { registerCreateQuery } from "./create_query.js"; +export { registerUpdateQuery } from "./update_query.js"; +export { registerDeleteQuery } from "./delete_query.js"; diff --git a/src/tools/builtin/data_explorer/run_query.ts b/src/tools/builtin/data_explorer/run_query.ts new file mode 100644 index 0000000..cae75c8 --- /dev/null +++ b/src/tools/builtin/data_explorer/run_query.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import type { RegisterFn } from "../../types.js"; +import { + jsonResponse, + jsonError, + isZodError, + zodError, + type QueryRunResult, +} from "../../../util/json_response.js"; +import { requireAdminAccess } from "../../../util/access.js"; + +export const registerRunQuery: RegisterFn = (server, ctx, opts) => { + if (!opts.allowAdminTools) return; + const schema = z.object({ + id: z.number().int().describe("Query ID to run"), + params: z + .record(z.string(), z.unknown()) + .optional() + .describe("Query parameters as key-value pairs"), + limit: z + .union([z.number().int().positive(), z.literal("ALL")]) + .optional() + .describe( + "Maximum number of rows to return (default: query default, use 'ALL' for unlimited)", + ), + explain: z + .boolean() + .optional() + .describe("Include query execution plan in response"), + }); + + server.registerTool( + "discourse_run_query", + { + title: "Run Data Explorer Query", + description: + "Execute a Data Explorer query with parameters. Returns columns, rows, result_count, duration_ms. Queries run in read-only transactions with 10-second timeout. Requires admin API key.", + inputSchema: schema.shape, + }, + async (input: unknown, _extra: unknown) => { + try { + const { id, params, limit, explain } = schema.parse(input); + + const accessError = requireAdminAccess(ctx.siteState); + if (accessError) return accessError; + + const { client } = ctx.siteState.ensureSelectedSite(); + + const payload: Record = {}; + if (params && Object.keys(params).length > 0) { + payload.params = JSON.stringify(params); + } + if (limit !== undefined) { + payload.limit = limit; + } + if (explain) { + payload.explain = true; + } + + const data = (await client.post( + `/admin/plugins/explorer/queries/${id}/run.json`, + payload, + )) as any; + + const result: QueryRunResult = { + columns: Array.isArray(data?.columns) ? data.columns : [], + rows: Array.isArray(data?.rows) ? data.rows : [], + result_count: data?.result_count ?? data?.rows?.length ?? 0, + duration_ms: data?.duration ?? 0, + }; + + if (data?.explain) { + result.explain = data.explain; + } + + if (data?.relations && Object.keys(data.relations).length > 0) { + result.relations = data.relations; + } + + return jsonResponse(result); + } catch (e: unknown) { + if (isZodError(e)) return zodError(e); + const err = e as any; + return jsonError(`Failed to run query: ${err?.message || String(e)}`); + } + }, + ); +}; diff --git a/src/tools/builtin/data_explorer/update_query.ts b/src/tools/builtin/data_explorer/update_query.ts new file mode 100644 index 0000000..03e3aed --- /dev/null +++ b/src/tools/builtin/data_explorer/update_query.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; +import type { RegisterFn } from "../../types.js"; +import { + jsonResponse, + jsonError, + isZodError, + zodError, + rateLimit, + transformQueryDetail, +} from "../../../util/json_response.js"; +import { requireAdminAccess } from "../../../util/access.js"; + +export const registerUpdateQuery: RegisterFn = (server, ctx, opts) => { + if (!opts.allowAdminTools || !opts.allowWrites) return; + + const schema = z.object({ + id: z.number().int().positive().describe("Query ID to update"), + name: z.string().min(1).max(255).optional().describe("New query name"), + sql: z + .string() + .min(1) + .optional() + .describe("New SQL query"), + description: z.string().optional().describe("New query description"), + group_ids: z + .array(z.number().int()) + .optional() + .describe("New group IDs allowed to run this query"), + }); + + server.registerTool( + "discourse_update_query", + { + title: "Update Data Explorer Query", + description: + "Update an existing Data Explorer query. Only provided fields are updated. Requires admin API key and write access.", + inputSchema: schema.shape, + }, + async (input: unknown, _extra: unknown) => { + try { + const { id, name, sql, description, group_ids } = schema.parse(input); + + const accessError = requireAdminAccess(ctx.siteState); + if (accessError) return accessError; + + await rateLimit("query"); + + const { client } = ctx.siteState.ensureSelectedSite(); + + const queryUpdate: Record = {}; + if (name !== undefined) queryUpdate.name = name; + if (sql !== undefined) queryUpdate.sql = sql; + if (description !== undefined) queryUpdate.description = description; + if (group_ids !== undefined) queryUpdate.group_ids = group_ids; + + if (Object.keys(queryUpdate).length === 0) { + return jsonError("No fields to update"); + } + + const payload = { query: queryUpdate }; + + const data = (await client.put( + `/admin/plugins/explorer/queries/${id}.json`, + payload + )) as any; + + const query = data?.query || data; + return jsonResponse(transformQueryDetail(query)); + } catch (e: unknown) { + if (isZodError(e)) return zodError(e); + const err = e as any; + return jsonError(`Failed to update query: ${err?.message || String(e)}`); + } + } + ); +}; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 73e8a45..b246273 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -22,6 +22,13 @@ import { registerSaveDraft, registerDeleteDraft, } from "./builtin/drafts.js"; +import { + registerGetQuery, + registerRunQuery, + registerCreateQuery, + registerUpdateQuery, + registerDeleteQuery, +} from "./builtin/data_explorer/index.js"; // Note: The following tools have been replaced by MCP Resources (v0.2.0): // - discourse_list_categories → discourse://site/categories @@ -83,4 +90,11 @@ export async function registerAllTools( registerUploadFile(server, ctx, { allowWrites: opts.allowWrites }); registerSaveDraft(server, ctx, { allowWrites: opts.allowWrites }); registerDeleteDraft(server, ctx, { allowWrites: opts.allowWrites }); + + // Data Explorer tools (admin-only) + registerGetQuery(server, ctx, { allowWrites: false, allowAdminTools: opts.allowAdminTools }); + registerRunQuery(server, ctx, { allowWrites: false, allowAdminTools: opts.allowAdminTools }); + registerCreateQuery(server, ctx, { allowWrites: opts.allowWrites, allowAdminTools: opts.allowAdminTools }); + registerUpdateQuery(server, ctx, { allowWrites: opts.allowWrites, allowAdminTools: opts.allowAdminTools }); + registerDeleteQuery(server, ctx, { allowWrites: opts.allowWrites, allowAdminTools: opts.allowAdminTools }); } diff --git a/src/util/json_response.ts b/src/util/json_response.ts index 0eaa88d..666f7f4 100644 --- a/src/util/json_response.ts +++ b/src/util/json_response.ts @@ -283,3 +283,63 @@ export function transformDraft(raw: any): LeanDraft { reply_preview: replyPreview, }; } + +/** + * Data Explorer types and transforms. + * Used for query management. + */ + +export interface LeanQuery { + id: number; + name: string; + description: string | null; + username: string | null; + group_ids: number[]; + last_run_at: string | null; +} + +export interface LeanQueryDetail extends LeanQuery { + sql: string; + param_info: Array<{ + identifier: string; + type: string; + default: string | null; + nullable: boolean; + }>; +} + +export function transformQuery(raw: any): LeanQuery { + return { + id: raw.id, + name: raw.name || "", + description: raw.description || null, + username: raw.username || raw.user?.username || null, + group_ids: Array.isArray(raw.group_ids) ? raw.group_ids : [], + last_run_at: raw.last_run_at || null, + }; +} + +export function transformQueryDetail(raw: any): LeanQueryDetail { + const base = transformQuery(raw); + return { + ...base, + sql: raw.sql || "", + param_info: Array.isArray(raw.param_info) + ? raw.param_info.map((p: any) => ({ + identifier: p.identifier || "", + type: p.type || "string", + default: p.default ?? null, + nullable: p.nullable === true, + })) + : [], + }; +} + +export interface QueryRunResult { + columns: string[]; + rows: unknown[][]; + result_count: number; + duration_ms: number; + explain?: string; + relations?: Record; +}