agent-relay-server 0.62.3 → 0.63.1
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 +1 -1
- package/package.json +4 -2
- package/public/assets/{MessageBubble-DRrxfjyT.js → MessageBubble-Cl6ufPHw.js} +2 -2
- package/public/assets/MessageBubble-Cl6ufPHw.js.map +1 -0
- package/public/assets/{PlanGraphPanel-Dp2eYazz.js → PlanGraphPanel-BnfjmkQ8.js} +2 -2
- package/public/assets/{PlanGraphPanel-Dp2eYazz.js.map → PlanGraphPanel-BnfjmkQ8.js.map} +1 -1
- package/public/assets/{activity--t0U2yzq.js → activity-aFxvGLtd.js} +2 -2
- package/public/assets/{activity--t0U2yzq.js.map → activity-aFxvGLtd.js.map} +1 -1
- package/public/assets/{agent-profiles-BMtJYzxn.js → agent-profiles-B4mleUDz.js} +2 -2
- package/public/assets/{agent-profiles-BMtJYzxn.js.map → agent-profiles-B4mleUDz.js.map} +1 -1
- package/public/assets/{agents-Ck9AePlc.js → agents-DW5djToQ.js} +2 -2
- package/public/assets/{agents-Ck9AePlc.js.map → agents-DW5djToQ.js.map} +1 -1
- package/public/assets/{analytics-CzfK387L.js → analytics-CRi6thtg.js} +2 -2
- package/public/assets/{analytics-CzfK387L.js.map → analytics-CRi6thtg.js.map} +1 -1
- package/public/assets/{automation-Cgw8vtE8.js → automation-Do4OpauI.js} +2 -2
- package/public/assets/{automation-Cgw8vtE8.js.map → automation-Do4OpauI.js.map} +1 -1
- package/public/assets/{branch-state-badge-DnEEPtOP.js → branch-state-badge-D3W3w7zz.js} +2 -2
- package/public/assets/{branch-state-badge-DnEEPtOP.js.map → branch-state-badge-D3W3w7zz.js.map} +1 -1
- package/public/assets/{channels-Dc3L2MQs.js → channels-BytdR9NS.js} +2 -2
- package/public/assets/{channels-Dc3L2MQs.js.map → channels-BytdR9NS.js.map} +1 -1
- package/public/assets/{chat-Fc6iJ7sa.js → chat-CvaQ2czL.js} +2 -2
- package/public/assets/{chat-Fc6iJ7sa.js.map → chat-CvaQ2czL.js.map} +1 -1
- package/public/assets/{connectors-Dc2ttGWs.js → connectors-BLQDp8Bc.js} +2 -2
- package/public/assets/{connectors-Dc2ttGWs.js.map → connectors-BLQDp8Bc.js.map} +1 -1
- package/public/assets/{display-DzNvOu1j.js → display-B06I5sqa.js} +3 -3
- package/public/assets/display-B06I5sqa.js.map +1 -0
- package/public/assets/{formatted-body-impl-Bg-gx7rG.js → formatted-body-impl-0Lf8e1Ic.js} +2 -2
- package/public/assets/{formatted-body-impl-Bg-gx7rG.js.map → formatted-body-impl-0Lf8e1Ic.js.map} +1 -1
- package/public/assets/{index-BeDncjN4.js → index-C3su8ezq.js} +6 -6
- package/public/assets/{index-BeDncjN4.js.map → index-C3su8ezq.js.map} +1 -1
- package/public/assets/{insights-c08aW-7n.js → insights-64uExH1E.js} +2 -2
- package/public/assets/{insights-c08aW-7n.js.map → insights-64uExH1E.js.map} +1 -1
- package/public/assets/{integrations-BwpgcqnF.js → integrations-HyC1EaIp.js} +2 -2
- package/public/assets/{integrations-BwpgcqnF.js.map → integrations-HyC1EaIp.js.map} +1 -1
- package/public/assets/{maintenance-CTKP0kWB.js → maintenance-EVBpKH07.js} +2 -2
- package/public/assets/{maintenance-CTKP0kWB.js.map → maintenance-EVBpKH07.js.map} +1 -1
- package/public/assets/{managed-agents-BofCxo8d.js → managed-agents-BLZPxGuw.js} +2 -2
- package/public/assets/{managed-agents-BofCxo8d.js.map → managed-agents-BLZPxGuw.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CQ-6BIb8.js → markdown-preview-impl-Bg6rCWYN.js} +2 -2
- package/public/assets/{markdown-preview-impl-CQ-6BIb8.js.map → markdown-preview-impl-Bg6rCWYN.js.map} +1 -1
- package/public/assets/{memory-DvfXjMoi.js → memory-Bjr8bgjj.js} +2 -2
- package/public/assets/{memory-DvfXjMoi.js.map → memory-Bjr8bgjj.js.map} +1 -1
- package/public/assets/{messages-C9VsPzZ_.js → messages-CRYw1F4z.js} +2 -2
- package/public/assets/{messages-C9VsPzZ_.js.map → messages-CRYw1F4z.js.map} +1 -1
- package/public/assets/{orchestrators-MffVueeE.js → orchestrators-D-co1vdp.js} +2 -2
- package/public/assets/{orchestrators-MffVueeE.js.map → orchestrators-D-co1vdp.js.map} +1 -1
- package/public/assets/{overview-BSVajefM.js → overview-Ddy5czri.js} +2 -2
- package/public/assets/{overview-BSVajefM.js.map → overview-Ddy5czri.js.map} +1 -1
- package/public/assets/{pairs-B5711-Cs.js → pairs-CFVM8uHL.js} +2 -2
- package/public/assets/{pairs-B5711-Cs.js.map → pairs-CFVM8uHL.js.map} +1 -1
- package/public/assets/{security-DkUwK9Vj.js → security-Df4_76bh.js} +2 -2
- package/public/assets/{security-DkUwK9Vj.js.map → security-Df4_76bh.js.map} +1 -1
- package/public/assets/{select-DrM2RRiT.js → select-Cq3BBnL9.js} +2 -2
- package/public/assets/{select-DrM2RRiT.js.map → select-Cq3BBnL9.js.map} +1 -1
- package/public/assets/{settings-DdKCb-vy.js → settings-DOLEYXZL.js} +2 -2
- package/public/assets/{settings-DdKCb-vy.js.map → settings-DOLEYXZL.js.map} +1 -1
- package/public/assets/{store-iTiheuTD.js → store-C1E8jmNE.js} +4 -4
- package/public/assets/store-C1E8jmNE.js.map +1 -0
- package/public/assets/{tasks-e1y1PFfO.js → tasks-BcsmPi9_.js} +2 -2
- package/public/assets/{tasks-e1y1PFfO.js.map → tasks-BcsmPi9_.js.map} +1 -1
- package/public/assets/{teams-BUcxoGC_.js → teams-BQrV3eKI.js} +2 -2
- package/public/assets/{teams-BUcxoGC_.js.map → teams-BQrV3eKI.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CmZjQOB2.js → terminal-viewer-impl-Dle04qpH.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CmZjQOB2.js.map → terminal-viewer-impl-Dle04qpH.js.map} +1 -1
- package/public/assets/{work-queue-CXBQx43n.js → work-queue-BLhTLg4o.js} +2 -2
- package/public/assets/{work-queue-CXBQx43n.js.map → work-queue-BLhTLg4o.js.map} +1 -1
- package/public/assets/{workspaces-D2EfZh9a.js → workspaces-4NCNTmRQ.js} +3 -3
- package/public/assets/{workspaces-D2EfZh9a.js.map → workspaces-4NCNTmRQ.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/src/adapter.ts +2 -1
- package/runner/src/config.ts +143 -5
- package/src/agent-ref.ts +8 -6
- package/src/artifact-storage.ts +3 -5
- package/src/automations/constants.ts +12 -0
- package/src/automations/cron.ts +129 -0
- package/src/automations/crud.ts +84 -0
- package/src/automations/dispatch.ts +275 -0
- package/src/automations/index.ts +5 -0
- package/src/automations/reconcile.ts +245 -0
- package/src/automations/storage.ts +181 -0
- package/src/automations/types.ts +16 -0
- package/src/automations/validation.ts +140 -0
- package/src/automations.ts +1 -1045
- package/src/bus.ts +3 -4
- package/src/cli/_shared.ts +5 -4
- package/src/cli/agent-detect.ts +6 -4
- package/src/cli/introspect.ts +3 -2
- package/src/cli/token.ts +2 -1
- package/src/cli/workspace.ts +2 -1
- package/src/config.ts +176 -1
- package/src/connectors.ts +3 -3
- package/src/db/activity.ts +1 -1
- package/src/db/agent-predicates.ts +9 -0
- package/src/db/agent-search.ts +2 -2
- package/src/db/agents.ts +13 -41
- package/src/db/artifacts.ts +8 -8
- package/src/db/channel-identity.ts +38 -0
- package/src/db/channels.ts +37 -61
- package/src/db/connection.ts +2 -1
- package/src/db/continuation-archives.ts +7 -7
- package/src/db/delivery.ts +7 -12
- package/src/db/inbox.ts +1 -1
- package/src/db/integrations.ts +4 -4
- package/src/db/issue-lifecycle.ts +6 -6
- package/src/db/mappers.ts +1 -2
- package/src/db/merge-lease.ts +3 -3
- package/src/db/message-claims.ts +16 -0
- package/src/db/message-queries.ts +8 -0
- package/src/db/message-reads.ts +26 -9
- package/src/db/messages.ts +17 -41
- package/src/db/notification-subscriptions.ts +2 -2
- package/src/db/orchestrators.ts +4 -4
- package/src/db/pair-maintenance.ts +9 -0
- package/src/db/pairs.ts +10 -18
- package/src/db/plans.ts +1 -1
- package/src/db/policy-runtime.ts +14 -0
- package/src/db/project-entries.ts +1 -1
- package/src/db/projects.ts +2 -2
- package/src/db/shared.ts +7 -0
- package/src/db/stats.ts +2 -2
- package/src/db/task-core.ts +18 -0
- package/src/db/task-maintenance.ts +24 -0
- package/src/db/tasks.ts +5 -17
- package/src/db/teams.ts +3 -3
- package/src/db/workspaces.ts +6 -6
- package/src/index.ts +6 -5
- package/src/issue-tracker/github.ts +3 -3
- package/src/maintenance/constants.ts +52 -0
- package/src/maintenance/events.ts +32 -0
- package/src/maintenance/index.ts +7 -0
- package/src/maintenance/jobs.ts +340 -0
- package/src/maintenance/orphaned-sessions.ts +86 -0
- package/src/maintenance/scheduler.ts +231 -0
- package/src/maintenance/teams.ts +65 -0
- package/src/maintenance/types.ts +31 -0
- package/src/maintenance/workspaces.ts +251 -0
- package/src/maintenance.ts +1 -1099
- package/src/mcp/identity.ts +131 -0
- package/src/mcp/index.ts +208 -0
- package/src/mcp/tools-messaging.ts +459 -0
- package/src/mcp/tools-spawn.ts +145 -0
- package/src/mcp/tools-workspace.ts +227 -0
- package/src/mcp/types.ts +24 -0
- package/src/mcp/validation.ts +104 -0
- package/src/mcp.ts +1 -1319
- package/src/notification-router.ts +2 -2
- package/src/notification-rules.ts +2 -2
- package/src/project-ingest.ts +3 -3
- package/src/project-instructions.ts +1 -1
- package/src/provider-config-store.ts +5 -4
- package/src/provider-state.ts +2 -4
- package/src/reviewer-pipeline.ts +1 -1
- package/src/routes/agent-sessions.ts +5 -4
- package/src/routes/orchestrator-proxy.ts +5 -4
- package/src/routes/workspaces.ts +5 -4
- package/src/security.ts +13 -7
- package/src/self-resume.ts +3 -7
- package/src/services/agent-action.ts +2 -2
- package/src/services/issue-activity.ts +0 -4
- package/src/services/issue-associations.ts +2 -2
- package/src/services/issue-lifecycle.ts +8 -11
- package/src/services/managed-running.ts +1 -1
- package/src/services/parity-harness.ts +3 -3
- package/src/services/plan-graph.ts +4 -7
- package/src/services/send-message.ts +16 -12
- package/src/services/shutdown-agent.ts +2 -2
- package/src/services/team-templates.ts +2 -2
- package/src/services/team.ts +2 -2
- package/src/settings/modules.ts +1 -1
- package/src/settings/registry.ts +3 -3
- package/src/spawn-command.ts +1 -1
- package/src/sse.ts +0 -4
- package/src/upgrade.ts +7 -9
- package/src/workspace-actions.ts +1 -1
- package/src/workspace-auto-merge.ts +4 -5
- package/src/workspace-claim.ts +1 -2
- package/src/workspace-orphans.ts +2 -7
- package/src/workspace-probe.ts +5 -8
- package/public/assets/MessageBubble-DRrxfjyT.js.map +0 -1
- package/public/assets/display-DzNvOu1j.js.map +0 -1
- package/public/assets/store-iTiheuTD.js.map +0 -1
package/src/mcp.ts
CHANGED
|
@@ -1,1319 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "./artifact-storage";
|
|
3
|
-
import { spawnAgent, type SpawnAgentInput } from "./services/spawn-agent";
|
|
4
|
-
import { authContextFromMcp } from "./services/auth-context";
|
|
5
|
-
import { isPathWithinBase } from "./utils";
|
|
6
|
-
import { optionalEnum } from "./validation";
|
|
7
|
-
import { bytesToStream, readBodyBytes } from "./http-body";
|
|
8
|
-
import { MAX_BODY_BYTES, VERSION } from "./config";
|
|
9
|
-
import { getManagedAgentState, getSpawnPolicy, listSpawnPolicies } from "./config-store";
|
|
10
|
-
import { buildSpawnTargets, selectSpawnOrchestrator, spawnCapablePrimer } from "./spawn-targets";
|
|
11
|
-
import { McpAuthError, McpNotFoundError } from "./mcp-errors";
|
|
12
|
-
import {
|
|
13
|
-
createArtifact,
|
|
14
|
-
createActivityEvent,
|
|
15
|
-
getAgent,
|
|
16
|
-
getArtifact,
|
|
17
|
-
getMessage,
|
|
18
|
-
getOrchestrator,
|
|
19
|
-
getRepoSteward,
|
|
20
|
-
getTask,
|
|
21
|
-
getThread,
|
|
22
|
-
getWorkspace,
|
|
23
|
-
linkArtifact,
|
|
24
|
-
listAgents,
|
|
25
|
-
listWorkspaces,
|
|
26
|
-
ownedIsolatedWorkspace,
|
|
27
|
-
searchContinuationArchives,
|
|
28
|
-
searchAgents,
|
|
29
|
-
type AgentSearchFilter,
|
|
30
|
-
type AgentSearchSort,
|
|
31
|
-
listArtifactsForEntity,
|
|
32
|
-
listOrchestrators,
|
|
33
|
-
upsertArtifactBlob,
|
|
34
|
-
ValidationError,
|
|
35
|
-
} from "./db";
|
|
36
|
-
import { resolveCallerAgentId, type DeliveryReceipt } from "./agent-ref";
|
|
37
|
-
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "./services/shutdown-agent";
|
|
38
|
-
import { emitRelayEvent } from "./events";
|
|
39
|
-
import { compactAndResume } from "./self-resume";
|
|
40
|
-
import {
|
|
41
|
-
getComponentAuth,
|
|
42
|
-
getIntegrationAuth,
|
|
43
|
-
hasComponentScope,
|
|
44
|
-
hasIntegrationScope,
|
|
45
|
-
isComponentAuthorizedFor,
|
|
46
|
-
} from "./security";
|
|
47
|
-
import { sendMessageService } from "./services/send-message";
|
|
48
|
-
import { ServiceAuthError } from "./services/errors";
|
|
49
|
-
import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
|
|
50
|
-
import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
|
|
51
|
-
import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
|
|
52
|
-
import { PROJECT_TOOLS, relayProjectTool } from "./mcp-project-tools"; import { ISSUE_REF_TOOLS, relayIssueRefTool } from "./mcp-issue-ref-tools"; import { ISSUE_ASSOCIATION_TOOLS, relayIssueAssociationTool } from "./mcp-issue-association-tools"; import { ISSUE_ACTIVITY_TOOLS, relayIssueActivityTool } from "./mcp-issue-activity-tools"; import { ISSUE_LIFECYCLE_TOOLS, relayIssueLifecycleTool } from "./mcp-issue-lifecycle-tools";
|
|
53
|
-
import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "./mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "./mcp-merge-tools";
|
|
54
|
-
import { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
|
|
55
|
-
import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action"; import { providerCatalogToolInputSchema } from "./mcp-provider-catalog";
|
|
56
|
-
import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
|
|
57
|
-
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
|
|
58
|
-
import { AUTO_MERGE_POLICIES } from "./workspace-merge";
|
|
59
|
-
import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
|
|
60
|
-
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
61
|
-
import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
62
|
-
|
|
63
|
-
type JsonRpcId = string | number | null;
|
|
64
|
-
|
|
65
|
-
type JsonRpcRequest = { jsonrpc?: "2.0"; id?: JsonRpcId; method?: string; params?: unknown };
|
|
66
|
-
|
|
67
|
-
type McpAuthContext = {
|
|
68
|
-
actor: string;
|
|
69
|
-
kind: "component" | "integration" | "server";
|
|
70
|
-
scopes: string[];
|
|
71
|
-
component?: NonNullable<ReturnType<typeof getComponentAuth>>;
|
|
72
|
-
integration?: NonNullable<ReturnType<typeof getIntegrationAuth>>;
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
type ToolDefinition = {
|
|
76
|
-
name: string;
|
|
77
|
-
description: string;
|
|
78
|
-
requiredScopes: string[];
|
|
79
|
-
inputSchema: Record<string, unknown>;
|
|
80
|
-
// Emit `_meta: { "anthropic/alwaysLoad": true }` in tools/list so Claude Code
|
|
81
|
-
// (v2.1.121+) loads this tool's schema EAGERLY instead of deferring it behind a
|
|
82
|
-
// ToolSearch round-trip. Reserve for the few verbs most agents use every session
|
|
83
|
-
// (the doc warns each one costs context every turn). Verified convention; same
|
|
84
|
-
// mechanism callmux's per-tool alwaysLoad uses.
|
|
85
|
-
alwaysLoad?: boolean;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const MCP_PROTOCOL_VERSION = "2024-11-05";
|
|
89
|
-
const MCP_BODY_OVERHEAD_BYTES = 16 * 1024;
|
|
90
|
-
const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive", "other"] as const;
|
|
91
|
-
const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"] as const;
|
|
92
|
-
const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
|
|
93
|
-
const VALID_ARTIFACT_ENTITY_TYPES = ["message", "task", "recipeRun", "recipeStep", "channelEvent"] as const;
|
|
94
|
-
const TOOLS: ToolDefinition[] = [
|
|
95
|
-
{
|
|
96
|
-
name: "relay_send_message",
|
|
97
|
-
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. An ambiguous target is rejected up front. An unknown target is stored for delivery once that id registers (the receipt reports delivered:false with empty recipients and a reason) — never silently dropped.",
|
|
98
|
-
requiredScopes: ["messages:write", "message:send"],
|
|
99
|
-
inputSchema: {
|
|
100
|
-
type: "object",
|
|
101
|
-
properties: {
|
|
102
|
-
from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
|
|
103
|
-
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:, team:, policy:, or broadcast." },
|
|
104
|
-
body: { type: "string" },
|
|
105
|
-
subject: { type: "string" },
|
|
106
|
-
channel: { type: "string" },
|
|
107
|
-
claimable: { type: "boolean" },
|
|
108
|
-
idempotencyKey: { type: "string" },
|
|
109
|
-
attachments: { type: "array", items: { type: "object" } },
|
|
110
|
-
payload: { type: "object" },
|
|
111
|
-
meta: { type: "object" },
|
|
112
|
-
},
|
|
113
|
-
required: ["to", "body"],
|
|
114
|
-
additionalProperties: false,
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
name: "relay_reply",
|
|
119
|
-
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.",
|
|
120
|
-
requiredScopes: ["messages:write", "message:send"],
|
|
121
|
-
inputSchema: {
|
|
122
|
-
type: "object",
|
|
123
|
-
properties: {
|
|
124
|
-
from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
|
|
125
|
-
messageId: { type: "integer", minimum: 1 },
|
|
126
|
-
body: { type: "string" },
|
|
127
|
-
subject: { type: "string" },
|
|
128
|
-
format: { type: "string", enum: ["text", "markdown", "markdownv2"] },
|
|
129
|
-
attachments: { type: "array", items: { type: "object" } },
|
|
130
|
-
payload: { type: "object" },
|
|
131
|
-
meta: { type: "object" },
|
|
132
|
-
},
|
|
133
|
-
required: ["messageId", "body"],
|
|
134
|
-
additionalProperties: false,
|
|
135
|
-
},
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
name: "relay_get_message",
|
|
139
|
-
description: "Fetch a full Agent Relay message body with optional attachments and thread context.",
|
|
140
|
-
requiredScopes: ["messages:read", "message:read"],
|
|
141
|
-
inputSchema: {
|
|
142
|
-
type: "object",
|
|
143
|
-
properties: {
|
|
144
|
-
messageId: { type: "integer", minimum: 1 },
|
|
145
|
-
includeArtifacts: { type: "boolean", default: true },
|
|
146
|
-
includeThread: { type: "boolean", default: true },
|
|
147
|
-
},
|
|
148
|
-
required: ["messageId"],
|
|
149
|
-
additionalProperties: false,
|
|
150
|
-
},
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
name: "relay_get_thread",
|
|
154
|
-
description: "Fetch the full Agent Relay thread containing a message.",
|
|
155
|
-
requiredScopes: ["messages:read", "message:read"],
|
|
156
|
-
inputSchema: {
|
|
157
|
-
type: "object",
|
|
158
|
-
properties: {
|
|
159
|
-
messageId: { type: "integer", minimum: 1 },
|
|
160
|
-
},
|
|
161
|
-
required: ["messageId"],
|
|
162
|
-
additionalProperties: false,
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
{
|
|
166
|
-
name: "relay_upload_artifact",
|
|
167
|
-
description: "Upload long text or base64 content as an Agent Relay artifact.",
|
|
168
|
-
requiredScopes: ["artifact:write"],
|
|
169
|
-
inputSchema: {
|
|
170
|
-
type: "object",
|
|
171
|
-
properties: {
|
|
172
|
-
content: { type: "string", description: "UTF-8 text content to store." },
|
|
173
|
-
base64: { type: "string", description: "Base64-encoded binary content to store." },
|
|
174
|
-
filename: { type: "string" },
|
|
175
|
-
mediaType: { type: "string" },
|
|
176
|
-
kind: { type: "string", enum: VALID_ARTIFACT_KINDS },
|
|
177
|
-
sensitivity: { type: "string", enum: VALID_ARTIFACT_SENSITIVITIES },
|
|
178
|
-
role: { type: "string", enum: VALID_ARTIFACT_ROLES },
|
|
179
|
-
title: { type: "string" },
|
|
180
|
-
metadata: { type: "object" },
|
|
181
|
-
expiresAt: { type: "integer", minimum: 1 },
|
|
182
|
-
digest: { type: "string", description: "Optional sha256 digest for integrity verification." },
|
|
183
|
-
},
|
|
184
|
-
additionalProperties: false,
|
|
185
|
-
},
|
|
186
|
-
},
|
|
187
|
-
{
|
|
188
|
-
name: "relay_attach_artifact",
|
|
189
|
-
description: "Attach an existing Relay artifact to a message, task, recipe run, recipe step, or channel event.",
|
|
190
|
-
requiredScopes: ["artifact:write"],
|
|
191
|
-
inputSchema: {
|
|
192
|
-
type: "object",
|
|
193
|
-
properties: {
|
|
194
|
-
artifactId: { type: "string" },
|
|
195
|
-
entityType: { type: "string", enum: VALID_ARTIFACT_ENTITY_TYPES },
|
|
196
|
-
entityId: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] },
|
|
197
|
-
role: { type: "string", enum: VALID_ARTIFACT_ROLES },
|
|
198
|
-
title: { type: "string" },
|
|
199
|
-
},
|
|
200
|
-
required: ["artifactId", "entityType", "entityId"],
|
|
201
|
-
additionalProperties: false,
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
{
|
|
205
|
-
name: "relay_agent_status",
|
|
206
|
-
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.",
|
|
207
|
-
requiredScopes: ["agents:read", "agent:read"],
|
|
208
|
-
inputSchema: {
|
|
209
|
-
type: "object",
|
|
210
|
-
properties: {
|
|
211
|
-
agentId: { type: "string" },
|
|
212
|
-
policyName: { type: "string" },
|
|
213
|
-
orchestratorId: { type: "string" },
|
|
214
|
-
includeOffline: { type: "boolean", description: "Include offline/stale agents in the default list (default false)." },
|
|
215
|
-
},
|
|
216
|
-
additionalProperties: false,
|
|
217
|
-
},
|
|
218
|
-
},
|
|
219
|
-
{
|
|
220
|
-
name: "relay_find_agents",
|
|
221
|
-
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.",
|
|
222
|
-
requiredScopes: ["agents:read", "agent:read"],
|
|
223
|
-
inputSchema: {
|
|
224
|
-
type: "object",
|
|
225
|
-
properties: {
|
|
226
|
-
label: { type: "string", description: "Substring match on label or name." },
|
|
227
|
-
capability: { type: ["string", "array"], items: { type: "string" }, description: "One or more capabilities (OR within)." },
|
|
228
|
-
provider: { type: ["string", "array"], items: { type: "string" }, description: "Model provider, e.g. claude or codex." },
|
|
229
|
-
tag: { type: ["string", "array"], items: { type: "string" } },
|
|
230
|
-
machine: { type: ["string", "array"], items: { type: "string" } },
|
|
231
|
-
kind: { type: ["string", "array"], items: { type: "string" } },
|
|
232
|
-
status: { type: ["string", "array"], items: { type: "string" }, description: "online/idle/busy/offline/stale, the 'running' shorthand, or 'all'. Default 'running'." },
|
|
233
|
-
spawnedBy: { type: "string", description: "Parent agent id, or 'me' for your own spawned children (#221)." },
|
|
234
|
-
teamId: { type: "string", description: "Durable team id for roster filtering." },
|
|
235
|
-
sortBy: { type: "string", enum: ["lastActive", "created", "label"] },
|
|
236
|
-
order: { type: "string", enum: ["asc", "desc"] },
|
|
237
|
-
limit: { type: "integer", minimum: 1, maximum: 200 },
|
|
238
|
-
},
|
|
239
|
-
additionalProperties: false,
|
|
240
|
-
},
|
|
241
|
-
},
|
|
242
|
-
{
|
|
243
|
-
name: "relay_whoami",
|
|
244
|
-
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.",
|
|
245
|
-
requiredScopes: ["mcp:use"],
|
|
246
|
-
inputSchema: {
|
|
247
|
-
type: "object",
|
|
248
|
-
properties: {},
|
|
249
|
-
additionalProperties: false,
|
|
250
|
-
},
|
|
251
|
-
},
|
|
252
|
-
{
|
|
253
|
-
name: "relay_compact_and_resume",
|
|
254
|
-
description: "Self-resume this same agent in place: Relay stores the continuation envelope, compacts or clears context, then resumes with the relay-built continuation. Use at a clean seam after a context advisory.",
|
|
255
|
-
requiredScopes: ["command:write"],
|
|
256
|
-
inputSchema: {
|
|
257
|
-
type: "object",
|
|
258
|
-
properties: {
|
|
259
|
-
workingState: { type: "string", description: "Current replaceable working state for the next generation." },
|
|
260
|
-
ruledOut: {
|
|
261
|
-
type: "array",
|
|
262
|
-
items: {
|
|
263
|
-
oneOf: [
|
|
264
|
-
{ type: "string" },
|
|
265
|
-
{
|
|
266
|
-
type: "object",
|
|
267
|
-
properties: {
|
|
268
|
-
ruledOut: { type: "string" },
|
|
269
|
-
why: { type: "string" },
|
|
270
|
-
},
|
|
271
|
-
required: ["ruledOut"],
|
|
272
|
-
additionalProperties: false,
|
|
273
|
-
},
|
|
274
|
-
],
|
|
275
|
-
},
|
|
276
|
-
},
|
|
277
|
-
mode: { type: "string", enum: ["clear-inject", "native"] },
|
|
278
|
-
objective: { type: "string", description: "Seed-only objective. Used only on the first call when no spawn prompt seeded the envelope." },
|
|
279
|
-
},
|
|
280
|
-
required: ["workingState"],
|
|
281
|
-
additionalProperties: false,
|
|
282
|
-
},
|
|
283
|
-
},
|
|
284
|
-
{
|
|
285
|
-
name: "relay_recall",
|
|
286
|
-
description: "Recall this same agent's archived pre-compaction transcript segments by keyword/BM25 search. Self-only: caller identity comes from the MCP token; no agentId parameter is accepted.",
|
|
287
|
-
requiredScopes: ["memory:read"],
|
|
288
|
-
inputSchema: {
|
|
289
|
-
type: "object",
|
|
290
|
-
properties: {
|
|
291
|
-
query: { type: "string", description: "Keyword query over archived compact/clear segments." },
|
|
292
|
-
limit: { type: "integer", minimum: 1, maximum: 20 },
|
|
293
|
-
},
|
|
294
|
-
required: ["query"],
|
|
295
|
-
additionalProperties: false,
|
|
296
|
-
},
|
|
297
|
-
},
|
|
298
|
-
{
|
|
299
|
-
name: "relay_spawn_agent",
|
|
300
|
-
description: "Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via `prompt` in the same call. Defaults to your own host (override with orchestratorId) and returns the resolved agent id once it registers. 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).",
|
|
301
|
-
requiredScopes: ["command:spawn"],
|
|
302
|
-
inputSchema: {
|
|
303
|
-
type: "object",
|
|
304
|
-
properties: {
|
|
305
|
-
provider: { type: "string", enum: SPAWN_PROVIDERS },
|
|
306
|
-
orchestratorId: { type: "string", description: "Target host. Defaults to the host that owns cwd, else YOUR OWN host — only set it to spawn onto a different machine." },
|
|
307
|
-
cwd: { type: "string", description: "Working directory for the agent. Defaults to YOUR OWN cwd (the repo you're working in) when omitted, else the orchestrator's base dir. Must resolve within the target orchestrator's base directory (enforced server-side). NOTE: workspaceMode `isolated` requires a git repo — the resolved cwd must be inside one or the spawn is rejected." },
|
|
308
|
-
label: { type: "string" },
|
|
309
|
-
model: { type: "string" },
|
|
310
|
-
effort: { type: "string", enum: VALID_EFFORTS },
|
|
311
|
-
approvalMode: { type: "string", enum: APPROVAL_MODES, description: "Permission posture for the worker. Defaults to YOUR OWN approval mode when omitted (a headless `guarded` worker wedges on the first approval prompt), so a coordinator running `open` spawns workers that can act autonomously. Pass an explicit value to narrow a child (e.g. `read-only` reviewer)." },
|
|
312
|
-
prompt: { type: "string", description: "Initial task/message delivered to the agent on launch — spawn and hand it its first instruction in one call (no separate follow-up message needed)." },
|
|
313
|
-
expectReply: { type: "boolean", description: "Require the worker to report back: it gets a pending reply obligation to you and CANNOT go idle until it delivers its result (which routes to your inbox and wakes you). Defaults true when you pass a prompt — set false for a true fire-and-forget worker that needn't report (e.g. one that only lands a branch)." },
|
|
314
|
-
systemPromptAppend: { type: "string" },
|
|
315
|
-
projectInstructions: { type: "object", description: "#409 — project instruction projection (default OFF). When set, relay composes a knowledge preamble from the spawn cwd's resolved project (own + inherited entries) and prepends it to systemPromptAppend, instead of relying on host CLAUDE.md. `policy: \"all\"` includes ingested host content; `\"vanilla\"` excludes it (manual+promoted only). Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_INSTRUCTION_POLICY_NAMES } }, additionalProperties: false },
|
|
316
|
-
profile: { type: "string", description: "Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota)." },
|
|
317
|
-
tags: { type: "array", items: { type: "string" } },
|
|
318
|
-
capabilities: { type: "array", items: { type: "string" } },
|
|
319
|
-
providerArgs: { type: "array", items: { type: "string" } },
|
|
320
|
-
policyName: { type: "string" },
|
|
321
|
-
spawnRequestId: { type: "string" },
|
|
322
|
-
lifecycle: { type: "string", enum: VALID_AGENT_LIFECYCLES, description: "Lifecycle mode. `persistent` keeps the agent until explicitly stopped. `transient` lets Relay auto-shutdown the child after it is idle and, for isolated workers, after its branch has landed. Default: persistent." },
|
|
323
|
-
workspaceMode: { type: "string", enum: VALID_WORKSPACE_MODES, description: "Git workspace for the worker. `isolated` = its own worktree on an `agent/session-*` branch off main that auto-lands when ready (delegate a unit of work, get a branch worker). `shared` = edits your live checkout (a sidecar). `inherit` = let the orchestrator decide. DEFAULT for agent-initiated spawns is `isolated` so a delegated worker lands to main instead of editing your tree; pass `shared`/`inherit` to override." },
|
|
324
|
-
waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 8000; 0 = return immediately with just spawnRequestId)." },
|
|
325
|
-
},
|
|
326
|
-
required: ["provider"],
|
|
327
|
-
additionalProperties: false,
|
|
328
|
-
},
|
|
329
|
-
},
|
|
330
|
-
{
|
|
331
|
-
name: "relay_spawn_targets",
|
|
332
|
-
description: "Return the LIVE spawn capability matrix before you spawn: which orchestrators/hosts are online, which providers actually run on each (orchestrator-probed, not a static guess), each provider's models with per-model effort levels + defaults, your own host (isSelf) and remaining spawn quota, and named profiles you can spawn by role. Call this first whenever you decide to spawn — it removes the guesswork that otherwise only surfaces as a runtime rejection. Requires the command:spawn scope.",
|
|
333
|
-
requiredScopes: ["command:spawn"],
|
|
334
|
-
inputSchema: {
|
|
335
|
-
type: "object",
|
|
336
|
-
properties: {},
|
|
337
|
-
additionalProperties: false,
|
|
338
|
-
},
|
|
339
|
-
},
|
|
340
|
-
{
|
|
341
|
-
name: "relay_shutdown_agent",
|
|
342
|
-
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, addressed by agentId or spawnRequestId (policyName/tmuxSession are admin-only broad targets). Admin tokens keep full reach.",
|
|
343
|
-
requiredScopes: ["command:shutdown"],
|
|
344
|
-
inputSchema: {
|
|
345
|
-
type: "object",
|
|
346
|
-
properties: {
|
|
347
|
-
agentId: { type: "string", description: "Target child by agent id. For an agent caller, agentId or spawnRequestId identifies your spawned child." },
|
|
348
|
-
policyName: { type: "string", description: "Broad target by managed-agent policy — admin/full-reach callers only." },
|
|
349
|
-
spawnRequestId: { type: "string", description: "Target child by the idempotency key relay_spawn_agent returned — resolved to your spawned child server-side." },
|
|
350
|
-
tmuxSession: { type: "string", description: "Broad target by tmux session — admin/full-reach callers only." },
|
|
351
|
-
orchestratorId: { type: "string" },
|
|
352
|
-
reason: { type: "string" },
|
|
353
|
-
timeoutMs: { type: "integer", minimum: 1 },
|
|
354
|
-
},
|
|
355
|
-
additionalProperties: false,
|
|
356
|
-
},
|
|
357
|
-
},
|
|
358
|
-
AGENT_ACTION_TOOL,
|
|
359
|
-
{
|
|
360
|
-
name: "relay_workspace_status",
|
|
361
|
-
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.",
|
|
362
|
-
requiredScopes: ["agents:read", "agent:read", "agent:write"],
|
|
363
|
-
alwaysLoad: true,
|
|
364
|
-
inputSchema: {
|
|
365
|
-
type: "object",
|
|
366
|
-
properties: {
|
|
367
|
-
workspaceId: { type: "string", description: "Defaults to your own isolated workspace (resolved from your identity)." },
|
|
368
|
-
wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout." },
|
|
369
|
-
timeoutSeconds: { type: "integer", minimum: 1, maximum: 600, description: "Max seconds to wait when wait:true (default 300, max 600). Returns early on any transition." },
|
|
370
|
-
},
|
|
371
|
-
additionalProperties: false,
|
|
372
|
-
},
|
|
373
|
-
},
|
|
374
|
-
{
|
|
375
|
-
name: "relay_workspace_ready",
|
|
376
|
-
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.",
|
|
377
|
-
requiredScopes: ["agent:write"],
|
|
378
|
-
alwaysLoad: true,
|
|
379
|
-
inputSchema: {
|
|
380
|
-
type: "object",
|
|
381
|
-
properties: {
|
|
382
|
-
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
383
|
-
detail: { type: "string", description: "Optional note recorded on the activity feed." },
|
|
384
|
-
},
|
|
385
|
-
additionalProperties: false,
|
|
386
|
-
},
|
|
387
|
-
},
|
|
388
|
-
{
|
|
389
|
-
name: "relay_workspace_deps",
|
|
390
|
-
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.",
|
|
391
|
-
requiredScopes: ["agent:write"],
|
|
392
|
-
inputSchema: {
|
|
393
|
-
type: "object",
|
|
394
|
-
properties: {
|
|
395
|
-
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
396
|
-
checkOnly: { type: "boolean", description: "Report staleness only; do not re-provision." },
|
|
397
|
-
detail: { type: "string" },
|
|
398
|
-
},
|
|
399
|
-
additionalProperties: false,
|
|
400
|
-
},
|
|
401
|
-
},
|
|
402
|
-
{
|
|
403
|
-
name: "relay_workspace_list",
|
|
404
|
-
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.",
|
|
405
|
-
requiredScopes: ["agents:read", "agent:read", "agent:write"],
|
|
406
|
-
inputSchema: {
|
|
407
|
-
type: "object",
|
|
408
|
-
properties: {
|
|
409
|
-
all: { type: "boolean", description: "Admin/server only: list all workspaces, not just your own." },
|
|
410
|
-
ownerAgentId: { type: "string", description: "Admin/server + all:true: filter to one owner." },
|
|
411
|
-
repoRoot: { type: "string", description: "Filter to one repository root." },
|
|
412
|
-
},
|
|
413
|
-
additionalProperties: false,
|
|
414
|
-
},
|
|
415
|
-
},
|
|
416
|
-
{
|
|
417
|
-
name: "relay_workspace_claim",
|
|
418
|
-
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.",
|
|
419
|
-
requiredScopes: ["agent:write"],
|
|
420
|
-
inputSchema: {
|
|
421
|
-
type: "object",
|
|
422
|
-
properties: {
|
|
423
|
-
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
424
|
-
purpose: { type: "string", description: "Why you're claiming (shown in the activity feed)." },
|
|
425
|
-
detail: { type: "string" },
|
|
426
|
-
},
|
|
427
|
-
additionalProperties: false,
|
|
428
|
-
},
|
|
429
|
-
},
|
|
430
|
-
{
|
|
431
|
-
name: "relay_workspace_release",
|
|
432
|
-
description: "Release a steward claim taken with relay_workspace_claim, letting the auto-merge resume. Owner or assigned steward only.",
|
|
433
|
-
requiredScopes: ["agent:write"],
|
|
434
|
-
inputSchema: {
|
|
435
|
-
type: "object",
|
|
436
|
-
properties: {
|
|
437
|
-
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
438
|
-
purpose: { type: "string" },
|
|
439
|
-
detail: { type: "string" },
|
|
440
|
-
},
|
|
441
|
-
additionalProperties: false,
|
|
442
|
-
},
|
|
443
|
-
},
|
|
444
|
-
{
|
|
445
|
-
name: "relay_workspace_land",
|
|
446
|
-
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.",
|
|
447
|
-
requiredScopes: ["command:write"],
|
|
448
|
-
inputSchema: {
|
|
449
|
-
type: "object",
|
|
450
|
-
properties: {
|
|
451
|
-
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
452
|
-
strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto). Falls back to the instance landing.strategy config when unset." },
|
|
453
|
-
deleteBranch: { type: "boolean", description: "Delete the branch after a successful merge (default true)." },
|
|
454
|
-
prTitle: { type: "string" },
|
|
455
|
-
prBody: { type: "string" },
|
|
456
|
-
autoMerge: { type: "string", enum: AUTO_MERGE_POLICIES, description: "Auto-merge policy for PR-strategy lands. Defaults to 'on-green' when strategy resolves to 'pr'. Ignored for rebase-ff/auto strategies." },
|
|
457
|
-
reviewer: { type: "string", description: "GitHub login or team slug to request as a reviewer on the opened PR (pr strategy only)." },
|
|
458
|
-
detail: { type: "string" },
|
|
459
|
-
},
|
|
460
|
-
additionalProperties: false,
|
|
461
|
-
},
|
|
462
|
-
},
|
|
463
|
-
...MERGE_TOOLS, ...TEAM_TOOLS,
|
|
464
|
-
...PLAN_TOOLS, ...ISSUE_REF_TOOLS, ...ISSUE_ASSOCIATION_TOOLS, ...ISSUE_ACTIVITY_TOOLS, ...ISSUE_LIFECYCLE_TOOLS,
|
|
465
|
-
...BLACKBOARD_TOOLS,
|
|
466
|
-
...PROJECT_TOOLS,
|
|
467
|
-
...NOTIFICATION_SUBSCRIPTION_TOOLS,
|
|
468
|
-
];
|
|
469
|
-
|
|
470
|
-
export async function postMcp(req: Request): Promise<Response> {
|
|
471
|
-
const parsed = await parseJsonRpcRequest(req);
|
|
472
|
-
if (!parsed.ok) return Response.json(jsonRpcError(null, -32700, parsed.error), { status: parsed.status });
|
|
473
|
-
const request = parsed.request;
|
|
474
|
-
const id = request.id ?? null;
|
|
475
|
-
const method = request.method;
|
|
476
|
-
if (typeof method !== "string") return Response.json(jsonRpcError(id, -32600, "method required"));
|
|
477
|
-
|
|
478
|
-
try {
|
|
479
|
-
const auth = mcpAuthContext(req);
|
|
480
|
-
if (!hasAnyScope(auth, ["mcp:use"])) {
|
|
481
|
-
return Response.json(jsonRpcError(id, -32001, "MCP token missing mcp:use scope", { status: 403 }));
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (method === "initialize") {
|
|
485
|
-
// Mode-tailored primer (#214 §3): surface the worktree merge-back map only
|
|
486
|
-
// to callers that own an isolated worktree; shared-workspace agents omit it.
|
|
487
|
-
// #308: append the spawn primer only when the token actually grants command:spawn —
|
|
488
|
-
// authoritative scope-gating, eager + thin, so non-spawn agents get nothing.
|
|
489
|
-
const worktree = callerIsolatedWorkspace(auth);
|
|
490
|
-
const instructions = [
|
|
491
|
-
worktree ? worktreeMcpInstructions(worktree) : "",
|
|
492
|
-
spawnCapablePrimer({ canSpawn: hasAnyScope(auth, ["command:spawn"]), quota: auth.component?.constraints?.maxSpawnedAgents }),
|
|
493
|
-
].filter(Boolean).join("\n\n");
|
|
494
|
-
return Response.json(jsonRpcResult(id, {
|
|
495
|
-
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
496
|
-
capabilities: { tools: {} },
|
|
497
|
-
serverInfo: { name: "agent-relay", title: "Agent Relay", version: VERSION },
|
|
498
|
-
...(instructions ? { instructions } : {}),
|
|
499
|
-
}));
|
|
500
|
-
}
|
|
501
|
-
if (method === "notifications/initialized") {
|
|
502
|
-
return new Response(null, { status: 202 });
|
|
503
|
-
}
|
|
504
|
-
if (method === "tools/list") {
|
|
505
|
-
return Response.json(jsonRpcResult(id, { tools: visibleTools(auth) }));
|
|
506
|
-
}
|
|
507
|
-
if (method === "tools/call") {
|
|
508
|
-
const result = await callTool(auth, request.params);
|
|
509
|
-
return Response.json(jsonRpcResult(id, result));
|
|
510
|
-
}
|
|
511
|
-
return Response.json(jsonRpcError(id, -32601, `unknown MCP method: ${method}`));
|
|
512
|
-
} catch (error) {
|
|
513
|
-
const message = errMessage(error);
|
|
514
|
-
const status = error instanceof McpAuthError ? 403 : error instanceof McpNotFoundError ? 404 : 400;
|
|
515
|
-
const code = error instanceof McpAuthError ? -32001 : error instanceof McpNotFoundError ? -32004 : -32602;
|
|
516
|
-
return Response.json(jsonRpcError(id, code, message, { status }));
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
async function parseJsonRpcRequest(req: Request): Promise<
|
|
521
|
-
| { ok: true; request: JsonRpcRequest }
|
|
522
|
-
| { ok: false; status: number; error: string }
|
|
523
|
-
> {
|
|
524
|
-
if (!req.body) return { ok: false, status: 400, error: "JSON-RPC body required" };
|
|
525
|
-
const maxBodyBytes = Math.max(MAX_BODY_BYTES, maxArtifactBytes() + MCP_BODY_OVERHEAD_BYTES);
|
|
526
|
-
const read = await readBodyBytes(req.body, maxBodyBytes);
|
|
527
|
-
if (!read.ok) return read;
|
|
528
|
-
try {
|
|
529
|
-
const body = JSON.parse(new TextDecoder().decode(read.bytes)) as unknown;
|
|
530
|
-
if (!isRecord(body)) return { ok: false, status: 400, error: "JSON-RPC body must be an object" };
|
|
531
|
-
return { ok: true, request: body as JsonRpcRequest };
|
|
532
|
-
} catch {
|
|
533
|
-
return { ok: false, status: 400, error: "invalid JSON-RPC body" };
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function mcpAuthContext(req: Request): McpAuthContext {
|
|
538
|
-
const component = getComponentAuth(req);
|
|
539
|
-
if (component) return { actor: component.sub, kind: "component", scopes: component.scope, component };
|
|
540
|
-
const integration = getIntegrationAuth(req);
|
|
541
|
-
if (integration) return { actor: integration.name, kind: "integration", scopes: integration.scopes, integration };
|
|
542
|
-
return { actor: "server", kind: "server", scopes: ["*"] };
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<string, unknown>> {
|
|
546
|
-
if (!isRecord(params)) throw new ValidationError("tools/call params must be an object");
|
|
547
|
-
const name = stringField(params.name, "name", { required: true });
|
|
548
|
-
const args = recordField(params.arguments ?? {}, "arguments");
|
|
549
|
-
const tool = TOOLS.find((item) => item.name === name);
|
|
550
|
-
if (!tool) throw new ValidationError(`unknown tool: ${name}`);
|
|
551
|
-
if (!hasAnyScope(auth, tool.requiredScopes)) {
|
|
552
|
-
auditToolCall(auth, name, "denied", undefined, `missing scope: ${tool.requiredScopes.join(" or ")}`);
|
|
553
|
-
throw new McpAuthError(`tool ${name} requires ${tool.requiredScopes.join(" or ")}`);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
try {
|
|
557
|
-
let result: unknown;
|
|
558
|
-
if (name === "relay_send_message") result = relaySendMessage(auth, args);
|
|
559
|
-
else if (name === "relay_reply") result = relayReply(auth, args);
|
|
560
|
-
else if (name === "relay_get_message") result = relayGetMessage(args);
|
|
561
|
-
else if (name === "relay_get_thread") result = relayGetThread(args);
|
|
562
|
-
else if (name === "relay_upload_artifact") result = await relayUploadArtifact(auth, args);
|
|
563
|
-
else if (name === "relay_attach_artifact") result = relayAttachArtifact(auth, args);
|
|
564
|
-
else if (name === "relay_agent_status") result = relayAgentStatus(args);
|
|
565
|
-
else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
|
|
566
|
-
else if (name === "relay_whoami") result = relayWhoami(auth);
|
|
567
|
-
else if (name === "relay_compact_and_resume") result = relayCompactAndResume(auth, args);
|
|
568
|
-
else if (name === "relay_recall") result = relayRecall(auth, args);
|
|
569
|
-
else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
|
|
570
|
-
else if (name === "relay_spawn_targets") result = relaySpawnTargets(auth);
|
|
571
|
-
else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
|
|
572
|
-
else if (name === "relay_agent_action") result = relayAgentAction(auth, args);
|
|
573
|
-
else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
|
|
574
|
-
else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
|
|
575
|
-
else if (name === "relay_workspace_ready") result = relayWorkspaceMutation(auth, "ready", args);
|
|
576
|
-
else if (name === "relay_workspace_deps") result = relayWorkspaceMutation(auth, "deps-refresh", args);
|
|
577
|
-
else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
|
|
578
|
-
else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
|
|
579
|
-
else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
|
|
580
|
-
else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
|
|
581
|
-
else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
|
|
582
|
-
else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
|
|
583
|
-
else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args); else if (name.startsWith("relay_issue_ref_")) result = relayIssueRefTool(auth, name, args); else if (name.startsWith("relay_issue_association_")) result = relayIssueAssociationTool(auth, name, args); else if (name.startsWith("relay_issue_activity_")) result = relayIssueActivityTool(auth, name, args); else if (name.startsWith("relay_issue_lifecycle_")) result = await relayIssueLifecycleTool(auth, name, args);
|
|
584
|
-
else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions") result = relayNotificationSubscriptionTool(auth, name, args);
|
|
585
|
-
else throw new ValidationError(`unknown tool: ${name}`);
|
|
586
|
-
|
|
587
|
-
auditToolCall(auth, name, "ok", result);
|
|
588
|
-
return toolResult(result);
|
|
589
|
-
} catch (error) {
|
|
590
|
-
const message = errMessage(error);
|
|
591
|
-
auditToolCall(auth, name, error instanceof McpAuthError ? "denied" : "error", undefined, message);
|
|
592
|
-
throw error;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// A managed/runtime MCP token is minted with `constraints.agents = [its own agent id]`
|
|
597
|
-
// (see issueMcpRuntimeToken). That single allowed agent IS the caller's identity, and the
|
|
598
|
-
// security layer already treats it as the only `from` this token may use. Derive it so
|
|
599
|
-
// agents never need to know — or type — their own id.
|
|
600
|
-
// THE caller-identity resolver for MCP: the agent id behind this token, for `from`-autofill,
|
|
601
|
-
// relay_whoami, and spawn/shutdown gating (#221, #243). Delegates to the shared
|
|
602
|
-
// `resolveCallerAgentId` (one home, src/agent-ref.ts) so the bus/HTTP/MCP transports resolve
|
|
603
|
-
// caller identity identically — managed agents (runner token carrying spawnRequestId/policy,
|
|
604
|
-
// no `agents` constraint) resolve back to their registered card; server/admin/multi-agent
|
|
605
|
-
// tokens return undefined. Keep `relay_whoami` + the spawn/shutdown gates on THIS, not a
|
|
606
|
-
// narrower check, or managed agents silently lose implicit identity again (the #243 drift).
|
|
607
|
-
// (The send/reply `from`-autofill now flows through the shared send service's `ctx.callerAgentId`,
|
|
608
|
-
// populated by `authContextFromMcp` from this same resolver — one home across all transports.)
|
|
609
|
-
function callerAgentId(auth: McpAuthContext): string | undefined {
|
|
610
|
-
if (auth.kind !== "component") return undefined;
|
|
611
|
-
return resolveCallerAgentId(auth.component?.constraints, listAgents);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
|
|
615
|
-
const agentId = callerAgentId(auth);
|
|
616
|
-
const agent = agentId ? getAgent(agentId) : null;
|
|
617
|
-
return {
|
|
618
|
-
agentId: agentId ?? null,
|
|
619
|
-
actor: auth.actor,
|
|
620
|
-
kind: auth.kind,
|
|
621
|
-
scopes: auth.scopes,
|
|
622
|
-
agent: agent ?? null,
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
function relayCompactAndResume(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
627
|
-
const agentId = callerAgentId(auth);
|
|
628
|
-
if (!agentId) throw new McpAuthError("relay_compact_and_resume requires a single caller agent identity");
|
|
629
|
-
return compactAndResume({
|
|
630
|
-
agentId,
|
|
631
|
-
workingState: stringField(args.workingState, "workingState", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
632
|
-
ruledOut: args.ruledOut,
|
|
633
|
-
mode: optionalEnum(args.mode, "mode", ["clear-inject", "native"] as const),
|
|
634
|
-
objective: optionalString(args.objective, "objective", 64_000),
|
|
635
|
-
}) as unknown as Record<string, unknown>;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
function relayRecall(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
639
|
-
const agentId = callerAgentId(auth);
|
|
640
|
-
if (!agentId) throw new McpAuthError("relay_recall requires a single caller agent identity");
|
|
641
|
-
const matches = searchContinuationArchives({
|
|
642
|
-
agentId,
|
|
643
|
-
query: stringField(args.query, "query", { required: true, max: 1000 }),
|
|
644
|
-
limit: optionalPositiveInt(args.limit, "limit"),
|
|
645
|
-
});
|
|
646
|
-
return {
|
|
647
|
-
query: stringField(args.query, "query", { required: true, max: 1000 }),
|
|
648
|
-
matches,
|
|
649
|
-
count: matches.length,
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// MCP transport adapter over the shared send service (epic #342). The service owns from-resolution
|
|
654
|
-
// (token identity via ctx.callerAgentId), reply routing, target resolution + the converged
|
|
655
|
-
// store-ahead policy, authorization, persistence, and emit. This adapter only builds the input,
|
|
656
|
-
// the AuthContext (carrying the integration token separately, per the lean-AuthContext contract),
|
|
657
|
-
// and maps the service's typed ServiceAuthError onto the MCP wire error (McpAuthError → 403).
|
|
658
|
-
function runMcpSend(auth: McpAuthContext, input: SendMessageInput): Message & { delivery: DeliveryReceipt } {
|
|
659
|
-
try {
|
|
660
|
-
const result = sendMessageService(
|
|
661
|
-
input,
|
|
662
|
-
authContextFromMcp({ kind: auth.kind, actor: auth.actor, scopes: auth.scopes, component: auth.component }),
|
|
663
|
-
{ integration: auth.integration },
|
|
664
|
-
);
|
|
665
|
-
return { ...result.message, delivery: result.receipt };
|
|
666
|
-
} catch (e) {
|
|
667
|
-
if (e instanceof ServiceAuthError) throw new McpAuthError(e.message);
|
|
668
|
-
throw e; // AmbiguousTargetError/ValidationError → invalid-params via the central JSON-RPC map.
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
|
|
673
|
-
const attachments = optionalAttachments(args.attachments);
|
|
674
|
-
const payload = payloadWithAttachments(optionalRecord(args.payload, "payload"), attachments);
|
|
675
|
-
const input: SendMessageInput = {
|
|
676
|
-
// `from` is resolved by the service from the token identity (callerAgentId wins); the wire
|
|
677
|
-
// value is only a fallback for identity-less tokens.
|
|
678
|
-
from: optionalString(args.from, "from", 200) ?? "",
|
|
679
|
-
to: stringField(args.to, "to", { required: true, max: 200 }),
|
|
680
|
-
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
681
|
-
subject: optionalString(args.subject, "subject", 200),
|
|
682
|
-
channel: optionalString(args.channel, "channel", 120),
|
|
683
|
-
claimable: optionalBoolean(args.claimable, "claimable"),
|
|
684
|
-
idempotencyKey: optionalString(args.idempotencyKey, "idempotencyKey", 240),
|
|
685
|
-
attachments,
|
|
686
|
-
payload,
|
|
687
|
-
meta: optionalRecord(args.meta, "meta"),
|
|
688
|
-
};
|
|
689
|
-
return runMcpSend(auth, input);
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
|
|
693
|
-
const messageId = positiveId(args.messageId, "messageId");
|
|
694
|
-
const parent = getMessage(messageId);
|
|
695
|
-
if (!parent) throw new McpNotFoundError(`message ${messageId} not found`);
|
|
696
|
-
const attachments = optionalAttachments(args.attachments);
|
|
697
|
-
const payload = optionalRecord(args.payload, "payload") ?? {};
|
|
698
|
-
const format = optionalEnum(args.format, "format", ["text", "markdown", "markdownv2"] as const);
|
|
699
|
-
const replyPayload = payloadWithAttachments({
|
|
700
|
-
...payload,
|
|
701
|
-
...(format ? { message: { ...(isRecord(payload.message) ? payload.message : {}), format } } : {}),
|
|
702
|
-
}, attachments);
|
|
703
|
-
const input: SendMessageInput = {
|
|
704
|
-
from: optionalString(args.from, "from", 200) ?? "",
|
|
705
|
-
// Empty `to` + replyTo lets the service auto-route to the parent's sender, inherit its
|
|
706
|
-
// channel, and propagate channel replyContext — identical to the HTTP reply path.
|
|
707
|
-
to: "",
|
|
708
|
-
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
709
|
-
subject: optionalString(args.subject, "subject", 200),
|
|
710
|
-
replyTo: parent.id,
|
|
711
|
-
attachments,
|
|
712
|
-
payload: replyPayload,
|
|
713
|
-
meta: optionalRecord(args.meta, "meta"),
|
|
714
|
-
};
|
|
715
|
-
return runMcpSend(auth, input);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function relayGetMessage(args: Record<string, unknown>): Record<string, unknown> {
|
|
719
|
-
const messageId = positiveId(args.messageId, "messageId");
|
|
720
|
-
const message = getMessage(messageId);
|
|
721
|
-
if (!message) throw new McpNotFoundError(`message ${messageId} not found`);
|
|
722
|
-
const includeArtifacts = args.includeArtifacts !== false;
|
|
723
|
-
const includeThread = args.includeThread !== false;
|
|
724
|
-
return {
|
|
725
|
-
message,
|
|
726
|
-
...(includeArtifacts ? { artifacts: listArtifactsForEntity("message", message.id) } : {}),
|
|
727
|
-
...(includeThread ? { thread: getThread(message.id) } : {}),
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
function relayGetThread(args: Record<string, unknown>): Record<string, unknown> {
|
|
732
|
-
const messageId = positiveId(args.messageId, "messageId");
|
|
733
|
-
const thread = getThread(messageId);
|
|
734
|
-
if (!thread.length) throw new McpNotFoundError(`message ${messageId} not found`);
|
|
735
|
-
return { thread };
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
async function relayUploadArtifact(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
739
|
-
const bytes = artifactBytes(args);
|
|
740
|
-
const maxBytes = maxArtifactBytes();
|
|
741
|
-
if (bytes.byteLength > maxBytes) throw new ValidationError(`artifact exceeds ${maxBytes} bytes`);
|
|
742
|
-
const filename = optionalString(args.filename, "filename", 240);
|
|
743
|
-
const hintedMediaType = optionalString(args.mediaType, "mediaType", 160);
|
|
744
|
-
const mediaType = sniffMediaType(bytes, hintedMediaType, filename);
|
|
745
|
-
const storage = getArtifactStorage();
|
|
746
|
-
const digest = optionalString(args.digest, "digest", 80);
|
|
747
|
-
const stored = await storage.store(bytesToStream(bytes), {
|
|
748
|
-
mediaType,
|
|
749
|
-
size: bytes.byteLength,
|
|
750
|
-
digest: digest ? normalizeDigest(digest) : undefined,
|
|
751
|
-
});
|
|
752
|
-
upsertArtifactBlob({
|
|
753
|
-
digest: stored.digest,
|
|
754
|
-
storageUri: stored.storageUri,
|
|
755
|
-
mediaType,
|
|
756
|
-
size: stored.size,
|
|
757
|
-
});
|
|
758
|
-
const artifact = createArtifact({
|
|
759
|
-
blobDigest: stored.digest,
|
|
760
|
-
mediaType,
|
|
761
|
-
kind: (optionalEnum(args.kind, "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined) ?? inferArtifactKind(mediaType, filename),
|
|
762
|
-
filename,
|
|
763
|
-
size: stored.size,
|
|
764
|
-
sensitivity: optionalEnum(args.sensitivity, "sensitivity", VALID_ARTIFACT_SENSITIVITIES) as ArtifactSensitivity | undefined,
|
|
765
|
-
createdBy: auth.actor,
|
|
766
|
-
expiresAt: optionalFutureTimestamp(args.expiresAt, "expiresAt"),
|
|
767
|
-
metadata: optionalRecord(args.metadata, "metadata"),
|
|
768
|
-
});
|
|
769
|
-
emitRelayEvent({ type: "artifact.created", source: "mcp", subject: artifact.id, data: { artifact, actor: auth.actor } });
|
|
770
|
-
return {
|
|
771
|
-
artifact,
|
|
772
|
-
attachment: {
|
|
773
|
-
artifactId: artifact.id,
|
|
774
|
-
kind: artifact.kind,
|
|
775
|
-
role: optionalEnum(args.role, "role", VALID_ARTIFACT_ROLES),
|
|
776
|
-
title: optionalString(args.title, "title", 240) ?? artifact.filename,
|
|
777
|
-
},
|
|
778
|
-
};
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
function relayAttachArtifact(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
782
|
-
const artifactId = stringField(args.artifactId, "artifactId", { required: true, max: 120 });
|
|
783
|
-
const artifact = getArtifact(artifactId);
|
|
784
|
-
if (!artifact) throw new McpNotFoundError(`artifact ${artifactId} not found`);
|
|
785
|
-
const entityType = enumField(args.entityType, "entityType", VALID_ARTIFACT_ENTITY_TYPES);
|
|
786
|
-
const rawEntityId = args.entityId;
|
|
787
|
-
if (typeof rawEntityId !== "string" && typeof rawEntityId !== "number") throw new ValidationError("entityId must be a string or integer");
|
|
788
|
-
const entityId = typeof rawEntityId === "number" ? positiveId(rawEntityId, "entityId") : stringField(rawEntityId, "entityId", { required: true, max: 200 });
|
|
789
|
-
assertArtifactEntityExists(entityType, entityId);
|
|
790
|
-
const link = linkArtifact({
|
|
791
|
-
artifactId,
|
|
792
|
-
entityType,
|
|
793
|
-
entityId,
|
|
794
|
-
role: optionalEnum(args.role, "role", VALID_ARTIFACT_ROLES),
|
|
795
|
-
title: optionalString(args.title, "title", 240),
|
|
796
|
-
createdBy: auth.actor,
|
|
797
|
-
});
|
|
798
|
-
return { artifact: getArtifact(artifactId) ?? artifact, link };
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
function relayAgentStatus(args: Record<string, unknown>): Record<string, unknown> {
|
|
802
|
-
const agentId = optionalString(args.agentId, "agentId", 240);
|
|
803
|
-
if (agentId) {
|
|
804
|
-
const agent = getAgent(agentId);
|
|
805
|
-
if (!agent) throw new McpNotFoundError(`agent ${agentId} not found`);
|
|
806
|
-
return { agent };
|
|
807
|
-
}
|
|
808
|
-
const policyName = optionalString(args.policyName, "policyName", 120);
|
|
809
|
-
if (policyName) return policyStatus(policyName);
|
|
810
|
-
const orchestratorId = optionalString(args.orchestratorId, "orchestratorId", 200);
|
|
811
|
-
if (orchestratorId) {
|
|
812
|
-
const orchestrator = getOrchestrator(orchestratorId);
|
|
813
|
-
if (!orchestrator) throw new McpNotFoundError(`orchestrator ${orchestratorId} not found`);
|
|
814
|
-
return { orchestrator };
|
|
815
|
-
}
|
|
816
|
-
// Default to running agents only — offline/stale rows are pure noise (you can only
|
|
817
|
-
// message/reply/pair with a live agent) and bloat the payload. includeOffline opts back in.
|
|
818
|
-
const includeOffline = optionalBoolean(args.includeOffline, "includeOffline") === true;
|
|
819
|
-
return {
|
|
820
|
-
agents: searchAgents({ status: includeOffline ? "all" : "running" }),
|
|
821
|
-
spawnPolicies: listSpawnPolicies().map((entry) => policyStatusPayload(entry.value)),
|
|
822
|
-
orchestrators: listOrchestrators(),
|
|
823
|
-
};
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
function optionalStringOrArray(value: unknown, field: string): string | string[] | undefined {
|
|
827
|
-
if (value === undefined || value === null) return undefined;
|
|
828
|
-
if (typeof value === "string") return value;
|
|
829
|
-
if (Array.isArray(value)) {
|
|
830
|
-
return value.map((item, i) => stringField(item, `${field}[${i}]`, { required: true, max: 200 }));
|
|
831
|
-
}
|
|
832
|
-
throw new ValidationError(`${field} must be a string or string array`);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
836
|
-
// `spawnedBy: "me"` resolves to the caller's own id — list the children you spawned (#221).
|
|
837
|
-
const spawnedByArg = optionalString(args.spawnedBy, "spawnedBy", 240);
|
|
838
|
-
const spawnedBy = spawnedByArg === "me" ? callerAgentId(auth) ?? "\0unresolved" : spawnedByArg;
|
|
839
|
-
const filter: AgentSearchFilter = {
|
|
840
|
-
label: optionalString(args.label, "label", 200),
|
|
841
|
-
capability: optionalStringOrArray(args.capability, "capability"),
|
|
842
|
-
provider: optionalStringOrArray(args.provider, "provider"),
|
|
843
|
-
tag: optionalStringOrArray(args.tag, "tag"),
|
|
844
|
-
machine: optionalStringOrArray(args.machine, "machine"),
|
|
845
|
-
kind: optionalStringOrArray(args.kind, "kind"),
|
|
846
|
-
spawnedBy,
|
|
847
|
-
teamId: optionalString(args.teamId, "teamId", 120),
|
|
848
|
-
// Default to running — you can only act on a live agent (consistent with #218).
|
|
849
|
-
status: optionalStringOrArray(args.status, "status") ?? "running",
|
|
850
|
-
};
|
|
851
|
-
const sort: AgentSearchSort = {
|
|
852
|
-
sortBy: optionalEnum(args.sortBy, "sortBy", ["lastActive", "created", "label"] as const),
|
|
853
|
-
order: optionalEnum(args.order, "order", ["asc", "desc"] as const),
|
|
854
|
-
limit: optionalPositiveInt(args.limit, "limit"),
|
|
855
|
-
};
|
|
856
|
-
const agents = searchAgents(filter, sort);
|
|
857
|
-
return { agents, count: agents.length };
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
861
|
-
// Thin transport: parse wire → build AuthContext → call the spawnAgent service → serialize.
|
|
862
|
-
// ALL policy + side effects (caller-context inheritance, the #221 no-grandchild + quota gate,
|
|
863
|
-
// the #323 resource gate, the command:spawn scope assert, authoritative lineage stamping, the
|
|
864
|
-
// command payload + emit + audit) live in src/services/spawn-agent.ts so the MCP and HTTP spawn
|
|
865
|
-
// paths cannot drift (epic #342). `waitForRegistrationMs` defaults to a short wait here because
|
|
866
|
-
// isolated-worktree spawns register near-instantly (symlinked deps); 0 opts out.
|
|
867
|
-
const input: SpawnAgentInput = {
|
|
868
|
-
provider: enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
|
|
869
|
-
orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
|
|
870
|
-
cwd: optionalString(args.cwd, "cwd", 500),
|
|
871
|
-
model: optionalString(args.model, "model", 120),
|
|
872
|
-
effort: optionalString(args.effort, "effort", 120) as ProviderEffort | undefined,
|
|
873
|
-
approvalMode: optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
|
|
874
|
-
workspaceMode: optionalEnum(args.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
|
|
875
|
-
lifecycle: (optionalEnum(args.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined) ?? "persistent",
|
|
876
|
-
label: optionalString(args.label, "label", 120),
|
|
877
|
-
policyName: optionalString(args.policyName, "policyName", 120),
|
|
878
|
-
profile: optionalString(args.profile, "profile", 120),
|
|
879
|
-
prompt: optionalString(args.prompt, "prompt", 16_000),
|
|
880
|
-
expectReply: typeof args.expectReply === "boolean" ? args.expectReply : undefined,
|
|
881
|
-
systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
|
|
882
|
-
projectInstructions: parseProjectInstructions(args.projectInstructions, (m) => new ValidationError(m)),
|
|
883
|
-
tags: optionalStringArray(args.tags, "tags"),
|
|
884
|
-
capabilities: optionalStringArray(args.capabilities, "capabilities"),
|
|
885
|
-
providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
|
|
886
|
-
spawnRequestId: optionalString(args.spawnRequestId, "spawnRequestId", 160),
|
|
887
|
-
requestedVia: "mcp",
|
|
888
|
-
waitForRegistrationMs: optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 8000,
|
|
889
|
-
};
|
|
890
|
-
const result = await spawnAgent(input, authContextFromMcp(auth));
|
|
891
|
-
return { ...result };
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
895
|
-
const agentId = optionalString(args.agentId, "agentId", 240);
|
|
896
|
-
const policyName = optionalString(args.policyName, "policyName", 120);
|
|
897
|
-
const spawnRequestId = optionalString(args.spawnRequestId, "spawnRequestId", 160);
|
|
898
|
-
const tmuxSession = optionalString(args.tmuxSession, "tmuxSession", 200);
|
|
899
|
-
if (!agentId && !policyName && !spawnRequestId && !tmuxSession) {
|
|
900
|
-
throw new ValidationError("agentId, policyName, spawnRequestId, or tmuxSession required");
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// The #221 parent→child gate, control-orchestrator resolution, and the canonical
|
|
904
|
-
// agent.shutdown payload + side-effects all live in the shutdownAgent service now —
|
|
905
|
-
// shared byte-for-byte with the HTTP route and the bus command frame (epic #342, #347).
|
|
906
|
-
// Map the service's transport-neutral errors back to MCP's JSON-RPC error codes.
|
|
907
|
-
try {
|
|
908
|
-
const result = shutdownAgent(
|
|
909
|
-
{
|
|
910
|
-
agentId,
|
|
911
|
-
policyName,
|
|
912
|
-
spawnRequestId,
|
|
913
|
-
tmuxSession,
|
|
914
|
-
orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
|
|
915
|
-
graceful: true,
|
|
916
|
-
timeoutMs: optionalPositiveInt(args.timeoutMs, "timeoutMs") ?? 10_000,
|
|
917
|
-
reason: optionalString(args.reason, "reason", 200) ?? "mcp-shutdown",
|
|
918
|
-
requestedVia: "mcp",
|
|
919
|
-
requestedBy: auth.actor,
|
|
920
|
-
},
|
|
921
|
-
authContextFromMcp(auth),
|
|
922
|
-
);
|
|
923
|
-
return { ok: true, action: "shutdown", orchestratorId: result.orchestratorId, command: result.command };
|
|
924
|
-
} catch (e) {
|
|
925
|
-
if (e instanceof ShutdownAuthError) throw new McpAuthError(e.message);
|
|
926
|
-
if (e instanceof ShutdownTargetError) throw new McpNotFoundError(e.message);
|
|
927
|
-
throw e;
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
// --- Workspace lifecycle tools (#215) -------------------------------------
|
|
932
|
-
// Thin MCP surface over the shared applyWorkspaceAction core. Coarse scope is
|
|
933
|
-
// enforced in callTool; here we add the resource check the routes' authorizeRoute
|
|
934
|
-
// does: an agent may only act on a workspace it OWNS or stewards (server/wildcard
|
|
935
|
-
// tokens keep full reach). Defaults the target to the caller's own workspace so a
|
|
936
|
-
// branch agent never has to hunt for its workspace id.
|
|
937
|
-
|
|
938
|
-
function isWorkspaceAdmin(auth: McpAuthContext): boolean {
|
|
939
|
-
return auth.kind === "server" || hasScope(auth, "*");
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
function assertWorkspaceAccess(auth: McpAuthContext, ws: WorkspaceRecord, caller: string | undefined): void {
|
|
943
|
-
if (isWorkspaceAdmin(auth)) return;
|
|
944
|
-
if (!caller) throw new McpAuthError(`not authorized for workspace ${ws.id} (its owner or assigned steward only)`);
|
|
945
|
-
if (caller === ws.ownerAgentId) return;
|
|
946
|
-
// The repo's elected steward (authoritative repo_stewards record, not the mirrored
|
|
947
|
-
// workspace column which only updates on steward change) may reconcile any
|
|
948
|
-
// workspace in its repo — the conflict-landing flow it exists for.
|
|
949
|
-
if (caller === getRepoSteward(ws.repoRoot)?.stewardAgentId) return;
|
|
950
|
-
throw new McpAuthError(`not authorized for workspace ${ws.id} (its owner or assigned steward only)`);
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
function resolveWorkspaceForCaller(auth: McpAuthContext, args: Record<string, unknown>): WorkspaceRecord {
|
|
954
|
-
const explicitId = optionalString(args.workspaceId, "workspaceId", 240);
|
|
955
|
-
const caller = callerAgentId(auth);
|
|
956
|
-
if (explicitId) {
|
|
957
|
-
const ws = getWorkspace(explicitId);
|
|
958
|
-
if (!ws) throw new McpNotFoundError(`workspace ${explicitId} not found`);
|
|
959
|
-
assertWorkspaceAccess(auth, ws, caller);
|
|
960
|
-
return ws;
|
|
961
|
-
}
|
|
962
|
-
if (!caller) throw new ValidationError("workspaceId is required: no caller agent identity to resolve your own workspace");
|
|
963
|
-
const first = callerIsolatedWorkspace(auth);
|
|
964
|
-
if (!first) throw new McpNotFoundError("you don't own an isolated workspace; pass workspaceId");
|
|
965
|
-
return first;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
// Non-throwing variant for the initialize primer: the caller's most recently
|
|
969
|
-
// active isolated worktree, or undefined (no caller identity / shared-only).
|
|
970
|
-
// listWorkspaces is ORDER BY updated_at DESC → most recent worktree first.
|
|
971
|
-
function callerIsolatedWorkspace(auth: McpAuthContext): WorkspaceRecord | undefined {
|
|
972
|
-
const caller = callerAgentId(auth);
|
|
973
|
-
if (!caller) return undefined;
|
|
974
|
-
return ownedIsolatedWorkspace(caller);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
async function relayWorkspaceStatus(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
978
|
-
const ws = resolveWorkspaceForCaller(auth, args);
|
|
979
|
-
if (optionalBoolean(args.wait, "wait") !== true) {
|
|
980
|
-
return { workspace: ws, guidance: describeWorkspacePhase(ws), transitioned: false, timedOut: false };
|
|
981
|
-
}
|
|
982
|
-
const timeoutSeconds = optionalPositiveInt(args.timeoutSeconds, "timeoutSeconds");
|
|
983
|
-
const result = await waitForWorkspaceStatus(ws.id, timeoutSeconds ? { timeoutMs: timeoutSeconds * 1000 } : {});
|
|
984
|
-
if (!result.workspace) throw new McpNotFoundError(`workspace ${ws.id} not found`);
|
|
985
|
-
const landed = result.transitioned ? landReceipt(result.fromStatus, result.workspace) : null;
|
|
986
|
-
return {
|
|
987
|
-
workspace: result.workspace,
|
|
988
|
-
guidance: describeWorkspacePhase(result.workspace),
|
|
989
|
-
...(landed ? { landed } : {}),
|
|
990
|
-
fromStatus: result.fromStatus,
|
|
991
|
-
transitioned: result.transitioned,
|
|
992
|
-
timedOut: result.timedOut,
|
|
993
|
-
};
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
function relayWorkspaceList(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
997
|
-
const filter: { ownerAgentId?: string; repoRoot?: string } = {};
|
|
998
|
-
const repoRoot = optionalString(args.repoRoot, "repoRoot", 1024);
|
|
999
|
-
if (repoRoot) filter.repoRoot = repoRoot;
|
|
1000
|
-
if (isWorkspaceAdmin(auth) && optionalBoolean(args.all, "all") === true) {
|
|
1001
|
-
const owner = optionalString(args.ownerAgentId, "ownerAgentId", 240);
|
|
1002
|
-
if (owner) filter.ownerAgentId = owner;
|
|
1003
|
-
} else {
|
|
1004
|
-
const caller = callerAgentId(auth);
|
|
1005
|
-
if (!caller) throw new ValidationError("no caller agent identity to list your workspaces");
|
|
1006
|
-
filter.ownerAgentId = caller;
|
|
1007
|
-
}
|
|
1008
|
-
const workspaces = listWorkspaces(filter);
|
|
1009
|
-
return { workspaces, count: workspaces.length };
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, args: Record<string, unknown>): Record<string, unknown> {
|
|
1013
|
-
const ws = resolveWorkspaceForCaller(auth, args);
|
|
1014
|
-
const result = applyWorkspaceAction(ws, {
|
|
1015
|
-
action,
|
|
1016
|
-
agentId: callerAgentId(auth) ?? auth.actor,
|
|
1017
|
-
detail: optionalString(args.detail, "detail", 4000),
|
|
1018
|
-
strategy: action === "merge" ? (optionalEnum(args.strategy, "strategy", LAND_STRATEGIES) as WorkspaceMergeStrategy | undefined) : undefined,
|
|
1019
|
-
deleteBranch: action === "merge" ? optionalBoolean(args.deleteBranch, "deleteBranch") : undefined,
|
|
1020
|
-
prTitle: optionalString(args.prTitle, "prTitle", 240),
|
|
1021
|
-
prBody: optionalString(args.prBody, "prBody", 8000),
|
|
1022
|
-
autoMerge: action === "merge" ? (optionalEnum(args.autoMerge, "autoMerge", AUTO_MERGE_POLICIES) as WorkspaceAutoMergePolicy | undefined) : undefined,
|
|
1023
|
-
reviewer: action === "merge" ? optionalString(args.reviewer, "reviewer", 240) : undefined,
|
|
1024
|
-
purpose: optionalString(args.purpose, "purpose", 120),
|
|
1025
|
-
checkOnly: action === "deps-refresh" ? optionalBoolean(args.checkOnly, "checkOnly") === true : undefined,
|
|
1026
|
-
auditMetadata: { via: "mcp", actor: auth.actor },
|
|
1027
|
-
});
|
|
1028
|
-
if (!result.ok) {
|
|
1029
|
-
if (result.httpStatus === 404) throw new McpNotFoundError(result.error);
|
|
1030
|
-
if (result.httpStatus === 403) throw new McpAuthError(result.error);
|
|
1031
|
-
throw new ValidationError(result.error);
|
|
1032
|
-
}
|
|
1033
|
-
if (result.command) emitCommand(result.command);
|
|
1034
|
-
const payload: Record<string, unknown> = { workspace: result.workspace };
|
|
1035
|
-
if (result.command) payload.command = result.command;
|
|
1036
|
-
if (result.claim !== undefined) payload.claim = result.claim;
|
|
1037
|
-
// After a `ready` hand-off, state the whole contract up front + the directive
|
|
1038
|
-
// next-step, so the agent doesn't decode status enums over the next minutes (#235).
|
|
1039
|
-
if (action === "ready") {
|
|
1040
|
-
payload.contract = readyContract(result.workspace);
|
|
1041
|
-
payload.guidance = describeWorkspacePhase(result.workspace);
|
|
1042
|
-
}
|
|
1043
|
-
return payload;
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
function payloadWithAttachments(
|
|
1047
|
-
payload: Record<string, unknown> | undefined,
|
|
1048
|
-
attachments: AttachmentRef[] | undefined,
|
|
1049
|
-
): Record<string, unknown> | undefined {
|
|
1050
|
-
if (!attachments) return payload;
|
|
1051
|
-
if (payload?.attachments !== undefined) throw new ValidationError("attachments must be provided either top-level or in payload.attachments, not both");
|
|
1052
|
-
return { ...(payload ?? {}), attachments };
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
function emitCommand(command: Command): void {
|
|
1056
|
-
emitRelayEvent({
|
|
1057
|
-
type: command.status === "pending" ? "command.requested" : `command.${command.status}`,
|
|
1058
|
-
source: command.source,
|
|
1059
|
-
subject: command.id,
|
|
1060
|
-
data: { command },
|
|
1061
|
-
});
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
function policyStatus(policyName: string): Record<string, unknown> {
|
|
1065
|
-
const entry = getSpawnPolicy(policyName);
|
|
1066
|
-
if (!entry) throw new McpNotFoundError(`spawn policy ${policyName} not found`);
|
|
1067
|
-
return policyStatusPayload(entry.value);
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
function policyStatusPayload(policy: NonNullable<ReturnType<typeof getSpawnPolicy>>["value"]): Record<string, unknown> {
|
|
1071
|
-
return {
|
|
1072
|
-
policy,
|
|
1073
|
-
state: getManagedAgentState(policy.name) ?? {
|
|
1074
|
-
policyName: policy.name,
|
|
1075
|
-
status: "stopped",
|
|
1076
|
-
orchestratorId: policy.orchestratorId,
|
|
1077
|
-
provider: policy.provider,
|
|
1078
|
-
restartCount: 0,
|
|
1079
|
-
consecutiveFailures: 0,
|
|
1080
|
-
updatedAt: 0,
|
|
1081
|
-
},
|
|
1082
|
-
};
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
// #308 — thin auth wrapper over the spawn capability matrix (src/spawn-targets.ts owns the build).
|
|
1086
|
-
function relaySpawnTargets(auth: McpAuthContext): Record<string, unknown> {
|
|
1087
|
-
return buildSpawnTargets({
|
|
1088
|
-
callerId: callerAgentId(auth),
|
|
1089
|
-
quota: auth.component?.constraints?.maxSpawnedAgents ?? 0,
|
|
1090
|
-
});
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
function artifactBytes(args: Record<string, unknown>): Uint8Array {
|
|
1094
|
-
const hasContent = args.content !== undefined && args.content !== null;
|
|
1095
|
-
const hasBase64 = args.base64 !== undefined && args.base64 !== null;
|
|
1096
|
-
if (hasContent === hasBase64) throw new ValidationError("provide exactly one of content or base64");
|
|
1097
|
-
if (hasContent) {
|
|
1098
|
-
if (typeof args.content !== "string" || args.content.length === 0) throw new ValidationError("content required");
|
|
1099
|
-
return new TextEncoder().encode(args.content);
|
|
1100
|
-
}
|
|
1101
|
-
const encoded = stringField(args.base64, "base64", { required: true }).replace(/\s+/g, "");
|
|
1102
|
-
if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) throw new ValidationError("base64 must be valid base64 content");
|
|
1103
|
-
return new Uint8Array(Buffer.from(encoded, "base64"));
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
function assertArtifactEntityExists(entityType: string, entityId: string | number): void {
|
|
1107
|
-
if (entityType === "message" && !getMessage(Number(entityId))) throw new McpNotFoundError(`message ${entityId} not found`);
|
|
1108
|
-
if (entityType === "task" && !getTask(Number(entityId))) throw new McpNotFoundError(`task ${entityId} not found`);
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
function inferArtifactKind(mediaType: string, filename?: string): ArtifactKind {
|
|
1112
|
-
const lowerName = filename?.toLowerCase() ?? "";
|
|
1113
|
-
if (mediaType.startsWith("image/")) return "image";
|
|
1114
|
-
if (mediaType.startsWith("audio/")) return "audio";
|
|
1115
|
-
if (mediaType.startsWith("video/")) return "video";
|
|
1116
|
-
if (["application/zip", "application/gzip", "application/x-tar"].includes(mediaType) || /\.(zip|tgz|tar|gz)$/.test(lowerName)) return "archive";
|
|
1117
|
-
if (
|
|
1118
|
-
mediaType.startsWith("text/") ||
|
|
1119
|
-
["application/pdf", "application/json", "application/xml"].includes(mediaType) ||
|
|
1120
|
-
/\.(pdf|txt|md|json|csv|diff|patch)$/.test(lowerName)
|
|
1121
|
-
) return "document";
|
|
1122
|
-
return "other";
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
function sniffMediaType(bytes: Uint8Array, hinted?: string, filename?: string): string {
|
|
1126
|
-
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
1127
|
-
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
|
|
1128
|
-
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "image/gif";
|
|
1129
|
-
if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) return "application/pdf";
|
|
1130
|
-
if (bytes[0] === 0x50 && bytes[1] === 0x4b) return "application/zip";
|
|
1131
|
-
const lower = filename?.toLowerCase() ?? "";
|
|
1132
|
-
if (lower.endsWith(".svg")) return "image/svg+xml";
|
|
1133
|
-
if (lower.endsWith(".md")) return "text/markdown";
|
|
1134
|
-
if (lower.endsWith(".json")) return "application/json";
|
|
1135
|
-
if (lower.endsWith(".csv")) return "text/csv";
|
|
1136
|
-
if (lower.endsWith(".diff") || lower.endsWith(".patch")) return "text/x-diff";
|
|
1137
|
-
return hinted || "text/plain";
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
function visibleTools(auth: McpAuthContext): Array<Record<string, unknown>> {
|
|
1141
|
-
return TOOLS
|
|
1142
|
-
.filter((tool) => hasAnyScope(auth, tool.requiredScopes))
|
|
1143
|
-
.map(({ name, description, alwaysLoad, ...tool }) => ({
|
|
1144
|
-
name,
|
|
1145
|
-
description,
|
|
1146
|
-
inputSchema: providerCatalogToolInputSchema(name, tool.inputSchema),
|
|
1147
|
-
...(alwaysLoad ? { _meta: { "anthropic/alwaysLoad": true } } : {}),
|
|
1148
|
-
}));
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
function toolResult(result: unknown): Record<string, unknown> {
|
|
1152
|
-
return {
|
|
1153
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1154
|
-
structuredContent: result,
|
|
1155
|
-
};
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
function jsonRpcResult(id: JsonRpcId, result: unknown): Record<string, unknown> {
|
|
1159
|
-
return { jsonrpc: "2.0", id, result };
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
function jsonRpcError(id: JsonRpcId, code: number, message: string, data?: unknown): Record<string, unknown> {
|
|
1163
|
-
return { jsonrpc: "2.0", id, error: { code, message, ...(data ? { data } : {}) } };
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
function assertComponentResourceAllowed(
|
|
1167
|
-
auth: McpAuthContext,
|
|
1168
|
-
check: Parameters<typeof isComponentAuthorizedFor>[1],
|
|
1169
|
-
): void {
|
|
1170
|
-
if (auth.component && !isComponentAuthorizedFor(auth.component, check)) {
|
|
1171
|
-
throw new McpAuthError("component token cannot access this resource");
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
function hasAnyScope(auth: McpAuthContext, required: string[]): boolean {
|
|
1176
|
-
if (auth.scopes.includes("*") || auth.scopes.includes("admin:*")) return true;
|
|
1177
|
-
return required.some((scope) => hasScope(auth, scope));
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
function hasScope(auth: McpAuthContext, scope: string): boolean {
|
|
1181
|
-
if (auth.kind === "component") {
|
|
1182
|
-
return hasComponentScope({ sub: auth.actor, role: "provider", scope: auth.scopes, iat: 0 }, scope);
|
|
1183
|
-
}
|
|
1184
|
-
if (auth.kind === "integration" && auth.integration) {
|
|
1185
|
-
if (hasIntegrationScope(auth.integration, scope)) return true;
|
|
1186
|
-
const [category] = scope.split(":");
|
|
1187
|
-
return Boolean(category && hasIntegrationScope(auth.integration, `${category}:*`));
|
|
1188
|
-
}
|
|
1189
|
-
const [category] = scope.split(":");
|
|
1190
|
-
return auth.scopes.includes(scope) || Boolean(category && auth.scopes.includes(`${category}:*`));
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
function auditToolCall(
|
|
1194
|
-
auth: McpAuthContext,
|
|
1195
|
-
tool: string,
|
|
1196
|
-
status: "ok" | "denied" | "error",
|
|
1197
|
-
result?: unknown,
|
|
1198
|
-
error?: string,
|
|
1199
|
-
): void {
|
|
1200
|
-
try {
|
|
1201
|
-
const messageId = isRecord(result) && isRecord(result.message)
|
|
1202
|
-
? positiveIdOrUndefined(result.message.id)
|
|
1203
|
-
: isRecord(result) ? positiveIdOrUndefined(result.id) : undefined;
|
|
1204
|
-
const kind: ActivityKind = tool.includes("reply") ? "reply" : tool.includes("send") ? "message" : "state";
|
|
1205
|
-
createActivityEvent({
|
|
1206
|
-
kind,
|
|
1207
|
-
title: `MCP ${tool}`,
|
|
1208
|
-
body: error ?? status,
|
|
1209
|
-
meta: auth.actor,
|
|
1210
|
-
icon: status === "ok" ? "ti-plug-connected" : status === "denied" ? "ti-shield-x" : "ti-alert-triangle",
|
|
1211
|
-
view: "messages",
|
|
1212
|
-
messageId,
|
|
1213
|
-
agentId: auth.actor,
|
|
1214
|
-
metadata: { source: "mcp", tool, actor: auth.actor, authKind: auth.kind, status },
|
|
1215
|
-
});
|
|
1216
|
-
} catch {
|
|
1217
|
-
// Audit writes must not block tool calls.
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
function recordField(value: unknown, field: string): Record<string, unknown> {
|
|
1222
|
-
if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
|
|
1223
|
-
return value;
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
function optionalRecord(value: unknown, field: string): Record<string, unknown> | undefined {
|
|
1227
|
-
if (value === undefined || value === null) return undefined;
|
|
1228
|
-
return recordField(value, field);
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
function stringField(
|
|
1232
|
-
value: unknown,
|
|
1233
|
-
field: string,
|
|
1234
|
-
opts: { required?: boolean; max?: number; maxBytes?: number } = {},
|
|
1235
|
-
): string {
|
|
1236
|
-
if (value === undefined || value === null) {
|
|
1237
|
-
if (opts.required) throw new ValidationError(`${field} required`);
|
|
1238
|
-
return "";
|
|
1239
|
-
}
|
|
1240
|
-
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
|
|
1241
|
-
const trimmed = value.trim();
|
|
1242
|
-
if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
|
|
1243
|
-
if (opts.max && trimmed.length > opts.max) throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
|
|
1244
|
-
if (opts.maxBytes && encodedLength(trimmed) > opts.maxBytes) throw new ValidationError(`${field} exceeds ${opts.maxBytes} bytes`);
|
|
1245
|
-
return trimmed;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
function optionalString(value: unknown, field: string, max: number): string | undefined {
|
|
1249
|
-
if (value === undefined || value === null) return undefined;
|
|
1250
|
-
const cleaned = stringField(value, field, { max });
|
|
1251
|
-
return cleaned || undefined;
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
function optionalBoolean(value: unknown, field: string): boolean | undefined {
|
|
1255
|
-
if (value === undefined || value === null) return undefined;
|
|
1256
|
-
if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
|
|
1257
|
-
return value;
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
function enumField<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] {
|
|
1261
|
-
const cleaned = optionalEnum(value, field, valid);
|
|
1262
|
-
if (!cleaned) throw new ValidationError(`${field} required`);
|
|
1263
|
-
return cleaned;
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
function optionalStringArray(value: unknown, field: string): string[] | undefined {
|
|
1267
|
-
if (value === undefined || value === null) return undefined;
|
|
1268
|
-
if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
|
|
1269
|
-
const cleaned = value.map((item, index) => stringField(item, `${field}[${index}]`, { required: true, max: 120 }));
|
|
1270
|
-
if (cleaned.length > 50) throw new ValidationError(`${field} can contain at most 50 values`);
|
|
1271
|
-
return [...new Set(cleaned)];
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
function positiveId(value: unknown, field: string): number {
|
|
1275
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
|
|
1276
|
-
return value;
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
function positiveIdOrUndefined(value: unknown): number | undefined {
|
|
1280
|
-
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
function optionalPositiveInt(value: unknown, field: string): number | undefined {
|
|
1284
|
-
if (value === undefined || value === null) return undefined;
|
|
1285
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
|
|
1286
|
-
return value;
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
function optionalNonNegativeInt(value: unknown, field: string): number | undefined {
|
|
1290
|
-
if (value === undefined || value === null) return undefined;
|
|
1291
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new ValidationError(`${field} must be a non-negative integer`);
|
|
1292
|
-
return value;
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
function optionalFutureTimestamp(value: unknown, field: string): number | undefined {
|
|
1296
|
-
const timestamp = optionalPositiveInt(value, field);
|
|
1297
|
-
if (timestamp !== undefined && timestamp <= Date.now()) throw new ValidationError(`${field} must be a future unix timestamp in milliseconds`);
|
|
1298
|
-
return timestamp;
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
function optionalAttachments(value: unknown): AttachmentRef[] | undefined {
|
|
1302
|
-
if (value === undefined || value === null) return undefined;
|
|
1303
|
-
if (!Array.isArray(value)) throw new ValidationError("attachments must be an array");
|
|
1304
|
-
return value.map((item, index) => {
|
|
1305
|
-
const record = recordField(item, `attachments[${index}]`);
|
|
1306
|
-
return {
|
|
1307
|
-
artifactId: stringField(record.artifactId, `attachments[${index}].artifactId`, { required: true, max: 120 }),
|
|
1308
|
-
kind: optionalString(record.kind, `attachments[${index}].kind`, 40) as AttachmentRef["kind"],
|
|
1309
|
-
role: optionalString(record.role, `attachments[${index}].role`, 40) as AttachmentRef["role"],
|
|
1310
|
-
title: optionalString(record.title, `attachments[${index}].title`, 240),
|
|
1311
|
-
ref: optionalRecord(record.ref, `attachments[${index}].ref`) as AttachmentRef["ref"],
|
|
1312
|
-
metadata: optionalRecord(record.metadata, `attachments[${index}].metadata`),
|
|
1313
|
-
};
|
|
1314
|
-
});
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
function encodedLength(value: string): number {
|
|
1318
|
-
return new TextEncoder().encode(value).byteLength;
|
|
1319
|
-
}
|
|
1
|
+
export * from "./mcp/index.js";
|