From 8cdde51c0be1c4539a4e4ef12391b5fd27e69540 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Thu, 5 Nov 2026 22:45:52 +0300 Subject: [PATCH 1/3] chore: add the ctx-guard config for this repo .claude/ctx-guard.json gives the main role context notes at 300k, 400k and 500k (then every 50k), the solo preset's usage triggers, and an automatic clear-and-resume restart above 90k. .gitignore keeps the personal ctx-guard.local.json and the .ctx-guard/ state folder out. --- .claude/ctx-guard.json | 28 ++++++++++++++++++++++++++++ .gitignore | 2 ++ 2 files changed, 30 insertions(+) create mode 100644 .claude/ctx-guard.json diff --git a/.claude/ctx-guard.json b/.claude/ctx-guard.json new file mode 100644 index 0000000..46495c6 --- /dev/null +++ b/.claude/ctx-guard.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "defaultRole": "main", + "roles": { + "main": { + "description": "the one long-running session in this project", + "triggers": [ + { "id": "ctx-soft", "when": { "context": "300k" }, "do": ["note"], "level": "soft" }, + { "id": "ctx-hard", "when": { "context": "400k" }, "do": ["note"], "level": "hard" }, + { "id": "ctx-stop", "when": { "context": "500k", "every": "50k" }, "do": ["note"], "level": "stop" }, + { "id": "limit-soft", "when": { "limit.any": 80 }, "do": ["note"], "level": "soft" }, + { "id": "limit-hard", "when": { "limit.any": 90 }, "do": ["note", "toast"], "level": "hard" }, + { "id": "week-80-early", "when": { "limit.seven_day": 80, "limit.seven_day.resetsIn": "2d" }, "do": ["note", { "deny": ["Agent", "Workflow"] }], "level": "hard", "message": "With {resetsIn} still to the weekly reset, start no new subagents so the rest of the window lasts; do small steps in this session.", "lift": { "do": ["note", "notify"] } }, + { "id": "week-90-early", "when": { "limit.seven_day": 90, "limit.seven_day.resetsIn": "1d" }, "do": ["note", { "deny": ["Agent", "Workflow"] }], "level": "hard", "message": "With {resetsIn} still to the weekly reset, start no new subagents so the rest of the window lasts; do small steps in this session.", "lift": { "do": ["note", "notify"] } }, + { "id": "limit-brake", "when": { "limit.any": 95 }, "do": ["note", "toast", { "deny": ["Agent", "Workflow", "WebSearch", "WebFetch"] }], "level": "stop", "lift": { "do": ["note", "notify"] } }, + { "id": "cache-miss", "when": { "cache.miss": "50k" }, "do": ["toast"] } + ], + "handoff": { + "instructions": "Rewrite the whole file. Header: when it was written and the git HEAD, if this is a git repo. Sections: Goal, State, Next action." + }, + "restart": { + "mode": "auto", + "prompt": "Carry out Next action without waiting.", + "minContext": "90k" + } + } + } +} diff --git a/.gitignore b/.gitignore index 69c67d5..595e758 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,5 @@ __marimo__/ # codegraph index .codegraph/ +.claude/ctx-guard.local.json +.ctx-guard/ From 9e1114fc2f0894ce1e36b7f9ef31c2a83d560d05 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 9 Nov 2026 18:42:16 +0300 Subject: [PATCH 2/3] fix: gate /me/joinedTeams on the scopes Graph accepts for it Graph refuses GET /me/joinedTeams to Group.Read.All and names Team.ReadBasic.All, TeamSettings.Read.All, User.Read.All and Directory.Read.All (or their ReadWrite forms). `teams list` declared Team.ReadBasic.All|Group.Read.All, and a TEAM given by display name was resolved through that path under the same gate, so the default sign-in passed the local check and then got error[Forbidden] from Graph. `teams list` now declares the joinedTeams read scopes, and every teams verb gates them again before it resolves a TEAM by display name, so the command stops with MISSING_SCOPE and the `login --scopes extended` hint before any request. A team given by GUID or `id:` keeps the verb's own gate, which Group.Read.All still satisfies. The scope implication table maps User.ReadWrite.All, Directory.ReadWrite.All and TeamSettings.ReadWrite.All to their read forms, so those tokens pass while `teams list` declares only read scopes and stays read-only to MCP. SKILL.md lists `teams list` and team-by-name among the verbs that need `extended`, and its teams examples and channel-post recipe pass a team GUID; the reference marks the by-name verbs "Beyond default". --- CHANGELOG.md | 12 ++ docs/specs/2026-09-02-mgraphctl-design.md | 10 +- skills/mgraphctl/SKILL.md | 30 ++--- skills/mgraphctl/evals/evals.json | 4 +- skills/mgraphctl/reference/commands.md | 2 +- skills/mgraphctl/reference/commands/teams.md | 14 ++- src/mgraphctl/commands/teams.py | 38 +++++-- src/mgraphctl/config.py | 3 + tests/test_auth.py | 3 + tests/test_cli_teams.py | 112 +++++++++++++++++++ tests/test_mcp_discover.py | 6 + 11 files changed, 203 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1b0d93..9d45882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [Unreleased] + +- `teams list`, and any `teams` verb given a `TEAM` by display name, check for + `Team.ReadBasic.All` (or another scope Graph accepts for `/me/joinedTeams`) before sending a + request, and stop with `error[MISSING_SCOPE]` and the `login --scopes extended` hint. They used to + accept `Group.Read.All`, which the `default` sign-in holds, and then fail with Graph's + `error[Forbidden]`. A team given by id (GUID) still works with `Group.Read.All`. The skill's + channel-post recipe and teams examples pass the team id, which the user copies from Teams' + *Get link to team* (`groupId`). +- The scope check counts `User.ReadWrite.All`, `Directory.ReadWrite.All` and + `TeamSettings.ReadWrite.All` as holding their `.Read.All` forms. + ## [0.5.0] — 2026-11-05 - `calendar list` selects `lastModifiedDateTime`, and takes `--select` to override the `$select` diff --git a/docs/specs/2026-09-02-mgraphctl-design.md b/docs/specs/2026-09-02-mgraphctl-design.md index a7d99c1..eb83435 100644 --- a/docs/specs/2026-09-02-mgraphctl-design.md +++ b/docs/specs/2026-09-02-mgraphctl-design.md @@ -173,6 +173,8 @@ Every command declares `scopes: list[str]`; an entry `"A|B"` means any-of. `auth | `Chat.ReadWrite` | `Chat.Read`, `Chat.ReadBasic`, `ChatMessage.Send` | | `Tasks.ReadWrite`, `Notes.ReadWrite`, `Contacts.ReadWrite`, `Presence.ReadWrite`, `MailboxSettings.ReadWrite`, `TeamMember.ReadWrite.All`, `Group.ReadWrite.All` | the matching `.Read` scope | | `User.Read.All` | `User.ReadBasic.All` | +| `User.ReadWrite.All` | `User.Read.All`, `User.ReadBasic.All` | +| `Directory.ReadWrite.All`, `TeamSettings.ReadWrite.All` | the matching `.Read.All` scope | Fixture mode (§5.8) substitutes a synthetic token whose `scp` is the union of every scope declared by any command, so the gate never fires in replay. @@ -364,7 +366,7 @@ Positional resource arguments accept an id or a human name; resolution is determ | Argument | Id detection | Name lookup | |---|---|---| | mail `--folder` | `all` → `/me/messages` (whole mailbox, Node's default); well-known name (case-insensitive: inbox, drafts, sentitems, deleteditems, junkemail, archive, outbox, clutter, conversationhistory, msgfolderroot) or `len ≥ 40 and no spaces` | exact `displayName` among `GET /me/mailFolders?$top=200` then one level of `childFolders` | -| `TEAM` | GUID regex | case-insensitive `displayName` in `/me/joinedTeams` | +| `TEAM` | GUID regex | case-insensitive `displayName` in `/me/joinedTeams`, gated on the `teams list` scopes (§15 item 12) | | `CHANNEL` | starts with `19:` | `displayName` in `/teams/{t}/channels` | | `CHAT` | starts with `19:` | contains `@` → the 1:1 chat with that UPN (§8 chats dm lookup) | | `USER`/`UPN` | GUID → `/users/{id}`; contains `@` → `/users/{upn}`; `me` → `/me` | otherwise `people users` search when `User.ReadBasic.All` is held, else exit 2 | @@ -502,7 +504,7 @@ Legend. **Tier**: P0 = Node parity; P1 = works with the `default` scope set, new | Verb | Args | Options (default) | Graph call | Scopes | Parity | Tier | Notes | |---|---|---|---|---|---|---|---| -| `list` | — | — | `GET /me/joinedTeams` (no OData params) | `Team.ReadBasic.All\|Group.Read.All` | 5.6 teams-list | P0 | | +| `list` | — | — | `GET /me/joinedTeams` (no OData params) | `Team.ReadBasic.All\|TeamSettings.Read.All\|User.Read.All\|Directory.Read.All` | 5.6 teams-list | P0 | | | `get` | `TEAM` | — | `GET /teams/{t}` | `Team.ReadBasic.All\|Group.Read.All` | — | P1 | | | `members` | `TEAM` | `--limit 100`, `--all` | `GET /teams/{t}/members` | `TeamMember.Read.All` | — | P2 | | | `channels` | `TEAM` | — | `GET /teams/{t}/channels?$select=id,displayName,description,membershipType` | `Channel.ReadBasic.All\|Group.Read.All` | 5.7 list | P0 | | @@ -757,7 +759,7 @@ Sections, in order, with the rule each must state: | Setup | `uv` is the only prerequisite; first run installs (10–40 s, stderr notice). Every example spells out the full `${CLAUDE_PLUGIN_ROOT}/mgraphctl ` path — no shell variables or aliases, because `allowed-tools` matches the literal command prefix and environment does not persist between Bash calls | | Login and status | always run `${CLAUDE_PLUGIN_ROOT}/mgraphctl status` first (exit 3 = not logged in). On `NOT_LOGGED_IN`/`CONSENT_REQUIRED`: tell the user the exact command to run in **their own terminal** (`… mgraphctl login`, or `… login --scopes extended`, or the admin-consent URL), stop, and wait; never run `login` yourself, never retry in a loop; `MISSING_SCOPE` → relay the hint verbatim | | Command cheat-sheet | one block per noun listing verbs with the 1–2 most common flag combos; links to `reference/commands.md` for the full option tables | -| Recipes | inbox triage (`mail list --unread` → `mail read` → `mail mark --read` / `todo from-mail`); schedule a meeting (`calendar availability --users … --start … --end …` → `calendar create --teams … --dry-run` → confirm → run); find a file (`search Q --type driveItem` or `sharepoint url URL`); DM someone (`chats dm USER --body … --dry-run` → confirm → run); transcript to summary (`meetings list --subject … --resolve --with-transcripts` → `meetings transcript MID TID --output` → summarise; `meetings insights` when Copilot-licensed); to-do from mail; post to a channel (`teams list` → `teams channels TEAM` → `teams channel send`) | +| Recipes | inbox triage (`mail list --unread` → `mail read` → `mail mark --read` / `todo from-mail`); schedule a meeting (`calendar availability --users … --start … --end …` → `calendar create --teams … --dry-run` → confirm → run); find a file (`search Q --type driveItem` or `sharepoint url URL`); DM someone (`chats dm USER --body … --dry-run` → confirm → run); transcript to summary (`meetings list --subject … --resolve --with-transcripts` → `meetings transcript MID TID --output` → summarise; `meetings insights` when Copilot-licensed); to-do from mail; post to a channel (team GUID from the user, the `groupId` in Teams' *Get link to team*, or `teams list` on an `extended` sign-in → `teams channels TEAM` → `teams channel send … --dry-run` → confirm → run) | | Guardrails | (1) before **any** write (`send`, `reply`, `forward`, `create`, `update`, `complete`, `delete`, `move`, `mark`, `respond`, `upload`, `mkdir`, `rename`, `share`, `dm`, `from-mail`, `oof set`, `presence set/clear`, `drafts create/send`, non-GET `api`) run the same command with `--dry-run`, show the user the request, and get explicit confirmation before running it without `--dry-run`; (2) never run `login`; (3) prefer `--json` when parsing output, text when showing the user; (4) respect `--limit`/`--all` caps and quote `truncated`; (5) Microsoft 365 only — decline Slack/Google requests; (6) get today's date from the shell (`date`) and the zone from `--tz`/`MGRAPHCTL_TZ` before computing windows; (7) ids from `mail move` change — re-list after moving | | Output conventions | JSON envelope shapes; text-mode ordering (messages chronological, JSON newest first); datetimes carry offsets; `truncated` note on stderr | | Exit codes | the §6.5 table verbatim | @@ -807,7 +809,7 @@ Decided without user input; each is reversible before phase 1 starts. 9. `Prefer: IdType="ImmutableId"` is not sent; `mail move` therefore returns a new id (documented). Revisit if the skill needs to cache ids across calls. 10. Downloads overwrite existing files and create parent directories; `--output` never prompts. 11. `meetings insights` probes v1.0 first and falls back to `/beta` on 404, exactly as Node does; it is the only implicit beta call outside `--beta`/`api --beta`. -12. `teams list`/`channels` declare `Team.ReadBasic.All|Group.Read.All` and `Channel.ReadBasic.All|Group.Read.All` because Node proves `Group.Read.All` suffices; `teams members` declares `TeamMember.Read.All` only (P2), to be relaxed if `Group.Read.All` is confirmed sufficient. +12. `teams get`/`channels`/`channel get` declare `Team.ReadBasic.All|Group.Read.All` and `Channel.ReadBasic.All|Group.Read.All`: Graph serves `/teams/{t}` and `/teams/{t}/channels` to `Group.Read.All`. `/me/joinedTeams` it does not (verified 2026-11-09: 403 naming `Team.ReadBasic.All`, `TeamSettings.Read*.All`, `User.Read*.All`, `Directory.Read*.All`), so `teams list` declares the read ones of those, and every verb gates them again before resolving a `TEAM` given by display name; `teams members` declares `TeamMember.Read.All` only (P2), to be relaxed if `Group.Read.All` is confirmed sufficient. 13. `org manager|reports` without a UPN declare `User.Read` (Node proves it works for `/me`); for other users Graph requires `User.Read.All` (admin). 14. `people contacts --search` keeps Node's `$search` with a client-side fallback; `onenote search` passes Graph's error through for work accounts. 15. The Node tests were never in CI, and this plugin's pytest suite is also not in `.gitlab-ci.yml` yet (runners lack `uv`); adding a `uv`-capable job is a follow-up. diff --git a/skills/mgraphctl/SKILL.md b/skills/mgraphctl/SKILL.md index 8eb48e3..a6dfdf5 100644 --- a/skills/mgraphctl/SKILL.md +++ b/skills/mgraphctl/SKILL.md @@ -136,12 +136,14 @@ ${CLAUDE_PLUGIN_ROOT}/mgraphctl groups list --unified --json ${CLAUDE_PLUGIN_ROOT}/mgraphctl groups members "Design Guild" ``` -**teams** — `list`, `get`, `members`, `channels`, `channel get|messages|send` +**teams** — `list`, `get`, `members`, `channels`, `channel get|messages|send`. A team given +by display name needs `extended`; with `default`, pass the team id (GUID): the `groupId` in the +link Teams' *Get link to team* copies. ```bash -${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channels "Platform" --json -${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel messages "Platform" "General" --limit 20 -${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel messages "Platform" "General" --after -7d --all +${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channels 11111111-1111-4111-8111-111111111111 --json +${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel messages 11111111-1111-4111-8111-111111111111 "General" --limit 20 +${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel messages 11111111-1111-4111-8111-111111111111 "General" --after -7d --all ``` **chats** — `list`, `get`, `members`, `messages`, `send`, `dm`, `create`, `search`, @@ -238,12 +240,12 @@ ${CLAUDE_PLUGIN_ROOT}/mgraphctl search "budget plan" --type driveItem --json ${CLAUDE_PLUGIN_ROOT}/mgraphctl chats dm anna@example.com --body "Sending the deck now." --dry-run ``` -**Post to a channel.** Resolve the team and channel by name first. +**Post to a channel.** Get the team id from the user (the `groupId` in Teams' *Get link to +team*) or, on an `extended` sign-in, from `teams list`; then find the channel by name. ```bash -${CLAUDE_PLUGIN_ROOT}/mgraphctl teams list --json -${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channels "Platform" --json -${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel send "Platform" "General" --body "Release is out." --dry-run +${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channels 11111111-1111-4111-8111-111111111111 --json +${CLAUDE_PLUGIN_ROOT}/mgraphctl teams channel send 11111111-1111-4111-8111-111111111111 "General" --body "Release is out." --dry-run ``` **Transcript to summary.** Find the meeting by date and subject, then read the transcript. @@ -371,15 +373,15 @@ belong to the user's environment. `login` requests the `default` set — the 23 permissions covering mail read and send, calendars, files, sites, chats, channel messages, transcripts, people, contacts, notes, tasks and group membership. `login --scopes extended` adds mail and mailbox writes, presence, directory user -lookup, shared calendars, chat creation, and team and channel listing. +lookup, shared calendars, chat creation, and team listing and team lookup by name. These verbs need `extended`: `mail mark`, `mail move`, `mail delete`, `mail drafts create`, `mail drafts send`, `mail rules list`, `mail categories`, `mailbox settings`, `mailbox oof get`, -`mailbox oof set`, `calendar find-times`, `people users`, `teams members`, `chats create`, -`presence get`, `presence set`, `presence clear`. Three more need it only on one path: -`mail send` when the attachments are large enough to take the draft path, `chats dm` when no -1:1 chat exists yet and it has to create one, and `people photo UPN` when the photo belongs to -someone else. +`mailbox oof set`, `calendar find-times`, `people users`, `teams list`, `teams members`, +`chats create`, `presence get`, `presence set`, `presence clear`. Four more need it only on one +path: `mail send` when the attachments are large enough to take the draft path, `chats dm` when +no 1:1 chat exists yet and it has to create one, `people photo UPN` when the photo belongs to +someone else, and any `teams` verb given a team by display name rather than its id. Three scopes are in neither set and are asked for one at a time with `login --scope `: `OnlineMeetingRecording.Read.All` for `meetings recordings`, `User.Read.All` for `org manager`, diff --git a/skills/mgraphctl/evals/evals.json b/skills/mgraphctl/evals/evals.json index 27a114c..dc170d8 100644 --- a/skills/mgraphctl/evals/evals.json +++ b/skills/mgraphctl/evals/evals.json @@ -120,7 +120,7 @@ { "id": 8, "prompt": "Post 'Sprint planning at 2pm tomorrow' to the announcements channel in the Engineering team.", - "expected_output": "Resolves the team and channel by name, previews the send with --dry-run, and asks before actually posting.", + "expected_output": "Gets the Engineering team's id: on a default sign-in asks the user for it (the groupId in Teams' Get link to team), or runs teams list on an extended sign-in. Finds the channel by name, previews the send with --dry-run, and asks before actually posting.", "files": [], "assertions": [ { @@ -129,7 +129,7 @@ }, { "name": "uses-channel-send", - "description": "The response uses `teams channel send` with the team and channel named." + "description": "The response uses `teams channel send` with the team id (a GUID, or the team name on an extended sign-in) and the channel." } ] }, diff --git a/skills/mgraphctl/reference/commands.md b/skills/mgraphctl/reference/commands.md index acee8c5..84c08c6 100644 --- a/skills/mgraphctl/reference/commands.md +++ b/skills/mgraphctl/reference/commands.md @@ -87,7 +87,7 @@ as an id; a leading `/` forces a drive path. | Argument | Read as an id when | Otherwise looked up as | |---|---|---| | `mail --folder` | `all` (the whole mailbox), a well-known name (`inbox`, `drafts`, `sentitems`, `deleteditems`, `junkemail`, `archive`, `outbox`, `clutter`, `conversationhistory`, `msgfolderroot`), or 40+ characters with no spaces | exact `displayName` among `/me/mailFolders` and one level of child folders | -| `TEAM` | a GUID | case-insensitive `displayName` in `/me/joinedTeams` | +| `TEAM` | a GUID | case-insensitive `displayName` in `/me/joinedTeams`, which needs `Team.ReadBasic.All` | | `CHANNEL` | starts with `19:` | `displayName` in the team's channels | | `CHAT` | starts with `19:` | a value containing `@` finds the 1:1 chat with that user | | `USER` / `UPN` | a GUID, a value containing `@`, or `me` | a directory search, which needs `User.ReadBasic.All` | diff --git a/skills/mgraphctl/reference/commands/teams.md b/skills/mgraphctl/reference/commands/teams.md index ca4b39f..59a5cd0 100644 --- a/skills/mgraphctl/reference/commands/teams.md +++ b/skills/mgraphctl/reference/commands/teams.md @@ -3,6 +3,11 @@ Conventions, argument resolution, paging defaults, exit codes and environment variables are in [`../commands.md`](../commands.md). +A `TEAM` given by display name is looked up in `/me/joinedTeams`, which needs +`Team.ReadBasic.All` (in the `extended` set) on top of the verb's own scopes; Graph refuses that +path to `Group.Read.All`. A team id (GUID) needs only the verb's scopes, so with the `default` set +pass the GUID from the team's link or from `teams list` on an `extended` sign-in. + ### `teams list` | Option | Default | Meaning | @@ -10,7 +15,9 @@ are in [`../commands.md`](../commands.md). | `--json` | off | Print JSON instead of text. | - **Graph:** `GET /me/joinedTeams` (Graph accepts no OData parameters on this path). -- **Scopes:** `Team.ReadBasic.All` or `Group.Read.All` +- **Scopes:** `Team.ReadBasic.All`, `TeamSettings.Read.All`, `User.Read.All` (*on-demand*) or + `Directory.Read.All` +- **Beyond `default`:** always. - **Notes:** returns every team, unpaged; there is no `--limit`. ### `teams get TEAM` @@ -21,6 +28,7 @@ are in [`../commands.md`](../commands.md). - **Graph:** `GET /teams/{t}`. - **Scopes:** `Team.ReadBasic.All` or `Group.Read.All` +- **Beyond `default`:** only when TEAM is a display name. - **Notes:** `TEAM` may be a team id (GUID) or a display name from `teams list`. ### `teams members TEAM` @@ -43,6 +51,7 @@ are in [`../commands.md`](../commands.md). - **Graph:** `GET /teams/{t}/channels?$select=id,displayName,description,membershipType`. - **Scopes:** `Channel.ReadBasic.All` or `Group.Read.All` +- **Beyond `default`:** only when TEAM is a display name. ### `teams channel get TEAM CHANNEL` @@ -52,6 +61,7 @@ are in [`../commands.md`](../commands.md). - **Graph:** `GET /teams/{t}/channels/{c}`. - **Scopes:** `Channel.ReadBasic.All` or `Group.Read.All` +- **Beyond `default`:** only when TEAM is a display name. - **Notes:** `CHANNEL` may be a channel id (starts with `19:`) or a display name. ### `teams channel messages TEAM CHANNEL` @@ -70,6 +80,7 @@ are in [`../commands.md`](../commands.md). - **Graph:** `GET /teams/{t}/channels/{c}/messages?$top=50[&$expand=replies]`; `--replies`: `GET …/messages/{m}/replies?$top=50`, paged and bounded by `--limit`. - **Scopes:** `ChannelMessage.Read.All` +- **Beyond `default`:** only when TEAM is a display name. - **Notes:** deleted messages and system placeholders are dropped. Text mode prints oldest first with HTML bodies converted to Markdown; JSON keeps Graph's order, newest first. `--after`/`--before` add no query parameter: Graph documents `$top` and `$expand` as the only @@ -101,4 +112,5 @@ are in [`../commands.md`](../commands.md). - **Graph:** `POST /teams/{t}/channels/{c}/messages`, or `…/messages/{m}/replies` with `--reply-to`, body `{subject?, body:{contentType:"text"|"html", content}}`. - **Scopes:** `ChannelMessage.Send` +- **Beyond `default`:** only when TEAM is a display name. - **Notes:** write. Plain text is sent as `contentType:"text"`, so nothing needs escaping. diff --git a/src/mgraphctl/commands/teams.py b/src/mgraphctl/commands/teams.py index fd17498..32c516d 100644 --- a/src/mgraphctl/commands/teams.py +++ b/src/mgraphctl/commands/teams.py @@ -4,11 +4,13 @@ import typer +from mgraphctl import resolve from mgraphctl.cli import ( AllFlag, DryRunFlag, JsonFlag, LimitOpt, + gate, graph_command, make_noun_app, page_bounds, @@ -30,8 +32,18 @@ READ_TEAM = ["Team.ReadBasic.All|Group.Read.All"] READ_CHANNEL = ["Channel.ReadBasic.All|Group.Read.All"] - -TeamArg = Annotated[str, typer.Argument(metavar="TEAM", help="Team GUID or display name.")] +# `/me/joinedTeams` rejects Group.Read.All; these are the read scopes Graph names for it. Their +# ReadWrite forms satisfy the gate through config.SCOPE_IMPLIES: listing them here would make +# mcp.discover.can_write count `teams list` as a write. `teams list` and every team given by +# display name read this path; a team given by GUID never does. +JOINED_TEAMS = ["Team.ReadBasic.All|TeamSettings.Read.All|User.Read.All|Directory.Read.All"] + +TeamArg = Annotated[ + str, + typer.Argument( + metavar="TEAM", help="Team GUID, or display name (a name needs the extended sign-in)." + ), +] ChannelArg = Annotated[ str, typer.Argument(metavar="CHANNEL", help="Channel id (19:…) or display name.") ] @@ -52,13 +64,21 @@ ("Channel id", "id"), ] + +def _team_id(client: GraphClient, team: str) -> str: + """The team's id, gating the `/me/joinedTeams` scopes first when `team` is a display name.""" + if not resolve.looks_like_id(team, "team"): + gate(JOINED_TEAMS) + return teams.resolve_team(client, team)["id"] + + app = make_noun_app("Teams and channels.") channel = make_noun_app("Channels within a team.") app.add_typer(channel, name="channel") @app.command("list") -@graph_command(scopes=READ_TEAM) +@graph_command(scopes=JOINED_TEAMS) def list_(client: GraphClient, json_: JsonFlag = False): """List the teams you belong to.""" page = teams.list_teams(client) @@ -79,7 +99,7 @@ def list_(client: GraphClient, json_: JsonFlag = False): @graph_command(scopes=READ_TEAM) def get(client: GraphClient, team: TeamArg, json_: JsonFlag = False): """Show one team.""" - team_id = teams.resolve_team(client, team)["id"] + team_id = _team_id(client, team) return ObjectResult(obj=teams.get_team(client, team_id), fields=list(TEAM_FIELDS)) @@ -94,7 +114,7 @@ def members( ): """List a team's members.""" limit, all_ = page_bounds(limit, all_, default=100) - team_id = teams.resolve_team(client, team)["id"] + team_id = _team_id(client, team) page = teams.list_members(client, team_id, limit=limit, all_=all_) return ListResult( items=page.items, @@ -114,7 +134,7 @@ def members( @graph_command(scopes=READ_CHANNEL) def channels(client: GraphClient, team: TeamArg, json_: JsonFlag = False): """List a team's channels.""" - team_id = teams.resolve_team(client, team)["id"] + team_id = _team_id(client, team) page = teams.list_channels(client, team_id) return ListResult( items=page.items, @@ -134,7 +154,7 @@ def channels(client: GraphClient, team: TeamArg, json_: JsonFlag = False): @graph_command(scopes=READ_CHANNEL) def channel_get(client: GraphClient, team: TeamArg, chan: ChannelArg, json_: JsonFlag = False): """Show one channel.""" - team_id = teams.resolve_team(client, team)["id"] + team_id = _team_id(client, team) channel_id = teams.resolve_channel(client, team_id, chan)["id"] return ObjectResult( obj=teams.get_channel(client, team_id, channel_id), fields=list(CHANNEL_FIELDS) @@ -179,7 +199,7 @@ def channel_messages( if after_dt is not None and before_dt is not None and after_dt > before_dt: raise UsageError("USAGE", "--after must not be later than --before") limit, all_ = page_bounds(limit, all_, default=20) - team_id = teams.resolve_team(client, team)["id"] + team_id = _team_id(client, team) channel_id = teams.resolve_channel(client, team_id, chan)["id"] if replies is not None: page = teams.channel_replies(client, team_id, channel_id, replies, limit=limit, all_=all_) @@ -225,7 +245,7 @@ def channel_send( ): """Post a message to a channel.""" text = read_body(body, body_file) - team_id = teams.resolve_team(client, team)["id"] + team_id = _team_id(client, team) channel_id = teams.resolve_channel(client, team_id, chan)["id"] plan = teams.plan_channel_send( client, team_id, channel_id, body=text, html=html, subject=subject, reply_to=reply_to diff --git a/src/mgraphctl/config.py b/src/mgraphctl/config.py index 6bae38d..5e7aed1 100644 --- a/src/mgraphctl/config.py +++ b/src/mgraphctl/config.py @@ -104,6 +104,9 @@ "TeamMember.ReadWrite.All": ("TeamMember.Read.All",), "Group.ReadWrite.All": ("Group.Read.All",), "User.Read.All": ("User.ReadBasic.All",), + "User.ReadWrite.All": ("User.Read.All", "User.ReadBasic.All"), + "Directory.ReadWrite.All": ("Directory.Read.All",), + "TeamSettings.ReadWrite.All": ("TeamSettings.Read.All",), } # What `config init` writes: every key present, commented out, at its default. diff --git a/tests/test_auth.py b/tests/test_auth.py index 84fb26b..138ec64 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -343,6 +343,9 @@ def test_expand_scopes_implication_table(): assert {"Mail.Read", "Mail.ReadBasic"} <= auth.expand_scopes(["Mail.ReadWrite"]) assert "ChatMessage.Send" in auth.expand_scopes(["Chat.ReadWrite"]) assert "User.ReadBasic.All" in auth.expand_scopes(["User.Read.All"]) + assert {"User.Read.All", "User.ReadBasic.All"} <= auth.expand_scopes(["User.ReadWrite.All"]) + assert "Directory.Read.All" in auth.expand_scopes(["Directory.ReadWrite.All"]) + assert "TeamSettings.Read.All" in auth.expand_scopes(["TeamSettings.ReadWrite.All"]) assert auth.expand_scopes([]) == set() diff --git a/tests/test_cli_teams.py b/tests/test_cli_teams.py index e8b6151..90d6c97 100644 --- a/tests/test_cli_teams.py +++ b/tests/test_cli_teams.py @@ -6,6 +6,7 @@ import pytest from helpers import GRAPH, covers, graph_error, mock_graph +from mgraphctl import config TEAM = "11111111-1111-4111-8111-111111111111" CHANNEL = "19:channel-0001@thread.tacv2" @@ -112,6 +113,117 @@ def test_teams_members_scope_gate(invoke, graph): assert r.stderr.startswith("error[MISSING_SCOPE]: this command needs TeamMember.Read.All;") +# What `mgraphctl login` grants: Group.Read.All and ChannelMessage.Read.All, no Team.ReadBasic.All. +DEFAULT_TOKEN = pytest.mark.scopes(config.DEFAULT_SCOPES) +JOINED_TEAMS_REFUSED = "error[MISSING_SCOPE]: this command needs Team.ReadBasic.All or " + + +@covers("teams list") +@DEFAULT_TOKEN +def test_teams_list_gate_refuses_a_default_token(invoke, graph): + r = invoke("teams", "list", "--json") + assert r.exit_code == 3 and graph.calls.call_count == 0 + assert r.stderr.startswith(JOINED_TEAMS_REFUSED) + assert "login --scopes extended" in r.stderr + + +@covers("teams list") +@pytest.mark.scopes(["Team.ReadBasic.All"]) +def test_teams_list_passes_on_team_readbasic(invoke, graph): + mock_graph(graph, "teams/list") + r = invoke("teams", "list", "--json") + assert r.exit_code == 0, r.stderr + + +@covers("teams get", "teams channels", "teams channel get", "teams channel messages") +@covers("teams channel send") +@DEFAULT_TOKEN +@pytest.mark.parametrize( + "args", + [ + ["get", "Engineering"], + ["channels", "Engineering"], + ["channel", "get", "Engineering", CHANNEL], + ["channel", "messages", "Engineering", CHANNEL], + ["channel", "send", "Engineering", CHANNEL, "--body", "hi", "--dry-run"], + ], +) +def test_team_by_name_gate_refuses_a_default_token(invoke, graph, args): + r = invoke("teams", *args, "--json") + assert r.exit_code == 3 and graph.calls.call_count == 0, r.stderr + assert r.stderr.startswith(JOINED_TEAMS_REFUSED) + assert "login --scopes extended" in r.stderr + + +@covers("teams members") +@pytest.mark.scopes([*config.DEFAULT_SCOPES, "TeamMember.Read.All"]) +def test_teams_members_by_name_gate_refuses_without_joined_teams(invoke, graph): + r = invoke("teams", "members", "Engineering") + assert r.exit_code == 3 and graph.calls.call_count == 0 + assert r.stderr.startswith(JOINED_TEAMS_REFUSED) + + +@covers("teams get", "teams channels", "teams channel get", "teams channel messages") +@DEFAULT_TOKEN +@pytest.mark.parametrize( + "args,fixture", + [ + (["get", TEAM], "teams/get"), + (["channels", TEAM], "teams/channels"), + (["channel", "get", TEAM, CHANNEL], "teams/channel_get"), + ], +) +def test_team_by_guid_passes_on_a_default_token(invoke, graph, args, fixture): + mock_graph(graph, fixture) + r = invoke("teams", *args, "--json") + assert r.exit_code == 0, r.stderr + assert all("joinedTeams" not in str(c.request.url) for c in graph.calls) + + +@covers("teams get") +@DEFAULT_TOKEN +def test_team_forced_id_skips_the_joined_teams_gate(invoke, graph): + route = graph.get(f"{GRAPH}/v1.0/teams/not-a-guid").mock( + return_value=httpx.Response(200, json={"id": "not-a-guid", "displayName": "Odd"}) + ) + r = invoke("teams", "get", "id:not-a-guid", "--json") + assert r.exit_code == 0, r.stderr + assert route.call_count == 1 and graph.calls.call_count == 1 + + +@covers("teams channels") +@pytest.mark.parametrize( + "scope", + [ + pytest.param(scope, marks=pytest.mark.scopes(["Group.Read.All", scope]), id=scope) + for scope in ( + "Team.ReadBasic.All", + # Graph accepts the ReadWrite forms too; SCOPE_IMPLIES maps each to the gated read form. + "TeamSettings.ReadWrite.All", + "User.ReadWrite.All", + "Directory.ReadWrite.All", + ) + ], +) +def test_team_by_name_passes_with_a_joined_teams_scope(invoke, graph, scope): + mock_graph(graph, "teams/list") + mock_graph(graph, "teams/channels") + r = invoke("teams", "channels", "Engineering", "--json") + assert r.exit_code == 0, r.stderr + assert [c["displayName"] for c in json.loads(r.stdout)["items"]][0] == "General" + + +@covers("teams channel messages") +@DEFAULT_TOKEN +def test_channel_messages_by_guid_passes_on_a_default_token(invoke, graph): + graph.get(f"{CHANNEL_URL}/messages").mock( + return_value=httpx.Response(200, json={"value": [_msg("m-1", "2026-08-31T09:00:00Z")]}) + ) + r = invoke("teams", "channel", "messages", TEAM, CHANNEL, "--json") + assert r.exit_code == 0, r.stderr + assert [m["id"] for m in json.loads(r.stdout)["items"]] == ["m-1"] + + @covers("teams members") def test_teams_members_limit_zero_is_usage_error(invoke, graph): r = invoke("teams", "members", TEAM, "--limit", "0") diff --git a/tests/test_mcp_discover.py b/tests/test_mcp_discover.py index 38a8b3f..58c4289 100644 --- a/tests/test_mcp_discover.py +++ b/tests/test_mcp_discover.py @@ -112,6 +112,12 @@ def test_a_verb_that_could_write_is_either_mutating_or_knowingly_excused(specs): assert set(specs) >= discover.READ_WITH_WRITE_SCOPE +def test_teams_list_stays_read_only(specs): + """Its gate lists only read scopes; the ReadWrite forms are accepted via SCOPE_IMPLIES.""" + spec = specs["teams list"] + assert not discover.can_write(spec.scopes) and not spec.mutating + + def test_annotations_describe_the_verb(specs): assert specs["mail list"].annotations == {"readOnlyHint": True, "openWorldHint": True} delete = specs["calendar delete"].annotations From abe51a2d8093b9b89718322459702d98d78c3279 Mon Sep 17 00:00:00 2001 From: Sviatoslav Sviridov Date: Mon, 9 Nov 2026 19:53:21 +0300 Subject: [PATCH 3/3] chore(release): v0.5.1 - `teams list`, and any `teams` verb given a TEAM by display name, check for Team.ReadBasic.All (or another scope Graph accepts for /me/joinedTeams) before any request and stop with MISSING_SCOPE and the `login --scopes extended` hint; a team given by GUID still works with Group.Read.All - the scope check counts User.ReadWrite.All, Directory.ReadWrite.All and TeamSettings.ReadWrite.All as holding their .Read.All forms --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 2 +- VERSIONING.md | 1 + pyproject.toml | 2 +- skills/mgraphctl/SKILL.md | 2 +- src/mgraphctl/__init__.py | 2 +- uv.lock | 2 +- 9 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index d768776..379837a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mgraphctl", "displayName": "mgraphctl", "source": "./", - "version": "0.5.0", + "version": "0.5.1", "description": "Work with Microsoft 365 — Outlook mail and calendar, Teams chats and channels, presence, meetings and transcripts, SharePoint, OneDrive, OneNote, Planner, To Do, people and org chart — through the Microsoft Graph API (Python CLI run with uv).", "author": { "name": "Sviatoslav Sviridov", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2c754e5..05b962f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin.json", "name": "mgraphctl", - "version": "0.5.0", + "version": "0.5.1", "description": "Work with Microsoft 365 — Outlook mail and calendar, Teams chats and channels, presence, meetings and transcripts, SharePoint, OneDrive, OneNote, Planner, To Do, people and org chart — through the Microsoft Graph API (Python CLI run with uv).", "author": { "name": "Sviatoslav Sviridov", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 08ef5ff..3cd07c2 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mgraphctl", - "version": "0.5.0", + "version": "0.5.1", "description": "Work with Microsoft 365 through the Microsoft Graph API using a bundled Python CLI run with uv.", "author": { "name": "Sviatoslav Sviridov", diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d45882..b56d496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.5.1] — 2026-11-09 - `teams list`, and any `teams` verb given a `TEAM` by display name, check for `Team.ReadBasic.All` (or another scope Graph accepts for `/me/joinedTeams`) before sending a diff --git a/VERSIONING.md b/VERSIONING.md index b5c220c..fe52842 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -82,3 +82,4 @@ summarises it. | `mgraphctl--v0.4.0` | 2026-09-08 | MCP server: `mcp serve` and `mcp tools`, with capability groups, path confinement and the loopback HTTP transport. | | `mgraphctl--v0.4.1` | 2026-11-05 | `mail list --from`/`--to` values match as OR; `@domain` documented. | | `mgraphctl--v0.5.0` | 2026-11-05 | `calendar list --select` and `lastModifiedDateTime`; transcript `createdDateTime` in `meetings list`; short Graph timestamp fractions parse. | +| `mgraphctl--v0.5.1` | 2026-11-09 | `teams list` and team-by-name resolution gate the `/me/joinedTeams` scopes up front; ReadWrite user, directory and team-settings scopes imply their read forms. | diff --git a/pyproject.toml b/pyproject.toml index 7a1ad12..04963b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mgraphctl" -version = "0.5.0" +version = "0.5.1" description = "Microsoft Graph CLI behind the mgraphctl Claude Code skill" readme = "README.md" license = "MIT" diff --git a/skills/mgraphctl/SKILL.md b/skills/mgraphctl/SKILL.md index a6dfdf5..c205ba3 100644 --- a/skills/mgraphctl/SKILL.md +++ b/skills/mgraphctl/SKILL.md @@ -14,7 +14,7 @@ description: > allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/mgraphctl *) metadata: author: Sviatoslav Sviridov - version: "0.5.0" + version: "0.5.1" --- # Microsoft Graph (Python CLI) diff --git a/src/mgraphctl/__init__.py b/src/mgraphctl/__init__.py index 5b71909..8f0a8cc 100644 --- a/src/mgraphctl/__init__.py +++ b/src/mgraphctl/__init__.py @@ -1,3 +1,3 @@ """mgraphctl: Microsoft Graph CLI for the mgraphctl skill.""" -__version__ = "0.5.0" +__version__ = "0.5.1" diff --git a/uv.lock b/uv.lock index 44eb4d5..5b3cffb 100644 --- a/uv.lock +++ b/uv.lock @@ -583,7 +583,7 @@ wheels = [ [[package]] name = "mgraphctl" -version = "0.5.0" +version = "0.5.1" source = { editable = "." } dependencies = [ { name = "httpx" },