diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 0e3b8b36..8f7e2528 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -56,6 +56,10 @@ jobs: run: yarn lint working-directory: packages/gitlab-mcp + - name: Verify documentation release downloads + run: node --test tests/scripts/docs-download.test.mjs + working-directory: packages/gitlab-mcp + - name: Run tests with coverage run: yarn test:cov working-directory: packages/gitlab-mcp diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f267ba2a..938562ad 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,10 +8,8 @@ on: - 'packages/gitlab-mcp/package.json' - 'yarn.lock' - '.github/workflows/docs.yml' - workflow_run: - workflows: ["CI/CD Pipeline"] - types: [completed] - branches: [main] + repository_dispatch: + types: [mcpb-published] workflow_dispatch: permissions: @@ -26,9 +24,6 @@ concurrency: jobs: build: runs-on: ubuntu-latest - if: > - github.event_name != 'workflow_run' || - github.event.workflow_run.conclusion == 'success' # docs/, vitepress, and the docs scripts live in the core package; run the # docs steps from there. (uses: steps below set explicit paths where needed.) defaults: @@ -58,8 +53,8 @@ jobs: mkdir -p docs/public/downloads LATEST_TAG=$(gh release view --json tagName --jq '.tagName' 2>/dev/null || echo "") if [ -n "$LATEST_TAG" ]; then - VERSION="${LATEST_TAG#v}" - echo "version=${VERSION}" >> $GITHUB_OUTPUT + VERSION="${LATEST_TAG#gitlab-mcp-v}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" gh release download "$LATEST_TAG" --pattern "*.mcpb" --dir docs/public/downloads/ || true MCPB_FILE="docs/public/downloads/gitlab-mcp-${VERSION}.mcpb" if [ -f "$MCPB_FILE" ]; then @@ -68,7 +63,7 @@ jobs: else # Fallback to package.json version if no release exists VERSION=$(jq -r '.version' package.json) - echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "MCPB bundle version from package.json: ${VERSION}" fi env: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index f9ec42e3..b560bc33 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -236,12 +236,20 @@ jobs: uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.release-please.outputs.release-tag }} + fail_on_unmatched_files: true files: | packages/gitlab-mcp/gitlab-mcp-[0-9]*.mcpb packages/gitlab-mcp/gitlab-mcp-db-*.mcpb env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # repository_dispatch can trigger downstream workflows using GITHUB_TOKEN. + # https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow + - name: Refresh documentation downloads + run: gh api --method POST "repos/${GITHUB_REPOSITORY}/dispatches" -f event_type=mcpb-published + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload Codex marketplace to GitHub Release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: diff --git a/TESTING.md b/TESTING.md index be33c2a3..cc370db6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -22,7 +22,7 @@ The GitLab MCP integration tests follow a **dependency chain pattern** to ensure ## 🚨 Critical Rules 1. **Tests CANNOT run standalone** - They depend on lifecycle data -2. **Must use `--runInBand`** - Tests must run serially to maintain dependencies +2. **Integration suites run serially** - Use the maintained integration scripts to preserve lifecycle order 3. **Single infrastructure creation** - Only data-lifecycle.test.ts creates data 4. **No individual cleanup** - Only final cleanup in data-lifecycle.test.ts 5. **Real data only** - No mocks, all tests use actual GitLab entities @@ -55,14 +55,36 @@ enables `--experimental-vm-modules` so Jest can load jsdom's ESM dependencies fr the CommonJS settings-panel tests. Use `test`, `test:cov`, `test:integration` or `test:all` rather than invoking Jest without that runtime option. -### Complete Integration Test Suite (Recommended) +### Select the required suite + +Run these from `packages/gitlab-mcp`: + +| Command | Executes | External prerequisites | +| --- | --- | --- | +| `yarn test` | Unit tests only | None | +| `yarn test:cov` | Unit tests with coverage | None | +| `yarn test:integration` | Integration suites in lifecycle order | Protected `.env.test`, designated test namespace | +| `yarn test:all` | Unit and integration suites | The same integration environment | + +The Linux publishing-workflow regression runs separately from the portable unit +suite and requires Bash. The required Quality Checks job executes it with: + +```bash +node --test tests/scripts/docs-download.test.mjs +``` + +### Complete Unit and Integration Test Suite (requires `.env.test`) ```bash -yarn test +yarn test:all ``` +Treat this as full-suite evidence only with the protected `.env.test` configuration +and designated test namespace available. Without that file, the test configuration +selects unit tests only, even when this command succeeds. + This command: - ✅ Uses all feature flags (WORKITEMS, MILESTONE, PIPELINE, GITLAB_WIKI) -- ✅ Runs tests in dependency order with `--runInBand` +- ✅ Runs suites serially with one worker and lifecycle ordering; accumulated worker memory can be reclaimed between files - ✅ Creates complete infrastructure once - ✅ Tests all schemas with real data - ✅ Cleans up everything at the end @@ -70,10 +92,10 @@ This command: ### Individual Test Files (NOT RECOMMENDED) ```bash # ❌ DON'T DO THIS - Will fail due to missing dependencies -yarn test tests/integration/schemas-dependent/merge-requests.test.ts +yarn test:integration --runTestsByPath tests/integration/schemas-dependent/merge-requests.test.ts # ✅ If you must, run the full chain: -yarn test +yarn test:integration ``` ## 📊 Data Lifecycle Stages @@ -163,7 +185,7 @@ describe('My Schema Tests', () => { ``` Error: Test data not available. Make sure to run data-lifecycle.test.ts first with --runInBand ``` -**Solution:** Use `yarn test` instead of individual test files. +**Solution:** Use `yarn test:integration` instead of individual dependent files. ### Tests running in parallel ``` @@ -186,30 +208,16 @@ Test expects MRs but none found 5. **Clean**: Single cleanup operation at the end 6. **Maintainable**: Clear separation between data creation and testing -## ✅ Current Status (2025-01-15) - -**🎉 ALL CRITICAL ISSUES RESOLVED**: -- ✅ **Complete test suite passing** - 27/27 test suites, 369/372 tests passing (99.2%) -- ✅ **Unit tests completely rewritten** - Proper mock infrastructure using enhancedFetch -- ✅ **Integration tests fully working** - Real GitLab API testing with data lifecycle -- ✅ **Node-fetch migration complete** - All tests now use native fetch API -- ✅ **Test dependency chain FIXED** - Persistent file storage enables data sharing between test files -- ✅ **Soft-fail patterns eliminated** - All tests use real data or fail properly -- ✅ **Jest configuration enhanced** - Proper serial execution with `--runInBand` - -**Test Results Summary**: -- **Total Test Suites**: 27 passed, 0 failed -- **Total Tests**: 369 passed, 3 skipped, 0 failed (372 total) -- **Integration Tests**: 16 suites passing (200+ tests) -- **Unit Tests**: 11 suites passing (169+ tests) -- **Coverage**: 54.92% statements, 37.39% branches, 53.28% functions - -**Key Test Categories**: -- ✅ **Data Lifecycle**: 12/12 tests passing - Complete infrastructure setup/teardown -- ✅ **Schema Validation**: 150+ tests passing - All GitLab API schemas validated -- ✅ **Unit Tests**: 169+ tests passing - Full mock-based handler testing -- ✅ **Work Items GraphQL**: 20+ tests passing - Full CRUD with real GitLab instance -- ✅ **Integration API**: 30+ tests passing - Real GitLab API validation +## Current results and installed-client qualification + +Use the CI results and coverage report for the exact commit under review. Historical +test counts do not establish the state of a current release, and unit-only results +do not establish that integration suites ran. + +The [Codex release qualification procedure](packages/gitlab-mcp/docs/guides/codex-qualification.md) +separates archive/CLI checks, read-only provider checks and manual desktop acceptance. +The [workflow evaluation guide](packages/gitlab-mcp/docs/guides/codex-workflows.md) +describes authenticated client evaluations against deterministic loopback fixtures. ## 🎯 Adding New Tests diff --git a/packages/gitlab-mcp/docs/.vitepress/config.mts b/packages/gitlab-mcp/docs/.vitepress/config.mts index 40f317f1..335ded51 100644 --- a/packages/gitlab-mcp/docs/.vitepress/config.mts +++ b/packages/gitlab-mcp/docs/.vitepress/config.mts @@ -414,6 +414,7 @@ export default defineConfig({ items: [ { text: "Overview", link: "/guides/" }, { text: "Codex and Claude Skills", link: "/guides/codex-workflows" }, + { text: "Codex Release Qualification", link: "/guides/codex-qualification" }, { text: "Duo Sessions", link: "/guides/duo-sessions" }, { text: "Complete Code Review", link: "/guides/complete-code-review" }, { text: "Setup CI Notifications", link: "/guides/setup-ci-notifications" }, diff --git a/packages/gitlab-mcp/docs/advanced/federation.md b/packages/gitlab-mcp/docs/advanced/federation.md index 61b0b931..6411dc88 100644 --- a/packages/gitlab-mcp/docs/advanced/federation.md +++ b/packages/gitlab-mcp/docs/advanced/federation.md @@ -13,6 +13,11 @@ Technical deep dive into how GitLab MCP manages multiple GitLab instances with p ## System Architecture +Namespace-aware capability checks are available from 13.2.0. GitLab.com uses +root-namespace subscriptions; self-managed instances retain their instance +license. See [Tier Detection](/advanced/tier-detection) for visibility and unknown +subscription behavior. + ``` ┌─────────────────────────────────────────────────────────────┐ │ MCP Server (single process) │ @@ -25,7 +30,7 @@ Technical deep dive into how GitLab MCP manages multiple GitLab instances with p │ │ v17.2 SaaS │ v16.8 Self │ v16.0 Self │ │ │ │ Rate: 0/100 │ Rate: 5/50 │ Rate: 2/20 │ │ │ └─────────────┴─────────────┴─────────────┘ │ -│ NOTE: No tier here! Tier is per-namespace, not instance │ +│ SaaS tier: per root namespace; self-managed: per instance │ ├─────────────────────────────────────────────────────────────┤ │ SessionManager (per-user sessions) │ │ ┌──────────────────┐ ┌──────────────────┐ │ @@ -64,12 +69,12 @@ Technical deep dive into how GitLab MCP manages multiple GitLab instances with p │ │ • User info (username, id) ││ │ └─────────────────────────────────────────────────────────────┘│ ├─────────────────────────────────────────────────────────────────┤ -│ NAMESPACE-LEVEL CACHE (per session) — TTL: 5 min │ -│ Tier varies per group/project, especially on gitlab.com │ +│ NAMESPACE CACHE (session, instance, credential): TTL 5 min │ +│ GitLab.com projects inherit their root subscription │ │ ┌─────────────────────────────────────────────────────────────┐│ -│ │ "gitlab-org" → Ultimate, features: {epics: ✓, iterations: ✓}││ -│ │ "my-free-group" → Free, features: {epics: ✗, iterations: ✗} ││ -│ │ "company/proj" → Premium, features: {epics: ✓, ...} ││ +│ │ "enterprise" → Ultimate ││ +│ │ "personal" → Free ││ +│ │ "company" → Unknown when plan metadata is unavailable ││ │ └─────────────────────────────────────────────────────────────┘│ └─────────────────────────────────────────────────────────────────┘ ``` @@ -167,31 +172,20 @@ Request flow: ### NamespaceTierDetector -Detects and caches namespace tier via GraphQL: - -```graphql -query GetNamespaceTier($fullPath: ID!) { - namespace(fullPath: $fullPath) { - id - fullPath - - ... on Group { - plan - } +Observes the REST root namespace's optional `plan` attribute. Numeric project and +group IDs are resolved to paths before looking up their root. Credentials and +instances have separate cache entries, with a five-minute TTL and a total +capacity of 1,024 observations. Missing or unrecognized metadata remains unknown. - ... on Project { - group { - plan - } - } - } -} -``` +Discovery uses the union of the selected roots. Execution uses the actual target +arguments. Both run inside a request-local tier context; `ConnectionManager` +projects that fact into the existing tool and widget capability checks without +mutating its stored instance information. Unknown tiers defer license decisions +to GitLab while preserving version, token and operator restrictions. -Tier normalization: -- `ultimate`, `gold` → Ultimate -- `premium`, `silver`, `bronze`, `starter` → Premium -- Everything else → Free +See [Tier Detection](/advanced/tier-detection) for the REST contract, normalization +and bounded lookup behavior. The cache is disposable and independent on each +replica; it is not an authorization or session store. ### TokenContext diff --git a/packages/gitlab-mcp/docs/advanced/tier-detection.md b/packages/gitlab-mcp/docs/advanced/tier-detection.md index 0c2e3717..2b098a4b 100644 --- a/packages/gitlab-mcp/docs/advanced/tier-detection.md +++ b/packages/gitlab-mcp/docs/advanced/tier-detection.md @@ -1,204 +1,113 @@ --- title: Tier Detection -description: "Understand how GitLab MCP detects namespace tier (Free/Premium/Ultimate) for feature availability. Learn about per-namespace caching, GraphQL queries, and tier-based tool restrictions." +description: "How GitLab MCP combines namespace subscriptions, GitLab versions and permissions when exposing tools and fields." head: - - meta - name: keywords content: tier detection, namespace tier, free, premium, ultimate, feature availability, MCP --- -# GitLab Tier Detection for Premium Features +# GitLab Tier Detection -How GitLab MCP detects the tier (Free/Premium/Ultimate) of each namespace to determine feature availability. +On GitLab.com, subscriptions belong to **root namespaces**. One account can work +with a Free personal namespace, a Premium organization and an Ultimate customer +at the same time. A subgroup or project inherits its root namespace's subscription. +See [GitLab subscription management](https://docs.gitlab.com/subscriptions/manage_subscription/). -## Critical Concept +Self-managed GitLab uses its instance license. Namespace subscription detection +does not replace that existing instance-level path. -::: danger Important -**Tier is per-NAMESPACE, not per-instance!** - -On gitlab.com, a single user can access Free, Premium, and Ultimate namespaces simultaneously. Detecting tier at the instance level would be incorrect. +::: info Available from 13.2.0 +Namespace observations are applied to tool discovery, action requirements and +Work Item widget checks from 13.2.0. Earlier versions use instance-level capability +data for these checks. ::: -## How It Works - -### GraphQL Query - -GitLab MCP queries the namespace tier via GraphQL: - -```graphql -query GetNamespaceTier($fullPath: ID!) { - namespace(fullPath: $fullPath) { - id - fullPath - - # For groups - check plan directly - ... on Group { - plan - } - - # For projects - check parent group's plan - ... on Project { - group { - plan - } - } - } -} -``` - -### Tier Normalization - -GitLab plan names are normalized to three tiers: - -| GitLab Plan | Normalized Tier | -|-------------|-----------------| -| `ultimate`, `gold` | Ultimate | -| `premium`, `silver`, `bronze`, `starter` | Premium | -| `free`, `null`, other | Free | +## What the client sees -### Caching +With a selected project or group, the advertised tools and fields use that +namespace's observed subscription. A scope covering several roots advertises +their union, so a Free project does not hide tools needed by an Ultimate project. -Namespace tier is cached per-session with 5-minute TTL: +Execution resolves the actual project or group arguments again. Parallel requests +use separate contexts, including when two chats share the same GitLab.com account. +They do not change a shared connection's tier. -``` -Session Cache Key: ${sessionId}:${namespacePath} -Example: abc123:gitlab-org → Ultimate -``` +An unscoped request, an opaque ID that cannot yet be resolved, or a call spanning +different subscriptions has **unknown** tier. Unknown does not mean Free: +discovery does not hide paid features solely because billing metadata is missing. +GitLab still checks the actual operation's license, permissions and feature flags. -Cache is cleared when: -- TTL expires (5 minutes) -- User switches GitLab instance (static token mode) -- Session ends - -## Feature Availability - -Each tier enables different features: - -| Feature | Free | Premium | Ultimate | -|---------|------|---------|----------| -| Issues | Yes | Yes | Yes | -| Merge Requests | Yes | Yes | Yes | -| Wiki | Yes | Yes | Yes | -| Epics | No | Yes | Yes | -| Iterations | No | Yes | Yes | -| Roadmaps | No | Yes | Yes | -| OKRs | No | No | Yes | -| Health Status | No | Yes | Yes | -| Weight | No | Yes | Yes | -| Multi-level Epics | No | Yes | Yes | -| Requirements | No | Yes | Yes | -| Security Dashboard | No | No | Yes | -| Compliance Framework | No | Yes | Yes | - -### Feature Checks in Tools - -Tools that require specific features check the namespace tier: - -```typescript -// Example: Creating an epic -async function handleCreateEpic(args) { - const tierInfo = await getNamespaceTier(args.groupPath); - - 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... -} -``` +Version requirements, token scopes, admin elevation, read-only settings and +operator project restrictions remain independent checks. -### Error Messages +## Subscription lookup -When a feature is unavailable, users receive helpful error messages: - -``` -Error: Epics require GitLab Premium or Ultimate. -Namespace "my-free-group" is on free tier. +The server reads the root entity through REST API v4: -Consider: -- Upgrade the namespace to Premium or Ultimate -- Use a different namespace with higher tier +```http +GET /api/v4/namespaces/example-team ``` -## SaaS vs Self-Hosted - -### GitLab.com (SaaS) - -- Each namespace can have a different tier -- Tier detection via GraphQL is required -- Same user may access Free/Premium/Ultimate namespaces - -### Self-Hosted - -- Typically has a single license for the entire instance -- All namespaces share the same tier -- Tier can be cached at instance level (optimization) - -GitLab MCP detects SaaS vs self-hosted and optimizes accordingly. - -## API Functions - -### getNamespaceTier - -```typescript -async function getNamespaceTier( - namespacePath: string -): Promise - -interface NamespaceTierInfo { - tier: 'free' | 'premium' | 'ultimate'; - features: Record; - cachedAt: Date; -} -``` - -### isFeatureAvailable - -```typescript -async function isFeatureAvailable( - namespacePath: string, - feature: string -): Promise - -// Example usage -const canCreateEpic = await isFeatureAvailable('my-group', 'epics'); -``` - -### clearNamespaceTierCache - -```typescript -function clearNamespaceTierCache(sessionId?: string): void - -// Clear for specific session -clearNamespaceTierCache('abc123'); - -// Clear all caches -clearNamespaceTierCache(); -``` +A numeric project ID is first resolved through `GET /projects/:id`; a numeric +group ID through `GET /groups/:id?with_projects=false`. Numeric namespace +arguments that accept either kind follow the handler's group-first resolution +and try a project only after a group 404. + +The `plan` attribute is permission-dependent. Access to a project does not +guarantee access to its root's billing metadata. Missing attributes, an unknown +plan, inaccessible metadata and lookup failures leave the subscription unknown. +A lookup failure does not turn an otherwise usable GitLab operation into a +license refusal. + +See the [GitLab Namespaces API](https://docs.gitlab.com/api/namespaces/) for +visibility rules. The endpoint and conditional plan attribute already exist in +the [GitLab 16.0 namespace entity](https://gitlab.com/gitlab-org/gitlab/-/blob/v16.0.0-ee/ee/lib/ee/api/entities/namespace.rb). + +| Observed plan | Tier | +| --- | --- | +| `free`, `default` | Free | +| `premium`, `premium_trial`, `silver` | Premium | +| `ultimate`, `ultimate_trial`, `gold`, `opensource` | Ultimate | +| Missing, unrecognized or legacy plan without a supported equivalent | Unknown | + +A plan is an input to capability checks, not proof that every feature is enabled. +Individual tools declare their version and tier requirements; widget checks use +the same request-local tier. GitLab's response remains authoritative. + +## Caching and request cost + +Observations, including unknown results, have a five-minute TTL. Cache keys +separate the selected instance, credential, session and root namespace. +Replacing a token or changing account cannot reuse another credential's facts. +The disposable process-local cache has a total capacity of 1,024 entries; it +contains no stored token values. + +Descendants of one root share a lookup. A catalog spanning several roots resolves +at most five distinct targets concurrently and stops starting queued optional +reads after an unknown observation prevents a complete tier decision. Free-only +tool calls do not perform subscription lookups. + +This cache is an optimization, not session or authorization storage. Each server +replica can rebuild its own observations. Refreshing facts never changes another +request's active tier, and an expired request cannot start a tool after a late +metadata response. ## Troubleshooting -### Feature unavailable error - -If you receive a feature unavailability error: - -1. Check the namespace path is correct -2. Verify the namespace has the required tier in GitLab UI -3. Wait 5 minutes for cache to expire if tier was recently upgraded - -### Tier not detected correctly - -If tier detection seems incorrect: - -1. Check GitLab API access - token must have `api` or `read_api` scope -2. Verify the namespace exists and is accessible -3. Check for GraphQL errors in server logs +- Confirm the selected account and project or group in `get_context`. +- Check the namespace subscription in GitLab. A user who can read a project may + still be unable to read its billing metadata. +- If a subscription recently changed, allow up to five minutes for cached facts + to expire. +- An explicit unavailable-action error can also be caused by the GitLab version, + token permissions or a disabled feature. Changing a subscription alone does + not remove those restrictions. +- Inspect logs for metadata status or read failures. Provider response bodies and + parser exception text are excluded from these diagnostics. ## Related Documentation -- [Multi-Instance Setup](/guide/multi-instance) - Getting started guide -- [Federation Architecture](/advanced/federation) - Technical deep dive -- [Context Switching](/advanced/context-switching) - Instance switching behavior +- [Multi-Instance Setup](/guide/multi-instance) +- [Federation Architecture](/advanced/federation) +- [Context Switching](/advanced/context-switching) diff --git a/packages/gitlab-mcp/docs/clients/claude-code.md b/packages/gitlab-mcp/docs/clients/claude-code.md index 5ca7c402..4e755877 100644 --- a/packages/gitlab-mcp/docs/clients/claude-code.md +++ b/packages/gitlab-mcp/docs/clients/claude-code.md @@ -70,13 +70,23 @@ Connect to a running GitLab MCP HTTP server: { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "http://localhost:3333/mcp" } } } ``` +Alternatively, use Claude Code's native registration and browser sign-in: + +```bash +claude mcp add --transport http gitlab https://mcp.example.com/mcp +``` + +Run `/mcp` in Claude Code to authenticate an OAuth server. Registration defaults +to the current project's local scope; add `--scope user` for all your projects. +See the [official MCP configuration guide](https://code.claude.com/docs/en/mcp). + ## Verification ```bash diff --git a/packages/gitlab-mcp/docs/clients/claude-desktop.md b/packages/gitlab-mcp/docs/clients/claude-desktop.md index 9414fa1e..c2638ef7 100644 --- a/packages/gitlab-mcp/docs/clients/claude-desktop.md +++ b/packages/gitlab-mcp/docs/clients/claude-desktop.md @@ -15,7 +15,7 @@ Configure GitLab MCP for [Claude Desktop](https://claude.ai/download). The easiest way to install — download and open the `.mcpb` bundle: - + Install in Claude Desktop @@ -88,18 +88,14 @@ To run via Docker instead of npx: ## Remote Server -Connect to a running GitLab MCP HTTP server: +Add a remote server through **Customize > Connectors > Add custom connector**, +using its complete MCP endpoint, for example `https://mcp.example.com/mcp`. +Complete OAuth when prompted. -```json -{ - "mcpServers": { - "gitlab": { - "type": "streamable-http", - "url": "http://localhost:3333/mcp" - } - } -} -``` +Remote connectors run through Anthropic's cloud and cannot reach a server at your +computer's `localhost`. Keep the local stdio configuration above for local access. +See [Anthropic's remote connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) +and [OAuth setup](/security/oauth). ## Verification diff --git a/packages/gitlab-mcp/docs/clients/claude-desktop.md.in b/packages/gitlab-mcp/docs/clients/claude-desktop.md.in index c751e132..0fb568c3 100644 --- a/packages/gitlab-mcp/docs/clients/claude-desktop.md.in +++ b/packages/gitlab-mcp/docs/clients/claude-desktop.md.in @@ -88,18 +88,14 @@ To run via Docker instead of npx: ## Remote Server -Connect to a running GitLab MCP HTTP server: - -```json -{ - "mcpServers": { - "gitlab": { - "type": "streamable-http", - "url": "http://localhost:3333/mcp" - } - } -} -``` +Add a remote server through **Customize > Connectors > Add custom connector**, +using its complete MCP endpoint, for example `https://mcp.example.com/mcp`. +Complete OAuth when prompted. + +Remote connectors run through Anthropic's cloud and cannot reach a server at your +computer's `localhost`. Keep the local stdio configuration above for local access. +See [Anthropic's remote connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) +and [OAuth setup](/security/oauth). ## Verification diff --git a/packages/gitlab-mcp/docs/clients/cline.md b/packages/gitlab-mcp/docs/clients/cline.md index fc70bd7a..2f9e9d08 100644 --- a/packages/gitlab-mcp/docs/clients/cline.md +++ b/packages/gitlab-mcp/docs/clients/cline.md @@ -60,13 +60,18 @@ Connect to a running GitLab MCP HTTP server: { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "streamableHttp", "url": "http://localhost:3333/mcp" } } } ``` +Cline's transport identifier is `streamableHttp`. You can also add the endpoint +through **MCP Servers > Remote Servers**, choosing Streamable HTTP. Use +**Configure MCP Servers** to locate the IDE extension's active configuration file; +the CLI uses a separate configuration. See [Cline's MCP guide](https://docs.cline.bot/mcp/mcp-overview). + ## Verification 1. Open VS Code with the Cline extension installed diff --git a/packages/gitlab-mcp/docs/clients/cursor.md b/packages/gitlab-mcp/docs/clients/cursor.md index b257c330..a69008df 100644 --- a/packages/gitlab-mcp/docs/clients/cursor.md +++ b/packages/gitlab-mcp/docs/clients/cursor.md @@ -60,13 +60,15 @@ Connect to a running GitLab MCP HTTP server: { "mcpServers": { "gitlab": { - "type": "streamable-http", "url": "http://localhost:3333/mcp" } } } ``` +Cursor infers the remote transport from its URL. See its +[MCP configuration guide](https://cursor.com/docs/mcp). + ## Verification 1. Open Cursor diff --git a/packages/gitlab-mcp/docs/clients/index.md b/packages/gitlab-mcp/docs/clients/index.md index 4099ef40..f0bfb11b 100644 --- a/packages/gitlab-mcp/docs/clients/index.md +++ b/packages/gitlab-mcp/docs/clients/index.md @@ -25,7 +25,9 @@ GitLab MCP works with all major AI coding assistants that support the Model Cont | [Codex](/guide/installation/codex) | stdio or HTTP | CLI command | Plugin or `codex mcp` | | [Antigravity CLI](/clients/antigravity) | stdio or HTTP | No | Workspace JSON | -All clients can also connect via HTTP (StreamableHTTP or SSE) to a [remote server deployment](/deployment/). +Remote HTTP support and configuration syntax depend on the client. Follow its +specific guide when connecting to a [remote server deployment](/deployment/); +a stdio-only configuration may need a supported HTTP-to-stdio adapter. ## Automatic Setup diff --git a/packages/gitlab-mcp/docs/clients/roo-code.md b/packages/gitlab-mcp/docs/clients/roo-code.md index 87095cad..ef64c511 100644 --- a/packages/gitlab-mcp/docs/clients/roo-code.md +++ b/packages/gitlab-mcp/docs/clients/roo-code.md @@ -9,33 +9,49 @@ head: # Roo Code Integration Guide -Configure GitLab MCP for [Roo Code](https://roocode.com). +Configure GitLab MCP for the Roo Code extension. Its +[MCP configuration reference](https://roocodeinc.github.io/Roo-Code/features/mcp/using-mcp-in-roo/) +describes the project and global configuration formats. -## Auto-Setup +## Open the active configuration + +In Roo Code's MCP settings, choose **Edit Project MCP** for the workspace or +**Edit Global MCP** for all workspaces. Project configuration overrides global +configuration for the same server name. + +For the standard VS Code profile, automatic setup writes the extension's global +`settings/mcp_settings.json`: + +When `VSCODE_APPDATA` is nonempty, its `Code` directory takes precedence on all +platforms. Otherwise, Linux uses `XDG_CONFIG_HOME/Code` when the variable is +nonempty, or `~/.config/Code`. ```bash npx @structured-world/gitlab-mcp install --roo-code ``` +With portable VS Code, a custom profile or `--user-data-dir`, another editor, or Roo's custom storage path, open +**Edit Global MCP** to find and edit the active configuration. The installer does +not migrate the unused home-level `~/.roo/mcp.json` file. + ## Detection | Method | Details | |--------|---------| | Type | Config directory | -| Path | `~/.roo/` | -| Auto-detected | Yes (when config directory exists) | +| Project path | `.roo/` in the workspace | +| Global configuration | Open with **Edit Global MCP** | ## Config File Location | OS | Path | |----|------| -| macOS | `~/.roo/mcp.json` | -| Windows | `%USERPROFILE%\.roo\mcp.json` | -| Linux | `~/.roo/mcp.json` | +| All platforms, project | `.roo/mcp.json` in the workspace root | +| All platforms, global | `mcp_settings.json` opened by Roo Code | ## Configuration -Add to your `~/.roo/mcp.json`: +Add to the configuration file opened by Roo Code: ```json { @@ -44,7 +60,7 @@ Add to your `~/.roo/mcp.json`: "command": "npx", "args": ["-y", "@structured-world/gitlab-mcp"], "env": { - "GITLAB_TOKEN": "glpat-xxxxxxxxxxxxxxxxxxxx", + "GITLAB_TOKEN": "${env:GITLAB_TOKEN}", "GITLAB_API_URL": "https://gitlab.com" } } @@ -52,6 +68,10 @@ Add to your `~/.roo/mcp.json`: } ``` +Set `GITLAB_TOKEN` in the environment of the VS Code process before starting Roo +Code. Roo resolves `${env:GITLAB_TOKEN}` from that environment. Do not replace the +reference with a literal token in `.roo/mcp.json` or commit credentials to Git. + ## Remote Server Connect to a running GitLab MCP HTTP server: @@ -76,7 +96,7 @@ Connect to a running GitLab MCP HTTP server: ## Troubleshooting -- **Config directory missing**: Create `~/.roo/` manually if it doesn't exist +- **Config directory missing**: Use **Edit Project MCP** to create the workspace configuration - **Server not appearing**: Restart Roo Code after editing `mcp.json` - **JSON syntax errors**: Validate the file with a JSON linter diff --git a/packages/gitlab-mcp/docs/clients/vscode.md b/packages/gitlab-mcp/docs/clients/vscode.md index 4be030a4..04bcf6d8 100644 --- a/packages/gitlab-mcp/docs/clients/vscode.md +++ b/packages/gitlab-mcp/docs/clients/vscode.md @@ -38,7 +38,8 @@ Install directly from VS Code: | All | `.vscode/mcp.json` (per-project) | ::: tip -VS Code uses per-project MCP configuration. Create the config in each project's `.vscode/` directory. +Use `.vscode/mcp.json` for workspace configuration. VS Code also supports user-level +MCP configuration through its MCP configuration commands. ::: ## Configuration @@ -47,12 +48,21 @@ Create or edit `.vscode/mcp.json` in your project root: ```json { - "mcpServers": { + "inputs": [ + { + "type": "promptString", + "id": "gitlab-token", + "description": "GitLab access token", + "password": true + } + ], + "servers": { "gitlab": { + "type": "stdio", "command": "npx", "args": ["-y", "@structured-world/gitlab-mcp"], "env": { - "GITLAB_TOKEN": "glpat-xxxxxxxxxxxxxxxxxxxx", + "GITLAB_TOKEN": "${input:gitlab-token}", "GITLAB_API_URL": "https://gitlab.com" } } @@ -60,21 +70,29 @@ Create or edit `.vscode/mcp.json` in your project root: } ``` +VS Code prompts for the token without writing its value into this workspace file. +Do not replace the input reference with a literal token in a committed configuration. + ## Remote Server Connect to a running GitLab MCP HTTP server: ```json { - "mcpServers": { + "servers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "http://localhost:3333/mcp" } } } ``` +These examples use the VS Code `.vscode/mcp.json` format with a `servers` root. +Portable Copilot configurations use `mcpServers` instead; do not interchange the +formats when selecting a different configuration destination. +See [Microsoft's MCP server guide](https://code.visualstudio.com/docs/agent-customization/mcp-servers). + ## Verification 1. Open the project in VS Code @@ -85,7 +103,7 @@ Connect to a running GitLab MCP HTTP server: ## Troubleshooting - **Tools not appearing**: Reload the VS Code window (`Cmd+Shift+P` > "Reload Window") -- **Per-project vs global**: VS Code MCP is per-project; ensure `.vscode/mcp.json` is in your workspace root +- **Configuration scope**: Check whether the server is registered for your workspace or user profile - **Copilot required**: MCP support requires GitHub Copilot extension ## See Also diff --git a/packages/gitlab-mcp/docs/clients/windsurf.md b/packages/gitlab-mcp/docs/clients/windsurf.md index d6a534b8..91a42694 100644 --- a/packages/gitlab-mcp/docs/clients/windsurf.md +++ b/packages/gitlab-mcp/docs/clients/windsurf.md @@ -60,13 +60,15 @@ Connect to a running GitLab MCP HTTP server: { "mcpServers": { "gitlab": { - "type": "streamable-http", - "url": "http://localhost:3333/mcp" + "serverUrl": "http://localhost:3333/mcp" } } } ``` +Windsurf uses `serverUrl` for remote MCP endpoints. Open its MCP configuration +from Cascade to confirm the active file for your installed version. + ## Verification 1. Open Windsurf diff --git a/packages/gitlab-mcp/docs/configuration/rate-limiting.md b/packages/gitlab-mcp/docs/configuration/rate-limiting.md index ad01be9e..b955b44d 100644 --- a/packages/gitlab-mcp/docs/configuration/rate-limiting.md +++ b/packages/gitlab-mcp/docs/configuration/rate-limiting.md @@ -13,11 +13,17 @@ Configure per-instance rate limiting to prevent overwhelming GitLab instances wi ## Overview -GitLab MCP implements per-instance rate limiting to: -- Prevent hitting GitLab API rate limits +GitLab MCP implements a shared per-instance concurrency queue to: +- Bound simultaneous outbound API requests - Ensure fair resource usage in multi-user environments - Protect self-hosted instances from overload +This queue does not impose a requests-per-minute quota and cannot guarantee that +GitLab will not return 429. It is separate from the inbound MCP/OAuth request limits +described in [OAuth request rate limits](/security/oauth#request-rate-limits). +GitLab response errors expose retry guidance, including `Retry-After` when supplied; +the server does not automatically replay a write. + ## Configuration ### Per-Instance Settings @@ -101,7 +107,9 @@ rateLimit: queueTimeout: 60000 ``` -GitLab.com can handle high concurrency but has its own rate limits (2000 requests/minute for authenticated users). +GitLab.com has its own endpoint- and account-specific rate limits. Check the +[current GitLab.com limits](https://docs.gitlab.com/user/gitlab_com/rate_limits/) +and reduce concurrency when the server returns 429. ### Self-Hosted (Production) diff --git a/packages/gitlab-mcp/docs/deployment/docker-compose.md b/packages/gitlab-mcp/docs/deployment/docker-compose.md index 15a3fad1..9ecbcc98 100644 --- a/packages/gitlab-mcp/docs/deployment/docker-compose.md +++ b/packages/gitlab-mcp/docs/deployment/docker-compose.md @@ -47,11 +47,13 @@ Clients connect to `OAUTH_ISSUER` (from the `.env` file below) with `/mcp` appen discovery metadata names that URL as the resource, so a client using any other address rejects the server or receives tokens for a different resource. +For example, Claude Code uses this HTTP configuration: + ```json { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "https://mcp.example.com/mcp" } } @@ -61,6 +63,9 @@ rejects the server or receives tokens for a different resource. For local testing set `OAUTH_ISSUER=http://localhost:3333` and connect to `http://localhost:3333/mcp`. +Other clients use different configuration formats. Follow the +[client-specific instructions](/clients/). + ## docker-compose.yml ```yaml diff --git a/packages/gitlab-mcp/docs/deployment/docker-postgres.md b/packages/gitlab-mcp/docs/deployment/docker-postgres.md index 22a66b2f..0fc0753e 100644 --- a/packages/gitlab-mcp/docs/deployment/docker-postgres.md +++ b/packages/gitlab-mcp/docs/deployment/docker-postgres.md @@ -69,11 +69,13 @@ docker run -d --name gitlab-mcp \ Clients connect to `OAUTH_ISSUER` with `/mcp` appended; the discovery metadata names that URL as the resource, so any other address fails discovery. +For example, Claude Code uses this HTTP configuration: + ```json { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "https://mcp.example.com/mcp" } } @@ -83,6 +85,9 @@ URL as the resource, so any other address fails discovery. For local testing set `OAUTH_ISSUER=http://localhost:3333` and connect to `http://localhost:3333/mcp`. +Other clients use different configuration formats. Follow the +[client-specific instructions](/clients/). + ## Environment Variables | Variable | Required | Description | diff --git a/packages/gitlab-mcp/docs/deployment/docker-standalone.md b/packages/gitlab-mcp/docs/deployment/docker-standalone.md index 098e7e77..a74a16d1 100644 --- a/packages/gitlab-mcp/docs/deployment/docker-standalone.md +++ b/packages/gitlab-mcp/docs/deployment/docker-standalone.md @@ -39,19 +39,21 @@ Select "standalone" when prompted for deployment type. ## Client Configuration -### StreamableHTTP (Recommended) +### Claude Code over HTTP (Recommended) ```json { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "http://localhost:3333/mcp" } } } ``` +For other clients, follow the corresponding [client guide](/clients/). + ### SSE (Legacy) ```json diff --git a/packages/gitlab-mcp/docs/deployment/local-stdio.md b/packages/gitlab-mcp/docs/deployment/local-stdio.md index 356a50cf..9feb88f0 100644 --- a/packages/gitlab-mcp/docs/deployment/local-stdio.md +++ b/packages/gitlab-mcp/docs/deployment/local-stdio.md @@ -9,7 +9,9 @@ head: # Local stdio Deployment -Run GitLab MCP locally via standard I/O. The MCP client spawns the server process directly — no network, no ports, no containers. +Run GitLab MCP locally via standard I/O. The MCP client spawns the server process +directly, without a local HTTP listener or container. The server still needs network +access to GitLab. ## When to Use @@ -79,7 +81,7 @@ See [Configuration reference](/guide/configuration) for all options. ## Limitations -- One instance per MCP client +- One server process per MCP client; that process can use several configured GitLab instances - No shared access between users - No OAuth support (token-based only) - Process lifecycle tied to the MCP client diff --git a/packages/gitlab-mcp/docs/guide/installation/claude-desktop.md b/packages/gitlab-mcp/docs/guide/installation/claude-desktop.md index 6ef9ce8d..2d003ec6 100644 --- a/packages/gitlab-mcp/docs/guide/installation/claude-desktop.md +++ b/packages/gitlab-mcp/docs/guide/installation/claude-desktop.md @@ -15,8 +15,8 @@ Install GitLab MCP as a Claude Desktop Extension with one click. Download the latest bundle and open it: - - Download gitlab-mcp-13.0.0.mcpb + + Download gitlab-mcp-13.1.0.mcpb Or drag-and-drop the `.mcpb` file into your Claude Desktop window. @@ -45,7 +45,7 @@ If you prefer manual setup, use the [npm method](/guide/installation/npm) or [JS ## Updating When a new version is released, download the latest `.mcpb` from the -documentation site or the +documentation site or the [GitHub releases page](https://github.com/structured-world/gitlab-mcp/releases). ## Advanced Configuration diff --git a/packages/gitlab-mcp/docs/guide/installation/codex.md b/packages/gitlab-mcp/docs/guide/installation/codex.md index 46a2f936..4cf0e8b4 100644 --- a/packages/gitlab-mcp/docs/guide/installation/codex.md +++ b/packages/gitlab-mcp/docs/guide/installation/codex.md @@ -182,3 +182,4 @@ GITLAB_TOKEN = "glpat-..." - [`gitlab-mcp install`](/cli/install) - [Codex workflows](/guides/codex-workflows) +- [Release qualification and desktop acceptance](/guides/codex-qualification) diff --git a/packages/gitlab-mcp/docs/guide/installation/docker.md b/packages/gitlab-mcp/docs/guide/installation/docker.md index a3a281da..8f999586 100644 --- a/packages/gitlab-mcp/docs/guide/installation/docker.md +++ b/packages/gitlab-mcp/docs/guide/installation/docker.md @@ -86,19 +86,21 @@ docker run -i --rm \ ghcr.io/structured-world/gitlab-mcp:latest ``` -### StreamableHTTP Client (Recommended) +### Claude Code over HTTP (Recommended) ```json { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "http://localhost:3333/mcp" } } } ``` +Other clients have [their own configuration formats](/clients/). + ### SSE Client (Legacy) ```json diff --git a/packages/gitlab-mcp/docs/guide/installation/manual.md b/packages/gitlab-mcp/docs/guide/installation/manual.md index eebf3c74..bc371bad 100644 --- a/packages/gitlab-mcp/docs/guide/installation/manual.md +++ b/packages/gitlab-mcp/docs/guide/installation/manual.md @@ -13,7 +13,10 @@ For users who prefer full control over their setup, you can configure GitLab MCP ## Configuration Format -All MCP clients use a JSON configuration with the same structure: +Configuration formats depend on the client. The JSON examples below use the +`mcpServers` format accepted by Claude Desktop and Cursor. VS Code workspace +configuration uses `servers` in `.vscode/mcp.json`; Codex manual registration uses +TOML. Follow the corresponding [client guide](/clients/) before copying an example. ```json { @@ -46,7 +49,11 @@ See [Supported Clients](/clients/) for the config file location specific to your ### 3. Edit the Configuration -Add the `gitlab` entry to the `mcpServers` object in your config file. If the file doesn't exist, create it with the full JSON structure above. +For the clients using the format above, add `gitlab` under `mcpServers` and preserve +any existing entries. If the file does not exist, create the full structure. +For VS Code, use its [workspace configuration example](/clients/vscode#configuration), +including the secure token input. For Codex, follow the +[Codex installation guide](/guide/installation/codex). ### 4. Set Environment Variables @@ -120,19 +127,21 @@ For containerized execution: ## Alternative: HTTP/SSE Remote Server -For connecting to a running GitLab MCP server: +For connecting Claude Code to a running GitLab MCP server: ```json { "mcpServers": { "gitlab": { - "type": "streamable-http", + "type": "http", "url": "http://localhost:3333/mcp" } } } ``` +Other clients use different configuration formats. See the [client guides](/clients/). + ## Verifying Configuration Use the `install --show` command to preview the generated configuration for any client: diff --git a/packages/gitlab-mcp/docs/guide/multi-instance.md b/packages/gitlab-mcp/docs/guide/multi-instance.md index 711d5a9e..ad09ed34 100644 --- a/packages/gitlab-mcp/docs/guide/multi-instance.md +++ b/packages/gitlab-mcp/docs/guide/multi-instance.md @@ -88,15 +88,19 @@ Each entry shows the instance address, below its label when one is set; choosing **Tier is per-NAMESPACE, not per-instance!** On gitlab.com, a single user can access Free, Premium, and Ultimate namespaces simultaneously. ::: -GitLab MCP detects tier per namespace via GraphQL: -- Tier is cached per-session with 5-minute TTL -- Different namespaces may have different tiers -- Features are validated against namespace tier +From 13.2.0, GitLab.com capability checks use the selected root namespace's REST +subscription metadata. Descendants inherit the root's subscription. The same +account can use different tiers concurrently without changing shared connection +data. Self-managed instances continue to use their instance license. + +Observations are cached per credential, instance and session for five minutes. +If GitLab does not expose the plan, it remains unknown rather than becoming Free. +Discovery then keeps paid features visible and GitLab checks the actual request. Example: User has access to: -- `my-free-group` (Free tier) - no epics -- `company/premium-proj` (Premium tier) - epics available -- `enterprise-client` (Ultimate tier) - all features +- `my-free-group` has a Free root subscription +- `company/premium-proj` inherits the Premium subscription of `company` +- `enterprise-client` has an Ultimate root subscription See [Tier Detection](/advanced/tier-detection) for technical details. diff --git a/packages/gitlab-mcp/docs/guide/quick-start.md b/packages/gitlab-mcp/docs/guide/quick-start.md index e35d93c8..37dfb3ec 100644 --- a/packages/gitlab-mcp/docs/guide/quick-start.md +++ b/packages/gitlab-mcp/docs/guide/quick-start.md @@ -12,7 +12,7 @@ howto: - name: "Get a GitLab Token" text: "Create a Personal Access Token in GitLab with 'api' and 'read_user' scopes. Go to GitLab Settings > Access Tokens, create a new token with the required scopes, and copy it." - name: "Configure Your MCP Client" - text: "Add the gitlab-mcp server configuration to your MCP client (Claude Desktop, VS Code, Cursor, etc.). Set GITLAB_TOKEN to your token and GITLAB_API_URL to your GitLab instance URL." + text: "Follow your client's configuration guide. The JSON example below is for Claude Desktop and Cursor; VS Code uses a different format. Set GITLAB_TOKEN to your token and GITLAB_API_URL to your GitLab instance URL." - name: "Start Using GitLab MCP" text: "The server starts automatically when your MCP client connects. All default tools are enabled. You can now ask your AI agent to interact with GitLab." --- @@ -38,7 +38,12 @@ See the [step-by-step authentication guide](/guide/authentication) for detailed ## 2. Configure Your MCP Client -Add to your MCP client configuration: +The example below is for Claude Desktop and Cursor. VS Code workspace configuration +uses `servers` instead of `mcpServers`; follow the [VS Code guide](/clients/vscode). +For Codex, use the [plugin or manual setup guide](/guide/installation/codex). +Other clients have [their own configuration formats](/clients/). + +Add the following to a compatible client's configuration: ```json { diff --git a/packages/gitlab-mcp/docs/guides/codex-qualification.md b/packages/gitlab-mcp/docs/guides/codex-qualification.md new file mode 100644 index 00000000..ce12407d --- /dev/null +++ b/packages/gitlab-mcp/docs/guides/codex-qualification.md @@ -0,0 +1,140 @@ +--- +title: Codex Release Qualification +description: Verify a released GitLab MCP plugin in Codex desktop and Claude Code without confusing protocol checks with desktop acceptance. +--- + +# Verify a released Codex plugin + +Use the [installation guide](/guide/installation/codex) for setup. This procedure +checks the installed release, both connection modes and the boundaries between +clients. A passing command-line check does not establish that desktop installation, +browser sign-in or native settings work. + +## Prerequisites and evidence + +Use the ZIP and checksum from the same GitHub release. Record the release tag, +ZIP SHA-256, operating system, Node version, desktop app version and Codex CLI +version. The configurable plugin requires Node.js 24 or later, including when +connecting to an existing HTTP server. The release ZIP includes its production +dependencies; the repository marketplace downloads the pinned npm runtime instead. + +Use only a designated test project. Keep tokens in credential fields and test +configuration, never in prompts or screenshots. Remove private URLs, account names +and project data from screenshots before sharing them. Do not execute Duo flows +or consume credits as part of qualification. + +For each check record `verified`, `failed`, or `not run or blocked`, with the +observed result. Keep desktop evidence separate from automated evidence. Record +the backend release as well as the plugin release when testing a remote server. + +### Recorded automated check + +On 2026-11-01, release `gitlab-mcp-v13.1.0` passed the package and native CLI +lifecycle checks below on macOS 27.0.1 with Node.js 26.8.1 and Codex CLI +`0.166.0-alpha.5.1`. Its ZIP SHA-256 was +`fcbd89cdfc70bf073981c0fbbda431f8508539cc896a2e769d1d306cc1e5692d`. + +All 28 connection/authentication contracts passed. The separate live read-only +check returned the same account through local stdio and loopback HTTP. +Desktop UI acceptance and an independent production HTTP deployment were not +established by these checks. Repeat qualification for the next published artifact; +these results do not qualify the unreleased 13.2.0 package. + +## Automated package and lifecycle checks + +This qualification command requires Node.js 24+, workspace dependencies, a Codex +CLI executable and `unzip` available on `PATH`. The `unzip` requirement also applies +when running qualification on Windows; it is separate from extracting the release +ZIP through a desktop file manager. + +Install [Corepack](https://yarnpkg.com/corepack), run `corepack enable`, and run +`yarn install --immutable` at the repository root first. This activates the Yarn +version declared by the repository. + +From `packages/gitlab-mcp`: + +```bash +yarn build +yarn qualify:codex-plugin --archive /path/to/gitlab-mcp-codex.zip --codex /path/to/codex +``` + +Place `gitlab-mcp-codex.zip.sha256` beside the ZIP. This verifies the checksum, +extracts the actual artifact, runs its connection/authentication contracts, and +uses the real Codex CLI in a temporary home for install, disable, enable, upgrade, +reinstall and uninstall. Unrelated Codex settings and seeded Claude configuration +must remain unchanged. The simulated upgrade stays inside that temporary home; +it does not publish a release or modify the input ZIP. + +Optional read-only provider qualification uses the existing protected test configuration: + +```bash +node --env-file=.env.test scripts/qualify-codex-plugin.mjs --archive /path/to/gitlab-mcp-codex.zip --live +``` + +It reads the GitLab profile through the installed runtime in local mode and through +a temporary loopback HTTP core, checks that the account agrees, then disconnects. +It does not verify an operator's production OAuth deployment or a desktop UI. + +CI already checks the built archive, connection contracts, installed CLI lifecycle +and generated repository marketplace. Authenticated skill evaluations are separate: +see [workflow qualification](/guides/codex-workflows#verify-the-installed-workflows). + +## Desktop acceptance + +Follow the installation guide with a released archive. Use the app's Plugins +Directory to confirm installation, not a manually added MCP server alone. + +1. **Installation:** confirm the plugin's name and release version. Check that no + old standalone registration hides its server or duplicates tools. Open a fresh + chat with the installed plugin enabled. +2. **Unconfigured startup:** open plugin settings and choose **Set up, change or + reconnect GitLab**. The connection panel must open before a backend is configured. +3. **Local mode:** choose **GitLab instance, run MCP locally**, enter the test GitLab + address and token in the panel, and connect. Verify the displayed account and + instance. Ask to identify the account, find the test project and read its README. +4. **Remote mode:** choose **Existing GitLab MCP server** and enter the test endpoint. + For OAuth, continue browser sign-in, authorize, then refresh status. Verify the + account and repeat the project read. Record denied sign-in separately: it must + not replace a previously saved connection or expose provider response bodies. +5. **Backend settings:** inspect account defaults, choose a project scope for this + chat, and enable read-only mode. Read the chosen project. A requested mutation + must be refused before it changes GitLab. Reset the chat and verify that account + defaults are restored. Use only disposable test data for authorized write checks. +6. **Normal work:** inspect a test MR and its discussions, read a work item and + inspect a failed pipeline with bounded job logs. Check that the selected project + and account stay correct throughout. Do not repeat an uncertain write. +7. **Recovery:** cancel an unfinished replacement, reconnect the saved connection, + restart the app and read the project again. Test invalid credentials and + insufficient scopes with dedicated test credentials. Use the + [connection diagnostics](/guides/codex-workflows#structured-diagnostics-and-failures) + and restore the working connection through the panel. +8. **Two clients:** keep Codex and Claude Code connected to the same backend. + Select a different chat scope or linked account in one session and verify that + the other keeps its own selection. Verify standalone Claude stdio separately. + Account defaults deliberately apply across clients using that account unless a + chat overrides them; connection-panel selection is shared by this user's local + plugin processes. These shared settings are not isolation failures. +9. **Lifecycle:** install the next released version and reconnect. Check identity + and settings again. Disable the plugin, then enable it and repeat a read. Before + uninstalling, disconnect if saved credentials should be erased. Remove the + plugin and confirm that its tools disappear while Claude configuration remains. + +Capture the plugin/version screen, connected panel, backend settings and one +successful test-project read. Do not manufacture desktop screenshots from a browser +preview or treat the CLI lifecycle as desktop acceptance. + +## Surface and runtime boundaries + +| Surface | What to verify | Boundary | +| --- | --- | --- | +| Codex desktop, configurable plugin | Both connection modes, panel, settings, real reads, lifecycle | Requires Node.js 24+ and local access to the selected backend | +| Codex CLI, installed plugin | Artifact installation, MCP discovery and lifecycle | Does not prove desktop panel rendering or browser sign-in | +| Codex standalone MCP | Registration and normal GitLab tools | Does not install the configurable plugin | +| Claude Code | Existing stdio/HTTP setup and normal workflows | Claude Channels remain specific to Claude Code | +| Claude Desktop MCPB | Install its separate release bundle and exercise MCP | Codex ZIP and MCPB are different artifacts | +| Codex cloud or ChatGPT web | Qualify independently if advertised | A local stdio plugin is desktop-only | + +Run deterministic protocol tests for GitLab 16.0 separately from a current real +test instance. For replicated remote deployments, verify session storage and +account isolation on the shared backend; a single local process is not an HA test. +No platform or surface is verified merely because another row passed. diff --git a/packages/gitlab-mcp/docs/guides/codex-workflows.md b/packages/gitlab-mcp/docs/guides/codex-workflows.md index 153f6e4f..30fa17da 100644 --- a/packages/gitlab-mcp/docs/guides/codex-workflows.md +++ b/packages/gitlab-mcp/docs/guides/codex-workflows.md @@ -5,7 +5,7 @@ description: Portable GitLab workflows, independent client schemas, structured d # Codex and Claude workflow skills -The npm package includes five portable skills under `skills/`. They use the existing +The npm package includes portable skills under `skills/`. They use the existing MCP tools and work with a configured Codex or Claude Code connection: | Skill | Workflow | @@ -118,7 +118,8 @@ loads its skills into a temporary workspace/session-only plugin, and runs the actual built server against a deterministic loopback GitLab fixture. It does not use live GitLab credentials or modify global client configuration. -The matrix covers all five direct workflows, indirect discovery/review, a retained +The evaluation matrix covers setup, discovery, review, work items and CI, indirect +discovery/review, a retained conversation follow-up, an unrelated request and a denied mutation. Assertions inspect actual tool calls, prohibit unintended writes/replays, and retain client versions, transcripts and fixture requests in the printed temporary directory. diff --git a/packages/gitlab-mcp/docs/guides/duo-sessions.md b/packages/gitlab-mcp/docs/guides/duo-sessions.md index bcf343bb..72d4b73f 100644 --- a/packages/gitlab-mcp/docs/guides/duo-sessions.md +++ b/packages/gitlab-mcp/docs/guides/duo-sessions.md @@ -5,6 +5,9 @@ description: Discover and inspect asynchronous GitLab Duo sessions, and explicit # GitLab Duo sessions +Available from GitLab MCP **13.2.0**. Use that release or later for these tools; +the earlier 13.1.0 plugin archive does not include them. + `browse_duo_sessions` discovers agents and flows, lists sessions and reads their progress. `manage_duo_session` starts a CI session or answers a checkpoint. GitLab owns execution, authorization, history and billing. The MCP server adds diff --git a/packages/gitlab-mcp/docs/guides/index.md b/packages/gitlab-mcp/docs/guides/index.md index c6ed812c..2534c6ee 100644 --- a/packages/gitlab-mcp/docs/guides/index.md +++ b/packages/gitlab-mcp/docs/guides/index.md @@ -16,6 +16,7 @@ Comprehensive walkthroughs for common GitLab MCP workflows. | Guide | Description | Key Tools | |-------|-------------|-----------| | [Codex and Claude Workflow Skills](/guides/codex-workflows) | Portable workflows and shared MCP contracts | manage_context, browse_projects, browse_merge_requests, browse_work_items, browse_pipelines | +| [Codex Release Qualification](/guides/codex-qualification) | Released artifact checks and desktop acceptance | Installed plugin, connection panel, account settings | | [Complete Code Review](/guides/complete-code-review) | End-to-end MR review process | browse_merge_requests, manage_mr_discussion | | [Setup CI Notifications](/guides/setup-ci-notifications) | Pipeline alerts via Slack/Discord/webhooks | manage_webhook, manage_integration | | [Automate Releases](/guides/automate-releases) | Release workflow with changelogs | manage_release, browse_commits | diff --git a/packages/gitlab-mcp/docs/index.md b/packages/gitlab-mcp/docs/index.md index 8a3c6bb1..fce8be74 100644 --- a/packages/gitlab-mcp/docs/index.md +++ b/packages/gitlab-mcp/docs/index.md @@ -44,7 +44,7 @@ features: ## One-Click Install -Install in Claude Desktop +Install in Claude Desktop [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-007ACC?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_MCP_Server-24bfa5?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode-insiders:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) diff --git a/packages/gitlab-mcp/docs/security/oauth.md b/packages/gitlab-mcp/docs/security/oauth.md index 2d15a43c..b998db35 100644 --- a/packages/gitlab-mcp/docs/security/oauth.md +++ b/packages/gitlab-mcp/docs/security/oauth.md @@ -99,53 +99,26 @@ Use a reverse proxy (nginx, Caddy, Traefik) to add HTTPS. See [TLS/HTTPS Configu ## Claude Web Setup 1. Go to [claude.ai](https://claude.ai) and sign in -2. Navigate to **Settings > Connectors** +2. Navigate to **Customize > Connectors** 3. Click **Add custom connector** -4. Enter your gitlab-mcp server URL: `https://your-mcp-server.com` +4. Enter your MCP endpoint: `https://mcp.example.com/mcp` 5. Click **Add** 6. When prompted, complete authentication: - - You'll see a device code (e.g., `ABCD-1234`) - - Open your GitLab instance and enter the code - - Approve the authorization request + - Follow the browser redirect to your GitLab instance + - Sign in and approve the authorization request + - Return to Claude when authorization completes 7. The connector is now active ## Claude Desktop Setup -### macOS / Linux +For a remote OAuth server, add a custom connector using the same connector UI as +Claude Web. These connections originate from Anthropic's cloud, including when +configured in Claude Desktop, so the MCP endpoint must be publicly reachable. +See [Anthropic's remote connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). -Edit `~/.config/claude/claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "gitlab": { - "type": "streamable-http", - "url": "https://your-mcp-server.com/mcp" - } - } -} -``` - -### Windows - -Edit `%APPDATA%\Claude\claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "gitlab": { - "type": "streamable-http", - "url": "https://your-mcp-server.com/mcp" - } - } -} -``` - -After adding the server: -1. Restart Claude Desktop -2. Claude will prompt you to authenticate -3. Complete the device flow authorization in GitLab -4. Start using GitLab tools with your personal identity +For local access, use a desktop extension or stdio configuration instead. +See [Claude Desktop integration](/clients/claude-desktop). An HTTP `url` entry is +not a local stdio configuration in `claude_desktop_config.json`. ## Private LAN GitLab Architecture diff --git a/packages/gitlab-mcp/docs/tools/code-review.md b/packages/gitlab-mcp/docs/tools/code-review.md index 7768b660..a1e4b1c2 100644 --- a/packages/gitlab-mcp/docs/tools/code-review.md +++ b/packages/gitlab-mcp/docs/tools/code-review.md @@ -32,6 +32,7 @@ Find and inspect merge requests. |--------|-------------| | `list` | List merge requests with filtering | | `get` | Get single MR by IID or branch name | +| `closing_issues` | Read one page of visible issues GitLab would close on merge; does not close issues | | `diffs` | Get file changes/diffs for an MR | | `compare` | Compare two branches or commits | | `versions` | List all diff versions of an MR (each push creates a version) | diff --git a/packages/gitlab-mcp/docs/tools/repository.md b/packages/gitlab-mcp/docs/tools/repository.md index fd9a4a02..ae47e824 100644 --- a/packages/gitlab-mcp/docs/tools/repository.md +++ b/packages/gitlab-mcp/docs/tools/repository.md @@ -230,8 +230,8 @@ always returns base64. | `update_branch_protection` | Update protection rules for a branch | | `create_tag` | Create a new tag in the repository | | `delete_tag` | Delete a tag from the repository | -| `protect_tag` | Add protection rules to a tag pattern (Premium) | -| `unprotect_tag` | Remove protection from a tag pattern (Premium) | +| `protect_tag` | Add protection rules to a tag pattern (all tiers) | +| `unprotect_tag` | Remove protection from a tag pattern (all tiers) | ::: code-group diff --git a/packages/gitlab-mcp/src/cli/install/types.ts b/packages/gitlab-mcp/src/cli/install/types.ts index 4b45c6c4..2c40ec1f 100644 --- a/packages/gitlab-mcp/src/cli/install/types.ts +++ b/packages/gitlab-mcp/src/cli/install/types.ts @@ -121,6 +121,21 @@ export interface ClientMetadata { /** * Client metadata registry */ +const ROO_SETTINGS_SUFFIX = + 'Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json'; + +function rooConfigPath(platform: 'darwin' | 'win32' | 'linux'): string { + // Match VS Code's application-data precedence for the default profile. + // https://github.com/microsoft/vscode/blob/main/src/vs/platform/environment/node/userDataPath.ts + const appData = process.env.VSCODE_APPDATA; + if (appData) return `${appData}/${ROO_SETTINGS_SUFFIX}`; + if (platform === 'darwin') return `~/Library/Application Support/${ROO_SETTINGS_SUFFIX}`; + if (platform === 'win32') return `%APPDATA%/${ROO_SETTINGS_SUFFIX}`; + const configHome = process.env.XDG_CONFIG_HOME; + if (configHome) return `${configHome}/${ROO_SETTINGS_SUFFIX}`; + return `~/.config/${ROO_SETTINGS_SUFFIX}`; +} + export const CLIENT_METADATA: Record = { 'claude-desktop': { name: 'Claude Desktop', @@ -190,9 +205,15 @@ export const CLIENT_METADATA: Record = { 'roo-code': { name: 'Roo Code', configPaths: { - darwin: '~/.roo/mcp.json', - win32: '%USERPROFILE%/.roo/mcp.json', - linux: '~/.roo/mcp.json', + get darwin(): string { + return rooConfigPath('darwin'); + }, + get win32(): string { + return rooConfigPath('win32'); + }, + get linux(): string { + return rooConfigPath('linux'); + }, }, supportsCliInstall: false, detectionMethod: 'config-file', diff --git a/packages/gitlab-mcp/src/configuration/namespace-capabilities.ts b/packages/gitlab-mcp/src/configuration/namespace-capabilities.ts new file mode 100644 index 00000000..e0535c1f --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/namespace-capabilities.ts @@ -0,0 +1,110 @@ +import { + extractGroupsFromArgs, + extractProjectsFromArgs, + numericTargets, +} from '../profiles/scope-enforcer'; +import type { ScopeConfig } from '../profiles/types'; +import type { ToolRequirements } from '../types'; +import { getNamespaceTier } from '../services/NamespaceTierDetector'; +import { TIER_ORDER } from '../services/InstanceCapabilities'; +import { + hasNamespaceSubscriptions, + runWithNamespaceTier, +} from '../services/namespace-tier-context'; +import { canonicalProjectPath } from '../utils/api-path'; +import { mapBounded } from '../utils/bounded-map'; +import type { GitLabTier } from '../services/GitLabVersionDetector'; + +export interface NamespaceTarget { + path: string; + kind: 'project' | 'group' | 'either'; +} + +/** Stop optional lookups once one target makes a complete tier decision impossible. */ +class UnknownSubscription extends Error {} + +export function namespaceTargetsForScope(scope: ScopeConfig | undefined): NamespaceTarget[] { + if (!scope) return []; + const targets: NamespaceTarget[] = []; + for (const path of [scope.project, ...(scope.projects ?? [])]) + if (path !== undefined) targets.push({ path, kind: 'project' }); + for (const path of [scope.group, scope.namespace, ...(scope.groups ?? [])]) + if (path !== undefined) targets.push({ path, kind: 'group' }); + return targets; +} + +/** Numeric namespace arguments retain the handler's group-first ambiguity. */ +export function namespaceTargetsForArgs(args: Record): NamespaceTarget[] { + const targets: NamespaceTarget[] = numericTargets(args).map(({ id, kind }) => ({ + path: id, + kind, + })); + for (const path of extractProjectsFromArgs(args)) + if (!/^\d+$/.test(path)) targets.push({ path, kind: 'project' }); + for (const path of extractGroupsFromArgs(args)) + if (!/^\d+$/.test(path)) targets.push({ path, kind: 'group' }); + return targets; +} + +export function needsNamespaceTier(requirements: ToolRequirements | undefined): boolean { + if (!requirements) return false; + return [ + requirements.default, + ...Object.values(requirements.actions ?? {}), + ...Object.values(requirements.parameters ?? {}), + ].some((req) => req.tier !== undefined && req.tier !== 'free'); +} + +function distinctRoots(targets: readonly NamespaceTarget[]): NamespaceTarget[] | null { + const unique = new Map(); + for (const target of targets) { + // An opaque GID is resolved by its owning handler, not guessed to be a namespace. + if (target.path.startsWith('gid://')) return null; + let path: string; + try { + path = canonicalProjectPath(target.path); + } catch { + return null; + } + const slash = path.indexOf('/'); + let key: string; + if (/^\d+$/.test(path)) key = `${target.kind}:${path}`; + else if (slash < 0) key = path; + else key = path.slice(0, slash); + unique.set(key, target); + } + return [...unique.values()]; +} + +function selectedTier(tiers: readonly GitLabTier[], union: boolean): GitLabTier | null { + let tier: GitLabTier | null = null; + for (const observation of tiers) { + if (!union && tier !== null && tier !== observation) return null; + if (tier === null || TIER_ORDER[observation] > TIER_ORDER[tier]) tier = observation; + } + return tier; +} + +/** Catalogs expose the union; a call spanning different subscriptions defers to GitLab. */ +export async function withNamespaceCapabilities( + instanceUrl: string, + targets: readonly NamespaceTarget[], + union: boolean, + fn: () => T, +): Promise> { + if (!hasNamespaceSubscriptions(instanceUrl)) return await fn(); + const unique = distinctRoots(targets); + if (unique === null) return await runWithNamespaceTier(instanceUrl, null, fn); + let observations; + try { + observations = await mapBounded(unique, 5, async (target) => { + const info = await getNamespaceTier(target.path, target.kind); + if (info.tier === null) throw new UnknownSubscription(); + return info.tier; + }); + } catch (error) { + if (!(error instanceof UnknownSubscription)) throw error; + return await runWithNamespaceTier(instanceUrl, null, fn); + } + return await runWithNamespaceTier(instanceUrl, selectedTier(observations, union), fn); +} diff --git a/packages/gitlab-mcp/src/configuration/scoped-execution.ts b/packages/gitlab-mcp/src/configuration/scoped-execution.ts index 7e9ec979..ad72f3ec 100644 --- a/packages/gitlab-mcp/src/configuration/scoped-execution.ts +++ b/packages/gitlab-mcp/src/configuration/scoped-execution.ts @@ -39,7 +39,16 @@ import { BrowseProjectsSchema, BrowseTodosSchema, } from '../entities/core/schema-readonly'; -import { ManageProjectSchema, ManageTodosSchema } from '../entities/core/schema'; +import { + ManageProjectSchema, + ManageNamespaceSchema, + ManageTodosSchema, +} from '../entities/core/schema'; +import { BrowseIterationsSchema } from '../entities/iterations/schema-readonly'; +import { BrowseMilestonesSchema } from '../entities/milestones/schema-readonly'; +import { ManageMilestoneSchema } from '../entities/milestones/schema'; +import { BrowseRefsSchema } from '../entities/refs/schema-readonly'; +import { ManageRefSchema } from '../entities/refs/schema'; import { BrowseSearchSchema } from '../entities/search/schema-readonly'; import { BrowseMergeRequestsSchema } from '../entities/mrs/schema-readonly'; import { @@ -103,6 +112,12 @@ const PROJECT_LIST_OPTIONS = new Set([ ]); /** The input schemas of the tools whose calls the scope rewrites, filters or emulates. */ const SCOPED_TOOL_SCHEMAS: Readonly> = { + browse_iterations: BrowseIterationsSchema, + browse_milestones: BrowseMilestonesSchema, + manage_milestone: ManageMilestoneSchema, + browse_refs: BrowseRefsSchema, + manage_ref: ManageRefSchema, + manage_namespace: ManageNamespaceSchema, browse_work_items: BrowseWorkItemsSchema, manage_work_item: ManageWorkItemSchema, browse_registry: BrowseRegistrySchema, diff --git a/packages/gitlab-mcp/src/entities/context/whoami.ts b/packages/gitlab-mcp/src/entities/context/whoami.ts index 8bdbd859..8f446d1b 100644 --- a/packages/gitlab-mcp/src/entities/context/whoami.ts +++ b/packages/gitlab-mcp/src/entities/context/whoami.ts @@ -252,7 +252,7 @@ function buildServerInfo(): WhoamiServerInfo { const connectionManager = ConnectionManager.getInstance(); const instanceInfo = connectionManager.getInstanceInfo(getGitLabApiUrlFromContext()); version = instanceInfo.version; - tier = instanceInfo.tier; + if (instanceInfo.tier !== null) tier = instanceInfo.tier; } catch { // Connection not initialized - use defaults } diff --git a/packages/gitlab-mcp/src/entities/instance-version.ts b/packages/gitlab-mcp/src/entities/instance-version.ts index 69e38767..36f60b8b 100644 --- a/packages/gitlab-mcp/src/entities/instance-version.ts +++ b/packages/gitlab-mcp/src/entities/instance-version.ts @@ -15,7 +15,7 @@ export function instanceAtLeast(minVersion: string): boolean { } /** Detected version/tier of the instance serving the current request, if known. */ -export function currentInstance(): { version: string; tier: GitLabTier } | undefined { +export function currentInstance(): { version: string; tier: GitLabTier | null } | undefined { try { return ConnectionManager.getInstance().getInstanceInfo(getGitLabApiUrlFromContext()); } catch { diff --git a/packages/gitlab-mcp/src/entities/workitems/registry.ts b/packages/gitlab-mcp/src/entities/workitems/registry.ts index bfba9eb6..b00e8bea 100644 --- a/packages/gitlab-mcp/src/entities/workitems/registry.ts +++ b/packages/gitlab-mcp/src/entities/workitems/registry.ts @@ -590,7 +590,9 @@ export const workitemsToolRegistry: ToolRegistry = new Map }, >( - tools: T[], + tools: () => T[], caller: Caller, registryManager: import('./registry-manager').RegistryManager, ): Promise { @@ -672,10 +680,26 @@ async function withoutRestrictedTools< policy = (await getConfigurationService().resolve(caller)).policy; } catch (error: unknown) { logWarn('Could not read the caller settings for the tool list', { err: error as Error }); - return tools; + return tools(); } + const catalog = await runWithCaller(caller, async () => { + let targets = namespaceTargetsForScope(policy.scope); + if (hasNamespaceSubscriptions(caller.instanceUrl) && GITLAB_ALLOWED_PROJECT_IDS.length > 0) { + targets = []; + if (canResolveNamespaceTier(caller.instanceUrl)) { + try { + const operatorPolicy = await withOperatorProjects(policy); + targets = namespaceTargetsForScope(operatorPolicy.scope); + } catch { + // Billing discovery is optional; execution still verifies operator restrictions. + logWarn('Could not resolve operator projects for namespace discovery'); + } + } + } + return withNamespaceCapabilities(caller.instanceUrl, targets, true, tools); + }); const listed: T[] = []; - for (const tool of tools) { + for (const tool of catalog) { const inputSchema = listedToolSchema( policy, registryManager.getToolFacts(tool.name), @@ -783,7 +807,7 @@ async function executeInScope( toolName: string, requested: Record, policy: EffectivePolicy, - instanceUrl: string | undefined, + instanceUrl: string, requestScopes: readonly string[] | undefined, expired: () => boolean, ): Promise { @@ -792,11 +816,23 @@ async function executeInScope( // executeScoped wraps this runner so every call it makes in place of the requested one is // held to the preset's denied actions; the operator's are checked by each tool itself. const run = (tool: string, toolArgs: Record): Promise => { - if (tool === 'browse_duo_sessions' || tool === 'manage_duo_session') - return registryManager.executeTool(tool, toolArgs, instanceUrl, requestScopes, policy); - if (requestScopes === undefined) - return registryManager.executeTool(tool, toolArgs, instanceUrl); - return registryManager.executeTool(tool, toolArgs, instanceUrl, requestScopes); + const dispatch = (validated: Record): Promise => { + if (expired()) return Promise.reject(new Error('Tool call expired before execution')); + if (tool === 'browse_duo_sessions' || tool === 'manage_duo_session') + return registryManager.executeTool(tool, validated, instanceUrl, requestScopes, policy); + if (requestScopes === undefined) + return registryManager.executeTool(tool, validated, instanceUrl); + return registryManager.executeTool(tool, validated, instanceUrl, requestScopes); + }; + const definition = registryManager.getTool(tool, instanceUrl, requestScopes); + const action = toolArgs.action; + if (typeof action === 'string' && definition?.unavailableActions?.has(action.toLowerCase())) + return dispatch(toolArgs); + if (!needsNamespaceTier(definition?.requirements)) return dispatch(toolArgs); + const validated = parseScopedArguments(tool, toolArgs); + return withNamespaceCapabilities(instanceUrl, namespaceTargetsForArgs(validated), false, () => + dispatch(validated), + ); }; return executeScoped(run, toolName, requested, policy, true); } @@ -1022,7 +1058,7 @@ export async function setupHandlers(server: Server): Promise { // OAuth deployments declare the token every tool needs; static-token mode keeps // the descriptors as before. const securitySchemes = isCatalogOAuth() ? OAUTH_SECURITY_SCHEMES : undefined; - const catalog = + const catalog = () => scopes === undefined ? registryManager.getAllToolDefinitions(sessionInstanceUrl, mode) : registryManager.getAllToolDefinitions(sessionInstanceUrl, mode, scopes); diff --git a/packages/gitlab-mcp/src/oauth/types.ts b/packages/gitlab-mcp/src/oauth/types.ts index f797e1f2..2a7d5c26 100644 --- a/packages/gitlab-mcp/src/oauth/types.ts +++ b/packages/gitlab-mcp/src/oauth/types.ts @@ -11,10 +11,8 @@ * Free group and Ultimate group simultaneously. */ export interface NamespaceTierInfo { - /** Tier level (free, premium, ultimate) */ - tier: 'free' | 'premium' | 'ultimate'; - /** Available features for this tier */ - features: Record; + /** Confirmed subscription tier, or null when billing information is undisclosed. */ + tier: 'free' | 'premium' | 'ultimate' | null; /** Cache timestamp */ cachedAt: Date; } diff --git a/packages/gitlab-mcp/src/registry-manager.ts b/packages/gitlab-mcp/src/registry-manager.ts index faf29880..6f05db45 100644 --- a/packages/gitlab-mcp/src/registry-manager.ts +++ b/packages/gitlab-mcp/src/registry-manager.ts @@ -99,7 +99,13 @@ import { isToolAvailable, getRestrictedParameters, getUnavailableActions, + type CapabilityGate, } from './services/InstanceCapabilities'; +import { + currentNamespaceTier, + hasNamespaceSubscriptions, + runWithNamespaceTier, +} from './services/namespace-tier-context'; import { ConnectionManager } from './services/ConnectionManager'; import { duoToolRegistry, getDuoReadOnlyToolNames } from './entities/duo/registry'; import type { EffectivePolicy, ToolFacts } from './configuration/policy'; @@ -465,10 +471,10 @@ class RegistryManager { * Returns undefined fields when connection is not initialized. * @throws on unexpected errors (anything other than expected init errors). */ private loadInstanceContext(instanceUrl?: string): { - instanceInfo?: { tier: GitLabTier; version: string; adminModeActive?: boolean }; + instanceInfo?: CapabilityGate; tokenScopes?: readonly string[]; } { - let instanceInfo: { tier: GitLabTier; version: string; adminModeActive?: boolean } | undefined; + let instanceInfo: CapabilityGate | undefined; try { const info = ConnectionManager.getInstance().getInstanceInfo(instanceUrl); instanceInfo = { tier: info.tier, version: info.version }; @@ -513,7 +519,7 @@ class RegistryManager { toolName: string, tool: EnhancedToolDefinition, ctx: { - instanceInfo?: { tier: GitLabTier; version: string; adminModeActive?: boolean }; + instanceInfo?: CapabilityGate; tokenScopes?: readonly string[]; }, unavailableActions: ReadonlyMap, @@ -550,7 +556,7 @@ class RegistryManager { private unavailableActionsFor( toolName: string, tool: EnhancedToolDefinition, - ctx: { instanceInfo?: { tier: GitLabTier; version: string; adminModeActive?: boolean } }, + ctx: { instanceInfo?: CapabilityGate }, ): ReadonlyMap { if (!ctx.instanceInfo || this.registries.get('context')?.has(toolName)) return NONE_UNAVAILABLE; return getUnavailableActions(tool.requirements, ctx.instanceInfo); @@ -558,7 +564,7 @@ class RegistryManager { /** Filter registries and build transformed tool map (schema + description overrides). */ private buildFilteredTools(ctx: { - instanceInfo?: { tier: GitLabTier; version: string; adminModeActive?: boolean }; + instanceInfo?: CapabilityGate; tokenScopes?: readonly string[]; }): Map { const result = new Map(); @@ -659,22 +665,30 @@ class RegistryManager { */ private resolveCache(instanceUrl?: string): Map { const url = this.resolveCacheUrl(instanceUrl); - let cache = this.toolLookupCaches.get(url); + const key = this.capabilityCacheKey(url); + let cache = this.toolLookupCaches.get(key); if (!cache) { this.buildToolLookupCache(url); - cache = this.toolLookupCaches.get(url); + cache = this.toolLookupCaches.get(key); } return cache ?? new Map(); } + /** At most three known subscription views per instance, plus the unknown view. */ + private capabilityCacheKey(url: string): string { + const namespace = currentNamespaceTier(url); + return hasNamespaceSubscriptions(url) && namespace?.tier ? `${url}\0${namespace.tier}` : url; + } + private buildToolLookupCache(instanceUrl?: string): void { const url = this.resolveCacheUrl(instanceUrl); + const key = this.capabilityCacheKey(url); let ctx: ReturnType; try { ctx = this.loadInstanceContext(url); } catch (err) { - if (this.verifiedContextUrls.has(url)) { + if (this.verifiedContextUrls.has(key)) { // Fail-close during refresh: preserve the previous verified cache // instead of rebuilding with empty context (which would surface tools // the instance cannot use). Pre-init caches (built before @@ -691,10 +705,10 @@ class RegistryManager { // Do not leave a permissive pre-init snapshot live after an unexpected // refresh failure. Callers (e.g. handlers.ts) may catch refreshCache() // and continue serving whatever is still in the per-URL caches. - this.toolLookupCaches.delete(url); - this.toolDefinitionsCaches.delete(url); - this.toolNamesCaches.delete(url); - this.filterStatsCaches.delete(url); + this.toolLookupCaches.delete(key); + this.toolDefinitionsCaches.delete(key); + this.toolNamesCaches.delete(key); + this.filterStatsCaches.delete(key); // On cold start there is no prior cache to preserve. Fail fast so the // process does not silently continue with no tools available. @@ -715,22 +729,22 @@ class RegistryManager { // temporary empty Map returned by resolveCache(); drop them before swapping. // filterStatsCaches is cleared here (not in invalidateCaches) so the last // good snapshot survives if this rebuild was triggered by a failed refresh. - this.toolDefinitionsCaches.delete(url); - this.toolNamesCaches.delete(url); - this.filterStatsCaches.delete(url); + this.toolDefinitionsCaches.delete(key); + this.toolNamesCaches.delete(key); + this.filterStatsCaches.delete(key); // Atomic per-URL swap — in-flight requests that captured a reference // to the old map via getTool() keep working; new requests see the // updated cache for this URL - this.toolLookupCaches.set(url, newCache); + this.toolLookupCaches.set(key, newCache); // Track whether this cache was built with real context. Pre-init caches // (built before ConnectionManager is ready) must not be preserved on // failure. Updated AFTER swap so the flag describes the live cache. if (ctx.instanceInfo) { - this.verifiedContextUrls.add(url); + this.verifiedContextUrls.add(key); } else { - this.verifiedContextUrls.delete(url); + this.verifiedContextUrls.delete(key); } logDebug('Registry manager built cache after filtering', { @@ -744,14 +758,16 @@ class RegistryManager { */ private invalidateCaches(instanceUrl?: string): void { const url = this.resolveCacheUrl(instanceUrl); - this.toolDefinitionsCaches.delete(url); - this.toolNamesCaches.delete(url); - // filterStatsCaches is intentionally NOT cleared here — it is preserved - // until buildToolLookupCache succeeds, so getFilterStats can fall back to - // the last good snapshot if the rebuild fails mid-way. - // readOnlyToolsCache is derived from registries + config flags (USE_*), - // not from instance URL — no need to clear on per-URL refresh. + const currentKey = this.capabilityCacheKey(url); + const prefix = `${url}\0`; + // Each successful build replaces its view atomically. A failed refresh keeps + // its own verified snapshot, including descriptors and statistics. this.buildToolLookupCache(url); + for (const key of this.toolLookupCaches.keys()) { + if (key === currentKey || (key !== url && !key.startsWith(prefix))) continue; + const tier = key === url ? null : (key.slice(prefix.length) as GitLabTier); + runWithNamespaceTier(url, tier, () => this.buildToolLookupCache(url)); + } } /** @@ -825,7 +841,8 @@ class RegistryManager { const unreachableMode = this.isUnreachableFor(instanceUrl); // In unreachable mode, rebuild every call (transient state — don't cache // a context-only list that would persist after recovery) - const cachedDefs = this.toolDefinitionsCaches.get(url)?.get(mode); + const key = this.capabilityCacheKey(url); + const cachedDefs = this.toolDefinitionsCaches.get(key)?.get(mode); if (cachedDefs === undefined || unreachableMode) { const contextTools = unreachableMode ? this.registries.get('context') : null; @@ -840,9 +857,9 @@ class RegistryManager { return this.forScopes(defs, scopes); } const modes = - this.toolDefinitionsCaches.get(url) ?? new Map(); + this.toolDefinitionsCaches.get(key) ?? new Map(); modes.set(mode, defs); - this.toolDefinitionsCaches.set(url, modes); + this.toolDefinitionsCaches.set(key, modes); return this.forScopes(defs, scopes); } @@ -884,7 +901,8 @@ class RegistryManager { ): ToolDefinition[] { const url = this.resolveCacheUrl(instanceUrl); const cache = this.resolveCache(instanceUrl); - const cachedDefs = this.toolDefinitionsCaches.get(url)?.get(mode); + const key = this.capabilityCacheKey(url); + const cachedDefs = this.toolDefinitionsCaches.get(key)?.get(mode); // Return the cached full-list when available (same object already stored by // getAllToolDefinitions on healthy path). If the cache is absent (e.g. first // call, or was cleared after an invalidation) build it now without the @@ -897,9 +915,9 @@ class RegistryManager { defs.push(this.toToolDefinition(tool, mode)); } const modes = - this.toolDefinitionsCaches.get(url) ?? new Map(); + this.toolDefinitionsCaches.get(key) ?? new Map(); modes.set(mode, defs); - this.toolDefinitionsCaches.set(url, modes); + this.toolDefinitionsCaches.set(key, modes); return defs; } @@ -1151,7 +1169,8 @@ class RegistryManager { const url = this.resolveCacheUrl(instanceUrl); const cache = this.resolveCache(instanceUrl); const unreachableMode = this.isUnreachableFor(instanceUrl); - const cachedNames = this.toolNamesCaches.get(url); + const key = this.capabilityCacheKey(url); + const cachedNames = this.toolNamesCaches.get(key); if (cachedNames === undefined || unreachableMode) { const contextTools = unreachableMode ? this.registries.get('context') : null; const names = Array.from(cache.keys()).filter( @@ -1160,7 +1179,7 @@ class RegistryManager { if (unreachableMode) { return names; } - this.toolNamesCaches.set(url, names); + this.toolNamesCaches.set(key, names); return names; } return cachedNames; @@ -1260,6 +1279,7 @@ class RegistryManager { // raw instanceUrl for reachability (preserves undefined → global fallback), // resolved url for instance context loading (avoids currentInstanceUrl leakage). const url = this.resolveCacheUrl(instanceUrl); + const key = this.capabilityCacheKey(url); const unreachableMode = this.isUnreachableFor(instanceUrl); const contextTools = unreachableMode ? this.registries.get('context') : null; @@ -1306,7 +1326,7 @@ class RegistryManager { // reflecting the real total so whoami doesn't show totalToolCount: 0. const filteredTotal = totalTools; return ( - (accountScopes === undefined ? this.filterStatsCaches.get(url) : undefined) ?? { + (accountScopes === undefined ? this.filterStatsCaches.get(key) : undefined) ?? { available: 0, total: filteredTotal, filteredByScopes: filteredTotal, @@ -1341,7 +1361,7 @@ class RegistryManager { filteredByActionDenial, filteredByAdmin, }; - if (accountScopes === undefined) this.filterStatsCaches.set(url, stats); + if (accountScopes === undefined) this.filterStatsCaches.set(key, stats); return stats; } diff --git a/packages/gitlab-mcp/src/services/ConnectionManager.ts b/packages/gitlab-mcp/src/services/ConnectionManager.ts index 6d13e099..2148bf19 100644 --- a/packages/gitlab-mcp/src/services/ConnectionManager.ts +++ b/packages/gitlab-mcp/src/services/ConnectionManager.ts @@ -1,5 +1,11 @@ import { GraphQLClient } from '../graphql/client'; -import { GitLabVersionDetector, GitLabInstanceInfo } from './GitLabVersionDetector'; +import { + GitLabVersionDetector, + GitLabInstanceInfo, + type GitLabTier, + type GitLabFeatures, +} from './GitLabVersionDetector'; +import { currentNamespaceTier, hasNamespaceSubscriptions } from './namespace-tier-context'; import { SchemaIntrospector, SchemaInfo, type SchemaFieldIndex } from './SchemaIntrospector'; import { detectTokenScopes, @@ -32,6 +38,12 @@ interface CacheEntry { timestamp: number; } +/** Instance facts projected into the current namespace, without mutating shared detection. */ +export type RequestInstanceInfo = Omit & { + tier: GitLabTier | null; + features: Partial; +}; + /** * Per-URL instance state. Each GitLab URL gets isolated client, detectors, * and introspection data — no shared mutable fields to overwrite. @@ -711,13 +723,25 @@ export class ConnectionManager { return state.schemaIntrospector; } - public getInstanceInfo(instanceUrl?: string): GitLabInstanceInfo { + public getInstanceInfo(instanceUrl?: string): RequestInstanceInfo { const [state, resolvedUrl] = this.resolveState(instanceUrl); if (!state.instanceInfo) { throw new Error( `Instance information is not available for ${resolvedUrl}. Initialization may have completed without version detection (OAuth deferred/REST-only mode).`, ); } + if (hasNamespaceSubscriptions(resolvedUrl)) { + const namespace = currentNamespaceTier(resolvedUrl); + const tier = namespace === undefined ? null : namespace.tier; + return { + ...state.instanceInfo, + tier, + features: + tier === null || state.instanceInfo.version === 'unknown' + ? {} + : GitLabVersionDetector.determineFeatures(state.instanceInfo.version, tier), + }; + } return state.instanceInfo; } @@ -780,13 +804,14 @@ export class ConnectionManager { public isFeatureAvailable( feature: keyof GitLabInstanceInfo['features'], instanceUrl?: string, - ): boolean { + ): boolean | null { const url = instanceUrl ? normalizeInstanceUrl(instanceUrl) : this.callInstanceUrl(); if (!url) return false; const state = this.instances.get(url); if (!state?.instanceInfo) return false; this.touchInstance(url); // Keep entry alive in LRU window. - return state.instanceInfo.features[feature]; + const value = this.getInstanceInfo(url).features[feature]; + return value ?? null; } public getTier(instanceUrl?: string): string { @@ -795,7 +820,8 @@ export class ConnectionManager { const state = this.instances.get(url); if (!state?.instanceInfo) return 'unknown'; this.touchInstance(url); // Keep entry alive in LRU window. - return state.instanceInfo.tier; + const tier = this.getInstanceInfo(url).tier; + return tier ?? 'unknown'; } public getVersion(instanceUrl?: string): string { diff --git a/packages/gitlab-mcp/src/services/GitLabVersionDetector.ts b/packages/gitlab-mcp/src/services/GitLabVersionDetector.ts index c9580aff..4e0bf391 100644 --- a/packages/gitlab-mcp/src/services/GitLabVersionDetector.ts +++ b/packages/gitlab-mcp/src/services/GitLabVersionDetector.ts @@ -129,7 +129,7 @@ export class GitLabVersionDetector { const version = await this.detectVersion(); const tier = await this.detectTier(); - const features = this.determineFeatures(version, tier); + const features = GitLabVersionDetector.determineFeatures(version, tier); this.cachedInfo = { version, @@ -239,7 +239,7 @@ export class GitLabVersionDetector { return 'free'; } - private determineFeatures(version: string, tier: GitLabTier): GitLabFeatures { + public static determineFeatures(version: string, tier: GitLabTier): GitLabFeatures { const v = parseVersion(version); const features: GitLabFeatures = { diff --git a/packages/gitlab-mcp/src/services/InstanceCapabilities.ts b/packages/gitlab-mcp/src/services/InstanceCapabilities.ts index 2aca2fc5..90f5b649 100644 --- a/packages/gitlab-mcp/src/services/InstanceCapabilities.ts +++ b/packages/gitlab-mcp/src/services/InstanceCapabilities.ts @@ -15,10 +15,10 @@ import { parseVersion } from '../utils/version'; export interface InstanceCapabilities { /** Detected GitLab version (semver string), or 'unknown' when not yet probed. */ version: string; - /** Licensed tier. Free covers CE and EE-without-license. */ - tier: GitLabTier; - /** Per-feature availability map (epics, iterations, vulnerabilities, ...). */ - features: GitLabFeatures; + /** Observed subscription or license tier; null means unavailable billing metadata. */ + tier: GitLabTier | null; + /** Per-feature availability; an absent key is unknown, not a confirmed refusal. */ + features: Partial; /** Token scopes (api, read_api, ...). Empty when scope detection was skipped. */ scopes: GitLabScope[]; /** @@ -44,7 +44,11 @@ export interface InstanceCapabilities { export type CapabilityGate = Pick; /** Tier hierarchy for comparison: free < premium < ultimate. */ -const TIER_ORDER: Record = { free: 0, premium: 1, ultimate: 2 }; +export const TIER_ORDER: Readonly> = { + free: 0, + premium: 1, + ultimate: 2, +}; /** Default requirement applied when a tool/action omits an explicit tier. */ const DEFAULT_TIER = 'free' as const; @@ -94,7 +98,7 @@ export function meetsRequirement(req: ToolRequirement, caps: CapabilityGate): bo if (req.requiresAdmin && caps.adminModeActive === false) return false; if (caps.version === 'unknown') return true; if (parseVersion(caps.version) < parseVersion(effectiveMinVersion(req))) return false; - if (!isTierSufficient(caps.tier, req.tier)) return false; + if (caps.tier !== null && !isTierSufficient(caps.tier, req.tier)) return false; return true; } @@ -179,7 +183,7 @@ export function getUnmetReason( return `Requires GitLab ${minVersion}+, current version is ${caps.version}`; } if (!req) return null; - if (!isTierSufficient(caps.tier, req.tier)) { + if (caps.tier !== null && !isTierSufficient(caps.tier, req.tier)) { return `Requires GitLab ${req.tier ?? DEFAULT_TIER} tier or higher, current tier is ${caps.tier}`; } return null; diff --git a/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts b/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts index ea66ca2a..93c17635 100644 --- a/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts +++ b/packages/gitlab-mcp/src/services/NamespaceTierDetector.ts @@ -1,360 +1,210 @@ /** - * Namespace Tier Detector - * - * Detects GitLab tier (free/premium/ultimate) per namespace. - * CRITICAL: Tier is per-NAMESPACE, not per-instance! - * On gitlab.com, one user can access Free group and Ultimate group simultaneously. - * - * Features: - * - Per-session namespace tier caching (5 min TTL) - * - GraphQL query for namespace tier detection - * - Feature availability mapping per tier - */ - -import { logDebug, logWarn } from '../logger.js'; -import { NamespaceTierInfo } from '../oauth/types.js'; -import { getTokenContext } from '../oauth/token-context.js'; -import { enhancedFetch } from '../utils/fetch.js'; -import { GITLAB_BASE_URL } from '../config'; - -/** - * Cache TTL for namespace tier (5 minutes) - */ -const NAMESPACE_TIER_CACHE_TTL_MS = 5 * 60 * 1000; - -/** - * In-memory namespace tier cache: session id -> account key (instance, user and namespace - * path) -> tier. A session can work with several accounts (linked accounts keep the - * session id), and each sees its own tier of a path. + * Subscription observations scoped to a credential and root namespace. + * These disposable facts guide discovery, not GitLab authorization. */ +import { createHash } from 'node:crypto'; +import { z } from 'zod'; +import type { NamespaceTierInfo } from '../oauth/types'; +import type { GitLabTier } from './GitLabVersionDetector'; +import { getTokenContext } from '../oauth/token-context'; +import { enhancedFetch, getAuthHeaders } from '../utils/fetch'; +import { getGitLabBaseUrl } from '../utils/gitlab-base-url'; +import { normalizeInstanceUrl } from '../utils/url'; +import { readBoundedJson } from '../utils/bounded-json'; +import { canonicalProjectPath, encodeApiSegment } from '../utils/api-path'; +import { logWarn } from '../logger'; +import { isOAuthEnabled } from '../oauth/config'; + +const CACHE_TTL_MS = 5 * 60 * 1000; +const CACHE_CAPACITY = 1024; const namespaceTierCache = new Map>(); +const ProjectMetadataSchema = z.object({ path_with_namespace: z.string() }); +const GroupMetadataSchema = z.object({ full_path: z.string() }); +const NamespaceMetadataSchema = z.object({ plan: z.string().nullish() }); + +interface Identity { + baseUrl: string; + headers: Record; + credential: string; + sessionId: string; +} -/** - * GraphQL query to get namespace tier - */ -const NAMESPACE_TIER_QUERY = ` - query GetNamespaceTier($fullPath: ID!) { - namespace(fullPath: $fullPath) { - id - fullPath - - ... on Group { - plan - } - - ... on Project { - group { - plan - } - } - } - } -`; - -/** - * Feature availability per tier - */ -const TIER_FEATURES: Record> = { - free: { - issues: true, - mergeRequests: true, - wiki: true, - snippets: true, - epics: false, - iterations: false, - roadmaps: false, - okrs: false, - healthStatus: false, - weight: false, - multiLevelEpics: false, - portfolioManagement: false, - requirements: false, - securityDashboard: false, - complianceFramework: false, - }, - premium: { - issues: true, - mergeRequests: true, - wiki: true, - snippets: true, - epics: true, - iterations: true, - roadmaps: true, - okrs: false, - healthStatus: true, - weight: true, - multiLevelEpics: true, - portfolioManagement: true, - requirements: true, - securityDashboard: false, - complianceFramework: true, - }, - ultimate: { - issues: true, - mergeRequests: true, - wiki: true, - snippets: true, - epics: true, - iterations: true, - roadmaps: true, - okrs: true, - healthStatus: true, - weight: true, - multiLevelEpics: true, - portfolioManagement: true, - requirements: true, - securityDashboard: true, - complianceFramework: true, - }, -}; - -/** - * Normalize plan name to tier - */ -function normalizeTier(plan: string | null | undefined): 'free' | 'premium' | 'ultimate' { - if (!plan) return 'free'; - - const normalized = plan.toLowerCase(); - - if (normalized.includes('ultimate') || normalized.includes('gold')) { - return 'ultimate'; - } - if (normalized.includes('premium') || normalized.includes('silver')) { - return 'premium'; - } - if (normalized.includes('bronze') || normalized.includes('starter')) { - return 'premium'; // Bronze/Starter maps to Premium tier features - } - - return 'free'; +// Plan names: https://gitlab.com/gitlab-org/gitlab/-/blob/v16.0.0-ee/ee/app/models/ee/plan.rb +// Legacy plans without an equivalent in the supported three-tier model stay unknown. +const PLAN_TIERS = new Map([ + ['free', 'free'], + ['default', 'free'], + ['premium', 'premium'], + ['premium_trial', 'premium'], + ['silver', 'premium'], + ['ultimate', 'ultimate'], + ['ultimate_trial', 'ultimate'], + ['gold', 'ultimate'], + ['opensource', 'ultimate'], +]); + +function identity(): Identity | null { + const context = getTokenContext(); + if (isOAuthEnabled() && !context?.gitlabToken) return null; + if ( + context?.gitlabScopes && + !context.gitlabScopes.some((scope) => scope === 'api' || scope === 'read_api') + ) + return null; + const baseUrl = getGitLabBaseUrl(); + if (!baseUrl) return null; + const headers = getAuthHeaders(baseUrl); + if (Object.keys(headers).length === 0) return null; + const credential = createHash('sha256') + .update(context ? String(context.gitlabUserId) : '') + .update('\0') + .update(JSON.stringify(headers)) + .digest('hex'); + return { + baseUrl: normalizeInstanceUrl(baseUrl), + headers, + credential, + sessionId: context ? context.sessionId : credential, + }; } -/** - * Get features for a tier - */ -export function getFeaturesForTier(tier: 'free' | 'premium' | 'ultimate'): Record { - return { ...TIER_FEATURES[tier] }; +/** Optional billing lookups use exactly the transport's selected instance and credential. */ +export function canResolveNamespaceTier(instanceUrl: string): boolean { + const caller = identity(); + return caller !== null && caller.baseUrl === normalizeInstanceUrl(instanceUrl); } -/** Key of a namespace path as one account sees it; a newline occurs in none of the parts. */ -function accountKey(baseUrl: string, gitlabUserId: number, namespacePath: string): string { - return `${baseUrl}\n${gitlabUserId}\n${namespacePath}`; +function unknown(): NamespaceTierInfo { + return { tier: null, cachedAt: new Date() }; } -/** - * Get cached namespace tier info - */ -function getCachedTier( - sessionId: string, - key: string, - namespacePath: string, -): NamespaceTierInfo | null { +function cached(sessionId: string, key: string): NamespaceTierInfo | undefined { const entries = namespaceTierCache.get(sessionId); - const cached = entries?.get(key); - - if (!entries || !cached) return null; - - // Check TTL - const age = Date.now() - cached.cachedAt.getTime(); - if (age > NAMESPACE_TIER_CACHE_TTL_MS) { - entries.delete(key); - if (entries.size === 0) namespaceTierCache.delete(sessionId); - logDebug('Namespace tier cache expired', { - sessionId, - namespacePath, - ageMs: age, - }); - return null; + const value = entries?.get(key); + if (!value) return undefined; + if (Date.now() - value.cachedAt.getTime() > CACHE_TTL_MS) { + entries?.delete(key); + if (entries?.size === 0) namespaceTierCache.delete(sessionId); + return undefined; } - - return cached; + return value; } -/** - * Set cached namespace tier info - */ -function setCachedTier( - sessionId: string, - key: string, - namespacePath: string, - tierInfo: NamespaceTierInfo, -): void { +function remember(sessionId: string, key: string, value: NamespaceTierInfo): void { let entries = namespaceTierCache.get(sessionId); if (!entries) { entries = new Map(); namespaceTierCache.set(sessionId, entries); } - entries.set(key, tierInfo); - - logDebug('Namespace tier cached', { - sessionId, - namespacePath, - tier: tierInfo.tier, - }); + entries.set(key, value); + let count = 0; + for (const values of namespaceTierCache.values()) count += values.size; + if (count <= CACHE_CAPACITY) return; + const oldestSession = namespaceTierCache.keys().next().value; + if (oldestSession === undefined) return; + const oldest = namespaceTierCache.get(oldestSession); + if (!oldest) return; + const oldestKey = oldest.keys().next().value; + if (oldestKey !== undefined) oldest.delete(oldestKey); + if (oldest.size === 0) namespaceTierCache.delete(oldestSession); } -/** - * Clear namespace tier cache for a session - * Called when user switches instances or session expires - */ export function clearNamespaceTierCache(sessionId?: string): void { - if (sessionId) { - // Clear only for specific session - namespaceTierCache.delete(sessionId); - logDebug('Namespace tier cache cleared for session', { sessionId }); - } else { - // Clear entire cache - namespaceTierCache.clear(); - logDebug('All namespace tier caches cleared'); - } + if (sessionId !== undefined) namespaceTierCache.delete(sessionId); + else namespaceTierCache.clear(); } -/** - * Query GitLab for namespace tier via GraphQL - */ -async function queryNamespaceTier( - namespacePath: string, - token: string, - baseUrl: string, -): Promise { - const graphqlUrl = `${baseUrl}/api/graphql`; - - try { - const response = await enhancedFetch(graphqlUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query: NAMESPACE_TIER_QUERY, - variables: { fullPath: namespacePath }, - }), - // Pass baseUrl for rate limiting to match InstanceRegistry key (supports subpath deployments) - rateLimitBaseUrl: baseUrl, - }); - - if (!response.ok) { - logWarn('Failed to query namespace tier', { - namespacePath, - status: response.status, - }); - // Return free tier as fallback - return { - tier: 'free', - features: getFeaturesForTier('free'), - cachedAt: new Date(), - }; - } - - const result = (await response.json()) as { - data?: { - namespace?: { - plan?: string; - group?: { plan?: string }; - }; - }; - errors?: Array<{ message: string }>; - }; - - if (result.errors?.length) { - logWarn('GraphQL errors querying namespace tier', { - namespacePath, - errors: result.errors.map((e) => e.message), - }); - } - - // Extract plan from response - const namespace = result.data?.namespace; - const plan = namespace?.plan ?? namespace?.group?.plan ?? null; - const tier = normalizeTier(plan); - - return { - tier, - features: getFeaturesForTier(tier), - cachedAt: new Date(), - }; - } catch (error) { - logWarn('Error querying namespace tier', { - namespacePath, - err: error instanceof Error ? error : new Error(String(error)), - }); - - // Return free tier as fallback - return { - tier: 'free', - features: getFeaturesForTier('free'), - cachedAt: new Date(), - }; +async function readMetadata( + collection: 'namespaces' | 'projects' | 'groups', + id: string, + caller: Identity, + limit: number, +): Promise<{ status: number; value: unknown }> { + const query = collection === 'groups' ? '?with_projects=false' : ''; + const response = await enhancedFetch( + `${caller.baseUrl}/api/v4/${collection}/${encodeApiSegment(id)}${query}`, + { + method: 'GET', + headers: caller.headers, + rateLimitBaseUrl: caller.baseUrl, + }, + ); + if (!response.ok) { + await response.body?.cancel(); + logWarn('Namespace subscription metadata is unavailable', { status: response.status }); + return { status: response.status, value: null }; } + return { status: response.status, value: await readBoundedJson(response, limit) }; } -/** - * Get namespace tier with caching - * Uses current token context for session ID and token - * - * @param namespacePath - Full path of the namespace (e.g., "gitlab-org" or "gitlab-org/gitlab") - * @returns Namespace tier info with features - */ -export async function getNamespaceTier(namespacePath: string): Promise { - const context = getTokenContext(); - - if (!context) { - logWarn('No token context available for namespace tier detection'); - return { - tier: 'free', - features: getFeaturesForTier('free'), - cachedAt: new Date(), - }; - } - - const { sessionId, gitlabToken, gitlabUserId, apiUrl } = context; - const baseUrl = apiUrl || GITLAB_BASE_URL; - - if (!baseUrl) { - logWarn('No base URL available for namespace tier detection'); - return { - tier: 'free', - features: getFeaturesForTier('free'), - cachedAt: new Date(), - }; - } +/** GitLab namespaces inherit the subscription of their root namespace. */ +function rootOf(path: string): string | null { + const canonical = canonicalProjectPath(path); + const separator = canonical.indexOf('/'); + const root = separator === -1 ? canonical : canonical.slice(0, separator); + // Namespace::URL_MAX_LENGTH, app/models/namespace.rb at v16.0.0-ee. + return root.length > 0 && root.length <= 255 ? root : null; +} - // Check cache first - const key = accountKey(baseUrl, gitlabUserId, namespacePath); - const cached = getCachedTier(sessionId, key, namespacePath); - if (cached) { - logDebug('Namespace tier from cache', { - namespacePath, - tier: cached.tier, - }); - return cached; +/** Match the handlers' group-first resolution, falling through only on a confirmed 404. */ +async function numericTargetPath( + id: string, + kind: 'project' | 'group' | 'either', + caller: Identity, +): Promise { + const collection = kind === 'project' ? 'projects' : 'groups'; + const { status, value } = await readMetadata(collection, id, caller, 1024 * 1024); + if (kind === 'either' && status === 404) return numericTargetPath(id, 'project', caller); + if (kind === 'project') { + const parsed = ProjectMetadataSchema.safeParse(value); + return parsed.success ? parsed.data.path_with_namespace : null; } - - // Query GitLab - const tierInfo = await queryNamespaceTier(namespacePath, gitlabToken, baseUrl); - - // Cache result - setCachedTier(sessionId, key, namespacePath, tierInfo); - - return tierInfo; + const parsed = GroupMetadataSchema.safeParse(value); + return parsed.success ? parsed.data.full_path : null; } /** - * Check if a feature is available for a namespace - * - * @param namespacePath - Full path of the namespace - * @param feature - Feature name to check - * @returns true if feature is available + * Read a root subscription, resolving typed numeric targets first. + * GitLab 16.0 exposes plan only with admin_namespace permission: + * https://gitlab.com/gitlab-org/gitlab/-/blob/v16.0.0-ee/ee/lib/ee/api/entities/namespace.rb#L28-30 */ -export async function isFeatureAvailable(namespacePath: string, feature: string): Promise { - const tierInfo = await getNamespaceTier(namespacePath); - return tierInfo.features[feature] ?? false; +export async function getNamespaceTier( + namespacePath: string, + targetKind?: 'project' | 'group' | 'either', +): Promise { + const canonical = canonicalProjectPath(namespacePath); + const root = rootOf(canonical); + if (root === null) return unknown(); + const caller = identity(); + if (caller === null) return unknown(); + const numeric = targetKind !== undefined && /^\d+$/.test(canonical); + const target = numeric ? `${targetKind}:${canonical}` : `namespace:${root}`; + const key = JSON.stringify([caller.baseUrl, caller.credential, target]); + const previous = cached(caller.sessionId, key); + if (previous) return previous; + + let observation: NamespaceTierInfo; + try { + const path = numeric ? await numericTargetPath(canonical, targetKind, caller) : root; + const namespace = path === null ? null : rootOf(path); + if (namespace === null) observation = unknown(); + else { + const { value } = await readMetadata('namespaces', namespace, caller, 64 * 1024); + const parsed = NamespaceMetadataSchema.safeParse(value); + const plan = parsed.success ? parsed.data.plan : null; + const tier = + plan === null || plan === undefined ? undefined : PLAN_TIERS.get(plan.toLowerCase()); + observation = { tier: tier ?? null, cachedAt: new Date() }; + } + } catch { + // Provider bodies and parser exceptions can contain secrets; never include them. + logWarn('Namespace subscription metadata could not be read'); + observation = unknown(); + } + remember(caller.sessionId, key, observation); + return observation; } -/** - * Get namespace tier metrics (for monitoring) - */ export function getNamespaceTierCacheMetrics(): { totalEntries: number; entriesBySession: Map; @@ -365,6 +215,5 @@ export function getNamespaceTierCacheMetrics(): { entriesBySession.set(sessionId, entries.size); totalEntries += entries.size; } - return { totalEntries, entriesBySession }; } diff --git a/packages/gitlab-mcp/src/services/WidgetAvailability.ts b/packages/gitlab-mcp/src/services/WidgetAvailability.ts index 52d88339..de87f9cd 100644 --- a/packages/gitlab-mcp/src/services/WidgetAvailability.ts +++ b/packages/gitlab-mcp/src/services/WidgetAvailability.ts @@ -2,7 +2,7 @@ import { WorkItemWidgetType, WorkItemWidgetTypes } from '../graphql/workItems'; import { ConnectionManager } from './ConnectionManager'; import { GitLabTier } from './GitLabVersionDetector'; import { parseVersion } from '../utils/version'; -import { effectiveMinVersion } from './InstanceCapabilities'; +import { effectiveMinVersion, TIER_ORDER } from './InstanceCapabilities'; import { logDebug } from '../logger'; interface WidgetRequirement { @@ -20,15 +20,9 @@ export interface WidgetValidationFailure { requiredVersion: string; detectedVersion: string; requiredTier: GitLabTier; - currentTier: GitLabTier; + currentTier: GitLabTier | null; } -const TIER_HIERARCHY: Record = { - free: 0, - premium: 1, - ultimate: 2, -}; - /** * Maps manage_work_item input parameters to their corresponding widget types. * Includes both current schema parameters and those planned for #135. @@ -128,12 +122,12 @@ export class WidgetAvailability { } // Check tier requirement - if (requirement.tier === 'free') { - return true; // Available to all tiers + if (requirement.tier === 'free' || instanceInfo.tier === null) { + return true; // No confirmed subscription restriction. } - const requiredTierLevel = TIER_HIERARCHY[requirement.tier]; - const actualTierLevel = TIER_HIERARCHY[instanceInfo.tier]; + const requiredTierLevel = TIER_ORDER[requirement.tier]; + const actualTierLevel = TIER_ORDER[instanceInfo.tier]; return actualTierLevel >= requiredTierLevel; } catch { @@ -171,7 +165,7 @@ export class WidgetAvailability { const connectionManager = ConnectionManager.getInstance(); let instanceVersion: string; - let instanceTier: GitLabTier; + let instanceTier: GitLabTier | null; try { const instanceInfo = connectionManager.getInstanceInfo(instanceUrl); @@ -212,9 +206,9 @@ export class WidgetAvailability { } // Check tier requirement - if (requirement.tier !== 'free') { - const requiredTierLevel = TIER_HIERARCHY[requirement.tier]; - const actualTierLevel = TIER_HIERARCHY[instanceTier]; + if (requirement.tier !== 'free' && instanceTier !== null) { + const requiredTierLevel = TIER_ORDER[requirement.tier]; + const actualTierLevel = TIER_ORDER[instanceTier]; if (actualTierLevel < requiredTierLevel) { return { diff --git a/packages/gitlab-mcp/src/services/namespace-tier-context.ts b/packages/gitlab-mcp/src/services/namespace-tier-context.ts new file mode 100644 index 00000000..ddd40bcb --- /dev/null +++ b/packages/gitlab-mcp/src/services/namespace-tier-context.ts @@ -0,0 +1,30 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { GitLabTier } from './GitLabVersionDetector'; +import { normalizeInstanceUrl } from '../utils/url'; + +interface NamespaceTierContext { + instanceUrl: string; + tier: GitLabTier | null; +} + +const storage = new AsyncLocalStorage(); + +/** GitLab.com subscriptions attach to top-level groups, not the shared instance. + * https://docs.gitlab.com/subscriptions/manage_subscription/ */ +export function hasNamespaceSubscriptions(instanceUrl: string): boolean { + return normalizeInstanceUrl(instanceUrl) === 'https://gitlab.com'; +} + +export function currentNamespaceTier(instanceUrl: string): NamespaceTierContext | undefined { + const context = storage.getStore(); + return context?.instanceUrl === normalizeInstanceUrl(instanceUrl) ? context : undefined; +} + +/** A request's subscription never changes another request or the connection's metadata. */ +export function runWithNamespaceTier( + instanceUrl: string, + tier: GitLabTier | null, + fn: () => T, +): T { + return storage.run({ instanceUrl: normalizeInstanceUrl(instanceUrl), tier }, fn); +} diff --git a/packages/gitlab-mcp/src/utils/error-handler.ts b/packages/gitlab-mcp/src/utils/error-handler.ts index f2a462b3..fbc49e69 100644 --- a/packages/gitlab-mcp/src/utils/error-handler.ts +++ b/packages/gitlab-mcp/src/utils/error-handler.ts @@ -486,7 +486,7 @@ function detectTierRestriction( // Check for iterations if (tool === 'browse_iterations') { - if (!connectionManager.isFeatureAvailable('iterations')) { + if (connectionManager.isFeatureAvailable('iterations') === false) { return createRestrictionInfo('iterations', currentTier); } } @@ -494,7 +494,7 @@ function detectTierRestriction( // Check for group webhooks (tool name pattern) if ((tool === 'browse_webhooks' || tool === 'manage_webhook') && toolArgs?.scope === 'group') { // Group webhooks require Premium - check if we have serviceDesk (Premium feature) as proxy - if (!connectionManager.isFeatureAvailable('serviceDesk')) { + if (connectionManager.isFeatureAvailable('serviceDesk') === false) { return { feature: 'serviceDesk', // Using serviceDesk as proxy for Premium tier name: 'Group Webhooks', @@ -530,21 +530,21 @@ function checkWorkItemTypeRestriction( // Check for EPIC if (types.includes('EPIC')) { - if (!connectionManager.isFeatureAvailable('epics')) { + if (connectionManager.isFeatureAvailable('epics') === false) { return createRestrictionInfo('epics', currentTier); } } // Check for OBJECTIVE/KEY_RESULT (OKRs) if (types.includes('OBJECTIVE') || types.includes('KEY_RESULT')) { - if (!connectionManager.isFeatureAvailable('okrs')) { + if (connectionManager.isFeatureAvailable('okrs') === false) { return createRestrictionInfo('okrs', currentTier); } } // Check for REQUIREMENT if (types.includes('REQUIREMENT')) { - if (!connectionManager.isFeatureAvailable('requirements')) { + if (connectionManager.isFeatureAvailable('requirements') === false) { return createRestrictionInfo('requirements', currentTier); } } diff --git a/packages/gitlab-mcp/tests/scripts/docs-download.test.mjs b/packages/gitlab-mcp/tests/scripts/docs-download.test.mjs new file mode 100644 index 00000000..f141e35b --- /dev/null +++ b/packages/gitlab-mcp/tests/scripts/docs-download.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, join } from 'node:path'; +import test from 'node:test'; +import { parse } from 'yaml'; + +// Execute the Linux publishing workflow's Bash step, independently of portable unit tests. +for (const version of ['13.1.0', '13.2.0']) { + test(`docs download resolves component release ${version}`, () => { + const workflow = parse( + readFileSync(new URL('../../../../.github/workflows/docs.yml', import.meta.url), 'utf8'), + ); + const step = workflow.jobs.build.steps.find((candidate) => candidate.id === 'mcpb'); + const root = mkdtempSync(join(tmpdir(), 'gitlab-docs-release-')); + const bin = join(root, 'bin'); + const output = join(root, 'output'); + mkdirSync(bin); + writeFileSync( + join(bin, 'gh'), + `#!${process.execPath} +const fs = require('node:fs'), path = require('node:path'); +const args = process.argv.slice(2); +if (args[0] !== 'release') throw new Error('Unexpected GitHub request'); +if (args[1] === 'view') console.log('gitlab-mcp-v${version}'); +else if (args[1] === 'download') { + if (args[2] !== 'gitlab-mcp-v${version}') throw new Error('Wrong release'); + const destination = args[args.indexOf('--dir') + 1]; + fs.writeFileSync(path.join(destination, 'gitlab-mcp-${version}.mcpb'), 'fixture bundle'); +} else throw new Error('Unexpected GitHub command'); +`, + { mode: 0o755 }, + ); + try { + const result = spawnSync('bash', ['-e', '-o', 'pipefail', '-c', step.run], { + cwd: root, + env: { + ...process.env, + PATH: `${bin}${delimiter}${process.env.PATH}`, + GITHUB_OUTPUT: output, + }, + encoding: 'utf8', + timeout: 10000, + }); + assert.equal(result.error, undefined); + assert.equal(result.status, 0); + assert.equal(readFileSync(output, 'utf8'), `version=${version}\n`); + assert.equal( + readFileSync(join(root, 'docs/public/downloads', `gitlab-mcp-${version}.mcpb`), 'utf8'), + 'fixture bundle', + ); + assert.ok(result.stdout.includes(`MCPB bundle included: ${version}`)); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +} diff --git a/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts b/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts index 57c88af7..e73daa91 100644 --- a/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts +++ b/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts @@ -76,7 +76,16 @@ jest.mock('../../src/entities/labels/registry', () => ({ jest.mock('../../src/services/ConnectionManager', () => ({ ConnectionManager: { getInstance: jest.fn().mockReturnValue({ - getInstanceInfo: jest.fn().mockReturnValue({ tier: 'free', version: '17.0.0' }), + getInstanceInfo: jest.fn().mockImplementation((url?: string) => { + const { currentNamespaceTier, hasNamespaceSubscriptions } = jest.requireActual( + '../../src/services/namespace-tier-context', + ); + const tier = + url && hasNamespaceSubscriptions(url) + ? (currentNamespaceTier(url)?.tier ?? null) + : 'free'; + return { tier, version: '17.0.0' }; + }), getTokenScopeInfo: jest.fn().mockReturnValue(null), getAdminInfo: jest.fn().mockReturnValue(null), getCurrentInstanceUrl: jest.fn().mockReturnValue('https://gitlab.example.com'), @@ -408,6 +417,81 @@ describe('RegistryManager', () => { }); describe('Tool Availability Filtering', () => { + // The same instance and schema mode must retain separate concurrent namespace views. + it('isolates namespace catalogs, dispatch and statistics through refresh', async () => { + const { runWithNamespaceTier } = jest.requireActual( + '../../src/services/namespace-tier-context', + ); + const core = require('../../src/entities/core/registry').coreToolRegistry; + const handler = jest.fn().mockResolvedValue({ ok: true }); + core.set('paid_namespace_tool', { + name: 'paid_namespace_tool', + description: 'Paid namespace tool', + inputSchema: { type: 'object' }, + requirements: { default: { tier: 'premium' } }, + handler, + }); + const url = 'https://gitlab.com'; + try { + registryManager.refreshCache(url); + const observe = (tier: 'free' | 'ultimate' | null) => + runWithNamespaceTier(url, tier, async () => { + await Promise.resolve(); + const available = tier !== 'free'; + expect(registryManager.getAvailableToolNames(url).includes('paid_namespace_tool')).toBe( + available, + ); + expect( + registryManager + .getAllToolDefinitions(url) + .some((t) => t.name === 'paid_namespace_tool'), + ).toBe(available); + expect(registryManager.getFilterStats(url).available).toBe( + registryManager.getAllToolDefinitions(url).length, + ); + if (available) + expect(await registryManager.executeTool('paid_namespace_tool', {}, url)).toEqual({ + ok: true, + }); + else + await expect( + registryManager.executeTool('paid_namespace_tool', {}, url), + ).rejects.toThrow('not found'); + }); + await Promise.all([observe(null), observe('free'), observe('ultimate')]); + // Refreshing one known view must also rebuild the existing unknown and sibling views. + runWithNamespaceTier(url, 'free', () => registryManager.refreshCache(url)); + await Promise.all([observe('ultimate'), observe(null), observe('free')]); + expect(registryManager.hasToolHandler('paid_namespace_tool', url)).toBe(true); + expect(handler).toHaveBeenCalledTimes(4); + } finally { + core.delete('paid_namespace_tool'); + } + }); + + // A SaaS instance has no single subscription: an unscoped catalog cannot assume Free. + it('keeps paid SaaS tools discoverable without a known namespace subscription', () => { + const core = require('../../src/entities/core/registry').coreToolRegistry; + core.set('paid_namespace_tool', { + name: 'paid_namespace_tool', + description: 'Paid namespace tool', + inputSchema: { type: 'object' }, + requirements: { default: { tier: 'premium' } }, + handler: jest.fn(), + }); + try { + registryManager.refreshCache('https://gitlab.com'); + expect(registryManager.getAvailableToolNames('https://gitlab.com')).toContain( + 'paid_namespace_tool', + ); + expect(registryManager.getAvailableToolNames('https://gitlab.example.com')).not.toContain( + 'paid_namespace_tool', + ); + } finally { + core.delete('paid_namespace_tool'); + } + }); + it('should filter unavailable tools', () => { // ConnectionManager mock reports version 17.0.0; a tool requiring 99.0 // fails the real InstanceCapabilities version gate and is filtered out. diff --git a/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts b/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts new file mode 100644 index 00000000..af3a0044 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/cli/install/roo-code.test.ts @@ -0,0 +1,124 @@ +import * as fs from 'fs'; +import { homedir } from 'os'; +import { dirname, join, normalize } from 'path'; +import { installRooCode } from '../../../../src/cli/install/installers'; +import { detectClient } from '../../../../src/cli/install/detector'; + +jest.mock('fs'); +jest.mock('../../../../src/cli/install/backup', () => ({ + createBackup: jest.fn(() => ({ created: false })), +})); + +const mockedFs = fs as jest.Mocked; + +describe('Roo Code global installation', () => { + const platform = process.platform; + const appData = process.env.APPDATA; + const configHome = process.env.XDG_CONFIG_HOME; + const vscodeAppData = process.env.VSCODE_APPDATA; + + beforeEach(() => { + delete process.env.XDG_CONFIG_HOME; + delete process.env.VSCODE_APPDATA; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: platform }); + if (appData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = appData; + if (configHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = configHome; + if (vscodeAppData === undefined) delete process.env.VSCODE_APPDATA; + else process.env.VSCODE_APPDATA = vscodeAppData; + jest.clearAllMocks(); + }); + + // A relocated VS Code config directory must be the file the installer preserves and updates. + it('honors the Linux XDG configuration directory', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.XDG_CONFIG_HOME = '/custom/config'; + const expected = + '/custom/config/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json'; + mockedFs.existsSync.mockImplementation((path) => String(path) === expected); + mockedFs.readFileSync.mockReturnValue( + JSON.stringify({ mcpServers: { other: { command: 'other-server' } } }), + ); + + const result = installRooCode({ command: 'node', args: ['server.js'], env: {} }); + + expect(result.success).toBe(true); + expect(result.configPath).toBe(expected); + const written = JSON.parse(mockedFs.writeFileSync.mock.calls[0][1] as string); + expect(written.mcpServers.other).toEqual({ command: 'other-server' }); + expect(written.mcpServers.gitlab).toEqual({ command: 'node', args: ['server.js'] }); + }); + + // VS Code's application-data override wins over platform defaults for detection and writes. + test.each(['linux', 'darwin', 'win32'])( + 'uses VSCODE_APPDATA for detection and installation on %s', + (os) => { + Object.defineProperty(process, 'platform', { value: os }); + process.env.XDG_CONFIG_HOME = join(homedir(), 'unused-config'); + process.env.VSCODE_APPDATA = join(homedir(), 'custom-vscode-data'); + const expected = `${process.env.VSCODE_APPDATA}/Code/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json`; + mockedFs.existsSync.mockImplementation( + (path) => + normalize(String(path)) === normalize(expected) || + normalize(String(path)) === dirname(normalize(expected)), + ); + mockedFs.readFileSync.mockReturnValue( + JSON.stringify({ mcpServers: { other: { command: 'other-server' } } }), + ); + + expect(detectClient('roo-code')).toMatchObject({ + detected: true, + configExists: true, + configPath: expected, + }); + expect(installRooCode({ command: 'node', args: ['server.js'], env: {} })).toMatchObject({ + success: true, + configPath: expected, + }); + const written = JSON.parse(mockedFs.writeFileSync.mock.calls[0][1] as string); + expect(written.mcpServers).toEqual({ + other: { command: 'other-server' }, + gitlab: { command: 'node', args: ['server.js'] }, + }); + }, + ); + + // Prevent successful installs into an unused home-level .roo file on any supported OS. + test.each([ + ['darwin', join(homedir(), 'Library/Application Support/Code/User/globalStorage')], + ['linux', join(homedir(), '.config/Code/User/globalStorage')], + ['win32', 'C:/Profiles/test/AppData/Roaming/Code/User/globalStorage'], + ])('writes the active default VS Code extension configuration on %s', (os, storage) => { + Object.defineProperty(process, 'platform', { value: os }); + process.env.APPDATA = 'C:/Profiles/test/AppData/Roaming'; + const suffix = 'rooveterinaryinc.roo-cline/settings/mcp_settings.json'; + const expected = os === 'win32' ? `${storage}/${suffix}` : join(storage, suffix); + const existing = { mcpServers: { other: { command: 'other-server' } } }; + mockedFs.existsSync.mockImplementation((path) => String(path) === expected); + mockedFs.readFileSync.mockReturnValue(JSON.stringify(existing)); + const config = { command: 'node', args: ['server.js'], env: {} }; + + const result = installRooCode(config); + + expect(result.success).toBe(true); + expect(result.configPath).toBe(expected); + expect(mockedFs.writeFileSync).toHaveBeenCalledWith( + expected, + JSON.stringify( + { + mcpServers: { + other: existing.mcpServers.other, + gitlab: { command: 'node', args: ['server.js'] }, + }, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/configuration/namespace-capabilities.test.ts b/packages/gitlab-mcp/tests/unit/configuration/namespace-capabilities.test.ts new file mode 100644 index 00000000..7795f17c --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/namespace-capabilities.test.ts @@ -0,0 +1,148 @@ +/** Namespace selection must preserve independent subscriptions without duplicating capability rules. */ +import { + namespaceTargetsForArgs, + namespaceTargetsForScope, + needsNamespaceTier, + withNamespaceCapabilities, +} from '../../../src/configuration/namespace-capabilities'; +import { getNamespaceTier } from '../../../src/services/NamespaceTierDetector'; +import { currentNamespaceTier } from '../../../src/services/namespace-tier-context'; + +jest.mock('../../../src/services/NamespaceTierDetector'); +const lookup = jest.mocked(getNamespaceTier); +const url = 'https://gitlab.com'; +const observed = () => currentNamespaceTier(url)?.tier; + +describe('namespace capabilities', () => { + beforeEach(() => { + jest.resetAllMocks(); + lookup.mockImplementation(async (path) => ({ + tier: path.startsWith('paid') ? 'ultimate' : 'free', + cachedAt: new Date(), + })); + }); + + // Scope metadata and actual arguments must retain project/group ID namespaces. + it('extracts configured scopes and typed numeric targets', () => { + expect(namespaceTargetsForScope({ project: 'a/p', groups: ['b', 'c'] })).toEqual([ + { path: 'a/p', kind: 'project' }, + { path: 'b', kind: 'group' }, + { path: 'c', kind: 'group' }, + ]); + expect(namespaceTargetsForArgs({ project_id: '7', group_id: '7' })).toEqual([ + { path: '7', kind: 'project' }, + { path: '7', kind: 'group' }, + ]); + expect(namespaceTargetsForScope(undefined)).toEqual([]); + }); + + // Configured plural scopes and path arguments must retain all relevant roots and kinds. + it('extracts plural scopes and nonnumeric argument paths', () => { + expect(namespaceTargetsForScope({ projects: ['team/a'], namespace: 'group' })).toEqual([ + { path: 'team/a', kind: 'project' }, + { path: 'group', kind: 'group' }, + ]); + expect(namespaceTargetsForArgs({ project_id: 'team/a', group_id: 'group' })).toEqual([ + { path: 'team/a', kind: 'project' }, + { path: 'group', kind: 'group' }, + ]); + }); + + // Numeric projects and groups must not deduplicate just because their IDs coincide. + it('retains numeric target kinds and agrees on equal subscriptions', async () => { + const targets = [ + { path: '7', kind: 'project' as const }, + { path: '7', kind: 'group' as const }, + { path: 'team', kind: 'group' as const }, + ]; + expect(await withNamespaceCapabilities(url, targets, false, observed)).toBe('free'); + expect(lookup.mock.calls.map(([path, kind]) => ({ path, kind }))).toEqual(targets); + }); + + // Union selection cannot downgrade when a lower subscription appears later in the input. + it('retains the higher tier when a lower tier follows it', async () => { + expect( + await withNamespaceCapabilities( + url, + [ + { path: 'paid/a', kind: 'project' }, + { path: 'free/b', kind: 'project' }, + ], + true, + observed, + ), + ).toBe('ultimate'); + }); + + // Free-only tools do not pay for optional billing observations. + it('requests observations only for declared paid capabilities', () => { + expect(needsNamespaceTier(undefined)).toBe(false); + expect(needsNamespaceTier({ default: { tier: 'free' } })).toBe(false); + expect(needsNamespaceTier({ default: {}, parameters: { weight: { tier: 'premium' } } })).toBe( + true, + ); + expect(needsNamespaceTier({ default: {}, actions: { audit: { tier: 'ultimate' } } })).toBe( + true, + ); + }); + + // Descendants inherit one root plan; catalog union and cross-project execution differ. + it('deduplicates root observations and selects a catalog union', async () => { + const targets = [ + { path: 'free/p', kind: 'project' as const }, + { path: 'paid/p', kind: 'project' as const }, + { path: 'paid/nested/p', kind: 'project' as const }, + ]; + expect(await withNamespaceCapabilities(url, targets, true, observed)).toBe('ultimate'); + expect(lookup).toHaveBeenCalledTimes(2); + expect(await withNamespaceCapabilities(url, targets, false, observed)).toBeNull(); + expect(currentNamespaceTier(url)).toBeUndefined(); + }); + + // Concurrent chats on one host must not exchange a request-local tier or instance. + it('isolates concurrent calls and restores the caller context', async () => { + const call = (path: string) => + withNamespaceCapabilities(url, [{ path, kind: 'project' }], false, async () => { + await Promise.resolve(); + expect(currentNamespaceTier('https://other.example')).toBeUndefined(); + return observed(); + }); + expect(await Promise.all([call('free/p'), call('paid/p')])).toEqual(['free', 'ultimate']); + expect(observed()).toBeUndefined(); + }); + + // Missing plans cannot establish Free; stop queued optional reads after that fact. + it('bounds lookups and stops queued work on an unknown subscription', async () => { + lookup.mockResolvedValue({ tier: null, cachedAt: new Date() }); + const targets = Array.from({ length: 20 }, (_, i) => ({ + path: `team${i}/p`, + kind: 'project' as const, + })); + expect(await withNamespaceCapabilities(url, targets, true, observed)).toBeNull(); + expect(lookup).toHaveBeenCalledTimes(5); + }); + + // Opaque identities and invalid paths are not resolved by guessing a namespace. + it.each(['gid://gitlab/Project/7', '../outside'])( + 'leaves %s to its owning handler', + async (path) => { + expect( + await withNamespaceCapabilities(url, [{ path, kind: 'project' }], false, observed), + ).toBeNull(); + expect(lookup).not.toHaveBeenCalled(); + }, + ); + + // Self-hosted licenses and unexpected programming failures retain their existing paths. + it('does not query namespaces on self-hosted instances or conceal unexpected errors', async () => { + expect( + await withNamespaceCapabilities('https://gitlab.example', [], true, () => 'self-hosted'), + ).toBe('self-hosted'); + expect(lookup).not.toHaveBeenCalled(); + lookup.mockRejectedValue(new Error('unexpected failure')); + await expect( + withNamespaceCapabilities(url, [{ path: 'team', kind: 'group' }], false, observed), + ).rejects.toThrow('unexpected failure'); + expect(observed()).toBeUndefined(); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/entities/context/whoami.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/whoami.test.ts index a5333f6e..ad44b2ef 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/whoami.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/whoami.test.ts @@ -142,6 +142,20 @@ describe('whoami handler', () => { }); describe('action: whoami', () => { + // A connected SaaS instance can have a known version but no observable namespace plan. + it('reports an unknown tier without discarding the detected version', async () => { + mockConnectionManager.getInstanceInfo.mockReturnValue({ + version: '17.5.2', + tier: null, + features: {}, + detectedAt: new Date(), + }); + const result = (await handleManageContext({ action: 'whoami' })) as WhoamiResult; + expect(result.server.version).toBe('17.5.2'); + expect(result.server.tier).toBe('unknown'); + expect(result.userLookup).toBe('ok'); + }); + it('should return complete WhoamiResult structure', async () => { const result = (await handleManageContext({ action: 'whoami' })) as WhoamiResult; diff --git a/packages/gitlab-mcp/tests/unit/entities/workitems/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/workitems/registry.test.ts index 7850ec8a..36cc5c6f 100644 --- a/packages/gitlab-mcp/tests/unit/entities/workitems/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/workitems/registry.test.ts @@ -4,6 +4,7 @@ import { getWorkitemsToolDefinitions, getFilteredWorkitemsTools, } from '../../../../src/entities/workitems/registry'; +import { StructuredToolError } from '../../../../src/utils/error-handler'; // Create mock client const mockClient = { @@ -271,6 +272,40 @@ describe('Workitems Registry - CQRS Tools', () => { mockValidateWidgetParams.mockClear(); }); + // Version restrictions must remain useful when GitLab does not disclose the subscription. + test.each(['create', 'update'])( + 'omits an unknown tier from %s widget errors', + async (action) => { + mockValidateWidgetParams.mockReturnValueOnce({ + parameter: 'weight', + widget: 'WEIGHT', + requiredVersion: '19.0', + detectedVersion: '16.0.0', + requiredTier: 'premium', + currentTier: null, + }); + const input = + action === 'create' + ? { action, namespace: 'test-group', workItemType: 'EPIC', title: 'Test', weight: 1 } + : { action, id: '123', weight: 1 }; + const failure = await workitemsToolRegistry + .get('manage_work_item')! + .handler(input) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(StructuredToolError); + expect((failure as StructuredToolError).structuredError).toMatchObject({ + error_code: 'VERSION_RESTRICTED', + tool: 'manage_work_item', + action, + }); + expect((failure as StructuredToolError).structuredError).toHaveProperty( + 'current_tier', + undefined, + ); + expect(mockClient.request).not.toHaveBeenCalled(); + }, + ); + // Helper function to create complete mock work items const createMockWorkItem = (overrides: Record = {}) => ({ id: 'gid://gitlab/WorkItem/1', diff --git a/packages/gitlab-mcp/tests/unit/handlers.test.ts b/packages/gitlab-mcp/tests/unit/handlers.test.ts index 9ad2471f..658825fa 100644 --- a/packages/gitlab-mcp/tests/unit/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/handlers.test.ts @@ -4,6 +4,7 @@ */ import { setImmediate } from 'node:timers'; +import { inspect } from 'node:util'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, @@ -282,6 +283,222 @@ describe('handlers', () => { mockSessionManager.setSessionInstanceUrl.mockReturnValue(undefined); }); + describe('namespace capabilities', () => { + const url = 'https://gitlab.com'; + let lookup: jest.SpyInstance; + + beforeEach(async () => { + const detector = await import('../../src/services/NamespaceTierDetector'); + lookup = jest + .spyOn(detector, 'getNamespaceTier') + .mockResolvedValue({ tier: 'ultimate', cachedAt: new Date() }); + mockSessionManager.getSessionInstanceUrl.mockReturnValue(url); + await setupHandlers(mockServer); + }); + + afterEach(() => { + lookup.mockRestore(); + jest.useRealTimers(); + }); + + // Catalog construction must observe the chat's scoped namespace on its selected host. + it('builds the catalog inside the selected namespace context', async () => { + const { currentNamespaceTier } = await import('../../src/services/namespace-tier-context'); + mockSettings.set(`token:${url}`, { + accountKey: `token:${url}`, + version: 1, + updatedAt: 1, + settings: { scope: { type: 'project', path: 'paid/project', includeSubgroups: false } }, + }); + const tiers: unknown[] = []; + mockRegistryManager.getAllToolDefinitions.mockImplementationOnce(() => { + tiers.push(currentNamespaceTier(url)?.tier); + return []; + }); + await getRegisteredHandler(mockServer, ListToolsRequestSchema)( + {}, + { sessionId: 'namespace-chat' }, + ); + expect(lookup).toHaveBeenCalledWith('paid/project', 'project'); + expect(tiers).toEqual(['ultimate']); + expect(currentNamespaceTier(url)).toBeUndefined(); + }); + + // Execution must bind each argument's namespace independently of another concurrent call. + it('dispatches concurrent paid-tool calls in their target namespace contexts', async () => { + const { currentNamespaceTier } = await import('../../src/services/namespace-tier-context'); + lookup.mockImplementation(async (path: string) => ({ + tier: path === 'paid' ? 'ultimate' : 'free', + cachedAt: new Date(), + })); + mockRegistryManager.getTool.mockReturnValue({ + requirements: { default: { tier: 'premium' } }, + }); + const tiers: unknown[] = []; + const execute = mockRegistryManager.executeTool.getMockImplementation(); + mockRegistryManager.executeTool.mockImplementation(async () => { + await Promise.resolve(); + tiers.push(currentNamespaceTier(url)?.tier); + return { ok: true }; + }); + try { + await Promise.all( + ['free', 'paid'].map((group_id) => + runWithTokenAccount({ instanceUrl: url, token: 'fixture' }, () => + getRegisteredHandler(mockServer, CallToolRequestSchema)( + { params: { name: 'browse_iterations', arguments: { action: 'list', group_id } } }, + { sessionId: `chat-${group_id}` }, + ), + ), + ), + ); + expect(tiers.sort()).toEqual(['free', 'ultimate']); + } finally { + if (execute) mockRegistryManager.executeTool.mockImplementation(execute); + } + }); + + // Discovery may inspect only namespaces within the operator's project intersection. + it('does not look up the plan of a configured project outside the operator list', async () => { + const { gitlab } = await import('../../src/utils/gitlab-api'); + const detector = await import('../../src/services/NamespaceTierDetector'); + const auth = jest.spyOn(detector, 'canResolveNamespaceTier').mockReturnValue(true); + const read = jest.spyOn(gitlab, 'get').mockResolvedValue({ + id: 1, + path_with_namespace: 'allowed/project', + name_with_namespace: 'Allowed / Project', + }); + runtimeConfig.GITLAB_ALLOWED_PROJECT_IDS.push('1'); + mockSettings.set(`token:${url}`, { + accountKey: `token:${url}`, + version: 1, + updatedAt: 1, + settings: { scope: { type: 'project', path: 'outside/project', includeSubgroups: false } }, + }); + try { + const result = await getRegisteredHandler(mockServer, ListToolsRequestSchema)( + {}, + { sessionId: 'namespace-chat' }, + ); + expect(result.tools).toEqual([expect.objectContaining({ name: 'test_tool' })]); + expect(lookup).not.toHaveBeenCalled(); + expect(read).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledWith('projects/1'); + } finally { + runtimeConfig.GITLAB_ALLOWED_PROJECT_IDS.splice(0); + read.mockRestore(); + auth.mockRestore(); + } + }); + + // Optional operator metadata must not take down discovery or leak provider diagnostics. + it('keeps discovery available when operator project metadata fails', async () => { + const detector = await import('../../src/services/NamespaceTierDetector'); + const { currentNamespaceTier } = await import('../../src/services/namespace-tier-context'); + const auth = jest.spyOn(detector, 'canResolveNamespaceTier').mockReturnValue(true); + const read = jest.spyOn(gitlab, 'get').mockRejectedValue(new Error('provider-secret')); + runtimeConfig.GITLAB_ALLOWED_PROJECT_IDS.push('1'); + const tiers: unknown[] = []; + mockRegistryManager.getAllToolDefinitions.mockImplementation(() => { + tiers.push(currentNamespaceTier(url)?.tier); + return [ + { + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + }, + ]; + }); + try { + const result = await getRegisteredHandler(mockServer, ListToolsRequestSchema)( + {}, + { sessionId: 'namespace-chat' }, + ); + expect(result.tools).toEqual([expect.objectContaining({ name: 'test_tool' })]); + expect(tiers).toEqual([null]); + expect(lookup).not.toHaveBeenCalled(); + const { logWarn } = await import('../../src/logger'); + expect(inspect(jest.mocked(logWarn).mock.calls)).not.toContain('provider-secret'); + } finally { + mockRegistryManager.getAllToolDefinitions.mockReset(); + runtimeConfig.GITLAB_ALLOWED_PROJECT_IDS.splice(0); + read.mockRestore(); + auth.mockRestore(); + } + }); + + // Version-denied actions are rejected by dispatch without spending a billing lookup. + it('leaves unavailable actions to the existing action gate', async () => { + mockRegistryManager.getTool.mockReturnValue({ + requirements: { default: { tier: 'premium' } }, + unavailableActions: new Set(['list']), + }); + mockRegistryManager.executeTool.mockRejectedValueOnce(new Error('Action unavailable')); + const result = await runWithTokenAccount({ instanceUrl: url, token: 'fixture' }, () => + getRegisteredHandler(mockServer, CallToolRequestSchema)( + { + params: { name: 'browse_iterations', arguments: { action: 'list', group_id: 'team' } }, + }, + { sessionId: 'namespace-chat' }, + ), + ); + expect(result.isError).toBe(true); + expect(mockRegistryManager.executeTool).toHaveBeenCalledTimes(1); + expect(lookup).not.toHaveBeenCalled(); + }); + + // Invalid paid-tool input must fail validation before an optional metadata read. + it('validates required arguments before namespace resolution', async () => { + mockRegistryManager.getTool.mockReturnValue({ + requirements: { default: { tier: 'premium' } }, + }); + const result = await runWithTokenAccount({ instanceUrl: url, token: 'fixture' }, () => + getRegisteredHandler(mockServer, CallToolRequestSchema)( + { params: { name: 'browse_iterations', arguments: { action: 'list' } } }, + { sessionId: 'namespace-chat' }, + ), + ); + expect(result.isError).toBe(true); + expect(lookup).not.toHaveBeenCalled(); + expect(mockRegistryManager.executeTool).not.toHaveBeenCalled(); + }); + + // A metadata read finishing after the request deadline must never start a late operation. + it('does not execute a tool after namespace discovery exceeds the deadline', async () => { + let markStarted!: () => void; + let release!: (value: { tier: 'ultimate'; cachedAt: Date }) => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const metadata = new Promise<{ tier: 'ultimate'; cachedAt: Date }>((resolve) => { + release = resolve; + }); + lookup.mockImplementation(() => { + markStarted(); + return metadata; + }); + mockRegistryManager.getTool.mockReturnValue({ + requirements: { default: { tier: 'premium' } }, + }); + jest.useFakeTimers(); + const request = runWithTokenAccount({ instanceUrl: url, token: 'fixture' }, () => + getRegisteredHandler(mockServer, CallToolRequestSchema)( + { + params: { name: 'browse_iterations', arguments: { action: 'list', group_id: 'paid' } }, + }, + { sessionId: 'namespace-chat' }, + ), + ); + await started; + await jest.advanceTimersByTimeAsync(101); + expect((await request).isError).toBe(true); + release({ tier: 'ultimate', cachedAt: new Date() }); + jest.useRealTimers(); + await new Promise((resolve) => setImmediate(resolve)); + expect(mockRegistryManager.executeTool).not.toHaveBeenCalled(); + }); + }); + describe('setupHandlers', () => { it('should initialize health monitor and set up request handlers', async () => { await setupHandlers(mockServer); diff --git a/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts new file mode 100644 index 00000000..005cad44 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/release/docs-download.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse } from 'yaml'; + +// An unrelated later publishing failure must not suppress already-uploaded downloads. +test('MCPB publication triggers docs before subsequent publishing steps', () => { + const workflow = parse( + readFileSync(join(__dirname, '../../../../../.github/workflows/docs.yml'), 'utf8'), + ); + const release = parse( + readFileSync(join(__dirname, '../../../../../.github/workflows/release-please.yml'), 'utf8'), + ); + const steps = release.jobs['post-release'].steps; + const upload = steps.findIndex( + (step: { name: string }) => step.name === 'Upload MCPB bundles to GitHub Release', + ); + const notification = steps.findIndex((step: { run?: string }) => + step.run?.includes('event_type=mcpb-published'), + ); + const docker = steps.findIndex((step: { name: string }) => step.name === 'Set up Docker Buildx'); + expect(upload).toBeGreaterThanOrEqual(0); + expect(steps[upload].with.fail_on_unmatched_files).toBe(true); + expect(notification).toBeGreaterThan(upload); + expect(notification).toBeLessThan(docker); + expect(steps[notification].if).toBeUndefined(); + expect(steps[notification]['continue-on-error']).toBeUndefined(); + expect(workflow.on.repository_dispatch.types).toEqual(['mcpb-published']); + expect(workflow.on.workflow_run).toBeUndefined(); + expect(workflow.jobs.build.if).toBeUndefined(); +}); diff --git a/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts b/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts index c92f75b5..c10bd4a3 100644 --- a/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/ConnectionManager.test.ts @@ -4,6 +4,7 @@ */ import { ConnectionManager, type InstanceState } from '../../../src/services/ConnectionManager'; +import { GitLabVersionDetector } from '../../../src/services/GitLabVersionDetector'; import { runWithCaller } from '../../../src/configuration/caller'; // Mock isOAuthEnabled and getGitLabApiUrlFromContext @@ -82,6 +83,53 @@ describe('ConnectionManager Unit', () => { (ConnectionManager as unknown as { instance: null }).instance = null; }); + // Instance-wide detection must not manufacture a subscription shared by SaaS namespaces. + it('projects request-local subscriptions without changing the stored connection', async () => { + const { runWithNamespaceTier } = await import('../../../src/services/namespace-tier-context'); + const manager = ConnectionManager.getInstance(); + const stored = { + version: '19.5.0', + tier: 'free' as const, + features: GitLabVersionDetector.determineFeatures('19.5.0', 'free'), + detectedAt: new Date(), + }; + injectInstanceState(manager, 'https://gitlab.com', { instanceInfo: stored }); + expect(manager.getInstanceInfo('https://gitlab.com').tier).toBeNull(); + expect(manager.isFeatureAvailable('epics', 'https://gitlab.com')).toBeNull(); + await Promise.all( + ['free', 'ultimate'].map((tier) => + runWithNamespaceTier('https://gitlab.com', tier as 'free' | 'ultimate', async () => { + await Promise.resolve(); + expect(manager.getInstanceInfo('https://gitlab.com').tier).toBe(tier); + expect(manager.isFeatureAvailable('epics', 'https://gitlab.com')).toBe( + tier === 'ultimate', + ); + }), + ), + ); + expect(stored.tier).toBe('free'); + expect(manager.getInstanceInfo('https://gitlab.com').tier).toBeNull(); + }); + + // Knowing a subscription does not establish which version-dependent widgets exist. + it('keeps feature availability unknown until the SaaS version is known', async () => { + const { runWithNamespaceTier } = await import('../../../src/services/namespace-tier-context'); + const manager = ConnectionManager.getInstance(); + injectInstanceState(manager, 'https://gitlab.com', { + instanceInfo: { + version: 'unknown', + tier: 'free', + features: GitLabVersionDetector.determineFeatures('19.5.0', 'free'), + detectedAt: new Date(), + }, + }); + runWithNamespaceTier('https://gitlab.com', 'ultimate', () => { + expect(manager.getInstanceInfo('https://gitlab.com').features).toEqual({}); + expect(manager.getTier('https://gitlab.com')).toBe('ultimate'); + expect(manager.isFeatureAvailable('epics', 'https://gitlab.com')).toBeNull(); + }); + }); + describe('singleton pattern', () => { it('should return the same instance on multiple calls', () => { const instance1 = ConnectionManager.getInstance(); diff --git a/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts b/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts index e286af5a..c8a7284d 100644 --- a/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/ConnectionManagerEnhanced.test.ts @@ -772,7 +772,7 @@ describe('ConnectionManager Enhanced Tests', () => { connectionManager.isFeatureAvailable( undefined as unknown as keyof import('../../../src/services/GitLabVersionDetector').GitLabInstanceInfo['features'], ), - ).toBe(undefined); + ).toBeNull(); }); it('should handle instance info with missing features', async () => { @@ -789,7 +789,7 @@ describe('ConnectionManager Enhanced Tests', () => { // Should handle gracefully without throwing expect(() => connectionManager.isFeatureAvailable('workItems')).not.toThrow(); - expect(connectionManager.isFeatureAvailable('workItems')).toBe(undefined); + expect(connectionManager.isFeatureAvailable('workItems')).toBeNull(); }); it('should handle parallel initialization attempts', async () => { diff --git a/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts b/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts index 67edc9d6..f01c5f14 100644 --- a/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/InstanceCapabilities.test.ts @@ -24,6 +24,17 @@ const free17: CapabilityGate = { version: '17.0.0', tier: 'free' }; const premium17: CapabilityGate = { version: '17.0.0', tier: 'premium' }; const ultimate17: CapabilityGate = { version: '17.0.0', tier: 'ultimate' }; +// Missing billing visibility cannot stand for Free, while version/admin checks still apply. +test('an unknown namespace tier does not bypass version or admin requirements', () => { + const unknown: CapabilityGate = { version: '17.0.0', tier: null }; + expect(meetsRequirement({ tier: 'ultimate' }, unknown)).toBe(true); + expect(meetsRequirement({ tier: 'ultimate', minVersion: '18.0' }, unknown)).toBe(false); + expect(meetsRequirement({ requiresAdmin: true }, { ...unknown, adminModeActive: false })).toBe( + false, + ); + expect(getUnmetReason({ default: { tier: 'ultimate' } }, unknown)).toBeNull(); +}); + const reqs: ToolRequirements = { default: { tier: 'free', minVersion: '8.0' }, actions: { diff --git a/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts b/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts index 8f2d2fc0..8e724169 100644 --- a/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/NamespaceTierDetector.test.ts @@ -1,776 +1,317 @@ -/** - * Unit tests for NamespaceTierDetector - * - * Tests namespace tier detection, caching, and feature availability mapping. - * Mocks external dependencies (token context, fetch) for isolated unit testing. - */ - +/** REST subscription observations, credential isolation and bounded cache lifecycle. */ import { - getFeaturesForTier, + canResolveNamespaceTier, clearNamespaceTierCache, getNamespaceTier, - isFeatureAvailable, getNamespaceTierCacheMetrics, } from '../../../src/services/NamespaceTierDetector'; import * as tokenContext from '../../../src/oauth/token-context'; +import { isOAuthEnabled } from '../../../src/oauth/config'; import * as fetchUtils from '../../../src/utils/fetch'; +import { getGitLabBaseUrl } from '../../../src/utils/gitlab-base-url'; -// Mock dependencies jest.mock('../../../src/oauth/token-context'); +jest.mock('../../../src/oauth/config', () => ({ isOAuthEnabled: jest.fn(() => false) })); jest.mock('../../../src/utils/fetch'); +jest.mock('../../../src/utils/gitlab-base-url'); jest.mock('../../../src/logger', () => ({ logDebug: jest.fn(), logWarn: jest.fn(), logError: jest.fn(), logInfo: jest.fn(), })); -jest.mock('../../../src/config', () => ({ - GITLAB_BASE_URL: 'https://gitlab.com', -})); - -const mockedGetTokenContext = tokenContext.getTokenContext as jest.MockedFunction< - typeof tokenContext.getTokenContext ->; -const mockedEnhancedFetch = fetchUtils.enhancedFetch as jest.MockedFunction< - typeof fetchUtils.enhancedFetch ->; +const mockedGetTokenContext = jest.mocked(tokenContext.getTokenContext); +const mockedEnhancedFetch = jest.mocked(fetchUtils.enhancedFetch); describe('NamespaceTierDetector', () => { beforeEach(() => { - // Clear all mocks and cache before each test jest.clearAllMocks(); + jest.mocked(isOAuthEnabled).mockReturnValue(false); clearNamespaceTierCache(); - }); - - describe('getFeaturesForTier', () => { - // Tests feature availability mapping for each tier level - - it('should return correct features for free tier', () => { - const features = getFeaturesForTier('free'); - - // Free tier has basic features - expect(features.issues).toBe(true); - expect(features.mergeRequests).toBe(true); - expect(features.wiki).toBe(true); - expect(features.snippets).toBe(true); - - // Free tier lacks premium features - expect(features.epics).toBe(false); - expect(features.iterations).toBe(false); - expect(features.roadmaps).toBe(false); - expect(features.healthStatus).toBe(false); - expect(features.weight).toBe(false); - expect(features.requirements).toBe(false); - - // Free tier lacks ultimate features - expect(features.okrs).toBe(false); - expect(features.securityDashboard).toBe(false); - }); - - it('should return correct features for premium tier', () => { - const features = getFeaturesForTier('premium'); - - // Premium has all free features - expect(features.issues).toBe(true); - expect(features.mergeRequests).toBe(true); - expect(features.wiki).toBe(true); - - // Premium has additional planning features - expect(features.epics).toBe(true); - expect(features.iterations).toBe(true); - expect(features.roadmaps).toBe(true); - expect(features.healthStatus).toBe(true); - expect(features.weight).toBe(true); - expect(features.multiLevelEpics).toBe(true); - expect(features.portfolioManagement).toBe(true); - expect(features.requirements).toBe(true); - expect(features.complianceFramework).toBe(true); - - // Premium lacks ultimate-only features - expect(features.okrs).toBe(false); - expect(features.securityDashboard).toBe(false); - }); - - it('should return correct features for ultimate tier', () => { - const features = getFeaturesForTier('ultimate'); - - // Ultimate has all features enabled - expect(features.issues).toBe(true); - expect(features.mergeRequests).toBe(true); - expect(features.wiki).toBe(true); - expect(features.epics).toBe(true); - expect(features.iterations).toBe(true); - expect(features.roadmaps).toBe(true); - expect(features.okrs).toBe(true); - expect(features.healthStatus).toBe(true); - expect(features.weight).toBe(true); - expect(features.multiLevelEpics).toBe(true); - expect(features.portfolioManagement).toBe(true); - expect(features.requirements).toBe(true); - expect(features.securityDashboard).toBe(true); - expect(features.complianceFramework).toBe(true); - }); - - it('should return a copy of features (not a reference)', () => { - // Verify immutability - modifying returned object should not affect internal state - const features1 = getFeaturesForTier('free'); - features1.issues = false; // Modify returned object - - const features2 = getFeaturesForTier('free'); - expect(features2.issues).toBe(true); // Original should be unchanged + jest.mocked(getGitLabBaseUrl).mockImplementation(() => mockedGetTokenContext()?.apiUrl ?? ''); + jest.mocked(fetchUtils.getAuthHeaders).mockImplementation((): Record => { + const token = mockedGetTokenContext()?.gitlabToken; + return token ? { Authorization: `Bearer ${token}` } : {}; }); }); - describe('clearNamespaceTierCache', () => { - // Tests cache clearing functionality for session-specific and global clearing - - it('should clear cache for specific session', async () => { - // Setup: Create entries for two sessions - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'token-1', - gitlabUserId: 1, - gitlabUsername: 'user1', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'ultimate' } }, - }), - } as Response); - - await getNamespaceTier('namespace-a'); - await getNamespaceTier('namespace-b'); - - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-2', - gitlabToken: 'token-2', - gitlabUserId: 2, - gitlabUsername: 'user2', - apiUrl: 'https://gitlab.com', - }); - - await getNamespaceTier('namespace-c'); - - // Verify initial state - let metrics = getNamespaceTierCacheMetrics(); - expect(metrics.totalEntries).toBe(3); - expect(metrics.entriesBySession.get('session-1')).toBe(2); - expect(metrics.entriesBySession.get('session-2')).toBe(1); - - // Clear only session-1 - clearNamespaceTierCache('session-1'); - - // Verify session-1 entries cleared, session-2 remains - metrics = getNamespaceTierCacheMetrics(); - expect(metrics.totalEntries).toBe(1); - expect(metrics.entriesBySession.get('session-1')).toBeUndefined(); - expect(metrics.entriesBySession.get('session-2')).toBe(1); - }); - - it('should clear all cache when no session specified', async () => { - // Setup: Create entries for multiple sessions - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'token-1', - gitlabUserId: 1, - gitlabUsername: 'user1', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'premium' } }, - }), - } as Response); - - await getNamespaceTier('namespace-a'); - - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-2', - gitlabToken: 'token-2', - gitlabUserId: 2, - gitlabUsername: 'user2', - apiUrl: 'https://gitlab.com', - }); - - await getNamespaceTier('namespace-b'); - - // Verify entries exist - expect(getNamespaceTierCacheMetrics().totalEntries).toBe(2); + const context = { + sessionId: 'session-one', + gitlabToken: 'fixture-token', + gitlabUserId: 1, + gitlabUsername: 'fixture-user', + apiUrl: 'https://gitlab.com', + }; - // Clear all - clearNamespaceTierCache(); - - // Verify all cleared - expect(getNamespaceTierCacheMetrics().totalEntries).toBe(0); - }); + beforeEach(() => { + mockedGetTokenContext.mockReturnValue({ ...context }); + mockedEnhancedFetch.mockImplementation( + async () => new Response(JSON.stringify({ plan: 'premium' })), + ); }); - describe('getNamespaceTier', () => { - // Tests tier detection from GraphQL API with caching behavior - - it('should return free tier when no token context available', async () => { - // Tests fallback behavior when OAuth context is not set - mockedGetTokenContext.mockReturnValue(undefined); - - const result = await getNamespaceTier('some-namespace'); - - expect(result.tier).toBe('free'); - expect(result.features.epics).toBe(false); - expect(result.cachedAt).toBeInstanceOf(Date); - - // Should not make API call - expect(mockedEnhancedFetch).not.toHaveBeenCalled(); - }); - - it('should use context apiUrl when available', async () => { - // Tests that apiUrl from context is used for GraphQL endpoint - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'token', - gitlabUserId: 1, - gitlabUsername: 'user', - apiUrl: 'https://custom-gitlab.example.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'premium' } }, - }), - } as Response); - - await getNamespaceTier('some-namespace'); - - // Verify custom URL was used - expect(mockedEnhancedFetch).toHaveBeenCalledWith( - 'https://custom-gitlab.example.com/api/graphql', - expect.anything(), - ); - }); - - it('should query GraphQL and return correct tier for group namespace', async () => { - // Tests tier detection for a group with "ultimate" plan - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.example.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { - namespace: { - id: 'gid://gitlab/Group/1', - fullPath: 'my-group', - plan: 'ultimate', - }, - }, - }), - } as Response); - - const result = await getNamespaceTier('my-group'); - - expect(result.tier).toBe('ultimate'); - expect(result.features.okrs).toBe(true); - expect(result.features.securityDashboard).toBe(true); - expect(result.cachedAt).toBeInstanceOf(Date); - - // Verify GraphQL call - expect(mockedEnhancedFetch).toHaveBeenCalledWith( - 'https://gitlab.example.com/api/graphql', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - Authorization: 'Bearer test-token', - }), - }), - ); - }); - - it('should query GraphQL and return correct tier for project namespace', async () => { - // Tests tier detection for a project (uses group.plan from parent group) - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { - namespace: { - id: 'gid://gitlab/Project/42', - fullPath: 'my-group/my-project', - group: { - plan: 'premium', - }, - }, - }, - }), - } as Response); - - const result = await getNamespaceTier('my-group/my-project'); - - expect(result.tier).toBe('premium'); - expect(result.features.epics).toBe(true); - expect(result.features.okrs).toBe(false); // Premium doesn't have OKRs - }); - - it('should use cached result on subsequent calls', async () => { - // Tests that caching works - second call should not trigger API request - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'premium' } }, - }), - } as Response); - - // First call - should hit API - const result1 = await getNamespaceTier('cached-namespace'); - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); - - // Second call - should use cache - const result2 = await getNamespaceTier('cached-namespace'); - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); // Still 1, no new call - - // Results should be identical - expect(result1.tier).toBe(result2.tier); - expect(result1.cachedAt.getTime()).toBe(result2.cachedAt.getTime()); - }); - - // A chat switched to a linked account keeps the connection's session id; the tier its - // account sees must not come from the previous account's or instance's cache entry. - it('keeps the tiers of different accounts of one session apart', async () => { - const corp = { - sessionId: 'session-1', - gitlabToken: 'corp-token', - gitlabUserId: 7, - gitlabUsername: 'alice', - apiUrl: 'https://git.corp.example.com', - }; - mockedEnhancedFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { namespace: { plan: 'ultimate' } } }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { namespace: { plan: 'free' } } }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: { namespace: { plan: 'premium' } } }), - } as Response); - - mockedGetTokenContext.mockReturnValue(corp); - expect((await getNamespaceTier('group/project')).tier).toBe('ultimate'); - mockedGetTokenContext.mockReturnValue({ - ...corp, - gitlabToken: 'com-token', - gitlabUserId: 99, - apiUrl: 'https://gitlab.com', - }); - expect((await getNamespaceTier('group/project')).tier).toBe('free'); - mockedGetTokenContext.mockReturnValue({ ...corp, gitlabToken: 'bob-token', gitlabUserId: 8 }); - expect((await getNamespaceTier('group/project')).tier).toBe('premium'); - - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(3); - expect(getNamespaceTierCacheMetrics().entriesBySession.get('session-1')).toBe(3); - }); - - it('should expire cache after TTL (5 minutes)', async () => { - // Tests that cache entries are invalidated after TTL expires - jest.useFakeTimers(); - - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-ttl', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'ultimate' } }, - }), - } as Response); - - // First call - caches the result - await getNamespaceTier('ttl-test-namespace'); - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); - - // Advance time by 4 minutes - cache should still be valid - jest.advanceTimersByTime(4 * 60 * 1000); - await getNamespaceTier('ttl-test-namespace'); - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); // Still cached - - // Advance time past 5 minute TTL (total 6 minutes) - jest.advanceTimersByTime(2 * 60 * 1000); - - // This call should trigger a new API request because cache expired - await getNamespaceTier('ttl-test-namespace'); - expect(mockedEnhancedFetch).toHaveBeenCalledTimes(2); // New call after expiry - - jest.useRealTimers(); - }); - - it('should return free tier on API error', async () => { - // Tests graceful degradation when API call fails - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({}), - } as Response); - - const result = await getNamespaceTier('error-namespace'); - - expect(result.tier).toBe('free'); - expect(result.features.epics).toBe(false); - }); - - it('should return free tier on network error', async () => { - // Tests graceful degradation on network failures - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockRejectedValue(new Error('Network error')); - - const result = await getNamespaceTier('network-error-namespace'); - - expect(result.tier).toBe('free'); - }); - - it('should return free tier on GraphQL errors', async () => { - // Tests handling of GraphQL-level errors in response - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - errors: [{ message: 'Namespace not found' }], - data: { namespace: null }, - }), - } as Response); - - const result = await getNamespaceTier('nonexistent-namespace'); - - // When namespace not found, returns free tier - expect(result.tier).toBe('free'); - }); - - it('should normalize plan names correctly', async () => { - // Tests normalization of legacy plan names (gold, silver, bronze) - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - // Test "gold" -> "ultimate" - mockedEnhancedFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'gold' } }, - }), - } as Response); - - let result = await getNamespaceTier('gold-plan-group'); - expect(result.tier).toBe('ultimate'); - - // Clear cache for next test - clearNamespaceTierCache(); - - // Test "silver" -> "premium" - mockedEnhancedFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'silver' } }, - }), - } as Response); - - result = await getNamespaceTier('silver-plan-group'); - expect(result.tier).toBe('premium'); - - clearNamespaceTierCache(); - - // Test "bronze" -> "premium" (bronze/starter maps to premium features) - mockedEnhancedFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'bronze' } }, - }), - } as Response); - - result = await getNamespaceTier('bronze-plan-group'); - expect(result.tier).toBe('premium'); - - clearNamespaceTierCache(); - - // Test "starter" -> "premium" - mockedEnhancedFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'starter' } }, - }), - } as Response); - - result = await getNamespaceTier('starter-plan-group'); - expect(result.tier).toBe('premium'); - }); - - it('should return free tier for null/undefined plan', async () => { - // Tests handling of namespaces without a plan - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: null } }, - }), - } as Response); - - const result = await getNamespaceTier('no-plan-namespace'); - expect(result.tier).toBe('free'); - }); + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); }); - describe('getNamespaceTier with empty base URL', () => { - // Isolated tests for empty base URL scenario - it('should return free tier when both apiUrl and GITLAB_BASE_URL are empty', async () => { - // Use isolateModules to test with different config - await jest.isolateModulesAsync(async () => { - // Mock config with empty GITLAB_BASE_URL - jest.doMock('../../../src/config', () => ({ - GITLAB_BASE_URL: '', - })); - jest.doMock('../../../src/logger', () => ({ - logDebug: jest.fn(), - logWarn: jest.fn(), - logError: jest.fn(), - logInfo: jest.fn(), - })); - jest.doMock('../../../src/utils/fetch', () => ({ - enhancedFetch: jest.fn(), - })); - jest.doMock('../../../src/oauth/token-context', () => ({ - getTokenContext: jest.fn().mockReturnValue({ - sessionId: 'session-empty-url', - gitlabToken: 'token', - gitlabUserId: 1, - gitlabUsername: 'user', - apiUrl: '', // Empty apiUrl - }), - })); + // Plan visibility is permission-dependent, so absence and failure never establish Free. + test.each([ + ['free', 'free'], + ['default', 'free'], + ['premium', 'premium'], + ['ultimate', 'ultimate'], + ['gold', 'ultimate'], + ['silver', 'premium'], + ['bronze', null], + ['starter', null], + ['PREMIUM', 'premium'], + ['premium_trial', 'premium'], + ['ultimate_trial', 'ultimate'], + ['opensource', 'ultimate'], + [null, null], + [undefined, null], + ['unrecognized-plan', null], + ])('normalizes the REST subscription %s to %s', async (plan, expected) => { + mockedEnhancedFetch.mockImplementation(async () => new Response(JSON.stringify({ plan }))); + expect((await getNamespaceTier('team')).tier).toBe(expected); + }); - // Import fresh module with new mocks - const { getNamespaceTier: getNamespaceTierFresh } = - await import('../../../src/services/NamespaceTierDetector'); + // Only a string plan in an object establishes subscription evidence. + test.each([null, [], 'premium', { plan: 42 }, { plan: ['premium'] }])( + 'keeps malformed namespace metadata unknown: %j', + async (value) => { + mockedEnhancedFetch.mockResolvedValue(new Response(JSON.stringify(value))); + expect((await getNamespaceTier('team')).tier).toBeNull(); + }, + ); + + // Static-token mode has no OAuth user or session but still uses its own credential identity. + it('supports authenticated metadata without an OAuth context', async () => { + mockedGetTokenContext.mockReturnValue(undefined); + jest.mocked(getGitLabBaseUrl).mockReturnValue('https://gitlab.com'); + jest.mocked(fetchUtils.getAuthHeaders).mockReturnValue({ 'PRIVATE-TOKEN': 'fixture-static' }); + expect((await getNamespaceTier('team')).tier).toBe('premium'); + expect((await getNamespaceTier('team/subgroup')).tier).toBe('premium'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); + }); - const result = await getNamespaceTierFresh('test-namespace'); - expect(result.tier).toBe('free'); - }); - }); + // A response without a body still carries no billing evidence and needs no stream cancellation. + it('handles an unavailable metadata response without a body', async () => { + mockedEnhancedFetch.mockResolvedValue(new Response(null, { status: 403 })); + expect((await getNamespaceTier('team')).tier).toBeNull(); }); - describe('isFeatureAvailable', () => { - // Tests feature availability checking for specific namespaces + // Namespace limits are checked before allocating a provider response. + it('does not request an overlong root namespace', async () => { + expect((await getNamespaceTier('a'.repeat(256))).tier).toBeNull(); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); + }); - it('should return true for available features', async () => { - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-1', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); + // Malformed numeric metadata must not be interpreted as another target's namespace. + test.each(['project', 'group'] as const)('rejects malformed %s metadata', async (kind) => { + mockedEnhancedFetch.mockResolvedValue( + new Response(JSON.stringify({ path_with_namespace: 7, full_path: [] })), + ); + expect((await getNamespaceTier('7', kind)).tier).toBeNull(); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); + }); - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'ultimate' } }, - }), - } as Response); + // A valid group match must not fall through to a project with the same numeric ID. + it('resolves an ambiguous target as a group when the group exists', async () => { + mockedEnhancedFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ full_path: 'team/subgroup' }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ plan: 'ultimate' }))); + expect((await getNamespaceTier('7', 'either')).tier).toBe('ultimate'); + expect(mockedEnhancedFetch.mock.calls.map(([url]) => url)).toEqual([ + 'https://gitlab.com/api/v4/groups/7?with_projects=false', + 'https://gitlab.com/api/v4/namespaces/team', + ]); + }); - // Ultimate tier should have OKRs - const hasOkrs = await isFeatureAvailable('ultimate-group', 'okrs'); - expect(hasOkrs).toBe(true); + // Descendants share the root subscription and do not issue duplicate metadata requests. + it('uses the root REST entity and shares its observation with descendants', async () => { + const first = await getNamespaceTier('team/subgroup/project'); + expect(first.tier).toBe('premium'); + expect(first.cachedAt).toBeInstanceOf(Date); + expect((await getNamespaceTier('team/another')).tier).toBe('premium'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(1); + expect(mockedEnhancedFetch).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/namespaces/team', + expect.objectContaining({ + method: 'GET', + headers: { Authorization: 'Bearer fixture-token' }, + }), + ); + }); - // Ultimate tier should have security dashboard - const hasSecDash = await isFeatureAvailable('ultimate-group', 'securityDashboard'); - expect(hasSecDash).toBe(true); + // An account switch or token replacement must not reuse another credential's metadata. + it('separates accounts, credentials and instances within one session', async () => { + await getNamespaceTier('team'); + mockedGetTokenContext.mockReturnValue({ + ...context, + gitlabUserId: 2, + gitlabToken: 'second-token', }); - - it('should return false for unavailable features', async () => { - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-2', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'free' } }, - }), - } as Response); - - // Free tier should not have epics - const hasEpics = await isFeatureAvailable('free-group', 'epics'); - expect(hasEpics).toBe(false); - - // Free tier should not have OKRs - const hasOkrs = await isFeatureAvailable('free-group', 'okrs'); - expect(hasOkrs).toBe(false); + await getNamespaceTier('team'); + mockedGetTokenContext.mockReturnValue({ ...context, gitlabToken: 'rotated-token' }); + await getNamespaceTier('team'); + mockedGetTokenContext.mockReturnValue({ + ...context, + apiUrl: 'https://gitlab.example.com/subpath', }); + await getNamespaceTier('team'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(4); + expect(mockedEnhancedFetch).toHaveBeenLastCalledWith( + 'https://gitlab.example.com/subpath/api/v4/namespaces/team', + expect.any(Object), + ); + }); - it('should return false for unknown features', async () => { - // Tests that unknown feature names return false (safe default) - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-3', - gitlabToken: 'test-token', - gitlabUserId: 123, - gitlabUsername: 'testuser', - apiUrl: 'https://gitlab.com', - }); - - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'ultimate' } }, - }), - } as Response); + // Static-token connections use the transport's credential selection and isolate rotations. + it('uses the selected static credential when no OAuth context exists', async () => { + mockedGetTokenContext.mockReturnValue(undefined); + jest.mocked(getGitLabBaseUrl).mockReturnValue('https://gitlab.com'); + jest.mocked(fetchUtils.getAuthHeaders).mockReturnValue({ 'PRIVATE-TOKEN': 'static-one' }); + await getNamespaceTier('team'); + jest.mocked(fetchUtils.getAuthHeaders).mockReturnValue({ 'PRIVATE-TOKEN': 'static-two' }); + await getNamespaceTier('team'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(2); + expect(mockedEnhancedFetch).toHaveBeenLastCalledWith( + 'https://gitlab.com/api/v4/namespaces/team', + expect.objectContaining({ headers: { 'PRIVATE-TOKEN': 'static-two' } }), + ); + }); - // Even ultimate tier returns false for unknown features - const hasUnknown = await isFeatureAvailable('any-group', 'nonExistentFeature'); - expect(hasUnknown).toBe(false); + // Cache invalidation is scoped to the exact session, including separator characters. + it('clears one session without discarding another session', async () => { + await getNamespaceTier('team'); + const second = 'session:with:separators'; + mockedGetTokenContext.mockReturnValue({ ...context, sessionId: second }); + await getNamespaceTier('team'); + expect(getNamespaceTierCacheMetrics().totalEntries).toBe(2); + clearNamespaceTierCache(context.sessionId); + expect(getNamespaceTierCacheMetrics().entriesBySession).toEqual(new Map([[second, 1]])); + await getNamespaceTier('team'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(2); + clearNamespaceTierCache(); + expect(getNamespaceTierCacheMetrics()).toEqual({ + totalEntries: 0, + entriesBySession: new Map(), }); }); - describe('getNamespaceTierCacheMetrics', () => { - // Tests cache metrics reporting + // A changed subscription becomes visible after the documented five-minute TTL. + it('refreshes an expired observation', async () => { + jest.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') }); + await getNamespaceTier('team'); + jest.setSystemTime(new Date('2026-01-01T00:05:00.001Z')); + mockedEnhancedFetch.mockImplementation( + async () => new Response(JSON.stringify({ plan: 'ultimate' })), + ); + expect((await getNamespaceTier('team')).tier).toBe('ultimate'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(2); + }); - it('should return empty metrics for empty cache', () => { - const metrics = getNamespaceTierCacheMetrics(); + // Missing context must not trigger an unauthenticated request or claim a Free subscription. + it('returns unknown without usable authentication', async () => { + mockedGetTokenContext.mockReturnValue({ ...context, gitlabToken: '' }); + expect((await getNamespaceTier('team')).tier).toBeNull(); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); + }); - expect(metrics.totalEntries).toBe(0); - expect(metrics.entriesBySession.size).toBe(0); - }); + // Known insufficient OAuth scopes must avoid optional billing requests entirely. + it('returns unknown when the credential cannot read API metadata', async () => { + mockedGetTokenContext.mockReturnValue({ ...context, gitlabScopes: ['read_user'] }); + expect(canResolveNamespaceTier(context.apiUrl)).toBe(false); + expect((await getNamespaceTier('team')).tier).toBeNull(); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); + }); - it('should return correct metrics for populated cache', async () => { - // Setup: Add entries from multiple sessions - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'premium' } }, - }), - } as Response); + // OAuth mode must not borrow static credentials when no user token is selected. + it('returns unknown without an OAuth token context', async () => { + jest.mocked(isOAuthEnabled).mockReturnValue(true); + mockedGetTokenContext.mockReturnValue(undefined); + expect(canResolveNamespaceTier(context.apiUrl)).toBe(false); + expect((await getNamespaceTier('team')).tier).toBeNull(); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); + }); - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-a', - gitlabToken: 'token-a', - gitlabUserId: 1, - gitlabUsername: 'user-a', - apiUrl: 'https://gitlab.com', - }); + // Billing observations belong only to the authenticated transport's selected instance. + it('resolves metadata only for the selected instance', () => { + expect(canResolveNamespaceTier(context.apiUrl)).toBe(true); + expect(canResolveNamespaceTier('https://other.example')).toBe(false); + jest.mocked(getGitLabBaseUrl).mockReturnValue(''); + expect(canResolveNamespaceTier(context.apiUrl)).toBe(false); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); + }); - await getNamespaceTier('ns1'); - await getNamespaceTier('ns2'); - await getNamespaceTier('ns3'); + // Neither an HTTP error nor malformed provider output constitutes subscription evidence. + test.each([403, 404, 429, 500])('returns unknown for metadata HTTP %s', async (status) => { + mockedEnhancedFetch.mockImplementation(async () => new Response('fixture error', { status })); + expect((await getNamespaceTier('team')).tier).toBeNull(); + }); - mockedGetTokenContext.mockReturnValue({ - sessionId: 'session-b', - gitlabToken: 'token-b', - gitlabUserId: 2, - gitlabUsername: 'user-b', - apiUrl: 'https://gitlab.com', - }); + it('returns unknown for a network failure', async () => { + mockedEnhancedFetch.mockRejectedValue(new Error('fixture network failure')); + expect((await getNamespaceTier('team')).tier).toBeNull(); + }); - await getNamespaceTier('ns4'); - await getNamespaceTier('ns5'); + it('does not log malformed provider content', async () => { + mockedEnhancedFetch.mockImplementation( + async () => new Response('fixture-secret-must-not-appear'), + ); + expect((await getNamespaceTier('team')).tier).toBeNull(); + const warnings = jest.requireMock('../../../src/logger').logWarn.mock.calls; + expect(JSON.stringify(warnings)).not.toContain('fixture-secret-must-not-appear'); + }); - // Verify metrics - const metrics = getNamespaceTierCacheMetrics(); - expect(metrics.totalEntries).toBe(5); - expect(metrics.entriesBySession.get('session-a')).toBe(3); - expect(metrics.entriesBySession.get('session-b')).toBe(2); + // Numeric project IDs and group IDs occupy distinct spaces on the same instance. + it('resolves and caches a numeric project independently of a group with the same ID', async () => { + mockedEnhancedFetch.mockImplementation(async (input) => { + const url = String(input); + if (url.includes('/projects/7')) + return new Response(JSON.stringify({ path_with_namespace: 'paid/project' })); + if (url.includes('/groups/7?with_projects=false')) + return new Response(JSON.stringify({ full_path: 'free-team/subgroup' })); + return new Response(JSON.stringify({ plan: url.endsWith('/paid') ? 'ultimate' : 'free' })); }); + expect((await getNamespaceTier('7', 'project')).tier).toBe('ultimate'); + expect((await getNamespaceTier('7', 'group')).tier).toBe('free'); + expect((await getNamespaceTier('7', 'project')).tier).toBe('ultimate'); + expect(mockedEnhancedFetch).toHaveBeenCalledTimes(4); + }); - it('should correctly count entries by session ID', async () => { - // Tests that sessionId is correctly extracted from cache keys - mockedEnhancedFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - data: { namespace: { plan: 'free' } }, - }), - } as Response); - - mockedGetTokenContext.mockReturnValue({ - sessionId: 'complex-session-id-with-colons:and:special:chars', - gitlabToken: 'token', - gitlabUserId: 1, - gitlabUsername: 'user', - apiUrl: 'https://gitlab.com', - }); + // The existing namespace convention tries groups first, falling through only on 404. + it('resolves an ambiguous numeric namespace in group-first order', async () => { + mockedEnhancedFetch + .mockResolvedValueOnce(new Response('', { status: 404 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ path_with_namespace: 'team/project' }))) + .mockResolvedValueOnce(new Response(JSON.stringify({ plan: 'ultimate' }))); + expect((await getNamespaceTier('7', 'either')).tier).toBe('ultimate'); + expect(mockedEnhancedFetch.mock.calls.map(([url]) => url)).toEqual([ + 'https://gitlab.com/api/v4/groups/7?with_projects=false', + 'https://gitlab.com/api/v4/projects/7', + 'https://gitlab.com/api/v4/namespaces/team', + ]); + }); - await getNamespaceTier('test-namespace'); + // Optional metadata cannot grow without bound when many different targets are requested. + it('evicts old observations at the cache capacity', async () => { + await getNamespaceTier('oldest'); + await Promise.all( + Array.from({ length: 1024 }, (_, index) => getNamespaceTier('team-' + index)), + ); + expect(getNamespaceTierCacheMetrics().totalEntries).toBeLessThanOrEqual(1024); + mockedEnhancedFetch.mockImplementation( + async () => new Response(JSON.stringify({ plan: 'ultimate' })), + ); + expect((await getNamespaceTier('oldest')).tier).toBe('ultimate'); + }); - const metrics = getNamespaceTierCacheMetrics(); - // Entries are grouped by session id, so separators inside the id do not matter. - expect(metrics.totalEntries).toBe(1); - expect(metrics.entriesBySession.get('complex-session-id-with-colons:and:special:chars')).toBe( - 1, - ); - }); + // Invalid path syntax must be rejected before a metadata resolver sees it. + it('rejects path traversal before network access', async () => { + await expect(getNamespaceTier('../team')).rejects.toThrow('dot segment'); + expect(mockedEnhancedFetch).not.toHaveBeenCalled(); }); }); diff --git a/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts b/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts index 2d641107..fc9e2f47 100644 --- a/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/WidgetAvailability.test.ts @@ -50,6 +50,22 @@ describe('WidgetAvailability', () => { }); describe('widget availability by tier', () => { + // Missing billing visibility must not reject a supported widget or bypass its version gate. + it('keeps unknown namespace tiers distinct from Free', () => { + mockConnectionManager.getInstanceInfo.mockReturnValue({ + ...mockInstanceInfoFree, + tier: null, + }); + expect(WidgetAvailability.isWidgetAvailable(WorkItemWidgetTypes.WEIGHT)).toBe(true); + expect(WidgetAvailability.validateWidgetParams({ weight: 1 })).toBeNull(); + mockConnectionManager.getInstanceInfo.mockReturnValue({ + ...mockInstanceInfoFree, + version: '16.0', + tier: null, + }); + expect(WidgetAvailability.isWidgetAvailable(WorkItemWidgetTypes.CUSTOM_FIELDS)).toBe(false); + }); + describe('free tier widgets', () => { beforeEach(() => { mockConnectionManager.getInstanceInfo.mockReturnValue(mockInstanceInfoFree); diff --git a/packages/gitlab-mcp/tests/unit/services/namespace-rest.test.ts b/packages/gitlab-mcp/tests/unit/services/namespace-rest.test.ts new file mode 100644 index 00000000..750f12b4 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/services/namespace-rest.test.ts @@ -0,0 +1,44 @@ +import { + getNamespaceTier, + clearNamespaceTierCache, +} from '../../../src/services/NamespaceTierDetector'; +import { getTokenContext } from '../../../src/oauth/token-context'; +import { enhancedFetch } from '../../../src/utils/fetch'; +import { getAuthHeaders } from '../../../src/utils/fetch'; +import { getGitLabBaseUrl } from '../../../src/utils/gitlab-base-url'; + +jest.mock('../../../src/oauth/token-context'); +jest.mock('../../../src/utils/fetch'); +jest.mock('../../../src/utils/gitlab-base-url'); + +beforeEach(() => { + jest.clearAllMocks(); + clearNamespaceTierCache(); + jest.mocked(getGitLabBaseUrl).mockReturnValue('https://gitlab.com'); + jest.mocked(getAuthHeaders).mockReturnValue({ Authorization: 'Bearer fixture-token' }); + jest.mocked(getTokenContext).mockReturnValue({ + sessionId: 'session', + gitlabUserId: 1, + gitlabUsername: 'test', + gitlabToken: 'fixture-token', + apiUrl: 'https://gitlab.com', + }); +}); + +// A billing field omitted for a non-owner must not disable paid namespace tools. +test('an undisclosed plan remains unknown instead of Free', async () => { + jest + .mocked(enhancedFetch) + .mockResolvedValue(new Response(JSON.stringify({ id: 1, full_path: 'team' }))); + expect((await getNamespaceTier('team')).tier).toBeNull(); +}); + +// The real REST entity has no GraphQL data.namespace envelope. +test('reads the root subscription through the GitLab 16 REST contract', async () => { + jest.mocked(enhancedFetch).mockResolvedValue(new Response(JSON.stringify({ plan: 'ultimate' }))); + expect((await getNamespaceTier('team/subgroup/project')).tier).toBe('ultimate'); + expect(enhancedFetch).toHaveBeenCalledWith( + 'https://gitlab.com/api/v4/namespaces/team', + expect.objectContaining({ method: 'GET' }), + ); +}); diff --git a/packages/gitlab-mcp/tests/unit/utils/error-handler.test.ts b/packages/gitlab-mcp/tests/unit/utils/error-handler.test.ts index 14b4b21f..35af6d6a 100644 --- a/packages/gitlab-mcp/tests/unit/utils/error-handler.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/error-handler.test.ts @@ -20,7 +20,7 @@ import { // Mock functions that we'll configure per-test const mockGetTier = jest.fn(() => 'free'); -const mockIsFeatureAvailable = jest.fn((feature: string) => { +const mockIsFeatureAvailable = jest.fn((feature: string): boolean | null => { // Default: Free tier - Premium/Ultimate features unavailable const premiumFeatures = ['epics', 'iterations', 'serviceDesk', 'weight']; const ultimateFeatures = ['okrs', 'requirements', 'securityDashboard']; @@ -58,6 +58,29 @@ describe('Error Handler', () => { }); describe('handleGitLabError', () => { + // Unknown billing metadata must not turn a GitLab permission denial into an upgrade claim. + test.each([ + { tool: 'browse_iterations', args: {} }, + { tool: 'browse_webhooks', args: { scope: 'group' } }, + { tool: 'browse_work_items', args: { types: ['EPIC'] } }, + { tool: 'browse_work_items', args: { types: ['OBJECTIVE'] } }, + { tool: 'manage_work_item', args: { types: ['REQUIREMENT'] } }, + ])( + 'preserves permission denial with unknown capabilities for $tool $args', + ({ tool, args }) => { + mockGetTier.mockReturnValue('unknown'); + mockIsFeatureAvailable.mockReturnValue(null); + const result = handleGitLabError( + { status: 403, message: '403 Forbidden' }, + tool, + 'list', + args, + ); + expect(result.error_code).toBe('PERMISSION_DENIED'); + expect(result).not.toHaveProperty('tier_required'); + }, + ); + describe('403 Forbidden - Tier Restricted', () => { it('should create tier-restricted error for iterations on Free tier', () => { const error: GitLabApiErrorResponse = {