From 90268f74bf52c76505cd15ae5aa910ea2b0c996a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 08:46:21 +0000 Subject: [PATCH] Tighten tool nomenclature: one naming rule across the three pillars 25 tools across three pillars, named four different ways. One rule -- _ for the pillar's own object, __ for anything else in it -- already fitted 19 of them, so six are renamed to match and the rule goes in CLAUDE.md where it can be held to. Each pillar has exactly one primary object: mail has messages, calendar has events, contacts has contacts. Naming it is redundant, which contacts already knew (contacts_create, not contacts_create_contact) and calendar did not, so the five calendar_*_event tools lose the suffix. And mail_draft was the one tool named with a noun while its two siblings, mail_edit_draft and mail_send_draft, lead with the verb; it becomes mail_create_draft. calendar_search_events -> calendar_search calendar_read_event -> calendar_read calendar_create_event -> calendar_create calendar_update_event -> calendar_update calendar_delete_event -> calendar_delete mail_draft -> mail_create_draft Two spellings survive on purpose and CLAUDE.md says so, to stop this being re-opened. contacts stays plural while mail and calendar are singular: the three pillars have been written that way since v1 and it is the namespace's name, not a bug worth six more renames. mail_flag and mail_mark_read keep the words a person would use rather than a symmetrical set_flagged / set_read. This is a breaking change to the MCP surface -- a renamed tool breaks any saved instruction in an AI client that names it -- and squirrel-mcp-admin registers this same tool layer, so both repos ship it together. Worth doing at six tools in a free beta rather than at thirty with customers. Closes #19. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K2DMxTXFL1aubHfjjEYCB6 --- CLAUDE.md | 20 +++++++++++++++++--- README.md | 14 +++++++------- scripts/mcp_smoke.py | 2 +- squirrel_mcp/knowledge.py | 16 ++++++++-------- squirrel_mcp/schemas.py | 2 +- squirrel_mcp/tools/calendar.py | 22 +++++++++++----------- squirrel_mcp/tools/mail/compose.py | 12 ++++++------ tests/test_attachments_inline.py | 2 +- tests/test_attachments_outgoing.py | 4 ++-- tests/test_calendar_invitations.py | 26 +++++++++++++------------- tests/test_reply_threading.py | 4 ++-- tests/test_tools.py | 4 ++-- 12 files changed, 71 insertions(+), 57 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6028a26..dc62572 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ namespaces are reserved so they plug in later as sibling providers + tool mixins grammar and the ladder, `tests/test_mail_search.py` the IMAP keys and the widening policy, and the GreenMail e2e proves a real server answers them. - **Replying is a provider concern, not a header the tool layer writes.** - `mail_send` / `mail_draft` take a `reply_to_uid` (+ `reply_to_folder`) and + `mail_send` / `mail_create_draft` take a `reply_to_uid` (+ `reply_to_folder`) and hand it to the provider untouched, because *how* you join a thread is the transport's business: IMAP/SMTP reads the parent's `Message-ID` / `References` and sends the matching headers (`imap.reply_headers` → @@ -213,7 +213,7 @@ namespaces are reserved so they plug in later as sibling providers + tool mixins expunge and the tool's confirm gate; the GreenMail e2e deletes for real and reads the message back out of Trash. - **A meeting is not an appointment, and CalDAV only does the second.** - `calendar_create_event` takes `attendees` and `online_meeting`, and both are + `calendar_create` takes `attendees` and `online_meeting`, and both are gated on a capability the provider declares -- `supports_attendees` / `supports_online_meeting`, read as `getattr(..., False)` so a backend written before them answers no. This is the attachments rule (below) applied to the @@ -226,7 +226,7 @@ namespaces are reserved so they plug in later as sibling providers + tool mixins Graph, in the admin package, answers True: `isOnlineMeeting` + `onlineMeetingProvider` mint a real Teams meeting, and Graph mails the invitations itself. The link comes back as `join_url` on `EventDetail`, and - `calendar_create_event` re-reads the event to report it -- a meeting nobody + `calendar_create` re-reads the event to report it -- a meeting nobody can join is half an answer, and a read-back that fails is logged rather than raised, because the event *was* created and a failed tool call invites a retry that books it twice. `attendees` accepts the shapes mail recipients @@ -366,6 +366,20 @@ defaults -- a missing one is a config error, never a guess. ## Conventions - Follow existing style (ruff configured in `pyproject.toml`). +- **Tool names: `_` for the pillar's own object, + `__` for anything else in that pillar.** Each pillar has + exactly one primary object -- mail has messages, calendar has events, contacts + has contacts -- so naming it is redundant: `calendar_search`, not + `calendar_search_events`. Everything secondary says what it is + (`mail_list_folders`, `mail_get_attachment`, `calendar_list_calendars`), and + a verb is a verb (`mail_create_draft`, never `mail_draft`). Two spellings + survive on purpose: `contacts` stays plural while its siblings are singular, + because the three pillars have been written `mail` / `contacts` / `calendar` + since v1 and that is the namespace's name; and `mail_flag` / `mail_mark_read` + keep the words a person would use over a symmetrical `set_flagged` / + `set_read`. A rename here is a breaking change to the MCP surface and to the + admin package, which registers this same tool layer -- both repos ship it + together. - Tools never import a concrete client -- only `providers.protocol`. - Every backend must satisfy `MailProvider`. - Secrets only via env / `.env` (gitignored). No hardcoded fallbacks. diff --git a/README.md b/README.md index a89c98a..934f589 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ is configured. | `mail_read` | Read one message (large bodies are truncated) | read-only | | `mail_read_chunk` | Fetch the next slice of a large body | read-only | | `mail_get_attachment` | Download one attachment | read-only | -| `mail_draft` / `mail_edit_draft` | Create / update a draft in Drafts, attachments included | writes to Drafts | +| `mail_create_draft` / `mail_edit_draft` | Create / update a draft in Drafts, attachments included | writes to Drafts | | `mail_send` | Send a message, attachments included — **requires `confirm=true`** | ⚠️ outgoing | | `mail_send_draft` | Send a draft that already exists, then take it out of Drafts — **requires `confirm=true`** | ⚠️ outgoing | | `mail_move` | Move messages between folders — **requires `confirm=true`** | ⚠️ mutating | @@ -45,7 +45,7 @@ is configured. | `mail_flag` | Flag / unflag messages — the star, `flagged=false` clears it | reversible | | `mail_mark_read` | Mark messages read / unread, `read=false` clears it | reversible | -Drafting and sending are the other two halves: `mail_draft` writes the message, +Drafting and sending are the other two halves: `mail_create_draft` writes the message, the user reads it, and `mail_send_draft` puts *that* message on the wire and removes it from Drafts. It takes a uid and nothing else on purpose — a draft is already a complete message, so re-typing its text into `mail_send` would send a @@ -69,14 +69,14 @@ message went. It is the delete key, not an erase: nothing is expunged, so `mail_move` brings it back. **Attachments** go both ways. `mail_read` lists them and `mail_get_attachment` -downloads one; sending takes an `attachments` list on `mail_send`, `mail_draft` +downloads one; sending takes an `attachments` list on `mail_send`, `mail_create_draft` and `mail_edit_draft`. Each entry either points at a file already in the mailbox — `{"source_uid": "412", "source_index": 0}`, which is how forwarding works and never copies the bytes through the AI client — or carries its own `{"filename": ..., "content_base64": ...}`. Editing a draft keeps the files it already has unless you say otherwise. -**Replies land in the thread.** `mail_send` and `mail_draft` take a +**Replies land in the thread.** `mail_send` and `mail_create_draft` take a `reply_to_uid` (the message being answered, plus the `reply_to_folder` it lives in), and the reply goes out carrying the `In-Reply-To` and `References` headers every mail client uses to build a conversation. A subject beginning with "Re:" @@ -98,9 +98,9 @@ never an all-in-one dump. `contacts_*`: `contacts_list_addressbooks` · `contacts_search` (paginated) · `contacts_read` · `contacts_create` / `contacts_update` / `contacts_delete`. -`calendar_*`: `calendar_list_calendars` · `calendar_search_events` (ISO date -window) · `calendar_read_event` · `calendar_create_event` / `calendar_update_event` -/ `calendar_delete_event`. +`calendar_*`: `calendar_list_calendars` · `calendar_search` (ISO date +window) · `calendar_read` · `calendar_create` / `calendar_update` +/ `calendar_delete`. All writes require `confirm=true`. Both pillars reuse the same account credentials as mail (most providers let one password cover IMAP/SMTP, CardDAV and CalDAV) and diff --git a/scripts/mcp_smoke.py b/scripts/mcp_smoke.py index df45997..0d0c32c 100644 --- a/scripts/mcp_smoke.py +++ b/scripts/mcp_smoke.py @@ -20,7 +20,7 @@ "mail_read", "mail_read_chunk", "mail_get_attachment", - "mail_draft", + "mail_create_draft", "mail_edit_draft", "mail_send", "mail_send_draft", diff --git a/squirrel_mcp/knowledge.py b/squirrel_mcp/knowledge.py index c8ca563..bfb1224 100644 --- a/squirrel_mcp/knowledge.py +++ b/squirrel_mcp/knowledge.py @@ -36,7 +36,7 @@ tells you the total length and how to page the rest. - mail_read_chunk: fetch the next slice of a large body (offset + length). - mail_get_attachment: download one attachment by its index (from mail_read). -- mail_draft / mail_edit_draft: create or update a draft in the Drafts folder. +- mail_create_draft / mail_edit_draft: create or update a draft in the Drafts folder. - mail_send: send a message. DESTRUCTIVE/OUTGOING. It requires confirm=true and will refuse otherwise. ALWAYS show the user the exact recipients, subject, body and the account it will be sent from, and get explicit approval BEFORE @@ -52,7 +52,7 @@ and leaves the draft behind, which is exactly the duplicate the user sees in their mailbox afterwards. `draft_removed: false` means it was sent but the draft is still there: say so, offer to delete it, and never send it again. -- ATTACHMENTS go out via the `attachments` argument on mail_send, mail_draft and +- ATTACHMENTS go out via the `attachments` argument on mail_send, mail_create_draft and mail_edit_draft. Two forms, and the choice matters: to send on a file that is already in the mailbox -- forwarding an invoice, passing on a contract -- use `{"source_uid": "", "source_index": , "source_folder": "INBOX"}` with @@ -74,7 +74,7 @@ flow of the text, not to dress up a message the user asked to keep plain. - REPLYING is not the same as sending: whenever the user is answering a message they just read, pass that message's uid as `reply_to_uid` (plus the - `reply_to_folder` it lives in) to mail_send or mail_draft. Only that puts the + `reply_to_folder` it lives in) to mail_send or mail_create_draft. Only that puts the message inside the existing thread; a subject starting with "Re:" does not -- Gmail may guess it back into the conversation, Outlook generally will not, and the user ends up with a second thread they did not ask for. With @@ -108,11 +108,11 @@ list replaces every address ([] clears them); omitting it leaves them alone. contacts_search does not carry addresses; read the contact for those. -Calendar tools (`calendar_*`): calendar_list_calendars · calendar_search_events -(ISO date window, defaults to ~±6 months) · calendar_read_event · calendar_create_event -/ calendar_update_event / calendar_delete_event (all writes need confirm=true). +Calendar tools (`calendar_*`): calendar_list_calendars · calendar_search +(ISO date window, defaults to ~±6 months) · calendar_read · calendar_create +/ calendar_update / calendar_delete (all writes need confirm=true). Dates/times are ISO 8601 (YYYY-MM-DD for all-day, otherwise full datetime). -- calendar_create_event takes `attendees` (email addresses), which turns the event +- calendar_create takes `attendees` (email addresses), which turns the event into a meeting and SENDS THEM AN INVITATION -- read the list back to the user before confirming, the same way you would recipients of a mail. - `online_meeting=true` asks the calendar to add a conference link (a Teams meeting @@ -135,7 +135,7 @@ Good habits: - Resolve folder/addressbook/calendar ids with the matching list_* tool first. -- Prefer drafting (mail_draft) and letting the user review over sending directly, +- Prefer drafting (mail_create_draft) and letting the user review over sending directly, then mail_send_draft to put that same reviewed message out. - ALWAYS show the user exactly what will change and get approval before calling any write tool (send/move/create/update/delete) with confirm=true. diff --git a/squirrel_mcp/schemas.py b/squirrel_mcp/schemas.py index ae4e012..364e618 100644 --- a/squirrel_mcp/schemas.py +++ b/squirrel_mcp/schemas.py @@ -252,7 +252,7 @@ class CalendarList(BaseModel): class EventOut(BaseModel): - uid: str = Field(description="Event id; pass to calendar_read_event") + uid: str = Field(description="Event id; pass to calendar_read") calendar: str summary: str start: Optional[str] = Field(default=None, description="ISO 8601 start") diff --git a/squirrel_mcp/tools/calendar.py b/squirrel_mcp/tools/calendar.py index 1e8c675..5c09395 100644 --- a/squirrel_mcp/tools/calendar.py +++ b/squirrel_mcp/tools/calendar.py @@ -6,7 +6,7 @@ CalDAV is one backend, not the only one, and the two things a *meeting* needs beyond an appointment -- inviting people and having somewhere to meet -- are the -place that shows. Both are asked for on ``calendar_create_event`` and both are +place that shows. Both are asked for on ``calendar_create`` and both are gated on a capability the provider declares (``supports_attendees`` / ``supports_online_meeting``, read with ``getattr`` so a backend predating them answers no). A backend that cannot do it is told so plainly, in the same breath @@ -88,7 +88,7 @@ async def calendar_list_calendars() -> CalendarList: ) @self.app.tool(title="Search Events", annotations=_READ) - async def calendar_search_events( + async def calendar_search( calendar: str, start: Optional[str] = None, end: Optional[str] = None, @@ -106,17 +106,17 @@ async def calendar_search_events( provider, provider.search_events, calendar, start=start, end=end, query=query, limit=eff_limit, ) - self._track_usage(sub, "calendar_search_events") + self._track_usage(sub, "calendar_search") return EventList( events=[_event_out(e) for e in events], count=len(events), calendar=calendar ) @self.app.tool(title="Read Event", annotations=_READ) - async def calendar_read_event(calendar: str, uid: str) -> EventDetailOut: + async def calendar_read(calendar: str, uid: str) -> EventDetailOut: """Read one event by uid.""" provider, sub = await self._get_provider() e = await run_blocking(provider, provider.get_event, calendar, uid) - self._track_usage(sub, "calendar_read_event") + self._track_usage(sub, "calendar_read") return EventDetailOut( uid=e.uid, calendar=e.calendar, summary=e.summary, start=e.start, end=e.end, all_day=e.all_day, location=e.location, description=e.description, @@ -125,7 +125,7 @@ async def calendar_read_event(calendar: str, uid: str) -> EventDetailOut: ) @self.app.tool(title="Create Event", annotations=_WRITE) - async def calendar_create_event( + async def calendar_create( calendar: str, summary: str, start: str, @@ -154,7 +154,7 @@ async def calendar_create_event( all_day=all_day, location=location, description=description, attendees=invitees, online_meeting=online_meeting, ) - self._track_usage(sub, "calendar_create_event") + self._track_usage(sub, "calendar_create") join_url = ( await self._join_url(provider, calendar, uid) if online_meeting else None ) @@ -166,7 +166,7 @@ async def calendar_create_event( ) @self.app.tool(title="Update Event", annotations=_WRITE) - async def calendar_update_event( + async def calendar_update( calendar: str, uid: str, summary: Optional[str] = None, @@ -183,18 +183,18 @@ async def calendar_update_event( provider, provider.update_event, calendar, uid, summary=summary, start=start, end=end, location=location, description=description, ) - self._track_usage(sub, "calendar_update_event") + self._track_usage(sub, "calendar_update") return EventWriteResult(uid=new_uid, calendar=calendar, status="Event updated") @self.app.tool(title="Delete Event", annotations=_WRITE) - async def calendar_delete_event( + async def calendar_delete( calendar: str, uid: str, confirm: bool = False ) -> EventWriteResult: """Delete an event. Requires confirm=true.""" provider, sub = await self._get_provider(writes=True) require_confirm(confirm, "Deleting an event") await run_blocking(provider, provider.delete_event, calendar, uid) - self._track_usage(sub, "calendar_delete_event") + self._track_usage(sub, "calendar_delete") return EventWriteResult(uid=uid, calendar=calendar, status="Event deleted") diff --git a/squirrel_mcp/tools/mail/compose.py b/squirrel_mcp/tools/mail/compose.py index f0aebee..1231c54 100644 --- a/squirrel_mcp/tools/mail/compose.py +++ b/squirrel_mcp/tools/mail/compose.py @@ -1,4 +1,4 @@ -"""mail_draft, mail_edit_draft, mail_send, mail_send_draft. +"""mail_create_draft, mail_edit_draft, mail_send, mail_send_draft. Replying: three of these take a ``reply_to_uid``. It does two separate jobs, and they live in different places on purpose. @@ -82,7 +82,7 @@ def _register_compose_tools(self): openWorldHint=True, ), ) - async def mail_draft( + async def mail_create_draft( to: Optional[Any] = None, subject: Optional[str] = None, body: Optional[str] = None, @@ -160,7 +160,7 @@ async def mail_draft( attachments=files, body_html=body_html, ) - self._track_usage(sub, "mail_draft") + self._track_usage(sub, "mail_create_draft") return DraftResult( uid=uid, folder=folder, @@ -198,11 +198,11 @@ async def mail_edit_draft( (The draft is rewritten, so the uid changes -- use the returned one.) Pass the same ``account`` the draft was created in. A draft created as a reply stays in its thread; to turn an ordinary draft into a - reply, save a new one with mail_draft's ``reply_to_uid``. + reply, save a new one with mail_create_draft's ``reply_to_uid``. ATTACHMENTS: omit the argument and whatever the draft already carries is kept -- fixing a typo does not drop the file. Pass a list - (same shape as mail_draft) to replace them, or [] to strip them. + (same shape as mail_create_draft) to replace them, or [] to strip them. ``body_html`` is rewritten like the rest of the draft, so re-pass it when editing a message that had one. """ @@ -266,7 +266,7 @@ async def mail_send_draft( ``folder``. Do NOT re-send the same text through mail_send instead: that composes a second message and leaves the draft behind. - ``uid`` is the draft's uid, as returned by mail_draft / + ``uid`` is the draft's uid, as returned by mail_create_draft / mail_edit_draft or found with mail_search in the Drafts folder. Pass the same ``account`` the draft lives in. diff --git a/tests/test_attachments_inline.py b/tests/test_attachments_inline.py index 4de89b1..18f0b48 100644 --- a/tests/test_attachments_inline.py +++ b/tests/test_attachments_inline.py @@ -247,7 +247,7 @@ async def test_send_hands_the_html_body_to_the_provider(app_with_tools, fake_pro async def test_a_draft_carries_the_html_body_too(app_with_tools, fake_provider): await app_with_tools.call_tool( - "mail_draft", + "mail_create_draft", {"to": "a@b.com", "subject": "Hi", "body": "text", "body_html": "

rich

"}, ) assert fake_provider.draft_kwargs[-1]["body_html"] == "

rich

" diff --git a/tests/test_attachments_outgoing.py b/tests/test_attachments_outgoing.py index e096854..972ce8e 100644 --- a/tests/test_attachments_outgoing.py +++ b/tests/test_attachments_outgoing.py @@ -250,7 +250,7 @@ async def test_send_refuses_a_bad_attachment_before_anything_leaves(app_with_too async def test_draft_carries_attachments_too(app_with_tools, fake_provider): await app_with_tools.call_tool( - "mail_draft", + "mail_create_draft", {"to": "a@b.com", "subject": "s", "body": "b", "attachments": [{"source_uid": "101", "source_index": 0}]}, ) @@ -278,7 +278,7 @@ async def test_editing_a_draft_with_an_empty_list_strips_them(app_with_tools, fa async def test_the_tools_advertise_the_argument(app_with_tools): tools = {t.name: t for t in await app_with_tools.list_tools()} - for name in ("mail_send", "mail_draft", "mail_edit_draft"): + for name in ("mail_send", "mail_create_draft", "mail_edit_draft"): assert "attachments" in tools[name].inputSchema["properties"] diff --git a/tests/test_calendar_invitations.py b/tests/test_calendar_invitations.py index c25de7e..d52fd09 100644 --- a/tests/test_calendar_invitations.py +++ b/tests/test_calendar_invitations.py @@ -1,4 +1,4 @@ -"""Attendees and online meetings on ``calendar_create_event``. +"""Attendees and online meetings on ``calendar_create``. The two things that separate a meeting from an appointment -- people to invite and somewhere to meet -- are the first calendar features no CalDAV server can @@ -114,7 +114,7 @@ async def test_attendees_are_refused_by_a_backend_that_cannot_invite(): provider = FakeCalendarProvider(attendees=False) with pytest.raises(Exception) as exc: await _app(provider).call_tool( - "calendar_create_event", {**CREATE, "attendees": ["a@example.com"]} + "calendar_create", {**CREATE, "attendees": ["a@example.com"]} ) assert "invitation" in str(exc.value).lower() assert provider.created == [] # and no event either way @@ -124,7 +124,7 @@ async def test_an_online_meeting_is_refused_by_a_backend_that_mints_none(): provider = FakeCalendarProvider(online=False) with pytest.raises(Exception) as exc: await _app(provider).call_tool( - "calendar_create_event", {**CREATE, "online_meeting": True} + "calendar_create", {**CREATE, "online_meeting": True} ) assert "online meeting" in str(exc.value).lower() assert provider.created == [] @@ -145,7 +145,7 @@ def __getattribute__(self, name): with pytest.raises(Exception) as exc: await _app(provider).call_tool( - "calendar_create_event", {**CREATE, "attendees": ["a@example.com"]} + "calendar_create", {**CREATE, "attendees": ["a@example.com"]} ) assert "invitation" in str(exc.value).lower() assert provider.created == [] @@ -154,7 +154,7 @@ def __getattribute__(self, name): async def test_a_plain_event_still_works_on_a_backend_that_can_do_neither(): """The gate is on the meeting half only; an appointment is unaffected.""" provider = FakeCalendarProvider() - await _app(provider).call_tool("calendar_create_event", CREATE) + await _app(provider).call_tool("calendar_create", CREATE) assert provider.created == [ { "all_day": False, @@ -170,7 +170,7 @@ async def test_a_plain_event_still_works_on_a_backend_that_can_do_neither(): async def test_attendees_reach_the_provider_and_the_result_says_so(): provider = FakeCalendarProvider(attendees=True) result = await _app(provider).call_tool( - "calendar_create_event", + "calendar_create", {**CREATE, "attendees": ["a@example.com", "b@example.com"]}, ) assert provider.created[0]["attendees"] == ["a@example.com", "b@example.com"] @@ -189,14 +189,14 @@ async def test_attendees_may_arrive_the_way_recipients_do(sent, expected): """A bare address or a comma-separated string, the shapes an MCP client actually sends -- normalised by the same helper mail recipients use.""" provider = FakeCalendarProvider(attendees=True) - await _app(provider).call_tool("calendar_create_event", {**CREATE, "attendees": sent}) + await _app(provider).call_tool("calendar_create", {**CREATE, "attendees": sent}) assert provider.created[0]["attendees"] == expected async def test_attendees_are_trimmed_and_deduped_case_insensitively(): provider = FakeCalendarProvider(attendees=True) await _app(provider).call_tool( - "calendar_create_event", + "calendar_create", {**CREATE, "attendees": [" a@example.com ", "A@Example.com", ""]}, ) assert provider.created[0]["attendees"] == ["a@example.com"] @@ -206,7 +206,7 @@ async def test_something_that_is_not_an_address_is_refused(): provider = FakeCalendarProvider(attendees=True) with pytest.raises(Exception) as exc: await _app(provider).call_tool( - "calendar_create_event", {**CREATE, "attendees": ["Anna Jansen"]} + "calendar_create", {**CREATE, "attendees": ["Anna Jansen"]} ) assert "not an email address" in str(exc.value).lower() assert provider.created == [] @@ -216,7 +216,7 @@ async def test_an_online_meeting_comes_back_with_its_join_link(): """The link is the deliverable: an event nobody can join is half an answer.""" provider = FakeCalendarProvider(attendees=True, online=True) result = await _app(provider).call_tool( - "calendar_create_event", + "calendar_create", {**CREATE, "attendees": ["a@example.com"], "online_meeting": True}, ) assert provider.created[0]["online_meeting"] is True @@ -228,7 +228,7 @@ async def test_a_read_back_that_fails_does_not_fail_the_creation(): provider = FakeCalendarProvider(online=True) provider.read_fails = True result = await _app(provider).call_tool( - "calendar_create_event", {**CREATE, "online_meeting": True} + "calendar_create", {**CREATE, "online_meeting": True} ) assert provider.created[0]["online_meeting"] is True assert "Event created" in str(result) @@ -239,7 +239,7 @@ async def test_creating_still_needs_confirmation(): provider = FakeCalendarProvider(attendees=True) with pytest.raises(Exception) as exc: await _app(provider).call_tool( - "calendar_create_event", + "calendar_create", {**{k: v for k, v in CREATE.items() if k != "confirm"}, "attendees": ["a@example.com"]}, ) @@ -251,6 +251,6 @@ async def test_reading_an_event_reports_its_join_link(): provider = FakeCalendarProvider(online=True) provider.join_url = "https://teams.microsoft.com/l/meetup-join/19%3ameeting_y" result = await _app(provider).call_tool( - "calendar_read_event", {"calendar": "cal-1", "uid": "evt-1"} + "calendar_read", {"calendar": "cal-1", "uid": "evt-1"} ) assert "meetup-join" in str(result) diff --git a/tests/test_reply_threading.py b/tests/test_reply_threading.py index 72c2c6d..657bbc4 100644 --- a/tests/test_reply_threading.py +++ b/tests/test_reply_threading.py @@ -243,7 +243,7 @@ async def test_draft_can_be_a_reply_too(app_with_tools, fake_provider): """Drafting a reply for the user to review is the recommended path, so it has to thread just as well as sending one.""" result = await app_with_tools.call_tool( - "mail_draft", {"body": "sure", "reply_to_uid": "101"} + "mail_create_draft", {"body": "sure", "reply_to_uid": "101"} ) to, subject, _body, _folder = fake_provider.drafts[-1] assert to == ["anna@example.com"] and subject == "Re: Hello" @@ -263,7 +263,7 @@ async def test_a_plain_send_reports_no_thread(app_with_tools, fake_provider): async def test_reply_arguments_are_in_the_tool_schema(app_with_tools): """A client can only reply if the schema tells it how.""" tools = {t.name: t for t in await app_with_tools.list_tools()} - for name in ("mail_send", "mail_draft"): + for name in ("mail_send", "mail_create_draft"): props = tools[name].inputSchema.get("properties", {}) assert {"reply_to_uid", "reply_to_folder", "reply_all"} <= set(props), name diff --git a/tests/test_tools.py b/tests/test_tools.py index 3c14f07..51e63a9 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -16,7 +16,7 @@ "mail_read", "mail_read_chunk", "mail_get_attachment", - "mail_draft", + "mail_create_draft", "mail_edit_draft", "mail_send", "mail_send_draft", @@ -220,6 +220,6 @@ async def test_flag_requires_at_least_one_uid(app_with_tools, fake_provider): async def test_draft_reports_the_from_address(app_with_tools): result = await app_with_tools.call_tool( - "mail_draft", {"to": "x@y.com", "subject": "hi", "body": "yo"} + "mail_create_draft", {"to": "x@y.com", "subject": "hi", "body": "yo"} ) assert "me@example.com" in str(result)