From a11abf1bb9a7cee0f1fcd63e633622af08f5cc24 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Wed, 20 Aug 2025 12:11:48 -0400 Subject: [PATCH] feat: trying to support client_token hash on mcp init --- mcp-worker/package.json | 2 +- mcp-worker/src/apiClient.ts | 10 +++- mcp-worker/src/auth.ts | 60 ++++++++++++++++---- mcp-worker/src/index.ts | 13 +++++ mcp-worker/src/types.ts | 2 + mcp-worker/worker-configuration.d.ts | 4 +- src/mcp/tools/installTools.ts | 1 - yarn.lock | 84 ++++++++++++++-------------- 8 files changed, 116 insertions(+), 60 deletions(-) diff --git a/mcp-worker/package.json b/mcp-worker/package.json index d7750bc7f..b06274726 100644 --- a/mcp-worker/package.json +++ b/mcp-worker/package.json @@ -19,7 +19,7 @@ "oauth4webapi": "^3.6.1" }, "devDependencies": { - "wrangler": "^4.28.0" + "wrangler": "^4.31.0" }, "packageManager": "yarn@4.9.2" } diff --git a/mcp-worker/src/apiClient.ts b/mcp-worker/src/apiClient.ts index dbf35b392..b0084da89 100644 --- a/mcp-worker/src/apiClient.ts +++ b/mcp-worker/src/apiClient.ts @@ -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 } /** @@ -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 { diff --git a/mcp-worker/src/auth.ts b/mcp-worker/src/auth.ts index 01e2c9dbb..49fe8f38c 100644 --- a/mcp-worker/src/auth.ts +++ b/mcp-worker/src/auth.ts @@ -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' @@ -18,6 +19,7 @@ type Auth0AuthRequest = { nonce: string transactionState: string consentToken: string + clientToken?: string } export async function getOidcConfig({ @@ -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) { const mcpClientAuthRequest = await c.env.OAUTH_PROVIDER.parseAuthRequest( c.req.raw, ) @@ -79,7 +81,7 @@ 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, @@ -87,16 +89,30 @@ export async function authorize( 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 @@ -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) { // Get form data const formData = await c.req.formData() @@ -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', @@ -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) { // Parse the state parameter to extract transaction state and Auth0 state const stateParam = c.req.query('state') as string if (!stateParam) { @@ -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) @@ -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) diff --git a/mcp-worker/src/index.ts b/mcp-worker/src/index.ts index 70d176f01..0e1a06590 100644 --- a/mcp-worker/src/index.ts +++ b/mcp-worker/src/index.ts @@ -22,6 +22,7 @@ import type { UserProps } from './types' */ type DevCycleMCPState = { selectedProjectKey?: string + clientToken?: string } /** @@ -134,6 +135,18 @@ export class DevCycleMCP extends McpAgent { // 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') } diff --git a/mcp-worker/src/types.ts b/mcp-worker/src/types.ts index ac16352ed..70dde0e24 100644 --- a/mcp-worker/src/types.ts +++ b/mcp-worker/src/types.ts @@ -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 diff --git a/mcp-worker/worker-configuration.d.ts b/mcp-worker/worker-configuration.d.ts index a5937e6f6..268045aa3 100644 --- a/mcp-worker/worker-configuration.d.ts +++ b/mcp-worker/worker-configuration.d.ts @@ -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/"; diff --git a/src/mcp/tools/installTools.ts b/src/mcp/tools/installTools.ts index 5bb0b403e..5d433c642 100644 --- a/src/mcp/tools/installTools.ts +++ b/src/mcp/tools/installTools.ts @@ -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' diff --git a/yarn.lock b/yarn.lock index 5195f3675..4b5ef06c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -648,50 +648,50 @@ __metadata: languageName: node linkType: hard -"@cloudflare/unenv-preset@npm:2.6.0": - version: 2.6.0 - resolution: "@cloudflare/unenv-preset@npm:2.6.0" +"@cloudflare/unenv-preset@npm:2.6.2": + version: 2.6.2 + resolution: "@cloudflare/unenv-preset@npm:2.6.2" peerDependencies: unenv: 2.0.0-rc.19 workerd: ^1.20250802.0 peerDependenciesMeta: workerd: optional: true - checksum: 10c0/5c8211828911a9c04baa2f3c48f2a13a790bc5c7ca9d3b66cda6655185ee7849cb92750f347f48c1edfa195d71f6cf11c399fb1e85bcda32ee312eb2247cf2ab + checksum: 10c0/d9230c241551f09abf25c61205ad300da7f834c16b431f69facae34e52ca66a46f7844b3d32bfff799c93337b3ab612eeb35841f1836f94bc747f17c0947cd44 languageName: node linkType: hard -"@cloudflare/workerd-darwin-64@npm:1.20250803.0": - version: 1.20250803.0 - resolution: "@cloudflare/workerd-darwin-64@npm:1.20250803.0" +"@cloudflare/workerd-darwin-64@npm:1.20250816.0": + version: 1.20250816.0 + resolution: "@cloudflare/workerd-darwin-64@npm:1.20250816.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@cloudflare/workerd-darwin-arm64@npm:1.20250803.0": - version: 1.20250803.0 - resolution: "@cloudflare/workerd-darwin-arm64@npm:1.20250803.0" +"@cloudflare/workerd-darwin-arm64@npm:1.20250816.0": + version: 1.20250816.0 + resolution: "@cloudflare/workerd-darwin-arm64@npm:1.20250816.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@cloudflare/workerd-linux-64@npm:1.20250803.0": - version: 1.20250803.0 - resolution: "@cloudflare/workerd-linux-64@npm:1.20250803.0" +"@cloudflare/workerd-linux-64@npm:1.20250816.0": + version: 1.20250816.0 + resolution: "@cloudflare/workerd-linux-64@npm:1.20250816.0" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@cloudflare/workerd-linux-arm64@npm:1.20250803.0": - version: 1.20250803.0 - resolution: "@cloudflare/workerd-linux-arm64@npm:1.20250803.0" +"@cloudflare/workerd-linux-arm64@npm:1.20250816.0": + version: 1.20250816.0 + resolution: "@cloudflare/workerd-linux-arm64@npm:1.20250816.0" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@cloudflare/workerd-windows-64@npm:1.20250803.0": - version: 1.20250803.0 - resolution: "@cloudflare/workerd-windows-64@npm:1.20250803.0" +"@cloudflare/workerd-windows-64@npm:1.20250816.0": + version: 1.20250816.0 + resolution: "@cloudflare/workerd-windows-64@npm:1.20250816.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -797,7 +797,7 @@ __metadata: hono: "npm:^4.8.12" jose: "npm:^6.0.12" oauth4webapi: "npm:^3.6.1" - wrangler: "npm:^4.28.0" + wrangler: "npm:^4.31.0" languageName: unknown linkType: soft @@ -7039,9 +7039,9 @@ __metadata: languageName: node linkType: hard -"miniflare@npm:4.20250803.0": - version: 4.20250803.0 - resolution: "miniflare@npm:4.20250803.0" +"miniflare@npm:4.20250816.0": + version: 4.20250816.0 + resolution: "miniflare@npm:4.20250816.0" dependencies: "@cspotcode/source-map-support": "npm:0.8.1" acorn: "npm:8.14.0" @@ -7051,13 +7051,13 @@ __metadata: sharp: "npm:^0.33.5" stoppable: "npm:1.1.0" undici: "npm:^7.10.0" - workerd: "npm:1.20250803.0" + workerd: "npm:1.20250816.0" ws: "npm:8.18.0" youch: "npm:4.1.0-beta.10" zod: "npm:3.22.3" bin: miniflare: bootstrap.js - checksum: 10c0/c3db6ef0653bfdb626fa92cc3243aa70f33f38cba5450f651fcfac87559ba338fc0e10ff68a9d5af3ed76dc547356e44c130d7c7a1a19ee6d2cb897cf69251ac + checksum: 10c0/29659893025baf84aab03eb5aae76d9011eed61afdadceb5368dde3197def7254f83e45543d43f831b393bcaf838cff39f2cc019181c032088cd4250b5f28c28 languageName: node linkType: hard @@ -10456,15 +10456,15 @@ __metadata: languageName: node linkType: hard -"workerd@npm:1.20250803.0": - version: 1.20250803.0 - resolution: "workerd@npm:1.20250803.0" +"workerd@npm:1.20250816.0": + version: 1.20250816.0 + resolution: "workerd@npm:1.20250816.0" dependencies: - "@cloudflare/workerd-darwin-64": "npm:1.20250803.0" - "@cloudflare/workerd-darwin-arm64": "npm:1.20250803.0" - "@cloudflare/workerd-linux-64": "npm:1.20250803.0" - "@cloudflare/workerd-linux-arm64": "npm:1.20250803.0" - "@cloudflare/workerd-windows-64": "npm:1.20250803.0" + "@cloudflare/workerd-darwin-64": "npm:1.20250816.0" + "@cloudflare/workerd-darwin-arm64": "npm:1.20250816.0" + "@cloudflare/workerd-linux-64": "npm:1.20250816.0" + "@cloudflare/workerd-linux-arm64": "npm:1.20250816.0" + "@cloudflare/workerd-windows-64": "npm:1.20250816.0" dependenciesMeta: "@cloudflare/workerd-darwin-64": optional: true @@ -10478,7 +10478,7 @@ __metadata: optional: true bin: workerd: bin/workerd - checksum: 10c0/df4bab96fec522e0e948ff8bcc76cf256947206bac1bba28fb0dd21d26e88001adef5409769240dd5d549a1edd94669626e380ba10b6db8e8086234542d4d3bc + checksum: 10c0/ddb41844507a41bb7a8a315681947bc301d10c0bfe27b7bac6457148abf1af27df0a7a9ac840a5e049232c2760710dab4e62b4d36d3d07cbddafb05360b1014c languageName: node linkType: hard @@ -10489,21 +10489,21 @@ __metadata: languageName: node linkType: hard -"wrangler@npm:^4.28.0": - version: 4.28.0 - resolution: "wrangler@npm:4.28.0" +"wrangler@npm:^4.31.0": + version: 4.31.0 + resolution: "wrangler@npm:4.31.0" dependencies: "@cloudflare/kv-asset-handler": "npm:0.4.0" - "@cloudflare/unenv-preset": "npm:2.6.0" + "@cloudflare/unenv-preset": "npm:2.6.2" blake3-wasm: "npm:2.1.5" esbuild: "npm:0.25.4" fsevents: "npm:~2.3.2" - miniflare: "npm:4.20250803.0" + miniflare: "npm:4.20250816.0" path-to-regexp: "npm:6.3.0" unenv: "npm:2.0.0-rc.19" - workerd: "npm:1.20250803.0" + workerd: "npm:1.20250816.0" peerDependencies: - "@cloudflare/workers-types": ^4.20250803.0 + "@cloudflare/workers-types": ^4.20250816.0 dependenciesMeta: fsevents: optional: true @@ -10513,7 +10513,7 @@ __metadata: bin: wrangler: bin/wrangler.js wrangler2: bin/wrangler.js - checksum: 10c0/10dc5866aade44a9f4ab5ebd47ab16274fea1173c39c2251e8960784bb7bf5b90ab719e95aea24a1473ee81a7e38ed86e0a60ba52a364f93bc11ad17866ff86b + checksum: 10c0/e18a13ae7a787b7ad6619ce2a086eee728031bd31d6ff4b52c1d14c8a5301fb5e3dd9845bd177c05c1cb5be95c79730262f832c4a0284b5a9a506e5d523277e3 languageName: node linkType: hard