teams-assistant-mcp

teams-assistant-mcp

Lets a Claude Code agent read and post in a fixed allowlist of Microsoft Teams group chats using an ordinary user account, with tools for messaging, replying, editing, deleting, and polling.

Category
Visit Server

README

teams-assistant-mcp

An MCP server that lets a Claude Code agent read and post in a fixed set of Microsoft Teams group chats. It signs in as an ordinary user account with a username and password, so from Teams' point of view there is a person in the chat, not a bot.

It is not built to scale. One server per project, one account, one short list of chats. That was the point: get a working two-way channel between an agent and the people in a pilot without waiting on an app registration, admin consent, or a Teams app manifest.

This repo ships the server on its own. Consuming projects install the compiled output; none of them carry this source tree.

Getting started

SETUP.md is the zero-to-working manual: requesting the account, .env, finding chat ids, the allowlist, wiring the server into a Claude Code agent, and the tool reference. The rest of this README is background on how and why the thing works.

How it works

Claude Code  --stdio-->  teams-assistant-mcp  --HTTPS-->  Microsoft Graph  -->  Teams
                                |
                           allowlist  (refuses any chat id not on it)
                                |
                          TokenProvider  (ROPC today, swappable)

The server speaks MCP over stdio and exposes ten tools:

Tool What it does
list_chats The allowlisted chats, annotated with whether the account can actually see each one
read_chat_messages Messages from one chat, oldest first, with a watermark for the next call
send_chat_message Posts plain text to a chat whose allowlist entry has canPost: true
send_chat_image Posts a PNG/JPEG that renders inline, from a local path or base64 bytes
send_chat_file Uploads a local file to the account's OneDrive (TEAMS_MCP_UPLOAD_DIR, default ai-test) and shares it into the chat
reply_chat_message Posts a quoted reply to a specific message — chats have no reply threads, so this is the quote card the Teams UI produces
edit_chat_message Replaces the text of a message this account sent (Graph refuses anyone else's)
delete_chat_message Soft-deletes a message this account sent — the reversible kind; no hard delete offered
get_chat_attachment Downloads one attachment to a local file and returns the path — shared files, and pasted images which appear as inline-image-N
poll_chats Reads every allowlisted chat in one call, carrying a watermark per chat

Besides the tools, the server runs a background inbox poller — see below.

All posting tools pass the same allowlist canPost gate. Editing and deleting only work on the account's own messages — that is Graph's rule for delegated calls, and the server surfaces Graph's refusal verbatim rather than pre-checking it.

Watermarks are exclusive ISO timestamps. Pass back what the previous call returned and you get only what arrived since. When nothing is new no watermark comes back, so the caller keeps the one it already had.

Graph returns message bodies as HTML even for plain typed text, so messages.ts flattens that to text. Mentions keep their visible name and lose the markup. This is not a general HTML renderer and does not try to be.

The background inbox

Reading chats through tools means the agent has to remember to poll, and every session that wanted to be woken by incoming messages ended up hand-rolling its own polling daemon. So the server does it: alongside the MCP transport it polls every allowlisted chat (30s interval, in-process, no child processes) and appends each new message as one JSON line to a stable, session-independent file:

~/.teams-assistant/inbox.jsonl        the inbox (override with TEAMS_INBOX_PATH)
~/.teams-assistant/inbox-state.json   watermark sidecar, lives next to the inbox

One line per message: {"chat","id","from","at","text","attachments"}text capped at 2000 characters, attachments a count. Messages posted by the signed-in account itself are skipped (resolved via /me, so the assistant's own posts never echo back as inbox events), as are deleted stubs and empty system events.

The sidecar remembers the delivered watermark and newest message id per chat, so a server restart never re-emits old messages. Losing the sidecar is safe: the next poll re-reads the recent window once and moves on.

A failing poll appends {"error": "...", "at": "..."} to the same file. That line is the difference between "the chats are quiet" and "auth is dead" — a watcher must never have to guess which silence it is looking at. An identical failure repeating poll after poll is written once, not once per poll. When everything fails (auth death, network gone) the interval backs off, doubling to a 10-minute cap and snapping back on recovery; a single failing chat — usually one the account has not been added to yet — is surfaced but does not slow the healthy chats down. The poller never crashes the server; every poll is fully caught.

The recommended consumption pattern: arm a file watcher (Claude Code's Monitor, tail -F, inotify) on the inbox path at session start and react per line. Do not poll the tools for new messages any more.

Two knobs: TEAMS_INBOX_PATH moves the inbox (the sidecar follows it), and TEAMS_INBOX_DISABLED=1 switches the poller off entirely for consumers that only post.

Auth, and the fact that ROPC is temporary

Sign-in uses the OAuth password grant (ROPC) against Entra ID, with one of Microsoft's own first-party client ids. That is why no app registration and no admin consent are needed: the tenant already trusts Microsoft's clients. The code defaults to the Teams client id (the TEAMS_FIRST_PARTY_CLIENT_ID constant in src/config.ts), but on some tenants the token that id returns carries no chat scopes at all, so env.example tells you to set the Office client id instead — the one proven to hand back a fully scoped token. Both ids are published by Microsoft; SETUP.md has the details and where to look them up.

Two things about this grant. It cannot be used by an account with MFA or an MFA-requiring Conditional Access policy, which is why the account has to be excluded from both. And Microsoft has ROPC on the way out, so at some point it will stop working.

So authentication sits behind one interface, TokenProvider in src/auth/token-provider.ts, with exactly one method. RopcTokenProvider is one implementation of it. The Graph client, the tools and the allowlist know nothing about how the token was obtained. Replacing ROPC with device code means writing a second implementation and changing the one line in src/index.ts that constructs it. Device code is the obvious successor: same client id, same scopes, one interactive sign-in per refresh-token lifetime instead of a password on every cold start.

The access token and its refresh token are cached on disk between restarts. That file holds a live credential, so it is written mode 0600 and its name is in .gitignore.

The allowlist

teams-mcp.config.json lists the chats the server may touch:

{
  "assistantDisplayName": "Assistant (AI)",
  "allowedChats": [
    { "id": "19:....@thread.v2", "label": "Pilot chat", "canPost": true },
    { "id": "19:....@thread.v2", "label": "Leadership", "canPost": false }
  ]
}

Every chat id entering a Graph call goes through the allowlist first. A chat that is not listed is refused for both reading and posting, and the refusal happens before any network call. canPost defaults to false when omitted, so a chat added carelessly gets read access only.

An empty list is a startup error rather than an open door, because an empty list is almost always a misconfigured file.

This matters because the account's token is broad. It carries every delegated Teams scope the first-party client id grants, which means the identity itself can reach every chat the account is a member of. The allowlist is the only thing narrowing that down, so treat editing it as a governance action, not a config tweak.

list_chats shows allowlisted chats only. It will not tell you about other chats the account is in, which also means it is no help for finding a chat id in the first place. That job belongs to npm run discover-chats, which a human runs and which prints every chat the account can see (SETUP.md step 4 lists the other ways to find an id).

The account: one identity per consuming project

Each project that installs this server gets its own dedicated cloud-only account. Not a person's identity, not an admin account, and not shared with another project. Sharing one account across projects would mean one allowlist edit in project A silently widening what project B's agent can reach, and one leaked password burning every project at once.

The account needs:

  • an Office 365 licence with the Teams service plan enabled (without it every /chats call fails with 403 "Failed to get license information for the user"; auth succeeds, the licence is the blocker, and no scope or permission change fixes it)
  • MFA off, and exclusion from any Conditional Access policy that requires MFA
  • a display name that says it is an AI, for example Assistant (AI)
  • membership in each allowlisted chat, added by a human the normal way

The display name is not cosmetic. Anyone in the chat should be able to see at a glance that the thing writing is an assistant. npm run probe compares the account's real displayName against TEAMS_MCP_DISPLAY_NAME (default Assistant (AI)) and warns on a mismatch.

Configuration

Everything sensitive comes from the environment, per installation. Nothing in this repo carries a credential, an account name, or a tenant id, and nothing ever should.

Variable Required Notes
TEAMS_MCP_TENANT_ID yes Entra ID tenant of the assistant account
TEAMS_MCP_USERNAME yes Assistant account UPN
TEAMS_MCP_PASSWORD yes
TEAMS_MCP_CONFIG yes Path to the allowlist config. The server will not start without one
TEAMS_MCP_CLIENT_ID no Defaults to the first-party Teams client id; see SETUP.md for why you usually want the Office one
TEAMS_MCP_TOKEN_CACHE no Defaults to .token-cache.json in the working directory
TEAMS_MCP_DOWNLOAD_DIR no Where get_chat_attachment writes. Defaults to a temp directory
TEAMS_MCP_UPLOAD_DIR no OneDrive folder where send_chat_file parks uploads. Defaults to ai-test
TEAMS_MCP_DISPLAY_NAME no Overrides the expected display name for the probe
TEAMS_INBOX_PATH no Where the background inbox JSONL lands. Defaults to ~/.teams-assistant/inbox.jsonl
TEAMS_INBOX_DISABLED no Set to 1 to not run the background inbox poller at all

env.example lists the same variables with comments, and SETUP.md walks through filling them in (including why the password wants single quotes and the paths want to be absolute). Put real values in a gitignored .env, never in this repo and never in the consuming repo.

Building and testing

npm ci
npm run lint       # oxlint
npm run test:run   # vitest
npm run build      # tsc -> dist/
npm pack           # versioned tarball of dist/ plus the example config

CI runs the same four gates on every push to main and publishes the npm pack tarball as a build artifact. Releases are the tarballs of tagged versions; there is no npm registry publish yet, which is the natural next step once a second consumer appears.

Tested without a licensed account: allowlist enforcement through a real MCP client, the token provider (caching, expiry skew, refresh-then-password fallback, concurrent callers, no password in error text), config and allowlist parsing, the 0600 token cache, HTML-to-text, Graph message mapping, watermark diffing, and the Graph client's handling of the licence 403. The end-to-end cases that need Graph to return an actual chat are listed as pending in src/graph/live.awaiting-licence.test.ts. They are deliberately not written against a mocked Graph, since a mock cannot answer the one question they exist for: whether Graph accepts these calls from a real assistant identity.

Installing into a project

SETUP.md covers the whole path, from requesting the account to a passing smoke check. The short version: build this repo, fill in .env and teams-mcp.config.json here, and run npm run install-local — it prints the exact claude mcp add command (and .mcp.json equivalent) for your checkout.

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured