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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ miftah setup

On a bare interactive `miftah setup`, Miftah starts with `What do you already have? (connector, remote HTTPS, local executable, browser sign-in, import) [connector]`; choose `connector`, `remote`, `local`, `browser sign-in`, or `import` based on what you already have. At that prompt, enter `remote` for a remote HTTPS endpoint, `local` for a reviewed executable and argument array, or `connector` for a known connector or pinned package. It then collects only the safe metadata that path needs and an output location, and can print an optional client JSON snippet for manual review. Choose `browser sign-in` when the remote MCP opens a browser to authenticate you. Miftah asks for the configuration name, account profile, and exact HTTPS endpoint, then discovers whether it can safely own a supported standards-based OAuth flow. It writes nothing if the endpoint does not prove one supported authorization server with dynamic registration. Keep `miftah setup --native-oauth` for scripted or repeatable setup. The generic `remote` path does not discover authentication or call the endpoint; use it for no-auth or documented API-key/header setup. Choose `import` when you already have one MCP entry in a client JSON file: Miftah asks for that explicit absolute file, lists only entry names, and asks you to select one. It never prints the source entry's command, arguments, headers, environment values, or credentials, and it does not scan or modify the source client file. It never asks for a token, password, or browser cookie. For `local`, it collects one literal argument at a time, shows a no-secret review summary, and requires an explicit acknowledgement. Miftah does not run the local executable during setup. Miftah validates the complete configuration before it writes an owner-restricted file, never overwrites an existing one, and never edits a Claude, Cursor, VS Code, or other MCP client file. Recognized adapters can then offer one explicit, provider-declared read-only readiness check; Miftah never guesses a tool or auto-approves a policy prompt. Use `miftah init` when you want the same catalog in a scripted command.

When setup finishes, Miftah tells you whether it ran a reviewed check, skipped one, or had no declared safe check at all. Browser sign-in setup can finish with browser authorization still outstanding. It saves a discovered connection plan, not a credential; use the later connect action to start the provider's browser flow. A completed configuration is not the same as a working client. You still review and merge any client JSON yourself, then restart or reconnect the client.

## First setup: GitHub with Claude Desktop

This path creates one Claude connector backed by two GitHub profiles: `work` and `personal`.
Expand Down
4 changes: 4 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ For a static local account, noninteractive use requires `--profile` and `--crede

`miftah setup <name> --import-file <absolute-json-file> --import-entry <name>` is a separate no-secret flow, not a generic client migration. The same flow is available to a first-time interactive user: run bare `miftah setup`, choose `import` at `What do you already have? (connector, remote HTTPS, local executable, browser sign-in, import) [connector]`, provide the exact file, and select one listed entry by number or exact name. The source file must be an absolute regular non-symlink file. Miftah reads it through one bounded verified handle, requires an explicitly selected entry, and never changes the source. Guided import lists safe entry names only; it does not print source command, argument, header, environment, or credential data. It accepts either a local `stdio` entry under `mcpServers` (Claude Desktop, Claude Code, or Cursor) or `servers` (VS Code) that fits its finite static launch grammar—literal executable, optional absolute working directory, and either an exact-version package-runner launch with only that runner's fixed safe prefix flags and no arguments after the package, a script path plus non-sensitive flags, or a direct executable plus non-sensitive flags—or one credential-free HTTPS remote entry. A remote import uses `url` under `mcpServers` or `servers` and must explicitly declare `type: "http"` or `"streamable-http"`. It requires HTTPS without userinfo, query, fragment, or opaque credential-shaped path segments, and does not discover OAuth or call the remote endpoint. On Windows, a local import accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. It rejects `env`, headers, shell settings, unknown fields, environment wrappers, inline code, opaque values or assignments, unsupported remote transports, URL userinfo, opaque credential-shaped URL path segments, unpinned package references, and credential-shaped arguments. It creates a read-only default profile with unknown tool risk set to destructive and does not launch the imported program. `--verify` is rejected before publication because no reviewed provider adapter is inferred. Use advanced manual setup when the existing entry does not fit this grammar; configure upstream credentials or OAuth separately through the upstream's documented path and Miftah secret references.

### Setup completion

After it publishes a first configuration, `miftah setup` prints a truthful completion handoff: whether a provider-declared safe check was unavailable, available but not run, skipped, completed, or incomplete; and whether client JSON was shown or still needs generating. Browser sign-in setup can finish with browser authorization still outstanding. The configuration records a discovered connection plan, not a credential, until the later connect action starts the provider flow. It never treats the config write as proof that an MCP client adopted it. The completion handoff prints the actual configuration path in any later `miftah profile test` command. Review any generated client JSON, merge it yourself, then restart or reconnect the client.

### `migrate-config`

`miftah migrate-config --config <file>` accepts only the documented supported formats and writes a JSON report containing source/target versions, safe structural actions, and whether a write occurred. It reads and validates the candidate before it changes anything. It does not emit a raw config, a diff, resolved secret values, or provider output.
Expand Down
10 changes: 9 additions & 1 deletion src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
publishSetupConfigurationPlan,
type SetupConfigurationPlan
} from "../setup/setup-configuration.js";
import type { SetupCompletionClientHandoff } from "../setup/setup-completion.js";
import {
CLIENT_NAMES,
ClientSnippetError,
Expand Down Expand Up @@ -79,6 +80,8 @@ export interface InitCommandResult {
readonly output: string;
readonly config: MiftahConfig;
readonly providerAdapter?: ProviderAdapterDefinition;
/** Whether this invocation displayed copy-only client JSON. */
readonly clientHandoff?: SetupCompletionClientHandoff;
}

interface Cancellation {
Expand Down Expand Up @@ -578,5 +581,10 @@ export async function runInitCommand(options: InitCommandOptions, context: InitC
context.output.write(`Created ${plan.output}\n`);
writeProviderAdapterGuidance(context.output, plan.providerAdapter);
writeSnippets(context.output, plan.snippets, plan.claudeCodePermissionGuidance);
return { output: plan.output, config: plan.config, providerAdapter: plan.providerAdapter };
return {
output: plan.output,
config: plan.config,
providerAdapter: plan.providerAdapter,
clientHandoff: plan.snippets.length === 0 ? "not-generated" : "shown"
};
}
6 changes: 5 additions & 1 deletion src/cli/setup-client-entry-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ export async function runClientEntryImportSetupFromDocument(
throw error;
}

const result: InitCommandResult = { output: plan.path, config };
const result: InitCommandResult = {
output: plan.path,
config,
clientHandoff: options.client === undefined ? "not-generated" : "shown"
};
context.output.write(`Created ${plan.path}\n`);
if (config.upstream?.transport === "streamable-http") {
context.output.write(
Expand Down
17 changes: 17 additions & 0 deletions src/cli/setup-native-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createSetupConfigurationPlan,
publishSetupConfigurationPlan
} from "../setup/setup-configuration.js";
import { createSetupCompletion } from "../setup/setup-completion.js";
import {
planNativeOAuthFirstRunConfiguration,
runNativeOAuthAccountAddition
Expand Down Expand Up @@ -207,6 +208,21 @@ function writeClientHandoff(
}
}

function writeFirstRunCompletion(
context: InitCommandContext,
selection: string | undefined,
configPath: string
): void {
const completion = createSetupCompletion({
surface: "cli",
verification: "authorization-pending",
clientHandoff: selection === undefined ? "not-generated" : "shown",
configPath
});
context.output.write(`${completion.verification.message}\n`);
context.output.write(`${completion.clientHandoff.message}\n`);
}

/**
* Performs endpoint-first OAuth planning before any configuration path is created.
* It does not launch a browser, dynamically register a client, or create a credential.
Expand Down Expand Up @@ -267,4 +283,5 @@ export async function runNativeOAuthSetup(
}
context.output.write(`Created ${configuration.path}\n`);
writeClientHandoff(context, values.client, plan.config.name, configuration.path);
writeFirstRunCompletion(context, values.client, configuration.path);
}
102 changes: 98 additions & 4 deletions src/cli/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import {
import { runNativeOAuthSetup } from "./setup-native-oauth.js";
import { runProviderAccountSetup } from "./setup-provider-account.js";
import { runEnvironmentProfileSetup } from "./setup-environment-profile.js";
import {
createSetupCompletion,
type SetupCompletionClientHandoff,
type SetupCompletionVerification
} from "../setup/setup-completion.js";

/** `init` remains network-free; only guided `setup --verify` may run the reviewed provider probe. */
export type SetupCommandOptions = InitCommandOptions & Pick<
Expand Down Expand Up @@ -153,7 +158,9 @@ function selectedGuidedClientEntry(answer: string | undefined, entries: readonly
* authority: source bytes stay private, only safe entry names are rendered,
* and the existing shared importer performs the sole conversion and write.
*/
async function runGuidedClientEntryImport(context: InitCommandContext): Promise<void> {
async function runGuidedClientEntryImport(
context: InitCommandContext
): Promise<Awaited<ReturnType<typeof runClientEntryImportSetupFromDocument>>> {
const prompts = createInteractivePromptSession(context, "Guided client-entry import was cancelled.");
try {
const importFile = await prompts.prompt("Client configuration file (absolute path)");
Expand Down Expand Up @@ -181,7 +188,7 @@ async function runGuidedClientEntryImport(context: InitCommandContext): Promise<
"Client (claude-desktop, claude-code, cursor, vscode, all; blank for config only)"
);

await runClientEntryImportSetupFromDocument({
return runClientEntryImportSetupFromDocument({
name,
output,
...(client === undefined ? {} : { client }),
Expand All @@ -204,6 +211,32 @@ async function accountAdditionKind(options: SetupCommandOptions, context: InitCo
return getProviderAdapterForAccountProvisioning(config)?.accountProvisioning === undefined ? "environment" : "provider";
}

function writeCliSetupCompletion(
context: InitCommandContext,
input: {
readonly verification: SetupCompletionVerification;
readonly clientHandoff: SetupCompletionClientHandoff;
readonly profile?: string;
readonly configPath?: string;
readonly includeClientHandoff?: boolean;
}
): void {
const completion = createSetupCompletion({
surface: "cli",
verification: input.verification,
clientHandoff: input.clientHandoff,
...(input.profile === undefined ? {} : { profile: input.profile }),
...(input.configPath === undefined ? {} : { configPath: input.configPath })
});
context.output.write(`${completion.verification.message}\n`);
if (completion.verification.nextAction !== undefined) {
context.output.write(`${completion.verification.nextAction}\n`);
}
if (input.includeClientHandoff !== false) {
context.output.write(`${completion.clientHandoff.message}\n`);
}
}

/**
* Starts the human-first setup journey while retaining `init` for scripts and
* existing automation. Both entry points deliberately use the same planner,
Expand Down Expand Up @@ -256,15 +289,36 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini
const decision = options.verify === true ? "verify" : !isTty(context) ? "skip" : await confirmReadiness(context, "the new account now");
if (decision === "skip") {
context.output.write("First-success verification was skipped; the new account was added but has not been tested with the provider.\n");
writeCliSetupCompletion(context, {
verification: "skipped",
clientHandoff: "not-generated",
profile: added.report.profile,
configPath: added.configPath,
includeClientHandoff: false
});
return { verification: "skipped", exitCode: 0, reports: [] };
}
if (decision === "cancelled") {
context.output.write("First-success verification was cancelled after the account was added; the configuration remains available.\n");
writeCliSetupCompletion(context, {
verification: "incomplete",
clientHandoff: "not-generated",
profile: added.report.profile,
configPath: added.configPath,
includeClientHandoff: false
});
return { verification: "incomplete", exitCode: 1, reports: [] };
}
try {
const report = await runProfileReadiness(added.configPath, { profile: added.report.profile });
writeReadinessReport(context, report);
writeCliSetupCompletion(context, {
verification: report.status === "ready" ? "complete" : "incomplete",
clientHandoff: "not-generated",
profile: added.report.profile,
configPath: added.configPath,
includeClientHandoff: false
});
return {
verification: report.status === "ready" ? "complete" : "incomplete",
exitCode: report.status === "ready" ? 0 : 1,
Expand All @@ -273,6 +327,13 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini
} catch (error) {
const code = error instanceof MiftahError ? error.code : "UPSTREAM_CALL_FAILED";
context.output.write(`Profile '${added.report.profile}': readiness did not complete (${code}).\n`);
writeCliSetupCompletion(context, {
verification: "incomplete",
clientHandoff: "not-generated",
profile: added.report.profile,
configPath: added.configPath,
includeClientHandoff: false
});
return { verification: "incomplete", exitCode: 1, reports: [] };
}
}
Expand Down Expand Up @@ -317,16 +378,26 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini
if (options.verify === true) {
throw new CliUsageError("Option '--verify' is unavailable for imported client entries because Miftah does not infer a reviewed provider adapter.");
}
await runClientEntryImportSetup(options, context);
const imported = await runClientEntryImportSetup(options, context);
// Imported client entries are intentionally untrusted/manual. They do not
// inherit a reviewed provider adapter and are never launched during import.
writeCliSetupCompletion(context, {
verification: "not-declared",
clientHandoff: imported.clientHandoff ?? "not-generated",
configPath: imported.output
});
return { verification: "not-applicable", exitCode: 0, reports: [] };
}
let guidedPreset: "streamable-http" | "local-stdio" | undefined;
if (isTty(context) && !hasExplicitNewConfigurationInput(options)) {
const startingPoint = await chooseGuidedSetupStartingPoint(context);
if (startingPoint === "import") {
await runGuidedClientEntryImport(context);
const imported = await runGuidedClientEntryImport(context);
writeCliSetupCompletion(context, {
verification: "not-declared",
clientHandoff: imported.clientHandoff ?? "not-generated",
configPath: imported.output
});
return { verification: "not-applicable", exitCode: 0, reports: [] };
}
if (startingPoint === "remote-sign-in") {
Expand Down Expand Up @@ -354,15 +425,32 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini
);
}
if (created.providerAdapter?.diagnostics.safeReadProbe === undefined) {
writeCliSetupCompletion(context, {
verification: "not-declared",
clientHandoff: created.clientHandoff ?? "not-generated",
configPath: created.output
});
return { verification: "not-applicable", exitCode: 0, reports: [] };
}
const decision = options.verify === true ? "verify" : await confirmReadiness(context, "every account now");
if (decision === "skip") {
context.output.write("First-success verification was skipped; the configuration was created but has not been tested with the provider.\n");
writeCliSetupCompletion(context, {
verification: "skipped",
clientHandoff: created.clientHandoff ?? "not-generated",
profile: created.config.defaultProfile,
configPath: created.output
});
return { verification: "skipped", exitCode: 0, reports: [] };
}
if (decision === "cancelled") {
context.output.write("First-success verification was cancelled after configuration creation; the configuration remains available.\n");
writeCliSetupCompletion(context, {
verification: "incomplete",
clientHandoff: created.clientHandoff ?? "not-generated",
profile: created.config.defaultProfile,
configPath: created.output
});
return { verification: "incomplete", exitCode: 1, reports: [] };
}

Expand All @@ -380,6 +468,12 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini
context.output.write(`Profile '${profile}': readiness did not complete (${code}).\n`);
}
}
writeCliSetupCompletion(context, {
verification: incomplete ? "incomplete" : "complete",
clientHandoff: created.clientHandoff ?? "not-generated",
...(incomplete ? { profile: created.config.defaultProfile } : {}),
configPath: created.output
});
return { verification: incomplete ? "incomplete" : "complete", exitCode: incomplete ? 1 : 0, reports };
}

Expand Down
Loading