ask-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +189 -0
- data/docs/auth-setup.md +141 -0
- data/lib/ask/mcp/adapters/ask_tool.rb +43 -0
- data/lib/ask/mcp/auth/oauth.rb +105 -0
- data/lib/ask/mcp/auth/token.rb +24 -0
- data/lib/ask/mcp/client.rb +219 -0
- data/lib/ask/mcp/native/messages.rb +161 -0
- data/lib/ask/mcp/prompt.rb +30 -0
- data/lib/ask/mcp/resource.rb +32 -0
- data/lib/ask/mcp/server.rb +41 -0
- data/lib/ask/mcp/tool.rb +41 -0
- data/lib/ask/mcp/transport/sse.rb +152 -0
- data/lib/ask/mcp/transport/stdio.rb +122 -0
- data/lib/ask/mcp/transport/streamable_http.rb +112 -0
- data/lib/ask/mcp/validator.rb +51 -0
- data/lib/ask/mcp/version.rb +5 -0
- data/lib/ask/mcp.rb +65 -0
- data/lib/ask-mcp.rb +1 -0
- metadata +135 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f1427ce9d98f71ef7654b7f8ac59cfd2c58940dbecf5d32525005e1af1e1a440
|
|
4
|
+
data.tar.gz: e4efdc1538eb707317ceb9f1fb2d06ba5b2e0f4903ee9a8eff7c094915d28d36
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e18c000c2f9069f8648e1090c0283d33359ae451172b0c831fa60b7d3ccc980285ea963bda1e86cae26ffa35ac5cfea6b89a48da6ad84b34ac1fc492ce9de3bf
|
|
7
|
+
data.tar.gz: 1456d670425a9b6cfb1f0609f6af58b4b81ea1d10755b3f6c0c9d0f2e93b7cf146a6aecbcca1426fcb9e3387ab4a94b3320a3f4c21d5486935bacd6e7d27f6d8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-06-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Core MCP client with full JSON-RPC 2.0 message layer
|
|
7
|
+
- stdio transport for local process MCP servers
|
|
8
|
+
- SSE transport for remote Server-Sent Events MCP servers
|
|
9
|
+
- Streamable HTTP transport for remote HTTP MCP servers
|
|
10
|
+
- Tool, Resource, and Prompt data models with `from_h`/`to_h` serialization
|
|
11
|
+
- Client lifecycle: initialize, capabilities discovery, session management
|
|
12
|
+
- Tool calling, resource reading, and prompt retrieval
|
|
13
|
+
- Token-based authentication (Bearer/Basic)
|
|
14
|
+
- OAuth 2.1 authentication (client credentials + authorization code flows)
|
|
15
|
+
- Ask::Tool adapter for integration with ask-agent
|
|
16
|
+
- Thread-safe request/response matching with configurable timeouts
|
|
17
|
+
- Server-side notifications handling (tools/resources/prompts list changed)
|
|
18
|
+
- Comprehensive test suite with mock MCP server
|
|
19
|
+
- Factory methods: `from_stdio`, `from_sse`, `from_http`, `connect`
|
|
20
|
+
- Ability to cache or bypass caching for tools/resources/prompts
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# ask-mcp
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/ask-mcp)
|
|
4
|
+
|
|
5
|
+
**Model Context Protocol (MCP) client for Ruby.** Connect to MCP servers via
|
|
6
|
+
stdio, SSE, or Streamable HTTP transports. Discover tools, resources, and
|
|
7
|
+
prompts. Supports the full MCP protocol with OAuth 2.1 authentication.
|
|
8
|
+
|
|
9
|
+
MCP is the industry standard for LLM tool discovery — the same protocol used by
|
|
10
|
+
Claude Code, Codex, Cursor, and GitHub Copilot.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
gem "ask-mcp"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or add to your Gemfile:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "ask-mcp", "~> 0.1.0"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
require "ask/mcp"
|
|
28
|
+
|
|
29
|
+
# Connect to a local MCP server via stdio
|
|
30
|
+
client = Ask::MCP.from_stdio("npx", ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"])
|
|
31
|
+
client.start
|
|
32
|
+
|
|
33
|
+
# List available tools
|
|
34
|
+
client.tools.each { |name, tool| puts "#{name}: #{tool.description}" }
|
|
35
|
+
|
|
36
|
+
# Call a tool
|
|
37
|
+
result = client.call_tool("read_file", path: "/tmp/test.txt")
|
|
38
|
+
puts result
|
|
39
|
+
|
|
40
|
+
# Clean up
|
|
41
|
+
client.stop
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Transports
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# stdio — local processes
|
|
48
|
+
Ask::MCP.from_stdio("npx", ["-y", "@modelcontextprotocol/server-github"])
|
|
49
|
+
|
|
50
|
+
# SSE — remote servers with Server-Sent Events
|
|
51
|
+
Ask::MCP.from_sse("https://mcp.example.com/sse")
|
|
52
|
+
|
|
53
|
+
# Streamable HTTP — remote servers
|
|
54
|
+
Ask::MCP.from_http("https://mcp.example.com/mcp")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
### Client Lifecycle
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# Create a client with any transport
|
|
63
|
+
transport = Ask::MCP::Transport::Stdio.new("ruby", ["server.rb"])
|
|
64
|
+
client = Ask::MCP::Client.new(transport, timeout: 30)
|
|
65
|
+
|
|
66
|
+
# Start the session (sends initialize + receives capabilities)
|
|
67
|
+
client.start
|
|
68
|
+
|
|
69
|
+
# Use the client
|
|
70
|
+
client.tools # => { "tool_name" => #<Ask::MCP::Tool> }
|
|
71
|
+
client.resources # => { "resource_uri" => #<Ask::MCP::Resource> }
|
|
72
|
+
client.prompts # => { "prompt_name" => #<Ask::MCP::Prompt> }
|
|
73
|
+
client.call_tool("tool_name", arg1: "value")
|
|
74
|
+
client.read_resource("file:///path")
|
|
75
|
+
client.get_prompt("prompt_name", arg1: "value")
|
|
76
|
+
|
|
77
|
+
# Stop the session
|
|
78
|
+
client.stop
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Tool, Resource, Prompt Objects
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
# Tool
|
|
85
|
+
tool = Ask::MCP::Tool.new(
|
|
86
|
+
name: "read_file",
|
|
87
|
+
description: "Read a file from disk",
|
|
88
|
+
input_schema: {
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: { path: { type: "string" } },
|
|
91
|
+
required: ["path"]
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
tool.name # => "read_file"
|
|
95
|
+
tool.description # => "Read a file from disk"
|
|
96
|
+
tool.input_schema # => { type: "object", ... }
|
|
97
|
+
|
|
98
|
+
# Resource
|
|
99
|
+
resource = Ask::MCP::Resource.new(
|
|
100
|
+
uri: "file:///tmp/test.txt",
|
|
101
|
+
name: "Test File",
|
|
102
|
+
mime_type: "text/plain"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Prompt
|
|
106
|
+
prompt = Ask::MCP::Prompt.new(
|
|
107
|
+
name: "greet",
|
|
108
|
+
description: "Generate a greeting",
|
|
109
|
+
arguments: [{ name: "name", description: "Name to greet", required: true }]
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Authentication
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# Token-based auth
|
|
117
|
+
token = Ask::MCP::Auth::Token.new("my-api-token")
|
|
118
|
+
headers = token.apply({}) # => { "Authorization" => "Bearer my-api-token" }
|
|
119
|
+
|
|
120
|
+
# OAuth 2.1
|
|
121
|
+
oauth = Ask::MCP::Auth::OAuth.new(
|
|
122
|
+
client_id: "my-client",
|
|
123
|
+
client_secret: "my-secret",
|
|
124
|
+
token_url: "https://auth.example.com/token",
|
|
125
|
+
scopes: ["mcp"]
|
|
126
|
+
)
|
|
127
|
+
oauth.authenticate!
|
|
128
|
+
headers = oauth.apply({})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### With ask-agent
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
require "ask/mcp"
|
|
135
|
+
|
|
136
|
+
client = Ask::MCP.from_stdio("npx", ["-y", "@modelcontextprotocol/server-github"])
|
|
137
|
+
client.start
|
|
138
|
+
|
|
139
|
+
# Convert MCP tools to Ask::Tool instances for use with Ask::Agent
|
|
140
|
+
client.tools.each do |name, mcp_tool|
|
|
141
|
+
agent.register_tool(mcp_tool.to_ask_tool)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Or use the adapter directly
|
|
145
|
+
wrapped = Ask::MCP::Adapters::AskTool.wrap(client.tools)
|
|
146
|
+
wrapped.each { |name, adapter| agent.register_tool(adapter.to_ask_tool) }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Architecture
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
ask-mcp/
|
|
153
|
+
├── lib/ask/mcp.rb # Entry point, factory methods
|
|
154
|
+
├── lib/ask/mcp/client.rb # MCP client (connect, call_tool, etc.)
|
|
155
|
+
├── lib/ask/mcp/server.rb # MCP server representation
|
|
156
|
+
├── lib/ask/mcp/tool.rb # MCP tool representation
|
|
157
|
+
├── lib/ask/mcp/resource.rb # MCP resource representation
|
|
158
|
+
├── lib/ask/mcp/prompt.rb # MCP prompt representation
|
|
159
|
+
├── lib/ask/mcp/native/messages.rb # JSON-RPC message layer
|
|
160
|
+
├── lib/ask/mcp/transport/
|
|
161
|
+
│ ├── stdio.rb # stdio transport
|
|
162
|
+
│ ├── sse.rb # Server-Sent Events transport
|
|
163
|
+
│ └── streamable_http.rb # Streamable HTTP transport
|
|
164
|
+
├── lib/ask/mcp/auth/
|
|
165
|
+
│ ├── oauth.rb # OAuth 2.1 for MCP
|
|
166
|
+
│ └── token.rb # Token-based auth
|
|
167
|
+
└── lib/ask/mcp/adapters/
|
|
168
|
+
└── ask_tool.rb # MCP::Tool → Ask::Tool adapter
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# Run tests
|
|
175
|
+
bundle exec rake test
|
|
176
|
+
|
|
177
|
+
# Run specific tests
|
|
178
|
+
bundle exec ruby -Itest test/messages_test.rb
|
|
179
|
+
bundle exec ruby -Itest test/stdio_integration_test.rb
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
|
185
|
+
|
|
186
|
+
## Authentication
|
|
187
|
+
|
|
188
|
+
See the [Auth Setup Guide](docs/auth-setup.md) for detailed documentation on
|
|
189
|
+
token-based and OAuth 2.1 authentication, including ask-auth integration.
|
data/docs/auth-setup.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Authentication Setup
|
|
2
|
+
|
|
3
|
+
ask-mcp supports two authentication methods for connecting to MCP servers that
|
|
4
|
+
require authorization: **Token-based auth** and **OAuth 2.1**.
|
|
5
|
+
|
|
6
|
+
## Token Authentication
|
|
7
|
+
|
|
8
|
+
Use for servers with simple API tokens or bearer tokens.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
require "ask/mcp"
|
|
12
|
+
|
|
13
|
+
token = Ask::MCP::Auth::Token.new("my-api-token")
|
|
14
|
+
headers = token.apply({})
|
|
15
|
+
# => { "Authorization" => "Bearer my-api-token" }
|
|
16
|
+
|
|
17
|
+
# Custom scheme
|
|
18
|
+
basic = Ask::MCP::Auth::Token.new("base64encoded", scheme: "Basic")
|
|
19
|
+
headers = basic.apply({})
|
|
20
|
+
# => { "Authorization" => "Basic base64encoded" }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Applying to Transports
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# With SSE transport
|
|
27
|
+
token = Ask::MCP::Auth::Token.new("my-token")
|
|
28
|
+
client = Ask::MCP.from_sse("https://mcp.example.com/sse",
|
|
29
|
+
headers: token.apply({})
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# With Streamable HTTP transport
|
|
33
|
+
client = Ask::MCP.from_http("https://mcp.example.com/mcp",
|
|
34
|
+
headers: token.apply({})
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## OAuth 2.1
|
|
39
|
+
|
|
40
|
+
Use for servers that implement the OAuth 2.1 authorization framework.
|
|
41
|
+
|
|
42
|
+
### Client Credentials Flow
|
|
43
|
+
|
|
44
|
+
For server-to-server communication where you have a client secret:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
oauth = Ask::MCP::Auth::OAuth.new(
|
|
48
|
+
client_id: "your-client-id",
|
|
49
|
+
client_secret: "your-client-secret",
|
|
50
|
+
token_url: "https://auth.example.com/token",
|
|
51
|
+
scopes: ["mcp"]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
oauth.authenticate!
|
|
55
|
+
headers = oauth.apply({})
|
|
56
|
+
# => { "Authorization" => "Bearer eyJhbGciOi..." }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Authorization Code Flow
|
|
60
|
+
|
|
61
|
+
For user-facing applications that need delegated access:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
oauth = Ask::MCP::Auth::OAuth.new(
|
|
65
|
+
client_id: "your-client-id",
|
|
66
|
+
token_url: "https://auth.example.com/token",
|
|
67
|
+
auth_url: "https://auth.example.com/authorize",
|
|
68
|
+
redirect_uri: "http://localhost:3000/callback",
|
|
69
|
+
scopes: ["mcp"]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# This opens the authorization URL for the user to approve
|
|
73
|
+
oauth.authenticate!
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Token Refresh
|
|
77
|
+
|
|
78
|
+
OAuth tokens are automatically refreshed when expired:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
oauth.authenticate!
|
|
82
|
+
# ... use the token ...
|
|
83
|
+
|
|
84
|
+
# When the token expires, refresh it:
|
|
85
|
+
oauth.refresh!
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## With ask-auth
|
|
89
|
+
|
|
90
|
+
If you're using `ask-auth` for credential resolution:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
require "ask-auth"
|
|
94
|
+
require "ask/mcp"
|
|
95
|
+
|
|
96
|
+
# Resolve credentials from ask-auth chain (env → file → rails)
|
|
97
|
+
token = Ask::Auth.resolve(:mcp_token)
|
|
98
|
+
if token
|
|
99
|
+
auth = Ask::MCP::Auth::Token.new(token)
|
|
100
|
+
client = Ask::MCP.from_sse("https://mcp.example.com/sse",
|
|
101
|
+
headers: auth.apply({})
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
For OAuth credentials:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
client_id = Ask::Auth.resolve(:mcp_client_id)
|
|
110
|
+
client_secret = Ask::Auth.resolve(:mcp_client_secret)
|
|
111
|
+
token_url = Ask::Auth.resolve(:mcp_token_url)
|
|
112
|
+
|
|
113
|
+
oauth = Ask::MCP::Auth::OAuth.new(
|
|
114
|
+
client_id: client_id,
|
|
115
|
+
client_secret: client_secret,
|
|
116
|
+
token_url: token_url
|
|
117
|
+
)
|
|
118
|
+
oauth.authenticate!
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Configuration File
|
|
122
|
+
|
|
123
|
+
You can store credentials in `~/.ask/credentials.yml` for use with ask-auth:
|
|
124
|
+
|
|
125
|
+
```yaml
|
|
126
|
+
mcp_token: "my-mcp-server-token"
|
|
127
|
+
mcp_client_id: "my-client-id"
|
|
128
|
+
mcp_client_secret: "my-client-secret"
|
|
129
|
+
mcp_token_url: "https://auth.example.com/token"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Environment Variables
|
|
133
|
+
|
|
134
|
+
ask-auth detects these environment variables automatically:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
MCP_TOKEN=my-token
|
|
138
|
+
MCP_CLIENT_ID=my-client-id
|
|
139
|
+
MCP_CLIENT_SECRET=my-client-secret
|
|
140
|
+
MCP_TOKEN_URL=https://auth.example.com/token
|
|
141
|
+
```
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module MCP
|
|
5
|
+
module Adapters
|
|
6
|
+
class AskTool
|
|
7
|
+
def initialize(mcp_tool)
|
|
8
|
+
@mcp_tool = mcp_tool
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def name
|
|
12
|
+
@mcp_tool.name
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def description
|
|
16
|
+
@mcp_tool.description
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parameters
|
|
20
|
+
@mcp_tool.input_schema
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_ask_tool
|
|
24
|
+
require "ask/tools/tool"
|
|
25
|
+
|
|
26
|
+
Ask::Tools::Tool.new(
|
|
27
|
+
name: @mcp_tool.name,
|
|
28
|
+
description: @mcp_tool.description,
|
|
29
|
+
parameters: @mcp_tool.input_schema
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.from(mcp_tool)
|
|
34
|
+
new(mcp_tool)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.wrap(tools_hash)
|
|
38
|
+
tools_hash.transform_values { |tool| new(tool) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module MCP
|
|
5
|
+
module Auth
|
|
6
|
+
class OAuth
|
|
7
|
+
attr_reader :client_id, :client_secret, :token_url, :auth_url
|
|
8
|
+
|
|
9
|
+
def initialize(client_id:, client_secret: nil, token_url:, auth_url: nil,
|
|
10
|
+
redirect_uri: nil, scopes: [])
|
|
11
|
+
@client_id = client_id
|
|
12
|
+
@client_secret = client_secret
|
|
13
|
+
@token_url = token_url
|
|
14
|
+
@auth_url = auth_url
|
|
15
|
+
@redirect_uri = redirect_uri
|
|
16
|
+
@scopes = scopes
|
|
17
|
+
@access_token = nil
|
|
18
|
+
@refresh_token = nil
|
|
19
|
+
@expires_at = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def authenticated?
|
|
23
|
+
!@access_token.nil? && !expired?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def apply(headers = {})
|
|
27
|
+
headers.merge("Authorization" => "Bearer #{@access_token}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def authenticate!
|
|
31
|
+
if @client_secret
|
|
32
|
+
authenticate_client_credentials
|
|
33
|
+
elsif @auth_url
|
|
34
|
+
authenticate_authorization_code
|
|
35
|
+
else
|
|
36
|
+
raise AuthError, "No authentication method available"
|
|
37
|
+
end
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def refresh!
|
|
42
|
+
raise AuthError, "No refresh token available" unless @refresh_token
|
|
43
|
+
perform_token_refresh
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def expired?
|
|
50
|
+
@expires_at && Time.now >= @expires_at
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def authenticate_client_credentials
|
|
54
|
+
require "httpx"
|
|
55
|
+
|
|
56
|
+
response = HTTPX.post(@token_url, json: {
|
|
57
|
+
grant_type: "client_credentials",
|
|
58
|
+
client_id: @client_id,
|
|
59
|
+
client_secret: @client_secret,
|
|
60
|
+
scope: @scopes.join(" ") || "mcp"
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
handle_token_response(response)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def authenticate_authorization_code
|
|
67
|
+
raise AuthError, "Authorization code flow requires a redirect URI" unless @redirect_uri
|
|
68
|
+
raise AuthError, "Authorization code flow must be completed interactively"
|
|
69
|
+
|
|
70
|
+
# The authorization code flow requires user interaction.
|
|
71
|
+
# This is a placeholder for the interactive flow that would:
|
|
72
|
+
# 1. Open the auth URL in a browser
|
|
73
|
+
# 2. Listen for the redirect with the auth code
|
|
74
|
+
# 3. Exchange the code for tokens
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def perform_token_refresh
|
|
78
|
+
require "httpx"
|
|
79
|
+
|
|
80
|
+
response = HTTPX.post(@token_url, json: {
|
|
81
|
+
grant_type: "refresh_token",
|
|
82
|
+
refresh_token: @refresh_token,
|
|
83
|
+
client_id: @client_id,
|
|
84
|
+
client_secret: @client_secret
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
handle_token_response(response)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_token_response(response)
|
|
91
|
+
unless response.status == 200
|
|
92
|
+
raise AuthError, "Token request failed: #{response.status} #{response.body.to_s[0..200]}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
data = JSON.parse(response.body.to_s, symbolize_names: true)
|
|
96
|
+
@access_token = data[:access_token]
|
|
97
|
+
@refresh_token = data[:refresh_token]
|
|
98
|
+
@expires_at = data[:expires_in] ? Time.now + data[:expires_in].to_i : nil
|
|
99
|
+
rescue JSON::ParserError => e
|
|
100
|
+
raise AuthError, "Invalid token response: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module MCP
|
|
5
|
+
module Auth
|
|
6
|
+
class Token
|
|
7
|
+
attr_reader :token, :scheme
|
|
8
|
+
|
|
9
|
+
def initialize(token, scheme: "Bearer")
|
|
10
|
+
@token = token
|
|
11
|
+
@scheme = scheme
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def apply(headers = {})
|
|
15
|
+
headers.merge("Authorization" => "#{@scheme} #{@token}")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
"#{@scheme} #{@token[0..7]}..."
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|