关于 add-workiq
Add Work IQ 是为 Power Apps 代码应用接入 Work IQ Copilot MCP 的开发技能,涵盖连接器配置、生成服务检查和 MCP 会话封装。它还处理 JSON-RPC 握手、连续对话和嵌套响应解析。
主要功能
- 01创建或复用 Work IQ Copilot MCP 连接
- 02添加数据源并检查生成的类型服务
- 03封装 MCP 初始化和 JSON-RPC 请求
- 04跨调用保存对话 ID
- 05解析 CopilotChat 的嵌套响应
适用场景
- 01在 Power Apps 代码应用中调用 Work IQ
- 02构建基于 Microsoft 365 内容的多轮查询
- 03处理 MCP 会话初始化与调用错误
MCP 相关
该技能通过 Work IQ Copilot MCP 连接器接入 MCP 端点,并调用 CopilotChat 工具。提供 McpSession 封装,负责握手、请求 ID、会话恢复和响应解析。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ microsoft/ power-platform-skills --skill add-workiq安装后在 Claude Code 中输入/add-workiq确认已加载。
详细介绍
📋 共享说明:shared-instructions.md - 横向关注事项。
添加 Work IQ(M365 Copilot 搜索)
Work IQ 通过专用的 Work IQ Copilot MCP 连接器(shared_a365copilotchatmcp;在 maker portal 中显示为“Work IQ Copilot MCP (Preview)”)访问。它公开一个 MCP(Model Context Protocol)端点,其 CopilotChat 工具可对 Microsoft 365 内容执行由 AI 驱动、基于知识的搜索和对话。
这是专为 Work IQ 构建的连接器,会生成一个
WorkIQCopilotMCPService,其中仅包含一个mcp_m365copilot操作。此技能中的所有命令和代码引用都专用于shared_a365copilotchatmcp(连接命令,以及生成的WorkIQCopilotMCPService/WorkIQCopilotMCPModel文件)。请勿将此技能用于其他连接器。如果你需要的是更广泛的shared_a365mcpservers“Microsoft 365 MCP Servers”套件(Mail/Teams/SharePoint MCP 服务),请通过/add-connector添加;它生成的服务为MicrosoftMCPServersService,因此你必须相应调整步骤 2 中的命令和封装器导入。
⚠️ Work IQ 使用 MCP。 首次执行
tools/call前会进行 JSON-RPCinitialize握手。此连接器的服务端(Microsoft.MCPPlatform.WebApi)可容忍无状态调用——在initialize时请勿发送Mcp-Session-Id(服务端会将其视为会话查找,并返回-32001 Session not found)。请通过下方的McpSession封装器使用此连接器;它会执行握手,默认不发送会话 ID,按顺序分配 JSON-RPC ID,在出现Session not found时自动重试,并为你解析嵌套响应。
工作流
- 检查 Memory Bank → 2. 添加连接器 → 3. 检查生成的服务 → 4. 创建 McpSession 封装器 → 5. 使用 Work IQ → 6. 构建 → 7. 更新 Memory Bank
步骤 1:检查 Memory Bank
根据 shared-instructions.md 检查是否存在 memory-bank.md。
步骤 2:添加连接器
Power Apps code-app CLI(@microsoft/power-apps-cli,通过 pa 或 power-apps 调用——请参阅 cli-binary.md 进行解析)会自行创建连接并生成类型化服务。请确保已安装 CLI(npm install)并已登录(pa auth status;仅使用 power-apps 的项目则运行 power-apps auth-status;它与其他 code-app 技能共用相同的身份验证)。
查找或创建连接
先检查是否已有连接:
pa connection list
在输出中查找 Work IQ Copilot MCP (Preview) 连接(api id shared_a365copilotchatmcp)。如果已列出,请记下其连接 ID,然后跳转到下方的“添加数据源”。
否则,使用原生 create-connection 动词创建连接:
pa connection create --connector shared_a365copilotchatmcp
- 环境会自动从应用的
power.config.json中读取——无需传入环境 ID。 - 此连接器需要 OAuth,因此 CLI 会打开浏览器以完成登录/同意(仅 SSO 的连接器会静默完成,不会打开浏览器)。
- 成功后会显示连接 ID——请保存,以供下一步使用。
请在此处停止——需要交互式登录:
- 告知用户浏览器已打开(或分享 CLI 打印的 URL)。
- 请他们使用 Microsoft 365 帐户登录,并同意 Work IQ Copilot MCP 连接器的授权请求。
- 等待用户确认浏览器显示成功后再继续。
如果 create-connection 失败:
- “not signed in”/身份验证错误 → 运行
pa auth status(或仅使用power-apps的项目运行power-apps auth-status),如有需要请先登录,然后重试。 - “Connection creation was cancelled.” → 浏览器流程提前关闭;请重新运行并完成流程。
- 任何其他非零退出 → 报告确切错误并停止。
作为备用方案,用户可以在 maker portal 中手动创建连接:https://make.powerapps.com/environments/<environment-id>/connections → + 新建连接 → 搜索“Work IQ Copilot MCP” → 创建,然后重新运行 list-connections。
添加数据源
连接创建完成后,将其添加到代码应用中(此操作会生成类型化服务和模型):
pa app add data-source --connector shared_a365copilotchatmcp -c <connection-id>
这是一个非表格型连接器——只需提供 --connector(api id)和 -c(连接 ID)。
步骤 3:检查生成的服务
添加连接器后,请确认生成的服务已存在。这是一个小型的单操作服务,因此可以直接查看或使用 grep 搜索:
Grep pattern="async \w+" path="src/generated/services/WorkIQCopilotMCPService.ts"
WorkIQCopilotMCPService 恰好公开一个操作——mcp_m365copilot(“Work IQ Copilot(预览版)”),以及一个仅用于连接验证的 Getmcp_m365copilot GET 变体。Work IQ / CopilotChat 通过 mcp_m365copilot 驱动。
其生成的签名如下:
public static async mcp_m365copilot(
Mcp_Session_Id?: string,
queryRequest?: QueryRequest
): Promise<IOperationResult<void>>
- 第一个参数是 MCP 会话 ID(连接器的
Mcp-Session-Id参数)。请保留为undefined——此服务不分配/不需要客户端会话 ID,在initialize时发送会返回-32001(见下文)。 - 第二个参数是 JSON-RPC 请求体,类型为
QueryRequest({ jsonrpc?, id?, method?, params?, result?, error? })——从src/generated/models/WorkIQCopilotMCPModel.ts导出。 - 返回类型为
IOperationResult<void>;实际的 JSON-RPC / SSE 请求体会在运行时出现在result.data中。
可直接依赖的事实(不要尝试在运行时发现):
- 工具名称为
CopilotChat(区分大小写)。不要替换为query、search或find。 - 参数键为
message——不是query、prompt或question。 - 不要在
initialize握手时发送Mcp-Session-Id。 此连接器的服务会将传入的 ID 视为现有会话查询,并返回-32001 Session not found。该服务可容忍无状态调用:initialize和tools/call在不带会话 ID 时都能成功,因此下面的McpSession包装器默认不会跟踪会话。
会话 ID 处理(测试期间已验证)。 此连接器的 MCP 服务可容忍无状态调用。在不带
Mcp-Session-Id的情况下调用initialize,会返回包含服务端能力的200;随后在不带 ID 的情况下调用tools/call,会返回 Copilot 回复——无需跟踪或回显会话 ID。不要生成客户端 ID 并在initialize时发送:服务会将其视为查询,并返回404 / -32001 Session not found(这是此包装器早期版本中的一个真实 bug)。请注意,code-apps 数据层的IOperationResult<TResponse>仅公开{ success, data, error, skipToken, count, fileName }——不会公开响应标头——因此,如果未来的 MCP 服务仅在Mcp-Session-Id响应标头中返回会话 ID,则此处将无法读取。不过,如果result.data中出现服务端 ID,包装器仍会防御性地采用该 ID。
第 4 步:创建 McpSession 封装
⚠️ 重要: MCP 会话处理和响应解析十分复杂。请完全照抄下面生产就绪的 McpSession 类。它会执行 initialize 握手(不发送任何会话 ID)、为 JSON-RPC ID 排序、在遇到“Session not found”时自动重试、持久化对话 ID,并解析深层嵌套的响应。
创建 src/connectors/mcpClient.ts:
// src/connectors/mcpClient.ts
import type { IOperationResult } from '@microsoft/power-apps/data'
import { WorkIQCopilotMCPService } from '../generated/services/WorkIQCopilotMCPService'
import type { QueryRequest } from '../generated/models/WorkIQCopilotMCPModel'
export interface JsonRpcRequest {
jsonrpc: '2.0'
id?: string
method: string
params?: Record<string, unknown>
}
export interface JsonRpcResponse {
jsonrpc?: string
id?: string
result?: Record<string, unknown>
error?: { code?: number; message?: string; data?: unknown }
}
type CopilotConversationMessage = {
text?: string
attributions?: Array<{
attributionType?: string
providerDisplayName?: string
seeMoreWebUrl?: string
}>
}
type CopilotConversation = {
messages?: CopilotConversationMessage[]
}
function parseRpc(result: IOperationResult<unknown>): JsonRpcResponse {
if (!result.success && result.error) {
return { error: { message: result.error.message } }
}
const data: unknown = result.data
if (data == null) return {}
if (typeof data === 'object') return data as JsonRpcResponse
if (typeof data === 'string') {
// Handle SSE framing: "event: message\ndata: {JSON}"
const dataLines = data
.split(/\r?\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trim())
// Per the SSE spec, multiple `data:` lines are joined with newlines — a single
// JSON object can be split across lines, so joining with '' would corrupt it.
const payload = dataLines.length ? dataLines.join('\n') : data
try {
return JSON.parse(payload) as JsonRpcResponse
} catch {
return { result: { raw: data } }
}
}
return { result: { raw: data } }
}
export class McpSession {
private nextId = 1
// MCP Streamable HTTP: the client must NOT send a session id on `initialize` —
// the server assigns one. Sending a client-generated id makes this connector's
// server return `404 / -32001 Session not found`. This server is stateless-
// tolerant, so we send no id at all; `extractSessionId` still adopts a server
// id if one ever surfaces in the response body.
private sessionId: string | undefined = undefined
private conversationId: string | undefined
private initialized = false
private extractSessionId(raw: IOperationResult<unknown>): string | undefined {
const container = raw as unknown as Record<string, unknown>
const dataObj =
raw.data && typeof raw.data === 'object'
? (raw.data as Record<string, unknown>)
: undefined
const resultObj =
dataObj?.result && typeof dataObj.result === 'object'
? (dataObj.result as Record<string, unknown>)
: undefined
const candidates: Array<unknown> = [
dataObj?.['Mcp-Session-Id'],
dataObj?.mcpSessionId,
dataObj?.sessionId,
resultObj?.['Mcp-Session-Id'],
resultObj?.mcpSessionId,
resultObj?.sessionId,
container['Mcp-Session-Id'],
container.mcpSessionId,
container.sessionId,
]
const found = candidates.find((value) => typeof value === 'string' && value.length > 0)
return typeof found === 'string' ? found : undefined
}
private isSessionNotFound(res: JsonRpcResponse): boolean {
const message = (res.error?.message ?? '').toLowerCase()
return message.includes('session not found') || res.error?.code === -32001
}
private resetSession(): void {
// Drop any session id and re-handshake from scratch (no id on `initialize`).
this.sessionId = undefined
this.initialized = false
}
private async send(
method: string,
params?: Record<string, unknown>,
allowRetry = true
): Promise<JsonRpcResponse> {
const req: JsonRpcRequest = { jsonrpc: '2.0', id: String(this.nextId++), method, params }
// Send no session id on `initialize` (server treats any id as existing-session lookup).
const sessionId = method === 'initialize' ? undefined : this.sessionId
const raw = (await WorkIQCopilotMCPService.mcp_m365copilot(
sessionId,
req as unknown as QueryRequest
)) as unknown as IOperationResult<unknown>
const negotiatedSessionId = this.extractSessionId(raw)
if (negotiatedSessionId) {
this.sessionId = negotiatedSessionId
}
const parsed = parseRpc(raw)
if (allowRetry && method !== 'initialize' && this.isSessionNotFound(parsed)) {
this.resetSession()
await this.initialize()
return this.send(method, params, false)
}
return parsed
}
async initialize(): Promise<JsonRpcResponse> {
const res = await this.send('initialize', {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'Power Apps Code App', version: '1.0.0' },
})
// Fail fast: surface a failed handshake instead of continuing to `tools/call`.
if (res.error) {
throw new Error(`MCP initialize failed: ${res.error.message ?? JSON.stringify(res.error)}`)
}
this.initialized = true
return res
}
/** Discover the available MCP tools (optional; the tool name is fixed as `CopilotChat`). */
async listTools(): Promise<JsonRpcResponse> {
if (!this.initialized) await this.initialize()
return this.send('tools/list', {})
}
async callCopilotChat(message: string): Promise<{ text: string; conversationId?: string }> {
if (!this.initialized) await this.initialize()
const raw = await this.send('tools/call', {
name: 'CopilotChat',
arguments: {
message,
...(this.conversationId ? { conversationId: this.conversationId } : {}),
},
})
// Surface MCP/connector errors as thrown exceptions so callers can `try/catch`
// instead of string-matching the returned text.
if (raw.error) {
throw new Error(raw.error.message ?? JSON.stringify(raw.error))
}
const parsed = extractCopilotText(raw)
if (parsed.conversationId) {
this.conversationId = parsed.conversationId
}
return { text: parsed.text, conversationId: parsed.conversationId }
}
}
function extractContentText(res: JsonRpcResponse): string | undefined {
const content = res.result?.content as Array<{ type?: string; text?: string }> | undefined
if (!Array.isArray(content)) {
return undefined
}
const textBlocks = content
.filter((c) => c.type === 'text' && typeof c.text === 'string')
.map((c) => c.text!.trim())
.filter((value) => value.length > 0)
if (textBlocks.length === 0) {
return undefined
}
// Prefer the JSON payload block. Some responses append metadata text blocks
// such as "CorrelationId: ..." that should not be concatenated.
const jsonBlock = textBlocks.find(
(block) => block.startsWith('{') && /"conversationId"|"rawResponse"|"reply"/.test(block)
)
if (jsonBlock) {
return jsonBlock
}
const nonMetadata = textBlocks.find((block) => !/^CorrelationId\s*:/i.test(block))
return nonMetadata ?? textBlocks[0]
}
export function extractCopilotText(res: JsonRpcResponse): {
text: string
conversationId?: string
} {
const rawText = extractContentText(res)
if (!rawText) {
return { text: res.result ? JSON.stringify(res.result, null, 2) : '(no content returned)' }
}
try {
const inner = JSON.parse(rawText) as {
conversationId?: string
reply?: string
message?: string
rawResponse?: string
}
if (typeof inner.rawResponse === 'string') {
try {
const convo = JSON.parse(inner.rawResponse) as CopilotConversation
const messages = Array.isArray(convo.messages) ? convo.messages : []
// messages[0] echoes the user query; the AI reply is the one carrying
// attributions (citations). Fall back to index 1, then the last message.
const attributed = messages.find(
(m) => Array.isArray(m.attributions) && m.attributions.length > 0
)
const selected = attributed ?? messages[1] ?? messages[messages.length - 1]
const replyText = selected?.text?.trim()
if (replyText) {
return { text: replyText, conversationId: inner.conversationId }
}
} catch {
// Fall through to simple reply extraction.
}
}
const fallbackText = inner.reply?.trim() || inner.message?.trim() || rawText
return { text: fallbackText, conversationId: inner.conversationId }
} catch {
return { text: rawText }
}
}
会话 ID 说明(测试期间已验证)。
McpSession初始不带会话 ID,且在initialize握手时不会发送会话 ID。针对shared_a365copilotchatmcp(服务Microsoft.MCPPlatform.WebApi)进行的测试确认,它可以容忍无状态调用:不带 ID 的initialize会返回200和 capabilities,不带 ID 的tools/call会返回 Copilot 回复。如果在initialize时发送客户端生成的 ID,服务会返回404 / -32001 Session not found——这就是最初的错误。IOperationResult仅公开{ success, data, error, skipToken, count, fileName }(没有响应头),因此在这里无法读取仅存在于响应头中的会话 ID;extractSessionId仍会在result.data中出现服务端 ID 时采用该 ID,而握手会在遇到Session not found时自动重试,以提高可靠性。如果你要绑定另一个确实有状态、且仅通过
Mcp-Session-Id响应头返回会话 ID 的 MCP 连接器,此 SDK 无法读取该 ID——请在调试器中捕获一个原始mcp_m365copilot结果,以确认 ID 出现的位置;如果它出现在result.data中,请将相应路径添加到extractSessionId。
第 5 步:使用 Work IQ
每个应用仅初始化一次(例如,在启动时通过 React useEffect 初始化,或使用模块单例),并在所有 Work IQ 调用中重复使用:
import { McpSession } from './connectors/mcpClient'
// Initialize once per app session and reuse across calls.
const workIqSession = new McpSession()
export async function queryWorkIQ(userPrompt: string): Promise<string> {
try {
const { text } = await workIqSession.callCopilotChat(userPrompt)
return text
} catch (error) {
const msg = error instanceof Error ? error.message : 'Work IQ query failed'
console.error('Work IQ Error:', msg)
throw error
}
}
关键模式:
- ✅ 初始化一次,在多次调用间重复使用(切勿为每个请求都执行
new McpSession()) - ✅ 向
callCopilotChat()传入与上下文相关的提示词 - ✅ 如果发生“Session not found”错误,会话会自动重新初始化
- ✅ 多轮聊天中,
conversationId会在各次调用间自动持久化
提示词结构
Work IQ 对上下文丰富、结构清晰的提示词响应良好。请根据你的场景调整此模式:
const prompt = `
You are [role/expert description].
**Context:**
- [Relevant data or background information]
- [Additional context as needed]
**Task:** [Clear, specific instruction]
**Format:**
- Use markdown with clear section headings (## Summary, ## Action Items, etc.)
- Specify limits (word count, number of items, etc.)
`.trim()
const { text } = await workIqSession.callCopilotChat(prompt)
可调整的场景:包含行动项的会议摘要、从电子邮件中提取并按优先级排序的每日行动项、基于文档的项目风险分析、团队绩效洞察,或任何基于 M365 数据中的知识进行的分析。
响应解析
callCopilotChat() 已经解包了嵌套的 JSON-RPC / SSE 响应,并返回干净的 text。请根据请求的格式解析该文本。
对于结构化的 Markdown 输出(当你要求使用 ## sections 时):
function extractSection(text: string, sectionName: string): string[] {
const regex = new RegExp(`##\\s*${sectionName}\\s*([\\s\\S]*?)(?=##|$)`)
const match = text.match(regex)
if (!match) return []
return match[1]
.split('\n')
.filter((line) => line.trim().startsWith('-'))
.map((line) => line.replace(/^-\s*/, '').trim())
.filter(Boolean)
}
const { text } = await workIqSession.callCopilotChat(prompt)
const summary = extractSection(text, 'Summary')
const actionItems = extractSection(text, 'Action Items')
对于非结构化输出——直接使用 text(原样显示或按 UI 的需要进行格式化)。
为什么必须使用 McpSession 模式
Work IQ 使用 MCP(Model Context Protocol)。正确调用它需要:
- 会话初始化:首次调用前进行(通过握手交换 capabilities)
- 会话处理——此连接器可以容忍无状态调用,因此封装层不会发送会话 ID(在
initialize时发送会触发-32001 Session not found);只有在响应正文中出现服务端 ID 时,封装层才会以防御性方式采用该 ID(请参阅上方的会话 ID 说明) - JSON-RPC ID 排序(每个请求都需要一个递增且唯一的 ID)
- 多轮对话支持:通过持久化对话 ID 实现
- 嵌套响应解析(JSON-RPC → 文本内容 → 内部 JSON → Graph 对话)
- 会话超时后自动恢复(
-32001/ “Session not found”)
McpSession 会处理以上所有事项。绕过它(为每次调用临时生成 ID,或不经握手直接调用)会导致“Session not found”错误和集成失败。
第 6 步:构建
npm run build
继续之前,请修复 TypeScript 错误。暂时不要部署。
第 7 步:更新记忆库
更新 memory-bank.md,记录:已添加连接器(shared_a365copilotchatmcp)、已在 src/connectors/mcpClient.ts 中创建 McpSession 封装、已接入 Work IQ 使用方式、构建状态。
重要 — 请勿保存敏感信息:
- ❌ OAuth URL 或授权链接
- ❌ 连接 ID
- ❌ 会话 ID 或令牌
- ❌ 环境 ID
- ❌ 任何身份验证凭据
仅保存高层进度,例如“已配置 Work IQ Copilot MCP 连接器”或“已通过 McpSession 实现 Work IQ CopilotChat 集成”。