diff --git a/registry/converters/converters_test.go b/registry/converters/converters_test.go index 8840c99..5b4f053 100644 --- a/registry/converters/converters_test.go +++ b/registry/converters/converters_test.go @@ -882,6 +882,76 @@ func TestRoundTrip_RemoteServerMetadata(t *testing.T) { assert.Len(t, result.ToolDefinitions, len(original.ToolDefinitions)) } +func TestRoundTrip_StatelessField(t *testing.T) { + t.Parallel() + + t.Run("remote server with stateless=true", func(t *testing.T) { + t.Parallel() + + original := ®istry.RemoteServerMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Description: "Stateless remote server", + Transport: "streamable-http", + Status: "active", + Tier: "Official", + Tools: []string{"tool1"}, + Tags: []string{"stateless"}, + Stateless: true, + }, + URL: "https://api.example.com/mcp", + } + + serverJSON, err := RemoteServerMetadataToServerJSON("stateless-remote", original) + require.NoError(t, err) + + result, err := ServerJSONToRemoteServerMetadata(serverJSON) + require.NoError(t, err) + + assert.True(t, result.Stateless, "Stateless field should be preserved through round-trip") + assert.True(t, result.GetStateless(), "GetStateless() should return true") + }) + + t.Run("remote server with stateless=false (default)", func(t *testing.T) { + t.Parallel() + + original := createTestRemoteServerMetadata() + assert.False(t, original.Stateless, "test fixture should default to false") + + serverJSON, err := RemoteServerMetadataToServerJSON("stateful-remote", original) + require.NoError(t, err) + + result, err := ServerJSONToRemoteServerMetadata(serverJSON) + require.NoError(t, err) + + assert.False(t, result.Stateless, "Stateless field should remain false") + }) + + t.Run("image server with stateless=true", func(t *testing.T) { + t.Parallel() + + original := ®istry.ImageMetadata{ + BaseServerMetadata: registry.BaseServerMetadata{ + Description: "Stateless container server", + Transport: "streamable-http", + Status: "active", + Tier: "Official", + Tools: []string{"tool1"}, + Tags: []string{"stateless"}, + Stateless: true, + }, + Image: "ghcr.io/test/stateless:latest", + } + + serverJSON, err := ImageMetadataToServerJSON("stateless-image", original) + require.NoError(t, err) + + result, err := ServerJSONToImageMetadata(serverJSON) + require.NoError(t, err) + + assert.True(t, result.Stateless, "Stateless field should be preserved through round-trip") + }) +} + func TestRoundTrip_ImageMetadataWithAllFields(t *testing.T) { t.Parallel() @@ -893,6 +963,7 @@ func TestRoundTrip_ImageMetadataWithAllFields(t *testing.T) { RepositoryURL: "https://github.com/test/full", Status: "active", Tier: "Official", + Stateless: true, Tools: []string{"tool1", "tool2", "tool3"}, Tags: []string{"tag1", "tag2"}, Metadata: ®istry.Metadata{ @@ -937,6 +1008,7 @@ func TestRoundTrip_ImageMetadataWithAllFields(t *testing.T) { assert.Equal(t, original.Tools, result.Tools) assert.Equal(t, original.Tags, result.Tags) assert.Equal(t, original.TargetPort, result.TargetPort) + assert.Equal(t, original.Stateless, result.Stateless) require.Len(t, result.EnvVars, len(original.EnvVars)) for i := range original.EnvVars { diff --git a/registry/converters/toolhive_to_upstream.go b/registry/converters/toolhive_to_upstream.go index 35cf1a3..3cb69b9 100644 --- a/registry/converters/toolhive_to_upstream.go +++ b/registry/converters/toolhive_to_upstream.go @@ -217,6 +217,7 @@ func createImageExtensions(imageMetadata *registry.ImageMetadata) map[string]int Provenance: imageMetadata.Provenance, DockerTags: imageMetadata.DockerTags, ProxyPort: imageMetadata.ProxyPort, + Stateless: imageMetadata.Stateless, } return buildPublisherExtensionsMap(ext, imageMetadata.Image) } @@ -236,6 +237,7 @@ func createRemoteExtensions(remoteMetadata *registry.RemoteServerMetadata) map[s OAuthConfig: remoteMetadata.OAuthConfig, EnvVars: remoteMetadata.EnvVars, ProxyPort: remoteMetadata.ProxyPort, + Stateless: remoteMetadata.Stateless, } return buildPublisherExtensionsMap(ext, remoteMetadata.URL) } diff --git a/registry/converters/upstream_to_toolhive.go b/registry/converters/upstream_to_toolhive.go index 4f4dad6..673661c 100644 --- a/registry/converters/upstream_to_toolhive.go +++ b/registry/converters/upstream_to_toolhive.go @@ -281,6 +281,7 @@ func applyBaseExtensions(ext *registry.ServerExtensions, base *registry.BaseServ base.ToolDefinitions = ext.ToolDefinitions base.Metadata = ext.Metadata base.CustomMetadata = ext.CustomMetadata + base.Stateless = ext.Stateless } // extractImageExtensions extracts publisher-provided extensions into ImageMetadata diff --git a/registry/types/data/publisher-provided.schema.json b/registry/types/data/publisher-provided.schema.json index 1e99dd3..d31ff43 100644 --- a/registry/types/data/publisher-provided.schema.json +++ b/registry/types/data/publisher-provided.schema.json @@ -105,6 +105,11 @@ "minimum": 1, "maximum": 65535 }, + "stateless": { + "type": "boolean", + "description": "Whether the server only supports POST requests (no SSE/GET). When true, the proxy returns 405 for GET and uses POST-based health checks.", + "default": false + }, "oauth_config": { "description": "OAuth/OIDC configuration for remote servers", "$ref": "#/$defs/oauth_config" diff --git a/registry/types/data/toolhive-legacy-registry.schema.json b/registry/types/data/toolhive-legacy-registry.schema.json index 141f74f..a9112e6 100644 --- a/registry/types/data/toolhive-legacy-registry.schema.json +++ b/registry/types/data/toolhive-legacy-registry.schema.json @@ -197,6 +197,11 @@ "streamable-http" ], "default": "stdio" + }, + "stateless": { + "type": "boolean", + "description": "Whether the server only supports POST requests (no SSE/GET). When true, the proxy returns 405 for GET and uses POST-based health checks.", + "default": false } } }, @@ -573,6 +578,11 @@ ], "default": "sse" }, + "stateless": { + "type": "boolean", + "description": "Whether the server only supports POST requests (no SSE/GET). When true, the proxy returns 405 for GET and uses POST-based health checks.", + "default": false + }, "tools": { "type": "array", "description": "List of tool names provided by this MCP server", diff --git a/registry/types/publisher_provided_types.go b/registry/types/publisher_provided_types.go index d90810b..7c02ad3 100644 --- a/registry/types/publisher_provided_types.go +++ b/registry/types/publisher_provided_types.go @@ -18,10 +18,10 @@ import ( // - For remote servers: keyed by URL (e.g., "https://api.example.com/mcp") // // Container servers may use: Status, Tier, Tools, Tags, Metadata, CustomMetadata, -// Permissions, Args, Provenance, DockerTags, ProxyPort, ToolDefinitions +// Permissions, Args, Provenance, DockerTags, ProxyPort, Stateless, ToolDefinitions // // Remote servers may use: Status, Tier, Tools, Tags, Metadata, CustomMetadata, -// OAuthConfig, EnvVars, ProxyPort, ToolDefinitions +// OAuthConfig, EnvVars, ProxyPort, Stateless, ToolDefinitions type ServerExtensions struct { // Status indicates whether the server is active or deprecated (required) Status string `json:"status" yaml:"status"` @@ -57,6 +57,9 @@ type ServerExtensions struct { // ProxyPort is the HTTP proxy port (1-65535). Applies to both container-based // and remote servers. ProxyPort int `json:"proxy_port,omitempty" yaml:"proxy_port,omitempty"` + // Stateless indicates the server only supports POST (no SSE/GET). + // Applies to both container-based and remote servers. + Stateless bool `json:"stateless,omitempty" yaml:"stateless,omitempty"` // Remote server-specific fields (only for servers keyed by URL) diff --git a/registry/types/registry_types.go b/registry/types/registry_types.go index 61c4470..b6177c0 100644 --- a/registry/types/registry_types.go +++ b/registry/types/registry_types.go @@ -66,6 +66,8 @@ type BaseServerMetadata struct { // For containers: stdio, sse, or streamable-http // For remote servers: sse or streamable-http (stdio not supported) Transport string `json:"transport" yaml:"transport"` + // Stateless indicates the server only supports POST (no SSE/GET) + Stateless bool `json:"stateless,omitempty" yaml:"stateless,omitempty"` // Tools is a list of tool names provided by this MCP server Tools []string `json:"tools" yaml:"tools"` // Metadata contains additional information about the server such as popularity metrics @@ -291,6 +293,8 @@ type ServerMetadata interface { GetStatus() string // GetTransport returns the server transport GetTransport() string + // GetStateless returns whether the server is stateless (POST-only, no SSE/GET) + GetStateless() bool // GetTools returns the list of tools provided by the server GetTools() []string // GetMetadata returns the server metadata @@ -362,6 +366,14 @@ func (b *BaseServerMetadata) GetTransport() string { return b.Transport } +// GetStateless returns whether the server is stateless (POST-only, no SSE/GET) +func (b *BaseServerMetadata) GetStateless() bool { + if b == nil { + return false + } + return b.Stateless +} + // GetTools returns the list of tools provided by the server func (b *BaseServerMetadata) GetTools() []string { if b == nil { diff --git a/registry/types/registry_types_test.go b/registry/types/registry_types_test.go index 5d10bcc..64ad229 100644 --- a/registry/types/registry_types_test.go +++ b/registry/types/registry_types_test.go @@ -4,6 +4,7 @@ package registry import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -251,6 +252,7 @@ func TestRegistry_ServerMetadataInterface(t *testing.T) { assert.Nil(t, sa.GetToolDefinitions()) assert.Nil(t, sa.GetCustomMetadata()) assert.Nil(t, sa.GetMetadata()) + assert.False(t, sa.GetStateless()) // not set in fixture assert.False(t, sa.IsRemote()) require.Len(t, sa.GetEnvVars(), 1) assert.Equal(t, "API_KEY", sa.GetEnvVars()[0].Name) @@ -292,3 +294,84 @@ func TestMetadata_ParsedTime(t *testing.T) { _, err = m.ParsedTime() assert.Error(t, err) } + +func TestStateless_YAMLRoundTrip(t *testing.T) { + t.Parallel() + + const yamlData = ` +version: "1.0.0" +last_updated: "2024-06-01T00:00:00Z" +servers: {} +remote_servers: + stateless-remote: + name: stateless-remote + description: A stateless remote server + tier: Community + status: Active + transport: streamable-http + stateless: true + url: https://api.example.com/mcp + tools: + - tool1 +` + var reg Registry + require.NoError(t, yaml.Unmarshal([]byte(yamlData), ®)) + + rs := reg.RemoteServers["stateless-remote"] + require.NotNil(t, rs) + assert.True(t, rs.Stateless, "Stateless should be true after YAML unmarshal") + assert.True(t, rs.GetStateless(), "GetStateless() should return true") + + // Verify through ServerMetadata interface + srv, ok := reg.GetServerByName("stateless-remote") + require.True(t, ok) + assert.True(t, srv.GetStateless()) + + // Verify default (omitted) is false via the shared fixture + sharedReg := parseTestRegistry(t) + ra := sharedReg.RemoteServers["remote-a"] + require.NotNil(t, ra) + assert.False(t, ra.Stateless, "Stateless should default to false when omitted") +} + +func TestStateless_JSONRoundTrip(t *testing.T) { + t.Parallel() + + original := &RemoteServerMetadata{ + BaseServerMetadata: BaseServerMetadata{ + Name: "test", + Description: "test server", + Transport: "streamable-http", + Stateless: true, + Status: "active", + Tier: "Official", + Tools: []string{"tool1"}, + }, + URL: "https://example.com/mcp", + } + + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded RemoteServerMetadata + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.True(t, decoded.Stateless) + + // Verify omitempty: false value should not appear in JSON + falseOriginal := &RemoteServerMetadata{ + BaseServerMetadata: BaseServerMetadata{ + Name: "test2", + Stateless: false, + }, + } + falseData, err := json.Marshal(falseOriginal) + require.NoError(t, err) + assert.NotContains(t, string(falseData), "stateless") +} + +func TestStateless_NilReceiver(t *testing.T) { + t.Parallel() + + var b *BaseServerMetadata + assert.False(t, b.GetStateless(), "nil receiver should return false") +}