Skip to content

Commit 7deaef6

Browse files
authored
feat(mcp): MSAL bootstrap for M365 server discovery (#97)
Add acquireDiscoveryToken() to setup-commands.ts — when no cached token exists for m365-refresh-servers, MSAL acquires one interactively (browser or device-code flow, matching the configured auth flow). Token chain: --token flag → cached MSAL token → interactive MSAL. Spawns a child process for MSAL to avoid importing the library at module level in the CLI entrypoint. Also updates MCP.md with minor corrections. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent 1656c7b commit 7deaef6

2 files changed

Lines changed: 165 additions & 8 deletions

File tree

docs/MCP.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ not start an agent session, and they do not require the repository Justfile.
142142
| `hyperagent --mcp-add-http <name> <url> [clientId] [tenantId] [scopes] [flow]` | Add a generic HTTP MCP server, optionally with OAuth. |
143143
| `hyperagent --mcp-m365-create-app [args...]` | Create/reuse an Entra app registration for Agent 365 HTTP MCP servers. Requires Azure CLI and `az login`. |
144144
| `hyperagent --mcp-setup-m365 [args...]` | Configure Agent 365 per-service HTTP MCP servers and pre-approve them. |
145-
| `hyperagent --mcp-m365-refresh-servers [args...]` | Refresh the user M365 server catalog using a cached or supplied bearer token. |
145+
| `hyperagent --mcp-m365-refresh-servers [args...]` | Refresh the user M365 server catalog using a supplied bearer token, cached MSAL token, or saved M365 app details. |
146146
| `hyperagent --mcp-m365-show` | Show saved M365 app registration details. |
147147

148148
The Justfile recipes with matching names are development conveniences for this
@@ -517,7 +517,8 @@ pre-consented Agent 365 MCP scopes in one shot.
517517
#### Refreshing the server catalog
518518

519519
```bash
520-
hyperagent --mcp-m365-refresh-servers # uses cached OAuth token
520+
hyperagent --mcp-m365-refresh-servers # uses cached token or MSAL browser auth
521+
hyperagent --mcp-m365-refresh-servers --flow device-code # SSH/headless auth
521522
hyperagent --mcp-m365-refresh-servers --token <bearer> # explicit token
522523
```
523524

src/agent/mcp/setup-commands.ts

Lines changed: 162 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// requiring users to download the repository Justfile or helper scripts.
55

66
import { createHash } from "node:crypto";
7+
import { delimiter } from "node:path";
78
import { spawnSync } from "node:child_process";
89
import {
910
existsSync,
@@ -1142,10 +1143,13 @@ function refreshM365Servers(argv: string[], contentRoot: string): void {
11421143
const endpoint = catalog.discoverEndpoint;
11431144
if (!endpoint) fail("M365 catalog is missing discoverEndpoint");
11441145

1145-
const token = args.token ?? loadTokenFromCache();
1146+
const token =
1147+
args.token ??
1148+
loadTokenFromCache() ??
1149+
acquireDiscoveryToken(catalog, args, contentRoot);
11461150
if (!token) {
11471151
fail(
1148-
"No bearer token found. Provide --token <bearer>, or connect any work-iq-* server once to seed ~/.hyperagent/mcp-tokens/.",
1152+
"No bearer token found. Provide --token <bearer>, run hyperagent --mcp-m365-create-app, or connect any work-iq-* server once to seed ~/.hyperagent/mcp-tokens/.",
11491153
);
11501154
}
11511155

@@ -1258,22 +1262,174 @@ process.stdout.write(await response.text());
12581262
}
12591263
}
12601264

1261-
function parseRefreshArgs(argv: string[]): {
1265+
function acquireDiscoveryToken(
1266+
catalog: Catalog,
1267+
args: RefreshArgs,
1268+
contentRoot: string,
1269+
): string | undefined {
1270+
const state = readJson<SavedM365State>(M365_STATE_FILE);
1271+
const clientId = args.clientId ?? state?.clientId;
1272+
const tenantId = args.tenantId ?? state?.tenantId;
1273+
const scope =
1274+
args.scope ?? `${catalog.resourceId ?? AGENT365_RESOURCE_ID}/.default`;
1275+
if (!clientId) return undefined;
1276+
1277+
logStep(`No cached token found; acquiring one with MSAL (${args.flow})`);
1278+
const result = spawnSync(
1279+
process.execPath,
1280+
[
1281+
"--input-type=module",
1282+
"-e",
1283+
`
1284+
import { createRequire } from "node:module";
1285+
import { execFile } from "node:child_process";
1286+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1287+
import { homedir } from "node:os";
1288+
import { join } from "node:path";
1289+
1290+
const require = createRequire(import.meta.url);
1291+
const msal = require("@azure/msal-node");
1292+
const cacheDir = join(homedir(), ".hyperagent", "mcp-tokens");
1293+
const cacheFile = join(cacheDir, "m365-discovery.msal.json");
1294+
const clientId = process.env.HYPERAGENT_M365_CLIENT_ID;
1295+
const tenantId = process.env.HYPERAGENT_M365_TENANT_ID;
1296+
const scope = process.env.HYPERAGENT_M365_SCOPE;
1297+
const flow = process.env.HYPERAGENT_M365_FLOW;
1298+
if (!clientId || !scope || !flow) process.exit(2);
1299+
1300+
function openBrowser(url) {
1301+
console.error("[mcp] If the browser doesn't open, visit:");
1302+
console.error("[mcp] " + url);
1303+
const [bin, args] = process.platform === "darwin"
1304+
? ["open", [url]]
1305+
: process.platform === "win32"
1306+
? ["cmd.exe", ["/c", "start", "", url]]
1307+
: ["xdg-open", [url]];
1308+
execFile(bin, args, { timeout: 10000 }, () => {});
1309+
}
1310+
1311+
const cachePlugin = {
1312+
async beforeCacheAccess(ctx) {
1313+
if (existsSync(cacheFile)) ctx.tokenCache.deserialize(readFileSync(cacheFile, "utf8"));
1314+
},
1315+
async afterCacheAccess(ctx) {
1316+
if (ctx.cacheHasChanged) {
1317+
mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
1318+
writeFileSync(cacheFile, ctx.tokenCache.serialize(), { mode: 0o600 });
1319+
}
1320+
},
1321+
};
1322+
1323+
const pca = new msal.PublicClientApplication({
1324+
auth: {
1325+
clientId,
1326+
authority: tenantId
1327+
? \`https://login.microsoftonline.com/\${tenantId}\`
1328+
: "https://login.microsoftonline.com/organizations",
1329+
},
1330+
cache: { cachePlugin },
1331+
system: { loggerOptions: { logLevel: msal.LogLevel.Warning } },
1332+
});
1333+
1334+
const scopes = [scope, "offline_access"];
1335+
const accounts = await pca.getAllAccounts();
1336+
let token = null;
1337+
if (accounts.length > 0) {
1338+
try {
1339+
token = await pca.acquireTokenSilent({ account: accounts[0], scopes });
1340+
} catch {}
1341+
}
1342+
if (!token && flow === "device-code") {
1343+
token = await pca.acquireTokenByDeviceCode({
1344+
scopes,
1345+
deviceCodeCallback: (response) => {
1346+
console.error("");
1347+
console.error("[mcp] 🔐 Device code authentication required");
1348+
console.error("[mcp] " + response.message);
1349+
console.error("");
1350+
},
1351+
});
1352+
}
1353+
if (!token && flow === "browser") {
1354+
console.error("[mcp] 🔐 Opening browser for authentication...");
1355+
token = await pca.acquireTokenInteractive({
1356+
scopes,
1357+
openBrowser: async (url) => openBrowser(url),
1358+
successTemplate: "<html><body><h1>Authentication Successful</h1><p>You can close this window and return to HyperAgent.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>",
1359+
errorTemplate: "<html><body><h1>Authentication Failed</h1><p>Check the terminal for details.</p></body></html>",
1360+
});
1361+
}
1362+
if (!token?.accessToken) throw new Error("MSAL did not return an access token");
1363+
process.stdout.write(token.accessToken);
1364+
`,
1365+
],
1366+
{
1367+
encoding: "utf8",
1368+
env: {
1369+
...process.env,
1370+
NODE_PATH: nodePathForContentRoot(contentRoot),
1371+
HYPERAGENT_M365_CLIENT_ID: clientId,
1372+
HYPERAGENT_M365_TENANT_ID: tenantId ?? "",
1373+
HYPERAGENT_M365_SCOPE: scope,
1374+
HYPERAGENT_M365_FLOW: args.flow,
1375+
},
1376+
maxBuffer: 10 * 1024 * 1024,
1377+
stdio: ["ignore", "pipe", "inherit"],
1378+
},
1379+
);
1380+
1381+
if (result.error)
1382+
fail(`MSAL token acquisition failed: ${result.error.message}`);
1383+
if (result.status !== 0) fail("MSAL token acquisition failed");
1384+
const token = result.stdout.trim();
1385+
if (token) logSuccess("Acquired discovery token with MSAL");
1386+
return token || undefined;
1387+
}
1388+
1389+
function nodePathForContentRoot(contentRoot: string): string {
1390+
const entries = [
1391+
join(contentRoot, "node_modules"),
1392+
join(contentRoot, "..", "..", "node_modules"),
1393+
process.env.NODE_PATH ?? "",
1394+
].filter(Boolean);
1395+
return entries.join(delimiter);
1396+
}
1397+
1398+
interface RefreshArgs {
12621399
token?: string;
1400+
clientId?: string;
1401+
tenantId?: string;
1402+
scope?: string;
1403+
flow: "browser" | "device-code";
12631404
includeCustom: boolean;
1264-
} {
1265-
const parsed: { token?: string; includeCustom: boolean } = {
1405+
}
1406+
1407+
function parseRefreshArgs(argv: string[]): RefreshArgs {
1408+
const parsed: RefreshArgs = {
1409+
flow: "browser",
12661410
includeCustom: false,
12671411
};
12681412
for (let index = 0; index < argv.length; index++) {
12691413
const arg = argv[index];
12701414
if (arg === "--token" && index + 1 < argv.length) {
12711415
parsed.token = argv[++index];
1416+
} else if (arg === "--client-id" && index + 1 < argv.length) {
1417+
parsed.clientId = argv[++index];
1418+
} else if (arg === "--tenant-id" && index + 1 < argv.length) {
1419+
parsed.tenantId = argv[++index];
1420+
} else if (arg === "--scope" && index + 1 < argv.length) {
1421+
parsed.scope = argv[++index];
1422+
} else if (arg === "--flow" && index + 1 < argv.length) {
1423+
const flow = argv[++index];
1424+
if (flow !== "browser" && flow !== "device-code") {
1425+
fail(`--flow must be "browser" or "device-code" (got: ${flow})`);
1426+
}
1427+
parsed.flow = flow;
12721428
} else if (arg === "--include-custom") {
12731429
parsed.includeCustom = true;
12741430
} else if (arg === "--help" || arg === "-h") {
12751431
console.log(
1276-
"Usage: hyperagent --mcp-m365-refresh-servers [--token <bearer>] [--include-custom]",
1432+
"Usage: hyperagent --mcp-m365-refresh-servers [--token <bearer>] [--client-id ID] [--tenant-id ID] [--scope SCOPE] [--flow browser|device-code] [--include-custom]",
12771433
);
12781434
process.exit(0);
12791435
} else {

0 commit comments

Comments
 (0)