agent-trellis 0.9.1 → 0.11.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/dist/adapters/mcpPlan.d.ts +5 -0
- package/dist/adapters/mcpPlan.js +7 -1
- package/dist/cli.js +39 -8
- package/dist/commands/mcp.d.ts +101 -2
- package/dist/commands/mcp.js +215 -14
- package/dist/commands/mcpAuth.js +28 -0
- package/dist/commands/skill.d.ts +45 -1
- package/dist/commands/skill.js +83 -2
- package/dist/core/canonical.d.ts +15 -12
- package/dist/core/canonical.js +86 -18
- package/dist/core/types.d.ts +28 -2
- package/dist/core/types.js +19 -0
- package/dist/lib/mcpRuntime.d.ts +6 -0
- package/dist/lib/mcpRuntime.js +52 -1
- package/dist/lib/oauth/flow.d.ts +13 -0
- package/dist/lib/oauth/flow.js +48 -3
- package/dist/lib/scopeSelection.d.ts +36 -0
- package/dist/lib/scopeSelection.js +65 -0
- package/dist/lib/secretEnv.d.ts +5 -0
- package/dist/lib/secretEnv.js +8 -0
- package/dist/lib/skillManifest.d.ts +65 -0
- package/dist/lib/skillManifest.js +203 -0
- package/dist/lib/skillProvider.d.ts +20 -0
- package/dist/lib/skillProvider.js +61 -1
- package/dist/pi-bridge/bundle.js +106 -48
- package/dist/pi-bridge/index.js +2 -2
- package/docs/architecture.md +19 -0
- package/docs/mcp-oauth-matrix.md +140 -0
- package/docs/research.md +11 -0
- package/docs/skills-over-mcp.md +79 -0
- package/package.json +2 -2
- package/schema/servers.example.yaml +18 -0
|
@@ -28,6 +28,11 @@ export declare const LEGACY_RUNTIME_ENTRY_NAME = "trellis-runtime";
|
|
|
28
28
|
* user installed the CLI onto. */
|
|
29
29
|
export declare const GATEWAY_COMMAND = "trellis";
|
|
30
30
|
export declare const RUNTIME_COMMAND = "trellis";
|
|
31
|
+
/** Either form of the OAuth classification counts — a strict equality
|
|
32
|
+
* check against the scalar would read the metadata-bearing object
|
|
33
|
+
* (`{ kind: oauth, client_id }`) as "not OAuth" and silently drop the
|
|
34
|
+
* routing it exists to carry (trellis-mcp-oauth-static-client design.md
|
|
35
|
+
* D1). */
|
|
31
36
|
export declare function isOAuthMcpServer(def: McpServerDef): boolean;
|
|
32
37
|
/**
|
|
33
38
|
* Gateway mode applies to every managed agent when `agents` is omitted —
|
package/dist/adapters/mcpPlan.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { isInScope } from "../core/adapter.js";
|
|
20
20
|
import { capabilityDeliveryForAgent } from "../core/types.js";
|
|
21
|
+
import { isOAuthAuth } from "../core/types.js";
|
|
21
22
|
import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
|
|
22
23
|
import { resolveSecretEnv } from "../lib/secretEnv.js";
|
|
23
24
|
export const HUB_ENTRY_NAME = "trellis-hub";
|
|
@@ -31,8 +32,13 @@ export const LEGACY_RUNTIME_ENTRY_NAME = "trellis-runtime";
|
|
|
31
32
|
* user installed the CLI onto. */
|
|
32
33
|
export const GATEWAY_COMMAND = "trellis";
|
|
33
34
|
export const RUNTIME_COMMAND = "trellis";
|
|
35
|
+
/** Either form of the OAuth classification counts — a strict equality
|
|
36
|
+
* check against the scalar would read the metadata-bearing object
|
|
37
|
+
* (`{ kind: oauth, client_id }`) as "not OAuth" and silently drop the
|
|
38
|
+
* routing it exists to carry (trellis-mcp-oauth-static-client design.md
|
|
39
|
+
* D1). */
|
|
34
40
|
export function isOAuthMcpServer(def) {
|
|
35
|
-
return def.auth
|
|
41
|
+
return isOAuthAuth(def.auth);
|
|
36
42
|
}
|
|
37
43
|
/**
|
|
38
44
|
* Gateway mode applies to every managed agent when `agents` is omitted —
|
package/dist/cli.js
CHANGED
|
@@ -9,13 +9,14 @@ import { runInit } from "./commands/init.js";
|
|
|
9
9
|
import { runMigrate } from "./commands/migrate.js";
|
|
10
10
|
import { runOnboard } from "./commands/onboard.js";
|
|
11
11
|
import { runSync } from "./commands/sync.js";
|
|
12
|
-
import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, runMcpSetAuth, parseMcpAddArgs } from "./commands/mcp.js";
|
|
12
|
+
import { runMcpSync, runMcpList, runMcpAdd, runMcpRemove, runMcpSetAuth, runMcpScope, parseMcpAddArgs, parseMcpSetArgs } from "./commands/mcp.js";
|
|
13
|
+
import { parseScopeArgs } from "./lib/scopeSelection.js";
|
|
13
14
|
import { runMcpImport } from "./commands/mcpImport.js";
|
|
14
15
|
import { parseMcpGatewayArgs, runMcpGateway, runMcpRuntime } from "./commands/mcpGateway.js";
|
|
15
16
|
import { runMcpAuth } from "./commands/mcpAuth.js";
|
|
16
17
|
import { runSecretsAudit } from "./commands/secretsAudit.js";
|
|
17
18
|
import { runRollback } from "./commands/rollback.js";
|
|
18
|
-
import { runSkillList, runSkillAdd, runSkillRemove, runSkillUpdateBuiltin } from "./commands/skill.js";
|
|
19
|
+
import { runSkillList, runSkillAdd, runSkillRemove, runSkillScope, runSkillUpdateBuiltin } from "./commands/skill.js";
|
|
19
20
|
import { runRemoteSkillAdd, runRemoteSkillList, runRemoteSkillUpdate } from "./commands/remoteSkill.js";
|
|
20
21
|
import { collectMemoryList, runMemoryExtraction, runMemorySync } from "./commands/memory.js";
|
|
21
22
|
import { parseManageArgs, runManage } from "./commands/manage.js";
|
|
@@ -131,9 +132,25 @@ Commands:
|
|
|
131
132
|
--dry-run preview without writing
|
|
132
133
|
--json machine-readable output, no report text
|
|
133
134
|
mcp set <name> --auth oauth|none
|
|
134
|
-
Set or clear explicit OAuth classification; canonical only
|
|
135
|
+
Set or clear explicit OAuth classification; canonical only.
|
|
136
|
+
--auth oauth [--client-id <id>] [--client-secret-env <NAME>]
|
|
137
|
+
pre-register a client for a provider that refuses
|
|
138
|
+
dynamic registration. --client-id is the PROVIDER's
|
|
139
|
+
published identifier (public by design);
|
|
140
|
+
--client-secret-env is a variable NAME resolved via
|
|
141
|
+
secrets.policy.yaml, never a secret value.
|
|
142
|
+
A bare --auth oauth keeps existing client metadata;
|
|
143
|
+
--auth none removes it along with the classification.
|
|
135
144
|
--dry-run preview the plan, write nothing
|
|
136
145
|
--json machine-readable output, no report text
|
|
146
|
+
mcp scope <name> --agents <ids> | --all | --none
|
|
147
|
+
Choose which managed agents reach a canonical MCP server, then
|
|
148
|
+
run the same sync as mcp sync. Exactly one selector is
|
|
149
|
+
required; ids must be managed agents. Selecting every managed
|
|
150
|
+
agent records no explicit scope, so a newly managed agent is
|
|
151
|
+
included. An entry Trellis did not write is never removed.
|
|
152
|
+
--dry-run preview the plan, write nothing
|
|
153
|
+
--json machine-readable output, no report text
|
|
137
154
|
mcp auth <server-name>
|
|
138
155
|
Authorize a remote (http/sse) MCP server that uses OAuth —
|
|
139
156
|
opens a browser once, stores the result 0600 under
|
|
@@ -161,6 +178,12 @@ Commands:
|
|
|
161
178
|
--dry-run preview the plan, write nothing
|
|
162
179
|
--json machine-readable output, no report text
|
|
163
180
|
skill remove <name>
|
|
181
|
+
skill scope <name> --agents <ids> | --all | --none
|
|
182
|
+
Choose which managed agents receive a canonical skill, then
|
|
183
|
+
sync it. Same selector rules as mcp scope; a built-in skill's
|
|
184
|
+
scope is not editable
|
|
185
|
+
--dry-run preview the plan, write nothing
|
|
186
|
+
--json machine-readable output, no report text
|
|
164
187
|
skill update-builtin
|
|
165
188
|
Refresh the package-owned trellis-runtime Skill in canonical
|
|
166
189
|
source; existing native symlinks see the update
|
|
@@ -429,9 +452,12 @@ async function main(argv) {
|
|
|
429
452
|
}
|
|
430
453
|
if (subcommand === "set") {
|
|
431
454
|
const [name] = mcpRest;
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
455
|
+
process.exitCode = runMcpSetAuth(name, parseMcpSetArgs(mcpRest), { json, dryRun }).exitCode;
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (subcommand === "scope") {
|
|
459
|
+
const [name] = mcpRest;
|
|
460
|
+
process.exitCode = (await runMcpScope(name && !name.startsWith("--") ? name : undefined, parseScopeArgs(mcpRest), { json, dryRun })).exitCode;
|
|
435
461
|
return;
|
|
436
462
|
}
|
|
437
463
|
if (subcommand === "remove") {
|
|
@@ -455,7 +481,7 @@ async function main(argv) {
|
|
|
455
481
|
process.exitCode = exitCode;
|
|
456
482
|
return;
|
|
457
483
|
}
|
|
458
|
-
console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|remove <name>|auth <name>\n`);
|
|
484
|
+
console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|scope <name>|remove <name>|auth <name>\n`);
|
|
459
485
|
process.exitCode = 1;
|
|
460
486
|
return;
|
|
461
487
|
}
|
|
@@ -489,11 +515,16 @@ async function main(argv) {
|
|
|
489
515
|
process.exitCode = (await runSkillRemove(name, { json, dryRun })).exitCode;
|
|
490
516
|
return;
|
|
491
517
|
}
|
|
518
|
+
if (subcommand === "scope") {
|
|
519
|
+
const [name] = skillRest;
|
|
520
|
+
process.exitCode = (await runSkillScope(name && !name.startsWith("--") ? name : undefined, parseScopeArgs(skillRest), { json, dryRun })).exitCode;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
492
523
|
if (subcommand === "update-builtin") {
|
|
493
524
|
process.exitCode = runSkillUpdateBuiltin({ json, dryRun }).exitCode;
|
|
494
525
|
return;
|
|
495
526
|
}
|
|
496
|
-
console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>|update-builtin\n`);
|
|
527
|
+
console.error(`Unknown skill subcommand: ${subcommand ?? "(none)"}\nUsage: trellis skill list|add <name> --from <path>|remove <name>|scope <name> --agents <ids>|--all|--none|update-builtin\n`);
|
|
497
528
|
process.exitCode = 1;
|
|
498
529
|
return;
|
|
499
530
|
}
|
package/dist/commands/mcp.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { type ServersYamlWriteResult } from "../core/canonical.js";
|
|
16
16
|
import type { AdapterPlanItem } from "../core/adapter.js";
|
|
17
17
|
import type { AgentId, McpAuthMode, McpConfig, McpServerDef, Transport } from "../core/types.js";
|
|
18
|
+
import { type ScopeSelectorRaw } from "../lib/scopeSelection.js";
|
|
18
19
|
import { type BackupSession } from "../lib/backup.js";
|
|
19
20
|
export interface RunMcpSyncOptions {
|
|
20
21
|
json?: boolean;
|
|
@@ -52,7 +53,22 @@ export declare function printReport(report: McpSyncReport, dryRun: boolean): voi
|
|
|
52
53
|
export interface McpListEntry {
|
|
53
54
|
name: string;
|
|
54
55
|
transport: Transport;
|
|
56
|
+
/** Normalized: `oauth` in either the scalar or the object form reports as
|
|
57
|
+
* the scalar here — the listing reports classification, not metadata. */
|
|
55
58
|
auth?: McpAuthMode;
|
|
59
|
+
/** Presence of pre-registered client metadata
|
|
60
|
+
* (trellis-mcp-oauth-static-client task 3.2) — `true` when the object form
|
|
61
|
+
* carries a `client_id`. A presence flag, deliberately not the id itself:
|
|
62
|
+
* the id is public-by-design, but listing output stays stable if that
|
|
63
|
+
* ever changes and there is one less place a future secret-looking field
|
|
64
|
+
* could leak through. */
|
|
65
|
+
preRegisteredClient?: true;
|
|
66
|
+
/** State of the stored OAuth credential, for OAuth-classified servers only
|
|
67
|
+
* (trellis-scope-editing-and-auth-status design.md D6). It describes the
|
|
68
|
+
* credential, not the connection: an `authorized` server can still be down. */
|
|
69
|
+
authStatus?: McpAuthStatus;
|
|
70
|
+
/** Epoch ms, when the stored token advertises an expiry. */
|
|
71
|
+
authExpiresAt?: number;
|
|
56
72
|
enabled: boolean;
|
|
57
73
|
agents: readonly AgentId[];
|
|
58
74
|
command?: string;
|
|
@@ -68,6 +84,18 @@ export interface McpListEntry {
|
|
|
68
84
|
* printed in full. */
|
|
69
85
|
staticEnv?: Record<string, string>;
|
|
70
86
|
}
|
|
87
|
+
export type McpAuthStatus = "not-authorized" | "authorized" | "refreshable" | "expired";
|
|
88
|
+
/**
|
|
89
|
+
* Reads only the per-server token store and returns the state, never a
|
|
90
|
+
* token. Classification stays explicit — the caller only asks for servers
|
|
91
|
+
* already declared OAuth, so a stray token file never makes one. A server
|
|
92
|
+
* name that cannot be a token filename, or an unreadable file, simply has no
|
|
93
|
+
* credential: one odd entry must not take down the whole listing.
|
|
94
|
+
*/
|
|
95
|
+
export declare function mcpCredentialState(homeDir: string, serverName: string): {
|
|
96
|
+
authStatus: McpAuthStatus;
|
|
97
|
+
authExpiresAt?: number;
|
|
98
|
+
};
|
|
71
99
|
export declare function collectMcpListPlan(homeDir?: string): McpListEntry[];
|
|
72
100
|
export declare function runMcpList(opts?: {
|
|
73
101
|
homeDir?: string;
|
|
@@ -98,6 +126,7 @@ export interface McpAddRawArgs {
|
|
|
98
126
|
enabled?: string;
|
|
99
127
|
}
|
|
100
128
|
export declare function parseMcpAddArgs(argv: readonly string[]): McpAddRawArgs;
|
|
129
|
+
export declare function parseMcpSetArgs(argv: readonly string[]): McpSetAuthRawArgs;
|
|
101
130
|
export type McpAddAction = "create" | "already-present" | "conflict" | "invalid-input";
|
|
102
131
|
export interface McpAddPlan {
|
|
103
132
|
name: string;
|
|
@@ -122,14 +151,84 @@ export interface McpSetAuthPlan {
|
|
|
122
151
|
detail: string;
|
|
123
152
|
def?: McpServerDef;
|
|
124
153
|
}
|
|
125
|
-
|
|
126
|
-
|
|
154
|
+
/** Raw, unvalidated CLI flag values for `mcp set` — same posture as
|
|
155
|
+
* `McpAddRawArgs`: the parser stays a dumb reader, validation lives in the
|
|
156
|
+
* plan collector. */
|
|
157
|
+
export interface McpSetAuthRawArgs {
|
|
158
|
+
auth?: string;
|
|
159
|
+
/** Public literal by design (design.md D2) — the one field it is fine to
|
|
160
|
+
* pass on a command line. Never echoed back in output or errors. */
|
|
161
|
+
clientId?: string;
|
|
162
|
+
/** A variable NAME, never a secret value. */
|
|
163
|
+
clientSecretEnv?: string;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Sets or clears the OAuth classification, and optionally the
|
|
167
|
+
* pre-registered client metadata that goes with it
|
|
168
|
+
* (trellis-mcp-oauth-static-client tasks.md 3.1).
|
|
169
|
+
*
|
|
170
|
+
* Three rules the flag interaction has to get right:
|
|
171
|
+
* - A bare `--auth oauth` is a re-classification, not a reset: an entry
|
|
172
|
+
* that already carries a `client_id` keeps it. Silently dropping
|
|
173
|
+
* metadata here would turn "make sure this is OAuth" into "break my
|
|
174
|
+
* Figma setup", which is exactly the kind of invisible precedence
|
|
175
|
+
* `mcp set` exists to avoid.
|
|
176
|
+
* - `--auth none` is the reset — it strips the whole `auth` key,
|
|
177
|
+
* metadata included, because that is the only reading of "none" that
|
|
178
|
+
* leaves the file in a self-consistent state.
|
|
179
|
+
* - `--client-id` / `--client-secret-env` require an explicit
|
|
180
|
+
* `--auth oauth` in the same invocation. Implying it would make a
|
|
181
|
+
* typo'd flag name mutate the classification.
|
|
182
|
+
*/
|
|
183
|
+
export declare function collectMcpSetAuthPlan(name: string | undefined, raw: McpSetAuthRawArgs, homeDir?: string): McpSetAuthPlan;
|
|
184
|
+
export declare function runMcpSetAuth(name: string | undefined, raw: McpSetAuthRawArgs, opts?: {
|
|
127
185
|
homeDir?: string;
|
|
128
186
|
json?: boolean;
|
|
129
187
|
dryRun?: boolean;
|
|
130
188
|
}): {
|
|
131
189
|
exitCode: number;
|
|
132
190
|
};
|
|
191
|
+
export type McpScopeAction = "updated" | "already-set" | "not-found" | "invalid-input";
|
|
192
|
+
export interface McpScopePlan {
|
|
193
|
+
name: string;
|
|
194
|
+
action: McpScopeAction;
|
|
195
|
+
detail: string;
|
|
196
|
+
/** Recorded as `agents:` on the entry; `undefined` removes the key so the
|
|
197
|
+
* server reaches every managed agent. */
|
|
198
|
+
scope?: AgentId[];
|
|
199
|
+
mode?: "all" | "none" | "explicit";
|
|
200
|
+
normalizedFromFull?: boolean;
|
|
201
|
+
effective?: readonly AgentId[];
|
|
202
|
+
/** Only set when action === "updated"; consumed by applyMcpScopeWithSync. */
|
|
203
|
+
def?: McpServerDef;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* `mcp scope <name> --agents a,b | --all | --none`
|
|
207
|
+
* (trellis-scope-editing-and-auth-status tasks.md 1.2). Same selector rules as
|
|
208
|
+
* `skill scope`, which is why both go through `resolveScopeSelection`.
|
|
209
|
+
*/
|
|
210
|
+
export declare function collectMcpScopePlan(name: string | undefined, raw: ScopeSelectorRaw, homeDir?: string): McpScopePlan;
|
|
211
|
+
export interface McpScopeOutcome {
|
|
212
|
+
plan: McpScopePlan;
|
|
213
|
+
writeError?: string;
|
|
214
|
+
sync?: McpSyncReport;
|
|
215
|
+
}
|
|
216
|
+
/** Write inside a backup session, then the same `mcp sync` `add` / `remove`
|
|
217
|
+
* run. Removal of an entry from an agent narrowed away stays gated by the
|
|
218
|
+
* ownership ledger, so an entry Trellis did not write is never deleted
|
|
219
|
+
* (design.md D5). */
|
|
220
|
+
export declare function applyMcpScopeWithSync(plan: McpScopePlan, opts?: {
|
|
221
|
+
homeDir?: string;
|
|
222
|
+
dryRun?: boolean;
|
|
223
|
+
backupSession?: BackupSession;
|
|
224
|
+
}): Promise<McpScopeOutcome>;
|
|
225
|
+
export declare function runMcpScope(name: string | undefined, raw: ScopeSelectorRaw, opts?: {
|
|
226
|
+
homeDir?: string;
|
|
227
|
+
json?: boolean;
|
|
228
|
+
dryRun?: boolean;
|
|
229
|
+
}): Promise<{
|
|
230
|
+
exitCode: number;
|
|
231
|
+
}>;
|
|
133
232
|
export declare function applyMcpAddPlan(plan: McpAddPlan, homeDir?: string, backup?: BackupSession): ServersYamlWriteResult;
|
|
134
233
|
export interface McpAddOutcome {
|
|
135
234
|
plan: McpAddPlan;
|
package/dist/commands/mcp.js
CHANGED
|
@@ -16,7 +16,10 @@ import { homedir } from "node:os";
|
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { loadCanonicalSource, upsertServerYaml, removeServerYaml } from "../core/canonical.js";
|
|
19
|
-
import { ALL_AGENTS, resolveScope } from "../core/types.js";
|
|
19
|
+
import { ALL_AGENTS, isOAuthAuth, oauthClientMetadata, resolveScope } from "../core/types.js";
|
|
20
|
+
import { isValidSecretVarName } from "../lib/secretEnv.js";
|
|
21
|
+
import { isExpired, readToken } from "../lib/oauth/store.js";
|
|
22
|
+
import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
|
|
20
23
|
import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
21
24
|
import { CodexAdapter } from "../adapters/codex.js";
|
|
22
25
|
import { KiroAdapter } from "../adapters/kiro.js";
|
|
@@ -113,12 +116,36 @@ export function printReport(report, dryRun) {
|
|
|
113
116
|
function serversYamlPath(homeDir) {
|
|
114
117
|
return join(homeDir, ".trellis", "mcp", "servers.yaml");
|
|
115
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Reads only the per-server token store and returns the state, never a
|
|
121
|
+
* token. Classification stays explicit — the caller only asks for servers
|
|
122
|
+
* already declared OAuth, so a stray token file never makes one. A server
|
|
123
|
+
* name that cannot be a token filename, or an unreadable file, simply has no
|
|
124
|
+
* credential: one odd entry must not take down the whole listing.
|
|
125
|
+
*/
|
|
126
|
+
export function mcpCredentialState(homeDir, serverName) {
|
|
127
|
+
let token;
|
|
128
|
+
try {
|
|
129
|
+
token = readToken(homeDir, serverName);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { authStatus: "not-authorized" };
|
|
133
|
+
}
|
|
134
|
+
if (!token?.accessToken)
|
|
135
|
+
return { authStatus: "not-authorized" };
|
|
136
|
+
const expiry = token.expiresAt !== undefined ? { authExpiresAt: token.expiresAt } : {};
|
|
137
|
+
if (!isExpired(token))
|
|
138
|
+
return { authStatus: "authorized", ...expiry };
|
|
139
|
+
return { authStatus: token.refreshToken ? "refreshable" : "expired", ...expiry };
|
|
140
|
+
}
|
|
116
141
|
export function collectMcpListPlan(homeDir = homedir()) {
|
|
117
142
|
const canonical = loadCanonicalSource(homeDir);
|
|
118
143
|
return Object.entries(canonical.mcp.servers).map(([name, def]) => ({
|
|
119
144
|
name,
|
|
120
145
|
transport: def.transport,
|
|
121
|
-
auth: def.auth,
|
|
146
|
+
auth: isOAuthAuth(def.auth) ? "oauth" : undefined,
|
|
147
|
+
preRegisteredClient: oauthClientMetadata(def.auth).clientId !== undefined ? true : undefined,
|
|
148
|
+
...(isOAuthAuth(def.auth) ? mcpCredentialState(homeDir, name) : {}),
|
|
122
149
|
enabled: def.enabled ?? true,
|
|
123
150
|
agents: resolveScope(def.agents, canonical.managedAgents),
|
|
124
151
|
command: def.command,
|
|
@@ -148,7 +175,7 @@ export function runMcpList(opts = {}) {
|
|
|
148
175
|
else {
|
|
149
176
|
for (const e of entries) {
|
|
150
177
|
const scope = e.agents.length > 0 ? e.agents.join(", ") : "(no managed agent reaches it)";
|
|
151
|
-
console.log(`${e.name} (${e.transport}${e.auth === "oauth" ? ", oauth" : ""})${e.enabled ? "" : " [disabled]"} — ${scope}`);
|
|
178
|
+
console.log(`${e.name} (${e.transport}${e.auth === "oauth" ? ", oauth" : ""}${e.preRegisteredClient ? ", pre-registered client" : ""}${e.authStatus ? `, credential: ${e.authStatus}` : ""})${e.enabled ? "" : " [disabled]"} — ${scope}`);
|
|
152
179
|
if (e.env && e.env.length > 0)
|
|
153
180
|
console.log(` env: ${e.env.join(", ")} (values never read/printed)`);
|
|
154
181
|
if (e.staticEnv)
|
|
@@ -175,6 +202,17 @@ export function parseMcpAddArgs(argv) {
|
|
|
175
202
|
enabled: flag("--enabled"),
|
|
176
203
|
};
|
|
177
204
|
}
|
|
205
|
+
export function parseMcpSetArgs(argv) {
|
|
206
|
+
const flag = (name) => {
|
|
207
|
+
const i = argv.indexOf(name);
|
|
208
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
209
|
+
};
|
|
210
|
+
return {
|
|
211
|
+
auth: flag("--auth"),
|
|
212
|
+
clientId: flag("--client-id"),
|
|
213
|
+
clientSecretEnv: flag("--client-secret-env"),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
178
216
|
function parseCsv(raw) {
|
|
179
217
|
if (!raw)
|
|
180
218
|
return undefined;
|
|
@@ -254,12 +292,54 @@ export function collectMcpAddPlan(name, raw, homeDir = homedir()) {
|
|
|
254
292
|
}
|
|
255
293
|
return { name, action: "create", detail: "will add to servers.yaml", def };
|
|
256
294
|
}
|
|
257
|
-
|
|
258
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Sets or clears the OAuth classification, and optionally the
|
|
297
|
+
* pre-registered client metadata that goes with it
|
|
298
|
+
* (trellis-mcp-oauth-static-client tasks.md 3.1).
|
|
299
|
+
*
|
|
300
|
+
* Three rules the flag interaction has to get right:
|
|
301
|
+
* - A bare `--auth oauth` is a re-classification, not a reset: an entry
|
|
302
|
+
* that already carries a `client_id` keeps it. Silently dropping
|
|
303
|
+
* metadata here would turn "make sure this is OAuth" into "break my
|
|
304
|
+
* Figma setup", which is exactly the kind of invisible precedence
|
|
305
|
+
* `mcp set` exists to avoid.
|
|
306
|
+
* - `--auth none` is the reset — it strips the whole `auth` key,
|
|
307
|
+
* metadata included, because that is the only reading of "none" that
|
|
308
|
+
* leaves the file in a self-consistent state.
|
|
309
|
+
* - `--client-id` / `--client-secret-env` require an explicit
|
|
310
|
+
* `--auth oauth` in the same invocation. Implying it would make a
|
|
311
|
+
* typo'd flag name mutate the classification.
|
|
312
|
+
*/
|
|
313
|
+
export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
|
|
314
|
+
const mode = raw.auth === "oauth" || raw.auth === "none" ? raw.auth : undefined;
|
|
259
315
|
if (!name)
|
|
260
|
-
return { name: "(none)", auth: (mode ?? "none"), action: "invalid-input", detail: "usage: trellis mcp set <name> --auth oauth|none" };
|
|
261
|
-
|
|
262
|
-
|
|
316
|
+
return { name: "(none)", auth: (mode ?? "none"), action: "invalid-input", detail: "usage: trellis mcp set <name> --auth oauth|none [--client-id <id>] [--client-secret-env <NAME>]" };
|
|
317
|
+
const hasClientFlags = raw.clientId !== undefined || raw.clientSecretEnv !== undefined;
|
|
318
|
+
const clientFlagsDetail = "--client-id / --client-secret-env require an explicit --auth oauth in the same command";
|
|
319
|
+
if (mode === undefined) {
|
|
320
|
+
// Naming the missing --auth is the actionable error when that is what
|
|
321
|
+
// the user was reaching for; otherwise report the invalid value itself.
|
|
322
|
+
if (hasClientFlags)
|
|
323
|
+
return { name, auth: "none", action: "invalid-input", detail: clientFlagsDetail };
|
|
324
|
+
return { name, auth: "none", action: "invalid-input", detail: `--auth must be "oauth" or "none" (got ${raw.auth ?? "(missing)"})` };
|
|
325
|
+
}
|
|
326
|
+
if (hasClientFlags && mode !== "oauth") {
|
|
327
|
+
return { name, auth: mode, action: "invalid-input", detail: clientFlagsDetail };
|
|
328
|
+
}
|
|
329
|
+
if (raw.clientId === "") {
|
|
330
|
+
return { name, auth: mode, action: "invalid-input", detail: "--client-id must not be empty" };
|
|
331
|
+
}
|
|
332
|
+
if (raw.clientSecretEnv !== undefined && !isValidSecretVarName(raw.clientSecretEnv)) {
|
|
333
|
+
// The value is deliberately not echoed: a name-shaped field failing
|
|
334
|
+
// this check is at best a typo and at worst a pasted credential
|
|
335
|
+
// (design.md D2).
|
|
336
|
+
return {
|
|
337
|
+
name,
|
|
338
|
+
auth: mode,
|
|
339
|
+
action: "invalid-input",
|
|
340
|
+
detail: "--client-secret-env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
263
343
|
const canonical = loadCanonicalSource(homeDir);
|
|
264
344
|
const current = canonical.mcp.servers[name];
|
|
265
345
|
if (!current)
|
|
@@ -267,19 +347,61 @@ export function collectMcpSetAuthPlan(name, auth, homeDir = homedir()) {
|
|
|
267
347
|
if (mode === "oauth" && !current.url) {
|
|
268
348
|
return { name, auth: mode, action: "invalid-input", detail: "auth: oauth requires an http or sse MCP server" };
|
|
269
349
|
}
|
|
270
|
-
|
|
350
|
+
// Same load-time guard as `assertAuthMetadataClean`, applied at write
|
|
351
|
+
// time so an offending value never reaches the file in the first place.
|
|
352
|
+
const offending = [
|
|
353
|
+
["client-id", raw.clientId],
|
|
354
|
+
["client-secret-env", raw.clientSecretEnv],
|
|
355
|
+
].find(([, value]) => value !== undefined && canonical.secretsPolicy.rejectPatterns.some((pattern) => pattern.test(value)));
|
|
356
|
+
if (offending) {
|
|
357
|
+
return {
|
|
358
|
+
name,
|
|
359
|
+
auth: mode,
|
|
360
|
+
action: "invalid-input",
|
|
361
|
+
detail: `--${offending[0]} matches a secrets.policy.yaml reject pattern — client-id must be the provider-published id, client-secret-env a variable name; the offending value is intentionally not printed`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
let next;
|
|
365
|
+
if (mode === "none") {
|
|
271
366
|
const { auth: _auth, ...withoutAuth } = current;
|
|
272
|
-
|
|
273
|
-
}
|
|
367
|
+
next = withoutAuth;
|
|
368
|
+
}
|
|
369
|
+
else if (!hasClientFlags && isOAuthAuth(current.auth)) {
|
|
370
|
+
// Already classified and no new metadata: a true no-op. Rewriting the
|
|
371
|
+
// scalar into a metadata-less object here would churn the file to say
|
|
372
|
+
// exactly what it already said (design.md D1 makes them equivalent).
|
|
373
|
+
next = current;
|
|
374
|
+
}
|
|
375
|
+
else if (!hasClientFlags) {
|
|
376
|
+
// Newly classified, nothing to record — the scalar is the documented
|
|
377
|
+
// shorthand, so write the smaller of two equivalent forms.
|
|
378
|
+
next = { ...current, auth: "oauth" };
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
const existing = oauthClientMetadata(current.auth);
|
|
382
|
+
// Per-field override, not replacement: `--client-id` alone keeps a
|
|
383
|
+
// `client_secret_env` that is already configured.
|
|
384
|
+
const clientId = raw.clientId ?? existing.clientId;
|
|
385
|
+
const clientSecretEnv = raw.clientSecretEnv ?? existing.clientSecretEnv;
|
|
386
|
+
next = {
|
|
387
|
+
...current,
|
|
388
|
+
auth: {
|
|
389
|
+
kind: "oauth",
|
|
390
|
+
...(clientId !== undefined ? { clientId } : {}),
|
|
391
|
+
...(clientSecretEnv !== undefined ? { clientSecretEnv } : {}),
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
274
395
|
if (JSON.stringify(current) === JSON.stringify(next))
|
|
275
396
|
return { name, auth: mode, action: "already-set", detail: `auth is already ${mode}`, def: next };
|
|
276
|
-
|
|
397
|
+
const changed = hasClientFlags ? " and client metadata" : "";
|
|
398
|
+
return { name, auth: mode, action: "updated", detail: `set auth to ${mode}${changed}`, def: next };
|
|
277
399
|
}
|
|
278
|
-
export function runMcpSetAuth(name,
|
|
400
|
+
export function runMcpSetAuth(name, raw, opts = {}) {
|
|
279
401
|
const homeDir = opts.homeDir ?? homedir();
|
|
280
402
|
let plan;
|
|
281
403
|
try {
|
|
282
|
-
plan = collectMcpSetAuthPlan(name,
|
|
404
|
+
plan = collectMcpSetAuthPlan(name, raw, homeDir);
|
|
283
405
|
}
|
|
284
406
|
catch (err) {
|
|
285
407
|
console.error(err instanceof Error ? err.message : String(err));
|
|
@@ -302,6 +424,85 @@ export function runMcpSetAuth(name, auth, opts = {}) {
|
|
|
302
424
|
}
|
|
303
425
|
return { exitCode: ["invalid-input", "not-found"].includes(plan.action) || Boolean(writeError) ? 1 : 0 };
|
|
304
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* `mcp scope <name> --agents a,b | --all | --none`
|
|
429
|
+
* (trellis-scope-editing-and-auth-status tasks.md 1.2). Same selector rules as
|
|
430
|
+
* `skill scope`, which is why both go through `resolveScopeSelection`.
|
|
431
|
+
*/
|
|
432
|
+
export function collectMcpScopePlan(name, raw, homeDir = homedir()) {
|
|
433
|
+
if (!name)
|
|
434
|
+
return { name: "(none)", action: "invalid-input", detail: "usage: trellis mcp scope <name> --agents <ids> | --all | --none" };
|
|
435
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
436
|
+
const selection = resolveScopeSelection(raw, canonical.managedAgents);
|
|
437
|
+
if (!selection.ok)
|
|
438
|
+
return { name, action: "invalid-input", detail: selection.detail };
|
|
439
|
+
const current = canonical.mcp.servers[name];
|
|
440
|
+
if (!current)
|
|
441
|
+
return { name, action: "not-found", detail: `no canonical MCP server named "${name}"` };
|
|
442
|
+
const base = {
|
|
443
|
+
name,
|
|
444
|
+
scope: selection.scope,
|
|
445
|
+
mode: selection.mode,
|
|
446
|
+
normalizedFromFull: selection.normalizedFromFull,
|
|
447
|
+
effective: resolveScope(selection.scope, canonical.managedAgents),
|
|
448
|
+
};
|
|
449
|
+
const summary = describeScope(selection.scope, canonical.managedAgents);
|
|
450
|
+
if (sameScope(current.agents, selection.scope)) {
|
|
451
|
+
return { ...base, action: "already-set", detail: `scope is already ${summary}` };
|
|
452
|
+
}
|
|
453
|
+
const { agents: _agents, ...withoutAgents } = current;
|
|
454
|
+
const def = selection.scope === undefined ? withoutAgents : { ...withoutAgents, agents: selection.scope };
|
|
455
|
+
const normalized = selection.normalizedFromFull ? " (every managed agent selected — recorded as the default, so a newly managed agent is included)" : "";
|
|
456
|
+
return { ...base, action: "updated", detail: `set scope to ${summary}${normalized}`, def };
|
|
457
|
+
}
|
|
458
|
+
/** Write inside a backup session, then the same `mcp sync` `add` / `remove`
|
|
459
|
+
* run. Removal of an entry from an agent narrowed away stays gated by the
|
|
460
|
+
* ownership ledger, so an entry Trellis did not write is never deleted
|
|
461
|
+
* (design.md D5). */
|
|
462
|
+
export async function applyMcpScopeWithSync(plan, opts = {}) {
|
|
463
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
464
|
+
const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "updated" ? openBackupSession(homeDir, "mcp-scope") : undefined;
|
|
465
|
+
const session = opts.backupSession ?? ownSession;
|
|
466
|
+
let writeError;
|
|
467
|
+
if (!opts.dryRun && plan.action === "updated" && plan.def) {
|
|
468
|
+
const result = upsertServerYaml(serversYamlPath(homeDir), plan.name, plan.def, session);
|
|
469
|
+
if (!result.ok)
|
|
470
|
+
writeError = result.error;
|
|
471
|
+
}
|
|
472
|
+
ownSession?.finalize();
|
|
473
|
+
let syncReport;
|
|
474
|
+
if (!writeError && plan.action === "updated" && existsSync(join(homeDir, ".trellis"))) {
|
|
475
|
+
syncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun });
|
|
476
|
+
}
|
|
477
|
+
return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
|
|
478
|
+
}
|
|
479
|
+
export async function runMcpScope(name, raw, opts = {}) {
|
|
480
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
481
|
+
let outcome;
|
|
482
|
+
try {
|
|
483
|
+
outcome = await applyMcpScopeWithSync(collectMcpScopePlan(name, raw, homeDir), { homeDir, dryRun: opts.dryRun });
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
487
|
+
return { exitCode: 1 };
|
|
488
|
+
}
|
|
489
|
+
const { plan, writeError, sync: syncReport } = outcome;
|
|
490
|
+
if (opts.json) {
|
|
491
|
+
const payload = writeError ? { ...plan, writeError } : plan;
|
|
492
|
+
console.log(JSON.stringify(syncReport ? { ...payload, sync: syncReport } : payload, null, 2));
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
console.log(`${opts.dryRun ? "[dry run] " : ""}mcp scope ${plan.name}`);
|
|
496
|
+
console.log(` [${plan.action}] ${plan.detail}`);
|
|
497
|
+
if (writeError)
|
|
498
|
+
console.error(` write failed: ${writeError}`);
|
|
499
|
+
if (syncReport)
|
|
500
|
+
printReport(syncReport, opts.dryRun ?? false);
|
|
501
|
+
}
|
|
502
|
+
const failed = plan.action === "invalid-input" || plan.action === "not-found" || Boolean(writeError);
|
|
503
|
+
const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
|
|
504
|
+
return { exitCode: failed || syncConflict ? 1 : 0 };
|
|
505
|
+
}
|
|
305
506
|
export function applyMcpAddPlan(plan, homeDir = homedir(), backup) {
|
|
306
507
|
if (plan.action !== "create" || !plan.def)
|
|
307
508
|
return { ok: true };
|
package/dist/commands/mcpAuth.js
CHANGED
|
@@ -13,7 +13,9 @@ import { loadCanonicalSource } from "../core/canonical.js";
|
|
|
13
13
|
import { discoverAuthorizationServer, parseResourceMetadataUrl } from "../lib/oauth/discovery.js";
|
|
14
14
|
import { authorize } from "../lib/oauth/flow.js";
|
|
15
15
|
import { ensureFreshToken } from "../lib/oauth/refresh.js";
|
|
16
|
+
import { resolveSecretEnv } from "../lib/secretEnv.js";
|
|
16
17
|
import { isExpired, readToken, tokenPath, writeToken } from "../lib/oauth/store.js";
|
|
18
|
+
import { oauthClientMetadata } from "../core/types.js";
|
|
17
19
|
export async function runMcpAuth(opts) {
|
|
18
20
|
const homeDir = opts.homeDir ?? homedir();
|
|
19
21
|
let result;
|
|
@@ -72,13 +74,39 @@ async function authorizeServer(opts, homeDir) {
|
|
|
72
74
|
if (!metadata) {
|
|
73
75
|
throw new Error(`could not discover OAuth metadata for "${opts.serverName}" (${def.url}) — it may not use OAuth, or may not publish RFC 8414/9728 metadata`);
|
|
74
76
|
}
|
|
77
|
+
// Pre-registered client metadata from canonical (design.md D3): a
|
|
78
|
+
// `client_id` here means the RFC 7591 step is skipped entirely, which is
|
|
79
|
+
// the only way to authorize against a provider that refuses registration.
|
|
80
|
+
// The secret is resolved by NAME through the same policy machinery `env`
|
|
81
|
+
// uses, and the resolved value never leaves this call — it goes straight
|
|
82
|
+
// into `authorize`, which pairs it with the grant it persists.
|
|
83
|
+
const client = oauthClientMetadata(def.auth);
|
|
84
|
+
let clientSecret;
|
|
85
|
+
if (client.clientSecretEnv) {
|
|
86
|
+
const { [client.clientSecretEnv]: value } = resolveSecretEnv([client.clientSecretEnv], canonical.secretsPolicy);
|
|
87
|
+
if (value === undefined) {
|
|
88
|
+
throw new Error(`auth.client_secret_env is "${client.clientSecretEnv}", but no value for that variable was found in ${secretSourceLabel(canonical.secretsPolicy)} — set it there, or drop the field if this provider issues a public client`);
|
|
89
|
+
}
|
|
90
|
+
clientSecret = value;
|
|
91
|
+
}
|
|
75
92
|
const token = await authorize(opts.serverName, metadata, {
|
|
76
93
|
...(opts.openBrowser ? { openBrowser: opts.openBrowser } : {}),
|
|
77
94
|
...(metadata.scopesSupported?.length ? { scope: metadata.scopesSupported.join(" ") } : {}),
|
|
95
|
+
...(client.clientId !== undefined ? { clientId: client.clientId } : {}),
|
|
96
|
+
...(clientSecret !== undefined ? { clientSecret } : {}),
|
|
78
97
|
});
|
|
79
98
|
writeToken(homeDir, opts.serverName, token);
|
|
80
99
|
return { server: opts.serverName, status: "authorized", tokenFile: file, ...(token.expiresAt ? { expiresAt: token.expiresAt } : {}) };
|
|
81
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Names where a declared secret was looked for, so an unresolvable name
|
|
103
|
+
* points at the file to edit. Mirrors `resolveSecretEnv`'s own two
|
|
104
|
+
* sources: an explicit `env_file` is the sole source when set, otherwise
|
|
105
|
+
* the ambient process environment.
|
|
106
|
+
*/
|
|
107
|
+
function secretSourceLabel(policy) {
|
|
108
|
+
return policy.envFile ? `${policy.envFile} (secrets.policy.yaml env_file)` : "the process environment (no env_file is set in secrets.policy.yaml)";
|
|
109
|
+
}
|
|
82
110
|
/**
|
|
83
111
|
* An unauthenticated request to the resource, purely to read its
|
|
84
112
|
* `WWW-Authenticate` challenge — the server naming its own authorization
|