agent-relay-server 0.20.0 → 0.22.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.
- package/docs/openapi.json +4 -1
- package/package.json +2 -2
- package/public/index.html +698 -140
- package/runner/src/adapter.ts +1 -1
- package/scripts/install-bin-shim.cjs +16 -3
- package/src/agent-ref.ts +217 -0
- package/src/automations.ts +4 -1
- package/src/cli.ts +68 -8
- package/src/config-store.ts +35 -1
- package/src/context-router.ts +7 -7
- package/src/db.ts +111 -29
- package/src/maintenance.ts +9 -4
- package/src/managed-policy.ts +4 -1
- package/src/mcp.ts +452 -69
- package/src/routes.ts +94 -170
- package/src/runtime-tokens.ts +26 -1
- package/src/security.ts +3 -1
- package/src/workspace-actions.ts +336 -0
- package/src/workspace-phase.ts +181 -0
package/src/mcp.ts
CHANGED
|
@@ -9,22 +9,30 @@ import { bytesToStream, readBodyBytes } from "./http-body";
|
|
|
9
9
|
import { MAX_BODY_BYTES, VERSION } from "./config";
|
|
10
10
|
import { getManagedAgentState, getSpawnPolicy, listSpawnPolicies } from "./config-store";
|
|
11
11
|
import {
|
|
12
|
+
countLiveSpawnedAgents,
|
|
12
13
|
createArtifact,
|
|
13
14
|
createActivityEvent,
|
|
14
15
|
getAgent,
|
|
15
16
|
getArtifact,
|
|
16
17
|
getMessage,
|
|
17
18
|
getOrchestrator,
|
|
19
|
+
getRepoSteward,
|
|
18
20
|
getTask,
|
|
19
21
|
getThread,
|
|
22
|
+
getWorkspace,
|
|
20
23
|
linkArtifact,
|
|
21
24
|
listAgents,
|
|
25
|
+
listWorkspaces,
|
|
26
|
+
searchAgents,
|
|
27
|
+
type AgentSearchFilter,
|
|
28
|
+
type AgentSearchSort,
|
|
22
29
|
listArtifactsForEntity,
|
|
23
30
|
listOrchestrators,
|
|
24
31
|
sendMessageWithResult,
|
|
25
32
|
upsertArtifactBlob,
|
|
26
33
|
ValidationError,
|
|
27
34
|
} from "./db";
|
|
35
|
+
import { planSend, type DeliveryReceipt } from "./agent-ref";
|
|
28
36
|
import { emitRelayEvent } from "./events";
|
|
29
37
|
import { emitMessageQueued, emitNewMessage } from "./sse";
|
|
30
38
|
import {
|
|
@@ -35,10 +43,12 @@ import {
|
|
|
35
43
|
isComponentAuthorizedFor,
|
|
36
44
|
isIntegrationAllowed,
|
|
37
45
|
} from "./security";
|
|
38
|
-
import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider } from "./types";
|
|
46
|
+
import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
|
|
47
|
+
import { applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
|
|
48
|
+
import { describeWorkspacePhase, landReceipt, readyContract, TERMINAL_WORKSPACE_STATUSES, worktreeMcpInstructions } from "./workspace-phase";
|
|
39
49
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
40
50
|
import { errMessage, isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
|
|
41
|
-
import {
|
|
51
|
+
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
42
52
|
|
|
43
53
|
type JsonRpcId = string | number | null;
|
|
44
54
|
|
|
@@ -62,6 +72,12 @@ type ToolDefinition = {
|
|
|
62
72
|
description: string;
|
|
63
73
|
requiredScopes: string[];
|
|
64
74
|
inputSchema: Record<string, unknown>;
|
|
75
|
+
// Emit `_meta: { "anthropic/alwaysLoad": true }` in tools/list so Claude Code
|
|
76
|
+
// (v2.1.121+) loads this tool's schema EAGERLY instead of deferring it behind a
|
|
77
|
+
// ToolSearch round-trip. Reserve for the few verbs most agents use every session
|
|
78
|
+
// (the doc warns each one costs context every turn). Verified convention; same
|
|
79
|
+
// mechanism callmux's per-tool alwaysLoad uses.
|
|
80
|
+
alwaysLoad?: boolean;
|
|
65
81
|
};
|
|
66
82
|
|
|
67
83
|
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
|
@@ -75,13 +91,13 @@ const VALID_ARTIFACT_ENTITY_TYPES = ["message", "task", "recipeRun", "recipeStep
|
|
|
75
91
|
const TOOLS: ToolDefinition[] = [
|
|
76
92
|
{
|
|
77
93
|
name: "relay_send_message",
|
|
78
|
-
description: "Send an Agent Relay message
|
|
94
|
+
description: "Send an Agent Relay message. Returns a delivery receipt (delivered/expectReply/recipients): if expectReply is false, no live recipient exists — don't wait for a reply. Unknown or ambiguous targets are rejected up front, never silently dropped.",
|
|
79
95
|
requiredScopes: ["messages:write", "message:send"],
|
|
80
96
|
inputSchema: {
|
|
81
97
|
type: "object",
|
|
82
98
|
properties: {
|
|
83
|
-
from: { type: "string", description: "
|
|
84
|
-
to: { type: "string", description: "Target agent id, label, tag:, cap:, policy:, or broadcast." },
|
|
99
|
+
from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
|
|
100
|
+
to: { type: "string", description: "Target: an agent id, label, name, or a unique id segment (e.g. the number part) — resolved automatically. Or a fan-out selector: tag:, cap:, label:, policy:, or broadcast." },
|
|
85
101
|
body: { type: "string" },
|
|
86
102
|
subject: { type: "string" },
|
|
87
103
|
channel: { type: "string" },
|
|
@@ -91,18 +107,18 @@ const TOOLS: ToolDefinition[] = [
|
|
|
91
107
|
payload: { type: "object" },
|
|
92
108
|
meta: { type: "object" },
|
|
93
109
|
},
|
|
94
|
-
required: ["
|
|
110
|
+
required: ["to", "body"],
|
|
95
111
|
additionalProperties: false,
|
|
96
112
|
},
|
|
97
113
|
},
|
|
98
114
|
{
|
|
99
115
|
name: "relay_reply",
|
|
100
|
-
description: "Reply to an Agent Relay message by id, preserving reply/thread/channel context.",
|
|
116
|
+
description: "Reply to an Agent Relay message by id, preserving reply/thread/channel context. Returns a delivery receipt; expectReply:false means the original sender is no longer reachable.",
|
|
101
117
|
requiredScopes: ["messages:write", "message:send"],
|
|
102
118
|
inputSchema: {
|
|
103
119
|
type: "object",
|
|
104
120
|
properties: {
|
|
105
|
-
from: { type: "string", description: "
|
|
121
|
+
from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
|
|
106
122
|
messageId: { type: "integer", minimum: 1 },
|
|
107
123
|
body: { type: "string" },
|
|
108
124
|
subject: { type: "string" },
|
|
@@ -111,7 +127,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
111
127
|
payload: { type: "object" },
|
|
112
128
|
meta: { type: "object" },
|
|
113
129
|
},
|
|
114
|
-
required: ["
|
|
130
|
+
required: ["messageId", "body"],
|
|
115
131
|
additionalProperties: false,
|
|
116
132
|
},
|
|
117
133
|
},
|
|
@@ -184,7 +200,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
184
200
|
},
|
|
185
201
|
{
|
|
186
202
|
name: "relay_agent_status",
|
|
187
|
-
description: "Inspect Relay agent, orchestrator, and managed spawn-policy status.",
|
|
203
|
+
description: "Inspect Relay agent, orchestrator, and managed spawn-policy status. The default agent list shows only RUNNING agents (the ones you can actually message/reply/pair with); pass includeOffline:true for the full roster.",
|
|
188
204
|
requiredScopes: ["agents:read", "agent:read"],
|
|
189
205
|
inputSchema: {
|
|
190
206
|
type: "object",
|
|
@@ -192,14 +208,47 @@ const TOOLS: ToolDefinition[] = [
|
|
|
192
208
|
agentId: { type: "string" },
|
|
193
209
|
policyName: { type: "string" },
|
|
194
210
|
orchestratorId: { type: "string" },
|
|
211
|
+
includeOffline: { type: "boolean", description: "Include offline/stale agents in the default list (default false)." },
|
|
195
212
|
},
|
|
196
213
|
additionalProperties: false,
|
|
197
214
|
},
|
|
198
215
|
},
|
|
216
|
+
{
|
|
217
|
+
name: "relay_find_agents",
|
|
218
|
+
description: "Search the fleet for agents by label, capability, provider, tag, machine, status, or kind, sorted by last-active/created/label. Defaults to running agents only. Use this to find a reachable, capable peer before messaging or pairing — instead of dumping the whole roster.",
|
|
219
|
+
requiredScopes: ["agents:read", "agent:read"],
|
|
220
|
+
inputSchema: {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: {
|
|
223
|
+
label: { type: "string", description: "Substring match on label or name." },
|
|
224
|
+
capability: { type: ["string", "array"], items: { type: "string" }, description: "One or more capabilities (OR within)." },
|
|
225
|
+
provider: { type: ["string", "array"], items: { type: "string" }, description: "Model provider, e.g. claude or codex." },
|
|
226
|
+
tag: { type: ["string", "array"], items: { type: "string" } },
|
|
227
|
+
machine: { type: ["string", "array"], items: { type: "string" } },
|
|
228
|
+
kind: { type: ["string", "array"], items: { type: "string" } },
|
|
229
|
+
status: { type: ["string", "array"], items: { type: "string" }, description: "online/idle/busy/offline/stale, the 'running' shorthand, or 'all'. Default 'running'." },
|
|
230
|
+
spawnedBy: { type: "string", description: "Parent agent id, or 'me' for your own spawned children (#221)." },
|
|
231
|
+
sortBy: { type: "string", enum: ["lastActive", "created", "label"] },
|
|
232
|
+
order: { type: "string", enum: ["asc", "desc"] },
|
|
233
|
+
limit: { type: "integer", minimum: 1, maximum: 200 },
|
|
234
|
+
},
|
|
235
|
+
additionalProperties: false,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "relay_whoami",
|
|
240
|
+
description: "Return your own Relay identity and agent card (id, label, status, tags, capabilities). Your identity comes from your auth token — you do NOT need this to send, reply, or pair; use it only when you need to reason about yourself.",
|
|
241
|
+
requiredScopes: ["mcp:use"],
|
|
242
|
+
inputSchema: {
|
|
243
|
+
type: "object",
|
|
244
|
+
properties: {},
|
|
245
|
+
additionalProperties: false,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
199
248
|
{
|
|
200
249
|
name: "relay_spawn_agent",
|
|
201
|
-
description: "
|
|
202
|
-
requiredScopes: ["
|
|
250
|
+
description: "Spawn a long-living provider agent through Relay's orchestrator. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).",
|
|
251
|
+
requiredScopes: ["command:spawn"],
|
|
203
252
|
inputSchema: {
|
|
204
253
|
type: "object",
|
|
205
254
|
properties: {
|
|
@@ -212,6 +261,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
212
261
|
approvalMode: { type: "string", enum: APPROVAL_MODES },
|
|
213
262
|
prompt: { type: "string" },
|
|
214
263
|
systemPromptAppend: { type: "string" },
|
|
264
|
+
profile: { type: "string", description: "Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota)." },
|
|
215
265
|
tags: { type: "array", items: { type: "string" } },
|
|
216
266
|
capabilities: { type: "array", items: { type: "string" } },
|
|
217
267
|
providerArgs: { type: "array", items: { type: "string" } },
|
|
@@ -224,8 +274,8 @@ const TOOLS: ToolDefinition[] = [
|
|
|
224
274
|
},
|
|
225
275
|
{
|
|
226
276
|
name: "relay_shutdown_agent",
|
|
227
|
-
description: "
|
|
228
|
-
requiredScopes: ["
|
|
277
|
+
description: "Shut down an agent through Relay's orchestrator. Gated: requires the command:shutdown scope; an agent caller may only target its OWN live spawned children. Admin tokens keep full reach.",
|
|
278
|
+
requiredScopes: ["command:shutdown"],
|
|
229
279
|
inputSchema: {
|
|
230
280
|
type: "object",
|
|
231
281
|
properties: {
|
|
@@ -240,6 +290,108 @@ const TOOLS: ToolDefinition[] = [
|
|
|
240
290
|
additionalProperties: false,
|
|
241
291
|
},
|
|
242
292
|
},
|
|
293
|
+
{
|
|
294
|
+
name: "relay_workspace_status",
|
|
295
|
+
description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes — use it after relay_workspace_ready to wait for the auto-merge to land your branch on main (status leaves 'ready' → merged/recycled-to-active with a fresh rebased branch) or to surface a conflict/review_requested the steward couldn't auto-resolve. Defaults to the workspace you own; pass workspaceId to target another.",
|
|
296
|
+
requiredScopes: ["agents:read", "agent:read", "agent:write"],
|
|
297
|
+
alwaysLoad: true,
|
|
298
|
+
inputSchema: {
|
|
299
|
+
type: "object",
|
|
300
|
+
properties: {
|
|
301
|
+
workspaceId: { type: "string", description: "Defaults to your own isolated workspace (resolved from your identity)." },
|
|
302
|
+
wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout." },
|
|
303
|
+
timeoutSeconds: { type: "integer", minimum: 1, maximum: 600, description: "Max seconds to wait when wait:true (default 300, max 600). Returns early on any transition." },
|
|
304
|
+
},
|
|
305
|
+
additionalProperties: false,
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: "relay_workspace_ready",
|
|
310
|
+
description: "Signal that your branch work is finished and ready to merge back to main. This kicks off the auto-merge-back: a clean merge lands immediately; on conflict a steward agent reconciles and lands it (escalating if it can't). After this, poll relay_workspace_status with wait:true until it lands and you get a fresh rebased branch to continue on. Defaults to your own workspace.",
|
|
311
|
+
requiredScopes: ["agent:write"],
|
|
312
|
+
alwaysLoad: true,
|
|
313
|
+
inputSchema: {
|
|
314
|
+
type: "object",
|
|
315
|
+
properties: {
|
|
316
|
+
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
317
|
+
detail: { type: "string", description: "Optional note recorded on the activity feed." },
|
|
318
|
+
},
|
|
319
|
+
additionalProperties: false,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: "relay_workspace_deps",
|
|
324
|
+
description: "Re-provision your worktree's dependencies when the shared symlinked node_modules has gone stale (e.g. typecheck reports a missing module). checkOnly:true just reports staleness without changing anything. Self-service — acts only on your own worktree.",
|
|
325
|
+
requiredScopes: ["agent:write"],
|
|
326
|
+
inputSchema: {
|
|
327
|
+
type: "object",
|
|
328
|
+
properties: {
|
|
329
|
+
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
330
|
+
checkOnly: { type: "boolean", description: "Report staleness only; do not re-provision." },
|
|
331
|
+
detail: { type: "string" },
|
|
332
|
+
},
|
|
333
|
+
additionalProperties: false,
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: "relay_workspace_list",
|
|
338
|
+
description: "List the isolated workspaces you own (id, branch, status, repo). Use it when you're unsure which workspace is yours or to see a workspace's current state. Admin/server tokens may pass all:true to list every workspace.",
|
|
339
|
+
requiredScopes: ["agents:read", "agent:read", "agent:write"],
|
|
340
|
+
inputSchema: {
|
|
341
|
+
type: "object",
|
|
342
|
+
properties: {
|
|
343
|
+
all: { type: "boolean", description: "Admin/server only: list all workspaces, not just your own." },
|
|
344
|
+
ownerAgentId: { type: "string", description: "Admin/server + all:true: filter to one owner." },
|
|
345
|
+
repoRoot: { type: "string", description: "Filter to one repository root." },
|
|
346
|
+
},
|
|
347
|
+
additionalProperties: false,
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
name: "relay_workspace_claim",
|
|
352
|
+
description: "Take a TTL'd steward claim on a workspace so the deterministic auto-merge yields to you while you reconcile a conflict. Renew/hold while validating; relay_workspace_release frees it. Owner or assigned steward only.",
|
|
353
|
+
requiredScopes: ["agent:write"],
|
|
354
|
+
inputSchema: {
|
|
355
|
+
type: "object",
|
|
356
|
+
properties: {
|
|
357
|
+
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
358
|
+
purpose: { type: "string", description: "Why you're claiming (shown in the activity feed)." },
|
|
359
|
+
detail: { type: "string" },
|
|
360
|
+
},
|
|
361
|
+
additionalProperties: false,
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: "relay_workspace_release",
|
|
366
|
+
description: "Release a steward claim taken with relay_workspace_claim, letting the auto-merge resume. Owner or assigned steward only.",
|
|
367
|
+
requiredScopes: ["agent:write"],
|
|
368
|
+
inputSchema: {
|
|
369
|
+
type: "object",
|
|
370
|
+
properties: {
|
|
371
|
+
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
372
|
+
purpose: { type: "string" },
|
|
373
|
+
detail: { type: "string" },
|
|
374
|
+
},
|
|
375
|
+
additionalProperties: false,
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
name: "relay_workspace_land",
|
|
380
|
+
description: "Dispatch a base merge for your workspace directly (lease-serialized per repo, same path the auto-merge job uses). Most branch agents should use relay_workspace_ready instead and let the relay land it; this is the explicit/steward path and requires the command:write scope.",
|
|
381
|
+
requiredScopes: ["command:write"],
|
|
382
|
+
inputSchema: {
|
|
383
|
+
type: "object",
|
|
384
|
+
properties: {
|
|
385
|
+
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
386
|
+
strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto)." },
|
|
387
|
+
deleteBranch: { type: "boolean", description: "Delete the branch after a successful merge (default true)." },
|
|
388
|
+
prTitle: { type: "string" },
|
|
389
|
+
prBody: { type: "string" },
|
|
390
|
+
detail: { type: "string" },
|
|
391
|
+
},
|
|
392
|
+
additionalProperties: false,
|
|
393
|
+
},
|
|
394
|
+
},
|
|
243
395
|
];
|
|
244
396
|
|
|
245
397
|
export async function postMcp(req: Request): Promise<Response> {
|
|
@@ -257,10 +409,14 @@ export async function postMcp(req: Request): Promise<Response> {
|
|
|
257
409
|
}
|
|
258
410
|
|
|
259
411
|
if (method === "initialize") {
|
|
412
|
+
// Mode-tailored primer (#214 §3): surface the worktree merge-back map only
|
|
413
|
+
// to callers that own an isolated worktree; shared-workspace agents omit it.
|
|
414
|
+
const worktree = callerIsolatedWorkspace(auth);
|
|
260
415
|
return Response.json(jsonRpcResult(id, {
|
|
261
416
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
262
417
|
capabilities: { tools: {} },
|
|
263
418
|
serverInfo: { name: "agent-relay", title: "Agent Relay", version: VERSION },
|
|
419
|
+
...(worktree ? { instructions: worktreeMcpInstructions(worktree) } : {}),
|
|
264
420
|
}));
|
|
265
421
|
}
|
|
266
422
|
if (method === "notifications/initialized") {
|
|
@@ -327,8 +483,17 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
327
483
|
else if (name === "relay_upload_artifact") result = await relayUploadArtifact(auth, args);
|
|
328
484
|
else if (name === "relay_attach_artifact") result = relayAttachArtifact(auth, args);
|
|
329
485
|
else if (name === "relay_agent_status") result = relayAgentStatus(args);
|
|
486
|
+
else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
|
|
487
|
+
else if (name === "relay_whoami") result = relayWhoami(auth);
|
|
330
488
|
else if (name === "relay_spawn_agent") result = relaySpawnAgent(auth, args);
|
|
331
489
|
else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
|
|
490
|
+
else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
|
|
491
|
+
else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
|
|
492
|
+
else if (name === "relay_workspace_ready") result = relayWorkspaceMutation(auth, "ready", args);
|
|
493
|
+
else if (name === "relay_workspace_deps") result = relayWorkspaceMutation(auth, "deps-refresh", args);
|
|
494
|
+
else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
|
|
495
|
+
else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
|
|
496
|
+
else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
|
|
332
497
|
else throw new ValidationError(`unknown tool: ${name}`);
|
|
333
498
|
|
|
334
499
|
auditToolCall(auth, name, "ok", result);
|
|
@@ -340,12 +505,67 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
340
505
|
}
|
|
341
506
|
}
|
|
342
507
|
|
|
343
|
-
|
|
508
|
+
// A managed/runtime MCP token is minted with `constraints.agents = [its own agent id]`
|
|
509
|
+
// (see issueMcpRuntimeToken). That single allowed agent IS the caller's identity, and the
|
|
510
|
+
// security layer already treats it as the only `from` this token may use. Derive it so
|
|
511
|
+
// agents never need to know — or type — their own id.
|
|
512
|
+
function senderIdentity(auth: McpAuthContext): string | undefined {
|
|
513
|
+
if (auth.kind !== "component") return undefined;
|
|
514
|
+
const agents = auth.component?.constraints?.agents;
|
|
515
|
+
return agents?.length === 1 ? agents[0] : undefined;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Caller's own agent id for spawn/shutdown gating (#221). `senderIdentity` covers
|
|
519
|
+
// identity-bearing tokens (interactive/mcp, constraints.agents). Managed agents spawned by
|
|
520
|
+
// the orchestrator authenticate with a runner token that carries no `agents` constraint but
|
|
521
|
+
// DOES carry its spawnRequestId/policy — resolve those back to the registered agent card.
|
|
522
|
+
// Returns undefined for server/admin tokens (unrestricted by design).
|
|
523
|
+
function callerAgentId(auth: McpAuthContext): string | undefined {
|
|
524
|
+
const direct = senderIdentity(auth);
|
|
525
|
+
if (direct) return direct;
|
|
526
|
+
if (auth.kind !== "component") return undefined;
|
|
527
|
+
const c = auth.component?.constraints;
|
|
528
|
+
const spawnRequestId = c?.spawnRequestIds?.length === 1 ? c.spawnRequestIds[0] : undefined;
|
|
529
|
+
const policyName = c?.policies?.length === 1 ? c.policies[0] : undefined;
|
|
530
|
+
if (!spawnRequestId && !policyName) return undefined;
|
|
531
|
+
const match = listAgents().find((a) =>
|
|
532
|
+
(spawnRequestId !== undefined && a.meta?.spawnRequestId === spawnRequestId) ||
|
|
533
|
+
(policyName !== undefined && a.meta?.policyName === policyName));
|
|
534
|
+
return match?.id;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function resolveSender(auth: McpAuthContext, rawFrom: unknown): string {
|
|
538
|
+
// Token identity wins and cannot be spoofed; any provided `from` is ignored when known.
|
|
539
|
+
const identity = senderIdentity(auth);
|
|
540
|
+
if (identity) return identity;
|
|
541
|
+
// Server/integration/multi-agent tokens carry no single identity — keep requiring `from`.
|
|
542
|
+
return stringField(rawFrom, "from", { required: true, max: 200 });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
|
|
546
|
+
const agentId = senderIdentity(auth);
|
|
547
|
+
const agent = agentId ? getAgent(agentId) : null;
|
|
548
|
+
return {
|
|
549
|
+
agentId: agentId ?? null,
|
|
550
|
+
actor: auth.actor,
|
|
551
|
+
kind: auth.kind,
|
|
552
|
+
scopes: auth.scopes,
|
|
553
|
+
agent: agent ?? null,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
|
|
344
558
|
const attachments = optionalAttachments(args.attachments);
|
|
345
559
|
const payload = payloadWithAttachments(optionalRecord(args.payload, "payload"), attachments);
|
|
560
|
+
const requestedTo = stringField(args.to, "to", { required: true, max: 200 });
|
|
561
|
+
// Resolve the target to a canonical agent id (so poll-time matching works) and refuse
|
|
562
|
+
// up front when it's unknown or ambiguous — never store a message no one will receive.
|
|
563
|
+
const plan = planSend(requestedTo, listAgents());
|
|
564
|
+
if (plan.kind === "not_found") throw new McpNotFoundError(plan.message);
|
|
565
|
+
if (plan.kind === "ambiguous") throw new ValidationError(plan.message);
|
|
346
566
|
const input: SendMessageInput = {
|
|
347
|
-
from:
|
|
348
|
-
to:
|
|
567
|
+
from: resolveSender(auth, args.from),
|
|
568
|
+
to: plan.to,
|
|
349
569
|
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
350
570
|
subject: optionalString(args.subject, "subject", 200),
|
|
351
571
|
channel: optionalString(args.channel, "channel", 120),
|
|
@@ -359,10 +579,10 @@ function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>):
|
|
|
359
579
|
assertComponentResourceAllowed(auth, { scope: "message:send", resource: { target: input.to, channel: input.channel, agentId: input.from } });
|
|
360
580
|
const result = sendMessageWithResult(input);
|
|
361
581
|
emitMessage(result.message, result.created);
|
|
362
|
-
return result.message;
|
|
582
|
+
return { ...result.message, delivery: plan.receipt };
|
|
363
583
|
}
|
|
364
584
|
|
|
365
|
-
function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Message {
|
|
585
|
+
function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
|
|
366
586
|
const messageId = positiveId(args.messageId, "messageId");
|
|
367
587
|
const parent = getMessage(messageId);
|
|
368
588
|
if (!parent) throw new McpNotFoundError(`message ${messageId} not found`);
|
|
@@ -375,7 +595,7 @@ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Messag
|
|
|
375
595
|
...replyContext(parent),
|
|
376
596
|
}, attachments);
|
|
377
597
|
const input: SendMessageInput = {
|
|
378
|
-
from:
|
|
598
|
+
from: resolveSender(auth, args.from),
|
|
379
599
|
to: parent.from,
|
|
380
600
|
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
381
601
|
subject: optionalString(args.subject, "subject", 200),
|
|
@@ -389,7 +609,13 @@ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Messag
|
|
|
389
609
|
assertComponentResourceAllowed(auth, { scope: "message:send", resource: { target: input.to, channel: input.channel, agentId: input.from } });
|
|
390
610
|
const result = sendMessageWithResult(input);
|
|
391
611
|
emitMessage(result.message, result.created);
|
|
392
|
-
|
|
612
|
+
// Reply routing is fixed to the parent's sender — never reject, but report whether
|
|
613
|
+
// that original sender is still reachable so the agent doesn't wait forever.
|
|
614
|
+
const plan = planSend(input.to, listAgents());
|
|
615
|
+
const delivery: DeliveryReceipt = plan.kind === "not_found" || plan.kind === "ambiguous"
|
|
616
|
+
? { delivered: false, expectReply: false, recipients: [], reason: "original sender no longer reachable" }
|
|
617
|
+
: plan.receipt;
|
|
618
|
+
return { ...result.message, delivery };
|
|
393
619
|
}
|
|
394
620
|
|
|
395
621
|
function relayGetMessage(args: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -490,13 +716,49 @@ function relayAgentStatus(args: Record<string, unknown>): Record<string, unknown
|
|
|
490
716
|
if (!orchestrator) throw new McpNotFoundError(`orchestrator ${orchestratorId} not found`);
|
|
491
717
|
return { orchestrator };
|
|
492
718
|
}
|
|
719
|
+
// Default to running agents only — offline/stale rows are pure noise (you can only
|
|
720
|
+
// message/reply/pair with a live agent) and bloat the payload. includeOffline opts back in.
|
|
721
|
+
const includeOffline = optionalBoolean(args.includeOffline, "includeOffline") === true;
|
|
493
722
|
return {
|
|
494
|
-
agents:
|
|
723
|
+
agents: searchAgents({ status: includeOffline ? "all" : "running" }),
|
|
495
724
|
spawnPolicies: listSpawnPolicies().map((entry) => policyStatusPayload(entry.value)),
|
|
496
725
|
orchestrators: listOrchestrators(),
|
|
497
726
|
};
|
|
498
727
|
}
|
|
499
728
|
|
|
729
|
+
function optionalStringOrArray(value: unknown, field: string): string | string[] | undefined {
|
|
730
|
+
if (value === undefined || value === null) return undefined;
|
|
731
|
+
if (typeof value === "string") return value;
|
|
732
|
+
if (Array.isArray(value)) {
|
|
733
|
+
return value.map((item, i) => stringField(item, `${field}[${i}]`, { required: true, max: 200 }));
|
|
734
|
+
}
|
|
735
|
+
throw new ValidationError(`${field} must be a string or string array`);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
739
|
+
// `spawnedBy: "me"` resolves to the caller's own id — list the children you spawned (#221).
|
|
740
|
+
const spawnedByArg = optionalString(args.spawnedBy, "spawnedBy", 240);
|
|
741
|
+
const spawnedBy = spawnedByArg === "me" ? callerAgentId(auth) ?? "\0unresolved" : spawnedByArg;
|
|
742
|
+
const filter: AgentSearchFilter = {
|
|
743
|
+
label: optionalString(args.label, "label", 200),
|
|
744
|
+
capability: optionalStringOrArray(args.capability, "capability"),
|
|
745
|
+
provider: optionalStringOrArray(args.provider, "provider"),
|
|
746
|
+
tag: optionalStringOrArray(args.tag, "tag"),
|
|
747
|
+
machine: optionalStringOrArray(args.machine, "machine"),
|
|
748
|
+
kind: optionalStringOrArray(args.kind, "kind"),
|
|
749
|
+
spawnedBy,
|
|
750
|
+
// Default to running — you can only act on a live agent (consistent with #218).
|
|
751
|
+
status: optionalStringOrArray(args.status, "status") ?? "running",
|
|
752
|
+
};
|
|
753
|
+
const sort: AgentSearchSort = {
|
|
754
|
+
sortBy: optionalEnum(args.sortBy, "sortBy", ["lastActive", "created", "label"] as const),
|
|
755
|
+
order: optionalEnum(args.order, "order", ["asc", "desc"] as const),
|
|
756
|
+
limit: optionalPositiveInt(args.limit, "limit"),
|
|
757
|
+
};
|
|
758
|
+
const agents = searchAgents(filter, sort);
|
|
759
|
+
return { agents, count: agents.length };
|
|
760
|
+
}
|
|
761
|
+
|
|
500
762
|
function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
501
763
|
const provider = enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider;
|
|
502
764
|
const cwd = optionalString(args.cwd, "cwd", 500);
|
|
@@ -510,28 +772,43 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
510
772
|
const spawnRequestId = optionalString(args.spawnRequestId, "spawnRequestId", 160) ?? generateSpawnRequestId();
|
|
511
773
|
const label = optionalString(args.label, "label", 120);
|
|
512
774
|
const policyName = optionalString(args.policyName, "policyName", 120);
|
|
775
|
+
const profile = optionalString(args.profile, "profile", 120);
|
|
776
|
+
|
|
777
|
+
// #221 runtime gate (belt; the coarse `command:spawn` scope is enforced in callTool, and is
|
|
778
|
+
// granted only to agents whose profile sets maxSpawnedAgents>0 and never to children).
|
|
779
|
+
// Server/admin tokens have no caller identity → unrestricted by design.
|
|
780
|
+
const callerId = callerAgentId(auth);
|
|
781
|
+
if (callerId) {
|
|
782
|
+
const me = getAgent(callerId);
|
|
783
|
+
if (me?.spawnedBy) {
|
|
784
|
+
throw new McpAuthError("spawned agents cannot spawn further agents (no grandchildren)");
|
|
785
|
+
}
|
|
786
|
+
const quota = auth.component?.constraints?.maxSpawnedAgents ?? 0;
|
|
787
|
+
const live = countLiveSpawnedAgents(callerId);
|
|
788
|
+
if (live >= quota) {
|
|
789
|
+
throw new ValidationError(`spawn quota reached (${live}/${quota} live children) — shut one down or wait for one to exit`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
513
793
|
assertComponentResourceAllowed(auth, {
|
|
514
794
|
scope: "agent:write",
|
|
515
795
|
resource: { orchestratorId: orchestrator.id, cwd: resolvedCwd, policyName, spawnRequestId },
|
|
516
796
|
});
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
spawnRequestId,
|
|
533
|
-
createdBy: auth.actor,
|
|
534
|
-
});
|
|
797
|
+
|
|
798
|
+
// Child runner token: a normal long-living agent that is NOT itself spawn-capable
|
|
799
|
+
// (canSpawn:false → no grandchildren), stamped with authoritative lineage so it registers
|
|
800
|
+
// with spawnedBy = caller (the child can't forge it; it's read from the signed token).
|
|
801
|
+
const env = runnerRuntimeTokenEnv({
|
|
802
|
+
orchestratorId: orchestrator.id,
|
|
803
|
+
cwd: resolvedCwd,
|
|
804
|
+
provider,
|
|
805
|
+
label,
|
|
806
|
+
policyName,
|
|
807
|
+
spawnRequestId,
|
|
808
|
+
createdBy: callerId ?? auth.actor,
|
|
809
|
+
canSpawn: false,
|
|
810
|
+
...(callerId ? { spawnedBy: callerId } : {}),
|
|
811
|
+
});
|
|
535
812
|
const command = createCommand({
|
|
536
813
|
type: "agent.spawn",
|
|
537
814
|
source: "system",
|
|
@@ -542,6 +819,7 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
542
819
|
modelParams: selection,
|
|
543
820
|
cwd: resolvedCwd,
|
|
544
821
|
label,
|
|
822
|
+
profile: profile || undefined,
|
|
545
823
|
tags: optionalStringArray(args.tags, "tags") ?? [],
|
|
546
824
|
capabilities: optionalStringArray(args.capabilities, "capabilities") ?? [],
|
|
547
825
|
approvalMode,
|
|
@@ -562,34 +840,6 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
562
840
|
return { ok: true, orchestratorId: orchestrator.id, provider, command };
|
|
563
841
|
}
|
|
564
842
|
|
|
565
|
-
function childRunnerEnvForDelegatingComponent(
|
|
566
|
-
auth: McpAuthContext,
|
|
567
|
-
input: {
|
|
568
|
-
orchestratorId: string;
|
|
569
|
-
cwd: string;
|
|
570
|
-
provider: SpawnProvider;
|
|
571
|
-
label?: string;
|
|
572
|
-
policyName?: string;
|
|
573
|
-
spawnRequestId: string;
|
|
574
|
-
},
|
|
575
|
-
): Record<string, string> {
|
|
576
|
-
const component = auth.component;
|
|
577
|
-
if (!component) throw new McpAuthError("component token required for child-agent delegation");
|
|
578
|
-
if (component.constraints?.canDelegate !== true) {
|
|
579
|
-
throw new McpAuthError("component token cannot delegate child agents");
|
|
580
|
-
}
|
|
581
|
-
return childRunnerRuntimeTokenEnv({
|
|
582
|
-
parentAgentId: component.sub,
|
|
583
|
-
orchestratorId: input.orchestratorId,
|
|
584
|
-
cwd: input.cwd,
|
|
585
|
-
provider: input.provider,
|
|
586
|
-
label: input.label,
|
|
587
|
-
policyName: input.policyName,
|
|
588
|
-
spawnRequestId: input.spawnRequestId,
|
|
589
|
-
createdBy: component.sub,
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
|
|
593
843
|
function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
594
844
|
const agentId = optionalString(args.agentId, "agentId", 240);
|
|
595
845
|
const policyName = optionalString(args.policyName, "policyName", 120);
|
|
@@ -598,6 +848,21 @@ function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>)
|
|
|
598
848
|
if (!agentId && !policyName && !spawnRequestId && !tmuxSession) {
|
|
599
849
|
throw new ValidationError("agentId, policyName, spawnRequestId, or tmuxSession required");
|
|
600
850
|
}
|
|
851
|
+
|
|
852
|
+
// #221: an agent caller may only shut down its OWN live spawned children, addressed by
|
|
853
|
+
// agentId. Broad targeting (policy/spawnRequestId/tmux) and cross-agent kills stay admin-only
|
|
854
|
+
// — otherwise spawn permission silently becomes kill-anyone permission. Server/admin tokens
|
|
855
|
+
// (no caller identity) keep full reach.
|
|
856
|
+
const callerId = callerAgentId(auth);
|
|
857
|
+
if (callerId) {
|
|
858
|
+
if (!agentId || policyName || spawnRequestId || tmuxSession) {
|
|
859
|
+
throw new McpAuthError("agents may only shut down their own spawned children, addressed by agentId");
|
|
860
|
+
}
|
|
861
|
+
const target = getAgent(agentId);
|
|
862
|
+
if (!target || target.spawnedBy !== callerId) {
|
|
863
|
+
throw new McpAuthError(`agent ${agentId} is not one of your spawned children`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
601
866
|
const orchestrator = selectControlOrchestrator({
|
|
602
867
|
orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
|
|
603
868
|
agentId,
|
|
@@ -630,6 +895,119 @@ function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>)
|
|
|
630
895
|
return { ok: true, action: "shutdown", orchestratorId: orchestrator.id, command };
|
|
631
896
|
}
|
|
632
897
|
|
|
898
|
+
// --- Workspace lifecycle tools (#215) -------------------------------------
|
|
899
|
+
// Thin MCP surface over the shared applyWorkspaceAction core. Coarse scope is
|
|
900
|
+
// enforced in callTool; here we add the resource check the routes' authorizeRoute
|
|
901
|
+
// does: an agent may only act on a workspace it OWNS or stewards (server/wildcard
|
|
902
|
+
// tokens keep full reach). Defaults the target to the caller's own workspace so a
|
|
903
|
+
// branch agent never has to hunt for its workspace id.
|
|
904
|
+
|
|
905
|
+
function isWorkspaceAdmin(auth: McpAuthContext): boolean {
|
|
906
|
+
return auth.kind === "server" || hasScope(auth, "*");
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function assertWorkspaceAccess(auth: McpAuthContext, ws: WorkspaceRecord, caller: string | undefined): void {
|
|
910
|
+
if (isWorkspaceAdmin(auth)) return;
|
|
911
|
+
if (!caller) throw new McpAuthError(`not authorized for workspace ${ws.id} (its owner or assigned steward only)`);
|
|
912
|
+
if (caller === ws.ownerAgentId) return;
|
|
913
|
+
// The repo's elected steward (authoritative repo_stewards record, not the mirrored
|
|
914
|
+
// workspace column which only updates on steward change) may reconcile any
|
|
915
|
+
// workspace in its repo — the conflict-landing flow it exists for.
|
|
916
|
+
if (caller === getRepoSteward(ws.repoRoot)?.stewardAgentId) return;
|
|
917
|
+
throw new McpAuthError(`not authorized for workspace ${ws.id} (its owner or assigned steward only)`);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function resolveWorkspaceForCaller(auth: McpAuthContext, args: Record<string, unknown>): WorkspaceRecord {
|
|
921
|
+
const explicitId = optionalString(args.workspaceId, "workspaceId", 240);
|
|
922
|
+
const caller = callerAgentId(auth);
|
|
923
|
+
if (explicitId) {
|
|
924
|
+
const ws = getWorkspace(explicitId);
|
|
925
|
+
if (!ws) throw new McpNotFoundError(`workspace ${explicitId} not found`);
|
|
926
|
+
assertWorkspaceAccess(auth, ws, caller);
|
|
927
|
+
return ws;
|
|
928
|
+
}
|
|
929
|
+
if (!caller) throw new ValidationError("workspaceId is required: no caller agent identity to resolve your own workspace");
|
|
930
|
+
const first = callerIsolatedWorkspace(auth);
|
|
931
|
+
if (!first) throw new McpNotFoundError("you don't own an isolated workspace; pass workspaceId");
|
|
932
|
+
return first;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Non-throwing variant for the initialize primer: the caller's most recently
|
|
936
|
+
// active isolated worktree, or undefined (no caller identity / shared-only).
|
|
937
|
+
// listWorkspaces is ORDER BY updated_at DESC → most recent worktree first.
|
|
938
|
+
function callerIsolatedWorkspace(auth: McpAuthContext): WorkspaceRecord | undefined {
|
|
939
|
+
const caller = callerAgentId(auth);
|
|
940
|
+
if (!caller) return undefined;
|
|
941
|
+
return listWorkspaces({ ownerAgentId: caller }).find((w) => w.mode === "isolated" && !TERMINAL_WORKSPACE_STATUSES.has(w.status));
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async function relayWorkspaceStatus(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
945
|
+
const ws = resolveWorkspaceForCaller(auth, args);
|
|
946
|
+
if (optionalBoolean(args.wait, "wait") !== true) {
|
|
947
|
+
return { workspace: ws, guidance: describeWorkspacePhase(ws), transitioned: false, timedOut: false };
|
|
948
|
+
}
|
|
949
|
+
const timeoutSeconds = optionalPositiveInt(args.timeoutSeconds, "timeoutSeconds");
|
|
950
|
+
const result = await waitForWorkspaceStatus(ws.id, timeoutSeconds ? { timeoutMs: timeoutSeconds * 1000 } : {});
|
|
951
|
+
if (!result.workspace) throw new McpNotFoundError(`workspace ${ws.id} not found`);
|
|
952
|
+
const landed = result.transitioned ? landReceipt(result.fromStatus, result.workspace) : null;
|
|
953
|
+
return {
|
|
954
|
+
workspace: result.workspace,
|
|
955
|
+
guidance: describeWorkspacePhase(result.workspace),
|
|
956
|
+
...(landed ? { landed } : {}),
|
|
957
|
+
fromStatus: result.fromStatus,
|
|
958
|
+
transitioned: result.transitioned,
|
|
959
|
+
timedOut: result.timedOut,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function relayWorkspaceList(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
964
|
+
const filter: { ownerAgentId?: string; repoRoot?: string } = {};
|
|
965
|
+
const repoRoot = optionalString(args.repoRoot, "repoRoot", 1024);
|
|
966
|
+
if (repoRoot) filter.repoRoot = repoRoot;
|
|
967
|
+
if (isWorkspaceAdmin(auth) && optionalBoolean(args.all, "all") === true) {
|
|
968
|
+
const owner = optionalString(args.ownerAgentId, "ownerAgentId", 240);
|
|
969
|
+
if (owner) filter.ownerAgentId = owner;
|
|
970
|
+
} else {
|
|
971
|
+
const caller = callerAgentId(auth);
|
|
972
|
+
if (!caller) throw new ValidationError("no caller agent identity to list your workspaces");
|
|
973
|
+
filter.ownerAgentId = caller;
|
|
974
|
+
}
|
|
975
|
+
const workspaces = listWorkspaces(filter);
|
|
976
|
+
return { workspaces, count: workspaces.length };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, args: Record<string, unknown>): Record<string, unknown> {
|
|
980
|
+
const ws = resolveWorkspaceForCaller(auth, args);
|
|
981
|
+
const result = applyWorkspaceAction(ws, {
|
|
982
|
+
action,
|
|
983
|
+
agentId: callerAgentId(auth) ?? auth.actor,
|
|
984
|
+
detail: optionalString(args.detail, "detail", 4000),
|
|
985
|
+
strategy: action === "merge" ? (optionalEnum(args.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const) as WorkspaceMergeStrategy | undefined) : undefined,
|
|
986
|
+
deleteBranch: action === "merge" ? optionalBoolean(args.deleteBranch, "deleteBranch") : undefined,
|
|
987
|
+
prTitle: optionalString(args.prTitle, "prTitle", 240),
|
|
988
|
+
prBody: optionalString(args.prBody, "prBody", 8000),
|
|
989
|
+
purpose: optionalString(args.purpose, "purpose", 120),
|
|
990
|
+
checkOnly: action === "deps-refresh" ? optionalBoolean(args.checkOnly, "checkOnly") === true : undefined,
|
|
991
|
+
auditMetadata: { via: "mcp", actor: auth.actor },
|
|
992
|
+
});
|
|
993
|
+
if (!result.ok) {
|
|
994
|
+
if (result.httpStatus === 404) throw new McpNotFoundError(result.error);
|
|
995
|
+
if (result.httpStatus === 403) throw new McpAuthError(result.error);
|
|
996
|
+
throw new ValidationError(result.error);
|
|
997
|
+
}
|
|
998
|
+
if (result.command) emitCommand(result.command);
|
|
999
|
+
const payload: Record<string, unknown> = { workspace: result.workspace };
|
|
1000
|
+
if (result.command) payload.command = result.command;
|
|
1001
|
+
if (result.claim !== undefined) payload.claim = result.claim;
|
|
1002
|
+
// After a `ready` hand-off, state the whole contract up front + the directive
|
|
1003
|
+
// next-step, so the agent doesn't decode status enums over the next minutes (#235).
|
|
1004
|
+
if (action === "ready") {
|
|
1005
|
+
payload.contract = readyContract(result.workspace);
|
|
1006
|
+
payload.guidance = describeWorkspacePhase(result.workspace);
|
|
1007
|
+
}
|
|
1008
|
+
return payload;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
633
1011
|
function replyContext(parent: Message): Record<string, unknown> {
|
|
634
1012
|
const parentPayload = parent.payload ?? {};
|
|
635
1013
|
if (parentPayload.schema !== "agent-relay.channel.v1" && !parentPayload.conversation) return {};
|
|
@@ -793,7 +1171,12 @@ function emitMessage(message: Message, created: boolean): void {
|
|
|
793
1171
|
function visibleTools(auth: McpAuthContext): Array<Record<string, unknown>> {
|
|
794
1172
|
return TOOLS
|
|
795
1173
|
.filter((tool) => hasAnyScope(auth, tool.requiredScopes))
|
|
796
|
-
.map(({ name, description, inputSchema }) => ({
|
|
1174
|
+
.map(({ name, description, inputSchema, alwaysLoad }) => ({
|
|
1175
|
+
name,
|
|
1176
|
+
description,
|
|
1177
|
+
inputSchema,
|
|
1178
|
+
...(alwaysLoad ? { _meta: { "anthropic/alwaysLoad": true } } : {}),
|
|
1179
|
+
}));
|
|
797
1180
|
}
|
|
798
1181
|
|
|
799
1182
|
function toolResult(result: unknown): Record<string, unknown> {
|