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/src/note_mcp/server.py b/src/note_mcp/server.py index 3508fbf..9d3a0ec 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,22 @@ 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}" + 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: - # 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/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: " diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index bb7d2f1..755a933 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -426,3 +426,247 @@ 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 + + @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 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" },