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"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"email-mcp/internal/mcpserver"
|
||||
"email-mcp/internal/secretstore"
|
||||
|
|
@ -24,7 +23,7 @@ type App struct {
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ func (r *runnerStub) Run(context.Context) error {
|
|||
}
|
||||
|
||||
func TestAppRunRejectsUnknownCommand(t *testing.T) {
|
||||
app := NewApp()
|
||||
app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{})
|
||||
|
||||
err := app.Run([]string{"unknown"})
|
||||
if err == nil {
|
||||
|
|
@ -64,7 +64,7 @@ func TestAppRunRejectsUnknownCommand(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) {
|
||||
app := NewApp()
|
||||
app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{})
|
||||
|
||||
err := app.Run(nil)
|
||||
if err == nil {
|
||||
|
|
@ -148,7 +148,7 @@ func TestAppRunMCPDelegatesToRunner(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
|
||||
app := NewApp()
|
||||
app := NewAppWithDependencies(nil, nil, nil, &bytes.Buffer{})
|
||||
|
||||
tests := []struct {
|
||||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"email-mcp/internal/imapclient"
|
||||
"email-mcp/internal/mcpserver"
|
||||
"email-mcp/internal/secretstore"
|
||||
"email-mcp/internal/secretstore/kwallet"
|
||||
)
|
||||
|
||||
var errSecretStoreNotConfigured = errors.New("secret store is not available in this build yet")
|
||||
var errMCPRunnerNotConfigured = errors.New("mcp runner is not available in this build yet")
|
||||
|
||||
type placeholderStore struct{}
|
||||
|
||||
func (placeholderStore) Save(context.Context, string, secretstore.Credential) error {
|
||||
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
|
||||
type runtimeFactories struct {
|
||||
newPrompter func(io.Reader, io.Writer) SetupPrompter
|
||||
newWalletClient func() kwallet.Client
|
||||
newStore func(kwallet.Client) secretstore.Store
|
||||
newMailService func() mcpserver.MailService
|
||||
newRunner func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner
|
||||
}
|
||||
|
||||
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 {
|
||||
_ = stdout
|
||||
func buildApp(stdin io.Reader, stdout io.Writer, stderr io.Writer, factories runtimeFactories) *App {
|
||||
factories = factories.withDefaults()
|
||||
|
||||
return NewAppWithDependencies(
|
||||
NewInteractiveSetupPrompter(stdin, stderr),
|
||||
placeholderStore{},
|
||||
placeholderRunner{},
|
||||
stderr,
|
||||
)
|
||||
prompter := factories.newPrompter(stdin, stderr)
|
||||
store := factories.newStore(factories.newWalletClient())
|
||||
mailService := factories.newMailService()
|
||||
runner := factories.newRunner(store, mailService, stdin, stdout, 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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"email-mcp/internal/imapclient"
|
||||
"email-mcp/internal/mcpserver"
|
||||
"email-mcp/internal/secretstore"
|
||||
"email-mcp/internal/secretstore/kwallet"
|
||||
)
|
||||
|
||||
func TestBuildAppConfiguresSetupCommand(t *testing.T) {
|
||||
stdin := strings.NewReader("imap.example.com\nalice\nsecret\n")
|
||||
type walletClientStub struct{}
|
||||
|
||||
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{}
|
||||
stderr := &bytes.Buffer{}
|
||||
app := buildApp(stdin, stdout, stderr)
|
||||
|
||||
err := app.Run([]string{"setup"})
|
||||
if err == nil {
|
||||
t.Fatal("expected placeholder store error")
|
||||
prompter := &promptStub{
|
||||
credential: secretstore.Credential{
|
||||
Host: "imap.example.com",
|
||||
Username: "alice",
|
||||
Password: "secret",
|
||||
},
|
||||
}
|
||||
if err.Error() != errSecretStoreNotConfigured.Error() {
|
||||
t.Fatalf("expected placeholder store error, got %v", err)
|
||||
store := &wireStoreStub{}
|
||||
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: " {
|
||||
t.Fatalf("expected setup prompts on stderr, got %q", got)
|
||||
if !prompter.called {
|
||||
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) {
|
||||
app := buildApp(strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{})
|
||||
|
||||
err := app.Run([]string{"mcp"})
|
||||
if err == nil {
|
||||
t.Fatal("expected placeholder runner error")
|
||||
}
|
||||
if err.Error() != errMCPRunnerNotConfigured.Error() {
|
||||
t.Fatalf("expected placeholder runner error, got %v", err)
|
||||
func TestBuildAppReturnsConfiguredApp(t *testing.T) {
|
||||
app := BuildApp()
|
||||
if app == nil {
|
||||
t.Fatal("expected app instance")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue