Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
44e242d
Add sign-in challenges and the authenticate tool to aitools mcp serve
simonfaltum Nov 12, 2026
cd431a4
Update the no-record notice in the authenticate golden
simonfaltum Nov 12, 2026
53b1098
Answer the challenge test requests with their own ids
simonfaltum Nov 13, 2026
64f387d
Update the authenticate golden for the sign-in notice
simonfaltum Nov 13, 2026
0c9ef32
Reuse the login sign-in helper in the authenticate tool
simonfaltum Nov 13, 2026
08dad27
Set MSYS_NO_PATHCONV on the new acceptance API calls
simonfaltum Nov 13, 2026
6966f8c
Print the login command of the profile's config file in the sign-in h…
simonfaltum Nov 13, 2026
ea1ca88
Answer nothing for an authenticate call the client cancelled
simonfaltum Nov 13, 2026
d8758b2
Open the sign-in page again after an attempt that did not hold it
simonfaltum Nov 13, 2026
1f38642
Intercept authenticate only after the service challenged the session
simonfaltum Nov 13, 2026
deb6121
Match service names without case in sign-in URLs and provider labels
simonfaltum Nov 13, 2026
0ba0ec2
Start a fresh sign-in after a challenge that follows an already-signe…
simonfaltum Nov 13, 2026
c9308ea
Route serve's sign-in client through the refresh guard and bound the …
simonfaltum Nov 13, 2026
ee5b508
Send progress notifications while authenticate waits
simonfaltum Nov 13, 2026
f5d7e52
Correct the serve comments on the sign-in options and the shared guard
simonfaltum Nov 13, 2026
c66c26c
Apply the whitespace and assertion fixes from the repo checks
simonfaltum Nov 13, 2026
e5e1e48
Ask the agent to list again after an already-signed-in answer
simonfaltum Nov 13, 2026
2b6b370
Drop the fake's unused sign-in challenge modes
simonfaltum Nov 13, 2026
02fb32e
Share the sign-in path and timeout error between mcp and the proxy
simonfaltum Nov 13, 2026
b19f097
Find sign-in URLs in markdown and angle brackets, and read a null pro…
simonfaltum Nov 13, 2026
60f891c
Authenticate the sign-in client through the refresh guard
simonfaltum Nov 13, 2026
6678318
Serialize sign-in progress with the bridge's stderr and compact hook …
simonfaltum Nov 13, 2026
3a7082b
Keep the authenticate tool's recovery state in step with the service
simonfaltum Nov 13, 2026
1ac0b76
Forward authenticate once the service's list passes again
simonfaltum Nov 13, 2026
f76f678
Let a queued authenticate call be cancelled and report progress
simonfaltum Nov 13, 2026
46c767c
Name the last failed check and the sign-in page on a timed-out sign-in
simonfaltum Nov 13, 2026
a6b6c21
Tidy the sign-in helpers: one serve target, shared method names, clea…
simonfaltum Nov 13, 2026
a7c7461
Treat a 401 as a sign-in challenge only when its body asks for sign-in
simonfaltum Nov 13, 2026
90f006c
Start a server whose startup read fails for a reason other than the c…
simonfaltum Nov 13, 2026
d86f4fd
Use SplitSeq in the stderr test
simonfaltum Nov 13, 2026
4d42c2e
Use a rejected token in the serve tests that expect the proxy to stop
simonfaltum Nov 13, 2026
c282b1a
Test that a transient failure of the sign-in client does not stop the…
simonfaltum Nov 13, 2026
f32bf21
Read sign-in state only from the response to the request
simonfaltum Nov 13, 2026
16da5cc
End a pending sign-in restart with the next success
simonfaltum Nov 13, 2026
d10e158
Keep authenticate listed after a challenged tool call
simonfaltum Nov 13, 2026
7c3ed23
Send no sign-in progress for a call that was cancelled
simonfaltum Nov 13, 2026
06f440e
Name the sign-in page in progress on every poll
simonfaltum Nov 13, 2026
181a61a
Name the sign-in timeout only in the sign-in's own error
simonfaltum Nov 13, 2026
97f6a70
Compile the sign-in URL pattern on first use
simonfaltum Nov 13, 2026
95c2b7a
Pass signIn only the options the caller sets
simonfaltum Nov 13, 2026
413d8bb
Describe sign-in and profile failures with the profile and fix
simonfaltum Nov 13, 2026
9085d79
Start serve quietly on a signal and build the sign-in client when needed
simonfaltum Nov 13, 2026
ee142d7
Log serve acceptance setup output to LOG files
simonfaltum Nov 13, 2026
1ce44aa
Share one JSON-RPC response envelope in the proxy
simonfaltum Nov 13, 2026
4875614
Test the refresh failure of the startup read and the sign-in together
simonfaltum Nov 13, 2026
263f060
Update the sign-in state comments for the call challenge
simonfaltum Nov 13, 2026
7803f2c
Stop serve with the login hint when a rejected token fails the startu…
simonfaltum Nov 13, 2026
edbfb76
Pass through responses the sign-in checks cannot read
simonfaltum Nov 13, 2026
c458c8e
Keep the Already signed in state until a tool call succeeds
simonfaltum Nov 13, 2026
a4e627d
Ask for a logout instead of restarting the sign-in after Already sign…
simonfaltum Nov 13, 2026
c48b65a
List authenticate on the first page only, and keep a service's own au…
simonfaltum Nov 13, 2026
db50827
Open the sign-in page with no stdin and a context the request cannot …
simonfaltum Nov 13, 2026
92e04cd
Wait for the last answers before the serve acceptance script closes s…
simonfaltum Nov 13, 2026
185760c
Make the sign-in page opener optional, and tidy the serve helpers
simonfaltum Nov 13, 2026
6b41591
Read upstream replies through one helper
simonfaltum Nov 13, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[logfood]
host = $DATABRICKS_HOST
token = test-token-logfood
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Cloud = false
43 changes: 43 additions & 0 deletions acceptance/cmd/aitools/mcp/serve-authenticate-tool/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@

=== Before sign-in, tools/list answers with one tool, authenticate; it opened no sign-in

>>> jq -c select(.id==2) | [.result.tools[].name] serve.stdout.txt
["authenticate"]

>>> cat stderr-before-authenticate.txt
No install record for system.ai.github on profile "logfood"; using its current host [DATABRICKS_URL]

=== authenticate waits for the sign-in, then answers and asks the agent to re-list

>>> jq -c select(.id==3) | .result | {isError, text: .content[0].text} serve.stdout.txt
{"isError":false,"text":"Signed in to system.ai.github on profile logfood. Tools are available now; retry the user's request in this turn."}

>>> jq -c select(.method=="notifications/tools/list_changed") serve.stdout.txt
{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}

>>> cat serve.stderr.txt
No install record for system.ai.github on profile "logfood"; using its current host [DATABRICKS_URL]
Sign in with the same account you use for Databricks profile logfood.
Open this URL to sign in to system.ai.github:
[DATABRICKS_URL]/mcp-service-login?name=system.ai.github&o=[WORKSPACE_ID]
Waiting for sign-in...
Signed in to system.ai.github on profile logfood.
Public repositories only until a repository owner installs https://github.com/apps/databricks-github-connector.

=== After the sign-in, tools/list passes through and tools/call reaches the service

>>> jq -c select(.id==4) | [.result.tools[].name] serve.stdout.txt
["echo"]

>>> jq -c select(.id==5) | .result.content[0].text serve.stdout.txt
"hello"

=== Stdout holds only JSON-RPC lines

>>> jq -c .jsonrpc serve.stdout.txt
"2.0"
"2.0"
"2.0"
"2.0"
"2.0"
"2.0"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"jsonrpc":"2.0","id":4,"method":"tools/list"}
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hello"}}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"authenticate","arguments":{}}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test-client","version":"0.0.1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
43 changes: 43 additions & 0 deletions acceptance/cmd/aitools/mcp/serve-authenticate-tool/script
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Isolate HOME so the proxy finds no state file and no agent config, as on a fresh machine.
sethome home
envsubst < .databrickscfg > out && mv out .databrickscfg
export DATABRICKS_CONFIG_FILE=.databrickscfg
# No browser: the proxy prints the sign-in URL and does not open anything.
export BROWSER=none
# The sign-in is polled every 2 s, so the waits below need more attempts than the default.
export RETRY_MAX_ATTEMPTS=60

# The fake gets the GitHub service with its OAuth connection; the caller has no credential for it yet.
MSYS_NO_PATHCONV=1 $CLI api post "/api/2.1/unity-catalog/mcp-services?parent=schemas/system.ai&mcp_service_id=github" --profile logfood --json '{"comment":"Repositories, issues, pull requests","config":{"source_connection":{"name":"connections/github","options":{"oauth_provider":"github"}}}}' &> LOG.create-github

# Stdin stays open until the last request is answered: the proxy shuts down at EOF and cancels a call
# still in flight after 2 s. Each step waits for the previous answer, so the order is deterministic.
# The last steps wait for ids 4 and 5 too, so the proxy has answered them before stdin closes.
{
cat requests-list.ndjson
retry --until '"id":2' cat serve.stdout.txt > /dev/null
cat serve.stderr.txt > stderr-before-authenticate.txt
cat requests-authenticate.ndjson
retry --until "Waiting for sign-in" cat serve.stderr.txt > /dev/null
MSYS_NO_PATHCONV=1 $CLI api post /api/2.1/unity-catalog/mcp-services/system.ai.github/user-credentials --profile logfood --json '{}' &> LOG.sign-in-credential
retry --until '"id":3' cat serve.stdout.txt > /dev/null
cat requests-after.ndjson
retry --until '"id":4' cat serve.stdout.txt > /dev/null
retry --until '"id":5' cat serve.stdout.txt > /dev/null
} | $CLI aitools mcp serve --profile logfood --service system.ai.github --agent claude-code > serve.stdout.txt 2> serve.stderr.txt

title "Before sign-in, tools/list answers with one tool, authenticate; it opened no sign-in"
trace jq -c 'select(.id==2) | [.result.tools[].name]' serve.stdout.txt
trace cat stderr-before-authenticate.txt

title "authenticate waits for the sign-in, then answers and asks the agent to re-list"
trace jq -c 'select(.id==3) | .result | {isError, text: .content[0].text}' serve.stdout.txt
trace jq -c 'select(.method=="notifications/tools/list_changed")' serve.stdout.txt
trace cat serve.stderr.txt

title "After the sign-in, tools/list passes through and tools/call reaches the service"
trace jq -c 'select(.id==4) | [.result.tools[].name]' serve.stdout.txt
trace jq -c 'select(.id==5) | .result.content[0].text' serve.stdout.txt

title "Stdout holds only JSON-RPC lines"
trace jq -c '.jsonrpc' serve.stdout.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# The script leaves the captured streams of the proxy and the snapshot of its stderr before authenticate in this
# directory. The output is the trace lines, so the captures are ignored.
Ignore = ["stderr-before-authenticate.txt", "serve.stdout.txt", "serve.stderr.txt"]
3 changes: 2 additions & 1 deletion cmd/aitools/mcp_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/databricks/cli/libs/aitools/mcp/agentconfig"
"github.com/databricks/cli/libs/aitools/mcpconfig"
"github.com/databricks/cli/libs/auth"
"github.com/databricks/cli/libs/browser"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/env"
"github.com/databricks/cli/libs/telemetry/protos"
Expand Down Expand Up @@ -879,7 +880,7 @@ func signInInstalled(ctx context.Context, errOut io.Writer, w *databricks.Worksp
continue
}
url := mcp.SignInURL(w.Config.Host, plans[i].service.FullName, req.workspaceID)
state, _, err := signIn(ctx, errOut, w, req.profile, req.pinned, url, plans[i].service)
state, _, _, err := signIn(ctx, errOut, w, req.pinned, url, plans[i].service, signInOptions{Profile: req.profile, Open: browser.OpenOutputToStderr})
server.SignIn = &addSignInOutput{State: state}
if err != nil {
server.SignIn.ErrorCategory = errorCategory(err)
Expand Down
41 changes: 30 additions & 11 deletions cmd/aitools/mcp_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import (
)

// mcpLoginCommand is the command that signs a profile in to a service. The hints name it.
const mcpLoginCommand = "databricks aitools mcp login"
const (
mcpLoginCommand = "databricks aitools mcp login"
mcpLogoutCommand = "databricks aitools mcp logout"
)

// Sign-in states of `mcp login` and `mcp logout`, reported in JSON.
const (
Expand Down Expand Up @@ -180,7 +183,7 @@ func mcpLogin(ctx context.Context, errOut io.Writer, w *databricks.WorkspaceClie
return describeRequestError(out.Profile, pinnedFile, fmt.Errorf("cannot read the workspace ID of %s: %w", w.Config.Host, err))
}
url := mcp.SignInURL(out.Host, svc.FullName, workspaceID)
state, urlShown, err := signIn(ctx, errOut, w, out.Profile, pinnedFile, url, svc)
state, _, urlShown, err := signIn(ctx, errOut, w, pinnedFile, url, svc, signInOptions{Profile: out.Profile, Open: browser.OpenOutputToStderr})
out.State = state
// The URL is named when it was printed. A sign-in that failed before its page is printed names none.
if urlShown {
Expand Down Expand Up @@ -218,6 +221,11 @@ func mcpLoginHint(service, profileName, pinnedFile string) string {
return mcp.ConfigFileCommand(pinnedFile, mcpLoginCommand+" "+shellquote.BashArg(service)+" --profile "+shellquote.BashArg(profileName))
}

// mcpLogoutHint returns the command that removes profileName's sign-in to service. pinnedFile is as in mcpLoginHint.
func mcpLogoutHint(service, profileName, pinnedFile string) string {
return mcp.ConfigFileCommand(pinnedFile, mcpLogoutCommand+" "+shellquote.BashArg(service)+" --profile "+shellquote.BashArg(profileName))
}

// lastCheckText names the failure of the last poll of a sign-in that timed out, for the timeout message. It is empty
// when the last poll read the credential.
func lastCheckText(timeout *mcp.SignInTimeoutError) string {
Expand All @@ -233,27 +241,38 @@ var (
signInWaitTimeout = mcp.SignInWaitTimeout
)

// signIn runs the provider sign-in of svc for the profile and reports progress on errOut. It returns the sign-in state
// name, whether url was printed, and the failure, which is categorized for JSON. `add --login` shares it.
func signIn(ctx context.Context, errOut io.Writer, w *databricks.WorkspaceClient, profileName, pinnedFile, url string, svc mcp.Service) (state string, urlShown bool, err error) {
alreadySignedIn, urlShown, err := mcp.SignIn(ctx, w, url, svc.FullName, errOut, mcp.SignInOptions{
// signInOptions is what a caller of signIn sets: the profile named in the notice before the page, the page opener, and
// the poll callback. signIn adds the polling settings.
type signInOptions struct {
Profile string
Open func(ctx context.Context, url string) error
OnPoll func()
}

// signIn runs the provider sign-in of svc for the profile named in opts and reports progress on errOut. It returns the
// sign-in state name, whether the caller was already signed in, whether url was printed, and the failure, which is
// categorized for JSON. `add --login` shares this.
func signIn(ctx context.Context, errOut io.Writer, w *databricks.WorkspaceClient, pinnedFile, url string, svc mcp.Service, opts signInOptions) (state string, alreadySignedIn, urlShown bool, err error) {
profileName := opts.Profile
alreadySignedIn, urlShown, err = mcp.SignIn(ctx, w, url, svc.FullName, errOut, mcp.SignInOptions{
Profile: profileName,
Poll: signInPollInterval,
Timeout: signInWaitTimeout,
Open: browser.OpenOutputToStderr,
Open: opts.Open,
OnPoll: opts.OnPoll,
})
timeout, timedOut := errors.AsType[*mcp.SignInTimeoutError](err)
switch {
case timedOut:
return signInStateTimeout, urlShown, failure(categorySignInTimeout, err,
return signInStateTimeout, false, urlShown, failure(categorySignInTimeout, err,
"sign-in to %s did not complete in %g minutes%s. Finish at %s then retry: %s",
svc.FullName, signInWaitTimeout.Minutes(), lastCheckText(timeout), url, mcpLoginHint(svc.FullName, profileName, pinnedFile))
case errors.Is(err, mcp.ErrSignInFailed):
return signInStateFailed, urlShown, failure(protos.AitoolsErrorCategoryUncategorized, err,
return signInStateFailed, false, urlShown, failure(protos.AitoolsErrorCategoryUncategorized, err,
"sign-in to %s failed on profile %s. Run: %s",
svc.FullName, profileName, mcpLoginHint(svc.FullName, profileName, pinnedFile))
case err != nil:
return "", urlShown, describeRequestError(profileName, pinnedFile, err)
return "", false, urlShown, describeRequestError(profileName, pinnedFile, err)
}

if alreadySignedIn {
Expand All @@ -264,5 +283,5 @@ func signIn(ctx context.Context, errOut io.Writer, w *databricks.WorkspaceClient
if svc.Known != nil && svc.Known.Note != "" {
fmt.Fprintln(errOut, svc.Known.Note)
}
return signInStateActive, urlShown, nil
return signInStateActive, alreadySignedIn, urlShown, nil
}
4 changes: 3 additions & 1 deletion cmd/aitools/mcp_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package aitools

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -344,7 +345,8 @@ func TestSignInTimeoutNamesTheLastFailedCheck(t *testing.T) {
require.NoError(t, err)

ctx, _ := cmdio.NewTestContextWithStderr(t.Context())
state, _, err := signIn(ctx, io.Discard, w, "logfood", "", "https://example.test/login", mcp.Service{FullName: "system.ai.github", SignInRequired: true})
state, _, _, err := signIn(ctx, io.Discard, w, "", "https://example.test/login", mcp.Service{FullName: "system.ai.github", SignInRequired: true},
signInOptions{Profile: "logfood", Open: func(context.Context, string) error { return nil }})
require.Error(t, err)
assert.Equal(t, signInStateTimeout, state)
_, timedOut := errors.AsType[*mcp.SignInTimeoutError](err)
Expand Down
Loading
Loading