Oauth tests (#13)
* Moving the non-authed cfagents tests to /public * Added OAuth CF Agents tests --------- Co-authored-by: Glen Maddern <glen@glenmaddern.com>
This commit is contained in:
parent
fe65d50ec3
commit
0ea0bc48c6
8 changed files with 127 additions and 19 deletions
|
|
@ -17,8 +17,10 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.0.4",
|
"@biomejs/biome": "^2.0.4",
|
||||||
|
"@cloudflare/workers-oauth-provider": "^0.0.5",
|
||||||
"@types/node": "^24.0.7",
|
"@types/node": "^24.0.7",
|
||||||
"agents": "^0.0.100",
|
"agents": "^0.0.100",
|
||||||
|
"hono": "^4.8.3",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"wrangler": "^4.22.0"
|
"wrangler": "^4.22.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
examples/servers/cf-agents/pnpm-lock.yaml
generated
19
examples/servers/cf-agents/pnpm-lock.yaml
generated
|
|
@ -18,12 +18,18 @@ importers:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: ^2.0.4
|
specifier: ^2.0.4
|
||||||
version: 2.0.6
|
version: 2.0.6
|
||||||
|
'@cloudflare/workers-oauth-provider':
|
||||||
|
specifier: ^0.0.5
|
||||||
|
version: 0.0.5
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.0.7
|
specifier: ^24.0.7
|
||||||
version: 24.0.7
|
version: 24.0.7
|
||||||
agents:
|
agents:
|
||||||
specifier: ^0.0.100
|
specifier: ^0.0.100
|
||||||
version: 0.0.100(@cloudflare/workers-types@4.20250628.0)(react@19.1.0)
|
version: 0.0.100(@cloudflare/workers-types@4.20250628.0)(react@19.1.0)
|
||||||
|
hono:
|
||||||
|
specifier: ^4.8.3
|
||||||
|
version: 4.8.3
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.3
|
specifier: ^5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
|
|
@ -155,6 +161,9 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@cloudflare/workers-oauth-provider@0.0.5':
|
||||||
|
resolution: {integrity: sha512-t1x5KAzsubCvb4APnJ93z407X1x7SGj/ga5ziRnwIb/iLy4PMkT/hgd1y5z7Bbsdy5Fy6mywhCP4lym24bX66w==}
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20250628.0':
|
'@cloudflare/workers-types@4.20250628.0':
|
||||||
resolution: {integrity: sha512-V4HthfhtQU2vTpwLeUic8FTLgGSjglsGZMJc9jKBNYEU/k0A1rE55UgQoTb5blKQdGtpQKfVKs3FROeY/lXmbw==}
|
resolution: {integrity: sha512-V4HthfhtQU2vTpwLeUic8FTLgGSjglsGZMJc9jKBNYEU/k0A1rE55UgQoTb5blKQdGtpQKfVKs3FROeY/lXmbw==}
|
||||||
|
|
||||||
|
|
@ -692,6 +701,10 @@ packages:
|
||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
hono@4.8.3:
|
||||||
|
resolution: {integrity: sha512-jYZ6ZtfWjzBdh8H/0CIFfCBHaFL75k+KMzaM177hrWWm2TWL39YMYaJgB74uK/niRc866NMlH9B8uCvIo284WQ==}
|
||||||
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
http-errors@2.0.0:
|
http-errors@2.0.0:
|
||||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
@ -1125,6 +1138,10 @@ snapshots:
|
||||||
'@cloudflare/workerd-windows-64@1.20250617.0':
|
'@cloudflare/workerd-windows-64@1.20250617.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workers-oauth-provider@0.0.5':
|
||||||
|
dependencies:
|
||||||
|
'@cloudflare/workers-types': 4.20250628.0
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20250628.0': {}
|
'@cloudflare/workers-types@4.20250628.0': {}
|
||||||
|
|
||||||
'@cspotcode/source-map-support@0.8.1':
|
'@cspotcode/source-map-support@0.8.1':
|
||||||
|
|
@ -1607,6 +1624,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
hono@4.8.3: {}
|
||||||
|
|
||||||
http-errors@2.0.0:
|
http-errors@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { McpAgent } from 'agents/mcp'
|
import { McpAgent } from 'agents/mcp'
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import OAuthProvider, { OAuthHelpers } from '@cloudflare/workers-oauth-provider'
|
||||||
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
// Define our MCP agent with tools
|
// Define our MCP agent with tools
|
||||||
export class MyMCP extends McpAgent {
|
export class MyMCP extends McpAgent {
|
||||||
|
|
@ -54,18 +56,59 @@ export class MyMCP extends McpAgent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Bindings = Env & {
|
||||||
|
OAUTH_PROVIDER: OAuthHelpers
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Hono<{
|
||||||
|
Bindings: Bindings
|
||||||
|
}>()
|
||||||
|
|
||||||
|
app.get('/authorize', async (c) => {
|
||||||
|
const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw)
|
||||||
|
const email = 'example@dotcom.com'
|
||||||
|
const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({
|
||||||
|
request: oauthReqInfo,
|
||||||
|
userId: email,
|
||||||
|
metadata: {
|
||||||
|
label: 'Test User',
|
||||||
|
},
|
||||||
|
scope: oauthReqInfo.scope,
|
||||||
|
props: {
|
||||||
|
userEmail: email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return Response.redirect(redirectTo)
|
||||||
|
})
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
|
|
||||||
if (url.pathname === '/sse' || url.pathname === '/sse/message') {
|
if (url.pathname === '/public/sse' || url.pathname === '/public/sse/message') {
|
||||||
return MyMCP.serveSSE('/sse').fetch(request, env, ctx)
|
return MyMCP.serveSSE('/public/sse').fetch(request, env, ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/mcp') {
|
if (url.pathname === '/public/mcp') {
|
||||||
return MyMCP.serve('/mcp').fetch(request, env, ctx)
|
return MyMCP.serve('/public/mcp').fetch(request, env, ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new OAuthProvider({
|
||||||
|
apiRoute: ['/sse', '/mcp'],
|
||||||
|
apiHandler: {
|
||||||
|
// @ts-ignore
|
||||||
|
fetch: (request, env, ctx) => {
|
||||||
|
const { pathname } = new URL(request.url)
|
||||||
|
if (pathname.startsWith('/sse')) return MyMCP.serveSSE('/sse').fetch(request as any, env, ctx)
|
||||||
|
if (pathname === '/mcp') return MyMCP.serve('/mcp').fetch(request as any, env, ctx)
|
||||||
return new Response('Not found', { status: 404 })
|
return new Response('Not found', { status: 404 })
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
defaultHandler: app,
|
||||||
|
authorizeEndpoint: '/authorize',
|
||||||
|
tokenEndpoint: '/token',
|
||||||
|
clientRegistrationEndpoint: '/register',
|
||||||
|
}).fetch(request, env, ctx)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// Generated by Wrangler by running `wrangler types` (hash: 15dd420dfccac8afafeb181790b9eddb)
|
// Generated by Wrangler by running `wrangler types` (hash: 71a1247d25eb5ef8b66311103d5f9180)
|
||||||
// Runtime types generated with workerd@1.20250617.0 2025-03-10 nodejs_compat
|
// Runtime types generated with workerd@1.20250617.0 2025-03-10 nodejs_compat
|
||||||
declare namespace Cloudflare {
|
declare namespace Cloudflare {
|
||||||
interface Env {
|
interface Env {
|
||||||
|
OAUTH_KV: KVNamespace
|
||||||
MCP_OBJECT: DurableObjectNamespace<import('./src/index').MyMCP>
|
MCP_OBJECT: DurableObjectNamespace<import('./src/index').MyMCP>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,13 @@
|
||||||
},
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
},
|
||||||
|
"kv_namespaces": [
|
||||||
|
{
|
||||||
|
"binding": "OAUTH_KV",
|
||||||
|
"id": "<Add-KV-ID>"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
/**
|
/**
|
||||||
* Smart Placement
|
* Smart Placement
|
||||||
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
|
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@
|
||||||
"prettier:fix": "prettier --write .",
|
"prettier:fix": "prettier --write .",
|
||||||
"fix:oranda": "sed -i 's/```tsx/```ts/g' README.md",
|
"fix:oranda": "sed -i 's/```tsx/```ts/g' README.md",
|
||||||
"build:site": "npx @axodotdev/oranda build",
|
"build:site": "npx @axodotdev/oranda build",
|
||||||
"deploy:site": "npx wrangler deploy"
|
"deploy:site": "npx wrangler deploy",
|
||||||
|
"postinstall": "ln -sf ../../scripts/pre-commit .git/hooks/pre-commit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"strict-url-sanitise": "^0.0.1"
|
"strict-url-sanitise": "^0.0.1"
|
||||||
|
|
|
||||||
24
scripts/pre-commit
Executable file
24
scripts/pre-commit
Executable file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Pre-commit hook to run prettier on staged files
|
||||||
|
# This hook is called by "git commit" and formats only the files being committed.
|
||||||
|
|
||||||
|
echo "Running prettier on staged files..."
|
||||||
|
|
||||||
|
# Get list of staged files that prettier can handle
|
||||||
|
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx|json|css|scss|md)$' | tr '\n' ' ')
|
||||||
|
|
||||||
|
if [ -z "$STAGED_FILES" ]; then
|
||||||
|
echo "No staged files need formatting."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Formatting files: $STAGED_FILES"
|
||||||
|
|
||||||
|
# Run prettier on staged files
|
||||||
|
pnpm prettier --write $STAGED_FILES
|
||||||
|
|
||||||
|
# Add the formatted files back to staging
|
||||||
|
git add $STAGED_FILES
|
||||||
|
|
||||||
|
echo "Prettier formatting completed."
|
||||||
|
|
@ -30,11 +30,21 @@ function getMCPServers() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'cf-agents',
|
name: 'cf-agents',
|
||||||
url: `http://localhost:${state.cfAgentsPort}/mcp`,
|
url: `http://localhost:${state.cfAgentsPort}/public/mcp`,
|
||||||
expectedTools: 1, // Minimum expected tools count
|
expectedTools: 1, // Minimum expected tools count
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'cf-agents-sse',
|
name: 'cf-agents-sse',
|
||||||
|
url: `http://localhost:${state.cfAgentsPort}/public/sse`,
|
||||||
|
expectedTools: 1, // Minimum expected tools count
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cf-agents-auth',
|
||||||
|
url: `http://localhost:${state.cfAgentsPort}/mcp`,
|
||||||
|
expectedTools: 1, // Minimum expected tools count
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cf-agents-auth-sse',
|
||||||
url: `http://localhost:${state.cfAgentsPort}/sse`,
|
url: `http://localhost:${state.cfAgentsPort}/sse`,
|
||||||
expectedTools: 1, // Minimum expected tools count
|
expectedTools: 1, // Minimum expected tools count
|
||||||
},
|
},
|
||||||
|
|
@ -216,23 +226,25 @@ describe('MCP Connection Integration Tests', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const testScenarios = [
|
const testScenarios = [
|
||||||
// Working examples with auto transport (should pass)
|
// Hono examples (MCP only)
|
||||||
{ serverName: 'hono-mcp', transportType: 'auto' as const },
|
{ serverName: 'hono-mcp', transportType: 'auto' as const },
|
||||||
{ serverName: 'cf-agents', transportType: 'auto' as const },
|
|
||||||
|
|
||||||
// SSE endpoint with SSE transport (should pass)
|
|
||||||
{ serverName: 'cf-agents-sse', transportType: 'sse' as const },
|
|
||||||
|
|
||||||
// Additional test cases for HTTP transport
|
|
||||||
{ serverName: 'hono-mcp', transportType: 'http' as const },
|
{ serverName: 'hono-mcp', transportType: 'http' as const },
|
||||||
{ serverName: 'cf-agents', transportType: 'http' as const },
|
|
||||||
|
|
||||||
// Failing case: SSE endpoint with auto transport (should fail)
|
// Agents, no auth
|
||||||
|
{ serverName: 'cf-agents', transportType: 'auto' as const },
|
||||||
|
{ serverName: 'cf-agents', transportType: 'http' as const },
|
||||||
|
{ serverName: 'cf-agents-sse', transportType: 'sse' as const },
|
||||||
{ serverName: 'cf-agents-sse', transportType: 'auto' as const },
|
{ serverName: 'cf-agents-sse', transportType: 'auto' as const },
|
||||||
|
|
||||||
|
// Agents, with auth
|
||||||
|
{ serverName: 'cf-agents-auth', transportType: 'auto' as const },
|
||||||
|
{ serverName: 'cf-agents-auth', transportType: 'http' as const },
|
||||||
|
{ serverName: 'cf-agents-auth-sse', transportType: 'sse' as const },
|
||||||
|
{ serverName: 'cf-agents-auth-sse', transportType: 'auto' as const },
|
||||||
]
|
]
|
||||||
|
|
||||||
test.each(testScenarios)(
|
test.each(testScenarios)(
|
||||||
'should connect to $serverName with $transportType transport (expect: $shouldPass)',
|
'should connect to $serverName with $transportType transport',
|
||||||
async ({ serverName, transportType }) => {
|
async ({ serverName, transportType }) => {
|
||||||
const servers = getMCPServers()
|
const servers = getMCPServers()
|
||||||
const server = servers.find((s) => s.name === serverName)
|
const server = servers.find((s) => s.name === serverName)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue