Skip to content

feat: Multi-Instance OAuth Federation with per-session introspection #274

@polaz

Description

@polaz

Summary

Implement full multi-instance GitLab support with OAuth, allowing one MCP server to serve users connecting to different GitLab instances simultaneously. Each user session maintains its own GitLab instance context with proper caching at correct levels.

Motivation

Current architecture limitations:

  • GITLAB_BASE_URL is global - all users connect to same instance
  • ConnectionManager is singleton with shared introspection cache
  • No per-user API URL in TokenContext
  • Cannot serve teams using multiple GitLab instances (gitlab.com + self-hosted)

Upstream (zereight/gitlab-mcp) supports X-GitLab-API-URL header but lacks OAuth integration. Our approach provides proper per-user authentication with instance selection.

Architecture

Caching Strategy (CRITICAL)

Tier is per-NAMESPACE, not per-instance! On gitlab.com, one user can access Free group and Ultimate group simultaneously. Caching tier at instance or session level is WRONG.

┌────────────────────────────────────────────────────────────────┐
│                    CACHING LAYERS                              │
├────────────────────────────────────────────────────────────────┤
│ INSTANCE-LEVEL CACHE (InstanceRegistry) — TTL: 10 min          │
│ Safe to share between all users of same instance               │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ • GitLab version (e.g., 17.2.0)                             ││
│ │ • GraphQL schema (available types, widgets, mutations)      ││
│ │ • Instance type: SaaS (gitlab.com) vs Self-hosted           ││
│ │ • Self-hosted only: instance-wide tier (if single license)  ││
│ └─────────────────────────────────────────────────────────────┘│
├────────────────────────────────────────────────────────────────┤
│ SESSION-LEVEL CACHE (OAuthSession) — TTL: session lifetime     │
│ Per-user, not shared                                           │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ • Token scopes (api, read_api, read_user, etc.)             ││
│ │ • User info (username, id)                                  ││
│ │ • Namespace tier cache (Map<namespacePath, TierInfo>)       ││
│ └─────────────────────────────────────────────────────────────┘│
├────────────────────────────────────────────────────────────────┤
│ NAMESPACE-LEVEL CACHE (within session) — TTL: 5 min            │
│ Tier varies per group/project, especially on gitlab.com        │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ "gitlab-org" → Ultimate, features: {epics: ✓, iterations: ✓}││
│ │ "my-free-group" → Free, features: {epics: ✗, iterations: ✗} ││
│ │ "company/proj" → Premium, features: {epics: ✓, ...}         ││
│ └─────────────────────────────────────────────────────────────┘│
└────────────────────────────────────────────────────────────────┘

System Architecture

┌────────────────────────────────────────────────────────────┐
│                    MCP Server (single process)             │
├────────────────────────────────────────────────────────────┤
│  InstanceRegistry (Singleton)                              │
│  ┌─────────────┬─────────────┬─────────────┐               │
│  │ gitlab.com  │ git.corp.io │ gl.dev.net  │               │
│  │ OAuth App 1 │ OAuth App 2 │ OAuth App 3 │               │
│  │ Schema ✓    │ Schema ✓    │ Schema (?)  │               │
│  │ v17.2 SaaS  │ v16.8 Self  │ v15.0 Self  │               │
│  │ Rate: 0/100 │ Rate: 5/50  │ Rate: 2/20  │               │
│  └─────────────┴─────────────┴─────────────┘               │
│  NOTE: No tier here! Tier is per-namespace, not instance   │
├────────────────────────────────────────────────────────────┤
│  SessionManager (per-user sessions)                        │
│  ┌──────────────────┐  ┌──────────────────┐                │
│  │ User A           │  │ User B           │                │
│  │ → gitlab.com     │  │ → git.corp.io    │                │
│  │ Token: xxx       │  │ Token: yyy       │                │
│  │ Scopes: [api]    │  │ Scopes: [api]    │                │
│  │ NS Cache:        │  │ NS Cache:        │                │
│  │  grp1→Ultimate   │  │  corp→Premium    │                │
│  │  grp2→Free       │  │                  │                │
│  └──────────────────┘  └──────────────────┘                │
└────────────────────────────────────────────────────────────┘

Implementation Plan

Phase 1: Core Infrastructure

1.1 InstanceRegistry Service

New file: src/services/InstanceRegistry.ts

interface GitLabInstanceConfig {
  baseUrl: string;
  oauthClientId?: string;
  oauthClientSecret?: string;
  maxConcurrentRequests?: number;  // Default: 100
  requestQueueSize?: number;       // Default: 500
  label?: string;                  // Human-readable name for UI
}

// Instance-level cache: version + schema only, NO TIER
interface InstanceIntrospection {
  version: string;
  isSaaS: boolean;                 // gitlab.com = true
  schemaInfo: SchemaInfo;          // GraphQL types, widgets
  detectedAt: Date;
}

interface GitLabInstance extends GitLabInstanceConfig {
  // Runtime state
  activeRequests: number;
  requestQueue: Array<QueuedRequest>;
  introspection: InstanceIntrospection | null;
  connectionStatus: 'healthy' | 'degraded' | 'offline';
  lastHealthCheck: Date | null;
}

class InstanceRegistry {
  private instances = new Map<string, GitLabInstance>();
  
  register(config: GitLabInstanceConfig): void;
  get(baseUrl: string): GitLabInstance | undefined;
  list(): GitLabInstance[];
  unregister(baseUrl: string): boolean;
  
  // Request slot management (rate limiting)
  async acquireSlot(baseUrl: string): Promise<ReleaseFunction>;
  
  // Instance-level introspection (version + schema, NO tier)
  async getIntrospection(baseUrl: string, token: string): Promise<InstanceIntrospection>;
}

1.2 Extended TokenContext

Modify: src/oauth/token-context.ts

interface TokenContextData {
  token: string;
  apiUrl: string;              // GitLab instance URL
  instanceLabel?: string;      // Human-readable instance name
  // NO tier here - tier is per-namespace!
}

1.3 OAuthSession with Namespace Tier Cache

Modify: src/oauth/session-manager.ts

interface NamespaceTierInfo {
  tier: 'free' | 'premium' | 'ultimate';
  features: Record<string, boolean>;
  cachedAt: Date;
}

interface OAuthSession {
  // Existing fields...
  gitlabApiUrl: string;
  instanceLabel?: string;
  scopes: string[];
  
  // Namespace tier cache (key: namespacePath)
  namespaceTierCache: Map<string, NamespaceTierInfo>;
}

// Helper to get/cache namespace tier
async function getNamespaceTier(
  session: OAuthSession, 
  namespacePath: string
): Promise<NamespaceTierInfo> {
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
  
  const cached = session.namespaceTierCache.get(namespacePath);
  if (cached && Date.now() - cached.cachedAt.getTime() < CACHE_TTL) {
    return cached;
  }
  
  // Query GitLab for namespace tier
  const tierInfo = await queryNamespaceTier(namespacePath);
  session.namespaceTierCache.set(namespacePath, tierInfo);
  return tierInfo;
}

1.4 Namespace Tier Detection

New file: src/services/NamespaceTierDetector.ts

// Query namespace tier via GraphQL
const NAMESPACE_TIER_QUERY = `
  query GetNamespaceTier($fullPath: ID!) {
    namespace(fullPath: $fullPath) {
      id
      fullPath
      
      # For groups - check plan directly
      ... on Group {
        plan
        actualPlanName  # "free", "premium", "ultimate"
      }
      
      # For projects - check parent group's plan
      ... on Project {
        group {
          plan
          actualPlanName
        }
      }
    }
  }
`;

async function queryNamespaceTier(namespacePath: string): Promise<NamespaceTierInfo> {
  const result = await graphqlRequest(NAMESPACE_TIER_QUERY, { fullPath: namespacePath });
  
  // Extract tier from response
  const planName = result.namespace?.actualPlanName 
    || result.namespace?.group?.actualPlanName 
    || 'free';
  
  return {
    tier: normalizeTier(planName),
    features: getFeaturesForTier(planName),
    cachedAt: new Date(),
  };
}

function getFeaturesForTier(tier: string): Record<string, boolean> {
  const tiers = {
    free: {
      epics: false,
      iterations: false,
      roadmaps: false,
      // ... etc
    },
    premium: {
      epics: true,
      iterations: true,
      roadmaps: true,
      // ... etc
    },
    ultimate: {
      epics: true,
      iterations: true,
      roadmaps: true,
      securityDashboard: true,
      // ... etc
    },
  };
  return tiers[tier] || tiers.free;
}

1.5 Enhanced Fetch with Context URL

Modify: src/utils/fetch.ts

export async function enhancedFetch(
  urlOrPath: string,
  options?: EnhancedFetchOptions
): Promise<Response> {
  const context = getTokenContext();
  
  // Determine base URL: context > global > error
  const baseUrl = context?.apiUrl ?? GITLAB_BASE_URL;
  if (!baseUrl) {
    throw new Error('No GitLab API URL available');
  }
  
  // Acquire rate limit slot before request
  const registry = InstanceRegistry.getInstance();
  const release = await registry.acquireSlot(baseUrl);
  
  try {
    const url = urlOrPath.startsWith('http') 
      ? urlOrPath 
      : `${baseUrl}${urlOrPath}`;
    
    // ... rest of fetch logic
  } finally {
    release();
  }
}

Phase 2: Configuration System

2.1 Environment Variable Formats

Backward compatible single URL:

# Legacy format - still works
GITLAB_BASE_URL=https://gitlab.com
GITLAB_TOKEN=glpat-xxx

# Or using new variable name with single URL
GITLAB_INSTANCES=https://gitlab.com

Multiple URLs (bash array syntax):

# Simple array - URLs only (OAuth credentials from separate vars)
GITLAB_INSTANCES=(
  https://gitlab.com
  https://git.corp.io
  https://gl.dev.net
)

# With per-instance OAuth (colon-separated)
GITLAB_INSTANCES=(
  "https://gitlab.com:client_id_1"
  "https://git.corp.io:client_id_2:client_secret_2"
)

File reference:

# Point to config file
GITLAB_INSTANCES_FILE=/etc/gitlab-mcp/instances.yaml
# Or
GITLAB_INSTANCES_FILE=~/.config/gitlab-mcp/instances.yaml

2.2 Configuration File Format

YAML format (instances.yaml):

# GitLab MCP Instances Configuration
# Documentation: https://gitlab-mcp.sw.foundation/advanced/multi-instance

instances:
  # Minimal configuration (OAuth disabled or uses global credentials)
  - url: https://gitlab.com
    label: "GitLab.com"
  
  # Full configuration with OAuth
  - url: https://git.corp.io
    label: "Corporate GitLab"
    oauth:
      clientId: "app_id_here"
      clientSecret: "optional_secret"  # Only for confidential apps
      scopes: "api read_user"          # Optional, default: api,read_user
    rateLimit:
      maxConcurrent: 50                # Max parallel requests
      queueSize: 200                   # Max queued requests
      queueTimeout: 30000              # Queue wait timeout (ms)
  
  # Self-hosted with custom settings
  - url: https://gl.dev.net
    label: "Dev Instance"
    oauth:
      clientId: "dev_app_id"
    rateLimit:
      maxConcurrent: 20
    # Skip TLS verification (development only!)
    insecureSkipVerify: true

# Global defaults (applied to all instances unless overridden)
defaults:
  rateLimit:
    maxConcurrent: 100
    queueSize: 500
    queueTimeout: 60000
  oauth:
    scopes: "api read_user"
  namespaceTierCacheTTL: 300  # 5 minutes in seconds

JSON format (instances.json) - alternative:

{
  "instances": [
    {
      "url": "https://gitlab.com",
      "label": "GitLab.com"
    },
    {
      "url": "https://git.corp.io",
      "label": "Corporate GitLab",
      "oauth": {
        "clientId": "app_id_here",
        "clientSecret": "optional_secret"
      },
      "rateLimit": {
        "maxConcurrent": 50,
        "queueSize": 200
      }
    }
  ],
  "defaults": {
    "rateLimit": {
      "maxConcurrent": 100,
      "queueSize": 500
    },
    "namespaceTierCacheTTL": 300
  }
}

2.3 Configuration Loading Priority

1. GITLAB_INSTANCES_FILE (if set, load from file)
2. GITLAB_INSTANCES (env var - URL, array, or JSON)
3. GITLAB_BASE_URL + GITLAB_TOKEN (legacy single-instance)
4. Auto-discovery from git remote (existing feature)

Phase 3: Setup Wizards

3.1 Interactive Setup Enhancement

Modify: src/cli/setup.ts

$ npx @structured-world/gitlab-mcp setup

╔══════════════════════════════════════════════════════════════╗
║                    GitLab MCP Setup                          ║
╚══════════════════════════════════════════════════════════════╝

? How many GitLab instances do you want to configure?
  ○ Single instance (simple setup)
  ○ Multiple instances (federation mode)

──────────────────────────────────────────────────────────────────

[Multiple instances selected]

Instance 1 of N:
? GitLab URL: https://gitlab.com
? Label (optional): GitLab.com
? Authentication method:
  ○ Personal Access Token (static)
  ○ OAuth 2.1 (per-user authentication)
  
[OAuth selected]
? OAuth Application ID: ________________________________
? OAuth Secret (optional, press Enter to skip): ________
? Max concurrent requests [100]: ________________________

✓ Instance configured: gitlab.com

──────────────────────────────────────────────────────────────────

? Add another instance? (Y/n): Y

Instance 2 of N:
? GitLab URL: https://git.corp.io
...

──────────────────────────────────────────────────────────────────

Configuration Summary:

┌────────────────────┬─────────────────┬──────────┬─────────────┐
│ Instance           │ Auth            │ Rate     │ Status      │
├────────────────────┼─────────────────┼──────────┼─────────────┤
│ gitlab.com         │ OAuth (app_123) │ 100 req  │ ✓ Connected │
│ git.corp.io        │ OAuth (app_456) │ 50 req   │ ✓ Connected │
└────────────────────┴─────────────────┴──────────┴─────────────┘

? Save configuration to:
  ○ Environment variables (.env file)
  ○ Configuration file (~/.config/gitlab-mcp/instances.yaml)
  ○ Both

✓ Configuration saved!

3.2 Instance Management Commands

# List configured instances
npx @structured-world/gitlab-mcp instances list

# Add new instance interactively
npx @structured-world/gitlab-mcp instances add

# Add instance non-interactively
npx @structured-world/gitlab-mcp instances add \
  --url https://gl.new.io \
  --label "New Instance" \
  --oauth-client-id app_id \
  --max-concurrent 50

# Remove instance
npx @structured-world/gitlab-mcp instances remove https://gl.old.io

# Test instance connection
npx @structured-world/gitlab-mcp instances test https://gitlab.com

# Show instance details
npx @structured-world/gitlab-mcp instances info https://gitlab.com

Phase 4: Feature Availability Checks

4.1 Per-Operation Tier Check

Tools that require specific tier features must check namespace tier:

// In work items handler
async function handleCreateEpic(args: CreateEpicArgs, session: OAuthSession) {
  // Get tier for target namespace
  const tierInfo = await getNamespaceTier(session, args.groupPath);
  
  // Check if epics are available
  if (!tierInfo.features.epics) {
    throw new Error(
      `Epics require GitLab Premium or Ultimate. ` +
      `Namespace "${args.groupPath}" is on ${tierInfo.tier} tier.`
    );
  }
  
  // Proceed with epic creation...
}

4.2 Tier-Aware Tool Descriptions

Tool descriptions should indicate tier requirements:

const manageWorkItemsTool = {
  name: 'manage_work_items',
  description: `
    Create and manage GitLab work items (issues, tasks, epics, etc.).
    
    Note: Some work item types require specific GitLab tiers:
    - Issues, Tasks: Available on all tiers
    - Epics: Requires Premium or Ultimate
    - OKRs: Requires Ultimate
    
    Tier is determined per-namespace (group/project), not globally.
  `,
  // ...
};

Phase 5: Rate Limiting

5.1 Per-Instance Rate Limiter

class InstanceRateLimiter {
  private activeRequests = 0;
  private queue: Array<{
    resolve: (release: () => void) => void;
    reject: (error: Error) => void;
    enqueuedAt: number;
  }> = [];
  
  constructor(
    private maxConcurrent: number,
    private queueSize: number,
    private queueTimeout: number
  ) {}
  
  async acquire(): Promise<() => void> {
    if (this.activeRequests < this.maxConcurrent) {
      this.activeRequests++;
      return () => this.release();
    }
    
    if (this.queue.length >= this.queueSize) {
      throw new Error(
        `Rate limit exceeded: ${this.activeRequests} active, ` +
        `${this.queue.length} queued (max: ${this.queueSize})`
      );
    }
    
    return new Promise((resolve, reject) => {
      const entry = { resolve, reject, enqueuedAt: Date.now() };
      this.queue.push(entry);
      
      setTimeout(() => {
        const idx = this.queue.indexOf(entry);
        if (idx !== -1) {
          this.queue.splice(idx, 1);
          reject(new Error(
            `Request queued for ${this.queueTimeout}ms, timing out`
          ));
        }
      }, this.queueTimeout);
    });
  }
  
  private release(): void {
    this.activeRequests--;
    
    if (this.queue.length > 0 && this.activeRequests < this.maxConcurrent) {
      const next = this.queue.shift()!;
      this.activeRequests++;
      next.resolve(() => this.release());
    }
  }
}

5.2 Rate Limit Metrics

interface RateLimitMetrics {
  instanceUrl: string;
  activeRequests: number;
  maxConcurrent: number;
  queuedRequests: number;
  queueSize: number;
  requestsTotal: number;
  requestsQueued: number;
  requestsRejected: number;
  avgQueueWaitMs: number;
}

Phase 6: OAuth Flow with Instance Selection

6.1 Instance Selection Before OAuth

User initiates OAuth flow:

┌────────────────────────────────────────────────────────────┐
│              Select GitLab Instance                        │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  Available instances:                                      │
│                                                            │
│  ┌───────────────────────────────────────────────────────┐ │
│  │ ○ GitLab.com (SaaS)                                   │ │
│  │   https://gitlab.com                                  │ │
│  │   Status: ● Healthy | v17.2.0                         │ │
│  │   Tier: varies by namespace                           │ │
│  └───────────────────────────────────────────────────────┘ │
│                                                            │
│  ┌───────────────────────────────────────────────────────┐ │
│  │ ○ Corporate GitLab (Self-hosted)                      │ │
│  │   https://git.corp.io                                 │ │
│  │   Status: ● Healthy | v16.8.0 | Premium               │ │
│  └───────────────────────────────────────────────────────┘ │
│                                                            │
│  [ Continue to GitLab Login ]                              │
│                                                            │
└────────────────────────────────────────────────────────────┘

Note: Self-hosted instances show instance-wide tier.
      SaaS (gitlab.com) shows "varies by namespace".

6.2 OAuth State with Instance

interface OAuthState {
  selectedInstance: string;
  // ... other fields
}

async function handleOAuthCallback(code: string, state: OAuthState) {
  const session = await createSession({
    gitlabApiUrl: state.selectedInstance,
    namespaceTierCache: new Map(), // Empty, will populate on demand
    // ... other fields
  });
}

Phase 7: Context Switching (Instance Switching)

When a user switches between GitLab instances via manage_context or presets, the system must re-introspect and re-validate tools.

7.1 Switching Behavior by Auth Mode

Mode Instance Switching Action Required
OAuth Blocked (session tied to instance) User must re-authenticate on different instance
Static token Allowed via manage_context Re-introspect + re-validate tools
Multi-token presets Allowed via manage_context Re-introspect + re-validate tools

7.2 Context Switch Handler

// In manage_context handler
async function handleSwitchContext(newInstanceUrl: string, session: OAuthSession) {
  // 1. Validate instance is registered
  const registry = InstanceRegistry.getInstance();
  const instance = registry.get(newInstanceUrl);
  if (!instance) {
    throw new Error(`Instance not configured: ${newInstanceUrl}`);
  }
  
  // 2. In OAuth mode, block switching (requires re-auth)
  if (isOAuthEnabled()) {
    throw new Error(
      `Cannot switch instances in OAuth mode. ` +
      `Please re-authenticate with ${instance.label || newInstanceUrl}`
    );
  }
  
  // 3. Get/refresh introspection for new instance
  const introspection = await registry.getIntrospection(newInstanceUrl, session.token);
  
  // 4. Update session
  session.gitlabApiUrl = newInstanceUrl;
  session.instanceLabel = instance.label;
  
  // 5. Clear namespace tier cache (invalid for new instance)
  session.namespaceTierCache.clear();
  
  // 6. Re-validate and filter tools based on new schema
  const toolRegistry = ToolRegistry.getInstance();
  const validationResult = await toolRegistry.revalidateForSchema(introspection.schemaInfo);
  
  return {
    success: true,
    instance: newInstanceUrl,
    label: instance.label,
    version: introspection.version,
    availableTools: validationResult.availableCount,
    disabledTools: validationResult.disabledTools,
  };
}

7.3 Tool Re-validation on Schema Change

Different GitLab versions have different GraphQL schemas. Tools must be re-validated when switching instances:

class ToolRegistry {
  async revalidateForSchema(schemaInfo: SchemaInfo): Promise<ValidationResult> {
    const disabledTools: string[] = [];
    
    for (const tool of this.tools.values()) {
      // Check if tool's required GraphQL types exist in schema
      const isValid = this.validateToolAgainstSchema(tool, schemaInfo);
      tool.setAvailable(isValid);
      
      if (!isValid) {
        disabledTools.push(tool.name);
        logInfo(`Tool ${tool.name} disabled: required types not in schema`, {
          requiredTypes: tool.requiredGraphQLTypes,
          instanceVersion: schemaInfo.version,
        });
      }
    }
    
    return {
      availableCount: this.tools.size - disabledTools.length,
      disabledTools,
    };
  }
  
  private validateToolAgainstSchema(tool: Tool, schema: SchemaInfo): boolean {
    // Check required types
    for (const typeName of tool.requiredGraphQLTypes || []) {
      if (!schema.typeDefinitions.has(typeName)) {
        return false;
      }
    }
    
    // Check required widgets (for work items)
    for (const widgetType of tool.requiredWidgets || []) {
      if (!schema.workItemWidgetTypes.includes(widgetType)) {
        return false;
      }
    }
    
    return true;
  }
}

7.4 User Notification on Tool Changes

When tools become unavailable after switching, inform the user:

// Response format for context switch
{
  "success": true,
  "instance": "https://gl.old.example.com",
  "version": "15.0.0",
  "availableTools": 38,
  "disabledTools": [
    "manage_work_items",  // Work Items API not available in v15
    "browse_iterations"   // Iterations require newer version
  ],
  "message": "Switched to gl.old.example.com (v15.0.0). 6 tools disabled due to schema differences."
}

Phase 8: Documentation

8.1 New Documentation Pages

Page Content
docs/guide/multi-instance.md Getting started with multiple instances
docs/advanced/federation.md Deep dive into federation architecture
docs/advanced/tier-detection.md How tier detection works per-namespace
docs/advanced/context-switching.md Instance switching behavior and limitations
docs/configuration/instances.md All configuration options
docs/configuration/rate-limiting.md Rate limiting configuration
docs/cli/instances.md CLI commands for instance management

8.2 Key Documentation Points

Must clearly explain:

  1. Tier is per-namespace on gitlab.com, not per-instance
  2. Self-hosted instances may have instance-wide tier
  3. Namespace tier is cached per-session (5 min TTL)
  4. Instance-level cache (version, schema) is shared between sessions
  5. How to configure multiple instances
  6. Rate limiting behavior and configuration
  7. Context switching: OAuth blocks it, static token allows it
  8. Tool availability may change when switching instances

Phase 9: Integration Tests

9.1 Test Scenarios

describe('Multi-Instance Federation', () => {
  describe('InstanceRegistry', () => {
    it('should register multiple instances from config file');
    it('should register instances from env var array');
    it('should fall back to GITLAB_BASE_URL for single instance');
    it('should validate instance URLs');
    it('should handle duplicate instance URLs');
  });

  describe('Caching Layers', () => {
    describe('Instance-Level Cache', () => {
      it('should cache version and schema per instance');
      it('should NOT cache tier at instance level');
      it('should share instance cache between sessions');
      it('should expire instance cache after TTL');
    });
    
    describe('Namespace-Level Cache', () => {
      it('should cache tier per namespace within session');
      it('should NOT share namespace tier between sessions');
      it('should expire namespace tier cache after TTL');
      it('should query tier on cache miss');
    });
  });

  describe('Tier Detection', () => {
    it('should detect Free tier for free namespace');
    it('should detect Premium tier for premium namespace');
    it('should detect Ultimate tier for ultimate namespace');
    it('should handle project tier via parent group');
    it('should return correct features for each tier');
  });

  describe('Rate Limiting', () => {
    it('should limit concurrent requests per instance');
    it('should queue requests when at capacity');
    it('should timeout queued requests after configured duration');
    it('should reject requests when queue is full');
    it('should release slots on request completion');
    it('should release slots on request failure');
    it('should track rate limit metrics');
  });

  describe('OAuth Flow with Instance Selection', () => {
    it('should include instance URL in OAuth state');
    it('should restore instance from OAuth callback state');
    it('should create session with correct gitlabApiUrl');
    it('should initialize empty namespace tier cache');
    it('should reject OAuth for unconfigured instances');
  });

  describe('Context Switching', () => {
    it('should block instance switching in OAuth mode');
    it('should allow instance switching in static token mode');
    it('should re-introspect on instance switch');
    it('should clear namespace tier cache on switch');
    it('should re-validate tools against new schema');
    it('should report disabled tools after switch');
    it('should use correct instance URL after switch');
  });

  describe('Cross-Session Isolation', () => {
    it('should isolate token context between sessions');
    it('should use correct instance URL in enhancedFetch');
    it('should NOT share namespace tier cache between sessions');
  });

  describe('Configuration Loading', () => {
    it('should load YAML config file');
    it('should load JSON config file');
    it('should parse bash array syntax');
    it('should apply defaults to all instances');
    it('should override defaults with instance-specific config');
  });
  
  describe('Feature Availability', () => {
    it('should block epic creation on Free tier namespace');
    it('should allow epic creation on Premium tier namespace');
    it('should provide helpful error message with tier info');
  });
});

9.2 Test Data Lifecycle

interface MultiInstanceTestContext {
  instances: {
    primary: TestGitLabInstance;
    secondary?: TestGitLabInstance;
  };
  sessions: Map<string, TestSession>;
  // Test namespaces with known tiers
  namespaces: {
    free: string;      // e.g., "test/free-group"
    premium: string;   // e.g., "test/premium-group"  
    ultimate: string;  // e.g., "test/ultimate-group"
  };
}

beforeAll(async () => {
  const registry = InstanceRegistry.getInstance();
  registry.register({
    baseUrl: process.env.GITLAB_API_URL!,
    label: 'Primary Test Instance',
    maxConcurrentRequests: 10,
  });
  
  if (process.env.GITLAB_SECONDARY_URL) {
    registry.register({
      baseUrl: process.env.GITLAB_SECONDARY_URL,
      label: 'Secondary Test Instance',
      maxConcurrentRequests: 5,
    });
  }
});

afterAll(async () => {
  const sessionManager = SessionManager.getInstance();
  await sessionManager.cleanupAllSessions();
});

Files to Create/Modify

New Files

File Purpose
src/services/InstanceRegistry.ts Instance management and registry
src/services/InstanceRateLimiter.ts Per-instance rate limiting
src/services/NamespaceTierDetector.ts Query namespace tier via GraphQL
src/cli/commands/instances.ts CLI commands for instance management
src/oauth/instance-selector.ts OAuth instance selection UI
src/config/instances-loader.ts Configuration file/env var loader
src/config/instances-schema.ts Zod schemas for instance config
docs/guide/multi-instance.md User guide
docs/advanced/federation.md Architecture deep dive
docs/advanced/tier-detection.md Tier detection explanation
docs/advanced/context-switching.md Context switching docs
docs/configuration/instances.md Configuration reference
docs/configuration/rate-limiting.md Rate limiting reference
docs/cli/instances.md CLI reference

Modified Files

File Changes
src/oauth/token-context.ts Add apiUrl, instanceLabel (no tier!)
src/oauth/session-manager.ts Add gitlabApiUrl, namespaceTierCache
src/utils/fetch.ts Use context URL, integrate rate limiter
src/services/ConnectionManager.ts Delegate to InstanceRegistry
src/entities/context/handlers.ts Add context switch with re-introspection
src/tools/registry.ts Add revalidateForSchema() method
src/cli/setup.ts Multi-instance setup wizard
src/config/index.ts Load instances configuration
README.md Add multi-instance section

Acceptance Criteria

  • Single GITLAB_BASE_URL still works (backward compatibility)
  • GITLAB_INSTANCES supports single URL format
  • GITLAB_INSTANCES supports bash array syntax
  • GITLAB_INSTANCES_FILE loads YAML configuration
  • GITLAB_INSTANCES_FILE loads JSON configuration
  • Setup wizard supports adding multiple instances
  • Setup wizard supports removing instances
  • CLI instances list/add/remove/test/info commands work
  • Each OAuth session stores its GitLab instance URL
  • TokenContext includes apiUrl field (NOT tier!)
  • enhancedFetch uses context URL over global
  • Per-instance rate limiting works
  • Request queuing works when at capacity
  • Queue timeout rejects stale requests
  • Instance-level cache: version + schema only
  • Namespace tier cached per-session with 5-min TTL
  • Tier detection queries correct GraphQL endpoint
  • Feature checks use namespace tier, not instance tier
  • OAuth flow includes instance selection step
  • Context switching blocked in OAuth mode
  • Context switching triggers re-introspection in static mode
  • Tools re-validated on instance switch
  • Disabled tools reported to user after switch
  • Namespace tier cache cleared on switch
  • All integration tests pass
  • Documentation clearly explains tier-per-namespace model
  • Documentation covers context switching behavior
  • yarn list-tools works with multi-instance

Time Estimate

Phase Effort
Phase 1: Core Infrastructure 10h
Phase 2: Configuration System 4h
Phase 3: Setup Wizards 4h
Phase 4: Feature Availability Checks 3h
Phase 5: Rate Limiting 4h
Phase 6: OAuth Flow 3h
Phase 7: Context Switching 5h
Phase 8: Documentation 5h
Phase 9: Integration Tests 9h
Total 47h

Future Enhancements (Separate Issues)


Labels

enhancement, architecture, oauth, documentation

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions