Skip to content

Commit 847d6eb

Browse files
dcramerclaude
andauthored
feat: add search support to find_organizations, find_teams, and find_projects (#574)
Add optional query parameter to list endpoints to support filtering by name/slug. Return up to 25 results with clear messaging when limit is hit, directing users to use the query parameter to narrow results. Changes: - Added ParamSearchQuery schema for consistent search validation - Updated API client methods (listOrganizations, listTeams, listProjects) to accept query parameter - Hardcoded per_page=25 limit in API client (not exposed to callers) - Updated all three find_* tools to support optional query parameter - Added warning message when hitting 25 result limit - Updated tool descriptions to mention search capability and result limits Fixes #573 --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 751ed07 commit 847d6eb

5 files changed

Lines changed: 140 additions & 30 deletions

File tree

‎packages/mcp-server/src/api-client/client.ts‎

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -798,8 +798,10 @@ export class SentryApiService {
798798
* Automatically handles multi-region queries by fetching from all
799799
* available regions and combining results.
800800
*
801+
* @param params Query parameters
802+
* @param params.query Search query to filter organizations by name/slug
801803
* @param opts Request options
802-
* @returns Array of organizations across all accessible regions
804+
* @returns Array of organizations across all accessible regions (limited to 25 results)
803805
*
804806
* @example
805807
* ```typescript
@@ -810,10 +812,22 @@ export class SentryApiService {
810812
* });
811813
* ```
812814
*/
813-
async listOrganizations(opts?: RequestOptions): Promise<OrganizationList> {
815+
async listOrganizations(
816+
params?: { query?: string },
817+
opts?: RequestOptions,
818+
): Promise<OrganizationList> {
819+
// Build query parameters
820+
const queryParams = new URLSearchParams();
821+
queryParams.set("per_page", "25");
822+
if (params?.query) {
823+
queryParams.set("query", params.query);
824+
}
825+
const queryString = queryParams.toString();
826+
const path = `/organizations/?${queryString}`;
827+
814828
// For self-hosted instances, the regions endpoint doesn't exist
815829
if (!this.isSaas()) {
816-
const body = await this.requestJSON("/organizations/", undefined, opts);
830+
const body = await this.requestJSON(path, undefined, opts);
817831
return OrganizationListSchema.parse(body);
818832
}
819833

@@ -828,10 +842,10 @@ export class SentryApiService {
828842
);
829843
const regionData = UserRegionsSchema.parse(regionsBody);
830844

831-
return (
845+
const allOrganizations = (
832846
await Promise.all(
833847
regionData.regions.map(async (region) =>
834-
this.requestJSON(`/organizations/`, undefined, {
848+
this.requestJSON(path, undefined, {
835849
...opts,
836850
host: new URL(region.url).host,
837851
}),
@@ -840,12 +854,15 @@ export class SentryApiService {
840854
)
841855
.map((data) => OrganizationListSchema.parse(data))
842856
.reduce((acc, curr) => acc.concat(curr), []);
857+
858+
// Apply the limit after combining results from all regions
859+
return allOrganizations.slice(0, 25);
843860
} catch (error) {
844861
// If regions endpoint fails (e.g., older self-hosted versions identifying as sentry.io),
845862
// fall back to direct organizations endpoint
846863
if (error instanceof ApiNotFoundError) {
847864
// logger.info("Regions endpoint not found, falling back to direct organizations endpoint");
848-
const body = await this.requestJSON("/organizations/", undefined, opts);
865+
const body = await this.requestJSON(path, undefined, opts);
849866
return OrganizationListSchema.parse(body);
850867
}
851868

@@ -874,18 +891,25 @@ export class SentryApiService {
874891
* Lists teams within an organization.
875892
*
876893
* @param organizationSlug Organization identifier
894+
* @param params Query parameters
895+
* @param params.query Search query to filter teams by name/slug
877896
* @param opts Request options including host override
878-
* @returns Array of teams in the organization
897+
* @returns Array of teams in the organization (limited to 25 results)
879898
*/
880899
async listTeams(
881900
organizationSlug: string,
901+
params?: { query?: string },
882902
opts?: RequestOptions,
883903
): Promise<TeamList> {
884-
const body = await this.requestJSON(
885-
`/organizations/${organizationSlug}/teams/`,
886-
undefined,
887-
opts,
888-
);
904+
const queryParams = new URLSearchParams();
905+
queryParams.set("per_page", "25");
906+
if (params?.query) {
907+
queryParams.set("query", params.query);
908+
}
909+
const queryString = queryParams.toString();
910+
const path = `/organizations/${organizationSlug}/teams/?${queryString}`;
911+
912+
const body = await this.requestJSON(path, undefined, opts);
889913
return TeamListSchema.parse(body);
890914
}
891915

@@ -924,18 +948,25 @@ export class SentryApiService {
924948
* Lists projects within an organization.
925949
*
926950
* @param organizationSlug Organization identifier
951+
* @param params Query parameters
952+
* @param params.query Search query to filter projects by name/slug
927953
* @param opts Request options
928-
* @returns Array of projects in the organization
954+
* @returns Array of projects in the organization (limited to 25 results)
929955
*/
930956
async listProjects(
931957
organizationSlug: string,
958+
params?: { query?: string },
932959
opts?: RequestOptions,
933960
): Promise<ProjectList> {
934-
const body = await this.requestJSON(
935-
`/organizations/${organizationSlug}/projects/`,
936-
undefined,
937-
opts,
938-
);
961+
const queryParams = new URLSearchParams();
962+
queryParams.set("per_page", "25");
963+
if (params?.query) {
964+
queryParams.set("query", params.query);
965+
}
966+
const queryString = queryParams.toString();
967+
const path = `/organizations/${organizationSlug}/projects/?${queryString}`;
968+
969+
const body = await this.requestJSON(path, undefined, opts);
939970
return ProjectListSchema.parse(body);
940971
}
941972

‎packages/mcp-server/src/schema.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ export const ParamProjectSlugOrAll = z
4545
"The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible.",
4646
);
4747

48+
export const ParamSearchQuery = z
49+
.string()
50+
.trim()
51+
.describe(
52+
"Search query to filter results by name or slug. Use this to narrow down results when there are many items.",
53+
);
54+
4855
export const ParamIssueShortId = z
4956
.string()
5057
.toUpperCase()

‎packages/mcp-server/src/tools/find-organizations.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { defineTool } from "../internal/tool-helpers/define";
22
import { apiServiceFromContext } from "../internal/tool-helpers/api";
33
import type { ServerContext } from "../types";
4+
import { ParamSearchQuery } from "../schema";
5+
6+
const RESULT_LIMIT = 25;
47

58
export default defineTool({
69
name: "find_organizations",
@@ -9,20 +12,33 @@ export default defineTool({
912
"Find organizations that the user has access to in Sentry.",
1013
"",
1114
"Use this tool when you need to:",
12-
"- View all organizations in Sentry",
15+
"- View organizations in Sentry",
1316
"- Find an organization's slug to aid other tool requests",
17+
"- Search for specific organizations by name or slug",
18+
"",
19+
`Returns up to ${RESULT_LIMIT} results. If you hit this limit, use the query parameter to narrow down results.`,
1420
].join("\n"),
15-
inputSchema: {},
21+
inputSchema: {
22+
query: ParamSearchQuery.optional(),
23+
},
1624
async handler(params, context: ServerContext) {
1725
// User data endpoints (like /users/me/regions/) should never use regionUrl
1826
// as they must always query the main API server, not region-specific servers
1927
const apiService = apiServiceFromContext(context);
20-
const organizations = await apiService.listOrganizations();
28+
const organizations = await apiService.listOrganizations({
29+
query: params.query,
30+
});
2131

2232
let output = "# Organizations\n\n";
2333

34+
if (params.query) {
35+
output += `**Search query:** "${params.query}"\n\n`;
36+
}
37+
2438
if (organizations.length === 0) {
25-
output += "You don't appear to be a member of any organizations.\n";
39+
output += params.query
40+
? `No organizations found matching "${params.query}".\n`
41+
: "You don't appear to be a member of any organizations.\n";
2642
return output;
2743
}
2844

@@ -37,6 +53,10 @@ export default defineTool({
3753
)
3854
.join("\n\n");
3955

56+
if (organizations.length === RESULT_LIMIT) {
57+
output += `\n\n---\n\n**Note:** Showing ${RESULT_LIMIT} results (maximum). There may be more organizations available. Use the \`query\` parameter to search for specific organizations.`;
58+
}
59+
4060
output += "\n\n# Using this information\n\n";
4161
output += `- The organization's name is the identifier for the organization, and is used in many tools for \`organizationSlug\`.\n`;
4262

‎packages/mcp-server/src/tools/find-projects.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { defineTool } from "../internal/tool-helpers/define";
33
import { apiServiceFromContext } from "../internal/tool-helpers/api";
44
import { UserInputError } from "../errors";
55
import type { ServerContext } from "../types";
6-
import { ParamOrganizationSlug, ParamRegionUrl } from "../schema";
6+
import {
7+
ParamOrganizationSlug,
8+
ParamRegionUrl,
9+
ParamSearchQuery,
10+
} from "../schema";
11+
12+
const RESULT_LIMIT = 25;
713

814
export default defineTool({
915
name: "find_projects",
@@ -12,12 +18,16 @@ export default defineTool({
1218
"Find projects in Sentry.",
1319
"",
1420
"Use this tool when you need to:",
15-
"- View all projects in a Sentry organization",
21+
"- View projects in a Sentry organization",
1622
"- Find a project's slug to aid other tool requests",
23+
"- Search for specific projects by name or slug",
24+
"",
25+
`Returns up to ${RESULT_LIMIT} results. If you hit this limit, use the query parameter to narrow down results.`,
1726
].join("\n"),
1827
inputSchema: {
1928
organizationSlug: ParamOrganizationSlug,
2029
regionUrl: ParamRegionUrl.optional(),
30+
query: ParamSearchQuery.optional(),
2131
},
2232
async handler(params, context: ServerContext) {
2333
const apiService = apiServiceFromContext(context, {
@@ -33,13 +43,29 @@ export default defineTool({
3343

3444
setTag("organization.slug", organizationSlug);
3545

36-
const projects = await apiService.listProjects(organizationSlug);
46+
const projects = await apiService.listProjects(organizationSlug, {
47+
query: params.query,
48+
});
49+
3750
let output = `# Projects in **${organizationSlug}**\n\n`;
51+
52+
if (params.query) {
53+
output += `**Search query:** "${params.query}"\n\n`;
54+
}
55+
3856
if (projects.length === 0) {
39-
output += "No projects found.\n";
57+
output += params.query
58+
? `No projects found matching "${params.query}".\n`
59+
: "No projects found.\n";
4060
return output;
4161
}
62+
4263
output += projects.map((project) => `- **${project.slug}**\n`).join("");
64+
65+
if (projects.length === RESULT_LIMIT) {
66+
output += `\n---\n\n**Note:** Showing ${RESULT_LIMIT} results (maximum). There may be more projects available. Use the \`query\` parameter to search for specific projects.`;
67+
}
68+
4369
return output;
4470
},
4571
});

‎packages/mcp-server/src/tools/find-teams.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { defineTool } from "../internal/tool-helpers/define";
33
import { apiServiceFromContext } from "../internal/tool-helpers/api";
44
import { UserInputError } from "../errors";
55
import type { ServerContext } from "../types";
6-
import { ParamOrganizationSlug, ParamRegionUrl } from "../schema";
6+
import {
7+
ParamOrganizationSlug,
8+
ParamRegionUrl,
9+
ParamSearchQuery,
10+
} from "../schema";
11+
12+
const RESULT_LIMIT = 25;
713

814
export default defineTool({
915
name: "find_teams",
@@ -12,12 +18,16 @@ export default defineTool({
1218
"Find teams in an organization in Sentry.",
1319
"",
1420
"Use this tool when you need to:",
15-
"- View all teams in a Sentry organization",
21+
"- View teams in a Sentry organization",
1622
"- Find a team's slug to aid other tool requests",
23+
"- Search for specific teams by name or slug",
24+
"",
25+
`Returns up to ${RESULT_LIMIT} results. If you hit this limit, use the query parameter to narrow down results.`,
1726
].join("\n"),
1827
inputSchema: {
1928
organizationSlug: ParamOrganizationSlug,
2029
regionUrl: ParamRegionUrl.optional(),
30+
query: ParamSearchQuery.optional(),
2131
},
2232
async handler(params, context: ServerContext) {
2333
const apiService = apiServiceFromContext(context, {
@@ -33,13 +43,29 @@ export default defineTool({
3343

3444
setTag("organization.slug", organizationSlug);
3545

36-
const teams = await apiService.listTeams(organizationSlug);
46+
const teams = await apiService.listTeams(organizationSlug, {
47+
query: params.query,
48+
});
49+
3750
let output = `# Teams in **${organizationSlug}**\n\n`;
51+
52+
if (params.query) {
53+
output += `**Search query:** "${params.query}"\n\n`;
54+
}
55+
3856
if (teams.length === 0) {
39-
output += "No teams found.\n";
57+
output += params.query
58+
? `No teams found matching "${params.query}".\n`
59+
: "No teams found.\n";
4060
return output;
4161
}
62+
4263
output += teams.map((team) => `- ${team.slug}\n`).join("");
64+
65+
if (teams.length === RESULT_LIMIT) {
66+
output += `\n---\n\n**Note:** Showing ${RESULT_LIMIT} results (maximum). There may be more teams available. Use the \`query\` parameter to search for specific teams.`;
67+
}
68+
4369
return output;
4470
},
4571
});

0 commit comments

Comments
 (0)