mcpField
A
add-workiq

add-workiq

github.com/microsoft開発1.1k110 回インストール今日更新
リポジトリ

add-workiq について

Add Work IQは、Power AppsのコードアプリにWork IQ Copilot MCPを接続する開発スキルです。コネクター設定や生成サービスの確認に加え、JSON-RPCのハンドシェイク、複数ターンの会話、ネストされた応答を扱うMCPセッションラッパーを作成します。

主な機能

  1. 01Work IQ Copilot MCP接続を作成または再利用
  2. 02データソースを追加し生成サービスを確認
  3. 03MCP初期化とJSON-RPCリクエストをラップ
  4. 04呼び出し間で会話IDを保持
  5. 05CopilotChatのネストされた応答を解析

活用シーン

  1. 01Power AppsコードアプリからWork IQを呼び出す
  2. 02Microsoft 365コンテンツに基づく複数ターンの検索を構築
  3. 03MCPセッションの初期化や呼び出しエラーに対処

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 Search)を追加する

Work IQ は、専用の Work IQ Copilot MCP コネクタ(shared_a365copilotchatmcp。メーカー ポータルでは「Work IQ Copilot MCP (Preview)」と表示されます)を通じて利用します。このコネクタは、MCP(Model Context Protocol)エンドポイントを公開し、その CopilotChat ツールによって、Microsoft 365 コンテンツを対象に、AI を活用したナレッジに基づく検索と会話を行います。

これは Work IQ 専用に構築されたコネクタで、単一の mcp_m365copilot オペレーションを含む WorkIQCopilotMCPService が生成されます。このスキルに記載されているすべてのコマンドとコード参照は、shared_a365copilotchatmcp 専用です(接続コマンド、および生成される WorkIQCopilotMCPService/WorkIQCopilotMCPModel ファイル)。別のコネクタにはこのスキルを使用しないでください。より広範な shared_a365mcpservers の「Microsoft 365 MCP Servers」バンドル(Mail/Teams/SharePoint MCP サーバー)が必要な場合は、/add-connector で追加してください。この場合、生成されるサービスは MicrosoftMCPServersService なので、Step 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 を更新

Step 1: Memory Bank を確認する

shared-instructions.md に従って memory-bank.md を確認してください。

Step 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)を探します。一覧にある場合は、その Connection ID を控えて、下の「データソースを追加する」に進みます。

見つからない場合は、 ネイティブの create-connection 動詞を使って作成します:

pa connection create --connector shared_a365copilotchatmcp
  • 環境はアプリの power.config.json から自動的に読み込まれるため、環境 ID を渡す必要はありません。
  • このコネクタでは OAuth が必要です。そのため、CLI はサインインと同意を完了するためにブラウザーを開きます(SSO のみのコネクタはブラウザーを開かずにサイレントで完了します)。
  • 成功すると Connection 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 → + New connection → 「Work IQ Copilot MCP」を検索 → Create。その後、list-connections を再実行します。

データソースを追加する

接続が作成されたら、コードアプリに追加します(これにより、型付きサービスとモデルが生成されます):

pa app add data-source --connector shared_a365copilotchatmcp -c <connection-id>

これは非表形式のコネクタのため、必要なのは --connector(api id)と -c(connection id)のみです。

ステップ 3:生成されたサービスを確認する

コネクタを追加した後、生成されたサービスが存在することを確認します。これは単一の操作だけを持つ小さなサービスなので、直接読むか、grep で検索できます。

Grep pattern="async \w+" path="src/generated/services/WorkIQCopilotMCPService.ts"

WorkIQCopilotMCPService が公開する操作は、mcp_m365copilot(「Work IQ Copilot(プレビュー)」)の 1 つだけです。また、接続確認専用の Getmcp_m365copilot GET バリアントもあります。Work IQ/CopilotChat は mcp_m365copilot を通じて動作します。

生成されたシグネチャは次のとおりです。

public static async mcp_m365copilot(
  Mcp_Session_Id?: string,
  queryRequest?: QueryRequest
): Promise<IOperationResult<void>>
  • 第 1 引数は MCP セッション ID(コネクタの Mcp-Session-Id パラメーター)です。undefined のままにしてください。このサーバーではクライアントセッション ID は割り当てられず、必要もありません。また、initialize で送信すると -32001 が返されます(以下を参照)。
  • 第 2 引数は 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 を返します。このサーバーは ステートレスにも対応しており、セッション ID がなくても initialize と tools/call の両方が成功するため、以下の McpSession ラッパーはデフォルトでセッション ID を追跡しません。

セッション ID の処理(テストで確認済み)。 このコネクタの MCP サーバーは ステートレスにも対応しています。Mcp-Session-Id を指定せずに initialize すると、サーバー機能とともに 200 が返されます。その後、ID を指定せずに tools/call を実行すると Copilot の応答が返されます。追跡またはエコーするセッション ID は不要です。クライアント ID を生成して initialize で送信しないでください。サーバーはそれを検索用 ID として扱い、404 / -32001 Session not found を返します(これはこのラッパーの以前のバージョンに実在したバグです)。なお、code-apps のデータレイヤーにある IOperationResult<TResponse> が公開するのは { success, data, error, skipToken, count, fileName } のみで、レスポンスヘッダーは公開されません。そのため、今後の MCP サーバーが Mcp-Session-Id レスポンスヘッダーだけでセッション ID を返す場合、ここでは読み取れません。ラッパーは引き続き、result.data 内にサーバー ID が現れた場合には防御的にそれを採用します。

Step 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 とケイパビリティを返し、ID を指定しない tools/call は Copilot の応答を返します。initialize でクライアントが生成した ID を送信すると、サーバーは 404 / -32001 Session not found を返します。これが元のバグでした。IOperationResult が公開するのは { success, data, error, skipToken, count, fileName } のみ(レスポンスヘッダーは含まれません)のため、ヘッダーにしかないセッション ID はここでは読み取れません。それでも、extractSessionId は result.data 内にサーバー ID が現れた場合はそれを引き継ぎ、ハンドシェイクは堅牢性を高めるため「Session not found」発生時に自動で再試行します。

ステートフルで、セッション ID を Mcp-Session-Id レスポンスヘッダーでのみ返す別の MCP コネクターを接続する場合、この SDK ではその ID を読み取れません。デバッガーで mcp_m365copilot の生の結果を 1 件確認し、ID がどこに現れるかを確かめてください。result.data 内に現れる場合は、そのパスを extractSessionId に追加します。

Step 5: Work IQ を使用する

アプリごとに 1 回だけ初期化し(例:起動時の 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
  }
}

主なパターン:

  • ✅ 1 回だけ初期化し、複数の呼び出しで再利用します(リクエストごとに 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. 初回呼び出し前のセッション初期化(ケイパビリティを交換するハンドシェイク)
  2. セッション処理 — このコネクターはステートレスなクライアントにも対応しているため、ラッパーはセッション ID を送信しません(initialize で送信すると -32001 Session not found が発生します)。レスポンス本文にサーバー ID が現れた場合に限り、それを安全に引き継ぎます(上記のセッション ID に関する注記を参照)
  3. JSON-RPC ID の順序付け(各リクエストには一意で、順に増加する ID が必要です)
  4. 会話 ID の保持による複数ターンの会話のサポート
  5. ネストされたレスポンスの解析(JSON-RPC → テキストコンテンツ → 内部 JSON → Graph の会話)
  6. セッションタイムアウト時の自動復旧(-32001/「Session not found」)

McpSession はこれらすべてを処理します。これを迂回すると(呼び出しごとに独自の ID を使う、またはハンドシェイクなしで直接呼び出すなど)、「Session not found」エラーが発生し、連携に失敗します。

Step 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 の統合を実装済み」のような、概要レベルの進捗のみを保存してください。

コメント