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..2d44ccaf --- /dev/null +++ b/packages/gitlab-mcp/docs/configuration/settings.md @@ -0,0 +1,163 @@ +--- +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. 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 +before anything reaches GitLab. A scope of projects reaches no group, so group operations +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 +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 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 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 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). 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 +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. + +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 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. + +## 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..ad530fd1 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,78 @@ 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; + const complete = !request.params?.cursor && !catalog.nextCursor; + this.recordCatalogReads(catalog, generation, complete); + 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, + complete: boolean, + ): 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 }); + } + // 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. */ + 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. */ @@ -147,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; @@ -199,7 +279,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 +298,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/cli/list-tools.ts b/packages/gitlab-mcp/src/cli/list-tools.ts index 37275463..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 - if (preset.denied_tools_regex) { - 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; } /** @@ -1105,18 +1086,11 @@ function getToolsForPreset( enabledTools = enabledTools.filter((name) => !name.startsWith('manage_')); } - // Apply denied_tools_regex - if (preset.denied_tools_regex) { - 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'}`, - ); - } + // Apply denied_tools_regex; a whitelist overrides it + 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/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..8957d921 --- /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, localSettingsDir, 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 + * local settings directory, 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 local settings directory meanwhile.', + { backend: sessionStore.getBackendType() }, + ); + } + return new LocalSettingsFile(localSettingsDir()); + })().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..235f295c --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/policy.ts @@ -0,0 +1,186 @@ +/** + * 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 { scopeCheckArgs, 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', +]); + +/** + * 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. + */ +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 ( + 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 } + : { 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 (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`; + } + // 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`; + } + 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 (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`; + } + // 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, scopeCheckArgs(tool.name, 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-ids.ts b/packages/gitlab-mcp/src/configuration/scope-ids.ts new file mode 100644 index 00000000..6e7f6792 --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/scope-ids.ts @@ -0,0 +1,68 @@ +/** + * 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'; +import { parseGitLabApiError } from '../utils/error-handler'; + +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 know 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'); +} + +/** + * 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 { + 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/src/configuration/scope-targets.ts b/packages/gitlab-mcp/src/configuration/scope-targets.ts new file mode 100644 index 00000000..90a967ca --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/scope-targets.ts @@ -0,0 +1,284 @@ +/** + * 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. */ +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 }; + 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: a creation that names no + * 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); + if (creation) return creation; + if (tool === 'browse_vulnerabilities' && isTargetlessList(args)) { + 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; +} + +/** 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 single project or group reads its own trail; the trails take no entity filter. + browse_audit_events: (args, target) => + args.action === 'list_instance' && + (!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 group cannot follow several targets. */ +function unnarrowable(scope: ScopeConfig, what: string, instead: string): string | null { + return singleTarget(scope) + ? null + : `${what} for a working scope of several projects or groups; ${instead} one of them`; +} + +/** 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; + // 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_id: namespace }; +} + +/** + * 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 (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 put it in with 'namespace'"; + } + 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; +} + +/** + * 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; +} + +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; 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 }; + }, + // The caller's own exclusion of subgroups stays; a scope without them excludes them anyway. + // A plain listing lists active projects, a group listing only when asked: that default is + // kept unless the caller chose active or archived projects. + 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 && args.include_subgroups !== false, + ...(hasValue(args.active) || hasValue(args.archived) ? {} : { active: true }), + } + : null, + browse_merge_requests: (args, target) => + args.action === 'list' && !hasValue(args.project_id) && 'project' in target + ? { ...args, project_id: target.project } + : null, + // The caller's own activity spans every project; a project scope shows that project's. + browse_events: (args, target) => + args.action === 'user' && 'project' in target + ? { ...args, action: 'project', project_id: target.project } + : null, + browse_deploy_keys: (args, target) => + 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. The caller's + // owned runners keep their own feed and are filtered to the scope instead. + browse_runners: (args, target) => { + 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 }; + }, + // 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 + ? { ...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. */ +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; +} + +/** 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. + * 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; + if (isProjectRowListing(tool, args)) { + return result.filter((item: Record) => { + const path = projectPathOf(tool, item); + return path !== undefined && enforcer.isAllowed(path); + }); + } + if (tool === 'browse_todos' && isTargetlessList(args)) { + 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/scoped-execution.ts b/packages/gitlab-mcp/src/configuration/scoped-execution.ts new file mode 100644 index 00000000..1f29c239 --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/scoped-execution.ts @@ -0,0 +1,371 @@ +/** + * 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 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, + singleTarget, +} from './scope-targets'; +import { + DIRECT_PROJECTS_READ, + directGroup, + directGroupSearchRows, + isScopeVulnerability, + keepNodes, + ownedRunnersInScope, +} from './scoped-feeds'; + +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']); +/** 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', +]); +/** 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; + +/** 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); + // 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); + 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') { + return directGroupSearch(run, args, group, scopeEnforcer); + } + 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; +} + +/** + * 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 + * 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') { + 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 ( + tool === 'browse_projects' && + requested.action === 'list' && + Object.keys(requested).every((key) => PROJECT_LIST_OPTIONS.has(key)) + ) { + return listScopeProjects(run, projects, BrowseProjectsSchema.parse(requested)); + } + // 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 }).then((found) => + searchAnswer(found, { scope: 'projects' }), + ); + } + 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'.`, + }; +} + +/** + * 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; 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, + tool: string, + args: Args, + keep: (rows: unknown[]) => unknown[], +): Promise { + 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. + 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(...keep(result)); + exhausted = result.length < UPSTREAM_PAGE_SIZE; + if (exhausted) break; + } + 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: `${reason} Narrow the listing (for example with a search) to see the rest.`, + }; +} + +/** 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 + * out; any other failure fails the call rather than returning a shorter list. + */ +async function listScopeProjects( + run: RunTool, + projects: string[], + requested: Args, +): Promise { + const search = typeof requested.search === 'string' ? requested.search.toLowerCase() : ''; + const matches = (project: unknown): boolean => { + 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); + 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); +} + +function simpleProject(project: unknown): unknown { + return Object.fromEntries( + Object.entries(project as Record).filter(([key]) => + SIMPLE_PROJECT_FIELDS.has(key), + ), + ); +} + +/** + * 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(REQUEST_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/scoped-feeds.ts b/packages/gitlab-mcp/src/configuration/scoped-feeds.ts new file mode 100644 index 00000000..5f14e271 --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/scoped-feeds.ts @@ -0,0 +1,161 @@ +/** + * 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<{ keep: (rows: unknown[]) => unknown[]; complete: boolean }> { + if (searchScope === 'users') return { keep: (rows) => rows, complete: true }; + if (searchScope === 'projects') { + 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, projects] = await Promise.all([ + run('browse_namespaces', { action: 'get', namespace_id: group }), + directProjectIds(run, group, enforcer), + ]); + const groupId = (namespace as Row | undefined)?.id; + const keep = (rows: unknown[]): unknown[] => + rows.filter((row) => { + const { project_id, group_id } = row as Row; + 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, 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<{ 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++) { + 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) return { ids, complete: true }; + } + // 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 }; +} + +/** + * 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, usable] = await Promise.all([ + run('browse_runners', requested), + runnerIds(run, available), + ]); + 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.`, + }; +} + +/** 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. + 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) return { ids, complete: true }; + after = connection.pageInfo.endCursor; + } + return { ids, complete: false }; +} diff --git a/packages/gitlab-mcp/src/configuration/service.ts b/packages/gitlab-mcp/src/configuration/service.ts new file mode 100644 index 00000000..31b1f199 --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/service.ts @@ -0,0 +1,279 @@ +/** + * 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) next.scope = scopeAtWrite(scope, saved.scope); + if (toolGroups) { + const disabled = disabledGroupsAtWrite(next.disabledToolGroups, toolGroups); + if (disabled.length > 0) next.disabledToolGroups = disabled; + else delete next.disabledToolGroups; + } + if (scopeIncludeSubgroups !== undefined && next.scope?.type === 'group') { + next.scope = { ...next.scope, includeSubgroups: scopeIncludeSubgroups }; + } + 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)) { + 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..70e70832 --- /dev/null +++ b/packages/gitlab-mcp/src/configuration/settings-store.ts @@ -0,0 +1,213 @@ +/** + * Where account settings are kept: in the session storage when it can keep them, otherwise + * 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'; +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; +} + +/** 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 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 dir: string) {} + + async get(accountKey: string): Promise { + return (await this.latest()).file.accounts[accountKey]; + } + + async put( + accountKey: string, + settings: AccountSettings, + expectedVersion: number, + ): Promise { + 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 = { + accountKey, + settings, + version: current + 1, + updatedAt: Date.now(), + }; + file.accounts[accountKey] = 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 newestGeneration(): Promise { + let names: string[]; + try { + names = await fs.promises.readdir(this.dir); + } catch (error: unknown) { + 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: ${filePath}`); + } + const accounts: Record = {}; + 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: filePath }); + } + } + return { version: 1, accounts }; + } + + /** + * 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'); + await handle.sync(); + } finally { + await handle.close(); + } + try { + 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 { + // 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 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 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 localSettingsDir(): string { + return path.join(os.homedir(), '.config', 'gitlab-mcp', 'settings'); +} 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..3f623d24 --- /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 { scopeLabel, 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: scopeLabel(scope) ?? 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..d2c70ec0 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,23 @@ 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); + // 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, @@ -492,7 +326,6 @@ export class ContextManager { label: instance.config.label, }); - // Notify clients that tool list may have changed await sendToolsListChangedNotification(); return { @@ -511,7 +344,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/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/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') { @@ -308,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)); } @@ -423,15 +468,90 @@ 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. 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, + 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); + return executeScoped(run, toolName, requested, policy); +} + +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 +690,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 +763,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 +860,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 +932,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 +983,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 = await callSettingsRejection( + 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 +1038,10 @@ 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()); + recordCallContext( + scopeLabel(configuration.policy.scope), + GITLAB_READ_ONLY_MODE || configuration.policy.readOnly, + ); } try { @@ -897,17 +1076,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 +1115,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..110ee352 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,31 @@ 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. 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 () => { + 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..1da99f90 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 = trimSlashes(decodePath(path.trim())); // If it's a numeric ID, return as-is if (/^\d+$/.test(trimmed)) { return trimmed; @@ -80,6 +81,28 @@ 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; + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + /** * Check if a path matches a namespace (used for both projects and groups) * @@ -216,9 +239,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 +353,50 @@ 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; +} + +/** + * 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', +]); + +/** + * 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 * @@ -342,24 +410,10 @@ export class ScopeEnforcer { */ export function extractProjectsFromArgs(args: Record): string[] { const projects: string[] = []; - - // Common parameter names for project identification - const projectFields = [ - 'project_id', - 'projectId', - 'project', - 'namespace', - 'namespacePath', - 'fullPath', - ]; - - for (const field of projectFields) { - const value = args[field]; - if (typeof value === 'string' && value.trim()) { - projects.push(value.trim()); - } + for (const field of PROJECT_FIELDS) { + const id = identifierOf(args[field]); + if (id) projects.push(id); } - return projects; } @@ -376,18 +430,33 @@ export function extractProjectsFromArgs(args: Record): string[] */ export function extractGroupsFromArgs(args: Record): string[] { const groups: string[] = []; + for (const field of GROUP_FIELDS) { + const id = identifierOf(args[field]); + if (id) groups.push(id); + } + return groups; +} - // Common parameter names for group identification - const groupFields = ['group_id', 'groupId', 'group']; +/** What a numeric id argument may name. */ +export type NumericTargetKind = 'project' | 'group' | 'either'; - for (const field of groupFields) { - const value = args[field]; - if (typeof value === 'string' && value.trim()) { - groups.push(value.trim()); - } +/** + * 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'); } - - return groups; + for (const field of GROUP_FIELDS) add(field, 'group'); + return found; } /** @@ -400,10 +469,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/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/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..5c093727 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) @@ -135,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. @@ -163,6 +185,7 @@ export class SessionManager { if (!session) return; this.sessions.delete(sessionId); + getConfigurationService().forgetSession(sessionId); try { await session.server.close(); @@ -215,6 +238,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..196c3715 --- /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 normalizeInstanceUrl(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..235ba0d5 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,52 +38,62 @@ 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; } /** - * Verify if a namespace exists as the specified type by making a lightweight API call + * 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') + ); +} + +/** + * 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 = `${process.env.GITLAB_API_URL}/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/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..207e76f2 --- /dev/null +++ b/packages/gitlab-mcp/tests/setup/isolatedSettings.ts @@ -0,0 +1,20 @@ +/** + * 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. + */ + +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'), + localSettingsDir: () => `${mockIsolatedSettingsDir}/settings`, +})); + +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..763d7721 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,179 @@ 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(); + } + }); + + // 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 = () => {}; + 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 = { @@ -281,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/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/index.test.ts b/packages/gitlab-mcp/tests/unit/configuration/index.test.ts new file mode 100644 index 00000000..6570798c --- /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 mockSettingsDir = ''; +jest.mock('../../../src/configuration/settings-store', () => ({ + ...jest.requireActual('../../../src/configuration/settings-store'), + localSettingsDir: () => mockSettingsDir, +})); + +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-')); + mockSettingsDir = path.join(dir, 'settings'); + }); + + 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(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']); + }); + + 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(mockSettingsDir)).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(path.join(mockSettingsDir, '1.json'), '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..15a5067c --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/policy.test.ts @@ -0,0 +1,303 @@ +/** + * 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(); + }); + + // 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'])( + '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' }); + }); +}); + +// 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( + { 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(); + }); + + // 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/], + // 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, + { 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( + 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..7f533d80 --- /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'), + localSettingsDir: () => path.join(dir, 'local-settings'), + })); + 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-ids.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scope-ids.test.ts new file mode 100644 index 00000000..c5e19f23 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-ids.test.ts @@ -0,0 +1,100 @@ +/** + * 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' }); + }); + + // 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); + + 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 new file mode 100644 index 00000000..35e554cc --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/scope-targets.test.ts @@ -0,0 +1,354 @@ +/** + * 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 { + isFilteredListing, + scopedArgs, + scopedResult, + scopeProjectsOnly, + 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); + }); + + // 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, 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([ + [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, active: true }); + }); + + // 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', + }); + }); + + // 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); + }); + + // 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', + }); + }); + + // 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_all' }, + 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' }, + ], + [ + '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); + }); + + 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], + ['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], + ['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], + // 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], + ])('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', () => { + 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_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/); + }); + + // 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/, + ); + }); + + // 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' }, several, /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_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], + ])('allows the listing %s %j under %j', (tool, args, scope) => { + 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/, + ); + }); + + 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], + ['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(); + }); +}); + +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/scoped-execution.test.ts b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts new file mode 100644 index 00000000..ac944166 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-execution.test.ts @@ -0,0 +1,505 @@ +/** + * 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'; +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'), + isActionDenied: jest.fn(() => false), +})); + +const groupPolicy = buildPolicy( + undefined, + { 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 } }, + {}, +); +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' }] }); + }); + + // 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.', + }); + }); + + // 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.', + }); + }); + + // 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, + // in the shape of a search answer. + it.each([ + [ + '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, + 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); + }); + + // 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' }); + }); + + // 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' }] : []; + } + // 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( + 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( + 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 + // 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 searchAnswer('issues', [{ project_id: 5, title: 'read' }]); + }); + + const result = await executeScoped( + run, + 'browse_search', + { action: 'global', scope: 'issues', search: 'x' }, + groupOnlyPolicy, + ); + + expect(result).toEqual({ + ...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.", + }); + }); + + // 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 }, + { + ...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.", + }, + ], + ])('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' }); + }); + + // 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', + [{ 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(searchAnswer(scope, rows)); + + const result = await executeScoped( + run, + 'browse_search', + { action: 'global', scope, search: 'x' }, + groupOnlyPolicy, + ); + + expect(result).toEqual(searchAnswer(scope, 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'); + + await expect( + executeScoped(run, 'browse_projects', { action: 'list' }, projectPolicy), + ).rejects.toBe('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..2d441743 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/scoped-feeds.test.ts @@ -0,0 +1,142 @@ +/** + * 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, complete } = await directGroupSearchRows(run, 'team', 'issues', groupOnly); + + expect(complete).toBe(true); + 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 }]); + }); + + // 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, complete } = await directGroupSearchRows(run, 'team', 'blobs', groupOnly); + + expect(complete).toBe(false); + 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 } }); + }); + + // 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, + ); + + expect( + await ownedRunnersInScope(run, { action: 'list_owned' }, { project: 'team/app' }), + ).toEqual({ + nodes: [], + }); + }); +}); 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..165b732b --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/configuration/settings-store.test.ts @@ -0,0 +1,350 @@ +/** + * 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, localSettingsDir } from '../../../src/configuration/settings-store'; + +// 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. +it('keeps a server settings in the user configuration directory', () => { + const actual = jest.requireActual( + '../../../src/configuration/settings-store', + ); + + expect(actual.localSettingsDir()).toBe( + path.join(os.homedir(), '.config', 'gitlab-mcp', 'settings'), + ); +}); + +describe('LocalSettingsFile', () => { + let root: string; + let dir: 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(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'settings-store-test-')); + dir = path.join(root, 'nested', 'settings'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('has no settings before the first write', async () => { + 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(dir); + + 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 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(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(dir); + 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 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 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 }); + 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(dir).put('acct', { preset: 'a' }, 0), + new LocalSettingsFile(dir).put('acct', { preset: 'b' }, 0), + new LocalSettingsFile(dir).put('acct', { preset: 'c' }, 0), + ]); + + 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(dir); + await store.put('one', { readOnly: true }, 0); + await store.put('two', { preset: 'pm' }, 0); + + expect((await store.get('one'))?.settings).toEqual({ readOnly: true }); + }); + + // 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, 'readFile') + .mockImplementationOnce(async (...args: Parameters) => { + const content = await realReadFile(...args); + 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' }); + }); + + 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, '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 }); + }); + + // 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(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 () => { + writeGeneration( + 1, + 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(dir); + + expect(await store.get('bad')).toBeUndefined(); + 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) => { + writeGeneration( + 1, + JSON.stringify({ + version: 1, + accounts: { + ...broken, + good: { accountKey: 'good', settings: { readOnly: true }, version: 1, updatedAt: 1 }, + }, + }), + ); + 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 () => { + writeGeneration(1, JSON.stringify({ version: 1, accounts: [{ settings: {} }] })); + + expect(await new LocalSettingsFile(dir).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 generation whose content %s is not an object', + async (content) => { + writeGeneration(1, content); + + await expect(new LocalSettingsFile(dir).get('acct')).rejects.toThrow( + `Settings file is not a JSON object: ${path.join(dir, '1.json')}`, + ); + }, + ); + + it('reports a generation that is not JSON instead of treating it as empty', async () => { + writeGeneration(1, '{ not json'); + + await expect(new LocalSettingsFile(dir).get('acct')).rejects.toThrow(SyntaxError); + }); + + // 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 }); + + await expect(new LocalSettingsFile(dir).get('acct')).rejects.toMatchObject({ + code: 'EISDIR', + }); + }); + + it('reports a settings location that is not a directory', async () => { + fs.mkdirSync(path.dirname(dir), { recursive: true }); + fs.writeFileSync(dir, '{}'); + + await expect(new LocalSettingsFile(dir).get('acct')).rejects.toMatchObject({ + code: 'ENOTDIR', + }); + }); +}); 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..0746832a --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/entities/context/connection-check.test.ts @@ -0,0 +1,217 @@ +/** + * 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 }; + 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}`); + }, + }, + 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 }); + }); + + // 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({ + 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..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 @@ -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,70 @@ 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 mockPinAllSessions = jest.fn(); +jest.mock('../../../../src/session-manager', () => ({ + getSessionManager: () => ({ pinAllSessions: mockPinAllSessions }), +})); + 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 +102,83 @@ 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 { 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'; +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 +186,419 @@ 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(); + it('reports the operator configuration for a caller without settings', async () => { + const context = await runWithCaller(alice1, () => getContextManager().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); + 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'); - - expect(result.success).toBe(true); - expect(result.current).toBe('readonly'); - expect(result.message).toContain('Switched to preset'); - }); + it('switches the current session and reports the previous preset', async () => { + const manager = getContextManager(); - it('should return previous preset name on switch', async () => { - const manager = ContextManager.getInstance(); + await runWithCaller(alice1, () => manager.switchPreset('developer')); + const result = await runWithCaller(alice1, () => manager.switchPreset('readonly')); - 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 = getContextManager(); - const manager = ContextManager.getInstance(); - const result = await manager.switchProfile('production'); + const result = await runWithCaller(alice1, () => manager.switchProfile('production')); - 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 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); + 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(); + }, + ); - 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(); - expect(manager.hasScope()).toBe(true); + 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(); }); - it('should update context with scope after setScope', async () => { - mockDetectNamespaceType.mockResolvedValue('group'); + // 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 manager = ContextManager.getInstance(); - await manager.setScope('my-group'); + const failure = await runWithCaller(alice1, () => manager.setScope('team/app')).catch( + (error: unknown) => error, + ); - const context = manager.getContext(); - expect(context.scope).toBeDefined(); - expect(context.scope?.type).toBe('group'); - expect(context.scope?.path).toBe('my-group'); + expect(isGitLabAuthFailure(failure)).toBe(true); + 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'); + expect(result.context).toMatchObject({ presetName: 'developer', scope: undefined }); }); }); - 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(); - }); - }); - - 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('refuses an instance that is not configured', async () => { + await expect( + getContextManager().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 + it('switches, notifies every session and clears the session scope', async () => { 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('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 - 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(); + // 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(); }); - }); - - 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..05bf6e2c 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', () => ({ @@ -149,6 +150,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/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'); 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..fe6d4eeb 100644 --- a/packages/gitlab-mcp/tests/unit/handlers.test.ts +++ b/packages/gitlab-mcp/tests/unit/handlers.test.ts @@ -4,10 +4,18 @@ */ 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 * 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'; +import { resolveCaller } from '../../src/configuration/caller'; // Mock ConnectionManager const mockConnectionManager = { @@ -49,8 +57,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), @@ -63,6 +96,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 @@ -74,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(), }; @@ -116,10 +152,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 +192,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 +216,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 +276,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 +287,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 +333,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 +2282,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', @@ -2441,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', () => { @@ -2519,13 +2606,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 +2863,527 @@ 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(); + }); + + // 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 }); + + 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' }, + ); + + // 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, + }); + 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([]); + }); + + // 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 () => { + 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/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 86953081..55c7b731 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'; @@ -33,6 +34,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' }); @@ -435,11 +447,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 +498,93 @@ 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); + }); + + // 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(); + }); + + // 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' }, + ]); + }); + + // 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 }); + + 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 +649,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..8533c228 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(); @@ -273,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); 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..5279706c --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/utils/gitlab-base-url.test.ts @@ -0,0 +1,77 @@ +/** + * 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'); + }); + + // 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'); + + 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..dc8ea0bb 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,76 @@ 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(); + }); + + // 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', () => { 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"