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
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,19 +179,34 @@ Instead of exposing 167 individual tools, the server exposes **3 meta-tools**:

### Field Projection

List endpoints return a curated, allow-listed default set of fields per resource. Callers can opt into the full payload with `fields: "all"` or pick their own list with `fields: ["id", "name"]`.
List endpoints — and a growing set of singular `get_*` endpoints — return a curated, allow-listed default set of fields per resource. Callers can opt into the full payload with `fields: "all"` or pick their own list with `fields: ["id", "name"]`.

Currently applied to:

- `list_projects`, `list_group_projects`
- `list_issues`, `my_issues`
- `list_merge_requests`
- `list_merge_requests`, `get_merge_request`
- `list_pipelines`
- `list_releases`
- `list_commits`

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.

Example — fetch a merge request with the compact default vs. the full GitLab payload:

```jsonc
// Default: ~17 fields (iid, title, state, draft, labels, branches, author, …)
{ "name": "get_merge_request", "arguments": { "merge_request_iid": 42 } }

// Opt out: the raw GitLab response
{ "name": "get_merge_request", "arguments": { "merge_request_iid": 42, "fields": "all" } }

// Custom pick
{ "name": "get_merge_request", "arguments": { "merge_request_iid": 42, "fields": ["iid", "title", "state"] } }
```

The slim defaults are derived from the same Zod response schemas that validate GitLab API responses (see `src/schemas/`), so they stay in sync with the type-level shape and there's a single source of truth per resource.

### Server-Side File Trimming

`get_file_contents` accepts trim parameters so agents don't have to pull whole files into context just to read a function:
Expand Down
47 changes: 42 additions & 5 deletions src/schemas/merge-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { z } from "zod";
import { GitLabMilestoneRefSchema, GitLabUserRefSchema } from "./shared.js";

/**
* Response schema for GitLab merge request resources. Covers the fields in
* LIST_MERGE_REQUESTS_DEFAULT_FIELDS (see `src/tools/merge-requests.ts`)
* plus a handful of universally-present fields. `.passthrough()` preserves
* unknown fields so Phase 1 doesn't drop anything the LLM might be relying
* on; Phase 2 (DOT-557) will introduce explicit `.pick()` slimming.
* Response schema for GitLab merge request resources. Covers the fields
* picked by `MergeRequestSlimShape` (below) plus a handful of
* universally-present fields. `.passthrough()` preserves unknown fields so
* Phase 1 doesn't drop anything the LLM might be relying on when callers
* opt into the full payload via `fields: "all"`.
*/
export const GitLabMergeRequestSchema = z
.object({
Expand Down Expand Up @@ -45,3 +45,40 @@ export const GitLabMergeRequestSchema = z
export const GitLabMergeRequestListSchema = z.array(GitLabMergeRequestSchema);

export type GitLabMergeRequest = z.infer<typeof GitLabMergeRequestSchema>;

/**
* Slim shape: the fields an LLM almost always wants from a merge request.
* Single source of truth for both the typed `.pick()` view
* (`GitLabMergeRequestSlimSchema`) and the field-name allow-list consumed by
* `projectField` / `projectFields` (`MERGE_REQUEST_SLIM_FIELDS`).
*
* Phase 2 (DOT-557): schemas drive slim defaults; the user-facing surface is
* the existing `fields` parameter on tool inputs (`"all"` opts back into the
* full GitLab response, `["iid", "title", ...]` picks a custom subset).
*/
export const MergeRequestSlimShape = {
id: true,
iid: true,
title: true,
state: true,
draft: true,
labels: true,
source_branch: true,
target_branch: true,
author: true,
assignees: true,
reviewers: true,
milestone: true,
web_url: true,
created_at: true,
updated_at: true,
merge_status: true,
detailed_merge_status: true,
} as const;

export const GitLabMergeRequestSlimSchema = GitLabMergeRequestSchema.pick(MergeRequestSlimShape);
export type GitLabMergeRequestSlim = z.infer<typeof GitLabMergeRequestSlimSchema>;

export const MERGE_REQUEST_SLIM_FIELDS = Object.keys(MergeRequestSlimShape) as ReadonlyArray<
keyof typeof MergeRequestSlimShape
>;
9 changes: 6 additions & 3 deletions src/schemas/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ import type { Logger } from "../utils/logger.js";
* - get TypeScript types flowing from schemas into handlers,
* - surface drift visibly via logs (not silently).
*
* Phase 2 (DOT-557) will use `.pick()` on these schemas to slim responses
* behind a `verbose: false` default — that's where the token-efficiency win
* lands.
* Phase 2 (DOT-557) layers field slimming on top: each domain schema
* defines a `*SlimShape` consumed via `.pick()` plus a `*_SLIM_FIELDS`
* allow-list passed to `projectFields` / `projectField`. The user-facing
* surface is the existing `fields` parameter on tool inputs — `fields: "all"`
* opts back into the full GitLab response, `fields: ["iid", "title", ...]`
* picks a custom subset. That's where the token-efficiency win lands.
*/
export function parseGitLabResponse<S extends ZodTypeAny>(
schema: S,
Expand Down
48 changes: 19 additions & 29 deletions src/tools/merge-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,14 @@ import { z } from "zod";
import {
GitLabMergeRequestListSchema,
GitLabMergeRequestSchema,
MERGE_REQUEST_SLIM_FIELDS,
} from "../schemas/merge-requests.js";
import { parseGitLabResponse } from "../schemas/parse.js";
import { buildQueryString, defaultClient, resolveProjectId } from "../utils/gitlab-client.js";
import type { Logger } from "../utils/logger.js";
import { projectFields } from "../utils/projection.js";
import { projectField, projectFields } from "../utils/projection.js";
import { coerceStringArray, fieldsParam } from "../utils/schema-helpers.js";

// Compact set of MR fields useful to an LLM by default. Identifies the MR,
// its branches, who's involved, and its current merge state — without
// dragging along the change_count, _links, time_stats, has_conflicts, etc.
// Pass `fields: "all"` for the raw GitLab response, `fields: [...]` to override.
const LIST_MERGE_REQUESTS_DEFAULT_FIELDS = [
"id",
"iid",
"title",
"state",
"draft",
"labels",
"source_branch",
"target_branch",
"author",
"assignees",
"reviewers",
"milestone",
"web_url",
"created_at",
"updated_at",
"merge_status",
"detailed_merge_status",
] as const;

const MAX_PATTERN_LENGTH = 200;
const NESTED_QUANTIFIER_RE = /(\+|\*|\{)\s*(\+|\*|\{)/;

Expand Down Expand Up @@ -69,6 +46,7 @@ const GetMergeRequestSchema = z.object({
.describe("Project ID or URL-encoded path (defaults to GITLAB_PROJECT_ID if set)"),
merge_request_iid: z.coerce.number().optional().describe("Merge request IID"),
branch_name: z.string().optional().describe("Branch name to find MR"),
fields: fieldsParam("merge request").optional(),
});

const ListMergeRequestsSchema = z.object({
Expand Down Expand Up @@ -504,14 +482,16 @@ export function registerMergeRequestTools(
"get_merge_request",
{
title: "Get Merge Request",
description: "Get details of a merge request",
description:
"Get details of a merge request. Returns a compact set of fields by default; pass `fields: 'all'` for the raw GitLab response or `fields: ['iid', 'title', ...]` to pick your own.",
inputSchema: {
project_id: z
.string()
.optional()
.describe("Project ID or URL-encoded path (defaults to GITLAB_PROJECT_ID if set)"),
merge_request_iid: z.coerce.number().optional().describe("Merge request IID"),
branch_name: z.string().optional().describe("Branch name to find MR"),
fields: fieldsParam("merge request").optional(),
},
annotations: {
readOnlyHint: true,
Expand All @@ -527,7 +507,12 @@ export function registerMergeRequestTools(
`/projects/${projectId}/merge_requests/${args.merge_request_iid}`,
);
const mr = parseGitLabResponse(GitLabMergeRequestSchema, raw, "get_merge_request", logger);
return { content: [{ type: "text", text: JSON.stringify(mr, null, 2) }] };
const projected = projectField(
mr as unknown as Record<string, unknown>,
MERGE_REQUEST_SLIM_FIELDS,
args.fields,
);
return { content: [{ type: "text", text: JSON.stringify(projected, null, 2) }] };
}

if (args.branch_name) {
Expand All @@ -544,7 +529,12 @@ export function registerMergeRequestTools(
if (mrs.length === 0) {
return { content: [{ type: "text", text: "No merge request found for this branch" }] };
}
return { content: [{ type: "text", text: JSON.stringify(mrs[0], null, 2) }] };
const projected = projectField(
mrs[0] as unknown as Record<string, unknown>,
MERGE_REQUEST_SLIM_FIELDS,
args.fields,
);
return { content: [{ type: "text", text: JSON.stringify(projected, null, 2) }] };
}

throw new Error("Either merge_request_iid or branch_name must be provided");
Expand Down Expand Up @@ -625,7 +615,7 @@ export function registerMergeRequestTools(
"list_merge_requests",
logger,
) as unknown as Record<string, unknown>[];
const projected = projectFields(mrs, LIST_MERGE_REQUESTS_DEFAULT_FIELDS, fields);
const projected = projectFields(mrs, MERGE_REQUEST_SLIM_FIELDS, fields);
return { content: [{ type: "text", text: JSON.stringify(projected, null, 2) }] };
},
);
Expand Down
14 changes: 14 additions & 0 deletions src/utils/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,17 @@ export function projectFields<T extends Record<string, unknown>>(
return out as Partial<T>;
});
}

/**
* Singular sibling of {@link projectFields} for `get_*`-style endpoints that
* return one resource rather than a list. Same allow-list semantics:
* `"all"` returns the item unchanged, an array picks those keys explicitly,
* `undefined` or `[]` falls back to `defaultFields`.
*/
export function projectField<T extends Record<string, unknown>>(
item: T,
defaultFields: readonly string[],
requested: FieldsParam,
): Partial<T> {
return projectFields([item], defaultFields, requested)[0];
}
108 changes: 108 additions & 0 deletions tests/merge-requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,114 @@ describe("Merge Request Tools Handlers", () => {
});
});

describe("get_merge_request field projection (DOT-557)", () => {
function mockMRResponse(mr: Record<string, unknown>) {
// @ts-expect-error - mock doesn't need full fetch signature
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(mr)),
} as Response),
);
}

const fullMr = {
id: 12345,
iid: 42,
project_id: 999,
title: "Add response schemas",
state: "opened",
draft: false,
labels: ["chore", "schemas"],
source_branch: "feature/x",
target_branch: "main",
author: { id: 1, username: "ismart", name: "Ismar", state: "active" },
web_url: "https://gitlab.example/p/-/merge_requests/42",
created_at: "2026-05-15T10:00:00Z",
updated_at: "2026-05-15T11:00:00Z",
merge_status: "can_be_merged",
detailed_merge_status: "mergeable",
// Bloat that should NOT survive default projection:
changes_count: "7",
has_conflicts: false,
blocking_discussions_resolved: true,
_links: { self: "..." },
time_stats: { time_estimate: 0 },
sha: "abc123",
merge_commit_sha: null,
};

it("returns only the default field set when fields is unset", async () => {
mockMRResponse(fullMr);
const result = await client.callTool({
name: "get_merge_request",
arguments: { project_id: "p", merge_request_iid: 42 },
});
const data = JSON.parse((result.content as Array<{ type: string; text: string }>)[0].text);
expect(data.iid).toBe(42);
expect(data.title).toBe("Add response schemas");
expect(data.merge_status).toBe("can_be_merged");
// Bloat dropped
expect(data.changes_count).toBeUndefined();
expect(data.has_conflicts).toBeUndefined();
expect(data._links).toBeUndefined();
expect(data.time_stats).toBeUndefined();
expect(data.sha).toBeUndefined();
});

it('returns the full payload when fields="all"', async () => {
mockMRResponse(fullMr);
const result = await client.callTool({
name: "get_merge_request",
arguments: { project_id: "p", merge_request_iid: 42, fields: "all" },
});
const data = JSON.parse((result.content as Array<{ type: string; text: string }>)[0].text);
expect(data.changes_count).toBe("7");
expect(data.has_conflicts).toBe(false);
expect(data._links).toEqual({ self: "..." });
expect(data.sha).toBe("abc123");
});

it("returns exactly the requested fields when fields is a custom list", async () => {
mockMRResponse(fullMr);
const result = await client.callTool({
name: "get_merge_request",
arguments: {
project_id: "p",
merge_request_iid: 42,
fields: ["iid", "title", "state"],
},
});
const data = JSON.parse((result.content as Array<{ type: string; text: string }>)[0].text);
expect(Object.keys(data).sort()).toEqual(["iid", "state", "title"]);
expect(data.iid).toBe(42);
expect(data.title).toBe("Add response schemas");
expect(data.state).toBe("opened");
});

it("applies projection on the branch_name lookup path too", async () => {
// branch_name route fetches a list, returns the first; projection
// must still slim the response.
// @ts-expect-error - mock doesn't need full fetch signature
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify([fullMr])),
} as Response),
);
const result = await client.callTool({
name: "get_merge_request",
arguments: { project_id: "p", branch_name: "feature/x" },
});
const data = JSON.parse((result.content as Array<{ type: string; text: string }>)[0].text);
expect(data.iid).toBe(42);
expect(data.changes_count).toBeUndefined();
expect(data._links).toBeUndefined();
});
});

describe("list_merge_request_pipelines (DOT-543)", () => {
it("GETs the MR pipelines endpoint and forwards pagination", async () => {
let capturedUrl = "";
Expand Down
Loading