Skip to main content
Glama

publish_note

Publishes articles to note.com by automatically extracting title, content, and tags from Markdown files and posting them directly to your account.

Instructions

note.comに記事を公開します。Markdownファイルからタイトル、本文、タグを読み取り、自動的に投稿します。

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
markdown_pathYesMarkdownファイルのパス(タイトル、本文、タグを含む)
thumbnail_pathNoサムネイル画像のパス(オプション)
state_pathNonote.comの認証状態ファイルのパス(デフォルト: /app/.note-state.json)
screenshot_dirNoスクリーンショット保存ディレクトリ(オプション)
timeoutNoタイムアウト(ミリ秒、デフォルト: 120000)

Implementation Reference

  • Tool handler within the MCP callToolRequestHandler that parses input parameters using the schema and invokes the postToNote function with isPublic: true to publish the article on note.com.
    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),
          },
        ],
      };
    }
  • Zod schema defining the input parameters and validation for the publish_note tool.
    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})`),
    });
  • src/index.ts:611-640 (registration)
    Registration of the publish_note tool in the TOOLS array, which is returned by the listTools MCP handler, including name, description, and JSON schema.
    {
      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'],
      },
    },
  • Core helper function that performs the actual browser automation to post or publish the note on note.com using Playwright. Called by both publish_note and save_draft handlers.
    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;
      }
    }
  • Helper function to parse Markdown file content, extracting title from frontmatter or H1, tags from frontmatter array or list, and body text.
    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),
      };
    }
Behavior2/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations are provided, so the description carries the full burden of behavioral disclosure. It mentions that the tool reads from a Markdown file and automatically posts, but lacks details on critical behaviors: authentication requirements (implied by 'state_path' but not explained), error handling, rate limits, or what happens on failure (e.g., if the file is invalid). For a mutation tool with zero annotation coverage, this is a significant gap in transparency.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is a single, efficient sentence in Japanese that front-loads the core action ('記事を公開します') and explains the mechanism. It avoids redundancy and wastes no words, though it could be slightly more structured (e.g., separating key points). Every part of the sentence contributes to understanding the tool's function.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness2/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the complexity (a mutation tool with 5 parameters, no annotations, and no output schema), the description is incomplete. It lacks information on authentication, error handling, return values, or how it differs from 'save_draft'. While the schema covers parameters well, the description doesn't compensate for missing behavioral and contextual details needed for safe and effective use.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 100%, so the schema already documents all parameters thoroughly. The description adds minimal value beyond the schema: it implies that 'markdown_path' contains title, body, and tags, but this is already stated in the schema's description for that parameter. No additional semantics or usage context are provided for other parameters, meeting the baseline for high schema coverage.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool's purpose: '記事を公開します' (publishes an article) on note.com using a Markdown file. It specifies the action (publish), resource (article), and mechanism (reading from Markdown file). However, it doesn't explicitly differentiate from the sibling tool 'save_draft' (which likely saves rather than publishes), though the distinction is implied through the verb '公開' (publish).

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description provides no guidance on when to use this tool versus alternatives. It doesn't mention the sibling tool 'save_draft' or any other options, nor does it specify prerequisites (e.g., authentication state) or scenarios where this tool is preferred. Usage is implied only by the action of publishing, with no explicit context or exclusions.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

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