Skip to content

Commit cf5e9a3

Browse files
committed
feat: make OAuth support optional
1 parent cbedeb4 commit cf5e9a3

8 files changed

Lines changed: 230 additions & 67 deletions

File tree

Project.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ version = "1.0.0"
55
[deps]
66
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
77
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
8-
OAuth = "22d8b318-f366-56fb-a292-a93f7d76c017"
98
Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
109
Sockets = "6462fe0b-24de-5631-8697-dd941f90decc"
1110
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
1211

1312
[weakdeps]
1413
Agentif = "b1e21dde-9d8e-492f-89d0-ee48af23bfc5"
14+
OAuth = "22d8b318-f366-56fb-a292-a93f7d76c017"
1515

1616
[extensions]
1717
ModelContextProtocolAgentifExt = ["Agentif"]
18+
ModelContextProtocolOAuthExt = ["OAuth"]
1819

1920
[compat]
2021
HTTP = "1.11, 2"
@@ -23,7 +24,8 @@ OAuth = "2"
2324
julia = "1.10"
2425

2526
[extras]
27+
OAuth = "22d8b318-f366-56fb-a292-a93f7d76c017"
2628
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
2729

2830
[targets]
29-
test = ["Test"]
31+
test = ["OAuth", "Test"]
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
module ModelContextProtocolOAuthExt
2+
3+
using HTTP
4+
using OAuth
5+
using ModelContextProtocol
6+
import ModelContextProtocol: MCPClient, attach_token!, request_client_credentials_token, start_public_client_flow
7+
8+
function start_public_client_flow(
9+
prm_url::AbstractString,
10+
config::OAuth.PublicClientConfig;
11+
http=HTTP,
12+
issuer::Union{String,Nothing}=nothing,
13+
kwargs...
14+
)
15+
result = OAuth.complete_pkce_authorization(
16+
prm_url,
17+
config;
18+
http=http,
19+
issuer=issuer,
20+
kwargs...,
21+
)
22+
resource = result.session.resource
23+
return (
24+
token=result.token,
25+
authorization_server=result.session.authorization_server,
26+
resource=resource,
27+
session=result.session,
28+
callback=result.callback,
29+
)
30+
end
31+
32+
function start_public_client_flow(
33+
prm_url::AbstractString;
34+
client_id::AbstractString,
35+
redirect_uri=nothing,
36+
scopes=String[],
37+
additional_parameters=nothing,
38+
dpop=nothing,
39+
kwargs...
40+
)
41+
config = OAuth.PublicClientConfig(
42+
client_id=String(client_id),
43+
redirect_uri=redirect_uri,
44+
scopes=scopes,
45+
additional_parameters=additional_parameters,
46+
dpop=dpop,
47+
)
48+
return start_public_client_flow(prm_url, config; kwargs...)
49+
end
50+
51+
function request_client_credentials_token(
52+
prm_url::AbstractString,
53+
config::OAuth.ConfidentialClientConfig;
54+
http=HTTP,
55+
issuer=nothing,
56+
extra_token_params=Dict{String,String}(),
57+
verbose::Bool=false,
58+
)
59+
return OAuth.request_client_credentials_token(
60+
prm_url,
61+
config;
62+
http=http,
63+
issuer=issuer,
64+
extra_token_params=extra_token_params,
65+
verbose=verbose,
66+
)
67+
end
68+
69+
function attach_token!(client::MCPClient, token::OAuth.TokenResponse)
70+
client.auth_token = string(token.token_type, " ", token.access_token)
71+
return token
72+
end
73+
74+
end

src/ModelContextProtocol.jl

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ module ModelContextProtocol
22

33
using HTTP
44
using JSON
5-
using OAuth
65

76
include("types.jl")
87
include("errors.jl")

src/auth.jl

Lines changed: 5 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,10 @@
1-
function start_public_client_flow(
2-
prm_url::AbstractString,
3-
config::PublicClientConfig;
4-
http=HTTP,
5-
issuer::Union{String,Nothing}=nothing,
6-
kwargs...
7-
)
8-
result = OAuth.complete_pkce_authorization(
9-
prm_url,
10-
config;
11-
http=http,
12-
issuer=issuer,
13-
kwargs...,
14-
)
15-
resource = result.session.resource
16-
return (
17-
token=result.token,
18-
authorization_server=result.session.authorization_server,
19-
resource=resource,
20-
session=result.session,
21-
callback=result.callback,
22-
)
23-
end
24-
25-
function start_public_client_flow(
26-
prm_url::AbstractString;
27-
client_id::AbstractString,
28-
redirect_uri=nothing,
29-
scopes=String[],
30-
additional_parameters=nothing,
31-
dpop=nothing,
32-
kwargs...
33-
)
34-
config = PublicClientConfig(
35-
client_id=String(client_id),
36-
redirect_uri=redirect_uri,
37-
scopes=scopes,
38-
additional_parameters=additional_parameters,
39-
dpop=dpop,
40-
)
41-
return start_public_client_flow(prm_url, config; kwargs...)
42-
end
1+
function start_public_client_flow end
432

44-
function request_client_credentials_token(
45-
prm_url::AbstractString,
46-
config::ConfidentialClientConfig;
47-
http=HTTP,
48-
issuer=nothing,
49-
extra_token_params=Dict{String,String}(),
50-
verbose::Bool=false,
51-
)
52-
return OAuth.request_client_credentials_token(
53-
prm_url,
54-
config;
55-
http=http,
56-
issuer=issuer,
57-
extra_token_params=extra_token_params,
58-
verbose=verbose,
59-
)
60-
end
3+
function request_client_credentials_token end
614

62-
function attach_token!(client::MCPClient, token::TokenResponse)
63-
client.auth_token = token
64-
return token
5+
function attach_token!(client::MCPClient, authorization::AbstractString)
6+
client.auth_token = String(authorization)
7+
return authorization
658
end
669

6710
function preferred_resource_metadata(challenges::Vector{MCPAuthenticationChallenge})

src/jsonrpc.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ function build_request_headers(
175175
return headers
176176
end
177177

178-
authorization_value(token::TokenResponse) = string(token.token_type, " ", token.access_token)
178+
authorization_value(token::AbstractString) = String(token)
179179

180180
function normalize_params(params)
181181
params === nothing && return nothing

src/types.jl

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,20 @@ struct MCPClientConfig
140140
verbose::Bool
141141
end
142142

143+
Base.@kwdef struct WWWAuthenticateChallenge
144+
scheme::String
145+
token::Union{String,Nothing}
146+
params::Dict{String,String}
147+
end
148+
149+
function WWWAuthenticateChallenge(scheme::AbstractString; token=nothing, params=Dict{String,String}())
150+
return WWWAuthenticateChallenge(
151+
scheme=String(scheme),
152+
token=token === nothing ? nothing : String(token),
153+
params=Dict{String,String}(params),
154+
)
155+
end
156+
143157
Base.@kwdef struct MCPAuthenticationChallenge
144158
challenge::WWWAuthenticateChallenge
145159
resource_metadata::Union{String,Nothing}
@@ -154,7 +168,7 @@ mutable struct MCPClient
154168
headers::HTTP.Headers
155169
timeout::NamedTuple
156170
verbose::Bool
157-
auth_token::Union{TokenResponse,Nothing}
171+
auth_token::Union{String,Nothing}
158172
session::Union{JSONDict,Nothing}
159173
session_id::Union{String,Nothing}
160174
initialized::Bool

src/util.jl

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,133 @@ function split_scopes(value)
165165
return String[p for p in parts if !isempty(p)]
166166
end
167167

168+
function parse_www_authenticate(header::AbstractString)
169+
challenges = WWWAuthenticateChallenge[]
170+
idx = firstindex(header)
171+
stop = lastindex(header)
172+
while true
173+
idx = skip_delimiters(header, idx, stop)
174+
idx > stop && break
175+
scheme, idx = read_token(header, idx, stop)
176+
isempty(scheme) && break
177+
params = Dict{String,String}()
178+
token = nothing
179+
seen_param = false
180+
while true
181+
idx = skip_spaces(header, idx, stop)
182+
idx > stop && break
183+
if header[idx] == ','
184+
next_idx = Base.nextind(header, idx)
185+
peek_idx = skip_delimiters(header, next_idx, stop)
186+
peek_token, after_peek = read_token(header, peek_idx, stop)
187+
if isempty(peek_token)
188+
idx = after_peek
189+
continue
190+
end
191+
if after_peek <= stop && header[after_peek] == '='
192+
idx = peek_idx
193+
else
194+
idx = next_idx
195+
break
196+
end
197+
end
198+
key_start = idx
199+
key, idx = read_token(header, idx, stop)
200+
isempty(key) && break
201+
idx = skip_spaces(header, idx, stop)
202+
if idx <= stop && header[idx] == '='
203+
idx = Base.nextind(header, idx)
204+
idx = skip_spaces(header, idx, stop)
205+
value, idx = read_value(header, idx, stop)
206+
params[String(key)] = value
207+
seen_param = true
208+
else
209+
if seen_param || token !== nothing
210+
idx = key_start
211+
break
212+
end
213+
token = String(key)
214+
end
215+
end
216+
push!(challenges, WWWAuthenticateChallenge(String(scheme); token, params))
217+
end
218+
return challenges
219+
end
220+
221+
function skip_spaces(str, idx, stop)
222+
while idx <= stop
223+
c = str[idx]
224+
if c == ' ' || c == '\t'
225+
idx = Base.nextind(str, idx)
226+
else
227+
break
228+
end
229+
end
230+
return idx
231+
end
232+
233+
function skip_delimiters(str, idx, stop)
234+
while idx <= stop
235+
c = str[idx]
236+
if c == ' ' || c == '\t' || c == ','
237+
idx = Base.nextind(str, idx)
238+
else
239+
break
240+
end
241+
end
242+
return idx
243+
end
244+
245+
function read_token(str, idx, stop)
246+
start = idx
247+
while idx <= stop
248+
c = str[idx]
249+
if c == ' ' || c == '\t' || c == '=' || c == ',' || c == '"'
250+
break
251+
end
252+
idx = Base.nextind(str, idx)
253+
end
254+
idx == start && return "", idx
255+
last = Base.prevind(str, idx)
256+
return String(str[start:last]), idx
257+
end
258+
259+
function read_value(str, idx, stop)
260+
idx > stop && return "", idx
261+
if str[idx] == '"'
262+
idx = Base.nextind(str, idx)
263+
buf = IOBuffer()
264+
escaped = false
265+
while idx <= stop
266+
c = str[idx]
267+
if escaped
268+
write(buf, c)
269+
escaped = false
270+
elseif c == '\\'
271+
escaped = true
272+
elseif c == '"'
273+
idx = Base.nextind(str, idx)
274+
break
275+
else
276+
write(buf, c)
277+
end
278+
idx = Base.nextind(str, idx)
279+
end
280+
return String(take!(buf)), idx
281+
end
282+
start = idx
283+
while idx <= stop
284+
c = str[idx]
285+
if c == ',' || c == ' ' || c == '\t'
286+
break
287+
end
288+
idx = Base.nextind(str, idx)
289+
end
290+
idx == start && return "", idx
291+
last = Base.prevind(str, idx)
292+
return String(str[start:last]), idx
293+
end
294+
168295
function extract_auth_challenges(headers::HTTP.Headers)
169296
challenges = MCPAuthenticationChallenge[]
170297
for (name, value) in headers

test/runtests.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,10 @@ end
410410
@test cancel_entry["sessionId"] == client.session_id
411411
@test cancel_entry["params"]["requestId"] == "req-1"
412412

413+
attach_token!(client, "Bearer rawtoken")
414+
list_tools(client)
415+
@test any(h -> get(h, "Authorization", "") == "Bearer rawtoken", state.headers)
416+
@test Base.get_extension(ModelContextProtocol, :ModelContextProtocolOAuthExt) !== nothing
413417
token_data = JSON.Object(Dict{String,Any}("access_token" => "stubtoken", "token_type" => "Bearer"))
414418
token = OAuth.TokenResponse(token_data)
415419
attach_token!(client, token)

0 commit comments

Comments
 (0)