> ## Documentation Index
> Fetch the complete documentation index at: https://tyk.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Tyk AI Studio Custom Endpoint Plugins

> Learn how to register and serve arbitrary HTTP endpoints on the Edge Gateway using Custom Endpoint plugins for use cases like OAuth providers and webhooks.

## Availability

| Edition | Deployment Type |
| :- | :- |
| [Community](https://tyk.io/docs/ai-management/ai-studio/overview.md#community-edition) & [Enterprise](https://tyk.io/docs/ai-management/ai-studio/overview.md#enterprise-edition) | Self-Managed |

```mermaid theme={null}
graph LR
    A[Client] -->|HTTP Request| B[Edge Gateway]
    B -->|gRPC| C[Plugin Custom Endpoint]
    C -->|HTTP Response| B
    B -->|HTTP Response| A
```

Custom Endpoint plugins allow you to **register and serve arbitrary HTTP endpoints** on the Edge Gateway under the `/plugins/{slug}/` URL namespace. This gives plugins full control over request handling, enabling use cases like OAuth identity providers, MCP proxy servers, webhook receivers, and custom protocol-specific APIs.

## Overview

Custom Endpoints provide plugins with the ability to:

* **Serve custom HTTP APIs** alongside the standard LLM/Tool/Datasource proxy endpoints
* **Handle arbitrary URL paths** with pre-split path segments for easy routing
* **Stream responses** via Server-Sent Events (SSE) for protocols like MCP Streamable HTTP
* **Authenticate requests** using the gateway's existing token system, with full App context (including metadata)
* **Support any HTTP method** (GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD)

### Working Example

See [`examples/plugins/gateway/custom-echo-endpoint/`](https://github.com/TykTechnologies/ai-studio/tree/main/examples/plugins/gateway/custom-echo-endpoint) for a complete, working example that combines **CustomEndpointHandler** + **UIProvider** + **ConfigProvider**. It echoes request metadata and serves content configurable via the Studio admin UI.

### URL Pattern

All custom endpoints are mounted under:

```
/plugins/{slug}/{path...}
```

Where `{slug}` is declared in the plugin's configuration (the `slug` key in the config map), and `{path...}` is the sub-path handled by the plugin.

### How It Works

```mermaid theme={null}
sequenceDiagram
    participant Client as HTTP Client
    participant Gateway as Edge Gateway (Gin Router)
    participant Plugin as Plugin Process

    Client->>Gateway: POST /plugins/my-mcp/mcp
    Note over Gateway: 1. Match /plugins/*path catch-all route<br/>2. Parse slug + relative path<br/>3. Look up EndpointRoute<br/>4. Validate auth & fetch App<br/>5. Build EndpointRequest
    Gateway->>Plugin: gRPC HandleEndpointRequest()
    Note over Plugin: CustomEndpointHandler<br/>Full control over response<br/>Access to App metadata
    Plugin-->>Gateway: EndpointResponse
    Gateway-->>Client: HTTP Response
```

## Implementing Custom Endpoints

### Step 1: Implement CustomEndpointHandler Interface

```go theme={null}
type CustomEndpointHandler interface {
    Plugin
    GetEndpointRegistrations() ([]*pb.EndpointRegistration, error)
    HandleEndpointRequest(ctx Context, req *pb.EndpointRequest) (*pb.EndpointResponse, error)
    HandleEndpointRequestStream(ctx Context, req *pb.EndpointRequest, stream grpc.ServerStreamingServer[pb.EndpointResponseChunk]) error
}
```

### Step 2: Register Your Endpoints

Declare which paths and HTTP methods your plugin handles:

```go expandable theme={null}
func (p *MyPlugin) GetEndpointRegistrations() ([]*pb.EndpointRegistration, error) {
    return []*pb.EndpointRegistration{
        {
            Path:           "/*",                            // Catch-all wildcard
            Methods:        []string{"GET", "POST", "DELETE"},
            Description:    "MCP Streamable HTTP endpoint",
            RequireAuth:    true,                            // Gateway enforces auth
            StreamResponse: true,                            // Use streaming RPC
        },
    }, nil
}
```

**Registration Options:**

| Field | Type | Description |
| - | - | - |
| `path` | string | Relative path under the plugin slug. Use `/*` for catch-all. |
| `methods` | \[]string | HTTP methods to handle. Valid: GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD. |
| `description` | string | Human-readable description of the endpoint. |
| `require_auth` | bool | If true, gateway validates the token and passes the full `App` object to the plugin. |
| `stream_response` | bool | If true, gateway uses `HandleEndpointRequestStream` (SSE). Otherwise uses `HandleEndpointRequest`. |
| `metadata` | map\[string]string | Plugin-defined metadata. |

### Step 3: Handle Requests

#### Unary (Non-Streaming) Endpoints

For standard request/response:

```go expandable theme={null}
func (p *MyPlugin) HandleEndpointRequest(ctx plugin_sdk.Context, req *pb.EndpointRequest) (*pb.EndpointResponse, error) {
    // Route based on path segments
    segments := req.PathSegments

    switch {
    case len(segments) == 0:
        return p.handleRoot(req)
    case segments[0] == ".well-known" && len(segments) >= 2:
        return p.handleWellKnown(segments[1], req)
    case segments[0] == "users" && len(segments) >= 2:
        return p.handleUser(segments[1], req)
    default:
        return &pb.EndpointResponse{
            StatusCode: 404,
            Headers:    map[string]string{"Content-Type": "application/json"},
            Body:       []byte(`{"error": "not found"}`),
        }, nil
    }
}
```

#### Streaming (SSE) Endpoints

For Server-Sent Events or MCP Streamable HTTP:

```go expandable theme={null}
func (p *MyPlugin) HandleEndpointRequestStream(
    ctx plugin_sdk.Context,
    req *pb.EndpointRequest,
    stream grpc.ServerStreamingServer[pb.EndpointResponseChunk],
) error {
    // Send headers first
    stream.Send(&pb.EndpointResponseChunk{
        Type:       pb.EndpointResponseChunk_HEADERS,
        StatusCode: 200,
        Headers: map[string]string{
            "Content-Type":  "text/event-stream",
            "Cache-Control": "no-cache",
            "Connection":    "keep-alive",
        },
    })

    // Send SSE data chunks — each is flushed immediately to the HTTP client
    for i := 0; i < 5; i++ {
        data := fmt.Sprintf("data: {\"count\": %d}\n\n", i)
        stream.Send(&pb.EndpointResponseChunk{
            Type: pb.EndpointResponseChunk_BODY,
            Data: []byte(data),
        })
        time.Sleep(1 * time.Second)
    }

    // Signal stream completion
    stream.Send(&pb.EndpointResponseChunk{
        Type: pb.EndpointResponseChunk_DONE,
    })

    return nil
}
```

**Chunk Protocol:** HEADERS → BODY\* → DONE

| Chunk Type | When | Fields Used |
| - | - | - |
| `HEADERS` | First chunk | `status_code`, `headers` |
| `BODY` | Zero or more times | `data` (raw bytes flushed to client) |
| `DONE` | Final chunk | — |
| `ERROR` | On failure | `error_message` |

### Step 4: Configure the Plugin Slug and Register

The plugin slug (used in the URL path) must be set explicitly in the plugin's config map. The slug determines the URL namespace: `/plugins/{slug}/...`.

#### Manifest

Declare `custom_endpoint` in the manifest's capabilities:

```json theme={null}
{
  "id": "com.example.my-plugin",
  "name": "My Custom Endpoint",
  "version": "1.0.0",
  "capabilities": {
    "hooks": ["custom_endpoint"],
    "primary_hook": "custom_endpoint"
  }
}
```

If the plugin also provides a Studio UI, include `"studio_ui"` in hooks:

```json theme={null}
"hooks": ["custom_endpoint", "studio_ui"]
```

#### Registration via AI Studio

Register the plugin in AI Studio (Admin > Plugins) with:

| Field | Value |
| - | - |
| **Hook type** | `custom_endpoint` |
| **Hook types** | `["custom_endpoint"]` (add `"studio_ui"` if plugin has UI) |
| **Config** | Must include `"slug"` key — e.g., `{"slug": "my-mcp"}` |

The `slug` in the config determines the URL path on the gateway. For example, `{"slug": "my-mcp"}` makes endpoints available at `http://gateway:8081/plugins/my-mcp/...`.

**Important:** The `slug` must be set in the config map. Without it, endpoints will not be registered and you'll see a warning in the gateway logs:

```
Plugin has endpoint registrations but no 'slug' in config
```

#### Building and Deploying

```bash theme={null}
# Build the plugin
cd examples/plugins/gateway/custom-echo-endpoint
go build -o custom-echo-endpoint

# Register with file:// for local development
# Command: file:///path/to/custom-echo-endpoint
# Config: {"slug": "custom-echo-endpoint", "custom_content": "Hello World"}
```

#### Gateway Loading

Custom endpoint plugins are loaded automatically at gateway startup via the pre-warming system. The gateway:

1. Queries all active plugins with `custom_endpoint` hook type
2. Loads each plugin (starts the binary, initializes via gRPC)
3. Calls `GetEndpointRegistrations()` to discover endpoints
4. Registers routes using the `slug` from config

After a control plane config sync, routes are refreshed automatically.

## EndpointRequest Fields

When a request arrives, the plugin receives a rich `EndpointRequest`:

| Field | Type | Description |
| - | - | - |
| `method` | string | HTTP method (GET, POST, etc.) |
| `path` | string | Full request path (`/plugins/my-mcp/users/123`) |
| `relative_path` | string | Path relative to plugin mount (`/users/123`) |
| `path_segments` | \[]string | Pre-split segments: `["users", "123"]` |
| `headers` | map\[string]string | Request headers |
| `body` | bytes | Request body |
| `query_string` | string | Raw query string (`foo=bar&baz=1`) |
| `remote_addr` | string | Client IP address |
| `host` | string | Request Host header |
| `protocol` | string | `"http"` (future: `"websocket"`, `"sse"`) |
| `context` | PluginContext | Request ID, metadata |
| `authenticated` | bool | Whether request was authenticated |
| `app` | App | Full App object (when authenticated) |
| `scopes` | \[]string | Token scopes (when authenticated) |

### Path Segments

The `path_segments` field pre-splits the relative path for easy pattern matching:

| Request URL | `relative_path` | `path_segments` |
| - | - | - |
| `/plugins/my-mcp/` | `/` | `[]` |
| `/plugins/my-mcp/mcp` | `/mcp` | `["mcp"]` |
| `/plugins/my-mcp/users/123/profile` | `/users/123/profile` | `["users", "123", "profile"]` |
| `/plugins/my-mcp/.well-known/openid-configuration` | `/.well-known/openid-configuration` | `[".well-known", "openid-configuration"]` |

## Authentication and App Context

When `require_auth: true`, the gateway:

> [!NOTE]
>
> From v2.2.1, a custom-endpoint plugin has its own ordered list of auth plugins. When the list is not empty, only these plugins authenticate requests to its endpoints on the Edge Gateway, and App keys are refused. The gateway asks the plugins in order, so put the plugin that authenticates the most requests first. Set the list in the **Authentication plugins** section of the plugin detail page.

1. Extracts the token from `Authorization: Bearer <token>` header or `?token=` query param
2. Validates the token via the gateway's auth provider
3. Fetches the **full App object** linked to the token
4. Populates `EndpointRequest` with `authenticated=true`, `app`, and `scopes`

The `App` object includes:

| Field | Type | Description |
| - | - | - |
| `id` | uint32 | App ID |
| `name` | string | App name |
| `description` | string | App description |
| `owner_email` | string | Owner email address |
| `is_active` | bool | Whether app is active |
| `monthly_budget` | double | Monthly budget limit |
| `rate_limit` | int32 | Rate limit (requests per minute) |
| `metadata` | map\[string]string | Custom key-value metadata |

### Access Control via App Metadata

The recommended pattern for per-app access control is to store ACL rules in App metadata, which admins configure per-app:

```go expandable theme={null}
func (p *MyPlugin) HandleEndpointRequest(ctx plugin_sdk.Context, req *pb.EndpointRequest) (*pb.EndpointResponse, error) {
    if !req.Authenticated || req.App == nil {
        return &pb.EndpointResponse{StatusCode: 401, Body: []byte("Unauthorized")}, nil
    }

    // Check custom ACL in App metadata
    if req.App.Metadata["mcp_access"] != "allowed" {
        return &pb.EndpointResponse{StatusCode: 403, Body: []byte("Forbidden")}, nil
    }

    // Check allowed operations
    allowedOps := req.App.Metadata["allowed_operations"]
    if allowedOps != "" && !strings.Contains(allowedOps, req.Method) {
        return &pb.EndpointResponse{StatusCode: 405, Body: []byte("Method not allowed for this app")}, nil
    }

    // Access granted — handle request
    return p.processRequest(req)
}
```

## Route Matching

The gateway matches routes in this order:

1. **Exact match** — `GET:/plugins/my-oauth/.well-known/openid-configuration`
2. **Wildcard catch-all** — `GET:/plugins/my-oauth/*`

**Recommended:** Register a single `/*` catch-all and handle routing internally using `path_segments`. This is the simplest and most flexible approach.

A plugin can also register multiple specific paths:

```go expandable theme={null}
func (p *MyPlugin) GetEndpointRegistrations() ([]*pb.EndpointRegistration, error) {
    return []*pb.EndpointRegistration{
        {
            Path:        "/.well-known/openid-configuration",
            Methods:     []string{"GET"},
            Description: "OpenID Connect discovery",
        },
        {
            Path:        "/token",
            Methods:     []string{"POST"},
            Description: "Token endpoint",
            RequireAuth: false,
        },
        {
            Path:        "/userinfo",
            Methods:     []string{"GET"},
            Description: "UserInfo endpoint",
            RequireAuth: true,
        },
    }, nil
}
```

## MCP Streamable HTTP Support

Custom endpoints are designed to support MCP (Model Context Protocol) Streamable HTTP out of the box.

### MCP Protocol Summary

MCP Streamable HTTP uses a single endpoint with:

* **POST**: Client sends JSON-RPC messages; server responds with `application/json` or `text/event-stream`
* **GET**: Client opens an inbound SSE stream for server-initiated messages
* **DELETE**: Client terminates the session
* Session tracking via `Mcp-Session-Id` header

### MCP Proxy Plugin Pattern

```go expandable theme={null}
type MCPProxyPlugin struct {
    plugin_sdk.BasePlugin
    sessions sync.Map // sessionID → session state
}

func (p *MCPProxyPlugin) GetEndpointRegistrations() ([]*pb.EndpointRegistration, error) {
    return []*pb.EndpointRegistration{
        {
            Path:           "/*",
            Methods:        []string{"POST", "GET", "DELETE"},
            Description:    "MCP Streamable HTTP proxy",
            RequireAuth:    true,
            StreamResponse: true, // Use streaming for all requests
        },
    }, nil
}

func (p *MCPProxyPlugin) HandleEndpointRequestStream(
    ctx plugin_sdk.Context,
    req *pb.EndpointRequest,
    stream grpc.ServerStreamingServer[pb.EndpointResponseChunk],
) error {
    switch req.Method {
    case "POST":
        return p.handleMCPPost(ctx, req, stream)
    case "GET":
        return p.handleMCPGet(ctx, req, stream)
    case "DELETE":
        return p.handleMCPDelete(ctx, req, stream)
    default:
        stream.Send(&pb.EndpointResponseChunk{
            Type:       pb.EndpointResponseChunk_HEADERS,
            StatusCode: 405,
        })
        stream.Send(&pb.EndpointResponseChunk{Type: pb.EndpointResponseChunk_DONE})
        return nil
    }
}

func (p *MCPProxyPlugin) handleMCPPost(ctx plugin_sdk.Context, req *pb.EndpointRequest, stream grpc.ServerStreamingServer[pb.EndpointResponseChunk]) error {
    // Parse JSON-RPC request
    var rpcReq map[string]interface{}
    json.Unmarshal(req.Body, &rpcReq)

    // Check if client accepts SSE
    acceptsSSE := strings.Contains(req.Headers["Accept"], "text/event-stream")

    if acceptsSSE {
        // Stream response as SSE
        stream.Send(&pb.EndpointResponseChunk{
            Type:       pb.EndpointResponseChunk_HEADERS,
            StatusCode: 200,
            Headers: map[string]string{
                "Content-Type":  "text/event-stream",
                "Cache-Control": "no-cache",
            },
        })

        // Send SSE events...
        for _, event := range p.processRPCRequest(rpcReq) {
            data := fmt.Sprintf("data: %s\n\n", event)
            stream.Send(&pb.EndpointResponseChunk{
                Type: pb.EndpointResponseChunk_BODY,
                Data: []byte(data),
            })
        }
    } else {
        // Single JSON response
        result := p.processRPCRequestSync(rpcReq)
        body, _ := json.Marshal(result)
        stream.Send(&pb.EndpointResponseChunk{
            Type:       pb.EndpointResponseChunk_HEADERS,
            StatusCode: 200,
            Headers:    map[string]string{"Content-Type": "application/json"},
        })
        stream.Send(&pb.EndpointResponseChunk{
            Type: pb.EndpointResponseChunk_BODY,
            Data: body,
        })
    }

    stream.Send(&pb.EndpointResponseChunk{Type: pb.EndpointResponseChunk_DONE})
    return nil
}
```

The gateway is a transparent pipe — all MCP protocol logic (JSON-RPC, sessions, resumability) lives in the plugin.

## Complete Example: Webhook Receiver

```go expandable theme={null}
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"

    "github.com/TykTechnologies/midsommar/v2/pkg/plugin_sdk"
    pb "github.com/TykTechnologies/midsommar/v2/proto"
)

type WebhookPlugin struct {
    plugin_sdk.BasePlugin
    secret string
}

func NewWebhookPlugin() *WebhookPlugin {
    return &WebhookPlugin{
        BasePlugin: plugin_sdk.NewBasePlugin("webhook-receiver", "1.0.0", "Receives and validates webhooks"),
    }
}

func (p *WebhookPlugin) Initialize(ctx plugin_sdk.Context, config map[string]string) error {
    p.secret = config["webhook_secret"]
    if p.secret == "" {
        return fmt.Errorf("webhook_secret is required")
    }
    ctx.Services.Logger().Info("Webhook receiver initialized")
    return nil
}

func (p *WebhookPlugin) GetEndpointRegistrations() ([]*pb.EndpointRegistration, error) {
    return []*pb.EndpointRegistration{
        {
            Path:        "/*",
            Methods:     []string{"POST"},
            Description: "Webhook receiver endpoint",
            RequireAuth: false, // Webhooks use their own auth (HMAC signature)
        },
    }, nil
}

func (p *WebhookPlugin) HandleEndpointRequest(ctx plugin_sdk.Context, req *pb.EndpointRequest) (*pb.EndpointResponse, error) {
    // Validate HMAC signature
    signature := req.Headers["X-Webhook-Signature"]
    if !p.validateSignature(req.Body, signature) {
        return &pb.EndpointResponse{
            StatusCode: 401,
            Headers:    map[string]string{"Content-Type": "application/json"},
            Body:       []byte(`{"error": "invalid signature"}`),
        }, nil
    }

    // Route by path segments
    segments := req.PathSegments
    if len(segments) == 0 {
        return &pb.EndpointResponse{StatusCode: 400, Body: []byte(`{"error": "missing event type"}`)}, nil
    }

    eventType := segments[0]
    ctx.Services.Logger().Info("Webhook received", "event", eventType, "body_size", len(req.Body))

    // Process webhook
    switch eventType {
    case "payment":
        return p.handlePaymentWebhook(ctx, req)
    case "user":
        return p.handleUserWebhook(ctx, req)
    default:
        return &pb.EndpointResponse{
            StatusCode: 200,
            Headers:    map[string]string{"Content-Type": "application/json"},
            Body:       []byte(`{"status": "acknowledged"}`),
        }, nil
    }
}

func (p *WebhookPlugin) HandleEndpointRequestStream(ctx plugin_sdk.Context, req *pb.EndpointRequest, stream pb.PluginService_HandleEndpointRequestStreamServer) error {
    return fmt.Errorf("streaming not supported for webhooks")
}

func (p *WebhookPlugin) validateSignature(body []byte, signature string) bool {
    mac := hmac.New(sha256.New, []byte(p.secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signature))
}

func main() {
    plugin_sdk.Serve(NewWebhookPlugin())
}
```

## Lifecycle Management

Custom endpoint routes are managed automatically across all plugin lifecycle events:

| Event | Route Behavior |
| - | - |
| **Plugin loaded** | Endpoints registered after `Initialize()` |
| **Plugin unloaded** | All routes for this plugin removed |
| **Plugin reloaded** | Routes cleared then re-registered |
| **Plugin deactivated** | Plugin unloaded, routes removed |
| **Plugin deleted** | Plugin unloaded, routes removed |
| **Gateway shutdown** | All routes cleared |
| **Health check failure** | Plugin auto-restarted, routes re-registered |
| **Control plane sync** | Routes refreshed to match new config |

## Error Handling

| Scenario | HTTP Status |
| - | - |
| No route match | 404 Not Found |
| Plugin not loaded / unhealthy | 503 Service Unavailable |
| Plugin returns error via gRPC | 502 Bad Gateway |
| gRPC timeout (60s unary / configurable stream) | 504 Gateway Timeout |
| Auth required but missing/invalid | 401 Unauthorized |
| Streaming error after headers sent | Connection closed, error logged |

## Configuration

### Streaming Timeout

The streaming endpoint timeout is configurable via environment variable:

| Environment Variable | Default | Description |
| - | - | - |
| `PLUGIN_ENDPOINT_STREAM_TIMEOUT` | `5m` | Maximum duration for streaming endpoint responses (SSE) |
| `PLUGIN_ENDPOINT_MAX_BODY_SIZE` | `1048576` (1MB) | Maximum request body size for custom plugin endpoints |

For long-running streaming connections (e.g., MCP proxy, real-time data feeds), increase the timeout:

```bash theme={null}
PLUGIN_ENDPOINT_STREAM_TIMEOUT=30m
```

The upstream server should send periodic data to keep the connection alive within the timeout window.
