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 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
+ [![Gem Version](https://badge.fury.io/rb/ask-mcp.svg)](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.
@@ -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