agent-trellis 0.9.0 → 0.10.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 +11 -5
- package/dist/commands/mcp.d.ts +41 -2
- package/dist/commands/mcp.js +111 -14
- package/dist/commands/mcpAuth.js +28 -0
- 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/oauth/flow.d.ts +13 -0
- package/dist/lib/oauth/flow.js +48 -3
- package/dist/lib/secretEnv.d.ts +5 -0
- package/dist/lib/secretEnv.js +8 -0
- package/dist/pi-bridge/bundle.js +738 -680
- 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/package.json +4 -2
- package/schema/servers.example.yaml +18 -0
- package/docs/video/README.md +0 -76
- package/docs/video/build/audio/01-opening.aiff +0 -0
- package/docs/video/build/audio/01-opening.mp3 +0 -0
- package/docs/video/build/audio/02-pain.aiff +0 -0
- package/docs/video/build/audio/02-pain.mp3 +0 -0
- package/docs/video/build/audio/03-solution.aiff +0 -0
- package/docs/video/build/audio/03-solution.mp3 +0 -0
- package/docs/video/build/audio/04-install.aiff +0 -0
- package/docs/video/build/audio/04-install.mp3 +0 -0
- package/docs/video/build/audio/04-setup.mp3 +0 -0
- package/docs/video/build/audio/05-dryrun.mp3 +0 -0
- package/docs/video/build/audio/05-init.aiff +0 -0
- package/docs/video/build/audio/05-init.mp3 +0 -0
- package/docs/video/build/audio/05-interactive.mp3 +0 -0
- package/docs/video/build/audio/06-dryrun.aiff +0 -0
- package/docs/video/build/audio/06-dryrun.mp3 +0 -0
- package/docs/video/build/audio/06-onboard.mp3 +0 -0
- package/docs/video/build/audio/06-run.mp3 +0 -0
- package/docs/video/build/audio/07-onboard.aiff +0 -0
- package/docs/video/build/audio/07-onboard.mp3 +0 -0
- package/docs/video/build/audio/07-verify.mp3 +0 -0
- package/docs/video/build/audio/08-closing.mp3 +0 -0
- package/docs/video/build/audio/08-doctor.aiff +0 -0
- package/docs/video/build/audio/08-doctor.mp3 +0 -0
- package/docs/video/build/audio/09-rollback.aiff +0 -0
- package/docs/video/build/audio/09-rollback.mp3 +0 -0
- package/docs/video/build/audio/10-principles.aiff +0 -0
- package/docs/video/build/audio/10-principles.mp3 +0 -0
- package/docs/video/build/audio/11-closing.aiff +0 -0
- package/docs/video/build/audio/11-closing.mp3 +0 -0
- package/docs/video/build/frames/01-opening-0.png +0 -0
- package/docs/video/build/frames/02-pain-1.png +0 -0
- package/docs/video/build/frames/02-pain-2.png +0 -0
- package/docs/video/build/frames/03-solution-3.png +0 -0
- package/docs/video/build/frames/04-install-4.png +0 -0
- package/docs/video/build/frames/04-setup-4.png +0 -0
- package/docs/video/build/frames/04-setup-5.png +0 -0
- package/docs/video/build/frames/05-dryrun-6.png +0 -0
- package/docs/video/build/frames/05-init-5.png +0 -0
- package/docs/video/build/frames/05-interactive-5.png +0 -0
- package/docs/video/build/frames/05-interactive-6.png +0 -0
- package/docs/video/build/frames/06-dryrun-6.png +0 -0
- package/docs/video/build/frames/06-dryrun-7.png +0 -0
- package/docs/video/build/frames/06-onboard-7.png +0 -0
- package/docs/video/build/frames/06-onboard-8.png +0 -0
- package/docs/video/build/frames/06-run-7.png +0 -0
- package/docs/video/build/frames/06-run-8.png +0 -0
- package/docs/video/build/frames/07-onboard-10.png +0 -0
- package/docs/video/build/frames/07-onboard-7.png +0 -0
- package/docs/video/build/frames/07-onboard-8.png +0 -0
- package/docs/video/build/frames/07-onboard-9.png +0 -0
- package/docs/video/build/frames/07-verify-10.png +0 -0
- package/docs/video/build/frames/07-verify-8.png +0 -0
- package/docs/video/build/frames/07-verify-9.png +0 -0
- package/docs/video/build/frames/08-closing-10.png +0 -0
- package/docs/video/build/frames/08-closing-11.png +0 -0
- package/docs/video/build/frames/08-doctor-11.png +0 -0
- package/docs/video/build/frames/08-doctor-9.png +0 -0
- package/docs/video/build/frames/09-rollback-10.png +0 -0
- package/docs/video/build/frames/09-rollback-12.png +0 -0
- package/docs/video/build/frames/10-principles-11.png +0 -0
- package/docs/video/build/frames/10-principles-13.png +0 -0
- package/docs/video/build/frames/11-closing-12.png +0 -0
- package/docs/video/build/frames/11-closing-14.png +0 -0
- package/docs/video/build/segments/01-opening.mp4 +0 -0
- package/docs/video/build/segments/01-opening.txt +0 -3
- package/docs/video/build/segments/02-pain.mp4 +0 -0
- package/docs/video/build/segments/02-pain.txt +0 -5
- package/docs/video/build/segments/03-solution.mp4 +0 -0
- package/docs/video/build/segments/03-solution.txt +0 -3
- package/docs/video/build/segments/04-install.mp4 +0 -0
- package/docs/video/build/segments/04-install.txt +0 -3
- package/docs/video/build/segments/04-setup.mp4 +0 -0
- package/docs/video/build/segments/04-setup.txt +0 -3
- package/docs/video/build/segments/05-dryrun.mp4 +0 -0
- package/docs/video/build/segments/05-dryrun.txt +0 -3
- package/docs/video/build/segments/05-init.mp4 +0 -0
- package/docs/video/build/segments/05-init.txt +0 -3
- package/docs/video/build/segments/05-interactive.mp4 +0 -0
- package/docs/video/build/segments/05-interactive.txt +0 -5
- package/docs/video/build/segments/06-dryrun.mp4 +0 -0
- package/docs/video/build/segments/06-dryrun.txt +0 -3
- package/docs/video/build/segments/06-onboard.mp4 +0 -0
- package/docs/video/build/segments/06-onboard.txt +0 -5
- package/docs/video/build/segments/06-run.mp4 +0 -0
- package/docs/video/build/segments/06-run.txt +0 -3
- package/docs/video/build/segments/07-onboard.mp4 +0 -0
- package/docs/video/build/segments/07-onboard.txt +0 -5
- package/docs/video/build/segments/07-verify.mp4 +0 -0
- package/docs/video/build/segments/07-verify.txt +0 -5
- package/docs/video/build/segments/08-closing.mp4 +0 -0
- package/docs/video/build/segments/08-closing.txt +0 -3
- package/docs/video/build/segments/08-doctor.mp4 +0 -0
- package/docs/video/build/segments/08-doctor.txt +0 -3
- package/docs/video/build/segments/09-rollback.mp4 +0 -0
- package/docs/video/build/segments/09-rollback.txt +0 -3
- package/docs/video/build/segments/10-principles.mp4 +0 -0
- package/docs/video/build/segments/10-principles.txt +0 -3
- package/docs/video/build/segments/11-closing.mp4 +0 -0
- package/docs/video/build/segments/11-closing.txt +0 -3
- package/docs/video/build/segments/final.txt +0 -8
- package/docs/video/build-ai/audio/01-hook.mp3 +0 -0
- package/docs/video/build-ai/audio/02-pain.mp3 +0 -0
- package/docs/video/build-ai/audio/03-solution.mp3 +0 -0
- package/docs/video/build-ai/audio/04-how.mp3 +0 -0
- package/docs/video/build-ai/audio/05-safe.mp3 +0 -0
- package/docs/video/build-ai/audio/06-closing.mp3 +0 -0
- package/docs/video/build-ai/clips/01-hook.mp4 +0 -0
- package/docs/video/build-ai/clips/02-pain.mp4 +0 -0
- package/docs/video/build-ai/clips/03-solution.mp4 +0 -0
- package/docs/video/build-ai/clips/04-how.mp4 +0 -0
- package/docs/video/build-ai/clips/05-safe.mp4 +0 -0
- package/docs/video/build-ai/clips/06-closing.mp4 +0 -0
- package/docs/video/build-ai/first-frame-02-pain.png +0 -0
- package/docs/video/build-ai/first-frame-03-solution.png +0 -0
- package/docs/video/build-ai/first-frame-04-how.png +0 -0
- package/docs/video/build-ai/first-frame-05-safe.png +0 -0
- package/docs/video/build-ai/segments/01-hook.caption.txt +0 -2
- package/docs/video/build-ai/segments/01-hook.mp4 +0 -0
- package/docs/video/build-ai/segments/02-pain.mp4 +0 -0
- package/docs/video/build-ai/segments/03-solution.mp4 +0 -0
- package/docs/video/build-ai/segments/04-how.mp4 +0 -0
- package/docs/video/build-ai/segments/05-safe.mp4 +0 -0
- package/docs/video/build-ai/segments/06-closing.mp4 +0 -0
- package/docs/video/build-ai/segments/cap-01-hook.png +0 -0
- package/docs/video/build-ai/segments/cap-02-pain.png +0 -0
- package/docs/video/build-ai/segments/cap-03-solution.png +0 -0
- package/docs/video/build-ai/segments/cap-04-how.png +0 -0
- package/docs/video/build-ai/segments/cap-05-safe.png +0 -0
- package/docs/video/build-ai/segments/cap-06-closing.png +0 -0
- package/docs/video/build-ai/segments/final.txt +0 -6
- package/docs/video/build-posters/audio/01-hook.mp3 +0 -0
- package/docs/video/build-posters/audio/02-pain.mp3 +0 -0
- package/docs/video/build-posters/audio/03-solution.mp3 +0 -0
- package/docs/video/build-posters/audio/04-how.mp3 +0 -0
- package/docs/video/build-posters/audio/05-safe.mp3 +0 -0
- package/docs/video/build-posters/audio/06-closing.mp3 +0 -0
- package/docs/video/build-posters/cap-01-hook.png +0 -0
- package/docs/video/build-posters/cap-02-pain.png +0 -0
- package/docs/video/build-posters/cap-03-solution.png +0 -0
- package/docs/video/build-posters/cap-04-how.png +0 -0
- package/docs/video/build-posters/cap-05-safe.png +0 -0
- package/docs/video/build-posters/cap-06-closing.png +0 -0
- package/docs/video/build-posters/card-01-hook.png +0 -0
- package/docs/video/build-posters/card-06-closing.png +0 -0
- package/docs/video/build-posters/segments/01-hook.mp4 +0 -0
- package/docs/video/build-posters/segments/02-pain.mp4 +0 -0
- package/docs/video/build-posters/segments/03-solution.mp4 +0 -0
- package/docs/video/build-posters/segments/04-how.mp4 +0 -0
- package/docs/video/build-posters/segments/05-safe.mp4 +0 -0
- package/docs/video/build-posters/segments/06-closing.mp4 +0 -0
- package/docs/video/build-posters/segments/final.txt +0 -6
- package/docs/video/generate-video-ai.mjs +0 -334
- package/docs/video/generate-video-posters.mjs +0 -204
- package/docs/video/generate-video.mjs +0 -426
- package/docs/video/outline.zh-CN.md +0 -102
- package/docs/video/trellis-demo-video-ai.mp4 +0 -0
- package/docs/video/trellis-demo-video.mp4 +0 -0
- package/docs/video/voiceover.zh-CN.md +0 -49
|
@@ -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,7 +9,7 @@ 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, parseMcpAddArgs, parseMcpSetArgs } from "./commands/mcp.js";
|
|
13
13
|
import { runMcpImport } from "./commands/mcpImport.js";
|
|
14
14
|
import { parseMcpGatewayArgs, runMcpGateway, runMcpRuntime } from "./commands/mcpGateway.js";
|
|
15
15
|
import { runMcpAuth } from "./commands/mcpAuth.js";
|
|
@@ -131,7 +131,15 @@ Commands:
|
|
|
131
131
|
--dry-run preview without writing
|
|
132
132
|
--json machine-readable output, no report text
|
|
133
133
|
mcp set <name> --auth oauth|none
|
|
134
|
-
Set or clear explicit OAuth classification; canonical only
|
|
134
|
+
Set or clear explicit OAuth classification; canonical only.
|
|
135
|
+
--auth oauth [--client-id <id>] [--client-secret-env <NAME>]
|
|
136
|
+
pre-register a client for a provider that refuses
|
|
137
|
+
dynamic registration. --client-id is the PROVIDER's
|
|
138
|
+
published identifier (public by design);
|
|
139
|
+
--client-secret-env is a variable NAME resolved via
|
|
140
|
+
secrets.policy.yaml, never a secret value.
|
|
141
|
+
A bare --auth oauth keeps existing client metadata;
|
|
142
|
+
--auth none removes it along with the classification.
|
|
135
143
|
--dry-run preview the plan, write nothing
|
|
136
144
|
--json machine-readable output, no report text
|
|
137
145
|
mcp auth <server-name>
|
|
@@ -429,9 +437,7 @@ async function main(argv) {
|
|
|
429
437
|
}
|
|
430
438
|
if (subcommand === "set") {
|
|
431
439
|
const [name] = mcpRest;
|
|
432
|
-
|
|
433
|
-
const auth = authIndex >= 0 ? mcpRest[authIndex + 1] : undefined;
|
|
434
|
-
process.exitCode = runMcpSetAuth(name, auth, { json, dryRun }).exitCode;
|
|
440
|
+
process.exitCode = runMcpSetAuth(name, parseMcpSetArgs(mcpRest), { json, dryRun }).exitCode;
|
|
435
441
|
return;
|
|
436
442
|
}
|
|
437
443
|
if (subcommand === "remove") {
|
package/dist/commands/mcp.d.ts
CHANGED
|
@@ -52,7 +52,16 @@ export declare function printReport(report: McpSyncReport, dryRun: boolean): voi
|
|
|
52
52
|
export interface McpListEntry {
|
|
53
53
|
name: string;
|
|
54
54
|
transport: Transport;
|
|
55
|
+
/** Normalized: `oauth` in either the scalar or the object form reports as
|
|
56
|
+
* the scalar here — the listing reports classification, not metadata. */
|
|
55
57
|
auth?: McpAuthMode;
|
|
58
|
+
/** Presence of pre-registered client metadata
|
|
59
|
+
* (trellis-mcp-oauth-static-client task 3.2) — `true` when the object form
|
|
60
|
+
* carries a `client_id`. A presence flag, deliberately not the id itself:
|
|
61
|
+
* the id is public-by-design, but listing output stays stable if that
|
|
62
|
+
* ever changes and there is one less place a future secret-looking field
|
|
63
|
+
* could leak through. */
|
|
64
|
+
preRegisteredClient?: true;
|
|
56
65
|
enabled: boolean;
|
|
57
66
|
agents: readonly AgentId[];
|
|
58
67
|
command?: string;
|
|
@@ -98,6 +107,7 @@ export interface McpAddRawArgs {
|
|
|
98
107
|
enabled?: string;
|
|
99
108
|
}
|
|
100
109
|
export declare function parseMcpAddArgs(argv: readonly string[]): McpAddRawArgs;
|
|
110
|
+
export declare function parseMcpSetArgs(argv: readonly string[]): McpSetAuthRawArgs;
|
|
101
111
|
export type McpAddAction = "create" | "already-present" | "conflict" | "invalid-input";
|
|
102
112
|
export interface McpAddPlan {
|
|
103
113
|
name: string;
|
|
@@ -122,8 +132,37 @@ export interface McpSetAuthPlan {
|
|
|
122
132
|
detail: string;
|
|
123
133
|
def?: McpServerDef;
|
|
124
134
|
}
|
|
125
|
-
|
|
126
|
-
|
|
135
|
+
/** Raw, unvalidated CLI flag values for `mcp set` — same posture as
|
|
136
|
+
* `McpAddRawArgs`: the parser stays a dumb reader, validation lives in the
|
|
137
|
+
* plan collector. */
|
|
138
|
+
export interface McpSetAuthRawArgs {
|
|
139
|
+
auth?: string;
|
|
140
|
+
/** Public literal by design (design.md D2) — the one field it is fine to
|
|
141
|
+
* pass on a command line. Never echoed back in output or errors. */
|
|
142
|
+
clientId?: string;
|
|
143
|
+
/** A variable NAME, never a secret value. */
|
|
144
|
+
clientSecretEnv?: string;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Sets or clears the OAuth classification, and optionally the
|
|
148
|
+
* pre-registered client metadata that goes with it
|
|
149
|
+
* (trellis-mcp-oauth-static-client tasks.md 3.1).
|
|
150
|
+
*
|
|
151
|
+
* Three rules the flag interaction has to get right:
|
|
152
|
+
* - A bare `--auth oauth` is a re-classification, not a reset: an entry
|
|
153
|
+
* that already carries a `client_id` keeps it. Silently dropping
|
|
154
|
+
* metadata here would turn "make sure this is OAuth" into "break my
|
|
155
|
+
* Figma setup", which is exactly the kind of invisible precedence
|
|
156
|
+
* `mcp set` exists to avoid.
|
|
157
|
+
* - `--auth none` is the reset — it strips the whole `auth` key,
|
|
158
|
+
* metadata included, because that is the only reading of "none" that
|
|
159
|
+
* leaves the file in a self-consistent state.
|
|
160
|
+
* - `--client-id` / `--client-secret-env` require an explicit
|
|
161
|
+
* `--auth oauth` in the same invocation. Implying it would make a
|
|
162
|
+
* typo'd flag name mutate the classification.
|
|
163
|
+
*/
|
|
164
|
+
export declare function collectMcpSetAuthPlan(name: string | undefined, raw: McpSetAuthRawArgs, homeDir?: string): McpSetAuthPlan;
|
|
165
|
+
export declare function runMcpSetAuth(name: string | undefined, raw: McpSetAuthRawArgs, opts?: {
|
|
127
166
|
homeDir?: string;
|
|
128
167
|
json?: boolean;
|
|
129
168
|
dryRun?: boolean;
|
package/dist/commands/mcp.js
CHANGED
|
@@ -16,7 +16,8 @@ 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";
|
|
20
21
|
import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
21
22
|
import { CodexAdapter } from "../adapters/codex.js";
|
|
22
23
|
import { KiroAdapter } from "../adapters/kiro.js";
|
|
@@ -118,7 +119,8 @@ export function collectMcpListPlan(homeDir = homedir()) {
|
|
|
118
119
|
return Object.entries(canonical.mcp.servers).map(([name, def]) => ({
|
|
119
120
|
name,
|
|
120
121
|
transport: def.transport,
|
|
121
|
-
auth: def.auth,
|
|
122
|
+
auth: isOAuthAuth(def.auth) ? "oauth" : undefined,
|
|
123
|
+
preRegisteredClient: oauthClientMetadata(def.auth).clientId !== undefined ? true : undefined,
|
|
122
124
|
enabled: def.enabled ?? true,
|
|
123
125
|
agents: resolveScope(def.agents, canonical.managedAgents),
|
|
124
126
|
command: def.command,
|
|
@@ -148,7 +150,7 @@ export function runMcpList(opts = {}) {
|
|
|
148
150
|
else {
|
|
149
151
|
for (const e of entries) {
|
|
150
152
|
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}`);
|
|
153
|
+
console.log(`${e.name} (${e.transport}${e.auth === "oauth" ? ", oauth" : ""}${e.preRegisteredClient ? ", pre-registered client" : ""})${e.enabled ? "" : " [disabled]"} — ${scope}`);
|
|
152
154
|
if (e.env && e.env.length > 0)
|
|
153
155
|
console.log(` env: ${e.env.join(", ")} (values never read/printed)`);
|
|
154
156
|
if (e.staticEnv)
|
|
@@ -175,6 +177,17 @@ export function parseMcpAddArgs(argv) {
|
|
|
175
177
|
enabled: flag("--enabled"),
|
|
176
178
|
};
|
|
177
179
|
}
|
|
180
|
+
export function parseMcpSetArgs(argv) {
|
|
181
|
+
const flag = (name) => {
|
|
182
|
+
const i = argv.indexOf(name);
|
|
183
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
auth: flag("--auth"),
|
|
187
|
+
clientId: flag("--client-id"),
|
|
188
|
+
clientSecretEnv: flag("--client-secret-env"),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
178
191
|
function parseCsv(raw) {
|
|
179
192
|
if (!raw)
|
|
180
193
|
return undefined;
|
|
@@ -254,12 +267,54 @@ export function collectMcpAddPlan(name, raw, homeDir = homedir()) {
|
|
|
254
267
|
}
|
|
255
268
|
return { name, action: "create", detail: "will add to servers.yaml", def };
|
|
256
269
|
}
|
|
257
|
-
|
|
258
|
-
|
|
270
|
+
/**
|
|
271
|
+
* Sets or clears the OAuth classification, and optionally the
|
|
272
|
+
* pre-registered client metadata that goes with it
|
|
273
|
+
* (trellis-mcp-oauth-static-client tasks.md 3.1).
|
|
274
|
+
*
|
|
275
|
+
* Three rules the flag interaction has to get right:
|
|
276
|
+
* - A bare `--auth oauth` is a re-classification, not a reset: an entry
|
|
277
|
+
* that already carries a `client_id` keeps it. Silently dropping
|
|
278
|
+
* metadata here would turn "make sure this is OAuth" into "break my
|
|
279
|
+
* Figma setup", which is exactly the kind of invisible precedence
|
|
280
|
+
* `mcp set` exists to avoid.
|
|
281
|
+
* - `--auth none` is the reset — it strips the whole `auth` key,
|
|
282
|
+
* metadata included, because that is the only reading of "none" that
|
|
283
|
+
* leaves the file in a self-consistent state.
|
|
284
|
+
* - `--client-id` / `--client-secret-env` require an explicit
|
|
285
|
+
* `--auth oauth` in the same invocation. Implying it would make a
|
|
286
|
+
* typo'd flag name mutate the classification.
|
|
287
|
+
*/
|
|
288
|
+
export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
|
|
289
|
+
const mode = raw.auth === "oauth" || raw.auth === "none" ? raw.auth : undefined;
|
|
259
290
|
if (!name)
|
|
260
|
-
return { name: "(none)", auth: (mode ?? "none"), action: "invalid-input", detail: "usage: trellis mcp set <name> --auth oauth|none" };
|
|
261
|
-
|
|
262
|
-
|
|
291
|
+
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>]" };
|
|
292
|
+
const hasClientFlags = raw.clientId !== undefined || raw.clientSecretEnv !== undefined;
|
|
293
|
+
const clientFlagsDetail = "--client-id / --client-secret-env require an explicit --auth oauth in the same command";
|
|
294
|
+
if (mode === undefined) {
|
|
295
|
+
// Naming the missing --auth is the actionable error when that is what
|
|
296
|
+
// the user was reaching for; otherwise report the invalid value itself.
|
|
297
|
+
if (hasClientFlags)
|
|
298
|
+
return { name, auth: "none", action: "invalid-input", detail: clientFlagsDetail };
|
|
299
|
+
return { name, auth: "none", action: "invalid-input", detail: `--auth must be "oauth" or "none" (got ${raw.auth ?? "(missing)"})` };
|
|
300
|
+
}
|
|
301
|
+
if (hasClientFlags && mode !== "oauth") {
|
|
302
|
+
return { name, auth: mode, action: "invalid-input", detail: clientFlagsDetail };
|
|
303
|
+
}
|
|
304
|
+
if (raw.clientId === "") {
|
|
305
|
+
return { name, auth: mode, action: "invalid-input", detail: "--client-id must not be empty" };
|
|
306
|
+
}
|
|
307
|
+
if (raw.clientSecretEnv !== undefined && !isValidSecretVarName(raw.clientSecretEnv)) {
|
|
308
|
+
// The value is deliberately not echoed: a name-shaped field failing
|
|
309
|
+
// this check is at best a typo and at worst a pasted credential
|
|
310
|
+
// (design.md D2).
|
|
311
|
+
return {
|
|
312
|
+
name,
|
|
313
|
+
auth: mode,
|
|
314
|
+
action: "invalid-input",
|
|
315
|
+
detail: "--client-secret-env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
263
318
|
const canonical = loadCanonicalSource(homeDir);
|
|
264
319
|
const current = canonical.mcp.servers[name];
|
|
265
320
|
if (!current)
|
|
@@ -267,19 +322,61 @@ export function collectMcpSetAuthPlan(name, auth, homeDir = homedir()) {
|
|
|
267
322
|
if (mode === "oauth" && !current.url) {
|
|
268
323
|
return { name, auth: mode, action: "invalid-input", detail: "auth: oauth requires an http or sse MCP server" };
|
|
269
324
|
}
|
|
270
|
-
|
|
325
|
+
// Same load-time guard as `assertAuthMetadataClean`, applied at write
|
|
326
|
+
// time so an offending value never reaches the file in the first place.
|
|
327
|
+
const offending = [
|
|
328
|
+
["client-id", raw.clientId],
|
|
329
|
+
["client-secret-env", raw.clientSecretEnv],
|
|
330
|
+
].find(([, value]) => value !== undefined && canonical.secretsPolicy.rejectPatterns.some((pattern) => pattern.test(value)));
|
|
331
|
+
if (offending) {
|
|
332
|
+
return {
|
|
333
|
+
name,
|
|
334
|
+
auth: mode,
|
|
335
|
+
action: "invalid-input",
|
|
336
|
+
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`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
let next;
|
|
340
|
+
if (mode === "none") {
|
|
271
341
|
const { auth: _auth, ...withoutAuth } = current;
|
|
272
|
-
|
|
273
|
-
}
|
|
342
|
+
next = withoutAuth;
|
|
343
|
+
}
|
|
344
|
+
else if (!hasClientFlags && isOAuthAuth(current.auth)) {
|
|
345
|
+
// Already classified and no new metadata: a true no-op. Rewriting the
|
|
346
|
+
// scalar into a metadata-less object here would churn the file to say
|
|
347
|
+
// exactly what it already said (design.md D1 makes them equivalent).
|
|
348
|
+
next = current;
|
|
349
|
+
}
|
|
350
|
+
else if (!hasClientFlags) {
|
|
351
|
+
// Newly classified, nothing to record — the scalar is the documented
|
|
352
|
+
// shorthand, so write the smaller of two equivalent forms.
|
|
353
|
+
next = { ...current, auth: "oauth" };
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
const existing = oauthClientMetadata(current.auth);
|
|
357
|
+
// Per-field override, not replacement: `--client-id` alone keeps a
|
|
358
|
+
// `client_secret_env` that is already configured.
|
|
359
|
+
const clientId = raw.clientId ?? existing.clientId;
|
|
360
|
+
const clientSecretEnv = raw.clientSecretEnv ?? existing.clientSecretEnv;
|
|
361
|
+
next = {
|
|
362
|
+
...current,
|
|
363
|
+
auth: {
|
|
364
|
+
kind: "oauth",
|
|
365
|
+
...(clientId !== undefined ? { clientId } : {}),
|
|
366
|
+
...(clientSecretEnv !== undefined ? { clientSecretEnv } : {}),
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
274
370
|
if (JSON.stringify(current) === JSON.stringify(next))
|
|
275
371
|
return { name, auth: mode, action: "already-set", detail: `auth is already ${mode}`, def: next };
|
|
276
|
-
|
|
372
|
+
const changed = hasClientFlags ? " and client metadata" : "";
|
|
373
|
+
return { name, auth: mode, action: "updated", detail: `set auth to ${mode}${changed}`, def: next };
|
|
277
374
|
}
|
|
278
|
-
export function runMcpSetAuth(name,
|
|
375
|
+
export function runMcpSetAuth(name, raw, opts = {}) {
|
|
279
376
|
const homeDir = opts.homeDir ?? homedir();
|
|
280
377
|
let plan;
|
|
281
378
|
try {
|
|
282
|
-
plan = collectMcpSetAuthPlan(name,
|
|
379
|
+
plan = collectMcpSetAuthPlan(name, raw, homeDir);
|
|
283
380
|
}
|
|
284
381
|
catch (err) {
|
|
285
382
|
console.error(err instanceof Error ? err.message : String(err));
|
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
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -10,13 +10,23 @@ import type { BackupSession } from "../lib/backup.js";
|
|
|
10
10
|
* The on-disk shape for one server entry — `static_env`/`env_aliases`
|
|
11
11
|
* (snake_case, like every other multi-word key across `.trellis/*.yaml`)
|
|
12
12
|
* are translated to `McpServerDef.staticEnv`/`envAliases` (camelCase)
|
|
13
|
-
* below
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* below, and `auth`'s object form carries `client_id`/`client_secret_env`
|
|
14
|
+
* the same way (trellis-mcp-static-env-and-disabled-servers,
|
|
15
|
+
* trellis-migrate-env-var-alias, trellis-mcp-oauth-static-client).
|
|
16
16
|
*/
|
|
17
|
-
type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases"> & {
|
|
17
|
+
type McpServerDefYaml = Omit<McpServerDef, "staticEnv" | "envAliases" | "auth"> & {
|
|
18
18
|
static_env?: Record<string, string>;
|
|
19
19
|
env_aliases?: Record<string, string>;
|
|
20
|
+
auth?: McpAuthYaml;
|
|
21
|
+
};
|
|
22
|
+
/** The on-disk `auth`: the `oauth` scalar exactly as before, or the object
|
|
23
|
+
* form whose keys are snake_case like every other multi-word key in
|
|
24
|
+
* `.trellis/*.yaml`. Values are `unknown` until `fromAuthYaml` validates
|
|
25
|
+
* them — a YAML cast is optimistic, validation is this module's job. */
|
|
26
|
+
type McpAuthYaml = string | {
|
|
27
|
+
kind?: unknown;
|
|
28
|
+
client_id?: unknown;
|
|
29
|
+
client_secret_env?: unknown;
|
|
20
30
|
};
|
|
21
31
|
/** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
|
|
22
32
|
* `undefined` fields so the written YAML never gets a literal `null`
|
|
@@ -127,13 +137,6 @@ export declare function ensureGitignoreEntry(gitignorePath: string, line: string
|
|
|
127
137
|
* `export` prefix of its own.
|
|
128
138
|
*/
|
|
129
139
|
export declare function ensureShellEnvSource(rcPath: string, envFilePath: string, backup?: BackupSession): void;
|
|
130
|
-
/**
|
|
131
|
-
* `homeDir` defaults to the real `~` and is only ever overridden for tests
|
|
132
|
-
* and `scripts/sandbox.sh` — the same seam P0's probes use
|
|
133
|
-
* (src/probes/*.ts) and for the same reason: never touch a developer's
|
|
134
|
-
* real dotfiles from a test. It is not a workspace/project root — see
|
|
135
|
-
* specs/canonical-source-loading's global-only requirement, which this
|
|
136
|
-
* parameter does not weaken.
|
|
137
|
-
*/
|
|
140
|
+
/** Load the global canonical source from the supplied home directory. */
|
|
138
141
|
export declare function loadCanonicalSource(homeDir?: string): CanonicalSource;
|
|
139
142
|
export {};
|
package/dist/core/canonical.js
CHANGED
|
@@ -8,26 +8,75 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { basename, dirname, join } from "node:path";
|
|
10
10
|
import { isMap, isSeq, parse as parseYaml, parseDocument } from "yaml";
|
|
11
|
-
import { ALL_AGENTS } from "./types.js";
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
import { ALL_AGENTS, oauthClientMetadata } from "./types.js";
|
|
12
|
+
import { isValidSecretVarName } from "../lib/secretEnv.js";
|
|
13
|
+
function fromServerDefYaml(serverName, def) {
|
|
14
|
+
const { static_env, env_aliases, auth, ...rest } = def;
|
|
14
15
|
const out = { ...rest };
|
|
15
16
|
if (static_env)
|
|
16
17
|
out.staticEnv = static_env;
|
|
17
18
|
if (env_aliases)
|
|
18
19
|
out.envAliases = env_aliases;
|
|
20
|
+
if (auth !== undefined)
|
|
21
|
+
out.auth = fromAuthYaml(serverName, auth);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* `auth` translation and shape validation
|
|
26
|
+
* (trellis-mcp-oauth-static-client tasks 1.1/1.3): the scalar and the
|
|
27
|
+
* object form share one discriminator — `kind: oauth` — and the object
|
|
28
|
+
* accepts exactly `kind`, `client_id`, `client_secret_env`. Everything
|
|
29
|
+
* else is refused here at load, naming the field but never echoing the
|
|
30
|
+
* value: an invalid `client_secret_env` is at best a typo, at worst a
|
|
31
|
+
* pasted secret.
|
|
32
|
+
*/
|
|
33
|
+
function fromAuthYaml(serverName, auth) {
|
|
34
|
+
if (auth === "oauth")
|
|
35
|
+
return "oauth";
|
|
36
|
+
if (typeof auth !== "object" || auth === null) {
|
|
37
|
+
throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env? }`);
|
|
38
|
+
}
|
|
39
|
+
const { kind, client_id, client_secret_env, ...extra } = auth;
|
|
40
|
+
const unknownKey = Object.keys(extra)[0];
|
|
41
|
+
if (unknownKey !== undefined) {
|
|
42
|
+
throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" — expected kind, client_id, client_secret_env`);
|
|
43
|
+
}
|
|
44
|
+
if (kind !== "oauth") {
|
|
45
|
+
throw new Error(`mcp/servers.yaml: "${serverName}" auth.kind must be "oauth" — kind is the only recognized discriminator`);
|
|
46
|
+
}
|
|
47
|
+
if (client_id !== undefined && typeof client_id !== "string") {
|
|
48
|
+
throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_id must be a string`);
|
|
49
|
+
}
|
|
50
|
+
if (client_secret_env !== undefined && (typeof client_secret_env !== "string" || !isValidSecretVarName(client_secret_env))) {
|
|
51
|
+
throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_secret_env must be a variable NAME (e.g. FIGMA_CLIENT_SECRET), never a secret value`);
|
|
52
|
+
}
|
|
53
|
+
const out = { kind: "oauth" };
|
|
54
|
+
if (typeof client_id === "string")
|
|
55
|
+
out.clientId = client_id;
|
|
56
|
+
if (typeof client_secret_env === "string")
|
|
57
|
+
out.clientSecretEnv = client_secret_env;
|
|
19
58
|
return out;
|
|
20
59
|
}
|
|
21
60
|
/** Inverse of `fromServerDefYaml` (trellis-canonical-cli-crud) — strips
|
|
22
61
|
* `undefined` fields so the written YAML never gets a literal `null`
|
|
23
62
|
* for an omitted optional. */
|
|
24
63
|
export function toServerDefYaml(def) {
|
|
25
|
-
const { staticEnv, envAliases, ...rest } = def;
|
|
64
|
+
const { staticEnv, envAliases, auth, ...rest } = def;
|
|
26
65
|
const out = { ...rest };
|
|
27
66
|
if (staticEnv)
|
|
28
67
|
out.static_env = staticEnv;
|
|
29
68
|
if (envAliases)
|
|
30
69
|
out.env_aliases = envAliases;
|
|
70
|
+
if (auth !== undefined) {
|
|
71
|
+
out.auth =
|
|
72
|
+
typeof auth === "string"
|
|
73
|
+
? auth
|
|
74
|
+
: {
|
|
75
|
+
kind: auth.kind,
|
|
76
|
+
...(auth.clientId !== undefined ? { client_id: auth.clientId } : {}),
|
|
77
|
+
...(auth.clientSecretEnv !== undefined ? { client_secret_env: auth.clientSecretEnv } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
31
80
|
for (const key of Object.keys(out)) {
|
|
32
81
|
if (out[key] === undefined)
|
|
33
82
|
delete out[key];
|
|
@@ -83,7 +132,7 @@ function loadServersYaml(path) {
|
|
|
83
132
|
return { servers: {}, knownHostInjected: [] };
|
|
84
133
|
}
|
|
85
134
|
const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
|
|
86
|
-
const servers = Object.fromEntries(Object.entries(parsed.servers ?? {}).map(([name, def]) => [name, fromServerDefYaml(def)]));
|
|
135
|
+
const servers = Object.fromEntries(Object.entries(parsed.servers ?? {}).map(([name, def]) => [name, fromServerDefYaml(name, def)]));
|
|
87
136
|
return {
|
|
88
137
|
servers,
|
|
89
138
|
knownHostInjected: parsed.known_host_injected ?? [],
|
|
@@ -452,7 +501,7 @@ export function ensureShellEnvSource(rcPath, envFilePath, backup) {
|
|
|
452
501
|
* all managed agents by default. A recognized but empty list is left as
|
|
453
502
|
* authored (an explicitly agent-less scope), not coerced to "all". */
|
|
454
503
|
/** Missing file and `agents: []` both resolve to `[]` — zero managed
|
|
455
|
-
* agents
|
|
504
|
+
* agents, never "everyone." An unrecognized id is dropped with a
|
|
456
505
|
* diagnostic, same posture as an unrecognized scope.yaml agent id. */
|
|
457
506
|
function loadManagedYaml(path, diagnostics) {
|
|
458
507
|
if (!existsSync(path)) {
|
|
@@ -480,17 +529,36 @@ function validAgentIds(scope) {
|
|
|
480
529
|
return scope.every((id) => ALL_AGENTS.includes(id));
|
|
481
530
|
}
|
|
482
531
|
/**
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
532
|
+
* Load-time literal-value scan for OAuth client metadata
|
|
533
|
+
* (trellis-mcp-oauth-static-client task 1.2). `secrets audit` reports
|
|
534
|
+
* whole-file pattern hits as findings; this check goes further for the
|
|
535
|
+
* two fields a pasted secret is most likely to reach and refuses the
|
|
536
|
+
* load outright — the offending server and field are named, the value
|
|
537
|
+
* never printed.
|
|
489
538
|
*/
|
|
539
|
+
function assertAuthMetadataClean(mcp, policy) {
|
|
540
|
+
for (const [name, def] of Object.entries(mcp.servers)) {
|
|
541
|
+
const meta = oauthClientMetadata(def.auth);
|
|
542
|
+
const fields = [
|
|
543
|
+
["client_id", meta.clientId],
|
|
544
|
+
["client_secret_env", meta.clientSecretEnv],
|
|
545
|
+
];
|
|
546
|
+
for (const [field, value] of fields) {
|
|
547
|
+
if (value === undefined)
|
|
548
|
+
continue;
|
|
549
|
+
for (const pattern of policy.rejectPatterns) {
|
|
550
|
+
if (pattern.test(value)) {
|
|
551
|
+
throw new Error(`mcp/servers.yaml: "${name}" auth.${field} 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`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/** Load the global canonical source from the supplied home directory. */
|
|
490
558
|
export function loadCanonicalSource(homeDir = homedir()) {
|
|
491
559
|
const root = trellisRoot(homeDir);
|
|
492
560
|
if (!existsSync(root)) {
|
|
493
|
-
throw new Error(`No canonical source at ${root}.
|
|
561
|
+
throw new Error(`No canonical source at ${root}. Run trellis init before trellis sync.`);
|
|
494
562
|
}
|
|
495
563
|
const diagnostics = [];
|
|
496
564
|
const managedAgents = loadManagedYaml(join(root, "managed.yaml"), diagnostics);
|
|
@@ -527,17 +595,17 @@ export function loadCanonicalSource(homeDir = homedir()) {
|
|
|
527
595
|
}
|
|
528
596
|
}
|
|
529
597
|
}
|
|
598
|
+
const mcp = loadServersYaml(join(root, "mcp", "servers.yaml"));
|
|
599
|
+
const secretsPolicy = loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir);
|
|
600
|
+
assertAuthMetadataClean(mcp, secretsPolicy);
|
|
530
601
|
return {
|
|
531
602
|
instructionsFile: join(root, "agents.md"),
|
|
532
603
|
managedAgents,
|
|
533
604
|
skills,
|
|
534
605
|
agents,
|
|
535
606
|
memories,
|
|
536
|
-
mcp
|
|
537
|
-
|
|
538
|
-
// hardcoded floor instead of this field — see design.md D1 in
|
|
539
|
-
// trellis-secrets-audit-p3.
|
|
540
|
-
secretsPolicy: loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir),
|
|
607
|
+
mcp,
|
|
608
|
+
secretsPolicy,
|
|
541
609
|
diagnostics,
|
|
542
610
|
};
|
|
543
611
|
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -6,6 +6,29 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export type Transport = "stdio" | "http" | "sse";
|
|
8
8
|
export type McpAuthMode = "oauth";
|
|
9
|
+
/**
|
|
10
|
+
* Object form of the OAuth classification (trellis-mcp-oauth-static-client
|
|
11
|
+
* design.md D1). `kind` is the only discriminator; `client_id` is a public
|
|
12
|
+
* literal by design; `client_secret_env` is a variable NAME resolved
|
|
13
|
+
* through the secrets policy at authorization time. There is deliberately
|
|
14
|
+
* no field for a secret value in any form.
|
|
15
|
+
*/
|
|
16
|
+
export interface McpAuthConfig {
|
|
17
|
+
kind: McpAuthMode;
|
|
18
|
+
clientId?: string;
|
|
19
|
+
clientSecretEnv?: string;
|
|
20
|
+
}
|
|
21
|
+
/** True when `auth` classifies the server as OAuth, in either form. Every
|
|
22
|
+
* former `def.auth === "oauth"` check must go through this now that the
|
|
23
|
+
* object form exists — a strict-equality check would silently strip the
|
|
24
|
+
* OAuth routing from metadata-bearing servers. */
|
|
25
|
+
export declare function isOAuthAuth(auth: McpAuthMode | McpAuthConfig | undefined): boolean;
|
|
26
|
+
/** The pre-registered client metadata a classification carries. A scalar
|
|
27
|
+
* carries none — both forms route identically (design.md D1). */
|
|
28
|
+
export declare function oauthClientMetadata(auth: McpAuthMode | McpAuthConfig | undefined): {
|
|
29
|
+
clientId?: string;
|
|
30
|
+
clientSecretEnv?: string;
|
|
31
|
+
};
|
|
9
32
|
export type AgentId = "claude-code" | "codex" | "kiro" | "pi" | "kimi-code" | "zcode";
|
|
10
33
|
export declare const ALL_AGENTS: readonly AgentId[];
|
|
11
34
|
/**
|
|
@@ -28,8 +51,11 @@ export declare function resolveScope(scope: Scope, managedAgents: readonly Agent
|
|
|
28
51
|
export interface McpServerDef {
|
|
29
52
|
transport: Transport;
|
|
30
53
|
/** Explicit authorization classification. Absence means ordinary MCP;
|
|
31
|
-
* Trellis never infers OAuth from a URL or a transient 401.
|
|
32
|
-
|
|
54
|
+
* Trellis never infers OAuth from a URL or a transient 401. The scalar
|
|
55
|
+
* form and a metadata-less object are equivalent — metadata extends the
|
|
56
|
+
* authorization flow, it never changes routing
|
|
57
|
+
* (trellis-mcp-oauth-static-client design.md D1). */
|
|
58
|
+
auth?: McpAuthMode | McpAuthConfig;
|
|
33
59
|
/** stdio only */
|
|
34
60
|
command?: string;
|
|
35
61
|
args?: string[];
|
package/dist/core/types.js
CHANGED
|
@@ -4,6 +4,25 @@
|
|
|
4
4
|
* for what a "canonical source" object looks like in memory; the YAML/MD
|
|
5
5
|
* files on disk are its serialization, not the other way around.
|
|
6
6
|
*/
|
|
7
|
+
/** True when `auth` classifies the server as OAuth, in either form. Every
|
|
8
|
+
* former `def.auth === "oauth"` check must go through this now that the
|
|
9
|
+
* object form exists — a strict-equality check would silently strip the
|
|
10
|
+
* OAuth routing from metadata-bearing servers. */
|
|
11
|
+
export function isOAuthAuth(auth) {
|
|
12
|
+
return auth === "oauth" || (typeof auth === "object" && auth !== null && auth.kind === "oauth");
|
|
13
|
+
}
|
|
14
|
+
/** The pre-registered client metadata a classification carries. A scalar
|
|
15
|
+
* carries none — both forms route identically (design.md D1). */
|
|
16
|
+
export function oauthClientMetadata(auth) {
|
|
17
|
+
if (typeof auth !== "object" || auth === null)
|
|
18
|
+
return {};
|
|
19
|
+
const meta = {};
|
|
20
|
+
if (auth.clientId !== undefined)
|
|
21
|
+
meta.clientId = auth.clientId;
|
|
22
|
+
if (auth.clientSecretEnv !== undefined)
|
|
23
|
+
meta.clientSecretEnv = auth.clientSecretEnv;
|
|
24
|
+
return meta;
|
|
25
|
+
}
|
|
7
26
|
export const ALL_AGENTS = [
|
|
8
27
|
"claude-code",
|
|
9
28
|
"codex",
|