// Command wikijs-mcp exposes a WikiJS instance as a Model Context Protocol // server over stdio or streamable HTTP. package main import ( "context" "errors" "flag" "fmt" "log/slog" "net/http" "os" "os/signal" "strings" "syscall" "wikijs-mcp/internal/config" "wikijs-mcp/internal/server" "wikijs-mcp/internal/transport" "wikijs-mcp/internal/wikijs" "github.com/modelcontextprotocol/go-sdk/mcp" ) // version is set at build time with -ldflags "-X main.version=...". var version = "dev" func main() { if err := run(os.Args[1:]); err != nil { fmt.Fprintf(os.Stderr, "wikijs-mcp: %v\n", err) os.Exit(1) } } func run(args []string) error { envFile, useEnvFile, err := envFileFromArgs(args) if err != nil { return err } if useEnvFile { if err := config.LoadEnvFile(envFile); err != nil { return err } } else if err := config.LoadDotEnv(); err != nil { return err } cfg, err := config.Load() if err != nil { return err } fs := flag.NewFlagSet("wikijs-mcp", flag.ContinueOnError) fs.SetOutput(os.Stderr) fs.Usage = func() { out := fs.Output() fmt.Fprintln(out, "Usage: wikijs-mcp [flags]") fmt.Fprintln(out) fmt.Fprintln(out, "WikiJS connection (environment):") fmt.Fprintln(out, " WIKIJS_URL Base URL of the WikiJS instance (required)") fmt.Fprintln(out, " WIKIJS_API_KEY WikiJS API key (required)") fmt.Fprintln(out, " WIKIJS_ENABLE_EDIT Expose create_page and edit_page (default false)") fmt.Fprintln(out, " WIKIJS_TIMEOUT GraphQL request timeout (default 30s)") fmt.Fprintln(out) fmt.Fprintln(out, "Other environment:") fmt.Fprintln(out, " ENV_FILE env file to load (default \".env\")") fmt.Fprintln(out, " MCP_AUTH_TOKEN bearer token for HTTP (required for non-loopback)") fmt.Fprintln(out) fmt.Fprintln(out, "Flags:") fs.PrintDefaults() } fs.String("env-file", "", "env file to load (overrides ENV_FILE and .env)") fs.StringVar(&cfg.Transport, "transport", cfg.Transport, `"stdio" or "http"`) fs.StringVar(&cfg.Addr, "addr", cfg.Addr, "listen address for the HTTP transport") fs.StringVar(&cfg.Path, "path", cfg.Path, "MCP endpoint path for the HTTP transport") fs.BoolVar(&cfg.Stateless, "stateless", cfg.Stateless, "disable MCP sessions on the HTTP transport") logLevel := strings.ToLower(cfg.LogLevel.String()) fs.StringVar(&logLevel, "log-level", logLevel, "debug, info, warn, or error") showVersion := fs.Bool("version", false, "print the version and exit") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return nil } return err } if *showVersion { fmt.Println(version) return nil } if fs.NArg() > 0 { return fmt.Errorf("unexpected arguments: %v", fs.Args()) } if cfg.LogLevel, err = config.ParseLogLevel(logLevel); err != nil { return err } if err := cfg.Validate(); err != nil { return err } logger := newLogger(cfg.LogLevel) protocolLogger := newLogger(slog.LevelWarn) if cfg.LogLevel == slog.LevelDebug { protocolLogger = logger } wikiClient, err := wikijs.New(cfg.WikiJSURL, cfg.WikiJSAPIKey, wikijs.WithHTTPClient(&http.Client{Timeout: cfg.Timeout}), wikijs.WithLogger(logger), wikijs.WithUserAgent("wikijs-mcp/"+version), ) if err != nil { return err } mcpServer := server.New(wikiClient, server.Options{ Version: version, EnableEdit: cfg.EnableEdit, Logger: protocolLogger, }) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() switch cfg.Transport { case config.TransportStdio: logger.Info("WikiJS MCP server running", "transport", "stdio", "version", version, "edit", cfg.EnableEdit) if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil && !errors.Is(err, context.Canceled) { return fmt.Errorf("stdio server: %w", err) } return nil case config.TransportHTTP: return transport.ServeHTTP(ctx, transport.HTTPOptions{ Addr: cfg.Addr, Path: cfg.Path, Stateless: cfg.Stateless, AuthToken: cfg.AuthToken, Logger: logger, ProtocolLogger: protocolLogger, NewServer: func(*http.Request) *mcp.Server { return mcpServer }, }) default: return fmt.Errorf("unsupported transport %q", cfg.Transport) } } func newLogger(level slog.Level) *slog.Logger { return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: level, })) } // envFileFromArgs finds --env-file before flag parsing, because the env file // must be loaded before the configuration is read. The regular flag set also // defines --env-file so it appears in help and is parsed consistently. func envFileFromArgs(args []string) (path string, found bool, err error) { for i := 0; i < len(args); i++ { arg := args[i] if arg == "--" { break } switch { case arg == "--env-file" || arg == "-env-file": if i+1 >= len(args) { return "", true, fmt.Errorf("flag needs an argument: %s", arg) } path = args[i+1] found = true i++ case strings.HasPrefix(arg, "--env-file="): path = strings.TrimPrefix(arg, "--env-file=") found = true case strings.HasPrefix(arg, "-env-file="): path = strings.TrimPrefix(arg, "-env-file=") found = true } } if found && strings.TrimSpace(path) == "" { return "", true, errors.New("--env-file must not be empty") } return path, found, nil }