agent-trellis 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -38
- package/dist/adapters/claude-code.js +3 -2
- package/dist/adapters/codex.js +3 -2
- package/dist/adapters/jsonMcp.d.ts +3 -1
- package/dist/adapters/jsonMcp.js +18 -4
- package/dist/adapters/kimi-code.d.ts +21 -0
- package/dist/adapters/kimi-code.js +107 -0
- package/dist/adapters/kiro.js +3 -2
- package/dist/adapters/mcpPlan.d.ts +6 -1
- package/dist/adapters/mcpPlan.js +106 -8
- package/dist/adapters/pi.js +22 -3
- package/dist/adapters/symlinkPlan.d.ts +7 -0
- package/dist/adapters/symlinkPlan.js +16 -7
- package/dist/cli.js +121 -21
- package/dist/commands/doctor.d.ts +7 -2
- package/dist/commands/doctor.js +38 -2
- package/dist/commands/init.d.ts +4 -2
- package/dist/commands/init.js +26 -13
- package/dist/commands/kimi.d.ts +16 -0
- package/dist/commands/kimi.js +64 -0
- package/dist/commands/manage.d.ts +39 -0
- package/dist/commands/manage.js +126 -0
- package/dist/commands/mcp.d.ts +22 -1
- package/dist/commands/mcp.js +65 -2
- package/dist/commands/mcpGateway.d.ts +6 -0
- package/dist/commands/mcpGateway.js +34 -14
- package/dist/commands/mcpImport.d.ts +27 -0
- package/dist/commands/mcpImport.js +336 -0
- package/dist/commands/memory.d.ts +5 -3
- package/dist/commands/memory.js +12 -8
- package/dist/commands/migrate.d.ts +16 -5
- package/dist/commands/migrate.js +90 -17
- package/dist/commands/onboard.d.ts +74 -2
- package/dist/commands/onboard.js +458 -71
- package/dist/commands/rollback.js +4 -1
- package/dist/commands/skill.d.ts +17 -0
- package/dist/commands/skill.js +47 -2
- package/dist/commands/sync.js +2 -0
- package/dist/core/canonical.d.ts +15 -7
- package/dist/core/canonical.js +101 -13
- package/dist/core/capabilitySelection.d.ts +45 -0
- package/dist/core/capabilitySelection.js +124 -0
- package/dist/core/types.d.ts +25 -2
- package/dist/core/types.js +7 -0
- package/dist/lib/backup.d.ts +2 -0
- package/dist/lib/backup.js +4 -0
- package/dist/lib/builtinSkills.d.ts +2 -0
- package/dist/lib/builtinSkills.js +4 -0
- package/dist/lib/cliMetadata.d.ts +1 -0
- package/dist/lib/cliMetadata.js +14 -0
- package/dist/lib/gatewayBackend.d.ts +28 -0
- package/dist/lib/gatewayBackend.js +72 -1
- package/dist/lib/installAgent.d.ts +7 -5
- package/dist/lib/installAgent.js +4 -3
- package/dist/lib/mcpConnect.js +5 -1
- package/dist/lib/mcpMigrateRead.d.ts +1 -0
- package/dist/lib/mcpMigrateRead.js +3 -0
- package/dist/lib/mcpRuntime.d.ts +46 -0
- package/dist/lib/mcpRuntime.js +185 -0
- package/dist/lib/mcpStatusProvider.d.ts +10 -0
- package/dist/lib/mcpStatusProvider.js +25 -0
- package/dist/lib/mcpToolRegistry.d.ts +18 -0
- package/dist/lib/mcpToolRegistry.js +107 -14
- package/dist/lib/memoryGraph.d.ts +1 -1
- package/dist/lib/memoryGraph.js +9 -3
- package/dist/lib/memoryProvider.d.ts +58 -0
- package/dist/lib/memoryProvider.js +175 -0
- package/dist/lib/memoryStatus.d.ts +17 -0
- package/dist/lib/memoryStatus.js +88 -0
- package/dist/lib/nativeMemory.d.ts +19 -0
- package/dist/lib/nativeMemory.js +129 -0
- package/dist/lib/runtimeControlProvider.d.ts +16 -0
- package/dist/lib/runtimeControlProvider.js +168 -0
- package/dist/lib/secretEnv.d.ts +2 -1
- package/dist/lib/secretEnv.js +6 -2
- package/dist/lib/skillProvider.d.ts +13 -0
- package/dist/lib/skillProvider.js +210 -0
- package/dist/lib/taskProvider.d.ts +7 -0
- package/dist/lib/taskProvider.js +43 -0
- package/dist/lib/taskStore.d.ts +46 -0
- package/dist/lib/taskStore.js +156 -0
- package/dist/lib/terminalPicker.d.ts +23 -49
- package/dist/lib/terminalPicker.js +53 -218
- package/dist/pi-bridge/bundle.js +475 -37
- package/dist/pi-bridge/index.js +42 -21
- package/dist/pi-bridge/schemaTranslate.d.ts +4 -8
- package/dist/pi-bridge/schemaTranslate.js +4 -8
- package/dist/probes/kimi-code.d.ts +12 -0
- package/dist/probes/kimi-code.js +55 -0
- package/dist/sdk.d.ts +1 -1
- package/docs/architecture.md +224 -26
- package/docs/assets/agent-trellis-architecture-horizontal.svg +80 -0
- package/docs/assets/agent-trellis-architecture-vertical.svg +82 -0
- package/docs/assets/trellis-mark.svg +12 -0
- package/docs/getting-started.md +328 -35
- package/docs/overview.md +131 -0
- package/docs/overview.zh-CN.md +100 -0
- package/docs/research.md +59 -0
- package/docs/roadmap.md +9 -5
- package/package.json +3 -2
- package/schema/builtin-skills/trellis-runtime/SKILL.md +117 -0
- package/schema/capability-selection.example.yaml +23 -0
- package/schema/scope.example.yaml +1 -1
- package/schema/servers.example.yaml +15 -1
|
@@ -14,17 +14,23 @@
|
|
|
14
14
|
* an in-process backend without this file, or any agent's config,
|
|
15
15
|
* changing.
|
|
16
16
|
*/
|
|
17
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
17
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
19
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
20
18
|
import { homedir } from "node:os";
|
|
21
19
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
22
|
-
import { resolveMcpPlan } from "../adapters/mcpPlan.js";
|
|
20
|
+
import { isGatewayAgent, isOAuthMcpServer, resolveMcpPlan } from "../adapters/mcpPlan.js";
|
|
23
21
|
import { LocalBackend } from "../lib/gatewayBackend.js";
|
|
24
22
|
import { DEFAULT_CONNECT_TIMEOUT_MS } from "../lib/mcpConnect.js";
|
|
23
|
+
import { BuiltinRegistry, createRuntimeServer, UpstreamProvider } from "../lib/mcpRuntime.js";
|
|
24
|
+
import { McpStatusProvider } from "../lib/mcpStatusProvider.js";
|
|
25
|
+
import { RuntimeControlProvider } from "../lib/runtimeControlProvider.js";
|
|
26
|
+
import { TaskProvider } from "../lib/taskProvider.js";
|
|
27
|
+
import { RuntimeMemoryProvider } from "../lib/memoryProvider.js";
|
|
28
|
+
import { SkillProvider } from "../lib/skillProvider.js";
|
|
25
29
|
import { ALL_AGENTS } from "../core/types.js";
|
|
26
30
|
const CLIENT_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
|
|
27
31
|
const SERVER_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
|
|
32
|
+
const RUNTIME_CLIENT_INFO = { name: "trellis-mcp-runtime", version: "0.0.0" };
|
|
33
|
+
const RUNTIME_SERVER_INFO = { name: "trellis-mcp-runtime", version: "0.0.0" };
|
|
28
34
|
export function parseMcpGatewayArgs(argv) {
|
|
29
35
|
const index = argv.indexOf("--agent");
|
|
30
36
|
if (index === -1 || index === argv.length - 1) {
|
|
@@ -50,7 +56,16 @@ export function parseMcpGatewayArgs(argv) {
|
|
|
50
56
|
* canonical gives the set gateway mode replaced.
|
|
51
57
|
*/
|
|
52
58
|
export function resolveGatewayUpstreams(agentId, canonical) {
|
|
53
|
-
const
|
|
59
|
+
const route = canonical.mcp.routes?.[agentId];
|
|
60
|
+
const hubActive = route ? route.mode === "hub" : Boolean(canonical.mcp.hub) && !isGatewayAgent(agentId, canonical.mcp, canonical.managedAgents);
|
|
61
|
+
if (hubActive) {
|
|
62
|
+
return { upstreams: [], conflicts: [] };
|
|
63
|
+
}
|
|
64
|
+
const directRoutes = canonical.mcp.routes && route
|
|
65
|
+
? { ...canonical.mcp.routes, [agentId]: { mode: "direct", ...(route.servers ? { servers: route.servers } : {}) } }
|
|
66
|
+
: canonical.mcp.routes;
|
|
67
|
+
const ordinaryServers = Object.fromEntries(Object.entries(canonical.mcp.servers).filter(([, def]) => !isOAuthMcpServer(def)));
|
|
68
|
+
const direct = { ...canonical.mcp, servers: ordinaryServers, gateway: undefined, hub: undefined, routes: directRoutes, runtime: undefined };
|
|
54
69
|
// The managed set is passed through unchanged, so an unmanaged agent
|
|
55
70
|
// reaches nothing. Being spawned is NOT treated as consent here, which
|
|
56
71
|
// is a deliberate divergence from `src/pi-bridge/index.ts` (which passes
|
|
@@ -69,6 +84,15 @@ export function resolveGatewayUpstreams(agentId, canonical) {
|
|
|
69
84
|
};
|
|
70
85
|
}
|
|
71
86
|
export async function runMcpGateway(opts) {
|
|
87
|
+
return runMcpEdge(opts, CLIENT_INFO, SERVER_INFO);
|
|
88
|
+
}
|
|
89
|
+
/** The first-class runtime entrypoint. mcp-gateway remains a compatibility
|
|
90
|
+
* alias for existing native agent config, but both edges use the same
|
|
91
|
+
* provider registry and backend construction path. */
|
|
92
|
+
export async function runMcpRuntime(opts) {
|
|
93
|
+
return runMcpEdge(opts, RUNTIME_CLIENT_INFO, RUNTIME_SERVER_INFO);
|
|
94
|
+
}
|
|
95
|
+
async function runMcpEdge(opts, clientInfo, serverInfo) {
|
|
72
96
|
const homeDir = opts.homeDir ?? homedir();
|
|
73
97
|
const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
74
98
|
let canonical;
|
|
@@ -90,22 +114,18 @@ export async function runMcpGateway(opts) {
|
|
|
90
114
|
? await opts.backendFactory(upstreams, homeDir)
|
|
91
115
|
: await LocalBackend.connect(upstreams, {
|
|
92
116
|
secretsPolicy: canonical.secretsPolicy,
|
|
93
|
-
clientInfo
|
|
117
|
+
clientInfo,
|
|
94
118
|
connectTimeoutMs,
|
|
95
119
|
// Enables silent OAuth refresh-before-connect. Never an
|
|
96
120
|
// interactive grant: that is `trellis mcp auth`'s job, and this
|
|
97
121
|
// process has no terminal to run one in (design.md D9).
|
|
98
122
|
homeDir,
|
|
99
123
|
});
|
|
100
|
-
const
|
|
101
|
-
server
|
|
102
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
103
|
-
const result = await backend.callTool(request.params.name, request.params.arguments);
|
|
104
|
-
return result;
|
|
105
|
-
});
|
|
124
|
+
const registry = new BuiltinRegistry([new SkillProvider(), new RuntimeMemoryProvider(), new RuntimeControlProvider(backend), new McpStatusProvider(backend), new TaskProvider(), new UpstreamProvider(backend)]);
|
|
125
|
+
const server = createRuntimeServer(serverInfo, { agentId: opts.agentId, homeDir }, registry);
|
|
106
126
|
const transport = new StdioServerTransport();
|
|
107
127
|
await server.connect(transport);
|
|
108
|
-
await waitForShutdown(
|
|
128
|
+
await waitForShutdown(registry, server);
|
|
109
129
|
return { exitCode: 0 };
|
|
110
130
|
}
|
|
111
131
|
/**
|
|
@@ -118,7 +138,7 @@ export async function runMcpGateway(opts) {
|
|
|
118
138
|
* Without this, each agent session would permanently leak a gateway plus
|
|
119
139
|
* its entire upstream process set (design.md D13).
|
|
120
140
|
*/
|
|
121
|
-
function waitForShutdown(
|
|
141
|
+
function waitForShutdown(registry, server) {
|
|
122
142
|
return new Promise((resolve) => {
|
|
123
143
|
let settled = false;
|
|
124
144
|
const shutdown = async () => {
|
|
@@ -126,7 +146,7 @@ function waitForShutdown(backend, server) {
|
|
|
126
146
|
return;
|
|
127
147
|
settled = true;
|
|
128
148
|
try {
|
|
129
|
-
await
|
|
149
|
+
await registry.close();
|
|
130
150
|
}
|
|
131
151
|
catch (err) {
|
|
132
152
|
console.error(`trellis-mcp-gateway: failed to close backend: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Safe import of standard JSON `mcpServers` exports.
|
|
2
|
+
*
|
|
3
|
+
* This is deliberately separate from `mcp add`: an export may contain real
|
|
4
|
+
* credentials, multiple differently-named copies of one server, and shapes
|
|
5
|
+
* that cannot be expressed safely for every native Agent. The source file is
|
|
6
|
+
* read-only; plans never carry secret values.
|
|
7
|
+
*/
|
|
8
|
+
import { type BackupSession } from "../lib/backup.js";
|
|
9
|
+
export type McpImportAction = "create" | "already-present" | "duplicate" | "skipped" | "conflict" | "invalid-input";
|
|
10
|
+
export interface McpImportItem {
|
|
11
|
+
name: string;
|
|
12
|
+
action: McpImportAction;
|
|
13
|
+
detail: string;
|
|
14
|
+
}
|
|
15
|
+
export interface McpImportPlan {
|
|
16
|
+
source: string;
|
|
17
|
+
items: McpImportItem[];
|
|
18
|
+
}
|
|
19
|
+
export declare function collectMcpImportPlan(path: string, homeDir?: string): McpImportPlan;
|
|
20
|
+
export declare function applyMcpImportPlan(path: string, homeDir?: string, backup?: BackupSession): McpImportPlan;
|
|
21
|
+
export declare function runMcpImport(path: string | undefined, opts?: {
|
|
22
|
+
homeDir?: string;
|
|
23
|
+
json?: boolean;
|
|
24
|
+
dryRun?: boolean;
|
|
25
|
+
}): {
|
|
26
|
+
exitCode: number;
|
|
27
|
+
};
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/** Safe import of standard JSON `mcpServers` exports.
|
|
2
|
+
*
|
|
3
|
+
* This is deliberately separate from `mcp add`: an export may contain real
|
|
4
|
+
* credentials, multiple differently-named copies of one server, and shapes
|
|
5
|
+
* that cannot be expressed safely for every native Agent. The source file is
|
|
6
|
+
* read-only; plans never carry secret values.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { loadCanonicalSource, ensureGitignoreEntry, ensureShellEnvSource, upsertServerYaml, writeSecretsPolicyExtraction } from "../core/canonical.js";
|
|
12
|
+
import { openBackupSession } from "../lib/backup.js";
|
|
13
|
+
import { parseDotenv, writeLocalSecretValue } from "../lib/secretEnv.js";
|
|
14
|
+
const DEFAULT_ENV_FILE_TILDE = "~/.trellis/mcp/servers.local.env";
|
|
15
|
+
class SkipImport extends Error {
|
|
16
|
+
}
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function stringValue(value, field) {
|
|
21
|
+
if (value === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (typeof value !== "string" || value.length === 0)
|
|
24
|
+
throw new Error(`${field} must be a non-empty string`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function stringArray(value, field) {
|
|
28
|
+
if (value === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string"))
|
|
31
|
+
throw new Error(`${field} must be an array of strings`);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function looksLikeCredentialKey(key) {
|
|
35
|
+
return /(token|secret|password|authorization|api.?key|bearer|credential|private.?key|headers?)/i.test(key);
|
|
36
|
+
}
|
|
37
|
+
function looksLikeCredentialValue(value) {
|
|
38
|
+
return /(mcpr_|ghp_|glpat-|sk-[A-Za-z0-9]{8,}|basic\s+[A-Za-z0-9+/=]{12,}|bearer\s+\S+)/i.test(value);
|
|
39
|
+
}
|
|
40
|
+
function exactReference(value) {
|
|
41
|
+
const match = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(value);
|
|
42
|
+
return match?.[1];
|
|
43
|
+
}
|
|
44
|
+
function sanitizeEnvPart(value) {
|
|
45
|
+
return value.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "X";
|
|
46
|
+
}
|
|
47
|
+
function generatedSecretName(server, key) {
|
|
48
|
+
return `TRELLIS_${sanitizeEnvPart(server)}_${sanitizeEnvPart(key)}`;
|
|
49
|
+
}
|
|
50
|
+
function defaultShellRcPath(homeDir) {
|
|
51
|
+
const shell = process.env.SHELL ?? "";
|
|
52
|
+
if (shell.includes("zsh"))
|
|
53
|
+
return join(homeDir, ".zshrc");
|
|
54
|
+
if (shell.includes("bash"))
|
|
55
|
+
return join(homeDir, ".bash_profile");
|
|
56
|
+
return join(homeDir, ".profile");
|
|
57
|
+
}
|
|
58
|
+
function inferTransport(raw) {
|
|
59
|
+
const value = raw.transport ?? raw.type;
|
|
60
|
+
if (value === "http" || value === "sse" || value === "stdio")
|
|
61
|
+
return value;
|
|
62
|
+
if (raw.url !== undefined)
|
|
63
|
+
return "http";
|
|
64
|
+
return "stdio";
|
|
65
|
+
}
|
|
66
|
+
function sorted(values) {
|
|
67
|
+
return [...values].sort();
|
|
68
|
+
}
|
|
69
|
+
function identityArgs(def) {
|
|
70
|
+
if (!def.args)
|
|
71
|
+
return undefined;
|
|
72
|
+
if (def.command !== "npx")
|
|
73
|
+
return def.args;
|
|
74
|
+
// MCP exports often omit npx's non-interactive confirmation flag while the
|
|
75
|
+
// canonical adapter adds it. It changes prompting, not server identity.
|
|
76
|
+
const normalized = def.args.filter((arg) => arg !== "-y" && arg !== "--yes");
|
|
77
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
78
|
+
}
|
|
79
|
+
function normalizeMcpRemote(serverName, args, secrets) {
|
|
80
|
+
const remoteIndex = args.indexOf("mcp-remote");
|
|
81
|
+
if (remoteIndex < 0)
|
|
82
|
+
return undefined;
|
|
83
|
+
const url = args[remoteIndex + 1];
|
|
84
|
+
if (!url || !/^https?:\/\//.test(url))
|
|
85
|
+
throw new Error(`MCP server "${serverName}" has an invalid mcp-remote URL`);
|
|
86
|
+
const headerIndex = args.indexOf("--header", remoteIndex + 2);
|
|
87
|
+
const allowed = new Set(["-y", "--yes", "mcp-remote", url, "--header", ...(headerIndex >= 0 && args[headerIndex + 1] ? [args[headerIndex + 1]] : [])]);
|
|
88
|
+
if (args.some((arg) => !allowed.has(arg)))
|
|
89
|
+
throw new Error(`MCP server "${serverName}" has unsupported mcp-remote arguments; import it manually after reviewing the shape`);
|
|
90
|
+
if (headerIndex < 0)
|
|
91
|
+
return { transport: "http", url };
|
|
92
|
+
const header = args[headerIndex + 1];
|
|
93
|
+
const match = /^Authorization:\s*Basic\s+(.+)$/i.exec(header ?? "");
|
|
94
|
+
if (!match)
|
|
95
|
+
throw new Error(`MCP server "${serverName}" has an unsupported mcp-remote header; only Basic Authorization can be migrated safely`);
|
|
96
|
+
const rawCredential = match[1];
|
|
97
|
+
const reference = exactReference(rawCredential);
|
|
98
|
+
const sourceName = generatedSecretName(serverName, "AUTHORIZATION");
|
|
99
|
+
if (reference) {
|
|
100
|
+
return { transport: "http", url, headers: { Authorization: `Basic \${${reference}}` } };
|
|
101
|
+
}
|
|
102
|
+
secrets.push({ name: sourceName, value: rawCredential });
|
|
103
|
+
return { transport: "http", url, headers: { Authorization: `Basic \${${sourceName}}` } };
|
|
104
|
+
}
|
|
105
|
+
/** Identity deliberately ignores secret values and generated source names but
|
|
106
|
+
* retains target env/header keys, so notion and notionApi do not collapse. */
|
|
107
|
+
function semanticIdentity(def) {
|
|
108
|
+
return JSON.stringify({
|
|
109
|
+
transport: def.transport,
|
|
110
|
+
auth: def.auth,
|
|
111
|
+
command: def.command,
|
|
112
|
+
args: identityArgs(def),
|
|
113
|
+
url: def.url,
|
|
114
|
+
enabled: def.enabled,
|
|
115
|
+
agents: def.agents ? sorted(def.agents) : undefined,
|
|
116
|
+
staticEnv: def.staticEnv,
|
|
117
|
+
envKeys: sorted([...(def.env ?? []), ...Object.keys(def.envAliases ?? {})]),
|
|
118
|
+
headerKeys: sorted(Object.keys(def.headers ?? {})),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function normalizeEntry(serverName, raw, secrets) {
|
|
122
|
+
if (!isRecord(raw))
|
|
123
|
+
throw new Error(`MCP server "${serverName}" must be an object`);
|
|
124
|
+
const transport = inferTransport(raw);
|
|
125
|
+
const command = stringValue(raw.command, `${serverName}.command`);
|
|
126
|
+
const parsedArgs = stringArray(raw.args, `${serverName}.args`);
|
|
127
|
+
const args = parsedArgs && parsedArgs.length > 0 ? parsedArgs : undefined;
|
|
128
|
+
const url = stringValue(raw.url, `${serverName}.url`);
|
|
129
|
+
if (transport === "stdio" && !command)
|
|
130
|
+
throw new Error(`MCP server "${serverName}" is stdio but has no command`);
|
|
131
|
+
if ((transport === "http" || transport === "sse") && !url)
|
|
132
|
+
throw new Error(`MCP server "${serverName}" is ${transport} but has no url`);
|
|
133
|
+
if (command === "npx" && args) {
|
|
134
|
+
const remote = normalizeMcpRemote(serverName, args, secrets);
|
|
135
|
+
if (remote) {
|
|
136
|
+
if (raw.env !== undefined || raw.headers !== undefined) {
|
|
137
|
+
throw new Error(`MCP server "${serverName}" combines mcp-remote arguments with env/headers fields; import it manually after reviewing the merge`);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
...remote,
|
|
141
|
+
...(raw.auth === "oauth" ? { auth: "oauth" } : {}),
|
|
142
|
+
...(raw.disabled === true || raw.enabled === false ? { enabled: false } : {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const env = [];
|
|
147
|
+
const envAliases = {};
|
|
148
|
+
const staticEnv = {};
|
|
149
|
+
if (raw.env !== undefined) {
|
|
150
|
+
if (!isRecord(raw.env))
|
|
151
|
+
throw new Error(`${serverName}.env must be an object`);
|
|
152
|
+
for (const [targetKey, rawValue] of Object.entries(raw.env)) {
|
|
153
|
+
const value = stringValue(rawValue, `${serverName}.env.${targetKey}`);
|
|
154
|
+
const reference = exactReference(value);
|
|
155
|
+
if (reference) {
|
|
156
|
+
if (reference === targetKey)
|
|
157
|
+
env.push(reference);
|
|
158
|
+
else
|
|
159
|
+
envAliases[targetKey] = reference;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (looksLikeCredentialKey(targetKey) || looksLikeCredentialValue(value)) {
|
|
163
|
+
const sourceName = generatedSecretName(serverName, targetKey);
|
|
164
|
+
envAliases[targetKey] = sourceName;
|
|
165
|
+
secrets.push({ name: sourceName, value });
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
staticEnv[targetKey] = value;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const headers = {};
|
|
173
|
+
if (raw.headers !== undefined) {
|
|
174
|
+
if (!isRecord(raw.headers))
|
|
175
|
+
throw new Error(`${serverName}.headers must be an object`);
|
|
176
|
+
for (const [key, rawValue] of Object.entries(raw.headers)) {
|
|
177
|
+
const value = stringValue(rawValue, `${serverName}.headers.${key}`);
|
|
178
|
+
const reference = exactReference(value);
|
|
179
|
+
if (reference) {
|
|
180
|
+
headers[key] = `\${${reference}}`;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
throw new Error(`MCP server "${serverName}" has a literal header "${key}"; move it to an environment variable before importing`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const inlineText = [command, ...(args ?? []), url].filter(Boolean).join(" ");
|
|
187
|
+
if (looksLikeCredentialValue(inlineText)) {
|
|
188
|
+
throw new Error(`MCP server "${serverName}" has a credential embedded in command, args, or url; importer will not store it in canonical`);
|
|
189
|
+
}
|
|
190
|
+
for (const arg of args ?? []) {
|
|
191
|
+
if (/^(\/Users\/|\/home\/|[A-Za-z]:[\\/])/.test(arg) && !existsSync(arg)) {
|
|
192
|
+
throw new SkipImport(`MCP server "${serverName}" references missing absolute path "${arg}"; skipped as unavailable on this machine`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const disabled = raw.disabled === true || raw.enabled === false;
|
|
196
|
+
const auth = raw.auth === "oauth" ? "oauth" : undefined;
|
|
197
|
+
return {
|
|
198
|
+
transport,
|
|
199
|
+
...(auth ? { auth } : {}),
|
|
200
|
+
...(command ? { command } : {}),
|
|
201
|
+
...(args ? { args } : {}),
|
|
202
|
+
...(url ? { url } : {}),
|
|
203
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
204
|
+
...(env.length > 0 ? { env } : {}),
|
|
205
|
+
...(Object.keys(envAliases).length > 0 ? { envAliases } : {}),
|
|
206
|
+
...(Object.keys(staticEnv).length > 0 ? { staticEnv } : {}),
|
|
207
|
+
...(disabled ? { enabled: false } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function readExport(path) {
|
|
211
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
212
|
+
if (!isRecord(parsed.mcpServers))
|
|
213
|
+
throw new Error(`MCP import requires a top-level "mcpServers" object in ${path}`);
|
|
214
|
+
return parsed.mcpServers;
|
|
215
|
+
}
|
|
216
|
+
function publicPlan(plan) {
|
|
217
|
+
return { source: plan.source, items: plan.items };
|
|
218
|
+
}
|
|
219
|
+
export function collectMcpImportPlan(path, homeDir = homedir()) {
|
|
220
|
+
return publicPlan(collectInternalPlan(path, homeDir));
|
|
221
|
+
}
|
|
222
|
+
function collectInternalPlan(path, homeDir) {
|
|
223
|
+
if (!existsSync(path))
|
|
224
|
+
throw new Error(`MCP import file does not exist: ${path}`);
|
|
225
|
+
const source = readExport(path);
|
|
226
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
227
|
+
const existingByIdentity = new Map(Object.entries(canonical.mcp.servers).map(([name, def]) => [semanticIdentity(def), name]));
|
|
228
|
+
const additions = [];
|
|
229
|
+
const secrets = [];
|
|
230
|
+
const items = [];
|
|
231
|
+
const envFilePath = canonical.secretsPolicy.envFile ?? join(homeDir, ".trellis", "mcp", "servers.local.env");
|
|
232
|
+
const existingSecrets = existsSync(envFilePath) ? parseDotenv(readFileSync(envFilePath, "utf8")) : {};
|
|
233
|
+
for (const [name, raw] of Object.entries(source)) {
|
|
234
|
+
let def;
|
|
235
|
+
const beforeSecretCount = secrets.length;
|
|
236
|
+
try {
|
|
237
|
+
def = normalizeEntry(name, raw, secrets);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
secrets.splice(beforeSecretCount);
|
|
241
|
+
if (err instanceof SkipImport) {
|
|
242
|
+
items.push({ name, action: "skipped", detail: err.message });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
items.push({ name, action: "conflict", detail: err instanceof Error ? err.message : String(err) });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const identity = semanticIdentity(def);
|
|
249
|
+
const existing = canonical.mcp.servers[name];
|
|
250
|
+
if (existing) {
|
|
251
|
+
secrets.splice(beforeSecretCount);
|
|
252
|
+
items.push(existing && semanticIdentity(existing) === identity
|
|
253
|
+
? { name, action: "already-present", detail: "canonical entry is already semantically identical; existing credentials are preserved" }
|
|
254
|
+
: { name, action: "conflict", detail: `servers.yaml already has "${name}" with different non-secret settings — left unchanged` });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const duplicate = existingByIdentity.get(identity) ?? additions.find((item) => semanticIdentity(item.def) === identity)?.name;
|
|
258
|
+
if (duplicate) {
|
|
259
|
+
secrets.splice(beforeSecretCount);
|
|
260
|
+
items.push({ name, action: "duplicate", detail: `same non-secret MCP definition is already represented by "${duplicate}" — not added` });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const newSecrets = secrets.slice(beforeSecretCount);
|
|
264
|
+
const secretConflict = newSecrets.find((secret) => Object.hasOwn(existingSecrets, secret.name) && existingSecrets[secret.name] !== secret.value);
|
|
265
|
+
if (secretConflict) {
|
|
266
|
+
secrets.splice(beforeSecretCount);
|
|
267
|
+
items.push({ name, action: "conflict", detail: `local secret variable "${secretConflict.name}" already has a different value — not overwritten` });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
additions.push({ name, def });
|
|
271
|
+
items.push({ name, action: "create", detail: `will add to canonical and extract ${newSecrets.length} credential value(s) to the ignored local secrets file` });
|
|
272
|
+
}
|
|
273
|
+
return { source: path, items, additions, secrets, envFilePath, envFileIsDefault: !canonical.secretsPolicy.envFile };
|
|
274
|
+
}
|
|
275
|
+
function applySecrets(plan, homeDir, backup) {
|
|
276
|
+
if (plan.secrets.length === 0)
|
|
277
|
+
return;
|
|
278
|
+
if (plan.envFileIsDefault)
|
|
279
|
+
ensureGitignoreEntry(join(homeDir, ".trellis", ".gitignore"), "mcp/servers.local.env", backup);
|
|
280
|
+
for (const secret of plan.secrets) {
|
|
281
|
+
const secretResult = writeLocalSecretValue(plan.envFilePath, secret.name, secret.value, backup);
|
|
282
|
+
if (secretResult === "conflict")
|
|
283
|
+
throw new Error(`local secret variable "${secret.name}" changed during import — refusing to overwrite it`);
|
|
284
|
+
const policyResult = writeSecretsPolicyExtraction(join(homeDir, ".trellis", "secrets.policy.yaml"), { varName: secret.name, envFilePath: DEFAULT_ENV_FILE_TILDE }, backup);
|
|
285
|
+
if (!policyResult.ok)
|
|
286
|
+
throw new Error(policyResult.error);
|
|
287
|
+
}
|
|
288
|
+
ensureShellEnvSource(defaultShellRcPath(homeDir), plan.envFilePath, backup);
|
|
289
|
+
}
|
|
290
|
+
export function applyMcpImportPlan(path, homeDir = homedir(), backup) {
|
|
291
|
+
const plan = collectInternalPlan(path, homeDir);
|
|
292
|
+
const ownSession = backup ?? openBackupSession(homeDir, "mcp-import");
|
|
293
|
+
try {
|
|
294
|
+
for (const addition of plan.additions) {
|
|
295
|
+
const result = upsertServerYaml(join(homeDir, ".trellis", "mcp", "servers.yaml"), addition.name, addition.def, ownSession);
|
|
296
|
+
if (!result.ok)
|
|
297
|
+
throw new Error(result.error);
|
|
298
|
+
}
|
|
299
|
+
applySecrets(plan, homeDir, ownSession);
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
// Keep the manifest even on failure so the caller can use the normal
|
|
303
|
+
// `trellis rollback` path to undo writes that happened before the error.
|
|
304
|
+
if (!backup)
|
|
305
|
+
ownSession.finalize();
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
if (!backup)
|
|
309
|
+
ownSession.finalize();
|
|
310
|
+
return publicPlan(plan);
|
|
311
|
+
}
|
|
312
|
+
export function runMcpImport(path, opts = {}) {
|
|
313
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
314
|
+
if (!path) {
|
|
315
|
+
console.error("Usage: trellis mcp import <json-file> [--dry-run]");
|
|
316
|
+
return { exitCode: 1 };
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const internal = collectInternalPlan(path, homeDir);
|
|
320
|
+
if (!opts.dryRun)
|
|
321
|
+
applyMcpImportPlan(path, homeDir);
|
|
322
|
+
const result = publicPlan(internal);
|
|
323
|
+
if (opts.json)
|
|
324
|
+
console.log(JSON.stringify(result, null, 2));
|
|
325
|
+
else {
|
|
326
|
+
console.log(`${opts.dryRun ? "[dry run] " : ""}mcp import ${path}`);
|
|
327
|
+
for (const item of result.items)
|
|
328
|
+
console.log(` [${item.action}] ${item.name} — ${item.detail}`);
|
|
329
|
+
}
|
|
330
|
+
return { exitCode: result.items.some((item) => item.action === "conflict" || item.action === "invalid-input") ? 1 : 0 };
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
334
|
+
return { exitCode: 1 };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* server process — both are plain file operations, same posture as
|
|
13
13
|
* every other Trellis write.
|
|
14
14
|
*/
|
|
15
|
-
import type { McpServerDef } from "../core/types.js";
|
|
15
|
+
import type { McpConfig, McpServerDef } from "../core/types.js";
|
|
16
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
16
17
|
import { type MemoryExtractionPlan, type MemorySyncPlan } from "../lib/memoryGraph.js";
|
|
17
18
|
/** Exported so `onboard` (`trellis onboard --memory on|off`,
|
|
18
19
|
* trellis-onboard-mcp-mode) can look up and write this same entry
|
|
@@ -32,6 +33,7 @@ export interface RunMemorySyncOptions {
|
|
|
32
33
|
homeDir?: string;
|
|
33
34
|
json?: boolean;
|
|
34
35
|
dryRun?: boolean;
|
|
36
|
+
selectedMemoryNames?: readonly string[];
|
|
35
37
|
}
|
|
36
38
|
export type MemorySyncResult = {
|
|
37
39
|
configured: true;
|
|
@@ -41,8 +43,8 @@ export type MemorySyncResult = {
|
|
|
41
43
|
configured: false;
|
|
42
44
|
reason: string;
|
|
43
45
|
};
|
|
44
|
-
export declare function collectMemorySyncResult(homeDir?: string): MemorySyncResult;
|
|
45
|
-
export declare function applyMemorySync(result: MemorySyncResult): void;
|
|
46
|
+
export declare function collectMemorySyncResult(homeDir?: string, selectedMemoryNames?: readonly string[], mcpOverride?: McpConfig): MemorySyncResult;
|
|
47
|
+
export declare function applyMemorySync(result: MemorySyncResult, backup?: BackupSession): void;
|
|
46
48
|
/**
|
|
47
49
|
* Extracted from `runMemorySync` (trellis-onboard-mcp-memory) so
|
|
48
50
|
* `onboard`'s own chained output can print this stage in the exact same
|
package/dist/commands/memory.js
CHANGED
|
@@ -50,9 +50,9 @@ export const DEFAULT_MEMORY_SERVER_DEF = {
|
|
|
50
50
|
* about which graph file they're reading/writing
|
|
51
51
|
* (trellis-memory-extraction).
|
|
52
52
|
*/
|
|
53
|
-
function resolveMemoryServerGraphPath(homeDir) {
|
|
53
|
+
function resolveMemoryServerGraphPath(homeDir, mcpOverride) {
|
|
54
54
|
const canonical = loadCanonicalSource(homeDir);
|
|
55
|
-
const server = canonical.mcp.servers[MEMORY_SERVER_NAME];
|
|
55
|
+
const server = (mcpOverride ?? canonical.mcp).servers[MEMORY_SERVER_NAME];
|
|
56
56
|
const rawPath = server?.staticEnv?.MEMORY_FILE_PATH;
|
|
57
57
|
if (!server || !rawPath) {
|
|
58
58
|
return {
|
|
@@ -62,20 +62,24 @@ function resolveMemoryServerGraphPath(homeDir) {
|
|
|
62
62
|
}
|
|
63
63
|
return { configured: true, graphPath: rawPath.replace(/^~(?=$|\/)/, homeDir) };
|
|
64
64
|
}
|
|
65
|
-
export function collectMemorySyncResult(homeDir = homedir()) {
|
|
66
|
-
const lookup = resolveMemoryServerGraphPath(homeDir);
|
|
65
|
+
export function collectMemorySyncResult(homeDir = homedir(), selectedMemoryNames, mcpOverride) {
|
|
66
|
+
const lookup = resolveMemoryServerGraphPath(homeDir, mcpOverride);
|
|
67
67
|
if (!lookup.configured)
|
|
68
68
|
return lookup;
|
|
69
69
|
const canonical = loadCanonicalSource(homeDir);
|
|
70
70
|
const currentContent = existsSync(lookup.graphPath) ? readFileSync(lookup.graphPath, "utf-8") : undefined;
|
|
71
|
-
const plan = planMemorySync(canonical, currentContent);
|
|
71
|
+
const plan = planMemorySync(canonical, currentContent, selectedMemoryNames);
|
|
72
72
|
return { configured: true, graphPath: lookup.graphPath, plan };
|
|
73
73
|
}
|
|
74
|
-
export function applyMemorySync(result) {
|
|
74
|
+
export function applyMemorySync(result, backup) {
|
|
75
75
|
if (!result.configured || !result.plan.nextGraph)
|
|
76
76
|
return;
|
|
77
77
|
mkdirSync(dirname(result.graphPath), { recursive: true });
|
|
78
|
-
|
|
78
|
+
const content = renderMemoryGraph(result.plan.nextGraph);
|
|
79
|
+
if (backup)
|
|
80
|
+
backup.writeFile(result.graphPath, content);
|
|
81
|
+
else
|
|
82
|
+
writeFileSync(result.graphPath, content);
|
|
79
83
|
}
|
|
80
84
|
/**
|
|
81
85
|
* Extracted from `runMemorySync` (trellis-onboard-mcp-memory) so
|
|
@@ -101,7 +105,7 @@ export function runMemorySync(opts = {}) {
|
|
|
101
105
|
const homeDir = opts.homeDir ?? homedir();
|
|
102
106
|
let result;
|
|
103
107
|
try {
|
|
104
|
-
result = collectMemorySyncResult(homeDir);
|
|
108
|
+
result = collectMemorySyncResult(homeDir, opts.selectedMemoryNames);
|
|
105
109
|
}
|
|
106
110
|
catch (err) {
|
|
107
111
|
console.error(err instanceof Error ? err.message : String(err));
|
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
* plan-then-apply split every adapter already uses.
|
|
6
6
|
*/
|
|
7
7
|
import type { AgentId, McpServerDef } from "../core/types.js";
|
|
8
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
9
|
+
import { type NativeMemoryCandidate, type NativeMemoryDiscovery } from "../lib/nativeMemory.js";
|
|
8
10
|
export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "reclassify" | "conflict" | "extract-secret";
|
|
9
11
|
/** Internal kind naming, unchanged since before `--only` existed
|
|
10
12
|
* (trellis-migrate-category-selection design.md D2) — the CLI-facing
|
|
11
13
|
* flag value is the plural `"skills"`, mapped to this singular `"skill"`
|
|
12
14
|
* in `runMigrate`, not renamed here to avoid touching every existing
|
|
13
15
|
* `MigratePlanItem.kind` comparison for no functional reason. */
|
|
14
|
-
export type MigrateKind = "skill" | "instructions" | "mcp";
|
|
16
|
+
export type MigrateKind = "skill" | "instructions" | "mcp" | "memory";
|
|
15
17
|
export interface MigratePlanItem {
|
|
16
18
|
kind: MigrateKind;
|
|
17
19
|
name: string;
|
|
@@ -45,11 +47,19 @@ export interface MigratePlanItem {
|
|
|
45
47
|
* already-migrated server extracted before this naming scheme existed,
|
|
46
48
|
* which never reaches this action at all (already-migrated instead). */
|
|
47
49
|
extractSourceKey?: string;
|
|
50
|
+
/** Memory migration only: source is re-read at apply time and the target
|
|
51
|
+
* path is canonical, so the plan never serializes the real body. */
|
|
52
|
+
memorySourcePath?: string;
|
|
53
|
+
memorySourceId?: string;
|
|
54
|
+
memoryTargetPath?: string;
|
|
48
55
|
}
|
|
49
56
|
export interface MigratePlan {
|
|
50
57
|
agent: AgentId;
|
|
51
58
|
present: boolean;
|
|
52
59
|
items: MigratePlanItem[];
|
|
60
|
+
memoryDiscovery?: Omit<NativeMemoryDiscovery, "candidates"> & {
|
|
61
|
+
candidates: Array<Omit<NativeMemoryCandidate, "content">>;
|
|
62
|
+
};
|
|
53
63
|
}
|
|
54
64
|
/**
|
|
55
65
|
* True when `existing` and `def` differ ONLY in which of `env`/
|
|
@@ -72,14 +82,15 @@ export declare function isSafeReclassification(existing: McpServerDef, def: McpS
|
|
|
72
82
|
* conflict. Omitting `only` (or passing both kinds) is exactly today's
|
|
73
83
|
* behavior.
|
|
74
84
|
*/
|
|
75
|
-
export declare function collectMigratePlan(agent: AgentId, homeDir?: string, only?: readonly MigrateKind[]): Promise<MigratePlan>;
|
|
76
|
-
export declare function applyMigratePlan(plan: MigratePlan, homeDir?: string): void;
|
|
85
|
+
export declare function collectMigratePlan(agent: AgentId, homeDir?: string, only?: readonly MigrateKind[], workspaceDir?: string): Promise<MigratePlan>;
|
|
86
|
+
export declare function applyMigratePlan(plan: MigratePlan, homeDir?: string, backup?: BackupSession): void;
|
|
77
87
|
/** CLI-facing spelling: `"skills"` (plural — a run usually touches more
|
|
78
|
-
* than one), `"instructions"` (already singular-shaped),
|
|
88
|
+
* than one), `"instructions"` (already singular-shaped), `"mcp"`, or
|
|
89
|
+
* `"memory"`
|
|
79
90
|
* (already the CLI's own convention, matching `trellis mcp`'s own
|
|
80
91
|
* command name). Mapped to `MigrateKind` in `runMigrate`, the one place
|
|
81
92
|
* this translation lives. */
|
|
82
|
-
export type MigrateOnlyValue = "skills" | "instructions" | "mcp";
|
|
93
|
+
export type MigrateOnlyValue = "skills" | "instructions" | "mcp" | "memory";
|
|
83
94
|
export interface RunMigrateOptions {
|
|
84
95
|
from?: string;
|
|
85
96
|
/** Restricts the run to one category (`"skills"`, `"instructions"`, or
|