Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 5 additions & 86 deletions internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,18 +608,11 @@ func parseAgentIDs(raw string) []string {
return out
}

const agentRulesToolName = "atryum.rules.get"

// atryumInitializeInstructions is returned in the MCP `initialize` result so
// MCP clients can surface it to the model as system-level guidance. The goal
// is to make the agent aware that approval rules govern tool access and that
// it should consult `atryum.rules.get` before choosing tools.
const atryumInitializeInstructions = "This MCP server (atryum) gates tool calls through approval rules. " +
"Before calling any tool, call the `atryum.rules.get` tool to retrieve the rules that apply to you " +
"and the effective action (`auto_approve`, `human_approval`, or `auto_deny`) for a given server/tool. " +
"Prefer tools that resolve to `auto_approve`. Tools that resolve to `human_approval` will block until a " +
"human responds; tools that resolve to `auto_deny` will be rejected. " +
"Pass the target `server` (or `source`) and `tool` arguments to `atryum.rules.get` to get a disposition."
// MCP clients can surface it to the model as system-level guidance.
const atryumInitializeInstructions = "This MCP server is gated by the Atryum harness. " +
"Atryum may approve, deny, or request human approval for tool calls according to configured rules. " +
"Those rules may change between conversation turns, so treat each tool call as subject to the current Atryum policy."

type AgentRule struct {
ID string `json:"id"`
Expand All @@ -640,8 +633,6 @@ type AgentRulesResponse struct {
Items []AgentRule `json:"items"`
}

var agentRulesToolInputSchema = json.RawMessage(`{"type":"object","properties":{"server":{"type":"string","description":"MCP server/upstream name to evaluate"},"source":{"type":"string","description":"External executor source to evaluate, such as amp"},"tool":{"type":"string","description":"Tool name to evaluate"}},"additionalProperties":false}`)

// ─────────────────────────────────────────────────────────────────────────────

type jsonRPCRequest struct {
Expand Down Expand Up @@ -1130,7 +1121,6 @@ func (h *Handler) handleMCPProxy(w http.ResponseWriter, r *http.Request, server
h.writeRPCError(w, req.ID, -32000, err.Error())
return
}
tools = appendAtryumTools(tools)
_ = h.emitTraceEvent(r.Context(), server, "mcp.tools.list", map[string]any{"tool_count": len(tools), "request_id": requestID})
annotated := h.annotateToolsWithPolicy(r.Context(), server, tools)
h.writeRPCResult(w, req.ID, map[string]any{"tools": annotated})
Expand All @@ -1143,16 +1133,6 @@ func (h *Handler) handleMCPProxy(w http.ResponseWriter, r *http.Request, server
h.writeRPCError(w, req.ID, -32602, "invalid params")
return
}
if params.Name == agentRulesToolName {
result, err := h.callAgentRulesTool(r.Context(), server, params.Arguments)
if err != nil {
h.writeRPCError(w, req.ID, -32000, err.Error())
return
}
_ = h.emitTraceEvent(r.Context(), server, "mcp.tools.call", map[string]any{"request_id": requestID, "status": "succeeded", "tool": params.Name})
h.writeRPCResult(w, req.ID, result)
return
}
toolReq := invocation.CreateInvocationRequest{Server: server, Tool: params.Name, Input: params.Arguments}
if requestID != "" {
toolReq.RequestID = stringPtr(requestID)
Expand Down Expand Up @@ -1434,10 +1414,6 @@ func (h *Handler) annotateToolsWithPolicy(ctx context.Context, server string, to
agentID := auth.AgentIDFromContext(ctx)
agentCUID := h.resolveAgentRecordForRules(ctx, agentID)
for i, t := range tools {
if t.Name == agentRulesToolName {
out[i] = t
continue
}
action, matched := effectiveActionForTool(rules, server, t.Name, agentCUID)
desc := t.Description
if action != "" {
Expand Down Expand Up @@ -1475,9 +1451,7 @@ func effectiveActionForTool(rules []store.Rule, server, tool, agentCUID string)
}

// appendRulesContextToToolResult adds an extra text content block to a denied
// tool call result describing the applicable rules and effective action, so
// the model can learn the policy through feedback instead of having to call
// atryum.rules.get explicitly.
// tool call result describing the applicable rules and effective action.
func (h *Handler) appendRulesContextToToolResult(ctx context.Context, result any, server, tool string) any {
if h.rulesRepo == nil {
return result
Expand Down Expand Up @@ -1507,61 +1481,6 @@ func (h *Handler) appendRulesContextToToolResult(ctx context.Context, result any
return m
}

func appendAtryumTools(tools []mcp.Tool) []mcp.Tool {
for _, tool := range tools {
if tool.Name == agentRulesToolName {
return tools
}
}
return append(tools, mcp.Tool{
Name: agentRulesToolName,
Description: "Return the approval rules and effective action for this agent. Call this before choosing tools so you can prefer auto_approve tools and avoid tools that require human_approval or auto_deny.",
InputSchema: agentRulesToolInputSchema,
})
}

func (h *Handler) callAgentRulesTool(ctx context.Context, currentServer string, args map[string]any) (map[string]any, error) {
server := strings.TrimSpace(currentServer)
if server == "" {
server = stringArg(args, "server")
}
if server == "" {
server = stringArg(args, "source")
}
tool := stringArg(args, "tool")
resp, err := h.buildAgentRulesResponse(ctx, auth.AgentIDFromContext(ctx), server, tool)
if err != nil {
return nil, err
}
body, err := json.MarshalIndent(resp, "", " ")
if err != nil {
return nil, err
}
return map[string]any{
"content": []map[string]any{{
"type": "text",
"text": string(body),
}},
"isError": false,
}, nil
}

func stringArg(args map[string]any, name string) string {
if args == nil {
return ""
}
value, ok := args[name]
if !ok {
return ""
}
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}

func (h *Handler) adminInvocations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
Expand Down
48 changes: 6 additions & 42 deletions internal/api/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,8 +399,8 @@ func TestMCPInitializeNegotiatesProtocolVersion(t *testing.T) {
t.Fatalf("expected protocol version 2025-06-18, got %#v", result["protocolVersion"])
}
instructions, ok := result["instructions"].(string)
if !ok || !strings.Contains(instructions, "atryum.rules.get") {
t.Fatalf("expected initialize instructions to mention atryum.rules.get, got %#v", result["instructions"])
if !ok || !strings.Contains(instructions, "gated by the Atryum harness") || !strings.Contains(instructions, "rules may change between conversation turns") {
t.Fatalf("expected initialize instructions to describe Atryum gating and changing rules, got %#v", result["instructions"])
}
}

Expand Down Expand Up @@ -790,8 +790,8 @@ func TestMCPToolsList(t *testing.T) {
if !strings.Contains(w.Body.String(), `"demo_tool"`) {
t.Fatalf("expected tools list, got %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), `"atryum.rules.get"`) {
t.Fatalf("expected synthetic rules tool, got %s", w.Body.String())
if strings.Contains(w.Body.String(), `"atryum.rules.get"`) {
t.Fatalf("did not expect synthetic rules tool, got %s", w.Body.String())
}
}

Expand Down Expand Up @@ -880,41 +880,6 @@ func TestMCPAgentIDQueryHintIgnoredWhenAuthConfigured(t *testing.T) {
}
}

func TestMCPRulesToolReturnsApplicableRulesWithoutInvocation(t *testing.T) {
rules := &stubRulesRepo{rules: []store.Rule{
{ID: "read-auto", Action: invocation.RuleActionAutoApprove, ServerPatterns: []string{"demo"}, ToolPatterns: []string{"Read"}, Enabled: true, Order: 0},
}}
svc := &stubService{}
h := NewHandler(svc, stubServerService{}, nil, rules, nil, nil, nil, nil, nil, nil)
req := httptest.NewRequest(http.MethodPost, "/mcp/demo", strings.NewReader(`{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"atryum.rules.get","arguments":{"tool":"Read"}}}`))
w := httptest.NewRecorder()

h.Routes().ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
if svc.invokedReq != nil {
t.Fatalf("rules tool should not create invocation, got %#v", svc.invokedReq)
}
var rpcResp struct {
Result struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
} `json:"result"`
}
if err := json.Unmarshal(w.Body.Bytes(), &rpcResp); err != nil {
t.Fatal(err)
}
if len(rpcResp.Result.Content) != 1 {
t.Fatalf("expected one content item, got %#v", rpcResp.Result.Content)
}
if !strings.Contains(rpcResp.Result.Content[0].Text, `"read-auto"`) || !strings.Contains(rpcResp.Result.Content[0].Text, `"action": "auto_approve"`) {
t.Fatalf("expected rules payload in tool result, got %s", rpcResp.Result.Content[0].Text)
}
}

func TestAgentRulesListsApplicableRulesAndDisposition(t *testing.T) {
rules := &stubRulesRepo{rules: []store.Rule{
{ID: "bash-deny", Action: invocation.RuleActionAutoDeny, ServerPatterns: []string{"amp"}, ToolPatterns: []string{"Bash"}, Enabled: true, Order: 0},
Expand Down Expand Up @@ -1039,9 +1004,8 @@ func TestMCPToolsListAnnotatesEffectiveAction(t *testing.T) {
if otherTool.Annotations == nil || otherTool.Annotations.Atryum.EffectiveAction != invocation.RuleActionHumanApproval {
t.Fatalf("Other tool annotations: %#v", otherTool.Annotations)
}
rulesTool := rpcResp.Result.Tools[byName["atryum.rules.get"]]
if rulesTool.Annotations != nil {
t.Fatalf("synthetic rules tool should not be annotated, got %#v", rulesTool.Annotations)
if _, ok := byName["atryum.rules.get"]; ok {
t.Fatalf("did not expect synthetic rules tool in tools/list")
}
}

Expand Down
4 changes: 4 additions & 0 deletions website/documentation/1_quickstart.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ <h2 id="install-initialize-atryum">Install &amp; initialize Atryum</h2>

<p>Download Atryum and register a server in Atryum for testing.</p>

<aside class="docs-callout"><p>Atryum is not production ready yet.</p></aside>

<h3 id="download-atryum">Download Atryum</h3>

<div class="code-block"><pre><code class="language-bash">curl -fsSL https://github.com/validmind/atryum/raw/main/install_atryum.sh | bash</code></pre><button class="copy-btn code-block-copy-btn" type="button" data-command="curl -fsSL https://github.com/validmind/atryum/raw/main/install_atryum.sh | bash" aria-label="Copy code block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><span>Copy</span></button></div>
Expand All @@ -56,6 +58,8 @@ <h3 id="download-atryum">Download Atryum</h3>

<h3 id="set-up-atryum-test-server">Set up Atryum test server</h3>

<aside class="docs-callout"><p>This quickstart will run atryum with a local sqlite. This is great for first use, bad for performance and real work. See <a href="https://github.com/validmind/atryum/blob/main/docker-compose.yml#L2">docker-compose.yaml</a> for an example running with postgres.</p></aside>

<ol><li>Generate a minimal testing configuration with a simple calculator Model Context Protocol (MCP) server that does not require any external credentials:<div class="code-block"><pre><code class="language-bash">./atryum setup demo</code></pre><button class="copy-btn code-block-copy-btn" type="button" data-command="./atryum setup demo" aria-label="Copy code block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><span>Copy</span></button></div></li><li>Start the Atryum service and register the test calculator server in Atryum's local database:<div class="code-block"><pre><code class="language-bash">./atryum run --init-servers</code></pre><button class="copy-btn code-block-copy-btn" type="button" data-command="./atryum run --init-servers" aria-label="Copy code block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><span>Copy</span></button></div></li><li>In your browser, navigate to <a href="http://localhost:8080"><code>localhost:8080</code></a> to open the Atryum local web user interface.<br><p>Here, you can view servers, manage tool invocation approvals, configure rules, and more.</p></li><li>Within Atryum, click <strong>Servers</strong> in the left sidebar. Confirm that your test calculator server was successfully registered:<ul><li>Verify that the server's <span style="font-variant: small-caps;">connection</span> and <span style="font-variant: small-caps;">auth</span> both display as <span style="font-variant: small-caps;"><code>ready</code></span>.</li><li>Verify that Atryum exposed the server at <a href="http://localhost:8080/mcp/calc"><code>localhost:8080/mcp/calc</code></a>.</li></ul></li><li>Connect your preferred coding agent to Atryum:<ul><li>Open your agent's MCP settings and add a standard MCP server with the calc server address:<div class="code-block"><pre><code class="language-text">http://localhost:8080/mcp/calc</code></pre><button class="copy-btn code-block-copy-btn" type="button" data-command="http://localhost:8080/mcp/calc" aria-label="Copy code block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><span>Copy</span></button></div></li><li>The agent will think it is talking to a calculator MCP server, but its tool invocations now pass through Atryum first.</li></ul></li><li>Trigger a test tool call from your agent. For example:<div class="code-block"><pre><code class="language-text">Use the calculator tools and show me 2*2</code></pre><button class="copy-btn code-block-copy-btn" type="button" data-command="Use the calculator tools and show me 2*2" aria-label="Copy code block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><span>Copy</span></button></div></li><li>Within Atryum, click <strong>Invocations</strong> in the left sidebar to review tool calls. Confirm that the calculator invocation request is <code>Pending Approval</code> — human approval is required by default.<ul><li>Under Approval Required, select <strong>Approve</strong> to let the tool call run.</li><li>Verify that the invocation's <span style="font-variant: small-caps;">status</span> is <code>Succeeded</code> and that the approval was <span style="font-variant: small-caps;">decided by</span> a <span style="font-variant: small-caps;">human</span>.</li></ul></li></ol>

<aside class="docs-callout"><p>To learn more about working with invocations, refer to <strong><a href="2_invocations.html">Invocations</a></strong>.</p></aside>
Expand Down
8 changes: 8 additions & 0 deletions website/md-drafts/1_quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Install and initialize Atryum, then integrate Atryum with ValidMind and your oth

Download Atryum and register a server in Atryum for testing.

:::
Atryum is not production ready yet.
:::

### Download Atryum

```bash
Expand All @@ -18,6 +22,10 @@ The install script downloads the latest Atryum release and selects the correct b

### Set up Atryum test server

:::
This quickstart will run atryum with a local sqlite. This is great for first use, bad for performance and real work. See [docker-compose.yaml](https://github.com/validmind/atryum/blob/main/docker-compose.yml#L2) for an example running with postgres.
:::

1. Generate a minimal testing configuration with a simple calculator Model Context Protocol (MCP) server that does not require any external credentials:

```bash
Expand Down
Loading