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
访问服务器

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.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选