@honua/mcp-server 0.2.3 → 0.2.5
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/README.md +63 -0
- package/dist/src/agent/cli.d.ts +3 -0
- package/dist/src/agent/cli.d.ts.map +1 -0
- package/dist/src/agent/cli.js +33 -0
- package/dist/src/agent/devops-configuration.d.ts +5 -0
- package/dist/src/agent/devops-configuration.d.ts.map +1 -0
- package/dist/src/agent/devops-configuration.js +130 -0
- package/dist/src/agent/devops-runtime.d.ts +42 -0
- package/dist/src/agent/devops-runtime.d.ts.map +1 -0
- package/dist/src/agent/devops-runtime.js +242 -0
- package/dist/src/agent/devops-targets.d.ts +33 -0
- package/dist/src/agent/devops-targets.d.ts.map +1 -0
- package/dist/src/agent/devops-targets.js +25 -0
- package/dist/src/agent/gateway.d.ts +58 -0
- package/dist/src/agent/gateway.d.ts.map +1 -0
- package/dist/src/agent/gateway.js +381 -0
- package/dist/src/agent/local-runtime.d.ts +26 -0
- package/dist/src/agent/local-runtime.d.ts.map +1 -0
- package/dist/src/agent/local-runtime.js +152 -0
- package/dist/src/agent/native-cli.d.ts +4 -0
- package/dist/src/agent/native-cli.d.ts.map +1 -0
- package/dist/src/agent/native-cli.js +49 -0
- package/dist/src/agent/native-http.d.ts +18 -0
- package/dist/src/agent/native-http.d.ts.map +1 -0
- package/dist/src/agent/native-http.js +261 -0
- package/dist/src/agent/workflow-knowledge.d.ts +73 -0
- package/dist/src/agent/workflow-knowledge.d.ts.map +1 -0
- package/dist/src/agent/workflow-knowledge.js +230 -0
- package/package.json +4 -2
- package/release/zero-to-map/configs/claude-code.mcp.json +1 -1
- package/release/zero-to-map/configs/claude-desktop.json +1 -1
- package/release/zero-to-map/configs/cursor.json +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,69 @@ Two modes:
|
|
|
29
29
|
|
|
30
30
|
## Requirements
|
|
31
31
|
|
|
32
|
+
### Persistent local agent connector — source preview
|
|
33
|
+
|
|
34
|
+
This branch adds `honua-agent-mcp`: one stdio connection that starts with local
|
|
35
|
+
setup tools and attaches the installed Honua server's canonical MCP surface
|
|
36
|
+
without replacing the client's registration. It is a source preview, not yet
|
|
37
|
+
the qualified public Docker-to-map getting-started path.
|
|
38
|
+
|
|
39
|
+
Start the built `dist/src/agent/cli.js` with Node from this package. The host sets
|
|
40
|
+
`HONUA_AGENT_INSTALL_DIRECTORY` (default `~/.honua/local`); the agent cannot
|
|
41
|
+
choose arbitrary credential files or remote URLs. Node and a local Unix-socket
|
|
42
|
+
Docker engine with Compose 2.23.1 or later are required. Linux/WSL is the initial
|
|
43
|
+
lane; Windows named pipes and remote Docker contexts are refused.
|
|
44
|
+
|
|
45
|
+
Prompt: **"Check this machine, then set up Honua locally in Docker. Show me the
|
|
46
|
+
target and available workflows when it is ready."** The agent calls
|
|
47
|
+
`honua_agent_inspect_local`, obtains installation authorization, and calls
|
|
48
|
+
`honua_agent_install_local` with `confirm: true`. Keep the installation tool
|
|
49
|
+
timeout long enough for image pulls and database initialization (ten minutes is
|
|
50
|
+
a useful starting point). If the client times out, inspect `honua_agent_status`
|
|
51
|
+
and the installation before issuing another install. Cancellation of a local
|
|
52
|
+
installation does not undo Docker work already started.
|
|
53
|
+
|
|
54
|
+
The connector verifies readiness and the installed credential before announcing
|
|
55
|
+
the server's tools. Existing installations attach on startup; after a transport
|
|
56
|
+
disconnect, `honua_agent_connect` verifies the same target again. A changed
|
|
57
|
+
credential identity requires a host restart. It never retries an upstream
|
|
58
|
+
mutation after an uncertain response.
|
|
59
|
+
|
|
60
|
+
Credentials stay in the installer's protected file and the HTTP transport. The
|
|
61
|
+
local agent key has `admin:read`, `admin:write`, and `read:*`; it has no
|
|
62
|
+
`admin:approve` or feature-write grant. Every upstream request carries that key,
|
|
63
|
+
and the server enforces current RBAC, resource access, and governance. Discovery
|
|
64
|
+
is not authorization; a denied operation remains denied. Browser human sessions
|
|
65
|
+
and their approval authority are separate.
|
|
66
|
+
|
|
67
|
+
This preview forwards canonical paginated tools, resources, resource templates,
|
|
68
|
+
prompts, request metadata, tool progress, cancellation, and catalog-change
|
|
69
|
+
notifications. It negotiates its own MCP identity and preserves the configured
|
|
70
|
+
workflow view. It does not advertise subscriptions, sampling, roots,
|
|
71
|
+
completions, or tasks. Durable workflow context, command reconciliation, browser
|
|
72
|
+
pairing, and coordinated Console/Studio artifacts remain tracked in
|
|
73
|
+
[the unified workspace workstream](https://github.com/honua-io/honua-server/issues/6297).
|
|
74
|
+
|
|
75
|
+
After building the SDK and this package, maintainers can run the opt-in Docker
|
|
76
|
+
handoff and process-restart check with
|
|
77
|
+
`HONUA_AGENT_LIVE_ENABLED=true npm run test:unit --prefix mcp -- test/agent-local-live.test.ts`
|
|
78
|
+
from the repository root. It creates and removes its own isolated Compose project.
|
|
79
|
+
This tests the built connector against the installer's pinned server image; it
|
|
80
|
+
does not certify the full map-making or browser journey.
|
|
81
|
+
|
|
82
|
+
The packaged workflow guide is available as the MCP resource
|
|
83
|
+
`honua://agent/knowledge/workflows/v1`, even before installation. Optional private
|
|
84
|
+
DevOps tools can share this registration through host configuration; see
|
|
85
|
+
[the host reference](../docs/unified-agent-host-configuration.md) for target and
|
|
86
|
+
credential requirements and the current qualification limits.
|
|
87
|
+
|
|
88
|
+
Native Console chat can use the preview `honua-agent-gateway` backend HTTP
|
|
89
|
+
transport to reach this same public/private catalog. Its separate bridge key,
|
|
90
|
+
workspace-turn checks and host configuration are described in that reference.
|
|
91
|
+
External agents continue to use their single `honua-agent-mcp` registration.
|
|
92
|
+
|
|
93
|
+
### Existing standalone and proxy modes
|
|
94
|
+
|
|
32
95
|
- Node.js `>=22.12`
|
|
33
96
|
- Any reachable FeatureServer/OGC endpoint (public or Honua)
|
|
34
97
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../../src/agent/cli.ts"],"names":[],"mappings":";AASA,wBAAsB,QAAQ,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAalF"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { isMainEntrypoint } from "../entrypoint.js";
|
|
6
|
+
import { configuredDevOps } from "./devops-configuration.js";
|
|
7
|
+
import { HonuaAgentGateway } from "./gateway.js";
|
|
8
|
+
import { createLocalAgentRuntime, nativeWorkspaceFromEnv } from "./local-runtime.js";
|
|
9
|
+
export async function runAgent(env = process.env) {
|
|
10
|
+
const directory = env.HONUA_AGENT_INSTALL_DIRECTORY ?? path.join(homedir(), ".honua", "local");
|
|
11
|
+
const runtime = createLocalAgentRuntime(directory, nativeWorkspaceFromEnv(env));
|
|
12
|
+
const devops = env.HONUA_AGENT_DEVOPS_CONFIG
|
|
13
|
+
? await configuredDevOps(env.HONUA_AGENT_DEVOPS_CONFIG, runtime)
|
|
14
|
+
: undefined;
|
|
15
|
+
const gateway = new HonuaAgentGateway(runtime, undefined, devops);
|
|
16
|
+
const close = () => {
|
|
17
|
+
void gateway.close().catch(() => { });
|
|
18
|
+
};
|
|
19
|
+
process.stdin.once("end", close);
|
|
20
|
+
process.stdin.once("close", close);
|
|
21
|
+
await gateway.connect(new StdioServerTransport());
|
|
22
|
+
}
|
|
23
|
+
if (isMainEntrypoint(import.meta.url)) {
|
|
24
|
+
if (process.argv.includes("--help")) {
|
|
25
|
+
process.stdout.write("honua-agent-mcp — persistent local Honua MCP gateway\nConfigure HONUA_AGENT_INSTALL_DIRECTORY in the host; default ~/.honua/local.\n");
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
runAgent().catch(() => {
|
|
29
|
+
process.stderr.write("Honua agent connector failed; inspect local configuration.\n");
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type DevOpsExtension } from "./devops-runtime.js";
|
|
2
|
+
import type { AgentRuntime } from "./local-runtime.js";
|
|
3
|
+
/** The host selects this protected file; MCP arguments cannot select executable paths or credentials. */
|
|
4
|
+
export declare function configuredDevOps(file: string, runtime: AgentRuntime, fetchImpl?: typeof fetch): Promise<DevOpsExtension>;
|
|
5
|
+
//# sourceMappingURL=devops-configuration.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"devops-configuration.d.ts","sourceRoot":"","sources":["../../../src/agent/devops-configuration.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,eAAe,EAAyB,MAAM,qBAAqB,CAAC;AAElF,OAAO,KAAK,EAAmB,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAyBxE,yGAAyG;AACzG,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,YAAY,EACrB,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC,eAAe,CAAC,CAoG1B"}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { buildUpstreamHeaders } from "../proxy.js";
|
|
5
|
+
import { createDevOpsExtension } from "./devops-runtime.js";
|
|
6
|
+
import { devOpsTarget } from "./devops-targets.js";
|
|
7
|
+
const configuration = z
|
|
8
|
+
.object({
|
|
9
|
+
serverTargetId: z.string().min(1).max(128),
|
|
10
|
+
environment: z.string().min(1).max(128),
|
|
11
|
+
deployTargetId: z.string().min(1).max(128),
|
|
12
|
+
runtimeKind: z.enum(["aws-ecs", "aws-lambda", "aws-eks"]).default("aws-ecs"),
|
|
13
|
+
command: z.string().min(1),
|
|
14
|
+
args: z.array(z.string()).max(64).optional(),
|
|
15
|
+
cwd: z.string().optional(),
|
|
16
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
17
|
+
fixedArguments: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
|
|
18
|
+
provisioningScope: z
|
|
19
|
+
.object({
|
|
20
|
+
environment: z.string().min(1),
|
|
21
|
+
region: z.string().min(1),
|
|
22
|
+
namePrefix: z.string().min(1),
|
|
23
|
+
allowedImages: z.array(z.string().min(1)).min(1),
|
|
24
|
+
})
|
|
25
|
+
.strict()
|
|
26
|
+
.optional(),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
29
|
+
/** The host selects this protected file; MCP arguments cannot select executable paths or credentials. */
|
|
30
|
+
export async function configuredDevOps(file, runtime, fetchImpl = fetch) {
|
|
31
|
+
if (!path.isAbsolute(file))
|
|
32
|
+
throw new Error("DevOps configuration requires an absolute host path");
|
|
33
|
+
const info = await lstat(file);
|
|
34
|
+
if (!info.isFile() || info.size > 65536 || (process.platform !== "win32" && (info.mode & 0o077) !== 0))
|
|
35
|
+
throw new Error("DevOps configuration must be a private, bounded regular file");
|
|
36
|
+
const config = configuration.parse(JSON.parse(await readFile(file, "utf8")));
|
|
37
|
+
const target = devOpsTarget(config.runtimeKind);
|
|
38
|
+
if (!path.isAbsolute(config.command) || (config.cwd && !path.isAbsolute(config.cwd)))
|
|
39
|
+
throw new Error("DevOps executable and working directory must be host-selected absolute paths");
|
|
40
|
+
if (config.provisioningScope && config.provisioningScope.environment !== config.environment)
|
|
41
|
+
throw new Error("Provisioning scope must match the configured environment");
|
|
42
|
+
let extension;
|
|
43
|
+
let closed = false;
|
|
44
|
+
async function get() {
|
|
45
|
+
if (closed)
|
|
46
|
+
throw new Error("Private integration closed");
|
|
47
|
+
if (!extension) {
|
|
48
|
+
extension = (async () => {
|
|
49
|
+
const connection = await runtime.resolve();
|
|
50
|
+
const base = new URL(connection.transport.remoteUrl);
|
|
51
|
+
if (!/\/mcp\/?$/.test(base.pathname))
|
|
52
|
+
throw new Error("Private integration requires the canonical MCP endpoint");
|
|
53
|
+
base.pathname = base.pathname.replace(/mcp\/?$/, "");
|
|
54
|
+
base.search = "";
|
|
55
|
+
base.hash = "";
|
|
56
|
+
// Child operations use this same scoped agent credential. A separately configured
|
|
57
|
+
// human/admin key must never become a silent authority fallback.
|
|
58
|
+
if (!connection.transport.apiKey)
|
|
59
|
+
throw new Error("A scoped private integration credential is required");
|
|
60
|
+
const authorize = async (action) => {
|
|
61
|
+
const current = await runtime.resolve();
|
|
62
|
+
requireSameConnection(connection, current);
|
|
63
|
+
const response = await fetchImpl(new URL("api/v1/agent/devops/authorize", base), {
|
|
64
|
+
method: "POST",
|
|
65
|
+
redirect: "error",
|
|
66
|
+
signal: AbortSignal.timeout(10000),
|
|
67
|
+
headers: { ...buildUpstreamHeaders(current.transport), "Content-Type": "application/json" },
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
targetId: config.serverTargetId,
|
|
70
|
+
deployTargetId: config.deployTargetId,
|
|
71
|
+
environment: config.environment,
|
|
72
|
+
canonicalTool: action.canonicalTool,
|
|
73
|
+
arguments: action.arguments,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok)
|
|
77
|
+
throw new Error("Private integration unauthorized");
|
|
78
|
+
const receipt = (await response.json());
|
|
79
|
+
if (receipt.targetId !== config.serverTargetId ||
|
|
80
|
+
receipt.deployTargetId !== config.deployTargetId ||
|
|
81
|
+
receipt.environment !== config.environment)
|
|
82
|
+
throw new Error("Private integration target changed");
|
|
83
|
+
const legacyEcs = config.runtimeKind === "aws-ecs" && receipt.runtimeKind === undefined && receipt.backend === undefined;
|
|
84
|
+
if (!legacyEcs && (receipt.runtimeKind !== config.runtimeKind || receipt.backend !== target.backend))
|
|
85
|
+
throw new Error("Private integration runtime changed or is unsupported");
|
|
86
|
+
};
|
|
87
|
+
await authorize({ canonicalTool: null });
|
|
88
|
+
return createDevOpsExtension({
|
|
89
|
+
...config,
|
|
90
|
+
env: {
|
|
91
|
+
...config.env,
|
|
92
|
+
HONUA_DEVOPS_HONUA_API_BASE_URL: base.toString().replace(/\/$/, ""),
|
|
93
|
+
HONUA_DEVOPS_HONUA_API_KEY: connection.transport.apiKey,
|
|
94
|
+
HONUA_DEVOPS_TERRAFORM_TARGETS: target.terraformTarget,
|
|
95
|
+
HONUA_DEVOPS_RUNTIME_KIND: config.runtimeKind,
|
|
96
|
+
HONUA_DEVOPS_RUNTIME_BACKEND: target.backend,
|
|
97
|
+
HONUA_DEVOPS_SERVER_TARGET_ID: config.serverTargetId,
|
|
98
|
+
HONUA_DEVOPS_ALLOWED_ENVIRONMENTS: config.environment,
|
|
99
|
+
HONUA_DEVOPS_DEPLOY_TARGET_ID: config.deployTargetId,
|
|
100
|
+
},
|
|
101
|
+
}, authorize);
|
|
102
|
+
})();
|
|
103
|
+
const attempt = extension;
|
|
104
|
+
void attempt.catch(() => {
|
|
105
|
+
if (extension === attempt)
|
|
106
|
+
extension = undefined;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return extension;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
async listTools() {
|
|
113
|
+
return (await get()).listTools();
|
|
114
|
+
},
|
|
115
|
+
async callTool(name, args, options) {
|
|
116
|
+
return (await get()).callTool(name, args, options);
|
|
117
|
+
},
|
|
118
|
+
async close() {
|
|
119
|
+
closed = true;
|
|
120
|
+
await extension?.then((value) => value.close()).catch(() => { });
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function requireSameConnection(expected, actual) {
|
|
125
|
+
if (expected.target.id !== actual.target.id ||
|
|
126
|
+
expected.target.credentialId !== actual.target.credentialId ||
|
|
127
|
+
expected.transport.remoteUrl !== actual.transport.remoteUrl ||
|
|
128
|
+
expected.transport.apiKey !== actual.transport.apiKey)
|
|
129
|
+
throw new Error("Private integration identity changed; restart explicitly");
|
|
130
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type CallToolResult, type Progress, type Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { type DevOpsRuntimeKind } from "./devops-targets.js";
|
|
3
|
+
export declare const DEVOPS_TOOL_FAMILY = "honua.workspace.devops";
|
|
4
|
+
/** Private provisioning/planning only. Day-2 operations remain on the server's canonical MCP surface. */
|
|
5
|
+
export declare const DEVOPS_TOOL_ALLOWLIST: ReadonlySet<string>;
|
|
6
|
+
export interface DevOpsExtension {
|
|
7
|
+
listTools(): Promise<Tool[]>;
|
|
8
|
+
callTool(name: string, args: Record<string, unknown>, options?: {
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
onprogress?: (progress: Progress) => void;
|
|
11
|
+
}): Promise<CallToolResult>;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/** Host configuration only. Never accept this configuration from an MCP tool argument. */
|
|
15
|
+
export interface DevOpsProcessConfiguration {
|
|
16
|
+
command: string;
|
|
17
|
+
runtimeKind?: DevOpsRuntimeKind;
|
|
18
|
+
args?: string[];
|
|
19
|
+
cwd?: string;
|
|
20
|
+
/** Explicit child environment, including any operator credentials. Never returned to the model. */
|
|
21
|
+
env?: Record<string, string>;
|
|
22
|
+
/** Host-only bindings; absent path bindings hide handoff tools entirely. */
|
|
23
|
+
fixedArguments?: Record<string, Record<string, unknown>>;
|
|
24
|
+
provisioningScope?: {
|
|
25
|
+
environment: string;
|
|
26
|
+
region: string;
|
|
27
|
+
namePrefix: string;
|
|
28
|
+
allowedImages: string[];
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export type DevOpsAuthorization = (action: {
|
|
32
|
+
canonicalTool: string | null;
|
|
33
|
+
arguments?: Readonly<Record<string, unknown>>;
|
|
34
|
+
}) => Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* One optional private subprocess behind the existing public registration. Authorization is
|
|
37
|
+
* rechecked before EVERY discovery/action by the gateway against its current bound principal
|
|
38
|
+
* and target. Canonical handlers still enforce their own approval, audit and exact-plan gates.
|
|
39
|
+
* Errors are intentionally opaque; upstream exceptions may contain credentials or stderr.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createDevOpsExtension(configuration: DevOpsProcessConfiguration, authorize: DevOpsAuthorization): DevOpsExtension;
|
|
42
|
+
//# sourceMappingURL=devops-runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"devops-runtime.d.ts","sourceRoot":"","sources":["../../../src/agent/devops-runtime.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,cAAc,EAEnB,KAAK,QAAQ,EACb,KAAK,IAAI,EACV,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,KAAK,iBAAiB,EAAgB,MAAM,qBAAqB,CAAC;AAG3E,eAAO,MAAM,kBAAkB,2BAA2B,CAAC;AAC3D,yGAAyG;AACzG,eAAO,MAAM,qBAAqB,EAAE,WAAW,CAAC,MAAM,CAgBpD,CAAC;AAEH,MAAM,WAAW,eAAe;IAC9B,SAAS,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC;IAC7B,QAAQ,CACN,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,KAAK,IAAI,CAAA;KAAE,GAC5E,OAAO,CAAC,cAAc,CAAC,CAAC;IAC3B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,0FAA0F;AAC1F,MAAM,WAAW,0BAA0B;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,mGAAmG;IACnG,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,4EAA4E;IAC5E,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACzD,iBAAiB,CAAC,EAAE;QAClB,WAAW,EAAE,MAAM,CAAC;QACpB,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,MAAM,CAAC;QACnB,aAAa,EAAE,MAAM,EAAE,CAAC;KACzB,CAAC;CACH;AAED,MAAM,MAAM,mBAAmB,GAAG,CAAC,MAAM,EAAE;IACzC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CAC/C,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAEpB;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,aAAa,EAAE,0BAA0B,EACzC,SAAS,EAAE,mBAAmB,GAC7B,eAAe,CAoNjB"}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
+
import { CallToolResultSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { devOpsTarget } from "./devops-targets.js";
|
|
6
|
+
const PREFIX = "honua_agent_devops_";
|
|
7
|
+
export const DEVOPS_TOOL_FAMILY = "honua.workspace.devops";
|
|
8
|
+
/** Private provisioning/planning only. Day-2 operations remain on the server's canonical MCP surface. */
|
|
9
|
+
export const DEVOPS_TOOL_ALLOWLIST = new Set([
|
|
10
|
+
"describe_environment",
|
|
11
|
+
"find_recent_operations",
|
|
12
|
+
"provision_infrastructure",
|
|
13
|
+
"install_handoff",
|
|
14
|
+
"verify_install_handoff",
|
|
15
|
+
"plan_gitops_engine",
|
|
16
|
+
"generate_metadata_release_changeset",
|
|
17
|
+
"explain_metadata_release_changeset",
|
|
18
|
+
"plan_metadata_release_gitops",
|
|
19
|
+
"create_gitops_proposal",
|
|
20
|
+
"get_gitops_proposal",
|
|
21
|
+
"get_devops_operation_status",
|
|
22
|
+
"deploy_service_gitops",
|
|
23
|
+
"create_scoped_gitops_pull_request",
|
|
24
|
+
"revert_scoped_gitops_pull_request",
|
|
25
|
+
]);
|
|
26
|
+
/**
|
|
27
|
+
* One optional private subprocess behind the existing public registration. Authorization is
|
|
28
|
+
* rechecked before EVERY discovery/action by the gateway against its current bound principal
|
|
29
|
+
* and target. Canonical handlers still enforce their own approval, audit and exact-plan gates.
|
|
30
|
+
* Errors are intentionally opaque; upstream exceptions may contain credentials or stderr.
|
|
31
|
+
*/
|
|
32
|
+
export function createDevOpsExtension(configuration, authorize) {
|
|
33
|
+
if (!path.isAbsolute(configuration.command))
|
|
34
|
+
throw new Error("DevOps executable must be an absolute host path");
|
|
35
|
+
const runtimeKind = configuration.runtimeKind ?? "aws-ecs";
|
|
36
|
+
const target = devOpsTarget(runtimeKind);
|
|
37
|
+
// Copy configuration so a caller cannot silently change credentials/commands after binding.
|
|
38
|
+
const config = {
|
|
39
|
+
command: configuration.command,
|
|
40
|
+
args: [...(configuration.args ?? ["--mcp"])],
|
|
41
|
+
...(configuration.cwd ? { cwd: configuration.cwd } : {}),
|
|
42
|
+
...(configuration.env ? { env: { ...configuration.env } } : {}),
|
|
43
|
+
};
|
|
44
|
+
const bindings = structuredClone(configuration.fixedArguments ?? {});
|
|
45
|
+
const provisioningScope = configuration.provisioningScope
|
|
46
|
+
? structuredClone(configuration.provisioningScope)
|
|
47
|
+
: undefined;
|
|
48
|
+
function enabled(name) {
|
|
49
|
+
if (name === "install_handoff")
|
|
50
|
+
return ["baseUrl", "adminKeySecretRef", "outputDirectory"].every((key) => typeof bindings[name]?.[key] === "string" && String(bindings[name][key]).length > 0);
|
|
51
|
+
if (name === "verify_install_handoff")
|
|
52
|
+
return (typeof bindings[name]?.handoffConfigPath === "string" && String(bindings[name].handoffConfigPath).length > 0);
|
|
53
|
+
if (name === "provision_infrastructure")
|
|
54
|
+
return Boolean(provisioningScope?.environment &&
|
|
55
|
+
provisioningScope.region &&
|
|
56
|
+
provisioningScope.namePrefix &&
|
|
57
|
+
provisioningScope.allowedImages.length);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
function bindArguments(name, supplied) {
|
|
61
|
+
if (!enabled(name))
|
|
62
|
+
throw new Error("Host scope binding required");
|
|
63
|
+
const effective = structuredClone(supplied);
|
|
64
|
+
for (const [key, value] of Object.entries(bindings[name] ?? {})) {
|
|
65
|
+
if (effective[key] !== undefined &&
|
|
66
|
+
effective[key] !== "" &&
|
|
67
|
+
JSON.stringify(effective[key]) !== JSON.stringify(value))
|
|
68
|
+
throw new Error("Host argument binding mismatch");
|
|
69
|
+
effective[key] = value;
|
|
70
|
+
}
|
|
71
|
+
if (name === "provision_infrastructure") {
|
|
72
|
+
if (effective.stack !== runtimeKind || !provisioningScope)
|
|
73
|
+
throw new Error("Unsupported adapter");
|
|
74
|
+
if (effective.variablesJson !== undefined && typeof effective.variablesJson !== "string")
|
|
75
|
+
throw new Error("Invalid variable document type");
|
|
76
|
+
if (typeof effective.variablesJson === "string" && effective.variablesJson.length > 64 * 1024)
|
|
77
|
+
throw new Error("Variable document exceeds scope limit");
|
|
78
|
+
const variables = JSON.parse(typeof effective.variablesJson === "string" && effective.variablesJson.trim() ? effective.variablesJson : "{}");
|
|
79
|
+
if (!variables || typeof variables !== "object" || Array.isArray(variables))
|
|
80
|
+
throw new Error("Invalid variables");
|
|
81
|
+
const scoped = variables;
|
|
82
|
+
for (const [key, value] of Object.entries({
|
|
83
|
+
environment: provisioningScope.environment,
|
|
84
|
+
region: provisioningScope.region,
|
|
85
|
+
name_prefix: provisioningScope.namePrefix,
|
|
86
|
+
})) {
|
|
87
|
+
if (scoped[key] !== undefined && scoped[key] !== value)
|
|
88
|
+
throw new Error("Cloud target scope mismatch");
|
|
89
|
+
scoped[key] = value;
|
|
90
|
+
}
|
|
91
|
+
const requireImage = (image) => {
|
|
92
|
+
if (typeof image !== "string" || !provisioningScope.allowedImages.includes(image))
|
|
93
|
+
throw new Error("Image outside approved scope");
|
|
94
|
+
};
|
|
95
|
+
// Marketplace install.artifact.image overrides the flat Terraform variable.
|
|
96
|
+
// Validate that effective image too, rather than letting the nested input
|
|
97
|
+
// bypass the host's allowlist.
|
|
98
|
+
if (scoped.install !== undefined) {
|
|
99
|
+
if (!scoped.install || typeof scoped.install !== "object" || Array.isArray(scoped.install))
|
|
100
|
+
throw new Error("Invalid install scope");
|
|
101
|
+
const artifact = scoped.install.artifact;
|
|
102
|
+
if (artifact !== undefined && artifact !== null) {
|
|
103
|
+
if (typeof artifact !== "object" || Array.isArray(artifact))
|
|
104
|
+
throw new Error("Invalid artifact scope");
|
|
105
|
+
const image = artifact.image;
|
|
106
|
+
if (image !== undefined && image !== null)
|
|
107
|
+
requireImage(image);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (target.imageVariable) {
|
|
111
|
+
if (scoped[target.imageVariable] !== undefined)
|
|
112
|
+
requireImage(scoped[target.imageVariable]);
|
|
113
|
+
scoped[target.imageVariable] ??= provisioningScope.allowedImages[0];
|
|
114
|
+
}
|
|
115
|
+
for (const variable of ["honua_image", "honua_image_uri"]) {
|
|
116
|
+
if (variable !== target.imageVariable && scoped[variable] !== undefined)
|
|
117
|
+
throw new Error("Image variable does not belong to the selected runtime");
|
|
118
|
+
}
|
|
119
|
+
effective.variablesJson = JSON.stringify(scoped);
|
|
120
|
+
}
|
|
121
|
+
return effective;
|
|
122
|
+
}
|
|
123
|
+
let pending;
|
|
124
|
+
let closed = false;
|
|
125
|
+
let progressToken = 0;
|
|
126
|
+
let generation = 0;
|
|
127
|
+
const secretValues = Object.entries(config.env ?? {})
|
|
128
|
+
.filter(([key, value]) => /secret|token|password|(?:^|_)key(?:$|_)|api_?key|credential|authorization|bearer/i.test(key) &&
|
|
129
|
+
value.length >= 4)
|
|
130
|
+
.map(([, value]) => value);
|
|
131
|
+
function scrub(value) {
|
|
132
|
+
return JSON.parse(JSON.stringify(value, (_key, field) => {
|
|
133
|
+
if (typeof field !== "string")
|
|
134
|
+
return field;
|
|
135
|
+
let text = field;
|
|
136
|
+
for (const secret of secretValues)
|
|
137
|
+
text = text.split(secret).join("[REDACTED]");
|
|
138
|
+
return text;
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
async function connect() {
|
|
142
|
+
if (closed)
|
|
143
|
+
throw new Error("DevOps integration is closed");
|
|
144
|
+
if (!pending) {
|
|
145
|
+
const currentGeneration = ++generation;
|
|
146
|
+
pending = (async () => {
|
|
147
|
+
const client = new Client({ name: "honua-agent-devops", version: "1.0.0" }, { capabilities: {} });
|
|
148
|
+
client.onerror = () => { };
|
|
149
|
+
client.onclose = () => {
|
|
150
|
+
if (generation === currentGeneration)
|
|
151
|
+
pending = undefined;
|
|
152
|
+
};
|
|
153
|
+
// Ignore child stderr: operator diagnostics are not model-visible tool content.
|
|
154
|
+
const transport = new StdioClientTransport({ ...config, stderr: "ignore" });
|
|
155
|
+
try {
|
|
156
|
+
await client.connect(transport);
|
|
157
|
+
if (closed)
|
|
158
|
+
throw new Error("closed");
|
|
159
|
+
return client;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
await client.close().catch(() => { });
|
|
163
|
+
throw new Error("Private DevOps connection failed");
|
|
164
|
+
}
|
|
165
|
+
})();
|
|
166
|
+
// A failed handshake can be retried, but a tool call is NEVER automatically replayed.
|
|
167
|
+
const attempt = pending;
|
|
168
|
+
void attempt.catch(() => {
|
|
169
|
+
if (pending === attempt)
|
|
170
|
+
pending = undefined;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return pending;
|
|
174
|
+
}
|
|
175
|
+
async function catalog(client) {
|
|
176
|
+
const tools = [];
|
|
177
|
+
const cursors = new Set();
|
|
178
|
+
let cursor;
|
|
179
|
+
do {
|
|
180
|
+
const page = await client.listTools(cursor ? { cursor } : {});
|
|
181
|
+
for (const tool of page.tools) {
|
|
182
|
+
if (!DEVOPS_TOOL_ALLOWLIST.has(tool.name) || !enabled(tool.name))
|
|
183
|
+
continue;
|
|
184
|
+
if (tools.some((existing) => existing.name === tool.name))
|
|
185
|
+
throw new Error("Duplicate private tool");
|
|
186
|
+
tools.push(tool);
|
|
187
|
+
}
|
|
188
|
+
cursor = page.nextCursor;
|
|
189
|
+
if (cursor && cursors.has(cursor))
|
|
190
|
+
throw new Error("Invalid private tool cursor");
|
|
191
|
+
if (cursor)
|
|
192
|
+
cursors.add(cursor);
|
|
193
|
+
if (cursors.size > 100)
|
|
194
|
+
throw new Error("Private tool catalog too large");
|
|
195
|
+
} while (cursor);
|
|
196
|
+
return tools;
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
async listTools() {
|
|
200
|
+
try {
|
|
201
|
+
await authorize({ canonicalTool: null });
|
|
202
|
+
const tools = await catalog(await connect());
|
|
203
|
+
return scrub(tools.map((tool) => ({
|
|
204
|
+
...tool,
|
|
205
|
+
name: `${PREFIX}${tool.name}`,
|
|
206
|
+
_meta: { ...tool._meta, "honua.studio": { family: DEVOPS_TOOL_FAMILY, view: "workspace", revision: "1" } },
|
|
207
|
+
})));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
throw new Error("Private DevOps discovery unavailable or unauthorized");
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
async callTool(name, args, options) {
|
|
214
|
+
const canonicalTool = name.startsWith(PREFIX) ? name.slice(PREFIX.length) : "";
|
|
215
|
+
if (!DEVOPS_TOOL_ALLOWLIST.has(canonicalTool))
|
|
216
|
+
throw new Error("Unsupported private DevOps tool");
|
|
217
|
+
try {
|
|
218
|
+
const effectiveArguments = bindArguments(canonicalTool, args);
|
|
219
|
+
await authorize({ canonicalTool, arguments: effectiveArguments });
|
|
220
|
+
const client = await connect();
|
|
221
|
+
// Discover afresh: do not use a stale descriptor after a target/policy change.
|
|
222
|
+
if (!(await catalog(client)).some((tool) => tool.name === canonicalTool))
|
|
223
|
+
throw new Error("Private DevOps capability unavailable");
|
|
224
|
+
return scrub(await client.request({
|
|
225
|
+
method: "tools/call",
|
|
226
|
+
params: {
|
|
227
|
+
name: canonicalTool,
|
|
228
|
+
arguments: effectiveArguments,
|
|
229
|
+
...(options?.onprogress ? { _meta: { progressToken: ++progressToken } } : {}),
|
|
230
|
+
},
|
|
231
|
+
}, CallToolResultSchema, { signal: options?.signal, onprogress: options?.onprogress }));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
throw new Error("Private DevOps action failed or unauthorized; reconcile canonical operation state before retry");
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
async close() {
|
|
238
|
+
closed = true;
|
|
239
|
+
await pending?.then((client) => client.close()).catch(() => { });
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Host-selected runtimes and their canonical server actuators, never model-selected backends. */
|
|
2
|
+
export declare const DEVOPS_TARGETS: {
|
|
3
|
+
readonly "aws-ecs": {
|
|
4
|
+
readonly backend: "honua-aws-ecs-alb";
|
|
5
|
+
readonly terraformTarget: "ecs";
|
|
6
|
+
readonly imageVariable: "honua_image";
|
|
7
|
+
};
|
|
8
|
+
readonly "aws-lambda": {
|
|
9
|
+
readonly backend: "honua-gitops-aws-lambda";
|
|
10
|
+
readonly terraformTarget: "lambda";
|
|
11
|
+
readonly imageVariable: "honua_image_uri";
|
|
12
|
+
};
|
|
13
|
+
readonly "aws-eks": {
|
|
14
|
+
readonly backend: "honua-kubernetes-deployment";
|
|
15
|
+
readonly terraformTarget: "eks";
|
|
16
|
+
readonly imageVariable: undefined;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
export type DevOpsRuntimeKind = keyof typeof DEVOPS_TARGETS;
|
|
20
|
+
export declare function devOpsTarget(runtimeKind?: DevOpsRuntimeKind): {
|
|
21
|
+
readonly backend: "honua-aws-ecs-alb";
|
|
22
|
+
readonly terraformTarget: "ecs";
|
|
23
|
+
readonly imageVariable: "honua_image";
|
|
24
|
+
} | {
|
|
25
|
+
readonly backend: "honua-gitops-aws-lambda";
|
|
26
|
+
readonly terraformTarget: "lambda";
|
|
27
|
+
readonly imageVariable: "honua_image_uri";
|
|
28
|
+
} | {
|
|
29
|
+
readonly backend: "honua-kubernetes-deployment";
|
|
30
|
+
readonly terraformTarget: "eks";
|
|
31
|
+
readonly imageVariable: undefined;
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=devops-targets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"devops-targets.d.ts","sourceRoot":"","sources":["../../../src/agent/devops-targets.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;CAkBjB,CAAC;AAEX,MAAM,MAAM,iBAAiB,GAAG,MAAM,OAAO,cAAc,CAAC;AAE5D,wBAAgB,YAAY,CAAC,WAAW,GAAE,iBAA6B;;;;;;;;;;;;EAGtE"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Host-selected runtimes and their canonical server actuators, never model-selected backends. */
|
|
2
|
+
export const DEVOPS_TARGETS = {
|
|
3
|
+
"aws-ecs": {
|
|
4
|
+
backend: "honua-aws-ecs-alb",
|
|
5
|
+
terraformTarget: "ecs",
|
|
6
|
+
imageVariable: "honua_image",
|
|
7
|
+
},
|
|
8
|
+
"aws-lambda": {
|
|
9
|
+
backend: "honua-gitops-aws-lambda",
|
|
10
|
+
terraformTarget: "lambda",
|
|
11
|
+
imageVariable: "honua_image_uri",
|
|
12
|
+
},
|
|
13
|
+
"aws-eks": {
|
|
14
|
+
backend: "honua-kubernetes-deployment",
|
|
15
|
+
terraformTarget: "eks",
|
|
16
|
+
// The EKS infrastructure root does not install the application. Its separate
|
|
17
|
+
// workload handoff must verify the actual Deployment, Service and cluster identity.
|
|
18
|
+
imageVariable: undefined,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
export function devOpsTarget(runtimeKind = "aws-ecs") {
|
|
22
|
+
if (!Object.hasOwn(DEVOPS_TARGETS, runtimeKind))
|
|
23
|
+
throw new Error("Unsupported private deployment runtime");
|
|
24
|
+
return DEVOPS_TARGETS[runtimeKind];
|
|
25
|
+
}
|