Skip to content

Commit 5f194e1

Browse files
bobbyhousecmrigney
andauthored
feat: Add remotes from legacy catalog (#261)
* feat: Add remotes from legacy catalog Add ability to represent the remote servers in our legacy catalog in the new catalog format as well as in working sets. - Add new `Type` 'remote' to `WorkingSet.Server and `Catalog.Server` - Add new property `endpoint` to `Catalog.Server` * fix: bug creating from working set * Migration fix. * Add remote endpoint to human readable print. * Remove unnecessary if check. --------- Co-authored-by: Cody Rigney <cody.rigney@docker.com>
1 parent 21f589c commit 5f194e1

File tree

12 files changed

+704
-70
lines changed

12 files changed

+704
-70
lines changed

pkg/catalog_next/catalog.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const (
3333
)
3434

3535
type Server struct {
36-
Type workingset.ServerType `yaml:"type" json:"type" validate:"required,oneof=registry image"`
36+
Type workingset.ServerType `yaml:"type" json:"type" validate:"required,oneof=registry image remote"`
3737
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"`
3838

3939
// ServerTypeRegistry only
@@ -42,6 +42,9 @@ type Server struct {
4242
// ServerTypeImage only
4343
Image string `yaml:"image,omitempty" json:"image,omitempty" validate:"required_if=Type image"`
4444

45+
// ServerTypeRemote only
46+
Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty" validate:"required_if=Type remote"`
47+
4548
Snapshot *workingset.ServerSnapshot `yaml:"snapshot,omitempty" json:"snapshot,omitempty"`
4649
}
4750

@@ -58,6 +61,9 @@ func NewFromDb(dbCatalog *db.Catalog) CatalogWithDigest {
5861
if server.ServerType == "image" {
5962
servers[i].Image = server.Image
6063
}
64+
if server.ServerType == "remote" {
65+
servers[i].Endpoint = server.Endpoint
66+
}
6167
if server.Snapshot != nil {
6268
servers[i].Snapshot = &workingset.ServerSnapshot{
6369
Server: server.Snapshot.Server,
@@ -93,6 +99,9 @@ func (catalog Catalog) ToDb() (db.Catalog, error) {
9399
if server.Type == workingset.ServerTypeImage {
94100
dbServers[i].Image = server.Image
95101
}
102+
if server.Type == workingset.ServerTypeRemote {
103+
dbServers[i].Endpoint = server.Endpoint
104+
}
96105
if server.Snapshot != nil {
97106
dbServers[i].Snapshot = &db.ServerSnapshot{
98107
Server: server.Snapshot.Server,

pkg/catalog_next/create.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ func createCatalogFromWorkingSet(ctx context.Context, dao db.DAO, workingSetID s
8383
Tools: server.Tools,
8484
Source: server.Source,
8585
Image: server.Image,
86+
Endpoint: server.Endpoint,
8687
Snapshot: server.Snapshot,
8788
}
8889
}
@@ -104,7 +105,6 @@ func createCatalogFromLegacyCatalog(ctx context.Context, legacyCatalogURL string
104105

105106
servers := make([]Server, 0, len(legacyCatalog.Servers))
106107
for name, server := range legacyCatalog.Servers {
107-
// TODO(cody): Add support for remote servers from the legacy catalog
108108
if server.Type == "server" && server.Image != "" {
109109
s := Server{
110110
Type: workingset.ServerTypeImage,
@@ -115,6 +115,16 @@ func createCatalogFromLegacyCatalog(ctx context.Context, legacyCatalogURL string
115115
}
116116
s.Snapshot.Server.Name = name
117117
servers = append(servers, s)
118+
} else if server.Type == "remote" {
119+
s := Server{
120+
Type: workingset.ServerTypeRemote,
121+
Endpoint: server.Remote.URL,
122+
Snapshot: &workingset.ServerSnapshot{
123+
Server: server,
124+
},
125+
}
126+
s.Snapshot.Server.Name = name
127+
servers = append(servers, s)
118128
}
119129
}
120130

pkg/catalog_next/create_test.go

Lines changed: 225 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,11 @@ func TestCreateFromWorkingSetPreservesAllServerFields(t *testing.T) {
300300
Image: "mycompany/myserver:v1.2.3",
301301
Tools: []string{"deploy"},
302302
},
303+
{
304+
Type: string(workingset.ServerTypeRemote),
305+
Endpoint: "https://remote.example.com/sse",
306+
Tools: []string{"remote-tool1", "remote-tool2"},
307+
},
303308
},
304309
Secrets: db.SecretMap{},
305310
}
@@ -319,7 +324,7 @@ func TestCreateFromWorkingSetPreservesAllServerFields(t *testing.T) {
319324

320325
assert.Equal(t, "Detailed Catalog", catalog.Title)
321326
assert.Equal(t, "profile:detailed-ws", catalog.Source)
322-
assert.Len(t, catalog.Servers, 2)
327+
assert.Len(t, catalog.Servers, 3)
323328

324329
// Check registry server
325330
assert.Equal(t, workingset.ServerTypeRegistry, catalog.Servers[0].Type)
@@ -330,6 +335,11 @@ func TestCreateFromWorkingSetPreservesAllServerFields(t *testing.T) {
330335
assert.Equal(t, workingset.ServerTypeImage, catalog.Servers[1].Type)
331336
assert.Equal(t, "mycompany/myserver:v1.2.3", catalog.Servers[1].Image)
332337
assert.Equal(t, []string{"deploy"}, catalog.Servers[1].Tools)
338+
339+
// Check remote server
340+
assert.Equal(t, workingset.ServerTypeRemote, catalog.Servers[2].Type)
341+
assert.Equal(t, "https://remote.example.com/sse", catalog.Servers[2].Endpoint)
342+
assert.Equal(t, []string{"remote-tool1", "remote-tool2"}, catalog.Servers[2].Tools)
333343
}
334344

335345
func TestCreateFromLegacyCatalog(t *testing.T) {
@@ -501,3 +511,217 @@ registry:
501511
assert.Equal(t, "Test Catalog", catalog.Title)
502512
assert.Equal(t, "legacy-catalog:test-catalog", catalog.Source)
503513
}
514+
515+
func TestCreateFromLegacyCatalogWithRemotes(t *testing.T) {
516+
tests := []struct {
517+
name string
518+
serverYAML string
519+
expectedType workingset.ServerType
520+
validateServer func(t *testing.T, server *catalog.Server)
521+
}{
522+
{
523+
name: "basic remote with SSE transport",
524+
serverYAML: ` title: "AIS Fleet"
525+
type: remote
526+
remote:
527+
transport_type: sse
528+
url: https://mcp.aisfleet.com/sse`,
529+
expectedType: workingset.ServerTypeRemote,
530+
validateServer: func(t *testing.T, server *catalog.Server) {
531+
t.Helper()
532+
assert.Equal(t, "sse", server.Remote.Transport)
533+
assert.Equal(t, "https://mcp.aisfleet.com/sse", server.Remote.URL)
534+
assert.Empty(t, server.Remote.Headers)
535+
assert.Empty(t, server.Secrets)
536+
assert.Nil(t, server.OAuth)
537+
},
538+
},
539+
{
540+
name: "remote with streamable-http and authorization header",
541+
serverYAML: ` title: "Apify Remote"
542+
type: remote
543+
remote:
544+
transport_type: streamable-http
545+
url: https://mcp.apify.com
546+
headers:
547+
Authorization: "Bearer ${APIFY_API_KEY}"
548+
secrets:
549+
- name: apify.api_key
550+
env: APIFY_API_KEY
551+
example: <YOUR_API_KEY>`,
552+
expectedType: workingset.ServerTypeRemote,
553+
validateServer: func(t *testing.T, server *catalog.Server) {
554+
t.Helper()
555+
assert.Equal(t, "streamable-http", server.Remote.Transport)
556+
assert.Equal(t, "https://mcp.apify.com", server.Remote.URL)
557+
assert.Equal(t, "Bearer ${APIFY_API_KEY}", server.Remote.Headers["Authorization"])
558+
assert.Len(t, server.Secrets, 1)
559+
assert.Equal(t, "apify.api_key", server.Secrets[0].Name)
560+
assert.Equal(t, "APIFY_API_KEY", server.Secrets[0].Env)
561+
},
562+
},
563+
{
564+
name: "remote with OAuth",
565+
serverYAML: ` title: "Asana"
566+
type: remote
567+
remote:
568+
transport_type: streamable-http
569+
url: https://asana.com/api/mcp/v1/sse
570+
oauth:
571+
providers:
572+
- provider: asana
573+
secret: asana.personal_access_token
574+
env: ASANA_PERSONAL_ACCESS_TOKEN`,
575+
expectedType: workingset.ServerTypeRemote,
576+
validateServer: func(t *testing.T, server *catalog.Server) {
577+
t.Helper()
578+
assert.Equal(t, "streamable-http", server.Remote.Transport)
579+
assert.Equal(t, "https://asana.com/api/mcp/v1/sse", server.Remote.URL)
580+
require.NotNil(t, server.OAuth)
581+
assert.Len(t, server.OAuth.Providers, 1)
582+
assert.Equal(t, "asana", server.OAuth.Providers[0].Provider)
583+
assert.Equal(t, "asana.personal_access_token", server.OAuth.Providers[0].Secret)
584+
assert.Equal(t, "ASANA_PERSONAL_ACCESS_TOKEN", server.OAuth.Providers[0].Env)
585+
},
586+
},
587+
{
588+
name: "remote with dynamic tools",
589+
serverYAML: ` title: "Cloudflare Audit Logs"
590+
type: remote
591+
dynamic:
592+
tools: true
593+
remote:
594+
transport_type: sse
595+
url: https://auditlogs.mcp.cloudflare.com/sse
596+
oauth:
597+
providers:
598+
- provider: cloudflare-audit-logs
599+
secret: cloudflare-audit-logs.personal_access_token
600+
env: CLOUDFLARE_PERSONAL_ACCESS_TOKEN`,
601+
expectedType: workingset.ServerTypeRemote,
602+
validateServer: func(t *testing.T, server *catalog.Server) {
603+
t.Helper()
604+
assert.Equal(t, "sse", server.Remote.Transport)
605+
assert.Equal(t, "https://auditlogs.mcp.cloudflare.com/sse", server.Remote.URL)
606+
require.NotNil(t, server.OAuth)
607+
assert.Len(t, server.OAuth.Providers, 1)
608+
assert.Equal(t, "cloudflare-audit-logs", server.OAuth.Providers[0].Provider)
609+
},
610+
},
611+
{
612+
name: "remote with static tools list (no headers/secrets)",
613+
serverYAML: ` title: "GitMCP"
614+
type: remote
615+
remote:
616+
transport_type: streamable-http
617+
url: https://gitmcp.io/docs
618+
tools:
619+
- name: match_common_libs_owner_repo_mapping
620+
- name: fetch_generic_documentation
621+
- name: search_generic_documentation`,
622+
expectedType: workingset.ServerTypeRemote,
623+
validateServer: func(t *testing.T, server *catalog.Server) {
624+
t.Helper()
625+
assert.Equal(t, "streamable-http", server.Remote.Transport)
626+
assert.Equal(t, "https://gitmcp.io/docs", server.Remote.URL)
627+
assert.Empty(t, server.Remote.Headers)
628+
assert.Empty(t, server.Secrets)
629+
assert.Nil(t, server.OAuth)
630+
assert.Len(t, server.Tools, 3)
631+
assert.Equal(t, "match_common_libs_owner_repo_mapping", server.Tools[0].Name)
632+
},
633+
},
634+
{
635+
name: "remote with SSE, headers, and secrets",
636+
serverYAML: ` title: "Dodo Payments"
637+
type: remote
638+
remote:
639+
transport_type: sse
640+
url: https://mcp.dodopayments.com/sse
641+
headers:
642+
Authorization: "Bearer ${DODO_PAYMENTS_API_KEY}"
643+
secrets:
644+
- name: dodo-payments.api_key
645+
env: DODO_PAYMENTS_API_KEY
646+
example: <YOUR_API_KEY>`,
647+
expectedType: workingset.ServerTypeRemote,
648+
validateServer: func(t *testing.T, server *catalog.Server) {
649+
t.Helper()
650+
assert.Equal(t, "sse", server.Remote.Transport)
651+
assert.Equal(t, "https://mcp.dodopayments.com/sse", server.Remote.URL)
652+
assert.Equal(t, "Bearer ${DODO_PAYMENTS_API_KEY}", server.Remote.Headers["Authorization"])
653+
assert.Len(t, server.Secrets, 1)
654+
assert.Equal(t, "dodo-payments.api_key", server.Secrets[0].Name)
655+
assert.Equal(t, "DODO_PAYMENTS_API_KEY", server.Secrets[0].Env)
656+
},
657+
},
658+
{
659+
name: "remote documentation server (no auth)",
660+
serverYAML: ` title: "Cloudflare Docs"
661+
type: remote
662+
remote:
663+
transport_type: sse
664+
url: https://docs.mcp.cloudflare.com/sse
665+
tools:
666+
- name: search_cloudflare_documentation
667+
- name: migrate_pages_to_workers_guide`,
668+
expectedType: workingset.ServerTypeRemote,
669+
validateServer: func(t *testing.T, server *catalog.Server) {
670+
t.Helper()
671+
assert.Equal(t, "sse", server.Remote.Transport)
672+
assert.Equal(t, "https://docs.mcp.cloudflare.com/sse", server.Remote.URL)
673+
assert.Empty(t, server.Remote.Headers)
674+
assert.Empty(t, server.Secrets)
675+
assert.Nil(t, server.OAuth)
676+
assert.Len(t, server.Tools, 2)
677+
assert.Equal(t, "search_cloudflare_documentation", server.Tools[0].Name)
678+
},
679+
},
680+
}
681+
682+
for _, tt := range tests {
683+
t.Run(tt.name, func(t *testing.T) {
684+
dao := setupTestDB(t)
685+
ctx := t.Context()
686+
687+
// Create a temporary legacy catalog file
688+
tempDir := t.TempDir()
689+
catalogFile := filepath.Join(tempDir, "test-catalog.yaml")
690+
691+
legacyCatalogYAML := "name: test-catalog\nregistry:\n test-server:\n" + tt.serverYAML + "\n"
692+
693+
err := os.WriteFile(catalogFile, []byte(legacyCatalogYAML), 0o644)
694+
require.NoError(t, err)
695+
696+
// Create catalog from legacy catalog
697+
output := captureStdout(t, func() {
698+
err := Create(ctx, dao, "test/imported:latest", "", catalogFile, "Imported Catalog")
699+
require.NoError(t, err)
700+
})
701+
702+
assert.Contains(t, output, "Catalog test/imported:latest created")
703+
704+
// Verify the catalog was created
705+
catalogs, err := dao.ListCatalogs(ctx)
706+
require.NoError(t, err)
707+
assert.Len(t, catalogs, 1)
708+
709+
catalog := NewFromDb(&catalogs[0])
710+
assert.Equal(t, "Imported Catalog", catalog.Title)
711+
assert.Equal(t, "legacy-catalog:test-catalog", catalog.Source)
712+
require.Len(t, catalog.Servers, 1)
713+
714+
// Verify server basic properties
715+
server := catalog.Servers[0]
716+
assert.Equal(t, tt.expectedType, server.Type)
717+
require.NotNil(t, server.Snapshot)
718+
assert.Equal(t, "test-server", server.Snapshot.Server.Name)
719+
720+
assert.NotEmpty(t, server.Endpoint, "Endpoint should be set for remote servers")
721+
assert.Equal(t, server.Snapshot.Server.Remote.URL, server.Endpoint, "Endpoint should match the Remote.URL from snapshot")
722+
723+
// Run custom validation
724+
tt.validateServer(t, &server.Snapshot.Server)
725+
})
726+
}
727+
}

pkg/catalog_next/show.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ func printHumanReadable(catalog CatalogWithDigest) string {
106106
servers += fmt.Sprintf(" Source: %s\n", server.Source)
107107
case workingset.ServerTypeImage:
108108
servers += fmt.Sprintf(" Image: %s\n", server.Image)
109+
case workingset.ServerTypeRemote:
110+
servers += fmt.Sprintf(" Endpoint: %s\n", server.Endpoint)
109111
}
110112
}
111113
servers = strings.TrimSuffix(servers, "\n")

pkg/db/catalog.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type CatalogServer struct {
3333
Tools ToolList `db:"tools" json:"tools"`
3434
Source string `db:"source" json:"source"`
3535
Image string `db:"image" json:"image"`
36+
Endpoint string `db:"endpoint" json:"endpoint"`
3637
CatalogRef string `db:"catalog_ref" json:"catalog_ref"`
3738

3839
Snapshot *ServerSnapshot `db:"snapshot" json:"snapshot"`
@@ -63,7 +64,7 @@ func (d *dao) GetCatalog(ctx context.Context, ref string) (*Catalog, error) {
6364
return nil, err
6465
}
6566

66-
const serverQuery = `SELECT id, server_type, tools, source, image, catalog_ref, snapshot from catalog_server where catalog_ref = $1`
67+
const serverQuery = `SELECT id, server_type, tools, source, image, endpoint, catalog_ref, snapshot from catalog_server where catalog_ref = $1`
6768

6869
var servers []CatalogServer
6970
err = d.db.SelectContext(ctx, &servers, serverQuery, catalog.Ref)
@@ -103,8 +104,8 @@ func (d *dao) UpsertCatalog(ctx context.Context, catalog Catalog) error {
103104

104105
if len(catalog.Servers) > 0 {
105106
const serverQuery = `INSERT INTO catalog_server (
106-
server_type, tools, source, image, catalog_ref, snapshot
107-
) VALUES (:server_type, :tools, :source, :image, :catalog_ref, :snapshot)`
107+
server_type, tools, source, image, endpoint, catalog_ref, snapshot
108+
) VALUES (:server_type, :tools, :source, :image, :endpoint, :catalog_ref, :snapshot)`
108109

109110
_, err = tx.NamedExecContext(ctx, serverQuery, catalog.Servers)
110111
if err != nil {
@@ -137,7 +138,7 @@ func (d *dao) ListCatalogs(ctx context.Context) ([]Catalog, error) {
137138

138139
const query = `SELECT c.ref, c.digest, c.title, c.source, c.last_updated,
139140
COALESCE(
140-
json_group_array(json_object('id', s.id, 'server_type', s.server_type, 'tools', json(s.tools), 'source', s.source, 'image', s.image, 'snapshot', json(s.snapshot))),
141+
json_group_array(json_object('id', s.id, 'server_type', s.server_type, 'tools', json(s.tools), 'source', s.source, 'image', s.image, 'endpoint', s.endpoint, 'snapshot', json(s.snapshot))),
141142
'[]'
142143
) AS server_json
143144
FROM catalog c
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- Add support for remote server type
2+
-- SQLite doesn't support modifying CHECK constraints, so we need to recreate the table
3+
4+
CREATE TABLE catalog_server_new (
5+
id integer primary key,
6+
server_type text check(server_type in ('registry', 'image', 'remote')),
7+
tools text CHECK (json_valid(tools)),
8+
source text,
9+
image text,
10+
endpoint text,
11+
snapshot text CHECK (json_valid(snapshot)),
12+
catalog_ref text not null,
13+
foreign key (catalog_ref) references catalog(ref) on delete cascade
14+
);
15+
16+
-- Copy existing data
17+
INSERT INTO catalog_server_new (id, server_type, tools, source, image, endpoint, snapshot, catalog_ref)
18+
SELECT id, server_type, tools, source, image, "", snapshot, catalog_ref
19+
FROM catalog_server;
20+
21+
DROP TABLE catalog_server;
22+
23+
ALTER TABLE catalog_server_new RENAME TO catalog_server;

0 commit comments

Comments
 (0)