[mcp] report transport type and egress auth type to prehog (#61397)

* [mcp] report transport type and egress auth type to prehog

* report unknown egress type and add more comments

* make grpc

* fix tests ( ̄▽ ̄;)
This commit is contained in:
STeve (Xin) Huang
2025-11-17 18:54:05 +00:00
committed by GitHub
parent 3241f4c260
commit 619768968f
11 changed files with 2516 additions and 1918 deletions
@@ -8687,6 +8687,14 @@ message MCPSessionStart {
string mcp_session_id = 7;
// ClientInfo stores reported client agent information, e.g. "claude-ai/0.1.0".
string client_info = 8;
// ServerInfo stores reported MCP server information, e.g. "teleport-mcp-demo/18.3.0".
string server_info = 9;
// ProtocolVersion is the MCP protocol version like "2025-06-18".
string protocol_version = 10;
// IngressAuthType indicates how the MCP session is authorized between MCP client and Teleport.
string ingress_auth_type = 11;
// EgressAuthType indicates how the MCP session is authorized between Teleport and remote MCP server.
string egress_auth_type = 12;
}
// MCPSessionEnd is emitted when an MCP session ends.
+1485 -1301
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+145 -1
View File
@@ -293,6 +293,13 @@ export interface SessionStartEvent {
* @generated from protobuf field: prehog.v1alpha.SessionStartGitMetadata git = 7;
*/
git?: SessionStartGitMetadata;
/**
* if session_type == "app_mcp" the mcp struct contains additional information
* about the MCP session.
*
* @generated from protobuf field: prehog.v1alpha.SessionStartMCPMetadata mcp = 8;
*/
mcp?: SessionStartMCPMetadata;
}
/**
* SessionStartDatabaseMetadata contains additional information about database session.
@@ -402,6 +409,49 @@ export interface SessionStartGitMetadata {
*/
gitService: string;
}
/**
* SessionStartMCPMetadata contains additional information about an MCP session.
*
* @generated from protobuf message prehog.v1alpha.SessionStartMCPMetadata
*/
export interface SessionStartMCPMetadata {
/**
* Transport type of the MCP server. Value from types.GetMCPServerTransportType.
*
* @generated from protobuf field: string transport = 1;
*/
transport: string;
/**
* MCP protocol version like "2025-06-18".
*
* @generated from protobuf field: string protocol_version = 2;
*/
protocolVersion: string;
/**
* Indicates the client used on the session, e.g "claude-ai".
*
* @generated from protobuf field: string client_name = 3;
*/
clientName: string;
/**
* Indicates the server used on the session, e.g "example-servers/everything".
*
* @generated from protobuf field: string server_name = 4;
*/
serverName: string;
/**
* Indicates how the MCP session is authorized between MCP client and Teleport.
*
* @generated from protobuf field: string ingress_auth_type = 5;
*/
ingressAuthType: string;
/**
* Indicates how the MCP session is authorized between Teleport and remote MCP server.
*
* @generated from protobuf field: string egress_auth_type = 6;
*/
egressAuthType: string;
}
/**
* the issuance of a user certificate from the user CA
*
@@ -4669,7 +4719,8 @@ class SessionStartEvent$Type extends MessageType<SessionStartEvent> {
{ no: 4, name: "desktop", kind: "message", T: () => SessionStartDesktopMetadata },
{ no: 5, name: "user_kind", kind: "enum", T: () => ["prehog.v1alpha.UserKind", UserKind, "USER_KIND_"] },
{ no: 6, name: "app", kind: "message", T: () => SessionStartAppMetadata },
{ no: 7, name: "git", kind: "message", T: () => SessionStartGitMetadata }
{ no: 7, name: "git", kind: "message", T: () => SessionStartGitMetadata },
{ no: 8, name: "mcp", kind: "message", T: () => SessionStartMCPMetadata }
]);
}
create(value?: PartialMessage<SessionStartEvent>): SessionStartEvent {
@@ -4707,6 +4758,9 @@ class SessionStartEvent$Type extends MessageType<SessionStartEvent> {
case /* prehog.v1alpha.SessionStartGitMetadata git */ 7:
message.git = SessionStartGitMetadata.internalBinaryRead(reader, reader.uint32(), options, message.git);
break;
case /* prehog.v1alpha.SessionStartMCPMetadata mcp */ 8:
message.mcp = SessionStartMCPMetadata.internalBinaryRead(reader, reader.uint32(), options, message.mcp);
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@@ -4740,6 +4794,9 @@ class SessionStartEvent$Type extends MessageType<SessionStartEvent> {
/* prehog.v1alpha.SessionStartGitMetadata git = 7; */
if (message.git)
SessionStartGitMetadata.internalBinaryWrite(message.git, writer.tag(7, WireType.LengthDelimited).fork(), options).join();
/* prehog.v1alpha.SessionStartMCPMetadata mcp = 8; */
if (message.mcp)
SessionStartMCPMetadata.internalBinaryWrite(message.mcp, writer.tag(8, WireType.LengthDelimited).fork(), options).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
@@ -5003,6 +5060,93 @@ class SessionStartGitMetadata$Type extends MessageType<SessionStartGitMetadata>
*/
export const SessionStartGitMetadata = new SessionStartGitMetadata$Type();
// @generated message type with reflection information, may provide speed optimized methods
class SessionStartMCPMetadata$Type extends MessageType<SessionStartMCPMetadata> {
constructor() {
super("prehog.v1alpha.SessionStartMCPMetadata", [
{ no: 1, name: "transport", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 2, name: "protocol_version", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 3, name: "client_name", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 4, name: "server_name", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 5, name: "ingress_auth_type", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 6, name: "egress_auth_type", kind: "scalar", T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<SessionStartMCPMetadata>): SessionStartMCPMetadata {
const message = globalThis.Object.create((this.messagePrototype!));
message.transport = "";
message.protocolVersion = "";
message.clientName = "";
message.serverName = "";
message.ingressAuthType = "";
message.egressAuthType = "";
if (value !== undefined)
reflectionMergePartial<SessionStartMCPMetadata>(this, message, value);
return message;
}
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: SessionStartMCPMetadata): SessionStartMCPMetadata {
let message = target ?? this.create(), end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string transport */ 1:
message.transport = reader.string();
break;
case /* string protocol_version */ 2:
message.protocolVersion = reader.string();
break;
case /* string client_name */ 3:
message.clientName = reader.string();
break;
case /* string server_name */ 4:
message.serverName = reader.string();
break;
case /* string ingress_auth_type */ 5:
message.ingressAuthType = reader.string();
break;
case /* string egress_auth_type */ 6:
message.egressAuthType = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
}
}
return message;
}
internalBinaryWrite(message: SessionStartMCPMetadata, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
/* string transport = 1; */
if (message.transport !== "")
writer.tag(1, WireType.LengthDelimited).string(message.transport);
/* string protocol_version = 2; */
if (message.protocolVersion !== "")
writer.tag(2, WireType.LengthDelimited).string(message.protocolVersion);
/* string client_name = 3; */
if (message.clientName !== "")
writer.tag(3, WireType.LengthDelimited).string(message.clientName);
/* string server_name = 4; */
if (message.serverName !== "")
writer.tag(4, WireType.LengthDelimited).string(message.serverName);
/* string ingress_auth_type = 5; */
if (message.ingressAuthType !== "")
writer.tag(5, WireType.LengthDelimited).string(message.ingressAuthType);
/* string egress_auth_type = 6; */
if (message.egressAuthType !== "")
writer.tag(6, WireType.LengthDelimited).string(message.egressAuthType);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message prehog.v1alpha.SessionStartMCPMetadata
*/
export const SessionStartMCPMetadata = new SessionStartMCPMetadata$Type();
// @generated message type with reflection information, may provide speed optimized methods
class UserCertificateIssuedEvent$Type extends MessageType<UserCertificateIssuedEvent> {
constructor() {
super("prehog.v1alpha.UserCertificateIssuedEvent", [
+51 -3
View File
@@ -22,15 +22,18 @@ import (
"context"
"log/slog"
"net/http"
"strings"
"github.com/gravitational/trace"
"github.com/mark3labs/mcp-go/mcp"
"github.com/gravitational/teleport"
apidefaults "github.com/gravitational/teleport/api/defaults"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/api/types/wrappers"
"github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/services"
appcommon "github.com/gravitational/teleport/lib/srv/app/common"
"github.com/gravitational/teleport/lib/utils"
"github.com/gravitational/teleport/lib/utils/mcputils"
@@ -104,9 +107,9 @@ func eventWithHTTPResponseError(resp *http.Response, err error) eventOptionFunc
return eventWithError(convertHTTPResponseErrorForAudit(resp, err))
}
func eventWithHeader(r *http.Request) eventOptionFunc {
func eventWithHeader(header http.Header) eventOptionFunc {
return func(o *eventOptions) {
o.header = headersForAudit(r.Header)
o.header = headersForAudit(header)
}
}
@@ -124,7 +127,9 @@ func (a *sessionAuditor) shouldEmitEvent(method mcp.MCPMethod) bool {
}
}
func (a *sessionAuditor) emitStartEvent(ctx context.Context) {
func (a *sessionAuditor) emitStartEvent(ctx context.Context, options ...eventOptionFunc) {
opts := newEventOptions(options...)
a.emitEvent(ctx, &apievents.MCPSessionStart{
Metadata: a.makeEventMetadata(
events.MCPSessionStartEvent,
@@ -136,6 +141,7 @@ func (a *sessionAuditor) emitStartEvent(ctx context.Context) {
ConnectionMetadata: a.makeConnectionMetadata(),
AppMetadata: a.makeAppMetadata(),
McpSessionId: a.sessionCtx.mcpSessionID.String(),
EgressAuthType: guessEgressAuthType(opts.header, a.sessionCtx.App.GetRewrite()),
})
}
@@ -352,3 +358,45 @@ func headersForAudit(h http.Header) http.Header {
}
return ret
}
// guessEgressAuthType makes an educated guess on what kind of auth is used to
// for the remote MCP server.
func guessEgressAuthType(headerWithoutRewrite http.Header, rewrite *types.Rewrite) string {
if rewrite != nil {
testJWTTraits := map[string][]string{
"jwt": {"test", "jwt"},
}
var rewriteAuth bool
for _, rewrite := range rewrite.Headers {
if strings.EqualFold(rewrite.Name, "Authorization") {
rewriteAuth = true
}
// Check if any header value includes "{{internal.jwt}}".
if strings.Contains(rewrite.Value, "internal.jwt") {
// Apply fake traits just to be sure. The fake traits will
// result two values if applied successfully.
if interpolated, _ := services.ApplyValueTraits(rewrite.Value, testJWTTraits); len(interpolated) > 1 {
return "app-jwt"
}
}
}
// Auth header has be defined in the app definition but not using
// "{{internal.jwt}}".
if rewriteAuth {
return "app-defined"
}
}
// Reach here when app.Rewrite not overwriting auth. Check if Auth header is
// defined by the user.
if headerWithoutRewrite.Get("Authorization") != "" {
return "user-defined"
}
// No auth required for the remote MCP server or something we don't
// understand yet.
return "unknown"
}
+54
View File
@@ -23,6 +23,8 @@ import (
"testing"
"github.com/stretchr/testify/require"
"github.com/gravitational/teleport/api/types"
)
func Test_headersForAudit(t *testing.T) {
@@ -68,3 +70,55 @@ func Test_headersForAudit(t *testing.T) {
})
}
}
func Test_guessEgressAuthType(t *testing.T) {
tests := []struct {
name string
inputHeader http.Header
inputRewrite *types.Rewrite
expect string
}{
{
name: "no header or rewrite",
expect: "unknown",
},
{
name: "user defined",
inputHeader: http.Header{
"Authorization": []string{"Bearer secret"},
},
inputRewrite: &types.Rewrite{
Headers: []*types.Header{{
Name: "Some-Other-Header",
Value: "some-other-value",
}},
},
expect: "user-defined",
},
{
name: "app defined",
inputRewrite: &types.Rewrite{
Headers: []*types.Header{{
Name: "Authorization",
Value: "Bearer abcdef",
}},
},
expect: "app-defined",
},
{
name: "app defined with Teleport JWT",
inputRewrite: &types.Rewrite{
Headers: []*types.Header{{
Name: "Authorization",
Value: "Bearer {{internal.jwt}}",
}},
},
expect: "app-jwt",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.expect, guessEgressAuthType(tt.inputHeader, tt.inputRewrite))
})
}
}
+6 -6
View File
@@ -193,13 +193,13 @@ func (t *streamableHTTPTransport) rewriteAndSendRequest(r *http.Request) (*http.
func (t *streamableHTTPTransport) handleSessionEndRequest(r *http.Request) (*http.Response, error) {
resp, err := t.rewriteAndSendRequest(r)
t.emitEndEvent(t.parentCtx, eventWithHTTPResponseError(resp, err), eventWithHeader(r))
t.emitEndEvent(t.parentCtx, eventWithHTTPResponseError(resp, err), eventWithHeader(r.Header))
return resp, trace.Wrap(err)
}
func (t *streamableHTTPTransport) handleListenSSEStreamRequest(r *http.Request) (*http.Response, error) {
resp, err := t.rewriteAndSendRequest(r)
t.emitListenSSEStreamEvent(t.parentCtx, eventWithHTTPResponseError(resp, err), eventWithHeader(r))
t.emitListenSSEStreamEvent(t.parentCtx, eventWithHTTPResponseError(resp, err), eventWithHeader(r.Header))
return resp, trace.Wrap(err)
}
@@ -242,17 +242,17 @@ func (t *streamableHTTPTransport) handleMCPMessage(r *http.Request) (*http.Respo
mcpRequest := baseMessage.MakeRequest()
// Only emit session start if "initialize" succeeded.
if mcpRequest.Method == mcp.MethodInitialize && respErrForAudit == nil {
t.emitStartEvent(t.parentCtx)
t.emitStartEvent(t.parentCtx, eventWithHeader(r.Header))
}
t.emitRequestEvent(t.parentCtx, mcpRequest, eventWithError(respErrForAudit), eventWithHeader(r))
t.emitRequestEvent(t.parentCtx, mcpRequest, eventWithError(respErrForAudit), eventWithHeader(r.Header))
case baseMessage.IsNotification():
t.emitNotificationEvent(t.parentCtx, baseMessage.MakeNotification(), eventWithError(respErrForAudit), eventWithHeader(r))
t.emitNotificationEvent(t.parentCtx, baseMessage.MakeNotification(), eventWithError(respErrForAudit), eventWithHeader(r.Header))
}
return resp, trace.Wrap(err)
}
func (t *streamableHTTPTransport) handleRequestAuthError(r *http.Request, mcpRequest *mcputils.JSONRPCRequest, errResp mcp.JSONRPCMessage, authErr error) (*http.Response, error) {
t.emitRequestEvent(t.parentCtx, mcpRequest, eventWithError(authErr), eventWithHeader(r))
t.emitRequestEvent(t.parentCtx, mcpRequest, eventWithError(authErr), eventWithHeader(r.Header))
errRespAsBody, err := json.Marshal(errResp)
if err != nil {
+5
View File
@@ -140,6 +140,11 @@ func Test_handleStreamableHTTP(t *testing.T) {
}, sliceutils.Map(emitter.Events(), getEventCode))
}, 2*time.Second, time.Millisecond*100, "waiting for events")
// First event must be the start event.
startEvent, ok := emitter.Events()[0].(*apievents.MCPSessionStart)
require.True(t, ok)
require.Equal(t, "app-jwt", startEvent.EgressAuthType)
// Close client and wait for end event.
require.NoError(t, client.Close())
require.EventuallyWithT(t, func(t *assert.CollectT) {
+8
View File
@@ -379,6 +379,14 @@ func ConvertAuditEvent(event apievents.AuditEvent) Anonymizable {
UserName: e.User,
SessionType: MCPAppSessionType,
UserKind: prehogUserKindFromEventKind(e.UserKind),
Mcp: &prehogv1a.SessionStartMCPMetadata{
Transport: types.GetMCPServerTransportType(e.AppURI),
ProtocolVersion: e.ProtocolVersion,
IngressAuthType: e.IngressAuthType,
EgressAuthType: e.EgressAuthType,
ClientName: e.ClientInfo,
ServerName: e.ServerInfo,
},
}
}
+14
View File
@@ -283,11 +283,25 @@ func TestConvertAuditEvent(t *testing.T) {
UserMetadata: apievents.UserMetadata{User: "alice"},
AppMetadata: apievents.AppMetadata{
AppName: "mcp-everything",
AppURI: "mcp+http://localhost:12345/mcp",
},
IngressAuthType: "ingress-auth",
EgressAuthType: "egress-auth",
ClientInfo: "client/1.0.0",
ServerInfo: "server/1.0.0",
ProtocolVersion: "protocol-version",
},
expected: &SessionStartEvent{
SessionType: MCPAppSessionType,
UserName: "alice",
Mcp: &prehogv1a.SessionStartMCPMetadata{
Transport: "Streamable HTTP",
ProtocolVersion: "protocol-version",
ClientName: "client/1.0.0",
ServerName: "server/1.0.0",
IngressAuthType: "ingress-auth",
EgressAuthType: "egress-auth",
},
},
expectedAnonymized: &prehogv1a.SubmitEventRequest{
Event: &prehogv1a.SubmitEventRequest_SessionStartV2{
+20
View File
@@ -254,6 +254,10 @@ message SessionStartEvent {
// if session_type == "git" the git struct contains additional information
// about git session.
SessionStartGitMetadata git = 7;
// if session_type == "app_mcp" the mcp struct contains additional information
// about the MCP session.
SessionStartMCPMetadata mcp = 8;
}
// SessionStartDatabaseMetadata contains additional information about database session.
@@ -308,6 +312,22 @@ message SessionStartGitMetadata {
string git_service = 2;
}
// SessionStartMCPMetadata contains additional information about an MCP session.
message SessionStartMCPMetadata {
// Transport type of the MCP server. Value from types.GetMCPServerTransportType.
string transport = 1;
// MCP protocol version like "2025-06-18".
string protocol_version = 2;
// Indicates the client used on the session, e.g "claude-ai".
string client_name = 3;
// Indicates the server used on the session, e.g "example-servers/everything".
string server_name = 4;
// Indicates how the MCP session is authorized between MCP client and Teleport.
string ingress_auth_type = 5;
// Indicates how the MCP session is authorized between Teleport and remote MCP server.
string egress_auth_type = 6;
}
// the issuance of a user certificate from the user CA
//
// PostHog event: tp.certificate.issued