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
2 changes: 1 addition & 1 deletion docs/memory/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ FastMCP, Streamable HTTP transport, port 8080. API-key auth via `Authorization:
| `loops.ts` (3) | `run_agent_loop`, `get_loop_status`, `stop_loop` | Sequential bounded task execution (#740) |
| `memory.ts` (1) | `write_user_memory` | Per-user memory blob; user email resolved server-side from execution_id (MEM-001, #888) |
| `voip.ts` (1) | `call_user` | Outbound phone call via Twilio Media Streams; server-gated + rate-limited (VOIP-001, #1056) |
| `operator_queue.ts` (2) | `list_operator_queue`, `get_operator_queue_item` | Read-only Operating Room queue, broad or `agent_name`-scoped; agent-scoped keys gated to `{self} ∪ permitted` in the MCP layer (OPS-001, #1101) |
| `operator_queue.ts` (3) | `list_operator_queue`, `get_operator_queue_item`, `respond_to_operator_queue` | Read the Operating Room queue (broad or `agent_name`-scoped) and **resolve** a pending item — answer / approve / deny via `POST /{id}/respond`. The respond tool resolves the item's `agent_name`, then applies the same MCP-layer gate before writing (non-`pending` → structured error). Agent-scoped keys gated to `{self} ∪ permitted`. `cancel` deferred. (OPS-001, #1101 read / #1104 respond) |

### Vector Log Aggregator (`config/vector.yaml`)

Expand Down
17 changes: 17 additions & 0 deletions src/mcp-server/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1200,6 +1200,23 @@ export class TrinityClient {
);
}

/**
* Respond to (resolve) a pending operator-queue item (OPS-001, #1104).
* Proxies POST /api/operator-queue/{id}/respond. The backend 400s if the
* item is not in a respondable (`pending`) state — surfaced as a thrown
* Error the tool layer catches and returns as a structured `{ error }`.
*/
async respondToOperatorQueueItem(
itemId: string,
body: { response: string; response_text?: string },
): Promise<OperatorQueueItem> {
return this.request<OperatorQueueItem>(
"POST",
`/api/operator-queue/${encodeURIComponent(itemId)}/respond`,
body,
);
}

// ============================================================================
// Outbound File Sharing (FILES-001)
// ============================================================================
Expand Down
106 changes: 105 additions & 1 deletion src/mcp-server/src/operator_queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
import { describe, it } from "node:test";
import { strict as assert } from "node:assert";

import { filterQueueItemsForAgentScope } from "./tools/operator_queue.js";
import {
filterQueueItemsForAgentScope,
createOperatorQueueTools,
} from "./tools/operator_queue.js";
import type { TrinityClient } from "./client.js";

type Item = { id: string; agent_name: string };

Expand Down Expand Up @@ -58,3 +62,103 @@ describe("#1101 filterQueueItemsForAgentScope", () => {
assert.deepEqual(out.map((i) => i.id), ["a", "c", "d"]);
});
});

// ---------------------------------------------------------------------------
// #1104 — respond_to_operator_queue access gate + proxy behavior.
// Builds the tool with requireApiKey=false so getClient() returns our fake
// client directly. The crux being pinned: an agent-scoped key may NOT resolve
// a non-permitted agent's item, and on denial the write is never attempted.
// ---------------------------------------------------------------------------

function makeRespondTool(fake: Partial<TrinityClient>) {
const tools = createOperatorQueueTools(fake as unknown as TrinityClient, false);
return tools.respondToOperatorQueue;
}

const agentCtx = (agentName: string) => ({
session: { scope: "agent", agentName } as any,
});

describe("#1104 respond_to_operator_queue", () => {
it("denies an agent-scoped key resolving a non-permitted agent's item — and never writes", async () => {
let responded = false;
const tool = makeRespondTool({
getOperatorQueueItem: async () => ({ agent_name: "stranger" }) as any,
getPermittedAgents: async () => [],
respondToOperatorQueueItem: async () => {
responded = true;
return {} as any;
},
});

const out = JSON.parse(
await tool.execute(
{ item_id: "x", response: "approve" },
agentCtx("self"),
),
);

assert.equal(out.error, "Access denied");
assert.equal(responded, false, "respond must not be called when access is denied");
});

it("allows an agent to resolve its own item and proxies the response", async () => {
const calls: Array<{ id: string; body: any }> = [];
const tool = makeRespondTool({
getOperatorQueueItem: async () => ({ agent_name: "self" }) as any,
getPermittedAgents: async () => [],
respondToOperatorQueueItem: async (id: string, body: any) => {
calls.push({ id, body });
return { id, status: "responded", agent_name: "self" } as any;
},
});

const out = JSON.parse(
await tool.execute(
{ item_id: "item1", response: "approve", response_text: "ok" },
agentCtx("self"),
),
);

assert.equal(out.status, "responded");
assert.deepEqual(calls, [
{ id: "item1", body: { response: "approve", response_text: "ok" } },
]);
});

it("allows resolving a permitted (non-self) agent's item", async () => {
let responded = false;
const tool = makeRespondTool({
getOperatorQueueItem: async () => ({ agent_name: "friend" }) as any,
getPermittedAgents: async () => ["friend"],
respondToOperatorQueueItem: async () => {
responded = true;
return { status: "responded" } as any;
},
});

const out = JSON.parse(
await tool.execute({ item_id: "y", response: "deny" }, agentCtx("self")),
);

assert.equal(out.status, "responded");
assert.equal(responded, true);
});

it("surfaces a backend 400 (non-pending item) as a structured error, not a throw", async () => {
const tool = makeRespondTool({
getOperatorQueueItem: async () => ({ agent_name: "self" }) as any,
getPermittedAgents: async () => [],
respondToOperatorQueueItem: async () => {
throw new Error("API error (400): Cannot respond to item with status 'responded'");
},
});

const out = JSON.parse(
await tool.execute({ item_id: "z", response: "approve" }, agentCtx("self")),
);

assert.match(out.error, /400/);
assert.match(out.error, /Cannot respond/);
});
});
2 changes: 1 addition & 1 deletion src/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export async function createServer(config: ServerConfig = {}) {
createMemoryTools(client, requireApiKey), // MEM-001 write path (#888)
createLoopTools(client, requireApiKey), // Sequential agent loops (#740)
createVoipTools(client, requireApiKey), // VoIP telephony — call_user (VOIP-001, #1056)
createOperatorQueueTools(client, requireApiKey), // Operator queue read surface (OPS-001, #1101)
createOperatorQueueTools(client, requireApiKey), // Operator queue read + respond (OPS-001, #1101/#1104)
];
for (const group of toolGroups) {
addAllTools(group);
Expand Down
86 changes: 80 additions & 6 deletions src/mcp-server/src/tools/operator_queue.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
/**
* Operator Queue read tools (OPS-001, #1101)
* Operator Queue tools (OPS-001, #1101 read + #1104 respond)
*
* Two MCP tools exposing the Operating Room queue over MCP (read-only v1):
* - list_operator_queue — broad listing, or scoped via the agent_name filter
* - get_operator_queue_item — a single item by id
* MCP tools exposing the Operating Room queue over MCP:
* - list_operator_queue — broad listing, or scoped via the agent_name filter
* - get_operator_queue_item — a single item by id
* - respond_to_operator_queue — resolve a pending item (answer / approve / deny)
*
* Access control crux: the backend resolves an agent-scoped MCP key to its
* OWNER and filters by the owner's accessible agents — it does NOT apply
* agent_permissions (architecture §5). So agent-to-agent gating lives HERE,
* mirroring executions.ts (`checkAgentAccess`) and agents.ts (`list_agents`
* post-filter). Write actions (respond / cancel) are intentionally deferred to
* a follow-up — this surface is read-only.
* post-filter). The write tool resolves the item's `agent_name` first, then
* runs the SAME `checkAgentAccess` gate before proxying the response — an
* agent-scoped key may resolve items for {self} ∪ permitted only. (#1104 v1
* exposes `respond` only; `cancel` is deferred — wider blast radius.)
*/

import { z } from "zod";
Expand Down Expand Up @@ -240,5 +243,76 @@ export function createOperatorQueueTools(
return JSON.stringify(item, null, 2);
},
},

// ========================================================================
// respond_to_operator_queue (#1104)
// ========================================================================
respondToOperatorQueue: {
name: "respond_to_operator_queue",
description:
"Respond to (resolve) a pending Operating Room (operator queue) item — " +
"answer a question, or approve/deny an approval request. `response` is " +
"the decision value (e.g. the chosen approval option, or the answer); " +
"`response_text` is optional freeform context. Only items in the " +
"'pending' state can be resolved — responding to an already-resolved, " +
"expired, or cancelled item returns a structured error. Access control: " +
"agent-scoped keys may only resolve items belonging to themselves or " +
"agents they have explicit permission for.",
parameters: z.object({
item_id: z.string().min(1).describe("Operator queue item id to resolve."),
response: z
.string()
.min(1)
.describe(
"The response/decision value — for an approval item the chosen option (e.g. 'approve'/'deny'); for a question, the answer.",
),
response_text: z
.string()
.optional()
.describe("Optional freeform text accompanying the response."),
}),
execute: async (
params: { item_id: string; response: string; response_text?: string },
context?: { session?: McpAuthContext },
) => {
const authContext = context?.session;
const apiClient = getClient(authContext);

// Resolve the item's agent_name first so the permission check has a
// target (the caller only supplies an id). A read here also surfaces a
// 404 cleanly before any write attempt.
let item: { agent_name: string };
try {
item = await apiClient.getOperatorQueueItem(params.item_id);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error(`[respond_to_operator_queue] lookup error: ${msg}`);
return JSON.stringify({ error: msg }, null, 2);
}

// Same MCP-layer agent_permissions gate as the read tools — the backend
// resolved the item under the KEY OWNER's access, so re-check it against
// the calling agent's permits before allowing a write.
const access = await checkAgentAccess(apiClient, authContext, item.agent_name);
if (!access.allowed) {
console.log(`[respond_to_operator_queue] Access denied: ${access.reason}`);
return JSON.stringify({ error: "Access denied", reason: access.reason }, null, 2);
}

try {
const updated = await apiClient.respondToOperatorQueueItem(params.item_id, {
response: params.response,
response_text: params.response_text,
});
return JSON.stringify(updated, null, 2);
} catch (error) {
// Backend 400s on a non-pending item (already responded / expired /
// cancelled) — surface as a structured error, not a thrown exception.
const msg = error instanceof Error ? error.message : String(error);
console.error(`[respond_to_operator_queue] error: ${msg}`);
return JSON.stringify({ error: msg }, null, 2);
}
},
},
};
}
Loading