From 6a032e2f697431595a78850446522469baa8ca87 Mon Sep 17 00:00:00 2001 From: Gabriel Darbord <78592838+Gabriel-Darbord@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:43:01 +0200 Subject: [PATCH 1/8] Add optional OpenTelemetry observability backend --- docs/user/safety-and-ecosystem.md | 36 +- src/BaselineOfMCP/BaselineOfMCP.class.st | 8 +- src/MCP-OpenTelemetry/MCP.extension.st | 8 + ...OpenTelemetryObservabilityBackend.class.st | 413 ++++++++++++++++++ src/MCP-OpenTelemetry/package.st | 1 + .../MCPDashboardPresenterTest.class.st | 181 +++++--- .../MCPMonitoringStateTest.class.st | 247 ++++++++--- src/MCP/MCP.class.st | 192 ++++++-- src/MCP/MCPMonitoringState.class.st | 263 ++++++++--- src/MCP/MCPNoopObservabilityBackend.class.st | 178 ++++++++ src/MCP/MCPObservabilityBackend.class.st | 190 ++++++++ src/MCP/MCPToolCallRecord.class.st | 61 ++- src/MCP/MCPToolCallStats.class.st | 65 ++- 13 files changed, 1612 insertions(+), 231 deletions(-) create mode 100644 src/MCP-OpenTelemetry/MCP.extension.st create mode 100644 src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st create mode 100644 src/MCP-OpenTelemetry/package.st create mode 100644 src/MCP/MCPNoopObservabilityBackend.class.st create mode 100644 src/MCP/MCPObservabilityBackend.class.st diff --git a/docs/user/safety-and-ecosystem.md b/docs/user/safety-and-ecosystem.md index aead7a7..e8fc43b 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 monitoringEnabled: true +``` + +By default, JSON observability exports under the image-local +`pharo-local/mcp/observability` directory. Configure +`mcp monitoringExportDirectory: 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, TinyLogger, 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 228e35a..36652ed 100644 --- a/src/BaselineOfMCP/BaselineOfMCP.class.st +++ b/src/BaselineOfMCP/BaselineOfMCP.class.st @@ -33,7 +33,11 @@ BaselineOfMCP >> defineDependencies: spec [ baseline: 'PCRE2' with: [ spec repository: 'github://Gabriel-Darbord/pharo-pcre2:v1.0.0/src'; - loads: #( 'Core' ) ] + loads: #( 'Core' ) ]; + baseline: 'OpenTelemetry' with: [ + spec + repository: 'github://Gabriel-Darbord/opentelemetry-pharo:main/src'; + loads: #( 'traces' 'metrics' 'logs' ) ] ] { #category : 'baselines' } @@ -42,6 +46,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' ) @@ -53,6 +58,7 @@ BaselineOfMCP >> definePackages: spec [ spec package: 'MCP' with: [ spec requires: #( 'PharoCompatibility' 'JRPC' 'TinyLogger' 'PCRE2' ) ]. spec package: 'MCP-UI' with: [ spec requires: #( 'MCP' ) ]. + spec package: 'MCP-OpenTelemetry' with: [ spec requires: #( 'MCP' 'OpenTelemetry' ) ]. 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 0000000..4337232 --- /dev/null +++ b/src/MCP-OpenTelemetry/MCP.extension.st @@ -0,0 +1,8 @@ +Extension { #name : 'MCP' } + +{ #category : '*MCP-OpenTelemetry' } +MCP >> useOpenTelemetryObservability [ + + self monitoring: MCPOpenTelemetryObservabilityBackend new. + ^ self monitoring +] diff --git a/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st b/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st new file mode 100644 index 0000000..6cca41c --- /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 : 'MCPNoopObservabilityBackend', + #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 0000000..883eee1 --- /dev/null +++ b/src/MCP-OpenTelemetry/package.st @@ -0,0 +1 @@ +Package { #name : 'MCP-OpenTelemetry' } diff --git a/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st b/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st index 5a3d17b..4712f83 100644 --- a/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st +++ b/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st @@ -44,13 +44,22 @@ MCPDashboardPresenterTest >> testCheckboxRowsIncludeLeftPadding [ | configurationLayout observabilityLayout | configurationLayout := MCPDashboardPresenter new configurationLayout. - observabilityLayout := MCPDashboardPresenter new monitoringControlsLayout. + observabilityLayout := MCPDashboardPresenter new + monitoringControlsLayout. self assert: configurationLayout children size equals: 5. - self assert: configurationLayout children third class equals: SpNullPresenter. - self assert: configurationLayout children fourth label equals: 'Debug mode'. - self assert: observabilityLayout children first class equals: SpNullPresenter. - self assert: observabilityLayout children fourth class equals: SpNullPresenter + self + assert: configurationLayout children third class + equals: SpNullPresenter. + self + assert: configurationLayout children fourth label + equals: 'Debug mode'. + self + assert: observabilityLayout children first class + equals: SpNullPresenter. + self + assert: observabilityLayout children fourth class + equals: SpNullPresenter ] { #category : 'tests' } @@ -80,7 +89,9 @@ MCPDashboardPresenterTest >> testCollapsedMetricsSectionKeepsHeaderVisible [ self deny: presenter isMetricsSectionExpanded. self assert: collapsedLayout children size equals: 1. - self assert: collapsedLayout children first class equals: SpButtonPresenter + self + assert: collapsedLayout children first class + equals: SpButtonPresenter ] { #category : 'tests' } @@ -204,9 +215,12 @@ MCPDashboardPresenterTest >> testDebugModeCheckboxExplainsBehavior [ debugCheckBox := presenter instVarNamed: #debugCheckBox. self assert: debugCheckBox label equals: 'Debug mode'. - self assert: (debugCheckBox help includesSubstring: 'server and JSON-RPC errors pass through'). - self assert: (debugCheckBox help includesSubstring: 'debugger can open'). - self assert: (debugCheckBox help includesSubstring: 'normal error responses') + self assert: (debugCheckBox help includesSubstring: + 'server and JSON-RPC errors pass through'). + self assert: + (debugCheckBox help includesSubstring: 'debugger can open'). + self assert: + (debugCheckBox help includesSubstring: 'normal error responses') ] { #category : 'tests' } @@ -215,27 +229,33 @@ MCPDashboardPresenterTest >> testDisclosureLabelsIncludeStateTitleAndCount [ | presenter | presenter := MCPDashboardPresenter new. - self assert: (presenter disclosureLabelFor: 'Logs' count: 3 expanded: true) equals: '[-] Logs (3)'. - self assert: (presenter disclosureLabelFor: 'Logs' count: 3 expanded: false) equals: '[+] Logs (3)' + self + assert: + (presenter disclosureLabelFor: 'Logs' count: 3 expanded: true) + equals: '[-] Logs (3)'. + self + assert: + (presenter disclosureLabelFor: 'Logs' count: 3 expanded: false) + equals: '[+] Logs (3)' ] { #category : 'tests' } -MCPDashboardPresenterTest >> testFreshMCPWithObservabilityEnabledExpandsMonitoringSections [ +MCPDashboardPresenterTest >> testFreshMCPWithNoopObservabilityCollapsesMonitoringSections [ | mcp presenter | mcp := MCP new. presenter := MCPDashboardPresenter on: mcp. presenter refreshView. - self assert: mcp monitoringEnabled. + self deny: mcp monitoringEnabled. 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' } @@ -252,45 +272,56 @@ MCPDashboardPresenterTest >> testHeaderLayoutUsesStatusAndEndpointRows [ self assert: statusRow class equals: SpBoxLayout. self assert: statusRow isHorizontal. self assert: statusRow children size equals: 3. - self assert: statusRow children first identicalTo: (presenter instVarNamed: #statusLabel). - self assert: statusRow children second identicalTo: (presenter instVarNamed: #statusIcon). - self assert: statusRow children third identicalTo: (presenter instVarNamed: #statusValueLabel). - self assert: headerLayout children second identicalTo: (presenter instVarNamed: #urlLabel) + self + assert: statusRow children first + identicalTo: (presenter instVarNamed: #statusLabel). + self + assert: statusRow children second + identicalTo: (presenter instVarNamed: #statusIcon). + self + assert: statusRow children third + identicalTo: (presenter instVarNamed: #statusValueLabel). + self + assert: headerLayout children second + identicalTo: (presenter instVarNamed: #urlLabel) ] { #category : 'tests' } -MCPDashboardPresenterTest >> testInitialSectionsOpenForObservabilityEnabled [ +MCPDashboardPresenterTest >> testInitialSectionsCollapseAfterObservabilityIsDisabled [ - | mcp presenter | + | mcp presenter record | 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. 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 monitoringEnabled. + 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. 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' } @@ -310,7 +341,10 @@ MCPDashboardPresenterTest >> testLogsForDisplayAreTailFirst [ | presenter | presenter := MCPDashboardPresenter new. - self assert: (presenter logsForDisplayFrom: #( 'oldest' 'middle' 'newest' )) equals: #( 'newest' 'middle' 'oldest' ) + self + assert: + (presenter logsForDisplayFrom: #( 'oldest' 'middle' 'newest' )) + equals: #( 'newest' 'middle' 'oldest' ) ] { #category : 'tests' } @@ -321,11 +355,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. @@ -349,7 +379,9 @@ MCPDashboardPresenterTest >> testObservabilityControlsStartWithEnableRefreshAuto layout := MCPDashboardPresenter new monitoringControlsLayout. self assert: layout children size equals: 6. - self assert: layout children second label equals: 'Enable observability'. + self + assert: layout children second label + equals: 'Enable observability'. self assert: layout children third label equals: 'Refresh'. self assert: layout children fifth label equals: 'Auto-refresh'. self assert: layout children sixth label equals: 'Clear' @@ -362,11 +394,11 @@ MCPDashboardPresenterTest >> testObservabilityToggleUpdatesModelImmediately [ mcp := MCP new. presenter := MCPDashboardPresenter on: mcp. - self assert: mcp monitoringEnabled. - presenter observabilityChangedTo: false. self deny: mcp monitoringEnabled. presenter observabilityChangedTo: true. - self assert: mcp monitoringEnabled + self assert: mcp monitoringEnabled. + presenter observabilityChangedTo: false. + self deny: mcp monitoringEnabled ] { #category : 'tests' } @@ -380,7 +412,9 @@ MCPDashboardPresenterTest >> testRefreshViewUpdatesStatusIcon [ statusLabel := presenter instVarNamed: #statusLabel. statusValueLabel := presenter instVarNamed: #statusValueLabel. - self assert: statusIcon image equals: (presenter iconNamed: #testNotRun). + self + assert: statusIcon image + equals: (presenter iconNamed: #testNotRun). self assert: statusIcon help equals: 'Status: stopped'. self assert: statusLabel label equals: 'Status:'. self assert: statusValueLabel label equals: 'stopped' @@ -398,10 +432,13 @@ MCPDashboardPresenterTest >> testRefreshViewUsesShortSectionLabels [ tracesHeader := presenter instVarNamed: #tracesHeaderButton. 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: 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)' ] { #category : 'tests' } @@ -424,10 +461,22 @@ MCPDashboardPresenterTest >> testServerStatusIconUsesTestIcons [ | presenter | presenter := MCPDashboardPresenter new. - self assert: (presenter serverStatusIconNameForRunning: false listening: false) equals: #testNotRun. - self assert: (presenter serverStatusIconNameForRunning: true listening: true) equals: #testGreen. - self assert: (presenter serverStatusIconNameForRunning: true listening: false) equals: #testRed. - self assert: (presenter serverStatusIconNameForRunning: false listening: true) equals: #testRed + self + assert: + (presenter serverStatusIconNameForRunning: false listening: false) + equals: #testNotRun. + self + assert: + (presenter serverStatusIconNameForRunning: true listening: true) + equals: #testGreen. + self + assert: + (presenter serverStatusIconNameForRunning: true listening: false) + equals: #testRed. + self + assert: + (presenter serverStatusIconNameForRunning: false listening: true) + equals: #testRed ] { #category : 'tests' } @@ -436,10 +485,22 @@ MCPDashboardPresenterTest >> testServerStatusLabelShowsListenerOnlyWhenAnomalous | presenter | presenter := MCPDashboardPresenter new. - self assert: (presenter serverStatusLabelForRunning: false listening: false) equals: 'Status: stopped'. - self assert: (presenter serverStatusLabelForRunning: true listening: true) equals: 'Status: running'. - self assert: (presenter serverStatusLabelForRunning: true listening: false) equals: 'Status: running, socket not listening'. - self assert: (presenter serverStatusLabelForRunning: false listening: true) equals: 'Status: stopped, socket still listening' + self + assert: + (presenter serverStatusLabelForRunning: false listening: false) + equals: 'Status: stopped'. + self + assert: + (presenter serverStatusLabelForRunning: true listening: true) + equals: 'Status: running'. + self + assert: + (presenter serverStatusLabelForRunning: true listening: false) + equals: 'Status: running, socket not listening'. + self + assert: + (presenter serverStatusLabelForRunning: false listening: true) + equals: 'Status: stopped, socket still listening' ] { #category : 'tests' } diff --git a/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st b/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st index 97f12d5..928cc2b 100644 --- a/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st +++ b/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st @@ -9,6 +9,16 @@ Class { #tag : 'Monitoring' } +{ #category : 'tests' } +MCPMonitoringStateTest >> testDefaultExportDirectoryIsImageLocal [ + + self + assert: MCPMonitoringState defaultExportDirectory + equals: + Smalltalk imageFile asFileReference parent / 'pharo-local' / 'mcp' + / 'observability' +] + { #category : 'tests' } MCPMonitoringStateTest >> testEnabledByDefaultRecords [ @@ -40,13 +50,21 @@ MCPMonitoringStateTest >> testInputSnapshotRedactsSensitiveKeys [ input := { (#limit -> 10). (#apiToken -> 'secret-token'). - (#nested -> { (#password -> 'secret-password') } asDictionary) } asDictionary. - record := state recordToolCallStart: 'method_metadata_search' input: input. + (#nested + -> { (#password -> 'secret-password') } asDictionary) } + asDictionary. + record := state + recordToolCallStart: 'method_metadata_search' + input: input. state recordToolCallFinish: record result: nil error: Error new. self assert: (record inputSnapshot at: #limit) equals: 10. - self assert: (record inputSnapshot at: #apiToken) equals: ''. - self assert: ((record inputSnapshot at: #nested) at: #password) equals: ''. + self + assert: (record inputSnapshot at: #apiToken) + equals: ''. + self + assert: ((record inputSnapshot at: #nested) at: #password) + equals: ''. self assert: record inputObject isNil ] @@ -59,9 +77,11 @@ MCPMonitoringStateTest >> testLargeOutputExceedingBudgetBecomesTraceAndGuidance state enable. largeText := String new: 200 withAll: $x. record := state recordToolCallStart: 'method_source_search'. - result := MCPStructureCallToolResult content: #( ) structuredContent: { - (#summary -> 'Large output'). - (#items -> { largeText }) } asDictionary. + result := MCPStructureCallToolResult + content: #( ) + structuredContent: { + (#summary -> 'Large output'). + (#items -> { largeText }) } asDictionary. state recordToolCallFinish: record result: result error: nil. stats := state metrics first. budgetBytes := state outputBudgetBytes. @@ -76,36 +96,70 @@ MCPMonitoringStateTest >> testLargeOutputExceedingBudgetBecomesTraceAndGuidance self assert: stats outputBudgetExceededCount equals: 1. self assert: stats maxOutputBytes equals: record outputByteCount. self assert: (state statusText includesSubstring: 'Output budget:'). - self assert: (state outputBudgetGuidance includesSubstring: 'pagination') + self assert: + (state outputBudgetGuidance includesSubstring: 'pagination') ] { #category : 'tests' } MCPMonitoringStateTest >> testMCPConfiguresMonitoringExportDirectory [ | defaultDirectory directory mcp | - directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). + directory := FileLocator temp asFileReference + / ('mcp-observability-' , UUID new asString). [ mcp := MCP new. defaultDirectory := MCPMonitoringState defaultExportDirectory. + self deny: mcp monitoringEnabled. + self assert: mcp monitoring class equals: MCPNoopObservabilityBackend. + self assert: mcp monitoringExportDirectory isNil. + mcp monitoringEnabled: true. 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: [ - directory exists ifTrue: [ directory deleteAll ] ] + self + assert: (mcp monitoring resourceMetadata at: #'pharo.majorVersion') + equals: PharoCompatibility currentMajorVersion. + self assert: + (mcp monitoring resourceMetadata includesKey: #'mcp.port') ] + ensure: [ directory exists ifTrue: [ directory deleteAll ] ] +] + +{ #category : 'tests' } +MCPMonitoringStateTest >> testMCPRecordsToolCallDispatchPath [ + + | mcp records | + mcp := MCP new. + mcp monitoringEnabled: true. + + mcp + rpcToolCall: 'tool_call' + withParams: { + (#toolName -> 'package_search'). + (#arguments -> { (#limit -> 0) } asDictionary) } + asDictionary. + records := mcp monitoring 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' } MCPMonitoringStateTest >> testMCPRecordsToolCallsWhenMonitoringIsEnabled [ | directory mcp record result stats | - directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). + directory := FileLocator temp asFileReference + / ('mcp-observability-' , UUID new asString). [ directory exists ifTrue: [ directory deleteAll ]. mcp := MCP new. mcp monitoringExportDirectory: directory. - result := mcp rpcToolCall: 'package_search' withParams: { (#limit -> 0) } asDictionary. + result := mcp + rpcToolCall: 'package_search' + withParams: { (#limit -> 0) } asDictionary. stats := mcp monitoringMetrics first. record := mcp monitoring recentCallRecords first. @@ -117,8 +171,9 @@ MCPMonitoringStateTest >> testMCPRecordsToolCallsWhenMonitoringIsEnabled [ 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 monitoring logFileReference exists. + self assert: mcp monitoring metricsFileReference exists ] ensure: [ + directory exists ifTrue: [ directory deleteAll ] ] ] { #category : 'tests' } @@ -131,15 +186,22 @@ MCPMonitoringStateTest >> testOutlierExplanationUsesPriorAverage [ stats := MCPToolCallStats forToolNamed: 'slow-tool'. start := DateAndTime now. 1 to: 3 do: [ :index | - record := MCPToolCallRecord id: index toolName: 'slow-tool' startedAt: start. + record := MCPToolCallRecord + id: index + toolName: 'slow-tool' + startedAt: start. record finishAt: start durationMilliseconds: 10 status: 'ok'. stats recordCall: record ]. - explanation := state outlierExplanationForDurationMilliseconds: 25 stats: stats. + explanation := state + outlierExplanationForDurationMilliseconds: 25 + stats: stats. self assert: (explanation beginsWith: '25 ms is'). self deny: (explanation beginsWith: 'Outlier:'). - self assert: (state outlierExplanationForDurationMilliseconds: 15 stats: stats) isEmpty + self assert: + (state outlierExplanationForDurationMilliseconds: 15 stats: stats) + isEmpty ] { #category : 'tests' } @@ -156,12 +218,19 @@ 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'. + self + assert: (recentCall outputSnapshot at: #errorClass) + equals: 'Error'. self assert: recentCall inputObject isNil. - self assert: (state logs anySatisfy: [ :each | each includesSubstring: 'Error tool method_compile #' ]). - self deny: (state logs anySatisfy: [ :each | each includesSubstring: 'error tool method_compile #' ]) + self assert: (state logs anySatisfy: [ :each | + each includesSubstring: 'Error tool method_compile #' ]). + self deny: (state logs anySatisfy: [ :each | + each includesSubstring: 'error tool method_compile #' ]) ] { #category : 'tests' } @@ -171,22 +240,37 @@ MCPMonitoringStateTest >> testRecordsOutputBudgetMetadata [ state := MCPMonitoringState new. state enable. record := state recordToolCallStart: 'method_metadata_search'. - result := MCPStructureCallToolResult content: #( ) structuredContent: { (#summary -> 'Small output') } asDictionary. + result := MCPStructureCallToolResult + content: #( ) + structuredContent: + { (#summary -> 'Small output') } asDictionary. state recordToolCallFinish: record result: result error: nil. stats := state metrics first. recordData := record asDictionary. statsData := stats asDictionary. self assert: record outputByteCount > 0. - self assert: record approximateOutputTokenCount equals: (state approximateOutputTokensForBytes: record outputByteCount). + self assert: record inputByteCount equals: 0. + self + assert: record approximateOutputTokenCount + equals: + (state approximateOutputTokensForBytes: record outputByteCount). self deny: record outputBudgetExceeded. self assert: record outputBudgetExplanation isEmpty. self assert: record outputSnapshot isNil. self assert: stats lastOutputBytes equals: record outputByteCount. 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: (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: #firstCallAt) isNotEmpty. + self assert: (statsData at: #lastCallAt) isNotEmpty ] { #category : 'tests' } @@ -196,7 +280,10 @@ MCPMonitoringStateTest >> testRecordsSuccessfulCallMetricsAndLogs [ state := MCPMonitoringState new. state enable. record := state recordToolCallStart: 'method_metadata_search'. - result := MCPStructureCallToolResult content: #( ) structuredContent: { (#summary -> 'Listed methods') } asDictionary. + result := MCPStructureCallToolResult + content: #( ) + structuredContent: + { (#summary -> 'Listed methods') } asDictionary. state recordToolCallFinish: record result: result error: nil. stats := state metrics first. @@ -206,36 +293,69 @@ MCPMonitoringStateTest >> testRecordsSuccessfulCallMetricsAndLogs [ self assert: stats errorCount equals: 0. self assert: state recentCallRecords size equals: 1. self assert: state logs size >= 2. - self assert: (state logs anySatisfy: [ :each | each includesSubstring: 'Begin tool method_metadata_search #' ]). - self assert: (state logs anySatisfy: [ :each | each includesSubstring: 'OK tool method_metadata_search #' ]). - self deny: (state logs anySatisfy: [ :each | each includesSubstring: 'ok tool method_metadata_search #' ]). - self assert: (state statusText beginsWith: 'Observability enabled. Calls: 1') + self assert: (state logs anySatisfy: [ :each | + each includesSubstring: 'Begin tool method_metadata_search #' ]). + self assert: (state logs anySatisfy: [ :each | + each includesSubstring: 'OK tool method_metadata_search #' ]). + self deny: (state logs anySatisfy: [ :each | + each includesSubstring: 'ok tool method_metadata_search #' ]). + self assert: + (state statusText beginsWith: 'Observability enabled. Calls: 1') +] + +{ #category : 'tests' } +MCPMonitoringStateTest >> testSessionLifecycleWritesResourceMarkers [ + + | directory resource state | + directory := FileLocator temp asFileReference + / ('mcp-observability-' , UUID new asString). + [ + directory exists ifTrue: [ directory deleteAll ]. + state := MCPMonitoringState 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' } MCPMonitoringStateTest >> testTraceExportSkipsNonTraceRecords [ | directory record result state | - directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). + directory := FileLocator temp asFileReference + / ('mcp-observability-' , UUID new asString). [ directory exists ifTrue: [ directory deleteAll ]. state := MCPMonitoringState new. state exportDirectory: directory. state outputBudgetBytes: 10000. record := state recordToolCallStart: 'package_search'. - result := MCPStructureCallToolResult content: #( ) structuredContent: { (#summary -> 'Small output') } asDictionary. + result := MCPStructureCallToolResult + content: #( ) + structuredContent: + { (#summary -> 'Small output') } asDictionary. state recordToolCallFinish: record result: result error: nil. self assert: directory exists. self deny: state traceFileReference exists. self assert: state logFileReference exists. - self assert: state metricsFileReference exists ] ensure: [ directory exists ifTrue: [ directory deleteAll ] ] + self assert: state metricsFileReference exists ] ensure: [ + directory exists ifTrue: [ directory deleteAll ] ] ] { #category : 'tests' } MCPMonitoringStateTest >> testTraceExportWritesOverBudgetRecordAsJSONLine [ | directory input line output payload record resource result state traceFile | - directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). + directory := FileLocator temp asFileReference + / ('mcp-observability-' , UUID new asString). [ directory exists ifTrue: [ directory deleteAll ]. state := MCPMonitoringState new. @@ -247,32 +367,48 @@ MCPMonitoringStateTest >> testTraceExportWritesOverBudgetRecordAsJSONLine [ input := { (#limit -> 1). (#apiToken -> 'secret-token') } asDictionary. - record := state recordToolCallStart: 'method_source_search' input: input. - result := MCPStructureCallToolResult content: #( ) structuredContent: { - (#summary -> 'Large output'). - (#items -> { (String new: 200 withAll: $x) }) } asDictionary. + record := state + recordToolCallStart: 'method_source_search' + input: input. + result := MCPStructureCallToolResult + content: #( ) + structuredContent: { + (#summary -> 'Large output'). + (#items -> { (String new: 200 withAll: $x) }) } + asDictionary. state recordToolCallFinish: record result: result error: nil. self assert: state exportInstanceDirectory exists. - self assert: (state exportInstanceDirectory basename beginsWith: 'pharo-13-9013-'). + self assert: + (state exportInstanceDirectory basename beginsWith: + 'pharo-13-9013-'). self assert: state resourceFileReference exists. self assert: state logFileReference exists. self assert: state metricsFileReference exists. - resource := STONJSON fromString: state resourceFileReference contents. + resource := STONJSON fromString: + state resourceFileReference contents. self assert: (resource at: 'service.name') equals: 'mcp-pharo'. self assert: (resource at: 'pharo.majorVersion') equals: 13. traceFile := state traceFileReference. self assert: traceFile exists. line := traceFile contents lines first. payload := STONJSON fromString: line. - self assert: (payload at: 'event.name') equals: 'mcp.tool_call.output_budget_exceeded'. + 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'). - self assert: ((payload at: 'input') at: 'apiToken') equals: ''. + self + assert: ((payload at: 'input') at: 'apiToken') + equals: ''. output := payload at: 'output'. self assert: (output at: 'summary') equals: 'Large output'. - self assert: (output at: 'items') first size equals: 200 ] ensure: [ directory exists ifTrue: [ directory deleteAll ] ] + self assert: (output at: 'items') first size equals: 200 ] ensure: [ + directory exists ifTrue: [ directory deleteAll ] ] ] { #category : 'tests' } @@ -281,13 +417,23 @@ MCPMonitoringStateTest >> testTraceRecordsIncludeErrorsAndOutliersOnly [ | error normal outlier start state traces | state := MCPMonitoringState new. start := DateAndTime now. - normal := MCPToolCallRecord id: 1 toolName: 'method_metadata_search' startedAt: start. + normal := MCPToolCallRecord + id: 1 + toolName: 'method_metadata_search' + startedAt: start. normal finishAt: start durationMilliseconds: 5 status: 'ok'. - error := MCPToolCallRecord id: 2 toolName: 'method_compile' startedAt: start. + error := MCPToolCallRecord + id: 2 + toolName: 'method_compile' + startedAt: start. error finishAt: start durationMilliseconds: 7 status: 'error'. - outlier := MCPToolCallRecord id: 3 toolName: 'method_rewrite' startedAt: start. + outlier := MCPToolCallRecord + id: 3 + toolName: 'method_rewrite' + startedAt: start. outlier finishAt: start durationMilliseconds: 250 status: 'ok'. - outlier outlierExplanation: '250 ms is 5.0x the prior average of 50.0 ms'. + outlier outlierExplanation: + '250 ms is 5.0x the prior average of 50.0 ms'. state recentCalls addAll: { outlier. error. @@ -308,7 +454,8 @@ MCPMonitoringStateTest >> testTraceSnapshotForCollectionAddsTruncationMarker [ | snapshot state | state := MCPMonitoringState new. - snapshot := state traceSnapshotFor: (1 to: state maxTraceCollectionItems + 2) asArray. + 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' ] diff --git a/src/MCP/MCP.class.st b/src/MCP/MCP.class.st index 6266105..5563859 100644 --- a/src/MCP/MCP.class.st +++ b/src/MCP/MCP.class.st @@ -98,6 +98,31 @@ MCP >> deferSaveImageAfterCurrentResponse [ server deferAfterCurrentResponse: [ self saveImageSession ] ] +{ #category : 'private - tools' } +MCP >> dispatchToolCallWith: callTool arguments: arguments monitorRecord: monitorRecord [ + + | 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: monitorRecord result: wrapperResult. + ^ targetResponse +] + +{ #category : 'monitoring' } +MCP >> ensureConcreteMonitoring [ + + self monitoring isNoop ifTrue: [ monitoring := self newMonitoringState ]. + ^ self monitoring +] + { #category : 'private - start' } MCP >> ensureServerIsListening [ @@ -111,26 +136,67 @@ MCP >> ensureServerIsListening [ { #category : 'private - tools' } MCP >> executeTool: tool named: toolName withParams: arguments [ + ^ self + executeTool: tool + named: toolName + withParams: arguments + dispatchPath: 'direct' +] + +{ #category : 'private - tools' } +MCP >> executeTool: tool named: toolName withParams: arguments dispatchPath: dispatchPath [ + | monitorRecord request result toolResult | - monitorRecord := self monitoring recordToolCallStart: toolName input: arguments. + monitorRecord := self monitoring + 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 monitoring + recordToolCallFinish: monitorRecord + 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 monitoring + recordToolCallFinish: monitorRecord + result: nil + error: error. error pass ]. errorResult := tool errorResultFor: error. - self monitoring recordToolCallFinish: monitorRecord result: errorResult error: error. + self monitoring + recordToolCallFinish: monitorRecord + result: errorResult + error: error. errorResult asJRPCJSONForResponseFields: self toolResponseFields ] ] +{ #category : 'private - tools' } +MCP >> finishToolCallWrapper: monitorRecord result: wrapperResult [ + + self monitoring recordToolCallFinish: monitorRecord result: wrapperResult asMCPStructureCallToolResult error: nil +] + +{ #category : 'private - tools' } +MCP >> handleToolCallWrapperError: error tool: callTool monitorRecord: monitorRecord [ + + | errorResult | + error mcpIsJRPCError ifTrue: [ + self monitoring recordToolCallFinish: monitorRecord result: nil error: error. + error pass ]. + errorResult := callTool errorResultFor: error. + self monitoring recordToolCallFinish: monitorRecord result: errorResult error: error. + ^ errorResult asJRPCJSONForResponseFields: self toolResponseFields +] + { #category : 'accessing' } MCP >> handlersCount [ @@ -144,7 +210,7 @@ MCP >> initialize [ self refreshToolsList. server := MCPHTTPServer new. server addHandlersFromPragmasIn: self. - self monitoringExportDirectory: MCPMonitoringState defaultExportDirectory + monitoring := MCPNoopObservabilityBackend new ] { #category : 'testing' } @@ -168,7 +234,13 @@ MCP >> localUrlString [ { #category : 'accessing' } MCP >> monitoring [ - ^ monitoring ifNil: [ monitoring := MCPMonitoringState new ] + ^ monitoring ifNil: [ monitoring := MCPNoopObservabilityBackend new ] +] + +{ #category : 'accessing' } +MCP >> monitoring: anObservabilityBackend [ + + monitoring := anObservabilityBackend ] { #category : 'monitoring' } @@ -180,7 +252,9 @@ MCP >> monitoringEnabled [ { #category : 'monitoring' } MCP >> monitoringEnabled: aBoolean [ - self monitoring enabled: aBoolean + aBoolean + ifTrue: [ self ensureConcreteMonitoring enabled: true ] + ifFalse: [ monitoring := MCPNoopObservabilityBackend new ] ] { #category : 'monitoring' } @@ -192,8 +266,10 @@ MCP >> monitoringExportDirectory [ { #category : 'monitoring' } MCP >> monitoringExportDirectory: aPath [ - self monitoring resourceMetadata: self monitoringResourceMetadata. - self monitoring exportDirectory: aPath + | concreteMonitoring | + concreteMonitoring := self ensureConcreteMonitoring. + concreteMonitoring resourceMetadata: self monitoringResourceMetadata. + concreteMonitoring exportDirectory: aPath ] { #category : 'monitoring' } @@ -225,7 +301,9 @@ MCP >> monitoringResourceMetadata [ | metadata | metadata := self monitoring resourceMetadata copy. - metadata at: #'pharo.majorVersion' put: PharoCompatibility currentMajorVersion. + metadata + at: #'pharo.majorVersion' + put: PharoCompatibility currentMajorVersion. metadata at: #'mcp.port' put: self port. ^ metadata ] @@ -242,6 +320,15 @@ MCP >> monitoringTraces [ ^ self monitoring traceRecords ] +{ #category : 'monitoring' } +MCP >> newMonitoringState [ + + ^ MCPMonitoringState new + resourceMetadata: self monitoringResourceMetadata; + exportDirectory: MCPMonitoringState defaultExportDirectory; + yourself +] + { #category : 'start' } MCP >> onInitialize: protocolVersion capabilities: capabilities clientInfo: clientInfo websiteUrl: websiteUrl [ @@ -266,7 +353,8 @@ MCP >> pharoRuntimeMetadata [ ^ { (#runtimeVersion -> PharoCompatibility runtimeVersionString). (#supportedMajorVersions -> self supportedPharoMajorVersions). - (#supportedLauncherVersions -> self supportedPharoLauncherVersions) } asDictionary + (#supportedLauncherVersions -> self supportedPharoLauncherVersions) } + asDictionary ] { #category : 'accessing' } @@ -281,6 +369,16 @@ MCP >> port: aPortNumber [ self server port: aPortNumber ] +{ #category : 'private - tools' } +MCP >> recordToolCallWrapperStartFor: callTool arguments: arguments [ + + ^ self monitoring + 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 +418,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 monitorRecord | + callTool := self toolCallTool. + monitorRecord := self recordToolCallWrapperStartFor: callTool arguments: arguments. + ^ [ self dispatchToolCallWith: callTool arguments: arguments monitorRecord: monitorRecord ] 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 monitorRecord: monitorRecord ] ] { #category : 'rpc' } @@ -373,7 +461,8 @@ MCP >> rpcToolCall: name withParams: arguments [ toolsList at: name ifPresent: [ :tool | - name = MCPToolCallTool toolName ifTrue: [ ^ self rpcCallToolCallWithParams: arguments ]. + name = MCPToolCallTool toolName ifTrue: [ + ^ self rpcCallToolCallWithParams: arguments ]. ^ self executeTool: tool named: name withParams: arguments ] ifAbsent: [ JRPCNonExistentHandler signal: name ] ] @@ -390,7 +479,8 @@ MCP >> rpcToolsList [ MCP >> saveImageAfterSuccessfulTool: aTool result: aResult [ aResult isError ifTrue: [ ^ self ]. - (aTool shouldSaveImageAfterSuccessfulExecutionForResult: aResult) ifTrue: [ self deferSaveImageAfterCurrentResponse ] + (aTool shouldSaveImageAfterSuccessfulExecutionForResult: aResult) + ifTrue: [ self deferSaveImageAfterCurrentResponse ] ] { #category : 'private - saving' } @@ -398,7 +488,10 @@ MCP >> saveImageSession [ "MCP autosave must not run SessionManager shutdown/startup hooks: Zinc stops managed servers during cycle, breaking active Streamable HTTP transports." | snapshotOperation | - snapshotOperation := SnapshotOperation save: true andQuit: false withSessionManager: SessionManager default. + snapshotOperation := SnapshotOperation + save: true + andQuit: false + withSessionManager: SessionManager default. snapshotOperation doSnapshot. ^ snapshotOperation isImageStarting ] @@ -412,13 +505,15 @@ MCP >> server [ { #category : 'infrastructure metadata' } MCP >> serverInfrastructureMetadata [ - ^ { (#'io.github.evref-bl/pharo' -> self pharoRuntimeMetadata) } asDictionary + ^ { (#'io.github.evref-bl/pharo' -> self pharoRuntimeMetadata) } + asDictionary ] { #category : 'accessing' } MCP >> sortedTools [ - ^ self toolsList values asSortedCollection: [ :left :right | left name <= right name ] + ^ self toolsList values asSortedCollection: [ :left :right | + left name <= right name ] ] { #category : 'start' } @@ -427,7 +522,8 @@ MCP >> start [ self refreshToolsList. server addHandlersFromPragmasIn: self. server start. - self ensureServerIsListening + self ensureServerIsListening. + self monitoring recordSessionStartFor: self ] { #category : 'configuration' } @@ -446,7 +542,7 @@ MCP >> staticToolNames: aCollection [ { #category : 'start' } MCP >> stop [ - server stop + [ server stop ] ensure: [ self monitoring shutdown ] ] { #category : 'infrastructure metadata' } @@ -461,6 +557,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 +664,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 [ @@ -555,7 +677,7 @@ MCP >> waitUntilListeningForMilliseconds: milliseconds [ delay := Delay forMilliseconds: 50. attempts := milliseconds // 50 max: 1. attempts timesRepeat: [ - self isListening ifTrue: [ ^ true ]. - delay wait ]. + self isListening ifTrue: [ ^ true ]. + delay wait ]. ^ self isListening ] diff --git a/src/MCP/MCPMonitoringState.class.st b/src/MCP/MCPMonitoringState.class.st index fdd63d1..8ff3a06 100644 --- a/src/MCP/MCPMonitoringState.class.st +++ b/src/MCP/MCPMonitoringState.class.st @@ -3,7 +3,7 @@ Runtime observability state for an MCP server. When enabled, it records TinyLogg " Class { #name : 'MCPMonitoringState', - #superclass : 'Object', + #superclass : 'MCPObservabilityBackend', #instVars : [ 'enabled', 'logger', @@ -30,14 +30,15 @@ Class { { #category : 'defaults' } MCPMonitoringState class >> defaultExportDirectory [ - ^ FileLocator home asFileReference / 'pharo-mcp-observability' + ^ Smalltalk imageFile asFileReference parent / 'pharo-local' / 'mcp' / 'observability' ] { #category : 'private' } MCPMonitoringState >> addRecentCall: aRecord [ self recentCalls addFirst: aRecord. - [ self recentCalls size > self maxRecentCalls ] whileTrue: [ self recentCalls removeLast ] + [ self recentCalls size > self maxRecentCalls ] whileTrue: [ + self recentCalls removeLast ] ] { #category : 'private - budgets' } @@ -53,11 +54,13 @@ MCPMonitoringState >> approximateOutputBytesForObject: anObject [ { #category : 'private - budgets' } MCPMonitoringState >> approximateOutputBytesForResult: aToolResult error: anError [ - aToolResult ifNotNil: [ ^ self approximateOutputBytesForObject: aToolResult asJRPCJSON ]. + aToolResult ifNotNil: [ + ^ self approximateOutputBytesForObject: aToolResult asJRPCJSON ]. anError ifNil: [ ^ 0 ]. ^ self approximateOutputBytesForObject: { (#errorClass -> anError class name asString). - (#message -> (anError messageText ifNil: [ anError asString ])) } asDictionary + (#message -> (anError messageText ifNil: [ anError asString ])) } + asDictionary ] { #category : 'private - budgets' } @@ -73,8 +76,10 @@ MCPMonitoringState >> captureTraceSnapshotsFor: aRecord result: aToolResult erro [ aRecord isTraceWorthy ifTrue: [ - aRecord inputSnapshot: (self traceSnapshotFor: aRecord inputObject). - aRecord outputSnapshot: (self traceSnapshotForResult: aToolResult error: anError) ] + aRecord inputSnapshot: + (self traceSnapshotFor: aRecord inputObject). + aRecord outputSnapshot: + (self traceSnapshotForResult: aToolResult error: anError) ] ifFalse: [ aRecord inputSnapshot: nil. aRecord outputSnapshot: nil ] ] @@ -83,8 +88,11 @@ MCPMonitoringState >> captureTraceSnapshotsFor: aRecord result: aToolResult erro aRecord inputSnapshot: nil. aRecord outputSnapshot: (self traceSnapshotFor: { (#errorClass -> snapshotError class name asString). - (#message -> (snapshotError messageText ifNil: [ snapshotError asString ])). - (#during -> 'trace snapshot creation') } asDictionary) ] ] ensure: [ aRecord inputObject: nil ] + (#message + -> + (snapshotError messageText ifNil: [ snapshotError asString ])). + (#during -> 'trace snapshot creation') } asDictionary) ] ] + ensure: [ aRecord inputObject: nil ] ] { #category : 'clearing' } @@ -147,7 +155,9 @@ MCPMonitoringState >> enabled: aBoolean [ { #category : 'private - exporting' } MCPMonitoringState >> ensureLogFileLogger [ - logFileLogger ifNil: [ logFileLogger := MCPJSONLineFileTinyLogger named: self logFileReference pathString ]. + logFileLogger ifNil: [ + logFileLogger := MCPJSONLineFileTinyLogger named: + self logFileReference pathString ]. ^ logFileLogger ] @@ -164,7 +174,9 @@ MCPMonitoringState >> ensureLogger [ { #category : 'private - tracing' } MCPMonitoringState >> ensureTraceLogger [ - traceLogger ifNil: [ traceLogger := MCPJSONLineFileTinyLogger named: self traceFileReference pathString ]. + traceLogger ifNil: [ + traceLogger := MCPJSONLineFileTinyLogger named: + self traceFileReference pathString ]. ^ traceLogger ] @@ -175,6 +187,15 @@ MCPMonitoringState >> errorClassNameFor: anError [ ^ anError class name asString ] +{ #category : 'private - tracing' } +MCPMonitoringState >> eventKindsForRecord: aRecord [ + + ^ 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 [ @@ -202,7 +223,8 @@ MCPMonitoringState >> exportInstanceDirectory [ | directory | self exportEnabled ifFalse: [ ^ nil ]. exportInstanceDirectory ifNil: [ - directory := self exportDirectory / self exportInstanceDirectoryName. + directory := self exportDirectory + / self exportInstanceDirectoryName. directory ensureCreateDirectory. exportInstanceDirectory := directory. self writeResourceMetadata ]. @@ -232,7 +254,9 @@ MCPMonitoringState >> exportInstanceDirectoryName [ MCPMonitoringState >> exportLogMessage: aString timestamp: timestamp [ self exportEnabled ifFalse: [ ^ self ]. - [ self ensureLogFileLogger record: (self logRecordJSONFor: aString timestamp: timestamp) ] + [ + self ensureLogFileLogger record: + (self logRecordJSONFor: aString timestamp: timestamp) ] on: Error do: [ :ignored | nil ] ] @@ -249,10 +273,18 @@ MCPMonitoringState >> exportMetrics [ { #category : 'private - tracing' } MCPMonitoringState >> exportTraceRecord: aRecord [ - (self exportEnabled and: [ aRecord isTraceWorthy ]) ifFalse: [ ^ self ]. + (self exportEnabled and: [ aRecord isTraceWorthy ]) ifFalse: [ + ^ self ]. [ self ensureTraceLogger record: (self traceRecordJSONFor: aRecord) ] on: Error - do: [ :error | self recordLog: 'Trace export failed: ' , error class name asString ] + do: [ :error | + self recordLog: 'Trace export failed: ' , error class name asString ] +] + +{ #category : 'actions' } +MCPMonitoringState >> forceFlush [ + + self exportMetrics ] { #category : 'initialization' } @@ -270,12 +302,19 @@ MCPMonitoringState >> initialize [ self ensureLogger ] +{ #category : 'testing' } +MCPMonitoringState >> isNoop [ + + ^ false +] + { #category : 'private - tracing' } MCPMonitoringState >> isSensitiveTraceKey: aKey [ | lowerKey | lowerKey := aKey asString asLowercase. - ^ #( 'password' 'passphrase' 'token' 'secret' 'credential' 'authorization' 'apikey' 'api_key' ) anySatisfy: [ :term | + ^ #( 'password' 'passphrase' 'token' 'secret' 'credential' + 'authorization' 'apikey' 'api_key' ) anySatisfy: [ :term | lowerKey includesSubstring: term ] ] @@ -288,7 +327,8 @@ MCPMonitoringState >> isVerboseTraceKey: aKey [ { #category : 'accessing' } MCPMonitoringState >> logFileReference [ - ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'logs.jsonl' ] + ^ self exportInstanceDirectory ifNotNil: [ :directory | + directory / 'logs.jsonl' ] ] { #category : 'private' } @@ -326,7 +366,8 @@ MCPMonitoringState >> logRecordDictionaryFor: aString timestamp: timestamp [ { #category : 'private - exporting' } MCPMonitoringState >> logRecordJSONFor: aString timestamp: timestamp [ - ^ STONJSON toString: (self logRecordDictionaryFor: aString timestamp: timestamp) + ^ STONJSON toString: + (self logRecordDictionaryFor: aString timestamp: timestamp) ] { #category : 'private' } @@ -389,13 +430,15 @@ MCPMonitoringState >> memoryLogger [ { #category : 'accessing' } MCPMonitoringState >> metrics [ - ^ self statsByTool values asSortedCollection: [ :left :right | left toolName <= right toolName ] + ^ self statsByTool values asSortedCollection: [ :left :right | + left toolName <= right toolName ] ] { #category : 'accessing' } MCPMonitoringState >> metricsFileReference [ - ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'metrics.json' ] + ^ self exportInstanceDirectory ifNotNil: [ :directory | + directory / 'metrics.json' ] ] { #category : 'private - exporting' } @@ -406,8 +449,13 @@ MCPMonitoringState >> metricsSnapshotDictionary [ (#updatedAt -> DateAndTime now asString). (#totalCallCount -> self totalCallCount). (#totalErrorCount -> self totalErrorCount). - (#totalOutputBudgetExceededCount -> self totalOutputBudgetExceededCount). - (#tools -> (self metrics collect: [ :each | each asMetricsDictionary ] as: Array)) } asDictionary + (#totalOutputBudgetExceededCount + -> self totalOutputBudgetExceededCount). + (#tools + -> + (self metrics + collect: [ :each | each asMetricsDictionary ] + as: Array)) } asDictionary ] { #category : 'accessing' } @@ -486,11 +534,14 @@ MCPMonitoringState >> outputBudgetBytes: anInteger [ MCPMonitoringState >> outputBudgetDataForResult: aToolResult error: anError [ | byteCount explanation | - byteCount := self approximateOutputBytesForResult: aToolResult error: anError. + byteCount := self + approximateOutputBytesForResult: aToolResult + error: anError. explanation := self outputBudgetExplanationForByteCount: byteCount. ^ { (#outputBytes -> byteCount). - (#approximateOutputTokens -> (self approximateOutputTokensForBytes: byteCount)). + (#approximateOutputTokens + -> (self approximateOutputTokensForBytes: byteCount)). (#outputBudgetBytes -> self outputBudgetBytes). (#outputBudgetExceeded -> explanation isNotEmpty). (#outputBudgetExplanation -> explanation) } asDictionary @@ -506,7 +557,8 @@ MCPMonitoringState >> outputBudgetExplanationForByteCount: byteCount [ print: byteCount; nextPutAll: ' bytes exceeds budget '; print: self outputBudgetBytes; - nextPutAll: ' bytes; consider pagination, handles, or summary-first output' ] + nextPutAll: + ' bytes; consider pagination, handles, or summary-first output' ] ] { #category : 'displaying' } @@ -550,7 +602,24 @@ MCPMonitoringState >> recordLog: aString [ { #category : 'private - budgets' } MCPMonitoringState >> recordOutputBudgetFor: aRecord result: aToolResult error: anError [ - aRecord recordOutputBudget: (self outputBudgetDataForResult: aToolResult error: anError) + aRecord recordOutputBudget: + (self outputBudgetDataForResult: aToolResult error: anError) +] + +{ #category : 'recording' } +MCPMonitoringState >> recordSessionEndFor: anMCP [ + + self resourceMetadata at: #sessionEndedAt put: DateAndTime now asString. + self exportEnabled ifTrue: [ self writeResourceMetadata ]. + self forceFlush +] + +{ #category : 'recording' } +MCPMonitoringState >> recordSessionStartFor: anMCP [ + + self resourceMetadata at: #sessionStartedAt put: DateAndTime now asString. + self resourceMetadata removeKey: #sessionEndedAt ifAbsent: [ nil ]. + self exportEnabled ifTrue: [ self writeResourceMetadata ] ] { #category : 'recording' } @@ -569,6 +638,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,35 +650,40 @@ MCPMonitoringState >> recordToolCallFinish: aRecord result: aToolResult error: a ] { #category : 'recording' } -MCPMonitoringState >> recordToolCallStart: aToolName [ - - ^ self recordToolCallStart: aToolName input: nil -] - -{ #category : 'recording' } -MCPMonitoringState >> recordToolCallStart: aToolName input: inputObject [ +MCPMonitoringState >> recordToolCallStart: aToolName input: inputObject dispatchPath: dispatchPath context: contextDictionary [ | record startTimestamp | self enabled ifFalse: [ ^ nil ]. nextCallId := self nextCallId + 1. startTimestamp := DateAndTime now. - record := MCPToolCallRecord id: self nextCallId toolName: aToolName asString startedAt: startTimestamp. + 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. + self recordLog: + 'Begin tool ' , aToolName asString , ' #' , record id asString. ^ record ] { #category : 'accessing' } MCPMonitoringState >> resourceFileReference [ - ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'resource.json' ] + ^ self exportInstanceDirectory ifNotNil: [ :directory | + directory / 'resource.json' ] ] { #category : 'accessing' } MCPMonitoringState >> resourceMetadata [ - ^ resourceMetadata ifNil: [ resourceMetadata := self newResourceMetadata ] + ^ resourceMetadata ifNil: [ + resourceMetadata := self newResourceMetadata ] ] { #category : 'accessing' } @@ -616,7 +691,9 @@ MCPMonitoringState >> resourceMetadata: aDictionary [ | metadata | metadata := self resourceMetadata copy. - aDictionary ifNotNil: [ aDictionary keysAndValuesDo: [ :key :value | metadata at: key put: value ] ]. + aDictionary ifNotNil: [ + aDictionary keysAndValuesDo: [ :key :value | + metadata at: key put: value ] ]. resourceMetadata := metadata. exportInstanceDirectory := nil. traceLogger := nil @@ -630,10 +707,17 @@ MCPMonitoringState >> shouldIncludeTraceAssociation: anAssociation [ value := anAssociation value. value ifNil: [ ^ false ]. (value isString and: [ value isEmpty ]) ifTrue: [ ^ false ]. - (value isCollection and: [ value isString not and: [ value isEmpty ] ]) ifTrue: [ ^ false ]. + (value isCollection and: [ value isString not and: [ value isEmpty ] ]) + ifTrue: [ ^ false ]. ^ true ] +{ #category : 'actions' } +MCPMonitoringState >> shutdown [ + + self recordSessionEndFor: nil +] + { #category : 'accessing' } MCPMonitoringState >> statsByTool [ @@ -643,16 +727,20 @@ MCPMonitoringState >> statsByTool [ { #category : 'accessing' } MCPMonitoringState >> statsForToolNamed: aToolName [ - ^ self statsByTool at: aToolName asString ifAbsentPut: [ MCPToolCallStats forToolNamed: aToolName asString ] + ^ self statsByTool + at: aToolName asString + ifAbsentPut: [ MCPToolCallStats forToolNamed: aToolName asString ] ] { #category : 'private - budgets' } MCPMonitoringState >> statsNeedingOutputBudgetAttention [ - ^ (self metrics select: [ :each | each outputBudgetExceededCount > 0 ]) asArray sort: [ :left :right | + ^ (self metrics select: [ :each | each outputBudgetExceededCount > 0 ]) + asArray sort: [ :left :right | left outputBudgetExceededCount = right outputBudgetExceededCount ifTrue: [ left maxOutputBytes >= right maxOutputBytes ] - ifFalse: [ left outputBudgetExceededCount > right outputBudgetExceededCount ] ] + ifFalse: [ + left outputBudgetExceededCount > right outputBudgetExceededCount ] ] ] { #category : 'private' } @@ -692,7 +780,10 @@ MCPMonitoringState >> summaryForResult: aToolResult [ aToolResult ifNil: [ ^ '' ]. structuredContent := aToolResult structuredContent. structuredContent ifNil: [ ^ '' ]. - ^ (structuredContent at: #summary ifAbsent: [ structuredContent at: 'summary' ifAbsent: [ '' ] ]) asString + ^ (structuredContent + at: #summary + ifAbsent: [ structuredContent at: 'summary' ifAbsent: [ '' ] ]) + asString ] { #category : 'accessing' } @@ -710,22 +801,25 @@ MCPMonitoringState >> totalErrorCount [ { #category : 'accessing' } MCPMonitoringState >> totalOutputBudgetExceededCount [ - ^ self metrics inject: 0 into: [ :sum :each | sum + each outputBudgetExceededCount ] + ^ self metrics + inject: 0 + into: [ :sum :each | sum + each outputBudgetExceededCount ] ] { #category : 'private - tracing' } MCPMonitoringState >> 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 [ - ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'traces.jsonl' ] + ^ self exportInstanceDirectory ifNotNil: [ :directory | + directory / 'traces.jsonl' ] ] { #category : 'private - tracing' } @@ -735,12 +829,15 @@ MCPMonitoringState >> traceRecordDictionaryFor: aRecord [ 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 ]. @@ -775,21 +872,29 @@ MCPMonitoringState >> traceSnapshotFor: anObject [ MCPMonitoringState >> traceSnapshotFor: anObject depth: depth [ anObject ifNil: [ ^ nil ]. - (anObject isString or: [ anObject isSymbol ]) ifTrue: [ ^ self traceStringSnapshotFor: anObject ]. - (anObject isNumber or: [ anObject == true or: [ anObject == false ] ]) ifTrue: [ ^ anObject ]. + (anObject isString or: [ anObject isSymbol ]) ifTrue: [ + ^ self traceStringSnapshotFor: anObject ]. + (anObject isNumber or: [ anObject == true or: [ anObject == false ] ]) + ifTrue: [ ^ anObject ]. anObject isCharacter ifTrue: [ ^ anObject asString ]. - depth >= self maxTraceDepth ifTrue: [ ^ self traceStringSnapshotFor: anObject printString ]. - anObject isAssociation ifTrue: [ ^ self traceSnapshotForAssociation: anObject depth: depth ]. - anObject isDictionary ifTrue: [ ^ self traceSnapshotForDictionary: anObject depth: depth ]. - (anObject isCollection and: [ anObject isString not ]) ifTrue: [ ^ self traceSnapshotForCollection: anObject depth: depth ]. + depth >= self maxTraceDepth ifTrue: [ + ^ self traceStringSnapshotFor: anObject printString ]. + anObject isAssociation ifTrue: [ + ^ self traceSnapshotForAssociation: anObject depth: depth ]. + anObject isDictionary ifTrue: [ + ^ self traceSnapshotForDictionary: anObject depth: depth ]. + (anObject isCollection and: [ anObject isString not ]) ifTrue: [ + ^ self traceSnapshotForCollection: anObject depth: depth ]. ^ self traceStringSnapshotFor: anObject printString ] { #category : 'private - tracing' } MCPMonitoringState >> traceSnapshotForAssociation: anAssociation depth: depth [ - (self isSensitiveTraceKey: anAssociation key) ifTrue: [ ^ anAssociation key -> '' ]. - ^ anAssociation key -> (self traceSnapshotFor: anAssociation value depth: depth + 1) + (self isSensitiveTraceKey: anAssociation key) ifTrue: [ + ^ anAssociation key -> '' ]. + ^ anAssociation key + -> (self traceSnapshotFor: anAssociation value depth: depth + 1) ] { #category : 'private - tracing' } @@ -799,8 +904,13 @@ MCPMonitoringState >> traceSnapshotForCollection: aCollection depth: depth [ array := aCollection asArray. limit := array size min: self maxTraceCollectionItems. overflow := array size - limit. - snapshot := (1 to: limit) collect: [ :index | self traceSnapshotFor: (array at: index) depth: depth + 1 ]. - overflow positive ifTrue: [ snapshot := snapshot copyWith: 'truncated ' , overflow asString , ' items omitted' ]. + snapshot := (1 to: limit) collect: [ :index | + self + traceSnapshotFor: (array at: index) + depth: depth + 1 ]. + overflow positive ifTrue: [ + snapshot := snapshot copyWith: + 'truncated ' , overflow asString , ' items omitted' ]. ^ snapshot asArray ] @@ -810,23 +920,30 @@ MCPMonitoringState >> traceSnapshotForDictionary: aDictionary depth: depth [ | snapshot | snapshot := Dictionary new. aDictionary associationsDo: [ :association | - (snapshot size < self maxTraceCollectionItems and: [ self shouldIncludeTraceAssociation: association ]) ifTrue: [ + (snapshot size < self maxTraceCollectionItems and: [ + self shouldIncludeTraceAssociation: association ]) ifTrue: [ | sanitized | - sanitized := self traceSnapshotForAssociation: association depth: depth. + sanitized := self + traceSnapshotForAssociation: association + depth: depth. snapshot at: sanitized key put: sanitized value ] ]. aDictionary size > self maxTraceCollectionItems ifTrue: [ - snapshot at: #__truncated__ put: aDictionary size - self maxTraceCollectionItems ]. + snapshot + at: #__truncated__ + put: aDictionary size - self maxTraceCollectionItems ]. ^ snapshot ] { #category : 'private - tracing' } MCPMonitoringState >> traceSnapshotForResult: aToolResult error: anError [ - aToolResult ifNotNil: [ ^ self traceSnapshotFor: aToolResult structuredContent ]. + aToolResult ifNotNil: [ + ^ self traceSnapshotFor: aToolResult structuredContent ]. anError ifNil: [ ^ nil ]. ^ self traceSnapshotFor: { (#errorClass -> anError class name asString). - (#message -> (anError messageText ifNil: [ anError asString ])) } asDictionary + (#message -> (anError messageText ifNil: [ anError asString ])) } + asDictionary ] { #category : 'private - tracing' } @@ -838,14 +955,30 @@ MCPMonitoringState >> traceStringSnapshotFor: aString [ ^ (text first: self maxTraceStringLength) , '... ' ] +{ #category : 'private - exporting' } +MCPMonitoringState >> 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' } MCPMonitoringState >> writeMetricsSnapshot [ - self metricsFileReference writeStreamDo: [ :stream | stream nextPutAll: (STONJSON toString: self metricsSnapshotDictionary) ] + self writeJSON: self metricsSnapshotDictionary to: self metricsFileReference ] { #category : 'private - exporting' } MCPMonitoringState >> writeResourceMetadata [ - self resourceFileReference writeStreamDo: [ :stream | stream nextPutAll: (STONJSON toString: self resourceMetadata) ] + self writeJSON: self resourceMetadata to: self resourceFileReference ] diff --git a/src/MCP/MCPNoopObservabilityBackend.class.st b/src/MCP/MCPNoopObservabilityBackend.class.st new file mode 100644 index 0000000..86f5405 --- /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-Monitoring', + #package : 'MCP', + #tag : 'Monitoring' +} + +{ #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 0000000..9de9e66 --- /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-Monitoring', + #package : 'MCP', + #tag : 'Monitoring' +} + +{ #category : 'testing' } +MCPObservabilityBackend class >> isAbstract [ + + ^ self == MCPObservabilityBackend +] + +{ #category : 'clearing' } +MCPObservabilityBackend >> clear [ + + self subclassResponsibility +] + +{ #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 [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> exportDirectory: aPath [ + + self subclassResponsibility +] + +{ #category : 'testing' } +MCPObservabilityBackend >> exportEnabled [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> exportInstanceDirectory [ + + self subclassResponsibility +] + +{ #category : 'actions' } +MCPObservabilityBackend >> forceFlush [ + + self subclassResponsibility +] + +{ #category : 'testing' } +MCPObservabilityBackend >> isNoop [ + + self subclassResponsibility +] + +{ #category : 'logging' } +MCPObservabilityBackend >> logs [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> metrics [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> recentCallRecords [ + + self subclassResponsibility +] + +{ #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 [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> resourceMetadata: aDictionary [ + + self subclassResponsibility +] + +{ #category : 'actions' } +MCPObservabilityBackend >> shutdown [ + + self subclassResponsibility +] + +{ #category : 'displaying' } +MCPObservabilityBackend >> statusText [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> totalCallCount [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> totalErrorCount [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> totalOutputBudgetExceededCount [ + + self subclassResponsibility +] + +{ #category : 'accessing' } +MCPObservabilityBackend >> traceRecords [ + + self subclassResponsibility +] diff --git a/src/MCP/MCPToolCallRecord.class.st b/src/MCP/MCPToolCallRecord.class.st index 76f2aa9..ecf85c3 100644 --- a/src/MCP/MCPToolCallRecord.class.st +++ b/src/MCP/MCPToolCallRecord.class.st @@ -21,7 +21,10 @@ Class { 'approximateOutputTokenCount', 'outputBudgetBytes', 'outputBudgetExceeded', - 'outputBudgetExplanation' + 'outputBudgetExplanation', + 'dispatchPath', + 'inputByteCount', + 'eventKinds' ], #category : 'MCP-Monitoring', #package : 'MCP', @@ -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 [ @@ -82,13 +100,16 @@ MCPToolCallRecord >> displayDurationMilliseconds [ { #category : 'displaying' } MCPToolCallRecord >> displayStartedAt [ - ^ self startedAt ifNil: [ '' ] ifNotNil: [ :timestamp | timestamp asString ] + ^ self startedAt + ifNil: [ '' ] + ifNotNil: [ :timestamp | timestamp asString ] ] { #category : 'displaying' } MCPToolCallRecord >> displaySummary [ - self outputBudgetExplanation isEmpty ifFalse: [ ^ self outputBudgetExplanation ]. + self outputBudgetExplanation isEmpty ifFalse: [ + ^ self outputBudgetExplanation ]. self outlierExplanation isEmpty ifFalse: [ ^ self outlierExplanation ]. self summary isEmpty ifFalse: [ ^ self summary ]. ^ self errorClass @@ -118,6 +139,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 +183,18 @@ MCPToolCallRecord >> id: anInteger [ id := anInteger ] +{ #category : 'accessing' } +MCPToolCallRecord >> inputByteCount [ + + ^ inputByteCount ifNil: [ 0 ] +] + +{ #category : 'accessing' } +MCPToolCallRecord >> inputByteCount: anInteger [ + + inputByteCount := anInteger +] + { #category : 'private' } MCPToolCallRecord >> inputObject [ @@ -183,7 +228,9 @@ MCPToolCallRecord >> isRunning [ { #category : 'testing' } MCPToolCallRecord >> isTraceWorthy [ - ^ self status = 'error' or: [ self outlierExplanation isNotEmpty or: [ self outputBudgetExceeded ] ] + ^ self status = 'error' or: [ + self outlierExplanation isNotEmpty or: [ + self outputBudgetExceeded ] ] ] { #category : 'accessing' } @@ -262,10 +309,12 @@ MCPToolCallRecord >> outputSnapshot: anObject [ MCPToolCallRecord >> recordOutputBudget: budgetData [ self outputByteCount: (budgetData at: #outputBytes). - self approximateOutputTokenCount: (budgetData at: #approximateOutputTokens). + self approximateOutputTokenCount: + (budgetData at: #approximateOutputTokens). self outputBudgetBytes: (budgetData at: #outputBudgetBytes). self outputBudgetExceeded: (budgetData at: #outputBudgetExceeded). - self outputBudgetExplanation: (budgetData at: #outputBudgetExplanation) + self outputBudgetExplanation: + (budgetData at: #outputBudgetExplanation) ] { #category : 'accessing' } diff --git a/src/MCP/MCPToolCallStats.class.st b/src/MCP/MCPToolCallStats.class.st index 3068cb9..c20999b 100644 --- a/src/MCP/MCPToolCallStats.class.st +++ b/src/MCP/MCPToolCallStats.class.st @@ -13,6 +13,7 @@ Class { 'minMilliseconds', 'maxMilliseconds', 'lastMilliseconds', + 'firstStartedAt', 'lastStartedAt', 'lastFinishedAt', 'lastOutlierExplanation', @@ -20,7 +21,8 @@ Class { 'maxOutputBytes', 'lastOutputBytes', 'lastApproximateOutputTokens', - 'outputBudgetExceededCount' + 'outputBudgetExceededCount', + 'errorCountsByClass' ], #category : 'MCP-Monitoring', #package : 'MCP', @@ -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 [ @@ -235,7 +268,8 @@ MCPToolCallStats >> outputBudgetGuidance [ stream nextPutAll: ' (max '; print: self maxOutputBytes; - nextPutAll: ' bytes). Prefer pagination, handles, or summary-first output.' ] + nextPutAll: + ' bytes). Prefer pagination, handles, or summary-first output.' ] ] { #category : 'recording' } @@ -248,7 +282,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 +291,35 @@ 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 [ From c654ecf599949b3cd1921e6f9f13f2329163ec90 Mon Sep 17 00:00:00 2001 From: Gabriel Darbord <78592838+Gabriel-Darbord@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:21:48 +0200 Subject: [PATCH 2/8] Fix OpenTelemetry baseline group loading --- docs/user/safety-and-ecosystem.md | 6 +++--- src/BaselineOfMCP/BaselineOfMCP.class.st | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/user/safety-and-ecosystem.md b/docs/user/safety-and-ecosystem.md index e8fc43b..5186a07 100644 --- a/docs/user/safety-and-ecosystem.md +++ b/docs/user/safety-and-ecosystem.md @@ -235,14 +235,14 @@ By default, JSON observability exports under the image-local `mcp monitoringExportDirectory: 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 +OpenTelemetry support is optional. Load the `Telemetry` group, then enable the OpenTelemetry backend for an MCP instance: ```smalltalk Metacello new baseline: 'MCP'; repository: 'github://Evref-BL/MCP:main/src'; - load: 'OpenTelemetry'. + load: 'Telemetry'. mcp useOpenTelemetryObservability ``` @@ -250,7 +250,7 @@ mcp useOpenTelemetryObservability ## Version Compatibility The core baseline loads PharoCompatibility, JRPC, TinyLogger, and PCRE2. -OpenTelemetry is loaded only by the optional `OpenTelemetry` group. CI covers +OpenTelemetry is loaded only by the optional `Telemetry` 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 36652ed..b21959e 100644 --- a/src/BaselineOfMCP/BaselineOfMCP.class.st +++ b/src/BaselineOfMCP/BaselineOfMCP.class.st @@ -46,7 +46,7 @@ BaselineOfMCP >> defineGroups: spec [ spec group: 'Core' with: #( 'MCP' ); group: 'UI' with: #( 'MCP-UI' ); - group: 'OpenTelemetry' with: #( 'MCP-OpenTelemetry' ); + group: 'Telemetry' 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' ) From b3342f6ef2c40fa1653cd1fe87b12c40d1412711 Mon Sep 17 00:00:00 2001 From: Gabriel Darbord <78592838+Gabriel-Darbord@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:38:20 +0200 Subject: [PATCH 3/8] Remove TinyLogger from core observability --- docs/user/safety-and-ecosystem.md | 2 +- src/BaselineOfMCP/BaselineOfMCP.class.st | 6 +- src/MCP/MCPJSONLineFileTinyLogger.class.st | 25 ----- src/MCP/MCPMemoryTinyLogger.class.st | 77 -------------- src/MCP/MCPMonitoringState.class.st | 112 ++++++++++----------- 5 files changed, 53 insertions(+), 169 deletions(-) delete mode 100644 src/MCP/MCPJSONLineFileTinyLogger.class.st delete mode 100644 src/MCP/MCPMemoryTinyLogger.class.st diff --git a/docs/user/safety-and-ecosystem.md b/docs/user/safety-and-ecosystem.md index 5186a07..87e96c1 100644 --- a/docs/user/safety-and-ecosystem.md +++ b/docs/user/safety-and-ecosystem.md @@ -249,7 +249,7 @@ mcp useOpenTelemetryObservability ## Version Compatibility -The core baseline loads PharoCompatibility, JRPC, TinyLogger, and PCRE2. +The core baseline loads PharoCompatibility, JRPC, and PCRE2. OpenTelemetry is loaded only by the optional `Telemetry` 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 diff --git a/src/BaselineOfMCP/BaselineOfMCP.class.st b/src/BaselineOfMCP/BaselineOfMCP.class.st index b21959e..04e1c31 100644 --- a/src/BaselineOfMCP/BaselineOfMCP.class.st +++ b/src/BaselineOfMCP/BaselineOfMCP.class.st @@ -26,10 +26,6 @@ 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'; @@ -56,7 +52,7 @@ 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' 'OpenTelemetry' ) ]. spec package: 'MCP-Tests-Resources' with: [ spec requires: #( 'MCP' ) ]. diff --git a/src/MCP/MCPJSONLineFileTinyLogger.class.st b/src/MCP/MCPJSONLineFileTinyLogger.class.st deleted file mode 100644 index 3768ae7..0000000 --- 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/MCPMemoryTinyLogger.class.st b/src/MCP/MCPMemoryTinyLogger.class.st deleted file mode 100644 index daf451c..0000000 --- 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/MCPMonitoringState.class.st b/src/MCP/MCPMonitoringState.class.st index 8ff3a06..22b83a0 100644 --- a/src/MCP/MCPMonitoringState.class.st +++ b/src/MCP/MCPMonitoringState.class.st @@ -1,23 +1,21 @@ " -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 : 'MCPObservabilityBackend', #instVars : [ 'enabled', - 'logger', - 'memoryLogger', + 'logEntries', 'statsByTool', 'recentCalls', 'nextCallId', 'maxRecentCalls', + 'maxLogEntries', 'outlierFactor', 'minimumOutlierMilliseconds', 'outputBudgetBytes', 'lastActivityAt', - 'traceLogger', - 'logFileLogger', 'exportDirectory', 'exportInstanceDirectory', 'resourceMetadata' @@ -109,7 +107,7 @@ MCPMonitoringState >> clear [ { #category : 'logging' } MCPMonitoringState >> clearLogs [ - memoryLogger ifNotNil: [ memoryLogger clearLog ] + self logEntries removeAll ] { #category : 'private - defaults' } @@ -129,11 +127,8 @@ MCPMonitoringState >> disable [ { #category : 'activation' } MCPMonitoringState >> enable [ - self enabled ifTrue: [ - self ensureLogger. - ^ self ]. + self enabled ifTrue: [ ^ self ]. enabled := true. - self ensureLogger. lastActivityAt := DateAndTime now. self recordLog: 'Observability enabled' ] @@ -152,34 +147,6 @@ MCPMonitoringState >> enabled: aBoolean [ ifFalse: [ self disable ] ] -{ #category : 'private - exporting' } -MCPMonitoringState >> ensureLogFileLogger [ - - logFileLogger ifNil: [ - logFileLogger := MCPJSONLineFileTinyLogger named: - self logFileReference pathString ]. - ^ logFileLogger -] - -{ #category : 'logging' } -MCPMonitoringState >> ensureLogger [ - - logger ifNil: [ logger := TinyLogger new ]. - memoryLogger ifNil: [ - memoryLogger := MCPMemoryTinyLogger new. - logger addLogger: memoryLogger ]. - ^ logger -] - -{ #category : 'private - tracing' } -MCPMonitoringState >> ensureTraceLogger [ - - traceLogger ifNil: [ - traceLogger := MCPJSONLineFileTinyLogger named: - self traceFileReference pathString ]. - ^ traceLogger -] - { #category : 'private' } MCPMonitoringState >> errorClassNameFor: anError [ @@ -206,9 +173,7 @@ MCPMonitoringState >> exportDirectory [ MCPMonitoringState >> exportDirectory: aPath [ exportDirectory := aPath ifNotNil: [ :path | path asFileReference ]. - exportInstanceDirectory := nil. - traceLogger := nil. - logFileLogger := nil + exportInstanceDirectory := nil ] { #category : 'testing' } @@ -255,8 +220,9 @@ MCPMonitoringState >> 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 ] ] @@ -275,7 +241,8 @@ MCPMonitoringState >> 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 ] @@ -296,10 +263,10 @@ 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' } @@ -324,6 +291,20 @@ MCPMonitoringState >> isVerboseTraceKey: aKey [ ^ #( 'stackTrace' 'signalerContext' ) includes: aKey asString ] +{ #category : 'private - logging' } +MCPMonitoringState >> logEntries [ + + ^ logEntries ifNil: [ logEntries := OrderedCollection new ] +] + +{ #category : 'private - logging' } +MCPMonitoringState >> logEntry: aString [ + + self logEntries add: aString. + [ self logEntries size > self maxLogEntries ] whileTrue: [ + self logEntries removeFirst ] +] + { #category : 'accessing' } MCPMonitoringState >> logFileReference [ @@ -378,17 +359,22 @@ MCPMonitoringState >> logStatusLabelForStatus: statusString [ ^ statusString capitalized ] +{ #category : 'logging' } +MCPMonitoringState >> logs [ + + ^ self logEntries asArray +] + { #category : 'accessing' } -MCPMonitoringState >> logger [ +MCPMonitoringState >> maxLogEntries [ - ^ logger + ^ maxLogEntries ifNil: [ 200 ] ] -{ #category : 'logging' } -MCPMonitoringState >> logs [ +{ #category : 'accessing' } +MCPMonitoringState >> maxLogEntries: anInteger [ - memoryLogger ifNil: [ ^ #( ) ]. - ^ memoryLogger recentEntries + maxLogEntries := anInteger ] { #category : 'accessing' } @@ -421,12 +407,6 @@ MCPMonitoringState >> maxTraceStringLength [ ^ 1000 ] -{ #category : 'accessing' } -MCPMonitoringState >> memoryLogger [ - - ^ memoryLogger -] - { #category : 'accessing' } MCPMonitoringState >> metrics [ @@ -595,7 +575,7 @@ MCPMonitoringState >> recordLog: aString [ | timestamp | self enabled ifFalse: [ ^ self ]. timestamp := DateAndTime now. - self ensureLogger record: aString. + self logEntry: aString. self exportLogMessage: aString timestamp: timestamp ] @@ -695,8 +675,7 @@ MCPMonitoringState >> resourceMetadata: aDictionary [ aDictionary keysAndValuesDo: [ :key :value | metadata at: key put: value ] ]. resourceMetadata := metadata. - exportInstanceDirectory := nil. - traceLogger := nil + exportInstanceDirectory := nil ] { #category : 'private - tracing' } @@ -971,6 +950,17 @@ MCPMonitoringState >> writeJSON: aDictionary to: aFileReference [ temporaryFile exists ifTrue: [ temporaryFile delete ] ] ] ] +{ #category : 'private - exporting' } +MCPMonitoringState >> writeJSONLine: aString to: aFileReference [ + + aFileReference parent ensureCreateDirectory. + aFileReference writeStreamDo: [ :stream | + stream + setToEnd; + nextPutAll: aString; + lf ] +] + { #category : 'private - exporting' } MCPMonitoringState >> writeMetricsSnapshot [ From c2e58c69e9da560a9aea5547f0f7229128fe8343 Mon Sep 17 00:00:00 2001 From: Gabriel Darbord <78592838+Gabriel-Darbord@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:43:45 +0200 Subject: [PATCH 4/8] Make OpenTelemetry backend inherit observability protocol --- ...OpenTelemetryObservabilityBackend.class.st | 2 +- src/MCP/MCPObservabilityBackend.class.st | 28 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st b/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st index 6cca41c..bef3725 100644 --- a/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st +++ b/src/MCP-OpenTelemetry/MCPOpenTelemetryObservabilityBackend.class.st @@ -5,7 +5,7 @@ This class lives in the optional MCP-OpenTelemetry package so MCP core can remai " Class { #name : 'MCPOpenTelemetryObservabilityBackend', - #superclass : 'MCPNoopObservabilityBackend', + #superclass : 'MCPObservabilityBackend', #instVars : [ 'enabled', 'tracer', diff --git a/src/MCP/MCPObservabilityBackend.class.st b/src/MCP/MCPObservabilityBackend.class.st index 9de9e66..3d61335 100644 --- a/src/MCP/MCPObservabilityBackend.class.st +++ b/src/MCP/MCPObservabilityBackend.class.st @@ -20,7 +20,7 @@ MCPObservabilityBackend class >> isAbstract [ { #category : 'clearing' } MCPObservabilityBackend >> clear [ - self subclassResponsibility + ] { #category : 'activation' } @@ -50,25 +50,25 @@ MCPObservabilityBackend >> enabled: aBoolean [ { #category : 'accessing' } MCPObservabilityBackend >> exportDirectory [ - self subclassResponsibility + ^ nil ] { #category : 'accessing' } MCPObservabilityBackend >> exportDirectory: aPath [ - self subclassResponsibility + ] { #category : 'testing' } MCPObservabilityBackend >> exportEnabled [ - self subclassResponsibility + ^ false ] { #category : 'accessing' } MCPObservabilityBackend >> exportInstanceDirectory [ - self subclassResponsibility + ^ nil ] { #category : 'actions' } @@ -86,19 +86,19 @@ MCPObservabilityBackend >> isNoop [ { #category : 'logging' } MCPObservabilityBackend >> logs [ - self subclassResponsibility + ^ #( ) ] { #category : 'accessing' } MCPObservabilityBackend >> metrics [ - self subclassResponsibility + ^ #( ) ] { #category : 'accessing' } MCPObservabilityBackend >> recentCallRecords [ - self subclassResponsibility + ^ #( ) ] { #category : 'recording' } @@ -144,13 +144,13 @@ MCPObservabilityBackend >> recordToolCallStart: aToolName input: inputObject dis { #category : 'accessing' } MCPObservabilityBackend >> resourceMetadata [ - self subclassResponsibility + ^ Dictionary new ] { #category : 'accessing' } MCPObservabilityBackend >> resourceMetadata: aDictionary [ - self subclassResponsibility + ] { #category : 'actions' } @@ -168,23 +168,23 @@ MCPObservabilityBackend >> statusText [ { #category : 'accessing' } MCPObservabilityBackend >> totalCallCount [ - self subclassResponsibility + ^ 0 ] { #category : 'accessing' } MCPObservabilityBackend >> totalErrorCount [ - self subclassResponsibility + ^ 0 ] { #category : 'accessing' } MCPObservabilityBackend >> totalOutputBudgetExceededCount [ - self subclassResponsibility + ^ 0 ] { #category : 'accessing' } MCPObservabilityBackend >> traceRecords [ - self subclassResponsibility + ^ #( ) ] From 6e980961830404fe71e303ee2186f20f58074d16 Mon Sep 17 00:00:00 2001 From: Gabriel Darbord <78592838+Gabriel-Darbord@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:45:08 +0200 Subject: [PATCH 5/8] Format observability code --- .../MCPDashboardPresenterTest.class.st | 121 +++------- .../MCPMonitoringStateTest.class.st | 208 +++++------------- src/MCP/MCP.class.st | 43 +--- src/MCP/MCPMonitoringState.class.st | 189 +++++----------- src/MCP/MCPToolCallRecord.class.st | 17 +- src/MCP/MCPToolCallStats.class.st | 7 +- 6 files changed, 163 insertions(+), 422 deletions(-) diff --git a/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st b/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st index 4712f83..a01a69a 100644 --- a/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st +++ b/src/MCP-UI-Tests/MCPDashboardPresenterTest.class.st @@ -44,22 +44,13 @@ MCPDashboardPresenterTest >> testCheckboxRowsIncludeLeftPadding [ | configurationLayout observabilityLayout | configurationLayout := MCPDashboardPresenter new configurationLayout. - observabilityLayout := MCPDashboardPresenter new - monitoringControlsLayout. + observabilityLayout := MCPDashboardPresenter new monitoringControlsLayout. self assert: configurationLayout children size equals: 5. - self - assert: configurationLayout children third class - equals: SpNullPresenter. - self - assert: configurationLayout children fourth label - equals: 'Debug mode'. - self - assert: observabilityLayout children first class - equals: SpNullPresenter. - self - assert: observabilityLayout children fourth class - equals: SpNullPresenter + self assert: configurationLayout children third class equals: SpNullPresenter. + self assert: configurationLayout children fourth label equals: 'Debug mode'. + self assert: observabilityLayout children first class equals: SpNullPresenter. + self assert: observabilityLayout children fourth class equals: SpNullPresenter ] { #category : 'tests' } @@ -89,9 +80,7 @@ MCPDashboardPresenterTest >> testCollapsedMetricsSectionKeepsHeaderVisible [ self deny: presenter isMetricsSectionExpanded. self assert: collapsedLayout children size equals: 1. - self - assert: collapsedLayout children first class - equals: SpButtonPresenter + self assert: collapsedLayout children first class equals: SpButtonPresenter ] { #category : 'tests' } @@ -215,12 +204,9 @@ MCPDashboardPresenterTest >> testDebugModeCheckboxExplainsBehavior [ debugCheckBox := presenter instVarNamed: #debugCheckBox. self assert: debugCheckBox label equals: 'Debug mode'. - self assert: (debugCheckBox help includesSubstring: - 'server and JSON-RPC errors pass through'). - self assert: - (debugCheckBox help includesSubstring: 'debugger can open'). - self assert: - (debugCheckBox help includesSubstring: 'normal error responses') + self assert: (debugCheckBox help includesSubstring: 'server and JSON-RPC errors pass through'). + self assert: (debugCheckBox help includesSubstring: 'debugger can open'). + self assert: (debugCheckBox help includesSubstring: 'normal error responses') ] { #category : 'tests' } @@ -229,14 +215,8 @@ MCPDashboardPresenterTest >> testDisclosureLabelsIncludeStateTitleAndCount [ | presenter | presenter := MCPDashboardPresenter new. - self - assert: - (presenter disclosureLabelFor: 'Logs' count: 3 expanded: true) - equals: '[-] Logs (3)'. - self - assert: - (presenter disclosureLabelFor: 'Logs' count: 3 expanded: false) - equals: '[+] Logs (3)' + self assert: (presenter disclosureLabelFor: 'Logs' count: 3 expanded: true) equals: '[-] Logs (3)'. + self assert: (presenter disclosureLabelFor: 'Logs' count: 3 expanded: false) equals: '[+] Logs (3)' ] { #category : 'tests' } @@ -272,18 +252,10 @@ MCPDashboardPresenterTest >> testHeaderLayoutUsesStatusAndEndpointRows [ self assert: statusRow class equals: SpBoxLayout. self assert: statusRow isHorizontal. self assert: statusRow children size equals: 3. - self - assert: statusRow children first - identicalTo: (presenter instVarNamed: #statusLabel). - self - assert: statusRow children second - identicalTo: (presenter instVarNamed: #statusIcon). - self - assert: statusRow children third - identicalTo: (presenter instVarNamed: #statusValueLabel). - self - assert: headerLayout children second - identicalTo: (presenter instVarNamed: #urlLabel) + self assert: statusRow children first identicalTo: (presenter instVarNamed: #statusLabel). + self assert: statusRow children second identicalTo: (presenter instVarNamed: #statusIcon). + self assert: statusRow children third identicalTo: (presenter instVarNamed: #statusValueLabel). + self assert: headerLayout children second identicalTo: (presenter instVarNamed: #urlLabel) ] { #category : 'tests' } @@ -293,10 +265,7 @@ MCPDashboardPresenterTest >> testInitialSectionsCollapseAfterObservabilityIsDisa mcp := MCP new. mcp monitoringEnabled: true. record := mcp monitoring recordToolCallStart: 'failing-tool'. - mcp monitoring - recordToolCallFinish: record - result: (MCPStructureErrorToolResult content: #( )) - error: Error new. + mcp monitoring recordToolCallFinish: record result: (MCPStructureErrorToolResult content: #( )) error: Error new. mcp monitoringEnabled: false. presenter := MCPDashboardPresenter on: mcp. presenter refreshView. @@ -341,10 +310,7 @@ MCPDashboardPresenterTest >> testLogsForDisplayAreTailFirst [ | presenter | presenter := MCPDashboardPresenter new. - self - assert: - (presenter logsForDisplayFrom: #( 'oldest' 'middle' 'newest' )) - equals: #( 'newest' 'middle' 'oldest' ) + self assert: (presenter logsForDisplayFrom: #( 'oldest' 'middle' 'newest' )) equals: #( 'newest' 'middle' 'oldest' ) ] { #category : 'tests' } @@ -379,9 +345,7 @@ MCPDashboardPresenterTest >> testObservabilityControlsStartWithEnableRefreshAuto layout := MCPDashboardPresenter new monitoringControlsLayout. self assert: layout children size equals: 6. - self - assert: layout children second label - equals: 'Enable observability'. + self assert: layout children second label equals: 'Enable observability'. self assert: layout children third label equals: 'Refresh'. self assert: layout children fifth label equals: 'Auto-refresh'. self assert: layout children sixth label equals: 'Clear' @@ -412,9 +376,7 @@ MCPDashboardPresenterTest >> testRefreshViewUpdatesStatusIcon [ statusLabel := presenter instVarNamed: #statusLabel. statusValueLabel := presenter instVarNamed: #statusValueLabel. - self - assert: statusIcon image - equals: (presenter iconNamed: #testNotRun). + self assert: statusIcon image equals: (presenter iconNamed: #testNotRun). self assert: statusIcon help equals: 'Status: stopped'. self assert: statusLabel label equals: 'Status:'. self assert: statusValueLabel label equals: 'stopped' @@ -432,10 +394,7 @@ MCPDashboardPresenterTest >> testRefreshViewUsesShortSectionLabels [ tracesHeader := presenter instVarNamed: #tracesHeaderButton. logsHeader := presenter instVarNamed: #logsHeaderButton. - self - assert: toolsHeader label - equals: - ('[-] Tools ({1})' format: { mcp sortedTools size asString }). + 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)' @@ -461,22 +420,10 @@ MCPDashboardPresenterTest >> testServerStatusIconUsesTestIcons [ | presenter | presenter := MCPDashboardPresenter new. - self - assert: - (presenter serverStatusIconNameForRunning: false listening: false) - equals: #testNotRun. - self - assert: - (presenter serverStatusIconNameForRunning: true listening: true) - equals: #testGreen. - self - assert: - (presenter serverStatusIconNameForRunning: true listening: false) - equals: #testRed. - self - assert: - (presenter serverStatusIconNameForRunning: false listening: true) - equals: #testRed + self assert: (presenter serverStatusIconNameForRunning: false listening: false) equals: #testNotRun. + self assert: (presenter serverStatusIconNameForRunning: true listening: true) equals: #testGreen. + self assert: (presenter serverStatusIconNameForRunning: true listening: false) equals: #testRed. + self assert: (presenter serverStatusIconNameForRunning: false listening: true) equals: #testRed ] { #category : 'tests' } @@ -485,22 +432,10 @@ MCPDashboardPresenterTest >> testServerStatusLabelShowsListenerOnlyWhenAnomalous | presenter | presenter := MCPDashboardPresenter new. - self - assert: - (presenter serverStatusLabelForRunning: false listening: false) - equals: 'Status: stopped'. - self - assert: - (presenter serverStatusLabelForRunning: true listening: true) - equals: 'Status: running'. - self - assert: - (presenter serverStatusLabelForRunning: true listening: false) - equals: 'Status: running, socket not listening'. - self - assert: - (presenter serverStatusLabelForRunning: false listening: true) - equals: 'Status: stopped, socket still listening' + self assert: (presenter serverStatusLabelForRunning: false listening: false) equals: 'Status: stopped'. + self assert: (presenter serverStatusLabelForRunning: true listening: true) equals: 'Status: running'. + self assert: (presenter serverStatusLabelForRunning: true listening: false) equals: 'Status: running, socket not listening'. + self assert: (presenter serverStatusLabelForRunning: false listening: true) equals: 'Status: stopped, socket still listening' ] { #category : 'tests' } diff --git a/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st b/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st index 928cc2b..87a394a 100644 --- a/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st +++ b/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st @@ -14,9 +14,7 @@ MCPMonitoringStateTest >> testDefaultExportDirectoryIsImageLocal [ self assert: MCPMonitoringState defaultExportDirectory - equals: - Smalltalk imageFile asFileReference parent / 'pharo-local' / 'mcp' - / 'observability' + equals: Smalltalk imageFile asFileReference parent / 'pharo-local' / 'mcp' / 'observability' ] { #category : 'tests' } @@ -50,21 +48,13 @@ MCPMonitoringStateTest >> testInputSnapshotRedactsSensitiveKeys [ input := { (#limit -> 10). (#apiToken -> 'secret-token'). - (#nested - -> { (#password -> 'secret-password') } asDictionary) } - asDictionary. - record := state - recordToolCallStart: 'method_metadata_search' - input: input. + (#nested -> { (#password -> 'secret-password') } asDictionary) } asDictionary. + record := state recordToolCallStart: 'method_metadata_search' input: input. state recordToolCallFinish: record result: nil error: Error new. self assert: (record inputSnapshot at: #limit) equals: 10. - self - assert: (record inputSnapshot at: #apiToken) - equals: ''. - self - assert: ((record inputSnapshot at: #nested) at: #password) - equals: ''. + self assert: (record inputSnapshot at: #apiToken) equals: ''. + self assert: ((record inputSnapshot at: #nested) at: #password) equals: ''. self assert: record inputObject isNil ] @@ -77,11 +67,9 @@ MCPMonitoringStateTest >> testLargeOutputExceedingBudgetBecomesTraceAndGuidance state enable. largeText := String new: 200 withAll: $x. record := state recordToolCallStart: 'method_source_search'. - result := MCPStructureCallToolResult - content: #( ) - structuredContent: { - (#summary -> 'Large output'). - (#items -> { largeText }) } asDictionary. + result := MCPStructureCallToolResult content: #( ) structuredContent: { + (#summary -> 'Large output'). + (#items -> { largeText }) } asDictionary. state recordToolCallFinish: record result: result error: nil. stats := state metrics first. budgetBytes := state outputBudgetBytes. @@ -96,16 +84,14 @@ MCPMonitoringStateTest >> testLargeOutputExceedingBudgetBecomesTraceAndGuidance self assert: stats outputBudgetExceededCount equals: 1. self assert: stats maxOutputBytes equals: record outputByteCount. self assert: (state statusText includesSubstring: 'Output budget:'). - self assert: - (state outputBudgetGuidance includesSubstring: 'pagination') + self assert: (state outputBudgetGuidance includesSubstring: 'pagination') ] { #category : 'tests' } MCPMonitoringStateTest >> testMCPConfiguresMonitoringExportDirectory [ | defaultDirectory directory mcp | - directory := FileLocator temp asFileReference - / ('mcp-observability-' , UUID new asString). + directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). [ mcp := MCP new. defaultDirectory := MCPMonitoringState defaultExportDirectory. @@ -117,12 +103,9 @@ MCPMonitoringStateTest >> testMCPConfiguresMonitoringExportDirectory [ 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: [ directory exists ifTrue: [ directory deleteAll ] ] + self assert: (mcp monitoring resourceMetadata at: #'pharo.majorVersion') equals: PharoCompatibility currentMajorVersion. + self assert: (mcp monitoring resourceMetadata includesKey: #'mcp.port') ] ensure: [ + directory exists ifTrue: [ directory deleteAll ] ] ] { #category : 'tests' } @@ -132,12 +115,9 @@ MCPMonitoringStateTest >> testMCPRecordsToolCallDispatchPath [ mcp := MCP new. mcp monitoringEnabled: true. - mcp - rpcToolCall: 'tool_call' - withParams: { - (#toolName -> 'package_search'). - (#arguments -> { (#limit -> 0) } asDictionary) } - asDictionary. + mcp rpcToolCall: 'tool_call' withParams: { + (#toolName -> 'package_search'). + (#arguments -> { (#limit -> 0) } asDictionary) } asDictionary. records := mcp monitoring recentCallRecords. self assert: records size equals: 2. @@ -151,15 +131,12 @@ MCPMonitoringStateTest >> testMCPRecordsToolCallDispatchPath [ MCPMonitoringStateTest >> testMCPRecordsToolCallsWhenMonitoringIsEnabled [ | directory mcp record result stats | - directory := FileLocator temp asFileReference - / ('mcp-observability-' , UUID new asString). + directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). [ directory exists ifTrue: [ directory deleteAll ]. mcp := MCP new. mcp monitoringExportDirectory: directory. - result := mcp - rpcToolCall: 'package_search' - withParams: { (#limit -> 0) } asDictionary. + result := mcp rpcToolCall: 'package_search' withParams: { (#limit -> 0) } asDictionary. stats := mcp monitoringMetrics first. record := mcp monitoring recentCallRecords first. @@ -171,9 +148,8 @@ MCPMonitoringStateTest >> testMCPRecordsToolCallsWhenMonitoringIsEnabled [ 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 monitoring logFileReference exists. + self assert: mcp monitoring metricsFileReference exists ] ensure: [ directory exists ifTrue: [ directory deleteAll ] ] ] { #category : 'tests' } @@ -186,22 +162,15 @@ MCPMonitoringStateTest >> testOutlierExplanationUsesPriorAverage [ stats := MCPToolCallStats forToolNamed: 'slow-tool'. start := DateAndTime now. 1 to: 3 do: [ :index | - record := MCPToolCallRecord - id: index - toolName: 'slow-tool' - startedAt: start. + record := MCPToolCallRecord id: index toolName: 'slow-tool' startedAt: start. record finishAt: start durationMilliseconds: 10 status: 'ok'. stats recordCall: record ]. - explanation := state - outlierExplanationForDurationMilliseconds: 25 - stats: stats. + explanation := state outlierExplanationForDurationMilliseconds: 25 stats: stats. self assert: (explanation beginsWith: '25 ms is'). self deny: (explanation beginsWith: 'Outlier:'). - self assert: - (state outlierExplanationForDurationMilliseconds: 15 stats: stats) - isEmpty + self assert: (state outlierExplanationForDurationMilliseconds: 15 stats: stats) isEmpty ] { #category : 'tests' } @@ -218,19 +187,13 @@ 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: (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'. + self assert: (recentCall outputSnapshot at: #errorClass) equals: 'Error'. self assert: recentCall inputObject isNil. - self assert: (state logs anySatisfy: [ :each | - each includesSubstring: 'Error tool method_compile #' ]). - self deny: (state logs anySatisfy: [ :each | - each includesSubstring: 'error tool method_compile #' ]) + self assert: (state logs anySatisfy: [ :each | each includesSubstring: 'Error tool method_compile #' ]). + self deny: (state logs anySatisfy: [ :each | each includesSubstring: 'error tool method_compile #' ]) ] { #category : 'tests' } @@ -240,10 +203,7 @@ MCPMonitoringStateTest >> testRecordsOutputBudgetMetadata [ state := MCPMonitoringState new. state enable. record := state recordToolCallStart: 'method_metadata_search'. - result := MCPStructureCallToolResult - content: #( ) - structuredContent: - { (#summary -> 'Small output') } asDictionary. + result := MCPStructureCallToolResult content: #( ) structuredContent: { (#summary -> 'Small output') } asDictionary. state recordToolCallFinish: record result: result error: nil. stats := state metrics first. recordData := record asDictionary. @@ -251,24 +211,15 @@ MCPMonitoringStateTest >> testRecordsOutputBudgetMetadata [ self assert: record outputByteCount > 0. self assert: record inputByteCount equals: 0. - self - assert: record approximateOutputTokenCount - equals: - (state approximateOutputTokensForBytes: record outputByteCount). + self assert: record approximateOutputTokenCount equals: (state approximateOutputTokensForBytes: record outputByteCount). self deny: record outputBudgetExceeded. self assert: record outputBudgetExplanation isEmpty. self assert: record outputSnapshot isNil. self assert: stats lastOutputBytes equals: record outputByteCount. 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: (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: #firstCallAt) isNotEmpty. self assert: (statsData at: #lastCallAt) isNotEmpty ] @@ -280,10 +231,7 @@ MCPMonitoringStateTest >> testRecordsSuccessfulCallMetricsAndLogs [ state := MCPMonitoringState new. state enable. record := state recordToolCallStart: 'method_metadata_search'. - result := MCPStructureCallToolResult - content: #( ) - structuredContent: - { (#summary -> 'Listed methods') } asDictionary. + result := MCPStructureCallToolResult content: #( ) structuredContent: { (#summary -> 'Listed methods') } asDictionary. state recordToolCallFinish: record result: result error: nil. stats := state metrics first. @@ -293,69 +241,55 @@ MCPMonitoringStateTest >> testRecordsSuccessfulCallMetricsAndLogs [ self assert: stats errorCount equals: 0. self assert: state recentCallRecords size equals: 1. self assert: state logs size >= 2. - self assert: (state logs anySatisfy: [ :each | - each includesSubstring: 'Begin tool method_metadata_search #' ]). - self assert: (state logs anySatisfy: [ :each | - each includesSubstring: 'OK tool method_metadata_search #' ]). - self deny: (state logs anySatisfy: [ :each | - each includesSubstring: 'ok tool method_metadata_search #' ]). - self assert: - (state statusText beginsWith: 'Observability enabled. Calls: 1') + self assert: (state logs anySatisfy: [ :each | each includesSubstring: 'Begin tool method_metadata_search #' ]). + self assert: (state logs anySatisfy: [ :each | each includesSubstring: 'OK tool method_metadata_search #' ]). + self deny: (state logs anySatisfy: [ :each | each includesSubstring: 'ok tool method_metadata_search #' ]). + self assert: (state statusText beginsWith: 'Observability enabled. Calls: 1') ] { #category : 'tests' } MCPMonitoringStateTest >> testSessionLifecycleWritesResourceMarkers [ | directory resource state | - directory := FileLocator temp asFileReference - / ('mcp-observability-' , UUID new asString). + directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). [ directory exists ifTrue: [ directory deleteAll ]. state := MCPMonitoringState new. state exportDirectory: directory. state recordSessionStartFor: nil. - resource := STONJSON fromString: - state resourceFileReference contents. + 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. + resource := STONJSON fromString: state resourceFileReference contents. self assert: (resource includesKey: 'sessionStartedAt'). - self assert: (resource includesKey: 'sessionEndedAt') ] ensure: [ - directory exists ifTrue: [ directory deleteAll ] ] + self assert: (resource includesKey: 'sessionEndedAt') ] ensure: [ directory exists ifTrue: [ directory deleteAll ] ] ] { #category : 'tests' } MCPMonitoringStateTest >> testTraceExportSkipsNonTraceRecords [ | directory record result state | - directory := FileLocator temp asFileReference - / ('mcp-observability-' , UUID new asString). + directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). [ directory exists ifTrue: [ directory deleteAll ]. state := MCPMonitoringState new. state exportDirectory: directory. state outputBudgetBytes: 10000. record := state recordToolCallStart: 'package_search'. - result := MCPStructureCallToolResult - content: #( ) - structuredContent: - { (#summary -> 'Small output') } asDictionary. + result := MCPStructureCallToolResult content: #( ) structuredContent: { (#summary -> 'Small output') } asDictionary. state recordToolCallFinish: record result: result error: nil. self assert: directory exists. self deny: state traceFileReference exists. self assert: state logFileReference exists. - self assert: state metricsFileReference exists ] ensure: [ - directory exists ifTrue: [ directory deleteAll ] ] + self assert: state metricsFileReference exists ] ensure: [ directory exists ifTrue: [ directory deleteAll ] ] ] { #category : 'tests' } MCPMonitoringStateTest >> testTraceExportWritesOverBudgetRecordAsJSONLine [ | directory input line output payload record resource result state traceFile | - directory := FileLocator temp asFileReference - / ('mcp-observability-' , UUID new asString). + directory := FileLocator temp asFileReference / ('mcp-observability-' , UUID new asString). [ directory exists ifTrue: [ directory deleteAll ]. state := MCPMonitoringState new. @@ -367,34 +301,24 @@ MCPMonitoringStateTest >> testTraceExportWritesOverBudgetRecordAsJSONLine [ input := { (#limit -> 1). (#apiToken -> 'secret-token') } asDictionary. - record := state - recordToolCallStart: 'method_source_search' - input: input. - result := MCPStructureCallToolResult - content: #( ) - structuredContent: { - (#summary -> 'Large output'). - (#items -> { (String new: 200 withAll: $x) }) } - asDictionary. + record := state recordToolCallStart: 'method_source_search' input: input. + result := MCPStructureCallToolResult content: #( ) structuredContent: { + (#summary -> 'Large output'). + (#items -> { (String new: 200 withAll: $x) }) } asDictionary. state recordToolCallFinish: record result: result error: nil. self assert: state exportInstanceDirectory exists. - self assert: - (state exportInstanceDirectory basename beginsWith: - 'pharo-13-9013-'). + self assert: (state exportInstanceDirectory basename beginsWith: 'pharo-13-9013-'). self assert: state resourceFileReference exists. self assert: state logFileReference exists. self assert: state metricsFileReference exists. - resource := STONJSON fromString: - state resourceFileReference contents. + resource := STONJSON fromString: state resourceFileReference contents. self assert: (resource at: 'service.name') equals: 'mcp-pharo'. self assert: (resource at: 'pharo.majorVersion') equals: 13. traceFile := state traceFileReference. self assert: traceFile exists. line := traceFile contents lines first. payload := STONJSON fromString: line. - self - assert: (payload at: 'event.name') - equals: 'mcp.tool_call.output_budget_exceeded'. + 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'). @@ -402,13 +326,10 @@ MCPMonitoringStateTest >> testTraceExportWritesOverBudgetRecordAsJSONLine [ self assert: (payload at: 'outputBudgetBytes') equals: 80. self deny: (payload includesKey: 'outputBudgetExceeded'). self deny: (payload includesKey: 'summary'). - self - assert: ((payload at: 'input') at: 'apiToken') - equals: ''. + self assert: ((payload at: 'input') at: 'apiToken') equals: ''. output := payload at: 'output'. self assert: (output at: 'summary') equals: 'Large output'. - self assert: (output at: 'items') first size equals: 200 ] ensure: [ - directory exists ifTrue: [ directory deleteAll ] ] + self assert: (output at: 'items') first size equals: 200 ] ensure: [ directory exists ifTrue: [ directory deleteAll ] ] ] { #category : 'tests' } @@ -417,23 +338,13 @@ MCPMonitoringStateTest >> testTraceRecordsIncludeErrorsAndOutliersOnly [ | error normal outlier start state traces | state := MCPMonitoringState new. start := DateAndTime now. - normal := MCPToolCallRecord - id: 1 - toolName: 'method_metadata_search' - startedAt: start. + normal := MCPToolCallRecord id: 1 toolName: 'method_metadata_search' startedAt: start. normal finishAt: start durationMilliseconds: 5 status: 'ok'. - error := MCPToolCallRecord - id: 2 - toolName: 'method_compile' - startedAt: start. + error := MCPToolCallRecord id: 2 toolName: 'method_compile' startedAt: start. error finishAt: start durationMilliseconds: 7 status: 'error'. - outlier := MCPToolCallRecord - id: 3 - toolName: 'method_rewrite' - startedAt: start. + outlier := MCPToolCallRecord id: 3 toolName: 'method_rewrite' startedAt: start. outlier finishAt: start durationMilliseconds: 250 status: 'ok'. - outlier outlierExplanation: - '250 ms is 5.0x the prior average of 50.0 ms'. + outlier outlierExplanation: '250 ms is 5.0x the prior average of 50.0 ms'. state recentCalls addAll: { outlier. error. @@ -454,8 +365,7 @@ MCPMonitoringStateTest >> testTraceSnapshotForCollectionAddsTruncationMarker [ | snapshot state | state := MCPMonitoringState new. - snapshot := state traceSnapshotFor: - (1 to: state maxTraceCollectionItems + 2) asArray. + 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' ] diff --git a/src/MCP/MCP.class.st b/src/MCP/MCP.class.st index 5563859..7754162 100644 --- a/src/MCP/MCP.class.st +++ b/src/MCP/MCP.class.st @@ -157,25 +157,16 @@ MCP >> executeTool: tool named: toolName withParams: arguments dispatchPath: dis result := tool executeWithRequest: request. toolResult := result asMCPStructureCallToolResult. self saveImageAfterSuccessfulTool: tool result: toolResult. - self monitoring - recordToolCallFinish: monitorRecord - result: toolResult - error: nil. + self monitoring recordToolCallFinish: monitorRecord 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 monitoring recordToolCallFinish: monitorRecord result: nil error: error. error pass ]. errorResult := tool errorResultFor: error. - self monitoring - recordToolCallFinish: monitorRecord - result: errorResult - error: error. + self monitoring recordToolCallFinish: monitorRecord result: errorResult error: error. errorResult asJRPCJSONForResponseFields: self toolResponseFields ] ] @@ -301,9 +292,7 @@ MCP >> monitoringResourceMetadata [ | metadata | metadata := self monitoring resourceMetadata copy. - metadata - at: #'pharo.majorVersion' - put: PharoCompatibility currentMajorVersion. + metadata at: #'pharo.majorVersion' put: PharoCompatibility currentMajorVersion. metadata at: #'mcp.port' put: self port. ^ metadata ] @@ -353,8 +342,7 @@ MCP >> pharoRuntimeMetadata [ ^ { (#runtimeVersion -> PharoCompatibility runtimeVersionString). (#supportedMajorVersions -> self supportedPharoMajorVersions). - (#supportedLauncherVersions -> self supportedPharoLauncherVersions) } - asDictionary + (#supportedLauncherVersions -> self supportedPharoLauncherVersions) } asDictionary ] { #category : 'accessing' } @@ -461,8 +449,7 @@ MCP >> rpcToolCall: name withParams: arguments [ toolsList at: name ifPresent: [ :tool | - name = MCPToolCallTool toolName ifTrue: [ - ^ self rpcCallToolCallWithParams: arguments ]. + name = MCPToolCallTool toolName ifTrue: [ ^ self rpcCallToolCallWithParams: arguments ]. ^ self executeTool: tool named: name withParams: arguments ] ifAbsent: [ JRPCNonExistentHandler signal: name ] ] @@ -479,8 +466,7 @@ MCP >> rpcToolsList [ MCP >> saveImageAfterSuccessfulTool: aTool result: aResult [ aResult isError ifTrue: [ ^ self ]. - (aTool shouldSaveImageAfterSuccessfulExecutionForResult: aResult) - ifTrue: [ self deferSaveImageAfterCurrentResponse ] + (aTool shouldSaveImageAfterSuccessfulExecutionForResult: aResult) ifTrue: [ self deferSaveImageAfterCurrentResponse ] ] { #category : 'private - saving' } @@ -488,10 +474,7 @@ MCP >> saveImageSession [ "MCP autosave must not run SessionManager shutdown/startup hooks: Zinc stops managed servers during cycle, breaking active Streamable HTTP transports." | snapshotOperation | - snapshotOperation := SnapshotOperation - save: true - andQuit: false - withSessionManager: SessionManager default. + snapshotOperation := SnapshotOperation save: true andQuit: false withSessionManager: SessionManager default. snapshotOperation doSnapshot. ^ snapshotOperation isImageStarting ] @@ -505,15 +488,13 @@ MCP >> server [ { #category : 'infrastructure metadata' } MCP >> serverInfrastructureMetadata [ - ^ { (#'io.github.evref-bl/pharo' -> self pharoRuntimeMetadata) } - asDictionary + ^ { (#'io.github.evref-bl/pharo' -> self pharoRuntimeMetadata) } asDictionary ] { #category : 'accessing' } MCP >> sortedTools [ - ^ self toolsList values asSortedCollection: [ :left :right | - left name <= right name ] + ^ self toolsList values asSortedCollection: [ :left :right | left name <= right name ] ] { #category : 'start' } @@ -677,7 +658,7 @@ MCP >> waitUntilListeningForMilliseconds: milliseconds [ delay := Delay forMilliseconds: 50. attempts := milliseconds // 50 max: 1. attempts timesRepeat: [ - self isListening ifTrue: [ ^ true ]. - delay wait ]. + self isListening ifTrue: [ ^ true ]. + delay wait ]. ^ self isListening ] diff --git a/src/MCP/MCPMonitoringState.class.st b/src/MCP/MCPMonitoringState.class.st index 22b83a0..21c50d4 100644 --- a/src/MCP/MCPMonitoringState.class.st +++ b/src/MCP/MCPMonitoringState.class.st @@ -35,8 +35,7 @@ MCPMonitoringState class >> defaultExportDirectory [ MCPMonitoringState >> addRecentCall: aRecord [ self recentCalls addFirst: aRecord. - [ self recentCalls size > self maxRecentCalls ] whileTrue: [ - self recentCalls removeLast ] + [ self recentCalls size > self maxRecentCalls ] whileTrue: [ self recentCalls removeLast ] ] { #category : 'private - budgets' } @@ -52,13 +51,11 @@ MCPMonitoringState >> approximateOutputBytesForObject: anObject [ { #category : 'private - budgets' } MCPMonitoringState >> approximateOutputBytesForResult: aToolResult error: anError [ - aToolResult ifNotNil: [ - ^ self approximateOutputBytesForObject: aToolResult asJRPCJSON ]. + aToolResult ifNotNil: [ ^ self approximateOutputBytesForObject: aToolResult asJRPCJSON ]. anError ifNil: [ ^ 0 ]. ^ self approximateOutputBytesForObject: { (#errorClass -> anError class name asString). - (#message -> (anError messageText ifNil: [ anError asString ])) } - asDictionary + (#message -> (anError messageText ifNil: [ anError asString ])) } asDictionary ] { #category : 'private - budgets' } @@ -74,10 +71,8 @@ MCPMonitoringState >> captureTraceSnapshotsFor: aRecord result: aToolResult erro [ aRecord isTraceWorthy ifTrue: [ - aRecord inputSnapshot: - (self traceSnapshotFor: aRecord inputObject). - aRecord outputSnapshot: - (self traceSnapshotForResult: aToolResult error: anError) ] + aRecord inputSnapshot: (self traceSnapshotFor: aRecord inputObject). + aRecord outputSnapshot: (self traceSnapshotForResult: aToolResult error: anError) ] ifFalse: [ aRecord inputSnapshot: nil. aRecord outputSnapshot: nil ] ] @@ -86,11 +81,8 @@ MCPMonitoringState >> captureTraceSnapshotsFor: aRecord result: aToolResult erro aRecord inputSnapshot: nil. aRecord outputSnapshot: (self traceSnapshotFor: { (#errorClass -> snapshotError class name asString). - (#message - -> - (snapshotError messageText ifNil: [ snapshotError asString ])). - (#during -> 'trace snapshot creation') } asDictionary) ] ] - ensure: [ aRecord inputObject: nil ] + (#message -> (snapshotError messageText ifNil: [ snapshotError asString ])). + (#during -> 'trace snapshot creation') } asDictionary) ] ] ensure: [ aRecord inputObject: nil ] ] { #category : 'clearing' } @@ -188,8 +180,7 @@ MCPMonitoringState >> exportInstanceDirectory [ | directory | self exportEnabled ifFalse: [ ^ nil ]. exportInstanceDirectory ifNil: [ - directory := self exportDirectory - / self exportInstanceDirectoryName. + directory := self exportDirectory / self exportInstanceDirectoryName. directory ensureCreateDirectory. exportInstanceDirectory := directory. self writeResourceMetadata ]. @@ -219,10 +210,7 @@ MCPMonitoringState >> exportInstanceDirectoryName [ MCPMonitoringState >> exportLogMessage: aString timestamp: timestamp [ self exportEnabled ifFalse: [ ^ self ]. - [ - self - writeJSONLine: (self logRecordJSONFor: aString timestamp: timestamp) - to: self logFileReference ] + [ self writeJSONLine: (self logRecordJSONFor: aString timestamp: timestamp) to: self logFileReference ] on: Error do: [ :ignored | nil ] ] @@ -239,13 +227,10 @@ MCPMonitoringState >> exportMetrics [ { #category : 'private - tracing' } MCPMonitoringState >> exportTraceRecord: aRecord [ - (self exportEnabled and: [ aRecord isTraceWorthy ]) ifFalse: [ - ^ self ]. - [ - self writeJSONLine: (self traceRecordJSONFor: aRecord) to: self traceFileReference ] + (self exportEnabled and: [ aRecord isTraceWorthy ]) ifFalse: [ ^ self ]. + [ self writeJSONLine: (self traceRecordJSONFor: aRecord) to: self traceFileReference ] on: Error - do: [ :error | - self recordLog: 'Trace export failed: ' , error class name asString ] + do: [ :error | self recordLog: 'Trace export failed: ' , error class name asString ] ] { #category : 'actions' } @@ -280,8 +265,7 @@ MCPMonitoringState >> isSensitiveTraceKey: aKey [ | lowerKey | lowerKey := aKey asString asLowercase. - ^ #( 'password' 'passphrase' 'token' 'secret' 'credential' - 'authorization' 'apikey' 'api_key' ) anySatisfy: [ :term | + ^ #( 'password' 'passphrase' 'token' 'secret' 'credential' 'authorization' 'apikey' 'api_key' ) anySatisfy: [ :term | lowerKey includesSubstring: term ] ] @@ -301,15 +285,13 @@ MCPMonitoringState >> logEntries [ MCPMonitoringState >> logEntry: aString [ self logEntries add: aString. - [ self logEntries size > self maxLogEntries ] whileTrue: [ - self logEntries removeFirst ] + [ self logEntries size > self maxLogEntries ] whileTrue: [ self logEntries removeFirst ] ] { #category : 'accessing' } MCPMonitoringState >> logFileReference [ - ^ self exportInstanceDirectory ifNotNil: [ :directory | - directory / 'logs.jsonl' ] + ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'logs.jsonl' ] ] { #category : 'private' } @@ -347,8 +329,7 @@ MCPMonitoringState >> logRecordDictionaryFor: aString timestamp: timestamp [ { #category : 'private - exporting' } MCPMonitoringState >> logRecordJSONFor: aString timestamp: timestamp [ - ^ STONJSON toString: - (self logRecordDictionaryFor: aString timestamp: timestamp) + ^ STONJSON toString: (self logRecordDictionaryFor: aString timestamp: timestamp) ] { #category : 'private' } @@ -410,15 +391,13 @@ MCPMonitoringState >> maxTraceStringLength [ { #category : 'accessing' } MCPMonitoringState >> metrics [ - ^ self statsByTool values asSortedCollection: [ :left :right | - left toolName <= right toolName ] + ^ self statsByTool values asSortedCollection: [ :left :right | left toolName <= right toolName ] ] { #category : 'accessing' } MCPMonitoringState >> metricsFileReference [ - ^ self exportInstanceDirectory ifNotNil: [ :directory | - directory / 'metrics.json' ] + ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'metrics.json' ] ] { #category : 'private - exporting' } @@ -429,13 +408,8 @@ MCPMonitoringState >> metricsSnapshotDictionary [ (#updatedAt -> DateAndTime now asString). (#totalCallCount -> self totalCallCount). (#totalErrorCount -> self totalErrorCount). - (#totalOutputBudgetExceededCount - -> self totalOutputBudgetExceededCount). - (#tools - -> - (self metrics - collect: [ :each | each asMetricsDictionary ] - as: Array)) } asDictionary + (#totalOutputBudgetExceededCount -> self totalOutputBudgetExceededCount). + (#tools -> (self metrics collect: [ :each | each asMetricsDictionary ] as: Array)) } asDictionary ] { #category : 'accessing' } @@ -514,14 +488,11 @@ MCPMonitoringState >> outputBudgetBytes: anInteger [ MCPMonitoringState >> outputBudgetDataForResult: aToolResult error: anError [ | byteCount explanation | - byteCount := self - approximateOutputBytesForResult: aToolResult - error: anError. + byteCount := self approximateOutputBytesForResult: aToolResult error: anError. explanation := self outputBudgetExplanationForByteCount: byteCount. ^ { (#outputBytes -> byteCount). - (#approximateOutputTokens - -> (self approximateOutputTokensForBytes: byteCount)). + (#approximateOutputTokens -> (self approximateOutputTokensForBytes: byteCount)). (#outputBudgetBytes -> self outputBudgetBytes). (#outputBudgetExceeded -> explanation isNotEmpty). (#outputBudgetExplanation -> explanation) } asDictionary @@ -537,8 +508,7 @@ MCPMonitoringState >> outputBudgetExplanationForByteCount: byteCount [ print: byteCount; nextPutAll: ' bytes exceeds budget '; print: self outputBudgetBytes; - nextPutAll: - ' bytes; consider pagination, handles, or summary-first output' ] + nextPutAll: ' bytes; consider pagination, handles, or summary-first output' ] ] { #category : 'displaying' } @@ -582,8 +552,7 @@ MCPMonitoringState >> recordLog: aString [ { #category : 'private - budgets' } MCPMonitoringState >> recordOutputBudgetFor: aRecord result: aToolResult error: anError [ - aRecord recordOutputBudget: - (self outputBudgetDataForResult: aToolResult error: anError) + aRecord recordOutputBudget: (self outputBudgetDataForResult: aToolResult error: anError) ] { #category : 'recording' } @@ -636,34 +605,25 @@ MCPMonitoringState >> recordToolCallStart: aToolName input: inputObject dispatch self enabled ifFalse: [ ^ nil ]. nextCallId := self nextCallId + 1. startTimestamp := DateAndTime now. - record := MCPToolCallRecord - id: self nextCallId - toolName: aToolName asString - startedAt: startTimestamp. + record := MCPToolCallRecord id: self nextCallId toolName: aToolName asString startedAt: startTimestamp. record inputObject: inputObject. - record inputByteCount: (inputObject - ifNil: [ 0 ] - ifNotNil: [ - self approximateOutputBytesForObject: 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. + self recordLog: 'Begin tool ' , aToolName asString , ' #' , record id asString. ^ record ] { #category : 'accessing' } MCPMonitoringState >> resourceFileReference [ - ^ self exportInstanceDirectory ifNotNil: [ :directory | - directory / 'resource.json' ] + ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'resource.json' ] ] { #category : 'accessing' } MCPMonitoringState >> resourceMetadata [ - ^ resourceMetadata ifNil: [ - resourceMetadata := self newResourceMetadata ] + ^ resourceMetadata ifNil: [ resourceMetadata := self newResourceMetadata ] ] { #category : 'accessing' } @@ -671,9 +631,7 @@ MCPMonitoringState >> resourceMetadata: aDictionary [ | metadata | metadata := self resourceMetadata copy. - aDictionary ifNotNil: [ - aDictionary keysAndValuesDo: [ :key :value | - metadata at: key put: value ] ]. + aDictionary ifNotNil: [ aDictionary keysAndValuesDo: [ :key :value | metadata at: key put: value ] ]. resourceMetadata := metadata. exportInstanceDirectory := nil ] @@ -686,8 +644,7 @@ MCPMonitoringState >> shouldIncludeTraceAssociation: anAssociation [ value := anAssociation value. value ifNil: [ ^ false ]. (value isString and: [ value isEmpty ]) ifTrue: [ ^ false ]. - (value isCollection and: [ value isString not and: [ value isEmpty ] ]) - ifTrue: [ ^ false ]. + (value isCollection and: [ value isString not and: [ value isEmpty ] ]) ifTrue: [ ^ false ]. ^ true ] @@ -706,20 +663,16 @@ MCPMonitoringState >> statsByTool [ { #category : 'accessing' } MCPMonitoringState >> statsForToolNamed: aToolName [ - ^ self statsByTool - at: aToolName asString - ifAbsentPut: [ MCPToolCallStats forToolNamed: aToolName asString ] + ^ self statsByTool at: aToolName asString ifAbsentPut: [ MCPToolCallStats forToolNamed: aToolName asString ] ] { #category : 'private - budgets' } MCPMonitoringState >> statsNeedingOutputBudgetAttention [ - ^ (self metrics select: [ :each | each outputBudgetExceededCount > 0 ]) - asArray sort: [ :left :right | + ^ (self metrics select: [ :each | each outputBudgetExceededCount > 0 ]) asArray sort: [ :left :right | left outputBudgetExceededCount = right outputBudgetExceededCount ifTrue: [ left maxOutputBytes >= right maxOutputBytes ] - ifFalse: [ - left outputBudgetExceededCount > right outputBudgetExceededCount ] ] + ifFalse: [ left outputBudgetExceededCount > right outputBudgetExceededCount ] ] ] { #category : 'private' } @@ -759,10 +712,7 @@ MCPMonitoringState >> summaryForResult: aToolResult [ aToolResult ifNil: [ ^ '' ]. structuredContent := aToolResult structuredContent. structuredContent ifNil: [ ^ '' ]. - ^ (structuredContent - at: #summary - ifAbsent: [ structuredContent at: 'summary' ifAbsent: [ '' ] ]) - asString + ^ (structuredContent at: #summary ifAbsent: [ structuredContent at: 'summary' ifAbsent: [ '' ] ]) asString ] { #category : 'accessing' } @@ -780,9 +730,7 @@ MCPMonitoringState >> totalErrorCount [ { #category : 'accessing' } MCPMonitoringState >> totalOutputBudgetExceededCount [ - ^ self metrics - inject: 0 - into: [ :sum :each | sum + each outputBudgetExceededCount ] + ^ self metrics inject: 0 into: [ :sum :each | sum + each outputBudgetExceededCount ] ] { #category : 'private - tracing' } @@ -797,8 +745,7 @@ MCPMonitoringState >> traceEventNameForRecord: aRecord [ { #category : 'accessing' } MCPMonitoringState >> traceFileReference [ - ^ self exportInstanceDirectory ifNotNil: [ :directory | - directory / 'traces.jsonl' ] + ^ self exportInstanceDirectory ifNotNil: [ :directory | directory / 'traces.jsonl' ] ] { #category : 'private - tracing' } @@ -851,29 +798,21 @@ MCPMonitoringState >> traceSnapshotFor: anObject [ MCPMonitoringState >> traceSnapshotFor: anObject depth: depth [ anObject ifNil: [ ^ nil ]. - (anObject isString or: [ anObject isSymbol ]) ifTrue: [ - ^ self traceStringSnapshotFor: anObject ]. - (anObject isNumber or: [ anObject == true or: [ anObject == false ] ]) - ifTrue: [ ^ anObject ]. + (anObject isString or: [ anObject isSymbol ]) ifTrue: [ ^ self traceStringSnapshotFor: anObject ]. + (anObject isNumber or: [ anObject == true or: [ anObject == false ] ]) ifTrue: [ ^ anObject ]. anObject isCharacter ifTrue: [ ^ anObject asString ]. - depth >= self maxTraceDepth ifTrue: [ - ^ self traceStringSnapshotFor: anObject printString ]. - anObject isAssociation ifTrue: [ - ^ self traceSnapshotForAssociation: anObject depth: depth ]. - anObject isDictionary ifTrue: [ - ^ self traceSnapshotForDictionary: anObject depth: depth ]. - (anObject isCollection and: [ anObject isString not ]) ifTrue: [ - ^ self traceSnapshotForCollection: anObject depth: depth ]. + depth >= self maxTraceDepth ifTrue: [ ^ self traceStringSnapshotFor: anObject printString ]. + anObject isAssociation ifTrue: [ ^ self traceSnapshotForAssociation: anObject depth: depth ]. + anObject isDictionary ifTrue: [ ^ self traceSnapshotForDictionary: anObject depth: depth ]. + (anObject isCollection and: [ anObject isString not ]) ifTrue: [ ^ self traceSnapshotForCollection: anObject depth: depth ]. ^ self traceStringSnapshotFor: anObject printString ] { #category : 'private - tracing' } MCPMonitoringState >> traceSnapshotForAssociation: anAssociation depth: depth [ - (self isSensitiveTraceKey: anAssociation key) ifTrue: [ - ^ anAssociation key -> '' ]. - ^ anAssociation key - -> (self traceSnapshotFor: anAssociation value depth: depth + 1) + (self isSensitiveTraceKey: anAssociation key) ifTrue: [ ^ anAssociation key -> '' ]. + ^ anAssociation key -> (self traceSnapshotFor: anAssociation value depth: depth + 1) ] { #category : 'private - tracing' } @@ -883,13 +822,8 @@ MCPMonitoringState >> traceSnapshotForCollection: aCollection depth: depth [ array := aCollection asArray. limit := array size min: self maxTraceCollectionItems. overflow := array size - limit. - snapshot := (1 to: limit) collect: [ :index | - self - traceSnapshotFor: (array at: index) - depth: depth + 1 ]. - overflow positive ifTrue: [ - snapshot := snapshot copyWith: - 'truncated ' , overflow asString , ' items omitted' ]. + snapshot := (1 to: limit) collect: [ :index | self traceSnapshotFor: (array at: index) depth: depth + 1 ]. + overflow positive ifTrue: [ snapshot := snapshot copyWith: 'truncated ' , overflow asString , ' items omitted' ]. ^ snapshot asArray ] @@ -899,30 +833,23 @@ MCPMonitoringState >> traceSnapshotForDictionary: aDictionary depth: depth [ | snapshot | snapshot := Dictionary new. aDictionary associationsDo: [ :association | - (snapshot size < self maxTraceCollectionItems and: [ - self shouldIncludeTraceAssociation: association ]) ifTrue: [ + (snapshot size < self maxTraceCollectionItems and: [ self shouldIncludeTraceAssociation: association ]) ifTrue: [ | sanitized | - sanitized := self - traceSnapshotForAssociation: association - depth: depth. + sanitized := self traceSnapshotForAssociation: association depth: depth. snapshot at: sanitized key put: sanitized value ] ]. aDictionary size > self maxTraceCollectionItems ifTrue: [ - snapshot - at: #__truncated__ - put: aDictionary size - self maxTraceCollectionItems ]. + snapshot at: #__truncated__ put: aDictionary size - self maxTraceCollectionItems ]. ^ snapshot ] { #category : 'private - tracing' } MCPMonitoringState >> traceSnapshotForResult: aToolResult error: anError [ - aToolResult ifNotNil: [ - ^ self traceSnapshotFor: aToolResult structuredContent ]. + aToolResult ifNotNil: [ ^ self traceSnapshotFor: aToolResult structuredContent ]. anError ifNil: [ ^ nil ]. ^ self traceSnapshotFor: { (#errorClass -> anError class name asString). - (#message -> (anError messageText ifNil: [ anError asString ])) } - asDictionary + (#message -> (anError messageText ifNil: [ anError asString ])) } asDictionary ] { #category : 'private - tracing' } @@ -945,9 +872,7 @@ MCPMonitoringState >> writeJSON: aDictionary to: aFileReference [ aFileReference exists ifTrue: [ aFileReference delete ]. temporaryFile renameTo: aFileReference basename. temporaryFile := nil. - aFileReference ] ensure: [ - temporaryFile ifNotNil: [ - temporaryFile exists ifTrue: [ temporaryFile delete ] ] ] + aFileReference ] ensure: [ temporaryFile ifNotNil: [ temporaryFile exists ifTrue: [ temporaryFile delete ] ] ] ] { #category : 'private - exporting' } @@ -955,10 +880,10 @@ MCPMonitoringState >> writeJSONLine: aString to: aFileReference [ aFileReference parent ensureCreateDirectory. aFileReference writeStreamDo: [ :stream | - stream - setToEnd; - nextPutAll: aString; - lf ] + stream + setToEnd; + nextPutAll: aString; + lf ] ] { #category : 'private - exporting' } diff --git a/src/MCP/MCPToolCallRecord.class.st b/src/MCP/MCPToolCallRecord.class.st index ecf85c3..43b60a7 100644 --- a/src/MCP/MCPToolCallRecord.class.st +++ b/src/MCP/MCPToolCallRecord.class.st @@ -100,16 +100,13 @@ MCPToolCallRecord >> displayDurationMilliseconds [ { #category : 'displaying' } MCPToolCallRecord >> displayStartedAt [ - ^ self startedAt - ifNil: [ '' ] - ifNotNil: [ :timestamp | timestamp asString ] + ^ self startedAt ifNil: [ '' ] ifNotNil: [ :timestamp | timestamp asString ] ] { #category : 'displaying' } MCPToolCallRecord >> displaySummary [ - self outputBudgetExplanation isEmpty ifFalse: [ - ^ self outputBudgetExplanation ]. + self outputBudgetExplanation isEmpty ifFalse: [ ^ self outputBudgetExplanation ]. self outlierExplanation isEmpty ifFalse: [ ^ self outlierExplanation ]. self summary isEmpty ifFalse: [ ^ self summary ]. ^ self errorClass @@ -228,9 +225,7 @@ MCPToolCallRecord >> isRunning [ { #category : 'testing' } MCPToolCallRecord >> isTraceWorthy [ - ^ self status = 'error' or: [ - self outlierExplanation isNotEmpty or: [ - self outputBudgetExceeded ] ] + ^ self status = 'error' or: [ self outlierExplanation isNotEmpty or: [ self outputBudgetExceeded ] ] ] { #category : 'accessing' } @@ -309,12 +304,10 @@ MCPToolCallRecord >> outputSnapshot: anObject [ MCPToolCallRecord >> recordOutputBudget: budgetData [ self outputByteCount: (budgetData at: #outputBytes). - self approximateOutputTokenCount: - (budgetData at: #approximateOutputTokens). + self approximateOutputTokenCount: (budgetData at: #approximateOutputTokens). self outputBudgetBytes: (budgetData at: #outputBudgetBytes). self outputBudgetExceeded: (budgetData at: #outputBudgetExceeded). - self outputBudgetExplanation: - (budgetData at: #outputBudgetExplanation) + self outputBudgetExplanation: (budgetData at: #outputBudgetExplanation) ] { #category : 'accessing' } diff --git a/src/MCP/MCPToolCallStats.class.st b/src/MCP/MCPToolCallStats.class.st index c20999b..7f077e8 100644 --- a/src/MCP/MCPToolCallStats.class.st +++ b/src/MCP/MCPToolCallStats.class.st @@ -268,8 +268,7 @@ MCPToolCallStats >> outputBudgetGuidance [ stream nextPutAll: ' (max '; print: self maxOutputBytes; - nextPutAll: - ' bytes). Prefer pagination, handles, or summary-first output.' ] + nextPutAll: ' bytes). Prefer pagination, handles, or summary-first output.' ] ] { #category : 'recording' } @@ -315,9 +314,7 @@ MCPToolCallStats >> successCount [ { #category : 'private' } MCPToolCallStats >> timestampStringFor: aTimestamp [ - ^ aTimestamp - ifNil: [ '' ] - ifNotNil: [ :timestamp | timestamp asString ] + ^ aTimestamp ifNil: [ '' ] ifNotNil: [ :timestamp | timestamp asString ] ] { #category : 'accessing' } From 690d880f850cf9c3f32ff5d70c9bd14a224af2b7 Mon Sep 17 00:00:00 2001 From: Gabriel Darbord <78592838+Gabriel-Darbord@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:27:38 +0200 Subject: [PATCH 6/8] Rename monitoring API to observability --- docs/user/safety-and-ecosystem.md | 10 +- src/BaselineOfMCP/BaselineOfMCP.class.st | 2 +- src/MCP-OpenTelemetry/MCP.extension.st | 4 +- src/MCP-Tests/MCPTestCase.class.st | 4 +- .../MCPDashboardPresenterTest.class.st | 72 +++---- ...MCPLocalObservabilityBackendTest.class.st} | 114 +++++----- src/MCP-UI/MCPDashboardPresenter.class.st | 90 ++++---- src/MCP/MCP.class.st | 148 ++++++------- ... => MCPLocalObservabilityBackend.class.st} | 200 +++++++++--------- src/MCP/MCPNoopObservabilityBackend.class.st | 4 +- src/MCP/MCPObservabilityBackend.class.st | 4 +- src/MCP/MCPToolCallRecord.class.st | 6 +- src/MCP/MCPToolCallStats.class.st | 6 +- 13 files changed, 332 insertions(+), 332 deletions(-) rename src/MCP-UI-Tests/{MCPMonitoringStateTest.class.st => MCPLocalObservabilityBackendTest.class.st} (79%) rename src/MCP/{MCPMonitoringState.class.st => MCPLocalObservabilityBackend.class.st} (78%) diff --git a/docs/user/safety-and-ecosystem.md b/docs/user/safety-and-ecosystem.md index 87e96c1..3bf5169 100644 --- a/docs/user/safety-and-ecosystem.md +++ b/docs/user/safety-and-ecosystem.md @@ -227,22 +227,22 @@ MCP starts with a no-op observability backend. Enable the built-in JSON observability backend explicitly: ```smalltalk -mcp monitoringEnabled: true +mcp observabilityEnabled: true ``` By default, JSON observability exports under the image-local `pharo-local/mcp/observability` directory. Configure -`mcp monitoringExportDirectory: aDirectory` to override that export root for +`mcp observabilityExportDirectory: aDirectory` to override that export root for instance metadata, `logs.jsonl`, `metrics.json`, and `traces.jsonl`. -OpenTelemetry support is optional. Load the `Telemetry` group, then enable +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: 'Telemetry'. + load: 'OpenTelemetry'. mcp useOpenTelemetryObservability ``` @@ -250,7 +250,7 @@ mcp useOpenTelemetryObservability ## Version Compatibility The core baseline loads PharoCompatibility, JRPC, and PCRE2. -OpenTelemetry is loaded only by the optional `Telemetry` group. CI covers +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 04e1c31..938606a 100644 --- a/src/BaselineOfMCP/BaselineOfMCP.class.st +++ b/src/BaselineOfMCP/BaselineOfMCP.class.st @@ -42,7 +42,7 @@ BaselineOfMCP >> defineGroups: spec [ spec group: 'Core' with: #( 'MCP' ); group: 'UI' with: #( 'MCP-UI' ); - group: 'Telemetry' with: #( 'MCP-OpenTelemetry' ); + 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' ) diff --git a/src/MCP-OpenTelemetry/MCP.extension.st b/src/MCP-OpenTelemetry/MCP.extension.st index 4337232..becf9a9 100644 --- a/src/MCP-OpenTelemetry/MCP.extension.st +++ b/src/MCP-OpenTelemetry/MCP.extension.st @@ -3,6 +3,6 @@ Extension { #name : 'MCP' } { #category : '*MCP-OpenTelemetry' } MCP >> useOpenTelemetryObservability [ - self monitoring: MCPOpenTelemetryObservabilityBackend new. - ^ self monitoring + self observability: MCPOpenTelemetryObservabilityBackend new. + ^ self observability ] diff --git a/src/MCP-Tests/MCPTestCase.class.st b/src/MCP-Tests/MCPTestCase.class.st index 1a0b765..0d84063 100644 --- a/src/MCP-Tests/MCPTestCase.class.st +++ b/src/MCP-Tests/MCPTestCase.class.st @@ -47,11 +47,11 @@ MCPTestCase >> ensureMethodSource: aMethodSource protocol: aProtocol on: aBehavi self withoutEpiceaDuring: [ aBehavior compile: aMethodSource classified: aProtocol ] ] -{ #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 a01a69a..0402ba8 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,14 +220,14 @@ MCPDashboardPresenterTest >> testDisclosureLabelsIncludeStateTitleAndCount [ ] { #category : 'tests' } -MCPDashboardPresenterTest >> testFreshMCPWithNoopObservabilityCollapsesMonitoringSections [ +MCPDashboardPresenterTest >> testFreshMCPWithNoopObservabilityCollapsesObservabilitySections [ | mcp presenter | mcp := MCP new. presenter := MCPDashboardPresenter on: mcp. presenter refreshView. - self deny: mcp monitoringEnabled. + self deny: mcp observabilityEnabled. self assert: presenter isToolsSectionExpanded. self deny: presenter isMetricsSectionExpanded. self deny: presenter isTracesSectionExpanded. @@ -263,14 +263,14 @@ MCPDashboardPresenterTest >> testInitialSectionsCollapseAfterObservabilityIsDisa | mcp presenter record | 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. + 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 deny: mcp monitoringEnabled. + self deny: mcp observabilityEnabled. self deny: presenter isMetricsSectionExpanded. self deny: presenter isTracesSectionExpanded. self deny: presenter isLogsSectionExpanded @@ -281,7 +281,7 @@ MCPDashboardPresenterTest >> testInitialSectionsOpenForObservabilityEnabled [ | mcp presenter | mcp := MCP new. - mcp monitoringEnabled: true. + mcp observabilityEnabled: true. presenter := MCPDashboardPresenter on: mcp. presenter refreshView. @@ -342,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'. @@ -358,11 +358,11 @@ MCPDashboardPresenterTest >> testObservabilityToggleUpdatesModelImmediately [ mcp := MCP new. presenter := MCPDashboardPresenter on: mcp. - 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 monitoringEnabled + self deny: mcp observabilityEnabled ] { #category : 'tests' } diff --git a/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st b/src/MCP-UI-Tests/MCPLocalObservabilityBackendTest.class.st similarity index 79% rename from src/MCP-UI-Tests/MCPMonitoringStateTest.class.st rename to src/MCP-UI-Tests/MCPLocalObservabilityBackendTest.class.st index 87a394a..1fba040 100644 --- a/src/MCP-UI-Tests/MCPMonitoringStateTest.class.st +++ b/src/MCP-UI-Tests/MCPLocalObservabilityBackendTest.class.st @@ -1,27 +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 >> testDefaultExportDirectoryIsImageLocal [ +MCPLocalObservabilityBackendTest >> testDefaultExportDirectoryIsImageLocal [ self - assert: MCPMonitoringState defaultExportDirectory + assert: MCPLocalObservabilityBackend defaultExportDirectory equals: Smalltalk imageFile asFileReference parent / 'pharo-local' / 'mcp' / 'observability' ] { #category : 'tests' } -MCPMonitoringStateTest >> testEnabledByDefaultRecords [ +MCPLocalObservabilityBackendTest >> testEnabledByDefaultRecords [ | record state | - state := MCPMonitoringState new. + state := MCPLocalObservabilityBackend new. record := state recordToolCallStart: 'method_metadata_search'. self assert: state enabled. self assert: record notNil. @@ -30,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). @@ -59,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. @@ -88,37 +88,37 @@ 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 deny: mcp monitoringEnabled. - self assert: mcp monitoring class equals: MCPNoopObservabilityBackend. - self assert: mcp monitoringExportDirectory isNil. - mcp monitoringEnabled: true. - 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 >> testMCPRecordsToolCallDispatchPath [ +MCPLocalObservabilityBackendTest >> testMCPRecordsToolCallDispatchPath [ | mcp records | mcp := MCP new. - mcp monitoringEnabled: true. + mcp observabilityEnabled: true. mcp rpcToolCall: 'tool_call' withParams: { (#toolName -> 'package_search'). (#arguments -> { (#limit -> 0) } asDictionary) } asDictionary. - records := mcp monitoring recentCallRecords. + records := mcp observability recentCallRecords. self assert: records size equals: 2. self assert: records first toolName equals: 'tool_call'. @@ -128,35 +128,35 @@ MCPMonitoringStateTest >> testMCPRecordsToolCallDispatchPath [ ] { #category : 'tests' } -MCPMonitoringStateTest >> testMCPRecordsToolCallsWhenMonitoringIsEnabled [ +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'. @@ -174,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. @@ -197,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. @@ -225,10 +225,10 @@ MCPMonitoringStateTest >> testRecordsOutputBudgetMetadata [ ] { #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. @@ -248,13 +248,13 @@ MCPMonitoringStateTest >> testRecordsSuccessfulCallMetricsAndLogs [ ] { #category : 'tests' } -MCPMonitoringStateTest >> testSessionLifecycleWritesResourceMarkers [ +MCPLocalObservabilityBackendTest >> testSessionLifecycleWritesResourceMarkers [ | directory resource 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 recordSessionStartFor: nil. resource := STONJSON fromString: state resourceFileReference contents. @@ -267,13 +267,13 @@ MCPMonitoringStateTest >> testSessionLifecycleWritesResourceMarkers [ ] { #category : 'tests' } -MCPMonitoringStateTest >> testTraceExportSkipsNonTraceRecords [ +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'. @@ -286,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. @@ -333,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'. @@ -361,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 a74be60..e2d1cad 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 7754162..cfc3f7f 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' } @@ -99,7 +99,7 @@ MCP >> deferSaveImageAfterCurrentResponse [ ] { #category : 'private - tools' } -MCP >> dispatchToolCallWith: callTool arguments: arguments monitorRecord: monitorRecord [ +MCP >> dispatchToolCallWith: callTool arguments: arguments observabilityRecord: observabilityRecord [ | callRequest request targetResponse targetTool wrapperResult | request := callTool requestFromToolCallArguments: arguments. @@ -112,15 +112,15 @@ MCP >> dispatchToolCallWith: callTool arguments: arguments monitorRecord: monito withParams: callRequest arguments dispatchPath: 'via_tool_call'. wrapperResult := self toolCallWrapperResultFor: callTool request: callRequest. - self finishToolCallWrapper: monitorRecord result: wrapperResult. + self finishToolCallWrapper: observabilityRecord result: wrapperResult. ^ targetResponse ] -{ #category : 'monitoring' } -MCP >> ensureConcreteMonitoring [ +{ #category : 'observability' } +MCP >> ensureLocalObservabilityBackend [ - self monitoring isNoop ifTrue: [ monitoring := self newMonitoringState ]. - ^ self monitoring + self observability isNoop ifTrue: [ observability := self newLocalObservabilityBackend ]. + ^ self observability ] { #category : 'private - start' } @@ -146,8 +146,8 @@ MCP >> executeTool: tool named: toolName withParams: arguments [ { #category : 'private - tools' } MCP >> executeTool: tool named: toolName withParams: arguments dispatchPath: dispatchPath [ - | monitorRecord request result toolResult | - monitorRecord := self monitoring + | observabilityRecord request result toolResult | + observabilityRecord := self observability recordToolCallStart: toolName input: arguments dispatchPath: dispatchPath @@ -157,34 +157,34 @@ MCP >> executeTool: tool named: toolName withParams: arguments dispatchPath: dis 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: monitorRecord result: wrapperResult [ +MCP >> finishToolCallWrapper: observabilityRecord result: wrapperResult [ - self monitoring recordToolCallFinish: monitorRecord result: wrapperResult asMCPStructureCallToolResult error: nil + self observability recordToolCallFinish: observabilityRecord result: wrapperResult asMCPStructureCallToolResult error: nil ] { #category : 'private - tools' } -MCP >> handleToolCallWrapperError: error tool: callTool monitorRecord: monitorRecord [ +MCP >> handleToolCallWrapperError: error tool: callTool observabilityRecord: observabilityRecord [ | errorResult | error mcpIsJRPCError ifTrue: [ - self monitoring recordToolCallFinish: monitorRecord result: nil error: error. + self observability recordToolCallFinish: observabilityRecord result: nil error: error. error pass ]. errorResult := callTool errorResultFor: error. - self monitoring recordToolCallFinish: monitorRecord result: errorResult error: error. + self observability recordToolCallFinish: observabilityRecord result: errorResult error: error. ^ errorResult asJRPCJSONForResponseFields: self toolResponseFields ] @@ -201,7 +201,7 @@ MCP >> initialize [ self refreshToolsList. server := MCPHTTPServer new. server addHandlersFromPragmasIn: self. - monitoring := MCPNoopObservabilityBackend new + observability := MCPNoopObservabilityBackend new ] { #category : 'testing' } @@ -223,98 +223,98 @@ MCP >> localUrlString [ ] { #category : 'accessing' } -MCP >> monitoring [ +MCP >> observability [ - ^ monitoring ifNil: [ monitoring := MCPNoopObservabilityBackend new ] + ^ observability ifNil: [ observability := MCPNoopObservabilityBackend new ] ] { #category : 'accessing' } -MCP >> monitoring: anObservabilityBackend [ +MCP >> observability: anObservabilityBackend [ - monitoring := anObservabilityBackend + 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 [ aBoolean - ifTrue: [ self ensureConcreteMonitoring enabled: true ] - ifFalse: [ monitoring := MCPNoopObservabilityBackend new ] + 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 [ - | concreteMonitoring | - concreteMonitoring := self ensureConcreteMonitoring. - concreteMonitoring resourceMetadata: self monitoringResourceMetadata. - concreteMonitoring 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 : 'monitoring' } -MCP >> newMonitoringState [ +{ #category : 'observability' } +MCP >> newLocalObservabilityBackend [ - ^ MCPMonitoringState new - resourceMetadata: self monitoringResourceMetadata; - exportDirectory: MCPMonitoringState defaultExportDirectory; + ^ MCPLocalObservabilityBackend new + resourceMetadata: self observabilityResourceMetadata; + exportDirectory: MCPLocalObservabilityBackend defaultExportDirectory; yourself ] @@ -360,7 +360,7 @@ MCP >> port: aPortNumber [ { #category : 'private - tools' } MCP >> recordToolCallWrapperStartFor: callTool arguments: arguments [ - ^ self monitoring + ^ self observability recordToolCallStart: callTool name input: arguments dispatchPath: 'direct' @@ -406,12 +406,12 @@ MCP >> restart [ { #category : 'private - tools' } MCP >> rpcCallToolCallWithParams: arguments [ - | callTool monitorRecord | + | callTool observabilityRecord | callTool := self toolCallTool. - monitorRecord := self recordToolCallWrapperStartFor: callTool arguments: arguments. - ^ [ self dispatchToolCallWith: callTool arguments: arguments monitorRecord: monitorRecord ] + observabilityRecord := self recordToolCallWrapperStartFor: callTool arguments: arguments. + ^ [ self dispatchToolCallWith: callTool arguments: arguments observabilityRecord: observabilityRecord ] on: Exception - do: [ :error | self handleToolCallWrapperError: error tool: callTool monitorRecord: monitorRecord ] + do: [ :error | self handleToolCallWrapperError: error tool: callTool observabilityRecord: observabilityRecord ] ] { #category : 'rpc' } @@ -504,7 +504,7 @@ MCP >> start [ server addHandlersFromPragmasIn: self. server start. self ensureServerIsListening. - self monitoring recordSessionStartFor: self + self observability recordSessionStartFor: self ] { #category : 'configuration' } @@ -523,7 +523,7 @@ MCP >> staticToolNames: aCollection [ { #category : 'start' } MCP >> stop [ - [ server stop ] ensure: [ self monitoring shutdown ] + [ server stop ] ensure: [ self observability shutdown ] ] { #category : 'infrastructure metadata' } diff --git a/src/MCP/MCPMonitoringState.class.st b/src/MCP/MCPLocalObservabilityBackend.class.st similarity index 78% rename from src/MCP/MCPMonitoringState.class.st rename to src/MCP/MCPLocalObservabilityBackend.class.st index 21c50d4..4c800d0 100644 --- a/src/MCP/MCPMonitoringState.class.st +++ b/src/MCP/MCPLocalObservabilityBackend.class.st @@ -2,7 +2,7 @@ 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', + #name : 'MCPLocalObservabilityBackend', #superclass : 'MCPObservabilityBackend', #instVars : [ 'enabled', @@ -20,26 +20,26 @@ Class { 'exportInstanceDirectory', 'resourceMetadata' ], - #category : 'MCP-Monitoring', + #category : 'MCP-Observability', #package : 'MCP', - #tag : 'Monitoring' + #tag : 'Observability' } { #category : 'defaults' } -MCPMonitoringState class >> defaultExportDirectory [ +MCPLocalObservabilityBackend class >> defaultExportDirectory [ ^ 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 ] @@ -49,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 ]. @@ -59,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 [ [ [ @@ -86,7 +86,7 @@ MCPMonitoringState >> captureTraceSnapshotsFor: aRecord result: aToolResult erro ] { #category : 'clearing' } -MCPMonitoringState >> clear [ +MCPLocalObservabilityBackend >> clear [ self statsByTool removeAll. self recentCalls removeAll. @@ -97,27 +97,27 @@ MCPMonitoringState >> clear [ ] { #category : 'logging' } -MCPMonitoringState >> clearLogs [ +MCPLocalObservabilityBackend >> clearLogs [ 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 ]. enabled := true. @@ -126,13 +126,13 @@ MCPMonitoringState >> enable [ ] { #category : 'accessing' } -MCPMonitoringState >> enabled [ +MCPLocalObservabilityBackend >> enabled [ ^ enabled ifNil: [ false ] ] { #category : 'accessing' } -MCPMonitoringState >> enabled: aBoolean [ +MCPLocalObservabilityBackend >> enabled: aBoolean [ aBoolean ifTrue: [ self enable ] @@ -140,14 +140,14 @@ MCPMonitoringState >> enabled: aBoolean [ ] { #category : 'private' } -MCPMonitoringState >> errorClassNameFor: anError [ +MCPLocalObservabilityBackend >> errorClassNameFor: anError [ anError ifNil: [ ^ '' ]. ^ anError class name asString ] { #category : 'private - tracing' } -MCPMonitoringState >> eventKindsForRecord: aRecord [ +MCPLocalObservabilityBackend >> eventKindsForRecord: aRecord [ ^ Array streamContents: [ :stream | aRecord status = 'error' ifTrue: [ stream nextPut: 'error' ]. @@ -156,26 +156,26 @@ MCPMonitoringState >> eventKindsForRecord: aRecord [ ] { #category : 'accessing' } -MCPMonitoringState >> exportDirectory [ +MCPLocalObservabilityBackend >> exportDirectory [ ^ exportDirectory ] { #category : 'accessing' } -MCPMonitoringState >> exportDirectory: aPath [ +MCPLocalObservabilityBackend >> exportDirectory: aPath [ exportDirectory := aPath ifNotNil: [ :path | path asFileReference ]. exportInstanceDirectory := nil ] { #category : 'testing' } -MCPMonitoringState >> exportEnabled [ +MCPLocalObservabilityBackend >> exportEnabled [ ^ self exportDirectory notNil ] { #category : 'accessing' } -MCPMonitoringState >> exportInstanceDirectory [ +MCPLocalObservabilityBackend >> exportInstanceDirectory [ | directory | self exportEnabled ifFalse: [ ^ nil ]. @@ -188,7 +188,7 @@ MCPMonitoringState >> exportInstanceDirectory [ ] { #category : 'private - exporting' } -MCPMonitoringState >> exportInstanceDirectoryName [ +MCPLocalObservabilityBackend >> exportInstanceDirectoryName [ | instanceId metadata port suffix version | metadata := self resourceMetadata. @@ -207,7 +207,7 @@ MCPMonitoringState >> exportInstanceDirectoryName [ ] { #category : 'private - exporting' } -MCPMonitoringState >> exportLogMessage: aString timestamp: timestamp [ +MCPLocalObservabilityBackend >> exportLogMessage: aString timestamp: timestamp [ self exportEnabled ifFalse: [ ^ self ]. [ self writeJSONLine: (self logRecordJSONFor: aString timestamp: timestamp) to: self logFileReference ] @@ -216,7 +216,7 @@ MCPMonitoringState >> exportLogMessage: aString timestamp: timestamp [ ] { #category : 'private - exporting' } -MCPMonitoringState >> exportMetrics [ +MCPLocalObservabilityBackend >> exportMetrics [ self exportEnabled ifFalse: [ ^ self ]. [ self writeMetricsSnapshot ] @@ -225,7 +225,7 @@ MCPMonitoringState >> exportMetrics [ ] { #category : 'private - tracing' } -MCPMonitoringState >> exportTraceRecord: aRecord [ +MCPLocalObservabilityBackend >> exportTraceRecord: aRecord [ (self exportEnabled and: [ aRecord isTraceWorthy ]) ifFalse: [ ^ self ]. [ self writeJSONLine: (self traceRecordJSONFor: aRecord) to: self traceFileReference ] @@ -234,13 +234,13 @@ MCPMonitoringState >> exportTraceRecord: aRecord [ ] { #category : 'actions' } -MCPMonitoringState >> forceFlush [ +MCPLocalObservabilityBackend >> forceFlush [ self exportMetrics ] { #category : 'initialization' } -MCPMonitoringState >> initialize [ +MCPLocalObservabilityBackend >> initialize [ super initialize. enabled := true. @@ -255,13 +255,13 @@ MCPMonitoringState >> initialize [ ] { #category : 'testing' } -MCPMonitoringState >> isNoop [ +MCPLocalObservabilityBackend >> isNoop [ ^ false ] { #category : 'private - tracing' } -MCPMonitoringState >> isSensitiveTraceKey: aKey [ +MCPLocalObservabilityBackend >> isSensitiveTraceKey: aKey [ | lowerKey | lowerKey := aKey asString asLowercase. @@ -270,32 +270,32 @@ MCPMonitoringState >> isSensitiveTraceKey: aKey [ ] { #category : 'private - tracing' } -MCPMonitoringState >> isVerboseTraceKey: aKey [ +MCPLocalObservabilityBackend >> isVerboseTraceKey: aKey [ ^ #( 'stackTrace' 'signalerContext' ) includes: aKey asString ] { #category : 'private - logging' } -MCPMonitoringState >> logEntries [ +MCPLocalObservabilityBackend >> logEntries [ ^ logEntries ifNil: [ logEntries := OrderedCollection new ] ] { #category : 'private - logging' } -MCPMonitoringState >> logEntry: aString [ +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 @@ -317,7 +317,7 @@ MCPMonitoringState >> logMessageForRecord: aRecord [ ] { #category : 'private - exporting' } -MCPMonitoringState >> logRecordDictionaryFor: aString timestamp: timestamp [ +MCPLocalObservabilityBackend >> logRecordDictionaryFor: aString timestamp: timestamp [ ^ { (#schemaVersion -> 1). @@ -327,13 +327,13 @@ 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' ]. @@ -341,67 +341,67 @@ MCPMonitoringState >> logStatusLabelForStatus: statusString [ ] { #category : 'logging' } -MCPMonitoringState >> logs [ +MCPLocalObservabilityBackend >> logs [ ^ self logEntries asArray ] { #category : 'accessing' } -MCPMonitoringState >> maxLogEntries [ +MCPLocalObservabilityBackend >> maxLogEntries [ ^ maxLogEntries ifNil: [ 200 ] ] { #category : 'accessing' } -MCPMonitoringState >> maxLogEntries: anInteger [ +MCPLocalObservabilityBackend >> maxLogEntries: anInteger [ 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 >> 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). @@ -413,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). @@ -435,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: [ ^ '' ]. @@ -461,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. @@ -499,7 +499,7 @@ MCPMonitoringState >> outputBudgetDataForResult: aToolResult error: anError [ ] { #category : 'private - budgets' } -MCPMonitoringState >> outputBudgetExplanationForByteCount: byteCount [ +MCPLocalObservabilityBackend >> outputBudgetExplanationForByteCount: byteCount [ byteCount <= self outputBudgetBytes ifTrue: [ ^ '' ]. ^ String streamContents: [ :stream | @@ -512,7 +512,7 @@ MCPMonitoringState >> outputBudgetExplanationForByteCount: byteCount [ ] { #category : 'displaying' } -MCPMonitoringState >> outputBudgetGuidance [ +MCPLocalObservabilityBackend >> outputBudgetGuidance [ | attentionStats displayedCount | attentionStats := self statsNeedingOutputBudgetAttention. @@ -528,19 +528,19 @@ 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 ]. @@ -550,13 +550,13 @@ MCPMonitoringState >> recordLog: aString [ ] { #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 >> recordSessionEndFor: anMCP [ +MCPLocalObservabilityBackend >> recordSessionEndFor: anMCP [ self resourceMetadata at: #sessionEndedAt put: DateAndTime now asString. self exportEnabled ifTrue: [ self writeResourceMetadata ]. @@ -564,7 +564,7 @@ MCPMonitoringState >> recordSessionEndFor: anMCP [ ] { #category : 'recording' } -MCPMonitoringState >> recordSessionStartFor: anMCP [ +MCPLocalObservabilityBackend >> recordSessionStartFor: anMCP [ self resourceMetadata at: #sessionStartedAt put: DateAndTime now asString. self resourceMetadata removeKey: #sessionEndedAt ifAbsent: [ nil ]. @@ -572,7 +572,7 @@ MCPMonitoringState >> recordSessionStartFor: anMCP [ ] { #category : 'recording' } -MCPMonitoringState >> recordToolCallFinish: aRecord result: aToolResult error: anError [ +MCPLocalObservabilityBackend >> recordToolCallFinish: aRecord result: aToolResult error: anError [ | explanation finishTimestamp stats statusString duration | aRecord ifNil: [ ^ self ]. @@ -599,7 +599,7 @@ MCPMonitoringState >> recordToolCallFinish: aRecord result: aToolResult error: a ] { #category : 'recording' } -MCPMonitoringState >> recordToolCallStart: aToolName input: inputObject dispatchPath: dispatchPath context: contextDictionary [ +MCPLocalObservabilityBackend >> recordToolCallStart: aToolName input: inputObject dispatchPath: dispatchPath context: contextDictionary [ | record startTimestamp | self enabled ifFalse: [ ^ nil ]. @@ -615,19 +615,19 @@ MCPMonitoringState >> recordToolCallStart: aToolName input: inputObject dispatch ] { #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. @@ -637,7 +637,7 @@ MCPMonitoringState >> resourceMetadata: aDictionary [ ] { #category : 'private - tracing' } -MCPMonitoringState >> shouldIncludeTraceAssociation: anAssociation [ +MCPLocalObservabilityBackend >> shouldIncludeTraceAssociation: anAssociation [ | value | (self isVerboseTraceKey: anAssociation key) ifTrue: [ ^ false ]. @@ -649,25 +649,25 @@ MCPMonitoringState >> shouldIncludeTraceAssociation: anAssociation [ ] { #category : 'actions' } -MCPMonitoringState >> shutdown [ +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 @@ -676,7 +676,7 @@ MCPMonitoringState >> statsNeedingOutputBudgetAttention [ ] { #category : 'private' } -MCPMonitoringState >> statusForResult: aToolResult error: anError [ +MCPLocalObservabilityBackend >> statusForResult: aToolResult error: anError [ anError ifNotNil: [ ^ 'error' ]. aToolResult ifNil: [ ^ 'error' ]. @@ -686,7 +686,7 @@ MCPMonitoringState >> statusForResult: aToolResult error: anError [ ] { #category : 'displaying' } -MCPMonitoringState >> statusText [ +MCPLocalObservabilityBackend >> statusText [ | guidance | self enabled ifFalse: [ ^ 'Observability disabled' ]. @@ -706,7 +706,7 @@ MCPMonitoringState >> statusText [ ] { #category : 'private' } -MCPMonitoringState >> summaryForResult: aToolResult [ +MCPLocalObservabilityBackend >> summaryForResult: aToolResult [ | structuredContent | aToolResult ifNil: [ ^ '' ]. @@ -716,25 +716,25 @@ 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 status = 'error' ifTrue: [ ^ 'mcp.tool_call.error' ]. aRecord outputBudgetExceeded ifTrue: [ ^ 'mcp.tool_call.output_budget_exceeded' ]. @@ -743,13 +743,13 @@ MCPMonitoringState >> traceEventNameForRecord: aRecord [ ] { #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. @@ -777,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 ]. @@ -809,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. @@ -828,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. @@ -843,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 ]. @@ -853,7 +853,7 @@ MCPMonitoringState >> traceSnapshotForResult: aToolResult error: anError [ ] { #category : 'private - tracing' } -MCPMonitoringState >> traceStringSnapshotFor: aString [ +MCPLocalObservabilityBackend >> traceStringSnapshotFor: aString [ | text | text := aString asString. @@ -862,7 +862,7 @@ MCPMonitoringState >> traceStringSnapshotFor: aString [ ] { #category : 'private - exporting' } -MCPMonitoringState >> writeJSON: aDictionary to: aFileReference [ +MCPLocalObservabilityBackend >> writeJSON: aDictionary to: aFileReference [ | temporaryFile | aFileReference parent ensureCreateDirectory. @@ -876,7 +876,7 @@ MCPMonitoringState >> writeJSON: aDictionary to: aFileReference [ ] { #category : 'private - exporting' } -MCPMonitoringState >> writeJSONLine: aString to: aFileReference [ +MCPLocalObservabilityBackend >> writeJSONLine: aString to: aFileReference [ aFileReference parent ensureCreateDirectory. aFileReference writeStreamDo: [ :stream | @@ -887,13 +887,13 @@ MCPMonitoringState >> writeJSONLine: aString to: aFileReference [ ] { #category : 'private - exporting' } -MCPMonitoringState >> writeMetricsSnapshot [ +MCPLocalObservabilityBackend >> writeMetricsSnapshot [ self writeJSON: self metricsSnapshotDictionary to: self metricsFileReference ] { #category : 'private - exporting' } -MCPMonitoringState >> writeResourceMetadata [ +MCPLocalObservabilityBackend >> writeResourceMetadata [ self writeJSON: self resourceMetadata to: self resourceFileReference ] diff --git a/src/MCP/MCPNoopObservabilityBackend.class.st b/src/MCP/MCPNoopObservabilityBackend.class.st index 86f5405..15287b9 100644 --- a/src/MCP/MCPNoopObservabilityBackend.class.st +++ b/src/MCP/MCPNoopObservabilityBackend.class.st @@ -6,9 +6,9 @@ This is the default backend for MCP core. It deliberately has no external depend Class { #name : 'MCPNoopObservabilityBackend', #superclass : 'MCPObservabilityBackend', - #category : 'MCP-Monitoring', + #category : 'MCP-Observability', #package : 'MCP', - #tag : 'Monitoring' + #tag : 'Observability' } { #category : 'clearing' } diff --git a/src/MCP/MCPObservabilityBackend.class.st b/src/MCP/MCPObservabilityBackend.class.st index 3d61335..70bb55f 100644 --- a/src/MCP/MCPObservabilityBackend.class.st +++ b/src/MCP/MCPObservabilityBackend.class.st @@ -6,9 +6,9 @@ Core MCP code talks to this protocol so normal tool execution can report observa Class { #name : 'MCPObservabilityBackend', #superclass : 'Object', - #category : 'MCP-Monitoring', + #category : 'MCP-Observability', #package : 'MCP', - #tag : 'Monitoring' + #tag : 'Observability' } { #category : 'testing' } diff --git a/src/MCP/MCPToolCallRecord.class.st b/src/MCP/MCPToolCallRecord.class.st index 43b60a7..ad9bbe6 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', @@ -26,9 +26,9 @@ Class { 'inputByteCount', 'eventKinds' ], - #category : 'MCP-Monitoring', + #category : 'MCP-Observability', #package : 'MCP', - #tag : 'Monitoring' + #tag : 'Observability' } { #category : 'instance creation' } diff --git a/src/MCP/MCPToolCallStats.class.st b/src/MCP/MCPToolCallStats.class.st index 7f077e8..b52e4af 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', @@ -24,9 +24,9 @@ Class { 'outputBudgetExceededCount', 'errorCountsByClass' ], - #category : 'MCP-Monitoring', + #category : 'MCP-Observability', #package : 'MCP', - #tag : 'Monitoring' + #tag : 'Observability' } { #category : 'instance creation' } From 15c68111f6f348ae94791caf236ec9934e3382c7 Mon Sep 17 00:00:00 2001 From: Gabriel Darbord <78592838+Gabriel-Darbord@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:11:42 +0200 Subject: [PATCH 7/8] Avoid OpenTelemetry baseline group clash --- src/BaselineOfMCP/BaselineOfMCP.class.st | 4 +-- .../MCPToolRemoveMethodsTestSender.class.st | 4 +-- .../MCPToolRemoveMethodsTestTarget.class.st | 20 ++++---------- src/MCP/MCP.class.st | 26 +++++++++---------- 4 files changed, 21 insertions(+), 33 deletions(-) diff --git a/src/BaselineOfMCP/BaselineOfMCP.class.st b/src/BaselineOfMCP/BaselineOfMCP.class.st index 938606a..22ce764 100644 --- a/src/BaselineOfMCP/BaselineOfMCP.class.st +++ b/src/BaselineOfMCP/BaselineOfMCP.class.st @@ -30,7 +30,7 @@ BaselineOfMCP >> defineDependencies: spec [ spec repository: 'github://Gabriel-Darbord/pharo-pcre2:v1.0.0/src'; loads: #( 'Core' ) ]; - baseline: 'OpenTelemetry' with: [ + baseline: 'OpenTelemetryPharo' with: [ spec repository: 'github://Gabriel-Darbord/opentelemetry-pharo:main/src'; loads: #( 'traces' 'metrics' 'logs' ) ] @@ -54,7 +54,7 @@ BaselineOfMCP >> definePackages: spec [ 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' 'OpenTelemetry' ) ]. + 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-Tests-Resources/MCPToolRemoveMethodsTestSender.class.st b/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestSender.class.st index e5bf6bc..607f6bf 100644 --- a/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestSender.class.st +++ b/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestSender.class.st @@ -9,7 +9,5 @@ Class { } { #category : 'tests' } -MCPToolRemoveMethodsTestSender >> sendProtectedRemovalTarget [ - - ^ MCPToolRemoveMethodsTestTarget new externallyReferencedBatchRemoval +MCPToolRemoveMethodsTestSender >> sendProtectedRemovalTarget [ ^ MCPToolRemoveMethodsTestTarget new externallyReferencedBatchRemoval ] diff --git a/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestTarget.class.st b/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestTarget.class.st index 7e9da93..8398ece 100644 --- a/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestTarget.class.st +++ b/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestTarget.class.st @@ -9,31 +9,21 @@ Class { } { #category : 'tests' } -MCPToolRemoveMethodsTestTarget class >> firstClassBatchRemoval [ - - ^ self secondClassBatchRemoval + 1 +MCPToolRemoveMethodsTestTarget class >> firstClassBatchRemoval [ ^ self secondClassBatchRemoval + 1 ] { #category : 'tests' } -MCPToolRemoveMethodsTestTarget class >> secondClassBatchRemoval [ - - ^ 7 +MCPToolRemoveMethodsTestTarget class >> secondClassBatchRemoval [ ^ 7 ] { #category : 'tests' } -MCPToolRemoveMethodsTestTarget >> externallyReferencedBatchRemoval [ - - ^ 5 +MCPToolRemoveMethodsTestTarget >> externallyReferencedBatchRemoval [ ^ 5 ] { #category : 'tests' } -MCPToolRemoveMethodsTestTarget >> firstBatchRemoval [ - - ^ self secondBatchRemoval + 1 +MCPToolRemoveMethodsTestTarget >> firstBatchRemoval [ ^ self secondBatchRemoval + 1 ] { #category : 'tests' } -MCPToolRemoveMethodsTestTarget >> secondBatchRemoval [ - - ^ 2 +MCPToolRemoveMethodsTestTarget >> secondBatchRemoval [ ^ 2 ] diff --git a/src/MCP/MCP.class.st b/src/MCP/MCP.class.st index cfc3f7f..9060350 100644 --- a/src/MCP/MCP.class.st +++ b/src/MCP/MCP.class.st @@ -148,10 +148,10 @@ MCP >> executeTool: tool named: toolName withParams: arguments dispatchPath: dis | observabilityRecord request result toolResult | observabilityRecord := self observability - recordToolCallStart: toolName - input: arguments - dispatchPath: dispatchPath - context: Dictionary new. + recordToolCallStart: toolName + input: arguments + dispatchPath: dispatchPath + context: Dictionary new. ^ [ request := tool requestFromToolCallArguments: arguments. result := tool executeWithRequest: request. @@ -222,6 +222,15 @@ MCP >> localUrlString [ ^ self server localUrl asString ] +{ #category : 'observability' } +MCP >> newLocalObservabilityBackend [ + + ^ MCPLocalObservabilityBackend new + resourceMetadata: self observabilityResourceMetadata; + exportDirectory: MCPLocalObservabilityBackend defaultExportDirectory; + yourself +] + { #category : 'accessing' } MCP >> observability [ @@ -309,15 +318,6 @@ MCP >> observabilityTraces [ ^ self observability traceRecords ] -{ #category : 'observability' } -MCP >> newLocalObservabilityBackend [ - - ^ MCPLocalObservabilityBackend new - resourceMetadata: self observabilityResourceMetadata; - exportDirectory: MCPLocalObservabilityBackend defaultExportDirectory; - yourself -] - { #category : 'start' } MCP >> onInitialize: protocolVersion capabilities: capabilities clientInfo: clientInfo websiteUrl: websiteUrl [ From 4c6f3cb127dacd8c91423c6d0836cbafdf052451 Mon Sep 17 00:00:00 2001 From: Gabriel Darbord <78592838+Gabriel-Darbord@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:21:00 +0200 Subject: [PATCH 8/8] Keep ensured test methods formatted --- .../MCPToolRemoveMethodsTestSender.class.st | 4 +++- .../MCPToolRemoveMethodsTestTarget.class.st | 20 ++++++++++++++----- src/MCP-Tests/MCPTestCase.class.st | 5 ++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestSender.class.st b/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestSender.class.st index 607f6bf..e5bf6bc 100644 --- a/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestSender.class.st +++ b/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestSender.class.st @@ -9,5 +9,7 @@ Class { } { #category : 'tests' } -MCPToolRemoveMethodsTestSender >> sendProtectedRemovalTarget [ ^ MCPToolRemoveMethodsTestTarget new externallyReferencedBatchRemoval +MCPToolRemoveMethodsTestSender >> sendProtectedRemovalTarget [ + + ^ MCPToolRemoveMethodsTestTarget new externallyReferencedBatchRemoval ] diff --git a/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestTarget.class.st b/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestTarget.class.st index 8398ece..7e9da93 100644 --- a/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestTarget.class.st +++ b/src/MCP-Tests-Resources/MCPToolRemoveMethodsTestTarget.class.st @@ -9,21 +9,31 @@ Class { } { #category : 'tests' } -MCPToolRemoveMethodsTestTarget class >> firstClassBatchRemoval [ ^ self secondClassBatchRemoval + 1 +MCPToolRemoveMethodsTestTarget class >> firstClassBatchRemoval [ + + ^ self secondClassBatchRemoval + 1 ] { #category : 'tests' } -MCPToolRemoveMethodsTestTarget class >> secondClassBatchRemoval [ ^ 7 +MCPToolRemoveMethodsTestTarget class >> secondClassBatchRemoval [ + + ^ 7 ] { #category : 'tests' } -MCPToolRemoveMethodsTestTarget >> externallyReferencedBatchRemoval [ ^ 5 +MCPToolRemoveMethodsTestTarget >> externallyReferencedBatchRemoval [ + + ^ 5 ] { #category : 'tests' } -MCPToolRemoveMethodsTestTarget >> firstBatchRemoval [ ^ self secondBatchRemoval + 1 +MCPToolRemoveMethodsTestTarget >> firstBatchRemoval [ + + ^ self secondBatchRemoval + 1 ] { #category : 'tests' } -MCPToolRemoveMethodsTestTarget >> secondBatchRemoval [ ^ 2 +MCPToolRemoveMethodsTestTarget >> secondBatchRemoval [ + + ^ 2 ] diff --git a/src/MCP-Tests/MCPTestCase.class.st b/src/MCP-Tests/MCPTestCase.class.st index 0d84063..c995d56 100644 --- a/src/MCP-Tests/MCPTestCase.class.st +++ b/src/MCP-Tests/MCPTestCase.class.st @@ -44,7 +44,10 @@ 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 - observability' }