Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a57f803
docs: mark direct MCP DCR plan approved
jhrozek Sep 10, 2026
c3fb882
test(mcp): specify direct DCR registration contract
jhrozek Sep 10, 2026
e1d24d9
feat(mcp): add direct OAuth DCR registration
Sep 10, 2026
09c943e
docs: mark direct MCP DCR implementation in progress
jhrozek Sep 10, 2026
2fb4dbb
fix(mcp): constrain direct DCR to no-refresh
jhrozek Sep 10, 2026
0591f0b
docs: amend direct DCR to no-refresh
jhrozek Sep 10, 2026
b6d3177
feat(mcp): persist no-refresh DCR grants
jhrozek Sep 10, 2026
eceaa3b
feat(mcp): expose direct DCR recovery
jhrozek Sep 10, 2026
2a13f1e
chore(mcp): document callback complexity
jhrozek Sep 10, 2026
27fd0e7
docs: report direct DCR acceptance gaps
jhrozek Sep 10, 2026
04a34da
test(mcp): pin direct DCR acceptance proofs
Sep 10, 2026
c4223fc
docs: mark direct MCP DCR candidate landed
jhrozek Sep 10, 2026
4da45a7
fix: harden direct MCP DCR authorization
jhrozek Sep 10, 2026
83b109f
fix(mcp): harden direct DCR recovery
jhrozek Sep 10, 2026
f5e8555
fix(mcp): close direct DCR review gaps
jhrozek Sep 10, 2026
2e5f53b
fix(mcp): preserve direct DCR recovery evidence
jhrozek Sep 11, 2026
1d137c2
Fixes from review
jhrozek Sep 11, 2026
af84c79
fix(mcp): repair direct DCR recovery lifecycle
jhrozek Sep 14, 2026
4f226de
fix(mcp): repair DCR reset/retry CLI test key after lifecycle-key rename
jhrozek Sep 14, 2026
756d29e
fix(mcp): close direct DCR rereview gaps
JAORMX Sep 15, 2026
beafc4f
fix(deps): synchronize root module with provider dependencies
JAORMX Sep 15, 2026
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
6 changes: 3 additions & 3 deletions cmd/mecated/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ func resolveMCPSubcommand(args []string) commandResolution {
}

func writeMCPHelp(out io.Writer) {
_, _ = fmt.Fprintln(out, "Usage: mecated mcp login SERVER [--no-browser] [--permission-config PATH ...]\n\nAuthorize one operator-configured OAuth MCP server. --permission-config selects trusted operator settings only; it never supplies OAuth values.")
_, _ = fmt.Fprintln(out, "Usage: "+strings.TrimPrefix(mcpLoginUsage, "usage: ")+"\n\nAuthorize one operator-configured OAuth MCP server. --permission-config selects trusted operator settings only; it never supplies OAuth values. DCR recovery modifiers are mutually exclusive and perform an explicit registration operation before login.")
}

func mcpUsageError(argv []string) error {
Expand All @@ -278,9 +278,9 @@ func mcpUsageError(argv []string) error {
sub = argv[2]
}
if sub == "" {
return errors.New("mcp: missing subcommand\navailable subcommands:\n mcp login SERVER [--no-browser] [--permission-config PATH ...] authorize a configured OAuth server")
return errors.New("mcp: missing subcommand\navailable subcommands:\n " + strings.TrimPrefix(mcpLoginUsage, "usage: mecated ") + " authorize a configured OAuth server")
}
return fmt.Errorf("mcp: unknown subcommand %q\navailable subcommands:\n mcp login SERVER [--no-browser] [--permission-config PATH ...] authorize a configured OAuth server", sub)
return fmt.Errorf("mcp: unknown subcommand %q\navailable subcommands:\n %s authorize a configured OAuth server", sub, strings.TrimPrefix(mcpLoginUsage, "usage: mecated "))
}

// configUsageError builds the error message for a bare/unknown `config` invocation.
Expand Down
52 changes: 43 additions & 9 deletions cmd/mecated/mcplogin.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@ import (
"github.com/stacklok/mecatl/mcp/oauthlogin"
)

var errMCPLoginUsage = errors.New("usage: mecated mcp login SERVER [--no-browser] [--permission-config PATH ...]")
const mcpLoginUsage = "usage: mecated mcp login SERVER [--no-browser] [--permission-config PATH ...] [--reset-dcr-registration | --retry-dcr-registration]"

var errMCPLoginUsage = errors.New(mcpLoginUsage)

type mcpLoginArgs struct {
server string
noBrowser bool
permissionConfigs []string
dcrAction mcp.OAuthDCRLoginAction
}

func parseMCPLoginArgs(args []string, out io.Writer) (mcpLoginArgs, error) {
Expand All @@ -33,19 +36,33 @@ func parseMCPLoginArgs(args []string, out io.Writer) (mcpLoginArgs, error) {
arg := args[i]
switch arg {
case "-h", "--help":
_, _ = fmt.Fprintln(out, "Usage: mecated mcp login SERVER [--no-browser] [--permission-config PATH ...]\n\nAuthorize one operator-configured OAuth MCP server. --no-browser prints the terminal authorization URL. --permission-config selects trusted operator settings and is repeatable.")
_, _ = fmt.Fprintln(out, "Usage: "+strings.TrimPrefix(mcpLoginUsage, "usage: ")+"\n\nAuthorize one operator-configured OAuth MCP server. --no-browser prints the terminal authorization URL. --permission-config selects trusted operator settings and is repeatable. DCR registration recovery requires exactly one explicit --reset-dcr-registration or --retry-dcr-registration operation.")
return mcpLoginArgs{}, flag.ErrHelp
case "--no-browser":
if parsed.noBrowser {
return mcpLoginArgs{}, errMCPLoginUsage
}
parsed.noBrowser = true
case "--reset-dcr-registration":
if parsed.dcrAction != mcp.OAuthDCRLoginReuse {
return mcpLoginArgs{}, errMCPLoginUsage
}
parsed.dcrAction = mcp.OAuthDCRLoginResetRegistration
case "--retry-dcr-registration":
if parsed.dcrAction != mcp.OAuthDCRLoginReuse {
return mcpLoginArgs{}, errMCPLoginUsage
}
parsed.dcrAction = mcp.OAuthDCRLoginRetryRegistration
case "--permission-config":
i++
if i >= len(args) || args[i] == "" || strings.HasPrefix(args[i], "-") {
if i+1 >= len(args) {
return mcpLoginArgs{}, errMCPLoginUsage
}
parsed.permissionConfigs = append(parsed.permissionConfigs, args[i])
path := args[i+1] // #nosec G602 -- the immediately preceding bound check proves i+1 is valid.
if path == "" || strings.HasPrefix(path, "-") {
return mcpLoginArgs{}, errMCPLoginUsage
}
i++
parsed.permissionConfigs = append(parsed.permissionConfigs, path)
default:
if path, ok := strings.CutPrefix(arg, "--permission-config="); ok {
if path == "" {
Expand Down Expand Up @@ -91,6 +108,23 @@ func mcpLoginRemedy(err error) error {
var rejected *oauthlogin.CallbackRejectedError
var bind *oauthlogin.CallbackBindError
switch {
case errors.Is(err, mcp.ErrOAuthDCRRecoveryRequired):
switch mcp.OAuthDCRRecoveryCategoryOf(err) {
case mcp.OAuthDCRRecoveryPending:
return errors.New("MCP OAuth DCR previous registration attempt did not complete and its exact safe failure stage was not recorded; use --retry-dcr-registration only if creating a duplicate or orphan client is acceptable")
case mcp.OAuthDCRRecoveryRegistrationOutcomeUnknown:
return errors.New("MCP OAuth DCR registration request outcome is unknown; use --retry-dcr-registration only if a possible orphan client is acceptable")
case mcp.OAuthDCRRecoveryResponseInvalid:
return errors.New("the OAuth provider returned a registration response that Mecatl could not safely use for DCR; use --retry-dcr-registration only if a possible orphan client is acceptable")
case mcp.OAuthDCRRecoveryReadyPersistence:
return errors.New("MCP OAuth DCR registration response was accepted but the ready record was not persisted; use --retry-dcr-registration only if a possible orphan client is acceptable")
case mcp.OAuthDCRRecoveryResetRequired:
return errors.New("MCP OAuth DCR valid ready registration identity differs from the current profile, principal, canonical resource, or exact issuer; use --reset-dcr-registration")
case mcp.OAuthDCRRecoveryPendingIdentityMismatch:
return errors.New("MCP OAuth DCR pending registration identity does not match the current configuration; restore the matching profile, principal, canonical resource, and exact issuer, then use --retry-dcr-registration")
default:
return errors.New("MCP OAuth DCR registration state is corrupt or unreadable; do not retry or reset registration. Preserve the records and configuration without editing, deleting, or renaming them. Contact the deployment operator or support team with only the server name and redacted command error; never send credential contents, OAuth URLs, client IDs, tokens, keys, or a raw response. Reset/retry do not repair corrupt state or revoke an upstream client")
}
case errors.As(err, &provider) && errors.Is(err, app.ErrMCPLoginAuthorization):
return fmt.Errorf("MCP OAuth authorization server rejected login (%s); review the requested scopes and provider policy", provider.Sanitized())
case errors.As(err, &rejected) && errors.Is(err, app.ErrMCPLoginAuthorization):
Expand All @@ -115,12 +149,12 @@ func mcpLoginRemedy(err error) error {
}
}

var executeMCPLogin = func(ctx context.Context, server mcp.ServerConfig, opts oauthlogin.Options) error {
runtime, err := oauthlogin.New(opts)
var executeMCPLogin = func(ctx context.Context, server mcp.ServerConfig, runtimeOpts oauthlogin.Options, loginOpts app.MCPLoginOptions) error {
runtime, err := oauthlogin.New(runtimeOpts)
if err != nil {
return errors.New("MCP OAuth login runtime unavailable")
}
return app.LoginMCP(ctx, server, runtime)
return app.LoginMCPWithOptions(ctx, server, runtime, loginOpts)
}

func loadMCPLoginProfiles(explicit []string) (*cliconfig.MCPProfiles, error) {
Expand Down Expand Up @@ -155,7 +189,7 @@ func runMCPLogin(args []string, stdout io.Writer) error {
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := executeMCPLogin(ctx, server, opts); err != nil {
if err := executeMCPLogin(ctx, server, opts, app.MCPLoginOptions{DCRAction: parsed.dcrAction}); err != nil {
return mcpLoginRemedy(err)
}
_, err = fmt.Fprintf(stdout, "MCP OAuth login succeeded for %s\n", server.Name)
Expand Down
Loading
Loading