Skip to content
Closed
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: 1 addition & 1 deletion mcp-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"oauth4webapi": "^3.6.1"
},
"devDependencies": {
"wrangler": "^4.28.0"
"wrangler": "^4.31.0"
},
"packageManager": "yarn@4.9.2"
}
10 changes: 8 additions & 2 deletions mcp-worker/src/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import { setMCPHeaders, setMCPToolCommand } from '../../src/mcp/utils/headers'
* Interface for state management - allows McpAgent or other state managers
*/
interface IStateManager {
state?: { selectedProjectKey?: string }
setState(newState: { selectedProjectKey?: string }): void
state?: { selectedProjectKey?: string; clientToken?: string }
setState(newState: {
selectedProjectKey?: string
clientToken?: string
}): void
}

/**
Expand Down Expand Up @@ -58,6 +61,9 @@ export class WorkerApiClient implements IDevCycleApiClient {
userId: this.getUserId(),
orgId: this.getOrgId(),
projectKey: requiresProject ? projectKey : 'N/A',
clientToken: this.stateManager?.state?.clientToken
? `${this.stateManager.state.clientToken.slice(0, 6)}…`
: undefined,
})

try {
Expand Down
60 changes: 48 additions & 12 deletions mcp-worker/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Import removed - using env parameter instead
import { Hono } from 'hono'
import type { Context } from 'hono'
import { getCookie, setCookie } from 'hono/cookie'
import * as oauth from 'oauth4webapi'
import type { UserProps } from './types'
Expand All @@ -18,6 +19,7 @@ type Auth0AuthRequest = {
nonce: string
transactionState: string
consentToken: string
clientToken?: string
}

export async function getOidcConfig({
Expand Down Expand Up @@ -57,9 +59,9 @@ export async function getOidcConfig({
* original request information in a state-specific cookie for later retrieval.
* Then it shows a consent screen before redirecting to Auth0.
*/
export async function authorize(
c: any & { env: Env & { OAUTH_PROVIDER: OAuthHelpers } },
) {
type AppEnv = { Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }

export async function authorize(c: Context<AppEnv>) {
const mcpClientAuthRequest = await c.env.OAUTH_PROVIDER.parseAuthRequest(
c.req.raw,
)
Expand All @@ -79,24 +81,38 @@ export async function authorize(
const transactionState = oauth.generateRandomState()
const consentToken = oauth.generateRandomState() // For CSRF protection on consent form

// We will persist everything in a cookie.
// Build the transaction payload (persisted cross redirects)
const auth0AuthRequest: Auth0AuthRequest = {
codeChallenge: await oauth.calculatePKCECodeChallenge(codeVerifier),
codeVerifier,
consentToken,
mcpAuthRequest: mcpClientAuthRequest,
nonce: oauth.generateRandomNonce(),
transactionState,
// Carry optional client token from query string if provided
clientToken:
c.req.query('client_token') ||
c.req.query('client_hash') ||
undefined,
}

// Debug: log clientToken presence (mask to first 6 chars)
if (auth0AuthRequest.clientToken) {
const preview = auth0AuthRequest.clientToken.slice(0, 6)
console.log('OAuth authorize: received clientToken', { preview })
} else {
console.log('OAuth authorize: no clientToken provided')
}

// Store the auth request in a transaction-specific cookie
const cookieName = `auth0_req_${transactionState}`
const nodeEnv = String(c.env.NODE_ENV)
setCookie(c, cookieName, btoa(JSON.stringify(auth0AuthRequest)), {
httpOnly: true,
maxAge: 60 * 60 * 1, // 1 hour
path: '/',
sameSite: c.env.NODE_ENV !== 'development' ? 'none' : 'lax',
secure: c.env.NODE_ENV !== 'development',
sameSite: nodeEnv !== 'development' ? 'none' : 'lax',
secure: nodeEnv !== 'development',
})

// Extract client information for the consent screen
Expand Down Expand Up @@ -124,7 +140,7 @@ export async function authorize(
*
* This route handles the consent confirmation before redirecting to Auth0
*/
export async function confirmConsent(c: any) {
export async function confirmConsent(c: Context<AppEnv>) {
// Get form data
const formData = await c.req.formData()

Expand Down Expand Up @@ -188,7 +204,10 @@ export async function confirmConsent(c: any) {
})

// Redirect to Auth0's authorization endpoint
const authorizationUrl = new URL(as.authorization_endpoint!)
if (!as.authorization_endpoint) {
return c.text('OIDC configuration missing authorization endpoint', 500)
}
const authorizationUrl = new URL(as.authorization_endpoint)
authorizationUrl.searchParams.set('client_id', c.env.AUTH0_CLIENT_ID)
authorizationUrl.searchParams.set(
'redirect_uri',
Expand All @@ -215,9 +234,7 @@ export async function confirmConsent(c: any) {
* It exchanges the authorization code for tokens and completes the
* authorization process.
*/
export async function callback(
c: any & { env: Env & { OAUTH_PROVIDER: OAuthHelpers } },
) {
export async function callback(c: Context<AppEnv>) {
// Parse the state parameter to extract transaction state and Auth0 state
const stateParam = c.req.query('state') as string
if (!stateParam) {
Expand Down Expand Up @@ -298,10 +315,11 @@ export async function callback(
idToken: result.id_token,
refreshToken: result.refresh_token,
},
clientToken: auth0AuthRequest.clientToken,
} as UserProps,
request: auth0AuthRequest.mcpAuthRequest,
scope: auth0AuthRequest.mcpAuthRequest.scope,
userId: claims.sub!,
userId: String(claims.sub || claims.email || 'unknown'),
})

return Response.redirect(redirectTo, 302)
Expand Down Expand Up @@ -408,6 +426,24 @@ export function createAuthApp(): Hono<{
}> {
const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>()

// Capture /connect/:token and redirect to OAuth authorize carrying token
app.get('/connect/:token', (c) => {
const clientToken = c.req.param('token')
if (clientToken) {
const preview = clientToken.slice(0, 6)
console.log('Captured clientToken from /connect', { preview })
} else {
console.log('Captured empty clientToken from /connect')
}
const url = new URL(c.req.url)
// Redirect to /oauth/authorize carrying the client_token param
url.pathname = '/oauth/authorize'
if (clientToken) {
url.searchParams.set('client_token', clientToken)
}
return c.redirect(url.toString(), 302)
})

// OAuth routes - these are required for the OAuth flow
app.get('/oauth/authorize', authorize)
app.post('/oauth/authorize/consent', confirmConsent)
Expand Down
13 changes: 13 additions & 0 deletions mcp-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { UserProps } from './types'
*/
type DevCycleMCPState = {
selectedProjectKey?: string
clientToken?: string
}

/**
Expand Down Expand Up @@ -134,6 +135,18 @@ export class DevCycleMCP extends McpAgent<Env, DevCycleMCPState, UserProps> {
// Register worker-specific project selection tools using the modern pattern
registerProjectSelectionTools(serverAdapter, this.apiClient)

// Persist optional clientToken from props into MCP state for this session
if (this.props.clientToken) {
const preview = String(this.props.clientToken).slice(0, 6)
console.log('MCP init: persisting clientToken', { preview })
this.setState({
...(this.state || {}),
clientToken: this.props.clientToken,
})
} else {
console.log('MCP init: no clientToken to persist')
}

console.log('✅ DevCycle MCP Worker initialization completed')
}

Expand Down
2 changes: 2 additions & 0 deletions mcp-worker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export type UserProps = {
idToken: string
refreshToken: string
}
/** Optional correlation token captured from /mcp/:token */
clientToken?: string
}

// Env interface is now generated in worker-configuration.d.ts
Expand Down
4 changes: 2 additions & 2 deletions mcp-worker/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 1d664210c769c6aac43d5af03f7d45db)
// Generated by Wrangler by running `wrangler types` (hash: ef89c022e303a545c4ed5c787079351b)
// Runtime types generated with workerd@1.20250803.0 2025-06-28 nodejs_compat
declare namespace Cloudflare {
interface Env {
OAUTH_KV: KVNamespace;
NODE_ENV: "production";
NODE_ENV: "production" | "development";
API_BASE_URL: "https://api.devcycle.com";
AUTH0_DOMAIN: "auth.devcycle.com";
AUTH0_AUDIENCE: "https://api.devcycle.com/";
Expand Down
1 change: 0 additions & 1 deletion src/mcp/tools/installTools.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import axios from 'axios'
import { z } from 'zod'
import type { IDevCycleApiClient } from '../api/interface'
import type { DevCycleMCPServerInstance } from '../server'
import { INSTALL_GUIDES } from './installGuides.generated'

Expand Down
Loading