UNPKG

growi-mcp-server

Version:

MCP Server for GROWI - a modern Wiki system

330 lines 12.8 kB
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