Skip to content

Commit 8093749

Browse files
committed
feat: per-session token isolation, X-Allure-Token header support, security hardening
- Each SSE session stores its own Allure token (map[sessionID]token) - X-Allure-Token header read on SSE connect, stored per-session automatically - JWT cache keyed by API token — concurrent users never share JWTs - sync.Mutex added to allure client JWT fields (fixes data race) - internal/session package for context-based session ID propagation - registry.go split into domain files (launches, results, testcases, etc.) - GetSessionToken callback now context-aware - resultToJSON error path fixed (was producing invalid JSON) - crypto/rand failure now panics instead of falling back to predictable ID - Dockerfile: alpine:3.21 pinned, Linux binary renamed from .exe - HTTP server ReadTimeout and IdleTimeout added - Build-time version via ldflags - Docs: remote server config corrected to url+headers, Authorization marked optional
1 parent 9bab9a0 commit 8093749

20 files changed

Lines changed: 3511 additions & 3298 deletions

.github/workflows/build-release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ jobs:
5555
GOARCH: ${{ matrix.arch }}
5656
run: |
5757
mkdir -p build/releases
58-
go build -o build/releases/${{ matrix.output }} ./cmd/server/main.go
58+
go build -ldflags "-X github.com/MimoJanra/TestOpsMCP/internal/mcp.Version=${{ github.ref_name }}" -o build/releases/${{ matrix.output }} ./cmd/server/main.go
5959
6060
- name: Upload artifact
6161
uses: actions/upload-artifact@v4

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.5.0] - Security & Architecture Hardening
11+
12+
### Added
13+
- **Per-session Allure token isolation** — Each SSE session stores its own token; concurrent users on a shared server never mix credentials
14+
- **`X-Allure-Token` header support** — Users pass their personal Allure token from Claude Desktop config via HTTP header; no need to call `configure_allure_token` manually
15+
- **Token-keyed JWT cache** — JWT tokens cached per API key, not globally; separate users get separate JWTs
16+
- **`internal/session` package** — Shared context helpers for session ID propagation across packages
17+
- **Server startup warning** — Logged when HTTP mode runs without `MCP_AUTH_TOKEN`
18+
- **Build-time version injection** — Server version set via `-ldflags` instead of hardcoded `"1.0.0"`
19+
20+
### Changed
21+
- **`registry.go` split into domain files**`tools_launches.go`, `tools_results.go`, `tools_testcases.go`, `tools_projects.go`, `tools_analytics.go`, `tools_bulk.go`, `tools_relations.go`
22+
- **`GetSessionToken` callback now context-aware** — Receives `context.Context` to resolve the correct per-session token
23+
- **Dockerfile** — Pinned `alpine:3.21`, removed `.exe` suffix from Linux binary
24+
- **HTTP server timeouts** — Added `ReadTimeout` and `IdleTimeout`; `WriteTimeout` disabled for SSE long-lived streams
25+
- **Documentation** — All Claude Desktop config examples corrected to use `url` + `headers` for remote servers; `Authorization` marked optional
26+
27+
### Fixed
28+
- **Data race on JWT cache** — Added `sync.Mutex` protecting `jwtToken`/`jwtExpiresAt` fields
29+
- **Cross-user token contamination** — Global `sessionToken` field replaced with per-session map
30+
- **Invalid JSON on marshal error**`resultToJSON` error path now uses `json.Marshal` instead of `fmt.Sprintf`
31+
- **Predictable session ID fallback**`crypto/rand` failure now panics instead of using `time.Now().UnixNano()`
32+
- **`SetSessionTokenFunc` visibility** — Exported function field replaced with proper setter method
33+
1034
## [1.4.0] - Token Priority & Shared Server Auth
1135

1236
### Added

Dockerfile

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,19 @@ RUN apk add --no-cache git make
99
# Copy source code
1010
COPY . .
1111

12-
# Build the binary
13-
RUN make build
12+
# Build the binary (explicit output name without .exe for Linux)
13+
RUN go build -o bin/server ./cmd/server
1414

1515
# Runtime stage - minimal image
16-
FROM alpine:latest
16+
FROM alpine:3.21
1717

1818
# Install ca-certificates for HTTPS connections to Allure
1919
RUN apk add --no-cache ca-certificates
2020

2121
WORKDIR /app
2222

2323
# Copy binary from builder
24-
COPY --from=builder /build/bin/server.exe /app/server.exe
24+
COPY --from=builder /build/bin/server /app/server
2525

2626
# Create non-root user
2727
RUN addgroup -g 1000 mcp && \
@@ -35,5 +35,5 @@ EXPOSE 3000
3535
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
3636
CMD wget -qO /dev/null http://localhost:3000/sse || exit 1
3737

38-
ENTRYPOINT ["/app/server.exe"]
38+
ENTRYPOINT ["/app/server"]
3939
CMD ["--http"]

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
.PHONY: build run run-http test clean lint help
22

33
# Build the server binary
4+
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
5+
LDFLAGS := -ldflags "-X github.com/MimoJanra/TestOpsMCP/internal/mcp.Version=$(VERSION)"
6+
47
build:
5-
@echo "Building Allure MCP server..."
8+
@echo "Building Allure MCP server ($(VERSION))..."
69
mkdir -p bin
7-
go build -o bin/server.exe ./cmd/server
10+
go build $(LDFLAGS) -o bin/server.exe ./cmd/server
811
@echo "✓ Server built successfully at bin/server.exe"
912

1013
# Run server in stdio mode (default)

README.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,19 @@ Add this to the `mcpServers` section:
4646
{
4747
"mcpServers": {
4848
"testops": {
49-
"command": "http://your-server.com:3000",
50-
"env": {
51-
"ALLURE_TOKEN": "your-personal-token-here"
49+
"url": "http://your-server.com:3000/sse",
50+
"headers": {
51+
"Authorization": "Bearer your-mcp-auth-token", // optional
52+
"X-Allure-Token": "your-personal-allure-token"
5253
}
5354
}
5455
}
5556
}
5657
```
5758

58-
Replace `your-server.com:3000` with the server address and `your-personal-token-here` with your personal Allure API token (so your actions are done from your account).
59+
- **`url`** — address of the shared MCP server (ask your team lead).
60+
- **`Authorization`** *(optional)* — the `MCP_AUTH_TOKEN` set on the server. Omit if the server has no `MCP_AUTH_TOKEN` configured.
61+
- **`X-Allure-Token`** — your personal Allure API token. All actions in Allure will be done under your account.
5962

6063
### 3. Restart Claude Desktop
6164
Close and reopen Claude.
@@ -186,21 +189,25 @@ Each team member adds to their Claude Desktop config (**Settings → Developer
186189
{
187190
"mcpServers": {
188191
"testops": {
189-
"command": "http://your-server.com:3000",
190-
"env": {
191-
"ALLURE_TOKEN": "their-personal-token-here"
192+
"url": "http://your-server.com:3000/sse",
193+
"headers": {
194+
"Authorization": "Bearer your-mcp-auth-token", // optional
195+
"X-Allure-Token": "your-personal-allure-token"
192196
}
193197
}
194198
}
195199
}
196200
```
197201

202+
- **`Authorization`** *(optional)* — the `MCP_AUTH_TOKEN` set on the server. Omit if the server has no `MCP_AUTH_TOKEN` configured.
203+
- **`X-Allure-Token`** — each person's own Allure API token. Actions in Allure are performed under that account.
204+
198205
Each person needs to:
199206
1. Get their personal Allure API token (Settings → API tokens in Allure TestOps)
200-
2. Add it to their Claude Desktop config
207+
2. Add it to their Claude Desktop config as `X-Allure-Token`
201208
3. Restart Claude Desktop
202209

203-
All actions will be done from their personal account! ✅
210+
All actions will be done from their personal Allure account! ✅
204211

205212
### Binary Setup (Alternative)
206213

cmd/server/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ func runHTTP(mcpServer *mcp.Server, cfg *config.Config, logger *core.Logger) {
5656
"auth": cfg.AuthToken != "",
5757
"cors": cfg.CORSAllowOrigin,
5858
})
59+
if cfg.AuthToken == "" {
60+
logger.Warn("MCP_AUTH_TOKEN is not set — HTTP server accepts unauthenticated requests", nil)
61+
}
5962

6063
mux := http.NewServeMux()
6164
mux.HandleFunc("/sse", mcpServer.HandleSSE)
@@ -65,6 +68,9 @@ func runHTTP(mcpServer *mcp.Server, cfg *config.Config, logger *core.Logger) {
6568
Addr: cfg.Port,
6669
Handler: mux,
6770
ReadHeaderTimeout: 10 * time.Second,
71+
ReadTimeout: 15 * time.Second,
72+
WriteTimeout: 0, // SSE streams are long-lived; rely on client disconnect
73+
IdleTimeout: 120 * time.Second,
6874
}
6975

7076
sigChan := make(chan os.Signal, 1)

docs/DEPLOYMENT.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,19 @@ Add to Claude Desktop config:
6161
{
6262
"mcpServers": {
6363
"testops": {
64-
"command": "http://your-server.com:3000"
64+
"url": "http://your-server.com:3000/sse",
65+
"headers": {
66+
"Authorization": "Bearer your-mcp-auth-token", // optional
67+
"X-Allure-Token": "your-personal-allure-token"
68+
}
6569
}
6670
}
6771
}
6872
```
6973

74+
- **`Authorization`** *(optional)* — the `MCP_AUTH_TOKEN` set on the server. Omit if no `MCP_AUTH_TOKEN` is configured.
75+
- **`X-Allure-Token`** — each user's personal Allure API token. Actions in Allure are performed under that account.
76+
7077
---
7178

7279
## Docker Deployment

docs/QUICKSTART.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ curl -H "Authorization: Bearer your_shared_secret" \
7070
{
7171
"mcpServers": {
7272
"allure": {
73-
"url": "http://localhost:3000",
74-
"env": {
75-
"MCP_AUTH_TOKEN": "your_shared_secret"
73+
"url": "http://localhost:3000/sse",
74+
"headers": {
75+
"Authorization": "Bearer your_shared_secret", // optional
76+
"X-Allure-Token": "your-personal-allure-token"
7677
}
7778
}
7879
}
@@ -107,9 +108,10 @@ docker-compose down
107108
{
108109
"mcpServers": {
109110
"allure": {
110-
"url": "http://your-server:3000",
111-
"env": {
112-
"MCP_AUTH_TOKEN": "your_shared_secret_from_.env"
111+
"url": "http://your-server:3000/sse",
112+
"headers": {
113+
"Authorization": "Bearer your_shared_secret_from_.env", // optional
114+
"X-Allure-Token": "your-personal-allure-token"
113115
}
114116
}
115117
}
@@ -146,9 +148,10 @@ kubectl get svc -n allure-mcp
146148
{
147149
"mcpServers": {
148150
"allure": {
149-
"url": "http://your-k8s-service:3000",
150-
"env": {
151-
"MCP_AUTH_TOKEN": "your_shared_secret"
151+
"url": "http://your-k8s-service:3000/sse",
152+
"headers": {
153+
"Authorization": "Bearer your_shared_secret", // optional
154+
"X-Allure-Token": "your-personal-allure-token"
152155
}
153156
}
154157
}

internal/adapters/allure/client.go

Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,86 +10,119 @@ import (
1010
"net/http/cookiejar"
1111
"net/url"
1212
"strings"
13+
"sync"
1314
"time"
1415
)
1516

17+
type jwtEntry struct {
18+
jwt string
19+
expiresAt time.Time
20+
}
21+
1622
type Client struct {
17-
baseURL string
18-
userToken string
19-
jwtToken string
20-
jwtExpiresAt time.Time
21-
httpClient *http.Client
22-
GetSessionToken func() string // optional callback to get session token
23+
baseURL string
24+
userToken string
25+
httpClient *http.Client
26+
27+
mu sync.Mutex
28+
jwtCache map[string]jwtEntry // keyed by API token — each user's JWT is stored separately
29+
getSessionToken func(ctx context.Context) string
2330
}
2431

2532
func NewClient(baseURL, token string, timeout time.Duration) *Client {
2633
jar, _ := cookiejar.New(nil)
2734
return &Client{
2835
baseURL: strings.TrimRight(baseURL, "/"),
2936
userToken: token,
37+
jwtCache: make(map[string]jwtEntry),
3038
httpClient: &http.Client{
3139
Timeout: timeout,
3240
Jar: jar,
3341
},
3442
}
3543
}
3644

45+
func (c *Client) SetSessionTokenFunc(fn func(ctx context.Context) string) {
46+
c.mu.Lock()
47+
defer c.mu.Unlock()
48+
c.getSessionToken = fn
49+
}
50+
51+
// resolveAPIToken returns the API token to use for this request: the per-session
52+
// token (if set by the user via configure_allure_token) or the server-wide token.
53+
func (c *Client) resolveAPIToken(ctx context.Context) string {
54+
if c.getSessionToken != nil {
55+
if t := c.getSessionToken(ctx); t != "" {
56+
return t
57+
}
58+
}
59+
return c.userToken
60+
}
61+
3762
func (c *Client) getJWTToken(ctx context.Context) (string, error) {
38-
if c.jwtToken != "" && time.Now().Before(c.jwtExpiresAt) {
39-
return c.jwtToken, nil
63+
apiToken := c.resolveAPIToken(ctx)
64+
if apiToken == "" {
65+
return "", fmt.Errorf("no token configured - set ALLURE_TOKEN env var or use configure_allure_token tool")
4066
}
4167

42-
// Session token has priority (user-provided in chat), fallback to configured token
43-
token := ""
44-
if c.GetSessionToken != nil {
45-
token = c.GetSessionToken()
68+
// Check cache without holding the lock during the HTTP call.
69+
c.mu.Lock()
70+
if entry, ok := c.jwtCache[apiToken]; ok && time.Now().Before(entry.expiresAt) {
71+
c.mu.Unlock()
72+
return entry.jwt, nil
4673
}
47-
if token == "" {
48-
token = c.userToken
74+
c.mu.Unlock()
75+
76+
jwt, expiresIn, err := c.fetchJWT(ctx, apiToken)
77+
if err != nil {
78+
return "", err
4979
}
50-
if token == "" {
51-
return "", fmt.Errorf("no token configured - set ALLURE_TOKEN env var or use configure_allure_token tool")
80+
81+
expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
82+
if expiresIn <= 0 {
83+
expiresAt = time.Now().Add(1 * time.Hour)
5284
}
5385

86+
c.mu.Lock()
87+
c.jwtCache[apiToken] = jwtEntry{jwt: jwt, expiresAt: expiresAt}
88+
c.mu.Unlock()
89+
90+
return jwt, nil
91+
}
92+
93+
func (c *Client) fetchJWT(ctx context.Context, apiToken string) (string, int, error) {
5494
values := url.Values{}
5595
values.Set("grant_type", "apitoken")
5696
values.Set("scope", "openid")
57-
values.Set("token", token)
97+
values.Set("token", apiToken)
5898

59-
body := strings.NewReader(values.Encode())
60-
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url("/api/uaa/oauth/token"), body)
99+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url("/api/uaa/oauth/token"), strings.NewReader(values.Encode()))
61100
if err != nil {
62-
return "", fmt.Errorf("create request: %w", err)
101+
return "", 0, fmt.Errorf("create request: %w", err)
63102
}
64103
httpReq.Header.Set("Expect", "")
65104
httpReq.Header.Set("Accept", "application/json")
66105
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
67106

68107
resp, err := c.httpClient.Do(httpReq)
69108
if err != nil {
70-
return "", fmt.Errorf("http request: %w", err)
109+
return "", 0, fmt.Errorf("http request: %w", err)
71110
}
72111
defer resp.Body.Close()
73112

74113
if resp.StatusCode != http.StatusOK {
75-
return "", errFromResponse(resp)
114+
return "", 0, errFromResponse(resp)
76115
}
77116

78117
var result struct {
79118
AccessToken string `json:"access_token"`
80119
ExpiresIn int `json:"expires_in"`
81120
}
82121
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
83-
return "", fmt.Errorf("decode response: %w", err)
122+
return "", 0, fmt.Errorf("decode response: %w", err)
84123
}
85124

86-
c.jwtToken = result.AccessToken
87-
if result.ExpiresIn > 0 {
88-
c.jwtExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
89-
} else {
90-
c.jwtExpiresAt = time.Now().Add(1 * time.Hour)
91-
}
92-
return c.jwtToken, nil
125+
return result.AccessToken, result.ExpiresIn, nil
93126
}
94127

95128
func (c *Client) CreateLaunch(ctx context.Context, projectID int64, launchName string) (*LaunchResponse, error) {

0 commit comments

Comments
 (0)