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
package/dist/commands/skill.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis skill list|add|remove` — command-line CRUD for canonical
|
|
2
|
+
* `trellis skill list|add|remove|scope` — command-line CRUD for canonical
|
|
3
3
|
* skills (trellis-canonical-cli-crud), an alternative to hand-editing
|
|
4
4
|
* `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
|
|
5
5
|
* decision and `remove`'s "sync will un-sync it" behavior both reuse
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { AgentId } from "../core/types.js";
|
|
11
11
|
import { type BackupSession } from "../lib/backup.js";
|
|
12
12
|
import type { SyncReport } from "./sync.js";
|
|
13
|
+
import { type ScopeSelectorRaw } from "../lib/scopeSelection.js";
|
|
13
14
|
import type { RemoteSkillLockEntry } from "../lib/remoteSkillLock.js";
|
|
14
15
|
export type BuiltinSkillUpdateAction = "create" | "update" | "already-current";
|
|
15
16
|
export interface BuiltinSkillUpdatePlan {
|
|
@@ -97,3 +98,46 @@ export declare function runSkillRemove(name: string, opts?: {
|
|
|
97
98
|
}): Promise<{
|
|
98
99
|
exitCode: number;
|
|
99
100
|
}>;
|
|
101
|
+
export type SkillScopeAction = "updated" | "already-set" | "not-found" | "invalid-input";
|
|
102
|
+
export interface SkillScopePlan {
|
|
103
|
+
name: string;
|
|
104
|
+
action: SkillScopeAction;
|
|
105
|
+
detail: string;
|
|
106
|
+
/** What will be recorded in `scope.yaml`; `undefined` clears the explicit
|
|
107
|
+
* scope so the skill reaches every managed agent. Set when the selection was
|
|
108
|
+
* valid, whatever the action. */
|
|
109
|
+
scope?: AgentId[];
|
|
110
|
+
mode?: "all" | "none" | "explicit";
|
|
111
|
+
/** True when a full selection was dropped instead of recorded
|
|
112
|
+
* (trellis-scope-editing-and-auth-status design.md D3). */
|
|
113
|
+
normalizedFromFull?: boolean;
|
|
114
|
+
/** The agents that will actually receive the skill afterwards. */
|
|
115
|
+
effective?: readonly AgentId[];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* `skill scope <name> --agents a,b | --all | --none`
|
|
119
|
+
* (trellis-scope-editing-and-auth-status tasks.md 1.1). Plan only — the write
|
|
120
|
+
* and the cascade sync live in `applySkillScopeWithSync`.
|
|
121
|
+
*/
|
|
122
|
+
export declare function collectSkillScopePlan(name: string | undefined, raw: ScopeSelectorRaw, homeDir?: string): SkillScopePlan;
|
|
123
|
+
export interface SkillScopeOutcome {
|
|
124
|
+
plan: SkillScopePlan;
|
|
125
|
+
writeError?: string;
|
|
126
|
+
sync?: SyncReport;
|
|
127
|
+
}
|
|
128
|
+
/** Write inside a backup session so `trellis rollback` covers it, then the
|
|
129
|
+
* same skills sync `add` / `remove` run — narrowing is converged by
|
|
130
|
+
* `symlinkPlan`'s existing "canonical entry gone or scoped away" removal
|
|
131
|
+
* (design.md D5). */
|
|
132
|
+
export declare function applySkillScopeWithSync(plan: SkillScopePlan, opts?: {
|
|
133
|
+
homeDir?: string;
|
|
134
|
+
dryRun?: boolean;
|
|
135
|
+
backupSession?: BackupSession;
|
|
136
|
+
}): Promise<SkillScopeOutcome>;
|
|
137
|
+
export declare function runSkillScope(name: string | undefined, raw: ScopeSelectorRaw, opts?: {
|
|
138
|
+
homeDir?: string;
|
|
139
|
+
json?: boolean;
|
|
140
|
+
dryRun?: boolean;
|
|
141
|
+
}): Promise<{
|
|
142
|
+
exitCode: number;
|
|
143
|
+
}>;
|
package/dist/commands/skill.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis skill list|add|remove` — command-line CRUD for canonical
|
|
2
|
+
* `trellis skill list|add|remove|scope` — command-line CRUD for canonical
|
|
3
3
|
* skills (trellis-canonical-cli-crud), an alternative to hand-editing
|
|
4
4
|
* `~/.trellis/skills/<name>/SKILL.md` directly. `add`'s conflict
|
|
5
5
|
* decision and `remove`'s "sync will un-sync it" behavior both reuse
|
|
@@ -14,12 +14,13 @@ import { dirname } from "node:path";
|
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { decideDirImport } from "../lib/dirEquals.js";
|
|
16
16
|
import { findSkillFile } from "../lib/skillFile.js";
|
|
17
|
-
import { loadCanonicalSource } from "../core/canonical.js";
|
|
17
|
+
import { loadCanonicalSource, writeSkillScopeYaml } from "../core/canonical.js";
|
|
18
18
|
import { resolveScope } from "../core/types.js";
|
|
19
19
|
import { isBuiltinSkillName } from "../lib/builtinSkills.js";
|
|
20
20
|
import { openBackupSession } from "../lib/backup.js";
|
|
21
21
|
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
22
22
|
import { remoteSkillProvenance } from "./remoteSkill.js";
|
|
23
|
+
import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
|
|
23
24
|
function builtinSkillTemplate() {
|
|
24
25
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
return readFileSync(join(here, "..", "..", "schema", "builtin-skills", "trellis-runtime", "SKILL.md"), "utf8");
|
|
@@ -205,3 +206,83 @@ export async function runSkillRemove(name, opts = {}) {
|
|
|
205
206
|
const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
|
|
206
207
|
return { exitCode: failed || syncConflict ? 1 : 0 };
|
|
207
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* `skill scope <name> --agents a,b | --all | --none`
|
|
211
|
+
* (trellis-scope-editing-and-auth-status tasks.md 1.1). Plan only — the write
|
|
212
|
+
* and the cascade sync live in `applySkillScopeWithSync`.
|
|
213
|
+
*/
|
|
214
|
+
export function collectSkillScopePlan(name, raw, homeDir = homedir()) {
|
|
215
|
+
if (!name)
|
|
216
|
+
return { name: "(none)", action: "invalid-input", detail: "usage: trellis skill scope <name> --agents <ids> | --all | --none" };
|
|
217
|
+
if (isBuiltinSkillName(name)) {
|
|
218
|
+
return { name, action: "invalid-input", detail: `"${name}" is a Trellis package-owned Skill — its scope is not editable` };
|
|
219
|
+
}
|
|
220
|
+
const canonical = loadCanonicalSource(homeDir);
|
|
221
|
+
const selection = resolveScopeSelection(raw, canonical.managedAgents);
|
|
222
|
+
if (!selection.ok)
|
|
223
|
+
return { name, action: "invalid-input", detail: selection.detail };
|
|
224
|
+
const skill = canonical.skills.find((candidate) => candidate.name === name);
|
|
225
|
+
if (!skill)
|
|
226
|
+
return { name, action: "not-found", detail: `no canonical skill named "${name}"` };
|
|
227
|
+
const base = {
|
|
228
|
+
name,
|
|
229
|
+
scope: selection.scope,
|
|
230
|
+
mode: selection.mode,
|
|
231
|
+
normalizedFromFull: selection.normalizedFromFull,
|
|
232
|
+
effective: resolveScope(selection.scope, canonical.managedAgents),
|
|
233
|
+
};
|
|
234
|
+
const summary = describeScope(selection.scope, canonical.managedAgents);
|
|
235
|
+
const normalized = selection.normalizedFromFull ? " (every managed agent selected — recorded as the default, so a newly managed agent is included)" : "";
|
|
236
|
+
if (sameScope(skill.scope, selection.scope)) {
|
|
237
|
+
return { ...base, action: "already-set", detail: `scope is already ${summary}` };
|
|
238
|
+
}
|
|
239
|
+
return { ...base, action: "updated", detail: `set scope to ${summary}${normalized}` };
|
|
240
|
+
}
|
|
241
|
+
/** Write inside a backup session so `trellis rollback` covers it, then the
|
|
242
|
+
* same skills sync `add` / `remove` run — narrowing is converged by
|
|
243
|
+
* `symlinkPlan`'s existing "canonical entry gone or scoped away" removal
|
|
244
|
+
* (design.md D5). */
|
|
245
|
+
export async function applySkillScopeWithSync(plan, opts = {}) {
|
|
246
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
247
|
+
const ownSession = !opts.dryRun && !opts.backupSession && plan.action === "updated" ? openBackupSession(homeDir, "skill-scope") : undefined;
|
|
248
|
+
const session = opts.backupSession ?? ownSession;
|
|
249
|
+
let writeError;
|
|
250
|
+
if (!opts.dryRun && plan.action === "updated") {
|
|
251
|
+
const result = writeSkillScopeYaml(join(homeDir, ".trellis", "scope.yaml"), plan.name, plan.scope, session);
|
|
252
|
+
if (!result.ok)
|
|
253
|
+
writeError = result.error;
|
|
254
|
+
}
|
|
255
|
+
ownSession?.finalize();
|
|
256
|
+
let syncReport;
|
|
257
|
+
if (!writeError && plan.action === "updated" && existsSync(join(homeDir, ".trellis"))) {
|
|
258
|
+
syncReport = await collectSyncReport({ target: "skills", homeDir, dryRun: opts.dryRun });
|
|
259
|
+
}
|
|
260
|
+
return { plan, ...(writeError ? { writeError } : {}), ...(syncReport ? { sync: syncReport } : {}) };
|
|
261
|
+
}
|
|
262
|
+
export async function runSkillScope(name, raw, opts = {}) {
|
|
263
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
264
|
+
let outcome;
|
|
265
|
+
try {
|
|
266
|
+
outcome = await applySkillScopeWithSync(collectSkillScopePlan(name, raw, homeDir), { homeDir, dryRun: opts.dryRun });
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
270
|
+
return { exitCode: 1 };
|
|
271
|
+
}
|
|
272
|
+
const { plan, writeError, sync: syncReport } = outcome;
|
|
273
|
+
if (opts.json) {
|
|
274
|
+
const payload = writeError ? { ...plan, writeError } : plan;
|
|
275
|
+
console.log(JSON.stringify(syncReport ? { ...payload, sync: syncReport } : payload, null, 2));
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
console.log(`${opts.dryRun ? "[dry run] " : ""}skill scope ${plan.name}`);
|
|
279
|
+
console.log(` [${plan.action}] ${plan.detail}`);
|
|
280
|
+
if (writeError)
|
|
281
|
+
console.error(` write failed: ${writeError}`);
|
|
282
|
+
if (syncReport)
|
|
283
|
+
printSyncReport(syncReport, opts.dryRun ?? false);
|
|
284
|
+
}
|
|
285
|
+
const failed = plan.action === "invalid-input" || plan.action === "not-found" || Boolean(writeError);
|
|
286
|
+
const syncConflict = syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false;
|
|
287
|
+
return { exitCode: failed || syncConflict ? 1 : 0 };
|
|
288
|
+
}
|
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",
|
package/dist/lib/mcpRuntime.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
2
2
|
import type { CallToolResult, GetPromptResult, Implementation, Prompt, ReadResourceResult, Resource, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import type { AgentId } from "../core/types.js";
|
|
4
4
|
import type { GatewayBackend } from "./gatewayBackend.js";
|
|
5
|
+
import type { SkillEntry } from "./skillManifest.js";
|
|
5
6
|
export interface RuntimeContext {
|
|
6
7
|
agentId: AgentId;
|
|
7
8
|
homeDir: string;
|
|
@@ -14,6 +15,9 @@ export interface TrellisProvider {
|
|
|
14
15
|
readResource?(uri: URL, context: RuntimeContext): Promise<ReadResourceResult> | ReadResourceResult;
|
|
15
16
|
listPrompts?(context: RuntimeContext): Promise<Prompt[]> | Prompt[];
|
|
16
17
|
getPrompt?(name: string, args: Record<string, string> | undefined, context: RuntimeContext): Promise<GetPromptResult> | GetPromptResult;
|
|
18
|
+
/** Skills-over-MCP `skills/list` and `skills/get` (trellis-skills-over-mcp). */
|
|
19
|
+
listSkillEntries?(context: RuntimeContext): Promise<SkillEntry[]> | SkillEntry[];
|
|
20
|
+
getSkillEntry?(uri: string, context: RuntimeContext): Promise<SkillEntry | undefined> | SkillEntry | undefined;
|
|
17
21
|
close?(): Promise<void>;
|
|
18
22
|
}
|
|
19
23
|
export declare class BuiltinRegistry {
|
|
@@ -29,6 +33,8 @@ export declare class BuiltinRegistry {
|
|
|
29
33
|
callTool(name: string, args: unknown, context: RuntimeContext): Promise<CallToolResult>;
|
|
30
34
|
listResources(context: RuntimeContext): Promise<Resource[]>;
|
|
31
35
|
readResource(uri: URL, context: RuntimeContext): Promise<ReadResourceResult>;
|
|
36
|
+
listSkills(context: RuntimeContext): Promise<SkillEntry[]>;
|
|
37
|
+
getSkill(uri: string, context: RuntimeContext): Promise<SkillEntry | undefined>;
|
|
32
38
|
listPrompts(context: RuntimeContext): Promise<Prompt[]>;
|
|
33
39
|
getPrompt(name: string, args: Record<string, string> | undefined, context: RuntimeContext): Promise<GetPromptResult>;
|
|
34
40
|
close(): Promise<void>;
|
package/dist/lib/mcpRuntime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
-
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { CallToolRequestSchema, ErrorCode, McpError, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
3
3
|
import { allocateExposedToolNames } from "./mcpToolRegistry.js";
|
|
4
4
|
function compactBuiltinToolName(name) {
|
|
5
5
|
return name.startsWith("trellis.") ? name.slice("trellis.".length).replaceAll(".", "_") : name;
|
|
@@ -106,6 +106,35 @@ export class BuiltinRegistry {
|
|
|
106
106
|
throw new Error(`unknown Trellis resource "${key}"`);
|
|
107
107
|
return provider.readResource(uri, context);
|
|
108
108
|
}
|
|
109
|
+
async listSkills(context) {
|
|
110
|
+
const skills = [];
|
|
111
|
+
for (const provider of this.providers) {
|
|
112
|
+
if (!provider.listSkillEntries)
|
|
113
|
+
continue;
|
|
114
|
+
try {
|
|
115
|
+
skills.push(...(await provider.listSkillEntries(context)));
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
this.warn(providerError(provider.id, err));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return skills;
|
|
122
|
+
}
|
|
123
|
+
async getSkill(uri, context) {
|
|
124
|
+
for (const provider of this.providers) {
|
|
125
|
+
if (!provider.getSkillEntry)
|
|
126
|
+
continue;
|
|
127
|
+
try {
|
|
128
|
+
const entry = await provider.getSkillEntry(uri, context);
|
|
129
|
+
if (entry)
|
|
130
|
+
return entry;
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
this.warn(providerError(provider.id, err));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
109
138
|
async listPrompts(context) {
|
|
110
139
|
const prompts = [];
|
|
111
140
|
this.promptOwners.clear();
|
|
@@ -181,5 +210,27 @@ export function createRuntimeServer(serverInfo, context, registry) {
|
|
|
181
210
|
server.setRequestHandler(ReadResourceRequestSchema, async (request) => registry.readResource(new URL(request.params.uri), context));
|
|
182
211
|
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await registry.listPrompts(context) }));
|
|
183
212
|
server.setRequestHandler(GetPromptRequestSchema, async (request) => registry.getPrompt(request.params.name, request.params.arguments, context));
|
|
213
|
+
// `skills/list` and `skills/get` are not in this SDK's schema set (it speaks
|
|
214
|
+
// at most 2025-11-25), so they are answered through the SDK's fallback for
|
|
215
|
+
// methods with no registered handler, which keeps the extension free of a new
|
|
216
|
+
// direct zod dependency. The extension itself is deliberately NOT declared in
|
|
217
|
+
// the capabilities above: the specification declares it only through
|
|
218
|
+
// `server/discover`, which this revision lacks, and inventing another field
|
|
219
|
+
// would claim a protocol nobody defined (trellis-skills-over-mcp D4). Any
|
|
220
|
+
// other unknown method keeps the SDK's usual "method not found".
|
|
221
|
+
server.fallbackRequestHandler = async (request) => {
|
|
222
|
+
const cache = { resultType: "complete", ttlMs: 0, cacheScope: "private" };
|
|
223
|
+
if (request.method === "skills/list") {
|
|
224
|
+
return { ...cache, skills: await registry.listSkills(context) };
|
|
225
|
+
}
|
|
226
|
+
if (request.method === "skills/get") {
|
|
227
|
+
const uri = request.params?.uri;
|
|
228
|
+
const entry = typeof uri === "string" ? await registry.getSkill(uri, context) : undefined;
|
|
229
|
+
if (!entry)
|
|
230
|
+
throw new McpError(ErrorCode.InvalidParams, "unknown skill");
|
|
231
|
+
return { ...cache, skill: entry };
|
|
232
|
+
}
|
|
233
|
+
throw new McpError(ErrorCode.MethodNotFound, `Method not found: ${request.method}`);
|
|
234
|
+
};
|
|
184
235
|
return server;
|
|
185
236
|
}
|
package/dist/lib/oauth/flow.d.ts
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* The interactive half: dynamic client registration (RFC 7591) plus the
|
|
3
3
|
* authorization-code grant with PKCE (RFC 6749 §4.1, RFC 7636).
|
|
4
4
|
*
|
|
5
|
+
* Registration is a fallback, not a requirement: a caller that already
|
|
6
|
+
* holds a pre-registered `client_id` passes it in and the RFC 7591 step
|
|
7
|
+
* is skipped entirely (trellis-mcp-oauth-static-client design.md D3) —
|
|
8
|
+
* some providers refuse registration outright, and those are exactly the
|
|
9
|
+
* ones that publish a client for third-party use instead.
|
|
10
|
+
*
|
|
5
11
|
* Only ever reached from `trellis mcp auth`, run by a human. The gateway
|
|
6
12
|
* must never call into this file: it is spawned silently by an agent,
|
|
7
13
|
* typically with no terminal and no display, so it can neither show a
|
|
@@ -31,6 +37,13 @@ export interface AuthorizeOptions {
|
|
|
31
37
|
* endpoint — that is a legitimate configuration (a pre-registered
|
|
32
38
|
* client), not an error, so the caller falls back to whatever client id
|
|
33
39
|
* it was given.
|
|
40
|
+
*
|
|
41
|
+
* A refused registration is the one failure a human can actually fix, so
|
|
42
|
+
* the AS's own OAuth error code is read out of the response body rather
|
|
43
|
+
* than collapsed into a bare status: `unauthorized_client` is the
|
|
44
|
+
* provider saying "this server is not open to registration", which is
|
|
45
|
+
* precisely when a pre-registered `client_id` is the answer
|
|
46
|
+
* (trellis-mcp-oauth-static-client design.md D3).
|
|
34
47
|
*/
|
|
35
48
|
export declare function registerClient(metadata: AuthorizationServerMetadata, redirectUri: string, opts?: {
|
|
36
49
|
fetchImpl?: FetchLike;
|
package/dist/lib/oauth/flow.js
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* The interactive half: dynamic client registration (RFC 7591) plus the
|
|
3
3
|
* authorization-code grant with PKCE (RFC 6749 §4.1, RFC 7636).
|
|
4
4
|
*
|
|
5
|
+
* Registration is a fallback, not a requirement: a caller that already
|
|
6
|
+
* holds a pre-registered `client_id` passes it in and the RFC 7591 step
|
|
7
|
+
* is skipped entirely (trellis-mcp-oauth-static-client design.md D3) —
|
|
8
|
+
* some providers refuse registration outright, and those are exactly the
|
|
9
|
+
* ones that publish a client for third-party use instead.
|
|
10
|
+
*
|
|
5
11
|
* Only ever reached from `trellis mcp auth`, run by a human. The gateway
|
|
6
12
|
* must never call into this file: it is spawned silently by an agent,
|
|
7
13
|
* typically with no terminal and no display, so it can neither show a
|
|
@@ -17,6 +23,13 @@ export class AuthorizationError extends Error {
|
|
|
17
23
|
* endpoint — that is a legitimate configuration (a pre-registered
|
|
18
24
|
* client), not an error, so the caller falls back to whatever client id
|
|
19
25
|
* it was given.
|
|
26
|
+
*
|
|
27
|
+
* A refused registration is the one failure a human can actually fix, so
|
|
28
|
+
* the AS's own OAuth error code is read out of the response body rather
|
|
29
|
+
* than collapsed into a bare status: `unauthorized_client` is the
|
|
30
|
+
* provider saying "this server is not open to registration", which is
|
|
31
|
+
* precisely when a pre-registered `client_id` is the answer
|
|
32
|
+
* (trellis-mcp-oauth-static-client design.md D3).
|
|
20
33
|
*/
|
|
21
34
|
export async function registerClient(metadata, redirectUri, opts = {}) {
|
|
22
35
|
if (!metadata.registrationEndpoint)
|
|
@@ -35,7 +48,10 @@ export async function registerClient(metadata, redirectUri, opts = {}) {
|
|
|
35
48
|
}),
|
|
36
49
|
});
|
|
37
50
|
if (!response.ok) {
|
|
38
|
-
|
|
51
|
+
const code = await readOAuthErrorCode(response);
|
|
52
|
+
throw new AuthorizationError(code
|
|
53
|
+
? `dynamic client registration was refused (${code})`
|
|
54
|
+
: `dynamic client registration failed with HTTP ${response.status}`);
|
|
39
55
|
}
|
|
40
56
|
const payload = (await response.json());
|
|
41
57
|
if (!payload.client_id) {
|
|
@@ -46,6 +62,22 @@ export async function registerClient(metadata, redirectUri, opts = {}) {
|
|
|
46
62
|
out.clientSecret = payload.client_secret;
|
|
47
63
|
return out;
|
|
48
64
|
}
|
|
65
|
+
/** RFC 6749 §5.2 error body. Best-effort by design: a registration
|
|
66
|
+
* endpoint that answers with plain text or an empty body still produces
|
|
67
|
+
* a usable error, just a less specific one. */
|
|
68
|
+
async function readOAuthErrorCode(response) {
|
|
69
|
+
try {
|
|
70
|
+
const payload = (await response.json());
|
|
71
|
+
return typeof payload.error === "string" ? payload.error : undefined;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Provider-neutral remediation, appended wherever registration is the
|
|
78
|
+
* thing that failed. Names the exact file and key so the fix needs no
|
|
79
|
+
* further search. */
|
|
80
|
+
const STATIC_CLIENT_REMEDIATION = "register a client with the provider and set auth.client_id (plus auth.client_secret_env if the provider requires a secret) under this server in ~/.trellis/mcp/servers.yaml";
|
|
49
81
|
/** A one-shot loopback listener for the redirect. Bound to 127.0.0.1
|
|
50
82
|
* explicitly, never 0.0.0.0: the authorization code arrives in this URL,
|
|
51
83
|
* and a listener on all interfaces would accept it from the network. */
|
|
@@ -101,9 +133,22 @@ export async function authorize(serverName, metadata, opts = {}) {
|
|
|
101
133
|
let clientId = opts.clientId;
|
|
102
134
|
let clientSecret = opts.clientSecret;
|
|
103
135
|
if (!clientId) {
|
|
104
|
-
|
|
136
|
+
// The AS cannot register us AND canonical carries no client_id — the
|
|
137
|
+
// one dead end a person has to resolve by hand, so it is reported as
|
|
138
|
+
// such rather than as a generic registration failure
|
|
139
|
+
// (trellis-mcp-oauth-static-client design.md D3, task 2.3).
|
|
140
|
+
let registered;
|
|
141
|
+
try {
|
|
142
|
+
registered = await registerClient(metadata, redirectUri, { fetchImpl, scope: opts.scope });
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err instanceof AuthorizationError) {
|
|
146
|
+
throw new AuthorizationError(`"${serverName}": ${err.message} — ${STATIC_CLIENT_REMEDIATION}`);
|
|
147
|
+
}
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
105
150
|
if (!registered) {
|
|
106
|
-
throw new AuthorizationError(`"${serverName}"'s authorization server offers no dynamic client registration
|
|
151
|
+
throw new AuthorizationError(`"${serverName}"'s authorization server offers no dynamic client registration — ${STATIC_CLIENT_REMEDIATION}`);
|
|
107
152
|
}
|
|
108
153
|
clientId = registered.clientId;
|
|
109
154
|
clientSecret = registered.clientSecret;
|