Skip to content
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
32 changes: 32 additions & 0 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,15 @@ const findOpportunitiesShape = {
limit: z.number().int().min(1).max(50).optional(),
};

const issueRagShape = {
owner: z.string(),
repo: z.string(),
title: z.string(),
body: z.string().optional(),
labels: z.array(z.string()).optional(),
topK: z.number().int().min(1).max(12).optional(),
};

const lintPrTextShape = {
commitMessages: z.array(z.string()).max(50).optional(),
prBody: z.string().optional(),
Expand Down Expand Up @@ -380,6 +389,10 @@ const STDIO_TOOL_DESCRIPTORS = [
name: "gittensory_find_opportunities",
description: "Cross-repo discovery: find high-fit contribution opportunities across registered Gittensor repos. Returns a ranked, public-safe list filtered by your MinerGoalSpec (lane, min rank score, languages). Metadata-only, no GitHub writes.",
},
{
name: "gittensory_retrieve_issue_context",
description: "Repo-scoped issue-centric RAG retrieval for the miner analyze phase. Returns related file paths and retrieval scores from issue title/body/labels — metadata only, never source text.",
},
{
name: "gittensory_lint_pr_text",
description: "Lint a commit message + PR body against the gittensor traceability/no-issue-rationale and Conventional Commit rubric before submitting. Returns a deterministic verdict (strong/adequate/weak) plus specific public-safe fixes. No source upload.",
Expand Down Expand Up @@ -596,6 +609,25 @@ server.registerTool(
},
);

server.registerTool(
"gittensory_retrieve_issue_context",
{
description: stdioToolDescription("gittensory_retrieve_issue_context"),
inputSchema: issueRagShape,
},
async ({ owner, repo, title, body, labels, topK }) => {
const payload = {
owner,
repo,
title,
...(body ? { body } : {}),
...(labels && labels.length > 0 ? { labels } : {}),
...(topK != null ? { topK } : {}),
};
return toolResult("Gittensory issue-centric RAG context.", await apiPost("/v1/issue-rag/retrieve", payload));
},
);

server.registerTool(
"gittensory_lint_pr_text",
{
Expand Down
18 changes: 18 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ import {
loadControlPanelRoleSummary,
} from "../services/control-panel-roles";
import { runFindOpportunities, validateFindOpportunitiesInput, type FindOpportunitiesInput } from "../mcp/find-opportunities";
import { runIssueRagRetrieval, validateIssueRagInput, type IssueRagInput } from "../mcp/issue-rag";
import {
buildMcpCompatibilityMetadata,
LATEST_RECOMMENDED_MCP_VERSION,
Expand Down Expand Up @@ -2853,6 +2854,21 @@ export function createApp() {
return c.json(result);
});

app.post(ISSUE_RAG_RETRIEVE_PATH, async (c) => {
const identity = await authenticateRequestIdentity(c);
/* v8 ignore next -- Protected middleware rejects unauthenticated private routes before route-specific guards. */
if (!identity) return c.json({ error: "unauthorized" }, 401);
const body = await c.req.json().catch(() => null);
const parsed = validateIssueRagInput((body ?? {}) as IssueRagInput);
if (!parsed.ok) {
return c.json({ status: "invalid_request", repoFullName: "", reason: parsed.reason, telemetry: { attempted: false, injected: false, retrievedPaths: [] } }, 400);
}
const forbidden = await requireApiRepoReadAccess(c, identity, parsed.value.repoFullName);
if (forbidden) return forbidden;
const result = await runIssueRagRetrieval(c.env, parsed.value);
return c.json(result);
});

app.post("/v1/preflight/pr", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = preflightSchema.safeParse(body);
Expand Down Expand Up @@ -5211,6 +5227,7 @@ function contributorEvidenceFromProfile(profile: {
const EXTENSION_PULL_CONTEXT_PATH = "/v1/extension/pull-context";
const EXTENSION_PULL_CONTEXT_SCOPE = "extension:pull_context";
const OPPORTUNITIES_FIND_PATH = "/v1/opportunities/find";
const ISSUE_RAG_RETRIEVE_PATH = "/v1/issue-rag/retrieve";
const LINT_PR_TEXT_PATH = "/v1/lint/pr-text";
const VALIDATE_FOCUS_MANIFEST_PATH = "/v1/validate/focus-manifest";
const LINT_SLOP_RISK_PATH = "/v1/lint/slop-risk";
Expand Down Expand Up @@ -5280,6 +5297,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
if (isRepoAgentPendingActionsPath(path)) return true; // list-only: requireRepoMaintainer; decision POSTs require server tokens
if (isRepoContributorIssueDraftGeneratePath(path)) return true;
if (path === OPPORTUNITIES_FIND_PATH) return true;
if (path === ISSUE_RAG_RETRIEVE_PATH) return true;
if (path === LINT_PR_TEXT_PATH || path === VALIDATE_FOCUS_MANIFEST_PATH || path === LINT_SLOP_RISK_PATH || path === LINT_ISSUE_SLOP_PATH) return true;
if (path === EXTENSION_PULL_CONTEXT_PATH && isExtensionScopedSession(identity)) return true;
// Contributor extension scope reaches only `/v1/extension/contributors/<login>/*`; the handler's
Expand Down
1 change: 1 addition & 0 deletions src/auth/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function routeClassForPath(path: string): RateLimitClass {
path.includes("/miner-dashboard/refresh") ||
path.includes("/open-pr-monitor") ||
path === "/v1/opportunities/find" ||
path === "/v1/issue-rag/retrieve" ||
// Maintainer BYOK config: POST /ai-key and /linear-key both run PBKDF2 (100k iters) + an encrypted D1
// upsert per request.
/\/(?:ai-(?:key|review)|linear-key)$/.test(path) ||
Expand Down
106 changes: 106 additions & 0 deletions src/mcp/issue-rag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Hosted `gittensory_retrieve_issue_context` (#4293): metadata-only issue-centric RAG retrieval for the
// miner analyze phase. Composes `buildIssueRagQuery` and runs `retrieveContextWithMetrics` server-side
// via a hosted API round-trip (stdio MCP proxies to `/v1/issue-rag/retrieve`). Returns retrieved paths
// and scores only — never chunk bodies or source text.

import { buildIssueRagQuery } from "../../packages/gittensory-engine/src/issue-rag-query";
import { PREFLIGHT_LIMITS } from "../signals/preflight-limits";
import { emptyIssueRagTelemetry, normalizeIssueRagTopK, retrieveIssueRagContext, type IssueRagTelemetry } from "../review/issue-rag-retrieval";

export const MAX_ISSUE_RAG_OWNER_LENGTH = 39;
export const MAX_ISSUE_RAG_REPO_LENGTH = 100;

export type IssueRagInput = {
owner: string;
repo: string;
title: string;
body?: string | undefined;
labels?: string[] | undefined;
topK?: number | undefined;
};

export type IssueRagResult = {
status: "ok" | "invalid_request" | "query_too_short";
repoFullName: string;
reason?: string | undefined;
telemetry: IssueRagTelemetry;
};

function cleanLabels(labels: string[] | undefined): string[] | undefined {
if (!labels) return undefined;
const cleaned = labels.map((label) => label.trim()).filter(Boolean).slice(0, PREFLIGHT_LIMITS.labels);
return cleaned.length > 0 ? cleaned : undefined;
}

export function validateIssueRagInput(
input: IssueRagInput,
): { ok: true; value: IssueRagInput & { repoFullName: string } } | { ok: false; reason: string } {
const owner = typeof input.owner === "string" ? input.owner.trim() : "";
const repo = typeof input.repo === "string" ? input.repo.trim() : "";
const title = typeof input.title === "string" ? input.title.trim() : "";
if (!owner || !repo) return { ok: false, reason: "owner_and_repo_required" };
if (!title) return { ok: false, reason: "title_required" };
if (owner.length > MAX_ISSUE_RAG_OWNER_LENGTH) return { ok: false, reason: "owner_too_long" };
if (repo.length > MAX_ISSUE_RAG_REPO_LENGTH) return { ok: false, reason: "repo_too_long" };
if (title.length > PREFLIGHT_LIMITS.titleChars) return { ok: false, reason: "title_too_long" };
const body = typeof input.body === "string" ? input.body.slice(0, PREFLIGHT_LIMITS.bodyChars) : undefined;
const labels = cleanLabels(input.labels);
if (labels) {
for (const label of labels) {
if (label.length > PREFLIGHT_LIMITS.labelChars) return { ok: false, reason: "invalid_labels" };
}
}
const topK = input.topK;
if (topK !== undefined && (!Number.isFinite(topK) || topK < 1 || topK > 12)) {
return { ok: false, reason: "invalid_top_k" };
}
return {
ok: true,
value: {
owner,
repo,
title,
...(body !== undefined ? { body } : {}),
...(labels ? { labels } : {}),
...(topK !== undefined ? { topK: normalizeIssueRagTopK(topK) } : {}),
repoFullName: `${owner}/${repo}`,
},
};
}

export async function runIssueRagRetrieval(env: Env, input: IssueRagInput): Promise<IssueRagResult> {
const validated = validateIssueRagInput(input);
if (!validated.ok) {
return {
status: "invalid_request",
repoFullName: "",
reason: validated.reason,
telemetry: emptyIssueRagTelemetry(),
};
}
const { queryText } = buildIssueRagQuery({
title: validated.value.title,
body: validated.value.body,
labels: validated.value.labels,
});
if (!queryText) {
return {
status: "query_too_short",
repoFullName: validated.value.repoFullName,
reason: "issue_query_below_retrieval_floor",
telemetry: emptyIssueRagTelemetry(),
};
}
const retrieved = await retrieveIssueRagContext(env, {
repoFullName: validated.value.repoFullName,
title: validated.value.title,
body: validated.value.body,
labels: validated.value.labels,
topK: validated.value.topK,
});
return {
status: "ok",
repoFullName: retrieved.repoFullName,
telemetry: retrieved.telemetry,
};
}
68 changes: 68 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import {
runFindOpportunities,
validateFindOpportunitiesInput,
} from "./find-opportunities";
import {
MAX_ISSUE_RAG_OWNER_LENGTH,
MAX_ISSUE_RAG_REPO_LENGTH,
runIssueRagRetrieval,
validateIssueRagInput,
} from "./issue-rag";
import {
authenticatePrivateToken,
extractBearerToken,
Expand Down Expand Up @@ -248,6 +254,15 @@ const checkBeforeStartShape = {
plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(),
};

const issueRagShape = {
owner: z.string().max(MAX_ISSUE_RAG_OWNER_LENGTH),
repo: z.string().max(MAX_ISSUE_RAG_REPO_LENGTH),
title: z.string().max(PREFLIGHT_LIMITS.titleChars),
body: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(),
labels: z.array(z.string().max(PREFLIGHT_LIMITS.labelChars)).max(PREFLIGHT_LIMITS.labels).optional(),
topK: z.number().int().min(1).max(12).optional(),
};

const findOpportunitiesShape = {
targets: z
.array(
Expand Down Expand Up @@ -1055,6 +1070,26 @@ const checkBeforeStartOutputSchema = {
report: z.unknown().optional(),
};

const issueRagOutputSchema = {
status: z.string().optional(),
repoFullName: z.string().optional(),
reason: z.string().optional(),
telemetry: z
.object({
attempted: z.boolean().optional(),
injected: z.boolean().optional(),
candidates: z.number().optional(),
kept: z.number().optional(),
topScore: z.number().optional(),
minScore: z.number().optional(),
reranked: z.boolean().optional(),
injectedChars: z.number().optional(),
retrievedPathCount: z.number().optional(),
retrievedPaths: z.array(z.string()).optional(),
})
.optional(),
};

const findOpportunitiesOutputSchema = {
status: z.string().optional(),
ranked: z
Expand Down Expand Up @@ -1729,6 +1764,17 @@ export class GittensoryMcp {
async (input) => this.toolResult(await this.findOpportunities(input)),
);

server.registerTool(
"gittensory_retrieve_issue_context",
{
description:
"Metadata-only, repo-scoped issue-centric RAG retrieval for the miner analyze phase. Composes an embeddable query from issue title/body/labels and returns retrieved file paths plus retrieval scores — never chunk bodies or source text. Requires hosted Vectorize/D1; degrades to empty paths when unavailable.",
inputSchema: issueRagShape,
outputSchema: issueRagOutputSchema,
},
async (input) => this.toolResult(await this.retrieveIssueContext(input)),
);

server.registerTool(
"gittensory_lint_pr_text",
{
Expand Down Expand Up @@ -2555,6 +2601,28 @@ export class GittensoryMcp {
};
}

private async retrieveIssueContext(input: z.infer<z.ZodObject<typeof issueRagShape>>): Promise<ToolPayload> {
const validated = validateIssueRagInput(input);
if (!validated.ok) {
return {
summary: "Invalid issue-context retrieval request.",
data: { status: "invalid_request", repoFullName: "", reason: validated.reason, telemetry: { attempted: false, injected: false, retrievedPaths: [] } },
};
}
await this.requireRepoAccess(validated.value.repoFullName);
const result = await runIssueRagRetrieval(this.env, validated.value);
const pathCount = result.telemetry.retrievedPathCount;
return {
summary:
result.status === "query_too_short"
? "Issue query is below the retrieval floor; no RAG context was fetched."
: result.telemetry.injected
? `Gittensory retrieved metadata-only context for ${pathCount} related path${pathCount === 1 ? "" : "s"}.`
: "Gittensory found no issue-centric RAG context for this request.",
data: result as unknown as Record<string, unknown>,
};
}

/** Cross-repo search requires unscoped MCP read (wildcard allowlist) or operator/session authority. */
private async requireDiscoveryAccess(): Promise<void> {
if (this.identity.kind === "session") {
Expand Down
Loading