Skip to content

Commit b0c2bef

Browse files
committed
fix: tool execution fix and documention
1 parent 98e0397 commit b0c2bef

3 files changed

Lines changed: 190 additions & 2 deletions

File tree

GATEWAY.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# ActionMCP Gateway Guide
2+
3+
This document explains the Gateway concept in ActionMCP, how it authenticates callers, and how tools/prompts/resources receive the caller context.
4+
5+
## What the Gateway Does
6+
- Runs **before** any tool/prompt/resource.
7+
- Authenticates the request and resolves one or more identifiers.
8+
- Seeds `ActionMCP::Current` with those identifiers for downstream use.
9+
- Rejects the request with `ActionMCP::UnauthorizedError` if authentication fails.
10+
11+
## Lifecycle
12+
1) Request hits the MCP endpoint.
13+
2) `ApplicationGateway#authenticate!` executes.
14+
3) On success, it returns a hash whose keys match `identified_by` declarations.
15+
4) Identifiers are stored on `ActionMCP::Current` and the session; tools/prompts/resources can read them via helper methods (`current_user`, etc.).
16+
5) On failure, a JSON-RPC error (`-32001 Unauthorized`) is returned; no tool code runs.
17+
18+
## Implementing `ApplicationGateway`
19+
`action_mcp:install` generates `app/mcp/application_gateway.rb`. You customize only `identified_by` and `authenticate!`.
20+
21+
```ruby
22+
# app/mcp/application_gateway.rb
23+
class ApplicationGateway < ActionMCP::Gateway
24+
identified_by :user, :organization
25+
26+
protected
27+
28+
def authenticate!
29+
token = extract_bearer_token
30+
raise ActionMCP::UnauthorizedError, "Missing token" unless token
31+
32+
payload = ActionMCP::JwtDecoder.decode(token)
33+
user = User.find_by(id: payload["sub"])
34+
org = Organization.find_by(id: payload["org_id"])
35+
36+
raise ActionMCP::UnauthorizedError, "Unauthorized" unless user && org
37+
38+
{ user: user, organization: org }
39+
end
40+
end
41+
```
42+
43+
### Accessing Identifiers
44+
Inside tools/prompts/resources:
45+
```ruby
46+
class MyTool < ApplicationMCPTool
47+
def perform
48+
render text: "Hi #{current_user.name} from #{current_organization.name}"
49+
end
50+
end
51+
```
52+
`current_user` and `current_organization` are provided via `ActionMCP::Current`.
53+
54+
## Authentication Patterns
55+
- **Bearer JWT (recommended):** Use `extract_bearer_token`, validate signature, resolve user/org/roles.
56+
- **API Key header:** Look up a key table; return identifiers; throttle invalid attempts.
57+
- **Session cookies:** Generally avoid—MCP traffic is not browser-oriented and runs best stateless.
58+
59+
## Authorization vs Authentication
60+
- Gateway authenticates and attaches identity.
61+
- Authorization should stay in tools/prompts (e.g., `authorize! current_user, :action`), because permissions depend on the specific operation.
62+
63+
## Error Handling
64+
- Raise `ActionMCP::UnauthorizedError` for auth failures; include only user-safe messages.
65+
- Avoid leaking reason details (e.g., “token expired”) unless you are certain they’re safe to expose.
66+
67+
## Testing Checklist
68+
- Missing token → unauthorized.
69+
- Invalid token → unauthorized.
70+
- Valid token → identifiers set (`ActionMCP::Current.user`).
71+
- Multi-tenant: ensure the correct org/tenant is bound.
72+
73+
## Production Hardening Tips
74+
- Keep `authenticate!` fast (DB lookups OK; no remote HTTP).
75+
- Prefer short-lived tokens; rotate signing keys.
76+
- Log auth failures at warn level without secrets.
77+
- Pair with `mcp_vanilla.ru` if web middleware interferes; Gateway still runs as usual.

README.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,111 @@ sum_tool = CalculateSumTool.new(a: 5, b: 10)
219219
result = sum_tool.call
220220
```
221221

222+
#### Structured output (output_schema)
223+
224+
Advertise a JSON Schema for your tool's structuredContent and return machine-validated results alongside any text output.
225+
226+
```ruby
227+
class PriceQuoteTool < ApplicationMCPTool
228+
tool_name "price_quote"
229+
description "Return a structured price quote"
230+
231+
property :sku, type: "string", description: "SKU to price", required: true
232+
233+
output_schema do
234+
string :sku, required: true, description: "SKU that was priced"
235+
number :price_cents, required: true, description: "Total price in cents"
236+
object :meta do
237+
string :currency, required: true, enum: %w[USD EUR GBP]
238+
boolean :cached, default: false
239+
end
240+
end
241+
242+
def perform
243+
price_cents = lookup_price_cents(sku) # Implement your lookup
244+
245+
render structured: { sku: sku,
246+
price_cents: price_cents,
247+
meta: { currency: "USD", cached: false } }
248+
end
249+
end
250+
```
251+
252+
The schema is included in the tool definition, and the `structured` payload is emitted as `structuredContent` in the response while remaining compatible with text/audio/image renders.
253+
254+
#### Returning resource links from a tool
255+
256+
When you want to hand back a URI instead of embedding the payload, use the built-in `render_resource_link`, which produces the MCP `resource_link` content type.
257+
258+
```ruby
259+
class ReportLinkTool < ApplicationMCPTool
260+
tool_name "report_link"
261+
description "Return a downloadable report link"
262+
263+
property :report_id, type: "string", required: true
264+
265+
def perform
266+
render_resource_link(
267+
uri: "reports://#{report_id}.json",
268+
name: "Report #{report_id}",
269+
description: "Downloadable JSON for report #{report_id}",
270+
mime_type: "application/json"
271+
)
272+
end
273+
end
274+
```
275+
276+
Clients can resolve the URI with a separate `resources/read` call, keeping tool responses lightweight while still discoverable.
277+
278+
#### Task-augmented tools (async execution with progress)
279+
280+
Use MCP Tasks when work might take seconds/minutes. Advertise task support with `task_required!` (or `task_optional!`) and let callers opt in by sending `_meta.task` on `tools/call`. While running as a task, you can emit progress updates with `report_progress!`.
281+
282+
```ruby
283+
class BatchIndexTool < ApplicationMCPTool
284+
tool_name "batch_index"
285+
description "Index many items asynchronously with progress updates"
286+
287+
task_required! # advertise that this tool is intended to run as a task
288+
property :items, type: "array_string", description: "Items to index", required: true
289+
290+
def perform
291+
total = items.length
292+
items.each_with_index do |item, idx|
293+
index_item(item) # your indexing logic
294+
295+
percent = ((idx + 1) * 100.0 / total).round
296+
report_progress!(percent: percent, message: "Indexed #{idx + 1}/#{total}")
297+
end
298+
299+
render(text: "Indexed #{total} items")
300+
end
301+
302+
private
303+
304+
def index_item(item)
305+
# Implement your indexing logic here
306+
end
307+
end
308+
```
309+
310+
Call it as a task from a client by adding `_meta.task` (creates a Task record and runs the tool via `ToolExecutionJob`):
311+
312+
```json
313+
{
314+
"jsonrpc": "2.0",
315+
"id": 1,
316+
"method": "tools/call",
317+
"params": {
318+
"name": "batch_index",
319+
"arguments": { "items": ["a", "b", "c"] },
320+
"_meta": { "task": { "ttl": 120000, "pollInterval": 2000 } }
321+
}
322+
}
323+
```
324+
325+
Poll task status with `tasks/get` or fetch the result when finished with `tasks/result`. Use `tasks/cancel` to stop non-terminal tasks.
326+
222327
### ActionMCP::ResourceTemplate
223328

224329
`ActionMCP::ResourceTemplate` facilitates the creation of URI templates for dynamic resources that LLMs can access.
@@ -311,6 +416,10 @@ ActionMCP provides comprehensive documentation across multiple specialized guide
311416
- Transport configuration and connection handling
312417
- Tool, prompt, and resource collections
313418
- Production deployment patterns
419+
- **[🔐 GATEWAY.md](GATEWAY.md)** - Authentication gateway guide
420+
- Implementing `ApplicationGateway`
421+
- Identifier handling via `ActionMCP::Current`
422+
- Auth patterns, error handling, and hardening tips
314423

315424
### Protocol & Technical Details
316425
- **[🚀 The Hitchhiker's Guide to MCP](The_Hitchhikers_Guide_to_MCP.md)** - Protocol versions and migration

app/jobs/action_mcp/tool_execution_job.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def perform(task_id, tool_name, arguments, meta = {})
2727
@session = step(:validate_session, @task)
2828
return unless @session
2929

30-
@tool = step(:prepare_tool, @session, tool_name, arguments)
30+
@tool = step(:prepare_tool, @session, tool_name, arguments, @task)
3131
return unless @tool
3232

3333
step(:execute_tool) do
@@ -60,7 +60,7 @@ def validate_session(task)
6060
session
6161
end
6262

63-
def prepare_tool(session, tool_name, arguments)
63+
def prepare_tool(session, tool_name, arguments, task)
6464
tool_class = session.registered_tools.find { |t| t.tool_name == tool_name }
6565
unless tool_class
6666
@task.update(status_message: "Tool '#{tool_name}' not found")
@@ -76,6 +76,8 @@ def prepare_tool(session, tool_name, arguments)
7676
params: @task.request_params
7777
}
7878
})
79+
# Enable report_progress! inside the tool during task-augmented runs
80+
tool.instance_variable_set(:@_task, task)
7981

8082
tool
8183
end

0 commit comments

Comments
 (0)