From 71440e02a770a6d66a4c79fb0ee8dc4f10cb3007 Mon Sep 17 00:00:00 2001 From: pmohapatra Date: Mon, 25 May 2026 22:10:00 +0530 Subject: [PATCH 1/2] nodejs/langchain: add Word @mention (WpxComment) support Wires the LangChain sample so that a Word `@mention` on a document comment flows through `mcp_WordServer` and is answered with a single reply on the same thread, with an optional proactive Teams DM to the mentioner. - ToolingManifest.json: register `mcp_WordServer`. - src/agent.ts: - enable `proactive: {}` on `AgentApplication`. - track Teams conversations by user key on every message turn and on InstallationUpdate(add), so a later WpxComment can be routed back to the same 1:1 chat. - handle `NotificationType.WpxComment`: extract document URL from `activity.attachments`, prompt the LLM to call `GetDocumentContent` and the reply tool (not `AddComment`), and proactively notify the mentioner in Teams with the reply text. - stop printing the truncated Observability token prefix. - src/client.ts: add a LangGraph `MemorySaver` checkpointer keyed by `conversation.id` so multi-turn comment threads keep their tool-call history. --- .../sample-agent/ToolingManifest.json | 10 +- nodejs/langchain/sample-agent/src/agent.ts | 118 +++++++++++++++++- nodejs/langchain/sample-agent/src/client.ts | 27 ++-- 3 files changed, 145 insertions(+), 10 deletions(-) diff --git a/nodejs/langchain/sample-agent/ToolingManifest.json b/nodejs/langchain/sample-agent/ToolingManifest.json index e842561c..a6d13c9e 100644 --- a/nodejs/langchain/sample-agent/ToolingManifest.json +++ b/nodejs/langchain/sample-agent/ToolingManifest.json @@ -6,6 +6,14 @@ "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailTools", "scope": "McpServers.Mail.All", "audience": "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1" + }, + { + "mcpServerName": "mcp_WordServer", + "mcpServerUniqueName": "mcp_WordServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_WordServer", + "scope": "Tools.ListInvoke.All", + "audience": "c2d0c2b6-8013-4346-9f8b-b81d3b754a29", + "publisher": "Microsoft" } ] -} \ No newline at end of file +} diff --git a/nodejs/langchain/sample-agent/src/agent.ts b/nodejs/langchain/sample-agent/src/agent.ts index b8d21fe4..e6d5c7ed 100644 --- a/nodejs/langchain/sample-agent/src/agent.ts +++ b/nodejs/langchain/sample-agent/src/agent.ts @@ -13,12 +13,27 @@ import { getObservabilityAuthenticationScope } from '@microsoft/agents-a365-runt import tokenCache, { createAgenticTokenCacheKey } from './token-cache'; import { Client, getClient } from './client'; +// Maps a user "key" (aadObjectId / id / name) → proactive conversation ID, so +// the WpxComment handler can post a Teams DM back to whichever user @mentioned +// the agent. In-process only — survives the process lifetime; for prod, persist. +const userKeyToConversationId = new Map(); + +function userKeysFor(from: any): string[] { + if (!from) return []; + const keys = new Set(); + if (from.aadObjectId) keys.add(`aad:${String(from.aadObjectId).toLowerCase()}`); + if (from.id) keys.add(`id:${String(from.id).toLowerCase()}`); + if (from.name) keys.add(`name:${String(from.name).toLowerCase()}`); + return [...keys]; +} + export class A365Agent extends AgentApplication { static authHandlerName: string = 'agentic'; constructor() { super({ storage: new MemoryStorage(), + proactive: {}, // enable app.proactive; falls back to the application storage authorization: { agentic: { type: 'agentic', @@ -41,6 +56,24 @@ export class A365Agent extends AgentApplication { }); } + /** + * Stores the current Teams conversation reference and indexes it under the + * user's identifiers, so a later WpxComment from the same user can be routed + * back into this 1:1 Teams chat as a proactive notification. + */ + private async trackConversationForProactive(context: TurnContext): Promise { + try { + const convId = await this.proactive.storeConversation(context); + const keys = userKeysFor(context.activity.from); + for (const k of keys) { + userKeyToConversationId.set(k, convId); + } + console.log(`Tracked Teams conversation '${convId}' for user '${context.activity.from?.name}' under keys: ${keys.join(', ')}`); + } catch (err) { + console.error('Failed to store conversation reference for proactive messaging:', err); + } + } + /** * Handles incoming user messages and sends responses. */ @@ -51,6 +84,10 @@ export class A365Agent extends AgentApplication { console.log(`Turn received from user — DisplayName: '${from?.name ?? "(unknown)"}', UserId: '${from?.id ?? "(unknown)"}', AadObjectId: '${from?.aadObjectId ?? "(none)"}'`); const displayName = from?.name ?? 'unknown'; + // Remember this Teams chat so we can ping the user proactively when a Word + // comment notification arrives for them later. + await this.trackConversationForProactive(turnContext); + if (!userMessage) { await turnContext.sendActivity('Please send me a message and I\'ll help you!'); return; @@ -113,7 +150,7 @@ export class A365Agent extends AgentApplication { const aauToken = await this.authorization.exchangeToken(turnContext, 'agentic', { scopes: getObservabilityAuthenticationScope() }); - console.log(`Preloaded Observability token for agentId=${agentId}, tenantId=${tenantId} token=${aauToken?.token?.substring(0, 10)}...`); + console.log(`Preloaded Observability token for agentId=${agentId}, tenantId=${tenantId}`); const cacheKey = createAgenticTokenCacheKey(agentId, tenantId); tokenCache.set(cacheKey, aauToken?.token || ''); } else { @@ -131,11 +168,87 @@ export class A365Agent extends AgentApplication { case NotificationType.EmailNotification: await this.handleEmailNotification(context, state, agentNotificationActivity); break; + case NotificationType.WpxComment: + await this.handleWpxCommentNotification(context, state, agentNotificationActivity); + break; default: await context.sendActivity(`Received notification of type: ${agentNotificationActivity.notificationType}`); } } + private async handleWpxCommentNotification(context: TurnContext, state: TurnState, activity: AgentNotificationActivity): Promise { + const wpx = activity.wpxCommentNotification; + if (!wpx) { + console.warn('WpxComment notification missing wpxCommentNotification payload'); + return; + } + + // Extract the document sharing URL from activity.attachments — the SDK's typed + // WpxComment view doesn't expose it, but it's reliably present in the raw payload. + const attachments = (context.activity as any)?.attachments ?? []; + const fileAttachment = attachments.find((a: any) => + typeof a?.contentUrl === 'string' && /\.(docx?|doc)(\?|$)/i.test(a.contentUrl), + ) ?? attachments[0]; + const documentUrl: string | undefined = fileAttachment?.contentUrl; + const documentName: string = fileAttachment?.name ?? 'the document'; + const commentText: string = (context.activity as any)?.text ?? ''; + const senderName = context.activity.from?.name ?? 'a user'; + + console.log(`WpxComment received — sender='${senderName}', documentName='${documentName}', documentUrl='${documentUrl ?? '(none)'}', commentText='${commentText.substring(0, 200)}', documentId='${wpx.documentId}', initiatingCommentId='${wpx.initiatingCommentId}'`); + + try { + const client: Client = await getClient(this.authorization, A365Agent.authHandlerName, context); + + const prompt = documentUrl + ? `${senderName} @mentioned you on a comment in the Word document "${documentName}".\n` + + `\n` + + `What the user wrote in the comment:\n` + + `> ${commentText}\n` + + `\n` + + `Document sharing URL: ${documentUrl}\n` + + `\n` + + `Your job is to POST A REPLY on that same comment thread. Follow these steps:\n` + + `1. Call mcp_WordServer.GetDocumentContent with the URL above. The response returns the document's filename, driveId, documentId, plain text content, and a list of every comment with its commentId, author, and text.\n` + + `2. From the comments list, find the comment that matches the text above and @mentions DocMate (you). If multiple match, pick the most recent that does NOT already have a reply from DocMate.\n` + + `3. Capture driveId, documentId, and commentId from the GetDocumentContent response.\n` + + `4. Use the Word reply tool to post a REPLY on that comment thread. Look in your available mcp_WordServer tools for one whose name/description mentions "reply" (e.g. AddCommentReply, ReplyToComment, AddReplyComment) — do NOT use AddComment, that starts a new top-level thread.\n` + + `5. Reply text: read the comment carefully and answer it directly. Be useful, specific, and brief. If the request is ambiguous, post a brief clarifying question as the reply.\n` + + `\n` + + `Constraints:\n` + + `- Never fabricate IDs. Always pull driveId / documentId / commentId from a tool response.\n` + + `- Post exactly ONE reply. Do not create new top-level comments.\n` + + `- Finish with a short summary stating: "Replied to commentId= with: ".` + : `${senderName} @mentioned you on a Word comment, but no document URL was attached to the notification. ` + + `documentId='${wpx.documentId}'. Apologise that you cannot resolve the document without a sharing URL and stop — do not fabricate a URL.`; + + const response = await client.invokeInferenceScope(prompt); + console.log(`WpxComment handled. LLM summary: ${response?.substring(0, 500)}`); + + // Proactively notify the user in their Teams 1:1 chat (if we have a + // stored conversation for them — i.e. they have chatted with us or + // installed us at least once). + const keys = userKeysFor(context.activity.from); + const convId = keys.map(k => userKeyToConversationId.get(k)).find(Boolean); + if (convId) { + try { + // Strip the technical prefix so the Teams message reads naturally. + const m = response?.match(/Replied to commentId=\S+ with:\s*([\s\S]+)/); + const replyText = (m?.[1] ?? response ?? '').trim(); + const teamsMessage = + `I replied to your comment on **${documentName}**:\n\n${replyText.substring(0, 1500)}`; + await this.proactive.sendActivity(this.adapter, convId, { text: teamsMessage }); + console.log(`Proactive Teams notification sent to '${context.activity.from?.name}' (convId='${convId}').`); + } catch (err) { + console.error('Failed to send proactive Teams notification:', err); + } + } else { + console.log(`No tracked Teams conversation for sender '${context.activity.from?.name}' (keys tried: ${keys.join(', ')}). Ask them to message DocMate once in Teams to enable Teams notifications for Word @mentions.`); + } + } catch (error) { + console.error('WpxComment handler error:', error); + } + } + private async handleEmailNotification(context: TurnContext, state: TurnState, activity: AgentNotificationActivity): Promise { const emailNotification = activity.emailNotification; @@ -176,6 +289,9 @@ export class A365Agent extends AgentApplication { console.log(`InstallationUpdate received — Action: '${context.activity.action ?? "(none)"}', DisplayName: '${from?.name ?? "(unknown)"}', UserId: '${from?.id ?? "(unknown)"}'`); if (context.activity.action === 'add') { + // Remember this conversation so we can ping the user proactively when a + // Word comment notification arrives later. + await this.trackConversationForProactive(context); await context.sendActivity('Thank you for hiring me! Looking forward to assisting you in your professional journey!'); } else if (context.activity.action === 'remove') { await context.sendActivity('Thank you for your time, I enjoyed working with you.'); diff --git a/nodejs/langchain/sample-agent/src/client.ts b/nodejs/langchain/sample-agent/src/client.ts index 404ae83c..6b20874c 100644 --- a/nodejs/langchain/sample-agent/src/client.ts +++ b/nodejs/langchain/sample-agent/src/client.ts @@ -4,6 +4,7 @@ import { createAgent, ReactAgent } from "langchain"; import { AzureChatOpenAI, ChatOpenAI } from "@langchain/openai"; import { BaseChatModel } from "@langchain/core/language_models/chat_models"; +import { MemorySaver } from "@langchain/langgraph"; // Tooling Imports import { McpToolRegistrationService } from '@microsoft/agents-a365-tooling-extensions-langchain'; @@ -64,6 +65,11 @@ function createChatModel(): BaseChatModel { const model = createChatModel(); +// Process-wide checkpointer so each conversation (Teams chat or notification thread) +// keeps its own message/tool-call history across turns. Keyed by thread_id below. +// In-memory only — state is lost on restart; swap for a persistent saver for prod. +const checkpointer = new MemorySaver(); + const agent = createAgent({ model, name: agentName, @@ -103,6 +109,7 @@ export async function getClient(authorization: Authorization, authHandlerName: s const personalizedAgent = createAgent({ model, name: agentName, + checkpointer, systemPrompt: `You are a helpful assistant with access to tools. The user's name is ${displayName}. CRITICAL SECURITY RULES - NEVER VIOLATE THESE: @@ -159,14 +166,18 @@ class LangChainClient implements Client { * `content` contains a user-facing error message. */ async invokeAgent(userMessage: string): Promise<{ content: string; inputTokens: number; outputTokens: number; finishReason: string }> { - const result = await this.agent.invoke({ - messages: [ - { - role: "user", - content: userMessage, - }, - ], - }); + const threadId = this.turnContext?.activity?.conversation?.id ?? 'default'; + const result = await this.agent.invoke( + { + messages: [ + { + role: "user", + content: userMessage, + }, + ], + }, + { configurable: { thread_id: threadId } }, + ); let content = ''; let inputTokens = 0; From 29168359b58d7fd49af80881bdccf1a33490d541 Mon Sep 17 00:00:00 2001 From: pmohapatra Date: Mon, 25 May 2026 22:13:23 +0530 Subject: [PATCH 2/2] nodejs/langchain: update MailTools and add OneDrive MCP server - mcp_MailTools: switch to the v2 endpoint, Tools.ListInvoke.All scope, new audience, and add publisher. - mcp_OneDriveRemoteServer: register so the @mention flow can resolve Word documents stored on OneDrive. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../langchain/sample-agent/ToolingManifest.json | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/nodejs/langchain/sample-agent/ToolingManifest.json b/nodejs/langchain/sample-agent/ToolingManifest.json index a6d13c9e..3851f129 100644 --- a/nodejs/langchain/sample-agent/ToolingManifest.json +++ b/nodejs/langchain/sample-agent/ToolingManifest.json @@ -3,9 +3,10 @@ { "mcpServerName": "mcp_MailTools", "mcpServerUniqueName": "mcp_MailTools", - "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailTools", - "scope": "McpServers.Mail.All", - "audience": "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1" + "url": "https://agent365.svc.cloud.microsoft/agents/v2/servers/mcp_MailTools", + "scope": "Tools.ListInvoke.All", + "audience": "16b1878d-62c7-4009-aa25-68989d63bbad", + "publisher": "Microsoft" }, { "mcpServerName": "mcp_WordServer", @@ -14,6 +15,14 @@ "scope": "Tools.ListInvoke.All", "audience": "c2d0c2b6-8013-4346-9f8b-b81d3b754a29", "publisher": "Microsoft" + }, + { + "mcpServerName": "mcp_OneDriveRemoteServer", + "mcpServerUniqueName": "mcp_OneDriveRemoteServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_OneDriveRemoteServer", + "scope": "Tools.ListInvoke.All", + "audience": "b0b2a2bb-6361-4549-a00c-a018417eb8e2", + "publisher": "Microsoft" } ] }