Skip to content

Commit c3674f4

Browse files
authored
feat(config): make OAuth callback port configurable (#39) (#48)
Port 8000 conflicts with common dev servers (Next.js, etc). Add a minimal config.json with oauth_port setting, change the default to 8100, and improve the port-conflict error message. - Add Config struct, LoadConfig/WriteDefaultConfig to internal/config - Extract resolveOAuthPort() with resolution order: env var → config.json → default - Validate port range 1-65535 for both env var and config paths - `gsuite-mcp init` now creates config.json with defaults - `gsuite-mcp check` shows resolved OAuth port (with env override note) - `gsuite-mcp help` shows config.json path and GSUITE_MCP_OAUTH_PORT env var - Update README, INSTALLATION.md, and AGENTS.md documentation
1 parent d9cfaf4 commit c3674f4

File tree

8 files changed

+304
-16
lines changed

8 files changed

+304
-16
lines changed

INSTALLATION.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ Claude should use gsuite-mcp to fetch your real data.
177177
| `403: SERVICE_DISABLED` | API not enabled in GCP project | Run `gsuite-mcp check` for enable links |
178178
| `invalid_grant` | Token expired or revoked | Run `gsuite-mcp auth` |
179179
| `401: invalid_client` | Wrong or corrupted client_secret.json | Re-download from GCP Console |
180+
| `port XXXX is in use` | OAuth callback port conflict | Set `oauth_port` in config.json or `GSUITE_MCP_OAUTH_PORT` env var |
180181

181182
### Token Expired or Invalid Credentials
182183

@@ -254,12 +255,23 @@ All configuration lives in `~/.config/gsuite-mcp/`:
254255

255256
```
256257
~/.config/gsuite-mcp/
257-
├── client_secret.json # Your OAuth app credentials
258+
├── client_secret.json # Your OAuth app credentials
259+
├── config.json # Settings (optional — created by `gsuite-mcp init`)
258260
└── credentials/
259261
├── alice@gmail.com.json # Token for alice@gmail.com
260262
└── bob@company.com.json # Token for bob@company.com
261263
```
262264

265+
### config.json
266+
267+
Optional configuration file created by `gsuite-mcp init`. Settings:
268+
269+
| Key | Default | Description |
270+
|-----|---------|-------------|
271+
| `oauth_port` | `8100` | Port for the OAuth callback server during `gsuite-mcp auth` |
272+
273+
Override `oauth_port` via the `GSUITE_MCP_OAUTH_PORT` environment variable.
274+
263275
## Next Steps
264276

265277
- Read the [README](README.md) for the full tool reference

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,11 +280,21 @@ See [INSTALLATION.md](INSTALLATION.md) for full setup instructions.
280280
```
281281
~/.config/gsuite-mcp/
282282
├── client_secret.json # Your OAuth app credentials
283-
├── config.json # Account configuration (optional)
283+
├── config.json # Settings (optional — created by `gsuite-mcp init`)
284284
└── credentials/
285-
└── {label}.json # Per-account tokens
285+
└── {email}.json # Per-account tokens
286286
```
287287

288+
### OAuth Callback Port
289+
290+
The default OAuth callback port is **8100**. Override it in `config.json`:
291+
292+
```json
293+
{ "oauth_port": 9000 }
294+
```
295+
296+
Or via environment variable: `GSUITE_MCP_OAUTH_PORT=9000`
297+
288298
## Development
289299

290300
```bash

cmd/gsuite-mcp/check.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ func runCheck() {
4545
fmt.Printf(" ✓ OAuth client ID (project: %s)\n", projectNumber)
4646
}
4747

48+
port, envOverride, err := auth.ResolveOAuthPort()
49+
if err != nil {
50+
fmt.Printf(" ✗ OAuth port: %v\n", err)
51+
issues++
52+
} else if envOverride {
53+
fmt.Printf(" ✓ OAuth port: %d (env override)\n", port)
54+
} else {
55+
fmt.Printf(" ✓ OAuth port: %d\n", port)
56+
}
57+
4858
// Stage 2: Accounts
4959
fmt.Println("\nChecking accounts...")
5060

cmd/gsuite-mcp/main.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,21 +104,38 @@ When tools request an account without credentials, auth flow is triggered automa
104104
105105
Configuration:
106106
Config dir: %s
107+
Config file: %s
107108
Credentials: %s
108109
Client secret: %s
109110
111+
Environment variables:
112+
GSUITE_MCP_OAUTH_PORT Override OAuth callback port (default: %d)
113+
110114
For more information, see README.md
111115
`, serverName, serverName, serverName, serverName, serverName, serverName,
112-
config.DefaultConfigDir(), config.CredentialsDir(), config.ClientSecretPath())
116+
config.DefaultConfigDir(), config.ConfigPath(), config.CredentialsDir(), config.ClientSecretPath(),
117+
config.DefaultOAuthPort)
113118
}
114119

115-
// runInit ensures config directory exists and shows setup instructions.
120+
// runInit ensures config directory exists, creates default config, and shows setup instructions.
116121
func runInit() {
117122
if err := config.EnsureConfigDir(); err != nil {
118123
fmt.Fprintf(os.Stderr, "Error creating config directory: %v\n", err)
119124
os.Exit(1)
120125
}
121126
fmt.Printf("Config directory ready: %s\n", config.DefaultConfigDir())
127+
128+
created, err := config.WriteDefaultConfig()
129+
if err != nil {
130+
fmt.Fprintf(os.Stderr, "Error creating config.json: %v\n", err)
131+
os.Exit(1)
132+
}
133+
if created {
134+
fmt.Printf("Created config.json: %s\n", config.ConfigPath())
135+
} else {
136+
fmt.Printf("Config.json exists: %s\n", config.ConfigPath())
137+
}
138+
122139
fmt.Printf("\nSetup steps:\n")
123140
fmt.Printf("1. Create OAuth credentials in Google Cloud Console\n")
124141
fmt.Printf("2. Download and save as: %s\n", config.ClientSecretPath())

docs/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,10 @@ gsuite-mcp/
5858
### Configuration Files
5959
```
6060
~/.config/gsuite-mcp/
61-
├── config.json # Account configuration
6261
├── client_secret.json # Google OAuth app credentials
62+
├── config.json # Settings: oauth_port (optional — created by init)
6363
└── credentials/
64-
└── {label}.json # Per-account OAuth tokens
64+
└── {email}.json # Per-account OAuth tokens
6565
```
6666

6767
## Key Rules

internal/auth/auth.go

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"os"
1818
"os/exec"
1919
"runtime"
20+
"strconv"
2021
"sync"
2122
"time"
2223

@@ -46,9 +47,6 @@ const (
4647
// oauthCallbackTimeout is the maximum time to wait for OAuth flow completion.
4748
oauthCallbackTimeout = 5 * time.Minute
4849

49-
// defaultOAuthPort is the default port for OAuth callback.
50-
defaultOAuthPort = 8000
51-
5250
// oauthResultTimeout is the maximum time to wait for the main loop to process the result.
5351
oauthResultTimeout = 10 * time.Second
5452

@@ -185,20 +183,52 @@ func loadOAuthConfig() (*oauth2.Config, error) {
185183
return cfg, nil
186184
}
187185

186+
// resolveOAuthPort determines the OAuth callback port.
187+
// Resolution order: GSUITE_MCP_OAUTH_PORT env var → config.json → default (8100).
188+
func resolveOAuthPort() (int, error) {
189+
if portStr := os.Getenv("GSUITE_MCP_OAUTH_PORT"); portStr != "" {
190+
port, err := strconv.Atoi(portStr)
191+
if err != nil || port < 1 || port > 65535 {
192+
return 0, fmt.Errorf("invalid GSUITE_MCP_OAUTH_PORT value %q: must be 1-65535", portStr)
193+
}
194+
return port, nil
195+
}
196+
197+
cfg, err := config.LoadConfig()
198+
if err != nil {
199+
return 0, err
200+
}
201+
202+
if cfg.OAuthPort < 1 || cfg.OAuthPort > 65535 {
203+
return 0, fmt.Errorf("invalid oauth_port %d in %s: must be 1-65535", cfg.OAuthPort, config.ConfigPath())
204+
}
205+
206+
return cfg.OAuthPort, nil
207+
}
208+
209+
// ResolveOAuthPort returns the resolved OAuth callback port and whether it was overridden by env var.
210+
func ResolveOAuthPort() (port int, envOverride bool, err error) {
211+
if os.Getenv("GSUITE_MCP_OAUTH_PORT") != "" {
212+
p, err := resolveOAuthPort()
213+
return p, true, err
214+
}
215+
p, err := resolveOAuthPort()
216+
return p, false, err
217+
}
218+
188219
// AuthenticateDynamic performs OAuth2 flow without requiring a pre-configured account.
189220
// It opens the browser, lets the user choose any Google account, and saves the credential
190221
// using the authenticated email as the identifier. Returns the authenticated email.
191222
func (m *Manager) AuthenticateDynamic(ctx context.Context) (string, error) {
192-
oauthPort := defaultOAuthPort
193-
if portStr := os.Getenv("GSUITE_MCP_OAUTH_PORT"); portStr != "" {
194-
if p, err := fmt.Sscanf(portStr, "%d", &oauthPort); err != nil || p != 1 {
195-
return "", fmt.Errorf("invalid GSUITE_MCP_OAUTH_PORT value: %s", portStr)
196-
}
223+
oauthPort, err := resolveOAuthPort()
224+
if err != nil {
225+
return "", err
197226
}
198227

199228
listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", oauthPort))
200229
if err != nil {
201-
return "", fmt.Errorf("failed to listen on port %d: %w", oauthPort, err)
230+
return "", fmt.Errorf("port %d is in use — set a different port in %s (oauth_port) or via GSUITE_MCP_OAUTH_PORT env var: %w",
231+
oauthPort, config.ConfigPath(), err)
202232
}
203233
defer listener.Close()
204234

internal/config/config.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,85 @@
33
package config
44

55
import (
6+
"encoding/json"
7+
"fmt"
68
"os"
79
"path/filepath"
810
"sort"
911
)
1012

13+
// DefaultOAuthPort is the default port used for the OAuth callback server.
14+
const DefaultOAuthPort = 8100
15+
16+
// Config holds the application configuration loaded from config.json.
17+
type Config struct {
18+
OAuthPort int `json:"oauth_port"`
19+
}
20+
21+
// ConfigPath returns the path to config.json.
22+
func ConfigPath() string {
23+
return filepath.Join(DefaultConfigDir(), "config.json")
24+
}
25+
26+
// LoadConfig loads config from the default config.json path.
27+
func LoadConfig() (Config, error) {
28+
return loadConfigFromPath(ConfigPath())
29+
}
30+
31+
// loadConfigFromPath loads config from the given path.
32+
// Returns default Config if the file doesn't exist (config.json is optional).
33+
func loadConfigFromPath(path string) (Config, error) {
34+
cfg := Config{OAuthPort: DefaultOAuthPort}
35+
36+
data, err := os.ReadFile(path)
37+
if err != nil {
38+
if os.IsNotExist(err) {
39+
return cfg, nil
40+
}
41+
return cfg, fmt.Errorf("reading config: %w", err)
42+
}
43+
44+
var fileCfg Config
45+
if err := json.Unmarshal(data, &fileCfg); err != nil {
46+
return cfg, fmt.Errorf("parsing config.json: %w", err)
47+
}
48+
49+
if fileCfg.OAuthPort == 0 {
50+
fileCfg.OAuthPort = DefaultOAuthPort
51+
}
52+
53+
return fileCfg, nil
54+
}
55+
56+
// WriteDefaultConfig creates config.json with defaults if it doesn't already exist.
57+
// Returns true if the file was created, false if it already existed.
58+
func WriteDefaultConfig() (bool, error) {
59+
return writeDefaultConfigTo(ConfigPath())
60+
}
61+
62+
// writeDefaultConfigTo creates config.json at the given path with defaults.
63+
// Returns true if the file was created, false if it already existed.
64+
func writeDefaultConfigTo(path string) (bool, error) {
65+
if _, err := os.Stat(path); err == nil {
66+
return false, nil
67+
} else if !os.IsNotExist(err) {
68+
return false, fmt.Errorf("checking config.json: %w", err)
69+
}
70+
71+
cfg := Config{OAuthPort: DefaultOAuthPort}
72+
data, err := json.MarshalIndent(cfg, "", " ")
73+
if err != nil {
74+
return false, fmt.Errorf("marshaling default config: %w", err)
75+
}
76+
data = append(data, '\n')
77+
78+
if err := os.WriteFile(path, data, 0644); err != nil {
79+
return false, fmt.Errorf("writing config.json: %w", err)
80+
}
81+
82+
return true, nil
83+
}
84+
1185
// DefaultConfigDir returns the default configuration directory.
1286
func DefaultConfigDir() string {
1387
home, err := os.UserHomeDir()

0 commit comments

Comments
 (0)