Skip to content
Closed
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
289 changes: 289 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { ElicitResultSchema, type ServerNotification, type ServerRequest } from "@modelcontextprotocol/sdk/types.js";
import { DEFAULT_MINER_GOAL_SPEC, rankOpportunities, type MinerGoalSpec, type OpportunityRankInput } from "@jsonbored/gittensory-engine";

Check failure on line 6 in src/mcp/server.ts

View workflow job for this annotation

GitHub Actions / validate-code

Cannot find module '@jsonbored/gittensory-engine' or its corresponding type declarations.
import { z } from "zod";
import { authenticatePrivateToken, extractBearerToken, isMcpActuationRepoAllowed, isMcpReadRepoAllowed, isMcpReadUnscoped, type AuthIdentity } from "../auth/security";
import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles";
Expand Down Expand Up @@ -101,6 +102,7 @@
buildQueueHealth,
buildRegistryChangeReport,
buildRoleContext,
type LaneAdvice,
} from "../signals/engine";
import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor";
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
Expand All @@ -125,6 +127,7 @@
import { PREFLIGHT_LIMITS } from "../signals/preflight-limits";
import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model";
import { loadUpstreamStatus } from "../upstream/ruleset";
import type { IssueRecord, PullRequestRecord, RepositoryRecord } from "../types";

type AppContext = Context<{ Bindings: Env }>;
type ToolPayload = {
Expand All @@ -133,6 +136,18 @@
};
type McpToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;

const FIND_OPPORTUNITIES_DEFAULT_LIMIT = 10;
const FIND_OPPORTUNITIES_MAX_LIMIT = 25;
const FIND_OPPORTUNITIES_MAX_TARGETS = 25;
const FIND_OPPORTUNITIES_MAX_SEARCH_REPOS = 50;
const FIND_OPPORTUNITY_LANE_FIT: Record<LaneAdvice["lane"], number> = {
direct_pr: 1,
split: 0.9,
issue_discovery: 0.65,
inactive: 0.25,
unknown: 0.25,
};

function decisionPackSummary(login: string, freshness: string, rebuildEnqueued: boolean): string {
if (freshness === "fresh") return `Gittensory decision pack for ${login}.`;
if (rebuildEnqueued) return `Gittensory decision pack for ${login} (stale; background rebuild enqueued).`;
Expand Down Expand Up @@ -197,6 +212,34 @@
plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(),
};

const minerGoalSpecShape = z
.object({
minerEnabled: z.boolean().optional(),
wantedPaths: z.array(z.string().min(1).max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(),
blockedPaths: z.array(z.string().min(1).max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(),
preferredLabels: z.array(z.string().min(1).max(PREFLIGHT_LIMITS.labelChars)).max(PREFLIGHT_LIMITS.labels).optional(),
maxConcurrentClaims: z.number().int().positive().optional(),
issueDiscoveryPolicy: z.enum(["encouraged", "neutral", "discouraged"]).optional(),
})
.strict();

const findOpportunitiesShape = {
targets: z
.array(
z
.object({
owner: z.string().min(1).max(100),
repo: z.string().min(1).max(100),
})
.strict(),
)
.max(FIND_OPPORTUNITIES_MAX_TARGETS)
.optional(),
searchQuery: z.string().min(1).max(200).optional(),
goalSpec: minerGoalSpecShape.optional(),
limit: z.number().int().min(1).max(FIND_OPPORTUNITIES_MAX_LIMIT).optional(),
};

const lintPrTextShape = {
commitMessages: z.array(z.string().max(PREFLIGHT_LIMITS.bodyChars)).max(50).optional(),
prBody: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(),
Expand Down Expand Up @@ -860,6 +903,26 @@
report: z.unknown().optional(),
};

const findOpportunitiesOutputSchema = {
source: z.literal("cached_metadata"),
searchedRepositories: z.number(),
candidateCount: z.number(),
opportunities: z.array(
z.object({
owner: z.string(),
repo: z.string(),
issueNumber: z.number(),
title: z.string(),
rankScore: z.number(),
laneFit: z.number(),
freshness: z.number(),
dupRisk: z.number(),
aiPolicyAllowed: z.literal(true),
}),
),
warnings: z.array(z.string()).optional(),
};

const remediationPlanOutputSchema = {
repoFullName: z.string().optional(),
login: z.string().optional(),
Expand Down Expand Up @@ -1000,6 +1063,144 @@
topAction: z.unknown().optional(),
};

type FindOpportunitiesInput = z.infer<z.ZodObject<typeof findOpportunitiesShape>>;
type FindOpportunitiesGoalSpec = NonNullable<FindOpportunitiesInput["goalSpec"]>;
type FindOpportunityRecord = {
owner: string;
repo: string;
issueNumber: number;
title: string;
rankScore: number;
laneFit: number;
freshness: number;
dupRisk: number;
aiPolicyAllowed: true;
};
type FindOpportunityCandidate = Omit<FindOpportunityRecord, "rankScore"> & OpportunityRankInput;

function boundedOpportunityLimit(limit: number | undefined): number {
return Math.min(FIND_OPPORTUNITIES_MAX_LIMIT, Math.max(1, limit ?? FIND_OPPORTUNITIES_DEFAULT_LIMIT));
}

function normalizedFindOpportunityTargets(targets: FindOpportunitiesInput["targets"]): Array<{ owner: string; repo: string; fullName: string }> {
const seen = new Set<string>();
return (targets ?? []).flatMap((target) => {
const owner = target.owner.trim();
const repo = target.repo.trim();
const fullName = `${owner}/${repo}`;
const key = fullName.toLowerCase();
if (!owner || !repo || seen.has(key)) return [];
seen.add(key);
return [{ owner, repo, fullName }];
});
}

function normalizedSearchTerms(searchQuery: string | undefined): string[] {
return (searchQuery ?? "")
.toLowerCase()
.split(/\s+/)
.map((term) => term.trim())
.filter(Boolean);
}

function issueMatchesSearch(issue: IssueRecord, repo: RepositoryRecord, terms: string[]): boolean {
if (terms.length === 0) return true;
const haystack = [repo.fullName, issue.title, issue.body ?? "", ...issue.labels].join(" ").toLowerCase();
return terms.every((term) => haystack.includes(term));
}

function hasLabelOverlap(left: readonly string[] | undefined, right: readonly string[]): boolean {
const wanted = new Set((left ?? []).map((label) => label.toLowerCase()));
return wanted.size > 0 && right.some((label) => wanted.has(label.toLowerCase()));
}

function signal(value: number): number {
return Math.round(Math.min(1, Math.max(0, value)) * 10_000) / 10_000;
}

function normalizedMinerGoalSpec(goalSpec: FindOpportunitiesGoalSpec | undefined): MinerGoalSpec {
return {
minerEnabled: goalSpec?.minerEnabled ?? DEFAULT_MINER_GOAL_SPEC.minerEnabled,
wantedPaths: goalSpec?.wantedPaths ?? DEFAULT_MINER_GOAL_SPEC.wantedPaths,
blockedPaths: goalSpec?.blockedPaths ?? DEFAULT_MINER_GOAL_SPEC.blockedPaths,
preferredLabels: goalSpec?.preferredLabels ?? DEFAULT_MINER_GOAL_SPEC.preferredLabels,
maxConcurrentClaims: goalSpec?.maxConcurrentClaims ?? DEFAULT_MINER_GOAL_SPEC.maxConcurrentClaims,
issueDiscoveryPolicy: goalSpec?.issueDiscoveryPolicy ?? DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy,
};
}

function issueFreshness(issue: IssueRecord, nowMs: number): number {
const timestamp = Date.parse(String(issue.updatedAt));
if (!Number.isFinite(timestamp)) return 0.5;
const ageDays = Math.max(0, (nowMs - timestamp) / 86_400_000);
return signal(1 - Math.min(0.9, ageDays / 100));
}

function issueDupRisk(issue: IssueRecord, openPullRequests: PullRequestRecord[]): number {
const linked = issue.linkedPrs.length > 0 || openPullRequests.some((pullRequest) => pullRequest.linkedIssues.includes(issue.number));
if (linked) return 1;
return signal(openPullRequests.length / 10);
}

function issueLaneFit(repo: RepositoryRecord, issue: IssueRecord, goalSpec: MinerGoalSpec): number {
const lane = buildLaneAdvice(repo, repo.fullName).lane;
const base = FIND_OPPORTUNITY_LANE_FIT[lane];
const policyAdjustment = goalSpec.issueDiscoveryPolicy === "discouraged" && lane === "issue_discovery" ? -0.3 : 0;
const labelAdjustment = hasLabelOverlap(goalSpec.preferredLabels, issue.labels) ? 0.1 : 0;
return signal(base + policyAdjustment + labelAdjustment);
}

function issuePotential(issue: IssueRecord, goalSpec: MinerGoalSpec): number {
const maintainerAuthored = ["OWNER", "MEMBER", "COLLABORATOR"].includes((issue.authorAssociation ?? "").toUpperCase());
const labelBoost = hasLabelOverlap(goalSpec.preferredLabels, issue.labels) ? 0.1 : 0;
return signal((maintainerAuthored ? 0.95 : 0.75) + labelBoost);
}

function issueFeasibility(issue: IssueRecord): number {
const labels = issue.labels.map((label) => label.toLowerCase());
if (labels.some((label) => /duplicate|invalid|wontfix|not planned|won't fix/.test(label))) return 0;
if (labels.some((label) => /needs[-\s]?proof|blocked|question/.test(label))) return 0.55;
return 0.9;
}

function buildFindOpportunityRecord(
repo: RepositoryRecord,
issue: IssueRecord,
openPullRequests: PullRequestRecord[],
goalSpec: MinerGoalSpec,
nowMs: number,
): FindOpportunityCandidate {
const laneFit = issueLaneFit(repo, issue, goalSpec);
const freshness = issueFreshness(issue, nowMs);
const dupRisk = issueDupRisk(issue, openPullRequests);
return {
owner: repo.owner,
repo: repo.name,
issueNumber: issue.number,
title: sanitizePublicComment(issue.title),
potential: issuePotential(issue, goalSpec),
feasibility: issueFeasibility(issue),
laneFit,
freshness,
dupRisk,
aiPolicyAllowed: true,
};
}

function toFindOpportunityRecord(candidate: FindOpportunityCandidate & { rankScore: number }): FindOpportunityRecord {
return {
owner: candidate.owner,
repo: candidate.repo,
issueNumber: candidate.issueNumber,
title: candidate.title,
rankScore: signal(candidate.rankScore),
laneFit: candidate.laneFit,
freshness: candidate.freshness,
dupRisk: candidate.dupRisk,
aiPolicyAllowed: true,
};
}

export async function handleMcpRequest(c: AppContext): Promise<Response> {
if (c.req.method === "OPTIONS") return new Response(null, { status: 204 });
const identity = await authenticateMcpRequest(c);
Expand Down Expand Up @@ -1343,6 +1544,17 @@
async (input) => this.toolResult(await this.checkBeforeStart(input)),
);

server.registerTool(
"gittensory_find_opportunities",
{
description:
"Find ranked contributor opportunities from cached repo and issue metadata. Metadata-only, no source upload, no GitHub writes.",
inputSchema: findOpportunitiesShape,
outputSchema: findOpportunitiesOutputSchema,
},
async (input) => this.toolResult(await this.findOpportunities(input)),
);

server.registerTool(
"gittensory_lint_pr_text",
{
Expand Down Expand Up @@ -2032,6 +2244,83 @@
};
}

private async findOpportunities(input: FindOpportunitiesInput): Promise<ToolPayload> {
const targets = normalizedFindOpportunityTargets(input.targets);
const searchTerms = normalizedSearchTerms(input.searchQuery);
if (targets.length === 0 && searchTerms.length === 0) throw new Error("targets_or_search_query_required");

const limit = boundedOpportunityLimit(input.limit);
const goalSpec = normalizedMinerGoalSpec(input.goalSpec);
if (!goalSpec.minerEnabled) {
return {
summary: "Gittensory found 0 ranked opportunity/opportunities because the supplied goalSpec disables miner targeting.",
data: {
source: "cached_metadata",
searchedRepositories: 0,
candidateCount: 0,
opportunities: [],
warnings: ["goalSpec.minerEnabled is false; no repositories were scanned."],
},
};
}

const repositoriesByName = new Map<string, RepositoryRecord>();
const warnings: string[] = [];
for (const target of targets) {
const repository = await getRepository(this.env, target.fullName);
if (!repository) {
warnings.push(`Skipping ${target.fullName}: repository is not cached.`);
continue;
}
repositoriesByName.set(repository.fullName.toLowerCase(), repository);
}
if (searchTerms.length > 0) {
const searchableRepositories = (await listRepositories(this.env))
.filter((candidate) => candidate.isRegistered)
.slice(0, FIND_OPPORTUNITIES_MAX_SEARCH_REPOS);
for (const repository of searchableRepositories) {
repositoriesByName.set(repository.fullName.toLowerCase(), repository);
}
}

const candidates: FindOpportunityCandidate[] = [];
let searchedRepositories = 0;
for (const repository of repositoriesByName.values()) {
if (!repository.isRegistered) {
warnings.push(`Skipping ${repository.fullName}: repository is not registered in the local cache.`);
continue;
}
if (!(await this.canAccessRepo(repository.fullName))) {
warnings.push(`Skipping ${repository.fullName}: caller cannot access cached repository metadata.`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Search mode leaks inaccessible cached repository names in warnings

When searchQuery is provided, the tool adds all registered repos to the scan set before access checks and then reveals their exact full names in warnings to unauthorized callers.

Move the access check before adding search-discovered repositories, or avoid naming inaccessible repos in warnings.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="src/mcp/server.ts">
<violation number="1" location="src/mcp/server.ts:2294">
<priority>medium</priority>
<title>Search mode leaks inaccessible cached repository names in warnings</title>
<evidence>In the findOpportunities method, when a searchQuery is provided, the code fetches all registered repositories via listRepositories(this.env), adds them to repositoriesByName without verifying caller access, and later emits a warning containing the repository fullName when the caller lacks access: warnings.push(`Skipping ${repository.fullName}: caller cannot access cached repository metadata.`). This allows an unauthorized caller to probe for the existence and exact names of private or restricted cached repositories.</evidence>
<recommendation>Move the access check (this.canAccessRepo) before adding search-discovered repositories into the scan set, or change the warning to a generic count-based message that does not include repository names the caller is not authorized to see.</recommendation>
</violation>
</file>

continue;
}
searchedRepositories += 1;
const [issues, openPullRequests] = await Promise.all([
listIssueSignalSample(this.env, repository.fullName),
listOpenPullRequests(this.env, repository.fullName),
]);
for (const issue of issues) {
if (!issueMatchesSearch(issue, repository, searchTerms)) continue;
const candidate = buildFindOpportunityRecord(repository, issue, openPullRequests, goalSpec, Date.now());
if (candidate.dupRisk >= 1) continue;
candidates.push(candidate);
}
}

const opportunities = rankOpportunities(candidates).filter((opportunity) => opportunity.rankScore > 0);

Check failure on line 2310 in src/mcp/server.ts

View workflow job for this annotation

GitHub Actions / validate-code

Parameter 'opportunity' implicitly has an 'any' type.
const ranked = opportunities.slice(0, limit).map(toFindOpportunityRecord);
return {
summary: `Gittensory found ${ranked.length} ranked opportunity/opportunities from cached metadata.`,
data: {
source: "cached_metadata",
searchedRepositories,
candidateCount: opportunities.length,
opportunities: ranked,
...(warnings.length > 0 ? { warnings } : {}),
},
};
}

private lintPrText(input: { commitMessages?: string[] | undefined; prBody?: string | undefined; linkedIssue?: number | undefined }): ToolPayload {
const report = buildPrTextLint(input);
return {
Expand Down
2 changes: 1 addition & 1 deletion test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4949,9 +4949,9 @@ describe("api routes", () => {
expect(toolNames).toContain("gittensory_agent_get_run");
expect(toolNames).toContain("gittensory_agent_explain_next_action");
expect(toolNames).toContain("gittensory_agent_prepare_pr_packet");
expect(toolNames).toContain("gittensory_find_opportunities");
for (const removed of [
"gittensory_get_contributor_fit",
"gittensory_find_opportunities",
"gittensory_get_contribution_strategy",
"gittensory_explain_reward_risk",
"gittensory_rank_next_actions",
Expand Down
Loading
Loading