Skip to content

Commit 9358e65

Browse files
committed
feat(ai): add administration toolsets
Add opt-in domains for managing AI agents, scripted custom tools, and AI feature settings with admin and write safeguards. Include compact discovery views, import/export support, custom-tool authoring guidance, and conditional resource registration. Prevent sensitive HTTP response bodies from reaching logs and handle empty 204 responses from delete operations.
1 parent 79473c2 commit 9358e65

20 files changed

Lines changed: 1134 additions & 16 deletions

File tree

README.md

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,13 @@ The server registers tools under the MCP server name `@discourse/mcp`. Choose a
8888
- `--timeout_ms <number>` (default: 15000)
8989
- `--concurrency <number>` (default: 4)
9090
- `--log_level <silent|error|info|debug>` (default: info)
91-
- `debug`: Shows all HTTP requests, responses, and detailed error information
91+
- `debug`: Shows HTTP request URLs, statuses, and detailed network/retry information (response bodies are never logged because admin APIs may echo sensitive content)
9292
- `info`: Shows retry attempts and general operational messages
9393
- `error`: Shows only errors
9494
- `silent`: No logging output
9595
- `--show_emails` (default: false). includes emails in user tools. Requires admin access
9696
- `--tools_mode <auto|discourse_api_only|tool_exec_api>` (default: auto)
97-
- `--toolsets <name[,name...]>`: Expose selected built-in domains. Omit for the default catalog (all non-opt-in domains); use `--toolsets all` to include opt-in domains such as `workflows`. See [Built-in toolsets](#built-in-toolsets).
97+
- `--toolsets <name[,name...]>`: Expose selected built-in domains. Omit for the default catalog (all non-opt-in domains); use `--toolsets all` to include opt-in workflows and AI administration domains. See [Built-in toolsets](#built-in-toolsets).
9898
- `--site <url>`: Tether MCP to a single site and hide `discourse_select_site`.
9999
- `--default-search <prefix>`: Unconditionally prefix every search query (e.g., `tag:ai order:latest`).
100100
- `--max-read-length <number>`: Maximum characters returned for post content (default 50000). Applies to `discourse_read_post` and per-post content in `discourse_read_topic`. The tools prefer `raw` content by requesting `include_raw=true`.
@@ -151,7 +151,7 @@ Flags still override values from the profile.
151151

152152
### Built-in toolsets
153153

154-
Toolsets let an operator expose only the built-in domains needed by an MCP client. They are optional: when `--toolsets` and the profile field are both omitted, the server registers the default catalog (all non-opt-in domains). The experimental admin-only `workflows` domain is opt-in. Use `--toolsets all` to explicitly load every domain.
154+
Toolsets let an operator expose only the built-in domains needed by an MCP client. They are optional: when `--toolsets` and the profile field are both omitted, the server registers the default catalog (all non-opt-in domains). The admin-only `workflows`, `ai_agents`, `ai_custom_tools`, and `ai_features` domains are opt-in. Use `--toolsets all` to explicitly load every domain.
155155

156156
Pass one name or a comma-separated union:
157157

@@ -201,6 +201,9 @@ Available toolsets are:
201201
| `uploads` | File upload |
202202
| `data_explorer` | Query retrieval, execution, creation, update, and deletion |
203203
| `workflows` *(opt-in)* | Admin-only workflow discovery, graph authoring, expression evaluation, pin-data, draft runs, step runs, executions, and version management |
204+
| `ai_agents` *(opt-in)* | Admin-only AI agent discovery, typed lifecycle, bot-user creation, and portable import/export |
205+
| `ai_custom_tools` *(opt-in)* | Admin-only database-backed scripted custom-tool guide, lifecycle, actual execution testing, and import/export |
206+
| `ai_features` *(opt-in)* | Admin-only AI feature discovery and exact-area, non-secret feature-setting updates; also includes agent discovery |
204207
| `all` *(sentinel)* | Expands to every built-in toolset, including opt-in domains; absorbs other selections |
205208

206209
Toolset membership is intentionally separate from safety and authorization:
@@ -227,6 +230,32 @@ The `workflows` toolset targets the experimental `discourse-workflows` plugin (`
227230

228231
Flat connections such as `[{"from":"Start","to":"Check","type":"main"}]` are accepted and converted to Discourse's nested wire format. Use the source node's catalog output key: condition/filter ports are `true` and `false`, not always `main`. MCP rejects one-sided graph updates before HTTP. Runs are not dry-runs and can create posts, send chat messages, or call external HTTP.
229232

233+
#### Discourse AI administration
234+
235+
The three AI administration domains require a Discourse admin API key (or an admin user API key accepted by the selected endpoint). They are independently opt-in and default-off. Mutations—and custom-tool test execution—also require both `--allow_writes` and `--read_only=false`.
236+
237+
```bash
238+
# Configure agents without exposing scripted source management
239+
npx -y @discourse/mcp@latest --site https://forum.example.com \
240+
--toolsets ai_agents --tools_mode discourse_api_only \
241+
--auth_pairs '[{"site":"https://forum.example.com","api_key":"...","api_username":"system"}]' \
242+
--allow_writes --read_only=false
243+
244+
# Assign agents and update safe feature settings, but do not expose custom-tool code editing
245+
npx -y @discourse/mcp@latest --site https://forum.example.com \
246+
--toolsets ai_agents,ai_features --tools_mode discourse_api_only \
247+
--auth_pairs '[{"site":"https://forum.example.com","api_key":"...","api_username":"system"}]' \
248+
--allow_writes --read_only=false
249+
```
250+
251+
The agent index is intentionally concise by default: `discourse_ai_list_agents` omits system prompts and per-agent configuration, returning summary counts plus slim tool/model catalogs. Use `discourse_ai_get_agent` with an ID to inspect one full configuration. `view: "full"` is available only for clients that explicitly need the complete upstream index.
252+
253+
`discourse_ai_list_custom_tools` follows the same pattern: it returns compact records and preset signatures without scripts, bindings, or verbose parameter documentation. Use `discourse_ai_get_custom_tool` for one stored tool, or call the guide with `topic: "presets"` and a `preset_id` for one complete preset example.
254+
255+
`ai_custom_tools` manages Discourse's database-backed `AiTool` records. It is separate from remote tools dynamically discovered at `/ai/tools`, which remain controlled by `--tools_mode`. Script authoring is synchronous MiniRacer JavaScript: define `invoke(parameters)`; do not use `async`, browser APIs, or Node modules. Call `discourse_ai_get_custom_tool_guide` with only the focused `topic` you need. `preset_id` is optional and meaningful only for `topic: "presets"`; it is ignored for other topics. Use `topic: "preamble"` for the exact selected-server contract before creating or substantially changing a script. The same exact live preamble and minimal template is exposed as the conditional `discourse://ai/custom-tools/authoring-guide` resource when this toolset is selected. Resources are application-driven; the guide tool is model-controlled, so autonomous clients should use the tool rather than assume a host attached the resource. A future optional authoring prompt would be user-controlled and would guide an explicitly initiated workflow rather than replace model-callable discovery.
256+
257+
**Safety:** `discourse_ai_test_custom_tool` actually executes code and can issue external requests or cause site side effects. Feature updates alter production behavior immediately and are limited to non-secret settings returned from one exact `ai-features/<module>` area. Custom-tool source, prompts, bindings, exports, and test parameters should be treated as sensitive. Use the narrowest toolset combination and test on a non-production site first.
258+
230259
- **Remote Tool Execution API (optional)**
231260

232261
- With `tools_mode=auto` (default) or `tool_exec_api`, the server discovers remote tools via GET `/ai/tools` after you select a site (or immediately at startup if `--site` is provided) and registers them dynamically. Set `--tools_mode=discourse_api_only` to disable remote tool discovery.
@@ -278,6 +307,12 @@ Resources provide static/semi-static read-only data via URI addressing. Use thes
278307
- Output: `{ drafts: [{draft_key, sequence, title, category_id, created_at, reply_preview}], meta: {total} }`
279308
- Requires authentication
280309

310+
- **discourse://ai/custom-tools/authoring-guide** *(conditional)*
311+
- Registered only when `ai_custom_tools` is selected (including through `all`)
312+
- Returns the exact selected-site `empty_tool` JavaScript preset: Discourse's current preamble plus minimal `invoke`/`details` template
313+
- MIME type: `text/javascript`; requires selected-site admin credentials
314+
- Applications may attach this resource; models can retrieve the same content with `discourse_ai_get_custom_tool_guide` and `topic: "preamble"`
315+
281316
## Tools
282317

283318
Built‑in tools (always present unless noted). All tools return **strict JSON** (no Markdown).
@@ -540,7 +575,7 @@ You can also manually create User API Keys via the Discourse UI (if enabled by t
540575
- **Should I use Admin API Keys or User API Keys?** Use User API Keys for personal use (no admin required). Use Admin API Keys only when you need admin-level operations or are setting up a system-wide integration.
541576
- **Getting "fetch failed" errors?** Run with `--log_level debug` to see detailed error information including:
542577
- The exact URL being requested
543-
- HTTP status codes and response bodies
578+
- HTTP status codes (response bodies are deliberately not logged because they may contain sensitive content)
544579
- Network-level errors (DNS, SSL/TLS, connectivity issues)
545580
- Retry attempts and timing
546581
- Timeout diagnostics

src/http/client.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,13 @@ export class HttpClient {
136136
if (!res.ok) {
137137
const text = await safeText(res);
138138
const errorBody = safeJson(text);
139-
this.opts.logger.error(`HTTP ${res.status} ${res.statusText} for ${method} ${url}: ${text}`);
139+
// Keep response bodies available on HttpError for structured client
140+
// errors, but never write them to logs: admin endpoints can echo
141+
// prompts, scripts, bindings, import bundles, or test parameters.
142+
this.opts.logger.error(`HTTP ${res.status} ${res.statusText} for ${method} ${url}`);
140143
throw new HttpError(res.status, `HTTP ${res.status} ${res.statusText}`, errorBody);
141144
}
145+
if (res.status === 204) return {};
142146
const ct = res.headers.get("content-type") || "";
143147
if (ct.includes("application/json")) {
144148
return res.json();

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ const ProfileSchema = z
102102
log_level: z.enum(["silent", "error", "info", "debug"]).optional().default("info"),
103103
show_emails: z.boolean().optional().default(false),
104104
tools_mode: z.enum(["auto", "discourse_api_only", "tool_exec_api"]).optional().default("auto"),
105-
toolsets: BuiltinToolsetsSchema.optional().describe("Built-in tool domains to expose (array or comma-separated string). Omit for the default catalog; use all to include opt-in domains such as workflows."),
105+
toolsets: BuiltinToolsetsSchema.optional().describe("Built-in tool domains to expose (array or comma-separated string). Omit for the default catalog; use all to include opt-in workflows and AI administration domains."),
106106
site: z.string().url().optional().describe("Tether MCP to a single Discourse site; hides select_site and preselects this site"),
107107
default_search: z.string().optional().describe("Optional search prefix added to every search query (set via --default-search)"),
108108
max_read_length: z
@@ -307,7 +307,7 @@ async function main() {
307307
});
308308

309309
// Register MCP resources (URI-addressable read-only data)
310-
registerAllResources(server, { siteState, logger });
310+
registerAllResources(server, { siteState, logger }, { toolsets: config.toolsets });
311311

312312
// Register MCP prompts (guided workflows)
313313
registerAllPrompts(server, { siteState, logger });

src/resources/ai_custom_tools.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { requireAdminAccess } from "../util/access.js";
2+
import type { ResourceContext, ResourceRegistrar } from "./registry.js";
3+
import { GUIDE_RESOURCE_URI } from "../tools/builtin/ai_custom_tools/guide_data.js";
4+
import { loadLiveAuthoringGuide } from "../tools/builtin/ai_custom_tools/guide.js";
5+
6+
export function registerAiCustomToolsAuthoringGuideResource(server: ResourceRegistrar, ctx: ResourceContext): void {
7+
server.resource(
8+
"ai_custom_tools_authoring_guide",
9+
GUIDE_RESOURCE_URI,
10+
{ title: "Discourse AI Custom Tool Authoring Guide", description: "Authoritative selected-site JavaScript preamble and minimal MiniRacer tool template. Admin-only; intended for assistant authoring context.", mimeType: "text/javascript" },
11+
async (uri) => {
12+
const access = requireAdminAccess(ctx.siteState);
13+
if (access) throw new Error("Selected-site admin API credentials are required to read this resource.");
14+
try {
15+
const guide = await loadLiveAuthoringGuide(ctx.siteState);
16+
return { contents: [{ uri: uri.href, mimeType: "text/javascript", text: guide.text }] };
17+
} catch (error) {
18+
throw new Error(`Failed to load the live empty_tool preamble: ${error instanceof Error ? error.message : String(error)}`);
19+
}
20+
},
21+
);
22+
}

src/resources/registry.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
registerExplorerSchemaResource,
2828
registerExplorerQueriesResource,
2929
} from "./data_explorer.js";
30+
import { registerAiCustomToolsAuthoringGuideResource } from "./ai_custom_tools.js";
31+
import type { BuiltinToolsetMembership } from "../tools/toolsets.js";
3032

3133
/** Narrowed interface for resource registration - only requires resource method */
3234
export type ResourceRegistrar = Pick<McpServer, "resource">;
@@ -40,9 +42,14 @@ export interface ResourceContext {
4042
* Registers all MCP resources.
4143
* Resources are read-only, URI-addressable data endpoints.
4244
*/
45+
export interface ResourceRegistrationOptions {
46+
toolsets?: BuiltinToolsetMembership;
47+
}
48+
4349
export function registerAllResources(
4450
server: ResourceRegistrar,
45-
ctx: ResourceContext
51+
ctx: ResourceContext,
52+
opts: ResourceRegistrationOptions = {},
4653
): void {
4754
registerCategoriesResource(server, ctx);
4855
registerTagsResource(server, ctx);
@@ -54,6 +61,10 @@ export function registerAllResources(
5461
// Data Explorer resources are always registered; access is checked at call time
5562
registerExplorerSchemaResource(server, ctx);
5663
registerExplorerQueriesResource(server, ctx);
64+
65+
if (opts.toolsets?.includes("ai_custom_tools")) {
66+
registerAiCustomToolsAuthoringGuideResource(server, ctx);
67+
}
5768
}
5869

5970
/**

0 commit comments

Comments
 (0)