Skip to content

Commit f3e7ba2

Browse files
authored
Add support for 1Password secret references (#37)
* Add support for 1Password secret references * Add more details and screenshot * Resize screenshot * Add test coverage for ResolveSecretReference helper function
1 parent 391db09 commit f3e7ba2

File tree

4 files changed

+222
-9
lines changed

4 files changed

+222
-9
lines changed

README.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,15 +162,51 @@ it starts a program that listens on stdin,
162162
outputs to stdout,
163163
and logs to stderr.
164164

165+
### Authentication
166+
167+
For APIs that require authentication,
168+
emcee supports several authentication methods:
169+
170+
| Authentication Type | Example Usage | Resulting Header |
171+
|------------------------|---------------|----------------------------|
172+
| **Bearer Token** | `--bearer-auth="abc123"` | `Authorization: Bearer abc123` |
173+
| **Basic Auth** | `--basic-auth="user:pass"` | `Authorization: Basic dXNlcjpwYXNz` |
174+
| **Raw Value** | `--raw-auth="Custom xyz789"` | `Authorization: Custom xyz789` |
175+
176+
These authentication values can be provided directly
177+
or as [1Password secret references][secret-reference-syntax].
178+
179+
When using 1Password references:
180+
- Use the format `op://vault/item/field`
181+
(e.g. `--bearer-auth="op://Shared/X/credential"`)
182+
- Ensure the 1Password CLI (`op`) is installed and available in your `PATH`
183+
- Sign in to 1Password before running `emcee` or launching Claude Desktop
184+
185+
```json
186+
{
187+
"mcpServers": {
188+
"twitter": {
189+
"command": "emcee",
190+
"args": [
191+
"--bearer-auth=op://shared/x/credential",
192+
"https://api.twitter.com/2/openapi.json"
193+
]
194+
}
195+
}
196+
}
197+
```
198+
199+
<img src="https://github.com/user-attachments/assets/d639fd7c-f3bf-477c-9eb7-229285b36f7d" alt="1Password Access Requested" width="512">
200+
201+
### JSON-RPC
202+
165203
You can interact directly with the provided MCP server
166204
by sending JSON-RPC requests.
167205

168206
> [!NOTE]
169207
> emcee provides only MCP tool capabilities.
170208
> Other features like resources, prompts, and sampling aren't yet supported.
171209
172-
### Example JSON-RPC Calls
173-
174210
#### List Tools
175211

176212
<details open>
@@ -281,3 +317,4 @@ emcee is licensed under the Apache License, Version 2.0.
281317
[mcp-servers]: https://modelcontextprotocol.io/examples
282318
[openapi]: https://openapi.org
283319
[releases]: https://github.com/loopwork-ai/emcee/releases
320+
[secret-reference-syntax]: https://developer.1password.com/docs/cli/secret-reference-syntax/

cmd/emcee/main.go

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"encoding/base64"
45
"fmt"
56
"io"
67
"log/slog"
@@ -15,8 +16,6 @@ import (
1516
"github.com/spf13/cobra"
1617
"golang.org/x/sync/errgroup"
1718

18-
"encoding/base64"
19-
2019
"github.com/loopwork-ai/emcee/internal"
2120
"github.com/loopwork-ai/emcee/mcp"
2221
)
@@ -33,7 +32,14 @@ The spec-path-or-url argument can be:
3332
- "-" to read from stdin
3433
3534
By default, a GET request with no additional headers is made to the spec URL to download the OpenAPI specification.
35+
3636
If additional authentication is required to download the specification, you can first download it to a local file using your preferred HTTP client with the necessary authentication headers, and then provide the local file path to emcee.
37+
38+
Authentication values can be provided directly or as 1Password secret references (e.g. op://vault/item/field). When using 1Password references:
39+
- The 1Password CLI (op) must be installed and available in your PATH
40+
- You must be signed in to 1Password
41+
- The reference must be in the format op://vault/item/field
42+
- The secret will be securely retrieved at runtime using the 1Password CLI
3743
`,
3844
Args: cobra.ExactArgs(1),
3945
RunE: func(cmd *cobra.Command, args []string) error {
@@ -70,18 +76,39 @@ If additional authentication is required to download the specification, you can
7076

7177
// Set default headers if auth is provided
7278
if bearerAuth != "" {
73-
opts = append(opts, mcp.WithAuth("Bearer "+bearerAuth))
79+
resolvedAuth, wasSecret, err := internal.ResolveSecretReference(ctx, bearerAuth)
80+
if err != nil {
81+
return fmt.Errorf("error resolving bearer auth: %w", err)
82+
}
83+
if wasSecret {
84+
logger.Debug("resolved bearer auth from 1Password")
85+
}
86+
opts = append(opts, mcp.WithAuth("Bearer "+resolvedAuth))
7487
} else if basicAuth != "" {
88+
resolvedAuth, wasSecret, err := internal.ResolveSecretReference(ctx, basicAuth)
89+
if err != nil {
90+
return fmt.Errorf("error resolving basic auth: %w", err)
91+
}
92+
if wasSecret {
93+
logger.Debug("resolved basic auth from 1Password")
94+
}
7595
// Check if already base64 encoded
76-
if strings.Contains(basicAuth, ":") {
77-
encoded := base64.StdEncoding.EncodeToString([]byte(basicAuth))
96+
if strings.Contains(resolvedAuth, ":") {
97+
encoded := base64.StdEncoding.EncodeToString([]byte(resolvedAuth))
7898
opts = append(opts, mcp.WithAuth("Basic "+encoded))
7999
} else {
80100
// Assume it's already base64 encoded
81-
opts = append(opts, mcp.WithAuth("Basic "+basicAuth))
101+
opts = append(opts, mcp.WithAuth("Basic "+resolvedAuth))
82102
}
83103
} else if rawAuth != "" {
84-
opts = append(opts, mcp.WithAuth(rawAuth))
104+
resolvedAuth, wasSecret, err := internal.ResolveSecretReference(ctx, rawAuth)
105+
if err != nil {
106+
return fmt.Errorf("error resolving raw auth: %w", err)
107+
}
108+
if wasSecret {
109+
logger.Debug("resolved raw auth from 1Password")
110+
}
111+
opts = append(opts, mcp.WithAuth(resolvedAuth))
85112
}
86113

87114
// Set HTTP client

internal/secret.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package internal
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"os/exec"
8+
"strings"
9+
)
10+
11+
var (
12+
// Command is a variable that allows overriding the command creation for testing
13+
CommandContext = exec.CommandContext
14+
// LookPath is a variable that allows overriding the lookup behavior for testing
15+
LookPath = exec.LookPath
16+
)
17+
18+
// ResolveSecretReference attempts to resolve a 1Password secret reference (e.g. op://vault/item/field)
19+
// Returns the resolved value and whether it was a secret reference
20+
func ResolveSecretReference(ctx context.Context, value string) (string, bool, error) {
21+
if !strings.HasPrefix(value, "op://") {
22+
return value, false, nil
23+
}
24+
25+
// Check if op CLI is available
26+
if _, err := LookPath("op"); err != nil {
27+
return "", true, fmt.Errorf("1Password CLI (op) not found in PATH: %w", err)
28+
}
29+
30+
// Create command to read secret
31+
cmd := CommandContext(ctx, "op", "read", value)
32+
output, err := cmd.Output()
33+
if err != nil {
34+
var exitErr *exec.ExitError
35+
if errors.As(err, &exitErr) {
36+
return "", true, fmt.Errorf("failed to read secret from 1Password: %s", string(exitErr.Stderr))
37+
}
38+
return "", true, fmt.Errorf("failed to read secret from 1Password: %w", err)
39+
}
40+
41+
// Trim any whitespace/newlines from the output
42+
return strings.TrimSpace(string(output)), true, nil
43+
}

internal/secret_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package internal
2+
3+
import (
4+
"context"
5+
"os/exec"
6+
"testing"
7+
)
8+
9+
func TestResolveSecretReference(t *testing.T) {
10+
// Save the original functions and restore them after the test
11+
originalCommand := CommandContext
12+
originalLookPath := LookPath
13+
t.Cleanup(func() {
14+
CommandContext = originalCommand
15+
LookPath = originalLookPath
16+
})
17+
18+
tests := []struct {
19+
name string
20+
input string
21+
mockCommandContext func(ctx context.Context, name string, args ...string) *exec.Cmd
22+
mockLookPath func(string) (string, error)
23+
wantValue string
24+
wantSecret bool
25+
wantErr bool
26+
}{
27+
{
28+
name: "non-secret value",
29+
input: "regular-value",
30+
wantValue: "regular-value",
31+
wantSecret: false,
32+
},
33+
{
34+
name: "successful secret resolution",
35+
input: "op://vault/item/field",
36+
mockLookPath: func(string) (string, error) {
37+
return "/usr/local/bin/op", nil
38+
},
39+
mockCommandContext: func(ctx context.Context, name string, args ...string) *exec.Cmd {
40+
return exec.CommandContext(ctx, "echo", "secret-value")
41+
},
42+
wantValue: "secret-value",
43+
wantSecret: true,
44+
},
45+
{
46+
name: "op CLI not found",
47+
input: "op://vault/item/field",
48+
mockLookPath: func(string) (string, error) {
49+
return "", exec.ErrNotFound
50+
},
51+
wantValue: "",
52+
wantSecret: true,
53+
wantErr: true,
54+
},
55+
{
56+
name: "op command execution failed",
57+
input: "op://vault/item/field",
58+
mockLookPath: func(string) (string, error) {
59+
return "/usr/local/bin/op", nil
60+
},
61+
mockCommandContext: func(ctx context.Context, name string, args ...string) *exec.Cmd {
62+
// Return a command that will fail
63+
return exec.CommandContext(ctx, "false")
64+
},
65+
wantValue: "",
66+
wantSecret: true,
67+
wantErr: true,
68+
},
69+
{
70+
name: "empty input",
71+
input: "",
72+
wantValue: "",
73+
wantSecret: false,
74+
},
75+
{
76+
name: "malformed op reference",
77+
input: "op://invalid",
78+
wantValue: "",
79+
wantSecret: true,
80+
wantErr: true,
81+
},
82+
}
83+
84+
for _, tt := range tests {
85+
t.Run(tt.name, func(t *testing.T) {
86+
if tt.mockCommandContext != nil {
87+
CommandContext = tt.mockCommandContext
88+
}
89+
if tt.mockLookPath != nil {
90+
LookPath = tt.mockLookPath
91+
}
92+
93+
got, isSecret, err := ResolveSecretReference(context.Background(), tt.input)
94+
if (err != nil) != tt.wantErr {
95+
t.Errorf("ResolveSecretReference() error = %v, wantErr %v", err, tt.wantErr)
96+
return
97+
}
98+
if got != tt.wantValue {
99+
t.Errorf("ResolveSecretReference() got = %v, want %v", got, tt.wantValue)
100+
}
101+
if isSecret != tt.wantSecret {
102+
t.Errorf("ResolveSecretReference() isSecret = %v, want %v", isSecret, tt.wantSecret)
103+
}
104+
})
105+
}
106+
}

0 commit comments

Comments
 (0)