Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions src/tools/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
listOrganizationsInputSchema,
listSharedProjectsInputSchema,
resetFromParentInputSchema,
searchInputSchema,
} from './toolsSchema.js';

export const NEON_TOOLS = [
Expand Down Expand Up @@ -608,4 +609,9 @@ export const NEON_TOOLS = [
description: 'Lists compute endpoints for a project or specific branch',
inputSchema: listBranchComputesInputSchema,
},
{
name: 'search' as const,
description: `Searches across all user organizations, projects, and branches that match the query. Returns a list of objects with id, title, and url. Each result includes its type (organization, project, or branch) and parent information when applicable. This tool searches through all accessible resources and provides direct links to the Neon Console.`,
inputSchema: searchInputSchema,
},
];
19 changes: 19 additions & 0 deletions src/tools/handlers/list-orgs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Api, Organization } from '@neondatabase/api-client';
import { ToolHandlerExtraParams } from '../types.js';
import { filterOrganizations } from '../utils.js';

export async function handleListOrganizations(
neonClient: Api<unknown>,
account: ToolHandlerExtraParams['account'],
search?: string,
): Promise<Organization[]> {
if (account.isOrg) {
const orgId = account.id;
const { data } = await neonClient.getOrganization(orgId);
return filterOrganizations([data], search);
}

const { data: response } = await neonClient.getCurrentUserOrganizations();
const organizations = response.organizations || [];
return filterOrganizations(organizations, search);
}
55 changes: 55 additions & 0 deletions src/tools/handlers/list-projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Api, ListProjectsParams } from '@neondatabase/api-client';
import { ToolHandlerExtraParams } from '../types.js';
import { getOrgByOrgIdOrDefault } from '../utils.js';
import { handleListOrganizations } from './list-orgs.js';

export async function handleListProjects(
params: ListProjectsParams,
neonClient: Api<unknown>,
extra: ToolHandlerExtraParams,
) {
const organization = await getOrgByOrgIdOrDefault(params, neonClient, extra);

const response = await neonClient.listProjects({
...params,
org_id: organization?.id,
});
if (response.status !== 200) {
throw new Error(`Failed to list projects: ${response.statusText}`);
}

let projects = response.data.projects;

// If search is provided and no org_id specified, and no projects found in personal account,
// search across all user organizations
if (params.search && !params.org_id && projects.length === 0) {
const organizations = await handleListOrganizations(
neonClient,
extra.account,
);

// Search projects across all organizations
const allProjects = [];
for (const org of organizations) {
// Skip the default organization
if (organization?.id === org.id) {
continue;
}

const orgResponse = await neonClient.listProjects({
...params,
org_id: org.id,
});
if (orgResponse.status === 200) {
allProjects.push(...orgResponse.data.projects);
}
}

// If we found projects in other organizations, return them
if (allProjects.length > 0) {
projects = allProjects;
}
}

return projects;
}
191 changes: 191 additions & 0 deletions src/tools/handlers/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { Api, Organization, ProjectListItem } from '@neondatabase/api-client';
import { Branch } from '@neondatabase/api-client';
import { searchInputSchema } from '../toolsSchema.js';
import { z } from 'zod';
import { ToolHandlerExtraParams } from '../types.js';
import { handleListProjects } from './list-projects.js';

type SearchProps = z.infer<typeof searchInputSchema>;

type MCPOrgId = `org:${string}`;
type MCPProjectId = `project:${string}`;
type MCPBranchId = `branch:${string}/${string}`; // projectId/branchId
type SearchResultId = MCPOrgId | MCPProjectId | MCPBranchId;

type SearchResult = {
id: SearchResultId;
title: string;
url: string;
type: 'organization' | 'project' | 'branch';
};

const matches = (
entity: Organization | ProjectListItem | Branch,
query: string,
) => {
return (
entity.name.toLowerCase().includes(query) ||
entity.id.toLowerCase().includes(query)
);
};

export async function handleSearch(
{ query }: SearchProps,
neonClient: Api<unknown>,
extra: ToolHandlerExtraParams,
): Promise<CallToolResult> {
try {
const results: SearchResult[] = [];
const searchQuery = query.toLowerCase();
// Search through all user's organizations
let organizations;
if (extra.account.isOrg) {
const orgId = extra.account.id;
const { data } = await neonClient.getOrganization(orgId);
organizations = [data];
} else {
const { data: response } = await neonClient.getCurrentUserOrganizations();
organizations = response.organizations || [];
}

// If in personal account, search projects
if (!extra.account.isOrg) {
const projects = await handleListProjects(
{
limit: 400,
},
neonClient,
extra,
);
const searchResults = await searchProjectsAndBranches(
projects,
neonClient,
searchQuery,
);

results.push(...searchResults);
}

// Search in all organizations
for (const org of organizations) {
// Check if organization matches the search query
if (matches(org, searchQuery)) {
results.push({
id: `org:${org.id}`,
title: org.name,
url: `https://console.neon.tech/app/${org.id}/projects`,
type: 'organization',
});
}

try {
const projects = await handleListProjects(
{
org_id: org.id,
limit: 400,
},
neonClient,
extra,
);

const searchResults = await searchProjectsAndBranches(
projects,
neonClient,
searchQuery,
);

results.push(...searchResults);
} catch {
// Skip projects if we can't access them
continue;
}
}

// Also search shared projects
try {
const { data } = await neonClient.listSharedProjects({
limit: 400,
});

const searchResults = await searchProjectsAndBranches(
data.projects,
neonClient,
searchQuery,
);
results.push(...searchResults);
} catch {
// Skip shared projects if we can't access them
}

return {
content: [
{
type: 'text',
text: JSON.stringify(results, null, 2),
},
],
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text',
text: `Failed to search: ${error instanceof Error ? error.message : 'Unknown error'}`,
},
],
};
}
}

const searchProjectsAndBranches = async (
projects: ProjectListItem[],
neonClient: Api<unknown>,
query: string,
): Promise<SearchResult[]> => {
const results: SearchResult[] = [];
projects.forEach((project) => {
if (matches(project, query)) {
results.push({
id: `project:${project.id}`,
title: project.name,
url: `https://console.neon.tech/app/projects/${project.id}`,
type: 'project',
});
}
});

const branches = await Promise.all(
projects.map(async (project) => {
return searchBranches(project.id, neonClient, query);
}),
);
results.push(...branches.flat());
return results;
};

const searchBranches = async (
projectId: string,
neonClient: Api<unknown>,
query: string,
): Promise<SearchResult[]> => {
try {
const { data } = await neonClient.listProjectBranches({
projectId,
});
const branches = data.branches;

return branches
.filter((branch) => matches(branch, query))
.map((branch) => ({
id: `branch:${projectId}/${branch.id}`,
title: branch.name,
url: `https://console.neon.tech/app/projects/${projectId}/branches/${branch.id}`,
type: 'branch',
}));
} catch {
// Ignore if we fail to fetch branches
return [];
}
};
Loading