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
46 changes: 46 additions & 0 deletions apps/mcp/src/context/feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
GitHubClient,
detectAuth,
detectRepo,
loadConfig,
type FirewatchConfig,
} from "@outfitter/firewatch-core";

import type { FeedbackParams } from "../schemas";
import { ensureRepoCacheIfNeeded } from "./repo";

export interface FeedbackContext {
repo: string;
owner: string;
name: string;
config: FirewatchConfig;
client: GitHubClient;
detectedRepo: string | null;
}

export async function createFeedbackContext(
params: FeedbackParams
): Promise<FeedbackContext> {
const config = await loadConfig();
const detected = await detectRepo();
const repo = params.repo ?? detected.repo;

if (!repo) {
throw new Error("No repository detected. Provide repo.");
}

const [owner, name] = repo.split("/");
if (!owner || !name) {
throw new Error(`Invalid repo format: ${repo}. Expected owner/repo.`);
}

const auth = await detectAuth(config.github_token);
if (auth.isErr()) {
throw new Error(auth.error.message);
}

const client = new GitHubClient(auth.value.token);
await ensureRepoCacheIfNeeded(repo, config, detected.repo, ["open", "draft"]);

return { repo, owner, name, config, client, detectedRepo: detected.repo };
}
36 changes: 36 additions & 0 deletions apps/mcp/src/context/mutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
GitHubClient,
detectAuth,
loadConfig,
} from "@outfitter/firewatch-core";

import { resolveRepo } from "./repo";

export interface MutationContext {
repo: string;
owner: string;
name: string;
client: GitHubClient;
}

export async function createMutationContext(
repoParam: string | undefined
): Promise<MutationContext> {
const repo = (await resolveRepo(repoParam)) ?? null;
if (!repo) {
throw new Error("No repository detected. Provide repo.");
}

const [owner, name] = repo.split("/");
if (!owner || !name) {
throw new Error(`Invalid repo format: ${repo}. Expected owner/repo.`);
}

const config = await loadConfig();
const auth = await detectAuth(config.github_token);
if (auth.isErr()) {
throw new Error(auth.error.message);
}

return { repo, owner, name, client: new GitHubClient(auth.value.token) };
}
123 changes: 123 additions & 0 deletions apps/mcp/src/context/repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
GitHubClient,
detectAuth,
detectRepo,
ensureDirectories,
getDatabase,
getSyncMeta,
parseDurationMs,
syncRepo,
type FirewatchConfig,
type PrState,
type SyncScope,
} from "@outfitter/firewatch-core";
import {
getGraphiteStacks,
graphitePlugin,
} from "@outfitter/firewatch-core/plugins";

import { DEFAULT_STALE_THRESHOLD, resolveSyncScopes } from "../utils/parsing";

export function isFullRepo(value: string): boolean {
return /^[^/]+\/[^/]+$/.test(value);
}

export function hasRepoCache(repo: string, scope: SyncScope): boolean {
const db = getDatabase();
const meta = getSyncMeta(db, repo, scope);
return meta !== null;
}

export async function resolveRepo(repo?: string): Promise<string | null> {
if (repo) {
return repo;
}

const detected = await detectRepo();
if (detected.repo) {
return detected.repo;
}

return null;
}

export async function ensureRepoCache(
repo: string,
config: FirewatchConfig,
detectedRepo: string | null,
scope: SyncScope
): Promise<void> {
if (hasRepoCache(repo, scope)) {
return;
}

await ensureDirectories();

const auth = await detectAuth(config.github_token);
if (auth.isErr()) {
throw new Error(auth.error.message);
}

const graphiteEnabled =
detectedRepo === repo && (await getGraphiteStacks()) !== null;
const plugins = graphiteEnabled ? [graphitePlugin] : [];
const client = new GitHubClient(auth.value.token);
await syncRepo(client, repo, { plugins, scope });
}

export async function ensureRepoCacheIfNeeded(
repoFilter: string | undefined,
config: FirewatchConfig,
detectedRepo: string | null,
states: PrState[],
options: { noSync?: boolean } = {}
): Promise<void> {
if (!repoFilter || !isFullRepo(repoFilter)) {
return;
}

const scopes = resolveSyncScopes(states);

if (options.noSync) {
for (const scope of scopes) {
if (!hasRepoCache(repoFilter, scope)) {
throw new Error(`No-sync mode: no cache for ${repoFilter} (${scope}).`);
}
}
return;
}

const autoSync = config.sync?.auto_sync ?? true;
const threshold = config.sync?.stale_threshold ?? DEFAULT_STALE_THRESHOLD;
const thresholdResult = parseDurationMs(threshold);
const fallbackResult = thresholdResult.isErr()
? parseDurationMs(DEFAULT_STALE_THRESHOLD)
: thresholdResult;
const thresholdMs = fallbackResult.isOk() ? fallbackResult.value : 0;

const db = getDatabase();

for (const scope of scopes) {
const hasCached = hasRepoCache(repoFilter, scope);
if (!hasCached) {
await ensureRepoCache(repoFilter, config, detectedRepo, scope);
continue;
}

if (!autoSync) {
continue;
}

const repoMeta = getSyncMeta(db, repoFilter, scope);
const lastSync = repoMeta?.last_sync;
if (!lastSync) {
await ensureRepoCache(repoFilter, config, detectedRepo, scope);
continue;
}

const ageMs = Date.now() - new Date(lastSync).getTime();
if (ageMs > thresholdMs) {
await ensureRepoCache(repoFilter, config, detectedRepo, scope);
}
}
}
87 changes: 87 additions & 0 deletions apps/mcp/src/handlers/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
PATHS,
getConfigPaths,
getProjectConfigPath,
loadConfig,
type FirewatchConfig,
} from "@outfitter/firewatch-core";

import type { FirewatchParams, McpToolResult } from "../types";
import { textResult } from "../utils/formatting";

function redactConfig(config: FirewatchConfig): FirewatchConfig {
if (!config.github_token) {
return config;
}

return {
...config,
github_token: "***",
};
}

function getConfigValue(config: FirewatchConfig, key: string): unknown {
const normalized = key.replaceAll("-", "_");
const segments = normalized.split(".");
let current: unknown = config;

for (const segment of segments) {
if (!current || typeof current !== "object") {
return undefined;
}
const record = current as Record<string, unknown>;
if (!(segment in record)) {
return undefined;
}
current = record[segment];
}

return current;
}

export async function handleConfig(
params: FirewatchParams
): Promise<McpToolResult> {
if (params.value !== undefined) {
throw new Error("config updates are not supported via MCP. Use the CLI.");
}

const config = await loadConfig();
const configPaths = await getConfigPaths();
const projectPath = await getProjectConfigPath();

if (params.path) {
return textResult(
JSON.stringify({
paths: {
user: configPaths.user,
project: projectPath,
cache: PATHS.cache,
repos: PATHS.repos,
meta: PATHS.meta,
},
})
);
}

if (params.key) {
const value = getConfigValue(redactConfig(config), params.key);
return textResult(
JSON.stringify({
ok: value !== undefined,
key: params.key,
value,
})
);
}

return textResult(
JSON.stringify({
config: redactConfig(config),
paths: {
user: configPaths.user,
project: projectPath,
},
})
);
}
Loading