From 81bc16889b5f4d362cd8711d6def1fb755a73ae5 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 11 Oct 2026 00:25:11 +0300 Subject: [PATCH 01/11] feat(settings): add per-account settings, native settings page and connection panel Per-account and per-chat settings on top of the operator configuration, a native settings page and an MCP App panel to manage them. - Settings layers: the operator configuration stays the floor; account settings (preset, read-only, default scope, tool groups) apply to every chat of a GitLab account; manage_context overrides apply to the current chat only. The caller comes from the authenticated context. - Restrictions apply to the tool list and to every call. The working scope is a focus: named targets and destinations are checked, targetless listings, searches, project creation and mark_all_done are narrowed to the scope or refused, and objects addressed only by a global id are not resolved against it. - Tools: get_settings / update_settings (openai/settings), check_connection, open_settings_panel (MCP App resource gated by mcp:resources), find_scope_targets and manage_context clear_scope. - Storage: compare-and-set writes in the durable session storage (file or PostgreSQL, new oauth_account_settings table) shared by replicas, otherwise in ~/.config/gitlab-mcp/settings.json; an older database package falls back to that file. - Every REST call goes to the caller's GitLab instance: the OAuth token's, else the one the server's token works with now, else GITLAB_API_URL. - The channel gateway forwards resources and tools/list_changed and replays reads by the downstream catalog's readOnlyHint. - manage_context switch_preset, set_scope and reset now affect the current chat only. - Tests never touch the user's own settings file; integration tests updated for GitLab 19 and a slow test instance. Closes #637 --- .../migration.sql | 12 + packages/gitlab-mcp-db/prisma/schema.prisma | 11 + packages/gitlab-mcp-db/src/postgresql.ts | 58 + .../gitlab-mcp-db/tests/postgresql.test.ts | 87 ++ .../gitlab-mcp/docs/.vitepress/config.mts | 1 + .../docs/advanced/context-switching.md | 5 +- .../gitlab-mcp/docs/configuration/settings.md | 140 +++ packages/gitlab-mcp/eslint.config.mjs | 2 + packages/gitlab-mcp/jest.config.js | 3 +- packages/gitlab-mcp/package.json | 2 + .../gitlab-mcp/src/channel-gateway/gateway.ts | 99 +- .../gitlab-mcp/src/configuration/caller.ts | 64 ++ .../gitlab-mcp/src/configuration/groups.ts | 53 + .../gitlab-mcp/src/configuration/index.ts | 72 ++ .../gitlab-mcp/src/configuration/policy.ts | 162 +++ .../src/configuration/scope-targets.ts | 149 +++ .../gitlab-mcp/src/configuration/service.ts | 268 +++++ .../src/configuration/settings-store.ts | 147 +++ .../gitlab-mcp/src/configuration/types.ts | 44 + packages/gitlab-mcp/src/contracts/storage.ts | 1 + .../src/entities/context/connection-check.ts | 100 ++ .../src/entities/context/context-manager.ts | 484 +++----- .../src/entities/context/handlers.ts | 2 + .../src/entities/context/output-schema.ts | 5 + .../src/entities/context/registry.ts | 131 ++- .../gitlab-mcp/src/entities/context/schema.ts | 16 +- .../src/entities/context/scope-search.ts | 97 ++ .../src/entities/context/settings-panel.ts | 519 +++++++++ .../src/entities/context/settings.ts | 280 +++++ .../gitlab-mcp/src/entities/context/types.ts | 2 + .../gitlab-mcp/src/entities/context/whoami.ts | 30 +- .../gitlab-mcp/src/entities/core/registry.ts | 67 +- .../gitlab-mcp/src/entities/files/registry.ts | 5 +- .../src/entities/iterations/registry.ts | 5 +- .../src/entities/pipelines/registry.ts | 5 +- packages/gitlab-mcp/src/handlers.ts | 314 ++++- .../gitlab-mcp/src/oauth/session-store.ts | 31 + packages/gitlab-mcp/src/oauth/storage/file.ts | 43 +- .../gitlab-mcp/src/oauth/storage/memory.ts | 41 + .../gitlab-mcp/src/oauth/storage/types.ts | 16 + packages/gitlab-mcp/src/oauth/tool-auth.ts | 34 +- .../gitlab-mcp/src/profiles/scope-enforcer.ts | 40 +- packages/gitlab-mcp/src/registry-manager.ts | 35 + .../src/services/ConnectionManager.ts | 4 + .../src/services/TokenScopeDetector.ts | 2 + packages/gitlab-mcp/src/session-manager.ts | 34 +- packages/gitlab-mcp/src/utils/fetch.ts | 22 +- packages/gitlab-mcp/src/utils/gitlab-api.ts | 3 +- .../gitlab-mcp/src/utils/gitlab-base-url.ts | 39 + packages/gitlab-mcp/src/utils/namespace.ts | 49 +- .../gitlab-mcp/src/utils/smart-user-search.ts | 3 +- .../gitlab-mcp/src/utils/sync-directory.ts | 15 + .../tests/integration/data-lifecycle.test.ts | 7 +- .../schemas-dependent/merge-requests.test.ts | 6 +- .../tests/integration/schemas/context.test.ts | 13 +- .../integration/schemas/workitems.test.ts | 36 +- .../tests/setup/isolatedSettings.ts | 20 + packages/gitlab-mcp/tests/setup/setupTests.ts | 6 +- .../gitlab-mcp/tests/setup/setupTests.unit.ts | 2 + .../tests/unit/RegistryManager.test.ts | 11 + .../unit/channel-gateway/gateway.test.ts | 165 ++- .../tests/unit/configuration/index.test.ts | 150 +++ .../tests/unit/configuration/policy.test.ts | 247 ++++ .../tests/unit/configuration/replicas.test.ts | 149 +++ .../unit/configuration/scope-targets.test.ts | 168 +++ .../tests/unit/configuration/service.test.ts | 384 +++++++ .../unit/configuration/settings-store.test.ts | 200 ++++ .../entities/context/connection-check.test.ts | 203 ++++ .../entities/context/context-manager.test.ts | 1009 ++++++----------- .../unit/entities/context/handlers.test.ts | 28 + .../unit/entities/context/registry.test.ts | 9 + .../entities/context/scope-search.test.ts | 67 ++ .../entities/context/settings-panel.test.ts | 588 ++++++++++ .../entities/context/settings-tools.test.ts | 86 ++ .../unit/entities/context/settings.test.ts | 334 ++++++ .../unit/entities/context/whoami.test.ts | 1 + .../gitlab-mcp/tests/unit/handlers.test.ts | 486 +++++++- .../tests/unit/mcp-client-contract.test.ts | 58 +- .../tests/unit/oauth/session-store.test.ts | 38 + .../tests/unit/oauth/storage/file.test.ts | 56 + .../tests/unit/oauth/storage/memory.test.ts | 93 ++ .../unit/profiles/scope-enforcer.test.ts | 62 +- .../unit/services/TokenScopeDetector.test.ts | 3 + .../tests/unit/session-manager.test.ts | 36 +- .../tests/unit/utils/gitlab-base-url.test.ts | 68 ++ .../tests/unit/utils/instance-routing.test.ts | 94 ++ .../unit/utils/namespace.coverage.test.ts | 28 + yarn.lock | 318 +++++- 88 files changed, 7849 insertions(+), 1233 deletions(-) create mode 100644 packages/gitlab-mcp-db/prisma/migrations/20261010000000_account_settings/migration.sql create mode 100644 packages/gitlab-mcp/docs/configuration/settings.md create mode 100644 packages/gitlab-mcp/src/configuration/caller.ts create mode 100644 packages/gitlab-mcp/src/configuration/groups.ts create mode 100644 packages/gitlab-mcp/src/configuration/index.ts create mode 100644 packages/gitlab-mcp/src/configuration/policy.ts create mode 100644 packages/gitlab-mcp/src/configuration/scope-targets.ts create mode 100644 packages/gitlab-mcp/src/configuration/service.ts create mode 100644 packages/gitlab-mcp/src/configuration/settings-store.ts create mode 100644 packages/gitlab-mcp/src/configuration/types.ts create mode 100644 packages/gitlab-mcp/src/entities/context/connection-check.ts create mode 100644 packages/gitlab-mcp/src/entities/context/scope-search.ts create mode 100644 packages/gitlab-mcp/src/entities/context/settings-panel.ts create mode 100644 packages/gitlab-mcp/src/entities/context/settings.ts create mode 100644 packages/gitlab-mcp/src/utils/gitlab-base-url.ts create mode 100644 packages/gitlab-mcp/src/utils/sync-directory.ts create mode 100644 packages/gitlab-mcp/tests/setup/isolatedSettings.ts create mode 100644 packages/gitlab-mcp/tests/unit/configuration/index.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/configuration/policy.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/configuration/replicas.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/configuration/service.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/entities/context/connection-check.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/entities/context/scope-search.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/entities/context/settings-panel.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/entities/context/settings-tools.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/entities/context/settings.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/utils/gitlab-base-url.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/utils/instance-routing.test.ts diff --git a/packages/gitlab-mcp-db/prisma/migrations/20261010000000_account_settings/migration.sql b/packages/gitlab-mcp-db/prisma/migrations/20261010000000_account_settings/migration.sql new file mode 100644 index 00000000..93a6dc7a --- /dev/null +++ b/packages/gitlab-mcp-db/prisma/migrations/20261010000000_account_settings/migration.sql @@ -0,0 +1,12 @@ +-- Account settings: what an account chose in the settings page or through manage_context, +-- shared by every replica. Additive only: a new table. + +-- CreateTable +CREATE TABLE "oauth_account_settings" ( + "account_key" TEXT NOT NULL, + "settings" JSONB NOT NULL, + "version" INTEGER NOT NULL, + "updated_at" BIGINT NOT NULL, + + CONSTRAINT "oauth_account_settings_pkey" PRIMARY KEY ("account_key") +); diff --git a/packages/gitlab-mcp-db/prisma/schema.prisma b/packages/gitlab-mcp-db/prisma/schema.prisma index 8a846d67..eb7c2239 100644 --- a/packages/gitlab-mcp-db/prisma/schema.prisma +++ b/packages/gitlab-mcp-db/prisma/schema.prisma @@ -148,6 +148,17 @@ model OAuthClient { @@index([expiresAt], map: "idx_oauth_clients_expires_at") } +// Settings an account keeps across its sessions and restarts +model AccountSettings { + accountKey String @id @map("account_key") + settings Json + // Grows with every write; writes are compare-and-set on it. + version Int + updatedAt BigInt @map("updated_at") + + @@map("oauth_account_settings") +} + // MCP session to OAuth session mapping model McpSessionMapping { mcpSessionId String @id @map("mcp_session_id") diff --git a/packages/gitlab-mcp-db/src/postgresql.ts b/packages/gitlab-mcp-db/src/postgresql.ts index 2d629e9f..db3997ca 100644 --- a/packages/gitlab-mcp-db/src/postgresql.ts +++ b/packages/gitlab-mcp-db/src/postgresql.ts @@ -20,6 +20,8 @@ import type { GitLabTokenResponse, SessionStorageBackend, SessionStorageStats, + AccountSettings, + AccountSettingsRecord, } from '@structured-world/gitlab-mcp/storage-contract'; import { logInfo, logError, logDebug } from './logger'; @@ -47,6 +49,13 @@ interface PrismaOAuthSessionRow { updatedAt: bigint; } +interface PrismaAccountSettingsRow { + accountKey: string; + settings: unknown; + version: number; + updatedAt: bigint; +} + interface PrismaDeviceFlowStateRow { requestedGitlabScopes?: unknown; clientState?: string | null; @@ -216,6 +225,11 @@ interface GenericPrismaClient { deleteMany(args: unknown): Promise; count(): Promise; }; + accountSettings: { + create(args: unknown): Promise; + findUnique(args: unknown): Promise; + updateMany(args: unknown): Promise; + }; } export interface PostgreSQLStorageOptions { @@ -782,6 +796,50 @@ export class PostgreSQLStorageBackend implements SessionStorageBackend { }; } + // Account settings + async getAccountSettings(accountKey: string): Promise { + const prisma = this.getPrisma(); + const row = (await prisma.accountSettings.findUnique({ + where: { accountKey }, + })) as PrismaAccountSettingsRow | null; + if (!row) return undefined; + return { + accountKey: row.accountKey, + settings: row.settings as AccountSettings, + version: row.version, + updatedAt: Number(row.updatedAt), + }; + } + + async putAccountSettings( + accountKey: string, + settings: AccountSettings, + expectedVersion: number, + ): Promise { + const prisma = this.getPrisma(); + const version = expectedVersion + 1; + const updatedAt = Date.now(); + if (expectedVersion === 0) { + // First settings of the account: the primary key lets one of racing inserts win. + try { + await prisma.accountSettings.create({ + data: { accountKey, settings, version, updatedAt: BigInt(updatedAt) }, + }); + } catch (error: unknown) { + if ((error as { code?: unknown }).code === 'P2002') return undefined; + throw error; + } + } else { + // Compare-and-set in one statement: only the row still at the expected version. + const result = (await prisma.accountSettings.updateMany({ + where: { accountKey, version: expectedVersion }, + data: { settings, version, updatedAt: BigInt(updatedAt) }, + })) as PrismaBatchPayload; + if (result.count !== 1) return undefined; + } + return { accountKey, settings, version, updatedAt }; + } + // Cleanup async cleanup(): Promise { const prisma = this.getPrisma(); diff --git a/packages/gitlab-mcp-db/tests/postgresql.test.ts b/packages/gitlab-mcp-db/tests/postgresql.test.ts index c56b9566..9076e7b9 100644 --- a/packages/gitlab-mcp-db/tests/postgresql.test.ts +++ b/packages/gitlab-mcp-db/tests/postgresql.test.ts @@ -74,6 +74,11 @@ const createMockPrisma = () => ({ deleteMany: jest.fn().mockResolvedValue({ count: 0 }), count: jest.fn().mockResolvedValue(0), }, + accountSettings: { + create: jest.fn().mockResolvedValue({}), + findUnique: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, }); const createSession = (): OAuthSession => ({ @@ -953,4 +958,86 @@ describe('PostgreSQLStorageBackend', () => { expect(stats.authCodes).toBe(5); expect(stats.mcpSessionMappings).toBe(6); }); + + // Account settings are written compare-and-set in one statement, so of replicas + // writing the same version exactly one succeeds and a stale edit changes nothing. + describe('account settings', () => { + beforeEach(() => { + (backend as any).prisma = mockPrisma; + }); + + it('reads stored settings with their version', async () => { + mockPrisma.accountSettings.findUnique.mockResolvedValue({ + accountKey: 'acct', + settings: { readOnly: true }, + version: 3, + updatedAt: BigInt(42), + }); + + expect(await backend.getAccountSettings('acct')).toEqual({ + accountKey: 'acct', + settings: { readOnly: true }, + version: 3, + updatedAt: 42, + }); + expect(mockPrisma.accountSettings.findUnique).toHaveBeenCalledWith({ + where: { accountKey: 'acct' }, + }); + }); + + it('has no settings for an unknown account', async () => { + mockPrisma.accountSettings.findUnique.mockResolvedValue(null); + + expect(await backend.getAccountSettings('acct')).toBeUndefined(); + }); + + it('creates the first settings at version 1', async () => { + const stored = await backend.putAccountSettings('acct', { preset: 'pm' }, 0); + + expect(mockPrisma.accountSettings.create).toHaveBeenCalledWith({ + data: { + accountKey: 'acct', + settings: { preset: 'pm' }, + version: 1, + updatedAt: expect.any(BigInt), + }, + }); + expect(stored).toMatchObject({ settings: { preset: 'pm' }, version: 1 }); + }); + + // Two replicas creating the first settings: the primary key lets one insert win. + it('reports a lost race for the first settings as a conflict', async () => { + mockPrisma.accountSettings.create.mockRejectedValue( + Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }), + ); + + expect(await backend.putAccountSettings('acct', { preset: 'pm' }, 0)).toBeUndefined(); + }); + + it('propagates any other failure of the first write', async () => { + mockPrisma.accountSettings.create.mockRejectedValue(new Error('connection lost')); + + await expect(backend.putAccountSettings('acct', { preset: 'pm' }, 0)).rejects.toThrow( + 'connection lost', + ); + }); + + it('updates only the row still at the expected version', async () => { + mockPrisma.accountSettings.updateMany.mockResolvedValue({ count: 1 }); + + const stored = await backend.putAccountSettings('acct', { readOnly: false }, 4); + + expect(mockPrisma.accountSettings.updateMany).toHaveBeenCalledWith({ + where: { accountKey: 'acct', version: 4 }, + data: { settings: { readOnly: false }, version: 5, updatedAt: expect.any(BigInt) }, + }); + expect(stored).toMatchObject({ settings: { readOnly: false }, version: 5 }); + }); + + it('reports a stale version as a conflict', async () => { + mockPrisma.accountSettings.updateMany.mockResolvedValue({ count: 0 }); + + expect(await backend.putAccountSettings('acct', { readOnly: false }, 4)).toBeUndefined(); + }); + }); }); diff --git a/packages/gitlab-mcp/docs/.vitepress/config.mts b/packages/gitlab-mcp/docs/.vitepress/config.mts index 74552613..58569068 100644 --- a/packages/gitlab-mcp/docs/.vitepress/config.mts +++ b/packages/gitlab-mcp/docs/.vitepress/config.mts @@ -279,6 +279,7 @@ export default defineConfig({ text: "Configuration Reference", items: [ { text: "Instance Configuration", link: "/configuration/instances" }, + { text: "Settings", link: "/configuration/settings" }, { text: "Rate Limiting", link: "/configuration/rate-limiting" }, ], }, diff --git a/packages/gitlab-mcp/docs/advanced/context-switching.md b/packages/gitlab-mcp/docs/advanced/context-switching.md index 31c6c3f6..b0b9a767 100644 --- a/packages/gitlab-mcp/docs/advanced/context-switching.md +++ b/packages/gitlab-mcp/docs/advanced/context-switching.md @@ -76,7 +76,10 @@ In static token mode (using `GITLAB_TOKEN`), instance switching is allowed via ` 2. **Clear namespace cache** - Old tier data is invalid 3. **Re-introspect** - Fetch version and schema for new instance 4. **Re-validate tools** - Check tools against new schema -5. **Notify client** - Send `tools/list_changed` if tools changed +5. **Keep chat settings** - Read-only mode and presets set with `manage_context` stay with + every chat; working scopes are cleared, since they named projects of the previous + instance. Account settings are per instance (see [Settings](/configuration/settings)) +6. **Notify client** - Send `tools/list_changed` if tools changed ### Response Format diff --git a/packages/gitlab-mcp/docs/configuration/settings.md b/packages/gitlab-mcp/docs/configuration/settings.md new file mode 100644 index 00000000..7b7aeeab --- /dev/null +++ b/packages/gitlab-mcp/docs/configuration/settings.md @@ -0,0 +1,140 @@ +--- +title: Settings +description: "Per-account and per-chat settings for GitLab MCP Server: working preset, read-only mode, default project or group, and tool groups. Changed from the client's settings page, the connection panel, or with tools." +head: + - - meta + - name: keywords + content: settings, preset, read-only, scope, tool groups, manage_context, update_settings, MCP App +--- + +# Settings + +Each GitLab account that uses the server has its own settings, and each chat can change +them for itself. Settings only narrow what the server administrator allows; they never +widen it. + +## Three layers + +| Layer | Who sets it | Applies to | Kept | +|-------|-------------|------------|------| +| Server | Administrator: environment variables and startup presets | Everyone | Server configuration | +| Account | The user, with `update_settings` or the client's settings page | Every new chat of that GitLab account | Durably (see [Storage](#storage)) | +| Chat | The user or the assistant, with `manage_context` or the connection panel | The current chat only | Until the chat ends or is reset | + +The account is the GitLab user behind the connection: with OAuth, the user who signed in; +with a static token, the token's instance. Two users of one server never see each other's +settings, and a chat override never leaks into another chat. + +## What can be set + +| Setting | Effect | +|---------|--------| +| `preset` | A working preset from the server's presets, or `none` | +| `readOnly` | Tools that change GitLab are off. Cannot be turned off when the administrator enabled read-only mode. | +| `scope` | A project or group path to work in; empty to work everywhere you have access | +| `scopeIncludeSubgroups` | With a group scope, whether projects in its subgroups are included | +| `tools_` | Turn a tool group off or on, for example `tools_wiki` or `tools_pipelines`. Groups the administrator turned off are not offered. | + +The effective restrictions combine all layers: a chat value replaces the account value for +that chat, and read-only from any layer (including the preset) wins. + +Restrictions are applied when a tool runs, not only to the tool list: a direct call to a +tool the settings turn off, or to a project or group outside the working scope, is refused +before anything reaches GitLab. A scope of projects reaches no group, so group operations +are refused under it. + +A listing or search that names no project or group reads the working scope instead of +everything the account can see: a global search becomes a search of the scope's group or +project, a project listing lists the scope's group, a cross-project merge request listing +lists the scope's project, and results that cannot be filtered at GitLab (project listings +and searches, including projects only shared with the scope's group, todos, merge requests +under a group scope) are filtered to the scope. A global search is refused under a scope of +several projects or groups, and under a group scope without subgroups (GitLab's group +search always includes them); search within one project or group instead. `manage_todos +mark_all_done` marks only the scope's pending todos done and leaves the others pending. A +project created without a namespace goes into the scope's group; with no single group to +put it in, and for a group without a parent, the call is refused. + +The working scope is a focus, not an access boundary: the user sets and clears it. Calls +that name an object only by its global id (a work item or todo id) are not checked against +the scope. To limit what the server can reach, give it a project or group access token; +GitLab enforces that on every call. + +## Changing settings + +### Clients with a settings page + +Clients that render server settings natively (for example Codex) show the account settings +as a settings page for the connection. The server advertises it with the `openai/settings` +capability; the page reads `get_settings` and saves with `update_settings`. + +### The connection panel + +Clients that support MCP Apps show the connection panel when the `open_settings_panel` +tool runs. In the panel you can: + +- search your projects and groups and choose where to work, for this chat + (**Use in this chat**) or as the default for new chats (**Save for new chats**), or let + this chat work everywhere (**Work everywhere in this chat**; the default for new chats and + the chat's preset and read-only mode stay); +- see what the current chat can do: preset, read-only mode and tool groups that are off; +- check the connection: account, GitLab version and tier, available tools, and what to do + about problems such as an expired sign-in. + +The panel loads nothing from the network and calls only this server's tools through the +client, with your own authorization. Success is reported from the settings the server +holds after saving, not from the request. + +### Any client + +Every setting can also be changed with tools, so clients without a settings page or app +panels, and the assistant itself, can use them: + +```json +// get_settings: current values and what each can be set to +{} + +// update_settings: change only the listed settings for new chats +{ "set": { "scope": "my-group", "scopeIncludeSubgroups": true, "tools_wiki": false } } + +// manage_context: change only this chat +{ "action": "set_scope", "namespace": "my-group/my-project" } +{ "action": "clear_scope" } +{ "action": "switch_preset", "preset": "readonly" } +{ "action": "reset" } +``` + +`update_settings` validates every value before saving anything; an invalid value saves +nothing. `manage_context clear_scope` lets the chat work everywhere the account has access, +also when the account has a default scope; its preset and read-only mode stay. +`manage_context reset` drops all of the chat's overrides, so the chat uses the account +settings again. + +`check_connection` reports the account, the instance, the restrictions in effect and +recommendations, and `find_scope_targets` finds projects and groups to scope to. + +## Storage + +| Deployment | Account settings are kept in | +|------------|------------------------------| +| OAuth with file or PostgreSQL session storage (`OAUTH_STORAGE_TYPE`) | The session storage, shared by every replica that uses it | +| OAuth with sessions in memory (the default), or a local server with a static token | `~/.config/gitlab-mcp/settings.json` of the user running the server, shared by every server process of that user; settings survive a restart even though memory sessions do not | + +Writes are compare-and-set: two chats editing different settings at the same time both +keep their change. Chat overrides are held by the server process that serves the chat. + +If a saved preset is later removed from the server, chats that use it work read-only until +another preset is chosen, and `check_connection` reports it. + +## Updates in open chats + +When settings change, the server sends `tools/list_changed` to the chats they affect, so +clients refresh the tool list. With several replicas, only chats served by the replica that +saved the change are notified; chats on other replicas apply the new settings from their +next request, but their clients refresh the tool list only on their next list request. + +## Related Documentation + +- [Read-Only Mode](/security/read-only) - Server-wide read-only mode +- [Context Switching](/advanced/context-switching) - Switching GitLab instances +- [Instance Configuration](/configuration/instances) - Configuring GitLab instances diff --git a/packages/gitlab-mcp/eslint.config.mjs b/packages/gitlab-mcp/eslint.config.mjs index ad234021..3942f604 100644 --- a/packages/gitlab-mcp/eslint.config.mjs +++ b/packages/gitlab-mcp/eslint.config.mjs @@ -75,6 +75,7 @@ export default [ clearInterval: 'readonly', clearTimeout: 'readonly', fetch: 'readonly', + structuredClone: 'readonly', // Node.js CommonJS globals __dirname: 'readonly', __filename: 'readonly', @@ -132,6 +133,7 @@ export default [ clearInterval: 'readonly', clearTimeout: 'readonly', fetch: 'readonly', + structuredClone: 'readonly', // Jest globals describe: 'readonly', it: 'readonly', diff --git a/packages/gitlab-mcp/jest.config.js b/packages/gitlab-mcp/jest.config.js index 34c18bcc..935b1504 100644 --- a/packages/gitlab-mcp/jest.config.js +++ b/packages/gitlab-mcp/jest.config.js @@ -62,7 +62,8 @@ module.exports = { ...(integrationTestsEnabled && { testSequencer: '/tests/setup/sequencer.js', maxWorkers: 1, // Serial execution for lifecycle tests - testTimeout: 30000, // Longer timeout for API calls + // A shared test instance can be slow; a test waits up to a minute for its API calls. + testTimeout: 60000, }), ...(!integrationTestsEnabled && { testTimeout: 10000, // Standard timeout for unit tests diff --git a/packages/gitlab-mcp/package.json b/packages/gitlab-mcp/package.json index b4bfa6f4..a28de088 100644 --- a/packages/gitlab-mcp/package.json +++ b/packages/gitlab-mcp/package.json @@ -591,6 +591,7 @@ "@structured-world/vue-privacy": "^1.11.0", "@types/express": "^5.0.6", "@types/jest": "^30.0.0", + "@types/jsdom": "^21.1.7", "@types/node": "^25.9.9", "@types/picomatch": "^4.0.3", "@typescript-eslint/eslint-plugin": "^8.71.1", @@ -602,6 +603,7 @@ "eslint": "^10.12.0", "eslint-plugin-prettier": "^5.5.6", "jest": "^30.5.2", + "jsdom": "^26.1.0", "prettier": "^3.9.9", "ts-jest": "^29.4.14", "ts-node": "^10.9.2", diff --git a/packages/gitlab-mcp/src/channel-gateway/gateway.ts b/packages/gitlab-mcp/src/channel-gateway/gateway.ts index 6cbf5a2e..03cde334 100644 --- a/packages/gitlab-mcp/src/channel-gateway/gateway.ts +++ b/packages/gitlab-mcp/src/channel-gateway/gateway.ts @@ -16,10 +16,14 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, + ListResourcesRequestSchema, ListToolsRequestSchema, + ReadResourceRequestSchema, + ToolListChangedNotificationSchema, type CallToolResult, + type ListResourcesResult, type ListToolsResult, - type ListToolsRequest, + type ReadResourceResult, McpError, ErrorCode, } from '@modelcontextprotocol/sdk/types.js'; @@ -61,13 +65,22 @@ export class ChannelGateway { private reconnecting = false; private closing = false; private pendingWaiters = 0; + /** + * Whether the downstream catalog marks a tool read-only (safe to replay after a lost + * link), with the catalog read that said so. Catalog pages arrive independently, so each + * page updates only its own tools, and an older read never overrides a newer one. + */ + private readonly catalogReads = new Map(); + private catalogGeneration = 0; constructor(private readonly config: GatewayConfig) { this.server = new Server( { name: config.name ?? 'gitlab-ci-gateway', version: config.version ?? '0.1.0' }, { capabilities: { - tools: {}, // re-exposes the downstream catalog + // Re-exposes the downstream catalog, which changes with the caller's settings. + tools: { listChanged: true }, + resources: {}, // and the ui:// resources its tools point at (settings panel) experimental: { 'claude/channel': {} }, // allowed to push channel events }, instructions: @@ -99,7 +112,12 @@ export class ChannelGateway { } private newClient(): Client { - return new Client({ name: this.config.name ?? 'gitlab-ci-gateway', version: '0.1.0' }); + const client = new Client({ name: this.config.name ?? 'gitlab-ci-gateway', version: '0.1.0' }); + // The host lists the catalog again only when told it changed. + client.setNotificationHandler(ToolListChangedNotificationSchema, () => { + void this.server.sendToolListChanged().catch(() => {}); + }); + return client; } private registerHandlers(): void { @@ -107,21 +125,67 @@ export class ChannelGateway { // The catalog read must honour the same reconnect/buffer policy as // CallTool: a ListTools that lands mid-reconnect should wait for the // link (bounded) and replay once, not throw against a dead client. - return (await forwardWithPolicy( - { - isRead: () => true, // listing the catalog is an idempotent read - isConnected: () => this.connected, - waitForConnection: () => this.waitForConnection(), - call: () => this.listDownstream(request.params), - }, - 'tools/list', - undefined, + const generation = ++this.catalogGeneration; + const catalog = (await this.readDownstream('tools/list', (client) => + client.listTools(request.params), )) as ListToolsResult; + this.recordCatalogReads(catalog, generation); + return catalog; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; return (await this.interceptor.handleCall(name, args ?? {})) as CallToolResult; }); + // Resource reads are idempotent like the catalog read: same bounded reconnect policy. + this.server.setRequestHandler(ListResourcesRequestSchema, async (request) => { + return (await this.readDownstream('resources/list', (client) => + client.listResources(request.params), + )) as ListResourcesResult; + }); + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + return (await this.readDownstream('resources/read', (client) => + client.readResource(request.params), + )) as ReadResourceResult; + }); + } + + private recordCatalogReads(catalog: ListToolsResult, generation: number): void { + for (const tool of catalog.tools) { + const known = this.catalogReads.get(tool.name); + if (known && known.generation > generation) continue; + // A tool without the annotation is classified by its name, as before it was listed. + const read = tool.annotations?.readOnlyHint ?? isReadCall(tool.name); + this.catalogReads.set(tool.name, { read, generation }); + } + } + + /** Whether a call is a read: the catalog's annotation, else the name for an unlisted tool. */ + private isRead(name: string): boolean { + return this.catalogReads.get(name)?.read ?? isReadCall(name); + } + + /** An idempotent downstream read under the reconnect/buffer policy. */ + private readDownstream( + name: string, + read: (client: Client) => Promise, + ): Promise { + return forwardWithPolicy( + { + isRead: () => true, + isConnected: () => this.connected, + waitForConnection: () => this.waitForConnection(), + call: async () => { + const client = this.client; + try { + return await read(client); + } catch (error) { + this.rethrowDownstreamError(client, error); + } + }, + }, + name, + undefined, + ); } /** Establish the downstream session, retrying with exponential backoff. */ @@ -199,7 +263,7 @@ export class ChannelGateway { private forward(name: string, args: unknown): Promise { return forwardWithPolicy( { - isRead: isReadCall, + isRead: (name) => this.isRead(name), isConnected: () => this.connected, waitForConnection: () => this.waitForConnection(), call: (n, a) => this.callDownstream(n, a), @@ -218,15 +282,6 @@ export class ChannelGateway { } } - private async listDownstream(params: ListToolsRequest['params']): Promise { - const client = this.client; - try { - return await client.listTools(params); - } catch (error) { - this.rethrowDownstreamError(client, error); - } - } - private rethrowDownstreamError(client: Client, error: unknown): never { // The SDK clears THIS client's transport when rejecting in-flight requests // with ConnectionClosed. A replacement client or unrelated close is irrelevant. diff --git a/packages/gitlab-mcp/src/configuration/caller.ts b/packages/gitlab-mcp/src/configuration/caller.ts new file mode 100644 index 00000000..02df034b --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/caller.ts @@ -0,0 +1,64 @@ +/** + * Who a request comes from, derived from the authenticated request only: the GitLab + * account of the OAuth token, or the operator's token in static-token mode. A client + * never names the account it acts for. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { getTokenContext } from '../oauth/token-context'; +import { normalizeInstanceUrl } from '../utils/url'; + +export interface Caller { + /** Account whose settings apply; shared by its sessions and replicas. */ + accountKey: string; + /** MCP session of the request; session overrides apply to it only. */ + sessionKey: string; + /** Display name of the account, never a credential. */ + accountLabel: string; + instanceUrl: string; + /** Whether the account is a GitLab user signed in through OAuth. */ + oauth: boolean; +} + +/** Session key of a request that carries no MCP session id (the stdio transport). */ +const SESSIONLESS = 'stdio'; + +/** + * The caller of the current request. In OAuth mode the account is the GitLab user the + * token was issued for on its instance; otherwise every request uses the operator's token, + * so the account is that token's instance. + */ +export function resolveCaller(sessionId: string | undefined, instanceUrl: string): Caller { + const token = getTokenContext(); + const sessionKey = sessionId ?? SESSIONLESS; + if (token) { + const instance = normalizeInstanceUrl(token.apiUrl); + return { + accountKey: `gitlab:${instance}#${token.gitlabUserId}`, + sessionKey, + accountLabel: token.gitlabUsername, + instanceUrl: instance, + oauth: true, + }; + } + const instance = normalizeInstanceUrl(instanceUrl); + return { + accountKey: `token:${instance}`, + sessionKey, + accountLabel: 'Configured access token', + instanceUrl: instance, + oauth: false, + }; +} + +const callerStorage = new AsyncLocalStorage(); + +/** Run `fn` with `caller` as the caller of everything it does. */ +export function runWithCaller(caller: Caller, fn: () => T): T { + return callerStorage.run(caller, fn); +} + +/** The caller of the running tool call; undefined outside one. */ +export function currentCaller(): Caller | undefined { + return callerStorage.getStore(); +} diff --git a/packages/gitlab-mcp/src/configuration/groups.ts b/packages/gitlab-mcp/src/configuration/groups.ts new file mode 100644 index 00000000..4c6641a7 --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/groups.ts @@ -0,0 +1,53 @@ +/** + * Tool groups an account can switch off. A group's id is the preset `features` key of the + * same tools, so a preset and the account settings name groups the same way. Core and + * context tools belong to no group: they are always available. + */ + +export interface ToolGroup { + id: string; + title: string; + /** Registry keys of the group's tools (see RegistryManager). */ + registries: readonly string[]; +} + +export const TOOL_GROUPS: readonly ToolGroup[] = [ + { id: 'mrs', title: 'Merge requests', registries: ['mrs'] }, + { id: 'workitems', title: 'Issues and work items', registries: ['workitems'] }, + { id: 'pipelines', title: 'Pipelines and jobs', registries: ['pipelines'] }, + { id: 'files', title: 'Repository files', registries: ['files'] }, + { id: 'refs', title: 'Branches and tags', registries: ['refs'] }, + { id: 'releases', title: 'Releases', registries: ['releases'] }, + { id: 'labels', title: 'Labels', registries: ['labels'] }, + { id: 'milestones', title: 'Milestones', registries: ['milestones'] }, + { id: 'iterations', title: 'Iterations', registries: ['iterations'] }, + { id: 'wiki', title: 'Wiki', registries: ['wiki'] }, + { id: 'snippets', title: 'Snippets', registries: ['snippets'] }, + { id: 'search', title: 'Search', registries: ['search'] }, + { id: 'members', title: 'Members', registries: ['members'] }, + { id: 'variables', title: 'CI/CD variables', registries: ['variables'] }, + { id: 'environments', title: 'Environments', registries: ['environments'] }, + { id: 'runners', title: 'Runners', registries: ['runners'] }, + { id: 'registry', title: 'Container registry', registries: ['registry'] }, + { + id: 'ci_tokens', + title: 'CI job tokens and deploy keys', + registries: ['job-token-scope', 'deploy-keys'], + }, + { id: 'access_tokens', title: 'Access tokens', registries: ['access_tokens'] }, + { id: 'webhooks', title: 'Webhooks', registries: ['webhooks'] }, + { id: 'integrations', title: 'Integrations', registries: ['integrations'] }, + { id: 'audit_events', title: 'Audit events', registries: ['audit_events'] }, + { id: 'vulnerabilities', title: 'Vulnerabilities', registries: ['vulnerabilities'] }, +]; + +const GROUP_OF_REGISTRY = new Map( + TOOL_GROUPS.flatMap((group) => group.registries.map((key) => [key, group.id] as const)), +); + +export const TOOL_GROUP_IDS: ReadonlySet = new Set(TOOL_GROUPS.map((g) => g.id)); + +/** Group of the tools of a registry; undefined for core and context tools. */ +export function groupOfRegistry(registryKey: string | undefined): string | undefined { + return registryKey === undefined ? undefined : GROUP_OF_REGISTRY.get(registryKey); +} diff --git a/packages/gitlab-mcp/src/configuration/index.ts b/packages/gitlab-mcp/src/configuration/index.ts new file mode 100644 index 00000000..6bc0e1ba --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/index.ts @@ -0,0 +1,72 @@ +/** + * The process's configuration service, wired to the settings store of the deployment, + * the preset loader and per-session tool-list notifications. + */ + +import { logWarn } from '../logger'; +import { isOAuthEnabled } from '../oauth/config'; +import { sessionStore } from '../oauth/session-store'; +import { ProfileLoader } from '../profiles/loader'; +import { ConfigurationService } from './service'; +import { LocalSettingsFile, localSettingsPath, type SettingsStore } from './settings-store'; + +export { ConfigurationError } from './service'; +export type { ResolvedConfiguration } from './service'; +export { resolveCaller, runWithCaller, currentCaller, type Caller } from './caller'; + +let settingsStore: Promise | undefined; + +/** + * Settings live with the sessions when that storage can keep them (file or PostgreSQL), + * shared by every replica using it. Otherwise (a local server, sessions held in memory as + * the OAuth default, or a database package older than the server) they live in the + * settings file, so saved settings survive a restart and calls keep working. + */ +function storeOfDeployment(): Promise { + settingsStore ??= (async (): Promise => { + const sessionsStored = isOAuthEnabled() || Boolean(process.env.OAUTH_STORAGE_TYPE); + if (sessionsStored && sessionStore.keepsAccountSettings()) { + await sessionStore.initialize(); + return { + get: (accountKey) => sessionStore.getAccountSettings(accountKey), + put: (accountKey, settings, expectedVersion) => + sessionStore.putAccountSettings(accountKey, settings, expectedVersion), + }; + } + if (sessionsStored && sessionStore.getBackendType() !== 'memory') { + logWarn( + 'The session storage cannot keep account settings; upgrade @structured-world/gitlab-mcp-db to the version of this server. Settings are kept in the settings file meanwhile.', + { backend: sessionStore.getBackendType() }, + ); + } + return new LocalSettingsFile(localSettingsPath()); + })().catch((error: unknown) => { + // A failed open is retried by the next request instead of failing until restart. + settingsStore = undefined; + throw error; + }); + return settingsStore; +} + +let service: ConfigurationService | undefined; + +export function getConfigurationService(): ConfigurationService { + if (!service) { + const loader = new ProfileLoader(); + service = new ConfigurationService( + storeOfDeployment, + { load: (name) => loader.loadPreset(name) }, + async (sessionKeys) => { + const { getSessionManager } = await import('../session-manager'); + await getSessionManager().notifyToolsListChanged(sessionKeys); + }, + ); + } + return service; +} + +/** Drop the service and its store (tests). */ +export function resetConfigurationService(): void { + service = undefined; + settingsStore = undefined; +} diff --git a/packages/gitlab-mcp/src/configuration/policy.ts b/packages/gitlab-mcp/src/configuration/policy.ts new file mode 100644 index 00000000..0a9b4de0 --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/policy.ts @@ -0,0 +1,162 @@ +/** + * The restrictions a caller's preset, account settings and session overrides put on + * tool use. They narrow what the operator's configuration already filtered at the + * registry; nothing here can bring back a tool or action the operator removed. + */ + +import type { Preset, ScopeConfig } from '../profiles/types'; +import { enforceArgsScope, ScopeEnforcer, ScopeViolationError } from '../profiles/scope-enforcer'; +import type { AccountSettings, WorkingScope } from './types'; +import { targetlessRestriction } from './scope-targets'; + +/** A session's choice to work without the account's default scope. */ +export const EVERYWHERE = 'everywhere'; + +/** Overrides of the current MCP session; they replace the account's choice for it. */ +export interface SessionOverrides { + preset?: string; + readOnly?: boolean; + scope?: WorkingScope | typeof EVERYWHERE; + /** Local profile chosen with manage_context switch_profile (OAuth mode). */ + profile?: string; +} + +export interface EffectivePolicy { + presetName?: string; + readOnly: boolean; + disabledGroups: ReadonlySet; + /** Preset whitelist; undefined allows every tool. */ + allowedTools?: ReadonlySet; + deniedTools?: RegExp; + /** Tool name -> actions the preset denies. */ + deniedActions: ReadonlyMap>; + scope?: ScopeConfig; + scopeEnforcer?: ScopeEnforcer; +} + +/** + * Tools that configure or diagnose the caller's own connection. Settings never restrict + * them, and they answer while GitLab is unreachable: that is when they are needed. + */ +export const CONFIGURATION_TOOLS: ReadonlySet = new Set([ + 'manage_context', + 'get_settings', + 'update_settings', + 'check_connection', + 'open_settings_panel', + // Choosing a new scope means searching outside the current one. + 'find_scope_targets', +]); + +export function scopeConfigOf(scope: WorkingScope): ScopeConfig { + return scope.type === 'project' + ? { project: scope.path } + : { group: scope.path, includeSubgroups: scope.includeSubgroups }; +} + +/** The preset the caller works with: the session's choice, else the account's. */ +export function selectedPreset( + account: AccountSettings, + session: SessionOverrides, +): string | undefined { + return session.preset ?? account.preset; +} + +/** + * Combine the selected preset (already loaded), the account settings and the session + * overrides. A session's own choice of preset, read-only mode or scope replaces the + * account's; groups the account switched off stay off in every session. + */ +export function buildPolicy( + preset: Preset | undefined, + account: AccountSettings, + session: SessionOverrides, +): EffectivePolicy { + const disabledGroups = new Set(account.disabledToolGroups ?? []); + for (const [feature, enabled] of Object.entries(preset?.features ?? {})) { + if (enabled === false) disabledGroups.add(feature); + } + const deniedActions = new Map>(); + for (const entry of preset?.denied_actions ?? []) { + const separator = entry.indexOf(':'); + if (separator <= 0) continue; + const tool = entry.slice(0, separator); + const actions = deniedActions.get(tool) ?? new Set(); + actions.add(entry.slice(separator + 1)); + deniedActions.set(tool, actions); + } + // A preset's own scope belongs to the preset and still applies when the chat works + // everywhere; only the account's default scope is dropped. + const working = session.scope ?? account.scope; + const scope = working && working !== EVERYWHERE ? scopeConfigOf(working) : preset?.scope; + return { + presetName: selectedPreset(account, session), + readOnly: (session.readOnly ?? account.readOnly ?? false) || preset?.read_only === true, + disabledGroups, + allowedTools: preset?.allowed_tools?.length ? new Set(preset.allowed_tools) : undefined, + deniedTools: preset?.denied_tools_regex ? new RegExp(preset.denied_tools_regex) : undefined, + deniedActions, + scope, + scopeEnforcer: scope ? new ScopeEnforcer(scope) : undefined, + }; +} + +/** What a tool is, as far as the policy is concerned. */ +export interface ToolFacts { + name: string; + /** Tool group id; undefined for core and context tools. */ + group?: string; + readOnly: boolean; +} + +/** Why the policy hides a tool from the caller, or null when it may use it. */ +export function toolRestriction(policy: EffectivePolicy, tool: ToolFacts): string | null { + if (CONFIGURATION_TOOLS.has(tool.name)) return null; + if (policy.readOnly && !tool.readOnly) return 'read-only mode is on'; + if (tool.group !== undefined && policy.disabledGroups.has(tool.group)) { + return `the '${tool.group}' tool group is turned off`; + } + if (policy.allowedTools && !policy.allowedTools.has(tool.name)) { + return `preset '${policy.presetName}' does not include it`; + } + if (policy.deniedTools?.test(tool.name)) { + return `preset '${policy.presetName}' excludes it`; + } + return null; +} + +/** + * Why the policy refuses this call, or null when it may run: the tool restriction, an + * action the preset denies, or a project or group outside the working scope. + */ +export function callRestriction( + policy: EffectivePolicy, + tool: ToolFacts, + args: Record, +): string | null { + const restriction = toolRestriction(policy, tool); + if (restriction) return restriction; + if (CONFIGURATION_TOOLS.has(tool.name)) return null; + const action = typeof args.action === 'string' ? args.action : undefined; + if (action !== undefined && policy.deniedActions.get(tool.name)?.has(action)) { + return `preset '${policy.presetName}' denies the '${action}' action`; + } + // The working scope is a focus, not an access boundary: the user sets and clears it. Named + // targets and destinations are checked here and targetless calls are narrowed or refused, + // but an object addressed only by its global id (a work item id, a todo id) or a namespace + // that could be a project or a group is not resolved through GitLab first. A hard boundary + // is a project or group access token, which GitLab enforces on every call. + if (policy.scopeEnforcer) { + try { + enforceArgsScope(policy.scopeEnforcer, args); + } catch (error: unknown) { + if (error instanceof ScopeViolationError) return error.message; + throw error; + } + } + if (policy.scope) { + const targetless = targetlessRestriction(tool.name, args, policy.scope); + if (targetless) return targetless; + } + return null; +} diff --git a/packages/gitlab-mcp/src/configuration/scope-targets.ts b/packages/gitlab-mcp/src/configuration/scope-targets.ts new file mode 100644 index 00000000..3166f251 --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/scope-targets.ts @@ -0,0 +1,149 @@ +/** + * Listing and search calls that name no project or group read across everything the + * account can see. Under a working scope they are narrowed to it: the scope becomes the + * call's own filter where the tool has one, and otherwise the results are filtered. + */ + +import type { ScopeConfig } from '../profiles/types'; +import type { ScopeEnforcer } from '../profiles/scope-enforcer'; + +type Args = Record; + +/** The scope as one group or one project, when it is exactly that. */ +function singleTarget(scope: ScopeConfig): { group: string } | { project: string } | undefined { + const lists = (scope.projects?.length ?? 0) + (scope.groups?.length ?? 0); + if (lists > 0 || scope.namespace) return undefined; + if (scope.group && !scope.project) return { group: scope.group }; + if (scope.project && !scope.group) return { project: scope.project }; + return undefined; +} + +const hasValue = (value: unknown): boolean => value !== undefined && value !== null && value !== ''; + +/** + * Why a targetless call cannot be narrowed to the scope, or null. Only a global search + * whose scope no GitLab search expresses: several projects or groups, or a group without + * its subgroups (group search always covers them, and search results of every kind + * cannot be filtered by path). + */ +export function targetlessRestriction(tool: string, args: Args, scope: ScopeConfig): string | null { + const creation = creationRestriction(tool, args, scope); + if (creation) return creation; + if (tool !== 'browse_search' || args.action !== 'global') return null; + const target = singleTarget(scope); + if (!target) { + return 'a global search cannot be limited to a working scope of several projects or groups; search within one of them'; + } + if ('group' in target && scope.includeSubgroups === false) { + return 'a global search cannot be limited to a group without its subgroups; search within one of its projects'; + } + return null; +} + +/** + * Creation that names no place lands outside the scope: a project without a namespace in + * the user's own namespace (filled in with the scope group when there is a single one), a + * group without a parent at the top level. + */ +function creationRestriction(tool: string, args: Args, scope: ScopeConfig): string | null { + if (args.action !== 'create') return null; + if (tool === 'manage_project' && !hasValue(args.namespace)) { + const target = singleTarget(scope); + if (target && 'group' in target) return null; + return "a project without a namespace would be created outside the working scope; name the group to create it in with 'namespace'"; + } + if (tool === 'manage_namespace' && !hasValue(args.parent_id)) { + return "a top-level group would be outside the working scope; create it inside a group of the scope with 'parent_id'"; + } + return null; +} + +/** + * The projects of a scope made of projects only, or null. Listing them directly replaces a + * project listing that GitLab paginates before the scope could filter it. + */ +export function scopeProjectsOnly(scope: ScopeConfig): string[] | null { + if (scope.group || scope.groups?.length || scope.namespace) return null; + const projects = [...(scope.project ? [scope.project] : []), ...(scope.projects ?? [])]; + return projects.length > 0 ? projects : null; +} + +/** The call's arguments with the scope as its filter, where the tool takes one. */ +export function scopedArgs(tool: string, args: Args, scope: ScopeConfig): Args { + const target = singleTarget(scope); + if (!target) return args; + if (tool === 'browse_search' && args.action === 'global') { + // Group search covers subgroups; a scope without them is refused before this point. + return 'group' in target + ? { ...args, action: 'group', group_id: target.group } + : { ...args, action: 'project', project_id: target.project }; + } + if (tool === 'browse_projects' && args.action === 'list' && !hasValue(args.group_id)) { + if ('group' in target) { + return { + ...args, + group_id: target.group, + include_subgroups: scope.includeSubgroups !== false, + }; + } + } + if (tool === 'browse_merge_requests' && args.action === 'list' && !hasValue(args.project_id)) { + if ('project' in target) return { ...args, project_id: target.project }; + } + if (tool === 'manage_project' && args.action === 'create' && !hasValue(args.namespace)) { + if ('group' in target) return { ...args, namespace: target.group }; + } + return args; +} + +/** Project path of a listed project or merge request, as each listing reports it. */ +function projectPathOf(tool: string, item: Record): string | undefined { + if (tool === 'browse_projects') { + return typeof item.path_with_namespace === 'string' ? item.path_with_namespace : undefined; + } + // A merge request's references.full is "group/project!iid" + const full = (item.references as { full?: unknown } | undefined)?.full; + return typeof full === 'string' && full.includes('!') + ? full.slice(0, full.lastIndexOf('!')) + : undefined; +} + +/** + * The results of a listing narrowed to the scope. Project listings are always filtered: + * a group's listing also returns projects only shared with it from other namespaces. + * An item whose place cannot be told is left out: listing it could show something + * outside the scope. + */ +export function scopedResult( + tool: string, + args: Args, + result: unknown, + enforcer: ScopeEnforcer, +): unknown { + if (!Array.isArray(result)) return result; + const listing = + (tool === 'browse_projects' && (args.action === 'search' || args.action === 'list')) || + (tool === 'browse_merge_requests' && args.action === 'list' && !hasValue(args.project_id)); + if (listing) { + return result.filter((item: Record) => { + const path = projectPathOf(tool, item); + return path !== undefined && enforcer.isAllowed(path); + }); + } + if ( + tool === 'browse_todos' && + args.action === 'list' && + !hasValue(args.project_id) && + !hasValue(args.group_id) + ) { + return result.filter((todo: Record) => { + const project = (todo.project as { path_with_namespace?: unknown } | undefined) + ?.path_with_namespace; + if (typeof project === 'string') return enforcer.isAllowed(project); + const group = (todo.group as { full_path?: unknown } | undefined)?.full_path; + if (typeof group === 'string') return enforcer.isGroupAllowed(group); + return false; + }); + } + return result; +} diff --git a/packages/gitlab-mcp/src/configuration/service.ts b/packages/gitlab-mcp/src/configuration/service.ts new file mode 100644 index 00000000..13c06170 --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/service.ts @@ -0,0 +1,268 @@ +/** + * The configuration service: the one place that reads and changes what a caller works + * with. Native settings, manage_context and the settings panel all go through it, so the + * same choice means the same thing everywhere and takes effect in tool execution. + */ + +import type { Preset } from '../profiles/types'; +import type { Caller } from './caller'; +import { TOOL_GROUP_IDS } from './groups'; +import { + buildPolicy, + selectedPreset, + type EVERYWHERE, + type EffectivePolicy, + type SessionOverrides, +} from './policy'; +import type { SettingsStore } from './settings-store'; +import { + AccountSettingsSchema, + type AccountSettings, + type AccountSettingsRecord, + type WorkingScope, +} from './types'; + +/** A request the configuration cannot honour; nothing was changed. */ +export class ConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigurationError'; + } +} + +export interface PresetSource { + /** Load a preset; rejects for an unknown name. */ + load(name: string): Promise; +} + +/** + * Field values to set; `null` removes the field and the operator default applies again. + * `toolGroups` and `scopeIncludeSubgroups` are changes to the settings as saved at write + * time, so they merge with concurrent edits of other groups or of the scope. + */ +export type ScopePatch = Omit & { includeSubgroups?: boolean }; + +export interface AccountSettingsPatch { + preset?: string | null; + readOnly?: boolean | null; + disabledToolGroups?: string[] | null; + /** + * A group scope without includeSubgroups keeps the choice saved at write time (included + * when none was saved), so it does not overwrite a concurrent change of that choice. + */ + scope?: ScopePatch | null; + /** Tool group id -> whether it is on. */ + toolGroups?: Record; + /** For a saved group scope: whether its subgroups are included. */ + scopeIncludeSubgroups?: boolean; +} + +export interface SessionOverridesPatch { + preset?: string | null; + readOnly?: boolean | null; + scope?: WorkingScope | typeof EVERYWHERE | null; + profile?: string | null; +} + +export interface ResolvedConfiguration { + caller: Caller; + /** Stored account settings; version 0 when the account never saved any. */ + account: AccountSettings; + accountVersion: number; + session: SessionOverrides; + policy: EffectivePolicy; + /** The selected preset could not be loaded; the caller works read-only until it is fixed. */ + presetUnavailable?: string; +} + +/** Concurrent edits of different fields are merged; this bounds the retries. */ +const MAX_WRITE_ATTEMPTS = 5; + +/** The account patch applied to the settings saved now, its changes included. */ +function applyAccountPatch( + saved: AccountSettings, + { toolGroups, scopeIncludeSubgroups, scope, ...fields }: AccountSettingsPatch, +): AccountSettings { + const next = applyPatch(saved, fields); + if (scope === null) delete next.scope; + else if (scope) { + // Only a saved group scope says anything about subgroups; a project's flag is always false. + const savedChoice = saved.scope?.type === 'group' ? saved.scope.includeSubgroups : undefined; + next.scope = { + ...scope, + includeSubgroups: scope.type === 'group' && (scope.includeSubgroups ?? savedChoice ?? true), + }; + } + if (toolGroups) { + const disabled = new Set(next.disabledToolGroups ?? []); + for (const [group, enabled] of Object.entries(toolGroups)) { + if (enabled) disabled.delete(group); + else disabled.add(group); + } + if (disabled.size > 0) + next.disabledToolGroups = [...disabled].sort((a, b) => a.localeCompare(b)); + else delete next.disabledToolGroups; + } + if (scopeIncludeSubgroups !== undefined && next.scope?.type === 'group') { + next.scope = { ...next.scope, includeSubgroups: scopeIncludeSubgroups }; + } + return next; +} + +function applyPatch(base: T, patch: object): T { + const next: Record = { ...(base as Record) }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + if (value === null) delete next[key]; + else next[key] = value; + } + return next as T; +} + +export class ConfigurationService { + /** Session overrides by account and session: another account never sees them. */ + private readonly overrides = new Map(); + /** Sessions of each account on this replica, to notify them of account changes. */ + private readonly accountSessions = new Map>(); + + constructor( + private readonly store: () => Promise, + private readonly presets: PresetSource, + private readonly notify: (sessionKeys: string[]) => Promise, + ) {} + + /** The caller's settings and the policy they produce. */ + async resolve(caller: Caller): Promise { + this.track(caller); + const record = await (await this.store()).get(caller.accountKey); + const account = record?.settings ?? {}; + const session = this.overrides.get(this.overrideKey(caller)) ?? {}; + const resolved = { + caller, + account, + accountVersion: record?.version ?? 0, + session, + }; + const presetName = selectedPreset(account, session); + if (presetName === undefined) { + return { ...resolved, policy: buildPolicy(undefined, account, session) }; + } + try { + const preset = await this.presets.load(presetName); + return { ...resolved, policy: buildPolicy(preset, account, session) }; + } catch { + // The preset was removed after it was selected. Ignoring it could widen access (it may + // have been read-only); failing every call would lock the caller out of the settings + // that fix it. Read-only keeps both safe. Its scope and tool limits are not kept: the + // caller chose the preset and may always choose none, so working read-only without + // them is narrower than what the caller can set itself. + const policy = buildPolicy(undefined, account, { ...session, readOnly: true }); + return { ...resolved, policy: { ...policy, presetName }, presetUnavailable: presetName }; + } + } + + /** + * Change the account's settings. Fields left out keep their stored value, also when + * another session saved meanwhile: a conflicting write re-reads and re-applies the + * patch. Every value is validated before anything is written. + */ + async updateAccount(caller: Caller, patch: AccountSettingsPatch): Promise { + await this.validate(patch); + const store = await this.store(); + for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) { + const current = await store.get(caller.accountKey); + const next = AccountSettingsSchema.safeParse( + applyAccountPatch(current?.settings ?? {}, patch), + ); + if (!next.success) throw new ConfigurationError(next.error.issues[0].message); + const stored = await store.put(caller.accountKey, next.data, current?.version ?? 0); + if (stored) { + await this.notify([...this.track(caller)]); + return stored; + } + } + throw new ConfigurationError('The settings kept changing meanwhile; try again'); + } + + /** Change what the caller's current session uses; other sessions keep theirs. */ + async updateSession(caller: Caller, patch: SessionOverridesPatch): Promise { + await this.validate(patch); + const key = this.overrideKey(caller); + const next = applyPatch(this.overrides.get(key) ?? {}, patch); + if (Object.keys(next).length === 0) this.overrides.delete(key); + else this.overrides.set(key, next); + this.track(caller); + await this.notify([caller.sessionKey]); + return next; + } + + /** Drop the session's overrides: it uses the account settings again. */ + async resetSession(caller: Caller): Promise { + this.overrides.delete(this.overrideKey(caller)); + await this.notify([caller.sessionKey]); + } + + /** + * Move every session of an account to another account key, keeping their overrides + * except the scope. A static token's account is keyed by its instance, so a switch of + * instance re-keys every chat; their scopes named projects of the previous instance. + */ + async moveSessions(fromAccountKey: string, toAccountKey: string): Promise { + if (fromAccountKey === toAccountKey) return; + const sessions = [...(this.accountSessions.get(fromAccountKey) ?? [])]; + this.accountSessions.delete(fromAccountKey); + const moved = this.accountSessions.get(toAccountKey) ?? new Set(); + for (const sessionKey of sessions) { + const old = `${fromAccountKey}\n${sessionKey}`; + const kept = applyPatch(this.overrides.get(old) ?? {}, { scope: null }); + this.overrides.delete(old); + if (Object.keys(kept).length > 0) this.overrides.set(`${toAccountKey}\n${sessionKey}`, kept); + moved.add(sessionKey); + } + this.accountSessions.set(toAccountKey, moved); + await this.notify(sessions); + } + + /** Forget a closed session. */ + forgetSession(sessionKey: string): void { + for (const [accountKey, sessions] of this.accountSessions) { + if (!sessions.delete(sessionKey)) continue; + this.overrides.delete(`${accountKey}\n${sessionKey}`); + if (sessions.size === 0) this.accountSessions.delete(accountKey); + } + } + + private async validate(patch: AccountSettingsPatch | SessionOverridesPatch): Promise { + if (typeof patch.preset === 'string') await this.loadPreset(patch.preset); + const groups = [ + ...(('disabledToolGroups' in patch ? patch.disabledToolGroups : undefined) ?? []), + ...Object.keys(('toolGroups' in patch ? patch.toolGroups : undefined) ?? {}), + ]; + const unknown = groups.filter((group) => !TOOL_GROUP_IDS.has(group)); + if (unknown.length > 0) { + throw new ConfigurationError(`Unknown tool group: ${unknown.join(', ')}`); + } + } + + private async loadPreset(name: string): Promise { + try { + return await this.presets.load(name); + } catch (error: unknown) { + throw new ConfigurationError( + `Unknown preset '${name}': ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private overrideKey(caller: Caller): string { + return `${caller.accountKey}\n${caller.sessionKey}`; + } + + /** Record the caller's session under its account; returns the account's sessions. */ + private track(caller: Caller): Set { + const sessions = this.accountSessions.get(caller.accountKey) ?? new Set(); + sessions.add(caller.sessionKey); + this.accountSessions.set(caller.accountKey, sessions); + return sessions; + } +} diff --git a/packages/gitlab-mcp/src/configuration/settings-store.ts b/packages/gitlab-mcp/src/configuration/settings-store.ts new file mode 100644 index 00000000..d3408c4f --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/settings-store.ts @@ -0,0 +1,147 @@ +/** + * Where account settings are kept. With OAuth or an explicitly configured session storage + * they live in that backend, shared by every replica. A local single-user server keeps + * them in a file in the user's configuration directory. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { AccountSettings, AccountSettingsRecord } from './types'; +import { AccountSettingsSchema } from './types'; +import { logWarn } from '../logger'; +import { syncDirectory } from '../utils/sync-directory'; + +export interface SettingsStore { + get(accountKey: string): Promise; + /** Compare-and-set write; undefined when another write changed the settings first. */ + put( + accountKey: string, + settings: AccountSettings, + expectedVersion: number, + ): Promise; +} + +interface SettingsFile { + version: 1; + accounts: Record; +} + +const LOCK_RETRY_MS = 25; +const LOCK_ATTEMPTS = 200; +/** A lock older than this was left by a process that died while holding it. */ +const STALE_LOCK_MS = 10_000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Settings of a local server in one JSON file. Every read goes to the file, and a write + * holds a lock file around read-compare-write, so several local server processes (one per + * client) share the settings without losing each other's edits. + */ +export class LocalSettingsFile implements SettingsStore { + constructor(private readonly filePath: string) {} + + async get(accountKey: string): Promise { + return (await this.read()).accounts[accountKey]; + } + + async put( + accountKey: string, + settings: AccountSettings, + expectedVersion: number, + ): Promise { + return this.withLock(async () => { + const file = await this.read(); + const current = file.accounts[accountKey]?.version ?? 0; + if (current !== expectedVersion) return undefined; + const record: AccountSettingsRecord = { + accountKey, + settings, + version: current + 1, + updatedAt: Date.now(), + }; + file.accounts[accountKey] = record; + await this.write(file); + return record; + }); + } + + private async read(): Promise { + let content: string; + try { + content = await fs.promises.readFile(this.filePath, 'utf-8'); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, accounts: {} }; + throw error; + } + const parsed = JSON.parse(content) as Partial; + const accounts: Record = {}; + for (const [key, record] of Object.entries(parsed.accounts ?? {})) { + // A hand-edited entry the schema refuses is ignored rather than applied half-valid. + const settings = AccountSettingsSchema.safeParse(record.settings); + if (settings.success) accounts[key] = { ...record, settings: settings.data }; + else logWarn('Ignoring invalid saved settings', { file: this.filePath }); + } + return { version: 1, accounts }; + } + + private async write(file: SettingsFile): Promise { + await fs.promises.mkdir(path.dirname(this.filePath), { recursive: true }); + const temp = `${this.filePath}.${process.pid}.tmp`; + const handle = await fs.promises.open(temp, 'w', 0o600); + try { + await handle.writeFile(JSON.stringify(file, null, 2), 'utf-8'); + await handle.sync(); + } finally { + await handle.close(); + } + await fs.promises.rename(temp, this.filePath); + await syncDirectory(path.dirname(this.filePath)); + } + + private async withLock(fn: () => Promise): Promise { + const lockPath = `${this.filePath}.lock`; + await fs.promises.mkdir(path.dirname(this.filePath), { recursive: true }); + for (let attempt = 0; ; attempt++) { + try { + const handle = await fs.promises.open(lockPath, 'wx', 0o600); + await handle.close(); + break; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + if (await this.removeStaleLock(lockPath)) continue; + if (attempt >= LOCK_ATTEMPTS) { + throw new Error(`Settings file is locked by another process: ${lockPath}`, { + cause: error, + }); + } + await sleep(LOCK_RETRY_MS); + } + } + try { + return await fn(); + } finally { + await fs.promises.rm(lockPath, { force: true }); + } + } + + private async removeStaleLock(lockPath: string): Promise { + try { + const stat = await fs.promises.stat(lockPath); + if (Date.now() - stat.mtimeMs < STALE_LOCK_MS) return false; + await fs.promises.rm(lockPath, { force: true }); + return true; + } catch { + // Released meanwhile: try again at once. + return true; + } + } +} + +/** Default location of a local server's settings. */ +export function localSettingsPath(): string { + return path.join(os.homedir(), '.config', 'gitlab-mcp', 'settings.json'); +} diff --git a/packages/gitlab-mcp/src/configuration/types.ts b/packages/gitlab-mcp/src/configuration/types.ts new file mode 100644 index 00000000..ba2814fc --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/types.ts @@ -0,0 +1,44 @@ +/** + * Configuration model of a caller: the account's durable settings and the overrides of + * the current MCP session. Both only narrow what the operator's configuration allows. + */ + +import { z } from 'zod'; + +/** A project or group the caller works in. */ +export const WorkingScopeSchema = z + .object({ + type: z.enum(['project', 'group']), + path: z.string().min(1), + /** Group scopes: whether projects of subgroups belong to the scope. */ + includeSubgroups: z.boolean(), + }) + .strict(); +export type WorkingScope = z.infer; + +/** + * Settings of one account, kept across restarts and shared by the account's sessions. + * Absent fields take the operator's configuration. + */ +export const AccountSettingsSchema = z + .object({ + /** Built-in or local preset applied to every session of the account. */ + preset: z.string().min(1).optional(), + /** Only read tools, on top of whatever the operator and the preset allow. */ + readOnly: z.boolean().optional(), + /** Tool groups (feature keys such as `wiki`, `pipelines`) the account switched off. */ + disabledToolGroups: z.array(z.string().min(1)).optional(), + /** Default working scope of new sessions. */ + scope: WorkingScopeSchema.optional(), + }) + .strict(); +export type AccountSettings = z.infer; + +/** Stored settings of an account with the version of the last write. */ +export interface AccountSettingsRecord { + accountKey: string; + settings: AccountSettings; + /** Starts at 1 and grows with every write; compare-and-set writes name it. */ + version: number; + updatedAt: number; +} diff --git a/packages/gitlab-mcp/src/contracts/storage.ts b/packages/gitlab-mcp/src/contracts/storage.ts index b2694417..0dfe337c 100644 --- a/packages/gitlab-mcp/src/contracts/storage.ts +++ b/packages/gitlab-mcp/src/contracts/storage.ts @@ -19,3 +19,4 @@ export type { RegisteredOAuthClient, GitLabTokenResponse, } from '../oauth/types'; +export type { AccountSettings, AccountSettingsRecord } from '../configuration/types'; diff --git a/packages/gitlab-mcp/src/entities/context/connection-check.ts b/packages/gitlab-mcp/src/entities/context/connection-check.ts new file mode 100644 index 00000000..f31e27bf --- /dev/null +++ b/packages/gitlab-mcp/src/entities/context/connection-check.ts @@ -0,0 +1,100 @@ +/** + * Connection check for the settings page: the account, whether GitLab answers, the + * restrictions in effect, and what to do about problems. It reports no token and no + * other user's data. + */ + +import { GITLAB_BASE_URL } from '../../config'; +import { currentCaller, getConfigurationService, resolveCaller } from '../../configuration'; +import { toolRestriction } from '../../configuration/policy'; +import { getTokenContext } from '../../oauth/token-context'; +import { isToolAvailableForScopes } from '../../services/TokenScopeDetector'; +import { executeWhoami } from './whoami'; + +export interface ConnectionCheck { + account: string; + instance: string; + gitlabVersion: string; + tier: string; + /** Whether GitLab identified the account with the current credentials. */ + authenticated: boolean; + readOnly: boolean; + preset: string | null; + scope: string | null; + availableTools: number; + warnings: string[]; + recommendations: string[]; +} + +export const CONNECTION_CHECK_OUTPUT_SCHEMA = { + type: 'object' as const, + properties: { + account: { type: 'string' }, + instance: { type: 'string' }, + gitlabVersion: { type: 'string' }, + tier: { type: 'string' }, + authenticated: { type: 'boolean' }, + readOnly: { type: 'boolean' }, + preset: { type: ['string', 'null'] }, + scope: { type: ['string', 'null'] }, + availableTools: { type: 'number' }, + warnings: { type: 'array', items: { type: 'string' } }, + recommendations: { type: 'array', items: { type: 'string' } }, + }, + required: [ + 'account', + 'instance', + 'gitlabVersion', + 'tier', + 'authenticated', + 'readOnly', + 'preset', + 'scope', + 'availableTools', + 'warnings', + 'recommendations', + ], + additionalProperties: false, +}; + +export async function checkConnection(): Promise { + const caller = currentCaller() ?? resolveCaller(undefined, GITLAB_BASE_URL); + const [whoami, resolved] = await Promise.all([ + executeWhoami(), + getConfigurationService().resolve(caller), + ]); + const scope = resolved.policy.scope; + // What this caller can call, as tools/list shows it: the instance's tools narrowed by + // the OAuth token's scopes and by the caller's own settings. + const { RegistryManager } = await import('../../registry-manager'); + const registry = RegistryManager.getInstance(); + const tokenScopes = getTokenContext()?.gitlabScopes; + const availableTools = registry + .getAvailableToolNames(caller.instanceUrl) + .filter( + (name) => + (tokenScopes === undefined || isToolAvailableForScopes(name, tokenScopes)) && + toolRestriction(resolved.policy, registry.getToolFacts(name)) === null, + ).length; + const warnings = [...whoami.warnings]; + const recommendations = whoami.recommendations.map((r) => r.message); + if (resolved.presetUnavailable) { + warnings.push( + `The saved preset '${resolved.presetUnavailable}' no longer exists on this server; tools that change GitLab are off until another preset is chosen.`, + ); + recommendations.push('Choose another preset in the GitLab settings, or with update_settings.'); + } + return { + account: whoami.user?.username ?? caller.accountLabel, + instance: whoami.server.apiUrl, + gitlabVersion: whoami.server.version, + tier: whoami.server.tier, + authenticated: whoami.user !== null, + readOnly: whoami.server.readOnlyMode || resolved.policy.readOnly, + preset: resolved.policy.presetName ?? null, + scope: scope?.project ?? scope?.group ?? scope?.namespace ?? null, + availableTools, + warnings, + recommendations, + }; +} diff --git a/packages/gitlab-mcp/src/entities/context/context-manager.ts b/packages/gitlab-mcp/src/entities/context/context-manager.ts index 53e7c5c2..5067c036 100644 --- a/packages/gitlab-mcp/src/entities/context/context-manager.ts +++ b/packages/gitlab-mcp/src/entities/context/context-manager.ts @@ -1,24 +1,25 @@ /** - * Context Manager - manages runtime session context + * Context Manager - the manage_context view of the caller's configuration * - * Singleton class that maintains the current session state including: - * - Current preset and its settings - * - Scope restrictions (project/group) - * - OAuth profile (in OAuth mode) - * - * Integrates with: - * - ProfileLoader for preset/profile loading - * - ScopeEnforcer for scope validation - * - detectNamespaceType for auto-detection + * Every action applies to the caller of the running tool call and to its current MCP + * session: another user's or another chat's context never changes. Presets and scopes are + * session overrides of the configuration service, so they take effect in tool execution; + * the account defaults are changed through the settings tools. */ import { GITLAB_BASE_URL, GITLAB_READ_ONLY_MODE } from '../../config'; -import { logInfo, logError, logDebug } from '../../logger'; +import { logInfo, logError } from '../../logger'; import { ProfileLoader } from '../../profiles/loader'; -import { ScopeEnforcer } from '../../profiles/scope-enforcer'; -import { Preset, ProfileInfo, ScopeConfig } from '../../profiles/types'; -import { sendToolsListChangedNotification } from '../../server'; -import { detectNamespaceType } from '../../utils/namespace'; +import { ProfileInfo, ScopeConfig } from '../../profiles/types'; +import { findNamespaceType } from '../../utils/namespace'; +import { + currentCaller, + getConfigurationService, + resolveCaller, + type Caller, + type ResolvedConfiguration, +} from '../../configuration'; +import { EVERYWHERE } from '../../configuration/policy'; import { PresetInfo, ResetResult, @@ -28,171 +29,113 @@ import { SwitchResult, } from './types'; -/** - * Check if OAuth mode is enabled - */ function isOAuthMode(): boolean { return process.env.OAUTH_ENABLED === 'true'; } -/** - * Get GitLab host from API URL - */ -function getHost(): string { +function hostOf(url: string): string { try { - const url = new URL(GITLAB_BASE_URL); - return url.hostname; + return new URL(url).hostname; } catch { - return GITLAB_BASE_URL; + return url; } } -/** - * Context Manager class - * - * Manages the runtime session context for the MCP server. - * Uses singleton pattern to ensure consistent state across the session. - */ +/** Convert a scope configuration to the runtime view shown to the caller. */ +export function runtimeScopeOf(scope: ScopeConfig, detected: boolean): RuntimeScope { + if (scope.project) { + return { type: 'project', path: scope.project, includeSubgroups: false, detected }; + } + if (scope.group ?? scope.namespace) { + return { + type: 'group', + path: (scope.group ?? scope.namespace) as string, + includeSubgroups: scope.includeSubgroups !== false, + detected, + }; + } + if (scope.projects && scope.projects.length > 0) { + return { + type: 'project', + path: scope.projects[0], + additionalPaths: scope.projects.length > 1 ? scope.projects.slice(1) : undefined, + includeSubgroups: false, + detected, + }; + } + if (scope.groups && scope.groups.length > 0) { + return { + type: 'group', + path: scope.groups[0], + additionalPaths: scope.groups.length > 1 ? scope.groups.slice(1) : undefined, + includeSubgroups: scope.includeSubgroups !== false, + detected, + }; + } + logError('Invalid scope configuration: no usable scope fields found', { scope }); + throw new Error( + 'Invalid scope configuration: expected project, group, namespace, projects, or groups to be defined', + ); +} + export class ContextManager { private static instance: ContextManager | null = null; - private profileLoader: ProfileLoader; - private currentPreset: Preset | null = null; - private currentPresetName: string | null = null; - private currentScope: ScopeConfig | null = null; - private currentScopeEnforcer: ScopeEnforcer | null = null; - private currentProfileName: string | null = null; - private initialContext: SessionContext | null = null; - - private constructor() { - this.profileLoader = new ProfileLoader(); - this.captureInitialContext(); - } + private readonly profileLoader = new ProfileLoader(); - /** - * Get the singleton instance - */ static getInstance(): ContextManager { ContextManager.instance ??= new ContextManager(); return ContextManager.instance; } - /** - * Reset the singleton instance (for testing) - */ + /** Reset the singleton instance (for testing) */ static resetInstance(): void { ContextManager.instance = null; } - /** - * Capture initial context for reset functionality - */ - private captureInitialContext(): void { - this.initialContext = { - host: getHost(), - apiUrl: GITLAB_BASE_URL, + /** The caller of the running tool call; the server's own token outside one. */ + private caller(): Caller { + return currentCaller() ?? resolveCaller(undefined, GITLAB_BASE_URL); + } + + /** What the operator configured, before any account or session choice. */ + private operatorContext(caller: Caller): Omit { + return { + host: hostOf(caller.instanceUrl), + apiUrl: caller.instanceUrl, readOnly: GITLAB_READ_ONLY_MODE, oauthMode: isOAuthMode(), - presetName: this.currentPresetName ?? undefined, - profileName: this.currentProfileName ?? undefined, - scope: this.currentScope - ? this.scopeConfigToRuntimeScope(this.currentScope, false) - : undefined, }; - - logDebug('Captured initial context', { initialContext: this.initialContext }); - } - - /** - * Convert ScopeConfig to RuntimeScope - */ - private scopeConfigToRuntimeScope(scope: ScopeConfig, detected: boolean): RuntimeScope { - // Determine the primary scope type and path - if (scope.project) { - return { - type: 'project', - path: scope.project, - includeSubgroups: false, - detected, - }; - } - - if (scope.group) { - return { - type: 'group', - path: scope.group, - includeSubgroups: scope.includeSubgroups !== false, - detected, - }; - } - - if (scope.namespace) { - // Namespace is treated as group by default - return { - type: 'group', - path: scope.namespace, - includeSubgroups: scope.includeSubgroups !== false, - detected, - }; - } - - // Multiple projects - first one as primary, rest as additional - if (scope.projects && scope.projects.length > 0) { - return { - type: 'project', - path: scope.projects[0], - additionalPaths: scope.projects.length > 1 ? scope.projects.slice(1) : undefined, - includeSubgroups: false, - detected, - }; - } - - // Multiple groups - first one as primary, rest as additional - if (scope.groups && scope.groups.length > 0) { - return { - type: 'group', - path: scope.groups[0], - additionalPaths: scope.groups.length > 1 ? scope.groups.slice(1) : undefined, - includeSubgroups: scope.includeSubgroups !== false, - detected, - }; - } - - // Invalid scope - should not happen with valid ScopeConfig (validated by Zod) - logError('Invalid scope configuration: no usable scope fields found', { scope }); - throw new Error( - 'Invalid scope configuration: expected project, group, namespace, projects, or groups to be defined', - ); } - /** - * Get current session context - */ - getContext(): SessionContext { - const context: SessionContext = { - host: getHost(), - apiUrl: GITLAB_BASE_URL, - readOnly: GITLAB_READ_ONLY_MODE, - oauthMode: isOAuthMode(), - presetName: this.currentPresetName ?? undefined, - profileName: this.currentProfileName ?? undefined, - scope: this.currentScope - ? this.scopeConfigToRuntimeScope(this.currentScope, false) + private contextOf(caller: Caller, resolved: ResolvedConfiguration): SessionContext { + const operator = this.operatorContext(caller); + const scope = resolved.policy.scope; + return { + ...operator, + readOnly: operator.readOnly || resolved.policy.readOnly, + presetName: resolved.policy.presetName, + disabledToolGroups: [...resolved.policy.disabledGroups].sort((a, b) => a.localeCompare(b)), + profileName: resolved.session.profile, + scope: scope + ? runtimeScopeOf( + scope, + resolved.session.scope !== undefined && resolved.session.scope !== EVERYWHERE, + ) : undefined, - initialContext: this.initialContext ?? undefined, + initialContext: operator, }; + } - return context; + /** The caller's current context: operator settings narrowed by account and session. */ + async getContext(): Promise { + const caller = this.caller(); + return this.contextOf(caller, await getConfigurationService().resolve(caller)); } - /** - * List available presets - */ async listPresets(): Promise { const profiles = await this.profileLoader.listProfiles(); - - // Filter to only presets (built-in profiles without host/auth) - const presets: PresetInfo[] = profiles + return profiles .filter((p) => p.isPreset) .map((p) => ({ name: p.name, @@ -200,168 +143,91 @@ export class ContextManager { readOnly: p.readOnly, isBuiltIn: p.isBuiltIn, })); - - // Add current preset if not in list - if (this.currentPresetName && this.currentPreset) { - const exists = presets.some((p) => p.name === this.currentPresetName); - if (!exists) { - presets.unshift({ - name: this.currentPresetName, - description: this.currentPreset.description, - readOnly: this.currentPreset.read_only ?? false, - isBuiltIn: false, - }); - } - } - - return presets; } - /** - * List available profiles (OAuth mode only) - */ async listProfiles(): Promise { if (!isOAuthMode()) { throw new Error('list_profiles is only available in OAuth mode'); } - const profiles = await this.profileLoader.listProfiles(); - - // Filter to only full profiles (with host/auth) return profiles.filter((p) => !p.isPreset); } - /** - * Switch to a different preset - * - * After a successful switch, sends a tools/list_changed notification - * to inform connected clients that available tools may have changed - * (e.g., read-only presets disable write tools). - */ + /** Use a preset in the current session; the account default stays as it is. */ async switchPreset(presetName: string): Promise { - const previousPreset = this.currentPresetName; - + const caller = this.caller(); + const service = getConfigurationService(); + const previous = (await service.resolve(caller)).policy.presetName; try { - const preset = await this.profileLoader.loadPreset(presetName); - - this.currentPreset = preset; - this.currentPresetName = presetName; - - // Apply scope from preset if defined; otherwise clear any previous scope - if (preset.scope) { - this.currentScope = preset.scope; - this.currentScopeEnforcer = new ScopeEnforcer(preset.scope); - } else { - this.currentScope = null; - this.currentScopeEnforcer = null; - } - - logInfo('Switched preset', { previous: previousPreset, current: presetName }); - - // Notify clients that tool list may have changed - // (e.g., read-only presets disable write tools) - await sendToolsListChangedNotification(); - - return { - success: true, - previous: previousPreset ?? undefined, - current: presetName, - message: `Switched to preset '${presetName}'`, - }; + await service.updateSession(caller, { preset: presetName }); } catch (error) { const message = error instanceof Error ? error.message : String(error); logError('Failed to switch preset', { error: message, preset: presetName }); throw new Error(`Failed to switch to preset '${presetName}': ${message}`, { cause: error }); } + logInfo('Switched preset for session', { previous, current: presetName }); + return { + success: true, + previous, + current: presetName, + message: `Switched this session to preset '${presetName}'`, + }; } - /** - * Resolve the GitLab API URL for the currently active profile, or null if - * no profile is active. Mirrors applicator.ts: api_url override wins over - * `https://{host}`. Used to proactively re-pin per-session instance URL - * after switch_profile (issue #407). - */ + /** API URL of the caller's active local profile, or null when none is active. */ async getCurrentProfileUrl(): Promise { - if (!this.currentProfileName) return null; - const profile = await this.profileLoader.loadProfile(this.currentProfileName); + const name = (await getConfigurationService().resolve(this.caller())).session.profile; + if (!name) return null; + const profile = await this.profileLoader.loadProfile(name); return profile.api_url ?? `https://${profile.host}`; } - /** - * Switch to a different profile (OAuth mode only) - */ async switchProfile(profileName: string): Promise { if (!isOAuthMode()) { throw new Error('switch_profile is only available in OAuth mode'); } - - const previousProfile = this.currentProfileName; - + const caller = this.caller(); + const service = getConfigurationService(); + const previous = (await service.resolve(caller)).session.profile; try { - // Validate profile exists await this.profileLoader.loadProfile(profileName); - - this.currentProfileName = profileName; - - logInfo('Switched profile', { previous: previousProfile, current: profileName }); - - return { - success: true, - previous: previousProfile ?? undefined, - current: profileName, - message: `Switched to profile '${profileName}'`, - }; } catch (error) { const message = error instanceof Error ? error.message : String(error); logError('Failed to switch profile', { error: message, profile: profileName }); throw new Error(`Failed to switch to profile '${profileName}': ${message}`, { cause: error }); } + await service.updateSession(caller, { profile: profileName }); + logInfo('Switched profile', { previous, current: profileName }); + return { + success: true, + previous, + current: profileName, + message: `Switched to profile '${profileName}'`, + }; } - /** - * Set scope with auto-detection - */ + /** Limit the current session to a project or group, detecting which one it is. */ async setScope(namespace: string, includeSubgroups: boolean = true): Promise { try { - // Auto-detect namespace type - const namespaceType = await detectNamespaceType(namespace); - - // Build scope config based on detected type - let scopeConfig: ScopeConfig; - if (namespaceType === 'project') { - scopeConfig = { - project: namespace, - includeSubgroups: false, - }; - } else { - scopeConfig = { - group: namespace, - includeSubgroups, - }; + // Confirmed by GitLab, so a typo is reported instead of becoming a guessed scope. + const type = await findNamespaceType(namespace); + if (!type) { + throw new Error( + `No project or group '${namespace}' was found on GitLab, or GitLab could not be reached`, + ); } - - // Update current scope - this.currentScope = scopeConfig; - this.currentScopeEnforcer = new ScopeEnforcer(scopeConfig); - - const runtimeScope: RuntimeScope = { - type: namespaceType, + const scope = { + type, path: namespace, - includeSubgroups: namespaceType === 'group' ? includeSubgroups : false, - detected: true, + includeSubgroups: type === 'group' ? includeSubgroups : false, }; - - logInfo('Scope set with auto-detection', { - namespace, - type: namespaceType, - includeSubgroups, - }); - + await getConfigurationService().updateSession(this.caller(), { scope }); + logInfo('Scope set with auto-detection', { namespace, type, includeSubgroups }); return { success: true, - scope: runtimeScope, - message: `Scope set to ${namespaceType} '${namespace}'${ - namespaceType === 'group' && includeSubgroups ? ' (including subgroups)' : '' + scope: { ...scope, detected: true }, + message: `Scope set to ${type} '${namespace}'${ + type === 'group' && includeSubgroups ? ' (including subgroups)' : '' }`, }; } catch (error) { @@ -372,58 +238,28 @@ export class ContextManager { } /** - * Reset context to initial state + * Let the current session work everywhere the account has access: its own scope and the + * account's default scope no longer apply to it. Its other overrides stay. */ - reset(): ResetResult { - if (!this.initialContext) { - throw new Error('No initial context captured - cannot reset'); - } - - // Clear runtime state - this.currentPreset = null; - this.currentPresetName = null; - this.currentScope = null; - this.currentScopeEnforcer = null; - this.currentProfileName = null; - - // Recapture initial context - this.captureInitialContext(); - - logInfo('Context reset to initial state'); - + async clearScope(): Promise { + await getConfigurationService().updateSession(this.caller(), { scope: EVERYWHERE }); + logInfo('Session scope cleared'); return { success: true, - message: 'Context reset to initial state', - context: this.getContext(), + message: 'This session now works everywhere the account has access', + context: await this.getContext(), }; } - /** - * Get current scope enforcer (if scope is set) - */ - getScopeEnforcer(): ScopeEnforcer | null { - return this.currentScopeEnforcer; - } - - /** - * Check if current context has scope restrictions - */ - hasScope(): boolean { - return this.currentScope !== null; - } - - /** - * Get current preset (if set) - */ - getCurrentPreset(): Preset | null { - return this.currentPreset; - } - - /** - * Get current preset name (if set) - */ - getCurrentPresetName(): string | null { - return this.currentPresetName; + /** Drop the session's preset, scope and profile: the account settings apply again. */ + async reset(): Promise { + await getConfigurationService().resetSession(this.caller()); + logInfo('Session context reset'); + return { + success: true, + message: 'Session context reset to the account settings', + context: await this.getContext(), + }; } /** @@ -433,13 +269,13 @@ export class ContextManager { * the session is tied to a specific instance. Users must re-authenticate * to use a different instance. * - * In static token mode, switching is allowed and triggers: + * In static token mode the server's connection is shared by its sessions, so the + * switch is too; it triggers: * 1. Re-introspection for the new instance * 2. Clearing namespace tier cache * 3. Tool re-validation against new schema */ async switchInstance(instanceUrl: string): Promise { - // Block instance switching in OAuth mode if (isOAuthMode()) { throw new Error( 'Cannot switch instances in OAuth mode. ' + @@ -451,16 +287,14 @@ export class ContextManager { const { InstanceRegistry } = await import('../../services/InstanceRegistry.js'); const { clearNamespaceTierCache } = await import('../../services/NamespaceTierDetector.js'); const { ConnectionManager } = await import('../../services/ConnectionManager.js'); + const { sendToolsListChangedNotification } = await import('../../server'); const registry = InstanceRegistry.getInstance(); - - // Ensure registry is initialized from config before accessing instances if (!registry.isInitialized()) { await registry.initialize(); } const instance = registry.get(instanceUrl); - if (!instance) { throw new Error( `Instance not configured: ${instanceUrl}. ` + @@ -468,23 +302,19 @@ export class ContextManager { ); } - // Get previous URL from ConnectionManager (tracks actual current instance) const connectionManager = ConnectionManager.getInstance(); const previousUrl = connectionManager.getCurrentInstanceUrl() ?? GITLAB_BASE_URL; + const before = this.caller(); try { - // Clear namespace tier cache (invalid for new instance) clearNamespaceTierCache(); - - // Re-initialize ConnectionManager for new instance - // This will trigger re-introspection await connectionManager.reinitialize(instanceUrl); - // Clear current scope (invalid for new instance) - // Order is intentional: reinit completes first ensuring new instance is ready, - // then we clear old scope references that are no longer valid - this.currentScope = null; - this.currentScopeEnforcer = null; + // The server's token now acts on the new instance, which is another account: every + // chat keeps its overrides there, without the scope that named the previous + // instance's projects. + const after = resolveCaller(before.sessionKey, instanceUrl); + await getConfigurationService().moveSessions(before.accountKey, after.accountKey); logInfo('Switched GitLab instance', { previous: previousUrl, @@ -492,7 +322,6 @@ export class ContextManager { label: instance.config.label, }); - // Notify clients that tool list may have changed await sendToolsListChangedNotification(); return { @@ -511,7 +340,6 @@ export class ContextManager { } } -// Export convenience function export function getContextManager(): ContextManager { return ContextManager.getInstance(); } diff --git a/packages/gitlab-mcp/src/entities/context/handlers.ts b/packages/gitlab-mcp/src/entities/context/handlers.ts index 3bee8962..01fb28b5 100644 --- a/packages/gitlab-mcp/src/entities/context/handlers.ts +++ b/packages/gitlab-mcp/src/entities/context/handlers.ts @@ -117,6 +117,8 @@ export async function handleManageContext( return handleSwitchProfile(input); case 'set_scope': return handleSetScope(input); + case 'clear_scope': + return getContextManager().clearScope(); case 'reset': return handleResetContext(input); case 'whoami': diff --git a/packages/gitlab-mcp/src/entities/context/output-schema.ts b/packages/gitlab-mcp/src/entities/context/output-schema.ts index 0e569423..4bd4923d 100644 --- a/packages/gitlab-mcp/src/entities/context/output-schema.ts +++ b/packages/gitlab-mcp/src/entities/context/output-schema.ts @@ -13,6 +13,7 @@ const baseContext = z.object({ profileName: z.string().optional(), presetName: z.string().optional(), readOnly: z.boolean(), + disabledToolGroups: z.array(z.string()).optional(), scope: scope.optional(), oauthMode: z.boolean(), }); @@ -138,6 +139,10 @@ const success = z.discriminatedUnion('action', [ action: z.literal('set_scope'), data: z.object({ success: z.boolean(), scope, message: z.string() }), }), + z.object({ + action: z.literal('clear_scope'), + data: z.object({ success: z.boolean(), message: z.string(), context }), + }), z.object({ action: z.literal('reset'), data: z.object({ success: z.boolean(), message: z.string(), context }), diff --git a/packages/gitlab-mcp/src/entities/context/registry.ts b/packages/gitlab-mcp/src/entities/context/registry.ts index 0558ae5e..4bb695c3 100644 --- a/packages/gitlab-mcp/src/entities/context/registry.ts +++ b/packages/gitlab-mcp/src/entities/context/registry.ts @@ -11,6 +11,25 @@ import { handleManageContext } from './handlers'; import { ContextOutputSchema } from './output-schema'; import { ToolSchema } from '@modelcontextprotocol/sdk/types.js'; import { ACCOUNT_PROFILE_OUTPUT_SCHEMA, getAccountProfile } from './profile'; +import { + CONNECTION_CHECK_TOOL, + SETTINGS_READ_OUTPUT_SCHEMA, + SETTINGS_READ_TOOL, + SETTINGS_UPDATE_INPUT_SCHEMA, + SETTINGS_UPDATE_OUTPUT_SCHEMA, + SETTINGS_PANEL_TOOL, + SETTINGS_UPDATE_TOOL, + readSettings, + updateSettings, +} from './settings'; +import { CONNECTION_CHECK_OUTPUT_SCHEMA, checkConnection } from './connection-check'; +import { SETTINGS_PANEL_URI } from './settings-panel'; +import { + SCOPE_SEARCH_INPUT_SCHEMA, + SCOPE_SEARCH_OUTPUT_SCHEMA, + SCOPE_SEARCH_TOOL, + findScopeTargets, +} from './scope-search'; /** * Context tools registry - 1 CQRS tool with 8 actions @@ -31,7 +50,7 @@ export const contextToolRegistry: ToolRegistry = new Map settingsResult(await readSettings()), + }, + ], + [ + SETTINGS_UPDATE_TOOL, + { + name: SETTINGS_UPDATE_TOOL, + title: 'Change GitLab settings', + description: + "Change this connection's settings for new chats. Pass only the settings to change, by the names get_settings lists; the others keep their value. Nothing is saved when any value is invalid. Returns the saved values.", + inputSchema: SETTINGS_UPDATE_INPUT_SCHEMA, + outputSchema: SETTINGS_UPDATE_OUTPUT_SCHEMA, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + resultFormat: 'mcp', + handler: async (args: unknown) => { + const { set } = args as { set: Record }; + return settingsResult(await updateSettings(set)); + }, + }, + ], + [ + CONNECTION_CHECK_TOOL, + { + name: CONNECTION_CHECK_TOOL, + title: 'Check connection', + description: + 'Check the GitLab connection: which account it uses, whether GitLab answers, the restrictions in effect, and what to do about problems.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + outputSchema: CONNECTION_CHECK_OUTPUT_SCHEMA, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, + resultFormat: 'mcp', + handler: async () => settingsResult(await checkConnection()), + }, + ], + [ + SETTINGS_PANEL_TOOL, + { + name: SETTINGS_PANEL_TOOL, + title: 'GitLab connection panel', + description: + 'Open the GitLab connection panel to search projects and groups, choose where this chat or new chats work, and check the connection.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + _meta: { + ui: { resourceUri: SETTINGS_PANEL_URI }, + // Alias read by hosts that predate the standard key. + 'openai/outputTemplate': SETTINGS_PANEL_URI, + }, + resultFormat: 'mcp', + handler: () => + Promise.resolve({ + content: [ + { + type: 'text', + text: 'Opened the GitLab connection panel. Clients without app panels change the same settings with get_settings, update_settings and manage_context set_scope.', + }, + ], + structuredContent: { opened: true }, + }), + }, + ], + [ + SCOPE_SEARCH_TOOL, + { + name: SCOPE_SEARCH_TOOL, + title: 'Find projects and groups', + description: + 'Find projects you are a member of and groups you can see by name or path, to choose a working scope.', + inputSchema: SCOPE_SEARCH_INPUT_SCHEMA, + outputSchema: SCOPE_SEARCH_OUTPUT_SCHEMA, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, + resultFormat: 'mcp', + handler: async (args: unknown) => { + const { query } = args as { query: string }; + return settingsResult(await findScopeTargets(query)); + }, + }, + ], ]); +function settingsResult(data: object): { + content: Array<{ type: 'text'; text: string }>; + structuredContent: Record; +} { + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + structuredContent: data as Record, + }; +} + /** * Get read-only tool names from the registry * manage_context has both read and write actions, but we expose it in read-only mode @@ -87,7 +205,16 @@ export const contextToolRegistry: ToolRegistry = new Map; export type SwitchPresetInput = z.infer; export type SwitchProfileInput = z.infer; export type SetScopeInput = z.infer; +export type ClearScopeInput = z.infer; export type ResetContextInput = z.infer; export type WhoamiInput = z.infer; diff --git a/packages/gitlab-mcp/src/entities/context/scope-search.ts b/packages/gitlab-mcp/src/entities/context/scope-search.ts new file mode 100644 index 00000000..2317c7a7 --- /dev/null +++ b/packages/gitlab-mcp/src/entities/context/scope-search.ts @@ -0,0 +1,97 @@ +/** + * Project and group search for choosing a working scope in the settings panel. It reads + * only what the account can already see in GitLab and returns paths and names, nothing + * else of the projects. + */ + +import { gitlab } from '../../utils/gitlab-api'; + +export const SCOPE_SEARCH_TOOL = 'find_scope_targets'; + +/** Results of one kind; projects and groups are listed together. */ +const PER_KIND = 10; + +export interface ScopeTarget { + type: 'project' | 'group'; + /** Full path, as the scope setting takes it. */ + path: string; + /** Display name with its namespace. */ + name: string; +} + +export const SCOPE_SEARCH_INPUT_SCHEMA = { + type: 'object' as const, + properties: { + query: { + type: 'string', + minLength: 2, + maxLength: 100, + description: 'Part of a project or group name or path.', + }, + }, + required: ['query'], + additionalProperties: false, +}; + +export const SCOPE_SEARCH_OUTPUT_SCHEMA = { + type: 'object' as const, + properties: { + targets: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['project', 'group'] }, + path: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['type', 'path', 'name'], + additionalProperties: false, + }, + }, + }, + required: ['targets'], + additionalProperties: false, +}; + +interface GitLabProject { + path_with_namespace: string; + name_with_namespace: string; +} + +interface GitLabGroup { + full_path: string; + full_name: string; +} + +/** Projects the account is a member of and groups it can see, matching the query. */ +export async function findScopeTargets(query: string): Promise<{ targets: ScopeTarget[] }> { + const search = query.trim(); + if (search.length < 2 || search.length > 100) { + throw new Error('Search for 2 to 100 characters'); + } + const [projects, groups] = await Promise.all([ + gitlab.get('projects', { + query: { + search, + membership: 'true', + simple: 'true', + per_page: String(PER_KIND), + order_by: 'last_activity_at', + }, + }), + gitlab.get('groups', { + query: { search, per_page: String(PER_KIND) }, + }), + ]); + return { + targets: [ + ...groups.map((g) => ({ type: 'group' as const, path: g.full_path, name: g.full_name })), + ...projects.map((p) => ({ + type: 'project' as const, + path: p.path_with_namespace, + name: p.name_with_namespace, + })), + ], + }; +} diff --git a/packages/gitlab-mcp/src/entities/context/settings-panel.ts b/packages/gitlab-mcp/src/entities/context/settings-panel.ts new file mode 100644 index 00000000..f836d0bd --- /dev/null +++ b/packages/gitlab-mcp/src/entities/context/settings-panel.ts @@ -0,0 +1,519 @@ +/** + * The settings panel: an MCP App (ui:// resource) for choosing where to work, previewing + * what the connection can do and recovering from connection problems. + * + * It talks only to its host over the MCP Apps bridge and only to this server's tools; it + * makes no network request of its own (empty CSP) and shows GitLab strings as text only. + * Saving goes through the same tools as every other client, so the panel never has an + * effect a direct tool call could not have. + */ + +import { packageVersion } from '../../config'; + +export const SETTINGS_PANEL_URI = 'ui://gitlab-mcp/settings-panel-v1.html'; +export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app'; +/** MCP Apps protocol version the panel speaks. */ +export const MCP_APPS_PROTOCOL_VERSION = '2026-01-26'; + +/** Resource metadata: no network origins at all, and a host border around the panel. */ +export const SETTINGS_PANEL_RESOURCE_META = { + ui: { csp: { connectDomains: [], resourceDomains: [], frameDomains: [] }, prefersBorder: true }, +}; + +const STYLE = ` +:root { + color-scheme: light dark; + --bg: var(--color-background-primary, #ffffff); + --bg-muted: var(--color-background-secondary, #f4f5f7); + --text: var(--color-text-primary, #1f2328); + --text-muted: var(--color-text-secondary, #59636e); + --border: var(--color-border-primary, #d1d9e0); + --ring: var(--color-ring-primary, #2f6feb); + --danger: var(--color-text-danger, #c62828); + --success: var(--color-text-success, #1a7f37); + --warning: var(--color-text-warning, #9a6700); + --radius: var(--border-radius-md, 8px); + --font: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif); +} +:root[data-theme="dark"] { + --bg: var(--color-background-primary, #0d1117); + --bg-muted: var(--color-background-secondary, #161b22); + --text: var(--color-text-primary, #e6edf3); + --text-muted: var(--color-text-secondary, #9198a1); + --border: var(--color-border-primary, #3d444d); + --danger: var(--color-text-danger, #f47067); + --success: var(--color-text-success, #57ab5a); + --warning: var(--color-text-warning, #c69026); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: var(--color-background-primary, #0d1117); + --bg-muted: var(--color-background-secondary, #161b22); + --text: var(--color-text-primary, #e6edf3); + --text-muted: var(--color-text-secondary, #9198a1); + --border: var(--color-border-primary, #3d444d); + --danger: var(--color-text-danger, #f47067); + --success: var(--color-text-success, #57ab5a); + --warning: var(--color-text-warning, #c69026); + } +} +* { box-sizing: border-box; } +body { margin: 0; padding: 16px; background: var(--bg); color: var(--text); + font: 14px/1.45 var(--font); } +h1 { font-size: 16px; margin: 0 0 4px; } +h2 { font-size: 14px; margin: 0 0 8px; } +section { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; + margin-top: 12px; } +.muted { color: var(--text-muted); } +.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +label { font-weight: 600; display: block; margin-bottom: 4px; } +input[type="search"] { width: 100%; padding: 8px; border: 1px solid var(--border); + border-radius: var(--radius); background: var(--bg); color: var(--text); font: inherit; } +button { padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg-muted); color: var(--text); font: inherit; cursor: pointer; } +button:disabled { opacity: 0.5; cursor: not-allowed; } +button.primary { border-color: var(--ring); } +:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +ul.results { list-style: none; margin: 8px 0 0; padding: 0; max-height: 240px; + overflow-y: auto; } +ul.results li { padding: 6px 8px; border-radius: var(--radius); cursor: pointer; } +ul.results li[aria-selected="true"] { background: var(--bg-muted); outline: 1px solid var(--ring); } +.kind { font-size: 12px; color: var(--text-muted); margin-left: 6px; } +dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; } +dt { color: var(--text-muted); } +dd { margin: 0; overflow-wrap: anywhere; } +.status-ok { color: var(--success); } +.status-error { color: var(--danger); } +.status-warning { color: var(--warning); } +#notice:empty { display: none; } +#notice { margin-top: 12px; padding: 8px; border-radius: var(--radius); background: var(--bg-muted); } +ul.plain { margin: 4px 0 0; padding-left: 18px; } +@media (max-width: 420px) { body { padding: 8px; } dl { grid-template-columns: 1fr; } } +`; + +// Raw: the escapes below belong to the browser script, not to this module. +const SCRIPT = String.raw` +(function () { + 'use strict'; + var PROTOCOL = '${MCP_APPS_PROTOCOL_VERSION}'; + var VERSION = '${packageVersion}'; + var REQUEST_TIMEOUT_MS = 30000; + var nextId = 1; + var pending = {}; + var state = { settings: null, context: null, selected: null, query: '', results: [], busy: false }; + + function el(id) { return document.getElementById(id); } + function text(node, value) { node.textContent = value == null ? '' : String(value); } + function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); } + function announce(message, kind) { + var notice = el('notice'); + notice.className = kind ? 'status-' + kind : ''; + text(notice, message); + } + + // ---- MCP Apps bridge (JSON-RPC over postMessage with the host) ---- + function post(message) { window.parent.postMessage(message, '*'); } + function request(method, params) { + var id = nextId++; + post({ jsonrpc: '2.0', id: id, method: method, params: params }); + return new Promise(function (resolve, reject) { + var timer = setTimeout(function () { + delete pending[id]; + reject(new Error('The app host did not answer')); + }, REQUEST_TIMEOUT_MS); + pending[id] = { resolve: resolve, reject: reject, timer: timer }; + }); + } + function notify(method, params) { post({ jsonrpc: '2.0', method: method, params: params || {} }); } + + window.addEventListener('message', function (event) { + if (event.source !== window.parent) return; + var message = event.data; + if (!message || message.jsonrpc !== '2.0') return; + if (message.method === undefined && message.id !== undefined && pending[message.id]) { + var entry = pending[message.id]; + delete pending[message.id]; + clearTimeout(entry.timer); + if (message.error) entry.reject(new Error(message.error.message || 'Request failed')); + else entry.resolve(message.result); + return; + } + if (message.method === 'ui/notifications/host-context-changed') applyHostContext(message.params); + if (message.method === 'ui/resource-teardown' && message.id !== undefined) { + post({ jsonrpc: '2.0', id: message.id, result: {} }); + } + }); + + function applyHostContext(context) { + if (!context) return; + if (context.theme === 'light' || context.theme === 'dark') { + document.documentElement.setAttribute('data-theme', context.theme); + } + var variables = context.styles && context.styles.variables; + if (variables) { + Object.keys(variables).forEach(function (name) { + if (name.indexOf('--') === 0 && typeof variables[name] === 'string') { + document.documentElement.style.setProperty(name, variables[name]); + } + }); + } + } + + function toolText(result) { + var parts = (result && result.content) || []; + return parts.filter(function (p) { return p.type === 'text'; }) + .map(function (p) { return p.text; }).join('\n'); + } + + function callTool(name, args) { + return request('tools/call', { name: name, arguments: args || {} }).then(function (result) { + if (result && result.isError) { + var error = new Error(toolText(result) || 'The tool failed'); + error.toolError = true; + throw error; + } + return (result && result.structuredContent) || {}; + }); + } + + // ---- Problems a user can act on ---- + function explain(error) { + var message = (error && error.message) || String(error); + if (/invalid_token|re-authenticate|401|sign in again|no longer accepts/i.test(message)) { + return 'Your GitLab sign-in expired or was revoked. Reconnect GitLab from the app’s connection settings, then open this panel again.'; + } + if (/CONNECTION_FAILED|unreachable|ECONNREFUSED|timed out|temporarily unavailable/i.test(message)) { + return 'GitLab is not reachable right now. Your saved settings still apply; try again in a moment.'; + } + if (/did not answer/.test(message)) { + return 'The app host stopped answering. Close and reopen this panel.'; + } + return message; + } + + // ---- Rendering (GitLab strings only ever go through textContent) ---- + function describeScope(scope) { + if (!scope || !scope.path) return 'Everywhere you have access'; + if (scope.type === 'group') { + return 'Group ' + scope.path + (scope.includeSubgroups ? ' and its subgroups' : ' (not its subgroups)'); + } + return 'Project ' + scope.path; + } + + function renderContext() { + var context = state.context; + var settings = state.settings; + if (!context || !settings) return; + text(el('chat-scope'), describeScope(context.scope)); + var values = settings.values || {}; + text(el('default-scope'), values.scope + ? values.scope + (values.scopeIncludeSubgroups ? '' : ' (not its subgroups)') + : 'Everywhere you have access'); + text(el('access-preset'), context.presetName || 'None'); + text(el('access-readonly'), context.readOnly ? 'On: tools that change GitLab are off' : 'Off'); + // What this chat has off: the account's choices and its preset's. + var off = (context.disabledToolGroups || []).map(function (group) { + return (settings.schema.properties['tools_' + group] || {}).title || group; + }); + text(el('access-groups'), off.length ? off.join(', ') : 'All offered groups are on'); + } + + function renderHealth(check) { + var status = el('health-status'); + if (!check) return; + if (check.authenticated) { + status.className = 'status-ok'; + text(status, 'Connected'); + } else { + status.className = 'status-error'; + text(status, 'GitLab did not accept the current sign-in'); + } + text(el('health-account'), check.account); + text(el('health-instance'), check.instance); + text(el('health-gitlab'), check.gitlabVersion + ' (' + check.tier + ')'); + text(el('health-tools'), check.availableTools); + var advice = el('health-advice'); + clear(advice); + (check.warnings || []).concat(check.recommendations || []).forEach(function (line) { + var item = document.createElement('li'); + text(item, line); + advice.appendChild(item); + }); + el('health-advice-block').hidden = advice.childNodes.length === 0; + } + + function renderResults() { + var list = el('results'); + clear(list); + if (state.query.length >= 2 && state.results.length === 0 && !state.busy) { + var empty = document.createElement('li'); + empty.setAttribute('role', 'option'); + empty.setAttribute('aria-disabled', 'true'); + text(empty, 'No project or group matches “' + state.query + '”'); + list.appendChild(empty); + return; + } + state.results.forEach(function (target, index) { + var item = document.createElement('li'); + item.id = 'result-' + index; + item.setAttribute('role', 'option'); + item.tabIndex = 0; + var selected = state.selected && state.selected.path === target.path && state.selected.type === target.type; + item.setAttribute('aria-selected', selected ? 'true' : 'false'); + var name = document.createElement('span'); + text(name, target.name); + var kind = document.createElement('span'); + kind.className = 'kind'; + text(kind, target.type + ' · ' + target.path); + item.appendChild(name); + item.appendChild(kind); + item.addEventListener('click', function () { select(target); }); + item.addEventListener('keydown', function (event) { + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); select(target); } + if (event.key === 'ArrowDown' && item.nextSibling) item.nextSibling.focus(); + if (event.key === 'ArrowUp' && item.previousSibling) item.previousSibling.focus(); + }); + list.appendChild(item); + }); + } + + function renderSelection() { + var target = state.selected; + var subgroups = el('subgroups'); + el('subgroups-row').hidden = !target || target.type !== 'group'; + text(el('preview'), target + ? 'Selected: ' + describeScope({ type: target.type, path: target.path, includeSubgroups: subgroups.checked }) + : 'Choose a project or group to work in.'); + el('use-chat').disabled = !target || state.busy; + el('save-default').disabled = !target || state.busy; + el('cancel').disabled = !target || state.busy; + } + + function select(target) { + state.selected = target; + renderResults(); + renderSelection(); + } + + function setBusy(busy) { + state.busy = busy; + ['use-chat', 'save-default', 'work-everywhere', 'check-again'].forEach(function (id) { + el(id).disabled = busy; + }); + renderSelection(); + document.body.setAttribute('aria-busy', busy ? 'true' : 'false'); + } + + // ---- Data: always the server's authoritative state ---- + function reload() { + return Promise.all([ + callTool('get_settings', {}), + callTool('manage_context', { action: 'show' }), + ]).then(function (results) { + state.settings = results[0]; + state.context = results[1].data || results[1]; + renderContext(); + }); + } + + function checkHealth() { + text(el('health-status'), 'Checking…'); + el('health-status').className = 'muted'; + return callTool('check_connection', {}).then(renderHealth).catch(function (error) { + el('health-status').className = 'status-error'; + text(el('health-status'), explain(error)); + }); + } + + var searchTimer = null; + // Only the newest search may change the panel; an older one finishing late is ignored. + var searchGeneration = 0; + function search(query) { + state.query = query.trim(); + clearTimeout(searchTimer); + var generation = ++searchGeneration; + if (state.query.length < 2) { state.results = []; state.busy = false; renderResults(); return; } + searchTimer = setTimeout(function () { + var current = function () { return generation === searchGeneration; }; + state.busy = true; + callTool('find_scope_targets', { query: state.query }).then(function (found) { + if (current()) state.results = found.targets || []; + }).catch(function (error) { + if (!current()) return; + state.results = []; + announce(explain(error), 'error'); + }).then(function () { + if (!current()) return; + state.busy = false; + renderResults(); + renderSelection(); + }); + }, 300); + } + + function save(where) { + var target = state.selected; + if (!target) return; + var includeSubgroups = target.type === 'group' && el('subgroups').checked; + setBusy(true); + announce('Saving…'); + var action = where === 'chat' + ? callTool('manage_context', { action: 'set_scope', namespace: target.path, includeSubgroups: includeSubgroups }) + : callTool('update_settings', { set: { scope: target.path, scopeIncludeSubgroups: includeSubgroups } }); + action.then(reload).then(function () { + // Success is reported from what the server now holds, not from the request. + var applied = where === 'chat' + ? state.context.scope && state.context.scope.path === target.path + : state.settings.values.scope === target.path; + if (applied) { + announce(where === 'chat' + ? 'This chat now works in ' + describeScope(state.context.scope) + '.' + : 'New chats will work in ' + target.path + '.', 'ok'); + state.selected = null; + renderResults(); + } else { + announce('The server did not keep this change. Check the settings and try again.', 'error'); + } + }).catch(function (error) { + announce(explain(error), 'error'); + }).then(function () { setBusy(false); }); + } + + function workEverywhere() { + setBusy(true); + announce('Saving…'); + // Only this chat's scope changes; its preset, read-only mode and the default for new + // chats stay as they are. + callTool('manage_context', { action: 'clear_scope' }) + .then(reload) + .then(function () { + announce(state.context.scope ? 'A preset still limits where this chat works.' : 'This chat works everywhere you have access.', + state.context.scope ? 'warning' : 'ok'); + }) + .catch(function (error) { announce(explain(error), 'error'); }) + .then(function () { setBusy(false); }); + } + + function start() { + el('search').addEventListener('input', function (event) { search(event.target.value); }); + el('subgroups').addEventListener('change', renderSelection); + el('use-chat').addEventListener('click', function () { save('chat'); }); + el('save-default').addEventListener('click', function () { save('default'); }); + el('cancel').addEventListener('click', function () { state.selected = null; renderResults(); renderSelection(); announce(''); }); + el('work-everywhere').addEventListener('click', workEverywhere); + el('check-again').addEventListener('click', checkHealth); + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'visible' && !state.busy) reload().catch(function () {}); + }); + if (typeof ResizeObserver === 'function') { + new ResizeObserver(function () { + notify('ui/notifications/size-changed', { height: document.documentElement.scrollHeight }); + }).observe(document.body); + } + renderSelection(); + + request('ui/initialize', { + appInfo: { name: 'gitlab-mcp-settings', version: VERSION }, + appCapabilities: {}, + protocolVersion: PROTOCOL, + }).then(function (result) { + applyHostContext(result && result.hostContext); + notify('ui/notifications/initialized'); + if (!result || !result.hostCapabilities || !result.hostCapabilities.serverTools) { + el('main').hidden = true; + announce('This app cannot reach the GitLab server from here. Use the GitLab settings page, or ask the assistant to change the settings.', 'error'); + return; + } + el('loading').hidden = true; + el('main').hidden = false; + return Promise.all([reload(), checkHealth()]); + }).catch(function (error) { + el('loading').hidden = true; + announce(explain(error), 'error'); + }); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); + else start(); +})(); +`; + +const BODY = ` +
+

GitLab connection

+

Choose where this chat works and check what the connection can do.

+
+

Loading…

+ +
+`; + +/** The panel document. */ +export function settingsPanelHtml(): string { + return [ + '', + '', + '', + '', + '', + 'GitLab connection', + ``, + '', + '', + BODY, + ``, + '', + '', + ].join('\n'); +} diff --git a/packages/gitlab-mcp/src/entities/context/settings.ts b/packages/gitlab-mcp/src/entities/context/settings.ts new file mode 100644 index 00000000..28179d80 --- /dev/null +++ b/packages/gitlab-mcp/src/entities/context/settings.ts @@ -0,0 +1,280 @@ +/** + * Native settings of the connection (OpenAI structured settings, `openai/settings`). + * + * The page shows the account's defaults for new chats: the working preset, read-only + * mode, the default project or group, and which tool groups are on. Every value goes + * through the configuration service, so the page, manage_context and tool execution agree. + * Nothing here can loosen what the server's operator configured. + */ + +import { GITLAB_BASE_URL, GITLAB_READ_ONLY_MODE } from '../../config'; +import { + ConfigurationError, + currentCaller, + getConfigurationService, + resolveCaller, + type Caller, +} from '../../configuration'; +import { TOOL_GROUPS, type ToolGroup } from '../../configuration/groups'; +import type { AccountSettingsPatch } from '../../configuration/service'; +import { findNamespaceType } from '../../utils/namespace'; +import { getContextManager } from './context-manager'; + +export const SETTINGS_READ_TOOL = 'get_settings'; +export const SETTINGS_UPDATE_TOOL = 'update_settings'; +export const CONNECTION_CHECK_TOOL = 'check_connection'; +export const SETTINGS_PANEL_TOOL = 'open_settings_panel'; + +/** Value of the preset field when the account uses no preset. */ +const NO_PRESET = 'none'; +const GROUP_PREFIX = 'tools_'; + +type SettingValue = string | number | boolean; + +interface SettingProperty { + type: 'string' | 'boolean'; + title: string; + description?: string; + enum?: string[]; + maxLength?: number; +} + +interface LayoutItem { + kind: 'property' | 'tool'; + property?: string; + tool?: string; + description?: string; +} + +export interface SettingsReadResult { + schema: { type: 'object'; properties: Record; required: string[] }; + values: Record; + layout: Array<{ kind: 'group'; title: string; items: LayoutItem[] }>; +} + +export const SETTINGS_READ_OUTPUT_SCHEMA = { + type: 'object' as const, + properties: { + schema: { type: 'object' }, + values: { type: 'object' }, + layout: { type: 'array', items: { type: 'object' } }, + }, + required: ['schema', 'values', 'layout'], +}; + +export const SETTINGS_UPDATE_INPUT_SCHEMA = { + type: 'object' as const, + properties: { + set: { + type: 'object', + description: 'Settings to change, by name; settings left out keep their value.', + minProperties: 1, + additionalProperties: { type: ['string', 'number', 'boolean'] }, + }, + }, + required: ['set'], + additionalProperties: false, +}; + +export const SETTINGS_UPDATE_OUTPUT_SCHEMA = { + type: 'object' as const, + properties: { values: { type: 'object' } }, + required: ['values'], +}; + +function caller(): Caller { + return currentCaller() ?? resolveCaller(undefined, GITLAB_BASE_URL); +} + +/** Tool groups this server offers; groups its operator switched off are not listed. */ +async function offeredGroups(): Promise { + const { RegistryManager } = await import('../../registry-manager'); + const loaded = new Set(RegistryManager.getInstance().getRegistryKeys()); + return TOOL_GROUPS.filter((group) => group.registries.some((key) => loaded.has(key))); +} + +async function settingsPage(): Promise { + const who = caller(); + const [resolved, presets, groups] = await Promise.all([ + getConfigurationService().resolve(who), + getContextManager().listPresets(), + offeredGroups(), + ]); + const account = resolved.account; + const disabled = new Set(account.disabledToolGroups ?? []); + const presetNames = presets.map((preset) => preset.name); + // A saved preset that no longer exists stays visible, so the page shows what applies. + if (account.preset && !presetNames.includes(account.preset)) presetNames.push(account.preset); + + const properties: Record = { + preset: { + type: 'string', + title: 'Working preset', + description: resolved.presetUnavailable + ? `The saved preset '${resolved.presetUnavailable}' no longer exists; the connection works read-only until you choose another one.` + : 'Which tools and actions new chats start with.', + enum: [NO_PRESET, ...presetNames], + }, + readOnly: { + type: 'boolean', + title: 'Read-only', + description: GITLAB_READ_ONLY_MODE + ? 'Set by the server administrator; it cannot be turned off here.' + : 'Only read from GitLab: tools that change anything are turned off.', + }, + scope: { + type: 'string', + title: 'Default project or group', + description: + 'A project or group path such as my-group/my-project. New chats work only there; leave empty to work everywhere you have access.', + maxLength: 255, + }, + scopeIncludeSubgroups: { + type: 'boolean', + title: 'Include subgroups', + description: 'For a group: also allow projects in its subgroups.', + }, + }; + const values: Record = { + preset: account.preset ?? NO_PRESET, + readOnly: GITLAB_READ_ONLY_MODE || account.readOnly === true, + scope: account.scope?.path ?? '', + scopeIncludeSubgroups: account.scope?.includeSubgroups ?? true, + }; + for (const group of groups) { + properties[GROUP_PREFIX + group.id] = { type: 'boolean', title: group.title }; + values[GROUP_PREFIX + group.id] = !disabled.has(group.id); + } + + return { + schema: { type: 'object', properties, required: Object.keys(properties) }, + values, + layout: [ + { + kind: 'group', + title: `Connection: ${who.accountLabel} on ${hostOf(who.instanceUrl)}`, + items: [ + { + kind: 'tool', + tool: CONNECTION_CHECK_TOOL, + description: 'Check the account, its permissions and whether GitLab is reachable.', + }, + { + kind: 'tool', + tool: SETTINGS_PANEL_TOOL, + description: 'Search projects and groups and preview what the connection can do.', + }, + ], + }, + { + kind: 'group', + title: 'Defaults for new chats', + items: ['preset', 'readOnly', 'scope', 'scopeIncludeSubgroups'].map((property) => ({ + kind: 'property' as const, + property, + })), + }, + { + kind: 'group', + title: 'Tool groups (groups the server administrator turned off are not listed)', + items: groups.map((group) => ({ + kind: 'property' as const, + property: GROUP_PREFIX + group.id, + })), + }, + ], + }; +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + +/** The settings page: field schema, current values and layout. */ +export async function readSettings(): Promise { + return settingsPage(); +} + +/** + * Apply a patch from the settings page. Every changed field is checked first; when any is + * invalid nothing is saved. Returns the values as saved. + */ +export async function updateSettings( + set: Record, +): Promise<{ values: Record }> { + const page = await settingsPage(); + validateSettings(set, page.schema.properties); + + const patch: AccountSettingsPatch = {}; + if (typeof set.preset === 'string') { + patch.preset = set.preset === NO_PRESET ? null : set.preset; + } + if (typeof set.readOnly === 'boolean') { + if (GITLAB_READ_ONLY_MODE && !set.readOnly) { + throw new ConfigurationError( + 'Read-only mode is set by the server administrator and cannot be turned off', + ); + } + patch.readOnly = set.readOnly; + } + if (typeof set.scope === 'string') { + // Without an explicit choice the subgroup flag is taken from the settings saved at + // write time, not from this page, which another chat may have changed since. + const includeSubgroups = + typeof set.scopeIncludeSubgroups === 'boolean' ? set.scopeIncludeSubgroups : undefined; + patch.scope = await scopeSetting(set.scope.trim(), includeSubgroups); + } else if (typeof set.scopeIncludeSubgroups === 'boolean') { + // A change of the saved scope, applied to it as saved at write time. + patch.scopeIncludeSubgroups = set.scopeIncludeSubgroups; + } + const groupChanges = Object.entries(set).filter(([name]) => name.startsWith(GROUP_PREFIX)); + if (groupChanges.length > 0) { + // Each toggle is applied to the groups saved at write time: a concurrent toggle of + // another group, or a group the operator hid, keeps its choice. + patch.toolGroups = Object.fromEntries( + groupChanges.map(([name, enabled]) => [name.slice(GROUP_PREFIX.length), enabled === true]), + ); + } + + await getConfigurationService().updateAccount(caller(), patch); + return { values: (await settingsPage()).values }; +} + +/** Every changed field must exist on the page and match its type and choices. */ +function validateSettings( + set: Record, + properties: SettingsReadResult['schema']['properties'], +): void { + for (const [name, value] of Object.entries(set)) { + const property = properties[name]; + if (!property) throw new ConfigurationError(`Unknown setting: ${name}`); + if (typeof value !== property.type) { + throw new ConfigurationError(`Setting ${name} must be a ${property.type}`); + } + if (property.enum && !property.enum.includes(value as string)) { + throw new ConfigurationError(`Setting ${name} must be one of: ${property.enum.join(', ')}`); + } + } +} + +/** The default scope for a path; an empty path clears it. */ +async function scopeSetting( + path: string, + includeSubgroups: boolean | undefined, +): Promise { + if (path === '') return null; + // The path is checked against GitLab, so a typo is reported instead of saved as a guess. + const type = await findNamespaceType(path); + if (!type) { + throw new ConfigurationError( + `No project or group '${path}' was found on GitLab, or GitLab could not be reached`, + ); + } + return type === 'group' + ? { type, path, includeSubgroups } + : { type, path, includeSubgroups: false }; +} diff --git a/packages/gitlab-mcp/src/entities/context/types.ts b/packages/gitlab-mcp/src/entities/context/types.ts index 85ca7998..07088e80 100644 --- a/packages/gitlab-mcp/src/entities/context/types.ts +++ b/packages/gitlab-mcp/src/entities/context/types.ts @@ -40,6 +40,8 @@ export interface SessionContext { presetName?: string; /** Read-only mode status */ readOnly: boolean; + /** Tool groups off for this session, by the account or its preset */ + disabledToolGroups?: string[]; /** Current scope restriction */ scope?: RuntimeScope; /** OAuth mode indicator */ diff --git a/packages/gitlab-mcp/src/entities/context/whoami.ts b/packages/gitlab-mcp/src/entities/context/whoami.ts index ff7f91aa..ca3c4ce2 100644 --- a/packages/gitlab-mcp/src/entities/context/whoami.ts +++ b/packages/gitlab-mcp/src/entities/context/whoami.ts @@ -16,7 +16,7 @@ * without restarting the MCP server. */ -import { GITLAB_BASE_URL, GITLAB_READ_ONLY_MODE } from '../../config'; +import { GITLAB_READ_ONLY_MODE } from '../../config'; import { logInfo, logDebug } from '../../logger'; import { isOAuthEnabled } from '../../oauth/index.js'; import { getGitLabApiUrlFromContext, getTokenContext } from '../../oauth/token-context'; @@ -25,6 +25,7 @@ import { getTokenCreationUrl, getScopeCapabilities } from '../../services/TokenS import { RegistryManager } from '../../registry-manager'; import { sendToolsListChangedNotification } from '../../server'; import { enhancedFetch } from '../../utils/fetch'; +import { getGitLabBaseUrl } from '../../utils/gitlab-base-url'; import { getContextManager } from './context-manager'; import { WhoamiResult, @@ -40,7 +41,7 @@ import { * Get GitLab host from API URL */ function getHost(): string { - const apiUrl = getGitLabApiUrlFromContext() ?? GITLAB_BASE_URL; + const apiUrl = getGitLabBaseUrl(); try { const url = new URL(apiUrl); return url.hostname; @@ -55,12 +56,9 @@ function getHost(): string { */ async function fetchCurrentUser(): Promise { try { - const response = await enhancedFetch( - `${getGitLabApiUrlFromContext() ?? GITLAB_BASE_URL}/api/v4/user`, - { - retry: false, - }, - ); + const response = await enhancedFetch(`${getGitLabBaseUrl()}/api/v4/user`, { + retry: false, + }); if (!response.ok) { logDebug('Failed to fetch current user', { status: response.status }); @@ -158,7 +156,7 @@ function buildServerInfo(): WhoamiServerInfo { return { host: getHost(), - apiUrl: getGitLabApiUrlFromContext() ?? GITLAB_BASE_URL, + apiUrl: getGitLabBaseUrl(), version, tier, edition, @@ -198,9 +196,9 @@ function buildCapabilities(tokenInfo: WhoamiTokenInfo | null): WhoamiCapabilitie /** * Build current context info from ContextManager */ -function buildContextInfo(): WhoamiContextInfo { +async function buildContextInfo(): Promise { const contextManager = getContextManager(); - const context = contextManager.getContext(); + const context = await contextManager.getContext(); return { activePreset: context.presetName ?? null, @@ -303,7 +301,7 @@ function generateRecommendations( recommendations.push({ action: 'renew_token', message: 'Your token has expired. Create a new token to restore access.', - url: getTokenCreationUrl(GITLAB_BASE_URL, ['api', 'read_user']), + url: getTokenCreationUrl(serverInfo.apiUrl, ['api', 'read_user']), priority: 'high', }); } @@ -318,7 +316,7 @@ function generateRecommendations( recommendations.push({ action: 'renew_token', message: `Your token expires in ${tokenInfo.daysUntilExpiry} day(s). Renew soon to avoid service interruption.`, - url: getTokenCreationUrl(GITLAB_BASE_URL, ['api', 'read_user']), + url: getTokenCreationUrl(serverInfo.apiUrl, ['api', 'read_user']), priority: 'medium', }); } @@ -330,7 +328,7 @@ function generateRecommendations( recommendations.push({ action: 'create_new_token', message: "Create a token with 'api' scope for full GitLab functionality", - url: getTokenCreationUrl(GITLAB_BASE_URL, ['api', 'read_user']), + url: getTokenCreationUrl(serverInfo.apiUrl, ['api', 'read_user']), priority: 'high', }); } @@ -346,7 +344,7 @@ function generateRecommendations( recommendations.push({ action: 'add_scope', message: "Add 'api' or 'read_api' scope to enable project, issue, and MR operations", - url: getTokenCreationUrl(GITLAB_BASE_URL, ['api', 'read_user']), + url: getTokenCreationUrl(serverInfo.apiUrl, ['api', 'read_user']), priority: 'high', }); } @@ -437,7 +435,7 @@ export async function executeWhoami(): Promise { const serverInfo = buildServerInfo(); const capabilities = buildCapabilities(tokenInfo); - const contextInfo = buildContextInfo(); + const contextInfo = await buildContextInfo(); const warnings = generateWarnings(tokenInfo, capabilities, effectiveIsAdmin); // Honest admin signal: role present but elevation inactive means admin tools diff --git a/packages/gitlab-mcp/src/entities/core/registry.ts b/packages/gitlab-mcp/src/entities/core/registry.ts index f2c0df9c..b969f122 100644 --- a/packages/gitlab-mcp/src/entities/core/registry.ts +++ b/packages/gitlab-mcp/src/entities/core/registry.ts @@ -16,6 +16,7 @@ import { ManageTodosSchema, } from './schema'; import { enhancedFetch } from '../../utils/fetch'; +import { getGitLabBaseUrl } from '../../utils/gitlab-base-url'; import { normalizeProjectId } from '../../utils/projectIdentifier'; import { fetchUsers, smartUserSearch, type UserSearchParams } from '../../utils/smart-user-search'; import { cleanGidsFromObject } from '../../utils/idConversion'; @@ -143,7 +144,7 @@ export const coreToolRegistry: ToolRegistry = new Map { - if (toolName !== 'manage_context' || healthMonitor.isInstanceReachable(effectiveInstanceUrl)) { + if ( + !CONFIGURATION_TOOLS.has(toolName) || + healthMonitor.isInstanceReachable(effectiveInstanceUrl) + ) { return null; } if (LOG_FORMAT === 'condensed') { @@ -423,15 +462,169 @@ function formatBootstrapFailure(ctx: BootstrapContext, initError: unknown): Call return withReauthChallenge(errorToolResult(connError), initError); } -function recordCallContext( - sessionContext: import('./entities/context/types').SessionContext, -): void { +/** + * The tool list narrowed by the caller's settings. When the settings cannot be read the + * list stays as the registry built it: listing is only visibility, and every call is + * still checked (and refused while the settings cannot be read). + */ +async function withoutRestrictedTools( + tools: T[], + caller: Caller, + registryManager: import('./registry-manager').RegistryManager, +): Promise { + let policy: EffectivePolicy; + try { + 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.filter( + (tool) => toolRestriction(policy, registryManager.getToolFacts(tool.name)) === null, + ); +} + +/** A refusal for a call the caller's settings do not allow, or null when it may run. */ +function settingsRejection( + toolName: string, + args: Record, + policy: EffectivePolicy, + registryManager: import('./registry-manager').RegistryManager, +): CallToolResult | null { + const reason = callRestriction(policy, registryManager.getToolFacts(toolName), args); + if (reason === null) return null; + const action = typeof args.action === 'string' ? args.action : undefined; + const call = action ? `${toolName} ${action}` : toolName; + const message = + `'${call}' is not allowed by the current settings: ` + + `${reason}. The account settings are changed with update_settings, this session's ` + + 'with manage_context.'; + recordEarlyReturnError(toolName, action, message); + return errorToolResult({ error: message }); +} + +/** + * Runs the tool through the registry (per-URL cache). A listing that names no target + * reads the working scope instead of everything the account sees. + */ +async function executeInScope( + registryManager: import('./registry-manager').RegistryManager, + toolName: string, + requested: Record, + policy: EffectivePolicy, + instanceUrl: string | undefined, + requestScopes: readonly string[] | undefined, +): Promise { + const run = (tool: string, toolArgs: Record): Promise => + requestScopes === undefined + ? registryManager.executeTool(tool, toolArgs, instanceUrl) + : registryManager.executeTool(tool, toolArgs, instanceUrl, requestScopes); + if (policy.scopeEnforcer && toolName === 'manage_todos' && requested.action === 'mark_all_done') { + return markScopeTodosDone(run, policy.scopeEnforcer); + } + const scopeProjects = + policy.scope && toolName === 'browse_projects' && requested.action === 'list' + ? scopeProjectsOnly(policy.scope) + : null; + if (scopeProjects && requested.group_id === undefined) { + return listScopeProjects(run, scopeProjects, requested); + } + const args = policy.scope ? scopedArgs(toolName, requested, policy.scope) : requested; + const executed = await run(toolName, args); + return policy.scopeEnforcer + ? scopedResult(toolName, args, executed, policy.scopeEnforcer) + : executed; +} + +/** + * browse_projects list for a scope made of projects: the projects themselves, filtered by + * the listing's search and paged like it. A project GitLab does not return is left out. + */ +async function listScopeProjects( + run: (tool: string, args: Record) => Promise, + projects: string[], + requested: Record, +): Promise { + const found = await Promise.allSettled( + projects.map((project_id) => run('browse_projects', { action: 'get', project_id })), + ); + const search = typeof requested.search === 'string' ? requested.search.toLowerCase() : ''; + const matching = found + .flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])) + .filter((project) => { + if (!search) return true; + const { name, path_with_namespace } = project as { + name?: unknown; + path_with_namespace?: unknown; + }; + return [name, path_with_namespace].some( + (value) => typeof value === 'string' && value.toLowerCase().includes(search), + ); + }); + const perPage = typeof requested.per_page === 'number' ? requested.per_page : 20; + const page = typeof requested.page === 'number' ? requested.page : 1; + return matching.slice((page - 1) * perPage, page * perPage); +} + +const TODO_PAGE_SIZE = 100; +const TODO_CONCURRENCY = 5; + +/** + * mark_all_done for a scoped chat: GitLab's own would also clear todos outside the scope, + * so the scope's pending todos are marked done one by one. + */ +async function markScopeTodosDone( + run: (tool: string, args: Record) => Promise, + enforcer: ScopeEnforcer, +): Promise { + // Collected before marking: a todo marked done leaves the pending pages being read. + const ids: unknown[] = []; + for (let page = 1; ; page++) { + const todos = await run('browse_todos', { + action: 'list', + state: 'pending', + per_page: TODO_PAGE_SIZE, + page, + }); + if (!Array.isArray(todos)) break; + const inScope = scopedResult('browse_todos', { action: 'list' }, todos, enforcer) as Array<{ + id?: unknown; + }>; + for (const todo of inScope) ids.push(todo.id); + if (todos.length < TODO_PAGE_SIZE) break; + } + // A few requests at a time, so a large scope does not run into GitLab's rate limits; + // a todo that fails is reported and the rest are still marked. + const failed: Array<{ id: unknown; error: string }> = []; + let next = 0; + const worker = async (): Promise => { + while (next < ids.length) { + const id = ids[next++]; + try { + await run('manage_todos', { action: 'mark_done', id }); + } catch (error: unknown) { + failed.push({ id, error: error instanceof Error ? error.message : String(error) }); + } + } + }; + await Promise.all(Array.from({ length: Math.min(TODO_CONCURRENCY, ids.length) }, worker)); + const marked = ids.length - failed.length; + const failedNote = failed.length > 0 ? `; ${failed.length} could not be marked` : ''; + return { + success: failed.length === 0, + marked, + failed, + message: `Marked ${marked} of ${ids.length} todos of the working scope as done${failedNote}; todos outside it were left pending`, + }; +} + +function recordCallContext(scopePath: string | undefined, readOnly: boolean): void { const requestTracker = getRequestTracker(); // Capture current context and read-only state for access logging - if (sessionContext.scope?.path) { - requestTracker.setContextForCurrentRequest(sessionContext.scope.path); + if (scopePath) { + requestTracker.setContextForCurrentRequest(scopePath); } - requestTracker.setReadOnlyForCurrentRequest(sessionContext.readOnly); + requestTracker.setReadOnlyForCurrentRequest(readOnly); // Increment tool count for connection tracking const currentRequestId = getCurrentRequestId(); @@ -570,6 +763,42 @@ export async function setupHandlers(server: Server): Promise { // No authentication configured - server will respond to tools/list but tool calls will fail logInfo('Skipping connection initialization - no authentication configured'); } + + // The settings panel is the server's one resource: an MCP App document. It holds no + // account data; everything it shows comes from tool calls made with the caller's own + // authorization. + server.setRequestHandler(ListResourcesRequestSchema, () => { + const rejection = resourceScopeRejection(); + if (rejection) throw rejection; + return { + resources: [ + { + uri: SETTINGS_PANEL_URI, + name: 'GitLab connection panel', + mimeType: MCP_APP_MIME_TYPE, + _meta: SETTINGS_PANEL_RESOURCE_META, + }, + ], + }; + }); + server.setRequestHandler(ReadResourceRequestSchema, (request) => { + const rejection = resourceScopeRejection(); + if (rejection) throw rejection; + if (request.params.uri !== SETTINGS_PANEL_URI) { + throw new McpError(ErrorCode.InvalidParams, `Unknown resource: ${request.params.uri}`); + } + return { + contents: [ + { + uri: SETTINGS_PANEL_URI, + mimeType: MCP_APP_MIME_TYPE, + text: settingsPanelHtml(), + _meta: SETTINGS_PANEL_RESOURCE_META, + }, + ], + }; + }); + // List tools handler // Uses per-session instance URL tracking so each session receives the tool list // filtered for its target GitLab instance (#398). The sessionId from RequestHandlerExtra @@ -607,10 +836,15 @@ 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 tools = + const catalog = scopes === undefined ? registryManager.getAllToolDefinitions(sessionInstanceUrl, mode) : registryManager.getAllToolDefinitions(sessionInstanceUrl, mode, scopes); + const tools = await withoutRestrictedTools( + catalog, + resolveCaller(listToolsSessionId, sessionInstanceUrl ?? GITLAB_BASE_URL), + registryManager, + ); logInfo('Returning tools list', { toolCount: tools.length }); @@ -699,12 +933,14 @@ export async function setupHandlers(server: Server): Promise { if (!securitySchemes || tool._meta?.securitySchemes !== undefined) { return { ...tool, inputSchema }; } - // Mirrored in _meta for clients whose descriptor schema drops unknown top-level fields. + // A tool that opens an app panel also needs the panel resource read. Mirrored in _meta + // for clients whose descriptor schema drops unknown top-level fields. + const schemes = tool._meta?.ui ? APP_PANEL_SECURITY_SCHEMES : securitySchemes; return { ...tool, inputSchema, - securitySchemes, - _meta: { ...tool._meta, securitySchemes }, + securitySchemes: schemes, + _meta: { ...tool._meta, securitySchemes: schemes }, }; }); @@ -769,6 +1005,8 @@ export async function setupHandlers(server: Server): Promise { const { getSessionManager: getSessionMgrForCall } = await import('./session-manager'); getSessionMgrForCall().setSessionInstanceUrl(callSessionId, requestInstanceUrl); } + // Whose settings apply, from the authenticated request only. + const caller = resolveCaller(callSessionId, requestInstanceUrl); // Flag to prevent late reportSuccess/reportError from a timed-out handlerWork() // overwriting the timeout signal already sent to HealthMonitor. @@ -818,6 +1056,18 @@ export async function setupHandlers(server: Server): Promise { const toolName = request.params.name; const toolArguments = request.params.arguments; + // The caller's settings, checked before anything reaches GitLab. A call is refused + // when they cannot be read: allowing it could bypass a restriction. + const { RegistryManager: CallRegistry } = await import('./registry-manager'); + const configuration = await getConfigurationService().resolve(caller); + const settingsRefusal = settingsRejection( + toolName, + toolArguments ?? {}, + configuration.policy, + CallRegistry.getInstance(), + ); + if (settingsRefusal) return settingsRefusal; + // Early return: instance unreachable for non-context tools // (isInstanceReachable treats untracked URLs as reachable before HealthMonitor.initialize) const unreachableResult = checkUnreachableInstance( @@ -861,8 +1111,11 @@ export async function setupHandlers(server: Server): Promise { if (LOG_FORMAT === 'condensed') { const requestTracker = getRequestTracker(); requestTracker.setToolForCurrentRequest(toolName, action); - const { getContextManager } = await import('./entities/context/context-manager'); - recordCallContext(getContextManager().getContext()); + const scope = configuration.policy.scope; + recordCallContext( + scope?.project ?? scope?.group ?? scope?.namespace ?? scope?.projects?.[0], + GITLAB_READ_ONLY_MODE || configuration.policy.readOnly, + ); } try { @@ -897,17 +1150,14 @@ export async function setupHandlers(server: Server): Promise { }); } - // Execute the tool using the registry manager (per-URL cache) - const args = request.params.arguments ?? {}; - const result = - requestScopes === undefined - ? await registryManager.executeTool(toolName, args, effectiveInstanceUrl) - : await registryManager.executeTool( - toolName, - args, - effectiveInstanceUrl, - requestScopes, - ); + const result = await executeInScope( + registryManager, + toolName, + request.params.arguments ?? {}, + configuration.policy, + effectiveInstanceUrl, + requestScopes, + ); // Guard against TOCTOU cache miss: hasToolHandler returned true but a // concurrent refreshCache swapped the lookup table before executeTool ran. @@ -939,7 +1189,7 @@ export async function setupHandlers(server: Server): Promise { // complete — but the alternative (no timeout) leaves bootstrap unbounded // if the instance is hung. The timedOut flag prevents late reportSuccess/ // reportError from overwriting the timeout health signal. - const result = await Promise.race([handlerWork(), timeoutPromise]); + const result = await Promise.race([runWithCaller(caller, handlerWork), timeoutPromise]); if (result === HANDLER_TIMEOUT_SYMBOL) { // timedOut already set in timer callback — handler is still running but we respond diff --git a/packages/gitlab-mcp/src/oauth/session-store.ts b/packages/gitlab-mcp/src/oauth/session-store.ts index 15b9b4b4..996ba4c1 100644 --- a/packages/gitlab-mcp/src/oauth/session-store.ts +++ b/packages/gitlab-mcp/src/oauth/session-store.ts @@ -23,6 +23,7 @@ import { RegisteredOAuthClient, } from './types'; import { SessionStorageBackend, createStorageBackend } from './storage'; +import type { AccountSettings, AccountSettingsRecord } from '../configuration/types'; import { MemoryStorageBackend } from './storage/memory'; import { logInfo, logError, logDebug } from '../logger'; @@ -317,6 +318,36 @@ export class SessionStore { return this.backend.countClientsRegisteredSince(registeredFrom, since); } + // ============================================================ + // Account Settings + // ============================================================ + + /** + * Whether account settings can live here: the backend survives a restart and has the + * settings methods, which a database package older than this server lacks. + */ + keepsAccountSettings(): boolean { + const backend = this.backend as Partial; + return ( + this.backend.type !== 'memory' && + typeof backend.getAccountSettings === 'function' && + typeof backend.putAccountSettings === 'function' + ); + } + + async getAccountSettings(accountKey: string): Promise { + return this.backend.getAccountSettings(accountKey); + } + + /** Compare-and-set write; undefined when another write changed the settings first. */ + async putAccountSettings( + accountKey: string, + settings: AccountSettings, + expectedVersion: number, + ): Promise { + return this.backend.putAccountSettings(accountKey, settings, expectedVersion); + } + // ============================================================ // MCP Session Mapping Operations // ============================================================ diff --git a/packages/gitlab-mcp/src/oauth/storage/file.ts b/packages/gitlab-mcp/src/oauth/storage/file.ts index e3614415..4c31d721 100644 --- a/packages/gitlab-mcp/src/oauth/storage/file.ts +++ b/packages/gitlab-mcp/src/oauth/storage/file.ts @@ -29,7 +29,9 @@ import { STORAGE_DATA_VERSION, } from './types'; import { MemoryStorageBackend } from './memory'; +import type { AccountSettings, AccountSettingsRecord } from '../../configuration/types'; import { logInfo, logDebug, logError, logWarn } from '../../logger'; +import { syncDirectory } from '../../utils/sync-directory'; export interface FileStorageOptions { /** Path to the storage file */ @@ -44,20 +46,6 @@ function isPresent(record: T | undefined): record is T { return record !== undefined; } -/** - * Flush a directory entry change (the rename) to disk. Windows cannot open a directory - * for syncing; NTFS journals the rename itself. - */ -async function syncDirectory(dir: string): Promise { - if (process.platform === 'win32') return; - const handle = await fs.promises.open(dir, 'r'); - try { - await handle.sync(); - } finally { - await handle.close(); - } -} - export class FileStorageBackend implements SessionStorageBackend { readonly type = 'file' as const; @@ -168,6 +156,7 @@ export class FileStorageBackend implements SessionStorageBackend { authCodes: validAuthCodes, mcpSessionMappings: data.mcpSessionMappings, clients: data.clients, + accountSettings: data.accountSettings, }); const stats = await this.memory.getStats(); @@ -243,7 +232,7 @@ export class FileStorageBackend implements SessionStorageBackend { private transition( change: () => Promise, changed: (result: T) => result is Changed, - undo: (result: Changed) => Promise, + undo: (result: Changed) => unknown, ): Promise { this.cancelPendingSave(); return this.enqueue(async () => { @@ -276,6 +265,7 @@ export class FileStorageBackend implements SessionStorageBackend { authCodes: exportedData.authCodes, mcpSessionMappings: exportedData.mcpSessionMappings, clients: exportedData.clients, + accountSettings: exportedData.accountSettings, }; // Atomic write: write to temp file, then rename. Both are flushed to disk, so a power @@ -479,6 +469,29 @@ export class FileStorageBackend implements SessionStorageBackend { return this.memory.countClientsRegisteredSince(registeredFrom, since); } + // Account settings + async getAccountSettings(accountKey: string): Promise { + return this.memory.getAccountSettings(accountKey); + } + + putAccountSettings( + accountKey: string, + settings: AccountSettings, + expectedVersion: number, + ): Promise { + // Written through and undone when the write fails: settings reported as not saved + // must not apply now or after a restart. + let previous: AccountSettingsRecord | undefined; + return this.transition( + async () => { + previous = await this.memory.getAccountSettings(accountKey); + return this.memory.putAccountSettings(accountKey, settings, expectedVersion); + }, + isPresent, + () => this.memory.restoreAccountSettings(accountKey, previous), + ); + } + // Single-use consumption and refresh rotation: written through before they are // reported, so a crash cannot make a spent code, flow or refresh token usable again. consumeAuthCode(code: string): Promise { diff --git a/packages/gitlab-mcp/src/oauth/storage/memory.ts b/packages/gitlab-mcp/src/oauth/storage/memory.ts index d71f8992..50192790 100644 --- a/packages/gitlab-mcp/src/oauth/storage/memory.ts +++ b/packages/gitlab-mcp/src/oauth/storage/memory.ts @@ -13,6 +13,7 @@ import { RegisteredOAuthClient, } from '../types'; import { SessionStorageBackend, SessionStorageStats } from './types'; +import type { AccountSettings, AccountSettingsRecord } from '../../configuration/types'; import { logInfo, logWarn, logError, logDebug, truncateId } from '../../logger'; export interface MemoryStorageOptions { @@ -43,6 +44,7 @@ export class MemoryStorageBackend implements SessionStorageBackend { private refreshTokenToSession = new Map(); private mcpSessionToOAuthSession = new Map(); private clients = new Map(); + private accountSettings = new Map(); /** Session id -> end of its GitLab refresh lease (transient, not exported). */ private gitlabRefreshLeases = new Map(); private cleanupIntervalId: ReturnType | null = null; @@ -230,6 +232,38 @@ export class MemoryStorageBackend implements SessionStorageBackend { return count; } + // Account settings: records are copied in and out, so a caller changing what it holds + // never changes the stored settings. + async getAccountSettings(accountKey: string): Promise { + const record = this.accountSettings.get(accountKey); + return record ? structuredClone(record) : undefined; + } + + // Compare and write run without an await in between: of concurrent writes of one + // version in this process exactly one succeeds. + async putAccountSettings( + accountKey: string, + settings: AccountSettings, + expectedVersion: number, + ): Promise { + const current = this.accountSettings.get(accountKey)?.version ?? 0; + if (current !== expectedVersion) return undefined; + const record: AccountSettingsRecord = { + accountKey, + settings: structuredClone(settings), + version: current + 1, + updatedAt: Date.now(), + }; + this.accountSettings.set(accountKey, record); + return structuredClone(record); + } + + /** Restore the record a failed write replaced (undefined: there was none). */ + restoreAccountSettings(accountKey: string, previous: AccountSettingsRecord | undefined): void { + if (previous) this.accountSettings.set(accountKey, previous); + else this.accountSettings.delete(accountKey); + } + // Single-use consumption: lookup and removal run without an await in between, so in // this process exactly one caller gets the record. async consumeAuthCode(code: string): Promise { @@ -375,9 +409,11 @@ export class MemoryStorageBackend implements SessionStorageBackend { authCodes: AuthorizationCode[]; mcpSessionMappings: Array<{ mcpSessionId: string; oauthSessionId: string }>; clients: RegisteredOAuthClient[]; + accountSettings: AccountSettingsRecord[]; } { return { clients: Array.from(this.clients.values()), + accountSettings: Array.from(this.accountSettings.values()), sessions: Array.from(this.sessions.values()), deviceFlows: Array.from(this.deviceFlows.entries()).map(([state, flow]) => ({ state, flow })), authCodeFlows: Array.from(this.authCodeFlows.entries()).map(([internalState, flow]) => ({ @@ -399,12 +435,17 @@ export class MemoryStorageBackend implements SessionStorageBackend { authCodes?: AuthorizationCode[]; mcpSessionMappings?: Array<{ mcpSessionId: string; oauthSessionId: string }>; clients?: RegisteredOAuthClient[]; + accountSettings?: AccountSettingsRecord[]; }): void { // Clear existing data this.clients.clear(); for (const client of data.clients ?? []) { this.clients.set(client.clientId, client); } + this.accountSettings.clear(); + for (const record of data.accountSettings ?? []) { + this.accountSettings.set(record.accountKey, record); + } this.sessions.clear(); this.deviceFlows.clear(); this.authCodeFlows.clear(); diff --git a/packages/gitlab-mcp/src/oauth/storage/types.ts b/packages/gitlab-mcp/src/oauth/storage/types.ts index 8ea0e5b2..ca947718 100644 --- a/packages/gitlab-mcp/src/oauth/storage/types.ts +++ b/packages/gitlab-mcp/src/oauth/storage/types.ts @@ -12,6 +12,7 @@ import { AuthorizationCode, RegisteredOAuthClient, } from '../types'; +import type { AccountSettings, AccountSettingsRecord } from '../../configuration/types'; /** * Session storage backend interface @@ -112,6 +113,19 @@ export interface SessionStorageBackend { updates: Partial, ): Promise; + // Account settings, shared by every replica + getAccountSettings(accountKey: string): Promise; + /** + * Store an account's settings only while its stored version is still `expectedVersion` + * (0: none stored yet). Returns the stored record, or undefined when another write + * changed the settings first, so a stale edit never overwrites a newer one. + */ + putAccountSettings( + accountKey: string, + settings: AccountSettings, + expectedVersion: number, + ): Promise; + // Lifecycle initialize(): Promise; /** Remove expired sessions, flows, codes and expired never-used client registrations. */ @@ -172,6 +186,8 @@ export interface StorageData { mcpSessionMappings: Array<{ mcpSessionId: string; oauthSessionId: string }>; /** Registered OAuth clients; absent in files written before client persistence */ clients?: RegisteredOAuthClient[]; + /** Account settings; absent in files written before settings persistence */ + accountSettings?: AccountSettingsRecord[]; } /** Current storage data format version */ diff --git a/packages/gitlab-mcp/src/oauth/tool-auth.ts b/packages/gitlab-mcp/src/oauth/tool-auth.ts index 0d8fe898..85c27962 100644 --- a/packages/gitlab-mcp/src/oauth/tool-auth.ts +++ b/packages/gitlab-mcp/src/oauth/tool-auth.ts @@ -7,7 +7,7 @@ * own reconnect flow instead of asking for a token in chat. */ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { ErrorCode, McpError, type CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { loadOAuthConfig } from './config'; import { getTokenContext } from './token-context'; import { defaultResource, resourceMetadataUrl } from './resource'; @@ -27,6 +27,38 @@ export const OAUTH_SECURITY_SCHEMES: readonly OAuthSecurityScheme[] = [ /** Scope a tool call needs, the one every tool declares. */ const TOOL_SCOPE = OAUTH_SECURITY_SCHEMES[0].scopes[0]; +/** Scope a resource list or read needs. */ +const RESOURCE_SCOPE = 'mcp:resources'; + +/** A tool that opens an app panel also needs its panel resource read. */ +export const APP_PANEL_SECURITY_SCHEMES: readonly OAuthSecurityScheme[] = [ + { type: 'oauth2', scopes: [TOOL_SCOPE, RESOURCE_SCOPE] }, +]; + +/** + * The error for a resource list or read whose MCP access token lacks `mcp:resources` + * (RFC 6750 section 3.1 insufficient_scope, with its challenge in the error data), or + * undefined when it may proceed. As for tools, calls without an OAuth token context are not + * scope-checked. + */ +export function resourceScopeRejection(): McpError | undefined { + const config = loadOAuthConfig(); + const context = getTokenContext(); + if (!config || !context?.mcpScopes || context.mcpScopes.includes(RESOURCE_SCOPE)) { + return undefined; + } + const resource = context.resource ?? defaultResource(config.issuer); + const challenge = + `Bearer resource_metadata="${resourceMetadataUrl(resource)}", ` + + `error="insufficient_scope", scope="${RESOURCE_SCOPE}", ` + + `error_description="This access token does not allow resource reads; reconnect to grant ${RESOURCE_SCOPE}"`; + return new McpError( + ErrorCode.InvalidRequest, + `insufficient_scope: the access token was not granted ${RESOURCE_SCOPE}`, + { 'mcp/www_authenticate': [challenge] }, + ); +} + /** * Error result for a tool call whose MCP access token lacks `mcp:tools` (RFC 6750 section * 3.1 insufficient_scope), or undefined when the call may proceed. The request middleware diff --git a/packages/gitlab-mcp/src/profiles/scope-enforcer.ts b/packages/gitlab-mcp/src/profiles/scope-enforcer.ts index b8100599..3b90d12c 100644 --- a/packages/gitlab-mcp/src/profiles/scope-enforcer.ts +++ b/packages/gitlab-mcp/src/profiles/scope-enforcer.ts @@ -216,9 +216,10 @@ export class ScopeEnforcer { * @returns true if allowed, false if outside scope */ isGroupAllowed(groupPath: string): boolean { - // If no group restrictions are defined, allow all groups + // Without group restrictions, any group only when nothing is restricted: a scope of + // projects reaches no group. if (!this.hasGroupRestrictions()) { - return true; + return !this.hasProjectRestrictions(); } const normalized = normalizeProjectPath(groupPath); @@ -329,6 +330,16 @@ export class ScopeEnforcer { } } +/** + * A project or group identifier as the tool will use it: tool schemas coerce a JSON + * number id to its string form, so a number is checked exactly like that string. + */ +function identifierOf(value: unknown): string | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'string' && value.trim()) return value.trim(); + return undefined; +} + /** * Extract project path from tool arguments * @@ -344,6 +355,7 @@ export function extractProjectsFromArgs(args: Record): string[] const projects: string[] = []; // Common parameter names for project identification + // Destinations count too: a cross-project merge request or job token target. const projectFields = [ 'project_id', 'projectId', @@ -351,13 +363,12 @@ export function extractProjectsFromArgs(args: Record): string[] 'namespace', 'namespacePath', 'fullPath', + 'target_project_id', ]; for (const field of projectFields) { - const value = args[field]; - if (typeof value === 'string' && value.trim()) { - projects.push(value.trim()); - } + const id = identifierOf(args[field]); + if (id) projects.push(id); } return projects; @@ -378,13 +389,20 @@ export function extractGroupsFromArgs(args: Record): string[] { const groups: string[] = []; // Common parameter names for group identification - const groupFields = ['group_id', 'groupId', 'group']; + // Destinations count too: a fork's target namespace, a job token target group, and a + // namespace read by id or path. + const groupFields = [ + 'group_id', + 'groupId', + 'group', + 'target_group_id', + 'namespace_path', + 'namespace_id', + ]; for (const field of groupFields) { - const value = args[field]; - if (typeof value === 'string' && value.trim()) { - groups.push(value.trim()); - } + const id = identifierOf(args[field]); + if (id) groups.push(id); } return groups; diff --git a/packages/gitlab-mcp/src/registry-manager.ts b/packages/gitlab-mcp/src/registry-manager.ts index dcb2bc51..0fe52a6c 100644 --- a/packages/gitlab-mcp/src/registry-manager.ts +++ b/packages/gitlab-mcp/src/registry-manager.ts @@ -115,6 +115,8 @@ import { } from './utils/schema-utils'; import { resolveRelatedReferences, stripRelatedSection } from './utils/description-utils'; import { normalizeInstanceUrl } from './utils/url'; +import { groupOfRegistry } from './configuration/groups'; +import type { ToolFacts } from './configuration/policy'; import { getGitLabApiUrlFromContext, getTokenContext } from './oauth/token-context'; const NONE_UNAVAILABLE: ReadonlyMap = new Map(); @@ -149,6 +151,8 @@ class RegistryManager { // Cached read-only tools list built from registries private readOnlyToolsCache: string[] | null = null; + private readOnlyToolSet: Set | null = null; + private toolRegistryKeys: Map | null = null; private constructor() { this.initializeRegistries(); @@ -401,6 +405,37 @@ class RegistryManager { return this.readOnlyToolsCache; } + /** Whether the tool only reads GitLab (it stays available in read-only mode). */ + public isReadOnlyTool(toolName: string): boolean { + this.readOnlyToolSet ??= new Set(this.getReadOnlyTools()); + return this.readOnlyToolSet.has(toolName); + } + + /** What the caller's settings need to know about a tool to allow or refuse it. */ + public getToolFacts(toolName: string): ToolFacts { + return { + name: toolName, + group: groupOfRegistry(this.getToolRegistryKey(toolName)), + readOnly: this.isReadOnlyTool(toolName), + }; + } + + /** Keys of the registries this server loaded (feature flags off are absent). */ + public getRegistryKeys(): string[] { + return [...this.registries.keys()]; + } + + /** Key of the registry a tool belongs to (`mrs`, `wiki`, `core`, ...); undefined if unknown. */ + public getToolRegistryKey(toolName: string): string | undefined { + if (!this.toolRegistryKeys) { + this.toolRegistryKeys = new Map(); + for (const [key, registry] of this.registries) { + for (const name of registry.keys()) this.toolRegistryKeys.set(name, key); + } + } + return this.toolRegistryKeys.get(toolName); + } + /** * Build unified tool lookup cache for O(1) tool access with filtering applied. * @param instanceUrl — when provided, fetches tier/version/scopes for this diff --git a/packages/gitlab-mcp/src/services/ConnectionManager.ts b/packages/gitlab-mcp/src/services/ConnectionManager.ts index 2c3dcc6b..84a8fefe 100644 --- a/packages/gitlab-mcp/src/services/ConnectionManager.ts +++ b/packages/gitlab-mcp/src/services/ConnectionManager.ts @@ -16,6 +16,7 @@ import { } from '../config'; import { isOAuthEnabled, getGitLabApiUrlFromContext } from '../oauth/index'; import { enhancedFetch } from '../utils/fetch'; +import { setActiveInstanceSource } from '../utils/gitlab-base-url'; import { logInfo, logDebug, logError } from '../logger'; import { InstanceRegistry } from './InstanceRegistry'; import { CachedIntrospection } from '../config/instances-schema'; @@ -1068,3 +1069,6 @@ export class ConnectionManager { } } } + +// REST calls go to the instance this manager works with now, also after switch_instance. +setActiveInstanceSource(() => ConnectionManager.getInstance().getCurrentInstanceUrl()); diff --git a/packages/gitlab-mcp/src/services/TokenScopeDetector.ts b/packages/gitlab-mcp/src/services/TokenScopeDetector.ts index 40b265f3..e4b9e26a 100644 --- a/packages/gitlab-mcp/src/services/TokenScopeDetector.ts +++ b/packages/gitlab-mcp/src/services/TokenScopeDetector.ts @@ -98,6 +98,8 @@ const TOOL_SCOPE_REQUIREMENTS: Record = { manage_todos: ['api'], // manage_context is intentionally excluded — it manages local session state // and never calls GitLab API, so it's available with any token scope. + // Scope search for the settings panel reads /projects and /groups. + find_scope_targets: ['api', 'read_api'], // Labels browse_labels: ['api', 'read_api'], diff --git a/packages/gitlab-mcp/src/session-manager.ts b/packages/gitlab-mcp/src/session-manager.ts index 3753375c..1202014a 100644 --- a/packages/gitlab-mcp/src/session-manager.ts +++ b/packages/gitlab-mcp/src/session-manager.ts @@ -4,6 +4,8 @@ import { packageName, packageVersion, GITLAB_BASE_URL } from './config'; import { setupHandlers } from './handlers'; import { logInfo, logWarn, logError, logDebug } from './logger'; import { normalizeInstanceUrl } from './utils/url'; +import { getConfigurationService } from './configuration'; +import { SETTINGS_READ_TOOL, SETTINGS_UPDATE_TOOL } from './entities/context/settings'; /** Default session idle timeout: 30 minutes */ const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000; @@ -73,7 +75,18 @@ export class SessionManager { const server = new Server( { name: packageName, version: packageVersion }, - { capabilities: { tools: { listChanged: true } } }, + { + capabilities: { + tools: { listChanged: true }, + // The settings panel (an MCP App) is served as a ui:// resource. + resources: {}, + // OpenAI structured settings: the host renders a native settings page from these + // tools. Up to protocol 2025-11-25 (the SDK's latest) it is advertised here. + experimental: { + 'openai/settings': { readTool: SETTINGS_READ_TOOL, updateTool: SETTINGS_UPDATE_TOOL }, + }, + }, + }, ); // Register request handlers (idempotent — same logic for every session) @@ -163,6 +176,7 @@ export class SessionManager { if (!session) return; this.sessions.delete(sessionId); + getConfigurationService().forgetSession(sessionId); try { await session.server.close(); @@ -215,6 +229,24 @@ export class SessionManager { }); } + /** + * Send tools/list_changed to the given sessions of this process only: a change of one + * account's or one session's settings does not make every client re-fetch its tools. + */ + async notifyToolsListChanged(sessionIds: readonly string[]): Promise { + await Promise.allSettled( + sessionIds.map(async (sessionId) => { + const session = this.sessions.get(sessionId); + if (!session) return; + try { + await session.server.notification({ method: 'notifications/tools/list_changed' }); + } catch (error: unknown) { + logDebug('Failed to send tools/list_changed to session', { err: error, sessionId }); + } + }), + ); + } + /** * Get the number of active sessions. */ diff --git a/packages/gitlab-mcp/src/utils/fetch.ts b/packages/gitlab-mcp/src/utils/fetch.ts index 8ab93786..70c0d676 100644 --- a/packages/gitlab-mcp/src/utils/fetch.ts +++ b/packages/gitlab-mcp/src/utils/fetch.ts @@ -21,7 +21,6 @@ import { HTTPS_PROXY, NODE_TLS_REJECT_UNAUTHORIZED, GITLAB_TOKEN, - GITLAB_BASE_URL, CONNECT_TIMEOUT_MS, HEADERS_TIMEOUT_MS, BODY_TIMEOUT_MS, @@ -30,7 +29,8 @@ import { API_RETRY_BASE_DELAY_MS, API_RETRY_MAX_DELAY_MS, } from '../config'; -import { isOAuthEnabled, getTokenContext, getGitLabApiUrlFromContext } from '../oauth/index'; +import { isOAuthEnabled, getTokenContext } from '../oauth/index'; +import { getGitLabBaseUrl } from './gitlab-base-url'; import { getRequestTracker } from '../logging/index'; import { InstanceRegistry } from '../services/InstanceRegistry.js'; @@ -224,24 +224,6 @@ function getGitLabToken(): string | undefined { return GITLAB_TOKEN; } -/** - * Get GitLab base URL from context or fallback to global config. - * In OAuth mode, uses apiUrl from token context. - * In static mode, uses GITLAB_BASE_URL from config. - * - * @returns The GitLab base URL (e.g., "https://gitlab.com") - */ -export function getGitLabBaseUrl(): string { - if (isOAuthEnabled()) { - const apiUrl = getGitLabApiUrlFromContext(); - if (apiUrl) { - return apiUrl; - } - logWarn('OAuth mode: no API URL in context, falling back to global config'); - } - return GITLAB_BASE_URL ?? 'https://gitlab.com'; -} - /** * Get authentication headers based on the current auth mode. * - Static mode (PAT): returns { "PRIVATE-TOKEN": token } diff --git a/packages/gitlab-mcp/src/utils/gitlab-api.ts b/packages/gitlab-mcp/src/utils/gitlab-api.ts index 263625f6..5a064f0b 100644 --- a/packages/gitlab-mcp/src/utils/gitlab-api.ts +++ b/packages/gitlab-mcp/src/utils/gitlab-api.ts @@ -11,6 +11,7 @@ */ import { enhancedFetch } from './fetch'; +import { getGitLabBaseUrl } from './gitlab-base-url'; import { cleanGidsFromObject } from './idConversion'; type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; @@ -117,7 +118,7 @@ async function request( path: string, options: RequestOptions = {}, ): Promise { - const baseUrl = process.env.GITLAB_API_URL ?? 'https://gitlab.com'; + const baseUrl = getGitLabBaseUrl(); const queryString = buildQueryString(options.query); const url = `${baseUrl}/api/v4/${path}${queryString}`; diff --git a/packages/gitlab-mcp/src/utils/gitlab-base-url.ts b/packages/gitlab-mcp/src/utils/gitlab-base-url.ts new file mode 100644 index 00000000..8b05646c --- /dev/null +++ b/packages/gitlab-mcp/src/utils/gitlab-base-url.ts @@ -0,0 +1,39 @@ +/** + * Which GitLab instance a REST call goes to. Every tool builds its URLs from here, so a + * call reaches the instance of its caller. + */ + +import { logWarn } from '../logger'; +import { isOAuthEnabled, getGitLabApiUrlFromContext } from '../oauth/index'; +import { normalizeInstanceUrl } from './url'; + +/** + * The instance the server's own token works with now. The connection manager reports + * it (it changes on switch_instance); it registers itself because it depends on the + * HTTP layer that depends on this module. + */ +let activeInstanceUrl: () => string | null = () => null; + +export function setActiveInstanceSource(source: () => string | null): void { + activeInstanceUrl = source; +} + +/** + * Base URL of the GitLab instance this call goes to (e.g. "https://gitlab.com"): the + * OAuth token's instance, else the instance the server's own token works with now, + * else GITLAB_API_URL as configured at the time of the call. + */ +export function getGitLabBaseUrl(): string { + if (isOAuthEnabled()) { + const apiUrl = getGitLabApiUrlFromContext(); + if (apiUrl) { + return apiUrl; + } + logWarn('OAuth mode: no API URL in context, falling back to global config'); + } + const active = activeInstanceUrl(); + if (active) return active; + // An unset or empty GITLAB_API_URL means gitlab.com, as in the startup configuration. + const configured = process.env.GITLAB_API_URL; + return configured ? normalizeInstanceUrl(configured) : 'https://gitlab.com'; +} diff --git a/packages/gitlab-mcp/src/utils/namespace.ts b/packages/gitlab-mcp/src/utils/namespace.ts index cf39b4bb..afbac95f 100644 --- a/packages/gitlab-mcp/src/utils/namespace.ts +++ b/packages/gitlab-mcp/src/utils/namespace.ts @@ -1,4 +1,5 @@ import { enhancedFetch } from './fetch'; +import { getGitLabBaseUrl } from './gitlab-base-url'; /** * Extract namespace (group path) from a full project path. @@ -37,32 +38,30 @@ export function isLikelyProjectPath(namespacePath: string): boolean { } /** - * Detect namespace type by attempting to fetch from GitLab API - * Tries both project and group endpoints to determine which one exists + * Whether GitLab knows the path as a project or a group, or null when it confirms neither + * (the path does not exist, or GitLab could not be reached). The likelier type is asked first. */ -export async function detectNamespaceType(namespacePath: string): Promise<'project' | 'group'> { - // First try heuristic for common cases - if (isLikelyProjectPath(namespacePath)) { - // Try project first, fallback to group if needed - const isProject = await verifyNamespaceType(namespacePath, 'project'); - if (isProject) return 'project'; - - const isGroup = await verifyNamespaceType(namespacePath, 'group'); - if (isGroup) return 'group'; - - // Default fallback for paths with slash - return 'project'; - } else { - // Try group first, fallback to project if needed - const isGroup = await verifyNamespaceType(namespacePath, 'group'); - if (isGroup) return 'group'; - - const isProject = await verifyNamespaceType(namespacePath, 'project'); - if (isProject) return 'project'; - - // Default fallback for paths without slash - return 'group'; +export async function findNamespaceType( + namespacePath: string, +): Promise<'project' | 'group' | null> { + const order: Array<'project' | 'group'> = isLikelyProjectPath(namespacePath) + ? ['project', 'group'] + : ['group', 'project']; + for (const type of order) { + if (await verifyNamespaceType(namespacePath, type)) return type; } + return null; +} + +/** + * Detect namespace type by attempting to fetch from GitLab API, falling back to the path + * shape when GitLab confirms neither type + */ +export async function detectNamespaceType(namespacePath: string): Promise<'project' | 'group'> { + return ( + (await findNamespaceType(namespacePath)) ?? + (isLikelyProjectPath(namespacePath) ? 'project' : 'group') + ); } /** @@ -74,7 +73,7 @@ async function verifyNamespaceType( ): Promise { try { const entityType = type === 'project' ? 'projects' : 'groups'; - const apiUrl = `${process.env.GITLAB_API_URL}/api/v4/${entityType}/${encodeURIComponent(namespacePath)}`; + const apiUrl = `${getGitLabBaseUrl()}/api/v4/${entityType}/${encodeURIComponent(namespacePath)}`; const response = await enhancedFetch(apiUrl); diff --git a/packages/gitlab-mcp/src/utils/smart-user-search.ts b/packages/gitlab-mcp/src/utils/smart-user-search.ts index 6c1292ee..1a3316e8 100644 --- a/packages/gitlab-mcp/src/utils/smart-user-search.ts +++ b/packages/gitlab-mcp/src/utils/smart-user-search.ts @@ -1,5 +1,6 @@ import * as z from 'zod'; import { enhancedFetch } from './fetch'; +import { getGitLabBaseUrl } from './gitlab-base-url'; import { transliterate } from 'transliteration'; import { instanceAtLeast } from '../entities/instance-version'; import { GITLAB_DEFAULT_PER_PAGE, GITLAB_MAX_PER_PAGE } from '../entities/utils'; @@ -131,7 +132,7 @@ async function fetchUsersPage(query: Record) { Object.entries(query).forEach(([key, value]) => { if (value !== undefined) queryParams.set(key, String(value)); }); - const response = await enhancedFetch(`${process.env.GITLAB_API_URL}/api/v4/users?${queryParams}`); + const response = await enhancedFetch(`${getGitLabBaseUrl()}/api/v4/users?${queryParams}`); if (!response.ok) { throw new Error(`GitLab API error: ${response.status} ${response.statusText}`); } diff --git a/packages/gitlab-mcp/src/utils/sync-directory.ts b/packages/gitlab-mcp/src/utils/sync-directory.ts new file mode 100644 index 00000000..723da229 --- /dev/null +++ b/packages/gitlab-mcp/src/utils/sync-directory.ts @@ -0,0 +1,15 @@ +import * as fs from 'node:fs'; + +/** + * Flush a directory entry change (a rename into it) to disk, so the replaced file + * survives a crash. Windows has no directory fsync; NTFS journals the rename. + */ +export async function syncDirectory(dir: string): Promise { + if (process.platform === 'win32') return; + const handle = await fs.promises.open(dir, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} diff --git a/packages/gitlab-mcp/tests/integration/data-lifecycle.test.ts b/packages/gitlab-mcp/tests/integration/data-lifecycle.test.ts index a15599e7..7032b0fc 100644 --- a/packages/gitlab-mcp/tests/integration/data-lifecycle.test.ts +++ b/packages/gitlab-mcp/tests/integration/data-lifecycle.test.ts @@ -148,7 +148,12 @@ describe('🔄 Data Lifecycle - Complete Infrastructure Setup', () => { }), }); - expect(createResponse.ok).toBe(true); + // Report what GitLab answered: every later lifecycle step depends on this project. + if (!createResponse.ok) { + throw new Error( + `Project creation failed: ${createResponse.status} ${await createResponse.text()}`, + ); + } const project = await createResponse.json(); expect(project).toHaveProperty('id'); diff --git a/packages/gitlab-mcp/tests/integration/schemas-dependent/merge-requests.test.ts b/packages/gitlab-mcp/tests/integration/schemas-dependent/merge-requests.test.ts index f68c10c3..a41964fb 100644 --- a/packages/gitlab-mcp/tests/integration/schemas-dependent/merge-requests.test.ts +++ b/packages/gitlab-mcp/tests/integration/schemas-dependent/merge-requests.test.ts @@ -71,7 +71,11 @@ describe('Merge Requests Schema - Using Lifecycle Data', () => { title: issue.title, state: 'opened', }); - expect(page[0].web_url).toBe(`${testProject.web_url}/-/issues/${issue.iid}`); + // GitLab's own link to the issue: /-/issues/N before 19.0, /-/work_items/N from 19.0. + expect([ + `${testProject.web_url}/-/issues/${issue.iid}`, + `${testProject.web_url}/-/work_items/${issue.iid}`, + ]).toContain(page[0].web_url); await expect( helper.executeTool('browse_merge_requests', { action: 'closing_issues', diff --git a/packages/gitlab-mcp/tests/integration/schemas/context.test.ts b/packages/gitlab-mcp/tests/integration/schemas/context.test.ts index 9e93bcd3..2c8498ff 100644 --- a/packages/gitlab-mcp/tests/integration/schemas/context.test.ts +++ b/packages/gitlab-mcp/tests/integration/schemas/context.test.ts @@ -5,7 +5,7 @@ * and scope enforcement functionality. */ -import { IntegrationTestHelper } from '../helpers/registry-helper'; +import { IntegrationTestHelper, findTestProjectPath } from '../helpers/registry-helper'; import { ContextManager } from '../../../src/entities/context/context-manager'; import { ManageContextSchema } from '../../../src/entities/context/schema'; @@ -139,7 +139,8 @@ describe('manage_context Integration Tests', () => { expect(result.success).toBe(true); expect(result.current).toBe(presetName); - expect(result.message).toContain('Switched to preset'); + // The switch applies to this session only. + expect(result.message).toBe(`Switched this session to preset '${presetName}'`); console.log(` Switched to preset: ${presetName}`); }); @@ -197,8 +198,9 @@ describe('manage_context Integration Tests', () => { }); it('should auto-detect project namespace', async () => { - // Use a test project that exists in the test environment - const testProject = 'test/backend/project1'; + // A project that exists: set_scope accepts only paths GitLab confirms. + const testProject = await findTestProjectPath(helper); + if (!testProject) throw new Error('No project under the test group to scope to'); const result = (await helper.executeTool('manage_context', { action: 'set_scope', @@ -283,7 +285,8 @@ describe('manage_context Integration Tests', () => { })) as Record; expect(result.success).toBe(true); - expect(result.message).toContain('reset to initial state'); + // Reset drops the session overrides; the account settings (none here) apply again. + expect(result.message).toBe('Session context reset to the account settings'); expect(result).toHaveProperty('context'); // Verify reset state diff --git a/packages/gitlab-mcp/tests/integration/schemas/workitems.test.ts b/packages/gitlab-mcp/tests/integration/schemas/workitems.test.ts index 62396193..fc5d2ee5 100644 --- a/packages/gitlab-mcp/tests/integration/schemas/workitems.test.ts +++ b/packages/gitlab-mcp/tests/integration/schemas/workitems.test.ts @@ -728,32 +728,36 @@ describe('Work Items Schema - GitLab 18.3 Integration', () => { } console.log('🏷️ Testing removeLabelIds (incremental remove)...'); + type LabelWidgetItem = { + widgets?: Array<{ type: string; labels?: { nodes?: Array<{ id: string }> } }>; + }; + const labelIdsOf = (item: LabelWidgetItem): string[] => + (item.widgets?.find((widget) => widget.type === 'LABELS')?.labels?.nodes ?? []).map( + (label) => String(label.id), + ); - // Get current labels count before removal + // Start from a known label set: the earlier label tests skip without two labels, so + // the work item may carry none at this point. + await helper.executeTool('manage_work_item', { + action: 'update', + id: testWorkItemId, + labelIds: [label1Id], + }); const beforeRemove = (await helper.executeTool('browse_work_items', { action: 'get', id: testWorkItemId, - })) as any; - const beforeWidget = beforeRemove.widgets?.find((w: any) => w.type === 'LABELS'); - const beforeCount = beforeWidget?.labels?.nodes?.length || 0; - console.log(` Before remove: ${beforeCount} labels`); + })) as LabelWidgetItem; + expect(labelIdsOf(beforeRemove)).toContain(label1Id); - // Remove label1 const removeUpdate = (await helper.executeTool('manage_work_item', { action: 'update', id: testWorkItemId, removeLabelIds: [label1Id], - })) as any; - - expect(removeUpdate).toBeDefined(); - - // Verify label was removed - const labelsWidget = removeUpdate.widgets?.find((w: any) => w.type === 'LABELS'); - const afterCount = labelsWidget?.labels?.nodes?.length || 0; - console.log(` ✅ After remove: ${afterCount} labels`); + })) as LabelWidgetItem; - // Should have fewer labels - expect(afterCount).toBeLessThan(beforeCount); + // The removed label is gone and nothing else changed + expect(labelIdsOf(removeUpdate)).not.toContain(label1Id); + expect(labelIdsOf(removeUpdate)).toHaveLength(labelIdsOf(beforeRemove).length - 1); console.log('✅ removeLabelIds incremental remove test completed'); }, 30000); diff --git a/packages/gitlab-mcp/tests/setup/isolatedSettings.ts b/packages/gitlab-mcp/tests/setup/isolatedSettings.ts new file mode 100644 index 00000000..e585a626 --- /dev/null +++ b/packages/gitlab-mcp/tests/setup/isolatedSettings.ts @@ -0,0 +1,20 @@ +/** + * Settings a test reads or saves through the local settings file go to a temporary file, + * never the user's own ~/.config/gitlab-mcp/settings.json. A test file that mocks the + * settings store itself replaces this. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const mockIsolatedSettingsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitlab-mcp-settings-')); + +jest.mock('../../src/configuration/settings-store', () => ({ + ...jest.requireActual('../../src/configuration/settings-store'), + localSettingsPath: () => `${mockIsolatedSettingsDir}/settings.json`, +})); + +afterAll(() => { + fs.rmSync(mockIsolatedSettingsDir, { recursive: true, force: true }); +}); diff --git a/packages/gitlab-mcp/tests/setup/setupTests.ts b/packages/gitlab-mcp/tests/setup/setupTests.ts index d6844ef5..225c4263 100644 --- a/packages/gitlab-mcp/tests/setup/setupTests.ts +++ b/packages/gitlab-mcp/tests/setup/setupTests.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { config } from 'dotenv'; +import './isolatedSettings'; // Check if .env.test exists and load it const envTestPath = path.resolve(__dirname, '../../.env.test'); @@ -28,8 +29,3 @@ if (fs.existsSync(envTestPath)) { console.log('⚠️ .env.test not found - Integration tests disabled'); console.log(' Create .env.test with GitLab credentials to enable integration tests'); } - -// Global test timeout for integration tests -if (process.env.INTEGRATION_TESTS_ENABLED === 'true') { - jest.setTimeout(30000); -} diff --git a/packages/gitlab-mcp/tests/setup/setupTests.unit.ts b/packages/gitlab-mcp/tests/setup/setupTests.unit.ts index 3a95ebeb..d189ccde 100644 --- a/packages/gitlab-mcp/tests/setup/setupTests.unit.ts +++ b/packages/gitlab-mcp/tests/setup/setupTests.unit.ts @@ -3,6 +3,8 @@ * DOES NOT load .env.test - unit tests should be isolated from environment */ +import './isolatedSettings'; + // Set timeout for unit tests jest.setTimeout(10000); diff --git a/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts b/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts index 775dee89..ff5600b1 100644 --- a/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts +++ b/packages/gitlab-mcp/tests/unit/RegistryManager.test.ts @@ -768,6 +768,17 @@ describe('RegistryManager', () => { } }); + // The settings page offers only the tool groups this server actually loaded. + it('lists the keys of loaded registries and not of those a feature flag turned off', () => { + process.env.USE_LABELS = 'false'; + resetRegistryManagerSingleton(); + + const keys = RegistryManager.getInstance().getRegistryKeys(); + + expect(keys).toContain('core'); + expect(keys).not.toContain('labels'); + }); + it('should provide cache refresh functionality', () => { const _originalNames = registryManager.getAvailableToolNames(); diff --git a/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts b/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts index 9d6ad856..386ade96 100644 --- a/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts +++ b/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts @@ -9,7 +9,10 @@ */ import { CallToolRequestSchema, + ListResourcesRequestSchema, ListToolsRequestSchema, + ReadResourceRequestSchema, + ToolListChangedNotificationSchema, McpError, ErrorCode, } from '@modelcontextprotocol/sdk/types.js'; @@ -18,9 +21,15 @@ import { const mockClientConnect = jest.fn, [unknown]>(); const mockClientListTools = jest.fn(); const mockClientCallTool = jest.fn(); +const mockClientListResources = jest.fn(); +const mockClientReadResource = jest.fn(); +let mockServerOptions: { capabilities?: Record } | undefined; const mockServerHandlers = new Map Promise>(); const mockServerConnect = jest.fn(); const mockServerNotification = jest.fn(); +const mockServerSendToolListChanged = jest.fn(); +// Notification handlers the gateway registers on the downstream client, by schema. +const mockClientNotificationHandlers = new Map void>(); const mockTransportClose = jest.fn(); // The most recently constructed downstream transport, so a test can fire onclose. let mockTransportInstance: { onclose?: () => void; close: jest.Mock; closed?: boolean }; @@ -32,6 +41,11 @@ jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ connect: mockClientConnect, listTools: mockClientListTools, callTool: mockClientCallTool, + listResources: mockClientListResources, + readResource: mockClientReadResource, + setNotificationHandler: (schema: unknown, handler: (notification: unknown) => void) => { + mockClientNotificationHandlers.set(schema, handler); + }, get transport() { return transport.closed ? undefined : transport; }, @@ -47,12 +61,14 @@ jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ })); jest.mock('@modelcontextprotocol/sdk/server/index.js', () => ({ - Server: jest.fn().mockImplementation(() => ({ + Server: jest.fn().mockImplementation((_info: unknown, options: typeof mockServerOptions) => ({ + ...((mockServerOptions = options), {}), setRequestHandler: (schema: unknown, handler: (req: unknown) => Promise) => { mockServerHandlers.set(schema, handler); }, connect: mockServerConnect, notification: mockServerNotification, + sendToolListChanged: mockServerSendToolListChanged, })), })); @@ -83,6 +99,8 @@ describe('ChannelGateway', () => { beforeEach(() => { jest.clearAllMocks(); mockServerHandlers.clear(); + mockClientNotificationHandlers.clear(); + mockServerSendToolListChanged.mockResolvedValue(undefined); mockClientConnect.mockResolvedValue(undefined); mockClientListTools.mockResolvedValue({ tools: [] }); mockClientCallTool.mockResolvedValue(mcp({ ok: true })); @@ -114,6 +132,151 @@ describe('ChannelGateway', () => { expect(result).toEqual({ tools }); }); + // A tool of the catalog points at a ui:// resource (the settings panel): the host must + // be able to list and read it through the gateway, or the panel never opens. + it('forwards resource listing and reading to the downstream', async () => { + const listing = { resources: [{ uri: 'ui://gitlab-mcp/panel.html', name: 'Panel' }] }; + const content = { + contents: [{ uri: 'ui://gitlab-mcp/panel.html', mimeType: 'text/html', text: '

x

' }], + }; + mockClientListResources.mockResolvedValue(listing); + mockClientReadResource.mockResolvedValue(content); + const gw = new ChannelGateway(baseConfig); + await gw.start(); + + expect(mockServerOptions?.capabilities).toHaveProperty('resources'); + const listParams = { cursor: 'c1' }; + expect( + await mockServerHandlers.get(ListResourcesRequestSchema)!({ params: listParams }), + ).toEqual(listing); + expect(mockClientListResources).toHaveBeenCalledWith(listParams); + const readParams = { uri: 'ui://gitlab-mcp/panel.html' }; + expect( + await mockServerHandlers.get(ReadResourceRequestSchema)!({ params: readParams }), + ).toEqual(content); + expect(mockClientReadResource).toHaveBeenCalledWith(readParams); + await gw.stop(); + }); + + it('routes resource reads through the reconnect policy', async () => { + // Never started -> downstream down; a resource read fails like any other read. + const gw = new ChannelGateway({ ...baseConfig, maxQueued: 0 }); + expect(gw).toBeInstanceOf(ChannelGateway); + await expect( + mockServerHandlers.get(ReadResourceRequestSchema)!({ params: { uri: 'ui://x' } }), + ).rejects.toThrow(/buffer full/); + expect(mockClientReadResource).not.toHaveBeenCalled(); + }); + + // A read-only tool without a browse_/get_/list_ name is retried after a lost link too: + // the catalog's readOnlyHint says it is a read. + it('replays a call the catalog marks read-only after the link drops', async () => { + mockClientListTools.mockResolvedValue({ + tools: [{ name: 'find_scope_targets', annotations: { readOnlyHint: true } }], + }); + const gw = new ChannelGateway(baseConfig); + await gw.start(); + await listTools(); + const original = mockTransportInstance; + mockClientCallTool + .mockImplementationOnce(async () => { + original.closed = true; + original.onclose!(); + await wait(0); + throw new McpError(ErrorCode.ConnectionClosed, 'Connection closed'); + }) + .mockResolvedValueOnce(mcp({ targets: [] })); + + try { + expect(await callTool('find_scope_targets', { query: 'team' })).toEqual(mcp({ targets: [] })); + expect(mockClientCallTool).toHaveBeenCalledTimes(2); + } finally { + // Once-implementations survive clearAllMocks; a failure here must not leak them. + mockClientCallTool.mockReset(); + await gw.stop(); + } + }); + + /** A call that loses the downstream link mid-flight; a replay would answer `replayed`. */ + function dropLinkOnNextCall(): void { + const original = mockTransportInstance; + mockClientCallTool + .mockImplementationOnce(async () => { + original.closed = true; + original.onclose!(); + await wait(0); + throw new McpError(ErrorCode.ConnectionClosed, 'Connection closed'); + }) + .mockResolvedValueOnce(mcp({ replayed: true })); + } + + // A later catalog that no longer marks the tool read-only wins: the call is not replayed. + it('stops replaying a tool a later catalog no longer marks read-only', async () => { + mockClientListTools + .mockResolvedValueOnce({ tools: [{ name: 'run_task', annotations: { readOnlyHint: true } }] }) + .mockResolvedValueOnce({ + tools: [{ name: 'run_task', annotations: { readOnlyHint: false } }], + }); + const gw = new ChannelGateway(baseConfig); + await gw.start(); + await listTools(); + await listTools(); + dropLinkOnNextCall(); + + try { + await expect(callTool('run_task')).rejects.toThrow(); + expect(mockClientCallTool).toHaveBeenCalledTimes(1); + } finally { + mockClientCallTool.mockReset(); + await gw.stop(); + } + }); + + // Two catalog reads in flight: the older answer arriving last does not undo the newer one. + it('keeps the newest catalog verdict when catalog answers arrive out of order', async () => { + let answerOlder: (value: unknown) => void = () => {}; + mockClientListTools + .mockImplementationOnce( + () => + new Promise((resolve) => { + answerOlder = resolve; + }), + ) + .mockResolvedValueOnce({ + tools: [{ name: 'run_task', annotations: { readOnlyHint: false } }], + }); + const gw = new ChannelGateway(baseConfig); + await gw.start(); + const older = listTools(); + await wait(0); + await listTools(); + answerOlder({ tools: [{ name: 'run_task', annotations: { readOnlyHint: true } }] }); + await older; + dropLinkOnNextCall(); + + try { + await expect(callTool('run_task')).rejects.toThrow(); + expect(mockClientCallTool).toHaveBeenCalledTimes(1); + } finally { + mockClientCallTool.mockReset(); + await gw.stop(); + } + }); + + // Settings changes make the downstream catalog change: the host must hear about it. + it('forwards a downstream tool-list change to the host', async () => { + const gw = new ChannelGateway(baseConfig); + await gw.start(); + + expect(mockServerOptions?.capabilities).toMatchObject({ tools: { listChanged: true } }); + mockClientNotificationHandlers.get(ToolListChangedNotificationSchema)!({ + method: 'notifications/tools/list_changed', + }); + + expect(mockServerSendToolListChanged).toHaveBeenCalledTimes(1); + await gw.stop(); + }); + it('preserves catalog pagination and metadata in both directions', async () => { // Prevent the gateway from truncating paginated catalogs or hiding UI/auth metadata. const catalog = { diff --git a/packages/gitlab-mcp/tests/unit/configuration/index.test.ts b/packages/gitlab-mcp/tests/unit/configuration/index.test.ts new file mode 100644 index 00000000..c8df5b31 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/index.test.ts @@ -0,0 +1,150 @@ +/** + * The process configuration service picks where settings live from the deployment: the + * session storage shared by replicas under OAuth or a configured storage, otherwise the + * local settings file. Account changes notify the account's sessions. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let mockOAuth = false; +jest.mock('../../../src/oauth/config', () => ({ + isOAuthEnabled: () => mockOAuth, +})); + +let mockBackendType = 'postgresql'; +let mockKeepsSettings = true; +const mockSessionStore = { + getBackendType: () => mockBackendType, + keepsAccountSettings: () => mockKeepsSettings, + initialize: jest.fn(async () => undefined), + getAccountSettings: jest.fn(async () => undefined), + putAccountSettings: jest.fn(async (accountKey: string, settings: object) => ({ + accountKey, + settings, + version: 1, + updatedAt: 1, + })), +}; +jest.mock('../../../src/oauth/session-store', () => ({ sessionStore: mockSessionStore })); + +jest.mock('../../../src/profiles/loader', () => ({ + ProfileLoader: jest.fn().mockImplementation(() => ({ + loadPreset: async (name: string) => { + throw new Error(`Preset not found: ${name}`); + }, + })), +})); + +const mockNotify = jest.fn(async () => undefined); +jest.mock('../../../src/session-manager', () => ({ + getSessionManager: () => ({ notifyToolsListChanged: mockNotify }), +})); + +let mockSettingsPath = ''; +jest.mock('../../../src/configuration/settings-store', () => ({ + ...jest.requireActual('../../../src/configuration/settings-store'), + localSettingsPath: () => mockSettingsPath, +})); + +import { getConfigurationService, resetConfigurationService } from '../../../src/configuration'; +import type { Caller } from '../../../src/configuration/caller'; + +const alice: Caller = { + accountKey: 'token:https://gitlab.example.com', + sessionKey: 'stdio', + accountLabel: 'alice', + instanceUrl: 'https://gitlab.example.com', + oauth: false, +}; + +describe('getConfigurationService', () => { + const originalStorage = process.env.OAUTH_STORAGE_TYPE; + let dir: string; + + beforeEach(() => { + jest.clearAllMocks(); + resetConfigurationService(); + mockOAuth = false; + mockBackendType = 'postgresql'; + mockKeepsSettings = true; + delete process.env.OAUTH_STORAGE_TYPE; + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'configuration-index-test-')); + mockSettingsPath = path.join(dir, 'settings.json'); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + if (originalStorage === undefined) delete process.env.OAUTH_STORAGE_TYPE; + else process.env.OAUTH_STORAGE_TYPE = originalStorage; + }); + + it('is one service until reset', () => { + const first = getConfigurationService(); + + expect(getConfigurationService()).toBe(first); + resetConfigurationService(); + expect(getConfigurationService()).not.toBe(first); + }); + + it('keeps a local server settings in the local file and notifies its session', async () => { + await getConfigurationService().updateAccount(alice, { readOnly: true }); + + const file = JSON.parse(fs.readFileSync(mockSettingsPath, 'utf-8')); + expect(file.accounts[alice.accountKey].settings).toEqual({ readOnly: true }); + expect(mockSessionStore.initialize).not.toHaveBeenCalled(); + expect(mockNotify).toHaveBeenCalledWith(['stdio']); + }); + + it.each([ + ['OAuth', () => (mockOAuth = true)], + ['a configured session storage', () => (process.env.OAUTH_STORAGE_TYPE = 'postgresql')], + ])('keeps settings in the shared session storage with %s', async (_label, configure) => { + configure(); + const service = getConfigurationService(); + + await service.resolve(alice); + await service.updateAccount(alice, { readOnly: true }); + + expect(mockSessionStore.initialize).toHaveBeenCalledTimes(1); + expect(mockSessionStore.getAccountSettings).toHaveBeenCalledWith(alice.accountKey); + expect(mockSessionStore.putAccountSettings).toHaveBeenCalledWith( + alice.accountKey, + { readOnly: true }, + 0, + ); + expect(fs.existsSync(mockSettingsPath)).toBe(false); + }); + + // Sessions held in memory (the OAuth default) are gone after a restart; saved settings + // must not be, so they go to the settings file instead. + it.each([ + ['sessions are held in memory', 'memory'], + // A database package older than the server: calls keep working instead of failing. + ['the database package cannot keep settings', 'postgresql'], + ])('keeps OAuth settings in the settings file when %s', async (_label, backendType) => { + mockOAuth = true; + mockBackendType = backendType; + mockKeepsSettings = false; + + await getConfigurationService().updateAccount(alice, { readOnly: true }); + + const file = JSON.parse(fs.readFileSync(mockSettingsPath, 'utf-8')); + expect(file.accounts[alice.accountKey].settings).toEqual({ readOnly: true }); + expect(mockSessionStore.putAccountSettings).not.toHaveBeenCalled(); + }); + + // A storage outage at the first request must not fail every later request until restart. + it('retries opening the shared storage after a failed first attempt', async () => { + mockOAuth = true; + mockSessionStore.initialize.mockRejectedValueOnce(new Error('database unavailable')); + const service = getConfigurationService(); + + await expect(service.resolve(alice)).rejects.toThrow('database unavailable'); + const resolved = await service.resolve(alice); + + expect(resolved.account).toEqual({}); + expect(mockSessionStore.initialize).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts b/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts new file mode 100644 index 00000000..3347934a --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts @@ -0,0 +1,247 @@ +/** + * The caller's policy narrows tool use: read-only mode, switched-off groups, a preset's + * whitelist, exclusions and denied actions, and the working scope. Configuration tools + * stay usable whatever is restricted, so a caller can always undo a restriction. + */ + +import { + buildPolicy, + callRestriction, + toolRestriction, + type ToolFacts, +} from '../../../src/configuration/policy'; +import type { Preset } from '../../../src/profiles/types'; + +const browse: ToolFacts = { name: 'browse_merge_requests', group: 'mrs', readOnly: true }; +const manage: ToolFacts = { name: 'manage_merge_request', group: 'mrs', readOnly: false }; +const core: ToolFacts = { name: 'browse_projects', readOnly: true }; + +describe('buildPolicy', () => { + it('restricts nothing without a preset, settings or overrides', () => { + const policy = buildPolicy(undefined, {}, {}); + + expect(policy).toMatchObject({ readOnly: false, presetName: undefined, scope: undefined }); + expect(policy.disabledGroups.size).toBe(0); + expect(toolRestriction(policy, manage)).toBeNull(); + }); + + it('turns read-only on from the account, the session or the preset', () => { + expect(buildPolicy(undefined, { readOnly: true }, {}).readOnly).toBe(true); + expect(buildPolicy(undefined, {}, { readOnly: true }).readOnly).toBe(true); + expect(buildPolicy({ read_only: true }, {}, {}).readOnly).toBe(true); + }); + + // The session's own choice replaces the account default for that session only. + it('lets the session turn off the read-only mode its account set', () => { + expect(buildPolicy(undefined, { readOnly: true }, { readOnly: false }).readOnly).toBe(false); + }); + + // A read-only preset stays read-only: a session toggle cannot widen what it selected. + it('keeps a read-only preset read-only whatever the session toggle says', () => { + expect(buildPolicy({ read_only: true }, {}, { readOnly: false }).readOnly).toBe(true); + }); + + it('selects the session preset before the account preset', () => { + expect(buildPolicy(undefined, { preset: 'pm' }, { preset: 'readonly' }).presetName).toBe( + 'readonly', + ); + expect(buildPolicy(undefined, { preset: 'pm' }, {}).presetName).toBe('pm'); + }); + + it('switches off the account groups and the groups the preset disables', () => { + const preset: Preset = { features: { wiki: false, mrs: true } }; + const policy = buildPolicy(preset, { disabledToolGroups: ['pipelines'] }, {}); + + expect([...policy.disabledGroups].sort()).toEqual(['pipelines', 'wiki']); + }); + + it('parses denied actions and ignores malformed entries', () => { + const policy = buildPolicy( + { denied_actions: ['manage_merge_request:merge', 'nocolon', ':merge'] }, + {}, + {}, + ); + + expect([...policy.deniedActions.keys()]).toEqual(['manage_merge_request']); + }); + + it('takes the session scope, then the account scope, then the preset scope', () => { + const preset: Preset = { scope: { group: 'preset-group' } }; + const account = { + scope: { type: 'group' as const, path: 'acct-group', includeSubgroups: false }, + }; + const session = { scope: { type: 'project' as const, path: 'g/p', includeSubgroups: false } }; + + expect(buildPolicy(preset, account, session).scope).toEqual({ project: 'g/p' }); + expect(buildPolicy(preset, account, {}).scope).toEqual({ + group: 'acct-group', + includeSubgroups: false, + }); + expect(buildPolicy(preset, {}, {}).scope).toEqual({ group: 'preset-group' }); + }); +}); + +describe('toolRestriction', () => { + it('hides write tools in read-only mode and keeps read tools', () => { + const policy = buildPolicy(undefined, { readOnly: true }, {}); + + expect(toolRestriction(policy, manage)).toBe('read-only mode is on'); + expect(toolRestriction(policy, browse)).toBeNull(); + }); + + it('hides the tools of a switched-off group only', () => { + const policy = buildPolicy(undefined, { disabledToolGroups: ['mrs'] }, {}); + + expect(toolRestriction(policy, browse)).toBe("the 'mrs' tool group is turned off"); + expect(toolRestriction(policy, core)).toBeNull(); + }); + + it('keeps only the tools a preset whitelists', () => { + const policy = buildPolicy({ allowed_tools: ['browse_projects'] }, { preset: 'mine' }, {}); + + expect(toolRestriction(policy, core)).toBeNull(); + expect(toolRestriction(policy, browse)).toBe("preset 'mine' does not include it"); + }); + + it('hides the tools a preset excludes by pattern', () => { + const policy = buildPolicy({ denied_tools_regex: '^manage_' }, { preset: 'mine' }, {}); + + expect(toolRestriction(policy, manage)).toBe("preset 'mine' excludes it"); + expect(toolRestriction(policy, browse)).toBeNull(); + }); + + // Otherwise read-only mode or a whitelist could lock the caller out of the very tool + // that turns them off again. + it.each(['manage_context', 'get_settings', 'update_settings', 'open_settings_panel'])( + 'never restricts %s', + (name) => { + const policy = buildPolicy( + { allowed_tools: ['browse_projects'], denied_tools_regex: '.*' }, + { readOnly: true }, + {}, + ); + + expect(toolRestriction(policy, { name, readOnly: false })).toBeNull(); + }, + ); +}); + +describe('working everywhere in one chat', () => { + // The chat drops the account's default scope; nothing else of the account changes. + it("drops the account's default scope for the chat", () => { + const policy = buildPolicy( + undefined, + { readOnly: true, scope: { type: 'group', path: 'team', includeSubgroups: true } }, + { scope: 'everywhere' }, + ); + + expect(policy.scope).toBeUndefined(); + expect(policy.scopeEnforcer).toBeUndefined(); + expect(policy.readOnly).toBe(true); + }); + + // A preset's own scope is part of the preset the chat chose; it still applies. + it("keeps the preset's scope", () => { + const policy = buildPolicy({ scope: { project: 'team/app' } }, {}, { scope: 'everywhere' }); + + expect(policy.scope).toEqual({ project: 'team/app' }); + }); +}); + +describe('callRestriction', () => { + it('refuses an action the preset denies and allows the others', () => { + const policy = buildPolicy( + { denied_actions: ['manage_merge_request:merge'] }, + { preset: 'mine' }, + {}, + ); + + expect(callRestriction(policy, manage, { action: 'merge' })).toBe( + "preset 'mine' denies the 'merge' action", + ); + expect(callRestriction(policy, manage, { action: 'create' })).toBeNull(); + }); + + it('refuses a project outside the working scope and allows one inside', () => { + const policy = buildPolicy( + undefined, + { scope: { type: 'group', path: 'team', includeSubgroups: true } }, + {}, + ); + + expect(callRestriction(policy, browse, { project_id: 'other/app' })).toMatch( + /outside the allowed scope/, + ); + expect(callRestriction(policy, browse, { project_id: 'team/sub/app' })).toBeNull(); + }); + + // A numeric id cannot be checked against a path scope without a lookup: refused. + it('refuses a numeric project id under a scope', () => { + const policy = buildPolicy( + undefined, + { scope: { type: 'project', path: 'team/app', includeSubgroups: false } }, + {}, + ); + + expect(callRestriction(policy, browse, { project_id: '42' })).toMatch( + /outside the allowed scope/, + ); + }); + + // Tool schemas accept ids as JSON numbers too; a number must not slip past the scope. + it.each([[{ project_id: 42 }], [{ group_id: 7 }]])( + 'refuses a numeric id %j outside the working scope', + (args) => { + const policy = buildPolicy( + undefined, + { scope: { type: 'group', path: 'team', includeSubgroups: true } }, + {}, + ); + + expect(callRestriction(policy, browse, args)).toMatch(/outside the allowed scope/); + }, + ); + + // Only a scope violation is a refusal; a failing check is a fault, not a reason to show. + it('propagates an error of the scope check that is not a violation', () => { + const policy = buildPolicy( + undefined, + { scope: { type: 'project', path: 'team/app', includeSubgroups: false } }, + {}, + ); + const broken = { + ...policy, + scopeEnforcer: { + enforce: () => { + throw new TypeError('enforcer broke'); + }, + } as unknown as NonNullable, + }; + + expect(() => callRestriction(broken, browse, { project_id: 'team/app' })).toThrow( + 'enforcer broke', + ); + }); + + it('reports the tool restriction before looking at the arguments', () => { + const policy = buildPolicy(undefined, { readOnly: true }, {}); + + expect(callRestriction(policy, manage, { action: 'create' })).toBe('read-only mode is on'); + }); + + it('lets configuration tools run under a scope', () => { + const policy = buildPolicy( + undefined, + { scope: { type: 'project', path: 'team/app', includeSubgroups: false } }, + {}, + ); + + expect( + callRestriction( + policy, + { name: 'manage_context', readOnly: false }, + { action: 'set_scope', namespace: 'elsewhere' }, + ), + ).toBeNull(); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/configuration/replicas.test.ts b/packages/gitlab-mcp/tests/unit/configuration/replicas.test.ts new file mode 100644 index 00000000..deb79f80 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/replicas.test.ts @@ -0,0 +1,149 @@ +/** + * Settings across server processes. Every replica loads its own module graph (its own + * configuration service, session overrides and caches) and shares only the storage + * backend, like processes behind a load balancer sharing a database. Account settings + * saved on one replica apply on the others and survive a restart; chat overrides and other + * accounts stay apart. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { SessionStorageBackend } from '../../../src/oauth/storage/types'; +import type { Caller } from '../../../src/configuration/caller'; +import type { ConfigurationService } from '../../../src/configuration/service'; +import { FileStorageBackend } from '../../../src/oauth/storage/file'; + +interface Replica { + service: ConfigurationService; + close(): Promise; +} + +async function startReplica(shared: SessionStorageBackend, dir: string): Promise { + let replica: Replica | undefined; + // Without it a later replica can get a storage mock instantiated for an earlier one. + jest.resetModules(); + await jest.isolateModulesAsync(async () => { + jest.doMock('../../../src/oauth/storage/factory', () => ({ + createStorageBackend: () => shared, + })); + // Never the user's own settings file, whatever store the replica picks. + jest.doMock('../../../src/configuration/settings-store', () => ({ + ...jest.requireActual('../../../src/configuration/settings-store'), + localSettingsPath: () => path.join(dir, 'local-settings.json'), + })); + const { getConfigurationService } = await import('../../../src/configuration'); + const { sessionStore } = await import('../../../src/oauth/session-store'); + const service = getConfigurationService(); + // The premise of every test here: the replica stores through the shared backend. + if ((sessionStore as unknown as { backend: unknown }).backend !== shared) { + throw new Error('The replica does not use the shared storage backend'); + } + replica = { + service, + close: async () => { + sessionStore.stopCleanupInterval(); + await shared.close(); + }, + }; + }); + return replica!; +} + +function caller(gitlabUserId: number, sessionKey: string): Caller { + return { + accountKey: `gitlab:https://gitlab.example.com#${gitlabUserId}`, + sessionKey, + accountLabel: `user-${gitlabUserId}`, + instanceUrl: 'https://gitlab.example.com', + oauth: true, + }; +} + +const alice = (session: string) => caller(1, session); +const bob = (session: string) => caller(2, session); + +describe('settings across replicas', () => { + const originalStorage = process.env.OAUTH_STORAGE_TYPE; + let dir: string; + + beforeEach(() => { + // A configured session storage keeps settings with the sessions, shared by replicas. + process.env.OAUTH_STORAGE_TYPE = 'file'; + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'settings-replicas-test-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + if (originalStorage === undefined) delete process.env.OAUTH_STORAGE_TYPE; + else process.env.OAUTH_STORAGE_TYPE = originalStorage; + }); + + // One durable backend instance stands for the database every replica connects to. + const sharedStorage = () => new FileStorageBackend({ filePath: path.join(dir, 'shared.json') }); + + it('applies account settings saved on one replica on another, for that account only', async () => { + const shared = sharedStorage(); + const first = await startReplica(shared, dir); + const second = await startReplica(shared, dir); + + await first.service.updateAccount(alice('chat-1'), { preset: 'readonly' }); + + const aliceElsewhere = await second.service.resolve(alice('chat-2')); + const bobElsewhere = await second.service.resolve(bob('chat-3')); + expect(aliceElsewhere.policy).toMatchObject({ presetName: 'readonly', readOnly: true }); + expect(bobElsewhere.policy).toMatchObject({ presetName: undefined, readOnly: false }); + await first.close(); + }); + + it('keeps a chat override in its chat, on its replica', async () => { + const shared = sharedStorage(); + const first = await startReplica(shared, dir); + const second = await startReplica(shared, dir); + + await first.service.updateSession(alice('chat-1'), { readOnly: true }); + + expect((await first.service.resolve(alice('chat-1'))).policy.readOnly).toBe(true); + expect((await first.service.resolve(alice('chat-2'))).policy.readOnly).toBe(false); + expect((await second.service.resolve(alice('chat-1'))).policy.readOnly).toBe(false); + expect((await first.service.resolve(bob('chat-1'))).policy.readOnly).toBe(false); + await first.close(); + }); + + // Two replicas save different settings of one account at the same time: both are kept. + it('keeps concurrent edits of different settings from two replicas', async () => { + const shared = sharedStorage(); + const first = await startReplica(shared, dir); + const second = await startReplica(shared, dir); + + await Promise.all([ + first.service.updateAccount(alice('chat-1'), { readOnly: true }), + second.service.updateAccount(alice('chat-2'), { disabledToolGroups: ['wiki'] }), + ]); + + expect((await first.service.resolve(alice('chat-3'))).account).toEqual({ + readOnly: true, + disabledToolGroups: ['wiki'], + }); + await first.close(); + }); + + it('keeps account settings across a restart, and not chat overrides', async () => { + const filePath = path.join(dir, 'sessions.json'); + const before = await startReplica(new FileStorageBackend({ filePath }), dir); + await before.service.updateAccount(alice('chat-1'), { + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + await before.service.updateSession(alice('chat-1'), { readOnly: true }); + await before.close(); + + const after = await startReplica(new FileStorageBackend({ filePath }), dir); + const resolved = await after.service.resolve(alice('chat-1')); + + expect(resolved.account.scope).toEqual({ type: 'group', path: 'team', includeSubgroups: true }); + expect(resolved.accountVersion).toBe(1); + expect(resolved.session).toEqual({}); + expect(resolved.policy.readOnly).toBe(false); + await after.close(); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts new file mode 100644 index 00000000..7840033b --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts @@ -0,0 +1,168 @@ +/** + * Under a working scope, listing and search calls that name no project or group are + * narrowed to it: the scope becomes the call's filter where the tool has one, otherwise + * the results outside it are left out. + */ + +import { + scopedArgs, + scopedResult, + targetlessRestriction, +} from '../../../src/configuration/scope-targets'; +import { ScopeEnforcer } from '../../../src/profiles/scope-enforcer'; + +const group = { group: 'team', includeSubgroups: true }; +const groupOnly = { group: 'team', includeSubgroups: false }; +const project = { project: 'team/app' }; +const several = { projects: ['team/app', 'other/api'] }; + +describe('scopedArgs', () => { + it.each([ + [group, { action: 'group', group_id: 'team', search: 'x', scope: 'issues' }], + [project, { action: 'project', project_id: 'team/app', search: 'x', scope: 'issues' }], + ])('turns a global search under %j into a search of the scope', (scope, expected) => { + expect( + scopedArgs('browse_search', { action: 'global', search: 'x', scope: 'issues' }, scope), + ).toEqual(expected); + }); + + it.each([ + [group, { action: 'list', group_id: 'team', include_subgroups: true }], + [groupOnly, { action: 'list', group_id: 'team', include_subgroups: false }], + ])('lists the projects of the scope group %j', (scope, expected) => { + expect(scopedArgs('browse_projects', { action: 'list' }, scope)).toEqual(expected); + }); + + // Without a namespace GitLab creates the project in the user's own namespace. + it('creates a project without a namespace in the scope group', () => { + expect(scopedArgs('manage_project', { action: 'create', name: 'app' }, group)).toEqual({ + action: 'create', + name: 'app', + namespace: 'team', + }); + }); + + it('lists the merge requests of the scope project', () => { + expect( + scopedArgs('browse_merge_requests', { action: 'list', state: 'opened' }, project), + ).toEqual({ action: 'list', state: 'opened', project_id: 'team/app' }); + }); + + // A call that names its target is checked against the scope, not rewritten. + it.each([ + ['browse_projects', { action: 'list', group_id: 'team/sub' }, group], + ['browse_merge_requests', { action: 'list', project_id: 'team/app' }, project], + ['browse_search', { action: 'project', project_id: 'team/app' }, project], + ['browse_projects', { action: 'list' }, project], + ['browse_merge_requests', { action: 'list' }, group], + ['browse_projects', { action: 'list' }, several], + ['browse_projects', { action: 'list' }, { project: 'team/app', group: 'other' }], + ['browse_issues', { action: 'list' }, group], + ])('leaves %s %j under %j as it is', (tool, args, scope) => { + expect(scopedArgs(tool, args, scope)).toBe(args); + }); +}); + +describe('targetlessRestriction', () => { + // GitLab's group search always covers subgroups; results are not filterable by path. + it('refuses a global search under a group scope that excludes subgroups', () => { + expect(targetlessRestriction('browse_search', { action: 'global' }, groupOnly)).toMatch( + /subgroups/, + ); + }); + + it('refuses a global search under a scope of several projects', () => { + expect(targetlessRestriction('browse_search', { action: 'global' }, several)).toMatch( + /search within one of them/, + ); + }); + + // A project with no namespace lands in the user's namespace, a group without a parent at + // the top level: both outside a scope that names no single group to put them in. + it.each([ + ['manage_project', { action: 'create', name: 'app' }, project], + ['manage_project', { action: 'create', name: 'app' }, several], + ['manage_namespace', { action: 'create', name: 'Team', path: 'team2' }, group], + ])('refuses %s %j under %j', (tool, args, scope) => { + expect(targetlessRestriction(tool, args, scope)).toMatch(/outside the working scope/); + }); + + it.each([ + ['browse_search', { action: 'global' }, group], + ['browse_search', { action: 'group', group_id: 'team' }, several], + ['browse_projects', { action: 'list' }, several], + ['manage_project', { action: 'create', name: 'app' }, group], + ['manage_project', { action: 'create', name: 'app', namespace: 'team/sub' }, project], + ['manage_namespace', { action: 'create', name: 'Sub', path: 'sub', parent_id: 7 }, group], + ])('allows %s %j under %j', (tool, args, scope) => { + expect(targetlessRestriction(tool, args, scope)).toBeNull(); + }); +}); + +describe('scopedResult', () => { + const enforcer = new ScopeEnforcer(group); + + it('keeps only projects of the scope from a project search', () => { + const found = [ + { path_with_namespace: 'team/app' }, + { path_with_namespace: 'other/api' }, + { name: 'no path' }, + ]; + + expect(scopedResult('browse_projects', { action: 'search' }, found, enforcer)).toEqual([ + { path_with_namespace: 'team/app' }, + ]); + }); + + // A group's project listing also returns projects only shared with the group. + it('leaves out projects shared into the scope group from other namespaces', () => { + const listed = [{ path_with_namespace: 'team/app' }, { path_with_namespace: 'other/shared' }]; + + expect( + scopedResult('browse_projects', { action: 'list', group_id: 'team' }, listed, enforcer), + ).toEqual([{ path_with_namespace: 'team/app' }]); + }); + + it('keeps only merge requests of the scope from a cross-project listing', () => { + const mrs = [ + { iid: 1, references: { full: 'team/sub/app!1' } }, + { iid: 2, references: { full: 'other/api!2' } }, + { iid: 3 }, + ]; + + expect(scopedResult('browse_merge_requests', { action: 'list' }, mrs, enforcer)).toEqual([ + { iid: 1, references: { full: 'team/sub/app!1' } }, + ]); + }); + + it('keeps only todos of projects and groups in the scope', () => { + const todos = [ + { id: 1, project: { path_with_namespace: 'team/app' } }, + { id: 2, project: { path_with_namespace: 'other/api' } }, + { id: 3, group: { full_path: 'team/sub' } }, + { id: 4, group: { full_path: 'other' } }, + { id: 5 }, + ]; + + expect(scopedResult('browse_todos', { action: 'list' }, todos, enforcer)).toEqual([ + { id: 1, project: { path_with_namespace: 'team/app' } }, + { id: 3, group: { full_path: 'team/sub' } }, + ]); + }); + + it.each([ + ['browse_merge_requests', { action: 'list', project_id: 'team/app' }], + ['browse_todos', { action: 'list', project_id: 7 }], + ['browse_issues', { action: 'list' }], + ])('passes the results of %s %j through', (tool, args) => { + const result = [{ path_with_namespace: 'other/api' }]; + + expect(scopedResult(tool, args, result, enforcer)).toBe(result); + }); + + it('passes a result that is not a list through', () => { + const result = { id: 1 }; + + expect(scopedResult('browse_projects', { action: 'search' }, result, enforcer)).toBe(result); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/configuration/service.test.ts b/packages/gitlab-mcp/tests/unit/configuration/service.test.ts new file mode 100644 index 00000000..a0d51438 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/service.test.ts @@ -0,0 +1,384 @@ +/** + * The configuration service keeps each account's settings and each session's overrides + * apart, validates before it writes, merges concurrent edits of different fields, and + * notifies only the sessions a change affects. + */ + +import type { Caller } from '../../../src/configuration/caller'; +import { + ConfigurationError, + ConfigurationService, + type PresetSource, +} from '../../../src/configuration/service'; +import type { SettingsStore } from '../../../src/configuration/settings-store'; +import type { AccountSettings, AccountSettingsRecord } from '../../../src/configuration/types'; +import type { Preset } from '../../../src/profiles/types'; + +/** In-memory compare-and-set store, with a hook to interleave a competing write. */ +class FakeStore implements SettingsStore { + records = new Map(); + beforePut?: () => Promise; + failPut = false; + + async get(accountKey: string): Promise { + const record = this.records.get(accountKey); + return record ? structuredClone(record) : undefined; + } + + async put( + accountKey: string, + settings: AccountSettings, + expectedVersion: number, + ): Promise { + const hook = this.beforePut; + this.beforePut = undefined; + await hook?.(); + if (this.failPut) throw new Error('database down'); + const version = this.records.get(accountKey)?.version ?? 0; + if (version !== expectedVersion) return undefined; + const record = { accountKey, settings, version: version + 1, updatedAt: 1 }; + this.records.set(accountKey, record); + return structuredClone(record); + } +} + +const PRESETS: Record = { + readonly: { read_only: true }, + developer: { features: { wiki: false } }, +}; + +const presets: PresetSource = { + load: async (name) => { + const preset = PRESETS[name]; + if (!preset) throw new Error('not found'); + return preset; + }, +}; + +function caller(accountKey: string, sessionKey: string): Caller { + return { + accountKey, + sessionKey, + accountLabel: accountKey, + instanceUrl: 'https://gitlab.example.com', + oauth: true, + }; +} + +describe('ConfigurationService', () => { + let store: FakeStore; + let notified: string[][]; + let service: ConfigurationService; + + beforeEach(() => { + store = new FakeStore(); + notified = []; + service = new ConfigurationService( + async () => store, + presets, + async (keys) => { + notified.push([...keys].sort()); + }, + ); + }); + + it('resolves a caller without settings to an unrestricted policy', async () => { + const resolved = await service.resolve(caller('alice', 's1')); + + expect(resolved.account).toEqual({}); + expect(resolved.accountVersion).toBe(0); + expect(resolved.policy.readOnly).toBe(false); + }); + + it('applies saved account settings to every session of the account', async () => { + await service.updateAccount(caller('alice', 's1'), { preset: 'readonly' }); + + const other = await service.resolve(caller('alice', 's2')); + + expect(other.policy).toMatchObject({ presetName: 'readonly', readOnly: true }); + }); + + // Two users of one server: one account's settings never reach the other. + it('keeps the settings of two accounts apart', async () => { + await service.updateAccount(caller('alice', 's1'), { readOnly: true }); + + expect((await service.resolve(caller('bob', 's2'))).policy.readOnly).toBe(false); + }); + + // Two chats of one user: a session override stays in its session. + it('keeps a session override in its own session', async () => { + await service.updateSession(caller('alice', 's1'), { readOnly: true }); + + expect((await service.resolve(caller('alice', 's1'))).policy.readOnly).toBe(true); + expect((await service.resolve(caller('alice', 's2'))).policy.readOnly).toBe(false); + }); + + // A session id is not a capability: another account presenting it gets nothing. + it('does not share a session override with another account using the same session id', async () => { + await service.updateSession(caller('alice', 'shared'), { preset: 'readonly' }); + + expect((await service.resolve(caller('bob', 'shared'))).session).toEqual({}); + }); + + it('keeps fields the patch leaves out and removes fields set to null', async () => { + await service.updateAccount(caller('alice', 's1'), { + preset: 'developer', + disabledToolGroups: ['pipelines'], + }); + + const stored = await service.updateAccount(caller('alice', 's1'), { + readOnly: true, + preset: null, + }); + + expect(stored.settings).toEqual({ disabledToolGroups: ['pipelines'], readOnly: true }); + }); + + // Another session saved a different field between this edit's read and its write: + // the edit is re-applied on top, and neither change is lost. + it('merges an edit with a concurrent edit of another field', async () => { + store.beforePut = async () => { + await store.put('alice', { readOnly: true }, 0); + }; + + const stored = await service.updateAccount(caller('alice', 's1'), { preset: 'developer' }); + + expect(stored.settings).toEqual({ readOnly: true, preset: 'developer' }); + expect(stored.version).toBe(2); + }); + + // Two chats switch off different tool groups at once: both groups end up off. + it('merges a tool-group toggle with a concurrent toggle of another group', async () => { + store.beforePut = async () => { + await store.put('alice', { disabledToolGroups: ['mrs'] }, 0); + }; + + const stored = await service.updateAccount(caller('alice', 's1'), { + toolGroups: { wiki: false }, + }); + + expect(stored.settings.disabledToolGroups).toEqual(['mrs', 'wiki']); + }); + + it('turns a tool group back on and clears the list when none is left off', async () => { + await service.updateAccount(caller('alice', 's1'), { toolGroups: { wiki: false } }); + + const stored = await service.updateAccount(caller('alice', 's1'), { + toolGroups: { wiki: true }, + }); + + expect(stored.settings).toEqual({}); + }); + + it('refuses an unknown tool group in a toggle without writing anything', async () => { + await expect( + service.updateAccount(caller('alice', 's1'), { toolGroups: { bogus: false } }), + ).rejects.toThrow('Unknown tool group: bogus'); + expect(store.records.size).toBe(0); + }); + + // The subgroup choice applies to the group saved now, not the one the page showed. + it('applies a subgroup change to a scope changed concurrently', async () => { + await service.updateAccount(caller('alice', 's1'), { + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + store.beforePut = async () => { + await store.put( + 'alice', + { scope: { type: 'group', path: 'other', includeSubgroups: true } }, + 1, + ); + }; + + const stored = await service.updateAccount(caller('alice', 's1'), { + scopeIncludeSubgroups: false, + }); + + expect(stored.settings.scope).toEqual({ + type: 'group', + path: 'other', + includeSubgroups: false, + }); + }); + + it('ignores a subgroup change when no group is saved', async () => { + const stored = await service.updateAccount(caller('alice', 's1'), { + readOnly: true, + scopeIncludeSubgroups: false, + }); + + expect(stored.settings).toEqual({ readOnly: true }); + }); + + it('reports settings that keep changing instead of looping forever', async () => { + const put = store.put.bind(store); + store.put = async () => undefined; + + await expect(service.updateAccount(caller('alice', 's1'), { readOnly: true })).rejects.toThrow( + 'The settings kept changing meanwhile; try again', + ); + store.put = put; + }); + + it.each([ + [{ preset: 'nonexistent' }, /Unknown preset 'nonexistent'/], + [{ disabledToolGroups: ['wiki', 'bogus'] }, /Unknown tool group: bogus/], + [{ scope: { type: 'project' as const, path: '', includeSubgroups: false } }, /.+/], + ])('refuses %j without writing anything', async (patch, message) => { + await expect(service.updateAccount(caller('alice', 's1'), patch)).rejects.toThrow(message); + + expect(store.records.size).toBe(0); + expect(notified).toEqual([]); + }); + + it('refuses an unknown preset for the session without changing it', async () => { + await expect( + service.updateSession(caller('alice', 's1'), { preset: 'nonexistent' }), + ).rejects.toBeInstanceOf(ConfigurationError); + + expect((await service.resolve(caller('alice', 's1'))).session).toEqual({}); + }); + + // A failed write is reported, never as saved settings. + it('propagates a failed write without notifying anyone', async () => { + store.failPut = true; + + await expect(service.updateAccount(caller('alice', 's1'), { readOnly: true })).rejects.toThrow( + 'database down', + ); + expect(notified).toEqual([]); + }); + + it('notifies every session of the account on an account change, and only those', async () => { + await service.resolve(caller('alice', 's1')); + await service.resolve(caller('alice', 's2')); + await service.resolve(caller('bob', 's3')); + + await service.updateAccount(caller('alice', 's1'), { readOnly: true }); + + expect(notified).toEqual([['s1', 's2']]); + }); + + it('notifies only the session a session change affects', async () => { + await service.resolve(caller('alice', 's2')); + + await service.updateSession(caller('alice', 's1'), { readOnly: true }); + + expect(notified).toEqual([['s1']]); + }); + + it('drops the session overrides on reset', async () => { + await service.updateSession(caller('alice', 's1'), { preset: 'readonly' }); + + await service.resetSession(caller('alice', 's1')); + + expect((await service.resolve(caller('alice', 's1'))).session).toEqual({}); + }); + + it('forgets a closed session and its overrides', async () => { + await service.updateSession(caller('alice', 's1'), { readOnly: true }); + + service.forgetSession('s1'); + await service.updateAccount(caller('alice', 's2'), { preset: 'developer' }); + + expect(notified.at(-1)).toEqual(['s2']); + expect((await service.resolve(caller('alice', 's1'))).session).toEqual({}); + }); + + // Closing one chat leaves the account's other chats tracked and their overrides intact. + it('keeps the other sessions of the account when one closes', async () => { + await service.updateSession(caller('alice', 's1'), { readOnly: true }); + await service.updateSession(caller('alice', 's2'), { preset: 'readonly' }); + + service.forgetSession('s1'); + await service.updateAccount(caller('alice', 's3'), { preset: 'developer' }); + + expect(notified.at(-1)).toEqual(['s2', 's3']); + expect((await service.resolve(caller('alice', 's2'))).session).toEqual({ preset: 'readonly' }); + }); + + it('forgets a session whose last override is cleared', async () => { + await service.updateSession(caller('alice', 's1'), { readOnly: true }); + + const next = await service.updateSession(caller('alice', 's1'), { readOnly: null }); + + expect(next).toEqual({}); + expect((await service.resolve(caller('alice', 's1'))).session).toEqual({}); + }); + + describe('moveSessions', () => { + // A tracked session without overrides moves too, so account changes still reach it. + it('moves every session of the account, overrides without the scope', async () => { + await service.updateSession(caller('old', 's1'), { + readOnly: true, + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + await service.updateSession(caller('old', 's2'), { + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + await service.resolve(caller('old', 's3')); + notified.length = 0; + + await service.moveSessions('old', 'new'); + await service.updateAccount(caller('new', 's9'), { readOnly: true }); + + expect((await service.resolve(caller('new', 's1'))).session).toEqual({ readOnly: true }); + expect((await service.resolve(caller('new', 's2'))).session).toEqual({}); + expect(notified[0]).toEqual(['s1', 's2', 's3']); + expect(notified[1]).toEqual(['s1', 's2', 's3', 's9']); + }); + + it('does nothing when the account key stays the same or has no sessions', async () => { + await service.updateSession(caller('same', 's1'), { readOnly: true }); + notified.length = 0; + + await service.moveSessions('same', 'same'); + await service.moveSessions('unknown', 'other'); + + expect((await service.resolve(caller('same', 's1'))).session).toEqual({ readOnly: true }); + expect(notified).toEqual([[]]); + }); + }); + + // A patch field left undefined means "unchanged", unlike null which clears it. + it('ignores patch fields that are undefined', async () => { + await service.updateSession(caller('alice', 's1'), { preset: 'readonly' }); + + const next = await service.updateSession(caller('alice', 's1'), { + preset: undefined, + readOnly: true, + }); + + expect(next).toEqual({ preset: 'readonly', readOnly: true }); + }); + + it('reports a preset loader failure that is not an Error', async () => { + const failing = new ConfigurationService( + async () => store, + { + load: () => Promise.reject('preset store offline'), + }, + async () => undefined, + ); + + await expect( + failing.updateAccount(caller('alice', 's1'), { preset: 'readonly' }), + ).rejects.toThrow("Unknown preset 'readonly': preset store offline"); + }); + + // The selected preset was deleted after it was saved: the caller keeps working read-only + // and can still open the settings to choose another one. + it('falls back to read-only when the selected preset is gone', async () => { + store.records.set('alice', { + accountKey: 'alice', + settings: { preset: 'deleted' }, + version: 1, + updatedAt: 1, + }); + + const resolved = await service.resolve(caller('alice', 's1')); + + expect(resolved.presetUnavailable).toBe('deleted'); + expect(resolved.policy).toMatchObject({ readOnly: true, presetName: 'deleted' }); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts b/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts new file mode 100644 index 00000000..f6e5506d --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts @@ -0,0 +1,200 @@ +/** + * A local server keeps account settings in a file shared by every local server process. + * Writes are compare-and-set against the file itself, so processes do not lose each + * other's edits, and a lock left by a crashed process does not block forever. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { LocalSettingsFile, localSettingsPath } from '../../../src/configuration/settings-store'; + +// The test setup keeps every test away from the user's own settings file. +it('points tests at a temporary settings file, not the home directory', () => { + expect(localSettingsPath().startsWith(os.tmpdir())).toBe(true); + expect(localSettingsPath().startsWith(os.homedir())).toBe(false); +}); + +describe('LocalSettingsFile', () => { + let dir: string; + let filePath: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'settings-store-test-')); + filePath = path.join(dir, 'nested', 'settings.json'); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('has no settings before the first write', async () => { + expect(await new LocalSettingsFile(filePath).get('acct')).toBeUndefined(); + }); + + it('writes the first settings at version 1 and creates the directory', async () => { + const store = new LocalSettingsFile(filePath); + + const stored = await store.put('acct', { readOnly: true }, 0); + + expect(stored).toMatchObject({ accountKey: 'acct', settings: { readOnly: true }, version: 1 }); + expect(await store.get('acct')).toEqual(stored); + }); + + // The file holds account choices only, but stays private to the user like the rest. + it('writes the file owner-only', async () => { + await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0); + + if (process.platform !== 'win32') { + expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + } + }); + + it('refuses a write against a stale version and keeps the newer settings', async () => { + const store = new LocalSettingsFile(filePath); + await store.put('acct', { preset: 'developer' }, 0); + await store.put('acct', { preset: 'pm' }, 1); + + expect(await store.put('acct', { preset: 'readonly' }, 1)).toBeUndefined(); + expect((await store.get('acct'))?.settings).toEqual({ preset: 'pm' }); + }); + + // Two local server processes: each reads the file on every call, so the second one + // sees the first one's write and its stale edit is refused. + it('shares settings between two stores of one file', async () => { + const first = new LocalSettingsFile(filePath); + const second = new LocalSettingsFile(filePath); + await first.put('acct', { readOnly: true }, 0); + + expect((await second.get('acct'))?.settings).toEqual({ readOnly: true }); + expect(await second.put('acct', { readOnly: false }, 0)).toBeUndefined(); + }); + + it('lets exactly one of concurrent writes of one version win', async () => { + const results = await Promise.all([ + new LocalSettingsFile(filePath).put('acct', { preset: 'a' }, 0), + new LocalSettingsFile(filePath).put('acct', { preset: 'b' }, 0), + new LocalSettingsFile(filePath).put('acct', { preset: 'c' }, 0), + ]); + + expect(results.filter((r) => r !== undefined)).toHaveLength(1); + expect(fs.existsSync(`${filePath}.lock`)).toBe(false); + }); + + it('keeps the settings of other accounts when one account writes', async () => { + const store = new LocalSettingsFile(filePath); + await store.put('one', { readOnly: true }, 0); + await store.put('two', { preset: 'pm' }, 0); + + expect((await store.get('one'))?.settings).toEqual({ readOnly: true }); + }); + + it('removes a lock left by a process that died while holding it', async () => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const lock = `${filePath}.lock`; + fs.writeFileSync(lock, ''); + const longAgo = new Date(Date.now() - 60_000); + fs.utimesSync(lock, longAgo, longAgo); + + expect(await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0)).toBeDefined(); + }); + + // A hand-edited entry the schema refuses is ignored instead of applied half-valid. + it('ignores an entry with invalid settings', async () => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + version: 1, + accounts: { + bad: { accountKey: 'bad', settings: { readOnly: 'yes' }, version: 1, updatedAt: 1 }, + good: { accountKey: 'good', settings: { readOnly: true }, version: 1, updatedAt: 1 }, + }, + }), + ); + const store = new LocalSettingsFile(filePath); + + expect(await store.get('bad')).toBeUndefined(); + expect((await store.get('good'))?.settings).toEqual({ readOnly: true }); + }); + + it('reports a file that is not JSON instead of treating it as empty', async () => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, '{ not json'); + + await expect(new LocalSettingsFile(filePath).get('acct')).rejects.toThrow(SyntaxError); + }); + + // Without syncing the directory, a crash after the rename can bring the old file back. + it('syncs the directory after replacing the file', async () => { + if (process.platform === 'win32') return; + const open = jest.spyOn(fs.promises, 'open'); + try { + await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0); + + expect(open).toHaveBeenCalledWith(path.dirname(filePath), 'r'); + } finally { + open.mockRestore(); + } + }); + + it('reads a file without accounts as empty', async () => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: 1 })); + + expect(await new LocalSettingsFile(filePath).get('acct')).toBeUndefined(); + }); + + // Only a missing file means "no settings yet"; an unreadable one is an error. + it('reports a file it cannot read instead of treating it as empty', async () => { + fs.mkdirSync(filePath, { recursive: true }); + + await expect(new LocalSettingsFile(filePath).get('acct')).rejects.toMatchObject({ + code: 'EISDIR', + }); + }); + + describe('lock', () => { + const errno = (code: string) => Object.assign(new Error(code), { code }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('reports a lock it cannot create for a reason other than contention', async () => { + jest.spyOn(fs.promises, 'open').mockRejectedValueOnce(errno('EACCES')); + + await expect( + new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0), + ).rejects.toMatchObject({ code: 'EACCES' }); + }); + + // The holder released the lock between the failed create and the age check. + it('retries at once when the lock disappears while being checked', async () => { + jest.spyOn(fs.promises, 'open').mockRejectedValueOnce(errno('EEXIST')); + jest.spyOn(fs.promises, 'stat').mockRejectedValueOnce(errno('ENOENT')); + + expect( + await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0), + ).toBeDefined(); + }); + + // A live process holding the lock for longer than the wait: give up with a clear error. + it('gives up on a lock another live process keeps holding', async () => { + jest.useFakeTimers(); + jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); + jest.spyOn(fs.promises, 'open').mockRejectedValue(errno('EEXIST')); + jest + .spyOn(fs.promises, 'stat') + .mockResolvedValue({ mtimeMs: Date.now() } as unknown as fs.Stats); + + const write = new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0); + const outcome = expect(write).rejects.toThrow( + `Settings file is locked by another process: ${filePath}.lock`, + ); + await jest.advanceTimersByTimeAsync(10_000); + await outcome; + }); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/entities/context/connection-check.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/connection-check.test.ts new file mode 100644 index 00000000..a4b47295 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/entities/context/connection-check.test.ts @@ -0,0 +1,203 @@ +/** + * The connection check combines what GitLab reports about the credentials with the + * caller's effective settings, and tells the user what to do about problems. + */ + +jest.mock('../../../../src/config', () => ({ GITLAB_BASE_URL: 'https://gitlab.example.com' })); + +const mockWhoami = jest.fn(); +jest.mock('../../../../src/entities/context/whoami', () => ({ + executeWhoami: () => mockWhoami(), +})); + +// Two tools the registry offers on the instance: one reads GitLab, one changes it. +const mockAvailableToolNames = jest.fn(() => ['browse_wiki', 'manage_wiki']); +jest.mock('../../../../src/registry-manager', () => ({ + RegistryManager: { + getInstance: () => ({ + getAvailableToolNames: mockAvailableToolNames, + getToolFacts: (name: string) => ({ + name, + group: 'wiki', + readOnly: name.startsWith('browse_'), + }), + }), + }, +})); + +let mockService: unknown; +jest.mock('../../../../src/configuration', () => ({ + ...jest.requireActual('../../../../src/configuration/caller'), + getConfigurationService: () => mockService, +})); + +import { checkConnection } from '../../../../src/entities/context/connection-check'; +import { ConfigurationService } from '../../../../src/configuration/service'; +import { runWithCaller, type Caller } from '../../../../src/configuration/caller'; +import type { AccountSettingsRecord } from '../../../../src/configuration/types'; +import { runWithTokenContext } from '../../../../src/oauth/token-context'; + +const alice: Caller = { + accountKey: 'gitlab:https://gitlab.example.com#1', + sessionKey: 's1', + accountLabel: 'alice@gitlab.example.com', + instanceUrl: 'https://gitlab.example.com', + oauth: true, +}; + +function whoami(overrides: Record = {}) { + return { + user: { username: 'alice' }, + server: { + apiUrl: 'https://gitlab.example.com', + version: '17.4.0', + tier: 'premium', + readOnlyMode: false, + }, + capabilities: { availableToolCount: 42 }, + warnings: ['Token expires in 3 days'], + recommendations: [{ action: 'renew_token', message: 'Renew the token', priority: 'high' }], + ...overrides, + }; +} + +describe('checkConnection', () => { + let records: Map; + + beforeEach(() => { + records = new Map(); + mockWhoami.mockResolvedValue(whoami()); + mockService = new ConfigurationService( + async () => ({ + get: async (key: string) => records.get(key), + put: async () => undefined, + }), + { + load: async (name: string) => { + if (name === 'readonly') return { read_only: true }; + throw new Error(`Preset not found: ${name}`); + }, + }, + async () => undefined, + ); + }); + + function save(settings: AccountSettingsRecord['settings']): void { + records.set(alice.accountKey, { + accountKey: alice.accountKey, + settings, + version: 1, + updatedAt: 1, + }); + } + + it("reports the account, GitLab and the caller's restrictions", async () => { + save({ preset: 'readonly', scope: { type: 'group', path: 'team', includeSubgroups: true } }); + + const check = await runWithCaller(alice, () => checkConnection()); + + expect(check).toEqual({ + account: 'alice', + instance: 'https://gitlab.example.com', + gitlabVersion: '17.4.0', + tier: 'premium', + authenticated: true, + readOnly: true, + preset: 'readonly', + scope: 'team', + // The read-only preset leaves only the reading tool of the two + availableTools: 1, + warnings: ['Token expires in 3 days'], + recommendations: ['Renew the token'], + }); + }); + + // The count is what this caller can call, not what the registry offers everyone. + it.each([ + [{}, 2], + [{ readOnly: true }, 1], + [{ disabledToolGroups: ['wiki'] }, 0], + ])('counts the tools %j leaves available', async (settings, count) => { + save(settings); + + const check = await runWithCaller(alice, () => checkConnection()); + + expect(check.availableTools).toBe(count); + expect(mockAvailableToolNames).toHaveBeenCalledWith(alice.instanceUrl); + }); + + // The count matches tools/list: an OAuth token's scopes hide tools it cannot call. + it("leaves out tools the caller's OAuth token scopes do not allow", async () => { + const check = await runWithTokenContext( + { + gitlabToken: 'token', + gitlabUserId: 1, + gitlabUsername: 'alice', + sessionId: 's1', + apiUrl: 'https://gitlab.example.com', + gitlabScopes: ['read_user'], + }, + () => runWithCaller(alice, () => checkConnection()), + ); + + expect(check.availableTools).toBe(0); + }); + + it('reports a project scope and no preset', async () => { + save({ scope: { type: 'project', path: 'team/app', includeSubgroups: false } }); + + const check = await runWithCaller(alice, () => checkConnection()); + + expect(check).toMatchObject({ preset: null, scope: 'team/app', readOnly: false }); + }); + + it('reports read-only mode of the server', async () => { + mockWhoami.mockResolvedValue( + whoami({ + server: { + apiUrl: 'https://gitlab.example.com', + version: '17.4.0', + tier: 'free', + readOnlyMode: true, + }, + }), + ); + + expect((await runWithCaller(alice, () => checkConnection())).readOnly).toBe(true); + }); + + // GitLab did not identify anyone with the credentials: shown as not signed in. + it('reports credentials GitLab does not accept', async () => { + mockWhoami.mockResolvedValue(whoami({ user: null })); + + const check = await runWithCaller(alice, () => checkConnection()); + + expect(check).toMatchObject({ + authenticated: false, + account: 'alice@gitlab.example.com', + scope: null, + }); + }); + + it('checks the server connection outside a tool call', async () => { + const check = await checkConnection(); + + expect(check.account).toBe('alice'); + }); + + // The saved preset was removed from the server: the chat works read-only, and the + // check says why and how to get out of it. + it('explains a saved preset that no longer exists', async () => { + save({ preset: 'retired' }); + + const check = await runWithCaller(alice, () => checkConnection()); + + expect(check.readOnly).toBe(true); + expect(check.warnings).toContain( + "The saved preset 'retired' no longer exists on this server; tools that change GitLab are off until another preset is chosen.", + ); + expect(check.recommendations).toContain( + 'Choose another preset in the GitLab settings, or with update_settings.', + ); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts index 8c1d9e35..bc339a74 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts @@ -1,10 +1,12 @@ /** * Unit tests for ContextManager * - * Tests the ContextManager singleton and its methods. + * manage_context acts on the caller of the running tool call and its current session, + * through the configuration service: presets and scopes are session overrides, another + * caller's or another session's context never changes, and reset brings back the + * account settings. */ -// Mock config module BEFORE importing ContextManager let mockGitLabBaseUrl = 'https://gitlab.example.com'; let mockGitLabReadOnlyMode = false; @@ -17,113 +19,65 @@ jest.mock('../../../../src/config', () => ({ }, })); -// Mock dependencies jest.mock('../../../../src/utils/namespace', () => ({ - detectNamespaceType: jest.fn(), + // The guessing lookup answers even for a path GitLab does not know, as the real one does. + detectNamespaceType: async (path: string) => (path.includes('/') ? 'project' : 'group'), + findNamespaceType: jest.fn(), })); -// Mock server module for tools list changed notification jest.mock('../../../../src/server', () => ({ sendToolsListChangedNotification: jest.fn().mockResolvedValue(undefined), })); -// Mock NamespaceTierDetector for switchInstance tests const mockClearNamespaceTierCache = jest.fn(); jest.mock('../../../../src/services/NamespaceTierDetector', () => ({ clearNamespaceTierCache: () => mockClearNamespaceTierCache(), detectNamespaceTier: jest.fn(), })); -// Mock ConnectionManager for switchInstance tests const mockReinitialize = jest.fn(); const mockGetCurrentInstanceUrl = jest.fn().mockReturnValue('https://gitlab.example.com'); -const mockConnectionManagerGetInstance = jest.fn(() => ({ - reinitialize: mockReinitialize, - getCurrentInstanceUrl: mockGetCurrentInstanceUrl, -})); jest.mock('../../../../src/services/ConnectionManager', () => ({ ConnectionManager: { - getInstance: () => mockConnectionManagerGetInstance(), + getInstance: () => ({ + reinitialize: mockReinitialize, + getCurrentInstanceUrl: mockGetCurrentInstanceUrl, + }), }, })); -// Mock preset responses for different test scenarios const mockPresets: Record = { - readonly: { - description: 'Read-only preset', - read_only: true, - }, - developer: { - description: 'Developer preset', - read_only: false, - }, - 'multi-projects': { - description: 'Multi-project preset', - read_only: false, - scope: { - projects: ['team/project1', 'team/project2', 'team/project3'], - }, - }, - 'multi-groups': { - description: 'Multi-group preset', - read_only: false, - scope: { - groups: ['team-a', 'team-b'], - includeSubgroups: true, - }, - }, - 'namespace-scope': { - description: 'Namespace scoped preset', - read_only: false, - scope: { - namespace: 'my-namespace', - includeSubgroups: true, - }, - }, - 'single-project-scope': { - description: 'Single project in array', - read_only: false, - scope: { - projects: ['only-project'], - }, - }, - 'single-group-scope': { - description: 'Single group in array', - read_only: false, - scope: { - groups: ['only-group'], - }, - }, + readonly: { description: 'Read-only preset', read_only: true }, + developer: { description: 'Developer preset', read_only: false }, + 'multi-projects': { scope: { projects: ['team/project1', 'team/project2', 'team/project3'] } }, + 'multi-groups': { scope: { groups: ['team-a', 'team-b'], includeSubgroups: true } }, + 'namespace-scope': { scope: { namespace: 'my-namespace', includeSubgroups: true } }, + 'single-project-scope': { scope: { projects: ['only-project'] } }, + 'single-group-scope': { scope: { groups: ['only-group'] } }, + 'empty-scope': { scope: { includeSubgroups: true } }, + 'no-wiki': { features: { wiki: false } }, }; +const mockLoadPreset = jest.fn((name: string) => + mockPresets[name] + ? Promise.resolve(mockPresets[name]) + : Promise.reject(new Error(`Preset not found: ${name}`)), +); + jest.mock('../../../../src/profiles/loader', () => ({ ProfileLoader: jest.fn().mockImplementation(() => ({ listProfiles: jest.fn().mockResolvedValue([ + { name: 'readonly', readOnly: true, isBuiltIn: true, isPreset: true }, + { name: 'developer', readOnly: false, isBuiltIn: true, isPreset: true }, { - name: 'readonly', - description: 'Read-only preset', - readOnly: true, - isBuiltIn: true, - isPreset: true, - }, - { - name: 'developer', - description: 'Developer preset', + name: 'production', + host: 'gitlab.example.com', readOnly: false, - isBuiltIn: true, - isPreset: true, + isBuiltIn: false, + isPreset: false, }, ]), - loadPreset: jest.fn().mockImplementation((name: string) => { - if (name === 'invalid-preset') { - return Promise.reject(new Error('Preset not found: invalid-preset')); - } - const preset = mockPresets[name] || { - description: 'Test preset', - read_only: false, - }; - return Promise.resolve(preset); - }), + loadPreset: (name: string) => mockLoadPreset(name), loadProfile: jest.fn().mockImplementation((name: string) => { if (name === 'invalid-profile') { return Promise.reject(new Error('Profile not found: invalid-profile')); @@ -143,41 +97,82 @@ jest.mock('../../../../src/profiles/loader', () => ({ })), })); +// A real configuration service over an in-memory store; notifications are recorded. +const mockNotified: string[][] = []; +let mockService: unknown; +jest.mock('../../../../src/configuration', () => { + const actual = jest.requireActual('../../../../src/configuration/caller'); + return { + ...actual, + getConfigurationService: () => mockService, + }; +}); + import { ContextManager, getContextManager, } from '../../../../src/entities/context/context-manager'; -import { detectNamespaceType } from '../../../../src/utils/namespace'; +import { findNamespaceType } from '../../../../src/utils/namespace'; import { sendToolsListChangedNotification } from '../../../../src/server'; +import { ConfigurationService } from '../../../../src/configuration/service'; +import { resolveCaller, runWithCaller, type Caller } from '../../../../src/configuration/caller'; +import type { AccountSettingsRecord, AccountSettings } from '../../../../src/configuration/types'; -const mockDetectNamespaceType = detectNamespaceType as jest.MockedFunction< - typeof detectNamespaceType ->; - +const mockDetectNamespaceType = findNamespaceType as jest.MockedFunction; const mockSendToolsListChangedNotification = sendToolsListChangedNotification as jest.MockedFunction; +function memoryStore() { + const records = new Map(); + return { + records, + get: async (key: string) => records.get(key), + put: async (key: string, settings: AccountSettings, expected: number) => { + const version = records.get(key)?.version ?? 0; + if (version !== expected) return undefined; + const record = { accountKey: key, settings, version: version + 1, updatedAt: 1 }; + records.set(key, record); + return record; + }, + }; +} + +function caller(accountKey: string, sessionKey: string): Caller { + return { + accountKey, + sessionKey, + accountLabel: accountKey, + instanceUrl: 'https://gitlab.example.com', + oauth: false, + }; +} + +const alice1 = caller('alice', 's1'); +const alice2 = caller('alice', 's2'); +const bob = caller('bob', 's3'); + describe('ContextManager', () => { const originalEnv = process.env; + let store: ReturnType; + let service: ConfigurationService; beforeEach(() => { - // Reset singleton and mocks before each test ContextManager.resetInstance(); jest.clearAllMocks(); - - // Reset mock values + mockNotified.length = 0; mockGitLabBaseUrl = 'https://gitlab.example.com'; mockGitLabReadOnlyMode = false; - - // Reset switchInstance mocks - mockClearNamespaceTierCache.mockReset(); mockReinitialize.mockReset(); - - // Set up test environment - process.env = { - ...originalEnv, - OAUTH_ENABLED: 'false', - }; + process.env = { ...originalEnv, OAUTH_ENABLED: 'false' }; + store = memoryStore(); + service = new ConfigurationService( + async () => store, + { load: (name) => mockLoadPreset(name) }, + async (keys) => { + mockNotified.push([...keys]); + }, + ); + mockService = service; }); afterAll(() => { @@ -185,691 +180,399 @@ describe('ContextManager', () => { }); describe('singleton pattern', () => { - it('should return the same instance on multiple calls', () => { - const instance1 = ContextManager.getInstance(); - const instance2 = ContextManager.getInstance(); - expect(instance1).toBe(instance2); - }); - - it('should create a new instance after reset', () => { - const instance1 = ContextManager.getInstance(); + it('returns the same instance until reset', () => { + const first = ContextManager.getInstance(); + expect(ContextManager.getInstance()).toBe(first); + expect(getContextManager()).toBe(first); ContextManager.resetInstance(); - const instance2 = ContextManager.getInstance(); - expect(instance1).not.toBe(instance2); - }); - - it('should work with getContextManager helper', () => { - const instance1 = getContextManager(); - const instance2 = ContextManager.getInstance(); - expect(instance1).toBe(instance2); + expect(ContextManager.getInstance()).not.toBe(first); }); }); describe('getContext', () => { - it('should return current context with basic info', () => { - const manager = ContextManager.getInstance(); - const context = manager.getContext(); - - expect(context.host).toBe('gitlab.example.com'); - expect(context.apiUrl).toBe('https://gitlab.example.com'); - expect(context.readOnly).toBe(false); - expect(context.oauthMode).toBe(false); + it('reports the operator configuration for a caller without settings', async () => { + const context = await runWithCaller(alice1, () => getContextManager().getContext()); + + expect(context).toMatchObject({ + host: 'gitlab.example.com', + apiUrl: 'https://gitlab.example.com', + readOnly: false, + oauthMode: false, + presetName: undefined, + scope: undefined, + }); + expect(context.initialContext).toEqual({ + host: 'gitlab.example.com', + apiUrl: 'https://gitlab.example.com', + readOnly: false, + oauthMode: false, + }); }); - it('should reflect read-only mode from config', () => { + it('reports the operator read-only mode', async () => { mockGitLabReadOnlyMode = true; - ContextManager.resetInstance(); - - const manager = ContextManager.getInstance(); - const context = manager.getContext(); - expect(context.readOnly).toBe(true); + expect((await getContextManager().getContext()).readOnly).toBe(true); }); - it('should reflect OAuth mode from environment', () => { + it('reports OAuth mode', async () => { process.env.OAUTH_ENABLED = 'true'; - ContextManager.resetInstance(); - const manager = ContextManager.getInstance(); - const context = manager.getContext(); + expect((await getContextManager().getContext()).oauthMode).toBe(true); + }); + + it('uses the server instance outside a tool call', async () => { + mockGitLabBaseUrl = 'https://other.example.com'; - expect(context.oauthMode).toBe(true); + expect((await getContextManager().getContext()).host).toBe('other.example.com'); }); - it('should include initial context for reset', () => { - const manager = ContextManager.getInstance(); - const context = manager.getContext(); + // A configured instance that is not a URL is shown as configured rather than failing. + it('shows an instance that is not a URL as it is', async () => { + const context = await runWithCaller({ ...alice1, instanceUrl: 'gitlab-internal' }, () => + getContextManager().getContext(), + ); - expect(context.initialContext).toBeDefined(); - expect(context.initialContext?.host).toBe('gitlab.example.com'); + expect(context.host).toBe('gitlab-internal'); }); - it('should handle invalid URL in getHost by returning raw value', () => { - // Test the catch branch in getHost() when URL parsing fails - mockGitLabBaseUrl = 'not-a-valid-url'; - ContextManager.resetInstance(); + it('reports the account settings of the caller', async () => { + await service.updateAccount(alice1, { preset: 'readonly' }); - const manager = ContextManager.getInstance(); - const context = manager.getContext(); + const context = await runWithCaller(alice2, () => getContextManager().getContext()); - // When URL is invalid, getHost returns the raw value - expect(context.host).toBe('not-a-valid-url'); + expect(context).toMatchObject({ presetName: 'readonly', readOnly: true }); }); - }); - describe('listPresets', () => { - it('should return available presets', async () => { - const manager = ContextManager.getInstance(); - const presets = await manager.listPresets(); + // The settings panel shows these: groups the preset turned off count, not only the + // account's own choices. + it('reports the tool groups off for the session, by the account and by its preset', async () => { + await service.updateAccount(alice1, { preset: 'no-wiki', disabledToolGroups: ['mrs'] }); + + const context = await runWithCaller(alice1, () => getContextManager().getContext()); - expect(presets).toHaveLength(2); - expect(presets[0].name).toBe('readonly'); - expect(presets[1].name).toBe('developer'); + expect(context.disabledToolGroups).toEqual(['mrs', 'wiki']); }); }); - describe('listProfiles', () => { - it('should throw error in non-OAuth mode', async () => { - process.env.OAUTH_ENABLED = 'false'; - ContextManager.resetInstance(); + describe('listPresets and listProfiles', () => { + it('lists the presets', async () => { + const presets = await getContextManager().listPresets(); - const manager = ContextManager.getInstance(); + expect(presets.map((p) => p.name)).toEqual(['readonly', 'developer']); + }); - await expect(manager.listProfiles()).rejects.toThrow('only available in OAuth mode'); + it('refuses to list profiles outside OAuth mode', async () => { + await expect(getContextManager().listProfiles()).rejects.toThrow( + 'only available in OAuth mode', + ); }); - it('should return profiles in OAuth mode', async () => { + it('lists only full profiles in OAuth mode', async () => { process.env.OAUTH_ENABLED = 'true'; - ContextManager.resetInstance(); - const manager = ContextManager.getInstance(); - const profiles = await manager.listProfiles(); + const profiles = await getContextManager().listProfiles(); - // Our mock returns presets, which are filtered out for profiles - // So this returns empty (profiles have isPreset: false) - expect(Array.isArray(profiles)).toBe(true); + expect(profiles.map((p) => p.name)).toEqual(['production']); }); }); describe('switchPreset', () => { - it('should switch to a valid preset', async () => { - const manager = ContextManager.getInstance(); - const result = await manager.switchPreset('readonly'); + it('switches the current session and reports the previous preset', async () => { + const manager = getContextManager(); - expect(result.success).toBe(true); - expect(result.current).toBe('readonly'); - expect(result.message).toContain('Switched to preset'); - }); + await runWithCaller(alice1, () => manager.switchPreset('developer')); + const result = await runWithCaller(alice1, () => manager.switchPreset('readonly')); - it('should return previous preset name on switch', async () => { - const manager = ContextManager.getInstance(); - - await manager.switchPreset('readonly'); - const result = await manager.switchPreset('developer'); - - expect(result.previous).toBe('readonly'); - expect(result.current).toBe('developer'); + expect(result).toEqual({ + success: true, + previous: 'developer', + current: 'readonly', + message: "Switched this session to preset 'readonly'", + }); }); - it('should update getCurrentPresetName after switch', async () => { - const manager = ContextManager.getInstance(); + // The defect this replaces: a preset switched in one chat applied to every user. + it('changes only the calling session', async () => { + const manager = getContextManager(); - expect(manager.getCurrentPresetName()).toBeNull(); + await runWithCaller(alice1, () => manager.switchPreset('readonly')); - await manager.switchPreset('readonly'); - - expect(manager.getCurrentPresetName()).toBe('readonly'); + expect((await runWithCaller(alice1, () => manager.getContext())).readOnly).toBe(true); + expect((await runWithCaller(alice2, () => manager.getContext())).readOnly).toBe(false); + expect((await runWithCaller(bob, () => manager.getContext())).presetName).toBeUndefined(); }); - it('should send tools/list_changed notification after successful switch', async () => { - // Reset mock to track calls in this test - mockSendToolsListChangedNotification.mockClear(); - - const manager = ContextManager.getInstance(); - await manager.switchPreset('readonly'); + it('notifies only the calling session', async () => { + await runWithCaller(alice1, () => getContextManager().switchPreset('readonly')); - // Verify notification was sent - expect(mockSendToolsListChangedNotification).toHaveBeenCalledTimes(1); + expect(mockNotified).toEqual([['s1']]); + expect(mockSendToolsListChangedNotification).not.toHaveBeenCalled(); }); - it('should send notification on each preset switch', async () => { - mockSendToolsListChangedNotification.mockClear(); + it('keeps the account default for other sessions', async () => { + await service.updateAccount(alice1, { preset: 'developer' }); - const manager = ContextManager.getInstance(); + await runWithCaller(alice1, () => getContextManager().switchPreset('readonly')); - await manager.switchPreset('readonly'); - await manager.switchPreset('developer'); - - // Should be called twice - once for each switch - expect(mockSendToolsListChangedNotification).toHaveBeenCalledTimes(2); + expect(store.records.get('alice')?.settings).toEqual({ preset: 'developer' }); }); - it('should throw error when preset loading fails', async () => { - const manager = ContextManager.getInstance(); + it('refuses an unknown preset and changes nothing', async () => { + await expect( + runWithCaller(alice1, () => getContextManager().switchPreset('invalid-preset')), + ).rejects.toThrow("Failed to switch to preset 'invalid-preset'"); - await expect(manager.switchPreset('invalid-preset')).rejects.toThrow( - "Failed to switch to preset 'invalid-preset'", - ); + expect( + (await runWithCaller(alice1, () => getContextManager().getContext())).presetName, + ).toBeUndefined(); }); - it('should clear scope when switching to preset without scope', async () => { - const manager = ContextManager.getInstance(); + it('drops the preset scope when switching to a preset without one', async () => { + const manager = getContextManager(); + await runWithCaller(alice1, () => manager.switchPreset('multi-groups')); + await runWithCaller(alice1, () => manager.switchPreset('readonly')); - // First switch to preset with scope - await manager.switchPreset('multi-groups'); - expect(manager.hasScope()).toBe(true); - - // Then switch to preset without scope - should clear - await manager.switchPreset('readonly'); - expect(manager.hasScope()).toBe(false); - expect(manager.getScopeEnforcer()).toBeNull(); + expect((await runWithCaller(alice1, () => manager.getContext())).scope).toBeUndefined(); }); }); - describe('switchProfile', () => { - it('should throw error in non-OAuth mode', async () => { - process.env.OAUTH_ENABLED = 'false'; - ContextManager.resetInstance(); - - const manager = ContextManager.getInstance(); - - await expect(manager.switchProfile('production')).rejects.toThrow( + describe('switchProfile and getCurrentProfileUrl', () => { + it('refuses to switch profiles outside OAuth mode', async () => { + await expect(getContextManager().switchProfile('production')).rejects.toThrow( 'only available in OAuth mode', ); }); - it('should switch profile in OAuth mode', async () => { + it('switches the session profile in OAuth mode and resolves its URL', async () => { process.env.OAUTH_ENABLED = 'true'; - ContextManager.resetInstance(); - - const manager = ContextManager.getInstance(); - const result = await manager.switchProfile('production'); + const manager = getContextManager(); - expect(result.success).toBe(true); - expect(result.current).toBe('production'); - }); - - it('should throw error when profile loading fails in OAuth mode', async () => { - process.env.OAUTH_ENABLED = 'true'; - ContextManager.resetInstance(); + const result = await runWithCaller(alice1, () => manager.switchProfile('production')); - const manager = ContextManager.getInstance(); - - await expect(manager.switchProfile('invalid-profile')).rejects.toThrow( - "Failed to switch to profile 'invalid-profile'", + expect(result).toMatchObject({ success: true, current: 'production' }); + await expect(runWithCaller(alice1, () => manager.getCurrentProfileUrl())).resolves.toBe( + 'https://gitlab.example.com', ); - }); - }); - - describe('getCurrentProfileUrl', () => { - it('should return null when no profile is active', async () => { - ContextManager.resetInstance(); - const manager = ContextManager.getInstance(); - - await expect(manager.getCurrentProfileUrl()).resolves.toBeNull(); + await expect(runWithCaller(alice2, () => manager.getCurrentProfileUrl())).resolves.toBeNull(); }); - it('should resolve URL from profile.host after switch_profile (#407)', async () => { + it('prefers the profile api_url over the host', async () => { process.env.OAUTH_ENABLED = 'true'; - ContextManager.resetInstance(); - - const manager = ContextManager.getInstance(); - await manager.switchProfile('production'); + const manager = getContextManager(); + await runWithCaller(alice1, () => manager.switchProfile('override-api-url')); - await expect(manager.getCurrentProfileUrl()).resolves.toBe('https://gitlab.example.com'); + await expect(runWithCaller(alice1, () => manager.getCurrentProfileUrl())).resolves.toBe( + 'https://api.example.com', + ); }); - it('should prefer profile.api_url over derived host URL (#407)', async () => { - // Regression guard: when a profile defines an explicit api_url override, - // getCurrentProfileUrl() must return it instead of the https://{host} fallback. - // This mirrors profiles/applicator.ts behavior and is required for multi-instance - // setups where api_url points at a separate API host. + it('refuses an unknown profile', async () => { process.env.OAUTH_ENABLED = 'true'; - ContextManager.resetInstance(); - const manager = ContextManager.getInstance(); - await manager.switchProfile('override-api-url'); - - await expect(manager.getCurrentProfileUrl()).resolves.toBe('https://api.example.com'); + await expect(getContextManager().switchProfile('invalid-profile')).rejects.toThrow( + "Failed to switch to profile 'invalid-profile'", + ); }); }); describe('setScope', () => { - it('should set scope for a group', async () => { - mockDetectNamespaceType.mockResolvedValue('group'); - - const manager = ContextManager.getInstance(); - const result = await manager.setScope('my-group'); - - expect(result.success).toBe(true); - expect(result.scope.type).toBe('group'); - expect(result.scope.path).toBe('my-group'); - expect(result.scope.includeSubgroups).toBe(true); - expect(result.scope.detected).toBe(true); - }); - - it('should set scope for a project', async () => { - mockDetectNamespaceType.mockResolvedValue('project'); - - const manager = ContextManager.getInstance(); - const result = await manager.setScope('group/project'); - - expect(result.success).toBe(true); - expect(result.scope.type).toBe('project'); - expect(result.scope.path).toBe('group/project'); - expect(result.scope.includeSubgroups).toBe(false); // Projects don't have subgroups - }); - - it('should respect includeSubgroups parameter for groups', async () => { - mockDetectNamespaceType.mockResolvedValue('group'); - - const manager = ContextManager.getInstance(); - const result = await manager.setScope('my-group', false); - - expect(result.scope.includeSubgroups).toBe(false); - }); - - it('should update hasScope after setScope', async () => { - mockDetectNamespaceType.mockResolvedValue('group'); - - const manager = ContextManager.getInstance(); - - expect(manager.hasScope()).toBe(false); - - await manager.setScope('my-group'); - - expect(manager.hasScope()).toBe(true); - }); - - it('should update context with scope after setScope', async () => { - mockDetectNamespaceType.mockResolvedValue('group'); + it.each([ + ['group', 'my-group', true, { type: 'group', path: 'my-group', includeSubgroups: true }], + ['group', 'my-group', false, { type: 'group', path: 'my-group', includeSubgroups: false }], + ['project', 'g/p', true, { type: 'project', path: 'g/p', includeSubgroups: false }], + ] as const)( + 'sets a detected %s scope for the session', + async (type, namespace, includeSubgroups, expected) => { + mockDetectNamespaceType.mockResolvedValue(type); + const manager = getContextManager(); + + const result = await runWithCaller(alice1, () => + manager.setScope(namespace, includeSubgroups), + ); + + expect(result.scope).toEqual({ ...expected, detected: true }); + expect((await runWithCaller(alice1, () => manager.getContext())).scope).toEqual({ + ...expected, + detected: true, + }); + expect((await runWithCaller(alice2, () => manager.getContext())).scope).toBeUndefined(); + }, + ); - const manager = ContextManager.getInstance(); - await manager.setScope('my-group'); + // A typo, or GitLab not answering, must not become a guessed scope for the chat. + it('refuses a namespace GitLab does not confirm and keeps the session scope', async () => { + mockDetectNamespaceType.mockResolvedValue(null); + const manager = getContextManager(); - const context = manager.getContext(); - expect(context.scope).toBeDefined(); - expect(context.scope?.type).toBe('group'); - expect(context.scope?.path).toBe('my-group'); + await expect(runWithCaller(alice1, () => manager.setScope('team/typo'))).rejects.toThrow( + "Failed to set scope for 'team/typo': No project or group 'team/typo' was found on GitLab, or GitLab could not be reached", + ); + expect((await runWithCaller(alice1, () => manager.getContext())).scope).toBeUndefined(); }); }); describe('reset', () => { - it('should reset context to initial state', async () => { + it('drops the session overrides and keeps the account settings', async () => { mockDetectNamespaceType.mockResolvedValue('group'); + await service.updateAccount(alice1, { preset: 'developer' }); + const manager = getContextManager(); + await runWithCaller(alice1, () => manager.switchPreset('readonly')); + await runWithCaller(alice1, () => manager.setScope('my-group')); - const manager = ContextManager.getInstance(); - - // Make some changes - await manager.switchPreset('readonly'); - await manager.setScope('my-group'); - - expect(manager.getCurrentPresetName()).toBe('readonly'); - expect(manager.hasScope()).toBe(true); - - // Reset - const result = manager.reset(); + const result = await runWithCaller(alice1, () => manager.reset()); expect(result.success).toBe(true); - expect(result.message).toContain('reset to initial state'); - expect(manager.getCurrentPresetName()).toBeNull(); - expect(manager.hasScope()).toBe(false); - }); - - it('should return restored context', async () => { - const manager = ContextManager.getInstance(); - const result = manager.reset(); - - expect(result.context).toBeDefined(); - expect(result.context.host).toBe('gitlab.example.com'); - }); - }); - - describe('getScopeEnforcer', () => { - it('should return null when no scope is set', () => { - const manager = ContextManager.getInstance(); - - expect(manager.getScopeEnforcer()).toBeNull(); - }); - - it('should return ScopeEnforcer after scope is set', async () => { - mockDetectNamespaceType.mockResolvedValue('group'); - - const manager = ContextManager.getInstance(); - await manager.setScope('my-group'); - - const enforcer = manager.getScopeEnforcer(); - expect(enforcer).not.toBeNull(); - expect(enforcer?.getScopeDescription()).toContain('my-group'); - }); - }); - - describe('getCurrentPreset', () => { - it('should return null when no preset is set', () => { - const manager = ContextManager.getInstance(); - expect(manager.getCurrentPreset()).toBeNull(); - }); - - it('should return preset after switchPreset', async () => { - const manager = ContextManager.getInstance(); - await manager.switchPreset('readonly'); - - const preset = manager.getCurrentPreset(); - expect(preset).not.toBeNull(); + expect(result.context).toMatchObject({ presetName: 'developer', scope: undefined }); }); }); - describe('error handling', () => { - it('should handle setScope errors', async () => { - mockDetectNamespaceType.mockRejectedValue(new Error('API error')); - - const manager = ContextManager.getInstance(); - - await expect(manager.setScope('invalid-namespace')).rejects.toThrow('Failed to set scope'); - }); - - it('should handle switchPreset with invalid preset', async () => { - // This test verifies that ContextManager is created successfully - // The actual error handling for invalid presets is tested via integration tests - // since mocking ProfileLoader after module import requires complex setup - const manager = ContextManager.getInstance(); - expect(manager).toBeDefined(); - }); - }); - - describe('scopeConfigToRuntimeScope edge cases', () => { - it('should handle scope with namespace field', async () => { - mockDetectNamespaceType.mockResolvedValue('group'); - - const manager = ContextManager.getInstance(); - await manager.setScope('my-namespace'); - - const context = manager.getContext(); - expect(context.scope).toBeDefined(); - expect(context.scope?.type).toBe('group'); - }); - - it('should not have additionalPaths for single project scope', async () => { - mockDetectNamespaceType.mockResolvedValue('project'); - - const manager = ContextManager.getInstance(); - await manager.setScope('group/project'); - - const context = manager.getContext(); - expect(context.scope).toBeDefined(); - expect(context.scope?.path).toBe('group/project'); - expect(context.scope?.additionalPaths).toBeUndefined(); - }); - - it('should not have additionalPaths for single group scope', async () => { - mockDetectNamespaceType.mockResolvedValue('group'); - - const manager = ContextManager.getInstance(); - await manager.setScope('my-group'); - - const context = manager.getContext(); - expect(context.scope).toBeDefined(); - expect(context.scope?.path).toBe('my-group'); - expect(context.scope?.additionalPaths).toBeUndefined(); - }); - - it('should include additionalPaths for multiple projects from preset', async () => { - const manager = ContextManager.getInstance(); - - // Switch to preset with multiple projects - await manager.switchPreset('multi-projects'); - - const context = manager.getContext(); - expect(context.scope).toBeDefined(); - expect(context.scope?.type).toBe('project'); - expect(context.scope?.path).toBe('team/project1'); - expect(context.scope?.additionalPaths).toEqual(['team/project2', 'team/project3']); - }); - - it('should include additionalPaths for multiple groups from preset', async () => { - const manager = ContextManager.getInstance(); - - // Switch to preset with multiple groups - await manager.switchPreset('multi-groups'); - - const context = manager.getContext(); - expect(context.scope).toBeDefined(); - expect(context.scope?.type).toBe('group'); - expect(context.scope?.path).toBe('team-a'); - expect(context.scope?.additionalPaths).toEqual(['team-b']); - expect(context.scope?.includeSubgroups).toBe(true); - }); - - it('should handle namespace scope from preset', async () => { - const manager = ContextManager.getInstance(); - - // Switch to preset with namespace scope - await manager.switchPreset('namespace-scope'); - - const context = manager.getContext(); - expect(context.scope).toBeDefined(); - expect(context.scope?.type).toBe('group'); - expect(context.scope?.path).toBe('my-namespace'); - expect(context.scope?.includeSubgroups).toBe(true); - }); - - it('should not have additionalPaths for single project in array', async () => { - const manager = ContextManager.getInstance(); - - // Switch to preset with single project in projects array - await manager.switchPreset('single-project-scope'); - - const context = manager.getContext(); - expect(context.scope).toBeDefined(); - expect(context.scope?.type).toBe('project'); - expect(context.scope?.path).toBe('only-project'); - expect(context.scope?.additionalPaths).toBeUndefined(); - }); - - it('should not have additionalPaths for single group in array', async () => { - const manager = ContextManager.getInstance(); + describe('clearScope', () => { + // Only the scope goes: the chat's read-only mode and preset stay, and the account's + // default scope does not come back for this chat. + it("drops only the chat's scope, also the account default for this chat", async () => { + await service.updateAccount(alice1, { + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + await service.updateSession(alice1, { readOnly: true, preset: 'developer' }); + const manager = getContextManager(); - // Switch to preset with single group in groups array - await manager.switchPreset('single-group-scope'); + const result = await runWithCaller(alice1, () => manager.clearScope()); - const context = manager.getContext(); - expect(context.scope).toBeDefined(); - expect(context.scope?.type).toBe('group'); - expect(context.scope?.path).toBe('only-group'); - expect(context.scope?.additionalPaths).toBeUndefined(); + expect(result).toMatchObject({ + success: true, + message: 'This session now works everywhere the account has access', + context: { readOnly: true, presetName: 'developer', scope: undefined }, + }); + expect((await service.resolve(alice2)).policy.scope).toEqual({ + group: 'team', + includeSubgroups: true, + }); }); }); - describe('listPresets edge cases', () => { - it('should add current preset to list if not already present', async () => { - const manager = ContextManager.getInstance(); - - // First switch to a preset (our mock returns 'readonly' and 'developer') - await manager.switchPreset('custom-preset'); - - // The preset should appear in list even if not in ProfileLoader results - const presets = await manager.listPresets(); - - // Should have presets from ProfileLoader mock - expect(presets.length).toBeGreaterThan(0); + describe('scopes of presets', () => { + it.each([ + [ + 'multi-projects', + { + type: 'project', + path: 'team/project1', + additionalPaths: ['team/project2', 'team/project3'], + }, + ], + [ + 'multi-groups', + { type: 'group', path: 'team-a', additionalPaths: ['team-b'], includeSubgroups: true }, + ], + ['namespace-scope', { type: 'group', path: 'my-namespace', includeSubgroups: true }], + [ + 'single-project-scope', + { type: 'project', path: 'only-project', additionalPaths: undefined }, + ], + ['single-group-scope', { type: 'group', path: 'only-group', additionalPaths: undefined }], + ])('shows the scope of preset %s', async (preset, expected) => { + const manager = getContextManager(); + await runWithCaller(alice1, () => manager.switchPreset(preset)); + + const context = await runWithCaller(alice1, () => manager.getContext()); + + expect(context.scope).toMatchObject({ ...expected, detected: false }); + }); + + it('reports a preset scope without any target', async () => { + const manager = getContextManager(); + await runWithCaller(alice1, () => manager.switchPreset('empty-scope')); + + await expect(runWithCaller(alice1, () => manager.getContext())).rejects.toThrow( + 'Invalid scope configuration', + ); }); }); describe('switchInstance', () => { - /** - * Tests switchInstance() method (lines 431-486) - */ - it('should throw error in OAuth mode', async () => { + it('refuses in OAuth mode', async () => { process.env.OAUTH_ENABLED = 'true'; - ContextManager.resetInstance(); - const manager = ContextManager.getInstance(); - - await expect(manager.switchInstance('https://other-gitlab.com')).rejects.toThrow( + await expect(getContextManager().switchInstance('https://other.example.com')).rejects.toThrow( 'Cannot switch instances in OAuth mode', ); }); - it('should throw error when instance is not configured', async () => { - process.env.OAUTH_ENABLED = 'false'; - ContextManager.resetInstance(); - - const manager = ContextManager.getInstance(); - - await expect(manager.switchInstance('https://unknown-gitlab.com')).rejects.toThrow( - 'Instance not configured', - ); - }); - - it('should successfully switch to a configured instance in static token mode', async () => { - process.env.OAUTH_ENABLED = 'false'; - ContextManager.resetInstance(); - - // Set up a configured instance via InstanceRegistry - const { InstanceRegistry } = await import('../../../../src/services/InstanceRegistry'); - const registry = InstanceRegistry.getInstance(); - registry.register({ - url: 'https://new-gitlab.example.com', - label: 'New GitLab', - insecureSkipVerify: false, - }); - - const manager = ContextManager.getInstance(); - - // With mocked ConnectionManager.reinitialize, this should succeed - // Verifies: validation passes (not "Instance not configured" or "OAuth mode" error) - await expect(manager.switchInstance('https://new-gitlab.example.com')).resolves.not.toThrow(); - }); - - it('should clear scope when switching instances', async () => { - process.env.OAUTH_ENABLED = 'false'; - mockDetectNamespaceType.mockResolvedValue('group'); - ContextManager.resetInstance(); - - // Set up a configured instance - const { InstanceRegistry } = await import('../../../../src/services/InstanceRegistry'); - const registry = InstanceRegistry.getInstance(); - registry.register({ - url: 'https://new-gitlab.example.com', - label: 'New GitLab', - insecureSkipVerify: false, - }); - - const manager = ContextManager.getInstance(); - - // Set a scope first - await manager.setScope('my-group'); - expect(manager.hasScope()).toBe(true); - - // Try to switch instance - it will fail at reinitialize but should clear scope - try { - await manager.switchInstance('https://new-gitlab.example.com'); - } catch { - // Expected to fail - } - - // Scope should be cleared even if switch fails later - // (scope is cleared before reinitialize in the try block) - expect(manager.hasScope()).toBe(false); - }); - - it('should send tools/list_changed notification on successful switch', async () => { - // This is tested indirectly - when switch succeeds, notification is sent - // Since we can't fully mock ConnectionManager.reinitialize easily, - // we verify the notification mock is set up - expect(mockSendToolsListChangedNotification).toBeDefined(); + it('refuses an instance that is not configured', async () => { + await expect( + getContextManager().switchInstance('https://unknown-gitlab.com'), + ).rejects.toThrow('Instance not configured'); }); - it('should successfully complete switch when all dependencies succeed', async () => { - /** - * Tests the SUCCESS path of switchInstance (lines 465-477): - * - Clears currentScope and currentScopeEnforcer - * - Logs "Switched GitLab instance" - * - Calls sendToolsListChangedNotification - * - Returns success result with previous/current URLs - */ - process.env.OAUTH_ENABLED = 'false'; - mockClearNamespaceTierCache.mockClear(); - mockReinitialize.mockClear(); - mockReinitialize.mockResolvedValue(undefined); // Make reinitialize succeed - mockSendToolsListChangedNotification.mockClear(); - ContextManager.resetInstance(); - - // Set up a configured instance via InstanceRegistry + it('switches, notifies every session and clears the session scope', async () => { const { InstanceRegistry } = await import('../../../../src/services/InstanceRegistry'); - const registry = InstanceRegistry.getInstance(); - registry.register({ + InstanceRegistry.getInstance().register({ url: 'https://success-gitlab.example.com', label: 'Success GitLab', insecureSkipVerify: false, }); - - const manager = ContextManager.getInstance(); - - // Set a scope that should be cleared on instance switch + mockReinitialize.mockResolvedValue(undefined); mockDetectNamespaceType.mockResolvedValue('group'); - await manager.setScope('my-group'); - expect(manager.hasScope()).toBe(true); + const manager = getContextManager(); + await runWithCaller(alice1, () => manager.setScope('my-group')); - // Now switch instance - should succeed with our mocks - const result = await manager.switchInstance('https://success-gitlab.example.com'); + const result = await runWithCaller(alice1, () => + manager.switchInstance('https://success-gitlab.example.com'), + ); - // Verify success result - expect(result.success).toBe(true); - expect(result.previous).toBe('https://gitlab.example.com'); - expect(result.current).toBe('https://success-gitlab.example.com'); + expect(result).toMatchObject({ + success: true, + previous: 'https://gitlab.example.com', + current: 'https://success-gitlab.example.com', + }); expect(result.message).toContain('Success GitLab'); - - // Verify dependencies were called expect(mockClearNamespaceTierCache).toHaveBeenCalled(); expect(mockReinitialize).toHaveBeenCalledWith('https://success-gitlab.example.com'); expect(mockSendToolsListChangedNotification).toHaveBeenCalled(); - - // Verify scope was cleared (lines 465-466) - expect(manager.hasScope()).toBe(false); - expect(manager.getScopeEnforcer()).toBeNull(); + expect((await runWithCaller(alice1, () => manager.getContext())).scope).toBeUndefined(); }); - }); - - describe('reset edge cases', () => { - /** - * Tests reset error when initialContext is null (line 367) - * This is a defensive check that should rarely happen in practice. - */ - it('should handle reset when initialContext is artificially cleared', () => { - const manager = ContextManager.getInstance(); - // Force clear initial context (defensive check in reset()) - (manager as any).initialContext = null; + // A static token's account is keyed by its instance, so the switch changes the key of + // every chat on this server: their read-only mode and presets move with them, their + // scopes (projects of the previous instance) do not. + it('keeps the chat overrides of every session across the switch, without the scope', async () => { + const { InstanceRegistry } = await import('../../../../src/services/InstanceRegistry'); + InstanceRegistry.getInstance().register({ + url: 'https://next-gitlab.example.com', + label: 'Next GitLab', + insecureSkipVerify: false, + }); + mockReinitialize.mockResolvedValue(undefined); + mockDetectNamespaceType.mockResolvedValue('group'); + const manager = getContextManager(); + const before = (session: string) => resolveCaller(session, 'https://gitlab.example.com'); + const after = (session: string) => resolveCaller(session, 'https://next-gitlab.example.com'); + await service.updateSession(before('chat-1'), { readOnly: true }); + await runWithCaller(before('chat-1'), () => manager.setScope('my-group')); + await service.updateSession(before('chat-2'), { preset: 'readonly' }); + + await runWithCaller(before('chat-1'), () => + manager.switchInstance('https://next-gitlab.example.com'), + ); - expect(() => manager.reset()).toThrow('No initial context captured - cannot reset'); + expect((await service.resolve(after('chat-1'))).session).toEqual({ readOnly: true }); + expect((await service.resolve(after('chat-2'))).session).toEqual({ preset: 'readonly' }); + expect((await service.resolve(before('chat-1'))).session).toEqual({}); }); - }); - - describe('scopeConfigToRuntimeScope invalid scope', () => { - /** - * Tests invalid scope error (lines 162-163) - * This happens when ScopeConfig has no usable scope fields. - * scopeConfigToRuntimeScope is called in getContext(), not in switchPreset() - */ - it('should throw error for empty scope config when getting context', async () => { - // Add preset with empty scope to mock - mockPresets['empty-scope'] = { - description: 'Empty scope preset', - read_only: false, - scope: { - // No project, group, namespace, projects, or groups defined - includeSubgroups: true, - }, - }; - - const manager = ContextManager.getInstance(); - // switchPreset succeeds (just stores the config) - await manager.switchPreset('empty-scope'); + it('reports a failed reconnection', async () => { + const { InstanceRegistry } = await import('../../../../src/services/InstanceRegistry'); + InstanceRegistry.getInstance().register({ + url: 'https://broken-gitlab.example.com', + label: 'Broken', + insecureSkipVerify: false, + }); + mockReinitialize.mockRejectedValue(new Error('unreachable')); - // getContext() calls scopeConfigToRuntimeScope which throws for invalid scope - expect(() => manager.getContext()).toThrow('Invalid scope configuration'); + await expect( + getContextManager().switchInstance('https://broken-gitlab.example.com'), + ).rejects.toThrow("Failed to switch to instance 'https://broken-gitlab.example.com'"); }); }); }); diff --git a/packages/gitlab-mcp/tests/unit/entities/context/handlers.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/handlers.test.ts index 1c5f0c16..474864f0 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/handlers.test.ts @@ -10,6 +10,7 @@ import { ContextManager } from '../../../../src/entities/context/context-manager // Mock dependencies jest.mock('../../../../src/utils/namespace', () => ({ detectNamespaceType: jest.fn().mockResolvedValue('group'), + findNamespaceType: jest.fn().mockResolvedValue('group'), })); jest.mock('../../../../src/profiles/loader', () => ({ @@ -34,6 +35,15 @@ jest.mock('../../../../src/profiles/loader', () => ({ })), })); +// Account settings go to a file of this test run, never to the user's configuration. +jest.mock('../../../../src/configuration/settings-store', () => ({ + ...jest.requireActual('../../../../src/configuration/settings-store'), + localSettingsPath: () => + jest + .requireActual('path') + .join(jest.requireActual('os').tmpdir(), `context-handlers-test-${process.pid}.json`), +})); + describe('handleManageContext', () => { const originalEnv = process.env; @@ -149,6 +159,24 @@ describe('handleManageContext', () => { }); }); + describe('action: clear_scope', () => { + it('drops the scope and keeps the preset of the session', async () => { + await handleManageContext({ action: 'switch_preset', preset: 'readonly' }); + await handleManageContext({ + action: 'set_scope', + namespace: 'my-group', + includeSubgroups: true, + }); + + const result = await handleManageContext({ action: 'clear_scope' }); + + expect(result).toMatchObject({ + success: true, + context: { presetName: 'readonly', scope: undefined }, + }); + }); + }); + describe('action: reset', () => { it('should reset context to initial state', async () => { // First make some changes diff --git a/packages/gitlab-mcp/tests/unit/entities/context/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/registry.test.ts index 7371525c..2f3a4ea7 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/registry.test.ts @@ -4,6 +4,15 @@ * Tests the context tool registry exports and functions. */ +// Account settings go to a file of this test run, never to the user's configuration. +jest.mock('../../../../src/configuration/settings-store', () => ({ + ...jest.requireActual('../../../../src/configuration/settings-store'), + localSettingsPath: () => + jest + .requireActual('path') + .join(jest.requireActual('os').tmpdir(), `context-registry-test-${process.pid}.json`), +})); + import { contextToolRegistry, getContextReadOnlyToolNames, diff --git a/packages/gitlab-mcp/tests/unit/entities/context/scope-search.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/scope-search.test.ts new file mode 100644 index 00000000..9d95e16e --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/entities/context/scope-search.test.ts @@ -0,0 +1,67 @@ +/** + * Scope search for the settings panel: it asks GitLab for projects the account is a + * member of and groups it can see, and returns only their type, path and name. + */ + +import { findScopeTargets } from '../../../../src/entities/context/scope-search'; +import { gitlab } from '../../../../src/utils/gitlab-api'; + +jest.mock('../../../../src/utils/gitlab-api', () => ({ + gitlab: { get: jest.fn() }, +})); + +const mockGet = gitlab.get as jest.Mock; + +describe('findScopeTargets', () => { + beforeEach(() => { + mockGet.mockReset(); + }); + + it('returns groups first, then member projects, with nothing but type, path and name', async () => { + mockGet.mockImplementation(async (path: string) => + path === 'projects' + ? [ + { + id: 7, + path_with_namespace: 'team/app', + name_with_namespace: 'Team / App', + visibility: 'private', + }, + ] + : [{ id: 3, full_path: 'team', full_name: 'Team', description: 'secret plans' }], + ); + + const result = await findScopeTargets(' team '); + + expect(result).toEqual({ + targets: [ + { type: 'group', path: 'team', name: 'Team' }, + { type: 'project', path: 'team/app', name: 'Team / App' }, + ], + }); + expect(mockGet).toHaveBeenCalledWith('projects', { + query: { + search: 'team', + membership: 'true', + simple: 'true', + per_page: '10', + order_by: 'last_activity_at', + }, + }); + expect(mockGet).toHaveBeenCalledWith('groups', { query: { search: 'team', per_page: '10' } }); + }); + + it.each(['a', ' b ', 'x'.repeat(101)])( + 'rejects the query %p without asking GitLab', + async (q) => { + await expect(findScopeTargets(q)).rejects.toThrow('Search for 2 to 100 characters'); + expect(mockGet).not.toHaveBeenCalled(); + }, + ); + + it('passes a GitLab failure on to the caller', async () => { + mockGet.mockRejectedValue(new Error('GitLab API error: 401 Unauthorized')); + + await expect(findScopeTargets('team')).rejects.toThrow('401 Unauthorized'); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/entities/context/settings-panel.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/settings-panel.test.ts new file mode 100644 index 00000000..62a527ab --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/entities/context/settings-panel.test.ts @@ -0,0 +1,588 @@ +/** + * The settings panel, run in a DOM against a simulated MCP Apps host. The host answers + * ui/initialize and proxies tools/call to fake tools, as a real host proxies them to the + * server. The panel must show GitLab strings as text only, report success only from the + * state the server returns, and say what to do when sign-in expired or GitLab is down. + */ + +import { JSDOM } from 'jsdom'; +import { settingsPanelHtml } from '../../../../src/entities/context/settings-panel'; + +type ToolHandler = (args: Record) => unknown; + +interface PanelOptions { + tools?: Record; + hostCapabilities?: Record; + hostContext?: Record; +} + +interface Message { + jsonrpc: '2.0'; + id?: number; + method?: string; + params?: Record; + result?: unknown; +} + +const SETTINGS = { + schema: { + type: 'object', + properties: { + preset: { type: 'string', title: 'Working preset' }, + tools_wiki: { type: 'boolean', title: 'Wiki' }, + }, + }, + values: { preset: 'none', scope: '', scopeIncludeSubgroups: true, tools_wiki: true }, + layout: [], +}; + +const HEALTH = { + account: 'alice', + instance: 'https://gitlab.example.com', + gitlabVersion: '17.4.0', + tier: 'premium', + authenticated: true, + readOnly: false, + preset: null, + scope: null, + availableTools: 42, + warnings: [], + recommendations: [], +}; + +function defaultTools(): Record { + let scope: unknown; + return { + get_settings: () => SETTINGS, + manage_context: (args) => { + if (args.action === 'set_scope') { + scope = { + type: 'group', + path: args.namespace, + includeSubgroups: args.includeSubgroups, + detected: true, + }; + return { action: 'set_scope', data: { success: true } }; + } + if (args.action === 'clear_scope') { + scope = undefined; + return { action: 'clear_scope', data: { success: true } }; + } + return { action: 'show', data: { readOnly: false, presetName: undefined, scope } }; + }, + check_connection: () => HEALTH, + find_scope_targets: () => ({ + targets: [ + { type: 'group', path: 'team', name: 'Team' }, + { type: 'project', path: 'team/app', name: 'Team / App' }, + ], + }), + update_settings: (args) => ({ values: { ...SETTINGS.values, ...(args.set as object) } }), + }; +} + +/** Closed after each test, which also stops the panel's pending request timers. */ +const openWindows: JSDOM[] = []; + +afterEach(() => { + for (const dom of openWindows.splice(0)) dom.window.close(); +}); + +/** Run the panel with a simulated host; returns the DOM and what the host received. */ +function startPanel(options: PanelOptions = {}) { + const tools = { ...defaultTools(), ...options.tools }; + const received: Message[] = []; + const toolCalls: Array<{ name: string; arguments: Record }> = []; + let panelWindow: JSDOM['window']; + + // Requests the host still owes an answer to; the panel is idle when there are none. + let unanswered = 0; + + const reply = (message: Message) => { + setTimeout(() => { + panelWindow.dispatchEvent( + new panelWindow.MessageEvent('message', { data: message, source: host as never }), + ); + if (message.method === undefined) unanswered -= 1; + }, 0); + }; + + const host = { + postMessage(message: Message) { + received.push(message); + if (message.id !== undefined && message.method !== undefined) unanswered += 1; + if (message.method === 'ui/initialize' && message.id !== undefined) { + reply({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2026-01-26', + hostInfo: { name: 'test-host', version: '1' }, + hostCapabilities: options.hostCapabilities ?? { serverTools: {} }, + hostContext: options.hostContext ?? {}, + }, + }); + } + if (message.method === 'tools/call' && message.id !== undefined) { + const params = message.params as { name: string; arguments: Record }; + toolCalls.push(params); + const id = message.id; + // A tool may answer later (a promise), as a real server does. + Promise.resolve() + .then(() => tools[params.name](params.arguments)) + .then( + (structuredContent) => ({ + content: [{ type: 'text', text: JSON.stringify(structuredContent) }], + structuredContent, + }), + (error: Error) => ({ isError: true, content: [{ type: 'text', text: error.message }] }), + ) + .then((result) => reply({ jsonrpc: '2.0', id, result })); + } + }, + }; + + const dom = new JSDOM(settingsPanelHtml(), { + runScripts: 'dangerously', + beforeParse(window) { + panelWindow = window; + Object.defineProperty(window, 'parent', { value: host, configurable: true }); + }, + }); + openWindows.push(dom); + const document = dom.window.document; + return { + dom, + document, + received, + toolCalls, + host, + byId: (id: string) => document.getElementById(id) as HTMLElement, + hostSends: reply, + /** Resolves once the panel started and every request it made has been answered. */ + idle: () => waitFor(() => received.length > 0 && unanswered === 0), + }; +} + +/** Polls instead of sleeping a fixed time, so a loaded machine cannot make it flaky. */ +async function waitFor(condition: () => boolean, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + let held = 0; + while (held < 2) { + if (Date.now() > deadline) throw new Error('Timed out waiting for the panel'); + await new Promise((resolve) => setTimeout(resolve, 5)); + held = condition() ? held + 1 : 0; + } +} + +async function typeSearch(panel: ReturnType, value: string): Promise { + const searches = () => panel.toolCalls.filter((c) => c.name === 'find_scope_targets').length; + const before = searches(); + const input = panel.byId('search') as HTMLInputElement; + input.value = value; + input.dispatchEvent(new panel.dom.window.Event('input')); + if (value.trim().length >= 2) await waitFor(() => searches() > before); + await panel.idle(); +} + +describe('settings panel', () => { + it('initializes with the host before calling any tool and shows the server state', async () => { + const panel = startPanel(); + await panel.idle(); + + expect(panel.received[0]).toMatchObject({ + method: 'ui/initialize', + params: { + protocolVersion: '2026-01-26', + appInfo: { name: 'gitlab-mcp-settings' }, + appCapabilities: {}, + }, + }); + const initializedAt = panel.received.findIndex( + (m) => m.method === 'ui/notifications/initialized', + ); + const firstCallAt = panel.received.findIndex((m) => m.method === 'tools/call'); + expect(initializedAt).toBeGreaterThan(0); + expect(firstCallAt).toBeGreaterThan(initializedAt); + expect(panel.byId('main').hidden).toBe(false); + expect(panel.byId('chat-scope').textContent).toBe('Everywhere you have access'); + expect(panel.byId('health-status').textContent).toBe('Connected'); + expect(panel.byId('health-tools').textContent).toBe('42'); + }); + + // A preset can turn groups off that the account left on: the preview shows what this + // chat actually has, not only the account's own choices. + it('shows the tool groups that are off in this chat, also by its preset', async () => { + const panel = startPanel({ + tools: { + manage_context: () => ({ + action: 'show', + data: { readOnly: false, presetName: 'ci', disabledToolGroups: ['wiki', 'runners'] }, + }), + }, + }); + await panel.idle(); + + expect(panel.byId('access-groups').textContent).toBe('Wiki, runners'); + }); + + it('says all groups are on when this chat has none off', async () => { + const panel = startPanel(); + await panel.idle(); + + expect(panel.byId('access-groups').textContent).toBe('All offered groups are on'); + }); + + it('applies the host theme and style variables', async () => { + const panel = startPanel({ + hostContext: { + theme: 'dark', + styles: { variables: { '--color-text-primary': 'rgb(1, 2, 3)', notAVariable: 'x' } }, + }, + }); + await panel.idle(); + + const root = panel.document.documentElement; + expect(root.getAttribute('data-theme')).toBe('dark'); + expect(root.style.getPropertyValue('--color-text-primary')).toBe('rgb(1, 2, 3)'); + expect(root.style.getPropertyValue('notAVariable')).toBe(''); + }); + + it('follows a theme change of the host', async () => { + const panel = startPanel({ hostContext: { theme: 'dark' } }); + await panel.idle(); + + panel.hostSends({ + jsonrpc: '2.0', + method: 'ui/notifications/host-context-changed', + params: { theme: 'light' }, + }); + await panel.idle(); + + expect(panel.document.documentElement.getAttribute('data-theme')).toBe('light'); + }); + + it('explains instead of failing when the host cannot call server tools', async () => { + const panel = startPanel({ hostCapabilities: {} }); + await panel.idle(); + + expect(panel.toolCalls).toHaveLength(0); + expect(panel.byId('main').hidden).toBe(true); + expect(panel.byId('notice').textContent).toContain('cannot reach the GitLab server'); + }); + + it('ignores messages that do not come from its host', async () => { + const panel = startPanel(); + await panel.idle(); + + panel.dom.window.dispatchEvent( + new panel.dom.window.MessageEvent('message', { + data: { + jsonrpc: '2.0', + method: 'ui/notifications/host-context-changed', + params: { theme: 'light' }, + }, + source: panel.dom.window as never, + }), + ); + await panel.idle(); + + expect(panel.document.documentElement.getAttribute('data-theme')).toBeNull(); + }); + + // GitLab names and server messages are untrusted: they must never become markup. + it('shows malicious names and messages as text only', async () => { + const markup = ''; + const panel = startPanel({ + tools: { + find_scope_targets: () => ({ targets: [{ type: 'group', path: 'evil', name: markup }] }), + check_connection: () => ({ ...HEALTH, account: markup, warnings: [markup] }), + }, + }); + await panel.idle(); + await typeSearch(panel, 'ev'); + + expect(panel.document.querySelector('img')).toBeNull(); + expect(panel.byId('results').textContent).toContain(markup); + expect(panel.byId('health-account').textContent).toBe(markup); + expect((panel.dom.window as unknown as { __pwned?: number }).__pwned).toBeUndefined(); + }); + + it('searches only from two characters on, after typing pauses', async () => { + const panel = startPanel(); + await panel.idle(); + + await typeSearch(panel, 't'); + expect(panel.toolCalls.filter((c) => c.name === 'find_scope_targets')).toHaveLength(0); + + await typeSearch(panel, 'te'); + expect(panel.toolCalls.filter((c) => c.name === 'find_scope_targets')).toEqual([ + { name: 'find_scope_targets', arguments: { query: 'te' } }, + ]); + const options = panel.byId('results').querySelectorAll('[role="option"]'); + expect(options).toHaveLength(2); + }); + + // An older search that fails after a newer one succeeded must not clear its results or + // show its error. + it('ignores a superseded search that fails late', async () => { + let failOlder: (error: Error) => void = () => {}; + const panel = startPanel({ + tools: { + find_scope_targets: (args) => + args.query === 'te' + ? new Promise((_resolve, reject) => { + failOlder = reject; + }) + : { targets: [{ type: 'group', path: 'team', name: 'Team' }] }, + }, + }); + await panel.idle(); + const input = panel.byId('search') as HTMLInputElement; + input.value = 'te'; + input.dispatchEvent(new panel.dom.window.Event('input')); + await waitFor(() => panel.toolCalls.some((c) => c.name === 'find_scope_targets')); + + // The older search is still open, so the panel is not idle: wait for the newer result. + input.value = 'tea'; + input.dispatchEvent(new panel.dom.window.Event('input')); + await waitFor(() => (panel.byId('results').textContent ?? '').includes('Team')); + failOlder(new Error('search failed')); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(panel.byId('results').textContent).toContain('Team'); + expect(panel.byId('notice').className).not.toBe('status-error'); + }); + + it('says so when nothing matches', async () => { + const panel = startPanel({ tools: { find_scope_targets: () => ({ targets: [] }) } }); + await panel.idle(); + + await typeSearch(panel, 'zz'); + + expect(panel.byId('results').textContent).toContain('No project or group matches'); + }); + + it('selects a result with the keyboard and offers subgroups for a group', async () => { + const panel = startPanel(); + await panel.idle(); + await typeSearch(panel, 'te'); + + const first = panel.byId('result-0'); + first.dispatchEvent(new panel.dom.window.KeyboardEvent('keydown', { key: 'Enter' })); + + expect(panel.byId('result-0').getAttribute('aria-selected')).toBe('true'); + expect(panel.byId('subgroups-row').hidden).toBe(false); + expect((panel.byId('use-chat') as HTMLButtonElement).disabled).toBe(false); + expect(panel.byId('preview').textContent).toBe('Selected: Group team and its subgroups'); + }); + + it('applies a scope to this chat and confirms it from the server state', async () => { + const panel = startPanel(); + await panel.idle(); + await typeSearch(panel, 'te'); + panel.byId('result-0').click(); + + panel.byId('use-chat').click(); + await panel.idle(); + + expect(panel.toolCalls).toContainEqual({ + name: 'manage_context', + arguments: { action: 'set_scope', namespace: 'team', includeSubgroups: true }, + }); + expect(panel.byId('notice').textContent).toBe( + 'This chat now works in Group team and its subgroups.', + ); + expect(panel.byId('chat-scope').textContent).toBe('Group team and its subgroups'); + }); + + // The request "succeeded" but the server holds something else: no false success. + it('reports a change the server did not keep', async () => { + const tools = defaultTools(); + const panel = startPanel({ + tools: { + manage_context: (args) => + args.action === 'set_scope' + ? { action: 'set_scope', data: { success: true } } + : tools.manage_context({ action: 'show' }), + }, + }); + await panel.idle(); + await typeSearch(panel, 'te'); + panel.byId('result-0').click(); + + panel.byId('use-chat').click(); + await panel.idle(); + + expect(panel.byId('notice').textContent).toBe( + 'The server did not keep this change. Check the settings and try again.', + ); + }); + + it('saves the default for new chats as a settings patch', async () => { + let saved: Record | undefined; + const panel = startPanel({ + tools: { + update_settings: (args) => { + saved = args.set as Record; + return { values: { ...SETTINGS.values, ...saved } }; + }, + get_settings: () => + saved ? { ...SETTINGS, values: { ...SETTINGS.values, ...saved } } : SETTINGS, + }, + }); + await panel.idle(); + await typeSearch(panel, 'te'); + panel.byId('result-1').click(); + + panel.byId('save-default').click(); + await panel.idle(); + + expect(saved).toEqual({ scope: 'team/app', scopeIncludeSubgroups: false }); + expect(panel.byId('notice').textContent).toBe('New chats will work in team/app.'); + }); + + // Working everywhere changes this chat's scope only: no reset of its preset or read-only + // mode, and no change of the default for new chats. + it('clears only the scope of this chat when working everywhere', async () => { + const panel = startPanel(); + await panel.idle(); + + panel.byId('work-everywhere').click(); + await panel.idle(); + + const writes = panel.toolCalls.filter( + (c) => + c.name === 'update_settings' || + (c.name === 'manage_context' && c.arguments.action !== 'show'), + ); + expect(writes).toEqual([{ name: 'manage_context', arguments: { action: 'clear_scope' } }]); + expect(panel.byId('notice').textContent).toBe('This chat works everywhere you have access.'); + }); + + it('cancels a selection without saving', async () => { + const panel = startPanel(); + await panel.idle(); + await typeSearch(panel, 'te'); + panel.byId('result-0').click(); + + panel.byId('cancel').click(); + + expect(panel.byId('preview').textContent).toBe('Choose a project or group to work in.'); + expect(panel.toolCalls.some((c) => c.name === 'update_settings')).toBe(false); + }); + + it('tells the user to reconnect when the sign-in expired', async () => { + const panel = startPanel({ + tools: { + manage_context: () => { + throw new Error('invalid_token: GitLab token refresh failed. Please re-authenticate.'); + }, + }, + }); + await panel.idle(); + + expect(panel.byId('notice').textContent).toBe( + 'Your GitLab sign-in expired or was revoked. Reconnect GitLab from the app’s connection settings, then open this panel again.', + ); + }); + + it('says GitLab is unreachable and recovers with Check again', async () => { + let down = true; + const panel = startPanel({ + tools: { + check_connection: () => { + if (down) throw new Error('CONNECTION_FAILED: instance unreachable'); + return HEALTH; + }, + }, + }); + await panel.idle(); + expect(panel.byId('health-status').textContent).toBe( + 'GitLab is not reachable right now. Your saved settings still apply; try again in a moment.', + ); + + down = false; + panel.byId('check-again').click(); + await panel.idle(); + + expect(panel.byId('health-status').textContent).toBe('Connected'); + }); + + it('lists warnings and recommendations as what to check', async () => { + const panel = startPanel({ + tools: { + check_connection: () => ({ + ...HEALTH, + authenticated: false, + warnings: ['Token expires today!'], + recommendations: ['Create a new token'], + }), + }, + }); + await panel.idle(); + + expect(panel.byId('health-status').textContent).toBe( + 'GitLab did not accept the current sign-in', + ); + expect(panel.byId('health-advice-block').hidden).toBe(false); + expect( + [...panel.byId('health-advice').querySelectorAll('li')].map((li) => li.textContent), + ).toEqual(['Token expires today!', 'Create a new token']); + }); + + it('answers a teardown request from the host', async () => { + const panel = startPanel(); + await panel.idle(); + + panel.hostSends({ jsonrpc: '2.0', id: 99, method: 'ui/resource-teardown', params: {} }); + await panel.idle(); + + expect(panel.received).toContainEqual({ jsonrpc: '2.0', id: 99, result: {} }); + }); + + it('reports its size to the host when it changes', async () => { + let observed: (() => void) | undefined; + const received: Message[] = []; + const dom = new JSDOM(settingsPanelHtml(), { + runScripts: 'dangerously', + beforeParse(window) { + Object.defineProperty(window, 'parent', { + value: { postMessage: (m: Message) => received.push(m) }, + configurable: true, + }); + (window as unknown as { ResizeObserver: unknown }).ResizeObserver = class { + constructor(callback: () => void) { + observed = callback; + } + observe() {} + }; + }, + }); + openWindows.push(dom); + expect(dom.window.document.title).toBe('GitLab connection'); + await waitFor(() => observed !== undefined); + + observed?.(); + + expect(received).toContainEqual({ + jsonrpc: '2.0', + method: 'ui/notifications/size-changed', + params: { height: expect.any(Number) }, + }); + }); + + it('labels its controls for assistive technology', () => { + const { document } = startPanel(); + + expect(document.querySelector('label[for="search"]')?.textContent).toBe( + 'Find a project or group', + ); + expect(document.getElementById('results')?.getAttribute('role')).toBe('listbox'); + expect(document.getElementById('notice')?.getAttribute('aria-live')).toBe('polite'); + for (const button of document.querySelectorAll('button')) { + expect(button.getAttribute('type')).toBe('button'); + expect(button.textContent?.trim()).not.toBe(''); + } + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/entities/context/settings-tools.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/settings-tools.test.ts new file mode 100644 index 00000000..efe1183a --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/entities/context/settings-tools.test.ts @@ -0,0 +1,86 @@ +/** + * The settings tools as registered: each returns its data both as text and as structured + * content that satisfies the tool's declared output schema, and the panel tool points the + * host at the panel resource. + */ + +import { contextToolRegistry } from '../../../../src/entities/context/registry'; +import { formatToolResult } from '../../../../src/utils/tool-result'; + +jest.mock('../../../../src/entities/context/settings', () => ({ + ...jest.requireActual('../../../../src/entities/context/settings'), + readSettings: jest.fn(async () => ({ + schema: { type: 'object', properties: { readOnly: { type: 'boolean' } }, required: [] }, + values: { readOnly: false }, + layout: [ + { kind: 'group', title: 'Defaults', items: [{ kind: 'property', property: 'readOnly' }] }, + ], + })), + updateSettings: jest.fn(async (set: Record) => ({ values: set })), +})); + +jest.mock('../../../../src/entities/context/connection-check', () => ({ + ...jest.requireActual('../../../../src/entities/context/connection-check'), + checkConnection: jest.fn(async () => ({ + account: 'alice', + instance: 'https://gitlab.example.com', + gitlabVersion: '17.4.0', + tier: 'premium', + authenticated: true, + readOnly: false, + preset: null, + scope: null, + availableTools: 42, + warnings: [], + recommendations: [], + })), +})); + +jest.mock('../../../../src/entities/context/scope-search', () => ({ + ...jest.requireActual('../../../../src/entities/context/scope-search'), + findScopeTargets: jest.fn(async (query: string) => ({ + targets: [{ type: 'group', path: query, name: 'Team' }], + })), +})); + +function tool(name: string) { + const definition = contextToolRegistry.get(name); + if (!definition) throw new Error(`Missing tool ${name}`); + return definition; +} + +async function run(name: string, args: Record = {}) { + const definition = tool(name); + return formatToolResult(await definition.handler(args), definition); +} + +describe('settings tools', () => { + it.each([ + ['get_settings', {}, { values: { readOnly: false } }], + ['update_settings', { set: { readOnly: true } }, { values: { readOnly: true } }], + ['check_connection', {}, { account: 'alice', availableTools: 42 }], + ['find_scope_targets', { query: 'team' }, { targets: [{ path: 'team' }] }], + ])('%s returns its data as text and structured content', async (name, args, expected) => { + const result = await run(name, args); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject(expected); + expect(JSON.parse((result.content[0] as { text: string }).text)).toEqual( + result.structuredContent, + ); + }); + + it('points the host at the panel resource and tells other clients what to use', async () => { + const panel = tool('open_settings_panel'); + + expect(panel._meta).toEqual({ + ui: { resourceUri: 'ui://gitlab-mcp/settings-panel-v1.html' }, + 'openai/outputTemplate': 'ui://gitlab-mcp/settings-panel-v1.html', + }); + const result = await run('open_settings_panel'); + expect(result.structuredContent).toEqual({ opened: true }); + expect((result.content[0] as { text: string }).text).toContain( + 'get_settings, update_settings and manage_context set_scope', + ); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/entities/context/settings.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/settings.test.ts new file mode 100644 index 00000000..70f1a82f --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/entities/context/settings.test.ts @@ -0,0 +1,334 @@ +/** + * Native settings (OpenAI structured settings): the page lists every setting with a value, + * a patch changes only the fields it names, nothing is saved when any value is invalid, + * and nothing the operator configured can be loosened from the page. + */ + +let mockReadOnlyMode = false; +jest.mock('../../../../src/config', () => ({ + GITLAB_BASE_URL: 'https://gitlab.example.com', + get GITLAB_READ_ONLY_MODE() { + return mockReadOnlyMode; + }, +})); + +let mockService: unknown; +jest.mock('../../../../src/configuration', () => ({ + ...jest.requireActual('../../../../src/configuration/caller'), + ConfigurationError: jest.requireActual('../../../../src/configuration/service') + .ConfigurationError, + getConfigurationService: () => mockService, +})); + +const mockRegistryKeys = jest.fn(); +jest.mock('../../../../src/registry-manager', () => ({ + RegistryManager: { getInstance: () => ({ getRegistryKeys: mockRegistryKeys }) }, +})); + +jest.mock('../../../../src/entities/context/context-manager', () => ({ + getContextManager: () => ({ + listPresets: async () => [ + { name: 'readonly', readOnly: true, isBuiltIn: true }, + { name: 'developer', readOnly: false, isBuiltIn: true }, + ], + }), +})); + +const mockDetect = jest.fn(); +jest.mock('../../../../src/utils/namespace', () => ({ + // The guessing lookup answers even for a path GitLab does not know, as the real one does. + detectNamespaceType: async (path: string) => (path.includes('/') ? 'project' : 'group'), + findNamespaceType: (path: string) => mockDetect(path), +})); + +import { ConfigurationService } from '../../../../src/configuration/service'; +import { runWithCaller, type Caller } from '../../../../src/configuration/caller'; +import type { AccountSettings, AccountSettingsRecord } from '../../../../src/configuration/types'; +import { readSettings, updateSettings } from '../../../../src/entities/context/settings'; + +const alice: Caller = { + accountKey: 'alice', + sessionKey: 's1', + accountLabel: 'alice', + instanceUrl: 'https://gitlab.example.com', + oauth: true, +}; + +describe('native settings', () => { + let records: Map; + + const saved = () => records.get('alice')?.settings; + const read = () => runWithCaller(alice, () => readSettings()); + const update = (set: Record) => + runWithCaller(alice, () => updateSettings(set)); + + beforeEach(() => { + mockReadOnlyMode = false; + mockRegistryKeys.mockReturnValue(['core', 'context', 'mrs', 'wiki', 'job-token-scope']); + mockDetect.mockReset(); + records = new Map(); + mockService = new ConfigurationService( + async () => ({ + get: async (key: string) => records.get(key), + put: async (key: string, settings: AccountSettings, expected: number) => { + const version = records.get(key)?.version ?? 0; + if (version !== expected) return undefined; + const record = { accountKey: key, settings, version: version + 1, updatedAt: 1 }; + records.set(key, record); + return record; + }, + }), + { + load: async (name: string) => { + if (name !== 'readonly' && name !== 'developer') throw new Error('not found'); + return name === 'readonly' ? { read_only: true } : {}; + }, + }, + async () => undefined, + ); + }); + + describe('readSettings', () => { + it('lists every setting with a value and marks all of them required', async () => { + const page = await read(); + const names = Object.keys(page.schema.properties); + + expect(names).toEqual([ + 'preset', + 'readOnly', + 'scope', + 'scopeIncludeSubgroups', + 'tools_mrs', + 'tools_wiki', + 'tools_ci_tokens', + ]); + expect(page.schema.required).toEqual(names); + expect(Object.keys(page.values).sort()).toEqual([...names].sort()); + expect(page.values).toEqual({ + preset: 'none', + readOnly: false, + scope: '', + scopeIncludeSubgroups: true, + tools_mrs: true, + tools_wiki: true, + tools_ci_tokens: true, + }); + }); + + // Outside a tool call (a local server's own startup) the server's token account is used. + it('reads the settings of the server account outside a tool call', async () => { + const page = await readSettings(); + + expect(page.layout[0].title).toBe( + 'Connection: Configured access token on gitlab.example.com', + ); + }); + + it('names an instance that is not a URL as configured', async () => { + const page = await runWithCaller({ ...alice, instanceUrl: 'gitlab-internal' }, () => + readSettings(), + ); + + expect(page.layout[0].title).toBe('Connection: alice on gitlab-internal'); + }); + + it('offers the presets of the server and no preset', async () => { + expect((await read()).schema.properties.preset.enum).toEqual([ + 'none', + 'readonly', + 'developer', + ]); + }); + + // Only primitive settings exist; the layout references real properties and tools. + it('lays out the settings and the connection actions', async () => { + const page = await read(); + + expect(page.layout.map((g) => g.title)).toEqual([ + 'Connection: alice on gitlab.example.com', + 'Defaults for new chats', + 'Tool groups (groups the server administrator turned off are not listed)', + ]); + const properties = page.layout.flatMap((g) => + g.items.filter((i) => i.kind === 'property').map((i) => i.property), + ); + expect(new Set(properties)).toEqual(new Set(Object.keys(page.schema.properties))); + expect(page.layout[0].items.map((i) => i.tool)).toEqual([ + 'check_connection', + 'open_settings_panel', + ]); + for (const property of Object.values(page.schema.properties)) { + expect(['string', 'boolean']).toContain(property.type); + } + }); + + it('shows read-only on and explains it when the operator set it', async () => { + mockReadOnlyMode = true; + + const page = await read(); + + expect(page.values.readOnly).toBe(true); + expect(page.schema.properties.readOnly.description).toContain('server administrator'); + }); + + // The saved preset was removed: the page still shows it and says what happens. + it('keeps a removed saved preset visible and explains the read-only fallback', async () => { + records.set('alice', { + accountKey: 'alice', + settings: { preset: 'gone' }, + version: 1, + updatedAt: 1, + }); + + const page = await read(); + + expect(page.values.preset).toBe('gone'); + expect(page.schema.properties.preset.enum).toContain('gone'); + expect(page.schema.properties.preset.description).toContain('no longer exists'); + }); + }); + + describe('updateSettings', () => { + it('saves the changed fields and returns the saved values', async () => { + const result = await update({ preset: 'readonly', tools_wiki: false }); + + expect(saved()).toEqual({ preset: 'readonly', disabledToolGroups: ['wiki'] }); + expect(result.values).toMatchObject({ + preset: 'readonly', + tools_wiki: false, + readOnly: false, + }); + }); + + it('keeps the fields a patch leaves out', async () => { + await update({ readOnly: true }); + await update({ tools_mrs: false }); + + expect(saved()).toEqual({ readOnly: true, disabledToolGroups: ['mrs'] }); + }); + + it('clears the preset with none and turns a group back on', async () => { + await update({ preset: 'developer', tools_wiki: false }); + + await update({ preset: 'none', tools_wiki: true }); + + expect(saved()).toEqual({}); + }); + + // A group the operator hid since keeps the account's choice. + it('keeps the choice for a group the server no longer offers', async () => { + await update({ tools_wiki: false }); + mockRegistryKeys.mockReturnValue(['core', 'context', 'mrs']); + + await update({ tools_mrs: false }); + + expect(saved()?.disabledToolGroups).toEqual(['mrs', 'wiki']); + }); + + it('saves a group scope with its subgroup choice after GitLab confirms it', async () => { + mockDetect.mockResolvedValue('group'); + + const result = await update({ scope: 'team', scopeIncludeSubgroups: false }); + + expect(mockDetect).toHaveBeenCalledWith('team'); + expect(saved()?.scope).toEqual({ type: 'group', path: 'team', includeSubgroups: false }); + expect(result.values).toMatchObject({ scope: 'team', scopeIncludeSubgroups: false }); + }); + + it('saves a project scope without subgroups', async () => { + mockDetect.mockResolvedValue('project'); + + await update({ scope: ' team/app ' }); + + expect(saved()?.scope).toEqual({ + type: 'project', + path: 'team/app', + includeSubgroups: false, + }); + }); + + it('changes the subgroup choice of the saved group scope', async () => { + mockDetect.mockResolvedValue('group'); + await update({ scope: 'team', scopeIncludeSubgroups: true }); + + await update({ scopeIncludeSubgroups: false }); + + expect(saved()?.scope).toEqual({ type: 'group', path: 'team', includeSubgroups: false }); + }); + + // Another chat changes the subgroup choice while this one checks a new scope path: a + // scope change that does not name the choice keeps the one saved at write time. + it('keeps a subgroup choice saved while the new scope path was being checked', async () => { + mockDetect.mockResolvedValue('group'); + await update({ scope: 'team', scopeIncludeSubgroups: true }); + mockDetect.mockImplementationOnce(async () => { + await update({ scopeIncludeSubgroups: false }); + return 'group'; + }); + + await update({ scope: 'other' }); + + expect(saved()?.scope).toEqual({ type: 'group', path: 'other', includeSubgroups: false }); + }); + + // A project scope's subgroup flag is always false and says nothing about a group. + it('includes subgroups of a group scope that replaces a project scope', async () => { + mockDetect.mockResolvedValueOnce('project').mockResolvedValueOnce('group'); + await update({ scope: 'team/app' }); + + await update({ scope: 'team' }); + + expect(saved()?.scope).toEqual({ type: 'group', path: 'team', includeSubgroups: true }); + }); + + it('includes subgroups of a first group scope by default', async () => { + mockDetect.mockResolvedValue('group'); + + await update({ scope: 'team' }); + + expect(saved()?.scope).toEqual({ type: 'group', path: 'team', includeSubgroups: true }); + }); + + it('clears the scope with an empty path', async () => { + mockDetect.mockResolvedValue('group'); + await update({ scope: 'team' }); + + await update({ scope: '' }); + + expect(saved()?.scope).toBeUndefined(); + }); + + it.each([ + [{ bogus: true }, 'Unknown setting: bogus'], + [{ readOnly: 'yes' }, 'Setting readOnly must be a boolean'], + [{ preset: 'nonexistent' }, 'Setting preset must be one of: none, readonly, developer'], + [{ tools_mrs: 1 }, 'Setting tools_mrs must be a boolean'], + ])('refuses %j and saves nothing, also the valid field beside it', async (set, message) => { + await expect(update({ tools_wiki: false, ...set })).rejects.toThrow(message); + + expect(saved()).toBeUndefined(); + }); + + // A typo, or GitLab not answering, must not be saved as a guessed project or group. + it.each(['typo', 'team/typo'])( + 'refuses %s when GitLab confirms no project or group, and saves nothing', + async (path) => { + mockDetect.mockResolvedValue(null); + + await expect(update({ scope: path, readOnly: true })).rejects.toThrow( + `No project or group '${path}' was found on GitLab, or GitLab could not be reached`, + ); + expect(saved()).toBeUndefined(); + }, + ); + + it('refuses to turn off read-only mode the operator set', async () => { + mockReadOnlyMode = true; + + await expect(update({ readOnly: false })).rejects.toThrow( + 'Read-only mode is set by the server administrator and cannot be turned off', + ); + expect(saved()).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 dec6863b..84475c1c 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/whoami.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/whoami.test.ts @@ -63,6 +63,7 @@ jest.mock('../../../../src/server', () => ({ // Mock isOAuthEnabled to check process.env at runtime jest.mock('../../../../src/oauth/index.js', () => ({ + ...jest.requireActual('../../../../src/oauth/index.js'), isOAuthEnabled: () => process.env.OAUTH_ENABLED === 'true', })); diff --git a/packages/gitlab-mcp/tests/unit/handlers.test.ts b/packages/gitlab-mcp/tests/unit/handlers.test.ts index 0c97efac..759bdc9d 100644 --- a/packages/gitlab-mcp/tests/unit/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/handlers.test.ts @@ -4,10 +4,17 @@ */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; import { setupHandlers, resetHandlersState } from '../../src/handlers'; import { StructuredToolError, parseGitLabApiError } from '../../src/utils/error-handler'; import { GitLabTimeoutError } from '../../src/utils/fetch'; +import { ConfigurationService } from '../../src/configuration/service'; +import { resolveCaller } from '../../src/configuration/caller'; // Mock ConnectionManager const mockConnectionManager = { @@ -49,8 +56,33 @@ const mockRegistryManager = { executeTool: jest.fn(), refreshCache: jest.fn(), getTool: jest.fn(), + getToolRegistryKey: jest.fn().mockReturnValue(undefined), + isReadOnlyTool: jest.fn().mockReturnValue(true), + // Built from the two mocks above, as the real registry builds it from its tables. + getToolFacts: (name: string) => ({ + name, + group: jest + .requireActual('../../src/configuration/groups') + .groupOfRegistry(mockRegistryManager.getToolRegistryKey(name)), + readOnly: mockRegistryManager.isReadOnlyTool(name) as boolean, + }), }; +// A real configuration service over an in-memory store, so the caller's settings are +// applied exactly as in production; the home directory is never touched. +const mockSettings = new Map< + string, + { accountKey: string; settings: Record; version: number; updatedAt: number } +>(); +const mockSettingsRead = jest.fn(); +/** Presets the configuration service loads by name; empty preset when absent. */ +const mockPresets: Record> = {}; +let mockConfigurationService: unknown; +jest.mock('../../src/configuration', () => ({ + ...jest.requireActual('../../src/configuration/caller'), + getConfigurationService: () => mockConfigurationService, +})); + jest.mock('../../src/registry-manager', () => ({ RegistryManager: { getInstance: jest.fn(() => mockRegistryManager), @@ -116,10 +148,8 @@ jest.mock('../../src/logging/index', () => ({ getCurrentRequestId: jest.fn(() => mockGetCurrentRequestId()), })); -// Mock ContextManager — returns default context (no scope.path by default). -// Tests that need scope.path coverage override mockContextManager.getContext directly. +// Mock ContextManager: only the profile re-pin after switch_profile reads it. const mockContextManager = { - getContext: jest.fn().mockReturnValue({ readOnly: false }), getCurrentProfileUrl: jest.fn().mockResolvedValue(null), }; @@ -158,7 +188,11 @@ type McpHandler = ( * This decouples tests from the registration order inside setupHandlers(). */ function getRegisteredHandler( mockServer: jest.Mocked, - schema: typeof ListToolsRequestSchema | typeof CallToolRequestSchema, + schema: + | typeof ListToolsRequestSchema + | typeof CallToolRequestSchema + | typeof ListResourcesRequestSchema + | typeof ReadResourceRequestSchema, ): McpHandler { // Use findLast to get the latest registered handler (not a stale pre-retry one) const calls = (mockServer.setRequestHandler as jest.Mock).mock.calls; @@ -178,6 +212,26 @@ describe('handlers', () => { jest.clearAllMocks(); resetHandlersState(); + mockSettings.clear(); + for (const name of Object.keys(mockPresets)) delete mockPresets[name]; + mockSettingsRead.mockImplementation(async (key: string) => mockSettings.get(key)); + mockRegistryManager.getToolRegistryKey.mockReturnValue(undefined); + mockRegistryManager.isReadOnlyTool.mockReturnValue(true); + mockConfigurationService = new ConfigurationService( + async () => ({ + get: mockSettingsRead, + put: async (key: string, settings: Record, expected: number) => { + const version = mockSettings.get(key)?.version ?? 0; + if (version !== expected) return undefined; + const record = { accountKey: key, settings, version: version + 1, updatedAt: 1 }; + mockSettings.set(key, record); + return record; + }, + }), + { load: async (name: string) => mockPresets[name] ?? {} }, + async () => undefined, + ); + // Create mock server mockServer = { setRequestHandler: jest.fn(), @@ -218,8 +272,6 @@ describe('handlers', () => { mockSessionManager.getSessionInstanceUrl.mockReturnValue('https://gitlab.example.com'); mockSessionManager.setSessionInstanceUrl.mockReturnValue(undefined); - // ContextManager default: no scope path (most tests don't need it) - mockContextManager.getContext.mockReturnValue({ readOnly: false }); mockContextManager.getCurrentProfileUrl.mockResolvedValue(null); }); @@ -231,8 +283,16 @@ describe('handlers', () => { expect(mockHealthMonitor.initialize).toHaveBeenCalledTimes(1); expect(mockHealthMonitor.onStateChange).toHaveBeenCalledTimes(1); - // Should set up both handlers - expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(2); + // Tools, plus the resources serving the settings panel + expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(4); + expect(mockServer.setRequestHandler).toHaveBeenCalledWith( + ListResourcesRequestSchema, + expect.any(Function), + ); + expect(mockServer.setRequestHandler).toHaveBeenCalledWith( + ReadResourceRequestSchema, + expect.any(Function), + ); expect(mockServer.setRequestHandler).toHaveBeenCalledWith( ListToolsRequestSchema, expect.any(Function), @@ -269,7 +329,7 @@ describe('handlers', () => { await setupHandlers(mockServer); // Should still set up handlers — tools/list returns context-only tools - expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(2); + expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(4); // refreshCache IS called even when disconnected (applies disconnected-mode filter) // setupHandlers calls refreshCache() without URL — the per-URL variant is // used only in the state-change callback, not during initial setup. @@ -2218,8 +2278,8 @@ describe('handlers', () => { }); await setupHandlers(mockServer); - // Both handlers should still be registered despite cache failure - expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(2); + // All handlers should still be registered despite cache failure + expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(4); // Cache failure is logged as a warning expect(logWarn).toHaveBeenCalledWith( 'Failed to refresh registry cache during handler setup', @@ -2519,13 +2579,12 @@ describe('handlers', () => { expect(mockConnectionTracker.recordError).not.toHaveBeenCalled(); }); - it('should set context path when sessionContext.scope.path is present (line 713)', async () => { - // Line 713: requestTracker.setContextForCurrentRequest is called only when - // contextManager.getContext() returns a context with scope.path set. - mockContextManager.getContext.mockReturnValue({ - scope: { path: 'groups/my-group' }, - readOnly: false, - }); + // The access log records the caller's working scope. + it('records the working scope of the caller in the access log', async () => { + await (mockConfigurationService as ConfigurationService).updateAccount( + resolveCaller(undefined, 'https://gitlab.example.com'), + { scope: { type: 'group', path: 'groups/my-group', includeSubgroups: true } }, + ); await callToolHandler({ params: { @@ -2777,4 +2836,393 @@ describe('handlers', () => { ); }); }); + + // The settings panel is served as an MCP App resource with a CSP that allows no origin. + describe('settings panel resource', () => { + const PANEL_URI = 'ui://gitlab-mcp/settings-panel-v1.html'; + + beforeEach(async () => { + await setupHandlers(mockServer); + }); + + it('lists the panel as an MCP App document', async () => { + const list = getRegisteredHandler(mockServer, ListResourcesRequestSchema); + + const result = (await list({ method: 'resources/list', params: {} })) as { + resources: Array>; + }; + + expect(result.resources).toEqual([ + { + uri: PANEL_URI, + name: 'GitLab connection panel', + mimeType: 'text/html;profile=mcp-app', + _meta: { + ui: { + csp: { connectDomains: [], resourceDomains: [], frameDomains: [] }, + prefersBorder: true, + }, + }, + }, + ]); + }); + + it('reads the panel document with its CSP metadata', async () => { + const read = getRegisteredHandler(mockServer, ReadResourceRequestSchema); + + const result = (await read({ method: 'resources/read', params: { uri: PANEL_URI } })) as { + contents: Array<{ uri: string; mimeType: string; text: string; _meta: unknown }>; + }; + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].uri).toBe(PANEL_URI); + expect(result.contents[0].mimeType).toBe('text/html;profile=mcp-app'); + expect(result.contents[0].text.startsWith('')).toBe(true); + // Self-contained: nothing is loaded from anywhere, which the empty CSP would block. + expect(result.contents[0].text).not.toMatch(/<(script|link|img)[^>]+(src|href)=/); + expect(result.contents[0]._meta).toEqual({ + ui: { + csp: { connectDomains: [], resourceDomains: [], frameDomains: [] }, + prefersBorder: true, + }, + }); + }); + + it('rejects an unknown resource', async () => { + const read = getRegisteredHandler(mockServer, ReadResourceRequestSchema); + + // The SDK invokes handlers inside a promise chain, so a thrown error answers the request. + await expect( + Promise.resolve().then(() => + read({ method: 'resources/read', params: { uri: 'ui://gitlab-mcp/other.html' } }), + ), + ).rejects.toThrow('Unknown resource: ui://gitlab-mcp/other.html'); + }); + }); + + // The caller's settings take effect in execution, also for direct calls a UI never + // offered: a restricted tool is neither listed nor run, and nothing reaches GitLab. + describe('caller settings', () => { + const staticCaller = () => resolveCaller('sess-1', 'https://gitlab.example.com'); + const service = () => mockConfigurationService as ConfigurationService; + + beforeEach(async () => { + await setupHandlers(mockServer); + listToolsHandler = getRegisteredHandler(mockServer, ListToolsRequestSchema); + callToolHandler = getRegisteredHandler(mockServer, CallToolRequestSchema); + mockRegistryManager.getAllToolDefinitions.mockReturnValue([ + { name: 'browse_merge_requests', description: '', inputSchema: { type: 'object' } }, + { name: 'manage_merge_request', description: '', inputSchema: { type: 'object' } }, + { name: 'manage_context', description: '', inputSchema: { type: 'object' } }, + ]); + mockRegistryManager.isReadOnlyTool.mockImplementation( + (name: string) => !name.startsWith('manage_'), + ); + mockRegistryManager.getToolRegistryKey.mockImplementation((name: string) => + name.includes('merge_request') ? 'mrs' : 'context', + ); + }); + + it('lists every tool for a caller without settings', async () => { + const result = await listToolsHandler({}, { sessionId: 'sess-1' }); + + expect(result.tools?.map((t) => t.name)).toEqual([ + 'browse_merge_requests', + 'manage_merge_request', + 'manage_context', + ]); + }); + + it('hides write tools from a read-only account and keeps manage_context', async () => { + await service().updateAccount(staticCaller(), { readOnly: true }); + + const result = await listToolsHandler({}, { sessionId: 'sess-1' }); + + expect(result.tools?.map((t) => t.name)).toEqual(['browse_merge_requests', 'manage_context']); + }); + + it('hides the tools of a group the account switched off', async () => { + await service().updateAccount(staticCaller(), { disabledToolGroups: ['mrs'] }); + + const result = await listToolsHandler({}, { sessionId: 'sess-1' }); + + expect(result.tools?.map((t) => t.name)).toEqual(['manage_context']); + }); + + // Listing is visibility only: an unreadable settings store does not empty the list. + it('keeps the registry list when the settings cannot be read', async () => { + mockSettingsRead.mockRejectedValue(new Error('database down')); + + const result = await listToolsHandler({}, { sessionId: 'sess-1' }); + + expect(result.tools).toHaveLength(3); + }); + + it('refuses a direct call to a write tool in read-only mode without running it', async () => { + await service().updateSession(staticCaller(), { readOnly: true }); + + const result = await callToolHandler( + { params: { name: 'manage_merge_request', arguments: { action: 'merge' } } }, + { sessionId: 'sess-1' }, + ); + + expect(result.isError).toBe(true); + expect(result.content?.[0].text).toContain( + "'manage_merge_request merge' is not allowed by the current settings: read-only mode is on", + ); + expect(mockRegistryManager.executeTool).not.toHaveBeenCalled(); + expect(mockConnectionManager.initialize).not.toHaveBeenCalled(); + }); + + it('names only the tool when a refused call has no action', async () => { + await service().updateSession(staticCaller(), { readOnly: true }); + + const result = await callToolHandler( + { params: { name: 'manage_merge_request', arguments: {} } }, + { sessionId: 'sess-1' }, + ); + + expect(result.content?.[0].text).toContain( + "'manage_merge_request' is not allowed by the current settings: read-only mode is on", + ); + }); + + it('refuses a project outside the working scope and runs one inside it', async () => { + await service().updateAccount(staticCaller(), { + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + + const outside = await callToolHandler( + { + params: { + name: 'browse_merge_requests', + arguments: { action: 'list', project_id: 'other/app' }, + }, + }, + { sessionId: 'sess-1' }, + ); + const inside = await callToolHandler( + { + params: { + name: 'browse_merge_requests', + arguments: { action: 'list', project_id: 'team/app' }, + }, + }, + { sessionId: 'sess-1' }, + ); + + expect(outside.content?.[0].text).toMatch(/outside the allowed scope/); + expect(inside.isError).toBeFalsy(); + expect(mockRegistryManager.executeTool).toHaveBeenCalledTimes(1); + }); + + // Another chat of the same account keeps its own session settings. + it('applies a session override to its own session only', async () => { + await service().updateSession(staticCaller(), { readOnly: true }); + + const other = await callToolHandler( + { params: { name: 'manage_merge_request', arguments: { action: 'merge' } } }, + { sessionId: 'sess-2' }, + ); + + expect(other.isError).toBeFalsy(); + expect(mockRegistryManager.executeTool).toHaveBeenCalledTimes(1); + }); + + // Running a call without knowing the caller's restrictions could bypass them. + it('refuses a call when the settings cannot be read', async () => { + mockSettingsRead.mockRejectedValue(new Error('database down')); + + const result = await callToolHandler( + { params: { name: 'browse_merge_requests', arguments: { action: 'list' } } }, + { sessionId: 'sess-1' }, + ); + + expect(result.isError).toBe(true); + expect(result.content?.[0].text).toContain('database down'); + expect(mockRegistryManager.executeTool).not.toHaveBeenCalled(); + }); + + it('runs the tool with its caller available to it', async () => { + const { currentCaller } = jest.requireActual('../../src/configuration/caller'); + let seen: unknown; + mockRegistryManager.executeTool.mockImplementation(async () => { + seen = currentCaller(); + return { ok: true }; + }); + + await callToolHandler( + { params: { name: 'manage_context', arguments: { action: 'show' } } }, + { sessionId: 'sess-1' }, + ); + + expect(seen).toMatchObject({ sessionKey: 'sess-1', oauth: false }); + }); + + // A listing that names no target reads everything the account sees; in a scoped + // chat it reads the scope instead, and what it cannot filter by is left out. + it('narrows a targetless listing to the working scope', async () => { + await service().updateAccount(staticCaller(), { + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + mockRegistryManager.executeTool.mockResolvedValue([ + { path_with_namespace: 'team/app' }, + { path_with_namespace: 'other/api' }, + ]); + + await callToolHandler( + { params: { name: 'browse_projects', arguments: { action: 'list' } } }, + { sessionId: 'sess-1' }, + ); + const search = await callToolHandler( + { params: { name: 'browse_projects', arguments: { action: 'search', q: 'app' } } }, + { sessionId: 'sess-1' }, + ); + + expect(mockRegistryManager.executeTool.mock.calls[0][1]).toEqual({ + action: 'list', + group_id: 'team', + include_subgroups: true, + }); + expect(JSON.parse(search.content?.[0].text ?? '')).toEqual([ + { path_with_namespace: 'team/app' }, + ]); + }); + + // A project listing filtered after GitLab paginated it can miss the scope project + // entirely; a scope made of projects lists those projects themselves. + it('lists the projects of a project scope instead of filtering one page of all', async () => { + await service().updateAccount(staticCaller(), { + scope: { type: 'project', path: 'team/app', includeSubgroups: false }, + }); + mockRegistryManager.executeTool.mockImplementation( + async (tool: string, args: Record) => + tool === 'browse_projects' && args.action === 'get' + ? { id: 7, path_with_namespace: args.project_id, name: 'App' } + : [{ path_with_namespace: 'other/api' }], + ); + + const listed = await callToolHandler( + { params: { name: 'browse_projects', arguments: { action: 'list' } } }, + { sessionId: 'sess-1' }, + ); + const filtered = await callToolHandler( + { params: { name: 'browse_projects', arguments: { action: 'list', search: 'zzz' } } }, + { sessionId: 'sess-1' }, + ); + + expect(mockRegistryManager.executeTool.mock.calls[0].slice(0, 2)).toEqual([ + 'browse_projects', + { action: 'get', project_id: 'team/app' }, + ]); + expect(JSON.parse(listed.content?.[0].text ?? '')).toEqual([ + { id: 7, path_with_namespace: 'team/app', name: 'App' }, + ]); + expect(JSON.parse(filtered.content?.[0].text ?? '')).toEqual([]); + }); + + // Marking every todo done would also clear todos outside the scope: in a scoped chat + // only the scope's pending todos are marked, one by one, over every page. + it('marks only the pending todos of the working scope done', async () => { + await service().updateAccount(staticCaller(), { + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + const pages: Record>> = { + 1: [ + ...Array.from({ length: 99 }, (_, i) => ({ + id: 100 + i, + project: { path_with_namespace: 'other/api' }, + })), + { id: 1, project: { path_with_namespace: 'team/app' } }, + ], + 2: [ + { id: 2, group: { full_path: 'team/sub' } }, + { id: 3, project: { path_with_namespace: 'other/api' } }, + ], + }; + mockRegistryManager.executeTool.mockImplementation( + async (tool: string, args: Record) => { + if (tool === 'browse_todos') return pages[args.page as number] ?? []; + return { id: args.id, state: 'done' }; + }, + ); + + const result = await callToolHandler( + { params: { name: 'manage_todos', arguments: { action: 'mark_all_done' } } }, + { sessionId: 'sess-1' }, + ); + + const calls = mockRegistryManager.executeTool.mock.calls.map(([tool, args]) => [tool, args]); + expect(calls).toEqual([ + ['browse_todos', { action: 'list', state: 'pending', per_page: 100, page: 1 }], + ['browse_todos', { action: 'list', state: 'pending', per_page: 100, page: 2 }], + ['manage_todos', { action: 'mark_done', id: 1 }], + ['manage_todos', { action: 'mark_done', id: 2 }], + ]); + expect(JSON.parse(result.content?.[0].text ?? '')).toEqual({ + success: true, + marked: 2, + failed: [], + message: + 'Marked 2 of 2 todos of the working scope as done; todos outside it were left pending', + }); + }); + + // A failed todo must not hide the ones already marked, and a large scope must not + // flood GitLab with every request at once. + it('reports the todos it could not mark and keeps the requests bounded', async () => { + await service().updateAccount(staticCaller(), { + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + const todos = Array.from({ length: 12 }, (_, i) => ({ + id: i + 1, + project: { path_with_namespace: 'team/app' }, + })); + let inFlight = 0; + let peak = 0; + mockRegistryManager.executeTool.mockImplementation( + async (tool: string, args: Record) => { + if (tool === 'browse_todos') return args.page === 1 ? todos : []; + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + inFlight--; + if (args.id === 3) throw new Error('GitLab API error: 429 Too Many Requests'); + return { id: args.id, state: 'done' }; + }, + ); + + const result = await callToolHandler( + { params: { name: 'manage_todos', arguments: { action: 'mark_all_done' } } }, + { sessionId: 'sess-1' }, + ); + + expect(peak).toBeLessThanOrEqual(5); + expect(JSON.parse(result.content?.[0].text ?? '')).toEqual({ + success: false, + marked: 11, + failed: [{ id: 3, error: 'GitLab API error: 429 Too Many Requests' }], + message: + 'Marked 11 of 12 todos of the working scope as done; 1 could not be marked; todos outside it were left pending', + }); + }); + + it('refuses a global search a scope of several projects cannot narrow', async () => { + await service().updateSession(staticCaller(), { preset: 'several' }); + mockPresets.several = { scope: { projects: ['team/app', 'other/api'] } }; + + const result = await callToolHandler( + { + params: { + name: 'browse_search', + arguments: { action: 'global', scope: 'issues', search: 'x' }, + }, + }, + { sessionId: 'sess-1' }, + ); + + expect(result.content?.[0].text).toMatch(/search within one of them/); + expect(mockRegistryManager.executeTool).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/gitlab-mcp/tests/unit/mcp-client-contract.test.ts b/packages/gitlab-mcp/tests/unit/mcp-client-contract.test.ts index e112d4af..31038273 100644 --- a/packages/gitlab-mcp/tests/unit/mcp-client-contract.test.ts +++ b/packages/gitlab-mcp/tests/unit/mcp-client-contract.test.ts @@ -279,10 +279,66 @@ describe('simultaneous MCP client contracts', () => { const catalog = await asAccount(['api'], 'https://new.example.com', () => client.listTools()); expect(catalog.tools.length).toBeGreaterThan(0); for (const tool of catalog.tools) { - expect(tool._meta?.securitySchemes).toEqual([{ type: 'oauth2', scopes: ['mcp:tools'] }]); + // A tool that opens an app panel also needs the panel resource read. + const scopes = tool._meta?.ui ? ['mcp:tools', 'mcp:resources'] : ['mcp:tools']; + expect(tool._meta?.securitySchemes).toEqual([{ type: 'oauth2', scopes }]); } + expect(catalog.tools.find((tool) => tool.name === 'open_settings_panel')?._meta).toMatchObject({ + securitySchemes: [{ type: 'oauth2', scopes: ['mcp:tools', 'mcp:resources'] }], + }); }); + // The separately granted mcp:resources must gate the panel resource like mcp:tools gates + // tool calls; otherwise a tools-only token reads resources too. + it.each([ + { mcpScopes: ['mcp:tools'], allowed: false }, + { mcpScopes: ['mcp:resources'], allowed: true }, + { mcpScopes: ['mcp:tools', 'mcp:resources'], allowed: true }, + ])( + 'requires mcp:resources to list and read resources: $mcpScopes', + async ({ mcpScopes, allowed }) => { + const oauthConfig = await import('../../src/oauth/config'); + const config = jest + .spyOn(oauthConfig, 'loadOAuthConfig') + .mockReturnValue({ issuer: 'https://mcp.example.com' } as ReturnType< + typeof oauthConfig.loadOAuthConfig + >); + try { + const client = await connect('codex', '2025-11-25'); + const asToken = (work: () => Promise): Promise => + Promise.resolve( + runWithTokenContext( + { + gitlabToken: 'fixture-only', + gitlabUserId: 1, + gitlabUsername: 'fixture', + sessionId: 'fixture', + gitlabScopes: ['api'], + apiUrl: 'https://new.example.com', + resource: 'https://mcp.example.com/mcp', + mcpScopes, + }, + work, + ), + ); + const list = asToken(() => client.listResources()); + const read = asToken(() => + client.readResource({ uri: 'ui://gitlab-mcp/settings-panel-v1.html' }), + ); + + if (allowed) { + await expect(list).resolves.toHaveProperty('resources'); + await expect(read).resolves.toHaveProperty('contents'); + } else { + await expect(list).rejects.toThrow(/insufficient_scope.*mcp:resources/); + await expect(read).rejects.toThrow(/insufficient_scope.*mcp:resources/); + } + } finally { + config.mockRestore(); + } + }, + ); + it('publishes the account profile tool and returns a schema-valid profile', async () => { // The host reads the connection's account from the tool marked openai/profile. const profile = await import('../../src/entities/context/profile'); diff --git a/packages/gitlab-mcp/tests/unit/oauth/session-store.test.ts b/packages/gitlab-mcp/tests/unit/oauth/session-store.test.ts index b84960ca..8be7ed98 100644 --- a/packages/gitlab-mcp/tests/unit/oauth/session-store.test.ts +++ b/packages/gitlab-mcp/tests/unit/oauth/session-store.test.ts @@ -538,6 +538,44 @@ describe('OAuth Session Store', () => { }); }); + describe('Account Settings', () => { + // Replicas sharing a backend see one account's settings and serialize writes on it. + it('keeps settings in the backend shared by every store', async () => { + const backend: SessionStorageBackend = new MemoryStorageBackend(); + + const stored = await new SessionStore(backend).putAccountSettings( + 'acct', + { readOnly: true }, + 0, + ); + + expect(stored).toMatchObject({ settings: { readOnly: true }, version: 1 }); + expect(await new SessionStore(backend).getAccountSettings('acct')).toEqual(stored); + expect( + await new SessionStore(backend).putAccountSettings('acct', { readOnly: false }, 0), + ).toBeUndefined(); + }); + + // Settings must survive a restart, and a database package older than the server lacks + // the account settings methods: neither backend can keep them. + it.each([ + ['a durable backend with the settings methods', 'postgresql', true, true], + ['a backend held in memory', 'memory', true, false], + ['a database package without the settings methods', 'postgresql', false, false], + ])('keeps account settings with %s: %s', (_label, type, hasMethods, expected) => { + const backend = Object.create(new MemoryStorageBackend()) as Record; + Object.defineProperty(backend, 'type', { value: type }); + if (!hasMethods) { + backend.getAccountSettings = undefined; + backend.putAccountSettings = undefined; + } + + expect( + new SessionStore(backend as unknown as SessionStorageBackend).keepsAccountSettings(), + ).toBe(expected); + }); + }); + describe('MCP Session Mapping Operations', () => { describe('associateMcpSession', () => { it('should associate MCP session with OAuth session', async () => { diff --git a/packages/gitlab-mcp/tests/unit/oauth/storage/file.test.ts b/packages/gitlab-mcp/tests/unit/oauth/storage/file.test.ts index 77648394..d57232e1 100644 --- a/packages/gitlab-mcp/tests/unit/oauth/storage/file.test.ts +++ b/packages/gitlab-mcp/tests/unit/oauth/storage/file.test.ts @@ -403,6 +403,62 @@ describe('FileStorageBackend', () => { await restarted.close(); }); + // Settings saved by an account survive a restart, with their version, so the + // compare-and-set of the next edit still refuses a stale one. + it('keeps account settings across a restart', async () => { + await seeded(); + await storage.putAccountSettings('acct', { readOnly: true }, 0); + const restarted = await reloadAfterCrash(); + + expect(await restarted.getAccountSettings('acct')).toMatchObject({ + settings: { readOnly: true }, + version: 1, + }); + expect(await restarted.putAccountSettings('acct', { readOnly: false }, 0)).toBeUndefined(); + await restarted.close(); + }); + + // A settings write that failed was reported as failed: neither this process nor a + // later write may keep it. + it('keeps the previous settings when a settings write cannot be written', async () => { + await seeded(); + await storage.putAccountSettings('acct', { preset: 'developer' }, 0); + const open = failNextWrite(); + try { + await expect(storage.putAccountSettings('acct', { preset: 'readonly' }, 1)).rejects.toThrow( + 'EACCES', + ); + } finally { + open.mockRestore(); + } + await storage.createSession(createTestSession({ id: 'later-write' })); + const restarted = await reloadAfterCrash(); + + expect(await storage.getAccountSettings('acct')).toMatchObject({ + settings: { preset: 'developer' }, + version: 1, + }); + expect((await restarted.getAccountSettings('acct'))?.settings).toEqual({ + preset: 'developer', + }); + await restarted.close(); + }); + + // A first settings write that failed leaves the account without settings. + it('drops first settings that cannot be written', async () => { + await seeded(); + const open = failNextWrite(); + try { + await expect(storage.putAccountSettings('acct', { readOnly: true }, 0)).rejects.toThrow( + 'EACCES', + ); + } finally { + open.mockRestore(); + } + + expect(await storage.getAccountSettings('acct')).toBeUndefined(); + }); + // A session whose creation could not be written was never returned to anyone. it('drops a session whose creation cannot be written', async () => { await seeded(); diff --git a/packages/gitlab-mcp/tests/unit/oauth/storage/memory.test.ts b/packages/gitlab-mcp/tests/unit/oauth/storage/memory.test.ts index c6e225f7..9671dff5 100644 --- a/packages/gitlab-mcp/tests/unit/oauth/storage/memory.test.ts +++ b/packages/gitlab-mcp/tests/unit/oauth/storage/memory.test.ts @@ -951,4 +951,97 @@ describe('MemoryStorageBackend', () => { expect(sessions.length).toBe(10); }); }); + + // Account settings are written compare-and-set: an edit made against an older version + // never overwrites a newer one, and of concurrent writes of one version exactly one wins. + describe('account settings', () => { + beforeEach(async () => { + storage = new MemoryStorageBackend({ silent: true }); + await storage.initialize(); + }); + + afterEach(async () => { + await storage.close(); + }); + + it('has no settings for an unknown account', async () => { + expect(await storage.getAccountSettings('acct')).toBeUndefined(); + }); + + it('stores the first settings at version 1 and returns them', async () => { + const stored = await storage.putAccountSettings('acct', { readOnly: true }, 0); + + expect(stored).toEqual({ + accountKey: 'acct', + settings: { readOnly: true }, + version: 1, + updatedAt: expect.any(Number), + }); + expect(await storage.getAccountSettings('acct')).toEqual(stored); + }); + + it('replaces the settings when the expected version is current', async () => { + await storage.putAccountSettings('acct', { readOnly: true }, 0); + + const stored = await storage.putAccountSettings('acct', { preset: 'readonly' }, 1); + + expect(stored?.version).toBe(2); + expect((await storage.getAccountSettings('acct'))?.settings).toEqual({ + preset: 'readonly', + }); + }); + + // The stored settings are at version 2 when the stale write arrives. + it.each([ + ['an older version', 1], + ['a newer version', 5], + ['none stored when some are', 0], + ])('refuses a write against %s', async (_case, expected) => { + await storage.putAccountSettings('acct', { readOnly: true }, 0); + await storage.putAccountSettings('acct', { readOnly: false }, 1); + + const refused = await storage.putAccountSettings('acct', { preset: 'pm' }, expected); + + expect(refused).toBeUndefined(); + expect((await storage.getAccountSettings('acct'))?.settings).toEqual({ readOnly: false }); + }); + + it('lets exactly one of concurrent writes of one version win', async () => { + const results = await Promise.all([ + storage.putAccountSettings('acct', { preset: 'a' }, 0), + storage.putAccountSettings('acct', { preset: 'b' }, 0), + ]); + + expect(results.filter((r) => r !== undefined)).toHaveLength(1); + }); + + it('keeps the settings of each account apart', async () => { + await storage.putAccountSettings('one', { readOnly: true }, 0); + await storage.putAccountSettings('two', { preset: 'pm' }, 0); + + expect((await storage.getAccountSettings('one'))?.settings).toEqual({ readOnly: true }); + expect((await storage.getAccountSettings('two'))?.settings).toEqual({ preset: 'pm' }); + }); + + // A caller mutating what it read or what it wrote must not change the stored record. + it('returns copies, not the stored record', async () => { + const stored = await storage.putAccountSettings('acct', { disabledToolGroups: ['wiki'] }, 0); + stored?.settings.disabledToolGroups?.push('mrs'); + const read = await storage.getAccountSettings('acct'); + read?.settings.disabledToolGroups?.push('labels'); + + expect((await storage.getAccountSettings('acct'))?.settings).toEqual({ + disabledToolGroups: ['wiki'], + }); + }); + + it('exports and imports the settings', async () => { + await storage.putAccountSettings('acct', { readOnly: true }, 0); + const restored = new MemoryStorageBackend({ silent: true }); + + restored.importData(storage.exportData()); + + expect((await restored.getAccountSettings('acct'))?.version).toBe(1); + }); + }); }); diff --git a/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts b/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts index 86953081..82a34b79 100644 --- a/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts +++ b/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts @@ -435,11 +435,17 @@ describe('extractProjectsFromArgs', () => { expect(projects).toContain('project2'); }); - it('should ignore non-string values', () => { + // Tool schemas coerce a numeric id to its string form, so the check sees the same id. + it('should extract a numeric project id as its string form', () => { + expect(extractProjectsFromArgs({ project_id: 12345 })).toEqual(['12345']); + }); + + it('should ignore values that are not identifiers', () => { const args = { - project_id: 12345, + project_id: Number.NaN, namespace: null, fullPath: undefined, + project: { nested: 'object' }, }; const projects = extractProjectsFromArgs(args); expect(projects).toHaveLength(0); @@ -480,6 +486,49 @@ describe('enforceArgsScope', () => { expect(() => enforceArgsScope(enforcer, args)).toThrow(ScopeViolationError); }); + // A scope of projects reaches no group: group operations (even on the project's own + // parent group, e.g. deleting it) are outside it. + it.each([{ project: 'myteam/backend' }, { projects: ['myteam/backend', 'other/api'] }])( + 'should refuse any group under the project-only scope %j', + (scope) => { + const enforcer = new ScopeEnforcer(scope); + + expect(enforcer.isGroupAllowed('unrelated')).toBe(false); + expect(enforcer.isGroupAllowed('myteam')).toBe(false); + expect(() => enforceArgsScope(enforcer, { action: 'delete', group_id: 'unrelated' })).toThrow( + ScopeViolationError, + ); + }, + ); + + // A destination (fork target, cross-project MR target, job token allowlist entry) is + // checked like the primary target: an allowed source must not reach an outside place. + it.each([ + [{ project: 'team/app' }, { project_id: 'team/app', target_project_id: 'other/api' }], + [{ group: 'team' }, { project_id: 'team/app', namespace_path: 'other' }], + [{ group: 'team' }, { project_id: 'team/app', target_group_id: 'other' }], + [{ group: 'team' }, { action: 'get', namespace_id: 'other' }], + ])('should refuse an outside destination under %j in %j', (scope, args) => { + expect(() => enforceArgsScope(new ScopeEnforcer(scope), args)).toThrow(ScopeViolationError); + }); + + it('should allow a destination inside the scope', () => { + const enforcer = new ScopeEnforcer({ group: 'team', includeSubgroups: true }); + + expect(() => + enforceArgsScope(enforcer, { + project_id: 'team/app', + namespace_path: 'team/sub', + target_project_id: 'team/api', + target_group_id: 'team/sub', + }), + ).not.toThrow(); + }); + + it('should allow any group when the scope restricts nothing', () => { + expect(new ScopeEnforcer({}).isGroupAllowed('anything')).toBe(true); + }); + it('should pass when no project fields in args', () => { const enforcer = new ScopeEnforcer({ project: 'myteam/backend' }); const args = { action: 'list', page: 1 }; @@ -544,12 +593,9 @@ describe('extractGroupsFromArgs', () => { expect(groups).toContain('group2'); }); - it('should ignore numeric group IDs (not supported)', () => { - // Note: extractGroupsFromArgs only handles string values - // Numeric IDs should be converted to strings by the caller if needed - const args = { group_id: 12345 }; - const groups = extractGroupsFromArgs(args); - expect(groups).toHaveLength(0); + // A numeric group id is checked like its string form; ignoring it bypassed the scope. + it('should extract a numeric group id as its string form', () => { + expect(extractGroupsFromArgs({ group_id: 12345 })).toEqual(['12345']); }); it('should ignore non-string values', () => { diff --git a/packages/gitlab-mcp/tests/unit/services/TokenScopeDetector.test.ts b/packages/gitlab-mcp/tests/unit/services/TokenScopeDetector.test.ts index 2b815c01..fb02091b 100644 --- a/packages/gitlab-mcp/tests/unit/services/TokenScopeDetector.test.ts +++ b/packages/gitlab-mcp/tests/unit/services/TokenScopeDetector.test.ts @@ -376,6 +376,9 @@ describe('TokenScopeDetector', () => { expect(isToolAvailableForScopes('browse_projects', scopes)).toBe(false); expect(isToolAvailableForScopes('browse_merge_requests', scopes)).toBe(false); expect(isToolAvailableForScopes('manage_project', scopes)).toBe(false); + // Scope search reads /projects and /groups, which need api or read_api + expect(isToolAvailableForScopes('find_scope_targets', scopes)).toBe(false); + expect(isToolAvailableForScopes('find_scope_targets', ['read_api'])).toBe(true); }); it('should allow browse_files with read_repository scope', () => { diff --git a/packages/gitlab-mcp/tests/unit/session-manager.test.ts b/packages/gitlab-mcp/tests/unit/session-manager.test.ts index 36cfaa30..0a642c61 100644 --- a/packages/gitlab-mcp/tests/unit/session-manager.test.ts +++ b/packages/gitlab-mcp/tests/unit/session-manager.test.ts @@ -79,9 +79,19 @@ describe('SessionManager', () => { manager.start(); await manager.createSession('session-1', mockTransport); + // The native settings page is advertised with the tools that serve it, and the + // settings panel with the resources capability. expect(Server).toHaveBeenCalledWith( { name: 'test-package', version: '1.0.0' }, - { capabilities: { tools: { listChanged: true } } }, + { + capabilities: { + tools: { listChanged: true }, + resources: {}, + experimental: { + 'openai/settings': { readTool: 'get_settings', updateTool: 'update_settings' }, + }, + }, + }, ); expect(manager.activeSessionCount).toBe(1); }); @@ -245,6 +255,30 @@ describe('SessionManager', () => { }); }); + // A settings change re-lists tools only in the sessions it affects. + describe('notifyToolsListChanged', () => { + it('notifies the named sessions and no others', async () => { + manager.start(); + const server1 = await manager.createSession('session-1', mockTransport); + const server2 = await manager.createSession('session-2', mockTransport); + + await manager.notifyToolsListChanged(['session-1', 'closed-elsewhere']); + + expect(server1.notification).toHaveBeenCalledWith({ + method: 'notifications/tools/list_changed', + }); + expect(server2.notification).not.toHaveBeenCalled(); + }); + + it('does not fail when a session cannot be notified', async () => { + manager.start(); + const server1 = await manager.createSession('session-1', mockTransport); + (server1.notification as jest.Mock).mockRejectedValueOnce(new Error('Disconnected')); + + await expect(manager.notifyToolsListChanged(['session-1'])).resolves.toBeUndefined(); + }); + }); + describe('per-session instance URL tracking (#398)', () => { it('should default instanceUrl to GITLAB_BASE_URL on session creation', async () => { manager.start(); diff --git a/packages/gitlab-mcp/tests/unit/utils/gitlab-base-url.test.ts b/packages/gitlab-mcp/tests/unit/utils/gitlab-base-url.test.ts new file mode 100644 index 00000000..665362e2 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/utils/gitlab-base-url.test.ts @@ -0,0 +1,68 @@ +/** + * Every REST call is sent to the GitLab instance of its caller: the OAuth token's + * instance, else the instance the server's own token works with now (it changes on + * switch_instance), else the configured GITLAB_API_URL. + */ + +let mockOAuth = false; +let mockContextUrl: string | undefined; +jest.mock('../../../src/oauth/index', () => ({ + ...jest.requireActual('../../../src/oauth/index'), + isOAuthEnabled: () => mockOAuth, + getGitLabApiUrlFromContext: () => mockContextUrl, +})); + +import { getGitLabBaseUrl, setActiveInstanceSource } from '../../../src/utils/gitlab-base-url'; + +describe('getGitLabBaseUrl', () => { + const originalUrl = process.env.GITLAB_API_URL; + + beforeEach(() => { + mockOAuth = false; + mockContextUrl = undefined; + setActiveInstanceSource(() => null); + process.env.GITLAB_API_URL = 'https://configured.example.com'; + }); + + afterAll(() => { + if (originalUrl === undefined) delete process.env.GITLAB_API_URL; + else process.env.GITLAB_API_URL = originalUrl; + }); + + it("uses the OAuth token's instance", () => { + mockOAuth = true; + mockContextUrl = 'https://other.example.com'; + setActiveInstanceSource(() => 'https://active.example.com'); + + expect(getGitLabBaseUrl()).toBe('https://other.example.com'); + }); + + it("uses the instance the server's token works with now", () => { + setActiveInstanceSource(() => 'https://switched.example.com'); + + expect(getGitLabBaseUrl()).toBe('https://switched.example.com'); + }); + + it('uses the active instance for an OAuth request without a token context', () => { + mockOAuth = true; + setActiveInstanceSource(() => 'https://active.example.com'); + + expect(getGitLabBaseUrl()).toBe('https://active.example.com'); + }); + + // Read at call time, as the configuration can change after startup (profiles). + it.each([ + ['https://later.example.com/', 'https://later.example.com'], + ['https://later.example.com/api/v4', 'https://later.example.com'], + ])('falls back to GITLAB_API_URL %s as it is now', (configured, expected) => { + process.env.GITLAB_API_URL = configured; + + expect(getGitLabBaseUrl()).toBe(expected); + }); + + it('falls back to gitlab.com when nothing is configured', () => { + delete process.env.GITLAB_API_URL; + + expect(getGitLabBaseUrl()).toBe('https://gitlab.com'); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/utils/instance-routing.test.ts b/packages/gitlab-mcp/tests/unit/utils/instance-routing.test.ts new file mode 100644 index 00000000..2552c0ce --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/utils/instance-routing.test.ts @@ -0,0 +1,94 @@ +/** + * REST calls of every tool go to the caller's GitLab instance. With OAuth that is the + * token's instance, which can differ from the configured GITLAB_API_URL; sending the call + * to the configured instance would also hand it a token issued by another one. + */ + +let mockOAuth = true; +jest.mock('../../../src/oauth/index', () => ({ + ...jest.requireActual('../../../src/oauth/index'), + isOAuthEnabled: () => mockOAuth, + getGitLabApiUrlFromContext: () => 'https://caller.example.com', +})); + +const mockFetch = jest.fn(); +jest.mock('../../../src/utils/fetch', () => ({ + ...jest.requireActual('../../../src/utils/fetch'), + enhancedFetch: (...args: unknown[]) => mockFetch(...args), +})); + +import { gitlab } from '../../../src/utils/gitlab-api'; +import { detectNamespaceType } from '../../../src/utils/namespace'; +import { smartUserSearch } from '../../../src/utils/smart-user-search'; +import { coreToolRegistry } from '../../../src/entities/core/registry'; +import { filesToolRegistry } from '../../../src/entities/files/registry'; +import { pipelinesToolRegistry } from '../../../src/entities/pipelines/registry'; +import { iterationsToolRegistry } from '../../../src/entities/iterations/registry'; +import type { ToolRegistry } from '../../../src/types'; + +function response(): Record { + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: async () => [], + text: async () => '', + arrayBuffer: async () => new ArrayBuffer(0), + }; +} + +const run = (registry: ToolRegistry, tool: string, args: Record) => + registry.get(tool)!.handler(args); + +describe('routing of REST calls to the caller instance', () => { + const originalUrl = process.env.GITLAB_API_URL; + + beforeEach(() => { + mockOAuth = true; + mockFetch.mockReset(); + mockFetch.mockImplementation(async () => response()); + process.env.GITLAB_API_URL = 'https://configured.example.com'; + }); + + afterAll(() => { + if (originalUrl === undefined) delete process.env.GITLAB_API_URL; + else process.env.GITLAB_API_URL = originalUrl; + }); + + it.each<[string, () => Promise]>([ + ['the shared GitLab client', () => gitlab.get('projects')], + ['namespace detection', () => detectNamespaceType('team').catch(() => undefined)], + ['user search', () => smartUserSearch('Alice Example')], + ['core tools', () => run(coreToolRegistry, 'browse_projects', { action: 'search', q: 'x' })], + [ + 'file tools', + () => + run(filesToolRegistry, 'browse_files', { + action: 'content', + project_id: 'team/app', + file_path: 'README.md', + }), + ], + [ + 'pipeline tools', + () => + run(pipelinesToolRegistry, 'browse_pipelines', { + action: 'logs', + project_id: 'team/app', + job_id: '1', + }), + ], + [ + 'iteration tools', + () => run(iterationsToolRegistry, 'browse_iterations', { action: 'list', group_id: 'team' }), + ], + ])('sends %s to the caller instance', async (_label, call) => { + await call(); + + expect(mockFetch).toHaveBeenCalled(); + for (const [url] of mockFetch.mock.calls) { + expect(String(url)).toMatch(/^https:\/\/caller\.example\.com\/api\/v4\//); + } + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/utils/namespace.coverage.test.ts b/packages/gitlab-mcp/tests/unit/utils/namespace.coverage.test.ts index 822c5daa..ba19bfa8 100644 --- a/packages/gitlab-mcp/tests/unit/utils/namespace.coverage.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/namespace.coverage.test.ts @@ -1,5 +1,6 @@ import { detectNamespaceType, + findNamespaceType, resolveNamespaceForAPI, extractNamespaceFromPath, } from '../../../src/utils/namespace'; @@ -55,6 +56,33 @@ describe('Namespace Utils Coverage Tests', () => { }); }); + // Only a type GitLab confirms: a scope must never be saved from a guess. + describe('findNamespaceType', () => { + it('returns the type GitLab confirms', async () => { + mockEnhancedFetch + .mockResolvedValueOnce({ ok: false, status: 404 } as Response) + .mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + expect(await findNamespaceType('team')).toBe('project'); + }); + + it.each(['team', 'team/app'])( + 'returns null for %s when GitLab knows neither type', + async (path) => { + mockEnhancedFetch.mockResolvedValue({ ok: false, status: 404 } as Response); + + expect(await findNamespaceType(path)).toBeNull(); + expect(mockEnhancedFetch).toHaveBeenCalledTimes(2); + }, + ); + + it('returns null when GitLab cannot be reached', async () => { + mockEnhancedFetch.mockRejectedValue(new Error('Network error')); + + expect(await findNamespaceType('team/app')).toBeNull(); + }); + }); + describe('detectNamespaceType', () => { it('should detect project type for likely project path', async () => { // Mock successful project response diff --git a/yarn.lock b/yarn.lock index cc3bc31d..c7bd6065 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,19 @@ __metadata: version: 8 cacheKey: 10c0 +"@asamuzakjp/css-color@npm:^3.2.0": + version: 3.2.0 + resolution: "@asamuzakjp/css-color@npm:3.2.0" + dependencies: + "@csstools/css-calc": "npm:^2.1.3" + "@csstools/css-color-parser": "npm:^3.0.9" + "@csstools/css-parser-algorithms": "npm:^3.0.4" + "@csstools/css-tokenizer": "npm:^3.0.3" + lru-cache: "npm:^10.4.3" + checksum: 10c0/a4bf1c831751b1fae46b437e37e8a38c0b5bd58d23230157ae210bd1e905fe509b89b7c243e63d1522d852668a6292ed730a160e21342772b4e5b7b8ea14c092 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.27.1": version: 7.28.6 resolution: "@babel/code-frame@npm:7.28.6" @@ -564,6 +577,52 @@ __metadata: languageName: node linkType: hard +"@csstools/color-helpers@npm:^5.1.0": + version: 5.1.0 + resolution: "@csstools/color-helpers@npm:5.1.0" + checksum: 10c0/b7f99d2e455cf1c9b41a67a5327d5d02888cd5c8802a68b1887dffef537d9d4bc66b3c10c1e62b40bbed638b6c1d60b85a232f904ed7b39809c4029cb36567db + languageName: node + linkType: hard + +"@csstools/css-calc@npm:^2.1.3, @csstools/css-calc@npm:^2.1.4": + version: 2.1.4 + resolution: "@csstools/css-calc@npm:2.1.4" + peerDependencies: + "@csstools/css-parser-algorithms": ^3.0.5 + "@csstools/css-tokenizer": ^3.0.4 + checksum: 10c0/42ce5793e55ec4d772083808a11e9fb2dfe36db3ec168713069a276b4c3882205b3507c4680224c28a5d35fe0bc2d308c77f8f2c39c7c09aad8747708eb8ddd8 + languageName: node + linkType: hard + +"@csstools/css-color-parser@npm:^3.0.9": + version: 3.1.0 + resolution: "@csstools/css-color-parser@npm:3.1.0" + dependencies: + "@csstools/color-helpers": "npm:^5.1.0" + "@csstools/css-calc": "npm:^2.1.4" + peerDependencies: + "@csstools/css-parser-algorithms": ^3.0.5 + "@csstools/css-tokenizer": ^3.0.4 + checksum: 10c0/0e0c670ad54ec8ec4d9b07568b80defd83b9482191f5e8ca84ab546b7be6db5d7cc2ba7ac9fae54488b129a4be235d6183d3aab4416fec5e89351f73af4222c5 + languageName: node + linkType: hard + +"@csstools/css-parser-algorithms@npm:^3.0.4": + version: 3.0.5 + resolution: "@csstools/css-parser-algorithms@npm:3.0.5" + peerDependencies: + "@csstools/css-tokenizer": ^3.0.4 + checksum: 10c0/d9a1c888bd43849ae3437ca39251d5c95d2c8fd6b5ccdb7c45491dfd2c1cbdc3075645e80901d120e4d2c1993db9a5b2d83793b779dbbabcfb132adb142eb7f7 + languageName: node + linkType: hard + +"@csstools/css-tokenizer@npm:^3.0.3": + version: 3.0.4 + resolution: "@csstools/css-tokenizer@npm:3.0.4" + checksum: 10c0/3b589f8e9942075a642213b389bab75a2d50d05d203727fcdac6827648a5572674caff07907eff3f9a2389d86a4ee47308fafe4f8588f4a77b7167c588d2559f + languageName: node + linkType: hard + "@docsearch/css@npm:^4.7.0": version: 4.7.0 resolution: "@docsearch/css@npm:4.7.0" @@ -2171,6 +2230,7 @@ __metadata: "@structured-world/vue-privacy": "npm:^1.11.0" "@types/express": "npm:^5.0.6" "@types/jest": "npm:^30.0.0" + "@types/jsdom": "npm:^21.1.7" "@types/node": "npm:^25.9.9" "@types/picomatch": "npm:^4.0.3" "@typescript-eslint/eslint-plugin": "npm:^8.71.1" @@ -2185,6 +2245,7 @@ __metadata: graphql: "npm:^17.0.2" graphql-tag: "npm:^2.12.7" jest: "npm:^30.5.2" + jsdom: "npm:^26.1.0" open: "npm:^11.0.4" picomatch: "npm:^4.0.7" pino: "npm:^10.4.0" @@ -2528,6 +2589,17 @@ __metadata: languageName: node linkType: hard +"@types/jsdom@npm:^21.1.7": + version: 21.1.7 + resolution: "@types/jsdom@npm:21.1.7" + dependencies: + "@types/node": "npm:*" + "@types/tough-cookie": "npm:*" + parse5: "npm:^7.0.0" + checksum: 10c0/c0c0025adc2b193e85453eeeea168bb909f0ebad08d6552be7474a407e9c163db8f696dcf1e3cbe8cb9c9d970ba45f4386171794509c1a0fe5d1fed72c91679d + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.15": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -2668,6 +2740,13 @@ __metadata: languageName: node linkType: hard +"@types/tough-cookie@npm:*": + version: 4.0.5 + resolution: "@types/tough-cookie@npm:4.0.5" + checksum: 10c0/68c6921721a3dcb40451543db2174a145ef915bc8bcbe7ad4e59194a0238e776e782b896c7a59f4b93ac6acefca9161fccb31d1ce3b3445cb6faa467297fb473 + languageName: node + linkType: hard + "@types/unist@npm:*, @types/unist@npm:^3.0.0": version: 3.0.3 resolution: "@types/unist@npm:3.0.3" @@ -4197,6 +4276,16 @@ __metadata: languageName: node linkType: hard +"cssstyle@npm:^4.2.1": + version: 4.6.0 + resolution: "cssstyle@npm:4.6.0" + dependencies: + "@asamuzakjp/css-color": "npm:^3.2.0" + rrweb-cssom: "npm:^0.8.0" + checksum: 10c0/71add1b0ffafa1bedbef6855db6189b9523d3320e015a0bf3fbd504760efb9a81e1f1a225228d5fa892ee58e56d06994ca372e7f4e461cda7c4c9985fe075f65 + languageName: node + linkType: hard + "csstype@npm:^3.2.2, csstype@npm:^3.2.3": version: 3.2.3 resolution: "csstype@npm:3.2.3" @@ -4317,6 +4406,16 @@ __metadata: languageName: node linkType: hard +"data-urls@npm:^5.0.0": + version: 5.0.0 + resolution: "data-urls@npm:5.0.0" + dependencies: + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.0.0" + checksum: 10c0/1b894d7d41c861f3a4ed2ae9b1c3f0909d4575ada02e36d3d3bc584bdd84278e20709070c79c3b3bff7ac98598cb191eb3e86a89a79ea4ee1ef360e1694f92ad + languageName: node + linkType: hard + "dateformat@npm:^4.6.3": version: 4.6.3 resolution: "dateformat@npm:4.6.3" @@ -4336,6 +4435,13 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.5.0": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: 10c0/07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa + languageName: node + linkType: hard + "dedent@npm:^1.6.0": version: 1.7.1 resolution: "dedent@npm:1.7.1" @@ -4653,6 +4759,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^6.0.0": + version: 6.0.1 + resolution: "entities@npm:6.0.1" + checksum: 10c0/ed836ddac5acb34341094eb495185d527bd70e8632b6c0d59548cbfa23defdbae70b96f9a405c82904efa421230b5b3fd2283752447d737beffd3f3e6ee74414 + languageName: node + linkType: hard + "entities@npm:^7.0.1": version: 7.0.1 resolution: "entities@npm:7.0.1" @@ -5665,6 +5778,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^4.0.0": + version: 4.0.0 + resolution: "html-encoding-sniffer@npm:4.0.0" + dependencies: + whatwg-encoding: "npm:^3.1.1" + checksum: 10c0/523398055dc61ac9b34718a719cb4aa691e4166f29187e211e1607de63dc25ac7af52ca7c9aead0c4b3c0415ffecb17326396e1202e2e86ff4bca4c0ee4c6140 + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -5699,7 +5821,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^7.0.0": +"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.2": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" dependencies: @@ -5709,7 +5831,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^7.0.1": +"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.6": version: 7.0.6 resolution: "https-proxy-agent@npm:7.0.6" dependencies: @@ -5726,7 +5848,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.6.2": +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -5924,6 +6046,13 @@ __metadata: languageName: node linkType: hard +"is-potential-custom-element-name@npm:^1.0.1": + version: 1.0.1 + resolution: "is-potential-custom-element-name@npm:1.0.1" + checksum: 10c0/b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9 + languageName: node + linkType: hard + "is-promise@npm:^4.0.0": version: 4.0.0 resolution: "is-promise@npm:4.0.0" @@ -6580,6 +6709,39 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^26.1.0": + version: 26.1.0 + resolution: "jsdom@npm:26.1.0" + dependencies: + cssstyle: "npm:^4.2.1" + data-urls: "npm:^5.0.0" + decimal.js: "npm:^10.5.0" + html-encoding-sniffer: "npm:^4.0.0" + http-proxy-agent: "npm:^7.0.2" + https-proxy-agent: "npm:^7.0.6" + is-potential-custom-element-name: "npm:^1.0.1" + nwsapi: "npm:^2.2.16" + parse5: "npm:^7.2.1" + rrweb-cssom: "npm:^0.8.0" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^5.1.1" + w3c-xmlserializer: "npm:^5.0.0" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^3.1.1" + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.1.1" + ws: "npm:^8.18.0" + xml-name-validator: "npm:^5.0.0" + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/5b14a5bc32ce077a06fb42d1ab95b1191afa5cbbce8859e3b96831c5143becbbcbf0511d4d4934e922d2901443ced2cdc3b734c1cf30b5f73b3e067ce457d0f4 + languageName: node + linkType: hard + "jsesc@npm:^3.0.2": version: 3.1.0 resolution: "jsesc@npm:3.1.0" @@ -6849,7 +7011,7 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^10.2.0": +"lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" checksum: 10c0/ebd04fbca961e6c1d6c0af3799adcc966a1babe798f685bb84e6599266599cd95d94630b10262f5424539bc4640107e8a33aa28585374abf561d30d16f4b39fb @@ -7316,6 +7478,13 @@ __metadata: languageName: node linkType: hard +"nwsapi@npm:^2.2.16": + version: 2.2.28 + resolution: "nwsapi@npm:2.2.28" + checksum: 10c0/8fb5ce37f352d48d64f631e5fe10346ba24010de3671dc4174783504c07912b5d44d0d507fe13ba461a7c8b8173e35f902605228b9a71be18bd3d62ba9e951ed + languageName: node + linkType: hard + "nx@npm:^23.3.0": version: 23.3.0 resolution: "nx@npm:23.3.0" @@ -7671,6 +7840,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.0.0, parse5@npm:^7.2.1": + version: 7.3.0 + resolution: "parse5@npm:7.3.0" + dependencies: + entities: "npm:^6.0.0" + checksum: 10c0/7fd2e4e247e85241d6f2a464d0085eed599a26d7b0a5233790c49f53473232eb85350e8133344d9b3fd58b89339e7ad7270fe1f89d28abe50674ec97b87f80b5 + languageName: node + linkType: hard + "parseurl@npm:^1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" @@ -8167,7 +8345,7 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0": +"punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 @@ -8446,6 +8624,13 @@ __metadata: languageName: node linkType: hard +"rrweb-cssom@npm:^0.8.0": + version: 0.8.0 + resolution: "rrweb-cssom@npm:0.8.0" + checksum: 10c0/56f2bfd56733adb92c0b56e274c43f864b8dd48784d6fe946ef5ff8d438234015e59ad837fc2ad54714b6421384141c1add4eb569e72054e350d1f8a50b8ac7b + languageName: node + linkType: hard + "run-applescript@npm:7.0.0": version: 7.0.0 resolution: "run-applescript@npm:7.0.0" @@ -8492,6 +8677,15 @@ __metadata: languageName: node linkType: hard +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10c0/3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74 + languageName: node + linkType: hard + "secure-json-parse@npm:^4.1.0": version: 4.1.0 resolution: "secure-json-parse@npm:4.1.0" @@ -8928,6 +9122,13 @@ __metadata: languageName: node linkType: hard +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 + languageName: node + linkType: hard + "synckit@npm:^0.11.13": version: 0.11.13 resolution: "synckit@npm:0.11.13" @@ -9019,6 +9220,24 @@ __metadata: languageName: node linkType: hard +"tldts-core@npm:^6.1.86": + version: 6.1.86 + resolution: "tldts-core@npm:6.1.86" + checksum: 10c0/8133c29375f3f99f88fce5f4d62f6ecb9532b106f31e5423b27c1eb1b6e711bd41875184a456819ceaed5c8b94f43911b1ad57e25c6eb86e1fc201228ff7e2af + languageName: node + linkType: hard + +"tldts@npm:^6.1.32": + version: 6.1.86 + resolution: "tldts@npm:6.1.86" + dependencies: + tldts-core: "npm:^6.1.86" + bin: + tldts: bin/cli.js + checksum: 10c0/27ae7526d9d78cb97b2de3f4d102e0b4321d1ccff0648a7bb0e039ed54acbce86bacdcd9cd3c14310e519b457854e7bafbef1f529f58a1e217a737ced63f0940 + languageName: node + linkType: hard + "tmp@npm:>=0.2.7": version: 0.2.7 resolution: "tmp@npm:0.2.7" @@ -9042,6 +9261,24 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^5.1.1": + version: 5.1.2 + resolution: "tough-cookie@npm:5.1.2" + dependencies: + tldts: "npm:^6.1.32" + checksum: 10c0/5f95023a47de0f30a902bba951664b359725597d8adeabc66a0b93a931c3af801e1e697dae4b8c21a012056c0ea88bd2bf4dfe66b2adcf8e2f42cd9796fe0626 + languageName: node + linkType: hard + +"tr46@npm:^5.1.0": + version: 5.1.1 + resolution: "tr46@npm:5.1.1" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10c0/ae270e194d52ec67ebd695c1a42876e0f19b96e4aca2ab464ab1d9d17dc3acd3e18764f5034c93897db73421563be27c70c98359c4501136a497e46deda5d5ec + languageName: node + linkType: hard + "transliteration@npm:^2.6.1": version: 2.6.1 resolution: "transliteration@npm:2.6.1" @@ -9612,6 +9849,15 @@ __metadata: languageName: node linkType: hard +"w3c-xmlserializer@npm:^5.0.0": + version: 5.0.0 + resolution: "w3c-xmlserializer@npm:5.0.0" + dependencies: + xml-name-validator: "npm:^5.0.0" + checksum: 10c0/8712774c1aeb62dec22928bf1cdfd11426c2c9383a1a63f2bcae18db87ca574165a0fbe96b312b73652149167ac6c7f4cf5409f2eb101d9c805efe0e4bae798b + languageName: node + linkType: hard + "wcwidth@npm:1.0.1, wcwidth@npm:^1.0.1": version: 1.0.1 resolution: "wcwidth@npm:1.0.1" @@ -9621,6 +9867,39 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10c0/228d8cb6d270c23b0720cb2d95c579202db3aaf8f633b4e9dd94ec2000a04e7e6e43b76a94509cdb30479bd00ae253ab2371a2da9f81446cc313f89a4213a2c4 + languageName: node + linkType: hard + +"whatwg-encoding@npm:^3.1.1": + version: 3.1.1 + resolution: "whatwg-encoding@npm:3.1.1" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10c0/273b5f441c2f7fda3368a496c3009edbaa5e43b71b09728f90425e7f487e5cef9eb2b846a31bd760dd8077739c26faf6b5ca43a5f24033172b003b72cf61a93e + languageName: node + linkType: hard + +"whatwg-mimetype@npm:^4.0.0": + version: 4.0.0 + resolution: "whatwg-mimetype@npm:4.0.0" + checksum: 10c0/a773cdc8126b514d790bdae7052e8bf242970cebd84af62fb2f35a33411e78e981f6c0ab9ed1fe6ec5071b09d5340ac9178e05b52d35a9c4bcf558ba1b1551df + languageName: node + linkType: hard + +"whatwg-url@npm:^14.0.0, whatwg-url@npm:^14.1.1": + version: 14.2.0 + resolution: "whatwg-url@npm:14.2.0" + dependencies: + tr46: "npm:^5.1.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10c0/f746fc2f4c906607d09537de1227b13f9494c171141e5427ed7d2c0dd0b6a48b43d8e71abaae57d368d0c06b673fd8ec63550b32ad5ed64990c7b0266c2b4272 + languageName: node + linkType: hard + "which@npm:3.0.1": version: 3.0.1 resolution: "which@npm:3.0.1" @@ -9707,6 +9986,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.0": + version: 8.22.0 + resolution: "ws@npm:8.22.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/7304d14ef38898dd366a528a85e2da389c4eebf30f4ce8a007bd2dce6025f9b619cefe44fe2354f0bad0ec541b1c9a4da06a3f3e046ab56f01fd52d5784ecfb2 + languageName: node + linkType: hard + "wsl-utils@npm:^1.0.0": version: 1.0.0 resolution: "wsl-utils@npm:1.0.0" @@ -9717,6 +10011,20 @@ __metadata: languageName: node linkType: hard +"xml-name-validator@npm:^5.0.0": + version: 5.0.0 + resolution: "xml-name-validator@npm:5.0.0" + checksum: 10c0/3fcf44e7b73fb18be917fdd4ccffff3639373c7cb83f8fc35df6001fecba7942f1dbead29d91ebb8315e2f2ff786b508f0c9dc0215b6353f9983c6b7d62cb1f5 + languageName: node + linkType: hard + +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593 + languageName: node + linkType: hard + "xstate@npm:^5.33.2": version: 5.33.2 resolution: "xstate@npm:5.33.2" From d7ff97b49a65e5a033b889fe9c70a05bdef8fa35 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 11 Oct 2026 01:15:34 +0300 Subject: [PATCH 02/11] fix(settings): fill scoped pages, keep scope checks consistent - Listings filtered to the working scope read further GitLab pages until the requested page is full (at most ten pages of 100), instead of filtering one page that may hold no in-scope rows. - A project scope lists its own projects only for a plain listing; with other list filters it pages GitLab's listing, keeping their semantics. A scope project that fails for a reason other than 404 fails the call instead of shortening the list. - URL-encoded paths ("group%2Fproject") are compared decoded. - The namespace of a created or forked project is checked as the group it goes into, so the scope group itself is allowed also without subgroups; a fork without a destination goes into the scope group or is refused, like project creation. - A targetless vulnerability listing names the scope target, and is refused for several targets or a group without subgroups. - get_profile is never hidden by settings. - Removing a dead settings lock claims it by rename and checks its identity, so a fresh lock taken meanwhile is handed back. - A settings file entry of the wrong shape is skipped, a root that is not an object is reported. - The channel gateway forgets tools a complete catalog no longer lists; check_connection and the access log name list scopes; the OAuth instance URL is normalized. - Scoped execution moved to its own module; the settings guide states read-only precedence and that file session storage serves one process. Regression tests: scoped page filling and paging, project scope list filters and failures, encoded paths, creation and fork destinations, vulnerability listings, the profile exemption, the stale lock race, settings file shapes, removed gateway tools, list scope reports and URL normalization. --- .../gitlab-mcp/docs/configuration/settings.md | 24 +-- .../gitlab-mcp/src/channel-gateway/gateway.ts | 15 +- .../gitlab-mcp/src/configuration/policy.ts | 24 ++- .../src/configuration/scope-targets.ts | 144 +++++++++++----- .../src/configuration/scoped-execution.ts | 159 ++++++++++++++++++ .../gitlab-mcp/src/configuration/service.ts | 41 +++-- .../src/configuration/settings-store.ts | 66 ++++++-- .../src/entities/context/connection-check.ts | 4 +- packages/gitlab-mcp/src/handlers.ts | 105 +----------- packages/gitlab-mcp/src/oauth/storage/file.ts | 4 +- .../gitlab-mcp/src/profiles/scope-enforcer.ts | 13 +- .../gitlab-mcp/src/utils/gitlab-base-url.ts | 2 +- .../unit/channel-gateway/gateway.test.ts | 28 +++ .../tests/unit/configuration/policy.test.ts | 34 ++++ .../unit/configuration/scope-targets.test.ts | 64 +++++++ .../configuration/scoped-execution.test.ts | 73 ++++++++ .../unit/configuration/settings-store.test.ts | 133 +++++++++++++++ .../entities/context/connection-check.test.ts | 14 ++ .../gitlab-mcp/tests/unit/handlers.test.ts | 105 ++++++++++++ .../tests/unit/oauth/tool-auth.test.ts | 37 ++++ .../unit/profiles/scope-enforcer.test.ts | 11 ++ .../tests/unit/utils/gitlab-base-url.test.ts | 9 + 22 files changed, 917 insertions(+), 192 deletions(-) create mode 100644 packages/gitlab-mcp/src/configuration/scoped-execution.ts create mode 100644 packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts diff --git a/packages/gitlab-mcp/docs/configuration/settings.md b/packages/gitlab-mcp/docs/configuration/settings.md index 7b7aeeab..0d3ad9a5 100644 --- a/packages/gitlab-mcp/docs/configuration/settings.md +++ b/packages/gitlab-mcp/docs/configuration/settings.md @@ -36,7 +36,8 @@ settings, and a chat override never leaks into another chat. | `tools_` | Turn a tool group off or on, for example `tools_wiki` or `tools_pipelines`. Groups the administrator turned off are not offered. | The effective restrictions combine all layers: a chat value replaces the account value for -that chat, and read-only from any layer (including the preset) wins. +that chat. Read-only mode set by the server administrator or by the selected preset always +applies; a chat cannot turn it off. Restrictions are applied when a tool runs, not only to the tool list: a direct call to a tool the settings turn off, or to a project or group outside the working scope, is refused @@ -45,15 +46,17 @@ are refused under it. A listing or search that names no project or group reads the working scope instead of everything the account can see: a global search becomes a search of the scope's group or -project, a project listing lists the scope's group, a cross-project merge request listing -lists the scope's project, and results that cannot be filtered at GitLab (project listings -and searches, including projects only shared with the scope's group, todos, merge requests -under a group scope) are filtered to the scope. A global search is refused under a scope of -several projects or groups, and under a group scope without subgroups (GitLab's group -search always includes them); search within one project or group instead. `manage_todos +project, a project listing lists the scope's group (or the scope's own projects), a +cross-project merge request listing and a vulnerability listing name the scope's target. +Results that cannot be filtered at GitLab (project listings and searches, including +projects only shared with the scope's group, todos, merge requests under a group scope) +are filtered to the scope, reading further GitLab pages until the requested page is full +(up to 1000 rows per call). A global search or vulnerability listing is refused under a +scope of several projects or groups, and under a group scope without subgroups (GitLab's +group results always include them); use one project or group instead. `manage_todos mark_all_done` marks only the scope's pending todos done and leaves the others pending. A -project created without a namespace goes into the scope's group; with no single group to -put it in, and for a group without a parent, the call is refused. +project created or forked without a namespace goes into the scope's group; with no single +group to put it in, and for a group without a parent, the call is refused. The working scope is a focus, not an access boundary: the user sets and clears it. Calls that name an object only by its global id (a work item or todo id) are not checked against @@ -117,7 +120,8 @@ recommendations, and `find_scope_targets` finds projects and groups to scope to. | Deployment | Account settings are kept in | |------------|------------------------------| -| OAuth with file or PostgreSQL session storage (`OAUTH_STORAGE_TYPE`) | The session storage, shared by every replica that uses it | +| OAuth with PostgreSQL session storage | The session storage, shared by every replica that uses it | +| OAuth with file session storage | The session storage file. File storage serves one server process: replicas need PostgreSQL | | OAuth with sessions in memory (the default), or a local server with a static token | `~/.config/gitlab-mcp/settings.json` of the user running the server, shared by every server process of that user; settings survive a restart even though memory sessions do not | Writes are compare-and-set: two chats editing different settings at the same time both diff --git a/packages/gitlab-mcp/src/channel-gateway/gateway.ts b/packages/gitlab-mcp/src/channel-gateway/gateway.ts index 03cde334..5e1a938c 100644 --- a/packages/gitlab-mcp/src/channel-gateway/gateway.ts +++ b/packages/gitlab-mcp/src/channel-gateway/gateway.ts @@ -129,7 +129,8 @@ export class ChannelGateway { const catalog = (await this.readDownstream('tools/list', (client) => client.listTools(request.params), )) as ListToolsResult; - this.recordCatalogReads(catalog, generation); + const complete = !request.params?.cursor && !catalog.nextCursor; + this.recordCatalogReads(catalog, generation, complete); return catalog; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { @@ -149,7 +150,11 @@ export class ChannelGateway { }); } - private recordCatalogReads(catalog: ListToolsResult, generation: number): void { + private recordCatalogReads( + catalog: ListToolsResult, + generation: number, + complete: boolean, + ): void { for (const tool of catalog.tools) { const known = this.catalogReads.get(tool.name); if (known && known.generation > generation) continue; @@ -157,6 +162,12 @@ export class ChannelGateway { const read = tool.annotations?.readOnlyHint ?? isReadCall(tool.name); this.catalogReads.set(tool.name, { read, generation }); } + // The whole catalog in one answer: a tool it no longer lists loses its verdict. A page + // of a paginated catalog cannot tell, the tool may be on another page. + if (!complete) return; + for (const [name, known] of this.catalogReads) { + if (known.generation < generation) this.catalogReads.delete(name); + } } /** Whether a call is a read: the catalog's annotation, else the name for an unlisted tool. */ diff --git a/packages/gitlab-mcp/src/configuration/policy.ts b/packages/gitlab-mcp/src/configuration/policy.ts index 0a9b4de0..7c5bdcc6 100644 --- a/packages/gitlab-mcp/src/configuration/policy.ts +++ b/packages/gitlab-mcp/src/configuration/policy.ts @@ -7,7 +7,7 @@ import type { Preset, ScopeConfig } from '../profiles/types'; import { enforceArgsScope, ScopeEnforcer, ScopeViolationError } from '../profiles/scope-enforcer'; import type { AccountSettings, WorkingScope } from './types'; -import { targetlessRestriction } from './scope-targets'; +import { scopeCheckArgs, targetlessRestriction } from './scope-targets'; /** A session's choice to work without the account's default scope. */ export const EVERYWHERE = 'everywhere'; @@ -48,6 +48,22 @@ export const CONFIGURATION_TOOLS: ReadonlySet = new Set([ 'find_scope_targets', ]); +/** + * Tools settings never restrict: the configuration tools, and the account identity the host + * reads to know whose connection it is. Unlike the configuration tools, the identity needs + * GitLab, so it does not answer while GitLab is unreachable. + */ +function isExemptFromSettings(name: string): boolean { + return CONFIGURATION_TOOLS.has(name) || name === 'get_profile'; +} + +/** The scope's first project or group, for reports; undefined when nothing is scoped. */ +export function scopeLabel(scope: ScopeConfig | undefined): string | undefined { + return ( + scope?.project ?? scope?.group ?? scope?.namespace ?? scope?.projects?.[0] ?? scope?.groups?.[0] + ); +} + export function scopeConfigOf(scope: WorkingScope): ScopeConfig { return scope.type === 'project' ? { project: scope.path } @@ -111,7 +127,7 @@ export interface ToolFacts { /** Why the policy hides a tool from the caller, or null when it may use it. */ export function toolRestriction(policy: EffectivePolicy, tool: ToolFacts): string | null { - if (CONFIGURATION_TOOLS.has(tool.name)) return null; + if (isExemptFromSettings(tool.name)) return null; if (policy.readOnly && !tool.readOnly) return 'read-only mode is on'; if (tool.group !== undefined && policy.disabledGroups.has(tool.group)) { return `the '${tool.group}' tool group is turned off`; @@ -136,7 +152,7 @@ export function callRestriction( ): string | null { const restriction = toolRestriction(policy, tool); if (restriction) return restriction; - if (CONFIGURATION_TOOLS.has(tool.name)) return null; + if (isExemptFromSettings(tool.name)) return null; const action = typeof args.action === 'string' ? args.action : undefined; if (action !== undefined && policy.deniedActions.get(tool.name)?.has(action)) { return `preset '${policy.presetName}' denies the '${action}' action`; @@ -148,7 +164,7 @@ export function callRestriction( // is a project or group access token, which GitLab enforces on every call. if (policy.scopeEnforcer) { try { - enforceArgsScope(policy.scopeEnforcer, args); + enforceArgsScope(policy.scopeEnforcer, scopeCheckArgs(tool.name, args)); } catch (error: unknown) { if (error instanceof ScopeViolationError) return error.message; throw error; diff --git a/packages/gitlab-mcp/src/configuration/scope-targets.ts b/packages/gitlab-mcp/src/configuration/scope-targets.ts index 3166f251..5e01e265 100644 --- a/packages/gitlab-mcp/src/configuration/scope-targets.ts +++ b/packages/gitlab-mcp/src/configuration/scope-targets.ts @@ -29,30 +29,63 @@ const hasValue = (value: unknown): boolean => value !== undefined && value !== n export function targetlessRestriction(tool: string, args: Args, scope: ScopeConfig): string | null { const creation = creationRestriction(tool, args, scope); if (creation) return creation; - if (tool !== 'browse_search' || args.action !== 'global') return null; + if (tool === 'browse_vulnerabilities' && isTargetlessList(args)) { + return unnarrowable(scope, 'vulnerabilities cannot be listed', 'list them for'); + } + if (tool === 'browse_search' && args.action === 'global') { + return unnarrowable(scope, 'a global search cannot be limited', 'search within'); + } + return null; +} + +/** + * Why a call GitLab only offers per project or per whole group cannot follow the scope: + * several targets, or a group without its subgroups (a group's results always cover them). + */ +function unnarrowable(scope: ScopeConfig, what: string, instead: string): string | null { const target = singleTarget(scope); if (!target) { - return 'a global search cannot be limited to a working scope of several projects or groups; search within one of them'; + return `${what} for a working scope of several projects or groups; ${instead} one of them`; } if ('group' in target && scope.includeSubgroups === false) { - return 'a global search cannot be limited to a group without its subgroups; search within one of its projects'; + return `${what} to a group without its subgroups; ${instead} one of its projects`; } return null; } +/** A project created or forked: its `namespace` names the group it goes into. */ +function isProjectCreation(tool: string, args: Args): boolean { + return tool === 'manage_project' && (args.action === 'create' || args.action === 'fork'); +} + +/** Whether a project created or forked names the group it goes into. */ +function namesDestination(args: Args): boolean { + return hasValue(args.namespace) || hasValue(args.namespace_path); +} + +/** + * The call's arguments as the scope check reads them. A created or forked project's + * `namespace` is the group it goes into, so it is checked as a group: the scope group itself + * is a valid place also when its subgroups are not part of the scope. + */ +export function scopeCheckArgs(tool: string, args: Args): Args { + if (!isProjectCreation(tool, args) || !hasValue(args.namespace)) return args; + const { namespace, ...rest } = args; + return { ...rest, namespace_path: rest.namespace_path ?? namespace }; +} + /** - * Creation that names no place lands outside the scope: a project without a namespace in - * the user's own namespace (filled in with the scope group when there is a single one), a - * group without a parent at the top level. + * Creation that names no place lands outside the scope: a project created or forked without + * a namespace in the user's own namespace (filled in with the scope group when there is a + * single one), a group without a parent at the top level. */ function creationRestriction(tool: string, args: Args, scope: ScopeConfig): string | null { - if (args.action !== 'create') return null; - if (tool === 'manage_project' && !hasValue(args.namespace)) { + if (isProjectCreation(tool, args) && !namesDestination(args)) { const target = singleTarget(scope); if (target && 'group' in target) return null; - return "a project without a namespace would be created outside the working scope; name the group to create it in with 'namespace'"; + return "a project without a namespace would be created outside the working scope; name the group to put it in with 'namespace'"; } - if (tool === 'manage_namespace' && !hasValue(args.parent_id)) { + if (tool === 'manage_namespace' && args.action === 'create' && !hasValue(args.parent_id)) { return "a top-level group would be outside the working scope; create it inside a group of the scope with 'parent_id'"; } return null; @@ -68,32 +101,54 @@ export function scopeProjectsOnly(scope: ScopeConfig): string[] | null { return projects.length > 0 ? projects : null; } -/** The call's arguments with the scope as its filter, where the tool takes one. */ -export function scopedArgs(tool: string, args: Args, scope: ScopeConfig): Args { - const target = singleTarget(scope); - if (!target) return args; - if (tool === 'browse_search' && args.action === 'global') { +type Target = { group: string } | { project: string }; + +/** A listing that names neither a project nor a group. */ +const isTargetlessList = (args: Args): boolean => + args.action === 'list' && !hasValue(args.project_id) && !hasValue(args.group_id); + +/** + * Per tool: the call's arguments with the single scope target as their filter, or null + * when the call keeps its own arguments. + */ +const NARROWERS: Record Args | null> = { + browse_search: (args, target) => { + if (args.action !== 'global') return null; // Group search covers subgroups; a scope without them is refused before this point. return 'group' in target ? { ...args, action: 'group', group_id: target.group } : { ...args, action: 'project', project_id: target.project }; - } - if (tool === 'browse_projects' && args.action === 'list' && !hasValue(args.group_id)) { - if ('group' in target) { - return { - ...args, - group_id: target.group, - include_subgroups: scope.includeSubgroups !== false, - }; - } - } - if (tool === 'browse_merge_requests' && args.action === 'list' && !hasValue(args.project_id)) { - if ('project' in target) return { ...args, project_id: target.project }; - } - if (tool === 'manage_project' && args.action === 'create' && !hasValue(args.namespace)) { - if ('group' in target) return { ...args, namespace: target.group }; - } - return args; + }, + browse_projects: (args, target, scope) => + args.action === 'list' && !hasValue(args.group_id) && 'group' in target + ? { ...args, group_id: target.group, include_subgroups: scope.includeSubgroups !== false } + : null, + browse_merge_requests: (args, target) => + args.action === 'list' && !hasValue(args.project_id) && 'project' in target + ? { ...args, project_id: target.project } + : null, + browse_vulnerabilities: (args, target) => { + if (!isTargetlessList(args)) return null; + return 'group' in target + ? { ...args, group_id: target.group } + : { ...args, project_id: target.project }; + }, + manage_project: (args, target) => { + if (!isProjectCreation('manage_project', args) || namesDestination(args)) return null; + if (!('group' in target)) return null; + // Fork takes the destination as namespace_path, create as namespace. + return args.action === 'fork' + ? { ...args, namespace_path: target.group } + : { ...args, namespace: target.group }; + }, +}; + +/** The call's arguments with the scope as its filter, where the tool takes one. */ +export function scopedArgs(tool: string, args: Args, scope: ScopeConfig): Args { + const target = singleTarget(scope); + const narrow = NARROWERS[tool]; + if (!target || !narrow) return args; + return narrow(args, target, scope) ?? args; } /** Project path of a listed project or merge request, as each listing reports it. */ @@ -108,6 +163,19 @@ function projectPathOf(tool: string, item: Record): string | un : undefined; } +/** A listing of projects or merge requests whose rows are filtered by their project. */ +function isProjectRowListing(tool: string, args: Args): boolean { + return ( + (tool === 'browse_projects' && (args.action === 'search' || args.action === 'list')) || + (tool === 'browse_merge_requests' && args.action === 'list' && !hasValue(args.project_id)) + ); +} + +/** Whether a call's results are filtered to the scope after GitLab returned them. */ +export function isFilteredListing(tool: string, args: Args): boolean { + return isProjectRowListing(tool, args) || (tool === 'browse_todos' && isTargetlessList(args)); +} + /** * The results of a listing narrowed to the scope. Project listings are always filtered: * a group's listing also returns projects only shared with it from other namespaces. @@ -121,21 +189,13 @@ export function scopedResult( enforcer: ScopeEnforcer, ): unknown { if (!Array.isArray(result)) return result; - const listing = - (tool === 'browse_projects' && (args.action === 'search' || args.action === 'list')) || - (tool === 'browse_merge_requests' && args.action === 'list' && !hasValue(args.project_id)); - if (listing) { + if (isProjectRowListing(tool, args)) { return result.filter((item: Record) => { const path = projectPathOf(tool, item); return path !== undefined && enforcer.isAllowed(path); }); } - if ( - tool === 'browse_todos' && - args.action === 'list' && - !hasValue(args.project_id) && - !hasValue(args.group_id) - ) { + if (tool === 'browse_todos' && isTargetlessList(args)) { return result.filter((todo: Record) => { const project = (todo.project as { path_with_namespace?: unknown } | undefined) ?.path_with_namespace; diff --git a/packages/gitlab-mcp/src/configuration/scoped-execution.ts b/packages/gitlab-mcp/src/configuration/scoped-execution.ts new file mode 100644 index 00000000..8eeb23ca --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/scoped-execution.ts @@ -0,0 +1,159 @@ +/** + * Calls under a working scope that one GitLab request cannot answer: listings whose rows + * are filtered after GitLab paginated them, the projects of a scope made of projects, and + * mark_all_done, which GitLab would apply to every todo. + */ + +import type { EffectivePolicy } from './policy'; +import type { ScopeEnforcer } from '../profiles/scope-enforcer'; +import { parseGitLabApiError } from '../utils/error-handler'; +import { isFilteredListing, scopedArgs, scopedResult, scopeProjectsOnly } from './scope-targets'; + +type Args = Record; +/** Runs one tool call with the caller's instance and token scopes. */ +export type RunTool = (tool: string, args: Args) => Promise; + +/** GitLab's largest page, so a scoped page needs as few requests as possible. */ +const UPSTREAM_PAGE_SIZE = 100; +/** Bounds the requests one scoped page may cost: up to 1000 rows read. */ +const MAX_UPSTREAM_PAGES = 10; +/** GitLab's own default page size, used when the call names none. */ +const DEFAULT_PAGE_SIZE = 20; +/** Listing options a scope made of projects can apply to its projects itself. */ +const PROJECT_LIST_OPTIONS = new Set(['action', 'search', 'page', 'per_page', 'simple']); +const TODO_CONCURRENCY = 5; + +const numberOr = (value: unknown, fallback: number): number => + typeof value === 'number' && value > 0 ? value : fallback; + +/** Runs a call for the caller's working scope, narrowing or emulating where needed. */ +export async function executeScoped( + run: RunTool, + tool: string, + requested: Args, + policy: EffectivePolicy, +): Promise { + const { scope, scopeEnforcer } = policy; + if (!scope || !scopeEnforcer) return run(tool, requested); + if (tool === 'manage_todos' && requested.action === 'mark_all_done') { + return markScopeTodosDone(run, scopeEnforcer); + } + const projects = scopeProjectsOnly(scope); + if ( + projects && + tool === 'browse_projects' && + requested.action === 'list' && + Object.keys(requested).every((key) => PROJECT_LIST_OPTIONS.has(key)) + ) { + return listScopeProjects(run, projects, requested); + } + const args = scopedArgs(tool, requested, scope); + if (isFilteredListing(tool, args)) return fillScopedPage(run, tool, args, scopeEnforcer); + return run(tool, args); +} + +/** + * The requested page of a listing filtered to the scope. GitLab paginates before the scope + * filters, so its pages are read in turn until the scoped page is full or GitLab has no + * more rows; at most MAX_UPSTREAM_PAGES pages are read for one call. + */ +async function fillScopedPage( + run: RunTool, + tool: string, + args: Args, + enforcer: ScopeEnforcer, +): Promise { + const perPage = numberOr(args.per_page, DEFAULT_PAGE_SIZE); + const page = numberOr(args.page, 1); + const wanted = page * perPage; + const rows: unknown[] = []; + // Pages are read one after another: whether the next one is needed depends on this one. + for (let upstream = 1; upstream <= MAX_UPSTREAM_PAGES && rows.length < wanted; upstream++) { + const result = await run(tool, { ...args, page: upstream, per_page: UPSTREAM_PAGE_SIZE }); + if (!Array.isArray(result)) return result; + rows.push(...(scopedResult(tool, args, result, enforcer) as unknown[])); + if (result.length < UPSTREAM_PAGE_SIZE) break; + } + return rows.slice((page - 1) * perPage, wanted); +} + +/** + * browse_projects list for a scope made of projects: the projects themselves, filtered by + * the listing's search and paged like it. A project GitLab no longer has (404) is left + * out; any other failure fails the call rather than returning a shorter list. + */ +async function listScopeProjects( + run: RunTool, + projects: string[], + requested: Args, +): Promise { + const found = await Promise.all( + projects.map((project_id) => + run('browse_projects', { action: 'get', project_id }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + if (parseGitLabApiError(message)?.status === 404) return undefined; + throw error; + }), + ), + ); + const search = typeof requested.search === 'string' ? requested.search.toLowerCase() : ''; + const matching = found.filter((project) => { + if (project === undefined) return false; + if (!search) return true; + const { name, path_with_namespace } = project as { + name?: unknown; + path_with_namespace?: unknown; + }; + return [name, path_with_namespace].some( + (value) => typeof value === 'string' && value.toLowerCase().includes(search), + ); + }); + const perPage = numberOr(requested.per_page, DEFAULT_PAGE_SIZE); + const page = numberOr(requested.page, 1); + return matching.slice((page - 1) * perPage, page * perPage); +} + +/** + * mark_all_done for a scoped chat: GitLab's own would also clear todos outside the scope, + * so the scope's pending todos are marked done one by one. + */ +async function markScopeTodosDone(run: RunTool, enforcer: ScopeEnforcer): Promise { + // Collected before marking: a todo marked done leaves the pending pages being read. + const ids: unknown[] = []; + // Pages are read one after another: whether the next one is needed depends on this one. + for (let page = 1; ; page++) { + const todos = await run('browse_todos', { + action: 'list', + state: 'pending', + per_page: UPSTREAM_PAGE_SIZE, + page, + }); + if (!Array.isArray(todos)) break; + const inScope = scopedResult('browse_todos', { action: 'list' }, todos, enforcer) as Array<{ + id?: unknown; + }>; + for (const todo of inScope) ids.push(todo.id); + if (todos.length < UPSTREAM_PAGE_SIZE) break; + } + // A few requests at a time, so a large scope does not run into GitLab's rate limits; + // a todo that fails is reported and the rest are still marked. + const failed: Array<{ id: unknown; error: string }> = []; + let next = 0; + const worker = async (): Promise => { + while (next < ids.length) { + const id = ids[next++]; + await run('manage_todos', { action: 'mark_done', id }).catch((error: unknown) => { + failed.push({ id, error: error instanceof Error ? error.message : String(error) }); + }); + } + }; + await Promise.all(Array.from({ length: Math.min(TODO_CONCURRENCY, ids.length) }, worker)); + const marked = ids.length - failed.length; + const failedNote = failed.length > 0 ? `; ${failed.length} could not be marked` : ''; + return { + success: failed.length === 0, + marked, + failed, + message: `Marked ${marked} of ${ids.length} todos of the working scope as done${failedNote}; todos outside it were left pending`, + }; +} diff --git a/packages/gitlab-mcp/src/configuration/service.ts b/packages/gitlab-mcp/src/configuration/service.ts index 13c06170..31b1f199 100644 --- a/packages/gitlab-mcp/src/configuration/service.ts +++ b/packages/gitlab-mcp/src/configuration/service.ts @@ -85,22 +85,10 @@ function applyAccountPatch( ): AccountSettings { const next = applyPatch(saved, fields); if (scope === null) delete next.scope; - else if (scope) { - // Only a saved group scope says anything about subgroups; a project's flag is always false. - const savedChoice = saved.scope?.type === 'group' ? saved.scope.includeSubgroups : undefined; - next.scope = { - ...scope, - includeSubgroups: scope.type === 'group' && (scope.includeSubgroups ?? savedChoice ?? true), - }; - } + else if (scope) next.scope = scopeAtWrite(scope, saved.scope); if (toolGroups) { - const disabled = new Set(next.disabledToolGroups ?? []); - for (const [group, enabled] of Object.entries(toolGroups)) { - if (enabled) disabled.delete(group); - else disabled.add(group); - } - if (disabled.size > 0) - next.disabledToolGroups = [...disabled].sort((a, b) => a.localeCompare(b)); + const disabled = disabledGroupsAtWrite(next.disabledToolGroups, toolGroups); + if (disabled.length > 0) next.disabledToolGroups = disabled; else delete next.disabledToolGroups; } if (scopeIncludeSubgroups !== undefined && next.scope?.type === 'group') { @@ -109,6 +97,29 @@ function applyAccountPatch( return next; } +/** A new scope; a group without a subgroup choice keeps the one saved for a group scope. */ +function scopeAtWrite(scope: ScopePatch, saved: WorkingScope | undefined): WorkingScope { + // Only a saved group scope says anything about subgroups; a project's flag is always false. + const savedChoice = saved?.type === 'group' ? saved.includeSubgroups : undefined; + return { + ...scope, + includeSubgroups: scope.type === 'group' && (scope.includeSubgroups ?? savedChoice ?? true), + }; +} + +/** The groups turned off after these toggles are applied to the saved ones, sorted. */ +function disabledGroupsAtWrite( + saved: string[] | undefined, + toggles: Record, +): string[] { + const disabled = new Set(saved ?? []); + for (const [group, enabled] of Object.entries(toggles)) { + if (enabled) disabled.delete(group); + else disabled.add(group); + } + return [...disabled].sort((a, b) => a.localeCompare(b)); +} + function applyPatch(base: T, patch: object): T { const next: Record = { ...(base as Record) }; for (const [key, value] of Object.entries(patch)) { diff --git a/packages/gitlab-mcp/src/configuration/settings-store.ts b/packages/gitlab-mcp/src/configuration/settings-store.ts index d3408c4f..5a598fc1 100644 --- a/packages/gitlab-mcp/src/configuration/settings-store.ts +++ b/packages/gitlab-mcp/src/configuration/settings-store.ts @@ -1,9 +1,10 @@ /** - * Where account settings are kept. With OAuth or an explicitly configured session storage - * they live in that backend, shared by every replica. A local single-user server keeps - * them in a file in the user's configuration directory. + * Where account settings are kept: in the session storage when it can keep them, otherwise + * in a file in the configuration directory of the user running the server, shared by every + * server process of that user. */ +import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -36,6 +37,10 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + /** * Settings of a local server in one JSON file. Every read goes to the file, and a write * holds a lock file around read-compare-write, so several local server processes (one per @@ -77,13 +82,26 @@ export class LocalSettingsFile implements SettingsStore { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, accounts: {} }; throw error; } - const parsed = JSON.parse(content) as Partial; + const parsed: unknown = JSON.parse(content); + if (!isPlainObject(parsed)) { + throw new Error(`Settings file is not a JSON object: ${this.filePath}`); + } const accounts: Record = {}; - for (const [key, record] of Object.entries(parsed.accounts ?? {})) { - // A hand-edited entry the schema refuses is ignored rather than applied half-valid. - const settings = AccountSettingsSchema.safeParse(record.settings); - if (settings.success) accounts[key] = { ...record, settings: settings.data }; - else logWarn('Ignoring invalid saved settings', { file: this.filePath }); + const stored = isPlainObject(parsed.accounts) ? parsed.accounts : {}; + for (const [key, record] of Object.entries(stored)) { + // A hand-edited entry of the wrong shape, or settings the schema refuses, is ignored + // rather than applied half-valid or failing every call. + const settings = isPlainObject(record) + ? AccountSettingsSchema.safeParse(record.settings) + : undefined; + if (settings?.success) { + accounts[key] = { + ...(record as Omit), + settings: settings.data, + }; + } else { + logWarn('Ignoring invalid saved settings', { file: this.filePath }); + } } return { version: 1, accounts }; } @@ -128,16 +146,38 @@ export class LocalSettingsFile implements SettingsStore { } } + /** + * Removes a lock left by a dead process; true when the lock can be taken again at once. + * The lock is claimed by an atomic rename and its identity checked, because between the + * age check and the removal another process may have replaced the dead lock with a fresh + * one; that one is put back instead of removed. + */ private async removeStaleLock(lockPath: string): Promise { + let seen: fs.Stats; try { - const stat = await fs.promises.stat(lockPath); - if (Date.now() - stat.mtimeMs < STALE_LOCK_MS) return false; - await fs.promises.rm(lockPath, { force: true }); - return true; + seen = await fs.promises.stat(lockPath); } catch { // Released meanwhile: try again at once. return true; } + if (Date.now() - seen.mtimeMs < STALE_LOCK_MS) return false; + const claimed = `${lockPath}.${process.pid}.${randomUUID()}.stale`; + try { + await fs.promises.rename(lockPath, claimed); + } catch { + // Another process removed or claimed it first. + return true; + } + const taken = await fs.promises.stat(claimed); + if (taken.ino !== seen.ino || taken.dev !== seen.dev) { + // A fresh lock taken after the age check: hand it back to its holder. If the path was + // taken again meanwhile, that newer lock stays and this one is dropped. + await fs.promises.link(claimed, lockPath).catch(() => undefined); + await fs.promises.rm(claimed, { force: true }); + return false; + } + await fs.promises.rm(claimed, { force: true }); + return true; } } diff --git a/packages/gitlab-mcp/src/entities/context/connection-check.ts b/packages/gitlab-mcp/src/entities/context/connection-check.ts index f31e27bf..3f623d24 100644 --- a/packages/gitlab-mcp/src/entities/context/connection-check.ts +++ b/packages/gitlab-mcp/src/entities/context/connection-check.ts @@ -6,7 +6,7 @@ import { GITLAB_BASE_URL } from '../../config'; import { currentCaller, getConfigurationService, resolveCaller } from '../../configuration'; -import { toolRestriction } from '../../configuration/policy'; +import { scopeLabel, toolRestriction } from '../../configuration/policy'; import { getTokenContext } from '../../oauth/token-context'; import { isToolAvailableForScopes } from '../../services/TokenScopeDetector'; import { executeWhoami } from './whoami'; @@ -92,7 +92,7 @@ export async function checkConnection(): Promise { authenticated: whoami.user !== null, readOnly: whoami.server.readOnlyMode || resolved.policy.readOnly, preset: resolved.policy.presetName ?? null, - scope: scope?.project ?? scope?.group ?? scope?.namespace ?? null, + scope: scopeLabel(scope) ?? null, availableTools, warnings, recommendations, diff --git a/packages/gitlab-mcp/src/handlers.ts b/packages/gitlab-mcp/src/handlers.ts index 74d293b6..88c01152 100644 --- a/packages/gitlab-mcp/src/handlers.ts +++ b/packages/gitlab-mcp/src/handlers.ts @@ -49,11 +49,11 @@ import { import { CONFIGURATION_TOOLS, callRestriction, + scopeLabel, toolRestriction, type EffectivePolicy, } from './configuration/policy'; -import { scopedArgs, scopedResult, scopeProjectsOnly } from './configuration/scope-targets'; -import type { ScopeEnforcer } from './profiles/scope-enforcer'; +import { executeScoped } from './configuration/scoped-execution'; interface JsonSchemaProperty { type?: string; @@ -519,103 +519,7 @@ async function executeInScope( requestScopes === undefined ? registryManager.executeTool(tool, toolArgs, instanceUrl) : registryManager.executeTool(tool, toolArgs, instanceUrl, requestScopes); - if (policy.scopeEnforcer && toolName === 'manage_todos' && requested.action === 'mark_all_done') { - return markScopeTodosDone(run, policy.scopeEnforcer); - } - const scopeProjects = - policy.scope && toolName === 'browse_projects' && requested.action === 'list' - ? scopeProjectsOnly(policy.scope) - : null; - if (scopeProjects && requested.group_id === undefined) { - return listScopeProjects(run, scopeProjects, requested); - } - const args = policy.scope ? scopedArgs(toolName, requested, policy.scope) : requested; - const executed = await run(toolName, args); - return policy.scopeEnforcer - ? scopedResult(toolName, args, executed, policy.scopeEnforcer) - : executed; -} - -/** - * browse_projects list for a scope made of projects: the projects themselves, filtered by - * the listing's search and paged like it. A project GitLab does not return is left out. - */ -async function listScopeProjects( - run: (tool: string, args: Record) => Promise, - projects: string[], - requested: Record, -): Promise { - const found = await Promise.allSettled( - projects.map((project_id) => run('browse_projects', { action: 'get', project_id })), - ); - const search = typeof requested.search === 'string' ? requested.search.toLowerCase() : ''; - const matching = found - .flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])) - .filter((project) => { - if (!search) return true; - const { name, path_with_namespace } = project as { - name?: unknown; - path_with_namespace?: unknown; - }; - return [name, path_with_namespace].some( - (value) => typeof value === 'string' && value.toLowerCase().includes(search), - ); - }); - const perPage = typeof requested.per_page === 'number' ? requested.per_page : 20; - const page = typeof requested.page === 'number' ? requested.page : 1; - return matching.slice((page - 1) * perPage, page * perPage); -} - -const TODO_PAGE_SIZE = 100; -const TODO_CONCURRENCY = 5; - -/** - * mark_all_done for a scoped chat: GitLab's own would also clear todos outside the scope, - * so the scope's pending todos are marked done one by one. - */ -async function markScopeTodosDone( - run: (tool: string, args: Record) => Promise, - enforcer: ScopeEnforcer, -): Promise { - // Collected before marking: a todo marked done leaves the pending pages being read. - const ids: unknown[] = []; - for (let page = 1; ; page++) { - const todos = await run('browse_todos', { - action: 'list', - state: 'pending', - per_page: TODO_PAGE_SIZE, - page, - }); - if (!Array.isArray(todos)) break; - const inScope = scopedResult('browse_todos', { action: 'list' }, todos, enforcer) as Array<{ - id?: unknown; - }>; - for (const todo of inScope) ids.push(todo.id); - if (todos.length < TODO_PAGE_SIZE) break; - } - // A few requests at a time, so a large scope does not run into GitLab's rate limits; - // a todo that fails is reported and the rest are still marked. - const failed: Array<{ id: unknown; error: string }> = []; - let next = 0; - const worker = async (): Promise => { - while (next < ids.length) { - const id = ids[next++]; - try { - await run('manage_todos', { action: 'mark_done', id }); - } catch (error: unknown) { - failed.push({ id, error: error instanceof Error ? error.message : String(error) }); - } - } - }; - await Promise.all(Array.from({ length: Math.min(TODO_CONCURRENCY, ids.length) }, worker)); - const marked = ids.length - failed.length; - const failedNote = failed.length > 0 ? `; ${failed.length} could not be marked` : ''; - return { - success: failed.length === 0, - marked, - failed, - message: `Marked ${marked} of ${ids.length} todos of the working scope as done${failedNote}; todos outside it were left pending`, - }; + return executeScoped(run, toolName, requested, policy); } function recordCallContext(scopePath: string | undefined, readOnly: boolean): void { @@ -1111,9 +1015,8 @@ export async function setupHandlers(server: Server): Promise { if (LOG_FORMAT === 'condensed') { const requestTracker = getRequestTracker(); requestTracker.setToolForCurrentRequest(toolName, action); - const scope = configuration.policy.scope; recordCallContext( - scope?.project ?? scope?.group ?? scope?.namespace ?? scope?.projects?.[0], + scopeLabel(configuration.policy.scope), GITLAB_READ_ONLY_MODE || configuration.policy.readOnly, ); } diff --git a/packages/gitlab-mcp/src/oauth/storage/file.ts b/packages/gitlab-mcp/src/oauth/storage/file.ts index 4c31d721..110ee352 100644 --- a/packages/gitlab-mcp/src/oauth/storage/file.ts +++ b/packages/gitlab-mcp/src/oauth/storage/file.ts @@ -480,7 +480,9 @@ export class FileStorageBackend implements SessionStorageBackend { expectedVersion: number, ): Promise { // Written through and undone when the write fails: settings reported as not saved - // must not apply now or after a restart. + // must not apply now or after a restart. The compare-and-set runs against this + // process's state: file storage serves one process, as for its sessions, and replicas + // that share storage use PostgreSQL. let previous: AccountSettingsRecord | undefined; return this.transition( async () => { diff --git a/packages/gitlab-mcp/src/profiles/scope-enforcer.ts b/packages/gitlab-mcp/src/profiles/scope-enforcer.ts index 3b90d12c..1f530527 100644 --- a/packages/gitlab-mcp/src/profiles/scope-enforcer.ts +++ b/packages/gitlab-mcp/src/profiles/scope-enforcer.ts @@ -67,12 +67,13 @@ function getScopeDescription(scope: ScopeConfig): string { /** * Normalize a project path for comparison * + * - Decodes a URL-encoded path ("group%2Fproject"), which tools accept as well * - Removes leading/trailing slashes * - Converts to lowercase * - Handles numeric IDs (returns as-is) */ function normalizeProjectPath(path: string): string { - const trimmed = path.trim().replace(/^\/+|\/+$/g, ''); + const trimmed = decodePath(path.trim()).replace(/^\/+|\/+$/g, ''); // If it's a numeric ID, return as-is if (/^\d+$/.test(trimmed)) { return trimmed; @@ -80,6 +81,16 @@ function normalizeProjectPath(path: string): string { return trimmed.toLowerCase(); } +/** A URL-encoded path decoded; a malformed escape is kept as written. */ +function decodePath(path: string): string { + if (!path.includes('%')) return path; + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + /** * Check if a path matches a namespace (used for both projects and groups) * diff --git a/packages/gitlab-mcp/src/utils/gitlab-base-url.ts b/packages/gitlab-mcp/src/utils/gitlab-base-url.ts index 8b05646c..196c3715 100644 --- a/packages/gitlab-mcp/src/utils/gitlab-base-url.ts +++ b/packages/gitlab-mcp/src/utils/gitlab-base-url.ts @@ -27,7 +27,7 @@ export function getGitLabBaseUrl(): string { if (isOAuthEnabled()) { const apiUrl = getGitLabApiUrlFromContext(); if (apiUrl) { - return apiUrl; + return normalizeInstanceUrl(apiUrl); } logWarn('OAuth mode: no API URL in context, falling back to global config'); } diff --git a/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts b/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts index 386ade96..750bfa24 100644 --- a/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts +++ b/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts @@ -232,6 +232,34 @@ describe('ChannelGateway', () => { } }); + // A complete catalog that no longer lists a tool drops its verdict; a page of a paginated + // catalog does not, since the tool may be on another page. + it.each([ + [undefined, false], + ['page-2', true], + ])('forgets a removed tool after a complete catalog (cursor %s)', async (cursor, replayed) => { + mockClientListTools + .mockResolvedValueOnce({ tools: [{ name: 'run_task', annotations: { readOnlyHint: true } }] }) + .mockResolvedValueOnce({ tools: [{ name: 'other_tool' }] }); + const gw = new ChannelGateway(baseConfig); + await gw.start(); + await listTools(); + await mockServerHandlers.get(ListToolsRequestSchema)!({ params: cursor ? { cursor } : {} }); + dropLinkOnNextCall(); + + try { + if (replayed) { + expect(await callTool('run_task')).toEqual(mcp({ replayed: true })); + } else { + await expect(callTool('run_task')).rejects.toThrow(); + expect(mockClientCallTool).toHaveBeenCalledTimes(1); + } + } finally { + mockClientCallTool.mockReset(); + await gw.stop(); + } + }); + // Two catalog reads in flight: the older answer arriving last does not undo the newer one. it('keeps the newest catalog verdict when catalog answers arrive out of order', async () => { let answerOlder: (value: unknown) => void = () => {}; diff --git a/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts b/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts index 3347934a..e1cc35b2 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts @@ -148,6 +148,20 @@ describe('working everywhere in one chat', () => { }); }); +// The host identifies the connection's account with get_profile: a preset's tool list or +// read-only mode must not hide it. +it('keeps the account profile tool under any preset', () => { + const policy = buildPolicy( + { allowed_tools: ['browse_projects'], denied_tools_regex: '^get_', read_only: true }, + { preset: 'narrow' }, + {}, + ); + const profile: ToolFacts = { name: 'get_profile', readOnly: true }; + + expect(toolRestriction(policy, profile)).toBeNull(); + expect(callRestriction(policy, profile, {})).toBeNull(); +}); + describe('callRestriction', () => { it('refuses an action the preset denies and allows the others', () => { const policy = buildPolicy( @@ -175,6 +189,26 @@ describe('callRestriction', () => { expect(callRestriction(policy, browse, { project_id: 'team/sub/app' })).toBeNull(); }); + // The namespace of a created or forked project is the group it goes into: naming the scope + // group itself is allowed also when its subgroups are not part of the scope. + it.each([ + [{ action: 'create', name: 'app', namespace: 'team' }, null], + [{ action: 'fork', project_id: 'team/app', namespace: 'team' }, null], + [{ action: 'create', name: 'app', namespace: 'team/sub' }, /outside the allowed scope/], + [{ action: 'create', name: 'app', namespace: 'other' }, /outside the allowed scope/], + ])('checks the namespace of %j as the group it goes into', (args, expected) => { + const policy = buildPolicy( + undefined, + { scope: { type: 'group', path: 'team', includeSubgroups: false } }, + {}, + ); + const project: ToolFacts = { name: 'manage_project', readOnly: false }; + + const reason = callRestriction(policy, project, args); + if (expected === null) expect(reason).toBeNull(); + else expect(reason).toMatch(expected); + }); + // A numeric id cannot be checked against a path scope without a lookup: refused. it('refuses a numeric project id under a scope', () => { const policy = buildPolicy( diff --git a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts index 7840033b..4b815de3 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts @@ -5,8 +5,10 @@ */ import { + isFilteredListing, scopedArgs, scopedResult, + scopeProjectsOnly, targetlessRestriction, } from '../../../src/configuration/scope-targets'; import { ScopeEnforcer } from '../../../src/profiles/scope-enforcer'; @@ -42,6 +44,21 @@ describe('scopedArgs', () => { }); }); + // Without a target namespace GitLab forks into the user's own namespace. + it('forks into the scope group when no target namespace is named', () => { + expect(scopedArgs('manage_project', { action: 'fork', project_id: 'team/app' }, group)).toEqual( + { action: 'fork', project_id: 'team/app', namespace_path: 'team' }, + ); + }); + + // A vulnerability listing without a target is instance-wide. + it.each([ + [group, { action: 'list', group_id: 'team' }], + [project, { action: 'list', project_id: 'team/app' }], + ])('lists the vulnerabilities of the scope %j', (scope, expected) => { + expect(scopedArgs('browse_vulnerabilities', { action: 'list' }, scope)).toEqual(expected); + }); + it('lists the merge requests of the scope project', () => { expect( scopedArgs('browse_merge_requests', { action: 'list', state: 'opened' }, project), @@ -58,11 +75,42 @@ describe('scopedArgs', () => { ['browse_projects', { action: 'list' }, several], ['browse_projects', { action: 'list' }, { project: 'team/app', group: 'other' }], ['browse_issues', { action: 'list' }, group], + ['browse_vulnerabilities', { action: 'list', project_id: 'team/app' }, group], + ['browse_vulnerabilities', { action: 'get', id: 'gid://x' }, group], + ['manage_project', { action: 'create', name: 'app', namespace: 'team/sub' }, group], + ['manage_project', { action: 'delete', project_id: 'team/app' }, group], + ['manage_project', { action: 'create', name: 'app' }, project], ])('leaves %s %j under %j as it is', (tool, args, scope) => { expect(scopedArgs(tool, args, scope)).toBe(args); }); }); +describe('scopeProjectsOnly', () => { + it.each([ + [{ project: 'team/app' }, ['team/app']], + [{ projects: ['team/app', 'team/api'] }, ['team/app', 'team/api']], + [{ project: 'team/app', projects: ['team/api'] }, ['team/app', 'team/api']], + [{}, null], + [{ group: 'team' }, null], + [{ projects: ['team/app'], groups: ['ops'] }, null], + ])('reads %j as the projects %j', (scope, expected) => { + expect(scopeProjectsOnly(scope)).toEqual(expected); + }); +}); + +describe('isFilteredListing', () => { + it.each([ + ['browse_projects', { action: 'search' }, true], + ['browse_merge_requests', { action: 'list' }, true], + ['browse_merge_requests', { action: 'list', project_id: 'team/app' }, false], + ['browse_todos', { action: 'list' }, true], + ['browse_todos', { action: 'list', project_id: 7 }, false], + ['browse_issues', { action: 'list' }, false], + ])('%s %j is filtered: %s', (tool, args, expected) => { + expect(isFilteredListing(tool, args)).toBe(expected); + }); +}); + describe('targetlessRestriction', () => { // GitLab's group search always covers subgroups; results are not filterable by path. it('refuses a global search under a group scope that excludes subgroups', () => { @@ -82,17 +130,33 @@ describe('targetlessRestriction', () => { it.each([ ['manage_project', { action: 'create', name: 'app' }, project], ['manage_project', { action: 'create', name: 'app' }, several], + ['manage_project', { action: 'fork', project_id: 'team/app' }, project], ['manage_namespace', { action: 'create', name: 'Team', path: 'team2' }, group], ])('refuses %s %j under %j', (tool, args, scope) => { expect(targetlessRestriction(tool, args, scope)).toMatch(/outside the working scope/); }); + it('refuses a vulnerability listing a scope of several targets cannot narrow', () => { + expect(targetlessRestriction('browse_vulnerabilities', { action: 'list' }, several)).toMatch( + /list them for one of them/, + ); + }); + + // A group's vulnerability list always covers its subgroups. + it('refuses a vulnerability listing under a group scope without subgroups', () => { + expect(targetlessRestriction('browse_vulnerabilities', { action: 'list' }, groupOnly)).toMatch( + /subgroups/, + ); + }); + it.each([ ['browse_search', { action: 'global' }, group], ['browse_search', { action: 'group', group_id: 'team' }, several], ['browse_projects', { action: 'list' }, several], ['manage_project', { action: 'create', name: 'app' }, group], ['manage_project', { action: 'create', name: 'app', namespace: 'team/sub' }, project], + ['manage_project', { action: 'fork', project_id: 'team/app', namespace_path: 'x' }, project], + ['manage_project', { action: 'fork', project_id: 'team/app' }, group], ['manage_namespace', { action: 'create', name: 'Sub', path: 'sub', parent_id: 7 }, group], ])('allows %s %j under %j', (tool, args, scope) => { expect(targetlessRestriction(tool, args, scope)).toBeNull(); diff --git a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts new file mode 100644 index 00000000..e188e18c --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts @@ -0,0 +1,73 @@ +/** + * Calls under a working scope that one GitLab request cannot answer: filtered listings read + * further pages, a scope of projects lists its projects, mark_all_done marks the scope's + * todos. These cover the answers GitLab may give besides the usual rows. + */ + +import { executeScoped } from '../../../src/configuration/scoped-execution'; +import { buildPolicy } from '../../../src/configuration/policy'; + +const groupPolicy = buildPolicy( + undefined, + { scope: { type: 'group', path: 'team', includeSubgroups: true } }, + {}, +); +const projectPolicy = buildPolicy( + undefined, + { scope: { type: 'project', path: 'team/app', includeSubgroups: false } }, + {}, +); + +describe('executeScoped', () => { + it('runs the call unchanged without a working scope', async () => { + const run = jest.fn().mockResolvedValue([{ path_with_namespace: 'other/api' }]); + + const result = await executeScoped( + run, + 'browse_projects', + { action: 'search' }, + buildPolicy(undefined, {}, {}), + ); + + expect(run).toHaveBeenCalledWith('browse_projects', { action: 'search' }); + expect(result).toEqual([{ path_with_namespace: 'other/api' }]); + }); + + // A tool may answer a listing with an object (an error envelope, a wrapped result): it is + // passed on as GitLab gave it instead of being paged. + it('passes on a listing answer that is not a list', async () => { + const run = jest.fn().mockResolvedValue({ message: 'not a list' }); + + expect(await executeScoped(run, 'browse_projects', { action: 'search' }, groupPolicy)).toEqual({ + message: 'not a list', + }); + }); + + it('marks nothing when the todo listing is not a list', async () => { + const run = jest.fn().mockResolvedValue({ message: 'not a list' }); + + expect( + await executeScoped(run, 'manage_todos', { action: 'mark_all_done' }, groupPolicy), + ).toMatchObject({ success: true, marked: 0, failed: [] }); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('reports a todo failure that is not an Error by its value', async () => { + const run = jest.fn(async (tool: string) => { + if (tool === 'browse_todos') return [{ id: 1, project: { path_with_namespace: 'team/app' } }]; + throw 'rate limited'; + }); + + expect( + await executeScoped(run, 'manage_todos', { action: 'mark_all_done' }, groupPolicy), + ).toMatchObject({ success: false, marked: 0, failed: [{ id: 1, error: 'rate limited' }] }); + }); + + it('fails a project scope listing on a failure that is not an Error', async () => { + const run = jest.fn().mockRejectedValue('connection reset'); + + await expect( + executeScoped(run, 'browse_projects', { action: 'list' }, projectPolicy), + ).rejects.toBe('connection reset'); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts b/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts index f6e5506d..d92a1c0f 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts @@ -15,6 +15,17 @@ it('points tests at a temporary settings file, not the home directory', () => { expect(localSettingsPath().startsWith(os.homedir())).toBe(false); }); +// The real location, read without touching it. +it('keeps a server settings in the user configuration directory', () => { + const actual = jest.requireActual( + '../../../src/configuration/settings-store', + ); + + expect(actual.localSettingsPath()).toBe( + path.join(os.homedir(), '.config', 'gitlab-mcp', 'settings.json'), + ); +}); + describe('LocalSettingsFile', () => { let dir: string; let filePath: string; @@ -89,6 +100,87 @@ describe('LocalSettingsFile', () => { expect((await store.get('one'))?.settings).toEqual({ readOnly: true }); }); + // Two writers see the same dead lock; one removes it and a third process takes a fresh + // lock before the second writer acts. The fresh lock must survive, or two writers would + // be inside the read-compare-write at once. + it('does not remove a fresh lock taken after the dead one was seen', async () => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const lock = `${filePath}.lock`; + fs.writeFileSync(lock, ''); + const longAgo = new Date(Date.now() - 60_000); + fs.utimesSync(lock, longAgo, longAgo); + const realStat = fs.promises.stat.bind(fs.promises); + jest.spyOn(fs.promises, 'stat').mockImplementationOnce(async (target) => { + const dead = await realStat(target); + // Another process replaced the dead lock with its own meanwhile. + fs.rmSync(lock); + fs.writeFileSync(lock, 'fresh'); + return dead; + }); + + try { + const write = new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0); + await new Promise((resolve) => setTimeout(resolve, 60)); + + expect(fs.readFileSync(lock, 'utf-8')).toBe('fresh'); + fs.rmSync(lock); + expect(await write).toBeDefined(); + } finally { + jest.restoreAllMocks(); + } + }); + + // Another writer claimed the dead lock first: the rename fails and the lock is tried again. + it('tries again when another writer claimed the dead lock first', async () => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const lock = `${filePath}.lock`; + fs.writeFileSync(lock, ''); + const longAgo = new Date(Date.now() - 60_000); + fs.utimesSync(lock, longAgo, longAgo); + jest + .spyOn(fs.promises, 'rename') + .mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + try { + expect( + await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0), + ).toBeDefined(); + } finally { + jest.restoreAllMocks(); + } + }); + + // The fresh lock could not be handed back because a newer one took the path: that newer + // lock stays, and the claimed copy is not left behind. + it('keeps a newer lock when a fresh one cannot be handed back', async () => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const lock = `${filePath}.lock`; + fs.writeFileSync(lock, ''); + const longAgo = new Date(Date.now() - 60_000); + fs.utimesSync(lock, longAgo, longAgo); + const realStat = fs.promises.stat.bind(fs.promises); + jest.spyOn(fs.promises, 'stat').mockImplementationOnce(async (target) => { + const dead = await realStat(target); + fs.rmSync(lock); + fs.writeFileSync(lock, 'fresh'); + return dead; + }); + jest + .spyOn(fs.promises, 'link') + .mockRejectedValueOnce(Object.assign(new Error('EEXIST'), { code: 'EEXIST' })); + + try { + expect( + await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0), + ).toBeDefined(); + expect(fs.readdirSync(path.dirname(filePath)).filter((f) => f.endsWith('.stale'))).toEqual( + [], + ); + } finally { + jest.restoreAllMocks(); + } + }); + it('removes a lock left by a process that died while holding it', async () => { fs.mkdirSync(path.dirname(filePath), { recursive: true }); const lock = `${filePath}.lock`; @@ -118,6 +210,47 @@ describe('LocalSettingsFile', () => { expect((await store.get('good'))?.settings).toEqual({ readOnly: true }); }); + // One hand-edited entry of the wrong shape must not fail every call of the server. + it.each([ + ['an entry that is null', { bad: null }], + ['an entry that is not an object', { bad: 'readOnly' }], + ])('skips %s and reads the others', async (_label, broken) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + version: 1, + accounts: { + ...broken, + good: { accountKey: 'good', settings: { readOnly: true }, version: 1, updatedAt: 1 }, + }, + }), + ); + const store = new LocalSettingsFile(filePath); + + expect(await store.get('bad')).toBeUndefined(); + expect((await store.get('good'))?.settings).toEqual({ readOnly: true }); + }); + + it('reads accounts that are not an object as none', async () => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ version: 1, accounts: [{ settings: {} }] })); + + expect(await new LocalSettingsFile(filePath).get('0')).toBeUndefined(); + }); + + it.each(['null', '[]', '"text"'])( + 'reports a file whose content %s is not an object', + async (content) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + + await expect(new LocalSettingsFile(filePath).get('acct')).rejects.toThrow( + `Settings file is not a JSON object: ${filePath}`, + ); + }, + ); + it('reports a file that is not JSON instead of treating it as empty', async () => { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, '{ not json'); diff --git a/packages/gitlab-mcp/tests/unit/entities/context/connection-check.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/connection-check.test.ts index a4b47295..0746832a 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/connection-check.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/connection-check.test.ts @@ -75,6 +75,8 @@ describe('checkConnection', () => { { load: async (name: string) => { if (name === 'readonly') return { read_only: true }; + if (name === 'several-projects') return { scope: { projects: ['team/app', 'team/api'] } }; + if (name === 'several-groups') return { scope: { groups: ['team', 'ops'] } }; throw new Error(`Preset not found: ${name}`); }, }, @@ -151,6 +153,18 @@ describe('checkConnection', () => { expect(check).toMatchObject({ preset: null, scope: 'team/app', readOnly: false }); }); + // A preset scope given as a list still restricts calls, so it is reported too. + it.each([ + ['several-projects', 'team/app'], + ['several-groups', 'team'], + ])('reports the scope of preset %s', async (preset, expected) => { + save({ preset }); + + const check = await runWithCaller(alice, () => checkConnection()); + + expect(check.scope).toBe(expected); + }); + it('reports read-only mode of the server', async () => { mockWhoami.mockResolvedValue( whoami({ diff --git a/packages/gitlab-mcp/tests/unit/handlers.test.ts b/packages/gitlab-mcp/tests/unit/handlers.test.ts index 759bdc9d..9d1ff652 100644 --- a/packages/gitlab-mcp/tests/unit/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/handlers.test.ts @@ -3079,10 +3079,13 @@ describe('handlers', () => { { sessionId: 'sess-1' }, ); + // Read in GitLab's largest pages: the scope filters the rows after GitLab paged them. expect(mockRegistryManager.executeTool.mock.calls[0][1]).toEqual({ action: 'list', group_id: 'team', include_subgroups: true, + page: 1, + per_page: 100, }); expect(JSON.parse(search.content?.[0].text ?? '')).toEqual([ { path_with_namespace: 'team/app' }, @@ -3121,6 +3124,108 @@ describe('handlers', () => { expect(JSON.parse(filtered.content?.[0].text ?? '')).toEqual([]); }); + // GitLab paginates before the scope filters: a page of mostly outside rows must not hide + // the matching ones on later pages. + it('reads further pages until the scoped page is filled', async () => { + await service().updateAccount(staticCaller(), { + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + const outside = Array.from({ length: 100 }, (_, i) => ({ + path_with_namespace: `other/p${i}`, + })); + mockRegistryManager.executeTool.mockImplementation( + async (_tool: string, args: Record) => + args.page === 1 ? outside : [{ path_with_namespace: 'team/app' }], + ); + + const result = await callToolHandler( + { params: { name: 'browse_projects', arguments: { action: 'search', q: 'app' } } }, + { sessionId: 'sess-1' }, + ); + + expect(mockRegistryManager.executeTool.mock.calls.map(([, args]) => args)).toEqual([ + { action: 'search', q: 'app', page: 1, per_page: 100 }, + { action: 'search', q: 'app', page: 2, per_page: 100 }, + ]); + expect(JSON.parse(result.content?.[0].text ?? '')).toEqual([ + { path_with_namespace: 'team/app' }, + ]); + }); + + it('returns the requested scoped page', async () => { + await service().updateAccount(staticCaller(), { + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + const rows = Array.from({ length: 5 }, (_, i) => ({ path_with_namespace: `team/p${i}` })); + mockRegistryManager.executeTool.mockResolvedValue(rows); + + const result = await callToolHandler( + { + params: { + name: 'browse_projects', + arguments: { action: 'search', q: 'p', page: 2, per_page: 2 }, + }, + }, + { sessionId: 'sess-1' }, + ); + + expect(JSON.parse(result.content?.[0].text ?? '')).toEqual([ + { path_with_namespace: 'team/p2' }, + { path_with_namespace: 'team/p3' }, + ]); + }); + + // List filters the scope's own projects cannot apply keep GitLab's listing semantics. + it('keeps list filters for a project scope by filtering GitLab listing pages', async () => { + await service().updateAccount(staticCaller(), { + scope: { type: 'project', path: 'team/app', includeSubgroups: false }, + }); + mockRegistryManager.executeTool.mockResolvedValue([ + { path_with_namespace: 'other/api' }, + { path_with_namespace: 'team/app' }, + ]); + + const result = await callToolHandler( + { params: { name: 'browse_projects', arguments: { action: 'list', archived: true } } }, + { sessionId: 'sess-1' }, + ); + + expect(mockRegistryManager.executeTool.mock.calls[0].slice(0, 2)).toEqual([ + 'browse_projects', + { action: 'list', archived: true, page: 1, per_page: 100 }, + ]); + expect(JSON.parse(result.content?.[0].text ?? '')).toEqual([ + { path_with_namespace: 'team/app' }, + ]); + }); + + // A scope project that is gone is left out; any other failure is reported, not hidden + // behind a shorter list. + it('reports a scope project GitLab failed to return', async () => { + await service().updateAccount(staticCaller(), { + scope: { type: 'project', path: 'team/app', includeSubgroups: false }, + }); + mockRegistryManager.executeTool.mockRejectedValueOnce( + new Error('GitLab API error: 503 Service Unavailable'), + ); + const failed = await callToolHandler( + { params: { name: 'browse_projects', arguments: { action: 'list' } } }, + { sessionId: 'sess-1' }, + ); + mockRegistryManager.executeTool.mockRejectedValueOnce( + new Error('GitLab API error: 404 Not Found'), + ); + const gone = await callToolHandler( + { params: { name: 'browse_projects', arguments: { action: 'list' } } }, + { sessionId: 'sess-1' }, + ); + + expect(failed.isError).toBe(true); + expect(failed.content?.[0].text).toContain('503'); + expect(gone.isError).toBeFalsy(); + expect(JSON.parse(gone.content?.[0].text ?? '')).toEqual([]); + }); + // Marking every todo done would also clear todos outside the scope: in a scoped chat // only the scope's pending todos are marked, one by one, over every page. it('marks only the pending todos of the working scope done', async () => { diff --git a/packages/gitlab-mcp/tests/unit/oauth/tool-auth.test.ts b/packages/gitlab-mcp/tests/unit/oauth/tool-auth.test.ts index c651186e..c1ef0998 100644 --- a/packages/gitlab-mcp/tests/unit/oauth/tool-auth.test.ts +++ b/packages/gitlab-mcp/tests/unit/oauth/tool-auth.test.ts @@ -6,8 +6,10 @@ import { loadOAuthConfig } from '../../../src/oauth/config'; import { runWithTokenContext } from '../../../src/oauth/token-context'; import { + APP_PANEL_SECURITY_SCHEMES, OAUTH_SECURITY_SCHEMES, isGitLabAuthFailure, + resourceScopeRejection, isGitLabInsufficientScope, toolScopeRejection, withReauthChallenge, @@ -116,6 +118,41 @@ describe('tool authorization contract', () => { }); }); + describe('resourceScopeRejection', () => { + const context = { + gitlabToken: 'fixture-only', + gitlabUserId: 1, + gitlabUsername: 'u', + sessionId: 's', + apiUrl: 'https://gitlab.example.com', + }; + + it('lets reads without an OAuth token context through', () => { + expect(resourceScopeRejection()).toBeUndefined(); + }); + + // RFC 6750 section 3.1: the challenge names the scope, at the default /mcp resource when + // the request recorded none. + it('refuses a tools-only token with a challenge naming mcp:resources', async () => { + const error = await runWithTokenContext({ ...context, mcpScopes: ['mcp:tools'] }, () => + resourceScopeRejection(), + ); + + expect(error?.message).toContain('insufficient_scope'); + expect((error?.data as { 'mcp/www_authenticate': string[] })['mcp/www_authenticate'][0]).toBe( + 'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", ' + + 'error="insufficient_scope", scope="mcp:resources", ' + + 'error_description="This access token does not allow resource reads; reconnect to grant mcp:resources"', + ); + }); + + it('declares both scopes for a tool that opens an app panel', () => { + expect(APP_PANEL_SECURITY_SCHEMES).toEqual([ + { type: 'oauth2', scopes: ['mcp:tools', 'mcp:resources'] }, + ]); + }); + }); + describe('withReauthChallenge', () => { it('adds an RFC 6750 challenge with error and error_description for a 401', () => { const result = withReauthChallenge(errorResult, unauthorized); diff --git a/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts b/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts index 82a34b79..84710f0d 100644 --- a/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts +++ b/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts @@ -33,6 +33,17 @@ describe('ScopeEnforcer', () => { expect(enforcer.isAllowed('other/project')).toBe(false); }); + // Tools accept URL-encoded paths ("group%2Fproject"); the scope compares decoded paths. + it('should treat a URL-encoded path as the same project or group', () => { + expect(new ScopeEnforcer({ project: 'team/app' }).isAllowed('team%2Fapp')).toBe(true); + expect(new ScopeEnforcer({ project: 'team/app' }).isAllowed('team%2Fother')).toBe(false); + const group = new ScopeEnforcer({ group: 'team', includeSubgroups: true }); + expect(group.isAllowed('team%2Fsub%2Fapp')).toBe(true); + expect(group.isGroupAllowed('team%2Fsub')).toBe(true); + // A malformed escape is compared as written instead of failing the call. + expect(new ScopeEnforcer({ project: 'team/app' }).isAllowed('team%2')).toBe(false); + }); + it('should handle leading/trailing slashes', () => { const enforcer = new ScopeEnforcer({ project: 'myteam/backend' }); diff --git a/packages/gitlab-mcp/tests/unit/utils/gitlab-base-url.test.ts b/packages/gitlab-mcp/tests/unit/utils/gitlab-base-url.test.ts index 665362e2..5279706c 100644 --- a/packages/gitlab-mcp/tests/unit/utils/gitlab-base-url.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/gitlab-base-url.test.ts @@ -37,6 +37,15 @@ describe('getGitLabBaseUrl', () => { expect(getGitLabBaseUrl()).toBe('https://other.example.com'); }); + // Every source yields a base URL without a trailing slash or /api/v4, so the REST paths + // built on it stay well-formed. + it("normalizes the OAuth token's instance URL", () => { + mockOAuth = true; + mockContextUrl = 'https://other.example.com/api/v4/'; + + expect(getGitLabBaseUrl()).toBe('https://other.example.com'); + }); + it("uses the instance the server's token works with now", () => { setActiveInstanceSource(() => 'https://switched.example.com'); From 5046a253f2c08306cb5cda30771b0d517a1602b4 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 11 Oct 2026 01:40:53 +0300 Subject: [PATCH 03/11] fix(settings): keep dead-lock removal safe on Linux, round out scoped calls - A dead settings lock is removed only while the claimed file is still old. Linux reuses a freed inode number at once, so a fresh lock could carry the dead one's inode and was removed (the stale lock test failed in CI on Linux). - A namespace argument (labels, wiki, milestones) passes when it names an allowed project or an allowed group, so the scope group itself is usable also without its subgroups. - browse_events user under a project scope shows that project's events and is refused under a group scope or several targets. - A project scope listing answers with GitLab's simple project fields unless simple is false. - A scoped page that stays short after the bounded read comes back marked partial with a hint to narrow the listing. Regression tests: the lock race with a reused inode, namespace arguments under a group scope without subgroups, scoped activity feeds, simple project fields and the partial page. --- .../gitlab-mcp/docs/configuration/settings.md | 4 +- .../src/configuration/scope-targets.ts | 11 +++ .../src/configuration/scoped-execution.ts | 55 +++++++++++++-- .../src/configuration/settings-store.ts | 10 +-- .../gitlab-mcp/src/profiles/scope-enforcer.ts | 47 ++++++++----- .../unit/configuration/scope-targets.test.ts | 18 +++++ .../configuration/scoped-execution.test.ts | 45 ++++++++++++ .../unit/configuration/settings-store.test.ts | 69 +++++++++++-------- .../unit/profiles/scope-enforcer.test.ts | 15 ++++ 9 files changed, 219 insertions(+), 55 deletions(-) diff --git a/packages/gitlab-mcp/docs/configuration/settings.md b/packages/gitlab-mcp/docs/configuration/settings.md index 0d3ad9a5..7ac4ce57 100644 --- a/packages/gitlab-mcp/docs/configuration/settings.md +++ b/packages/gitlab-mcp/docs/configuration/settings.md @@ -51,7 +51,9 @@ cross-project merge request listing and a vulnerability listing name the scope's Results that cannot be filtered at GitLab (project listings and searches, including projects only shared with the scope's group, todos, merge requests under a group scope) are filtered to the scope, reading further GitLab pages until the requested page is full -(up to 1000 rows per call). A global search or vulnerability listing is refused under a +(up to 1000 rows per call; a page still short then comes back marked `partial`, with a hint +to narrow the listing). Your own activity (`browse_events` with `action: "user"`) shows the +scope project's events under a project scope and is refused under a group scope. A global search or vulnerability listing is refused under a scope of several projects or groups, and under a group scope without subgroups (GitLab's group results always include them); use one project or group instead. `manage_todos mark_all_done` marks only the scope's pending todos done and leaves the others pending. A diff --git a/packages/gitlab-mcp/src/configuration/scope-targets.ts b/packages/gitlab-mcp/src/configuration/scope-targets.ts index 5e01e265..bedebebd 100644 --- a/packages/gitlab-mcp/src/configuration/scope-targets.ts +++ b/packages/gitlab-mcp/src/configuration/scope-targets.ts @@ -35,6 +35,12 @@ export function targetlessRestriction(tool: string, args: Args, scope: ScopeConf if (tool === 'browse_search' && args.action === 'global') { return unnarrowable(scope, 'a global search cannot be limited', 'search within'); } + // Events name their project only by numeric id, which a path scope cannot be matched + // against; a single project scope reads that project's events instead. + const target = singleTarget(scope); + if (tool === 'browse_events' && args.action === 'user' && !(target && 'project' in target)) { + return "your activity cannot be limited to a working scope of a group or several projects; list a project's events with action 'project'"; + } return null; } @@ -127,6 +133,11 @@ const NARROWERS: Record + args.action === 'user' && 'project' in target + ? { ...args, action: 'project', project_id: target.project } + : null, browse_vulnerabilities: (args, target) => { if (!isTargetlessList(args)) return null; return 'group' in target diff --git a/packages/gitlab-mcp/src/configuration/scoped-execution.ts b/packages/gitlab-mcp/src/configuration/scoped-execution.ts index 8eeb23ca..6adcaa4b 100644 --- a/packages/gitlab-mcp/src/configuration/scoped-execution.ts +++ b/packages/gitlab-mcp/src/configuration/scoped-execution.ts @@ -21,6 +21,33 @@ const MAX_UPSTREAM_PAGES = 10; const DEFAULT_PAGE_SIZE = 20; /** Listing options a scope made of projects can apply to its projects itself. */ const PROJECT_LIST_OPTIONS = new Set(['action', 'search', 'page', 'per_page', 'simple']); +/** GitLab's simple project representation (BasicProjectDetails), returned unless simple=false. */ +const SIMPLE_PROJECT_FIELDS: ReadonlySet = new Set([ + 'id', + 'description', + 'name', + 'name_with_namespace', + 'path', + 'path_with_namespace', + 'created_at', + 'default_branch', + 'tag_list', + 'topics', + 'ssh_url_to_repo', + 'http_url_to_repo', + 'web_url', + 'readme_url', + 'forks_count', + 'license_url', + 'license', + 'avatar_url', + 'star_count', + 'last_activity_at', + 'visibility', + 'namespace', + 'custom_attributes', + 'repository_storage', +]); const TODO_CONCURRENCY = 5; const numberOr = (value: unknown, fallback: number): number => @@ -55,7 +82,8 @@ export async function executeScoped( /** * The requested page of a listing filtered to the scope. GitLab paginates before the scope * filters, so its pages are read in turn until the scoped page is full or GitLab has no - * more rows; at most MAX_UPSTREAM_PAGES pages are read for one call. + * more rows. At most MAX_UPSTREAM_PAGES pages are read for one call; a page still short + * then is returned as partial, so it is not taken for the end of the results. */ async function fillScopedPage( run: RunTool, @@ -67,14 +95,22 @@ async function fillScopedPage( const page = numberOr(args.page, 1); const wanted = page * perPage; const rows: unknown[] = []; + let exhausted = false; // Pages are read one after another: whether the next one is needed depends on this one. for (let upstream = 1; upstream <= MAX_UPSTREAM_PAGES && rows.length < wanted; upstream++) { const result = await run(tool, { ...args, page: upstream, per_page: UPSTREAM_PAGE_SIZE }); if (!Array.isArray(result)) return result; rows.push(...(scopedResult(tool, args, result, enforcer) as unknown[])); - if (result.length < UPSTREAM_PAGE_SIZE) break; + exhausted = result.length < UPSTREAM_PAGE_SIZE; + if (exhausted) break; } - return rows.slice((page - 1) * perPage, wanted); + const items = rows.slice((page - 1) * perPage, wanted); + if (exhausted || rows.length >= wanted) return items; + return { + items, + partial: true, + message: `Read the first ${MAX_UPSTREAM_PAGES * UPSTREAM_PAGE_SIZE} rows GitLab returned and found ${rows.length} in the working scope; more may follow. Narrow the listing (for example with a search) to see the rest.`, + }; } /** @@ -110,7 +146,18 @@ async function listScopeProjects( }); const perPage = numberOr(requested.per_page, DEFAULT_PAGE_SIZE); const page = numberOr(requested.page, 1); - return matching.slice((page - 1) * perPage, page * perPage); + const listed = matching.slice((page - 1) * perPage, page * perPage); + // A project is read in full; a listing answers with the simple representation unless + // simple=false, as GitLab does (its schema default is true). + return requested.simple === false ? listed : listed.map(simpleProject); +} + +function simpleProject(project: unknown): unknown { + return Object.fromEntries( + Object.entries(project as Record).filter(([key]) => + SIMPLE_PROJECT_FIELDS.has(key), + ), + ); } /** diff --git a/packages/gitlab-mcp/src/configuration/settings-store.ts b/packages/gitlab-mcp/src/configuration/settings-store.ts index 5a598fc1..9b2ceee2 100644 --- a/packages/gitlab-mcp/src/configuration/settings-store.ts +++ b/packages/gitlab-mcp/src/configuration/settings-store.ts @@ -148,9 +148,10 @@ export class LocalSettingsFile implements SettingsStore { /** * Removes a lock left by a dead process; true when the lock can be taken again at once. - * The lock is claimed by an atomic rename and its identity checked, because between the - * age check and the removal another process may have replaced the dead lock with a fresh - * one; that one is put back instead of removed. + * The lock is claimed by an atomic rename and checked again, because between the age check + * and the removal another process may have replaced the dead lock with a fresh one; that + * one is put back instead of removed. The claimed lock must still be old: an inode check + * alone cannot tell, as Linux reuses a freed inode number at once. */ private async removeStaleLock(lockPath: string): Promise { let seen: fs.Stats; @@ -169,7 +170,8 @@ export class LocalSettingsFile implements SettingsStore { return true; } const taken = await fs.promises.stat(claimed); - if (taken.ino !== seen.ino || taken.dev !== seen.dev) { + const sameLock = taken.ino === seen.ino && taken.dev === seen.dev; + if (!sameLock || Date.now() - taken.mtimeMs < STALE_LOCK_MS) { // A fresh lock taken after the age check: hand it back to its holder. If the path was // taken again meanwhile, that newer lock stays and this one is dropped. await fs.promises.link(claimed, lockPath).catch(() => undefined); diff --git a/packages/gitlab-mcp/src/profiles/scope-enforcer.ts b/packages/gitlab-mcp/src/profiles/scope-enforcer.ts index 1f530527..53c6aa65 100644 --- a/packages/gitlab-mcp/src/profiles/scope-enforcer.ts +++ b/packages/gitlab-mcp/src/profiles/scope-enforcer.ts @@ -362,26 +362,33 @@ function identifierOf(value: unknown): string | undefined { * @param args Tool arguments object * @returns Array of project paths found in arguments */ +/** + * Arguments that name a project. Destinations count too: a cross-project merge request or + * job token target. + */ +const PROJECT_FIELDS = [ + 'project_id', + 'projectId', + 'project', + 'namespace', + 'namespacePath', + 'fullPath', + 'target_project_id', +]; + +/** Of those, the ones that name a project or a group (labels, wiki, milestones of either). */ +const PROJECT_OR_GROUP_FIELDS: ReadonlySet = new Set([ + 'namespace', + 'namespacePath', + 'fullPath', +]); + export function extractProjectsFromArgs(args: Record): string[] { const projects: string[] = []; - - // Common parameter names for project identification - // Destinations count too: a cross-project merge request or job token target. - const projectFields = [ - 'project_id', - 'projectId', - 'project', - 'namespace', - 'namespacePath', - 'fullPath', - 'target_project_id', - ]; - - for (const field of projectFields) { + for (const field of PROJECT_FIELDS) { const id = identifierOf(args[field]); if (id) projects.push(id); } - return projects; } @@ -429,10 +436,12 @@ export function extractGroupsFromArgs(args: Record): string[] { * @throws ScopeViolationError if any project or group is outside scope */ export function enforceArgsScope(enforcer: ScopeEnforcer, args: Record): void { - // Check project paths - const projects = extractProjectsFromArgs(args); - for (const project of projects) { - enforcer.enforce(project); + // Check project paths; an argument that may name a group passes as an allowed group too + for (const field of PROJECT_FIELDS) { + const id = identifierOf(args[field]); + if (!id) continue; + if (PROJECT_OR_GROUP_FIELDS.has(field) && enforcer.isGroupAllowed(id)) continue; + enforcer.enforce(id); } // Check group paths diff --git a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts index 4b815de3..32662e23 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts @@ -59,6 +59,15 @@ describe('scopedArgs', () => { expect(scopedArgs('browse_vulnerabilities', { action: 'list' }, scope)).toEqual(expected); }); + // The caller's own activity spans every project. + it('shows the activity of the scope project instead of all activity', () => { + expect(scopedArgs('browse_events', { action: 'user', sort: 'desc' }, project)).toEqual({ + action: 'project', + project_id: 'team/app', + sort: 'desc', + }); + }); + it('lists the merge requests of the scope project', () => { expect( scopedArgs('browse_merge_requests', { action: 'list', state: 'opened' }, project), @@ -80,6 +89,8 @@ describe('scopedArgs', () => { ['manage_project', { action: 'create', name: 'app', namespace: 'team/sub' }, group], ['manage_project', { action: 'delete', project_id: 'team/app' }, group], ['manage_project', { action: 'create', name: 'app' }, project], + ['browse_events', { action: 'project', project_id: 'team/app' }, project], + ['browse_events', { action: 'user' }, group], ])('leaves %s %j under %j as it is', (tool, args, scope) => { expect(scopedArgs(tool, args, scope)).toBe(args); }); @@ -136,6 +147,13 @@ describe('targetlessRestriction', () => { expect(targetlessRestriction(tool, args, scope)).toMatch(/outside the working scope/); }); + // Events carry only a numeric project id, which a group scope cannot be matched against. + it.each([group, several])('refuses the caller activity feed under %j', (scope) => { + expect(targetlessRestriction('browse_events', { action: 'user' }, scope)).toMatch( + /activity cannot be limited/, + ); + }); + it('refuses a vulnerability listing a scope of several targets cannot narrow', () => { expect(targetlessRestriction('browse_vulnerabilities', { action: 'list' }, several)).toMatch( /list them for one of them/, diff --git a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts index e188e18c..cee11598 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts @@ -63,6 +63,51 @@ describe('executeScoped', () => { ).toMatchObject({ success: false, marked: 0, failed: [{ id: 1, error: 'rate limited' }] }); }); + // A project listing answers with GitLab's simple representation unless simple is false; + // the scope's projects are read one by one, which returns the full one. + it.each([ + [{ action: 'list' }, ['id', 'name', 'path_with_namespace', 'namespace']], + [{ action: 'list', simple: true }, ['id', 'name', 'path_with_namespace', 'namespace']], + [ + { action: 'list', simple: false }, + ['id', 'name', 'path_with_namespace', 'namespace', 'permissions', 'statistics'], + ], + ])('lists the scope project %j with the fields %j', async (args, fields) => { + const run = jest.fn().mockResolvedValue({ + id: 7, + name: 'App', + path_with_namespace: 'team/app', + namespace: { full_path: 'team' }, + permissions: { project_access: null }, + statistics: { commit_count: 3 }, + }); + + const [project] = (await executeScoped(run, 'browse_projects', args, projectPolicy)) as Array< + Record + >; + + expect(Object.keys(project).sort()).toEqual([...fields].sort()); + }); + + // Reading stops after a bounded number of GitLab pages; a page that could not be filled + // then says so instead of looking like the end of the results. + it('reports a page it could not fill within the rows it reads', async () => { + const outside = Array.from({ length: 100 }, (_, i) => ({ path_with_namespace: `other/p${i}` })); + const run = jest.fn(async (_tool: string, args: Record) => + args.page === 3 ? [...outside.slice(1), { path_with_namespace: 'team/app' }] : outside, + ); + + const result = await executeScoped(run, 'browse_projects', { action: 'search' }, groupPolicy); + + expect(run).toHaveBeenCalledTimes(10); + expect(result).toEqual({ + items: [{ path_with_namespace: 'team/app' }], + partial: true, + message: + 'Read the first 1000 rows GitLab returned and found 1 in the working scope; more may follow. Narrow the listing (for example with a search) to see the rest.', + }); + }); + it('fails a project scope listing on a failure that is not an Error', async () => { const run = jest.fn().mockRejectedValue('connection reset'); diff --git a/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts b/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts index d92a1c0f..1eeb7817 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts @@ -102,33 +102,48 @@ describe('LocalSettingsFile', () => { // Two writers see the same dead lock; one removes it and a third process takes a fresh // lock before the second writer acts. The fresh lock must survive, or two writers would - // be inside the read-compare-write at once. - it('does not remove a fresh lock taken after the dead one was seen', async () => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const lock = `${filePath}.lock`; - fs.writeFileSync(lock, ''); - const longAgo = new Date(Date.now() - 60_000); - fs.utimesSync(lock, longAgo, longAgo); - const realStat = fs.promises.stat.bind(fs.promises); - jest.spyOn(fs.promises, 'stat').mockImplementationOnce(async (target) => { - const dead = await realStat(target); - // Another process replaced the dead lock with its own meanwhile. - fs.rmSync(lock); - fs.writeFileSync(lock, 'fresh'); - return dead; - }); - - try { - const write = new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0); - await new Promise((resolve) => setTimeout(resolve, 60)); - - expect(fs.readFileSync(lock, 'utf-8')).toBe('fresh'); - fs.rmSync(lock); - expect(await write).toBeDefined(); - } finally { - jest.restoreAllMocks(); - } - }); + // be inside the read-compare-write at once. Linux file systems reuse the freed inode + // number at once, so the fresh lock can carry the dead one's inode. + it.each([false, true])( + 'does not remove a fresh lock taken after the dead one was seen (inode reused: %s)', + async (inodeReused) => { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const lock = `${filePath}.lock`; + fs.writeFileSync(lock, ''); + const longAgo = new Date(Date.now() - 60_000); + fs.utimesSync(lock, longAgo, longAgo); + const realStat = fs.promises.stat.bind(fs.promises); + let dead: fs.Stats | undefined; + jest + .spyOn(fs.promises, 'stat') + .mockImplementationOnce(async (target) => { + dead = await realStat(target); + // Another process replaced the dead lock with its own meanwhile. + fs.rmSync(lock); + fs.writeFileSync(lock, 'fresh'); + return dead; + }) + .mockImplementationOnce(async (target) => { + const taken = await realStat(target); + if (!inodeReused || !dead) return taken; + return Object.assign(Object.create(Object.getPrototypeOf(taken)), taken, { + ino: dead.ino, + dev: dead.dev, + }); + }); + + try { + const write = new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0); + await new Promise((resolve) => setTimeout(resolve, 60)); + + expect(fs.readFileSync(lock, 'utf-8')).toBe('fresh'); + fs.rmSync(lock); + expect(await write).toBeDefined(); + } finally { + jest.restoreAllMocks(); + } + }, + ); // Another writer claimed the dead lock first: the rename fails and the lock is tried again. it('tries again when another writer claimed the dead lock first', async () => { diff --git a/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts b/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts index 84710f0d..962d8837 100644 --- a/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts +++ b/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts @@ -523,6 +523,21 @@ describe('enforceArgsScope', () => { expect(() => enforceArgsScope(new ScopeEnforcer(scope), args)).toThrow(ScopeViolationError); }); + // A namespace argument names a project or a group (labels, wiki, milestones of either): + // the scope group itself is a valid namespace also when its subgroups are not included. + it.each([ + [{ group: 'team', includeSubgroups: false }, { namespace: 'team' }, false], + [{ group: 'team', includeSubgroups: false }, { fullPath: 'team' }, false], + [{ group: 'team', includeSubgroups: false }, { namespace: 'team/app' }, false], + [{ group: 'team', includeSubgroups: false }, { namespace: 'other' }, true], + [{ project: 'team/app' }, { namespace: 'team' }, true], + ])('checks a namespace under %j by project or group: %j', (scope, args, refused) => { + const check = () => enforceArgsScope(new ScopeEnforcer(scope), args); + + if (refused) expect(check).toThrow(ScopeViolationError); + else expect(check).not.toThrow(); + }); + it('should allow a destination inside the scope', () => { const enforcer = new ScopeEnforcer({ group: 'team', includeSubgroups: true }); From 5ae496fed95dec93f5fa683be7f8b2ea595d303f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 11 Oct 2026 02:35:28 +0300 Subject: [PATCH 04/11] fix(settings): resolve numeric ids, scope instance listings - A project or group named by numeric id is read from GitLab and checked as its path; a working scope holds paths, so numeric ids were always refused, also for the scope's own project. - Instance-wide listings follow the working scope: deploy keys and instance audit events read the scope project, all or owned runners those available to the scope's project or group; scopes with no such form are refused instead of listing the whole instance. - A preset whitelist overrides its deny pattern, at runtime and in the preset tool counts of list-tools, as documented for profiles. - A scoped page that starts past the 1000 readable rows answers partial at once instead of making ten GitLab requests. Regression tests: numeric ids in and out of the scope, scoped and refused instance listings, a whitelisted tool its deny pattern matches, and the page past the readable rows. --- .../gitlab-mcp/docs/configuration/settings.md | 51 +++++++------ packages/gitlab-mcp/src/cli/list-tools.ts | 8 +- .../gitlab-mcp/src/configuration/policy.ts | 7 +- .../gitlab-mcp/src/configuration/scope-ids.ts | 59 +++++++++++++++ .../src/configuration/scope-targets.ts | 75 ++++++++++++++++--- .../src/configuration/scoped-execution.ts | 17 ++++- packages/gitlab-mcp/src/handlers.ts | 8 +- .../gitlab-mcp/src/profiles/scope-enforcer.ts | 73 +++++++++++------- packages/gitlab-mcp/src/profiles/types.ts | 5 +- .../tests/unit/cli/list-tools.test.ts | 31 ++++++++ .../tests/unit/configuration/policy.test.ts | 13 ++++ .../unit/configuration/scope-ids.test.ts | 73 ++++++++++++++++++ .../unit/configuration/scope-targets.test.ts | 69 +++++++++++++++++ .../configuration/scoped-execution.test.ts | 20 +++++ .../unit/profiles/scope-enforcer.test.ts | 20 +++++ 15 files changed, 460 insertions(+), 69 deletions(-) create mode 100644 packages/gitlab-mcp/src/configuration/scope-ids.ts create mode 100644 packages/gitlab-mcp/tests/unit/configuration/scope-ids.test.ts diff --git a/packages/gitlab-mcp/docs/configuration/settings.md b/packages/gitlab-mcp/docs/configuration/settings.md index 7ac4ce57..d19f262e 100644 --- a/packages/gitlab-mcp/docs/configuration/settings.md +++ b/packages/gitlab-mcp/docs/configuration/settings.md @@ -15,11 +15,11 @@ widen it. ## Three layers -| Layer | Who sets it | Applies to | Kept | -|-------|-------------|------------|------| -| Server | Administrator: environment variables and startup presets | Everyone | Server configuration | -| Account | The user, with `update_settings` or the client's settings page | Every new chat of that GitLab account | Durably (see [Storage](#storage)) | -| Chat | The user or the assistant, with `manage_context` or the connection panel | The current chat only | Until the chat ends or is reset | +| Layer | Who sets it | Applies to | Kept | +| ------- | ------------------------------------------------------------------------ | ------------------------------------- | --------------------------------- | +| Server | Administrator: environment variables and startup presets | Everyone | Server configuration | +| Account | The user, with `update_settings` or the client's settings page | Every new chat of that GitLab account | Durably (see [Storage](#storage)) | +| Chat | The user or the assistant, with `manage_context` or the connection panel | The current chat only | Until the chat ends or is reset | The account is the GitLab user behind the connection: with OAuth, the user who signed in; with a static token, the token's instance. Two users of one server never see each other's @@ -27,13 +27,13 @@ settings, and a chat override never leaks into another chat. ## What can be set -| Setting | Effect | -|---------|--------| -| `preset` | A working preset from the server's presets, or `none` | -| `readOnly` | Tools that change GitLab are off. Cannot be turned off when the administrator enabled read-only mode. | -| `scope` | A project or group path to work in; empty to work everywhere you have access | -| `scopeIncludeSubgroups` | With a group scope, whether projects in its subgroups are included | -| `tools_` | Turn a tool group off or on, for example `tools_wiki` or `tools_pipelines`. Groups the administrator turned off are not offered. | +| Setting | Effect | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `preset` | A working preset from the server's presets, or `none` | +| `readOnly` | Tools that change GitLab are off. Cannot be turned off when the administrator enabled read-only mode. | +| `scope` | A project or group path to work in; empty to work everywhere you have access | +| `scopeIncludeSubgroups` | With a group scope, whether projects in its subgroups are included | +| `tools_` | Turn a tool group off or on, for example `tools_wiki` or `tools_pipelines`. Groups the administrator turned off are not offered. | The effective restrictions combine all layers: a chat value replaces the account value for that chat. Read-only mode set by the server administrator or by the selected preset always @@ -42,7 +42,8 @@ applies; a chat cannot turn it off. Restrictions are applied when a tool runs, not only to the tool list: a direct call to a tool the settings turn off, or to a project or group outside the working scope, is refused before anything reaches GitLab. A scope of projects reaches no group, so group operations -are refused under it. +are refused under it. A project or group named by its numeric id is checked as the path +GitLab reports for it. A listing or search that names no project or group reads the working scope instead of everything the account can see: a global search becomes a search of the scope's group or @@ -53,12 +54,16 @@ projects only shared with the scope's group, todos, merge requests under a group are filtered to the scope, reading further GitLab pages until the requested page is full (up to 1000 rows per call; a page still short then comes back marked `partial`, with a hint to narrow the listing). Your own activity (`browse_events` with `action: "user"`) shows the -scope project's events under a project scope and is refused under a group scope. A global search or vulnerability listing is refused under a -scope of several projects or groups, and under a group scope without subgroups (GitLab's -group results always include them); use one project or group instead. `manage_todos -mark_all_done` marks only the scope's pending todos done and leaves the others pending. A -project created or forked without a namespace goes into the scope's group; with no single -group to put it in, and for a group without a parent, the call is refused. +scope project's events under a project scope and is refused under a group scope. +Instance-wide listings read the scope instead: deploy keys and instance audit events those +of the scope project, all or owned runners those available to the scope's project or group; +where the scope has no such form, the listing is refused. A global search or vulnerability +listing is refused under a scope of several projects or groups, and under a group scope +without subgroups (GitLab's group results always include them); use one project or group +instead. `manage_todos mark_all_done` marks only the scope's pending todos done and leaves +the others pending. A project created or forked without a namespace goes into the scope's +group; with no single group to put it in, and for a group without a parent, the call is +refused. The working scope is a focus, not an access boundary: the user sets and clears it. Calls that name an object only by its global id (a work item or todo id) are not checked against @@ -120,10 +125,10 @@ recommendations, and `find_scope_targets` finds projects and groups to scope to. ## Storage -| Deployment | Account settings are kept in | -|------------|------------------------------| -| OAuth with PostgreSQL session storage | The session storage, shared by every replica that uses it | -| OAuth with file session storage | The session storage file. File storage serves one server process: replicas need PostgreSQL | +| Deployment | Account settings are kept in | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OAuth with PostgreSQL session storage | The session storage, shared by every replica that uses it | +| OAuth with file session storage | The session storage file. File storage serves one server process: replicas need PostgreSQL | | OAuth with sessions in memory (the default), or a local server with a static token | `~/.config/gitlab-mcp/settings.json` of the user running the server, shared by every server process of that user; settings survive a restart even though memory sessions do not | Writes are compare-and-set: two chats editing different settings at the same time both diff --git a/packages/gitlab-mcp/src/cli/list-tools.ts b/packages/gitlab-mcp/src/cli/list-tools.ts index 37275463..572c9766 100644 --- a/packages/gitlab-mcp/src/cli/list-tools.ts +++ b/packages/gitlab-mcp/src/cli/list-tools.ts @@ -1056,8 +1056,8 @@ function countToolsForPreset(preset: Preset, allToolNames: string[]): number { enabledTools = enabledTools.filter((name) => !name.startsWith('manage_')); } - // Apply denied_tools_regex - if (preset.denied_tools_regex) { + // Apply denied_tools_regex; a whitelist overrides it + if (preset.denied_tools_regex && !preset.allowed_tools?.length) { try { const regex = new RegExp(preset.denied_tools_regex); enabledTools = enabledTools.filter((name) => !regex.test(name)); @@ -1105,8 +1105,8 @@ function getToolsForPreset( enabledTools = enabledTools.filter((name) => !name.startsWith('manage_')); } - // Apply denied_tools_regex - if (preset.denied_tools_regex) { + // Apply denied_tools_regex; a whitelist overrides it + if (preset.denied_tools_regex && !preset.allowed_tools?.length) { try { const regex = new RegExp(preset.denied_tools_regex); const denied = enabledTools.filter((name) => regex.test(name)); diff --git a/packages/gitlab-mcp/src/configuration/policy.ts b/packages/gitlab-mcp/src/configuration/policy.ts index 7c5bdcc6..ba537124 100644 --- a/packages/gitlab-mcp/src/configuration/policy.ts +++ b/packages/gitlab-mcp/src/configuration/policy.ts @@ -132,8 +132,11 @@ export function toolRestriction(policy: EffectivePolicy, tool: ToolFacts): strin if (tool.group !== undefined && policy.disabledGroups.has(tool.group)) { return `the '${tool.group}' tool group is turned off`; } - if (policy.allowedTools && !policy.allowedTools.has(tool.name)) { - return `preset '${policy.presetName}' does not include it`; + // A whitelist overrides the deny pattern: the tools it names are the preset's exceptions. + if (policy.allowedTools) { + return policy.allowedTools.has(tool.name) + ? null + : `preset '${policy.presetName}' does not include it`; } if (policy.deniedTools?.test(tool.name)) { return `preset '${policy.presetName}' excludes it`; diff --git a/packages/gitlab-mcp/src/configuration/scope-ids.ts b/packages/gitlab-mcp/src/configuration/scope-ids.ts new file mode 100644 index 00000000..ab32c67e --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/scope-ids.ts @@ -0,0 +1,59 @@ +/** + * A working scope is saved as project and group paths, while GitLab accepts either a path + * or a numeric id. Numeric ids are read from GitLab and checked as the paths they stand for. + */ + +import { numericTargets, type NumericTargetKind } from '../profiles/scope-enforcer'; +import { gitlab } from '../utils/gitlab-api'; + +type Args = Record; +/** Reads one GitLab REST resource with the caller's token and instance. */ +export type ReadResource = (path: string) => Promise; + +const readWithCaller: ReadResource = (path) => gitlab.get(path); + +/** + * The call's arguments with every numeric project or group id replaced by its path, for the + * scope check only; the call itself keeps the ids. An id GitLab does not resolve stays + * numeric, and the check refuses it. No request is made when no target is numeric. + */ +export async function withTargetPaths( + args: Args, + read: ReadResource = readWithCaller, +): Promise { + const targets = numericTargets(args); + if (targets.length === 0) return args; + const paths = await Promise.all(targets.map(({ id, kind }) => pathOf(read, id, kind))); + const resolved: Args = { ...args }; + targets.forEach(({ field }, index) => { + const path = paths[index]; + if (path !== undefined) resolved[field] = path; + }); + return resolved; +} + +/** + * The path a numeric id stands for, or undefined. A group id is a namespace id; an argument + * that may name either is read as a group first, as the handlers read a path without a slash. + */ +async function pathOf( + read: ReadResource, + id: string, + kind: NumericTargetKind, +): Promise { + if (kind !== 'project') { + const group = await field(read, `namespaces/${id}`, 'full_path'); + if (group !== undefined || kind === 'group') return group; + } + return field(read, `projects/${id}`, 'path_with_namespace'); +} + +async function field(read: ReadResource, path: string, name: string): Promise { + try { + const value = ((await read(path)) as Record | undefined)?.[name]; + return typeof value === 'string' ? value : undefined; + } catch { + // Unknown or unreadable: the id stays numeric and the scope check refuses it. + return undefined; + } +} diff --git a/packages/gitlab-mcp/src/configuration/scope-targets.ts b/packages/gitlab-mcp/src/configuration/scope-targets.ts index bedebebd..e398b915 100644 --- a/packages/gitlab-mcp/src/configuration/scope-targets.ts +++ b/packages/gitlab-mcp/src/configuration/scope-targets.ts @@ -21,10 +21,10 @@ function singleTarget(scope: ScopeConfig): { group: string } | { project: string const hasValue = (value: unknown): boolean => value !== undefined && value !== null && value !== ''; /** - * Why a targetless call cannot be narrowed to the scope, or null. Only a global search - * whose scope no GitLab search expresses: several projects or groups, or a group without - * its subgroups (group search always covers them, and search results of every kind - * cannot be filtered by path). + * Why a targetless call cannot be narrowed to the scope, or null: a creation that names no + * place, a global search or vulnerability listing no GitLab filter expresses (several + * projects or groups, or a group without its subgroups, which group results always cover), + * and instance-wide listings with no per-scope form. */ export function targetlessRestriction(tool: string, args: Args, scope: ScopeConfig): string | null { const creation = creationRestriction(tool, args, scope); @@ -35,15 +35,47 @@ export function targetlessRestriction(tool: string, args: Args, scope: ScopeConf if (tool === 'browse_search' && args.action === 'global') { return unnarrowable(scope, 'a global search cannot be limited', 'search within'); } - // Events name their project only by numeric id, which a path scope cannot be matched - // against; a single project scope reads that project's events instead. - const target = singleTarget(scope); - if (tool === 'browse_events' && args.action === 'user' && !(target && 'project' in target)) { - return "your activity cannot be limited to a working scope of a group or several projects; list a project's events with action 'project'"; - } - return null; + return INSTANCE_LISTINGS[tool]?.(args, singleTarget(scope)) ?? null; } +/** Whether the scope is exactly one project. */ +const isProject = (target: Target | undefined): boolean => + target !== undefined && 'project' in target; + +/** + * Per tool: why a listing across the whole instance (or all of the caller's activity) cannot + * be narrowed to the scope, or null. Those it can are rewritten by NARROWERS. + */ +const INSTANCE_LISTINGS: Record string | null> = + { + // Events name their project only by numeric id, which a path scope cannot be matched + // against; a single project scope reads that project's events instead. + browse_events: (args, target) => + args.action === 'user' && !isProject(target) + ? "your activity cannot be limited to a working scope of a group or several projects; list a project's events with action 'project'" + : null, + // GitLab lists deploy keys per project; the public listing exists only instance-wide. + browse_deploy_keys: (args, target) => { + if (args.action !== 'list' || hasValue(args.project_id)) return null; + if (hasValue(args.public)) { + return "public deploy keys are listed for the whole instance and cannot be limited to the working scope; list a project's keys with 'project_id'"; + } + return isProject(target) + ? null + : "deploy keys cannot be listed for a group or several projects; list a project's keys with 'project_id'"; + }, + browse_runners: (args, target) => + (args.action === 'list_all' || args.action === 'list_owned') && !target + ? "runners cannot be listed for a working scope of several projects or groups; list them for one of them with action 'list_project' or 'list_group'" + : null, + // A group's audit trail holds the group's own events, not those of its projects. + browse_audit_events: (args, target) => + args.action === 'list_instance' && + (!isProject(target) || hasValue(args.entity_type) || hasValue(args.entity_id)) + ? "instance audit events cannot be limited to the working scope; list a project's or a group's with action 'list_project' or 'list_group'" + : null, + }; + /** * Why a call GitLab only offers per project or per whole group cannot follow the scope: * several targets, or a group without its subgroups (a group's results always cover them). @@ -138,6 +170,27 @@ const NARROWERS: Record + args.action === 'list' && + !hasValue(args.project_id) && + !hasValue(args.public) && + 'project' in target + ? { ...args, project_id: target.project } + : null, + // Runners available to the scope's project or group, with the same filters. + browse_runners: (args, target) => { + if (args.action !== 'list_all' && args.action !== 'list_owned') return null; + return 'group' in target + ? { ...args, action: 'list_group', group_id: target.group } + : { ...args, action: 'list_project', project_id: target.project }; + }, + browse_audit_events: (args, target) => + args.action === 'list_instance' && + 'project' in target && + !hasValue(args.entity_type) && + !hasValue(args.entity_id) + ? { ...args, action: 'list_project', project_id: target.project } + : null, browse_vulnerabilities: (args, target) => { if (!isTargetlessList(args)) return null; return 'group' in target diff --git a/packages/gitlab-mcp/src/configuration/scoped-execution.ts b/packages/gitlab-mcp/src/configuration/scoped-execution.ts index 6adcaa4b..7bfb7d36 100644 --- a/packages/gitlab-mcp/src/configuration/scoped-execution.ts +++ b/packages/gitlab-mcp/src/configuration/scoped-execution.ts @@ -94,6 +94,13 @@ async function fillScopedPage( const perPage = numberOr(args.per_page, DEFAULT_PAGE_SIZE); const page = numberOr(args.page, 1); const wanted = page * perPage; + const readable = MAX_UPSTREAM_PAGES * UPSTREAM_PAGE_SIZE; + if ((page - 1) * perPage >= readable) { + return partialPage( + [], + `A scoped listing reads the first ${readable} rows GitLab returns, and page ${page} starts after them.`, + ); + } const rows: unknown[] = []; let exhausted = false; // Pages are read one after another: whether the next one is needed depends on this one. @@ -106,10 +113,18 @@ async function fillScopedPage( } const items = rows.slice((page - 1) * perPage, wanted); if (exhausted || rows.length >= wanted) return items; + return partialPage( + items, + `Read the first ${readable} rows GitLab returned and found ${rows.length} in the working scope; more may follow.`, + ); +} + +/** A scoped page the bounded read could not fill, so it is not taken for the end. */ +function partialPage(items: unknown[], reason: string): unknown { return { items, partial: true, - message: `Read the first ${MAX_UPSTREAM_PAGES * UPSTREAM_PAGE_SIZE} rows GitLab returned and found ${rows.length} in the working scope; more may follow. Narrow the listing (for example with a search) to see the rest.`, + message: `${reason} Narrow the listing (for example with a search) to see the rest.`, }; } diff --git a/packages/gitlab-mcp/src/handlers.ts b/packages/gitlab-mcp/src/handlers.ts index 88c01152..5bf386f8 100644 --- a/packages/gitlab-mcp/src/handlers.ts +++ b/packages/gitlab-mcp/src/handlers.ts @@ -54,6 +54,7 @@ import { type EffectivePolicy, } from './configuration/policy'; import { executeScoped } from './configuration/scoped-execution'; +import { withTargetPaths } from './configuration/scope-ids'; interface JsonSchemaProperty { type?: string; @@ -964,9 +965,14 @@ export async function setupHandlers(server: Server): Promise { // when they cannot be read: allowing it could bypass a restriction. const { RegistryManager: CallRegistry } = await import('./registry-manager'); const configuration = await getConfigurationService().resolve(caller); + // A scope holds paths: numeric ids are checked as the paths they stand for. + const callArguments = toolArguments ?? {}; + const checkedArguments = configuration.policy.scopeEnforcer + ? await withTargetPaths(callArguments) + : callArguments; const settingsRefusal = settingsRejection( toolName, - toolArguments ?? {}, + checkedArguments, configuration.policy, CallRegistry.getInstance(), ); diff --git a/packages/gitlab-mcp/src/profiles/scope-enforcer.ts b/packages/gitlab-mcp/src/profiles/scope-enforcer.ts index 53c6aa65..533cfeff 100644 --- a/packages/gitlab-mcp/src/profiles/scope-enforcer.ts +++ b/packages/gitlab-mcp/src/profiles/scope-enforcer.ts @@ -351,17 +351,6 @@ function identifierOf(value: unknown): string | undefined { return undefined; } -/** - * Extract project path from tool arguments - * - * Tools may specify project in different ways: - * - project_id: "group/project" or "123" - * - namespace: "group/project" - * - projectId: "group/project" - * - * @param args Tool arguments object - * @returns Array of project paths found in arguments - */ /** * Arguments that name a project. Destinations count too: a cross-project merge request or * job token target. @@ -383,6 +372,30 @@ const PROJECT_OR_GROUP_FIELDS: ReadonlySet = new Set([ 'fullPath', ]); +/** + * Arguments that name a group. Destinations count too: a fork's target namespace, a job + * token target group, and a namespace read by id or path. + */ +const GROUP_FIELDS = [ + 'group_id', + 'groupId', + 'group', + 'target_group_id', + 'namespace_path', + 'namespace_id', +]; + +/** + * Extract project path from tool arguments + * + * Tools may specify project in different ways: + * - project_id: "group/project" or "123" + * - namespace: "group/project" + * - projectId: "group/project" + * + * @param args Tool arguments object + * @returns Array of project paths found in arguments + */ export function extractProjectsFromArgs(args: Record): string[] { const projects: string[] = []; for (const field of PROJECT_FIELDS) { @@ -405,27 +418,35 @@ export function extractProjectsFromArgs(args: Record): string[] */ export function extractGroupsFromArgs(args: Record): string[] { const groups: string[] = []; - - // Common parameter names for group identification - // Destinations count too: a fork's target namespace, a job token target group, and a - // namespace read by id or path. - const groupFields = [ - 'group_id', - 'groupId', - 'group', - 'target_group_id', - 'namespace_path', - 'namespace_id', - ]; - - for (const field of groupFields) { + for (const field of GROUP_FIELDS) { const id = identifierOf(args[field]); if (id) groups.push(id); } - return groups; } +/** What a numeric id argument may name. */ +export type NumericTargetKind = 'project' | 'group' | 'either'; + +/** + * Every argument naming its project or group by numeric id. A scope is saved as paths, so + * these are resolved to paths before the check, which compares paths only. + */ +export function numericTargets( + args: Record, +): Array<{ field: string; id: string; kind: NumericTargetKind }> { + const found: Array<{ field: string; id: string; kind: NumericTargetKind }> = []; + const add = (field: string, kind: NumericTargetKind): void => { + const id = identifierOf(args[field]); + if (id && /^\d+$/.test(id)) found.push({ field, id, kind }); + }; + for (const field of PROJECT_FIELDS) { + add(field, PROJECT_OR_GROUP_FIELDS.has(field) ? 'either' : 'project'); + } + for (const field of GROUP_FIELDS) add(field, 'group'); + return found; +} + /** * Enforce scope on tool arguments * diff --git a/packages/gitlab-mcp/src/profiles/types.ts b/packages/gitlab-mcp/src/profiles/types.ts index 84c8374f..80d05574 100644 --- a/packages/gitlab-mcp/src/profiles/types.ts +++ b/packages/gitlab-mcp/src/profiles/types.ts @@ -199,7 +199,10 @@ export const PresetSchema = z // Access Control read_only: z.boolean().optional().describe('Enable read-only mode'), denied_tools_regex: z.string().optional().describe('Regex pattern to exclude tools'), - allowed_tools: z.array(z.string()).optional().describe('Explicit tool whitelist'), + allowed_tools: z + .array(z.string()) + .optional() + .describe('Explicit tool whitelist (overrides denied_tools_regex)'), denied_actions: z .array(z.string()) .optional() diff --git a/packages/gitlab-mcp/tests/unit/cli/list-tools.test.ts b/packages/gitlab-mcp/tests/unit/cli/list-tools.test.ts index 8ad9c94b..a7b1ca9f 100644 --- a/packages/gitlab-mcp/tests/unit/cli/list-tools.test.ts +++ b/packages/gitlab-mcp/tests/unit/cli/list-tools.test.ts @@ -1342,6 +1342,37 @@ describe('list-tools script', () => { expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('| 1 |')); }); + // The whitelist overrides the deny pattern, as at runtime: a broad pattern with explicit + // exceptions keeps the exceptions. + it('counts a whitelisted tool its deny pattern matches', async () => { + mockManager.getAllToolDefinitionsUnfiltered.mockReturnValue([ + { name: 'browse_projects', description: 'Browse', inputSchema: { type: 'object' } }, + { name: 'browse_wiki', description: 'Wiki', inputSchema: { type: 'object' } }, + { name: 'manage_files', description: 'Files', inputSchema: { type: 'object' } }, + ]); + mockProfileLoader.listProfiles.mockResolvedValue([ + { + name: 'exceptions', + readOnly: false, + isBuiltIn: true, + isPreset: true, + description: 'Exceptions', + }, + ]); + mockProfileLoader.loadPreset.mockResolvedValue({ + description: 'Exceptions', + read_only: false, + denied_tools_regex: '^browse_', + allowed_tools: ['browse_projects', 'browse_wiki'], + }); + process.argv = ['node', 'list-tools.ts', '--presets']; + + const { main } = await import('../../../src/cli/list-tools'); + await main(); + + expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('| 2 |')); + }); + it('should count tools correctly for preset with feature flags', async () => { mockManager.getAllToolDefinitionsUnfiltered.mockReturnValue([ { name: 'browse_projects', description: 'Browse', inputSchema: { type: 'object' } }, diff --git a/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts b/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts index e1cc35b2..4eb92f65 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts @@ -110,6 +110,19 @@ describe('toolRestriction', () => { expect(toolRestriction(policy, browse)).toBeNull(); }); + // The whitelist overrides the deny pattern: a broad pattern with explicit exceptions keeps + // the exceptions. + it('keeps a whitelisted tool its deny pattern matches', () => { + const policy = buildPolicy( + { allowed_tools: ['browse_projects'], denied_tools_regex: '^browse_' }, + { preset: 'mine' }, + {}, + ); + + expect(toolRestriction(policy, core)).toBeNull(); + expect(toolRestriction(policy, browse)).toBe("preset 'mine' does not include it"); + }); + // Otherwise read-only mode or a whitelist could lock the caller out of the very tool // that turns them off again. it.each(['manage_context', 'get_settings', 'update_settings', 'open_settings_panel'])( diff --git a/packages/gitlab-mcp/tests/unit/configuration/scope-ids.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scope-ids.test.ts new file mode 100644 index 00000000..e346e49c --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-ids.test.ts @@ -0,0 +1,73 @@ +/** + * A working scope is saved as paths, while a call may name its project or group by numeric + * id. The ids are read from GitLab and checked as the paths they stand for. + */ + +import { withTargetPaths } from '../../../src/configuration/scope-ids'; +import { buildPolicy, callRestriction } from '../../../src/configuration/policy'; +import { gitlab } from '../../../src/utils/gitlab-api'; + +jest.mock('../../../src/utils/gitlab-api', () => ({ gitlab: { get: jest.fn() } })); + +const notFound = new Error('GitLab API error: 404 Not Found'); + +/** A GitLab that knows project 7 (team/app), group 4 (team) and project 9 (other/api). */ +const read = jest.fn(async (path: string): Promise => { + if (path === 'projects/7') return { id: 7, path_with_namespace: 'team/app' }; + if (path === 'projects/9') return { id: 9, path_with_namespace: 'other/api' }; + if (path === 'namespaces/4') return { id: 4, full_path: 'team' }; + throw notFound; +}); + +beforeEach(() => read.mockClear()); + +describe('withTargetPaths', () => { + it('replaces numeric project and group ids with their paths', async () => { + expect( + await withTargetPaths({ action: 'list', project_id: 7, namespace_id: '4' }, read), + ).toEqual({ action: 'list', project_id: 'team/app', namespace_id: 'team' }); + }); + + // A namespace argument may be a group or a project; a group is tried first, as the + // handlers do for a path without a slash. + it('reads a numeric namespace as a group, else as a project', async () => { + expect(await withTargetPaths({ namespace: '4' }, read)).toEqual({ namespace: 'team' }); + expect(await withTargetPaths({ namespace: '7' }, read)).toEqual({ namespace: 'team/app' }); + }); + + // An id GitLab does not know stays numeric, and the scope check then refuses it. + it('keeps an id GitLab does not resolve', async () => { + expect(await withTargetPaths({ project_id: '12' }, read)).toEqual({ project_id: '12' }); + }); + + // Without a reader the caller's own GitLab client is asked; an empty answer resolves nothing. + it("reads with the caller's GitLab client and keeps an id it gets no path for", async () => { + jest.mocked(gitlab.get).mockResolvedValue(undefined); + + expect(await withTargetPaths({ project_id: 7 })).toEqual({ project_id: 7 }); + expect(gitlab.get).toHaveBeenCalledWith('projects/7'); + }); + + it('asks GitLab nothing when no target is numeric', async () => { + const args = { project_id: 'team/app' }; + + expect(await withTargetPaths(args, read)).toBe(args); + expect(read).not.toHaveBeenCalled(); + }); + + it('checks a numeric id as the path it stands for', async () => { + const policy = buildPolicy( + undefined, + { scope: { type: 'group', path: 'team', includeSubgroups: true } }, + {}, + ); + const tool = { name: 'browse_merge_requests', readOnly: true }; + + expect( + callRestriction(policy, tool, await withTargetPaths({ action: 'list', project_id: 7 }, read)), + ).toBeNull(); + expect( + callRestriction(policy, tool, await withTargetPaths({ action: 'list', project_id: 9 }, read)), + ).toMatch(/other\/api/); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts index 32662e23..557551c9 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts @@ -68,6 +68,36 @@ describe('scopedArgs', () => { }); }); + // Instance-wide administrator listings read the scope's own project or group instead. + it.each([ + [ + 'browse_deploy_keys', + { action: 'list', per_page: 5 }, + project, + { action: 'list', per_page: 5, project_id: 'team/app' }, + ], + [ + 'browse_runners', + { action: 'list_all', status: 'ONLINE' }, + project, + { action: 'list_project', status: 'ONLINE', project_id: 'team/app' }, + ], + [ + 'browse_runners', + { action: 'list_owned' }, + groupOnly, + { action: 'list_group', group_id: 'team' }, + ], + [ + 'browse_audit_events', + { action: 'list_instance', created_after: '2026-01-01' }, + project, + { action: 'list_project', created_after: '2026-01-01', project_id: 'team/app' }, + ], + ])('reads %s %j under %j as %j', (tool, args, scope, expected) => { + expect(scopedArgs(tool, args, scope)).toEqual(expected); + }); + it('lists the merge requests of the scope project', () => { expect( scopedArgs('browse_merge_requests', { action: 'list', state: 'opened' }, project), @@ -91,6 +121,14 @@ describe('scopedArgs', () => { ['manage_project', { action: 'create', name: 'app' }, project], ['browse_events', { action: 'project', project_id: 'team/app' }, project], ['browse_events', { action: 'user' }, group], + ['browse_deploy_keys', { action: 'list', project_id: 'team/app' }, project], + ['browse_deploy_keys', { action: 'list', public: true }, project], + ['browse_deploy_keys', { action: 'list' }, group], + ['browse_runners', { action: 'list_project', project_id: 'team/app' }, project], + ['browse_audit_events', { action: 'list_group', group_id: 'team' }, project], + ['browse_audit_events', { action: 'list_instance' }, group], + ['browse_audit_events', { action: 'list_instance', entity_type: 'User' }, project], + ['browse_audit_events', { action: 'list_instance', entity_id: 3 }, project], ])('leaves %s %j under %j as it is', (tool, args, scope) => { expect(scopedArgs(tool, args, scope)).toBe(args); }); @@ -154,6 +192,37 @@ describe('targetlessRestriction', () => { ); }); + // GitLab lists deploy keys per project and audit events per project or per group alone, + // and the instance-wide public key listing has no per-project form. + it.each([ + ['browse_deploy_keys', { action: 'list' }, group, /deploy keys/], + ['browse_deploy_keys', { action: 'list' }, several, /deploy keys/], + ['browse_deploy_keys', { action: 'list', public: true }, project, /public deploy keys/], + ['browse_runners', { action: 'list_all' }, several, /runners/], + ['browse_audit_events', { action: 'list_instance' }, group, /audit events/], + [ + 'browse_audit_events', + { action: 'list_instance', entity_type: 'User', entity_id: 4 }, + project, + /audit events/, + ], + ])('refuses the instance listing %s %j under %j', (tool, args, scope, reason) => { + expect(targetlessRestriction(tool, args, scope)).toMatch(reason); + }); + + it.each([ + ['browse_deploy_keys', { action: 'list' }, project], + ['browse_deploy_keys', { action: 'list', project_id: 'team/app' }, several], + ['browse_runners', { action: 'list_all' }, group], + ['browse_runners', { action: 'list_project', project_id: 'team/app' }, several], + ['browse_audit_events', { action: 'list_instance' }, project], + ['browse_audit_events', { action: 'list_group', group_id: 'team' }, several], + ['browse_deploy_keys', { action: 'get', project_id: 'team/app', key_id: 1 }, group], + ['browse_events', { action: 'project', project_id: 'team/app' }, group], + ])('allows the listing %s %j under %j', (tool, args, scope) => { + expect(targetlessRestriction(tool, args, scope)).toBeNull(); + }); + it('refuses a vulnerability listing a scope of several targets cannot narrow', () => { expect(targetlessRestriction('browse_vulnerabilities', { action: 'list' }, several)).toMatch( /list them for one of them/, diff --git a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts index cee11598..d9c60a2a 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts @@ -108,6 +108,26 @@ describe('executeScoped', () => { }); }); + // A page that starts past the rows a call may read cannot be filled: no request is made. + it('answers a page past the readable rows without asking GitLab', async () => { + const run = jest.fn(); + + const result = await executeScoped( + run, + 'browse_projects', + { action: 'search', page: 51, per_page: 20 }, + groupPolicy, + ); + + expect(run).not.toHaveBeenCalled(); + expect(result).toEqual({ + items: [], + partial: true, + message: + 'A scoped listing reads the first 1000 rows GitLab returns, and page 51 starts after them. Narrow the listing (for example with a search) to see the rest.', + }); + }); + it('fails a project scope listing on a failure that is not an Error', async () => { const run = jest.fn().mockRejectedValue('connection reset'); diff --git a/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts b/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts index 962d8837..4209b4d0 100644 --- a/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts +++ b/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts @@ -8,6 +8,7 @@ import { extractProjectsFromArgs, extractGroupsFromArgs, enforceArgsScope, + numericTargets, } from '../../../src/profiles/scope-enforcer'; import { ProjectPreset } from '../../../src/profiles/types'; @@ -538,6 +539,25 @@ describe('enforceArgsScope', () => { else expect(check).not.toThrow(); }); + // Numeric ids are resolved to paths before the check; these are the arguments to resolve. + it('lists the arguments that name a project or a group by numeric id', () => { + expect( + numericTargets({ + project_id: 7, + target_project_id: '8', + namespace: '9', + group_id: 'team', + namespace_id: 4, + projectId: 'team/app', + }), + ).toEqual([ + { field: 'project_id', id: '7', kind: 'project' }, + { field: 'namespace', id: '9', kind: 'either' }, + { field: 'target_project_id', id: '8', kind: 'project' }, + { field: 'namespace_id', id: '4', kind: 'group' }, + ]); + }); + it('should allow a destination inside the scope', () => { const enforcer = new ScopeEnforcer({ group: 'team', includeSubgroups: true }); From f004a66896235218b8cf49ae0044f439eb37d936 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 11 Oct 2026 03:16:40 +0300 Subject: [PATCH 05/11] fix(settings): fail numeric id lookups on errors other than not found Resolving a numeric project or group id for the scope check turned every failed lookup into an unresolved id, so an expired or revoked token read as a target outside the working scope and never reached the reauthorization challenge. Only a 404 keeps the id numeric now; any other failure fails the call with GitLab's own error. Regression tests: 401, 403 insufficient_scope, 500 and a non-Error failure, for a project id and for a namespace read as a group. --- .../gitlab-mcp/src/configuration/scope-ids.ts | 23 +++++++++++----- .../unit/configuration/scope-ids.test.ts | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/gitlab-mcp/src/configuration/scope-ids.ts b/packages/gitlab-mcp/src/configuration/scope-ids.ts index ab32c67e..6e7f6792 100644 --- a/packages/gitlab-mcp/src/configuration/scope-ids.ts +++ b/packages/gitlab-mcp/src/configuration/scope-ids.ts @@ -5,6 +5,7 @@ import { numericTargets, type NumericTargetKind } from '../profiles/scope-enforcer'; import { gitlab } from '../utils/gitlab-api'; +import { parseGitLabApiError } from '../utils/error-handler'; type Args = Record; /** Reads one GitLab REST resource with the caller's token and instance. */ @@ -14,8 +15,8 @@ const readWithCaller: ReadResource = (path) => gitlab.get(path); /** * The call's arguments with every numeric project or group id replaced by its path, for the - * scope check only; the call itself keeps the ids. An id GitLab does not resolve stays - * numeric, and the check refuses it. No request is made when no target is numeric. + * scope check only; the call itself keeps the ids. An id GitLab does not know stays numeric, + * and the check refuses it. No request is made when no target is numeric. */ export async function withTargetPaths( args: Args, @@ -48,12 +49,20 @@ async function pathOf( return field(read, `projects/${id}`, 'path_with_namespace'); } +/** + * One field of a GitLab resource, or undefined when GitLab has no such resource (404). Any + * other failure fails the call with GitLab's error, so a rejected token reaches the + * reauthorization challenge instead of reading as a target outside the scope. + */ async function field(read: ReadResource, path: string, name: string): Promise { + let resource: unknown; try { - const value = ((await read(path)) as Record | undefined)?.[name]; - return typeof value === 'string' ? value : undefined; - } catch { - // Unknown or unreadable: the id stays numeric and the scope check refuses it. - return undefined; + resource = await read(path); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + if (parseGitLabApiError(message)?.status === 404) return undefined; + throw error; } + const value = (resource as Record | undefined)?.[name]; + return typeof value === 'string' ? value : undefined; } diff --git a/packages/gitlab-mcp/tests/unit/configuration/scope-ids.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scope-ids.test.ts index e346e49c..c5e19f23 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scope-ids.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-ids.test.ts @@ -40,6 +40,33 @@ describe('withTargetPaths', () => { expect(await withTargetPaths({ project_id: '12' }, read)).toEqual({ project_id: '12' }); }); + // Only a definitive not-found keeps an id numeric. A rejected token or any other failure + // fails the call with GitLab's own error, so an expired sign-in reaches the reauthorization + // challenge instead of reading as an out-of-scope target. + it.each([ + ['GitLab API error: 401 Unauthorized'], + ['GitLab API error: 403 Forbidden - insufficient_scope'], + ['GitLab API error: 500 Internal Server Error'], + ])('fails on %s', async (message) => { + const failing = jest.fn().mockRejectedValue(new Error(message)); + + await expect(withTargetPaths({ project_id: 7 }, failing)).rejects.toThrow(message); + }); + + it('fails on a failure that is not an Error', async () => { + const failing = jest.fn().mockRejectedValue('connection reset'); + + await expect(withTargetPaths({ project_id: 7 }, failing)).rejects.toBe('connection reset'); + }); + + // A namespace that is not a group is read as a project; a failure there still fails. + it('fails when the group lookup of a namespace fails other than not found', async () => { + const failing = jest.fn().mockRejectedValue(new Error('GitLab API error: 401 Unauthorized')); + + await expect(withTargetPaths({ namespace: '4' }, failing)).rejects.toThrow('401'); + expect(failing).toHaveBeenCalledTimes(1); + }); + // Without a reader the caller's own GitLab client is asked; an empty answer resolves nothing. it("reads with the caller's GitLab client and keeps an id it gets no path for", async () => { jest.mocked(gitlab.get).mockResolvedValue(undefined); From 708bc64878dc27e5aec47dcc10cbcb2033af2afd Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 11 Oct 2026 04:02:50 +0300 Subject: [PATCH 06/11] fix(settings): save local settings lock-free, close scoped bypasses - Local settings are kept as numbered generations in a directory (~/.config/gitlab-mcp/settings/). A write saves the next generation with an exclusive link and, when another write saved it first, reads the new state and checks its version again. The lock file is gone: breaking a dead lock could free the path for a third writer, and a writer suspended past the lock lifetime overwrote newer settings when it resumed. A temporary file left after a successful save no longer reports the write as failed. - An action the operator denied stays denied under a working scope; the scoped emulations ran other actions in its place. - A project listing keeps the caller's include_subgroups=false under a group scope that includes subgroups. - A global project search under a project scope answers with the scope projects that match (project search has no projects scope); a snippet title search, instance-wide only, is refused under a scope. - A fork naming both namespace and namespace_path has both checked. - Scope lookups for update_settings and set_scope fail with GitLab's error on 401 or 403 insufficient_scope, so the call gets the reauthorization challenge instead of an unknown-path answer. Regression tests: a writer resuming after a newer save (failed on the lock), lost races across accounts, clean-up failures after a save, denied actions under emulation, subgroup exclusion, project and snippet searches, both fork destinations, and auth failures of the scope lookup. --- .../gitlab-mcp/docs/configuration/settings.md | 18 +- .../gitlab-mcp/src/configuration/index.ts | 8 +- .../src/configuration/scope-targets.ts | 15 +- .../src/configuration/scoped-execution.ts | 14 + .../src/configuration/settings-store.ts | 198 ++++---- packages/gitlab-mcp/src/utils/namespace.ts | 28 +- .../tests/setup/isolatedSettings.ts | 6 +- .../tests/unit/configuration/index.test.ts | 12 +- .../tests/unit/configuration/policy.test.ts | 9 + .../tests/unit/configuration/replicas.test.ts | 2 +- .../unit/configuration/scope-targets.test.ts | 23 + .../configuration/scoped-execution.test.ts | 51 +++ .../unit/configuration/settings-store.test.ts | 433 +++++++++--------- .../entities/context/context-manager.test.ts | 15 + .../unit/entities/context/handlers.test.ts | 9 - .../unit/entities/context/registry.test.ts | 9 - .../gitlab-mcp/tests/unit/handlers.test.ts | 2 + .../unit/utils/namespace.coverage.test.ts | 43 ++ 18 files changed, 548 insertions(+), 347 deletions(-) diff --git a/packages/gitlab-mcp/docs/configuration/settings.md b/packages/gitlab-mcp/docs/configuration/settings.md index d19f262e..e1310e8b 100644 --- a/packages/gitlab-mcp/docs/configuration/settings.md +++ b/packages/gitlab-mcp/docs/configuration/settings.md @@ -60,7 +60,10 @@ of the scope project, all or owned runners those available to the scope's projec where the scope has no such form, the listing is refused. A global search or vulnerability listing is refused under a scope of several projects or groups, and under a group scope without subgroups (GitLab's group results always include them); use one project or group -instead. `manage_todos mark_all_done` marks only the scope's pending todos done and leaves +instead. A global search for projects under a project scope answers with the scope's +projects that match; a search of snippet titles, which GitLab offers only instance-wide, is +refused under any scope. An action the administrator denied stays denied when the scope +runs other requests in its place. `manage_todos mark_all_done` marks only the scope's pending todos done and leaves the others pending. A project created or forked without a namespace goes into the scope's group; with no single group to put it in, and for a group without a parent, the call is refused. @@ -125,14 +128,17 @@ recommendations, and `find_scope_targets` finds projects and groups to scope to. ## Storage -| Deployment | Account settings are kept in | -| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OAuth with PostgreSQL session storage | The session storage, shared by every replica that uses it | -| OAuth with file session storage | The session storage file. File storage serves one server process: replicas need PostgreSQL | -| OAuth with sessions in memory (the default), or a local server with a static token | `~/.config/gitlab-mcp/settings.json` of the user running the server, shared by every server process of that user; settings survive a restart even though memory sessions do not | +| Deployment | Account settings are kept in | +| ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OAuth with PostgreSQL session storage | The session storage, shared by every replica that uses it | +| OAuth with file session storage | The session storage file. File storage serves one server process: replicas need PostgreSQL | +| OAuth with sessions in memory (the default), or a local server with a static token | `~/.config/gitlab-mcp/settings/` of the user running the server, shared by every server process of that user; settings survive a restart even though memory sessions do not | Writes are compare-and-set: two chats editing different settings at the same time both keep their change. Chat overrides are held by the server process that serves the chat. +The local settings directory keeps each saved state as a new numbered file, created only if +no other process saved that number first, so there is no lock: a server process that stops +or is suspended mid-write never blocks the others or overwrites newer settings. If a saved preset is later removed from the server, chats that use it work read-only until another preset is chosen, and `check_connection` reports it. diff --git a/packages/gitlab-mcp/src/configuration/index.ts b/packages/gitlab-mcp/src/configuration/index.ts index 6bc0e1ba..8957d921 100644 --- a/packages/gitlab-mcp/src/configuration/index.ts +++ b/packages/gitlab-mcp/src/configuration/index.ts @@ -8,7 +8,7 @@ import { isOAuthEnabled } from '../oauth/config'; import { sessionStore } from '../oauth/session-store'; import { ProfileLoader } from '../profiles/loader'; import { ConfigurationService } from './service'; -import { LocalSettingsFile, localSettingsPath, type SettingsStore } from './settings-store'; +import { LocalSettingsFile, localSettingsDir, type SettingsStore } from './settings-store'; export { ConfigurationError } from './service'; export type { ResolvedConfiguration } from './service'; @@ -20,7 +20,7 @@ let settingsStore: Promise | undefined; * Settings live with the sessions when that storage can keep them (file or PostgreSQL), * shared by every replica using it. Otherwise (a local server, sessions held in memory as * the OAuth default, or a database package older than the server) they live in the - * settings file, so saved settings survive a restart and calls keep working. + * local settings directory, so saved settings survive a restart and calls keep working. */ function storeOfDeployment(): Promise { settingsStore ??= (async (): Promise => { @@ -35,11 +35,11 @@ function storeOfDeployment(): Promise { } if (sessionsStored && sessionStore.getBackendType() !== 'memory') { logWarn( - 'The session storage cannot keep account settings; upgrade @structured-world/gitlab-mcp-db to the version of this server. Settings are kept in the settings file meanwhile.', + 'The session storage cannot keep account settings; upgrade @structured-world/gitlab-mcp-db to the version of this server. Settings are kept in the local settings directory meanwhile.', { backend: sessionStore.getBackendType() }, ); } - return new LocalSettingsFile(localSettingsPath()); + return new LocalSettingsFile(localSettingsDir()); })().catch((error: unknown) => { // A failed open is retried by the next request instead of failing until restart. settingsStore = undefined; diff --git a/packages/gitlab-mcp/src/configuration/scope-targets.ts b/packages/gitlab-mcp/src/configuration/scope-targets.ts index e398b915..93a4cbc8 100644 --- a/packages/gitlab-mcp/src/configuration/scope-targets.ts +++ b/packages/gitlab-mcp/src/configuration/scope-targets.ts @@ -33,6 +33,10 @@ export function targetlessRestriction(tool: string, args: Args, scope: ScopeConf return unnarrowable(scope, 'vulnerabilities cannot be listed', 'list them for'); } if (tool === 'browse_search' && args.action === 'global') { + // Group and project search have no snippet_titles scope (lib/api/helpers/search_helpers.rb). + if (args.scope === 'snippet_titles') { + return 'snippet titles are searched across the whole instance only and cannot be limited to the working scope'; + } return unnarrowable(scope, 'a global search cannot be limited', 'search within'); } return INSTANCE_LISTINGS[tool]?.(args, singleTarget(scope)) ?? null; @@ -108,8 +112,10 @@ function namesDestination(args: Args): boolean { */ export function scopeCheckArgs(tool: string, args: Args): Args { if (!isProjectCreation(tool, args) || !hasValue(args.namespace)) return args; + // Checked as the group-valued namespace_id, which create and fork do not take, so a fork + // naming both namespace and namespace_path has each destination checked. const { namespace, ...rest } = args; - return { ...rest, namespace_path: rest.namespace_path ?? namespace }; + return { ...rest, namespace_id: namespace }; } /** @@ -157,9 +163,14 @@ const NARROWERS: Record args.action === 'list' && !hasValue(args.group_id) && 'group' in target - ? { ...args, group_id: target.group, include_subgroups: scope.includeSubgroups !== false } + ? { + ...args, + group_id: target.group, + include_subgroups: scope.includeSubgroups !== false && args.include_subgroups !== false, + } : null, browse_merge_requests: (args, target) => args.action === 'list' && !hasValue(args.project_id) && 'project' in target diff --git a/packages/gitlab-mcp/src/configuration/scoped-execution.ts b/packages/gitlab-mcp/src/configuration/scoped-execution.ts index 7bfb7d36..4d48350c 100644 --- a/packages/gitlab-mcp/src/configuration/scoped-execution.ts +++ b/packages/gitlab-mcp/src/configuration/scoped-execution.ts @@ -7,6 +7,7 @@ import type { EffectivePolicy } from './policy'; import type { ScopeEnforcer } from '../profiles/scope-enforcer'; import { parseGitLabApiError } from '../utils/error-handler'; +import { assertActionAllowed } from '../entities/utils'; import { isFilteredListing, scopedArgs, scopedResult, scopeProjectsOnly } from './scope-targets'; type Args = Record; @@ -62,6 +63,9 @@ export async function executeScoped( ): Promise { const { scope, scopeEnforcer } = policy; if (!scope || !scopeEnforcer) return run(tool, requested); + // The emulations below run other actions in place of the requested one; an action the + // operator denied must stay denied rather than run through them. + if (typeof requested.action === 'string') assertActionAllowed(tool, requested.action); if (tool === 'manage_todos' && requested.action === 'mark_all_done') { return markScopeTodosDone(run, scopeEnforcer); } @@ -74,6 +78,16 @@ export async function executeScoped( ) { return listScopeProjects(run, projects, requested); } + // Project search has no projects scope: the scope's projects matching the search answer it. + if ( + projects && + tool === 'browse_search' && + requested.action === 'global' && + requested.scope === 'projects' + ) { + const { search, page, per_page } = requested; + return listScopeProjects(run, projects, { search, page, per_page }); + } const args = scopedArgs(tool, requested, scope); if (isFilteredListing(tool, args)) return fillScopedPage(run, tool, args, scopeEnforcer); return run(tool, args); diff --git a/packages/gitlab-mcp/src/configuration/settings-store.ts b/packages/gitlab-mcp/src/configuration/settings-store.ts index 9b2ceee2..70e70832 100644 --- a/packages/gitlab-mcp/src/configuration/settings-store.ts +++ b/packages/gitlab-mcp/src/configuration/settings-store.ts @@ -1,7 +1,7 @@ /** * Where account settings are kept: in the session storage when it can keep them, otherwise - * in a file in the configuration directory of the user running the server, shared by every - * server process of that user. + * in a directory in the configuration directory of the user running the server, shared by + * every server process of that user. */ import { randomUUID } from 'node:crypto'; @@ -28,29 +28,37 @@ interface SettingsFile { accounts: Record; } -const LOCK_RETRY_MS = 25; -const LOCK_ATTEMPTS = 200; -/** A lock older than this was left by a process that died while holding it. */ -const STALE_LOCK_MS = 10_000; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +/** The settings as of one generation; generation 0 is the empty state before any write. */ +interface Snapshot { + generation: number; + file: SettingsFile; } +/** How many lost races a write retries before it gives up. */ +const SAVE_ATTEMPTS = 100; +/** A temporary file older than this belongs to a writer that died before saving it. */ +const ABANDONED_TEMP_MS = 60 * 60_000; +const GENERATION_FILE = /^(\d+)\.json$/; +const TEMP_SUFFIX = '.tmp'; + function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +const errorCode = (error: unknown): string | undefined => (error as NodeJS.ErrnoException).code; + /** - * Settings of a local server in one JSON file. Every read goes to the file, and a write - * holds a lock file around read-compare-write, so several local server processes (one per - * client) share the settings without losing each other's edits. + * Settings of a local server as numbered generations in one directory. A write saves the + * next generation with an exclusive link, which fails when another write saved it first; the + * write then reads the new state and checks its version again. There is no lock, so a + * process that dies or is suspended mid-write cannot block others or overwrite newer + * settings when it resumes. */ export class LocalSettingsFile implements SettingsStore { - constructor(private readonly filePath: string) {} + constructor(private readonly dir: string) {} async get(accountKey: string): Promise { - return (await this.read()).accounts[accountKey]; + return (await this.latest()).file.accounts[accountKey]; } async put( @@ -58,8 +66,10 @@ export class LocalSettingsFile implements SettingsStore { settings: AccountSettings, expectedVersion: number, ): Promise { - return this.withLock(async () => { - const file = await this.read(); + await fs.promises.mkdir(this.dir, { recursive: true }); + // Each attempt depends on the state the previous one lost to. + for (let attempt = 0; attempt < SAVE_ATTEMPTS; attempt++) { + const { generation, file } = await this.latest(); const current = file.accounts[accountKey]?.version ?? 0; if (current !== expectedVersion) return undefined; const record: AccountSettingsRecord = { @@ -69,22 +79,52 @@ export class LocalSettingsFile implements SettingsStore { updatedAt: Date.now(), }; file.accounts[accountKey] = record; - await this.write(file); - return record; - }); + if (await this.save(generation + 1, file)) { + await this.prune(generation + 1); + return record; + } + } + throw new Error(`Settings are being changed by other processes: ${this.dir}`); + } + + /** The newest generation, or the empty state when none was saved yet. */ + private async latest(): Promise { + // A generation found can be pruned by a newer write before it is read: read again. + for (;;) { + const generation = await this.newestGeneration(); + if (generation === 0) return { generation, file: { version: 1, accounts: {} } }; + const filePath = this.generationPath(generation); + try { + return { + generation, + file: this.parse(await fs.promises.readFile(filePath, 'utf-8'), filePath), + }; + } catch (error: unknown) { + if (errorCode(error) !== 'ENOENT') throw error; + } + } } - private async read(): Promise { - let content: string; + private async newestGeneration(): Promise { + let names: string[]; try { - content = await fs.promises.readFile(this.filePath, 'utf-8'); + names = await fs.promises.readdir(this.dir); } catch (error: unknown) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, accounts: {} }; + if (errorCode(error) === 'ENOENT') return 0; throw error; } + let newest = 0; + for (const name of names) { + const match = GENERATION_FILE.exec(name); + if (match) newest = Math.max(newest, Number(match[1])); + } + return newest; + } + + private parse(content: string, filePath: string): SettingsFile { const parsed: unknown = JSON.parse(content); if (!isPlainObject(parsed)) { - throw new Error(`Settings file is not a JSON object: ${this.filePath}`); + throw new Error(`Settings file is not a JSON object: ${filePath}`); } const accounts: Record = {}; const stored = isPlainObject(parsed.accounts) ? parsed.accounts : {}; @@ -100,15 +140,19 @@ export class LocalSettingsFile implements SettingsStore { settings: settings.data, }; } else { - logWarn('Ignoring invalid saved settings', { file: this.filePath }); + logWarn('Ignoring invalid saved settings', { file: filePath }); } } return { version: 1, accounts }; } - private async write(file: SettingsFile): Promise { - await fs.promises.mkdir(path.dirname(this.filePath), { recursive: true }); - const temp = `${this.filePath}.${process.pid}.tmp`; + /** + * Saves a generation unless another write saved it first (false). The content is written + * and synced in a temporary file, then linked under the generation's name: the link is + * atomic and fails when the name exists, so a generation is saved once, complete. + */ + private async save(generation: number, file: SettingsFile): Promise { + const temp = path.join(this.dir, `.${process.pid}.${randomUUID()}${TEMP_SUFFIX}`); const handle = await fs.promises.open(temp, 'w', 0o600); try { await handle.writeFile(JSON.stringify(file, null, 2), 'utf-8'); @@ -116,74 +160,54 @@ export class LocalSettingsFile implements SettingsStore { } finally { await handle.close(); } - await fs.promises.rename(temp, this.filePath); - await syncDirectory(path.dirname(this.filePath)); - } - - private async withLock(fn: () => Promise): Promise { - const lockPath = `${this.filePath}.lock`; - await fs.promises.mkdir(path.dirname(this.filePath), { recursive: true }); - for (let attempt = 0; ; attempt++) { - try { - const handle = await fs.promises.open(lockPath, 'wx', 0o600); - await handle.close(); - break; - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; - if (await this.removeStaleLock(lockPath)) continue; - if (attempt >= LOCK_ATTEMPTS) { - throw new Error(`Settings file is locked by another process: ${lockPath}`, { - cause: error, - }); - } - await sleep(LOCK_RETRY_MS); - } - } try { - return await fn(); + await fs.promises.link(temp, this.generationPath(generation)); + } catch (error: unknown) { + // EEXIST: another write saved this generation. ENOENT: the temporary file was cleared + // as abandoned while this process was suspended. Either way, prepare the write again. + const code = errorCode(error); + if (code === 'EEXIST' || code === 'ENOENT') return false; + throw error; } finally { - await fs.promises.rm(lockPath, { force: true }); + // Once linked the generation is saved: a temporary file left behind must not report + // the write as failed. A later write prunes it as abandoned. + await fs.promises.rm(temp, { force: true }).catch(() => undefined); } + await syncDirectory(this.dir); + return true; } /** - * Removes a lock left by a dead process; true when the lock can be taken again at once. - * The lock is claimed by an atomic rename and checked again, because between the age check - * and the removal another process may have replaced the dead lock with a fresh one; that - * one is put back instead of removed. The claimed lock must still be old: an inode check - * alone cannot tell, as Linux reuses a freed inode number at once. + * Removes generations older than the one just saved and temporary files of writers that + * died before saving. Best effort: a leftover is never read as the current settings. */ - private async removeStaleLock(lockPath: string): Promise { - let seen: fs.Stats; - try { - seen = await fs.promises.stat(lockPath); - } catch { - // Released meanwhile: try again at once. - return true; - } - if (Date.now() - seen.mtimeMs < STALE_LOCK_MS) return false; - const claimed = `${lockPath}.${process.pid}.${randomUUID()}.stale`; - try { - await fs.promises.rename(lockPath, claimed); - } catch { - // Another process removed or claimed it first. - return true; - } - const taken = await fs.promises.stat(claimed); - const sameLock = taken.ino === seen.ino && taken.dev === seen.dev; - if (!sameLock || Date.now() - taken.mtimeMs < STALE_LOCK_MS) { - // A fresh lock taken after the age check: hand it back to its holder. If the path was - // taken again meanwhile, that newer lock stays and this one is dropped. - await fs.promises.link(claimed, lockPath).catch(() => undefined); - await fs.promises.rm(claimed, { force: true }); - return false; - } - await fs.promises.rm(claimed, { force: true }); - return true; + private async prune(saved: number): Promise { + const names = await fs.promises.readdir(this.dir).catch(() => [] as string[]); + const now = Date.now(); + await Promise.all( + names.map(async (name) => { + const target = path.join(this.dir, name); + try { + const match = GENERATION_FILE.exec(name); + if (match && Number(match[1]) < saved) { + await fs.promises.rm(target, { force: true }); + } else if (name.endsWith(TEMP_SUFFIX)) { + const { mtimeMs } = await fs.promises.stat(target); + if (now - mtimeMs > ABANDONED_TEMP_MS) await fs.promises.rm(target, { force: true }); + } + } catch { + // Removed by another writer meanwhile, or not removable now: the next write retries. + } + }), + ); + } + + private generationPath(generation: number): string { + return path.join(this.dir, `${generation}.json`); } } /** Default location of a local server's settings. */ -export function localSettingsPath(): string { - return path.join(os.homedir(), '.config', 'gitlab-mcp', 'settings.json'); +export function localSettingsDir(): string { + return path.join(os.homedir(), '.config', 'gitlab-mcp', 'settings'); } diff --git a/packages/gitlab-mcp/src/utils/namespace.ts b/packages/gitlab-mcp/src/utils/namespace.ts index afbac95f..235ba0d5 100644 --- a/packages/gitlab-mcp/src/utils/namespace.ts +++ b/packages/gitlab-mcp/src/utils/namespace.ts @@ -65,23 +65,35 @@ export async function detectNamespaceType(namespacePath: string): Promise<'proje } /** - * Verify if a namespace exists as the specified type by making a lightweight API call + * Verify if a namespace exists as the specified type by making a lightweight API call. + * A rejected sign-in (401, or 403 insufficient_scope) fails with GitLab's error, so the call + * reaches the reauthorization challenge; other failures read as "not this type". */ async function verifyNamespaceType( namespacePath: string, type: 'project' | 'group', ): Promise { + const entityType = type === 'project' ? 'projects' : 'groups'; + const apiUrl = `${getGitLabBaseUrl()}/api/v4/${entityType}/${encodeURIComponent(namespacePath)}`; + let response: Response; try { - const entityType = type === 'project' ? 'projects' : 'groups'; - const apiUrl = `${getGitLabBaseUrl()}/api/v4/${entityType}/${encodeURIComponent(namespacePath)}`; - - const response = await enhancedFetch(apiUrl); - - return response.ok; + response = await enhancedFetch(apiUrl); } catch { - // If API call fails, return false + // Unreachable: the caller falls back or reports that GitLab could not confirm the path. return false; } + if (response.ok) return true; + if (response.status === 401) { + throw new Error(`GitLab API error: 401 ${response.statusText}`); + } + if (response.status === 403) { + const body = await response.text().catch(() => ''); + // RFC 6750 section 3.1: the token lacks a scope, not access to this path. + if (body.includes('insufficient_scope')) { + throw new Error(`GitLab API error: 403 ${response.statusText} - insufficient_scope`); + } + } + return false; } /** diff --git a/packages/gitlab-mcp/tests/setup/isolatedSettings.ts b/packages/gitlab-mcp/tests/setup/isolatedSettings.ts index e585a626..207e76f2 100644 --- a/packages/gitlab-mcp/tests/setup/isolatedSettings.ts +++ b/packages/gitlab-mcp/tests/setup/isolatedSettings.ts @@ -1,6 +1,6 @@ /** - * Settings a test reads or saves through the local settings file go to a temporary file, - * never the user's own ~/.config/gitlab-mcp/settings.json. A test file that mocks the + * Settings a test reads or saves through the local settings directory go to a temporary + * directory, never the user's own ~/.config/gitlab-mcp/settings. A test file that mocks the * settings store itself replaces this. */ @@ -12,7 +12,7 @@ const mockIsolatedSettingsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitlab-mc jest.mock('../../src/configuration/settings-store', () => ({ ...jest.requireActual('../../src/configuration/settings-store'), - localSettingsPath: () => `${mockIsolatedSettingsDir}/settings.json`, + localSettingsDir: () => `${mockIsolatedSettingsDir}/settings`, })); afterAll(() => { diff --git a/packages/gitlab-mcp/tests/unit/configuration/index.test.ts b/packages/gitlab-mcp/tests/unit/configuration/index.test.ts index c8df5b31..6570798c 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/index.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/index.test.ts @@ -42,10 +42,10 @@ jest.mock('../../../src/session-manager', () => ({ getSessionManager: () => ({ notifyToolsListChanged: mockNotify }), })); -let mockSettingsPath = ''; +let mockSettingsDir = ''; jest.mock('../../../src/configuration/settings-store', () => ({ ...jest.requireActual('../../../src/configuration/settings-store'), - localSettingsPath: () => mockSettingsPath, + localSettingsDir: () => mockSettingsDir, })); import { getConfigurationService, resetConfigurationService } from '../../../src/configuration'; @@ -71,7 +71,7 @@ describe('getConfigurationService', () => { mockKeepsSettings = true; delete process.env.OAUTH_STORAGE_TYPE; dir = fs.mkdtempSync(path.join(os.tmpdir(), 'configuration-index-test-')); - mockSettingsPath = path.join(dir, 'settings.json'); + mockSettingsDir = path.join(dir, 'settings'); }); afterEach(() => { @@ -91,7 +91,7 @@ describe('getConfigurationService', () => { it('keeps a local server settings in the local file and notifies its session', async () => { await getConfigurationService().updateAccount(alice, { readOnly: true }); - const file = JSON.parse(fs.readFileSync(mockSettingsPath, 'utf-8')); + const file = JSON.parse(fs.readFileSync(path.join(mockSettingsDir, '1.json'), 'utf-8')); expect(file.accounts[alice.accountKey].settings).toEqual({ readOnly: true }); expect(mockSessionStore.initialize).not.toHaveBeenCalled(); expect(mockNotify).toHaveBeenCalledWith(['stdio']); @@ -114,7 +114,7 @@ describe('getConfigurationService', () => { { readOnly: true }, 0, ); - expect(fs.existsSync(mockSettingsPath)).toBe(false); + expect(fs.existsSync(mockSettingsDir)).toBe(false); }); // Sessions held in memory (the OAuth default) are gone after a restart; saved settings @@ -130,7 +130,7 @@ describe('getConfigurationService', () => { await getConfigurationService().updateAccount(alice, { readOnly: true }); - const file = JSON.parse(fs.readFileSync(mockSettingsPath, 'utf-8')); + const file = JSON.parse(fs.readFileSync(path.join(mockSettingsDir, '1.json'), 'utf-8')); expect(file.accounts[alice.accountKey].settings).toEqual({ readOnly: true }); expect(mockSessionStore.putAccountSettings).not.toHaveBeenCalled(); }); diff --git a/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts b/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts index 4eb92f65..15a5067c 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts @@ -209,6 +209,15 @@ describe('callRestriction', () => { [{ action: 'fork', project_id: 'team/app', namespace: 'team' }, null], [{ action: 'create', name: 'app', namespace: 'team/sub' }, /outside the allowed scope/], [{ action: 'create', name: 'app', namespace: 'other' }, /outside the allowed scope/], + // A fork accepts both destination fields; each is checked, not only the first. + [ + { action: 'fork', project_id: 'team/app', namespace: 'other', namespace_path: 'team' }, + /outside the allowed scope/, + ], + [ + { action: 'fork', project_id: 'team/app', namespace: 'team', namespace_path: 'other' }, + /outside the allowed scope/, + ], ])('checks the namespace of %j as the group it goes into', (args, expected) => { const policy = buildPolicy( undefined, diff --git a/packages/gitlab-mcp/tests/unit/configuration/replicas.test.ts b/packages/gitlab-mcp/tests/unit/configuration/replicas.test.ts index deb79f80..7f533d80 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/replicas.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/replicas.test.ts @@ -30,7 +30,7 @@ async function startReplica(shared: SessionStorageBackend, dir: string): Promise // Never the user's own settings file, whatever store the replica picks. jest.doMock('../../../src/configuration/settings-store', () => ({ ...jest.requireActual('../../../src/configuration/settings-store'), - localSettingsPath: () => path.join(dir, 'local-settings.json'), + localSettingsDir: () => path.join(dir, 'local-settings'), })); const { getConfigurationService } = await import('../../../src/configuration'); const { sessionStore } = await import('../../../src/oauth/session-store'); diff --git a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts index 557551c9..dca943de 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts @@ -35,6 +35,18 @@ describe('scopedArgs', () => { expect(scopedArgs('browse_projects', { action: 'list' }, scope)).toEqual(expected); }); + // The caller's narrower choice stays; a scope without subgroups excludes them whatever + // the caller asked, as the results outside it are filtered anyway. + it.each([ + [group, false, false], + [group, true, true], + [groupOnly, true, false], + ])('lists the scope group %j with subgroups %s as %s', (scope, requested, expected) => { + expect( + scopedArgs('browse_projects', { action: 'list', include_subgroups: requested }, scope), + ).toEqual({ action: 'list', group_id: 'team', include_subgroups: expected }); + }); + // Without a namespace GitLab creates the project in the user's own namespace. it('creates a project without a namespace in the scope group', () => { expect(scopedArgs('manage_project', { action: 'create', name: 'app' }, group)).toEqual({ @@ -223,6 +235,17 @@ describe('targetlessRestriction', () => { expect(targetlessRestriction(tool, args, scope)).toBeNull(); }); + // GitLab searches snippet titles only across the whole instance. + it.each([group, project])('refuses a snippet title search under %j', (scope) => { + expect( + targetlessRestriction( + 'browse_search', + { action: 'global', scope: 'snippet_titles', search: 'x' }, + scope, + ), + ).toMatch(/snippet titles/); + }); + it('refuses a vulnerability listing a scope of several targets cannot narrow', () => { expect(targetlessRestriction('browse_vulnerabilities', { action: 'list' }, several)).toMatch( /list them for one of them/, diff --git a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts index d9c60a2a..1a9ee01e 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts @@ -6,6 +6,12 @@ import { executeScoped } from '../../../src/configuration/scoped-execution'; import { buildPolicy } from '../../../src/configuration/policy'; +import { isActionDenied } from '../../../src/config'; + +jest.mock('../../../src/config', () => ({ + ...jest.requireActual('../../../src/config'), + isActionDenied: jest.fn(() => false), +})); const groupPolicy = buildPolicy( undefined, @@ -128,6 +134,51 @@ describe('executeScoped', () => { }); }); + // Emulating a call under a scope runs other actions in its place; an action the operator + // denied stays denied instead of running through them. + it.each([ + ['manage_todos', { action: 'mark_all_done' }, groupPolicy], + ['browse_events', { action: 'user' }, projectPolicy], + ['browse_search', { action: 'global', search: 'x', scope: 'issues' }, groupPolicy], + ])('refuses %s %j the operator denied', async (tool, args, policy) => { + jest + .mocked(isActionDenied) + .mockImplementation((name, action) => name === tool && action === args.action); + const run = jest.fn(); + + try { + await expect(executeScoped(run, tool, args, policy)).rejects.toThrow( + `Action '${args.action}' is not allowed for ${tool} tool`, + ); + expect(run).not.toHaveBeenCalled(); + } finally { + jest.mocked(isActionDenied).mockImplementation(() => false); + } + }); + + // Project search has no projects scope: the scope project itself answers, when it matches. + it.each([ + ['app', [{ id: 7, name: 'App', path_with_namespace: 'team/app' }]], + ['other', []], + ])('answers a project search for %s with the scope project', async (search, expected) => { + const run = jest.fn().mockResolvedValue({ + id: 7, + name: 'App', + path_with_namespace: 'team/app', + permissions: {}, + }); + + const result = await executeScoped( + run, + 'browse_search', + { action: 'global', scope: 'projects', search }, + projectPolicy, + ); + + expect(run).toHaveBeenCalledWith('browse_projects', { action: 'get', project_id: 'team/app' }); + expect(result).toEqual(expected); + }); + it('fails a project scope listing on a failure that is not an Error', async () => { const run = jest.fn().mockRejectedValue('connection reset'); diff --git a/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts b/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts index 1eeb7817..22566441 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts @@ -1,18 +1,19 @@ /** - * A local server keeps account settings in a file shared by every local server process. - * Writes are compare-and-set against the file itself, so processes do not lose each - * other's edits, and a lock left by a crashed process does not block forever. + * A local server keeps account settings in a directory shared by every local server process. + * Each write adds the next generation of the settings, created exclusively, so a write made + * from an older generation fails instead of replacing a newer one; there is no lock to leave + * behind or to break. */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { LocalSettingsFile, localSettingsPath } from '../../../src/configuration/settings-store'; +import { LocalSettingsFile, localSettingsDir } from '../../../src/configuration/settings-store'; -// The test setup keeps every test away from the user's own settings file. -it('points tests at a temporary settings file, not the home directory', () => { - expect(localSettingsPath().startsWith(os.tmpdir())).toBe(true); - expect(localSettingsPath().startsWith(os.homedir())).toBe(false); +// The test setup keeps every test away from the user's own settings. +it('points tests at temporary settings, not the home directory', () => { + expect(localSettingsDir().startsWith(os.tmpdir())).toBe(true); + expect(localSettingsDir().startsWith(os.homedir())).toBe(false); }); // The real location, read without touching it. @@ -21,30 +22,38 @@ it('keeps a server settings in the user configuration directory', () => { '../../../src/configuration/settings-store', ); - expect(actual.localSettingsPath()).toBe( - path.join(os.homedir(), '.config', 'gitlab-mcp', 'settings.json'), + expect(actual.localSettingsDir()).toBe( + path.join(os.homedir(), '.config', 'gitlab-mcp', 'settings'), ); }); describe('LocalSettingsFile', () => { + let root: string; let dir: string; - let filePath: string; + const generations = (): string[] => + fs.readdirSync(dir).filter((name) => /^\d+\.json$/.test(name)); + const writeGeneration = (generation: number, content: string): void => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${generation}.json`), content); + }; + const errno = (code: string) => Object.assign(new Error(code), { code }); beforeEach(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'settings-store-test-')); - filePath = path.join(dir, 'nested', 'settings.json'); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'settings-store-test-')); + dir = path.join(root, 'nested', 'settings'); }); afterEach(() => { - fs.rmSync(dir, { recursive: true, force: true }); + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); }); it('has no settings before the first write', async () => { - expect(await new LocalSettingsFile(filePath).get('acct')).toBeUndefined(); + expect(await new LocalSettingsFile(dir).get('acct')).toBeUndefined(); }); it('writes the first settings at version 1 and creates the directory', async () => { - const store = new LocalSettingsFile(filePath); + const store = new LocalSettingsFile(dir); const stored = await store.put('acct', { readOnly: true }, 0); @@ -52,17 +61,17 @@ describe('LocalSettingsFile', () => { expect(await store.get('acct')).toEqual(stored); }); - // The file holds account choices only, but stays private to the user like the rest. - it('writes the file owner-only', async () => { - await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0); + // The files hold account choices only, but stay private to the user like the rest. + it('writes the settings owner-only', async () => { + await new LocalSettingsFile(dir).put('acct', { readOnly: true }, 0); if (process.platform !== 'win32') { - expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.join(dir, '1.json')).mode & 0o777).toBe(0o600); } }); it('refuses a write against a stale version and keeps the newer settings', async () => { - const store = new LocalSettingsFile(filePath); + const store = new LocalSettingsFile(dir); await store.put('acct', { preset: 'developer' }, 0); await store.put('acct', { preset: 'pm' }, 1); @@ -70,11 +79,11 @@ describe('LocalSettingsFile', () => { expect((await store.get('acct'))?.settings).toEqual({ preset: 'pm' }); }); - // Two local server processes: each reads the file on every call, so the second one + // Two local server processes: each reads the settings on every call, so the second one // sees the first one's write and its stale edit is refused. - it('shares settings between two stores of one file', async () => { - const first = new LocalSettingsFile(filePath); - const second = new LocalSettingsFile(filePath); + it('shares settings between two stores of one directory', async () => { + const first = new LocalSettingsFile(dir); + const second = new LocalSettingsFile(dir); await first.put('acct', { readOnly: true }, 0); expect((await second.get('acct'))?.settings).toEqual({ readOnly: true }); @@ -83,134 +92,187 @@ describe('LocalSettingsFile', () => { it('lets exactly one of concurrent writes of one version win', async () => { const results = await Promise.all([ - new LocalSettingsFile(filePath).put('acct', { preset: 'a' }, 0), - new LocalSettingsFile(filePath).put('acct', { preset: 'b' }, 0), - new LocalSettingsFile(filePath).put('acct', { preset: 'c' }, 0), + new LocalSettingsFile(dir).put('acct', { preset: 'a' }, 0), + new LocalSettingsFile(dir).put('acct', { preset: 'b' }, 0), + new LocalSettingsFile(dir).put('acct', { preset: 'c' }, 0), ]); - expect(results.filter((r) => r !== undefined)).toHaveLength(1); - expect(fs.existsSync(`${filePath}.lock`)).toBe(false); + const winners = results.filter((r) => r !== undefined); + expect(winners).toHaveLength(1); + expect((await new LocalSettingsFile(dir).get('acct'))?.settings).toEqual(winners[0]?.settings); + }); + + // Writes of different accounts do not conflict: a lost race is retried from the new state. + it('keeps every account of concurrent writes', async () => { + await Promise.all( + ['one', 'two', 'three', 'four'].map((key) => + new LocalSettingsFile(dir).put(key, { preset: key }, 0), + ), + ); + + const store = new LocalSettingsFile(dir); + for (const key of ['one', 'two', 'three', 'four']) { + expect((await store.get(key))?.settings).toEqual({ preset: key }); + } }); it('keeps the settings of other accounts when one account writes', async () => { - const store = new LocalSettingsFile(filePath); + const store = new LocalSettingsFile(dir); await store.put('one', { readOnly: true }, 0); await store.put('two', { preset: 'pm' }, 0); expect((await store.get('one'))?.settings).toEqual({ readOnly: true }); }); - // Two writers see the same dead lock; one removes it and a third process takes a fresh - // lock before the second writer acts. The fresh lock must survive, or two writers would - // be inside the read-compare-write at once. Linux file systems reuse the freed inode - // number at once, so the fresh lock can carry the dead one's inode. - it.each([false, true])( - 'does not remove a fresh lock taken after the dead one was seen (inode reused: %s)', - async (inodeReused) => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const lock = `${filePath}.lock`; - fs.writeFileSync(lock, ''); - const longAgo = new Date(Date.now() - 60_000); - fs.utimesSync(lock, longAgo, longAgo); - const realStat = fs.promises.stat.bind(fs.promises); - let dead: fs.Stats | undefined; - jest - .spyOn(fs.promises, 'stat') - .mockImplementationOnce(async (target) => { - dead = await realStat(target); - // Another process replaced the dead lock with its own meanwhile. - fs.rmSync(lock); - fs.writeFileSync(lock, 'fresh'); - return dead; - }) - .mockImplementationOnce(async (target) => { - const taken = await realStat(target); - if (!inodeReused || !dead) return taken; - return Object.assign(Object.create(Object.getPrototypeOf(taken)), taken, { - ino: dead.ino, - dev: dead.dev, - }); - }); - - try { - const write = new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0); - await new Promise((resolve) => setTimeout(resolve, 60)); - - expect(fs.readFileSync(lock, 'utf-8')).toBe('fresh'); - fs.rmSync(lock); - expect(await write).toBeDefined(); - } finally { - jest.restoreAllMocks(); - } - }, - ); - - // Another writer claimed the dead lock first: the rename fails and the lock is tried again. - it('tries again when another writer claimed the dead lock first', async () => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const lock = `${filePath}.lock`; - fs.writeFileSync(lock, ''); - const longAgo = new Date(Date.now() - 60_000); - fs.utimesSync(lock, longAgo, longAgo); + // A writer that stops in the middle of its write (a suspended process, a laptop put to + // sleep) for longer than any lock lifetime, while another writer saves a newer version: + // when it resumes, its write is refused instead of replacing the newer settings. + it('refuses a write that resumes after a newer one was saved', async () => { + const store = new LocalSettingsFile(dir); + await store.put('acct', { preset: 'base' }, 0); + const realReadFile = fs.promises.readFile.bind(fs.promises); + let release: () => void = () => undefined; + let paused: () => void = () => undefined; + const resumed = new Promise((resolve) => (release = resolve)); + const reached = new Promise((resolve) => (paused = resolve)); jest - .spyOn(fs.promises, 'rename') - .mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); - - try { - expect( - await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0), - ).toBeDefined(); - } finally { - jest.restoreAllMocks(); - } + .spyOn(fs.promises, 'readFile') + .mockImplementationOnce(async (...args: Parameters) => { + const content = await realReadFile(...args); + // Long enough for any lock this writer holds to look abandoned. + for (const name of fs.readdirSync(path.dirname(dir))) { + if (name.endsWith('.lock')) { + const longAgo = new Date(Date.now() - 60_000); + fs.utimesSync(path.join(path.dirname(dir), name), longAgo, longAgo); + } + } + paused(); + await resumed; + return content; + }); + + const sleeper = new LocalSettingsFile(dir).put('acct', { preset: 'sleeper' }, 1); + await reached; + const newer = await new LocalSettingsFile(dir).put('acct', { preset: 'newer' }, 1); + release(); + + expect(newer).toBeDefined(); + expect(await sleeper).toBeUndefined(); + expect((await store.get('acct'))?.settings).toEqual({ preset: 'newer' }); }); - // The fresh lock could not be handed back because a newer one took the path: that newer - // lock stays, and the claimed copy is not left behind. - it('keeps a newer lock when a fresh one cannot be handed back', async () => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const lock = `${filePath}.lock`; - fs.writeFileSync(lock, ''); - const longAgo = new Date(Date.now() - 60_000); - fs.utimesSync(lock, longAgo, longAgo); - const realStat = fs.promises.stat.bind(fs.promises); - jest.spyOn(fs.promises, 'stat').mockImplementationOnce(async (target) => { - const dead = await realStat(target); - fs.rmSync(lock); - fs.writeFileSync(lock, 'fresh'); - return dead; - }); + it('keeps only the newest generation after a write', async () => { + const store = new LocalSettingsFile(dir); + await store.put('one', { readOnly: true }, 0); + await store.put('two', { readOnly: true }, 0); + await store.put('one', { readOnly: false }, 1); + + expect(generations()).toEqual(['3.json']); + expect(fs.readdirSync(dir).filter((name) => name.endsWith('.tmp'))).toEqual([]); + }); + + it('reads the newest generation and ignores other files', async () => { + const content = (preset: string) => + JSON.stringify({ + version: 1, + accounts: { acct: { accountKey: 'acct', settings: { preset }, version: 1, updatedAt: 1 } }, + }); + writeGeneration(2, content('old')); + writeGeneration(10, content('new')); + fs.writeFileSync(path.join(dir, 'notes.json'), '{ not settings'); + fs.writeFileSync(path.join(dir, '11.json.bak'), '{ not settings'); + + expect((await new LocalSettingsFile(dir).get('acct'))?.settings).toEqual({ preset: 'new' }); + }); + + // Another process saved a newer generation and removed the one being read. + it('reads again when the generation it found is removed before it is read', async () => { + await new LocalSettingsFile(dir).put('acct', { readOnly: true }, 0); + jest.spyOn(fs.promises, 'readFile').mockRejectedValueOnce(errno('ENOENT')); + + expect((await new LocalSettingsFile(dir).get('acct'))?.settings).toEqual({ readOnly: true }); + }); + + // A temporary file of a writer that crashed before saving is cleared by the next write; + // one of a writer still at work is left alone. + it('clears temporary files of crashed writers only', async () => { + fs.mkdirSync(dir, { recursive: true }); + const crashed = path.join(dir, '.4242.crashed.tmp'); + const working = path.join(dir, '.4243.working.tmp'); + fs.writeFileSync(crashed, '{}'); + fs.writeFileSync(working, '{}'); + const longAgo = new Date(Date.now() - 2 * 60 * 60_000); + fs.utimesSync(crashed, longAgo, longAgo); + + await new LocalSettingsFile(dir).put('acct', { readOnly: true }, 0); + + expect(fs.existsSync(crashed)).toBe(false); + expect(fs.existsSync(working)).toBe(true); + }); + + // Its temporary file was cleared while it was suspended: the write is prepared again. + it('prepares the write again when its temporary file is gone', async () => { + jest.spyOn(fs.promises, 'link').mockRejectedValueOnce(errno('ENOENT')); + + expect(await new LocalSettingsFile(dir).put('acct', { readOnly: true }, 0)).toBeDefined(); + expect(generations()).toEqual(['1.json']); + }); + + it('gives up when other processes keep saving first', async () => { + jest.spyOn(fs.promises, 'link').mockRejectedValue(errno('EEXIST')); + + await expect(new LocalSettingsFile(dir).put('acct', { readOnly: true }, 0)).rejects.toThrow( + `Settings are being changed by other processes: ${dir}`, + ); + expect(fs.readdirSync(dir)).toEqual([]); + }); + + it('reports a write it cannot save and leaves no temporary file', async () => { + jest.spyOn(fs.promises, 'link').mockRejectedValueOnce(errno('EACCES')); + + await expect( + new LocalSettingsFile(dir).put('acct', { readOnly: true }, 0), + ).rejects.toMatchObject({ code: 'EACCES' }); + expect(fs.readdirSync(dir)).toEqual([]); + }); + + // Once the generation is saved, files that cannot be removed (its temporary file, the + // older generation) do not turn the write into a failure: the newest generation is read. + it('reports a saved write as saved when nothing can be removed', async () => { + const store = new LocalSettingsFile(dir); + await store.put('acct', { preset: 'a' }, 0); + jest.spyOn(fs.promises, 'rm').mockRejectedValue(errno('EBUSY')); + + expect(await store.put('acct', { preset: 'b' }, 1)).toBeDefined(); + expect(generations().sort()).toEqual(['1.json', '2.json']); + expect((await store.get('acct'))?.settings).toEqual({ preset: 'b' }); + }); + + // Listing the directory for clean-up fails after the save: the write is still saved. + it('reports a saved write as saved when the clean-up cannot list the directory', async () => { jest - .spyOn(fs.promises, 'link') - .mockRejectedValueOnce(Object.assign(new Error('EEXIST'), { code: 'EEXIST' })); - - try { - expect( - await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0), - ).toBeDefined(); - expect(fs.readdirSync(path.dirname(filePath)).filter((f) => f.endsWith('.stale'))).toEqual( - [], - ); - } finally { - jest.restoreAllMocks(); - } + .spyOn(fs.promises, 'readdir') + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(errno('EIO')); + + expect(await new LocalSettingsFile(dir).put('acct', { readOnly: true }, 0)).toBeDefined(); + expect((await new LocalSettingsFile(dir).get('acct'))?.settings).toEqual({ readOnly: true }); }); - it('removes a lock left by a process that died while holding it', async () => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const lock = `${filePath}.lock`; - fs.writeFileSync(lock, ''); - const longAgo = new Date(Date.now() - 60_000); - fs.utimesSync(lock, longAgo, longAgo); + // Without syncing the directory, a crash after saving can lose the new generation. + it('syncs the directory after saving', async () => { + if (process.platform === 'win32') return; + const open = jest.spyOn(fs.promises, 'open'); + + await new LocalSettingsFile(dir).put('acct', { readOnly: true }, 0); - expect(await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0)).toBeDefined(); + expect(open).toHaveBeenCalledWith(dir, 'r'); }); // A hand-edited entry the schema refuses is ignored instead of applied half-valid. it('ignores an entry with invalid settings', async () => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync( - filePath, + writeGeneration( + 1, JSON.stringify({ version: 1, accounts: { @@ -219,7 +281,7 @@ describe('LocalSettingsFile', () => { }, }), ); - const store = new LocalSettingsFile(filePath); + const store = new LocalSettingsFile(dir); expect(await store.get('bad')).toBeUndefined(); expect((await store.get('good'))?.settings).toEqual({ readOnly: true }); @@ -230,9 +292,8 @@ describe('LocalSettingsFile', () => { ['an entry that is null', { bad: null }], ['an entry that is not an object', { bad: 'readOnly' }], ])('skips %s and reads the others', async (_label, broken) => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync( - filePath, + writeGeneration( + 1, JSON.stringify({ version: 1, accounts: { @@ -241,108 +302,56 @@ describe('LocalSettingsFile', () => { }, }), ); - const store = new LocalSettingsFile(filePath); + const store = new LocalSettingsFile(dir); expect(await store.get('bad')).toBeUndefined(); expect((await store.get('good'))?.settings).toEqual({ readOnly: true }); }); it('reads accounts that are not an object as none', async () => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify({ version: 1, accounts: [{ settings: {} }] })); + writeGeneration(1, JSON.stringify({ version: 1, accounts: [{ settings: {} }] })); + + expect(await new LocalSettingsFile(dir).get('0')).toBeUndefined(); + }); - expect(await new LocalSettingsFile(filePath).get('0')).toBeUndefined(); + it('reads a generation without accounts as empty', async () => { + writeGeneration(1, JSON.stringify({ version: 1 })); + + expect(await new LocalSettingsFile(dir).get('acct')).toBeUndefined(); }); it.each(['null', '[]', '"text"'])( - 'reports a file whose content %s is not an object', + 'reports a generation whose content %s is not an object', async (content) => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, content); + writeGeneration(1, content); - await expect(new LocalSettingsFile(filePath).get('acct')).rejects.toThrow( - `Settings file is not a JSON object: ${filePath}`, + await expect(new LocalSettingsFile(dir).get('acct')).rejects.toThrow( + `Settings file is not a JSON object: ${path.join(dir, '1.json')}`, ); }, ); - it('reports a file that is not JSON instead of treating it as empty', async () => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, '{ not json'); + it('reports a generation that is not JSON instead of treating it as empty', async () => { + writeGeneration(1, '{ not json'); - await expect(new LocalSettingsFile(filePath).get('acct')).rejects.toThrow(SyntaxError); + await expect(new LocalSettingsFile(dir).get('acct')).rejects.toThrow(SyntaxError); }); - // Without syncing the directory, a crash after the rename can bring the old file back. - it('syncs the directory after replacing the file', async () => { - if (process.platform === 'win32') return; - const open = jest.spyOn(fs.promises, 'open'); - try { - await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0); + // Only missing settings mean "no settings yet"; unreadable ones are an error. + it('reports a generation it cannot read instead of treating it as empty', async () => { + fs.mkdirSync(path.join(dir, '1.json'), { recursive: true }); - expect(open).toHaveBeenCalledWith(path.dirname(filePath), 'r'); - } finally { - open.mockRestore(); - } - }); - - it('reads a file without accounts as empty', async () => { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify({ version: 1 })); - - expect(await new LocalSettingsFile(filePath).get('acct')).toBeUndefined(); - }); - - // Only a missing file means "no settings yet"; an unreadable one is an error. - it('reports a file it cannot read instead of treating it as empty', async () => { - fs.mkdirSync(filePath, { recursive: true }); - - await expect(new LocalSettingsFile(filePath).get('acct')).rejects.toMatchObject({ + await expect(new LocalSettingsFile(dir).get('acct')).rejects.toMatchObject({ code: 'EISDIR', }); }); - describe('lock', () => { - const errno = (code: string) => Object.assign(new Error(code), { code }); - - afterEach(() => { - jest.restoreAllMocks(); - jest.useRealTimers(); - }); + it('reports a settings location that is not a directory', async () => { + fs.mkdirSync(path.dirname(dir), { recursive: true }); + fs.writeFileSync(dir, '{}'); - it('reports a lock it cannot create for a reason other than contention', async () => { - jest.spyOn(fs.promises, 'open').mockRejectedValueOnce(errno('EACCES')); - - await expect( - new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0), - ).rejects.toMatchObject({ code: 'EACCES' }); - }); - - // The holder released the lock between the failed create and the age check. - it('retries at once when the lock disappears while being checked', async () => { - jest.spyOn(fs.promises, 'open').mockRejectedValueOnce(errno('EEXIST')); - jest.spyOn(fs.promises, 'stat').mockRejectedValueOnce(errno('ENOENT')); - - expect( - await new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0), - ).toBeDefined(); - }); - - // A live process holding the lock for longer than the wait: give up with a clear error. - it('gives up on a lock another live process keeps holding', async () => { - jest.useFakeTimers(); - jest.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); - jest.spyOn(fs.promises, 'open').mockRejectedValue(errno('EEXIST')); - jest - .spyOn(fs.promises, 'stat') - .mockResolvedValue({ mtimeMs: Date.now() } as unknown as fs.Stats); - - const write = new LocalSettingsFile(filePath).put('acct', { readOnly: true }, 0); - const outcome = expect(write).rejects.toThrow( - `Settings file is locked by another process: ${filePath}.lock`, - ); - await jest.advanceTimersByTimeAsync(10_000); - await outcome; + await expect(new LocalSettingsFile(dir).get('acct')).rejects.toMatchObject({ + code: 'ENOTDIR', }); }); }); diff --git a/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts index bc339a74..4cc0f030 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts @@ -113,6 +113,7 @@ import { getContextManager, } from '../../../../src/entities/context/context-manager'; import { findNamespaceType } from '../../../../src/utils/namespace'; +import { isGitLabAuthFailure } from '../../../../src/oauth/tool-auth'; import { sendToolsListChangedNotification } from '../../../../src/server'; import { ConfigurationService } from '../../../../src/configuration/service'; import { resolveCaller, runWithCaller, type Caller } from '../../../../src/configuration/caller'; @@ -410,6 +411,20 @@ describe('ContextManager', () => { ); expect((await runWithCaller(alice1, () => manager.getContext())).scope).toBeUndefined(); }); + + // An expired sign-in keeps GitLab's error in the cause, which the call's error handling + // turns into the reauthorization challenge. + it('keeps the authorization failure of the lookup recognizable', async () => { + mockDetectNamespaceType.mockRejectedValue(new Error('GitLab API error: 401 Unauthorized')); + const manager = getContextManager(); + + const failure = await runWithCaller(alice1, () => manager.setScope('team/app')).catch( + (error: unknown) => error, + ); + + expect(isGitLabAuthFailure(failure)).toBe(true); + expect((await runWithCaller(alice1, () => manager.getContext())).scope).toBeUndefined(); + }); }); describe('reset', () => { diff --git a/packages/gitlab-mcp/tests/unit/entities/context/handlers.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/handlers.test.ts index 474864f0..05bf6e2c 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/handlers.test.ts @@ -35,15 +35,6 @@ jest.mock('../../../../src/profiles/loader', () => ({ })), })); -// Account settings go to a file of this test run, never to the user's configuration. -jest.mock('../../../../src/configuration/settings-store', () => ({ - ...jest.requireActual('../../../../src/configuration/settings-store'), - localSettingsPath: () => - jest - .requireActual('path') - .join(jest.requireActual('os').tmpdir(), `context-handlers-test-${process.pid}.json`), -})); - describe('handleManageContext', () => { const originalEnv = process.env; diff --git a/packages/gitlab-mcp/tests/unit/entities/context/registry.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/registry.test.ts index 2f3a4ea7..7371525c 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/registry.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/registry.test.ts @@ -4,15 +4,6 @@ * Tests the context tool registry exports and functions. */ -// Account settings go to a file of this test run, never to the user's configuration. -jest.mock('../../../../src/configuration/settings-store', () => ({ - ...jest.requireActual('../../../../src/configuration/settings-store'), - localSettingsPath: () => - jest - .requireActual('path') - .join(jest.requireActual('os').tmpdir(), `context-registry-test-${process.pid}.json`), -})); - import { contextToolRegistry, getContextReadOnlyToolNames, diff --git a/packages/gitlab-mcp/tests/unit/handlers.test.ts b/packages/gitlab-mcp/tests/unit/handlers.test.ts index 9d1ff652..7484a0ae 100644 --- a/packages/gitlab-mcp/tests/unit/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/handlers.test.ts @@ -95,6 +95,8 @@ jest.mock('../../src/config', () => ({ HANDLER_TIMEOUT_MS: 100, GITLAB_BASE_URL: 'https://gitlab.example.com', GITLAB_SCHEMA_MODE: 'flat', + // No action is denied by the operator here; scoped calls check the requested action. + isActionDenied: () => false, })); // Mock HealthMonitor diff --git a/packages/gitlab-mcp/tests/unit/utils/namespace.coverage.test.ts b/packages/gitlab-mcp/tests/unit/utils/namespace.coverage.test.ts index ba19bfa8..dc8ea0bb 100644 --- a/packages/gitlab-mcp/tests/unit/utils/namespace.coverage.test.ts +++ b/packages/gitlab-mcp/tests/unit/utils/namespace.coverage.test.ts @@ -81,6 +81,49 @@ describe('Namespace Utils Coverage Tests', () => { expect(await findNamespaceType('team/app')).toBeNull(); }); + + // A rejected sign-in is not "not found": it fails with GitLab's error, so the call gets + // the reauthorization challenge instead of a misleading unknown-path answer. + it.each([ + [401, 'Unauthorized', '{"message":"401 Unauthorized"}', 'GitLab API error: 401 Unauthorized'], + [ + 403, + 'Forbidden', + '{"error":"insufficient_scope"}', + 'GitLab API error: 403 Forbidden - insufficient_scope', + ], + ])( + 'fails on %d instead of reading it as not found', + async (status, statusText, body, message) => { + mockEnhancedFetch.mockResolvedValue({ + ok: false, + status, + statusText, + text: jest.fn().mockResolvedValue(body), + } as unknown as Response); + + await expect(findNamespaceType('team/app')).rejects.toThrow(message); + expect(mockEnhancedFetch).toHaveBeenCalledTimes(1); + }, + ); + + // A path the token may not read as this type is not an authorization failure, also when + // the body of the refusal cannot be read. + it.each([ + ['a plain 403', jest.fn().mockResolvedValue('{"message":"403 Forbidden"}')], + ['a 403 whose body cannot be read', jest.fn().mockRejectedValue(new Error('aborted'))], + ])('reads %s as not this type', async (_label, text) => { + mockEnhancedFetch + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + text, + } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + expect(await findNamespaceType('team/app')).toBe('group'); + }); }); describe('detectNamespaceType', () => { From 92e9c440884b09eb0d7251d0e8275ac552d663b1 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 11 Oct 2026 12:56:12 +0300 Subject: [PATCH 07/11] fix(settings): validate scoped emulations, refuse before lookups - A call the scope answers by other requests (a project search, a project listing of a scope made of projects, mark_all_done) is validated by the tool's schema first: an invalid call is refused instead of answered with the scope's projects. - Tool, group, read-only and action limits are checked before numeric targets are resolved, so a call they refuse reaches nothing on GitLab and an expired sign-in cannot mask the configured refusal. - An instance audit listing under a group scope reads the group's own trail and says that its projects' events are listed per project, instead of being refused. - A configuration tool answered while GitLab is marked unreachable has the health monitor probe GitLab again, so other tools recover as soon as GitLab answers. The monitor decides from its own probe; the tool's success is not reported as GitLab's. Regression tests: invalid emulated calls, a read-only call with a numeric target, the group audit trail, and the probe after a configuration tool answers. --- .../gitlab-mcp/docs/configuration/settings.md | 8 ++- .../gitlab-mcp/src/configuration/policy.ts | 7 ++- .../src/configuration/scope-targets.ts | 20 +++--- .../src/configuration/scoped-execution.ts | 61 +++++++++++++++---- packages/gitlab-mcp/src/handlers.ts | 29 +++++---- .../unit/configuration/scope-targets.test.ts | 10 ++- .../configuration/scoped-execution.test.ts | 46 ++++++++++++++ .../gitlab-mcp/tests/unit/handlers.test.ts | 52 ++++++++++++++++ 8 files changed, 193 insertions(+), 40 deletions(-) diff --git a/packages/gitlab-mcp/docs/configuration/settings.md b/packages/gitlab-mcp/docs/configuration/settings.md index e1310e8b..78984f5f 100644 --- a/packages/gitlab-mcp/docs/configuration/settings.md +++ b/packages/gitlab-mcp/docs/configuration/settings.md @@ -55,9 +55,11 @@ are filtered to the scope, reading further GitLab pages until the requested page (up to 1000 rows per call; a page still short then comes back marked `partial`, with a hint to narrow the listing). Your own activity (`browse_events` with `action: "user"`) shows the scope project's events under a project scope and is refused under a group scope. -Instance-wide listings read the scope instead: deploy keys and instance audit events those -of the scope project, all or owned runners those available to the scope's project or group; -where the scope has no such form, the listing is refused. A global search or vulnerability +Instance-wide listings read the scope instead: deploy keys those of the scope project, +instance audit events the trail of the scope project or group (a group's trail holds the +group's own events, and the result says that its projects' events are listed per project), +all or owned runners those available to the scope's project or group; where the scope has +no such form, the listing is refused. A global search or vulnerability listing is refused under a scope of several projects or groups, and under a group scope without subgroups (GitLab's group results always include them); use one project or group instead. A global search for projects under a project scope answers with the scope's diff --git a/packages/gitlab-mcp/src/configuration/policy.ts b/packages/gitlab-mcp/src/configuration/policy.ts index ba537124..235f295c 100644 --- a/packages/gitlab-mcp/src/configuration/policy.ts +++ b/packages/gitlab-mcp/src/configuration/policy.ts @@ -53,10 +53,15 @@ export const CONFIGURATION_TOOLS: ReadonlySet = new Set([ * reads to know whose connection it is. Unlike the configuration tools, the identity needs * GitLab, so it does not answer while GitLab is unreachable. */ -function isExemptFromSettings(name: string): boolean { +export function isExemptFromSettings(name: string): boolean { return CONFIGURATION_TOOLS.has(name) || name === 'get_profile'; } +/** The policy without its working scope: the limits checked without looking up targets. */ +export function withoutScope(policy: EffectivePolicy): EffectivePolicy { + return { ...policy, scope: undefined, scopeEnforcer: undefined }; +} + /** The scope's first project or group, for reports; undefined when nothing is scoped. */ export function scopeLabel(scope: ScopeConfig | undefined): string | undefined { return ( diff --git a/packages/gitlab-mcp/src/configuration/scope-targets.ts b/packages/gitlab-mcp/src/configuration/scope-targets.ts index 93a4cbc8..2ad901ff 100644 --- a/packages/gitlab-mcp/src/configuration/scope-targets.ts +++ b/packages/gitlab-mcp/src/configuration/scope-targets.ts @@ -72,10 +72,10 @@ const INSTANCE_LISTINGS: Record args.action === 'list_instance' && - (!isProject(target) || hasValue(args.entity_type) || hasValue(args.entity_id)) + (!target || hasValue(args.entity_type) || hasValue(args.entity_id)) ? "instance audit events cannot be limited to the working scope; list a project's or a group's with action 'list_project' or 'list_group'" : null, }; @@ -195,13 +195,15 @@ const NARROWERS: Record - args.action === 'list_instance' && - 'project' in target && - !hasValue(args.entity_type) && - !hasValue(args.entity_id) - ? { ...args, action: 'list_project', project_id: target.project } - : null, + // A group's trail holds the group's own events only; the result says so. + browse_audit_events: (args, target) => { + if (args.action !== 'list_instance' || hasValue(args.entity_type) || hasValue(args.entity_id)) { + return null; + } + return 'group' in target + ? { ...args, action: 'list_group', group_id: target.group } + : { ...args, action: 'list_project', project_id: target.project }; + }, browse_vulnerabilities: (args, target) => { if (!isTargetlessList(args)) return null; return 'group' in target diff --git a/packages/gitlab-mcp/src/configuration/scoped-execution.ts b/packages/gitlab-mcp/src/configuration/scoped-execution.ts index 4d48350c..15a4b599 100644 --- a/packages/gitlab-mcp/src/configuration/scoped-execution.ts +++ b/packages/gitlab-mcp/src/configuration/scoped-execution.ts @@ -6,8 +6,12 @@ import type { EffectivePolicy } from './policy'; import type { ScopeEnforcer } from '../profiles/scope-enforcer'; +import type { ScopeConfig } from '../profiles/types'; import { parseGitLabApiError } from '../utils/error-handler'; import { assertActionAllowed } from '../entities/utils'; +import { BrowseProjectsSchema } from '../entities/core/schema-readonly'; +import { ManageTodosSchema } from '../entities/core/schema'; +import { BrowseSearchSchema } from '../entities/search/schema-readonly'; import { isFilteredListing, scopedArgs, scopedResult, scopeProjectsOnly } from './scope-targets'; type Args = Record; @@ -66,31 +70,62 @@ export async function executeScoped( // The emulations below run other actions in place of the requested one; an action the // operator denied must stay denied rather than run through them. if (typeof requested.action === 'string') assertActionAllowed(tool, requested.action); + const emulation = emulate(run, tool, requested, scope, scopeEnforcer); + if (emulation) return emulation; + const args = scopedArgs(tool, requested, scope); + if (isFilteredListing(tool, args)) return fillScopedPage(run, tool, args, scopeEnforcer); + const result = await run(tool, args); + return isGroupAuditTrail(requested, args) ? groupAuditTrail(result, args.group_id) : result; +} + +/** + * The answer to a call that the scope replaces by other requests, or undefined when the + * call runs as itself. The call is validated as the tool would validate it first, so an + * invalid call is refused instead of answered with the scope's data. + */ +function emulate( + run: RunTool, + tool: string, + requested: Args, + scope: ScopeConfig, + enforcer: ScopeEnforcer, +): Promise | undefined { if (tool === 'manage_todos' && requested.action === 'mark_all_done') { - return markScopeTodosDone(run, scopeEnforcer); + ManageTodosSchema.parse(requested); + return markScopeTodosDone(run, enforcer); } const projects = scopeProjectsOnly(scope); + if (!projects) return undefined; if ( - projects && tool === 'browse_projects' && requested.action === 'list' && Object.keys(requested).every((key) => PROJECT_LIST_OPTIONS.has(key)) ) { - return listScopeProjects(run, projects, requested); + return listScopeProjects(run, projects, BrowseProjectsSchema.parse(requested)); } // Project search has no projects scope: the scope's projects matching the search answer it. - if ( - projects && - tool === 'browse_search' && - requested.action === 'global' && - requested.scope === 'projects' - ) { - const { search, page, per_page } = requested; + if (tool === 'browse_search' && requested.action === 'global' && requested.scope === 'projects') { + const { search, page, per_page } = BrowseSearchSchema.parse(requested); return listScopeProjects(run, projects, { search, page, per_page }); } - const args = scopedArgs(tool, requested, scope); - if (isFilteredListing(tool, args)) return fillScopedPage(run, tool, args, scopeEnforcer); - return run(tool, args); + return undefined; +} + +/** An instance audit listing a group scope answered with the group's own audit trail. */ +const isGroupAuditTrail = (requested: Args, args: Args): boolean => + requested.action === 'list_instance' && args.action === 'list_group'; + +/** + * The group's own audit events, marked partial: GitLab keeps the events of the group's + * projects in each project's trail, which an instance listing would also have shown. + */ +function groupAuditTrail(result: unknown, group: unknown): unknown { + if (!Array.isArray(result)) return result; + return { + items: result, + partial: true, + message: `These are the audit events of group '${String(group)}' itself; events of its projects are listed per project with action 'list_project'.`, + }; } /** diff --git a/packages/gitlab-mcp/src/handlers.ts b/packages/gitlab-mcp/src/handlers.ts index 5bf386f8..0fd73e11 100644 --- a/packages/gitlab-mcp/src/handlers.ts +++ b/packages/gitlab-mcp/src/handlers.ts @@ -49,8 +49,10 @@ import { import { CONFIGURATION_TOOLS, callRestriction, + isExemptFromSettings, scopeLabel, toolRestriction, + withoutScope, type EffectivePolicy, } from './configuration/policy'; import { executeScoped } from './configuration/scoped-execution'; @@ -313,8 +315,10 @@ async function resyncSessionAfterSwitchProfile( * fall through to the normal bootstrap path. * * Bypasses bootstrap intentionally — context tools mostly operate on local state - * (cached scopes, config, instance registry). Health reporting is skipped because - * the fast-path bypasses bootstrap — there is no connection lifecycle to report on. + * (cached scopes, config, instance registry). Some of them read GitLab too (check_connection, + * find_scope_targets, scope checks), and a caller runs them to check or recover the + * connection; their own success says nothing about GitLab, so instead of reporting it the + * monitor is asked to probe GitLab again, which lets other tools recover at once. */ async function tryManageContextFastPath( toolName: string, @@ -348,6 +352,7 @@ async function tryManageContextFastPath( if (result === undefined) { return null; } + healthMonitor.forceReconnect(effectiveInstanceUrl); await resyncSessionAfterSwitchProfile(toolName, toolArguments, sessionId); return formatToolResult(result, registryManager.getTool(toolName, effectiveInstanceUrl)); } @@ -965,17 +970,17 @@ export async function setupHandlers(server: Server): Promise { // when they cannot be read: allowing it could bypass a restriction. const { RegistryManager: CallRegistry } = await import('./registry-manager'); const configuration = await getConfigurationService().resolve(caller); - // A scope holds paths: numeric ids are checked as the paths they stand for. + const { policy } = configuration; + const callRegistry = CallRegistry.getInstance(); const callArguments = toolArguments ?? {}; - const checkedArguments = configuration.policy.scopeEnforcer - ? await withTargetPaths(callArguments) - : callArguments; - const settingsRefusal = settingsRejection( - toolName, - checkedArguments, - configuration.policy, - CallRegistry.getInstance(), - ); + // Tool, group, read-only and action limits first: they need nothing from GitLab, so a + // call they refuse reaches nothing. + const settingsRefusal = + settingsRejection(toolName, callArguments, withoutScope(policy), callRegistry) ?? + // A scope holds paths: numeric ids are checked as the paths they stand for. + (policy.scopeEnforcer && !isExemptFromSettings(toolName) + ? settingsRejection(toolName, await withTargetPaths(callArguments), policy, callRegistry) + : null); if (settingsRefusal) return settingsRefusal; // Early return: instance unreachable for non-context tools diff --git a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts index dca943de..46d1b04a 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts @@ -106,6 +106,12 @@ describe('scopedArgs', () => { project, { action: 'list_project', created_after: '2026-01-01', project_id: 'team/app' }, ], + [ + 'browse_audit_events', + { action: 'list_instance' }, + group, + { action: 'list_group', group_id: 'team' }, + ], ])('reads %s %j under %j as %j', (tool, args, scope, expected) => { expect(scopedArgs(tool, args, scope)).toEqual(expected); }); @@ -138,7 +144,6 @@ describe('scopedArgs', () => { ['browse_deploy_keys', { action: 'list' }, group], ['browse_runners', { action: 'list_project', project_id: 'team/app' }, project], ['browse_audit_events', { action: 'list_group', group_id: 'team' }, project], - ['browse_audit_events', { action: 'list_instance' }, group], ['browse_audit_events', { action: 'list_instance', entity_type: 'User' }, project], ['browse_audit_events', { action: 'list_instance', entity_id: 3 }, project], ])('leaves %s %j under %j as it is', (tool, args, scope) => { @@ -211,7 +216,7 @@ describe('targetlessRestriction', () => { ['browse_deploy_keys', { action: 'list' }, several, /deploy keys/], ['browse_deploy_keys', { action: 'list', public: true }, project, /public deploy keys/], ['browse_runners', { action: 'list_all' }, several, /runners/], - ['browse_audit_events', { action: 'list_instance' }, group, /audit events/], + ['browse_audit_events', { action: 'list_instance' }, several, /audit events/], [ 'browse_audit_events', { action: 'list_instance', entity_type: 'User', entity_id: 4 }, @@ -228,6 +233,7 @@ describe('targetlessRestriction', () => { ['browse_runners', { action: 'list_all' }, group], ['browse_runners', { action: 'list_project', project_id: 'team/app' }, several], ['browse_audit_events', { action: 'list_instance' }, project], + ['browse_audit_events', { action: 'list_instance' }, group], ['browse_audit_events', { action: 'list_group', group_id: 'team' }, several], ['browse_deploy_keys', { action: 'get', project_id: 'team/app', key_id: 1 }, group], ['browse_events', { action: 'project', project_id: 'team/app' }, group], diff --git a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts index 1a9ee01e..71964fc6 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts @@ -179,6 +179,52 @@ describe('executeScoped', () => { expect(result).toEqual(expected); }); + // An emulated call is validated as the tool itself would: a call the tool refuses is not + // turned into a listing of the scope. + it.each([ + ['browse_search', { action: 'global', scope: 'projects' }], + ['browse_search', { action: 'global', scope: 'projects', search: '' }], + ['browse_projects', { action: 'list', per_page: 'many' }], + ])('refuses the invalid call %s %j before emulating it', async (tool, args) => { + const run = jest.fn(); + + await expect(executeScoped(run, tool, args, projectPolicy)).rejects.toThrow(); + expect(run).not.toHaveBeenCalled(); + }); + + // The group's own audit trail answers an instance listing under a group scope; the events + // of its projects are listed per project, which the result says. + it('lists the scope group audit trail and says project events are listed per project', async () => { + const run = jest.fn().mockResolvedValue([{ id: 1 }]); + + const result = await executeScoped( + run, + 'browse_audit_events', + { action: 'list_instance', created_after: '2026-01-01' }, + groupPolicy, + ); + + expect(run).toHaveBeenCalledWith('browse_audit_events', { + action: 'list_group', + group_id: 'team', + created_after: '2026-01-01', + }); + expect(result).toEqual({ + items: [{ id: 1 }], + partial: true, + message: + "These are the audit events of group 'team' itself; events of its projects are listed per project with action 'list_project'.", + }); + }); + + it('passes on a group audit answer that is not a list', async () => { + const run = jest.fn().mockResolvedValue({ message: 'not a list' }); + + expect( + await executeScoped(run, 'browse_audit_events', { action: 'list_instance' }, groupPolicy), + ).toEqual({ message: 'not a list' }); + }); + it('fails a project scope listing on a failure that is not an Error', async () => { const run = jest.fn().mockRejectedValue('connection reset'); diff --git a/packages/gitlab-mcp/tests/unit/handlers.test.ts b/packages/gitlab-mcp/tests/unit/handlers.test.ts index 7484a0ae..0a1d0586 100644 --- a/packages/gitlab-mcp/tests/unit/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/handlers.test.ts @@ -11,6 +11,7 @@ import { ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { setupHandlers, resetHandlersState } from '../../src/handlers'; +import * as scopeIds from '../../src/configuration/scope-ids'; import { StructuredToolError, parseGitLabApiError } from '../../src/utils/error-handler'; import { GitLabTimeoutError } from '../../src/utils/fetch'; import { ConfigurationService } from '../../src/configuration/service'; @@ -108,6 +109,7 @@ const mockHealthMonitor = { isInstanceReachable: jest.fn().mockReturnValue(true), reportSuccess: jest.fn(), reportError: jest.fn(), + forceReconnect: jest.fn(), getMonitoredInstances: jest.fn().mockReturnValue(['https://gitlab.example.com']), shutdown: jest.fn(), }; @@ -2503,6 +2505,29 @@ describe('handlers', () => { mockHealthMonitor.getState.mockReturnValue('healthy'); (oauth.getGitLabApiUrlFromContext as jest.Mock).mockReturnValue(undefined); }); + + // A configuration tool answered while GitLab is marked unreachable is how a caller checks + // or recovers the connection: the monitor probes GitLab again itself, so other tools + // recover as soon as GitLab answers instead of waiting for the next scheduled attempt. + it('has the monitor probe GitLab again after a configuration tool answers', async () => { + mockHealthMonitor.isInstanceReachable.mockReturnValue(false); + mockHealthMonitor.getState.mockReturnValue('disconnected'); + mockRegistryManager.hasToolHandler.mockReturnValue(true); + mockRegistryManager.executeTool.mockResolvedValueOnce({ success: true }); + + try { + const result = await callToolHandler({ + params: { name: 'check_connection', arguments: {} }, + }); + + expect(result.isError).toBeFalsy(); + expect(mockHealthMonitor.forceReconnect).toHaveBeenCalledWith('https://gitlab.example.com'); + expect(mockHealthMonitor.reportSuccess).not.toHaveBeenCalled(); + } finally { + mockHealthMonitor.isInstanceReachable.mockReturnValue(true); + mockHealthMonitor.getState.mockReturnValue('healthy'); + } + }); }); describe('connection tracker integration via logging mock', () => { @@ -2976,6 +3001,33 @@ describe('handlers', () => { expect(mockConnectionManager.initialize).not.toHaveBeenCalled(); }); + // Limits that need no lookup refuse the call before a numeric target is resolved through + // GitLab, so a refused call reaches nothing. + it('refuses a read-only call with a numeric target without looking the target up', async () => { + await service().updateAccount(staticCaller(), { + readOnly: true, + scope: { type: 'group', path: 'team', includeSubgroups: true }, + }); + const lookup = jest.spyOn(scopeIds, 'withTargetPaths'); + + try { + const result = await callToolHandler( + { + params: { + name: 'manage_merge_request', + arguments: { action: 'merge', project_id: 7, merge_request_iid: 1 }, + }, + }, + { sessionId: 'sess-1' }, + ); + + expect(result.content?.[0].text).toContain('read-only mode is on'); + expect(lookup).not.toHaveBeenCalled(); + } finally { + lookup.mockRestore(); + } + }); + it('names only the tool when a refused call has no action', async () => { await service().updateSession(staticCaller(), { readOnly: true }); From 01ea6749701d7d348f3786c82513f8dc2affb531 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 11 Oct 2026 14:08:59 +0300 Subject: [PATCH 08/11] fix(settings): scope group-only searches and owned runners - Under a group scope without subgroups, a global search and a vulnerability listing read the group's results kept to the group and its direct projects, instead of being refused: search rows by project id, project path or group id, vulnerabilities by their project path (now read with each vulnerability). A user search stays as GitLab gives it. - The caller's own runners under a project or group scope keep the owned feed and its cursor, filtered to the runners the target can use; they were replaced by every runner available to the target. - A scope made of projects is read a few projects at a time and only as far as the requested page needs: the first page of a 50-project scope made 50 concurrent requests, now 5. - switch_instance points every session at the new instance before telling them to refresh, so their tool lists are built for the new instance's account. Regression tests: group-only search and vulnerability rows, owned runners in scope, request count and concurrency of a large project scope, and session pinning on an instance switch. --- .../gitlab-mcp/docs/configuration/settings.md | 21 ++- .../src/configuration/scope-targets.ts | 33 ++-- .../src/configuration/scoped-execution.ts | 82 +++++++-- .../src/configuration/scoped-feeds.ts | 141 ++++++++++++++++ .../src/entities/context/context-manager.ts | 4 + .../gitlab-mcp/src/graphql/vulnerabilities.ts | 3 + packages/gitlab-mcp/src/session-manager.ts | 9 + .../unit/configuration/scope-targets.test.ts | 21 +-- .../configuration/scoped-execution.test.ts | 157 ++++++++++++++++++ .../unit/configuration/scoped-feeds.test.ts | 114 +++++++++++++ .../unit/configuration/settings-store.test.ts | 7 - .../entities/context/context-manager.test.ts | 11 ++ .../tests/unit/session-manager.test.ts | 20 +++ 13 files changed, 555 insertions(+), 68 deletions(-) create mode 100644 packages/gitlab-mcp/src/configuration/scoped-feeds.ts create mode 100644 packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts diff --git a/packages/gitlab-mcp/docs/configuration/settings.md b/packages/gitlab-mcp/docs/configuration/settings.md index 78984f5f..7a93337d 100644 --- a/packages/gitlab-mcp/docs/configuration/settings.md +++ b/packages/gitlab-mcp/docs/configuration/settings.md @@ -58,15 +58,18 @@ scope project's events under a project scope and is refused under a group scope. Instance-wide listings read the scope instead: deploy keys those of the scope project, instance audit events the trail of the scope project or group (a group's trail holds the group's own events, and the result says that its projects' events are listed per project), -all or owned runners those available to the scope's project or group; where the scope has -no such form, the listing is refused. A global search or vulnerability -listing is refused under a scope of several projects or groups, and under a group scope -without subgroups (GitLab's group results always include them); use one project or group -instead. A global search for projects under a project scope answers with the scope's -projects that match; a search of snippet titles, which GitLab offers only instance-wide, is -refused under any scope. An action the administrator denied stays denied when the scope -runs other requests in its place. `manage_todos mark_all_done` marks only the scope's pending todos done and leaves -the others pending. A project created or forked without a namespace goes into the scope's +all runners those available to the scope's project or group, and your own runners those of +yours the scope's project or group can use; where the scope has no such form, the listing is +refused. Under a group scope without subgroups, a global search and a vulnerability listing +read the group's results kept to the group itself and its direct projects (GitLab's group +results always include subgroups; a user search stays as GitLab gives it). A global search or +vulnerability listing is refused under a scope of several projects or groups; use one +project or group instead. A global search for projects under a project scope answers with +the scope's projects that match; a search of snippet titles, which GitLab offers only +instance-wide, is refused under any scope. An action the administrator denied stays denied +when the scope runs other requests in its place. A scope made of many projects is read a few +projects at a time, and only as far as the requested page needs. `manage_todos +mark_all_done` marks only the scope's pending todos done and leaves the others pending. A project created or forked without a namespace goes into the scope's group; with no single group to put it in, and for a group without a parent, the call is refused. diff --git a/packages/gitlab-mcp/src/configuration/scope-targets.ts b/packages/gitlab-mcp/src/configuration/scope-targets.ts index 2ad901ff..42cff998 100644 --- a/packages/gitlab-mcp/src/configuration/scope-targets.ts +++ b/packages/gitlab-mcp/src/configuration/scope-targets.ts @@ -10,7 +10,9 @@ import type { ScopeEnforcer } from '../profiles/scope-enforcer'; type Args = Record; /** The scope as one group or one project, when it is exactly that. */ -function singleTarget(scope: ScopeConfig): { group: string } | { project: string } | undefined { +export function singleTarget( + scope: ScopeConfig, +): { group: string } | { project: string } | undefined { const lists = (scope.projects?.length ?? 0) + (scope.groups?.length ?? 0); if (lists > 0 || scope.namespace) return undefined; if (scope.group && !scope.project) return { group: scope.group }; @@ -22,9 +24,9 @@ const hasValue = (value: unknown): boolean => value !== undefined && value !== n /** * Why a targetless call cannot be narrowed to the scope, or null: a creation that names no - * place, a global search or vulnerability listing no GitLab filter expresses (several - * projects or groups, or a group without its subgroups, which group results always cover), - * and instance-wide listings with no per-scope form. + * place, a global search or vulnerability listing for several projects or groups, and + * instance-wide listings with no per-scope form. A group without its subgroups reads the + * group's results kept to its own projects instead. */ export function targetlessRestriction(tool: string, args: Args, scope: ScopeConfig): string | null { const creation = creationRestriction(tool, args, scope); @@ -80,19 +82,11 @@ const INSTANCE_LISTINGS: Record const NARROWERS: Record Args | null> = { browse_search: (args, target) => { if (args.action !== 'global') return null; - // Group search covers subgroups; a scope without them is refused before this point. + // Group search covers subgroups; under a scope without them its rows are filtered. return 'group' in target ? { ...args, action: 'group', group_id: target.group } : { ...args, action: 'project', project_id: target.project }; @@ -188,9 +182,10 @@ const NARROWERS: Record { - if (args.action !== 'list_all' && args.action !== 'list_owned') return null; + if (args.action !== 'list_all') return null; return 'group' in target ? { ...args, action: 'list_group', group_id: target.group } : { ...args, action: 'list_project', project_id: target.project }; diff --git a/packages/gitlab-mcp/src/configuration/scoped-execution.ts b/packages/gitlab-mcp/src/configuration/scoped-execution.ts index 15a4b599..14922ca6 100644 --- a/packages/gitlab-mcp/src/configuration/scoped-execution.ts +++ b/packages/gitlab-mcp/src/configuration/scoped-execution.ts @@ -12,7 +12,20 @@ import { assertActionAllowed } from '../entities/utils'; import { BrowseProjectsSchema } from '../entities/core/schema-readonly'; import { ManageTodosSchema } from '../entities/core/schema'; import { BrowseSearchSchema } from '../entities/search/schema-readonly'; -import { isFilteredListing, scopedArgs, scopedResult, scopeProjectsOnly } from './scope-targets'; +import { + isFilteredListing, + scopedArgs, + scopedResult, + scopeProjectsOnly, + singleTarget, +} from './scope-targets'; +import { + directGroup, + directGroupSearchRows, + isScopeVulnerability, + keepNodes, + ownedRunnersInScope, +} from './scoped-feeds'; type Args = Record; /** Runs one tool call with the caller's instance and token scopes. */ @@ -53,7 +66,8 @@ const SIMPLE_PROJECT_FIELDS: ReadonlySet = new Set([ 'custom_attributes', 'repository_storage', ]); -const TODO_CONCURRENCY = 5; +/** Requests one scoped call keeps in flight, so a large scope stays within rate limits. */ +const REQUEST_CONCURRENCY = 5; const numberOr = (value: unknown, fallback: number): number => typeof value === 'number' && value > 0 ? value : fallback; @@ -73,8 +87,24 @@ export async function executeScoped( const emulation = emulate(run, tool, requested, scope, scopeEnforcer); if (emulation) return emulation; const args = scopedArgs(tool, requested, scope); - if (isFilteredListing(tool, args)) return fillScopedPage(run, tool, args, scopeEnforcer); + const group = directGroup(scope); + // A group without its subgroups: GitLab's group search covers them, so its rows are kept. + if (group && tool === 'browse_search' && requested.action === 'global') { + const keep = await directGroupSearchRows(run, group, args.scope, scopeEnforcer); + return fillScopedPage(run, tool, args, keep); + } + if (isFilteredListing(tool, args)) { + return fillScopedPage( + run, + tool, + args, + (rows) => scopedResult(tool, args, rows, scopeEnforcer) as unknown[], + ); + } const result = await run(tool, args); + if (group && tool === 'browse_vulnerabilities' && requested.group_id === undefined) { + return keepNodes(result, isScopeVulnerability(scopeEnforcer)); + } return isGroupAuditTrail(requested, args) ? groupAuditTrail(result, args.group_id) : result; } @@ -94,6 +124,11 @@ function emulate( ManageTodosSchema.parse(requested); return markScopeTodosDone(run, enforcer); } + // The caller's own runners stay the feed asked for, kept to those the scope can use. + const target = singleTarget(scope); + if (target && tool === 'browse_runners' && requested.action === 'list_owned') { + return ownedRunnersInScope(run, requested, target); + } const projects = scopeProjectsOnly(scope); if (!projects) return undefined; if ( @@ -138,7 +173,7 @@ async function fillScopedPage( run: RunTool, tool: string, args: Args, - enforcer: ScopeEnforcer, + keep: (rows: unknown[]) => unknown[], ): Promise { const perPage = numberOr(args.per_page, DEFAULT_PAGE_SIZE); const page = numberOr(args.page, 1); @@ -156,7 +191,7 @@ async function fillScopedPage( for (let upstream = 1; upstream <= MAX_UPSTREAM_PAGES && rows.length < wanted; upstream++) { const result = await run(tool, { ...args, page: upstream, per_page: UPSTREAM_PAGE_SIZE }); if (!Array.isArray(result)) return result; - rows.push(...(scopedResult(tool, args, result, enforcer) as unknown[])); + rows.push(...keep(result)); exhausted = result.length < UPSTREAM_PAGE_SIZE; if (exhausted) break; } @@ -187,17 +222,8 @@ async function listScopeProjects( projects: string[], requested: Args, ): Promise { - const found = await Promise.all( - projects.map((project_id) => - run('browse_projects', { action: 'get', project_id }).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - if (parseGitLabApiError(message)?.status === 404) return undefined; - throw error; - }), - ), - ); const search = typeof requested.search === 'string' ? requested.search.toLowerCase() : ''; - const matching = found.filter((project) => { + const matches = (project: unknown): boolean => { if (project === undefined) return false; if (!search) return true; const { name, path_with_namespace } = project as { @@ -207,10 +233,30 @@ async function listScopeProjects( return [name, path_with_namespace].some( (value) => typeof value === 'string' && value.toLowerCase().includes(search), ); - }); + }; const perPage = numberOr(requested.per_page, DEFAULT_PAGE_SIZE); const page = numberOr(requested.page, 1); - const listed = matching.slice((page - 1) * perPage, page * perPage); + const wanted = page * perPage; + const matching: unknown[] = []; + // A few projects at a time, in the scope's order, and only until the page is full: a + // large scope must not send a request per project for a small page. + for ( + let start = 0; + start < projects.length && matching.length < wanted; + start += REQUEST_CONCURRENCY + ) { + const batch = await Promise.all( + projects.slice(start, start + REQUEST_CONCURRENCY).map((project_id) => + run('browse_projects', { action: 'get', project_id }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + if (parseGitLabApiError(message)?.status === 404) return undefined; + throw error; + }), + ), + ); + matching.push(...batch.filter(matches)); + } + const listed = matching.slice((page - 1) * perPage, wanted); // A project is read in full; a listing answers with the simple representation unless // simple=false, as GitLab does (its schema default is true). return requested.simple === false ? listed : listed.map(simpleProject); @@ -258,7 +304,7 @@ async function markScopeTodosDone(run: RunTool, enforcer: ScopeEnforcer): Promis }); } }; - await Promise.all(Array.from({ length: Math.min(TODO_CONCURRENCY, ids.length) }, worker)); + await Promise.all(Array.from({ length: Math.min(REQUEST_CONCURRENCY, ids.length) }, worker)); const marked = ids.length - failed.length; const failedNote = failed.length > 0 ? `; ${failed.length} could not be marked` : ''; return { diff --git a/packages/gitlab-mcp/src/configuration/scoped-feeds.ts b/packages/gitlab-mcp/src/configuration/scoped-feeds.ts new file mode 100644 index 00000000..dfd8bca6 --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/scoped-feeds.ts @@ -0,0 +1,141 @@ +/** + * Feeds GitLab only offers wider than the working scope, kept to it: the search and + * vulnerabilities of a group without its subgroups (GitLab's group results always include + * them), and the caller's own runners available to the scope's project or group. + */ + +import type { ScopeEnforcer } from '../profiles/scope-enforcer'; +import type { ScopeConfig } from '../profiles/types'; +import type { RunTool } from './scoped-execution'; + +type Args = Record; +type Row = Record; + +/** GitLab's largest page, for the listings these feeds read in full. */ +const PAGE_SIZE = 100; +/** Bounds those listings: up to 1000 direct projects or runners. */ +const MAX_PAGES = 10; + +/** The scope's group when it is exactly one group without its subgroups. */ +export function directGroup(scope: ScopeConfig): string | undefined { + const lists = (scope.projects?.length ?? 0) + (scope.groups?.length ?? 0); + if (lists > 0 || scope.namespace || scope.project || !scope.group) return undefined; + return scope.includeSubgroups === false ? scope.group : undefined; +} + +/** + * Keeps the search rows of a group without its subgroups: projects by their path, results + * of a project (issues, merge requests, notes, commits, code, wiki, milestones) by the + * group's direct projects, results of the group itself by its id. Users belong to no + * project, so a group's user search stays as GitLab gives it. + */ +export async function directGroupSearchRows( + run: RunTool, + group: string, + searchScope: unknown, + enforcer: ScopeEnforcer, +): Promise<(rows: unknown[]) => unknown[]> { + if (searchScope === 'users') return (rows) => rows; + if (searchScope === 'projects') { + return (rows) => + rows.filter((row) => { + const path = (row as Row).path_with_namespace; + return typeof path === 'string' && enforcer.isAllowed(path); + }); + } + const [namespace, projectIds] = await Promise.all([ + run('browse_namespaces', { action: 'get', namespace_id: group }), + directProjectIds(run, group, enforcer), + ]); + const groupId = (namespace as Row | undefined)?.id; + return (rows) => + rows.filter((row) => { + const { project_id, group_id } = row as Row; + if (project_id !== undefined && project_id !== null) return projectIds.has(project_id); + return group_id !== undefined && group_id === groupId; + }); +} + +/** + * Ids of the group's own projects. A group's listing also returns projects shared with it + * from other namespaces; those are left out. + */ +async function directProjectIds( + run: RunTool, + group: string, + enforcer: ScopeEnforcer, +): Promise> { + const ids = new Set(); + // Pages are read one after another: whether the next one is needed depends on this one. + for (let page = 1; page <= MAX_PAGES; page++) { + const projects = await run('browse_projects', { + action: 'list', + group_id: group, + include_subgroups: false, + per_page: PAGE_SIZE, + page, + }); + if (!Array.isArray(projects)) break; + for (const project of projects as Row[]) { + const path = project.path_with_namespace; + if (typeof path === 'string' && enforcer.isAllowed(path)) ids.add(project.id); + } + if (projects.length < PAGE_SIZE) break; + } + return ids; +} + +/** + * A connection (`nodes` and `pageInfo`) with only the nodes that pass. Its cursor stays the + * one GitLab gave, so paging on continues the same feed; a page may come back shorter. + */ +export function keepNodes(result: unknown, keep: (node: Row) => boolean): unknown { + const nodes = (result as { nodes?: unknown } | undefined)?.nodes; + if (!Array.isArray(nodes)) return result; + return { ...(result as Row), nodes: (nodes as Row[]).filter(keep) }; +} + +/** A vulnerability of the scope: its project is one the scope allows. */ +export function isScopeVulnerability(enforcer: ScopeEnforcer): (node: Row) => boolean { + return (node) => { + const path = (node.project as { fullPath?: unknown } | undefined)?.fullPath; + return typeof path === 'string' && enforcer.isAllowed(path); + }; +} + +/** + * The caller's own runners available to the scope's project or group: the owned feed as + * requested, with its own cursor, kept to the runners the target can use. + */ +export async function ownedRunnersInScope( + run: RunTool, + requested: Args, + target: { group: string } | { project: string }, +): Promise { + const available: Args = + 'group' in target + ? { action: 'list_group', group_id: target.group } + : { action: 'list_project', project_id: target.project }; + const [owned, ids] = await Promise.all([ + run('browse_runners', requested), + runnerIds(run, available), + ]); + return keepNodes(owned, (node) => ids.has(node.id)); +} + +async function runnerIds(run: RunTool, args: Args): Promise> { + const ids = new Set(); + let after: unknown; + // Pages are read one after another: each continues from the previous page's cursor. + for (let page = 0; page < MAX_PAGES; page++) { + const connection = (await run('browse_runners', { + ...args, + first: PAGE_SIZE, + ...(after === undefined ? {} : { after }), + })) as { nodes?: Row[]; pageInfo?: { hasNextPage?: boolean; endCursor?: unknown } }; + for (const node of connection?.nodes ?? []) ids.add(node.id); + if (!connection?.pageInfo?.hasNextPage) break; + after = connection.pageInfo.endCursor; + } + return ids; +} diff --git a/packages/gitlab-mcp/src/entities/context/context-manager.ts b/packages/gitlab-mcp/src/entities/context/context-manager.ts index 5067c036..d2c70ec0 100644 --- a/packages/gitlab-mcp/src/entities/context/context-manager.ts +++ b/packages/gitlab-mcp/src/entities/context/context-manager.ts @@ -315,6 +315,10 @@ export class ContextManager { // instance's projects. const after = resolveCaller(before.sessionKey, instanceUrl); await getConfigurationService().moveSessions(before.accountKey, after.accountKey); + // Every session follows the shared token before it is told to refresh, so its tool + // list is built for the new instance's account. + const { getSessionManager } = await import('../../session-manager'); + getSessionManager().pinAllSessions(instanceUrl); logInfo('Switched GitLab instance', { previous: previousUrl, diff --git a/packages/gitlab-mcp/src/graphql/vulnerabilities.ts b/packages/gitlab-mcp/src/graphql/vulnerabilities.ts index 2179c38c..3687dbd1 100644 --- a/packages/gitlab-mcp/src/graphql/vulnerabilities.ts +++ b/packages/gitlab-mcp/src/graphql/vulnerabilities.ts @@ -29,6 +29,7 @@ const VULN_FIELDS = ` dismissedAt vulnerabilityPath webUrl + project { fullPath } `; export interface VulnerabilityNode { @@ -45,6 +46,8 @@ export interface VulnerabilityNode { dismissedAt: string | null; vulnerabilityPath: string | null; webUrl: string | null; + /** The project the vulnerability was found in; a working scope keeps it by this path. */ + project: { fullPath: string } | null; } interface PageInfo { diff --git a/packages/gitlab-mcp/src/session-manager.ts b/packages/gitlab-mcp/src/session-manager.ts index 1202014a..5c093727 100644 --- a/packages/gitlab-mcp/src/session-manager.ts +++ b/packages/gitlab-mcp/src/session-manager.ts @@ -148,6 +148,15 @@ export class SessionManager { } } + /** + * Point every session at one instance, without notifying them: a switch of the shared + * static-token instance moves all sessions, and its caller notifies them once afterwards. + */ + pinAllSessions(url: string): void { + const normalizedUrl = normalizeInstanceUrl(url); + for (const session of this.sessions.values()) session.instanceUrl = normalizedUrl; + } + /** * Get the GitLab instance URL associated with a session. * Returns undefined if the session does not exist. diff --git a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts index 46d1b04a..4fdc2267 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts @@ -96,7 +96,7 @@ describe('scopedArgs', () => { ], [ 'browse_runners', - { action: 'list_owned' }, + { action: 'list_all' }, groupOnly, { action: 'list_group', group_id: 'team' }, ], @@ -143,6 +143,8 @@ describe('scopedArgs', () => { ['browse_deploy_keys', { action: 'list', public: true }, project], ['browse_deploy_keys', { action: 'list' }, group], ['browse_runners', { action: 'list_project', project_id: 'team/app' }, project], + // The caller's own runners keep their own feed; they are filtered to the scope instead. + ['browse_runners', { action: 'list_owned' }, project], ['browse_audit_events', { action: 'list_group', group_id: 'team' }, project], ['browse_audit_events', { action: 'list_instance', entity_type: 'User' }, project], ['browse_audit_events', { action: 'list_instance', entity_id: 3 }, project], @@ -178,13 +180,6 @@ describe('isFilteredListing', () => { }); describe('targetlessRestriction', () => { - // GitLab's group search always covers subgroups; results are not filterable by path. - it('refuses a global search under a group scope that excludes subgroups', () => { - expect(targetlessRestriction('browse_search', { action: 'global' }, groupOnly)).toMatch( - /subgroups/, - ); - }); - it('refuses a global search under a scope of several projects', () => { expect(targetlessRestriction('browse_search', { action: 'global' }, several)).toMatch( /search within one of them/, @@ -258,15 +253,11 @@ describe('targetlessRestriction', () => { ); }); - // A group's vulnerability list always covers its subgroups. - it('refuses a vulnerability listing under a group scope without subgroups', () => { - expect(targetlessRestriction('browse_vulnerabilities', { action: 'list' }, groupOnly)).toMatch( - /subgroups/, - ); - }); - it.each([ ['browse_search', { action: 'global' }, group], + // A group without its subgroups is answered by the group's results, kept to it. + ['browse_search', { action: 'global' }, groupOnly], + ['browse_vulnerabilities', { action: 'list' }, groupOnly], ['browse_search', { action: 'group', group_id: 'team' }, several], ['browse_projects', { action: 'list' }, several], ['manage_project', { action: 'create', name: 'app' }, group], diff --git a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts index 71964fc6..67fbf0ff 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts @@ -7,6 +7,7 @@ import { executeScoped } from '../../../src/configuration/scoped-execution'; import { buildPolicy } from '../../../src/configuration/policy'; import { isActionDenied } from '../../../src/config'; +import { ScopeEnforcer } from '../../../src/profiles/scope-enforcer'; jest.mock('../../../src/config', () => ({ ...jest.requireActual('../../../src/config'), @@ -18,6 +19,11 @@ const groupPolicy = buildPolicy( { scope: { type: 'group', path: 'team', includeSubgroups: true } }, {}, ); +const groupOnlyPolicy = buildPolicy( + undefined, + { scope: { type: 'group', path: 'team', includeSubgroups: false } }, + {}, +); const projectPolicy = buildPolicy( undefined, { scope: { type: 'project', path: 'team/app', includeSubgroups: false } }, @@ -225,6 +231,157 @@ describe('executeScoped', () => { ).toEqual({ message: 'not a list' }); }); + // A scope of many projects: the first page reads only the projects it needs, a few at a + // time, instead of every project of the scope at once. + it('reads a page of a large project scope a few projects at a time', async () => { + const paths = Array.from({ length: 50 }, (_, i) => `team/p${i}`); + const many = buildPolicy(undefined, {}, {}); + many.scope = { projects: paths }; + many.scopeEnforcer = new ScopeEnforcer(many.scope); + let inFlight = 0; + let peak = 0; + const run = jest.fn(async (_tool: string, args: Record) => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + inFlight--; + return { id: 1, name: String(args.project_id), path_with_namespace: args.project_id }; + }); + + const result = (await executeScoped( + run, + 'browse_projects', + { action: 'list', per_page: 2 }, + many, + )) as Array>; + + expect(result.map((p) => p.path_with_namespace)).toEqual(['team/p0', 'team/p1']); + expect(run.mock.calls.length).toBeLessThanOrEqual(5); + expect(peak).toBeLessThanOrEqual(5); + }); + + // The caller's own runners stay the caller's: the owned feed, with its own cursor, keeps + // the runners available to the scope project. + it('lists the owned runners available to the scope', async () => { + const run = jest.fn(async (_tool: string, args: Record) => + args.action === 'list_project' + ? { nodes: [{ id: 1 }, { id: 2 }], pageInfo: { hasNextPage: false, endCursor: null } } + : { nodes: [{ id: 2 }, { id: 3 }], pageInfo: { hasNextPage: true, endCursor: 'c' } }, + ); + + const result = await executeScoped( + run, + 'browse_runners', + { action: 'list_owned', status: 'ONLINE', first: 20 }, + projectPolicy, + ); + + expect(run).toHaveBeenCalledWith('browse_runners', { + action: 'list_owned', + status: 'ONLINE', + first: 20, + }); + expect(run).toHaveBeenCalledWith('browse_runners', { + action: 'list_project', + project_id: 'team/app', + first: 100, + }); + expect(result).toEqual({ + nodes: [{ id: 2 }], + pageInfo: { hasNextPage: true, endCursor: 'c' }, + }); + }); + + // A group without its subgroups has no search of its own: the group search answers, kept + // to the group's direct projects and the group itself. + it('searches a group without its subgroups through its direct projects', async () => { + const run = jest.fn(async (tool: string, args: Record) => { + if (tool === 'browse_namespaces') return { id: 10, full_path: 'team' }; + if (tool === 'browse_projects') { + return args.page === 1 ? [{ id: 1, path_with_namespace: 'team/app' }] : []; + } + return [ + { project_id: 1, title: 'direct' }, + { project_id: 2, title: 'subgroup' }, + { group_id: 10, title: 'group' }, + { group_id: 11, title: 'subgroup group' }, + ]; + }); + + const result = await executeScoped( + run, + 'browse_search', + { action: 'global', scope: 'issues', search: 'x' }, + groupOnlyPolicy, + ); + + expect(run).toHaveBeenCalledWith('browse_search', { + action: 'group', + group_id: 'team', + scope: 'issues', + search: 'x', + page: 1, + per_page: 100, + }); + expect(result).toEqual([ + { project_id: 1, title: 'direct' }, + { group_id: 10, title: 'group' }, + ]); + }); + + it.each([ + [ + 'projects', + [{ path_with_namespace: 'team/app' }, { path_with_namespace: 'team/sub/x' }], + [{ path_with_namespace: 'team/app' }], + ], + // Users belong to no project; a group search lists them all. + ['users', [{ id: 1 }, { id: 2 }], [{ id: 1 }, { id: 2 }]], + ])( + 'keeps %s of a group search without subgroups by their own place', + async (scope, rows, expected) => { + const run = jest.fn().mockResolvedValue(rows); + + const result = await executeScoped( + run, + 'browse_search', + { action: 'global', scope, search: 'x' }, + groupOnlyPolicy, + ); + + expect(result).toEqual(expected); + expect(run).toHaveBeenCalledTimes(1); + }, + ); + + // The group's vulnerabilities, kept to its direct projects; the cursor stays the group's. + it('lists the vulnerabilities of a group without its subgroups', async () => { + const run = jest.fn().mockResolvedValue({ + nodes: [ + { id: 1, project: { fullPath: 'team/app' } }, + { id: 2, project: { fullPath: 'team/sub/x' } }, + ], + pageInfo: { hasNextPage: true, endCursor: 'c' }, + }); + + const result = await executeScoped( + run, + 'browse_vulnerabilities', + { action: 'list', first: 20 }, + groupOnlyPolicy, + ); + + expect(run).toHaveBeenCalledWith('browse_vulnerabilities', { + action: 'list', + group_id: 'team', + first: 20, + }); + expect(result).toEqual({ + nodes: [{ id: 1, project: { fullPath: 'team/app' } }], + pageInfo: { hasNextPage: true, endCursor: 'c' }, + }); + }); + it('fails a project scope listing on a failure that is not an Error', async () => { const run = jest.fn().mockRejectedValue('connection reset'); diff --git a/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts new file mode 100644 index 00000000..c166ecbb --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts @@ -0,0 +1,114 @@ +/** + * Feeds GitLab offers only wider than the working scope, kept to it: the rows of a group + * without its subgroups and the caller's own runners the scope can use. + */ + +import { + directGroup, + directGroupSearchRows, + keepNodes, + ownedRunnersInScope, +} from '../../../src/configuration/scoped-feeds'; +import { ScopeEnforcer } from '../../../src/profiles/scope-enforcer'; + +const groupOnly = new ScopeEnforcer({ group: 'team', includeSubgroups: false }); + +describe('directGroup', () => { + it.each([ + [{ group: 'team', includeSubgroups: false }, 'team'], + [{ group: 'team', includeSubgroups: true }, undefined], + [{ group: 'team' }, undefined], + [{ project: 'team/app' }, undefined], + [{ groups: ['team'], includeSubgroups: false }, undefined], + [{ group: 'team', project: 'other/app', includeSubgroups: false }, undefined], + ])('reads %j as the direct group %s', (scope, expected) => { + expect(directGroup(scope)).toBe(expected); + }); +}); + +describe('directGroupSearchRows', () => { + // The group listing pages through its projects; projects shared into the group from other + // namespaces, and rows without a path, are not its own. + it('keeps the rows of the group own projects across listing pages', async () => { + const full = Array.from({ length: 100 }, (_, i) => ({ + id: 100 + i, + path_with_namespace: `team/p${i}`, + })); + const run = jest.fn(async (tool: string, args: Record) => { + if (tool === 'browse_namespaces') return { id: 10 }; + if (args.page === 1) return full; + return [ + { id: 7, path_with_namespace: 'other/shared' }, + { id: 8 }, + { id: 9, path_with_namespace: 'team/last' }, + ]; + }); + + const keep = await directGroupSearchRows(run, 'team', 'issues', groupOnly); + + expect( + keep([ + { project_id: 150 }, + { project_id: 9 }, + { project_id: 7 }, + { project_id: 8 }, + { project_id: null, group_id: 10 }, + { title: 'no place' }, + ]), + ).toEqual([{ project_id: 150 }, { project_id: 9 }, { project_id: null, group_id: 10 }]); + }); + + it('keeps no project rows when the group listing is not a list', async () => { + const run = jest.fn(async (tool: string) => + tool === 'browse_namespaces' ? { id: 10 } : { message: 'not a list' }, + ); + + const keep = await directGroupSearchRows(run, 'team', 'blobs', groupOnly); + + expect(keep([{ project_id: 1 }, { group_id: 10 }])).toEqual([{ group_id: 10 }]); + }); +}); + +describe('keepNodes', () => { + it('passes on a result that is not a connection', () => { + const result = { message: 'not a connection' }; + + expect(keepNodes(result, () => false)).toBe(result); + }); +}); + +describe('ownedRunnersInScope', () => { + // The group's runners are read through their cursor; the owned feed keeps its own. + it('keeps the owned runners a scope group can use, over several pages of its runners', async () => { + const run = jest.fn(async (_tool: string, args: Record) => { + if (args.action === 'list_owned') { + return { nodes: [{ id: 1 }, { id: 2 }, { id: 3 }], pageInfo: { hasNextPage: false } }; + } + return args.after === undefined + ? { nodes: [{ id: 1 }], pageInfo: { hasNextPage: true, endCursor: 'next' } } + : { nodes: [{ id: 3 }], pageInfo: { hasNextPage: false, endCursor: null } }; + }); + + const result = await ownedRunnersInScope(run, { action: 'list_owned' }, { group: 'team' }); + + expect(run).toHaveBeenCalledWith('browse_runners', { + action: 'list_group', + group_id: 'team', + first: 100, + after: 'next', + }); + expect(result).toEqual({ nodes: [{ id: 1 }, { id: 3 }], pageInfo: { hasNextPage: false } }); + }); + + it('keeps no owned runner when the scope runners come back without nodes', async () => { + const run = jest.fn(async (_tool: string, args: Record) => + args.action === 'list_owned' ? { nodes: [{ id: 1 }] } : undefined, + ); + + expect( + await ownedRunnersInScope(run, { action: 'list_owned' }, { project: 'team/app' }), + ).toEqual({ + nodes: [], + }); + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts b/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts index 22566441..165b732b 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts @@ -139,13 +139,6 @@ describe('LocalSettingsFile', () => { .spyOn(fs.promises, 'readFile') .mockImplementationOnce(async (...args: Parameters) => { const content = await realReadFile(...args); - // Long enough for any lock this writer holds to look abandoned. - for (const name of fs.readdirSync(path.dirname(dir))) { - if (name.endsWith('.lock')) { - const longAgo = new Date(Date.now() - 60_000); - fs.utimesSync(path.join(path.dirname(dir), name), longAgo, longAgo); - } - } paused(); await resumed; return content; diff --git a/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts index 4cc0f030..1cbf90db 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/context-manager.test.ts @@ -29,6 +29,11 @@ jest.mock('../../../../src/server', () => ({ sendToolsListChangedNotification: jest.fn().mockResolvedValue(undefined), })); +const mockPinAllSessions = jest.fn(); +jest.mock('../../../../src/session-manager', () => ({ + getSessionManager: () => ({ pinAllSessions: mockPinAllSessions }), +})); + const mockClearNamespaceTierCache = jest.fn(); jest.mock('../../../../src/services/NamespaceTierDetector', () => ({ clearNamespaceTierCache: () => mockClearNamespaceTierCache(), @@ -545,6 +550,12 @@ describe('ContextManager', () => { expect(mockClearNamespaceTierCache).toHaveBeenCalled(); expect(mockReinitialize).toHaveBeenCalledWith('https://success-gitlab.example.com'); expect(mockSendToolsListChangedNotification).toHaveBeenCalled(); + // Sessions follow the shared token to the new instance before they are told to refresh, + // so the refreshed tool list is built for the new instance's account. + expect(mockPinAllSessions).toHaveBeenCalledWith('https://success-gitlab.example.com'); + expect(mockPinAllSessions.mock.invocationCallOrder[0]).toBeLessThan( + mockSendToolsListChangedNotification.mock.invocationCallOrder[0], + ); expect((await runWithCaller(alice1, () => manager.getContext())).scope).toBeUndefined(); }); diff --git a/packages/gitlab-mcp/tests/unit/session-manager.test.ts b/packages/gitlab-mcp/tests/unit/session-manager.test.ts index 0a642c61..8533c228 100644 --- a/packages/gitlab-mcp/tests/unit/session-manager.test.ts +++ b/packages/gitlab-mcp/tests/unit/session-manager.test.ts @@ -307,6 +307,26 @@ describe('SessionManager', () => { expect(manager.getSessionInstanceUrl('session-1')).toBe('https://other.gitlab.com'); }); + // A switch of the shared static-token instance moves every session; the caller notifies + // them once afterwards, so pinning itself sends nothing. + it('pins every session to an instance without notifying them', async () => { + manager.start(); + const first = await manager.createSession('session-1', mockTransport); + const second = await manager.createSession( + 'session-2', + mockTransport, + 'https://a.example.com', + ); + + manager.pinAllSessions('https://other.gitlab.com/'); + + expect(manager.getSessionInstanceUrl('session-1')).toBe('https://other.gitlab.com'); + expect(manager.getSessionInstanceUrl('session-2')).toBe('https://other.gitlab.com'); + await Promise.resolve(); + expect(first.notification).not.toHaveBeenCalled(); + expect(second.notification).not.toHaveBeenCalled(); + }); + it('should send tools/list_changed notification when instanceUrl changes', async () => { manager.start(); const server = await manager.createSession('session-1', mockTransport); From 2d0f75e284b1ea2e317ed6f5d49af61c39105be5 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 11 Oct 2026 14:39:48 +0300 Subject: [PATCH 09/11] fix(settings): mark truncated group searches, refresh after reconnect - A search of a group without its subgroups reads the group's own projects up to 1000; past them, rows of projects not read were dropped silently. The answer is now marked partial, with the reason added to any the page already had. - The channel gateway tells the host to list tools again after it reconnects downstream: the new downstream may serve another catalog (settings changed during the outage, or the old link lost its notification). - get_profile reads the account of the instance the server's token works with now, so it follows switch_instance like every other tool. Regression tests: the truncated group search and combined partial reasons, the catalog refresh after a reconnect, and the profile after an instance switch. --- .../gitlab-mcp/docs/configuration/settings.md | 3 +- .../gitlab-mcp/src/channel-gateway/gateway.ts | 9 ++- .../src/configuration/scoped-execution.ts | 19 +++++- .../src/configuration/scoped-feeds.ts | 30 +++++---- .../src/entities/context/profile.ts | 6 +- .../unit/channel-gateway/gateway.test.ts | 15 +++++ .../configuration/scoped-execution.test.ts | 64 +++++++++++++++++++ .../unit/configuration/scoped-feeds.test.ts | 9 ++- .../unit/entities/context/profile.test.ts | 36 +++++++++-- 9 files changed, 164 insertions(+), 27 deletions(-) diff --git a/packages/gitlab-mcp/docs/configuration/settings.md b/packages/gitlab-mcp/docs/configuration/settings.md index 7a93337d..2d44ccaf 100644 --- a/packages/gitlab-mcp/docs/configuration/settings.md +++ b/packages/gitlab-mcp/docs/configuration/settings.md @@ -62,7 +62,8 @@ all runners those available to the scope's project or group, and your own runner yours the scope's project or group can use; where the scope has no such form, the listing is refused. Under a group scope without subgroups, a global search and a vulnerability listing read the group's results kept to the group itself and its direct projects (GitLab's group -results always include subgroups; a user search stays as GitLab gives it). A global search or +results always include subgroups; a user search stays as GitLab gives it). The group's own +projects are read up to 1000; a search of a larger group comes back marked `partial`. A global search or vulnerability listing is refused under a scope of several projects or groups; use one project or group instead. A global search for projects under a project scope answers with the scope's projects that match; a search of snippet titles, which GitLab offers only diff --git a/packages/gitlab-mcp/src/channel-gateway/gateway.ts b/packages/gitlab-mcp/src/channel-gateway/gateway.ts index 5e1a938c..ad530fd1 100644 --- a/packages/gitlab-mcp/src/channel-gateway/gateway.ts +++ b/packages/gitlab-mcp/src/channel-gateway/gateway.ts @@ -222,8 +222,13 @@ export class ChannelGateway { return; } this.connected = true; - // Only on a reconnect (not the initial connect): announce link restored. - if (this.reconnecting) this.notifyLink('restored'); + // Only on a reconnect (not the initial connect): announce link restored, and have the + // host list tools again: the replacement downstream may serve another catalog (its + // settings changed during the outage, or the old link lost its notification). + if (this.reconnecting) { + this.notifyLink('restored'); + void this.server.sendToolListChanged().catch(() => {}); + } return; } catch { if (this.closing) return; diff --git a/packages/gitlab-mcp/src/configuration/scoped-execution.ts b/packages/gitlab-mcp/src/configuration/scoped-execution.ts index 14922ca6..fff59aa2 100644 --- a/packages/gitlab-mcp/src/configuration/scoped-execution.ts +++ b/packages/gitlab-mcp/src/configuration/scoped-execution.ts @@ -20,6 +20,7 @@ import { singleTarget, } from './scope-targets'; import { + DIRECT_PROJECTS_READ, directGroup, directGroupSearchRows, isScopeVulnerability, @@ -90,8 +91,14 @@ export async function executeScoped( const group = directGroup(scope); // A group without its subgroups: GitLab's group search covers them, so its rows are kept. if (group && tool === 'browse_search' && requested.action === 'global') { - const keep = await directGroupSearchRows(run, group, args.scope, scopeEnforcer); - return fillScopedPage(run, tool, args, keep); + const { keep, complete } = await directGroupSearchRows(run, group, args.scope, scopeEnforcer); + const page = await fillScopedPage(run, tool, args, keep); + return complete + ? page + : withPartialReason( + page, + `Results are kept to the first ${DIRECT_PROJECTS_READ} direct projects of group '${group}'; results of its other projects are not shown.`, + ); } if (isFilteredListing(tool, args)) { return fillScopedPage( @@ -212,6 +219,14 @@ function partialPage(items: unknown[], reason: string): unknown { }; } +/** A scoped page marked partial for one more reason, whether or not it already was. */ +function withPartialReason(page: unknown, reason: string): unknown { + if (Array.isArray(page)) return partialPage(page, reason); + const partial = page as { items?: unknown; partial?: unknown; message?: unknown }; + if (partial?.partial !== true || !Array.isArray(partial.items)) return page; + return { ...partial, message: `${reason} ${String(partial.message)}` }; +} + /** * browse_projects list for a scope made of projects: the projects themselves, filtered by * the listing's search and paged like it. A project GitLab no longer has (404) is left diff --git a/packages/gitlab-mcp/src/configuration/scoped-feeds.ts b/packages/gitlab-mcp/src/configuration/scoped-feeds.ts index dfd8bca6..5c8d73b3 100644 --- a/packages/gitlab-mcp/src/configuration/scoped-feeds.ts +++ b/packages/gitlab-mcp/src/configuration/scoped-feeds.ts @@ -34,37 +34,43 @@ export async function directGroupSearchRows( group: string, searchScope: unknown, enforcer: ScopeEnforcer, -): Promise<(rows: unknown[]) => unknown[]> { - if (searchScope === 'users') return (rows) => rows; +): Promise<{ keep: (rows: unknown[]) => unknown[]; complete: boolean }> { + if (searchScope === 'users') return { keep: (rows) => rows, complete: true }; if (searchScope === 'projects') { - return (rows) => + const keep = (rows: unknown[]): unknown[] => rows.filter((row) => { const path = (row as Row).path_with_namespace; return typeof path === 'string' && enforcer.isAllowed(path); }); + return { keep, complete: true }; } - const [namespace, projectIds] = await Promise.all([ + const [namespace, projects] = await Promise.all([ run('browse_namespaces', { action: 'get', namespace_id: group }), directProjectIds(run, group, enforcer), ]); const groupId = (namespace as Row | undefined)?.id; - return (rows) => + const keep = (rows: unknown[]): unknown[] => rows.filter((row) => { const { project_id, group_id } = row as Row; - if (project_id !== undefined && project_id !== null) return projectIds.has(project_id); + if (project_id !== undefined && project_id !== null) return projects.ids.has(project_id); return group_id !== undefined && group_id === groupId; }); + return { keep, complete: projects.complete }; } +/** The bound on the group's own projects read to keep its search rows. */ +export const DIRECT_PROJECTS_READ = MAX_PAGES * PAGE_SIZE; + /** - * Ids of the group's own projects. A group's listing also returns projects shared with it - * from other namespaces; those are left out. + * Ids of the group's own projects, and whether all of them were read within the bound. A + * group's listing also returns projects shared with it from other namespaces; those are left + * out. */ async function directProjectIds( run: RunTool, group: string, enforcer: ScopeEnforcer, -): Promise> { +): Promise<{ ids: Set; complete: boolean }> { const ids = new Set(); // Pages are read one after another: whether the next one is needed depends on this one. for (let page = 1; page <= MAX_PAGES; page++) { @@ -80,9 +86,11 @@ async function directProjectIds( const path = project.path_with_namespace; if (typeof path === 'string' && enforcer.isAllowed(path)) ids.add(project.id); } - if (projects.length < PAGE_SIZE) break; + if (projects.length < PAGE_SIZE) return { ids, complete: true }; } - return ids; + // The read stopped at the bound on a full page, or the listing gave no list: more of the + // group's projects may exist. + return { ids, complete: false }; } /** diff --git a/packages/gitlab-mcp/src/entities/context/profile.ts b/packages/gitlab-mcp/src/entities/context/profile.ts index 33bbb02f..360ae2c1 100644 --- a/packages/gitlab-mcp/src/entities/context/profile.ts +++ b/packages/gitlab-mcp/src/entities/context/profile.ts @@ -8,10 +8,10 @@ */ import { createHash } from 'node:crypto'; -import { GITLAB_BASE_URL } from '../../config'; import { logDebug } from '../../logger'; import { getGitLabApiUrlFromContext, getTokenContext } from '../../oauth/token-context'; import { enhancedFetch } from '../../utils/fetch'; +import { getGitLabBaseUrl } from '../../utils/gitlab-base-url'; import { normalizeInstanceUrl } from '../../utils/url'; /** Profile returned to the host (OpenAI `openai/profile` contract). */ @@ -84,7 +84,9 @@ function instanceName(instanceUrl: string, label: string | undefined): string { */ export async function getAccountProfile(): Promise { const context = getTokenContext(); - const instanceUrl = normalizeInstanceUrl(getGitLabApiUrlFromContext() ?? GITLAB_BASE_URL); + // The linked account's instance, else the one the server's token works with now (it + // follows switch_instance). + const instanceUrl = normalizeInstanceUrl(getGitLabApiUrlFromContext() ?? getGitLabBaseUrl()); const user = await fetchCurrentUser(instanceUrl); const userId = context?.gitlabUserId ?? user?.id; diff --git a/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts b/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts index 750bfa24..763d7721 100644 --- a/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts +++ b/packages/gitlab-mcp/tests/unit/channel-gateway/gateway.test.ts @@ -472,6 +472,21 @@ describe('ChannelGateway', () => { await gw.stop(); }); + // The replacement downstream may serve another catalog (settings changed during the + // outage, or its own notification was lost with the old link): the host is told to list + // again after a reconnect, not after the first connect. + it('tells the host to list tools again after a reconnect', async () => { + const gw = new ChannelGateway(baseConfig); + await gw.start(); + expect(mockServerSendToolListChanged).not.toHaveBeenCalled(); + + mockTransportInstance.onclose!(); + await wait(10); + + expect(mockServerSendToolListChanged).toHaveBeenCalledTimes(1); + await gw.stop(); + }); + it('retries the downstream connection with backoff', async () => { jest.useFakeTimers(); try { diff --git a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts index 67fbf0ff..a861e700 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts @@ -329,6 +329,70 @@ describe('executeScoped', () => { ]); }); + // The group's own projects are read up to a bound; past it, rows of the projects not read + // would be dropped, so the answer says it is partial instead of looking complete. + it('marks a group search partial when the group has more direct projects than are read', async () => { + let next = 0; + const run = jest.fn(async (tool: string) => { + if (tool === 'browse_namespaces') return { id: 10 }; + if (tool === 'browse_projects') { + return Array.from({ length: 100 }, () => ({ + id: next++, + path_with_namespace: `team/p${next}`, + })); + } + return [{ project_id: 5, title: 'read' }]; + }); + + const result = await executeScoped( + run, + 'browse_search', + { action: 'global', scope: 'issues', search: 'x' }, + groupOnlyPolicy, + ); + + expect(result).toEqual({ + items: [{ project_id: 5, title: 'read' }], + partial: true, + message: + "Results are kept to the first 1000 direct projects of group 'team'; results of its other projects are not shown. Narrow the listing (for example with a search) to see the rest.", + }); + }); + + // A page that is partial for its own reason keeps it, and adds the projects bound. + it.each([ + [ + { action: 'global', scope: 'issues', search: 'x', page: 51, per_page: 20 }, + { + items: [], + partial: true, + message: + "Results are kept to the first 1000 direct projects of group 'team'; results of its other projects are not shown. A scoped listing reads the first 1000 rows GitLab returns, and page 51 starts after them. Narrow the listing (for example with a search) to see the rest.", + }, + ], + ])('combines the reasons of a partial group search %j', async (args, expected) => { + const run = jest.fn(async (tool: string) => + tool === 'browse_namespaces' ? { id: 10 } : { message: 'not a list' }, + ); + + expect(await executeScoped(run, 'browse_search', args, groupOnlyPolicy)).toEqual(expected); + }); + + it('passes on a group search answer that is not a list', async () => { + const run = jest.fn(async (tool: string) => + tool === 'browse_search' ? { message: 'search failed' } : { message: 'not a list' }, + ); + + expect( + await executeScoped( + run, + 'browse_search', + { action: 'global', scope: 'issues', search: 'x' }, + groupOnlyPolicy, + ), + ).toEqual({ message: 'search failed' }); + }); + it.each([ [ 'projects', diff --git a/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts index c166ecbb..137e2a4c 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts @@ -44,8 +44,9 @@ describe('directGroupSearchRows', () => { ]; }); - const keep = await directGroupSearchRows(run, 'team', 'issues', groupOnly); + const { keep, complete } = await directGroupSearchRows(run, 'team', 'issues', groupOnly); + expect(complete).toBe(true); expect( keep([ { project_id: 150 }, @@ -58,13 +59,15 @@ describe('directGroupSearchRows', () => { ).toEqual([{ project_id: 150 }, { project_id: 9 }, { project_id: null, group_id: 10 }]); }); - it('keeps no project rows when the group listing is not a list', async () => { + // The group's projects are unknown, so the answer cannot claim to be complete. + it('keeps no project rows, and says so, when the group listing is not a list', async () => { const run = jest.fn(async (tool: string) => tool === 'browse_namespaces' ? { id: 10 } : { message: 'not a list' }, ); - const keep = await directGroupSearchRows(run, 'team', 'blobs', groupOnly); + const { keep, complete } = await directGroupSearchRows(run, 'team', 'blobs', groupOnly); + expect(complete).toBe(false); expect(keep([{ project_id: 1 }, { group_id: 10 }])).toEqual([{ group_id: 10 }]); }); }); diff --git a/packages/gitlab-mcp/tests/unit/entities/context/profile.test.ts b/packages/gitlab-mcp/tests/unit/entities/context/profile.test.ts index 9ebcecf5..b95bd8d5 100644 --- a/packages/gitlab-mcp/tests/unit/entities/context/profile.test.ts +++ b/packages/gitlab-mcp/tests/unit/entities/context/profile.test.ts @@ -6,16 +6,12 @@ import { accountProfileId, getAccountProfile } from '../../../../src/entities/context/profile'; import { runWithTokenContext } from '../../../../src/oauth/token-context'; import type { TokenContext } from '../../../../src/oauth/types'; +import { setActiveInstanceSource } from '../../../../src/utils/gitlab-base-url'; jest.mock('../../../../src/utils/fetch', () => ({ enhancedFetch: jest.fn(), })); -jest.mock('../../../../src/config', () => ({ - ...jest.requireActual('../../../../src/config'), - GITLAB_BASE_URL: 'https://gitlab.example.com', -})); - import { enhancedFetch } from '../../../../src/utils/fetch'; const mockFetch = enhancedFetch as jest.MockedFunction; @@ -100,8 +96,15 @@ describe('account profile', () => { it('identifies a static-token connection from GitLab', async () => { mockFetch.mockResolvedValue(userResponse({ id: 7, username: 'bot', public_email: 'b@x.io' })); + const configured = process.env.GITLAB_API_URL; + process.env.GITLAB_API_URL = 'https://gitlab.example.com'; - const profile = await getAccountProfile(); + let profile; + try { + profile = await getAccountProfile(); + } finally { + process.env.GITLAB_API_URL = configured; + } expect(profile).toEqual({ id: accountProfileId('https://gitlab.example.com', 7), @@ -110,6 +113,27 @@ describe('account profile', () => { }); }); + // After switch_instance the server's token works with another instance: the profile is + // that instance's account, read from it. + it('identifies a static-token connection on the instance it works with now', async () => { + mockFetch.mockResolvedValue(userResponse({ id: 7, username: 'bot' })); + setActiveInstanceSource(() => 'https://other.example.com'); + + try { + const profile = await getAccountProfile(); + + expect(mockFetch).toHaveBeenCalledWith('https://other.example.com/api/v4/user', { + retry: false, + }); + expect(profile).toEqual({ + id: accountProfileId('https://other.example.com', 7), + nickname: 'bot @ other.example.com', + }); + } finally { + setActiveInstanceSource(() => null); + } + }); + it('keeps the linked identity when GitLab throws a non-Error value', async () => { mockFetch.mockRejectedValue('socket hang up'); From 32324b861cbf5b334a6938912aef25a4123e4abe Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 11 Oct 2026 14:57:28 +0300 Subject: [PATCH 10/11] refactor(settings): trim scope paths linearly, split settings checks - Scope paths are trimmed of their leading and trailing slashes in one pass; the end-anchored pattern backtracked over every run of slashes inside a path a caller supplies. - The two-step settings check of a tool call (limits needing nothing from GitLab, then the scope with numeric targets resolved) is its own function instead of part of the call handler. - list-tools counts a preset's tools from the same function that lists them, with the deny pattern built in one place, instead of two copies of the filtering. --- packages/gitlab-mcp/src/cli/list-tools.ts | 66 ++++++------------- packages/gitlab-mcp/src/handlers.ts | 36 ++++++---- .../gitlab-mcp/src/profiles/scope-enforcer.ts | 14 +++- .../unit/profiles/scope-enforcer.test.ts | 10 +++ 4 files changed, 67 insertions(+), 59 deletions(-) diff --git a/packages/gitlab-mcp/src/cli/list-tools.ts b/packages/gitlab-mcp/src/cli/list-tools.ts index 572c9766..7a1c34e8 100644 --- a/packages/gitlab-mcp/src/cli/list-tools.ts +++ b/packages/gitlab-mcp/src/cli/list-tools.ts @@ -1049,43 +1049,24 @@ const FEATURE_NAMES = Object.keys(FEATURE_TO_TOOLS) as readonly string[]; * Count tools enabled for a preset by simulating its application */ function countToolsForPreset(preset: Preset, allToolNames: string[]): number { - let enabledTools = allToolNames; - - // Apply read_only filter (removes manage_* tools) - if (preset.read_only) { - enabledTools = enabledTools.filter((name) => !name.startsWith('manage_')); - } - - // Apply denied_tools_regex; a whitelist overrides it - if (preset.denied_tools_regex && !preset.allowed_tools?.length) { - try { - const regex = new RegExp(preset.denied_tools_regex); - enabledTools = enabledTools.filter((name) => !regex.test(name)); - } catch (error) { - console.warn( - `Warning: invalid denied_tools_regex "${preset.denied_tools_regex}": ${error instanceof Error ? error.message : 'unknown error'}`, - ); - } - } - - // Apply allowed_tools (whitelist overrides everything) - if (preset.allowed_tools && preset.allowed_tools.length > 0) { - const allowedSet = new Set(preset.allowed_tools); - enabledTools = enabledTools.filter((name) => allowedSet.has(name)); - } + return getToolsForPreset(preset, allToolNames).enabled.length; +} - // Apply feature flags - if (preset.features) { - for (const [feature, tools] of Object.entries(FEATURE_TO_TOOLS)) { - const featureKey = feature as keyof typeof preset.features; - if (preset.features[featureKey] === false) { - const toolSet = new Set(tools); - enabledTools = enabledTools.filter((name) => !toolSet.has(name)); - } - } +/** + * The preset's deny pattern, or undefined when it has none, has a whitelist (which + * overrides it), or does not compile (reported once as a warning). + */ +function presetDenyPattern(preset: Preset): RegExp | undefined { + if (!preset.denied_tools_regex || preset.allowed_tools?.length) return undefined; + try { + return new RegExp(preset.denied_tools_regex); + } catch (error) { + // The RegExp constructor throws a SyntaxError for a pattern it cannot compile. + console.warn( + `Warning: invalid denied_tools_regex "${preset.denied_tools_regex}": ${(error as SyntaxError).message}`, + ); + return undefined; } - - return enabledTools.length; } /** @@ -1106,17 +1087,10 @@ function getToolsForPreset( } // Apply denied_tools_regex; a whitelist overrides it - if (preset.denied_tools_regex && !preset.allowed_tools?.length) { - try { - const regex = new RegExp(preset.denied_tools_regex); - const denied = enabledTools.filter((name) => regex.test(name)); - disabledTools.push(...denied); - enabledTools = enabledTools.filter((name) => !regex.test(name)); - } catch (error) { - console.warn( - `Warning: invalid denied_tools_regex "${preset.denied_tools_regex}": ${error instanceof Error ? error.message : 'unknown error'}`, - ); - } + const denyPattern = presetDenyPattern(preset); + if (denyPattern) { + disabledTools.push(...enabledTools.filter((name) => denyPattern.test(name))); + enabledTools = enabledTools.filter((name) => !denyPattern.test(name)); } // Apply allowed_tools (whitelist) diff --git a/packages/gitlab-mcp/src/handlers.ts b/packages/gitlab-mcp/src/handlers.ts index 0fd73e11..b67ee271 100644 --- a/packages/gitlab-mcp/src/handlers.ts +++ b/packages/gitlab-mcp/src/handlers.ts @@ -490,7 +490,24 @@ async function withoutRestrictedTools( ); } -/** A refusal for a call the caller's settings do not allow, or null when it may run. */ +/** + * A refusal for a call the caller's settings do not allow, or null when it may run. Tool, + * group, read-only and action limits come first: they need nothing from GitLab, so a call + * they refuse reaches nothing. Then the scope, which holds paths: numeric ids are checked + * as the paths they stand for. + */ +async function callSettingsRejection( + toolName: string, + args: Record, + policy: EffectivePolicy, + registryManager: import('./registry-manager').RegistryManager, +): Promise { + const unscoped = settingsRejection(toolName, args, withoutScope(policy), registryManager); + if (unscoped || !policy.scopeEnforcer || isExemptFromSettings(toolName)) return unscoped; + return settingsRejection(toolName, await withTargetPaths(args), policy, registryManager); +} + +/** A refusal for a call the given policy does not allow, or null when it may run. */ function settingsRejection( toolName: string, args: Record, @@ -970,17 +987,12 @@ export async function setupHandlers(server: Server): Promise { // when they cannot be read: allowing it could bypass a restriction. const { RegistryManager: CallRegistry } = await import('./registry-manager'); const configuration = await getConfigurationService().resolve(caller); - const { policy } = configuration; - const callRegistry = CallRegistry.getInstance(); - const callArguments = toolArguments ?? {}; - // Tool, group, read-only and action limits first: they need nothing from GitLab, so a - // call they refuse reaches nothing. - const settingsRefusal = - settingsRejection(toolName, callArguments, withoutScope(policy), callRegistry) ?? - // A scope holds paths: numeric ids are checked as the paths they stand for. - (policy.scopeEnforcer && !isExemptFromSettings(toolName) - ? settingsRejection(toolName, await withTargetPaths(callArguments), policy, callRegistry) - : null); + const settingsRefusal = await callSettingsRejection( + toolName, + toolArguments ?? {}, + configuration.policy, + CallRegistry.getInstance(), + ); if (settingsRefusal) return settingsRefusal; // Early return: instance unreachable for non-context tools diff --git a/packages/gitlab-mcp/src/profiles/scope-enforcer.ts b/packages/gitlab-mcp/src/profiles/scope-enforcer.ts index 533cfeff..1da99f90 100644 --- a/packages/gitlab-mcp/src/profiles/scope-enforcer.ts +++ b/packages/gitlab-mcp/src/profiles/scope-enforcer.ts @@ -73,7 +73,7 @@ function getScopeDescription(scope: ScopeConfig): string { * - Handles numeric IDs (returns as-is) */ function normalizeProjectPath(path: string): string { - const trimmed = decodePath(path.trim()).replace(/^\/+|\/+$/g, ''); + const trimmed = trimSlashes(decodePath(path.trim())); // If it's a numeric ID, return as-is if (/^\d+$/.test(trimmed)) { return trimmed; @@ -81,6 +81,18 @@ function normalizeProjectPath(path: string): string { return trimmed.toLowerCase(); } +/** + * The path without leading and trailing slashes, in one pass: a pattern anchored at the end + * backtracks over every run of slashes inside a path a caller supplies. + */ +function trimSlashes(path: string): string { + let start = 0; + let end = path.length; + while (start < end && path[start] === '/') start++; + while (end > start && path[end - 1] === '/') end--; + return path.slice(start, end); +} + /** A URL-encoded path decoded; a malformed escape is kept as written. */ function decodePath(path: string): string { if (!path.includes('%')) return path; diff --git a/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts b/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts index 4209b4d0..55c7b731 100644 --- a/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts +++ b/packages/gitlab-mcp/tests/unit/profiles/scope-enforcer.test.ts @@ -558,6 +558,16 @@ describe('enforceArgsScope', () => { ]); }); + // Slashes are trimmed at both ends only, also on a long run of them inside a path. + it.each([ + ['///team/app///', true], + ['team//app', false], + [`team${'/'.repeat(5000)}x`, false], + ['////', false], + ])('trims the slashes of %s at its ends only', (path, allowed) => { + expect(new ScopeEnforcer({ project: 'team/app' }).isAllowed(path)).toBe(allowed); + }); + it('should allow a destination inside the scope', () => { const enforcer = new ScopeEnforcer({ group: 'team', includeSubgroups: true }); From 0c40ab77dc8b9ce7cebde38c2752c01eb8add2e2 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 11 Oct 2026 15:38:28 +0300 Subject: [PATCH 11/11] fix(settings): filter wrapped search answers, keep listing defaults - The search tool answers with its rows under `results`; the filter of a group search without subgroups expected a bare list and passed the answer through, so subgroup results stayed visible. The rows are now unwrapped, kept to the group and its direct projects, and returned in the search's own answer shape. The project search of a scope made of projects answers in that shape too. - A project listing narrowed to the scope's group keeps the active projects default of a plain listing, unless the caller chose active or archived projects. - The owned runners of a scope are marked partial when the target has more runners than are read. Regression tests: the group search filtered through the real search tool, search answer shapes, the active default and its overrides, and the truncated runner intersection. --- .../src/configuration/scope-targets.ts | 3 + .../src/configuration/scoped-execution.ts | 60 ++++++++++++--- .../src/configuration/scoped-feeds.ts | 22 ++++-- .../unit/configuration/scope-targets.test.ts | 23 +++++- .../configuration/scoped-execution.test.ts | 77 +++++++++++++++---- .../unit/configuration/scoped-feeds.test.ts | 25 ++++++ .../gitlab-mcp/tests/unit/handlers.test.ts | 2 + 7 files changed, 179 insertions(+), 33 deletions(-) diff --git a/packages/gitlab-mcp/src/configuration/scope-targets.ts b/packages/gitlab-mcp/src/configuration/scope-targets.ts index 42cff998..90a967ca 100644 --- a/packages/gitlab-mcp/src/configuration/scope-targets.ts +++ b/packages/gitlab-mcp/src/configuration/scope-targets.ts @@ -158,12 +158,15 @@ const NARROWERS: Record args.action === 'list' && !hasValue(args.group_id) && 'group' in target ? { ...args, group_id: target.group, include_subgroups: scope.includeSubgroups !== false && args.include_subgroups !== false, + ...(hasValue(args.active) || hasValue(args.archived) ? {} : { active: true }), } : null, browse_merge_requests: (args, target) => diff --git a/packages/gitlab-mcp/src/configuration/scoped-execution.ts b/packages/gitlab-mcp/src/configuration/scoped-execution.ts index fff59aa2..1f29c239 100644 --- a/packages/gitlab-mcp/src/configuration/scoped-execution.ts +++ b/packages/gitlab-mcp/src/configuration/scoped-execution.ts @@ -91,14 +91,7 @@ export async function executeScoped( const group = directGroup(scope); // A group without its subgroups: GitLab's group search covers them, so its rows are kept. if (group && tool === 'browse_search' && requested.action === 'global') { - const { keep, complete } = await directGroupSearchRows(run, group, args.scope, scopeEnforcer); - const page = await fillScopedPage(run, tool, args, keep); - return complete - ? page - : withPartialReason( - page, - `Results are kept to the first ${DIRECT_PROJECTS_READ} direct projects of group '${group}'; results of its other projects are not shown.`, - ); + return directGroupSearch(run, args, group, scopeEnforcer); } if (isFilteredListing(tool, args)) { return fillScopedPage( @@ -115,6 +108,50 @@ export async function executeScoped( return isGroupAuditTrail(requested, args) ? groupAuditTrail(result, args.group_id) : result; } +/** + * A global search under a group scope without subgroups: the group search, its rows kept to + * the group and its direct projects and paged like the search, in the search's answer shape. + */ +async function directGroupSearch( + run: RunTool, + args: Args, + group: string, + enforcer: ScopeEnforcer, +): Promise { + const { keep, complete } = await directGroupSearchRows(run, group, args.scope, enforcer); + // The search tool answers with its rows under `results`. + const rows: RunTool = async (tool, toolArgs) => { + const answer = await run(tool, toolArgs); + const results = (answer as { results?: unknown } | undefined)?.results; + return Array.isArray(results) ? results : answer; + }; + const page = await fillScopedPage(rows, 'browse_search', args, keep); + const marked = complete + ? page + : withPartialReason( + page, + `Results are kept to the first ${DIRECT_PROJECTS_READ} direct projects of group '${group}'; results of its other projects are not shown.`, + ); + return searchAnswer(marked, { group_id: args.group_id, scope: args.scope }); +} + +/** + * A scoped page in the search tool's answer shape (`count` and `results`, beside what the + * search was for), keeping a partial page's reason. Anything else passes as it came. + */ +function searchAnswer(page: unknown, about: Args): unknown { + if (Array.isArray(page)) return { ...about, count: page.length, results: page }; + const partial = page as { items?: unknown; partial?: unknown; message?: unknown } | undefined; + if (partial?.partial !== true || !Array.isArray(partial.items)) return page; + return { + ...about, + count: partial.items.length, + results: partial.items, + partial: true, + message: partial.message, + }; +} + /** * The answer to a call that the scope replaces by other requests, or undefined when the * call runs as itself. The call is validated as the tool would validate it first, so an @@ -145,10 +182,13 @@ function emulate( ) { return listScopeProjects(run, projects, BrowseProjectsSchema.parse(requested)); } - // Project search has no projects scope: the scope's projects matching the search answer it. + // Project search has no projects scope: the scope's projects matching the search answer it, + // in the global search's answer shape. if (tool === 'browse_search' && requested.action === 'global' && requested.scope === 'projects') { const { search, page, per_page } = BrowseSearchSchema.parse(requested); - return listScopeProjects(run, projects, { search, page, per_page }); + return listScopeProjects(run, projects, { search, page, per_page }).then((found) => + searchAnswer(found, { scope: 'projects' }), + ); } return undefined; } diff --git a/packages/gitlab-mcp/src/configuration/scoped-feeds.ts b/packages/gitlab-mcp/src/configuration/scoped-feeds.ts index 5c8d73b3..5f14e271 100644 --- a/packages/gitlab-mcp/src/configuration/scoped-feeds.ts +++ b/packages/gitlab-mcp/src/configuration/scoped-feeds.ts @@ -124,14 +124,26 @@ export async function ownedRunnersInScope( 'group' in target ? { action: 'list_group', group_id: target.group } : { action: 'list_project', project_id: target.project }; - const [owned, ids] = await Promise.all([ + const [owned, usable] = await Promise.all([ run('browse_runners', requested), runnerIds(run, available), ]); - return keepNodes(owned, (node) => ids.has(node.id)); + const kept = keepNodes(owned, (node) => usable.ids.has(node.id)); + if (usable.complete || kept === owned) return kept; + // Past the bound an owned runner the target can use would be dropped: say the answer is + // partial instead of letting it look complete. + return { + ...(kept as Row), + partial: true, + message: `Runners are kept to the first ${MAX_PAGES * PAGE_SIZE} runners the scope's project or group can use; your runners beyond them are not shown.`, + }; } -async function runnerIds(run: RunTool, args: Args): Promise> { +/** Ids of the runners a target can use, and whether all of them were read within the bound. */ +async function runnerIds( + run: RunTool, + args: Args, +): Promise<{ ids: Set; complete: boolean }> { const ids = new Set(); let after: unknown; // Pages are read one after another: each continues from the previous page's cursor. @@ -142,8 +154,8 @@ async function runnerIds(run: RunTool, args: Args): Promise> { ...(after === undefined ? {} : { after }), })) as { nodes?: Row[]; pageInfo?: { hasNextPage?: boolean; endCursor?: unknown } }; for (const node of connection?.nodes ?? []) ids.add(node.id); - if (!connection?.pageInfo?.hasNextPage) break; + if (!connection?.pageInfo?.hasNextPage) return { ids, complete: true }; after = connection.pageInfo.endCursor; } - return ids; + return { ids, complete: false }; } diff --git a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts index 4fdc2267..35e554cc 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts @@ -28,13 +28,28 @@ describe('scopedArgs', () => { ).toEqual(expected); }); + // A plain listing lists active projects; the group listing applies that only when asked, + // so the default is kept when the scope's group is filled in. it.each([ - [group, { action: 'list', group_id: 'team', include_subgroups: true }], - [groupOnly, { action: 'list', group_id: 'team', include_subgroups: false }], - ])('lists the projects of the scope group %j', (scope, expected) => { + [group, { action: 'list', group_id: 'team', include_subgroups: true, active: true }], + [groupOnly, { action: 'list', group_id: 'team', include_subgroups: false, active: true }], + ])('lists the active projects of the scope group %j', (scope, expected) => { expect(scopedArgs('browse_projects', { action: 'list' }, scope)).toEqual(expected); }); + // The caller's own choice of active or archived projects stays as asked. + it.each([[{ active: false }], [{ archived: true }]])( + 'keeps the requested %j filter when listing the scope group', + (filter) => { + expect(scopedArgs('browse_projects', { action: 'list', ...filter }, group)).toEqual({ + action: 'list', + ...filter, + group_id: 'team', + include_subgroups: true, + }); + }, + ); + // The caller's narrower choice stays; a scope without subgroups excludes them whatever // the caller asked, as the results outside it are filtered anyway. it.each([ @@ -44,7 +59,7 @@ describe('scopedArgs', () => { ])('lists the scope group %j with subgroups %s as %s', (scope, requested, expected) => { expect( scopedArgs('browse_projects', { action: 'list', include_subgroups: requested }, scope), - ).toEqual({ action: 'list', group_id: 'team', include_subgroups: expected }); + ).toEqual({ action: 'list', group_id: 'team', include_subgroups: expected, active: true }); }); // Without a namespace GitLab creates the project in the user's own namespace. diff --git a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts index a861e700..ac944166 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts @@ -8,6 +8,13 @@ import { executeScoped } from '../../../src/configuration/scoped-execution'; import { buildPolicy } from '../../../src/configuration/policy'; import { isActionDenied } from '../../../src/config'; import { ScopeEnforcer } from '../../../src/profiles/scope-enforcer'; +import { searchToolRegistry } from '../../../src/entities/search/registry'; +import { gitlab } from '../../../src/utils/gitlab-api'; + +jest.mock('../../../src/utils/gitlab-api', () => ({ + ...jest.requireActual('../../../src/utils/gitlab-api'), + gitlab: { get: jest.fn() }, +})); jest.mock('../../../src/config', () => ({ ...jest.requireActual('../../../src/config'), @@ -19,6 +26,13 @@ const groupPolicy = buildPolicy( { scope: { type: 'group', path: 'team', includeSubgroups: true } }, {}, ); +/** The answer of the search tool for a group search: its rows under `results`. */ +const searchAnswer = (scope: string, results: unknown[]) => ({ + group_id: 'team', + scope, + count: results.length, + results, +}); const groupOnlyPolicy = buildPolicy( undefined, { scope: { type: 'group', path: 'team', includeSubgroups: false } }, @@ -162,10 +176,18 @@ describe('executeScoped', () => { } }); - // Project search has no projects scope: the scope project itself answers, when it matches. + // Project search has no projects scope: the scope project itself answers, when it matches, + // in the shape of a search answer. it.each([ - ['app', [{ id: 7, name: 'App', path_with_namespace: 'team/app' }]], - ['other', []], + [ + 'app', + { + scope: 'projects', + count: 1, + results: [{ id: 7, name: 'App', path_with_namespace: 'team/app' }], + }, + ], + ['other', { scope: 'projects', count: 0, results: [] }], ])('answers a project search for %s with the scope project', async (search, expected) => { const run = jest.fn().mockResolvedValue({ id: 7, @@ -300,12 +322,13 @@ describe('executeScoped', () => { if (tool === 'browse_projects') { return args.page === 1 ? [{ id: 1, path_with_namespace: 'team/app' }] : []; } - return [ + // The search tool's own answer shape. + return searchAnswer('issues', [ { project_id: 1, title: 'direct' }, { project_id: 2, title: 'subgroup' }, { group_id: 10, title: 'group' }, { group_id: 11, title: 'subgroup group' }, - ]; + ]); }); const result = await executeScoped( @@ -323,10 +346,12 @@ describe('executeScoped', () => { page: 1, per_page: 100, }); - expect(result).toEqual([ - { project_id: 1, title: 'direct' }, - { group_id: 10, title: 'group' }, - ]); + expect(result).toEqual( + searchAnswer('issues', [ + { project_id: 1, title: 'direct' }, + { group_id: 10, title: 'group' }, + ]), + ); }); // The group's own projects are read up to a bound; past it, rows of the projects not read @@ -341,7 +366,7 @@ describe('executeScoped', () => { path_with_namespace: `team/p${next}`, })); } - return [{ project_id: 5, title: 'read' }]; + return searchAnswer('issues', [{ project_id: 5, title: 'read' }]); }); const result = await executeScoped( @@ -352,7 +377,7 @@ describe('executeScoped', () => { ); expect(result).toEqual({ - items: [{ project_id: 5, title: 'read' }], + ...searchAnswer('issues', [{ project_id: 5, title: 'read' }]), partial: true, message: "Results are kept to the first 1000 direct projects of group 'team'; results of its other projects are not shown. Narrow the listing (for example with a search) to see the rest.", @@ -364,7 +389,7 @@ describe('executeScoped', () => { [ { action: 'global', scope: 'issues', search: 'x', page: 51, per_page: 20 }, { - items: [], + ...searchAnswer('issues', []), partial: true, message: "Results are kept to the first 1000 direct projects of group 'team'; results of its other projects are not shown. A scoped listing reads the first 1000 rows GitLab returns, and page 51 starts after them. Narrow the listing (for example with a search) to see the rest.", @@ -393,6 +418,30 @@ describe('executeScoped', () => { ).toEqual({ message: 'search failed' }); }); + // Against the search tool itself, not a stand-in: its answer wraps the rows, and those are + // what the group scope keeps. + it('keeps the rows the search tool returns for a group without subgroups', async () => { + const searchTool = searchToolRegistry.get('browse_search'); + jest.mocked(gitlab.get).mockResolvedValue([ + { project_id: 1, title: 'direct' }, + { project_id: 2, title: 'subgroup' }, + ]); + const run = jest.fn(async (tool: string, args: Record) => { + if (tool === 'browse_namespaces') return { id: 10 }; + if (tool === 'browse_projects') return [{ id: 1, path_with_namespace: 'team/app' }]; + return searchTool?.handler(args); + }); + + const result = await executeScoped( + run, + 'browse_search', + { action: 'global', scope: 'issues', search: 'x' }, + groupOnlyPolicy, + ); + + expect(result).toEqual(searchAnswer('issues', [{ project_id: 1, title: 'direct' }])); + }); + it.each([ [ 'projects', @@ -404,7 +453,7 @@ describe('executeScoped', () => { ])( 'keeps %s of a group search without subgroups by their own place', async (scope, rows, expected) => { - const run = jest.fn().mockResolvedValue(rows); + const run = jest.fn().mockResolvedValue(searchAnswer(scope, rows)); const result = await executeScoped( run, @@ -413,7 +462,7 @@ describe('executeScoped', () => { groupOnlyPolicy, ); - expect(result).toEqual(expected); + expect(result).toEqual(searchAnswer(scope, expected)); expect(run).toHaveBeenCalledTimes(1); }, ); diff --git a/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts index 137e2a4c..2d441743 100644 --- a/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts @@ -103,6 +103,31 @@ describe('ownedRunnersInScope', () => { expect(result).toEqual({ nodes: [{ id: 1 }, { id: 3 }], pageInfo: { hasNextPage: false } }); }); + // The scope's runners are read up to a bound; an owned runner past it would be dropped, so + // the answer says it is partial instead of looking complete. + it('marks the owned runners partial when the scope has more runners than are read', async () => { + let next = 0; + const run = jest.fn(async (_tool: string, args: Record) => { + if (args.action === 'list_owned') return { nodes: [{ id: 0 }], pageInfo: {} }; + const nodes = Array.from({ length: 100 }, () => ({ id: next++ })); + return { nodes, pageInfo: { hasNextPage: true, endCursor: String(next) } }; + }); + + const result = await ownedRunnersInScope( + run, + { action: 'list_owned' }, + { project: 'team/app' }, + ); + + expect(result).toEqual({ + nodes: [{ id: 0 }], + pageInfo: {}, + partial: true, + message: + "Runners are kept to the first 1000 runners the scope's project or group can use; your runners beyond them are not shown.", + }); + }); + it('keeps no owned runner when the scope runners come back without nodes', async () => { const run = jest.fn(async (_tool: string, args: Record) => args.action === 'list_owned' ? { nodes: [{ id: 1 }] } : undefined, diff --git a/packages/gitlab-mcp/tests/unit/handlers.test.ts b/packages/gitlab-mcp/tests/unit/handlers.test.ts index 0a1d0586..fe6d4eeb 100644 --- a/packages/gitlab-mcp/tests/unit/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/handlers.test.ts @@ -3134,10 +3134,12 @@ describe('handlers', () => { ); // Read in GitLab's largest pages: the scope filters the rows after GitLab paged them. + // Active projects only, as a plain listing lists them. expect(mockRegistryManager.executeTool.mock.calls[0][1]).toEqual({ action: 'list', group_id: 'team', include_subgroups: true, + active: true, page: 1, per_page: 100, });