Skip to content

Commit 6febdc4

Browse files
committed
Introduce optional URL argument to all tools
Also include the ADR for this decision.
1 parent a7f5166 commit 6febdc4

File tree

11 files changed

+509
-338
lines changed

11 files changed

+509
-338
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# 1. Use optional URL argument in MCP tool
2+
3+
Date: 2025-06-21
4+
5+
## Status
6+
7+
Accepted.
8+
9+
## Context
10+
11+
Currently, switching between wikis requires using the stateful `set-wiki` command. To get pages from two different wikis, one must do:
12+
13+
```
14+
setWiki( 'foo.example.com' )
15+
getPage( 'Main Page' )
16+
17+
setWiki( 'bar.example.com' )
18+
getPage( 'Main Page' )
19+
```
20+
21+
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.
22+
23+
The proposed solution is to add an optional URL argument to commands, allowing for stateless calls:
24+
25+
```
26+
getPage( 'Main Page', 'foo.example.com' )
27+
getPage( 'Main Page', 'bar.example.com' )
28+
```
29+
30+
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.
31+
32+
## Decision
33+
34+
An optional `wikiUrl` argument will be added as the last parameter to all MCP tools that interact with a MediaWiki instance.
35+
36+
* If the `wikiUrl` argument is provided, the tool will target that wiki for the operation.
37+
* 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.
38+
* 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.
39+
40+
This decision considers the following use cases:
41+
* **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.
42+
* **Multiple wikis**: For a general-purpose "wiki helper chatbot" where wikis are not known in advance, this argument is essential.
43+
* **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.
44+
45+
## Consequences
46+
47+
### Positive
48+
* Simplifies interactions involving multiple wikis.
49+
* Reduces the need for the LLM to track the "current" wiki state.
50+
* Makes interactions more robust and stateless.
51+
52+
### Negative
53+
* Increases complexity in most tools. Extra code is needed to handle the current wiki.
54+
* Requires a coordinated change across all relevant tool definitions.

src/common/config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ function loadConfigFromFile(): Config {
6868

6969
const config = loadConfigFromFile();
7070
const defaultWiki = config.defaultWiki;
71-
let currentConfig: WikiConfig = config.wikis[ defaultWiki ];
71+
let currentWikiKey: string = defaultWiki;
72+
let currentConfig: WikiConfig = config.wikis[ currentWikiKey ];
7273

7374
if ( !currentConfig ) {
7475
throw new Error( `Default wiki "${ defaultWiki }" not found in config.json` );
@@ -82,10 +83,15 @@ export function getCurrentWikiConfig(): Readonly<WikiConfig> {
8283
return currentConfig;
8384
}
8485

86+
export function getCurrentWikiKey(): string {
87+
return currentWikiKey;
88+
}
89+
8590
export function setCurrentWiki( wiki: string ): void {
8691
if ( !config.wikis[ wiki ] ) {
8792
throw new Error( `Wiki "${ wiki }" not found in config.json` );
8893
}
94+
currentWikiKey = wiki;
8995
currentConfig = config.wikis[ wiki ];
9096
}
9197

@@ -98,6 +104,7 @@ export function updateWikiConfig( wiki: string, newConfig: WikiConfig ): void {
98104

99105
export function resetConfig(): void {
100106
if ( config.wikis[ defaultWiki ] ) {
107+
currentWikiKey = defaultWiki;
101108
currentConfig = config.wikis[ defaultWiki ];
102109
} else {
103110
throw new Error( `Default wiki "${ defaultWiki }" not found in config.json` );

src/common/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class WikiDiscoveryError extends Error {
2+
public constructor( message: string ) {
3+
super( message );
4+
this.name = 'WikiDiscoveryError';
5+
}
6+
}

src/common/wikiDiscovery.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { makeApiRequest, fetchPageHtml } from './utils.js';
2+
import { getAllWikis, updateWikiConfig } from './config.js';
3+
import { WikiDiscoveryError } from './errors.js';
4+
5+
const COMMON_SCRIPT_PATHS = [ '/w', '' ];
6+
7+
// TODO: Move these types to a dedicated file if we end up using Action API types elsewhere
8+
interface MediaWikiActionApiSiteInfoGeneral {
9+
sitename: string;
10+
articlepath: string;
11+
scriptpath: string;
12+
server: string;
13+
servername: string;
14+
// Omitted other fields for now since we don't use them
15+
}
16+
17+
interface MediaWikiActionApiSiteInfoQuery {
18+
general: MediaWikiActionApiSiteInfoGeneral;
19+
}
20+
21+
interface MediaWikiActionApiResponse {
22+
query?: MediaWikiActionApiSiteInfoQuery;
23+
}
24+
25+
export interface WikiInfo {
26+
sitename: string;
27+
articlepath: string;
28+
scriptpath: string;
29+
server: string;
30+
servername: string;
31+
}
32+
33+
export async function resolveWiki( wikiUrl: string ): Promise<string> {
34+
const url = new URL( wikiUrl );
35+
const allWikis = getAllWikis();
36+
37+
if ( allWikis[ url.hostname ] ) {
38+
return url.hostname;
39+
}
40+
41+
const wikiServer = parseWikiUrl( wikiUrl );
42+
const wikiInfo = await getWikiInfo( wikiServer, wikiUrl );
43+
44+
if ( wikiInfo !== null ) {
45+
updateWikiConfig( wikiInfo.servername, {
46+
sitename: wikiInfo.sitename,
47+
server: wikiInfo.server,
48+
articlepath: wikiInfo.articlepath,
49+
scriptpath: wikiInfo.scriptpath
50+
} );
51+
return wikiInfo.servername;
52+
} else {
53+
throw new WikiDiscoveryError( 'Failed to determine wiki info. Please ensure the URL is correct and the wiki is accessible.' );
54+
}
55+
}
56+
57+
export function parseWikiUrl( wikiUrl: string ): string {
58+
const url = new URL( wikiUrl );
59+
return `${ url.protocol }//${ url.host }`;
60+
}
61+
62+
export async function getWikiInfo(
63+
wikiServer: string, originalWikiUrl: string
64+
): Promise<WikiInfo | null> {
65+
return ( await fetchUsingCommonScriptPaths( wikiServer ) ) ??
66+
( await fetchUsingScriptPathsFromHtml( wikiServer, originalWikiUrl ) );
67+
}
68+
69+
async function fetchWikiInfoFromApi(
70+
wikiServer: string, scriptPath: string
71+
): Promise<WikiInfo | null> {
72+
const baseUrl = `${ wikiServer }${ scriptPath }/api.php`;
73+
const params = {
74+
action: 'query',
75+
meta: 'siteinfo',
76+
siprop: 'general',
77+
format: 'json',
78+
origin: '*'
79+
};
80+
81+
let data: MediaWikiActionApiResponse | null = null;
82+
try {
83+
data = await makeApiRequest<MediaWikiActionApiResponse>( baseUrl, params );
84+
} catch ( error ) {
85+
console.error( `Error fetching wiki info from ${ baseUrl }:`, error );
86+
return null;
87+
}
88+
89+
if ( data === null || data.query?.general === undefined ) {
90+
return null;
91+
}
92+
93+
const general = data.query.general;
94+
95+
// We don't need to check for every field, the API should be returning the correct values.
96+
if ( typeof general.scriptpath !== 'string' ) {
97+
return null;
98+
}
99+
100+
return {
101+
sitename: general.sitename,
102+
scriptpath: general.scriptpath,
103+
articlepath: general.articlepath.replace( '/$1', '' ),
104+
server: general.server,
105+
servername: general.servername
106+
};
107+
}
108+
109+
async function fetchUsingCommonScriptPaths(
110+
wikiServer: string
111+
): Promise<WikiInfo | null> {
112+
for ( const candidatePath of COMMON_SCRIPT_PATHS ) {
113+
const apiResult = await fetchWikiInfoFromApi( wikiServer, candidatePath );
114+
if ( apiResult ) {
115+
return apiResult;
116+
}
117+
}
118+
return null;
119+
}
120+
121+
async function fetchUsingScriptPathsFromHtml(
122+
wikiServer: string,
123+
originalWikiUrl: string
124+
): Promise<WikiInfo | null> {
125+
const htmlContent = await fetchPageHtml( originalWikiUrl );
126+
const htmlScriptPathCandidates = extractScriptPathsFromHtml( htmlContent, wikiServer );
127+
const pathsToTry = htmlScriptPathCandidates.length > 0 ?
128+
htmlScriptPathCandidates : COMMON_SCRIPT_PATHS;
129+
130+
for ( const candidatePath of pathsToTry ) {
131+
const apiResult = await fetchWikiInfoFromApi( wikiServer, candidatePath );
132+
if ( apiResult ) {
133+
return apiResult;
134+
}
135+
}
136+
137+
return null;
138+
}
139+
140+
function extractScriptPathsFromHtml( htmlContent: string | null, wikiServer: string ): string[] {
141+
const candidatesFromHtml: string[] = [];
142+
if ( htmlContent ) {
143+
const fromSearchForm = extractScriptPathFromSearchForm( htmlContent, wikiServer );
144+
if ( fromSearchForm !== null ) {
145+
candidatesFromHtml.push( fromSearchForm );
146+
}
147+
}
148+
149+
const uniqueCandidatesFromHtml = [ ...new Set( candidatesFromHtml ) ];
150+
return uniqueCandidatesFromHtml.filter( ( p ) => typeof p === 'string' && ( p === '' || p.trim() !== '' ) );
151+
}
152+
153+
function extractScriptPathFromSearchForm( htmlContent: string, wikiServer: string ): string | null {
154+
const searchFormMatch = htmlContent.match( /<form[^>]+id=['"]searchform['"][^>]+action=['"]([^'"]*index\.php[^'"]*)['"]/i );
155+
if ( searchFormMatch && searchFormMatch[ 1 ] ) {
156+
const actionAttribute = searchFormMatch[ 1 ];
157+
try {
158+
const fullActionUrl = new URL( actionAttribute, wikiServer );
159+
const path = fullActionUrl.pathname;
160+
const indexPathIndex = path.toLowerCase().lastIndexOf( '/index.php' );
161+
if ( indexPathIndex !== -1 ) {
162+
return path.slice( 0, indexPathIndex );
163+
}
164+
} catch ( error ) {
165+
console.error( `Error extracting script path from search form: ${ error }` );
166+
}
167+
}
168+
return null;
169+
}

src/tools/create-page.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import type { CallToolResult, TextContent, ToolAnnotations } from '@modelcontext
55
/* eslint-enable n/no-missing-import */
66
import { makeRestPostRequest, getPageUrl, formatEditComment } from '../common/utils.js';
77
import type { MwRestApiPageObject } from '../types/mwRestApi.js';
8+
import { getCurrentWikiKey, setCurrentWiki } from '../common/config.js';
9+
import { resolveWiki } from '../common/wikiDiscovery.js';
10+
import { WikiDiscoveryError } from '../common/errors.js';
811

912
export function createPageTool( server: McpServer ): RegisteredTool {
1013
return server.tool(
@@ -14,56 +17,70 @@ export function createPageTool( server: McpServer ): RegisteredTool {
1417
source: z.string().describe( 'Page content in the format specified by the contentModel parameter' ),
1518
title: z.string().describe( 'Wiki page title' ),
1619
comment: z.string().describe( 'Reason for creating the page' ).optional(),
17-
contentModel: z.string().describe( 'Type of content on the page. Defaults to "wikitext"' ).optional()
20+
contentModel: z.string().describe( 'Type of content on the page. Defaults to "wikitext"' ).optional(),
21+
wikiUrl: z.string().url().describe( 'Optional URL of the wiki to use for this request.' ).optional()
1822
},
1923
{
2024
title: 'Create page',
2125
readOnlyHint: false,
2226
destructiveHint: true
2327
} as ToolAnnotations,
2428
async (
25-
{ source, title, comment, contentModel }
26-
) => handleCreatePageTool( source, title, comment, contentModel )
29+
{ source, title, comment, contentModel, wikiUrl }
30+
) => handleCreatePageTool( source, title, comment, contentModel, wikiUrl )
2731
);
2832
}
2933

3034
async function handleCreatePageTool(
3135
source: string,
3236
title: string,
3337
comment?: string,
34-
contentModel?: string
38+
contentModel?: string,
39+
wikiUrl?: string
3540
): Promise<CallToolResult> {
36-
let data: MwRestApiPageObject | null = null;
37-
41+
const originalWikiKey = getCurrentWikiKey();
3842
try {
39-
data = await makeRestPostRequest<MwRestApiPageObject>( '/v1/page', {
43+
if ( wikiUrl ) {
44+
const wikiKey = await resolveWiki( wikiUrl );
45+
setCurrentWiki( wikiKey );
46+
}
47+
48+
const data = await makeRestPostRequest<MwRestApiPageObject>( '/v1/page', {
4049
source: source,
4150
title: title,
4251
comment: formatEditComment( 'create-page', comment ),
4352
// eslint-disable-next-line camelcase
4453
content_model: contentModel
4554
}, true );
46-
} catch ( error ) {
55+
56+
if ( data === null ) {
57+
return {
58+
content: [
59+
{ type: 'text', text: 'Failed to create page: No data returned from API' } as TextContent
60+
],
61+
isError: true
62+
};
63+
}
64+
4765
return {
48-
content: [
49-
{ type: 'text', text: `Failed to create page: ${ ( error as Error ).message }` } as TextContent
50-
],
51-
isError: true
66+
content: createPageToolResult( data )
5267
};
53-
}
54-
55-
if ( data === null ) {
68+
} catch ( error ) {
69+
if ( error instanceof WikiDiscoveryError ) {
70+
return {
71+
content: [ { type: 'text', text: error.message } as TextContent ],
72+
isError: true
73+
};
74+
}
5675
return {
5776
content: [
58-
{ type: 'text', text: 'Failed to create page: No data returned from API' } as TextContent
77+
{ type: 'text', text: `Failed to create page: ${ ( error as Error ).message }` } as TextContent
5978
],
6079
isError: true
6180
};
81+
} finally {
82+
setCurrentWiki( originalWikiKey );
6283
}
63-
64-
return {
65-
content: createPageToolResult( data )
66-
};
6784
}
6885

6986
function createPageToolResult( result: MwRestApiPageObject ): TextContent[] {

0 commit comments

Comments
 (0)