Skip to content
This repository was archived by the owner on May 25, 2026. It is now read-only.
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ Currently applied to:
- `list_pipelines`, `get_pipeline`
- `list_releases`
- `list_commits`, `get_commit`
- `get_current_user`, `get_user`, `get_users`, `search_users`

A spike measurement against `list_projects` with 5 owned projects went from **~32 KB → ~3 KB** by switching to the compact default. Because it's allow-list based, the compact output stays compact when GitLab adds new fields upstream.

Expand Down
32 changes: 32 additions & 0 deletions src/schemas/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,35 @@ export const GitLabUserSchema = z
.passthrough();

export type GitLabUser = z.infer<typeof GitLabUserSchema>;

export const GitLabUserListSchema = z.array(GitLabUserSchema);

/**
* Slim shape (Phase 3b / DOT-560): identity-only fields safe to expose for
* any GitLab user. Deliberately excludes every privacy-sensitive field
* GitLab returns for the current user (email, last_sign_in_at, is_admin,
* two_factor_enabled, confirmed_at, current_sign_in_at, private_profile,
* last_activity_on, theme_id, color_scheme_id, projects_limit, external)
* and the bulky public-profile fields (bio, location, organization,
* job_title, work_information, pronouns, followers, following). Callers
* opt back in via `fields: "all"` or a custom `fields: [...]` allow-list.
*
* `bot` is included because the LLM often needs to know whether to treat
* the user as a service account vs. a person. Always safe to expose.
*/
export const UserSlimShape = {
id: true,
username: true,
name: true,
state: true,
avatar_url: true,
web_url: true,
bot: true,
} as const;

export const GitLabUserSlimSchema = GitLabUserSchema.pick(UserSlimShape);
export type GitLabUserSlim = z.infer<typeof GitLabUserSlimSchema>;

export const USER_SLIM_FIELDS = Object.keys(UserSlimShape) as ReadonlyArray<
keyof typeof UserSlimShape
>;
73 changes: 59 additions & 14 deletions src/tools/users.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import type { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { parseGitLabResponse } from "../schemas/parse.js";
import { GitLabUserSchema } from "../schemas/users.js";
import { GitLabUserListSchema, GitLabUserSchema, USER_SLIM_FIELDS } from "../schemas/users.js";
import { buildQueryString, defaultClient, resolveProjectId } from "../utils/gitlab-client.js";
import type { Logger } from "../utils/logger.js";
import { projectField, projectFields } from "../utils/projection.js";
import { fieldsParam } from "../utils/schema-helpers.js";

const GetUsersSchema = z.object({
usernames: z.array(z.string()).describe("List of usernames to look up"),
fields: fieldsParam("user").optional(),
});

const GetUserSchema = z.object({
user_id: z.number().describe("User ID"),
fields: fieldsParam("user").optional(),
});

const SearchUsersSchema = z.object({
search: z.string().describe("Search query"),
page: z.number().optional().describe("Page number"),
per_page: z.number().optional().describe("Results per page"),
fields: fieldsParam("user").optional(),
});

const GetCurrentUserSchema = z.object({
fields: fieldsParam("user").optional(),
});

const ListEventsSchema = z.object({
Expand Down Expand Up @@ -101,9 +110,11 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
"get_users",
{
title: "Get Users",
description: "Get GitLab user details by usernames",
description:
"Get GitLab user details by usernames. Returns a compact set of fields per user by default; pass `fields: 'all'` for the raw GitLab response or `fields: ['id', 'username', ...]` to pick your own.",
inputSchema: {
usernames: z.array(z.string()).describe("List of usernames to look up"),
fields: fieldsParam("user").optional(),
},
annotations: {
readOnlyHint: true,
Expand All @@ -116,11 +127,20 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string

for (const username of args.usernames) {
const query = buildQueryString({ username });
const users = await defaultClient.get<unknown[]>(`/users${query}`);
const raw = await defaultClient.get(`/users${query}`);
const users = parseGitLabResponse(GitLabUserListSchema, raw, "get_users", logger);
// Always assign — null when the username didn't resolve. Omitting
// missing keys would collapse signal: callers couldn't tell whether
// a key wasn't asked for or actually didn't exist on GitLab.
results[username] = users.length > 0 ? users[0] : null;
if (users.length === 0) {
results[username] = null;
continue;
}
results[username] = projectField(
users[0] as unknown as Record<string, unknown>,
USER_SLIM_FIELDS,
args.fields,
);
}

return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
Expand All @@ -133,9 +153,11 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
"get_user",
{
title: "Get User",
description: "Get details of a specific user by ID",
description:
"Get details of a specific user by ID. Returns a compact set of fields by default; pass `fields: 'all'` for the raw GitLab response or `fields: ['id', 'username', ...]` to pick your own.",
inputSchema: {
user_id: z.number().describe("User ID"),
fields: fieldsParam("user").optional(),
},
annotations: {
readOnlyHint: true,
Expand All @@ -146,7 +168,12 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
const args = GetUserSchema.parse(params);
const raw = await defaultClient.get(`/users/${args.user_id}`);
const user = parseGitLabResponse(GitLabUserSchema, raw, "get_user", logger);
return { content: [{ type: "text", text: JSON.stringify(user, null, 2) }] };
const projected = projectField(
user as unknown as Record<string, unknown>,
USER_SLIM_FIELDS,
args.fields,
);
return { content: [{ type: "text", text: JSON.stringify(projected, null, 2) }] };
},
);
toolRef2.disable();
Expand All @@ -156,11 +183,13 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
"search_users",
{
title: "Search Users",
description: "Search for GitLab users",
description:
"Search for GitLab users. Returns a compact set of fields per user by default; pass `fields: 'all'` for the raw GitLab response or `fields: ['id', 'username', ...]` to pick your own.",
inputSchema: {
search: z.string().describe("Search query"),
page: z.number().optional().describe("Page number"),
per_page: z.number().optional().describe("Results per page"),
fields: fieldsParam("user").optional(),
},
annotations: {
readOnlyHint: true,
Expand All @@ -169,10 +198,18 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
},
async (params) => {
const args = SearchUsersSchema.parse(params);
const query = buildQueryString(args);
const { fields, ...queryParams } = args;
const query = buildQueryString(queryParams);

const users = await defaultClient.get(`/users${query}`);
return { content: [{ type: "text", text: JSON.stringify(users, null, 2) }] };
const raw = await defaultClient.get(`/users${query}`);
const users = parseGitLabResponse(
GitLabUserListSchema,
raw,
"search_users",
logger,
) as unknown as Record<string, unknown>[];
const projected = projectFields(users, USER_SLIM_FIELDS, fields);
return { content: [{ type: "text", text: JSON.stringify(projected, null, 2) }] };
},
);
toolRef3.disable();
Expand Down Expand Up @@ -366,17 +403,25 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
{
title: "Get Current User",
description:
"Get details of the authenticated user (whoami). Returns the user identified by the configured PAT / OAuth token.",
inputSchema: {},
"Get details of the authenticated user (whoami). Returns the user identified by the configured PAT / OAuth token. Returns a compact set of identity fields by default; pass `fields: 'all'` for the raw GitLab response (including email, last_sign_in_at, is_admin, etc.) or `fields: ['id', 'username', 'email']` to pick your own.",
inputSchema: {
fields: fieldsParam("user").optional(),
},
annotations: {
readOnlyHint: true,
openWorldHint: true,
},
},
async () => {
async (params) => {
const args = GetCurrentUserSchema.parse(params);
const raw = await defaultClient.get(`/user`);
const user = parseGitLabResponse(GitLabUserSchema, raw, "get_current_user", logger);
return { content: [{ type: "text", text: JSON.stringify(user, null, 2) }] };
const projected = projectField(
user as unknown as Record<string, unknown>,
USER_SLIM_FIELDS,
args.fields,
);
return { content: [{ type: "text", text: JSON.stringify(projected, null, 2) }] };
},
);
toolRef8.disable();
Expand Down
107 changes: 106 additions & 1 deletion tests/schemas/users.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it } from "bun:test";
import { GitLabUserSchema } from "../../src/schemas/users.js";
import {
GitLabUserListSchema,
GitLabUserSchema,
GitLabUserSlimSchema,
USER_SLIM_FIELDS,
UserSlimShape,
} from "../../src/schemas/users.js";
import { projectField } from "../../src/utils/projection.js";
import getCurrentUserFixture from "../fixtures/users/get_current_user.json";
import getUserFixture from "../fixtures/users/get_user.json";

Expand Down Expand Up @@ -28,3 +35,101 @@ describe("GitLabUserSchema", () => {
expect(result.success).toBe(false);
});
});

describe("GitLabUserSlimSchema (Phase 3b / DOT-560)", () => {
it("parses the get_user fixture and types every slim field", () => {
const result = GitLabUserSlimSchema.safeParse(getUserFixture);
expect(result.success).toBe(true);
if (result.success) {
for (const k of Object.keys(UserSlimShape)) {
if (k in (getUserFixture as Record<string, unknown>)) {
expect(k in result.data).toBe(true);
}
}
}
});

it("USER_SLIM_FIELDS exactly mirrors UserSlimShape keys", () => {
expect([...USER_SLIM_FIELDS].sort()).toEqual(Object.keys(UserSlimShape).sort());
});

it("projectField with USER_SLIM_FIELDS keeps only slim keys at runtime", () => {
// Use get_current_user fixture (has the admin/2FA fields populated)
const slim = projectField(
getCurrentUserFixture as Record<string, unknown>,
USER_SLIM_FIELDS,
undefined,
);
const allowed = new Set<string>(USER_SLIM_FIELDS);
for (const k of Object.keys(slim)) {
expect(allowed.has(k)).toBe(true);
}
});

it("USER_SLIM_FIELDS does NOT include privacy-sensitive fields (guardrail)", () => {
// Pin the privacy invariant. If a future change adds any of these to
// UserSlimShape, this test fails loudly and forces a deliberate decision.
const privacyFields = [
"email",
"public_email",
"last_sign_in_at",
"confirmed_at",
"last_activity_on",
"two_factor_enabled",
"current_sign_in_at",
"external",
"private_profile",
"is_admin",
"theme_id",
"color_scheme_id",
"projects_limit",
];
const slimSet = new Set<string>(USER_SLIM_FIELDS);
for (const field of privacyFields) {
expect(slimSet.has(field)).toBe(false);
}
});

it("projectField on the current-user fixture drops every privacy-sensitive field", () => {
const slim = projectField(
getCurrentUserFixture as Record<string, unknown>,
USER_SLIM_FIELDS,
undefined,
) as Record<string, unknown>;
for (const field of [
"email",
"last_sign_in_at",
"confirmed_at",
"last_activity_on",
"two_factor_enabled",
"current_sign_in_at",
"external",
"private_profile",
"is_admin",
"theme_id",
"color_scheme_id",
"projects_limit",
]) {
expect(slim[field]).toBeUndefined();
}
});

it("parses an array of users via GitLabUserListSchema", () => {
const result = GitLabUserListSchema.safeParse([getUserFixture, getCurrentUserFixture]);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(2);
}
});

it("token budget: projected user is materially smaller than the full payload", () => {
const fullBytes = Buffer.byteLength(JSON.stringify(getCurrentUserFixture), "utf8");
const slim = projectField(
getCurrentUserFixture as Record<string, unknown>,
USER_SLIM_FIELDS,
undefined,
);
const slimBytes = Buffer.byteLength(JSON.stringify(slim), "utf8");
expect(slimBytes).toBeLessThanOrEqual(Math.floor(fullBytes * 0.8));
});
});
Loading
Loading