-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Multi-Instance OAuth Federation with per-session introspection #274
Description
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_URLis global - all users connect to same instanceConnectionManageris 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.comMultiple 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.yaml2.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 secondsJSON 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.comPhase 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:
- Tier is per-namespace on gitlab.com, not per-instance
- Self-hosted instances may have instance-wide tier
- Namespace tier is cached per-session (5 min TTL)
- Instance-level cache (version, schema) is shared between sessions
- How to configure multiple instances
- Rate limiting behavior and configuration
- Context switching: OAuth blocks it, static token allows it
- 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_URLstill works (backward compatibility) -
GITLAB_INSTANCESsupports single URL format -
GITLAB_INSTANCESsupports bash array syntax -
GITLAB_INSTANCES_FILEloads YAML configuration -
GITLAB_INSTANCES_FILEloads JSON configuration - Setup wizard supports adding multiple instances
- Setup wizard supports removing instances
- CLI
instances list/add/remove/test/infocommands work - Each OAuth session stores its GitLab instance URL
-
TokenContextincludesapiUrlfield (NOT tier!) -
enhancedFetchuses 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-toolsworks 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)
- feat: Instance Health Dashboard on GET / endpoint #275 — Instance Health Dashboard on
GET /endpoint
Labels
enhancement, architecture, oauth, documentation