Skip to content

Commit 14ac700

Browse files
committed
Introduce optional URL argument to all tools
1 parent 6e65eac commit 14ac700

File tree

10 files changed

+510
-374
lines changed

10 files changed

+510
-374
lines changed

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: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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 ( e ) {}
165+
}
166+
return null;
167+
}

src/tools/create-page.ts

Lines changed: 50 additions & 30 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 } 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,73 @@ 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', {
40-
source: source,
41-
title: title,
42-
comment: comment || '',
43-
// eslint-disable-next-line camelcase
44-
content_model: contentModel
45-
}, true );
46-
} catch ( error ) {
47-
return {
48-
content: [
49-
{ type: 'text', text: `Failed to create page: ${ ( error as Error ).message }` } as TextContent
50-
],
51-
isError: true
52-
};
53-
}
43+
if ( wikiUrl ) {
44+
const wikiKey = await resolveWiki( wikiUrl );
45+
setCurrentWiki( wikiKey );
46+
}
47+
let data: MwRestApiPageObject | null = null;
48+
49+
try {
50+
data = await makeRestPostRequest<MwRestApiPageObject>( '/v1/page', {
51+
source: source,
52+
title: title,
53+
comment: comment || '',
54+
// eslint-disable-next-line camelcase
55+
content_model: contentModel
56+
}, true );
57+
} catch ( error ) {
58+
if ( error instanceof WikiDiscoveryError ) {
59+
return {
60+
content: [ { type: 'text', text: error.message } as TextContent ],
61+
isError: true
62+
};
63+
}
64+
return {
65+
content: [
66+
{ type: 'text', text: `Failed to create page: ${ ( error as Error ).message }` } as TextContent
67+
],
68+
isError: true
69+
};
70+
}
71+
72+
if ( data === null ) {
73+
return {
74+
content: [
75+
{ type: 'text', text: 'Failed to create page: No data returned from API' } as TextContent
76+
],
77+
isError: true
78+
};
79+
}
5480

55-
if ( data === null ) {
5681
return {
57-
content: [
58-
{ type: 'text', text: 'Failed to create page: No data returned from API' } as TextContent
59-
],
60-
isError: true
82+
content: createPageToolResult( data )
6183
};
84+
} finally {
85+
setCurrentWiki( originalWikiKey );
6286
}
63-
64-
return {
65-
content: createPageToolResult( data )
66-
};
6787
}
6888

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

0 commit comments

Comments
 (0)