Skip to content

Commit 4b837d9

Browse files
authored
feat: branded two-zone header for public viewer (#222)
Add implementor brand (far left) and platform brand (far right) zones to the public viewer header. The implementor zone is optional and only renders when portal.implementor.name or logo is configured. Both zones support clickable links when URLs are provided. - Add ImplementorConfig (name/logo/url) to PortalConfig - Add BrandURL, ImplementorName/LogoSVG/URL to portal.Deps - Resolve platform brand_name and brand_url from mcpapps config - Add ResolveImplementorLogo() with fetch + cache pattern - Redesign template: left (implementor) / center (asset) / right (platform)
1 parent c57f88a commit 4b837d9

File tree

10 files changed

+518
-18
lines changed

10 files changed

+518
-18
lines changed

cmd/mcp-data-platform/main.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,15 @@ func mountPortalAPI(mux *http.ServeMux, p *platform.Platform) {
498498
}
499499
}
500500

501+
// Platform brand (far right): prefer mcpapps platform-info config, then portal title.
502+
brandName := mcpappsBrandName(p)
503+
if brandName == "" {
504+
brandName = p.Config().Portal.Title
505+
}
506+
if brandName == "" {
507+
brandName = p.Config().Server.Name
508+
}
509+
501510
deps := portal.Deps{
502511
AssetStore: p.PortalAssetStore(),
503512
ShareStore: p.PortalShareStore(),
@@ -508,8 +517,14 @@ func mountPortalAPI(mux *http.ServeMux, p *platform.Platform) {
508517
RequestsPerMinute: p.Config().Portal.RateLimit.RequestsPerMinute,
509518
BurstSize: p.Config().Portal.RateLimit.BurstSize,
510519
},
511-
OIDCEnabled: p.BrowserSessionFlow() != nil,
512-
AdminRoles: adminRoles,
520+
OIDCEnabled: p.BrowserSessionFlow() != nil,
521+
AdminRoles: adminRoles,
522+
BrandName: brandName,
523+
BrandLogoSVG: p.BrandLogoSVG(),
524+
BrandURL: p.BrandURL(),
525+
ImplementorName: p.Config().Portal.Implementor.Name,
526+
ImplementorLogoSVG: p.ResolveImplementorLogo(),
527+
ImplementorURL: p.Config().Portal.Implementor.URL,
513528
}
514529

515530
wirePortalOptionalDeps(&deps, p)
@@ -520,6 +535,17 @@ func mountPortalAPI(mux *http.ServeMux, p *platform.Platform) {
520535
log.Println("Portal API enabled on /api/v1/portal/")
521536
}
522537

538+
// mcpappsBrandName extracts brand_name from the mcpapps platform-info config,
539+
// or returns empty string if not configured.
540+
func mcpappsBrandName(p *platform.Platform) string {
541+
appCfg, ok := p.Config().MCPApps.Apps["platform-info"]
542+
if !ok {
543+
return ""
544+
}
545+
name, _ := appCfg.Config["brand_name"].(string)
546+
return name
547+
}
548+
523549
// wirePortalOptionalDeps populates optional portal dependencies (audit, knowledge, persona).
524550
func wirePortalOptionalDeps(deps *portal.Deps, p *platform.Platform) {
525551
if p.AuditStore() != nil {

cmd/mcp-data-platform/main_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,3 +1017,55 @@ func TestMountPortalUI_NoAssets(t *testing.T) {
10171017
mux := http.NewServeMux()
10181018
mountPortalUI(mux, p, false) // no assets available
10191019
}
1020+
1021+
func TestMcpappsBrandName(t *testing.T) {
1022+
t.Run("returns brand_name from mcpapps config", func(t *testing.T) {
1023+
p := newTestPlatform(t, &platform.Config{
1024+
Server: platform.ServerConfig{Name: "test"},
1025+
MCPApps: platform.MCPAppsConfig{
1026+
Apps: map[string]platform.AppConfig{
1027+
"platform-info": {
1028+
Config: map[string]any{"brand_name": "Plexara"},
1029+
},
1030+
},
1031+
},
1032+
})
1033+
defer func() { _ = p.Close() }()
1034+
1035+
got := mcpappsBrandName(p)
1036+
if got != "Plexara" {
1037+
t.Errorf("mcpappsBrandName() = %q, want %q", got, "Plexara")
1038+
}
1039+
})
1040+
1041+
t.Run("returns empty when no platform-info app", func(t *testing.T) {
1042+
p := newTestPlatform(t, &platform.Config{
1043+
Server: platform.ServerConfig{Name: "test"},
1044+
})
1045+
defer func() { _ = p.Close() }()
1046+
1047+
got := mcpappsBrandName(p)
1048+
if got != "" {
1049+
t.Errorf("mcpappsBrandName() = %q, want empty", got)
1050+
}
1051+
})
1052+
1053+
t.Run("returns empty when brand_name not in config", func(t *testing.T) {
1054+
p := newTestPlatform(t, &platform.Config{
1055+
Server: platform.ServerConfig{Name: "test"},
1056+
MCPApps: platform.MCPAppsConfig{
1057+
Apps: map[string]platform.AppConfig{
1058+
"platform-info": {
1059+
Config: map[string]any{"other_key": "value"},
1060+
},
1061+
},
1062+
},
1063+
})
1064+
defer func() { _ = p.Close() }()
1065+
1066+
got := mcpappsBrandName(p)
1067+
if got != "" {
1068+
t.Errorf("mcpappsBrandName() = %q, want empty", got)
1069+
}
1070+
})
1071+
}

configs/platform.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,10 @@ tuning:
319319
# public_base_url: "https://portal.example.com"
320320
# s3_connection: data_lake # S3 toolkit instance for artifact storage
321321
# s3_bucket: portal-assets # bucket for artifact storage
322+
# implementor: # optional implementor brand (far-left header zone)
323+
# name: "ACME Corp"
324+
# logo: "https://acme.com/logo.svg"
325+
# url: "https://acme.com"
322326

323327
# Admin REST API
324328
# admin:

pkg/platform/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,18 @@ type PortalConfig struct {
113113
S3Prefix string `yaml:"s3_prefix"` // key prefix within the bucket
114114
PublicBaseURL string `yaml:"public_base_url"` // base URL for portal links (e.g., "https://portal.example.com")
115115
MaxContentSize int `yaml:"max_content_size"` // max artifact size in bytes (default: 10MB)
116+
Implementor ImplementorConfig `yaml:"implementor"` // optional implementor brand (far-left header zone)
116117
RateLimit PortalRateLimitConfig `yaml:"rate_limit"`
117118
}
118119

120+
// ImplementorConfig configures the optional implementor brand shown in the
121+
// far-left zone of the public viewer header (e.g., "ACME Corp").
122+
type ImplementorConfig struct {
123+
Name string `yaml:"name"` // display name (e.g., "ACME Corp")
124+
Logo string `yaml:"logo"` // URL to logo SVG (fetched at startup for inline rendering)
125+
URL string `yaml:"url"` // link URL (e.g., "https://acme.com")
126+
}
127+
119128
// PortalRateLimitConfig configures rate limiting for the public portal viewer.
120129
type PortalRateLimitConfig struct {
121130
RequestsPerMinute int `yaml:"requests_per_minute"` // default: 60

pkg/platform/platform.go

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,13 @@ type Platform struct {
125125
knowledgeDataHubWriter knowledgekit.DataHubWriter
126126

127127
// Portal stores (exposed for REST API in Phase 3)
128-
portalAssetStore portal.AssetStore
129-
portalShareStore portal.ShareStore
130-
portalS3Client portal.S3Client
131-
provenanceTracker *middleware.ProvenanceTracker
128+
portalAssetStore portal.AssetStore
129+
portalShareStore portal.ShareStore
130+
portalS3Client portal.S3Client
131+
provenanceTracker *middleware.ProvenanceTracker
132+
resolvedBrandLogoSVG string // cached SVG from portal.logo or mcpapps config
133+
resolvedBrandURL string // cached brand_url from mcpapps platform-info config
134+
resolvedImplementorLogo string // cached SVG fetched from portal.implementor.logo
132135

133136
// Workflow gating
134137
workflowTracker *middleware.SessionWorkflowTracker
@@ -981,23 +984,40 @@ func (p *Platform) registerBuiltinPlatformInfo() error {
981984
// explicitly. When the logo is an SVG URL, it is fetched and inlined as
982985
// logo_svg so the logo renders in sandboxed contexts (MCP App iframes)
983986
// that block external resource loading.
987+
//
988+
// Also caches brand_url from the app config for use by BrandURL().
984989
func (p *Platform) injectPortalLogo(cfg any) any {
990+
m, ok := cfg.(map[string]any)
991+
if !ok {
992+
m = make(map[string]any)
993+
}
994+
995+
// Cache brand_url from the mcpapps platform-info config.
996+
if brandURL, _ := m["brand_url"].(string); brandURL != "" {
997+
p.resolvedBrandURL = brandURL
998+
}
999+
9851000
portalLogo := p.config.Portal.Logo
9861001
if portalLogo == "" {
987-
return cfg
1002+
// Still cache logo_svg if present in the app config.
1003+
if svg, _ := m["logo_svg"].(string); svg != "" {
1004+
p.resolvedBrandLogoSVG = svg
1005+
}
1006+
return m
9881007
}
9891008

990-
m, ok := cfg.(map[string]any)
991-
if !ok {
992-
m = make(map[string]any)
1009+
if svg, _ := m["logo_svg"].(string); svg != "" {
1010+
p.resolvedBrandLogoSVG = svg
1011+
return m
9931012
}
994-
if m["logo_svg"] != nil || m["logo_url"] != nil {
1013+
if m["logo_url"] != nil {
9951014
return m
9961015
}
9971016

9981017
// Fetch SVG content for inline rendering; fall back to URL on failure.
9991018
if svg, err := fetchLogoSVG(portalLogo); err == nil {
10001019
m["logo_svg"] = svg
1020+
p.resolvedBrandLogoSVG = svg
10011021
} else {
10021022
slog.Debug("portal logo fetch failed, using URL", "url", portalLogo, "err", err)
10031023
m["logo_url"] = portalLogo
@@ -1706,6 +1726,39 @@ func (p *Platform) PortalS3Client() portal.S3Client {
17061726
return p.portalS3Client
17071727
}
17081728

1729+
// BrandLogoSVG returns the resolved brand logo SVG content (from portal.logo
1730+
// or mcpapps platform-info config), or empty string if none is configured.
1731+
func (p *Platform) BrandLogoSVG() string {
1732+
return p.resolvedBrandLogoSVG
1733+
}
1734+
1735+
// BrandURL returns the resolved brand URL from the mcpapps platform-info
1736+
// config (brand_url), or empty string if not configured.
1737+
func (p *Platform) BrandURL() string {
1738+
return p.resolvedBrandURL
1739+
}
1740+
1741+
// ResolveImplementorLogo fetches the implementor logo SVG from the URL
1742+
// configured in portal.implementor.logo. The result is cached so subsequent
1743+
// calls return the same value without another HTTP request. Returns empty
1744+
// string if no logo URL is configured or the fetch fails.
1745+
func (p *Platform) ResolveImplementorLogo() string {
1746+
logoURL := p.config.Portal.Implementor.Logo
1747+
if logoURL == "" {
1748+
return ""
1749+
}
1750+
if p.resolvedImplementorLogo != "" {
1751+
return p.resolvedImplementorLogo
1752+
}
1753+
svg, err := fetchLogoSVG(logoURL)
1754+
if err != nil {
1755+
slog.Debug("implementor logo fetch failed", "url", logoURL, "err", err)
1756+
return ""
1757+
}
1758+
p.resolvedImplementorLogo = svg
1759+
return svg
1760+
}
1761+
17091762
// BrowserSessionFlow returns the OIDC login flow, or nil if browser sessions are disabled.
17101763
func (p *Platform) BrowserSessionFlow() *browsersession.Flow {
17111764
return p.browserSessionFlow

pkg/platform/platform_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3870,6 +3870,101 @@ func TestFetchLogoSVG(t *testing.T) {
38703870
})
38713871
}
38723872

3873+
func TestBrandURL(t *testing.T) {
3874+
t.Run("returns empty when not set", func(t *testing.T) {
3875+
p := &Platform{config: &Config{}}
3876+
if got := p.BrandURL(); got != "" {
3877+
t.Errorf("BrandURL() = %q, want empty", got)
3878+
}
3879+
})
3880+
3881+
t.Run("returns cached value from injectPortalLogo", func(t *testing.T) {
3882+
p := &Platform{config: &Config{}}
3883+
cfg := map[string]any{"brand_url": "https://example.com"}
3884+
_ = p.injectPortalLogo(cfg)
3885+
if got := p.BrandURL(); got != "https://example.com" {
3886+
t.Errorf("BrandURL() = %q, want %q", got, "https://example.com")
3887+
}
3888+
})
3889+
}
3890+
3891+
func TestInjectPortalLogo_CachesBrandURL(t *testing.T) {
3892+
t.Run("caches brand_url from config", func(t *testing.T) {
3893+
p := &Platform{config: &Config{}}
3894+
cfg := map[string]any{"brand_url": "https://platform.io"}
3895+
_ = p.injectPortalLogo(cfg)
3896+
if p.resolvedBrandURL != "https://platform.io" {
3897+
t.Errorf("resolvedBrandURL = %q, want %q", p.resolvedBrandURL, "https://platform.io")
3898+
}
3899+
})
3900+
3901+
t.Run("caches brand_url even without portal logo", func(t *testing.T) {
3902+
p := &Platform{config: &Config{}} // no Portal.Logo
3903+
cfg := map[string]any{"brand_url": "https://noportallogo.io", "logo_svg": "<svg/>"}
3904+
_ = p.injectPortalLogo(cfg)
3905+
if p.resolvedBrandURL != "https://noportallogo.io" {
3906+
t.Errorf("resolvedBrandURL = %q, want %q", p.resolvedBrandURL, "https://noportallogo.io")
3907+
}
3908+
// Also verify logo_svg was cached even without portal.Logo
3909+
if p.resolvedBrandLogoSVG != "<svg/>" {
3910+
t.Errorf("resolvedBrandLogoSVG = %q, want %q", p.resolvedBrandLogoSVG, "<svg/>")
3911+
}
3912+
})
3913+
3914+
t.Run("does not set brand_url when absent", func(t *testing.T) {
3915+
p := &Platform{config: &Config{}}
3916+
cfg := map[string]any{"brand_name": "Test"}
3917+
_ = p.injectPortalLogo(cfg)
3918+
if p.resolvedBrandURL != "" {
3919+
t.Errorf("resolvedBrandURL = %q, want empty", p.resolvedBrandURL)
3920+
}
3921+
})
3922+
}
3923+
3924+
func TestResolveImplementorLogo(t *testing.T) {
3925+
svgContent := `<svg viewBox="0 0 32 32"><rect width="32" height="32"/></svg>`
3926+
3927+
t.Run("fetches and caches SVG", func(t *testing.T) {
3928+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
3929+
w.Header().Set("Content-Type", "image/svg+xml")
3930+
_, _ = w.Write([]byte(svgContent))
3931+
}))
3932+
defer srv.Close()
3933+
3934+
p := &Platform{config: &Config{
3935+
Portal: PortalConfig{Implementor: ImplementorConfig{Logo: srv.URL + "/impl.svg"}},
3936+
}}
3937+
3938+
got := p.ResolveImplementorLogo()
3939+
if got != svgContent {
3940+
t.Errorf("ResolveImplementorLogo() = %q, want %q", got, svgContent)
3941+
}
3942+
3943+
// Second call should return cached value (no HTTP request)
3944+
srv.Close()
3945+
got2 := p.ResolveImplementorLogo()
3946+
if got2 != svgContent {
3947+
t.Errorf("cached ResolveImplementorLogo() = %q, want %q", got2, svgContent)
3948+
}
3949+
})
3950+
3951+
t.Run("returns empty when logo URL is empty", func(t *testing.T) {
3952+
p := &Platform{config: &Config{}}
3953+
if got := p.ResolveImplementorLogo(); got != "" {
3954+
t.Errorf("ResolveImplementorLogo() = %q, want empty", got)
3955+
}
3956+
})
3957+
3958+
t.Run("returns empty on fetch failure", func(t *testing.T) {
3959+
p := &Platform{config: &Config{
3960+
Portal: PortalConfig{Implementor: ImplementorConfig{Logo: "http://127.0.0.1:1/unreachable.svg"}},
3961+
}}
3962+
if got := p.ResolveImplementorLogo(); got != "" {
3963+
t.Errorf("ResolveImplementorLogo() = %q, want empty on fetch failure", got)
3964+
}
3965+
})
3966+
}
3967+
38733968
func TestNew_WorkflowGatingDisabled(t *testing.T) {
38743969
cfg := &Config{
38753970
Server: ServerConfig{Name: testServerName},

pkg/portal/handler.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ type Deps struct {
6363
AuditMetrics AuditMetrics
6464
InsightStore InsightReader
6565
PersonaResolver PersonaResolver
66+
// Platform brand (far right of public viewer header)
67+
BrandName string // display name (default: "MCP Data Platform")
68+
BrandLogoSVG string // inline SVG for header logo (empty = default icon)
69+
BrandURL string // link URL (e.g., "https://plexara.io"); empty = no link
70+
71+
// Implementor brand (far left of public viewer header, optional)
72+
ImplementorName string // display name (e.g., "ACME Corp"); empty = hidden
73+
ImplementorLogoSVG string // inline SVG; empty = hidden
74+
ImplementorURL string // link URL; empty = no link
6675
}
6776

6877
// Handler provides portal REST API endpoints.

0 commit comments

Comments
 (0)