Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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")
);
11 changes: 11 additions & 0 deletions packages/gitlab-mcp-db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
58 changes: 58 additions & 0 deletions packages/gitlab-mcp-db/src/postgresql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import type {
GitLabTokenResponse,
SessionStorageBackend,
SessionStorageStats,
AccountSettings,
AccountSettingsRecord,
} from '@structured-world/gitlab-mcp/storage-contract';
import { logInfo, logError, logDebug } from './logger';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -216,6 +225,11 @@ interface GenericPrismaClient {
deleteMany(args: unknown): Promise<unknown>;
count(): Promise<number>;
};
accountSettings: {
create(args: unknown): Promise<unknown>;
findUnique(args: unknown): Promise<unknown>;
updateMany(args: unknown): Promise<unknown>;
};
}

export interface PostgreSQLStorageOptions {
Expand Down Expand Up @@ -782,6 +796,50 @@ export class PostgreSQLStorageBackend implements SessionStorageBackend {
};
}

// Account settings
async getAccountSettings(accountKey: string): Promise<AccountSettingsRecord | undefined> {
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<AccountSettingsRecord | undefined> {
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<void> {
const prisma = this.getPrisma();
Expand Down
87 changes: 87 additions & 0 deletions packages/gitlab-mcp-db/tests/postgresql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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();
});
});
});
1 change: 1 addition & 0 deletions packages/gitlab-mcp/docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
},
Expand Down
5 changes: 4 additions & 1 deletion packages/gitlab-mcp/docs/advanced/context-switching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
163 changes: 163 additions & 0 deletions packages/gitlab-mcp/docs/configuration/settings.md
Original file line number Diff line number Diff line change
@@ -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_<group>` | 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
Loading
Loading