diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df5dadf..32b13c7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. The format - [#83](https://github.com/mohanagy/miftah/issues/83) Added durable bounded profile/account identity bindings, visible per-profile binding states and evidence, exact OAuth connection identity-state updates, and opt-in explicit or confirmed current-session selection for protected multi-profile operations. Persisted evidence never replaces live verification, and external state changes never silently override an active client's in-memory profile. - [#84](https://github.com/mohanagy/miftah/issues/84) Added dry-run-first OAuth connection setup, redacted connection list/status/test reports, safe connect and reauthentication, exact local disconnect, client snippets, and headless diagnostics through shared typed application services. - [#85](https://github.com/mohanagy/miftah/issues/85) Added the explicitly launched, literal-loopback Console control API with a separate `/api/v1` listener, one-use terminal bootstrap, bounded HttpOnly browser sessions, strict Host/Origin and CSRF enforcement, metadata-only configuration/profile/connection health, atomic audited connection changes, redacted Console audit queries, credential rotation, and clean shutdown. The browser UI remains a separate roadmap layer. +- [#87](https://github.com/mohanagy/miftah/issues/87) Added a typed built-in provider-adapter contract and a bounded Google Search Console pilot with exact `mcp-search-console@0.3.2` launch pinning, explicit upstream-owned OAuth/browser/cache lifecycle, safe health metadata, read-only defaults, manual service-account guidance, and no token-cache access or native-OAuth claims. ## [0.3.3] - 2026-07-22 diff --git a/README.md b/README.md index 74d1d502..989cd649 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ When you need the other product, select its profile deliberately. When a tool ca - **Other compatible MCP servers:** configure a generic STDIO, Streamable HTTP, or legacy SSE upstream, then add profiles around it. - **Multiple upstreams in one wrapper:** use an account bundle when related upstreams belong behind one controlled connection. -Miftah can authorize a standards-compatible remote HTTPS Streamable HTTP MCP server through discovery, PKCE, a bounded loopback browser callback, OS-vault storage, and refresh. `miftah connection …` and `miftah auth …` provide reviewed setup, status, noninteractive testing, connect, safe reauth, and local disconnect. Provider-specific or local STDIO OAuth remains owned by that upstream, and local disconnect does not claim provider-side token revocation. Read [OAuth support](docs/oauth-support.md) for the exact compatibility and configuration boundary. +Miftah can authorize a standards-compatible remote HTTPS Streamable HTTP MCP server through discovery, PKCE, a bounded loopback browser callback, OS-vault storage, and refresh. `miftah connection …` and `miftah auth …` provide reviewed setup, status, noninteractive testing, connect, safe reauth, and local disconnect. Provider-specific or local STDIO OAuth remains owned by that upstream, and local disconnect does not claim provider-side token revocation. A bounded [Google Search Console provider-adapter pilot](docs/provider-adapters.md#google-search-console-pilot) now generates an exact-pinned upstream-owned setup without reading its token cache or pretending it is native Miftah OAuth. Read [OAuth support](docs/oauth-support.md) for the exact compatibility and configuration boundary. ## Trust and control boundaries @@ -117,6 +117,7 @@ If you use one account with one direct MCP server and do not need profile, routi - [Configuration reference](docs/config.md) - [Security boundary](docs/security.md), [Threat model](docs/threat-model.md), and [OAuth and Console security design](docs/oauth-console-threat-model.md) - [OAuth support](docs/oauth-support.md) +- [Provider adapters and Google Search Console pilot](docs/provider-adapters.md) - [Local Console control API](docs/console-api.md) - [CLI reference](docs/cli.md) - [Architecture](docs/architecture.md) diff --git a/docs/architecture.md b/docs/architecture.md index 883499b3..5151c9aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -The [threat model](threat-model.md) maps these components and their boundaries to public security controls and residual risks. The [OAuth and Console security design delta](oauth-console-threat-model.md) describes the control-plane and credential-lifecycle gates. Version 3 adds a standards-compatible remote OAuth runtime, in-process operator lifecycle services, and a separately launched local Console control API for exact HTTPS Streamable HTTP connections; provider adapters, provider-side revocation, and the browser UI remain separate milestones. +The [threat model](threat-model.md) maps these components and their boundaries to public security controls and residual risks. The [OAuth and Console security design delta](oauth-console-threat-model.md) describes the control-plane and credential-lifecycle gates. Version 3 adds a standards-compatible remote OAuth runtime, in-process operator lifecycle services, and a separately launched local Console control API for exact HTTPS Streamable HTTP connections. A separate typed built-in [provider-adapter catalog](provider-adapters.md) can launch and describe upstream-owned local authentication without entering the native OAuth engine; provider-side revocation and the browser UI remain separate milestones. Miftah is an MCP-aware proxy, not a byte-level reverse proxy: diff --git a/docs/cli.md b/docs/cli.md index 3abe328f..18b54c4d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,7 +23,7 @@ Documented command names, options, JSON success forms, and exit categories are c | `miftah validate --config ` | `--config` | `--config ` | Validates the JSON configuration without starting an upstream. Writes a JSON object with `ok`, `name`, and `profiles`. | | `miftah doctor --config ` | `--config` | `--config `, `--json` | Validates configuration and checks upstream readiness. Default output is a human-readable report; `--json` writes only the JSON report. A healthy or degraded report exits `0`; a failed report exits `1`. | | `miftah schema` | none | none | Writes the Miftah JSON Schema as pretty-printed JSON. | -| `miftah init [name]` | none | `--name `, `--preset `, `--output `, `--interactive`, `--client `, `--credential-env `, `--npm-package `, `--docker-image `, `--url `, `--header-name `, `--header-prefix ` | Writes a strict catalog configuration with exclusive creation and can print client JSON snippets. The positional `name` and `--name` are alternatives; the default name is `miftah-wrapper`. | +| `miftah init [name]` | none | `--name `, `--preset `, `--output `, `--interactive`, `--client `, `--credential-env `, `--npm-package `, `--docker-image `, `--url `, `--header-name `, `--header-prefix `, `--oauth-client-secrets-file ` | Writes a strict catalog configuration with exclusive creation and can print client JSON snippets. The positional `name` and `--name` are alternatives; the default name is `miftah-wrapper`. | | `miftah migrate-config --config ` | `--config` | `--config `, `--write` | Plans a supported configuration-format migration and writes a safe JSON report. It is dry-run by default. `--write` validates the candidate, makes an exact exclusive `.bak`, then uses a same-directory non-overwriting publication for a changed regular non-symlink source; it never resolves secrets or starts an upstream. | | `miftah connection add --config ` | `--config`, `--profile`, `--issuer`, `--client-registration` | `--connection `, `--upstream `, repeated `--scope `, `--write` | Plans a v3 OAuth binding by default. `--write` applies the reviewed candidate with a unique recovery backup and configured audit event. It never resolves credentials or starts an upstream. | | `miftah connection list --config ` | `--config` | `--client ` | Lists redacted connection state. Optional snippets are copyable JSON only; Miftah never edits client settings. | @@ -55,14 +55,14 @@ The Console API is versioned under `/api/v1` and uses exact Host/Origin checks, ### `init` presets and paths -`--preset` defaults to `generic`. The strict catalog accepts `generic`, `github`, `sentry`, `generic-npx`, `generic-docker`, and `streamable-http`; an unrecognized preset is a usage error. `--output` defaults to `.miftah.json`. Miftah resolves the output path from the current working directory, creates missing parent directories, and refuses to overwrite an existing file. Quote shell paths and names containing spaces: +`--preset` defaults to `generic`. The strict catalog accepts `generic`, `github`, `sentry`, `google-search-console`, `generic-npx`, `generic-docker`, and `streamable-http`; an unrecognized preset is a usage error. `--output` defaults to `.miftah.json`. Miftah resolves the output path from the current working directory, creates missing parent directories, and refuses to overwrite an existing file. Quote shell paths and names containing spaces: ```sh miftah init "work wrapper" --preset github --output "$HOME/Miftah configs/work wrapper.json" miftah validate --config "$HOME/Miftah configs/work wrapper.json" ``` -`generic-npx` requires `--npm-package` with exact package SemVer; `generic-docker` requires a canonical digest in `--docker-image`; and `streamable-http` requires `--url` plus optional credential environment/header metadata. `--credential-env` is optional where supported. See [preset and client compatibility](presets-and-clients.md) for exact inputs, pins, provenance, and client snippets. +`generic-npx` requires `--npm-package` with exact package SemVer; `generic-docker` requires a canonical digest in `--docker-image`; `streamable-http` requires `--url` plus optional credential environment/header metadata; and `google-search-console` requires `--oauth-client-secrets-file` with an absolute path. `--credential-env` is optional where supported. The GSC adapter prints credential/browser/identity ownership without printing that path. See [preset and client compatibility](presets-and-clients.md) for exact inputs, pins, provenance, and client snippets, and [provider adapters](provider-adapters.md) for the upstream-owned OAuth boundary. `--interactive` uses a wizard only when both input and output are TTYs. EOF or Ctrl-C cancels without writing a config. It asks for variable names and safe metadata, never secret values. In noninteractive use, `init` creates only the config unless `--client` is supplied. `--client` prints JSON with absolute Node and compiled Miftah paths; it does not write a host config. For `claude-code` or `all`, it also prints a separate, exact management-tool `permissions.ask` fragment for manual merge into Claude Code settings; it never writes or overwrites those settings. Regenerate the snippets after moving or upgrading Miftah or changing the config path. diff --git a/docs/oauth-support.md b/docs/oauth-support.md index 126b1d58..2acc9e6a 100644 --- a/docs/oauth-support.md +++ b/docs/oauth-support.md @@ -13,7 +13,7 @@ The [OAuth and local Console design delta](oauth-console-threat-model.md) record | Support class | Transport and current ownership | Operator fallback | | --- | --- | --- | | Standards-compatible remote HTTP MCP OAuth | Enabled for an exact HTTPS `streamable-http` connection whose protected-resource and authorization-server metadata satisfy Miftah's discovery checks. Miftah owns browser authorization, the bounded loopback callback, exchange, vault storage, refresh, and bearer injection. | If the server does not advertise the required standards, use its documented API-key, static-header, or provider-owned flow. | -| Provider-adapter-backed local or non-standard OAuth | No provider-adapter API exists today. Miftah does not automate a provider-specific browser flow, private callback convention, or token-store format. | Complete the upstream's documented login and configuration; Miftah can launch, redact, and diagnose that configured upstream. | +| Provider-adapter-backed local or non-standard OAuth | The built-in [Google Search Console pilot](provider-adapters.md#google-search-console-pilot) launches an exact-pinned local upstream and publishes its ownership contract. OAuth, browser handoff, token cache, reauthentication, and revocation remain upstream/manual-owned; this is not native Miftah OAuth. | Complete the upstream's documented login and configuration. The typed adapter supplies bounded launch and safe health metadata without cache access. | | Upstream-owned or manual credentials | `stdio`, legacy `sse`, and remote Streamable HTTP headers remain supported. The upstream/provider owns login, callback, token cache, refresh, reauth, and revoke. | Complete the provider-owned login, supply its documented credential path, environment value, or static secret reference, then run `miftah validate` and `miftah doctor`. | | Unsupported authentication patterns | Provider passwords, browser cookies, and arbitrary third-party token caches are not a supported Miftah-managed OAuth mechanism. Miftah does not own, parse, scrape, import, replay, or lifecycle-manage provider passwords, browser cookies, or arbitrary third-party token caches as OAuth artifacts. | Use a provider-supported mechanism, or leave that upstream unconfigured when its only path depends on opaque private state. | @@ -103,7 +103,7 @@ The exact OAuth-bound Streamable HTTP transport receives the profile-bound OAuth ## Local and provider-owned OAuth -For a **Google Search Console-style local OAuth** upstream, such as a local `uvx mcp-search-console` process configured with a provider client-secrets file, complete the provider's login first. Configure only the documented client-secret file path, environment value, or other upstream input; then let that upstream create and maintain its own local credential cache. **Miftah must not scrape, copy, or manage that upstream token cache.** +For Google Search Console-style local OAuth, the [Google Search Console pilot](provider-adapters.md#google-search-console-pilot) generates an exact-pinned local `uvx mcp-search-console@0.3.2` process configured with a provider client-secrets file. Complete the upstream's browser login on first use and let that upstream create and maintain its own local credential cache. **Miftah must not scrape, copy, or manage that upstream token cache.** The same rule applies when Sentry or another upstream MCP server owns consent, redirects, or cached tokens. Miftah may pass an explicit configured path or secret reference to the child and redact resolved values. It does not reinterpret an opaque provider cache as a native Miftah OAuth connection. diff --git a/docs/presets-and-clients.md b/docs/presets-and-clients.md index def00403..f7d82d78 100644 --- a/docs/presets-and-clients.md +++ b/docs/presets-and-clients.md @@ -2,7 +2,7 @@ This is the compatibility source of truth for generated `miftah init` configurations and client snippets. -- Catalog version: `1` +- Catalog version: `2` - Miftah package version: `0.3.3` - Last tested / validation boundary: the catalog builds strict Miftah configuration that `validateConfig` accepts. The docs contract test checks generated configuration only; it does **not** construct a runtime, start, authenticate to, or smoke-test external providers. @@ -15,6 +15,7 @@ Miftah itself requires Node.js `>=20`. That does not establish an upstream serve | `generic` | `npx --yes @modelcontextprotocol/server-everything@2026.7.4 stdio` | This is the MCP reference/test server, not a production-ready provider recommendation. Its npm metadata declares no upstream Node engine floor. An optional `--credential-env ` adds only a `${ENV_NAME}` reference. | | `github` | Docker STDIO: `docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server:v1.5.0 stdio --read-only --toolsets=repos,issues,pull_requests` | Docker is required. Supply least-privilege GitHub provider tokens through the generated environment references. The catalog intentionally pins a tag, **not** an invented OCI digest. Before a reproducible production deployment, use an authenticated registry process to promote the approved tag, inspect and record its resolved digest in deployment records, then deploy that recorded digest according to the [GitHub digest guidance](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#pull-by-digest). | | `sentry` | `npx --yes @sentry/mcp-server@0.36.0 --skills=inspect`; generated environment reference: `SENTRY_ACCESS_TOKEN` | The upstream package requires Node.js `>=20`. Use least-privilege Sentry token scopes. `--skills=inspect` filters CLI skills; it is not provider-token authorization and is not a read-only flag or preset. Miftah local policy does not reduce provider token permissions. | +| `google-search-console` | Required: `--oauth-client-secrets-file `; generated command: `uvx mcp-search-console@0.3.2`; generated environment: `GSC_OAUTH_CLIENT_SECRETS_FILE` | Python `>=3.11` and `uvx` are required. OAuth, browser handoff, token cache, reauthentication, and revocation remain upstream/manual-owned. Miftah applies a read-only policy, does not set `GSC_ALLOW_DESTRUCTIVE`, and never reads the upstream cache. See the [provider-adapter contract](provider-adapters.md#google-search-console-pilot). | | `generic-npx` | Required: `--npm-package `; optional `--credential-env ` | Only an exact npm package SemVer is accepted. The selected external package, not Miftah, declares its own Node requirement. | | `generic-docker` | Required: `--docker-image `; optional `--credential-env ` | Docker is required. Only a canonical image reference with a 64-hex-character `@sha256:` digest is accepted. | | `streamable-http` | Required: `--url `; optional credential metadata: `--credential-env`, `--header-name`, and `--header-prefix` | The URL must be HTTPS and must not contain userinfo, a query, or a fragment. Credentials may appear only as `${ENV_NAME}` through validated header metadata; allowed prefixes are empty, `Bearer `, and `Sentry `. Never place a credential in a URL or URL component. | @@ -41,6 +42,12 @@ Use the following upstream materials when assessing a pin or provider configurat - [MCP Everything source](https://github.com/modelcontextprotocol/servers/tree/main/src/everything) - [MCP Everything npm package](https://www.npmjs.com/package/@modelcontextprotocol/server-everything) +### Google Search Console pilot + +- [Google Search Console MCP source](https://github.com/AminForou/mcp-gsc) +- [Google Search Console MCP package](https://pypi.org/project/mcp-search-console/0.3.2/) +- [uv tool version pinning](https://docs.astral.sh/uv/guides/tools/#installing-tools) + ## First run `init` uses the strict catalog. The default preset is `generic`; an unknown preset is rejected rather than falling back. (The legacy library-only `presetConfig` fallback does not describe CLI behavior.) @@ -51,14 +58,15 @@ miftah init [name] \ [--interactive] [--client ] \ [--credential-env ] [--npm-package ] \ [--docker-image ] [--url ] \ - [--header-name ] [--header-prefix ] + [--header-name ] [--header-prefix ] \ + [--oauth-client-secrets-file ] ``` Without `--interactive`, `init` creates only a configuration unless `--client` is supplied. With `--client`, it still creates the configuration and prints JSON snippets; it never writes a client file. For `--client claude-code` or `--client all`, it also prints a separately labelled Claude Code `permissions.ask` fragment for the visible Miftah management tools that require explicit client review. It never writes or overwrites Claude Code settings. Creation is exclusive and never overwrites an existing output path. `--interactive` is available only when both input and output are real TTYs. EOF or Ctrl-C cancels before the configuration write. The wizard asks only for a name, catalog preset, safe preset metadata (variable names, URLs, header metadata, pins), output location, and client selection. It never asks for or echoes a secret value. -The printed snippets use absolute paths to the Node executable and compiled Miftah CLI so GUI clients do not depend on `PATH`. Regenerate them after moving or upgrading Miftah, or after changing the configuration path. Copy generated JSON as JSON; do not hand-edit the command into a shell string. +For `google-search-console`, `--oauth-client-secrets-file` is required and is written only to the generated profile environment. The safe adapter summary printed by `init` does not echo that path. The printed client snippets use absolute paths to the Node executable and compiled Miftah CLI so GUI clients do not depend on `PATH`. Regenerate them after moving or upgrading Miftah, or after changing the configuration path. Copy generated JSON as JSON; do not hand-edit the command into a shell string. ## Client destinations and JSON shapes diff --git a/docs/provider-adapters.md b/docs/provider-adapters.md new file mode 100644 index 00000000..f75de37e --- /dev/null +++ b/docs/provider-adapters.md @@ -0,0 +1,45 @@ +# Provider adapters + +Provider adapters describe a narrow, reviewed integration with an MCP server whose authentication is local, proprietary, or owned by that upstream. An adapter is not a generic OAuth plugin and does not grant provider code access to Miftah internals. The built-in catalog records launch prerequisites, credential ownership, browser handoff, identity evidence, health, reauthentication, disconnect, diagnostics, and destructive-tool posture. + +Every adapter declares one credential owner: Miftah, the upstream, or manual-only. The typed ownership union prevents an upstream-owned adapter from claiming Miftah's browser callback or OS vault. Adapter diagnostics are metadata-only. They never inspect arbitrary credential files or token caches, and an adapter cannot turn a provider-specific flow into a native `oauth.connections` binding. + +## Google Search Console pilot + +The initial pilot wraps the community [`mcp-search-console`](https://github.com/AminForou/mcp-gsc) STDIO server. It is deliberately upstream-owned: + +| Boundary | Contract | +| --- | --- | +| Launch | `uvx mcp-search-console@0.3.2`; Python 3.11 or newer and `uvx` are prerequisites. | +| Credential ownership | Upstream | +| Browser handoff | The upstream opens the browser on first authenticated use. Miftah does not run this OAuth callback. | +| Token cache | The upstream chooses and maintains its platform user-config cache. Miftah never reads, copies, exports, or deletes that cache. | +| Safe health evidence | The upstream `get_capabilities` tool can report authentication readiness. It is health metadata, not verified Google-account identity. | +| Reauthentication | The upstream owns the `reauthenticate` MCP tool. The generated read-only Miftah policy does not silently grant it. | +| Disconnect and revoke | Manual-only. Remove/revoke access with the upstream and Google account controls; Miftah cannot promise provider-side revocation. | +| Identity evidence | Unavailable by default. OAuth success and `get_capabilities` do not prove the intended Google account or property. | +| Destructive tools | Disabled upstream by default. The preset never sets `GSC_ALLOW_DESTRUCTIVE`; enabling it is a separate manual review and still remains subject to Miftah policy. | + +Create the pilot configuration with an absolute path to a Google OAuth desktop client-secrets JSON file: + +```sh +miftah init gsc \ + --preset google-search-console \ + --oauth-client-secrets-file "$HOME/.config/gsc/client-secrets.json" \ + --output "$HOME/.config/miftah/gsc.json" \ + --client claude-desktop +``` + +The generated profile passes that path as `GSC_OAUTH_CLIENT_SECRETS_FILE`, pins `mcp-search-console@0.3.2`, applies Miftah's read-only policy, and does not create an `oauth.connections` entry. `init` prints the safe ownership summary but never echoes the configured client-secrets path. Complete the upstream browser flow on first use, then call `get_capabilities` when you need its coarse auth health. Use the upstream's `reauthenticate` tool only after explicitly reviewing and authorizing that lifecycle operation. + +Manual configuration remains supported. If `uvx` is installed at an absolute path, or the upstream needs another documented environment value, edit the generated config and run `miftah validate` followed by `miftah doctor`. Do not add the upstream token-cache path as a Miftah secret provider and do not copy a cache between profiles. + +### OAuth versus service accounts + +OAuth is convenient for an interactive desktop user, but the upstream-owned cache can make two profiles look separate in Miftah while they still share one Google login under the same operating-system user. Do not claim deterministic multi-profile account isolation from different client-secrets files alone. Use separate OS-level homes/profile isolation only when you understand the upstream's cache layout and lifecycle. + +For unattended or deterministic automation, the upstream also documents a service-account path through `GSC_CREDENTIALS_PATH` with `GSC_SKIP_OAUTH=true`. A service account avoids browser consent and a user refresh-token cache, but it must be granted access to each Search Console property and its private key needs stricter file handling. This remains a manual configuration path in the pilot; Miftah does not import, display, or rotate the key file. + +## Adding another built-in adapter + +A future adapter must be reviewed in-tree and must state all contract fields. It must pin its launch artifact, keep arguments as arrays with no command shell, expose only bounded metadata diagnostics, retain manual setup, and document any gap in identity or lifecycle ownership. Provider API reimplementations, arbitrary executable definitions, cache scraping, hidden token migration, and silent destructive enablement do not belong in this catalog. diff --git a/src/cli/init.ts b/src/cli/init.ts index 27645a6e..2a67a057 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -6,6 +6,8 @@ import { validateConfig } from "../config/validate-config.js"; import { buildPresetConfig, PresetCatalogError } from "../config/presets.js"; import type { PresetBuildOptions } from "../config/presets.js"; import type { MiftahConfig } from "../config/types.js"; +import { getProviderAdapterForPreset } from "../config/provider-adapters.js"; +import type { ProviderAdapterDefinition } from "../config/provider-adapters.js"; import { CLIENT_NAMES, ClientSnippetError, @@ -34,6 +36,7 @@ export type InitCommandOptions = Pick< | "url" | "headerName" | "headerPrefix" + | "oauthClientSecretsFile" >; export interface InitCommandContext { @@ -55,6 +58,7 @@ interface InitPlan { readonly config: MiftahConfig; readonly snippets: readonly ClientSnippet[]; readonly claudeCodePermissionGuidance?: ClaudeCodePermissionGuidance; + readonly providerAdapter?: ProviderAdapterDefinition; } interface Cancellation { @@ -149,6 +153,14 @@ async function collectPresetOptions( options: InitCommandOptions ): Promise { switch (preset) { + case "google-search-console": + return { + oauthClientSecretsFile: options.oauthClientSecretsFile ?? (await prompt( + line, + cancellation, + "Google OAuth client-secrets file (absolute path)" + )) + }; case "generic-npx": return { credentialEnv: options.credentialEnv, @@ -168,7 +180,8 @@ async function collectPresetOptions( dockerImage: options.dockerImage, url: options.url, headerName: options.headerName, - headerPrefix: options.headerPrefix + headerPrefix: options.headerPrefix, + oauthClientSecretsFile: options.oauthClientSecretsFile }; } } @@ -216,7 +229,8 @@ function nonInteractiveValues(options: InitCommandOptions): InitValues { dockerImage: options.dockerImage, url: options.url, headerName: options.headerName, - headerPrefix: options.headerPrefix + headerPrefix: options.headerPrefix, + oauthClientSecretsFile: options.oauthClientSecretsFile }; } @@ -248,7 +262,8 @@ function buildInitPlan(values: InitValues, context: InitCommandContext): InitPla dockerImage: values.dockerImage, url: values.url, headerName: values.headerName, - headerPrefix: values.headerPrefix + headerPrefix: values.headerPrefix, + oauthClientSecretsFile: values.oauthClientSecretsFile }); validateConfig(config); } catch (error) { @@ -277,7 +292,34 @@ function buildInitPlan(values: InitValues, context: InitCommandContext): InitPla } } - return { output, config, snippets, claudeCodePermissionGuidance }; + return { + output, + config, + snippets, + claudeCodePermissionGuidance, + providerAdapter: getProviderAdapterForPreset(values.preset) + }; +} + +function writeProviderAdapterGuidance(output: Writable, adapter: ProviderAdapterDefinition | undefined): void { + if (adapter === undefined) return; + const reauth = adapter.lifecycle.reauth; + const reauthDescription = reauth.mechanism === "mcp-tool" + ? `${reauth.owner} MCP tool '${reauth.name}'` + : reauth.owner; + const tokenCacheBoundary = adapter.authentication.tokenStore === "upstream-private" + ? "Miftah will not read or manage the upstream token cache.\n" + : ""; + output.write( + `Provider adapter: ${adapter.displayName}\n` + + `Credential ownership: ${adapter.authentication.credentialOwnership}\n` + + `Browser handoff: ${adapter.authentication.browserHandoff}\n` + + `Token store: ${adapter.authentication.tokenStore}\n` + + `Identity evidence: ${adapter.identity.evidence}\n` + + `Reauthentication: ${reauthDescription}\n` + + `Disconnect/revocation: ${adapter.lifecycle.disconnect.owner}\n` + + tokenCacheBoundary + ); } /** Writes copy-paste client configuration and optional Claude Code review guidance without modifying client settings. */ @@ -317,5 +359,6 @@ export async function runInitCommand(options: InitCommandOptions, context: InitC throw error; } context.output.write(`Created ${plan.output}\n`); + writeProviderAdapterGuidance(context.output, plan.providerAdapter); writeSnippets(context.output, plan.snippets, plan.claudeCodePermissionGuidance); } diff --git a/src/cli/parse.ts b/src/cli/parse.ts index 4d1df8a9..69d2c40b 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -11,6 +11,7 @@ type ValueOptionName = | "url" | "headerName" | "headerPrefix" + | "oauthClientSecretsFile" | "transport" | "connection" | "upstream" @@ -34,6 +35,7 @@ export interface CliOptions { readonly url?: string; readonly headerName?: string; readonly headerPrefix?: string; + readonly oauthClientSecretsFile?: string; readonly transport?: "stdio" | "http"; readonly connection?: string; readonly upstream?: string; @@ -90,7 +92,8 @@ export const CLI_COMMANDS = { "dockerImage", "url", "headerName", - "headerPrefix" + "headerPrefix", + "oauthClientSecretsFile" ] }, "list-tools": { @@ -247,6 +250,12 @@ const OPTION_DEFINITIONS: Record = { usage: "--header-prefix ", description: "Credential header prefix for the streamable-http preset." }, + oauthClientSecretsFile: { + name: "oauthClientSecretsFile", + takesValue: true, + usage: "--oauth-client-secrets-file ", + description: "Absolute Google OAuth client-secrets file for the GSC preset." + }, transport: { name: "transport", takesValue: true, @@ -340,6 +349,7 @@ const FLAG_DEFINITIONS: Record "--url": OPTION_DEFINITIONS.url, "--header-name": OPTION_DEFINITIONS.headerName, "--header-prefix": OPTION_DEFINITIONS.headerPrefix, + "--oauth-client-secrets-file": OPTION_DEFINITIONS.oauthClientSecretsFile, "--transport": OPTION_DEFINITIONS.transport, "--connection": OPTION_DEFINITIONS.connection, "--upstream": OPTION_DEFINITIONS.upstream, diff --git a/src/config/presets.ts b/src/config/presets.ts index 8983e28d..8836be7c 100644 --- a/src/config/presets.ts +++ b/src/config/presets.ts @@ -1,4 +1,6 @@ +import { isAbsolute } from "node:path"; import type { MiftahConfig, ProfileConfig } from "./types.js"; +import { PROVIDER_ADAPTER_CATALOG } from "./provider-adapters.js"; import { CURRENT_CONFIG_VERSION } from "./versions.js"; /** Pinned GitHub MCP server image used by the GitHub preset. */ @@ -26,6 +28,7 @@ export interface PresetBuildOptions { url?: string; headerName?: string; headerPrefix?: string; + oauthClientSecretsFile?: string; } type PresetOptionRequirement = "required" | "optional" | "optional-with-credentialEnv" | "provider-managed"; @@ -149,6 +152,43 @@ function buildSentryPreset(name: string): MiftahConfig { return config; } +function requireOAuthClientSecretsFile(value: unknown): string { + if (typeof value !== "string") { + catalogError("Preset option 'oauthClientSecretsFile' must be a string."); + } + if ( + !value || + value.trim() !== value || + !isAbsolute(value) || + /\$\{[A-Za-z_][A-Za-z0-9_]*\}/u.test(value) || + Array.from(value).some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f); + }) + ) { + catalogError("Preset 'google-search-console' requires an absolute literal OAuth client-secrets file path without environment references, controls, or surrounding whitespace."); + } + return value; +} + +function buildGoogleSearchConsolePreset(name: string, options: PresetBuildOptions): MiftahConfig { + const adapter = PROVIDER_ADAPTER_CATALOG.adapters["google-search-console"]; + const config = buildStandardPreset(name, { + transport: adapter.launch.transport, + command: adapter.launch.command, + args: [...adapter.launch.args] + }); + config.profiles.default = { + description: "Google Search Console account (OAuth owned by upstream)", + env: { + GSC_OAUTH_CLIENT_SECRETS_FILE: requireOAuthClientSecretsFile(options.oauthClientSecretsFile) + }, + policy: "readonly" + }; + config.policies = buildReadonlyPolicies(); + return config; +} + /** Builds the multi-profile GitHub preset and its referenced policies. */ function buildGithubPreset(name: string): MiftahConfig { return { @@ -302,7 +342,7 @@ function buildStreamableHttpPreset(name: string, options: PresetBuildOptions): M * caller-supplied inputs each builder may receive. */ export const PRESET_CATALOG = { - version: "1", + version: "2", presets: { generic: { requirements: { credentialEnv: "optional" }, @@ -316,6 +356,10 @@ export const PRESET_CATALOG = { requirements: { credentialEnv: "provider-managed" }, build: buildSentryPreset }, + "google-search-console": { + requirements: { oauthClientSecretsFile: "required" }, + build: buildGoogleSearchConsolePreset + }, "generic-npx": { requirements: { npmPackage: "required", credentialEnv: "optional" }, build: buildGenericNpxPreset diff --git a/src/config/provider-adapters.ts b/src/config/provider-adapters.ts new file mode 100644 index 00000000..78da7a1d --- /dev/null +++ b/src/config/provider-adapters.ts @@ -0,0 +1,126 @@ +export type ProviderAdapterOwner = "miftah" | "upstream" | "manual-only"; + +export type ProviderAuthenticationContract = + | { + readonly credentialOwnership: "miftah"; + readonly browserHandoff: "miftah"; + readonly tokenStore: "miftah-vault"; + } + | { + readonly credentialOwnership: "upstream"; + readonly browserHandoff: "upstream"; + readonly tokenStore: "upstream-private"; + } + | { + readonly credentialOwnership: "manual-only"; + readonly browserHandoff: "manual-only"; + readonly tokenStore: "external"; + }; + +export type ProviderAdapterOperation = + | { + readonly owner: ProviderAdapterOwner; + readonly mechanism: "mcp-tool"; + readonly name: string; + } + | { + readonly owner: ProviderAdapterOwner; + readonly mechanism: "native" | "provider-console" | "operator"; + readonly name?: string; + }; + +export type ProviderIdentityContract = + | { + readonly evidence: "verified-probe"; + readonly assurance: "verified"; + } + | { + readonly evidence: "upstream-reported"; + readonly assurance: "informational"; + } + | { + readonly evidence: "unavailable"; + readonly assurance: "none"; + }; + +export interface ProviderAdapterDefinition { + readonly displayName: string; + readonly preset: string; + readonly launch: { + readonly transport: "stdio"; + readonly command: string; + readonly args: readonly string[]; + readonly prerequisites: readonly string[]; + }; + readonly authentication: ProviderAuthenticationContract; + readonly lifecycle: { + readonly health: ProviderAdapterOperation; + readonly reauth: ProviderAdapterOperation; + readonly disconnect: ProviderAdapterOperation; + }; + readonly identity: ProviderIdentityContract; + readonly diagnostics: { + readonly mode: "metadata-only"; + readonly tokenCacheAccess: "forbidden"; + readonly safeHealthTool?: string; + }; + readonly destructiveTools: { + readonly default: "disabled" | "upstream-default"; + readonly enablement: "manual-only" | "unsupported"; + readonly upstreamEnvironmentControl?: string; + }; + readonly manualSetup: { + readonly supported: true; + readonly documentation: string; + }; +} + +export const PROVIDER_ADAPTER_CATALOG = { + version: "1", + adapters: { + "google-search-console": { + displayName: "Google Search Console", + preset: "google-search-console", + launch: { + transport: "stdio", + command: "uvx", + args: ["mcp-search-console@0.3.2"], + prerequisites: ["Python >=3.11", "uv/uvx", "Google OAuth desktop client-secrets JSON"] + }, + authentication: { + credentialOwnership: "upstream", + browserHandoff: "upstream", + tokenStore: "upstream-private" + }, + lifecycle: { + health: { owner: "upstream", mechanism: "mcp-tool", name: "get_capabilities" }, + reauth: { owner: "upstream", mechanism: "mcp-tool", name: "reauthenticate" }, + disconnect: { owner: "manual-only", mechanism: "provider-console" } + }, + identity: { evidence: "unavailable", assurance: "none" }, + diagnostics: { + mode: "metadata-only", + tokenCacheAccess: "forbidden", + safeHealthTool: "get_capabilities" + }, + destructiveTools: { + default: "disabled", + enablement: "manual-only", + upstreamEnvironmentControl: "GSC_ALLOW_DESTRUCTIVE" + }, + manualSetup: { + supported: true, + documentation: "docs/provider-adapters.md#google-search-console-pilot" + } + } + } +} as const satisfies { + readonly version: string; + readonly adapters: Record; +}; + +export type ProviderAdapterName = keyof typeof PROVIDER_ADAPTER_CATALOG.adapters; + +export function getProviderAdapterForPreset(preset: string): ProviderAdapterDefinition | undefined { + return Object.values(PROVIDER_ADAPTER_CATALOG.adapters).find((adapter) => adapter.preset === preset); +} diff --git a/tests/cli-parse.test.ts b/tests/cli-parse.test.ts index 28cc53ee..4db2893e 100644 --- a/tests/cli-parse.test.ts +++ b/tests/cli-parse.test.ts @@ -165,6 +165,22 @@ describe("CLI parser", () => { }); it("parses all init-only onboarding options before or after init, including equals values", () => { + expect( + parseCli([ + "init", + "gsc", + "--preset=google-search-console", + "--oauth-client-secrets-file=/Users/example/.config/gsc/client-secrets.json" + ]) + ).toEqual({ + kind: "run", + command: "init", + options: { + name: "gsc", + preset: "google-search-console", + oauthClientSecretsFile: "/Users/example/.config/gsc/client-secrets.json" + } + }); expect( parseCli([ "--interactive", @@ -239,6 +255,7 @@ describe("CLI parser", () => { expect(initHelp).toContain("--url "); expect(initHelp).toContain("--header-name "); expect(initHelp).toContain("--header-prefix "); + expect(initHelp).toContain("--oauth-client-secrets-file "); expect(renderCommandHelp("connection add")).toContain("miftah connection add"); expect(renderCommandHelp("connection add")).toContain("--scope "); expect(renderCommandHelp("auth reauth")).toContain("--non-interactive"); @@ -280,6 +297,7 @@ describe("CLI parser", () => { expectUsageError(["init", "--url"]); expectUsageError(["init", "--header-name"]); expectUsageError(["init", "--header-prefix"]); + expectUsageError(["init", "--oauth-client-secrets-file"]); expectUsageError(["init", "--interactive", "--interactive"]); expectUsageError(["init", "--client=cursor", "--client=cursor"]); expectUsageError(["init", "--credential-env=MCP_TOKEN", "--credential-env=MCP_TOKEN"]); @@ -292,6 +310,11 @@ describe("CLI parser", () => { expectUsageError(["init", "--url=https://one.example", "--url=https://two.example"]); expectUsageError(["init", "--header-name=Authorization", "--header-name=Authorization"]); expectUsageError(["init", "--header-prefix=Bearer", "--header-prefix=Bearer"]); + expectUsageError([ + "init", + "--oauth-client-secrets-file=/one.json", + "--oauth-client-secrets-file=/two.json" + ]); expectUsageError(["validate", "unexpected"]); expectUsageError(["connection"]); expectUsageError(["connection", "unknown"]); diff --git a/tests/init-command.test.ts b/tests/init-command.test.ts index d3f80861..a80e4324 100644 --- a/tests/init-command.test.ts +++ b/tests/init-command.test.ts @@ -221,6 +221,35 @@ describe("init command", () => { expect(() => validateConfig(fileConfig)).not.toThrow(); }); + it("creates the GSC pilot and prints ownership guidance without exposing the configured path", async () => { + const streams = createStreams(); + const output = resolve(outputRoot, "gsc.json"); + const clientSecretsFile = resolve(outputRoot, "private", "client-secrets.json"); + + await runInitCommand( + { + name: "gsc", + preset: "google-search-console", + output: "gsc.json", + oauthClientSecretsFile: clientSecretsFile + }, + commandContext(streams) + ); + streams.input.end(); + + const config = validateConfig(JSON.parse(await readFile(output, "utf8"))); + expect(config.upstream?.args).toEqual(["mcp-search-console@0.3.2"]); + expect(streams.transcript.contents).toContain("Provider adapter: Google Search Console"); + expect(streams.transcript.contents).toContain("Credential ownership: upstream"); + expect(streams.transcript.contents).toContain("Browser handoff: upstream"); + expect(streams.transcript.contents).toContain("Token store: upstream-private"); + expect(streams.transcript.contents).toContain("Identity evidence: unavailable"); + expect(streams.transcript.contents).toContain("Reauthentication: upstream MCP tool 'reauthenticate'"); + expect(streams.transcript.contents).toContain("Disconnect/revocation: manual-only"); + expect(streams.transcript.contents).toContain("Miftah will not read or manage the upstream token cache."); + expect(streams.transcript.contents).not.toContain(clientSecretsFile); + }); + it("normalizes wizard EOF and SIGINT cancellation to usage errors without creating files", async () => { const eofStreams = createStreams(); eofStreams.input.end(); diff --git a/tests/package-contract.test.ts b/tests/package-contract.test.ts index 0cf3073b..c4cb5bed 100644 --- a/tests/package-contract.test.ts +++ b/tests/package-contract.test.ts @@ -73,6 +73,7 @@ const requiredPackPaths = [ "dist/plugin-host.js", "docs/cli.md", "docs/library-api.md", + "docs/provider-adapters.md", "docs/plugins.md", "examples/generic.miftah.json", "examples/plugins.miftah.json", @@ -1084,6 +1085,37 @@ describe("packed artifact contract", () => { expect(validatedInit.stderr).toBe(""); expect(JSON.parse(validatedInit.stdout)).toMatchObject({ ok: true, name: "starter config with spaces" }); + const gscOutputPath = join(cliContractDirectory, "gsc pilot.json"); + const gscClientSecretsPath = join(cliContractDirectory, "private client secrets.json"); + const initializedGsc = runInstalledBinaryThroughShell( + binary, + [ + "init", + "gsc-pilot", + "--preset", + "google-search-console", + "--oauth-client-secrets-file", + gscClientSecretsPath, + "--output", + gscOutputPath + ], + cliContractDirectory + ); + expect(initializedGsc.status, initializedGsc.stderr || initializedGsc.stdout).toBe(0); + expect(initializedGsc.stderr).toBe(""); + expect(initializedGsc.stdout).toContain("Credential ownership: upstream"); + expect(initializedGsc.stdout).not.toContain(gscClientSecretsPath); + expect(JSON.parse(await readFile(gscOutputPath, "utf8"))).toMatchObject({ + name: "gsc-pilot", + upstream: { command: "uvx", args: ["mcp-search-console@0.3.2"] }, + profiles: { + default: { + env: { GSC_OAUTH_CLIENT_SECRETS_FILE: gscClientSecretsPath }, + policy: "readonly" + } + } + }); + const automationConfigPath = await writeCliConfig( "automation config with spaces.json", cliConfig("packed-cli-automation", { diff --git a/tests/preset-catalog.test.ts b/tests/preset-catalog.test.ts index e54df523..09043acb 100644 --- a/tests/preset-catalog.test.ts +++ b/tests/preset-catalog.test.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { buildPresetConfig, @@ -11,13 +12,16 @@ function serializedConfig(config: unknown): string { return JSON.stringify(config); } +const gscClientSecretsFile = resolve("fixtures", "gsc", "client-secrets.json"); + describe("preset catalog", () => { it("publishes one versioned catalog with inspectable preset requirements", () => { - expect(PRESET_CATALOG.version).toBe("1"); + expect(PRESET_CATALOG.version).toBe("2"); expect(Object.keys(PRESET_CATALOG.presets)).toEqual([ "generic", "github", "sentry", + "google-search-console", "generic-npx", "generic-docker", "streamable-http" @@ -25,6 +29,7 @@ describe("preset catalog", () => { expect(PRESET_CATALOG.presets["generic-npx"].requirements.npmPackage).toBe("required"); expect(PRESET_CATALOG.presets["generic-docker"].requirements.dockerImage).toBe("required"); expect(PRESET_CATALOG.presets["streamable-http"].requirements.url).toBe("required"); + expect(PRESET_CATALOG.presets["google-search-console"].requirements.oauthClientSecretsFile).toBe("required"); }); it("builds every catalog config as a valid strict Miftah config without literal secrets", () => { @@ -33,6 +38,9 @@ describe("preset catalog", () => { buildPresetConfig("generic", "generic", genericOptions), buildPresetConfig("github", "github"), buildPresetConfig("sentry", "sentry"), + buildPresetConfig("gsc", "google-search-console", { + oauthClientSecretsFile: gscClientSecretsFile + }), buildPresetConfig("npx", "generic-npx", { npmPackage: "@scope/server@1.2.3", credentialEnv: "NPM_SERVER_TOKEN" @@ -83,6 +91,41 @@ describe("preset catalog", () => { }); }); + it("builds the exact pinned GSC pilot without enabling destructive tools or Miftah native OAuth", () => { + const config = buildPresetConfig("gsc", "google-search-console", { + oauthClientSecretsFile: gscClientSecretsFile + }); + + expect(config.upstream).toEqual({ + transport: "stdio", + command: "uvx", + args: ["mcp-search-console@0.3.2"] + }); + expect(config.profiles.default).toMatchObject({ + description: "Google Search Console account (OAuth owned by upstream)", + env: { GSC_OAUTH_CLIENT_SECRETS_FILE: gscClientSecretsFile }, + policy: "readonly" + }); + expect(config).not.toHaveProperty("oauth"); + expect(config.profiles.default?.env).not.toHaveProperty("GSC_ALLOW_DESTRUCTIVE"); + expect(() => validateConfig(config)).not.toThrow(); + }); + + it("requires one safe absolute OAuth client-secrets file path for the GSC pilot", () => { + expect(() => buildPresetConfig("gsc", "google-search-console")).toThrow(PresetCatalogError); + for (const value of [ + "client-secrets.json", + "", + " /tmp/client.json", + "/tmp/client.json\nignored", + resolve("fixtures", "${HOME}", "client-secrets.json") + ] as const) { + expect(() => buildPresetConfig("gsc", "google-search-console", { oauthClientSecretsFile: value })).toThrow( + PresetCatalogError + ); + } + }); + it("requires and validates exact generic preset inputs", () => { expect(() => buildPresetConfig("npx", "generic-npx", { npmPackage: "server@1.2.3" })).not.toThrow(); expect(() => buildPresetConfig("npx", "generic-npx", { npmPackage: "@scope/server@1.2.3" })).not.toThrow(); @@ -229,6 +272,11 @@ describe("preset catalog", () => { { dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } ], ["streamable-http", "url", { url: "https://mcp.example.com/v1" }], + [ + "google-search-console", + "oauthClientSecretsFile", + { oauthClientSecretsFile: gscClientSecretsFile } + ], [ "streamable-http", "headerName", @@ -271,6 +319,7 @@ describe("preset catalog", () => { ["generic", { npmPackage: "server@1.2.3" }], ["github", { credentialEnv: "GITHUB_TOKEN" }], ["sentry", { credentialEnv: "SENTRY_TOKEN" }], + ["google-search-console", { oauthClientSecretsFile: gscClientSecretsFile, credentialEnv: "GSC_TOKEN" }], ["generic-npx", { npmPackage: "server@1.2.3", dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }], ["generic-docker", { dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", url: "https://mcp.example.com" }], ["streamable-http", { url: "https://mcp.example.com", npmPackage: "server@1.2.3" }] diff --git a/tests/preset-docs-contract.test.ts b/tests/preset-docs-contract.test.ts index 3026ce21..da2b9a43 100644 --- a/tests/preset-docs-contract.test.ts +++ b/tests/preset-docs-contract.test.ts @@ -18,6 +18,8 @@ describe("preset documentation contract", () => { const readme = readRepositoryFile("README.md"); const cli = readRepositoryFile("docs/cli.md"); const claudeDesktop = readRepositoryFile("docs/claude-desktop.md"); + const providerAdapters = readRepositoryFile("docs/provider-adapters.md"); + const oauthSupport = readRepositoryFile("docs/oauth-support.md"); const changelog = readRepositoryFile("CHANGELOG.md"); const packageManifest = parseRepositoryJson("package.json") as { version: string }; @@ -43,7 +45,9 @@ describe("preset documentation contract", () => { "--read-only", "repos,issues,pull_requests", "@sentry/mcp-server@0.36.0", - "--skills=inspect" + "--skills=inspect", + "mcp-search-console@0.3.2", + "GSC_OAUTH_CLIENT_SECRETS_FILE" ]) { expect(compatibility).toContain(requiredFact); } @@ -58,6 +62,9 @@ describe("preset documentation contract", () => { "[Sentry `0.36.0` CLI usage](https://github.com/getsentry/sentry-mcp/blob/0.36.0/packages/mcp-server/src/cli/usage.ts)", "[MCP Everything source](https://github.com/modelcontextprotocol/servers/tree/main/src/everything)", "[MCP Everything npm package](https://www.npmjs.com/package/@modelcontextprotocol/server-everything)", + "[Google Search Console MCP source](https://github.com/AminForou/mcp-gsc)", + "[Google Search Console MCP package](https://pypi.org/project/mcp-search-console/0.3.2/)", + "[uv tool version pinning](https://docs.astral.sh/uv/guides/tools/#installing-tools)", "[Claude Code MCP](https://code.claude.com/docs/en/mcp)", "[Cursor MCP](https://cursor.com/docs/mcp)", "[VS Code MCP servers](https://code.visualstudio.com/docs/agent-customization/mcp-servers)", @@ -84,7 +91,8 @@ describe("preset documentation contract", () => { "--docker-image", "--url", "--header-name", - "--header-prefix" + "--header-prefix", + "--oauth-client-secrets-file" ]) { expect(cli).toContain(option); } @@ -95,9 +103,21 @@ describe("preset documentation contract", () => { const issue19 = changelogIssueEntry(changelog, 19); const issue98 = changelogIssueEntry(changelog, 98); + const issue87 = changelogIssueEntry(changelog, 87); expect(issue19).toContain("catalog"); expect(issue19).toContain("onboarding"); expect(issue98).toContain("permission"); expect(issue98).not.toContain("runtime construction"); + expect(issue87).toContain("upstream-owned"); + expect(providerAdapters).toContain("## Google Search Console pilot"); + expect(providerAdapters).toContain("Credential ownership | Upstream"); + expect(providerAdapters).toContain("Miftah never reads, copies, exports, or deletes that cache"); + expect(providerAdapters).toContain("GSC_CREDENTIALS_PATH"); + expect(providerAdapters).toContain("GSC_SKIP_OAUTH=true"); + expect(providerAdapters).toContain("get_capabilities"); + expect(providerAdapters).toContain("reauthenticate"); + expect(providerAdapters).toContain("GSC_ALLOW_DESTRUCTIVE"); + expect(oauthSupport).not.toContain("No provider-adapter API exists today"); + expect(oauthSupport).toContain("Google Search Console pilot"); }); }); diff --git a/tests/provider-adapter-contract.test.ts b/tests/provider-adapter-contract.test.ts new file mode 100644 index 00000000..93b699a8 --- /dev/null +++ b/tests/provider-adapter-contract.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { PROVIDER_ADAPTER_CATALOG } from "../src/config/provider-adapters.js"; +import type { + ProviderAdapterDefinition, + ProviderAdapterOperation, + ProviderAuthenticationContract +} from "../src/config/provider-adapters.js"; + +// @ts-expect-error Upstream-owned credentials cannot claim Miftah's browser or vault. +const invalidMixedOwnership: ProviderAuthenticationContract = { + credentialOwnership: "upstream", + browserHandoff: "miftah", + tokenStore: "miftah-vault" +}; +void invalidMixedOwnership; + +// @ts-expect-error MCP-tool operations must name the upstream tool to invoke. +const invalidUnnamedMcpTool: ProviderAdapterOperation = { + owner: "upstream", + mechanism: "mcp-tool" +}; +void invalidUnnamedMcpTool; + +// @ts-expect-error Unavailable identity evidence cannot claim verified assurance. +const invalidIdentityAssurance: ProviderAdapterDefinition["identity"] = { + evidence: "unavailable", + assurance: "verified" +}; +void invalidIdentityAssurance; + +describe("provider adapter contract", () => { + it("describes the GSC pilot as upstream-owned without claiming native OAuth or identity proof", () => { + const adapter = PROVIDER_ADAPTER_CATALOG.adapters["google-search-console"]; + + expect(PROVIDER_ADAPTER_CATALOG.version).toBe("1"); + expect(adapter.preset).toBe("google-search-console"); + expect(adapter.launch).toEqual({ + transport: "stdio", + command: "uvx", + args: ["mcp-search-console@0.3.2"], + prerequisites: ["Python >=3.11", "uv/uvx", "Google OAuth desktop client-secrets JSON"] + }); + expect(adapter.authentication).toEqual({ + credentialOwnership: "upstream", + browserHandoff: "upstream", + tokenStore: "upstream-private" + }); + expect(adapter.lifecycle).toEqual({ + health: { owner: "upstream", mechanism: "mcp-tool", name: "get_capabilities" }, + reauth: { owner: "upstream", mechanism: "mcp-tool", name: "reauthenticate" }, + disconnect: { owner: "manual-only", mechanism: "provider-console" } + }); + expect(adapter.identity).toEqual({ evidence: "unavailable", assurance: "none" }); + expect(adapter.diagnostics).toEqual({ + mode: "metadata-only", + tokenCacheAccess: "forbidden", + safeHealthTool: "get_capabilities" + }); + expect(adapter.destructiveTools).toEqual({ + default: "disabled", + enablement: "manual-only", + upstreamEnvironmentControl: "GSC_ALLOW_DESTRUCTIVE" + }); + expect(adapter.manualSetup.supported).toBe(true); + }); +});