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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## [0.2.8](https://github.com/discourse/discourse-mcp/compare/v0.2.7...v0.2.8) (2026-05-11)

### Bug Fixes

* Support Discourse installations served from a subfolder such as `https://example.com/forum`
- Preserve the path component when normalizing `--site` and `auth_pairs` site URLs
- Route leading-slash API paths like `/about.json` and `/search.json` under the configured subfolder
- Keep root-site behavior unchanged for sites hosted at the domain root

## [0.2.7](https://github.com/discourse/discourse-mcp/compare/v0.2.6...v0.2.7) (2026-03-31)

### Features
Expand Down
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.2.7",
"version": "0.2.8",
"description": "Discourse MCP CLI server (stdio) exposing Discourse tools via MCP",
"author": "Discourse",
"license": "MIT",
Expand Down
16 changes: 14 additions & 2 deletions src/http/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ export class HttpClient {

constructor(private opts: HttpClientOptions) {
this.base = new URL(opts.baseUrl);
if (!this.base.pathname.endsWith("/")) {
this.base.pathname += "/";
}
}

private urlFor(path: string): string {
if (/^https?:\/\//i.test(path)) {
return new URL(path).toString();
}

const relativePath = path.replace(/^\/+/, "");
return new URL(relativePath, this.base).toString();
}

private headers(): Record<string, string> {
Expand Down Expand Up @@ -54,7 +66,7 @@ export class HttpClient {
}

async getCached(path: string, ttlMs: number, { signal }: { signal?: AbortSignal } = {}) {
const url = new URL(path, this.base).toString();
const url = this.urlFor(path);
const entry = this.cache.get(url);
const now = Date.now();
if (entry && entry.expiresAt > now) return entry.value;
Expand Down Expand Up @@ -103,7 +115,7 @@ export class HttpClient {
}

private async executeRequest(method: string, path: string, body: BodyInit | undefined, headers: Record<string, string>, signal?: AbortSignal, allowRetries = true) {
const url = new URL(path, this.base).toString();
const url = this.urlFor(path);
this.opts.logger.debug(`HTTP ${method} ${url}`);

const controller = new AbortController();
Expand Down
4 changes: 3 additions & 1 deletion src/site/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ export type AuthOverride = {

function normalizeBase(url: string): string {
const u = new URL(url);
u.pathname = "/";
u.search = "";
u.hash = "";
if (u.pathname !== "/") {
u.pathname = u.pathname.replace(/\/+$/, "");
}
return u.toString().replace(/\/$/, "");
}

Expand Down
148 changes: 148 additions & 0 deletions src/test/site_http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Logger } from '../util/logger.js';
import { HttpClient } from '../http/client.js';
import { SiteState } from '../site/state.js';
import { registerAllTools, type RegistryOptions } from '../tools/registry.js';
import type { ToolRegistrar } from '../tools/types.js';

interface ToolResult {
isError?: boolean;
content?: Array<{ type: string; text: string }>;
}

type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<ToolResult>;

function createMockServer(): { server: ToolRegistrar; tools: Record<string, { handler: ToolHandler }> } {
const tools: Record<string, { handler: ToolHandler }> = {};
const server = {
registerTool(name: string, _meta: Record<string, unknown>, handler: ToolHandler) {
tools[name] = { handler };
},
} as ToolRegistrar;
return { server, tools };
}

function mockJsonFetch(calls: string[]) {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, _init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
calls.push(url);
if (url.endsWith('/about.json')) {
return new Response(JSON.stringify({ about: { title: 'Example Discourse' } }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.includes('/search.json')) {
return new Response(JSON.stringify({ topics: [{ id: 123, title: 'Hello World', slug: 'hello-world' }] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.endsWith('/site.json')) {
return new Response(JSON.stringify({ site: { title: 'Example Discourse' } }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
return new Response('not found', { status: 404 });
}) as typeof fetch;
return () => {
globalThis.fetch = originalFetch;
};
}

test('SiteState preserves and normalizes subfolder base paths', () => {
const logger = new Logger('silent');
const siteState = new SiteState({ logger, timeoutMs: 5000, defaultAuth: { type: 'none' } });

const first = siteState.buildClientForSite('https://example.com/forum');
const second = siteState.buildClientForSite('https://example.com/forum/');

assert.equal(first.base, 'https://example.com/forum');
assert.equal(second.base, 'https://example.com/forum');
assert.equal(first.client, second.client);
});

test('HttpClient routes leading-slash paths under subfolder base', async () => {
const calls: string[] = [];
const restoreFetch = mockJsonFetch(calls);
const logger = new Logger('silent');
const client = new HttpClient({ baseUrl: 'https://example.com/forum', timeoutMs: 5000, logger, auth: { type: 'none' } });

try {
await client.get('/about.json');
assert.equal(calls[0], 'https://example.com/forum/about.json');
} finally {
restoreFetch();
}
});

test('HttpClient root base still routes leading-slash paths from origin root', async () => {
const calls: string[] = [];
const restoreFetch = mockJsonFetch(calls);
const logger = new Logger('silent');
const client = new HttpClient({ baseUrl: 'https://example.com', timeoutMs: 5000, logger, auth: { type: 'none' } });

try {
await client.get('/about.json');
assert.equal(calls[0], 'https://example.com/about.json');
} finally {
restoreFetch();
}
});

test('HttpClient getCached cache key preserves subfolder base path', async () => {
const calls: string[] = [];
const restoreFetch = mockJsonFetch(calls);
const logger = new Logger('silent');
const client = new HttpClient({ baseUrl: 'https://example.com/forum', timeoutMs: 5000, logger, auth: { type: 'none' } });

try {
await client.getCached('/site.json', 60_000);
await client.getCached('/site.json', 60_000);
assert.deepEqual(calls, ['https://example.com/forum/site.json']);
} finally {
restoreFetch();
}
});

test('select-site then search flow preserves subfolder base path', async () => {
const logger = new Logger('silent');
const siteState = new SiteState({ logger, timeoutMs: 5000, defaultAuth: { type: 'none' } });
const { server, tools } = createMockServer();
const calls: string[] = [];
const restoreFetch = mockJsonFetch(calls);

try {
await registerAllTools(server, siteState, logger, { allowWrites: false, toolsMode: 'discourse_api_only' } satisfies RegistryOptions);

const selectRes = await tools['discourse_select_site'].handler({ site: 'https://example.com/forum' }, {});
assert.equal(selectRes?.isError, undefined);

const searchRes = await tools['discourse_search'].handler({ query: 'hello' }, {});
assert.equal(searchRes?.isError, undefined);

assert.equal(calls[0], 'https://example.com/forum/about.json');
assert.ok(calls[1]?.startsWith('https://example.com/forum/search.json?'));
} finally {
restoreFetch();
}
});

test('tethered validation then search preserves subfolder base path', async () => {
const logger = new Logger('silent');
const siteState = new SiteState({ logger, timeoutMs: 5000, defaultAuth: { type: 'none' } });
const { server, tools } = createMockServer();
const calls: string[] = [];
const restoreFetch = mockJsonFetch(calls);

try {
const { base, client } = siteState.buildClientForSite('https://example.com/forum');
await client.get('/about.json');
siteState.selectSite(base);

await registerAllTools(server, siteState, logger, { allowWrites: false, toolsMode: 'discourse_api_only', hideSelectSite: true } satisfies RegistryOptions);
assert.ok(!('discourse_select_site' in tools));

const searchRes = await tools['discourse_search'].handler({ query: 'hello' }, {});
assert.equal(searchRes?.isError, undefined);

assert.equal(calls[0], 'https://example.com/forum/about.json');
assert.ok(calls[1]?.startsWith('https://example.com/forum/search.json?'));
} finally {
restoreFetch();
}
});
2 changes: 1 addition & 1 deletion src/tools/builtin/data_explorer/run_query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from "../../../util/json_response.js";
import { requireAdminAccess } from "../../../util/access.js";

export const registerRunQuery: RegisterFn = (server, ctx, opts) => {
export const registerRunQuery: RegisterFn = (server, ctx, _opts) => {
const schema = z.object({
id: z.number().int().describe("Query ID to run"),
params: z
Expand Down
Loading