diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 0af0440..6835267 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -200,6 +200,8 @@ type DenyRequest struct { type AdminServer struct { Name string `json:"name"` + EndpointSlug string `json:"endpoint_slug"` + EndpointURL string `json:"endpoint_url,omitempty"` Mode string `json:"mode"` BaseURL string `json:"base_url,omitempty"` AuthToken string `json:"auth_token,omitempty"` @@ -3074,6 +3076,7 @@ type ServerAdminService struct { type serverRepo interface { ListServers(ctx context.Context, filter mcp.ServerFilter) ([]mcp.Upstream, int, error) GetServerAny(ctx context.Context, name string) (mcp.Upstream, error) + GetServerByEndpointSlugAny(ctx context.Context, slug string) (mcp.Upstream, error) UpsertServer(ctx context.Context, upstream mcp.Upstream) error UpdateServerStatus(ctx context.Context, name string, status mcp.ServerStatus) error DeleteServer(ctx context.Context, name string) error @@ -3110,6 +3113,7 @@ func (s *ServerAdminService) Get(ctx context.Context, name string) (AdminServer, // what we requested and what's actually live. func (s *ServerAdminService) adminViewWithGrantedScopes(ctx context.Context, upstream mcp.Upstream) AdminServer { view := toAdminServer(upstream) + view.EndpointURL = s.endpointURL(view.EndpointSlug) if cred, err := s.oauthRepo.GetCredential(ctx, upstream.Name); err == nil { view.OAuthGrantedScopes = cred.Scope } @@ -3128,6 +3132,9 @@ func (s *ServerAdminService) Upsert(ctx context.Context, name string, req AdminS if serverName == "" { return AdminServer{}, fmt.Errorf("name is required") } + if mcp.EndpointSlug(serverName) == "" { + return AdminServer{}, fmt.Errorf("name must include at least one letter or number") + } mode := strings.TrimSpace(req.Mode) if mode == "" { return AdminServer{}, fmt.Errorf("mode is required") @@ -3140,18 +3147,26 @@ func (s *ServerAdminService) Upsert(ctx context.Context, name string, req AdminS // chose not to re-send (notably client_secret, which is never echoed // back to the browser). existing, _ := s.repo.GetServerAny(ctx, serverName) + endpointSlug := existing.EndpointSlug + if endpointSlug == "" { + endpointSlug = mcp.EndpointSlug(serverName) + if err := s.validateEndpointSlugAvailable(ctx, serverName, endpointSlug); err != nil { + return AdminServer{}, err + } + } upstream := mcp.Upstream{ - Name: serverName, - Mode: mcp.UpstreamMode(mode), - BaseURL: strings.TrimRight(strings.TrimSpace(req.BaseURL), "/"), - AuthToken: req.AuthToken, - AuthHeaders: append([]mcp.AuthHeader(nil), req.AuthHeaders...), - Timeout: time.Duration(defaultIfZero(req.TimeoutSeconds, 30)) * time.Second, - Command: strings.TrimSpace(req.Command), - Args: append([]string(nil), req.Args...), - Env: cloneEnv(req.Env), - Enabled: enabled, + Name: serverName, + EndpointSlug: endpointSlug, + Mode: mcp.UpstreamMode(mode), + BaseURL: strings.TrimRight(strings.TrimSpace(req.BaseURL), "/"), + AuthToken: req.AuthToken, + AuthHeaders: append([]mcp.AuthHeader(nil), req.AuthHeaders...), + Timeout: time.Duration(defaultIfZero(req.TimeoutSeconds, 30)) * time.Second, + Command: strings.TrimSpace(req.Command), + Args: append([]string(nil), req.Args...), + Env: cloneEnv(req.Env), + Enabled: enabled, } // OAuth fields from the request body win, except client_secret which @@ -3192,6 +3207,33 @@ func (s *ServerAdminService) Upsert(ctx context.Context, name string, req AdminS return s.adminViewWithGrantedScopes(ctx, upstream), nil } +func (s *ServerAdminService) validateEndpointSlugAvailable(ctx context.Context, serverName string, slug string) error { + item, err := s.repo.GetServerByEndpointSlugAny(ctx, slug) + if err == sql.ErrNoRows { + return nil + } + if err != nil { + return err + } + if item.Name != serverName { + if strings.EqualFold(item.Name, serverName) { + return fmt.Errorf("server name %q already exists", item.Name) + } + return fmt.Errorf("server name %q would use endpoint slug %q, already used by %q", serverName, slug, item.Name) + } + return nil +} + +func (s *ServerAdminService) endpointURL(slug string) string { + if slug == "" { + return "" + } + if s.publicBaseURL == "" { + return "/mcp/" + slug + } + return s.publicBaseURL + "/mcp/" + slug +} + func (s *ServerAdminService) Delete(ctx context.Context, name string, disable bool) error { if disable { return s.repo.DisableServer(ctx, name) @@ -3544,7 +3586,11 @@ func (h *Handler) externalInvocationDetail(w http.ResponseWriter, r *http.Reques } func toAdminServer(upstream mcp.Upstream) AdminServer { - return AdminServer{Name: upstream.Name, Mode: string(upstream.Mode), BaseURL: upstream.BaseURL, AuthToken: upstream.AuthToken, AuthHeaders: append([]mcp.AuthHeader(nil), upstream.AuthHeaders...), TimeoutSeconds: int(upstream.Timeout / time.Second), Command: upstream.Command, Args: append([]string(nil), upstream.Args...), Env: cloneEnv(upstream.Env), Enabled: upstream.Enabled, AuthType: string(upstream.Status.AuthType), ConnectionStatus: string(upstream.Status.ConnectionStatus), AuthStatus: string(upstream.Status.AuthStatus), ReauthNeeded: upstream.Status.ReauthNeeded, LastCheckedAt: upstream.Status.LastCheckedAt, LastCheckOK: upstream.Status.LastCheckOK, LastErrorSummary: upstream.Status.LastErrorSummary, ActionRequired: upstream.Status.ActionRequired, OAuthProviderID: upstream.OAuthProviderID, OAuthProviderLabel: upstream.OAuthProviderLabel, OAuthClientRegistration: string(upstream.OAuthClientRegistration), OAuthClientID: upstream.OAuthClientID, OAuthAuthorizeURL: upstream.OAuthAuthorizeURL, OAuthTokenURL: upstream.OAuthTokenURL, OAuthScopes: upstream.OAuthScopes, HasOAuthClientSecret: strings.TrimSpace(upstream.OAuthClientSecret) != ""} + endpointSlug := upstream.EndpointSlug + if endpointSlug == "" { + endpointSlug = mcp.EndpointSlug(upstream.Name) + } + return AdminServer{Name: upstream.Name, EndpointSlug: endpointSlug, Mode: string(upstream.Mode), BaseURL: upstream.BaseURL, AuthToken: upstream.AuthToken, AuthHeaders: append([]mcp.AuthHeader(nil), upstream.AuthHeaders...), TimeoutSeconds: int(upstream.Timeout / time.Second), Command: upstream.Command, Args: append([]string(nil), upstream.Args...), Env: cloneEnv(upstream.Env), Enabled: upstream.Enabled, AuthType: string(upstream.Status.AuthType), ConnectionStatus: string(upstream.Status.ConnectionStatus), AuthStatus: string(upstream.Status.AuthStatus), ReauthNeeded: upstream.Status.ReauthNeeded, LastCheckedAt: upstream.Status.LastCheckedAt, LastCheckOK: upstream.Status.LastCheckOK, LastErrorSummary: upstream.Status.LastErrorSummary, ActionRequired: upstream.Status.ActionRequired, OAuthProviderID: upstream.OAuthProviderID, OAuthProviderLabel: upstream.OAuthProviderLabel, OAuthClientRegistration: string(upstream.OAuthClientRegistration), OAuthClientID: upstream.OAuthClientID, OAuthAuthorizeURL: upstream.OAuthAuthorizeURL, OAuthTokenURL: upstream.OAuthTokenURL, OAuthScopes: upstream.OAuthScopes, HasOAuthClientSecret: strings.TrimSpace(upstream.OAuthClientSecret) != ""} } func validateUpstream(upstream mcp.Upstream) error { diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 1dfd7ff..4718736 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -16,6 +16,7 @@ import ( "atryum/internal/auth" backendclient "atryum/internal/backend" + "atryum/internal/config" "atryum/internal/invocation" "atryum/internal/managedagents" "atryum/internal/mcp" @@ -330,6 +331,152 @@ func TestStartConnectUsesUpstreamMCPOAuthCallbackRedirectURI(t *testing.T) { } } +func TestServerAdminServiceSurfacesEndpointURLForSluggedServerName(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + if err := store.InitDB(db); err != nil { + t.Fatalf("InitDB: %v", err) + } + + ctx := context.Background() + serverRepo := store.NewServerRepo(db) + oauthRepo := store.NewOAuthRepo(db) + svc := NewServerAdminService(serverRepo, oauthRepo, nil, 5*time.Second, "https://atryum.example") + + server, err := svc.Upsert(ctx, "", AdminServerUpsertRequest{ + Name: "Slack Local", + Mode: string(mcp.UpstreamModeHTTP), + BaseURL: "https://mcp.slack.test/mcp", + TimeoutSeconds: 10, + }) + if err != nil { + t.Fatalf("Upsert: %v", err) + } + if server.Name != "Slack Local" { + t.Fatalf("name = %q, want Slack Local", server.Name) + } + if server.EndpointSlug != "slack-local" { + t.Fatalf("endpoint_slug = %q, want slack-local", server.EndpointSlug) + } + if server.EndpointURL != "https://atryum.example/mcp/slack-local" { + t.Fatalf("endpoint_url = %q", server.EndpointURL) + } + + resolver := mcp.NewResolver(serverRepo, config.Config{}) + upstream, err := resolver.ResolveContext(ctx, "slack-local") + if err != nil { + t.Fatalf("ResolveContext: %v", err) + } + if upstream.Name != "Slack Local" { + t.Fatalf("resolved name = %q, want Slack Local", upstream.Name) + } +} + +func TestServerAdminServiceRejectsDuplicateEndpointSlug(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + if err := store.InitDB(db); err != nil { + t.Fatalf("InitDB: %v", err) + } + + ctx := context.Background() + serverRepo := store.NewServerRepo(db) + oauthRepo := store.NewOAuthRepo(db) + svc := NewServerAdminService(serverRepo, oauthRepo, nil, 5*time.Second, "") + + _, err = svc.Upsert(ctx, "", AdminServerUpsertRequest{ + Name: "Slack Local", + Mode: string(mcp.UpstreamModeHTTP), + BaseURL: "https://mcp.slack.test/mcp", + }) + if err != nil { + t.Fatalf("Upsert first server: %v", err) + } + _, err = svc.Upsert(ctx, "", AdminServerUpsertRequest{ + Name: "slack-local", + Mode: string(mcp.UpstreamModeHTTP), + BaseURL: "https://other.example/mcp", + }) + if err == nil || !strings.Contains(err.Error(), `endpoint slug "slack-local"`) { + t.Fatalf("expected duplicate endpoint slug error, got %v", err) + } +} + +func TestServerAdminServiceRejectsCaseOnlyDuplicateNameWithClearError(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + if err := store.InitDB(db); err != nil { + t.Fatalf("InitDB: %v", err) + } + + ctx := context.Background() + serverRepo := store.NewServerRepo(db) + oauthRepo := store.NewOAuthRepo(db) + svc := NewServerAdminService(serverRepo, oauthRepo, nil, 5*time.Second, "") + + _, err = svc.Upsert(ctx, "", AdminServerUpsertRequest{ + Name: "slack local", + Mode: string(mcp.UpstreamModeHTTP), + BaseURL: "https://mcp.slack.test/mcp", + }) + if err != nil { + t.Fatalf("Upsert first server: %v", err) + } + _, err = svc.Upsert(ctx, "", AdminServerUpsertRequest{ + Name: "Slack Local", + Mode: string(mcp.UpstreamModeHTTP), + BaseURL: "https://other.example/mcp", + }) + if err == nil || !strings.Contains(err.Error(), `server name "slack local" already exists`) { + t.Fatalf("expected duplicate name error, got %v", err) + } +} + +func TestServerAdminServiceUsesStoredEndpointSlug(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + if err := store.InitDB(db); err != nil { + t.Fatalf("InitDB: %v", err) + } + + ctx := context.Background() + serverRepo := store.NewServerRepo(db) + oauthRepo := store.NewOAuthRepo(db) + if err := serverRepo.UpsertServer(ctx, mcp.Upstream{ + Name: "Slack Local", + EndpointSlug: "slack-local-2", + Mode: mcp.UpstreamModeHTTP, + BaseURL: "https://mcp.slack.test/mcp", + Enabled: true, + }); err != nil { + t.Fatalf("UpsertServer: %v", err) + } + + svc := NewServerAdminService(serverRepo, oauthRepo, nil, 5*time.Second, "https://atryum.example") + server, err := svc.Get(ctx, "Slack Local") + if err != nil { + t.Fatalf("Get: %v", err) + } + if server.EndpointSlug != "slack-local-2" { + t.Fatalf("endpoint_slug = %q, want slack-local-2", server.EndpointSlug) + } + if server.EndpointURL != "https://atryum.example/mcp/slack-local-2" { + t.Fatalf("endpoint_url = %q", server.EndpointURL) + } +} + func TestAdminServerTestDebugLogsRequestContext(t *testing.T) { t.Setenv("ATRYUM_MCP_DEBUG", "1") diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 921bfaa..a009a22 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "os/exec" + "regexp" "sort" "strconv" "strings" @@ -95,6 +96,7 @@ type AuthHeader struct { type Upstream struct { Name string + EndpointSlug string Mode UpstreamMode BaseURL string AuthToken string @@ -187,6 +189,7 @@ func DecodeAuthHeaders(env map[string]string) []AuthHeader { type ServerStore interface { GetServer(ctx context.Context, name string) (Upstream, error) + GetServerByEndpointSlug(ctx context.Context, slug string) (Upstream, error) ListServers(ctx context.Context, filter ServerFilter) ([]Upstream, int, error) CountServers(ctx context.Context) (int, error) CreateServer(ctx context.Context, upstream Upstream) error @@ -198,6 +201,18 @@ type ServerFilter struct { Enabled *bool } +var endpointSlugInvalidChars = regexp.MustCompile(`[^a-z0-9._~-]+`) + +func EndpointSlug(name string) string { + slug := strings.ToLower(strings.TrimSpace(name)) + slug = endpointSlugInvalidChars.ReplaceAllString(slug, "-") + slug = strings.Trim(slug, "-") + if strings.Trim(slug, ".") == "" { + return "" + } + return slug +} + // CredentialStore returns the OAuth access token currently stored for a // server. It is consulted on every ResolveContext so that proxied MCP // requests (tools/list, tools/call, forward) get the live access token @@ -310,9 +325,24 @@ func (r *Resolver) ResolveContext(ctx context.Context, name string) (Upstream, e if err != nil && err != sql.ErrNoRows { return Upstream{}, err } + if slug := EndpointSlug(name); slug != "" { + upstream, err := r.store.GetServerByEndpointSlug(ctx, slug) + if err == nil { + return r.overlayCredential(ctx, upstream), nil + } + if err != nil && err != sql.ErrNoRows { + return Upstream{}, err + } + } } upstream, ok := r.bootstrap[name] if !ok || !upstream.Enabled { + slug := EndpointSlug(name) + for _, candidate := range r.bootstrap { + if candidate.Enabled && EndpointSlug(candidate.Name) == slug { + return r.overlayCredential(ctx, candidate), nil + } + } return Upstream{}, fmt.Errorf("upstream %q not configured or disabled", name) } return r.overlayCredential(ctx, upstream), nil diff --git a/internal/mcp/client_test.go b/internal/mcp/client_test.go index 7824838..367cc6b 100644 --- a/internal/mcp/client_test.go +++ b/internal/mcp/client_test.go @@ -3,6 +3,7 @@ package mcp import ( "bytes" "context" + "database/sql" "encoding/json" "io" "log" @@ -14,8 +15,89 @@ import ( "sync/atomic" "testing" "time" + + "atryum/internal/config" ) +type fakeServerStore struct { + upstreams []Upstream +} + +func (s fakeServerStore) GetServer(_ context.Context, name string) (Upstream, error) { + for _, upstream := range s.upstreams { + if upstream.Name == name && upstream.Enabled { + return upstream, nil + } + } + return Upstream{}, sql.ErrNoRows +} + +func (s fakeServerStore) GetServerByEndpointSlug(_ context.Context, slug string) (Upstream, error) { + for _, upstream := range s.upstreams { + endpointSlug := upstream.EndpointSlug + if endpointSlug == "" { + endpointSlug = EndpointSlug(upstream.Name) + } + if endpointSlug == slug && upstream.Enabled { + return upstream, nil + } + } + return Upstream{}, sql.ErrNoRows +} + +func (s fakeServerStore) ListServers(_ context.Context, filter ServerFilter) ([]Upstream, int, error) { + var out []Upstream + for _, upstream := range s.upstreams { + if filter.Enabled != nil && upstream.Enabled != *filter.Enabled { + continue + } + out = append(out, upstream) + } + return out, len(out), nil +} + +func (s fakeServerStore) CountServers(context.Context) (int, error) { + return len(s.upstreams), nil +} + +func (s fakeServerStore) CreateServer(context.Context, Upstream) error { + return nil +} + +func TestEndpointSlug(t *testing.T) { + tests := map[string]string{ + "Slack Local": "slack-local", + " GitHub MCP ": "github-mcp", + "Foo_Bar.v1~dev": "foo_bar.v1~dev", + "!!!": "", + ".": "", + "...": "", + } + for input, want := range tests { + if got := EndpointSlug(input); got != want { + t.Fatalf("EndpointSlug(%q) = %q, want %q", input, got, want) + } + } +} + +func TestResolverResolvesServerByEndpointSlug(t *testing.T) { + resolver := NewResolver(fakeServerStore{upstreams: []Upstream{{ + Name: "Slack Local", + EndpointSlug: "slack-local-2", + Mode: UpstreamModeHTTP, + BaseURL: "https://mcp.slack.test", + Enabled: true, + }}}, config.Config{}) + + upstream, err := resolver.ResolveContext(context.Background(), "slack-local-2") + if err != nil { + t.Fatalf("ResolveContext: %v", err) + } + if upstream.Name != "Slack Local" { + t.Fatalf("resolved name = %q, want Slack Local", upstream.Name) + } +} + func TestConnectionDebugLogsHTTPProbeWithoutSecrets(t *testing.T) { var logs bytes.Buffer prevWriter := log.Writer() diff --git a/internal/store/db_test.go b/internal/store/db_test.go index ca9031d..ba8aa05 100644 --- a/internal/store/db_test.go +++ b/internal/store/db_test.go @@ -46,7 +46,7 @@ func TestResolveDBTarget_SelectsSQLiteForSQLiteFileAndBarePaths(t *testing.T) { } func TestMigrationRegistryPreservesExistingVersionsAndNames(t *testing.T) { - if len(migrations) != 23 { + if len(migrations) != 24 { t.Fatalf("migration count = %d", len(migrations)) } want := []struct { @@ -76,6 +76,7 @@ func TestMigrationRegistryPreservesExistingVersionsAndNames(t *testing.T) { {21, "021_rename_constitution_to_charter"}, {22, "022_drop_agent_id_pattern"}, {23, "023_managed_agent_bindings"}, + {24, "024_server_endpoint_slug"}, } for i, w := range want { if migrations[i].Version != w.version || migrations[i].Name != w.name { @@ -86,10 +87,10 @@ func TestMigrationRegistryPreservesExistingVersionsAndNames(t *testing.T) { func TestGetPendingMigrationsUsesRegistryOrder(t *testing.T) { pending := getPendingMigrations(map[int]bool{1: true}) - if len(pending) != 22 { + if len(pending) != 23 { t.Fatalf("pending count = %d", len(pending)) } - wantVersions := []int{2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23} + wantVersions := []int{2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24} for i, want := range wantVersions { if pending[i].Version != want { t.Fatalf("pending[%d].Version = %d, want %d", i, pending[i].Version, want) diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 85e68b5..610df2b 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -1,9 +1,10 @@ package store import ( - storemigrations "atryum/internal/store/migrations" "database/sql" "fmt" + + storemigrations "atryum/internal/store/migrations" ) // migrationRecord tracks applied migrations. @@ -21,6 +22,7 @@ type migration struct { type migrationStep struct { Description string Build func(dialect Dialect) (string, []any, error) + Run func(tx *sql.Tx, dialect Dialect) error } var migrations = loadMigrations() @@ -32,12 +34,21 @@ func loadMigrations() []migration { steps := make([]migrationStep, 0, len(definition.Steps)) for _, step := range definition.Steps { build := step.Build - steps = append(steps, migrationStep{ + run := step.Run + loadedStep := migrationStep{ Description: step.Description, - Build: func(dialect Dialect) (string, []any, error) { + } + if build != nil { + loadedStep.Build = func(dialect Dialect) (string, []any, error) { return build(dialect == DialectPostgres) - }, - }) + } + } + if run != nil { + loadedStep.Run = func(tx *sql.Tx, dialect Dialect) error { + return run(tx, dialect == DialectPostgres) + } + } + steps = append(steps, loadedStep) } loaded = append(loaded, migration{ Version: definition.Version, @@ -133,6 +144,12 @@ func applyMigration(db *sql.DB, m migration, dialect Dialect) error { defer tx.Rollback() for _, step := range m.Steps { + if step.Run != nil { + if err := step.Run(tx, dialect); err != nil { + return fmt.Errorf("run step %q: %w", step.Description, err) + } + continue + } query, args, err := step.Build(dialect) if err != nil { return fmt.Errorf("build step %q: %w", step.Description, err) diff --git a/internal/store/migrations/024_server_endpoint_slug.go b/internal/store/migrations/024_server_endpoint_slug.go new file mode 100644 index 0000000..b31681a --- /dev/null +++ b/internal/store/migrations/024_server_endpoint_slug.go @@ -0,0 +1,89 @@ +package migrations + +import ( + "database/sql" + "fmt" + "sort" + + "atryum/internal/mcp" +) + +func migration024() Definition { + return Definition{ + Version: 24, + Name: "024_server_endpoint_slug", + Steps: []Step{ + Raw("add server endpoint_slug", ` + ALTER TABLE mcp_servers ADD COLUMN endpoint_slug TEXT NOT NULL DEFAULT '' + `), + Custom("backfill server endpoint_slug", backfillServerEndpointSlugs), + Raw("create unique server endpoint_slug index", ` + CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_servers_endpoint_slug ON mcp_servers(endpoint_slug) + `), + }, + } +} + +func backfillServerEndpointSlugs(tx *sql.Tx, usePostgres bool) error { + rows, err := tx.Query(`SELECT name, endpoint_slug FROM mcp_servers`) + if err != nil { + return err + } + type serverEndpointSlugRow struct { + name string + } + var rowsToBackfill []serverEndpointSlugRow + used := map[string]bool{} + for rows.Next() { + var name, endpointSlug string + if err := rows.Scan(&name, &endpointSlug); err != nil { + rows.Close() + return err + } + if endpointSlug != "" { + used[endpointSlug] = true + continue + } + rowsToBackfill = append(rowsToBackfill, serverEndpointSlugRow{name: name}) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + + sort.Slice(rowsToBackfill, func(i, j int) bool { + return rowsToBackfill[i].name < rowsToBackfill[j].name + }) + + updateSQL := `UPDATE mcp_servers SET endpoint_slug = ? WHERE name = ?` + if usePostgres { + updateSQL = `UPDATE mcp_servers SET endpoint_slug = $1 WHERE name = $2` + } + for _, row := range rowsToBackfill { + slug := uniqueEndpointSlug(mcp.EndpointSlug(row.name), used) + if _, err := tx.Exec(updateSQL, slug, row.name); err != nil { + return err + } + } + return nil +} + +func uniqueEndpointSlug(base string, used map[string]bool) string { + if base == "" { + base = "server" + } + if !used[base] { + used[base] = true + return base + } + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s-%d", base, i) + if !used[candidate] { + used[candidate] = true + return candidate + } + } +} diff --git a/internal/store/migrations/registry.go b/internal/store/migrations/registry.go index bf2965b..e632d2a 100644 --- a/internal/store/migrations/registry.go +++ b/internal/store/migrations/registry.go @@ -1,5 +1,7 @@ package migrations +import "database/sql" + type Definition struct { Version int Name string @@ -9,6 +11,7 @@ type Definition struct { type Step struct { Description string Build func(usePostgres bool) (string, []any, error) + Run func(tx *sql.Tx, usePostgres bool) error } func Raw(description, query string) Step { @@ -32,6 +35,13 @@ func RawDialect(description, sqliteQuery, postgresQuery string) Step { } } +func Custom(description string, run func(tx *sql.Tx, usePostgres bool) error) Step { + return Step{ + Description: description, + Run: run, + } +} + func All() []Definition { return []Definition{ migration001(), @@ -57,5 +67,6 @@ func All() []Definition { migration021(), migration022(), migration023(), + migration024(), } } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 4fb1950..13efaa2 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -236,10 +236,14 @@ func (r *ServerRepo) CreateServer(ctx context.Context, upstream mcp.Upstream) er if err != nil { return err } + endpointSlug := upstream.EndpointSlug + if endpointSlug == "" { + endpointSlug = mcp.EndpointSlug(upstream.Name) + } query, args, err := r.sb.Insert("mcp_servers").Columns( - "name", "mode", "base_url", "auth_token", "timeout_seconds", "command", "args_json", "env_json", "enabled", "auth_type", "connection_status", "auth_status", "reauth_needed", "last_checked_at", "last_check_ok", "last_error_summary", "action_required", "oauth_provider_id", "oauth_provider_label", "oauth_authorize_url", "oauth_token_url", "oauth_client_id", "oauth_client_secret", "oauth_scopes", "oauth_client_registration", "created_at", "updated_at", + "name", "endpoint_slug", "mode", "base_url", "auth_token", "timeout_seconds", "command", "args_json", "env_json", "enabled", "auth_type", "connection_status", "auth_status", "reauth_needed", "last_checked_at", "last_check_ok", "last_error_summary", "action_required", "oauth_provider_id", "oauth_provider_label", "oauth_authorize_url", "oauth_token_url", "oauth_client_id", "oauth_client_secret", "oauth_scopes", "oauth_client_registration", "created_at", "updated_at", ).Values( - upstream.Name, string(upstream.Mode), emptyToNil(upstream.BaseURL), emptyToNil(upstream.AuthToken), int(upstream.Timeout/time.Second), emptyToNil(upstream.Command), argsJSON, envJSON, boolToInt(upstream.Enabled), string(upstream.Status.AuthType), string(upstream.Status.ConnectionStatus), string(upstream.Status.AuthStatus), boolToInt(upstream.Status.ReauthNeeded), upstream.Status.LastCheckedAt, boolToInt(upstream.Status.LastCheckOK), emptyToNil(derefString(upstream.Status.LastErrorSummary)), emptyToNil(derefString(upstream.Status.ActionRequired)), emptyToNil(upstream.OAuthProviderID), emptyToNil(upstream.OAuthProviderLabel), emptyToNil(upstream.OAuthAuthorizeURL), emptyToNil(upstream.OAuthTokenURL), emptyToNil(upstream.OAuthClientID), emptyToNil(upstream.OAuthClientSecret), emptyToNil(upstream.OAuthScopes), emptyToNil(string(upstream.OAuthClientRegistration)), now, now, + upstream.Name, endpointSlug, string(upstream.Mode), emptyToNil(upstream.BaseURL), emptyToNil(upstream.AuthToken), int(upstream.Timeout/time.Second), emptyToNil(upstream.Command), argsJSON, envJSON, boolToInt(upstream.Enabled), string(upstream.Status.AuthType), string(upstream.Status.ConnectionStatus), string(upstream.Status.AuthStatus), boolToInt(upstream.Status.ReauthNeeded), upstream.Status.LastCheckedAt, boolToInt(upstream.Status.LastCheckOK), emptyToNil(derefString(upstream.Status.LastErrorSummary)), emptyToNil(derefString(upstream.Status.ActionRequired)), emptyToNil(upstream.OAuthProviderID), emptyToNil(upstream.OAuthProviderLabel), emptyToNil(upstream.OAuthAuthorizeURL), emptyToNil(upstream.OAuthTokenURL), emptyToNil(upstream.OAuthClientID), emptyToNil(upstream.OAuthClientSecret), emptyToNil(upstream.OAuthScopes), emptyToNil(string(upstream.OAuthClientRegistration)), now, now, ).ToSql() if err != nil { return err @@ -254,8 +258,13 @@ func (r *ServerRepo) UpsertServer(ctx context.Context, upstream mcp.Upstream) er return err } now := time.Now().UTC() + endpointSlug := upstream.EndpointSlug + if endpointSlug == "" { + endpointSlug = mcp.EndpointSlug(upstream.Name) + } updateMap := map[string]interface{}{ + "endpoint_slug": endpointSlug, "mode": string(upstream.Mode), "base_url": emptyToNil(upstream.BaseURL), "auth_token": emptyToNil(upstream.AuthToken), @@ -285,13 +294,13 @@ func (r *ServerRepo) UpsertServer(ctx context.Context, upstream mcp.Upstream) er query, args, err := r.sb.Insert("mcp_servers"). Columns( - "name", "mode", "base_url", "auth_token", "timeout_seconds", "command", "args_json", "env_json", "enabled", + "name", "endpoint_slug", "mode", "base_url", "auth_token", "timeout_seconds", "command", "args_json", "env_json", "enabled", "auth_type", "connection_status", "auth_status", "reauth_needed", "last_checked_at", "last_check_ok", "last_error_summary", "action_required", "oauth_provider_id", "oauth_provider_label", "oauth_authorize_url", "oauth_token_url", "oauth_client_id", "oauth_client_secret", "oauth_scopes", "oauth_client_registration", "created_at", "updated_at", ). Values( - upstream.Name, updateMap["mode"], updateMap["base_url"], updateMap["auth_token"], updateMap["timeout_seconds"], + upstream.Name, updateMap["endpoint_slug"], updateMap["mode"], updateMap["base_url"], updateMap["auth_token"], updateMap["timeout_seconds"], updateMap["command"], argsJSON, envJSON, updateMap["enabled"], updateMap["auth_type"], updateMap["connection_status"], updateMap["auth_status"], updateMap["reauth_needed"], updateMap["last_checked_at"], updateMap["last_check_ok"], updateMap["last_error_summary"], updateMap["action_required"], @@ -335,7 +344,16 @@ func (r *ServerRepo) UpdateServerStatus(ctx context.Context, name string, status } func (r *ServerRepo) GetServer(ctx context.Context, name string) (mcp.Upstream, error) { - query, args, err := r.sb.Select("name", "mode", "base_url", "auth_token", "timeout_seconds", "command", "args_json", "env_json", "enabled", "auth_type", "connection_status", "auth_status", "reauth_needed", "last_checked_at", "last_check_ok", "last_error_summary", "action_required", "oauth_provider_id", "oauth_provider_label", "oauth_authorize_url", "oauth_token_url", "oauth_client_id", "oauth_client_secret", "oauth_scopes", "oauth_client_registration").From("mcp_servers").Where(sq.Eq{"name": name, "enabled": 1}).ToSql() + query, args, err := r.sb.Select(serverSelectColumns()...).From("mcp_servers").Where(sq.Eq{"name": name, "enabled": 1}).ToSql() + if err != nil { + return mcp.Upstream{}, err + } + row := r.db.QueryRowContext(ctx, query, args...) + return scanServer(row) +} + +func (r *ServerRepo) GetServerByEndpointSlug(ctx context.Context, slug string) (mcp.Upstream, error) { + query, args, err := r.sb.Select(serverSelectColumns()...).From("mcp_servers").Where(sq.Eq{"endpoint_slug": slug, "enabled": 1}).ToSql() if err != nil { return mcp.Upstream{}, err } @@ -344,7 +362,16 @@ func (r *ServerRepo) GetServer(ctx context.Context, name string) (mcp.Upstream, } func (r *ServerRepo) GetServerAny(ctx context.Context, name string) (mcp.Upstream, error) { - query, args, err := r.sb.Select("name", "mode", "base_url", "auth_token", "timeout_seconds", "command", "args_json", "env_json", "enabled", "auth_type", "connection_status", "auth_status", "reauth_needed", "last_checked_at", "last_check_ok", "last_error_summary", "action_required", "oauth_provider_id", "oauth_provider_label", "oauth_authorize_url", "oauth_token_url", "oauth_client_id", "oauth_client_secret", "oauth_scopes", "oauth_client_registration").From("mcp_servers").Where(sq.Eq{"name": name}).ToSql() + query, args, err := r.sb.Select(serverSelectColumns()...).From("mcp_servers").Where(sq.Eq{"name": name}).ToSql() + if err != nil { + return mcp.Upstream{}, err + } + row := r.db.QueryRowContext(ctx, query, args...) + return scanServer(row) +} + +func (r *ServerRepo) GetServerByEndpointSlugAny(ctx context.Context, slug string) (mcp.Upstream, error) { + query, args, err := r.sb.Select(serverSelectColumns()...).From("mcp_servers").Where(sq.Eq{"endpoint_slug": slug}).ToSql() if err != nil { return mcp.Upstream{}, err } @@ -356,7 +383,7 @@ func (r *ServerRepo) ListServers(ctx context.Context, filter mcp.ServerFilter) ( if filter.Limit == 0 { filter.Limit = 50 } - builder := r.sb.Select("name", "mode", "base_url", "auth_token", "timeout_seconds", "command", "args_json", "env_json", "enabled", "auth_type", "connection_status", "auth_status", "reauth_needed", "last_checked_at", "last_check_ok", "last_error_summary", "action_required", "oauth_provider_id", "oauth_provider_label", "oauth_authorize_url", "oauth_token_url", "oauth_client_id", "oauth_client_secret", "oauth_scopes", "oauth_client_registration").From("mcp_servers") + builder := r.sb.Select(serverSelectColumns()...).From("mcp_servers") countBuilder := r.sb.Select("COUNT(*)").From("mcp_servers") if filter.Enabled != nil { value := boolToInt(*filter.Enabled) @@ -589,6 +616,7 @@ func scanInvocation(scanner interface{ Scan(dest ...any) error }) (invocation.In func scanServer(scanner interface{ Scan(dest ...any) error }) (mcp.Upstream, error) { var upstream mcp.Upstream var mode string + var endpointSlug string var baseURL, authToken, command sql.NullString var timeoutSeconds int var argsJSON, envJSON string @@ -598,9 +626,10 @@ func scanServer(scanner interface{ Scan(dest ...any) error }) (mcp.Upstream, err var lastCheckedAt sql.NullTime var lastErrorSummary, actionRequired sql.NullString var oauthProviderID, oauthProviderLabel, oauthAuthorizeURL, oauthTokenURL, oauthClientID, oauthClientSecret, oauthScopes, oauthClientRegistration sql.NullString - if err := scanner.Scan(&upstream.Name, &mode, &baseURL, &authToken, &timeoutSeconds, &command, &argsJSON, &envJSON, &enabled, &authType, &connectionStatus, &authStatus, &reauthNeeded, &lastCheckedAt, &lastCheckOK, &lastErrorSummary, &actionRequired, &oauthProviderID, &oauthProviderLabel, &oauthAuthorizeURL, &oauthTokenURL, &oauthClientID, &oauthClientSecret, &oauthScopes, &oauthClientRegistration); err != nil { + if err := scanner.Scan(&upstream.Name, &endpointSlug, &mode, &baseURL, &authToken, &timeoutSeconds, &command, &argsJSON, &envJSON, &enabled, &authType, &connectionStatus, &authStatus, &reauthNeeded, &lastCheckedAt, &lastCheckOK, &lastErrorSummary, &actionRequired, &oauthProviderID, &oauthProviderLabel, &oauthAuthorizeURL, &oauthTokenURL, &oauthClientID, &oauthClientSecret, &oauthScopes, &oauthClientRegistration); err != nil { return mcp.Upstream{}, err } + upstream.EndpointSlug = endpointSlug upstream.Mode = mcp.UpstreamMode(mode) upstream.BaseURL = baseURL.String upstream.AuthToken = authToken.String @@ -644,6 +673,37 @@ func scanServer(scanner interface{ Scan(dest ...any) error }) (mcp.Upstream, err return upstream, nil } +func serverSelectColumns() []string { + return []string{ + "name", + "endpoint_slug", + "mode", + "base_url", + "auth_token", + "timeout_seconds", + "command", + "args_json", + "env_json", + "enabled", + "auth_type", + "connection_status", + "auth_status", + "reauth_needed", + "last_checked_at", + "last_check_ok", + "last_error_summary", + "action_required", + "oauth_provider_id", + "oauth_provider_label", + "oauth_authorize_url", + "oauth_token_url", + "oauth_client_id", + "oauth_client_secret", + "oauth_scopes", + "oauth_client_registration", + } +} + func applyInvocationFilter(builder sq.SelectBuilder, countBuilder sq.SelectBuilder, filter invocation.InvocationListFilter) (sq.SelectBuilder, sq.SelectBuilder) { if filter.Server != "" { builder = builder.Where(sq.Eq{"upstream_name": filter.Server}) diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index 969b769..6af71f1 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -44,8 +44,8 @@ func TestInitDB_FreshDatabase(t *testing.T) { if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&count); err != nil { t.Fatalf("count migrations: %v", err) } - if count != 23 { - t.Fatalf("expected 23 migrations, got %d", count) + if count != 24 { + t.Fatalf("expected 24 migrations, got %d", count) } // Verify all tables exist @@ -73,8 +73,8 @@ func TestInitDB_Idempotent(t *testing.T) { if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&count); err != nil { t.Fatalf("count migrations: %v", err) } - if count != 23 { - t.Fatalf("expected 23 migrations after double init, got %d", count) + if count != 24 { + t.Fatalf("expected 24 migrations after double init, got %d", count) } } @@ -384,6 +384,9 @@ func TestServerRepo_CRUD(t *testing.T) { if got.Name != "test-server" || got.Mode != mcp.UpstreamModeHTTP { t.Errorf("GetServer mismatch: %+v", got) } + if got.EndpointSlug != "test-server" { + t.Errorf("endpoint slug: %s", got.EndpointSlug) + } // GetServerAny got2, err := repo.GetServerAny(ctx, "test-server") @@ -394,6 +397,14 @@ func TestServerRepo_CRUD(t *testing.T) { t.Errorf("GetServerAny: %s", got2.Name) } + gotBySlug, err := repo.GetServerByEndpointSlug(ctx, "test-server") + if err != nil { + t.Fatalf("GetServerByEndpointSlug: %v", err) + } + if gotBySlug.Name != "test-server" { + t.Errorf("GetServerByEndpointSlug: %s", gotBySlug.Name) + } + // List servers, total, err := repo.ListServers(ctx, mcp.ServerFilter{Limit: 10}) if err != nil { @@ -457,6 +468,128 @@ func TestServerRepo_CRUD(t *testing.T) { } } +func TestServerRepoEndpointSlugIsUnique(t *testing.T) { + db, cleanup := openTestDB(t) + defer cleanup() + if err := InitDB(db); err != nil { + t.Fatalf("InitDB: %v", err) + } + + repo := NewServerRepo(db) + ctx := context.Background() + base := mcp.Upstream{ + Name: "Slack Local", + Mode: mcp.UpstreamModeHTTP, + BaseURL: "http://localhost:8080", + Timeout: 30 * time.Second, + Enabled: true, + } + if err := repo.CreateServer(ctx, base); err != nil { + t.Fatalf("CreateServer first: %v", err) + } + duplicate := base + duplicate.Name = "slack-local" + if err := repo.CreateServer(ctx, duplicate); err == nil { + t.Fatalf("expected duplicate endpoint slug error") + } +} + +func TestServerRepoUpsertKeepsEndpointSlugImmutable(t *testing.T) { + db, cleanup := openTestDB(t) + defer cleanup() + if err := InitDB(db); err != nil { + t.Fatalf("InitDB: %v", err) + } + + repo := NewServerRepo(db) + ctx := context.Background() + upstream := mcp.Upstream{ + Name: "Slack Local", + EndpointSlug: "slack-local-2", + Mode: mcp.UpstreamModeHTTP, + BaseURL: "http://localhost:8080", + Timeout: 30 * time.Second, + Enabled: true, + } + if err := repo.UpsertServer(ctx, upstream); err != nil { + t.Fatalf("UpsertServer insert: %v", err) + } + upstream.BaseURL = "http://localhost:9090" + upstream.EndpointSlug = "slack-local" + if err := repo.UpsertServer(ctx, upstream); err != nil { + t.Fatalf("UpsertServer update: %v", err) + } + got, err := repo.GetServerAny(ctx, "Slack Local") + if err != nil { + t.Fatalf("GetServerAny: %v", err) + } + if got.EndpointSlug != "slack-local-2" { + t.Fatalf("endpoint slug = %q, want immutable slack-local-2", got.EndpointSlug) + } + if got.BaseURL != "http://localhost:9090" { + t.Fatalf("base_url = %q, want updated URL", got.BaseURL) + } +} + +func TestInitDBBackfillsEndpointSlugCollisionsDeterministically(t *testing.T) { + db, cleanup := openTestDB(t) + defer cleanup() + + if _, err := db.Exec(` + CREATE TABLE schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE mcp_servers ( + name TEXT PRIMARY KEY + ); + INSERT INTO mcp_servers(name) VALUES ('Slack Local'), ('slack-local'), ('!!!'); + `); err != nil { + t.Fatalf("seed old schema: %v", err) + } + for _, m := range migrations { + if m.Version >= 24 { + continue + } + if _, err := db.Exec(`INSERT INTO schema_migrations(version, name) VALUES (?, ?)`, m.Version, m.Name); err != nil { + t.Fatalf("seed migration %d: %v", m.Version, err) + } + } + + if err := InitDB(db); err != nil { + t.Fatalf("InitDB: %v", err) + } + + got := map[string]string{} + rows, err := db.Query(`SELECT name, endpoint_slug FROM mcp_servers`) + if err != nil { + t.Fatalf("query slugs: %v", err) + } + defer rows.Close() + for rows.Next() { + var name, slug string + if err := rows.Scan(&name, &slug); err != nil { + t.Fatalf("scan slug: %v", err) + } + got[name] = slug + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + + want := map[string]string{ + "!!!": "server", + "Slack Local": "slack-local", + "slack-local": "slack-local-2", + } + for name, wantSlug := range want { + if got[name] != wantSlug { + t.Fatalf("slug for %q = %q, want %q (all slugs: %+v)", name, got[name], wantSlug, got) + } + } +} + func TestServerRepo_UpsertNew(t *testing.T) { db, cleanup := openTestDB(t) defer cleanup() diff --git a/ui/src/api/AtryumAPI.ts b/ui/src/api/AtryumAPI.ts index 08b7960..a5ad053 100644 --- a/ui/src/api/AtryumAPI.ts +++ b/ui/src/api/AtryumAPI.ts @@ -6,6 +6,30 @@ export const atryumApi = axios.create({ headers: { 'Content-Type': 'application/json' }, }); +export const apiErrorMessage = (err: unknown, fallback: string): string => { + if (typeof err === 'object' && err !== null && 'response' in err) { + const data = (err as { + response?: { data?: { error?: string | { message?: unknown } } }; + }).response?.data; + const apiError = data?.error; + if (typeof apiError === 'string' && apiError) return apiError; + if ( + typeof apiError === 'object' && + apiError !== null && + typeof apiError.message === 'string' && + apiError.message + ) { + return apiError.message; + } + } + if (err instanceof Error && err.message) return err.message; + if (typeof err === 'object' && err !== null && 'message' in err) { + const msg = (err as { message: unknown }).message; + if (typeof msg === 'string' && msg) return msg; + } + return fallback; +}; + type RetryableRequestConfig = InternalAxiosRequestConfig & { _atryumAuthRetried?: boolean; }; @@ -180,6 +204,8 @@ export interface AuthHeader { export interface Server { name: string; + endpoint_slug: string; + endpoint_url?: string; mode: ServerMode; connection_status: ConnectionStatus; auth_status: AuthStatus; diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 8f3c06e..7e1b1bf 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -48,7 +48,7 @@ import type { ClaudeManagedAgent, ClaudeManagedAgentBinding, } from '../api/AtryumAPI'; -import { agentsApi } from '../api/AtryumAPI'; +import { agentsApi, apiErrorMessage } from '../api/AtryumAPI'; type SelectOption = { value: string; label: string }; type ManagedAgentOption = { @@ -72,21 +72,6 @@ const formatDate = (iso: string): string => { } }; -const errorMessage = (err: unknown, fallback: string): string => { - // Prefer the API response body: { error: { message: "..." } } - if (typeof err === 'object' && err !== null) { - const apiMsg = (err as { response?: { data?: { error?: { message?: unknown } } } }).response - ?.data?.error?.message; - if (typeof apiMsg === 'string' && apiMsg) return apiMsg; - } - if (err instanceof Error) return err.message; - if (typeof err === 'object' && err !== null && 'message' in err) { - const msg = (err as { message: unknown }).message; - if (typeof msg === 'string') return msg; - } - return fallback; -}; - const statusCode = (err: unknown): number | undefined => { if (typeof err !== 'object' || err === null || !('response' in err)) return undefined; return (err as { response?: { status?: number } }).response?.status; @@ -166,7 +151,7 @@ const CreateAgentModal: React.FC = ({ isOpen, onClose }) await createMutation.mutateAsync(form); handleClose(); } catch (err: unknown) { - setStatusMsg({ text: errorMessage(err, 'Create failed.'), isError: true }); + setStatusMsg({ text: apiErrorMessage(err, 'Create failed.'), isError: true }); } }; @@ -361,7 +346,7 @@ const EditAgentModal: React.FC = ({ agent, isOpen, onClose setStatusMsg(null); onClose(); } catch (err: unknown) { - setStatusMsg({ text: errorMessage(err, 'Update failed.'), isError: true }); + setStatusMsg({ text: apiErrorMessage(err, 'Update failed.'), isError: true }); } }; @@ -371,7 +356,7 @@ const EditAgentModal: React.FC = ({ agent, isOpen, onClose await deleteMutation.mutateAsync(agent.cuid); onClose(); } catch (err: unknown) { - setStatusMsg({ text: errorMessage(err, 'Delete failed.'), isError: true }); + setStatusMsg({ text: apiErrorMessage(err, 'Delete failed.'), isError: true }); } }; @@ -519,7 +504,7 @@ const EditAgentModal: React.FC = ({ agent, isOpen, onClose - {errorMessage(managedAgentsQuery.error, 'Failed to load Claude Managed Agents.')} + {apiErrorMessage(managedAgentsQuery.error, 'Failed to load Claude Managed Agents.')} )} diff --git a/ui/src/pages/Rules.tsx b/ui/src/pages/Rules.tsx index 3c21c43..8c214e4 100644 --- a/ui/src/pages/Rules.tsx +++ b/ui/src/pages/Rules.tsx @@ -50,7 +50,7 @@ import { useServers } from '../hooks/useServers'; import { useAgents } from '../hooks/useAgents'; import { useSettings } from '../hooks/useSettings'; import { useLLMConfigs } from '../hooks/useLLMConfigs'; -import { type Agent, type LLMConfig, type Rule, type RuleAction, type RuleInput, modelConfigsApi } from '../api/AtryumAPI'; +import { type Agent, type LLMConfig, type Rule, type RuleAction, type RuleInput, apiErrorMessage, modelConfigsApi } from '../api/AtryumAPI'; const ACTION_COLOR: Record = { auto_approve: 'green', @@ -109,15 +109,6 @@ const formatCreateLabel = (input: string): string => `Add "${input}"`; const patternLabel = (patterns: string[]): string => patterns.length === 0 ? 'all' : patterns.join(', '); -const errorMessage = (err: unknown, fallback: string): string => { - if (err instanceof Error) return err.message; - if (typeof err === 'object' && err !== null && 'message' in err) { - const msg = (err as { message: unknown }).message; - if (typeof msg === 'string') return msg; - } - return fallback; -}; - const stopPropagation = (e: React.MouseEvent): void => e.stopPropagation(); type RuleRowProps = { @@ -329,7 +320,7 @@ const Rules: React.FC = () => { handleClose(); } } catch (err: unknown) { - setStatusMsg({ text: errorMessage(err, 'Save failed.'), isError: true }); + setStatusMsg({ text: apiErrorMessage(err, 'Save failed.'), isError: true }); } }, [createRule, form, handleClose, isCreating, selectedId, updateRule]); @@ -340,7 +331,7 @@ const Rules: React.FC = () => { await removeRule.mutateAsync(selectedId); handleClose(); } catch (err: unknown) { - setStatusMsg({ text: errorMessage(err, 'Delete failed.'), isError: true }); + setStatusMsg({ text: apiErrorMessage(err, 'Delete failed.'), isError: true }); } }, [handleClose, removeRule, selectedId]); diff --git a/ui/src/pages/Servers.tsx b/ui/src/pages/Servers.tsx index 5d4f422..305b752 100644 --- a/ui/src/pages/Servers.tsx +++ b/ui/src/pages/Servers.tsx @@ -54,6 +54,7 @@ import { type Server, type ServerInput, type ServerMode, + apiErrorMessage, } from '../api/AtryumAPI'; const CONNECTION_COLOR: Record = { @@ -88,15 +89,6 @@ const EMPTY_FORM: ServerInput = { oauth_scopes: '', }; -const errorMessage = (err: unknown, fallback: string): string => { - if (err instanceof Error) return err.message; - if (typeof err === 'object' && err !== null && 'message' in err) { - const msg = (err as { message: unknown }).message; - if (typeof msg === 'string') return msg; - } - return fallback; -}; - const serverToInput = (s: Server): ServerInput => ({ name: s.name, mode: s.mode, @@ -115,6 +107,14 @@ const serverToInput = (s: Server): ServerInput => ({ oauth_scopes: s.oauth_scopes ?? '', }); +const endpointLabel = (server: Server): string => { + const endpoint = server.endpoint_url || (server.endpoint_slug ? `/mcp/${server.endpoint_slug}` : ''); + if (endpoint.startsWith('/') && typeof window !== 'undefined') { + return `${window.location.origin}${endpoint}`; + } + return endpoint; +}; + const Servers: React.FC = () => { const [showDisabled, setShowDisabled] = useState(true); const [selectedName, setSelectedName] = useState(null); @@ -216,13 +216,25 @@ const Servers: React.FC = () => { const saved = await createServer.mutateAsync(payload); setSelectedName(saved.name); setIsCreating(false); - setStatusMsg({ text: `Created server "${saved.name}".`, isError: false }); + const endpoint = endpointLabel(saved); + setStatusMsg({ + text: endpoint + ? `Created server "${saved.name}". MCP endpoint: ${endpoint}` + : `Created server "${saved.name}".`, + isError: false, + }); } else { const saved = await updateServer.mutateAsync({ name: selectedName!, input: payload }); - setStatusMsg({ text: `Saved server "${saved.name}".`, isError: false }); + const endpoint = endpointLabel(saved); + setStatusMsg({ + text: endpoint + ? `Saved server "${saved.name}". MCP endpoint: ${endpoint}` + : `Saved server "${saved.name}".`, + isError: false, + }); } } catch (err: unknown) { - setStatusMsg({ text: errorMessage(err, 'Save failed.'), isError: true }); + setStatusMsg({ text: apiErrorMessage(err, 'Save failed.'), isError: true }); } }; @@ -232,7 +244,7 @@ const Servers: React.FC = () => { const result = await testServer.mutateAsync(selectedName); setStatusMsg({ text: result.message, isError: !result.ok }); } catch (err: unknown) { - setStatusMsg({ text: errorMessage(err, 'Test failed.'), isError: true }); + setStatusMsg({ text: apiErrorMessage(err, 'Test failed.'), isError: true }); } }; @@ -251,7 +263,7 @@ const Servers: React.FC = () => { setForm((f) => ({ ...f, enabled: true })); } } catch (err: unknown) { - setStatusMsg({ text: errorMessage(err, 'Toggle failed.'), isError: true }); + setStatusMsg({ text: apiErrorMessage(err, 'Toggle failed.'), isError: true }); } }; @@ -263,7 +275,7 @@ const Servers: React.FC = () => { setConnectPolling(true); setStatusMsg({ text: 'OAuth flow opened — waiting for completion…', isError: false }); } catch (err: unknown) { - setStatusMsg({ text: errorMessage(err, 'Connect failed.'), isError: true }); + setStatusMsg({ text: apiErrorMessage(err, 'Connect failed.'), isError: true }); } }; @@ -276,7 +288,7 @@ const Servers: React.FC = () => { setSelectedName(null); setIsCreating(false); } catch (err: unknown) { - setStatusMsg({ text: errorMessage(err, 'Delete failed.'), isError: true }); + setStatusMsg({ text: apiErrorMessage(err, 'Delete failed.'), isError: true }); } }; @@ -459,6 +471,18 @@ const Servers: React.FC = () => { /> + {currentServer && endpointLabel(currentServer) && ( + + MCP Endpoint + + + )} + Mode