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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ Create a new Fluent app in ~/projects/time-off-tracker to manage employee PTO re

| Tool | Description | Key Parameters |
|------|-------------|----------------|
| `sdk_info` | Get SDK version, help, or debug info | `flag` (-v/-h/-d), `command` (optional) |
| `sdk_info` | Get SDK version or help | `flag` (-v/-h), `command` (optional for -h) |
| `get-api-spec` | Get API spec or list all metadata types | `metadataType` (optional, omit to list all) |
| `init_fluent_app` | Initialize or convert ServiceNow app | `workingDirectory` (required), `template`, `from` (optional) |
| `build_fluent_app` | Build the application | `debug` (optional) |
| `deploy_fluent_app` | Deploy to ServiceNow instance | `auth` (auto-injected), `debug` (optional) |
Expand Down Expand Up @@ -224,6 +225,7 @@ npm run build && npm run inspect

- Version command returns SDK version string
- Help command returns detailed command documentation
- List metadata (`-lm`) returns available Fluent metadata types
- No errors in notifications pane
- Commands execute within 2-3 seconds

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.19",
"version": "0.1.0",
"description": "MCP server for Fluent (ServiceNow SDK)",
"keywords": [
"Servicenow SDK",
Expand Down
154 changes: 54 additions & 100 deletions src/server/fluentMCPServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import { z } from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
CallToolResult,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
ListRootsRequestSchema,
Expand All @@ -23,6 +21,7 @@ import { PromptManager } from '../prompts/promptManager.js';
import { autoValidateAuthIfConfigured } from './fluentInstanceAuth.js';
import { SamplingManager } from '../utils/samplingManager.js';
import { AuthNotificationHandler } from './authNotificationHandler.js';
import { McpResourceNotFoundError, McpInternalError } from '../utils/mcpErrors.js';

/** Delay before fallback initialization if client doesn't send notifications */
const INITIALIZATION_DELAY_MS = 1000;
Expand Down Expand Up @@ -108,12 +107,12 @@ export class FluentMcpServer {
return `✅ Output:\n${result.output}`;
} else {
let errorOutput = `❌ Error:\n${result.error || 'Unknown error'}\n(exit code: ${result.exitCode})\n${result.output}`;

// Append AI error analysis if available
if (result.errorAnalysis) {
errorOutput += '\n' + this.samplingManager.formatAnalysis(result.errorAnalysis);
}

return errorOutput;
}
}
Expand All @@ -136,7 +135,7 @@ export class FluentMcpServer {
// Only initialize if roots haven't been set up yet
if (this.roots.length === 0) {
logger.info('No roots received from client after delay, using fallback initialization...');

// Try to request from client first, with short timeout
try {
await Promise.race([
Expand All @@ -152,7 +151,7 @@ export class FluentMcpServer {
await this.addRoot(`file://${projectRoot}`, 'Project Root (Fallback)');
}
}

// Trigger auth validation if not already triggered
if (!this.autoAuthTriggered) {
this.autoAuthTriggered = true;
Expand Down Expand Up @@ -187,7 +186,7 @@ export class FluentMcpServer {
}

logger.info('Requesting roots from client via roots/list...');

try {
// Create a schema for the response using the Zod library
// This is needed because the request method requires a result schema
Expand All @@ -208,15 +207,15 @@ export class FluentMcpServer {
) as RootsResponse;

const roots = response.roots;

if (Array.isArray(roots) && roots.length > 0) {
logger.info('Received roots from client', { rootCount: roots.length });

// Update roots with client-provided roots
await this.updateRoots(roots);
} else {
logger.warn('Client responded to roots/list but provided no roots');

// Fall back to project root if no valid roots received
if (this.roots.length === 0) {
const projectRoot = getProjectRootPath();
Expand All @@ -225,10 +224,10 @@ export class FluentMcpServer {
}
}
} catch (error) {
logger.error('Error requesting roots from client',
logger.error('Error requesting roots from client',
error instanceof Error ? error : new Error(String(error))
);

// Fall back to project root if request fails
if (this.roots.length === 0) {
const projectRoot = getProjectRootPath();
Expand All @@ -244,15 +243,15 @@ export class FluentMcpServer {
private setupHandlers(): void {
const server = this.mcpServer?.server;
if (!server) return;

// Set up the tools/list handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
const tools = this.toolsManager.getMCPTools();

// Start a delayed initialization process to ensure roots and auth are set up
// even if the client doesn't send proper notifications
this.scheduleDelayedInitialization();

return { tools };
});

Expand All @@ -266,39 +265,54 @@ export class FluentMcpServer {
return { resources: [] };
}
});

// Set up the resources/read handler
// The SDK's registerResource() doesn't automatically set up the read handler
// We need to explicitly handle resource read requests
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;

try {
logger.debug('Reading resource', { uri });

// Call ResourceManager to handle the read request
const result = await this.resourceManager.readResource(uri);

// Check if resource was not found (result has no contents)
if (!result.contents || result.contents.length === 0) {
throw new McpResourceNotFoundError(uri);
}

return result;
} catch (error) {
// Re-throw MCP errors as-is for proper error codes
if (error instanceof McpResourceNotFoundError) {
logger.warn('Resource not found', { uri });
throw error;
}

// Wrap other errors as internal errors
logger.error('Error reading resource',
error instanceof Error ? error : new Error(String(error)),
{ uri }
);
throw error;
throw new McpInternalError(
`Failed to read resource: ${error instanceof Error ? error.message : String(error)}`
);
}
});

// Set up prompts handlers
this.promptManager.setupHandlers();

// Set up roots/list handler
server.setRequestHandler(ListRootsRequestSchema, async () => {
logger.debug('Received roots/list request, returning current roots');
return {
roots: this.roots,
};
});

// Set up handler for notifications/initialized
server.setNotificationHandler(InitializedNotificationSchema, async () => {
logger.info('Received notifications/initialized notification from client');
Expand Down Expand Up @@ -336,83 +350,23 @@ export class FluentMcpServer {
}
}
});

// Set up handler for roots/list_changed notification
server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
logger.info('Received notifications/roots/list_changed notification from client');

// When a root list change notification is received, request the updated roots list
try {
await this.requestRootsFromClient();
} catch (error) {
logger.error('Failed to request updated roots after notification',
logger.error('Failed to request updated roots after notification',
error instanceof Error ? error : new Error(String(error))
);
}
});

// Execute tool calls handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;

const command = this.toolsManager.getCommand(name);
if (!command) {
throw new Error(`Unknown command: ${name}`);
}

try {
const result = await command.execute(args || {});

// If command failed and error analysis is enabled, analyze the error
if (!result.success && this.config.sampling.enableErrorAnalysis && result.error) {
const errorMessage = result.error.message;

if (this.samplingManager.shouldAnalyzeError(errorMessage, this.config.sampling.minErrorLength)) {
logger.info('Triggering error analysis for failed command', { command: name });

try {
const analysis = await this.samplingManager.analyzeError({
command: name,
args: Object.entries(args || {}).map(([key, value]) => `${key}=${value}`),
errorOutput: errorMessage,
exitCode: result.exitCode,
});

if (analysis) {
result.errorAnalysis = analysis;
}
} catch (analysisError) {
// Log but don't fail the tool call if analysis fails
logger.warn('Error analysis failed', {
error: analysisError instanceof Error ? analysisError.message : String(analysisError),
});
}
}
}

return {
content: [
{
type: 'text',
text: this.formatResult(result),
},
],
} as CallToolResult;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);

return {
content: [
{
type: 'text',
text: `Error: ${errorMessage}`,
},
],
isError: true,
} as CallToolResult;
}
});
// Note: Tool calls are handled by the callbacks registered via mcpServer.registerTool() in ToolsManager.
// We don't need a separate setRequestHandler for CallToolRequestSchema as that would conflict.
}

/**
Expand All @@ -428,17 +382,17 @@ export class FluentMcpServer {
}
return true;
});

// Check if roots have changed
const hasChanged = this.roots.length !== validatedRoots.length ||
this.roots.some((root, index) =>
root.uri !== validatedRoots[index]?.uri ||
this.roots.some((root, index) =>
root.uri !== validatedRoots[index]?.uri ||
root.name !== validatedRoots[index]?.name
);

if (hasChanged) {
this.roots = [...validatedRoots];

// Only update tools manager with the roots if the server is running
// or if the status is INITIALIZING (for tests)
// This prevents unnecessary updates during initialization
Expand All @@ -459,7 +413,7 @@ export class FluentMcpServer {
}
}
}

/**
* Add a new root to the list of roots
* @param uri The URI of the root
Expand All @@ -471,10 +425,10 @@ export class FluentMcpServer {
logger.warn('Attempted to add root with empty URI, ignoring');
return;
}

// Check if root already exists
const existingIndex = this.roots.findIndex(root => root.uri === uri);

if (existingIndex >= 0) {
// Update existing root if name has changed
if (this.roots[existingIndex].name !== name) {
Expand All @@ -491,27 +445,27 @@ export class FluentMcpServer {
await this.updateRoots([...this.roots, { uri, name }]);
}
}

/**
* Remove a root from the list of roots
* @param uri The URI of the root to remove
*/
async removeRoot(uri: string): Promise<void> {
const updatedRoots = this.roots.filter(root => root.uri !== uri);

if (updatedRoots.length !== this.roots.length) {
await this.updateRoots(updatedRoots);
}
}

/**
* Get the current list of roots
* @returns The list of roots
*/
getRoots(): { uri: string; name?: string }[] {
return [...this.roots];
}

/**
* Start the MCP server
*/
Expand Down Expand Up @@ -552,7 +506,7 @@ export class FluentMcpServer {
// This ensures that client notifications will be sent correctly
this.status = ServerStatus.RUNNING;
loggingManager.logServerStarted();

// 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
Loading