microsoft-teams-mcp
MCP server for Microsoft Teams — list every chat and channel you are in, read any of them by id or name, pull everything you have not read yet, read your Activity feed, fetch inline images, and — each one confirmed by you first — post messages and reactions, using your signed-in browser session.
This project was developed and is maintained by AI (Claude Code). Use at your own discretion.
How it works
Chats and channels are read through Teams' own chat-service REST API
(https://teams.cloud.microsoft/api/chatsvc/<region>/v1). The server needs a
bearer token for it, and the only place one exists is your signed-in
teams.cloud.microsoft tab: through the
fetchproxy browser bridge it
captures the Authorization header off a request the Teams web app makes to
its chat service — once. From then on every call is a plain server-side
request; the token is cached in ~/.microsoft-teams-mcp/token.json for its
~23h lifetime (so a restart needs no browser), and a 401 re-captures once and
replays. teams.microsoft.com (the old host) is not supported.
So chats and channels no longer need to be open in the browser — any of them can be read by id or name, and listing covers every chat, not just the ones the sidebar shows. To capture a token, an idle tab is not enough: click into any chat so Teams calls its chat service.
The Activity feed comes from the same API. It is a chat-service
conversation of its own (48:notifications), so teams_list_activity reads
it like any chat — no Activity view (or any view) needs to be open, and
nothing reads the page. The bridge is used for one thing only: capturing the
token.
By default nothing here marks anything read. Reading a chat through this
server leaves your Teams unread badges exactly as they were — unless you ask
for it: teams_mark_read, or mark_read: true on teams_list_messages
/ teams_list_unread_messages, moves your read marker forward. The writes —
teams_send_message, teams_edit_message, teams_delete_message, teams_react, marking read, renaming a group chat
or adding and removing its members (teams_rename_chat,
teams_add_chat_members, teams_remove_chat_members), and starting a new
one (teams_create_group_chat) — go through the same
chat-service API and change nothing until you confirm (see
Writes).
Pairing
The bridge remembers a pairing only for the exact set of domains it approved,
and approving a grant replaces the stored list of header captures. So all of
this server's captures (chat service, SharePoint, Substrate search, meeting
recaps) declare the same scope: the three domains teams.cloud.microsoft,
sharepoint.com and substrate.office.com, plus the chat-service, search and
meeting-recap captures and one
capture per SharePoint host the server has already captured from (usually just
your own OneDrive host — see SharePoint file download). Each capture
still reads only the one header it needs. The SharePoint hosts are remembered
in ~/.microsoft-teams-mcp/bridge-scope.json (beside the SharePoint token
cache; not written with TEAMS_TOKEN_CACHE=false) once a capture from them
succeeds.
Up to 0.7, each capture declared only its own domain, so the bridge asked to
pair again (or to update its grants) whenever the server moved between chats,
search and files. Upgrading from 0.7 asks to pair once more, for the
combined scope; after that it stays paired. Upgrading to the release that added
teams_get_meeting_recap asks once more, because the recap capture
(teams.cloud.microsoft/api/mcps/*) is new to that scope.
If you run the bridge in more than one browser or browser profile, each one is a
different extension identity, and the server trusts only the one it paired
with. Keep the bridge enabled in the browser you pair with (or re-pair
deliberately with FETCHPROXY_TRUST_NEW_EXTENSION=1 after switching browsers).
SharePoint file download
A shared file in a chat or channel is not in Teams' own object store
(where inline images live) — it is in SharePoint, which does not accept the
chat-service token. So teams_get_attachment captures a separate SharePoint
token and fetches the file through SharePoint's v2.1 shares API, then
downloads the pre-signed link it returns (with no token attached).
Two things follow from that:
- One pairing covers it. Every capture this server makes — chat service,
SharePoint and search — declares the same ContextMint Bridge scope: the domains
teams.cloud.microsoft,sharepoint.comandsubstrate.office.com, and the same list of header captures. So approvingmicrosoft-teams-mcponce covers all three, and moving between chats, files and search never asks again (see Pairing). The very first download asks once to grant the capture from your OneDrive host. - One token serves every SharePoint host. The SharePoint token is captured
from a request your Teams tab makes to your own OneDrive host
(
<tenant>-my.sharepoint.com), and SharePoint accepts it on every host of the tenant: colleagues' OneDrives in other regions and team sites alike. So you never need to open a file in Teams before downloading it. The first download, and the first after the short-lived token expires, may need you to reload a signed-in Teams tab (the requests it makes while loading are what the token is read from); after that the token is cached until it expires. If the bridge has not yet granted a SharePoint host, the download says so: approve the pending scope update formicrosoft-teams-mcpin the ContextMint Bridge popup (no need to remove or re-pair it) and retry. - A file your account cannot open (SharePoint answers 403) comes back as metadata and its open link rather than failing.
ref matches an attachment ignoring case, and a file without its extension
(report finds report.csv); a name that matches two attachments is refused
with both listed. save: true writes the attachment to a fresh file in the
same folder teams_export_messages uses — TEAMS_OUTPUT_DIR, else
~/Downloads/microsoft-teams-mcp — and returns its path instead of the
contents (a download link on a hosted install, whose disk you cannot reach).
Substrate search
teams_search_messages searches the way the Teams search box does: through
Microsoft Substrate (POST https://substrate.office.com/searchservice/api/v2/query).
Substrate does not accept the chat-service token either, so search captures a
third token — off the request the Teams tab makes to
substrate.office.com/searchservice/* when you run a search there — and caches it
in ~/.microsoft-teams-mcp/substrate-token.json. As with the other two, it is
read only from the request header, never from page storage.
-
No separate pairing. Search is part of the one bridge scope every capture declares (see Pairing), so the pairing that reads chats covers it.
-
Run a search in Teams once. The token is read off the Teams tab's own search request, so the first search may need you to type anything into the Teams search box and press Enter while the capture waits (
TEAMS_CAPTURE_TIMEOUT, default 30s). -
It falls back to scanning. In the default
mode: "auto", when no Substrate token can be captured or the search fails, the tool scans recent conversations instead (source: "scan", with afallback_reason), and does not wait on another capture for 10 minutes.mode: "substrate"fails instead;mode: "scan"never touches Substrate. -
It also finds people.
teams_add_chat_members,teams_remove_chat_membersandteams_create_group_chatuse the same token to look a person up in your organisation's directory (Substrate's people suggestions) when a name is not in your chats, or when you pass an email address. A lookup is an explicit request, so it always tries a fresh capture rather than honouring the 10-minute cooldown below. -
It does not re-capture in a loop. If a freshly captured token is still refused (401), the same 10-minute cooldown applies, so
autogoes straight to the scan instead of waiting on the bridge every call.
Verified live 2026-10-12. A token captured off the Teams tab's own search request (it lives about 1.4 hours) was replayed from Node against read-only queries such as
deploy. Confirmed: the request body (cvid,logicalIdand ascenarioare required; the sort is{ Field: "Time", SortDirection: "Desc" }), that no header beyond the bearer is needed, the response fields (conversation inClientThreadId, a channel root inClientConversationId's;messageid=, the message id inInternetMessageId, sender name and SMTP address but no MRI, a plain-textPreviewof at most ~255 characters, an approximateTotal), paging byfrom/size(up to 200), and thatconversationandsincecan be sent server-side asClientThreadId:"…"and an unquotedsent>=….fromstays client-side: Substrate's ownfrom:matches word prefixes, not "display name contains". A redacted response is intests/fixtures/substrate-query-response.json.
Meeting recaps
teams_get_meeting_recap reads what a meeting's Recap tab shows, from the
service that tab uses: the Meeting Collab Platform Service
(GET https://teams.cloud.microsoft/api/mcps/<region>/collab/readcollabobject/V2/…).
That service accepts neither the chat-service token nor the Substrate one, so
recaps use a fourth token, captured off the request the Teams tab makes
when you open a meeting chat's Recap tab, and cached in
~/.microsoft-teams-mcp/recap-token.json (it lives about an hour).
- Open a Recap tab once. The first recap, and the first after the token
expires, may need you to open any meeting chat's Recap tab in Teams while the
capture waits (
TEAMS_CAPTURE_TIMEOUT, default 30s). - One recap per occurrence. The meeting is found from the chat's own
thread (organizer, tenant and the calendar
iCalUid); a recurring meeting's occurrence is that id with its date written in. Withoutdate, the tool reads the chat for the days a call or recording happened and returns the newest of those (up to 5 tried) whose recap holds anything. - AI notes are reported, not read. When Teams made Intelligent Recap AI
notes, the result says so (
ai_summary). Their text is a per-user file that Teams shows only to licensed users; reading it for anyone else was refused (403) live, so the tool does not try.
Verified live 2026-11-10 against 40 meeting chats: the URL and token above, the occurrence id (bytes 16–19 of the
iCalUid= year, month, day), an emptyresourceslist for an occurrence with nothing recorded, and the resource typesRecording,TranscriptV2,transcript,notes,AttendanceReport,MeetingChat,MeetingStageandAISummary. A redacted response is intests/fixtures/recap-collab-object.json. The region (us) is the one this tenant used; setTEAMS_MCPS_REGIONif yours differs.
Install
npm i -g @chrischall/microsoft-teams-mcp
Requires the ContextMint Bridge browser extension and @fetchproxy/cli,
on a matching major version. Install the bridge from
its releases page:
in Chrome, unzip the chrome zip and load it unpacked
(chrome://extensions → Developer mode → Load unpacked). Safari isn't
available yet (it will ship inside the ContextMint app, which has no public
download), so use Chrome for now.
ContextMint Bridge is the fetchproxy browser extension under its new name,
from the same maintainer — fetchproxy's own README
(https://github.com/chrischall/fetchproxy#extension) points to it. Its source
is public at https://github.com/nullnet-app/contextmint-bridge: build it
yourself, or check a release zip against the .sha256 file published beside
it (shasum -a 256 -c contextmint-bridge-chrome-<version>.zip.sha256).
npm i -g @fetchproxy/cli
The first call prints a 6-digit pair code to approve in the ContextMint Bridge popup; the grant persists.
Install in opencode
opencode reads MCP servers from opencode.json (project) or
~/.config/opencode/opencode.json (global):
{
"mcp": {
"servers": {
"teams": {
"type": "local",
"command": ["npx", "-y", "@chrischall/microsoft-teams-mcp"]
}
}
}
}
Configuration
Everything is optional — by default the token is captured from your browser.
| variable | purpose |
|---|---|
TEAMS_ACCESS_TOKEN | A chat-service bearer token (the JWT itself) to use instead of capturing one. Never re-captured from the browser: when Teams rejects it, replace it. |
TEAMS_DISABLE_FETCHPROXY | true to never capture from the browser (then TEAMS_ACCESS_TOKEN is required). |
TEAMS_CHATSVC_REGION | Chat-service region in the API path. Defaults to amer. |
TEAMS_CHATSVC_BASE | Full override for the API base (default https://teams.cloud.microsoft/api/chatsvc/<region>/v1). |
TEAMS_CAPTURE_TIMEOUT | Seconds to wait for the Teams tab to issue a chat-service request. Defaults to 30. The bridge deadline is derived from it (window + 15s). |
TEAMS_TOKEN_CACHE | false to keep the captured token in memory only. Defaults to on. |
TEAMS_TOKEN_FILE | Where the captured token is cached. Defaults to ~/.microsoft-teams-mcp/token.json. |
TEAMS_RECAP_TOKEN_FILE | Where the captured meeting-recap token is cached. Defaults to ~/.microsoft-teams-mcp/recap-token.json. TEAMS_TOKEN_CACHE=false keeps it in memory too. |
TEAMS_MCPS_REGION | The region in the meeting-recap API path (teams.cloud.microsoft/api/mcps/<region>). Defaults to us. |
TEAMS_SUBSTRATE_TOKEN_FILE | Where the captured Substrate search token is cached. Defaults to ~/.microsoft-teams-mcp/substrate-token.json. TEAMS_TOKEN_CACHE=false keeps it in memory too. |
TEAMS_SHAREPOINT_TOKEN_FILE | Where the captured SharePoint token (one for every SharePoint host, used to download shared files) is cached. Defaults to ~/.microsoft-teams-mcp/sharepoint-tokens.json. TEAMS_TOKEN_CACHE=false keeps them in memory too. |
TEAMS_TIME_ZONE | Default IANA time zone (e.g. America/New_York) for the tz argument of teams_list_messages and teams_list_unread_messages: each message then carries time_local (ISO 8601 with offset) next to its UTC time. Unset means no time_local unless tz is passed; a name Intl does not know is reported as an error. |
TEAMS_OUTPUT_DIR | Folder teams_export_messages (and teams_get_transcript with save, and teams_get_attachment with save, for a file over 10 MiB, or for the original of a converted PDF, workbook or deck) writes into (each export gets its own timestamped subfolder). Defaults to ~/Downloads/microsoft-teams-mcp. When set, a per-call dest must lie inside it; when unset, dest must be under ~/Downloads, ~/Documents or ~/Desktop. |
TEAMS_WS_PORT | fetchproxy concentrator port. Defaults to 37149, the fleet-wide shared port. |
FETCHPROXY_WS_HOST | Bind interface for the bridge (default 127.0.0.1; a literal IP only, anything else is ignored). Keep on loopback — it exists for ONE topology: a hosted child in its own network namespace. |
FETCHPROXY_IDENTITY_DIR | Where to store the long-term fetchproxy identity keypair (default ~/.fetchproxy/identity/; an absolute path only, anything else is ignored). Exists for a host that runs a separate child per caller, so they can share one identity. |
Tools
-
teams_list_conversations— every chat you are in (1:1, group and meeting chats, not just the visible sidebar, plus your48:notesself-chat as kindselfwhen Teams lists it), favorites and pinned chats first, then most recent activity first: id, kind, title, unread hint, ISO-8601 last-activity time, last-message preview and sender.unread_only(exact — see below),since_days(withunread_only; default 14),older_unread(withunread_only; lists whatsince_daysleft out — see below),query(title, a participant's name, or the last-message preview),search_messages,since/until(ISO 8601 date-times: keep chats whose last activity is at or after / at or before them; a chat with no last activity is left out when either is given),sortandlimit(default 50, max 500; keeps the head of the sort order).sortisactivity(default — favorites, pinned, then most recent; withunread_only, most recent),activity_only(most recent first, ignoring favorites and pinned) ortitle(alphabetical, case-insensitive — this titles every untitled chat in the listing beforelimitapplies, so it reads more than the other orders). A group chat with no topic is titled after who is in it —Group chat: <the first three recent senders other than you, sorted by name>plus+Nfor more, with every recent sender inparticipantsin the same order — rather than after whoever posted last, so chats that share a last sender stay distinguishable, and a chat keeps its title when someone different posts next. This server has no member-list call, so this reads up to 30 recent messages (one small page) of each such chat returned (four at a time, cached until a new message arrives) and names the people who wrote in them; the title changes only when that set of people does. If the read fails, the chat keeps its last-sender title. Every other tool that returns a conversation names it the same way: the Activity feed'swhere, an attachment's caption, search hits, read errors, and the conversation a send, reaction or mark-read targets. A 1:1 is titled by the other person (its id names them) and carries them as its oneparticipantsentry — read from their recent messages when you sent the last one,(1:1 chat)if they wrote nothing recently.querymatches such chats by their participants, never by the made-upGroup chat:or(1:1 chat)titles, sogrouporchatdoes not hit every untitled chat. With aquery, every row carriesmatch_reason— the first ofid(an exact id),title,team,participant(an untitled chat's participant, including a 1:1's other person) ormessagethat applies.messagemeans the query was found in what was said, not in what the chat is called: by default only the last-message preview the listing already carries is checked (no extra reads).search_messages: true(needsquery) also reads the last 50 messages of every chat no name or preview matched, four at a time, and returns the ones where a message a person wrote contains the query, with the newest such message asmatched_message(id,time,sender,textcut to a window around the match). It is one read per chat, so narrow a large listing withsince/untilorunread_only; chats whose read failed are counted inmessages_not_searched. Conversations the chat service marks favorite or pinned carryfavorite: true/pinned: trueand lead the plain listing (favorites, then pinned);unread_onlykeeps most-recent-first order but carries the flags. Note the new Teams Favorites section for chats is kept by a different Teams service this MCP does not read, so a chat you put there is not flagged (verified live 2026-10-10) — the flags mostly reflect favorited channels and pinned conversations. A chat you muted in Teams carriesmuted: true. -
teams_list_channels— every channel you are in, by team then title, with the same fields — favorite and pinned channels first, flagged as inteams_list_conversations. Channels you have hidden in Teams are listed after the rest withhidden: true, and left out ofunread_onlyunlessinclude_hiddenis set.unread_only,include_hidden,since_days(withunread_only; default 14),query(channel or team name, or the last-message preview; each match carriesmatch_reason, as inteams_list_conversations) andlimit(default 50, max 500). -
teams_list_messages— read any chat or channel by id or name, oldest first: sender, time (UTC, ISO 8601), text, subject, edited/deleted flags and attachments. A formatted message also carriescontent_html— its body as sanitised HTML (bold, italic, lists, links, code, quotes, headings, tables and @mention spans; every other tag and attribute dropped) — and any message with them carriesmentions([{mri, displayName}], who was @mentioned) andlinks([{href, text}], each link with the text it was shown as). A plain message has none of the three: itstextsays everything. Intexta numbered list keeps its numbers (1.,2.) and a bulleted one its-. A channel comes back as threads (root post + replies). A thread whose root post is older than the window (a reply came in aftersince,limitorafter_message_idcut the read) still shows its root: it is fetched by id and markedoutside_window: true— context only, not counted inunread_countormessage_countand never thenext_cursor. Up to 25 roots are fetched per call (shared by every conversation it reads, newest threads first); one past that, or one that cannot be fetched, is{ id, missing: true }.unread_only,since(ISO 8601),limit(default 50, max 500, newest kept) andtz(an IANA time zone such asAmerica/New_York, defaultTEAMS_TIME_ZONE), which addstime_local— the same instant in that zone, ISO 8601 with its offset — next to each message'stime. Message text is never truncated, and a table's cells are separated by|, one row per line. The resolvedconversationcarriesmatch_reason(id,title,teamorparticipant); a name never resolves by what was said in a chat, sinceteams_send_messageandteams_reactname their target the same way. An ambiguous name is not an error here: the result is{ambiguous: true, query, match_count, candidates, hint}— up to 10 candidates, each withid,title,kind,team(channels),lastActivityandmatch_reason— and no messages, so you can read again by one candidate's id (teams_send_messageandteams_reactstill refuse an ambiguous name outright, since a write never guesses); an untitled group chat can be named by one of its participants, and a 1:1 by the other person (this, and the same naming inteams_send_message/teams_react, titles every untitled chat first).48:notes— or the namenotes/self— is your notes self-chat.after_message_idreturns only messages newer than that id (the read stops at the first message at or before it, alongsideunread_only,sinceandlimit); every result carriesnext_cursor— the newest returned message's id, or theafter_message_idyou passed when nothing is new — so passing it back asafter_message_idreads only what arrived since.after_message_idtakes message ids only: to read whatteams_list_changed_conversationscounted, pass that row'safter_message_id, not itsnext_cursor. See Cursors.frompages oldest first from a UTC time instead — see Paging from a time.formatsets how much comes back:full(default, everything above),compact(each message onlyid,time,time_local,sender,text, plussubjectwhen set — no reactions, attachments or edited/deleted flags — and the conversation onlyid,kind,title,team) orsummary(no messages:message_count,newest_timeand, for a channel, one{ root_id, subject, reply_count, newest_time }per thread, withroot_outside_window: trueorroot_missing: truefor a root from outside the window).conversationmay also be an array of up to 20 ids or names: the result is then{ conversations: [...], errors, fetched_at }, one single-read result per conversation (the other arguments apply to each), read four at a time; an ambiguous name takes its place inconversationsas the same{ambiguous: true, query, match_count, candidates, hint}object a single read returns, one that cannot be resolved or read is listed inerrorswith its reason (and, once it resolved, itsid,title,kind,teamandparticipants), and the rest still come back. A single string returns exactly the shape above. By default it does not mark anything read;mark_read: truealso moves your read marker up to the newest message returned (never backwards;outside_windowroots do not count) and reportsmarked_read— a write, so it asks you to confirm first. With an array, each conversation read is marked up to its own newest message, and one confirmation covers them all. It never marks a message you were not given:mark_readwithformat: "summary"is refused (compactis fine), and whenlimitorsincecut off unread messages older than those returned, that conversation's marker stays put and it reportsmarked_read: { skipped: "truncated" | "since", hint }(alimitthat cut only messages you had already read still marks). An empty read, or anafter_message_idwith nothing new, marks nothing (marked_read: false). -
teams_list_unread_messages— every unread message across your chats and channels, grouped by conversation, most recent first (or, withfrom, one oldest-first list across them all — see Paging from a time). Filter bykinds(chat,channel,meeting,oneOnOne) orquery(title, channel or team name, an untitled chat's participant name, or the last-message preview — each conversation then carriesmatch_reason, as inteams_list_conversations);per_conversation_limit(default 50) andmax_conversations(default 20 returned with their messages — the rest are named, with theirunread_count, inmore_unread_conversations). Hidden channels are left out unlessinclude_hidden; chats you muted in Teams (muted: true) are left out unread and counted inmuted_skippedunlessinclude_muted. Only conversations with unread messages from the lastsince_days(default 14, max 3650) are returned; older ones are counted inolder_unread_skipped, andolder_unread: truealso lists them (see below). An untitled group chat or 1:1 is titled after its participants, as inteams_list_conversations— inconversations, inmore_unread_conversationsand inerrors. By default it does not mark anything read;mark_read: truealso moves the read marker of each conversation returned with its messages (notmore_unread_conversations) up to its newest unread message returned, and reportsmarked_readper conversation (withmark_read_warningwhen that move failed). One confirmation covers every move. It never marks a message you were not given:mark_readwithformat: "summary"is refused (compactis fine), a conversationper_conversation_limitcut keeps its marker and reportsmarked_read: { skipped: "truncated", hint }, andoutside_windowroots never count.tz(defaultTEAMS_TIME_ZONE) adds each message'stime_local, as inteams_list_messages. A channel's unread replies come grouped under their root post; a root you had already read is fetched and markedoutside_window: true(not unread, not counted), up to 25 per call as inteams_list_messages, else{ id, missing: true }.cursors—{ "<conversation id>": "<message id>" }— returns, per conversation, only unread messages newer than its cursor (each read stops at the later of the read marker and the cursor; a conversation with nothing past its cursor is left out). Each returned conversation carriesnext_cursor, and the result carriesnext_cursors: the cursors you passed, updated with every returned conversation'snext_cursor— pass it back ascursorsnext time.format(full,compactorsummary) trims each conversation as inteams_list_messages; every format keeps the conversationid,unread_countandnext_cursor. -
teams_list_changed_conversations— which chats and channels got new messages since a cursor: counts, not messages. Each changed conversation comes back with its summary,new_message_count(real messages only — call recordings and other system events, and deleted messages, are not counted),new_from_others_count,newest_timeand, for a channel,threads: one{ root_id, new_root, new_reply_count }per thread the new messages landed in, including a thread whose root post is older than the cursor, andafter_message_id— the value to pass toteams_list_messagesasafter_message_idto read exactly the messages counted (a message id is its arrival ms, so this is the cursor time written as an id). Withoutcursorit looks back 24 hours.next_cursoris an opaque token (base64url of{"t": <epoch ms>};cursoralso takes a plain ISO 8601 time) — pass it back to see only what arrived since. It is at-least-once: a conversation can come back twice, but none is skipped, and it stays put when a read failed (errors).kindsandinclude_hiddenfilter as inteams_list_unread_messages— use the same ones with a cursor as the call that returned it;limit(default 50, max 200) caps the conversations returned, the rest are named inmore_conversations(each with itsafter_message_idtoo). Then read what changed withteams_list_messages, handing on the row'safter_message_id— notnext_cursor, which is a time token that onlyteams_list_changed_conversationsaccepts (teams_list_messagesrejects it, andteams_list_changed_conversationsrejects a message id).view: "brief"answers lines instead, inside the untrusted-data envelope asdata: string[], one per changed conversation —title — <newest ISO time | no date> — <n> new, <m> from others, at most 120 characters, plus-- nonewhen nothing is new and-- error: …when some conversations could not be read — the shape mcp-host's context groups read (this is the work group's canonical what's new):// first call: the last 24 hours teams_list_changed_conversations({}) // → { since, total_new_messages: 5, conversations: [ // { conversation: { id: "19:…@thread.tacv2", kind: "channel", title: "Design", … }, // new_message_count: 3, new_from_others_count: 3, newest_time: "…", // after_message_id: "1791728801361", // threads: [{ root_id: "1791803726629", new_root: false, new_reply_count: 2 }, …] }, … ], // next_cursor: "eyJ0IjoxNzkxODE1MjAxMzYxfQ", next_cursor_time: "…", fetched_at } // read the messages it counted in one conversation: hand on the row's after_message_id teams_list_messages({ conversation: "19:…@thread.tacv2", after_message_id: "1791728801361" }) // later: only what arrived since — next_cursor goes back to teams_list_changed_conversations only teams_list_changed_conversations({ cursor: "eyJ0IjoxNzkxODE1MjAxMzYxfQ" })A channel reply moves only the listing's
lastUpdatedMessageId(itslastMessagestays on the newest root post), so that is what nominates a channel; each conversation is then read newest change first down to the cursor, since an old message edited after it is served ahead of new ones. A hidden channel's listing entry stops moving altogether, so withinclude_hiddenevery hidden channel is read. -
teams_search_messages— find messages by text across your chats and channels, newest first: each hit names its conversation (id, title, kind, team) and carries the message's id, ISO-8601 time, sender,fromMe(scan only), plain text, subject andreplyTo(a channel reply's root) — read it in context withteams_list_messages.conversationnarrows to one chat or channel (id or name, as inteams_list_messages),fromto senders whose display name contains it,since(ISO 8601; default the last 30 days) sets how far back to look,limitcaps the hits (default 25, max 200;total_hitsandtruncatedsay when there were more).sourcesays which path answered:"substrate"(the default,mode: "auto") — Teams' own search through Microsoft Substrate: matched the way the Teams search box matches (words ANDed with stemming, sodeployalso finds "deployed";"quoted phrases";OR), across all of your conversations rather than the most recent ones.conversationandsinceare sent to Substrate itself;fromis applied to what it returns, 50 results a page, up to 10 pages. Its text is Substrate's plain-text preview of the message (at most ~255 characters), so every hit is markedexcerpt: true, and its hits carry nofromMe(Substrate does not say who you are on a hit).substratereports the pages read and how many results the filters dropped. A conversation missing from your listing is named by its id."scan"— when Substrate is unavailable or fails (fallback_reasonsays why), or withmode: "scan". Every word ofquerymust appear (any order, ignoring case;"quoted words"match as a phrase); themax_conversations(default 100, max 500) most recently active conversations with activity since the cutoff are read back to it, four at a time, andscanreports how many were read, how many the window or the cap left out, and any read only partly (1,000 messages each). Live, 100 conversations took about 15 seconds — narrow withconversationorsincewhen you can. Channels hidden in Teams are skipped unless named; deleted messages and system events never match.
mode: "substrate"uses only Substrate and fails rather than falling back. -
teams_get_attachment— one attachment of a message: an inline image is returned as the image itself; a shared file is downloaded from SharePoint and returned as its contents — an image as the image, anything else as an embedded resource carrying the bytes.save: truewrites it to a fresh file underTEAMS_OUTPUT_DIR(else~/Downloads/microsoft-teams-mcp) and returns itspathinstead.refmatches ignoring case, and a file without its extension. Shared-file download uses a separate, short-lived SharePoint token that serves every SharePoint host, so the first download may need your Teams tab reloaded to capture it; a file your account cannot open, or one that could not be fetched, comes back as metadata and its open link instead of failing. The caption names the conversation beside its id (title, plusparticipantsfor an untitled chat), titled asteams_list_conversationstitles it.A shared PDF, Excel workbook (.xlsx/.xlsm) or PowerPoint deck (.pptx) is also read for you, since a hosted client can only show the model images —
convertpicks how:convertPDF Workbook Deck auto(default)Markdown ( ## Page N), plus page images when it has little text (a scan)one CSV per sheet per-slide Markdown (title, text, tables, speaker notes) plus its embedded pictures textMarkdown only, never page images as autoas autoimagespage images only as autoas autononeno conversion: the file is handed over as above The converted blocks come first, fenced by an untrusted-content header that counts them (the file's text is someone else's, like any message), then the caption, whose
convertedlistsformat,count(pages / sheets / slides), thepartsreturned and anynotes(what was cut or skipped — text is capped at 200 KiB and images at 8 MiB, never silently). The original is still handed over — a download link when hosted, a saved file (path) on a local install — but never inlined beside its conversion. On a local install every converted read saves a fresh copy of the original to the download folder (TEAMS_OUTPUT_DIR, else~/Downloads/microsoft-teams-mcp), never overwriting, so reading the same file twice leaves two copies. A file that will not convert (corrupt, say) is handed over as before, with the reason inconversion_error.save: trueskips conversion: it asks for the file itself. Page images need the native@napi-rs/canvaspackage, installed with the npm package; where it is missing (the.mcpbbundle ships nonode_modules) a PDF comes back as text with a note saying page images are unavailable. -
teams_list_activity— the Activity feed (the bell icon), newest first: mentions (including @team / @everyone), reactions, replies, meeting notices and missed calls across every chat and channel. Each row has its time (ISO 8601),kind(mention,reaction,reply,meeting,callorother) with Teams' raw type/subtype, whether you have seen it, who did it, where (conversation_id,title,type, andteam/participantswhen known), themessage_idand a plain-text preview. The feed carries no name for an untitled group chat or 1:1, sotitleis taken from the conversations listing and titled asteams_list_conversationstitles it (one listing read plus cached title reads for the rows returned; if that fails the feed's own topic stands in) — passwhere.conversation_idtoteams_list_messagesto read it in context. Filter withunread_only,kindsandsince_days(default 14, max 3650);limit(default 50, max 200). Read from the chat service, so the Activity view does not need to be open; it never marks anything read (teams_mark_readmarks chats and channels read, not the Activity feed). -
teams_get_meeting_recap— what a meeting's Recap tab shows, from its meeting chat (conversation: the chat's id or a substring of its title):recap.recordings(start, end,duration, playurl),recap.transcripts(start and duration — read the text withteams_get_transcript),recap.notes(the Loop meeting-notes link),recap.attendance_reports,recap.files(shared in the meeting chat, or on stage such as a whiteboard:shared_in),recap.ai_summary(whether Teams made AI notes; their text is not read) andrecap.other(any type not listed). Sections with nothing are left out. For a recurring meeting,date(YYYY-MM-DD, the UTC day it met) picks the occurrence; by default the newest day the chat shows a call on that has a recap is returned asoccurrence, withseries(what belongs to the whole series, e.g. files shared across it) andother_occurrences(other call days, newest first). Uses its own token (see Meeting recaps). Read-only. -
teams_get_transcript— the transcript of a recorded Teams meeting, read from its meeting chat (conversation: the chat's id or a substring of its title). It reads Teams' own copy of the transcript — the one the meeting's Recap tab shows — from Teams' object store with the chat-service token, so it works even when the organizer turned transcript download off (only the OneDrive copy is locked then). No SharePoint token or extra pairing is involved. Returns the newest transcribed recording unlessrecording_idnames another, withrecording(recording_id, time,duration,play_url),speakers,cue_count,other_recordings(newest first, up to 20) andtranscript:format: "text"(default — one line per speaker turn,[hh:mm:ss] Name: words) or"vtt"(the WebVTT as Teams serves it). A transcript over 200,000 characters is cut short (truncated: true).save: truewrites<meeting>-<time>.vttand.txtinstead, liketeams_export_messages(a fresh folder underdest→TEAMS_OUTPUT_DIR→~/Downloads/microsoft-teams-mcplocally; a zip behind a signed link on a hosted install), and returns where. The newest 2,000 messages of the chat are searched for recordings. Teams' copy expires after some weeks (verified: about four): an expired transcript is an error naming that, never a silent switch to another recording. -
teams_export_messages— bulk-export chats and channels to files, so thousands of messages can be archived or searched with shell tools without any of them entering the conversation: the result is only a manifest (where the files are, each file's size and record count, counts, the time span, errors,has_more/cursor), never a message. One file per conversation, named after it (<title>-<8-char id hash>.jsonl; an untitled group chat is named after its participants), plusmanifest.json. JSONL by default — a header record ({"_export": {...}}: source, account, time, arguments, the conversation, and a note that the content is untrusted third-party text), then one record per message, oldest first, each carrying its conversation'sid,kind,title,team(channels) andparticipants(chats: the people seen writing, other than you) and the message'scontent_html,mentionsandlinkswhen it has them — orformat: "markdown"for readable transcripts, channels threaded under their root posts, a formatted message rendered as Markdown (bold, italic, lists, links with their text, code, quotes, tables, mentions as@name).conversations(ids or names, one or an array; omit for every conversation),kinds(chat,channel,meeting),since/until(by arrival; default all history),tz. Each call stops atmax_itemsmessages (default 5000, max 50000) orbudget_seconds(default 90, max 600); withhas_more: true, pass thecursorback (alone, or withmax_items/budget_seconds) to continue the SAME export — on disk into the same folder, a conversation split across calls continuing in<name>.part-0002.jsonlbeside its first file (andmanifest.part-0002.json). The budget is checked between conversations and between pages of a read: a long read past it is dropped and redone by the next call (except the first conversation of a call, which always finishes, so every call makes progress). Conversations are exported in id order, and the cursor holds only the id to go on from (so it stays short however many conversations remain): the next call re-lists conversations from there, so a conversation that first appears between calls is in this export only if its id sorts after that point. An export of namedconversationsinstead keeps the ids they resolved to, so a resume never resolves the names again. A rejected credential, rate limiting that outlasts three backed-off retries, or a Teams-side failure stops the call early with a cursor, keeping what was written (re-authenticate and resume); a conversation that cannot be read (403/404) is listed inerrorsand skipped. Where the files go depends on the install: a local (stdio) install writes a fresh folder underdest→TEAMS_OUTPUT_DIR→~/Downloads/microsoft-teams-mcpand returns absolute local paths; a hosted install (mcp-host setsMCP_BLOB_BASE_URLandMCP_BLOB_SIGNING_KEY) uploads one zip per call and returns a signed download link that expires after an hour (url_expires_at). Withattachments: trueit also saves each message's inline images and shared files (SharePoint, up to 25 MB each; four downloads at a time) underattachments/<8-char conversation hash>/<message id>-<name>in the same folder or zip, and each message's record lists them by that relative path (Markdown transcripts link them); an attachment that cannot be fetched — a file your account cannot open, a SharePoint host with no token yet, one too large — is listed withskippedand the reason instead, and never fails the export (counts.attachments/counts.attachments_skipped). Never marks anything read. -
teams_healthcheck— resolves the token the way every tool does (capturing one on a cold start) and probes the chat service with one conversations listing. Reports where the token came from and when it expires (bothnullwhen no token is held, e.g. after a failed capture) and how many conversations the listing returned — never the token.
Write (each asks you to confirm first — see Writes):
-
teams_send_message— post as you to any chat or channel, by id or name.textis plain text by default: it is escaped, newlines are kept, and:key:shortcodes become emoji: Teams' own animated ones forlike,heart,laugh,surprised,sad,angry,smile,wink,cool,hearteyes,fireandparty, and the plain character for about 50 common names (:rocket:,:tada:,:thumbsup:,:eyes:,:white_check_mark:, …) and for unicode-style keys (:1f680_rocket:). Teams has no animated image for many code points, so only the twelve names it is known to have get one.format: "markdown"reads it as Markdown instead:**bold**,*italic*,~~strike~~,`inline code`, fenced code blocks, bulleted (-) and numbered (1.) lists nested by indenting,[link text](https://…)(http, https and mailto only), blank-line paragraphs and line breaks; emoji and mentions work as in plain text. Anything else — raw HTML, headings, quotes, images — is escaped and shown as written. The preview'swillSend.contentis the exact HTML that will be posted. Nothing is quoted by default.reply_toposts into a channel post's thread — it must be a root post in that channel, not a reply. In a chat,reply_toandquoteare the same thing: both quote-reply that message.quotequote-replies a message anywhere. Pass either only when the user asked for a reply or a quote; when that is ambiguous (a message's timestamp, or "reply" in a chat), ask. The preview says what is quoted in plain words —quoting: "Bea Example", 2026-11-12 12:31 UTC: "…"(the time inTEAMS_TIME_ZONEwhen set) orquoting: "none"— and its summary ends, quoting "<sender>"or, no quote. Settingreply_toadds awarningsaying what it does (a thread reply in a channel, a quote in a chat). The fenced details of the quoted message are inquoted_message.mentions@-mentions people by MRI or by a display name matched among the conversation's recent senders (an ambiguous name is refused with the candidates). The result says whether the post was found again by reading the conversation back (verified). -
teams_edit_message— replace the text of one of your own messages:conversation,message_id(one you sent — someone else's, a deleted message or a system event is refused before any preview),text, andformat/mentionsas forteams_send_message. A quote-reply keeps its quote. The preview shows the current text, the new text and the exact HTML that will replace it; text that renders exactly as the message already does, mentioning the same people, changes nothing (status: "unchanged"). The message is read back to verify the edit (verified). -
teams_delete_message— delete one of your own messages:conversationandmessage_id(one you sent — someone else's or a system event is refused before any preview). One already deleted is reported asstatus: "already_deleted"with nothing asked or sent. The preview shows the message that will go; the message is read back to verify it is deleted (verified). This MCP cannot bring a deleted message back. -
teams_react— add or (withremove: true) withdraw your reaction on a message: an emoji or name (thumbsup/👍,heart/❤️,laugh/😆,surprised/😮,sad/😢,angry/😡,checkmark/✅) or any raw Teams reaction key (fire,1f389_partypopper). The message is read back to verify the change (verified: nullwhen it is not found again). It writes only that message's reaction — never your recent-reactions list or any other preference. -
teams_mark_read— mark a chat or channel read in Teams, clearing its unread badge: moves your read marker (the conversation'sconsumptionhorizon) up toup_to_message_id, or by default up to the newest message in the conversation. Returns the conversation,previous_horizon_message_id(nullif you never opened it) andnew_horizon_message_id. The marker never moves backwards: when it is already at or past the target, nothing is written (no confirmation is asked) andstatusisalready_read. Only you see your read marker. -
teams_rename_chat— rename a group chat (set its topic):nameis trimmed and must not be empty. Only group chats — 1:1s, meeting chats, channels and the self-chat are refused. Teams posts a notice in the chat, so everyone in it sees the rename. The preview shows the current and new name; renaming to the current name writes nothing and asks nothing. The chat is read back to verify the new name (verified). -
teams_add_chat_members— addpeople(1–20) to a group chat as ordinary members. Each person is one of:- an MRI (
8:orgid:…) or a bare object id — used as given; - an email address — looked up in your organisation's directory and taken only when exactly one person there has that email or UPN (ignoring case);
- a display name — matched, ignoring case and with "Greg King" and "King, Greg" the same, among the other person of each of your 1:1 chats and the chat's recent senders (exact first, then substring; several is refused). Only when no one there matches is the directory searched: the one person whose name is exactly that is taken; failing an exact match, the directory's only suggestion is taken when the name is whole words of it ("Solomon" for "Solomon, Gary", never "Sol"). Anything else — several exact matches, several suggestions, a partial word, nothing — refuses, listing the candidates with their email and MRI.
The directory is Substrate's people suggestions (
POST https://substrate.office.com/search/api/v1/suggestions), reached with the Substrate token search already captures — no new token or bridge scope, but the first lookup may need you to run any search in the Teams search box so the token can be captured. Only people in your organisation are found there (not distribution lists or personal contacts). If the directory cannot be searched (no token yet, a capture timeout,TEAMS_DISABLE_FETCHPROXY), the error says so and what to do; passing the MRI always works. An ambiguous or unknown person refuses the whole call. The preview lists everyone with theirsource(id,chatordirectory) and, for a directory hit, theiremail, so you can see exactly who will be added. People already in the chat are reported asalready_memberand not written (if everyone is, nothing is asked). Teams posts a notice in the chat for each person added. The chat is read back, and each person is reportedadded,already_member,unknown(Teams did not confirm and the read-back does not show them) orfailed(with the error). - an MRI (
-
teams_remove_chat_members— removepeople(1–20, named as forteams_add_chat_members) from a group chat; refuses to remove you (leave the chat in Teams instead). People not in the chat are reported asnot_memberand not written. Teams posts a notice in the chat for each person removed, and they lose access to it. Each person is reportedremoved,not_member,unknownorfailed. -
teams_create_group_chat— start a new group chat withpeople(2–20, named as forteams_add_chat_members, except that with no chat yet a name is matched among the other person of each of your 1:1 chats only, then the directory) and an optionaltopic(trimmed; empty or missing is an untitled chat). You are always included — listing yourself is ignored, as are duplicates — and fewer than two other people is refused: one other person is a 1:1, which this tool does not create. Sent as onePOST /threadswith everyone as a member (Teams makes every group-chat member an Admin). Everyone added sees the new chat at once, and it cannot be deleted afterwards — only left. No message is sent; post the first one withteams_send_message. The preview shows the topic and everyone with theirsourceand, from the directory,email. The chat is read back and the result iscreatedwith the newconversation(id, title, kindgroup), themembersandverified(everyone and the topic shown). It is not idempotent:status: "unknown"means Teams did not confirm the create — checkteams_list_conversationsbefore retrying, since a blind retry makes a second chat.
teams_list_messages and teams_list_unread_messages also write when — and only
when — you pass mark_read: true (see above). Every other tool is
read-only, and nothing marks anything read unless you ask it to.
Writes
A post, an edit, a delete, a reaction, a rename, a membership change and a new group chat are
visible to everyone in the conversation, and marking read clears your unread badges, so each
write asks you first. A
client that can show a confirmation prompt (Claude Code) shows one. On a
client that cannot, the first call changes nothing and returns a
confirmation-required preview — the conversation, the exact text, any
mentions, the quoted message, a message's current and new text, the reaction, the message your read marker
moves to and where it was, a chat's current and new name, or who is added or
removed, with everything other people wrote (titles,
names, quoted text) fenced as untrusted — plus a confirmToken; only a
repeat call with that token writes. With mark_read on
teams_list_messages / teams_list_unread_messages, that first call returns just
the preview (no messages); the repeat call reads and marks. The token is single-use, expires, and is
bound to the exact arguments: change anything between the two calls and it
is refused with a fresh preview.
| variable | default | |
|---|---|---|
MCP_CONFIRM_MODE | ask-user | What a write does on a client that cannot show a confirmation prompt (claude.ai, Claude Desktop). ask-user: two steps, and the model must get your approval in chat before calling again with the token. auto: the same two steps, but the model may use the token after reviewing the preview itself. refuse: writes are refused on such clients. An unrecognised value is treated as refuse. |
MCP_CONFIRM_ELICITATION | on | off never shows a confirmation prompt, so every client gets the MCP_CONFIRM_MODE behaviour. Set it for a client that says it can show prompts but never does. |
MCP_CONFIRM_TTL_SECONDS | 600 | How long a token stays valid. |
MCP_CONFIRM_SECRET | random per process | Signing key; set it only if tokens must survive a server restart. |
On a client without prompts the token is something the model passes back
itself, so keep MCP_CONFIRM_MODE=ask-user and approve each preview in chat
as the real guard on what gets posted.
Try it in your own notes first. The conversation 48:notes (also by the
name notes or self) is your Teams self-chat ("notes to self"): sending,
editing, deleting, reacting and marking read accept it (the group-chat tools do not — it has no
one else in it), teams_list_messages reads it, nobody else sees it, and
it is the safe place to see what a message or reaction will look like. Every
other 48: system conversation (calendar and the like) is refused.
A send that times out is not retried. If Teams does not confirm a post (a
timeout, a dropped connection or a 5xx after the request went out), the
result is status: "unknown" — the message may already be there, so read the
conversation before sending again. A 4xx is a refusal and stays an error. A
reaction Teams does not confirm is settled by reading the message back, and
is status: "unknown" only when that does not show the change; so is an
edit or a delete. A read-marker
move Teams does not confirm is status: "unknown" (marked_read: false with a
mark_read_warning from the read tools); moving it again to the same message
is harmless. Marking read (PUT …/properties?name=consumptionhorizon) was
verified live on 48:notes on 2026-10-12. A rename or membership change
Teams does not confirm is settled the same way, by reading the chat back;
renaming, adding and removing (/threads/{id}/properties?name=topic and
/threads/{id}/members/{mri}) were verified live on 2026-11-09. Markdown
sends, editing a message (PUT …/messages/{id}) and deleting one
(DELETE …/messages/{id}) were verified live on 48:notes on 2026-11-11;
@mentions (in a Markdown send, and kept through an edit) were verified in a
group chat the same day; editing and deleting in a channel have not been
tried yet. Threaded channel replies,
removing a reaction and the ✅ reaction's key follow Teams' documented or
inferred shapes but have not been verified against a live tenant yet.
No Calendar tool here. Teams Web's Calendar is Outlook's data, rendered
inside an embedded outlook.office.com iframe — not the chat service's. Use
office-outlook-mcp's
outlook_list_events / outlook_get_event / outlook_list_calendars
instead.
Things worth knowing
-
Corporate networks. The server talks to several hosts:
teams.cloud.microsoft(chats), the regional*.asm.skype.comimage store (inline images),*.sharepoint.com(shared files) andsubstrate.office.com(search). If one is unreachable while the others work, the error names that host and the network cause (ENOTFOUND,ECONNREFUSED, a certificate code, …) with a hint, instead of a bare "fetch failed". An inline image the image store cannot be reached for is retried through the Teams gateway (us-prod.asyncgw.teams.microsoft.com), which serves the same bytes and is often allowed where*.asm.skype.comis not. Two settings fix most other cases, because Node reads neither the system keychain nor the system proxy:- TLS inspection:
NODE_EXTRA_CA_CERTS=/path/to/corporate-root-ca.pem. - A required proxy:
HTTPS_PROXY=<proxy URL>together withNODE_USE_ENV_PROXY=1(Node 22.21+ / 24.5+).
- TLS inspection:
-
Cursors: only what is new. A message id is its arrival time in epoch milliseconds, so "newer than the last message I saw" is a numeric comparison and a cursor is just that message's id —
next_cursoris a plain id string, passed back unchanged asafter_message_id(teams_list_messages) or insidecursors(teams_list_unread_messages). The server keeps no state and no cache: every call reads Teams live, andfetched_at(ISO 8601, UTC) says when. There is no refresh flag because there is nothing to refresh. A cursor never moves the read marker; withlimit/per_conversation_limitthe newest messages are kept andnext_cursorjumps to the newest, so checktruncatedbefore assuming nothing was skipped. Two cursor kinds:teams_list_changed_conversationshas its own time cursor (an opaque token, or an ISO 8601 time) that goes back only toteams_list_changed_conversations; it is not a message id. Each of its conversation rows carries the message-id cursor to hand on,after_message_id, forteams_list_messages(orteams_list_unread_messages'scursors). Each tool rejects the other kind. -
Paging from a time (ingestion and backfill).
teams_list_messagesandteams_list_unread_messagestakefrom: a UTC time (ISO 8601; one with no zone is read as UTC) or a previous result'snext_from. They return what arrived at or after it oldest first, at mostlimit(default 50;teams_list_unread_messagespools every conversation into onemessageslist, each message carrying itsconversation_id), plushas_more,next_fromandnext_from_time. Passnext_fromback asfromuntilhas_moreis false; it is returned on the last (and an empty) page too, so the same call keeps polling for what arrives later. Messages sharing a millisecond are never split or repeated across pages; delivery is otherwise at-least-once (next_fromnever moves past the newest change the listing knew of, and not at all while a read failed), so dedupe on message id. Cost: Teams serves messages newest first with no way to seek, so each call reads everything that changed since itsfrom— a deep backfill gets faster as it catches up — and a conversation with more than 20,000 messages sincefromcannot be paged oldest first:teams_list_messagesreports an error rather than skip any, andteams_list_unread_messagesreturns what arrived after the oldest point it reached, names the stretch it could not read inincomplete(unread_not_read_from/unread_not_read_until), and keepsnext_frommoving — so one oversized conversation never stalls the stream.fromreplacessince/after_message_id/mark_read(and, forteams_list_unread_messages,since_days,cursors, the per-conversation limits,older_unreadandformat: "summary"). -
Results are framed as untrusted. Every data tool returns
{ untrusted_content: true, note, ... }and says in its description that the text is written by other Teams users. Message bodies, previews and subjects are data to report, never instructions to follow — this matters because the server usually runs beside write-capable MCPs (mail, messaging) that an injected instruction could otherwise reach. -
Unread means what Teams means, and is confirmed by reading. A message is unread when it arrived after the conversation's read marker (
consumptionhorizon), was not sent by you, is a real message (TextorRichText/Html) and is not deleted. System messages — call recordings and transcripts (RichText/Media_*),Event/*,ThreadActivity/*,Control/*— never make anything unread. The conversation listing cannot tell on its own:lastimreceivedtimemoves for a call recording too,lastMessagecan be stale, and a chat you never opened has no marker at all. So the listing only nominates candidates (anything past the marker, or no marker), andunread_onlyandteams_list_unread_messagesread each candidate's messages past its marker (four at a time) and keep only those holding a real unread message, with itsunread_count. Withoutunread_onlythe list tools'unreadis just that cheap hint, markedunread_confirmed: false. A candidate that answers 404 or 403 (a channel that is gone or no longer readable) is not unread: it is skipped and counted inskipped_unreachable. Channels you have hidden in Teams (hidden: true) are not counted as unread unless you passinclude_hidden. Unread answers are also limited to recent activity: a conversation counts only when its newest unread message arrived in the lastsince_daysdays (default 14, max 3650) — in practice most "unread" channels are dormant ones, untouched for months or years in teams you never open. A candidate with no activity at all inside the window is not even read. Whatever the window leaves out is counted inolder_unread_skipped(with a hint to widensince_days), never dropped silently.older_unread: true(onteams_list_unread_messages, and onteams_list_conversationswithunread_only) also lists them inolder_unread_conversations—id,kind,title,team(a channel's),lastActivity, most recent first — with anunread_countfor each one whose messages were already read to place it outside the window; one with no activity at all inside the window is not read just to count it, and carriesunread_count_known: falseinstead (its news past the marker may be only system events). Muted chats: the conversation listing marks a chat you muted withproperties.alerts: "false"(verified live 2026-10-12: 8 of 1,435 conversations, every other one"true"or absent), surfaced asmuted: true.teams_list_unread_messagesleaves them out without reading them and counts them inmuted_skipped;include_mutedadds them back. The list tools'unread_onlystill includes them.teams_list_messagesis not windowed: it takes an explicitsinceandlimit. This server only reads the marker; it never moves it. -
The token only goes to Teams. The chat-service token is sent to the chat-service base and, for inline images, only to https hosts under
.asm.skype.com(Teams' own image store) — an image link pointing anywhere else is refused. Shared-file download uses a separate SharePoint token (below), sent only to the file's own*.sharepoint.comhost; the pre-signed download link it returns is fetched with no token at all. Search uses a third, Substrate token, sent only tohttps://substrate.office.com/searchservice/api/v2/queryand, to look a person up in the directory for the group-chat member tools,https://substrate.office.com/search/api/v1/suggestions. -
Shared files download from SharePoint. They live in SharePoint, which does not accept the chat-service token, so
teams_get_attachmentcaptures a separate SharePoint token and fetches the file through SharePoint's v2.1 shares API — see below. -
Scope grows quietly. If you see a
not in declared seterror, the extension's approved scope is behind the server's declared one — approvemicrosoft-teams-mcpin the ContextMint Bridge popup.
Upgrading from 0.4
0.5 only adds arguments and fields, except for these changes in behaviour:
- Ambiguous names in
teams_list_messages: a name that matches several conversations used to fail with an error listing the candidates. It now succeeds with{ ambiguous: true, query, match_count, candidates, hint }and no messages. Check forambiguousbefore readingmessagesorthreads. Send, react and attachment still fail on an ambiguous name. - Muted chats in
teams_list_unread_messages: chats you muted in Teams are left out and counted inmuted_skipped. Passinclude_muted: truefor the old behaviour. - Read tools are no longer annotated read-only:
teams_list_messagesandteams_list_unread_messagestake an opt-inmark_readflag, so they are published withreadOnly: false. A client that auto-approves only read-only tools may now ask before each read. Withoutmark_readthey still never write.
Upgrading from 0.3
0.4 moves every tool onto the Teams chat-service API, and several outputs changed shape. If you script against them:
- Removed tools:
teams_get_open_chat_messagesandteams_get_open_channel_posts— useteams_list_messages, which reads any chat or channel by id or name without it being open. teams_list_activity: rows now come from the Activity feed itself.titleandlocationare replaced bytype/subtype/kind,from,where: { conversation_id, title, type },message_id,reply_chain_idandpreview;timeis ISO 8601 (it was prose like "1:19 PM");readis new;empty_hintis gone (the feed no longer depends on the Activity view).teams_get_attachment: a shared file now returns its contents (an image as image content, anything else as an MCP embedded resource carrying the bytes), not just metadata and a link — see SharePoint file download. A file that cannot be fetched still returns the old metadata-and-link shape, so a script that only reads the metadata keeps working.teams_healthcheck: the top-levelbridgesection is gone — the bridge is only used to capture the token now. Token health is undercredentialandchat_service; a newsharepointnote describes the on-demand, short-lived SharePoint token (the healthcheck does not require one).- Chat titles: untitled chats are titled
(1:1 chat)(or the other person's name),Group chat: A; B; C +N(with aparticipantsarray), or(untitled group chat)— no longer the last sender's name or(untitled chat). Plainteams_list_conversations/teams_list_channelslistings putfavoriteand thenpinnedconversations first. - Unread: confirmed by reading messages after your read point (call
recordings and other system events never count), hidden channels are left
out unless
include_hidden, andsince_daysdefaults to 14 witholder_unread_skippedreporting the rest.
Development
npm install
npm run build
npm test # typecheck + suite
npm run test:coverage # CI's gate
License
MIT