Skip to content

feat: adds "thv inspector" to run an inspector for an MCP server #381

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ go.work

.roo/
^thv$

.claude/
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this to a separate PR.

kconfig.yaml

.DS_Store
1 change: 1 addition & 0 deletions cmd/thv/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func NewRootCmd() *cobra.Command {
rootCmd.AddCommand(newVersionCmd())
rootCmd.AddCommand(logsCommand())
rootCmd.AddCommand(newSecretCommand())
rootCmd.AddCommand(inspectorCommand())

// Skip update check for completion command
if !IsCompletionCommand(os.Args) {
Expand Down
203 changes: 203 additions & 0 deletions cmd/thv/app/inspector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package app

import (
"context"
"fmt"
"net/url"
"strings"

"github.com/spf13/cobra"

"github.com/stacklok/toolhive/pkg/client"
"github.com/stacklok/toolhive/pkg/container"
"github.com/stacklok/toolhive/pkg/container/runtime"
"github.com/stacklok/toolhive/pkg/labels"
"github.com/stacklok/toolhive/pkg/lifecycle"
"github.com/stacklok/toolhive/pkg/logger"
"github.com/stacklok/toolhive/pkg/permissions"
"github.com/stacklok/toolhive/pkg/runner"
"github.com/stacklok/toolhive/pkg/transport/types"
)

func inspectorCommand() *cobra.Command {
inspectorCommand := &cobra.Command{
Use: "inspector [container-name]",
Short: "Output the logs of an MCP server",
Long: `Output the logs of an MCP server managed by Vibe Tool.`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not accurate.

Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return inspectorCmdFunc(cmd, args)
},
}

return inspectorCommand
}

var (
// TODO: This could probably be a flag with a sensible default
// TODO: Additionally, when the inspector image has been published
// TODO: to docker.io, we can use that instead of npx
inspectorImage = "npx://@modelcontextprotocol/inspector@latest"
)

func inspectorCmdFunc(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

// Get server name from args
if len(args) == 0 || args[0] == "" {
return fmt.Errorf("server name is required as an argument")
}

serverName := args[0]

// Instantiate the container manager
manager, err := lifecycle.NewManager(ctx)
if err != nil {
return fmt.Errorf("failed to create container manager: %v", err)
}

// Find the server with the matching name
serverURL, err := getServerURL(ctx, manager, serverName)
if err != nil || serverURL == "" {
return fmt.Errorf("failed to get server URL: %v", err)
}

// Format the server URL for the inspector
// TODO: We don't do anything with this at the moment.
// TODO: When the inspector supports the search params we will use
// TODO: and give it to to the user
formattedURL, err := formatServerURL(serverURL)
if err != nil {
return fmt.Errorf("failed to format server URL: %v", err)
}

logger.Infof("Found MCP server: %s", serverName)
logger.Infof("Server URL: %s", formattedURL)

// Create container runtime
rt, err := container.NewFactory().Create(ctx)
if err != nil {
return fmt.Errorf("failed to create container runtime: %v", err)
}

processedImage, err := runner.HandleProtocolScheme(ctx, rt, inspectorImage, "")
if err != nil {
return fmt.Errorf("failed to handle protocol scheme: %v", err)
}

// Define fixed port configuration for the inspector container
clientUIPort := "6274"
mcpProxyPort := "6277"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make these configurable flags? These can be the defaults.


// Setup container options with the required port configuration
options := &runtime.CreateContainerOptions{
ExposedPorts: map[string]struct{}{
clientUIPort + "/tcp": {},
mcpProxyPort + "/tcp": {},
},
PortBindings: map[string][]runtime.PortBinding{
clientUIPort + "/tcp": {
{
HostIP: "0.0.0.0",
HostPort: clientUIPort,
},
},
mcpProxyPort + "/tcp": {
{
HostIP: "0.0.0.0",
HostPort: mcpProxyPort,
},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we default to ipv4 localhost?

},
},
AttachStdio: false,
}

// Create environment variables with the server information
envVars := map[string]string{
"MCP_SERVER_URL": formattedURL, // Pass the formatted server URL as an environment variable
"MCP_SERVER_NAME": serverName, // Pass the server name as an environment variable
}

containerId, err := rt.CreateContainer(
ctx,
processedImage,
"inspector",
[]string{}, // No custom command needed
envVars, // Set environment variables with server info
map[string]string{"toolhive": "true"}, // Add toolhive label
&permissions.Profile{}, // Empty profile as we don't need special permissions
string(types.TransportTypeSSE), // Use bridge network for port bindings
options,
)
if err != nil {
return fmt.Errorf("failed to create inspector container: %v", err)
}

// TODO: We should output the URL that the user can use to connect to the inspector
// TODO: that has their SSE search params already applied
logger.Infof("MCP Inspector launched with container ID: %s", containerId)
logger.Infof("Inspector UI is now available at http://localhost:6274")
logger.Infof("Inspector API is available at http://localhost:6277")
logger.Infof("Connected to MCP server: %s", serverName)
logger.Infof("Using server URL: %s", formattedURL)

return nil
}

// formatServerURL ensures the URL is properly formatted for the inspector
// It removes the fragment part (#container-name) as it's not needed for the inspector
func formatServerURL(serverURL string) (string, error) {
// Parse the URL
parsedURL, err := url.Parse(serverURL)
if err != nil {
return "", fmt.Errorf("failed to parse server URL: %w", err)
}

// Clear the fragment as it's not needed for the inspector
parsedURL.Fragment = ""

// Ensure the URL has the /sse endpoint
if !strings.HasSuffix(parsedURL.Path, "/sse") {
if parsedURL.Path == "" {
parsedURL.Path = "/sse"
} else if !strings.HasSuffix(parsedURL.Path, "/") {
parsedURL.Path += "/sse"
} else {
parsedURL.Path += "sse"

Check failure on line 166 in cmd/thv/app/inspector.go

View workflow job for this annotation

GitHub Actions / Linting / Lint

string `sse` has 3 occurrences, make it a constant (goconst)
}
}

// Return the formatted URL
return parsedURL.String(), nil
}

// getServerURL gets the URL of a running MCP server with the given name
func getServerURL(ctx context.Context, manager lifecycle.Manager, serverName string) (string, error) {
// Get list of all containers
containers, err := manager.ListContainers(ctx, true)
if err != nil {
return "", fmt.Errorf("failed to list containers: %v", err)
}

for _, c := range containers {
name := labels.GetContainerName(c.Labels)
if name == "" {
name = c.Name // Fallback to container name
}

if name == serverName {
// Get port from labels
port, err := labels.GetPort(c.Labels)
if err != nil {
return "", fmt.Errorf("failed to get port for server %s: %v", serverName, err)
}

// Generate URL for the MCP server
if port > 0 {
return client.GenerateMCPServerURL(defaultHost, port, serverName), nil
}
}
}

return "", fmt.Errorf("server with name %s not found", serverName)
}
5 changes: 5 additions & 0 deletions pkg/transport/types/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ const (

// TransportTypeSSE represents the SSE transport.
TransportTypeSSE TransportType = "sse"

// TransportTypeBridge represents the bridge network mode for container networking.
TransportTypeBridge TransportType = "bridge"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this piece? I don't think we'll be supporting a bridge transport.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'll be removing this. It was added simply because the Inspector server doesn't fit into the sse or stdio category. Just wanted to push what I had up and draft PR it so I can checkout other branches in the meantime.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aha! Let's document that a little better. If it really doesn't fit we can re-evaluate.

)

// String returns the string representation of the transport type.
Expand All @@ -68,6 +71,8 @@ func ParseTransportType(s string) (TransportType, error) {
return TransportTypeStdio, nil
case "sse", "SSE":
return TransportTypeSSE, nil
case "bridge", "BRIDGE":
return TransportTypeBridge, nil
default:
return "", errors.ErrUnsupportedTransport
}
Expand Down
Loading