Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion catalog/mcp/catalog.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"updated_at": "2026-07-23T00:00:00Z",
"updated_at": "2026-08-01T00:00:00Z",
"items": [
{
"id": "context7",
Expand Down Expand Up @@ -88,6 +88,52 @@
"name": "notion",
"url": "https://mcp.notion.com/mcp"
}
},
{
"id": "linear",
"name": "Linear",
"description": "Manage issues, projects, and comments in Linear through the hosted MCP server.",
"publisher": {
"name": "Linear",
"url": "https://linear.app"
},
"homepage_url": "https://linear.app/docs/mcp",
"verified": true,
"categories": ["Productivity", "Project Management"],
"featured_rank": 5,
"version": "1.0.0",
"transport": "streamable-http",
"authentication": {
"type": "oauth2",
"scopes": ["read", "write"]
},
"server": {
"name": "linear",
"url": "https://mcp.linear.app/mcp"
}
},
{
"id": "sentry",
"name": "Sentry",
"description": "Inspect Sentry projects, teams, and events through the hosted MCP server.",
"publisher": {
"name": "Sentry",
"url": "https://sentry.io"
},
"homepage_url": "https://docs.sentry.io/product/sentry-mcp/",
"verified": true,
"categories": ["Developer Tools", "Monitoring"],
"featured_rank": 6,
"version": "1.0.0",
"transport": "streamable-http",
"authentication": {
"type": "oauth2",
"scopes": ["org:read", "project:write", "team:write", "event:write"]
},
"server": {
"name": "sentry",
"url": "https://mcp.sentry.dev/mcp"
}
}
]
}
15 changes: 10 additions & 5 deletions server/internal/mcpcatalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ func TestBuiltinCatalogLoads(t *testing.T) {
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"context7", "exa", "firecrawl", "notion"}
want := []string{"context7", "exa", "firecrawl", "notion", "linear", "sentry"}
oauthIDs := map[string]struct{}{
"notion": {},
"linear": {},
"sentry": {},
}
if len(catalog.Items) != len(want) {
t.Fatalf("items = %d, want %d", len(catalog.Items), len(want))
}
Expand All @@ -23,13 +28,13 @@ func TestBuiltinCatalogLoads(t *testing.T) {
if err := item.CanonicalSpec().Validate(); err != nil {
t.Fatalf("item %q canonical spec: %v", item.ID, err)
}
if item.ID == "notion" {
if _, ok := oauthIDs[item.ID]; ok {
header := item.CanonicalSpec().MCP.Servers[0].Headers["Authorization"]
if header.Prefix != "Bearer " || header.CredentialKindCode != "mcp_oauth" {
t.Fatalf("notion authorization header = %+v", header)
t.Fatalf("%s authorization header = %+v", item.ID, header)
}
if len(item.Authentication.Scopes) != 1 || item.Authentication.Scopes[0] != "default" {
t.Fatalf("notion scopes = %v", item.Authentication.Scopes)
if len(item.Authentication.Scopes) == 0 {
t.Fatalf("%s scopes = %v", item.ID, item.Authentication.Scopes)
}
}
}
Expand Down
155 changes: 155 additions & 0 deletions server/migrations/000013_mcp_oauth_compatibility.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
-- +goose Up

-- Earlier directory builds registered provider-specific *_mcp_oauth kinds.
-- Some databases already reached migration 11/12 on those builds and will
-- therefore skip the current main branch's migration 000010. Register the
-- generic kind again before normalizing their existing rows.

INSERT INTO credential_kinds (
code, display_name, description, source, built_in
)
VALUES (
'mcp_oauth',
'MCP OAuth',
'OAuth credential for hosted MCP connectors',
'platform_oauth',
TRUE
)
ON CONFLICT DO NOTHING;

UPDATE capability_version
SET required_credentials = (
SELECT COALESCE(
jsonb_agg(
CASE
WHEN entry->>'kind' IN ('notion_mcp_oauth', 'linear_mcp_oauth', 'sentry_mcp_oauth')
THEN jsonb_set(entry, '{kind}', to_jsonb('mcp_oauth'::text), TRUE)
ELSE entry
END
),
'[]'::jsonb
)
FROM jsonb_array_elements(required_credentials) AS entry
)
WHERE source_payload->>'catalog_id' IN ('notion', 'linear', 'sentry')
AND required_credentials::text ~ '(notion|linear|sentry)_mcp_oauth';

UPDATE capability_version
SET canonical_spec = jsonb_set(
canonical_spec,
'{mcp,servers,0,headers,Authorization,credential_kind_code}',
to_jsonb('mcp_oauth'::text),
TRUE
)
WHERE source_payload->>'catalog_id' IN ('notion', 'linear', 'sentry')
AND canonical_spec->'mcp'->'servers'->0->'headers'->'Authorization'->>'credential_kind_code'
IN ('notion_mcp_oauth', 'linear_mcp_oauth', 'sentry_mcp_oauth');

UPDATE secrets
SET metadata = jsonb_set(
metadata,
'{credential_kind_code}',
to_jsonb('mcp_oauth'::text),
TRUE
),
updated_at = now()
WHERE provider IN ('notion', 'linear', 'sentry')
AND metadata->>'credential_kind_code'
IN ('notion_mcp_oauth', 'linear_mcp_oauth', 'sentry_mcp_oauth');

-- Keep each connector binding capability-scoped. Otherwise one Agent using
-- multiple hosted connectors would have those Secrets overwrite each other
-- under the shared mcp_oauth key.
WITH legacy_bindings(catalog_id, legacy_kind) AS (
VALUES
('notion', 'notion_mcp_oauth'),
('linear', 'linear_mcp_oauth'),
('sentry', 'sentry_mcp_oauth')
), capability_bindings AS (
SELECT
ac.id,
ac.configuration->'credential_bindings'->legacy_bindings.legacy_kind AS binding
FROM agent_capabilities ac
JOIN capability_version cv ON cv.id = ac.capability_version_id
JOIN legacy_bindings
ON legacy_bindings.catalog_id = cv.source_payload->>'catalog_id'
WHERE jsonb_typeof(ac.configuration->'credential_bindings'->legacy_bindings.legacy_kind) = 'object'
AND NOT (
CASE
WHEN jsonb_typeof(ac.configuration->'credential_bindings') = 'object'
THEN ac.configuration->'credential_bindings'
ELSE '{}'::jsonb
END ? 'mcp_oauth'
)
)
UPDATE agent_capabilities ac
SET configuration = jsonb_set(
ac.configuration,
'{credential_bindings}',
(
CASE
WHEN jsonb_typeof(ac.configuration->'credential_bindings') = 'object'
THEN ac.configuration->'credential_bindings'
ELSE '{}'::jsonb
END
) || jsonb_build_object('mcp_oauth', capability_bindings.binding),
TRUE
),
updated_at = now()
FROM capability_bindings
WHERE ac.id = capability_bindings.id;

WITH legacy_bindings(catalog_id, legacy_kind) AS (
VALUES
('notion', 'notion_mcp_oauth'),
('linear', 'linear_mcp_oauth'),
('sentry', 'sentry_mcp_oauth')
), agent_bindings AS (
SELECT
ac.id,
agents.config->'credential_bindings'->legacy_bindings.legacy_kind AS binding
FROM agent_capabilities ac
JOIN agents ON agents.id = ac.agent_id
JOIN capability_version cv ON cv.id = ac.capability_version_id
JOIN legacy_bindings
ON legacy_bindings.catalog_id = cv.source_payload->>'catalog_id'
WHERE jsonb_typeof(agents.config->'credential_bindings'->legacy_bindings.legacy_kind) = 'object'
AND NOT (
CASE
WHEN jsonb_typeof(ac.configuration->'credential_bindings') = 'object'
THEN ac.configuration->'credential_bindings'
ELSE '{}'::jsonb
END ? 'mcp_oauth'
)
)
UPDATE agent_capabilities ac
SET configuration = jsonb_set(
ac.configuration,
'{credential_bindings}',
(
CASE
WHEN jsonb_typeof(ac.configuration->'credential_bindings') = 'object'
THEN ac.configuration->'credential_bindings'
ELSE '{}'::jsonb
END
) || jsonb_build_object('mcp_oauth', agent_bindings.binding),
TRUE
),
updated_at = now()
FROM agent_bindings
WHERE ac.id = agent_bindings.id;

-- Preserve historical rows for foreign-key compatibility, but hide obsolete
-- provider-specific kinds from new credential pickers.
UPDATE credential_kinds
SET deleted_at = COALESCE(deleted_at, now()),
updated_at = now()
WHERE built_in = TRUE
AND code IN ('notion_mcp_oauth', 'linear_mcp_oauth', 'sentry_mcp_oauth')
AND deleted_at IS NULL;

-- +goose Down

-- The data normalization is intentionally not reversed. Restoring the old
-- provider-specific kinds would make current catalog capabilities unusable.
SELECT 1;