From 2d9a0e240273e10d9a28262561d33db6a3f55bdc Mon Sep 17 00:00:00 2001 From: driller Date: Sun, 1 Feb 2026 21:35:19 +0900 Subject: [PATCH 1/4] feat(#258): add file_path parameter to note_publish_article Add file_path parameter to note_publish_article tool to extract tags from Markdown frontmatter when publishing existing drafts. This allows users to preserve tags from the original Markdown file during publish. - When article_id is specified with file_path (and no tags), tags are extracted from the Markdown file's YAML frontmatter - Explicit tags parameter takes precedence over file_path - file_path is ignored for new article creation (title/body mode) - Returns appropriate error message if file_path doesn't exist Co-Authored-By: Claude Opus 4.5 --- src/note_mcp/server.py | 25 ++++- tests/unit/test_server.py | 214 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 4 deletions(-) diff --git a/src/note_mcp/server.py b/src/note_mcp/server.py index 3508fbf..4acff39 100644 --- a/src/note_mcp/server.py +++ b/src/note_mcp/server.py @@ -408,6 +408,7 @@ async def note_get_preview_html( @mcp.tool() async def note_publish_article( article_id: Annotated[str | None, "公開する下書き記事のID(新規作成時は省略)"] = None, + file_path: Annotated[str | None, "タグを取得するMarkdownファイルのパス"] = None, title: Annotated[str | None, "記事タイトル(新規作成時は必須)"] = None, body: Annotated[str | None, "記事本文(Markdown形式、新規作成時は必須)"] = None, tags: Annotated[list[str] | None, "記事のタグ(#なしでも可)"] = None, @@ -418,15 +419,21 @@ async def note_publish_article( article_idを指定すると既存の下書きを公開します。 title/bodyを指定すると新規記事を作成して公開します。 + 既存の下書きを公開する際、tagsが未指定でfile_pathが指定されている場合、 + Markdownファイルのフロントマターからタグを取得します。 + Args: article_id: 公開する下書き記事のID(新規作成時は省略) + file_path: タグを取得するMarkdownファイルのパス(既存下書き公開時のみ有効) title: 記事タイトル(新規作成時は必須) body: 記事本文(Markdown形式、新規作成時は必須) - tags: 記事のタグ(オプション) + tags: 記事のタグ(オプション、file_pathより優先) Returns: 公開結果のメッセージ(記事URLを含む) """ + from pathlib import Path + session = _session_manager.load() if session is None or session.is_expired(): return "セッションが無効です。note_loginでログインしてください。" @@ -434,10 +441,20 @@ async def note_publish_article( # Determine whether to publish existing or create new try: if article_id is not None: - # Publish existing draft (Issue #252: pass tags to set during publish) - article = await publish_article(session, article_id=article_id, tags=tags) + # Publish existing draft + publish_tags = tags + + # Issue #258: If tags not specified but file_path is, get tags from file + if publish_tags is None and file_path is not None: + try: + parsed = parse_markdown_file(Path(file_path)) + publish_tags = parsed.tags if parsed.tags else [] + except FileNotFoundError: + return f"ファイルが見つかりません: {file_path}" + + article = await publish_article(session, article_id=article_id, tags=publish_tags) elif title is not None and body is not None: - # Create and publish new article + # Create and publish new article (file_path is ignored for new articles) article_input = ArticleInput( title=title, body=body, diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index bb7d2f1..47164e0 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -426,3 +426,217 @@ async def test_eyecatch_api_error_shows_filename( assert "⚠️ アイキャッチ画像アップロード失敗" in result assert "header.png" in result assert "Server error" in result + + +class TestNotePublishArticle: + """Tests for note_publish_article function.""" + + @pytest.mark.asyncio + async def test_file_path_provides_tags_when_tags_not_specified( + self, + tmp_path: Path, + ) -> None: + """file_path指定時、タグがファイルから取得されて公開される。""" + # Create a test markdown file with tags + md_file = tmp_path / "test.md" + md_file.write_text("---\ntitle: Test Article\ntags:\n - Python\n - MCP\n---\n\nBody content") + + mock_session = MagicMock() + mock_article = Article( + id="123456789", + key="n1234567890ab", + title="Test Article", + status=ArticleStatus.PUBLISHED, + body="Body content", + url="https://note.com/user/n/n1234567890ab", + ) + + with ( + patch("note_mcp.server._session_manager") as mock_session_manager, + patch("note_mcp.server.publish_article", new_callable=AsyncMock) as mock_publish, + ): + mock_session.is_expired.return_value = False + mock_session_manager.load.return_value = mock_session + mock_publish.return_value = mock_article + + from note_mcp.server import note_publish_article + + fn = note_publish_article.fn + result = await fn(article_id="123456789", file_path=str(md_file)) + + # publish_article should be called with tags from file + mock_publish.assert_called_once() + call_kwargs = mock_publish.call_args[1] + assert call_kwargs["tags"] == ["Python", "MCP"] + + # Result should indicate success + assert "公開しました" in result + assert "123456789" in result + + @pytest.mark.asyncio + async def test_tags_parameter_takes_precedence_over_file_path( + self, + tmp_path: Path, + ) -> None: + """tagsとfile_path両方指定時、tagsが優先される。""" + # Create a test markdown file with tags + md_file = tmp_path / "test.md" + md_file.write_text("---\ntitle: Test Article\ntags:\n - FileTag1\n - FileTag2\n---\n\nBody") + + mock_session = MagicMock() + mock_article = Article( + id="123456789", + key="n1234567890ab", + title="Test Article", + status=ArticleStatus.PUBLISHED, + body="Body", + url="https://note.com/user/n/n1234567890ab", + ) + + with ( + patch("note_mcp.server._session_manager") as mock_session_manager, + patch("note_mcp.server.publish_article", new_callable=AsyncMock) as mock_publish, + patch("note_mcp.server.parse_markdown_file") as mock_parse, + ): + mock_session.is_expired.return_value = False + mock_session_manager.load.return_value = mock_session + mock_publish.return_value = mock_article + + from note_mcp.server import note_publish_article + + fn = note_publish_article.fn + # Specify both tags and file_path + result = await fn( + article_id="123456789", + tags=["ExplicitTag1", "ExplicitTag2"], + file_path=str(md_file), + ) + + # publish_article should be called with explicit tags, not file tags + mock_publish.assert_called_once() + call_kwargs = mock_publish.call_args[1] + assert call_kwargs["tags"] == ["ExplicitTag1", "ExplicitTag2"] + + # parse_markdown_file should NOT be called when tags are provided + mock_parse.assert_not_called() + + assert "公開しました" in result + + @pytest.mark.asyncio + async def test_file_path_without_tags_publishes_without_tags( + self, + tmp_path: Path, + ) -> None: + """file_path指定、タグなしの場合、タグなしで公開される。""" + # Create a test markdown file without tags + md_file = tmp_path / "test.md" + md_file.write_text("---\ntitle: Test Article\n---\n\nBody without tags") + + mock_session = MagicMock() + mock_article = Article( + id="123456789", + key="n1234567890ab", + title="Test Article", + status=ArticleStatus.PUBLISHED, + body="Body without tags", + url="https://note.com/user/n/n1234567890ab", + ) + + with ( + patch("note_mcp.server._session_manager") as mock_session_manager, + patch("note_mcp.server.publish_article", new_callable=AsyncMock) as mock_publish, + ): + mock_session.is_expired.return_value = False + mock_session_manager.load.return_value = mock_session + mock_publish.return_value = mock_article + + from note_mcp.server import note_publish_article + + fn = note_publish_article.fn + result = await fn(article_id="123456789", file_path=str(md_file)) + + # publish_article should be called with empty tags list + mock_publish.assert_called_once() + call_kwargs = mock_publish.call_args[1] + assert call_kwargs["tags"] == [] + + assert "公開しました" in result + + @pytest.mark.asyncio + async def test_file_path_not_found_returns_error( + self, + tmp_path: Path, + ) -> None: + """file_pathが存在しない場合、適切なエラーメッセージを返す。""" + non_existent_file = tmp_path / "non_existent.md" + + mock_session = MagicMock() + + with ( + patch("note_mcp.server._session_manager") as mock_session_manager, + patch("note_mcp.server.publish_article", new_callable=AsyncMock) as mock_publish, + ): + mock_session.is_expired.return_value = False + mock_session_manager.load.return_value = mock_session + + from note_mcp.server import note_publish_article + + fn = note_publish_article.fn + result = await fn(article_id="123456789", file_path=str(non_existent_file)) + + # publish_article should NOT be called + mock_publish.assert_not_called() + + # Result should contain error message + assert "ファイルが見つかりません" in result + assert str(non_existent_file) in result + + @pytest.mark.asyncio + async def test_file_path_ignored_for_new_article( + self, + tmp_path: Path, + ) -> None: + """新規作成時(article_idなし)はfile_pathが無視される。""" + # Create a test markdown file with tags + md_file = tmp_path / "test.md" + md_file.write_text("---\ntitle: File Title\ntags:\n - FileTag\n---\n\nFile Body") + + mock_session = MagicMock() + mock_article = Article( + id="123456789", + key="n1234567890ab", + title="New Title", + status=ArticleStatus.PUBLISHED, + body="New Body", + url="https://note.com/user/n/n1234567890ab", + ) + + with ( + patch("note_mcp.server._session_manager") as mock_session_manager, + patch("note_mcp.server.publish_article", new_callable=AsyncMock) as mock_publish, + patch("note_mcp.server.parse_markdown_file") as mock_parse, + ): + mock_session.is_expired.return_value = False + mock_session_manager.load.return_value = mock_session + mock_publish.return_value = mock_article + + from note_mcp.server import note_publish_article + + fn = note_publish_article.fn + # New article creation (no article_id) + result = await fn( + title="New Title", + body="New Body", + file_path=str(md_file), + ) + + # parse_markdown_file should NOT be called for new article + mock_parse.assert_not_called() + + # publish_article should be called with article_input (not article_id) + mock_publish.assert_called_once() + call_kwargs = mock_publish.call_args[1] + assert "article_input" in call_kwargs + assert "article_id" not in call_kwargs + + assert "公開しました" in result From 7dac145874a0e629798b0d9b9b69f0c72046d46c Mon Sep 17 00:00:00 2001 From: driller Date: Sun, 1 Feb 2026 21:38:00 +0900 Subject: [PATCH 2/4] test(#258): update contract test for file_path parameter Add file_path to expected properties in note_publish_article schema test. Co-Authored-By: Claude Opus 4.5 --- tests/contract/test_mcp_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/contract/test_mcp_tools.py b/tests/contract/test_mcp_tools.py index 26c9e86..bc94fb8 100644 --- a/tests/contract/test_mcp_tools.py +++ b/tests/contract/test_mcp_tools.py @@ -335,7 +335,7 @@ def test_note_publish_article_schema(self) -> None: assert "properties" in schema # Exact properties match - expected_properties = {"article_id", "title", "body", "tags"} + expected_properties = {"article_id", "file_path", "title", "body", "tags"} actual_properties = set(schema.get("properties", {}).keys()) assert actual_properties == expected_properties, ( f"Schema mismatch: " From e654b2087f589191f48a222d3f2f63cfc0150354 Mon Sep 17 00:00:00 2001 From: driller Date: Sun, 1 Feb 2026 21:40:57 +0900 Subject: [PATCH 3/4] fix(#258): handle ValueError in file_path parsing Add ValueError handling when parsing Markdown file for tags extraction. This prevents unhandled exceptions when the file has no title. Also add test case for invalid Markdown file scenario. Co-Authored-By: Claude Opus 4.5 --- src/note_mcp/server.py | 2 ++ tests/unit/test_server.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/note_mcp/server.py b/src/note_mcp/server.py index 4acff39..9d3a0ec 100644 --- a/src/note_mcp/server.py +++ b/src/note_mcp/server.py @@ -451,6 +451,8 @@ async def note_publish_article( publish_tags = parsed.tags if parsed.tags else [] except FileNotFoundError: return f"ファイルが見つかりません: {file_path}" + except ValueError as e: + return f"ファイル解析エラー: {e}" article = await publish_article(session, article_id=article_id, tags=publish_tags) elif title is not None and body is not None: diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index 47164e0..755a933 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -640,3 +640,33 @@ async def test_file_path_ignored_for_new_article( assert "article_id" not in call_kwargs assert "公開しました" in result + + @pytest.mark.asyncio + async def test_file_path_with_invalid_markdown_returns_error( + self, + tmp_path: Path, + ) -> None: + """file_pathのMarkdownが解析できない場合、エラーメッセージを返す。""" + # Create a markdown file without title (no frontmatter, no heading) + md_file = tmp_path / "no_title.md" + md_file.write_text("No frontmatter, no heading\n\nJust body content") + + mock_session = MagicMock() + + with ( + patch("note_mcp.server._session_manager") as mock_session_manager, + patch("note_mcp.server.publish_article", new_callable=AsyncMock) as mock_publish, + ): + mock_session.is_expired.return_value = False + mock_session_manager.load.return_value = mock_session + + from note_mcp.server import note_publish_article + + fn = note_publish_article.fn + result = await fn(article_id="123456789", file_path=str(md_file)) + + # publish_article should NOT be called + mock_publish.assert_not_called() + + # Result should contain error message + assert "ファイル解析エラー" in result From d9dbc2c86c0c507858216d04cbb4cc958c9cafe6 Mon Sep 17 00:00:00 2001 From: driller Date: Sun, 1 Feb 2026 21:41:37 +0900 Subject: [PATCH 4/4] chore: revert version to 0.0.1 Co-Authored-By: Claude Opus 4.5 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 44e0882..34d9c62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "note-mcp" -version = "0.1.0" +version = "0.0.1" description = "MCP server for managing note.com articles" readme = "README.md" requires-python = ">=3.13" diff --git a/uv.lock b/uv.lock index 60160e9..32235ae 100644 --- a/uv.lock +++ b/uv.lock @@ -1138,7 +1138,7 @@ wheels = [ [[package]] name = "note-mcp" -version = "0.1.0" +version = "0.0.1" source = { editable = "." } dependencies = [ { name = "fastmcp" },