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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/commands/__tests__/mcp-add-impl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,35 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"
const {
mockListConnectionsByUrl,
mockCreateConnection,
mockGetConnection,
mockCreateSession,
mockOutputConnectionDetail,
mockExecFile,
} = vi.hoisted(() => {
const listConnectionsByUrl = vi.fn()
const createConnection = vi.fn()
const getConnection = vi.fn()
const execFile = vi.fn((_file, _args, callback) => callback(null))
const createSession = vi.fn(async () => ({
listConnectionsByUrl,
createConnection,
getConnection,
}))

return {
mockListConnectionsByUrl: listConnectionsByUrl,
mockCreateConnection: createConnection,
mockGetConnection: getConnection,
mockCreateSession: createSession,
mockOutputConnectionDetail: vi.fn(),
mockExecFile: execFile,
}
})

vi.mock("node:child_process", () => ({
execFile: mockExecFile,
}))

vi.mock("../mcp/api", () => ({
ConnectSession: {
create: mockCreateSession,
Expand All @@ -35,14 +46,21 @@ import { addServer } from "../mcp/add-impl"

describe("mcp add duplicate handling", () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
const originalStdinTTY = process.stdin.isTTY
const originalStdoutTTY = process.stdout.isTTY

beforeEach(() => {
vi.clearAllMocks()
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
process.stdin.isTTY = originalStdinTTY
process.stdout.isTTY = originalStdoutTTY
})

afterEach(() => {
consoleErrorSpy.mockRestore()
vi.useRealTimers()
process.stdin.isTTY = originalStdinTTY
process.stdout.isTTY = originalStdoutTTY
})

test("shows a remove and re-add hint for unresolved duplicate connections", async () => {
Expand Down Expand Up @@ -124,4 +142,49 @@ describe("mcp add duplicate handling", () => {
}),
)
})

test("opens setupUrl and waits for auth completion in TTY mode", async () => {
vi.useFakeTimers()
process.stdin.isTTY = true
process.stdout.isTTY = true
const setupUrl = "https://smithery.ai/setup/github"
const createdConnection = {
connectionId: "github-oauth",
name: "github-oauth",
mcpUrl: "https://server.smithery.ai/github",
metadata: null,
status: {
state: "auth_required",
setupUrl,
},
}
const connectedConnection = {
...createdConnection,
status: {
state: "connected",
},
}
mockListConnectionsByUrl.mockResolvedValue({ connections: [] })
mockCreateConnection.mockResolvedValue(createdConnection)
mockGetConnection
.mockResolvedValueOnce(createdConnection)
.mockResolvedValueOnce(connectedConnection)

const addPromise = addServer("https://server.smithery.ai/github", {})
await vi.advanceTimersByTimeAsync(6000)
await addPromise

expect(mockExecFile).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining([setupUrl]),
expect.any(Function),
)
expect(mockGetConnection).toHaveBeenCalledTimes(2)
expect(mockGetConnection).toHaveBeenCalledWith("github-oauth")
expect(mockOutputConnectionDetail).toHaveBeenCalledWith(
expect.objectContaining({
connection: connectedConnection,
}),
)
})
})
65 changes: 65 additions & 0 deletions src/commands/mcp/add-flow.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import pc from "picocolors"
import { promptForConnectionInputs } from "../../utils/command-prompts"
import { isJsonMode } from "../../utils/output"
import type { Connection, ConnectSession } from "./api"
import {
buildInputRequiredAddCommand,
getConnectionSetupUrl,
isInputRequiredStatus,
rewriteConnectionUrl,
} from "./connection-status"

const execFileAsync = promisify(execFile)
const AUTH_POLL_INTERVAL_MS = 3000
const AUTH_POLL_TIMEOUT_MS = 60_000

export async function finalizeAddedConnection(
session: ConnectSession,
connection: Connection,
Expand Down Expand Up @@ -40,6 +48,40 @@ export async function finalizeAddedConnection(
return current
}

export async function completeConnectionAuthorization(
session: ConnectSession,
connection: Connection,
): Promise<Connection> {
const setupUrl = getConnectionSetupUrl(connection.status)
if (connection.status?.state !== "auth_required" || !setupUrl) {
return connection
}

if (!process.stdin.isTTY || !process.stdout.isTTY || isJsonMode()) {
console.error(pc.yellow(`Authorization required. Run: open "${setupUrl}"`))
return connection
}

console.error()
console.error(pc.cyan("Opening browser for authorization..."))
console.error(pc.bold("If your browser doesn't open, visit:"))
console.error(pc.blue(pc.underline(setupUrl)))
await openSetupUrl(setupUrl)

const deadline = Date.now() + AUTH_POLL_TIMEOUT_MS
let latest = connection
while (Date.now() < deadline) {
await sleep(AUTH_POLL_INTERVAL_MS)
latest = await session.getConnection(connection.connectionId)
if (latest.status?.state !== "auth_required") {
return latest
}
}

console.error(pc.yellow("Authorization was not completed within 1 minute."))
return latest
}

export function buildDuplicateInputRequiredTip(
connection: Connection,
): string | undefined {
Expand All @@ -57,6 +99,29 @@ export function buildDuplicateInputRequiredTip(
].join("\n")
}

async function openSetupUrl(setupUrl: string): Promise<void> {
try {
const [command, args] = getOpenCommand(setupUrl)
await execFileAsync(command, args)
} catch {
// The setup URL is already visible.
}
}

function getOpenCommand(url: string): [string, string[]] {
if (process.platform === "darwin") {
return ["open", [url]]
}
if (process.platform === "win32") {
return ["cmd", ["/c", "start", "", url]]
}
return ["xdg-open", [url]]
}

async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

function requireConnectionUrl(connection: Connection): string {
if (!connection.mcpUrl) {
throw new Error(
Expand Down
31 changes: 11 additions & 20 deletions src/commands/mcp/add-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import pc from "picocolors"
import { fatal } from "../../lib/cli-error"
import {
buildDuplicateInputRequiredTip,
completeConnectionAuthorization,
finalizeAddedConnection,
} from "./add-flow"
import { ConnectSession } from "./api"
import { getConnectionSetupUrl } from "./connection-status"
import { normalizeMcpUrl } from "./normalize-url"
import { outputConnectionDetail } from "./output-connection"
import { parseJsonObject } from "./parse-json"
Expand Down Expand Up @@ -36,32 +36,28 @@ export async function addServer(
const { connections: existing } =
await session.listConnectionsByUrl(normalizedUrl)
if (existing.length > 0) {
const match = existing[0]
let match = existing[0]
const status = match.status?.state ?? "unknown"
console.error(
pc.yellow(
`Connection already exists for this URL: ${match.name} (${match.connectionId}, status: ${status})`,
),
)
if (status === "auth_required") {
const setupUrl = getConnectionSetupUrl(match.status)
if (setupUrl) {
console.error(
pc.yellow(`Authorization required. Run: open "${setupUrl}"`),
)
}
match = await completeConnectionAuthorization(session, match)
} else if (status === "connected") {
console.error(
pc.yellow(
`Use "smithery tool list ${match.connectionId}" to interact with it.`,
),
)
}
const finalStatus = match.status?.state ?? "unknown"
const tip =
buildDuplicateInputRequiredTip(match) ??
(status === "connected"
(finalStatus === "connected"
? `Use smithery tool list ${match.connectionId} to interact with it.`
: status === "auth_required"
: finalStatus === "auth_required"
? "Use the setup URL above to complete setup."
: `Use --force to create a new connection anyway.`)
outputConnectionDetail({
Expand All @@ -78,20 +74,15 @@ export async function addServer(
headers: parsedHeaders,
})

const finalConnection = await finalizeAddedConnection(session, connection, {
let finalConnection = await finalizeAddedConnection(session, connection, {
name: options.name,
metadata: parsedMetadata,
headers: parsedHeaders,
})

if (finalConnection.status?.state === "auth_required") {
const setupUrl = getConnectionSetupUrl(finalConnection.status)
if (setupUrl) {
console.error(
pc.yellow(`Authorization required. Run: open "${setupUrl}"`),
)
}
}
finalConnection = await completeConnectionAuthorization(
session,
finalConnection,
)

const id = finalConnection.connectionId
outputConnectionDetail({
Expand Down
19 changes: 11 additions & 8 deletions src/commands/mcp/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { verbose } from "../../lib/logger"
import { resolveServer } from "../../lib/registry"
import { serveUplink, type UplinkTarget } from "../../lib/uplink"
import { parseQualifiedName } from "../../utils/cli-utils"
import { finalizeAddedConnection } from "./add-flow"
import {
completeConnectionAuthorization,
finalizeAddedConnection,
} from "./add-flow"
import { addServer as addServerImpl } from "./add-impl"
import {
addBundleUplinkServer,
Expand Down Expand Up @@ -73,14 +76,14 @@ export async function addServer(
headers: parsedHeaders,
},
)
const finalConnection = await finalizeAddedConnection(
let finalConnection = await finalizeAddedConnection(session, connection, {
name,
metadata: parsedMetadata,
headers: parsedHeaders,
})
finalConnection = await completeConnectionAuthorization(
session,
connection,
{
name,
metadata: parsedMetadata,
headers: parsedHeaders,
},
finalConnection,
)
outputConnectionDetail({
connection: finalConnection,
Expand Down
Loading