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
14 changes: 14 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Default rules plus one narrow exemption.
[extend]
useDefault = true

# mcpurl redacts JWT-shaped runs out of server-supplied text (pkg/redact), and
# the tests for that have to carry fixtures that look exactly like a JWT — a
# fixture that does not match the shape would test nothing. These are the
# public jwt.io sample tokens: header {"alg":"RS256"}, payload {"sub":"user"},
# no secret behind them. Scoped to the jwt rule and to _test.go so a real
# credential committed anywhere else is still caught.
[[allowlists]]
description = "JWT-shaped fixtures in tests"
targetRules = ["jwt"]
paths = ['''.*_test\.go$''']
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ mcpurl login @mysrv # browser → IdP → tokens in the keychain
mcpurl token @mysrv # print a live access token (auto-refresh) for curl
```

Undo: `mcpurl uninstall mysrv` (profile and tokens stay; `mcpurl logout
@mysrv` drops the tokens).
Undo: `mcpurl uninstall mysrv` (or `@mysrv`) — it removes the client entry, the
profile it references and the stored tokens. Re-running `install` on an
existing name updates the profile in place.

> **Claude Code doesn't need a bridge** — it connects directly:
> `claude mcp add --transport http mysrv https://mcp.example.com/mcp`
Expand All @@ -80,13 +81,16 @@ mcpurl show [name] list profiles / show one (no secrets)
mcpurl claude-config <url | @profile> print ready-to-paste client config
mcpurl install <url | @profile> [--client claude-desktop|cursor|windsurf]
write the entry into the client config
mcpurl uninstall <name> [--client ...] remove a previously installed entry
mcpurl uninstall <name | @profile> [--client ...]
remove the entry, its profile, tokens
mcpurl version
```

The bare-URL form works with zero config against any spec-compliant server —
discovery does the rest. A profile only pins what discovery can't provide
(client_id, a non-default callback port, scope overrides).
(client_id, a non-default callback port, scope overrides). The `@` is a
readability marker, not a requirement: `@mysrv` and `mysrv` are the same
target in every command.

## Profiles

Expand Down Expand Up @@ -122,7 +126,7 @@ Resolution order for every setting: **flag > profile > discovery > default**.
| `--no-sse` | false | Don't open the GET listening stream |
| `--allow-http` | false | Permit plain http for non-loopback hosts |
| `--timeout 10s` | `10s` | Connect/TLS timeout (no overall response timeout — tools can be slow) |
| `-v` | false | Debug log to stderr, `Authorization` redacted |
| `-v` | false | Debug log to stderr: statuses, challenges and failed-response bodies; never request headers or token material |

## How auth works

Expand Down
18 changes: 12 additions & 6 deletions cmd/mcpurl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import (

const appName = "mcpurl"

// clientHelp is the --client flag help, derived from the one supported list.
var clientHelp = "target MCP client: " + strings.Join(app.Clients(), "|")

var version = "dev" // stamped via -ldflags "-X main.version=…"

// Exit codes (02-cli.md).
Expand Down Expand Up @@ -78,11 +81,14 @@ Usage:
mcpurl claude-config <url | @profile> print MCP client config snippets
mcpurl install [flags] <url | @profile>
write the entry into the MCP client's
config (persists a profile for URLs)
mcpurl uninstall <name> [--client ...] remove a previously installed entry
config (persists/updates the profile)
mcpurl uninstall <name | @profile> [--client ...]
remove the entry, its profile and its
stored tokens
mcpurl version

Profiles: ~/.config/mcpurl/config.toml (MCPURL_CONFIG overrides). Run
Profiles: ~/.config/mcpurl/config.toml (MCPURL_CONFIG overrides). The "@" is
optional everywhere — "@mysrv" and "mysrv" are the same target. Run
'mcpurl <cmd> -h' for flags.`)
}

Expand Down Expand Up @@ -251,7 +257,7 @@ func cmdLogout(args []string) int {
func cmdInstall(args []string) int {
var o app.Options
fs := newFlagSet(appName+" install", &o)
client := fs.String("client", "claude-desktop", "target MCP client: claude-desktop|cursor|windsurf")
client := fs.String("client", "claude-desktop", clientHelp)
name := fs.String("name", "", "entry/profile name (default: profile name or host)")
dryRun := fs.Bool("dry-run", false, "print the resulting config without writing")
target, err := parseTarget(fs, args)
Expand All @@ -274,10 +280,10 @@ func cmdInstall(args []string) int {

func cmdUninstall(args []string) int {
fs := flag.NewFlagSet(appName+" uninstall", flag.ContinueOnError)
client := fs.String("client", "claude-desktop", "target MCP client: claude-desktop|cursor|windsurf")
client := fs.String("client", "claude-desktop", clientHelp)
name, err := parseTarget(fs, args)
if err != nil || name == "" {
return fail(exitUsage, errors.New("usage: mcpurl uninstall <name> [--client ...]"))
return fail(exitUsage, errors.New("usage: mcpurl uninstall <name | @profile> [--client ...]"))
}
a, err := app.New(app.Options{}, os.Stderr)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/vmkteam/mcpurl

go 1.26.5
go 1.26.7

require (
github.com/BurntSushi/toml v1.6.0
Expand Down
5 changes: 4 additions & 1 deletion pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,17 @@ func (a *App) tokenProvider() mcp.TokenProvider {
return a.flow()
}

// store is the token backend for this run (--no-keychain forces files).
func (a *App) store() oauth.Store { return oauth.NewStore(a.opts.NoKeychain) }

func (a *App) flow() *oauth.Flow {
return &oauth.Flow{
Endpoint: a.prof.URL,
ClientID: a.prof.ClientID,
Scopes: a.prof.Scopes,
Issuer: a.prof.Issuer,
CallbackPort: a.prof.CallbackPort,
Store: oauth.NewStore(a.opts.NoKeychain),
Store: a.store(),
HTTP: a.httpc,
Logf: a.logf,
Warnf: a.warnf,
Expand Down
31 changes: 31 additions & 0 deletions pkg/app/app_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package app

import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -73,6 +78,32 @@ func TestNewWithoutTarget(t *testing.T) {
assert.NoError(t, a.ClaudeConfig(io.Discard, "@acme", "")) //nolint:testifylint // value check
}

// A server that refuses the credential ends the run on exit code 3, with its
// own explanation both in the error the CLI prints and in the JSON-RPC error
// the MCP client shows (docs/tasks/02-401-body-discarded.md).
func TestBridgeAuthFailure(t *testing.T) {
writeConfig(t)
t.Setenv("MCPURL_TEST_BEARER", "static-key")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
http.Error(w, `auth: invalid token: oidc: malformed jwt: unexpected signature algorithm "HS256"`,
http.StatusUnauthorized)
}))
defer srv.Close()

a, err := New(Options{Target: srv.URL, BearerEnv: "MCPURL_TEST_BEARER"}, io.Discard)
require.NoError(t, err)

const initReq = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`
var out bytes.Buffer
err = a.Bridge(context.Background(), strings.NewReader(initReq+"\n"), &out)
require.Error(t, err)
assert.True(t, IsAuthError(err), "must map to exit code 3")
assert.Contains(t, err.Error(), "malformed jwt", "the server's diagnosis must reach stderr")
assert.Contains(t, out.String(), `"code":-32603`)
assert.Contains(t, out.String(), "malformed jwt", "…and the MCP client")
}

func TestCheckScheme(t *testing.T) {
tests := []struct {
url string
Expand Down
33 changes: 21 additions & 12 deletions pkg/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"io"
"maps"
"net/url"
"slices"
"strings"

Expand Down Expand Up @@ -61,21 +60,24 @@ func (a *App) Logout() error {
if a.prof.ClientID == "" {
return errors.New("logout needs a clientID (profile or --client-id) to locate the stored token")
}
a.dropTokens(oauth.NewStore(a.opts.NoKeychain), a.prof)
a.dropTokens(a.store(), a.prof)
return nil
}

// LogoutAll drops tokens for every configured profile plus file-backend
// leftovers from bare-URL runs; needs no target resolution.
func (a *App) LogoutAll() error {
store := oauth.NewStore(a.opts.NoKeychain)
store := a.store()
for _, name := range a.cfg.Names() {
a.dropTokens(store, a.cfg.Profiles[name])
}
oauth.WipeFiles() //nolint:errcheck // best-effort cleanup
return nil
}

// dropTokens deletes the stored token for p. The deletion is unconditional
// (an unreadable leftover must go too), but nothing is reported when there was
// nothing stored — "dropped tokens" for an empty store reads as a lie.
func (a *App) dropTokens(store oauth.Store, p Profile) {
if p.URL == "" || p.ClientID == "" {
return
Expand All @@ -84,11 +86,17 @@ func (a *App) dropTokens(store oauth.Store, p Profile) {
if err != nil {
return
}
// Load first only to decide whether to report: it costs one os.ReadFile
// for the "nothing stored" case (both backends answer ErrNotFound before
// touching the keyring), and Delete must stay a no-op either way.
_, loadErr := store.Load(key)
if err := store.Delete(key); err != nil {
a.warnf("logout %s: %v", p.Name, err)
return
}
fmt.Fprintf(a.msg, "dropped tokens for %s\n", p.URL)
if !errors.Is(loadErr, oauth.ErrNotFound) {
fmt.Fprintf(a.msg, "dropped tokens for %s\n", p.URL)
}
}

// Show writes the profile list (name == "") or one profile without secrets.
Expand All @@ -107,8 +115,7 @@ func (a *App) Show(w io.Writer, name string) error {
if err != nil {
return err
}
name = strings.TrimPrefix(name, "@")
fmt.Fprintf(w, "profile: @%s\nurl: %s\n", name, p.URL)
fmt.Fprintf(w, "profile: @%s\nurl: %s\n", p.Name, p.URL)
if p.ClientID != "" {
fmt.Fprintf(w, "clientID: %s\n", p.ClientID)
}
Expand Down Expand Up @@ -142,11 +149,13 @@ func (a *App) ClaudeConfig(w io.Writer, target, name string) error {
name = prof.Name
}
if name == "" {
if u, err := url.Parse(prof.URL); err == nil {
name = strings.Split(u.Hostname(), ".")[0]
} else {
name = "mcp"
}
name = hostLabel(prof.URL) // same default install uses
}
// The snippet is config, not shorthand: spell the profile reference the
// way install writes it, "@" included, whichever form was typed.
arg := target
if prof.Name != "" {
arg = "@" + prof.Name
}
fmt.Fprintf(w, `# Claude Desktop / Cursor / Windsurf (stdio bridge):
{
Expand All @@ -159,6 +168,6 @@ func (a *App) ClaudeConfig(w io.Writer, target, name string) error {
}
# Claude Code (no bridge needed — connects directly):
# claude mcp add --transport http %s %s
`, name, target, name, prof.URL)
`, name, arg, name, prof.URL)
return nil
}
25 changes: 25 additions & 0 deletions pkg/app/commands_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package app

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// claude-config used to be the one command that insisted on the "@" — the
// same asymmetry that made `uninstall @name` fail from the other side.
func TestClaudeConfigAcceptsBareName(t *testing.T) {
writeConfig(t)
a, err := New(Options{}, nil)
require.NoError(t, err)

var bare, at strings.Builder
require.NoError(t, a.ClaudeConfig(&bare, "acme", ""))
require.NoError(t, a.ClaudeConfig(&at, "@acme", ""))
assert.Equal(t, at.String(), bare.String(), "both spellings render the same snippet")
assert.Contains(t, bare.String(), "https://mcp.acme.example/mcp")
assert.Contains(t, bare.String(), `"@acme"`,
"the snippet is config: spell the reference the way install writes it")
}
Loading
Loading