Skip to content
4 changes: 3 additions & 1 deletion packages/agents-a365-runtime/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './power-platform-api-discovery';
export * from './agentic-authorization-service';
export * from './environment-utils';
export * from './utility';
export * from './utility';
export * from './operation-error';
export * from './operation-result';
38 changes: 38 additions & 0 deletions packages/agents-a365-runtime/src/operation-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* Encapsulates an error from an operation.
*/
export class OperationError {
/**
* Gets the exception associated with the error.
*/
public readonly exception: Error;

/**
* Gets the message associated with the error.
*/
public get message(): string {
return this.exception.message;
}

/**
* Initializes a new instance of the OperationError class.
* @param exception The exception associated with the error.
*/
constructor(exception: Error) {
if (!exception) {
throw new Error('exception is required');
}
this.exception = exception;
}

/**
* Returns a string representation of the error.
* @returns A string representation of the error.
*/
public toString(): string {
return this.exception.toString();
}
}
66 changes: 66 additions & 0 deletions packages/agents-a365-runtime/src/operation-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { OperationError } from './operation-error';

/**
* Represents the result of an operation.
*/
export class OperationResult {
private static readonly _success = new OperationResult(true);
private readonly _errors: OperationError[];

/**
* Gets a flag indicating whether the operation succeeded.
*/
public readonly succeeded: boolean;

/**
* Gets an array of OperationError instances indicating errors that occurred during the operation.
*/
public get errors(): OperationError[] {
return this._errors || [];
}

/**
* Private constructor for OperationResult.
* @param succeeded Whether the operation succeeded.
* @param errors Optional array of errors.
*/
private constructor(succeeded: boolean, errors?: OperationError[]) {
this.succeeded = succeeded;
this._errors = errors || [];
}

/**
* Returns an OperationResult indicating a successful operation.
*/
public static get success(): OperationResult {
return OperationResult._success;
}

/**
* Creates an OperationResult indicating a failed operation, with a list of errors if applicable.
* @param errors An optional array of OperationError which caused the operation to fail.
* @returns An OperationResult indicating a failed operation, with a list of errors if applicable.
*/
public static failed(...errors: OperationError[]): OperationResult {
return new OperationResult(false, errors && errors.length > 0 ? errors : []);
Comment thread
pontemonti marked this conversation as resolved.
Outdated
}

/**
* Converts the value of the current OperationResult object to its equivalent string representation.
* @returns A string representation of the current OperationResult object.
* @remarks
* If the operation was successful the toString() will return "Succeeded" otherwise it will return
* "Failed : " followed by a comma delimited list of error messages from its errors collection, if any.
*/
public toString(): string {
if (this.succeeded) {
return 'Succeeded';
}

const errorMessages = this.errors.map(e => e.message).join(', ');
return `Failed : ${errorMessages}`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import fs from 'fs';
import path from 'path';
import axios from 'axios';
import { TurnContext } from '@microsoft/agents-hosting';
import { OperationResult, OperationError } from '@microsoft/agents-a365-runtime';
import { MCPServerConfig, McpClientTool, ToolOptions } from './contracts';
import { ChatHistoryMessage, ChatMessageRequest } from './models';
import { Utility } from './Utility';

import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
Expand Down Expand Up @@ -81,6 +84,108 @@ export class McpToolServerConfigurationService {
return toolsObj.tools;
}

/**
* Sends chat history to the MCP platform for real-time threat protection.
*
* @param turnContext The turn context containing conversation information.
* @param chatHistoryMessages The chat history messages to send.
* @returns A Promise that resolves to an OperationResult indicating success or failure.
* @throws Error if turnContext or chatHistoryMessages is null/undefined.
* @throws Error if required turn context properties (Conversation.Id, Activity.Id, or Activity.Text) are null.
* @remarks
* HTTP exceptions (network errors, timeouts) are caught and logged but not rethrown.
* Instead, the method returns an OperationResult indicating whether the operation succeeded or failed.
* Callers can choose to inspect the result for error handling or ignore it if error details are not needed.
*/
async sendChatHistory(turnContext: TurnContext, chatHistoryMessages: ChatHistoryMessage[]): Promise<OperationResult>;

/**
* Sends chat history to the MCP platform for real-time threat protection.
*
* @param turnContext The turn context containing conversation information.
* @param chatHistoryMessages The chat history messages to send.
* @param options Optional tool options for sending chat history.
* @returns A Promise that resolves to an OperationResult indicating success or failure.
* @throws Error if turnContext or chatHistoryMessages is null/undefined.
* @throws Error if required turn context properties (Conversation.Id, Activity.Id, or Activity.Text) are null.
* @remarks
* HTTP exceptions (network errors, timeouts) are caught and logged but not rethrown.
* Instead, the method returns an OperationResult indicating whether the operation succeeded or failed.
* Callers can choose to inspect the result for error handling or ignore it if error details are not needed.
*/
async sendChatHistory(turnContext: TurnContext, chatHistoryMessages: ChatHistoryMessage[], options?: ToolOptions): Promise<OperationResult>;

async sendChatHistory(turnContext: TurnContext, chatHistoryMessages: ChatHistoryMessage[], options?: ToolOptions): Promise<OperationResult> {
Comment thread
pontemonti marked this conversation as resolved.
if (!turnContext) {
throw new Error('turnContext is required');
}
if (!chatHistoryMessages) {
throw new Error('chatHistoryMessages is required');
}

// Extract required information from turn context
const conversationId = turnContext.activity?.conversation?.id;
if (!conversationId) {
throw new Error('Conversation ID is required but not found in turn context');
}

const messageId = turnContext.activity?.id;
if (!messageId) {
throw new Error('Message ID is required but not found in turn context');
}

const userMessage = turnContext.activity?.text;
if (!userMessage) {
throw new Error('User message is required but not found in turn context');
}

// Get the endpoint URL
const endpoint = Utility.GetChatHistoryEndpoint();

this.logger.info(`Sending chat history to endpoint: ${endpoint}`);

// Create the request payload
const request: ChatMessageRequest = {
conversationId,
messageId,
userMessage,
chatHistory: chatHistoryMessages
};

try {
const headers = Utility.GetToolRequestHeaders(undefined, turnContext, options);

await axios.post(
endpoint,
request,
{
headers: {
...headers,
'Content-Type': 'application/json'
},
timeout: 10000 // 10 seconds timeout
}
);

this.logger.info('Successfully sent chat history to MCP platform');
return OperationResult.success;
} catch (err: unknown) {
const error = err as Error & { code?: string };

if (axios.isAxiosError(err)) {
if (err.code === 'ECONNABORTED' || err.code === 'ETIMEDOUT') {
this.logger.error(`Request timeout sending chat history to '${endpoint}': ${error.message}`);
} else {
this.logger.error(`HTTP error sending chat history to '${endpoint}': ${error.message}`);
}
} else {
this.logger.error(`Failed to send chat history to '${endpoint}': ${error.message}`);
}

return OperationResult.failed(new OperationError(error));
}
}

/**
* Query the tooling gateway for MCP servers for the specified agent and normalize each entry's mcpServerUniqueName into a full URL using Utility.BuildMcpServerUrl.
* Throws an error if the gateway call fails.
Expand Down
14 changes: 14 additions & 0 deletions packages/agents-a365-tooling/src/Utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,18 @@ export class Utility {

return MCP_PLATFORM_PROD_BASE_URL;
}

/**
* Constructs the endpoint URL for sending chat history to the MCP platform for real-time threat protection.
*
* @returns An absolute URL that tooling components can use to send or retrieve chat messages for
* real-time threat protection scenarios.
* @remarks
* Call this method when constructing HTTP requests that need to access the chat-message history
* for real-time threat protection. The returned URL already includes the MCP platform base address
* and the fixed path segment `/agents/real-time-threat-protection/chat-message`.
*/
public static GetChatHistoryEndpoint(): string {
return `${this.getMcpPlatformBaseUrl()}/agents/real-time-threat-protection/chat-message`;
}
}
3 changes: 2 additions & 1 deletion packages/agents-a365-tooling/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './Utility';
export * from './McpToolServerConfigurationService';
export * from './contracts';
export * from './contracts';
export * from './models';
52 changes: 52 additions & 0 deletions packages/agents-a365-tooling/src/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* Represents a single message in the chat history.
*/
export interface ChatHistoryMessage {
/**
* The unique identifier for the chat message.
*/
id: string;

/**
* The role of the message sender (e.g., "user", "assistant", "system").
*/
role: string;

/**
* The content of the chat message.
*/
content: string;

/**
* The timestamp of when the message was sent.
*/
timestamp: Date;
}

/**
* Represents the request payload for a real-time threat protection check on a chat message.
*/
export interface ChatMessageRequest {
/**
* The unique identifier for the conversation.
*/
conversationId: string;

/**
* The unique identifier for the message within the conversation.
*/
messageId: string;

/**
* The content of the user's message.
*/
userMessage: string;

/**
* The chat history messages.
*/
chatHistory: ChatHistoryMessage[];
}
47 changes: 47 additions & 0 deletions tests/runtime/operation-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { OperationError } from '../../packages/agents-a365-runtime/src/operation-error';

describe('OperationError', () => {
it('should create an instance with an exception', () => {
const error = new Error('Test error');
const operationError = new OperationError(error);

expect(operationError).toBeDefined();
expect(operationError.exception).toBe(error);
expect(operationError.message).toBe('Test error');
});

it('should throw if exception is null', () => {
expect(() => new OperationError(null as unknown as Error)).toThrow('exception is required');
});

it('should throw if exception is undefined', () => {
expect(() => new OperationError(undefined as unknown as Error)).toThrow('exception is required');
});

it('should return exception string from toString', () => {
const error = new Error('Test error message');
const operationError = new OperationError(error);

const result = operationError.toString();
expect(result).toContain('Test error message');
});

it('should preserve exception type information', () => {
class CustomError extends Error {
code: string;
constructor(message: string, code: string) {
super(message);
this.code = code;
}
}

const customError = new CustomError('Custom error', 'ERR_CUSTOM');
const operationError = new OperationError(customError);

expect(operationError.exception).toBeInstanceOf(CustomError);
expect((operationError.exception as CustomError).code).toBe('ERR_CUSTOM');
});
});
Loading