npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@chrischall/microsoft-teams-mcp

v0.7.0

Published

Microsoft Teams MCP server for Claude — developed and maintained by AI (Claude Code)

Readme

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_react and marking read — go through the same chat-service API and change nothing until you confirm (see Writes).

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, both one-time and additive — they do not change how the chat-service token is captured:

  • A one-time re-pair. The SharePoint path declares a second trust scope, sharepoint.com, so the ContextMint Bridge grant has to cover it. If you paired microsoft-teams-mcp before this feature, revoke and re-pair it in the bridge popup once to add the SharePoint scope (a not in declared set error on a file download is this, nothing more). The chat-service capture is untouched — it still trusts only teams.cloud.microsoft.
  • Open the file once per SharePoint host. The SharePoint token is captured from the browser's own request to the file's SharePoint host, is short-lived (~53 minutes), and is scoped to one host family. So the first file from a given host in a session may need you to open it once in a signed-in Teams tab (that browser request is what the token is read from); after that the token is cached and refreshed until it expires. A file your account cannot open, or on a SharePoint host your token does not cover, comes back as metadata and its open link rather than failing.

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.

  • A one-time re-pair. The search path declares its own trust scope, substrate.office.com, which the ContextMint Bridge grant has to cover. If you paired microsoft-teams-mcp before this feature, revoke and re-pair it in the bridge popup once. The chat-service and SharePoint captures are untouched.

  • 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 a fallback_reason), and does not wait on another capture for 10 minutes. mode: "substrate" fails instead; mode: "scan" never touches Substrate.

  • It does not re-capture in a loop. If a freshly captured token is still refused (401), the same 10-minute cooldown applies, so auto goes 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, logicalId and a scenario are required; the sort is { Field: "Time", SortDirection: "Desc" }), that no header beyond the bearer is needed, the response fields (conversation in ClientThreadId, a channel root in ClientConversationId's ;messageid=, the message id in InternetMessageId, sender name and SMTP address but no MRI, a plain-text Preview of at most ~255 characters, an approximate Total), paging by from/size (up to 200), and that conversation and since can be sent server-side as ClientThreadId:"…" and an unquoted sent>=…. from stays client-side: Substrate's own from: matches word prefixes, not "display name contains". A redacted response is in tests/fixtures/substrate-query-response.json.

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_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 tokens (one per host, used to download shared files) are 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 writes exports 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 your 48:notes self-chat as kind self when 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 (with unread_only; default 14), older_unread (with unread_only; lists what since_days left 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), sort and limit (default 50, max 500; keeps the head of the sort order). sort is activity (default — favorites, pinned, then most recent; with unread_only, most recent), activity_only (most recent first, ignoring favorites and pinned) or title (alphabetical, case-insensitive — this titles every untitled chat in the listing before limit applies, 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 +N for more, with every recent sender in participants in 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's where, 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 one participants entry — read from their recent messages when you sent the last one, (1:1 chat) if they wrote nothing recently. query matches such chats by their participants, never by the made-up Group chat: or (1:1 chat) titles, so group or chat does not hit every untitled chat. With a query, every row carries match_reason — the first of id (an exact id), title, team, participant (an untitled chat's participant, including a 1:1's other person) or message that applies. message means 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 (needs query) 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 as matched_message (id, time, sender, text cut to a window around the match). It is one read per chat, so narrow a large listing with since / until or unread_only; chats whose read failed are counted in messages_not_searched. Conversations the chat service marks favorite or pinned carry favorite: true / pinned: true and lead the plain listing (favorites, then pinned); unread_only keeps 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 carries muted: true.

  • teams_list_channels — every channel you are in, by team then title, with the same fields — favorite and pinned channels first, flagged as in teams_list_conversations. Channels you have hidden in Teams are listed after the rest with hidden: true, and left out of unread_only unless include_hidden is set. unread_only, include_hidden, since_days (with unread_only; default 14), query (channel or team name, or the last-message preview; each match carries match_reason, as in teams_list_conversations) and limit (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 channel comes back as threads (root post + replies). A thread whose root post is older than the window (a reply came in after since, limit or after_message_id cut the read) still shows its root: it is fetched by id and marked outside_window: true — context only, not counted in unread_count or message_count and never the next_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) and tz (an IANA time zone such as America/New_York, default TEAMS_TIME_ZONE), which adds time_local — the same instant in that zone, ISO 8601 with its offset — next to each message's time. Message text is never truncated, and a table's cells are separated by |, one row per line. The resolved conversation carries match_reason (id, title, team or participant); a name never resolves by what was said in a chat, since teams_send_message and teams_react name 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 with id, title, kind, team (channels), lastActivity and match_reason — and no messages, so you can read again by one candidate's id (teams_send_message and teams_react still 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 in teams_send_message / teams_react, titles every untitled chat first). 48:notes — or the name notes / self — is your notes self-chat. after_message_id returns only messages newer than that id (the read stops at the first message at or before it, alongside unread_only, since and limit); every result carries next_cursor — the newest returned message's id, or the after_message_id you passed when nothing is new — so passing it back as after_message_id reads only what arrived since. after_message_id takes message ids only: to read what teams_list_changed_conversations counted, pass that row's after_message_id, not its next_cursor. See Cursors. from pages oldest first from a UTC time instead — see Paging from a time. format sets how much comes back: full (default, everything above), compact (each message only id, time, time_local, sender, text, plus subject when set — no reactions, attachments or edited/deleted flags — and the conversation only id, kind, title, team) or summary (no messages: message_count, newest_time and, for a channel, one { root_id, subject, reply_count, newest_time } per thread, with root_outside_window: true or root_missing: true for a root from outside the window). conversation may 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 in conversations as the same {ambiguous: true, query, match_count, candidates, hint} object a single read returns, one that cannot be resolved or read is listed in errors with its reason (and, once it resolved, its id, title, kind, team and participants), and the rest still come back. A single string returns exactly the shape above. By default it does not mark anything read; mark_read: true also moves your read marker up to the newest message returned (never backwards; outside_window roots do not count) and reports marked_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_read with format: "summary" is refused (compact is fine), and when limit or since cut off unread messages older than those returned, that conversation's marker stays put and it reports marked_read: { skipped: "truncated" | "since", hint } (a limit that cut only messages you had already read still marks). An empty read, or an after_message_id with 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, with from, one oldest-first list across them all — see Paging from a time). Filter by kinds (chat, channel, meeting, oneOnOne) or query (title, channel or team name, an untitled chat's participant name, or the last-message preview — each conversation then carries match_reason, as in teams_list_conversations); per_conversation_limit (default 50) and max_conversations (default 20 returned with their messages — the rest are named, with their unread_count, in more_unread_conversations). Hidden channels are left out unless include_hidden; chats you muted in Teams (muted: true) are left out unread and counted in muted_skipped unless include_muted. Only conversations with unread messages from the last since_days (default 14, max 3650) are returned; older ones are counted in older_unread_skipped, and older_unread: true also lists them (see below). An untitled group chat or 1:1 is titled after its participants, as in teams_list_conversations — in conversations, in more_unread_conversations and in errors. By default it does not mark anything read; mark_read: true also moves the read marker of each conversation returned with its messages (not more_unread_conversations) up to its newest unread message returned, and reports marked_read per conversation (with mark_read_warning when that move failed). One confirmation covers every move. It never marks a message you were not given: mark_read with format: "summary" is refused (compact is fine), a conversation per_conversation_limit cut keeps its marker and reports marked_read: { skipped: "truncated", hint }, and outside_window roots never count. tz (default TEAMS_TIME_ZONE) adds each message's time_local, as in teams_list_messages. A channel's unread replies come grouped under their root post; a root you had already read is fetched and marked outside_window: true (not unread, not counted), up to 25 per call as in teams_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 carries next_cursor, and the result carries next_cursors: the cursors you passed, updated with every returned conversation's next_cursor — pass it back as cursors next time. format (full, compact or summary) trims each conversation as in teams_list_messages; every format keeps the conversation id, unread_count and next_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_time and, 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, and after_message_id — the value to pass to teams_list_messages as after_message_id to read exactly the messages counted (a message id is its arrival ms, so this is the cursor time written as an id). Without cursor it looks back 24 hours. next_cursor is an opaque token (base64url of {"t": <epoch ms>}; cursor also 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). kinds and include_hidden filter as in teams_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 in more_conversations (each with its after_message_id too). Then read what changed with teams_list_messages, handing on the row's after_message_id — not next_cursor, which is a time token that only teams_list_changed_conversations accepts (teams_list_messages rejects it, and teams_list_changed_conversations rejects a message id):

    // 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 (its lastMessage stays 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 with include_hidden every 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 and replyTo (a channel reply's root) — read it in context with teams_list_messages. conversation narrows to one chat or channel (id or name, as in teams_list_messages), from to senders whose display name contains it, since (ISO 8601; default the last 30 days) sets how far back to look, limit caps the hits (default 25, max 200; total_hits and truncated say when there were more). source says 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, so deploy also finds "deployed"; "quoted phrases"; OR), across all of your conversations rather than the most recent ones. conversation and since are sent to Substrate itself; from is 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 marked excerpt: true, and its hits carry no fromMe (Substrate does not say who you are on a hit). substrate reports 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_reason says why), or with mode: "scan". Every word of query must appear (any order, ignoring case; "quoted words" match as a phrase); the max_conversations (default 100, max 500) most recently active conversations with activity since the cutoff are read back to it, four at a time, and scan reports 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 with conversation or since when 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. Shared-file download uses a separate, short-lived SharePoint token captured per SharePoint host, so the first file from a given host may need opening once in Teams to mint 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, plus participants for an untitled chat), titled as teams_list_conversations titles it.

  • 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, call or other) with Teams' raw type/subtype, whether you have seen it, who did it, where (conversation_id, title, type, and team / participants when known), the message_id and a plain-text preview. The feed carries no name for an untitled group chat or 1:1, so title is taken from the conversations listing and titled as teams_list_conversations titles it (one listing read plus cached title reads for the rows returned; if that fails the feed's own topic stands in) — pass where.conversation_id to teams_list_messages to read it in context. Filter with unread_only, kinds and since_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_read marks chats and channels read, not the Activity feed).

  • 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), plus manifest.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's id, kind, title, team (channels) and participants (chats: the people seen writing, other than you) — or format: "markdown" for readable transcripts, channels threaded under their root posts. 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 at max_items messages (default 5000, max 50000) or budget_seconds (default 90, max 600); with has_more: true, pass the cursor back (alone, or with max_items / budget_seconds) to continue the SAME export — on disk into the same folder, a conversation split across calls continuing in <name>.part-0002.jsonl beside its first file (and manifest.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; the cursor normally lists the ones still to do, but when that list is too long for a cursor it holds only the id to go on from, and the next call re-lists conversations from there — so a conversation that first appears between calls with an id before that point is not in this export. 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 in errors and skipped. Where the files go depends on the install: a local (stdio) install writes a fresh folder under dest → TEAMS_OUTPUT_DIR → ~/Downloads/microsoft-teams-mcp and returns absolute local paths; a hosted install (mcp-host sets MCP_BLOB_BASE_URL and MCP_BLOB_SIGNING_KEY) uploads one zip per call and returns a signed download link that expires after an hour (url_expires_at). With attachments: true it also saves each message's inline images and shared files (SharePoint, up to 25 MB each; four downloads at a time) under attachments/<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 with skipped and 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 (both null when 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. text is plain text: it is escaped, newlines are kept, and :key: shortcodes (:smile:, :1f389_partypopper:) become Teams emoji. reply_to posts into a channel post's thread — it must be a root post in that channel, not a reply (in a chat it quote-replies that message); quote quote-replies a message anywhere; 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_react — add or (with remove: 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: null when 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's consumptionhorizon) up to up_to_message_id, or by default up to the newest message in the conversation. Returns the conversation, previous_horizon_message_id (null if you never opened it) and new_horizon_message_id. The marker never moves backwards: when it is already at or past the target, nothing is written (no confirmation is asked) and status is already_read. Only you see your read marker.

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 and a reaction 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, the reaction, or the message your read marker moves to and where it was, 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"): every write accepts 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. 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. Threaded channel replies, @mentions, 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.com image store (inline images), *.sharepoint.com (shared files) and substrate.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.com is 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 with NODE_USE_ENV_PROXY=1 (Node 22.21+ / 24.5+).
  • 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_cursor is a plain id string, passed back unchanged as after_message_id (teams_list_messages) or inside cursors (teams_list_unread_messages). The server keeps no state and no cache: every call reads Teams live, and fetched_at (ISO 8601, UTC) says when. There is no refresh flag because there is nothing to refresh. A cursor never moves the read marker; with limit / per_conversation_limit the newest messages are kept and next_cursor jumps to the newest, so check truncated before assuming nothing was skipped. Two cursor kinds: teams_list_changed_conversations has its own time cursor (an opaque token, or an ISO 8601 time) that goes back only to teams_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, for teams_list_messages (or teams_list_unread_messages's cursors). Each tool rejects the other kind.

  • Paging from a time (ingestion and backfill). teams_list_messages and teams_list_unread_messages take from: a UTC time (ISO 8601; one with no zone is read as UTC) or a previous result's next_from. They return what arrived at or after it oldest first, at most limit (default 50; teams_list_unread_messages pools every conversation into one messages list, each message carrying its conversation_id), plus has_more, next_from and next_from_time. Pass next_from back as from until has_more is 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_from never 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 its from — a deep backfill gets faster as it catches up — and a conversation with more than 20,000 messages since from cannot be paged oldest first: teams_list_messages reports an error rather than skip any, and teams_list_unread_messages returns what arrived after the oldest point it reached, names the stretch it could not read in incomplete (unread_not_read_from / unread_not_read_until), and keeps next_from moving — so one oversized conversation never stalls the stream. from replaces since / after_message_id / mark_read (and, for teams_list_unread_messages, since_days, cursors, the per-conversation limits, older_unread and format: "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 (Text or RichText/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: lastimreceivedtime moves for a call recording too, lastMessage can 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), and unread_only and teams_list_unread_messages read each candidate's messages past its marker (four at a time) and keep only those holding a real unread message, with its unread_count. Without unread_only the list tools' unread is just that cheap hint, marked unread_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 in skipped_unreachable. Channels you have hidden in Teams (hidden: true) are not counted as unread unless you pass include_hidden. Unread answers are also limited to recent activity: a conversation counts only when its newest unread message arrived in the last since_days days (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 in older_unread_skipped (with a hint to widen since_days), never dropped silently. older_unread: true (on teams_list_unread_messages, and on teams_list_conversations with unread_only) also lists them in older_unread_conversations — id, kind, title, team (a channel's), lastActivity, most recent first — with an unread_count for 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 carries unread_count_known: false instead (its news past the marker may be only system events). Muted chats: the conversation listing marks a chat you muted with properties.alerts: "false" (verified live 2026-10-12: 8 of 1,435 conversations, every other one "true" or absent), surfaced as muted: true. teams_list_unread_messages leaves them out without reading them and counts them in muted_skipped; include_muted adds them back. The list tools' unread_only still includes them. teams_list_messages is not windowed: it takes an explicit since and limit. 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.com host; the pre-signed download link it returns is fetched with no token at all. Search uses a third, Substrate token, sent only to https://substrate.office.com/searchservice/api/v2/query.

  • Shared files download from SharePoint. They live 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 — see below.

  • Scope grows quietly. If you see a not in declared set error, the extension's approved scope is behind the server's declared one — revoke and re-pair microsoft-teams-mcp in 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 for ambiguous before reading messages or threads. 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 in muted_skipped. Pass include_muted: true for the old behaviour.
  • Read tools are no longer annotated read-only: teams_list_messages and teams_list_unread_messages take an opt-in mark_read flag, so they are published with readOnly: false. A client that auto-approves only read-only tools may now ask before each read. Without mark_read they 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_messages and teams_get_open_channel_posts — use teams_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. title and location are replaced by type/subtype/kind, from, where: { conversation_id, title, type }, message_id, reply_chain_id and preview; time is ISO 8601 (it was prose like "1:19 PM"); read is new; empty_hint is 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-level bridge section is gone — the bridge is only used to capture the token now. Token health is under credential and chat_service; a new sharepoint note 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 a participants array), or (untitled group chat) — no longer the last sender's name or (untitled chat). Plain teams_list_conversations / teams_list_channels listings put favorite and then pinned conversations 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, and since_days defaults to 14 with older_unread_skipped reporting the rest.

Development

npm install
npm run build
npm test              # typecheck + suite
npm run test:coverage # CI's gate

License

MIT