diff --git a/docs/user/safety-and-ecosystem.md b/docs/user/safety-and-ecosystem.md index aead7a7a..3bf51692 100644 --- a/docs/user/safety-and-ecosystem.md +++ b/docs/user/safety-and-ecosystem.md @@ -218,17 +218,39 @@ The Spec dashboard is available from an inspected `MCP` object. It shows: - server status and port - debug mode - registered tools and descriptions -- observability enabled by default +- observability status - per-tool timing metrics - traces for errors, outliers, and output overruns - recent logs -Configure `mcp monitoringExportDirectory: aDirectory` to override the default -`~/pharo-mcp-observability` export root for instance metadata, -`logs.jsonl`, `metrics.json`, and `traces.jsonl`. +MCP starts with a no-op observability backend. Enable the built-in JSON +observability backend explicitly: + +```smalltalk +mcp observabilityEnabled: true +``` + +By default, JSON observability exports under the image-local +`pharo-local/mcp/observability` directory. Configure +`mcp observabilityExportDirectory: aDirectory` to override that export root for +instance metadata, `logs.jsonl`, `metrics.json`, and `traces.jsonl`. + +OpenTelemetry support is optional. Load the `OpenTelemetry` group, then enable +the OpenTelemetry backend for an MCP instance: + +```smalltalk +Metacello new + baseline: 'MCP'; + repository: 'github://Evref-BL/MCP:main/src'; + load: 'OpenTelemetry'. + +mcp useOpenTelemetryObservability +``` ## Version Compatibility -The baseline loads PharoCompatibility, JRPC, and TinyLogger. CI covers Pharo 12, -13, and 14. Code that depends on version-sensitive Pharo APIs should go through -PharoCompatibility rather than assuming the Pharo 13 API is present everywhere. +The core baseline loads PharoCompatibility, JRPC, and PCRE2. +OpenTelemetry is loaded only by the optional `OpenTelemetry` group. CI covers +Pharo 12, 13, and 14. Code that depends on version-sensitive Pharo APIs should +go through PharoCompatibility rather than assuming the Pharo 13 API is present +everywhere. diff --git a/src/BaselineOfMCP/BaselineOfMCP.class.st b/src/BaselineOfMCP/BaselineOfMCP.class.st index 228e35a3..22ce764e 100644 --- a/src/BaselineOfMCP/BaselineOfMCP.class.st +++ b/src/BaselineOfMCP/BaselineOfMCP.class.st @@ -26,14 +26,14 @@ BaselineOfMCP >> defineDependencies: spec [ spec repository: 'github://juliendelplanque/JRPC:v3.2.1/src'; loads: #( 'Deployment' ) ]; - baseline: 'TinyLogger' with: [ - spec - repository: 'github://jecisc/TinyLogger:v1.x.x/src'; - loads: #( 'Core' ) ]; baseline: 'PCRE2' with: [ spec repository: 'github://Gabriel-Darbord/pharo-pcre2:v1.0.0/src'; - loads: #( 'Core' ) ] + loads: #( 'Core' ) ]; + baseline: 'OpenTelemetryPharo' with: [ + spec + repository: 'github://Gabriel-Darbord/opentelemetry-pharo:main/src'; + loads: #( 'traces' 'metrics' 'logs' ) ] ] { #category : 'baselines' } @@ -42,6 +42,7 @@ BaselineOfMCP >> defineGroups: spec [ spec group: 'Core' with: #( 'MCP' ); group: 'UI' with: #( 'MCP-UI' ); + group: 'OpenTelemetry' with: #( 'MCP-OpenTelemetry' ); group: 'Tests' with: #( 'MCP-Tests-Resources' 'MCP-Tests-RunResources' 'MCP-Tests' 'MCP-Tests-Resources-Extensions' ); group: 'UI Tests' with: #( 'MCP-UI-Tests' ); group: 'default' with: #( 'Core' 'UI' 'Tests' 'UI Tests' ) @@ -51,8 +52,9 @@ BaselineOfMCP >> defineGroups: spec [ BaselineOfMCP >> definePackages: spec [ "common" - spec package: 'MCP' with: [ spec requires: #( 'PharoCompatibility' 'JRPC' 'TinyLogger' 'PCRE2' ) ]. + spec package: 'MCP' with: [ spec requires: #( 'PharoCompatibility' 'JRPC' 'PCRE2' ) ]. spec package: 'MCP-UI' with: [ spec requires: #( 'MCP' ) ]. + spec package: 'MCP-OpenTelemetry' with: [ spec requires: #( 'MCP' 'OpenTelemetryPharo' ) ]. spec package: 'MCP-Tests-Resources' with: [ spec requires: #( 'MCP' ) ]. spec package: 'MCP-Tests-RunResources' with: [ spec requires: #( 'MCP-Tests-Resources' ) ]. spec package: 'MCP-Tests' with: [ spec requires: #( 'MCP' 'MCP-Tests-Resources' 'MCP-Tests-RunResources' ) ]. diff --git a/src/MCP-OpenTelemetry/MCP.extension.st b/src/MCP-OpenTelemetry/MCP.extension.st new file mode 100644 index 00000000..becf9a90 --- /dev/null +++ b/src/MCP-OpenTelemetry/MCP.extension.st @@ -0,0 +1,8 @@ +Extension { #name : 'MCP' } + +{ #category : '*MCP-OpenTelemetry' } +MCP >> useOpenTelemetryObservability [ + + self observability: MCPOpenTelemetryObservabilityBackend new. + ^ self observability +] diff --git a/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st b/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st new file mode 100644 index 00000000..bef3725b --- /dev/null +++ b/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st @@ -0,0 +1,413 @@ +" +OpenTelemetry observability backend for MCP. + +This class lives in the optional MCP-OpenTelemetry package so MCP core can remain free of OpenTelemetry dependencies. +" +Class { + #name : 'MCPOpenTelemetryObservabilityBackend', + #superclass : 'MCPObservabilityBackend', + #instVars : [ + 'enabled', + 'tracer', + 'meter', + 'logger', + 'nextCallId', + 'callCounter', + 'errorCounter', + 'durationHistogram', + 'inputSizeHistogram', + 'outputSizeHistogram', + 'outputBudgetExceededCounter', + 'recordsById', + 'outputBudgetBytes' + ], + #category : 'MCP-OpenTelemetry', + #package : 'MCP-OpenTelemetry' +} + +{ #category : 'private - metrics' } +MCPOpenTelemetryObservabilityBackend >> attributesForRecord: aRecord [ + + ^ { + ('tool.name' -> aRecord toolName). + ('mcp.dispatch_path' -> aRecord dispatchPath). + ('mcp.status' -> aRecord status) } +] + +{ #category : 'private - instruments' } +MCPOpenTelemetryObservabilityBackend >> callCounter [ + + callCounter ifNotNil: [ ^ callCounter ]. + callCounter := self meter counterNamed: 'mcp.tool.calls' unit: '1' description: 'MCP tool calls'. + ^ callCounter +] + +{ #category : 'private - budgets' } +MCPOpenTelemetryObservabilityBackend >> defaultOutputBudgetBytes [ + + ^ 32 * 1024 +] + +{ #category : 'activation' } +MCPOpenTelemetryObservabilityBackend >> disable [ + + enabled := false +] + +{ #category : 'private - instruments' } +MCPOpenTelemetryObservabilityBackend >> durationHistogram [ + + durationHistogram ifNotNil: [ ^ durationHistogram ]. + durationHistogram := self meter histogramNamed: 'mcp.tool.duration' unit: 'ms' description: 'MCP tool-call duration'. + ^ durationHistogram +] + +{ #category : 'private - logging' } +MCPOpenTelemetryObservabilityBackend >> emitEventNamed: eventName body: bodyString [ + + self enabled ifFalse: [ ^ nil ]. + self logger logRecordBuilder + eventName: eventName; + body: bodyString; + emit +] + +{ #category : 'activation' } +MCPOpenTelemetryObservabilityBackend >> enable [ + + enabled := true +] + +{ #category : 'accessing' } +MCPOpenTelemetryObservabilityBackend >> enabled [ + + ^ enabled ifNil: [ true ] +] + +{ #category : 'accessing' } +MCPOpenTelemetryObservabilityBackend >> enabled: aBoolean [ + + aBoolean + ifTrue: [ self enable ] + ifFalse: [ self disable ] +] + +{ #category : 'private - spans' } +MCPOpenTelemetryObservabilityBackend >> endSpan: span withRecord: aRecord error: anError at: finishedAt [ + + | attributes | + span ifNil: [ ^ nil ]. + attributes := self attributesForRecord: aRecord. + self + enrichSpan: span + withRecord: aRecord + attributes: attributes + error: anError. + span end: finishedAt +] + +{ #category : 'private - spans' } +MCPOpenTelemetryObservabilityBackend >> enrichSpan: span withRecord: aRecord attributes: attributes error: anError [ + + span + attributeAt: 'mcp.call.id' put: aRecord id; + attributeAt: 'mcp.tool.name' put: aRecord toolName; + attributeAt: 'mcp.dispatch_path' put: aRecord dispatchPath; + attributeAt: 'mcp.status' put: aRecord status; + attributeAt: 'mcp.input.bytes' put: aRecord inputByteCount; + attributeAt: 'mcp.output.bytes' put: aRecord outputByteCount; + attributeAt: 'mcp.output.tokens.approx' put: aRecord approximateOutputTokenCount; + attributeAt: 'mcp.output_budget.exceeded' put: aRecord outputBudgetExceeded. + aRecord outputBudgetExceeded ifTrue: [ span addEventNamed: 'mcp.output_budget_exceeded' attributes: attributes ]. + anError ifNotNil: [ + span + attributeAt: 'error.type' put: anError class name asString; + setStatus: OTSpan error description: anError description; + recordException: anError ]. + aRecord status = 'ok' ifTrue: [ span setStatus: OTSpan ok description: '' ] +] + +{ #category : 'private - metrics' } +MCPOpenTelemetryObservabilityBackend >> errorAttributesForRecord: aRecord baseAttributes: attributes [ + + ^ attributes copyWith: 'error.type' -> aRecord errorClass +] + +{ #category : 'private' } +MCPOpenTelemetryObservabilityBackend >> errorClassNameFor: anError [ + + anError ifNil: [ ^ '' ]. + ^ anError class name asString +] + +{ #category : 'private - instruments' } +MCPOpenTelemetryObservabilityBackend >> errorCounter [ + + errorCounter ifNotNil: [ ^ errorCounter ]. + errorCounter := self meter counterNamed: 'mcp.tool.errors' unit: '1' description: 'MCP tool-call errors'. + ^ errorCounter +] + +{ #category : 'private - records' } +MCPOpenTelemetryObservabilityBackend >> finishRecord: aRecord result: aToolResult error: anError at: finishedAt [ + + | duration statusString | + duration := (finishedAt - aRecord startedAt) asMilliSeconds. + statusString := self statusForResult: aToolResult error: anError. + aRecord finishAt: finishedAt durationMilliseconds: duration status: statusString. + aRecord errorClass: (self errorClassNameFor: anError). + self recordOutputBudgetFor: aRecord result: aToolResult error: anError. + aRecord inputObject: nil. + self updateEventKindsForRecord: aRecord +] + +{ #category : 'actions' } +MCPOpenTelemetryObservabilityBackend >> forceFlush [ + + OTTracerProvider current forceFlush. + OTMeterProvider current forceFlush. + OTLoggerProvider current forceFlush +] + +{ #category : 'initialization' } +MCPOpenTelemetryObservabilityBackend >> initialize [ + + super initialize. + enabled := true. + nextCallId := 0. + recordsById := Dictionary new. + outputBudgetBytes := self defaultOutputBudgetBytes +] + +{ #category : 'private - instruments' } +MCPOpenTelemetryObservabilityBackend >> inputSizeHistogram [ + + inputSizeHistogram ifNotNil: [ ^ inputSizeHistogram ]. + inputSizeHistogram := self meter histogramNamed: 'mcp.tool.input.size' unit: 'By' description: 'MCP tool-call input size'. + ^ inputSizeHistogram +] + +{ #category : 'testing' } +MCPOpenTelemetryObservabilityBackend >> isNoop [ + + ^ false +] + +{ #category : 'accessing' } +MCPOpenTelemetryObservabilityBackend >> logger [ + + logger ifNotNil: [ ^ logger ]. + logger := OTLoggerProvider current loggerNamed: 'mcp-pharo'. + ^ logger +] + +{ #category : 'accessing' } +MCPOpenTelemetryObservabilityBackend >> meter [ + + meter ifNotNil: [ ^ meter ]. + meter := OTMeterProvider current meterNamed: 'mcp-pharo'. + ^ meter +] + +{ #category : 'private' } +MCPOpenTelemetryObservabilityBackend >> nextCallId [ + + nextCallId := (nextCallId ifNil: [ 0 ]) + 1. + ^ nextCallId +] + +{ #category : 'private - budgets' } +MCPOpenTelemetryObservabilityBackend >> outputBudgetBytes [ + + ^ outputBudgetBytes ifNil: [ self defaultOutputBudgetBytes ] +] + +{ #category : 'private - budgets' } +MCPOpenTelemetryObservabilityBackend >> outputBudgetBytes: anInteger [ + + outputBudgetBytes := anInteger +] + +{ #category : 'private - instruments' } +MCPOpenTelemetryObservabilityBackend >> outputBudgetExceededCounter [ + + outputBudgetExceededCounter ifNotNil: [ ^ outputBudgetExceededCounter ]. + outputBudgetExceededCounter := self meter + counterNamed: 'mcp.tool.output_budget.exceeded' + unit: '1' + description: 'MCP tool calls exceeding output budget'. + ^ outputBudgetExceededCounter +] + +{ #category : 'private - budgets' } +MCPOpenTelemetryObservabilityBackend >> outputBudgetExplanationForByteCount: byteCount [ + + byteCount <= self outputBudgetBytes ifTrue: [ ^ '' ]. + ^ String streamContents: [ :stream | + stream + nextPutAll: 'Output '; + print: byteCount; + nextPutAll: ' bytes exceeds budget '; + print: self outputBudgetBytes; + nextPutAll: ' bytes; consider pagination, handles, or summary-first output' ] +] + +{ #category : 'private - instruments' } +MCPOpenTelemetryObservabilityBackend >> outputSizeHistogram [ + + outputSizeHistogram ifNotNil: [ ^ outputSizeHistogram ]. + outputSizeHistogram := self meter histogramNamed: 'mcp.tool.output.size' unit: 'By' description: 'MCP tool-call output size'. + ^ outputSizeHistogram +] + +{ #category : 'private - metrics' } +MCPOpenTelemetryObservabilityBackend >> recordMetricsForRecord: aRecord attributes: attributes [ + + self callCounter add: 1 attributes: attributes. + self durationHistogram record: aRecord durationMilliseconds attributes: attributes. + self inputSizeHistogram record: aRecord inputByteCount attributes: attributes. + self outputSizeHistogram record: aRecord outputByteCount attributes: attributes. + aRecord outputBudgetExceeded ifTrue: [ self outputBudgetExceededCounter add: 1 attributes: attributes ]. + aRecord status = 'error' ifTrue: [ + self errorCounter add: 1 attributes: (self errorAttributesForRecord: aRecord baseAttributes: attributes) ] +] + +{ #category : 'private - budgets' } +MCPOpenTelemetryObservabilityBackend >> recordOutputBudgetFor: aRecord result: aToolResult error: anError [ + + | byteCount explanation | + byteCount := self sizeInBytesForToolResult: aToolResult error: anError. + explanation := self outputBudgetExplanationForByteCount: byteCount. + aRecord outputByteCount: byteCount. + aRecord approximateOutputTokenCount: byteCount + 3 // 4. + aRecord outputBudgetBytes: self outputBudgetBytes. + aRecord outputBudgetExceeded: explanation isNotEmpty. + aRecord outputBudgetExplanation: explanation +] + +{ #category : 'recording' } +MCPOpenTelemetryObservabilityBackend >> recordSessionEndFor: anMCP [ + + self emitEventNamed: 'mcp.session.end' body: 'MCP session ended'. + self forceFlush +] + +{ #category : 'recording' } +MCPOpenTelemetryObservabilityBackend >> recordSessionStartFor: anMCP [ + + self emitEventNamed: 'mcp.session.start' body: 'MCP session started' +] + +{ #category : 'recording' } +MCPOpenTelemetryObservabilityBackend >> recordToolCallFinish: aRecord result: aToolResult error: anError [ + + | attributes finishedAt span | + aRecord ifNil: [ ^ nil ]. + span := self recordsById removeKey: aRecord id ifAbsent: [ nil ]. + self enabled ifFalse: [ + span ifNotNil: [ span end: DateAndTime now ]. + aRecord inputObject: nil. + ^ aRecord ]. + finishedAt := DateAndTime now. + self + finishRecord: aRecord + result: aToolResult + error: anError + at: finishedAt. + attributes := self attributesForRecord: aRecord. + self recordMetricsForRecord: aRecord attributes: attributes. + self + endSpan: span + withRecord: aRecord + error: anError + at: finishedAt. + ^ aRecord +] + +{ #category : 'recording' } +MCPOpenTelemetryObservabilityBackend >> recordToolCallStart: aToolName input: inputObject dispatchPath: dispatchPath context: contextDictionary [ + + | attributes record span | + self enabled ifFalse: [ ^ nil ]. + record := MCPToolCallRecord id: self nextCallId toolName: aToolName asString startedAt: DateAndTime now. + record inputObject: inputObject. + record inputByteCount: (inputObject ifNil: [ 0 ] ifNotNil: [ self sizeInBytesForObject: inputObject ]). + record dispatchPath: dispatchPath asString. + attributes := { + ('tool.name' -> record toolName). + ('mcp.dispatch_path' -> record dispatchPath) }. + span := (self tracer spanBuilderNamed: 'mcp.tool_call') + attributes: attributes; + startSpan. + self recordsById at: record id put: span. + ^ record +] + +{ #category : 'private' } +MCPOpenTelemetryObservabilityBackend >> recordsById [ + + recordsById ifNotNil: [ ^ recordsById ]. + recordsById := Dictionary new. + ^ recordsById +] + +{ #category : 'actions' } +MCPOpenTelemetryObservabilityBackend >> shutdown [ + + self forceFlush +] + +{ #category : 'private' } +MCPOpenTelemetryObservabilityBackend >> sizeInBytesForObject: anObject [ + + | jsonString | + anObject ifNil: [ ^ 0 ]. + jsonString := [ STONJSON toString: anObject ] + on: Error + do: [ :ignored | anObject asString ]. + ^ jsonString utf8Encoded size +] + +{ #category : 'private' } +MCPOpenTelemetryObservabilityBackend >> sizeInBytesForToolResult: aToolResult error: anError [ + + aToolResult ifNotNil: [ ^ self sizeInBytesForObject: aToolResult asJRPCJSON ]. + anError ifNil: [ ^ 0 ]. + ^ self sizeInBytesForObject: { + (#errorClass -> anError class name asString). + (#message -> (anError messageText ifNil: [ anError asString ])) } asDictionary +] + +{ #category : 'private' } +MCPOpenTelemetryObservabilityBackend >> statusForResult: aToolResult error: anError [ + + anError ifNotNil: [ ^ 'error' ]. + aToolResult ifNil: [ ^ 'error' ]. + ^ aToolResult isError + ifTrue: [ 'error' ] + ifFalse: [ 'ok' ] +] + +{ #category : 'displaying' } +MCPOpenTelemetryObservabilityBackend >> statusText [ + + ^ 'Observability exported through OpenTelemetry' +] + +{ #category : 'accessing' } +MCPOpenTelemetryObservabilityBackend >> tracer [ + + tracer ifNotNil: [ ^ tracer ]. + tracer := OTTracerProvider current tracerNamed: 'mcp-pharo'. + ^ tracer +] + +{ #category : 'private' } +MCPOpenTelemetryObservabilityBackend >> updateEventKindsForRecord: aRecord [ + + | kinds | + kinds := OrderedCollection new. + aRecord status = 'error' ifTrue: [ kinds add: 'error' ]. + aRecord outputBudgetExceeded ifTrue: [ kinds add: 'output_budget_exceeded' ]. + aRecord eventKinds: kinds asArray +] diff --git a/src/MCP-OpenTelemetry/package.st b/src/MCP-OpenTelemetry/package.st new file mode 100644 index 00000000..883eee10 --- /dev/null +++ b/src/MCP-OpenTelemetry/package.st @@ -0,0 +1 @@ +Package { #name : 'MCP-OpenTelemetry' } diff --git a/src/MCP-Tests/MCPTestCase.class.st b/src/MCP-Tests/MCPTestCase.class.st index 1a0b765a..c995d56b 100644 --- a/src/MCP-Tests/MCPTestCase.class.st +++ b/src/MCP-Tests/MCPTestCase.class.st @@ -44,14 +44,17 @@ MCPTestCase >> createClassNamed: aClassName superclassName: aSuperclassName slot { #category : 'private - methods' } MCPTestCase >> ensureMethodSource: aMethodSource protocol: aProtocol on: aBehavior [ - self withoutEpiceaDuring: [ aBehavior compile: aMethodSource classified: aProtocol ] + self withoutEpiceaDuring: [ + | selector | + selector := aBehavior compile: aMethodSource classified: aProtocol. + (aBehavior >> selector) reformat ] ] -{ #category : 'private - monitoring' } +{ #category : 'private - observability' } MCPTestCase >> mcpWithoutObservabilityExport [ ^ MCP new - monitoringExportDirectory: nil; + observabilityExportDirectory: nil; useContentAndStructuredContentToolResponses; yourself ] diff --git a/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st b/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st index 5a3d17bc..0402ba8e 100644 --- a/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st +++ b/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st @@ -4,28 +4,28 @@ Tests the MCP Spec dashboard presenter layout, actions, and refresh behavior. Class { #name : 'MCPDashboardPresenterTest', #superclass : 'MCPTestCase', - #category : 'MCP-UI-Tests-Monitoring', + #category : 'MCP-UI-Tests-Observability', #package : 'MCP-UI-Tests', - #tag : 'Monitoring' + #tag : 'Observability' } { #category : 'tests' } -MCPDashboardPresenterTest >> testAllMonitoringSectionsCollapsedRemovesOuterSplitter [ +MCPDashboardPresenterTest >> testAllObservabilitySectionsCollapsedRemovesOuterSplitter [ - | presenter layout monitoringLayout sectionsLayout | + | presenter layout observabilityLayout sectionsLayout | presenter := MCPDashboardPresenter new. presenter toggleMetricsSection. presenter toggleTracesSection. presenter toggleLogsSection. layout := presenter dashboardWidgetsLayout. - monitoringLayout := layout children second. - sectionsLayout := monitoringLayout children second. + observabilityLayout := layout children second. + sectionsLayout := observabilityLayout children second. - self deny: presenter hasExpandedMonitoringSection. + self deny: presenter hasExpandedObservabilitySection. self assert: layout class equals: SpBoxLayout. self assert: layout children size equals: 2. - self assert: monitoringLayout children size equals: 2. + self assert: observabilityLayout children size equals: 2. self assert: sectionsLayout children size equals: 3 ] @@ -44,7 +44,7 @@ MCPDashboardPresenterTest >> testCheckboxRowsIncludeLeftPadding [ | configurationLayout observabilityLayout | configurationLayout := MCPDashboardPresenter new configurationLayout. - observabilityLayout := MCPDashboardPresenter new monitoringControlsLayout. + observabilityLayout := MCPDashboardPresenter new observabilityControlsLayout. self assert: configurationLayout children size equals: 5. self assert: configurationLayout children third class equals: SpNullPresenter. @@ -60,7 +60,7 @@ MCPDashboardPresenterTest >> testCollapsedLogsSectionKeepsHeaderAndSplitsOpenSec presenter := MCPDashboardPresenter new. presenter toggleLogsSection. - sectionsLayout := presenter monitoringSectionsLayout. + sectionsLayout := presenter observabilitySectionsLayout. splitLayout := sectionsLayout children first. self assert: sectionsLayout class equals: SpBoxLayout. @@ -84,13 +84,13 @@ MCPDashboardPresenterTest >> testCollapsedMetricsSectionKeepsHeaderVisible [ ] { #category : 'tests' } -MCPDashboardPresenterTest >> testCollapsedMonitoringSectionUsesFixedHeaderRow [ +MCPDashboardPresenterTest >> testCollapsedObservabilitySectionUsesFixedHeaderRow [ | presenter sectionsLayout splitLayout | presenter := MCPDashboardPresenter new. presenter toggleMetricsSection. - sectionsLayout := presenter monitoringSectionsLayout. + sectionsLayout := presenter observabilitySectionsLayout. splitLayout := sectionsLayout children second. self assert: sectionsLayout class equals: SpBoxLayout. @@ -115,19 +115,19 @@ MCPDashboardPresenterTest >> testCollapsedSectionCanBeExpandedAgain [ { #category : 'tests' } MCPDashboardPresenterTest >> testCollapsedToolsSectionUsesFixedHeaderRow [ - | presenter layout monitoringLayout monitoringSectionsLayout | + | presenter layout observabilityLayout observabilitySectionsLayout | presenter := MCPDashboardPresenter new. presenter toggleToolsSection. layout := presenter dashboardWidgetsLayout. - monitoringLayout := layout children second. - monitoringSectionsLayout := monitoringLayout children second. + observabilityLayout := layout children second. + observabilitySectionsLayout := observabilityLayout children second. self assert: layout class equals: SpBoxLayout. self assert: layout children size equals: 2. self assert: layout children first children size equals: 1. - self assert: monitoringLayout class equals: SpBoxLayout. - self assert: monitoringSectionsLayout class equals: SpPanedLayout + self assert: observabilityLayout class equals: SpBoxLayout. + self assert: observabilitySectionsLayout class equals: SpPanedLayout ] { #category : 'tests' } @@ -137,7 +137,7 @@ MCPDashboardPresenterTest >> testCollapsedTracesSectionKeepsHeaderAndSplitsOpenS presenter := MCPDashboardPresenter new. presenter toggleTracesSection. - sectionsLayout := presenter monitoringSectionsLayout. + sectionsLayout := presenter observabilitySectionsLayout. lowerLayout := sectionsLayout children second. self assert: sectionsLayout class equals: SpPanedLayout. @@ -181,17 +181,17 @@ MCPDashboardPresenterTest >> testCollapsibleSectionsRemoveOnlyTheirBody [ { #category : 'tests' } MCPDashboardPresenterTest >> testDashboardWidgetsUseVerticalSplitters [ - | nestedSections presenter layout monitoringLayout monitoringSectionsLayout | + | nestedSections presenter layout observabilityLayout observabilitySectionsLayout | presenter := MCPDashboardPresenter new. layout := presenter dashboardWidgetsLayout. - monitoringLayout := layout children second. - monitoringSectionsLayout := monitoringLayout children second. - nestedSections := monitoringSectionsLayout children second. + observabilityLayout := layout children second. + observabilitySectionsLayout := observabilityLayout children second. + nestedSections := observabilitySectionsLayout children second. self assert: layout class equals: SpPanedLayout. self assert: layout isVertical. - self assert: monitoringSectionsLayout class equals: SpPanedLayout. - self assert: monitoringSectionsLayout isVertical. + self assert: observabilitySectionsLayout class equals: SpPanedLayout. + self assert: observabilitySectionsLayout isVertical. self assert: nestedSections class equals: SpPanedLayout. self assert: nestedSections isVertical ] @@ -220,22 +220,22 @@ MCPDashboardPresenterTest >> testDisclosureLabelsIncludeStateTitleAndCount [ ] { #category : 'tests' } -MCPDashboardPresenterTest >> testFreshMCPWithObservabilityEnabledExpandsMonitoringSections [ +MCPDashboardPresenterTest >> testFreshMCPWithNoopObservabilityCollapsesObservabilitySections [ | mcp presenter | mcp := MCP new. presenter := MCPDashboardPresenter on: mcp. presenter refreshView. - self assert: mcp monitoringEnabled. + self deny: mcp observabilityEnabled. self assert: presenter isToolsSectionExpanded. - self assert: presenter isMetricsSectionExpanded. - self assert: presenter isTracesSectionExpanded. - self assert: presenter isLogsSectionExpanded. + self deny: presenter isMetricsSectionExpanded. + self deny: presenter isTracesSectionExpanded. + self deny: presenter isLogsSectionExpanded. self assert: presenter toolsWidgetLayout children size equals: 2. - self assert: presenter metricsWidgetLayout children size equals: 2. - self assert: presenter tracesWidgetLayout children size equals: 2. - self assert: presenter logsWidgetLayout children size equals: 2 + self assert: presenter metricsWidgetLayout children size equals: 1. + self assert: presenter tracesWidgetLayout children size equals: 1. + self assert: presenter logsWidgetLayout children size equals: 1 ] { #category : 'tests' } @@ -259,38 +259,38 @@ MCPDashboardPresenterTest >> testHeaderLayoutUsesStatusAndEndpointRows [ ] { #category : 'tests' } -MCPDashboardPresenterTest >> testInitialSectionsOpenForObservabilityEnabled [ +MCPDashboardPresenterTest >> testInitialSectionsCollapseAfterObservabilityIsDisabled [ - | mcp presenter | + | mcp presenter record | mcp := MCP new. - mcp monitoringEnabled: true. + mcp observabilityEnabled: true. + record := mcp observability recordToolCallStart: 'failing-tool'. + mcp observability recordToolCallFinish: record result: (MCPStructureErrorToolResult content: #( )) error: Error new. + mcp observabilityEnabled: false. presenter := MCPDashboardPresenter on: mcp. presenter refreshView. - self assert: presenter isMetricsSectionExpanded. - self assert: presenter isTracesSectionExpanded. - self assert: presenter isLogsSectionExpanded. - self assert: presenter metricsWidgetLayout children size equals: 2. - self assert: presenter tracesWidgetLayout children size equals: 2. - self assert: presenter logsWidgetLayout children size equals: 2 + self deny: mcp observabilityEnabled. + self deny: presenter isMetricsSectionExpanded. + self deny: presenter isTracesSectionExpanded. + self deny: presenter isLogsSectionExpanded ] { #category : 'tests' } -MCPDashboardPresenterTest >> testInitialSectionsOpenForStoredMonitoringData [ +MCPDashboardPresenterTest >> testInitialSectionsOpenForObservabilityEnabled [ - | mcp presenter record | + | mcp presenter | mcp := MCP new. - mcp monitoringEnabled: true. - record := mcp monitoring recordToolCallStart: 'failing-tool'. - mcp monitoring recordToolCallFinish: record result: (MCPStructureErrorToolResult content: #( )) error: Error new. - mcp monitoringEnabled: false. + mcp observabilityEnabled: true. presenter := MCPDashboardPresenter on: mcp. presenter refreshView. - self deny: mcp monitoringEnabled. self assert: presenter isMetricsSectionExpanded. self assert: presenter isTracesSectionExpanded. - self assert: presenter isLogsSectionExpanded + self assert: presenter isLogsSectionExpanded. + self assert: presenter metricsWidgetLayout children size equals: 2. + self assert: presenter tracesWidgetLayout children size equals: 2. + self assert: presenter logsWidgetLayout children size equals: 2 ] { #category : 'tests' } @@ -321,11 +321,7 @@ MCPDashboardPresenterTest >> testManualExpansionWorksAfterInitialStateIsChosen [ presenter := MCPDashboardPresenter on: mcp. presenter refreshView. - self assert: presenter isMetricsSectionExpanded. - presenter toggleMetricsSection. - presenter refreshView. self deny: presenter isMetricsSectionExpanded. - self assert: presenter metricsWidgetLayout children size equals: 1. presenter toggleMetricsSection. presenter refreshView. self assert: presenter isMetricsSectionExpanded. @@ -346,7 +342,7 @@ MCPDashboardPresenterTest >> testMetricsTableHasExpectedColumns [ MCPDashboardPresenterTest >> testObservabilityControlsStartWithEnableRefreshAutoRefreshAndClear [ | layout | - layout := MCPDashboardPresenter new monitoringControlsLayout. + layout := MCPDashboardPresenter new observabilityControlsLayout. self assert: layout children size equals: 6. self assert: layout children second label equals: 'Enable observability'. @@ -362,11 +358,11 @@ MCPDashboardPresenterTest >> testObservabilityToggleUpdatesModelImmediately [ mcp := MCP new. presenter := MCPDashboardPresenter on: mcp. - self assert: mcp monitoringEnabled. - presenter observabilityChangedTo: false. - self deny: mcp monitoringEnabled. + self deny: mcp observabilityEnabled. presenter observabilityChangedTo: true. - self assert: mcp monitoringEnabled + self assert: mcp observabilityEnabled. + presenter observabilityChangedTo: false. + self deny: mcp observabilityEnabled ] { #category : 'tests' } @@ -399,9 +395,9 @@ MCPDashboardPresenterTest >> testRefreshViewUsesShortSectionLabels [ logsHeader := presenter instVarNamed: #logsHeaderButton. self assert: toolsHeader label equals: ('[-] Tools ({1})' format: { mcp sortedTools size asString }). - self assert: metricsHeader label equals: '[-] Metrics (0)'. - self assert: tracesHeader label equals: '[-] Traces (0)'. - self assert: logsHeader label equals: '[-] Logs (0)' + self assert: metricsHeader label equals: '[+] Metrics (0)'. + self assert: tracesHeader label equals: '[+] Traces (0)'. + self assert: logsHeader label equals: '[+] Logs (0)' ] { #category : 'tests' } diff --git a/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st b/src/MCP-UI-Tests/MCPLocalObservabilityBackendTest.class.st similarity index 68% rename from src/MCP-UI-Tests/MCPMonitoringStateTest.class.st rename to src/MCP-UI-Tests/MCPLocalObservabilityBackendTest.class.st index 97f12d5c..1fba0405 100644 --- a/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st +++ b/src/MCP-UI-Tests/MCPLocalObservabilityBackendTest.class.st @@ -1,19 +1,27 @@ " -Tests for the in-memory MCP monitoring state and its integration at the MCP tool-call boundary. +Tests for the in-memory MCP observability state and its integration at the MCP tool-call boundary. " Class { - #name : 'MCPMonitoringStateTest', + #name : 'MCPLocalObservabilityBackendTest', #superclass : 'MCPTestCase', - #category : 'MCP-UI-Tests-Monitoring', + #category : 'MCP-UI-Tests-Observability', #package : 'MCP-UI-Tests', - #tag : 'Monitoring' + #tag : 'Observability' } { #category : 'tests' } -MCPMonitoringStateTest >> testEnabledByDefaultRecords [ +MCPLocalObservabilityBackendTest >> testDefaultExportDirectoryIsImageLocal [ + + self + assert: MCPLocalObservabilityBackend defaultExportDirectory + equals: Smalltalk imageFile asFileReference parent / 'pharo-local' / 'mcp' / 'observability' +] + +{ #category : 'tests' } +MCPLocalObservabilityBackendTest >> testEnabledByDefaultRecords [ | record state | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. record := state recordToolCallStart: 'method_metadata_search'. self assert: state enabled. self assert: record notNil. @@ -22,20 +30,20 @@ MCPMonitoringStateTest >> testEnabledByDefaultRecords [ ] { #category : 'tests' } -MCPMonitoringStateTest >> testExportDisabledByDefault [ +MCPLocalObservabilityBackendTest >> testExportDisabledByDefault [ | state | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. self deny: state exportEnabled. self assert: state exportDirectory isNil. self assert: state exportInstanceDirectory isNil ] { #category : 'tests' } -MCPMonitoringStateTest >> testInputSnapshotRedactsSensitiveKeys [ +MCPLocalObservabilityBackendTest >> testInputSnapshotRedactsSensitiveKeys [ | input record state | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. state enable. input := { (#limit -> 10). @@ -51,10 +59,10 @@ MCPMonitoringStateTest >> testInputSnapshotRedactsSensitiveKeys [ ] { #category : 'tests' } -MCPMonitoringStateTest >> testLargeOutputExceedingBudgetBecomesTraceAndGuidance [ +MCPLocalObservabilityBackendTest >> testLargeOutputExceedingBudgetBecomesTraceAndGuidance [ | budgetBytes exceedsBudget largeText record result state stats | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. state outputBudgetBytes: 80. state enable. largeText := String new: 200 withAll: $x. @@ -80,52 +88,75 @@ MCPMonitoringStateTest >> testLargeOutputExceedingBudgetBecomesTraceAndGuidance ] { #category : 'tests' } -MCPMonitoringStateTest >> testMCPConfiguresMonitoringExportDirectory [ +MCPLocalObservabilityBackendTest >> testMCPConfiguresObservabilityExportDirectory [ | defaultDirectory directory mcp | directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). [ mcp := MCP new. - defaultDirectory := MCPMonitoringState defaultExportDirectory. - self assert: mcp monitoringEnabled. - self assert: mcp monitoringExportDirectory equals: defaultDirectory. - mcp monitoringExportDirectory: directory. - self assert: mcp monitoringExportDirectory equals: directory. - self assert: (mcp monitoring resourceMetadata at: #'pharo.majorVersion') equals: PharoCompatibility currentMajorVersion. - self assert: (mcp monitoring resourceMetadata includesKey: #'mcp.port') ] ensure: [ + defaultDirectory := MCPLocalObservabilityBackend defaultExportDirectory. + self deny: mcp observabilityEnabled. + self assert: mcp observability class equals: MCPNoopObservabilityBackend. + self assert: mcp observabilityExportDirectory isNil. + mcp observabilityEnabled: true. + self assert: mcp observabilityEnabled. + self assert: mcp observabilityExportDirectory equals: defaultDirectory. + mcp observabilityExportDirectory: directory. + self assert: mcp observabilityExportDirectory equals: directory. + self assert: (mcp observability resourceMetadata at: #'pharo.majorVersion') equals: PharoCompatibility currentMajorVersion. + self assert: (mcp observability resourceMetadata includesKey: #'mcp.port') ] ensure: [ directory exists ifTrue: [ directory deleteAll ] ] ] { #category : 'tests' } -MCPMonitoringStateTest >> testMCPRecordsToolCallsWhenMonitoringIsEnabled [ +MCPLocalObservabilityBackendTest >> testMCPRecordsToolCallDispatchPath [ + + | mcp records | + mcp := MCP new. + mcp observabilityEnabled: true. + + mcp rpcToolCall: 'tool_call' withParams: { + (#toolName -> 'package_search'). + (#arguments -> { (#limit -> 0) } asDictionary) } asDictionary. + records := mcp observability recentCallRecords. + + self assert: records size equals: 2. + self assert: records first toolName equals: 'tool_call'. + self assert: records first dispatchPath equals: 'direct'. + self assert: records second toolName equals: 'package_search'. + self assert: records second dispatchPath equals: 'via_tool_call' +] + +{ #category : 'tests' } +MCPLocalObservabilityBackendTest >> testMCPRecordsToolCallsWhenObservabilityIsEnabled [ | directory mcp record result stats | directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). [ directory exists ifTrue: [ directory deleteAll ]. mcp := MCP new. - mcp monitoringExportDirectory: directory. + mcp observabilityExportDirectory: directory. result := mcp rpcToolCall: 'package_search' withParams: { (#limit -> 0) } asDictionary. - stats := mcp monitoringMetrics first. - record := mcp monitoring recentCallRecords first. + stats := mcp observabilityMetrics first. + record := mcp observability recentCallRecords first. self deny: (result at: #isError ifAbsent: [ false ]). - self assert: mcp monitoring totalCallCount equals: 1. + self assert: mcp observability totalCallCount equals: 1. self assert: stats toolName equals: 'package_search'. self assert: stats successCount equals: 1. self assert: record inputSnapshot isNil. self assert: record outputSnapshot isNil. self assert: record inputObject isNil. - self assert: mcp monitoringLogs notEmpty. - self assert: mcp monitoring logFileReference exists. - self assert: mcp monitoring metricsFileReference exists ] ensure: [ directory exists ifTrue: [ directory deleteAll ] ] + self assert: mcp observabilityLogs notEmpty. + self assert: mcp observability logFileReference exists. + self assert: mcp observability metricsFileReference exists ] ensure: [ directory exists ifTrue: [ directory deleteAll ] ] ] { #category : 'tests' } -MCPMonitoringStateTest >> testOutlierExplanationUsesPriorAverage [ +MCPLocalObservabilityBackendTest >> testOutlierExplanationUsesPriorAverage [ | state stats start record explanation | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. state minimumOutlierMilliseconds: 1. state outlierFactor: 2. stats := MCPToolCallStats forToolNamed: 'slow-tool'. @@ -143,10 +174,10 @@ MCPMonitoringStateTest >> testOutlierExplanationUsesPriorAverage [ ] { #category : 'tests' } -MCPMonitoringStateTest >> testRecordsErrorCall [ +MCPLocalObservabilityBackendTest >> testRecordsErrorCall [ | recentCall record state stats | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. state enable. record := state recordToolCallStart: 'method_compile'. state recordToolCallFinish: record result: nil error: Error new. @@ -156,6 +187,7 @@ MCPMonitoringStateTest >> testRecordsErrorCall [ self assert: state totalCallCount equals: 1. self assert: state totalErrorCount equals: 1. self assert: stats errorCount equals: 1. + self assert: (stats errorCountsByClass at: 'Error') equals: 1. self assert: recentCall status equals: 'error'. self assert: recentCall errorClass equals: 'Error'. self assert: (recentCall outputSnapshot at: #errorClass) equals: 'Error'. @@ -165,10 +197,10 @@ MCPMonitoringStateTest >> testRecordsErrorCall [ ] { #category : 'tests' } -MCPMonitoringStateTest >> testRecordsOutputBudgetMetadata [ +MCPLocalObservabilityBackendTest >> testRecordsOutputBudgetMetadata [ | record result state stats recordData statsData | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. state enable. record := state recordToolCallStart: 'method_metadata_search'. result := MCPStructureCallToolResult content: #( ) structuredContent: { (#summary -> 'Small output') } asDictionary. @@ -178,6 +210,7 @@ MCPMonitoringStateTest >> testRecordsOutputBudgetMetadata [ statsData := stats asDictionary. self assert: record outputByteCount > 0. + self assert: record inputByteCount equals: 0. self assert: record approximateOutputTokenCount equals: (state approximateOutputTokensForBytes: record outputByteCount). self deny: record outputBudgetExceeded. self assert: record outputBudgetExplanation isEmpty. @@ -186,14 +219,16 @@ MCPMonitoringStateTest >> testRecordsOutputBudgetMetadata [ self assert: stats outputBudgetExceededCount equals: 0. self assert: (recordData at: #outputBytes) equals: record outputByteCount. self assert: (recordData at: #approximateOutputTokens) equals: record approximateOutputTokenCount. - self assert: (statsData at: #lastOutputBytes) equals: record outputByteCount + self assert: (statsData at: #lastOutputBytes) equals: record outputByteCount. + self assert: (statsData at: #firstCallAt) isNotEmpty. + self assert: (statsData at: #lastCallAt) isNotEmpty ] { #category : 'tests' } -MCPMonitoringStateTest >> testRecordsSuccessfulCallMetricsAndLogs [ +MCPLocalObservabilityBackendTest >> testRecordsSuccessfulCallMetricsAndLogs [ | state record result stats | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. state enable. record := state recordToolCallStart: 'method_metadata_search'. result := MCPStructureCallToolResult content: #( ) structuredContent: { (#summary -> 'Listed methods') } asDictionary. @@ -213,13 +248,32 @@ MCPMonitoringStateTest >> testRecordsSuccessfulCallMetricsAndLogs [ ] { #category : 'tests' } -MCPMonitoringStateTest >> testTraceExportSkipsNonTraceRecords [ +MCPLocalObservabilityBackendTest >> testSessionLifecycleWritesResourceMarkers [ + + | directory resource state | + directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). + [ + directory exists ifTrue: [ directory deleteAll ]. + state := MCPLocalObservabilityBackend new. + state exportDirectory: directory. + state recordSessionStartFor: nil. + resource := STONJSON fromString: state resourceFileReference contents. + self assert: (resource includesKey: 'sessionStartedAt'). + self deny: (resource includesKey: 'sessionEndedAt'). + state recordSessionEndFor: nil. + resource := STONJSON fromString: state resourceFileReference contents. + self assert: (resource includesKey: 'sessionStartedAt'). + self assert: (resource includesKey: 'sessionEndedAt') ] ensure: [ directory exists ifTrue: [ directory deleteAll ] ] +] + +{ #category : 'tests' } +MCPLocalObservabilityBackendTest >> testTraceExportSkipsNonTraceRecords [ | directory record result state | directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). [ directory exists ifTrue: [ directory deleteAll ]. - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. state exportDirectory: directory. state outputBudgetBytes: 10000. record := state recordToolCallStart: 'package_search'. @@ -232,13 +286,13 @@ MCPMonitoringStateTest >> testTraceExportSkipsNonTraceRecords [ ] { #category : 'tests' } -MCPMonitoringStateTest >> testTraceExportWritesOverBudgetRecordAsJSONLine [ +MCPLocalObservabilityBackendTest >> testTraceExportWritesOverBudgetRecordAsJSONLine [ | directory input line output payload record resource result state traceFile | directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). [ directory exists ifTrue: [ directory deleteAll ]. - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. state resourceMetadata: { (#'pharo.majorVersion' -> 13). (#'mcp.port' -> 9013) } asDictionary. @@ -266,6 +320,9 @@ MCPMonitoringStateTest >> testTraceExportWritesOverBudgetRecordAsJSONLine [ payload := STONJSON fromString: line. self assert: (payload at: 'event.name') equals: 'mcp.tool_call.output_budget_exceeded'. self assert: (payload at: 'toolName') equals: 'method_source_search'. + self assert: (payload at: 'dispatchPath') equals: 'direct'. + self assert: ((payload at: 'event.kinds') includes: 'output_budget_exceeded'). + self assert: (payload at: 'inputBytes') > 0. self assert: (payload at: 'outputBudgetBytes') equals: 80. self deny: (payload includesKey: 'outputBudgetExceeded'). self deny: (payload includesKey: 'summary'). @@ -276,10 +333,10 @@ MCPMonitoringStateTest >> testTraceExportWritesOverBudgetRecordAsJSONLine [ ] { #category : 'tests' } -MCPMonitoringStateTest >> testTraceRecordsIncludeErrorsAndOutliersOnly [ +MCPLocalObservabilityBackendTest >> testTraceRecordsIncludeErrorsAndOutliersOnly [ | error normal outlier start state traces | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. start := DateAndTime now. normal := MCPToolCallRecord id: 1 toolName: 'method_metadata_search' startedAt: start. normal finishAt: start durationMilliseconds: 5 status: 'ok'. @@ -304,20 +361,20 @@ MCPMonitoringStateTest >> testTraceRecordsIncludeErrorsAndOutliersOnly [ ] { #category : 'tests' } -MCPMonitoringStateTest >> testTraceSnapshotForCollectionAddsTruncationMarker [ +MCPLocalObservabilityBackendTest >> testTraceSnapshotForCollectionAddsTruncationMarker [ | snapshot state | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. snapshot := state traceSnapshotFor: (1 to: state maxTraceCollectionItems + 2) asArray. self assert: snapshot size equals: state maxTraceCollectionItems + 1. self assert: snapshot last equals: 'truncated 2 items omitted' ] { #category : 'tests' } -MCPMonitoringStateTest >> testTraceSnapshotHasCompactErrorShape [ +MCPLocalObservabilityBackendTest >> testTraceSnapshotHasCompactErrorShape [ | errorData output record result state | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. state outputBudgetBytes: 10000. record := state recordToolCallStart: 'image_evaluate'. errorData := { diff --git a/src/MCP-UI/MCPDashboardPresenter.class.st b/src/MCP-UI/MCPDashboardPresenter.class.st index a74be601..e2d1cad7 100644 --- a/src/MCP-UI/MCPDashboardPresenter.class.st +++ b/src/MCP-UI/MCPDashboardPresenter.class.st @@ -18,8 +18,8 @@ Class { 'stopButton', 'restartButton', 'toolsTable', - 'monitoringCheckBox', - 'clearMonitoringButton', + 'observabilityCheckBox', + 'clearObservabilityButton', 'metricsHeaderButton', 'metricsTable', 'tracesHeaderButton', @@ -55,11 +55,11 @@ MCPDashboardPresenter class >> openOn: anMCP [ ] { #category : 'layout' } -MCPDashboardPresenter >> addMonitoringWidgetsLayoutTo: aBoxLayout [ +MCPDashboardPresenter >> addObservabilityWidgetsLayoutTo: aBoxLayout [ - self hasExpandedMonitoringSection - ifTrue: [ aBoxLayout add: self monitoringWidgetsLayout ] - ifFalse: [ aBoxLayout add: self monitoringWidgetsLayout expand: false ] + self hasExpandedObservabilitySection + ifTrue: [ aBoxLayout add: self observabilityWidgetsLayout ] + ifFalse: [ aBoxLayout add: self observabilityWidgetsLayout expand: false ] ] { #category : 'layout' } @@ -92,7 +92,7 @@ MCPDashboardPresenter >> applyConfiguration [ ] { #category : 'collapsing' } -MCPDashboardPresenter >> areAllMonitoringSectionsExpanded [ +MCPDashboardPresenter >> areAllObservabilitySectionsExpanded [ ^ self isMetricsSectionExpanded and: [ self isTracesSectionExpanded and: [ self isLogsSectionExpanded ] ] ] @@ -121,9 +121,9 @@ MCPDashboardPresenter >> autoRefreshLoop [ ] { #category : 'actions' } -MCPDashboardPresenter >> clearMonitoring [ +MCPDashboardPresenter >> clearObservability [ - self model clearMonitoring. + self model clearObservability. self refreshView ] @@ -153,7 +153,7 @@ MCPDashboardPresenter >> connectPresenters [ applyButton action: [ self applyConfiguration ]. refreshButton action: [ self refreshView ]. autoRefreshCheckBox whenChangedDo: [ :enabled | self autoRefreshChangedTo: enabled ]. - monitoringCheckBox whenChangedDo: [ :enabled | self observabilityChangedTo: enabled ]. + observabilityCheckBox whenChangedDo: [ :enabled | self observabilityChangedTo: enabled ]. toolsHeaderButton action: [ self toggleToolsSection ]. metricsHeaderButton action: [ self toggleMetricsSection ]. tracesHeaderButton action: [ self toggleTracesSection ]. @@ -162,7 +162,7 @@ MCPDashboardPresenter >> connectPresenters [ startButton action: [ self startServer ]. stopButton action: [ self stopServer ]. restartButton action: [ self restartServer ]. - clearMonitoringButton action: [ self clearMonitoring ] + clearObservabilityButton action: [ self clearObservability ] ] { #category : 'layout' } @@ -171,18 +171,18 @@ MCPDashboardPresenter >> dashboardWidgetsBoxLayout [ | widgetLayout | widgetLayout := SpBoxLayout newTopToBottom. self addSectionLayout: self toolsWidgetLayout expanded: self isToolsSectionExpanded to: widgetLayout. - self addMonitoringWidgetsLayoutTo: widgetLayout. + self addObservabilityWidgetsLayoutTo: widgetLayout. ^ widgetLayout ] { #category : 'layout' } MCPDashboardPresenter >> dashboardWidgetsLayout [ - (self isToolsSectionExpanded and: [ self hasExpandedMonitoringSection ]) ifTrue: [ + (self isToolsSectionExpanded and: [ self hasExpandedObservabilitySection ]) ifTrue: [ ^ SpPanedLayout newTopToBottom positionOfSlider: 42 percent; first: self toolsWidgetLayout; - second: self monitoringWidgetsLayout; + second: self observabilityWidgetsLayout; yourself ]. ^ self dashboardWidgetsBoxLayout ] @@ -219,7 +219,7 @@ MCPDashboardPresenter >> disclosureLabelFor: title count: count expanded: expand ] { #category : 'collapsing' } -MCPDashboardPresenter >> expandedMonitoringSectionCount [ +MCPDashboardPresenter >> expandedObservabilitySectionCount [ | count | count := 0. @@ -230,7 +230,7 @@ MCPDashboardPresenter >> expandedMonitoringSectionCount [ ] { #category : 'collapsing' } -MCPDashboardPresenter >> hasExpandedMonitoringSection [ +MCPDashboardPresenter >> hasExpandedObservabilitySection [ ^ self isMetricsSectionExpanded or: [ self isTracesSectionExpanded or: [ self isLogsSectionExpanded ] ] ] @@ -275,8 +275,8 @@ MCPDashboardPresenter >> initializePresenters [ stopButton := self instantiate: SpButtonPresenter. restartButton := self instantiate: SpButtonPresenter. toolsTable := self newTable. - monitoringCheckBox := self instantiate: SpCheckBoxPresenter. - clearMonitoringButton := self instantiate: SpButtonPresenter. + observabilityCheckBox := self instantiate: SpCheckBoxPresenter. + clearObservabilityButton := self instantiate: SpButtonPresenter. metricsHeaderButton := self instantiate: SpButtonPresenter. metricsTable := self newTable. tracesHeaderButton := self instantiate: SpButtonPresenter. @@ -295,8 +295,8 @@ MCPDashboardPresenter >> initializePresenters [ startButton label: 'Start'. stopButton label: 'Stop'. restartButton label: 'Restart'. - monitoringCheckBox label: 'Enable observability'. - clearMonitoringButton label: 'Clear'. + observabilityCheckBox label: 'Enable observability'. + clearObservabilityButton label: 'Clear'. toolsTable addColumn: (SpStringTableColumn title: 'Tool' evaluated: #name); @@ -323,9 +323,9 @@ MCPDashboardPresenter >> initializeSectionExpansionFor: mcp [ sectionExpansionInitialized ifTrue: [ ^ self ]. toolsSectionExpanded := mcp sortedTools notEmpty. - metricsSectionExpanded := mcp monitoringEnabled or: [ mcp monitoringMetrics notEmpty ]. - tracesSectionExpanded := mcp monitoringEnabled or: [ mcp monitoringTraces notEmpty ]. - logsSectionExpanded := mcp monitoringEnabled or: [ mcp monitoringLogs notEmpty ]. + metricsSectionExpanded := mcp observabilityEnabled or: [ mcp observabilityMetrics notEmpty ]. + tracesSectionExpanded := mcp observabilityEnabled or: [ mcp observabilityTraces notEmpty ]. + logsSectionExpanded := mcp observabilityEnabled or: [ mcp observabilityLogs notEmpty ]. sectionExpansionInitialized := true ] @@ -408,7 +408,7 @@ MCPDashboardPresenter >> lifecycleActionsLayout [ yourself ] -{ #category : 'private - monitoring' } +{ #category : 'private - observability' } MCPDashboardPresenter >> logsForDisplayFrom: logEntries [ ^ logEntries reversed @@ -442,29 +442,29 @@ MCPDashboardPresenter >> modelChanged [ ] { #category : 'layout' } -MCPDashboardPresenter >> monitoringControlsLayout [ +MCPDashboardPresenter >> observabilityControlsLayout [ | controlsLayout | controlsLayout := SpBoxLayout newLeftToRight. - self addPaddedCheckbox: monitoringCheckBox width: 190 to: controlsLayout. + self addPaddedCheckbox: observabilityCheckBox width: 190 to: controlsLayout. controlsLayout add: refreshButton withConstraints: [ :constraints | constraints width: 110 ]. self addPaddedCheckbox: autoRefreshCheckBox width: 150 to: controlsLayout. - controlsLayout add: clearMonitoringButton withConstraints: [ :constraints | constraints width: 90 ]. + controlsLayout add: clearObservabilityButton withConstraints: [ :constraints | constraints width: 90 ]. ^ controlsLayout ] { #category : 'layout' } -MCPDashboardPresenter >> monitoringHeaderLayout [ +MCPDashboardPresenter >> observabilityHeaderLayout [ ^ SpBoxLayout newTopToBottom - add: self monitoringControlsLayout withConstraints: [ :constraints | constraints height: 30 ]; + add: self observabilityControlsLayout withConstraints: [ :constraints | constraints height: 30 ]; yourself ] { #category : 'layout' } -MCPDashboardPresenter >> monitoringSectionsLayout [ +MCPDashboardPresenter >> observabilitySectionsLayout [ - self areAllMonitoringSectionsExpanded ifTrue: [ + self areAllObservabilitySectionsExpanded ifTrue: [ ^ SpPanedLayout newTopToBottom positionOfSlider: 42 percent; first: self metricsWidgetLayout; @@ -474,12 +474,12 @@ MCPDashboardPresenter >> monitoringSectionsLayout [ second: self logsWidgetLayout; yourself); yourself ]. - self expandedMonitoringSectionCount = 2 ifTrue: [ ^ self twoExpandedMonitoringSectionsLayout ]. - ^ self monitoringWidgetsBoxLayout + self expandedObservabilitySectionCount = 2 ifTrue: [ ^ self twoExpandedObservabilitySectionsLayout ]. + ^ self observabilityWidgetsBoxLayout ] { #category : 'layout' } -MCPDashboardPresenter >> monitoringWidgetsBoxLayout [ +MCPDashboardPresenter >> observabilityWidgetsBoxLayout [ | widgetLayout | widgetLayout := SpBoxLayout newTopToBottom. @@ -490,11 +490,11 @@ MCPDashboardPresenter >> monitoringWidgetsBoxLayout [ ] { #category : 'layout' } -MCPDashboardPresenter >> monitoringWidgetsLayout [ +MCPDashboardPresenter >> observabilityWidgetsLayout [ ^ SpBoxLayout newTopToBottom - add: self monitoringHeaderLayout expand: false; - add: self monitoringSectionsLayout; + add: self observabilityHeaderLayout expand: false; + add: self observabilitySectionsLayout; yourself ] @@ -503,8 +503,8 @@ MCPDashboardPresenter >> observabilityChangedTo: enabled [ | mcp | mcp := self model ifNil: [ ^ self ]. - mcp monitoringEnabled = enabled ifTrue: [ ^ self ]. - mcp monitoringEnabled: enabled. + mcp observabilityEnabled = enabled ifTrue: [ ^ self ]. + mcp observabilityEnabled: enabled. self refreshView ] @@ -540,9 +540,9 @@ MCPDashboardPresenter >> refreshView [ self configureTracesTableMenu. self initializeSectionExpansionFor: mcp. tools := mcp sortedTools. - metrics := mcp monitoringMetrics. - traces := mcp monitoringTraces. - logs := mcp monitoringLogs. + metrics := mcp observabilityMetrics. + traces := mcp observabilityTraces. + logs := mcp observabilityLogs. isRunning := mcp isRunning. isListening := mcp isListening. statusText := self serverStatusLabelForRunning: isRunning listening: isListening. @@ -557,7 +557,7 @@ MCPDashboardPresenter >> refreshView [ debugCheckBox state: mcp debugMode. toolsHeaderButton label: (self disclosureLabelFor: 'Tools' count: tools size expanded: self isToolsSectionExpanded). toolsTable items: tools. - monitoringCheckBox state: mcp monitoringEnabled. + observabilityCheckBox state: mcp observabilityEnabled. metricsHeaderButton label: (self disclosureLabelFor: 'Metrics' count: metrics size expanded: self isMetricsSectionExpanded). metricsTable items: metrics. tracesHeaderButton label: (self disclosureLabelFor: 'Traces' count: traces size expanded: self isTracesSectionExpanded). @@ -741,7 +741,7 @@ MCPDashboardPresenter >> tracesWidgetLayout [ ] { #category : 'layout' } -MCPDashboardPresenter >> twoExpandedMonitoringSectionsLayout [ +MCPDashboardPresenter >> twoExpandedObservabilitySectionsLayout [ self isMetricsSectionExpanded ifFalse: [ ^ SpBoxLayout newTopToBottom @@ -770,5 +770,5 @@ MCPDashboardPresenter >> twoExpandedMonitoringSectionsLayout [ yourself); add: self logsWidgetLayout expand: false; yourself ]. - ^ self monitoringWidgetsBoxLayout + ^ self observabilityWidgetsBoxLayout ] diff --git a/src/MCP/MCP.class.st b/src/MCP/MCP.class.st index 62661058..9060350a 100644 --- a/src/MCP/MCP.class.st +++ b/src/MCP/MCP.class.st @@ -14,7 +14,7 @@ Class { #instVars : [ 'server', 'toolsList', - 'monitoring', + 'observability', 'toolRegistry', 'toolResponseFields' ], @@ -43,10 +43,10 @@ MCP class >> supportedProtocolVersions [ ^ #( '2025-03-26' '2025-06-18' '2025-11-25' ) ] -{ #category : 'monitoring' } -MCP >> clearMonitoring [ +{ #category : 'observability' } +MCP >> clearObservability [ - self monitoring clear + self observability clear ] { #category : 'configuration' } @@ -98,6 +98,31 @@ MCP >> deferSaveImageAfterCurrentResponse [ server deferAfterCurrentResponse: [ self saveImageSession ] ] +{ #category : 'private - tools' } +MCP >> dispatchToolCallWith: callTool arguments: arguments observabilityRecord: observabilityRecord [ + + | callRequest request targetResponse targetTool wrapperResult | + request := callTool requestFromToolCallArguments: arguments. + callRequest := callTool parsedRequestFromToolRequest: request. + self validateToolCallRequest: callRequest wrapperTool: callTool. + targetTool := self targetToolForCallRequest: callRequest. + targetResponse := self + executeTool: targetTool + named: targetTool name + withParams: callRequest arguments + dispatchPath: 'via_tool_call'. + wrapperResult := self toolCallWrapperResultFor: callTool request: callRequest. + self finishToolCallWrapper: observabilityRecord result: wrapperResult. + ^ targetResponse +] + +{ #category : 'observability' } +MCP >> ensureLocalObservabilityBackend [ + + self observability isNoop ifTrue: [ observability := self newLocalObservabilityBackend ]. + ^ self observability +] + { #category : 'private - start' } MCP >> ensureServerIsListening [ @@ -111,26 +136,58 @@ MCP >> ensureServerIsListening [ { #category : 'private - tools' } MCP >> executeTool: tool named: toolName withParams: arguments [ - | monitorRecord request result toolResult | - monitorRecord := self monitoring recordToolCallStart: toolName input: arguments. + ^ self + executeTool: tool + named: toolName + withParams: arguments + dispatchPath: 'direct' +] + +{ #category : 'private - tools' } +MCP >> executeTool: tool named: toolName withParams: arguments dispatchPath: dispatchPath [ + + | observabilityRecord request result toolResult | + observabilityRecord := self observability + recordToolCallStart: toolName + input: arguments + dispatchPath: dispatchPath + context: Dictionary new. ^ [ request := tool requestFromToolCallArguments: arguments. result := tool executeWithRequest: request. toolResult := result asMCPStructureCallToolResult. self saveImageAfterSuccessfulTool: tool result: toolResult. - self monitoring recordToolCallFinish: monitorRecord result: toolResult error: nil. + self observability recordToolCallFinish: observabilityRecord result: toolResult error: nil. toolResult asJRPCJSONForResponseFields: self toolResponseFields ] on: Exception do: [ :error | | errorResult | error mcpIsJRPCError ifTrue: [ - self monitoring recordToolCallFinish: monitorRecord result: nil error: error. + self observability recordToolCallFinish: observabilityRecord result: nil error: error. error pass ]. errorResult := tool errorResultFor: error. - self monitoring recordToolCallFinish: monitorRecord result: errorResult error: error. + self observability recordToolCallFinish: observabilityRecord result: errorResult error: error. errorResult asJRPCJSONForResponseFields: self toolResponseFields ] ] +{ #category : 'private - tools' } +MCP >> finishToolCallWrapper: observabilityRecord result: wrapperResult [ + + self observability recordToolCallFinish: observabilityRecord result: wrapperResult asMCPStructureCallToolResult error: nil +] + +{ #category : 'private - tools' } +MCP >> handleToolCallWrapperError: error tool: callTool observabilityRecord: observabilityRecord [ + + | errorResult | + error mcpIsJRPCError ifTrue: [ + self observability recordToolCallFinish: observabilityRecord result: nil error: error. + error pass ]. + errorResult := callTool errorResultFor: error. + self observability recordToolCallFinish: observabilityRecord result: errorResult error: error. + ^ errorResult asJRPCJSONForResponseFields: self toolResponseFields +] + { #category : 'accessing' } MCP >> handlersCount [ @@ -144,7 +201,7 @@ MCP >> initialize [ self refreshToolsList. server := MCPHTTPServer new. server addHandlersFromPragmasIn: self. - self monitoringExportDirectory: MCPMonitoringState defaultExportDirectory + observability := MCPNoopObservabilityBackend new ] { #category : 'testing' } @@ -165,81 +222,100 @@ MCP >> localUrlString [ ^ self server localUrl asString ] +{ #category : 'observability' } +MCP >> newLocalObservabilityBackend [ + + ^ MCPLocalObservabilityBackend new + resourceMetadata: self observabilityResourceMetadata; + exportDirectory: MCPLocalObservabilityBackend defaultExportDirectory; + yourself +] + +{ #category : 'accessing' } +MCP >> observability [ + + ^ observability ifNil: [ observability := MCPNoopObservabilityBackend new ] +] + { #category : 'accessing' } -MCP >> monitoring [ +MCP >> observability: anObservabilityBackend [ - ^ monitoring ifNil: [ monitoring := MCPMonitoringState new ] + observability := anObservabilityBackend ] -{ #category : 'monitoring' } -MCP >> monitoringEnabled [ +{ #category : 'observability' } +MCP >> observabilityEnabled [ - ^ self monitoring enabled + ^ self observability enabled ] -{ #category : 'monitoring' } -MCP >> monitoringEnabled: aBoolean [ +{ #category : 'observability' } +MCP >> observabilityEnabled: aBoolean [ - self monitoring enabled: aBoolean + aBoolean + ifTrue: [ self ensureLocalObservabilityBackend enabled: true ] + ifFalse: [ observability := MCPNoopObservabilityBackend new ] ] -{ #category : 'monitoring' } -MCP >> monitoringExportDirectory [ +{ #category : 'observability' } +MCP >> observabilityExportDirectory [ - ^ self monitoring exportDirectory + ^ self observability exportDirectory ] -{ #category : 'monitoring' } -MCP >> monitoringExportDirectory: aPath [ +{ #category : 'observability' } +MCP >> observabilityExportDirectory: aPath [ - self monitoring resourceMetadata: self monitoringResourceMetadata. - self monitoring exportDirectory: aPath + | localObservability | + localObservability := self ensureLocalObservabilityBackend. + localObservability resourceMetadata: self observabilityResourceMetadata. + localObservability exportDirectory: aPath ] -{ #category : 'monitoring' } -MCP >> monitoringExportInstanceDirectory [ +{ #category : 'observability' } +MCP >> observabilityExportInstanceDirectory [ - ^ self monitoring exportInstanceDirectory + ^ self observability exportInstanceDirectory ] -{ #category : 'monitoring' } -MCP >> monitoringLogs [ +{ #category : 'observability' } +MCP >> observabilityLogs [ - ^ self monitoring logs + ^ self observability logs ] -{ #category : 'monitoring' } -MCP >> monitoringMetrics [ +{ #category : 'observability' } +MCP >> observabilityMetrics [ - ^ self monitoring metrics + ^ self observability metrics ] -{ #category : 'monitoring' } -MCP >> monitoringRecentCalls [ +{ #category : 'observability' } +MCP >> observabilityRecentCalls [ - ^ self monitoring recentCallRecords + ^ self observability recentCallRecords ] -{ #category : 'monitoring' } -MCP >> monitoringResourceMetadata [ +{ #category : 'observability' } +MCP >> observabilityResourceMetadata [ | metadata | - metadata := self monitoring resourceMetadata copy. + metadata := self observability resourceMetadata copy. metadata at: #'pharo.majorVersion' put: PharoCompatibility currentMajorVersion. metadata at: #'mcp.port' put: self port. ^ metadata ] -{ #category : 'monitoring' } -MCP >> monitoringStatusText [ +{ #category : 'observability' } +MCP >> observabilityStatusText [ - ^ self monitoring statusText + ^ self observability statusText ] -{ #category : 'monitoring' } -MCP >> monitoringTraces [ +{ #category : 'observability' } +MCP >> observabilityTraces [ - ^ self monitoring traceRecords + ^ self observability traceRecords ] { #category : 'start' } @@ -281,6 +357,16 @@ MCP >> port: aPortNumber [ self server port: aPortNumber ] +{ #category : 'private - tools' } +MCP >> recordToolCallWrapperStartFor: callTool arguments: arguments [ + + ^ self observability + recordToolCallStart: callTool name + input: arguments + dispatchPath: 'direct' + context: Dictionary new +] + { #category : 'private - tools' } MCP >> refreshToolsList [ "Refresh the cached tools/list response surface from the registry's current static tools." @@ -320,22 +406,12 @@ MCP >> restart [ { #category : 'private - tools' } MCP >> rpcCallToolCallWithParams: arguments [ - | callRequest callTool request targetTool | - callTool := self toolRegistry toolNamed: MCPToolCallTool toolName ifAbsent: [ Error signal: 'tool_call is not registered.' ]. - ^ [ - request := callTool requestFromToolCallArguments: arguments. - callRequest := callTool parsedRequestFromToolRequest: request. - callRequest toolName = callTool name ifTrue: [ Error signal: 'tool_call cannot call itself.' ]. - targetTool := self toolRegistry - toolNamed: callRequest toolName - ifAbsent: [ Error signal: 'Unknown MCP tool: ' , callRequest toolName ]. - self executeTool: targetTool named: targetTool name withParams: callRequest arguments ] + | callTool observabilityRecord | + callTool := self toolCallTool. + observabilityRecord := self recordToolCallWrapperStartFor: callTool arguments: arguments. + ^ [ self dispatchToolCallWith: callTool arguments: arguments observabilityRecord: observabilityRecord ] on: Exception - do: [ :error | - | errorResult | - error mcpIsJRPCError ifTrue: [ error pass ]. - errorResult := callTool errorResultFor: error. - errorResult asJRPCJSONForResponseFields: self toolResponseFields ] + do: [ :error | self handleToolCallWrapperError: error tool: callTool observabilityRecord: observabilityRecord ] ] { #category : 'rpc' } @@ -427,7 +503,8 @@ MCP >> start [ self refreshToolsList. server addHandlersFromPragmasIn: self. server start. - self ensureServerIsListening + self ensureServerIsListening. + self observability recordSessionStartFor: self ] { #category : 'configuration' } @@ -446,7 +523,7 @@ MCP >> staticToolNames: aCollection [ { #category : 'start' } MCP >> stop [ - server stop + [ server stop ] ensure: [ self observability shutdown ] ] { #category : 'infrastructure metadata' } @@ -461,6 +538,26 @@ MCP >> supportedPharoMajorVersions [ ^ #( 12 13 14 ) ] +{ #category : 'private - tools' } +MCP >> targetToolForCallRequest: callRequest [ + + ^ self toolRegistry toolNamed: callRequest toolName ifAbsent: [ Error signal: 'Unknown MCP tool: ' , callRequest toolName ] +] + +{ #category : 'private - tools' } +MCP >> toolCallTool [ + + ^ self toolRegistry toolNamed: MCPToolCallTool toolName ifAbsent: [ Error signal: 'tool_call is not registered.' ] +] + +{ #category : 'private - tools' } +MCP >> toolCallWrapperResultFor: callTool request: callRequest [ + + ^ callTool + successResultText: 'Dispatched tool ' , callRequest toolName + data: { (#toolName -> callRequest toolName) } asDictionary +] + { #category : 'accessing' } MCP >> toolExposurePolicy [ @@ -548,6 +645,12 @@ MCP >> validToolResponseFields [ ^ #( content structuredContent both ) ] +{ #category : 'private - tools' } +MCP >> validateToolCallRequest: callRequest wrapperTool: callTool [ + + callRequest toolName = callTool name ifTrue: [ Error signal: 'tool_call cannot call itself.' ] +] + { #category : 'private - start' } MCP >> waitUntilListeningForMilliseconds: milliseconds [ diff --git a/src/MCP/MCPJSONLineFileTinyLogger.class.st b/src/MCP/MCPJSONLineFileTinyLogger.class.st deleted file mode 100644 index 3768ae76..00000000 --- a/src/MCP/MCPJSONLineFileTinyLogger.class.st +++ /dev/null @@ -1,25 +0,0 @@ -" -TinyLogger file sink that writes one JSON record per line. -" -Class { - #name : 'MCPJSONLineFileTinyLogger', - #superclass : 'TinyFileLogger', - #category : 'MCP-Monitoring', - #package : 'MCP', - #tag : 'Monitoring' -} - -{ #category : 'accessing' } -MCPJSONLineFileTinyLogger class >> kind [ - - ^ 'mcpJSONLineFile' -] - -{ #category : 'logging' } -MCPJSONLineFileTinyLogger >> record: aString [ - - self fileStreamDo: [ :stream | - stream - nextPutAll: aString; - nextPutAll: self newLine ] -] diff --git a/src/MCP/MCPMonitoringState.class.st b/src/MCP/MCPLocalObservabilityBackend.class.st similarity index 66% rename from src/MCP/MCPMonitoringState.class.st rename to src/MCP/MCPLocalObservabilityBackend.class.st index fdd63d18..4c800d05 100644 --- a/src/MCP/MCPMonitoringState.class.st +++ b/src/MCP/MCPLocalObservabilityBackend.class.st @@ -1,47 +1,45 @@ " -Runtime observability state for an MCP server. When enabled, it records TinyLogger-backed log lines, per-tool timing and output-size metrics, recent completed calls, and compact trace records for errors, timing outliers, or output-budget overruns. MCP server instances enable observability and configure a default export directory; standalone state objects can still be configured explicitly. +Runtime observability state for an MCP server. When enabled, it records local log lines, per-tool timing and output-size metrics, recent completed calls, and compact trace records for errors, timing outliers, or output-budget overruns. MCP server instances enable observability and configure a default export directory; standalone state objects can still be configured explicitly. " Class { - #name : 'MCPMonitoringState', - #superclass : 'Object', + #name : 'MCPLocalObservabilityBackend', + #superclass : 'MCPObservabilityBackend', #instVars : [ 'enabled', - 'logger', - 'memoryLogger', + 'logEntries', 'statsByTool', 'recentCalls', 'nextCallId', 'maxRecentCalls', + 'maxLogEntries', 'outlierFactor', 'minimumOutlierMilliseconds', 'outputBudgetBytes', 'lastActivityAt', - 'traceLogger', - 'logFileLogger', 'exportDirectory', 'exportInstanceDirectory', 'resourceMetadata' ], - #category : 'MCP-Monitoring', + #category : 'MCP-Observability', #package : 'MCP', - #tag : 'Monitoring' + #tag : 'Observability' } { #category : 'defaults' } -MCPMonitoringState class >> defaultExportDirectory [ +MCPLocalObservabilityBackend class >> defaultExportDirectory [ - ^ FileLocator home asFileReference / 'pharo-mcp-observability' + ^ Smalltalk imageFile asFileReference parent / 'pharo-local' / 'mcp' / 'observability' ] { #category : 'private' } -MCPMonitoringState >> addRecentCall: aRecord [ +MCPLocalObservabilityBackend >> addRecentCall: aRecord [ self recentCalls addFirst: aRecord. [ self recentCalls size > self maxRecentCalls ] whileTrue: [ self recentCalls removeLast ] ] { #category : 'private - budgets' } -MCPMonitoringState >> approximateOutputBytesForObject: anObject [ +MCPLocalObservabilityBackend >> approximateOutputBytesForObject: anObject [ | jsonString | jsonString := [ STONJSON toString: anObject ] @@ -51,7 +49,7 @@ MCPMonitoringState >> approximateOutputBytesForObject: anObject [ ] { #category : 'private - budgets' } -MCPMonitoringState >> approximateOutputBytesForResult: aToolResult error: anError [ +MCPLocalObservabilityBackend >> approximateOutputBytesForResult: aToolResult error: anError [ aToolResult ifNotNil: [ ^ self approximateOutputBytesForObject: aToolResult asJRPCJSON ]. anError ifNil: [ ^ 0 ]. @@ -61,13 +59,13 @@ MCPMonitoringState >> approximateOutputBytesForResult: aToolResult error: anErro ] { #category : 'private - budgets' } -MCPMonitoringState >> approximateOutputTokensForBytes: byteCount [ +MCPLocalObservabilityBackend >> approximateOutputTokensForBytes: byteCount [ ^ byteCount + 3 // 4 ] { #category : 'private - tracing' } -MCPMonitoringState >> captureTraceSnapshotsFor: aRecord result: aToolResult error: anError [ +MCPLocalObservabilityBackend >> captureTraceSnapshotsFor: aRecord result: aToolResult error: anError [ [ [ @@ -88,7 +86,7 @@ MCPMonitoringState >> captureTraceSnapshotsFor: aRecord result: aToolResult erro ] { #category : 'clearing' } -MCPMonitoringState >> clear [ +MCPLocalObservabilityBackend >> clear [ self statsByTool removeAll. self recentCalls removeAll. @@ -99,105 +97,85 @@ MCPMonitoringState >> clear [ ] { #category : 'logging' } -MCPMonitoringState >> clearLogs [ +MCPLocalObservabilityBackend >> clearLogs [ - memoryLogger ifNotNil: [ memoryLogger clearLog ] + self logEntries removeAll ] { #category : 'private - defaults' } -MCPMonitoringState >> defaultOutputBudgetBytes [ - "32 KiB is about 8k tokens with the monitoring estimate of four bytes per token. It is only the default trace threshold; callers can override it with outputBudgetBytes:." +MCPLocalObservabilityBackend >> defaultOutputBudgetBytes [ + "32 KiB is about 8k tokens with the observability estimate of four bytes per token. It is only the default trace threshold; callers can override it with outputBudgetBytes:." ^ 32 * 1024 ] { #category : 'activation' } -MCPMonitoringState >> disable [ +MCPLocalObservabilityBackend >> disable [ self enabled ifTrue: [ self recordLog: 'Observability disabled' ]. enabled := false ] { #category : 'activation' } -MCPMonitoringState >> enable [ +MCPLocalObservabilityBackend >> enable [ - self enabled ifTrue: [ - self ensureLogger. - ^ self ]. + self enabled ifTrue: [ ^ self ]. enabled := true. - self ensureLogger. lastActivityAt := DateAndTime now. self recordLog: 'Observability enabled' ] { #category : 'accessing' } -MCPMonitoringState >> enabled [ +MCPLocalObservabilityBackend >> enabled [ ^ enabled ifNil: [ false ] ] { #category : 'accessing' } -MCPMonitoringState >> enabled: aBoolean [ +MCPLocalObservabilityBackend >> enabled: aBoolean [ aBoolean ifTrue: [ self enable ] ifFalse: [ self disable ] ] -{ #category : 'private - exporting' } -MCPMonitoringState >> ensureLogFileLogger [ - - logFileLogger ifNil: [ logFileLogger := MCPJSONLineFileTinyLogger named: self logFileReference pathString ]. - ^ logFileLogger -] - -{ #category : 'logging' } -MCPMonitoringState >> ensureLogger [ +{ #category : 'private' } +MCPLocalObservabilityBackend >> errorClassNameFor: anError [ - logger ifNil: [ logger := TinyLogger new ]. - memoryLogger ifNil: [ - memoryLogger := MCPMemoryTinyLogger new. - logger addLogger: memoryLogger ]. - ^ logger + anError ifNil: [ ^ '' ]. + ^ anError class name asString ] { #category : 'private - tracing' } -MCPMonitoringState >> ensureTraceLogger [ +MCPLocalObservabilityBackend >> eventKindsForRecord: aRecord [ - traceLogger ifNil: [ traceLogger := MCPJSONLineFileTinyLogger named: self traceFileReference pathString ]. - ^ traceLogger -] - -{ #category : 'private' } -MCPMonitoringState >> errorClassNameFor: anError [ - - anError ifNil: [ ^ '' ]. - ^ anError class name asString + ^ Array streamContents: [ :stream | + aRecord status = 'error' ifTrue: [ stream nextPut: 'error' ]. + aRecord outputBudgetExceeded ifTrue: [ stream nextPut: 'output_budget_exceeded' ]. + aRecord outlierExplanation ifNotEmpty: [ :ignored | stream nextPut: 'duration_outlier' ] ] ] { #category : 'accessing' } -MCPMonitoringState >> exportDirectory [ +MCPLocalObservabilityBackend >> exportDirectory [ ^ exportDirectory ] { #category : 'accessing' } -MCPMonitoringState >> exportDirectory: aPath [ +MCPLocalObservabilityBackend >> exportDirectory: aPath [ exportDirectory := aPath ifNotNil: [ :path | path asFileReference ]. - exportInstanceDirectory := nil. - traceLogger := nil. - logFileLogger := nil + exportInstanceDirectory := nil ] { #category : 'testing' } -MCPMonitoringState >> exportEnabled [ +MCPLocalObservabilityBackend >> exportEnabled [ ^ self exportDirectory notNil ] { #category : 'accessing' } -MCPMonitoringState >> exportInstanceDirectory [ +MCPLocalObservabilityBackend >> exportInstanceDirectory [ | directory | self exportEnabled ifFalse: [ ^ nil ]. @@ -210,7 +188,7 @@ MCPMonitoringState >> exportInstanceDirectory [ ] { #category : 'private - exporting' } -MCPMonitoringState >> exportInstanceDirectoryName [ +MCPLocalObservabilityBackend >> exportInstanceDirectoryName [ | instanceId metadata port suffix version | metadata := self resourceMetadata. @@ -229,16 +207,16 @@ MCPMonitoringState >> exportInstanceDirectoryName [ ] { #category : 'private - exporting' } -MCPMonitoringState >> exportLogMessage: aString timestamp: timestamp [ +MCPLocalObservabilityBackend >> exportLogMessage: aString timestamp: timestamp [ self exportEnabled ifFalse: [ ^ self ]. - [ self ensureLogFileLogger record: (self logRecordJSONFor: aString timestamp: timestamp) ] + [ self writeJSONLine: (self logRecordJSONFor: aString timestamp: timestamp) to: self logFileReference ] on: Error do: [ :ignored | nil ] ] { #category : 'private - exporting' } -MCPMonitoringState >> exportMetrics [ +MCPLocalObservabilityBackend >> exportMetrics [ self exportEnabled ifFalse: [ ^ self ]. [ self writeMetricsSnapshot ] @@ -247,16 +225,22 @@ MCPMonitoringState >> exportMetrics [ ] { #category : 'private - tracing' } -MCPMonitoringState >> exportTraceRecord: aRecord [ +MCPLocalObservabilityBackend >> exportTraceRecord: aRecord [ (self exportEnabled and: [ aRecord isTraceWorthy ]) ifFalse: [ ^ self ]. - [ self ensureTraceLogger record: (self traceRecordJSONFor: aRecord) ] + [ self writeJSONLine: (self traceRecordJSONFor: aRecord) to: self traceFileReference ] on: Error do: [ :error | self recordLog: 'Trace export failed: ' , error class name asString ] ] +{ #category : 'actions' } +MCPLocalObservabilityBackend >> forceFlush [ + + self exportMetrics +] + { #category : 'initialization' } -MCPMonitoringState >> initialize [ +MCPLocalObservabilityBackend >> initialize [ super initialize. enabled := true. @@ -264,14 +248,20 @@ MCPMonitoringState >> initialize [ recentCalls := OrderedCollection new. nextCallId := 0. maxRecentCalls := 100. + maxLogEntries := 200. outlierFactor := 3. minimumOutlierMilliseconds := 250. - outputBudgetBytes := self defaultOutputBudgetBytes. - self ensureLogger + outputBudgetBytes := self defaultOutputBudgetBytes +] + +{ #category : 'testing' } +MCPLocalObservabilityBackend >> isNoop [ + + ^ false ] { #category : 'private - tracing' } -MCPMonitoringState >> isSensitiveTraceKey: aKey [ +MCPLocalObservabilityBackend >> isSensitiveTraceKey: aKey [ | lowerKey | lowerKey := aKey asString asLowercase. @@ -280,19 +270,32 @@ MCPMonitoringState >> isSensitiveTraceKey: aKey [ ] { #category : 'private - tracing' } -MCPMonitoringState >> isVerboseTraceKey: aKey [ +MCPLocalObservabilityBackend >> isVerboseTraceKey: aKey [ ^ #( 'stackTrace' 'signalerContext' ) includes: aKey asString ] +{ #category : 'private - logging' } +MCPLocalObservabilityBackend >> logEntries [ + + ^ logEntries ifNil: [ logEntries := OrderedCollection new ] +] + +{ #category : 'private - logging' } +MCPLocalObservabilityBackend >> logEntry: aString [ + + self logEntries add: aString. + [ self logEntries size > self maxLogEntries ] whileTrue: [ self logEntries removeFirst ] +] + { #category : 'accessing' } -MCPMonitoringState >> logFileReference [ +MCPLocalObservabilityBackend >> logFileReference [ ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'logs.jsonl' ] ] { #category : 'private' } -MCPMonitoringState >> logMessageForRecord: aRecord [ +MCPLocalObservabilityBackend >> logMessageForRecord: aRecord [ ^ String streamContents: [ :stream | stream @@ -314,7 +317,7 @@ MCPMonitoringState >> logMessageForRecord: aRecord [ ] { #category : 'private - exporting' } -MCPMonitoringState >> logRecordDictionaryFor: aString timestamp: timestamp [ +MCPLocalObservabilityBackend >> logRecordDictionaryFor: aString timestamp: timestamp [ ^ { (#schemaVersion -> 1). @@ -324,82 +327,81 @@ MCPMonitoringState >> logRecordDictionaryFor: aString timestamp: timestamp [ ] { #category : 'private - exporting' } -MCPMonitoringState >> logRecordJSONFor: aString timestamp: timestamp [ +MCPLocalObservabilityBackend >> logRecordJSONFor: aString timestamp: timestamp [ ^ STONJSON toString: (self logRecordDictionaryFor: aString timestamp: timestamp) ] { #category : 'private' } -MCPMonitoringState >> logStatusLabelForStatus: statusString [ +MCPLocalObservabilityBackend >> logStatusLabelForStatus: statusString [ statusString = 'ok' ifTrue: [ ^ 'OK' ]. statusString = 'error' ifTrue: [ ^ 'Error' ]. ^ statusString capitalized ] +{ #category : 'logging' } +MCPLocalObservabilityBackend >> logs [ + + ^ self logEntries asArray +] + { #category : 'accessing' } -MCPMonitoringState >> logger [ +MCPLocalObservabilityBackend >> maxLogEntries [ - ^ logger + ^ maxLogEntries ifNil: [ 200 ] ] -{ #category : 'logging' } -MCPMonitoringState >> logs [ +{ #category : 'accessing' } +MCPLocalObservabilityBackend >> maxLogEntries: anInteger [ - memoryLogger ifNil: [ ^ #( ) ]. - ^ memoryLogger recentEntries + maxLogEntries := anInteger ] { #category : 'accessing' } -MCPMonitoringState >> maxRecentCalls [ +MCPLocalObservabilityBackend >> maxRecentCalls [ ^ maxRecentCalls ifNil: [ 100 ] ] { #category : 'accessing' } -MCPMonitoringState >> maxRecentCalls: anInteger [ +MCPLocalObservabilityBackend >> maxRecentCalls: anInteger [ maxRecentCalls := anInteger ] { #category : 'accessing' } -MCPMonitoringState >> maxTraceCollectionItems [ +MCPLocalObservabilityBackend >> maxTraceCollectionItems [ ^ 20 ] { #category : 'accessing' } -MCPMonitoringState >> maxTraceDepth [ +MCPLocalObservabilityBackend >> maxTraceDepth [ ^ 4 ] { #category : 'accessing' } -MCPMonitoringState >> maxTraceStringLength [ +MCPLocalObservabilityBackend >> maxTraceStringLength [ ^ 1000 ] { #category : 'accessing' } -MCPMonitoringState >> memoryLogger [ - - ^ memoryLogger -] - -{ #category : 'accessing' } -MCPMonitoringState >> metrics [ +MCPLocalObservabilityBackend >> metrics [ ^ self statsByTool values asSortedCollection: [ :left :right | left toolName <= right toolName ] ] { #category : 'accessing' } -MCPMonitoringState >> metricsFileReference [ +MCPLocalObservabilityBackend >> metricsFileReference [ ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'metrics.json' ] ] { #category : 'private - exporting' } -MCPMonitoringState >> metricsSnapshotDictionary [ +MCPLocalObservabilityBackend >> metricsSnapshotDictionary [ ^ { (#schemaVersion -> 1). @@ -411,19 +413,19 @@ MCPMonitoringState >> metricsSnapshotDictionary [ ] { #category : 'accessing' } -MCPMonitoringState >> minimumOutlierMilliseconds [ +MCPLocalObservabilityBackend >> minimumOutlierMilliseconds [ ^ minimumOutlierMilliseconds ifNil: [ 250 ] ] { #category : 'accessing' } -MCPMonitoringState >> minimumOutlierMilliseconds: anInteger [ +MCPLocalObservabilityBackend >> minimumOutlierMilliseconds: anInteger [ minimumOutlierMilliseconds := anInteger ] { #category : 'private - exporting' } -MCPMonitoringState >> newResourceMetadata [ +MCPLocalObservabilityBackend >> newResourceMetadata [ ^ { (#schemaVersion -> 1). @@ -433,13 +435,13 @@ MCPMonitoringState >> newResourceMetadata [ ] { #category : 'accessing' } -MCPMonitoringState >> nextCallId [ +MCPLocalObservabilityBackend >> nextCallId [ ^ nextCallId ifNil: [ 0 ] ] { #category : 'private' } -MCPMonitoringState >> outlierExplanationForDurationMilliseconds: duration stats: stats [ +MCPLocalObservabilityBackend >> outlierExplanationForDurationMilliseconds: duration stats: stats [ | average ratio | stats callCount < 3 ifTrue: [ ^ '' ]. @@ -459,31 +461,31 @@ MCPMonitoringState >> outlierExplanationForDurationMilliseconds: duration stats: ] { #category : 'accessing' } -MCPMonitoringState >> outlierFactor [ +MCPLocalObservabilityBackend >> outlierFactor [ ^ outlierFactor ifNil: [ 3 ] ] { #category : 'accessing' } -MCPMonitoringState >> outlierFactor: aNumber [ +MCPLocalObservabilityBackend >> outlierFactor: aNumber [ outlierFactor := aNumber ] { #category : 'accessing' } -MCPMonitoringState >> outputBudgetBytes [ +MCPLocalObservabilityBackend >> outputBudgetBytes [ ^ outputBudgetBytes ifNil: [ self defaultOutputBudgetBytes ] ] { #category : 'accessing' } -MCPMonitoringState >> outputBudgetBytes: anInteger [ +MCPLocalObservabilityBackend >> outputBudgetBytes: anInteger [ outputBudgetBytes := anInteger ] { #category : 'private - budgets' } -MCPMonitoringState >> outputBudgetDataForResult: aToolResult error: anError [ +MCPLocalObservabilityBackend >> outputBudgetDataForResult: aToolResult error: anError [ | byteCount explanation | byteCount := self approximateOutputBytesForResult: aToolResult error: anError. @@ -497,7 +499,7 @@ MCPMonitoringState >> outputBudgetDataForResult: aToolResult error: anError [ ] { #category : 'private - budgets' } -MCPMonitoringState >> outputBudgetExplanationForByteCount: byteCount [ +MCPLocalObservabilityBackend >> outputBudgetExplanationForByteCount: byteCount [ byteCount <= self outputBudgetBytes ifTrue: [ ^ '' ]. ^ String streamContents: [ :stream | @@ -510,7 +512,7 @@ MCPMonitoringState >> outputBudgetExplanationForByteCount: byteCount [ ] { #category : 'displaying' } -MCPMonitoringState >> outputBudgetGuidance [ +MCPLocalObservabilityBackend >> outputBudgetGuidance [ | attentionStats displayedCount | attentionStats := self statsNeedingOutputBudgetAttention. @@ -526,35 +528,51 @@ MCPMonitoringState >> outputBudgetGuidance [ ] { #category : 'accessing' } -MCPMonitoringState >> recentCallRecords [ +MCPLocalObservabilityBackend >> recentCallRecords [ ^ self recentCalls asArray ] { #category : 'accessing' } -MCPMonitoringState >> recentCalls [ +MCPLocalObservabilityBackend >> recentCalls [ ^ recentCalls ifNil: [ recentCalls := OrderedCollection new ] ] { #category : 'logging' } -MCPMonitoringState >> recordLog: aString [ +MCPLocalObservabilityBackend >> recordLog: aString [ | timestamp | self enabled ifFalse: [ ^ self ]. timestamp := DateAndTime now. - self ensureLogger record: aString. + self logEntry: aString. self exportLogMessage: aString timestamp: timestamp ] { #category : 'private - budgets' } -MCPMonitoringState >> recordOutputBudgetFor: aRecord result: aToolResult error: anError [ +MCPLocalObservabilityBackend >> recordOutputBudgetFor: aRecord result: aToolResult error: anError [ aRecord recordOutputBudget: (self outputBudgetDataForResult: aToolResult error: anError) ] { #category : 'recording' } -MCPMonitoringState >> recordToolCallFinish: aRecord result: aToolResult error: anError [ +MCPLocalObservabilityBackend >> recordSessionEndFor: anMCP [ + + self resourceMetadata at: #sessionEndedAt put: DateAndTime now asString. + self exportEnabled ifTrue: [ self writeResourceMetadata ]. + self forceFlush +] + +{ #category : 'recording' } +MCPLocalObservabilityBackend >> recordSessionStartFor: anMCP [ + + self resourceMetadata at: #sessionStartedAt put: DateAndTime now asString. + self resourceMetadata removeKey: #sessionEndedAt ifAbsent: [ nil ]. + self exportEnabled ifTrue: [ self writeResourceMetadata ] +] + +{ #category : 'recording' } +MCPLocalObservabilityBackend >> recordToolCallFinish: aRecord result: aToolResult error: anError [ | explanation finishTimestamp stats statusString duration | aRecord ifNil: [ ^ self ]. @@ -569,6 +587,7 @@ MCPMonitoringState >> recordToolCallFinish: aRecord result: aToolResult error: a aRecord summary: (self summaryForResult: aToolResult). aRecord outlierExplanation: explanation. self recordOutputBudgetFor: aRecord result: aToolResult error: anError. + aRecord eventKinds: (self eventKindsForRecord: aRecord). self captureTraceSnapshotsFor: aRecord result: aToolResult error: anError. self exportTraceRecord: aRecord. stats recordCall: aRecord. @@ -580,13 +599,7 @@ MCPMonitoringState >> recordToolCallFinish: aRecord result: aToolResult error: a ] { #category : 'recording' } -MCPMonitoringState >> recordToolCallStart: aToolName [ - - ^ self recordToolCallStart: aToolName input: nil -] - -{ #category : 'recording' } -MCPMonitoringState >> recordToolCallStart: aToolName input: inputObject [ +MCPLocalObservabilityBackend >> recordToolCallStart: aToolName input: inputObject dispatchPath: dispatchPath context: contextDictionary [ | record startTimestamp | self enabled ifFalse: [ ^ nil ]. @@ -594,36 +607,37 @@ MCPMonitoringState >> recordToolCallStart: aToolName input: inputObject [ startTimestamp := DateAndTime now. record := MCPToolCallRecord id: self nextCallId toolName: aToolName asString startedAt: startTimestamp. record inputObject: inputObject. + record inputByteCount: (inputObject ifNil: [ 0 ] ifNotNil: [ self approximateOutputBytesForObject: inputObject ]). + record dispatchPath: dispatchPath asString. lastActivityAt := startTimestamp. self recordLog: 'Begin tool ' , aToolName asString , ' #' , record id asString. ^ record ] { #category : 'accessing' } -MCPMonitoringState >> resourceFileReference [ +MCPLocalObservabilityBackend >> resourceFileReference [ ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'resource.json' ] ] { #category : 'accessing' } -MCPMonitoringState >> resourceMetadata [ +MCPLocalObservabilityBackend >> resourceMetadata [ ^ resourceMetadata ifNil: [ resourceMetadata := self newResourceMetadata ] ] { #category : 'accessing' } -MCPMonitoringState >> resourceMetadata: aDictionary [ +MCPLocalObservabilityBackend >> resourceMetadata: aDictionary [ | metadata | metadata := self resourceMetadata copy. aDictionary ifNotNil: [ aDictionary keysAndValuesDo: [ :key :value | metadata at: key put: value ] ]. resourceMetadata := metadata. - exportInstanceDirectory := nil. - traceLogger := nil + exportInstanceDirectory := nil ] { #category : 'private - tracing' } -MCPMonitoringState >> shouldIncludeTraceAssociation: anAssociation [ +MCPLocalObservabilityBackend >> shouldIncludeTraceAssociation: anAssociation [ | value | (self isVerboseTraceKey: anAssociation key) ifTrue: [ ^ false ]. @@ -634,20 +648,26 @@ MCPMonitoringState >> shouldIncludeTraceAssociation: anAssociation [ ^ true ] +{ #category : 'actions' } +MCPLocalObservabilityBackend >> shutdown [ + + self recordSessionEndFor: nil +] + { #category : 'accessing' } -MCPMonitoringState >> statsByTool [ +MCPLocalObservabilityBackend >> statsByTool [ ^ statsByTool ifNil: [ statsByTool := Dictionary new ] ] { #category : 'accessing' } -MCPMonitoringState >> statsForToolNamed: aToolName [ +MCPLocalObservabilityBackend >> statsForToolNamed: aToolName [ ^ self statsByTool at: aToolName asString ifAbsentPut: [ MCPToolCallStats forToolNamed: aToolName asString ] ] { #category : 'private - budgets' } -MCPMonitoringState >> statsNeedingOutputBudgetAttention [ +MCPLocalObservabilityBackend >> statsNeedingOutputBudgetAttention [ ^ (self metrics select: [ :each | each outputBudgetExceededCount > 0 ]) asArray sort: [ :left :right | left outputBudgetExceededCount = right outputBudgetExceededCount @@ -656,7 +676,7 @@ MCPMonitoringState >> statsNeedingOutputBudgetAttention [ ] { #category : 'private' } -MCPMonitoringState >> statusForResult: aToolResult error: anError [ +MCPLocalObservabilityBackend >> statusForResult: aToolResult error: anError [ anError ifNotNil: [ ^ 'error' ]. aToolResult ifNil: [ ^ 'error' ]. @@ -666,7 +686,7 @@ MCPMonitoringState >> statusForResult: aToolResult error: anError [ ] { #category : 'displaying' } -MCPMonitoringState >> statusText [ +MCPLocalObservabilityBackend >> statusText [ | guidance | self enabled ifFalse: [ ^ 'Observability disabled' ]. @@ -686,7 +706,7 @@ MCPMonitoringState >> statusText [ ] { #category : 'private' } -MCPMonitoringState >> summaryForResult: aToolResult [ +MCPLocalObservabilityBackend >> summaryForResult: aToolResult [ | structuredContent | aToolResult ifNil: [ ^ '' ]. @@ -696,51 +716,54 @@ MCPMonitoringState >> summaryForResult: aToolResult [ ] { #category : 'accessing' } -MCPMonitoringState >> totalCallCount [ +MCPLocalObservabilityBackend >> totalCallCount [ ^ self metrics inject: 0 into: [ :sum :each | sum + each callCount ] ] { #category : 'accessing' } -MCPMonitoringState >> totalErrorCount [ +MCPLocalObservabilityBackend >> totalErrorCount [ ^ self metrics inject: 0 into: [ :sum :each | sum + each errorCount ] ] { #category : 'accessing' } -MCPMonitoringState >> totalOutputBudgetExceededCount [ +MCPLocalObservabilityBackend >> totalOutputBudgetExceededCount [ ^ self metrics inject: 0 into: [ :sum :each | sum + each outputBudgetExceededCount ] ] { #category : 'private - tracing' } -MCPMonitoringState >> traceEventNameForRecord: aRecord [ +MCPLocalObservabilityBackend >> traceEventNameForRecord: aRecord [ - aRecord outputBudgetExceeded ifTrue: [ ^ 'mcp.tool_call.output_budget_exceeded' ]. aRecord status = 'error' ifTrue: [ ^ 'mcp.tool_call.error' ]. - aRecord outlierExplanation isNotEmpty ifTrue: [ ^ 'mcp.tool_call.duration_outlier' ]. - ^ 'mcp.tool_call.trace' + aRecord outputBudgetExceeded ifTrue: [ ^ 'mcp.tool_call.output_budget_exceeded' ]. + aRecord outlierExplanation ifNotEmpty: [ ^ 'mcp.tool_call.duration_outlier' ]. + ^ 'mcp.tool_call.completed' ] { #category : 'accessing' } -MCPMonitoringState >> traceFileReference [ +MCPLocalObservabilityBackend >> traceFileReference [ ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'traces.jsonl' ] ] { #category : 'private - tracing' } -MCPMonitoringState >> traceRecordDictionaryFor: aRecord [ +MCPLocalObservabilityBackend >> traceRecordDictionaryFor: aRecord [ | dictionary | dictionary := Dictionary new. dictionary at: #schemaVersion put: 1. dictionary at: #'event.name' put: (self traceEventNameForRecord: aRecord). + dictionary at: #'event.kinds' put: aRecord eventKinds. dictionary at: #id put: aRecord id. dictionary at: #toolName put: aRecord toolName. + dictionary at: #dispatchPath put: aRecord dispatchPath. dictionary at: #startedAt put: aRecord displayStartedAt. dictionary at: #durationMilliseconds put: aRecord durationMilliseconds. dictionary at: #status put: aRecord status. aRecord errorClass ifNotEmpty: [ :errorClass | dictionary at: #errorClass put: errorClass ]. + aRecord inputByteCount > 0 ifTrue: [ dictionary at: #inputBytes put: aRecord inputByteCount ]. aRecord outputByteCount > 0 ifTrue: [ dictionary at: #outputBytes put: aRecord outputByteCount. dictionary at: #approximateOutputTokens put: aRecord approximateOutputTokenCount ]. @@ -754,25 +777,25 @@ MCPMonitoringState >> traceRecordDictionaryFor: aRecord [ ] { #category : 'private - tracing' } -MCPMonitoringState >> traceRecordJSONFor: aRecord [ +MCPLocalObservabilityBackend >> traceRecordJSONFor: aRecord [ ^ STONJSON toString: (self traceRecordDictionaryFor: aRecord) ] { #category : 'accessing' } -MCPMonitoringState >> traceRecords [ +MCPLocalObservabilityBackend >> traceRecords [ ^ self recentCallRecords select: [ :each | each isTraceWorthy ] ] { #category : 'private - tracing' } -MCPMonitoringState >> traceSnapshotFor: anObject [ +MCPLocalObservabilityBackend >> traceSnapshotFor: anObject [ ^ self traceSnapshotFor: anObject depth: 0 ] { #category : 'private - tracing' } -MCPMonitoringState >> traceSnapshotFor: anObject depth: depth [ +MCPLocalObservabilityBackend >> traceSnapshotFor: anObject depth: depth [ anObject ifNil: [ ^ nil ]. (anObject isString or: [ anObject isSymbol ]) ifTrue: [ ^ self traceStringSnapshotFor: anObject ]. @@ -786,14 +809,14 @@ MCPMonitoringState >> traceSnapshotFor: anObject depth: depth [ ] { #category : 'private - tracing' } -MCPMonitoringState >> traceSnapshotForAssociation: anAssociation depth: depth [ +MCPLocalObservabilityBackend >> traceSnapshotForAssociation: anAssociation depth: depth [ (self isSensitiveTraceKey: anAssociation key) ifTrue: [ ^ anAssociation key -> '' ]. ^ anAssociation key -> (self traceSnapshotFor: anAssociation value depth: depth + 1) ] { #category : 'private - tracing' } -MCPMonitoringState >> traceSnapshotForCollection: aCollection depth: depth [ +MCPLocalObservabilityBackend >> traceSnapshotForCollection: aCollection depth: depth [ | array limit overflow snapshot | array := aCollection asArray. @@ -805,7 +828,7 @@ MCPMonitoringState >> traceSnapshotForCollection: aCollection depth: depth [ ] { #category : 'private - tracing' } -MCPMonitoringState >> traceSnapshotForDictionary: aDictionary depth: depth [ +MCPLocalObservabilityBackend >> traceSnapshotForDictionary: aDictionary depth: depth [ | snapshot | snapshot := Dictionary new. @@ -820,7 +843,7 @@ MCPMonitoringState >> traceSnapshotForDictionary: aDictionary depth: depth [ ] { #category : 'private - tracing' } -MCPMonitoringState >> traceSnapshotForResult: aToolResult error: anError [ +MCPLocalObservabilityBackend >> traceSnapshotForResult: aToolResult error: anError [ aToolResult ifNotNil: [ ^ self traceSnapshotFor: aToolResult structuredContent ]. anError ifNil: [ ^ nil ]. @@ -830,7 +853,7 @@ MCPMonitoringState >> traceSnapshotForResult: aToolResult error: anError [ ] { #category : 'private - tracing' } -MCPMonitoringState >> traceStringSnapshotFor: aString [ +MCPLocalObservabilityBackend >> traceStringSnapshotFor: aString [ | text | text := aString asString. @@ -839,13 +862,38 @@ MCPMonitoringState >> traceStringSnapshotFor: aString [ ] { #category : 'private - exporting' } -MCPMonitoringState >> writeMetricsSnapshot [ +MCPLocalObservabilityBackend >> writeJSON: aDictionary to: aFileReference [ + + | temporaryFile | + aFileReference parent ensureCreateDirectory. + temporaryFile := aFileReference parent / (aFileReference basename , '.' , UUID new asString , '.tmp'). + [ + temporaryFile writeStreamDo: [ :stream | stream nextPutAll: (STONJSON toString: aDictionary) ]. + aFileReference exists ifTrue: [ aFileReference delete ]. + temporaryFile renameTo: aFileReference basename. + temporaryFile := nil. + aFileReference ] ensure: [ temporaryFile ifNotNil: [ temporaryFile exists ifTrue: [ temporaryFile delete ] ] ] +] + +{ #category : 'private - exporting' } +MCPLocalObservabilityBackend >> writeJSONLine: aString to: aFileReference [ + + aFileReference parent ensureCreateDirectory. + aFileReference writeStreamDo: [ :stream | + stream + setToEnd; + nextPutAll: aString; + lf ] +] + +{ #category : 'private - exporting' } +MCPLocalObservabilityBackend >> writeMetricsSnapshot [ - self metricsFileReference writeStreamDo: [ :stream | stream nextPutAll: (STONJSON toString: self metricsSnapshotDictionary) ] + self writeJSON: self metricsSnapshotDictionary to: self metricsFileReference ] { #category : 'private - exporting' } -MCPMonitoringState >> writeResourceMetadata [ +MCPLocalObservabilityBackend >> writeResourceMetadata [ - self resourceFileReference writeStreamDo: [ :stream | stream nextPutAll: (STONJSON toString: self resourceMetadata) ] + self writeJSON: self resourceMetadata to: self resourceFileReference ] diff --git a/src/MCP/MCPMemoryTinyLogger.class.st b/src/MCP/MCPMemoryTinyLogger.class.st deleted file mode 100644 index daf451c8..00000000 --- a/src/MCP/MCPMemoryTinyLogger.class.st +++ /dev/null @@ -1,77 +0,0 @@ -" -TinyLogger leaf used by MCP monitoring to retain recent log lines in memory for the dashboard. -" -Class { - #name : 'MCPMemoryTinyLogger', - #superclass : 'TinyLeafLogger', - #instVars : [ - 'entries', - 'maxEntries' - ], - #category : 'MCP-Monitoring', - #package : 'MCP', - #tag : 'Monitoring' -} - -{ #category : 'accessing' } -MCPMemoryTinyLogger class >> kind [ - - ^ 'mcpMemory' -] - -{ #category : 'logging' } -MCPMemoryTinyLogger >> addEntry: aString [ - - self entries add: aString. - [ self entries size > self maxEntries ] whileTrue: [ self entries removeFirst ] -] - -{ #category : 'logging' } -MCPMemoryTinyLogger >> clearLog [ - - self entries removeAll -] - -{ #category : 'accessing' } -MCPMemoryTinyLogger >> entries [ - - ^ entries ifNil: [ entries := OrderedCollection new ] -] - -{ #category : 'initialization' } -MCPMemoryTinyLogger >> initialize [ - - super initialize. - entries := OrderedCollection new. - maxEntries := 200 -] - -{ #category : 'accessing' } -MCPMemoryTinyLogger >> maxEntries [ - - ^ maxEntries ifNil: [ 200 ] -] - -{ #category : 'accessing' } -MCPMemoryTinyLogger >> maxEntries: anInteger [ - - maxEntries := anInteger -] - -{ #category : 'opening' } -MCPMemoryTinyLogger >> open [ - - ^ self -] - -{ #category : 'accessing' } -MCPMemoryTinyLogger >> recentEntries [ - - ^ self entries asArray -] - -{ #category : 'logging' } -MCPMemoryTinyLogger >> record: aString [ - - self addEntry: (String streamContents: [ :stream | self record: aString on: stream ]) -] diff --git a/src/MCP/MCPNoopObservabilityBackend.class.st b/src/MCP/MCPNoopObservabilityBackend.class.st new file mode 100644 index 00000000..15287b9d --- /dev/null +++ b/src/MCP/MCPNoopObservabilityBackend.class.st @@ -0,0 +1,178 @@ +" +No-op observability backend for MCP. + +This is the default backend for MCP core. It deliberately has no external dependencies, including OpenTelemetry. +" +Class { + #name : 'MCPNoopObservabilityBackend', + #superclass : 'MCPObservabilityBackend', + #category : 'MCP-Observability', + #package : 'MCP', + #tag : 'Observability' +} + +{ #category : 'clearing' } +MCPNoopObservabilityBackend >> clear [ + "No-op backend has no state to clear." + + +] + +{ #category : 'activation' } +MCPNoopObservabilityBackend >> disable [ + "No-op backend stays disabled." + + +] + +{ #category : 'activation' } +MCPNoopObservabilityBackend >> enable [ + "No-op backend stays disabled." + + +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> enabled [ + + ^ false +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> enabled: aBoolean [ + "No-op backend stays disabled." + + +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> exportDirectory [ + + ^ nil +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> exportDirectory: aPath [ + "No-op backend does not export." + + +] + +{ #category : 'testing' } +MCPNoopObservabilityBackend >> exportEnabled [ + + ^ false +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> exportInstanceDirectory [ + + ^ nil +] + +{ #category : 'actions' } +MCPNoopObservabilityBackend >> forceFlush [ + "No-op backend has nothing to flush." + + +] + +{ #category : 'testing' } +MCPNoopObservabilityBackend >> isNoop [ + + ^ true +] + +{ #category : 'logging' } +MCPNoopObservabilityBackend >> logs [ + + ^ #( ) +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> metrics [ + + ^ #( ) +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> recentCallRecords [ + + ^ #( ) +] + +{ #category : 'recording' } +MCPNoopObservabilityBackend >> recordSessionEndFor: anMCP [ + "No-op backend has no session state." + + +] + +{ #category : 'recording' } +MCPNoopObservabilityBackend >> recordSessionStartFor: anMCP [ + "No-op backend has no session state." + + +] + +{ #category : 'recording' } +MCPNoopObservabilityBackend >> recordToolCallFinish: aRecord result: aToolResult error: anError [ + + ^ nil +] + +{ #category : 'recording' } +MCPNoopObservabilityBackend >> recordToolCallStart: aToolName input: inputObject dispatchPath: dispatchPath context: contextDictionary [ + + ^ nil +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> resourceMetadata [ + + ^ Dictionary new +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> resourceMetadata: aDictionary [ + "No-op backend does not keep resource metadata." + + +] + +{ #category : 'actions' } +MCPNoopObservabilityBackend >> shutdown [ + "No-op backend has nothing to shut down." + + +] + +{ #category : 'displaying' } +MCPNoopObservabilityBackend >> statusText [ + + ^ 'Observability disabled' +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> totalCallCount [ + + ^ 0 +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> totalErrorCount [ + + ^ 0 +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> totalOutputBudgetExceededCount [ + + ^ 0 +] + +{ #category : 'accessing' } +MCPNoopObservabilityBackend >> traceRecords [ + + ^ #( ) +] diff --git a/src/MCP/MCPObservabilityBackend.class.st b/src/MCP/MCPObservabilityBackend.class.st new file mode 100644 index 00000000..70bb55f3 --- /dev/null +++ b/src/MCP/MCPObservabilityBackend.class.st @@ -0,0 +1,190 @@ +" +Abstract protocol for MCP observability backends. + +Core MCP code talks to this protocol so normal tool execution can report observability facts without depending on a concrete telemetry implementation. The core package provides a no-op implementation; optional packages can provide concrete exporters such as OpenTelemetry. +" +Class { + #name : 'MCPObservabilityBackend', + #superclass : 'Object', + #category : 'MCP-Observability', + #package : 'MCP', + #tag : 'Observability' +} + +{ #category : 'testing' } +MCPObservabilityBackend class >> isAbstract [ + + ^ self == MCPObservabilityBackend +] + +{ #category : 'clearing' } +MCPObservabilityBackend >> clear [ + + +] + +{ #category : 'activation' } +MCPObservabilityBackend >> disable [ + + self subclassResponsibility +] + +{ #category : 'activation' } +MCPObservabilityBackend >> enable [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> enabled [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> enabled: aBoolean [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> exportDirectory [ + + ^ nil +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> exportDirectory: aPath [ + + +] + +{ #category : 'testing' } +MCPObservabilityBackend >> exportEnabled [ + + ^ false +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> exportInstanceDirectory [ + + ^ nil +] + +{ #category : 'actions' } +MCPObservabilityBackend >> forceFlush [ + + self subclassResponsibility +] + +{ #category : 'testing' } +MCPObservabilityBackend >> isNoop [ + + self subclassResponsibility +] + +{ #category : 'logging' } +MCPObservabilityBackend >> logs [ + + ^ #( ) +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> metrics [ + + ^ #( ) +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> recentCallRecords [ + + ^ #( ) +] + +{ #category : 'recording' } +MCPObservabilityBackend >> recordSessionEndFor: anMCP [ + + self subclassResponsibility +] + +{ #category : 'recording' } +MCPObservabilityBackend >> recordSessionStartFor: anMCP [ + + self subclassResponsibility +] + +{ #category : 'recording' } +MCPObservabilityBackend >> recordToolCallFinish: aRecord result: aToolResult error: anError [ + + self subclassResponsibility +] + +{ #category : 'recording' } +MCPObservabilityBackend >> recordToolCallStart: aToolName [ + + ^ self recordToolCallStart: aToolName input: nil +] + +{ #category : 'recording' } +MCPObservabilityBackend >> recordToolCallStart: aToolName input: inputObject [ + + ^ self + recordToolCallStart: aToolName + input: inputObject + dispatchPath: 'direct' + context: Dictionary new +] + +{ #category : 'recording' } +MCPObservabilityBackend >> recordToolCallStart: aToolName input: inputObject dispatchPath: dispatchPath context: contextDictionary [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> resourceMetadata [ + + ^ Dictionary new +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> resourceMetadata: aDictionary [ + + +] + +{ #category : 'actions' } +MCPObservabilityBackend >> shutdown [ + + self subclassResponsibility +] + +{ #category : 'displaying' } +MCPObservabilityBackend >> statusText [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> totalCallCount [ + + ^ 0 +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> totalErrorCount [ + + ^ 0 +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> totalOutputBudgetExceededCount [ + + ^ 0 +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> traceRecords [ + + ^ #( ) +] diff --git a/src/MCP/MCPToolCallRecord.class.st b/src/MCP/MCPToolCallRecord.class.st index 76f2aa97..ad9bbe64 100644 --- a/src/MCP/MCPToolCallRecord.class.st +++ b/src/MCP/MCPToolCallRecord.class.st @@ -1,5 +1,5 @@ " -Monitoring DTO for one MCP tool call. Active records have no finishedAt duration yet. Completed records store status, timing, compact output-budget metadata, optional error class, optional outlier explanation, and bounded redacted input/output snapshots only when the call is trace-worthy. The raw input object is held transiently while the call is active and cleared when recording finishes. +Observability DTO for one MCP tool call. Active records have no finishedAt duration yet. Completed records store status, timing, compact output-budget metadata, optional error class, optional outlier explanation, and bounded redacted input/output snapshots only when the call is trace-worthy. The raw input object is held transiently while the call is active and cleared when recording finishes. " Class { #name : 'MCPToolCallRecord', @@ -21,11 +21,14 @@ Class { 'approximateOutputTokenCount', 'outputBudgetBytes', 'outputBudgetExceeded', - 'outputBudgetExplanation' + 'outputBudgetExplanation', + 'dispatchPath', + 'inputByteCount', + 'eventKinds' ], - #category : 'MCP-Monitoring', + #category : 'MCP-Observability', #package : 'MCP', - #tag : 'Monitoring' + #tag : 'Observability' } { #category : 'instance creation' } @@ -57,12 +60,15 @@ MCPToolCallRecord >> asDictionary [ ^ { (#id -> self id). (#toolName -> self toolName). + (#dispatchPath -> self dispatchPath). (#startedAt -> self displayStartedAt). (#durationMilliseconds -> self durationMilliseconds). (#status -> self status). (#errorClass -> self errorClass). (#summary -> self summary). + (#eventKinds -> self eventKinds). (#outlierExplanation -> self outlierExplanation). + (#inputBytes -> self inputByteCount). (#outputBytes -> self outputByteCount). (#approximateOutputTokens -> self approximateOutputTokenCount). (#outputBudgetBytes -> self outputBudgetBytes). @@ -72,6 +78,18 @@ MCPToolCallRecord >> asDictionary [ (#output -> self outputSnapshot) } asDictionary ] +{ #category : 'accessing' } +MCPToolCallRecord >> dispatchPath [ + + ^ dispatchPath ifNil: [ 'direct' ] +] + +{ #category : 'accessing' } +MCPToolCallRecord >> dispatchPath: aString [ + + dispatchPath := aString +] + { #category : 'displaying' } MCPToolCallRecord >> displayDurationMilliseconds [ @@ -118,6 +136,18 @@ MCPToolCallRecord >> errorClass: aString [ errorClass := aString ] +{ #category : 'accessing' } +MCPToolCallRecord >> eventKinds [ + + ^ eventKinds ifNil: [ #( ) ] +] + +{ #category : 'accessing' } +MCPToolCallRecord >> eventKinds: aCollection [ + + eventKinds := aCollection asArray +] + { #category : 'recording' } MCPToolCallRecord >> finishAt: finishTimestamp durationMilliseconds: milliseconds status: statusString [ @@ -150,6 +180,18 @@ MCPToolCallRecord >> id: anInteger [ id := anInteger ] +{ #category : 'accessing' } +MCPToolCallRecord >> inputByteCount [ + + ^ inputByteCount ifNil: [ 0 ] +] + +{ #category : 'accessing' } +MCPToolCallRecord >> inputByteCount: anInteger [ + + inputByteCount := anInteger +] + { #category : 'private' } MCPToolCallRecord >> inputObject [ diff --git a/src/MCP/MCPToolCallStats.class.st b/src/MCP/MCPToolCallStats.class.st index 3068cb9b..b52e4aff 100644 --- a/src/MCP/MCPToolCallStats.class.st +++ b/src/MCP/MCPToolCallStats.class.st @@ -1,5 +1,5 @@ " -Aggregated monitoring metrics for one MCP tool. It tracks call counts, success/error counts, timing totals, min/max/last duration, output-size totals and maxima, output-budget overruns, and the last outlier explanation. +Aggregated observability metrics for one MCP tool. It tracks call counts, success/error counts, timing totals, min/max/last duration, output-size totals and maxima, output-budget overruns, and the last outlier explanation. " Class { #name : 'MCPToolCallStats', @@ -13,6 +13,7 @@ Class { 'minMilliseconds', 'maxMilliseconds', 'lastMilliseconds', + 'firstStartedAt', 'lastStartedAt', 'lastFinishedAt', 'lastOutlierExplanation', @@ -20,11 +21,12 @@ Class { 'maxOutputBytes', 'lastOutputBytes', 'lastApproximateOutputTokens', - 'outputBudgetExceededCount' + 'outputBudgetExceededCount', + 'errorCountsByClass' ], - #category : 'MCP-Monitoring', + #category : 'MCP-Observability', #package : 'MCP', - #tag : 'Monitoring' + #tag : 'Observability' } { #category : 'instance creation' } @@ -43,10 +45,13 @@ MCPToolCallStats >> asDictionary [ (#callCount -> self callCount). (#successCount -> self successCount). (#errorCount -> self errorCount). + (#errorCountsByClass -> self errorCountsByClass copy). (#averageMilliseconds -> self averageMilliseconds asFloat). (#minMilliseconds -> (self minMilliseconds ifNil: [ 0 ])). (#maxMilliseconds -> (self maxMilliseconds ifNil: [ 0 ])). (#lastMilliseconds -> (self lastMilliseconds ifNil: [ 0 ])). + (#firstCallAt -> self firstCallAt). + (#lastCallAt -> self lastCallAt). (#averageOutputBytes -> self averageOutputBytes asFloat). (#maxOutputBytes -> self maxOutputBytes). (#lastOutputBytes -> self lastOutputBytes). @@ -63,11 +68,20 @@ MCPToolCallStats >> asMetricsDictionary [ dictionary := { (#toolName -> self toolName). (#callCount -> self callCount). + (#successCount -> self successCount). (#averageMilliseconds -> self averageMilliseconds asFloat). - (#maxMilliseconds -> self maxMilliseconds). + (#minMilliseconds -> (self minMilliseconds ifNil: [ 0 ])). + (#maxMilliseconds -> (self maxMilliseconds ifNil: [ 0 ])). + (#lastMilliseconds -> (self lastMilliseconds ifNil: [ 0 ])). + (#firstCallAt -> self firstCallAt). + (#lastCallAt -> self lastCallAt). (#averageOutputBytes -> self averageOutputBytes asFloat). - (#maxOutputBytes -> self maxOutputBytes) } asDictionary. - self errorCount positive ifTrue: [ dictionary at: #errorCount put: self errorCount ]. + (#maxOutputBytes -> self maxOutputBytes). + (#lastOutputBytes -> self lastOutputBytes). + (#lastApproximateOutputTokens -> self lastApproximateOutputTokens) } asDictionary. + self errorCount positive ifTrue: [ + dictionary at: #errorCount put: self errorCount. + dictionary at: #errorCountsByClass put: self errorCountsByClass copy ]. self outputBudgetExceededCount positive ifTrue: [ dictionary at: #outputBudgetExceededCount put: self outputBudgetExceededCount ]. self lastOutlierExplanation ifNotNil: [ :explanation | explanation isEmpty ifFalse: [ dictionary at: #lastOutlierExplanation put: explanation ] ]. @@ -154,6 +168,12 @@ MCPToolCallStats >> errorCount [ ^ errorCount ifNil: [ 0 ] ] +{ #category : 'accessing' } +MCPToolCallStats >> errorCountsByClass [ + + ^ errorCountsByClass ifNil: [ errorCountsByClass := Dictionary new ] +] + { #category : 'accessing' } MCPToolCallStats >> errorRate [ @@ -161,6 +181,12 @@ MCPToolCallStats >> errorRate [ ^ self errorCount / self callCount ] +{ #category : 'accessing' } +MCPToolCallStats >> firstCallAt [ + + ^ self timestampStringFor: firstStartedAt +] + { #category : 'initialization' } MCPToolCallStats >> initialize [ @@ -170,7 +196,8 @@ MCPToolCallStats >> initialize [ errorCount := 0. totalMilliseconds := 0. totalOutputBytes := 0. - outputBudgetExceededCount := 0 + outputBudgetExceededCount := 0. + errorCountsByClass := Dictionary new ] { #category : 'accessing' } @@ -179,6 +206,12 @@ MCPToolCallStats >> lastApproximateOutputTokens [ ^ lastApproximateOutputTokens ifNil: [ 0 ] ] +{ #category : 'accessing' } +MCPToolCallStats >> lastCallAt [ + + ^ self timestampStringFor: lastFinishedAt +] + { #category : 'accessing' } MCPToolCallStats >> lastMilliseconds [ @@ -248,7 +281,7 @@ MCPToolCallStats >> recordCall: aRecord [ callCount := self callCount + 1. statusString = 'ok' ifTrue: [ successCount := self successCount + 1 ] - ifFalse: [ errorCount := self errorCount + 1 ]. + ifFalse: [ self recordErrorFor: aRecord ]. totalMilliseconds := self totalMilliseconds + duration. minMilliseconds := self minMilliseconds ifNil: [ duration ] ifNotNil: [ :current | current min: duration ]. maxMilliseconds := self maxMilliseconds ifNil: [ duration ] ifNotNil: [ :current | current max: duration ]. @@ -257,18 +290,33 @@ MCPToolCallStats >> recordCall: aRecord [ lastOutputBytes := outputBytes. lastApproximateOutputTokens := aRecord approximateOutputTokenCount. aRecord outputBudgetExceeded ifTrue: [ outputBudgetExceededCount := self outputBudgetExceededCount + 1 ]. + firstStartedAt ifNil: [ firstStartedAt := aRecord startedAt ]. lastMilliseconds := duration. lastStartedAt := aRecord startedAt. lastFinishedAt := aRecord finishedAt. lastOutlierExplanation := aRecord outlierExplanation ] +{ #category : 'recording' } +MCPToolCallStats >> recordErrorFor: aRecord [ + + errorCount := self errorCount + 1. + aRecord errorClass ifNotEmpty: [ :className | + self errorCountsByClass at: className put: (self errorCountsByClass at: className ifAbsent: [ 0 ]) + 1 ] +] + { #category : 'accessing' } MCPToolCallStats >> successCount [ ^ successCount ifNil: [ 0 ] ] +{ #category : 'private' } +MCPToolCallStats >> timestampStringFor: aTimestamp [ + + ^ aTimestamp ifNil: [ '' ] ifNotNil: [ :timestamp | timestamp asString ] +] + { #category : 'accessing' } MCPToolCallStats >> toolName [