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.
@@ -15140,11 +15140,21 @@ import { homedir as homedir2 } from "node:os";
15140
15140
 
15141
15141
  // src/core/canonical.ts
15142
15142
  var import_yaml = __toESM(require_dist(), 1);
15143
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
15143
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync, statSync, writeFileSync } from "node:fs";
15144
15144
  import { homedir } from "node:os";
15145
15145
  import { basename, dirname, join } from "node:path";
15146
15146
 
15147
15147
  // src/core/types.ts
15148
+ function isOAuthAuth(auth2) {
15149
+ return auth2 === "oauth" || typeof auth2 === "object" && auth2 !== null && auth2.kind === "oauth";
15150
+ }
15151
+ function oauthClientMetadata(auth2) {
15152
+ if (typeof auth2 !== "object" || auth2 === null) return {};
15153
+ const meta2 = {};
15154
+ if (auth2.clientId !== void 0) meta2.clientId = auth2.clientId;
15155
+ if (auth2.clientSecretEnv !== void 0) meta2.clientSecretEnv = auth2.clientSecretEnv;
15156
+ return meta2;
15157
+ }
15148
15158
  var ALL_AGENTS = [
15149
15159
  "claude-code",
15150
15160
  "codex",
@@ -15161,12 +15171,68 @@ function capabilityDeliveryForAgent(agentId, mcp) {
15161
15171
  return mcp.runtime?.delivery?.[agentId] ?? "native";
15162
15172
  }
15163
15173
 
15174
+ // src/lib/secretEnv.ts
15175
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
15176
+ var LINE_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
15177
+ var VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
15178
+ function isValidSecretVarName(name) {
15179
+ return VAR_NAME_RE.test(name);
15180
+ }
15181
+ function parseDotenv(content) {
15182
+ const result = {};
15183
+ for (const rawLine of content.split("\n")) {
15184
+ const line = rawLine.trim();
15185
+ if (line === "" || line.startsWith("#")) continue;
15186
+ const match = LINE_RE.exec(line);
15187
+ if (match) result[match[1]] = match[2];
15188
+ }
15189
+ return result;
15190
+ }
15191
+ function loadEnvFile(path) {
15192
+ return existsSync(path) ? parseDotenv(readFileSync(path, "utf-8")) : {};
15193
+ }
15194
+ function resolveSecretEnv(names, policy) {
15195
+ const result = {};
15196
+ if (policy.envFile) {
15197
+ const map = loadEnvFile(policy.envFile);
15198
+ for (const name of names) result[name] = map[name];
15199
+ } else {
15200
+ for (const name of names) result[name] = process.env[name];
15201
+ }
15202
+ return result;
15203
+ }
15204
+
15164
15205
  // src/core/canonical.ts
15165
- function fromServerDefYaml(def) {
15166
- const { static_env, env_aliases, ...rest } = def;
15206
+ function fromServerDefYaml(serverName, def) {
15207
+ const { static_env, env_aliases, auth: auth2, ...rest } = def;
15167
15208
  const out = { ...rest };
15168
15209
  if (static_env) out.staticEnv = static_env;
15169
15210
  if (env_aliases) out.envAliases = env_aliases;
15211
+ if (auth2 !== void 0) out.auth = fromAuthYaml(serverName, auth2);
15212
+ return out;
15213
+ }
15214
+ function fromAuthYaml(serverName, auth2) {
15215
+ if (auth2 === "oauth") return "oauth";
15216
+ if (typeof auth2 !== "object" || auth2 === null) {
15217
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth must be the scalar "oauth" or { kind: oauth, client_id?, client_secret_env? }`);
15218
+ }
15219
+ const { kind, client_id, client_secret_env, ...extra } = auth2;
15220
+ const unknownKey = Object.keys(extra)[0];
15221
+ if (unknownKey !== void 0) {
15222
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth has unrecognized key "${unknownKey}" \u2014 expected kind, client_id, client_secret_env`);
15223
+ }
15224
+ if (kind !== "oauth") {
15225
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth.kind must be "oauth" \u2014 kind is the only recognized discriminator`);
15226
+ }
15227
+ if (client_id !== void 0 && typeof client_id !== "string") {
15228
+ throw new Error(`mcp/servers.yaml: "${serverName}" auth.client_id must be a string`);
15229
+ }
15230
+ if (client_secret_env !== void 0 && (typeof client_secret_env !== "string" || !isValidSecretVarName(client_secret_env))) {
15231
+ 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`);
15232
+ }
15233
+ const out = { kind: "oauth" };
15234
+ if (typeof client_id === "string") out.clientId = client_id;
15235
+ if (typeof client_secret_env === "string") out.clientSecretEnv = client_secret_env;
15170
15236
  return out;
15171
15237
  }
15172
15238
  function trellisRoot(homeDir) {
@@ -15201,18 +15267,18 @@ function listSkillDirs(dir) {
15201
15267
  return skills;
15202
15268
  }
15203
15269
  function loadScopeYaml(path) {
15204
- if (!existsSync(path)) {
15270
+ if (!existsSync2(path)) {
15205
15271
  return {};
15206
15272
  }
15207
- const parsed = (0, import_yaml.parse)(readFileSync(path, "utf-8"));
15273
+ const parsed = (0, import_yaml.parse)(readFileSync2(path, "utf-8"));
15208
15274
  return parsed ?? {};
15209
15275
  }
15210
15276
  function loadServersYaml(path) {
15211
- if (!existsSync(path)) {
15277
+ if (!existsSync2(path)) {
15212
15278
  return { servers: {}, knownHostInjected: [] };
15213
15279
  }
15214
- const parsed = (0, import_yaml.parse)(readFileSync(path, "utf-8")) ?? {};
15215
- const servers = Object.fromEntries(Object.entries(parsed.servers ?? {}).map(([name, def]) => [name, fromServerDefYaml(def)]));
15280
+ const parsed = (0, import_yaml.parse)(readFileSync2(path, "utf-8")) ?? {};
15281
+ const servers = Object.fromEntries(Object.entries(parsed.servers ?? {}).map(([name, def]) => [name, fromServerDefYaml(name, def)]));
15216
15282
  return {
15217
15283
  servers,
15218
15284
  knownHostInjected: parsed.known_host_injected ?? [],
@@ -15252,10 +15318,10 @@ function fromGatewayYaml(gateway) {
15252
15318
  return out;
15253
15319
  }
15254
15320
  function loadSecretsPolicyYaml(path, homeDir) {
15255
- if (!existsSync(path)) {
15321
+ if (!existsSync2(path)) {
15256
15322
  return { allowedVars: [], rejectPatterns: [] };
15257
15323
  }
15258
- const parsed = (0, import_yaml.parse)(readFileSync(path, "utf-8")) ?? {};
15324
+ const parsed = (0, import_yaml.parse)(readFileSync2(path, "utf-8")) ?? {};
15259
15325
  return {
15260
15326
  allowedVars: parsed.allowed_vars ?? [],
15261
15327
  rejectPatterns: (parsed.reject_patterns ?? []).map((pattern) => new RegExp(pattern)),
@@ -15263,10 +15329,10 @@ function loadSecretsPolicyYaml(path, homeDir) {
15263
15329
  };
15264
15330
  }
15265
15331
  function loadManagedYaml(path, diagnostics) {
15266
- if (!existsSync(path)) {
15332
+ if (!existsSync2(path)) {
15267
15333
  return [];
15268
15334
  }
15269
- const parsed = (0, import_yaml.parse)(readFileSync(path, "utf-8")) ?? {};
15335
+ const parsed = (0, import_yaml.parse)(readFileSync2(path, "utf-8")) ?? {};
15270
15336
  const raw = parsed.agents ?? [];
15271
15337
  const valid = [];
15272
15338
  for (const id of raw) {
@@ -15285,11 +15351,30 @@ function validAgentIds(scope) {
15285
15351
  if (!scope) return true;
15286
15352
  return scope.every((id) => ALL_AGENTS.includes(id));
15287
15353
  }
15354
+ function assertAuthMetadataClean(mcp, policy) {
15355
+ for (const [name, def] of Object.entries(mcp.servers)) {
15356
+ const meta2 = oauthClientMetadata(def.auth);
15357
+ const fields = [
15358
+ ["client_id", meta2.clientId],
15359
+ ["client_secret_env", meta2.clientSecretEnv]
15360
+ ];
15361
+ for (const [field, value] of fields) {
15362
+ if (value === void 0) continue;
15363
+ for (const pattern of policy.rejectPatterns) {
15364
+ if (pattern.test(value)) {
15365
+ throw new Error(
15366
+ `mcp/servers.yaml: "${name}" auth.${field} matches a secrets.policy.yaml reject pattern \u2014 client_id must be the provider-published id, client_secret_env a variable name; the offending value is intentionally not printed`
15367
+ );
15368
+ }
15369
+ }
15370
+ }
15371
+ }
15372
+ }
15288
15373
  function loadCanonicalSource(homeDir = homedir()) {
15289
15374
  const root = trellisRoot(homeDir);
15290
- if (!existsSync(root)) {
15375
+ if (!existsSync2(root)) {
15291
15376
  throw new Error(
15292
- `No canonical source at ${root}. Create it before running trellis sync \u2014 see docs/architecture.md's canonical schema.`
15377
+ `No canonical source at ${root}. Run trellis init before trellis sync.`
15293
15378
  );
15294
15379
  }
15295
15380
  const diagnostics = [];
@@ -15326,17 +15411,17 @@ function loadCanonicalSource(homeDir = homedir()) {
15326
15411
  }
15327
15412
  }
15328
15413
  }
15414
+ const mcp = loadServersYaml(join(root, "mcp", "servers.yaml"));
15415
+ const secretsPolicy = loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir);
15416
+ assertAuthMetadataClean(mcp, secretsPolicy);
15329
15417
  return {
15330
15418
  instructionsFile: join(root, "agents.md"),
15331
15419
  managedAgents,
15332
15420
  skills,
15333
15421
  agents,
15334
15422
  memories,
15335
- mcp: loadServersYaml(join(root, "mcp", "servers.yaml")),
15336
- // P2's pre-write guard (src/adapters/mcpPlan.ts) uses its own narrow,
15337
- // hardcoded floor instead of this field — see design.md D1 in
15338
- // trellis-secrets-audit-p3.
15339
- secretsPolicy: loadSecretsPolicyYaml(join(root, "secrets.policy.yaml"), homeDir),
15423
+ mcp,
15424
+ secretsPolicy,
15340
15425
  diagnostics
15341
15426
  };
15342
15427
  }
@@ -15358,33 +15443,6 @@ function codexBearerTokenEnvVar(def) {
15358
15443
  return BEARER_TOKEN_VALUE_RE.exec(value)?.[1];
15359
15444
  }
15360
15445
 
15361
- // src/lib/secretEnv.ts
15362
- import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2 } from "node:fs";
15363
- var LINE_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
15364
- function parseDotenv(content) {
15365
- const result = {};
15366
- for (const rawLine of content.split("\n")) {
15367
- const line = rawLine.trim();
15368
- if (line === "" || line.startsWith("#")) continue;
15369
- const match = LINE_RE.exec(line);
15370
- if (match) result[match[1]] = match[2];
15371
- }
15372
- return result;
15373
- }
15374
- function loadEnvFile(path) {
15375
- return existsSync2(path) ? parseDotenv(readFileSync2(path, "utf-8")) : {};
15376
- }
15377
- function resolveSecretEnv(names, policy) {
15378
- const result = {};
15379
- if (policy.envFile) {
15380
- const map = loadEnvFile(policy.envFile);
15381
- for (const name of names) result[name] = map[name];
15382
- } else {
15383
- for (const name of names) result[name] = process.env[name];
15384
- }
15385
- return result;
15386
- }
15387
-
15388
15446
  // src/adapters/mcpPlan.ts
15389
15447
  var HUB_ENTRY_NAME = "trellis-hub";
15390
15448
  var GATEWAY_ENTRY_NAME = "trellis";
@@ -15392,7 +15450,7 @@ var RUNTIME_ENTRY_NAME = "trellis";
15392
15450
  var GATEWAY_COMMAND = "trellis";
15393
15451
  var RUNTIME_COMMAND = "trellis";
15394
15452
  function isOAuthMcpServer(def) {
15395
- return def.auth === "oauth";
15453
+ return isOAuthAuth(def.auth);
15396
15454
  }
15397
15455
  function isGatewayAgent(agentId, mcp, managedAgents) {
15398
15456
  const route = mcp.routes?.[agentId];
@@ -32557,7 +32615,7 @@ async function trellisMcpBridge(pi, homeDir = homedir2(), connectTimeoutMs = DEF
32557
32615
  desired.map(async ({ name, def }) => {
32558
32616
  let client;
32559
32617
  try {
32560
- const effectiveDef = def.auth === "oauth" && def.url ? await withOAuthHeader(name, def, homeDir) : def;
32618
+ const effectiveDef = isOAuthAuth(def.auth) && def.url ? await withOAuthHeader(name, def, homeDir) : def;
32561
32619
  client = await connectServer(effectiveDef, canonical.secretsPolicy, connectTimeoutMs, CLIENT_INFO);
32562
32620
  } catch (err) {
32563
32621
  console.error(`trellis-mcp-bridge: failed to connect to MCP server "${name}": ${err instanceof Error ? err.message : String(err)}`);
@@ -17,7 +17,7 @@ import { resolveMcpPlan } from "../adapters/mcpPlan.js";
17
17
  import { connectServer, withTimeout, DEFAULT_CONNECT_TIMEOUT_MS } from "../lib/mcpConnect.js";
18
18
  import { McpToolRegistry } from "../lib/mcpToolRegistry.js";
19
19
  import { withOAuthHeader } from "../lib/gatewayBackend.js";
20
- import { ALL_AGENTS } from "../core/types.js";
20
+ import { ALL_AGENTS, isOAuthAuth } from "../core/types.js";
21
21
  import { toParametersSchema, toPiContent } from "./schemaTranslate.js";
22
22
  /** Re-exported so `trellis-mcp-connect-timeout`'s existing test keeps
23
23
  * importing it from the module it was written against — proving the
@@ -75,7 +75,7 @@ export default async function trellisMcpBridge(pi, homeDir = homedir(), connectT
75
75
  const discovered = await Promise.all(desired.map(async ({ name, def }) => {
76
76
  let client;
77
77
  try {
78
- const effectiveDef = def.auth === "oauth" && def.url
78
+ const effectiveDef = isOAuthAuth(def.auth) && def.url
79
79
  ? await withOAuthHeader(name, def, homeDir)
80
80
  : def;
81
81
  client = await connectServer(effectiveDef, canonical.secretsPolicy, connectTimeoutMs, CLIENT_INFO);
@@ -266,6 +266,25 @@ flow for Claude Code/Codex/Kiro; Pi's bridge is the client for its direct OAuth
266
266
  connection and may use Trellis's own OAuth store. Trellis never infers this
267
267
  classification from a URL or a transient 401.
268
268
 
269
+ The classification optionally carries pre-registered client metadata, for
270
+ providers that refuse Dynamic Client Registration and require an allowlisted
271
+ client instead:
272
+
273
+ ```yaml
274
+ auth:
275
+ kind: oauth
276
+ client_id: "<provider-published-id>" # public by design, literal
277
+ client_secret_env: PROVIDER_CLIENT_SECRET # a NAME, resolved via secrets.policy.yaml
278
+ ```
279
+
280
+ `client_id` wins over DCR when present; DCR stays the fallback when it is
281
+ absent. There is deliberately no literal secret field — a secret enters only
282
+ as a variable name, the same `env`-style discipline everything else follows.
283
+ See docs/mcp-oauth-matrix.md for which providers are open to registration and
284
+ which are not, and for the distribution rule: examples, docs, and seed data
285
+ ship placeholders only, and only provider-**published** identifiers may ever
286
+ be pre-filled.
287
+
269
288
  Trellis still implements the client half itself — discovery (RFC 8414 /
270
289
  RFC 9728), dynamic client registration (RFC 7591), authorization code with
271
290
  PKCE S256 (RFC 7636), and the refresh grant — in `src/lib/oauth/`, but that
@@ -0,0 +1,140 @@
1
+ # Remote MCP providers — how OAuth identity works, and where Trellis can help
2
+
3
+ `trellis mcp auth <name>` can only complete an authorization-code grant if it
4
+ is allowed to *have* a client identity. Whether it may is entirely the
5
+ provider's decision, and that decision splits remote MCP providers into two
6
+ families. This page records which family each known provider is in, what
7
+ evidence that rests on, and what Trellis does in each case.
8
+
9
+ Read alongside docs/architecture.md §"Static header auth vs. real OAuth",
10
+ which covers the *other* shape — a long-lived bearer token or API key in a
11
+ `headers` field — and why Trellis builds for both.
12
+
13
+ ## The two families
14
+
15
+ **DCR-open.** The provider implements RFC 7591 Dynamic Client Registration.
16
+ Trellis POSTs its own metadata, receives a `client_id`, and holds its own
17
+ identity per installation. Nothing a user has to supply; nothing Trellis has
18
+ to distribute. This already works: it is what `trellis mcp auth` has done
19
+ since `trellis-mcp-gateway-hosting`.
20
+
21
+ **Allowlist.** The provider refuses registration — either by not advertising a
22
+ `registration_endpoint`, or by answering it with `unauthorized_client` — and
23
+ requires clients to be pre-registered with the provider. There is no
24
+ self-service path: the user obtains a `client_id` out of band and supplies it.
25
+ That is what the `auth.client_id` / `auth.client_secret_env` fields exist for
26
+ (trellis-mcp-oauth-static-client design.md D1–D3).
27
+
28
+ The 2026-07-28 MCP authorization spec moved in the allowlist direction,
29
+ downgrading Dynamic Client Registration from a MUST to a MAY in favour of
30
+ pre-registration. Expect the second family to grow; the mechanism, not the
31
+ per-provider list, is what Trellis commits to.
32
+
33
+ ## Matrix
34
+
35
+ | Provider | Family | What identifies the client | Evidence | Confidence |
36
+ |---|---|---|---|---|
37
+ | Sentry | DCR-open | `client_id` from DCR | Local successful grant against the real server; secondary sources describing its DCR endpoint | Medium — see the note below |
38
+ | Supabase | DCR-open | `client_id` from DCR | Same | Medium |
39
+ | Morphix | DCR-open | `client_id` from DCR | Same | Medium |
40
+ | GitHub | DCR-open | `client_id` from DCR | Same | Medium |
41
+ | Notion | DCR-open | `client_id` from DCR | Same | Medium |
42
+ | Linear | DCR-open | `client_id` from DCR | Same | Medium |
43
+ | Cloudflare | DCR-open | `client_id` from DCR | Same | Medium |
44
+ | Figma | Allowlist | Provider-published `client_id`, supplied by the user | Registration refused with `unauthorized_client` — observed directly, and the reason this change exists | High |
45
+
46
+ **On confidence.** "High" means the behavior was observed directly against the
47
+ live provider and the failure (or success) is reproducible from this
48
+ repository's own test fixtures. "Medium" means the status rests on a working
49
+ local grant plus secondary sources rather than a first-party policy statement —
50
+ the *observed* behavior is solid, but a provider is free to change its
51
+ registration policy at any time, and none of these have published a
52
+ contractual commitment either way. Sentry is explicitly in this bucket
53
+ (trellis-mcp-oauth-static-client design.md D6): the local grant proves
54
+ registration works today, not that it will next quarter.
55
+
56
+ Treat the whole table as a snapshot of observed behavior, not as provider
57
+ policy. `trellis mcp auth` reports the actual failure when a row goes stale —
58
+ see "When a provider refuses" below.
59
+
60
+ ## What actually identifies a client
61
+
62
+ There is no magic header and no provider-specific handshake. Any OAuth 2.0
63
+ authorization server that speaks the MCP authorization profile identifies a
64
+ client by a combination of:
65
+
66
+ - **`client_id`** — the identity itself. Public by design for a public client:
67
+ it travels in the browser URL on every authorization (`?client_id=...`), so
68
+ it is visible to the user, their browser history, and any proxy in between.
69
+ Its security rests on the *other* factors here, never on its secrecy.
70
+ - **Exact `redirect_uri` match** — registered with the client and required to
71
+ match byte-for-byte. This is why Trellis's loopback callback is what it is:
72
+ `http://127.0.0.1:<port>/callback`, with the port chosen by the OS at bind
73
+ time. A pre-registered client whose provider pins one specific port is the
74
+ one case where a fixed `callbackPort` is needed.
75
+ - **PKCE (RFC 7636, `S256`)** — the proof that whoever redeems the code is
76
+ whoever started the flow. The verifier never travels through the browser;
77
+ only its SHA-256 challenge does. Trellis's fake Authorization Server in the
78
+ test suite *enforces* this rather than rubber-stamping it, so the tests
79
+ cannot pass for a client that gets PKCE wrong.
80
+ - **`resource` parameter** — binds the grant to the MCP server it was issued
81
+ for, so a token minted for one server is not replayable against another.
82
+
83
+ Nothing in that list is a secret for a public client, which is exactly why
84
+ `client_id` is a plain literal in canonical YAML and `client_secret_env` — the
85
+ one field that *is* a credential — is a variable NAME resolved through
86
+ `secrets.policy.yaml` and never a value (design.md D2).
87
+
88
+ ## When a provider refuses
89
+
90
+ `trellis mcp auth <name>` tries pre-registered metadata first, then DCR. When
91
+ both are unavailable it fails with the remediation rather than a bare status
92
+ code:
93
+
94
+ ```
95
+ "figma": dynamic client registration was refused (unauthorized_client) —
96
+ register a client with the provider and set auth.client_id (plus
97
+ auth.client_secret_env if the provider requires a secret) under this server in
98
+ ~/.trellis/mcp/servers.yaml
99
+ ```
100
+
101
+ The fix is then two commands:
102
+
103
+ ```sh
104
+ trellis mcp set figma --auth oauth --client-id <provider-published-id>
105
+ trellis mcp auth figma
106
+ ```
107
+
108
+ The id comes from the provider. Trellis does not ship one, look one up for
109
+ you, or read another client's stored credentials to borrow an identity —
110
+ that last option is rejected structurally, not just by policy (design.md D5:
111
+ borrowing a refresh token makes both clients fight over rotation, and reading
112
+ another tool's credential store would mean decrypting files Trellis does not
113
+ own).
114
+
115
+ ## Distribution boundary
116
+
117
+ Trellis is open source. What it distributes is a mechanism — the schema field,
118
+ the flow, the tests. It does not distribute an identity.
119
+
120
+ A user who obtains a `client_id` and pastes it into their own
121
+ `~/.trellis/mcp/servers.yaml` is making their own decision under the provider's
122
+ terms, in the same position as anyone running `mcp-remote
123
+ --static-oauth-client-info`. Trellis shipping that same id in
124
+ `schema/servers.example.yaml`, in this page, or in seed data would instead make
125
+ the project itself the distributor of a whitelist-bypass tool. So:
126
+
127
+ - Examples and docs use placeholders only — see the commented block in
128
+ `schema/servers.example.yaml`.
129
+ - Only provider-**published** identifiers may ever be pre-filled. None exist
130
+ today, so nothing is pre-filled.
131
+ - A literal that matches `reject_patterns` is refused at load and at write, so
132
+ a pasted credential cannot quietly become part of the canonical source.
133
+
134
+ ## See also
135
+
136
+ - docs/architecture.md §"Static header auth vs. real OAuth" — the other shape.
137
+ - `schema/servers.example.yaml` — the object form, with placeholders.
138
+ - trellis-mcp-oauth-static-client design.md — D1 (field shape), D2 (literal vs.
139
+ name), D3 (metadata wins over DCR), D4 (distribution boundary), D5 (no token
140
+ borrowing), D6 (this matrix's confidence notes).
package/docs/research.md CHANGED
@@ -216,6 +216,17 @@ every agent's adapter renders it through the exact same reference mechanism
216
216
  `env` already uses, and the pi bridge resolves it through the exact same
217
217
  `resolveSecretEnv` call.
218
218
 
219
+ The same reference-not-value rule is what shapes OAuth client metadata
220
+ (trellis-mcp-oauth-static-client). A public client's `client_id` is published
221
+ by design — it rides in the browser URL on every authorization — so it is a
222
+ plain literal, comparable to how `mcp-remote --static-oauth-client-info` and
223
+ `claude mcp add --client-id` already treat it. A `client_secret` is the
224
+ opposite kind of thing, so it enters only as `client_secret_env`, a variable
225
+ NAME resolved through this same machinery; there is deliberately no literal
226
+ secret field. Which providers accept an unregistered client at all is a
227
+ per-provider question — see docs/mcp-oauth-matrix.md, including its note that
228
+ that matrix records observed behavior, not provider policy.
229
+
219
230
  ## pi coding agent — the one place we write real code, not config generation
220
231
 
221
232
  Reverse-engineered directly from the installed binary
@@ -0,0 +1,79 @@
1
+ # Skills over MCP — what Trellis serves, and what it does not claim
2
+
3
+ The MCP Skills extension (`io.modelcontextprotocol/skills`, SEP-2640, Final)
4
+ defines how a server publishes Agent Skills through the Resources primitive.
5
+ Trellis's runtime serves its canonical skills in that shape. This page records
6
+ the exact behavior, including what is deliberately *not* done.
7
+
8
+ ## What is served
9
+
10
+ For each canonical skill in the requesting agent's scope:
11
+
12
+ - `skill://<name>/SKILL.md` and every supporting file at
13
+ `skill://<name>/<relative-path>`, through `resources/list` and
14
+ `resources/read`.
15
+ - `skills/list` and `skills/get`, returning the specified entry: the skill's
16
+ `uri`, its frontmatter verbatim, and a complete manifest of
17
+ `{ uri, digest, size }` per file, with `resultType`, `ttlMs` and `cacheScope`.
18
+ `cacheScope` is `private` because the catalog differs per agent.
19
+
20
+ The existing surface is unchanged: `trellis.skills.search|read|read_file` and
21
+ `trellis://skills/<name>/SKILL.md` keep working exactly as before. The
22
+ `skill://` view is a second, additive view of the same data.
23
+
24
+ ## The manifest is computed from the bytes served
25
+
26
+ `digest` is `sha256:` plus 64 lowercase hex characters of the raw bytes;
27
+ `size` is their length. The same reader produces the manifest and the
28
+ `resources/read` response, so they cannot disagree. Invalid UTF-8 is returned as
29
+ a base64 `blob` rather than lossy `text`, so the bytes a host verifies are the
30
+ bytes that were hashed.
31
+
32
+ A file the reader refuses — secret-shaped path, hidden file or directory,
33
+ `node_modules`, anything resolving outside the skill root, oversized — is absent
34
+ from the manifest and unreadable, both. A skill with more than 512 files or
35
+ 16 MiB is omitted whole and reported on stderr, never published with a partial
36
+ manifest.
37
+
38
+ A skill is served under `skill://` only when its directory name equals its
39
+ frontmatter `name`, that name is a valid Agent Skills name (lowercase letters,
40
+ digits, single hyphens), and it has a non-empty `description`. Otherwise it is
41
+ omitted and the reason is reported once. It stays reachable through the existing
42
+ tools.
43
+
44
+ ## A digest is consistency, not trust
45
+
46
+ The extension is explicit that digests are unsigned and that an intermediary can
47
+ rewrite entry and content together. A matching digest tells a host the bytes
48
+ match the manifest; it says nothing about whether the content is safe. Nothing in
49
+ Trellis treats a match as an authorization.
50
+
51
+ ## The extension is not declared yet
52
+
53
+ The extension is declared through `server/discover`, introduced with protocol
54
+ revision 2026-07-28. The MCP SDK Trellis uses speaks at most 2025-11-25 and has
55
+ neither `server/discover` nor `skills/*`, and the specification defines no
56
+ declaration for the `initialize` handshake. Declaring it in another field would
57
+ invent a protocol, and a conforming client only issues `skills/list` after
58
+ observing a declaration anyway.
59
+
60
+ So on today's SDK the runtime declares nothing, answers `skills/list` and
61
+ `skills/get` through the SDK's fallback for unregistered methods, and serves
62
+ `skill://` as ordinary resources — which the specification expressly allows. Once
63
+ the SDK exposes `server/discover`, the declaration is a one-place change. Any other
64
+ unknown method still gets JSON-RPC `-32601`.
65
+
66
+ ## Relaying skills from upstream servers is not implemented
67
+
68
+ Today an upstream server's skills are not relayed — the gateway backend
69
+ aggregates tools only. When a change adds that, it must:
70
+
71
+ - identify a skill by (upstream label, URI) together, never by URI alone, with a
72
+ namespace per upstream;
73
+ - never let an upstream skill shadow a canonical skill or another upstream's;
74
+ - bind reads to the originating upstream, with no cross-upstream reads;
75
+ - pass the upstream manifest through unchanged or re-derive it from the relayed
76
+ bytes — never pair an upstream digest with different bytes;
77
+ - not write an upstream skill into `~/.trellis/skills` automatically. The
78
+ specification requires per-skill user approval before such content is used, and
79
+ materializing it would bypass that.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "A unified Code Agent Runtime for Skills, MCP, Memory, and shared instructions across Claude Code, Codex, Kiro, pi, Kimi Code, and ZCode.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Leo",
@@ -34,7 +34,7 @@
34
34
  "pretest": "node scripts/build-pi-bridge.mjs",
35
35
  "test": "tsx --test \"test/unit/**/*.test.ts\"",
36
36
  "verify-pack": "node scripts/verify-pack.mjs",
37
- "prepublishOnly": "npm run typecheck && npm run build && npm test && npm run verify-pack"
37
+ "prepublishOnly": "npm run typecheck && npm run build && npm test"
38
38
  },
39
39
  "engines": {
40
40
  "node": ">=20"
@@ -118,6 +118,24 @@ servers:
118
118
  # Explicit OAuth classification keeps this server direct when an Agent
119
119
  # uses gateway mode. Trellis never infers OAuth from a URL or 401.
120
120
  # auth: oauth
121
+ #
122
+ # Object form (trellis-mcp-oauth-static-client): for a provider that
123
+ # rejects Dynamic Client Registration (Figma does — `unauthorized_client`)
124
+ # and requires a pre-registered client. `kind: oauth` is the only
125
+ # discriminator and the only required key; a bare `auth: oauth` above and
126
+ # a metadata-less `{ kind: oauth }` are exactly equivalent.
127
+ # - `client_id` is a PUBLIC literal by design — fine to commit. Use the
128
+ # identifier the PROVIDER published for this client; a self-registered
129
+ # one from your own account only if you accept managing it.
130
+ # - `client_secret_env` is a variable NAME, resolved through
131
+ # secrets.policy.yaml at `trellis mcp auth` time — never a secret
132
+ # value, and never a literal secret field (there is none).
133
+ # Placeholders below on purpose: only provider-published identifiers may
134
+ # ever be pre-filled in a distribution artifact.
135
+ # auth:
136
+ # kind: oauth
137
+ # client_id: "<provider-published-client-id>"
138
+ # client_secret_env: FIGMA_CLIENT_SECRET
121
139
 
122
140
  # Example of a private/agent-specific server: only Claude Code has a use
123
141
  # for this one, so the other three agents never see it defined at all.