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/pi-bridge/bundle.js
CHANGED
|
@@ -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 (!
|
|
15270
|
+
if (!existsSync2(path)) {
|
|
15205
15271
|
return {};
|
|
15206
15272
|
}
|
|
15207
|
-
const parsed = (0, import_yaml.parse)(
|
|
15273
|
+
const parsed = (0, import_yaml.parse)(readFileSync2(path, "utf-8"));
|
|
15208
15274
|
return parsed ?? {};
|
|
15209
15275
|
}
|
|
15210
15276
|
function loadServersYaml(path) {
|
|
15211
|
-
if (!
|
|
15277
|
+
if (!existsSync2(path)) {
|
|
15212
15278
|
return { servers: {}, knownHostInjected: [] };
|
|
15213
15279
|
}
|
|
15214
|
-
const parsed = (0, import_yaml.parse)(
|
|
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 (!
|
|
15321
|
+
if (!existsSync2(path)) {
|
|
15256
15322
|
return { allowedVars: [], rejectPatterns: [] };
|
|
15257
15323
|
}
|
|
15258
|
-
const parsed = (0, import_yaml.parse)(
|
|
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 (!
|
|
15332
|
+
if (!existsSync2(path)) {
|
|
15267
15333
|
return [];
|
|
15268
15334
|
}
|
|
15269
|
-
const parsed = (0, import_yaml.parse)(
|
|
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 (!
|
|
15375
|
+
if (!existsSync2(root)) {
|
|
15291
15376
|
throw new Error(
|
|
15292
|
-
`No canonical source at ${root}.
|
|
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
|
|
15336
|
-
|
|
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
|
|
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
|
|
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)}`);
|
package/dist/pi-bridge/index.js
CHANGED
|
@@ -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
|
|
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);
|
package/docs/architecture.md
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|