main.go 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. // Command wikijs-mcp exposes a WikiJS instance as a Model Context Protocol
  2. // server over stdio or streamable HTTP.
  3. package main
  4. import (
  5. "context"
  6. "errors"
  7. "flag"
  8. "fmt"
  9. "log/slog"
  10. "net/http"
  11. "os"
  12. "os/signal"
  13. "strings"
  14. "syscall"
  15. "wikijs-mcp/internal/config"
  16. "wikijs-mcp/internal/server"
  17. "wikijs-mcp/internal/transport"
  18. "wikijs-mcp/internal/wikijs"
  19. "github.com/modelcontextprotocol/go-sdk/mcp"
  20. )
  21. // version is set at build time with -ldflags "-X main.version=...".
  22. var version = "dev"
  23. func main() {
  24. if err := run(os.Args[1:]); err != nil {
  25. fmt.Fprintf(os.Stderr, "wikijs-mcp: %v\n", err)
  26. os.Exit(1)
  27. }
  28. }
  29. func run(args []string) error {
  30. envFile, useEnvFile, err := envFileFromArgs(args)
  31. if err != nil {
  32. return err
  33. }
  34. if useEnvFile {
  35. if err := config.LoadEnvFile(envFile); err != nil {
  36. return err
  37. }
  38. } else if err := config.LoadDotEnv(); err != nil {
  39. return err
  40. }
  41. cfg, err := config.Load()
  42. if err != nil {
  43. return err
  44. }
  45. fs := flag.NewFlagSet("wikijs-mcp", flag.ContinueOnError)
  46. fs.SetOutput(os.Stderr)
  47. fs.Usage = func() {
  48. out := fs.Output()
  49. fmt.Fprintln(out, "Usage: wikijs-mcp [flags]")
  50. fmt.Fprintln(out)
  51. fmt.Fprintln(out, "WikiJS connection (environment):")
  52. fmt.Fprintln(out, " WIKIJS_URL Base URL of the WikiJS instance (required)")
  53. fmt.Fprintln(out, " WIKIJS_API_KEY WikiJS API key (required)")
  54. fmt.Fprintln(out, " WIKIJS_ENABLE_EDIT Expose create_page and edit_page (default false)")
  55. fmt.Fprintln(out, " WIKIJS_TIMEOUT GraphQL request timeout (default 30s)")
  56. fmt.Fprintln(out)
  57. fmt.Fprintln(out, "Other environment:")
  58. fmt.Fprintln(out, " ENV_FILE env file to load (default \".env\")")
  59. fmt.Fprintln(out, " MCP_AUTH_TOKEN bearer token for HTTP (required for non-loopback)")
  60. fmt.Fprintln(out)
  61. fmt.Fprintln(out, "Flags:")
  62. fs.PrintDefaults()
  63. }
  64. fs.String("env-file", "", "env file to load (overrides ENV_FILE and .env)")
  65. fs.StringVar(&cfg.Transport, "transport", cfg.Transport, `"stdio" or "http"`)
  66. fs.StringVar(&cfg.Addr, "addr", cfg.Addr, "listen address for the HTTP transport")
  67. fs.StringVar(&cfg.Path, "path", cfg.Path, "MCP endpoint path for the HTTP transport")
  68. fs.BoolVar(&cfg.Stateless, "stateless", cfg.Stateless, "disable MCP sessions on the HTTP transport")
  69. logLevel := strings.ToLower(cfg.LogLevel.String())
  70. fs.StringVar(&logLevel, "log-level", logLevel, "debug, info, warn, or error")
  71. showVersion := fs.Bool("version", false, "print the version and exit")
  72. if err := fs.Parse(args); err != nil {
  73. if errors.Is(err, flag.ErrHelp) {
  74. return nil
  75. }
  76. return err
  77. }
  78. if *showVersion {
  79. fmt.Println(version)
  80. return nil
  81. }
  82. if fs.NArg() > 0 {
  83. return fmt.Errorf("unexpected arguments: %v", fs.Args())
  84. }
  85. if cfg.LogLevel, err = config.ParseLogLevel(logLevel); err != nil {
  86. return err
  87. }
  88. if err := cfg.Validate(); err != nil {
  89. return err
  90. }
  91. logger := newLogger(cfg.LogLevel)
  92. protocolLogger := newLogger(slog.LevelWarn)
  93. if cfg.LogLevel == slog.LevelDebug {
  94. protocolLogger = logger
  95. }
  96. wikiClient, err := wikijs.New(cfg.WikiJSURL, cfg.WikiJSAPIKey,
  97. wikijs.WithHTTPClient(&http.Client{Timeout: cfg.Timeout}),
  98. wikijs.WithLogger(logger),
  99. wikijs.WithUserAgent("wikijs-mcp/"+version),
  100. )
  101. if err != nil {
  102. return err
  103. }
  104. mcpServer := server.New(wikiClient, server.Options{
  105. Version: version,
  106. EnableEdit: cfg.EnableEdit,
  107. Logger: protocolLogger,
  108. })
  109. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  110. defer stop()
  111. switch cfg.Transport {
  112. case config.TransportStdio:
  113. logger.Info("WikiJS MCP server running", "transport", "stdio", "version", version, "edit", cfg.EnableEdit)
  114. if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil && !errors.Is(err, context.Canceled) {
  115. return fmt.Errorf("stdio server: %w", err)
  116. }
  117. return nil
  118. case config.TransportHTTP:
  119. return transport.ServeHTTP(ctx, transport.HTTPOptions{
  120. Addr: cfg.Addr,
  121. Path: cfg.Path,
  122. Stateless: cfg.Stateless,
  123. AuthToken: cfg.AuthToken,
  124. Logger: logger,
  125. ProtocolLogger: protocolLogger,
  126. NewServer: func(*http.Request) *mcp.Server { return mcpServer },
  127. })
  128. default:
  129. return fmt.Errorf("unsupported transport %q", cfg.Transport)
  130. }
  131. }
  132. func newLogger(level slog.Level) *slog.Logger {
  133. return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
  134. Level: level,
  135. }))
  136. }
  137. // envFileFromArgs finds --env-file before flag parsing, because the env file
  138. // must be loaded before the configuration is read. The regular flag set also
  139. // defines --env-file so it appears in help and is parsed consistently.
  140. func envFileFromArgs(args []string) (path string, found bool, err error) {
  141. for i := 0; i < len(args); i++ {
  142. arg := args[i]
  143. if arg == "--" {
  144. break
  145. }
  146. switch {
  147. case arg == "--env-file" || arg == "-env-file":
  148. if i+1 >= len(args) {
  149. return "", true, fmt.Errorf("flag needs an argument: %s", arg)
  150. }
  151. path = args[i+1]
  152. found = true
  153. i++
  154. case strings.HasPrefix(arg, "--env-file="):
  155. path = strings.TrimPrefix(arg, "--env-file=")
  156. found = true
  157. case strings.HasPrefix(arg, "-env-file="):
  158. path = strings.TrimPrefix(arg, "-env-file=")
  159. found = true
  160. }
  161. }
  162. if found && strings.TrimSpace(path) == "" {
  163. return "", true, errors.New("--env-file must not be empty")
  164. }
  165. return path, found, nil
  166. }