From 5710e898dbf9dd3e7fdc80ba2d0d85913ee26493 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 12:13:04 +0100 Subject: [PATCH 01/15] Add cross-platform M365 / Agent 365 HTTP MCP integration - Catalog of 21 Agent 365 MCP servers (scripts/m365-mcp-servers.json), populated from the live discoverToolServers endpoint with per-server url + scope baked in. URL pattern is /agents/servers/ (verified against discovery), not /agents/tenants//servers/. - Cross-platform setup scripts (TypeScript via tsx; run on Linux, macOS, WSL, Windows native, Git Bash): - scripts/setup-m365-app.ts: create / reuse / adopt single-tenant Entra app registration, declare per-server delegated scopes, attempt admin consent, persist state to ~/.hyperagent/m365.json. - scripts/m365-setup.ts: write one HTTP MCP entry per selected service into ~/.hyperagent/config.json with narrow per-server scopes (e.g. McpServers.Mail.All). - scripts/m365-refresh-servers.ts: refresh the catalog from the live discovery endpoint using a cached token. - scripts/m365-show.ts: print the saved app details. - scripts/mcp-add-http.ts: generic HTTP MCP entry writer (any vendor). - Justfile recipes are now plain delegates to tsx (no [unix] attribute, no inline bash) so they run identically across all supported shells. - src/agent/mcp/retry-fetch.ts: HTTP retry middleware (429/502/503/504 + network errors, exponential backoff, capped Retry-After) wired into StreamableHTTPClientTransport via the SDK's fetch option. - src/agent/mcp/session-cache.ts: Mcp-Session-Id persistence at ~/.hyperagent/mcp-sessions/.json so reconnects can reattach to an existing session; cleared on connect failure. - extractContent now handles three Agent 365 response shapes via the new extractEmbeddedJson helper: clean JSON, status-prefixed JSON (Calendar), and {rawResponse: '...'} wrappers (Mail). - 16 new tests covering retry-fetch behaviour and the embedded-JSON extraction patterns. Full suite: 2197 passing. --- Justfile | 87 ++- docs/MCP.md | 101 ++++ scripts/m365-mcp-servers.json | 108 ++++ scripts/m365-refresh-servers.ts | 219 ++++++++ scripts/m365-setup.ts | 231 ++++++++ scripts/m365-show.ts | 40 ++ scripts/mcp-add-http.ts | 112 ++++ scripts/setup-m365-app.ts | 649 ++++++++++++++++++++++ src/agent/index.ts | 8 +- src/agent/mcp/auth/browser-oauth.ts | 306 +++++++++++ src/agent/mcp/auth/token-cache.ts | 166 ++++++ src/agent/mcp/client-manager.ts | 389 +++++++++++--- src/agent/mcp/config.ts | 386 ++++++++++++- src/agent/mcp/retry-fetch.ts | 140 +++++ src/agent/mcp/session-cache.ts | 102 ++++ src/agent/mcp/types.ts | 161 +++++- src/agent/slash-commands.ts | 15 +- tests/mcp-extract-embedded-json.test.ts | 59 ++ tests/mcp-retry-fetch.test.ts | 99 ++++ tests/mcp.test.ts | 686 +++++++++++++++++++++++- 20 files changed, 3945 insertions(+), 119 deletions(-) create mode 100644 scripts/m365-mcp-servers.json create mode 100644 scripts/m365-refresh-servers.ts create mode 100644 scripts/m365-setup.ts create mode 100644 scripts/m365-show.ts create mode 100644 scripts/mcp-add-http.ts create mode 100644 scripts/setup-m365-app.ts create mode 100644 src/agent/mcp/auth/browser-oauth.ts create mode 100644 src/agent/mcp/auth/token-cache.ts create mode 100644 src/agent/mcp/retry-fetch.ts create mode 100644 src/agent/mcp/session-cache.ts create mode 100644 tests/mcp-extract-embedded-json.test.ts create mode 100644 tests/mcp-retry-fetch.test.ts diff --git a/Justfile b/Justfile index 897ac01..fc9a446 100644 --- a/Justfile +++ b/Justfile @@ -712,7 +712,12 @@ mcp-show-config: if (cfg.mcpServers) { console.log('Configured MCP servers:'); for (const [name, s] of Object.entries(cfg.mcpServers)) { - console.log(' ' + name + ': ' + (s.command || '?') + ' ' + (s.args || []).join(' ')); + if (s.type === 'http') { + const auth = s.auth ? ' [' + s.auth.method + ']' : ''; + console.log(' ' + name + ': ' + s.url + auth); + } else { + console.log(' ' + name + ': ' + (s.command || '?') + ' ' + (s.args || []).join(' ')); + } } } else { console.log('No MCP servers configured.'); @@ -787,3 +792,83 @@ mcp-setup-workiq: echo " /mcp enable workiq" echo "" echo " First tool call opens a browser for Microsoft sign-in." + +# ── Generic HTTP MCP server recipe ─────────────────────────────────── +# +# Adds a single HTTP MCP server entry to ~/.hyperagent/config.json. Used +# directly for ad-hoc HTTP MCP servers, and also called per-service by +# `mcp-setup-m365` below. +# +# Args: +# NAME Config key (becomes the alias for /mcp enable ). +# URL HTTPS endpoint of the MCP server. +# CLIENT_ID Optional. If set, OAuth (browser+PKCE) is configured. +# TENANT_ID Optional. Defaults to the auth-side default ('organizations'). +# SCOPES Optional, comma-separated. If empty + CLIENT_ID set, +# defaults to '/.default'. +# CALLBACK_PORT Optional. Defaults to 8080. +# +# Add an HTTP MCP server entry to ~/.hyperagent/config.json. Used by +# `mcp-setup-m365` and intended for direct use when wiring custom HTTP +# MCP servers (any vendor — not M365-specific). +# +# Examples: +# just mcp-add-http example https://mcp.example.com/sse +# just mcp-add-http work-iq-mail \ +# https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailRemoteServer \ +# +mcp-add-http NAME URL CLIENT_ID="" TENANT_ID="" SCOPES="" CALLBACK_PORT="8080": + npx tsx scripts/mcp-add-http.ts "{{ NAME }}" "{{ URL }}" "{{ CLIENT_ID }}" "{{ TENANT_ID }}" "{{ SCOPES }}" "{{ CALLBACK_PORT }}" + +# ── Microsoft 365 / Agent 365 HTTP MCP servers ─────────────────────── +# +# Alternative to the stdio `mcp-setup-workiq` recipe above: direct +# HTTP+OAuth to the Agent 365 per-service MCP endpoints (mail, calendar, +# teams, sharepoint, onedrive, user, copilot, word, …). Requires either +# a per-tenant Entra app registration (`mcp-m365-create-app`) or a +# pre-existing client id passed explicitly. +# +# Flow: +# 1. just mcp-m365-create-app # one-time: Entra app registration +# 2. just mcp-m365-setup # writes one entry per M365 service +# 3. just start → /plugin enable mcp → /mcp enable work-iq- +# +# State lives at ~/.hyperagent/m365.json (clientId, tenantId, callbackPort). +# The server catalog (alias → mcp_* id mapping) lives at +# scripts/m365-mcp-servers.json — refresh via `just mcp-m365-refresh-servers`. + +# Create (or reuse) the Entra app registration for the Agent 365 MCP servers. +# Optional: --service-ref GUID for corporate tenants that require one. +# Optional: --client-id ID to adopt an existing app. +# Requires `az` CLI installed and `az login`'d. Cross-platform (Linux, +# macOS, Windows native, Git Bash, WSL) — runs via tsx. +mcp-m365-create-app *ARGS: + npx tsx scripts/setup-m365-app.ts {{ ARGS }} + +# Write the M365 HTTP MCP server entries into ~/.hyperagent/config.json +# by looping over scripts/m365-mcp-servers.json. Reads clientId/tenantId +# from ~/.hyperagent/m365.json by default; override with explicit args. +# +# Each server uses the URL and per-server scope discovered from +# Agent 365 (see catalog file). The Agent 365 gateway uses the +# /agents/servers/ URL pattern — NOT /tenants//servers/ +# from the MS Learn docs (verified against discoverToolServers). +# +# Args: +# SERVICES "all" (default), or comma-separated alias list ("mail,teams") +# CLIENT_ID Override Entra app client id +# TENANT_ID Override Entra tenant id (used for OAuth authority) +# SCOPE_OVERRIDE Optional: force a single scope for every server +# (default: each server uses its catalogued scope) +mcp-setup-m365 SERVICES="all" CLIENT_ID="" TENANT_ID="" SCOPE_OVERRIDE="": + npx tsx scripts/m365-setup.ts "{{ SERVICES }}" "{{ CLIENT_ID }}" "{{ TENANT_ID }}" "{{ SCOPE_OVERRIDE }}" + +# Refresh scripts/m365-mcp-servers.json from the live Agent 365 catalog. +# Existing alias→server-id mappings are preserved; new server ids appear +# under a derived alias. +mcp-m365-refresh-servers *ARGS: + npx tsx scripts/m365-refresh-servers.ts {{ ARGS }} + +# Print the saved M365 app details (if any). +mcp-m365-show: + npx tsx scripts/m365-show.ts diff --git a/docs/MCP.md b/docs/MCP.md index 4df0486..f9a00a9 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -373,6 +373,107 @@ no documented service-principal / client-credentials flow for Work IQ. | `/mcp enable workiq` hangs | First run downloads ~188 MB of platform binaries via `npx`. Be patient. | | "AADSTS650052" / "Access denied" on consent URL | Work IQ Tools service principal not provisioned. Run the admin PS script. | +### Alternative: HTTP path (Agent 365 per-service servers) + +Instead of the single stdio `workiq` server you can wire up the +per-service Agent 365 HTTP endpoints directly. This gives you finer +`/mcp enable` control per M365 service but requires a per-tenant Entra +app registration. Use the stdio path above unless you specifically need +per-service scoping. + +Servers registered (names verified against the +[Work IQ MCP reference](https://learn.microsoft.com/en-us/microsoft-agent-365/mcp-server-reference/)): + +| Config entry | Agent 365 server id | Service | +|----------------------|-------------------------------|----------------------------------| +| `work-iq-mail` | `mcp_MailTools` | Outlook mail | +| `work-iq-calendar` | `mcp_CalendarTools` | Calendar & scheduling | +| `work-iq-teams` | `mcp_TeamsServer` | Teams chats & channels | +| `work-iq-sharepoint` | `mcp_SharePointRemoteServer` | SharePoint sites, lists, files | +| `work-iq-onedrive` | `mcp_OneDriveRemoteServer` | Personal OneDrive | +| `work-iq-user` | `mcp_MeServer` | User profiles, org chart | +| `work-iq-copilot` | `mcp_M365Copilot` | M365 Copilot search | +| `work-iq-word` | `mcp_WordServer` | Word documents | + +Each entry is wired to `https://agent365.svc.cloud.microsoft/agents/tenants//servers/`. + +#### Setup + +```bash +# 1. One-time: create (or reuse) the Entra app registration. +# Requires Azure CLI logged in; reuses any existing app in the tenant. +just mcp-workiq-create-app +# Add --service-ref if your tenant requires a Service Tree ref: +just mcp-workiq-create-app --service-ref 00000000-0000-0000-0000-000000000000 + +# 2. Write the HTTP config entries. Reads the saved clientId/tenantId +# from ~/.hyperagent/workiq.json (populated by step 1). +just mcp-setup-workiq-http # all services +just mcp-setup-workiq-http mail # just mail +just mcp-setup-workiq-http mail,teams # selected subset + +# 3. Enable whichever services you need. +just start +# /plugin enable mcp +# /mcp enable work-iq-mail +# /mcp enable work-iq-calendar +# ... + +# See stored app details any time: +just mcp-workiq-show +``` + +The eight Work IQ servers above are the complete public catalogue as of +the [MS Learn Tooling servers overview](https://learn.microsoft.com/en-us/microsoft-agent-365/tooling-servers-overview). +Microsoft is adding more (Dataverse/Dynamics 365 is already listed but +ships on a different URL pattern) — when a new one lands, add it to the +`MAP` in the `mcp-setup-workiq-http` recipe in the `Justfile`. + +The app registration is **single-tenant** (`AzureADMyOrg`) and grants the +Agent 365 delegated scopes required for the per-service token exchange. +Tenant admin consent is attempted automatically; if you aren't an admin, +the script prints an admin-consent URL to hand off. + +State file `~/.hyperagent/workiq.json` (not committed) holds the +resolved `clientId`, `tenantId`, and `callbackPort`. A second developer on +the same tenant can re-run `just mcp-workiq-create-app` — the script +looks up the existing app by saved clientId first, then by display name, +and only creates a new one as a last resort. + +## HTTP Transport & OAuth (generic remote MCP servers) + +HyperAgent supports remote MCP servers over HTTP with OAuth 2.0 (PKCE) for +cases where a hosted MCP endpoint requires bearer-token auth. + +Config shape: + +```json +{ + "mcpServers": { + "my-remote": { + "type": "http", + "url": "https://example.com/mcp", + "auth": { + "method": "oauth", + "clientId": "", + "tenantId": "", + "callbackPort": 8080 + } + } + } +} +``` + +On first connect HyperAgent starts a short-lived callback listener on +`http://localhost:/callback`, opens the system browser to the +authorisation endpoint advertised by the server's OAuth metadata, performs +PKCE, and persists the resulting tokens to +`~/.hyperagent/mcp-tokens/.json` (mode `0600` on Unix). + +Subsequent sessions reuse the cached token and refresh silently. Deleting +the token file forces a fresh sign-in. Tokens are **never** written to the +transcript log. + --- ## Debugging diff --git a/scripts/m365-mcp-servers.json b/scripts/m365-mcp-servers.json new file mode 100644 index 0000000..22429bf --- /dev/null +++ b/scripts/m365-mcp-servers.json @@ -0,0 +1,108 @@ +{ + "_comment": "Catalog of Microsoft 365 / Agent 365 MCP servers. Refresh via 'just mcp-m365-refresh-servers' (requires a cached OAuth token from a prior /mcp enable). Each server stores its discovered url and scope verbatim. Tenant-custom OOB_* servers are excluded.", + "resourceId": "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1", + "discoverEndpoint": "https://agent365.svc.cloud.microsoft/agents/discoverToolServers", + "callbackPort": 8080, + "servers": { + "admin": { + "id": "mcp_AdminTools", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_AdminTools", + "scope": "McpServers.M365Admin.All" + }, + "admin365-graph": { + "id": "mcp_Admin365_GraphTools", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_Admin365_GraphTools", + "scope": "McpServers.Admin365Graph.All" + }, + "calendar": { + "id": "mcp_CalendarTools", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_CalendarTools", + "scope": "McpServers.Calendar.All" + }, + "copilot": { + "id": "mcp_M365Copilot", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_M365Copilot", + "scope": "McpServers.CopilotMCP.All" + }, + "dasearch": { + "id": "mcp_DASearch", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_DASearch", + "scope": "McpServers.DASearch.All" + }, + "excel": { + "id": "mcp_ExcelServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_ExcelServer", + "scope": "McpServers.Excel.All" + }, + "knowledge": { + "id": "mcp_KnowledgeTools", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_KnowledgeTools", + "scope": "McpServers.Knowledge.All" + }, + "mail": { + "id": "mcp_MailTools", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailTools", + "scope": "McpServers.Mail.All" + }, + "odsp": { + "id": "mcp_ODSPRemoteServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_ODSPRemoteServer", + "scope": "McpServers.OneDriveSharepoint.All" + }, + "onedrive": { + "id": "mcp_OneDriveRemoteServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_OneDriveRemoteServer", + "scope": "McpServers.OneDrive.All" + }, + "planner": { + "id": "mcp_PlannerServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_PlannerServer", + "scope": "McpServers.Planner.All" + }, + "sharepoint": { + "id": "mcp_SharePointRemoteServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_SharePointRemoteServer", + "scope": "McpServers.SharePoint.All" + }, + "sharepoint-lists": { + "id": "mcp_SharePointListsTools", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_SharePointListsTools", + "scope": "McpServers.SharepointLists.All" + }, + "task-personalization": { + "id": "mcp_TaskPersonalizationServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_TaskPersonalizationServer", + "scope": "McpServers.TaskPersonalization.All" + }, + "teams": { + "id": "mcp_TeamsServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_TeamsServer", + "scope": "McpServers.Teams.All" + }, + "teams-canary": { + "id": "mcp_TeamsCanaryServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_TeamsCanaryServer", + "scope": "McpServers.Teams.All" + }, + "teams-v1": { + "id": "mcp_TeamsServerV1", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_TeamsServerV1", + "scope": "McpServers.Teams.All" + }, + "w365-computer-use": { + "id": "mcp_W365ComputerUse", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_W365ComputerUse", + "scope": "McpServers.W365ComputerUse.All" + }, + "websearch": { + "id": "mcp_WebSearchTools", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_WebSearchTools", + "scope": "McpServers.WebSearch.All" + }, + "word": { + "id": "mcp_WordServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_WordServer", + "scope": "McpServers.Word.All" + } + } +} diff --git a/scripts/m365-refresh-servers.ts b/scripts/m365-refresh-servers.ts new file mode 100644 index 0000000..534fd1c --- /dev/null +++ b/scripts/m365-refresh-servers.ts @@ -0,0 +1,219 @@ +// ── M365 MCP server catalog refresher ──────────────────────────────── +// +// Hits Agent 365's `discoverToolServers` endpoint and rewrites +// `scripts/m365-mcp-servers.json`. The catalog stores the discovered +// `url` and `scope` per server verbatim (the Agent 365 gateway does NOT +// use the /tenants// URL pattern from MS Learn — it uses +// /agents/servers/ directly). +// +// Token source (in order of preference): +// 1. --token command-line flag +// 2. Any file in ~/.hyperagent/mcp-tokens/work-iq-*.json +// (so the user can `/mcp enable work-iq-` once, then +// run this recipe to discover the rest) +// +// Tenant-custom servers (audience != Agent 365 resource id) are +// excluded from the public catalog by default — they tend to be +// per-tenant Dataverse plugins. +// +// Usage: +// just mcp-m365-refresh-servers +// just mcp-m365-refresh-servers --token +// just mcp-m365-refresh-servers --include-custom +import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve, join } from "node:path"; +import { homedir } from "node:os"; + +const __filename = fileURLToPath(import.meta.url); +const CATALOG_PATH = resolve(dirname(__filename), "m365-mcp-servers.json"); +const TOKENS_DIR = join(homedir(), ".hyperagent", "mcp-tokens"); + +interface CatalogServer { + readonly id: string; + readonly url: string; + readonly scope: string; +} +interface Catalog { + _comment?: string; + resourceId: string; + discoverEndpoint: string; + callbackPort: number; + servers: Record; +} + +interface DiscoveredServer { + readonly mcpServerName?: string; + readonly id?: string; + readonly url?: string; + readonly scope?: string; + readonly audience?: string; + readonly publisher?: string; +} +interface DiscoveryPayload { + readonly mcpServers?: readonly DiscoveredServer[]; +} + +interface CliArgs { + token?: string; + includeCustom: boolean; +} + +function parseArgs(argv: readonly string[]): CliArgs { + const out: CliArgs = { includeCustom: false }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--token" && i + 1 < argv.length) { + out.token = argv[i + 1]; + i++; + } else if (argv[i] === "--include-custom") { + out.includeCustom = true; + } + } + return out; +} + +/** + * Load the most recent OAuth access token from any cached MCP server. + * Returns undefined if no usable token is found. + */ +function loadTokenFromCache(): string | undefined { + if (!existsSync(TOKENS_DIR)) return undefined; + const files = readdirSync(TOKENS_DIR).filter((f) => f.endsWith(".json")); + if (files.length === 0) return undefined; + + // Pick the most recently saved token (largest savedAt). Any Agent 365 + // server token works for discovery — they all share the same audience. + let best: { token: string; savedAt: string } | undefined; + for (const f of files) { + try { + const raw = readFileSync(join(TOKENS_DIR, f), "utf8"); + const parsed = JSON.parse(raw) as { + savedAt?: string; + tokens?: { access_token?: string }; + }; + const tok = parsed.tokens?.access_token; + const savedAt = parsed.savedAt; + if (typeof tok !== "string" || typeof savedAt !== "string") continue; + if (!best || savedAt > best.savedAt) best = { token: tok, savedAt }; + } catch { + // skip corrupt files + } + } + return best?.token; +} + +/** + * Derive a short alias from an Agent 365 server id. Best-effort only — + * the existing alias map always wins for known ids so renames don't + * surprise anyone. + */ +function deriveAlias(serverId: string): string { + let s = serverId.replace(/^mcp_/i, ""); + // Normalise common Microsoft-internal suffixes. + s = s.replace(/(RemoteServer|Server|Tools)$/i, ""); + // M365Copilot → Copilot + s = s.replace(/^M365/i, ""); + // Camel/PascalCase → kebab-case (e.g. SharePointLists → sharepoint-lists) + s = s.replace(/([a-z0-9])([A-Z])/g, "$1-$2"); + s = s.replace(/_/g, "-"); + return s.toLowerCase() || serverId.toLowerCase(); +} + +async function main(): Promise { + const { token: cliToken, includeCustom } = parseArgs(process.argv.slice(2)); + + const raw = readFileSync(CATALOG_PATH, "utf8"); + const catalog = JSON.parse(raw) as Catalog; + + const token = cliToken ?? loadTokenFromCache(); + if (!token) { + throw new Error( + "No bearer token found.\n" + + ` Provide one via --token , OR run /mcp enable \n` + + ` inside HyperAgent first to seed ${TOKENS_DIR}/.`, + ); + } + + console.log(`▸ Fetching ${catalog.discoverEndpoint}`); + const res = await fetch(catalog.discoverEndpoint, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `Discovery failed: ${res.status} ${res.statusText}\n${body.slice(0, 500)}`, + ); + } + const payload = (await res.json()) as DiscoveryPayload; + const list = payload.mcpServers ?? []; + if (list.length === 0) { + throw new Error( + "Discovery returned no servers — endpoint shape may have changed", + ); + } + + // Build reverse lookup of existing alias map so we keep stable names. + const idToExistingAlias = new Map(); + for (const [alias, srv] of Object.entries(catalog.servers)) { + idToExistingAlias.set(srv.id, alias); + } + + const next: Record = {}; + let added = 0; + let skipped = 0; + for (const entry of list) { + const id = entry.mcpServerName ?? entry.id; + const url = entry.url; + const scope = entry.scope; + const audience = entry.audience; + + if (typeof id !== "string" || !/^[A-Za-z0-9_]+$/.test(id)) { + skipped++; + continue; + } + if (typeof url !== "string" || !url.startsWith("https://")) { + console.error(` ⚠ skipping ${id}: invalid url`); + skipped++; + continue; + } + if (typeof scope !== "string" || scope.length === 0) { + console.error(` ⚠ skipping ${id}: missing scope`); + skipped++; + continue; + } + if (!includeCustom && audience !== catalog.resourceId) { + console.error( + ` ⚠ skipping ${id}: audience '${audience ?? "(none)"}' != resource (use --include-custom to allow)`, + ); + skipped++; + continue; + } + + const existingAlias = idToExistingAlias.get(id); + let alias = existingAlias ?? deriveAlias(id); + if (next[alias] && next[alias].id !== id) { + alias = `${alias}-${id.toLowerCase()}`; + } + next[alias] = { id, url, scope }; + if (!existingAlias) added++; + } + + catalog.servers = Object.fromEntries( + Object.entries(next).sort(([a], [b]) => a.localeCompare(b)), + ); + + writeFileSync(CATALOG_PATH, JSON.stringify(catalog, null, 2) + "\n"); + const total = Object.keys(catalog.servers).length; + console.log( + `✅ Rewrote ${CATALOG_PATH} (${total} servers, ${added} new, ${skipped} skipped)`, + ); +} + +main().catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + console.error(`❌ ${msg}`); + process.exit(1); +}); diff --git a/scripts/m365-setup.ts b/scripts/m365-setup.ts new file mode 100644 index 0000000..e59f7bf --- /dev/null +++ b/scripts/m365-setup.ts @@ -0,0 +1,231 @@ +#!/usr/bin/env tsx +// ── Configure HyperAgent for Microsoft 365 / Agent 365 MCP servers ─── +// +// Cross-platform replacement for the bash recipe. Reads the catalog at +// scripts/m365-mcp-servers.json and writes one entry per selected +// service into ~/.hyperagent/config.json (via the shared mcp-add-http +// writer logic). +// +// Usage: +// tsx scripts/m365-setup.ts [services] [clientId] [tenantId] [scopeOverride] +// +// services "all" (default) or comma-separated alias list +// clientId Override Entra app client id (else read from state) +// tenantId Override Entra tenant id (else read from state) +// scopeOverride Force a single scope for every server (testing) +// +// State file at ~/.hyperagent/m365.json supplies clientId/tenantId/ +// callbackPort when not overridden. + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_CALLBACK_PORT = 8080; +const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +const ALIAS_PREFIX = "work-iq-"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const CATALOG_PATH = join(scriptDir, "m365-mcp-servers.json"); + +// ── Types ──────────────────────────────────────────────────────────── + +interface CatalogServer { + id?: string; + url: string; + scope: string; + audience?: string; + publisher?: string; +} + +interface Catalog { + servers: Record; + resourceId?: string; +} + +interface SavedState { + clientId?: string; + tenantId?: string; + callbackPort?: number; + appName?: string; +} + +interface OAuthAuth { + method: "oauth"; + clientId: string; + callbackPort: number; + scopes: string[]; + tenantId?: string; +} + +interface HttpServerEntry { + type: "http"; + url: string; + auth?: OAuthAuth; +} + +interface HyperAgentConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +// ── Helpers ────────────────────────────────────────────────────────── + +function fail(msg: string): never { + console.error(`❌ ${msg}`); + process.exit(1); +} + +function readJson(path: string): T | undefined { + if (!existsSync(path)) return undefined; + try { + return JSON.parse(readFileSync(path, "utf8")) as T; + } catch (err) { + fail(`Failed to read ${path}: ${(err as Error).message}`); + } +} + +function writeServerEntry( + configFile: string, + name: string, + url: string, + clientId: string, + tenantId: string, + scope: string, + callbackPort: number, +): void { + if (!NAME_PATTERN.test(name)) { + fail(`Invalid alias '${name}' — must match ${NAME_PATTERN}`); + } + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + fail(`Invalid URL for ${name}: ${url}`); + } + const isLocal = + parsedUrl.hostname === "localhost" || parsedUrl.hostname === "127.0.0.1"; + if (parsedUrl.protocol !== "https:" && !isLocal) { + fail(`URL must be https:// (or localhost): ${url}`); + } + + mkdirSync(dirname(configFile), { recursive: true }); + const cfg: HyperAgentConfig = existsSync(configFile) + ? (JSON.parse(readFileSync(configFile, "utf8")) as HyperAgentConfig) + : {}; + cfg.mcpServers = cfg.mcpServers ?? {}; + + cfg.mcpServers[name] = { + type: "http", + url, + auth: { + method: "oauth", + clientId, + callbackPort, + scopes: [scope], + ...(tenantId ? { tenantId } : {}), + }, + }; + + writeFileSync(configFile, JSON.stringify(cfg, null, 2) + "\n"); + console.log(`✅ Wrote mcpServers.${name} → ${url} (oauth)`); +} + +// ── Main ───────────────────────────────────────────────────────────── + +function main(): void { + const [ + servicesArg = "all", + clientIdArg = "", + tenantIdArg = "", + scopeOverride = "", + ] = process.argv.slice(2); + + const stateFile = join(homedir(), ".hyperagent", "m365.json"); + const configFile = join(homedir(), ".hyperagent", "config.json"); + + const catalog = readJson(CATALOG_PATH); + if (!catalog) fail(`Catalog missing: ${CATALOG_PATH}`); + const known = Object.keys(catalog.servers); + const raw = (servicesArg || "all").trim().toLowerCase(); + const selected = + raw === "" || raw === "all" + ? known + : raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const unknown = selected.filter((s) => !known.includes(s)); + if (unknown.length > 0) { + console.error(`❌ Unknown service(s): ${unknown.join(", ")}`); + console.error(` Known: ${known.join(", ")}, all`); + process.exit(1); + } + + // Resolve client/tenant/callbackPort from args ⊕ state file. + let clientId = clientIdArg; + let tenantId = tenantIdArg; + let callbackPort = DEFAULT_CALLBACK_PORT; + + if (!clientId || !tenantId) { + const state = readJson(stateFile); + if (!state) { + console.error("❌ No saved app state and no clientId/tenantId provided."); + console.error(" Run: just mcp-m365-create-app"); + console.error( + " Or: just mcp-setup-m365 ", + ); + process.exit(1); + } + clientId = clientId || state.clientId || ""; + tenantId = tenantId || state.tenantId || ""; + callbackPort = state.callbackPort || DEFAULT_CALLBACK_PORT; + console.log(`▸ Using saved app from ${stateFile}`); + } + + if (!clientId || !tenantId) { + fail("clientId/tenantId required (state file missing them)"); + } + + console.log(`▸ clientId: ${clientId}`); + console.log(`▸ tenantId: ${tenantId}`); + console.log(`▸ callbackPort: ${callbackPort}`); + console.log(`▸ services: ${servicesArg}`); + if (scopeOverride) { + console.log(`▸ scope (override): ${scopeOverride}`); + } + console.log(""); + + let count = 0; + for (const s of selected) { + const srv = catalog.servers[s]; + const scope = scopeOverride || srv.scope; + if (!srv.url || !scope) { + fail(`Catalog entry for ${s} missing url or scope`); + } + writeServerEntry( + configFile, + ALIAS_PREFIX + s, + srv.url, + clientId, + tenantId, + scope, + callbackPort, + ); + count += 1; + } + + console.log(""); + console.log(`✅ Configured ${count} M365 MCP server(s)`); + console.log(""); + console.log(" Next:"); + console.log(" just start"); + console.log(" /plugin enable mcp"); + console.log(" /mcp enable work-iq-"); + console.log(""); + console.log(" First enable opens a browser for Microsoft sign-in."); + console.log(" Tokens cached in ~/.hyperagent/mcp-tokens/"); +} + +main(); diff --git a/scripts/m365-show.ts b/scripts/m365-show.ts new file mode 100644 index 0000000..5616513 --- /dev/null +++ b/scripts/m365-show.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env tsx +// ── Print saved Microsoft 365 / Agent 365 app registration details ─── +// +// Cross-platform replacement for the bash `just mcp-m365-show` recipe. + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +interface SavedState { + clientId?: string; + tenantId?: string; + appName?: string; + callbackPort?: number; +} + +function main(): void { + const stateFile = join(homedir(), ".hyperagent", "m365.json"); + if (!existsSync(stateFile)) { + console.log("No saved M365 app. Run: just mcp-m365-create-app"); + return; + } + + let state: SavedState; + try { + state = JSON.parse(readFileSync(stateFile, "utf8")) as SavedState; + } catch (err) { + console.error(`❌ Failed to read ${stateFile}: ${(err as Error).message}`); + process.exit(1); + } + + console.log("M365 app registration:"); + console.log(` App name: ${state.appName ?? "(unset)"}`); + console.log(` Client ID: ${state.clientId ?? "(unset)"}`); + console.log(` Tenant ID: ${state.tenantId ?? "(unset)"}`); + console.log(` Callback port: ${state.callbackPort ?? 8080}`); + console.log(` State file: ${stateFile}`); +} + +main(); diff --git a/scripts/mcp-add-http.ts b/scripts/mcp-add-http.ts new file mode 100644 index 0000000..63547b3 --- /dev/null +++ b/scripts/mcp-add-http.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env tsx +// ── Add an HTTP MCP server entry to ~/.hyperagent/config.json ──────── +// +// Cross-platform replacement for the inline node script that used to +// live in the `just mcp-add-http` recipe. Runs on any OS where Node + +// tsx work (Linux, macOS, Windows native, WSL, Git Bash). +// +// Usage: +// tsx scripts/mcp-add-http.ts [clientId] [tenantId] [scopes] [callbackPort] +// +// All args after are optional. If clientId is provided, an OAuth +// auth block is written. scopes is comma-separated; if empty, defaults +// to "/.default". callbackPort defaults to 8080. + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; + +const DEFAULT_CALLBACK_PORT = 8080; +const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +interface OAuthAuth { + method: "oauth"; + clientId: string; + callbackPort: number; + scopes: string[]; + tenantId?: string; +} + +interface HttpServerEntry { + type: "http"; + url: string; + auth?: OAuthAuth; +} + +interface HyperAgentConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +function fail(msg: string): never { + console.error(`❌ ${msg}`); + process.exit(1); +} + +function main(): void { + const [name, url, clientId, tenantId, scopes, callbackPortArg] = + process.argv.slice(2); + + if (!name || !url) { + fail( + "Usage: tsx scripts/mcp-add-http.ts " + + "[clientId] [tenantId] [scopes] [callbackPort]", + ); + } + + if (!NAME_PATTERN.test(name)) { + fail(`Invalid NAME: '${name}' (use lowercase letters, digits, hyphens)`); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + fail(`Invalid URL: ${url}`); + } + const isLocal = + parsedUrl.hostname === "localhost" || parsedUrl.hostname === "127.0.0.1"; + if (parsedUrl.protocol !== "https:" && !isLocal) { + fail(`URL must be https:// (or localhost for testing): ${url}`); + } + + const callbackPort = callbackPortArg + ? Number.parseInt(callbackPortArg, 10) || DEFAULT_CALLBACK_PORT + : DEFAULT_CALLBACK_PORT; + + const configDir = join(homedir(), ".hyperagent"); + const configFile = join(configDir, "config.json"); + mkdirSync(configDir, { recursive: true }); + + const cfg: HyperAgentConfig = existsSync(configFile) + ? (JSON.parse(readFileSync(configFile, "utf8")) as HyperAgentConfig) + : {}; + cfg.mcpServers = cfg.mcpServers ?? {}; + + const entry: HttpServerEntry = { type: "http", url }; + if (clientId) { + const scopeList = scopes + ? scopes + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : [`${parsedUrl.origin}/.default`]; + entry.auth = { + method: "oauth", + clientId, + callbackPort, + scopes: scopeList, + }; + if (tenantId) entry.auth.tenantId = tenantId; + } + cfg.mcpServers[name] = entry; + + // Ensure the config dir exists for writeFileSync (idempotent). + mkdirSync(dirname(configFile), { recursive: true }); + writeFileSync(configFile, JSON.stringify(cfg, null, 2) + "\n"); + + const suffix = clientId ? " (oauth)" : ""; + console.log(`✅ Wrote mcpServers.${name} → ${url}${suffix}`); +} + +main(); diff --git a/scripts/setup-m365-app.ts b/scripts/setup-m365-app.ts new file mode 100644 index 0000000..f8cc3b3 --- /dev/null +++ b/scripts/setup-m365-app.ts @@ -0,0 +1,649 @@ +#!/usr/bin/env tsx +// ── Set up Entra ID app registration for Microsoft 365 MCP servers ── +// +// Cross-platform replacement for setup-m365-app.sh. Calls the Azure +// CLI (`az`) via execFileSync so it works on Linux, macOS, WSL, native +// Windows (PowerShell or Git Bash). The user must have `az` installed +// and `az login`'d before running this. +// +// What it does: +// 1. Creates / reuses / adopts (--client-id) a single-tenant +// public-client app registration in Entra ID. +// 2. Verifies the Agent 365 service principal exists in the tenant. +// 3. Reads scripts/m365-mcp-servers.json and declares every catalogued +// delegated scope on the app reg (required because we request +// narrow per-server scopes at runtime, not `.default`). +// 4. Attempts admin consent. Non-admins get a URL to share. +// 5. Persists clientId/tenantId/etc. to ~/.hyperagent/m365.json. +// +// Usage: +// tsx scripts/setup-m365-app.ts [options] +// +// Options: +// --app-name NAME Display name (default: "HyperAgent M365") +// --callback-port PORT OAuth callback port (default: 8080) +// --service-ref GUID Service Tree GUID (some corporate tenants) +// --client-id ID Adopt an existing Entra app by client id + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +// ── Constants ──────────────────────────────────────────────────────── + +/** Agent 365 resource app id — gates every Work IQ MCP server. */ +const AGENT365_RESOURCE_ID = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"; + +const DEFAULT_APP_NAME = "HyperAgent M365"; +const DEFAULT_CALLBACK_PORT = 8080; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const CATALOG_PATH = join(scriptDir, "m365-mcp-servers.json"); + +// On Windows az is `az.cmd` — spawn-without-shell needs the full name. +// execFileSync handles this when shell:true, but we avoid shell:true to +// dodge quoting issues, so resolve the binary name explicitly. +const AZ_BIN = platform() === "win32" ? "az.cmd" : "az"; + +// ── Colour log helpers (TTY only) ──────────────────────────────────── + +const supportsColour = process.stdout.isTTY === true; +const C = supportsColour + ? { + red: "\u001b[0;31m", + green: "\u001b[0;32m", + yellow: "\u001b[0;33m", + cyan: "\u001b[0;36m", + reset: "\u001b[0m", + } + : { red: "", green: "", yellow: "", cyan: "", reset: "" }; + +const logStep = (msg: string): void => + console.log(`${C.cyan}▸${C.reset} ${msg}`); +const logSuccess = (msg: string): void => + console.log(`${C.green}✅${C.reset} ${msg}`); +const logWarning = (msg: string): void => + console.log(`${C.yellow}⚠️${C.reset} ${msg}`); +const logError = (msg: string): void => + console.error(`${C.red}❌${C.reset} ${msg}`); + +// ── Argument parsing ───────────────────────────────────────────────── + +interface CliArgs { + appName: string; + callbackPort: number; + serviceRef: string; + clientId: string; +} + +function parseArgs(argv: string[]): CliArgs { + const args: CliArgs = { + appName: DEFAULT_APP_NAME, + callbackPort: DEFAULT_CALLBACK_PORT, + serviceRef: "", + clientId: "", + }; + + let i = 0; + while (i < argv.length) { + const arg = argv[i]; + switch (arg) { + case "--app-name": + args.appName = argv[++i] ?? args.appName; + break; + case "--callback-port": + args.callbackPort = + Number.parseInt(argv[++i] ?? "", 10) || DEFAULT_CALLBACK_PORT; + break; + case "--service-ref": + args.serviceRef = argv[++i] ?? ""; + break; + case "--client-id": + args.clientId = argv[++i] ?? ""; + break; + case "--help": + case "-h": + printHelp(); + process.exit(0); + break; + default: + logError(`Unknown option: ${arg} (run with --help)`); + process.exit(1); + } + i += 1; + } + return args; +} + +function printHelp(): void { + console.log( + "Usage: tsx scripts/setup-m365-app.ts " + + "[--app-name NAME] [--callback-port PORT] " + + "[--service-ref GUID] [--client-id ID]", + ); + console.log(""); + console.log( + "Creates a single-tenant public-client Entra ID app registration", + ); + console.log("for the Microsoft 365 / Agent 365 HTTP MCP servers."); + console.log(""); + console.log("Prerequisites:"); + console.log(" • Microsoft 365 Copilot licence"); + console.log(" • Frontier preview enrolment:"); + console.log(" https://adoption.microsoft.com/copilot/frontier-program/"); + console.log(""); + console.log("Options:"); + console.log( + " --app-name NAME Display name (default: HyperAgent M365)", + ); + console.log(" --callback-port PORT OAuth callback port (default: 8080)"); + console.log(" --service-ref GUID Service Tree GUID (corporate tenants)"); + console.log(" --client-id ID Adopt an existing app by client id"); +} + +// ── az CLI wrapper ─────────────────────────────────────────────────── + +interface AzResult { + ok: boolean; + stdout: string; + stderr: string; + status: number | null; +} + +/** + * Run `az` with the given args. Never throws — returns a result object. + * Captures stdout/stderr so callers can inspect failures. + */ +function az(args: string[]): AzResult { + const result = spawnSync(AZ_BIN, args, { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }); + if (result.error) { + return { + ok: false, + stdout: "", + stderr: result.error.message, + status: null, + }; + } + return { + ok: result.status === 0, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + status: result.status, + }; +} + +/** Run `az` and exit with a friendly error if it fails. */ +function azOrFail(args: string[], failMsg: string): string { + const r = az(args); + if (!r.ok) { + logError(failMsg); + if (r.stderr.trim()) console.error(` ${r.stderr.trim()}`); + process.exit(1); + } + return r.stdout.trim(); +} + +// ── Prerequisites ──────────────────────────────────────────────────── + +function checkPrerequisites(): void { + // `az --version` returns 0 if installed. + if (!az(["--version"]).ok) { + logError("Azure CLI (az) not found."); + console.error( + " Install: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli", + ); + process.exit(1); + } + + if (!az(["account", "show"]).ok) { + logError("Not logged in to Azure CLI."); + console.error(" Run: az login"); + console.error(" From WSL: az login --use-device-code"); + process.exit(1); + } + + if (!az(["ad", "signed-in-user", "show"]).ok) { + logError("Azure CLI session lacks Microsoft Graph permissions."); + console.error( + " Run: az login --scope https://graph.microsoft.com//.default", + ); + process.exit(1); + } +} + +// ── Tenant + resource verification ─────────────────────────────────── + +interface TenantInfo { + tenantId: string; + tenantDomain: string; +} + +function resolveTenant(): TenantInfo { + logStep("Resolving tenant..."); + const tenantId = azOrFail( + ["account", "show", "--query", "tenantId", "-o", "tsv"], + "Failed to read tenantId from az account show", + ); + const userPrincipal = azOrFail( + ["account", "show", "--query", "user.name", "-o", "tsv"], + "Failed to read signed-in user from az account show", + ); + const tenantDomain = userPrincipal.includes("@") + ? userPrincipal.split("@")[1] + : "(unknown)"; + logSuccess(`Tenant: ${tenantId} (${tenantDomain})`); + return { tenantId, tenantDomain }; +} + +function verifyAgent365Resource(): void { + logStep("Verifying Agent 365 resource is available..."); + if (!az(["ad", "sp", "show", "--id", AGENT365_RESOURCE_ID]).ok) { + logError( + `Agent 365 service principal (${AGENT365_RESOURCE_ID}) not found in your tenant.`, + ); + console.error(" This usually means one of:"); + console.error(" 1. No Microsoft 365 Copilot licence on this tenant."); + console.error(" 2. Not enrolled in the Frontier preview programme:"); + console.error( + " https://adoption.microsoft.com/copilot/frontier-program/", + ); + console.error(""); + console.error(" Re-run this script once Agent 365 is provisioned."); + process.exit(1); + } + logSuccess("Agent 365 resource present"); +} + +// ── App registration: create / reuse / adopt ───────────────────────── + +interface SavedState { + clientId?: string; + tenantId?: string; + appName?: string; + callbackPort?: number; +} + +function readSavedClientId(stateFile: string): string { + if (!existsSync(stateFile)) return ""; + try { + const state = JSON.parse(readFileSync(stateFile, "utf8")) as SavedState; + return state.clientId ?? ""; + } catch { + return ""; + } +} + +function appExists(appId: string): boolean { + return az(["ad", "app", "show", "--id", appId]).ok; +} + +function updateAppPublicClient(appId: string, redirectUri: string): void { + azOrFail( + [ + "ad", + "app", + "update", + "--id", + appId, + "--public-client-redirect-uris", + redirectUri, + "--is-fallback-public-client", + "true", + "-o", + "none", + ], + `Failed to update app ${appId}`, + ); + logSuccess("Updated redirect URI + public-client flag"); +} + +function resolveAppId( + args: CliArgs, + redirectUri: string, + stateFile: string, +): string { + const savedClientId = readSavedClientId(stateFile); + + // Path 1: explicit --client-id (adopt) + if (args.clientId) { + if (!appExists(args.clientId)) { + logError(`App not found in this tenant: ${args.clientId}`); + console.error( + " Check that you're logged into the right tenant (az account show)", + ); + console.error(" and that the app id is correct."); + process.exit(1); + } + logWarning(`Adopting existing app via --client-id: ${args.clientId}`); + updateAppPublicClient(args.clientId, redirectUri); + return args.clientId; + } + + // Path 2: saved client id from previous run + if (savedClientId && appExists(savedClientId)) { + logWarning(`Reusing saved app from ${stateFile}: ${savedClientId}`); + updateAppPublicClient(savedClientId, redirectUri); + return savedClientId; + } + + // Path 3: lookup by display name + logStep(`Checking for existing app: ${args.appName}`); + const lookup = az([ + "ad", + "app", + "list", + "--display-name", + args.appName, + "--query", + "[0].appId", + "-o", + "tsv", + ]); + const existing = lookup.ok ? lookup.stdout.trim() : ""; + if (existing && existing !== "None") { + logWarning(`App already exists (${existing}) — updating redirect URI`); + updateAppPublicClient(existing, redirectUri); + return existing; + } + + // Path 4: create + logStep("Creating app registration..."); + if (args.serviceRef) { + return createAppViaGraph(args.appName, redirectUri, args.serviceRef); + } + return createAppViaCli(args.appName, redirectUri); +} + +function createAppViaCli(appName: string, redirectUri: string): string { + const r = az([ + "ad", + "app", + "create", + "--display-name", + appName, + "--sign-in-audience", + "AzureADMyOrg", + "--public-client-redirect-uris", + redirectUri, + "--is-fallback-public-client", + "true", + "--query", + "appId", + "-o", + "tsv", + ]); + + if (!r.ok) { + const combined = `${r.stdout}\n${r.stderr}`.toLowerCase(); + if (combined.includes("servicemanagementreference")) { + logError("Your tenant requires a Service Tree GUID."); + console.error( + " Find one: az ad app list --all --query '[0].serviceManagementReference' -o tsv", + ); + console.error( + ' Re-run: tsx scripts/setup-m365-app.ts --service-ref ""', + ); + process.exit(1); + } + logError(`Failed to create app: ${(r.stderr || r.stdout).trim()}`); + process.exit(1); + } + + const appId = r.stdout.trim(); + logSuccess(`App created: ${appId}`); + return appId; +} + +function createAppViaGraph( + appName: string, + redirectUri: string, + serviceRef: string, +): string { + const body = JSON.stringify({ + displayName: appName, + signInAudience: "AzureADMyOrg", + isFallbackPublicClient: true, + publicClient: { redirectUris: [redirectUri] }, + serviceManagementReference: serviceRef, + }); + + const appId = azOrFail( + [ + "rest", + "--method", + "POST", + "--url", + "https://graph.microsoft.com/v1.0/applications", + "--headers", + "Content-Type=application/json", + "--body", + body, + "--query", + "appId", + "-o", + "tsv", + ], + "Failed to create app via Graph API", + ); + + logSuccess(`App created: ${appId}`); + return appId; +} + +// ── Scope declaration ──────────────────────────────────────────────── + +interface CatalogServer { + scope?: string; +} + +interface Catalog { + servers: Record; +} + +interface SpScope { + value: string; + id: string; +} + +function declareCatalogScopes(appId: string): void { + if (!existsSync(CATALOG_PATH)) { + logWarning(`Catalog missing: ${CATALOG_PATH} — skipping scope declaration`); + return; + } + const catalog = JSON.parse(readFileSync(CATALOG_PATH, "utf8")) as Catalog; + const scopeValues = [ + ...new Set( + Object.values(catalog.servers) + .map((s) => s.scope) + .filter((v): v is string => Boolean(v)), + ), + ]; + + if (scopeValues.length === 0) { + logWarning("Catalog has no scopes to declare — skipping"); + return; + } + + logStep("Discovering Agent 365 published scopes..."); + const r = az([ + "ad", + "sp", + "show", + "--id", + AGENT365_RESOURCE_ID, + "--query", + "oauth2PermissionScopes[].{value:value,id:id}", + "-o", + "json", + ]); + if (!r.ok || !r.stdout.trim() || r.stdout.trim() === "null") { + logWarning( + "Could not enumerate Agent 365 scopes — skipping scope declaration", + ); + logWarning( + "Users will need to consent each scope individually on first sign-in", + ); + return; + } + + let spScopes: SpScope[]; + try { + spScopes = JSON.parse(r.stdout) as SpScope[]; + } catch { + logWarning("Agent 365 scope list returned invalid JSON — skipping"); + return; + } + + logStep("Declaring catalog scopes on the app registration..."); + const valueToId = new Map(spScopes.map((s) => [s.value, s.id])); + + let added = 0; + let missing = 0; + for (const scopeValue of scopeValues) { + const scopeId = valueToId.get(scopeValue); + if (!scopeId) { + console.log( + ` ⚠️ ${scopeValue} (not published by Agent 365 in this tenant)`, + ); + missing += 1; + continue; + } + const addRes = az([ + "ad", + "app", + "permission", + "add", + "--id", + appId, + "--api", + AGENT365_RESOURCE_ID, + "--api-permissions", + `${scopeId}=Scope`, + "-o", + "none", + ]); + if (addRes.ok) { + console.log(` ✅ ${scopeValue}`); + added += 1; + } else { + console.log(` ➖ ${scopeValue} (already declared)`); + } + } + + logSuccess(`Declared scopes (${added} new, ${missing} missing in tenant)`); + if (missing > 0) { + console.log( + " Missing scopes are normal in tenants without all Agent 365 features.", + ); + console.log(" Refresh the catalog from a tenant that has them:"); + console.log(" just mcp-m365-refresh-servers"); + } +} + +// ── Admin consent ──────────────────────────────────────────────────── + +function requestAdminConsent(appId: string, tenantId: string): void { + logStep("Requesting admin consent for the app..."); + const r = az(["ad", "app", "permission", "admin-consent", "--id", appId]); + if (r.ok) { + logSuccess("Admin consent granted"); + return; + } + logWarning("Admin consent not granted (you are probably not a tenant admin)"); + console.log(" Ask a tenant admin to open this URL once:"); + console.log( + ` https://login.microsoftonline.com/${tenantId}/adminconsent?client_id=${appId}`, + ); + console.log(""); + console.log( + " Until then, end users will see 'Need admin approval' on first sign-in", + ); + console.log(" for any scopes that are not user-consentable in this tenant."); +} + +// ── Persist state ──────────────────────────────────────────────────── + +function saveState(stateFile: string, next: SavedState): void { + mkdirSync(dirname(stateFile), { recursive: true }); + let cur: SavedState = {}; + if (existsSync(stateFile)) { + try { + cur = JSON.parse(readFileSync(stateFile, "utf8")) as SavedState; + } catch { + // ignore — we'll overwrite + } + } + writeFileSync(stateFile, JSON.stringify({ ...cur, ...next }, null, 2) + "\n"); + logSuccess(`Saved app details to ${stateFile}`); +} + +// ── Final summary ──────────────────────────────────────────────────── + +function printSummary( + appName: string, + appId: string, + tenantId: string, + redirectUri: string, +): void { + console.log(""); + console.log( + "════════════════════════════════════════════════════════════════", + ); + logSuccess("App registration complete!"); + console.log(""); + console.log(` App Name: ${appName}`); + console.log(` Client ID: ${appId}`); + console.log(` Tenant ID: ${tenantId}`); + console.log(` Redirect: ${redirectUri}`); + console.log(""); + console.log(" Next steps:"); + console.log(""); + console.log(" 1. Configure HyperAgent (writes one entry per M365 service):"); + console.log(" just mcp-setup-m365"); + console.log(""); + console.log(" 2. Start HyperAgent:"); + console.log(" just start"); + console.log(""); + console.log(" 3. Enable and connect:"); + console.log(" /plugin enable mcp"); + console.log(" /mcp enable work-iq-planner"); + console.log(" /mcp enable work-iq-mail # may need admin consent"); + console.log(""); + console.log(" Browser opens for Microsoft sign-in on first use."); + console.log(" Tokens cached in ~/.hyperagent/mcp-tokens/"); + console.log( + "════════════════════════════════════════════════════════════════", + ); +} + +// ── Main ───────────────────────────────────────────────────────────── + +function main(): void { + const args = parseArgs(process.argv.slice(2)); + const redirectUri = `http://localhost:${args.callbackPort}/callback`; + const stateFile = join(homedir(), ".hyperagent", "m365.json"); + + checkPrerequisites(); + const { tenantId } = resolveTenant(); + verifyAgent365Resource(); + + const appId = resolveAppId(args, redirectUri, stateFile); + declareCatalogScopes(appId); + requestAdminConsent(appId, tenantId); + + saveState(stateFile, { + clientId: appId, + tenantId, + appName: args.appName, + callbackPort: args.callbackPort, + }); + + printSummary(args.appName, appId, tenantId, redirectUri); +} + +main(); diff --git a/src/agent/index.ts b/src/agent/index.ts index f6fa5b5..9d259f5 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -708,6 +708,7 @@ if (discoveredCount > 0) { // ── MCP Integration ────────────────────────────────────────────────── import { parseMCPConfig } from "./mcp/config.js"; +import { isMCPHttpConfig, mcpConfigDisplayString } from "./mcp/types.js"; import { createMCPClientManager, type MCPClientManager, @@ -3372,7 +3373,8 @@ const listMCPServersTool = defineTool("list_mcp_servers", { const servers = mcpManager!.listServers().map((conn) => ({ name: conn.name, state: conn.state, - command: conn.config.command, + transport: isMCPHttpConfig(conn.config) ? "http" : "stdio", + endpoint: mcpConfigDisplayString(conn.config), toolCount: conn.tools.length, tools: conn.tools.map((t) => t.name), module: `host:mcp-${conn.name}`, @@ -3427,8 +3429,8 @@ const mcpServerInfoTool = defineTool("mcp_server_info", { return { name: conn.name, state: conn.state, - command: conn.config.command, - args: conn.config.args ?? [], + transport: isMCPHttpConfig(conn.config) ? "http" : "stdio", + endpoint: mcpConfigDisplayString(conn.config), toolCount: conn.tools.length, tools: conn.tools.map((t) => ({ name: t.name, diff --git a/src/agent/mcp/auth/browser-oauth.ts b/src/agent/mcp/auth/browser-oauth.ts new file mode 100644 index 0000000..d763d2f --- /dev/null +++ b/src/agent/mcp/auth/browser-oauth.ts @@ -0,0 +1,306 @@ +// ── MCP browser OAuth provider ─────────────────────────────────────── +// +// Implements OAuthClientProvider for interactive browser-based OAuth +// with PKCE. On first use, opens a browser for sign-in. Tokens are +// cached to disk for subsequent sessions. +// +// Flow: +// 1. Check for cached tokens → use if valid +// 2. SDK calls redirectToAuthorization() → we open the browser +// 3. Local callback server receives the auth code +// 4. Caller awaits waitForAuthCallback() to get the code +// 5. Caller calls transport.finishAuth(code) to complete the flow +// 6. SDK exchanges code for tokens → calls saveTokens() +// 7. We cache the tokens to disk +// +// This provider is used for local REPL sessions. For K8s / headless, +// use WorkloadIdentityProvider or ClientCredentialsProvider instead. + +import { createServer, type Server } from "node:http"; +import { exec } from "node:child_process"; + +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientMetadata, + OAuthTokens, + OAuthClientInformationMixed, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +import type { MCPOAuthConfig } from "../types.js"; +import { + loadCachedTokens, + saveCachedTokens, + deleteCachedTokens, +} from "./token-cache.js"; + +// ── Constants ──────────────────────────────────────────────────────── + +/** Default port for the OAuth callback server. */ +const DEFAULT_CALLBACK_PORT = 8080; + +/** How long to wait for the user to authenticate (ms). */ +const AUTH_TIMEOUT_MS = 120_000; // 2 minutes + +// ── Result type ────────────────────────────────────────────────────── + +/** + * Result from createBrowserOAuthProvider. + * Includes the provider and a function to wait for the auth callback. + */ +export interface BrowserOAuthProviderResult { + /** The OAuthClientProvider to pass to StreamableHTTPClientTransport. */ + provider: OAuthClientProvider; + + /** + * Wait for the OAuth callback to arrive after the browser opens. + * Resolves with the authorization code. + * Rejects on timeout or error. + */ + waitForAuthCallback: () => Promise; + + /** Stop the callback server (cleanup). */ + stopCallbackServer: () => void; +} + +// ── Provider implementation ────────────────────────────────────────── + +/** + * Create a browser-based OAuthClientProvider for interactive OAuth flows. + * + * @param serverName - MCP server name (used for token cache key). + * @param authConfig - OAuth configuration from the MCP server config. + * @returns Provider, waitForAuthCallback function, and cleanup function. + */ +export function createBrowserOAuthProvider( + serverName: string, + authConfig: MCPOAuthConfig, +): BrowserOAuthProviderResult { + const callbackPort = authConfig.callbackPort ?? DEFAULT_CALLBACK_PORT; + const callbackUrl = new URL(`http://localhost:${callbackPort}/callback`); + + // In-memory session state (not persisted — per-session only) + let currentCodeVerifier: string = ""; + let callbackServer: Server | null = null; + let authTimeout: ReturnType | null = null; + + // Promise resolve/reject for the auth code — set when callback server starts + let resolveAuthCode: ((code: string) => void) | null = null; + let rejectAuthCode: ((err: Error) => void) | null = null; + let authCodePromise: Promise | null = null; + + const provider: OAuthClientProvider = { + get redirectUrl(): URL { + return callbackUrl; + }, + + get clientMetadata(): OAuthClientMetadata { + return { + redirect_uris: [callbackUrl.toString()], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: "HyperAgent", + ...(authConfig.scopes ? { scope: authConfig.scopes.join(" ") } : {}), + }; + }, + + clientInformation(): OAuthClientInformationMixed | undefined { + // Pre-registered client — return static client ID + return { client_id: authConfig.clientId }; + }, + + tokens(): OAuthTokens | undefined { + return loadCachedTokens(serverName); + }, + + saveTokens(tokens: OAuthTokens): void { + saveCachedTokens(serverName, tokens); + }, + + async redirectToAuthorization(authorizationUrl: URL): Promise { + console.error(`[mcp] 🔐 Opening browser for authentication...`); + console.error( + `[mcp] URL: ${authorizationUrl.origin}${authorizationUrl.pathname}`, + ); + + // Start callback server before opening browser + await startServer(callbackPort); + + // Open browser + openBrowser(authorizationUrl.toString()); + }, + + saveCodeVerifier(codeVerifier: string): void { + currentCodeVerifier = codeVerifier; + }, + + codeVerifier(): string { + return currentCodeVerifier; + }, + + invalidateCredentials( + scope: "all" | "client" | "tokens" | "verifier" | "discovery", + ): void { + if (scope === "all" || scope === "tokens") { + deleteCachedTokens(serverName); + currentCodeVerifier = ""; + } + if (scope === "verifier") { + currentCodeVerifier = ""; + } + }, + }; + + /** + * Start an ephemeral HTTP server on localhost to receive the OAuth callback. + * Only binds to 127.0.0.1 — not accessible from the network. + */ + function startServer(port: number): Promise { + return new Promise((resolve, reject) => { + stopServer(); + + // Create the auth code promise that waitForAuthCallback returns + authCodePromise = new Promise((res, rej) => { + resolveAuthCode = res; + rejectAuthCode = rej; + }); + + callbackServer = createServer((req, res) => { + // Only handle the callback path + if (!req.url?.startsWith("/callback")) { + res.writeHead(404); + res.end(); + return; + } + + const parsed = new URL(req.url, `http://localhost:${port}`); + const code = parsed.searchParams.get("code"); + const error = parsed.searchParams.get("error"); + + if (code) { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + "

Authentication Successful

" + + "

You can close this window and return to HyperAgent.

" + + "" + + "", + ); + + resolveAuthCode?.(code); + resolveAuthCode = null; + rejectAuthCode = null; + setTimeout(() => stopServer(), 1000); + } else if (error) { + const desc = parsed.searchParams.get("error_description") ?? error; + res.writeHead(400, { "Content-Type": "text/html" }); + res.end( + `

Authentication Failed

` + + `

${escapeHtml(desc)}

`, + ); + + rejectAuthCode?.(new Error(`OAuth error: ${desc}`)); + resolveAuthCode = null; + rejectAuthCode = null; + setTimeout(() => stopServer(), 1000); + } else { + res.writeHead(400); + res.end("Missing authorization code"); + } + }); + + callbackServer.listen(port, "127.0.0.1", () => { + resolve(); + }); + + callbackServer.on("error", (err) => { + reject( + new Error( + `Failed to start OAuth callback server on port ${port}: ${err.message}`, + ), + ); + }); + + // Timeout — don't leave the server hanging forever + authTimeout = setTimeout(() => { + rejectAuthCode?.( + new Error( + `OAuth authentication timed out after ${AUTH_TIMEOUT_MS / 1000}s`, + ), + ); + resolveAuthCode = null; + rejectAuthCode = null; + stopServer(); + }, AUTH_TIMEOUT_MS); + }); + } + + /** Stop the callback server and clear the timeout. */ + function stopServer(): void { + if (authTimeout) { + clearTimeout(authTimeout); + authTimeout = null; + } + if (callbackServer) { + try { + callbackServer.close(); + } catch { + // Ignore close errors + } + callbackServer = null; + } + } + + /** + * Wait for the OAuth callback to deliver an authorization code. + * The callback server must have been started first + * (via redirectToAuthorization). + */ + function waitForAuthCallback(): Promise { + if (!authCodePromise) { + return Promise.reject( + new Error( + "OAuth callback server not started — redirectToAuthorization must be called first", + ), + ); + } + return authCodePromise; + } + + return { + provider, + waitForAuthCallback, + stopCallbackServer: stopServer, + }; +} + +// ── Helpers ────────────────────────────────────────────────────────── + +/** + * Open a URL in the default browser. + * Falls back to logging the URL if no browser command is available. + */ +function openBrowser(url: string): void { + const cmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + + exec(`${cmd} "${url}"`, (err) => { + if (err) { + console.error( + `[mcp] Could not open browser automatically. Please open this URL manually:`, + ); + console.error(`[mcp] ${url}`); + } + }); +} + +/** Escape HTML special characters to prevent XSS in callback page. */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/src/agent/mcp/auth/token-cache.ts b/src/agent/mcp/auth/token-cache.ts new file mode 100644 index 0000000..a55cc21 --- /dev/null +++ b/src/agent/mcp/auth/token-cache.ts @@ -0,0 +1,166 @@ +// ── MCP OAuth token cache ──────────────────────────────────────────── +// +// Persists OAuth tokens to ~/.hyperagent/mcp-tokens/.json +// with restrictive file permissions (0o600). Used by the browser OAuth +// provider to cache tokens between sessions so users don't have to +// re-authenticate every time. +// +// Token files contain: +// - access_token, refresh_token, token_type, expires_in, scope +// - savedAt: ISO timestamp of when tokens were saved +// +// No encryption at rest (v1) — relies on file permissions. +// Future: Key Vault–backed encryption for enterprise deployments. + +import { + readFileSync, + writeFileSync, + unlinkSync, + existsSync, + mkdirSync, + statSync, +} from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; + +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; + +// ── Constants ──────────────────────────────────────────────────────── + +/** Directory for cached MCP OAuth tokens. */ +const TOKENS_DIR = join(homedir(), ".hyperagent", "mcp-tokens"); + +/** File permission: owner read/write only (no group, no other). */ +const TOKEN_FILE_MODE = 0o600; + +/** Directory permission: owner only. */ +const TOKEN_DIR_MODE = 0o700; + +// ── Types ──────────────────────────────────────────────────────────── + +/** On-disk shape of a cached token file. */ +interface CachedTokenFile { + /** ISO timestamp of when tokens were saved. */ + savedAt: string; + + /** The OAuth tokens. */ + tokens: OAuthTokens; +} + +// ── Public API ─────────────────────────────────────────────────────── + +/** + * Load cached OAuth tokens for a server. + * Returns undefined if no cached tokens exist or they can't be read. + */ +export function loadCachedTokens(serverName: string): OAuthTokens | undefined { + const filePath = tokenFilePath(serverName); + try { + if (!existsSync(filePath)) return undefined; + + // Warn if file permissions are too open (Unix only) + warnIfInsecurePermissions(filePath); + + const raw = readFileSync(filePath, "utf8"); + const cached = JSON.parse(raw) as CachedTokenFile; + + if (!cached.tokens || typeof cached.tokens.access_token !== "string") { + console.error( + `[mcp] Warning: corrupt token cache for "${serverName}" — ignoring`, + ); + return undefined; + } + + return cached.tokens; + } catch { + return undefined; + } +} + +/** + * Save OAuth tokens to disk for a server. + * Creates the token directory if it doesn't exist. + */ +export function saveCachedTokens( + serverName: string, + tokens: OAuthTokens, +): void { + const filePath = tokenFilePath(serverName); + try { + ensureTokenDir(); + + const cached: CachedTokenFile = { + savedAt: new Date().toISOString(), + tokens, + }; + + writeFileSync(filePath, JSON.stringify(cached, null, 2), { + mode: TOKEN_FILE_MODE, + }); + } catch (err) { + console.error( + `[mcp] Failed to cache tokens for "${serverName}": ${(err as Error).message}`, + ); + } +} + +/** + * Delete cached tokens for a server. + * Used when tokens are explicitly invalidated or the server is removed. + */ +export function deleteCachedTokens(serverName: string): void { + const filePath = tokenFilePath(serverName); + try { + if (existsSync(filePath)) { + unlinkSync(filePath); + } + } catch { + // Best-effort — ignore errors + } +} + +/** + * Check whether cached tokens exist for a server. + */ +export function hasCachedTokens(serverName: string): boolean { + return existsSync(tokenFilePath(serverName)); +} + +// ── Internal helpers ───────────────────────────────────────────────── + +/** Compute the file path for a server's cached tokens. */ +function tokenFilePath(serverName: string): string { + // Sanitise server name for safe filesystem use + const safeName = serverName.replace(/[^a-z0-9-]/g, "_"); + return join(TOKENS_DIR, `${safeName}.json`); +} + +/** Ensure the token directory exists with secure permissions. */ +function ensureTokenDir(): void { + if (!existsSync(TOKENS_DIR)) { + mkdirSync(TOKENS_DIR, { recursive: true, mode: TOKEN_DIR_MODE }); + } +} + +/** + * Warn if a token file has permissions that are too open. + * Only checks on Unix systems where `mode` is meaningful. + */ +function warnIfInsecurePermissions(filePath: string): void { + if (process.platform === "win32") return; + + try { + const stat = statSync(filePath); + // Check if group or other have any permissions + // mode & 0o077 gives us any non-owner permission bits + const insecureBits = stat.mode & 0o077; + if (insecureBits !== 0) { + console.error( + `[mcp] Warning: token file ${filePath} has insecure permissions ` + + `(${(stat.mode & 0o777).toString(8)}). Should be 600.`, + ); + } + } catch { + // Ignore — stat failure is not a security issue we can fix + } +} diff --git a/src/agent/mcp/client-manager.ts b/src/agent/mcp/client-manager.ts index 424ed8b..8fd867c 100644 --- a/src/agent/mcp/client-manager.ts +++ b/src/agent/mcp/client-manager.ts @@ -6,12 +6,17 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import { type MCPServerConfig, + type MCPHttpServerConfig, type MCPConnection, type MCPToolSchema, type MCPConnectionState, + isMCPStdioConfig, + isMCPHttpConfig, MAX_MCP_CONNECTIONS, MCP_CONNECT_TIMEOUT_MS, MCP_CALL_TIMEOUT_MS, @@ -20,6 +25,17 @@ import { MCP_MAX_DESCRIPTION_LENGTH, } from "./types.js"; import { sanitiseToolName, sanitiseDescription } from "./sanitise.js"; +import { + createBrowserOAuthProvider, + type BrowserOAuthProviderResult, +} from "./auth/browser-oauth.js"; +import { hasCachedTokens } from "./auth/token-cache.js"; +import { createRetryFetch } from "./retry-fetch.js"; +import { + loadCachedSession, + saveCachedSession, + deleteCachedSession, +} from "./session-cache.js"; /** * Create an MCP client manager that handles connection lifecycle, @@ -48,6 +64,10 @@ export function createMCPClientManager() { /** * Connect to an MCP server, discover tools, and apply filtering. + * For HTTP servers with OAuth, handles the browser auth flow: + * 1. Attempt connection (may use cached tokens) + * 2. If UnauthorizedError → open browser, wait for callback, retry + * 3. If no TTY and no cached tokens → fail with clear instructions * Throws on connection failure after timeout. */ async function connect(name: string): Promise { @@ -73,73 +93,188 @@ export function createMCPClientManager() { conn.state = "connecting"; try { - // Build environment with resolved vars - const env: Record = { - ...(process.env as Record), - ...(conn.config.env ?? {}), - }; - - const transport = new StdioClientTransport({ - command: conn.config.command, - args: conn.config.args, - env, - }); - - const client = new Client({ - name: "hyperagent", - version: "1.0.0", - }); - - // Connect with timeout - const connectPromise = client.connect(transport); - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => - reject( - new Error( - `Connection timed out after ${MCP_CONNECT_TIMEOUT_MS}ms`, - ), - ), - MCP_CONNECT_TIMEOUT_MS, - ), - ); - - await Promise.race([connectPromise, timeoutPromise]); - - // Discover tools - const toolsResult = await client.listTools(); - const rawTools = toolsResult.tools ?? []; - - // Apply allow/deny filtering and sanitise - const filtered = filterTools(rawTools, conn.config); - const sanitised = filtered.map((tool) => ({ - name: sanitiseToolName(tool.name), - originalName: tool.name, - description: sanitiseDescription(tool.description ?? ""), - inputSchema: (tool.inputSchema as Record) ?? {}, - })); - - conn.client = client; - conn.transport = transport; - conn.tools = sanitised; - conn.state = "connected"; - conn.lastError = undefined; - - console.error( - `[mcp] Connected to "${name}" — ${sanitised.length} tool(s) available`, - ); + // For HTTP + OAuth servers, handle the auth flow + if ( + isMCPHttpConfig(conn.config) && + conn.config.auth?.method === "oauth" + ) { + return await connectWithOAuth(name, conn); + } - return conn; + // Standard connection (stdio or unauthenticated HTTP) + return await connectDirect(name, conn); } catch (err) { conn.state = "error"; conn.lastError = (err as Error).message; conn.retryCount++; + // A cached session id may be stale (server-side expiry); drop it + // so the next attempt starts a fresh handshake. + deleteCachedSession(name); throw new Error( `[mcp] Failed to connect to "${name}": ${(err as Error).message}`, ); } } + /** + * Direct connection — no OAuth flow. Used for stdio and + * unauthenticated HTTP servers. + */ + async function connectDirect( + name: string, + conn: MCPConnection, + ): Promise { + const transport = createTransport(conn.config, name); + return await connectWithTransport(name, conn, transport); + } + + /** + * Connect to an HTTP server with OAuth authentication. + * Handles the full browser-based auth flow including + * UnauthorizedError retry. + */ + async function connectWithOAuth( + name: string, + conn: MCPConnection, + ): Promise { + const httpConfig = conn.config as MCPHttpServerConfig; + const authConfig = httpConfig.auth!; + + if (authConfig.method !== "oauth") { + throw new Error(`[mcp] connectWithOAuth called with non-oauth method`); + } + + // Check if we can do interactive auth + const hasCached = hasCachedTokens(name); + const isInteractive = process.stdin.isTTY === true; + + if (!hasCached && !isInteractive) { + throw new Error( + `[mcp] OAuth authentication required for "${name}" but no cached ` + + `tokens found and no interactive terminal available.\n` + + ` Run HyperAgent interactively first to authenticate:\n` + + ` npx tsx src/agent/index.ts\n` + + ` /mcp enable ${name}`, + ); + } + + // Create the OAuth provider (loads cached tokens automatically) + const { provider, waitForAuthCallback, stopCallbackServer } = + createBrowserOAuthProvider(name, authConfig); + + const url = new URL(httpConfig.url); + const requestInit: RequestInit = {}; + if (httpConfig.headers && Object.keys(httpConfig.headers).length > 0) { + requestInit.headers = { ...httpConfig.headers }; + } + + const cachedSessionId = loadCachedSession(name); + const transport = new StreamableHTTPClientTransport(url, { + authProvider: provider, + requestInit, + fetch: createRetryFetch(), + ...(cachedSessionId ? { sessionId: cachedSessionId } : {}), + }); + + try { + // First attempt — may succeed with cached tokens + return await connectWithTransport(name, conn, transport); + } catch (err) { + // If it's not an auth error, or we can't do interactive, re-throw + if (!(err instanceof UnauthorizedError) || !isInteractive) { + stopCallbackServer(); + throw err; + } + + // Interactive OAuth flow — browser was opened by the provider's + // redirectToAuthorization(), now wait for the callback + console.error(`[mcp] 🔐 Waiting for browser authentication...`); + + try { + const authCode = await waitForAuthCallback(); + await transport.finishAuth(authCode); + + console.error(`[mcp] ✅ Authentication successful — reconnecting...`); + + // Retry connection with authenticated transport + return await connectWithTransport(name, conn, transport); + } catch (authErr) { + stopCallbackServer(); + throw new Error( + `OAuth authentication failed: ${(authErr as Error).message}`, + ); + } + } + } + + /** + * Complete a connection using a pre-built transport. + * Handles client creation, timeout, tool discovery, and state update. + */ + async function connectWithTransport( + name: string, + conn: MCPConnection, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transport: any, + ): Promise { + const client = new Client({ + name: "hyperagent", + version: "1.0.0", + }); + + // Connect with timeout + const connectPromise = client.connect(transport); + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => + reject( + new Error(`Connection timed out after ${MCP_CONNECT_TIMEOUT_MS}ms`), + ), + MCP_CONNECT_TIMEOUT_MS, + ), + ); + + await Promise.race([connectPromise, timeoutPromise]); + + // Discover tools + const toolsResult = await client.listTools(); + const rawTools = toolsResult.tools ?? []; + + // Apply allow/deny filtering and sanitise + const filtered = filterTools(rawTools, conn.config); + const sanitised = filtered.map((tool) => ({ + name: sanitiseToolName(tool.name), + originalName: tool.name, + description: sanitiseDescription(tool.description ?? ""), + inputSchema: (tool.inputSchema as Record) ?? {}, + })); + + conn.client = client; + conn.transport = transport; + conn.tools = sanitised; + conn.state = "connected"; + conn.lastError = undefined; + + // Persist the session id (HTTP transports only) so we can resume on + // next start. transport.sessionId is set by the SDK after the + // initialize handshake completes. + const sessionId = + typeof transport === "object" && + transport !== null && + "sessionId" in transport + ? (transport as { sessionId?: unknown }).sessionId + : undefined; + if (typeof sessionId === "string" && sessionId.length > 0) { + saveCachedSession(name, sessionId); + } + + console.error( + `[mcp] Connected to "${name}" — ${sanitised.length} tool(s) available`, + ); + + return conn; + } + /** * Call a tool on an MCP server. Handles lazy connection and retry. */ @@ -203,16 +338,20 @@ export function createMCPClientManager() { return content; } catch (err) { - // Server may have died — mark as error for reconnect + // Server may have died or network is down — mark as error for reconnect + const msg = (err as Error).message; if ( - (err as Error).message.includes("closed") || - (err as Error).message.includes("EPIPE") || - (err as Error).message.includes("ECONNRESET") + msg.includes("closed") || + msg.includes("EPIPE") || + msg.includes("ECONNRESET") || + msg.includes("ECONNREFUSED") || + msg.includes("ETIMEDOUT") || + msg.includes("fetch failed") ) { conn.state = "error"; - conn.lastError = (err as Error).message; + conn.lastError = msg; } - return { error: `[mcp] Tool call failed: ${(err as Error).message}` }; + return { error: `[mcp] Tool call failed: ${msg}` }; } } @@ -276,6 +415,55 @@ export type MCPClientManager = ReturnType; // ── Internal helpers ───────────────────────────────────────────────── +/** + * Create the appropriate transport for a server config. + * Returns a StdioClientTransport for stdio configs and a + * StreamableHTTPClientTransport for HTTP configs. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function createTransport(config: MCPServerConfig, serverName?: string): any { + if (isMCPHttpConfig(config)) { + return createHttpTransport(config, serverName); + } + + // stdio transport (default) + const env: Record = { + ...(process.env as Record), + ...(config.env ?? {}), + }; + + return new StdioClientTransport({ + command: config.command, + args: config.args, + env, + }); +} + +/** + * Create a StreamableHTTPClientTransport for an HTTP MCP server. + * Used for unauthenticated HTTP servers and non-OAuth auth methods. + * OAuth is handled separately in connectWithOAuth(). + */ +function createHttpTransport( + config: MCPHttpServerConfig, + serverName?: string, +): StreamableHTTPClientTransport { + const url = new URL(config.url); + + // Build request init with static headers from config + const requestInit: RequestInit = {}; + if (config.headers && Object.keys(config.headers).length > 0) { + requestInit.headers = { ...config.headers }; + } + + const sessionId = serverName ? loadCachedSession(serverName) : undefined; + return new StreamableHTTPClientTransport(url, { + requestInit, + fetch: createRetryFetch(), + ...(sessionId ? { sessionId } : {}), + }); +} + /** * Filter discovered tools based on allowTools/denyTools config. * allowTools takes precedence — if set, only those are included. @@ -317,30 +505,81 @@ function extractTextContent(content: any[]): string { /** * Extract structured content from MCP response. * Returns text for text-only responses, or the full content array. + * + * Agent 365 servers return three flavours of single-text responses: + * + * 1. Clean JSON — `{"teams":[...]}` (Teams) + * 2. Status + JSON — `Success.\n{"value":[...]}` (Calendar) + * 3. Wrapped JSON — `{"rawResponse":"…","message":…}` (Mail) + * + * `extractEmbeddedJson` peels back layers (1) → (2) → (3) so callers + * always get the structured data. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractContent(content: any[]): unknown { if (!Array.isArray(content)) return content; - // Single text content → return as string + // Single text content → unwrap to structured data when possible if (content.length === 1 && content[0].type === "text") { - // Try to parse as JSON - try { - return JSON.parse(content[0].text); - } catch { - return content[0].text; - } + return extractEmbeddedJson(content[0].text); } - // Multiple items → return structured + // Multiple items → return structured, parsing each text item return content.map((c) => { if (c.type === "text") { - try { - return { type: "text", data: JSON.parse(c.text) }; - } catch { - return { type: "text", data: c.text }; - } + return { type: "text", data: extractEmbeddedJson(c.text) }; } return c; }); } + +/** + * Try to recover structured JSON from a text content payload. + * Handles three patterns observed in the wild: + * + * • clean JSON → returns the parsed object + * • status + JSON → strips leading status text, returns JSON + * • {rawResponse: "..."} → un-nests one level, recurses + * + * Falls back to the original string if no pattern matches. + */ +export function extractEmbeddedJson(text: string): unknown { + if (typeof text !== "string") return text; + const trimmed = text.trim(); + if (trimmed.length === 0) return text; + + // (1) Clean JSON — most common. + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed) as unknown; + // (3) Wrapped: { rawResponse: "", message: "..." } + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + typeof (parsed as { rawResponse?: unknown }).rawResponse === "string" + ) { + const inner = (parsed as { rawResponse: string }).rawResponse; + return extractEmbeddedJson(inner); + } + return parsed; + } catch { + // fall through + } + } + + // (2) Status + JSON — find the first "{" or "[" after some prefix and + // try parsing from there. Only accept if the suffix is itself valid + // JSON, otherwise we'd false-positive on prose containing a brace. + const firstBrace = trimmed.search(/[\{\[]/); + if (firstBrace > 0) { + const suffix = trimmed.slice(firstBrace); + try { + return JSON.parse(suffix) as unknown; + } catch { + // fall through + } + } + + return text; +} diff --git a/src/agent/mcp/config.ts b/src/agent/mcp/config.ts index 563351b..b58001c 100644 --- a/src/agent/mcp/config.ts +++ b/src/agent/mcp/config.ts @@ -1,13 +1,18 @@ // ── MCP config parser ──────────────────────────────────────────────── // // Parses and validates the `mcpServers` section of -// ~/.hyperagent/config.json. Supports ${ENV_VAR} substitution -// in env values. Rejects invalid names, collisions with native -// plugins, and configs exceeding the server limit. +// ~/.hyperagent/config.json. Supports stdio (command + args) and +// HTTP (url) transports. Handles ${ENV_VAR} substitution in env +// values. Rejects invalid names, collisions with native plugins, +// and configs exceeding the server limit. import { type MCPServerConfig, + type MCPStdioServerConfig, + type MCPHttpServerConfig, + type MCPAuthConfig, type MCPConfig, + isMCPHttpConfig, MAX_MCP_SERVERS, MCP_SERVER_NAME_PATTERN, MCP_RESERVED_NAMES, @@ -73,6 +78,7 @@ export function parseMCPConfig(raw: unknown): { /** * Validate a single server entry. + * Dispatches to stdio or HTTP validation based on the "type" field. */ function validateServerEntry(name: string, value: unknown): MCPConfigError[] { const errors: MCPConfigError[] = []; @@ -105,7 +111,35 @@ function validateServerEntry(name: string, value: unknown): MCPConfigError[] { const obj = value as Record; - // Command is required + // Dispatch based on transport type + const transportType = obj.type ?? "stdio"; + if (transportType === "http") { + errors.push(...validateHttpServerEntry(name, obj)); + } else if (transportType === "stdio") { + errors.push(...validateStdioServerEntry(name, obj)); + } else { + errors.push({ + server: name, + message: `Invalid transport type "${String(transportType)}". Must be "stdio" or "http".`, + }); + } + + // Tool filtering (shared across transports) + errors.push(...validateToolFiltering(name, obj)); + + return errors; +} + +/** + * Validate stdio-specific fields (command, args, env). + */ +function validateStdioServerEntry( + name: string, + obj: Record, +): MCPConfigError[] { + const errors: MCPConfigError[] = []; + + // Command is required for stdio if (typeof obj.command !== "string" || obj.command.trim().length === 0) { errors.push({ server: name, @@ -140,7 +174,225 @@ function validateServerEntry(name: string, value: unknown): MCPConfigError[] { } } - // allowTools must be array of strings if present + return errors; +} + +/** + * Validate HTTP-specific fields (url, headers, auth). + */ +function validateHttpServerEntry( + name: string, + obj: Record, +): MCPConfigError[] { + const errors: MCPConfigError[] = []; + + // URL is required for HTTP + if (typeof obj.url !== "string" || obj.url.trim().length === 0) { + errors.push({ + server: name, + message: + '"url" is required for HTTP transport and must be a non-empty string.', + }); + return errors; + } + + // Validate URL format + try { + const parsed = new URL(obj.url as string); + if (!["http:", "https:"].includes(parsed.protocol)) { + errors.push({ + server: name, + message: `URL must use http:// or https:// protocol (got "${parsed.protocol}").`, + }); + } + } catch { + errors.push({ + server: name, + message: `Invalid URL: "${String(obj.url)}".`, + }); + } + + // Headers must be Record if present + if (obj.headers !== undefined) { + if ( + typeof obj.headers !== "object" || + obj.headers === null || + Array.isArray(obj.headers) + ) { + errors.push({ + server: name, + message: '"headers" must be an object of string key-value pairs.', + }); + } + } + + // Validate auth config if present + if (obj.auth !== undefined) { + errors.push(...validateAuthConfig(name, obj.auth)); + } + + return errors; +} + +/** Valid auth methods. */ +const VALID_AUTH_METHODS = new Set([ + "oauth", + "workload-identity", + "client-credentials", +]); + +/** + * Validate the auth configuration for an HTTP server. + */ +function validateAuthConfig(name: string, auth: unknown): MCPConfigError[] { + const errors: MCPConfigError[] = []; + + if (typeof auth !== "object" || auth === null || Array.isArray(auth)) { + errors.push({ + server: name, + message: '"auth" must be an object.', + }); + return errors; + } + + const obj = auth as Record; + + if (typeof obj.method !== "string" || !VALID_AUTH_METHODS.has(obj.method)) { + errors.push({ + server: name, + message: `"auth.method" must be one of: ${[...VALID_AUTH_METHODS].join(", ")}.`, + }); + return errors; + } + + switch (obj.method) { + case "oauth": + errors.push(...validateOAuthConfig(name, obj)); + break; + case "workload-identity": + // No additional fields required — env vars are checked at connect time + break; + case "client-credentials": + errors.push(...validateClientCredentialsConfig(name, obj)); + break; + } + + return errors; +} + +/** + * Validate OAuth browser auth config fields. + */ +function validateOAuthConfig( + name: string, + obj: Record, +): MCPConfigError[] { + const errors: MCPConfigError[] = []; + + if (typeof obj.clientId !== "string" || obj.clientId.trim().length === 0) { + errors.push({ + server: name, + message: '"auth.clientId" is required for OAuth authentication.', + }); + } + + if (obj.tenantId !== undefined && typeof obj.tenantId !== "string") { + errors.push({ + server: name, + message: '"auth.tenantId" must be a string.', + }); + } + + if (obj.scopes !== undefined) { + if ( + !Array.isArray(obj.scopes) || + !obj.scopes.every((s: unknown) => typeof s === "string") + ) { + errors.push({ + server: name, + message: '"auth.scopes" must be an array of strings.', + }); + } + } + + if (obj.callbackPort !== undefined) { + if ( + typeof obj.callbackPort !== "number" || + !Number.isInteger(obj.callbackPort) || + obj.callbackPort < 1 || + obj.callbackPort > 65535 + ) { + errors.push({ + server: name, + message: '"auth.callbackPort" must be an integer between 1 and 65535.', + }); + } + } + + return errors; +} + +/** + * Validate client credentials auth config fields. + */ +function validateClientCredentialsConfig( + name: string, + obj: Record, +): MCPConfigError[] { + const errors: MCPConfigError[] = []; + + if (typeof obj.clientId !== "string" || obj.clientId.trim().length === 0) { + errors.push({ + server: name, + message: + '"auth.clientId" is required for client-credentials authentication.', + }); + } + + if (typeof obj.tenantId !== "string" || obj.tenantId.trim().length === 0) { + errors.push({ + server: name, + message: + '"auth.tenantId" is required for client-credentials authentication.', + }); + } + + if ( + typeof obj.clientSecretEnv !== "string" || + obj.clientSecretEnv.trim().length === 0 + ) { + errors.push({ + server: name, + message: + '"auth.clientSecretEnv" is required for client-credentials authentication ' + + "(name of env var holding the secret).", + }); + } + + if (obj.scopes !== undefined) { + if ( + !Array.isArray(obj.scopes) || + !obj.scopes.every((s: unknown) => typeof s === "string") + ) { + errors.push({ + server: name, + message: '"auth.scopes" must be an array of strings.', + }); + } + } + + return errors; +} + +/** + * Validate tool filtering fields (shared across transports). + */ +function validateToolFiltering( + name: string, + obj: Record, +): MCPConfigError[] { + const errors: MCPConfigError[] = []; + if (obj.allowTools !== undefined) { if ( !Array.isArray(obj.allowTools) || @@ -153,7 +405,6 @@ function validateServerEntry(name: string, value: unknown): MCPConfigError[] { } } - // denyTools must be array of strings if present if (obj.denyTools !== undefined) { if ( !Array.isArray(obj.denyTools) || @@ -171,12 +422,28 @@ function validateServerEntry(name: string, value: unknown): MCPConfigError[] { /** * Resolve a validated server config, performing ${ENV_VAR} substitution. + * Returns the appropriate typed config based on transport type. */ function resolveServerConfig( _name: string, raw: Record, ): MCPServerConfig { - const resolved: MCPServerConfig = { + const transportType = (raw.type as string | undefined) ?? "stdio"; + + if (transportType === "http") { + return resolveHttpServerConfig(raw); + } + + return resolveStdioServerConfig(raw); +} + +/** + * Resolve a stdio server config with env var substitution. + */ +function resolveStdioServerConfig( + raw: Record, +): MCPStdioServerConfig { + const resolved: MCPStdioServerConfig = { command: (raw.command as string).trim(), }; @@ -204,6 +471,73 @@ function resolveServerConfig( return resolved; } +/** + * Resolve an HTTP server config with auth configuration. + */ +function resolveHttpServerConfig( + raw: Record, +): MCPHttpServerConfig { + const resolved: MCPHttpServerConfig = { + type: "http", + url: (raw.url as string).trim(), + }; + + if (raw.headers) { + resolved.headers = { ...(raw.headers as Record) }; + } + + if (raw.auth) { + resolved.auth = resolveAuthConfig(raw.auth as Record); + } + + if (raw.allowTools) { + resolved.allowTools = raw.allowTools as string[]; + } + + if (raw.denyTools) { + resolved.denyTools = raw.denyTools as string[]; + } + + return resolved; +} + +/** + * Resolve auth config — copies fields without transformation. + * Env var names are stored as-is (resolved at connect time, not parse time). + */ +function resolveAuthConfig(raw: Record): MCPAuthConfig { + const method = raw.method as string; + + switch (method) { + case "oauth": + return { + method: "oauth", + clientId: (raw.clientId as string).trim(), + ...(raw.tenantId ? { tenantId: (raw.tenantId as string).trim() } : {}), + ...(raw.scopes ? { scopes: raw.scopes as string[] } : {}), + ...(raw.callbackPort + ? { callbackPort: raw.callbackPort as number } + : {}), + }; + + case "workload-identity": + return { method: "workload-identity" }; + + case "client-credentials": + return { + method: "client-credentials", + clientId: (raw.clientId as string).trim(), + tenantId: (raw.tenantId as string).trim(), + clientSecretEnv: (raw.clientSecretEnv as string).trim(), + ...(raw.scopes ? { scopes: raw.scopes as string[] } : {}), + }; + + default: + // Validation already rejects invalid methods, but satisfy TS + throw new Error(`Unknown auth method: ${method}`); + } +} + /** * Substitute ${ENV_VAR} references with values from the host environment. * Unresolved variables are left as empty strings with a warning logged. @@ -223,22 +557,36 @@ function substituteEnvVars(value: string): string { /** * Compute a config hash for approval validation. - * Includes name, command, args, tool filtering, and env key names. * Any execution-affecting config change invalidates the approval. + * + * For stdio: includes name, command, args, tool filtering, env key names. + * For HTTP: includes name, url, auth method + clientId, tool filtering. */ export function computeMCPConfigHash( name: string, config: MCPServerConfig, ): string { - return ( - createHash("sha256") - .update(name, "utf8") - .update(config.command, "utf8") - .update(JSON.stringify(config.args ?? []), "utf8") - .update(JSON.stringify(config.allowTools ?? []), "utf8") - .update(JSON.stringify(config.denyTools ?? []), "utf8") - // Hash env key names (not values — secrets stay out of the hash) - .update(JSON.stringify(Object.keys(config.env ?? {}).sort()), "utf8") - .digest("hex") - ); + const hash = createHash("sha256").update(name, "utf8"); + + if (isMCPHttpConfig(config)) { + hash.update("http", "utf8"); + hash.update(config.url, "utf8"); + if (config.auth) { + hash.update(config.auth.method, "utf8"); + if ("clientId" in config.auth) { + hash.update(config.auth.clientId, "utf8"); + } + } + } else { + hash.update("stdio", "utf8"); + hash.update(config.command, "utf8"); + hash.update(JSON.stringify(config.args ?? []), "utf8"); + // Hash env key names (not values — secrets stay out of the hash) + hash.update(JSON.stringify(Object.keys(config.env ?? {}).sort()), "utf8"); + } + + hash.update(JSON.stringify(config.allowTools ?? []), "utf8"); + hash.update(JSON.stringify(config.denyTools ?? []), "utf8"); + + return hash.digest("hex"); } diff --git a/src/agent/mcp/retry-fetch.ts b/src/agent/mcp/retry-fetch.ts new file mode 100644 index 0000000..de4d4f2 --- /dev/null +++ b/src/agent/mcp/retry-fetch.ts @@ -0,0 +1,140 @@ +// ── HTTP retry middleware for MCP transports ───────────────────────── +// +// Wraps `fetch` so transient gateway failures (429/502/503/504) trigger +// retries with exponential backoff. Honours `Retry-After` headers up to +// a sane cap. All other failures pass through untouched. +// +// The Agent 365 gateway in particular returns 502/503 occasionally +// during normal operation; without retries every reconnect is brittle. +// +// Policy (hard-coded — not configurable per-server): +// • Retry on: 429, 502, 503, 504 + network errors (TypeError from fetch) +// • Max attempts: 3 (1 initial + 2 retries) +// • Backoff: 1s, 2s (exponential, factor 2) +// • Retry-After: respected if ≤ MAX_RETRY_AFTER_MS, else fail +// +// Auth errors (401/403) are NEVER retried here — the OAuth provider +// handles them at the transport level. + +import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; + +// ── Constants ──────────────────────────────────────────────────────── + +/** Initial retry delay in milliseconds. Doubles each retry. */ +const INITIAL_BACKOFF_MS = 1000; + +/** Maximum number of attempts (initial + retries). */ +const MAX_ATTEMPTS = 3; + +/** + * Cap on Retry-After header. If the server asks us to wait longer than + * this, we fail fast rather than appearing to hang. 30s is generous + * enough for any well-behaved gateway throttle. + */ +const MAX_RETRY_AFTER_MS = 30_000; + +/** Status codes that warrant a retry. */ +const RETRYABLE_STATUS = new Set([429, 502, 503, 504]); + +// ── Public API ─────────────────────────────────────────────────────── + +/** + * Wrap a fetch implementation with retry/backoff for transient failures. + * + * @param baseFetch - The underlying fetch implementation. Defaults to + * globalThis.fetch. + * @returns A FetchLike that retries 429/502/503/504 + network errors. + */ +export function createRetryFetch(baseFetch?: FetchLike): FetchLike { + const f: FetchLike = baseFetch ?? ((url, init) => fetch(url, init)); + + return async (url, init) => { + let lastError: unknown; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + let response: Response | undefined; + try { + response = await f(url, init); + } catch (err) { + lastError = err; + // Network errors (DNS, connection refused, etc.) — retry only + // if attempts remain. AbortError indicates intentional cancel — + // never retry. + if (isAbortError(err) || attempt === MAX_ATTEMPTS) throw err; + await sleep(backoffMs(attempt)); + continue; + } + + if (!RETRYABLE_STATUS.has(response.status)) { + return response; + } + + // Retryable status. If this was the last attempt, return the + // response as-is — let the caller see the error. + if (attempt === MAX_ATTEMPTS) return response; + + const wait = retryAfterMs(response) ?? backoffMs(attempt); + if (wait > MAX_RETRY_AFTER_MS) { + // Server wants us to wait too long — give up and surface the + // response so the caller can react properly. + return response; + } + + // Drain the body so the connection can be reused. Errors here are + // best-effort — we're about to retry anyway. + try { + await response.body?.cancel(); + } catch { + // ignore + } + + await sleep(wait); + } + + // Unreachable: loop either returns or throws on the final attempt. + throw lastError ?? new Error("retry-fetch: exhausted attempts"); + }; +} + +// ── Internals ──────────────────────────────────────────────────────── + +function backoffMs(attempt: number): number { + // attempt is 1-based: 1 → 1000ms, 2 → 2000ms. + return INITIAL_BACKOFF_MS * 2 ** (attempt - 1); +} + +/** + * Parse the Retry-After header. Supports both delta-seconds and + * HTTP-date formats. Returns undefined if absent or unparseable. + */ +function retryAfterMs(response: Response): number | undefined { + const header = response.headers.get("retry-after"); + if (!header) return undefined; + const trimmed = header.trim(); + + // delta-seconds (e.g. "30") + if (/^\d+$/.test(trimmed)) { + const seconds = Number.parseInt(trimmed, 10); + return seconds * 1000; + } + + // HTTP-date (e.g. "Wed, 21 Oct 2026 07:28:00 GMT") + const date = Date.parse(trimmed); + if (!Number.isNaN(date)) { + const delta = date - Date.now(); + return delta > 0 ? delta : 0; + } + + return undefined; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isAbortError(err: unknown): boolean { + return ( + err instanceof Error && + (err.name === "AbortError" || err.message.includes("aborted")) + ); +} diff --git a/src/agent/mcp/session-cache.ts b/src/agent/mcp/session-cache.ts new file mode 100644 index 0000000..cc57d5d --- /dev/null +++ b/src/agent/mcp/session-cache.ts @@ -0,0 +1,102 @@ +// ── MCP session cache ──────────────────────────────────────────────── +// +// Persists the Mcp-Session-Id from the StreamableHTTPClientTransport so +// the agent can reattach to an existing server session across REPL +// restarts instead of doing a fresh `initialize` handshake every time. +// +// On-disk shape (~/.hyperagent/mcp-sessions/.json): +// { "savedAt": "", "sessionId": "" } +// +// Sessions naturally expire server-side; if the cached id is stale the +// server returns 404 and we just discard the file and reconnect fresh. + +import { + readFileSync, + writeFileSync, + unlinkSync, + existsSync, + mkdirSync, +} from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const SESSIONS_DIR = join(homedir(), ".hyperagent", "mcp-sessions"); +const FILE_MODE = 0o600; +const DIR_MODE = 0o700; + +/** + * 30 minutes — anything older we treat as definitely stale and don't + * even try. Keeps us from sending obviously-dead session ids. + */ +const SESSION_MAX_AGE_MS = 30 * 60 * 1000; + +interface CachedSession { + savedAt: string; + sessionId: string; +} + +function sessionFilePath(serverName: string): string { + // serverName is validated upstream (alphanumeric + hyphen) so it's + // safe to use directly in the file path. + return join(SESSIONS_DIR, `${serverName}.json`); +} + +function ensureDir(): void { + if (!existsSync(SESSIONS_DIR)) { + mkdirSync(SESSIONS_DIR, { recursive: true, mode: DIR_MODE }); + } +} + +/** + * Load a cached session id for a server. Returns undefined if missing, + * corrupt, or older than the max age. + */ +export function loadCachedSession(serverName: string): string | undefined { + const path = sessionFilePath(serverName); + try { + if (!existsSync(path)) return undefined; + const cached = JSON.parse(readFileSync(path, "utf8")) as CachedSession; + if (typeof cached.sessionId !== "string" || !cached.sessionId) { + return undefined; + } + const savedAt = Date.parse(cached.savedAt); + if (Number.isNaN(savedAt)) return undefined; + if (Date.now() - savedAt > SESSION_MAX_AGE_MS) return undefined; + return cached.sessionId; + } catch { + return undefined; + } +} + +/** + * Save a session id for a server. No-op on errors — session caching is + * an optimisation, never required for correctness. + */ +export function saveCachedSession(serverName: string, sessionId: string): void { + if (!sessionId) return; + try { + ensureDir(); + const cached: CachedSession = { + savedAt: new Date().toISOString(), + sessionId, + }; + writeFileSync(sessionFilePath(serverName), JSON.stringify(cached), { + mode: FILE_MODE, + }); + } catch { + // ignore + } +} + +/** + * Discard a cached session — call when the server has rejected the + * session (typically a 404 from the gateway). + */ +export function deleteCachedSession(serverName: string): void { + try { + const path = sessionFilePath(serverName); + if (existsSync(path)) unlinkSync(path); + } catch { + // ignore + } +} diff --git a/src/agent/mcp/types.ts b/src/agent/mcp/types.ts index 6b28856..1f72bec 100644 --- a/src/agent/mcp/types.ts +++ b/src/agent/mcp/types.ts @@ -4,11 +4,39 @@ // tool discovery. These are internal to the agent — guest code never // sees these types directly. +// ── Transport types ────────────────────────────────────────────────── + +/** Transport type discriminator. */ +export type MCPTransportType = "stdio" | "http"; + +// ── Tool filtering (shared across transports) ──────────────────────── + +/** Fields common to both stdio and HTTP server configs. */ +interface MCPServerConfigBase { + /** + * Allowlist of tool names to expose. If set, only these tools are + * available in the sandbox. Takes precedence over denyTools. + */ + allowTools?: string[]; + + /** + * Denylist of tool names to hide. If set, these tools are excluded + * even if discovered. If allowTools is also set, deny is applied + * after allow (intersection minus denied). + */ + denyTools?: string[]; +} + +// ── stdio transport ────────────────────────────────────────────────── + /** - * MCP server configuration as specified in ~/.hyperagent/config.json. - * Accepts the same format as VS Code's mcp.json for familiarity. + * Configuration for an MCP server running as a child process (stdio). + * This is the original transport and the default when "type" is omitted. */ -export interface MCPServerConfig { +export interface MCPStdioServerConfig extends MCPServerConfigBase { + /** Transport type — "stdio" or omitted (defaults to "stdio"). */ + type?: "stdio"; + /** Command to spawn the MCP server process. */ command: string; @@ -20,19 +48,128 @@ export interface MCPServerConfig { * Supports `${ENV_VAR}` substitution from the host environment. */ env?: Record; +} - /** - * Allowlist of tool names to expose. If set, only these tools are - * available in the sandbox. Takes precedence over denyTools. - */ - allowTools?: string[]; +// ── HTTP transport ─────────────────────────────────────────────────── + +/** Supported authentication methods for HTTP MCP servers. */ +export type MCPAuthMethod = + | "oauth" + | "workload-identity" + | "client-credentials"; + +/** + * OAuth 2.0 browser-based authentication (PKCE). + * Used for interactive local sessions — opens a browser for sign-in. + */ +export interface MCPOAuthConfig { + method: "oauth"; + + /** OAuth client (application) ID. */ + clientId: string; + + /** Entra ID tenant ID (for Microsoft identity). */ + tenantId?: string; + + /** Requested OAuth scopes (e.g. ["Mail.Read"]). */ + scopes?: string[]; + + /** Local port for the OAuth callback server. @default 8080 */ + callbackPort?: number; +} + +/** + * Azure Workload Identity authentication for K8s/AKS pods. + * Uses the projected service account token to obtain an Entra access token. + * Requires: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_FEDERATED_TOKEN_FILE + * environment variables (injected by the workload identity webhook). + */ +export interface MCPWorkloadIdentityConfig { + method: "workload-identity"; +} + +/** + * OAuth 2.0 client credentials flow (service-to-service, no user). + * Uses a client secret to obtain an app-level access token. + */ +export interface MCPClientCredentialsConfig { + method: "client-credentials"; + + /** OAuth client (application) ID. */ + clientId: string; + + /** Entra ID tenant ID. */ + tenantId: string; /** - * Denylist of tool names to hide. If set, these tools are excluded - * even if discovered. If allowTools is also set, deny is applied - * after allow (intersection minus denied). + * Name of the environment variable holding the client secret. + * The actual secret is never stored in config — only the env var name. */ - denyTools?: string[]; + clientSecretEnv: string; + + /** Requested scopes (e.g. ["https://graph.microsoft.com/.default"]). */ + scopes?: string[]; +} + +/** Discriminated union of all auth configurations. */ +export type MCPAuthConfig = + | MCPOAuthConfig + | MCPWorkloadIdentityConfig + | MCPClientCredentialsConfig; + +/** + * Configuration for an MCP server accessible over HTTP (Streamable HTTP). + * Used for remote servers like Microsoft Work IQ / Office 365. + */ +export interface MCPHttpServerConfig extends MCPServerConfigBase { + /** Transport type — must be "http". */ + type: "http"; + + /** Server URL (must be https:// in production). */ + url: string; + + /** Static HTTP headers to include in every request. */ + headers?: Record; + + /** Authentication configuration (omit for unauthenticated servers). */ + auth?: MCPAuthConfig; +} + +// ── Union type ─────────────────────────────────────────────────────── + +/** + * MCP server configuration — discriminated union on the `type` field. + * When `type` is omitted, defaults to stdio transport. + */ +export type MCPServerConfig = MCPStdioServerConfig | MCPHttpServerConfig; + +/** + * Type guard: returns true if the config uses HTTP transport. + */ +export function isMCPHttpConfig( + config: MCPServerConfig, +): config is MCPHttpServerConfig { + return config.type === "http"; +} + +/** + * Type guard: returns true if the config uses stdio transport. + */ +export function isMCPStdioConfig( + config: MCPServerConfig, +): config is MCPStdioServerConfig { + return config.type !== "http"; +} + +/** + * Get a human-readable connection string for display purposes. + * Returns "command args..." for stdio or the URL for HTTP servers. + */ +export function mcpConfigDisplayString(config: MCPServerConfig): string { + if (isMCPHttpConfig(config)) { + return config.url; + } + return `${config.command} ${(config.args ?? []).join(" ")}`.trim(); } /** Parsed and validated MCP configuration (all servers). */ diff --git a/src/agent/slash-commands.ts b/src/agent/slash-commands.ts index fa71f4e..7121178 100644 --- a/src/agent/slash-commands.ts +++ b/src/agent/slash-commands.ts @@ -36,6 +36,11 @@ import { auditMCPTools, } from "./mcp/approval.js"; import { maskEnvValue } from "./mcp/sanitise.js"; +import { + isMCPHttpConfig, + isMCPStdioConfig, + mcpConfigDisplayString, +} from "./mcp/types.js"; import { createMCPPluginAdapter, generateMCPDeclarations, @@ -2320,9 +2325,7 @@ export async function handleSlashCommand( const tools = s.state === "connected" ? ` — ${s.tools.length} tool(s)` : ""; console.log(` ${C.label(s.name)} [${stateColor}]${tools}`); - console.log( - ` ${C.dim(`${s.config.command} ${(s.config.args ?? []).join(" ")}`)}`, - ); + console.log(` ${C.dim(mcpConfigDisplayString(s.config))}`); } } console.log(); @@ -2371,10 +2374,10 @@ export async function handleSlashCommand( console.log(); console.log(` Server: ${C.label(mcpName)}`); console.log( - ` Command: ${C.dim(`${conn.config.command} ${(conn.config.args ?? []).join(" ")}`)}`, + ` ${isMCPStdioConfig(conn.config) ? "Command" : "URL"}: ${C.dim(mcpConfigDisplayString(conn.config))}`, ); - if (conn.config.env) { + if (isMCPStdioConfig(conn.config) && conn.config.env) { console.log(` Env vars:`); for (const [k, v] of Object.entries(conn.config.env)) { console.log(` ${k}=${C.dim(maskEnvValue(v))}`); @@ -2478,7 +2481,7 @@ export async function handleSlashCommand( console.log(` ${C.label(mcpName)}`); console.log(` State: ${info.state}`); console.log( - ` Command: ${info.config.command} ${(info.config.args ?? []).join(" ")}`, + ` ${isMCPStdioConfig(info.config) ? "Command" : "URL"}: ${mcpConfigDisplayString(info.config)}`, ); if (info.config.allowTools) { console.log(` Allow: ${info.config.allowTools.join(", ")}`); diff --git a/tests/mcp-extract-embedded-json.test.ts b/tests/mcp-extract-embedded-json.test.ts new file mode 100644 index 0000000..0b5e0a7 --- /dev/null +++ b/tests/mcp-extract-embedded-json.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; + +import { extractEmbeddedJson } from "../src/agent/mcp/client-manager.js"; + +describe("extractEmbeddedJson — Agent 365 response shapes", () => { + it("parses clean JSON objects", () => { + expect(extractEmbeddedJson('{"foo":1,"bar":[2,3]}')).toEqual({ + foo: 1, + bar: [2, 3], + }); + }); + + it("parses clean JSON arrays", () => { + expect(extractEmbeddedJson("[1,2,3]")).toEqual([1, 2, 3]); + }); + + it("strips a status prefix and parses the embedded JSON (Calendar pattern)", () => { + const text = 'Success.\n{"value":[{"id":"abc"}]}'; + expect(extractEmbeddedJson(text)).toEqual({ + value: [{ id: "abc" }], + }); + }); + + it("unwraps {rawResponse} wrapper objects (Mail pattern)", () => { + const inner = '{"value":[{"subject":"hi"}]}'; + const text = JSON.stringify({ + rawResponse: inner, + message: "Mail fetched.", + }); + expect(extractEmbeddedJson(text)).toEqual({ + value: [{ subject: "hi" }], + }); + }); + + it("unwraps a wrapper whose rawResponse itself has a status prefix", () => { + const inner = 'Success.\n{"value":["ok"]}'; + const text = JSON.stringify({ + rawResponse: inner, + message: "ok", + }); + expect(extractEmbeddedJson(text)).toEqual({ value: ["ok"] }); + }); + + it("returns the original string for prose with no JSON", () => { + expect(extractEmbeddedJson("Operation completed.")).toBe( + "Operation completed.", + ); + }); + + it("returns the original string when prefix-suffix isn't valid JSON", () => { + // A '{' inside prose with no real JSON object — must NOT match. + const text = "Failed: invalid request {bad}"; + expect(extractEmbeddedJson(text)).toBe(text); + }); + + it("preserves empty input", () => { + expect(extractEmbeddedJson("")).toBe(""); + }); +}); diff --git a/tests/mcp-retry-fetch.test.ts b/tests/mcp-retry-fetch.test.ts new file mode 100644 index 0000000..9447dd1 --- /dev/null +++ b/tests/mcp-retry-fetch.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi } from "vitest"; + +import { createRetryFetch } from "../src/agent/mcp/retry-fetch.js"; + +function makeResponse(status: number, headers: Record = {}) { + return new Response(`status-${status}`, { status, headers }); +} + +describe("createRetryFetch", () => { + it("returns a successful response without retrying", async () => { + const base = vi.fn().mockResolvedValue(makeResponse(200)); + const f = createRetryFetch(base); + const res = await f("https://example.test/", {}); + expect(res.status).toBe(200); + expect(base).toHaveBeenCalledTimes(1); + }); + + it("does not retry 4xx other than 429", async () => { + const base = vi.fn().mockResolvedValue(makeResponse(404)); + const f = createRetryFetch(base); + const res = await f("https://example.test/", {}); + expect(res.status).toBe(404); + expect(base).toHaveBeenCalledTimes(1); + }); + + it("retries 503 and succeeds on second attempt", async () => { + vi.useFakeTimers(); + const base = vi + .fn() + .mockResolvedValueOnce(makeResponse(503)) + .mockResolvedValueOnce(makeResponse(200)); + const f = createRetryFetch(base); + const promise = f("https://example.test/", {}); + await vi.runAllTimersAsync(); + const res = await promise; + expect(res.status).toBe(200); + expect(base).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it("retries 429/502/504 and gives up after MAX_ATTEMPTS", async () => { + vi.useFakeTimers(); + const base = vi.fn().mockResolvedValue(makeResponse(502)); + const f = createRetryFetch(base); + const promise = f("https://example.test/", {}); + await vi.runAllTimersAsync(); + const res = await promise; + expect(res.status).toBe(502); + expect(base).toHaveBeenCalledTimes(3); + vi.useRealTimers(); + }); + + it("respects Retry-After (delta-seconds) when reasonable", async () => { + vi.useFakeTimers(); + const base = vi + .fn() + .mockResolvedValueOnce(makeResponse(429, { "retry-after": "2" })) + .mockResolvedValueOnce(makeResponse(200)); + const f = createRetryFetch(base); + const promise = f("https://example.test/", {}); + await vi.runAllTimersAsync(); + const res = await promise; + expect(res.status).toBe(200); + expect(base).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it("gives up immediately if Retry-After exceeds the cap", async () => { + const base = vi + .fn() + .mockResolvedValueOnce(makeResponse(429, { "retry-after": "999" })); + const f = createRetryFetch(base); + const res = await f("https://example.test/", {}); + expect(res.status).toBe(429); + expect(base).toHaveBeenCalledTimes(1); + }); + + it("retries network errors and surfaces the final one", async () => { + vi.useFakeTimers(); + const err = new TypeError("fetch failed"); + const base = vi.fn().mockImplementation(() => Promise.reject(err)); + const f = createRetryFetch(base); + const promise = f("https://example.test/", {}); + // Swallow the unhandled rejection until we await the result below. + promise.catch(() => undefined); + await vi.runAllTimersAsync(); + await expect(promise).rejects.toBe(err); + expect(base).toHaveBeenCalledTimes(3); + vi.useRealTimers(); + }); + + it("does not retry AbortError", async () => { + const err = Object.assign(new Error("aborted"), { name: "AbortError" }); + const base = vi.fn().mockImplementation(() => Promise.reject(err)); + const f = createRetryFetch(base); + await expect(f("https://example.test/", {})).rejects.toBe(err); + expect(base).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 2055873..c07d64f 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -16,6 +16,15 @@ import { generateMCPModuleHints, } from "../src/agent/mcp/plugin-adapter.js"; import type { MCPToolSchema } from "../src/agent/mcp/types.js"; +import { + isMCPHttpConfig, + isMCPStdioConfig, + mcpConfigDisplayString, +} from "../src/agent/mcp/types.js"; +import type { + MCPStdioServerConfig, + MCPHttpServerConfig, +} from "../src/agent/mcp/types.js"; // ── Config parser ──────────────────────────────────────────────────── @@ -32,8 +41,12 @@ describe("parseMCPConfig", () => { expect(errors).toHaveLength(0); expect(config.servers.size).toBe(1); const server = config.servers.get("weather"); - expect(server?.command).toBe("node"); - expect(server?.args).toEqual(["weather-server.js"]); + expect(server).toBeDefined(); + expect(isMCPStdioConfig(server!)).toBe(true); + if (isMCPStdioConfig(server!)) { + expect(server!.command).toBe("node"); + expect(server!.args).toEqual(["weather-server.js"]); + } }); it("returns empty config for undefined input", () => { @@ -96,7 +109,10 @@ describe("parseMCPConfig", () => { }); const server = config.servers.get("weather"); - expect(server?.env?.API_KEY).toBe("secret-value"); + expect(isMCPStdioConfig(server!)).toBe(true); + if (isMCPStdioConfig(server!)) { + expect(server!.env?.API_KEY).toBe("secret-value"); + } delete process.env.TEST_MCP_KEY; }); @@ -115,6 +131,232 @@ describe("parseMCPConfig", () => { expect(server?.allowTools).toEqual(["list_issues", "create_issue"]); expect(server?.denyTools).toEqual(["delete_branch"]); }); + + // ── HTTP transport configs ──────────────────────────────────────── + + it("parses valid HTTP config (no auth)", () => { + const { config, errors } = parseMCPConfig({ + "remote-server": { + type: "http", + url: "https://example.com/mcp", + }, + }); + + expect(errors).toHaveLength(0); + expect(config.servers.size).toBe(1); + const server = config.servers.get("remote-server"); + expect(server).toBeDefined(); + expect(isMCPHttpConfig(server!)).toBe(true); + expect((server as MCPHttpServerConfig).url).toBe("https://example.com/mcp"); + }); + + it("parses HTTP config with OAuth auth", () => { + const { config, errors } = parseMCPConfig({ + "work-iq-mail": { + type: "http", + url: "https://agent365.svc.cloud.microsoft/mcp", + auth: { + method: "oauth", + clientId: "18f4deab-76fc-406d-b9d8-3cc0377fa30d", + tenantId: "9c23c1e3-15be-4744-a3d7-027089c33654", + scopes: ["Mail.Read"], + callbackPort: 8080, + }, + }, + }); + + expect(errors).toHaveLength(0); + const server = config.servers.get("work-iq-mail") as MCPHttpServerConfig; + expect(server.type).toBe("http"); + expect(server.auth).toBeDefined(); + expect(server.auth!.method).toBe("oauth"); + if (server.auth!.method === "oauth") { + expect(server.auth!.clientId).toBe( + "18f4deab-76fc-406d-b9d8-3cc0377fa30d", + ); + expect(server.auth!.tenantId).toBe( + "9c23c1e3-15be-4744-a3d7-027089c33654", + ); + expect(server.auth!.scopes).toEqual(["Mail.Read"]); + expect(server.auth!.callbackPort).toBe(8080); + } + }); + + it("parses HTTP config with workload-identity auth", () => { + const { config, errors } = parseMCPConfig({ + "work-iq-calendar": { + type: "http", + url: "https://agent365.svc.cloud.microsoft/mcp/calendar", + auth: { + method: "workload-identity", + }, + }, + }); + + expect(errors).toHaveLength(0); + const server = config.servers.get( + "work-iq-calendar", + ) as MCPHttpServerConfig; + expect(server.auth?.method).toBe("workload-identity"); + }); + + it("parses HTTP config with client-credentials auth", () => { + const { config, errors } = parseMCPConfig({ + "service-api": { + type: "http", + url: "https://api.example.com/mcp", + auth: { + method: "client-credentials", + clientId: "app-id-123", + tenantId: "tenant-456", + clientSecretEnv: "MY_CLIENT_SECRET", + scopes: ["https://api.example.com/.default"], + }, + }, + }); + + expect(errors).toHaveLength(0); + const server = config.servers.get("service-api") as MCPHttpServerConfig; + expect(server.auth?.method).toBe("client-credentials"); + if (server.auth?.method === "client-credentials") { + expect(server.auth.clientSecretEnv).toBe("MY_CLIENT_SECRET"); + } + }); + + it("parses HTTP config with headers", () => { + const { config, errors } = parseMCPConfig({ + "custom-server": { + type: "http", + url: "https://mcp.example.com", + headers: { "X-Api-Key": "test-key" }, + }, + }); + + expect(errors).toHaveLength(0); + const server = config.servers.get("custom-server") as MCPHttpServerConfig; + expect(server.headers).toEqual({ "X-Api-Key": "test-key" }); + }); + + it("parses HTTP config with allowTools/denyTools", () => { + const { config, errors } = parseMCPConfig({ + "filtered-http": { + type: "http", + url: "https://example.com/mcp", + allowTools: ["read_mail"], + denyTools: ["delete_mail"], + }, + }); + + expect(errors).toHaveLength(0); + const server = config.servers.get("filtered-http"); + expect(server?.allowTools).toEqual(["read_mail"]); + expect(server?.denyTools).toEqual(["delete_mail"]); + }); + + it("defaults to stdio when type is omitted", () => { + const { config, errors } = parseMCPConfig({ + legacy: { command: "node", args: ["server.js"] }, + }); + + expect(errors).toHaveLength(0); + const server = config.servers.get("legacy"); + expect(isMCPStdioConfig(server!)).toBe(true); + expect(isMCPHttpConfig(server!)).toBe(false); + }); + + it("rejects HTTP config with missing url", () => { + const { errors } = parseMCPConfig({ + "no-url": { type: "http" }, + }); + expect(errors.some((e) => e.message.includes("url"))).toBe(true); + }); + + it("rejects HTTP config with invalid URL", () => { + const { errors } = parseMCPConfig({ + "bad-url": { type: "http", url: "not-a-url" }, + }); + expect(errors.some((e) => e.message.includes("Invalid URL"))).toBe(true); + }); + + it("rejects HTTP config with non-http protocol", () => { + const { errors } = parseMCPConfig({ + "ftp-url": { type: "http", url: "ftp://example.com/mcp" }, + }); + expect(errors.some((e) => e.message.includes("http://"))).toBe(true); + }); + + it("rejects invalid transport type", () => { + const { errors } = parseMCPConfig({ + "bad-type": { type: "websocket", url: "ws://example.com" }, + }); + expect( + errors.some((e) => e.message.includes("Invalid transport type")), + ).toBe(true); + }); + + it("rejects invalid auth method", () => { + const { errors } = parseMCPConfig({ + "bad-auth": { + type: "http", + url: "https://example.com/mcp", + auth: { method: "magic" }, + }, + }); + expect(errors.some((e) => e.message.includes("auth.method"))).toBe(true); + }); + + it("rejects OAuth auth missing clientId", () => { + const { errors } = parseMCPConfig({ + "no-client-id": { + type: "http", + url: "https://example.com/mcp", + auth: { method: "oauth" }, + }, + }); + expect(errors.some((e) => e.message.includes("auth.clientId"))).toBe(true); + }); + + it("rejects OAuth auth with invalid callbackPort", () => { + const { errors } = parseMCPConfig({ + "bad-port": { + type: "http", + url: "https://example.com/mcp", + auth: { method: "oauth", clientId: "abc", callbackPort: 99999 }, + }, + }); + expect(errors.some((e) => e.message.includes("callbackPort"))).toBe(true); + }); + + it("rejects client-credentials auth missing required fields", () => { + const { errors } = parseMCPConfig({ + "bad-creds": { + type: "http", + url: "https://example.com/mcp", + auth: { method: "client-credentials" }, + }, + }); + expect(errors.some((e) => e.message.includes("auth.clientId"))).toBe(true); + expect(errors.some((e) => e.message.includes("auth.tenantId"))).toBe(true); + expect(errors.some((e) => e.message.includes("auth.clientSecretEnv"))).toBe( + true, + ); + }); + + it("allows mixed stdio and HTTP servers", () => { + const { config, errors } = parseMCPConfig({ + "local-weather": { command: "node", args: ["weather.js"] }, + "remote-mail": { + type: "http", + url: "https://agent365.svc.cloud.microsoft/mcp", + auth: { method: "oauth", clientId: "abc" }, + }, + }); + + expect(errors).toHaveLength(0); + expect(config.servers.size).toBe(2); + expect(isMCPStdioConfig(config.servers.get("local-weather")!)).toBe(true); + expect(isMCPHttpConfig(config.servers.get("remote-mail")!)).toBe(true); + }); }); describe("computeMCPConfigHash", () => { @@ -184,6 +426,444 @@ describe("computeMCPConfigHash", () => { }); expect(hash1).not.toBe(hash2); }); + + // ── HTTP config hashes ─────────────────────────────────────────── + + it("produces consistent hash for HTTP config", () => { + const config: MCPHttpServerConfig = { + type: "http", + url: "https://example.com/mcp", + }; + const hash1 = computeMCPConfigHash("test", config); + const hash2 = computeMCPConfigHash("test", config); + expect(hash1).toBe(hash2); + }); + + it("changes when HTTP url changes", () => { + const hash1 = computeMCPConfigHash("test", { + type: "http" as const, + url: "https://a.example.com/mcp", + }); + const hash2 = computeMCPConfigHash("test", { + type: "http" as const, + url: "https://b.example.com/mcp", + }); + expect(hash1).not.toBe(hash2); + }); + + it("changes when HTTP auth method changes", () => { + const hash1 = computeMCPConfigHash("test", { + type: "http" as const, + url: "https://example.com/mcp", + auth: { method: "oauth" as const, clientId: "abc" }, + }); + const hash2 = computeMCPConfigHash("test", { + type: "http" as const, + url: "https://example.com/mcp", + auth: { method: "workload-identity" as const }, + }); + expect(hash1).not.toBe(hash2); + }); + + it("changes when HTTP auth clientId changes", () => { + const hash1 = computeMCPConfigHash("test", { + type: "http" as const, + url: "https://example.com/mcp", + auth: { method: "oauth" as const, clientId: "abc" }, + }); + const hash2 = computeMCPConfigHash("test", { + type: "http" as const, + url: "https://example.com/mcp", + auth: { method: "oauth" as const, clientId: "xyz" }, + }); + expect(hash1).not.toBe(hash2); + }); + + it("produces different hashes for stdio vs HTTP with same name", () => { + const hash1 = computeMCPConfigHash("test", { command: "node" }); + const hash2 = computeMCPConfigHash("test", { + type: "http" as const, + url: "https://example.com/mcp", + }); + expect(hash1).not.toBe(hash2); + }); +}); + +// ── Type guards & display helpers ──────────────────────────────────── + +describe("isMCPHttpConfig / isMCPStdioConfig", () => { + it("identifies HTTP config", () => { + const config: MCPHttpServerConfig = { + type: "http", + url: "https://example.com/mcp", + }; + expect(isMCPHttpConfig(config)).toBe(true); + expect(isMCPStdioConfig(config)).toBe(false); + }); + + it("identifies stdio config (explicit type)", () => { + const config: MCPStdioServerConfig = { + type: "stdio", + command: "node", + }; + expect(isMCPStdioConfig(config)).toBe(true); + expect(isMCPHttpConfig(config)).toBe(false); + }); + + it("identifies stdio config (type omitted)", () => { + const config: MCPStdioServerConfig = { command: "node" }; + expect(isMCPStdioConfig(config)).toBe(true); + expect(isMCPHttpConfig(config)).toBe(false); + }); +}); + +describe("mcpConfigDisplayString", () => { + it("returns command + args for stdio", () => { + expect( + mcpConfigDisplayString({ command: "npx", args: ["-y", "server"] }), + ).toBe("npx -y server"); + }); + + it("returns command only when no args", () => { + expect(mcpConfigDisplayString({ command: "node" })).toBe("node"); + }); + + it("returns url for HTTP", () => { + expect( + mcpConfigDisplayString({ + type: "http", + url: "https://example.com/mcp", + }), + ).toBe("https://example.com/mcp"); + }); +}); + +// ── Client manager (HTTP transport) ────────────────────────────────── + +import { createMCPClientManager } from "../src/agent/mcp/client-manager.js"; + +describe("createMCPClientManager — HTTP transport", () => { + it("registers HTTP server without connecting", () => { + const manager = createMCPClientManager(); + manager.registerServer("remote", { + type: "http", + url: "https://example.com/mcp", + }); + + const servers = manager.listServers(); + expect(servers).toHaveLength(1); + expect(servers[0].name).toBe("remote"); + expect(servers[0].state).toBe("idle"); + expect(isMCPHttpConfig(servers[0].config)).toBe(true); + }); + + it("registers mixed stdio and HTTP servers", () => { + const manager = createMCPClientManager(); + manager.registerServer("local", { + command: "node", + args: ["server.js"], + }); + manager.registerServer("remote", { + type: "http", + url: "https://example.com/mcp", + }); + + const servers = manager.listServers(); + expect(servers).toHaveLength(2); + expect(isMCPStdioConfig(servers[0].config)).toBe(true); + expect(isMCPHttpConfig(servers[1].config)).toBe(true); + }); + + it("HTTP connect fails gracefully for unreachable server", async () => { + const manager = createMCPClientManager(); + manager.registerServer("unreachable", { + type: "http", + url: "https://localhost:19999/mcp-does-not-exist", + }); + + await expect(manager.connect("unreachable")).rejects.toThrow( + /Failed to connect/, + ); + + const conn = manager.getConnection("unreachable"); + expect(conn?.state).toBe("error"); + expect(conn?.retryCount).toBe(1); + }); + + it("HTTP + OAuth fails with clear message when no TTY and no cached tokens", async () => { + // Save original isTTY and override + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + + const manager = createMCPClientManager(); + manager.registerServer("oauth-headless", { + type: "http", + url: "https://localhost:19999/mcp", + auth: { method: "oauth", clientId: "test-id" }, + }); + + await expect(manager.connect("oauth-headless")).rejects.toThrow( + /no cached tokens.*no interactive terminal/i, + ); + + // Restore + Object.defineProperty(process.stdin, "isTTY", { + value: originalIsTTY, + configurable: true, + }); + }); + + it("HTTP + non-OAuth auth (workload-identity) connects without browser flow", async () => { + const manager = createMCPClientManager(); + manager.registerServer("wi-server", { + type: "http", + url: "https://localhost:19999/mcp-wi", + auth: { method: "workload-identity" }, + }); + + // Will fail (no server) but should NOT trigger the OAuth flow — + // should fail with a connection error, not an auth error + await expect(manager.connect("wi-server")).rejects.toThrow( + /Failed to connect/, + ); + }); + + it("disconnect on unconnected HTTP server is safe", async () => { + const manager = createMCPClientManager(); + manager.registerServer("remote", { + type: "http", + url: "https://example.com/mcp", + }); + + // Should not throw + await manager.disconnect("remote"); + const conn = manager.getConnection("remote"); + expect(conn?.state).toBe("closed"); + }); +}); + +// ── Token cache ────────────────────────────────────────────────────── + +import { + loadCachedTokens, + saveCachedTokens, + deleteCachedTokens, + hasCachedTokens, +} from "../src/agent/mcp/auth/token-cache.js"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { tmpdir } from "node:os"; + +describe("token cache", () => { + // Use a unique server name per test to avoid conflicts + const testServer = `test-cache-${Date.now()}`; + + afterEach(() => { + // Clean up test tokens + deleteCachedTokens(testServer); + }); + + it("returns undefined for non-existent cache", () => { + expect(loadCachedTokens("non-existent-server-xyz")).toBeUndefined(); + }); + + it("saves and loads tokens", () => { + const tokens = { + access_token: "test-access-token", + token_type: "Bearer", + refresh_token: "test-refresh-token", + expires_in: 3600, + }; + + saveCachedTokens(testServer, tokens); + expect(hasCachedTokens(testServer)).toBe(true); + + const loaded = loadCachedTokens(testServer); + expect(loaded).toBeDefined(); + expect(loaded!.access_token).toBe("test-access-token"); + expect(loaded!.token_type).toBe("Bearer"); + expect(loaded!.refresh_token).toBe("test-refresh-token"); + }); + + it("deletes cached tokens", () => { + saveCachedTokens(testServer, { + access_token: "deleteme", + token_type: "Bearer", + }); + expect(hasCachedTokens(testServer)).toBe(true); + + deleteCachedTokens(testServer); + expect(hasCachedTokens(testServer)).toBe(false); + expect(loadCachedTokens(testServer)).toBeUndefined(); + }); + + it("delete is safe for non-existent cache", () => { + // Should not throw + deleteCachedTokens("does-not-exist-xyz"); + }); + + it("overwrites existing tokens on save", () => { + saveCachedTokens(testServer, { + access_token: "old-token", + token_type: "Bearer", + }); + saveCachedTokens(testServer, { + access_token: "new-token", + token_type: "Bearer", + }); + + const loaded = loadCachedTokens(testServer); + expect(loaded!.access_token).toBe("new-token"); + }); +}); + +// ── Browser OAuth provider ─────────────────────────────────────────── + +import { createBrowserOAuthProvider } from "../src/agent/mcp/auth/browser-oauth.js"; +import { afterEach } from "vitest"; + +describe("createBrowserOAuthProvider", () => { + const testServer = `test-oauth-${Date.now()}`; + + afterEach(() => { + deleteCachedTokens(testServer); + }); + + it("creates a provider with correct client metadata", () => { + const { provider, stopCallbackServer } = createBrowserOAuthProvider( + testServer, + { + method: "oauth", + clientId: "test-client-id", + scopes: ["Mail.Read", "Calendar.Read"], + callbackPort: 9999, + }, + ); + + const metadata = provider.clientMetadata; + expect(metadata.client_name).toBe("HyperAgent"); + expect(metadata.grant_types).toContain("authorization_code"); + expect(metadata.grant_types).toContain("refresh_token"); + expect(metadata.scope).toBe("Mail.Read Calendar.Read"); + + const redirectUrl = provider.redirectUrl; + expect(redirectUrl).toBeDefined(); + expect(redirectUrl!.toString()).toContain("localhost:9999"); + + stopCallbackServer(); + }); + + it("returns static client information", async () => { + const { provider, stopCallbackServer } = createBrowserOAuthProvider( + testServer, + { + method: "oauth", + clientId: "my-app-id", + }, + ); + + const info = await provider.clientInformation(); + expect(info).toBeDefined(); + expect(info!.client_id).toBe("my-app-id"); + + stopCallbackServer(); + }); + + it("stores and retrieves code verifier", () => { + const { provider, stopCallbackServer } = createBrowserOAuthProvider( + testServer, + { + method: "oauth", + clientId: "test-id", + }, + ); + + provider.saveCodeVerifier("test-verifier-123"); + expect(provider.codeVerifier()).toBe("test-verifier-123"); + + stopCallbackServer(); + }); + + it("saves and loads tokens via cache", async () => { + const { provider, stopCallbackServer } = createBrowserOAuthProvider( + testServer, + { + method: "oauth", + clientId: "test-id", + }, + ); + + // Initially no tokens + expect(await provider.tokens()).toBeUndefined(); + + // Save tokens + provider.saveTokens({ + access_token: "cached-access", + token_type: "Bearer", + refresh_token: "cached-refresh", + }); + + // Should load from cache + const loaded = await provider.tokens(); + expect(loaded).toBeDefined(); + expect(loaded!.access_token).toBe("cached-access"); + + stopCallbackServer(); + }); + + it("invalidateCredentials clears tokens", async () => { + const { provider, stopCallbackServer } = createBrowserOAuthProvider( + testServer, + { + method: "oauth", + clientId: "test-id", + }, + ); + + provider.saveTokens({ + access_token: "to-be-cleared", + token_type: "Bearer", + }); + expect(await provider.tokens()).toBeDefined(); + + provider.invalidateCredentials!("tokens"); + expect(await provider.tokens()).toBeUndefined(); + + stopCallbackServer(); + }); + + it("uses default callback port when not specified", () => { + const { provider, stopCallbackServer } = createBrowserOAuthProvider( + testServer, + { + method: "oauth", + clientId: "test-id", + }, + ); + + const redirectUrl = provider.redirectUrl; + expect(redirectUrl!.toString()).toContain("localhost:8080"); + + stopCallbackServer(); + }); + + it("omits scope when not configured", () => { + const { provider, stopCallbackServer } = createBrowserOAuthProvider( + testServer, + { + method: "oauth", + clientId: "test-id", + }, + ); + + expect(provider.clientMetadata.scope).toBeUndefined(); + + stopCallbackServer(); + }); }); // ── Sanitisation ───────────────────────────────────────────────────── From 1ea078764a0ca1b5e0f3c25776457c7a26a81900 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 14:32:53 +0100 Subject: [PATCH 02/15] Fix build-modules.js fresh-checkout failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/build-modules.js was regenerating ha-modules.d.ts BEFORE running tsc. On a fresh checkout the gitignored builtin-modules/*.d.ts files don't exist, so the regenerated ha-modules.d.ts only contained the 4 native module declarations — wiping the up-to-date committed file. tsc then failed with cascading TS2305 errors: src/pptx-charts.ts: Module 'ha:ooxml-core' has no exported member '_createShapeFragment' (and similar for ShapeFragment, isShapeFragment, fragmentsToXml, MAX_CHARTS_PER_DECK, etc.) CI didn't catch this because it relied on cached / pre-built .d.ts files surviving between runs. The dts-sync test (which would catch drift) only runs after a successful build. Fix: swap the order. Run tsc first using the committed (correct) ha-modules.d.ts to emit fresh per-module .d.ts files, THEN regenerate ha-modules.d.ts from those fresh files. The regenerated output now matches committed byte-for-byte, and dts-sync.test.ts continues to catch drift. --- scripts/build-modules.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/scripts/build-modules.js b/scripts/build-modules.js index b38a3c5..d16b7eb 100644 --- a/scripts/build-modules.js +++ b/scripts/build-modules.js @@ -46,16 +46,27 @@ execSync("npx tsx scripts/generate-native-dts.ts", { stdio: "inherit", }); -// Step 2: Regenerate ha-modules.d.ts so tsc can resolve native module imports +// Step 2: Compile TypeScript using the committed ha-modules.d.ts (which is +// up to date — enforced by tests/dts-sync.test.ts). tsc emits fresh .d.ts +// files for every src/*.ts module. +// +// This MUST run before regenerating ha-modules.d.ts. On a fresh checkout +// the gitignored builtin-modules/*.d.ts files don't exist yet — if we +// regenerated ha-modules.d.ts first it would be a partial file (only +// the 4 native modules are present), and tsc would then fail because +// the ambient `declare module "ha:ooxml-core"` block (etc) was wiped. +execSync("tsc --project tsconfig.json", { cwd: BUILTIN_DIR, stdio: "inherit" }); + +// Step 3: Regenerate ha-modules.d.ts from the now-fresh .d.ts files. +// Output should match the committed ha-modules.d.ts byte-for-byte, modulo +// any actual API changes the user just made — which is exactly what +// tests/dts-sync.test.ts catches. console.log("\nGenerating ha-modules.d.ts..."); execSync("npx tsx scripts/generate-ha-modules-dts.ts", { cwd: ROOT, stdio: "inherit", }); -// Step 3: Compile TypeScript (can now resolve ha:ziplib etc. via ha-modules.d.ts) -execSync("tsc --project tsconfig.json", { cwd: BUILTIN_DIR, stdio: "inherit" }); - // Step 4: Format with Prettier execSync(`prettier --write "${BUILTIN_DIR}/*.js"`, { cwd: ROOT, From 5ada57453013c381a86d1f57f02a7a98a741c6cb Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 14:46:34 +0100 Subject: [PATCH 03/15] Add cross-platform 'just clean' that wipes generated build outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously 'just clean' only removed dist/ and node_modules/. The real landmines after a failed build are: - gitignored builtin-modules/*.{js,d.ts} (except the committed _save.js / _restore.js) - gitignored plugins/*/index.d.ts and plugins/shared/*.js - generated plugins/host-modules.d.ts and plugin-schema-types.d.ts - a clobbered (tracked) builtin-modules/src/types/ha-modules.d.ts — 'git pull' refuses to overwrite it because it has local changes from the broken build, so subsequent setups keep failing The new recipe wipes all of these and restores ha-modules.d.ts from HEAD, so a single 'just clean && just setup' recovers from any mid-build failure on every supported platform (unix + windows variants provided). --- Justfile | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Justfile b/Justfile index fc9a446..cdefc22 100644 --- a/Justfile +++ b/Justfile @@ -301,8 +301,36 @@ check: lint-all test-all @echo "✅ All checks passed — you may proceed to commit" # Clean build artifacts (keeps deps/) +# +# Removes: +# - node_modules and dist (npm/binary outputs) +# - generated builtin-modules/*.{js,d.ts,d.ts.map} (preserves +# _save.js / _restore.js which ARE committed) +# - generated plugin .d.ts files and plugins/shared/*.js +# - generated plugins/host-modules.d.ts +# +# Use this when a previous build failed mid-way and left stale +# generated files that confuse `just setup` / `just build`. +[unix] clean: + #!/usr/bin/env bash + set -euo pipefail rm -rf dist node_modules + # Wipe gitignored builtin-modules build outputs (keep _save.js / _restore.js) + find builtin-modules -maxdepth 1 \ + \( -name '*.js' -o -name '*.d.ts' -o -name '*.d.ts.map' \) \ + ! -name '_save.js' ! -name '_restore.js' -delete 2>/dev/null || true + # Wipe gitignored plugin build outputs + find plugins -maxdepth 3 -name '*.d.ts' -delete 2>/dev/null || true + find plugins/shared -maxdepth 1 -name '*.js' -delete 2>/dev/null || true + rm -f plugins/host-modules.d.ts plugins/plugin-schema-types.d.ts + # Restore committed ha-modules.d.ts in case a failed build clobbered it + git checkout -- builtin-modules/src/types/ha-modules.d.ts 2>/dev/null || true + echo "🧹 Cleaned build artefacts" + +[windows] +clean: + if (Test-Path dist) { Remove-Item -Recurse -Force dist }; if (Test-Path node_modules) { Remove-Item -Recurse -Force node_modules }; Get-ChildItem builtin-modules -File | Where-Object { ($_.Extension -in '.js','.ts','.map') -and ($_.Name -notin '_save.js','_restore.js') } | Remove-Item -Force -ErrorAction SilentlyContinue; Get-ChildItem plugins -Recurse -Filter '*.d.ts' | Remove-Item -Force -ErrorAction SilentlyContinue; Get-ChildItem plugins/shared -Filter '*.js' -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue; if (Test-Path plugins/host-modules.d.ts) { Remove-Item plugins/host-modules.d.ts }; if (Test-Path plugins/plugin-schema-types.d.ts) { Remove-Item plugins/plugin-schema-types.d.ts }; git checkout -- builtin-modules/src/types/ha-modules.d.ts 2>$null; Write-Output "🧹 Cleaned build artefacts" # Clean everything including deps/ symlinks clean-all: clean From 2c3d1b41ea58c9d53a691fe9fa3692ddc3011771 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 15:07:47 +0100 Subject: [PATCH 04/15] Fix M365 MCP URLs: inject tenantId into path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent 365 gateway requires a tenant-scoped path: https:///agents/tenants//servers/ The discoverToolServers endpoint returns un-tenanted URLs of the form https:///agents/servers/ because the tenant comes from the caller's context. We were storing the discovery URL verbatim in ~/.hyperagent/config.json — which made the gateway respond: {"code":"EndpointInvalid", "message":"Tenant id is invalid.", "innererror":{"code":"TenantIdInvalid"}} (note the double space — empty tenantId substitution). Fix: m365-setup.ts now rewrites each catalog URL at config-write time via injectTenantIntoUrl(), splicing /tenants/ into the path. The catalog itself still stores the canonical discovery URL so it stays tenant-agnostic and re-usable across users. Apologies for previously concluding the catalog URL pattern was correct based on raw curl tests — the SDK's full handshake reaches a deeper code path that surfaces the real tenant requirement. --- Justfile | 11 +++++--- scripts/m365-setup.ts | 62 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/Justfile b/Justfile index cdefc22..9bd267e 100644 --- a/Justfile +++ b/Justfile @@ -878,12 +878,15 @@ mcp-m365-create-app *ARGS: # from ~/.hyperagent/m365.json by default; override with explicit args. # # Each server uses the URL and per-server scope discovered from -# Agent 365 (see catalog file). The Agent 365 gateway uses the -# /agents/servers/ URL pattern — NOT /tenants//servers/ -# from the MS Learn docs (verified against discoverToolServers). +# Agent 365 (see catalog file). The catalog stores the discovery URL +# (/agents/servers/); the setup script injects the caller's +# tenantId at config-write time to produce the actual gateway URL +# (/agents/tenants//servers/) that the gateway requires — +# without it the server returns EndpointInvalid / TenantIdInvalid. # # Args: -# SERVICES "all" (default), or comma-separated alias list ("mail,teams") +# SERVICES "all" (default), comma-separated alias list ("mail,teams"), +# or "list" to print all known service aliases and exit. # CLIENT_ID Override Entra app client id # TENANT_ID Override Entra tenant id (used for OAuth authority) # SCOPE_OVERRIDE Optional: force a single scope for every server diff --git a/scripts/m365-setup.ts b/scripts/m365-setup.ts index e59f7bf..4e19b6e 100644 --- a/scripts/m365-setup.ts +++ b/scripts/m365-setup.ts @@ -9,7 +9,8 @@ // Usage: // tsx scripts/m365-setup.ts [services] [clientId] [tenantId] [scopeOverride] // -// services "all" (default) or comma-separated alias list +// services "all" (default), comma-separated alias list, or +// "list" to print the catalog and exit. // clientId Override Entra app client id (else read from state) // tenantId Override Entra tenant id (else read from state) // scopeOverride Force a single scope for every server (testing) @@ -86,6 +87,36 @@ function readJson(path: string): T | undefined { } } +/** + * Rewrite a discovery URL (`/agents/servers/`) into the + * tenant-scoped form the Agent 365 gateway actually serves + * (`/agents/tenants//servers/`). + * + * If the URL already contains `/agents/tenants/...` it's left alone + * (the catalog could legitimately store tenant-already-baked URLs in + * the future). + */ +function injectTenantIntoUrl(url: string, tenantId: string): string { + if (!tenantId) { + fail("tenantId is required to build M365 MCP server URLs"); + } + if (url.includes("/agents/tenants/")) return url; + const marker = "/agents/servers/"; + const idx = url.indexOf(marker); + if (idx === -1) { + fail( + `Catalog URL does not contain '${marker}' — cannot inject tenant: ${url}`, + ); + } + return ( + url.slice(0, idx) + + "/agents/tenants/" + + tenantId + + "/servers/" + + url.slice(idx + marker.length) + ); +} + function writeServerEntry( configFile: string, name: string, @@ -149,6 +180,24 @@ function main(): void { if (!catalog) fail(`Catalog missing: ${CATALOG_PATH}`); const known = Object.keys(catalog.servers); const raw = (servicesArg || "all").trim().toLowerCase(); + + // `list` / `--list` / `ls`: print catalog and exit (no config writes). + if (raw === "list" || raw === "--list" || raw === "ls") { + console.log("Available M365 / Agent 365 MCP servers:\n"); + const sorted = [...known].sort(); + const aliasWidth = Math.max(...sorted.map((a) => a.length)); + for (const alias of sorted) { + const srv = catalog.servers[alias]; + console.log(` ${alias.padEnd(aliasWidth)} ${srv.scope}`); + } + console.log(""); + console.log("Usage:"); + console.log(" just mcp-setup-m365 # writes ALL servers"); + console.log(' just mcp-setup-m365 "mail,planner" # subset'); + console.log(" just mcp-setup-m365 list # this listing"); + return; + } + const selected = raw === "" || raw === "all" ? known @@ -204,10 +253,19 @@ function main(): void { if (!srv.url || !scope) { fail(`Catalog entry for ${s} missing url or scope`); } + // The discovery endpoint returns URLs of the form + // https:///agents/servers/ + // but the Agent 365 gateway requires the caller's tenantId in the + // path, otherwise it responds with EndpointInvalid / TenantIdInvalid: + // https:///agents/tenants//servers/ + // Inject the tenantId here at config-write time. We don't store the + // already-tenanted URL in the catalog because the catalog is shared + // across tenants. + const tenantedUrl = injectTenantIntoUrl(srv.url, tenantId); writeServerEntry( configFile, ALIAS_PREFIX + s, - srv.url, + tenantedUrl, clientId, tenantId, scope, From 1a4e3972508c08a3ce43402de7358fede774bd4d Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 16:39:03 +0100 Subject: [PATCH 05/15] feat(mcp): replace hand-rolled OAuth with MSAL, add device-code flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled browser-oauth.ts and device-code-oauth.ts with @azure/msal-node's PublicClientApplication. MSAL handles PKCE, token caching, refresh, and redirect URIs correctly out of the box. Browser flow now uses http://localhost (ephemeral port, no /callback path) which matches the redirect URI registered on MSAL-compatible Entra apps (FOCI / VS Code / az CLI). This fixes AADSTS50011 redirect URI mismatch when using the VS Code app ID. Device-code flow uses acquireTokenByDeviceCode — prints verification URL + user code to stderr, no redirect URI needed. Changes: - Add @azure/msal-node dependency - New src/agent/mcp/auth/msal-oauth.ts (MSAL provider + file cache) - Delete browser-oauth.ts and device-code-oauth.ts - MCPOAuthConfig: flow is required (browser|device-code), callbackPort replaced with optional redirectUri - client-manager: single connectWithMsal replaces two methods - Scripts/Justfile: FLOW arg required, CALLBACK_PORT removed - Tests: MSAL provider tests, flow validation tests Tested: just check passes (2198/2198 tests, lint clean) --- Justfile | 22 +- package-lock.json | 154 +++++++++++- package.json | 1 + scripts/m365-setup.ts | 53 ++-- scripts/mcp-add-http.ts | 28 +-- src/agent/mcp/auth/browser-oauth.ts | 306 ---------------------- src/agent/mcp/auth/msal-oauth.ts | 377 ++++++++++++++++++++++++++++ src/agent/mcp/client-manager.ts | 113 ++++----- src/agent/mcp/config.ts | 30 +-- src/agent/mcp/types.ts | 35 ++- tests/mcp.test.ts | 237 ++++++++--------- 11 files changed, 805 insertions(+), 551 deletions(-) delete mode 100644 src/agent/mcp/auth/browser-oauth.ts create mode 100644 src/agent/mcp/auth/msal-oauth.ts diff --git a/Justfile b/Justfile index 9bd267e..539c818 100644 --- a/Justfile +++ b/Justfile @@ -830,11 +830,11 @@ mcp-setup-workiq: # Args: # NAME Config key (becomes the alias for /mcp enable ). # URL HTTPS endpoint of the MCP server. -# CLIENT_ID Optional. If set, OAuth (browser+PKCE) is configured. +# CLIENT_ID Optional. If set, OAuth is configured (and FLOW becomes required). # TENANT_ID Optional. Defaults to the auth-side default ('organizations'). # SCOPES Optional, comma-separated. If empty + CLIENT_ID set, # defaults to '/.default'. -# CALLBACK_PORT Optional. Defaults to 8080. +# FLOW REQUIRED when CLIENT_ID is set. "browser" or "device-code". # # Add an HTTP MCP server entry to ~/.hyperagent/config.json. Used by # `mcp-setup-m365` and intended for direct use when wiring custom HTTP @@ -844,9 +844,9 @@ mcp-setup-workiq: # just mcp-add-http example https://mcp.example.com/sse # just mcp-add-http work-iq-mail \ # https://agent365.svc.cloud.microsoft/agents/servers/mcp_MailRemoteServer \ -# -mcp-add-http NAME URL CLIENT_ID="" TENANT_ID="" SCOPES="" CALLBACK_PORT="8080": - npx tsx scripts/mcp-add-http.ts "{{ NAME }}" "{{ URL }}" "{{ CLIENT_ID }}" "{{ TENANT_ID }}" "{{ SCOPES }}" "{{ CALLBACK_PORT }}" +# "" browser +mcp-add-http NAME URL CLIENT_ID="" TENANT_ID="" SCOPES="" FLOW="": + npx tsx scripts/mcp-add-http.ts "{{ NAME }}" "{{ URL }}" "{{ CLIENT_ID }}" "{{ TENANT_ID }}" "{{ SCOPES }}" "{{ FLOW }}" # ── Microsoft 365 / Agent 365 HTTP MCP servers ─────────────────────── # @@ -861,7 +861,7 @@ mcp-add-http NAME URL CLIENT_ID="" TENANT_ID="" SCOPES="" CALLBACK_PORT="8080": # 2. just mcp-m365-setup # writes one entry per M365 service # 3. just start → /plugin enable mcp → /mcp enable work-iq- # -# State lives at ~/.hyperagent/m365.json (clientId, tenantId, callbackPort). +# State lives at ~/.hyperagent/m365.json (clientId, tenantId). # The server catalog (alias → mcp_* id mapping) lives at # scripts/m365-mcp-servers.json — refresh via `just mcp-m365-refresh-servers`. @@ -891,8 +891,14 @@ mcp-m365-create-app *ARGS: # TENANT_ID Override Entra tenant id (used for OAuth authority) # SCOPE_OVERRIDE Optional: force a single scope for every server # (default: each server uses its catalogued scope) -mcp-setup-m365 SERVICES="all" CLIENT_ID="" TENANT_ID="" SCOPE_OVERRIDE="": - npx tsx scripts/m365-setup.ts "{{ SERVICES }}" "{{ CLIENT_ID }}" "{{ TENANT_ID }}" "{{ SCOPE_OVERRIDE }}" +# FLOW REQUIRED. "browser" or "device-code". Picks which +# user-interaction OAuth flow gets baked into every +# server entry. There is no default — different +# environments (laptop vs SSH vs FOCI app) need +# different flows so the recipe forces an explicit +# choice. +mcp-setup-m365 SERVICES="all" CLIENT_ID="" TENANT_ID="" SCOPE_OVERRIDE="" FLOW="": + npx tsx scripts/m365-setup.ts "{{ SERVICES }}" "{{ CLIENT_ID }}" "{{ TENANT_ID }}" "{{ SCOPE_OVERRIDE }}" "{{ FLOW }}" # Refresh scripts/m365-mcp-servers.json from the live Agent 365 catalog. # Existing alias→server-id mappings are preserved; new server ids appear diff --git a/package-lock.json b/package-lock.json index 7a9ecb9..533d1c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { + "@azure/msal-node": "^5.1.4", "@github/copilot-sdk": "^0.2.1", "@hyperlight/js-host-api": "file:deps/js-host-api", "@modelcontextprotocol/sdk": "^1.29.0", @@ -63,6 +64,29 @@ "node": ">= 18" } }, + "node_modules/@azure/msal-common": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.1.tgz", + "integrity": "sha512-WS9w9SfI8SEYO7mTnxGeZ3UwQfhAVYCWglYF2/7GNx3ioHiAs2gPkl9eSwVs8cPrmiGh+zi9ai/OOKoq4cyzDw==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.4.tgz", + "integrity": "sha512-G4LXGGggok1QC48uKu64/SV2DPRDlddmV8EieK8pflsNYMj9/Zz+Y9OHoEBhT15h+zpdwXXLYA/7PJCR/yZ8aw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.5.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -2531,6 +2555,12 @@ "node": "18 || 20 || >=22" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2770,6 +2800,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3612,6 +3651,49 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -3907,6 +3989,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4423,13 +4547,32 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "license": "MIT" }, "node_modules/semver": { "version": "7.7.4", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4806,6 +4949,15 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 70581b4..328cc2b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "postinstall": "node -e \"var fs=require('fs'),cp=require('child_process');if(fs.existsSync('scripts/patch-vscode-jsonrpc.js')){cp.execSync('node scripts/patch-vscode-jsonrpc.js',{stdio:'inherit'});cp.execSync('node scripts/check-native-runtime.js',{stdio:'inherit'});}\"" }, "dependencies": { + "@azure/msal-node": "^5.1.4", "@github/copilot-sdk": "^0.2.1", "@hyperlight/js-host-api": "file:deps/js-host-api", "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/scripts/m365-setup.ts b/scripts/m365-setup.ts index 4e19b6e..083ed11 100644 --- a/scripts/m365-setup.ts +++ b/scripts/m365-setup.ts @@ -7,23 +7,24 @@ // writer logic). // // Usage: -// tsx scripts/m365-setup.ts [services] [clientId] [tenantId] [scopeOverride] +// tsx scripts/m365-setup.ts [services] [clientId] [tenantId] [scopeOverride] // // services "all" (default), comma-separated alias list, or // "list" to print the catalog and exit. // clientId Override Entra app client id (else read from state) // tenantId Override Entra tenant id (else read from state) // scopeOverride Force a single scope for every server (testing) +// flow REQUIRED. "browser" or "device-code". +// No default — every config must explicitly choose. // -// State file at ~/.hyperagent/m365.json supplies clientId/tenantId/ -// callbackPort when not overridden. +// State file at ~/.hyperagent/m365.json supplies clientId/tenantId +// when not overridden. import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -const DEFAULT_CALLBACK_PORT = 8080; const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/; const ALIAS_PREFIX = "work-iq-"; @@ -48,14 +49,13 @@ interface Catalog { interface SavedState { clientId?: string; tenantId?: string; - callbackPort?: number; appName?: string; } interface OAuthAuth { method: "oauth"; + flow: "browser" | "device-code"; clientId: string; - callbackPort: number; scopes: string[]; tenantId?: string; } @@ -124,7 +124,7 @@ function writeServerEntry( clientId: string, tenantId: string, scope: string, - callbackPort: number, + flow: "browser" | "device-code", ): void { if (!NAME_PATTERN.test(name)) { fail(`Invalid alias '${name}' — must match ${NAME_PATTERN}`); @@ -152,15 +152,15 @@ function writeServerEntry( url, auth: { method: "oauth", + flow, clientId, - callbackPort, scopes: [scope], ...(tenantId ? { tenantId } : {}), }, }; writeFileSync(configFile, JSON.stringify(cfg, null, 2) + "\n"); - console.log(`✅ Wrote mcpServers.${name} → ${url} (oauth)`); + console.log(`✅ Wrote mcpServers.${name} → ${url} (oauth/${flow})`); } // ── Main ───────────────────────────────────────────────────────────── @@ -171,6 +171,7 @@ function main(): void { clientIdArg = "", tenantIdArg = "", scopeOverride = "", + flowArg = "", ] = process.argv.slice(2); const stateFile = join(homedir(), ".hyperagent", "m365.json"); @@ -182,6 +183,8 @@ function main(): void { const raw = (servicesArg || "all").trim().toLowerCase(); // `list` / `--list` / `ls`: print catalog and exit (no config writes). + // Runs BEFORE flow validation so users can discover the catalog + // without having to pick a flow first. if (raw === "list" || raw === "--list" || raw === "ls") { console.log("Available M365 / Agent 365 MCP servers:\n"); const sorted = [...known].sort(); @@ -191,13 +194,23 @@ function main(): void { console.log(` ${alias.padEnd(aliasWidth)} ${srv.scope}`); } console.log(""); - console.log("Usage:"); - console.log(" just mcp-setup-m365 # writes ALL servers"); - console.log(' just mcp-setup-m365 "mail,planner" # subset'); - console.log(" just mcp-setup-m365 list # this listing"); + console.log("Usage (FLOW is required — browser or device-code):"); + console.log(' just mcp-setup-m365 all "" "" "" browser'); + console.log(' just mcp-setup-m365 "mail,planner" "" "" "" device-code'); + console.log(" just mcp-setup-m365 list # this listing"); return; } + // `flow` is mandatory for any path that writes config. Comes last + // positionally so earlier optional args can be left blank — + // `just mcp-setup-m365 ... "" "" "" device-code`. + if (flowArg !== "browser" && flowArg !== "device-code") { + fail( + `flow is required and must be "browser" or "device-code" (got: "${flowArg}")`, + ); + } + const flow = flowArg as "browser" | "device-code"; + const selected = raw === "" || raw === "all" ? known @@ -212,10 +225,9 @@ function main(): void { process.exit(1); } - // Resolve client/tenant/callbackPort from args ⊕ state file. + // Resolve client/tenant from args ⊕ state file. let clientId = clientIdArg; let tenantId = tenantIdArg; - let callbackPort = DEFAULT_CALLBACK_PORT; if (!clientId || !tenantId) { const state = readJson(stateFile); @@ -229,7 +241,6 @@ function main(): void { } clientId = clientId || state.clientId || ""; tenantId = tenantId || state.tenantId || ""; - callbackPort = state.callbackPort || DEFAULT_CALLBACK_PORT; console.log(`▸ Using saved app from ${stateFile}`); } @@ -239,8 +250,8 @@ function main(): void { console.log(`▸ clientId: ${clientId}`); console.log(`▸ tenantId: ${tenantId}`); - console.log(`▸ callbackPort: ${callbackPort}`); console.log(`▸ services: ${servicesArg}`); + console.log(`▸ flow: ${flow}`); if (scopeOverride) { console.log(`▸ scope (override): ${scopeOverride}`); } @@ -269,7 +280,7 @@ function main(): void { clientId, tenantId, scope, - callbackPort, + flow, ); count += 1; } @@ -282,7 +293,11 @@ function main(): void { console.log(" /plugin enable mcp"); console.log(" /mcp enable work-iq-"); console.log(""); - console.log(" First enable opens a browser for Microsoft sign-in."); + console.log( + flow === "device-code" + ? " First enable shows a device code + URL to enter on any browser." + : " First enable opens a browser for Microsoft sign-in.", + ); console.log(" Tokens cached in ~/.hyperagent/mcp-tokens/"); } diff --git a/scripts/mcp-add-http.ts b/scripts/mcp-add-http.ts index 63547b3..ef7ce65 100644 --- a/scripts/mcp-add-http.ts +++ b/scripts/mcp-add-http.ts @@ -6,23 +6,22 @@ // tsx work (Linux, macOS, Windows native, WSL, Git Bash). // // Usage: -// tsx scripts/mcp-add-http.ts [clientId] [tenantId] [scopes] [callbackPort] +// tsx scripts/mcp-add-http.ts [clientId] [tenantId] [scopes] [flow] // // All args after are optional. If clientId is provided, an OAuth -// auth block is written. scopes is comma-separated; if empty, defaults -// to "/.default". callbackPort defaults to 8080. - +// auth block is written and `flow` becomes REQUIRED — must be "browser" +// or "device-code". scopes is comma-separated; if empty, defaults to +// "/.default". import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join, dirname } from "node:path"; -const DEFAULT_CALLBACK_PORT = 8080; const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/; interface OAuthAuth { method: "oauth"; + flow: "browser" | "device-code"; clientId: string; - callbackPort: number; scopes: string[]; tenantId?: string; } @@ -44,13 +43,13 @@ function fail(msg: string): never { } function main(): void { - const [name, url, clientId, tenantId, scopes, callbackPortArg] = + const [name, url, clientId, tenantId, scopes, flowArg] = process.argv.slice(2); if (!name || !url) { fail( "Usage: tsx scripts/mcp-add-http.ts " + - "[clientId] [tenantId] [scopes] [callbackPort]", + "[clientId] [tenantId] [scopes] [flow]", ); } @@ -70,10 +69,6 @@ function main(): void { fail(`URL must be https:// (or localhost for testing): ${url}`); } - const callbackPort = callbackPortArg - ? Number.parseInt(callbackPortArg, 10) || DEFAULT_CALLBACK_PORT - : DEFAULT_CALLBACK_PORT; - const configDir = join(homedir(), ".hyperagent"); const configFile = join(configDir, "config.json"); mkdirSync(configDir, { recursive: true }); @@ -85,6 +80,11 @@ function main(): void { const entry: HttpServerEntry = { type: "http", url }; if (clientId) { + if (flowArg !== "browser" && flowArg !== "device-code") { + fail( + `flow is required when clientId is provided and must be "browser" or "device-code" (got: "${flowArg ?? ""}")`, + ); + } const scopeList = scopes ? scopes .split(",") @@ -93,8 +93,8 @@ function main(): void { : [`${parsedUrl.origin}/.default`]; entry.auth = { method: "oauth", + flow: flowArg as "browser" | "device-code", clientId, - callbackPort, scopes: scopeList, }; if (tenantId) entry.auth.tenantId = tenantId; @@ -105,7 +105,7 @@ function main(): void { mkdirSync(dirname(configFile), { recursive: true }); writeFileSync(configFile, JSON.stringify(cfg, null, 2) + "\n"); - const suffix = clientId ? " (oauth)" : ""; + const suffix = clientId ? ` (oauth/${flowArg})` : ""; console.log(`✅ Wrote mcpServers.${name} → ${url}${suffix}`); } diff --git a/src/agent/mcp/auth/browser-oauth.ts b/src/agent/mcp/auth/browser-oauth.ts deleted file mode 100644 index d763d2f..0000000 --- a/src/agent/mcp/auth/browser-oauth.ts +++ /dev/null @@ -1,306 +0,0 @@ -// ── MCP browser OAuth provider ─────────────────────────────────────── -// -// Implements OAuthClientProvider for interactive browser-based OAuth -// with PKCE. On first use, opens a browser for sign-in. Tokens are -// cached to disk for subsequent sessions. -// -// Flow: -// 1. Check for cached tokens → use if valid -// 2. SDK calls redirectToAuthorization() → we open the browser -// 3. Local callback server receives the auth code -// 4. Caller awaits waitForAuthCallback() to get the code -// 5. Caller calls transport.finishAuth(code) to complete the flow -// 6. SDK exchanges code for tokens → calls saveTokens() -// 7. We cache the tokens to disk -// -// This provider is used for local REPL sessions. For K8s / headless, -// use WorkloadIdentityProvider or ClientCredentialsProvider instead. - -import { createServer, type Server } from "node:http"; -import { exec } from "node:child_process"; - -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; -import type { - OAuthClientMetadata, - OAuthTokens, - OAuthClientInformationMixed, -} from "@modelcontextprotocol/sdk/shared/auth.js"; - -import type { MCPOAuthConfig } from "../types.js"; -import { - loadCachedTokens, - saveCachedTokens, - deleteCachedTokens, -} from "./token-cache.js"; - -// ── Constants ──────────────────────────────────────────────────────── - -/** Default port for the OAuth callback server. */ -const DEFAULT_CALLBACK_PORT = 8080; - -/** How long to wait for the user to authenticate (ms). */ -const AUTH_TIMEOUT_MS = 120_000; // 2 minutes - -// ── Result type ────────────────────────────────────────────────────── - -/** - * Result from createBrowserOAuthProvider. - * Includes the provider and a function to wait for the auth callback. - */ -export interface BrowserOAuthProviderResult { - /** The OAuthClientProvider to pass to StreamableHTTPClientTransport. */ - provider: OAuthClientProvider; - - /** - * Wait for the OAuth callback to arrive after the browser opens. - * Resolves with the authorization code. - * Rejects on timeout or error. - */ - waitForAuthCallback: () => Promise; - - /** Stop the callback server (cleanup). */ - stopCallbackServer: () => void; -} - -// ── Provider implementation ────────────────────────────────────────── - -/** - * Create a browser-based OAuthClientProvider for interactive OAuth flows. - * - * @param serverName - MCP server name (used for token cache key). - * @param authConfig - OAuth configuration from the MCP server config. - * @returns Provider, waitForAuthCallback function, and cleanup function. - */ -export function createBrowserOAuthProvider( - serverName: string, - authConfig: MCPOAuthConfig, -): BrowserOAuthProviderResult { - const callbackPort = authConfig.callbackPort ?? DEFAULT_CALLBACK_PORT; - const callbackUrl = new URL(`http://localhost:${callbackPort}/callback`); - - // In-memory session state (not persisted — per-session only) - let currentCodeVerifier: string = ""; - let callbackServer: Server | null = null; - let authTimeout: ReturnType | null = null; - - // Promise resolve/reject for the auth code — set when callback server starts - let resolveAuthCode: ((code: string) => void) | null = null; - let rejectAuthCode: ((err: Error) => void) | null = null; - let authCodePromise: Promise | null = null; - - const provider: OAuthClientProvider = { - get redirectUrl(): URL { - return callbackUrl; - }, - - get clientMetadata(): OAuthClientMetadata { - return { - redirect_uris: [callbackUrl.toString()], - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - client_name: "HyperAgent", - ...(authConfig.scopes ? { scope: authConfig.scopes.join(" ") } : {}), - }; - }, - - clientInformation(): OAuthClientInformationMixed | undefined { - // Pre-registered client — return static client ID - return { client_id: authConfig.clientId }; - }, - - tokens(): OAuthTokens | undefined { - return loadCachedTokens(serverName); - }, - - saveTokens(tokens: OAuthTokens): void { - saveCachedTokens(serverName, tokens); - }, - - async redirectToAuthorization(authorizationUrl: URL): Promise { - console.error(`[mcp] 🔐 Opening browser for authentication...`); - console.error( - `[mcp] URL: ${authorizationUrl.origin}${authorizationUrl.pathname}`, - ); - - // Start callback server before opening browser - await startServer(callbackPort); - - // Open browser - openBrowser(authorizationUrl.toString()); - }, - - saveCodeVerifier(codeVerifier: string): void { - currentCodeVerifier = codeVerifier; - }, - - codeVerifier(): string { - return currentCodeVerifier; - }, - - invalidateCredentials( - scope: "all" | "client" | "tokens" | "verifier" | "discovery", - ): void { - if (scope === "all" || scope === "tokens") { - deleteCachedTokens(serverName); - currentCodeVerifier = ""; - } - if (scope === "verifier") { - currentCodeVerifier = ""; - } - }, - }; - - /** - * Start an ephemeral HTTP server on localhost to receive the OAuth callback. - * Only binds to 127.0.0.1 — not accessible from the network. - */ - function startServer(port: number): Promise { - return new Promise((resolve, reject) => { - stopServer(); - - // Create the auth code promise that waitForAuthCallback returns - authCodePromise = new Promise((res, rej) => { - resolveAuthCode = res; - rejectAuthCode = rej; - }); - - callbackServer = createServer((req, res) => { - // Only handle the callback path - if (!req.url?.startsWith("/callback")) { - res.writeHead(404); - res.end(); - return; - } - - const parsed = new URL(req.url, `http://localhost:${port}`); - const code = parsed.searchParams.get("code"); - const error = parsed.searchParams.get("error"); - - if (code) { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end( - "

Authentication Successful

" + - "

You can close this window and return to HyperAgent.

" + - "" + - "", - ); - - resolveAuthCode?.(code); - resolveAuthCode = null; - rejectAuthCode = null; - setTimeout(() => stopServer(), 1000); - } else if (error) { - const desc = parsed.searchParams.get("error_description") ?? error; - res.writeHead(400, { "Content-Type": "text/html" }); - res.end( - `

Authentication Failed

` + - `

${escapeHtml(desc)}

`, - ); - - rejectAuthCode?.(new Error(`OAuth error: ${desc}`)); - resolveAuthCode = null; - rejectAuthCode = null; - setTimeout(() => stopServer(), 1000); - } else { - res.writeHead(400); - res.end("Missing authorization code"); - } - }); - - callbackServer.listen(port, "127.0.0.1", () => { - resolve(); - }); - - callbackServer.on("error", (err) => { - reject( - new Error( - `Failed to start OAuth callback server on port ${port}: ${err.message}`, - ), - ); - }); - - // Timeout — don't leave the server hanging forever - authTimeout = setTimeout(() => { - rejectAuthCode?.( - new Error( - `OAuth authentication timed out after ${AUTH_TIMEOUT_MS / 1000}s`, - ), - ); - resolveAuthCode = null; - rejectAuthCode = null; - stopServer(); - }, AUTH_TIMEOUT_MS); - }); - } - - /** Stop the callback server and clear the timeout. */ - function stopServer(): void { - if (authTimeout) { - clearTimeout(authTimeout); - authTimeout = null; - } - if (callbackServer) { - try { - callbackServer.close(); - } catch { - // Ignore close errors - } - callbackServer = null; - } - } - - /** - * Wait for the OAuth callback to deliver an authorization code. - * The callback server must have been started first - * (via redirectToAuthorization). - */ - function waitForAuthCallback(): Promise { - if (!authCodePromise) { - return Promise.reject( - new Error( - "OAuth callback server not started — redirectToAuthorization must be called first", - ), - ); - } - return authCodePromise; - } - - return { - provider, - waitForAuthCallback, - stopCallbackServer: stopServer, - }; -} - -// ── Helpers ────────────────────────────────────────────────────────── - -/** - * Open a URL in the default browser. - * Falls back to logging the URL if no browser command is available. - */ -function openBrowser(url: string): void { - const cmd = - process.platform === "darwin" - ? "open" - : process.platform === "win32" - ? "start" - : "xdg-open"; - - exec(`${cmd} "${url}"`, (err) => { - if (err) { - console.error( - `[mcp] Could not open browser automatically. Please open this URL manually:`, - ); - console.error(`[mcp] ${url}`); - } - }); -} - -/** Escape HTML special characters to prevent XSS in callback page. */ -function escapeHtml(str: string): string { - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} diff --git a/src/agent/mcp/auth/msal-oauth.ts b/src/agent/mcp/auth/msal-oauth.ts new file mode 100644 index 0000000..d8bc9b6 --- /dev/null +++ b/src/agent/mcp/auth/msal-oauth.ts @@ -0,0 +1,377 @@ +// ── MCP OAuth via MSAL ────────────────────────────────────────────── +// +// Wraps @azure/msal-node's PublicClientApplication to implement the +// MCP SDK's OAuthClientProvider interface. +// +// Supports two interactive flows: +// +// • "browser" — acquireTokenInteractive() +// MSAL opens a system browser and spins up an ephemeral loopback +// server on http://localhost (random port, no /callback path) to +// receive the auth code. This matches the redirect URI registered +// on MSAL-compatible Entra apps (FOCI / VS Code app, az CLI, etc.). +// +// • "device-code" — acquireTokenByDeviceCode() +// Prints verification URL + user code to stderr. No browser or +// loopback port needed — works in SSH, containers, headless boxes. +// +// Both flows try acquireTokenSilent() first (cached/refreshed tokens), +// falling back to interactive only when silent fails. +// +// Token persistence uses MSAL's ICachePlugin with a per-server JSON +// file at ~/.hyperagent/mcp-tokens/.msal.json (0o600). + +import { exec } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import * as msal from "@azure/msal-node"; +import type { ICachePlugin } from "@azure/msal-common/node"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientMetadata, + OAuthClientInformationMixed, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +import type { MCPOAuthConfig } from "../types.js"; + +// ── Constants ──────────────────────────────────────────────────────── + +/** Directory for MSAL token caches. */ +const MSAL_CACHE_DIR = join(homedir(), ".hyperagent", "mcp-tokens"); +const CACHE_FILE_MODE = 0o600; +const CACHE_DIR_MODE = 0o700; + +// ── MSAL cache plugin ──────────────────────────────────────────────── + +/** + * Minimal file-based MSAL cache plugin. + * + * Before MSAL accesses the cache it deserialises from the file; after a + * mutation it serialises back. File permissions are locked to owner-only. + */ +function createFileCachePlugin(cacheFilePath: string): ICachePlugin { + return { + async beforeCacheAccess(ctx) { + if (existsSync(cacheFilePath)) { + const raw = readFileSync(cacheFilePath, "utf8"); + ctx.tokenCache.deserialize(raw); + } + }, + async afterCacheAccess(ctx) { + if (ctx.cacheHasChanged) { + ensureCacheDir(); + writeFileSync(cacheFilePath, ctx.tokenCache.serialize(), { + mode: CACHE_FILE_MODE, + }); + } + }, + }; +} + +function ensureCacheDir(): void { + if (!existsSync(MSAL_CACHE_DIR)) { + mkdirSync(MSAL_CACHE_DIR, { recursive: true, mode: CACHE_DIR_MODE }); + } +} + +function msalCacheFilePath(serverName: string): string { + const safeName = serverName.replace(/[^a-z0-9-]/g, "_"); + return join(MSAL_CACHE_DIR, `${safeName}.msal.json`); +} + +/** + * Check if an MSAL token cache file exists for this server. Used for + * fast pre-flight checks (e.g. "is there any chance silent auth will + * work?") without instantiating a full PCA. + */ +export function hasMsalCache(serverName: string): boolean { + return existsSync(msalCacheFilePath(serverName)); +} + +// ── Build MSAL PCA ─────────────────────────────────────────────────── + +/** + * Build a configured PublicClientApplication for the given OAuth config + * and server name. + */ +function buildPca( + serverName: string, + authConfig: MCPOAuthConfig, +): msal.PublicClientApplication { + const authority = authConfig.tenantId + ? `https://login.microsoftonline.com/${authConfig.tenantId}` + : "https://login.microsoftonline.com/organizations"; + + const cachePlugin = createFileCachePlugin(msalCacheFilePath(serverName)); + + return new msal.PublicClientApplication({ + auth: { + clientId: authConfig.clientId, + authority, + }, + cache: { cachePlugin }, + system: { + // Suppress MSAL's info-level log spam. + loggerOptions: { + logLevel: msal.LogLevel.Warning, + }, + }, + }); +} + +// ── Scopes helper ──────────────────────────────────────────────────── + +/** + * Resolve scopes to send to Entra. Falls back to `.default` if none + * configured. Always includes `offline_access` for refresh tokens. + */ +function resolveScopes(authConfig: MCPOAuthConfig): string[] { + const base = + authConfig.scopes && authConfig.scopes.length > 0 + ? [...authConfig.scopes] + : []; + // offline_access is needed for refresh_token. openid + profile for + // id_token. MSAL adds these itself for interactive flows but being + // explicit avoids surprises. + for (const s of ["offline_access"]) { + if (!base.includes(s)) base.push(s); + } + return base; +} + +// ── Silent acquisition ─────────────────────────────────────────────── + +/** + * Try acquireTokenSilent. Returns null if there are no cached accounts + * or the silent request fails (token expired, no refresh token, etc.). + */ +async function tryAcquireSilent( + pca: msal.PublicClientApplication, + scopes: string[], +): Promise { + const accounts = await pca.getAllAccounts(); + if (accounts.length === 0) return null; + + try { + return await pca.acquireTokenSilent({ + account: accounts[0], + scopes, + }); + } catch { + // InteractionRequired, token expired, etc. — fall through. + return null; + } +} + +// ── Browser flow helpers ───────────────────────────────────────────── + +/** Open a URL in the system browser (best-effort, fire-and-forget). */ +function openBrowser(url: string): void { + const cmd = + process.platform === "darwin" + ? `open "${url}"` + : process.platform === "win32" + ? `start "" "${url}"` + : `xdg-open "${url}"`; + exec(cmd, { timeout: 10_000 }, () => { + // ignore errors — user can copy/paste if browser launch fails + }); +} + +// ── Public API ─────────────────────────────────────────────────────── + +/** + * Acquire an OAuth access token using MSAL. + * + * 1. Tries silent acquisition (cached / refresh token). + * 2. Falls back to interactive (browser or device-code per config). + * + * Returns the AuthenticationResult from MSAL (includes accessToken, + * account, expiresOn, etc.). + */ +export async function acquireMsalToken( + serverName: string, + authConfig: MCPOAuthConfig, +): Promise { + const pca = buildPca(serverName, authConfig); + const scopes = resolveScopes(authConfig); + + // 1. Try silent first. + const silent = await tryAcquireSilent(pca, scopes); + if (silent) return silent; + + // 2. Interactive fallback. + if (authConfig.flow === "device-code") { + return await acquireByDeviceCode(pca, scopes); + } + return await acquireByBrowser(pca, scopes); +} + +/** + * Interactive browser flow — MSAL opens a loopback server and the + * system browser. Uses `http://localhost` (random port, no path) which + * matches the redirect URI registered on MSAL-compatible apps (FOCI, + * VS Code, az CLI). + */ +async function acquireByBrowser( + pca: msal.PublicClientApplication, + scopes: string[], +): Promise { + console.error("[mcp] 🔐 Opening browser for authentication..."); + + return await pca.acquireTokenInteractive({ + scopes, + openBrowser: async (url: string) => { + openBrowser(url); + }, + successTemplate: + "

Authentication Successful

" + + "

You can close this window and return to HyperAgent.

" + + "", + errorTemplate: + "

Authentication Failed

" + + "

Check the terminal for details.

", + }); +} + +/** + * Device code flow — prints the verification URL and user code to + * stderr; the user opens the URL on any device and types the code. + */ +async function acquireByDeviceCode( + pca: msal.PublicClientApplication, + scopes: string[], +): Promise { + const result = await pca.acquireTokenByDeviceCode({ + scopes, + deviceCodeCallback: (response) => { + console.error(""); + console.error("[mcp] 🔐 Device code authentication required"); + console.error(`[mcp] ${response.message}`); + console.error(""); + }, + }); + + if (!result) { + throw new Error( + "Device code flow returned null — user may have cancelled.", + ); + } + return result; +} + +/** + * Create an OAuthClientProvider backed by MSAL for the MCP SDK. + * + * The provider's `tokens()` method acquires tokens via MSAL (silent → + * interactive fallback). The MCP SDK calls `tokens()` before each + * request, so we get automatic re-auth when tokens expire. + * + * The `redirectToAuthorization`, `saveCodeVerifier`, `codeVerifier` + * hooks are stubs — MSAL handles the full auth dance internally, + * so the MCP SDK's PKCE orchestration layer is bypassed. + */ +export function createMsalOAuthProvider( + serverName: string, + authConfig: MCPOAuthConfig, +): OAuthClientProvider { + const pca = buildPca(serverName, authConfig); + const scopes = resolveScopes(authConfig); + + const provider: OAuthClientProvider = { + get redirectUrl(): string { + // MSAL handles the redirect internally. Return the OOB urn so + // the SDK doesn't crash if it inspects this field. + return "urn:ietf:wg:oauth:2.0:oob"; + }, + + get clientMetadata(): OAuthClientMetadata { + return { + redirect_uris: [], + grant_types: [ + "authorization_code", + "urn:ietf:params:oauth:grant-type:device_code", + "refresh_token", + ], + response_types: ["code"], + client_name: "HyperAgent", + ...(authConfig.scopes ? { scope: authConfig.scopes.join(" ") } : {}), + }; + }, + + clientInformation(): OAuthClientInformationMixed | undefined { + return { client_id: authConfig.clientId }; + }, + + async tokens(): Promise { + // Try silent acquisition — returns cached / refreshed token. + const result = await tryAcquireSilent(pca, scopes); + if (!result) return undefined; + + return { + access_token: result.accessToken, + token_type: "Bearer", + ...(result.expiresOn + ? { + expires_in: Math.max( + 0, + Math.floor((result.expiresOn.getTime() - Date.now()) / 1000), + ), + } + : {}), + }; + }, + + saveTokens(_tokens: OAuthTokens): void { + // MSAL manages its own cache via the ICachePlugin — nothing to do. + }, + + async redirectToAuthorization(_authorizationUrl: URL): Promise { + // The MCP SDK calls this when tokens() returns undefined. We run + // the MSAL interactive flow right here instead of using the SDK's + // own PKCE machinery (which doesn't know about MSAL). + // + // NOTE: The SDK doesn't use our return value from this method — + // it expects us to open a browser and have the callback server + // handle the code. Since MSAL does everything internally, we + // just acquire the token now and let the next tokens() call pick + // it up from the MSAL cache. + if (authConfig.flow === "device-code") { + await acquireByDeviceCode(pca, scopes); + } else { + await acquireByBrowser(pca, scopes); + } + }, + + saveCodeVerifier(_codeVerifier: string): void { + // MSAL handles PKCE internally. + }, + + codeVerifier(): string { + // MSAL handles PKCE internally. + return ""; + }, + + invalidateCredentials( + scope: "all" | "client" | "tokens" | "verifier" | "discovery", + ): void { + if (scope === "all" || scope === "tokens") { + // Clear all cached accounts from MSAL's in-memory cache. The + // file cache will be updated on next afterCacheAccess. + pca + .getAllAccounts() + .then((accounts) => { + for (const account of accounts) { + pca.signOut({ account }).catch(() => {}); + } + }) + .catch(() => {}); + } + }, + }; + + return provider; +} diff --git a/src/agent/mcp/client-manager.ts b/src/agent/mcp/client-manager.ts index 8fd867c..17952ef 100644 --- a/src/agent/mcp/client-manager.ts +++ b/src/agent/mcp/client-manager.ts @@ -26,10 +26,10 @@ import { } from "./types.js"; import { sanitiseToolName, sanitiseDescription } from "./sanitise.js"; import { - createBrowserOAuthProvider, - type BrowserOAuthProviderResult, -} from "./auth/browser-oauth.js"; -import { hasCachedTokens } from "./auth/token-cache.js"; + acquireMsalToken, + createMsalOAuthProvider, + hasMsalCache, +} from "./auth/msal-oauth.js"; import { createRetryFetch } from "./retry-fetch.js"; import { loadCachedSession, @@ -64,9 +64,9 @@ export function createMCPClientManager() { /** * Connect to an MCP server, discover tools, and apply filtering. - * For HTTP servers with OAuth, handles the browser auth flow: - * 1. Attempt connection (may use cached tokens) - * 2. If UnauthorizedError → open browser, wait for callback, retry + * For HTTP servers with OAuth, handles auth via MSAL: + * 1. Attempt token acquisition via MSAL (silent → interactive) + * 2. Connect with authenticated transport * 3. If no TTY and no cached tokens → fail with clear instructions * Throws on connection failure after timeout. */ @@ -93,12 +93,12 @@ export function createMCPClientManager() { conn.state = "connecting"; try { - // For HTTP + OAuth servers, handle the auth flow + // For HTTP + OAuth servers, handle the auth flow via MSAL if ( isMCPHttpConfig(conn.config) && conn.config.auth?.method === "oauth" ) { - return await connectWithOAuth(name, conn); + return await connectWithMsal(name, conn); } // Standard connection (stdio or unauthenticated HTTP) @@ -129,11 +129,17 @@ export function createMCPClientManager() { } /** - * Connect to an HTTP server with OAuth authentication. - * Handles the full browser-based auth flow including - * UnauthorizedError retry. + * Connect to an HTTP server using MSAL for OAuth authentication. + * + * MSAL handles both browser (PKCE, ephemeral loopback) and device-code + * flows, plus silent token refresh via its internal cache. We eagerly + * acquire a token before connecting so the MCP SDK transport gets a + * valid Bearer token on the first request. + * + * If there's already a valid cached token, acquireMsalToken() returns + * it silently — no browser or device-code prompt. */ - async function connectWithOAuth( + async function connectWithMsal( name: string, conn: MCPConnection, ): Promise { @@ -141,26 +147,45 @@ export function createMCPClientManager() { const authConfig = httpConfig.auth!; if (authConfig.method !== "oauth") { - throw new Error(`[mcp] connectWithOAuth called with non-oauth method`); + throw new Error(`[mcp] connectWithMsal called with non-oauth method`); } - // Check if we can do interactive auth - const hasCached = hasCachedTokens(name); const isInteractive = process.stdin.isTTY === true; - - if (!hasCached && !isInteractive) { - throw new Error( - `[mcp] OAuth authentication required for "${name}" but no cached ` + - `tokens found and no interactive terminal available.\n` + - ` Run HyperAgent interactively first to authenticate:\n` + - ` npx tsx src/agent/index.ts\n` + - ` /mcp enable ${name}`, - ); + if (!isInteractive) { + // In non-interactive mode, we can only succeed if MSAL has a + // cached token to refresh silently. Check for the cache file + // first — if it doesn't exist, fail fast with a clear message + // instead of letting MSAL hang trying to do interactive auth. + if (!hasMsalCache(name)) { + throw new Error( + `[mcp] OAuth authentication required for "${name}" but no cached ` + + `tokens found and no interactive terminal available.\n` + + ` Run HyperAgent interactively first to authenticate:\n` + + ` npx tsx src/agent/index.ts\n` + + ` /mcp enable ${name}`, + ); + } + // Cache exists — try silent refresh. + try { + await acquireMsalToken(name, authConfig); + } catch { + throw new Error( + `[mcp] OAuth authentication required for "${name}" but cached ` + + `tokens could not be refreshed and no interactive terminal ` + + `available.\n` + + ` Run HyperAgent interactively first to re-authenticate:\n` + + ` npx tsx src/agent/index.ts\n` + + ` /mcp enable ${name}`, + ); + } + } else { + // Interactive: acquire token eagerly (silent → browser/device-code). + await acquireMsalToken(name, authConfig); + console.error(`[mcp] ✅ Authentication successful.`); } - // Create the OAuth provider (loads cached tokens automatically) - const { provider, waitForAuthCallback, stopCallbackServer } = - createBrowserOAuthProvider(name, authConfig); + // Build a provider that serves cached tokens to the MCP transport. + const provider = createMsalOAuthProvider(name, authConfig); const url = new URL(httpConfig.url); const requestInit: RequestInit = {}; @@ -176,35 +201,7 @@ export function createMCPClientManager() { ...(cachedSessionId ? { sessionId: cachedSessionId } : {}), }); - try { - // First attempt — may succeed with cached tokens - return await connectWithTransport(name, conn, transport); - } catch (err) { - // If it's not an auth error, or we can't do interactive, re-throw - if (!(err instanceof UnauthorizedError) || !isInteractive) { - stopCallbackServer(); - throw err; - } - - // Interactive OAuth flow — browser was opened by the provider's - // redirectToAuthorization(), now wait for the callback - console.error(`[mcp] 🔐 Waiting for browser authentication...`); - - try { - const authCode = await waitForAuthCallback(); - await transport.finishAuth(authCode); - - console.error(`[mcp] ✅ Authentication successful — reconnecting...`); - - // Retry connection with authenticated transport - return await connectWithTransport(name, conn, transport); - } catch (authErr) { - stopCallbackServer(); - throw new Error( - `OAuth authentication failed: ${(authErr as Error).message}`, - ); - } - } + return await connectWithTransport(name, conn, transport); } /** @@ -442,7 +439,7 @@ function createTransport(config: MCPServerConfig, serverName?: string): any { /** * Create a StreamableHTTPClientTransport for an HTTP MCP server. * Used for unauthenticated HTTP servers and non-OAuth auth methods. - * OAuth is handled separately in connectWithOAuth(). + * OAuth is handled separately in connectWithMsal(). */ function createHttpTransport( config: MCPHttpServerConfig, diff --git a/src/agent/mcp/config.ts b/src/agent/mcp/config.ts index b58001c..24f4b9d 100644 --- a/src/agent/mcp/config.ts +++ b/src/agent/mcp/config.ts @@ -315,18 +315,19 @@ function validateOAuthConfig( } } - if (obj.callbackPort !== undefined) { - if ( - typeof obj.callbackPort !== "number" || - !Number.isInteger(obj.callbackPort) || - obj.callbackPort < 1 || - obj.callbackPort > 65535 - ) { - errors.push({ - server: name, - message: '"auth.callbackPort" must be an integer between 1 and 65535.', - }); - } + if (obj.redirectUri !== undefined && typeof obj.redirectUri !== "string") { + errors.push({ + server: name, + message: '"auth.redirectUri" must be a string.', + }); + } + + if (obj.flow !== "browser" && obj.flow !== "device-code") { + errors.push({ + server: name, + message: + '"auth.flow" is required and must be "browser" or "device-code".', + }); } return errors; @@ -512,11 +513,12 @@ function resolveAuthConfig(raw: Record): MCPAuthConfig { case "oauth": return { method: "oauth", + flow: raw.flow as "browser" | "device-code", clientId: (raw.clientId as string).trim(), ...(raw.tenantId ? { tenantId: (raw.tenantId as string).trim() } : {}), ...(raw.scopes ? { scopes: raw.scopes as string[] } : {}), - ...(raw.callbackPort - ? { callbackPort: raw.callbackPort as number } + ...(raw.redirectUri + ? { redirectUri: (raw.redirectUri as string).trim() } : {}), }; diff --git a/src/agent/mcp/types.ts b/src/agent/mcp/types.ts index 1f72bec..a473e30 100644 --- a/src/agent/mcp/types.ts +++ b/src/agent/mcp/types.ts @@ -59,23 +59,48 @@ export type MCPAuthMethod = | "client-credentials"; /** - * OAuth 2.0 browser-based authentication (PKCE). - * Used for interactive local sessions — opens a browser for sign-in. + * OAuth 2.0 user-delegated authentication via MSAL. + * + * Uses @azure/msal-node's PublicClientApplication under the hood. + * Token caching, refresh, and PKCE are handled by MSAL automatically. + * + * Two flows are supported and `flow` MUST be set explicitly: + * + * • "browser" — acquireTokenInteractive (auth-code + PKCE). + * MSAL opens the system browser and spins up an ephemeral loopback + * server on http://localhost (random port, no path suffix). This + * redirect URI is registered by default on MSAL-compatible Entra + * apps (FOCI / VS Code / az CLI). Custom registrations may need a + * different `redirectUri` — see below. + * + * • "device-code" — acquireTokenByDeviceCode (RFC 8628). + * Prints a verification URL + user code to the terminal; the user + * opens the URL on any device, types the code, signs in. No + * redirect URI or loopback port needed — works in SSH sessions, + * containers, and locked-down corporate machines. */ export interface MCPOAuthConfig { method: "oauth"; + /** Which user-interaction flow to use. Required — no default. */ + flow: "browser" | "device-code"; + /** OAuth client (application) ID. */ clientId: string; - /** Entra ID tenant ID (for Microsoft identity). */ + /** Entra ID tenant ID. If omitted, defaults to "organizations". */ tenantId?: string; /** Requested OAuth scopes (e.g. ["Mail.Read"]). */ scopes?: string[]; - /** Local port for the OAuth callback server. @default 8080 */ - callbackPort?: number; + /** + * Override redirect URI for the browser flow. @default "http://localhost" + * Only needed for custom Entra app registrations that have a different + * redirect URI configured. MSAL-compatible apps (VS Code FOCI, az CLI) + * work with the default. Ignored for device-code flow. + */ + redirectUri?: string; } /** diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index c07d64f..5603790 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -157,10 +157,10 @@ describe("parseMCPConfig", () => { url: "https://agent365.svc.cloud.microsoft/mcp", auth: { method: "oauth", + flow: "browser", clientId: "18f4deab-76fc-406d-b9d8-3cc0377fa30d", tenantId: "9c23c1e3-15be-4744-a3d7-027089c33654", scopes: ["Mail.Read"], - callbackPort: 8080, }, }, }); @@ -178,7 +178,6 @@ describe("parseMCPConfig", () => { "9c23c1e3-15be-4744-a3d7-027089c33654", ); expect(server.auth!.scopes).toEqual(["Mail.Read"]); - expect(server.auth!.callbackPort).toBe(8080); } }); @@ -310,21 +309,70 @@ describe("parseMCPConfig", () => { "no-client-id": { type: "http", url: "https://example.com/mcp", - auth: { method: "oauth" }, + auth: { method: "oauth", flow: "browser" }, }, }); expect(errors.some((e) => e.message.includes("auth.clientId"))).toBe(true); }); - it("rejects OAuth auth with invalid callbackPort", () => { + it("rejects OAuth auth missing flow", () => { const { errors } = parseMCPConfig({ - "bad-port": { + "no-flow": { type: "http", url: "https://example.com/mcp", - auth: { method: "oauth", clientId: "abc", callbackPort: 99999 }, + auth: { method: "oauth", clientId: "abc" }, + }, + }); + expect(errors.some((e) => e.message.includes("auth.flow"))).toBe(true); + }); + + it("rejects OAuth auth with invalid flow", () => { + const { errors } = parseMCPConfig({ + "bad-flow": { + type: "http", + url: "https://example.com/mcp", + auth: { method: "oauth", flow: "magic", clientId: "abc" }, }, }); - expect(errors.some((e) => e.message.includes("callbackPort"))).toBe(true); + expect(errors.some((e) => e.message.includes("auth.flow"))).toBe(true); + }); + + it("accepts OAuth auth with device-code flow", () => { + const { config, errors } = parseMCPConfig({ + "dc-server": { + type: "http", + url: "https://example.com/mcp", + auth: { + method: "oauth", + flow: "device-code", + clientId: "abc", + tenantId: "tid", + scopes: ["Mail.Read"], + }, + }, + }); + expect(errors).toHaveLength(0); + const server = config.servers.get("dc-server") as MCPHttpServerConfig; + expect(server.auth!.method).toBe("oauth"); + if (server.auth!.method === "oauth") { + expect(server.auth!.flow).toBe("device-code"); + } + }); + + it("rejects OAuth auth with invalid redirectUri", () => { + const { errors } = parseMCPConfig({ + "bad-redirect": { + type: "http", + url: "https://example.com/mcp", + auth: { + method: "oauth", + flow: "browser", + clientId: "abc", + redirectUri: 12345, + }, + }, + }); + expect(errors.some((e) => e.message.includes("redirectUri"))).toBe(true); }); it("rejects client-credentials auth missing required fields", () => { @@ -348,7 +396,7 @@ describe("parseMCPConfig", () => { "remote-mail": { type: "http", url: "https://agent365.svc.cloud.microsoft/mcp", - auth: { method: "oauth", clientId: "abc" }, + auth: { method: "oauth", flow: "browser", clientId: "abc" }, }, }); @@ -455,7 +503,11 @@ describe("computeMCPConfigHash", () => { const hash1 = computeMCPConfigHash("test", { type: "http" as const, url: "https://example.com/mcp", - auth: { method: "oauth" as const, clientId: "abc" }, + auth: { + method: "oauth" as const, + flow: "browser" as const, + clientId: "abc", + }, }); const hash2 = computeMCPConfigHash("test", { type: "http" as const, @@ -469,12 +521,20 @@ describe("computeMCPConfigHash", () => { const hash1 = computeMCPConfigHash("test", { type: "http" as const, url: "https://example.com/mcp", - auth: { method: "oauth" as const, clientId: "abc" }, + auth: { + method: "oauth" as const, + flow: "browser" as const, + clientId: "abc", + }, }); const hash2 = computeMCPConfigHash("test", { type: "http" as const, url: "https://example.com/mcp", - auth: { method: "oauth" as const, clientId: "xyz" }, + auth: { + method: "oauth" as const, + flow: "browser" as const, + clientId: "xyz", + }, }); expect(hash1).not.toBe(hash2); }); @@ -602,7 +662,7 @@ describe("createMCPClientManager — HTTP transport", () => { manager.registerServer("oauth-headless", { type: "http", url: "https://localhost:19999/mcp", - auth: { method: "oauth", clientId: "test-id" }, + auth: { method: "oauth", flow: "browser", clientId: "test-id" }, }); await expect(manager.connect("oauth-headless")).rejects.toThrow( @@ -721,28 +781,19 @@ describe("token cache", () => { }); }); -// ── Browser OAuth provider ─────────────────────────────────────────── +// ── MSAL OAuth provider ────────────────────────────────────────────── -import { createBrowserOAuthProvider } from "../src/agent/mcp/auth/browser-oauth.js"; +import { createMsalOAuthProvider } from "../src/agent/mcp/auth/msal-oauth.js"; import { afterEach } from "vitest"; -describe("createBrowserOAuthProvider", () => { - const testServer = `test-oauth-${Date.now()}`; - - afterEach(() => { - deleteCachedTokens(testServer); - }); - - it("creates a provider with correct client metadata", () => { - const { provider, stopCallbackServer } = createBrowserOAuthProvider( - testServer, - { - method: "oauth", - clientId: "test-client-id", - scopes: ["Mail.Read", "Calendar.Read"], - callbackPort: 9999, - }, - ); +describe("createMsalOAuthProvider", () => { + it("returns correct client metadata and information", () => { + const provider = createMsalOAuthProvider("test-msal", { + method: "oauth", + flow: "browser", + clientId: "test-client-id", + scopes: ["Mail.Read", "Calendar.Read"], + }); const metadata = provider.clientMetadata; expect(metadata.client_name).toBe("HyperAgent"); @@ -750,119 +801,53 @@ describe("createBrowserOAuthProvider", () => { expect(metadata.grant_types).toContain("refresh_token"); expect(metadata.scope).toBe("Mail.Read Calendar.Read"); - const redirectUrl = provider.redirectUrl; - expect(redirectUrl).toBeDefined(); - expect(redirectUrl!.toString()).toContain("localhost:9999"); - - stopCallbackServer(); + // MSAL handles redirects internally; provider returns OOB urn. + expect(String(provider.redirectUrl)).toBe("urn:ietf:wg:oauth:2.0:oob"); }); it("returns static client information", async () => { - const { provider, stopCallbackServer } = createBrowserOAuthProvider( - testServer, - { - method: "oauth", - clientId: "my-app-id", - }, - ); + const provider = createMsalOAuthProvider("test-msal-info", { + method: "oauth", + flow: "browser", + clientId: "my-app-id", + }); const info = await provider.clientInformation(); expect(info).toBeDefined(); expect(info!.client_id).toBe("my-app-id"); - - stopCallbackServer(); - }); - - it("stores and retrieves code verifier", () => { - const { provider, stopCallbackServer } = createBrowserOAuthProvider( - testServer, - { - method: "oauth", - clientId: "test-id", - }, - ); - - provider.saveCodeVerifier("test-verifier-123"); - expect(provider.codeVerifier()).toBe("test-verifier-123"); - - stopCallbackServer(); }); - it("saves and loads tokens via cache", async () => { - const { provider, stopCallbackServer } = createBrowserOAuthProvider( - testServer, - { - method: "oauth", - clientId: "test-id", - }, - ); - - // Initially no tokens - expect(await provider.tokens()).toBeUndefined(); - - // Save tokens - provider.saveTokens({ - access_token: "cached-access", - token_type: "Bearer", - refresh_token: "cached-refresh", + it("tokens() returns undefined when no MSAL cache exists", async () => { + const provider = createMsalOAuthProvider(`test-msal-empty-${Date.now()}`, { + method: "oauth", + flow: "browser", + clientId: "test-id", }); - // Should load from cache - const loaded = await provider.tokens(); - expect(loaded).toBeDefined(); - expect(loaded!.access_token).toBe("cached-access"); - - stopCallbackServer(); + // No accounts in cache → silent acquisition returns undefined. + const tokens = await provider.tokens(); + expect(tokens).toBeUndefined(); }); - it("invalidateCredentials clears tokens", async () => { - const { provider, stopCallbackServer } = createBrowserOAuthProvider( - testServer, - { - method: "oauth", - clientId: "test-id", - }, - ); - - provider.saveTokens({ - access_token: "to-be-cleared", - token_type: "Bearer", + it("omits scope when not configured", () => { + const provider = createMsalOAuthProvider("test-msal-noscope", { + method: "oauth", + flow: "device-code", + clientId: "test-id", }); - expect(await provider.tokens()).toBeDefined(); - - provider.invalidateCredentials!("tokens"); - expect(await provider.tokens()).toBeUndefined(); - - stopCallbackServer(); - }); - - it("uses default callback port when not specified", () => { - const { provider, stopCallbackServer } = createBrowserOAuthProvider( - testServer, - { - method: "oauth", - clientId: "test-id", - }, - ); - - const redirectUrl = provider.redirectUrl; - expect(redirectUrl!.toString()).toContain("localhost:8080"); - stopCallbackServer(); + expect(provider.clientMetadata.scope).toBeUndefined(); }); - it("omits scope when not configured", () => { - const { provider, stopCallbackServer } = createBrowserOAuthProvider( - testServer, - { - method: "oauth", - clientId: "test-id", - }, - ); - - expect(provider.clientMetadata.scope).toBeUndefined(); + it("codeVerifier stubs return empty string (MSAL handles PKCE)", () => { + const provider = createMsalOAuthProvider("test-msal-pkce", { + method: "oauth", + flow: "browser", + clientId: "test-id", + }); - stopCallbackServer(); + provider.saveCodeVerifier("whatever"); + expect(provider.codeVerifier()).toBe(""); }); }); From 4fd5474d14186afa44bcf71e83c6dfe2c4aa2185 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 19:11:52 +0100 Subject: [PATCH 06/15] fix(mcp): use resource .default scope, print auth URL as fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use ea9ffc3e-.../.default (Agent 365 resource) instead of per-server scopes. Per-server scopes aren't fully qualified so MSAL falls back to Graph, which breaks with FOCI apps. Matches a365cli behaviour. - Always print the auth URL to stderr so users can copy/paste when xdg-open isn't available (headless distros, SSH, no browser setup). - Remove callbackPort from catalog JSON. Tested: browser flow with VS Code FOCI app — works end-to-end. --- scripts/m365-setup.ts | 12 +++++++++++- src/agent/mcp/auth/msal-oauth.ts | 13 +++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/scripts/m365-setup.ts b/scripts/m365-setup.ts index 083ed11..50625b8 100644 --- a/scripts/m365-setup.ts +++ b/scripts/m365-setup.ts @@ -257,10 +257,20 @@ function main(): void { } console.log(""); + // The Agent 365 resource app id. Per-server scopes (e.g. + // McpServers.MailTools.All) are not fully qualified — MSAL doesn't + // know which resource they belong to and falls back to Graph, which + // breaks with FOCI apps like the VS Code client. Using + // {resourceId}/.default requests all pre-consented scopes for the + // Agent 365 resource in one shot, matching what a365cli does. + const defaultScope = catalog.resourceId + ? `${catalog.resourceId}/.default` + : undefined; + let count = 0; for (const s of selected) { const srv = catalog.servers[s]; - const scope = scopeOverride || srv.scope; + const scope = scopeOverride || defaultScope || srv.scope; if (!srv.url || !scope) { fail(`Catalog entry for ${s} missing url or scope`); } diff --git a/src/agent/mcp/auth/msal-oauth.ts b/src/agent/mcp/auth/msal-oauth.ts index d8bc9b6..a5bb20b 100644 --- a/src/agent/mcp/auth/msal-oauth.ts +++ b/src/agent/mcp/auth/msal-oauth.ts @@ -168,16 +168,25 @@ async function tryAcquireSilent( // ── Browser flow helpers ───────────────────────────────────────────── -/** Open a URL in the system browser (best-effort, fire-and-forget). */ +/** + * Open a URL in the system browser (best-effort). + * + * Always prints the URL to stderr so the user can copy/paste if the + * browser doesn't open (e.g. headless distro, SSH, no xdg-open). + */ function openBrowser(url: string): void { + console.error(`[mcp] If the browser doesn't open, visit:`); + console.error(`[mcp] ${url}`); + const cmd = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`; + exec(cmd, { timeout: 10_000 }, () => { - // ignore errors — user can copy/paste if browser launch fails + // ignore errors — URL is printed above for manual use }); } From 2b1f024bce8209f812ca83594f1fb0d49ee60015 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 19:51:45 +0100 Subject: [PATCH 07/15] fix: MSAL cache reader in refresh-servers, remove bogus plugin reconfigure hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - m365-refresh-servers: read MSAL .msal.json cache format only, drop legacy {savedAt, tokens} format (deleted provider wrote that) - slash-commands: remove hardcoded allowedContentTypes reconfigure suggestion from /plugin enable — was a fake example that doesn't exist on most plugins --- scripts/m365-refresh-servers.ts | 38 ++++++++++++++++++++++----------- src/agent/slash-commands.ts | 4 ---- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/scripts/m365-refresh-servers.ts b/scripts/m365-refresh-servers.ts index 534fd1c..f5e4755 100644 --- a/scripts/m365-refresh-servers.ts +++ b/scripts/m365-refresh-servers.ts @@ -73,28 +73,40 @@ function parseArgs(argv: readonly string[]): CliArgs { } /** - * Load the most recent OAuth access token from any cached MCP server. + * Load the most recent OAuth access token from the MSAL cache. + * + * MSAL cache files are named *.msal.json and contain an AccessToken + * map with {secret, expires_on} entries. We pick the freshest + * non-expired token — any Agent 365 server token works for discovery + * since they all share the same audience. + * * Returns undefined if no usable token is found. */ function loadTokenFromCache(): string | undefined { if (!existsSync(TOKENS_DIR)) return undefined; - const files = readdirSync(TOKENS_DIR).filter((f) => f.endsWith(".json")); + const files = readdirSync(TOKENS_DIR).filter((f) => + f.endsWith(".msal.json"), + ); if (files.length === 0) return undefined; - // Pick the most recently saved token (largest savedAt). Any Agent 365 - // server token works for discovery — they all share the same audience. - let best: { token: string; savedAt: string } | undefined; + let best: { token: string; expiresOn: number } | undefined; + for (const f of files) { try { const raw = readFileSync(join(TOKENS_DIR, f), "utf8"); - const parsed = JSON.parse(raw) as { - savedAt?: string; - tokens?: { access_token?: string }; - }; - const tok = parsed.tokens?.access_token; - const savedAt = parsed.savedAt; - if (typeof tok !== "string" || typeof savedAt !== "string") continue; - if (!best || savedAt > best.savedAt) best = { token: tok, savedAt }; + const parsed = JSON.parse(raw) as Record; + const atMap = parsed["AccessToken"] as + | Record + | undefined; + if (!atMap) continue; + for (const entry of Object.values(atMap)) { + if (typeof entry.secret !== "string") continue; + const expiresOn = Number(entry.expires_on ?? "0"); + if (expiresOn * 1000 < Date.now()) continue; // expired + if (!best || expiresOn > best.expiresOn) { + best = { token: entry.secret, expiresOn }; + } + } } catch { // skip corrupt files } diff --git a/src/agent/slash-commands.ts b/src/agent/slash-commands.ts index 7121178..c24de17 100644 --- a/src/agent/slash-commands.ts +++ b/src/agent/slash-commands.ts @@ -1087,10 +1087,6 @@ export async function handleSlashCommand( if (targetPlugin.state === "enabled") { if (!hasInlineConfig) { console.log(` ℹ️ "${pluginName}" is already enabled.`); - console.log(` To reconfigure, pass key=value overrides:`); - console.log( - ` /plugin enable ${pluginName} allowedContentTypes=[application/json,text/plain,text/html]`, - ); console.log(); break; } From 40a25f2ddf70d56894f0f31ff163947c9d9c3aff Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 20:30:35 +0100 Subject: [PATCH 08/15] feat(mcp): LLM-driven server connect, auto-enable gateway, MCP skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable the LLM to discover and connect MCP servers autonomously: 1. manage_mcp('connect') now works end-to-end: - Pre-approved servers connect silently - Unapproved + interactive TTY → prompts user for approval - Unapproved + no TTY → refuses with clear error - Mirrors the approval flow from /mcp enable 2. MCP gateway auto-enables on startup when servers are configured (the plugin is a boolean sentinel — zero risk, no audit needed) 3. list_mcp_servers() ungated — works before gateway is enabled so the LLM can discover servers without user running /plugin enable 4. System prompt: dynamic MCP section — concise hint when servers are configured, tells LLM to use tools for discovery instead of dumping a static docs block 5. Generic MCP skill (skills/mcp-services/SKILL.md) — teaches the full workflow: discover → connect → get schemas → import → call 6. mcp-setup-m365 pre-approves configured servers in the approval store so the LLM can connect them without interactive prompts Target flow: user asks 'What is in Teams?' → LLM discovers servers → connects work-iq-teams (pre-approved) → calls tool → returns data. Tested: just check passes (2198/2198 tests, lint clean) --- scripts/m365-setup.ts | 86 +++++++++++++++++++-- skills/mcp-services/SKILL.md | 111 +++++++++++++++++++++++++++ src/agent/index.ts | 140 ++++++++++++++++++++++++++++++++--- src/agent/system-message.ts | 32 ++++---- 4 files changed, 338 insertions(+), 31 deletions(-) create mode 100644 skills/mcp-services/SKILL.md diff --git a/scripts/m365-setup.ts b/scripts/m365-setup.ts index 50625b8..e3e6a17 100644 --- a/scripts/m365-setup.ts +++ b/scripts/m365-setup.ts @@ -20,6 +20,7 @@ // State file at ~/.hyperagent/m365.json supplies clientId/tenantId // when not overridden. +import { createHash } from "node:crypto"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { join, dirname } from "node:path"; @@ -71,6 +72,72 @@ interface HyperAgentConfig { [key: string]: unknown; } +// ── Approval store ─────────────────────────────────────────────────── + +const APPROVAL_FILE = join(homedir(), ".hyperagent", "approved-mcp.json"); + +interface ApprovalRecord { + configHash: string; + approvedAt: string; + approvedTools: string[]; + auditWarnings: string[]; +} + +/** + * Compute the same config hash that `src/agent/mcp/config.ts` uses. + * Must stay in sync with `computeMCPConfigHash()` — if the hash + * algorithm changes there, it must change here too. + */ +function computeConfigHash( + name: string, + url: string, + clientId: string, +): string { + return createHash("sha256") + .update(name, "utf8") + .update("http", "utf8") + .update(url, "utf8") + .update("oauth", "utf8") // auth method + .update(clientId, "utf8") + .update("[]", "utf8") // allowTools + .update("[]", "utf8") // denyTools + .digest("hex"); +} + +/** + * Pre-approve all configured servers so the LLM can connect them + * without prompting the user. Approval is keyed on config hash — + * if the config changes, re-approval is required. + */ +function preApproveServers( + servers: Array<{ name: string; url: string; clientId: string }>, +): void { + let store: Record = {}; + try { + if (existsSync(APPROVAL_FILE)) { + store = JSON.parse( + readFileSync(APPROVAL_FILE, "utf8"), + ) as Record; + } + } catch { + store = {}; + } + + for (const srv of servers) { + store[srv.name] = { + configHash: computeConfigHash(srv.name, srv.url, srv.clientId), + approvedAt: new Date().toISOString(), + approvedTools: [], // Tools not known until connect — empty is fine + auditWarnings: [], + }; + } + + mkdirSync(dirname(APPROVAL_FILE), { recursive: true, mode: 0o700 }); + writeFileSync(APPROVAL_FILE, JSON.stringify(store, null, 2), { + mode: 0o600, + }); +} + // ── Helpers ────────────────────────────────────────────────────────── function fail(msg: string): never { @@ -268,6 +335,7 @@ function main(): void { : undefined; let count = 0; + const configured: Array<{ name: string; url: string; clientId: string }> = []; for (const s of selected) { const srv = catalog.servers[s]; const scope = scopeOverride || defaultScope || srv.scope; @@ -292,21 +360,29 @@ function main(): void { scope, flow, ); + configured.push({ + name: ALIAS_PREFIX + s, + url: tenantedUrl, + clientId, + }); count += 1; } + // Pre-approve all configured servers so the LLM can connect them + // without interactive approval prompts. + preApproveServers(configured); + console.log(""); - console.log(`✅ Configured ${count} M365 MCP server(s)`); + console.log(`✅ Configured ${count} M365 MCP server(s) (pre-approved)`); console.log(""); console.log(" Next:"); console.log(" just start"); - console.log(" /plugin enable mcp"); - console.log(" /mcp enable work-iq-"); + console.log(' Ask: "What\'s happening in Teams?"'); console.log(""); console.log( flow === "device-code" - ? " First enable shows a device code + URL to enter on any browser." - : " First enable opens a browser for Microsoft sign-in.", + ? " First connect shows a device code + URL to enter on any browser." + : " First connect opens a browser for Microsoft sign-in.", ); console.log(" Tokens cached in ~/.hyperagent/mcp-tokens/"); } diff --git a/skills/mcp-services/SKILL.md b/skills/mcp-services/SKILL.md new file mode 100644 index 0000000..111bb3e --- /dev/null +++ b/skills/mcp-services/SKILL.md @@ -0,0 +1,111 @@ +--- +name: mcp-services +description: Connect and use external MCP servers (M365, GitHub, custom services) +triggers: + - MCP + - Teams + - Mail + - Calendar + - Planner + - SharePoint + - OneDrive + - Copilot + - email + - meetings + - tasks + - external service + - mcp server + - work-iq +antiPatterns: + - Don't try to manage_plugin("mcp:") — MCP servers are NOT regular plugins + - Don't import from "host:mcp-gateway" — that's the gateway sentinel, not a server + - Don't guess tool names — always call mcp_server_info() first + - Don't hardcode MCP tool schemas — they change when servers update +allowed-tools: + - list_mcp_servers + - mcp_server_info + - manage_mcp + - execute_javascript +--- + +## MCP Server Workflow + +MCP (Model Context Protocol) servers provide external tool capabilities — M365 +services, GitHub, databases, custom APIs. Follow this exact workflow: + +### Step 1: Discover configured servers + +``` +list_mcp_servers() +``` + +Returns all configured servers with their state (`idle`, `connected`, `error`). +Each server has a name like `work-iq-mail`, `work-iq-teams`, `github`, etc. + +### Step 2: Connect the server you need + +``` +manage_mcp({ action: "connect", name: "work-iq-mail" }) +``` + +- If pre-approved → connects silently +- If not approved → prompts the user for approval (shows tools + security info) +- Returns `{ success: true, tools: [...], module: "host:mcp-" }` + +### Step 3: Get tool schemas + +``` +mcp_server_info("work-iq-mail") +``` + +Returns full JSON Schema for every tool plus TypeScript declarations. Read this +BEFORE writing handler code — tool names and parameter shapes vary per server. + +### Step 4: Use the tools in handler code + +```javascript +import { SearchEmails } from "host:mcp-work-iq-mail"; + +export default async function handler(event) { + const result = await SearchEmails({ query: "from:boss subject:urgent" }); + return { content: [{ type: "text", text: JSON.stringify(result) }] }; +} +``` + +Key rules: +- Import from `host:mcp-` (the name from list_mcp_servers) +- Tool function names are EXACTLY as returned by mcp_server_info +- All MCP tool calls are async — use `await` +- Tools return `{ content: [{type, text}] }` — parse the text field as needed +- Some servers return embedded JSON (status text + JSON) — extract the JSON part + +### Server name patterns + +M365 servers use the `work-iq-` prefix: +- `work-iq-mail` — Email (search, send, reply, drafts) +- `work-iq-teams` — Teams (channels, chats, messages) +- `work-iq-calendar` — Calendar (events, scheduling) +- `work-iq-planner` — Planner (tasks, plans) +- `work-iq-sharepoint` — SharePoint (files, sites) +- `work-iq-onedrive` — OneDrive (personal files) +- `work-iq-copilot` — M365 Copilot (natural language queries) + +Other servers use their own names (e.g. `github`, `filesystem`). + +### Error handling + +- If `manage_mcp` returns `success: false` — the user denied approval or + auth failed. Tell the user what happened. +- If a tool call fails — check `lastError` in `list_mcp_servers()` output. +- OAuth servers may prompt for browser auth on first connect — this is normal. + +### Multiple servers in one task + +You can connect multiple servers in sequence: + +``` +manage_mcp({ action: "connect", name: "work-iq-mail" }) +manage_mcp({ action: "connect", name: "work-iq-calendar" }) +``` + +Then use tools from both in a single handler. diff --git a/src/agent/index.ts b/src/agent/index.ts index 9d259f5..9f19803 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -708,7 +708,11 @@ if (discoveredCount > 0) { // ── MCP Integration ────────────────────────────────────────────────── import { parseMCPConfig } from "./mcp/config.js"; -import { isMCPHttpConfig, mcpConfigDisplayString } from "./mcp/types.js"; +import { + isMCPHttpConfig, + isMCPStdioConfig, + mcpConfigDisplayString, +} from "./mcp/types.js"; import { createMCPClientManager, type MCPClientManager, @@ -717,6 +721,12 @@ import { createMCPPluginAdapter, generateMCPDeclarations, } from "./mcp/plugin-adapter.js"; +import { + loadMCPApprovalStore, + isMCPApproved, + approveMCPServer, + auditMCPTools, +} from "./mcp/approval.js"; // Load MCP config from ~/.hyperagent/config.json let mcpManager: MCPClientManager | null = null; @@ -3360,17 +3370,22 @@ const listMCPServersTool = defineTool("list_mcp_servers", { description: [ "List all configured MCP servers with their connection state and available tools.", "Use this to discover which external tool servers are available.", - "Requires the 'mcp' plugin to be enabled first.", + "Works even before the MCP gateway plugin is enabled.", ].join("\n"), parameters: { type: "object", properties: {}, }, handler: async () => { - const err = requireMCPEnabled(); - if (err) return { error: err }; + if (!mcpManager) { + return { + servers: [], + total: 0, + hint: "No MCP servers configured. Add servers to ~/.hyperagent/config.json under mcpServers.", + }; + } - const servers = mcpManager!.listServers().map((conn) => ({ + const servers = mcpManager.listServers().map((conn) => ({ name: conn.name, state: conn.state, transport: isMCPHttpConfig(conn.config) ? "http" : "stdio", @@ -3473,6 +3488,7 @@ const manageMCPTool = defineTool("manage_mcp", { action: "connect" | "disconnect"; name: string; }) => { + const rl = state.readlineInstance; const err = requireMCPEnabled(); if (err) return { error: err }; @@ -3496,12 +3512,99 @@ const manageMCPTool = defineTool("manage_mcp", { }; } - // Suggest the user use /mcp enable which handles approval - return { - success: false, - message: `Use /mcp enable ${params.name} to connect — it handles approval and security review.`, - hint: "Tell the user to run the /mcp enable command.", - }; + try { + // Connect and discover tools + console.error(`[mcp] Connecting to ${params.name}...`); + const connected = await mcpManager!.connect(params.name); + + // Audit tool descriptions for prompt injection risks + const warnings = auditMCPTools(connected.tools); + + // Check if already approved (pre-approved via /mcp approve, + // profile, or a previous session) + const approvalStore = loadMCPApprovalStore(); + const approved = isMCPApproved(params.name, conn.config, approvalStore); + + if (!approved) { + // Show server details + tool list to the user + console.log(); + console.log(` ${C.label("MCP Server Approval Required")}`); + console.log(); + console.log(` Server: ${C.label(params.name)}`); + console.log( + ` ${isMCPStdioConfig(conn.config) ? "Command" : "URL"}: ${C.dim(mcpConfigDisplayString(conn.config))}`, + ); + console.log(); + console.log(` Tools (${connected.tools.length}):`); + for (const tool of connected.tools) { + console.log( + ` ${C.label(tool.name)} — ${tool.description.slice(0, 80)}`, + ); + } + if (warnings.length > 0) { + console.log(); + console.log(` ${C.err("⚠️ Audit Warnings:")}`); + for (const w of warnings) { + console.log(` ${C.err("•")} ${w}`); + } + } + console.log(); + + // Auto-approve or prompt + if (state.autoApprove) { + console.log(` ${C.ok("Auto-approved")} (--auto-approve mode)`); + } else if (process.stdin.isTTY && rl) { + await drainAndWarn(rl); + const answer = await promptUser( + rl, + ` Approve "${params.name}"? (y/n) `, + ); + if (answer.trim().toLowerCase() !== "y") { + console.log(` ${C.dim("Denied by user.")}`); + await mcpManager!.disconnect(params.name); + return { + success: false, + error: `User denied approval for "${params.name}".`, + }; + } + } else { + // Non-interactive, not auto-approved — refuse + await mcpManager!.disconnect(params.name); + return { + success: false, + error: `"${params.name}" requires approval but no interactive terminal is available. Run /mcp approve ${params.name} first.`, + }; + } + + // Store approval + approveMCPServer( + params.name, + conn.config, + connected.tools.map((t) => t.name), + warnings, + approvalStore, + ); + } + + // Sync MCP modules to the sandbox so the LLM can import them + await syncPluginsToSandbox(); + + console.log( + ` ${C.ok(`✓ "${params.name}" connected`)} — ${connected.tools.length} tool(s) available as ${C.dim(`host:mcp-${params.name}`)}`, + ); + + return { + success: true, + message: `"${params.name}" connected with ${connected.tools.length} tool(s).`, + module: `host:mcp-${params.name}`, + tools: connected.tools.map((t) => t.name), + }; + } catch (err) { + return { + success: false, + error: `Failed to connect "${params.name}": ${(err as Error).message}`, + }; + } } // Disconnect @@ -4251,6 +4354,7 @@ function buildSessionConfig() { scratchMb: memory.scratchMb, inputKb: buffers.inputKb, outputKb: buffers.outputKb, + mcpConfigured: mcpManager !== null, }); const pluginAdditions = pluginManager.getSystemMessageAdditions(); @@ -5110,6 +5214,20 @@ async function main(): Promise { // Register event handler for streaming + tool visibility registerEventHandler(session); + // ── Auto-enable MCP gateway if servers are configured ──────── + // The gateway plugin is a boolean sentinel (zero risk). When MCP + // servers are configured, auto-enable it so the LLM can discover + // and connect servers via tools without the user having to run + // /plugin enable mcp first. + if (mcpManager) { + const mcpPlugin = pluginManager.getPlugin("mcp"); + if (mcpPlugin && mcpPlugin.state !== "enabled") { + pluginManager.enable("mcp"); + await syncPluginsToSandbox(); + console.log(` ${C.ok("MCP gateway auto-enabled")} (servers configured)`); + } + } + // ── REPL Loop ──────────────────────────────────────────────── // // Slash commands are intercepted before reaching the agent. diff --git a/src/agent/system-message.ts b/src/agent/system-message.ts index a753a15..b1e30db 100644 --- a/src/agent/system-message.ts +++ b/src/agent/system-message.ts @@ -19,6 +19,8 @@ export interface SystemMessageParams { inputKb: number; /** Output buffer size in kilobytes. */ outputKb: number; + /** Whether MCP servers are configured (controls MCP hint in prompt). */ + mcpConfigured?: boolean; } /** Bytes per kilobyte — used for buffer size calculations. */ @@ -127,20 +129,7 @@ PLUGINS: Require explicit enable via manage_plugin. is unnecessary since they already return synchronously. MCP (Model Context Protocol) SERVERS: - External tool servers can be enabled via the "mcp" gateway plugin. - MCP servers are configured by the operator in ~/.hyperagent/config.json. - You CANNOT enable MCP servers yourself — the user must run: - /plugin enable mcp (enables the gateway) - /mcp enable (connects a specific server) - Once enabled, MCP tools appear as host:mcp- modules: - import { tool_name } from "host:mcp-"; - Discovery workflow: - 1. list_mcp_servers() — see configured servers and connection state - 2. mcp_server_info(name) — get tool schemas and TypeScript declarations - 3. Write handler code using the host:mcp- module - manage_mcp(action, name) — connect/disconnect servers programmatically. - Do NOT try to manage_plugin("mcp:") — MCP servers are NOT plugins. - Do NOT import from "host:mcp-gateway" — that is the gateway, not a server. +\${MCP_SECTION} async/await IS needed for libraries that use Promises internally. URLS: Do NOT guess URLs — they will 404. Discover via APIs or verify first. @@ -167,6 +156,18 @@ OUTPUT: Plain terminal — no markdown rendering. Tool results auto-display — export function buildSystemMessage(params: SystemMessageParams): string { const inputBytes = params.inputKb * BYTES_PER_KB; const outputBytes = params.outputKb * BYTES_PER_KB; + + const mcpSection = params.mcpConfigured + ? [ + " MCP servers are configured. Call list_mcp_servers() to discover available", + ' services and manage_mcp({action:"connect", name:"..."}) to connect them.', + " Once connected, get tool schemas via mcp_server_info(name), then import", + ' tools with: import { tool_name } from "host:mcp-"', + " Connection may prompt the user for approval (security review).", + ' Do NOT try to manage_plugin("mcp:") — MCP servers are NOT plugins.', + ].join("\n") + : " No MCP servers configured. Add servers to ~/.hyperagent/config.json under mcpServers."; + return SYSTEM_MESSAGE_TEMPLATE.replace( "${CPU_TIMEOUT_MS}", String(params.cpuTimeoutMs), @@ -177,5 +178,6 @@ export function buildSystemMessage(params: SystemMessageParams): string { .replace("${INPUT_KB}", String(params.inputKb)) .replace("${INPUT_BYTES}", String(inputBytes)) .replace("${OUTPUT_KB}", String(params.outputKb)) - .replace("${OUTPUT_BYTES}", String(outputBytes)); + .replace("${OUTPUT_BYTES}", String(outputBytes)) + .replace("${MCP_SECTION}", mcpSection); } From 2bc27a81db7ffd91b62176fd36ea56542bfdfe52 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 20:39:50 +0100 Subject: [PATCH 09/15] fix(mcp): bump MAX_MCP_SERVERS from 20 to 50 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M365 catalog has 21 servers alone — adding GitHub/filesystem pushed over the limit. Config parsing failed silently, gateway didn't auto- enable, LLM had no MCP awareness. --- src/agent/mcp/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent/mcp/types.ts b/src/agent/mcp/types.ts index a473e30..c8a19e6 100644 --- a/src/agent/mcp/types.ts +++ b/src/agent/mcp/types.ts @@ -275,7 +275,7 @@ export type MCPApprovalStore = Record; // ── Constants ──────────────────────────────────────────────────────── /** Maximum number of configured MCP servers. */ -export const MAX_MCP_SERVERS = 20; +export const MAX_MCP_SERVERS = 50; /** Maximum concurrent MCP connections. */ export const MAX_MCP_CONNECTIONS = 5; From 0b2bd59d3acae7e86d723ee0896d6927a5d1d160 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 20:58:22 +0100 Subject: [PATCH 10/15] fix(mcp): don't block tool calls on interactive auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manage_mcp('connect') now checks if MSAL can acquire a token silently before attempting connection. If interactive auth (browser/device-code) would be needed, it returns immediately with an error telling the LLM to direct the user to /mcp enable instead of hanging on a browser window inside a tool call. Once the user has authenticated once via /mcp enable, subsequent manage_mcp('connect') calls use the cached token and connect instantly. Added canAcquireSilently() to msal-oauth.ts — tries acquireTokenSilent and returns a boolean without triggering interactive flows. --- skills/mcp-services/SKILL.md | 7 +++++-- src/agent/index.ts | 22 ++++++++++++++++++++++ src/agent/mcp/auth/msal-oauth.ts | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/skills/mcp-services/SKILL.md b/skills/mcp-services/SKILL.md index 111bb3e..5d6732a 100644 --- a/skills/mcp-services/SKILL.md +++ b/skills/mcp-services/SKILL.md @@ -94,8 +94,11 @@ Other servers use their own names (e.g. `github`, `filesystem`). ### Error handling -- If `manage_mcp` returns `success: false` — the user denied approval or - auth failed. Tell the user what happened. +- If `manage_mcp` returns `success: false` with "requires authentication" — + tell the user to run `/mcp enable ` to authenticate in their browser. + Once they've done that, retry `manage_mcp` — it will connect silently. +- If `manage_mcp` returns `success: false` with "denied approval" — the user + declined. Don't retry — explain what the server does and ask if they want to try again. - If a tool call fails — check `lastError` in `list_mcp_servers()` output. - OAuth servers may prompt for browser auth on first connect — this is normal. diff --git a/src/agent/index.ts b/src/agent/index.ts index 9f19803..b520911 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -727,6 +727,7 @@ import { approveMCPServer, auditMCPTools, } from "./mcp/approval.js"; +import { canAcquireSilently } from "./mcp/auth/msal-oauth.js"; // Load MCP config from ~/.hyperagent/config.json let mcpManager: MCPClientManager | null = null; @@ -3513,6 +3514,27 @@ const manageMCPTool = defineTool("manage_mcp", { } try { + // For OAuth servers, check if we can authenticate silently + // (cached/refreshed token). If not, interactive auth would + // block the tool call indefinitely — bail out and tell the + // user to authenticate first via /mcp enable. + if ( + isMCPHttpConfig(conn.config) && + conn.config.auth?.method === "oauth" + ) { + const canSilent = await canAcquireSilently( + params.name, + conn.config.auth, + ); + if (!canSilent) { + return { + success: false, + error: `"${params.name}" requires authentication. Tell the user to run: /mcp enable ${params.name}`, + hint: "The user needs to authenticate in their browser first. Once done, you can retry manage_mcp to connect.", + }; + } + } + // Connect and discover tools console.error(`[mcp] Connecting to ${params.name}...`); const connected = await mcpManager!.connect(params.name); diff --git a/src/agent/mcp/auth/msal-oauth.ts b/src/agent/mcp/auth/msal-oauth.ts index a5bb20b..894ee46 100644 --- a/src/agent/mcp/auth/msal-oauth.ts +++ b/src/agent/mcp/auth/msal-oauth.ts @@ -192,6 +192,24 @@ function openBrowser(url: string): void { // ── Public API ─────────────────────────────────────────────────────── +/** + * Try to acquire a token silently (cached or refresh token). + * Returns true if a valid token can be obtained without user interaction. + * Returns false if interactive auth (browser/device-code) would be needed. + * + * Use this to pre-flight check before calling acquireMsalToken() in + * contexts where interactive auth would hang (e.g. inside a tool call). + */ +export async function canAcquireSilently( + serverName: string, + authConfig: MCPOAuthConfig, +): Promise { + const pca = buildPca(serverName, authConfig); + const scopes = resolveScopes(authConfig); + const result = await tryAcquireSilent(pca, scopes); + return result !== null; +} + /** * Acquire an OAuth access token using MSAL. * From 6af867b1b693b9e8636fcb4fcddd6883327fb3e2 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 21:06:57 +0100 Subject: [PATCH 11/15] fix(mcp): auto-approve gateway plugin on auto-enable The MCP gateway plugin source hash changes on every npm install (rebuild), invalidating the old approval. syncPluginsToSandbox then refuses to load it. Fix: when auto-enabling the gateway, set a synthetic audit result and approve with the current content hash. The plugin is a boolean sentinel (returns true from a single function) so auto-approving is zero risk. --- src/agent/index.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/agent/index.ts b/src/agent/index.ts index b520911..38a2b82 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -44,6 +44,7 @@ import { Transcript } from "./transcript.js"; import { createPluginManager, contentHash, + computePluginHash, loadOperatorConfig, exceedsRiskThreshold, resolvePluginSource, @@ -5241,9 +5242,33 @@ async function main(): Promise { // servers are configured, auto-enable it so the LLM can discover // and connect servers via tools without the user having to run // /plugin enable mcp first. + // + // We also set a synthetic audit result and re-approve the plugin + // so that syncPluginsToSandbox doesn't reject it due to stale + // content hashes (which change every time npm install rebuilds). if (mcpManager) { const mcpPlugin = pluginManager.getPlugin("mcp"); if (mcpPlugin && mcpPlugin.state !== "enabled") { + // Compute current content hash so the audit matches the source + const mcpHash = computePluginHash(mcpPlugin.dir); + if (mcpHash) { + pluginManager.setAuditResult("mcp", { + contentHash: mcpHash, + auditedAt: new Date().toISOString(), + findings: [], + riskLevel: "LOW", + summary: + "Boolean sentinel — exposes a single read-only status check.", + descriptionAccurate: true, + capabilities: ["MCP gateway status"], + riskReasons: ["No filesystem, network, or exec capabilities"], + recommendation: { + verdict: "approve", + reason: "Auto-approved: gateway sentinel", + }, + }); + pluginManager.approve("mcp"); + } pluginManager.enable("mcp"); await syncPluginsToSandbox(); console.log(` ${C.ok("MCP gateway auto-enabled")} (servers configured)`); From 3025e33902ff1289f883b273aaa0ab3a0896e5fd Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 21:29:45 +0100 Subject: [PATCH 12/15] fix(mcp): safe auth in yolo mode, /mcp enable as suggested command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes: 1. /mcp enable refuses OAuth servers in --auto-approve/yolo mode when no cached silent token exists. Prevents opening a browser that nobody is watching in CI/pipeline scenarios. 2. Add /mcp enable to ACTIONABLE_COMMAND_PREFIXES so the LLM's suggestion to run '/mcp enable work-iq-teams' gets picked up by extractSuggestedCommands and offered as a one-click [Y/n] prompt. This closes the 'user has to manually type the command' gap. 3. Fix 'too many servers' test to match bumped MAX_MCP_SERVERS (50). Auth safety model: - manage_mcp tool call: canAcquireSilently check → refuses if interactive needed - /mcp enable (interactive): browser opens, user authenticates → fine - /mcp enable (yolo): canAcquireSilently check → refuses with clear message - Once authenticated: cached tokens → everything works silently --- src/agent/command-suggestions.ts | 3 ++- src/agent/slash-commands.ts | 26 ++++++++++++++++++++++++++ tests/mcp.test.ts | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/agent/command-suggestions.ts b/src/agent/command-suggestions.ts index 2335c35..fc20ce5 100644 --- a/src/agent/command-suggestions.ts +++ b/src/agent/command-suggestions.ts @@ -11,6 +11,7 @@ export const ACTIONABLE_COMMAND_PREFIXES = [ "/plugin enable", "/plugin disable", + "/mcp enable", "/buffer ", "/timeout ", "/set ", @@ -37,7 +38,7 @@ export function extractSuggestedCommands(text: string): string[] { // Pattern 1: commands inside backticks — `/plugin enable fetch ...` // This catches inline code references the LLM wraps in backticks. const backtickRe = - /`(\/(?:plugin\s+enable|plugin\s+disable|buffer|timeout|set)\s[^`]+)`/gi; + /`(\/(?:plugin\s+enable|plugin\s+disable|mcp\s+enable|buffer|timeout|set)\s[^`]+)`/gi; for (const m of text.matchAll(backtickRe)) { const cmd = m[1].trim(); if (!seen.has(cmd) && !PLACEHOLDER_RE.test(cmd)) { diff --git a/src/agent/slash-commands.ts b/src/agent/slash-commands.ts index c24de17..66f03d2 100644 --- a/src/agent/slash-commands.ts +++ b/src/agent/slash-commands.ts @@ -36,6 +36,7 @@ import { auditMCPTools, } from "./mcp/approval.js"; import { maskEnvValue } from "./mcp/sanitise.js"; +import { canAcquireSilently } from "./mcp/auth/msal-oauth.js"; import { isMCPHttpConfig, isMCPStdioConfig, @@ -2352,6 +2353,31 @@ export async function handleSlashCommand( } try { + // For OAuth servers in auto-approve mode, check if we can + // authenticate silently. If not, interactive auth would + // open a browser that nobody's watching (yolo/CI mode). + // Refuse early instead of hanging. + if ( + deps.state.autoApprove && + isMCPHttpConfig(conn.config) && + conn.config.auth?.method === "oauth" + ) { + const canSilent = await canAcquireSilently( + mcpName, + conn.config.auth, + ); + if (!canSilent) { + console.log( + ` ${C.err(`"${mcpName}" requires interactive authentication but running in auto-approve mode.`)}`, + ); + console.log( + ` ${C.dim("Run without --auto-approve first to authenticate, then tokens will be cached for future runs.")}`, + ); + console.log(); + return true; + } + } + // Connect and discover tools console.log(` Connecting to ${C.label(mcpName)}...`); const connected = await deps.mcpManager.connect(mcpName); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 5603790..2efdb41 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -92,7 +92,7 @@ describe("parseMCPConfig", () => { it("rejects too many servers", () => { const servers: Record = {}; - for (let i = 0; i < 25; i++) { + for (let i = 0; i < 55; i++) { servers[`server-${i}`] = { command: "test" }; } const { errors } = parseMCPConfig(servers); From 5a170c10d1d36de40023182e99c0f37ea69a7d69 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 22:08:45 +0100 Subject: [PATCH 13/15] feat(mcp): write-safety gate + docs overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write-safety gate: - Capture tool annotations (readOnlyHint, destructiveHint, etc.) from MCP listTools() into MCPToolSchema - Intercept non-read-only tool calls in plugin-adapter before execution - Interactive TTY: prompt user 'Allow? [y/n]' with tool name + args - --auto-approve: allow all operations (yolo is yolo) - No TTY + no auto-approve: refuse with clear error - Gate is transparent to the LLM — it sees results or error objects Docs (MCP.md): - Rewrite HTTP/OAuth section: MSAL, flow field, redirect URI, scopes - Rewrite M365 section: VS Code FOCI app, mcp-setup-m365 recipes, auth flows (browser/device-code), scope (.default), pre-approval - Add write-safety gate section with decision matrix - Update quick start: gateway auto-enables, LLM-driven discovery - Update architecture diagram with gate - Fix stale references (callbackPort, hand-rolled PKCE, old recipe names) - Bump max servers to 50 Tested: just check passes (2198/2198 tests, lint clean) --- docs/MCP.md | 233 +++++++++++++++++++++----------- skills/mcp-services/SKILL.md | 4 + src/agent/index.ts | 64 ++++++++- src/agent/mcp/client-manager.ts | 20 +++ src/agent/mcp/plugin-adapter.ts | 48 ++++++- src/agent/mcp/types.ts | 15 ++ 6 files changed, 301 insertions(+), 83 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index f9a00a9..a951359 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -32,10 +32,13 @@ Or use the setup script: just mcp-setup-everything # sets up the MCP everything test server ``` -### 2. Enable the MCP gateway plugin +### 2. Start HyperAgent -``` -/plugin enable mcp +The MCP gateway plugin auto-enables when servers are configured — no +manual `/plugin enable mcp` needed. + +```bash +just start ``` ### 3. Connect a server @@ -44,6 +47,9 @@ just mcp-setup-everything # sets up the MCP everything test server /mcp enable everything ``` +Or just ask a question — the LLM will discover configured servers and +connect them automatically (prompting for approval if needed). + ### 4. Use MCP tools in your prompt ``` @@ -86,7 +92,7 @@ function handler(event) { - Names must match `/^[a-z][a-z0-9-]*$/` (lowercase, alphanumeric, hyphens). - Cannot collide with native plugin names (`fs-read`, `fs-write`, `fetch`). -- Maximum 20 configured servers. +- Maximum 50 configured servers. ### Tool filtering @@ -140,11 +146,14 @@ are shown during approval. | `mcp_server_info(name)`| Detailed info + TypeScript declarations | | `manage_mcp(action, name)` | Connect/disconnect servers | -The LLM discovers MCP by: -1. Enabling the `mcp` plugin via `manage_plugin` -2. Calling `list_mcp_servers()` to see what's available -3. Calling `mcp_server_info(name)` for tool schemas -4. Writing handler code with `import { tool } from "host:mcp-"` +The LLM discovers MCP automatically: +1. MCP gateway auto-enables on startup when servers are configured +2. Calls `list_mcp_servers()` to discover available servers +3. Calls `manage_mcp("connect", name)` — pre-approved servers connect silently, + others prompt the user for approval. OAuth servers that need first-time + browser auth will direct the user to run `/mcp enable `. +4. Calls `mcp_server_info(name)` for tool schemas +5. Writes handler code with `import { tool } from "host:mcp-"` --- @@ -194,6 +203,35 @@ Suspicious descriptions are flagged during approval. - Env var values are **never** logged anywhere - MCP responses are capped at 1 MB +### Write-safety gate + +MCP tools that are not read-only are intercepted before execution. The +gate uses the MCP spec's `ToolAnnotations` (hints from the server): + +| Scenario | `readOnlyHint=true` | No annotations / write | `destructiveHint=true` | +|----------------------------------|---------------------|------------------------|------------------------| +| **Interactive TTY** | Execute ✅ | Prompt `[y/n]` | Prompt `[y/n]` ⚠️ | +| **`--auto-approve` (yolo)** | Execute ✅ | Execute ✅ | Execute ✅ | +| **No TTY, no auto-approve** | Execute ✅ | Refuse ❌ | Refuse ❌ | + +The gate runs on the **host side** while the guest VM is paused — the +LLM's handler code sees either a normal result or +`{ error: "Operation denied..." }`. The LLM doesn't need to know about +the gate; it writes code normally. + +Example prompt shown to the user: + +``` +⚠️ MCP write operation: work-iq-mail.SendEmail + to: boss@contoso.com + subject: Q4 Report + Allow? [y/n] +``` + +Note: annotations are **hints from untrusted servers** (per MCP spec). +Tools without annotations are treated as writes (prompted). Use +`allowTools`/`denyTools` in config for hard enforcement. + --- ## End-to-End Example: GitHub Issue Report @@ -375,75 +413,97 @@ no documented service-principal / client-credentials flow for Work IQ. ### Alternative: HTTP path (Agent 365 per-service servers) -Instead of the single stdio `workiq` server you can wire up the +Instead of the single stdio `workiq` server you can connect to the per-service Agent 365 HTTP endpoints directly. This gives you finer -`/mcp enable` control per M365 service but requires a per-tenant Entra -app registration. Use the stdio path above unless you specifically need -per-service scoping. - -Servers registered (names verified against the -[Work IQ MCP reference](https://learn.microsoft.com/en-us/microsoft-agent-365/mcp-server-reference/)): - -| Config entry | Agent 365 server id | Service | -|----------------------|-------------------------------|----------------------------------| -| `work-iq-mail` | `mcp_MailTools` | Outlook mail | -| `work-iq-calendar` | `mcp_CalendarTools` | Calendar & scheduling | -| `work-iq-teams` | `mcp_TeamsServer` | Teams chats & channels | -| `work-iq-sharepoint` | `mcp_SharePointRemoteServer` | SharePoint sites, lists, files | -| `work-iq-onedrive` | `mcp_OneDriveRemoteServer` | Personal OneDrive | -| `work-iq-user` | `mcp_MeServer` | User profiles, org chart | -| `work-iq-copilot` | `mcp_M365Copilot` | M365 Copilot search | -| `work-iq-word` | `mcp_WordServer` | Word documents | - -Each entry is wired to `https://agent365.svc.cloud.microsoft/agents/tenants//servers/`. +`/mcp enable` control per M365 service and uses MSAL for OAuth. + +The setup script uses the VS Code MCP extension's pre-registered client ID +(`aebc6443-...`) which has `McpServers.*` scopes admin-consented in all +M365 Copilot tenants — no per-tenant app registration needed. + +21 servers are available (see the full list with `just mcp-setup-m365 list`). +Common ones: + +| Config entry | Service | +|----------------------|----------------------------------| +| `work-iq-mail` | Outlook mail | +| `work-iq-calendar` | Calendar & scheduling | +| `work-iq-teams` | Teams chats & channels | +| `work-iq-planner` | Planner tasks & plans | +| `work-iq-sharepoint` | SharePoint sites & files | +| `work-iq-onedrive` | Personal OneDrive | +| `work-iq-copilot` | M365 Copilot search | #### Setup ```bash -# 1. One-time: create (or reuse) the Entra app registration. -# Requires Azure CLI logged in; reuses any existing app in the tenant. -just mcp-workiq-create-app -# Add --service-ref if your tenant requires a Service Tree ref: -just mcp-workiq-create-app --service-ref 00000000-0000-0000-0000-000000000000 - -# 2. Write the HTTP config entries. Reads the saved clientId/tenantId -# from ~/.hyperagent/workiq.json (populated by step 1). -just mcp-setup-workiq-http # all services -just mcp-setup-workiq-http mail # just mail -just mcp-setup-workiq-http mail,teams # selected subset - -# 3. Enable whichever services you need. -just start -# /plugin enable mcp -# /mcp enable work-iq-mail -# /mcp enable work-iq-calendar -# ... +# Configure all M365 servers with browser auth (one-time) +just mcp-setup-m365 all \ + aebc6443-996d-45c2-90f0-388ff96faa56 \ + \ + "" browser + +# Or a subset +just mcp-setup-m365 "mail,teams,planner" \ + aebc6443-996d-45c2-90f0-388ff96faa56 \ + \ + "" browser + +# List available services +just mcp-setup-m365 list +``` + +This writes config entries AND pre-approves all configured servers so the +LLM can connect them without interactive prompts. + +#### Auth flows + +The `FLOW` argument (last positional) is **required**: + +| Flow | When to use | +|------|-------------| +| `browser` | Workstation with a browser. MSAL opens `http://localhost` (ephemeral port). | +| `device-code` | SSH, containers, no browser. Prints a code + URL to enter on any device. | + +First connect opens the browser / shows the device code. Tokens are cached +in `~/.hyperagent/mcp-tokens/.msal.json` and refreshed silently on +subsequent sessions. + +#### Auth safety in `--auto-approve` mode + +If the LLM tries to connect an OAuth server with no cached token in +`--auto-approve` (yolo) mode, it refuses immediately instead of opening a +browser nobody's watching. Authenticate interactively first, then yolo +works with cached tokens. -# See stored app details any time: -just mcp-workiq-show +#### Custom Entra app registration + +If your tenant blocks the VS Code client ID, create your own app: + +```bash +just mcp-m365-create-app +# Then use your app's client ID: +just mcp-setup-m365 all "" browser ``` -The eight Work IQ servers above are the complete public catalogue as of -the [MS Learn Tooling servers overview](https://learn.microsoft.com/en-us/microsoft-agent-365/tooling-servers-overview). -Microsoft is adding more (Dataverse/Dynamics 365 is already listed but -ships on a different URL pattern) — when a new one lands, add it to the -`MAP` in the `mcp-setup-workiq-http` recipe in the `Justfile`. +#### Scope + +All servers use `ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default` (the Agent 365 +resource app ID with `.default`), which requests all pre-consented scopes in +one shot. This matches what [a365cli](https://github.com/sozercan/a365cli) uses. -The app registration is **single-tenant** (`AzureADMyOrg`) and grants the -Agent 365 delegated scopes required for the per-service token exchange. -Tenant admin consent is attempted automatically; if you aren't an admin, -the script prints an admin-consent URL to hand off. +#### Refreshing the server catalog -State file `~/.hyperagent/workiq.json` (not committed) holds the -resolved `clientId`, `tenantId`, and `callbackPort`. A second developer on -the same tenant can re-run `just mcp-workiq-create-app` — the script -looks up the existing app by saved clientId first, then by display name, -and only creates a new one as a last resort. +```bash +just mcp-m365-refresh-servers # uses cached OAuth token +just mcp-m365-refresh-servers --token # explicit token +``` -## HTTP Transport & OAuth (generic remote MCP servers) +## HTTP Transport & OAuth -HyperAgent supports remote MCP servers over HTTP with OAuth 2.0 (PKCE) for -cases where a hosted MCP endpoint requires bearer-token auth. +HyperAgent supports remote MCP servers over HTTP with OAuth 2.0 via +[@azure/msal-node](https://github.com/AzureAD/microsoft-authentication-library-for-js). +MSAL handles PKCE, token caching, refresh, and redirect URIs. Config shape: @@ -455,24 +515,34 @@ Config shape: "url": "https://example.com/mcp", "auth": { "method": "oauth", + "flow": "browser", "clientId": "", - "tenantId": "", - "callbackPort": 8080 + "tenantId": "", + "scopes": ["api://example/.default"], + "redirectUri": "http://localhost" } } } } ``` -On first connect HyperAgent starts a short-lived callback listener on -`http://localhost:/callback`, opens the system browser to the -authorisation endpoint advertised by the server's OAuth metadata, performs -PKCE, and persists the resulting tokens to -`~/.hyperagent/mcp-tokens/.json` (mode `0600` on Unix). +### Auth config fields + +| Field | Required | Description | +|-------|----------|-------------| +| `method` | Yes | Must be `"oauth"` | +| `flow` | Yes | `"browser"` or `"device-code"` — no default | +| `clientId` | Yes | Entra app (client) ID | +| `tenantId` | No | Defaults to `"organizations"` | +| `scopes` | No | OAuth scopes array | +| `redirectUri` | No | Override redirect URI (default: `http://localhost`, works with MSAL-compatible apps) | + +### Token caching -Subsequent sessions reuse the cached token and refresh silently. Deleting -the token file forces a fresh sign-in. Tokens are **never** written to the -transcript log. +MSAL persists tokens to `~/.hyperagent/mcp-tokens/.msal.json` +(mode `0600`). Refresh tokens survive across sessions — only the first +connect requires interactive auth. Deleting the file forces re-auth. +Tokens are **never** written to the transcript log. --- @@ -534,10 +604,10 @@ Any MCP-compatible server works. Popular options from the ▼ ┌──────────────────────────────────────┐ │ MCPClientManager │ -│ Lazy connect → stdio process │ -│ Tool discovery → PluginAdapter │ +│ Connect → stdio process / HTTP+MSAL │ +│ Tool discovery + annotations │ └──────────────┬───────────────────────┘ - │ setPlugins() + │ PluginAdapter + WriteSafetyGate ▼ ┌──────────────────────────────────────┐ │ Hyperlight Sandbox (micro-VM) │ @@ -545,13 +615,14 @@ Any MCP-compatible server works. Popular options from the │ import { echo } from │ │ "host:mcp-everything"; │ │ const r = echo({ message: "hi" }); │ +│ // → write-safety gate checks │ │ // → { content: "Echo: hi" } │ └──────────────────────────────────────┘ ``` MCP tools are bridged through the same `host:` module mechanism as native -plugins. The sandbox sees synchronous function calls — async transport is -handled transparently by the bridge layer. +plugins. The sandbox sees synchronous function calls — async transport and +the write-safety gate are handled transparently by the bridge layer. --- diff --git a/skills/mcp-services/SKILL.md b/skills/mcp-services/SKILL.md index 5d6732a..f9a219b 100644 --- a/skills/mcp-services/SKILL.md +++ b/skills/mcp-services/SKILL.md @@ -78,6 +78,10 @@ Key rules: - All MCP tool calls are async — use `await` - Tools return `{ content: [{type, text}] }` — parse the text field as needed - Some servers return embedded JSON (status text + JSON) — extract the JSON part +- **Write operations** (tools not marked `readOnlyHint: true`) may prompt the + user for approval before executing. If denied, the tool returns + `{ error: "Operation denied..." }` — handle this gracefully and explain + to the user what happened. Do NOT retry denied operations. ### Server name patterns diff --git a/src/agent/index.ts b/src/agent/index.ts index 38a2b82..bef9d93 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -721,6 +721,7 @@ import { import { createMCPPluginAdapter, generateMCPDeclarations, + type WriteSafetyGate, } from "./mcp/plugin-adapter.js"; import { loadMCPApprovalStore, @@ -777,6 +778,63 @@ let mcpManager: MCPClientManager | null = null; * function from ever executing if dangerous code was detected. This * closes GAP 2 where malicious code could run in host context. */ + +/** + * MCP write-safety gate. + * + * Intercepts non-read-only MCP tool calls before execution. + * Read-only tools (readOnlyHint === true) bypass the gate entirely. + * + * Behaviour: + * - autoApprove mode → allow silently + * - interactive TTY → prompt user "[y/n]" + * - no TTY → refuse + * + * The gate runs on the host while the guest VM is paused, so prompting + * the user is safe — there's no timeout risk from the sandbox side. + */ +const mcpWriteSafetyGate: WriteSafetyGate = async ( + serverName, + toolName, + args, + annotations, +) => { + // Auto-approve mode → allow everything + if (state.autoApprove) return true; + + // Build a concise summary of the args for the prompt + const argSummary = Object.entries(args) + .slice(0, 5) // Don't dump huge arg lists + .map(([k, v]) => { + const val = typeof v === "string" ? v.slice(0, 80) : JSON.stringify(v); + return ` ${k}: ${val}`; + }) + .join("\n"); + + const label = annotations?.destructiveHint + ? C.err("⚠️ MCP DESTRUCTIVE operation") + : C.warn("⚠️ MCP write operation"); + + console.log(); + console.log(` ${label}: ${C.label(serverName)}.${C.label(toolName)}`); + if (argSummary) { + console.log(argSummary); + } + + const rl = state.readlineInstance; + if (!rl || !process.stdin.isTTY) { + console.log( + ` ${C.err("Refused")} — no interactive terminal to approve write operations.`, + ); + return false; + } + + await drainAndWarn(rl); + const answer = await promptUser(rl, ` Allow? [y/n] `); + const normalised = answer.trim().toLowerCase(); + return normalised === "y" || normalised === "yes"; +}; + async function syncPluginsToSandbox(): Promise { const enabled = pluginManager.getEnabledPlugins(); @@ -867,7 +925,11 @@ async function syncPluginsToSandbox(): Promise { if (mcpPlugin && mcpPlugin.state === "enabled") { for (const conn of mcpManager.listServers()) { if (conn.state === "connected" && conn.tools.length > 0) { - const adapter = createMCPPluginAdapter(conn, mcpManager); + const adapter = createMCPPluginAdapter( + conn, + mcpManager, + mcpWriteSafetyGate, + ); registrations.push(adapter); } } diff --git a/src/agent/mcp/client-manager.ts b/src/agent/mcp/client-manager.ts index 17952ef..159c736 100644 --- a/src/agent/mcp/client-manager.ts +++ b/src/agent/mcp/client-manager.ts @@ -244,6 +244,26 @@ export function createMCPClientManager() { originalName: tool.name, description: sanitiseDescription(tool.description ?? ""), inputSchema: (tool.inputSchema as Record) ?? {}, + // Capture behavioural annotations if the server provides them. + // These are hints (not guarantees) used by the write-safety gate. + ...(tool.annotations + ? { + annotations: { + ...(tool.annotations.readOnlyHint !== undefined + ? { readOnlyHint: tool.annotations.readOnlyHint } + : {}), + ...(tool.annotations.destructiveHint !== undefined + ? { destructiveHint: tool.annotations.destructiveHint } + : {}), + ...(tool.annotations.idempotentHint !== undefined + ? { idempotentHint: tool.annotations.idempotentHint } + : {}), + ...(tool.annotations.openWorldHint !== undefined + ? { openWorldHint: tool.annotations.openWorldHint } + : {}), + }, + } + : {}), })); conn.client = client; diff --git a/src/agent/mcp/plugin-adapter.ts b/src/agent/mcp/plugin-adapter.ts index de15023..6dcef5f 100644 --- a/src/agent/mcp/plugin-adapter.ts +++ b/src/agent/mcp/plugin-adapter.ts @@ -3,9 +3,34 @@ // Wraps an MCP server connection as a PluginRegistration so it can // be registered with the sandbox via setPlugins() alongside native // plugins. Each MCP server becomes a host module at `host:mcp-`. +// +// Write-safety gate: tools that are not read-only are intercepted +// before execution. In interactive mode, the user is prompted to +// approve. In auto-approve mode, they execute silently. In non- +// interactive mode without auto-approve, they are refused. import type { MCPClientManager } from "./client-manager.js"; -import type { MCPConnection, MCPToolSchema } from "./types.js"; +import type { + MCPConnection, + MCPToolSchema, + MCPToolAnnotations, +} from "./types.js"; + +/** + * Callback that decides whether a write operation should proceed. + * + * @param serverName - MCP server name (e.g. "work-iq-mail") + * @param toolName - Tool name (e.g. "SendEmail") + * @param args - The arguments being passed to the tool + * @param annotations - Tool annotations (hints from server) + * @returns true to allow, false to deny + */ +export type WriteSafetyGate = ( + serverName: string, + toolName: string, + args: Record, + annotations: MCPToolAnnotations | undefined, +) => Promise; /** * PluginRegistration-compatible interface. @@ -27,11 +52,14 @@ export interface MCPPluginRegistration { * * @param conn - The MCP server connection (must be connected). * @param manager - The client manager for making tool calls. + * @param gate - Optional write-safety gate. When provided, non-read-only + * tools are checked before execution. * @returns A PluginRegistration that can be passed to setPlugins(). */ export function createMCPPluginAdapter( conn: MCPConnection, manager: MCPClientManager, + gate?: WriteSafetyGate, ): MCPPluginRegistration { const moduleName = `mcp-${conn.name}`; @@ -45,6 +73,24 @@ export function createMCPPluginAdapter( for (const tool of conn.tools) { functions[tool.name] = async (...args: unknown[]): Promise => { const toolArgs = (args[0] as Record) ?? {}; + + // Write-safety gate: if the tool is not read-only, check + // with the gate before executing. The guest VM is paused + // during this check — it's safe to prompt the user. + if (gate && tool.annotations?.readOnlyHint !== true) { + const allowed = await gate( + conn.name, + tool.name, + toolArgs, + tool.annotations, + ); + if (!allowed) { + return { + error: `Operation denied: ${tool.name} on ${conn.name} was blocked by the write-safety gate. The user declined the operation.`, + }; + } + } + return manager.callTool(conn.name, tool.name, toolArgs); }; } diff --git a/src/agent/mcp/types.ts b/src/agent/mcp/types.ts index c8a19e6..60a80aa 100644 --- a/src/agent/mcp/types.ts +++ b/src/agent/mcp/types.ts @@ -202,6 +202,18 @@ export interface MCPConfig { servers: Map; } +/** MCP tool annotations — hints about tool behaviour (from MCP spec). */ +export interface MCPToolAnnotations { + /** Tool only reads data, no side effects. */ + readOnlyHint?: boolean; + /** Tool can delete or destroy data. */ + destructiveHint?: boolean; + /** Tool is safe to retry (same input → same effect). */ + idempotentHint?: boolean; + /** Tool interacts with the external world. */ + openWorldHint?: boolean; +} + /** MCP tool schema as returned by listTools(). */ export interface MCPToolSchema { /** Tool name (sanitised to valid JS identifier). */ @@ -215,6 +227,9 @@ export interface MCPToolSchema { /** JSON Schema for the tool's input parameters. */ inputSchema: Record; + + /** Behavioural annotations from the server (hints, not guarantees). */ + annotations?: MCPToolAnnotations; } /** Connection state for an MCP server. */ From 36ab2498b912091807626a501e456b54a6749f73 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 27 Apr 2026 22:19:15 +0100 Subject: [PATCH 14/15] fix(mcp): session-remember tool approvals, fix gateway hash on startup 1. Write-safety gate now remembers approved tools for the session. First call to SearchMessages prompts [y/n], subsequent calls to the same tool skip the prompt. Avoids prompting on every read when servers don't provide readOnlyHint annotations. 2. Pre-approve MCP gateway at discovery time (before any sync), not just at auto-enable time. Fixes the 'REFUSING to load' error on startup when the plugin hash changed since last session. --- src/agent/index.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/agent/index.ts b/src/agent/index.ts index bef9d93..5504fd0 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -707,6 +707,34 @@ if (discoveredCount > 0) { console.error(`[plugins] Discovered ${discoveredCount} plugin(s)`); } +// Pre-approve the MCP gateway plugin if it exists. The gateway is a +// boolean sentinel (zero risk) and its source hash changes on every +// npm install rebuild. Without this, syncPluginsToSandbox would refuse +// to load it due to a stale approval hash from a previous session. +{ + const mcpGateway = pluginManager.getPlugin("mcp"); + if (mcpGateway) { + const mcpHash = computePluginHash(mcpGateway.dir); + if (mcpHash) { + pluginManager.setAuditResult("mcp", { + contentHash: mcpHash, + auditedAt: new Date().toISOString(), + findings: [], + riskLevel: "LOW", + summary: "Boolean sentinel — exposes a single read-only status check.", + descriptionAccurate: true, + capabilities: ["MCP gateway status"], + riskReasons: ["No filesystem, network, or exec capabilities"], + recommendation: { + verdict: "approve", + reason: "Auto-approved: gateway sentinel", + }, + }); + pluginManager.approve("mcp"); + } + } +} + // ── MCP Integration ────────────────────────────────────────────────── import { parseMCPConfig } from "./mcp/config.js"; import { @@ -787,12 +815,17 @@ let mcpManager: MCPClientManager | null = null; * * Behaviour: * - autoApprove mode → allow silently - * - interactive TTY → prompt user "[y/n]" + * - already approved this session → allow silently + * - interactive TTY → prompt user "[y/n]" (remembered for session) * - no TTY → refuse * * The gate runs on the host while the guest VM is paused, so prompting * the user is safe — there's no timeout risk from the sandbox side. */ + +/** Tools approved by the user during this session (server.tool keys). */ +const mcpSessionApprovedTools = new Set(); + const mcpWriteSafetyGate: WriteSafetyGate = async ( serverName, toolName, @@ -802,6 +835,10 @@ const mcpWriteSafetyGate: WriteSafetyGate = async ( // Auto-approve mode → allow everything if (state.autoApprove) return true; + // Already approved this tool in this session → allow silently + const toolKey = `${serverName}.${toolName}`; + if (mcpSessionApprovedTools.has(toolKey)) return true; + // Build a concise summary of the args for the prompt const argSummary = Object.entries(args) .slice(0, 5) // Don't dump huge arg lists @@ -832,7 +869,12 @@ const mcpWriteSafetyGate: WriteSafetyGate = async ( await drainAndWarn(rl); const answer = await promptUser(rl, ` Allow? [y/n] `); const normalised = answer.trim().toLowerCase(); - return normalised === "y" || normalised === "yes"; + const allowed = normalised === "y" || normalised === "yes"; + if (allowed) { + // Remember for the rest of this session — don't re-prompt + mcpSessionApprovedTools.add(toolKey); + } + return allowed; }; async function syncPluginsToSandbox(): Promise { From ca0043c1e4cad0cb915509f4006fe04e0224ed99 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 28 Apr 2026 09:11:42 +0100 Subject: [PATCH 15/15] fix(mcp): address PR #83 review feedback (4 comments) 1. Header validation: verify all header values are strings with non-empty keys, not just that headers is an object. 2. Config hash: include flow, tenantId, scopes, redirectUri, clientSecretEnv, and header keys in the SHA-256 hash. Approval is now invalidated on any meaningful HTTP config change. Updated m365-setup.ts hash computation to match. 3. Require scopes for OAuth: resolveScopes() throws if no scopes configured instead of silently using only offline_access. Config validator now rejects missing/empty scopes for OAuth. 4. Non-interactive connect: use canAcquireSilently() instead of acquireMsalToken() in non-interactive mode. Never falls back to interactive auth in headless/no-TTY scenarios. All test fixtures updated for required scopes. 2198/2198 pass. --- scripts/m365-setup.ts | 39 ++++++++++++++++++++++++++++---- src/agent/mcp/auth/msal-oauth.ts | 19 ++++++++-------- src/agent/mcp/client-manager.ts | 29 +++++++----------------- src/agent/mcp/config.ts | 37 +++++++++++++++++++++++++++++- tests/mcp.test.ts | 35 +++++++++++++++++++--------- 5 files changed, 113 insertions(+), 46 deletions(-) diff --git a/scripts/m365-setup.ts b/scripts/m365-setup.ts index e3e6a17..8d8a17a 100644 --- a/scripts/m365-setup.ts +++ b/scripts/m365-setup.ts @@ -92,13 +92,20 @@ function computeConfigHash( name: string, url: string, clientId: string, + flow: string, + tenantId: string, + scopes: string[], ): string { return createHash("sha256") .update(name, "utf8") .update("http", "utf8") .update(url, "utf8") .update("oauth", "utf8") // auth method + .update(flow, "utf8") .update(clientId, "utf8") + .update(tenantId, "utf8") + .update(JSON.stringify(scopes), "utf8") + .update("", "utf8") // redirectUri (empty = default) .update("[]", "utf8") // allowTools .update("[]", "utf8") // denyTools .digest("hex"); @@ -110,7 +117,14 @@ function computeConfigHash( * if the config changes, re-approval is required. */ function preApproveServers( - servers: Array<{ name: string; url: string; clientId: string }>, + servers: Array<{ + name: string; + url: string; + clientId: string; + flow: string; + tenantId: string; + scopes: string[]; + }>, ): void { let store: Record = {}; try { @@ -125,9 +139,16 @@ function preApproveServers( for (const srv of servers) { store[srv.name] = { - configHash: computeConfigHash(srv.name, srv.url, srv.clientId), + configHash: computeConfigHash( + srv.name, + srv.url, + srv.clientId, + srv.flow, + srv.tenantId, + srv.scopes, + ), approvedAt: new Date().toISOString(), - approvedTools: [], // Tools not known until connect — empty is fine + approvedTools: [], auditWarnings: [], }; } @@ -335,7 +356,14 @@ function main(): void { : undefined; let count = 0; - const configured: Array<{ name: string; url: string; clientId: string }> = []; + const configured: Array<{ + name: string; + url: string; + clientId: string; + flow: string; + tenantId: string; + scopes: string[]; + }> = []; for (const s of selected) { const srv = catalog.servers[s]; const scope = scopeOverride || defaultScope || srv.scope; @@ -364,6 +392,9 @@ function main(): void { name: ALIAS_PREFIX + s, url: tenantedUrl, clientId, + flow, + tenantId, + scopes: [scope], }); count += 1; } diff --git a/src/agent/mcp/auth/msal-oauth.ts b/src/agent/mcp/auth/msal-oauth.ts index 894ee46..264dc8e 100644 --- a/src/agent/mcp/auth/msal-oauth.ts +++ b/src/agent/mcp/auth/msal-oauth.ts @@ -129,15 +129,16 @@ function buildPca( * configured. Always includes `offline_access` for refresh tokens. */ function resolveScopes(authConfig: MCPOAuthConfig): string[] { - const base = - authConfig.scopes && authConfig.scopes.length > 0 - ? [...authConfig.scopes] - : []; - // offline_access is needed for refresh_token. openid + profile for - // id_token. MSAL adds these itself for interactive flows but being - // explicit avoids surprises. - for (const s of ["offline_access"]) { - if (!base.includes(s)) base.push(s); + if (!authConfig.scopes || authConfig.scopes.length === 0) { + throw new Error( + "OAuth scopes are required but none configured. " + + 'Set "scopes" in the auth config (e.g. ["api://resource/.default"]).', + ); + } + const base = [...authConfig.scopes]; + // offline_access is needed for refresh_token. + if (!base.includes("offline_access")) { + base.push("offline_access"); } return base; } diff --git a/src/agent/mcp/client-manager.ts b/src/agent/mcp/client-manager.ts index 159c736..6967242 100644 --- a/src/agent/mcp/client-manager.ts +++ b/src/agent/mcp/client-manager.ts @@ -28,7 +28,7 @@ import { sanitiseToolName, sanitiseDescription } from "./sanitise.js"; import { acquireMsalToken, createMsalOAuthProvider, - hasMsalCache, + canAcquireSilently, } from "./auth/msal-oauth.js"; import { createRetryFetch } from "./retry-fetch.js"; import { @@ -152,32 +152,19 @@ export function createMCPClientManager() { const isInteractive = process.stdin.isTTY === true; if (!isInteractive) { - // In non-interactive mode, we can only succeed if MSAL has a - // cached token to refresh silently. Check for the cache file - // first — if it doesn't exist, fail fast with a clear message - // instead of letting MSAL hang trying to do interactive auth. - if (!hasMsalCache(name)) { + // In non-interactive mode, we can only succeed if MSAL can + // refresh silently. Use canAcquireSilently() which never triggers + // interactive auth — it returns false if interaction is needed. + const canSilent = await canAcquireSilently(name, authConfig); + if (!canSilent) { throw new Error( - `[mcp] OAuth authentication required for "${name}" but no cached ` + - `tokens found and no interactive terminal available.\n` + + `[mcp] OAuth authentication required for "${name}" but no valid ` + + `cached tokens and no interactive terminal available.\n` + ` Run HyperAgent interactively first to authenticate:\n` + ` npx tsx src/agent/index.ts\n` + ` /mcp enable ${name}`, ); } - // Cache exists — try silent refresh. - try { - await acquireMsalToken(name, authConfig); - } catch { - throw new Error( - `[mcp] OAuth authentication required for "${name}" but cached ` + - `tokens could not be refreshed and no interactive terminal ` + - `available.\n` + - ` Run HyperAgent interactively first to re-authenticate:\n` + - ` npx tsx src/agent/index.ts\n` + - ` /mcp enable ${name}`, - ); - } } else { // Interactive: acquire token eagerly (silent → browser/device-code). await acquireMsalToken(name, authConfig); diff --git a/src/agent/mcp/config.ts b/src/agent/mcp/config.ts index 24f4b9d..0a2842e 100644 --- a/src/agent/mcp/config.ts +++ b/src/agent/mcp/config.ts @@ -223,6 +223,15 @@ function validateHttpServerEntry( server: name, message: '"headers" must be an object of string key-value pairs.', }); + } else if ( + !Object.entries(obj.headers as Record).every( + ([key, value]) => key.length > 0 && typeof value === "string", + ) + ) { + errors.push({ + server: name, + message: '"headers" values must all be strings with non-empty keys.', + }); } } @@ -312,7 +321,18 @@ function validateOAuthConfig( server: name, message: '"auth.scopes" must be an array of strings.', }); + } else if (obj.scopes.length === 0) { + errors.push({ + server: name, + message: + '"auth.scopes" must contain at least one scope (e.g. ["api://resource/.default"]).', + }); } + } else { + errors.push({ + server: name, + message: '"auth.scopes" is required for OAuth authentication.', + }); } if (obj.redirectUri !== undefined && typeof obj.redirectUri !== "string") { @@ -573,11 +593,26 @@ export function computeMCPConfigHash( if (isMCPHttpConfig(config)) { hash.update("http", "utf8"); hash.update(config.url, "utf8"); + // Include headers keys (not values — could contain secrets) + if (config.headers) { + hash.update(JSON.stringify(Object.keys(config.headers).sort()), "utf8"); + } if (config.auth) { hash.update(config.auth.method, "utf8"); - if ("clientId" in config.auth) { + if (config.auth.method === "oauth") { + hash.update(config.auth.flow, "utf8"); + hash.update(config.auth.clientId, "utf8"); + hash.update(config.auth.tenantId ?? "", "utf8"); + hash.update(JSON.stringify(config.auth.scopes ?? []), "utf8"); + hash.update(config.auth.redirectUri ?? "", "utf8"); + } else if (config.auth.method === "client-credentials") { hash.update(config.auth.clientId, "utf8"); + hash.update(config.auth.tenantId, "utf8"); + // clientSecretEnv name (not the secret itself) + hash.update(config.auth.clientSecretEnv, "utf8"); + hash.update(JSON.stringify(config.auth.scopes ?? []), "utf8"); } + // workload-identity has no config fields to hash } } else { hash.update("stdio", "utf8"); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 2efdb41..4d601fb 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -396,7 +396,12 @@ describe("parseMCPConfig", () => { "remote-mail": { type: "http", url: "https://agent365.svc.cloud.microsoft/mcp", - auth: { method: "oauth", flow: "browser", clientId: "abc" }, + auth: { + method: "oauth", + flow: "browser", + clientId: "abc", + scopes: ["api://.default"], + }, }, }); @@ -662,11 +667,16 @@ describe("createMCPClientManager — HTTP transport", () => { manager.registerServer("oauth-headless", { type: "http", url: "https://localhost:19999/mcp", - auth: { method: "oauth", flow: "browser", clientId: "test-id" }, + auth: { + method: "oauth", + flow: "browser", + clientId: "test-id", + scopes: ["api://.default"], + }, }); await expect(manager.connect("oauth-headless")).rejects.toThrow( - /no cached tokens.*no interactive terminal/i, + /no.*cached tokens.*no interactive terminal/i, ); // Restore @@ -810,6 +820,7 @@ describe("createMsalOAuthProvider", () => { method: "oauth", flow: "browser", clientId: "my-app-id", + scopes: ["api://.default"], }); const info = await provider.clientInformation(); @@ -822,6 +833,7 @@ describe("createMsalOAuthProvider", () => { method: "oauth", flow: "browser", clientId: "test-id", + scopes: ["api://.default"], }); // No accounts in cache → silent acquisition returns undefined. @@ -829,14 +841,14 @@ describe("createMsalOAuthProvider", () => { expect(tokens).toBeUndefined(); }); - it("omits scope when not configured", () => { - const provider = createMsalOAuthProvider("test-msal-noscope", { - method: "oauth", - flow: "device-code", - clientId: "test-id", - }); - - expect(provider.clientMetadata.scope).toBeUndefined(); + it("throws when scopes not configured", () => { + expect(() => + createMsalOAuthProvider("test-msal-noscope", { + method: "oauth", + flow: "device-code", + clientId: "test-id", + }), + ).toThrow(/scopes are required/); }); it("codeVerifier stubs return empty string (MSAL handles PKCE)", () => { @@ -844,6 +856,7 @@ describe("createMsalOAuthProvider", () => { method: "oauth", flow: "browser", clientId: "test-id", + scopes: ["api://.default"], }); provider.saveCodeVerifier("whatever");