Skip to content

Commit 5e73c34

Browse files
authored
Merge pull request #82 from esnible/github_tool
✨ Example of man-in-the-middle MCP server with OIDC and two API keys
2 parents 5176de9 + a1bd673 commit 5e73c34

9 files changed

Lines changed: 915 additions & 0 deletions

File tree

mcp/github_tool/.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
README.md

mcp/github_tool/.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# CLI binary for this project
2+
/mcp-mitm
3+
4+
# Mac stuff
5+
.DS_Store
6+
7+
# IDE-related
8+
.vscode

mcp/github_tool/Dockerfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
FROM golang:1.24.9-bookworm
2+
3+
WORKDIR /app
4+
EXPOSE 8080
5+
6+
COPY . /app
7+
RUN ls -l /app
8+
9+
RUN CGO_ENABLED=0 GOOS=linux go build -o /mcp-mitm /app/cli/main/main.go
10+
11+
CMD ["/mcp-mitm"]

mcp/github_tool/README.md

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
2+
# MCP-MitM
3+
4+
This is a man-in-the-middle that facades to MCP tools of any MCP HTTPStreamable server, and does some cool OIDC to APIKey stuff.
5+
6+
# Develop and debug locally
7+
8+
To use it, `go run cli/main/main.go`. Most secured MCP servers expect authorization to get a list of tools, and this
9+
is provided with `$INIT_AUTH_HEADER`.
10+
11+
The code can be tested locally. For example, to facade the public GitHub MCP server,
12+
13+
```bash
14+
UPSTREAM_MCP="https://api.githubcopilot.com/mcp/" \
15+
INIT_AUTH_HEADER="Bearer ${GITHUB_TOKEN}" \
16+
REQUIRED_SCOPE=profile \
17+
UPSTREAM_HEADER_TO_USE_IF_IN_AUDIENCE="Bearer $GITHUB_TOKEN" \
18+
UPSTREAM_HEADER_TO_USE_IF_NOT_IN_AUDIENCE="Bearer rutabaga" \
19+
go run cli/main/main.go
20+
```
21+
22+
First, get a `GITHUB_TOKEN` by creating one at https://github.com/settings/tokens
23+
24+
At this point, the server localhost:9090 will be offering the same tools as the upstream.
25+
26+
To use it, we must first follow the standard MCP HTTPSTreamable init dance:
27+
28+
```bash
29+
MCP=http://localhost:9090/mcp
30+
curl --include -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" ${MCP} --data '
31+
{
32+
"jsonrpc": "2.0",
33+
"id": 1,
34+
"method": "initialize",
35+
"params": {
36+
"protocolVersion": "2025-03-26",
37+
"capabilities": {
38+
"roots": {
39+
"listChanged": true
40+
},
41+
"sampling": {}
42+
},
43+
"clientInfo": {
44+
"name": "ExampleClient",
45+
"version": "1.0.0"
46+
}
47+
}
48+
}
49+
' | tee /tmp/init-response.txt
50+
51+
SESSION_ID=$(cat /tmp/init-response.txt | grep -i mcp-session-id: | sed 's/mcp-session-id: //I' | sed 's/\r//g')
52+
echo SESSION_ID is "${SESSION_ID}"
53+
54+
curl -v -X POST -H "mcp-session-id: ${SESSION_ID}" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" ${MCP} --data '
55+
{
56+
"jsonrpc": "2.0",
57+
"method": "notifications/initialized"
58+
}
59+
'
60+
```
61+
62+
At this point, optionally, you can ask for tools to verify everything worked:
63+
64+
```bash
65+
curl -v ${MCP} -X POST -H "mcp-session-id: ${SESSION_ID}" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" --data '
66+
{
67+
"jsonrpc": "2.0",
68+
"id": 1,
69+
"method": "tools/list"
70+
}
71+
' | jq
72+
```
73+
74+
Now we want to invoke a tool, to prove it works. This facade expects an OIDC token. We'll need one. To get one from Keycloak in a Kagenti environment:
75+
76+
```bash
77+
KEYCLOAK_USER=admin
78+
KEYCLOAK_PASSWORD=... # use yours
79+
KAGENTI_SECRET=$(oc -n kagenti-system get secret kagenti-ui-oauth-secret -o jsonpath="{.data.CLIENT_SECRET}" | base64 -d)
80+
MITM_TOKEN=$(curl -sX POST -H "Content-Type: application/x-www-form-urlencoded" \
81+
-d "client_secret=$KAGENTI_SECRET" -d "client_id=kagenti" \
82+
-d "grant_type=password" \
83+
-d "username=${KEYCLOAK_USER}" -d "password=${KEYCLOAK_PASSWORD}" \
84+
"http://keycloak.localtest.me:8080/realms/master/protocol/openid-connect/token" | jq --raw-output ".access_token")
85+
echo '$MITM_TOKEN' is ${MITM_TOKEN}
86+
MITM_AUTH="Bearer ${MITM_TOKEN}"
87+
```
88+
89+
REQUIRED_SCOPE tells the man-in-the-middle the expected scope to find.
90+
91+
```bash
92+
export REQUIRED_SCOPE=profile
93+
export UPSTREAM_HEADER_TO_USE_IF_IN_AUDIENCE="Bearer $GITHUB_TOKEN"
94+
export UPSTREAM_HEADER_TO_USE_IF_NOT_IN_AUDIENCE="Bearer rutabaga"
95+
```
96+
97+
To test, we can invoke the man-in-the-middle MCP server with the token from Keycloak simulating how an OIDC-secured Agent
98+
would invoke it:
99+
100+
```bash
101+
curl -v ${MCP} -H "Authorization: ${MITM_AUTH}" -H "mcp-session-id: ${SESSION_ID}" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" --data '
102+
{
103+
"jsonrpc": "2.0",
104+
"id": 2,
105+
"method": "tools/call",
106+
"params": {
107+
"name": "list_branches",
108+
"arguments": {
109+
"owner": "kagenti",
110+
"repo": "kagenti"
111+
}
112+
}
113+
}
114+
' | jq
115+
```
116+
117+
If this works, you should see the list of tools, as JSON.
118+
119+
# Deploying in Kagenti
120+
121+
First, you'll need to give Kagenti credentials. For the GitHub OAuth API key demo, you'll need to supply API keys
122+
123+
oc -n team1 get configmap environments -o yaml > /tmp/environments.yaml
124+
(edit /tmp/environments.yaml) creating the following, and replacing `${GITHUB_TOKEN}` with your GitHub personal access token (PAT):
125+
126+
```YAML
127+
data:
128+
UPSTREAM_MCP: https://api.githubcopilot.com/mcp/
129+
REQUIRED_SCOPE: profile
130+
INIT_AUTH_HEADER: Bearer ${GITHUB_TOKEN}
131+
UPSTREAM_HEADER_TO_USE_IF_IN_AUDIENCE: Bearer $GITHUB_TOKEN
132+
UPSTREAM_HEADER_TO_USE_IF_NOT_IN_AUDIENCE: Bearer rutabaga
133+
```
134+
135+
After adding the new env vars, apply to Kagenti using `kubectl apply -n team1 -f /tmp/environments.yaml`.
136+
137+
Now that the environment variables are available, start an instance of the tool
138+
139+
- Browse to http://kagenti-ui.localtest.me:8080/Import_New_Tool
140+
- Select namespace (e.g. `team1`)
141+
- Set the Target Port to 9090
142+
- Specify Subfolder `mcp/github_tool`
143+
- Click "Build & Deploy New Tool" to deploy.
144+
145+
Once the tool is deployed, if you wish to test it from outside Kagenti you'll need to create an HTTPRoute for it.
146+
147+
```bash
148+
cat <<EOF | > /tmp/expose-github-tool.yaml
149+
apiVersion: gateway.networking.k8s.io/v1
150+
kind: HTTPRoute
151+
metadata:
152+
labels:
153+
mcp-server: "true"
154+
name: github-route-ext
155+
namespace: team1
156+
spec:
157+
hostnames:
158+
- github-tool.127-0-0-1.sslip.io
159+
parentRefs:
160+
- group: gateway.networking.k8s.io
161+
kind: Gateway
162+
name: mcp-gateway
163+
namespace: gateway-system
164+
rules:
165+
- backendRefs:
166+
- group: ""
167+
kind: Service
168+
name: github-tool
169+
port: 8000
170+
weight: 1
171+
matches:
172+
- path:
173+
type: PathPrefix
174+
value: /
175+
EOF
176+
oc --context kind-agent-platform apply -f /tmp/expose-github-tool.yaml
177+
```
178+
179+
At this point, you can do the MCP curls above if you define `MCP=http://github-tool.127-0-0-1.sslip.io:8888/mcp`
180+

mcp/github_tool/cli/main/main.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"net/http"
8+
"os"
9+
"strconv"
10+
"time"
11+
12+
lib "github.com/kagenti/mcp-mitm/gtlib"
13+
)
14+
15+
func main() {
16+
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
17+
18+
mcpServerURL := os.Getenv("UPSTREAM_MCP")
19+
if mcpServerURL == "" {
20+
mcpServerURL = "https://api.githubcopilot.com/mcp/"
21+
}
22+
logger.Info("Using MCP Server", "UPSTREAM_MCP", os.Getenv("UPSTREAM_MCP"), "mcpServerURL", mcpServerURL)
23+
24+
initAuthHeader := os.Getenv("INIT_AUTH_HEADER")
25+
if initAuthHeader == "" {
26+
fmt.Fprintf(os.Stderr, "You must supply INIT_AUTH_HEADER=\"Bearer ${GITHUB_TOKEN}\"\n")
27+
os.Exit(1)
28+
}
29+
logger.Info("Using Authorization header", "initAuthHeader", initAuthHeader)
30+
31+
mcpServer, err := lib.MakeMCPServer(mcpServerURL, initAuthHeader, listenerHost(), listenerPort())
32+
if err != nil {
33+
fmt.Fprintf(os.Stderr, "Can't server MCP: %v", err)
34+
os.Exit(4)
35+
}
36+
37+
// We ignore if the MCP server reports tools changing later
38+
mcpServer.MCPServer().AddTools(lib.ToolsToServerTools(mcpServer, mcpServerURL, mcpServer.Tools())...)
39+
40+
go func() {
41+
logger.Info("[http] starting MCP Broker (public)", "listening", listenerAddress())
42+
if err := mcpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
43+
logger.Error("[http] Cannot start MCP server", "err", err)
44+
}
45+
fmt.Printf("ListenAndServe returned %v\n", err)
46+
}()
47+
48+
ctx := context.Background()
49+
for {
50+
err = mcpServer.ToolsClient().Ping(ctx)
51+
if err != nil {
52+
logger.Error("failed to Ping", "err", err)
53+
} else {
54+
logger.Info("Ping!")
55+
}
56+
time.Sleep(5 * time.Second)
57+
}
58+
}
59+
60+
func listenerHost() string {
61+
listenAddr := os.Getenv("LISTENER_HOST")
62+
if listenAddr != "" {
63+
return listenAddr
64+
}
65+
66+
return "0.0.0.0"
67+
}
68+
69+
func listenerPort() int {
70+
listenPort := os.Getenv("LISTENER_PORT")
71+
if listenPort != "" {
72+
port, err := strconv.Atoi(listenPort)
73+
if err != nil {
74+
panic(fmt.Sprintf("$LISTENER_PORT must be a port number, got %q", port))
75+
}
76+
return port
77+
}
78+
return 9090
79+
}
80+
81+
func listenerAddress() string {
82+
return fmt.Sprintf("%s:%d", listenerHost(), listenerPort())
83+
}

mcp/github_tool/go.mod

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
module github.com/kagenti/mcp-mitm
2+
3+
go 1.24.9
4+
5+
require (
6+
github.com/dgrijalva/jwt-go v3.2.0+incompatible
7+
github.com/mark3labs/mcp-go v0.41.1
8+
)
9+
10+
require (
11+
github.com/bahlo/generic-list-go v0.2.0 // indirect
12+
github.com/buger/jsonparser v1.1.1 // indirect
13+
github.com/google/uuid v1.6.0 // indirect
14+
github.com/invopop/jsonschema v0.13.0 // indirect
15+
github.com/mailru/easyjson v0.7.7 // indirect
16+
github.com/spf13/cast v1.7.1 // indirect
17+
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
18+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
19+
gopkg.in/yaml.v3 v3.0.1 // indirect
20+
)

mcp/github_tool/go.sum

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
2+
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
3+
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
4+
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
5+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
6+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7+
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
8+
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
9+
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
10+
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
11+
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
12+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
13+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
14+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
15+
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
16+
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
17+
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
18+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
19+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
20+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
21+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
22+
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
23+
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
24+
github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA=
25+
github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
26+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
27+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
28+
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
29+
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
30+
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
31+
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
32+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
33+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
34+
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
35+
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
36+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
37+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
38+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
39+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
40+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
41+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)