index.ts•14 kB
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import axios from 'axios';
const WIKI_BASE_URL = 'https://consumerrights.wiki';
const API_ENDPOINT = `${WIKI_BASE_URL}/api.php`;
interface WikiResponse {
batchcomplete?: string;
query?: any;
parse?: any;
error?: {
code: string;
info: string;
};
}
class ConsumerRightsWikiServer {
private server: Server;
constructor() {
this.server = new Server(
{
name: 'consumer-rights-wiki-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
}
private async makeApiRequest(params: Record<string, string>): Promise<WikiResponse> {
try {
const response = await axios.get(API_ENDPOINT, {
params: {
format: 'json',
...params,
},
});
return response.data;
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to make API request: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'search_wiki',
description: 'Search for articles in the Consumer Rights Wiki',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query',
},
limit: {
type: 'number',
description: 'Number of results to return (default: 10, max: 50)',
default: 10,
},
},
required: ['query'],
},
},
{
name: 'get_page_content',
description: 'Get the full content of a specific wiki page',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'The title of the wiki page',
},
section: {
type: 'number',
description: 'Optional section number to retrieve',
},
},
required: ['title'],
},
},
{
name: 'get_page_info',
description: 'Get metadata about a wiki page',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'The title of the wiki page',
},
},
required: ['title'],
},
},
{
name: 'get_recent_changes',
description: 'Get recent changes to the wiki',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Number of recent changes to return (default: 10, max: 50)',
default: 10,
},
namespace: {
type: 'number',
description: 'Filter by namespace (0 = main articles)',
},
},
},
},
{
name: 'get_categories',
description: 'Get all categories or pages in a specific category',
inputSchema: {
type: 'object',
properties: {
category: {
type: 'string',
description: 'Category name (without Category: prefix). If empty, lists all categories.',
},
limit: {
type: 'number',
description: 'Number of results to return (default: 20, max: 50)',
default: 20,
},
},
},
},
{
name: 'get_page_sections',
description: 'Get the section structure of a wiki page',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'The title of the wiki page',
},
},
required: ['title'],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case 'search_wiki':
return this.searchWiki(request.params.arguments);
case 'get_page_content':
return this.getPageContent(request.params.arguments);
case 'get_page_info':
return this.getPageInfo(request.params.arguments);
case 'get_recent_changes':
return this.getRecentChanges(request.params.arguments);
case 'get_categories':
return this.getCategories(request.params.arguments);
case 'get_page_sections':
return this.getPageSections(request.params.arguments);
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
});
}
private async searchWiki(args: any) {
const { query, limit = 10 } = args;
const data = await this.makeApiRequest({
action: 'query',
list: 'search',
srsearch: query,
srlimit: Math.min(limit, 50).toString(),
srprop: 'size|wordcount|timestamp|snippet',
});
if (data.error) {
throw new McpError(ErrorCode.InternalError, data.error.info);
}
const results = data.query?.search || [];
return {
content: [
{
type: 'text',
text: JSON.stringify({
query: query,
totalResults: data.query?.searchinfo?.totalhits || 0,
results: results.map((result: any) => ({
title: result.title,
size: result.size,
wordcount: result.wordcount,
timestamp: result.timestamp,
snippet: result.snippet.replace(/<[^>]*>/g, ''), // Remove HTML tags
url: `${WIKI_BASE_URL}/${result.title.replace(/ /g, '_')}`,
})),
}, null, 2),
},
],
};
}
private async getPageContent(args: any) {
const { title, section } = args;
const params: Record<string, string> = {
action: 'parse',
page: title,
prop: 'text|sections|categories|links|externallinks',
disablelimitreport: '1',
disableeditsection: '1',
};
if (section !== undefined) {
params.section = section.toString();
}
const data = await this.makeApiRequest(params);
if (data.error) {
throw new McpError(ErrorCode.InternalError, data.error.info);
}
const parse = data.parse;
if (!parse) {
throw new McpError(ErrorCode.InvalidRequest, `Page '${title}' not found`);
}
// Extract text content and remove HTML
const htmlContent = parse.text['*'];
const textContent = htmlContent
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
.replace(/<[^>]*>/g, '')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/\n\s*\n/g, '\n\n')
.trim();
return {
content: [
{
type: 'text',
text: JSON.stringify({
title: parse.title,
pageid: parse.pageid,
content: textContent,
categories: parse.categories?.map((cat: any) => cat['*']) || [],
sections: parse.sections || [],
internalLinks: parse.links?.map((link: any) => link['*']) || [],
externalLinks: parse.externallinks || [],
url: `${WIKI_BASE_URL}/${title.replace(/ /g, '_')}`,
}, null, 2),
},
],
};
}
private async getPageInfo(args: any) {
const { title } = args;
const data = await this.makeApiRequest({
action: 'query',
titles: title,
prop: 'info|revisions|categories',
inprop: 'protection|talkid|watched|watchers|visitingwatchers|notificationtimestamp|subjectid|url|readable|preload|displaytitle',
rvprop: 'timestamp|user|comment|size',
rvlimit: '1',
});
if (data.error) {
throw new McpError(ErrorCode.InternalError, data.error.info);
}
const pages = data.query?.pages;
const pageId = Object.keys(pages)[0];
const pageInfo = pages[pageId];
if (pageInfo.missing) {
throw new McpError(ErrorCode.InvalidRequest, `Page '${title}' not found`);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
title: pageInfo.title,
pageid: pageInfo.pageid,
namespace: pageInfo.ns,
length: pageInfo.length,
touched: pageInfo.touched,
lastrevid: pageInfo.lastrevid,
counter: pageInfo.counter,
fullurl: pageInfo.fullurl,
editurl: pageInfo.editurl,
canonicalurl: pageInfo.canonicalurl,
readable: pageInfo.readable,
categories: pageInfo.categories?.map((cat: any) => cat.title) || [],
lastRevision: pageInfo.revisions?.[0] || null,
protection: pageInfo.protection || [],
}, null, 2),
},
],
};
}
private async getRecentChanges(args: any) {
const { limit = 10, namespace } = args;
const params: Record<string, string> = {
action: 'query',
list: 'recentchanges',
rcprop: 'title|timestamp|user|comment|sizes|flags',
rclimit: Math.min(limit, 50).toString(),
};
if (namespace !== undefined) {
params.rcnamespace = namespace.toString();
}
const data = await this.makeApiRequest(params);
if (data.error) {
throw new McpError(ErrorCode.InternalError, data.error.info);
}
const changes = data.query?.recentchanges || [];
return {
content: [
{
type: 'text',
text: JSON.stringify({
recentChanges: changes.map((change: any) => ({
title: change.title,
timestamp: change.timestamp,
user: change.user,
comment: change.comment,
oldSize: change.oldlen,
newSize: change.newlen,
sizeChange: change.newlen - change.oldlen,
type: change.type,
isNew: change.new === '',
isMinor: change.minor === '',
isBot: change.bot === '',
url: `${WIKI_BASE_URL}/${change.title.replace(/ /g, '_')}`,
})),
}, null, 2),
},
],
};
}
private async getCategories(args: any) {
const { category, limit = 20 } = args;
if (category) {
// Get pages in a specific category
const data = await this.makeApiRequest({
action: 'query',
list: 'categorymembers',
cmtitle: `Category:${category}`,
cmlimit: Math.min(limit, 50).toString(),
cmprop: 'ids|title|timestamp',
});
if (data.error) {
throw new McpError(ErrorCode.InternalError, data.error.info);
}
const members = data.query?.categorymembers || [];
return {
content: [
{
type: 'text',
text: JSON.stringify({
category: `Category:${category}`,
members: members.map((member: any) => ({
title: member.title,
pageid: member.pageid,
timestamp: member.timestamp,
url: `${WIKI_BASE_URL}/${member.title.replace(/ /g, '_')}`,
})),
}, null, 2),
},
],
};
} else {
// List all categories
const data = await this.makeApiRequest({
action: 'query',
list: 'allcategories',
aclimit: Math.min(limit, 50).toString(),
acprop: 'size',
});
if (data.error) {
throw new McpError(ErrorCode.InternalError, data.error.info);
}
const categories = data.query?.allcategories || [];
return {
content: [
{
type: 'text',
text: JSON.stringify({
categories: categories.map((cat: any) => ({
name: cat['*'],
size: cat.size,
url: `${WIKI_BASE_URL}/Category:${cat['*'].replace(/ /g, '_')}`,
})),
}, null, 2),
},
],
};
}
}
private async getPageSections(args: any) {
const { title } = args;
const data = await this.makeApiRequest({
action: 'parse',
page: title,
prop: 'sections',
});
if (data.error) {
throw new McpError(ErrorCode.InternalError, data.error.info);
}
const sections = data.parse?.sections || [];
return {
content: [
{
type: 'text',
text: JSON.stringify({
title: data.parse?.title,
sections: sections.map((section: any) => ({
index: section.index,
level: parseInt(section.level),
line: section.line,
number: section.number,
anchor: section.anchor,
byteoffset: section.byteoffset,
})),
}, null, 2),
},
],
};
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Consumer Rights Wiki MCP server running on stdio');
}
}
const server = new ConsumerRightsWikiServer();
server.run().catch(console.error);