Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## [0.3.1](https://github.com/discourse/discourse-mcp/compare/v0.3.0...v0.3.1) (2026-08-25)

### Changed

* Simplify write-mode opt-in and deprecate `read_only=false`
- `--allow_writes` now enables mutation tools by itself; the redundant `--read_only=false` CLI/profile setting is deprecated, has no effect, and emits an informational migration notice
- Migration note: an existing command or profile with `allow_writes=true` and no `read_only` value previously remained read-only; it now enables mutation tools as its name indicates
- Keep writes disabled when `allow_writes` is omitted or false, and retain toolset selection, authentication, authorization, confirmation, and call-time access checks unchanged

### Breaking Changes

* Reject contradictory `allow_writes=true` and `read_only=true` configuration at startup instead of silently hiding mutation tools; remove `read_only=true` to enable writes, or remove `allow_writes=true` to remain read-only

## [0.3.0](https://github.com/discourse/discourse-mcp/compare/v0.2.9...v0.3.0) (2026-08-21)

### Features
Expand Down
46 changes: 22 additions & 24 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@discourse/mcp",
"mcpName": "io.github.discourse/mcp",
"version": "0.3.0",
"version": "0.3.1",
"description": "Discourse MCP CLI server (stdio) exposing Discourse tools via MCP",
"author": "Discourse",
"license": "MIT",
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
"url": "https://github.com/discourse/discourse-mcp",
"source": "github"
},
"version": "0.3.0",
"version": "0.3.1",
"packages": [
{
"registryType": "npm",
"identifier": "@discourse/mcp",
"version": "0.3.0",
"version": "0.3.1",
"transport": {
"type": "stdio"
}
Expand Down
16 changes: 11 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ Configuration:
--profile <path> JSON profile (~, ~/ and ~\\ expand for the current user)
--site <url> Tether and preselect one Discourse site
--auth_pairs <json> Site-specific API/User API credentials
--read_only <boolean> Keep mutation tools disabled (default: true)
--allow_writes <boolean> Allow writes when read_only=false
--allow_writes <boolean> Enable mutation tools (default: false)
--read_only <boolean> Deprecated: true vetoes writes; false has no effect
--tools_mode <mode> auto, discourse_api_only, or tool_exec_api
--toolsets <domains> Comma-separated built-in domains or all
Includes opt-in administration, groups, tag_groups,
Expand Down Expand Up @@ -143,7 +143,7 @@ const ProfileSchema = z
.strict()
)
.optional(),
read_only: z.boolean().optional().default(true),
read_only: z.boolean().optional(),
allow_writes: z.boolean().optional().default(false),
timeout_ms: z.number().int().positive().optional().default(DEFAULT_TIMEOUT_MS),
concurrency: z.number().int().positive().optional().default(4),
Expand Down Expand Up @@ -230,7 +230,7 @@ function parseAllowedUploadPaths(value: unknown, source: string): string[] | und
function mergeConfig(profile: Partial<Profile>, flags: Record<string, unknown>): Profile {
const merged = {
auth_pairs: parseAuthPairs(flags.auth_pairs ?? flags["auth-pairs"], "from CLI") ?? parseAuthPairs(profile.auth_pairs, "from profile"),
read_only: ((flags.read_only ?? flags["read-only"]) as boolean | undefined) ?? profile.read_only ?? true,
read_only: ((flags.read_only ?? flags["read-only"]) as boolean | undefined) ?? profile.read_only,
allow_writes: ((flags.allow_writes ?? flags["allow-writes"]) as boolean | undefined) ?? profile.allow_writes ?? false,
timeout_ms: ((flags.timeout_ms ?? flags["timeout-ms"]) as number | undefined) ?? profile.timeout_ms ?? DEFAULT_TIMEOUT_MS,
concurrency: (flags.concurrency as number | undefined) ?? profile.concurrency ?? 4,
Expand All @@ -248,6 +248,9 @@ function mergeConfig(profile: Partial<Profile>, flags: Record<string, unknown>):
} satisfies Profile;
const result = ProfileSchema.safeParse(merged);
if (!result.success) throw new Error(`Invalid configuration: ${result.error.message}`);
if (result.data.allow_writes && result.data.read_only === true) {
throw new Error("Invalid configuration: allow_writes=true conflicts with read_only=true (from CLI or profile); remove read_only=true to enable writes, or remove allow_writes=true to remain read-only");
}
return result.data;
}

Expand Down Expand Up @@ -303,6 +306,9 @@ async function main() {
const config = mergeConfig(profile, argv);

const logger = new Logger(config.log_level);
if (config.read_only === false) {
logger.info("Deprecated configuration: read_only=false is no longer required and has no effect; use allow_writes=true to enable mutation tools.");
}
const auth = buildAuth(config);

// Meta log (stderr) without leaking secrets
Expand Down Expand Up @@ -335,7 +341,7 @@ async function main() {
}
);

const allowWrites = Boolean(config.allow_writes && !config.read_only);
const allowWrites = Boolean(config.allow_writes);
const showEmails = Boolean(config.show_emails);

// If tethered to a site, validate and preselect it before registering tools,
Expand Down
16 changes: 16 additions & 0 deletions src/test/cli_info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ test("all top-level help spellings describe current options and never start a tr
"--max-read-length", "--allowed_upload_paths", "--show_emails", "--transport",
"--port", "--log_level", "--cache_dir", "generate-user-api-key",
]) assert.match(result.stdout, new RegExp(option.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(result.stdout, /--allow_writes <boolean>\s+Enable mutation tools/);
assert.match(result.stdout, /--read_only <boolean>\s+Deprecated: true vetoes writes/);
assert.doesNotMatch(result.stdout, /allow_writes when read_only=false/);
assert.doesNotMatch(result.stdout, /listening on/);
}
});
Expand All @@ -54,6 +57,19 @@ test("top-level metadata scanning stops at -- and does not steal subcommand help
assert.match(stopped.stderr, /Starting Discourse MCP/);
});

test("profile-sourced contradictory write settings fail startup", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "discourse-mcp-write-mode-"));
const profilePath = path.join(directory, "profile.json");
try {
await writeFile(profilePath, JSON.stringify({ allow_writes: true, read_only: true }));
const result = run(["--profile", profilePath]);
assert.equal(result.status, 1);
assert.match(result.stderr, /allow_writes=true conflicts with read_only=true \(from CLI or profile\)/);
} finally {
await rm(directory, { recursive: true, force: true });
}
});

test("profile loading expands current-user ~/ paths while preserving validation wrapping", async () => {
const home = await mkdtemp(path.join(tmpdir(), "discourse-mcp-home-"));
try {
Expand Down
82 changes: 82 additions & 0 deletions src/test/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,50 @@ async function postMcp(
}, headers);
}

async function listToolsFromServer(args: string[]): Promise<{ names: string[]; stderr: string }> {
const port = await getFreePort();
const indexPath = path.resolve(__dirname, '../../dist/index.js');
const serverProcess = spawn('node', [
indexPath,
'--transport', 'http',
'--port', String(port),
'--tools_mode', 'discourse_api_only',
...args,
], {
stdio: ['ignore', 'pipe', 'pipe']
});
const headers = { Host: `127.0.0.1:${port}` };
let stderr = '';
serverProcess.stderr?.setEncoding('utf8');
serverProcess.stderr?.on('data', (chunk) => { stderr += chunk; });

try {
const ready = await waitForServer(port);
assert.ok(ready, 'Server should start');
const initialization = await postMcp(port, headers);
assert.equal(initialization.statusCode, 200);
assert.ok(initialization.sessionId);

const response = await postMcpRequest(port, {
jsonrpc: '2.0',
id: 2,
method: 'tools/list',
params: {},
}, { ...headers, 'Mcp-Session-Id': initialization.sessionId });
assert.equal(response.statusCode, 200);
const body = JSON.parse(response.body) as {
result?: { tools?: Array<{ name: string }> };
};
return {
names: body.result?.tools?.map((tool) => tool.name) ?? [],
stderr,
};
} finally {
serverProcess.kill('SIGTERM');
await new Promise(resolve => setTimeout(resolve, 100));
}
}

test('HTTP transport starts on specified port', async () => {
const port = await getFreePort();
const indexPath = path.resolve(__dirname, '../../dist/index.js');
Expand Down Expand Up @@ -190,6 +234,44 @@ test('invalid CLI toolsets fail startup with actionable output', () => {
assert.match(result.stderr, /data_explorer/);
});

test('conflicting write-mode flags fail startup with actionable output', () => {
const indexPath = path.resolve(__dirname, '../../dist/index.js');
const result = spawnSync('node', [indexPath, '--allow_writes', '--read_only=true'], {
encoding: 'utf8',
});

assert.equal(result.status, 1);
assert.match(result.stderr, /allow_writes=true conflicts with read_only=true/);
});

test('--allow_writes alone registers mutation tools', async () => {
const { names } = await listToolsFromServer([
'--log_level', 'silent',
'--toolsets', 'themes',
'--allow_writes',
]);
assert.ok(names.includes('discourse_install_theme'));
assert.ok(names.includes('discourse_update_theme'));
});

test('legacy read_only values remain safe while false emits a deprecation notice', async () => {
const veto = await listToolsFromServer([
'--log_level', 'silent',
'--toolsets', 'themes',
'--read_only=true',
]);
assert.ok(!veto.names.includes('discourse_install_theme'));
assert.ok(veto.names.includes('discourse_get_theme'));

const deprecated = await listToolsFromServer([
'--log_level', 'info',
'--toolsets', 'themes',
'--read_only=false',
]);
assert.ok(!deprecated.names.includes('discourse_install_theme'));
assert.match(deprecated.stderr, /read_only=false is no longer required and has no effect/);
});

test('CLI toolsets override profile toolsets and are reflected by MCP tools/list', async () => {
const port = await getFreePort();
const indexPath = path.resolve(__dirname, '../../dist/index.js');
Expand Down
2 changes: 1 addition & 1 deletion src/util/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function requireAuthenticatedAccess(siteState: SiteState) {

export function requireWriteAccess(siteState: SiteState, allowWrites: boolean) {
if (!allowWrites) {
return jsonError("Writes are disabled. Run with --allow_writes --read_only=false to enable.");
return jsonError("Writes are disabled. Run with --allow_writes to enable.");
}
return requireSiteAuth(siteState, "any");
}
Expand Down
Loading