growi-mcp-server
Version:
MCP Server for GROWI - a modern Wiki system
203 lines • 8.54 kB
JavaScript
import { z } from 'zod';
import https from 'https';
import http from 'http';
import { URL } from 'url';
// Ensure logging goes to stderr
const logToStderr = (...args) => {
console.error(...args);
};
export const listPagesSchema = z.object({
path: z.union([z.string(), z.number()]).optional().describe('Path prefix to list pages from (default: /)'),
limit: z.union([z.string(), z.number()]).optional().describe('Maximum number of pages to return (default: 100)'),
page: z.union([z.string(), z.number()]).optional().describe('Page number (1-based, default: 1)'),
});
function normalizeParams(params) {
if (!params) {
logToStderr('No parameters provided, using defaults');
return { path: '/', limit: 100, page: 1 };
}
const parsed = listPagesSchema.parse(params);
let path = parsed.path !== undefined ? String(parsed.path) : '/';
if (!path.startsWith('/'))
path = '/' + path;
let limit = parsed.limit !== undefined ? Number(parsed.limit) : 100;
if (typeof parsed.limit === 'string') {
logToStderr(`Converted string limit to number: ${limit}`);
}
if (isNaN(limit) || limit < 1) {
limit = 100;
logToStderr(`Invalid limit value, reset to default: ${limit}`);
}
else if (limit > 1000) {
limit = 1000;
logToStderr(`Limit too large, capped at: ${limit}`);
}
let page = parsed.page !== undefined ? Number(parsed.page) : 1;
if (typeof parsed.page === 'string') {
logToStderr(`Converted string page to number: ${page}`);
}
if (isNaN(page) || page < 1) {
page = 1;
logToStderr(`Invalid page value, reset to default: ${page}`);
}
return { path, limit, page };
}
function formatResultText(path, pages, total, limit, page) {
if (!pages || pages.length === 0) {
logToStderr('No pages returned from API');
return `No pages found under path: ${path}`;
}
const startIndex = (page - 1) * limit + 1;
const endIndex = Math.min(startIndex + pages.length - 1, total ?? pages.length);
let text = `Found ${pages.length} pages under path: ${path}\n\n`;
pages.forEach((p, index) => {
if (index < 10)
logToStderr(` - Page ${index + 1}: ${p.path}`);
text += `- ${p.path}\n`;
});
if (pages.length > 10) {
logToStderr(` - ... and ${pages.length - 10} more pages`);
}
if (total !== undefined) {
text += `\nShowing ${startIndex}-${endIndex} of ${total} total pages`;
logToStderr(`Pagination info: showing ${startIndex}-${endIndex} of ${total} total pages`);
}
return text;
}
/**
* 直接HTTPリクエストを実行する関数
* curlコマンドと同様の挙動を実現する
*/
function makeNativeHttpRequest(url, apiToken) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
logToStderr(`Making native HTTP request to: ${url}`);
// トークンデータをx-www-form-urlencodedデータとして準備
const postData = `access_token=${encodeURIComponent(apiToken)}`;
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: `${parsedUrl.pathname}${parsedUrl.search}`,
method: 'GET',
headers: {
'User-Agent': 'curl/8.7.1',
'Accept': '*/*',
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
logToStderr(`Sending access_token in request body: ${apiToken.substring(0, 5)}...`);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
const req = protocol.request(options, (res) => {
logToStderr(`Response status: ${res.statusCode}`);
let data = '';
res.on('data', (chunk) => {
data += chunk.toString();
});
res.on('end', () => {
logToStderr(`Response completed. Data length: ${data.length}`);
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
try {
const jsonData = JSON.parse(data);
logToStderr(`Got ${jsonData.pages?.length || 0} pages out of ${jsonData.totalCount || 0} total`);
resolve(jsonData);
}
catch (error) {
reject(new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`));
}
}
else {
reject(new Error(`HTTP Error: ${res.statusCode} ${res.statusMessage || ''} - ${data}`));
}
});
});
req.on('error', (error) => {
logToStderr(`Request failed: ${error.message}`);
reject(error);
});
// Send the request with the access token in the body
req.write(postData);
req.end();
});
}
export async function listPages(client, params) {
try {
const { path, limit, page } = normalizeParams(params);
logToStderr(`Calling GROWI API with: path="${path}", limit=${limit}, page=${page}`);
// ここで直接curlと同様のHTTPリクエストを実行
try {
const apiUrl = client.baseURL;
const apiToken = client.apiToken;
if (!apiUrl || !apiToken) {
throw new Error('Missing API URL or token');
}
// curlで成功するのと同じURLを構築 - URL は自動的にエンコードを行うため注意
const url = new URL(`${apiUrl}/_api/v3/pages/list`);
url.searchParams.append('path', path);
url.searchParams.append('limit', String(limit));
url.searchParams.append('page', String(page));
// トークンはリクエストボディで送信するため、URLには含めない
const urlString = url.toString();
// 直接HTTPリクエストを行う
const data = await makeNativeHttpRequest(urlString, apiToken);
if (data && data.pages) {
const resultText = formatResultText(path, data.pages, data.totalCount, limit, page);
return {
content: [
{
type: 'text',
text: resultText,
},
],
};
}
}
catch (directError) {
logToStderr(`Direct request failed: ${directError instanceof Error ? directError.message : String(directError)}`);
// エラーの場合はここでcatchして、次の処理に進む
}
// GrowiClientを使用したフォールバック処理
const response = await client.listPages(path, limit, page);
// Log more detailed response info for debugging
logToStderr(`GROWI API response details:`, {
ok: response.ok,
pagesCount: response.pages?.length || 0,
hasError: response.error ? true : false,
error: response.error || 'none',
meta: response.meta || 'none'
});
if (!response.ok) {
console.error(`GROWI API returned error: ${response.error || 'Unknown error'}`);
return {
content: [
{
type: 'text',
text: `Error listing pages (path: ${path}, offset: ${(page - 1) * limit}): ${response.error || 'Unknown error'}`,
},
],
};
}
logToStderr(`GROWI API returned ${response.pages?.length || 0} pages`);
const resultText = formatResultText(path, response.pages, response.meta?.total, limit, page);
return {
content: [
{
type: 'text',
text: resultText,
},
],
};
}
catch (error) {
console.error('Exception in listPages tool:', error);
return {
content: [
{
type: 'text',
text: `Error listing pages: ${error instanceof Error ? error.message : String(error)}`,
},
],
};
}
}
//# sourceMappingURL=list-pages.js.map