Skip to content
Open
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
430 changes: 430 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"csv-parse": "^5.6.0",
"dotenv": "^16.5.0",
"form-data": "^4.0.2",
"octokit": "^5.0.5",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"sharp": "^0.34.1",
Expand Down
48 changes: 48 additions & 0 deletions src/repo/GitHubRepositoryFetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// src/repo/GitHubRepositoryFetcher.ts

import { Octokit } from "octokit";
import { RepositoryFetcher, RepoFileMeta } from "./RepositoryFetcher.js";

export class GitHubRepositoryFetcher implements RepositoryFetcher {
private client: Octokit;

constructor(
private owner: string,
private repo: string,
private branch: string,
token: string,
) {
this.client = new Octokit({ auth: token });
}

async getTree(): Promise<RepoFileMeta[]> {
const treeRes = await this.client.rest.git.getTree({
owner: this.owner,
repo: this.repo,
tree_sha: this.branch,
recursive: "true",
});

return treeRes.data.tree.map((item: any) => ({
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using any type weakens type safety. The GitHub API's tree response has a well-defined structure. Consider using the proper Octokit types instead of any, such as RestEndpointMethodTypes["git"]["getTree"]["response"]["data"]["tree"][number].

Copilot uses AI. Check for mistakes.
path: item.path!,
sha: item.sha!,
type: item.type as "blob" | "tree",
size: item.size,
}));
Comment on lines +18 to +31
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getTree() method fetches the entire repository tree recursively, which could be very slow and memory-intensive for large repositories. Consider:

  1. Adding pagination support
  2. Implementing lazy loading or filtering at the API level
  3. Adding a timeout mechanism
  4. Documenting the performance implications in the interface comments
Suggested change
async getTree(): Promise<RepoFileMeta[]> {
const treeRes = await this.client.rest.git.getTree({
owner: this.owner,
repo: this.repo,
tree_sha: this.branch,
recursive: "true",
});
return treeRes.data.tree.map((item: any) => ({
path: item.path!,
sha: item.sha!,
type: item.type as "blob" | "tree",
size: item.size,
}));
/**
* Fetches the entire repository tree recursively.
* WARNING: For large repositories, this can be very slow and memory-intensive.
* Consider using the `filter` argument to limit results, and be aware of possible timeouts.
* @param filter Optional filter function to select which files to include.
* @param timeoutMs Optional timeout in milliseconds (default: 10000ms).
*/
async getTree(
filter?: (item: RepoFileMeta) => boolean,
timeoutMs: number = 10000
): Promise<RepoFileMeta[]> {
// Add a timeout to the API call
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
let treeRes;
try {
treeRes = await this.client.rest.git.getTree({
owner: this.owner,
repo: this.repo,
tree_sha: this.branch,
recursive: "true",
request: { signal: controller.signal }
});
} catch (err: any) {
if (err.name === "AbortError") {
throw new Error(`getTree request timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timeout);
}
let files = treeRes.data.tree.map((item: any) => ({
path: item.path!,
sha: item.sha!,
type: item.type as "blob" | "tree",
size: item.size,
}));
if (filter) {
files = files.filter(filter);
}
return files;

Copilot uses AI. Check for mistakes.
}

async getFileContent(path: string): Promise<string> {
const res = await this.client.rest.repos.getContent({
owner: this.owner,
repo: this.repo,
path,
ref: this.branch,
});

if (!("content" in res.data)) {
throw new Error("Not a file");
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message "Not a file" is unclear and unhelpful for debugging. Consider providing more context, such as: File path '${path}' does not point to a file (may be a directory or symlink).

Suggested change
throw new Error("Not a file");
throw new Error(`File path '${path}' does not point to a file (may be a directory or symlink)`);

Copilot uses AI. Check for mistakes.
}

return Buffer.from(res.data.content, "base64").toString("utf8");
}
}
22 changes: 22 additions & 0 deletions src/repo/RemoteIndexer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// src/repo/RemoteIndexer.ts

import { RepositoryFetcher, RepoFileMeta } from "./RepositoryFetcher.js";

export class RemoteIndexer {
private index: Map<string, RepoFileMeta> = new Map();

constructor(private fetcher: RepositoryFetcher) {}
Comment on lines +5 to +8
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RemoteIndexer class lacks documentation. Consider adding JSDoc comments to explain:

  • The purpose and lifecycle of the class
  • When buildIndex() should be called
  • Whether buildIndex() can be called multiple times to refresh the index
  • Thread safety considerations if any

Copilot uses AI. Check for mistakes.

async buildIndex() {
const tree = await this.fetcher.getTree();
tree.forEach((item: RepoFileMeta) => this.index.set(item.path, item));
Comment on lines +11 to +12
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The buildIndex() method lacks error handling. If fetcher.getTree() fails (e.g., due to network issues, authentication problems, or rate limiting), the error will propagate uncaught. Consider adding try-catch with appropriate error handling or documenting that callers must handle errors.

Suggested change
const tree = await this.fetcher.getTree();
tree.forEach((item: RepoFileMeta) => this.index.set(item.path, item));
try {
const tree = await this.fetcher.getTree();
tree.forEach((item: RepoFileMeta) => this.index.set(item.path, item));
} catch (error) {
// Handle error appropriately: log and rethrow, or handle as needed
console.error("Failed to build index:", error);
throw error;
}

Copilot uses AI. Check for mistakes.
}

getIndex() {
return this.index;
}

fileExists(path: string) {
return this.index.has(path);
}
}
13 changes: 13 additions & 0 deletions src/repo/RepositoryFetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// src/repo/RepositoryFetcher.ts

export interface RepoFileMeta {
path: string;
sha: string;
type: "blob" | "tree";
size?: number;
}

export interface RepositoryFetcher {
getTree(): Promise<RepoFileMeta[]>; // indexing
getFileContent(path: string): Promise<string>; // actual file fetching
Comment on lines +3 to +12
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RepositoryFetcher interface and RepoFileMeta interface lack documentation. Consider adding JSDoc comments to explain:

  • The purpose of each method
  • What each property in RepoFileMeta represents
  • When size might be undefined
  • Expected error conditions and how they should be handled
Suggested change
export interface RepoFileMeta {
path: string;
sha: string;
type: "blob" | "tree";
size?: number;
}
export interface RepositoryFetcher {
getTree(): Promise<RepoFileMeta[]>; // indexing
getFileContent(path: string): Promise<string>; // actual file fetching
/**
* Metadata about a file or directory in a repository.
*/
export interface RepoFileMeta {
/**
* The path of the file or directory relative to the repository root.
*/
path: string;
/**
* The SHA hash of the file or directory.
*/
sha: string;
/**
* The type of the entry: "blob" for files, "tree" for directories.
*/
type: "blob" | "tree";
/**
* The size of the file in bytes. May be undefined for directories ("tree") or if the size is not available.
*/
size?: number;
}
/**
* Interface for fetching repository data such as file trees and file contents.
*/
export interface RepositoryFetcher {
/**
* Fetches the file tree of the repository.
* @returns A promise that resolves to an array of RepoFileMeta objects representing files and directories.
* @throws May reject if the repository cannot be accessed or the tree cannot be fetched.
*/
getTree(): Promise<RepoFileMeta[]>;
/**
* Fetches the content of a file at the given path.
* @param path - The path to the file relative to the repository root.
* @returns A promise that resolves to the file content as a string.
* @throws May reject if the file does not exist, cannot be accessed, or if there is a network or permission error.
*/
getFileContent(path: string): Promise<string>;

Copilot uses AI. Check for mistakes.
}
30 changes: 30 additions & 0 deletions src/repo/repo-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// src/repo/repo-setup.ts

import { GitHubRepositoryFetcher } from "./GitHubRepositoryFetcher.js";
import { RemoteIndexer } from "./RemoteIndexer.js";
import { createFetchFromRepoTool } from "../tools/fetch-from-repo.js";
import { createDesignRepoContextResource } from "../resources/design-repo-context.js";

export interface RepoConfig {
owner: string;
repo: string;
branch: string;
token: string;
}

export async function setupRemoteRepoMCP(config: RepoConfig) {
const fetcher = new GitHubRepositoryFetcher(
config.owner,
config.repo,
config.branch,
config.token,
);

const indexer = new RemoteIndexer(fetcher);
await indexer.buildIndex(); // build repo map

const fetchFromRepoTool = createFetchFromRepoTool(indexer, fetcher);
const contextResource = createDesignRepoContextResource(indexer, fetcher);

return { fetchFromRepoTool, contextResource, indexer };
}
47 changes: 47 additions & 0 deletions src/resources/design-repo-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// src/resources/design-repo-context.ts

import { RemoteIndexer } from "../repo/RemoteIndexer.js";
import { RepositoryFetcher } from "../repo/RepositoryFetcher.js";

export function createDesignRepoContextResource(
indexer: RemoteIndexer,
fetcher: RepositoryFetcher,
) {
return {
uri: "repo://design-context",
name: "Design Repository Context",
description:
"Provides context from the remote design repository including tokens, components, and documentation",
mimeType: "text/plain",
handler: async () => {
const index = indexer.getIndex();

// Automatically select relevant files
const relevantFiles = [...index.keys()].filter((path) =>
path.match(/(tokens|design|components|docs).*\.(json|md|tsx?|css)$/),
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter regex will only match files in subdirectories containing the keywords (tokens, design, components, docs). Files like tokens.json or design.md at the root level won't match because the regex expects a path separator before the keywords. Consider updating the regex to: /(^|\/)((tokens|design|components|docs).*\.(json|md|tsx?|css))$/ or simplifying to match keywords anywhere in the path.

Suggested change
path.match(/(tokens|design|components|docs).*\.(json|md|tsx?|css)$/),
path.match(/(^|\/)((tokens|design|components|docs).*\.(json|md|tsx?|css))$/),

Copilot uses AI. Check for mistakes.
);

const parts: string[] = [];

// Limit for safety and performance
for (const path of relevantFiles.slice(0, 10)) {
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded limit of 10 files lacks documentation explaining why this value was chosen. Consider adding a comment explaining the rationale (e.g., token limits, performance constraints) or making it configurable. Also consider logging when files are truncated so users know content is being omitted.

Copilot uses AI. Check for mistakes.
try {
const content = await fetcher.getFileContent(path);
parts.push(`### File: ${path}\n\`\`\`\n${content}\n\`\`\``);
} catch (error: any) {
parts.push(`### File: ${path}\nError: ${error.message}`);
Comment on lines +31 to +32
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using any type for the error weakens type safety. Consider using error: unknown and then type-narrowing to check for .message property, or use a more specific error type.

Suggested change
} catch (error: any) {
parts.push(`### File: ${path}\nError: ${error.message}`);
} catch (error: unknown) {
let errorMessage = "Unknown error";
if (typeof error === "object" && error !== null && "message" in error && typeof (error as any).message === "string") {
errorMessage = (error as { message: string }).message;
}
parts.push(`### File: ${path}\nError: ${errorMessage}`);

Copilot uses AI. Check for mistakes.
}
}

return {
contents: [
{
uri: "repo://design-context",
mimeType: "text/plain",
text: `# Client Repository Context\n\n${parts.join("\n\n")}`,
},
],
};
},
};
}
2 changes: 2 additions & 0 deletions src/server-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import addBuildInsightsTools from "./tools/build-insights.js";
import { setupOnInitialized } from "./oninitialized.js";
import { BrowserStackConfig } from "./lib/types.js";
import addRCATools from "./tools/rca-agent.js";
import addRemoteRepoTools from "./tools/remote-repo.js";

/**
* Wrapper class for BrowserStack MCP Server
Expand Down Expand Up @@ -61,6 +62,7 @@ export class BrowserStackMcpServer {
addSelfHealTools,
addBuildInsightsTools,
addRCATools,
addRemoteRepoTools,
];

toolAdders.forEach((adder) => {
Expand Down
50 changes: 50 additions & 0 deletions src/tools/fetch-from-repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// src/tools/fetch-from-repo.ts

import { z } from "zod";
import { RemoteIndexer } from "../repo/RemoteIndexer.js";
import { RepositoryFetcher } from "../repo/RepositoryFetcher.js";

export function createFetchFromRepoTool(
indexer: RemoteIndexer,
fetcher: RepositoryFetcher,
) {
return {
name: "fetchFromRepo",
description: "Fetch a file from a remote repository that has been indexed",
inputSchema: z.object({
path: z.string().describe("The file path in the repository"),
}),
handler: async ({ path }: { path: string }) => {
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing input validation for the path parameter. An empty string or path with special characters could lead to unexpected behavior or security issues. Consider validating that:

  • Path is not empty
  • Path doesn't contain path traversal sequences (../)
  • Path uses forward slashes consistently
Suggested change
handler: async ({ path }: { path: string }) => {
handler: async ({ path }: { path: string }) => {
// Validate path input
if (
typeof path !== "string" ||
path.trim() === "" ||
path.includes("..") ||
path.includes("\\") ||
!path.startsWith("/") && !path.match(/^[a-zA-Z0-9_\-./]+$/)
) {
return {
content: [
{
type: "text" as const,
text: `Error: Invalid file path.`,
},
],
};
}

Copilot uses AI. Check for mistakes.
if (!indexer.fileExists(path)) {
return {
content: [
{
type: "text" as const,
text: `Error: File not found in index: ${path}`,
},
],
};
}
try {
const content = await fetcher.getFileContent(path);
return {
content: [
{
type: "text" as const,
text: `File: ${path}\n\n${content}`,
},
],
};
} catch (error: any) {
return {
content: [
{
type: "text" as const,
text: `Error fetching file: ${error.message}`,
Comment on lines +38 to +43
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using any type for the error weakens type safety. Consider using error: unknown and then type-narrowing to check for .message property, or use a more specific error type.

Suggested change
} catch (error: any) {
return {
content: [
{
type: "text" as const,
text: `Error fetching file: ${error.message}`,
} catch (error: unknown) {
let errorMessage = "Unknown error";
if (
typeof error === "object" &&
error !== null &&
"message" in error &&
typeof (error as { message?: unknown }).message === "string"
) {
errorMessage = (error as { message: string }).message;
}
return {
content: [
{
type: "text" as const,
text: `Error fetching file: ${errorMessage}`,

Copilot uses AI. Check for mistakes.
},
],
};
}
},
};
}
74 changes: 74 additions & 0 deletions src/tools/remote-repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// src/tools/remote-repo.ts

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { BrowserStackConfig } from "../lib/types.js";
import { setupRemoteRepoMCP } from "../repo/repo-setup.js";
import logger from "../logger.js";

/**
* Adds remote repository tools (if configured)
*/
export default function addRemoteRepoTools(
server: McpServer,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_config: BrowserStackConfig,
Comment on lines +13 to +14
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The _config parameter is declared but unused, with an eslint-disable comment. If the config parameter is not needed for this function, consider removing it entirely rather than keeping it with a disable comment.

Suggested change
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_config: BrowserStackConfig,

Copilot uses AI. Check for mistakes.
): Record<string, any> {
const tools: Record<string, any> = {};

// Check if GitHub token is configured
const githubToken = process.env.GITHUB_TOKEN;
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GitHub token is passed directly from environment variables without any sanitization or security checks. Consider:

  1. Validating the token format (GitHub tokens have specific prefixes like ghp_, gho_, etc.)
  2. Ensuring tokens are not logged accidentally
  3. Adding a warning about token scope requirements in documentation

Copilot uses AI. Check for mistakes.
const repoOwner = process.env.GITHUB_REPO_OWNER;
const repoName = process.env.GITHUB_REPO_NAME;
const repoBranch = process.env.GITHUB_REPO_BRANCH || "main";

if (!githubToken || !repoOwner || !repoName) {
logger.info(
"Remote repository integration not configured. Skipping remote repo tools.",
);
return tools;
}
Comment on lines +24 to +29
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation for the GitHub token and repository configuration. If the token is invalid or doesn't have the necessary permissions, the error will only surface when setupRemoteRepoMCP fails. Consider validating the token format and checking basic repository access before proceeding with setup.

Copilot uses AI. Check for mistakes.

logger.info(
"Setting up remote repository integration for %s/%s (branch: %s)",
repoOwner,
repoName,
repoBranch,
);

// Set up remote repo asynchronously
setupRemoteRepoMCP({
owner: repoOwner,
repo: repoName,
branch: repoBranch,
token: githubToken,
})
.then(({ fetchFromRepoTool, contextResource, indexer }) => {
// Register the fetch tool
const registeredTool = server.tool(
fetchFromRepoTool.name,
fetchFromRepoTool.description,
fetchFromRepoTool.inputSchema.shape,
fetchFromRepoTool.handler,
);

tools[fetchFromRepoTool.name] = registeredTool;

// Register the context resource
server.resource(contextResource.name, contextResource.uri, async () =>
contextResource.handler(),
);

logger.info(
"Remote repository tools registered successfully. Indexed %d files.",
indexer.getIndex().size,
);
})
.catch((error) => {
logger.error(
"Failed to set up remote repository integration: %s",
error.message,
);
});

return tools;
Comment on lines +38 to +73
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setupRemoteRepoMCP function is called asynchronously with .then()/.catch(), but the addRemoteRepoTools function returns immediately with an empty tools object. This means that when the server tries to use these tools, they won't be registered yet, leading to a race condition. The tools will only be added to the local tools object after the promise resolves, but the returned object is already passed to the caller.

Consider either:

  1. Making addRemoteRepoTools async and awaiting the setup
  2. Returning a promise that resolves when tools are ready
  3. Registering tools synchronously and deferring only the indexing operation

Copilot uses AI. Check for mistakes.
}