mcpField
A
add-workiq

add-workiq

github.com/microsoft开发1.1k110 次安装今天更新
代码仓库

关于 add-workiq

Add Work IQ 是为 Power Apps 代码应用接入 Work IQ Copilot MCP 的开发技能,涵盖连接器配置、生成服务检查和 MCP 会话封装。它还处理 JSON-RPC 握手、连续对话和嵌套响应解析。

主要功能

  1. 01创建或复用 Work IQ Copilot MCP 连接
  2. 02添加数据源并检查生成的类型服务
  3. 03封装 MCP 初始化和 JSON-RPC 请求
  4. 04跨调用保存对话 ID
  5. 05解析 CopilotChat 的嵌套响应

适用场景

  1. 01在 Power Apps 代码应用中调用 Work IQ
  2. 02构建基于 Microsoft 365 内容的多轮查询
  3. 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-RPC initialize 握手。此连接器的服务端(Microsoft.MCPPlatform.WebApi)可容忍无状态调用——在 initialize 时请勿发送 Mcp-Session-Id(服务端会将其视为会话查找,并返回 -32001 Session not found)。请通过下方的 McpSession 封装器使用此连接器;它会执行握手,默认不发送会话 ID,按顺序分配 JSON-RPC ID,在出现 Session not found 时自动重试,并为你解析嵌套响应。

工作流

  1. 检查 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——请保存,以供下一步使用。

请在此处停止——需要交互式登录:

  1. 告知用户浏览器已打开(或分享 CLI 打印的 URL)。
  2. 请他们使用 Microsoft 365 帐户登录,并同意 Work IQ Copilot MCP 连接器的授权请求。
  3. 等待用户确认浏览器显示成功后再继续。

如果 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)。正确调用它需要:

  1. 会话初始化:首次调用前进行(通过握手交换 capabilities)
  2. 会话处理——此连接器可以容忍无状态调用,因此封装层不会发送会话 ID(在 initialize 时发送会触发 -32001 Session not found);只有在响应正文中出现服务端 ID 时,封装层才会以防御性方式采用该 ID(请参阅上方的会话 ID 说明)
  3. JSON-RPC ID 排序(每个请求都需要一个递增且唯一的 ID)
  4. 多轮对话支持:通过持久化对话 ID 实现
  5. 嵌套响应解析(JSON-RPC → 文本内容 → 内部 JSON → Graph 对话)
  6. 会话超时后自动恢复(-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 集成”。

评论

支持 Markdown