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
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import { VcsCommitService } from "./services/vcs/VcsCommitService"
import { VcsProviderRegistry } from "./services/vcs/VcsProviderRegistry"
import { VcsRepository } from "./services/vcs/VcsRepository"
import { VcsSyncQueue } from "./services/vcs/VcsSyncQueue"
import { VcsSourceService } from "./services/vcs/VcsSourceService"
import { API_CORS_OPTIONS } from "./lib/api-cors"

const HealthRouter = HttpRouter.use((router) => router.add("GET", "/health", HttpServerResponse.text("OK")))
Expand Down Expand Up @@ -250,6 +251,7 @@ const VcsServicesLive = Layer.mergeAll(
GithubConnectService.layer.pipe(Layer.provide(Layer.mergeAll(VcsDataLive, GithubAppClientLive))),
// Routed via VcsProviderRegistry so no provider module is imported directly.
VcsCommitService.layer.pipe(Layer.provide(Layer.mergeAll(VcsDataLive, VcsProviderRegistryLive))),
VcsSourceService.layer.pipe(Layer.provide(Layer.mergeAll(VcsDataLive, VcsProviderRegistryLive))),
).pipe(Layer.provideMerge(InfraLive))

export const MainLive = Layer.mergeAll(
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/mcp/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { registerSearchSessionsTool } from "./search-sessions"
import { registerGetSessionTranscriptTool } from "./get-session-transcript"
import { registerGetSessionTracesTool } from "./get-session-traces"
import { registerServiceMapTool } from "./service-map"
import { registerSourceCodeTools } from "./source-code"
import type { McpToolError, McpToolRegistrar, McpToolResult } from "./types"
import { registerUpdateDashboardTool } from "./update-dashboard"
import { registerUpdateDashboardWidgetTool } from "./update-dashboard-widget"
Expand Down Expand Up @@ -126,6 +127,7 @@ const collectMapleToolDefinitions = (): ReadonlyArray<MapleToolDefinition> => {
registerListServicesTool(registrar)
registerGetServiceTopOperationsTool(registrar)
registerGetInstrumentationRecommendationsTool(registrar)
registerSourceCodeTools(registrar)
registerListErrorIssuesTool(registrar)
registerTransitionErrorIssueTool(registrar)
registerSetIssueSeverityTool(registrar)
Expand Down
133 changes: 133 additions & 0 deletions apps/api/src/mcp/tools/source-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Effect, Schema } from "effect"
import { resolveTenant } from "@/mcp/lib/query-warehouse"
import { VcsSourceService } from "@/services/vcs/VcsSourceService"
import { optionalNumberParam, optionalStringParam, requiredStringParam, type McpToolRegistrar } from "./types"
import { McpQueryError, validationError } from "./types"

const MAX_SEARCH_RESULTS = 20
const DEFAULT_SEARCH_RESULTS = 10
const MAX_FILE_LINES = 400
const MAX_FILE_CHARS = 40_000

const toSourceError = (operation: string) => (error: { readonly message: string }) =>
new McpQueryError({ message: error.message, pipeName: operation, cause: error })

const unsafePath = (path: string): boolean =>
path.startsWith("/") || path.split("/").some((segment) => segment === "..")

export function registerSourceCodeTools(server: McpToolRegistrar) {
server.tool(
"list_source_repositories",
"List source repositories connected to this Maple organization. Use before source investigation when telemetry does not identify an exact vcs.repository.url.full. Returns only repositories the organization's GitHub App installation can access.",
Schema.Struct({}),
Effect.fn("McpTool.listSourceRepositories")(function* () {
const tenant = yield* resolveTenant
const source = yield* VcsSourceService
const repositories = yield* source
.listRepositories(tenant.orgId)
.pipe(Effect.mapError(toSourceError("list_source_repositories")))
const lines = [
`## Connected source repositories (${repositories.length})`,
...repositories.map(
(repo) =>
`- ${repo.fullName} — tracked ref \`${repo.trackedBranch}\`${repo.isArchived ? " (archived)" : ""}`,
),
]
return { content: [{ type: "text" as const, text: lines.join("\n") }] }
}),
)

server.tool(
"search_source_code",
"Search code in one connected source repository. Use exact exception text, function/class names, routes, span names, or log fragments from observed telemetry. Call read_source_file on promising paths. The repository must come from telemetry or list_source_repositories.",
Schema.Struct({
repository: requiredStringParam("Connected repository in owner/name form"),
query: requiredStringParam(
"Plain code or text to search for; do not include repo/org/user qualifiers",
),
path: optionalStringParam("Optional repository path to narrow the search"),
limit: optionalNumberParam(
`Maximum matches (default ${DEFAULT_SEARCH_RESULTS}, max ${MAX_SEARCH_RESULTS})`,
),
}),
Effect.fn("McpTool.searchSourceCode")(function* ({ repository, query, path, limit }) {
if (!query.trim() || query.length > 256 || /(?:^|\s)(?:repo|org|user):/i.test(query)) {
return validationError(
"query must be 1-256 characters of plain source text without repo:, org:, or user: qualifiers",
)
}
if (path && unsafePath(path)) return validationError("path must be repository-relative")
const requestedLimit = Math.min(
MAX_SEARCH_RESULTS,
Math.max(1, Math.floor(limit ?? DEFAULT_SEARCH_RESULTS)),
)
const tenant = yield* resolveTenant
const source = yield* VcsSourceService
const matches = yield* source
.searchCode(tenant.orgId, repository.trim(), query.trim(), {
...(path ? { path } : {}),
limit: requestedLimit,
})
.pipe(Effect.mapError(toSourceError("search_source_code")))
const lines = [`## Source search: ${repository}`, `Query: \`${query.trim()}\``, ""]
if (matches.length === 0) lines.push("No matching source files found.")
for (const match of matches) {
lines.push(`### ${match.path}`, `Blob: \`${match.sha}\``, `URL: ${match.htmlUrl}`)
for (const snippet of match.snippets.slice(0, 2))
lines.push("```", snippet.slice(0, 2_000), "```")
lines.push("")
}
return { content: [{ type: "text" as const, text: lines.join("\n") }] }
}),
)

server.tool(
"read_source_file",
"Read a bounded line range from a file in one connected repository. For incident causality, pass the exact deployed commit SHA from telemetry as ref when available; otherwise the repository's tracked branch is used and the result is not proof of deployed code.",
Schema.Struct({
repository: requiredStringParam("Connected repository in owner/name form"),
path: requiredStringParam("Repository-relative file path"),
ref: optionalStringParam("Branch, tag, or preferably the exact deployed commit SHA"),
start_line: optionalNumberParam("First 1-based line to return (default 1)"),
end_line: optionalNumberParam(`Last 1-based line to return (max ${MAX_FILE_LINES} lines)`),
}),
Effect.fn("McpTool.readSourceFile")(function* ({ repository, path, ref, start_line, end_line }) {
if (!path.trim() || unsafePath(path.trim()))
return validationError("path must be repository-relative")
const start = Math.max(1, Math.floor(start_line ?? 1))
const requestedEnd = Math.floor(end_line ?? start + MAX_FILE_LINES - 1)
if (requestedEnd < start)
return validationError("end_line must be greater than or equal to start_line")
const end = Math.min(requestedEnd, start + MAX_FILE_LINES - 1)
const tenant = yield* resolveTenant
const source = yield* VcsSourceService
const file = yield* source
.readFile(tenant.orgId, repository.trim(), path.trim(), ref?.trim() || undefined)
.pipe(Effect.mapError(toSourceError("read_source_file")))
if (file.content.includes("\u0000"))
return validationError("The requested file is binary and cannot be read as source text")
const allLines = file.content.split("\n")
const selected = allLines.slice(start - 1, end)
let rendered = selected.map((line, index) => `${start + index}: ${line}`).join("\n")
const charTruncated = rendered.length > MAX_FILE_CHARS
if (charTruncated) rendered = rendered.slice(0, MAX_FILE_CHARS)
const rangeEnd = Math.min(end, allLines.length)
const truncated = charTruncated || rangeEnd < allLines.length
return {
content: [
{
type: "text" as const,
text: [
`## ${repository}/${file.path}`,
`Ref: \`${file.ref}\` · Blob: \`${file.sha}\` · Lines: ${start}-${rangeEnd}/${allLines.length}${truncated ? " · truncated" : ""}`,
`URL: ${file.htmlUrl}`,
"```",
rendered,
"```",
].join("\n"),
},
],
}
}),
)
}
39 changes: 39 additions & 0 deletions apps/api/src/services/vcs/VcsProviderClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ export interface VcsWebhookRequest {
readonly rawBody: string
}

/** A provider-neutral code-search hit returned to the investigation layer. */
export interface VcsCodeSearchMatch {
readonly path: string
readonly sha: string
readonly htmlUrl: string
readonly snippets: ReadonlyArray<string>
}

/** A text source file fetched from a repository at an explicit ref. */
export interface VcsSourceFile {
readonly path: string
readonly sha: string
readonly htmlUrl: string
readonly size: number
readonly content: string
}

export interface VcsProviderClient {
readonly id: VcsProviderId

Expand Down Expand Up @@ -111,4 +128,26 @@ export interface VcsProviderClient {
Option.Option<CommitUpsertInput>,
VcsProviderError | VcsInstallationGoneError | VcsRepoUnavailableError
>

/** Search source within one repository visible to this installation. */
readonly searchCode: (
installation: VcsInstallation,
repo: VcsRepositoryRef,
query: string,
opts: { readonly path?: string; readonly limit: number },
) => Effect.Effect<
ReadonlyArray<VcsCodeSearchMatch>,
VcsProviderError | VcsInstallationGoneError | VcsRepoUnavailableError | VcsRateLimitedError
>

/** Fetch a UTF-8 source file. `Option.none` is an expected missing path/ref. */
readonly fetchSourceFile: (
installation: VcsInstallation,
repo: VcsRepositoryRef,
path: string,
ref: string,
) => Effect.Effect<
Option.Option<VcsSourceFile>,
VcsProviderError | VcsInstallationGoneError | VcsRepoUnavailableError
>
}
110 changes: 110 additions & 0 deletions apps/api/src/services/vcs/VcsSourceService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { assert, describe, it } from "@effect/vitest"
import { OrgId } from "@maple/domain/http"
import { Effect, Exit, Layer, Option, Schema } from "effect"
import { VcsProviderRegistry } from "./VcsProviderRegistry"
import { VcsRepository } from "./VcsRepository"
import { VcsSourceService } from "./VcsSourceService"
import type { VcsProviderClient } from "./VcsProviderClient"

const ORG = Schema.decodeUnknownSync(OrgId)("org_source_test")
const OTHER_ORG = Schema.decodeUnknownSync(OrgId)("org_other")

const installation = {
id: "00000000-0000-4000-8000-000000000001",
orgId: ORG,
provider: "github",
status: "active",
} as const

const repository = {
id: "00000000-0000-4000-8000-000000000002",
orgId: ORG,
provider: "github",
installationId: installation.id,
externalRepoId: "7",
owner: "octo",
name: "shop",
fullName: "octo/shop",
defaultBranch: "main",
trackedBranch: "production",
htmlUrl: "https://github.com/octo/shop",
isPrivate: true,
isArchived: false,
status: "active",
syncStatus: "ready",
} as const

const makeLayer = (providerCalls: string[]) => {
const provider = {
id: "github",
searchCode: (_installation, repo, query) =>
Effect.sync(() => {
providerCalls.push(`search:${repo.owner}/${repo.name}:${query}`)
return [
{
path: "src/checkout.ts",
sha: "blob",
htmlUrl: "https://github.com/octo/shop/blob/main/src/checkout.ts",
snippets: ["checkout"],
},
]
}),
fetchSourceFile: (_installation, repo, path, ref) =>
Effect.sync(() => {
providerCalls.push(`read:${repo.owner}/${repo.name}:${path}:${ref}`)
return Option.some({
path,
sha: "blob",
htmlUrl: "https://github.com/octo/shop/blob/production/src/checkout.ts",
size: 8,
content: "checkout",
})
}),
} as VcsProviderClient

const repoLayer = Layer.succeed(VcsRepository, {
listInstallationsByOrg: (orgId: OrgId) => Effect.succeed(orgId === ORG ? [installation] : []),
listRepositoriesByInstallation: () => Effect.succeed([repository]),
} as never)
const registryLayer = Layer.succeed(VcsProviderRegistry, {
ids: ["github"],
resolve: () => Effect.succeed(provider),
})
return VcsSourceService.layer.pipe(Layer.provide(Layer.mergeAll(repoLayer, registryLayer)))
}

describe("VcsSourceService", () => {
it.effect("uses only the current organization's active installation and tracked ref", () => {
const calls: string[] = []
return Effect.gen(function* () {
const source = yield* VcsSourceService
const repositories = yield* source.listRepositories(ORG)
assert.deepStrictEqual(
repositories.map((repo) => repo.fullName),
["octo/shop"],
)
assert.strictEqual(repositories[0]?.trackedBranch, "production")

const matches = yield* source.searchCode(ORG, "OCTO/SHOP", "checkout", { limit: 5 })
assert.strictEqual(matches[0]?.path, "src/checkout.ts")
const file = yield* source.readFile(ORG, "octo/shop", "src/checkout.ts")
assert.strictEqual(file.ref, "production")
assert.deepStrictEqual(calls, [
"search:octo/shop:checkout",
"read:octo/shop:src/checkout.ts:production",
])
}).pipe(Effect.provide(makeLayer(calls)))
})

it.effect("does not expose repositories across organizations", () => {
const calls: string[] = []
return Effect.gen(function* () {
const source = yield* VcsSourceService
const result = yield* Effect.exit(
source.searchCode(OTHER_ORG, "octo/shop", "checkout", { limit: 5 }),
)
assert.ok(Exit.isFailure(result))
assert.deepStrictEqual(calls, [])
}).pipe(Effect.provide(makeLayer(calls)))
})
})
Loading
Loading