diff --git a/pygittools-mcp/pyproject.toml b/pygittools-mcp/pyproject.toml
index 21e0486..e99c4b1 100644
--- a/pygittools-mcp/pyproject.toml
+++ b/pygittools-mcp/pyproject.toml
@@ -5,7 +5,8 @@ description = "MCP server exposing pygittools task boards to AI clients."
 requires-python = ">=3.11"
 dependencies = [
     "pygittools>=0.2.1",
-    "mcp>=1.28.0",
+    "mcp>=2.3.0",
+    "pytest-anyio>=0.0.0",
 ]
 
 [project.scripts]
@@ -14,6 +15,15 @@ pygittools-mcp = "pygittools_mcp.mcp_server:main"
 [tool.uv.sources]
 pygittools = { workspace = true }
 
+[tool.pytest.ini_options]
+anyio_mode = "auto"
+
 [build-system]
 requires = ["uv_build>=0.12.23,<0.13"]
 build-backend = "uv_build"
+
+[dependency-groups]
+dev = [
+    "mcp[cli,client]>=2.3.0",
+    "pytest-asyncio>=1.4.0",
+]
diff --git a/pygittools-mcp/src/pygittools_mcp/exceptions.py b/pygittools-mcp/src/pygittools_mcp/exceptions.py
new file mode 100644
index 0000000..6315b69
--- /dev/null
+++ b/pygittools-mcp/src/pygittools_mcp/exceptions.py
@@ -0,0 +1,37 @@
+from mcp.server.mcpserver.exceptions import ToolError
+from pygittools.tasks import Task
+
+
+class BoardNotFoundError(ToolError):
+	def __init__(self, board_name: str):
+		self.board_name = board_name
+		super().__init__(f"Board {board_name} not found")
+
+
+class TaskNotFoundError(ToolError):
+	def __init__(self, task_ref: str):
+		self.task_ref = task_ref
+		super().__init__(f"Task {task_ref} not found")
+
+
+class InvalidTaskStatusError(ToolError):
+	def __init__(self, value: str):
+		self.value = value
+		self.valid_values = [item.value for item in Task.Status]
+		super().__init__(
+			f"Invalid task status value, {value}. Valid task status values: ({','.join(self.valid_values)})"
+		)
+
+
+class InvalidTaskPriorityError(ToolError):
+	def __init__(self, value: str):
+		self.value = value
+		self.valid_values = [item.value for item in Task.Priority]
+		super().__init__(
+			f"Invalid task priority value, {value}. Valid task priority values: ({','.join(self.valid_values)})"
+		)
+
+
+class InvalidTaskDueDateError(ToolError):
+	def __init__(self, dt: str):
+		super().__init__(f"Invalid due date string, {dt}. Due dates must me an iso8601 timestamp")
diff --git a/pygittools-mcp/src/pygittools_mcp/mcp_server.py b/pygittools-mcp/src/pygittools_mcp/mcp_server.py
index d026dd0..2f79c3d 100644
--- a/pygittools-mcp/src/pygittools_mcp/mcp_server.py
+++ b/pygittools-mcp/src/pygittools_mcp/mcp_server.py
@@ -1,17 +1,19 @@
 """MCP server exposing pygittools task boards to AI clients."""
 
 from __future__ import annotations
-
 import json
 import os
 
-from mcp.server.fastmcp import FastMCP
+from mcp.server import MCPServer
+from pygittools.tasks import Task, _parse_dt,create_task
+from pygittools.tasks_query import board_ref
 from pygittools.tasks_query import (
 	BoardColumnView,
 	BoardView,
 	RepoLocation,
 	TaskView,
 	discover_repo_path,
+	get_board,
 	get_board_tasks_grouped,
 	get_task_view,
 	list_boards,
@@ -20,18 +22,19 @@ from pygittools.tasks_query import (
 	resolve_repo_location,
 )
 
-mcp = FastMCP(
+from pygittools_mcp.exceptions import BoardNotFoundError, InvalidTaskDueDateError, InvalidTaskPriorityError, InvalidTaskStatusError, TaskNotFoundError
+from pygittools_mcp.models import CreateTaskToolResult
+
+mcp = MCPServer(
 	"pygittools-tasks",
 	instructions=(
 		"Read task boards stored as Git annotated tags. "
 		"Boards live under refs/tags/boards/, tasks under refs/tags/tasks/, "
 		"and comments under refs/tags/comments/. "
 		"Set PYGITTOOLS_REPO to a repository path, or PYGITWEB_PROJECTROOT plus a project name."
-	),
-	json_response=True,
+	)
 )
 
-
 def _repo_for_tool(project: str | None = None) -> RepoLocation:
 	return resolve_repo_location(project=project)
 
@@ -56,8 +59,7 @@ def list_tasks_tool(
 	try:
 		return list_tasks(repo, board, include_comments=include_comments)
 	except KeyError as exc:
-		raise ValueError(str(exc)) from exc
-
+		raise BoardNotFoundError(board) from exc
 
 @mcp.tool(title="Get task")
 def get_task_tool(task_ref: str, project: str | None = None) -> TaskView:
@@ -67,7 +69,63 @@ def get_task_tool(task_ref: str, project: str | None = None) -> TaskView:
 	try:
 		return get_task_view(repo, task_ref, include_comments=True)
 	except KeyError as exc:
-		raise ValueError(str(exc)) from exc
+		raise TaskNotFoundError(task_ref) from exc
+
+
+@mcp.tool(title="Create task")
+def create_task_tool(
+	title: str,
+	description: str,
+	board: str = "Tasks",
+	status: str = "TODO",
+	priority: str = "LOW",
+	assignee: str = "",
+	due_date: str | None = None,
+	tags: str | None = None,
+	points: int | None = None,
+	project: str | None = None,
+) -> CreateTaskToolResult:
+	"""Create a new task on a board."""
+	location = _repo_for_tool(project)
+	repo = open_repository(location)
+	try:
+		b = get_board(repo, board_ref(board))
+		if b is None:
+			raise BoardNotFoundError(board)
+	except KeyError as e:
+		raise BoardNotFoundError(board) from e
+	
+	try:
+		s = Task.Status(status)
+	except ValueError as e:
+		raise InvalidTaskStatusError(status) from e
+
+	try:
+		p = Task.Priority(priority)
+	except ValueError as e:
+		raise InvalidTaskPriorityError(priority) from e
+
+	try:
+		d = _parse_dt(due_date)
+	except ValueError as e:
+		raise InvalidTaskDueDateError(due_date or "") from e
+	
+	try:
+		result = create_task(
+			repo=repo,
+			board=b,
+			title=title,
+			description=description,
+			status=s,
+			priority=p,
+			assignee=assignee,
+			due_date=d,
+			tags=tags or "",
+			points=points,
+		)
+		return {"task_id": result["id"], "ref": result["ref"], "oid": str(result["oid"])}
+	except Exception:
+		raise
 
 
 @mcp.tool(title="Board grouped by status")
diff --git a/pygittools-mcp/src/pygittools_mcp/mcp_test.py b/pygittools-mcp/src/pygittools_mcp/mcp_test.py
new file mode 100644
index 0000000..2eae202
--- /dev/null
+++ b/pygittools-mcp/src/pygittools_mcp/mcp_test.py
@@ -0,0 +1,240 @@
+"""MCP service create_task_tool unit tests."""
+
+from collections.abc import AsyncGenerator
+from datetime import datetime, timedelta
+import os
+from pathlib import Path
+from typing import Any
+
+from mcp import Client as MCPClient
+from pygit2 import Oid, Repository, init_repository
+from pygittools.tasks import Board, EMPTY_TREE_OID_HEX, get_board
+from pygittools.tasks_query import TaskView, board_ref
+import pytest
+
+from pygittools.tasks import CreateTaskResult, create_task, Task
+from pygittools_mcp.exceptions import BoardNotFoundError, InvalidTaskDueDateError, InvalidTaskPriorityError, InvalidTaskStatusError
+from pygittools_mcp.mcp_server import mcp
+from pygittools_mcp.models import CreateTaskToolResult
+
+TAGGER: str = "alice <alice@example.com>"
+
+
+@pytest.fixture
+def repo(tmp_path: Path) -> Repository:
+	"""Create a temp repo with a Tasks board so create_task_tool can find it."""
+	repo_path = tmp_path / "repo"
+	repo_path.mkdir()
+
+	repo = init_repository(str(repo_path), bare=False)
+
+	# Set up initial commit on main
+	import subprocess
+
+	os_repo_path = str(repo_path)
+	subprocess.run(
+		["git", "config", "user.email", "test@test.com"],
+		cwd=os_repo_path,
+		check=True,
+	)
+	subprocess.run(
+		["git", "config", "user.name", "Test"],
+		cwd=os_repo_path,
+		check=True,
+	)
+	with open(os.path.join(os_repo_path, "README.md"), "w") as f:
+		f.write("# initial\n")
+	subprocess.run(["git", "add", "README.md"], cwd=os_repo_path, check=True)
+	subprocess.run(
+		["git", "commit", "-m", "initial"],
+		cwd=os_repo_path,
+		check=True,
+		capture_output=True,
+		text=True,
+	)
+
+	# Create the Tasks board ref so create_task_tool.get_board() can find it
+	eoi = Oid(hex=EMPTY_TREE_OID_HEX)
+	tasks_board = Board(eoi, "refs/tags/boards/Tasks", TAGGER, description="Tasks")
+	tasks_board.write(repo)
+	return repo
+
+
+@pytest.fixture
+def test_task(repo: Repository) -> CreateTaskResult:
+	board = get_board(repo, board_ref("Tasks"))
+	assert board is not None
+	return create_task(repo, board, title="Test Task")
+
+
+@pytest.fixture
+async def mcp_client(repo: Repository, monkeypatch: pytest.MonkeyPatch) -> AsyncGenerator[MCPClient, None]:
+	monkeypatch.setenv("PYGITTOOLS_REPO", repo.path)
+	async with MCPClient(mcp) as client:
+		yield client
+
+
+def assert_default_board(board: dict[str, Any]):
+	assert board["name"] == "Tasks"
+	assert board["description"] == "Tasks"
+	assert board["task_count"] == 0
+
+
+def assert_is_test_task(view: TaskView, task: CreateTaskResult):
+	assert view.get("ref") == task["ref"]
+	assert view.get("title") == task["task"].title
+	assert view.get("description") == task["task"].description
+	view_status = view.get("status")
+	task_status = task["task"].status
+	assert view_status == task_status.value if task_status else None
+	view_priority = view.get("priority")
+	task_priority = task["task"].priority
+	assert view_priority == task_priority.value if task_priority else None
+	view_due_date = view.get("due_date")
+	task_due_date = task["task"].due_date
+	if view_due_date:
+		assert view_due_date == task_due_date.isoformat() if task_due_date else None
+	if (t := view.get("created_at")):
+		assert t == task["task"].created_at.isoformat()
+	if (t := view.get("updated_at")):
+		assert t == task["task"].updated_at.isoformat()
+
+
+@pytest.mark.anyio
+async def test_list_boards_tool(mcp_client: MCPClient) -> None:
+	result = await mcp_client.call_tool("list_boards_tool", {"project": None})
+	assert not result.is_error, result.content
+	content = result.structured_content["result"]
+	assert isinstance(content, list)
+	assert len(content) == 1
+	assert_default_board(content[0])
+
+
+@pytest.mark.anyio
+async def test_list_tasks_tool(mcp_client: MCPClient, test_task: CreateTaskResult) -> None:
+	result = await mcp_client.call_tool("list_tasks_tool", {})
+	assert not result.is_error, result.content
+	content = result.structured_content["result"]
+	assert isinstance(content, list)
+	assert len(content) == 1
+	assert_is_test_task(content[0], test_task)
+
+
+@pytest.mark.anyio
+async def test_get_task_tool(mcp_client: MCPClient, test_task: CreateTaskResult) -> None:
+	result = await mcp_client.call_tool("get_task_tool", {"task_ref": test_task["ref"]})
+	assert not result.is_error, result.content
+	assert_is_test_task(result.structured_content, test_task)
+
+
+@pytest.mark.anyio
+async def test_get_task_tool_not_found(mcp_client: MCPClient) -> None:
+	result = await mcp_client.call_tool("get_task_tool", {"task_ref": "fake-task-ref"})
+	assert result.is_error
+
+
+@pytest.mark.anyio
+async def test_create_task_tool_success(mcp_client: MCPClient) -> None:
+	title = "Test Task"
+	description = "A test task"
+	create_result = await mcp_client.call_tool("create_task_tool", {
+		"title": title,
+		"description":description 
+	})
+	assert not create_result.is_error
+	task: CreateTaskToolResult = create_result.structured_content
+	task_ref = task["ref"]
+	
+	get_result = await mcp_client.call_tool("get_task_tool", {"task_ref": task_ref})
+	assert not get_result.is_error
+	view: TaskView = get_result.structured_content
+	assert view.get("ref") == task["ref"]
+	assert view.get("title") == title
+	assert view.get("description") == description
+
+
+@pytest.mark.anyio
+async def test_create_task_invalid_board(mcp_client: MCPClient) -> None:
+	title = "Test Task"
+	description = "A test task"
+	board = "Fake-Board"
+	create_result = await mcp_client.call_tool("create_task_tool", {
+		"title": title,
+		"description":description ,
+		"board": board 
+	})
+	assert create_result.is_error, create_result.content
+	assert str(BoardNotFoundError(board)) in create_result.content[0].text
+
+
+@pytest.mark.anyio
+async def test_create_task_invalid_status(mcp_client: MCPClient) -> None:
+	title = "Test Task"
+	description = "A test task"
+	status = "invalid"
+	create_result = await mcp_client.call_tool("create_task_tool", {
+		"title": title,
+		"description": description,
+		"status": status
+	})
+	assert create_result.is_error
+	assert str(InvalidTaskStatusError(status)) in create_result.content[0].text
+
+@pytest.mark.anyio
+async def test_create_task_invalid_priority(mcp_client: MCPClient) -> None:
+	title = "Test Task"
+	description = "A test task"
+	priority = "invalid"
+	create_result = await mcp_client.call_tool("create_task_tool", {
+		"title": title,
+		"description": description,
+		"priority": priority
+	})
+	assert create_result.is_error
+	assert str(InvalidTaskPriorityError(priority)) in create_result.content[0].text
+
+
+@pytest.mark.anyio
+async def test_create_task_invalid_due_date(mcp_client: MCPClient):
+	title = "Test Task"
+	description = "A test task"
+	due_date = "soon"
+	create_result = await mcp_client.call_tool("create_task_tool", {
+		"title": title,
+		"description": description,
+		"due_date": due_date
+	})
+	assert create_result.is_error
+	assert str(InvalidTaskDueDateError(due_date)) in create_result.content[0].text
+
+
+@pytest.mark.anyio
+async def test_create_full_task(mcp_client: MCPClient):
+	call_args = {
+		"title": "Test Task",
+		"description": "Test task description",
+		"board": "Tasks",
+		"status": Task.Status.IN_PROGRESS.value,
+		"priority": Task.Priority.HIGH.value,
+		"assignee": "ahoyt41",
+		"due_duate": (datetime.now() + timedelta(days=5)).isoformat(),
+		"tags": '("foo"="bar")',
+		"points": 3
+	}
+	create_result = await mcp_client.call_tool("create_task_tool", call_args)
+	assert not create_result.is_error, create_result.content
+	assert create_result.structured_content
+	task: CreateTaskToolResult = create_result.structured_content
+
+	get_result = await mcp_client.call_tool("get_task_tool", {"task_ref": task["ref"]})
+	assert not get_result.is_error
+	assert get_result.structured_content
+	view: TaskView = get_result.structured_content
+
+	assert view.get("assignee") == call_args["assignee"]
+	assert view.get("description") == call_args["description"]
+	assert view.get("status") == call_args["status"]
+	assert view.get("priority") == call_args["priority"]
+	assert view.get("title") == call_args["title"]
+	assert view.get("ref") == task["ref"]
+
diff --git a/pygittools-mcp/src/pygittools_mcp/models.py b/pygittools-mcp/src/pygittools_mcp/models.py
new file mode 100644
index 0000000..439efd6
--- /dev/null
+++ b/pygittools-mcp/src/pygittools_mcp/models.py
@@ -0,0 +1,7 @@
+from typing import TypedDict
+
+
+class CreateTaskToolResult(TypedDict, total=True):
+	task_id: str
+	ref: str
+	oid: str
diff --git a/pygittools/src/pygittools/tasks.py b/pygittools/src/pygittools/tasks.py
index c69e13d..7362328 100644
--- a/pygittools/src/pygittools/tasks.py
+++ b/pygittools/src/pygittools/tasks.py
@@ -1,12 +1,16 @@
+from __future__ import annotations
+
 import json
+import time
 from datetime import datetime
-from enum import Enum
+from enum import Enum, StrEnum
 from json import JSONEncoder
 from random import randint
-from typing import Any
+from typing import Any, TypedDict
 
 from pygit2 import Oid, Repository, Signature, Tag, reference_is_valid_name
 
+
 # GIT_OBJECT_TAG = 4, GIT_OBJECT_TREE = 2
 GIT_OBJECT_TAG = 4
 GIT_OBJECT_TREE = 2
@@ -22,6 +26,39 @@ COMMENT_REF_PREFIX: str = "refs/tags/comments/"
 ARCHIVE_BOARD_NAME: str = "Archive"
 
 
+# Well-known empty tree OID for boards/tasks when repo has no HEAD
+EMPTY_TREE_OID = Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
+
+# Allowed sprint story point values (Fibonacci scale). Anything else is treated as unestimated.
+SPRINT_POINTS: tuple[int, ...] = (0, 1, 2, 3, 5, 8, 13, 21)
+
+
+def get_board_ref(name: str) -> str:
+	return f"{BOARD_REF_PREFIX}{name}"
+
+
+def get_task_ref(task_id: str) -> str:
+	return f"{TASK_REF_PREFIX}{task_id}"
+
+
+def get_repo_head_or_empty(repo: Repository) -> Oid | str:
+	try:
+		return repo.head.target
+	except KeyError:
+		return EMPTY_TREE_OID
+
+
+def parse_points(raw: Any) -> int | None:
+	"""Parse a story points value, keeping only values on the Fibonacci scale."""
+	if raw is None or raw == "":
+		return None
+	try:
+		value = int(raw)
+	except (TypeError, ValueError):
+		return None
+	return value if value in SPRINT_POINTS else None
+
+
 def _ensure_empty_tree(repo: Repository) -> None:
 	"""Ensure the well-known empty tree object exists in the repo ODB (so tags can point to it)."""
 	try:
@@ -85,14 +122,14 @@ class Task:
 	Because the ODB is immutable, the tag will always point to the latest version of the task.
 	"""
 
-	class Status(Enum):
+	class Status(StrEnum):
 		TODO = "TODO"
 		IN_PROGRESS = "IN_PROGRESS"
 		IN_REVIEW = "IN_REVIEW"
 		DONE = "DONE"
 		CANCELLED = "CANCELLED"
 
-	class Priority(Enum):
+	class Priority(StrEnum):
 		LOW = "LOW"
 		MEDIUM = "MEDIUM"
 		HIGH = "HIGH"
@@ -470,3 +507,51 @@ def archive_task_local(
 	archive_board.update_message()
 	archive_board.write(repo)
 	return archive_ref
+
+
+class CreateTaskResult(TypedDict, total=True):
+    id: str
+    ref: str
+    oid: Oid
+    task: Task
+
+def create_task(
+    repo: Repository,
+    board: Board,
+    title: str,
+    description: str = "",
+    status: Task.Status = Task.Status.TODO,
+    priority: Task.Priority = Task.Priority.LOW,
+    assignee: str = "",
+    due_date: datetime | None = None,
+    tags: str = "",
+    points: int | None = None
+) -> CreateTaskResult:
+    task_id = f"task_{int(time.time() * 1000)}"
+    target = get_repo_head_or_empty(repo)
+    ref = get_task_ref(task_id)
+    task = Task(
+        target=target,
+        name=ref,
+        tagger="",
+        title=title,
+        description=description,
+        status=status,
+        priority=priority,
+        assignee=assignee or None,
+        due_date=due_date,
+        tags=tags or "",
+        points=parse_points(points)
+    )
+    oid = task.write(repo)
+    board.tasks = getattr(board, "tasks", []) or []
+    board.tasks.append(str(oid))
+    board.update_message()
+    board.write(repo)
+    return {
+        "id": task_id,
+        "ref": ref,
+        "oid": oid,
+        "task": task
+    }
+
diff --git a/pygittools/src/pygittools/tasks_test.py b/pygittools/src/pygittools/tasks_test.py
index eb502b0..300946a 100644
--- a/pygittools/src/pygittools/tasks_test.py
+++ b/pygittools/src/pygittools/tasks_test.py
@@ -1,3 +1,4 @@
+import os
 import subprocess
 from pathlib import Path
 
@@ -28,7 +29,34 @@ BOARD_MAIN: str = f"{BOARD_REF_PREFIX}main"
 def repo(tmp_path: Path) -> Repository:
 	repo_path = tmp_path / "repo"
 	repo_path.mkdir()
-	return init_repository(str(repo_path), bare=False)
+
+	repo = init_repository(str(repo_path), bare=False)
+
+	# Set up initial commit on main to satisfy repo_head_or_empty()
+	import subprocess
+	os_repo_path = str(repo_path)
+	subprocess.run(
+		["git", "config", "user.email", "test@test.com"],
+		cwd=os_repo_path,
+		check=True,
+	)
+	subprocess.run(
+		["git", "config", "user.name", "Test"],
+		cwd=os_repo_path,
+		check=True,
+	)
+	with open(os.path.join(os_repo_path, "README.md"), "w") as f:
+		f.write("# initial\n")
+	subprocess.run(["git", "add", "README.md"], cwd=os_repo_path, check=True)
+	result = subprocess.run(
+		["git", "commit", "-m", "initial"],
+		cwd=os_repo_path,
+		check=True,
+		capture_output=True,
+		text=True,
+	)
+
+	return repo
 
 
 def _empty_tree() -> Oid:
@@ -269,6 +297,98 @@ def test_archive_task_local_moves_to_archive_board(repo: Repository) -> None:
 	assert archive_ref == f"{BOARD_REF_PREFIX}{ARCHIVE_BOARD_NAME}"
 
 
+def test_create_task_returns_tuple(repo: Repository) -> None:
+	"""Shared create_task returns CreateTaskResult with task_id and oid fields"""
+	from pygittools.tasks import create_task
+
+	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Test board")
+	result = create_task(repo, board, "Test Title")
+	oide_hex = str(result["oid"])
+	assert isinstance(result["id"], str)
+	assert isinstance(oide_hex, str)
+	assert len(oide_hex) == 40  # valid git SHA
+	assert result["id"].startswith("task_")
+
+
+def test_task_tag_persistence(repo: Repository) -> None:
+	"""Task tag created at refs/tags/tasks/{task_id} in repo ODB"""
+	from pygittools.tasks import create_task
+
+	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Tag test board")
+	result = create_task(repo, board, "Tag Test")
+	task_id = result["id"]
+	ref = f"refs/tags/tasks/{task_id}"
+	assert ref in repo.references
+
+
+def test_board_updated(repo: Repository) -> None:
+	"""Board task list gains new OID after create_task"""
+	from pygittools.tasks import create_task
+
+	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Board test")
+	original_len = len(board.tasks)
+	result = create_task(repo, board, "Board Test")
+	assert len(board.tasks) == original_len + 1
+	assert result["oid"] in board.tasks
+
+
+def test_default_parameters(repo: Repository) -> None:
+	"""Default params: TODO status, LOW priority"""
+	from pygittools.tasks import create_task, Task
+
+	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Defaults board")
+	result = create_task(repo, board, "Defaults")
+	task = result["task"]
+	assert task.status == Task.Status.TODO
+	assert task.priority == Task.Priority.LOW
+
+
+def test_custom_parameters(repo: Repository) -> None:
+	"""Custom status, priority, assignee, due_date, tags, points"""
+	from datetime import datetime
+	from pygittools.tasks import create_task, Task
+
+	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Custom board")
+	result = create_task(
+		repo, board, "Custom",
+		status=Task.Status.IN_PROGRESS,
+		priority=Task.Priority.HIGH,
+		assignee="alice",
+		due_date=datetime(2024, 1, 15),
+		tags="urgent",
+		points=5
+	)
+	task = result["task"]
+	assert task.assignee == "alice"
+	assert task.due_date == datetime(2024, 1, 15)
+	assert task.tags == "urgent"
+	assert task.points == 5
+
+
+def test_task_id_uniqueness(repo: Repository) -> None:
+	"""Running create_task twice creates distinct tasks (OIDs always unique)"""
+	from pygittools.tasks import create_task
+
+	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Uniqueness board")
+	result1 = create_task(repo, board, "Task 1")
+	result2 = create_task(repo, board, "Task 2")
+	# OIDs are always unique since they're derived from Task.write()
+	assert result1["oid"] != result2["oid"]
+	# Both should persist on board
+	assert str(result1["oid"]) in board.tasks
+	assert str(result2["oid"]) in board.tasks
+
+
+def test_board_message_updated(repo: Repository) -> None:
+	"""Board update_message and write are called as side effects"""
+	from pygittools.tasks import create_task
+
+	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Message test board")
+	result = create_task(repo, board, "Message Test")
+	# Verify the new OID appears on the board
+	assert result["oid"] in board.tasks
+
+
 def test_archive_task_local_creates_archive_board_when_missing(repo: Repository) -> None:
 	board = Board(_empty_tree(), BOARD_MAIN, TAGGER, description="Main")
 	task = Task(
diff --git a/pygitweb/src/pygitweb/comments_permissions_test.py b/pygitweb/src/pygitweb/comments_permissions_test.py
index 5dfaba3..89e034f 100644
--- a/pygitweb/src/pygitweb/comments_permissions_test.py
+++ b/pygitweb/src/pygitweb/comments_permissions_test.py
@@ -8,13 +8,13 @@ import pygit2
 import pytest
 from fastapi import FastAPI
 from fastapi.testclient import TestClient
-from pygittools.tasks import BOARD_REF_PREFIX, TASK_REF_PREFIX, Board, Comment, Task
+from pygittools.tasks import BOARD_REF_PREFIX, TASK_REF_PREFIX, Board, Comment, Task, EMPTY_TREE_OID
 
 from pygitweb.auth_config import AuthConfig, LocalUser
 from pygitweb.config import settings
 from pygitweb.conftest import client_as_user
 from pygitweb.permissions import Permission, permission_key, permissions_with_defaults
-from pygitweb.tasks import EMPTY_TREE_OID, comment_router
+from pygitweb.tasks import comment_router
 
 _COMMENTS_AUTH = AuthConfig(
 	auth_mode="local",
diff --git a/pygitweb/src/pygitweb/tasks.py b/pygitweb/src/pygitweb/tasks.py
index 587ea90..2c145e4 100644
--- a/pygitweb/src/pygitweb/tasks.py
+++ b/pygitweb/src/pygitweb/tasks.py
@@ -4,29 +4,35 @@ Data is stored in the repo ODB/RefDB via pygittools.tasks (Board, Task, Comment)
 """
 
 from __future__ import annotations
-
 import os
 import re
 import time
 from typing import Annotated, Any
 from urllib.parse import quote
 
-import pygit2
 from fastapi import APIRouter, Depends, HTTPException, Query, Request
 from fastapi.responses import HTMLResponse, JSONResponse
+import pygit2
+from pygit2 import Oid
 from pygittools.tasks import (
 	ARCHIVE_BOARD_NAME,
 	BOARD_REF_PREFIX,
-	TASK_REF_PREFIX,
 	Board,
 	Comment,
+	TASK_REF_PREFIX,
 	Task,
+	_parse_dt,
 	archive_task_local,
+	create_task,
 	delete_task_local,
 	get_board,
+	get_board_ref,
 	get_comment,
+	get_repo_head_or_empty,
 	get_task,
 	get_task_by_oid,
+	get_task_ref,
+	parse_points,
 )
 
 from pygitweb.auth import (
@@ -37,43 +43,24 @@ from pygitweb.auth import (
 	require_permission,
 )
 from pygitweb.config import settings
-from pygitweb.dependencies import ValidatedReadableQueryProject, filter_projects_by_read_access
+from pygitweb.dependencies import (
+	ValidatedReadableQueryProject,
+	filter_projects_by_read_access,
+)
 from pygitweb.gravatar import gravatar_url
 from pygitweb.permissions import Permission, PermissionPrincipal
 from pygitweb.projects import git_get_projects_list
-from pygitweb.templates_env import Cell, html_link, person_label, priority_badge, render_page, status_badge
-
-# Well-known empty tree OID for boards/tasks when repo has no HEAD
-EMPTY_TREE_OID = pygit2.Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
-
-# Allowed sprint story point values (Fibonacci scale). Anything else is treated as unestimated.
-SPRINT_POINTS: tuple[int, ...] = (0, 1, 2, 3, 5, 8, 13, 21)
-
-
-def _parse_points(raw: Any) -> int | None:
-	"""Parse a story points value, keeping only values on the Fibonacci scale."""
-	if raw is None or raw == "":
-		return None
-	try:
-		value = int(raw)
-	except (TypeError, ValueError):
-		return None
-	return value if value in SPRINT_POINTS else None
-
-
-def _board_ref(name: str) -> str:
-	return f"{BOARD_REF_PREFIX}{name}"
-
+from pygitweb.templates_env import (
+	Cell,
+	html_link,
+	person_label,
+	priority_badge,
+	render_page,
+	status_badge,
+)
 
-def _task_ref(task_id: str) -> str:
-	return f"{TASK_REF_PREFIX}{task_id}"
 
-
-def _repo_head_or_empty(repo: pygit2.Repository) -> pygit2.Oid | str:
-	try:
-		return repo.head.target
-	except KeyError:
-		return EMPTY_TREE_OID
+EMPTY_TREE_OID = Oid(hex="4b825dc642cb6eb9a060e54bf8d69288fbee4904")
 
 
 def _sync_board_task_oid(
@@ -85,7 +72,7 @@ def _sync_board_task_oid(
 	move_to_end: bool = False,
 ) -> None:
 	"""Replace a task OID on the board after the task tag object was rewritten."""
-	b = get_board(repo, _board_ref(board))
+	b = get_board(repo, get_board_ref(board))
 	if not b or not getattr(b, "tasks", None):
 		return
 	new_oid_str = str(new_oid)
@@ -135,10 +122,10 @@ def create_board_for_project(
 ) -> str:
 	"""Create a board in the project repo. Returns the board ref. Raises HTTPException on error."""
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = _board_ref(name)
+	ref = get_board_ref(name)
 	if ref in repo.references:
 		raise HTTPException(status_code=409, detail="Board already exists")
-	target = _repo_head_or_empty(repo)
+	target = get_repo_head_or_empty(repo)
 	board = Board(target, ref, tagger="", description=description)
 	board.write(repo)
 	return ref
@@ -162,11 +149,13 @@ def list_project_boards(project: str) -> list[dict[str, Any]]:
 		try:
 			b = get_board(repo, ref_name)
 			if b:
-				boards.append({
-					"name": ref_name.replace(BOARD_REF_PREFIX, ""),
-					"description": getattr(b, "description", "") or "",
-					"task_count": len(getattr(b, "tasks", [])),
-				})
+				boards.append(
+					{
+						"name": ref_name.replace(BOARD_REF_PREFIX, ""),
+						"description": getattr(b, "description", "") or "",
+						"task_count": len(getattr(b, "tasks", [])),
+					}
+				)
 		except (ValueError, KeyError):
 			continue
 	return boards
@@ -192,13 +181,13 @@ def boards_create(
 	description: str = Query("", description="Board description"),
 ) -> JSONResponse:
 	"""Create a new board."""
-	if not name or not pygit2.reference_is_valid_name(_board_ref(name)):
+	if not name or not pygit2.reference_is_valid_name(get_board_ref(name)):
 		raise HTTPException(status_code=400, detail="Invalid board name")
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = _board_ref(name)
+	ref = get_board_ref(name)
 	if ref in repo.references:
 		raise HTTPException(status_code=409, detail="Board already exists")
-	target = _repo_head_or_empty(repo)
+	target = get_repo_head_or_empty(repo)
 	board = Board(target, ref, tagger="", description=description or "")
 	oid = board.write(repo)
 	return JSONResponse(content={"name": name, "ref": ref, "oid": str(oid)})
@@ -215,7 +204,7 @@ def boards_delete(
 ) -> JSONResponse:
 	"""Delete a board (removes ref; tag object remains in ODB)."""
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = _board_ref(name)
+	ref = get_board_ref(name)
 	if ref not in repo.references:
 		raise HTTPException(status_code=404, detail="Board not found")
 	repo.references.delete(ref)
@@ -267,7 +256,7 @@ def get_board_tasks_grouped(
 	in BOARD_STATUS_ORDER. Skips validation/auth (caller must validate project).
 	"""
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = _board_ref(board_name)
+	ref = get_board_ref(board_name)
 	b = get_board(repo, ref)
 	task_oids = getattr(b, "tasks", []) or []
 	by_status: dict[str, list[dict[str, Any]]] = {s: [] for s in BOARD_STATUS_ORDER}
@@ -346,15 +335,17 @@ def all_tasks_page(
 			project_url = f"/project/{quote(project, safe='/')}"
 			board_url = f"{project_url}/board/?b={quote(t['board'], safe='')}"
 			task_url = f"{board_url}&task={quote(t['ref'], safe='')}"
-			rows.append([
-				html_link(task_url, t.get("title") or "(untitled)"),
-				html_link(project_url, project),
-				t.get("board") or "",
-				status_badge(task_status),
-				priority_badge(t.get("priority") or "LOW"),
-				person_label(t.get("assignee") or ""),
-				str(t.get("points") if t.get("points") is not None else ""),
-			])
+			rows.append(
+				[
+					html_link(task_url, t.get("title") or "(untitled)"),
+					html_link(project_url, project),
+					t.get("board") or "",
+					status_badge(task_status),
+					priority_badge(t.get("priority") or "LOW"),
+					person_label(t.get("assignee") or ""),
+					str(t.get("points") if t.get("points") is not None else ""),
+				]
+			)
 	statuses = [
 		{"value": status, "label": status.replace("_", " ").title(), "selected": status_filter == status}
 		for status in BOARD_STATUS_ORDER
@@ -380,7 +371,7 @@ def task_list(
 ) -> JSONResponse:
 	"""List all tasks on a board."""
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = _board_ref(board)
+	ref = get_board_ref(board)
 	b = get_board(repo, ref)
 	if not b:
 		raise HTTPException(status_code=404, detail="Board not found")
@@ -415,42 +406,22 @@ def task_create(
 ) -> JSONResponse:
 	"""Create a new task on a board."""
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	board_ref = _board_ref(board)
-	b = get_board(repo, board_ref)
+	b = get_board(repo, get_board_ref(board))
 	if not b:
 		raise HTTPException(status_code=404, detail="Board not found")
-	task_id = f"task_{int(time.time() * 1000)}"
-	ref = _task_ref(task_id)
-	target = _repo_head_or_empty(repo)
-	status_enum = getattr(Task.Status, status, None) or Task.Status.TODO
-	priority_enum = getattr(Task.Priority, priority, None) or Task.Priority.LOW
-	due = None
-	if due_date:
-		try:
-			from datetime import datetime
-
-			due = datetime.fromisoformat(due_date.replace("Z", "+00:00"))
-		except ValueError:
-			pass
-	task = Task(
-		target,
-		ref,
-		tagger="",
+	result = create_task(
+		repo=repo,
+		board=b,
 		title=title,
-		description=description or "",
-		status=status_enum,
-		priority=priority_enum,
-		assignee=assignee or None,
-		due_date=due,
-		tags=tags or "",
-		points=_parse_points(points),
+		description=description,
+		status=Task.Status(status),
+		priority=Task.Priority(priority),
+		assignee=assignee,
+		due_date=_parse_dt(due_date),
+		tags=tags,
+		points=parse_points(points),
 	)
-	oid = task.write(repo)
-	b.tasks = getattr(b, "tasks", []) or []
-	b.tasks.append(str(oid))
-	b.update_message()
-	b.write(repo)
-	return JSONResponse(content={"task_id": task_id, "ref": ref, "oid": str(oid)})
+	return JSONResponse(content={"task_id": result["id"], "ref": result["ref"], "oid": str(result["oid"])})
 
 
 @task_router.post(
@@ -470,7 +441,7 @@ async def task_update(
 	except Exception:
 		body = {}
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = task_ref if task_ref.startswith("refs/") else _task_ref(task_ref)
+	ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
 	t = get_task(repo, ref)
 	if not t:
 		raise HTTPException(status_code=404, detail="Task not found")
@@ -486,7 +457,7 @@ async def task_update(
 	if "assignee" in body:
 		t.assignee = body["assignee"] or None
 	if "points" in body:
-		t.points = _parse_points(body["points"])
+		t.points = parse_points(body["points"])
 	if "due_date" in body:
 		try:
 			from datetime import datetime
@@ -517,7 +488,7 @@ def task_delete(
 ) -> JSONResponse:
 	"""Delete a task (remove ref, comments, and board entries)."""
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = task_ref if task_ref.startswith("refs/") else _task_ref(task_ref)
+	ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
 	if ref not in repo.references:
 		raise HTTPException(status_code=404, detail="Task not found")
 	delete_task_local(repo, ref)
@@ -539,7 +510,7 @@ def task_archive(
 	if not archive_board or "/" in archive_board or ".." in archive_board:
 		raise HTTPException(status_code=400, detail="Invalid archive board name")
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = task_ref if task_ref.startswith("refs/") else _task_ref(task_ref)
+	ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
 	try:
 		archive_ref = archive_task_local(repo, ref, board, archive_board_name=archive_board)
 	except KeyError as exc:
@@ -565,14 +536,14 @@ def task_move(
 	target: str = Query(..., description="Target board name (e.g. a sprint board)"),
 ) -> JSONResponse:
 	"""Move a task onto another board (e.g. pulling it into a sprint board)."""
-	target_ref = _board_ref(target)
+	target_ref = get_board_ref(target)
 	if not target or not pygit2.reference_is_valid_name(target_ref):
 		raise HTTPException(status_code=400, detail="Invalid board name")
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	ref = task_ref if task_ref.startswith("refs/") else _task_ref(task_ref)
+	ref = task_ref if task_ref.startswith("refs/") else get_task_ref(task_ref)
 	if ref not in repo.references:
 		raise HTTPException(status_code=404, detail="Task not found")
-	source_ref = _board_ref(board)
+	source_ref =get_board_ref(board)
 	if target_ref not in repo.references:
 		raise HTTPException(status_code=404, detail="Target board not found")
 	tb = get_board(repo, target_ref)
@@ -670,7 +641,7 @@ def comment_list(
 ) -> JSONResponse:
 	"""List all comments for a task."""
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	task_ref = task if task.startswith("refs/") else _task_ref(task)
+	task_ref = task if task.startswith("refs/") else get_task_ref(task)
 	t = get_task(repo, task_ref)
 	if not t:
 		raise HTTPException(status_code=404, detail="Task not found")
@@ -702,7 +673,7 @@ def comment_create(
 ) -> JSONResponse:
 	"""Create a new comment on a task."""
 	repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
-	task_ref = task if task.startswith("refs/") else _task_ref(task)
+	task_ref = task if task.startswith("refs/") else get_task_ref(task)
 	t = get_task(repo, task_ref)
 	if not t:
 		raise HTTPException(status_code=404, detail="Task not found")
@@ -758,7 +729,7 @@ async def comment_modify(
 	c.edited_at = datetime.now()
 	c.update_message()
 	new_oid = c.write(repo)
-	task_ref = task if task.startswith("refs/") else _task_ref(task)
+	task_ref = task if task.startswith("refs/") else get_task_ref(task)
 	t = get_task(repo, task_ref)
 	if t and getattr(t, "comments", None):
 		t.comments = [str(new_oid) if str(co) == comment_oid else co for co in t.comments]
diff --git a/pyproject.toml b/pyproject.toml
index 7b91a3f..4431f4a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,6 +11,7 @@ dev = [
     "pytest",
     "mypy>=1.20.1",
     "coverage>=7.15.2",
+    "mcp[client]>=1.28.0",
 ]
 # Playwright runs the browser integration test and the JavaScript unit tests (--js-tests).
 playwright = [
diff --git a/uv.lock b/uv.lock
index 9a09dc6..1379272 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
 version = 1
-revision = 3
+revision = 5
 requires-python = ">=3.12"
 resolution-markers = [
     "python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -431,6 +431,19 @@ wheels = [
 ]
 
 [[package]]
+name = "httpcore2"
+version = "2.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "h11" },
+    { name = "truststore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/30/ff/b53af1b49bb2cb5fa97c92215614b173ca5c1a99ded80e2d52f15a94fa6d/httpcore2-2.14.0.tar.gz", hash = "sha256:4f57a1a8249fe6af589d935cfd456f1d13bbdcb0f0fc72af9142368fc94811d7", size = 69096, upload-time = "2026-10-18T16:29:21.898Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/02/06/a8d6a8f90d11b76d9086242b6bb0ee4b503473bfc8be34c2d5b067275c22/httpcore2-2.14.0-py3-none-any.whl", hash = "sha256:4c4f64f4b544afeb778c42c0ffb5941b29e671157e90ed3f6dc787c4bca90cd1", size = 84291, upload-time = "2026-10-18T16:29:19.743Z" },
+]
+
+[[package]]
 name = "httptools"
 version = "0.7.1"
 source = { registry = "https://pypi.org/simple" }
@@ -475,21 +488,38 @@ wheels = [
 ]
 
 [[package]]
-name = "httpx-sse"
-version = "0.4.3"
+name = "httpx2"
+version = "2.14.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
+dependencies = [
+    { name = "anyio", marker = "sys_platform != 'emscripten'" },
+    { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
+    { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
+    { name = "idna" },
+    { name = "truststore", marker = "sys_platform != 'emscripten'" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e8/67/58353f1848b4ba568e1ec22ea1e0d8e039f0bca5fde7e2d1f07739fd83e7/httpx2-2.14.0.tar.gz", hash = "sha256:47ad61a3c40282cd8d0dc96311531f00f5bfc71a066c6fe3b36d8f63fb9e4f6e", size = 102036, upload-time = "2026-10-18T16:29:22.627Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
+    { url = "https://files.pythonhosted.org/packages/69/5a/469616aa8c2cbb0bb9dd67a3c18d07c01ab604cc0b236ee1c47d260c7f6e/httpx2-2.14.0-py3-none-any.whl", hash = "sha256:9d20c13a10bae1773a0be3b0f1e5beeb4bdd549bc3a2ba18efa32897c65403e4", size = 96901, upload-time = "2026-10-18T16:29:20.819Z" },
+]
+
+[[package]]
+name = "httpx2-jsfetch"
+version = "1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
 ]
 
 [[package]]
 name = "idna"
-version = "3.11"
+version = "3.20"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+    { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
 ]
 
 [[package]]
@@ -677,15 +707,15 @@ wheels = [
 
 [[package]]
 name = "mcp"
-version = "1.28.0"
+version = "2.3.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "anyio" },
-    { name = "httpx" },
-    { name = "httpx-sse" },
+    { name = "httpx2" },
     { name = "jsonschema" },
+    { name = "mcp-types" },
+    { name = "opentelemetry-api" },
     { name = "pydantic" },
-    { name = "pydantic-settings" },
     { name = "pyjwt", extra = ["crypto"] },
     { name = "python-multipart" },
     { name = "pywin32", marker = "sys_platform == 'win32'" },
@@ -695,9 +725,28 @@ dependencies = [
     { name = "typing-inspection" },
     { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" },
+    { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" },
+]
+
+[package.optional-dependencies]
+cli = [
+    { name = "python-dotenv" },
+    { name = "typer" },
+]
+
+[[package]]
+name = "mcp-types"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pydantic" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" },
 ]
 
 [[package]]
@@ -762,6 +811,18 @@ wheels = [
 ]
 
 [[package]]
+name = "opentelemetry-api"
+version = "1.45.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
+]
+
+[[package]]
 name = "orjson"
 version = "3.11.8"
 source = { registry = "https://pypi.org/simple" }
@@ -1142,12 +1203,26 @@ source = { editable = "pygittools-mcp" }
 dependencies = [
     { name = "mcp" },
     { name = "pygittools" },
+    { name = "pytest-anyio" },
+]
+
+[package.dev-dependencies]
+dev = [
+    { name = "mcp", extra = ["cli"] },
+    { name = "pytest-asyncio" },
 ]
 
 [package.metadata]
 requires-dist = [
-    { name = "mcp", specifier = ">=1.28.0" },
+    { name = "mcp", specifier = ">=2.3.0" },
     { name = "pygittools", editable = "pygittools" },
+    { name = "pytest-anyio", specifier = ">=0.0.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+    { name = "mcp", extras = ["cli", "client"], specifier = ">=2.3.0" },
+    { name = "pytest-asyncio", specifier = ">=1.4.0" },
 ]
 
 [[package]]
@@ -1191,6 +1266,7 @@ source = { virtual = "." }
 [package.dev-dependencies]
 dev = [
     { name = "coverage" },
+    { name = "mcp" },
     { name = "mypy" },
     { name = "pytest" },
     { name = "ruff" },
@@ -1204,6 +1280,7 @@ playwright = [
 [package.metadata.requires-dev]
 dev = [
     { name = "coverage", specifier = ">=7.15.2" },
+    { name = "mcp", extras = ["client"], specifier = ">=1.28.0" },
     { name = "mypy", specifier = ">=1.20.1" },
     { name = "pytest" },
     { name = "ruff" },
@@ -1264,6 +1341,32 @@ wheels = [
 ]
 
 [[package]]
+name = "pytest-anyio"
+version = "0.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/00/44/a02e5877a671b0940f21a7a0d9704c22097b123ed5cdbcca9cab39f17acc/pytest-anyio-0.0.0.tar.gz", hash = "sha256:b41234e9e9ad7ea1dbfefcc1d6891b23d5ef7c9f07ccf804c13a9cc338571fd3", size = 1560, upload-time = "2021-06-29T22:57:30.846Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c6/25/bd6493ae85d0a281b6a0f248d0fdb1d9aa2b31f18bcd4a8800cf397d8209/pytest_anyio-0.0.0-py2.py3-none-any.whl", hash = "sha256:dc8b5c4741cb16ff90be37fddd585ca943ed12bbeb563de7ace6cd94441d8746", size = 1999, upload-time = "2021-06-29T22:57:29.158Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pytest" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
+]
+
+[[package]]
 name = "python-dotenv"
 version = "1.2.2"
 source = { registry = "https://pypi.org/simple" }
@@ -1580,6 +1683,15 @@ wheels = [
 ]
 
 [[package]]
+name = "truststore"
+version = "0.10.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" },
+]
+
+[[package]]
 name = "typer"
 version = "0.24.1"
 source = { registry = "https://pypi.org/simple" }
