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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` →
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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: `<pillar>_<verb>` for the pillar's own object,
`<pillar>_<verb>_<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.
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ 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 |
| `mail_delete` | The delete key: move to the account's own Trash — **requires `confirm=true`** | ⚠️ mutating |
| `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
Expand All @@ -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:"
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/mcp_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 8 additions & 8 deletions squirrel_mcp/knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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": "<uid>", "source_index": <n>, "source_folder": "INBOX"}` with
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion squirrel_mcp/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
22 changes: 11 additions & 11 deletions squirrel_mcp/tools/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
)
Expand All @@ -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,
Expand All @@ -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")


Expand Down
12 changes: 6 additions & 6 deletions squirrel_mcp/tools/mail/compose.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion tests/test_attachments_inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<p>rich</p>"},
)
assert fake_provider.draft_kwargs[-1]["body_html"] == "<p>rich</p>"
Expand Down
4 changes: 2 additions & 2 deletions tests/test_attachments_outgoing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}]},
)
Expand Down Expand Up @@ -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"]


Expand Down
Loading
Loading