Skip to content
Draft
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
54 changes: 54 additions & 0 deletions docs/adr/0001-use-optional-url-argument-in-mcp-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 1. Use optional URL argument in MCP tool

Date: 2025-06-21
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this from exactly 3 months ago or did you typo?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I used the date on the issue #59


## Status

Accepted.

## Context

Currently, switching between wikis requires using the stateful `set-wiki` command. To get pages from two different wikis, one must do:

```
setWiki( 'foo.example.com' )
getPage( 'Main Page' )

setWiki( 'bar.example.com' )
getPage( 'Main Page' )
```

This is cumbersome for users and LLMs, especially in conversations involving multiple wikis where the LLM needs to track the currently active wiki, which can be prone to error.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is cumbersome for users

Why? The LLM can already handle this without the user specifying it repeatedly:

Screenshot_20250923_151139

Granted, it does make the UI a bit more cluttered, but I don't think the LLM prompt is any more or less cumbersome.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes the LLM did handle it perfectly in my tests as well, in fact it had never lose track of the state.

Personally I don't think it matters whether it's cumbersome for the user, because these tools are meant to be used by the LLM. The minor thing might be the LLM invoking the set tools which clutters the UI, but it is a minor issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So how did we end up with this sentence in the ADR? Is it AI-generated?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which can be prone to error.

Do we have evidence of multiple tool calls being more error prone? If we're worried about the LLM forgetting to call setWiki(), or calling it with the wrong URL, should we not be equally worried about it calling getPage() without a URL or with a wrong URL?

Or are we worried about our internal implementation somehow losing track of what is going on? (i.e. a bug)

I raise this not to object to the change itself, but to clarify the ADR. My feeling is the original issue really only solves the "problem" of making tools more flexible by not needing pre-configured wikis.

The current ADR wording makes it sound like we have specific problems (UX and error proneness - neither of which I think are necessarily present, or fully "fixed" here), and the solution is to implement someting that is optional and thus allows the original problems to still exist.

Perhaps I was expecting ADRs more along the lines of:

  • Decision: tools should be usable without config
  • Decision: tools should be stateless

Where this PR is a step in implementing those. And a future step might be to remove the current state completely.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seconded

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps I am confused about the issue itself, it'll be more beneficial if we re-align on this ADR.

Do we have evidence of multiple tool calls being more error prone?

No. I even tried to prompt the LLM to do the multi-wiki operations, and it still defers to the set wiki tool.

Are we worried about our internal implementation somehow losing track of what is going on?

No, that is not the case, the MCP server can maintain the state properly.

Decision: tools should be usable without config

The tools were already usable without the config, since we have a default config, and set wiki tools are always run before any other tools by the LLM.

Decision: tools should be stateless

That is another matter that needs more clarification. The tools are only used by the LLM, in order to be stateless, we would have to rely on the LLM to pass the state every time. I'm not sure if that is a better approach.


The proposed solution is to add an optional URL argument to commands, allowing for stateless calls:

```
getPage( 'Main Page', 'foo.example.com' )
getPage( 'Main Page', 'bar.example.com' )
```

This works smoothly for LLMs. If a user's initial prompt is "I have my test wiki at test.example.wiki and production at www.example.wiki", and they later say "copy the pages from the Foo category on my test wiki to production", the LLM can provide the correct arguments for all the calls without invoking extra tool calls, preventing potential errors.

## Decision

An optional `wikiUrl` argument will be added as the last parameter to all MCP tools that interact with a MediaWiki instance.

* If the `wikiUrl` argument is provided, the tool will target that wiki for the operation.
* If the argument is omitted, the tool will fall back to the wiki set by `set-wiki`, or a default wiki from the server configuration.
* The `set-wiki` tool will be retained for now as a convenience for sessions focused on a single wiki. Its long-term utility will be evaluated separately.

This decision considers the following use cases:
* **Single, locked-down wiki**: In environments like an internal corporate wiki, the wiki can be fixed in the MCP configuration. In this setup, the optional `wikiUrl` argument should be ignored.
* **Multiple wikis**: For a general-purpose "wiki helper chatbot" where wikis are not known in advance, this argument is essential.
* **Ad-hoc single wiki**: While `set-wiki` could serve this case, the optional argument is sufficient and avoids the complexity of maintaining state, especially since a single-wiki conversation can evolve to include multiple wikis.

## Consequences

### Positive
* Simplifies interactions involving multiple wikis.
* Reduces the need for the LLM to track the "current" wiki state.
* Makes interactions more robust and stateless.

### Negative
* Increases complexity in most tools. Extra code is needed to handle the current wiki.
* Requires a coordinated change across all relevant tool definitions.
9 changes: 8 additions & 1 deletion src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,17 @@
const configPath = process.env.CONFIG || 'config.json';

function loadConfigFromFile(): Config {
if ( !fs.existsSync( configPath ) ) {

Check warning on line 62 in src/common/config.ts

View workflow job for this annotation

GitHub Actions / lint

Found existsSync from package "fs" with non literal argument at index 0
return defaultConfig;
}
const rawData = fs.readFileSync( configPath, 'utf-8' );

Check warning on line 65 in src/common/config.ts

View workflow job for this annotation

GitHub Actions / lint

Found readFileSync from package "fs" with non literal argument at index 0
return JSON.parse( rawData ) as Config;
}

const config = loadConfigFromFile();
const defaultWiki = config.defaultWiki;
let currentConfig: WikiConfig = config.wikis[ defaultWiki ];
let currentWikiKey: string = defaultWiki;
let currentConfig: WikiConfig = config.wikis[ currentWikiKey ];

if ( !currentConfig ) {
throw new Error( `Default wiki "${ defaultWiki }" not found in config.json` );
Expand All @@ -82,10 +83,15 @@
return currentConfig;
}

export function getCurrentWikiKey(): string {
return currentWikiKey;
}

export function setCurrentWiki( wiki: string ): void {
if ( !config.wikis[ wiki ] ) {
throw new Error( `Wiki "${ wiki }" not found in config.json` );
}
currentWikiKey = wiki;
currentConfig = config.wikis[ wiki ];
}

Expand All @@ -98,6 +104,7 @@

export function resetConfig(): void {
if ( config.wikis[ defaultWiki ] ) {
currentWikiKey = defaultWiki;
currentConfig = config.wikis[ defaultWiki ];
} else {
throw new Error( `Default wiki "${ defaultWiki }" not found in config.json` );
Expand Down
6 changes: 6 additions & 0 deletions src/common/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class WikiDiscoveryError extends Error {
public constructor( message: string ) {
super( message );
this.name = 'WikiDiscoveryError';
}
}
169 changes: 169 additions & 0 deletions src/common/wikiDiscovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { makeApiRequest, fetchPageHtml } from './utils.js';
import { getAllWikis, updateWikiConfig } from './config.js';
import { WikiDiscoveryError } from './errors.js';

const COMMON_SCRIPT_PATHS = [ '/w', '' ];

// TODO: Move these types to a dedicated file if we end up using Action API types elsewhere
interface MediaWikiActionApiSiteInfoGeneral {
sitename: string;
articlepath: string;
scriptpath: string;
server: string;
servername: string;
// Omitted other fields for now since we don't use them
}

interface MediaWikiActionApiSiteInfoQuery {
general: MediaWikiActionApiSiteInfoGeneral;
}

interface MediaWikiActionApiResponse {
query?: MediaWikiActionApiSiteInfoQuery;
}

export interface WikiInfo {
sitename: string;
articlepath: string;
scriptpath: string;
server: string;
servername: string;
}

export async function resolveWiki( wikiUrl: string ): Promise<string> {
const url = new URL( wikiUrl );
const allWikis = getAllWikis();

if ( allWikis[ url.hostname ] ) {
return url.hostname;
}

const wikiServer = parseWikiUrl( wikiUrl );
const wikiInfo = await getWikiInfo( wikiServer, wikiUrl );

if ( wikiInfo !== null ) {
updateWikiConfig( wikiInfo.servername, {
sitename: wikiInfo.sitename,
server: wikiInfo.server,
articlepath: wikiInfo.articlepath,
scriptpath: wikiInfo.scriptpath
} );
return wikiInfo.servername;
} else {
throw new WikiDiscoveryError( 'Failed to determine wiki info. Please ensure the URL is correct and the wiki is accessible.' );
}
}

export function parseWikiUrl( wikiUrl: string ): string {
const url = new URL( wikiUrl );
return `${ url.protocol }//${ url.host }`;
}

export async function getWikiInfo(
wikiServer: string, originalWikiUrl: string
): Promise<WikiInfo | null> {
return ( await fetchUsingCommonScriptPaths( wikiServer ) ) ??
( await fetchUsingScriptPathsFromHtml( wikiServer, originalWikiUrl ) );
}

async function fetchWikiInfoFromApi(
wikiServer: string, scriptPath: string
): Promise<WikiInfo | null> {
const baseUrl = `${ wikiServer }${ scriptPath }/api.php`;
const params = {
action: 'query',
meta: 'siteinfo',
siprop: 'general',
format: 'json',
origin: '*'
};

let data: MediaWikiActionApiResponse | null = null;
try {
data = await makeApiRequest<MediaWikiActionApiResponse>( baseUrl, params );
} catch ( error ) {
console.error( `Error fetching wiki info from ${ baseUrl }:`, error );
return null;
}

if ( data === null || data.query?.general === undefined ) {
return null;
}

const general = data.query.general;

// We don't need to check for every field, the API should be returning the correct values.
if ( typeof general.scriptpath !== 'string' ) {
return null;
}

return {
sitename: general.sitename,
scriptpath: general.scriptpath,
articlepath: general.articlepath.replace( '/$1', '' ),
server: general.server,
servername: general.servername
};
}

async function fetchUsingCommonScriptPaths(
wikiServer: string
): Promise<WikiInfo | null> {
for ( const candidatePath of COMMON_SCRIPT_PATHS ) {
const apiResult = await fetchWikiInfoFromApi( wikiServer, candidatePath );
if ( apiResult ) {
return apiResult;
}
}
return null;
}

async function fetchUsingScriptPathsFromHtml(
wikiServer: string,
originalWikiUrl: string
): Promise<WikiInfo | null> {
const htmlContent = await fetchPageHtml( originalWikiUrl );
const htmlScriptPathCandidates = extractScriptPathsFromHtml( htmlContent, wikiServer );
const pathsToTry = htmlScriptPathCandidates.length > 0 ?
htmlScriptPathCandidates : COMMON_SCRIPT_PATHS;

for ( const candidatePath of pathsToTry ) {
const apiResult = await fetchWikiInfoFromApi( wikiServer, candidatePath );
if ( apiResult ) {
return apiResult;
}
}

return null;
}

function extractScriptPathsFromHtml( htmlContent: string | null, wikiServer: string ): string[] {
const candidatesFromHtml: string[] = [];
if ( htmlContent ) {
const fromSearchForm = extractScriptPathFromSearchForm( htmlContent, wikiServer );
if ( fromSearchForm !== null ) {
candidatesFromHtml.push( fromSearchForm );
}
}

const uniqueCandidatesFromHtml = [ ...new Set( candidatesFromHtml ) ];
return uniqueCandidatesFromHtml.filter( ( p ) => typeof p === 'string' && ( p === '' || p.trim() !== '' ) );
}

function extractScriptPathFromSearchForm( htmlContent: string, wikiServer: string ): string | null {
const searchFormMatch = htmlContent.match( /<form[^>]+id=['"]searchform['"][^>]+action=['"]([^'"]*index\.php[^'"]*)['"]/i );
if ( searchFormMatch && searchFormMatch[ 1 ] ) {
const actionAttribute = searchFormMatch[ 1 ];
try {
const fullActionUrl = new URL( actionAttribute, wikiServer );
const path = fullActionUrl.pathname;
const indexPathIndex = path.toLowerCase().lastIndexOf( '/index.php' );
if ( indexPathIndex !== -1 ) {
return path.slice( 0, indexPathIndex );
}
} catch ( error ) {
console.error( `Error extracting script path from search form: ${ error }` );
}
}
return null;
}
57 changes: 37 additions & 20 deletions src/tools/create-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import type { CallToolResult, TextContent, ToolAnnotations } from '@modelcontext
/* eslint-enable n/no-missing-import */
import { makeRestPostRequest, getPageUrl, formatEditComment } from '../common/utils.js';
import type { MwRestApiPageObject } from '../types/mwRestApi.js';
import { getCurrentWikiKey, setCurrentWiki } from '../common/config.js';
import { resolveWiki } from '../common/wikiDiscovery.js';
import { WikiDiscoveryError } from '../common/errors.js';

export function createPageTool( server: McpServer ): RegisteredTool {
return server.tool(
Expand All @@ -14,56 +17,70 @@ export function createPageTool( server: McpServer ): RegisteredTool {
source: z.string().describe( 'Page content in the format specified by the contentModel parameter' ),
title: z.string().describe( 'Wiki page title' ),
comment: z.string().describe( 'Reason for creating the page' ).optional(),
contentModel: z.string().describe( 'Type of content on the page. Defaults to "wikitext"' ).optional()
contentModel: z.string().describe( 'Type of content on the page. Defaults to "wikitext"' ).optional(),
wikiUrl: z.string().url().describe( 'Optional URL of the wiki to use for this request.' ).optional()
},
{
title: 'Create page',
readOnlyHint: false,
destructiveHint: true
} as ToolAnnotations,
async (
{ source, title, comment, contentModel }
) => handleCreatePageTool( source, title, comment, contentModel )
{ source, title, comment, contentModel, wikiUrl }
) => handleCreatePageTool( source, title, comment, contentModel, wikiUrl )
);
}

async function handleCreatePageTool(
source: string,
title: string,
comment?: string,
contentModel?: string
contentModel?: string,
wikiUrl?: string
): Promise<CallToolResult> {
let data: MwRestApiPageObject | null = null;

const originalWikiKey = getCurrentWikiKey();
try {
data = await makeRestPostRequest<MwRestApiPageObject>( '/v1/page', {
if ( wikiUrl ) {
const wikiKey = await resolveWiki( wikiUrl );
setCurrentWiki( wikiKey );
}

const data = await makeRestPostRequest<MwRestApiPageObject>( '/v1/page', {
source: source,
title: title,
comment: formatEditComment( 'create-page', comment ),
// eslint-disable-next-line camelcase
content_model: contentModel
}, true );
} catch ( error ) {

if ( data === null ) {
return {
content: [
{ type: 'text', text: 'Failed to create page: No data returned from API' } as TextContent
],
isError: true
};
}

return {
content: [
{ type: 'text', text: `Failed to create page: ${ ( error as Error ).message }` } as TextContent
],
isError: true
content: createPageToolResult( data )
};
}

if ( data === null ) {
} catch ( error ) {
if ( error instanceof WikiDiscoveryError ) {
return {
content: [ { type: 'text', text: error.message } as TextContent ],
isError: true
};
}
return {
content: [
{ type: 'text', text: 'Failed to create page: No data returned from API' } as TextContent
{ type: 'text', text: `Failed to create page: ${ ( error as Error ).message }` } as TextContent
],
isError: true
};
} finally {
setCurrentWiki( originalWikiKey );
}

return {
content: createPageToolResult( data )
};
}

function createPageToolResult( result: MwRestApiPageObject ): TextContent[] {
Expand Down
Loading