@@ -24,6 +24,7 @@ import * as Layer from "effect/Layer";
2424import * as Logger from "effect/Logger" ;
2525import * as Schema from "effect/Schema" ;
2626import * as Stream from "effect/Stream" ;
27+ import * as Tracer from "effect/Tracer" ;
2728import { McpSchema , McpServer , Tool , Toolkit } from "effect/ai" ;
2829import { HttpBody , HttpClient , HttpClientRequest , HttpRouter } from "effect/http" ;
2930
@@ -140,6 +141,26 @@ const serveSnapshots = (clientId: string, result: unknown) =>
140141 return inputs ;
141142 } ) ;
142143
144+ /** Records every span and the order spans start and end in, by span ID. */
145+ const recordSpans = ( ) => {
146+ const spans : Array < Tracer . NativeSpan > = [ ] ;
147+ const order : Array < string > = [ ] ;
148+ const tracer = Tracer . make ( {
149+ span : ( options ) => {
150+ const span = new Tracer . NativeSpan ( options ) ;
151+ spans . push ( span ) ;
152+ order . push ( `start ${ span . spanId } ` ) ;
153+ const end = span . end . bind ( span ) ;
154+ span . end = ( endTime , exit ) => {
155+ end ( endTime , exit ) ;
156+ order . push ( `end ${ span . spanId } ` ) ;
157+ } ;
158+ return span ;
159+ } ,
160+ } ) ;
161+ return { tracer, spans, order } ;
162+ } ;
163+
143164const callTool = ( name : string , args : Record < string , unknown > ) =>
144165 Effect . gen ( function * ( ) {
145166 const server = yield * McpServer . McpServer ;
@@ -178,19 +199,29 @@ it.effect.each([
178199 ( { input, open, message, ...options } ) =>
179200 Effect . gen ( function * ( ) {
180201 const server = yield * McpServer . McpServer ;
202+ const traced = recordSpans ( ) ;
181203 const result = yield * server . callTool ( { name : "device_screenshot" , arguments : input } ) . pipe (
182204 Effect . provideService ( McpInvocationContext . McpInvocationContext , {
183205 ...invocation ,
184206 capabilities : new Set ( [ "device" ] as const ) ,
185207 } ) ,
186208 Effect . provideService ( McpSchema . McpServerClient , client ) ,
209+ Effect . withTracer ( traced . tracer ) ,
187210 ) ;
188211 expect ( result . isError ) . toBe ( true ) ;
189212 expect ( result . content ) . toEqual ( [ { type : "text" , text : message } ] ) ;
190213 expect ( result . structuredContent ) . toMatchObject ( {
191214 error : { message, operation : "screenshot" } ,
192215 } ) ;
193216 expect ( JSON . stringify ( result ) ) . not . toContain ( "private hub output" ) ;
217+ // The tool's span names it, never its arguments.
218+ expect (
219+ traced . spans
220+ . filter ( ( span ) => span . name . startsWith ( "mcp.tool." ) )
221+ . map ( ( span ) => Object . fromEntries ( span . attributes ) ) ,
222+ ) . toEqual ( [
223+ { "rpc.system" : "mcp" , "rpc.method" : "tools/call" , "mcp.tool.name" : "device_screenshot" } ,
224+ ] ) ;
194225 } ) . pipe (
195226 Effect . provide (
196227 McpHttpServer . layerDeviceToolkit . pipe (
@@ -904,8 +935,10 @@ it.effect("admits provider and OAuth client credentials and points only clients
904935 } ) ,
905936 ) ,
906937 ) ;
938+ const traced = recordSpans ( ) ;
907939 yield * HttpRouter . serve ( serverLayer , { disableListenLog : true , disableLogger : true } ) . pipe (
908940 Layer . build ,
941+ Effect . withTracer ( traced . tracer ) ,
909942 ) ;
910943 const httpClient = yield * HttpClient . HttpClient ;
911944 const call = ( token : string | undefined ) =>
@@ -936,6 +969,21 @@ it.effect("admits provider and OAuth client credentials and points only clients
936969 } ) ;
937970
938971 expect ( ( yield * call ( providerToken ) ) . status ) . toBe ( 200 ) ;
972+ // The tool gets its own span, without its arguments, which starts once authentication has ended.
973+ const toolSpan = traced . spans . find ( ( span ) => span . name === "mcp.tool.probe" ) ;
974+ expect ( Object . fromEntries ( toolSpan ?. attributes ?? [ ] ) ) . toEqual ( {
975+ "rpc.system" : "mcp" ,
976+ "rpc.method" : "tools/call" ,
977+ "mcp.tool.name" : "probe" ,
978+ } ) ;
979+ const authSpan = traced . spans . find (
980+ ( span ) =>
981+ span . name === "McpHttpServer.authenticateRequest" && span . traceId === toolSpan ?. traceId ,
982+ ) ;
983+ expect ( authSpan ) . toBeDefined ( ) ;
984+ expect ( traced . order . slice ( 0 , traced . order . indexOf ( `start ${ toolSpan ?. spanId } ` ) ) ) . toContain (
985+ `end ${ authSpan ?. spanId } ` ,
986+ ) ;
939987 expect ( ( yield * call ( clientToken ) ) . status ) . toBe ( 200 ) ;
940988 expect ( seen . map ( ( scope ) => scope . requestNamespace ) ) . toEqual ( [
941989 "provider-session" ,
@@ -952,9 +1000,22 @@ it.effect("admits provider and OAuth client credentials and points only clients
9521000 expect ( expiredClient . headers [ "www-authenticate" ] ) . toContain ( "resource_metadata=" ) ;
9531001 expect ( expiredClient . headers [ "www-authenticate" ] ) . toContain ( 'error="invalid_token"' ) ;
9541002
1003+ const spansBeforeRejection = traced . spans . length ;
9551004 const deadProvider = yield * call ( "deadProviderToken" ) ;
9561005 expect ( deadProvider . status ) . toBe ( 401 ) ;
9571006 expect ( deadProvider . headers [ "www-authenticate" ] ) . toBe ( 'Bearer error="invalid_token"' ) ;
1007+ // A rejected credential is recorded on the auth span, and nothing is handled.
1008+ const rejectedSpans = traced . spans . slice ( spansBeforeRejection ) ;
1009+ expect (
1010+ rejectedSpans
1011+ . filter ( ( span ) => span . name === "McpHttpServer.authenticateRequest" )
1012+ . map ( ( span ) => span . events . map ( ( [ name ] ) => name ) ) ,
1013+ ) . toEqual ( [ [ expect . stringContaining ( "unknown_or_expired_token" ) ] ] ) ;
1014+ expect (
1015+ rejectedSpans . filter (
1016+ ( span ) => span . name . startsWith ( "mcp.tool." ) || span . name . startsWith ( "McpServer." ) ,
1017+ ) ,
1018+ ) . toEqual ( [ ] ) ;
9581019 } ) ,
9591020 ) . pipe ( Effect . provide ( NodeHttpServer . layerTest ) ) ,
9601021) ;
0 commit comments