Skip to main content
Glama
index.ts26.6 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, Tool, } from '@modelcontextprotocol/sdk/types.js'; import { chromium } from 'playwright'; import { z } from 'zod'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import 'dotenv/config'; // 名称一貫性 const SERVER_NAME = process.env.MCP_NAME ?? 'note-post-mcp'; const SERVER_VERSION = '1.0.0'; // 環境変数デフォルト const DEFAULT_STATE_PATH = process.env.NOTE_POST_MCP_STATE_PATH ?? path.join(os.homedir(), '.note-state.json'); const DEFAULT_TIMEOUT = parseInt(process.env.NOTE_POST_MCP_TIMEOUT ?? '180000', 10); // ログ用ユーティリティ function log(message: string, data?: any) { const timestamp = new Date().toISOString(); console.error(`[${timestamp}] [${SERVER_NAME}] ${message}`, data ?? ''); } // 現在時刻のフォーマット function nowStr(): string { const d = new Date(); const z = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}_${z(d.getHours())}-${z(d.getMinutes())}-${z(d.getSeconds())}`; } // 画像情報の型定義 interface ImageInfo { alt: string; localPath: string; absolutePath: string; placeholder: string; } // Markdownから画像パスを抽出する関数 function extractImages(markdown: string, baseDir: string): ImageInfo[] { const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; const images: ImageInfo[] = []; let match; while ((match = imageRegex.exec(markdown)) !== null) { const alt = match[1] || 'image'; const imagePath = match[2]; // URLではなくローカルパスの場合のみ処理 if (!imagePath.startsWith('http://') && !imagePath.startsWith('https://')) { const absolutePath = path.resolve(baseDir, imagePath); if (fs.existsSync(absolutePath)) { images.push({ alt, localPath: imagePath, absolutePath, placeholder: match[0], // 元のマークダウン記法全体 }); } else { log(`Warning: Image file not found: ${absolutePath}`); } } } return images; } // Markdownファイルをパースする関数 function parseMarkdown(content: string): { title: string; body: string; tags: string[]; } { const lines = content.split('\n'); let title = ''; let body = ''; const tags: string[] = []; let inFrontMatter = false; let frontMatterEnded = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Front matter の処理(YAML形式) if (line.trim() === '---') { if (!frontMatterEnded) { inFrontMatter = !inFrontMatter; if (!inFrontMatter) { frontMatterEnded = true; } continue; } } if (inFrontMatter) { // タイトルとタグをfront matterから抽出 if (line.startsWith('title:')) { title = line.substring(6).trim().replace(/^["']|["']$/g, ''); } else if (line.startsWith('tags:')) { const tagsStr = line.substring(5).trim(); if (tagsStr.startsWith('[') && tagsStr.endsWith(']')) { // 配列形式: tags: [tag1, tag2] tags.push(...tagsStr.slice(1, -1).split(',').map(t => t.trim().replace(/^["']|["']$/g, ''))); } } else if (line.trim().startsWith('-')) { // 配列形式: - tag1 const tag = line.trim().substring(1).trim().replace(/^["']|["']$/g, ''); if (tag) tags.push(tag); } continue; } // タイトルを # から抽出(front matterがない場合) if (!title && line.startsWith('# ')) { title = line.substring(2).trim(); continue; } // 本文を追加 if (frontMatterEnded || !line.trim().startsWith('---')) { body += line + '\n'; } } return { title: title || 'Untitled', body: body.trim(), tags: tags.filter(Boolean), }; } // note.com投稿関数 async function postToNote(params: { markdownPath: string; thumbnailPath?: string; statePath?: string; isPublic: boolean; screenshotDir?: string; timeout?: number; }): Promise<{ success: boolean; url: string; screenshot?: string; message: string; }> { const { markdownPath, thumbnailPath, statePath = DEFAULT_STATE_PATH, isPublic, screenshotDir = path.join(os.tmpdir(), 'note-screenshots'), timeout = DEFAULT_TIMEOUT, } = params; // Markdownファイルを読み込み if (!fs.existsSync(markdownPath)) { throw new Error(`Markdown file not found: ${markdownPath}`); } const mdContent = fs.readFileSync(markdownPath, 'utf-8'); const { title, body, tags } = parseMarkdown(mdContent); // 本文中の画像を抽出 const baseDir = path.dirname(markdownPath); const images = extractImages(body, baseDir); log('Parsed markdown', { title, bodyLength: body.length, tags, imageCount: images.length }); // 認証状態ファイルを確認 if (!fs.existsSync(statePath)) { throw new Error(`State file not found: ${statePath}. Please login first.`); } // スクリーンショットディレクトリを作成 fs.mkdirSync(screenshotDir, { recursive: true }); const screenshotPath = path.join(screenshotDir, `note-post-${nowStr()}.png`); const browser = await chromium.launch({ headless: true, args: ['--lang=ja-JP'], }); try { const context = await browser.newContext({ storageState: statePath, locale: 'ja-JP', permissions: ['clipboard-read', 'clipboard-write'], }); const page = await context.newPage(); page.setDefaultTimeout(timeout); // クリップボード権限を明示的に付与 await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: 'https://editor.note.com' }); // 新規記事作成ページに移動 const startUrl = 'https://editor.note.com/new'; await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout }); await page.waitForSelector('textarea[placeholder*="タイトル"]', { timeout }); // サムネイル画像の設定 if (thumbnailPath && fs.existsSync(thumbnailPath)) { log('Uploading thumbnail image'); const candidates = page.locator('button[aria-label="画像を追加"]'); await candidates.first().waitFor({ state: 'visible', timeout }); let target = candidates.first(); const cnt = await candidates.count(); if (cnt > 1) { let minY = Infinity; let idx = 0; for (let i = 0; i < cnt; i++) { const box = await candidates.nth(i).boundingBox(); if (box && box.y < minY) { minY = box.y; idx = i; } } target = candidates.nth(idx); } await target.scrollIntoViewIfNeeded(); await target.click({ force: true }); const uploadBtn = page.locator('button:has-text("画像をアップロード")').first(); await uploadBtn.waitFor({ state: 'visible', timeout }); let chooser = null; try { [chooser] = await Promise.all([ page.waitForEvent('filechooser', { timeout: 5000 }), uploadBtn.click({ force: true }), ]); } catch (_) { // フォールバック } if (chooser) { await chooser.setFiles(thumbnailPath); } else { await uploadBtn.click({ force: true }).catch(() => {}); const fileInput = page.locator('input[type="file"]').first(); await fileInput.waitFor({ state: 'attached', timeout }); await fileInput.setInputFiles(thumbnailPath); } // トリミングダイアログ内「保存」を押す const dialog = page.locator('div[role="dialog"]'); await dialog.waitFor({ state: 'visible', timeout }); const saveThumbBtn = dialog.locator('button:has-text("保存")').first(); const cropper = dialog.locator('[data-testid="cropper"]').first(); const cropperEl = await cropper.elementHandle(); const saveEl = await saveThumbBtn.elementHandle(); if (cropperEl && saveEl) { await Promise.race([ page.waitForFunction( (el) => getComputedStyle(el as Element).pointerEvents === 'none', cropperEl, { timeout } ), page.waitForFunction( (el) => !(el as HTMLButtonElement).disabled, saveEl, { timeout } ), ]); } await saveThumbBtn.click(); await dialog.waitFor({ state: 'hidden', timeout }).catch(() => {}); await page.waitForLoadState('networkidle', { timeout }).catch(() => {}); // 反映確認 const changedBtn = page.locator('button[aria-label="画像を変更"]'); const addBtn = page.locator('button[aria-label="画像を追加"]'); let applied = false; try { await changedBtn.waitFor({ state: 'visible', timeout: 5000 }); applied = true; } catch {} if (!applied) { try { await addBtn.waitFor({ state: 'hidden', timeout: 5000 }); applied = true; } catch {} } if (!applied) { log('Thumbnail reflection uncertain, continuing'); } } // タイトル設定 await page.fill('textarea[placeholder*="タイトル"]', title); log('Title set'); // 本文設定(行ごとに処理してURLをリンクカードに変換、画像を埋め込む) const bodyBox = page.locator('div[contenteditable="true"][role="textbox"]').first(); await bodyBox.waitFor({ state: 'visible' }); await bodyBox.click(); const lines = body.split('\n'); let previousLineWasList = false; // 前の行がリスト項目だったかを追跡 let previousLineWasQuote = false; // 前の行が引用だったかを追跡 let previousLineWasHorizontalRule = false; // 前の行が水平線だったかを追跡 for (let i = 0; i < lines.length; i++) { const line = lines[i]; const isLastLine = i === lines.length - 1; // コードブロックの開始を検出 if (line.trim().startsWith('```')) { // コードブロック全体(```から```まで)を収集 const codeBlockLines: string[] = [line]; // 開始行を含める let j = i + 1; // 終了行まで収集 while (j < lines.length) { codeBlockLines.push(lines[j]); if (lines[j].trim().startsWith('```')) { break; // 終了行を含めて終了 } j++; } // コードブロック全体をクリップボードにコピー const codeBlockContent = codeBlockLines.join('\n'); await page.evaluate((text) => { return navigator.clipboard.writeText(text); }, codeBlockContent); await page.waitForTimeout(200); // ペースト const isMac = process.platform === 'darwin'; if (isMac) { await page.keyboard.press('Meta+v'); } else { await page.keyboard.press('Control+v'); } await page.waitForTimeout(300); // コードブロックの後に改行(最終行でない場合) if (j < lines.length - 1) { await page.keyboard.press('Enter'); } // iをコードブロック終了行まで進める i = j; // フラグをリセット previousLineWasList = false; previousLineWasQuote = false; previousLineWasHorizontalRule = false; continue; } // 次の行が水平線かどうかをチェック const nextLine = i < lines.length - 1 ? lines[i + 1] : ''; const nextLineIsHorizontalRule = nextLine.trim() === '---'; // 水平線の直後の空行をスキップ if (previousLineWasHorizontalRule && line.trim() === '') { previousLineWasHorizontalRule = false; continue; // 空行をスキップ } previousLineWasHorizontalRule = false; // 画像マークダウンを検出 const imageMatch = line.match(/!\[([^\]]*)\]\(([^)]+)\)/); if (imageMatch) { const imagePath = imageMatch[2]; // ローカルパスの画像をアップロード if (!imagePath.startsWith('http://') && !imagePath.startsWith('https://')) { const imageInfo = images.find(img => img.localPath === imagePath); if (imageInfo && fs.existsSync(imageInfo.absolutePath)) { log('Pasting inline image', { path: imageInfo.absolutePath }); // 画像をクリップボードにコピーしてペーストする方法 // 1. 改行して新しい行を作成 await page.keyboard.press('Enter'); await page.waitForTimeout(300); // 2. 画像ファイルをクリップボードにコピー const imageBuffer = fs.readFileSync(imageInfo.absolutePath); const base64Image = imageBuffer.toString('base64'); const mimeType = imageInfo.absolutePath.endsWith('.png') ? 'image/png' : imageInfo.absolutePath.endsWith('.jpg') || imageInfo.absolutePath.endsWith('.jpeg') ? 'image/jpeg' : imageInfo.absolutePath.endsWith('.gif') ? 'image/gif' : 'image/png'; // クリップボードに画像を設定するためのJavaScriptを実行 await page.evaluate(async ({ base64, mime }) => { const response = await fetch(`data:${mime};base64,${base64}`); const blob = await response.blob(); const item = new ClipboardItem({ [mime]: blob }); await navigator.clipboard.write([item]); }, { base64: base64Image, mime: mimeType }); await page.waitForTimeout(500); // 3. Cmd+V (macOS) または Ctrl+V でペースト const isMac = process.platform === 'darwin'; if (isMac) { await page.keyboard.press('Meta+v'); } else { await page.keyboard.press('Control+v'); } // ペースト完了を待つ await page.waitForTimeout(2000); log('Inline image pasted'); // 画像の後に改行してテキストボックスに戻る if (!isLastLine) { await page.keyboard.press('Enter'); } previousLineWasList = false; // 画像の後はリストではない previousLineWasQuote = false; // 画像の後は引用ではない previousLineWasHorizontalRule = false; // 画像の後は水平線ではない continue; // 次の行へ } } } // 水平線かどうかをチェック const isHorizontalRule = line.trim() === '---'; // 現在の行がリスト項目かどうかをチェック const isBulletList = /^(\s*)- /.test(line); const isNumberedList = /^(\s*)\d+\.\s/.test(line); const isCurrentLineList = isBulletList || isNumberedList; // 現在の行が引用かどうかをチェック const isQuote = /^>/.test(line); // 通常のテキスト行を入力 let processedLine = line; // 前の行がリスト項目で、現在の行もリスト項目なら、マークダウン記号を削除 if (previousLineWasList && isCurrentLineList) { // 箇条書きリスト: "- " または " - " などを削除 // 先頭のスペース(インデント)を保持しつつ、"- " だけを削除 if (isBulletList) { processedLine = processedLine.replace(/^(\s*)- /, '$1'); } // 番号付きリスト: "1. " または " 1. " などを削除 // 先頭のスペース(インデント)を保持しつつ、"数字. " だけを削除 if (isNumberedList) { processedLine = processedLine.replace(/^(\s*)\d+\.\s/, '$1'); } } // 前の行が引用で、現在の行も引用なら、マークダウン記号を削除 if (previousLineWasQuote && isQuote) { // 引用: "> " を削除 processedLine = processedLine.replace(/^>\s?/, ''); } await page.keyboard.type(processedLine); // 次の行のために、現在の行の状態を記録 previousLineWasList = isCurrentLineList; previousLineWasQuote = isQuote; previousLineWasHorizontalRule = isHorizontalRule; // URL単独行の場合、追加でEnterを押してリンクカード化をトリガー const isUrlLine = /^https?:\/\/[^\s]+$/.test(line.trim()); if (isUrlLine) { await page.keyboard.press('Enter'); // リンクカード展開のアニメーション完了を待機 await page.waitForTimeout(1200); // 次の行がある場合、キャレットがカード内に残らないよう、確実に次段落へ移動 if (!isLastLine) { await page.keyboard.press('ArrowDown'); await page.waitForTimeout(150); } } else { // URL以外の行の場合のみ、最後の行でなければ改行 if (!isLastLine) { await page.keyboard.press('Enter'); } } } log('Body set'); // 下書き保存の場合 if (!isPublic) { const saveBtn = page.locator('button:has-text("下書き保存"), [aria-label*="下書き保存"]').first(); await saveBtn.waitFor({ state: 'visible', timeout }); if (await saveBtn.isEnabled()) { await saveBtn.click(); await page.locator('text=保存しました').waitFor({ timeout: 4000 }).catch(() => {}); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); } await page.screenshot({ path: screenshotPath, fullPage: true }); const finalUrl = page.url(); log('Draft saved', { url: finalUrl }); await context.close(); await browser.close(); return { success: true, url: finalUrl, screenshot: screenshotPath, message: '下書きを保存しました', }; } // 公開に進む const proceedBtn = page.locator('button:has-text("公開に進む")').first(); await proceedBtn.waitFor({ state: 'visible', timeout }); for (let i = 0; i < 20; i++) { if (await proceedBtn.isEnabled()) break; await page.waitForTimeout(100); } await proceedBtn.click({ force: true }); // 公開ページへ遷移 await Promise.race([ page.waitForURL(/\/publish/i, { timeout }).catch(() => {}), page.locator('button:has-text("投稿する")').first().waitFor({ state: 'visible', timeout }).catch(() => {}), ]); // タグ入力 if (tags.length > 0) { log('Adding tags', { tags }); let tagInput = page.locator('input[placeholder*="ハッシュタグ"]'); if (!(await tagInput.count())) { tagInput = page.locator('input[role="combobox"]').first(); } await tagInput.waitFor({ state: 'visible', timeout }); for (const tag of tags) { await tagInput.click(); await tagInput.fill(tag); await page.keyboard.press('Enter'); await page.waitForTimeout(120); } } // 投稿する const publishBtn = page.locator('button:has-text("投稿する")').first(); await publishBtn.waitFor({ state: 'visible', timeout }); for (let i = 0; i < 20; i++) { if (await publishBtn.isEnabled()) break; await page.waitForTimeout(100); } await publishBtn.click({ force: true }); // 投稿完了待ち await Promise.race([ page.waitForURL((url) => !/\/publish/i.test(url.toString()), { timeout: 20000 }).catch(() => {}), page.locator('text=投稿しました').first().waitFor({ timeout: 8000 }).catch(() => {}), page.waitForTimeout(5000), ]); await page.screenshot({ path: screenshotPath, fullPage: true }); const finalUrl = page.url(); log('Published', { url: finalUrl }); await context.close(); await browser.close(); return { success: true, url: finalUrl, screenshot: screenshotPath, message: '記事を公開しました', }; } catch (error) { await browser.close(); throw error; } } // Zodスキーマ定義 const PublishNoteSchema = z.object({ markdown_path: z.string().describe('Markdownファイルのパス(タイトル、本文、タグを含む)'), thumbnail_path: z.string().optional().describe('サムネイル画像のパス(オプション)'), state_path: z.string().optional().describe(`note.comの認証状態ファイルのパス(デフォルト: ${DEFAULT_STATE_PATH})`), screenshot_dir: z.string().optional().describe('スクリーンショット保存ディレクトリ(オプション)'), timeout: z.number().optional().describe(`タイムアウト(ミリ秒、デフォルト: ${DEFAULT_TIMEOUT})`), }); const SaveDraftSchema = z.object({ markdown_path: z.string().describe('Markdownファイルのパス(タイトル、本文、タグを含む)'), thumbnail_path: z.string().optional().describe('サムネイル画像のパス(オプション)'), state_path: z.string().optional().describe(`note.comの認証状態ファイルのパス(デフォルト: ${DEFAULT_STATE_PATH})`), screenshot_dir: z.string().optional().describe('スクリーンショット保存ディレクトリ(オプション)'), timeout: z.number().optional().describe(`タイムアウト(ミリ秒、デフォルト: ${DEFAULT_TIMEOUT})`), }); // ツール定義 const TOOLS: Tool[] = [ { name: 'publish_note', description: 'note.comに記事を公開します。Markdownファイルからタイトル、本文、タグを読み取り、自動的に投稿します。', inputSchema: { type: 'object', properties: { markdown_path: { type: 'string', description: 'Markdownファイルのパス(タイトル、本文、タグを含む)', }, thumbnail_path: { type: 'string', description: 'サムネイル画像のパス(オプション)', }, state_path: { type: 'string', description: `note.comの認証状態ファイルのパス(デフォルト: ${DEFAULT_STATE_PATH})`, }, screenshot_dir: { type: 'string', description: 'スクリーンショット保存ディレクトリ(オプション)', }, timeout: { type: 'number', description: `タイムアウト(ミリ秒、デフォルト: ${DEFAULT_TIMEOUT})`, }, }, required: ['markdown_path'], }, }, { name: 'save_draft', description: 'note.comに下書きを保存します。Markdownファイルからタイトル、本文、タグを読み取り、下書きとして保存します。', inputSchema: { type: 'object', properties: { markdown_path: { type: 'string', description: 'Markdownファイルのパス(タイトル、本文、タグを含む)', }, thumbnail_path: { type: 'string', description: 'サムネイル画像のパス(オプション)', }, state_path: { type: 'string', description: `note.comの認証状態ファイルのパス(デフォルト: ${DEFAULT_STATE_PATH})`, }, screenshot_dir: { type: 'string', description: 'スクリーンショット保存ディレクトリ(オプション)', }, timeout: { type: 'number', description: `タイムアウト(ミリ秒、デフォルト: ${DEFAULT_TIMEOUT})`, }, }, required: ['markdown_path'], }, }, ]; // MCPサーバーの初期化 const server = new Server( { name: SERVER_NAME, version: SERVER_VERSION, }, { capabilities: { tools: {}, }, } ); // ツール一覧ハンドラ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS, })); // ツール呼び出しハンドラ server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { if (name === 'publish_note') { const params = PublishNoteSchema.parse(args); const result = await postToNote({ markdownPath: params.markdown_path, thumbnailPath: params.thumbnail_path, statePath: params.state_path, screenshotDir: params.screenshot_dir, timeout: params.timeout, isPublic: true, }); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } if (name === 'save_draft') { const params = SaveDraftSchema.parse(args); const result = await postToNote({ markdownPath: params.markdown_path, thumbnailPath: params.thumbnail_path, statePath: params.state_path, screenshotDir: params.screenshot_dir, timeout: params.timeout, isPublic: false, }); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } throw new Error(`Unknown tool: ${name}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); log('Tool execution error', { name, error: errorMessage }); return { content: [ { type: 'text', text: JSON.stringify( { success: false, error: errorMessage, }, null, 2 ), }, ], isError: true, }; } }); // サーバー起動 async function main() { const transport = new StdioServerTransport(); await server.connect(transport); log('Server started', { name: SERVER_NAME, version: SERVER_VERSION }); } main().catch((error) => { log('Fatal error', error); process.exit(1); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Go-555/note-post-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server