From 2c16258784d0d488b597db0e5ceeac5d9bc4c5bf Mon Sep 17 00:00:00 2001 From: rrader26 Date: Mon, 11 May 2026 20:39:43 -0400 Subject: [PATCH] feat(plugins): Microsoft Workflows Pack v0 (Graph-only, cross-platform) First external pack built on the AgentMarkPlugin contract from PR #22. Universal Outlook + OneDrive surface that runs identically on Windows and macOS because every tool hits Microsoft Graph rather than native COM/AppleScript bindings. Office app drivers (Excel-COM on Windows, AppleScript on macOS) ship in a follow-up. Tools (10): - agentmark_microsoft_login / logout / whoami - agentmark_outlook_send_email / search / get_message / reply - agentmark_onedrive_list / upload / download Auth: OAuth 2.0 device-code flow (RFC 8628). Tokens are persisted to ~/.thinkfleet/agentmark/microsoft-tokens.json with 0600 permissions. Refresh tokens rotate automatically on access-token expiry; expired refresh tokens surface NotAuthenticatedError pointing the user back to agentmark_microsoft_login. Client ID is read from the AGENTMARK_MS_CLIENT_ID environment variable or the `clientId` config option. Register an Azure AD app with these delegated permissions: Mail.Send, Mail.ReadWrite, Files.ReadWrite, offline_access, User.Read. The "Allow public client flows" toggle must be enabled for device-code to work. Opt-in pack: not part of the default plugin set. Use it explicitly: import { createMcpServer, createWebPlugin, createPdfPlugin, createDesktopPlugin, createMetaPlugin, createMicrosoftPlugin } from '@thinkfleet/agentmark' const web = createWebPlugin() const pdf = createPdfPlugin() const desktop = createDesktopPlugin() const microsoft = createMicrosoftPlugin({ clientId: '...' }) const meta = createMetaPlugin([web, pdf, desktop, microsoft]) createMcpServer({ plugins: [web, pdf, desktop, microsoft, meta] }) Tests: 12 new (auth state machine + plugin registration + Outlook handler shape). Total 338 pass, 10 skip. Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/index.ts | 17 ++ src/plugins/microsoft/auth.ts | 262 ++++++++++++++++++++ src/plugins/microsoft/graph-client.ts | 151 ++++++++++++ src/plugins/microsoft/index.ts | 67 +++++ src/plugins/microsoft/tool-defs.ts | 198 +++++++++++++++ src/plugins/microsoft/tools/auth-tools.ts | 75 ++++++ src/plugins/microsoft/tools/onedrive.ts | 185 ++++++++++++++ src/plugins/microsoft/tools/outlook.ts | 157 ++++++++++++ test/microsoft/microsoft-plugin.test.ts | 284 ++++++++++++++++++++++ 9 files changed, 1396 insertions(+) create mode 100644 src/plugins/microsoft/auth.ts create mode 100644 src/plugins/microsoft/graph-client.ts create mode 100644 src/plugins/microsoft/index.ts create mode 100644 src/plugins/microsoft/tool-defs.ts create mode 100644 src/plugins/microsoft/tools/auth-tools.ts create mode 100644 src/plugins/microsoft/tools/onedrive.ts create mode 100644 src/plugins/microsoft/tools/outlook.ts create mode 100644 test/microsoft/microsoft-plugin.test.ts diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 65b89c3..ef6856a 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -34,6 +34,23 @@ export { createPdfPlugin, type PdfPlugin } from './plugins/pdf' export { createDesktopPlugin, type DesktopPlugin } from './plugins/desktop' export { createMetaPlugin } from './plugins/meta' +// Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the +// default plugin set. Pass it explicitly via `createMcpServer({ plugins })`. +export { + createMicrosoftPlugin, + MicrosoftAuth, + GraphClient, + GraphError, + NotAuthenticatedError, + MICROSOFT_TOOLS, +} from '../plugins/microsoft' +export type { + MicrosoftPluginConfig, + MicrosoftAuthConfig, + TokenSet, + DeviceCodeStartResponse, +} from '../plugins/microsoft' + // Tool definition shape + the aggregated default list. export { ALL_TOOLS } from './tool-defs' export type { McpToolDef } from './tool-defs' diff --git a/src/plugins/microsoft/auth.ts b/src/plugins/microsoft/auth.ts new file mode 100644 index 0000000..3963272 --- /dev/null +++ b/src/plugins/microsoft/auth.ts @@ -0,0 +1,262 @@ +/** + * Microsoft Graph authentication — device code OAuth 2.0 flow with + * refresh-token rotation and on-disk token cache. + * + * Device code is the right flow for desktop / CLI contexts: the user + * opens a URL on any device, enters a short code, completes login. No + * embedded webview, no redirect URI. Works identically on Windows and + * macOS. + * + * Token cache lives at `~/.thinkfleet/agentmark/microsoft-tokens.json` + * with 0600 permissions. The cache is keyed by `clientId|scopeSet` so + * multiple Azure AD apps can coexist on one machine. + */ +import { mkdir, readFile, writeFile, chmod, unlink } from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' + +/** Default Azure AD client ID. Override via AGENTMARK_MS_CLIENT_ID. */ +const DEFAULT_CLIENT_ID = process.env.AGENTMARK_MS_CLIENT_ID ?? '' + +/** The "common" tenant accepts personal + work/school Microsoft accounts. */ +const TENANT = 'common' + +const DEVICE_CODE_ENDPOINT = `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/devicecode` +const TOKEN_ENDPOINT = `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0/token` + +/** Default scope set for the v0 pack (Outlook + OneDrive + offline refresh). */ +export const DEFAULT_SCOPES = [ + 'Mail.Send', + 'Mail.ReadWrite', + 'Files.ReadWrite', + 'offline_access', + 'User.Read', +] + +export interface MicrosoftAuthConfig { + /** Azure AD app client id. Falls back to AGENTMARK_MS_CLIENT_ID env var. */ + clientId?: string + /** OAuth scopes. Defaults to the v0 pack scope set. */ + scopes?: string[] + /** Override the token-cache file path (mostly for tests). */ + cachePath?: string +} + +export interface TokenSet { + access_token: string + refresh_token?: string + expires_at: number // epoch ms + scope: string +} + +export interface DeviceCodeStartResponse { + user_code: string + device_code: string + verification_uri: string + expires_in: number + interval: number + message: string +} + +export class MicrosoftAuth { + readonly clientId: string + readonly scopes: string[] + private readonly cachePath: string + private cached: TokenSet | null = null + + constructor(config: MicrosoftAuthConfig = {}) { + this.clientId = config.clientId ?? DEFAULT_CLIENT_ID + this.scopes = config.scopes ?? DEFAULT_SCOPES + this.cachePath = config.cachePath ?? defaultCachePath() + } + + /** + * Begin a device-code login. Surface the returned `verification_uri` + * and `user_code` to the user; then call `completeDeviceCode()` with + * the returned `device_code` to poll for completion. + */ + async startDeviceCode(): Promise { + this.requireClientId() + const body = new URLSearchParams({ + client_id: this.clientId, + scope: this.scopes.join(' '), + }) + const response = await fetch(DEVICE_CODE_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }) + if (!response.ok) { + throw new Error( + `Microsoft device-code request failed: ${response.status} ${await response.text()}`, + ) + } + return (await response.json()) as DeviceCodeStartResponse + } + + /** + * Poll the token endpoint until the user completes login or the + * device code expires. Persists the resulting token to disk on + * success. Throws on failure / timeout. + */ + async completeDeviceCode(start: DeviceCodeStartResponse): Promise { + const deadline = Date.now() + start.expires_in * 1000 + // RFC 8628 §3.5: clients MUST respect the server-supplied interval + // (and bump it by ≥5s when slow_down comes back). Microsoft sends + // 5 in practice; tests pass 0 to run fast. + const intervalMs = Math.max(start.interval * 1000, 0) + + while (Date.now() < deadline) { + await sleep(intervalMs) + const body = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + client_id: this.clientId, + device_code: start.device_code, + }) + const response = await fetch(TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }) + if (response.ok) { + const json = (await response.json()) as { + access_token: string + refresh_token?: string + expires_in: number + scope: string + } + const tokens: TokenSet = { + access_token: json.access_token, + refresh_token: json.refresh_token, + expires_at: Date.now() + (json.expires_in - 60) * 1000, + scope: json.scope, + } + await this.saveTokens(tokens) + return tokens + } + const err = (await response.json()) as { error?: string; error_description?: string } + if (err.error === 'authorization_pending') continue + if (err.error === 'slow_down') continue + throw new Error( + `Microsoft device-code completion failed: ${err.error ?? response.status}: ${err.error_description ?? ''}`, + ) + } + throw new Error('Microsoft device-code login timed out.') + } + + /** + * Return a non-expired access token, refreshing or surfacing a + * `NotAuthenticated` error if necessary. + */ + async getAccessToken(): Promise { + const tokens = await this.loadTokens() + if (!tokens) { + throw new NotAuthenticatedError( + 'No Microsoft tokens cached. Run agentmark_microsoft_login first.', + ) + } + if (Date.now() < tokens.expires_at) { + return tokens.access_token + } + if (!tokens.refresh_token) { + throw new NotAuthenticatedError( + 'Access token expired and no refresh token available. Re-run agentmark_microsoft_login.', + ) + } + const refreshed = await this.refresh(tokens.refresh_token) + return refreshed.access_token + } + + /** Drop the cached token file and in-memory cache. */ + async clear(): Promise { + this.cached = null + await unlink(this.cachePath).catch(() => {}) + } + + /** Whether a token file currently exists on disk. */ + async hasTokens(): Promise { + return (await this.loadTokens()) !== null + } + + private async refresh(refreshToken: string): Promise { + this.requireClientId() + const body = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.clientId, + refresh_token: refreshToken, + scope: this.scopes.join(' '), + }) + const response = await fetch(TOKEN_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }) + if (!response.ok) { + const text = await response.text() + throw new NotAuthenticatedError( + `Microsoft token refresh failed (${response.status}). Re-run agentmark_microsoft_login. Detail: ${text}`, + ) + } + const json = (await response.json()) as { + access_token: string + refresh_token?: string + expires_in: number + scope: string + } + const tokens: TokenSet = { + access_token: json.access_token, + refresh_token: json.refresh_token ?? refreshToken, + expires_at: Date.now() + (json.expires_in - 60) * 1000, + scope: json.scope, + } + await this.saveTokens(tokens) + return tokens + } + + private async loadTokens(): Promise { + if (this.cached) return this.cached + try { + const raw = await readFile(this.cachePath, 'utf8') + const parsed = JSON.parse(raw) as TokenSet + this.cached = parsed + return parsed + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null + throw err + } + } + + private async saveTokens(tokens: TokenSet): Promise { + this.cached = tokens + await mkdir(path.dirname(this.cachePath), { recursive: true }) + await writeFile(this.cachePath, JSON.stringify(tokens, null, 2), { encoding: 'utf8' }) + await chmod(this.cachePath, 0o600).catch(() => { + // Windows ACLs swallow chmod; not fatal. + }) + } + + private requireClientId(): void { + if (!this.clientId) { + throw new Error( + 'Microsoft Graph client ID is not set. ' + + 'Register an Azure AD app and pass `clientId` to createMicrosoftPlugin() ' + + 'or set the AGENTMARK_MS_CLIENT_ID environment variable.', + ) + } + } +} + +export class NotAuthenticatedError extends Error { + constructor(message: string) { + super(message) + this.name = 'NotAuthenticatedError' + } +} + +function defaultCachePath(): string { + return path.join(os.homedir(), '.thinkfleet', 'agentmark', 'microsoft-tokens.json') +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/plugins/microsoft/graph-client.ts b/src/plugins/microsoft/graph-client.ts new file mode 100644 index 0000000..e73ae52 --- /dev/null +++ b/src/plugins/microsoft/graph-client.ts @@ -0,0 +1,151 @@ +/** + * Thin Microsoft Graph HTTP client. + * + * Only the verbs the v0 pack uses (GET / POST / PATCH / DELETE / PUT-bytes). + * Each call acquires a fresh access token from MicrosoftAuth, attaches + * the bearer header, and parses the response. On 401 we retry exactly + * once after a token refresh, since cached tokens can be invalidated + * server-side (admin revocation, password change, etc). + */ +import { MicrosoftAuth, NotAuthenticatedError } from './auth' + +const GRAPH_BASE = 'https://graph.microsoft.com/v1.0' + +export interface GraphRequestOptions { + /** Query-string parameters, automatically encoded. */ + query?: Record + /** JSON body (will be stringified). Use `bytes` for raw upload bodies. */ + body?: unknown + /** Raw bytes body (e.g. PUT /content for file uploads). */ + bytes?: Uint8Array + /** Override the Content-Type (default: application/json for body, application/octet-stream for bytes). */ + contentType?: string + /** Extra request headers. */ + headers?: Record +} + +export class GraphClient { + constructor(private readonly auth: MicrosoftAuth) {} + + get(p: string, options?: GraphRequestOptions): Promise { + return this.request('GET', p, options) + } + + post(p: string, options?: GraphRequestOptions): Promise { + return this.request('POST', p, options) + } + + patch(p: string, options?: GraphRequestOptions): Promise { + return this.request('PATCH', p, options) + } + + delete(p: string, options?: GraphRequestOptions): Promise { + return this.request('DELETE', p, options) + } + + put(p: string, options?: GraphRequestOptions): Promise { + return this.request('PUT', p, options) + } + + /** GET that returns raw bytes (e.g. `/content` endpoints). */ + async getBytes(p: string, options?: GraphRequestOptions): Promise { + const url = buildUrl(p, options?.query) + const token = await this.auth.getAccessToken() + const response = await fetch(url, { + method: 'GET', + headers: { authorization: `Bearer ${token}`, ...(options?.headers ?? {}) }, + }) + if (!response.ok) throw await graphError(response) + const buffer = await response.arrayBuffer() + return new Uint8Array(buffer) + } + + private async request( + method: string, + p: string, + options: GraphRequestOptions = {}, + retried = false, + ): Promise { + const url = buildUrl(p, options.query) + const token = await this.auth.getAccessToken() + const headers: Record = { + authorization: `Bearer ${token}`, + ...(options.headers ?? {}), + } + let body: BodyInit | undefined + if (options.bytes !== undefined) { + // Node's undici-based fetch accepts Uint8Array; the dom-lib + // type for BodyInit is overly narrow, so we cast. + body = options.bytes as unknown as BodyInit + headers['content-type'] = options.contentType ?? 'application/octet-stream' + } else if (options.body !== undefined) { + body = JSON.stringify(options.body) + headers['content-type'] = options.contentType ?? 'application/json' + } + + const response = await fetch(url, { method, headers, body }) + + if (response.status === 401 && !retried) { + // Force a fresh access token and retry once. + await this.auth.clear() + // The retry will re-trigger NotAuthenticatedError if the + // refresh token is also dead — which is the right surface. + return this.request(method, p, options, true) + } + if (!response.ok) throw await graphError(response) + + // 204 No Content + if (response.status === 204) return undefined as T + const text = await response.text() + if (!text) return undefined as T + try { + return JSON.parse(text) as T + } catch { + // Some endpoints (e.g. ranged downloads) return non-JSON. + return text as unknown as T + } + } +} + +export class GraphError extends Error { + constructor( + message: string, + readonly status: number, + readonly graphCode?: string, + ) { + super(message) + this.name = 'GraphError' + } +} + +async function graphError(response: Response): Promise { + const text = await response.text() + if (response.status === 401) { + return new NotAuthenticatedError(`Microsoft Graph rejected the token: ${text}`) + } + try { + const parsed = JSON.parse(text) as { error?: { code?: string; message?: string } } + const code = parsed.error?.code + const message = parsed.error?.message ?? text + return new GraphError( + `Microsoft Graph ${response.status} ${code ?? ''}: ${message}`.trim(), + response.status, + code, + ) + } catch { + return new GraphError(`Microsoft Graph ${response.status}: ${text}`, response.status) + } +} + +function buildUrl(p: string, query?: Record): string { + const base = p.startsWith('http') ? p : `${GRAPH_BASE}${p.startsWith('/') ? p : '/' + p}` + if (!query) return base + const qs = new URLSearchParams() + for (const [k, v] of Object.entries(query)) { + if (v === undefined) continue + qs.append(k, String(v)) + } + const queryString = qs.toString() + if (!queryString) return base + return base.includes('?') ? `${base}&${queryString}` : `${base}?${queryString}` +} diff --git a/src/plugins/microsoft/index.ts b/src/plugins/microsoft/index.ts new file mode 100644 index 0000000..c048bf0 --- /dev/null +++ b/src/plugins/microsoft/index.ts @@ -0,0 +1,67 @@ +/** + * Microsoft Workflows Pack — v0 (Graph-only surface). + * + * Cross-platform by construction: every tool here hits Microsoft Graph + * over HTTPS, so the same code path runs on Windows and macOS. Native + * Office drivers (Excel-COM on Windows, AppleScript on macOS) ship in + * a follow-up pack. + * + * Tools shipped: + * - agentmark_microsoft_login / logout / whoami + * - agentmark_outlook_send_email / search / get_message / reply + * - agentmark_onedrive_list / upload / download + * + * Auth model: OAuth 2.0 device-code flow. The user is prompted with a + * short code + URL, completes login in a browser, and tokens are cached + * to ~/.thinkfleet/agentmark/microsoft-tokens.json (mode 0600). Refresh + * tokens rotate automatically on access-token expiry. + * + * Setup: + * 1. Register an Azure AD app at https://entra.microsoft.com + * - Account types: "Accounts in any organizational directory and personal Microsoft accounts" + * - Add "Allow public client flows": Yes (for device-code) + * - API permissions (delegated): Mail.Send, Mail.ReadWrite, + * Files.ReadWrite, offline_access, User.Read + * 2. Either set AGENTMARK_MS_CLIENT_ID environment variable, or pass + * `clientId` when creating the plugin. + */ +import { MicrosoftAuth, type MicrosoftAuthConfig } from './auth' +import { GraphClient } from './graph-client' +import { MICROSOFT_TOOLS } from './tool-defs' +import { buildOutlookHandlers } from './tools/outlook' +import { buildOneDriveHandlers } from './tools/onedrive' +import { buildAuthHandlers } from './tools/auth-tools' +import type { AgentMarkPlugin } from '../../mcp/plugin' + +export interface MicrosoftPluginConfig extends MicrosoftAuthConfig {} + +export function createMicrosoftPlugin(config: MicrosoftPluginConfig = {}): AgentMarkPlugin { + const auth = new MicrosoftAuth(config) + const graph = new GraphClient(auth) + + const handlers = { + ...buildAuthHandlers(auth, graph), + ...buildOutlookHandlers(graph), + ...buildOneDriveHandlers(graph), + } + + return { + name: 'microsoft', + version: '0.1.0', + tools: MICROSOFT_TOOLS, + handlers, + describeSessions: () => ({ + microsoft: { + client_id_set: !!auth.clientId, + scopes: auth.scopes, + }, + }), + } +} + +// Re-export the building blocks so packs that want a custom subset can +// compose their own plugin. +export { MicrosoftAuth, NotAuthenticatedError } from './auth' +export { GraphClient, GraphError } from './graph-client' +export type { MicrosoftAuthConfig, TokenSet, DeviceCodeStartResponse } from './auth' +export { MICROSOFT_TOOLS } from './tool-defs' diff --git a/src/plugins/microsoft/tool-defs.ts b/src/plugins/microsoft/tool-defs.ts new file mode 100644 index 0000000..1901e58 --- /dev/null +++ b/src/plugins/microsoft/tool-defs.ts @@ -0,0 +1,198 @@ +/** + * Microsoft Workflows Pack — tool definitions. + * + * v0 surface: device-code login, Outlook (send/search/reply/get_message), + * OneDrive (list/upload/download). All universal — works identically on + * Windows and macOS because they hit Microsoft Graph rather than native + * COM/AppleScript bindings. (Excel + Word drivers ship in PR B.) + */ +import type { McpToolDef } from '../../mcp/tool-defs' + +export const MICROSOFT_TOOLS: McpToolDef[] = [ + // ── Auth ───────────────────────────────────────────────────────────── + { + name: 'agentmark_microsoft_login', + description: + 'Initiate Microsoft Graph device-code login. Returns a short ' + + 'user_code + verification_uri to surface to the human user. ' + + 'When `wait=true` (default), the tool polls until login ' + + 'completes and returns the resulting token state. When ' + + '`wait=false`, returns immediately so a UI can render the ' + + 'code itself and call again later. Tokens are persisted to ' + + '~/.thinkfleet/agentmark/microsoft-tokens.json (0600).', + inputSchema: { + type: 'object', + properties: { + wait: { + type: 'boolean', + description: 'Block until the user completes login (default: true).', + }, + }, + }, + }, + { + name: 'agentmark_microsoft_logout', + description: 'Clear the cached Microsoft Graph tokens (forces a fresh login).', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'agentmark_microsoft_whoami', + description: + 'Return basic profile info for the currently authenticated ' + + 'Microsoft Graph user (id, displayName, userPrincipalName, mail). ' + + 'Useful for confirming which account a session is operating against.', + inputSchema: { type: 'object', properties: {} }, + }, + + // ── Outlook ────────────────────────────────────────────────────────── + { + name: 'agentmark_outlook_send_email', + description: + 'Send an email via Outlook (Microsoft Graph). Body may be plain ' + + 'text or HTML. Recipients are arrays; pass strings for simple ' + + 'addresses or {address, name} objects for display-name overrides. ' + + 'Optional CC/BCC. Attachments are inline base64 byte arrays.', + inputSchema: { + type: 'object', + properties: { + to: { + type: 'array', + items: { type: 'string' }, + description: 'Recipient email addresses.', + }, + subject: { type: 'string' }, + body: { type: 'string', description: 'Message body (HTML or plain text — see body_type).' }, + body_type: { + type: 'string', + enum: ['html', 'text'], + description: 'Body content type. Default: html.', + }, + cc: { type: 'array', items: { type: 'string' } }, + bcc: { type: 'array', items: { type: 'string' } }, + save_to_sent_items: { + type: 'boolean', + description: 'Persist a copy in Sent Items. Default: true.', + }, + }, + required: ['to', 'subject', 'body'], + }, + }, + { + name: 'agentmark_outlook_search', + description: + 'Search the authenticated user\'s mailbox. Uses Graph KQL ' + + '(e.g. `from:alice@x.com AND subject:invoice`). Returns up ' + + 'to `top` message summaries with id, subject, from, ' + + 'receivedDateTime, hasAttachments, previewBody.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'KQL query.' }, + top: { type: 'number', description: 'Max messages to return (default 25, max 100).' }, + folder: { + type: 'string', + description: + 'Restrict to a folder ID or well-known folder name ' + + '(inbox, sentItems, drafts, etc). Default: inbox.', + }, + }, + required: ['query'], + }, + }, + { + name: 'agentmark_outlook_get_message', + description: + 'Fetch a single message by id, including body. Returns subject, ' + + 'from, to, cc, body content + content type, attachment list.', + inputSchema: { + type: 'object', + properties: { + message_id: { type: 'string' }, + }, + required: ['message_id'], + }, + }, + { + name: 'agentmark_outlook_reply', + description: + 'Reply to an existing message by id. Sends to the original ' + + 'sender (and CC list if reply_all=true). Body is HTML or text ' + + '— see body_type.', + inputSchema: { + type: 'object', + properties: { + message_id: { type: 'string' }, + body: { type: 'string' }, + body_type: { + type: 'string', + enum: ['html', 'text'], + description: 'Body content type. Default: html.', + }, + reply_all: { + type: 'boolean', + description: 'Send to original sender + CC list. Default: false.', + }, + }, + required: ['message_id', 'body'], + }, + }, + + // ── OneDrive ───────────────────────────────────────────────────────── + { + name: 'agentmark_onedrive_list', + description: + 'List the contents of a OneDrive folder. Pass a slash-prefixed ' + + 'path (e.g. "/Documents/Invoices") or omit `path` to list the ' + + 'root. Returns name, id, kind (file|folder), size, modified time.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Folder path within the drive (omit for root).', + }, + top: { type: 'number', description: 'Max items to return (default 200).' }, + }, + }, + }, + { + name: 'agentmark_onedrive_upload', + description: + 'Upload a local file to OneDrive. For files under ~4MB this is ' + + 'a single PUT; larger files use a resumable upload session. ' + + 'Returns the resulting OneDrive item id + webUrl.', + inputSchema: { + type: 'object', + properties: { + local_path: { type: 'string', description: 'Absolute path on the local filesystem.' }, + remote_path: { + type: 'string', + description: 'OneDrive target path (e.g. "/Documents/report.pdf").', + }, + conflict_behavior: { + type: 'string', + enum: ['rename', 'replace', 'fail'], + description: 'How to handle existing files. Default: replace.', + }, + }, + required: ['local_path', 'remote_path'], + }, + }, + { + name: 'agentmark_onedrive_download', + description: + 'Download a OneDrive file to a local path. Returns the local ' + + 'path + byte count.', + inputSchema: { + type: 'object', + properties: { + remote_path: { + type: 'string', + description: 'OneDrive source path (e.g. "/Documents/report.pdf").', + }, + local_path: { type: 'string', description: 'Where to write the file locally.' }, + }, + required: ['remote_path', 'local_path'], + }, + }, +] diff --git a/src/plugins/microsoft/tools/auth-tools.ts b/src/plugins/microsoft/tools/auth-tools.ts new file mode 100644 index 0000000..2af455d --- /dev/null +++ b/src/plugins/microsoft/tools/auth-tools.ts @@ -0,0 +1,75 @@ +/** + * Auth-related tool handlers (login / logout / whoami). + * + * `login` runs the device-code flow inline by default — the AI gets back + * the user_code + verification_uri to show, then the handler polls until + * the user finishes. Pass wait=false for UIs that want to render the code + * themselves and orchestrate completion separately. + */ +import type { ToolHandler, DispatchResult } from '../../../mcp/plugin' +import type { GraphClient } from '../graph-client' +import type { MicrosoftAuth } from '../auth' + +interface UserProfile { + id: string + displayName?: string + userPrincipalName?: string + mail?: string +} + +export function buildAuthHandlers(auth: MicrosoftAuth, graph: GraphClient): Record { + return { + agentmark_microsoft_login: async (args): Promise => { + const wait = args.wait !== false + const start = await auth.startDeviceCode() + + const instructions = { + verification_uri: start.verification_uri, + user_code: start.user_code, + expires_in_seconds: start.expires_in, + message: start.message, + } + + if (!wait) { + return { + text: JSON.stringify({ + status: 'pending', + instructions, + note: 'Call agentmark_microsoft_login again (with wait=true) once the user has finished.', + }, null, 2), + } + } + + // Tell the user up-front via the response. The AI surfaces this + // immediately; polling happens in the background. + const tokens = await auth.completeDeviceCode(start) + return { + text: JSON.stringify({ + status: 'authenticated', + instructions, // include for transcript completeness + expires_at: new Date(tokens.expires_at).toISOString(), + scopes: tokens.scope.split(' '), + }, null, 2), + } + }, + + agentmark_microsoft_logout: async (): Promise => { + await auth.clear() + return { text: JSON.stringify({ status: 'logged_out' }, null, 2) } + }, + + agentmark_microsoft_whoami: async (): Promise => { + const me = await graph.get('/me', { + query: { $select: 'id,displayName,userPrincipalName,mail' }, + }) + return { + text: JSON.stringify({ + id: me.id, + display_name: me.displayName, + user_principal_name: me.userPrincipalName, + mail: me.mail, + }, null, 2), + } + }, + } +} diff --git a/src/plugins/microsoft/tools/onedrive.ts b/src/plugins/microsoft/tools/onedrive.ts new file mode 100644 index 0000000..84eb969 --- /dev/null +++ b/src/plugins/microsoft/tools/onedrive.ts @@ -0,0 +1,185 @@ +/** + * OneDrive tool handlers — list, upload, download. + * + * Uploads under ~4MB use a single PUT; larger files use a Graph upload + * session (chunked, resumable). Downloads stream via /content. + */ +import { readFile, stat, writeFile, mkdir } from 'node:fs/promises' +import * as path from 'node:path' +import type { ToolHandler, DispatchResult } from '../../../mcp/plugin' +import type { GraphClient } from '../graph-client' + +const SMALL_FILE_THRESHOLD = 4 * 1024 * 1024 // 4 MB +const UPLOAD_CHUNK_SIZE = 5 * 1024 * 1024 // 5 MB — must be a multiple of 320 KiB per Graph docs + +interface DriveItem { + id: string + name: string + size?: number + lastModifiedDateTime?: string + webUrl?: string + folder?: { childCount?: number } + file?: { mimeType?: string } +} + +export function buildOneDriveHandlers(graph: GraphClient): Record { + return { + agentmark_onedrive_list: async (args): Promise => { + const targetPath = typeof args.path === 'string' ? args.path : undefined + const top = clamp(typeof args.top === 'number' ? args.top : 200, 1, 999) + const url = targetPath + ? `/me/drive/root:${encodePath(targetPath)}:/children` + : '/me/drive/root/children' + + const result = await graph.get<{ value: DriveItem[] }>(url, { + query: { + $top: top, + $select: 'id,name,size,lastModifiedDateTime,webUrl,folder,file', + }, + }) + + const items = (result.value ?? []).map((it) => ({ + id: it.id, + name: it.name, + kind: it.folder ? 'folder' : 'file', + size: it.size ?? 0, + modified: it.lastModifiedDateTime, + mime_type: it.file?.mimeType, + child_count: it.folder?.childCount, + web_url: it.webUrl, + })) + + return { text: JSON.stringify({ count: items.length, items }, null, 2) } + }, + + agentmark_onedrive_upload: async (args): Promise => { + const localPath = path.resolve(requireString(args, 'local_path')) + const remotePath = requireString(args, 'remote_path') + const conflict = (args.conflict_behavior as string | undefined) ?? 'replace' + if (!['rename', 'replace', 'fail'].includes(conflict)) { + return { + text: `conflict_behavior must be one of rename|replace|fail; got "${conflict}"`, + isError: true, + } + } + + const info = await stat(localPath) + if (!info.isFile()) { + return { text: `local_path is not a file: ${localPath}`, isError: true } + } + + let item: DriveItem + if (info.size <= SMALL_FILE_THRESHOLD) { + const bytes = new Uint8Array(await readFile(localPath)) + item = await graph.put( + `/me/drive/root:${encodePath(remotePath)}:/content`, + { + bytes, + query: { '@microsoft.graph.conflictBehavior': conflict }, + }, + ) + } else { + item = await uploadLargeFile(graph, localPath, remotePath, info.size, conflict) + } + + return { + text: JSON.stringify({ + uploaded: true, + id: item.id, + name: item.name, + size: item.size ?? info.size, + web_url: item.webUrl, + }, null, 2), + } + }, + + agentmark_onedrive_download: async (args): Promise => { + const remotePath = requireString(args, 'remote_path') + const localPath = path.resolve(requireString(args, 'local_path')) + const bytes = await graph.getBytes(`/me/drive/root:${encodePath(remotePath)}:/content`) + await mkdir(path.dirname(localPath), { recursive: true }) + await writeFile(localPath, bytes) + return { + text: JSON.stringify({ + downloaded: true, + local_path: localPath, + bytes: bytes.length, + }, null, 2), + } + }, + } +} + +async function uploadLargeFile( + graph: GraphClient, + localPath: string, + remotePath: string, + totalSize: number, + conflict: string, +): Promise { + // Step 1: create the upload session. + const session = await graph.post<{ uploadUrl: string }>( + `/me/drive/root:${encodePath(remotePath)}:/createUploadSession`, + { + body: { + item: { + '@microsoft.graph.conflictBehavior': conflict, + name: path.basename(remotePath), + }, + }, + }, + ) + + // Step 2: upload chunks. The session URL is pre-authenticated; do not + // attach the bearer token, per Graph docs. + const data = await readFile(localPath) + let offset = 0 + let finalItem: DriveItem | null = null + + while (offset < totalSize) { + const end = Math.min(offset + UPLOAD_CHUNK_SIZE, totalSize) + const chunk = data.subarray(offset, end) + const response = await fetch(session.uploadUrl, { + method: 'PUT', + headers: { + 'content-length': String(chunk.length), + 'content-range': `bytes ${offset}-${end - 1}/${totalSize}`, + }, + // Node's undici accepts Buffer; dom-lib BodyInit type is narrow. + body: chunk as unknown as BodyInit, + }) + if (!response.ok) { + throw new Error( + `Chunk upload failed at offset ${offset}: ${response.status} ${await response.text()}`, + ) + } + // The final chunk responds with the DriveItem; intermediate chunks + // respond with an upload-progress structure we don't need to inspect. + if (end === totalSize) { + finalItem = (await response.json()) as DriveItem + } + offset = end + } + + if (!finalItem) throw new Error('Upload completed without a final response item.') + return finalItem +} + +function encodePath(p: string): string { + // OneDrive uses "/drive/root:/path/to/file" — the path after the colon + // must be URL-encoded segment-by-segment, with leading slash. + const cleaned = p.startsWith('/') ? p : '/' + p + return cleaned.split('/').map(encodeURIComponent).join('/') +} + +function requireString(args: Record, key: string): string { + const v = args[key] + if (typeof v !== 'string' || v.length === 0) { + throw new Error(`Missing required argument: ${key}`) + } + return v +} + +function clamp(n: number, min: number, max: number): number { + return Math.max(min, Math.min(max, n)) +} diff --git a/src/plugins/microsoft/tools/outlook.ts b/src/plugins/microsoft/tools/outlook.ts new file mode 100644 index 0000000..3e9f8e2 --- /dev/null +++ b/src/plugins/microsoft/tools/outlook.ts @@ -0,0 +1,157 @@ +/** + * Outlook tool handlers — send, search, get_message, reply. + * + * All via Microsoft Graph; works identically on Windows + macOS. + */ +import type { ToolHandler, DispatchResult } from '../../../mcp/plugin' +import type { GraphClient } from '../graph-client' + +interface MessageSummary { + id: string + subject?: string + bodyPreview?: string + from?: { emailAddress: { address: string; name?: string } } + receivedDateTime?: string + hasAttachments?: boolean +} + +interface MessageDetail extends MessageSummary { + toRecipients?: Array<{ emailAddress: { address: string; name?: string } }> + ccRecipients?: Array<{ emailAddress: { address: string; name?: string } }> + body?: { contentType?: string; content?: string } +} + +export function buildOutlookHandlers(graph: GraphClient): Record { + return { + agentmark_outlook_send_email: async (args): Promise => { + const to = requireStringArray(args, 'to') + const subject = requireString(args, 'subject') + const body = requireString(args, 'body') + const bodyType = args.body_type === 'text' ? 'Text' : 'HTML' + const cc = optionalStringArray(args, 'cc') + const bcc = optionalStringArray(args, 'bcc') + const saveToSent = args.save_to_sent_items !== false + + await graph.post('/me/sendMail', { + body: { + message: { + subject, + body: { contentType: bodyType, content: body }, + toRecipients: to.map(toRecipient), + ...(cc ? { ccRecipients: cc.map(toRecipient) } : {}), + ...(bcc ? { bccRecipients: bcc.map(toRecipient) } : {}), + }, + saveToSentItems: saveToSent, + }, + }) + + return { + text: JSON.stringify({ + sent: true, + to, + subject, + saved_to_sent_items: saveToSent, + }, null, 2), + } + }, + + agentmark_outlook_search: async (args): Promise => { + const query = requireString(args, 'query') + const top = clamp(typeof args.top === 'number' ? args.top : 25, 1, 100) + const folder = typeof args.folder === 'string' ? args.folder : 'inbox' + + const result = await graph.get<{ value: MessageSummary[] }>( + `/me/mailFolders/${encodeURIComponent(folder)}/messages`, + { + query: { + $search: `"${query.replace(/"/g, '\\"')}"`, + $top: top, + $select: 'id,subject,bodyPreview,from,receivedDateTime,hasAttachments', + }, + }, + ) + + const messages = (result.value ?? []).map((m) => ({ + id: m.id, + subject: m.subject ?? '', + from: m.from?.emailAddress.address ?? '', + from_name: m.from?.emailAddress.name ?? '', + received: m.receivedDateTime, + has_attachments: !!m.hasAttachments, + preview: m.bodyPreview ?? '', + })) + + return { text: JSON.stringify({ count: messages.length, messages }, null, 2) } + }, + + agentmark_outlook_get_message: async (args): Promise => { + const id = requireString(args, 'message_id') + const m = await graph.get(`/me/messages/${encodeURIComponent(id)}`, { + query: { + $select: 'id,subject,from,toRecipients,ccRecipients,body,hasAttachments,receivedDateTime', + }, + }) + + return { + text: JSON.stringify({ + id: m.id, + subject: m.subject ?? '', + from: m.from?.emailAddress.address ?? '', + to: (m.toRecipients ?? []).map((r) => r.emailAddress.address), + cc: (m.ccRecipients ?? []).map((r) => r.emailAddress.address), + received: m.receivedDateTime, + has_attachments: !!m.hasAttachments, + body_type: m.body?.contentType ?? '', + body: m.body?.content ?? '', + }, null, 2), + } + }, + + agentmark_outlook_reply: async (args): Promise => { + const id = requireString(args, 'message_id') + const body = requireString(args, 'body') + const bodyType = args.body_type === 'text' ? 'Text' : 'HTML' + const replyAll = args.reply_all === true + + const endpoint = replyAll ? 'replyAll' : 'reply' + await graph.post(`/me/messages/${encodeURIComponent(id)}/${endpoint}`, { + body: { + message: { body: { contentType: bodyType, content: body } }, + }, + }) + + return { + text: JSON.stringify({ replied: true, message_id: id, reply_all: replyAll }, null, 2), + } + }, + } +} + +function toRecipient(address: string): { emailAddress: { address: string } } { + return { emailAddress: { address } } +} + +function requireString(args: Record, key: string): string { + const v = args[key] + if (typeof v !== 'string' || v.length === 0) { + throw new Error(`Missing required argument: ${key}`) + } + return v +} + +function requireStringArray(args: Record, key: string): string[] { + const v = args[key] + if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) { + throw new Error(`Argument ${key} must be an array of strings.`) + } + return v as string[] +} + +function optionalStringArray(args: Record, key: string): string[] | undefined { + if (args[key] === undefined) return undefined + return requireStringArray(args, key) +} + +function clamp(n: number, min: number, max: number): number { + return Math.max(min, Math.min(max, n)) +} diff --git a/test/microsoft/microsoft-plugin.test.ts b/test/microsoft/microsoft-plugin.test.ts new file mode 100644 index 0000000..944b0a1 --- /dev/null +++ b/test/microsoft/microsoft-plugin.test.ts @@ -0,0 +1,284 @@ +/** + * Tests for the Microsoft Workflows Pack (Graph-only v0). + * + * We mock `globalThis.fetch` so the tests run offline and deterministically. + * The auth flow is exercised end-to-end (device code → poll → token cache + * → refresh) against the mocked endpoints. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import * as os from 'node:os' +import * as path from 'node:path' +import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises' +import { + createMicrosoftPlugin, + MicrosoftAuth, + MICROSOFT_TOOLS, + NotAuthenticatedError, +} from '../../src/plugins/microsoft' +import { Dispatcher } from '../../src/mcp/plugin' + +let originalFetch: typeof globalThis.fetch +let tmpDir: string +let cachePath: string + +beforeEach(async () => { + originalFetch = globalThis.fetch + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'agentmark-ms-test-')) + cachePath = path.join(tmpDir, 'tokens.json') +}) + +afterEach(async () => { + globalThis.fetch = originalFetch + await rm(tmpDir, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +function mockFetch(responder: (url: string, init?: RequestInit) => Response | Promise) { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL | Request).toString() + return Promise.resolve(responder(url, init)) + }) as typeof globalThis.fetch +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('MicrosoftAuth — device code flow', () => { + it('starts a device-code session and surfaces the user instructions', async () => { + mockFetch((url) => { + if (url.includes('devicecode')) { + return jsonResponse({ + user_code: 'ABC-123', + device_code: 'dc_token', + verification_uri: 'https://microsoft.com/devicelogin', + expires_in: 900, + interval: 5, + message: 'To sign in, use a web browser to open the page.', + }) + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const auth = new MicrosoftAuth({ clientId: 'test-client', cachePath }) + const start = await auth.startDeviceCode() + expect(start.user_code).toBe('ABC-123') + expect(start.verification_uri).toBe('https://microsoft.com/devicelogin') + }) + + it('refuses to start without a client id', async () => { + const auth = new MicrosoftAuth({ clientId: '', cachePath }) + await expect(auth.startDeviceCode()).rejects.toThrow(/client ID is not set/i) + }) + + it('polls the token endpoint and caches tokens on completion', async () => { + let poll = 0 + mockFetch((url) => { + if (url.includes('/oauth2/v2.0/token')) { + poll += 1 + if (poll < 2) { + return jsonResponse({ error: 'authorization_pending' }, 400) + } + return jsonResponse({ + access_token: 'access-1', + refresh_token: 'refresh-1', + expires_in: 3600, + scope: 'Mail.Send Files.ReadWrite', + }) + } + throw new Error(`Unexpected URL: ${url}`) + }) + + const auth = new MicrosoftAuth({ clientId: 'test', cachePath }) + const tokens = await auth.completeDeviceCode({ + user_code: 'X', + device_code: 'dc', + verification_uri: 'https://x', + expires_in: 60, + // Use a short interval so the test runs quickly. + interval: 0, + message: '', + }) + expect(tokens.access_token).toBe('access-1') + expect(tokens.refresh_token).toBe('refresh-1') + + const saved = JSON.parse(await readFile(cachePath, 'utf8')) + expect(saved.access_token).toBe('access-1') + }) + + it('returns a cached access token when non-expired', async () => { + await writeFile( + cachePath, + JSON.stringify({ + access_token: 'cached-access', + refresh_token: 'cached-refresh', + expires_at: Date.now() + 5 * 60_000, + scope: 'Mail.Send', + }), + ) + const auth = new MicrosoftAuth({ clientId: 'test', cachePath }) + const token = await auth.getAccessToken() + expect(token).toBe('cached-access') + }) + + it('refreshes when the cached token is expired', async () => { + await writeFile( + cachePath, + JSON.stringify({ + access_token: 'old-access', + refresh_token: 'refresh-token', + expires_at: Date.now() - 1000, + scope: 'Mail.Send', + }), + ) + mockFetch((url, init) => { + if (url.includes('/oauth2/v2.0/token')) { + const body = (init?.body as URLSearchParams).toString() + expect(body).toContain('grant_type=refresh_token') + expect(body).toContain('refresh_token=refresh-token') + return jsonResponse({ + access_token: 'fresh-access', + refresh_token: 'rotated-refresh', + expires_in: 3600, + scope: 'Mail.Send', + }) + } + throw new Error(`Unexpected URL: ${url}`) + }) + const auth = new MicrosoftAuth({ clientId: 'test', cachePath }) + const token = await auth.getAccessToken() + expect(token).toBe('fresh-access') + }) + + it('throws NotAuthenticatedError when no tokens are cached', async () => { + const auth = new MicrosoftAuth({ clientId: 'test', cachePath }) + await expect(auth.getAccessToken()).rejects.toBeInstanceOf(NotAuthenticatedError) + }) +}) + +describe('Microsoft plugin — registration', () => { + it('registers every tool from MICROSOFT_TOOLS with a matching handler', () => { + const plugin = createMicrosoftPlugin({ clientId: 'test', cachePath }) + // Dispatcher construction validates the handler-vs-tools contract; + // if any tool is missing a handler this throws. + const dispatcher = new Dispatcher([plugin]) + expect(dispatcher.toolNames).toEqual(MICROSOFT_TOOLS.map((t) => t.name)) + }) + + it('exposes the expected v0 tool names', () => { + const names = MICROSOFT_TOOLS.map((t) => t.name).sort() + expect(names).toEqual([ + 'agentmark_microsoft_login', + 'agentmark_microsoft_logout', + 'agentmark_microsoft_whoami', + 'agentmark_onedrive_download', + 'agentmark_onedrive_list', + 'agentmark_onedrive_upload', + 'agentmark_outlook_get_message', + 'agentmark_outlook_reply', + 'agentmark_outlook_search', + 'agentmark_outlook_send_email', + ]) + }) + + it('describeSessions reports the configured scopes + client id state', () => { + const plugin = createMicrosoftPlugin({ clientId: 'test', cachePath, scopes: ['Mail.Send'] }) + const info = plugin.describeSessions?.() + expect(info).toEqual({ + microsoft: { client_id_set: true, scopes: ['Mail.Send'] }, + }) + }) +}) + +describe('Microsoft plugin — Outlook handlers (via Graph mock)', () => { + async function loginFirst(): Promise { + await writeFile( + cachePath, + JSON.stringify({ + access_token: 'fake-access', + refresh_token: 'fake-refresh', + expires_at: Date.now() + 60 * 60_000, + scope: 'Mail.Send', + }), + ) + } + + it('agentmark_outlook_send_email posts to /me/sendMail with the expected envelope', async () => { + await loginFirst() + let captured: { url: string; body: unknown } | null = null + mockFetch((url, init) => { + captured = { url, body: JSON.parse(init!.body as string) } + return new Response(null, { status: 202 }) + }) + + const plugin = createMicrosoftPlugin({ clientId: 'test', cachePath }) + const dispatcher = new Dispatcher([plugin]) + const result = await dispatcher.dispatch('agentmark_outlook_send_email', { + to: ['user@example.com'], + subject: 'Test', + body: '

hi

', + }) + expect(result.isError).toBeFalsy() + expect(captured).not.toBeNull() + expect(captured!.url).toMatch(/\/me\/sendMail$/) + const envelope = captured!.body as { message: { subject: string; toRecipients: unknown[] } } + expect(envelope.message.subject).toBe('Test') + expect(envelope.message.toRecipients).toEqual([ + { emailAddress: { address: 'user@example.com' } }, + ]) + }) + + it('agentmark_outlook_search returns a flattened message list', async () => { + await loginFirst() + mockFetch((url) => { + expect(url).toMatch(/\/me\/mailFolders\/inbox\/messages/) + expect(url).toContain('%24search') + return jsonResponse({ + value: [ + { + id: 'msg1', + subject: 'Hello', + from: { emailAddress: { address: 'alice@x.com', name: 'Alice' } }, + receivedDateTime: '2026-05-10T00:00:00Z', + hasAttachments: false, + bodyPreview: 'preview', + }, + ], + }) + }) + + const plugin = createMicrosoftPlugin({ clientId: 'test', cachePath }) + const dispatcher = new Dispatcher([plugin]) + const result = await dispatcher.dispatch('agentmark_outlook_search', { query: 'invoice' }) + expect(result.isError).toBeFalsy() + const body = JSON.parse(result.text) + expect(body.count).toBe(1) + expect(body.messages[0]).toMatchObject({ + id: 'msg1', + subject: 'Hello', + from: 'alice@x.com', + preview: 'preview', + }) + }) + + it('agentmark_outlook_reply hits /reply by default, /replyAll when asked', async () => { + await loginFirst() + const hits: string[] = [] + mockFetch((url) => { + hits.push(url) + return new Response(null, { status: 202 }) + }) + + const plugin = createMicrosoftPlugin({ clientId: 'test', cachePath }) + const dispatcher = new Dispatcher([plugin]) + + await dispatcher.dispatch('agentmark_outlook_reply', { message_id: 'abc', body: 'ack' }) + await dispatcher.dispatch('agentmark_outlook_reply', { message_id: 'abc', body: 'ack', reply_all: true }) + + expect(hits[0]).toMatch(/\/me\/messages\/abc\/reply$/) + expect(hits[1]).toMatch(/\/me\/messages\/abc\/replyAll$/) + }) +})