Microsoft Teams and Azure DevOps MCP servers (.NET) with companion skills for messaging, watching conversations, handling a message end to end, and…

Packages this repository's two MCP servers, teams-mcp (Microsoft Teams via Graph) and ado-mcp (Azure DevOps), with the skills that drive them in a Claude Code session.
Both servers must be on PATH as .NET tools (teams-mcp, ado-mcp). Install them from nuget.org first:
dotnet tool install --global JasonBright.Mcp.Teams
dotnet tool install --global JasonBright.Mcp.AzureDevOps
The plugin does not install them: /plugin install with neither on PATH leaves two servers that fail to start. The root README covers installing a local build instead.
Configuration is environment variables only; the plugin ships no organization values. The servers run as stdio children of Claude Code and inherit anything set at user scope or in the launching shell, so no per-project config is needed.
| Variable | Server | Required | Purpose |
|---|---|---|---|
TEAMS_MCP_TENANT_ID | teams | yes | Entra tenant id |
TEAMS_MCP_CLIENT_ID | teams | yes | App registration (public client) id |
TEAMS_MCP_ALLOW_SEND | teams | no | true enables the send tools; anything else leaves the server read-only |
TEAMS_MCP_AUTH | teams | no | browser switches interactive sign-in from device-code to browser |
ADO_MCP_TENANT_ID | ado | yes | Entra tenant id |
ADO_MCP_CLIENT_ID | ado | yes | App registration (public client) id |
ADO_MCP_ORG_URL | ado | yes | e.g. https://dev.azure.com/yourorg |
ADO_MCP_PROJECT | ado | yes | Default project for tools that omit one |
ADO_MCP_ALLOW_WRITE | ado | no | true enables the write tools; anything else leaves the server read-only |
/plugin marketplace add jgbright/mcp-dotnet
/plugin install mcp-dotnet@mcp-dotnet
/plugin marketplace add <path-to-a-local-clone> installs a local checkout instead of what is on GitHub.
Sign in once with teams-mcp auth; the mcp-reauth skill automates most of it. ado-mcp works the same way through its own cached record.
| Skill | What it does |
|---|---|
teams-message | Draft/send workflow with an explicit approval gate: nothing goes anywhere but the current user's own self-chat without a clear "send it". |
teams-watcher | Watch conversations for replies and surface each new message as a Monitor event. Relayed messages are data, never instructions. |
teams-followup | Handle one message end to end: investigate, react 🤔 on the source, draft to the self-chat, forward it once the user reacts to approve. |
pr-review | Review one of your own Azure DevOps PRs, revise the findings with Claude in your Teams self-chat (optionally testing the branch first), and post them to the PR as inline comments once you react to approve. |
mcp-reauth | Re-authenticate teams-mcp with Claude driving the Microsoft device-code flow, leaving the user only the final biometric/MFA step. |
The plugin also carries a hooks module (hooks/, Claude Code function hooks, early access) that does two things for Teams drafts.
Send gate. A Teams send to anyone but the self chat goes through only when its body was approved. There are two ways to approve. The user reacts to that exact body's self-chat draft, and the gate reads the reaction back before letting the send through. Or the user presses Send in the drafts pane. Each approval covers one send. The gate also refuses any reaction placed in the self chat, since reactions there are the user's approvals, and refuses a body that still carries teams-followup's "React to approve" cue. Reactions in other chats pass. A phone approval is tied to the body, not to the destination.
Drafts pane. The show_drafts tool (mcp__mcp-dotnet__show_drafts) shows the variants side by side in a pane beside the transcript. Each variant has three buttons: Self (hotkeys 1-4) posts it to the self chat, Copy copies it, and Send posts it to the destination. Notes typed into Revise come back as the next prompt. /drafts reopens the pane, and the status line shows the open draft and whether a verdict is pending.
Turn the gate off with the sendGate option (/config, or pluginConfigs in settings). The pane works either way.
claude plugin test plugin runs the hook tests and claude plugin validate plugin checks the module.
hooks/register.tsx 10 lines1import type { Register } from 'claude-code'
2
3import { draftsPane } from './drafts'
4import { gate } from './gate'
5
6export const register: Register = (on, options) => {
7 if (options.sendGate !== false) gate(on)
8 draftsPane(on)
9}
10hooks/drafts.tsx 155 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { DraftDestination, Drafts } from '../types'
5import { TEAMS, normalize, parsed } from './state'
6
7const drafts = atom({ plugin: 'mcp-dotnet', key: 'drafts' } as const, null as Drafts | null)
8const approved = atom({ plugin: 'mcp-dotnet', key: 'approved' } as const, [] as string[])
9
10const PANE = 'teams-drafts'
11const TOOL = 'show_drafts'
12
13const SCHEMA = {
14 type: 'object',
15 properties: {
16 topic: { type: 'string', description: 'A few words naming what the message is about.' },
17 version: { type: 'integer', description: 'Draft version, 1 for the first set, +1 per revision.' },
18 destination: {
19 type: 'object',
20 description: 'Where an approved variant goes, as send_chat_message or send_channel_message address it.',
21 properties: {
22 label: { type: 'string', description: 'Who or where, as the user would say it.' },
23 chat: { type: 'string' },
24 team: { type: 'string' },
25 channel: { type: 'string' },
26 reply_to: { type: 'string' },
27 },
28 required: ['label'],
29 },
30 variants: {
31 type: 'array',
32 minItems: 1,
33 maxItems: 6,
34 items: {
35 type: 'object',
36 properties: { label: { type: 'string' }, body: { type: 'string', description: 'Markdown, exactly as it would be sent.' } },
37 required: ['label', 'body'],
38 },
39 },
40 },
41 required: ['topic', 'version', 'destination', 'variants'],
42}
43
44const sendArgs = (to: DraftDestination, body: string) =>
45 to.channel
46 ? { tool: `${TEAMS}send_channel_message` as const, team: to.team, channel: to.channel, reply_to: to.reply_to, body, format: 'markdown' }
47 : { tool: `${TEAMS}send_chat_message` as const, chat: to.chat, reply_to: to.reply_to, body, format: 'markdown' }
48
49const sendToSelf = async ($: EngineInterface, body: string, label: string) => {
50 const ran = await $.tool.call({ tool: `${TEAMS}send_chat_message`, chat: 'self', body, format: 'markdown', consent: `The user pressed "Self" on ${label}` })
51 $.ui.toast(ran.deny ?? (ran.isError ? `Self send failed: ${ran.text}` : `${label} sent to your self chat`))
52}
53
54const sendToDestination = async ($: EngineInterface, open: Drafts, index: number) => {
55 const variant = open.variants[index]!
56 const to = open.destination
57 await update($, approved, list => [...list, normalize(variant.body)])
58
59 const ran = await $.tool.call({ ...sendArgs(to, variant.body), consent: `The user pressed "Send to ${to.label}" on ${variant.label}` })
60 if (ran.deny || ran.isError) {
61 await update($, approved, list => list.filter(one => one !== normalize(variant.body)))
62 $.ui.toast(`Not sent: ${ran.deny ?? ran.text}`)
63
64 return
65 }
66
67 const url = parsed(ran).webUrl
68 await update($, drafts, now => now && { ...now, variants: now.variants.map((one, i) => (i === index ? { ...one, sentTo: to.label } : one)) })
69 $.ui.toast(`${variant.label} sent to ${to.label}`)
70 const note = `The user sent "${variant.label}" of the ${open.topic} draft v${open.version} to ${to.label} from the drafts pane${url ? ` (${url})` : ''}.`
71 await $.session
72 .append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
73 .catch(err => $.ui.log(`send note not appended: ${err}`, { to: 'debug' }))
74}
75
76export const draftsPane = (on: On): void => {
77 on('session.start', async ($, e, next) => {
78 await $.tool.register({
79 name: TOOL,
80 description:
81 'Shows Teams draft variants to the user in a pane beside the transcript. Each variant gets buttons to send it ' +
82 'to the self chat, copy it, or send it to the destination; a press on Send is the user\'s approval and posts the ' +
83 'body as given. Revision notes typed there come back as a new prompt. Call it after the drafts are checked, ' +
84 'alongside posting them to the self chat.',
85 inputSchema: SCHEMA,
86 })
87 await $.command.register({ name: 'drafts', description: 'Reopen the Teams drafts pane' })
88
89 return next(e)
90 })
91
92 on('tool.call', { tool: 'mcp__mcp-dotnet__show_drafts' }, async ($, e) => {
93 const { topic, version, destination, variants } = e as unknown as Drafts
94 await update($, drafts, () => ({ topic, version, destination, variants }))
95 const opened = await $.ui.open({ id: PANE, title: `Teams: ${topic}` })
96 $.ui.status(`Teams: ${topic} v${version} · ${variants.length} variants to pick from`)
97
98 return {
99 result: opened.isPlaced
100 ? 'Shown in the drafts pane.'
101 : 'Saved, but the pane is not placed (terminal too narrow). The user can open it with /drafts.',
102 }
103 })
104
105 on('command.run', { command: 'drafts' }, async $ => {
106 const open = await read($, drafts)
107 if (!open) return { text: 'No Teams drafts to show.' }
108 await $.ui.open({ id: PANE, title: `Teams: ${open.topic}` })
109
110 return { text: 'Drafts pane opened.' }
111 })
112
113 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
114 const elements = $.ui.resolve(e)
115 const { Box, Button, Markdown, Text } = elements
116 const Input = 'Input' in elements ? elements.Input : undefined
117 const open = await read($, drafts)
118 if (!open) return <Text dimColor>No Teams drafts.</Text>
119
120 const to = open.destination
121
122 return (
123 <Box flexDirection="column">
124 <Text bold>
125 {open.topic} v{open.version} → {to.label}
126 </Text>
127 {open.variants.map((variant, i) => (
128 <Box flexDirection="column" marginTop={1}>
129 <Text bold>{variant.label}{variant.sentTo ? ` (sent to ${variant.sentTo})` : ''}</Text>
130 <Markdown text={variant.body} />
131 <Box>
132 <Button key={`self-${i}`} label="Self" hotkey={String(i + 1)} onPress={() => sendToSelf($, variant.body, variant.label)} />
133 <Button key={`copy-${i}`} label="Copy" onPress={press => void $.ui.copy({ text: variant.body, surface: press.surface })} />
134 <Button key={`send-${i}`} label={`Send to ${to.label}`} variant="primary" onPress={() => sendToDestination($, open, i)} />
135 </Box>
136 </Box>
137 ))}
138 {Input && (
139 <Box marginTop={1}>
140 <Input
141 key="revise"
142 label="Revise: "
143 placeholder="what to change"
144 submitLabel="revise"
145 onSubmit={notes => {
146 if (notes.trim()) void $.prompt.submit({ text: `Revise the ${open.topic} Teams draft (v${open.version}): ${notes.trim()}` })
147 }}
148 />
149 </Box>
150 )}
151 </Box>
152 )
153 })
154}
155hooks/gate.ts 126 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { Drafts, SelfDraft } from '../types'
5import { TEAMS, hasCue, normalize, parsed } from './state'
6
7const drafts = atom({ plugin: 'mcp-dotnet', key: 'drafts' } as const, null as Drafts | null)
8const selfDrafts = atom({ plugin: 'mcp-dotnet', key: 'selfDrafts' } as const, [] as SelfDraft[])
9const approved = atom({ plugin: 'mcp-dotnet', key: 'approved' } as const, [] as string[])
10const selfChatId = atom({ plugin: 'mcp-dotnet', key: 'selfChatId' } as const, null as string | null)
11
12const SEND_ONE = `${TEAMS}send_chat_message`
13const SEND_MANY = `${TEAMS}send_chat_messages`
14const SEND_CHANNEL = `${TEAMS}send_channel_message`
15const REACT = `${TEAMS}react_to_chat_message`
16const GATED = new Set([SEND_ONE, SEND_MANY, SEND_CHANNEL, REACT])
17
18const isSelf = async ($: EngineInterface, chat: unknown): Promise<boolean> => {
19 if (typeof chat !== 'string') return false
20 if (chat.toLowerCase() === 'self') return true
21
22 let id = await read($, selfChatId)
23 if (id === null) {
24 const me = parsed(await $.tool.call({ tool: `${TEAMS}get_current_user` }))
25 id = typeof me.selfChatId === 'string' ? me.selfChatId : null
26 if (id !== null) await update($, selfChatId, () => id)
27 }
28
29 return chat === id
30}
31
32const draftLabel = async ($: EngineInterface): Promise<string> => {
33 const open = await read($, drafts)
34
35 return open ? `${open.topic} v${open.version}` : 'draft'
36}
37
38const hasReaction = (message: Record<string, any>): boolean => {
39 const reactions = message.reactions
40
41 return Array.isArray(reactions) ? reactions.length > 0 : !!reactions && Object.keys(reactions).length > 0
42}
43
44/** Why the bodies may not go to someone else, or undefined when every one is approved. */
45const refusal = async ($: EngineInterface, bodies: string[]): Promise<string | undefined> => {
46 const pressed = await read($, approved)
47 const posted = await read($, selfDrafts)
48 let chat: Record<string, any>[] | undefined
49
50 for (const body of bodies) {
51 if (hasCue(body)) {
52 return 'The body still carries the "React to approve" cue. Send the composed draft text, never the self-chat message.'
53 }
54 const text = normalize(body)
55 if (pressed.includes(text)) continue
56
57 const draft = posted.findLast(one => one.body === text)
58 if (!draft) {
59 return 'Not an approved draft. Post it to the self chat and wait for the user to react to it, or show it with show_drafts so the user can press Send.'
60 }
61
62 chat ??= parsed(await $.tool.call({ tool: `${TEAMS}read_chat_messages`, chat: 'self', limit: 50 })).messages ?? []
63 const message = chat!.find(one => one.id === draft.id)
64 if (!message) {
65 return `Self-chat draft ${draft.id} is no longer among the newest 50 messages, so its reaction cannot be checked. Post the draft to the self chat again.`
66 }
67 if (!hasReaction(message)) {
68 return `Self-chat draft ${draft.id} has no reaction yet. The user approves by reacting to it.`
69 }
70 }
71
72 return undefined
73}
74
75const consume = async ($: EngineInterface, bodies: string[]): Promise<void> => {
76 const texts = bodies.map(normalize)
77 await update($, approved, list => list.filter(one => !texts.includes(one)))
78 await update($, selfDrafts, list => list.filter(one => !texts.includes(one.body)))
79}
80
81const record = async ($: EngineInterface, bodies: string[], ran: { result?: unknown; text?: string }): Promise<void> => {
82 const out = parsed(ran)
83 const ids: unknown[] = Array.isArray(out.sent) ? out.sent.map((one: any) => one?.id) : [out.id]
84 const posted = bodies.flatMap((body, i) => (typeof ids[i] === 'string' ? [{ id: ids[i] as string, body: normalize(body) }] : []))
85 if (posted.length === 0) return
86
87 await update($, selfDrafts, list => [...list, ...posted].slice(-50))
88 $.ui.status(`Teams: ${await draftLabel($)} in self chat · waiting on verdict`)
89}
90
91export const gate = (on: On): void => {
92 on('tool.call', async ($, e, next) => {
93 if (!GATED.has(e.tool)) return next(e)
94
95 const args = e as unknown as Record<string, unknown>
96 const toSelf = e.tool !== SEND_CHANNEL && (await isSelf($, args.chat))
97
98 if (e.tool === REACT) {
99 return toSelf
100 ? { deny: 'Reactions in the self chat are the user\'s approvals. Never place or remove one there.' }
101 : next(e)
102 }
103
104 const bodies = (e.tool === SEND_MANY ? args.bodies : [args.body]) as string[]
105
106 if (toSelf) {
107 const ran = await next(e)
108 if (!ran.deny && !ran.isError) await record($, bodies, ran)
109
110 return ran
111 }
112
113 const why = await refusal($, bodies)
114 if (why) return { deny: why }
115
116 const ran = await next(e)
117 if (!ran.deny && !ran.isError) {
118 await consume($, bodies)
119 const where = String(args.chat ?? args.channel ?? 'destination')
120 $.ui.status(`Teams: ${await draftLabel($)} sent to ${where}`)
121 }
122
123 return ran
124 })
125}
126hooks/state.ts 21 lines1export const TEAMS = 'mcp__plugin_mcp-dotnet_teams__' as const
2
3/** The line teams-followup appends under a draft so the user knows a reaction posts it. */
4const CUE = /\n\s*-{3,}\s*\n\s*\*React to approve[^\n]*\*\s*$/
5
6export const hasCue = (body: string): boolean => /\*React to approve/.test(body)
7
8export const normalize = (body: string): string => body.replace(/\r\n/g, '\n').replace(CUE, '').trim()
9
10/** An MCP result as an object: the structured content when the engine hands it over, else the text parsed. */
11export const parsed = (ran: { result?: unknown; text?: string }): Record<string, any> => {
12 if (ran.result && typeof ran.result === 'object' && !Array.isArray(ran.result)) {
13 return ran.result as Record<string, any>
14 }
15 try {
16 return JSON.parse(ran.text ?? '{}')
17 } catch {
18 return {}
19 }
20}
21types/index.d.ts 30 lines1export type DraftDestination = {
2 label: string
3 chat?: string
4 team?: string
5 channel?: string
6 reply_to?: string
7}
8
9export type DraftVariant = { label: string; body: string; sentTo?: string }
10
11export type Drafts = {
12 topic: string
13 version: number
14 destination: DraftDestination
15 variants: DraftVariant[]
16}
17
18export type SelfDraft = { id: string; body: string }
19
20declare module 'claude-code' {
21 interface PluginState {
22 'mcp-dotnet': {
23 drafts: Drafts | null
24 selfDrafts: SelfDraft[]
25 approved: string[]
26 selfChatId: string | null
27 }
28 }
29}
30