feat: wire email mcp application graph
This commit is contained in:
parent
2c1dab1bb2
commit
e09b4afa0a
4 changed files with 208 additions and 52 deletions
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
|
|
||||||
"email-mcp/internal/mcpserver"
|
"email-mcp/internal/mcpserver"
|
||||||
"email-mcp/internal/secretstore"
|
"email-mcp/internal/secretstore"
|
||||||
|
|
@ -24,7 +23,7 @@ type App struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
return NewAppWithDependencies(nil, nil, nil, os.Stderr)
|
return BuildApp()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAppWithDependencies(prompter SetupPrompter, store secretstore.Store, runner MCPRunner, stderr io.Writer) *App {
|
func NewAppWithDependencies(prompter SetupPrompter, store secretstore.Store, runner MCPRunner, stderr io.Writer) *App {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ func (r *runnerStub) Run(context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppRunRejectsUnknownCommand(t *testing.T) {
|
func TestAppRunRejectsUnknownCommand(t *testing.T) {
|
||||||
app := NewApp()
|
app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{})
|
||||||
|
|
||||||
err := app.Run([]string{"unknown"})
|
err := app.Run([]string{"unknown"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -64,7 +64,7 @@ func TestAppRunRejectsUnknownCommand(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) {
|
func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) {
|
||||||
app := NewApp()
|
app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{})
|
||||||
|
|
||||||
err := app.Run(nil)
|
err := app.Run(nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -148,7 +148,7 @@ func TestAppRunMCPDelegatesToRunner(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
|
func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
|
||||||
app := NewApp()
|
app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{})
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
command string
|
command string
|
||||||
|
|
@ -170,3 +170,19 @@ func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewAppBuildsProductionDependencies(t *testing.T) {
|
||||||
|
app := NewApp()
|
||||||
|
if app == nil {
|
||||||
|
t.Fatal("expected app instance")
|
||||||
|
}
|
||||||
|
if app.prompter == nil {
|
||||||
|
t.Fatal("expected setup prompter to be configured")
|
||||||
|
}
|
||||||
|
if app.store == nil {
|
||||||
|
t.Fatal("expected secret store to be configured")
|
||||||
|
}
|
||||||
|
if app.runner == nil {
|
||||||
|
t.Fatal("expected MCP runner to be configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,62 @@
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"email-mcp/internal/imapclient"
|
||||||
|
"email-mcp/internal/mcpserver"
|
||||||
"email-mcp/internal/secretstore"
|
"email-mcp/internal/secretstore"
|
||||||
|
"email-mcp/internal/secretstore/kwallet"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errSecretStoreNotConfigured = errors.New("secret store is not available in this build yet")
|
type runtimeFactories struct {
|
||||||
var errMCPRunnerNotConfigured = errors.New("mcp runner is not available in this build yet")
|
newPrompter func(io.Reader, io.Writer) SetupPrompter
|
||||||
|
newWalletClient func() kwallet.Client
|
||||||
type placeholderStore struct{}
|
newStore func(kwallet.Client) secretstore.Store
|
||||||
|
newMailService func() mcpserver.MailService
|
||||||
func (placeholderStore) Save(context.Context, string, secretstore.Credential) error {
|
newRunner func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
|
||||||
return errSecretStoreNotConfigured
|
|
||||||
}
|
|
||||||
|
|
||||||
func (placeholderStore) Load(context.Context, string) (secretstore.Credential, error) {
|
|
||||||
return secretstore.Credential{}, errSecretStoreNotConfigured
|
|
||||||
}
|
|
||||||
|
|
||||||
type placeholderRunner struct{}
|
|
||||||
|
|
||||||
func (placeholderRunner) Run(context.Context) error {
|
|
||||||
return errMCPRunnerNotConfigured
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildApp() *App {
|
func BuildApp() *App {
|
||||||
return buildApp(os.Stdin, os.Stdout, os.Stderr)
|
return buildApp(os.Stdin, os.Stdout, os.Stderr, runtimeFactories{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer) *App {
|
func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, factories runtimeFactories) *App {
|
||||||
_ = stdout
|
factories = factories.withDefaults()
|
||||||
|
|
||||||
return NewAppWithDependencies(
|
prompter := factories.newPrompter(stdin, stderr)
|
||||||
NewInteractiveSetupPrompter(stdin, stderr),
|
store := factories.newStore(factories.newWalletClient())
|
||||||
placeholderStore{},
|
mailService := factories.newMailService()
|
||||||
placeholderRunner{},
|
runner := factories.newRunner(store, mailService, stdin, stdout, stderr)
|
||||||
stderr,
|
|
||||||
)
|
return NewAppWithDependencies(prompter, store, runner, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f runtimeFactories) withDefaults() runtimeFactories {
|
||||||
|
if f.newPrompter == nil {
|
||||||
|
f.newPrompter = func(input io.Reader, output io.Writer) SetupPrompter {
|
||||||
|
return NewInteractiveSetupPrompter(input, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.newWalletClient == nil {
|
||||||
|
f.newWalletClient = kwallet.NewDefaultWalletClient
|
||||||
|
}
|
||||||
|
if f.newStore == nil {
|
||||||
|
f.newStore = func(client kwallet.Client) secretstore.Store {
|
||||||
|
return kwallet.NewStore(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.newMailService == nil {
|
||||||
|
f.newMailService = func() mcpserver.MailService {
|
||||||
|
return imapclient.NewService(imapclient.NewDefaultBackend())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.newRunner == nil {
|
||||||
|
f.newRunner = func(store secretstore.Store, mail mcpserver.MailService, input io.Reader, output io.Writer, errOut io.Writer) MCPRunner {
|
||||||
|
return mcpserver.NewRunner(mcpserver.New(store, mail), input, output, errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return f
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,36 +2,159 @@ package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"email-mcp/internal/imapclient"
|
||||||
|
"email-mcp/internal/mcpserver"
|
||||||
|
"email-mcp/internal/secretstore"
|
||||||
|
"email-mcp/internal/secretstore/kwallet"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildAppConfiguresSetupCommand(t *testing.T) {
|
type walletClientStub struct{}
|
||||||
stdin := strings.NewReader("imap.example.com\nalice\nsecret\n")
|
|
||||||
|
func (walletClientStub) IsAvailable(context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (walletClientStub) Open(context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (walletClientStub) WriteEntry(context.Context, string, []byte) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (walletClientStub) ReadEntry(context.Context, string) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireStoreStub struct {
|
||||||
|
saved secretstore.Credential
|
||||||
|
savedKey string
|
||||||
|
saveCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *wireStoreStub) Save(_ context.Context, key string, cred secretstore.Credential) error {
|
||||||
|
s.saveCalled = true
|
||||||
|
s.savedKey = key
|
||||||
|
s.saved = cred
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *wireStoreStub) Load(context.Context, string) (secretstore.Credential, error) {
|
||||||
|
return secretstore.Credential{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireMailServiceStub struct{}
|
||||||
|
|
||||||
|
func (wireMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wireMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wireMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) {
|
||||||
|
return imapclient.Message{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAppWiresSetupAndMCPCommands(t *testing.T) {
|
||||||
|
stdin := strings.NewReader("unused")
|
||||||
stdout := &bytes.Buffer{}
|
stdout := &bytes.Buffer{}
|
||||||
stderr := &bytes.Buffer{}
|
stderr := &bytes.Buffer{}
|
||||||
app := buildApp(stdin, stdout, stderr)
|
|
||||||
|
|
||||||
err := app.Run([]string{"setup"})
|
prompter := &promptStub{
|
||||||
if err == nil {
|
credential: secretstore.Credential{
|
||||||
t.Fatal("expected placeholder store error")
|
Host: "imap.example.com",
|
||||||
|
Username: "alice",
|
||||||
|
Password: "secret",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if err.Error() != errSecretStoreNotConfigured.Error() {
|
store := &wireStoreStub{}
|
||||||
t.Fatalf("expected placeholder store error, got %v", err)
|
mail := wireMailServiceStub{}
|
||||||
|
runner := &runnerStub{}
|
||||||
|
walletClient := &walletClientStub{}
|
||||||
|
|
||||||
|
var gotStoreClient kwallet.Client
|
||||||
|
var gotRunnerStore secretstore.Store
|
||||||
|
var gotRunnerMail mcpserver.MailService
|
||||||
|
|
||||||
|
app := buildApp(stdin, stdout, stderr, runtimeFactories{
|
||||||
|
newPrompter: func(in io.Reader, errOut io.Writer) SetupPrompter {
|
||||||
|
if in != stdin {
|
||||||
|
t.Fatalf("expected stdin to be forwarded to prompter")
|
||||||
|
}
|
||||||
|
if errOut != stderr {
|
||||||
|
t.Fatalf("expected stderr to be forwarded to prompter")
|
||||||
|
}
|
||||||
|
return prompter
|
||||||
|
},
|
||||||
|
newWalletClient: func() kwallet.Client {
|
||||||
|
return walletClient
|
||||||
|
},
|
||||||
|
newStore: func(client kwallet.Client) secretstore.Store {
|
||||||
|
gotStoreClient = client
|
||||||
|
return store
|
||||||
|
},
|
||||||
|
newMailService: func() mcpserver.MailService {
|
||||||
|
return mail
|
||||||
|
},
|
||||||
|
newRunner: func(store secretstore.Store, mail mcpserver.MailService, in io.Reader, out io.Writer, errOut io.Writer) MCPRunner {
|
||||||
|
gotRunnerStore = store
|
||||||
|
gotRunnerMail = mail
|
||||||
|
if in != stdin {
|
||||||
|
t.Fatalf("expected stdin to be forwarded to runner")
|
||||||
|
}
|
||||||
|
if out != stdout {
|
||||||
|
t.Fatalf("expected stdout to be forwarded to runner")
|
||||||
|
}
|
||||||
|
if errOut != stderr {
|
||||||
|
t.Fatalf("expected stderr to be forwarded to runner")
|
||||||
|
}
|
||||||
|
return runner
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := app.Run([]string{"setup"}); err != nil {
|
||||||
|
t.Fatalf("setup returned error: %v", err)
|
||||||
}
|
}
|
||||||
if got := stderr.String(); got != "IMAP host: Username: Password: " {
|
if !prompter.called {
|
||||||
t.Fatalf("expected setup prompts on stderr, got %q", got)
|
t.Fatal("expected setup prompter to be called")
|
||||||
|
}
|
||||||
|
if !store.saveCalled {
|
||||||
|
t.Fatal("expected store Save to be called")
|
||||||
|
}
|
||||||
|
if store.savedKey != secretstore.DefaultAccountKey {
|
||||||
|
t.Fatalf("expected setup to save %q, got %q", secretstore.DefaultAccountKey, store.savedKey)
|
||||||
|
}
|
||||||
|
if store.saved.Host != "imap.example.com" || store.saved.Username != "alice" || store.saved.Password != "secret" {
|
||||||
|
t.Fatalf("unexpected saved credential: %#v", store.saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Run([]string{"mcp"}); err != nil {
|
||||||
|
t.Fatalf("mcp returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !runner.called {
|
||||||
|
t.Fatal("expected MCP runner to be called")
|
||||||
|
}
|
||||||
|
if gotStoreClient != walletClient {
|
||||||
|
t.Fatal("expected wallet client to be passed to the store factory")
|
||||||
|
}
|
||||||
|
if gotRunnerStore != store {
|
||||||
|
t.Fatal("expected runner to receive the assembled store")
|
||||||
|
}
|
||||||
|
if gotRunnerMail != mail {
|
||||||
|
t.Fatal("expected runner to receive the assembled mail service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildAppConfiguresMCPCommand(t *testing.T) {
|
func TestBuildAppReturnsConfiguredApp(t *testing.T) {
|
||||||
app := buildApp(strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
|
app := BuildApp()
|
||||||
|
if app == nil {
|
||||||
err := app.Run([]string{"mcp"})
|
t.Fatal("expected app instance")
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected placeholder runner error")
|
|
||||||
}
|
|
||||||
if err.Error() != errMCPRunnerNotConfigured.Error() {
|
|
||||||
t.Fatalf("expected placeholder runner error, got %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue