growi-mcp-server
Version:
MCP Server for GROWI - a modern Wiki system
330 lines • 12.8 kB
JavaScript
import axios from 'axios';
import https from 'https';
import http from 'http';
import { URL } from 'url';
// Ensure console methods are redirected to stderr
const logToStderr = (...args) => {
console.error(...args);
};
export class GrowiClient {
constructor(apiUrl, apiToken) {
if (!apiUrl)
throw new Error('GROWI API URL is required');
if (!apiToken)
throw new Error('GROWI API token is required');
logToStderr(`Initializing GROWI client with URL: ${apiUrl}`);
this.apiToken = apiToken;
this.baseURL = apiUrl;
// シンプルなHTTPクライアントとして初期化
this.client = axios.create({
baseURL: apiUrl,
headers: {
'Content-Type': 'application/json',
},
});
// レスポンスのエラーログ用インターセプタ追加
this.client.interceptors.response.use((response) => response, (error) => {
this.logAxiosError(error);
throw error;
});
}
// エラーログ用ヘルパーメソッド
logAxiosError(error) {
console.error('Axios Error:', error.message);
if (error.response) {
console.error('Response Status:', error.response.status);
if (error.response.data) {
console.error('Response Data:', typeof error.response.data === 'object'
? JSON.stringify(error.response.data)
: String(error.response.data));
}
}
else if (error.request) {
console.error('No response received from server');
}
}
/**
* クエリパラメータを含むURLを構築するヘルパーメソッド
*/
buildUrl(endpoint, params = {}) {
const url = new URL(this.baseURL + endpoint);
// URLパラメータを追加
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
// アクセストークンをURLに追加しない
return url.toString();
}
/**
* curlと同様のHTTPリクエストを実行する
*/
makeNativeCurlRequest(url, method = 'GET') {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
// トークンデータをx-www-form-urlencodedデータとして準備
const postData = `access_token=${encodeURIComponent(this.apiToken)}`;
// curlと同じリクエストオプションを使用
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: `${parsedUrl.pathname}${parsedUrl.search}`,
method: method.toUpperCase(),
headers: {
'User-Agent': 'curl/8.7.1',
'Accept': '*/*',
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
const safeToken = this.apiToken.substring(0, 5) + '...';
logToStderr(`Making native curl-like request to URL: ${parsedUrl.protocol}//${parsedUrl.hostname}${options.path}`);
logToStderr(`Sending access_token in request body: ${safeToken}`);
const requestModule = parsedUrl.protocol === 'https:' ? https : http;
const req = requestModule.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);
if (jsonData.pages) {
logToStderr(`Got ${jsonData.pages.length} pages out of ${jsonData.totalCount} total`);
}
resolve(jsonData);
}
catch (error) {
reject(new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : String(error)}`));
}
}
else {
reject(new Error(`HTTP Error: ${res.statusCode} - ${data}`));
}
});
});
req.on('error', (error) => {
logToStderr(`Native HTTP request failed: ${error.message}`);
reject(error);
});
// Send the request with the access token in the body
req.write(postData);
req.end();
});
}
/**
* APIリクエストを実行するヘルパーメソッド
* 常にcurl互換のnativeリクエストを使用
*/
async request(method, endpoint, params = {}) {
try {
// Build URL with query parameters and access_token
const url = this.buildUrl(endpoint, params);
// Use the curl-like native HTTP request
return await this.makeNativeCurlRequest(url, method);
}
catch (error) {
logToStderr(`Request failed for ${endpoint}: ${error.message}`);
throw error;
}
}
/**
* エラーレスポンスの整形
*/
formatErrorResponse(error) {
const errorMessage = axios.isAxiosError(error)
? error.response
? `API Error (${error.response.status}): ${JSON.stringify(error.response.data)}`
: error.request
? `No response from server: ${error.message}`
: `Request setup error: ${error.message}`
: `Error: ${error instanceof Error ? error.message : String(error)}`;
return {
ok: false,
error: errorMessage,
};
}
/**
* ページ一覧を取得
* @param path 取得対象のパス
* @param limit 一度に取得する最大ページ数
* @param page ページ番号(1始まり)
*/
async listPages(path = '/', limit = 100, page = 1) {
try {
const data = await this.request('get', '/_api/v3/pages/list', {
path,
limit,
page,
});
// レスポンスデータを整形
const pagesCount = data.pages?.length || 0;
return {
ok: true,
pages: Array.isArray(data.pages) ? data.pages.map((page) => ({
...page,
_id: String(page._id),
path: String(page.path),
creator: {
_id: String(page.creator?._id || ''),
name: String(page.creator?.name || '')
},
revision: {
_id: String(page.revision?._id || ''),
body: String(page.revision?.body || ''),
author: {
_id: String(page.revision?.author?._id || ''),
name: String(page.revision?.author?.name || '')
},
createdAt: String(page.revision?.createdAt || '')
},
createdAt: String(page.createdAt || ''),
updatedAt: String(page.updatedAt || '')
})) : [],
meta: {
total: Number(data.totalCount || 0),
limit: Number(limit),
offset: Number((page - 1) * limit)
}
};
}
catch (error) {
return this.formatErrorResponse(error);
}
}
/**
* Recently updated pages
* @param limit Number of pages to return
* @param offset Offset for pagination
*/
async getRecentlyUpdatedPages(limit = 20, offset = 0) {
try {
const data = await this.request('get', '/_api/v3/pages/recent', {
limit,
offset,
});
return {
ok: true,
pages: Array.isArray(data.pages) ? data.pages.map((page) => ({
...page,
_id: String(page._id),
path: String(page.path),
creator: {
_id: String(page.creator?._id || ''),
name: String(page.creator?.name || '')
},
revision: {
_id: String(page.revision?._id || ''),
body: String(page.revision?.body || ''),
author: {
_id: String(page.revision?.author?._id || ''),
name: String(page.revision?.author?.name || '')
},
createdAt: String(page.revision?.createdAt || '')
},
createdAt: String(page.createdAt || ''),
updatedAt: String(page.updatedAt || '')
})) : [],
meta: {
total: Number(data.totalCount || 0),
limit: Number(limit),
offset: Number(offset)
}
};
}
catch (error) {
return this.formatErrorResponse(error);
}
}
/**
* Get a single page by path
* @param path Page path
*/
async getPage(path) {
try {
const data = await this.request('get', '/_api/v3/page', { path });
return {
ok: true,
page: {
...data.page,
_id: String(data.page?._id || ''),
path: String(data.page?.path || ''),
creator: {
_id: String(data.page?.creator?._id || ''),
name: String(data.page?.creator?.name || '')
},
revision: {
_id: String(data.page?.revision?._id || ''),
body: String(data.page?.revision?.body || ''),
author: {
_id: String(data.page?.revision?.author?._id || ''),
name: String(data.page?.revision?.author?.name || '')
},
createdAt: String(data.page?.revision?.createdAt || '')
},
createdAt: String(data.page?.createdAt || ''),
updatedAt: String(data.page?.updatedAt || '')
}
};
}
catch (error) {
return this.formatErrorResponse(error);
}
}
/**
* Search pages by keyword
* @param query Search query string
* @param limit Number of results to return
* @param offset Pagination offset
*/
async searchPages(query, limit = 20, offset = 0) {
try {
const data = await this.request('get', '/_api/v3/search', {
q: query,
limit,
offset,
});
return {
ok: true,
data: Array.isArray(data.data)
? data.data.map((p) => ({
...p,
_id: String(p._id),
path: String(p.path),
}))
: [],
meta: {
total: Number(data.meta?.total || 0),
took: Number(data.meta?.took || 0),
hitsCount: Number(data.meta?.hitsCount || 0),
},
};
}
catch (error) {
return this.formatErrorResponse(error);
}
}
/**
* Check if a page exists by path
* @param path Page path
*/
async pageExists(path) {
try {
const data = await this.request('get', '/_api/v3/page/exist', {
path,
});
return {
ok: true,
exists: Boolean(data.ok),
};
}
catch (error) {
return this.formatErrorResponse(error);
}
}
}
//# sourceMappingURL=growi-client.js.map