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 README.md
Original file line number Diff line number Diff line change
@@ -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 <instance>`
- 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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
126 changes: 56 additions & 70 deletions src/server/fluentInstanceAuth.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,69 +11,70 @@ import { CLIExecutor, NodeProcessRunner } from '../tools/cliCommandTools.js';
* - Otherwise -> run `npx now-sdk auth --add <SN_INSTANCE_URL> --type <SN_AUTH_TYPE>` and
* for basic type, interactively provide username/password
*/
export async function autoValidateAuthIfConfigured(): Promise<void> {
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();
export async function autoValidateAuthIfConfigured(toolsManager: ToolsManager): Promise<void> {
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;
}

// 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 username = process.env.SN_USERNAME?.trim();
// const password = process.env.SN_PASSWORD?.trim();

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;
}
// if (!username || !password) {
// logger.info('Auto-auth skipped: SN_USERNAME/SN_PASSWORD/SN_AUTH_TYPE not fully set');
// 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 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) {
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 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.error?.message,
});
}
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 toolsManager.runAuth({ add: instUrl, type: authType, 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:', {
Expand Down Expand Up @@ -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 };
}


16 changes: 12 additions & 4 deletions src/server/fluentMCPServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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),
});
});
}
}
}
}
Expand Down Expand Up @@ -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) {
Expand Down
32 changes: 7 additions & 25 deletions src/tools/cliCommandTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 25 additions & 14 deletions src/tools/commands/sessionFallbackCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,34 @@ export abstract class SessionFallbackCommand extends BaseCLICommand {
args: string[],
useMcpCwd: boolean = false
): Promise<CommandResult> {
// 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);
}
}
Loading