blob: 9afb08c85964ce525181527b88987c75cc86b97a [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import asyncio
import email
import email.policy
import pathlib
from email.message import EmailMessage
from unittest.mock import patch
import pytest
from oauth_draft import mcp_server
from oauth_draft.credentials import Credentials
CREDS = Credentials(
client_id="cid",
client_secret="secret",
refresh_token="refresh",
from_address="me@example.com",
)
def _run_impl(
*,
thread_id: str | None = None,
no_reply_headers: bool = False,
body: str = "Reply body with a link: https://lists.apache.org/thread/abc123\n",
):
"""Call the tool implementation with all network boundaries mocked.
Returns ``(result_dict, raw_bytes_posted)`` where ``raw_bytes`` is the
RFC822 message that ``build_mime`` produced and that would have been
POSTed to Gmail's ``drafts.create``.
"""
captured: dict[str, bytes] = {}
def fake_create_draft(access_token, thread_id, raw_bytes):
captured["raw"] = raw_bytes
return {"id": "draft-1", "message": {"id": "msg-1", "threadId": thread_id or "tid"}}
with (
patch.object(mcp_server, "locate_credentials", return_value="/dev/null"),
patch.object(mcp_server.Credentials, "load", return_value=CREDS),
patch.object(mcp_server, "refresh_access_token", return_value="tok"),
patch.object(mcp_server._cd, "latest_reply_headers", return_value=("<a@x>", "<root@x> <a@x>")),
patch.object(mcp_server._cd, "create_draft", side_effect=fake_create_draft),
):
result = mcp_server._create_draft_impl(
to=["rcpt@example.com"],
subject="Re: hello",
body=body,
cc=None,
bcc=None,
thread_id=thread_id,
no_reply_headers=no_reply_headers,
credentials_path=None,
)
return result, captured.get("raw", b"")
def _parse(raw: bytes) -> EmailMessage:
msg = email.message_from_bytes(raw, policy=email.policy.default)
assert isinstance(msg, EmailMessage)
return msg
def test_tools_are_registered_without_any_html_parameter():
tools = asyncio.run(mcp_server.mcp.list_tools())
names = {t.name for t in tools}
assert names == {"create_draft", "setup_credentials", "check_auth"}
by_name = {t.name: t for t in tools}
draft_props = by_name["create_draft"].inputSchema.get("properties", {})
# The draft tool must never expose an HTML / rich-text body knob.
assert not any("html" in p.lower() for p in draft_props)
assert {"to", "subject", "body"} <= set(draft_props)
# No tool anywhere exposes an html parameter.
for t in tools:
assert not any("html" in p.lower() for p in t.inputSchema.get("properties", {}))
def test_impl_produces_single_part_plain_text_with_verbatim_link():
result, raw = _run_impl(thread_id="tid")
msg = _parse(raw)
assert msg.get_content_type() == "text/plain"
assert not msg.is_multipart()
assert all(part.get_content_type() != "text/html" for part in msg.walk())
assert b"text/html" not in raw
# Link goes out verbatim — no google.com/url tracking redirect.
assert "https://lists.apache.org/thread/abc123" in msg.get_content()
assert "google.com/url" not in raw.decode()
assert result["content_type"] == "text/plain"
def test_impl_return_shape():
result, _ = _run_impl(thread_id="tid")
assert result["draft_id"] == "draft-1"
assert result["message_id"] == "msg-1"
assert result["thread_id"] == "tid"
assert result["gmail_url"].endswith("#drafts/msg-1")
def test_impl_sets_reply_headers_when_thread_id_given():
_, raw = _run_impl(thread_id="tid")
decoded = raw.decode()
assert "In-Reply-To: <a@x>" in decoded
assert "References: <root@x> <a@x>" in decoded
def test_impl_skips_thread_lookup_when_no_reply_headers():
# Patch directly here (not via _run_impl) so the assertion sees the same
# mock the implementation would call.
with (
patch.object(mcp_server, "locate_credentials", return_value="/dev/null"),
patch.object(mcp_server.Credentials, "load", return_value=CREDS),
patch.object(mcp_server, "refresh_access_token", return_value="tok"),
patch.object(mcp_server._cd, "latest_reply_headers") as latest,
patch.object(mcp_server._cd, "create_draft", return_value={"id": "d", "message": {"id": "m"}}),
):
mcp_server._create_draft_impl(
to=["x@example.com"],
subject="S",
body="x",
cc=None,
bcc=None,
thread_id="tid",
no_reply_headers=True,
credentials_path=None,
)
latest.assert_not_called()
def test_impl_skips_thread_lookup_when_no_thread_id():
_, raw = _run_impl(thread_id=None)
decoded = raw.decode()
assert "In-Reply-To:" not in decoded
# --- create_draft SystemExit handling --------------------------------------
def test_create_draft_impl_converts_systemexit_to_tool_error():
# The CLI helpers raise SystemExit (BaseException); it must be surfaced as
# a normal tool error, not allowed to kill the server process.
with patch.object(mcp_server, "locate_credentials", side_effect=SystemExit("no creds found")):
with pytest.raises(RuntimeError, match="no creds found"):
mcp_server._create_draft_impl(
to=["x@example.com"],
subject="S",
body="b",
cc=None,
bcc=None,
thread_id=None,
no_reply_headers=False,
credentials_path=None,
)
# --- setup_credentials -----------------------------------------------------
class _FakeOAuthCreds:
refresh_token = "rt"
scopes = ("https://mail.google.com/",)
def test_setup_credentials_runs_flow_and_writes(tmp_path):
secrets = tmp_path / "client_secrets.json"
secrets.write_text("{}")
out = tmp_path / "gmail-oauth.json"
written: dict[str, str] = {}
def fake_write(**kwargs):
written.update(kwargs)
with (
patch.object(mcp_server._setup, "run_consent_flow", return_value=_FakeOAuthCreds()),
patch.object(mcp_server._setup, "read_client_app", return_value=("cid", "sec")),
patch.object(mcp_server._setup, "write_credentials", side_effect=fake_write),
):
result = mcp_server.setup_credentials(
client_secrets_path=str(secrets),
from_address="me@example.com",
credentials_path=str(out),
)
assert result["from_address"] == "me@example.com"
assert result["credentials_path"] == str(out)
assert result["scopes"] == "https://mail.google.com/"
assert written["client_id"] == "cid"
assert written["client_secret"] == "sec"
assert written["refresh_token"] == "rt"
assert written["from_address"] == "me@example.com"
def test_setup_credentials_errors_without_from_address(tmp_path):
secrets = tmp_path / "client_secrets.json"
secrets.write_text("{}")
with patch.object(mcp_server._setup, "detect_from_address", return_value=None):
with pytest.raises(ValueError, match="from_address"):
mcp_server.setup_credentials(client_secrets_path=str(secrets))
def test_setup_credentials_errors_when_client_secrets_missing(tmp_path):
with pytest.raises(ValueError, match="client_secrets not found"):
mcp_server.setup_credentials(
client_secrets_path=str(tmp_path / "nope.json"),
from_address="me@example.com",
)
# --- check_auth ------------------------------------------------------------
def test_check_auth_reports_ok_when_token_refreshes():
with (
patch.object(mcp_server, "locate_credentials", return_value=pathlib.Path("/dev/null")),
patch.object(mcp_server.Credentials, "load", return_value=CREDS),
patch.object(mcp_server, "refresh_access_token", return_value="tok"),
):
result = mcp_server.check_auth()
assert result["status"] == "ok"
assert result["from_address"] == "me@example.com"
def test_check_auth_converts_systemexit_to_tool_error():
with (
patch.object(mcp_server, "locate_credentials", return_value=pathlib.Path("/dev/null")),
patch.object(mcp_server.Credentials, "load", return_value=CREDS),
patch.object(mcp_server, "refresh_access_token", side_effect=SystemExit("refresh failed (401)")),
):
with pytest.raises(RuntimeError, match="refresh failed"):
mcp_server.check_auth()