Skip to content

Commit 09c8165

Browse files
committed
feat(mcp): add cloud runtime preflight tool
Expose check_cloud_agent_runtime so lead agents can validate runtime routing and AWS AgentCore reachability before calling spawn_cloud_agent.
1 parent 6e0a707 commit 09c8165

7 files changed

Lines changed: 227 additions & 8 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,12 @@ Running this inline consumes context, blocks the lead-agent session, and loses c
4444

4545
## What it does
4646

47-
A single MCP server exposes nine tools to your assistant:
47+
A single MCP server exposes ten tools to your assistant:
4848

4949
| Tool | What it does |
5050
| --- | --- |
5151
| `spawn_cloud_agent` | The shortcut. One `instruction`, defaults from a runtime profile, returns a durable `taskId` and optional `cloudAgent` metadata. |
52+
| `check_cloud_agent_runtime` | Preflight a configured runtime before spawning. For AWS AgentCore, it can verify credentials and runtime/control-plane reachability. |
5253
| `dispatch_task` | The escape hatch. Full control over provider, capability, backend, target, and input. |
5354
| `get_task_status` | Current status for a dispatched task. |
5455
| `get_task_logs` | Paginated logs, cursor-based. |
@@ -61,6 +62,9 @@ A single MCP server exposes nine tools to your assistant:
6162
The model side looks like this:
6263

6364
```ts
65+
check_cloud_agent_runtime({ runtime: "research-agent" })
66+
// → { ok: true, checks: [...] }
67+
6468
spawn_cloud_agent({ instruction: "Audit our S3 buckets for public read and report findings." })
6569
// → { taskId: "task_...", status: "queued", cloudAgent: { ... } }
6670

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@agent-dispatch/mcp-server",
3-
"version": "0.1.3",
3+
"version": "0.1.4",
44
"description": "Provider-neutral MCP server for AgentDispatch.",
55
"type": "module",
66
"license": "Apache-2.0",

src/index.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { AgentDispatchError, type DispatchRequest, type RuntimeProfile, type RuntimeService } from "@agent-dispatch/core";
3+
import { checkAwsAgentCoreLivePreflight, type AwsAgentCoreLivePreflightCheck, type AwsAgentCoreLivePreflightInput } from "@agent-dispatch/adapter-aws-agentcore";
34
import packageJson from "../package.json" with { type: "json" };
45
import { mcpToolSchemas } from "./schemas.js";
56

6-
export function createAgentDispatchMcpServer(runtime: RuntimeService): McpServer {
7+
export interface AgentDispatchMcpServerOptions {
8+
awsAgentCoreLivePreflight?: (input: AwsAgentCoreLivePreflightInput) => Promise<AwsAgentCoreLivePreflightCheck[]>;
9+
}
10+
11+
export function createAgentDispatchMcpServer(runtime: RuntimeService, options: AgentDispatchMcpServerOptions = {}): McpServer {
712
const server = new McpServer({ name: "agentdispatch", version: packageJson.version });
813

914
server.tool("list_providers", mcpToolSchemas.list_providers.shape, async () => jsonContent(runtime.listProviders()));
@@ -14,6 +19,10 @@ export function createAgentDispatchMcpServer(runtime: RuntimeService): McpServer
1419

1520
server.tool("list_account_profiles", mcpToolSchemas.list_account_profiles.shape, async () => jsonContent(runtime.listAccountProfiles()));
1621

22+
server.tool("check_cloud_agent_runtime", mcpToolSchemas.check_cloud_agent_runtime.shape, async (input) => {
23+
return jsonContent(await createCloudAgentRuntimeCheck(runtime, input, options));
24+
});
25+
1726
server.tool("spawn_cloud_agent", mcpToolSchemas.spawn_cloud_agent.shape, async (input) => {
1827
const clarification = createSpawnClarification(runtime, input);
1928
if (clarification) return jsonContent(clarification);
@@ -56,6 +65,113 @@ export function createAgentDispatchMcpServer(runtime: RuntimeService): McpServer
5665
export * from "./bootstrap.js";
5766
export * from "./schemas.js";
5867

68+
async function createCloudAgentRuntimeCheck(runtime: RuntimeService, input: {
69+
runtime?: string;
70+
provider?: string;
71+
account_profile?: string;
72+
live?: boolean;
73+
runtimeArn?: string;
74+
runtime_arn?: string;
75+
target?: { mode?: string; protocol?: string; details?: Record<string, unknown> };
76+
}, options: AgentDispatchMcpServerOptions) {
77+
const checks: Array<{ name: string; status: "pass" | "warn" | "fail"; message: string }> = [];
78+
const defaults = runtime.getDefaults();
79+
const profile = selectRuntimeProfileForCheck(runtime, input.runtime, input.provider);
80+
if (!profile) {
81+
checks.push({
82+
name: "runtime",
83+
status: "fail",
84+
message: input.runtime
85+
? `Runtime profile ${input.runtime} is not configured.`
86+
: "No default runtime profile is configured."
87+
});
88+
return cloudAgentRuntimeCheckResult({ checks });
89+
}
90+
91+
const backend = runtime.getBackend(profile.backend);
92+
const accountName = input.account_profile ?? profile.account ?? defaults.accountProfile;
93+
const account = runtime.listAccountProfiles().find((candidate) => candidate.name === accountName);
94+
const targetMode = input.target?.mode ?? profile.target?.mode ?? defaults.targetMode ?? "session";
95+
checks.push({
96+
name: "runtime",
97+
status: "pass",
98+
message: `Runtime ${profile.name} routes ${profile.provider}/${profile.capability}/${targetMode} through ${profile.backend}.`
99+
});
100+
101+
if (!backend) {
102+
checks.push({
103+
name: "backend",
104+
status: "fail",
105+
message: `Runtime ${profile.name} references missing backend ${profile.backend}.`
106+
});
107+
return cloudAgentRuntimeCheckResult({ profile, account, backend, targetMode, checks });
108+
}
109+
checks.push({
110+
name: "backend",
111+
status: "pass",
112+
message: `Backend ${profile.backend} uses adapter ${backend.adapter}.`
113+
});
114+
115+
if (!account) {
116+
checks.push({
117+
name: "account_profile",
118+
status: "fail",
119+
message: accountName ? `Account profile ${accountName} is not configured.` : "No account profile is configured for this runtime."
120+
});
121+
return cloudAgentRuntimeCheckResult({ profile, account, backend, targetMode, checks });
122+
}
123+
checks.push({
124+
name: "account_profile",
125+
status: "pass",
126+
message: `Account profile ${account.name} uses provider ${account.provider}.`
127+
});
128+
129+
if (input.live !== false && account.provider === "aws" && backend.adapter === "aws-agentcore") {
130+
const targetDetails = mergeRecords(profile.target?.details, spawnTargetDetails(input), input.target?.details) ?? {};
131+
const preflight = options.awsAgentCoreLivePreflight ?? checkAwsAgentCoreLivePreflight;
132+
checks.push(...await preflight({
133+
runtimeName: profile.name,
134+
region: account.region ?? String(backend.details?.region ?? process.env.AWS_REGION ?? "us-east-1"),
135+
mode: targetMode,
136+
runtimeArn: stringValue(targetDetails.runtimeArn) ?? stringValue(backend.details?.runtimeArn) ?? process.env.AGENTDISPATCH_AGENTCORE_RUNTIME_ARN
137+
}));
138+
} else if (input.live !== false) {
139+
checks.push({
140+
name: "live",
141+
status: "warn",
142+
message: `Live preflight is not implemented for ${account.provider}/${backend.adapter}.`
143+
});
144+
}
145+
146+
return cloudAgentRuntimeCheckResult({ profile, account, backend, targetMode, checks });
147+
}
148+
149+
function cloudAgentRuntimeCheckResult(input: {
150+
profile?: RuntimeProfile;
151+
account?: { name: string; provider: string; region?: string; credentialSource: string };
152+
backend?: { adapter: string };
153+
targetMode?: string;
154+
checks: Array<{ name: string; status: "pass" | "warn" | "fail"; message: string }>;
155+
}) {
156+
return {
157+
ok: input.checks.every((check) => check.status !== "fail"),
158+
runtime: input.profile?.name,
159+
provider: input.account?.provider ?? input.profile?.provider,
160+
account_profile: input.account?.name ?? input.profile?.account,
161+
backend: input.profile?.backend,
162+
adapter: input.backend?.adapter,
163+
target_mode: input.targetMode,
164+
checks: input.checks
165+
};
166+
}
167+
168+
function selectRuntimeProfileForCheck(runtime: RuntimeService, runtimeName?: string, provider?: string): RuntimeProfile | undefined {
169+
if (runtimeName) return runtime.getRuntimeProfile(runtimeName);
170+
const defaultProfile = runtime.getDefaultRuntimeProfile();
171+
if (defaultProfile && (!provider || defaultProfile.provider === provider)) return defaultProfile;
172+
return runtime.listRuntimeProfiles().find((profile) => !provider || profile.provider === provider);
173+
}
174+
59175
function createSpawnCloudAgentRequest(runtime: RuntimeService, input: {
60176
instruction?: string;
61177
runtime?: string;
@@ -293,6 +409,10 @@ function stringRecord(key: string, value: unknown): Record<string, unknown> | un
293409
return typeof value === "string" && value.length > 0 ? { [key]: value } : undefined;
294410
}
295411

412+
function stringValue(value: unknown): string | undefined {
413+
return typeof value === "string" && value.length > 0 ? value : undefined;
414+
}
415+
296416
function recordRecord(key: string, value: unknown): Record<string, unknown> | undefined {
297417
return value && typeof value === "object" && !Array.isArray(value) ? { [key]: value } : undefined;
298418
}

src/schemas.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ export const spawnCloudAgentInputSchema = z.object({
4545
metadata: z.record(z.unknown()).optional()
4646
});
4747

48+
export const checkCloudAgentRuntimeInputSchema = z.object({
49+
runtime: z.string().optional(),
50+
provider: z.string().optional(),
51+
account_profile: z.string().optional(),
52+
live: z.boolean().optional(),
53+
runtimeArn: z.string().optional(),
54+
runtime_arn: z.string().optional(),
55+
target: z.object({
56+
mode: z.string().optional(),
57+
protocol: z.string().optional(),
58+
details: z.record(z.unknown()).optional()
59+
}).optional()
60+
});
61+
4862
export const taskIdInputSchema = z.object({
4963
task_id: z.string()
5064
});
@@ -59,6 +73,7 @@ export const mcpToolSchemas = {
5973
list_providers: z.object({}),
6074
list_capabilities: listCapabilitiesInputSchema,
6175
list_account_profiles: z.object({}),
76+
check_cloud_agent_runtime: checkCloudAgentRuntimeInputSchema,
6277
spawn_cloud_agent: spawnCloudAgentInputSchema,
6378
dispatch_task: dispatchTaskInputSchema,
6479
get_task_status: taskIdInputSchema,

test/e2e.test.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { join } from "node:path";
55
import { tmpdir } from "node:os";
66
import { afterEach, describe, expect, it } from "vitest";
77
import { RuntimeService, type BackendAdapter, type DispatchRequest, type RuntimeEvent, type RuntimeRecord, type TaskStore } from "@agent-dispatch/core";
8-
import { createAgentDispatchMcpServer, createRuntimeServiceFromConfig } from "../src/index.js";
8+
import { createAgentDispatchMcpServer, createRuntimeServiceFromConfig, type AgentDispatchMcpServerOptions } from "../src/index.js";
99

1010
const tempDirs: string[] = [];
1111

@@ -133,9 +133,9 @@ function createRuntime(adapter = mockAdapter([{ taskId: "ignored", type: "task.l
133133
});
134134
}
135135

136-
async function createClient(runtime = createRuntime()) {
136+
async function createClient(runtime = createRuntime(), options?: AgentDispatchMcpServerOptions) {
137137
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
138-
const server = createAgentDispatchMcpServer(runtime);
138+
const server = createAgentDispatchMcpServer(runtime, options);
139139
const client = new Client({ name: "agentdispatch-test", version: "0.1.0" });
140140
await server.connect(serverTransport);
141141
await client.connect(clientTransport);
@@ -236,6 +236,73 @@ describe("MCP tool invocation", () => {
236236
}
237237
});
238238

239+
it("checks cloud runtime readiness through the agent-facing MCP tool", async () => {
240+
const runtime = new RuntimeService({
241+
config: {
242+
accounts: { "dev-aws": { provider: "aws", region: "us-west-2", credentialSource: "aws-sdk-default" } },
243+
backends: {
244+
"aws-agentcore": {
245+
provider: "aws",
246+
capability: "agent-runtime",
247+
adapter: "aws-agentcore",
248+
account: "dev-aws"
249+
}
250+
},
251+
runtimes: {
252+
"research-agent": {
253+
provider: "aws",
254+
account: "dev-aws",
255+
capability: "agent-runtime",
256+
backend: "aws-agentcore",
257+
protocol: "a2a",
258+
target: {
259+
mode: "session",
260+
protocol: "a2a",
261+
details: { runtimeArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:agent/11111111-1111-1111-1111-111111111111:1" }
262+
}
263+
}
264+
},
265+
defaults: { runtime: "research-agent" }
266+
},
267+
store: new MemoryStore(),
268+
adapters: [mockAdapter([], "aws-agentcore")]
269+
});
270+
let liveInput: unknown;
271+
const { client, server } = await createClient(runtime, {
272+
awsAgentCoreLivePreflight: async (input) => {
273+
liveInput = input;
274+
return [{
275+
name: `aws.${input.runtimeName}.runtime`,
276+
status: "pass",
277+
message: `runtime ${input.runtimeArn} reachable`
278+
}];
279+
}
280+
});
281+
try {
282+
const response = await callJson(client, "check_cloud_agent_runtime", { runtime: "research-agent" });
283+
expect(response).toMatchObject({
284+
ok: true,
285+
runtime: "research-agent",
286+
provider: "aws",
287+
account_profile: "dev-aws",
288+
adapter: "aws-agentcore",
289+
checks: expect.arrayContaining([
290+
expect.objectContaining({ name: "runtime", status: "pass" }),
291+
expect.objectContaining({ name: "aws.research-agent.runtime", status: "pass" })
292+
])
293+
});
294+
expect(liveInput).toMatchObject({
295+
runtimeName: "research-agent",
296+
region: "us-west-2",
297+
mode: "session",
298+
runtimeArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:agent/11111111-1111-1111-1111-111111111111:1"
299+
});
300+
} finally {
301+
await client.close();
302+
await server.close();
303+
}
304+
});
305+
239306
it("surfaces unsupported provider errors to MCP clients", async () => {
240307
const { client, server } = await createClient();
241308
try {

test/schemas.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ describe("MCP schemas", () => {
2323
it("exports stable MCP tool schemas", () => {
2424
expect(Object.keys(mcpToolSchemas).sort()).toEqual([
2525
"cancel_task",
26+
"check_cloud_agent_runtime",
2627
"dispatch_task",
2728
"get_task_logs",
2829
"get_task_result",
@@ -34,6 +35,18 @@ describe("MCP schemas", () => {
3435
]);
3536
});
3637

38+
it("accepts cloud runtime preflight input", () => {
39+
expect(mcpToolSchemas.check_cloud_agent_runtime.parse({
40+
runtime: "research-agent",
41+
live: true,
42+
runtimeArn: "arn:aws:bedrock-agentcore:us-west-2:123456789012:agent/11111111-1111-1111-1111-111111111111:1"
43+
})).toMatchObject({
44+
runtime: "research-agent",
45+
live: true,
46+
runtimeArn: expect.stringContaining("agent/11111111")
47+
});
48+
});
49+
3750
it("keeps spawn_cloud_agent simple for agents", () => {
3851
const parsed = spawnCloudAgentInputSchema.parse({
3952
runtime: "research-agent",

0 commit comments

Comments
 (0)