diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index 8b71802..b7cd8ab 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -40,6 +40,7 @@ from pygitweb.git_helpers import (
 	parse_tag,
 )
 from pygitweb.hooks_install import HookStatus, bundle_status
+from pygitweb.merge_requests import merge_request_tag_response, resolve_merge_request_tag_to_tip
 from pygitweb.projects import git_get_project_owner
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
 from pygitweb.validation import is_valid_pathname, is_valid_ref_format
@@ -864,6 +865,11 @@ def git_tag(project: str, h: str | None) -> HTMLResponse:
 		raise HTTPException(status_code=404, detail="Tag or object not found") from e
 	if not isinstance(obj, pygit2.Tag):
 		raise HTTPException(status_code=404, detail="Not a tag object")
+	mr_pair = resolve_merge_request_tag_to_tip(repo, obj)
+	if mr_pair is not None:
+		obj, mr = mr_pair
+	else:
+		mr = None
 	tag_oid = str(obj.id)
 	tag_data = parse_tag(project, tag_oid)
 	if not tag_data:
@@ -881,6 +887,16 @@ def git_tag(project: str, h: str | None) -> HTMLResponse:
 			break
 	if tag_name is None:
 		tag_name = tag_oid[:7]
+	if mr is not None:
+		return merge_request_tag_response(
+			project=project,
+			repo=repo,
+			mr=mr,
+			tag_name=tag_name,
+			tag_oid=tag_oid,
+			tagger=tagger,
+			tagger_date=_format_date(tagger_epoch, tagger_tz),
+		)
 	target_short = target_oid[:7] if target_oid else ""
 	if target_type == "commit":
 		object_link = (
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 1110d29..7520176 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -72,6 +72,7 @@ from pygitweb.hooks_install import (
 from pygitweb.hooks_install import (
 	status as hook_status,
 )
+from pygitweb.merge_requests import merge_router
 from pygitweb.plugin_loader import load_plugin_actions, load_subpages
 from pygitweb.projects import git_get_project_owner, git_get_projects_list
 from pygitweb.settings import router as settings_router
@@ -194,6 +195,7 @@ app.include_router(settings_router)
 app.include_router(board_router, prefix="/board")
 app.include_router(task_router, prefix="/tasks")
 app.include_router(comment_router, prefix="/comments")
+app.include_router(merge_router, prefix="/mr")
 
 
 @app.middleware("http")
diff --git a/pygitweb/merge_requests.py b/pygitweb/merge_requests.py
new file mode 100644
index 0000000..2527038
--- /dev/null
+++ b/pygitweb/merge_requests.py
@@ -0,0 +1,178 @@
+"""Merge request tag view helpers and fast-forward merge endpoint."""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+from urllib.parse import quote
+
+import pygit2
+from fastapi import APIRouter, HTTPException, Query
+from fastapi.responses import HTMLResponse, RedirectResponse, Response
+
+from pygittools.merge import MergeRequest, MergeRequestStatus, get_merge_request_by_oid
+from pygitweb.config import settings
+from pygitweb.git_helpers import open_repo
+from pygitweb.tasks import _require_auth_disabled, _validate_project
+from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
+from pygitweb.validation import is_valid_ref_format
+
+merge_router = APIRouter(tags=["merge_requests"])
+
+_STATUS_LABELS: dict[MergeRequestStatus, str] = {
+	MergeRequestStatus.CAN_FAST_FORWARD: "Can fast-forward",
+	MergeRequestStatus.UP_TO_DATE: "Already up to date",
+	MergeRequestStatus.BLOCKED_DIVERGED: "Blocked: histories diverged (not fast-forward)",
+	MergeRequestStatus.OURS_REF_MISSING: "Blocked: ours ref is missing",
+	MergeRequestStatus.MR_HEAD_MISSING: "Blocked: merge request head is missing from the repository",
+}
+
+
+def try_merge_request_from_tag(repo: pygit2.Repository, tag: pygit2.Tag) -> MergeRequest | None:
+	try:
+		return get_merge_request_by_oid(repo, tag.id)
+	except (ValueError, json.JSONDecodeError, TypeError, KeyError):
+		return None
+
+
+def resolve_merge_request_tag_to_tip(repo: pygit2.Repository, obj: pygit2.Tag) -> tuple[pygit2.Tag, MergeRequest] | None:
+	"""If ``obj`` is an MR tag and ``refs/tags/mr/…`` points to a newer tag object, return that tip."""
+	mr = try_merge_request_from_tag(repo, obj)
+	if mr is None:
+		return None
+	if mr.name not in repo.references:
+		return obj, mr
+	tip_id = repo.references[mr.name].resolve().target
+	if tip_id == obj.id:
+		return obj, mr
+	tip_obj = repo[tip_id]
+	if not isinstance(tip_obj, pygit2.Tag):
+		return obj, mr
+	mr_tip = try_merge_request_from_tag(repo, tip_obj)
+	if mr_tip is None:
+		return obj, mr
+	return tip_obj, mr_tip
+
+
+def merge_request_status_fields(repo: pygit2.Repository, mr: MergeRequest) -> dict[str, Any]:
+	try:
+		status = mr.check_status(repo)
+	except ValueError as exc:
+		return {
+			"status_code": "error",
+			"status_label": "Unable to analyze merge",
+			"status_detail": str(exc),
+			"show_merge_button": False,
+		}
+	label = _STATUS_LABELS.get(status, status.value)
+	return {
+		"status_code": status.value,
+		"status_label": label,
+		"status_detail": "",
+		"show_merge_button": status == MergeRequestStatus.CAN_FAST_FORWARD,
+	}
+
+
+def _head_view_url(project: str, repo: pygit2.Repository, head_hex: str) -> tuple[str, str]:
+	try:
+		oid = pygit2.Oid(hex=head_hex)
+		obj = repo[oid]
+	except (KeyError, ValueError, TypeError):
+		return head_hex[:7], ""
+	t = getattr(obj, "type_str", "") or ""
+	short = head_hex[:7]
+	if t == "commit":
+		return short, f"/project/{project}?a=commit&h={quote(head_hex, safe='')}"
+	if t == "tag":
+		return short, f"/project/{project}?a=tag&h={quote(head_hex, safe='')}"
+	return short, ""
+
+
+def merge_request_tag_response(
+	project: str,
+	repo: pygit2.Repository,
+	mr: MergeRequest,
+	tag_name: str,
+	tag_oid: str,
+	tagger: str,
+	tagger_date: str,
+) -> Response:
+	new_oid = mr.refresh_from_theirs(repo)
+	if new_oid is not None:
+		return RedirectResponse(
+			url=f"/project/{project}?a=tag&h={quote(str(new_oid), safe='')}",
+			status_code=302,
+		)
+
+	status_fields = merge_request_status_fields(repo, mr)
+	head_short, head_url = _head_view_url(project, repo, str(mr.target))
+	ours_ref = mr.ours
+	ours_url = f"/project/{project}?a=log&h={quote(ours_ref, safe='')}"
+	theirs_ref = mr.theirs or ""
+	theirs_url = f"/project/{project}?a=log&h={quote(theirs_ref, safe='')}" if mr.theirs else ""
+
+	comments_rows: list[tuple[str, str]] = []
+	for c in mr.comments:
+		ch = str(c)
+		comments_rows.append((ch[:7], f"/project/{project}?a=tag&h={quote(ch, safe='')}"))
+
+	current_tag_oid = str(repo.references[mr.name].resolve().target)
+
+	ctx = {
+		"project": project,
+		"project_url": f"/project/{project}",
+		"tag_name": tag_name,
+		"tag_oid": current_tag_oid,
+		"tag_view_url": f"/project/{project}?a=tag&h={quote(current_tag_oid, safe='')}",
+		"mr_title": mr.title,
+		"mr_description": mr.description or "",
+		"ours_ref": ours_ref,
+		"ours_url": ours_url,
+		"theirs_ref": theirs_ref,
+		"theirs_url": theirs_url,
+		"head_short": head_short,
+		"head_url": head_url,
+		"head_full": str(mr.target),
+		"tagger": tagger,
+		"tagger_date": tagger_date,
+		"opener": str(mr.opener),
+		"created_at": mr.created_at.isoformat() if mr.created_at else "",
+		"updated_at": mr.updated_at.isoformat() if mr.updated_at else "",
+		"comments": comments_rows,
+		"merge_action_url": f"/mr/ff?project={quote(project, safe='')}&h={quote(current_tag_oid, safe='')}",
+		**status_fields,
+	}
+	title = f"Merge request: {jinja_escape(mr.title) or ''} — {jinja_escape(project) or ''}"
+	pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
+	body = env.get_template("merge_request_tag.html").render(**ctx)
+	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
+
+
+@merge_router.post("/ff")
+def merge_fast_forward(
+	project: str = Query(..., description="Project path"),
+	h: str = Query(..., description="Annotated tag object id (hex)"),
+) -> RedirectResponse:
+	_require_auth_disabled()
+	_validate_project(project)
+	if not h or not is_valid_ref_format(h):
+		raise HTTPException(status_code=400, detail="Invalid tag hash (h)")
+	repo = open_repo(project)
+	try:
+		obj = repo.revparse_single(h)
+	except (KeyError, pygit2.GitError, OSError) as exc:
+		raise HTTPException(status_code=404, detail="Tag not found") from exc
+	if not isinstance(obj, pygit2.Tag):
+		raise HTTPException(status_code=400, detail="Not an annotated tag")
+	resolved = resolve_merge_request_tag_to_tip(repo, obj)
+	if resolved is None:
+		raise HTTPException(status_code=400, detail="Not a merge request tag")
+	obj, mr = resolved
+	mr.refresh_from_theirs(repo)
+	try:
+		mr.merge(repo)
+	except ValueError as exc:
+		raise HTTPException(status_code=400, detail=str(exc)) from exc
+	tag_oid = str(repo.references[mr.name].resolve().target)
+	dest = f"/project/{project}?a=tag&h={quote(tag_oid, safe='')}"
+	return RedirectResponse(url=dest, status_code=303)
diff --git a/pygitweb/merge_requests_test.py b/pygitweb/merge_requests_test.py
new file mode 100644
index 0000000..2a4c312
--- /dev/null
+++ b/pygitweb/merge_requests_test.py
@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+from collections.abc import Generator
+from pathlib import Path
+from unittest.mock import patch
+
+import pygit2
+import pytest
+from fastapi.testclient import TestClient
+
+from pygittools.merge import MR_REF_PREFIX, MergeRequest
+from pygitweb.config import settings
+from pygitweb.main import app
+
+
+def _commit_chain(repo: pygit2.Repository) -> tuple[pygit2.Oid, pygit2.Oid]:
+	sig = pygit2.Signature("tester", "tester@example.com")
+	tb = repo.TreeBuilder()
+	tree = tb.write()
+	a = repo.create_commit(None, sig, sig, "a", tree, [])
+	repo.create_reference("refs/heads/main", a)
+	b = repo.create_commit(None, sig, sig, "b", tree, [a])
+	return a, b
+
+
+class TestMergeRequestTagView:
+	@pytest.fixture
+	def mr_env(self, tmp_path: Path) -> Generator[dict[str, str], None, None]:
+		root: Path = tmp_path / "merge-requests-web"
+		root.mkdir()
+		repo_dir: Path = root / "demo"
+		repo_dir.mkdir()
+		repo = pygit2.init_repository(str(repo_dir), bare=False)
+		_a, b = _commit_chain(repo)
+		mr = MergeRequest(
+			b,
+			repo.default_signature,
+			ours="refs/heads/main",
+			title="Feature MR",
+			description="Desc line",
+			name=f"{MR_REF_PREFIX}aaaabbbbccccdddd",
+		)
+		mr_oid = mr.write(repo)
+		projects_list: Path = root / "projects.list"
+		projects_list.write_text("demo tester\n", encoding="utf-8")
+		with (
+			patch.object(settings, "PROJECTROOT", str(root)),
+			patch.object(settings, "PROJECTS_LIST", str(projects_list)),
+			patch.object(settings, "STRICT_EXPORT", False),
+			patch.object(settings, "EXPORT_OK", ""),
+			patch.object(settings, "AUTH", None),
+		):
+			client = TestClient(app)
+			yield {
+				"client": client,
+				"project": "demo",
+				"mr_tag_oid": str(mr_oid),
+				"mr_ref": mr.name,
+				"tip_b": str(b),
+			}
+
+	def test_tag_action_renders_merge_request_template(self, mr_env: dict[str, str]) -> None:
+		client = mr_env["client"]
+		resp = client.get(
+			f"/project/{mr_env['project']}",
+			params={"a": "tag", "h": mr_env["mr_tag_oid"]},
+		)
+		assert resp.status_code == 200
+		text = resp.text
+		assert "Merge request" in text
+		assert "Feature MR" in text
+		assert "Desc line" in text
+		assert "Can fast-forward" in text
+		assert "refs/heads/main" in text
+
+	def test_merge_ff_updates_branch(self, mr_env: dict[str, str]) -> None:
+		client = mr_env["client"]
+		post = client.post(
+			"/mr/ff",
+			params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
+			follow_redirects=False,
+		)
+		assert post.status_code == 303
+		repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / mr_env["project"]))
+		assert repo.references["refs/heads/main"].resolve().target == pygit2.Oid(hex=mr_env["tip_b"])
diff --git a/pygitweb/templates/merge_request_tag.html b/pygitweb/templates/merge_request_tag.html
new file mode 100644
index 0000000..ee7ea44
--- /dev/null
+++ b/pygitweb/templates/merge_request_tag.html
@@ -0,0 +1,90 @@
+{# Merge request view for annotated MR tags. Expects: project, project_url, tag_name, tag_oid, tag_view_url, mr_title, mr_description, ours_ref, ours_url, theirs_ref, theirs_url, head_short, head_url, head_full, tagger, tagger_date, opener, created_at, updated_at, comments [ (short, url) ], merge_action_url, status_code, status_label, status_detail, show_merge_button #}
+
+<h1 class="page-title mb-3">Merge request</h1>
+
+<p class="text-muted mb-4">
+  Project: <a href="{{ project_url }}" class="text-reset">{{ project }}</a>
+  · Tag: <a href="{{ tag_view_url }}" class="text-reset">{{ tag_name }}</a>
+</p>
+
+<div class="row row-cards mb-4">
+  <div class="col-12">
+    <div class="card">
+      <div class="card-header">
+        <h2 class="card-title mb-0">{{ mr_title }}</h2>
+      </div>
+      <div class="card-body">
+        {% if mr_description %}
+        <div class="markdown-body mb-3" style="white-space: pre-wrap;">{{ mr_description }}</div>
+        {% endif %}
+        <dl class="row mb-0">
+          <dt class="col-sm-3">Ours</dt>
+          <dd class="col-sm-9"><a href="{{ ours_url }}">{{ ours_ref }}</a></dd>
+          <dt class="col-sm-3">Theirs</dt>
+          <dd class="col-sm-9">
+            {% if theirs_ref %}
+            <a href="{{ theirs_url }}">{{ theirs_ref }}</a>
+            {% else %}
+            <span class="text-muted">(not tracked)</span>
+            {% endif %}
+          </dd>
+          <dt class="col-sm-3">Head</dt>
+          <dd class="col-sm-9">
+            {% if head_url %}<a href="{{ head_url }}">{{ head_short }}</a>{% else %}{{ head_short }}{% endif %}
+            <span class="text-muted small ms-1">{{ head_full }}</span>
+          </dd>
+          <dt class="col-sm-3">Tagger (annotated tag)</dt>
+          <dd class="col-sm-9">{{ tagger }} · {{ tagger_date }}</dd>
+          <dt class="col-sm-3">Opener (payload)</dt>
+          <dd class="col-sm-9">{{ opener }}</dd>
+          <dt class="col-sm-3">Created</dt>
+          <dd class="col-sm-9"><code class="small">{{ created_at }}</code></dd>
+          <dt class="col-sm-3">Updated</dt>
+          <dd class="col-sm-9"><code class="small">{{ updated_at }}</code></dd>
+        </dl>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div class="row row-cards mb-4">
+  <div class="col-12 col-lg-6">
+    <div class="card">
+      <div class="card-header">
+        <h3 class="card-title">Merge status</h3>
+      </div>
+      <div class="card-body">
+        <p class="mb-1"><span class="badge bg-secondary">{{ status_code }}</span> {{ status_label }}</p>
+        {% if status_detail %}
+        <p class="text-danger small mb-0">{{ status_detail }}</p>
+        {% endif %}
+        <form method="post" action="{{ merge_action_url }}" class="mt-3">
+          <button type="submit" class="btn btn-primary" {% if not show_merge_button %}disabled{% endif %}>
+            Merge (fast-forward only)
+          </button>
+          {% if not show_merge_button %}
+          <span class="text-muted small ms-2">Only available when a fast-forward merge is possible.</span>
+          {% endif %}
+        </form>
+      </div>
+    </div>
+  </div>
+  <div class="col-12 col-lg-6">
+    <div class="card">
+      <div class="card-header">
+        <h3 class="card-title">Comments</h3>
+      </div>
+      <div class="card-body">
+        {% if comments %}
+        <ul class="list-unstyled mb-0">
+          {% for short_id, url in comments %}
+          <li class="mb-1"><a href="{{ url }}">{{ short_id }}</a></li>
+          {% endfor %}
+        </ul>
+        {% else %}
+        <p class="text-muted mb-0">No comment references.</p>
+        {% endif %}
+      </div>
+    </div>
+  </div>
+</div>
