Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
27 changes: 23 additions & 4 deletions src/note_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -418,26 +419,44 @@ 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でログインしてください。"

# 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,
Expand Down
2 changes: 1 addition & 1 deletion tests/contract/test_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: "
Expand Down
244 changes: 244 additions & 0 deletions tests/unit/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.