Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/FrodoCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
56 changes: 51 additions & 5 deletions src/cli/mcp/server/server-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, number> = {};
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],
Expand All @@ -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,
Expand All @@ -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(', ')}`
);
Expand All @@ -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)`
);
Expand Down
33 changes: 29 additions & 4 deletions src/cli/mcp/server/server-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { Option } from 'commander';
import c from 'tinyrainbow';

import * as s from '../../../help/SampleData';
import {
MCP_LOG_LEVELS,
McpLogger,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 13 additions & 1 deletion src/ops/McpServerMetadata.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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()})`;
31 changes: 28 additions & 3 deletions src/ops/cloud/VariablesOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -30,7 +31,7 @@ const {
getWorkingDirectory,
saveJsonToFile,
} = frodo.utils;
const { resolvePerpetratorUuid } = frodo.idm.managed;
const { resolveIdentity } = frodo.idm.managed;
const {
readVariables,
readVariable,
Expand All @@ -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
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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
}
Expand Down
18 changes: 16 additions & 2 deletions src/utils/Version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = '';
Expand Down
9 changes: 7 additions & 2 deletions test/client_cli/en/mcp-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
Expand Down
8 changes: 8 additions & 0 deletions tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading