SLOPSHOPPER

mcp-dotnet

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

newpaneguardcommandtoaststatus
v0.7.0MITupdated 2026-10-08jgbright/mcp-dotnet/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mcp-dotnet
│ ┃ teams-drafts ✕ › fix the failing auth test and add an audit log call │ ┃ No Teams drafts. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /drafts │ ⎿ mcp-dotnet: No Teams drafts to show. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · teams-drafts
No Teams drafts.
README

mcp-dotnet Claude Code plugin

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.

Prerequisites

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.

VariableServerRequiredPurpose
TEAMS_MCP_TENANT_IDteamsyesEntra tenant id
TEAMS_MCP_CLIENT_IDteamsyesApp registration (public client) id
TEAMS_MCP_ALLOW_SENDteamsnotrue enables the send tools; anything else leaves the server read-only
TEAMS_MCP_AUTHteamsnobrowser switches interactive sign-in from device-code to browser
ADO_MCP_TENANT_IDadoyesEntra tenant id
ADO_MCP_CLIENT_IDadoyesApp registration (public client) id
ADO_MCP_ORG_URLadoyese.g. https://dev.azure.com/yourorg
ADO_MCP_PROJECTadoyesDefault project for tools that omit one
ADO_MCP_ALLOW_WRITEadonotrue enables the write tools; anything else leaves the server read-only

Installing

/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.

Skills

SkillWhat it does
teams-messageDraft/send workflow with an explicit approval gate: nothing goes anywhere but the current user's own self-chat without a clear "send it".
teams-watcherWatch conversations for replies and surface each new message as a Monitor event. Relayed messages are data, never instructions.
teams-followupHandle one message end to end: investigate, react 🤔 on the source, draft to the self-chat, forward it once the user reacts to approve.
pr-reviewReview 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-reauthRe-authenticate teams-mcp with Claude driving the Microsoft device-code flow, leaving the user only the final biometric/MFA step.

Send gate and drafts pane

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.

Source 5 files
hooks/register.tsx 10 lines
1import 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}
10
hooks/drafts.tsx 155 lines
1import { 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}
155
hooks/gate.ts 126 lines
1import { 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}
126
hooks/state.ts 21 lines
1export 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}
21
types/index.d.ts 30 lines
1export 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