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
7 changes: 7 additions & 0 deletions mcp-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,10 @@ All DevCycle CLI MCP tools are available. See [complete reference](../docs/mcp.m
**Self-Targeting**: `get_self_targeting_identity`, `update_self_targeting_identity`, `list_self_targeting_overrides`, `set_self_targeting_override`, `clear_feature_self_targeting_overrides`

**Analytics**: `get_feature_total_evaluations`, `get_project_total_evaluations`, `get_feature_audit_log_history`

## Events

When a user completes OAuth on the hosted MCP Worker, the worker emits a single Ably event for first-time installs.

- **Channel**: `${orgId}-mcp-install`
- **Event name**: `mcp-install`
51 changes: 28 additions & 23 deletions mcp-worker/package.json
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
{
"name": "@devcycle/mcp-worker",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"deploy:dev": "wrangler deploy --env dev",
"build": "tsc",
"type-check": "tsc --noEmit",
"cf-typegen": "wrangler types"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.0.5",
"agents": "^0.0.111",
"hono": "^4.8.12",
"jose": "^6.0.12",
"oauth4webapi": "^3.6.1"
},
"devDependencies": {
"wrangler": "^4.28.0"
},
"packageManager": "yarn@4.9.2"
"name": "@devcycle/mcp-worker",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev --env dev",
"deploy:prod": "wrangler deploy --env prod",
"deploy:dev": "wrangler deploy --env dev",
"build": "tsc",
"type-check": "tsc --noEmit",
"cf-typegen": "wrangler types",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.0.5",
"ably": "^1.2.48",
"agents": "^0.0.111",
"hono": "^4.8.12",
"jose": "^6.0.12",
"oauth4webapi": "^3.6.1"
},
"devDependencies": {
"@types/node": "^24.3.0",
"vitest": "^3.2.4",
"wrangler": "^4.31.0"
},
"packageManager": "yarn@4.9.2"
}
109 changes: 109 additions & 0 deletions mcp-worker/src/ably.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, test, vi } from 'vitest'

// Lock Ably import and shape
vi.mock('ably/build/ably-webworker.min', () => {
const publish = vi.fn()
const channelsGet = vi.fn(() => ({ publish }))
const channels = { get: channelsGet }

class RestPromiseMock {
public static Promise = vi.fn(() => new RestPromiseMock())
public channels = channels
}

return { default: { Rest: RestPromiseMock } }
})

import Ably from 'ably/build/ably-webworker.min'
import { publishMCPInstallEvent } from './ably'

const getMocks = () => {
const Rest = (Ably as any).Rest as { Promise: any }
const instance =
Rest.Promise.mock.results[Rest.Promise.mock.results.length - 1]?.value
const channelsGet = instance?.channels.get as ReturnType<typeof vi.fn>
const publish = channelsGet?.mock.results[0]?.value.publish as ReturnType<
typeof vi.fn
>
return { Rest, instance, channelsGet, publish }
}

afterEach(() => {
vi.clearAllMocks()
})

describe('publishMCPInstallEvent', () => {
test('publishes correct channel and payload', async () => {
const env = { ABLY_API_KEY: 'key-123' }
const claims = {
org_id: 'org_abc',
email: 'u@example.com',
name: 'User',
}

await publishMCPInstallEvent(env, claims as any)

const { Rest, instance, channelsGet, publish } = getMocks()

expect(Rest.Promise).toHaveBeenCalledWith({ key: env.ABLY_API_KEY })
expect(instance).toBeDefined()
expect(channelsGet).toHaveBeenCalledWith('org_abc-mcp-install')
expect(publish).toHaveBeenCalledWith('mcp-install', {
org_id: 'org_abc',
name: 'User',
email: 'u@example.com',
})
})

test('throws when ABLY_API_KEY missing', async () => {
await expect(
publishMCPInstallEvent({}, {
org_id: 'o',
name: 'N',
email: 'e',
} as any),
).rejects.toThrow('ABLY_API_KEY is required to publish Ably MCP events')
})

test('throws when org_id missing', async () => {
await expect(
publishMCPInstallEvent({ ABLY_API_KEY: 'k' }, {
name: 'N',
email: 'e',
} as any),
).rejects.toThrow(
'org_id is required in claims to publish Ably MCP events',
)
})

test('logs and rethrows on publish error', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})

const env = { ABLY_API_KEY: 'key-123' }
const claims = {
org_id: 'org_abc',
email: 'u@example.com',
name: 'User',
}

// Arrange the mock chain to throw on publish
const Rest = (await import('ably/build/ably-webworker.min')).default
.Rest as any
const instance = Rest.Promise()
const channelsGet = vi.spyOn(instance.channels, 'get')
channelsGet.mockReturnValue({
publish: vi.fn(async () => {
throw new Error('boom')
}),
})

await expect(
publishMCPInstallEvent(env, claims as any),
).rejects.toThrow('boom')
expect(consoleErrorSpy).toHaveBeenCalled()

consoleErrorSpy.mockRestore()
})
})
41 changes: 41 additions & 0 deletions mcp-worker/src/ably.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Ably from 'ably/build/ably-webworker.min'
import type { DevCycleJWTClaims } from './types'

export async function publishMCPInstallEvent(
env: { ABLY_API_KEY?: string },
claims: DevCycleJWTClaims,
): Promise<void> {
if (!env.ABLY_API_KEY) {
throw new Error('ABLY_API_KEY is required to publish Ably MCP events')
}
if (!claims.org_id) {
throw new Error(
'org_id is required in claims to publish Ably MCP events',
)
}

const channel = `${claims.org_id}-mcp-install`

try {
const ably = new Ably.Rest.Promise({ key: env.ABLY_API_KEY })
const ablyChannel = ably.channels.get(channel)
const payload = {
org_id: claims.org_id,
name: claims.name,
email: claims.email,
}
await ablyChannel.publish('mcp-install', payload)
console.log(
`Successfully published "mcp-install" event to Ably channel: ${channel}`,
)
} catch (error) {
console.error('Failed to publish ably "mcp-install" event', {
error:
error instanceof Error
? { message: error.message }
: { message: String(error) },
channel,
})
throw error
}
}
7 changes: 7 additions & 0 deletions mcp-worker/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Hono } from 'hono'
import { getCookie, setCookie } from 'hono/cookie'
import * as oauth from 'oauth4webapi'
import type { UserProps } from './types'
import { publishMCPInstallEvent } from './ably'
import { OAuthHelpers } from '@cloudflare/workers-oauth-provider'
import type {
AuthRequest,
Expand Down Expand Up @@ -304,6 +305,12 @@ export async function callback(
userId: claims.sub!,
})

c.executionCtx.waitUntil(
publishMCPInstallEvent(c.env, claims).catch((error) => {
console.error('Error publishing MCP install event', error)
}),
)

return Response.redirect(redirectTo, 302)
}

Expand Down
13 changes: 9 additions & 4 deletions mcp-worker/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"rootDir": "../",
"noEmit": true,
"types": [
"vitest/globals",
"node"
],
"lib": ["ES2022"],
Expand All @@ -15,17 +16,21 @@
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"declaration": false,
"declaration": true,
"declarationMap": false,
"sourceMap": true
},
"include": [
"src/**/*",
"../src/mcp/**/*",
"worker-configuration.d.ts"
"../src/**/*",
"worker-configuration.d.ts",
"package.json"
],
"exclude": [
"node_modules",
"dist"
"dist",
"../src/**/*.test.ts",
"../src/**/__snapshots__/**",
"src/**/*.test.ts"
]
}
Loading