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
27 changes: 10 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ import {
createOAuthProvider,
handleAuthorize,
handleGitHubCallback,
type OAuthEnv,
type GitHubUserProps,
readGitHubProps,
readOAuthHelpers,
writeMcpProps,
} from "./oauth.js";
import { handleScheduled } from "./poller.js";
import { handleWebhook } from "./webhook.js";
Expand Down Expand Up @@ -127,36 +128,30 @@ const innerHandler: ExportedHandler<Env> = {

// -- MCP endpoint (OAuth-protected, ctx.props set by OAuthProvider) --
if (url.pathname.startsWith("/mcp")) {
const props = (ctx as unknown as { props: GitHubUserProps }).props;
const props = readGitHubProps(ctx);
if (!props?.githubUserId) {
return new Response("Unauthorized", { status: 401 });
}

// Rewrite ctx.props to McpProps shape expected by RagMcpAgentV2.
// Pass the GitHub access token so the agent can make API calls.
(ctx as unknown as { props: { githubUserId: number; githubLogin: string; accessToken: string } }).props = {
writeMcpProps(ctx, {
githubUserId: props.githubUserId,
githubLogin: props.githubLogin,
accessToken: props.githubAccessToken,
};
});

return mcpHandler.fetch(request, env, ctx);
}

// -- OAuth authorize (redirect to GitHub) --
if (url.pathname === "/oauth/authorize") {
const oauthHelpers = (
env as unknown as { OAUTH_PROVIDER: Parameters<typeof handleAuthorize>[2] }
).OAUTH_PROVIDER;
return handleAuthorize(request, env, oauthHelpers);
return handleAuthorize(request, env, readOAuthHelpers(env));
}

// -- OAuth callback (GitHub redirects back here) --
if (url.pathname === "/oauth/callback") {
const oauthHelpers = (
env as unknown as { OAUTH_PROVIDER: Parameters<typeof handleGitHubCallback>[2] }
).OAUTH_PROVIDER;
return handleGitHubCallback(request, env, oauthHelpers);
return handleGitHubCallback(request, env, readOAuthHelpers(env));
}

return new Response("Not found", { status: 404 });
Expand All @@ -170,13 +165,11 @@ const innerHandler: ExportedHandler<Env> = {
// OAuthProvider wraps the inner handler, adding OAuth endpoints
// and protecting /mcp route with access token validation.
// Note: OAuthProvider only wraps fetch. We re-export scheduled separately.
const oauthWrapped = createOAuthProvider(
innerHandler as unknown as ExportedHandler<OAuthEnv & Record<string, unknown>>,
);
const oauthWrapped = createOAuthProvider(innerHandler);

export default {
fetch: (req: Request, env: Env, ctx: ExecutionContext) =>
oauthWrapped.fetch(req, env as unknown as OAuthEnv & Record<string, unknown>, ctx),
oauthWrapped.fetch(req, env, ctx),
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
await handleScheduled(controller, env, ctx);
},
Expand Down
75 changes: 68 additions & 7 deletions src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,45 @@ export interface OAuthEnv {
GITHUB_CLIENT_SECRET: string;
}

/**
* Props consumed by RagMcpAgentV2 on the /mcp route, after the inner handler
* rewrites ctx.props from GitHubUserProps. Distinct from GitHubUserProps so
* the MCP agent does not see GitHub refresh tokens it has no use for.
*/
export interface McpProps {
githubUserId: number;
githubLogin: string;
accessToken: string;
}

/**
* Read GitHubUserProps from ctx. OAuthProvider sets ctx.props after access
* token validation on protected routes, but the workers-types ExecutionContext
* declaration does not include `props`. Centralize the cast here so call
* sites stay type-safe.
*/
export function readGitHubProps(ctx: ExecutionContext): GitHubUserProps | undefined {
return (ctx as unknown as { props?: GitHubUserProps }).props;
}

/**
* Replace ctx.props with the McpProps shape RagMcpAgentV2 expects.
* Mirrors readGitHubProps on the write side.
*/
export function writeMcpProps(ctx: ExecutionContext, props: McpProps): void {
(ctx as unknown as { props: McpProps }).props = props;
}

/**
* Read OAUTH_PROVIDER helper that OAuthProvider injects into env on default
* routes. The library injects OAuthHelpers under a known key but its env
* generic does not declare it, so call sites would otherwise need an ad-hoc
* cast each time.
*/
export function readOAuthHelpers(env: OAuthEnv): OAuthHelpers {
return (env as unknown as OAuthEnv & { OAUTH_PROVIDER: OAuthHelpers }).OAUTH_PROVIDER;
}

/**
* Handle the /oauth/authorize endpoint.
*
Expand Down Expand Up @@ -169,26 +208,43 @@ export async function handleGitHubCallback(
return Response.redirect(redirectTo, 302);
}

/**
* Worker handler shape returned by createOAuthProvider. Hides the library's
* `OAuthEnv & Record<string, unknown>` index-signature requirement so
* downstream wiring can stay in terms of the caller's own Env type.
*/
export interface OAuthWrappedHandler<TEnv extends OAuthEnv> {
fetch: (request: Request, env: TEnv, ctx: ExecutionContext) => Promise<Response>;
}

/**
* Create the OAuthProvider instance.
*
* Generic over TEnv so callers wire in their own Worker Env type without
* casting at the call site. All `Record<string, unknown>` index-signature
* gymnastics live here, at the single boundary between the caller's Env and
* the OAuthProvider library's generic constraint.
*
* The provider wraps the existing Worker default handler, adding OAuth
* endpoints and protecting API routes. Non-OAuth routes pass through
* to the defaultHandler.
*
* @param defaultHandler - The existing Worker fetch handler to wrap
*/
export function createOAuthProvider(
defaultHandler: ExportedHandler<OAuthEnv & Record<string, unknown>>,
): OAuthProvider<OAuthEnv & Record<string, unknown>> {
return new OAuthProvider<OAuthEnv & Record<string, unknown>>({
export function createOAuthProvider<TEnv extends OAuthEnv>(
defaultHandler: ExportedHandler<TEnv>,
): OAuthWrappedHandler<TEnv> {
type LibEnv = OAuthEnv & Record<string, unknown>;
const libHandler = defaultHandler as unknown as ExportedHandler<LibEnv>;

const provider = new OAuthProvider<LibEnv>({
// API routes protected by OAuth access tokens
apiRoute: ["/mcp"],

// The existing Worker handler serves as both apiHandler and defaultHandler
apiHandler: defaultHandler as ExportedHandler<OAuthEnv & Record<string, unknown>> &
Pick<Required<ExportedHandler<OAuthEnv & Record<string, unknown>>>, "fetch">,
defaultHandler,
apiHandler: libHandler as ExportedHandler<LibEnv> &
Pick<Required<ExportedHandler<LibEnv>>, "fetch">,
defaultHandler: libHandler,

// OAuth endpoints
authorizeEndpoint: "/oauth/authorize",
Expand All @@ -212,4 +268,9 @@ export function createOAuthProvider(
}
},
});

return {
fetch: (request, env, ctx) =>
provider.fetch(request, env as unknown as LibEnv, ctx),
};
}
17 changes: 10 additions & 7 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,19 +211,22 @@ export interface VectorMetadata {
assignee_1?: string;
}

/** Env bindings for the Worker */
export interface Env {
import type { OAuthEnv } from "./oauth.js";

/**
* Env bindings for the Worker.
*
* Extends OAuthEnv so OAUTH_KV / GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET are
* inherited; this makes Env structurally compatible with the OAuthProvider
* boundary in oauth.ts without an ad-hoc cast at the wiring site.
*/
export interface Env extends OAuthEnv {
MCP_OBJECT: DurableObjectNamespace;
ISSUE_STORE: DurableObjectNamespace;
OAUTH_KV: KVNamespace;
VECTORIZE: Vectorize;
/** D1 database for full-text search (sparse side of hybrid retrieval, BM25 via FTS5) */
DB_FTS: D1Database;
AI: Ai;
/** GitHub App OAuth client ID (set via `wrangler secret put`) */
GITHUB_CLIENT_ID: string;
/** GitHub App OAuth client secret (set via `wrangler secret put`) */
GITHUB_CLIENT_SECRET: string;
/** GitHub personal access token or installation token for API access (set via `wrangler secret put`) */
GITHUB_TOKEN: string;
/** Comma-separated list of repos to poll, e.g. "owner/repo1,owner/repo2" */
Expand Down
Loading