@hashgraphonline/conversational-agent
Version:
Hashgraph Online conversational AI agent implementing HCS-10 communication, HCS-2 registries, and content inscription on Hedera. https://hol.org
366 lines (319 loc) • 9.63 kB
text/typescript
import type { ContentType, ContentSource } from '../types/content-reference';
import type { ContentStorage } from '../memory/content-storage';
import { Logger } from '@hashgraphonline/standards-sdk';
export interface MCPResponseContent {
content: unknown;
type: 'text' | 'image' | 'resource' | 'text[]' | 'image[]';
sizeBytes: number;
mimeType?: string;
}
export interface ProcessedResponse {
content: unknown;
wasProcessed: boolean;
referenceCreated?: boolean;
referenceId?: string;
originalSize?: number;
errors?: string[];
}
/**
* Content reference interface
*/
interface ContentReference {
referenceId: string;
preview?: string;
metadata: {
sizeBytes: number;
contentType?: string;
[key: string]: unknown;
};
}
export interface ContentAnalysis {
shouldProcess: boolean;
contents: MCPResponseContent[];
totalSize: number;
largestContentSize: number;
}
export class MCPContentProcessor {
private contentStorage: ContentStorage;
private logger: Logger;
constructor(contentStorage: ContentStorage, logger: Logger) {
this.contentStorage = contentStorage;
this.logger = logger;
}
analyzeResponse(response: unknown): ContentAnalysis {
const contents: MCPResponseContent[] = [];
let totalSize = 0;
this.extractContentFromResponse(response, contents);
totalSize = contents.reduce((sum, content) => sum + content.sizeBytes, 0);
const largestContentSize = contents.reduce(
(max, content) => Math.max(max, content.sizeBytes),
0
);
const shouldProcess = contents.some((content) =>
this.contentStorage.shouldUseReference(
typeof content.content === 'string'
? content.content
: JSON.stringify(content.content)
)
);
return {
shouldProcess,
contents,
totalSize,
largestContentSize,
};
}
async processResponse(
response: unknown,
serverName: string,
toolName: string
): Promise<ProcessedResponse> {
try {
const analysis = this.analyzeResponse(response);
if (!analysis.shouldProcess) {
return {
content: response,
wasProcessed: false,
};
}
const processedResponse = await this.createReferencedResponse(
response,
analysis,
serverName,
toolName
);
return processedResponse;
} catch (error) {
this.logger.error('Error processing MCP response:', error);
return {
content: response,
wasProcessed: false,
errors: [
error instanceof Error ? error.message : 'Unknown processing error',
],
};
}
}
private extractContentFromResponse(
obj: unknown,
contents: MCPResponseContent[]
): void {
if (obj === null || obj === undefined) {
return;
}
if (Array.isArray(obj)) {
obj.forEach((item) => this.extractContentFromResponse(item, contents));
return;
}
if (typeof obj === 'object') {
const record = obj as Record<string, unknown>;
if (record.type === 'text' && typeof record.text === 'string') {
contents.push({
content: record.text,
type: 'text',
sizeBytes: Buffer.byteLength(record.text, 'utf8'),
mimeType: this.detectMimeType(record.text as string),
});
return;
}
if (record.type === 'image' && typeof record.data === 'string') {
contents.push({
content: record.data,
type: 'image',
sizeBytes: Math.ceil(record.data.length * 0.75),
mimeType: (record.mimeType as string) || 'image/jpeg',
});
return;
}
if (record.type === 'resource' && record.resource) {
const resourceStr = JSON.stringify(record.resource);
contents.push({
content: resourceStr,
type: 'resource',
sizeBytes: Buffer.byteLength(resourceStr, 'utf8'),
mimeType: 'application/json',
});
return;
}
Object.values(record).forEach((value) =>
this.extractContentFromResponse(value, contents)
);
return;
}
if (typeof obj === 'string') {
if (obj.length > 1000) {
contents.push({
content: obj,
type: 'text',
sizeBytes: Buffer.byteLength(obj, 'utf8'),
mimeType: this.detectMimeType(obj),
});
}
}
}
private async createReferencedResponse(
originalResponse: unknown,
analysis: ContentAnalysis,
serverName: string,
toolName: string
): Promise<ProcessedResponse> {
const processedResponse = this.deepClone(originalResponse);
const errors: string[] = [];
let referenceCreated = false;
let totalReferenceSize = 0;
for (const contentInfo of analysis.contents) {
if (
this.contentStorage.shouldUseReference(
typeof contentInfo.content === 'string'
? contentInfo.content
: JSON.stringify(contentInfo.content)
)
) {
try {
const contentBuffer = Buffer.from(
typeof contentInfo.content === 'string'
? contentInfo.content
: JSON.stringify(contentInfo.content),
'utf8'
);
const contentType = this.mapMimeTypeToContentType(
contentInfo.mimeType
);
const metadata: Parameters<
typeof this.contentStorage.storeContentIfLarge
>[1] = {
contentType,
source: 'mcp_tool' as ContentSource,
mcpToolName: `${serverName}::${toolName}`,
tags: ['mcp_response', serverName, toolName],
};
if (contentInfo.mimeType !== undefined) {
metadata.mimeType = contentInfo.mimeType;
}
const reference = await this.contentStorage.storeContentIfLarge(
contentBuffer,
metadata
);
if (reference) {
this.replaceContentInResponse(
processedResponse,
contentInfo.content,
this.createLightweightReference(reference)
);
referenceCreated = true;
totalReferenceSize += contentBuffer.length;
}
} catch (error) {
errors.push(
`Failed to create reference: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
}
const result: ProcessedResponse = {
content: processedResponse,
wasProcessed: true,
referenceCreated,
originalSize: totalReferenceSize,
};
if (errors.length > 0) {
result.errors = errors;
}
return result;
}
private createLightweightReference(
reference: ContentReference
): Record<string, unknown> {
return {
type: 'content_reference',
referenceId: reference.referenceId,
preview: reference.preview,
size: reference.metadata.sizeBytes,
contentType: reference.metadata.contentType,
format: 'ref://{id}',
_isReference: true,
};
}
private replaceContentInResponse(
obj: unknown,
oldContent: unknown,
newContent: unknown
): void {
if (obj === null || obj === undefined) {
return;
}
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
if (obj[i] === oldContent) {
obj[i] = newContent;
} else {
this.replaceContentInResponse(obj[i], oldContent, newContent);
}
}
return;
}
if (typeof obj === 'object') {
const record = obj as Record<string, unknown>;
if (record.type === 'text' && record.text === oldContent) {
for (const key of Object.keys(record)) {
delete record[key];
}
if (typeof newContent === 'object' && newContent !== null) {
const newContentRecord = newContent as Record<string, unknown>;
for (const key of Object.keys(newContentRecord)) {
record[key] = newContentRecord[key];
}
}
return;
}
for (const key in record) {
if (record[key] === oldContent) {
record[key] = newContent;
} else {
this.replaceContentInResponse(record[key], oldContent, newContent);
}
}
}
}
private detectMimeType(content: string): string {
if (content.trim().startsWith('{') || content.trim().startsWith('[')) {
return 'application/json';
}
if (content.includes('<html>') || content.includes('<!DOCTYPE')) {
return 'text/html';
}
if (content.includes('# ') || content.includes('## ')) {
return 'text/markdown';
}
return 'text/plain';
}
private mapMimeTypeToContentType(mimeType?: string): ContentType {
if (!mimeType) return 'text';
if (mimeType.startsWith('text/plain')) return 'text';
if (mimeType === 'application/json') return 'json';
if (mimeType === 'text/html') return 'html';
if (mimeType === 'text/markdown') return 'markdown';
if (mimeType.startsWith('text/')) return 'text';
return 'binary';
}
private deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as T;
}
if (Array.isArray(obj)) {
return obj.map((item) => this.deepClone(item)) as T;
}
const cloned = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = this.deepClone(obj[key]);
}
}
return cloned;
}
}