157 lines
5.9 KiB
TypeScript
157 lines
5.9 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto"
|
|
import { z } from "zod"
|
|
import type { EnterpriseMcpScenario } from "../src/index.js"
|
|
import { getProviderProfile } from "../src/index.js"
|
|
|
|
const tokenSchema = z.object({ access_token: z.string(), refresh_token: z.string() })
|
|
const rpcSchema = z.object({
|
|
jsonrpc: z.literal("2.0"),
|
|
id: z.union([z.string(), z.number()]),
|
|
result: z.unknown().optional(),
|
|
error: z.object({ code: z.number(), message: z.string(), data: z.unknown().optional() }).optional(),
|
|
})
|
|
|
|
export interface WireToken {
|
|
readonly accessToken: string
|
|
readonly refreshToken: string
|
|
}
|
|
|
|
export interface WireSession extends WireToken {
|
|
readonly sessionId: string
|
|
readonly protocolVersion: string
|
|
}
|
|
|
|
async function json(response: Response): Promise<unknown> {
|
|
const parsed: unknown = JSON.parse(await response.text())
|
|
return parsed
|
|
}
|
|
|
|
export async function authorizeManual(
|
|
baseUrl: string,
|
|
scenario: EnterpriseMcpScenario,
|
|
clientSecret: string,
|
|
): Promise<WireToken> {
|
|
return authorizeClient(baseUrl, scenario, scenario.oauth.clientId, clientSecret)
|
|
}
|
|
|
|
export async function authorizeClient(
|
|
baseUrl: string,
|
|
scenario: EnterpriseMcpScenario,
|
|
clientId: string,
|
|
clientSecret?: string,
|
|
): Promise<WireToken> {
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const mcpUrl = new URL(profile.endpointPath, baseUrl).href
|
|
const verifier = randomBytes(32).toString("base64url")
|
|
const challenge = createHash("sha256").update(verifier).digest("base64url")
|
|
const redirectUri = scenario.oauth.redirectUris[0]
|
|
if (!redirectUri) throw new Error("Scenario redirect URI missing")
|
|
const authorize = new URL(profile.oauth.authorizationPath, baseUrl)
|
|
authorize.searchParams.set("response_type", "code")
|
|
authorize.searchParams.set("client_id", clientId)
|
|
authorize.searchParams.set("redirect_uri", redirectUri)
|
|
authorize.searchParams.set("scope", scenario.oauth.authorizationScopes.join(" "))
|
|
authorize.searchParams.set("resource", mcpUrl)
|
|
authorize.searchParams.set("state", "wire-state")
|
|
authorize.searchParams.set("code_challenge", challenge)
|
|
authorize.searchParams.set("code_challenge_method", "S256")
|
|
const authorizeResponse = await fetch(authorize, { redirect: "manual" })
|
|
if (authorizeResponse.status !== 302) throw new Error(`Authorize failed with ${authorizeResponse.status}`)
|
|
const location = authorizeResponse.headers.get("location")
|
|
if (!location) throw new Error("Authorize response omitted location")
|
|
const code = new URL(location).searchParams.get("code")
|
|
if (!code) throw new Error("Authorize response omitted code")
|
|
const form = new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: clientId,
|
|
redirect_uri: redirectUri,
|
|
code,
|
|
code_verifier: verifier,
|
|
resource: mcpUrl,
|
|
})
|
|
if (clientSecret) form.set("client_secret", clientSecret)
|
|
const tokenResponse = await fetch(new URL(profile.oauth.tokenPath, baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: form,
|
|
})
|
|
if (tokenResponse.status !== 200) throw new Error(`Token exchange failed with ${tokenResponse.status}`)
|
|
const token = tokenSchema.parse(await json(tokenResponse))
|
|
return { accessToken: token.access_token, refreshToken: token.refresh_token }
|
|
}
|
|
|
|
export async function initializeSession(
|
|
baseUrl: string,
|
|
scenario: EnterpriseMcpScenario,
|
|
token: WireToken,
|
|
): Promise<WireSession> {
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const mcpUrl = new URL(profile.endpointPath, baseUrl)
|
|
const response = await fetch(mcpUrl, {
|
|
method: "POST",
|
|
headers: rpcHeaders(baseUrl, token.accessToken),
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "initialize",
|
|
params: {
|
|
protocolVersion: scenario.protocol.version,
|
|
capabilities: {},
|
|
clientInfo: { name: "wire-test", version: "1" },
|
|
},
|
|
}),
|
|
})
|
|
if (response.status !== 200) throw new Error(`Initialize failed with ${response.status}`)
|
|
const envelope = rpcSchema.parse(await json(response))
|
|
if (envelope.error) throw new Error(envelope.error.message)
|
|
const sessionId = response.headers.get("mcp-session-id")
|
|
const protocolVersion = response.headers.get("mcp-protocol-version")
|
|
if (!sessionId || !protocolVersion) throw new Error("Initialize response omitted session headers")
|
|
const initialized = await fetch(mcpUrl, {
|
|
method: "POST",
|
|
headers: sessionHeaders(baseUrl, token.accessToken, sessionId, protocolVersion),
|
|
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
})
|
|
if (initialized.status !== 202) throw new Error(`Initialized notification failed with ${initialized.status}`)
|
|
return { ...token, sessionId, protocolVersion }
|
|
}
|
|
|
|
export function rpcHeaders(baseUrl: string, accessToken: string): Record<string, string> {
|
|
return {
|
|
authorization: `Bearer ${accessToken}`,
|
|
accept: "application/json, text/event-stream",
|
|
"content-type": "application/json",
|
|
origin: new URL(baseUrl).origin,
|
|
}
|
|
}
|
|
|
|
export function sessionHeaders(
|
|
baseUrl: string,
|
|
accessToken: string,
|
|
sessionId: string,
|
|
protocolVersion: string,
|
|
): Record<string, string> {
|
|
return {
|
|
...rpcHeaders(baseUrl, accessToken),
|
|
"mcp-session-id": sessionId,
|
|
"mcp-protocol-version": protocolVersion,
|
|
}
|
|
}
|
|
|
|
export async function callRpc(
|
|
baseUrl: string,
|
|
scenario: EnterpriseMcpScenario,
|
|
session: WireSession,
|
|
id: number,
|
|
method: string,
|
|
params: unknown,
|
|
): Promise<{ readonly response: Response; readonly envelope: z.infer<typeof rpcSchema> }> {
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const response = await fetch(new URL(profile.endpointPath, baseUrl), {
|
|
method: "POST",
|
|
headers: sessionHeaders(baseUrl, session.accessToken, session.sessionId, session.protocolVersion),
|
|
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
|
|
})
|
|
return { response, envelope: rpcSchema.parse(await json(response)) }
|
|
}
|