microsoft/teams.net

Public

mirrored fromhttps://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/update-release-process

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

core/samples/McpServer/McpTools.cs

241lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using System.ComponentModel;
5using Microsoft.Teams.Apps;
6using Microsoft.Teams.Apps.Schema;
7using Microsoft.Teams.Core;
8using Microsoft.Teams.Core.Schema;
9using ModelContextProtocol.Server;
10
11namespace McpServer;
12
13// [McpServerToolType] marks the class for tool discovery; each
14// [McpServerTool] method below becomes one tool surfaced to MCP clients.
15// The class is registered via WithTools<McpTools>() in Program.cs.
16[McpServerToolType]
17public sealed class McpTools(TeamsBotApplication app, State state, IConfiguration config, GraphClient graph)
18{
19 [McpServerTool(Name = "notify"), Description("Send a notification to a Teams user. No response expected.")]
20 public async Task<NotifyResult> Notify(
21 [Description("The AAD object id of the Teams user to notify.")] string userId,
22 [Description("The message text to send.")] string message,
23 CancellationToken cancellationToken = default)
24 {
25 string conversationId = await GetOrCreateConversationAsync(userId, cancellationToken);
26 TeamsActivity notifyActivity = TeamsActivity.CreateBuilder()
27 .WithType(TeamsActivityTypes.Message)
28 .WithServiceUrl(state.ServiceUrl)
29 .WithConversation(new Conversation(conversationId))
30 .WithText(message)
31 .Build();
32 await app.SendActivityAsync(notifyActivity, cancellationToken);
33 return new NotifyResult(Notified: true, UserId: userId);
34 }
35
36 [McpServerTool(Name = "ask"), Description(
37 "Ask a Teams user a question. Returns a request_id — call wait_for_reply with it to get the answer.")]
38 public async Task<AskResult> Ask(
39 [Description("The AAD object id of the Teams user to ask.")] string userId,
40 [Description("The question to ask.")] string question,
41 CancellationToken cancellationToken = default)
42 {
43 string conversationId = await GetOrCreateConversationAsync(userId, cancellationToken);
44 string requestId = Guid.NewGuid().ToString();
45
46 // Record the pending ask before sending, so a fast reply is never lost.
47 state.PendingAsks[requestId] = new PendingAsk(userId);
48 TeamsActivity askActivity = TeamsActivity.CreateBuilder()
49 .WithType(TeamsActivityTypes.Message)
50 .WithServiceUrl(state.ServiceUrl)
51 .WithConversation(new Conversation(conversationId))
52 .WithAdaptiveCardAttachment(Cards.AskCard(requestId, question))
53 .Build();
54 try
55 {
56 await app.SendActivityAsync(askActivity, cancellationToken);
57 }
58 catch
59 {
60 state.PendingAsks.TryRemove(requestId, out _);
61 throw;
62 }
63 return new AskResult(RequestId: requestId);
64 }
65
66 [McpServerTool(Name = "get_reply"), Description(
67 "Snapshot the current reply state for an ask. this exists for manual polling. " +
68 "Returns status 'pending' until the user responds.")]
69 public ReplyResult GetReply(
70 [Description("The request_id returned from ask.")] string requestId)
71 {
72 if (!state.PendingAsks.TryGetValue(requestId, out PendingAsk? entry))
73 {
74 throw new InvalidOperationException($"No ask found with request_id {requestId}.");
75 }
76 return new ReplyResult(Status: entry.Status, Reply: entry.Reply);
77 }
78
79 [McpServerTool(Name = "wait_for_reply"), Description(
80 "Wait for the user's reply to an earlier ask. Blocks up to timeout_seconds (default 30). " +
81 "Returns the reply when it arrives, or status='pending' if the timeout fires")]
82 public async Task<ReplyResult> WaitForReply(
83 [Description("The request_id returned from ask.")] string requestId,
84 [Description("Max seconds to wait before returning (default 30).")] int timeoutSeconds = 30,
85 CancellationToken cancellationToken = default)
86 {
87 if (!state.PendingAsks.TryGetValue(requestId, out PendingAsk? entry))
88 {
89 throw new InvalidOperationException($"No ask found with request_id {requestId}.");
90 }
91 if (entry.Status != AskStatus.Pending)
92 {
93 return new ReplyResult(entry.Status, entry.Reply);
94 }
95
96 TaskCompletionSource<PendingAsk> waiter = state.ReplyWaiters.GetOrAdd(
97 requestId,
98 _ => new TaskCompletionSource<PendingAsk>(TaskCreationOptions.RunContinuationsAsynchronously));
99
100 // Re-check after registering the waiter so we don't miss a signal that
101 // fired between the initial read and GetOrAdd.
102 if (state.PendingAsks.TryGetValue(requestId, out PendingAsk? latest)
103 && latest.Status != AskStatus.Pending)
104 {
105 return new ReplyResult(latest.Status, latest.Reply);
106 }
107
108 try
109 {
110 PendingAsk result = await waiter.Task.WaitAsync(
111 TimeSpan.FromSeconds(timeoutSeconds), cancellationToken);
112 return new ReplyResult(result.Status, result.Reply);
113 }
114 catch (TimeoutException)
115 {
116 state.PendingAsks.TryGetValue(requestId, out PendingAsk? current);
117 return new ReplyResult(current?.Status ?? AskStatus.Pending, current?.Reply);
118 }
119 }
120
121 [McpServerTool(Name = "request_approval"), Description(
122 "Send an approval request to a Teams user. Returns an approval_id — call wait_for_approval with it to get the decision.")]
123 public async Task<ApprovalRequestResult> RequestApproval(
124 [Description("The AAD object id of the Teams user to ask for approval.")] string userId,
125 [Description("Title of the approval request.")] string title,
126 [Description("Description of what is being approved.")] string description,
127 CancellationToken cancellationToken = default)
128 {
129 string conversationId = await GetOrCreateConversationAsync(userId, cancellationToken);
130 string approvalId = Guid.NewGuid().ToString();
131
132 TeamsActivity activity = TeamsActivity.CreateBuilder()
133 .WithType(TeamsActivityTypes.Message)
134 .WithServiceUrl(state.ServiceUrl)
135 .WithConversation(new Conversation(conversationId))
136 .WithAdaptiveCardAttachment(Cards.ApprovalCard(approvalId, title, description))
137 .Build();
138
139 state.Approvals[approvalId] = ApprovalStatus.Pending;
140 try
141 {
142 await app.SendActivityAsync(activity, cancellationToken);
143 }
144 catch
145 {
146 state.Approvals.TryRemove(approvalId, out _);
147 throw;
148 }
149
150 return new ApprovalRequestResult(ApprovalId: approvalId);
151 }
152
153 [McpServerTool(Name = "get_approval"), Description(
154 "Snapshot the current status of an approval request. this exists for manual polling." +
155 "Returns 'pending', 'approved', or 'rejected'.")]
156 public ApprovalResult GetApproval(
157 [Description("The approval_id returned from request_approval.")] string approvalId)
158 {
159 if (!state.Approvals.TryGetValue(approvalId, out string? status))
160 {
161 throw new InvalidOperationException($"No approval found with approval_id {approvalId}.");
162 }
163 return new ApprovalResult(ApprovalId: approvalId, Status: status);
164 }
165
166 [McpServerTool(Name = "wait_for_approval"), Description(
167 "Wait for an approval decision. Blocks up to timeout_seconds (default 30). " +
168 "Returns 'approved' or 'rejected' when the user clicks, or 'pending' if the timeout fires.")]
169 public async Task<ApprovalResult> WaitForApproval(
170 [Description("The approval_id returned from request_approval.")] string approvalId,
171 [Description("Max seconds to wait before returning (default 30).")] int timeoutSeconds = 30,
172 CancellationToken cancellationToken = default)
173 {
174 if (!state.Approvals.TryGetValue(approvalId, out string? status))
175 {
176 throw new InvalidOperationException($"No approval found with approval_id {approvalId}.");
177 }
178 if (status != ApprovalStatus.Pending)
179 {
180 return new ApprovalResult(approvalId, status);
181 }
182
183 TaskCompletionSource<string> waiter = state.ApprovalWaiters.GetOrAdd(
184 approvalId,
185 _ => new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously));
186
187 if (state.Approvals.TryGetValue(approvalId, out string? latest) && latest != ApprovalStatus.Pending)
188 {
189 return new ApprovalResult(approvalId, latest);
190 }
191
192 try
193 {
194 string result = await waiter.Task.WaitAsync(
195 TimeSpan.FromSeconds(timeoutSeconds), cancellationToken);
196 return new ApprovalResult(approvalId, result);
197 }
198 catch (TimeoutException)
199 {
200 state.Approvals.TryGetValue(approvalId, out string? current);
201 return new ApprovalResult(approvalId, current ?? ApprovalStatus.Pending);
202 }
203 }
204
205 [McpServerTool(Name = "find_user"), Description(
206 "Find users in this tenant by partial name, email, or UPN. " +
207 "Returns up to 5 matches with their AAD object ids — pass an id to " +
208 "notify, ask, or request_approval.")]
209 public async Task<FindUserResult> FindUser(
210 [Description("Name, email, or UPN fragment to search for.")] string query,
211 CancellationToken cancellationToken = default)
212 {
213 IReadOnlyList<UserMatch> matches = await graph.SearchUsersAsync(query, top: 5, cancellationToken);
214 return new FindUserResult(matches);
215 }
216
217 // Returns the cached 1:1 conversation id for a user, or opens a new 1:1 proactively.
218 private async Task<string> GetOrCreateConversationAsync(string userId, CancellationToken cancellationToken)
219 {
220 if (state.Conversations.TryGetValue(userId, out string? existing))
221 {
222 return existing;
223 }
224
225 ConversationParameters parameters = new()
226 {
227 Members = [new ChannelAccount { Id = userId }],
228 TenantId = config["AzureAd:TenantId"],
229 };
230
231 CreateConversationResponse resource = await app.Api
232 .ForServiceUrl(state.ServiceUrl)
233 .Conversations
234 .CreateAsync(parameters, cancellationToken: cancellationToken);
235
236 string id = resource.Id
237 ?? throw new InvalidOperationException("conversations.create returned no id.");
238 state.Conversations[userId] = id;
239 return id;
240 }
241}
242