Skip to content
This repository was archived by the owner on May 25, 2026. It is now read-only.

Commit 6ea1953

Browse files
feat(schemas): schema-driven slim fields on users (Phase 3b, DOT-560) (#111)
Sibling of DOT-558 (Phase 3a). Extends the schema-driven slim-fields pattern (canonical: PR #109, PR #110) to the users domain — the one piece Phase 3a deferred because the slim shape needed a privacy-aware design pass. Slim shape: identity-only — id, username, name, state, avatar_url, web_url, bot. Seven fields. Always safe to expose for any user. Privacy-sensitive fields (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) are excluded by default; opt back in via fields:'all' or explicit pick. A dedicated test pins the privacy guardrail so future expansions of UserSlimShape can't accidentally leak those fields. Phase-1 gaps closed along the way: - src/schemas/users.ts: GitLabUserListSchema didn't exist; add it. - src/tools/users.ts: get_users and search_users were never wrapped with parseGitLabResponse; wrap them now (a prerequisite for typed slim defaults to project from). Wires four user-returning tools with fields + projection: - get_current_user (already parse-wrapped, just adds fields+project) - get_user (already parse-wrapped, just adds fields+project) - get_users (newly parse-wrapped, per-username map slimmed element-wise) - search_users (newly parse-wrapped, list slimmed) Out of scope: list_events, get_project_events, upload_markdown, download_attachment, health_check — they live in users.ts for organizational convenience but don't return user-shaped data. Tests: - tests/schemas/users.test.ts: slim-schema parse, USER_SLIM_FIELDS allow-list invariant, projectField allow-list at runtime, **privacy guardrail** (USER_SLIM_FIELDS does NOT contain email / is_admin / two_factor_enabled / etc.), separate test that projectField actually drops those fields on a populated current-user fixture, GitLabUserListSchema parse, token-budget regression. - tests/users.test.ts: default / fields:'all' / custom-pick handler tests for each of the four tools (12+ cases). get_current_user and get_user tests specifically verify privacy fields absent by default and present under fields:'all'. search_users test confirms `fields` is not forwarded to GitLab as a query string. README "Field Projection" section extended to list the four user endpoints.
1 parent 0f5aea4 commit 6ea1953

5 files changed

Lines changed: 492 additions & 15 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ Currently applied to:
189189
- `list_pipelines`, `get_pipeline`
190190
- `list_releases`
191191
- `list_commits`, `get_commit`
192+
- `get_current_user`, `get_user`, `get_users`, `search_users`
192193

193194
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.
194195

src/schemas/users.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,35 @@ export const GitLabUserSchema = z
4343
.passthrough();
4444

4545
export type GitLabUser = z.infer<typeof GitLabUserSchema>;
46+
47+
export const GitLabUserListSchema = z.array(GitLabUserSchema);
48+
49+
/**
50+
* Slim shape (Phase 3b / DOT-560): identity-only fields safe to expose for
51+
* any GitLab user. Deliberately excludes every privacy-sensitive field
52+
* GitLab returns for the current user (email, last_sign_in_at, is_admin,
53+
* two_factor_enabled, confirmed_at, current_sign_in_at, private_profile,
54+
* last_activity_on, theme_id, color_scheme_id, projects_limit, external)
55+
* and the bulky public-profile fields (bio, location, organization,
56+
* job_title, work_information, pronouns, followers, following). Callers
57+
* opt back in via `fields: "all"` or a custom `fields: [...]` allow-list.
58+
*
59+
* `bot` is included because the LLM often needs to know whether to treat
60+
* the user as a service account vs. a person. Always safe to expose.
61+
*/
62+
export const UserSlimShape = {
63+
id: true,
64+
username: true,
65+
name: true,
66+
state: true,
67+
avatar_url: true,
68+
web_url: true,
69+
bot: true,
70+
} as const;
71+
72+
export const GitLabUserSlimSchema = GitLabUserSchema.pick(UserSlimShape);
73+
export type GitLabUserSlim = z.infer<typeof GitLabUserSlimSchema>;
74+
75+
export const USER_SLIM_FIELDS = Object.keys(UserSlimShape) as ReadonlyArray<
76+
keyof typeof UserSlimShape
77+
>;

src/tools/users.ts

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,31 @@
11
import type { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { z } from "zod";
33
import { parseGitLabResponse } from "../schemas/parse.js";
4-
import { GitLabUserSchema } from "../schemas/users.js";
4+
import { GitLabUserListSchema, GitLabUserSchema, USER_SLIM_FIELDS } from "../schemas/users.js";
55
import { buildQueryString, defaultClient, resolveProjectId } from "../utils/gitlab-client.js";
66
import type { Logger } from "../utils/logger.js";
7+
import { projectField, projectFields } from "../utils/projection.js";
8+
import { fieldsParam } from "../utils/schema-helpers.js";
79

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

1215
const GetUserSchema = z.object({
1316
user_id: z.number().describe("User ID"),
17+
fields: fieldsParam("user").optional(),
1418
});
1519

1620
const SearchUsersSchema = z.object({
1721
search: z.string().describe("Search query"),
1822
page: z.number().optional().describe("Page number"),
1923
per_page: z.number().optional().describe("Results per page"),
24+
fields: fieldsParam("user").optional(),
25+
});
26+
27+
const GetCurrentUserSchema = z.object({
28+
fields: fieldsParam("user").optional(),
2029
});
2130

2231
const ListEventsSchema = z.object({
@@ -101,9 +110,11 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
101110
"get_users",
102111
{
103112
title: "Get Users",
104-
description: "Get GitLab user details by usernames",
113+
description:
114+
"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.",
105115
inputSchema: {
106116
usernames: z.array(z.string()).describe("List of usernames to look up"),
117+
fields: fieldsParam("user").optional(),
107118
},
108119
annotations: {
109120
readOnlyHint: true,
@@ -116,11 +127,20 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
116127

117128
for (const username of args.usernames) {
118129
const query = buildQueryString({ username });
119-
const users = await defaultClient.get<unknown[]>(`/users${query}`);
130+
const raw = await defaultClient.get(`/users${query}`);
131+
const users = parseGitLabResponse(GitLabUserListSchema, raw, "get_users", logger);
120132
// Always assign — null when the username didn't resolve. Omitting
121133
// missing keys would collapse signal: callers couldn't tell whether
122134
// a key wasn't asked for or actually didn't exist on GitLab.
123-
results[username] = users.length > 0 ? users[0] : null;
135+
if (users.length === 0) {
136+
results[username] = null;
137+
continue;
138+
}
139+
results[username] = projectField(
140+
users[0] as unknown as Record<string, unknown>,
141+
USER_SLIM_FIELDS,
142+
args.fields,
143+
);
124144
}
125145

126146
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
@@ -133,9 +153,11 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
133153
"get_user",
134154
{
135155
title: "Get User",
136-
description: "Get details of a specific user by ID",
156+
description:
157+
"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.",
137158
inputSchema: {
138159
user_id: z.number().describe("User ID"),
160+
fields: fieldsParam("user").optional(),
139161
},
140162
annotations: {
141163
readOnlyHint: true,
@@ -146,7 +168,12 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
146168
const args = GetUserSchema.parse(params);
147169
const raw = await defaultClient.get(`/users/${args.user_id}`);
148170
const user = parseGitLabResponse(GitLabUserSchema, raw, "get_user", logger);
149-
return { content: [{ type: "text", text: JSON.stringify(user, null, 2) }] };
171+
const projected = projectField(
172+
user as unknown as Record<string, unknown>,
173+
USER_SLIM_FIELDS,
174+
args.fields,
175+
);
176+
return { content: [{ type: "text", text: JSON.stringify(projected, null, 2) }] };
150177
},
151178
);
152179
toolRef2.disable();
@@ -156,11 +183,13 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
156183
"search_users",
157184
{
158185
title: "Search Users",
159-
description: "Search for GitLab users",
186+
description:
187+
"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.",
160188
inputSchema: {
161189
search: z.string().describe("Search query"),
162190
page: z.number().optional().describe("Page number"),
163191
per_page: z.number().optional().describe("Results per page"),
192+
fields: fieldsParam("user").optional(),
164193
},
165194
annotations: {
166195
readOnlyHint: true,
@@ -169,10 +198,18 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
169198
},
170199
async (params) => {
171200
const args = SearchUsersSchema.parse(params);
172-
const query = buildQueryString(args);
201+
const { fields, ...queryParams } = args;
202+
const query = buildQueryString(queryParams);
173203

174-
const users = await defaultClient.get(`/users${query}`);
175-
return { content: [{ type: "text", text: JSON.stringify(users, null, 2) }] };
204+
const raw = await defaultClient.get(`/users${query}`);
205+
const users = parseGitLabResponse(
206+
GitLabUserListSchema,
207+
raw,
208+
"search_users",
209+
logger,
210+
) as unknown as Record<string, unknown>[];
211+
const projected = projectFields(users, USER_SLIM_FIELDS, fields);
212+
return { content: [{ type: "text", text: JSON.stringify(projected, null, 2) }] };
176213
},
177214
);
178215
toolRef3.disable();
@@ -366,17 +403,25 @@ export function registerUserTools(server: McpServer, logger: Logger): Map<string
366403
{
367404
title: "Get Current User",
368405
description:
369-
"Get details of the authenticated user (whoami). Returns the user identified by the configured PAT / OAuth token.",
370-
inputSchema: {},
406+
"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.",
407+
inputSchema: {
408+
fields: fieldsParam("user").optional(),
409+
},
371410
annotations: {
372411
readOnlyHint: true,
373412
openWorldHint: true,
374413
},
375414
},
376-
async () => {
415+
async (params) => {
416+
const args = GetCurrentUserSchema.parse(params);
377417
const raw = await defaultClient.get(`/user`);
378418
const user = parseGitLabResponse(GitLabUserSchema, raw, "get_current_user", logger);
379-
return { content: [{ type: "text", text: JSON.stringify(user, null, 2) }] };
419+
const projected = projectField(
420+
user as unknown as Record<string, unknown>,
421+
USER_SLIM_FIELDS,
422+
args.fields,
423+
);
424+
return { content: [{ type: "text", text: JSON.stringify(projected, null, 2) }] };
380425
},
381426
);
382427
toolRef8.disable();

tests/schemas/users.test.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { describe, expect, it } from "bun:test";
2-
import { GitLabUserSchema } from "../../src/schemas/users.js";
2+
import {
3+
GitLabUserListSchema,
4+
GitLabUserSchema,
5+
GitLabUserSlimSchema,
6+
USER_SLIM_FIELDS,
7+
UserSlimShape,
8+
} from "../../src/schemas/users.js";
9+
import { projectField } from "../../src/utils/projection.js";
310
import getCurrentUserFixture from "../fixtures/users/get_current_user.json";
411
import getUserFixture from "../fixtures/users/get_user.json";
512

@@ -28,3 +35,101 @@ describe("GitLabUserSchema", () => {
2835
expect(result.success).toBe(false);
2936
});
3037
});
38+
39+
describe("GitLabUserSlimSchema (Phase 3b / DOT-560)", () => {
40+
it("parses the get_user fixture and types every slim field", () => {
41+
const result = GitLabUserSlimSchema.safeParse(getUserFixture);
42+
expect(result.success).toBe(true);
43+
if (result.success) {
44+
for (const k of Object.keys(UserSlimShape)) {
45+
if (k in (getUserFixture as Record<string, unknown>)) {
46+
expect(k in result.data).toBe(true);
47+
}
48+
}
49+
}
50+
});
51+
52+
it("USER_SLIM_FIELDS exactly mirrors UserSlimShape keys", () => {
53+
expect([...USER_SLIM_FIELDS].sort()).toEqual(Object.keys(UserSlimShape).sort());
54+
});
55+
56+
it("projectField with USER_SLIM_FIELDS keeps only slim keys at runtime", () => {
57+
// Use get_current_user fixture (has the admin/2FA fields populated)
58+
const slim = projectField(
59+
getCurrentUserFixture as Record<string, unknown>,
60+
USER_SLIM_FIELDS,
61+
undefined,
62+
);
63+
const allowed = new Set<string>(USER_SLIM_FIELDS);
64+
for (const k of Object.keys(slim)) {
65+
expect(allowed.has(k)).toBe(true);
66+
}
67+
});
68+
69+
it("USER_SLIM_FIELDS does NOT include privacy-sensitive fields (guardrail)", () => {
70+
// Pin the privacy invariant. If a future change adds any of these to
71+
// UserSlimShape, this test fails loudly and forces a deliberate decision.
72+
const privacyFields = [
73+
"email",
74+
"public_email",
75+
"last_sign_in_at",
76+
"confirmed_at",
77+
"last_activity_on",
78+
"two_factor_enabled",
79+
"current_sign_in_at",
80+
"external",
81+
"private_profile",
82+
"is_admin",
83+
"theme_id",
84+
"color_scheme_id",
85+
"projects_limit",
86+
];
87+
const slimSet = new Set<string>(USER_SLIM_FIELDS);
88+
for (const field of privacyFields) {
89+
expect(slimSet.has(field)).toBe(false);
90+
}
91+
});
92+
93+
it("projectField on the current-user fixture drops every privacy-sensitive field", () => {
94+
const slim = projectField(
95+
getCurrentUserFixture as Record<string, unknown>,
96+
USER_SLIM_FIELDS,
97+
undefined,
98+
) as Record<string, unknown>;
99+
for (const field of [
100+
"email",
101+
"last_sign_in_at",
102+
"confirmed_at",
103+
"last_activity_on",
104+
"two_factor_enabled",
105+
"current_sign_in_at",
106+
"external",
107+
"private_profile",
108+
"is_admin",
109+
"theme_id",
110+
"color_scheme_id",
111+
"projects_limit",
112+
]) {
113+
expect(slim[field]).toBeUndefined();
114+
}
115+
});
116+
117+
it("parses an array of users via GitLabUserListSchema", () => {
118+
const result = GitLabUserListSchema.safeParse([getUserFixture, getCurrentUserFixture]);
119+
expect(result.success).toBe(true);
120+
if (result.success) {
121+
expect(result.data).toHaveLength(2);
122+
}
123+
});
124+
125+
it("token budget: projected user is materially smaller than the full payload", () => {
126+
const fullBytes = Buffer.byteLength(JSON.stringify(getCurrentUserFixture), "utf8");
127+
const slim = projectField(
128+
getCurrentUserFixture as Record<string, unknown>,
129+
USER_SLIM_FIELDS,
130+
undefined,
131+
);
132+
const slimBytes = Buffer.byteLength(JSON.stringify(slim), "utf8");
133+
expect(slimBytes).toBeLessThanOrEqual(Math.floor(fullBytes * 0.8));
134+
});
135+
});

0 commit comments

Comments
 (0)