From 3d601ff3c5f0179e306e7604ff9c3c3b4a399f3e Mon Sep 17 00:00:00 2001 From: modesty Date: Sun, 31 Aug 2025 11:11:32 -0700 Subject: [PATCH 1/2] doc: update readme and indentation --- README.md | 8 +-- src/server/fluentInstanceAuth.ts | 110 +++++++++++++++---------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 17f3b43..e1961cf 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # MCP Server for Fluent (ServiceNow SDK) -A stdio MCP Server for [Fluent (ServiceNow SDK)](https://www.servicenow.com/docs/bundle/yokohama-application-development/page/build/servicenow-sdk/concept/servicenow-fluent.html), a TypeScript-based declarative domain-specific language for creating and managing metadata, modules, records and tests in ServiceNow platform. It supports all commands available in the ServiceNow SDK CLI and provides access to Fluent Plugin's API specifications, code snippets, and instructions for various metadata types. It can be configured for any MCP client, such as VSCode Agent mode, Claude Desktop, Cursor, or Windsurf, for either development or learning purposes. +MCP Server for [Fluent (ServiceNow SDK)](https://www.servicenow.com/docs/bundle/yokohama-application-development/page/build/servicenow-sdk/concept/servicenow-fluent.html), a TypeScript-based declarative domain-specific language for creating and managing metadata, modules, records and tests in ServiceNow platform. It supports all commands available in the ServiceNow SDK CLI and provides access to Fluent Plugin's API specifications, code snippets, and instructions for various metadata types. It can be configured for any MCP client with stdio, such as VSCode Agent mode, Claude Code, Cursor, or Windsurf, for either development or learning purposes. ## Overview -Fluent (ServiceNow SDK) MCP bridges ServiceNow development tools with modern AI-assisted development environments by implementing the [Model Context Protocol](https://github.com/modelcontextprotocol). It enables developers and AI assistants to interact with Fluent commands and access resources like API specifications, code snippets, and instructions through natural language. +Fluent (ServiceNow SDK) MCP bridges development tools with AI-assisted development environments by implementing the [Model Context Protocol](https://github.com/modelcontextprotocol). It enables developers and AI Agents to interact with Fluent commands and access resources like API specifications, code snippets, and instructions through natural language. Key capabilities include: - All ServiceNow SDK CLI commands: `version`, `help`, `auth`, `init`, `build`, `install`, `upgrade`, `dependencies`, `transform` -- ServiceNow instance authentication via `npx now-sdk auth --add ` -- API specifications for metadata types like `acl`, `business-rule`, `client-script`, `table`, `ui-action` and more +- ServiceNow instance `basic` or `oauth` authentication (optional, only needed for CLI commands, not for resources) +- Resource capability of API specifications for metadata types like `acl`, `business-rule`, `client-script`, `table`, `ui-action` and more - Code snippets and examples for different metadata types - Instructions for creating and modifying metadata types diff --git a/src/server/fluentInstanceAuth.ts b/src/server/fluentInstanceAuth.ts index f619229..74071e8 100644 --- a/src/server/fluentInstanceAuth.ts +++ b/src/server/fluentInstanceAuth.ts @@ -12,68 +12,68 @@ import { CLIExecutor, NodeProcessRunner } from '../tools/cliCommandTools.js'; * for basic type, interactively provide username/password */ export async function autoValidateAuthIfConfigured(): Promise { - const instUrl = process.env.SN_INSTANCE_URL?.trim(); - const authType = process.env.SN_AUTH_TYPE?.trim() || 'oauth'; - if (!instUrl) { - logger.info('Auto-auth skipped: SN_INSTANCE_URL is not set'); - return; - } + const instUrl = process.env.SN_INSTANCE_URL?.trim(); + const authType = process.env.SN_AUTH_TYPE?.trim() || 'oauth'; + if (!instUrl) { + logger.info('Auto-auth skipped: SN_INSTANCE_URL is not set'); + return; + } - // const username = process.env.SN_USERNAME?.trim(); - // const password = process.env.SN_PASSWORD?.trim(); + // const username = process.env.SN_USERNAME?.trim(); + // const password = process.env.SN_PASSWORD?.trim(); - // if (!username || !password) { - // logger.info('Auto-auth skipped: SN_USERNAME/SN_PASSWORD/SN_AUTH_TYPE not fully set'); - // return; - // } + // if (!username || !password) { + // logger.info('Auto-auth skipped: SN_USERNAME/SN_PASSWORD/SN_AUTH_TYPE not fully set'); + // return; + // } - try { - // Validate existing auth profiles - const listRes = await runNowSdk(['auth', '--list']); - const profiles = parseAuthListOutput(listRes.stdout); - - const matched = profiles.find((p) => urlsEqual(p.host, instUrl)); - if (matched) { - if (matched.defaultYes) { - logger.info('Auto-auth validated: default profile matches SN_INSTANCE_URL', { - alias: matched.alias, - host: matched.host, - }); - return; - } - - // Found matching host but not default, switch default - const useAlias = matched.alias; - const useRes = await runNowSdk(['auth', '--use', useAlias]); - if (useRes.exitCode === 0) { - logger.info('Auto-auth updated: switched default profile', { alias: useAlias, host: matched.host }); - } else { - logger.warn('Auto-auth warning: failed to switch default profile with now-sdk auth --use', { - alias: useAlias, - stderr: useRes.stderr, - }); - } + try { + // Validate existing auth profiles + const listRes = await runNowSdk(['auth', '--list']); + const profiles = parseAuthListOutput(listRes.stdout); + + const matched = profiles.find((p) => urlsEqual(p.host, instUrl)); + if (matched) { + if (matched.defaultYes) { + logger.info('Auto-auth validated: default profile matches SN_INSTANCE_URL', { + alias: matched.alias, + host: matched.host, + }); return; } - // Not found -> attempt to add credentials (simplified) - const alias = deriveAliasFromInstance(instUrl); - // logger.info('Auto-auth attempting to add credentials', { alias, host: instUrl, type: authType }); + // Found matching host but not default, switch default + const useAlias = matched.alias; + const useRes = await runNowSdk(['auth', '--use', useAlias]); + if (useRes.exitCode === 0) { + logger.info('Auto-auth updated: switched default profile', { alias: useAlias, host: matched.host }); + } else { + logger.warn('Auto-auth warning: failed to switch default profile with now-sdk auth --use', { + alias: useAlias, + stderr: useRes.stderr, + }); + } + return; + } + + // Not found -> attempt to add credentials (simplified) + const alias = deriveAliasFromInstance(instUrl); + // logger.info('Auto-auth attempting to add credentials', { alias, host: instUrl, type: authType }); - // const addRes = await runNowSdk(['auth', '--add', instUrl, '--type', authType, '--alias', alias]); - // if (addRes.exitCode === 0) { - // logger.info('Auto-auth added credentials', { alias, host: instUrl, type: authType }); + // const addRes = await runNowSdk(['auth', '--add', instUrl, '--type', authType, '--alias', alias]); + // if (addRes.exitCode === 0) { + // logger.info('Auto-auth added credentials', { alias, host: instUrl, type: authType }); - // // Try to set as default - // const useRes = await runNowSdk(['auth', '--use', alias]); - // if (useRes.exitCode === 0) { - // logger.info('Auto-auth set as default', { alias }); - // } - // } else { - logger.notice('not authenticated, please run following shell command to login:', { - shellCommand: `npx @servicenow/sdk auth --add ${instUrl} --type ${authType} --alias ${alias}` - }); - // } + // // Try to set as default + // const useRes = await runNowSdk(['auth', '--use', alias]); + // if (useRes.exitCode === 0) { + // logger.info('Auto-auth set as default', { alias }); + // } + // } else { + logger.notice('not authenticated, please run following shell command to login:', { + shellCommand: `npx @servicenow/sdk auth --add ${instUrl} --type ${authType} --alias ${alias}` + }); + // } } catch (error) { logger.warn('Auto-auth failed to validate', { error: error instanceof Error ? error.message : String(error) }); logger.notice('please run following shell command to login:', { @@ -141,7 +141,7 @@ async function runNowSdk(subArgs: string[]): Promise<{ stdout: string; stderr: s // Try direct binary first, fallback to npx with ServiceNow SDK package // let result = await executor.execute('now-sdk', subArgs); // if (result.exitCode !== 0) { - const result = await executor.execute('npx', ['@servicenow/sdk', ...subArgs]); + const result = await executor.execute('npx', ['@servicenow/sdk', ...subArgs]); // } return { stdout: result.output, stderr: result.error?.message || '', exitCode: result.exitCode }; } From aa640cb85d974c2508967600c07b68cc5ccd560d Mon Sep 17 00:00:00 2001 From: modesty Date: Thu, 11 Sep 2025 11:25:31 -0700 Subject: [PATCH 2/2] refactor: migrate auth validation to use ToolsManager for command execution, add rootContext to ensure auth list is executed within the root --- package.json | 2 +- src/server/fluentInstanceAuth.ts | 32 ++++--------- src/server/fluentMCPServer.ts | 16 +++++-- src/tools/cliCommandTools.ts | 32 +++---------- src/tools/commands/sessionFallbackCommand.ts | 39 +++++++++------ src/tools/toolsManager.ts | 28 ++++++++++- src/utils/rootContext.ts | 50 ++++++++++++++++++++ test/tools/sessionFallbackCommand.test.ts | 9 ++-- 8 files changed, 135 insertions(+), 73 deletions(-) create mode 100644 src/utils/rootContext.ts diff --git a/package.json b/package.json index 6bcc81d..d09887b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modesty/fluent-mcp", - "version": "0.0.14", + "version": "0.0.15", "description": "MCP server for Fluent (ServiceNow SDK)", "keywords": [ "Servicenow SDK", diff --git a/src/server/fluentInstanceAuth.ts b/src/server/fluentInstanceAuth.ts index 74071e8..8cff328 100644 --- a/src/server/fluentInstanceAuth.ts +++ b/src/server/fluentInstanceAuth.ts @@ -1,5 +1,5 @@ import logger from '../utils/logger.js'; -import { CLIExecutor, NodeProcessRunner } from '../tools/cliCommandTools.js'; +import { ToolsManager } from '../tools/toolsManager.js'; /** * Perform auto-authentication validation if environment is configured @@ -11,7 +11,7 @@ import { CLIExecutor, NodeProcessRunner } from '../tools/cliCommandTools.js'; * - Otherwise -> run `npx now-sdk auth --add --type ` and * for basic type, interactively provide username/password */ -export async function autoValidateAuthIfConfigured(): Promise { +export async function autoValidateAuthIfConfigured(toolsManager: ToolsManager): Promise { const instUrl = process.env.SN_INSTANCE_URL?.trim(); const authType = process.env.SN_AUTH_TYPE?.trim() || 'oauth'; if (!instUrl) { @@ -19,6 +19,7 @@ export async function autoValidateAuthIfConfigured(): Promise { return; } + // const username = process.env.SN_USERNAME?.trim(); // const password = process.env.SN_PASSWORD?.trim(); @@ -28,9 +29,9 @@ export async function autoValidateAuthIfConfigured(): Promise { // } try { - // Validate existing auth profiles - const listRes = await runNowSdk(['auth', '--list']); - const profiles = parseAuthListOutput(listRes.stdout); + // Validate existing auth profiles using the registered AuthCommand via ToolsManager + const listRes = await toolsManager.runAuth({ list: true }); + const profiles = parseAuthListOutput(listRes.output); const matched = profiles.find((p) => urlsEqual(p.host, instUrl)); if (matched) { @@ -44,13 +45,13 @@ export async function autoValidateAuthIfConfigured(): Promise { // Found matching host but not default, switch default const useAlias = matched.alias; - const useRes = await runNowSdk(['auth', '--use', useAlias]); + const useRes = await toolsManager.runAuth({ use: useAlias }); if (useRes.exitCode === 0) { logger.info('Auto-auth updated: switched default profile', { alias: useAlias, host: matched.host }); } else { logger.warn('Auto-auth warning: failed to switch default profile with now-sdk auth --use', { alias: useAlias, - stderr: useRes.stderr, + stderr: useRes.error?.message, }); } return; @@ -60,7 +61,7 @@ export async function autoValidateAuthIfConfigured(): Promise { const alias = deriveAliasFromInstance(instUrl); // logger.info('Auto-auth attempting to add credentials', { alias, host: instUrl, type: authType }); - // const addRes = await runNowSdk(['auth', '--add', instUrl, '--type', authType, '--alias', alias]); + // const addRes = await toolsManager.runAuth({ add: instUrl, type: authType, alias }); // if (addRes.exitCode === 0) { // logger.info('Auto-auth added credentials', { alias, host: instUrl, type: authType }); @@ -132,18 +133,3 @@ function parseAuthListOutput(stdout: string): { alias: string; host?: string; ty if (current) profiles.push(current); return profiles; } - -// Use CLIExecutor for consistent process handling -const executor = new CLIExecutor(new NodeProcessRunner()); - -/** Run a now-sdk command and capture stdout/stderr without interaction */ -async function runNowSdk(subArgs: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { - // Try direct binary first, fallback to npx with ServiceNow SDK package - // let result = await executor.execute('now-sdk', subArgs); - // if (result.exitCode !== 0) { - const result = await executor.execute('npx', ['@servicenow/sdk', ...subArgs]); - // } - return { stdout: result.output, stderr: result.error?.message || '', exitCode: result.exitCode }; -} - - diff --git a/src/server/fluentMCPServer.ts b/src/server/fluentMCPServer.ts index 4f5c06c..767ef53 100644 --- a/src/server/fluentMCPServer.ts +++ b/src/server/fluentMCPServer.ts @@ -36,6 +36,7 @@ export class FluentMcpServer { private promptManager: PromptManager; private status: ServerStatus = ServerStatus.STOPPED; private roots: { uri: string; name?: string }[] = []; + private autoAuthTriggered = false; /** * Create a new MCP server instance @@ -295,7 +296,6 @@ export class FluentMcpServer { if (this.status === ServerStatus.RUNNING || this.status === ServerStatus.INITIALIZING) { // Update roots in tools manager this.toolsManager.updateRoots(this.roots); - // Notify clients if server is running if (this.status === ServerStatus.RUNNING && this.mcpServer?.server) { // Use the SDK's notification method for roots/list_changed @@ -305,6 +305,17 @@ export class FluentMcpServer { // Log the root change only once at this level loggingManager.logRootsChanged(this.roots); } + + // Trigger auto-auth validation ONCE after roots are available + if (!this.autoAuthTriggered) { + this.autoAuthTriggered = true; + // Fire and forget; we don't want to block roots update + autoValidateAuthIfConfigured(this.toolsManager).catch((error) => { + logger.warn('Auto-auth validation failed after roots update', { + error: error instanceof Error ? error.message : String(error), + }); + }); + } } } } @@ -396,9 +407,6 @@ export class FluentMcpServer { this.status = ServerStatus.RUNNING; loggingManager.logServerStarted(); - // Kick off auto-auth validation asynchronously (non-blocking) - await autoValidateAuthIfConfigured(); - // The root list will be requested when the client sends the notifications/initialized notification // This ensures proper timing according to the MCP protocol } catch (error) { diff --git a/src/tools/cliCommandTools.ts b/src/tools/cliCommandTools.ts index c5e2fc7..a872fcb 100644 --- a/src/tools/cliCommandTools.ts +++ b/src/tools/cliCommandTools.ts @@ -8,7 +8,7 @@ import { ProcessResult, ProcessRunner, } from '../utils/types.js'; -import { getProjectRootPath } from '../config.js'; +import { getPrimaryRootPath as getRootContextPrimaryRootPath, getPrimaryRootPathFrom as getPrimaryRootPathFromArray } from '../utils/rootContext.js'; import { VersionCommand, HelpCommand, @@ -126,19 +126,7 @@ export class CLIExecutor implements CommandProcessor { } } - /** - * Gets the primary root URI or falls back to project root path - * @returns The URI of the primary root or project root path - */ - private getPrimaryRoot(): string { - // Use the first root if available, otherwise fall back to project root - if (this.roots.length > 0) { - return this.roots[0].uri; - } - - // Fall back to project root path if no roots are set - return getProjectRootPath(); - } + // Removed getPrimaryRoot(): use RootContext directly where needed /** * Process a command by executing it and returning the result @@ -162,7 +150,9 @@ export class CLIExecutor implements CommandProcessor { try { let cwd = customWorkingDir; if (!cwd && useMcpCwd) { - cwd = this.getPrimaryRoot(); + // Prefer instance roots when provided, fallback to RootContext + const resolved = getPrimaryRootPathFromArray(this.roots); + cwd = resolved || getRootContextPrimaryRootPath(); } // Sanity check on working directory - warn if it's the system root @@ -220,15 +210,7 @@ export class CLICmdWriter implements CommandProcessor { * Gets the primary root URI or falls back to project root path * @returns The URI of the primary root or project root path */ - private getPrimaryRoot(): string { - // Use the first root if available, otherwise fall back to project root - if (this.roots.length > 0) { - return this.roots[0].uri; - } - - // Fall back to project root path if no roots are set - return getProjectRootPath(); - } + // Removed getPrimaryRoot(): use RootContext directly where needed /** * Process a command by generating its text representation without executing it @@ -273,7 +255,7 @@ export class CLICmdWriter implements CommandProcessor { try { let cwd = customWorkingDir; if (!cwd && useMcpCwd) { - cwd = this.getPrimaryRoot(); + cwd = getPrimaryRootPathFromArray(this.roots) || getRootContextPrimaryRootPath(); } // Sanity check on working directory - warn if it's the system root diff --git a/src/tools/commands/sessionFallbackCommand.ts b/src/tools/commands/sessionFallbackCommand.ts index 88f3445..373ec3c 100644 --- a/src/tools/commands/sessionFallbackCommand.ts +++ b/src/tools/commands/sessionFallbackCommand.ts @@ -60,23 +60,34 @@ export abstract class SessionFallbackCommand extends BaseCLICommand { args: string[], useMcpCwd: boolean = false ): Promise { - // If useMcpCwd is true, we use the MCPs CWD and ignore session working directory + // If explicitly requested, use the MCP root-based CWD if (useMcpCwd) { return await this.commandProcessor.process(command, args, true); } - - // Otherwise, get working directory from session or fallback to project root - const workingDirectory = this.getWorkingDirectoryWithFallback(); - - try { - return await this.commandProcessor.process(command, args, false, workingDirectory); - } catch (error) { - return { - exitCode: 1, - success: false, - output: '', - error: error instanceof Error ? error : new Error(String(error)), - }; + + // Prefer session working directory when available + const sessionManager = SessionManager.getInstance(); + const sessionWorkingDir = sessionManager.getWorkingDirectory(); + + // Use session working directory if it exists (skip fs check in tests) + if ( + sessionWorkingDir && + (process.env.NODE_ENV === 'test' || fs.existsSync(sessionWorkingDir)) + ) { + try { + return await this.commandProcessor.process(command, args, false, sessionWorkingDir); + } catch (error) { + return { + exitCode: 1, + success: false, + output: '', + error: error instanceof Error ? error : new Error(String(error)), + }; + } } + + // Fallback: use MCP server's root as the working directory + // This ensures root-based CWD is applicable to all fallback commands + return await this.commandProcessor.process(command, args, true); } } diff --git a/src/tools/toolsManager.ts b/src/tools/toolsManager.ts index 5a8c52a..ea8bc4e 100644 --- a/src/tools/toolsManager.ts +++ b/src/tools/toolsManager.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { CommandFactory, CommandRegistry } from './cliCommandTools.js'; -import { CLICommand } from '../utils/types.js'; +import { CLICommand, CommandProcessor, CommandResult } from '../utils/types.js'; import logger from '../utils/logger.js'; import { GetApiSpecCommand, @@ -10,6 +10,8 @@ import { ListMetadataTypesCommand } from './resourceTools.js'; import { CLIExecutor, CLICmdWriter, NodeProcessRunner } from './cliCommandTools.js'; +import { AuthCommand } from './commands/authCommand.js'; +import { setRoots as setRootContextRoots } from '../utils/rootContext.js'; /** * Manager for handling MCP tools registration and execution @@ -17,6 +19,8 @@ import { CLIExecutor, CLICmdWriter, NodeProcessRunner } from './cliCommandTools. export class ToolsManager { private commandRegistry: CommandRegistry; private mcpServer: McpServer; + private cliExecutor!: CLIExecutor; + private cliCmdWriter!: CLICmdWriter; /** * Create a new ToolsManager @@ -40,6 +44,9 @@ export class ToolsManager { // Create both types of command processors const cliExecutor = new CLIExecutor(processRunner); const cliCmdWriter = new CLICmdWriter(); // CLICmdWriter doesn't need processRunner + // Store shared processors for later use (e.g., server-internal invocations) + this.cliExecutor = cliExecutor; + this.cliCmdWriter = cliCmdWriter; // Create commands with appropriate processors for each type // AuthCommand and InitCommand will use CLICmdWriter, others will use CLIExecutor @@ -235,7 +242,26 @@ export class ToolsManager { writer.setRoots(roots); }); + // Also update global RootContext for modules that don't participate in the command registry + setRootContextRoots(roots); + // Log only once at this level after all updates are complete logger.info('Updated roots in all CLI tools', { roots }); } + + /** + * Get the shared CLI executor used by registered commands + */ + getExecutorProcessor(): CommandProcessor { + return this.cliExecutor; + } + + /** + * Execute the AuthCommand using the shared executor (not the writer) + * Used by server-internal flows like auto auth validation + */ + async runAuth(args: Record): Promise { + const cmd = new AuthCommand(this.cliExecutor); + return await cmd.execute(args); + } } diff --git a/src/utils/rootContext.ts b/src/utils/rootContext.ts new file mode 100644 index 0000000..dc21977 --- /dev/null +++ b/src/utils/rootContext.ts @@ -0,0 +1,50 @@ +import { fileURLToPath } from 'node:url'; +import { getProjectRootPath } from '../config.js'; + +let roots: { uri: string; name?: string }[] = []; + +export function setRoots(newRoots: { uri: string; name?: string }[]): void { + roots = Array.isArray(newRoots) ? [...newRoots] : []; +} + +export function getRoots(): { uri: string; name?: string }[] { + return [...roots]; +} + +export function getPrimaryRootPath(): string { + if (roots && roots.length > 0) { + const uri = roots[0]?.uri; + if (uri) { + try { + if (uri.startsWith('file://')) { + return fileURLToPath(new URL(uri)); + } + return uri; + } catch { + // fall through + } + } + } + return getProjectRootPath(); +} + +/** Resolve a primary root path from a provided roots array, fallback to project root */ +export function getPrimaryRootPathFrom( + providedRoots?: { uri: string; name?: string }[] +): string { + if (providedRoots && providedRoots.length > 0) { + const uri = providedRoots[0]?.uri; + if (uri) { + try { + if (uri.startsWith('file://')) { + return fileURLToPath(new URL(uri)); + } + return uri; + } catch { + // ignore and fall through + } + } + } + // Default fallback when no provided roots + return getProjectRootPath(); +} diff --git a/test/tools/sessionFallbackCommand.test.ts b/test/tools/sessionFallbackCommand.test.ts index 95d8b90..f95eebb 100644 --- a/test/tools/sessionFallbackCommand.test.ts +++ b/test/tools/sessionFallbackCommand.test.ts @@ -87,7 +87,7 @@ describe("SessionFallbackCommand", () => { ); }); - test("should fall back to project root when no session working directory", async () => { + test("should fall back to MCP root when no session working directory", async () => { // Setup session to return no working directory (SessionManager.getInstance().getWorkingDirectory as jest.Mock).mockReturnValue(undefined); @@ -96,10 +96,9 @@ describe("SessionFallbackCommand", () => { expect(SessionManager.getInstance().getWorkingDirectory).toHaveBeenCalled(); // The config module is already mocked via Jest setup expect(mockExecutor.process).toHaveBeenCalledWith( - "test", - ["arg1", "arg2"], - false, // useMcpCwd - "/mock-project-root" // fallback to project root from mocked config + "test", + ["arg1", "arg2"], + true // use MCP root as working directory ); }); });