From b466912bcf337171f597e9d92bbff451852fd896 Mon Sep 17 00:00:00 2001 From: vrivri Date: Thu, 22 Oct 2026 00:12:04 +0530 Subject: [PATCH] feat(transports): management MCP builder exposing every /api operation as a tool --- .../paths/management/accessprofiles.yaml | 2 + docs/openapi/paths/management/governance.yaml | 2 + docs/openapi/paths/management/logging.yaml | 1 + docs/openapi/paths/management/mcp.yaml | 2 + docs/openapi/paths/management/routing.yaml | 1 + docs/openapi/paths/management/vault.yaml | 1 + docs/openapi/paths/management/webhooks.yaml | 1 + framework/openapimcp/parse.go | 16 + framework/openapimcp/parse_test.go | 23 + framework/openapimcp/swagger2.go | 1 + framework/openapimcp/types.go | 1 + .../bifrost-http/handlers/bifrostmcp.go | 407 ++++++++++++++++ .../bifrost-http/handlers/bifrostmcp_test.go | 437 ++++++++++++++++++ transports/bifrost-http/server/server.go | 1 + 14 files changed, 896 insertions(+) create mode 100644 transports/bifrost-http/handlers/bifrostmcp.go create mode 100644 transports/bifrost-http/handlers/bifrostmcp_test.go diff --git a/docs/openapi/paths/management/accessprofiles.yaml b/docs/openapi/paths/management/accessprofiles.yaml index 8d45ebdbff..80ebc1b13f 100644 --- a/docs/openapi/paths/management/accessprofiles.yaml +++ b/docs/openapi/paths/management/accessprofiles.yaml @@ -295,6 +295,7 @@ access-profiles-activate: access-profiles-deactivate: put: operationId: deactivateAccessProfile + x-bifrost-destructive: true x-mint: metadata: tag: Enterprise @@ -393,6 +394,7 @@ access-profiles-clone: access-profiles-propagate: post: operationId: propagateAccessProfile + x-bifrost-destructive: true x-mint: metadata: tag: Enterprise diff --git a/docs/openapi/paths/management/governance.yaml b/docs/openapi/paths/management/governance.yaml index c79f2cf2fa..d2ffebd0eb 100644 --- a/docs/openapi/paths/management/governance.yaml +++ b/docs/openapi/paths/management/governance.yaml @@ -361,6 +361,7 @@ virtual-keys-by-id: virtual-keys-rotate-by-id: post: operationId: rotateVirtualKey + x-bifrost-destructive: true x-mint: content: | **Required Permissions** @@ -538,6 +539,7 @@ virtual-keys-budget-override: virtual-keys-rotate: post: operationId: bulkRotateVirtualKeys + x-bifrost-destructive: true x-mint: content: | **Required Permissions** diff --git a/docs/openapi/paths/management/logging.yaml b/docs/openapi/paths/management/logging.yaml index 21a34c8855..60ac6f0f99 100644 --- a/docs/openapi/paths/management/logging.yaml +++ b/docs/openapi/paths/management/logging.yaml @@ -1370,6 +1370,7 @@ _ranking-parameters: logs-recalculate-cost: post: operationId: recalculateLogCosts + x-bifrost-destructive: true x-mint: content: | **Required Permissions** diff --git a/docs/openapi/paths/management/mcp.yaml b/docs/openapi/paths/management/mcp.yaml index 9275ca1032..fd8358d6b9 100644 --- a/docs/openapi/paths/management/mcp.yaml +++ b/docs/openapi/paths/management/mcp.yaml @@ -641,6 +641,7 @@ client-initiate-verification: client-reauthorize: post: operationId: reauthorizeMCPClient + x-bifrost-destructive: true x-mint: content: | **Required Permissions** @@ -710,6 +711,7 @@ client-reauthorize: client-reregister: post: operationId: reregisterMCPClient + x-bifrost-destructive: true summary: Reauthorize an MCP client against a newly registered OAuth client description: | Registers a replacement OAuth client (RFC 7591 Dynamic Client diff --git a/docs/openapi/paths/management/routing.yaml b/docs/openapi/paths/management/routing.yaml index 677c65d9f1..64a4e4cff2 100644 --- a/docs/openapi/paths/management/routing.yaml +++ b/docs/openapi/paths/management/routing.yaml @@ -328,6 +328,7 @@ complexity: complexity-reset: post: operationId: resetComplexityAnalyzerConfig + x-bifrost-destructive: true x-mint: content: | **Required Permissions** diff --git a/docs/openapi/paths/management/vault.yaml b/docs/openapi/paths/management/vault.yaml index 013df8ab64..35fa8b7328 100644 --- a/docs/openapi/paths/management/vault.yaml +++ b/docs/openapi/paths/management/vault.yaml @@ -1,6 +1,7 @@ flush-cache: post: operationId: flushVaultCache + x-bifrost-destructive: true x-mint: metadata: tag: Enterprise diff --git a/docs/openapi/paths/management/webhooks.yaml b/docs/openapi/paths/management/webhooks.yaml index fb156ac4d9..3a88760d93 100644 --- a/docs/openapi/paths/management/webhooks.yaml +++ b/docs/openapi/paths/management/webhooks.yaml @@ -205,6 +205,7 @@ webhooks-by-id: webhooks-rotate-secret: post: operationId: rotateWebhookEndpointSecret + x-bifrost-destructive: true x-mint: content: | **Required Permissions** diff --git a/framework/openapimcp/parse.go b/framework/openapimcp/parse.go index 1630cf12c5..85089c57c9 100644 --- a/framework/openapimcp/parse.go +++ b/framework/openapimcp/parse.go @@ -208,6 +208,7 @@ func buildOperationV3(method, path string, op map[string]any, pathParams []Param Description: strings.TrimSpace(asString(op["description"])), Deprecated: asBool(op["deprecated"]), Tags: asStringSlice(op["tags"]), + Extensions: operationExtensions(op), } if servers := parseServers(op["servers"], doc, opts); len(servers) > 0 { operation.Servers = servers @@ -272,6 +273,21 @@ func buildOperationV3(method, path string, op map[string]any, pathParams []Param return operation } +// operationExtensions returns an operation object's x-* keys (nil when it has +// none), so callers can act on vendor markers such as x-bifrost-destructive. +func operationExtensions(op map[string]any) map[string]any { + var ext map[string]any + for k, v := range op { + if strings.HasPrefix(k, "x-") { + if ext == nil { + ext = map[string]any{} + } + ext[k] = v + } + } + return ext +} + // resolveRequestBody resolves a request body and each of its media types on // its own, so an alternative that cannot be resolved (an external $ref) is // dropped rather than rejecting a body that offers a usable one. When every diff --git a/framework/openapimcp/parse_test.go b/framework/openapimcp/parse_test.go index f4b9635e0b..4cd01efcb6 100644 --- a/framework/openapimcp/parse_test.go +++ b/framework/openapimcp/parse_test.go @@ -476,3 +476,26 @@ func TestParse_LiteralRefInInstanceDataIsNotAReference(t *testing.T) { require.NoError(t, err) assert.Empty(t, findOp(t, doc, "POST", "/s").Unsupported) } + +// Vendor extensions on an operation are kept, in both spec families, so a +// caller can act on markers such as x-bifrost-destructive. +func TestParse_OperationExtensions(t *testing.T) { + v3 := `{"openapi":"3.1.0","info":{"title":"t","version":"1"},"paths":{ +"/a":{"post":{"operationId":"a","x-bifrost-destructive":true,"x-note":{"k":"v"},"responses":{"200":{"description":"ok"}}}, +"get":{"operationId":"b","responses":{"200":{"description":"ok"}}}}}}` + doc, err := Parse([]byte(v3), ParseOptions{}) + require.NoError(t, err) + byID := map[string]Operation{} + for _, op := range doc.Operations { + byID[op.ID] = op + } + assert.Equal(t, map[string]any{"x-bifrost-destructive": true, "x-note": map[string]any{"k": "v"}}, byID["a"].Extensions) + assert.Nil(t, byID["b"].Extensions) + + v2 := `{"swagger":"2.0","info":{"title":"t","version":"1"},"host":"api.example","paths":{ +"/a":{"delete":{"operationId":"a","x-bifrost-destructive":false,"responses":{"200":{"description":"ok"}}}}}}` + doc, err = Parse([]byte(v2), ParseOptions{}) + require.NoError(t, err) + require.Len(t, doc.Operations, 1) + assert.Equal(t, map[string]any{"x-bifrost-destructive": false}, doc.Operations[0].Extensions) +} diff --git a/framework/openapimcp/swagger2.go b/framework/openapimcp/swagger2.go index 47ddee3634..27db30cd52 100644 --- a/framework/openapimcp/swagger2.go +++ b/framework/openapimcp/swagger2.go @@ -93,6 +93,7 @@ func buildOperationV2(method, path string, op map[string]any, pathParamsRaw []an Description: strings.TrimSpace(asString(op["description"])), Deprecated: asBool(op["deprecated"]), Tags: asStringSlice(op["tags"]), + Extensions: operationExtensions(op), } if _, has := op["security"]; has { operation.HasSecurity = true diff --git a/framework/openapimcp/types.go b/framework/openapimcp/types.go index f15eef5b10..eab0716b34 100644 --- a/framework/openapimcp/types.go +++ b/framework/openapimcp/types.go @@ -93,6 +93,7 @@ type Operation struct { HasSecurity bool // op declared its own security (even if empty) Servers []string // op/path-level server override Unsupported string // non-empty: reason the operation cannot become a tool + Extensions map[string]any // the operation object's x-* keys, as decoded } // Parameter is one path/query/header/cookie/querystring parameter. diff --git a/transports/bifrost-http/handlers/bifrostmcp.go b/transports/bifrost-http/handlers/bifrostmcp.go new file mode 100644 index 0000000000..230af1256f --- /dev/null +++ b/transports/bifrost-http/handlers/bifrostmcp.go @@ -0,0 +1,407 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/mark3labs/mcp-go/server" + "github.com/maximhq/bifrost/framework/openapimcp" +) + +// The Bifrost MCP serves Bifrost's own HTTP surface as MCP tools on /v1/mcp. +// Every operation in the published OpenAPI spec becomes a tool, except the +// deprecated ones, the few on bifrostMCPDenied, the enterprise-only ones on an +// OSS gateway, and any this gateway does not route. With BIFROST_MCP_SPEC +// unset the spec is read from the dev branch; a non-empty value replaces that +// source. + +const ( + // BifrostMCPSpecEnv overrides the spec source with a URL or a file path + // (absolute, or relative to the app directory), for air-gapped installs. + BifrostMCPSpecEnv = "BIFROST_MCP_SPEC" + // BifrostMCPWritesEnv turns the non-GET /api tools (the gateway's own + // mutations) on or off. Routes outside /api are served either way. + BifrostMCPWritesEnv = "BIFROST_MCP_ALLOW_WRITES" + // bifrostMCPWritesDefault applies when BifrostMCPWritesEnv is unset. Writes + // are on: /api authorizes each call with the caller's own credential, exactly + // as a direct call, and destructive operations carry destructiveHint so + // clients confirm them. + bifrostMCPWritesDefault = true + + // bifrostMCPBaseURL is the base the synthesized tools address. The + // executor serves every request in process, so the host is never dialed. + bifrostMCPBaseURL = "http://bifrost.internal" + + bifrostMCPSpecRepoURL = "https://raw.githubusercontent.com/maximhq/bifrost" + bifrostMCPSpecDir = "docs/openapi/" + bifrostMCPSpecFull = "openapi.json" // every operation + bifrostMCPSpecOSS = "openapi-oss.json" // enterprise-only operations removed + + bifrostMCPLoadTimeout = 30 * time.Second + bifrostMCPRetryInitial = 5 * time.Second + bifrostMCPRetryMax = 5 * time.Minute + bifrostMCPUnavailableMsg = "Bifrost MCP spec unavailable" +) + +// BifrostMCPEdition is the build the Bifrost MCP serves: it decides which +// spec is read and whether enterprise-only operations become tools. +type BifrostMCPEdition string + +const ( + BifrostMCPEditionOSS BifrostMCPEdition = "oss" + BifrostMCPEditionEnterprise BifrostMCPEdition = "enterprise" +) + +// bifrostMCPEnterpriseTag is the spec's marker for an enterprise-only +// operation (x-mint.metadata.tag), the one bundle.py --edition oss filters on. +const bifrostMCPEnterpriseTag = "Enterprise" + +// bifrostMCPDestructiveExt marks a POST or PUT operation in the spec that +// destroys or invalidates state. Every DELETE is destructive without it. +const bifrostMCPDestructiveExt = "x-bifrost-destructive" + +// bifrostMCPDenied lists the /api operations never served on /v1/mcp, by +// operationId. operationIds are a public contract, so a rename in the spec +// surfaces as an unknown entry here (logged at load, failed in tests). +var bifrostMCPDenied = map[string]bool{ + // Mint, trade or end dashboard credentials. + "login": true, "logout": true, "startSetupSession": true, "issueWSTicket": true, "isAuthEnabled": true, + // Browser steps of end-user OAuth and per-user-headers flows, authorized by one-time tokens. + "handleOAuthCallback": true, "startPerUserOauthFlow": true, "getPerUserOauthFlow": true, + "getPerUserHeadersFlow": true, "submitPerUserHeadersFlow": true, + // Streams (SSE) and WebSocket upgrades, which a request/response tool cannot carry. + "warpSession": true, "websocketConnect": true, "connectRealtime": true, "openaiRealtimeWebSocket": true, + "createResponseWebSocket": true, "openaiResponsesWebSocket": true, + "geminiStreamGenerateContent": true, "litellmGeminiStreamGenerateContent": true, + "langchainGeminiStreamGenerateContent": true, "pydanticaiGeminiStreamGenerateContent": true, + "agent_rest_message_stream_post": true, "agent_rest_tasks_taskId_subscribe_post": true, + // The other MCP server, whose session a stateless tool call cannot hold. + "mcpServerMessage": true, "mcpServerSSE": true, + // Binary downloads and installer manifests: a model only sees "[binary response]". + "downloadAllSkills": true, "downloadSkill": true, "downloadSkillFile": true, + "getClaudeCodeSkillsMarketplace": true, "getCodexSkillsMarketplace": true, +} + +// bifrostMCPDeniedRoutes denies the operations of bifrostMCPDenied by route as +// well. The gateway reads dev's spec, so a released build can meet an +// operationId renamed after it shipped, which the ID list no longer matches. +// Path parameters compare by position, not name (see bifrostMCPRouteKey). +var bifrostMCPDeniedRoutes = bifrostMCPRouteSet( + "POST /api/session/login", "POST /api/session/logout", "POST /api/session/setup", + "POST /api/session/ws-ticket", "GET /api/session/is-auth-enabled", + "GET /api/oauth/callback", "GET /api/oauth/per-user/flows/{id}/start", "GET /api/oauth/per-user/flows/{id}", + "GET /api/mcp/per-user-headers/flows/{id}", "PUT /api/mcp/per-user-headers/flows/{id}", + "POST /api/warp/chat", "GET /ws", "GET /v1/realtime", "GET /openai/v1/realtime", + "GET /v1/responses", "GET /openai/v1/responses", + "POST /genai/v1beta/models/{model}:streamGenerateContent", + "POST /litellm/genai/v1beta/models/{model}:streamGenerateContent", + "POST /langchain/genai/v1beta/models/{model}:streamGenerateContent", + "POST /pydanticai/genai/v1beta/models/{model}:streamGenerateContent", + "POST /agents/a2a/{name}/rest/message:stream", "POST /agents/a2a/{name}/rest/tasks/{taskId}:subscribe", + "POST /mcp", "GET /mcp", + "GET /api/skills/serve/all/download.zip", "GET /api/skills/serve/{skill-name}/download.zip", + "GET /api/skills/serve/{skill-name}/files/{filepath}", + "GET /api/skills/serve/claude-code/.claude-plugin/marketplace.json", + "GET /api/skills/serve/codex/.agents/plugins/marketplace.json", +) + +// bifrostMCPPathParam matches one path parameter, {name}. +var bifrostMCPPathParam = regexp.MustCompile(`\{[^}]*\}`) + +// bifrostMCPRouteKey is "METHOD path" with every path parameter written {}, so +// renaming a parameter does not change the key. +func bifrostMCPRouteKey(method, path string) string { + return strings.ToUpper(method) + " " + bifrostMCPPathParam.ReplaceAllString(path, "{}") +} + +// bifrostMCPRouteSet keys "METHOD path" routes by bifrostMCPRouteKey. +func bifrostMCPRouteSet(routes ...string) map[string]bool { + set := make(map[string]bool, len(routes)) + for _, route := range routes { + method, path, _ := strings.Cut(route, " ") + set[bifrostMCPRouteKey(method, path)] = true + } + return set +} + +// BifrostMCPOptions configures the Bifrost MCP. +type BifrostMCPOptions struct { + SpecSources []string // URLs or file paths, tried in order; see BifrostMCPSpecSources + AppDir string // relative file paths resolve against it + Edition BifrostMCPEdition + AllowWrites bool // false leaves out non-GET /api operations + Served func(method, path string) bool // nil serves every operation; else only those it accepts + SpecClient *http.Client // fetches a URL spec; carries the proxy policy + ExecClient *http.Client // serves tool calls through the /api chain + Headers openapimcp.HeaderResolver +} + +// BifrostMCP holds the Bifrost MCP server, which becomes available once +// the spec has loaded. Until then Server returns nil. +type BifrostMCP struct { + opts BifrostMCPOptions + server atomic.Pointer[server.MCPServer] +} + +// NewBifrostMCP returns an unloaded holder; call Start to load it. +func NewBifrostMCP(opts BifrostMCPOptions) *BifrostMCP { + return &BifrostMCP{opts: opts} +} + +// Server returns the loaded server, or nil while the spec is unavailable. +func (m *BifrostMCP) Server() *server.MCPServer { + if m == nil { + return nil + } + return m.server.Load() +} + +// Start loads the spec in the background, retrying with backoff until it +// succeeds or ctx ends. It never blocks startup. +func (m *BifrostMCP) Start(ctx context.Context) { + go func() { + delay := bifrostMCPRetryInitial + for { + err := m.Load(ctx) + if err == nil { + return + } + logger.Warn("[Bifrost MCP] %s, retrying in %s: %v", bifrostMCPUnavailableMsg, delay, err) + select { + case <-ctx.Done(): + return + case <-time.After(delay): + } + delay = min(delay*2, bifrostMCPRetryMax) + } + }() +} + +// Load fetches, curates and synthesizes the spec once, and publishes the +// resulting server. +func (m *BifrostMCP) Load(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, bifrostMCPLoadTimeout) + defer cancel() + data, source, specURL, err := loadBifrostMCPSpec(ctx, m.opts) + if err != nil { + return err + } + srv, report, err := buildBifrostMCPServer(data, specURL, m.opts) + if err != nil { + return err + } + logger.Info("[Bifrost MCP] loaded %s spec from %s: %d tools exposed, %d operations excluded (%d not routed by this gateway), %d unsupported, writes %s", + m.opts.edition(), source, report.exposed, report.excluded, report.unrouted, len(report.unsupported), enabledWord(m.opts.AllowWrites)) + for _, op := range report.unsupported { + logger.Debug("[Bifrost MCP] operation %s (%s %s) cannot be served: %s", op.OperationID, op.Method, op.Path, op.Reason) + } + for _, id := range report.unknown { + logger.Warn("[Bifrost MCP] denied operation %s is not in the spec", id) + } + m.server.Store(srv) + return nil +} + +// edition defaults an unset edition to OSS. +func (o BifrostMCPOptions) edition() BifrostMCPEdition { + if o.Edition == "" { + return BifrostMCPEditionOSS + } + return o.Edition +} + +// BifrostMCPSpecSources lists the specs the gateway reads, in order: a +// non-empty BIFROST_MCP_SPEC alone, replacing the default; else dev's published +// spec. Every build reads dev, whatever its version: a version can look like a +// release with no spec behind it (enterprise's dev build stamps v1.2.3). +// BifrostMCPOptions.Served drops the operations this gateway does not route, so +// a newer spec cannot add a tool the build lacks; it does not pin the schemas or +// annotations of the operations it serves, which follow dev's spec. An OSS +// gateway reads openapi-oss.json, which bundle.py writes beside openapi.json, and +// falls back to the full spec filtered by the enterprise tag. +func BifrostMCPSpecSources(edition BifrostMCPEdition) []string { + if override := strings.TrimSpace(os.Getenv(BifrostMCPSpecEnv)); override != "" { + return []string{override} + } + base := bifrostMCPSpecRepoURL + "/refs/heads/dev/" + bifrostMCPSpecDir + if edition == BifrostMCPEditionEnterprise { + return []string{base + bifrostMCPSpecFull} + } + return []string{base + bifrostMCPSpecOSS, base + bifrostMCPSpecFull} +} + +// BifrostMCPAllowWrites reads the writes switch. +func BifrostMCPAllowWrites() bool { + if v, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(BifrostMCPWritesEnv))); err == nil { + return v + } + return bifrostMCPWritesDefault +} + +// loadBifrostMCPSpec returns the first spec source that loads, which one it was, +// and the URL relative servers resolve against. +func loadBifrostMCPSpec(ctx context.Context, opts BifrostMCPOptions) ([]byte, string, string, error) { + var errs []error + for _, source := range opts.SpecSources { + if source = strings.TrimSpace(source); source == "" { + continue + } + data, specURL, err := loadBifrostMCPSpecSource(ctx, opts, source) + if err == nil { + return data, source, specURL, nil + } + errs = append(errs, fmt.Errorf("%s: %w", source, err)) + } + if len(errs) == 0 { + return nil, "", "", errors.New("no spec source configured") + } + return nil, "", "", errors.Join(errs...) +} + +func loadBifrostMCPSpecSource(ctx context.Context, opts BifrostMCPOptions, source string) ([]byte, string, error) { + if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") { + return openapimcp.LoadURL(ctx, opts.SpecClient, source) + } + baseDir := "" + if !filepath.IsAbs(source) { + baseDir = opts.AppDir + } + data, err := openapimcp.LoadFile(source, baseDir) + return data, "", err +} + +type bifrostMCPReport struct { + exposed int + excluded int + unrouted int // excluded because this gateway does not route them + unsupported []openapimcp.UnsupportedOperation + unknown []string // bifrostMCPDenied entries the spec does not define +} + +// buildBifrostMCPServer turns spec bytes into the Bifrost MCP server. +func buildBifrostMCPServer(data []byte, specURL string, opts BifrostMCPOptions) (*server.MCPServer, bifrostMCPReport, error) { + var report bifrostMCPReport + doc, err := openapimcp.Parse(data, openapimcp.ParseOptions{SpecURL: specURL}) + if err != nil { + return nil, report, fmt.Errorf("parsing spec: %w", err) + } + var kept []openapimcp.Operation + kept, report.unrouted, report.unknown = curateBifrostMCPOperations(doc.Operations, opts) + report.excluded = len(doc.Operations) - len(kept) + doc.Operations = kept + // Bifrost's spec writes dynamic query names as (metadata_), + // a convention openapimcp only expands when asked to. + syn, err := openapimcp.Synthesize(doc, openapimcp.SynthesizeOptions{TemplateParams: true}) + if err != nil { + return nil, report, fmt.Errorf("synthesizing tools: %w", err) + } + report.unsupported = syn.Unsupported + report.exposed = len(syn.Tools) + for i := range syn.Tools { + annotateBifrostMCPTool(&syn.Tools[i]) + } + srv, err := openapimcp.BuildServer(openapimcp.ServerOptions{ + ClientName: "gateway", + Synthesis: syn, + BaseURL: bifrostMCPBaseURL, + BaseURLExplicit: true, + HTTPClient: opts.ExecClient, + Headers: opts.Headers, + Logger: logger, + }) + if err != nil { + return nil, report, fmt.Errorf("building server: %w", err) + } + return srv, report, nil +} + +// bifrostMCPWrite reports whether op is one the writes switch gates: a non-GET +// operation on the gateway's own /api, which changes its configuration. A +// non-GET outside /api (an inference call, a batch, a file upload to a +// provider) is the caller's own request, authorized by its credential the same +// way a direct call is, so the switch leaves it alone. +func bifrostMCPWrite(op openapimcp.Operation) bool { + return op.Method != http.MethodGet && strings.HasPrefix(op.Path, "/api/") +} + +// curateBifrostMCPOperations keeps the operations that become tools: not +// deprecated, not denied by operationId or route, not enterprise-only on an OSS gateway, routed by this +// gateway and, when writes are off, not a non-GET /api operation. It also returns how many were +// left out because the gateway does not route them, and the denied operationIds +// the spec does not define. +func curateBifrostMCPOperations(ops []openapimcp.Operation, opts BifrostMCPOptions) ([]openapimcp.Operation, int, []string) { + seen := make(map[string]bool, len(bifrostMCPDenied)) + oss := opts.edition() == BifrostMCPEditionOSS + var kept []openapimcp.Operation + unrouted := 0 + for _, op := range ops { + if bifrostMCPDenied[op.ID] { + seen[op.ID] = true + continue + } + if bifrostMCPDeniedRoutes[bifrostMCPRouteKey(op.Method, op.Path)] { + continue + } + if op.Deprecated || (!opts.AllowWrites && bifrostMCPWrite(op)) { + continue + } + if oss && bifrostMCPEnterpriseOnly(op) { + continue + } + if opts.Served != nil && !opts.Served(op.Method, op.Path) { + unrouted++ + continue + } + kept = append(kept, op) + } + var unknown []string + for id := range bifrostMCPDenied { + if !seen[id] { + unknown = append(unknown, id) + } + } + return kept, unrouted, unknown +} + +// bifrostMCPEnterpriseOnly reports whether the spec marks op enterprise-only. +func bifrostMCPEnterpriseOnly(op openapimcp.Operation) bool { + mint, _ := op.Extensions["x-mint"].(map[string]any) + metadata, _ := mint["metadata"].(map[string]any) + tag, _ := metadata["tag"].(string) + return tag == bifrostMCPEnterpriseTag +} + +// annotateBifrostMCPTool replaces the method-derived hints openapimcp sets for +// an arbitrary upstream: /api tools act on this gateway only (closed world), +// the rest reach providers and agents beyond it, and a POST or PUT is +// destructive only when the spec marks it so. +func annotateBifrostMCPTool(tool *openapimcp.Tool) { + b := func(v bool) *bool { return &v } + ann := &tool.Annotations + ann.OpenWorldHint = b(!strings.HasPrefix(tool.Operation.Path, "/api/")) + switch tool.Operation.Method { + case http.MethodGet: + return + case http.MethodDelete: + ann.DestructiveHint = b(true) + default: + marked, _ := tool.Operation.Extensions[bifrostMCPDestructiveExt].(bool) + ann.DestructiveHint = b(marked) + } +} + +func enabledWord(on bool) string { + if on { + return "enabled" + } + return "disabled" +} diff --git a/transports/bifrost-http/handlers/bifrostmcp_test.go b/transports/bifrost-http/handlers/bifrostmcp_test.go new file mode 100644 index 0000000000..334c07e995 --- /dev/null +++ b/transports/bifrost-http/handlers/bifrostmcp_test.go @@ -0,0 +1,437 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "sync/atomic" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/maximhq/bifrost/framework/openapimcp" +) + +// repoOpenAPISpec is the spec this source tree publishes. A gateway fetches it +// from the dev branch unless BIFROST_MCP_SPEC names another source. +const repoOpenAPISpec = "../../../docs/openapi/openapi.json" + +func readRepoSpec(t *testing.T) []byte { + t.Helper() + data, err := os.ReadFile(repoOpenAPISpec) + if err != nil { + t.Fatalf("reading %s: %v", repoOpenAPISpec, err) + } + return data +} + +func repoSpecOperations(t *testing.T) []openapimcp.Operation { + t.Helper() + doc, err := openapimcp.Parse(readRepoSpec(t), openapimcp.ParseOptions{}) + if err != nil { + t.Fatalf("parsing repo spec: %v", err) + } + return doc.Operations +} + +func bifrostMCPTestOptions(allowWrites bool) BifrostMCPOptions { + return BifrostMCPOptions{AllowWrites: allowWrites, ExecClient: http.DefaultClient} +} + +// listBifrostMCPTools returns the tools a built server answers tools/list with. +func listBifrostMCPTools(t *testing.T, opts BifrostMCPOptions) map[string]mcp.Tool { + t.Helper() + srv, _, err := buildBifrostMCPServer(readRepoSpec(t), "", opts) + if err != nil { + t.Fatalf("building server: %v", err) + } + tools := map[string]mcp.Tool{} + for name, st := range srv.ListTools() { + tools[name] = st.Tool + } + return tools +} + +// Every current operation in the spec becomes a tool unless it is denied. An +// operation the synthesizer cannot serve must be a file upload; anything else +// fails here by name, so a new endpoint that silently drops out of the MCP is +// caught in CI. +func TestBifrostMCPExposesEveryOperation(t *testing.T) { + _, report, err := buildBifrostMCPServer(readRepoSpec(t), "", bifrostMCPTestOptions(true)) + if err != nil { + t.Fatalf("building server: %v", err) + } + knownUnsupported := map[string]bool{} + for _, op := range report.unsupported { + knownUnsupported[op.OperationID] = true + if !strings.Contains(op.Reason, "multipart/binary") { + t.Errorf("operation %s (%s %s) cannot become a tool: %s", op.OperationID, op.Method, op.Path, op.Reason) + } + } + tools := listBifrostMCPTools(t, bifrostMCPTestOptions(true)) + for _, op := range repoSpecOperations(t) { + if op.Deprecated || bifrostMCPDenied[op.ID] || knownUnsupported[op.ID] || bifrostMCPEnterpriseOnly(op) { + continue + } + if _, ok := tools[op.ID]; !ok { + t.Errorf("operation %s (%s %s) is not served", op.ID, op.Method, op.Path) + } + } + if _, ok := tools["createChatCompletion"]; !ok { + t.Error("the inference route is not a tool") + } +} + +// Every deny-list entry names an operation the spec defines, and none is served. +func TestBifrostMCPDenyListMatchesSpec(t *testing.T) { + _, report, err := buildBifrostMCPServer(readRepoSpec(t), "", bifrostMCPTestOptions(true)) + if err != nil { + t.Fatalf("building server: %v", err) + } + sort.Strings(report.unknown) + if len(report.unknown) > 0 { + t.Errorf("denied operationIds missing from %s: %v", repoOpenAPISpec, report.unknown) + } + tools := listBifrostMCPTools(t, bifrostMCPTestOptions(true)) + for id := range bifrostMCPDenied { + if _, ok := tools[id]; ok { + t.Errorf("denied operation %s is served", id) + } + } +} + +// Every route-deny entry names an operation the spec defines, and the two deny +// lists name the same operations: each denied route's operation is a denied +// operationId, and each denied operationId's route is denied. +func TestBifrostMCPDeniedRoutesMatchSpec(t *testing.T) { + doc, err := openapimcp.Parse(readRepoSpec(t), openapimcp.ParseOptions{}) + if err != nil { + t.Fatalf("parsing %s: %v", repoOpenAPISpec, err) + } + matched := map[string]bool{} + for _, op := range doc.Operations { + key := bifrostMCPRouteKey(op.Method, op.Path) + if bifrostMCPDeniedRoutes[key] { + matched[key] = true + if !bifrostMCPDenied[op.ID] { + t.Errorf("denied route %s is operation %s, which bifrostMCPDenied does not list", key, op.ID) + } + } else if bifrostMCPDenied[op.ID] { + t.Errorf("denied operation %s (%s %s) has no entry in bifrostMCPDeniedRoutes", op.ID, op.Method, op.Path) + } + } + for key := range bifrostMCPDeniedRoutes { + if !matched[key] { + t.Errorf("denied route %s matches no operation in %s", key, repoOpenAPISpec) + } + } +} + +func TestBifrostMCPCuration(t *testing.T) { + enterprise := map[string]any{"x-mint": map[string]any{"metadata": map[string]any{"tag": "Enterprise"}}} + ops := []openapimcp.Operation{ + {ID: "listProviders", Method: http.MethodGet, Path: "/api/providers"}, + {ID: "addProvider", Method: http.MethodPost, Path: "/api/providers"}, + {ID: "chatCompletion", Method: http.MethodPost, Path: "/v1/chat/completions"}, + {ID: "login", Method: http.MethodPost, Path: "/api/session/login"}, + {ID: "listTeamsLegacy", Method: http.MethodGet, Path: "/api/teams", Deprecated: true}, + {ID: "listUsers", Method: http.MethodGet, Path: "/api/governance/users", Extensions: enterprise}, + // A newer spec renaming a denied operation, or its path parameter, keeps + // it out: the route is denied as well as the operationId. + {ID: "signIn", Method: http.MethodPost, Path: "/api/session/login"}, + {ID: "readOauthFlow", Method: http.MethodGet, Path: "/api/oauth/per-user/flows/{flow_id}"}, + } + ids := func(kept []openapimcp.Operation) []string { + var out []string + for _, op := range kept { + out = append(out, op.ID) + } + return out + } + kept, _, unknown := curateBifrostMCPOperations(ops, BifrostMCPOptions{AllowWrites: true}) + if got := ids(kept); strings.Join(got, ",") != "listProviders,addProvider,chatCompletion" { + t.Errorf("OSS, writes on kept %v, want listProviders, addProvider and chatCompletion", got) + } + if len(unknown) != len(bifrostMCPDenied)-1 { + t.Errorf("unknown = %d entries, want every denied id but login", len(unknown)) + } + // Writes gate /api only: with them off the inference POST stays, the /api POST goes. + kept, _, _ = curateBifrostMCPOperations(ops, BifrostMCPOptions{}) + if got := ids(kept); strings.Join(got, ",") != "listProviders,chatCompletion" { + t.Errorf("OSS, writes off kept %v, want listProviders and chatCompletion", got) + } + kept, _, _ = curateBifrostMCPOperations(ops, BifrostMCPOptions{AllowWrites: true, Edition: BifrostMCPEditionEnterprise}) + if got := ids(kept); strings.Join(got, ",") != "listProviders,addProvider,chatCompletion,listUsers" { + t.Errorf("enterprise kept %v, want the enterprise-only listUsers too", got) + } + // An operation the gateway does not route is left out and counted. + served := func(method, path string) bool { return method == http.MethodGet } + kept, unrouted, _ := curateBifrostMCPOperations(ops, BifrostMCPOptions{AllowWrites: true, Served: served}) + if got := ids(kept); strings.Join(got, ",") != "listProviders" || unrouted != 2 { + t.Errorf("served filter kept %v (unrouted %d), want listProviders with addProvider and chatCompletion unrouted", got, unrouted) + } +} + +// Bifrost's own spec writes its metadata filters as metadata_, so the +// Bifrost MCP expands them: getLogs takes metadata as an object of key to value. +func TestBifrostMCPExpandsMetadataTemplates(t *testing.T) { + getLogs, ok := listBifrostMCPTools(t, bifrostMCPTestOptions(true))["getLogs"] + if !ok { + t.Fatal("getLogs is not a tool") + } + schema := string(getLogs.RawInputSchema) + if strings.Contains(schema, `"metadata__key_"`) || !strings.Contains(schema, `"metadata":{`) { + t.Fatalf("metadata_ was not expanded into a metadata object argument: %s", schema) + } + if !strings.Contains(schema, `"type":"object"`) { + t.Errorf("metadata is not an object argument: %s", schema) + } +} + +// An OSS gateway serves none of the spec's enterprise-only operations, whichever +// spec it read; an enterprise gateway serves them. +func TestBifrostMCPEditions(t *testing.T) { + oss := listBifrostMCPTools(t, bifrostMCPTestOptions(true)) + if _, ok := oss["listUsers"]; ok { + t.Error("OSS serves the enterprise-only listUsers") + } + if _, ok := oss["listProviders"]; !ok { + t.Error("OSS lacks listProviders") + } + opts := bifrostMCPTestOptions(true) + opts.Edition = BifrostMCPEditionEnterprise + if _, ok := listBifrostMCPTools(t, opts)["listUsers"]; !ok { + t.Error("enterprise lacks listUsers") + } +} + +// The writes switch gates the gateway's own /api mutations. Inference and the +// other routes outside /api are served either way: the caller's credential +// authorizes them, as it would a direct call. +func TestBifrostMCPWritesSwitch(t *testing.T) { + readOnly := listBifrostMCPTools(t, bifrostMCPTestOptions(false)) + ops := map[string]openapimcp.Operation{} + for _, op := range repoSpecOperations(t) { + ops[op.ID] = op + } + for name, tool := range readOnly { + if !strings.HasPrefix(ops[name].Path, "/api/") { + continue + } + if tool.Annotations.ReadOnlyHint == nil || !*tool.Annotations.ReadOnlyHint { + t.Errorf("writes off: /api tool %s is served but not read-only", name) + } + } + if _, ok := readOnly["createChatCompletion"]; !ok { + t.Error("writes off: createChatCompletion missing; inference is not a write") + } + if _, ok := readOnly["listProviders"]; !ok { + t.Error("writes off: listProviders missing") + } + if _, ok := readOnly["createVirtualKey"]; ok { + t.Error("writes off: createVirtualKey is served") + } + + withWrites := listBifrostMCPTools(t, bifrostMCPTestOptions(true)) + if _, ok := withWrites["createVirtualKey"]; !ok { + t.Error("writes on: createVirtualKey missing") + } + if len(withWrites) <= len(readOnly) { + t.Errorf("writes on serves %d tools, writes off %d", len(withWrites), len(readOnly)) + } +} + +func TestBifrostMCPAnnotations(t *testing.T) { + tools := listBifrostMCPTools(t, bifrostMCPTestOptions(true)) + cases := []struct { + name string + readOnly, destructive bool + }{ + {"listVirtualKeys", true, false}, + {"createVirtualKey", false, false}, + {"updateVirtualKey", false, false}, + {"deleteVirtualKey", false, true}, + {"createWebhookEndpoint", false, false}, + } + for _, tc := range cases { + tool, ok := tools[tc.name] + if !ok { + t.Errorf("%s not served", tc.name) + continue + } + ann := tool.Annotations + if ann.ReadOnlyHint == nil || *ann.ReadOnlyHint != tc.readOnly { + t.Errorf("%s ReadOnlyHint = %v, want %v", tc.name, ann.ReadOnlyHint, tc.readOnly) + } + if ann.DestructiveHint == nil || *ann.DestructiveHint != tc.destructive { + t.Errorf("%s DestructiveHint = %v, want %v", tc.name, ann.DestructiveHint, tc.destructive) + } + if ann.OpenWorldHint == nil || *ann.OpenWorldHint { + t.Errorf("%s OpenWorldHint = %v, want false", tc.name, ann.OpenWorldHint) + } + } +} + +// x-bifrost-destructive marks a POST or PUT destructive; without it only DELETE +// is. Driven by synthetic operations: openapi.json is regenerated by CI after +// merge, so a marker added in the YAML is not in the committed bundle yet. +func TestBifrostMCPDestructiveMarker(t *testing.T) { + cases := []struct { + method string + ext map[string]any + want bool + }{ + {http.MethodPost, nil, false}, + {http.MethodPost, map[string]any{bifrostMCPDestructiveExt: true}, true}, + {http.MethodPut, map[string]any{bifrostMCPDestructiveExt: true}, true}, + {http.MethodPut, map[string]any{bifrostMCPDestructiveExt: false}, false}, + {http.MethodPost, map[string]any{bifrostMCPDestructiveExt: "yes"}, false}, + {http.MethodDelete, nil, true}, + } + for _, tc := range cases { + tool := openapimcp.Tool{Operation: openapimcp.Operation{ID: "op", Method: tc.method, Extensions: tc.ext}} + annotateBifrostMCPTool(&tool) + if got := tool.Annotations.DestructiveHint; got == nil || *got != tc.want { + t.Errorf("%s %v: DestructiveHint = %v, want %v", tc.method, tc.ext, got, tc.want) + } + } +} + +// A failed load leaves the server unset (the endpoint answers 503); a later +// successful load publishes it. +func TestBifrostMCPLoadFailureThenRecovery(t *testing.T) { + spec := readRepoSpec(t) + var healthy atomic.Bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if !healthy.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(spec) + })) + defer upstream.Close() + + m := NewBifrostMCP(BifrostMCPOptions{ + SpecSources: []string{upstream.URL + "/openapi.json"}, + SpecClient: upstream.Client(), + ExecClient: http.DefaultClient, + }) + if err := m.Load(context.Background()); err == nil { + t.Fatal("load succeeded against a failing upstream") + } + if m.Server() != nil { + t.Fatal("server published after a failed load") + } + healthy.Store(true) + if err := m.Load(context.Background()); err != nil { + t.Fatalf("load after recovery: %v", err) + } + if m.Server() == nil { + t.Fatal("server not published after a successful load") + } +} + +func TestBifrostMCPLoadFromFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "openapi.json"), readRepoSpec(t), 0o600); err != nil { + t.Fatal(err) + } + for name, opts := range map[string]BifrostMCPOptions{ + "relative to app dir": {SpecSources: []string{"openapi.json"}, AppDir: dir, ExecClient: http.DefaultClient}, + "absolute": {SpecSources: []string{filepath.Join(dir, "openapi.json")}, ExecClient: http.DefaultClient}, + } { + m := NewBifrostMCP(opts) + if err := m.Load(context.Background()); err != nil { + t.Errorf("%s: %v", name, err) + } + } +} + +func TestBifrostMCPSpecSources(t *testing.T) { + t.Setenv(BifrostMCPSpecEnv, "") + suffixes := func(sources []string) []string { + var out []string + for _, s := range sources { + out = append(out, s[strings.Index(s, "/refs/"):]) + } + return out + } + // Every build reads dev's spec, whatever its version: a version can look like + // a release with no spec behind it (enterprise's dev build stamps v1.2.3), and + // only operations the gateway routes become tools, so dev's newer spec cannot + // expose an operation this build lacks. + cases := []struct { + edition BifrostMCPEdition + want []string + }{ + {BifrostMCPEditionOSS, []string{"/refs/heads/dev/docs/openapi/openapi-oss.json", "/refs/heads/dev/docs/openapi/openapi.json"}}, + {BifrostMCPEditionEnterprise, []string{"/refs/heads/dev/docs/openapi/openapi.json"}}, + } + for _, tc := range cases { + if got := suffixes(BifrostMCPSpecSources(tc.edition)); strings.Join(got, " ") != strings.Join(tc.want, " ") { + t.Errorf("%s reads %v, want %v", tc.edition, got, tc.want) + } + } + t.Setenv(BifrostMCPSpecEnv, "/etc/bifrost/openapi.json") + if got := BifrostMCPSpecSources(BifrostMCPEditionOSS); len(got) != 1 || got[0] != "/etc/bifrost/openapi.json" { + t.Errorf("override ignored: got %v", got) + } +} + +// A missing OSS spec (a release cut before CI published it) falls back to the +// full one; its enterprise-only operations are still left out. +func TestBifrostMCPSpecFallback(t *testing.T) { + spec := readRepoSpec(t) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/openapi.json" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(spec) + })) + defer upstream.Close() + + m := NewBifrostMCP(BifrostMCPOptions{ + SpecSources: []string{upstream.URL + "/openapi-oss.json", upstream.URL + "/openapi.json"}, + SpecClient: upstream.Client(), + ExecClient: http.DefaultClient, + }) + if err := m.Load(context.Background()); err != nil { + t.Fatalf("load with fallback: %v", err) + } + tools := m.Server().ListTools() + if _, ok := tools["listProviders"]; !ok { + t.Error("listProviders missing after fallback") + } + if _, ok := tools["listUsers"]; ok { + t.Error("enterprise-only listUsers served after falling back to the full spec") + } + + none := NewBifrostMCP(BifrostMCPOptions{SpecSources: []string{upstream.URL + "/a.json", upstream.URL + "/b.json"}, SpecClient: upstream.Client(), ExecClient: http.DefaultClient}) + err := none.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "/a.json") || !strings.Contains(err.Error(), "/b.json") { + t.Errorf("all sources failing: %v, want both named", err) + } +} + +func TestBifrostMCPAllowWrites(t *testing.T) { + // Writes are on unless turned off: /api still authorizes every call with the + // caller's own credential, and clients confirm what destructiveHint marks. + t.Setenv(BifrostMCPWritesEnv, "") + if !BifrostMCPAllowWrites() { + t.Error("unset: writes are off, want on by default") + } + t.Setenv(BifrostMCPWritesEnv, "true") + if !BifrostMCPAllowWrites() { + t.Error("true not honoured") + } + t.Setenv(BifrostMCPWritesEnv, "false") + if BifrostMCPAllowWrites() { + t.Error("false not honoured") + } +} diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index 7d69209ccc..6e99f67270 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -285,6 +285,7 @@ type BifrostHTTPServer struct { RSSFeedsHandler *handlers.RSSFeedsHandler WarpHandler *handlers.WarpHandler MCPServerHandler *handlers.MCPServerHandler + BifrostMCP *handlers.BifrostMCP // /api as MCP tools on /v1/mcp; its server loads after boot AgentGatewayHandler *handlers.AgentGatewayHandler devPprofHandler *handlers.DevPprofHandler skillsServingHandler *handlers.SkillsServingHandler