diff --git a/src/api/routes.ts b/src/api/routes.ts index 2d53bc620a..92aa9dfea3 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -155,6 +155,7 @@ import { } from "../services/mcp-compatibility"; import { buildOperatorDashboardPayload } from "../services/operator-dashboard"; import { buildSelfDogfoodRegistrationPack, resolveSelfDogfoodRepoFullName } from "../services/self-dogfood-registration-pack"; +import { buildSubnetInterfaceDescriptor } from "../services/subnet-interface"; import { buildWeeklyValueReport, formatWeeklyValueReportMarkdown, @@ -674,6 +675,14 @@ export function createApp() { app.get("/openapi.json", (c) => c.json(buildOpenApiSpec())); app.all("/mcp", handleMcpRequest); + // Public SN74 contribution-interface descriptor (#695): metagraphed (and any agent) fetches this to route + // gittensor discovery → Gittensory. Unauthenticated product metadata; excluded from requiresApiToken below. + app.get("/v1/public/subnet-interface", (c) => { + const origin = c.env.PUBLIC_API_ORIGIN ?? new URL(c.req.url).origin; + c.header("Cache-Control", "public, max-age=600, stale-while-revalidate=86400"); + return c.json(buildSubnetInterfaceDescriptor({ origin, generatedAt: nowIso(), appSlug: c.env.GITHUB_APP_SLUG, upstreamRepo: c.env.GITTENSOR_UPSTREAM_REPO })); + }); + app.get("/v1/public/github/repos/:owner/:repo/stats", async (c) => { try { const stats = await fetchPublicRepoStats(c.env, c.req.param("owner"), c.req.param("repo")); @@ -4144,6 +4153,7 @@ function requiresApiToken(path: string): boolean { if (path === "/health") return false; if (path === "/v1/mcp/compatibility") return false; if (/^\/v1\/public\/github\/repos\/[^/]+\/[^/]+\/stats$/.test(path)) return false; + if (path === "/v1/public/subnet-interface") return false; if (path === "/openapi.json") return false; if (path === "/mcp") return false; if (path.startsWith("/v1/auth/")) return false; diff --git a/src/services/subnet-interface.ts b/src/services/subnet-interface.ts new file mode 100644 index 0000000000..ae5c02238c --- /dev/null +++ b/src/services/subnet-interface.ts @@ -0,0 +1,91 @@ +import { GITTENSOR_HOME_URL, GITTENSORY_SITE_URL } from "../github/footer"; +import { GITTENSORY_MCP_PACKAGE_NAME, LATEST_RECOMMENDED_MCP_VERSION, MINIMUM_SUPPORTED_MCP_VERSION } from "./mcp-compatibility"; + +// Gittensor is Bittensor subnet 74 (the code subnet). Gittensory is its contribution interface. +export const GITTENSOR_NETUID = 74; +const DEFAULT_GITTENSOR_UPSTREAM_REPO = "entrius/gittensor"; +const SUBNET_INTERFACE_SCHEMA_VERSION = "1.0"; + +// Curated, contribution-relevant MCP tools surfaced to agents/devs who discover gittensor via metagraphed. +// Names mirror src/mcp/server.ts registrations; the list is intentionally a miner-facing subset (not all 33). +const CONTRIBUTION_MCP_TOOLS: ReadonlyArray<{ name: string; summary: string }> = [ + { name: "gittensory_get_decision_pack", summary: "Rank high-fit, low-duplicate issues to contribute to across registered repos." }, + { name: "gittensory_check_before_start", summary: "Check whether an issue is already claimed or solved before writing code." }, + { name: "gittensory_validate_linked_issue", summary: "Confirm that linking an issue will earn the linked-issue scoring multiplier." }, + { name: "gittensory_preflight_pr", summary: "Preflight a planned PR for lane fit, duplicate risk, and review burden." }, + { name: "gittensory_monitor_open_prs", summary: "Track your open PRs and what to clean up first." }, + { name: "gittensory_list_notifications", summary: "See review feedback (e.g. changes requested) on your PRs." }, + { name: "gittensory_agent_plan_next_work", summary: "Deterministically rank your next gittensor contribution actions." }, +]; + +const ONBOARDING_STEPS: ReadonlyArray = [ + "Maintainers: install the Gittensory GitHub App on a gittensor-registered repository.", + "Contributors (miners): connect the Gittensory MCP endpoint in your agent harness (Claude Code, Cursor, etc.).", + "Use gittensory_get_decision_pack to find high-fit, low-duplicate issues, then gittensory_check_before_start before writing code.", + "Preflight with gittensory_preflight_pr and open a focused PR linked to its issue.", +]; + +export type SubnetInterfaceDescriptor = { + schemaVersion: string; + generatedAt: string; + subnet: { netuid: number; name: string; home: string; upstreamRepo: string }; + provider: { name: string; role: "contribution_interface"; site: string; summary: string }; + interfaces: { + mcp: { + kind: "mcp"; + transport: "http"; + endpoint: string; + package: string; + minimumVersion: string; + recommendedVersion: string; + tools: Array<{ name: string; summary: string }>; + }; + githubApp: { kind: "github_app"; slug: string; installUrl: string }; + }; + onboarding: { docs: string; steps: string[] }; +}; + +/** + * Machine-readable descriptor declaring Gittensory as gittensor (subnet 74)'s contribution interface, so + * metagraphed (and any agent) can route discovery → contribution (#695). Pure product metadata (URLs, tool + * names) — no private/reward/score wording, so it never needs sanitization. Public + unauthenticated. + */ +export function buildSubnetInterfaceDescriptor(args: { origin: string; generatedAt: string; appSlug: string; upstreamRepo?: string | undefined }): SubnetInterfaceDescriptor { + const origin = args.origin.replace(/\/+$/, ""); + return { + schemaVersion: SUBNET_INTERFACE_SCHEMA_VERSION, + generatedAt: args.generatedAt, + subnet: { + netuid: GITTENSOR_NETUID, + name: "gittensor", + home: GITTENSOR_HOME_URL, + upstreamRepo: args.upstreamRepo ?? DEFAULT_GITTENSOR_UPSTREAM_REPO, + }, + provider: { + name: "Gittensory", + role: "contribution_interface", + site: GITTENSORY_SITE_URL, + summary: "Gittensor-native contribution quality & planning layer: deterministic signals for miners and a free anti-slop + AI second-opinion gate for maintainers.", + }, + interfaces: { + mcp: { + kind: "mcp", + transport: "http", + endpoint: `${origin}/mcp`, + package: GITTENSORY_MCP_PACKAGE_NAME, + minimumVersion: MINIMUM_SUPPORTED_MCP_VERSION, + recommendedVersion: LATEST_RECOMMENDED_MCP_VERSION, + tools: CONTRIBUTION_MCP_TOOLS.map((tool) => ({ ...tool })), + }, + githubApp: { + kind: "github_app", + slug: args.appSlug, + installUrl: `https://github.com/apps/${args.appSlug}`, + }, + }, + onboarding: { + docs: GITTENSORY_SITE_URL, + steps: [...ONBOARDING_STEPS], + }, + }; +} diff --git a/test/integration/subnet-interface.test.ts b/test/integration/subnet-interface.test.ts new file mode 100644 index 0000000000..c27bd86c50 --- /dev/null +++ b/test/integration/subnet-interface.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createTestEnv } from "../helpers/d1"; + +describe("public subnet-interface descriptor route", () => { + it("serves the SN74 contribution-interface descriptor without authentication", async () => { + const app = createApp(); + const env = createTestEnv({ GITHUB_APP_SLUG: "gittensory", PUBLIC_API_ORIGIN: "https://gittensory-api.aethereal.dev" }); + + const response = await app.request("/v1/public/subnet-interface", {}, env); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toContain("max-age=600"); + await expect(response.json()).resolves.toMatchObject({ + schemaVersion: "1.0", + subnet: { netuid: 74, name: "gittensor" }, + provider: { name: "Gittensory", role: "contribution_interface" }, + interfaces: { + mcp: { endpoint: "https://gittensory-api.aethereal.dev/mcp", transport: "http" }, + githubApp: { slug: "gittensory", installUrl: "https://github.com/apps/gittensory" }, + }, + }); + }); + + it("falls back to the request origin when PUBLIC_API_ORIGIN is unset", async () => { + const app = createApp(); + const env = createTestEnv({ GITHUB_APP_SLUG: "gittensory" }); + delete (env as Partial).PUBLIC_API_ORIGIN; + + const response = await app.request("https://fallback.example/v1/public/subnet-interface", {}, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + interfaces: { mcp: { endpoint: "https://fallback.example/mcp" } }, + }); + }); +}); diff --git a/test/unit/subnet-interface.test.ts b/test/unit/subnet-interface.test.ts new file mode 100644 index 0000000000..9ac29bbae5 --- /dev/null +++ b/test/unit/subnet-interface.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { buildSubnetInterfaceDescriptor, GITTENSOR_NETUID } from "../../src/services/subnet-interface"; + +describe("buildSubnetInterfaceDescriptor", () => { + it("declares Gittensory as gittensor SN74's contribution interface", () => { + const descriptor = buildSubnetInterfaceDescriptor({ + origin: "https://gittensory-api.aethereal.dev/", + generatedAt: "2026-06-14T00:00:00.000Z", + appSlug: "gittensory", + upstreamRepo: "entrius/gittensor", + }); + + expect(GITTENSOR_NETUID).toBe(74); + expect(descriptor.subnet).toMatchObject({ netuid: 74, name: "gittensor", upstreamRepo: "entrius/gittensor" }); + expect(descriptor.provider).toMatchObject({ name: "Gittensory", role: "contribution_interface" }); + // Trailing slash on origin is normalized before appending /mcp. + expect(descriptor.interfaces.mcp.endpoint).toBe("https://gittensory-api.aethereal.dev/mcp"); + expect(descriptor.interfaces.mcp.transport).toBe("http"); + expect(descriptor.interfaces.githubApp.installUrl).toBe("https://github.com/apps/gittensory"); + + const toolNames = descriptor.interfaces.mcp.tools.map((tool) => tool.name); + expect(toolNames).toContain("gittensory_get_decision_pack"); + expect(toolNames).toContain("gittensory_list_notifications"); + expect(descriptor.interfaces.mcp.tools.every((tool) => tool.summary.length > 0)).toBe(true); + expect(descriptor.onboarding.steps.length).toBeGreaterThan(0); + }); + + it("defaults the upstream repo when not provided and contains no private/reward wording", () => { + const descriptor = buildSubnetInterfaceDescriptor({ origin: "https://x.dev", generatedAt: "2026-06-14T00:00:00.000Z", appSlug: "gittensory" }); + expect(descriptor.subnet.upstreamRepo).toBe("entrius/gittensor"); + expect(JSON.stringify(descriptor)).not.toMatch(/wallet|hotkey|reward|payout|trust score|scoreability|ranking/i); + }); +});