Skip to content
Open
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
24 changes: 24 additions & 0 deletions src/ax/mcp/oauth/discovery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { fetchJSON, stripTrailingSlash } from '../util/http.js';

/**
* Extracts the resource_metadata URL from a WWW-Authenticate header value.
* Supports both quoted and unquoted parameter formats.
* @param www - The WWW-Authenticate header value to parse
* @returns The extracted resource_metadata URL, or null if not found
*/
export function parseWWWAuthenticateForResourceMetadata(
www: string | null
): string | null {
Expand All @@ -10,6 +16,16 @@ export function parseWWWAuthenticateForResourceMetadata(
return match ? match[1] : null;
}

/**
* Discovers the protected resource identifier and its authorization servers.
* First attempts to use the resource_metadata URL from the WWW-Authenticate header.
* If not available, falls back to trying well-known endpoints with and without path components.
* Validates that the discovered resource matches the requested URL and that authorization servers are advertised.
* @param requestedUrl - The URL of the protected resource being accessed
* @param wwwAuthenticate - The WWW-Authenticate header value from the response
* @returns An object containing the resource identifier and array of issuer URLs
* @throws {Error} If resource metadata cannot be discovered or validation fails
*/
export async function discoverResourceAndAS(
requestedUrl: string,
wwwAuthenticate: string | null
Expand Down Expand Up @@ -82,6 +98,14 @@ export async function discoverResourceAndAS(
);
}

/**
* Discovers authorization server metadata by attempting multiple well-known endpoint patterns.
* Tries OAuth authorization server and OpenID configuration endpoints with various path combinations.
* Validates that the metadata includes required endpoints and PKCE S256 support.
* @param issuer - The authorization server issuer URL
* @returns The authorization server metadata object
* @throws {Error} If metadata cannot be discovered or validation fails
*/
export async function discoverASMetadata(issuer: string): Promise<any> {
const u = new URL(issuer);
const path = u.pathname.replace(/^\/+/, '');
Expand Down
69 changes: 69 additions & 0 deletions src/ax/mcp/transports/sseTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import type {
import type { AxMCPStreamableHTTPTransportOptions } from './options.js';
import { OAuthHelper } from '../oauth/oauthHelper.js';

/**
* HTTP Server-Sent Events (SSE) transport implementation for MCP communication.
* Establishes a persistent SSE connection for receiving messages and uses HTTP POST for sending.
*/
export class AxMCPHTTPSSETransport implements AxMCPTransport {
private endpoint: string | null = null;
private sseUrl: string;
Expand All @@ -28,6 +32,13 @@ export class AxMCPHTTPSSETransport implements AxMCPTransport {
) => void;
private endpointReady?: { resolve: () => void; promise: Promise<void> };

/**
* Creates a new SSE transport instance.
* Initializes custom headers and OAuth helper from the provided options.
*
* @param sseUrl - The URL to establish the SSE connection
* @param options - Optional configuration including headers and OAuth settings
*/
constructor(sseUrl: string, options?: AxMCPStreamableHTTPTransportOptions) {
this.sseUrl = sseUrl;
this.customHeaders = { ...(options?.headers ?? {}) };
Expand All @@ -36,10 +47,25 @@ export class AxMCPHTTPSSETransport implements AxMCPTransport {
this.oauthHelper = new OAuthHelper(options?.oauth);
}

/**
* Merges custom headers with the provided base headers.
*
* @param base - The base headers to merge with custom headers
* @returns The merged headers object
*/
private buildHeaders(base: Record<string, string>): Record<string, string> {
return { ...this.customHeaders, ...base };
}

/**
* Establishes an SSE connection using the Fetch API.
* Handles OAuth authentication by retrying with a bearer token if a 401 response is received.
* Waits for the endpoint URI to be received via SSE before resolving.
*
* @param headers - The headers to use for the SSE connection request
* @throws {Error} If a 401 response is received and OAuth authentication fails
* @throws {Error} If the SSE connection cannot be established
*/
private async openSSEWithFetch(
headers: Record<string, string>
): Promise<void> {
Expand Down Expand Up @@ -72,6 +98,12 @@ export class AxMCPHTTPSSETransport implements AxMCPTransport {
await ready;
}

/**
* Creates a promise that resolves when the endpoint URI is received.
* Returns the existing promise if already created.
*
* @returns A promise that resolves when the endpoint is ready
*/
private createEndpointReady(): Promise<void> {
if (!this.endpointReady) {
let resolver!: () => void;
Expand All @@ -83,6 +115,16 @@ export class AxMCPHTTPSSETransport implements AxMCPTransport {
return this.endpointReady.promise;
}

/**
* Processes the SSE stream from the response body.
* Parses SSE events and handles 'endpoint' events to set the endpoint URI.
* Routes JSON-RPC messages to pending request handlers or the message handler.
* Continues reading until the stream is closed.
*
* @param response - The fetch response containing the SSE stream
* @throws {Error} If the response body is not available
* @throws {Error} If the endpoint URI is missing in the SSE event data
*/
private async consumeSSEStream(response: Response): Promise<void> {
if (!response.body)
throw new Error('No response body available for SSE stream');
Expand Down Expand Up @@ -150,11 +192,25 @@ export class AxMCPHTTPSSETransport implements AxMCPTransport {
}
}

/**
* Establishes the SSE connection and waits for the endpoint URI to be received.
*/
async connect(): Promise<void> {
const headers = this.buildHeaders({ Accept: 'text/event-stream' });
await this.openSSEWithFetch(headers);
}

/**
* Sends a JSON-RPC request to the endpoint and returns the response.
* Handles OAuth authentication by retrying with a bearer token if a 401 response is received.
* If the response is not immediate JSON, waits for the response to arrive via SSE.
*
* @param message - The JSON-RPC request to send
* @returns The JSON-RPC response received from the server
* @throws {Error} If the endpoint is not initialized
* @throws {Error} If a 401 response is received and OAuth authentication fails
* @throws {Error} If the HTTP request fails with a non-OK status
*/
async send(
message: Readonly<AxMCPJSONRPCRequest<unknown>>
): Promise<AxMCPJSONRPCResponse<unknown>> {
Expand Down Expand Up @@ -211,6 +267,16 @@ export class AxMCPHTTPSSETransport implements AxMCPTransport {
return pending;
}

/**
* Sends a JSON-RPC notification to the endpoint without expecting a response.
* Handles OAuth authentication by retrying with a bearer token if a 401 response is received.
* Expects a 202 status code for successful notifications.
*
* @param message - The JSON-RPC notification to send
* @throws {Error} If the endpoint is not initialized
* @throws {Error} If a 401 response is received and OAuth authentication fails
* @throws {Error} If the HTTP request fails with a non-OK status
*/
async sendNotification(
message: Readonly<AxMCPJSONRPCNotification>
): Promise<void> {
Expand Down Expand Up @@ -251,6 +317,9 @@ export class AxMCPHTTPSSETransport implements AxMCPTransport {
console.warn(`Unexpected status for notification: ${res.status}`);
}

/**
* Closes the SSE connection and aborts any ongoing requests.
*/
close(): void {
if (this.eventSource) {
this.eventSource.close();
Expand Down
20 changes: 20 additions & 0 deletions src/docs/src/components/TypeDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ interface FieldTypeOption {
requiresLanguage?: boolean;
}

/**
* Available field type options with their metadata and requirements.
* Each type includes display information and optional constraints for options or language specification.
*/
const FIELD_TYPES: FieldTypeOption[] = [
{
value: 'string',
Expand Down Expand Up @@ -86,6 +90,11 @@ interface TypeDropdownProps {
isInputField?: boolean;
}

/**
* Dropdown component for selecting field types with optional modifiers.
* Displays available field types filtered by input/output context, allows toggling optional and array modifiers,
* handles click-outside and keyboard events for closing, and formats the selected type with appropriate syntax.
*/
export default function TypeDropdown({
visible,
position,
Expand All @@ -99,6 +108,9 @@ export default function TypeDropdown({
const dropdownRef = useRef<HTMLDivElement>(null);

useEffect(() => {
/**
* Closes the dropdown when a click occurs outside the dropdown element.
*/
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
Expand All @@ -116,6 +128,9 @@ export default function TypeDropdown({
}, [visible, onClose]);

useEffect(() => {
/**
* Closes the dropdown when the Escape key is pressed.
*/
function handleKeyDown(event: KeyboardEvent) {
if (!visible) return;

Expand All @@ -130,6 +145,11 @@ export default function TypeDropdown({

if (!visible) return null;

/**
* Processes the selected field type and formats it with modifiers.
* Adds special formatting for types requiring options, appends array notation if selected,
* prefixes with colon, and resets the modifier state after selection.
*/
const handleTypeSelect = (type: FieldTypeOption) => {
let insertText = type.value;

Expand Down
Loading