From 0330079103a21ab2d0b8199d1147301a6a4c3a58 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 14 Oct 2026 12:06:38 +0800 Subject: [PATCH] feat(mcp): add client registration via jevwise mcp install Introduce a one-command registration subcommand that registers the MCP HTTP endpoint with an installed `codex` or `claude` client CLI. Both default to user-wide settings, support custom ports via `--listen`, and Claude additionally accepts `--scope` for local or project settings. Registration stores only an environment reference (`JEVWISE_MCP_TOKEN`), never the token value, and refuses to overwrite an existing `jevwise` entry. It does not start MCP, export the token, or verify connectivity; users still load the token and verify the connection separately. Wire the new `install` subcommand into the `mcp` command group and document it in the README, agent onboarding guide, and command table. --- README.md | 1 + docs/mcp-agents.md | 20 +++++ docs/mcp.md | 1 + internal/cli/mcp.go | 2 +- internal/cli/mcp_install.go | 85 +++++++++++++++++++ internal/cli/mcp_install_test.go | 138 +++++++++++++++++++++++++++++++ 6 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 internal/cli/mcp_install.go create mode 100644 internal/cli/mcp_install_test.go diff --git a/README.md b/README.md index 05aa63a..e25193d 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ api_key = "$ABCD_TOKEN" - Use a separate bearer token: **never reuse the upstream API key or give it to the agent**. - With no token override, `jevwise mcp` generates a random token in `/agent.token` and reuses it across restarts. Only your upstream API key needs manual setup. - `jevwise mcp token-path` prints the automatic file path, never the secret; see [agent onboarding](docs/mcp-agents.md) to load it into the client environment. +- Register the installed client with `jevwise mcp install codex` or `jevwise mcp install claude`. Both default to user-wide settings; custom ports use `--listen IP:PORT`. Registration stores only an environment reference, not the token. See [agent onboarding](docs/mcp-agents.md#3-choose-a-client). - Overrides take precedence: `--token-file /path/to/protected/agent-token` > `JEVWISE_MCP_TOKEN` (including aliases) > automatic token. Explicit values are never copied into the automatic file. - MCP fallback names, in order: `JEVWISE_MCP_AUTH_TOKEN`, `JEV_MCP_TOKEN`. Invalid nonempty tokens fail without fallback; tokens must be printable ASCII without whitespace. - Keep secrets out of shell history, logs and stored agent configuration. Follow [local MCP setup](docs/mcp.md) and [agent onboarding](docs/mcp-agents.md) for protected files and client environment references. diff --git a/docs/mcp-agents.md b/docs/mcp-agents.md index 9871676..4e10d85 100644 --- a/docs/mcp-agents.md +++ b/docs/mcp-agents.md @@ -46,6 +46,26 @@ Clients read the token from their process environment. Launch the client from th ## 3. Choose a client +### One-command registration (current checkout) + +After starting MCP and loading the token in your client terminal, run: + +```sh +jevwise mcp install codex +jevwise mcp install claude +# Match a custom server port: +jevwise mcp install codex --listen 127.0.0.1:9090 +# Claude-only scopes: +jevwise mcp install claude --scope local # Current project, private settings +jevwise mcp install claude --scope project # Shared .mcp.json, requires client approval +``` + +- Both default to user-wide settings. Requires the selected `codex` or `claude` executable on PATH; Claude Code must support `--bare`. Standard client configuration overrides (`CODEX_HOME`, `CLAUDE_CONFIG_DIR`) are honored. +- Uses native registration commands, preserving unrelated settings. An existing `jevwise` entry is refused; inspect it before intentionally removing/updating it with the client's own commands. Configuration assumes trusted clients and cooperative writers; do not run competing installers/settings edits concurrently. +- Stores `JEVWISE_MCP_TOKEN` as an environment reference, never its value. The installer does not export the token into your shell, read Jev configuration, start MCP, verify connectivity or make a paid decision. Use section 2 to load the token before launching your client. `--listen` must match the running server; it does not change the server port. +- Child processes do not inherit API keys or MCP tokens. Client output is bounded and withheld, including diagnostics; failures may have partially registered an entry, so inspect settings before retrying. `--timeout` bounds registration (default `10s`), not a server/model request. Only the direct process is managed, not wrapper descendants. +- Manual setup remains available below. Installed registration is not proof of a live authenticated connection; verify via `/mcp` without calling `decide`. + ### Codex Register the HTTP endpoint, storing the **environment variable name**, not its value: diff --git a/docs/mcp.md b/docs/mcp.md index 4b716a6..4e285f6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -30,6 +30,7 @@ jevwise mcp stop | `jevwise mcp` | Foreground server; prints the public `/mcp` endpoint after startup. Ctrl+C stops it. | | `--background` | Starts a terminal-independent child after authenticated readiness and ownership checks. | | `jevwise mcp status` | Authenticates the exact instance and reports its startup/running state. v1.2.0 and newer also show the recorded PID when available. | +| `jevwise mcp install codex` / `claude` | Register the HTTP endpoint using an installed client CLI; no server/model startup or token export. | | `jevwise mcp token-path` | Prints only the automatic token path; does not read credentials, create files or report explicit overrides. | | `jevwise mcp stop` | Authenticates the exact instance and requests shutdown; an acknowledgement is not a promise that cleanup has already finished. | | `--listen` | Literal loopback IP and nonzero port; default `127.0.0.1:8080`. | diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index b815f7c..17bae28 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -134,7 +134,7 @@ func newMCPWithBackground(factory ServiceFactory, run mcpRunner, start mcpStarte runtimeDir = filepath.Join(cache, "jevwise-mcp") } cmd.PersistentFlags().StringVar(&runtimeDir, "runtime-dir", runtimeDir, "Private per-user MCP background runtime directory") - cmd.AddCommand(newMCPManagement("status", &runtimeDir), newMCPManagement("stop", &runtimeDir), newMCPTokenPath(&runtimeDir)) + cmd.AddCommand(newMCPManagement("status", &runtimeDir), newMCPManagement("stop", &runtimeDir), newMCPTokenPath(&runtimeDir), newMCPInstall(runAgentCommand)) return cmd } diff --git a/internal/cli/mcp_install.go b/internal/cli/mcp_install.go new file mode 100644 index 0000000..2cc94b4 --- /dev/null +++ b/internal/cli/mcp_install.go @@ -0,0 +1,85 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "io" + "time" + + "github.com/bitbrew-dev/jevwise/internal/debuglog" + "github.com/bitbrew-dev/jevwise/internal/mcpserver" + "github.com/spf13/cobra" +) + +func newMCPInstall(run agentCommand) *cobra.Command { + var address, scope string + noArgs := func(_ *cobra.Command, args []string) error { + if len(args) != 0 { + return errors.New("MCP install accepts only codex or claude, without positional arguments") + } + return nil + } + cmd := &cobra.Command{Use: "install", Short: "Register Jevwise with an installed MCP client", Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Help() }} + cmd.PersistentFlags().StringVar(&address, "listen", "127.0.0.1:8080", "Server's literal loopback IP:port (same as mcp --listen)") + cmd.PersistentFlags().StringVar(&scope, "scope", "user", "Client settings scope: user; Claude also supports local or project") + for _, client := range []string{"codex", "claude"} { + cmd.AddCommand(&cobra.Command{Use: client, Short: "Register Jevwise with " + client, Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := cmd.Context().Err(); err != nil { + return err + } + canonical, err := mcpserver.ValidateAddress(address) + if err != nil { + return err + } + if scope != "user" && (client != "claude" || scope != "local" && scope != "project") { + return errors.New("invalid MCP install scope; Codex supports user only") + } + duration, err := time.ParseDuration(cmd.Flags().Lookup("timeout").Value.String()) + if err != nil || duration <= 0 { + return errors.New("MCP installation timeout must be a positive duration") + } + ctx, cancel := context.WithTimeout(cmd.Context(), duration) + defer cancel() + endpoint := "http://" + canonical + "/mcp" + debuglog.Event(ctx, "mcp.install.register") + if client == "codex" { + data, inspectErr := run(ctx, client, "mcp", "list", "--json") + var servers []struct{ Name string } + if inspectErr != nil || len(data) > agentOutputLimit || json.Unmarshal(data, &servers) != nil || servers == nil { + return &decisionError{"cannot inspect Codex MCP settings; check installed client and configuration", inspectErr} + } + for _, server := range servers { + if server.Name == "" { + return errors.New("invalid Codex MCP inventory") + } + if server.Name == "jevwise" { + return errors.New("Codex MCP entry jevwise already exists; inspect it before changing configuration") + } + } + if err := ctx.Err(); err != nil { + return err + } + _, err = run(ctx, client, "mcp", "add", "jevwise", "--url", endpoint, "--bearer-token-env-var", "JEVWISE_MCP_TOKEN") + } else { + data, _ := json.Marshal(map[string]any{"type": "http", "url": endpoint, "headers": map[string]string{"Authorization": "Bearer ${JEVWISE_MCP_TOKEN}"}}) + _, err = run(ctx, client, "--bare", "mcp", "add-json", "--scope", scope, "jevwise", string(data)) + } + if err != nil { + return &decisionError{"cannot register MCP client; check client installation, settings and duplicate entry (diagnostics withheld)", err} + } + line := "Registered Jevwise MCP for " + client + ". Load JEVWISE_MCP_TOKEN in the client environment before launch.\n" + n, err := io.WriteString(cmd.OutOrStdout(), line) + if err == nil && n != len(line) { + err = io.ErrShortWrite + } + if err != nil { + return &decisionError{"MCP client registered but confirmation could not be written", err} + } + return ctx.Err() + }}) + } + return cmd +} diff --git a/internal/cli/mcp_install_test.go b/internal/cli/mcp_install_test.go new file mode 100644 index 0000000..1843c70 --- /dev/null +++ b/internal/cli/mcp_install_test.go @@ -0,0 +1,138 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/bitbrew-dev/jevwise/internal/config" + "github.com/bitbrew-dev/jevwise/internal/service" + "github.com/spf13/cobra" +) + +func installerRoot(t *testing.T, run agentCommand) *cobra.Command { + t.Helper() + root := NewRootWithFactory(func(config.Config) (service.DecisionService, func(), error) { + t.Fatal("installation initialized upstream service") + return nil, nil, nil + }) + mcp, _, _ := root.Find([]string{"mcp"}) + for _, child := range mcp.Commands() { + if child.Name() == "install" { + mcp.RemoveCommand(child) + } + } + child := newMCPInstall(run) + mcp.AddCommand(child) + instrumentCommands(child) + return root +} + +func TestMCPInstallClientArgumentsAndNoCredentialOutput(t *testing.T) { + for _, client := range []string{"codex", "claude"} { + calls := 0 + run := func(ctx context.Context, name string, args ...string) ([]byte, error) { + calls++ + if _, ok := ctx.Deadline(); !ok || name != client { + t.Error("installer lacks timeout or selected wrong client") + } + if client == "codex" && calls == 1 { + if strings.Join(args, " ") != "mcp list --json" { + t.Error("Codex preflight changed") + } + return []byte(`[{"name":"unrelated"}]`), nil + } + if client == "codex" { + if strings.Join(args, " ") != "mcp add jevwise --url http://127.0.0.1:9090/mcp --bearer-token-env-var JEVWISE_MCP_TOKEN" { + t.Error("Codex registration changed") + } + } else { + var entry struct { + Type, URL string + Headers map[string]string + } + if len(args) != 7 || strings.Join(args[:6], " ") != "--bare mcp add-json --scope user jevwise" || json.Unmarshal([]byte(args[6]), &entry) != nil || entry.Type != "http" || entry.URL != "http://127.0.0.1:9090/mcp" || entry.Headers["Authorization"] != "Bearer ${JEVWISE_MCP_TOKEN}" { + t.Error("Claude literal environment reference or scope changed") + } + } + return []byte("private-client-output"), nil + } + out, stderr, err := execute(installerRoot(t, run), "mcp", "install", client, "--listen", "127.0.0.1:9090", "--debug", "--config", "/missing", "--api-key", "private-key") + if err != nil || !strings.Contains(out, "Registered") || !strings.Contains(stderr, "mcp.install.register") || strings.Contains(out+stderr, "private") { + t.Fatal("installation failed or leaked client output", err) + } + } +} + +func TestMCPInstallPreflightAndClientFailuresNeverOverwriteOrLeak(t *testing.T) { + cause := errors.New("private-client-error") + for _, data := range []string{`[{"name":"jevwise"}]`, `null`, `{}`, `[{}]`, `private-invalid`, strings.Repeat("x", agentOutputLimit+1)} { + calls := 0 + run := func(context.Context, string, ...string) ([]byte, error) { calls++; return []byte(data), nil } + out, stderr, err := execute(installerRoot(t, run), "mcp", "install", "codex") + if err == nil || calls != 1 || out != "" || stderr != "" || strings.Contains(err.Error(), "private") { + t.Fatal("bad inventory overwrote settings or leaked", err) + } + } + for _, client := range []string{"codex", "claude"} { + run := func(_ context.Context, _ string, args ...string) ([]byte, error) { + if len(args) > 1 && args[1] == "list" { + return []byte(`[]`), nil + } + return []byte("private-output"), cause + } + out, stderr, err := execute(installerRoot(t, run), "mcp", "install", client) + if !errors.Is(err, cause) || out != "" || stderr != "" || strings.Contains(err.Error(), "private") { + t.Fatal("client failure not safely retained", err) + } + } +} + +func TestMCPInstallLazyHelpAndInvalidInput(t *testing.T) { + run := func(context.Context, string, ...string) ([]byte, error) { + t.Fatal("lazy or invalid command ran client") + return nil, nil + } + for _, args := range [][]string{{"mcp", "install"}, {"mcp", "install", "--help"}, {"mcp", "install", "codex", "--help"}, {"mcp", "install", "claude", "--help"}} { + if _, _, err := execute(installerRoot(t, run), args...); err != nil { + t.Fatal("installer help not lazy", err) + } + } + for _, extra := range [][]string{{"--listen", "0.0.0.0:9090"}, {"--listen", "localhost:9090"}, {"--scope", "local"}, {"--timeout", "invalid"}, {"private-argument"}} { + if _, _, err := execute(installerRoot(t, run), append([]string{"mcp", "install", "codex"}, extra...)...); err == nil || strings.Contains(err.Error(), "private") { + t.Fatal("invalid input accepted or echoed", err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + root := installerRoot(t, run) + root.SetArgs([]string{"mcp", "install", "claude"}) + if err := root.ExecuteContext(ctx); err != context.Canceled { + t.Fatal("pre-canceled installer executed client", err) + } +} + +func TestMCPInstallClaudeScopesAndCanceledPreflight(t *testing.T) { + for _, scope := range []string{"local", "project"} { + run := func(_ context.Context, _ string, args ...string) ([]byte, error) { + if args[4] != scope || !strings.Contains(args[6], "http://[::1]:9090/mcp") { + t.Error("Claude scope or IPv6 endpoint changed") + } + return nil, nil + } + if _, _, err := execute(installerRoot(t, run), "mcp", "install", "claude", "--scope", scope, "--listen", "[::1]:9090"); err != nil { + t.Fatal(err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + calls := 0 + run := func(context.Context, string, ...string) ([]byte, error) { calls++; cancel(); return []byte(`[]`), nil } + root := installerRoot(t, run) + root.SetArgs([]string{"mcp", "install", "codex"}) + if err := root.ExecuteContext(ctx); err != context.Canceled || calls != 1 { + t.Fatal("canceled preflight still registered client", err) + } +}