diff --git a/package-lock.json b/package-lock.json index 76495fc88..6adfd4c1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", - "@rockcarver/frodo-lib": "4.3.1", + "@rockcarver/frodo-lib": "4.3.2", "@types/colors": "^1.2.1", "@types/fs-extra": "^11.0.1", "@types/jest": "^29.2.3", @@ -1908,9 +1908,9 @@ } }, "node_modules/@rockcarver/frodo-lib": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@rockcarver/frodo-lib/-/frodo-lib-4.3.1.tgz", - "integrity": "sha512-CRPaQgJExqHJ8cBgfj3BbGoggPgq0uQwUmITnHingMlu7PqGnYf3qrFaHRlRoulXwOxpgIPGIKzr9dH7pC7hqg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@rockcarver/frodo-lib/-/frodo-lib-4.3.2.tgz", + "integrity": "sha512-lcmmakt7rQnr823fwj27STQPsOy2o/AR9BGiWm/nP7hyUKSpkjtyigV7ECZeJuRwgcpxsko1Gvs6mYKkp+y4zA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 0795a910d..3bc4f59d7 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", - "@rockcarver/frodo-lib": "4.3.1", + "@rockcarver/frodo-lib": "4.3.2", "@types/colors": "^1.2.1", "@types/fs-extra": "^11.0.1", "@types/jest": "^29.2.3", diff --git a/src/cli/FrodoCommand.ts b/src/cli/FrodoCommand.ts index 08990b58f..b72d67b45 100644 --- a/src/cli/FrodoCommand.ts +++ b/src/cli/FrodoCommand.ts @@ -443,7 +443,7 @@ const realmArgument = new Argument( const usernameArgument = new Argument( '[username]', - 'Username to login with. Must be an admin user with appropriate rights to manage authentication journeys/trees.' + "Username to login with. Must be an admin user with appropriate rights to manage authentication journeys/trees. If given without a password, and it matches the username already stored in the connection profile for the target host, frodo uses that profile's stored password instead of requiring it on the command line." ); const passwordArgument = new Argument('[password]', 'Password.'); diff --git a/src/cli/mcp/server/server-info.ts b/src/cli/mcp/server/server-info.ts index 1f8a1d0b8..0c1ae954b 100644 --- a/src/cli/mcp/server/server-info.ts +++ b/src/cli/mcp/server/server-info.ts @@ -9,11 +9,13 @@ import { import { Option } from 'commander'; import c from 'tinyrainbow'; +import packageJson from '../../../../package.json'; import { MCP_SERVER_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, } from '../../../ops/McpServerMetadata.js'; import { printMessage } from '../../../utils/Console'; +import { getCliBuildTimestamp } from '../../../utils/Version.js'; import { FrodoStubCommand } from '../../FrodoCommand'; import { type McpPolicyPreset, resolvePolicySelection } from './server-policy'; @@ -129,15 +131,35 @@ export default function setup() { policyOverride: policySelection.policyOverride, inventoryOptions, }); - const inventoryCapabilityCount = buildCapabilityInventory( - frodo, - inventoryOptions - ).length; + const inventory = buildCapabilityInventory(frodo, inventoryOptions); + const inventoryCapabilityCount = inventory.length; + const specialInInventory = inventory.filter((c) => c.kind === 'special'); + const specialActive = service.capabilities.filter( + (c) => c.kind === 'special' + ); + const activeByRiskClass: Record = {}; + for (const capability of specialActive) { + activeByRiskClass[capability.riskClass] = + (activeByRiskClass[capability.riskClass] ?? 0) + 1; + } const info = { server: { name: 'Frodo MCP Server', + // MCP_SERVER_VERSION already carries the cli build timestamp (the + // format handshake/introspecting MCP clients see); cli/lib below + // spell out both build timestamps in the same `frodo -v` format — + // verify a running process actually reflects a given source change + // without needing shell access to the host it's running on. version: MCP_SERVER_VERSION, + cli: { + version: packageJson.version, + buildTimestamp: getCliBuildTimestamp(), + }, + lib: { + version: frodo.utils.version.getVersion(), + buildTimestamp: frodo.utils.version.getBuildTimestamp(), + }, }, protocol: { supportedVersions: [...MCP_SUPPORTED_PROTOCOL_VERSIONS], @@ -148,6 +170,15 @@ export default function setup() { skillCounts: { inventory: inventoryCapabilityCount, active: service.capabilities.length, + special: { + // 'special' capabilities (non-CRUD, e.g. tail/evaluateScript/getTokens) + // are governed by includeSpecial rather than allowOperationTypes/ + // denyOperationTypes — surfaced explicitly here since that gate is easy + // to get wrong silently. See CapabilityPolicy.ts. + inventory: specialInInventory.length, + active: specialActive.length, + activeByRiskClass, + }, }, toolCounts: { total: service.manifest.totalToolCount, @@ -170,7 +201,13 @@ export default function setup() { } printMessage('MCP server info:', 'info'); - printMessage(` ${info.server.name} v${info.server.version}`); + printMessage(` ${info.server.name}`); + printMessage( + ` cli: v${info.server.cli.version} (${info.server.cli.buildTimestamp})` + ); + printMessage( + ` lib: v${info.server.lib.version} (${info.server.lib.buildTimestamp})` + ); printMessage( ` Supported protocol versions: ${info.protocol.supportedVersions.join(', ')}` ); @@ -179,6 +216,15 @@ export default function setup() { printMessage( ` Active skills: ${info.service.skillCounts.active} (total: ${info.service.skillCounts.inventory})` ); + const special = info.service.skillCounts.special; + const riskBreakdown = Object.entries(special.activeByRiskClass) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([riskClass, count]) => `${count} ${riskClass}`) + .join(', '); + printMessage( + ` Active special-kind skills: ${special.active} (available: ${special.inventory})` + + (riskBreakdown ? ` — by risk: ${riskBreakdown}` : '') + ); printMessage( ` Active tools: ${info.service.toolCounts.total} (${info.service.toolCounts.canonical} canonical, ${info.service.toolCounts.discovery} discovery)` ); diff --git a/src/cli/mcp/server/server-start.ts b/src/cli/mcp/server/server-start.ts index febe18833..e3ea862ae 100644 --- a/src/cli/mcp/server/server-start.ts +++ b/src/cli/mcp/server/server-start.ts @@ -8,6 +8,7 @@ import { import { Option } from 'commander'; import c from 'tinyrainbow'; +import * as s from '../../../help/SampleData'; import { MCP_LOG_LEVELS, McpLogger, @@ -62,7 +63,14 @@ type McpStartOptions = { * MCP server start command. */ export default function setup() { - const program = new FrodoCommand('frodo mcp server start', []) + // 'no-cache'/'flush-cache': the token cache is hard-coded off for this + // command (see below) — showing these flags in --help would suggest a + // choice that doesn't actually exist here. + const program = new FrodoCommand('frodo mcp server start', [ + 'realm', + 'no-cache', + 'flush-cache', + ]) .description('Start an MCP server session from frodo-lib skills.') .withStability('experimental') .suppressStabilityWarning() @@ -154,17 +162,34 @@ export default function setup() { ` $ frodo mcp server start --policy read-only --profile authentication\n` ) + ` Start with selected domains only:\n` + - c.cyanBright(` $ frodo mcp server start --include-domains authn idm\n`) + c.cyanBright( + ` $ frodo mcp server start --include-domains authn idm\n` + ) + + ` Start authenticated as a username whose password is already saved in a connection profile for this host (no password on the command line):\n` + + c.cyanBright( + ` $ frodo mcp server start ${s.amBaseUrl} ${s.username}\n` + ) ) - .action(async (host, realm, username, password, options, command) => { + .action(async (host, username, password, options, command) => { command.handleDefaultArgsAndOpts( host, - realm, username, password, options, command ); + // The token cache exists to let successive short-lived CLI invocations + // reuse tokens instead of re-authenticating every time — not relevant + // to a long-running MCP server, which logs in once and relies on + // frodo-lib's own auto-refresh for the rest of its lifetime. Worse, + // it's actively unsafe here: multiple `mcp server start` processes + // (one per policy/profile) commonly run concurrently against the same + // host, all reading and writing the same on-disk token cache file — + // a real corruption/collision risk this command should never + // participate in. Hard-coded off, not exposed as a configurable + // default, until that on-disk cache is made safe for concurrent + // writers (tracked separately). + state.setUseTokenCache(false); const opts = options as McpStartOptions; if (opts.json && !opts.dryRun) { diff --git a/src/ops/McpServerMetadata.ts b/src/ops/McpServerMetadata.ts index b72f22225..67241e910 100644 --- a/src/ops/McpServerMetadata.ts +++ b/src/ops/McpServerMetadata.ts @@ -1,6 +1,7 @@ import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; import packageJson from '../../package.json'; +import { getCliBuildTimestamp } from '../utils/Version'; const MCP_LATEST_PROTOCOL_VERSION = '2026-07-28'; @@ -15,4 +16,15 @@ export const MCP_SUPPORTED_PROTOCOL_VERSIONS = [ ), ]; -export const MCP_SERVER_VERSION = packageJson.version; +/** + * Reported to every MCP client at protocol handshake ({name, version} — see + * McpServerOps.ts) and by `frodo mcp server info`. Carries the CLI build + * timestamp in parentheses, matching `frodo -v`'s `cli: vX (timestamp)` + * format, so a client (or an agent debugging a "why doesn't this behave + * like the source I just changed" problem) can verify which build is + * actually running without needing shell access to grep or introspect the + * binary — standard MCP protocol introspection is enough. The lib build + * timestamp — a dependency's build, not this server's own — is available + * via `frodo mcp server info` rather than crammed into this single field. + */ +export const MCP_SERVER_VERSION = `${packageJson.version} (${getCliBuildTimestamp()})`; diff --git a/src/ops/cloud/VariablesOps.ts b/src/ops/cloud/VariablesOps.ts index 389066a32..6d9f0444f 100644 --- a/src/ops/cloud/VariablesOps.ts +++ b/src/ops/cloud/VariablesOps.ts @@ -4,6 +4,7 @@ import { VariableSkeleton, } from '@rockcarver/frodo-lib/types/api/cloud/VariablesApi'; import { VariablesExportInterface } from '@rockcarver/frodo-lib/types/ops/cloud/VariablesOps'; +import { ResolvedIdentity } from '@rockcarver/frodo-lib/types/ops/ManagedObjectOps'; import fs from 'fs'; import c from 'tinyrainbow'; @@ -30,7 +31,7 @@ const { getWorkingDirectory, saveJsonToFile, } = frodo.utils; -const { resolvePerpetratorUuid } = frodo.idm.managed; +const { resolveIdentity } = frodo.idm.managed; const { readVariables, readVariable, @@ -43,6 +44,26 @@ const { importVariables, } = frodo.cloud.variable; +/** + * Formats a resolved identity the way callers of the old + * resolvePerpetratorUuid string helper expect: a human-readable label, or the + * raw id unchanged when nothing could be resolved. + */ +function formatResolvedIdentity(identity: ResolvedIdentity): string { + switch (identity.kind) { + case 'admin': + return `Admin user: ${identity.displayName} (${identity.username})`; + case 'service': + return `Service account: ${identity.username} (${identity.displayName})`; + case 'user': + return `${identity.realm} user: ${identity.displayName} (${identity.username})`; + case 'admin-unconfirmed': + return `Tenant admin (unconfirmed): ${identity.id}`; + default: + return identity.id; + } +} + /** * List variables * @param {boolean} long Long version, all the fields besides usage @@ -119,7 +140,9 @@ export async function listVariables( wordwrap(variable.description, 40), state.getUseBearerTokenForAmApis() ? variable.lastChangedBy - : await resolvePerpetratorUuid(variable.lastChangedBy), + : formatResolvedIdentity( + await resolveIdentity(variable.lastChangedBy) + ), new Date(variable.lastChangeDate).toUTCString(), ] : [variable._id]; @@ -369,7 +392,9 @@ export async function describeVariable( ]); let modifierName: string; try { - modifierName = await resolvePerpetratorUuid(variable.lastChangedBy); + modifierName = formatResolvedIdentity( + await resolveIdentity(variable.lastChangedBy) + ); } catch { // ignore } diff --git a/src/utils/Version.ts b/src/utils/Version.ts index 49db5147c..12fb36bf7 100644 --- a/src/utils/Version.ts +++ b/src/utils/Version.ts @@ -7,7 +7,21 @@ import path from 'path'; import pkg from '../../package.json'; -const { getVersion, getAllVersions } = frodo.utils.version; +const { getVersion, getBuildTimestamp, getAllVersions } = frodo.utils.version; + +declare const __CLI_BUILD_TIMESTAMP__: string; + +/** + * ISO 8601 timestamp of when this frodo-cli bundle was built, substituted as + * a literal by tsup's `define` at bundle time (see tsup.config.ts). Falls + * back to an explicit placeholder when running from raw TypeScript source + * rather than a tsup build, since no bundler ever substitutes the + * identifier there. + */ +export const getCliBuildTimestamp = (): string => + typeof __CLI_BUILD_TIMESTAMP__ !== 'undefined' + ? __CLI_BUILD_TIMESTAMP__ + : 'unknown (running from source, not a tsup build)'; const VERSION_CACHE_FILE = `${os.homedir()}/.frodo/Versions.json`; const VERSION_CHECK_INTERVAL = 86400; @@ -145,7 +159,7 @@ export async function getVersions(checkOnly: boolean) { : 'NPM package' }.`; - versionString += `\ncli: v${getCliVersion()}\nlib: v${getLibVersion()}\nnode: ${ + versionString += `\ncli: v${getCliVersion()} (${getCliBuildTimestamp()})\nlib: v${getLibVersion()} (${getBuildTimestamp()})\nnode: ${ process.version }`; let newVersionString = ''; diff --git a/test/client_cli/en/mcp-server.test.js b/test/client_cli/en/mcp-server.test.js index 0feb77623..acf214c87 100644 --- a/test/client_cli/en/mcp-server.test.js +++ b/test/client_cli/en/mcp-server.test.js @@ -80,9 +80,14 @@ afterAll(() => { test("'mcp server info' prints the active server summary", async () => { const stdout = await runMcpCommand('info', '--policy', 'admin'); - expect(stdout).toContain( - `MCP server info:\n Frodo MCP Server v${cliVersion}\n` + expect(stdout).toContain('MCP server info:\n Frodo MCP Server\n'); + // Build timestamps are real and change every build, so these check the + // stable `cli: vX (` / `lib: vY (` prefix plus a real ISO 8601 value + // rather than an exact match. + expect(stdout).toMatch( + new RegExp(` {2}cli: v${cliVersion} \\(\\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z\\)\\n`) ); + expect(stdout).toMatch(/ {2}lib: v[\d.]+ \(\d{4}-\d{2}-\d{2}T[\d:.]+Z\)\n/); expect(stdout).toContain( ' Supported protocol versions: 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07\n' ); diff --git a/tsup.config.ts b/tsup.config.ts index c66cf7f11..ebce29001 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -9,6 +9,14 @@ export default defineConfig({ clean: true, bundle: true, shims: true, // this will properly transpile 'import.meta.url' + // Injected as a literal at bundle time so a running process's build can + // be verified directly, rather than trusting file mtimes or a packaging + // step (tsup here, but especially the later pkg binary-packaging step) + // that can silently produce a stale artifact despite every file on disk + // looking current. See getCliBuildTimestamp in src/utils/Version.ts. + define: { + __CLI_BUILD_TIMESTAMP__: JSON.stringify(new Date().toISOString()), + }, external: [ // list all the dev dependencies, which do NOT need to be bundled. '@types/colors',