Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,25 @@ jobs:

- name: Run tests
run: make test

proto:
name: Proto lint + breaking
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# buf breaking needs main's history to diff against.
fetch-depth: 0

- uses: bufbuild/buf-action@v1
with:
input: proto
lint: true
# `format: true` makes the action run `buf format -d --exit-code`,
# failing the job on any unformatted .proto. Catches drift before
# generated code can diverge.
format: true
# Only run breaking on PRs (push to main has nothing to diff against).
breaking: ${{ github.event_name == 'pull_request' }}
breaking_against: 'https://github.com/${{ github.repository }}.git#branch=main,subdir=proto'
32 changes: 32 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# golangci-lint v2 configuration.
# Run via `make lint-go` (or `golangci-lint run ./...`).
version: "2"

run:
# Build tags used by the test/storage matrix so all files type-check.
tests: true

linters:
# Default linter set (errcheck, govet, ineffassign, staticcheck, unused).
default: standard
settings:
staticcheck:
checks:
- all
# QF1008 wants embedded-field selectors collapsed (p.Config.X -> p.X);
# the codebase deliberately keeps the explicit p.Config.X form.
- -QF1008
exclusions:
# Generated protobuf/gateway/openapi code is owned by buf, not us.
generated: lax
paths:
- gen/
- ".*\\.pb\\.go$"
- ".*\\.pb\\.gw\\.go$"

formatters:
enable:
- gofmt
exclusions:
paths:
- gen/
68 changes: 68 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,71 @@ generate-graphql:
generate-db-template:
cp -rf internal/storage/db/provider_template internal/storage/db/${dbname}
find internal/storage/db/${dbname} -type f -exec sed -i -e 's/provider_template/${dbname}/g' {} \;

# ----------------------------------------------------------------------------
# Protobuf (Phase 0+): public-API source of truth under ./proto.
# `buf` is installed on demand into $(GOBIN) if missing.
# ----------------------------------------------------------------------------
BUF ?= $(shell command -v buf 2>/dev/null)
BUF_VERSION ?= v1.47.2

.PHONY: proto-tools proto-lint proto-breaking proto-gen

proto-tools:
@if [ -z "$(BUF)" ]; then \
echo "Installing buf $(BUF_VERSION) via go install"; \
go install github.com/bufbuild/buf/cmd/buf@$(BUF_VERSION); \
fi

proto-lint: proto-tools
cd proto && buf lint

# Compare the working tree's proto against origin/main; fails on breaking changes.
# Override BUF_BREAKING_AGAINST for local runs (e.g. "main" or a SHA).
BUF_BREAKING_AGAINST ?= .git#branch=origin/main,subdir=proto
proto-breaking: proto-tools
cd proto && buf breaking --against '../$(BUF_BREAKING_AGAINST)'

proto-gen: proto-tools
cd proto && buf dep update && buf generate

# ----------------------------------------------------------------------------
# Formatting & linting (Go + TypeScript). `make fmt` before committing,
# `make lint` in CI. golangci-lint is installed on demand if missing.
# ----------------------------------------------------------------------------
GOLANGCI_LINT ?= $(shell command -v golangci-lint 2>/dev/null)
GOLANGCI_LINT_VERSION ?= v2.11.4

.PHONY: fmt fmt-go fmt-ts lint lint-go lint-ts lint-tools

# Format everything.
fmt: fmt-go fmt-ts

# gofmt -s over all hand-written Go sources (generated protobuf output under
# gen/ is excluded — it is owned by buf).
fmt-go:
@gofmt -s -w $(shell find . -type f -name '*.go' -not -path './gen/*')

# Prettier over both web apps via their configured format scripts.
fmt-ts:
cd web/app && npm run format
cd web/dashboard && npm run format

# Lint everything.
lint: lint-go lint-ts

lint-tools:
@if [ -z "$(GOLANGCI_LINT)" ]; then \
echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)"; \
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION); \
fi

# golangci-lint over the module. Generated code under gen/ is excluded via
# .golangci.yml.
lint-go: lint-tools
golangci-lint run ./...

# Prettier in --check mode: fails (non-zero) if any web source is unformatted.
lint-ts:
cd web/app && npx prettier --check 'src/**/*.(ts|tsx|js|jsx)'
cd web/dashboard && npx prettier --check 'src/**/*.(ts|tsx|js|jsx)'
177 changes: 177 additions & 0 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package cmd

import (
"context"
"os"
"os/signal"
"syscall"

"github.com/rs/zerolog"
"github.com/spf13/cobra"

"github.com/authorizerdev/authorizer/internal/audit"
"github.com/authorizerdev/authorizer/internal/authorization"
"github.com/authorizerdev/authorizer/internal/constants"
"github.com/authorizerdev/authorizer/internal/email"
"github.com/authorizerdev/authorizer/internal/events"
"github.com/authorizerdev/authorizer/internal/grpcsrv"
"github.com/authorizerdev/authorizer/internal/mcp"
"github.com/authorizerdev/authorizer/internal/memory_store"
"github.com/authorizerdev/authorizer/internal/service"
"github.com/authorizerdev/authorizer/internal/sms"
"github.com/authorizerdev/authorizer/internal/storage"
"github.com/authorizerdev/authorizer/internal/token"
)

// mcpArgs are the MCP-subcommand-only flags. The root command's flags
// (--database-type, --client-id, --jwt-secret, ...) are inherited by the
// subcommand automatically since they live on RootCmd.
var mcpArgs struct {
// bearer is propagated as `Authorization: Bearer <bearer>` on every
// outgoing gRPC call. Without it the MCP server runs anonymously —
// fine for the `meta` tool (public) but identity-bearing tools
// (`profile`, `permissions`) won't have a caller to attribute to.
bearer string
}

// mcpCmd serves Authorizer's MCP surface over stdio. Designed to be wired
// into Claude Code or any other MCP host via:
//
// claude mcp add authorizer -- /path/to/authorizer mcp --client-id=... \
// --database-type=sqlite --database-url=auth.db --mcp-bearer=$TOKEN
//
// Which tools are exposed is declared at the proto layer via the
// `(authorizer.common.v1.mcp_tool).exposed` option; the MCP server discovers
// them at startup.
//
// Transport: STDIO ONLY. The MCP server has no auth/rate-limit interceptors
// of its own — the security model relies on the OS-level trust boundary of
// the subprocess. See internal/mcp/server.go's Server type comment.
var mcpCmd = &cobra.Command{
Use: "mcp",
Short: "Serve Authorizer's MCP tool surface over stdio",
Long: "Exposes a subset of Authorizer's gRPC methods (those marked " +
"(authorizer.common.v1.mcp_tool).exposed=true in proto) as MCP " +
"tools, suitable for use with Claude Code or any MCP-compatible " +
"host. Stdio is the only supported transport.",
Run: runMCP,
}

func init() {
mcpCmd.Flags().StringVar(&mcpArgs.bearer, "mcp-bearer", "",
"Bearer token to attach to every outgoing gRPC call (carries the "+
"user identity for tools like Profile / Permissions / Session). "+
"When unset the MCP server runs anonymously; public tools (Meta) "+
"still work but identity-bearing tools will fail authn.")
RootCmd.AddCommand(mcpCmd)
}

func runMCP(_ *cobra.Command, _ []string) {
// MCP stdio mode: stderr-only logging so it doesn't interleave with the
// JSON-RPC framing on stdout.
log := zerolog.New(os.Stderr).With().Timestamp().Logger()

// Wire all subsystems an MCP-exposed tool might need. As more ops
// migrate into internal/service, this list stays the same — the
// service-provider dependencies don't change per op, only the methods
// on the provider do.
storageProvider, err := storage.New(&rootArgs.config, &storage.Dependencies{Log: &log})
if err != nil {
log.Fatal().Err(err).Msg("failed to create storage provider")
}
memoryStoreProvider, err := memory_store.New(&rootArgs.config, &memory_store.Dependencies{
Log: &log,
StorageProvider: storageProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create memory store provider")
}
tokenProvider, err := token.New(&rootArgs.config, &token.Dependencies{
Log: &log,
MemoryStoreProvider: memoryStoreProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create token provider")
}
emailProvider, err := email.New(&rootArgs.config, &email.Dependencies{
Log: &log,
StorageProvider: storageProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create email provider")
}
smsProvider, err := sms.New(&rootArgs.config, &sms.Dependencies{Log: &log})
if err != nil {
log.Fatal().Err(err).Msg("failed to create sms provider")
}
auditProvider := audit.New(&audit.Dependencies{
Log: &log,
StorageProvider: storageProvider,
})
eventsProvider, err := events.New(&rootArgs.config, &events.Dependencies{
Log: &log,
StorageProvider: storageProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create events provider")
}

authorizationProvider, err := authorization.New(
&authorization.Config{CacheTTL: 0},
&authorization.Dependencies{
Log: &log,
StorageProvider: storageProvider,
MemoryStoreProvider: memoryStoreProvider,
},
)
if err != nil {
log.Fatal().Err(err).Msg("failed to create authorization provider")
}

svc, err := service.New(&rootArgs.config, &service.Dependencies{
Log: &log,
AuditProvider: auditProvider,
AuthorizationProvider: authorizationProvider,
EmailProvider: emailProvider,
EventsProvider: eventsProvider,
MemoryStoreProvider: memoryStoreProvider,
SMSProvider: smsProvider,
StorageProvider: storageProvider,
TokenProvider: tokenProvider,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create service provider")
}

grpcSrv, err := grpcsrv.New(":0", &grpcsrv.Dependencies{
Log: &log,
Config: &rootArgs.config,
ServiceProvider: svc,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create grpc server")
}

mcpSrv, err := mcp.New(&log, grpcSrv.GRPCServer(), mcp.Options{
Name: "authorizer",
Version: constants.VERSION,
Bearer: mcpArgs.bearer,
})
if err != nil {
log.Fatal().Err(err).Msg("failed to create mcp server")
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
<-c
cancel()
}()

if err := mcpSrv.RunStdio(ctx); err != nil {
log.Error().Err(err).Msg("mcp server exited")
os.Exit(1)
}
}
Loading
Loading