From bb7f77801a0f52fc80abde2db85f7b172ce481f9 Mon Sep 17 00:00:00 2001 From: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:59:47 +0300 Subject: [PATCH 1/9] feat(mcp): scaffold ypcli mcp command and add the Go MCP SDK --- go.mod | 1 + go.sum | 4 ++-- internal/cli/mcp.go | 28 ++++++++++++++++++++++++++++ internal/cli/root.go | 1 + 4 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 internal/cli/mcp.go diff --git a/go.mod b/go.mod index 70f57ca..fe991e3 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect diff --git a/go.sum b/go.sum index 9ffe7a7..fe68a87 100644 --- a/go.sum +++ b/go.sum @@ -65,8 +65,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go new file mode 100644 index 0000000..ebe7668 --- /dev/null +++ b/internal/cli/mcp.go @@ -0,0 +1,28 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func (a *app) newMCPCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Run an MCP server exposing ypcli to AI agents", + Long: "Serve ypcli's send/receive operations over the Model Context Protocol\n" + + "so agents (Claude, Codex, Gemini, …) can share and fetch secrets. Uses\n" + + "stdio by default, or HTTP with --http for a shared server.", + Args: cobra.NoArgs, + RunE: a.runMCP, + } + f := cmd.Flags() + f.String("http", "", "serve over HTTP at this address instead of stdio (e.g. 127.0.0.1:8765)") + f.String("http-token", "", "bearer token required in HTTP mode ($YPCLI_MCP_TOKEN)") + f.Bool("read-only", false, "expose send-only tools (omit receive_secret)") + return cmd +} + +func (a *app) runMCP(_ *cobra.Command, _ []string) error { + return fmt.Errorf("mcp server not implemented yet") +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f15ebfe..2deff49 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -92,6 +92,7 @@ func (a *app) newRootCmd() *cobra.Command { a.newReceiveCmd(), a.newConfigCmd(), a.newVersionCmd(), + a.newMCPCmd(), ) return root } From 8955242b65f1959100509a5888a21efbf509b14e Mon Sep 17 00:00:00 2001 From: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:05:46 +0300 Subject: [PATCH 2/9] refactor(share): extract send/receive core into internal/share The key/argon2/encrypt/create/URL and fetch/decrypt glue moves from the cobra commands into a transport-agnostic internal/share service (with its own httptest round-trip tests) so the upcoming MCP server reuses identical logic. CLI behavior is unchanged; all unit and e2e tests pass. --- internal/cli/helpers.go | 3 - internal/cli/receive.go | 33 +++---- internal/cli/send.go | 113 ++++----------------- internal/crypto/crypto.go | 11 +++ internal/share/share.go | 170 ++++++++++++++++++++++++++++++++ internal/share/share_test.go | 186 +++++++++++++++++++++++++++++++++++ 6 files changed, 397 insertions(+), 119 deletions(-) create mode 100644 internal/share/share.go create mode 100644 internal/share/share_test.go diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go index 98c963e..557ccbc 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -1,7 +1,6 @@ package cli import ( - "bytes" "encoding/json" "fmt" "io" @@ -19,8 +18,6 @@ func newClient(baseAPI, token string) *api.Client { return api.New(baseAPI, api.WithToken(token)) } -func readerOf(b []byte) io.Reader { return bytes.NewReader(b) } - func stringReader(s string) io.Reader { return strings.NewReader(s) } // encodeJSON writes v as indented JSON to the command's stdout. diff --git a/internal/cli/receive.go b/internal/cli/receive.go index 0904e15..3f5fc6c 100644 --- a/internal/cli/receive.go +++ b/internal/cli/receive.go @@ -12,6 +12,7 @@ import ( "github.com/dantte-lp/ypcli/internal/config" "github.com/dantte-lp/ypcli/internal/crypto" "github.com/dantte-lp/ypcli/internal/output" + "github.com/dantte-lp/ypcli/internal/share" "github.com/spf13/cobra" ) @@ -63,48 +64,40 @@ func (a *app) runReceive(cmd *cobra.Command, args []string) error { } func (a *app) receiveText(ctx context.Context, cmd *cobra.Command, s *settings, client *api.Client, id, key string) error { - msg, err := client.FetchSecret(ctx, id) - if err != nil { - return err - } - plaintext, _, err := crypto.Decrypt(stringReader(msg), key) + res, err := share.Receive(ctx, client, share.Target{ID: id, Key: key}) if err != nil { return err } if out, _ := cmd.Flags().GetString("output"); out != "" { - if err := writeFile(out, []byte(plaintext)); err != nil { + if err := writeFile(out, []byte(res.Content)); err != nil { return err } - return s.printer(cmd).Receive(output.ReceiveResult{Written: out, Bytes: len(plaintext)}) + return s.printer(cmd).Receive(output.ReceiveResult{Written: out, Bytes: len(res.Content)}) } - return s.printer(cmd).Receive(output.ReceiveResult{Content: plaintext}) + return s.printer(cmd).Receive(output.ReceiveResult{Content: res.Content}) } func (a *app) receiveFile(ctx context.Context, cmd *cobra.Command, s *settings, client *api.Client, id, key string) error { - body, size, err := client.FetchFile(ctx, id) - if err != nil { - return err - } - defer body.Close() - - var src io.Reader = body + target := share.Target{ID: id, Key: key, File: true} if !s.jsonMode { - src = output.NewProgressReader(body, size, cmd.ErrOrStderr(), "downloading") + target.Wrap = func(r io.Reader, total int64) io.Reader { + return output.NewProgressReader(r, total, cmd.ErrOrStderr(), "downloading") + } } - plaintext, filename, err := crypto.Decrypt(src, key) + res, err := share.Receive(ctx, client, target) if err != nil { return err } out, _ := cmd.Flags().GetString("output") - dest := destPath(out, filename, id) - if err := writeFile(dest, []byte(plaintext)); err != nil { + dest := destPath(out, res.Filename, id) + if err := writeFile(dest, []byte(res.Content)); err != nil { return err } return s.printer(cmd).Receive(output.ReceiveResult{ - Written: dest, Filename: filename, Bytes: len(plaintext), + Written: dest, Filename: res.Filename, Bytes: len(res.Content), }) } diff --git a/internal/cli/send.go b/internal/cli/send.go index 12d540b..90f264b 100644 --- a/internal/cli/send.go +++ b/internal/cli/send.go @@ -6,14 +6,13 @@ import ( "fmt" "io" "os" - "path/filepath" "strings" - "github.com/dantte-lp/ypcli/internal/api" "github.com/dantte-lp/ypcli/internal/clipboard" "github.com/dantte-lp/ypcli/internal/config" "github.com/dantte-lp/ypcli/internal/crypto" "github.com/dantte-lp/ypcli/internal/output" + "github.com/dantte-lp/ypcli/internal/share" "github.com/dantte-lp/ypcli/internal/vault" "github.com/spf13/cobra" ) @@ -65,11 +64,7 @@ func (a *app) runSend(cmd *cobra.Command, _ []string) error { } oneTime := resolveOneTime(cmd, s.profile) requireAuth, _ := cmd.Flags().GetBool("require-auth") - - key, manualKey, err := resolveKey(cmd) - if err != nil { - return err - } + keyFlag, _ := cmd.Flags().GetString("key") ctx, cancel := context.WithTimeout(cmd.Context(), s.timeout) defer cancel() @@ -79,64 +74,45 @@ func (a *app) runSend(cmd *cobra.Command, _ []string) error { return err } client := newClient(s.api, token) - useArgon2 := resolveArgon2(ctx, client, s.profile) - s.log.Debug("sending secret", "api", s.api, "argon2", useArgon2, - "one_time", oneTime, "expiration", expirationLabel(exp), "authenticated", token != "") + opts := share.Options{ + Key: keyFlag, Expiration: exp, OneTime: oneTime, + RequireAuth: requireAuth, Argon2: s.profile.Argon2, + } + s.log.Debug("sending secret", "api", s.api, "one_time", oneTime, + "expiration", crypto.ExpirationLabel(exp), "authenticated", token != "") vaultPath, _ := cmd.Flags().GetString("vault-path") inputCommand, _ := cmd.Flags().GetString("input-command") filePath, _ := cmd.Flags().GetString("file") - var ( - id string - fileOpt bool - ) + var res share.SendResult switch { case vaultPath != "": var secret string - secret, err = readFromVault(ctx, cmd, vaultPath, s.profile) - if err == nil { - id, err = sendMessage(ctx, client, stringReader(secret), key, exp, oneTime, requireAuth, useArgon2) + if secret, err = readFromVault(ctx, cmd, vaultPath, s.profile); err == nil { + res, err = share.SendText(ctx, client, s.url, stringReader(secret), opts) } case inputCommand != "": var secret string if secret, err = config.RunCommand(ctx, inputCommand); err != nil { err = fmt.Errorf("input-command failed: %w", err) } else { - id, err = sendMessage(ctx, client, stringReader(secret), key, exp, oneTime, requireAuth, useArgon2) + res, err = share.SendText(ctx, client, s.url, stringReader(secret), opts) } case filePath != "": - id, err = sendFile(ctx, client, filePath, key, exp, oneTime, useArgon2) - fileOpt = true + res, err = share.SendFile(ctx, client, s.url, filePath, opts) default: var r io.Reader - r, err = textReader(ctx, cmd) - if err == nil { - id, err = sendMessage(ctx, client, r, key, exp, oneTime, requireAuth, useArgon2) + if r, err = textReader(ctx, cmd); err == nil { + res, err = share.SendText(ctx, client, s.url, r, opts) } } if err != nil { return err } - shareURL := crypto.SecretURL(s.url, id, key, fileOpt, manualKey) return emitSend(cmd, s, output.SendResult{ - ID: id, URL: shareURL, Key: key, ManualKey: manualKey, - File: fileOpt, OneTime: oneTime, Expiration: expirationLabel(exp), - }) -} - -// sendMessage encrypts the plaintext from r and stores it as a text secret. -func sendMessage(ctx context.Context, client *api.Client, r io.Reader, key string, exp int32, oneTime, requireAuth, argon2 bool) (string, error) { - enc := crypto.Encrypt - if argon2 { - enc = crypto.EncryptWithArgon2 - } - msg, err := enc(r, key) - if err != nil { - return "", fmt.Errorf("encrypt secret: %w", err) - } - return client.CreateSecret(ctx, api.Secret{ - Message: msg, Expiration: exp, OneTime: oneTime, RequireAuth: requireAuth, + ID: res.ID, URL: res.URL, Key: res.Key, ManualKey: res.ManualKey, + File: res.File, OneTime: res.OneTime, Expiration: res.Expiration, }) } @@ -173,24 +149,6 @@ func readFromVault(ctx context.Context, cmd *cobra.Command, path string, prof co return val, err } -func sendFile(ctx context.Context, client *api.Client, path, key string, exp int32, oneTime, argon2 bool) (string, error) { - f, err := os.Open(path) //nolint:gosec // path provided by the user by design - if err != nil { - return "", fmt.Errorf("open file: %w", err) - } - defer f.Close() - - encBin := crypto.EncryptBinary - if argon2 { - encBin = crypto.EncryptBinaryWithArgon2 - } - data, err := encBin(f, key, filepath.Base(path)) - if err != nil { - return "", fmt.Errorf("encrypt file: %w", err) - } - return client.CreateFile(ctx, readerOf(data), exp, oneTime) -} - // textReader returns the plaintext source. Priority: --text, then piped stdin; // when stdin is a terminal (or --editor is set) it opens the user's editor. func textReader(ctx context.Context, cmd *cobra.Command) (io.Reader, error) { @@ -256,30 +214,6 @@ func resolveOneTime(cmd *cobra.Command, p config.Profile) bool { return true } -func resolveKey(cmd *cobra.Command) (key string, manual bool, err error) { - if k, _ := cmd.Flags().GetString("key"); k != "" { - return k, true, nil - } - k, err := crypto.GenerateKey() - if err != nil { - return "", false, fmt.Errorf("generate key: %w", err) - } - return k, false, nil -} - -// resolveArgon2 uses the profile override when set, else asks the server via -// /config. A failed lookup falls back to the default (non-Argon2) derivation, -// which every yopass server accepts. -func resolveArgon2(ctx context.Context, client *api.Client, p config.Profile) bool { - if p.Argon2 != nil { - return *p.Argon2 - } - if cfg, err := client.Config(ctx); err == nil { - return cfg.Argon2 - } - return false -} - func changedString(cmd *cobra.Command, name string) string { if cmd.Flags().Changed(name) { v, _ := cmd.Flags().GetString(name) @@ -287,16 +221,3 @@ func changedString(cmd *cobra.Command, name string) string { } return "" } - -func expirationLabel(seconds int32) string { - switch seconds { - case 3600: - return "1h" - case 86400: - return "1d" - case 604800: - return "1w" - default: - return fmt.Sprintf("%ds", seconds) - } -} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index e24e506..195c670 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -84,6 +84,17 @@ func ValidExpirationSeconds(seconds int32) bool { return false } +// ExpirationLabel is the inverse of ExpirationSeconds: it renders seconds as a +// human-readable duration ("1h"/"1d"/"1w"), falling back to "s". +func ExpirationLabel(seconds int32) string { + for label, ttl := range expirations { + if ttl == seconds { + return label + } + } + return fmt.Sprintf("%ds", seconds) +} + // Encrypt reads plaintext from r and returns ASCII-armored ciphertext. func Encrypt(r io.Reader, key string) (string, error) { return encrypt(r, key, pgpConfig) diff --git a/internal/share/share.go b/internal/share/share.go new file mode 100644 index 0000000..6d49035 --- /dev/null +++ b/internal/share/share.go @@ -0,0 +1,170 @@ +// Package share holds the transport-agnostic core of publishing and fetching +// yopass secrets: key generation, Argon2 selection, encryption, the API calls +// and share-URL assembly. Both the cobra CLI and the MCP server build on it so +// the behavior is identical regardless of entry point. +package share + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/dantte-lp/ypcli/internal/api" + "github.com/dantte-lp/ypcli/internal/crypto" +) + +// Options controls a send operation. +type Options struct { + Key string // manual key; empty means generate one + Expiration int32 // seconds (1h/1d/1w) + OneTime bool + RequireAuth bool + Argon2 *bool // nil = auto-detect from the server /config +} + +// SendResult describes a published secret. +type SendResult struct { + ID string + URL string + Key string + ManualKey bool + File bool + OneTime bool + Expiration string +} + +// SendText encrypts the plaintext from r and publishes it as a text secret. +func SendText(ctx context.Context, client *api.Client, publicURL string, r io.Reader, o Options) (SendResult, error) { + key, manual, err := key(o.Key) + if err != nil { + return SendResult{}, err + } + enc := crypto.Encrypt + if useArgon2(ctx, client, o.Argon2) { + enc = crypto.EncryptWithArgon2 + } + msg, err := enc(r, key) + if err != nil { + return SendResult{}, fmt.Errorf("encrypt secret: %w", err) + } + id, err := client.CreateSecret(ctx, api.Secret{ + Message: msg, Expiration: o.Expiration, OneTime: o.OneTime, RequireAuth: o.RequireAuth, + }) + if err != nil { + return SendResult{}, err + } + return result(id, key, publicURL, manual, false, o), nil +} + +// SendFile encrypts the file at path and publishes it as a file secret. +func SendFile(ctx context.Context, client *api.Client, publicURL, path string, o Options) (SendResult, error) { + key, manual, err := key(o.Key) + if err != nil { + return SendResult{}, err + } + f, err := os.Open(path) //nolint:gosec // path provided by the user by design + if err != nil { + return SendResult{}, fmt.Errorf("open file: %w", err) + } + defer f.Close() + + encBin := crypto.EncryptBinary + if useArgon2(ctx, client, o.Argon2) { + encBin = crypto.EncryptBinaryWithArgon2 + } + data, err := encBin(f, key, filepath.Base(path)) + if err != nil { + return SendResult{}, fmt.Errorf("encrypt file: %w", err) + } + id, err := client.CreateFile(ctx, strings.NewReader(string(data)), o.Expiration, o.OneTime) + if err != nil { + return SendResult{}, err + } + return result(id, key, publicURL, manual, true, o), nil +} + +// Target identifies a secret to receive. +type Target struct { + ID string + Key string + File bool + // Wrap optionally wraps the file download stream (e.g. a progress reader). + Wrap func(r io.Reader, total int64) io.Reader +} + +// ReceiveResult is the decrypted secret. +type ReceiveResult struct { + Content string + Filename string + File bool +} + +// Receive fetches and decrypts a secret. +func Receive(ctx context.Context, client *api.Client, t Target) (ReceiveResult, error) { + if t.File { + body, size, err := client.FetchFile(ctx, t.ID) + if err != nil { + return ReceiveResult{}, err + } + defer body.Close() + + var src io.Reader = body + if t.Wrap != nil { + src = t.Wrap(body, size) + } + plaintext, filename, err := crypto.Decrypt(src, t.Key) + if err != nil { + return ReceiveResult{}, err + } + return ReceiveResult{Content: plaintext, Filename: filename, File: true}, nil + } + + msg, err := client.FetchSecret(ctx, t.ID) + if err != nil { + return ReceiveResult{}, err + } + plaintext, _, err := crypto.Decrypt(strings.NewReader(msg), t.Key) + if err != nil { + return ReceiveResult{}, err + } + return ReceiveResult{Content: plaintext, File: false}, nil +} + +// key returns the encryption key: the manual key if given, else a fresh one. +func key(manual string) (k string, isManual bool, err error) { + if manual != "" { + return manual, true, nil + } + k, err = crypto.GenerateKey() + if err != nil { + return "", false, fmt.Errorf("generate key: %w", err) + } + return k, false, nil +} + +// useArgon2 honors an explicit override, else asks the server /config; a failed +// lookup falls back to the default derivation, which every server accepts. +func useArgon2(ctx context.Context, client *api.Client, override *bool) bool { + if override != nil { + return *override + } + if cfg, err := client.Config(ctx); err == nil { + return cfg.Argon2 + } + return false +} + +func result(id, k, publicURL string, manual, file bool, o Options) SendResult { + return SendResult{ + ID: id, + URL: crypto.SecretURL(publicURL, id, k, file, manual), + Key: k, + ManualKey: manual, + File: file, + OneTime: o.OneTime, + Expiration: crypto.ExpirationLabel(o.Expiration), + } +} diff --git a/internal/share/share_test.go b/internal/share/share_test.go new file mode 100644 index 0000000..0be6b3a --- /dev/null +++ b/internal/share/share_test.go @@ -0,0 +1,186 @@ +package share + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + + "github.com/dantte-lp/ypcli/internal/api" +) + +// fakeYopass is a minimal in-memory yopass API for share tests. +type fakeYopass struct { + mu sync.Mutex + secrets map[string]string + files map[string][]byte + argon2 bool + n int +} + +func newFake(argon2 bool) *fakeYopass { + return &fakeYopass{secrets: map[string]string{}, files: map[string][]byte{}, argon2: argon2} +} + +func (f *fakeYopass) id() string { f.n++; return "id" + string(rune('a'+f.n)) } + +func (f *fakeYopass) server(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/config", func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"ARGON2": f.argon2}) + }) + mux.HandleFunc("/create/secret", func(w http.ResponseWriter, r *http.Request) { + var b struct { + Message string `json:"message"` + } + json.NewDecoder(r.Body).Decode(&b) + f.mu.Lock() + id := f.id() + f.secrets[id] = b.Message + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]string{"message": id}) + }) + mux.HandleFunc("/create/file", func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + f.mu.Lock() + id := f.id() + f.files[id] = data + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]string{"message": id}) + }) + mux.HandleFunc("/secret/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/secret/") + f.mu.Lock() + msg, ok := f.secrets[id] + delete(f.secrets, id) + f.mu.Unlock() + if !ok { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"message": "gone"}) + return + } + json.NewEncoder(w).Encode(map[string]string{"message": msg}) + }) + mux.HandleFunc("/file/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/file/") + f.mu.Lock() + data, ok := f.files[id] + delete(f.files, id) + f.mu.Unlock() + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.Write(data) + }) + return httptest.NewServer(mux) +} + +func TestSendTextReceiveRoundTrip(t *testing.T) { + fake := newFake(true) // argon2 advertised → auto-detected + srv := fake.server(t) + defer srv.Close() + client := api.New(srv.URL, api.WithHTTPClient(srv.Client())) + ctx := context.Background() + + res, err := SendText(ctx, client, srv.URL, strings.NewReader("top secret"), + Options{Expiration: 3600, OneTime: true}) + if err != nil { + t.Fatalf("SendText: %v", err) + } + if res.File || res.ManualKey || res.Key == "" || res.Expiration != "1h" || !res.OneTime { + t.Errorf("unexpected result %+v", res) + } + if !strings.Contains(res.URL, "/#/s/") || !strings.Contains(res.URL, res.Key) { + t.Errorf("url = %q", res.URL) + } + + got, err := Receive(ctx, client, Target{ID: res.ID, Key: res.Key}) + if err != nil { + t.Fatalf("Receive: %v", err) + } + if got.Content != "top secret" || got.File { + t.Errorf("received %+v", got) + } +} + +func TestSendFileReceiveRoundTrip(t *testing.T) { + fake := newFake(false) + srv := fake.server(t) + defer srv.Close() + client := api.New(srv.URL, api.WithHTTPClient(srv.Client())) + ctx := context.Background() + + dir := t.TempDir() + path := dir + "/creds.env" + if err := os.WriteFile(path, []byte("USER=admin\n"), 0o600); err != nil { + t.Fatal(err) + } + + res, err := SendFile(ctx, client, srv.URL, path, Options{Expiration: 86400}) + if err != nil { + t.Fatalf("SendFile: %v", err) + } + if !res.File || !strings.Contains(res.URL, "/#/f/") || res.Expiration != "1d" { + t.Errorf("unexpected result %+v", res) + } + + var wrapped bool + got, err := Receive(ctx, client, Target{ + ID: res.ID, Key: res.Key, File: true, + Wrap: func(r io.Reader, _ int64) io.Reader { wrapped = true; return r }, + }) + if err != nil { + t.Fatalf("Receive: %v", err) + } + if got.Content != "USER=admin\n" || got.Filename != "creds.env" || !got.File { + t.Errorf("received %+v", got) + } + if !wrapped { + t.Error("Wrap should be invoked for file downloads") + } +} + +func TestSendManualKeyAndArgon2Override(t *testing.T) { + fake := newFake(false) + srv := fake.server(t) + defer srv.Close() + client := api.New(srv.URL, api.WithHTTPClient(srv.Client())) + ctx := context.Background() + + forceArgon2 := true + res, err := SendText(ctx, client, srv.URL, strings.NewReader("x"), + Options{Key: "manual-key-1234567890", Expiration: 3600, Argon2: &forceArgon2}) + if err != nil { + t.Fatalf("SendText: %v", err) + } + if !res.ManualKey || strings.Contains(res.URL, res.Key) { + t.Errorf("manual key should be omitted from URL: %+v", res) + } + got, err := Receive(ctx, client, Target{ID: res.ID, Key: "manual-key-1234567890"}) + if err != nil || got.Content != "x" { + t.Errorf("received %q err %v", got.Content, err) + } +} + +func TestReceiveConsumedIsNotFound(t *testing.T) { + fake := newFake(false) + srv := fake.server(t) + defer srv.Close() + client := api.New(srv.URL, api.WithHTTPClient(srv.Client())) + ctx := context.Background() + + res, _ := SendText(ctx, client, srv.URL, strings.NewReader("once"), Options{Expiration: 3600, OneTime: true}) + if _, err := Receive(ctx, client, Target{ID: res.ID, Key: res.Key}); err != nil { + t.Fatalf("first receive: %v", err) + } + if _, err := Receive(ctx, client, Target{ID: res.ID, Key: res.Key}); err == nil { + t.Error("second receive should fail (consumed)") + } +} From 824f3ef6457e6aa8eedbbd27d38bb6e23788a7d4 Mon Sep 17 00:00:00 2001 From: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:10:55 +0300 Subject: [PATCH 3/9] feat(mcp): MCP server core with send/receive/list/version tools internal/mcpserver builds an mcp.Server (official Go SDK) exposing send_secret, send_file, receive_secret (omitted with --read-only), list_profiles and server_version, reusing internal/share. Config/profile resolved per call from the host ypcli config. ypcli mcp serves it over stdio. In-memory client round-trip tests cover every tool. --- go.mod | 7 +- go.sum | 12 ++ internal/cli/mcp.go | 20 +- internal/mcpserver/server.go | 298 ++++++++++++++++++++++++++++++ internal/mcpserver/server_test.go | 212 +++++++++++++++++++++ 5 files changed, 546 insertions(+), 3 deletions(-) create mode 100644 internal/mcpserver/server.go create mode 100644 internal/mcpserver/server_test.go diff --git a/go.mod b/go.mod index fe991e3..9d0959c 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/ProtonMail/go-crypto v1.4.1 github.com/atotto/clipboard v0.1.4 github.com/jhaals/yopass v0.0.0-20260715042249-feed1b4adf28 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -16,17 +17,21 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.54.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index fe68a87..a5537a8 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= @@ -83,6 +85,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -106,6 +110,10 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= @@ -127,6 +135,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/zitadel/logging v0.7.0 h1:eugftwMM95Wgqwftsvj81isL0JK/hoScVqp/7iA2adQ= github.com/zitadel/logging v0.7.0/go.mod h1:9A6h9feBF/3u0IhA4uffdzSDY7mBaf7RE78H5sFMINQ= github.com/zitadel/oidc/v3 v3.47.8 h1:1IcKnNsBzrUpyPyIJeNwBwa2ncrjOHQB4rdV8zuTWLk= @@ -159,6 +169,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index ebe7668..63697c2 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -3,6 +3,8 @@ package cli import ( "fmt" + "github.com/dantte-lp/ypcli/internal/mcpserver" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" ) @@ -23,6 +25,20 @@ func (a *app) newMCPCmd() *cobra.Command { return cmd } -func (a *app) runMCP(_ *cobra.Command, _ []string) error { - return fmt.Errorf("mcp server not implemented yet") +func (a *app) runMCP(cmd *cobra.Command, _ []string) error { + readOnly, _ := cmd.Flags().GetBool("read-only") + cfgPath, err := configPath(cmd.Root()) + if err != nil { + return err + } + srv := mcpserver.New(mcpserver.Options{ + ConfigPath: cfgPath, + ReadOnly: readOnly, + Version: a.build.Version, + }) + + if addr, _ := cmd.Flags().GetString("http"); addr != "" { + return fmt.Errorf("--http mode is not implemented yet") + } + return srv.Run(cmd.Context(), &mcp.StdioTransport{}) } diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..8771fc2 --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,298 @@ +// Package mcpserver builds an MCP server that exposes ypcli's send/receive +// operations as tools, so AI agents (Claude, Codex, Gemini, …) can share and +// fetch yopass secrets. It reuses internal/share, so behavior is identical to +// the CLI. Connection settings come from the ypcli config profiles on the host. +package mcpserver + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + "unicode/utf8" + + "github.com/dantte-lp/ypcli/internal/api" + "github.com/dantte-lp/ypcli/internal/config" + "github.com/dantte-lp/ypcli/internal/crypto" + "github.com/dantte-lp/ypcli/internal/share" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Options configures the MCP server. +type Options struct { + ConfigPath string // path to the ypcli config file + ReadOnly bool // omit receive_secret when true (send-only deployments) + Version string // client build version, reported by server_version +} + +// New builds the MCP server and registers ypcli's tools. +func New(o Options) *mcp.Server { + s := mcp.NewServer(&mcp.Implementation{Name: "ypcli", Version: o.Version}, nil) + r := ®istry{o: o} + + mcp.AddTool(s, &mcp.Tool{ + Name: "send_secret", + Description: "Encrypt and publish a text secret to yopass, returning a one-time share URL.", + }, r.sendSecret) + mcp.AddTool(s, &mcp.Tool{ + Name: "send_file", + Description: "Encrypt and publish a file (by path) to yopass, returning a one-time share URL.", + }, r.sendFile) + if !o.ReadOnly { + mcp.AddTool(s, &mcp.Tool{ + Name: "receive_secret", + Description: "Fetch and decrypt a yopass secret by share URL or id+key. " + + "NOTE: one-time secrets are consumed (deleted) on the first successful fetch.", + }, r.receiveSecret) + } + mcp.AddTool(s, &mcp.Tool{ + Name: "list_profiles", + Description: "List the configured ypcli server profiles.", + }, r.listProfiles) + mcp.AddTool(s, &mcp.Tool{ + Name: "server_version", + Description: "Report the ypcli client version and the target yopass server version.", + }, r.serverVersion) + + return s +} + +type registry struct{ o Options } + +// ---- tool inputs / outputs ------------------------------------------------- + +type sendSecretInput struct { + Text string `json:"text" jsonschema:"the secret text to share"` + Profile string `json:"profile,omitempty" jsonschema:"config profile to use (optional; default active)"` + Expiration string `json:"expiration,omitempty" jsonschema:"lifetime: 1h, 1d or 1w (default 1h)"` + OneTime *bool `json:"one_time,omitempty" jsonschema:"delete after first view (default true)"` + RequireAuth bool `json:"require_auth,omitempty" jsonschema:"require authentication to view (server support needed)"` +} + +type sendFileInput struct { + Path string `json:"path" jsonschema:"absolute path to the file to share"` + Profile string `json:"profile,omitempty" jsonschema:"config profile to use (optional; default active)"` + Expiration string `json:"expiration,omitempty" jsonschema:"lifetime: 1h, 1d or 1w (default 1h)"` + OneTime *bool `json:"one_time,omitempty" jsonschema:"delete after first view (default true)"` +} + +type sendOutput struct { + URL string `json:"url" jsonschema:"the one-time share URL to hand to the recipient"` + ID string `json:"id"` + Key string `json:"key" jsonschema:"the decryption key (embedded in the URL unless a manual key was used)"` + Expiration string `json:"expiration"` + OneTime bool `json:"one_time"` + File bool `json:"file"` +} + +type receiveInput struct { + URL string `json:"url,omitempty" jsonschema:"a yopass share URL"` + ID string `json:"id,omitempty" jsonschema:"secret id (when no url is given)"` + Key string `json:"key,omitempty" jsonschema:"decryption key (required for id, or for manual-key URLs)"` + File bool `json:"file,omitempty" jsonschema:"treat as a file secret (with id)"` + Profile string `json:"profile,omitempty" jsonschema:"config profile to use (optional; default active)"` +} + +type receiveOutput struct { + Content string `json:"content,omitempty" jsonschema:"the decrypted secret (for UTF-8 text)"` + ContentBase64 string `json:"content_base64,omitempty" jsonschema:"base64 of the decrypted bytes (for binary payloads)"` + Filename string `json:"filename,omitempty" jsonschema:"embedded filename, for file secrets"` + File bool `json:"file"` +} + +type noInput struct{} + +type profileInfo struct { + Name string `json:"name"` + API string `json:"api"` + URL string `json:"url"` + Active bool `json:"active"` +} + +type listProfilesOutput struct { + Profiles []profileInfo `json:"profiles"` +} + +type versionInput struct { + Profile string `json:"profile,omitempty" jsonschema:"config profile to use (optional; default active)"` +} + +type versionOutput struct { + Client string `json:"client"` + Server string `json:"server"` +} + +// ---- handlers -------------------------------------------------------------- + +func (r *registry) sendSecret(ctx context.Context, _ *mcp.CallToolRequest, in sendSecretInput) (*mcp.CallToolResult, sendOutput, error) { + client, publicURL, prof, err := r.clientFor(ctx, in.Profile) + if err != nil { + return nil, sendOutput{}, err + } + exp, err := expiration(in.Expiration, prof) + if err != nil { + return nil, sendOutput{}, err + } + res, err := share.SendText(ctx, client, publicURL, strings.NewReader(in.Text), share.Options{ + Expiration: exp, OneTime: oneTime(in.OneTime, prof), RequireAuth: in.RequireAuth, Argon2: prof.Argon2, + }) + if err != nil { + return nil, sendOutput{}, err + } + return nil, toSendOutput(res), nil +} + +func (r *registry) sendFile(ctx context.Context, _ *mcp.CallToolRequest, in sendFileInput) (*mcp.CallToolResult, sendOutput, error) { + client, publicURL, prof, err := r.clientFor(ctx, in.Profile) + if err != nil { + return nil, sendOutput{}, err + } + exp, err := expiration(in.Expiration, prof) + if err != nil { + return nil, sendOutput{}, err + } + res, err := share.SendFile(ctx, client, publicURL, in.Path, share.Options{ + Expiration: exp, OneTime: oneTime(in.OneTime, prof), Argon2: prof.Argon2, + }) + if err != nil { + return nil, sendOutput{}, err + } + return nil, toSendOutput(res), nil +} + +func (r *registry) receiveSecret(ctx context.Context, _ *mcp.CallToolRequest, in receiveInput) (*mcp.CallToolResult, receiveOutput, error) { + client, _, _, err := r.clientFor(ctx, in.Profile) + if err != nil { + return nil, receiveOutput{}, err + } + id, key, file, err := target(in) + if err != nil { + return nil, receiveOutput{}, err + } + res, err := share.Receive(ctx, client, share.Target{ID: id, Key: key, File: file}) + if err != nil { + return nil, receiveOutput{}, err + } + out := receiveOutput{Filename: res.Filename, File: res.File} + if utf8.ValidString(res.Content) { + out.Content = res.Content + } else { + out.ContentBase64 = base64.StdEncoding.EncodeToString([]byte(res.Content)) + } + return nil, out, nil +} + +func (r *registry) listProfiles(_ context.Context, _ *mcp.CallToolRequest, _ noInput) (*mcp.CallToolResult, listProfilesOutput, error) { + cfg, err := config.Load(r.o.ConfigPath) + if err != nil { + return nil, listProfilesOutput{}, err + } + out := listProfilesOutput{Profiles: []profileInfo{}} + for name, p := range cfg.Profiles { + out.Profiles = append(out.Profiles, profileInfo{ + Name: name, + API: firstNonEmpty(p.API, cfg.Defaults.API), + URL: firstNonEmpty(p.URL, cfg.Defaults.URL), + Active: name == cfg.Active, + }) + } + return nil, out, nil +} + +func (r *registry) serverVersion(ctx context.Context, _ *mcp.CallToolRequest, in versionInput) (*mcp.CallToolResult, versionOutput, error) { + client, _, _, err := r.clientFor(ctx, in.Profile) + if err != nil { + return nil, versionOutput{}, err + } + server := "unknown" + if v, verr := client.Version(ctx); verr == nil { + server = v + } + return nil, versionOutput{Client: r.o.Version, Server: server}, nil +} + +// ---- helpers --------------------------------------------------------------- + +// clientFor loads the config, resolves the (effective) profile, sources its +// token, and builds an API client plus the public share URL. +func (r *registry) clientFor(ctx context.Context, profileName string) (*api.Client, string, config.Profile, error) { + cfg, err := config.Load(r.o.ConfigPath) + if err != nil { + return nil, "", config.Profile{}, err + } + prof, err := cfg.Effective(profileName) + if err != nil { + return nil, "", config.Profile{}, err + } + token, err := config.ResolveToken(ctx, "", prof.TokenCommand) + if err != nil { + return nil, "", prof, err + } + apiBase := strings.TrimSuffix(firstNonEmpty(prof.API, config.DefaultAPI), "/") + publicURL := strings.TrimSuffix(firstNonEmpty(prof.URL, config.DefaultURL), "/") + + opts := []api.Option{} + if token != "" { + opts = append(opts, api.WithToken(token)) + } + return api.New(apiBase, opts...), publicURL, prof, nil +} + +func expiration(label string, p config.Profile) (int32, error) { + label = firstNonEmpty(label, p.Expiration, config.DefaultExpiration) + seconds, ok := crypto.ExpirationSeconds(label) + if !ok { + return 0, fmt.Errorf("invalid expiration %q: use 1h, 1d or 1w", label) + } + return seconds, nil +} + +func oneTime(in *bool, p config.Profile) bool { + if in != nil { + return *in + } + if p.OneTime != nil { + return *p.OneTime + } + return true +} + +func target(in receiveInput) (id, key string, file bool, err error) { + if in.URL != "" { + var keyOpt bool + id, key, file, keyOpt, err = crypto.ParseURL(in.URL) + if err != nil { + return "", "", false, err + } + if keyOpt || key == "" { + if in.Key == "" { + return "", "", false, fmt.Errorf("this link needs a manual key: set key") + } + key = in.Key + } + return id, key, file, nil + } + if in.ID == "" { + return "", "", false, fmt.Errorf("provide a url or an id") + } + if in.Key == "" { + return "", "", false, fmt.Errorf("key is required with id") + } + return in.ID, in.Key, in.File, nil +} + +func toSendOutput(res share.SendResult) sendOutput { + return sendOutput{ + URL: res.URL, ID: res.ID, Key: res.Key, + Expiration: res.Expiration, OneTime: res.OneTime, File: res.File, + } +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..637750e --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,212 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// fakeYopass is a minimal in-memory yopass API for MCP server tests. +type fakeYopass struct { + mu sync.Mutex + secrets map[string]string + files map[string][]byte + n int +} + +func fakeServer(t *testing.T) (*httptest.Server, *fakeYopass) { + t.Helper() + f := &fakeYopass{secrets: map[string]string{}, files: map[string][]byte{}} + mux := http.NewServeMux() + mux.HandleFunc("/config", func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"ARGON2": true}) + }) + mux.HandleFunc("/version", func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(map[string]string{"version": "fake-13.0"}) + }) + mux.HandleFunc("/create/secret", func(w http.ResponseWriter, r *http.Request) { + var b struct { + Message string `json:"message"` + } + json.NewDecoder(r.Body).Decode(&b) + f.mu.Lock() + f.n++ + id := "s" + string(rune('a'+f.n)) + f.secrets[id] = b.Message + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]string{"message": id}) + }) + mux.HandleFunc("/create/file", func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + f.mu.Lock() + f.n++ + id := "f" + string(rune('a'+f.n)) + f.files[id] = data + f.mu.Unlock() + json.NewEncoder(w).Encode(map[string]string{"message": id}) + }) + mux.HandleFunc("/secret/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/secret/") + f.mu.Lock() + msg, ok := f.secrets[id] + delete(f.secrets, id) + f.mu.Unlock() + if !ok { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"message": "gone"}) + return + } + json.NewEncoder(w).Encode(map[string]string{"message": msg}) + }) + mux.HandleFunc("/file/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/file/") + f.mu.Lock() + data, ok := f.files[id] + delete(f.files, id) + f.mu.Unlock() + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.Write(data) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, f +} + +// connect wires an in-memory client to a fresh server pointed at cfgPath. +func connect(t *testing.T, opts Options) *mcp.ClientSession { + t.Helper() + ctx := context.Background() + server := New(opts) + ct, st := mcp.NewInMemoryTransports() + ss, err := server.Connect(ctx, st, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + t.Cleanup(func() { ss.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil) + cs, err := client.Connect(ctx, ct, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + t.Cleanup(func() { cs.Close() }) + return cs +} + +func call(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any, dst any) *mcp.CallToolResult { + t.Helper() + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("call %s: %v", name, err) + } + if res.IsError { + t.Fatalf("call %s returned tool error: %+v", name, res.Content) + } + if dst != nil { + b, _ := json.Marshal(res.StructuredContent) + if err := json.Unmarshal(b, dst); err != nil { + t.Fatalf("decode %s output: %v", name, err) + } + } + return res +} + +func writeConfig(t *testing.T, apiURL string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + body := "defaults:\n api: " + apiURL + "\n url: " + apiURL + "\n" + + "active: work\nprofiles:\n work: {}\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestSendReceiveRoundTrip(t *testing.T) { + srv, _ := fakeServer(t) + cfg := writeConfig(t, srv.URL) + cs := connect(t, Options{ConfigPath: cfg, Version: "test-1.0"}) + + var sent sendOutput + call(t, cs, "send_secret", map[string]any{"text": "hello mcp", "expiration": "1d"}, &sent) + if !strings.Contains(sent.URL, "/#/s/") || sent.Expiration != "1d" || !sent.OneTime { + t.Fatalf("send output = %+v", sent) + } + if !strings.HasPrefix(sent.URL, srv.URL) { + t.Errorf("url not on configured server: %s", sent.URL) + } + + var got receiveOutput + call(t, cs, "receive_secret", map[string]any{"url": sent.URL}, &got) + if got.Content != "hello mcp" || got.File { + t.Errorf("receive output = %+v", got) + } +} + +func TestSendFileRoundTrip(t *testing.T) { + srv, _ := fakeServer(t) + cfg := writeConfig(t, srv.URL) + cs := connect(t, Options{ConfigPath: cfg, Version: "test"}) + + file := filepath.Join(t.TempDir(), "creds.env") + if err := os.WriteFile(file, []byte("USER=admin\n"), 0o600); err != nil { + t.Fatal(err) + } + var sent sendOutput + call(t, cs, "send_file", map[string]any{"path": file}, &sent) + if !sent.File || !strings.Contains(sent.URL, "/#/f/") { + t.Fatalf("send_file output = %+v", sent) + } + var got receiveOutput + call(t, cs, "receive_secret", map[string]any{"url": sent.URL}, &got) + if got.Content != "USER=admin\n" || got.Filename != "creds.env" || !got.File { + t.Errorf("receive output = %+v", got) + } +} + +func TestListProfilesAndVersion(t *testing.T) { + srv, _ := fakeServer(t) + cfg := writeConfig(t, srv.URL) + cs := connect(t, Options{ConfigPath: cfg, Version: "test-1.0"}) + + var list listProfilesOutput + call(t, cs, "list_profiles", map[string]any{}, &list) + if len(list.Profiles) != 1 || list.Profiles[0].Name != "work" || !list.Profiles[0].Active { + t.Errorf("profiles = %+v", list.Profiles) + } + + var ver versionOutput + call(t, cs, "server_version", map[string]any{}, &ver) + if ver.Client != "test-1.0" || ver.Server != "fake-13.0" { + t.Errorf("version = %+v", ver) + } +} + +func TestReadOnlyOmitsReceive(t *testing.T) { + srv, _ := fakeServer(t) + cfg := writeConfig(t, srv.URL) + cs := connect(t, Options{ConfigPath: cfg, ReadOnly: true, Version: "test"}) + + res, err := cs.ListTools(context.Background(), nil) + if err != nil { + t.Fatalf("list tools: %v", err) + } + for _, tool := range res.Tools { + if tool.Name == "receive_secret" { + t.Error("receive_secret must be absent in read-only mode") + } + } +} From 3a383b4b5bc0261f4258ca85440529b295459991 Mon Sep 17 00:00:00 2001 From: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:26:07 +0300 Subject: [PATCH 4/9] feat(mcp): HTTP transport with bearer auth and systemd deploy ypcli mcp --http serves the MCP server over Streamable HTTP; a constant-time bearer-token middleware gates it (required, from --http-token/$YPCLI_MCP_TOKEN). Graceful shutdown on signal. Adds a hardened systemd unit (DynamicUser, ProtectSystem=strict, no caps, syscall filter) and deploy/README. --- deploy/README.md | 73 ++++++++++++++++++++++++++++++++ deploy/systemd/ypcli-mcp.service | 44 +++++++++++++++++++ internal/cli/mcp.go | 27 +++++++++++- internal/mcpserver/http.go | 32 ++++++++++++++ internal/mcpserver/http_test.go | 53 +++++++++++++++++++++++ 5 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 deploy/README.md create mode 100644 deploy/systemd/ypcli-mcp.service create mode 100644 internal/mcpserver/http.go create mode 100644 internal/mcpserver/http_test.go diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..e1bb570 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,73 @@ +# Deploying the ypcli MCP server + +Two ways to expose ypcli to AI agents (Claude, Codex, Gemini): + +- **stdio** (local) — the agent launches `ypcli mcp` as a subprocess. Nothing to + deploy beyond installing the binary and a profile. See + [docs/en/09-mcp.md](../docs/en/09-mcp.md). +- **HTTP** (shared server) — run `ypcli mcp --http` as a service that agents + connect to over the network with a bearer token. That is what this directory + covers. + +## Install the binary + +```bash +go install github.com/dantte-lp/ypcli/cmd/ypcli@latest +sudo install "$(go env GOPATH)/bin/ypcli" /usr/local/bin/ypcli # or a release binary +``` + +## Configure + +```bash +sudo mkdir -p /etc/ypcli + +# 1) Profile config — no plaintext secrets; use token_command for yopass auth. +sudo tee /etc/ypcli/config.yaml >/dev/null <<'YAML' +defaults: + api: https://api.yopass.corp + url: https://yopass.corp + # token_command: vault read -field=token secret/yopass # if the server needs auth +YAML +sudo chmod 0644 /etc/ypcli/config.yaml + +# 2) Bearer token for the HTTP endpoint (root-only). +printf 'YPCLI_MCP_TOKEN=%s\n' "$(openssl rand -hex 32)" | sudo tee /etc/ypcli/mcp.env >/dev/null +sudo chmod 0600 /etc/ypcli/mcp.env +``` + +## Run as a service + +```bash +sudo cp deploy/systemd/ypcli-mcp.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now ypcli-mcp +systemctl status ypcli-mcp +``` + +The unit runs under `DynamicUser` with a strict sandbox (`ProtectSystem=strict`, +`NoNewPrivileges`, no capabilities, filtered syscalls) and binds to +`127.0.0.1:8765` by default. + +## TLS / exposure + +The server speaks plain HTTP and binds to loopback. Put it behind a +TLS-terminating reverse proxy (nginx, Caddy, Traefik) if agents connect from +other hosts, and keep the bearer token secret. Example Caddy: + +```caddy +mcp.yopass.corp { + reverse_proxy 127.0.0.1:8765 +} +``` + +## Connect an agent + +Point the client at the URL with the bearer token — see +[docs/en/09-mcp.md](../docs/en/09-mcp.md#http-shared-server) and the ready-made +snippets in [`integrations/`](../integrations). + +```bash +# Claude Code +claude mcp add --transport http ypcli https://mcp.yopass.corp \ + --header "Authorization: Bearer $YPCLI_MCP_TOKEN" +``` diff --git a/deploy/systemd/ypcli-mcp.service b/deploy/systemd/ypcli-mcp.service new file mode 100644 index 0000000..82e6a78 --- /dev/null +++ b/deploy/systemd/ypcli-mcp.service @@ -0,0 +1,44 @@ +[Unit] +Description=ypcli MCP server (yopass secret sharing for AI agents) +Documentation=https://github.com/dantte-lp/ypcli +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# Runs as a transient, unprivileged user with no home or shell. +DynamicUser=yes +# YPCLI_MCP_TOKEN (bearer for the HTTP endpoint) lives here; keep it root:root 0600. +EnvironmentFile=/etc/ypcli/mcp.env +# The profile config (no plaintext secrets — use token_command for auth). +ExecStart=/usr/local/bin/ypcli mcp --http 127.0.0.1:8765 --config /etc/ypcli/config.yaml +Restart=on-failure +RestartSec=2 + +# --- hardening --- +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectClock=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectProc=invisible +RestrictAddressFamilies=AF_INET AF_INET6 +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +SystemCallArchitectures=native +CapabilityBoundingSet= +AmbientCapabilities= +ReadOnlyPaths=/etc/ypcli + +[Install] +WantedBy=multi-user.target diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index 63697c2..dec0133 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -1,7 +1,12 @@ package cli import ( + "context" + "errors" "fmt" + "net/http" + "os" + "time" "github.com/dantte-lp/ypcli/internal/mcpserver" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -38,7 +43,27 @@ func (a *app) runMCP(cmd *cobra.Command, _ []string) error { }) if addr, _ := cmd.Flags().GetString("http"); addr != "" { - return fmt.Errorf("--http mode is not implemented yet") + token := coalesce(changedString(cmd, "http-token"), os.Getenv("YPCLI_MCP_TOKEN")) + if token == "" { + return usage("--http requires a bearer token: set --http-token or $YPCLI_MCP_TOKEN") + } + return serveHTTP(cmd, addr, mcpserver.Handler(srv, token)) } return srv.Run(cmd.Context(), &mcp.StdioTransport{}) } + +// serveHTTP runs the MCP HTTP server until the command context is cancelled. +func serveHTTP(cmd *cobra.Command, addr string, h http.Handler) error { + server := &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 10 * time.Second} + go func() { + <-cmd.Context().Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + fmt.Fprintf(cmd.ErrOrStderr(), "ypcli mcp listening on %s\n", addr) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("mcp http server: %w", err) + } + return nil +} diff --git a/internal/mcpserver/http.go b/internal/mcpserver/http.go new file mode 100644 index 0000000..4c2d4fd --- /dev/null +++ b/internal/mcpserver/http.go @@ -0,0 +1,32 @@ +package mcpserver + +import ( + "crypto/subtle" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Handler serves the MCP server over Streamable HTTP. When token is non-empty +// (required for any network-exposed deployment) every request must carry +// "Authorization: Bearer ". +func Handler(srv *mcp.Server, token string) http.Handler { + base := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil) + if token == "" { + return base + } + return bearerAuth(token, base) +} + +func bearerAuth(token string, next http.Handler) http.Handler { + want := []byte("Bearer " + token) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := []byte(r.Header.Get("Authorization")) + if subtle.ConstantTimeCompare(got, want) != 1 { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/mcpserver/http_test.go b/internal/mcpserver/http_test.go new file mode 100644 index 0000000..539675b --- /dev/null +++ b/internal/mcpserver/http_test.go @@ -0,0 +1,53 @@ +package mcpserver + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestBearerAuth(t *testing.T) { + var reached bool + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + }) + h := bearerAuth("s3cr3t", next) + + cases := []struct { + name string + header string + want int + reached bool + }{ + {"no header", "", http.StatusUnauthorized, false}, + {"wrong token", "Bearer nope", http.StatusUnauthorized, false}, + {"missing scheme", "s3cr3t", http.StatusUnauthorized, false}, + {"valid", "Bearer s3cr3t", http.StatusOK, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + reached = false + req := httptest.NewRequest(http.MethodPost, "/", nil) + if c.header != "" { + req.Header.Set("Authorization", c.header) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != c.want { + t.Errorf("status = %d, want %d", rec.Code, c.want) + } + if reached != c.reached { + t.Errorf("reached = %v, want %v", reached, c.reached) + } + }) + } +} + +func TestHandlerNoTokenSkipsAuth(t *testing.T) { + // With an empty token the handler is the bare MCP handler (no auth wrapper). + h := Handler(New(Options{ConfigPath: "/nonexistent"}), "") + if h == nil { + t.Fatal("Handler returned nil") + } +} From af470004a25351952f716cbfc84c8e164dc77134 Mon Sep 17 00:00:00 2001 From: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:28:55 +0300 Subject: [PATCH 5/9] feat(mcp): Claude skill and Codex/Gemini integration assets skills/ypcli/SKILL.md (Agent Skill), integrations/ with ready-to-copy MCP config for Claude (.mcp.json), Codex (config.toml + prompt) and Gemini (settings.json + extension), each with stdio and HTTP variants. --- integrations/README.md | 23 ++++++++++++ integrations/claude/.mcp.json | 8 ++++ integrations/codex/config.toml | 12 ++++++ integrations/codex/prompts/share-secret.md | 6 +++ integrations/gemini/GEMINI.md | 13 +++++++ integrations/gemini/gemini-extension.json | 12 ++++++ integrations/gemini/settings.json | 9 +++++ skills/ypcli/SKILL.md | 43 ++++++++++++++++++++++ 8 files changed, 126 insertions(+) create mode 100644 integrations/README.md create mode 100644 integrations/claude/.mcp.json create mode 100644 integrations/codex/config.toml create mode 100644 integrations/codex/prompts/share-secret.md create mode 100644 integrations/gemini/GEMINI.md create mode 100644 integrations/gemini/gemini-extension.json create mode 100644 integrations/gemini/settings.json create mode 100644 skills/ypcli/SKILL.md diff --git a/integrations/README.md b/integrations/README.md new file mode 100644 index 0000000..1d5fe41 --- /dev/null +++ b/integrations/README.md @@ -0,0 +1,23 @@ +# Agent integrations + +Ready-to-copy configuration connecting AI agents to the `ypcli mcp` server. The +MCP server is universal — the same `ypcli mcp` binary works for every client. + +| Client | File | How | +|---|---|---| +| Claude Code | [`claude/.mcp.json`](claude/.mcp.json) | copy to your project root, or `claude mcp add ypcli -- ypcli mcp` | +| Claude (skill) | [`../skills/ypcli/`](../skills/ypcli) | copy to `~/.claude/skills/ypcli/` | +| Codex | [`codex/config.toml`](codex/config.toml) | merge into `~/.codex/config.toml`; prompt in [`codex/prompts/`](codex/prompts) | +| Gemini CLI | [`gemini/settings.json`](gemini/settings.json) | merge into `~/.gemini/settings.json`, or install the extension in [`gemini/`](gemini) | + +Each config has a **stdio** variant (the agent launches `ypcli mcp` locally) and, +where the client supports it, an **HTTP** variant pointing at a shared +`ypcli mcp --http` server with a bearer token. See +[`docs/en/09-mcp.md`](../docs/en/09-mcp.md) and [`deploy/`](../deploy). + +Before connecting, install ypcli and configure a profile: + +```bash +go install github.com/dantte-lp/ypcli/cmd/ypcli@latest +ypcli config add work --api https://api.yopass.corp --url https://yopass.corp +``` diff --git a/integrations/claude/.mcp.json b/integrations/claude/.mcp.json new file mode 100644 index 0000000..1f9ef12 --- /dev/null +++ b/integrations/claude/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "ypcli": { + "command": "ypcli", + "args": ["mcp"] + } + } +} diff --git a/integrations/codex/config.toml b/integrations/codex/config.toml new file mode 100644 index 0000000..8c54493 --- /dev/null +++ b/integrations/codex/config.toml @@ -0,0 +1,12 @@ +# Add to ~/.codex/config.toml (or a trusted project's .codex/config.toml). + +# stdio (local): Codex launches `ypcli mcp` as a subprocess. +[mcp_servers.ypcli] +command = "ypcli" +args = ["mcp"] + +# HTTP (shared server): connect to a running `ypcli mcp --http` instead. +# Comment out the stdio block above and use this: +# [mcp_servers.ypcli] +# url = "https://mcp.yopass.corp" +# bearer_token_env_var = "YPCLI_MCP_TOKEN" diff --git a/integrations/codex/prompts/share-secret.md b/integrations/codex/prompts/share-secret.md new file mode 100644 index 0000000..d534940 --- /dev/null +++ b/integrations/codex/prompts/share-secret.md @@ -0,0 +1,6 @@ +Share the following secret securely and give me back only the one-time URL +(never echo the secret itself). Use the ypcli MCP `send_secret` tool (or +`ypcli send --json` if the tool is unavailable), with `one_time` on and the +shortest reasonable expiration. + +Secret: $1 diff --git a/integrations/gemini/GEMINI.md b/integrations/gemini/GEMINI.md new file mode 100644 index 0000000..08cea68 --- /dev/null +++ b/integrations/gemini/GEMINI.md @@ -0,0 +1,13 @@ +# ypcli — sharing secrets via yopass + +Use the `ypcli` MCP tools to share and fetch end-to-end-encrypted one-time +secrets: + +- `send_secret` — encrypt and publish text; returns a one-time share URL. +- `send_file` — publish a file by path. +- `receive_secret` — fetch and decrypt a share URL (or `id` + `key`). One-time + secrets are consumed on first fetch. + +Never print the plaintext secret back to the user — return only the resulting +URL. Keep `one_time` on and choose the shortest workable `expiration` +(`1h`/`1d`/`1w`). diff --git a/integrations/gemini/gemini-extension.json b/integrations/gemini/gemini-extension.json new file mode 100644 index 0000000..9e21e3b --- /dev/null +++ b/integrations/gemini/gemini-extension.json @@ -0,0 +1,12 @@ +{ + "name": "ypcli", + "version": "0.1.0", + "description": "Share end-to-end-encrypted one-time secrets via yopass", + "mcpServers": { + "ypcli": { + "command": "ypcli", + "args": ["mcp"] + } + }, + "contextFileName": "GEMINI.md" +} diff --git a/integrations/gemini/settings.json b/integrations/gemini/settings.json new file mode 100644 index 0000000..172030d --- /dev/null +++ b/integrations/gemini/settings.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "ypcli": { + "command": "ypcli", + "args": ["mcp"], + "trust": false + } + } +} diff --git a/skills/ypcli/SKILL.md b/skills/ypcli/SKILL.md new file mode 100644 index 0000000..89335d2 --- /dev/null +++ b/skills/ypcli/SKILL.md @@ -0,0 +1,43 @@ +--- +name: ypcli +description: Share passwords, secrets, API keys, tokens, or files securely as end-to-end-encrypted one-time links via yopass, and fetch/decrypt yopass share URLs. Use whenever the user wants to send or share a secret/password/credential/token/file safely, deliver something sensitive without pasting it in plaintext, or open a yopass link. Backed by the ypcli MCP tools (send_secret, send_file, receive_secret) with a ypcli CLI fallback. +--- + +# Sharing secrets with ypcli (yopass) + +ypcli publishes secrets to a [yopass](https://github.com/jhaals/yopass) server +with **client-side** OpenPGP encryption; each secret becomes a one-time URL that +expires. Prefer the MCP tools when the `ypcli` MCP server is connected; otherwise +use the `ypcli` CLI. + +## Share a text secret + +- MCP: call `send_secret` with `text` (optional: `expiration` = `1h`/`1d`/`1w`, + `one_time`, `require_auth`, `profile`). It returns a one-time `url` — give that + URL to the recipient. The decryption key is embedded in the URL fragment. +- CLI: `printf '%s' "$SECRET" | ypcli send --json` → take `.url`. + +## Share a file + +- MCP: `send_file` with the absolute `path`. +- CLI: `ypcli send --file --json`. + +## Receive / decrypt + +- MCP: `receive_secret` with `url` (or `id` + `key`). Returns the decrypted + `content` (binary payloads come back as `content_base64`). +- CLI: `ypcli receive ''`. + +> One-time secrets are **consumed (deleted) on the first successful fetch** — +> only receive when you intend to reveal and destroy the secret. + +## Guidance + +- Never paste the plaintext secret into the conversation; share only the URL. +- Keep `one_time` on (default) and pick the shortest workable `expiration`. +- For a private/self-hosted yopass, pass a `profile` (see `list_profiles`). + +## Install + +Copy this folder to `~/.claude/skills/ypcli/`, and connect the MCP server: +`claude mcp add ypcli -- ypcli mcp`. See the repo's `docs/en/09-mcp.md`. From 8d5f622eadbc214e8cc38a706894c1db9f63a999 Mon Sep 17 00:00:00 2001 From: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:33:59 +0300 Subject: [PATCH 6/9] fix(mcp): address independent review findings - clientFor now honors $YPCLI_TOKEN for upstream yopass auth (was hardcoded '') - send_file requires an absolute path and its description/docs warn that it reads arbitrary local files (prompt-injection exfiltration risk) - list_profiles output is sorted (deterministic) - add MCP error-path, binary/base64, profile-honored, and CLI --http-token usage tests; drive real requests through the HTTP auth handler - doc-comment the no-token Handler; minor bytes.NewReader micro-opt --- docs/en/09-mcp.md | 97 ++++++++++++++++++++++++++++ docs/ru/09-mcp.md | 101 ++++++++++++++++++++++++++++++ internal/cli/cli_test.go | 9 +++ internal/mcpserver/http.go | 10 ++- internal/mcpserver/http_test.go | 20 +++++- internal/mcpserver/server.go | 24 +++++-- internal/mcpserver/server_test.go | 68 ++++++++++++++++++++ internal/share/share.go | 3 +- 8 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 docs/en/09-mcp.md create mode 100644 docs/ru/09-mcp.md diff --git a/docs/en/09-mcp.md b/docs/en/09-mcp.md new file mode 100644 index 0000000..3b99916 --- /dev/null +++ b/docs/en/09-mcp.md @@ -0,0 +1,97 @@ +# MCP server & agent integration + +`ypcli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) +server that exposes ypcli's operations as tools, so AI agents (Claude, Codex, +Gemini, …) can share and fetch secrets. It reuses the same crypto and transport +as the CLI, so behavior is identical. Connection settings come from the ypcli +[config profiles](05-configuration.md) on the host. + +```mermaid +flowchart LR + A["AI agent
Claude · Codex · Gemini"] -->|MCP tools| M["ypcli mcp
(stdio or HTTP)"] + M -->|client-side OpenPGP| Y["yopass server"] +``` + +## Tools + +| Tool | Purpose | +|---|---| +| `send_secret` | encrypt & publish text → one-time share URL | +| `send_file` | encrypt & publish a file (by path) → share URL | +| `receive_secret` | fetch & decrypt a share URL (or `id`+`key`) — consumes one-time secrets | +| `list_profiles` | list configured server profiles | +| `server_version` | client + yopass server version | + +Each tool accepts an optional `profile`. `--read-only` omits `receive_secret` +for send-only deployments. + +## Local (stdio) + +The agent launches `ypcli mcp` as a subprocess. Install ypcli and configure a +profile first (see [Installation](02-installation.md), [Configuration](05-configuration.md)). + +**Claude Code** + +```bash +claude mcp add ypcli -- ypcli mcp +``` + +**Codex** — add to `~/.codex/config.toml`: + +```toml +[mcp_servers.ypcli] +command = "ypcli" +args = ["mcp"] +``` + +**Gemini CLI** — add to `~/.gemini/settings.json`: + +```json +{ "mcpServers": { "ypcli": { "command": "ypcli", "args": ["mcp"] } } } +``` + +Ready-made snippets live in [`integrations/`](https://github.com/dantte-lp/ypcli/tree/master/integrations). + +## HTTP (shared server) + +Run one server that agents reach over the network with a bearer token: + +```bash +YPCLI_MCP_TOKEN=$(openssl rand -hex 32) ypcli mcp --http 127.0.0.1:8765 +``` + +A token is **required** in HTTP mode. Put the server behind a TLS reverse proxy +for remote access; deploy it as a hardened systemd service — see +[`deploy/`](https://github.com/dantte-lp/ypcli/tree/master/deploy). Then point a +client at the URL: + +```bash +claude mcp add --transport http ypcli https://mcp.yopass.corp \ + --header "Authorization: Bearer $YPCLI_MCP_TOKEN" +``` + +Codex uses `url` + `bearer_token_env_var`; Gemini uses an `httpUrl` server entry. + +## Claude skill + +The repo ships a Claude [Agent Skill](https://code.claude.com/docs/en/skills) at +[`skills/ypcli/`](https://github.com/dantte-lp/ypcli/tree/master/skills/ypcli). +Copy it to `~/.claude/skills/ypcli/` so Claude knows when and how to share +secrets with the MCP tools. + +## Security + +- **`send_file` reads any local file** the caller names (absolute path only). An + autonomous agent that can be prompt-injected could be steered into exfiltrating + sensitive files (SSH keys, cloud credentials). Run the MCP server under a + least-privileged user with a restricted filesystem view — the systemd unit in + [`deploy/`](https://github.com/dantte-lp/ypcli/tree/master/deploy) uses + `ProtectSystem=strict`; for stdio/local agents, launch ypcli from a confined + working directory or omit `send_file` from the client's allowed tools. +- HTTP mode requires a bearer token (constant-time compared); bind to loopback + behind TLS for anything non-local. +- `receive_secret` **consumes** one-time secrets on first fetch — only call it to + reveal (and destroy) a secret. +- Plaintext secrets and tokens are never logged. Tokens come from the profile's + `token_command`, never from disk. +- Use `--read-only` where agents should only publish, never fetch. diff --git a/docs/ru/09-mcp.md b/docs/ru/09-mcp.md new file mode 100644 index 0000000..b854eaf --- /dev/null +++ b/docs/ru/09-mcp.md @@ -0,0 +1,101 @@ +# MCP-сервер и интеграция с агентами + +`ypcli mcp` запускает сервер [Model Context Protocol](https://modelcontextprotocol.io), +который экспонирует операции ypcli как инструменты, чтобы ИИ-агенты (Claude, +Codex, Gemini, …) могли делиться секретами и получать их. Он переиспользует ту же +криптографию и транспорт, что и CLI, поэтому поведение идентично. Настройки +подключения берутся из [профилей конфигурации](05-configuration.md) ypcli на +хосте. + +```mermaid +flowchart LR + A["AI agent
Claude · Codex · Gemini"] -->|MCP tools| M["ypcli mcp
(stdio or HTTP)"] + M -->|client-side OpenPGP| Y["yopass server"] +``` + +## Инструменты + +| Инструмент | Назначение | +|---|---| +| `send_secret` | зашифровать и опубликовать текст → one-time share URL | +| `send_file` | зашифровать и опубликовать файл (по пути) → share URL | +| `receive_secret` | получить и расшифровать share URL (или `id`+`key`) — потребляет one-time секреты | +| `list_profiles` | список настроенных профилей серверов | +| `server_version` | версия клиента + сервера yopass | + +Каждый инструмент принимает опциональный `profile`. `--read-only` убирает +`receive_secret` для send-only деплоев. + +## Локально (stdio) + +Агент запускает `ypcli mcp` как подпроцесс. Сначала установите ypcli и настройте +профиль (см. [Установка](02-installation.md), [Конфигурация](05-configuration.md)). + +**Claude Code** + +```bash +claude mcp add ypcli -- ypcli mcp +``` + +**Codex** — добавьте в `~/.codex/config.toml`: + +```toml +[mcp_servers.ypcli] +command = "ypcli" +args = ["mcp"] +``` + +**Gemini CLI** — добавьте в `~/.gemini/settings.json`: + +```json +{ "mcpServers": { "ypcli": { "command": "ypcli", "args": ["mcp"] } } } +``` + +Готовые сниппеты — в [`integrations/`](https://github.com/dantte-lp/ypcli/tree/master/integrations). + +## HTTP (общий сервер) + +Запустите один сервер, к которому агенты подключаются по сети с bearer-токеном: + +```bash +YPCLI_MCP_TOKEN=$(openssl rand -hex 32) ypcli mcp --http 127.0.0.1:8765 +``` + +В HTTP-режиме токен **обязателен**. Для удалённого доступа поставьте сервер за +TLS reverse-proxy; разверните его как hardened systemd-сервис — см. +[`deploy/`](https://github.com/dantte-lp/ypcli/tree/master/deploy). Затем укажите +клиенту URL: + +```bash +claude mcp add --transport http ypcli https://mcp.yopass.corp \ + --header "Authorization: Bearer $YPCLI_MCP_TOKEN" +``` + +Codex использует `url` + `bearer_token_env_var`; Gemini — запись сервера с +`httpUrl`. + +## Claude skill + +Репозиторий поставляет Claude [Agent Skill](https://code.claude.com/docs/en/skills) +в [`skills/ypcli/`](https://github.com/dantte-lp/ypcli/tree/master/skills/ypcli). +Скопируйте её в `~/.claude/skills/ypcli/`, чтобы Claude знал, когда и как делиться +секретами через MCP-инструменты. + +## Безопасность + +- **`send_file` читает любой локальный файл** по указанному пути (только + абсолютный). Автономный агент, подверженный prompt-injection, может быть + склонён к эксфильтрации чувствительных файлов (SSH-ключи, облачные + credentials). Запускайте MCP-сервер под least-privileged пользователем с + ограниченным доступом к ФС — systemd-юнит в + [`deploy/`](https://github.com/dantte-lp/ypcli/tree/master/deploy) использует + `ProtectSystem=strict`; для stdio/локальных агентов запускайте ypcli из + ограниченного рабочего каталога или уберите `send_file` из разрешённых + инструментов клиента. +- HTTP-режим требует bearer-токен (сравнение constant-time); для не-локального + доступа биндите на loopback за TLS. +- `receive_secret` **потребляет** one-time секреты при первом получении — + вызывайте только чтобы раскрыть (и уничтожить) секрет. +- Plaintext-секреты и токены никогда не логируются. Токены берутся из + `token_command` профиля, не с диска. +- Используйте `--read-only`, где агенты должны только публиковать, не получать. diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 65d2ca7..47b3177 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -231,6 +231,15 @@ func TestManualKeyURLRequiresKey(t *testing.T) { } } +func TestMCPHTTPRequiresToken(t *testing.T) { + // --http without a token (and no YPCLI_MCP_TOKEN) is a usage error. + t.Setenv("YPCLI_MCP_TOKEN", "") + _, _, code := run(t, "mcp", "--http", "127.0.0.1:0") + if code != 2 { + t.Errorf("exit = %d, want 2 (usage: token required)", code) + } +} + func TestUnknownCommandExit(t *testing.T) { _, _, code := run(t, "frobnicate") if code == 0 { diff --git a/internal/mcpserver/http.go b/internal/mcpserver/http.go index 4c2d4fd..efb66ab 100644 --- a/internal/mcpserver/http.go +++ b/internal/mcpserver/http.go @@ -8,8 +8,14 @@ import ( ) // Handler serves the MCP server over Streamable HTTP. When token is non-empty -// (required for any network-exposed deployment) every request must carry -// "Authorization: Bearer ". +// (REQUIRED for any network-exposed deployment) every request must carry +// "Authorization: Bearer ". An empty token returns an UNAUTHENTICATED +// handler — the CLI never allows that (it refuses --http without a token), so +// only pass "" for loopback/testing. +// +// The SDK's Streamable handler enables localhost DNS-rebinding protection by +// default; the mandatory bearer token covers browser-CSRF, so an explicit +// CrossOriginProtection is intentionally not added here. func Handler(srv *mcp.Server, token string) http.Handler { base := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil) if token == "" { diff --git a/internal/mcpserver/http_test.go b/internal/mcpserver/http_test.go index 539675b..9bcfcd3 100644 --- a/internal/mcpserver/http_test.go +++ b/internal/mcpserver/http_test.go @@ -45,9 +45,23 @@ func TestBearerAuth(t *testing.T) { } func TestHandlerNoTokenSkipsAuth(t *testing.T) { - // With an empty token the handler is the bare MCP handler (no auth wrapper). + // With an empty token the handler is the bare MCP handler (no auth wrapper): + // a request must reach it rather than being 401'd by the auth middleware. h := Handler(New(Options{ConfigPath: "/nonexistent"}), "") - if h == nil { - t.Fatal("Handler returned nil") + req := httptest.NewRequest(http.MethodPost, "/", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code == http.StatusUnauthorized { + t.Error("no-token handler must not enforce bearer auth") + } +} + +func TestHandlerWithTokenEnforcesAuth(t *testing.T) { + h := Handler(New(Options{ConfigPath: "/nonexistent"}), "tok") + req := httptest.NewRequest(http.MethodPost, "/", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401 without a bearer token", rec.Code) } } diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 8771fc2..f42b03f 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -8,6 +8,9 @@ import ( "context" "encoding/base64" "fmt" + "os" + "path/filepath" + "sort" "strings" "unicode/utf8" @@ -35,8 +38,9 @@ func New(o Options) *mcp.Server { Description: "Encrypt and publish a text secret to yopass, returning a one-time share URL.", }, r.sendSecret) mcp.AddTool(s, &mcp.Tool{ - Name: "send_file", - Description: "Encrypt and publish a file (by path) to yopass, returning a one-time share URL.", + Name: "send_file", + Description: "Encrypt and publish a file (by absolute path) to yopass, returning a one-time share URL. " + + "SECURITY: this reads an arbitrary local file — only share files the user explicitly intended.", }, r.sendFile) if !o.ReadOnly { mcp.AddTool(s, &mcp.Tool{ @@ -143,6 +147,9 @@ func (r *registry) sendSecret(ctx context.Context, _ *mcp.CallToolRequest, in se } func (r *registry) sendFile(ctx context.Context, _ *mcp.CallToolRequest, in sendFileInput) (*mcp.CallToolResult, sendOutput, error) { + if !filepath.IsAbs(in.Path) { + return nil, sendOutput{}, fmt.Errorf("path must be absolute, got %q", in.Path) + } client, publicURL, prof, err := r.clientFor(ctx, in.Profile) if err != nil { return nil, sendOutput{}, err @@ -187,8 +194,15 @@ func (r *registry) listProfiles(_ context.Context, _ *mcp.CallToolRequest, _ noI if err != nil { return nil, listProfilesOutput{}, err } + names := make([]string, 0, len(cfg.Profiles)) + for name := range cfg.Profiles { + names = append(names, name) + } + sort.Strings(names) + out := listProfilesOutput{Profiles: []profileInfo{}} - for name, p := range cfg.Profiles { + for _, name := range names { + p := cfg.Profiles[name] out.Profiles = append(out.Profiles, profileInfo{ Name: name, API: firstNonEmpty(p.API, cfg.Defaults.API), @@ -224,7 +238,9 @@ func (r *registry) clientFor(ctx context.Context, profileName string) (*api.Clie if err != nil { return nil, "", config.Profile{}, err } - token, err := config.ResolveToken(ctx, "", prof.TokenCommand) + // Honor $YPCLI_TOKEN for upstream yopass auth, matching the CLI, before + // falling back to the profile's token_command. + token, err := config.ResolveToken(ctx, os.Getenv("YPCLI_TOKEN"), prof.TokenCommand) if err != nil { return nil, "", prof, err } diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 637750e..d9e4534 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -2,6 +2,7 @@ package mcpserver import ( "context" + "encoding/base64" "encoding/json" "io" "net/http" @@ -195,6 +196,73 @@ func TestListProfilesAndVersion(t *testing.T) { } } +func callErr(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) { + t.Helper() + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("call %s transport error: %v", name, err) + } + if !res.IsError { + t.Fatalf("call %s: expected a tool error, got %+v", name, res.StructuredContent) + } +} + +func TestToolErrorPaths(t *testing.T) { + srv, _ := fakeServer(t) + cfg := writeConfig(t, srv.URL) + cs := connect(t, Options{ConfigPath: cfg, Version: "test"}) + + callErr(t, cs, "send_secret", map[string]any{"text": "x", "expiration": "2w"}) // bad expiration + callErr(t, cs, "send_secret", map[string]any{"text": "x", "profile": "ghost"}) // unknown profile + callErr(t, cs, "send_file", map[string]any{"path": "relative/path"}) // not absolute + callErr(t, cs, "receive_secret", map[string]any{"id": "abc"}) // missing key + callErr(t, cs, "receive_secret", map[string]any{}) // no url/id +} + +func TestBinaryReceiveIsBase64(t *testing.T) { + srv, _ := fakeServer(t) + cfg := writeConfig(t, srv.URL) + cs := connect(t, Options{ConfigPath: cfg, Version: "test"}) + + bin := []byte{0x00, 0x01, 0xff, 0xfe, 0x80} + file := filepath.Join(t.TempDir(), "blob.bin") + if err := os.WriteFile(file, bin, 0o600); err != nil { + t.Fatal(err) + } + var sent sendOutput + call(t, cs, "send_file", map[string]any{"path": file}, &sent) + + var got receiveOutput + call(t, cs, "receive_secret", map[string]any{"url": sent.URL}, &got) + if got.Content != "" || got.ContentBase64 == "" { + t.Fatalf("binary payload should use content_base64: %+v", got) + } + decoded, err := base64.StdEncoding.DecodeString(got.ContentBase64) + if err != nil || string(decoded) != string(bin) { + t.Errorf("base64 round-trip mismatch: %v", err) + } +} + +func TestProfileArgumentHonored(t *testing.T) { + srvA, _ := fakeServer(t) + srvB, _ := fakeServer(t) + dir := t.TempDir() + cfg := filepath.Join(dir, "config.yaml") + body := "active: a\nprofiles:\n" + + " a:\n api: " + srvA.URL + "\n url: " + srvA.URL + "\n" + + " b:\n api: " + srvB.URL + "\n url: " + srvB.URL + "\n" + if err := os.WriteFile(cfg, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + cs := connect(t, Options{ConfigPath: cfg, Version: "test"}) + + var sent sendOutput + call(t, cs, "send_secret", map[string]any{"text": "hi", "profile": "b"}, &sent) + if !strings.HasPrefix(sent.URL, srvB.URL) { + t.Errorf("profile b should target %s, got %s", srvB.URL, sent.URL) + } +} + func TestReadOnlyOmitsReceive(t *testing.T) { srv, _ := fakeServer(t) cfg := writeConfig(t, srv.URL) diff --git a/internal/share/share.go b/internal/share/share.go index 6d49035..015810c 100644 --- a/internal/share/share.go +++ b/internal/share/share.go @@ -5,6 +5,7 @@ package share import ( + "bytes" "context" "fmt" "io" @@ -79,7 +80,7 @@ func SendFile(ctx context.Context, client *api.Client, publicURL, path string, o if err != nil { return SendResult{}, fmt.Errorf("encrypt file: %w", err) } - id, err := client.CreateFile(ctx, strings.NewReader(string(data)), o.Expiration, o.OneTime) + id, err := client.CreateFile(ctx, bytes.NewReader(data), o.Expiration, o.OneTime) if err != nil { return SendResult{}, err } From a5ce541f54cf25711566afa4a65ef40638c57fb5 Mon Sep 17 00:00:00 2001 From: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:35:26 +0300 Subject: [PATCH 7/9] docs(mcp): add MCP guide (09-mcp, en+ru), CLI ref, indexes, changelog --- .cspell.json | 13 ++++++++++++- CHANGELOG.md | 6 ++++++ CHANGELOG.ru.md | 6 ++++++ README.md | 1 + docs/en/04-cli.md | 16 ++++++++++++++++ docs/en/README.md | 1 + docs/ru/04-cli.md | 16 ++++++++++++++++ docs/ru/README.md | 1 + 8 files changed, 59 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index 4262d8a..743e650 100644 --- a/.cspell.json +++ b/.cspell.json @@ -77,7 +77,18 @@ "Zed", "Aider", "Gemini", - "commitlintrc" + "commitlintrc", + "mcp", + "MCP", + "stdio", + "systemd", + "Streamable", + "jsonrpc", + "ndjson", + "openssl", + "DynamicUser", + "modelcontextprotocol", + "reverse" ], "ignorePaths": [ "go.mod", diff --git a/CHANGELOG.md b/CHANGELOG.md index 9999145..1504c96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ Russian translation: [CHANGELOG.ru.md](CHANGELOG.ru.md). ### Added +- **`ypcli mcp`** — a Model Context Protocol server exposing send/receive to AI + agents (Claude, Codex, Gemini). Tools: `send_secret`, `send_file`, + `receive_secret` (omit with `--read-only`), `list_profiles`, `server_version`. + Serves over stdio or HTTP (`--http`, bearer-token protected). Ships a Claude + Agent Skill (`skills/ypcli/`), per-client configs (`integrations/`), and a + hardened systemd unit (`deploy/`). - `ypcli send --input-command ''` runs any command and sends its raw stdout as the secret — a generic bridge to any secrets manager (AWS Secrets Manager, gopass, `pass`, 1Password CLI, …). diff --git a/CHANGELOG.ru.md b/CHANGELOG.ru.md index e149d48..9a49a56 100644 --- a/CHANGELOG.ru.md +++ b/CHANGELOG.ru.md @@ -11,6 +11,12 @@ English version: [CHANGELOG.md](CHANGELOG.md). ### Добавлено +- **`ypcli mcp`** — сервер Model Context Protocol, экспонирующий send/receive + ИИ-агентам (Claude, Codex, Gemini). Инструменты: `send_secret`, `send_file`, + `receive_secret` (убирается через `--read-only`), `list_profiles`, + `server_version`. Работает по stdio или HTTP (`--http`, защита bearer-токеном). + Поставляет Claude Agent Skill (`skills/ypcli/`), конфиги для клиентов + (`integrations/`) и hardened systemd-юнит (`deploy/`). - `ypcli send --input-command ''` выполняет любую команду и отправляет её сырой stdout как секрет — универсальный мост к любому менеджеру секретов (AWS Secrets Manager, gopass, `pass`, 1Password CLI, …). diff --git a/README.md b/README.md index ad1b331..27ef8cf 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ a Russian mirror is in [`docs/ru/`](docs/ru/README.md). | 06 | [Automation](docs/en/06-automation.md) | [ru](docs/ru/06-automation.md) | CI/agents, JSON, exit codes | | 07 | [Security](docs/en/07-security.md) | [ru](docs/ru/07-security.md) | Crypto model, interoperability | | 08 | [Development](docs/en/08-development.md) | [ru](docs/ru/08-development.md) | Build, test, lint, release | +| 09 | [MCP server](docs/en/09-mcp.md) | [ru](docs/ru/09-mcp.md) | Expose ypcli to AI agents (Claude/Codex/Gemini) | ## Exit codes diff --git a/docs/en/04-cli.md b/docs/en/04-cli.md index f1eb01f..d8be4c9 100644 --- a/docs/en/04-cli.md +++ b/docs/en/04-cli.md @@ -93,6 +93,22 @@ endpoint. Servers older than yopass 13.x report `unsupported`. ypcli version --api https://api.yopass.se --json ``` +## `ypcli mcp` + +Run an MCP server exposing ypcli's send/receive operations to AI agents. See +[MCP server](09-mcp.md) for the full guide. + +| Flag | Description | +|---|---| +| `--http` | serve over HTTP at this address instead of stdio (e.g. `127.0.0.1:8765`) | +| `--http-token` | bearer token required in HTTP mode (`$YPCLI_MCP_TOKEN`) | +| `--read-only` | expose send-only tools (omit `receive_secret`) | + +```bash +ypcli mcp # stdio (for a local agent) +YPCLI_MCP_TOKEN=… ypcli mcp --http :8765 # shared HTTP server +``` + ## `ypcli completion` Generate a shell completion script for `bash`, `zsh`, `fish`, or `powershell`. diff --git a/docs/en/README.md b/docs/en/README.md index 6f84317..7a75e26 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -12,3 +12,4 @@ | 06 | [Automation](06-automation.md) | CI/agents, JSON output, exit codes | | 07 | [Security](07-security.md) | Cryptographic model, interoperability | | 08 | [Development](08-development.md) | Build, test, lint, release workflow | +| 09 | [MCP server](09-mcp.md) | Expose ypcli to AI agents (Claude, Codex, Gemini) | diff --git a/docs/ru/04-cli.md b/docs/ru/04-cli.md index 39712b6..a186163 100644 --- a/docs/ru/04-cli.md +++ b/docs/ru/04-cli.md @@ -92,6 +92,22 @@ ypcli config remove work ypcli version --api https://api.yopass.se --json ``` +## `ypcli mcp` + +Запустить MCP-сервер, экспонирующий операции send/receive ypcli ИИ-агентам. См. +[MCP-сервер](09-mcp.md) для полного руководства. + +| Флаг | Описание | +|---|---| +| `--http` | обслуживать по HTTP на этом адресе вместо stdio (напр. `127.0.0.1:8765`) | +| `--http-token` | bearer-токен, обязательный в HTTP-режиме (`$YPCLI_MCP_TOKEN`) | +| `--read-only` | экспонировать только send-инструменты (без `receive_secret`) | + +```bash +ypcli mcp # stdio (для локального агента) +YPCLI_MCP_TOKEN=… ypcli mcp --http :8765 # общий HTTP-сервер +``` + ## `ypcli completion` Сгенерировать скрипт автодополнения оболочки для `bash`, `zsh`, `fish` или `powershell`. diff --git a/docs/ru/README.md b/docs/ru/README.md index 908ae0a..0d477b2 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -12,3 +12,4 @@ | 06 | [Автоматизация](06-automation.md) | CI/агенты, вывод JSON, коды возврата | | 07 | [Безопасность](07-security.md) | Криптографическая модель, совместимость | | 08 | [Разработка](08-development.md) | Сборка, тестирование, линтинг, процесс релиза | +| 09 | [MCP-сервер](09-mcp.md) | Экспонировать ypcli агентам (Claude, Codex, Gemini) | From 626a665d4ff5323b0bd86ac7cfea0ca60f21c37f Mon Sep 17 00:00:00 2001 From: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:39:24 +0300 Subject: [PATCH 8/9] ci: allow mcp/share commit scopes and add mcp cspell terms --- .commitlintrc.yaml | 2 ++ .cspell.json | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.commitlintrc.yaml b/.commitlintrc.yaml index cd2e794..46b2b97 100644 --- a/.commitlintrc.yaml +++ b/.commitlintrc.yaml @@ -25,6 +25,8 @@ rules: - config - output - clipboard + - mcp + - share - docs - ci - build diff --git a/.cspell.json b/.cspell.json index 743e650..f86db47 100644 --- a/.cspell.json +++ b/.cspell.json @@ -88,7 +88,12 @@ "openssl", "DynamicUser", "modelcontextprotocol", - "reverse" + "reverse", + "GOPATH", + "syscalls", + "exfiltrating", + "exfiltration", + "loopback" ], "ignorePaths": [ "go.mod", From 199868ec437ec281fe338a0a750e374ebcf43fea Mon Sep 17 00:00:00 2001 From: Pavel Lavrukhin <46395539+dantte-lp@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:43:20 +0300 Subject: [PATCH 9/9] ci: relax subject-case to allow acronyms in commit subjects --- .commitlintrc.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.commitlintrc.yaml b/.commitlintrc.yaml index 46b2b97..00b44ca 100644 --- a/.commitlintrc.yaml +++ b/.commitlintrc.yaml @@ -35,11 +35,14 @@ rules: - test - lint - security + # Allow acronyms (MCP, SDK, HTTP) in subjects; only forbid Title-Case / ALL-CAPS + # subjects, matching the @commitlint/config-conventional default. subject-case: - 2 - - always - - - sentence-case - - lower-case + - never + - - upper-case + - pascal-case + - start-case header-max-length: - 2 - always