diff --git a/Justfile b/Justfile index 897ac01..539c818 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 @@ -712,7 +740,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 +820,92 @@ 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 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'. +# 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 +# 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 \ +# "" 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 ─────────────────────── +# +# 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). +# 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 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), 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 +# (default: each server uses its catalogued scope) +# 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 +# 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..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 @@ -373,6 +411,139 @@ 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 connect to the +per-service Agent 365 HTTP endpoints directly. This gives you finer +`/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 +# 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. + +#### 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 +``` + +#### 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. + +#### Refreshing the server catalog + +```bash +just mcp-m365-refresh-servers # uses cached OAuth token +just mcp-m365-refresh-servers --token # explicit token +``` + +## HTTP Transport & OAuth + +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: + +```json +{ + "mcpServers": { + "my-remote": { + "type": "http", + "url": "https://example.com/mcp", + "auth": { + "method": "oauth", + "flow": "browser", + "clientId": "", + "tenantId": "", + "scopes": ["api://example/.default"], + "redirectUri": "http://localhost" + } + } + } +} +``` + +### 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 + +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. + --- ## Debugging @@ -433,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) β”‚ @@ -444,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/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/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, 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..f5e4755 --- /dev/null +++ b/scripts/m365-refresh-servers.ts @@ -0,0 +1,231 @@ +// ── 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 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(".msal.json"), + ); + if (files.length === 0) return 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 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 + } + } + 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..8d8a17a --- /dev/null +++ b/scripts/m365-setup.ts @@ -0,0 +1,421 @@ +#!/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), 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 +// 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"; +import { fileURLToPath } from "node:url"; + +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; + appName?: string; +} + +interface OAuthAuth { + method: "oauth"; + flow: "browser" | "device-code"; + clientId: string; + scopes: string[]; + tenantId?: string; +} + +interface HttpServerEntry { + type: "http"; + url: string; + auth?: OAuthAuth; +} + +interface HyperAgentConfig { + mcpServers?: Record; + [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, + 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"); +} + +/** + * 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; + flow: string; + tenantId: string; + scopes: 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, + srv.flow, + srv.tenantId, + srv.scopes, + ), + approvedAt: new Date().toISOString(), + approvedTools: [], + 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 { + 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}`); + } +} + +/** + * 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, + url: string, + clientId: string, + tenantId: string, + scope: string, + flow: "browser" | "device-code", +): 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", + flow, + clientId, + scopes: [scope], + ...(tenantId ? { tenantId } : {}), + }, + }; + + writeFileSync(configFile, JSON.stringify(cfg, null, 2) + "\n"); + console.log(`βœ… Wrote mcpServers.${name} β†’ ${url} (oauth/${flow})`); +} + +// ── Main ───────────────────────────────────────────────────────────── + +function main(): void { + const [ + servicesArg = "all", + clientIdArg = "", + tenantIdArg = "", + scopeOverride = "", + flowArg = "", + ] = 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(); + + // `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(); + 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 (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 + : 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 from args βŠ• state file. + let clientId = clientIdArg; + let tenantId = tenantIdArg; + + 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 || ""; + 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(`β–Έ services: ${servicesArg}`); + console.log(`β–Έ flow: ${flow}`); + if (scopeOverride) { + console.log(`β–Έ scope (override): ${scopeOverride}`); + } + 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; + 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; + 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, + tenantedUrl, + clientId, + tenantId, + scope, + flow, + ); + configured.push({ + name: ALIAS_PREFIX + s, + url: tenantedUrl, + clientId, + flow, + tenantId, + scopes: [scope], + }); + 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) (pre-approved)`); + console.log(""); + console.log(" Next:"); + console.log(" just start"); + console.log(' Ask: "What\'s happening in Teams?"'); + console.log(""); + console.log( + flow === "device-code" + ? " 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/"); +} + +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..ef7ce65 --- /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] [flow] +// +// All args after are optional. If clientId is provided, an OAuth +// 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 NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +interface OAuthAuth { + method: "oauth"; + flow: "browser" | "device-code"; + clientId: string; + 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, flowArg] = + process.argv.slice(2); + + if (!name || !url) { + fail( + "Usage: tsx scripts/mcp-add-http.ts " + + "[clientId] [tenantId] [scopes] [flow]", + ); + } + + 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 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) { + 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(",") + .map((s) => s.trim()) + .filter(Boolean) + : [`${parsedUrl.origin}/.default`]; + entry.auth = { + method: "oauth", + flow: flowArg as "browser" | "device-code", + clientId, + 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/${flowArg})` : ""; + 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/skills/mcp-services/SKILL.md b/skills/mcp-services/SKILL.md new file mode 100644 index 0000000..f9a219b --- /dev/null +++ b/skills/mcp-services/SKILL.md @@ -0,0 +1,118 @@ +--- +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 +- **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 + +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` 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. + +### 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/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/index.ts b/src/agent/index.ts index f6fa5b5..5504fd0 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, @@ -706,8 +707,41 @@ 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 { + isMCPHttpConfig, + isMCPStdioConfig, + mcpConfigDisplayString, +} from "./mcp/types.js"; import { createMCPClientManager, type MCPClientManager, @@ -715,7 +749,15 @@ import { import { createMCPPluginAdapter, generateMCPDeclarations, + type WriteSafetyGate, } from "./mcp/plugin-adapter.js"; +import { + loadMCPApprovalStore, + isMCPApproved, + 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; @@ -764,6 +806,77 @@ 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 + * - 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, + args, + annotations, +) => { + // 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 + .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(); + 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 { const enabled = pluginManager.getEnabledPlugins(); @@ -854,7 +967,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); } } @@ -3359,20 +3476,26 @@ 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, - 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 +3550,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, @@ -3471,6 +3594,7 @@ const manageMCPTool = defineTool("manage_mcp", { action: "connect" | "disconnect"; name: string; }) => { + const rl = state.readlineInstance; const err = requireMCPEnabled(); if (err) return { error: err }; @@ -3494,12 +3618,120 @@ 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 { + // 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); + + // 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 @@ -4249,6 +4481,7 @@ function buildSessionConfig() { scratchMb: memory.scratchMb, inputKb: buffers.inputKb, outputKb: buffers.outputKb, + mcpConfigured: mcpManager !== null, }); const pluginAdditions = pluginManager.getSystemMessageAdditions(); @@ -5108,6 +5341,44 @@ 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. + // + // 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)`); + } + } + // ── REPL Loop ──────────────────────────────────────────────── // // Slash commands are intercepted before reaching the agent. diff --git a/src/agent/mcp/auth/msal-oauth.ts b/src/agent/mcp/auth/msal-oauth.ts new file mode 100644 index 0000000..264dc8e --- /dev/null +++ b/src/agent/mcp/auth/msal-oauth.ts @@ -0,0 +1,405 @@ +// ── 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[] { + 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; +} + +// ── 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). + * + * 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 β€” URL is printed above for manual use + }); +} + +// ── 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. + * + * 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/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..6967242 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 { + acquireMsalToken, + createMsalOAuthProvider, + canAcquireSilently, +} from "./auth/msal-oauth.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 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. */ async function connect(name: string): Promise { @@ -73,73 +93,192 @@ 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 via MSAL + if ( + isMCPHttpConfig(conn.config) && + conn.config.auth?.method === "oauth" + ) { + return await connectWithMsal(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 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 connectWithMsal( + name: string, + conn: MCPConnection, + ): Promise { + const httpConfig = conn.config as MCPHttpServerConfig; + const authConfig = httpConfig.auth!; + + if (authConfig.method !== "oauth") { + throw new Error(`[mcp] connectWithMsal called with non-oauth method`); + } + + const isInteractive = process.stdin.isTTY === true; + if (!isInteractive) { + // 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 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}`, + ); + } + } else { + // Interactive: acquire token eagerly (silent β†’ browser/device-code). + await acquireMsalToken(name, authConfig); + console.error(`[mcp] βœ… Authentication successful.`); + } + + // 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 = {}; + 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 } : {}), + }); + + return await connectWithTransport(name, conn, transport); + } + + /** + * 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) ?? {}, + // 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; + 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 +342,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 +419,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 connectWithMsal(). + */ +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 +509,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..0a2842e 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,246 @@ 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.', + }); + } 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.', + }); + } + } + + // 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.', + }); + } 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") { + 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; +} + +/** + * 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 +426,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 +443,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 +492,74 @@ 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", + 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.redirectUri + ? { redirectUri: (raw.redirectUri as string).trim() } + : {}), + }; + + 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 +579,51 @@ 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"); + // 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 (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"); + 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/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/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..60a80aa 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,153 @@ export interface MCPServerConfig { * Supports `${ENV_VAR}` substitution from the host environment. */ env?: Record; +} + +// ── HTTP transport ─────────────────────────────────────────────────── + +/** Supported authentication methods for HTTP MCP servers. */ +export type MCPAuthMethod = + | "oauth" + | "workload-identity" + | "client-credentials"; + +/** + * 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. If omitted, defaults to "organizations". */ + tenantId?: string; + + /** Requested OAuth scopes (e.g. ["Mail.Read"]). */ + scopes?: string[]; /** - * Allowlist of tool names to expose. If set, only these tools are - * available in the sandbox. Takes precedence over denyTools. + * 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. */ - allowTools?: string[]; + redirectUri?: string; +} + +/** + * 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). */ @@ -40,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). */ @@ -53,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. */ @@ -113,7 +290,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; diff --git a/src/agent/slash-commands.ts b/src/agent/slash-commands.ts index fa71f4e..66f03d2 100644 --- a/src/agent/slash-commands.ts +++ b/src/agent/slash-commands.ts @@ -36,6 +36,12 @@ import { auditMCPTools, } from "./mcp/approval.js"; import { maskEnvValue } from "./mcp/sanitise.js"; +import { canAcquireSilently } from "./mcp/auth/msal-oauth.js"; +import { + isMCPHttpConfig, + isMCPStdioConfig, + mcpConfigDisplayString, +} from "./mcp/types.js"; import { createMCPPluginAdapter, generateMCPDeclarations, @@ -1082,10 +1088,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; } @@ -2320,9 +2322,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(); @@ -2353,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); @@ -2371,10 +2396,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 +2503,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/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); } 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..4d601fb 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", () => { @@ -79,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); @@ -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,285 @@ 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", + flow: "browser", + clientId: "18f4deab-76fc-406d-b9d8-3cc0377fa30d", + tenantId: "9c23c1e3-15be-4744-a3d7-027089c33654", + scopes: ["Mail.Read"], + }, + }, + }); + + 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"]); + } + }); + + 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", flow: "browser" }, + }, + }); + expect(errors.some((e) => e.message.includes("auth.clientId"))).toBe(true); + }); + + it("rejects OAuth auth missing flow", () => { + const { errors } = parseMCPConfig({ + "no-flow": { + type: "http", + url: "https://example.com/mcp", + 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("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", () => { + 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", + flow: "browser", + clientId: "abc", + scopes: ["api://.default"], + }, + }, + }); + + 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 +479,389 @@ 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, + flow: "browser" 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, + flow: "browser" as const, + clientId: "abc", + }, + }); + const hash2 = computeMCPConfigHash("test", { + type: "http" as const, + url: "https://example.com/mcp", + auth: { + method: "oauth" as const, + flow: "browser" 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", + flow: "browser", + clientId: "test-id", + scopes: ["api://.default"], + }, + }); + + 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"); + }); +}); + +// ── MSAL OAuth provider ────────────────────────────────────────────── + +import { createMsalOAuthProvider } from "../src/agent/mcp/auth/msal-oauth.js"; +import { afterEach } from "vitest"; + +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"); + expect(metadata.grant_types).toContain("authorization_code"); + expect(metadata.grant_types).toContain("refresh_token"); + expect(metadata.scope).toBe("Mail.Read Calendar.Read"); + + // 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 = createMsalOAuthProvider("test-msal-info", { + method: "oauth", + flow: "browser", + clientId: "my-app-id", + scopes: ["api://.default"], + }); + + const info = await provider.clientInformation(); + expect(info).toBeDefined(); + expect(info!.client_id).toBe("my-app-id"); + }); + + 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", + scopes: ["api://.default"], + }); + + // No accounts in cache β†’ silent acquisition returns undefined. + const tokens = await provider.tokens(); + expect(tokens).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)", () => { + const provider = createMsalOAuthProvider("test-msal-pkce", { + method: "oauth", + flow: "browser", + clientId: "test-id", + scopes: ["api://.default"], + }); + + provider.saveCodeVerifier("whatever"); + expect(provider.codeVerifier()).toBe(""); + }); }); // ── Sanitisation ─────────────────────────────────────────────────────