Guided-auth removal, OAuth smoke testing, and TUI/CLI support (#1531)
* refactor(web): update OAuth E2E tests to use new authentication method - Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration. - Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs. - Ensured that the tests continue to validate the expected authorization URL after the change. Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process. * Guided-auth removal, OAuth smoke testing, and TUI/CLI support * Added CLI OAuth runner flags tests * Added clarifying comment on port 0 binding * README notes on TUI oauth callback port, prettier fixes for CLI tests * CIMD validation uses inline like EMA IdP * Fixed TUI test failures from merge * test: bring 5 OAuth files over the 90 per-file coverage gate The guided-auth-removal work added/touched several files that fell below the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run the gate (it's local-only), so validate + test:integration passed while these gaps slipped through. Bring them all back over the line: - core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the provably-dead guards (empty host, out-of-range port, String(err) fallback, pathname "/" fallback) -- all rejected by new URL() upstream -- with justified v8 ignore comments instead of lowering the gate. - core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return. - core/react/useClientSettingsDraft.ts: test the functional-updater path, the stale-onChange-after-close prev===null guard, and the timer-fires-after-close value===null guard. - ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear. - clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration kinds, the expired IdP session, and the configured-scope/empty-scope branches of formatScopes. npm run coverage now passes (exit 0) across web, cli, tui, launcher. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: cliffhall <cliff@futurescale.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a927d3978
commit
13dafe457b
108 changed files with 5702 additions and 4444 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@ test-servers/build
|
||||||
/*.png
|
/*.png
|
||||||
/inspector-history-*.json
|
/inspector-history-*.json
|
||||||
/*.server.json
|
/*.server.json
|
||||||
|
/configs/
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,49 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
|
||||||
| `--metadata <key=value>` | General metadata (key=value); applied to all methods. |
|
| `--metadata <key=value>` | General metadata (key=value); applied to all methods. |
|
||||||
| `--tool-metadata <key=value>` | Tool-specific metadata for `tools/call`. |
|
| `--tool-metadata <key=value>` | Tool-specific metadata for `tools/call`. |
|
||||||
|
|
||||||
|
### CLI-specific (OAuth for HTTP servers)
|
||||||
|
|
||||||
|
The CLI **reuses** OAuth tokens from `~/.mcp-inspector/storage/oauth.json` (same file as the TUI). Complete first-time authorization in the **web** or **TUI** client, then run one-shot CLI commands against HTTP/SSE servers without signing in again.
|
||||||
|
|
||||||
|
The CLI does **not** start a local callback server or retry connect on 401. If tokens are missing or expired, connect fails; `ConsoleNavigation` may print an authorize URL to stdout, but the CLI cannot finish the redirect flow. Use the TUI for interactive runner OAuth until Phase 4 adds a CLI callback server.
|
||||||
|
|
||||||
|
**Shared with TUI** (config only, not interactive login):
|
||||||
|
|
||||||
|
- Per-server OAuth fields from `mcp.json` (static client, EMA resource credentials, scopes)
|
||||||
|
- Install-level settings from **`~/.mcp-inspector/storage/client.json`** (or `--client-config` / `MCP_CLIENT_CONFIG_PATH`) — EMA IdP, CIMD
|
||||||
|
- CLI flags `--client-id`, `--client-secret`, `--client-metadata-url` override `client.json` when set
|
||||||
|
- Keychain-backed secrets in `mcp.json` are rehydrated on catalog load (same as TUI)
|
||||||
|
|
||||||
|
#### OAuth callback URL
|
||||||
|
|
||||||
|
| Surface | Default callback |
|
||||||
|
| ------- | ---------------- |
|
||||||
|
| **Web** | `http://localhost:6274/oauth/callback` |
|
||||||
|
| **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) |
|
||||||
|
| **CLI** | `http://127.0.0.1:6276/oauth/callback` (redirect URI in OAuth metadata only; no listener) |
|
||||||
|
|
||||||
|
Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** (or when your OAuth app expects that URI). Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. The CLI passes this value as `redirect_uri` when an OAuth flow runs, but does not listen on the port.
|
||||||
|
|
||||||
|
#### Flags
|
||||||
|
|
||||||
|
| Option | Env | Description |
|
||||||
|
| ------ | --- | ----------- |
|
||||||
|
| `--client-config <path>` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). |
|
||||||
|
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
|
||||||
|
| `--client-secret <secret>` | — | OAuth client secret; overrides `client.json`. |
|
||||||
|
| `--client-metadata-url <url>` | — | CIMD metadata URL; overrides `client.json`. |
|
||||||
|
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). |
|
||||||
|
|
||||||
|
**Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @modelcontextprotocol/inspector --cli --catalog mcp.json --server my-http-server \
|
||||||
|
--client-metadata-url https://example.com/.well-known/oauth/client-metadata.json \
|
||||||
|
--method tools/list
|
||||||
|
```
|
||||||
|
|
||||||
|
See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for configuration details and staging servers.
|
||||||
|
|
||||||
## Why use the CLI?
|
## Why use the CLI?
|
||||||
|
|
||||||
While the Web Client provides a rich visual interface, the CLI is designed for:
|
While the Web Client provides a rich visual interface, the CLI is designed for:
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,22 @@ export function createInvalidConfig(): string {
|
||||||
export function deleteConfigFile(configPath: string): void {
|
export function deleteConfigFile(configPath: string): void {
|
||||||
cleanupTempDir(path.dirname(configPath));
|
cleanupTempDir(path.dirname(configPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a temporary install-level client.json for --client-config tests.
|
||||||
|
*/
|
||||||
|
export function createClientConfigFile(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): string {
|
||||||
|
const tempDir = createTempDir("mcp-inspector-client-config-");
|
||||||
|
const configPath = path.join(tempDir, "client.json");
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||||
|
return configPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a client config file and its containing directory
|
||||||
|
*/
|
||||||
|
export function deleteClientConfigFile(configPath: string): void {
|
||||||
|
cleanupTempDir(path.dirname(configPath));
|
||||||
|
}
|
||||||
|
|
|
||||||
188
clients/cli/__tests__/oauth-runner.test.ts
Normal file
188
clients/cli/__tests__/oauth-runner.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import * as runner from "@inspector/core/client/runner.js";
|
||||||
|
import {
|
||||||
|
createTestServerHttp,
|
||||||
|
createEchoTool,
|
||||||
|
createTestServerInfo,
|
||||||
|
} from "@modelcontextprotocol/inspector-test-server";
|
||||||
|
import { runCli } from "./helpers/cli-runner.js";
|
||||||
|
import {
|
||||||
|
expectCliFailure,
|
||||||
|
expectCliSuccess,
|
||||||
|
expectOutputContains,
|
||||||
|
} from "./helpers/assertions.js";
|
||||||
|
import {
|
||||||
|
createClientConfigFile,
|
||||||
|
deleteClientConfigFile,
|
||||||
|
} from "./helpers/fixtures.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers OAuth runner flag wiring in `src/cli.ts` (#1514): --client-config,
|
||||||
|
* --client-id/--client-secret/--client-metadata-url, and --callback-url /
|
||||||
|
* MCP_OAUTH_CALLBACK_URL. Shared auth logic is unit-tested in
|
||||||
|
* clients/web/src/test/core/client/runner.test.ts; these assert the CLI
|
||||||
|
* parses flags and passes them through on HTTP (OAuth-capable) connects.
|
||||||
|
*/
|
||||||
|
describe("CLI OAuth runner flags", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects over HTTP with OAuth CLI overrides and custom callback URL", async () => {
|
||||||
|
const server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
});
|
||||||
|
const buildSpy = vi.spyOn(runner, "buildRunnerClientAuthOptions");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
const result = await runCli([
|
||||||
|
server.url,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--client-id",
|
||||||
|
"test-client-id",
|
||||||
|
"--client-secret",
|
||||||
|
"test-client-secret",
|
||||||
|
"--client-metadata-url",
|
||||||
|
"https://example.com/oauth/client-metadata.json",
|
||||||
|
"--callback-url",
|
||||||
|
"http://127.0.0.1:9999/oauth/callback",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expectCliSuccess(result);
|
||||||
|
expect(buildSpy).toHaveBeenCalled();
|
||||||
|
expect(buildSpy.mock.calls[0]?.[2]).toEqual({
|
||||||
|
clientId: "test-client-id",
|
||||||
|
clientSecret: "test-client-secret",
|
||||||
|
clientMetadataUrl: "https://example.com/oauth/client-metadata.json",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads install client.json from --client-config and prefers CLI CIMD override", async () => {
|
||||||
|
const server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
});
|
||||||
|
const buildSpy = vi.spyOn(runner, "buildRunnerClientAuthOptions");
|
||||||
|
const clientConfigPath = createClientConfigFile({
|
||||||
|
cimd: {
|
||||||
|
enabled: true,
|
||||||
|
clientMetadataUrl: "https://example.com/from-client-json.json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
const result = await runCli([
|
||||||
|
server.url,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--client-config",
|
||||||
|
clientConfigPath,
|
||||||
|
"--client-metadata-url",
|
||||||
|
"https://example.com/from-cli-flag.json",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expectCliSuccess(result);
|
||||||
|
expect(buildSpy).toHaveBeenCalled();
|
||||||
|
expect(buildSpy.mock.calls[0]?.[0]).toMatchObject({
|
||||||
|
cimd: {
|
||||||
|
enabled: true,
|
||||||
|
clientMetadataUrl: "https://example.com/from-client-json.json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(buildSpy.mock.calls[0]?.[2]).toEqual({
|
||||||
|
clientMetadataUrl: "https://example.com/from-cli-flag.json",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
deleteClientConfigFile(clientConfigPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts port 0 callback URL for ephemeral listener binding", async () => {
|
||||||
|
const server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
const result = await runCli([
|
||||||
|
server.url,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--callback-url",
|
||||||
|
"http://127.0.0.1:0/oauth/callback",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expectCliSuccess(result);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses MCP_OAUTH_CALLBACK_URL when --callback-url is absent", async () => {
|
||||||
|
const server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
const result = await runCli(
|
||||||
|
[server.url, "--cli", "--method", "tools/list", "--transport", "http"],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
MCP_OAUTH_CALLBACK_URL:
|
||||||
|
"http://127.0.0.1:8888/custom/oauth/callback",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expectCliSuccess(result);
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid OAuth callback URL before connecting", async () => {
|
||||||
|
const server = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo(),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.start();
|
||||||
|
const result = await runCli([
|
||||||
|
server.url,
|
||||||
|
"--cli",
|
||||||
|
"--method",
|
||||||
|
"tools/list",
|
||||||
|
"--transport",
|
||||||
|
"http",
|
||||||
|
"--callback-url",
|
||||||
|
"not-a-valid-url",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expectCliFailure(result);
|
||||||
|
expectOutputContains(result, "Invalid OAuth callback URL");
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
220
clients/cli/package-lock.json
generated
220
clients/cli/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@napi-rs/keyring": "^1.3.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"atomically": "^2.1.1",
|
"atomically": "^2.1.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
|
|
@ -907,6 +908,225 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/keyring": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/keyring-darwin-arm64": "1.3.0",
|
||||||
|
"@napi-rs/keyring-darwin-x64": "1.3.0",
|
||||||
|
"@napi-rs/keyring-freebsd-x64": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-arm64-gnu": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-arm64-musl": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-riscv64-gnu": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-x64-gnu": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-x64-musl": "1.3.0",
|
||||||
|
"@napi-rs/keyring-win32-arm64-msvc": "1.3.0",
|
||||||
|
"@napi-rs/keyring-win32-ia32-msvc": "1.3.0",
|
||||||
|
"@napi-rs/keyring-win32-x64-msvc": "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-darwin-arm64": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-darwin-x64": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-freebsd-x64": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-arm64-gnu": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-arm64-musl": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-riscv64-gnu": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-x64-gnu": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-x64-musl": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-win32-arm64-msvc": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-win32-ia32-msvc": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-win32-x64-msvc": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@napi-rs/keyring": "^1.3.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"atomically": "^2.1.1",
|
"atomically": "^2.1.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { awaitableLog } from "./utils/awaitable-log.js";
|
||||||
import type {
|
import type {
|
||||||
InspectorServerSettings,
|
InspectorServerSettings,
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
|
InspectorClientEnvironment,
|
||||||
} from "@inspector/core/mcp/types.js";
|
} from "@inspector/core/mcp/types.js";
|
||||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,6 +23,24 @@ import {
|
||||||
parseHeaderPair,
|
parseHeaderPair,
|
||||||
} from "@inspector/core/mcp/node/index.js";
|
} from "@inspector/core/mcp/node/index.js";
|
||||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||||
|
import {
|
||||||
|
ConsoleNavigation,
|
||||||
|
MutableRedirectUrlProvider,
|
||||||
|
} from "@inspector/core/auth/index.js";
|
||||||
|
import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
|
||||||
|
formatRunnerOAuthRedirectUrl,
|
||||||
|
parseRunnerOAuthCallbackUrl,
|
||||||
|
type RunnerOAuthCallbackConfig,
|
||||||
|
} from "@inspector/core/auth/node/runner-oauth-callback.js";
|
||||||
|
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||||
|
import {
|
||||||
|
buildRunnerClientAuthOptions,
|
||||||
|
isOAuthCapableServerConfig,
|
||||||
|
loadRunnerClientConfig,
|
||||||
|
type RunnerClientConfigOverrides,
|
||||||
|
} from "@inspector/core/client/runner.js";
|
||||||
import {
|
import {
|
||||||
LoggingLevelSchema,
|
LoggingLevelSchema,
|
||||||
type LoggingLevel,
|
type LoggingLevel,
|
||||||
|
|
@ -47,6 +66,9 @@ async function callMethod(
|
||||||
serverConfig: MCPServerConfig,
|
serverConfig: MCPServerConfig,
|
||||||
serverSettings: InspectorServerSettings | undefined,
|
serverSettings: InspectorServerSettings | undefined,
|
||||||
args: MethodArgs & { method: string },
|
args: MethodArgs & { method: string },
|
||||||
|
clientConfig: ClientConfig,
|
||||||
|
cliAuthOverrides?: RunnerClientConfigOverrides,
|
||||||
|
callbackUrlConfig?: RunnerOAuthCallbackConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const packageJsonPath = join(__dirname, "../package.json");
|
const packageJsonPath = join(__dirname, "../package.json");
|
||||||
|
|
@ -62,16 +84,36 @@ async function callMethod(
|
||||||
const version = packageJson.version;
|
const version = packageJson.version;
|
||||||
const clientIdentity = { name, version };
|
const clientIdentity = { name, version };
|
||||||
|
|
||||||
|
const environment: InspectorClientEnvironment = {
|
||||||
|
transport: createTransportNode,
|
||||||
|
};
|
||||||
|
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||||
|
if (isOAuthCapableServerConfig(serverConfig)) {
|
||||||
|
redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl(
|
||||||
|
callbackUrlConfig!,
|
||||||
|
);
|
||||||
|
environment.oauth = {
|
||||||
|
storage: new NodeOAuthStorage(),
|
||||||
|
navigation: new ConsoleNavigation(),
|
||||||
|
redirectUrlProvider,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientAuthOptions = buildRunnerClientAuthOptions(
|
||||||
|
clientConfig,
|
||||||
|
serverSettings,
|
||||||
|
cliAuthOverrides,
|
||||||
|
);
|
||||||
|
|
||||||
const inspectorClient = new InspectorClient(serverConfig, {
|
const inspectorClient = new InspectorClient(serverConfig, {
|
||||||
environment: {
|
environment,
|
||||||
transport: createTransportNode,
|
|
||||||
},
|
|
||||||
clientIdentity,
|
clientIdentity,
|
||||||
initialLoggingLevel: "debug",
|
initialLoggingLevel: "debug",
|
||||||
progress: false,
|
progress: false,
|
||||||
sample: false,
|
sample: false,
|
||||||
elicit: false,
|
elicit: false,
|
||||||
serverSettings,
|
serverSettings,
|
||||||
|
...clientAuthOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
let managedToolsState: ManagedToolsState | null = null;
|
let managedToolsState: ManagedToolsState | null = null;
|
||||||
|
|
@ -240,11 +282,16 @@ function parseKeyValuePair(
|
||||||
return { ...previous, [key as string]: parsedValue };
|
return { ...previous, [key as string]: parsedValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv?: string[]): {
|
async function parseArgs(argv?: string[]): Promise<{
|
||||||
serverConfig: MCPServerConfig;
|
serverConfig: MCPServerConfig;
|
||||||
serverSettings: InspectorServerSettings | undefined;
|
serverSettings: InspectorServerSettings | undefined;
|
||||||
methodArgs: MethodArgs & { method: string };
|
methodArgs: MethodArgs & { method: string };
|
||||||
} {
|
clientConfigPath?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
clientMetadataUrl?: string;
|
||||||
|
callbackUrl?: string;
|
||||||
|
}> {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
// On a parse/usage ERROR (exitCode !== 0), throw the CommanderError instead
|
// On a parse/usage ERROR (exitCode !== 0), throw the CommanderError instead
|
||||||
// of letting commander call process.exit(). The binary entry (index.ts) still
|
// of letting commander call process.exit(). The binary entry (index.ts) still
|
||||||
|
|
@ -368,6 +415,26 @@ function parseArgs(argv?: string[]): {
|
||||||
"Tool-specific metadata as key=value pairs (for tools/call method only)",
|
"Tool-specific metadata as key=value pairs (for tools/call method only)",
|
||||||
parseKeyValuePair,
|
parseKeyValuePair,
|
||||||
{},
|
{},
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--client-config <path>",
|
||||||
|
"Install-level client config (default: ~/.mcp-inspector/storage/client.json, or MCP_CLIENT_CONFIG_PATH)",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--client-id <id>",
|
||||||
|
"OAuth client ID (static client) for HTTP servers",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--client-secret <secret>",
|
||||||
|
"OAuth client secret (for confidential clients)",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--client-metadata-url <url>",
|
||||||
|
"OAuth Client ID Metadata Document URL (CIMD) for HTTP servers",
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--callback-url <url>",
|
||||||
|
`OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
program.parse(preArgs);
|
program.parse(preArgs);
|
||||||
|
|
@ -390,6 +457,11 @@ function parseArgs(argv?: string[]): {
|
||||||
transport?: "sse" | "http" | "stdio";
|
transport?: "sse" | "http" | "stdio";
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
header?: Record<string, string>;
|
header?: Record<string, string>;
|
||||||
|
clientConfig?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
clientMetadataUrl?: string;
|
||||||
|
callbackUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const serverOptions = {
|
const serverOptions = {
|
||||||
|
|
@ -410,7 +482,7 @@ function parseArgs(argv?: string[]): {
|
||||||
// Shared with the TUI: resolves the catalog/config source (or ad-hoc target),
|
// Shared with the TUI: resolves the catalog/config source (or ad-hoc target),
|
||||||
// enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into
|
// enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into
|
||||||
// per-server settings. `--server` selects one when the file has several.
|
// per-server settings. `--server` selects one when the file has several.
|
||||||
const entries = loadServerEntries(serverOptions);
|
const entries = await loadServerEntries(serverOptions);
|
||||||
const { config: serverConfig, settings: serverSettings } = selectServerEntry(
|
const { config: serverConfig, settings: serverSettings } = selectServerEntry(
|
||||||
entries,
|
entries,
|
||||||
options.server,
|
options.server,
|
||||||
|
|
@ -452,12 +524,37 @@ function parseArgs(argv?: string[]): {
|
||||||
serverConfig,
|
serverConfig,
|
||||||
serverSettings,
|
serverSettings,
|
||||||
methodArgs,
|
methodArgs,
|
||||||
|
clientConfigPath: options.clientConfig,
|
||||||
|
clientId: options.clientId,
|
||||||
|
clientSecret: options.clientSecret,
|
||||||
|
clientMetadataUrl: options.clientMetadataUrl,
|
||||||
|
callbackUrl: options.callbackUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runCli(argv?: string[]): Promise<void> {
|
export async function runCli(argv?: string[]): Promise<void> {
|
||||||
const { serverConfig, serverSettings, methodArgs } = parseArgs(
|
const {
|
||||||
argv ?? process.argv,
|
serverConfig,
|
||||||
|
serverSettings,
|
||||||
|
methodArgs,
|
||||||
|
clientConfigPath,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
clientMetadataUrl,
|
||||||
|
callbackUrl,
|
||||||
|
} = await parseArgs(argv ?? process.argv);
|
||||||
|
const clientConfig = await loadRunnerClientConfig({ clientConfigPath });
|
||||||
|
const callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl);
|
||||||
|
await callMethod(
|
||||||
|
serverConfig,
|
||||||
|
serverSettings,
|
||||||
|
methodArgs,
|
||||||
|
clientConfig,
|
||||||
|
{
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
clientMetadataUrl,
|
||||||
|
},
|
||||||
|
callbackUrlConfig,
|
||||||
);
|
);
|
||||||
await callMethod(serverConfig, serverSettings, methodArgs);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export default defineConfig({
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
// Bundle core source; leave npm deps external.
|
// Bundle core source; leave npm deps external.
|
||||||
noExternal: [/^@inspector\/core/],
|
noExternal: [/^@inspector\/core/],
|
||||||
|
external: ['@napi-rs/keyring', '@modelcontextprotocol/sdk', 'commander', 'pino', 'zustand'],
|
||||||
esbuildOptions(options) {
|
esbuildOptions(options) {
|
||||||
options.alias = {
|
options.alias = {
|
||||||
'@inspector/core': path.join(repoRoot, 'core'),
|
'@inspector/core': path.join(repoRoot, 'core'),
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,41 @@ Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL,
|
||||||
|
|
||||||
### TUI-specific (OAuth for HTTP servers)
|
### TUI-specific (OAuth for HTTP servers)
|
||||||
|
|
||||||
When connecting to SSE or Streamable HTTP servers that use OAuth, you can pass:
|
The TUI supports OAuth for **SSE** and **Streamable HTTP** servers. Per-server OAuth fields in `mcp.json` (static client id/secret, scopes, enterprise-managed flag) are applied automatically when loaded from `--catalog` or `--config`. Install-wide settings (CIMD, enterprise IdP) come from **`~/.mcp-inspector/storage/client.json`** — the same file the web **Client Settings** dialog writes. You can point at a different file with `--client-config` or `MCP_CLIENT_CONFIG_PATH`.
|
||||||
|
|
||||||
| Option | Description |
|
#### OAuth callback URL
|
||||||
| ----------------------------- | ------------------------------------------------------------------------------------ |
|
|
||||||
| `--client-id <id>` | OAuth client ID (static client). |
|
The TUI starts a small loopback HTTP server to receive the authorization redirect after you sign in in the browser. Defaults:
|
||||||
| `--client-secret <secret>` | OAuth client secret (confidential clients). |
|
|
||||||
| `--client-metadata-url <url>` | OAuth Client ID Metadata Document URL (CIMD). |
|
| Surface | Default callback |
|
||||||
| `--callback-url <url>` | OAuth redirect/callback listener URL (default: `http://127.0.0.1:0/oauth/callback`). |
|
| ------- | ---------------- |
|
||||||
|
| **Web** | `http://localhost:6274/oauth/callback` (main app server) |
|
||||||
|
| **TUI** | `http://127.0.0.1:6276/oauth/callback` (dedicated runner port; avoids colliding with web on 6274) |
|
||||||
|
|
||||||
|
**Why a fixed default port?** Enterprise-managed auth (EMA), CIMD, and many static OAuth apps require **pre-registered redirect URIs**. A predictable default (`http://127.0.0.1:6276/oauth/callback`) lets you register once on the IdP and reuse it across TUI sessions. Dynamic registration (DCR) can use ephemeral ports instead — see below.
|
||||||
|
|
||||||
|
**Trade-off:** only **one TUI OAuth flow at a time** can listen on the default port. A second instance starting OAuth while another is in progress may fail with `EADDRINUSE`. Override the listener with `--callback-url` or `MCP_OAUTH_CALLBACK_URL` (e.g. a different fixed port per instance, or `http://127.0.0.1:0/oauth/callback` when the authorization server accepts dynamically registered redirect URIs).
|
||||||
|
|
||||||
|
OAuth redirect URIs must match **exactly** what you register on the authorization server — `localhost` and `127.0.0.1` are different URIs. Register the TUI default on your OAuth app / IdP when using pre-registered (static), CIMD, or enterprise-managed clients. Override the listener with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`; use `http://127.0.0.1:0/oauth/callback` for an OS-assigned ephemeral port when the authorization server registers redirect URIs dynamically (DCR).
|
||||||
|
|
||||||
|
#### Flags
|
||||||
|
|
||||||
|
| Option | Env | Description |
|
||||||
|
| ------ | --- | ----------- |
|
||||||
|
| `--client-config <path>` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). |
|
||||||
|
| `--client-id <id>` | — | OAuth client ID (static client); overrides `client.json`. |
|
||||||
|
| `--client-secret <secret>` | — | OAuth client secret (confidential clients); overrides `client.json`. |
|
||||||
|
| `--client-metadata-url <url>` | — | Client ID Metadata Document URL (CIMD); overrides `client.json`. |
|
||||||
|
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | OAuth redirect/callback listener (default: `http://127.0.0.1:6276/oauth/callback`). |
|
||||||
|
|
||||||
|
#### Authenticating in the TUI
|
||||||
|
|
||||||
|
1. Select an HTTP/SSE server and press **C** to connect.
|
||||||
|
2. If authorization is required, the TUI starts OAuth automatically (browser opens for sign-in).
|
||||||
|
3. After the callback completes, connect finishes without a second **C**.
|
||||||
|
4. Use the **Auth** tab to inspect OAuth state (same fields as web Connection Info) or **Clear OAuth state** (disconnects when connected).
|
||||||
|
|
||||||
|
See also [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for staging servers and verification steps.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,11 +52,9 @@ const h = vi.hoisted(() => {
|
||||||
authenticate: vi.fn(
|
authenticate: vi.fn(
|
||||||
async (): Promise<string | undefined> => "https://auth.example/start",
|
async (): Promise<string | undefined> => "https://auth.example/start",
|
||||||
),
|
),
|
||||||
beginGuidedAuth: vi.fn(async (): Promise<void> => {}),
|
|
||||||
runGuidedAuth: vi.fn(async (): Promise<string | undefined> => undefined),
|
|
||||||
proceedOAuthStep: vi.fn(async (): Promise<void> => {}),
|
|
||||||
clearOAuthTokens: vi.fn(),
|
clearOAuthTokens: vi.fn(),
|
||||||
completeOAuthFlow: vi.fn(async (): Promise<void> => {}),
|
completeOAuthFlow: vi.fn(async (): Promise<void> => {}),
|
||||||
|
getOAuthState: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
// Captured options from the most recent callbackServer.start(), so a test can
|
// Captured options from the most recent callbackServer.start(), so a test can
|
||||||
// drive the onCallback / onError handlers the OAuth flows register.
|
// drive the onCallback / onError handlers the OAuth flows register.
|
||||||
|
|
@ -92,14 +90,11 @@ const h = vi.hoisted(() => {
|
||||||
| "sse"
|
| "sse"
|
||||||
| "streamable-http",
|
| "streamable-http",
|
||||||
);
|
);
|
||||||
getOAuthFlowState = vi.fn(() => ctrl.oauthFlowState);
|
|
||||||
authenticate = (...a: unknown[]) => clientSpies.authenticate(...a);
|
authenticate = (...a: unknown[]) => clientSpies.authenticate(...a);
|
||||||
beginGuidedAuth = (...a: unknown[]) => clientSpies.beginGuidedAuth(...a);
|
|
||||||
runGuidedAuth = (...a: unknown[]) => clientSpies.runGuidedAuth(...a);
|
|
||||||
proceedOAuthStep = (...a: unknown[]) => clientSpies.proceedOAuthStep(...a);
|
|
||||||
clearOAuthTokens = (...a: unknown[]) => clientSpies.clearOAuthTokens(...a);
|
clearOAuthTokens = (...a: unknown[]) => clientSpies.clearOAuthTokens(...a);
|
||||||
completeOAuthFlow = (...a: unknown[]) =>
|
completeOAuthFlow = (...a: unknown[]) =>
|
||||||
clientSpies.completeOAuthFlow(...a);
|
clientSpies.completeOAuthFlow(...a);
|
||||||
|
getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a);
|
||||||
readResource = vi.fn(async () => ({
|
readResource = vi.fn(async () => ({
|
||||||
result: { contents: [{ uri: "file://x", text: "hello" }] },
|
result: { contents: [{ uri: "file://x", text: "hello" }] },
|
||||||
}));
|
}));
|
||||||
|
|
@ -179,12 +174,17 @@ vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({
|
||||||
vi.mock("@inspector/core/react/useStderrLog.js", () => ({
|
vi.mock("@inspector/core/react/useStderrLog.js", () => ({
|
||||||
useStderrLog: h.useStderrLog,
|
useStderrLog: h.useStderrLog,
|
||||||
}));
|
}));
|
||||||
vi.mock("@inspector/core/auth/index.js", () => ({
|
vi.mock("@inspector/core/auth/index.js", async (importOriginal) => {
|
||||||
CallbackNavigation: class {},
|
const actual =
|
||||||
MutableRedirectUrlProvider: class {
|
await importOriginal<typeof import("@inspector/core/auth/index.js")>();
|
||||||
redirectUrl = "";
|
return {
|
||||||
},
|
...actual,
|
||||||
}));
|
CallbackNavigation: class {},
|
||||||
|
MutableRedirectUrlProvider: class {
|
||||||
|
redirectUrl = "";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock("@inspector/core/auth/node/index.js", () => ({
|
vi.mock("@inspector/core/auth/node/index.js", () => ({
|
||||||
createOAuthCallbackServer: h.createOAuthCallbackServer,
|
createOAuthCallbackServer: h.createOAuthCallbackServer,
|
||||||
NodeOAuthStorage: class {},
|
NodeOAuthStorage: class {},
|
||||||
|
|
@ -198,6 +198,7 @@ import type { TuiServer } from "../src/tui-servers.js";
|
||||||
|
|
||||||
const tick = () => new Promise((r) => setTimeout(r, 25));
|
const tick = () => new Promise((r) => setTimeout(r, 25));
|
||||||
const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" };
|
const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" };
|
||||||
|
const emptyClientConfig = {};
|
||||||
|
|
||||||
function stdioServer(): Record<string, TuiServer> {
|
function stdioServer(): Record<string, TuiServer> {
|
||||||
return {
|
return {
|
||||||
|
|
@ -234,7 +235,7 @@ function oneHttp(): Record<string, TuiServer> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by
|
// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by
|
||||||
// a stdio server — drives per-server tab gating + the tab-switch-away effects.
|
// a stdio server <EFBFBD> drives per-server tab gating + the tab-switch-away effects.
|
||||||
function httpThenStdio(): Record<string, TuiServer> {
|
function httpThenStdio(): Record<string, TuiServer> {
|
||||||
return {
|
return {
|
||||||
web: { config: { type: "streamable-http", url: "http://x" } } as never,
|
web: { config: { type: "streamable-http", url: "http://x" } } as never,
|
||||||
|
|
@ -351,13 +352,17 @@ const bareRequest = {
|
||||||
};
|
};
|
||||||
const stderrLog = { timestamp: new Date(0), message: "log line" };
|
const stderrLog = { timestamp: new Date(0), message: "log line" };
|
||||||
|
|
||||||
// Track rendered instances so each is unmounted after its test — concurrent
|
// Track rendered instances so each is unmounted after its test <EFBFBD> concurrent
|
||||||
// mounted ink apps share raw-mode stdin handling and interfere with useInput.
|
// mounted ink apps share raw-mode stdin handling and interfere with useInput.
|
||||||
const mounted: RenderResult[] = [];
|
const mounted: RenderResult[] = [];
|
||||||
|
|
||||||
function renderApp(servers: Record<string, TuiServer>) {
|
function renderApp(servers: Record<string, TuiServer>) {
|
||||||
const r = render(
|
const r = render(
|
||||||
<App mcpServers={servers} callbackUrlConfig={callbackUrlConfig} />,
|
<App
|
||||||
|
mcpServers={servers}
|
||||||
|
clientConfig={emptyClientConfig}
|
||||||
|
callbackUrlConfig={callbackUrlConfig}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
mounted.push(r);
|
mounted.push(r);
|
||||||
return r;
|
return r;
|
||||||
|
|
@ -390,7 +395,7 @@ const ENTER = "\r";
|
||||||
/**
|
/**
|
||||||
* Write each key in order. ink re-subscribes the active component's useInput on
|
* Write each key in order. ink re-subscribes the active component's useInput on
|
||||||
* re-render, so a key that changes focus/tab must let that re-render flush
|
* re-render, so a key that changes focus/tab must let that re-render flush
|
||||||
* before the next key — otherwise the next key is routed to the old handler.
|
* before the next key <EFBFBD> otherwise the next key is routed to the old handler.
|
||||||
* Two ticks per key keeps multi-step navigation deterministic under the heavier
|
* Two ticks per key keeps multi-step navigation deterministic under the heavier
|
||||||
* v8 coverage instrumentation (where a single tick can race the render).
|
* v8 coverage instrumentation (where a single tick can race the render).
|
||||||
*/
|
*/
|
||||||
|
|
@ -423,7 +428,7 @@ async function waitForFrame(r: RenderResult, substr: string, tries = 25) {
|
||||||
await waitUntil(() => (r.lastFrame() ?? "").includes(substr), tries);
|
await waitUntil(() => (r.lastFrame() ?? "").includes(substr), tries);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Poll until the frame contains `substr`, then assert it — stable under load. */
|
/** Poll until the frame contains `substr`, then assert it <EFBFBD> stable under load. */
|
||||||
async function expectFrame(r: RenderResult, substr: string) {
|
async function expectFrame(r: RenderResult, substr: string) {
|
||||||
await waitForFrame(r, substr);
|
await waitForFrame(r, substr);
|
||||||
expect(r.lastFrame() ?? "").toContain(substr);
|
expect(r.lastFrame() ?? "").toContain(substr);
|
||||||
|
|
@ -456,15 +461,11 @@ beforeEach(() => {
|
||||||
h.callbackStop.mockClear();
|
h.callbackStop.mockClear();
|
||||||
h.clientSpies.authenticate.mockReset();
|
h.clientSpies.authenticate.mockReset();
|
||||||
h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start");
|
h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start");
|
||||||
h.clientSpies.beginGuidedAuth.mockReset();
|
|
||||||
h.clientSpies.beginGuidedAuth.mockResolvedValue(undefined);
|
|
||||||
h.clientSpies.runGuidedAuth.mockReset();
|
|
||||||
h.clientSpies.runGuidedAuth.mockResolvedValue(undefined);
|
|
||||||
h.clientSpies.proceedOAuthStep.mockReset();
|
|
||||||
h.clientSpies.proceedOAuthStep.mockResolvedValue(undefined);
|
|
||||||
h.clientSpies.clearOAuthTokens.mockReset();
|
h.clientSpies.clearOAuthTokens.mockReset();
|
||||||
h.clientSpies.completeOAuthFlow.mockReset();
|
h.clientSpies.completeOAuthFlow.mockReset();
|
||||||
h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined);
|
h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined);
|
||||||
|
h.clientSpies.getOAuthState.mockReset();
|
||||||
|
h.clientSpies.getOAuthState.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -522,14 +523,14 @@ describe("App (foundation)", () => {
|
||||||
// shift+tab is delivered as ESC [ Z
|
// shift+tab is delivered as ESC [ Z
|
||||||
stdin.write("[Z");
|
stdin.write("[Z");
|
||||||
await tick();
|
await tick();
|
||||||
// no assertion on hidden focus state — exercising the branches
|
// no assertion on hidden focus state <EFBFBD> exercising the branches
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the Auth tab and G/Q/S accelerators for an OAuth-capable server", async () => {
|
it("shows the Auth tab via the accelerator for an OAuth-capable server", async () => {
|
||||||
h.ctrl.serverType = "streamable-http";
|
h.ctrl.serverType = "streamable-http";
|
||||||
const r = await mount(httpServer());
|
const r = await mount(httpServer());
|
||||||
await press(r, ["g"]); // guided auth accelerator -> Auth tab
|
await press(r, ["a"]);
|
||||||
await expectFrame(r, "Auth");
|
await expectFrame(r, "OAuth");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders connected status with capabilities", async () => {
|
it("renders connected status with capabilities", async () => {
|
||||||
|
|
@ -554,11 +555,12 @@ describe("App (status, layout, modals)", () => {
|
||||||
await expectFrame(r, "error");
|
await expectFrame(r, "error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the 401 auth hint for an http server with a 401 response", async () => {
|
it("shows error status when an http server has a 401 response logged", async () => {
|
||||||
h.ctrl.status = "error";
|
h.ctrl.status = "error";
|
||||||
h.ctrl.fetchRequests = [{ ...errorRequest, responseStatus: 401 }];
|
h.ctrl.fetchRequests = [{ ...errorRequest, responseStatus: 401 }];
|
||||||
const r = await mount(oneHttp());
|
const r = await mount(oneHttp());
|
||||||
await expectFrame(r, "401 Unauthorized");
|
await expectFrame(r, "error");
|
||||||
|
await expectFrame(r, "HTTP Requests (1)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates dimensions when the terminal resizes", async () => {
|
it("updates dimensions when the terminal resizes", async () => {
|
||||||
|
|
@ -803,10 +805,10 @@ describe("App (input handling, focus, effects)", () => {
|
||||||
|
|
||||||
it("switches away from the Auth tab when selecting a non-OAuth server", async () => {
|
it("switches away from the Auth tab when selecting a non-OAuth server", async () => {
|
||||||
const r = await mount(httpThenStdio());
|
const r = await mount(httpThenStdio());
|
||||||
await press(r, ["a"]); // Auth tab (http is OAuth-capable)
|
await press(r, ["a", STAB, STAB, DOWN]);
|
||||||
await press(r, [STAB]); // tabs -> serverList
|
await waitUntil(() => (r.lastFrame() ?? "").includes("Type: stdio"));
|
||||||
await press(r, [DOWN]); // select the stdio server -> effect leaves Auth
|
|
||||||
await expectFrame(r, "Server Configuration");
|
await expectFrame(r, "Server Configuration");
|
||||||
|
expect(r.lastFrame() ?? "").not.toContain("No OAuth information yet");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches away from the Logging tab when selecting a non-stdio server", async () => {
|
it("switches away from the Logging tab when selecting a non-stdio server", async () => {
|
||||||
|
|
@ -834,6 +836,7 @@ describe("App (input handling, focus, effects)", () => {
|
||||||
const r = render(
|
const r = render(
|
||||||
<App
|
<App
|
||||||
mcpServers={oneHttp()}
|
mcpServers={oneHttp()}
|
||||||
|
clientConfig={emptyClientConfig}
|
||||||
callbackUrlConfig={callbackUrlConfig}
|
callbackUrlConfig={callbackUrlConfig}
|
||||||
clientId="cid"
|
clientId="cid"
|
||||||
clientSecret="sec"
|
clientSecret="sec"
|
||||||
|
|
@ -847,287 +850,90 @@ describe("App (input handling, focus, effects)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("App (OAuth flows)", () => {
|
describe("App (OAuth flows)", () => {
|
||||||
it("runs quick auth to success when no auth URL is returned", async () => {
|
const unauthorized = Object.assign(new Error("request failed (401)"), {
|
||||||
h.clientSpies.authenticate.mockResolvedValue(undefined);
|
status: 401,
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["q", ENTER]);
|
|
||||||
await tick();
|
|
||||||
expect(h.callbackStart).toHaveBeenCalled();
|
|
||||||
expect(h.clientSpies.authenticate).toHaveBeenCalled();
|
|
||||||
await expectFrame(r, "OAuth complete");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("completes quick auth when the OAuth callback fires", async () => {
|
it("runs OAuth on connect when the server returns 401", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
|
||||||
|
h.clientSpies.authenticate.mockResolvedValue(undefined);
|
||||||
const r = await mount(oneHttp());
|
const r = await mount(oneHttp());
|
||||||
await press(r, ["q", ENTER]);
|
await press(r, ["c"]);
|
||||||
|
await waitUntil(() => h.callbackStart.mock.calls.length > 0);
|
||||||
|
expect(h.disconnect).toHaveBeenCalled();
|
||||||
|
expect(h.clientSpies.authenticate).toHaveBeenCalled();
|
||||||
|
expect(h.connect).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes OAuth when the callback fires during a 401 connect", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined);
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["c"]);
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
await waitUntil(() => h.cb.opts !== null);
|
||||||
expect(h.cb.opts).not.toBeNull();
|
|
||||||
await h.cb.opts!.onCallback({ code: "abc" });
|
await h.cb.opts!.onCallback({ code: "abc" });
|
||||||
await tick();
|
await tick();
|
||||||
expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("abc");
|
expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("abc");
|
||||||
await expectFrame(r, "OAuth complete");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports an error when the OAuth callback errors", async () => {
|
it("reports an OAuth callback error during a 401 connect", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized);
|
||||||
const r = await mount(oneHttp());
|
const r = await mount(oneHttp());
|
||||||
await press(r, ["q", ENTER]);
|
await press(r, ["c"]);
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
await waitUntil(() => h.cb.opts !== null);
|
||||||
expect(h.cb.opts).not.toBeNull();
|
|
||||||
h.cb.opts!.onError({ error_description: "denied" });
|
h.cb.opts!.onError({ error_description: "denied" });
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "denied");
|
await expectFrame(r, "denied");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs guided auth to completion and opens the auth URL", async () => {
|
it("clears OAuth state from the Auth tab", async () => {
|
||||||
h.clientSpies.runGuidedAuth.mockResolvedValue("http://auth/x");
|
|
||||||
const r = await mount(oneHttp());
|
const r = await mount(oneHttp());
|
||||||
await press(r, ["g", ENTER]);
|
await press(r, ["a", "s"]);
|
||||||
await tick();
|
|
||||||
expect(h.clientSpies.runGuidedAuth).toHaveBeenCalled();
|
|
||||||
expect(h.openUrl).toHaveBeenCalledWith("http://auth/x");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an error when guided-to-completion fails", async () => {
|
|
||||||
h.clientSpies.runGuidedAuth.mockRejectedValue(new Error("nope"));
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", ENTER]);
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "nope");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts guided auth then advances a step, opening the auth URL", async () => {
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", " "]); // Space starts the guided flow
|
|
||||||
await tick();
|
|
||||||
h.ctrl.oauthFlowState = {
|
|
||||||
oauthStep: "authorization_code",
|
|
||||||
authorizationUrl: "http://auth/code",
|
|
||||||
};
|
|
||||||
await press(r, [" "]); // Space again advances one step
|
|
||||||
await tick();
|
|
||||||
expect(h.clientSpies.beginGuidedAuth).toHaveBeenCalled();
|
|
||||||
expect(h.clientSpies.proceedOAuthStep).toHaveBeenCalled();
|
|
||||||
expect(h.openUrl).toHaveBeenCalledWith("http://auth/code");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("advances guided auth without opening a URL", async () => {
|
|
||||||
h.ctrl.oauthFlowState = { oauthStep: "token_request" };
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", " "]);
|
|
||||||
await tick();
|
|
||||||
await press(r, [" "]);
|
|
||||||
await tick();
|
|
||||||
expect(h.clientSpies.proceedOAuthStep).toHaveBeenCalled();
|
|
||||||
expect(h.openUrl).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an error when guided start fails", async () => {
|
|
||||||
h.clientSpies.beginGuidedAuth.mockRejectedValue(new Error("startfail"));
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", " "]);
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "startfail");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an error when advancing guided auth fails", async () => {
|
|
||||||
h.clientSpies.proceedOAuthStep.mockRejectedValue(new Error("advfail"));
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", " "]);
|
|
||||||
await tick();
|
|
||||||
await press(r, [" "]);
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "advfail");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears OAuth state via the Clear action", async () => {
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["s", ENTER]);
|
|
||||||
await tick();
|
await tick();
|
||||||
expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalled();
|
expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalled();
|
||||||
await expectFrame(r, "OAuth state cleared");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports an error when quick callback completion fails", async () => {
|
it("reports callback completion failure during a 401 connect", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized);
|
||||||
h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("qcfail"));
|
h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("qcfail"));
|
||||||
const r = await mount(oneHttp());
|
const r = await mount(oneHttp());
|
||||||
await press(r, ["q", ENTER]);
|
await press(r, ["c"]);
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
await waitUntil(() => h.cb.opts !== null);
|
||||||
expect(h.cb.opts).not.toBeNull();
|
|
||||||
await h.cb.opts!.onCallback({ code: "x" });
|
await h.cb.opts!.onCallback({ code: "x" });
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "qcfail");
|
await expectFrame(r, "qcfail");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stops a prior callback server before starting quick auth again", async () => {
|
it("stringifies a non-Error authenticate rejection on 401 connect", async () => {
|
||||||
h.clientSpies.authenticate.mockResolvedValue(undefined);
|
h.connect.mockRejectedValueOnce(unauthorized);
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["q", ENTER]); // first run completes, leaving the server set
|
|
||||||
await tick();
|
|
||||||
await press(r, ["q", ENTER]); // second run stops the prior server
|
|
||||||
await tick();
|
|
||||||
expect(h.callbackStop).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("completes guided auth when the callback fires", async () => {
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", " "]); // Space starts guided + registers callback server
|
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
|
||||||
expect(h.cb.opts).not.toBeNull();
|
|
||||||
await h.cb.opts!.onCallback({ code: "gc" });
|
|
||||||
await tick();
|
|
||||||
expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("gc");
|
|
||||||
await expectFrame(r, "OAuth complete");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an error when the guided callback errors", async () => {
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", " "]);
|
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
|
||||||
expect(h.cb.opts).not.toBeNull();
|
|
||||||
h.cb.opts!.onError({ error: "guided-bad" });
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "guided-bad");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an error when guided callback completion fails", async () => {
|
|
||||||
h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("gfail"));
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", " "]);
|
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
|
||||||
await h.cb.opts!.onCallback({ code: "x" });
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "gfail");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("completes run-to-completion auth when the callback fires", async () => {
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", ENTER]); // runs to completion -> ensureCallbackServer
|
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
|
||||||
expect(h.cb.opts).not.toBeNull();
|
|
||||||
await h.cb.opts!.onCallback({ code: "rc" });
|
|
||||||
await tick();
|
|
||||||
expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("rc");
|
|
||||||
await expectFrame(r, "OAuth complete");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an error when the run-to-completion callback errors", async () => {
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", ENTER]);
|
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
|
||||||
expect(h.cb.opts).not.toBeNull();
|
|
||||||
h.cb.opts!.onError({ error: "rc-bad" });
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "rc-bad");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stringifies a non-Error quick auth rejection", async () => {
|
|
||||||
h.clientSpies.authenticate.mockRejectedValue("plainstring");
|
h.clientSpies.authenticate.mockRejectedValue("plainstring");
|
||||||
const r = await mount(oneHttp());
|
const r = await mount(oneHttp());
|
||||||
await press(r, ["q", ENTER]);
|
await press(r, ["c"]);
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "plainstring");
|
await expectFrame(r, "plainstring");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the default OAuth error label when the callback error is empty", async () => {
|
it("uses the default OAuth error label when the callback error is empty", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized);
|
||||||
const r = await mount(oneHttp());
|
const r = await mount(oneHttp());
|
||||||
await press(r, ["q", ENTER]);
|
await press(r, ["c"]);
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
await waitUntil(() => h.cb.opts !== null);
|
||||||
expect(h.cb.opts).not.toBeNull();
|
|
||||||
h.cb.opts!.onError({});
|
h.cb.opts!.onError({});
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "OAuth error");
|
await expectFrame(r, "OAuth error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stringifies a non-Error guided callback completion failure", async () => {
|
it("falls back to params.error when the callback has no description", async () => {
|
||||||
h.clientSpies.completeOAuthFlow.mockRejectedValue("guided-string");
|
h.connect.mockRejectedValueOnce(unauthorized);
|
||||||
const r = await mount(oneHttp());
|
const r = await mount(oneHttp());
|
||||||
await press(r, ["g", " "]);
|
await press(r, ["c"]);
|
||||||
|
await waitUntil(() => h.cb.opts !== null);
|
||||||
|
h.cb.opts!.onError({ error: "oauth-error-code" });
|
||||||
|
await expectFrame(r, "oauth-error-code");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps a non-Error callback completion failure into an Error", async () => {
|
||||||
|
h.connect.mockRejectedValueOnce(unauthorized);
|
||||||
|
h.clientSpies.completeOAuthFlow.mockRejectedValue("cb-string");
|
||||||
|
const r = await mount(oneHttp());
|
||||||
|
await press(r, ["c"]);
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
await waitUntil(() => h.cb.opts !== null);
|
||||||
await h.cb.opts!.onCallback({ code: "x" });
|
await h.cb.opts!.onCallback({ code: "x" });
|
||||||
await tick();
|
await expectFrame(r, "cb-string");
|
||||||
await expectFrame(r, "guided-string");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to params.error when the quick callback has no description", async () => {
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["q", ENTER]);
|
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
|
||||||
h.cb.opts!.onError({ error: "quick-error-code" });
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "quick-error-code");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the default label when the guided callback error is empty", async () => {
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", " "]);
|
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
|
||||||
h.cb.opts!.onError({});
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "OAuth error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the default label when the run-to-completion error is empty", async () => {
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", ENTER]);
|
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
|
||||||
h.cb.opts!.onError({});
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "OAuth error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("wraps a non-Error quick callback rejection into an Error", async () => {
|
|
||||||
h.clientSpies.completeOAuthFlow.mockRejectedValue("quick-cb-string");
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["q", ENTER]);
|
|
||||||
await waitUntil(() => h.cb.opts !== null);
|
|
||||||
expect(h.cb.opts).not.toBeNull();
|
|
||||||
await h.cb.opts!.onCallback({ code: "x" });
|
|
||||||
await tick();
|
|
||||||
// quick auth's onCallback wraps the throw via new Error(String(err)); the
|
|
||||||
// flow rejects and the catch surfaces the stringified message.
|
|
||||||
await expectFrame(r, "quick-cb-string");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stringifies a non-Error guided-start rejection", async () => {
|
|
||||||
h.clientSpies.beginGuidedAuth.mockRejectedValue("startfail-string");
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", " "]);
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "startfail-string");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stringifies a non-Error guided-advance rejection", async () => {
|
|
||||||
h.clientSpies.proceedOAuthStep.mockRejectedValue("advfail-string");
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", " "]);
|
|
||||||
await tick();
|
|
||||||
await press(r, [" "]);
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "advfail-string");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stringifies a non-Error run-to-completion rejection", async () => {
|
|
||||||
h.clientSpies.runGuidedAuth.mockRejectedValue("runfail-string");
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["g", ENTER]);
|
|
||||||
await tick();
|
|
||||||
await expectFrame(r, "runfail-string");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores a re-entrant quick-auth trigger while one is in progress", async () => {
|
|
||||||
// Hold the first flow open by leaving authenticate pending, so the second
|
|
||||||
// 'q' hits the `if (oauthInProgressRef.current) return` guard.
|
|
||||||
let release!: () => void;
|
|
||||||
h.clientSpies.authenticate.mockImplementation(
|
|
||||||
() => new Promise<string>((res) => (release = () => res(undefined))),
|
|
||||||
);
|
|
||||||
const r = await mount(oneHttp());
|
|
||||||
await press(r, ["q", ENTER]);
|
|
||||||
await tick();
|
|
||||||
await press(r, ["q", ENTER]); // re-entrant: guarded out
|
|
||||||
await tick();
|
|
||||||
expect(h.callbackStart).toHaveBeenCalledTimes(1);
|
|
||||||
release();
|
|
||||||
await tick();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,14 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render } from "ink-testing-library";
|
import { render } from "ink-testing-library";
|
||||||
import {
|
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
|
||||||
EMPTY_OAUTH_FLOW_STATE,
|
|
||||||
type OAuthFlowState,
|
|
||||||
} from "@inspector/core/auth/index.js";
|
|
||||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
|
||||||
// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap
|
|
||||||
// in the non-TTY test env and never mounts its children. This passthrough
|
|
||||||
// renders children directly and stubs scrollBy/scrollTo/getViewportHeight.
|
|
||||||
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
|
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
|
||||||
|
|
||||||
import { AuthTab } from "../src/components/AuthTab.js";
|
import { AuthTab } from "../src/components/AuthTab.js";
|
||||||
|
|
||||||
// Ink processes stdin keypresses asynchronously — await this after stdin.write.
|
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
// Flush several macrotask cycles so an effect -> setState -> re-render chain
|
|
||||||
// settles before assertions, even on slow/loaded CI (a single tick can race).
|
|
||||||
for (let i = 0; i < 8; i++)
|
for (let i = 0; i < 8; i++)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 4));
|
await new Promise((resolve) => setTimeout(resolve, 4));
|
||||||
};
|
};
|
||||||
|
|
@ -25,16 +16,32 @@ const tick = async () => {
|
||||||
const ESC = String.fromCharCode(27);
|
const ESC = String.fromCharCode(27);
|
||||||
const UP = `${ESC}[A`;
|
const UP = `${ESC}[A`;
|
||||||
const DOWN = `${ESC}[B`;
|
const DOWN = `${ESC}[B`;
|
||||||
const LEFT = `${ESC}[D`;
|
|
||||||
const RIGHT = `${ESC}[C`;
|
|
||||||
const PAGE_UP = `${ESC}[5~`;
|
const PAGE_UP = `${ESC}[5~`;
|
||||||
const PAGE_DOWN = `${ESC}[6~`;
|
const PAGE_DOWN = `${ESC}[6~`;
|
||||||
|
|
||||||
/** Minimal fake InspectorClient that only implements the surface AuthTab uses. */
|
const sampleOAuthState: OAuthConnectionState = {
|
||||||
function makeClient(state?: OAuthFlowState) {
|
authorized: true,
|
||||||
|
protocol: "standard",
|
||||||
|
serverUrl: "http://x/mcp",
|
||||||
|
client: {
|
||||||
|
clientId: "abc123",
|
||||||
|
registrationKind: "dcr",
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
access_token: "tok-abcdefghijklmnopqrstuvwxyz",
|
||||||
|
token_type: "Bearer",
|
||||||
|
},
|
||||||
|
authorizationServerMetadata: {
|
||||||
|
authorization_endpoint: "https://auth.example.com/authorize",
|
||||||
|
},
|
||||||
|
configuredScope: "read write",
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeClient(oauthState?: OAuthConnectionState) {
|
||||||
const listeners = new Map<string, Set<() => void>>();
|
const listeners = new Map<string, Set<() => void>>();
|
||||||
|
const getOAuthState = vi.fn(async () => oauthState);
|
||||||
const client = {
|
const client = {
|
||||||
getOAuthFlowState: () => state,
|
getOAuthState,
|
||||||
addEventListener: (event: string, fn: () => void) => {
|
addEventListener: (event: string, fn: () => void) => {
|
||||||
if (!listeners.has(event)) listeners.set(event, new Set());
|
if (!listeners.has(event)) listeners.set(event, new Set());
|
||||||
listeners.get(event)!.add(fn);
|
listeners.get(event)!.add(fn);
|
||||||
|
|
@ -48,51 +55,20 @@ function makeClient(state?: OAuthFlowState) {
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
client: client as unknown as InspectorClient,
|
client: client as unknown as InspectorClient,
|
||||||
|
getOAuthState,
|
||||||
fire,
|
fire,
|
||||||
listeners,
|
listeners,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const flow = (over: Partial<OAuthFlowState>): OAuthFlowState => ({
|
|
||||||
...EMPTY_OAUTH_FLOW_STATE,
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A state at "complete" with every detail field populated so getStepDetails
|
|
||||||
// returns a non-null value for every step (all rendered as completed).
|
|
||||||
const completeState = flow({
|
|
||||||
execution: "guided",
|
|
||||||
oauthStep: "complete",
|
|
||||||
resourceMetadata: {
|
|
||||||
resource: "https://api.example.com",
|
|
||||||
} as OAuthFlowState["resourceMetadata"],
|
|
||||||
oauthMetadata: {
|
|
||||||
issuer: "https://issuer.example.com",
|
|
||||||
} as OAuthFlowState["oauthMetadata"],
|
|
||||||
oauthClientInfo: {
|
|
||||||
client_id: "abc123",
|
|
||||||
} as OAuthFlowState["oauthClientInfo"],
|
|
||||||
authorizationUrl: new URL("https://auth.example.com/authorize?x=1"),
|
|
||||||
authorizationCode: "code-abcdef1234567890",
|
|
||||||
oauthTokens: {
|
|
||||||
access_token: "tok-abcdefghijklmnopqrstuvwxyz",
|
|
||||||
token_type: "Bearer",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
serverName: "srv" as string | null,
|
serverName: "srv" as string | null,
|
||||||
serverConfig: null,
|
serverConfig: null,
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 30,
|
height: 30,
|
||||||
isOAuthCapable: true,
|
oauthRevision: 0,
|
||||||
selectedAction: "guided" as "guided" | "quick" | "clear",
|
|
||||||
onSelectedActionChange: vi.fn(),
|
|
||||||
onQuickAuth: vi.fn(async () => {}),
|
|
||||||
onGuidedStart: vi.fn(async () => {}),
|
|
||||||
onGuidedAdvance: vi.fn(async () => {}),
|
|
||||||
onRunGuidedToCompletion: vi.fn(async () => {}),
|
|
||||||
onClearOAuth: vi.fn(),
|
onClearOAuth: vi.fn(),
|
||||||
|
connectionStatus: "disconnected" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("AuthTab", () => {
|
describe("AuthTab", () => {
|
||||||
|
|
@ -106,30 +82,16 @@ describe("AuthTab", () => {
|
||||||
oauthMessage={null}
|
oauthMessage={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(lastFrame() ?? "").toContain("Select an OAuth-capable server");
|
expect(lastFrame() ?? "").toContain(
|
||||||
});
|
"Select a server to view authentication",
|
||||||
|
|
||||||
it("renders the placeholder when the server is not OAuth-capable", () => {
|
|
||||||
const { lastFrame } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
isOAuthCapable={false}
|
|
||||||
inspectorClient={null}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(lastFrame() ?? "").toContain("Select an OAuth-capable server");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the guided action bar, hint, and progress (unfocused)", async () => {
|
it("renders OAuth details from getOAuthState", async () => {
|
||||||
const { client } = makeClient(completeState);
|
const { client } = makeClient(sampleOAuthState);
|
||||||
// tall enough that no step detail is clipped by the fixed-height Box
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<AuthTab
|
<AuthTab
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
height={80}
|
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
inspectorClient={client}
|
||||||
oauthStatus="idle"
|
oauthStatus="idle"
|
||||||
oauthMessage={null}
|
oauthMessage={null}
|
||||||
|
|
@ -137,44 +99,19 @@ describe("AuthTab", () => {
|
||||||
);
|
);
|
||||||
await tick();
|
await tick();
|
||||||
const frame = lastFrame() ?? "";
|
const frame = lastFrame() ?? "";
|
||||||
expect(frame).toContain("Authentication");
|
expect(frame).toContain("OAuth Details");
|
||||||
expect(frame).toContain("uided Auth");
|
expect(frame).toContain("Authorized");
|
||||||
expect(frame).toContain("uick Auth");
|
expect(frame).toContain("abc123");
|
||||||
expect(frame).toContain("Clear OAuth");
|
expect(frame).toContain("Dynamic (DCR)");
|
||||||
expect(frame).toContain("Press [Space] to advance one step");
|
expect(frame).toContain("read, write");
|
||||||
expect(frame).toContain("Press [Enter] to run guided auth to completion");
|
expect(frame).toContain("tok-abcdefghijklmnopqrst");
|
||||||
expect(frame).toContain("Guided OAuth Flow Progress");
|
|
||||||
// every step label, all completed (✓)
|
|
||||||
expect(frame).toContain("Metadata Discovery");
|
|
||||||
expect(frame).toContain("Client Registration");
|
|
||||||
expect(frame).toContain("Preparing Authorization");
|
|
||||||
expect(frame).toContain("Request Authorization Code");
|
|
||||||
expect(frame).toContain("Token Request");
|
|
||||||
expect(frame).toContain("Authentication Complete");
|
|
||||||
expect(frame).toContain("✓");
|
|
||||||
// completed detail strings from getStepDetails
|
|
||||||
expect(frame).toContain("Resource:");
|
|
||||||
expect(frame).toContain("OAuth:");
|
|
||||||
expect(frame).toContain("Code received:");
|
|
||||||
expect(frame).toContain("Exchanging code for tokens...");
|
|
||||||
expect(frame).toContain("Tokens: access_token=");
|
|
||||||
// no focused footer
|
|
||||||
expect(frame).not.toContain("select, G/Q/S or Enter run");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders an in-progress step (cyan →) and not-started steps (○)", async () => {
|
it("shows the not-yet-authorized hint when getOAuthState is empty", async () => {
|
||||||
const midState = flow({
|
const { client } = makeClient(undefined);
|
||||||
execution: "guided",
|
|
||||||
oauthStep: "client_registration",
|
|
||||||
oauthClientInfo: {
|
|
||||||
client_id: "mid-client",
|
|
||||||
} as OAuthFlowState["oauthClientInfo"],
|
|
||||||
});
|
|
||||||
const { client } = makeClient(midState);
|
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<AuthTab
|
<AuthTab
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
inspectorClient={client}
|
||||||
oauthStatus="idle"
|
oauthStatus="idle"
|
||||||
oauthMessage={null}
|
oauthMessage={null}
|
||||||
|
|
@ -182,133 +119,25 @@ describe("AuthTab", () => {
|
||||||
);
|
);
|
||||||
await tick();
|
await tick();
|
||||||
const frame = lastFrame() ?? "";
|
const frame = lastFrame() ?? "";
|
||||||
expect(frame).toContain("(in progress)");
|
expect(frame).toContain("No OAuth information yet");
|
||||||
expect(frame).toContain("→");
|
expect(frame).toContain("Connect (C) to authorize");
|
||||||
expect(frame).toContain("○");
|
|
||||||
// in-progress detail (client_registration → oauthClientInfo JSON)
|
|
||||||
expect(frame).toContain("mid-client");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the 'authorization URL opened' block when awaiting an auth code", async () => {
|
it("renders authenticating and error status messages", async () => {
|
||||||
const awaitingState = flow({
|
const { client } = makeClient(undefined);
|
||||||
execution: "guided",
|
const { lastFrame, rerender } = render(
|
||||||
oauthStep: "authorization_code",
|
|
||||||
authorizationUrl: new URL("https://auth.example.com/go?code=here"),
|
|
||||||
});
|
|
||||||
const { client } = makeClient(awaitingState);
|
|
||||||
const { lastFrame } = render(
|
|
||||||
<AuthTab
|
<AuthTab
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await tick();
|
|
||||||
const frame = lastFrame() ?? "";
|
|
||||||
expect(frame).toContain("Authorization URL opened in browser");
|
|
||||||
expect(frame).toContain("auth.example.com/go");
|
|
||||||
expect(frame).toContain("Complete authorization in the browser");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("covers metadata details when only the resource metadata is present", async () => {
|
|
||||||
const resourceOnly = flow({
|
|
||||||
oauthStep: "complete",
|
|
||||||
resourceMetadata: {
|
|
||||||
resource: "https://only-resource.example.com",
|
|
||||||
} as OAuthFlowState["resourceMetadata"],
|
|
||||||
});
|
|
||||||
const { client } = makeClient(resourceOnly);
|
|
||||||
const { lastFrame } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await tick();
|
|
||||||
const frame = lastFrame() ?? "";
|
|
||||||
expect(frame).toContain("Resource:");
|
|
||||||
expect(frame).not.toContain("OAuth: {");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("covers metadata details when only the oauth metadata is present", async () => {
|
|
||||||
const oauthOnly = flow({
|
|
||||||
oauthStep: "complete",
|
|
||||||
oauthMetadata: {
|
|
||||||
issuer: "https://only-issuer.example.com",
|
|
||||||
} as OAuthFlowState["oauthMetadata"],
|
|
||||||
});
|
|
||||||
const { client } = makeClient(oauthOnly);
|
|
||||||
const { lastFrame } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await tick();
|
|
||||||
expect(lastFrame() ?? "").toContain("OAuth:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders guided progress with no details when the flow state is empty", () => {
|
|
||||||
const { client } = makeClient(flow({}));
|
|
||||||
const { lastFrame } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const frame = lastFrame() ?? "";
|
|
||||||
expect(frame).toContain("Guided OAuth Flow Progress");
|
|
||||||
expect(frame).not.toContain("Resource:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders guided progress with no inspector client (no flow state)", () => {
|
|
||||||
const { lastFrame } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={null}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const frame = lastFrame() ?? "";
|
|
||||||
expect(frame).toContain("Guided OAuth Flow Progress");
|
|
||||||
expect(frame).toContain("○");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the quick hint and 'Authenticating...' status", () => {
|
|
||||||
const { client } = makeClient(flow({ execution: "quick" }));
|
|
||||||
const { lastFrame } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
selectedAction="quick"
|
|
||||||
inspectorClient={client}
|
inspectorClient={client}
|
||||||
oauthStatus="authenticating"
|
oauthStatus="authenticating"
|
||||||
oauthMessage={null}
|
oauthMessage={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
const frame = lastFrame() ?? "";
|
expect(lastFrame() ?? "").toContain("Authenticating");
|
||||||
expect(frame).toContain("Press [Enter] to run quick auth");
|
|
||||||
expect(frame).toContain("Authenticating...");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the quick error message", () => {
|
rerender(
|
||||||
const { client } = makeClient(flow({ execution: "quick" }));
|
|
||||||
const { lastFrame } = render(
|
|
||||||
<AuthTab
|
<AuthTab
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
selectedAction="quick"
|
|
||||||
inspectorClient={client}
|
inspectorClient={client}
|
||||||
oauthStatus="error"
|
oauthStatus="error"
|
||||||
oauthMessage="Something went wrong"
|
oauthMessage="Something went wrong"
|
||||||
|
|
@ -317,178 +146,69 @@ describe("AuthTab", () => {
|
||||||
expect(lastFrame() ?? "").toContain("Something went wrong");
|
expect(lastFrame() ?? "").toContain("Something went wrong");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders quick auth results with client info and tokens", async () => {
|
it("clears OAuth state on S and shows confirmation", async () => {
|
||||||
const quickSuccess = flow({
|
|
||||||
execution: "quick",
|
|
||||||
oauthClientInfo: {
|
|
||||||
client_id: "quick-client",
|
|
||||||
} as OAuthFlowState["oauthClientInfo"],
|
|
||||||
oauthTokens: {
|
|
||||||
access_token: "quick-token-abcdefghijklmnop",
|
|
||||||
token_type: "Bearer",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { client } = makeClient(quickSuccess);
|
|
||||||
const { lastFrame } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
selectedAction="quick"
|
|
||||||
inspectorClient={client}
|
|
||||||
oauthStatus="success"
|
|
||||||
oauthMessage={null}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await tick();
|
|
||||||
const frame = lastFrame() ?? "";
|
|
||||||
expect(frame).toContain("Quick Auth Results");
|
|
||||||
expect(frame).toContain("quick-client");
|
|
||||||
expect(frame).toContain("Access Token:");
|
|
||||||
expect(frame).toContain("quick-token-abcdefgh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the clear hint and the confirmation after pressing Enter", async () => {
|
|
||||||
const onClearOAuth = vi.fn();
|
const onClearOAuth = vi.fn();
|
||||||
const { client } = makeClient(flow({}));
|
const { client } = makeClient(sampleOAuthState);
|
||||||
const { lastFrame, stdin } = render(
|
const { lastFrame, stdin } = render(
|
||||||
<AuthTab
|
<AuthTab
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
focused
|
focused
|
||||||
selectedAction="clear"
|
|
||||||
inspectorClient={client}
|
inspectorClient={client}
|
||||||
oauthStatus="idle"
|
oauthStatus="idle"
|
||||||
oauthMessage={null}
|
oauthMessage={null}
|
||||||
onClearOAuth={onClearOAuth}
|
onClearOAuth={onClearOAuth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(lastFrame() ?? "").toContain("Press [Enter] to clear OAuth state");
|
stdin.write("s");
|
||||||
// a leading no-op key absorbs any dropped first keypress
|
|
||||||
stdin.write("x");
|
|
||||||
await tick();
|
|
||||||
stdin.write("\r");
|
|
||||||
await tick();
|
|
||||||
await tick();
|
await tick();
|
||||||
expect(onClearOAuth).toHaveBeenCalled();
|
expect(onClearOAuth).toHaveBeenCalled();
|
||||||
expect(lastFrame() ?? "").toContain("OAuth state cleared.");
|
expect(lastFrame() ?? "").toContain("OAuth state cleared");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the focused footer and header highlight when focused", () => {
|
it("shows clear+disconnect label when connected", async () => {
|
||||||
const { client } = makeClient(completeState);
|
const { client } = makeClient(sampleOAuthState);
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
<AuthTab
|
<AuthTab
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
focused
|
focused
|
||||||
selectedAction="guided"
|
connectionStatus="connected"
|
||||||
inspectorClient={client}
|
inspectorClient={client}
|
||||||
oauthStatus="idle"
|
oauthStatus="idle"
|
||||||
oauthMessage={null}
|
oauthMessage={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(lastFrame() ?? "").toContain("select, G/Q/S or Enter run");
|
await tick();
|
||||||
|
expect(lastFrame() ?? "").toContain("clear+disconnect");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects actions via G/Q/S keys when focused", async () => {
|
it("shows the focused footer when focused", async () => {
|
||||||
const onSelectedActionChange = vi.fn();
|
const { client } = makeClient(sampleOAuthState);
|
||||||
const { client } = makeClient(flow({}));
|
const { lastFrame } = render(
|
||||||
const { stdin } = render(
|
|
||||||
<AuthTab
|
<AuthTab
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
focused
|
focused
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
inspectorClient={client}
|
||||||
oauthStatus="idle"
|
oauthStatus="idle"
|
||||||
oauthMessage={null}
|
oauthMessage={null}
|
||||||
onSelectedActionChange={onSelectedActionChange}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
stdin.write("g");
|
|
||||||
await tick();
|
await tick();
|
||||||
stdin.write("q");
|
expect(lastFrame() ?? "").toContain("S clear");
|
||||||
await tick();
|
|
||||||
stdin.write("s");
|
|
||||||
await tick();
|
|
||||||
expect(onSelectedActionChange).toHaveBeenCalledWith("guided");
|
|
||||||
expect(onSelectedActionChange).toHaveBeenCalledWith("quick");
|
|
||||||
expect(onSelectedActionChange).toHaveBeenCalledWith("clear");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cycles selection with left/right arrows from 'guided'", async () => {
|
it("scrolls with arrow and page keys when focused", async () => {
|
||||||
const onSelectedActionChange = vi.fn();
|
const { client } = makeClient(sampleOAuthState);
|
||||||
const { client } = makeClient(flow({}));
|
|
||||||
const { stdin } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
focused
|
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
onSelectedActionChange={onSelectedActionChange}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
stdin.write(LEFT);
|
|
||||||
await tick();
|
|
||||||
stdin.write(RIGHT);
|
|
||||||
await tick();
|
|
||||||
expect(onSelectedActionChange).toHaveBeenCalledWith("clear");
|
|
||||||
expect(onSelectedActionChange).toHaveBeenCalledWith("quick");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cycles selection with left/right arrows from 'quick'", async () => {
|
|
||||||
const onSelectedActionChange = vi.fn();
|
|
||||||
const { client } = makeClient(flow({ execution: "quick" }));
|
|
||||||
const { stdin } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
focused
|
|
||||||
selectedAction="quick"
|
|
||||||
inspectorClient={client}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
onSelectedActionChange={onSelectedActionChange}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
stdin.write(LEFT);
|
|
||||||
await tick();
|
|
||||||
stdin.write(RIGHT);
|
|
||||||
await tick();
|
|
||||||
expect(onSelectedActionChange).toHaveBeenCalledWith("guided");
|
|
||||||
expect(onSelectedActionChange).toHaveBeenCalledWith("clear");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cycles selection with left/right arrows from 'clear'", async () => {
|
|
||||||
const onSelectedActionChange = vi.fn();
|
|
||||||
const { client } = makeClient(flow({}));
|
|
||||||
const { stdin } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
focused
|
|
||||||
selectedAction="clear"
|
|
||||||
inspectorClient={client}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
onSelectedActionChange={onSelectedActionChange}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
stdin.write(LEFT);
|
|
||||||
await tick();
|
|
||||||
stdin.write(RIGHT);
|
|
||||||
await tick();
|
|
||||||
expect(onSelectedActionChange).toHaveBeenCalledWith("quick");
|
|
||||||
expect(onSelectedActionChange).toHaveBeenCalledWith("guided");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scrolls with up/down/pageUp/pageDown when focused", async () => {
|
|
||||||
const { client } = makeClient(completeState);
|
|
||||||
const { lastFrame, stdin } = render(
|
const { lastFrame, stdin } = render(
|
||||||
<AuthTab
|
<AuthTab
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
|
height={80}
|
||||||
focused
|
focused
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
inspectorClient={client}
|
||||||
oauthStatus="idle"
|
oauthStatus="idle"
|
||||||
oauthMessage={null}
|
oauthMessage={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
await tick();
|
||||||
stdin.write(UP);
|
stdin.write(UP);
|
||||||
await tick();
|
await tick();
|
||||||
stdin.write(DOWN);
|
stdin.write(DOWN);
|
||||||
|
|
@ -497,126 +217,28 @@ describe("AuthTab", () => {
|
||||||
await tick();
|
await tick();
|
||||||
stdin.write(PAGE_DOWN);
|
stdin.write(PAGE_DOWN);
|
||||||
await tick();
|
await tick();
|
||||||
// still rendered (scroll stubs are no-ops)
|
expect(lastFrame() ?? "").toContain("OAuth Details");
|
||||||
expect(lastFrame() ?? "").toContain("Guided OAuth Flow Progress");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs guided to completion on Enter when 'guided' is selected", async () => {
|
it("refreshes OAuth state when oauthComplete fires", async () => {
|
||||||
const onRunGuidedToCompletion = vi.fn(async () => {});
|
const { client, getOAuthState, fire, listeners } =
|
||||||
const { client } = makeClient(flow({}));
|
makeClient(sampleOAuthState);
|
||||||
const { stdin } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
focused
|
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
onRunGuidedToCompletion={onRunGuidedToCompletion}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// a leading no-op key absorbs any dropped first keypress
|
|
||||||
stdin.write("x");
|
|
||||||
await tick();
|
|
||||||
stdin.write("\r");
|
|
||||||
await tick();
|
|
||||||
expect(onRunGuidedToCompletion).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs quick auth on Enter when 'quick' is selected", async () => {
|
|
||||||
const onQuickAuth = vi.fn(async () => {});
|
|
||||||
const { client } = makeClient(flow({ execution: "quick" }));
|
|
||||||
const { stdin } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
focused
|
|
||||||
selectedAction="quick"
|
|
||||||
inspectorClient={client}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
onQuickAuth={onQuickAuth}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
stdin.write("x");
|
|
||||||
await tick();
|
|
||||||
stdin.write("\r");
|
|
||||||
await tick();
|
|
||||||
expect(onQuickAuth).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("advances guided one step on Space (start then advance)", async () => {
|
|
||||||
const onGuidedStart = vi.fn(async () => {});
|
|
||||||
const onGuidedAdvance = vi.fn(async () => {});
|
|
||||||
// mid-flow state so needsAuthCode/isComplete are both false on advance
|
|
||||||
const { client } = makeClient(
|
|
||||||
flow({ execution: "guided", oauthStep: "client_registration" }),
|
|
||||||
);
|
|
||||||
const { stdin } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
focused
|
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
onGuidedStart={onGuidedStart}
|
|
||||||
onGuidedAdvance={onGuidedAdvance}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
// first space starts the flow
|
|
||||||
stdin.write(" ");
|
|
||||||
await tick();
|
|
||||||
// second space advances one step
|
|
||||||
stdin.write(" ");
|
|
||||||
await tick();
|
|
||||||
// third space (in case the first was dropped) keeps both reachable
|
|
||||||
stdin.write(" ");
|
|
||||||
await tick();
|
|
||||||
expect(onGuidedStart).toHaveBeenCalled();
|
|
||||||
expect(onGuidedAdvance).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not act on input when not OAuth-capable but focused", async () => {
|
|
||||||
const onSelectedActionChange = vi.fn();
|
|
||||||
const { stdin, lastFrame } = render(
|
|
||||||
<AuthTab
|
|
||||||
{...baseProps}
|
|
||||||
focused
|
|
||||||
isOAuthCapable={false}
|
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={null}
|
|
||||||
oauthStatus="idle"
|
|
||||||
oauthMessage={null}
|
|
||||||
onSelectedActionChange={onSelectedActionChange}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
stdin.write("g");
|
|
||||||
await tick();
|
|
||||||
expect(onSelectedActionChange).not.toHaveBeenCalled();
|
|
||||||
expect(lastFrame() ?? "").toContain("Select an OAuth-capable server");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("subscribes to oauth events and refreshes when they fire", async () => {
|
|
||||||
const { client, fire, listeners } = makeClient(completeState);
|
|
||||||
const { unmount } = render(
|
const { unmount } = render(
|
||||||
<AuthTab
|
<AuthTab
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
selectedAction="guided"
|
|
||||||
inspectorClient={client}
|
inspectorClient={client}
|
||||||
oauthStatus="idle"
|
oauthStatus="idle"
|
||||||
oauthMessage={null}
|
oauthMessage={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await tick();
|
await tick();
|
||||||
expect(listeners.get("oauthStepChange")?.size).toBe(1);
|
expect(getOAuthState).toHaveBeenCalled();
|
||||||
expect(listeners.get("oauthComplete")?.size).toBe(1);
|
expect(listeners.get("oauthComplete")?.size).toBe(1);
|
||||||
// firing the listeners runs the update() callback
|
getOAuthState.mockClear();
|
||||||
fire("oauthStepChange");
|
|
||||||
fire("oauthComplete");
|
fire("oauthComplete");
|
||||||
await tick();
|
await tick();
|
||||||
// unmount runs the cleanup (removeEventListener)
|
expect(getOAuthState).toHaveBeenCalled();
|
||||||
unmount();
|
unmount();
|
||||||
expect(listeners.get("oauthStepChange")?.size).toBe(0);
|
|
||||||
expect(listeners.get("oauthComplete")?.size).toBe(0);
|
expect(listeners.get("oauthComplete")?.size).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
52
clients/tui/__tests__/oauthDisplay.test.ts
Normal file
52
clients/tui/__tests__/oauthDisplay.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
formatAuthProtocol,
|
||||||
|
formatClientRegistrationKind,
|
||||||
|
formatIdpSession,
|
||||||
|
formatScopes,
|
||||||
|
} from "../src/utils/oauthDisplay.js";
|
||||||
|
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
|
||||||
|
|
||||||
|
describe("oauthDisplay", () => {
|
||||||
|
it("formatAuthProtocol distinguishes standard and EMA", () => {
|
||||||
|
expect(formatAuthProtocol("standard")).toBe("Standard");
|
||||||
|
expect(formatAuthProtocol("ema")).toBe("Enterprise-managed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatClientRegistrationKind covers registration kinds", () => {
|
||||||
|
expect(formatClientRegistrationKind("static")).toBe(
|
||||||
|
"Static (preregistered)",
|
||||||
|
);
|
||||||
|
expect(formatClientRegistrationKind("dcr")).toBe("Dynamic (DCR)");
|
||||||
|
expect(formatClientRegistrationKind("cimd")).toBe(
|
||||||
|
"Client ID Metadata (CIMD)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatIdpSession maps session states", () => {
|
||||||
|
expect(formatIdpSession("logged_in")).toBe("Signed in");
|
||||||
|
expect(formatIdpSession("expired")).toBe("Session expired");
|
||||||
|
expect(formatIdpSession("none")).toBe("Not signed in");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatScopes joins granted scope", () => {
|
||||||
|
const state = {
|
||||||
|
grantedScope: "openid profile",
|
||||||
|
} as OAuthConnectionState;
|
||||||
|
expect(formatScopes(state)).toBe("openid, profile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatScopes falls back to configured scope", () => {
|
||||||
|
const state = {
|
||||||
|
configuredScope: "read write",
|
||||||
|
} as OAuthConnectionState;
|
||||||
|
expect(formatScopes(state)).toBe("read, write");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatScopes returns undefined when no scopes are present", () => {
|
||||||
|
expect(formatScopes({} as OAuthConnectionState)).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
formatScopes({ grantedScope: " " } as OAuthConnectionState),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,7 +21,7 @@ describe("loadTuiServers", () => {
|
||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads named servers from a read-only --config file", () => {
|
it("loads named servers from a read-only --config file", async () => {
|
||||||
const configPath = join(tempDir, "mcp.json");
|
const configPath = join(tempDir, "mcp.json");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
configPath,
|
configPath,
|
||||||
|
|
@ -29,7 +29,7 @@ describe("loadTuiServers", () => {
|
||||||
mcpServers: { foo: { command: "node", args: ["foo.js"] } },
|
mcpServers: { foo: { command: "node", args: ["foo.js"] } },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const servers = loadTuiServers({ configPath });
|
const servers = await loadTuiServers({ configPath });
|
||||||
expect(Object.keys(servers)).toEqual(["foo"]);
|
expect(Object.keys(servers)).toEqual(["foo"]);
|
||||||
expect(servers.foo?.config).toMatchObject({
|
expect(servers.foo?.config).toMatchObject({
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
|
|
@ -37,9 +37,9 @@ describe("loadTuiServers", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds an empty writable catalog when --catalog is missing", () => {
|
it("seeds an empty writable catalog when --catalog is missing", async () => {
|
||||||
const catalogPath = join(tempDir, "catalog.json");
|
const catalogPath = join(tempDir, "catalog.json");
|
||||||
const servers = loadTuiServers({ catalogPath });
|
const servers = await loadTuiServers({ catalogPath });
|
||||||
expect(servers).toEqual({});
|
expect(servers).toEqual({});
|
||||||
expect(existsSync(catalogPath)).toBe(true);
|
expect(existsSync(catalogPath)).toBe(true);
|
||||||
expect(JSON.parse(readFileSync(catalogPath, "utf-8"))).toEqual({
|
expect(JSON.parse(readFileSync(catalogPath, "utf-8"))).toEqual({
|
||||||
|
|
@ -47,34 +47,34 @@ describe("loadTuiServers", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when a read-only --config file is missing (never seeds)", () => {
|
it("throws when a read-only --config file is missing (never seeds)", async () => {
|
||||||
const configPath = join(tempDir, "absent.json");
|
const configPath = join(tempDir, "absent.json");
|
||||||
expect(() => loadTuiServers({ configPath })).toThrow(
|
await expect(loadTuiServers({ configPath })).rejects.toThrow(
|
||||||
/Config file not found/,
|
/Config file not found/,
|
||||||
);
|
);
|
||||||
expect(existsSync(configPath)).toBe(false);
|
expect(existsSync(configPath)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects --catalog and --config together", () => {
|
it("rejects --catalog and --config together", async () => {
|
||||||
const catalogPath = join(tempDir, "catalog.json");
|
const catalogPath = join(tempDir, "catalog.json");
|
||||||
const configPath = join(tempDir, "config.json");
|
const configPath = join(tempDir, "config.json");
|
||||||
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
|
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
|
||||||
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
|
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
|
||||||
expect(() => loadTuiServers({ catalogPath, configPath })).toThrow(
|
await expect(loadTuiServers({ catalogPath, configPath })).rejects.toThrow(
|
||||||
/mutually exclusive/,
|
/mutually exclusive/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects --catalog combined with an ad-hoc target", () => {
|
it("rejects --catalog combined with an ad-hoc target", async () => {
|
||||||
const catalogPath = join(tempDir, "catalog.json");
|
const catalogPath = join(tempDir, "catalog.json");
|
||||||
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
|
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
|
||||||
expect(() =>
|
await expect(
|
||||||
loadTuiServers({ catalogPath, target: ["my-server"] }),
|
loadTuiServers({ catalogPath, target: ["my-server"] }),
|
||||||
).toThrow(/--catalog cannot be combined/);
|
).rejects.toThrow(/--catalog cannot be combined/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds a single ad-hoc server from a positional target", () => {
|
it("builds a single ad-hoc server from a positional target", async () => {
|
||||||
const servers = loadTuiServers({ target: ["my-server", "--flag"] });
|
const servers = await loadTuiServers({ target: ["my-server", "--flag"] });
|
||||||
expect(Object.keys(servers)).toEqual(["default"]);
|
expect(Object.keys(servers)).toEqual(["default"]);
|
||||||
expect(servers.default?.config).toMatchObject({
|
expect(servers.default?.config).toMatchObject({
|
||||||
type: "stdio",
|
type: "stdio",
|
||||||
|
|
@ -83,7 +83,7 @@ describe("loadTuiServers", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges --header into per-server settings for catalog servers", () => {
|
it("merges --header into per-server settings for catalog servers", async () => {
|
||||||
const catalogPath = join(tempDir, "catalog.json");
|
const catalogPath = join(tempDir, "catalog.json");
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
catalogPath,
|
catalogPath,
|
||||||
|
|
@ -91,7 +91,7 @@ describe("loadTuiServers", () => {
|
||||||
mcpServers: { web: { type: "streamable-http", url: "http://x/mcp" } },
|
mcpServers: { web: { type: "streamable-http", url: "http://x/mcp" } },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const servers = loadTuiServers({
|
const servers = await loadTuiServers({
|
||||||
catalogPath,
|
catalogPath,
|
||||||
headers: { Authorization: "Bearer t" },
|
headers: { Authorization: "Bearer t" },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
220
clients/tui/package-lock.json
generated
220
clients/tui/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@napi-rs/keyring": "^1.3.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"atomically": "^2.1.1",
|
"atomically": "^2.1.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
|
|
@ -1159,6 +1160,225 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/keyring": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/keyring-darwin-arm64": "1.3.0",
|
||||||
|
"@napi-rs/keyring-darwin-x64": "1.3.0",
|
||||||
|
"@napi-rs/keyring-freebsd-x64": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-arm64-gnu": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-arm64-musl": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-riscv64-gnu": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-x64-gnu": "1.3.0",
|
||||||
|
"@napi-rs/keyring-linux-x64-musl": "1.3.0",
|
||||||
|
"@napi-rs/keyring-win32-arm64-msvc": "1.3.0",
|
||||||
|
"@napi-rs/keyring-win32-ia32-msvc": "1.3.0",
|
||||||
|
"@napi-rs/keyring-win32-x64-msvc": "1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-darwin-arm64": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-darwin-x64": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-freebsd-x64": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-arm64-gnu": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-arm64-musl": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-riscv64-gnu": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-x64-gnu": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-linux-x64-musl": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-win32-arm64-msvc": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-win32-ia32-msvc": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/keyring-win32-x64-msvc": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@napi-rs/keyring": "^1.3.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"atomically": "^2.1.1",
|
"atomically": "^2.1.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { dirname, join } from "path";
|
||||||
import type {
|
import type {
|
||||||
MessageEntry,
|
MessageEntry,
|
||||||
FetchRequestEntry,
|
FetchRequestEntry,
|
||||||
MCPServerConfig,
|
|
||||||
InspectorClientOptions,
|
InspectorClientOptions,
|
||||||
InspectorClientEnvironment,
|
InspectorClientEnvironment,
|
||||||
} from "@inspector/core/mcp/index.js";
|
} from "@inspector/core/mcp/index.js";
|
||||||
|
|
@ -45,7 +44,15 @@ import { useStderrLog } from "@inspector/core/react/useStderrLog.js";
|
||||||
import {
|
import {
|
||||||
CallbackNavigation,
|
CallbackNavigation,
|
||||||
MutableRedirectUrlProvider,
|
MutableRedirectUrlProvider,
|
||||||
|
isUnauthorizedError,
|
||||||
} from "@inspector/core/auth/index.js";
|
} from "@inspector/core/auth/index.js";
|
||||||
|
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
||||||
|
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||||
|
import {
|
||||||
|
buildRunnerClientAuthOptions,
|
||||||
|
isOAuthCapableServerConfig,
|
||||||
|
} from "@inspector/core/client/runner.js";
|
||||||
|
import { formatRunnerOAuthRedirectUrl } from "@inspector/core/auth/node/runner-oauth-callback.js";
|
||||||
import {
|
import {
|
||||||
createOAuthCallbackServer,
|
createOAuthCallbackServer,
|
||||||
type OAuthCallbackServer,
|
type OAuthCallbackServer,
|
||||||
|
|
@ -114,6 +121,7 @@ type FocusArea =
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
mcpServers: Record<string, TuiServer>;
|
mcpServers: Record<string, TuiServer>;
|
||||||
|
clientConfig: ClientConfig;
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
clientMetadataUrl?: string;
|
clientMetadataUrl?: string;
|
||||||
|
|
@ -124,15 +132,9 @@ interface AppProps {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP transports (SSE, streamable-http) can use OAuth. No config gate. */
|
|
||||||
function isOAuthCapableServer(config: MCPServerConfig | null): boolean {
|
|
||||||
if (!config) return false;
|
|
||||||
const c = config as MCPServerConfig & { oauth?: unknown };
|
|
||||||
return c.type === "sse" || c.type === "streamable-http";
|
|
||||||
}
|
|
||||||
|
|
||||||
function App({
|
function App({
|
||||||
mcpServers,
|
mcpServers,
|
||||||
|
clientConfig,
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
clientMetadataUrl,
|
clientMetadataUrl,
|
||||||
|
|
@ -168,13 +170,13 @@ function App({
|
||||||
logging?: number;
|
logging?: number;
|
||||||
}>({});
|
}>({});
|
||||||
const [oauthStatus, setOauthStatus] = useState<
|
const [oauthStatus, setOauthStatus] = useState<
|
||||||
"idle" | "authenticating" | "success" | "error"
|
"idle" | "authenticating" | "error"
|
||||||
>("idle");
|
>("idle");
|
||||||
const [oauthMessage, setOauthMessage] = useState<string | null>(null);
|
const [oauthMessage, setOauthMessage] = useState<string | null>(null);
|
||||||
|
const [oauthRevision, setOauthRevision] = useState(0);
|
||||||
|
const [connectError, setConnectError] = useState<string | null>(null);
|
||||||
const oauthInProgressRef = useRef(false);
|
const oauthInProgressRef = useRef(false);
|
||||||
const [selectedAuthAction, setSelectedAuthAction] = useState<
|
const callbackServerRef = useRef<OAuthCallbackServer | null>(null);
|
||||||
"guided" | "quick" | "clear"
|
|
||||||
>("guided");
|
|
||||||
|
|
||||||
// Tool test modal state
|
// Tool test modal state
|
||||||
const [toolTestModal, setToolTestModal] = useState<{
|
const [toolTestModal, setToolTestModal] = useState<{
|
||||||
|
|
@ -287,23 +289,11 @@ function App({
|
||||||
.map((m) => [m.key, m.value]),
|
.map((m) => [m.key, m.value]),
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const oauthFromSettings =
|
const clientAuthOptions = buildRunnerClientAuthOptions(
|
||||||
savedSettings &&
|
clientConfig,
|
||||||
(savedSettings.oauthClientId ||
|
savedSettings,
|
||||||
savedSettings.oauthClientSecret ||
|
{ clientId, clientSecret, clientMetadataUrl },
|
||||||
savedSettings.oauthScopes)
|
);
|
||||||
? {
|
|
||||||
...(savedSettings.oauthClientId && {
|
|
||||||
clientId: savedSettings.oauthClientId,
|
|
||||||
}),
|
|
||||||
...(savedSettings.oauthClientSecret && {
|
|
||||||
clientSecret: savedSettings.oauthClientSecret,
|
|
||||||
}),
|
|
||||||
...(savedSettings.oauthScopes && {
|
|
||||||
scope: savedSettings.oauthScopes,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
const opts: InspectorClientOptions = {
|
const opts: InspectorClientOptions = {
|
||||||
environment,
|
environment,
|
||||||
pipeStderr: true,
|
pipeStderr: true,
|
||||||
|
|
@ -316,12 +306,15 @@ function App({
|
||||||
defaultMetadata,
|
defaultMetadata,
|
||||||
}),
|
}),
|
||||||
...(savedSettings && { serverSettings: savedSettings }),
|
...(savedSettings && { serverSettings: savedSettings }),
|
||||||
|
...clientAuthOptions,
|
||||||
};
|
};
|
||||||
if (isOAuthCapableServer(serverConfig)) {
|
if (isOAuthCapableServerConfig(serverConfig)) {
|
||||||
const redirectUrlProvider =
|
const redirectUrlProvider =
|
||||||
redirectUrlProvidersRef.current[serverName] ??
|
redirectUrlProvidersRef.current[serverName] ??
|
||||||
(redirectUrlProvidersRef.current[serverName] =
|
(redirectUrlProvidersRef.current[serverName] =
|
||||||
new MutableRedirectUrlProvider());
|
new MutableRedirectUrlProvider());
|
||||||
|
redirectUrlProvider.redirectUrl =
|
||||||
|
formatRunnerOAuthRedirectUrl(callbackUrlConfig);
|
||||||
environment.oauth = {
|
environment.oauth = {
|
||||||
storage: new NodeOAuthStorage(),
|
storage: new NodeOAuthStorage(),
|
||||||
navigation: new CallbackNavigation(
|
navigation: new CallbackNavigation(
|
||||||
|
|
@ -329,12 +322,6 @@ function App({
|
||||||
),
|
),
|
||||||
redirectUrlProvider,
|
redirectUrlProvider,
|
||||||
};
|
};
|
||||||
opts.oauth = {
|
|
||||||
...(oauthFromSettings ?? {}),
|
|
||||||
...(clientId && { clientId }),
|
|
||||||
...(clientSecret && { clientSecret }),
|
|
||||||
...(clientMetadataUrl && { clientMetadataUrl }),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const client = new InspectorClient(serverConfig, opts);
|
const client = new InspectorClient(serverConfig, opts);
|
||||||
newClients[serverName] = client;
|
newClients[serverName] = client;
|
||||||
|
|
@ -373,11 +360,19 @@ function App({
|
||||||
setStderrLogStates((prev) => ({ ...prev, ...newStderrLogStates }));
|
setStderrLogStates((prev) => ({ ...prev, ...newStderrLogStates }));
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [clientId, clientSecret, clientMetadataUrl]);
|
}, [
|
||||||
|
clientConfig,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
clientMetadataUrl,
|
||||||
|
callbackUrlConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
// Cleanup: destroy managers and disconnect all clients on unmount
|
// Cleanup: destroy managers and disconnect all clients on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
void callbackServerRef.current?.stop();
|
||||||
|
callbackServerRef.current = null;
|
||||||
Object.values(managedToolsStates).forEach((manager) => {
|
Object.values(managedToolsStates).forEach((manager) => {
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
@ -435,7 +430,7 @@ function App({
|
||||||
if (
|
if (
|
||||||
activeTab === "auth" &&
|
activeTab === "auth" &&
|
||||||
selectedServerConfig &&
|
selectedServerConfig &&
|
||||||
!isOAuthCapableServer(selectedServerConfig)
|
!isOAuthCapableServerConfig(selectedServerConfig)
|
||||||
) {
|
) {
|
||||||
setActiveTab("info");
|
setActiveTab("info");
|
||||||
}
|
}
|
||||||
|
|
@ -455,6 +450,7 @@ function App({
|
||||||
instructions: inspectorInstructions,
|
instructions: inspectorInstructions,
|
||||||
connect: connectInspector,
|
connect: connectInspector,
|
||||||
disconnect: disconnectInspector,
|
disconnect: disconnectInspector,
|
||||||
|
lastError: inspectorLastError,
|
||||||
} = useInspectorClient(selectedInspectorClient);
|
} = useInspectorClient(selectedInspectorClient);
|
||||||
|
|
||||||
// Log state from managers (per-server)
|
// Log state from managers (per-server)
|
||||||
|
|
@ -538,46 +534,23 @@ function App({
|
||||||
selectedManagedPromptsState,
|
selectedManagedPromptsState,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Connect handler - InspectorClient now handles fetching server data automatically
|
|
||||||
const handleConnect = useCallback(async () => {
|
|
||||||
if (!selectedServer || !selectedInspectorClient) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connectInspector();
|
|
||||||
// InspectorClient automatically fetches server data (capabilities, tools, resources, resource templates, prompts, etc.)
|
|
||||||
// on connect, so we don't need to do anything here
|
|
||||||
} catch {
|
|
||||||
// Error handling is done by InspectorClient and will be reflected in status
|
|
||||||
}
|
|
||||||
}, [selectedServer, selectedInspectorClient, connectInspector]);
|
|
||||||
|
|
||||||
// Disconnect handler
|
|
||||||
const handleDisconnect = useCallback(async () => {
|
|
||||||
if (!selectedServer) return;
|
|
||||||
await disconnectInspector();
|
|
||||||
// InspectorClient will update status automatically, and data is preserved
|
|
||||||
}, [selectedServer, disconnectInspector]);
|
|
||||||
|
|
||||||
// Shared ref for OAuth callback server; stop before starting new (avoids EADDRINUSE when prior auth failed without redirect)
|
// Shared ref for OAuth callback server; stop before starting new (avoids EADDRINUSE when prior auth failed without redirect)
|
||||||
const callbackServerRef = useRef<OAuthCallbackServer | null>(null);
|
|
||||||
|
|
||||||
// OAuth Quick Auth (quick execution; callback server + open URL)
|
// Connect — on 401, run OAuth then retry (same pattern as web App.tsx).
|
||||||
const handleQuickAuth = useCallback(async () => {
|
const runOAuthAuthentication = useCallback(async () => {
|
||||||
if (
|
if (
|
||||||
!selectedServer ||
|
!selectedServer ||
|
||||||
!selectedInspectorClient ||
|
!selectedInspectorClient ||
|
||||||
!selectedServerConfig ||
|
!selectedServerConfig ||
|
||||||
!isOAuthCapableServer(selectedServerConfig)
|
!isOAuthCapableServerConfig(selectedServerConfig)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (oauthInProgressRef.current) return;
|
if (oauthInProgressRef.current) return;
|
||||||
oauthInProgressRef.current = true;
|
oauthInProgressRef.current = true;
|
||||||
setOauthStatus("authenticating");
|
|
||||||
setOauthMessage(null);
|
|
||||||
getTuiLogger().info(
|
getTuiLogger().info(
|
||||||
{ server: selectedServer },
|
{ server: selectedServer },
|
||||||
"OAuth authentication started (Quick Auth)",
|
"OAuth authentication started",
|
||||||
);
|
);
|
||||||
const existing = callbackServerRef.current;
|
const existing = callbackServerRef.current;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -624,12 +597,7 @@ function App({
|
||||||
if (authUrl !== undefined) {
|
if (authUrl !== undefined) {
|
||||||
await flowDone;
|
await flowDone;
|
||||||
}
|
}
|
||||||
setOauthStatus("success");
|
setOauthRevision((n) => n + 1);
|
||||||
setOauthMessage("OAuth complete. Press C to connect.");
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
setOauthStatus("error");
|
|
||||||
setOauthMessage(msg);
|
|
||||||
} finally {
|
} finally {
|
||||||
oauthInProgressRef.current = false;
|
oauthInProgressRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
@ -640,186 +608,92 @@ function App({
|
||||||
callbackServerBaseOptions,
|
callbackServerBaseOptions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// OAuth Guided Auth - step-by-step
|
const handleConnect = useCallback(async () => {
|
||||||
const handleGuidedStart = useCallback(async () => {
|
if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) {
|
||||||
if (
|
|
||||||
!selectedServer ||
|
|
||||||
!selectedInspectorClient ||
|
|
||||||
!selectedServerConfig ||
|
|
||||||
!isOAuthCapableServer(selectedServerConfig)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (oauthInProgressRef.current) return;
|
|
||||||
oauthInProgressRef.current = true;
|
const finishConnect = async () => {
|
||||||
setOauthStatus("authenticating");
|
await connectInspector();
|
||||||
setOauthMessage(null);
|
setConnectError(null);
|
||||||
getTuiLogger().info(
|
|
||||||
{ server: selectedServer },
|
|
||||||
"OAuth authentication started (Guided Auth)",
|
|
||||||
);
|
|
||||||
// Stop any previous callback server (e.g. from failed auth where AS never redirected)
|
|
||||||
const existing = callbackServerRef.current;
|
|
||||||
if (existing) {
|
|
||||||
await existing.stop();
|
|
||||||
callbackServerRef.current = null;
|
|
||||||
}
|
|
||||||
const callbackServer = createOAuthCallbackServer();
|
|
||||||
callbackServerRef.current = callbackServer;
|
|
||||||
try {
|
|
||||||
const { redirectUrl } = await callbackServer.start({
|
|
||||||
...callbackServerBaseOptions,
|
|
||||||
onCallback: async (params) => {
|
|
||||||
try {
|
|
||||||
await selectedInspectorClient!.completeOAuthFlow(params.code);
|
|
||||||
setOauthStatus("success");
|
|
||||||
setOauthMessage("OAuth complete. Press C to connect.");
|
|
||||||
} catch (err) {
|
|
||||||
setOauthStatus("error");
|
|
||||||
setOauthMessage(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
callbackServerRef.current = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (params) => {
|
|
||||||
setOauthStatus("error");
|
|
||||||
setOauthMessage(
|
|
||||||
params.error_description ?? params.error ?? "OAuth error",
|
|
||||||
);
|
|
||||||
void callbackServer.stop();
|
|
||||||
callbackServerRef.current = null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const redirectUrlProvider =
|
|
||||||
redirectUrlProvidersRef.current[selectedServer];
|
|
||||||
if (redirectUrlProvider) {
|
|
||||||
redirectUrlProvider.redirectUrl = redirectUrl;
|
|
||||||
}
|
|
||||||
await selectedInspectorClient.beginGuidedAuth();
|
|
||||||
setOauthStatus("idle");
|
setOauthStatus("idle");
|
||||||
} catch (err) {
|
setOauthMessage(null);
|
||||||
setOauthStatus("error");
|
|
||||||
setOauthMessage(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
oauthInProgressRef.current = false;
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
selectedServer,
|
|
||||||
selectedInspectorClient,
|
|
||||||
selectedServerConfig,
|
|
||||||
callbackServerBaseOptions,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleGuidedAdvance = useCallback(async () => {
|
|
||||||
if (!selectedInspectorClient) return;
|
|
||||||
if (oauthInProgressRef.current) return;
|
|
||||||
oauthInProgressRef.current = true;
|
|
||||||
setOauthStatus("authenticating");
|
|
||||||
setOauthMessage(null);
|
|
||||||
getTuiLogger().info(
|
|
||||||
"OAuth authentication started (Guided Auth advance step)",
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await selectedInspectorClient.proceedOAuthStep();
|
|
||||||
const state = selectedInspectorClient.getOAuthFlowState();
|
|
||||||
if (state?.oauthStep === "authorization_code" && state.authorizationUrl) {
|
|
||||||
await openUrl(state.authorizationUrl);
|
|
||||||
}
|
|
||||||
setOauthStatus("idle");
|
|
||||||
} catch (err) {
|
|
||||||
setOauthStatus("error");
|
|
||||||
setOauthMessage(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
oauthInProgressRef.current = false;
|
|
||||||
}
|
|
||||||
}, [selectedInspectorClient]);
|
|
||||||
|
|
||||||
const handleRunGuidedToCompletion = useCallback(async () => {
|
|
||||||
if (
|
|
||||||
!selectedServer ||
|
|
||||||
!selectedInspectorClient ||
|
|
||||||
!selectedServerConfig ||
|
|
||||||
!isOAuthCapableServer(selectedServerConfig)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (oauthInProgressRef.current) return;
|
|
||||||
oauthInProgressRef.current = true;
|
|
||||||
setOauthStatus("authenticating");
|
|
||||||
setOauthMessage(null);
|
|
||||||
getTuiLogger().info(
|
|
||||||
{ server: selectedServer },
|
|
||||||
"OAuth authentication started (Run Guided Auth to completion)",
|
|
||||||
);
|
|
||||||
|
|
||||||
const ensureCallbackServer = async () => {
|
|
||||||
if (callbackServerRef.current) return;
|
|
||||||
const callbackServer = createOAuthCallbackServer();
|
|
||||||
callbackServerRef.current = callbackServer;
|
|
||||||
const { redirectUrl } = await callbackServer.start({
|
|
||||||
...callbackServerBaseOptions,
|
|
||||||
onCallback: async (params) => {
|
|
||||||
try {
|
|
||||||
await selectedInspectorClient!.completeOAuthFlow(params.code);
|
|
||||||
setOauthStatus("success");
|
|
||||||
setOauthMessage("OAuth complete. Press C to connect.");
|
|
||||||
} catch (err) {
|
|
||||||
setOauthStatus("error");
|
|
||||||
setOauthMessage(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
callbackServerRef.current = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (params) => {
|
|
||||||
setOauthStatus("error");
|
|
||||||
setOauthMessage(
|
|
||||||
params.error_description ?? params.error ?? "OAuth error",
|
|
||||||
);
|
|
||||||
void callbackServer.stop();
|
|
||||||
callbackServerRef.current = null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const redirectUrlProvider =
|
|
||||||
redirectUrlProvidersRef.current[selectedServer];
|
|
||||||
if (redirectUrlProvider) {
|
|
||||||
redirectUrlProvider.redirectUrl = redirectUrl;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureCallbackServer();
|
await finishConnect();
|
||||||
const authUrl = await selectedInspectorClient.runGuidedAuth();
|
|
||||||
if (authUrl) {
|
|
||||||
await openUrl(authUrl);
|
|
||||||
}
|
|
||||||
setOauthStatus("idle");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setOauthStatus("error");
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
setOauthMessage(err instanceof Error ? err.message : String(err));
|
setConnectError(msg);
|
||||||
} finally {
|
|
||||||
oauthInProgressRef.current = false;
|
if (isEmaClientNotConfiguredError(err)) {
|
||||||
|
setOauthStatus("error");
|
||||||
|
setOauthMessage(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isOAuthCapableServerConfig(selectedServerConfig) &&
|
||||||
|
isUnauthorizedError(err)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
setOauthStatus("authenticating");
|
||||||
|
setOauthMessage(null);
|
||||||
|
// Tear down the failed handshake transport so the post-OAuth connect
|
||||||
|
// creates a fresh transport with tokens from storage (same as web,
|
||||||
|
// which reloads the client after redirect).
|
||||||
|
await disconnectInspector();
|
||||||
|
await runOAuthAuthentication();
|
||||||
|
await finishConnect();
|
||||||
|
} catch (authErr) {
|
||||||
|
const authMsg =
|
||||||
|
authErr instanceof Error ? authErr.message : String(authErr);
|
||||||
|
setConnectError(authMsg);
|
||||||
|
if (isEmaClientNotConfiguredError(authErr)) {
|
||||||
|
setOauthStatus("error");
|
||||||
|
setOauthMessage(authErr.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOauthStatus("error");
|
||||||
|
setOauthMessage(authMsg);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
selectedServer,
|
selectedServer,
|
||||||
selectedInspectorClient,
|
selectedInspectorClient,
|
||||||
selectedServerConfig,
|
selectedServerConfig,
|
||||||
callbackServerBaseOptions,
|
connectInspector,
|
||||||
|
disconnectInspector,
|
||||||
|
runOAuthAuthentication,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleClearOAuth = useCallback(() => {
|
// Disconnect handler
|
||||||
if (selectedInspectorClient) {
|
const handleDisconnect = useCallback(async () => {
|
||||||
selectedInspectorClient.clearOAuthTokens();
|
if (!selectedServer) return;
|
||||||
setOauthStatus("idle");
|
await disconnectInspector();
|
||||||
setOauthMessage(null);
|
// InspectorClient will update status automatically, and data is preserved
|
||||||
|
}, [selectedServer, disconnectInspector]);
|
||||||
|
|
||||||
|
const handleClearOAuth = useCallback(async () => {
|
||||||
|
if (!selectedInspectorClient) return;
|
||||||
|
selectedInspectorClient.clearOAuthTokens();
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage(null);
|
||||||
|
setConnectError(null);
|
||||||
|
if (inspectorStatus === "connected" || inspectorStatus === "connecting") {
|
||||||
|
await disconnectInspector();
|
||||||
}
|
}
|
||||||
}, [selectedInspectorClient]);
|
setOauthRevision((n) => n + 1);
|
||||||
|
}, [selectedInspectorClient, inspectorStatus, disconnectInspector]);
|
||||||
|
|
||||||
// Build current server state from InspectorClient data (tools from ManagedToolsState)
|
// Build current server state from InspectorClient data (tools from ManagedToolsState)
|
||||||
const currentServerState = useMemo(() => {
|
const currentServerState = useMemo(() => {
|
||||||
if (!selectedServer) return null;
|
if (!selectedServer) return null;
|
||||||
return {
|
return {
|
||||||
status: inspectorStatus,
|
status: inspectorStatus,
|
||||||
error: null, // InspectorClient doesn't track error in state, only emits error events
|
error: connectError ?? inspectorLastError ?? null,
|
||||||
capabilities: inspectorCapabilities,
|
capabilities: inspectorCapabilities,
|
||||||
serverInfo: inspectorServerInfo,
|
serverInfo: inspectorServerInfo,
|
||||||
instructions: inspectorInstructions,
|
instructions: inspectorInstructions,
|
||||||
|
|
@ -832,6 +706,8 @@ function App({
|
||||||
}, [
|
}, [
|
||||||
selectedServer,
|
selectedServer,
|
||||||
inspectorStatus,
|
inspectorStatus,
|
||||||
|
connectError,
|
||||||
|
inspectorLastError,
|
||||||
inspectorCapabilities,
|
inspectorCapabilities,
|
||||||
inspectorServerInfo,
|
inspectorServerInfo,
|
||||||
inspectorInstructions,
|
inspectorInstructions,
|
||||||
|
|
@ -842,22 +718,6 @@ function App({
|
||||||
inspectorStderrLogs,
|
inspectorStderrLogs,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 401 on connect → prompt to authenticate (HTTP servers). Hide during/after auth.
|
|
||||||
const show401AuthHint = useMemo(() => {
|
|
||||||
if (inspectorStatus !== "error") return false;
|
|
||||||
if (oauthStatus === "authenticating" || oauthStatus === "success")
|
|
||||||
return false;
|
|
||||||
if (!selectedServerConfig || !isOAuthCapableServer(selectedServerConfig))
|
|
||||||
return false;
|
|
||||||
return inspectorFetchRequests.some((r) => r.responseStatus === 401);
|
|
||||||
}, [
|
|
||||||
inspectorStatus,
|
|
||||||
oauthStatus,
|
|
||||||
selectedServerConfig,
|
|
||||||
inspectorFetchRequests,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Helper functions to render details modal content
|
|
||||||
const renderResourceDetails = (
|
const renderResourceDetails = (
|
||||||
resource:
|
resource:
|
||||||
| Resource
|
| Resource
|
||||||
|
|
@ -1220,29 +1080,11 @@ function App({
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// G/Q/S: switch to Auth tab (if not already) and select Guided/Quick/Clear
|
|
||||||
const showAuthTabForAccel =
|
|
||||||
!!selectedServer &&
|
|
||||||
!!selectedServerConfig &&
|
|
||||||
isOAuthCapableServer(selectedServerConfig);
|
|
||||||
const lower = input.toLowerCase();
|
|
||||||
if (
|
|
||||||
showAuthTabForAccel &&
|
|
||||||
(lower === "g" || lower === "q" || lower === "s")
|
|
||||||
) {
|
|
||||||
setActiveTab("auth");
|
|
||||||
setFocus("tabContentList");
|
|
||||||
setSelectedAuthAction(
|
|
||||||
lower === "g" ? "guided" : lower === "q" ? "quick" : "clear",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab switching with accelerator keys (first character of tab name)
|
// Tab switching with accelerator keys (first character of tab name)
|
||||||
const showAuthTab =
|
const showAuthTab =
|
||||||
!!selectedServer &&
|
!!selectedServer &&
|
||||||
!!selectedServerConfig &&
|
!!selectedServerConfig &&
|
||||||
isOAuthCapableServer(selectedServerConfig);
|
isOAuthCapableServerConfig(selectedServerConfig);
|
||||||
const showLoggingTab =
|
const showLoggingTab =
|
||||||
!!selectedServer &&
|
!!selectedServer &&
|
||||||
inspectorClients[selectedServer]?.getServerType() === "stdio";
|
inspectorClients[selectedServer]?.getServerType() === "stdio";
|
||||||
|
|
@ -1265,8 +1107,16 @@ function App({
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
if (tabAccelerators[input.toLowerCase()]) {
|
if (tabAccelerators[input.toLowerCase()]) {
|
||||||
setActiveTab(tabAccelerators[input.toLowerCase()]);
|
const nextTab = tabAccelerators[input.toLowerCase()]!;
|
||||||
setFocus("tabs");
|
setActiveTab(nextTab);
|
||||||
|
setFocus(nextTab === "auth" ? "tabContentList" : "tabs");
|
||||||
|
} else if (
|
||||||
|
activeTab === "auth" &&
|
||||||
|
showAuthTab &&
|
||||||
|
input.toLowerCase() === "s"
|
||||||
|
) {
|
||||||
|
void handleClearOAuth();
|
||||||
|
setFocus("tabContentList");
|
||||||
} else if (key.tab && !key.shift) {
|
} else if (key.tab && !key.shift) {
|
||||||
// Flat focus order: servers -> tabs -> list -> details -> wrap to servers
|
// Flat focus order: servers -> tabs -> list -> details -> wrap to servers
|
||||||
const focusOrder: FocusArea[] =
|
const focusOrder: FocusArea[] =
|
||||||
|
|
@ -1322,7 +1172,7 @@ function App({
|
||||||
const showAuthTab =
|
const showAuthTab =
|
||||||
!!selectedServer &&
|
!!selectedServer &&
|
||||||
!!selectedServerConfig &&
|
!!selectedServerConfig &&
|
||||||
isOAuthCapableServer(selectedServerConfig);
|
isOAuthCapableServerConfig(selectedServerConfig);
|
||||||
const showLoggingTab =
|
const showLoggingTab =
|
||||||
!!selectedServer &&
|
!!selectedServer &&
|
||||||
inspectorClients[selectedServer]?.getServerType() === "stdio";
|
inspectorClients[selectedServer]?.getServerType() === "stdio";
|
||||||
|
|
@ -1358,7 +1208,6 @@ function App({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accelerator keys for connect/disconnect (work from anywhere)
|
// Accelerator keys for connect/disconnect (work from anywhere)
|
||||||
// 'a' switches to Auth tab; use the Auth tab for Quick/Guided auth
|
|
||||||
if (selectedServer) {
|
if (selectedServer) {
|
||||||
if (
|
if (
|
||||||
input.toLowerCase() === "c" &&
|
input.toLowerCase() === "c" &&
|
||||||
|
|
@ -1543,24 +1392,14 @@ function App({
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{show401AuthHint && (
|
{oauthStatus === "authenticating" && (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color="yellow">
|
<Text dimColor>OAuth: authenticating…</Text>
|
||||||
401 Unauthorized. Press <Text bold>A</Text> to authenticate.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{oauthStatus !== "idle" && (
|
{oauthStatus === "error" && oauthMessage && (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
{oauthStatus === "authenticating" && (
|
<Text color="red">OAuth: {oauthMessage}</Text>
|
||||||
<Text dimColor>OAuth: authenticating…</Text>
|
|
||||||
)}
|
|
||||||
{oauthStatus === "success" && oauthMessage && (
|
|
||||||
<Text color="green">{oauthMessage}</Text>
|
|
||||||
)}
|
|
||||||
{oauthStatus === "error" && oauthMessage && (
|
|
||||||
<Text color="red">OAuth: {oauthMessage}</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -1577,7 +1416,7 @@ function App({
|
||||||
!!(
|
!!(
|
||||||
selectedServer &&
|
selectedServer &&
|
||||||
selectedServerConfig &&
|
selectedServerConfig &&
|
||||||
isOAuthCapableServer(selectedServerConfig)
|
isOAuthCapableServerConfig(selectedServerConfig)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
showLogging={
|
showLogging={
|
||||||
|
|
@ -1623,26 +1462,23 @@ function App({
|
||||||
{activeTab === "auth" &&
|
{activeTab === "auth" &&
|
||||||
selectedServer &&
|
selectedServer &&
|
||||||
selectedServerConfig &&
|
selectedServerConfig &&
|
||||||
isOAuthCapableServer(selectedServerConfig) ? (
|
isOAuthCapableServerConfig(selectedServerConfig) ? (
|
||||||
<AuthTab
|
<AuthTab
|
||||||
serverName={selectedServer}
|
serverName={selectedServer}
|
||||||
serverConfig={selectedServerConfig}
|
serverConfig={selectedServerConfig}
|
||||||
inspectorClient={selectedInspectorClient}
|
inspectorClient={selectedInspectorClient}
|
||||||
oauthStatus={oauthStatus}
|
oauthStatus={oauthStatus}
|
||||||
oauthMessage={oauthMessage}
|
oauthMessage={oauthMessage}
|
||||||
|
oauthRevision={oauthRevision}
|
||||||
width={contentWidth}
|
width={contentWidth}
|
||||||
height={contentHeight}
|
height={contentHeight}
|
||||||
focused={
|
focused={
|
||||||
focus === "tabContentList" || focus === "tabContentDetails"
|
focus === "tabContentList" || focus === "tabContentDetails"
|
||||||
}
|
}
|
||||||
selectedAction={selectedAuthAction}
|
onClearOAuth={() => {
|
||||||
onSelectedActionChange={setSelectedAuthAction}
|
void handleClearOAuth();
|
||||||
onQuickAuth={handleQuickAuth}
|
}}
|
||||||
onGuidedStart={handleGuidedStart}
|
connectionStatus={inspectorStatus}
|
||||||
onGuidedAdvance={handleGuidedAdvance}
|
|
||||||
onRunGuidedToCompletion={handleRunGuidedToCompletion}
|
|
||||||
onClearOAuth={handleClearOAuth}
|
|
||||||
isOAuthCapable={true}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab === "resources" &&
|
{activeTab === "resources" &&
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,41 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Box, Text, useInput, type Key } from "ink";
|
import { Box, Text, useInput, type Key } from "ink";
|
||||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||||
import { SelectableItem } from "./SelectableItem.js";
|
import { SelectableItem } from "./SelectableItem.js";
|
||||||
import type {
|
import type {
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
InspectorClient,
|
InspectorClient,
|
||||||
|
ConnectionStatus,
|
||||||
} from "@inspector/core/mcp/index.js";
|
} from "@inspector/core/mcp/index.js";
|
||||||
import type { OAuthFlowState, OAuthStep } from "@inspector/core/auth/index.js";
|
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
|
||||||
|
import {
|
||||||
const STEP_LABELS: Record<OAuthStep, string> = {
|
formatAuthProtocol,
|
||||||
metadata_discovery: "Metadata Discovery",
|
formatClientRegistrationKind,
|
||||||
client_registration: "Client Registration",
|
formatIdpSession,
|
||||||
authorization_redirect: "Preparing Authorization",
|
formatScopes,
|
||||||
authorization_code: "Request Authorization Code",
|
} from "../utils/oauthDisplay.js";
|
||||||
token_request: "Token Request",
|
|
||||||
complete: "Authentication Complete",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STEP_ORDER: OAuthStep[] = [
|
|
||||||
"metadata_discovery",
|
|
||||||
"client_registration",
|
|
||||||
"authorization_redirect",
|
|
||||||
"authorization_code",
|
|
||||||
"token_request",
|
|
||||||
"complete",
|
|
||||||
];
|
|
||||||
|
|
||||||
function stepIndex(step: OAuthStep): number {
|
|
||||||
const i = STEP_ORDER.indexOf(step);
|
|
||||||
return i >= 0 ? i : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthTabProps {
|
interface AuthTabProps {
|
||||||
serverName: string | null;
|
serverName: string | null;
|
||||||
serverConfig: MCPServerConfig | null;
|
serverConfig: MCPServerConfig | null;
|
||||||
inspectorClient: InspectorClient | null;
|
inspectorClient: InspectorClient | null;
|
||||||
oauthStatus: "idle" | "authenticating" | "success" | "error";
|
oauthStatus: "idle" | "authenticating" | "error";
|
||||||
oauthMessage: string | null;
|
oauthMessage: string | null;
|
||||||
|
oauthRevision: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
selectedAction: "guided" | "quick" | "clear";
|
|
||||||
onSelectedActionChange: (action: "guided" | "quick" | "clear") => void;
|
|
||||||
onQuickAuth: () => Promise<void>;
|
|
||||||
onGuidedStart: () => Promise<void>;
|
|
||||||
onGuidedAdvance: () => Promise<void>;
|
|
||||||
onRunGuidedToCompletion: () => Promise<void>;
|
|
||||||
onClearOAuth: () => void;
|
onClearOAuth: () => void;
|
||||||
isOAuthCapable: boolean;
|
connectionStatus: ConnectionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OAuthDetailRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" gap={1}>
|
||||||
|
<Text dimColor>{label}:</Text>
|
||||||
|
<Text>{value}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthTab({
|
export function AuthTab({
|
||||||
|
|
@ -55,115 +43,57 @@ export function AuthTab({
|
||||||
inspectorClient,
|
inspectorClient,
|
||||||
oauthStatus,
|
oauthStatus,
|
||||||
oauthMessage,
|
oauthMessage,
|
||||||
|
oauthRevision,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
focused = false,
|
focused = false,
|
||||||
selectedAction,
|
|
||||||
onSelectedActionChange,
|
|
||||||
onQuickAuth,
|
|
||||||
onGuidedStart,
|
|
||||||
onGuidedAdvance,
|
|
||||||
onRunGuidedToCompletion,
|
|
||||||
onClearOAuth,
|
onClearOAuth,
|
||||||
isOAuthCapable,
|
connectionStatus,
|
||||||
}: AuthTabProps) {
|
}: AuthTabProps) {
|
||||||
|
const isLiveConnection =
|
||||||
|
connectionStatus === "connected" || connectionStatus === "connecting";
|
||||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||||
const [oauthFlowState, setOauthFlowState] = useState<
|
const [oauthState, setOauthState] = useState<
|
||||||
OAuthFlowState | undefined
|
OAuthConnectionState | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const [guidedStarted, setGuidedStarted] = useState(false);
|
|
||||||
const [clearedConfirmation, setClearedConfirmation] = useState(false);
|
const [clearedConfirmation, setClearedConfirmation] = useState(false);
|
||||||
|
const [lastClearDisconnected, setLastClearDisconnected] = useState(false);
|
||||||
|
|
||||||
// Sync oauthFlowState from InspectorClient
|
const refreshOAuthState = useCallback(async () => {
|
||||||
useEffect(() => {
|
|
||||||
if (!inspectorClient) {
|
if (!inspectorClient) {
|
||||||
setOauthFlowState(undefined);
|
setOauthState(undefined);
|
||||||
setGuidedStarted(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const state = await inspectorClient.getOAuthState();
|
||||||
const update = () => setOauthFlowState(inspectorClient.getOAuthFlowState());
|
setOauthState(state);
|
||||||
update();
|
|
||||||
|
|
||||||
const onStepChange = () => update();
|
|
||||||
inspectorClient.addEventListener("oauthStepChange", onStepChange);
|
|
||||||
inspectorClient.addEventListener("oauthComplete", onStepChange);
|
|
||||||
return () => {
|
|
||||||
inspectorClient.removeEventListener("oauthStepChange", onStepChange);
|
|
||||||
inspectorClient.removeEventListener("oauthComplete", onStepChange);
|
|
||||||
};
|
|
||||||
}, [inspectorClient]);
|
}, [inspectorClient]);
|
||||||
|
|
||||||
// Reset guided state when switching servers
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGuidedStarted(false);
|
void refreshOAuthState();
|
||||||
}, [serverName]);
|
}, [refreshOAuthState, oauthRevision]);
|
||||||
|
|
||||||
// Clear confirmation when switching away from Clear menu item
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedAction !== "clear") {
|
setClearedConfirmation(false);
|
||||||
setClearedConfirmation(false);
|
setLastClearDisconnected(false);
|
||||||
}
|
}, [oauthRevision]);
|
||||||
}, [selectedAction]);
|
|
||||||
|
|
||||||
const guidedFlowStarted = !!oauthFlowState?.oauthStep;
|
useEffect(() => {
|
||||||
const currentStep = oauthFlowState?.oauthStep ?? "metadata_discovery";
|
if (!inspectorClient) return;
|
||||||
const needsAuthCode =
|
|
||||||
currentStep === "authorization_code" && oauthFlowState?.authorizationUrl;
|
|
||||||
const isComplete = currentStep === "complete";
|
|
||||||
|
|
||||||
const handleContinue = useCallback(async () => {
|
const update = () => {
|
||||||
if (!guidedStarted) {
|
void refreshOAuthState();
|
||||||
await onGuidedStart();
|
};
|
||||||
setGuidedStarted(true);
|
inspectorClient.addEventListener("oauthComplete", update);
|
||||||
} else if (!needsAuthCode && !isComplete) {
|
return () => {
|
||||||
await onGuidedAdvance();
|
inspectorClient.removeEventListener("oauthComplete", update);
|
||||||
}
|
};
|
||||||
}, [
|
}, [inspectorClient, refreshOAuthState]);
|
||||||
guidedStarted,
|
|
||||||
needsAuthCode,
|
|
||||||
isComplete,
|
|
||||||
onGuidedStart,
|
|
||||||
onGuidedAdvance,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Keyboard: G/Q/S select menu item (handled by App when not focused),
|
|
||||||
// left/right select, Enter run, up/down scroll
|
|
||||||
useInput(
|
useInput(
|
||||||
(input: string, key: Key) => {
|
(input: string, key: Key) => {
|
||||||
if (!focused || !isOAuthCapable) return;
|
if (!focused) return;
|
||||||
|
|
||||||
const lower = input.toLowerCase();
|
if (key.upArrow && scrollViewRef.current) {
|
||||||
if (lower === "g") {
|
|
||||||
onSelectedActionChange("guided");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lower === "q") {
|
|
||||||
onSelectedActionChange("quick");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lower === "s") {
|
|
||||||
onSelectedActionChange("clear");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.leftArrow) {
|
|
||||||
onSelectedActionChange(
|
|
||||||
selectedAction === "guided"
|
|
||||||
? "clear"
|
|
||||||
: selectedAction === "quick"
|
|
||||||
? "guided"
|
|
||||||
: "quick",
|
|
||||||
);
|
|
||||||
} else if (key.rightArrow) {
|
|
||||||
onSelectedActionChange(
|
|
||||||
selectedAction === "guided"
|
|
||||||
? "quick"
|
|
||||||
: selectedAction === "quick"
|
|
||||||
? "clear"
|
|
||||||
: "guided",
|
|
||||||
);
|
|
||||||
} else if (key.upArrow && scrollViewRef.current) {
|
|
||||||
scrollViewRef.current.scrollBy(-1);
|
scrollViewRef.current.scrollBy(-1);
|
||||||
} else if (key.downArrow && scrollViewRef.current) {
|
} else if (key.downArrow && scrollViewRef.current) {
|
||||||
scrollViewRef.current.scrollBy(1);
|
scrollViewRef.current.scrollBy(1);
|
||||||
|
|
@ -173,210 +103,123 @@ export function AuthTab({
|
||||||
} else if (key.pageDown && scrollViewRef.current) {
|
} else if (key.pageDown && scrollViewRef.current) {
|
||||||
const h = scrollViewRef.current.getViewportHeight() || 1;
|
const h = scrollViewRef.current.getViewportHeight() || 1;
|
||||||
scrollViewRef.current.scrollBy(h);
|
scrollViewRef.current.scrollBy(h);
|
||||||
} else if (key.return) {
|
} else if (input.toLowerCase() === "s") {
|
||||||
if (selectedAction === "guided") onRunGuidedToCompletion();
|
setLastClearDisconnected(isLiveConnection);
|
||||||
else if (selectedAction === "quick") onQuickAuth();
|
onClearOAuth();
|
||||||
else if (selectedAction === "clear") {
|
setClearedConfirmation(true);
|
||||||
onClearOAuth();
|
|
||||||
setClearedConfirmation(true);
|
|
||||||
}
|
|
||||||
} else if (input === " " && selectedAction === "guided") {
|
|
||||||
handleContinue();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{ isActive: focused },
|
||||||
isActive: focused,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!serverName || !isOAuthCapable) {
|
if (!serverName) {
|
||||||
return (
|
return (
|
||||||
<Box width={width} height={height} paddingX={1} paddingY={1}>
|
<Box width={width} height={height} paddingX={1} paddingY={1}>
|
||||||
<Text dimColor>
|
<Text dimColor>Select a server to view authentication.</Text>
|
||||||
Select an OAuth-capable server (SSE or Streamable HTTP) to configure
|
|
||||||
authentication.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scopes = oauthState ? formatScopes(oauthState) : undefined;
|
||||||
|
const accessToken = oauthState?.tokens?.access_token;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box width={width} height={height} flexDirection="column" paddingX={1}>
|
<Box width={width} height={height} flexDirection="column" paddingX={1}>
|
||||||
<Box paddingY={1} flexShrink={0}>
|
<Box paddingY={1} flexShrink={0}>
|
||||||
<Text bold backgroundColor={focused ? "yellow" : undefined}>
|
<Text bold backgroundColor={focused ? "yellow" : undefined}>
|
||||||
Authentication
|
OAuth
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
|
||||||
flexGrow={0}
|
<ScrollView ref={scrollViewRef} height={height - 8}>
|
||||||
overflow="hidden"
|
<Box flexDirection="column" gap={0}>
|
||||||
flexDirection="column"
|
{oauthStatus === "authenticating" && (
|
||||||
gap={0}
|
<Text color="yellow">Authenticating…</Text>
|
||||||
paddingY={0}
|
)}
|
||||||
>
|
{oauthStatus === "error" && oauthMessage && (
|
||||||
{/* Action bar and hint - single container for tight spacing */}
|
<Text color="red">{oauthMessage}</Text>
|
||||||
<Box flexShrink={0} flexDirection="column" gap={0} paddingBottom={1}>
|
)}
|
||||||
<Box flexDirection="row" gap={2}>
|
|
||||||
<SelectableItem
|
{oauthState ? (
|
||||||
isSelected={selectedAction === "guided"}
|
<Box flexDirection="column" marginTop={1} gap={0}>
|
||||||
bold={selectedAction === "guided"}
|
<Text bold>OAuth Details</Text>
|
||||||
>
|
<Box marginTop={1} flexDirection="column" paddingLeft={0} gap={0}>
|
||||||
<Text underline>G</Text>uided Auth
|
<OAuthDetailRow
|
||||||
</SelectableItem>
|
label="Protocol"
|
||||||
<SelectableItem
|
value={formatAuthProtocol(oauthState.protocol)}
|
||||||
isSelected={selectedAction === "quick"}
|
/>
|
||||||
bold={selectedAction === "quick"}
|
<OAuthDetailRow
|
||||||
>
|
label="Status"
|
||||||
<Text underline>Q</Text>uick Auth
|
value={
|
||||||
</SelectableItem>
|
oauthState.authorized ? "Authorized" : "Not authorized"
|
||||||
<SelectableItem
|
}
|
||||||
isSelected={selectedAction === "clear"}
|
/>
|
||||||
bold={selectedAction === "clear"}
|
{oauthState.client?.clientId && (
|
||||||
>
|
<OAuthDetailRow
|
||||||
|
label="Client ID"
|
||||||
|
value={oauthState.client.clientId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{oauthState.client?.registrationKind && (
|
||||||
|
<OAuthDetailRow
|
||||||
|
label="Client registration"
|
||||||
|
value={formatClientRegistrationKind(
|
||||||
|
oauthState.client.registrationKind,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{oauthState.protocol === "ema" &&
|
||||||
|
oauthState.ema?.idpSession && (
|
||||||
|
<OAuthDetailRow
|
||||||
|
label="IdP session"
|
||||||
|
value={formatIdpSession(oauthState.ema.idpSession)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{oauthState.authorizationServerMetadata
|
||||||
|
?.authorization_endpoint && (
|
||||||
|
<OAuthDetailRow
|
||||||
|
label="Auth URL"
|
||||||
|
value={
|
||||||
|
oauthState.authorizationServerMetadata
|
||||||
|
.authorization_endpoint
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{scopes && <OAuthDetailRow label="Scopes" value={scopes} />}
|
||||||
|
{accessToken && (
|
||||||
|
<OAuthDetailRow
|
||||||
|
label="Access token"
|
||||||
|
value={`${accessToken.slice(0, 24)}…`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
oauthStatus !== "authenticating" && (
|
||||||
|
<Box marginTop={1} flexDirection="column" gap={0}>
|
||||||
|
<Text dimColor>No OAuth information yet.</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
Connect (C) to authorize when this server requires it.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box marginTop={2} flexDirection="column" gap={0}>
|
||||||
|
<SelectableItem isSelected bold>
|
||||||
Clear OAuth <Text underline>S</Text>tate
|
Clear OAuth <Text underline>S</Text>tate
|
||||||
|
{isLiveConnection && " and disconnect"}
|
||||||
</SelectableItem>
|
</SelectableItem>
|
||||||
</Box>
|
{clearedConfirmation && (
|
||||||
<Box flexDirection="column">
|
<Text color="green">
|
||||||
{selectedAction === "guided" && (
|
{lastClearDisconnected
|
||||||
<>
|
? "OAuth state cleared. Disconnected."
|
||||||
<Text dimColor>
|
: "OAuth state cleared."}
|
||||||
Press [Space] to advance one step through guided auth.
|
</Text>
|
||||||
</Text>
|
|
||||||
<Text dimColor>
|
|
||||||
Press [Enter] to run guided auth to completion.
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedAction === "quick" && (
|
|
||||||
<Text dimColor>Press [Enter] to run quick auth.</Text>
|
|
||||||
)}
|
|
||||||
{selectedAction === "clear" && (
|
|
||||||
<Text dimColor>Press [Enter] to clear OAuth state.</Text>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<ScrollView ref={scrollViewRef} height={height - 10}>
|
</ScrollView>
|
||||||
{selectedAction === "guided" && (
|
|
||||||
<Box key="guided" flexShrink={0} flexDirection="column">
|
|
||||||
<Text bold>Guided OAuth Flow Progress</Text>
|
|
||||||
{STEP_ORDER.map((step) => {
|
|
||||||
const stepIdx = stepIndex(step);
|
|
||||||
const currentIdx = stepIndex(currentStep);
|
|
||||||
const completed =
|
|
||||||
guidedFlowStarted &&
|
|
||||||
(stepIdx < currentIdx ||
|
|
||||||
(step === currentStep && isComplete));
|
|
||||||
const inProgress =
|
|
||||||
guidedFlowStarted && step === currentStep && !isComplete;
|
|
||||||
const details = oauthFlowState
|
|
||||||
? getStepDetails(oauthFlowState, step)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const icon = completed ? "✓" : inProgress ? "→" : "○";
|
|
||||||
const color = completed
|
|
||||||
? "green"
|
|
||||||
: inProgress
|
|
||||||
? "cyan"
|
|
||||||
: "gray";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={step}
|
|
||||||
marginTop={1}
|
|
||||||
flexDirection="column"
|
|
||||||
paddingLeft={2}
|
|
||||||
>
|
|
||||||
<Text color={color}>
|
|
||||||
{icon} {STEP_LABELS[step]}
|
|
||||||
{inProgress && " (in progress)"}
|
|
||||||
</Text>
|
|
||||||
{completed && details && (
|
|
||||||
<Box marginTop={1} paddingLeft={2} flexDirection="column">
|
|
||||||
<Text dimColor>{details}</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{inProgress && details && (
|
|
||||||
<Box marginTop={1} paddingLeft={2} flexDirection="column">
|
|
||||||
<Text dimColor>{details}</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Waiting for auth - URL was opened when we reached this step */}
|
|
||||||
{oauthFlowState &&
|
|
||||||
needsAuthCode &&
|
|
||||||
oauthFlowState?.authorizationUrl && (
|
|
||||||
<Box marginTop={2} flexDirection="column">
|
|
||||||
<Text bold>Authorization URL opened in browser</Text>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text dimColor>
|
|
||||||
{oauthFlowState.authorizationUrl.toString()}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text dimColor>
|
|
||||||
Complete authorization in the browser. You will be
|
|
||||||
redirected and the flow will complete automatically.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedAction === "quick" && (
|
|
||||||
<Box key="quick" flexShrink={0} flexDirection="column">
|
|
||||||
{oauthStatus === "authenticating" && (
|
|
||||||
<Text dimColor>Authenticating...</Text>
|
|
||||||
)}
|
|
||||||
{oauthStatus === "error" && oauthMessage && (
|
|
||||||
<Text color="red">{oauthMessage}</Text>
|
|
||||||
)}
|
|
||||||
{oauthStatus === "success" &&
|
|
||||||
oauthFlowState &&
|
|
||||||
oauthFlowState.execution === "quick" &&
|
|
||||||
(oauthFlowState.oauthTokens ||
|
|
||||||
oauthFlowState.oauthClientInfo) && (
|
|
||||||
<>
|
|
||||||
<Text bold>Quick Auth Results</Text>
|
|
||||||
{oauthFlowState.oauthClientInfo && (
|
|
||||||
<Box marginTop={1} flexDirection="column" paddingLeft={2}>
|
|
||||||
<Text dimColor>
|
|
||||||
Client:{" "}
|
|
||||||
{JSON.stringify(
|
|
||||||
oauthFlowState.oauthClientInfo,
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{oauthFlowState.oauthTokens && (
|
|
||||||
<Box marginTop={1} flexDirection="column" paddingLeft={2}>
|
|
||||||
<Text dimColor>
|
|
||||||
Access Token:{" "}
|
|
||||||
{oauthFlowState.oauthTokens.access_token?.slice(
|
|
||||||
0,
|
|
||||||
20,
|
|
||||||
)}
|
|
||||||
...
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedAction === "clear" && clearedConfirmation && (
|
|
||||||
<Box key="clear" flexShrink={0} flexDirection="column">
|
|
||||||
<Text color="green">OAuth state cleared.</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{focused && (
|
{focused && (
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -386,52 +229,10 @@ export function AuthTab({
|
||||||
backgroundColor="gray"
|
backgroundColor="gray"
|
||||||
>
|
>
|
||||||
<Text bold color="white">
|
<Text bold color="white">
|
||||||
←/→ select, G/Q/S or Enter run, ↑/↓ scroll
|
S {isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStepDetails(state: OAuthFlowState, step: OAuthStep): string | null {
|
|
||||||
switch (step) {
|
|
||||||
case "metadata_discovery":
|
|
||||||
if (state.resourceMetadata || state.oauthMetadata) {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (state.resourceMetadata) {
|
|
||||||
parts.push(
|
|
||||||
`Resource: ${JSON.stringify(state.resourceMetadata, null, 2)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (state.oauthMetadata) {
|
|
||||||
parts.push(`OAuth: ${JSON.stringify(state.oauthMetadata, null, 2)}`);
|
|
||||||
}
|
|
||||||
return parts.join("\n");
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
case "client_registration":
|
|
||||||
if (state.oauthClientInfo) {
|
|
||||||
return JSON.stringify(state.oauthClientInfo, null, 2);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
case "authorization_redirect":
|
|
||||||
if (state.authorizationUrl) {
|
|
||||||
return `URL: ${state.authorizationUrl.toString()}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
case "authorization_code":
|
|
||||||
return state.authorizationCode
|
|
||||||
? `Code received: ${state.authorizationCode.slice(0, 10)}...`
|
|
||||||
: null;
|
|
||||||
case "token_request":
|
|
||||||
return "Exchanging code for tokens...";
|
|
||||||
case "complete":
|
|
||||||
if (state.oauthTokens) {
|
|
||||||
return `Tokens: access_token=${state.oauthTokens.access_token?.slice(0, 15)}...`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
42
clients/tui/src/utils/oauthDisplay.ts
Normal file
42
clients/tui/src/utils/oauthDisplay.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type {
|
||||||
|
OAuthClientRegistrationKind,
|
||||||
|
OAuthConnectionState,
|
||||||
|
} from "@inspector/core/auth/types.js";
|
||||||
|
|
||||||
|
export function formatAuthProtocol(
|
||||||
|
protocol: OAuthConnectionState["protocol"],
|
||||||
|
): string {
|
||||||
|
return protocol === "ema" ? "Enterprise-managed" : "Standard";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatIdpSession(
|
||||||
|
session: NonNullable<OAuthConnectionState["ema"]>["idpSession"],
|
||||||
|
): string {
|
||||||
|
switch (session) {
|
||||||
|
case "logged_in":
|
||||||
|
return "Signed in";
|
||||||
|
case "expired":
|
||||||
|
return "Session expired";
|
||||||
|
default:
|
||||||
|
return "Not signed in";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatClientRegistrationKind(
|
||||||
|
kind: OAuthClientRegistrationKind,
|
||||||
|
): string {
|
||||||
|
switch (kind) {
|
||||||
|
case "static":
|
||||||
|
return "Static (preregistered)";
|
||||||
|
case "dcr":
|
||||||
|
return "Dynamic (DCR)";
|
||||||
|
case "cimd":
|
||||||
|
return "Client ID Metadata (CIMD)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatScopes(state: OAuthConnectionState): string | undefined {
|
||||||
|
const scopeSource = state.grantedScope ?? state.configuredScope;
|
||||||
|
const scopes = scopeSource?.split(" ").filter(Boolean);
|
||||||
|
return scopes && scopes.length > 0 ? scopes.join(", ") : undefined;
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ export default defineConfig({
|
||||||
'pino',
|
'pino',
|
||||||
'zustand',
|
'zustand',
|
||||||
'@modelcontextprotocol/sdk',
|
'@modelcontextprotocol/sdk',
|
||||||
|
'@napi-rs/keyring',
|
||||||
],
|
],
|
||||||
esbuildOptions(options) {
|
esbuildOptions(options) {
|
||||||
options.alias = {
|
options.alias = {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ import {
|
||||||
parseKeyValuePair,
|
parseKeyValuePair,
|
||||||
parseHeaderPair,
|
parseHeaderPair,
|
||||||
} from "@inspector/core/mcp/node/index.js";
|
} from "@inspector/core/mcp/node/index.js";
|
||||||
|
import { loadRunnerClientConfig } from "@inspector/core/client/runner.js";
|
||||||
|
import {
|
||||||
|
parseRunnerOAuthCallbackUrl,
|
||||||
|
DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
|
||||||
|
} from "@inspector/core/auth/node/runner-oauth-callback.js";
|
||||||
import App from "./src/App.js";
|
import App from "./src/App.js";
|
||||||
import { loadTuiServers } from "./src/tui-servers.js";
|
import { loadTuiServers } from "./src/tui-servers.js";
|
||||||
|
|
||||||
|
|
@ -48,9 +53,13 @@ export async function runTui(args?: string[]): Promise<void> {
|
||||||
"--client-metadata-url <url>",
|
"--client-metadata-url <url>",
|
||||||
"OAuth Client ID Metadata Document URL (CIMD) for HTTP servers",
|
"OAuth Client ID Metadata Document URL (CIMD) for HTTP servers",
|
||||||
)
|
)
|
||||||
|
.option(
|
||||||
|
"--client-config <path>",
|
||||||
|
"Install-level client config (default: ~/.mcp-inspector/storage/client.json, or MCP_CLIENT_CONFIG_PATH)",
|
||||||
|
)
|
||||||
.option(
|
.option(
|
||||||
"--callback-url <url>",
|
"--callback-url <url>",
|
||||||
"OAuth redirect/callback listener URL (default: http://127.0.0.1:0/oauth/callback)",
|
`OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`,
|
||||||
)
|
)
|
||||||
.argument(
|
.argument(
|
||||||
"[target...]",
|
"[target...]",
|
||||||
|
|
@ -72,6 +81,7 @@ export async function runTui(args?: string[]): Promise<void> {
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
clientMetadataUrl?: string;
|
clientMetadataUrl?: string;
|
||||||
|
clientConfig?: string;
|
||||||
callbackUrl?: string;
|
callbackUrl?: string;
|
||||||
transport?: "stdio" | "sse" | "http";
|
transport?: "stdio" | "sse" | "http";
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
|
|
@ -89,52 +99,13 @@ export async function runTui(args?: string[]): Promise<void> {
|
||||||
serverUrl: options.serverUrl?.trim() || undefined,
|
serverUrl: options.serverUrl?.trim() || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mcpServers = loadTuiServers(serverOptions);
|
const mcpServers = await loadTuiServers(serverOptions);
|
||||||
|
|
||||||
interface CallbackUrlConfig {
|
const callbackUrlConfig = parseRunnerOAuthCallbackUrl(options.callbackUrl);
|
||||||
hostname: string;
|
|
||||||
port: number;
|
|
||||||
pathname: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCallbackUrl(raw?: string): CallbackUrlConfig {
|
const clientConfig = await loadRunnerClientConfig({
|
||||||
if (!raw) {
|
clientConfigPath: options.clientConfig,
|
||||||
return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" };
|
});
|
||||||
}
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(raw);
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid callback URL: ${(err as Error)?.message ?? String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (url.protocol !== "http:") {
|
|
||||||
throw new Error("Callback URL must use http scheme");
|
|
||||||
}
|
|
||||||
const hostname = url.hostname;
|
|
||||||
if (!hostname) {
|
|
||||||
throw new Error("Callback URL must include a hostname");
|
|
||||||
}
|
|
||||||
const pathname = url.pathname || "/";
|
|
||||||
let port: number;
|
|
||||||
if (url.port === "") {
|
|
||||||
port = 80;
|
|
||||||
} else {
|
|
||||||
port = Number(url.port);
|
|
||||||
if (
|
|
||||||
!Number.isFinite(port) ||
|
|
||||||
!Number.isInteger(port) ||
|
|
||||||
port < 0 ||
|
|
||||||
port > 65535
|
|
||||||
) {
|
|
||||||
throw new Error("Callback URL port must be between 0 and 65535");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { hostname, port, pathname };
|
|
||||||
}
|
|
||||||
|
|
||||||
const callbackUrlConfig = parseCallbackUrl(options.callbackUrl);
|
|
||||||
|
|
||||||
const ansiEraseSavedLines = new RegExp(
|
const ansiEraseSavedLines = new RegExp(
|
||||||
String.fromCharCode(0x1b) + "\\[3J",
|
String.fromCharCode(0x1b) + "\\[3J",
|
||||||
|
|
@ -181,6 +152,7 @@ export async function runTui(args?: string[]): Promise<void> {
|
||||||
const instance = render(
|
const instance = render(
|
||||||
<App
|
<App
|
||||||
mcpServers={mcpServers}
|
mcpServers={mcpServers}
|
||||||
|
clientConfig={clientConfig}
|
||||||
clientId={options.clientId}
|
clientId={options.clientId}
|
||||||
clientSecret={options.clientSecret}
|
clientSecret={options.clientSecret}
|
||||||
clientMetadataUrl={options.clientMetadataUrl}
|
clientMetadataUrl={options.clientMetadataUrl}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue