agent-trellis 0.11.0 → 0.13.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 +1 -0
- package/dist/adapters/mcpPlan.js +14 -5
- package/dist/cli.js +18 -2
- package/dist/commands/doctor.d.ts +22 -2
- package/dist/commands/doctor.js +51 -3
- package/dist/commands/mcp.d.ts +13 -2
- package/dist/commands/mcp.js +42 -5
- package/dist/commands/mcpAuth.d.ts +6 -0
- package/dist/commands/mcpAuth.js +38 -10
- package/dist/commands/mcpGateway.d.ts +1 -0
- package/dist/commands/mcpGateway.js +39 -11
- package/dist/commands/mcpRenew.d.ts +52 -0
- package/dist/commands/mcpRenew.js +104 -0
- package/dist/core/canonical.d.ts +1 -0
- package/dist/core/canonical.js +8 -3
- package/dist/core/types.d.ts +4 -1
- package/dist/core/types.js +3 -0
- package/dist/lib/agentBridgeProvider.d.ts +1 -1
- package/dist/lib/gatewayBackend.d.ts +16 -0
- package/dist/lib/gatewayBackend.js +25 -5
- package/dist/lib/mcpConnect.d.ts +6 -3
- package/dist/lib/mcpConnect.js +8 -6
- package/dist/lib/mcpRuntime.d.ts +6 -2
- package/dist/lib/mcpRuntime.js +39 -29
- package/dist/lib/mcpStatusProvider.d.ts +36 -2
- package/dist/lib/mcpStatusProvider.js +27 -4
- package/dist/lib/memoryProvider.d.ts +1 -1
- package/dist/lib/oauth/callbackPage.d.ts +11 -0
- package/dist/lib/oauth/callbackPage.js +89 -0
- package/dist/lib/oauth/discovery.d.ts +1 -0
- package/dist/lib/oauth/discovery.js +2 -0
- package/dist/lib/oauth/flow.d.ts +4 -0
- package/dist/lib/oauth/flow.js +59 -19
- package/dist/lib/oauth/refresh.d.ts +1 -0
- package/dist/lib/oauth/refresh.js +3 -1
- package/dist/lib/oauth/renew.d.ts +75 -0
- package/dist/lib/oauth/renew.js +86 -0
- package/dist/lib/oauth/store.d.ts +2 -0
- package/dist/lib/runtimeControlProvider.d.ts +1 -1
- package/dist/lib/skillManifest.d.ts +1 -0
- package/dist/lib/skillManifest.js +1 -1
- package/dist/lib/skillProvider.d.ts +1 -1
- package/dist/lib/taskProvider.d.ts +1 -1
- package/dist/lib/upstreamResources.d.ts +23 -0
- package/dist/lib/upstreamResources.js +192 -0
- package/dist/pi-bridge/bundle.js +37558 -19584
- package/docs/architecture.md +14 -13
- package/docs/getting-started.md +9 -0
- package/docs/mcp-authorization-ownership.md +78 -0
- package/docs/mcp-oauth-renewal.md +88 -0
- package/docs/skills-over-mcp.md +43 -31
- package/package.json +5 -2
|
@@ -34,6 +34,7 @@ export declare const RUNTIME_COMMAND = "trellis";
|
|
|
34
34
|
* routing it exists to carry (trellis-mcp-oauth-static-client design.md
|
|
35
35
|
* D1). */
|
|
36
36
|
export declare function isOAuthMcpServer(def: McpServerDef): boolean;
|
|
37
|
+
export declare function isAgentOwnedOAuth(def: McpServerDef): boolean;
|
|
37
38
|
/**
|
|
38
39
|
* Gateway mode applies to every managed agent when `agents` is omitted —
|
|
39
40
|
* the expected normal case (design.md D14). An explicit list narrows it,
|
package/dist/adapters/mcpPlan.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { isInScope } from "../core/adapter.js";
|
|
20
20
|
import { capabilityDeliveryForAgent } from "../core/types.js";
|
|
21
|
-
import { isOAuthAuth } from "../core/types.js";
|
|
21
|
+
import { isOAuthAuth, oauthOwner } from "../core/types.js";
|
|
22
22
|
import { codexBearerTokenEnvVar } from "../lib/tomlSection.js";
|
|
23
23
|
import { resolveSecretEnv } from "../lib/secretEnv.js";
|
|
24
24
|
export const HUB_ENTRY_NAME = "trellis-hub";
|
|
@@ -40,6 +40,9 @@ export const RUNTIME_COMMAND = "trellis";
|
|
|
40
40
|
export function isOAuthMcpServer(def) {
|
|
41
41
|
return isOAuthAuth(def.auth);
|
|
42
42
|
}
|
|
43
|
+
export function isAgentOwnedOAuth(def) {
|
|
44
|
+
return isOAuthMcpServer(def) && oauthOwner(def.auth) === "agent";
|
|
45
|
+
}
|
|
43
46
|
/**
|
|
44
47
|
* Gateway mode applies to every managed agent when `agents` is omitted —
|
|
45
48
|
* the expected normal case (design.md D14). An explicit list narrows it,
|
|
@@ -158,7 +161,7 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
|
|
|
158
161
|
conflicts: [{ name: entryName, message: collisionMessage(entryName, agentId), remediation: collisionRemediation(entryName) }],
|
|
159
162
|
};
|
|
160
163
|
}
|
|
161
|
-
const oauth = planDirectServers(agentId, mcp, managedAgents, policy, route,
|
|
164
|
+
const oauth = planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
|
|
162
165
|
// Every ordinary per-server check (enabled, scope, literal secrets,
|
|
163
166
|
// unresolved env names, Codex's header shape) still runs at gateway
|
|
164
167
|
// startup. OAuth servers are the deliberate exception: they are written
|
|
@@ -234,15 +237,21 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
|
|
|
234
237
|
// Runtime mode owns the agent-facing MCP edge. It resolves the same
|
|
235
238
|
// direct/gateway-compatible upstream route at startup, so native
|
|
236
239
|
// per-server entries are not duplicated here.
|
|
240
|
+
const oauth = planDirectServers(agentId, mcp, managedAgents, policy, route, isAgentOwnedOAuth);
|
|
237
241
|
return {
|
|
238
242
|
desired: [{
|
|
239
243
|
name: RUNTIME_ENTRY_NAME,
|
|
240
244
|
def: { transport: "stdio", command: RUNTIME_COMMAND, args: ["mcp-runtime", "--agent", agentId] },
|
|
241
|
-
}],
|
|
242
|
-
conflicts:
|
|
245
|
+
}, ...oauth.desired],
|
|
246
|
+
conflicts: oauth.conflicts,
|
|
243
247
|
};
|
|
244
248
|
}
|
|
245
|
-
|
|
249
|
+
const native = planDirectServers(agentId, mcp, managedAgents, policy, route);
|
|
250
|
+
const hosted = native.desired.filter(({ def }) => isOAuthMcpServer(def) && oauthOwner(def.auth) === "trellis");
|
|
251
|
+
return {
|
|
252
|
+
desired: native.desired.filter(({ def }) => !isOAuthMcpServer(def) || oauthOwner(def.auth) === "agent"),
|
|
253
|
+
conflicts: [...native.conflicts, ...hosted.map(({ name }) => ({ name, message: `Trellis-owned OAuth server "${name}" requires gateway or runtime delivery for ${agentId}`, remediation: "enable gateway/runtime delivery or set auth.owner to agent" }))],
|
|
254
|
+
};
|
|
246
255
|
}
|
|
247
256
|
function planDirectServers(agentId, mcp, managedAgents, policy, route, filter) {
|
|
248
257
|
const desired = [];
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { parseScopeArgs } from "./lib/scopeSelection.js";
|
|
|
14
14
|
import { runMcpImport } from "./commands/mcpImport.js";
|
|
15
15
|
import { parseMcpGatewayArgs, runMcpGateway, runMcpRuntime } from "./commands/mcpGateway.js";
|
|
16
16
|
import { runMcpAuth } from "./commands/mcpAuth.js";
|
|
17
|
+
import { runMcpRenew } from "./commands/mcpRenew.js";
|
|
17
18
|
import { runSecretsAudit } from "./commands/secretsAudit.js";
|
|
18
19
|
import { runRollback } from "./commands/rollback.js";
|
|
19
20
|
import { runSkillList, runSkillAdd, runSkillRemove, runSkillScope, runSkillUpdateBuiltin } from "./commands/skill.js";
|
|
@@ -133,7 +134,7 @@ Commands:
|
|
|
133
134
|
--json machine-readable output, no report text
|
|
134
135
|
mcp set <name> --auth oauth|none
|
|
135
136
|
Set or clear explicit OAuth classification; canonical only.
|
|
136
|
-
--auth oauth [--client-id <id>] [--client-secret-env <NAME>]
|
|
137
|
+
--auth oauth [--auth-owner agent|trellis] [--client-id <id>] [--client-secret-env <NAME>]
|
|
137
138
|
pre-register a client for a provider that refuses
|
|
138
139
|
dynamic registration. --client-id is the PROVIDER's
|
|
139
140
|
published identifier (public by design);
|
|
@@ -159,6 +160,16 @@ Commands:
|
|
|
159
160
|
refreshes silently on its own and never prompts.
|
|
160
161
|
--force re-authorize even if the stored token is valid
|
|
161
162
|
--json machine-readable output, no report text
|
|
163
|
+
mcp renew
|
|
164
|
+
Silently refresh every Trellis-owned stored OAuth token that
|
|
165
|
+
needs it —
|
|
166
|
+
never opens a browser, never performs an initial grant.
|
|
167
|
+
Reports per-server health and exits non-zero when any
|
|
168
|
+
classified server needs 'mcp auth'. Safe under a scheduler
|
|
169
|
+
(launchd): idempotent on valid tokens, refreshes serialized
|
|
170
|
+
per server, overlapping runs degrade to one refresh.
|
|
171
|
+
--dry-run report status without any token-endpoint call
|
|
172
|
+
--json machine-readable output, no report text
|
|
162
173
|
mcp-runtime --agent <agent>
|
|
163
174
|
Internal MCP edge exposing Trellis providers and selected
|
|
164
175
|
upstream capabilities; existing mcp-gateway configs remain valid
|
|
@@ -470,6 +481,11 @@ async function main(argv) {
|
|
|
470
481
|
process.exitCode = (await runMcpRemove(name, { json, dryRun })).exitCode;
|
|
471
482
|
return;
|
|
472
483
|
}
|
|
484
|
+
if (subcommand === "renew") {
|
|
485
|
+
const { exitCode } = await runMcpRenew({ json, dryRun });
|
|
486
|
+
process.exitCode = exitCode;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
473
489
|
if (subcommand === "auth") {
|
|
474
490
|
const [serverName] = mcpRest;
|
|
475
491
|
if (!serverName || serverName.startsWith("--")) {
|
|
@@ -481,7 +497,7 @@ async function main(argv) {
|
|
|
481
497
|
process.exitCode = exitCode;
|
|
482
498
|
return;
|
|
483
499
|
}
|
|
484
|
-
console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|scope <name>|remove <name>|auth <name
|
|
500
|
+
console.error(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\nUsage: trellis mcp sync|list|add <name>|set <name>|scope <name>|remove <name>|auth <name>|renew\n`);
|
|
485
501
|
process.exitCode = 1;
|
|
486
502
|
return;
|
|
487
503
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* throughout docs/research.md's investigation, now formalized.
|
|
6
6
|
*/
|
|
7
7
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
8
|
-
import type { AgentId, AgentSnapshot } from "../core/types.js";
|
|
8
|
+
import type { AgentId, AgentSnapshot, CanonicalSource } from "../core/types.js";
|
|
9
9
|
/**
|
|
10
10
|
* Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
|
|
11
11
|
* real `known_host_injected` field (P0 has no canonical source at all —
|
|
@@ -14,7 +14,7 @@ import type { AgentId, AgentSnapshot } from "../core/types.js";
|
|
|
14
14
|
* machine.
|
|
15
15
|
*/
|
|
16
16
|
export declare const DEFAULT_KNOWN_HOST_INJECTED: readonly string[];
|
|
17
|
-
export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "mcp-unreachable";
|
|
17
|
+
export type FindingKind = "probe-failed" | "duplication" | "collision" | "drift" | "case-mismatch" | "parse-diagnostic" | "runtime-drift" | "oauth-expired" | "mcp-unreachable";
|
|
18
18
|
export interface Finding {
|
|
19
19
|
kind: FindingKind;
|
|
20
20
|
/** Omitted for a finding that names more than one agent (drift). */
|
|
@@ -48,9 +48,19 @@ export interface RunDoctorOptions {
|
|
|
48
48
|
* every other Trellis entry point uses. */
|
|
49
49
|
homeDir?: string;
|
|
50
50
|
}
|
|
51
|
+
export interface OAuthHealthEntry {
|
|
52
|
+
server: string;
|
|
53
|
+
status: "valid" | "expired" | "absent" | "unreadable";
|
|
54
|
+
expiresAt?: number;
|
|
55
|
+
}
|
|
51
56
|
export interface DoctorReport {
|
|
52
57
|
snapshots: AgentSnapshot[];
|
|
53
58
|
findings: Finding[];
|
|
59
|
+
/** Credential health for canonical OAuth-classified servers. Absent
|
|
60
|
+
* when no canonical source exists (doctor stays standalone, P0) or no
|
|
61
|
+
* server is OAuth-classified. Read-only by construction
|
|
62
|
+
* (trellis-mcp-oauth-renew design.md D4). */
|
|
63
|
+
oauth?: OAuthHealthEntry[];
|
|
54
64
|
}
|
|
55
65
|
/** Exported so tests can assert on this resolution directly, without
|
|
56
66
|
* routing through real per-agent probes `runDoctor` also runs. */
|
|
@@ -67,6 +77,16 @@ export declare function runDoctor(opts?: RunDoctorOptions): Promise<{
|
|
|
67
77
|
* `undefined`) is unaffected.
|
|
68
78
|
*/
|
|
69
79
|
export declare function collectDoctorReport(homeDir?: string, knownHostInjected?: readonly string[], probeMcp?: boolean): Promise<DoctorReport>;
|
|
80
|
+
/**
|
|
81
|
+
* Read-only OAuth credential health (trellis-mcp-oauth-renew design.md
|
|
82
|
+
* D4): classified from the stored token file alone — no token endpoint,
|
|
83
|
+
* no writes. Scope is canonical Trellis-owned OAuth servers only, the
|
|
84
|
+
* same rule `trellis mcp renew` iterates (design.md D5 +
|
|
85
|
+
* trellis-desktop-oauth-ownership: an agent-owned server's authorization
|
|
86
|
+
* lives in the Agent's own client, so Trellis's empty token store for it
|
|
87
|
+
* is expected, never a finding), via the same classifier (design.md D2).
|
|
88
|
+
*/
|
|
89
|
+
export declare function collectOAuthHealth(homeDir: string, canonical: Pick<CanonicalSource, "mcp">): OAuthHealthEntry[];
|
|
70
90
|
/**
|
|
71
91
|
* Within one agent, across all of its skill roots: any realpath reached by
|
|
72
92
|
* more than one entry, where at least one of those entries is NOT a
|
package/dist/commands/doctor.js
CHANGED
|
@@ -13,7 +13,9 @@ import * as kimiCodeProbe from "../probes/kimi-code.js";
|
|
|
13
13
|
import * as zcodeProbe from "../probes/zcode.js";
|
|
14
14
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
15
15
|
import { GATEWAY_ENTRY_NAME, RUNTIME_ENTRY_NAME, resolveMcpPlan } from "../adapters/mcpPlan.js";
|
|
16
|
-
import { ALL_AGENTS } from "../core/types.js";
|
|
16
|
+
import { ALL_AGENTS, isOAuthAuth, oauthOwner } from "../core/types.js";
|
|
17
|
+
import { classifyStoredToken, remediationFor } from "../lib/oauth/renew.js";
|
|
18
|
+
import { readToken } from "../lib/oauth/store.js";
|
|
17
19
|
/**
|
|
18
20
|
* Interim default until P1 wires this from `.trellis/mcp/servers.yaml`'s
|
|
19
21
|
* real `known_host_injected` field (P0 has no canonical source at all —
|
|
@@ -36,6 +38,10 @@ const EXIT_NONZERO_KINDS = new Set([
|
|
|
36
38
|
"case-mismatch",
|
|
37
39
|
"parse-diagnostic",
|
|
38
40
|
"runtime-drift",
|
|
41
|
+
// A stored credential that will 401 on next use is exactly what doctor
|
|
42
|
+
// exists to catch *before* the session does (trellis-mcp-oauth-renew
|
|
43
|
+
// design.md D4).
|
|
44
|
+
"oauth-expired",
|
|
39
45
|
]);
|
|
40
46
|
/** Exported so tests can assert on this resolution directly, without
|
|
41
47
|
* routing through real per-agent probes `runDoctor` also runs. */
|
|
@@ -103,8 +109,42 @@ export async function collectDoctorReport(homeDir = homedir(), knownHostInjected
|
|
|
103
109
|
// Doctor remains useful as a standalone cross-agent scanner when
|
|
104
110
|
// canonical has not been initialized yet.
|
|
105
111
|
}
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
const oauth = canonical ? collectOAuthHealth(homeDir, canonical) : [];
|
|
113
|
+
findings.push(...detectDuplication(snapshots), ...detectCollisions(snapshots, knownHostInjected), ...detectCrossAgentDrift(snapshots), ...detectCaseMismatches(snapshots), ...detectParseDiagnostics(snapshots), ...(canonical ? detectRuntimeDrift(snapshots, canonical) : []), ...detectMcpUnreachable(snapshots), ...oauth.flatMap((entry) => (entry.status === "valid" ? [] : [oauthFinding(entry)])));
|
|
114
|
+
return { snapshots, findings, ...(oauth.length > 0 ? { oauth } : {}) };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Read-only OAuth credential health (trellis-mcp-oauth-renew design.md
|
|
118
|
+
* D4): classified from the stored token file alone — no token endpoint,
|
|
119
|
+
* no writes. Scope is canonical Trellis-owned OAuth servers only, the
|
|
120
|
+
* same rule `trellis mcp renew` iterates (design.md D5 +
|
|
121
|
+
* trellis-desktop-oauth-ownership: an agent-owned server's authorization
|
|
122
|
+
* lives in the Agent's own client, so Trellis's empty token store for it
|
|
123
|
+
* is expected, never a finding), via the same classifier (design.md D2).
|
|
124
|
+
*/
|
|
125
|
+
export function collectOAuthHealth(homeDir, canonical) {
|
|
126
|
+
return Object.entries(canonical.mcp.servers)
|
|
127
|
+
.filter(([, def]) => isOAuthAuth(def.auth) && oauthOwner(def.auth) === "trellis")
|
|
128
|
+
.map(([name]) => name)
|
|
129
|
+
.sort()
|
|
130
|
+
.map((name) => {
|
|
131
|
+
const status = classifyStoredToken(homeDir, name);
|
|
132
|
+
const expiresAt = readToken(homeDir, name)?.expiresAt;
|
|
133
|
+
const collapsed = status === "valid" ? "valid"
|
|
134
|
+
: status === "no-token" ? "absent"
|
|
135
|
+
: status === "unreadable" ? "unreadable"
|
|
136
|
+
: "expired";
|
|
137
|
+
return { server: name, status: collapsed, ...(expiresAt !== undefined ? { expiresAt } : {}) };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function oauthFinding(entry) {
|
|
141
|
+
const reason = entry.status === "absent" ? "has never been authorized"
|
|
142
|
+
: entry.status === "unreadable" ? "has an unreadable credential file"
|
|
143
|
+
: "has an expired stored token";
|
|
144
|
+
return {
|
|
145
|
+
kind: "oauth-expired",
|
|
146
|
+
message: `OAuth server "${entry.server}" ${reason} — ${remediationFor(entry.status === "expired" ? "expired-renewable" : "needs-auth", entry.server)}`,
|
|
147
|
+
};
|
|
108
148
|
}
|
|
109
149
|
/**
|
|
110
150
|
* Within one agent, across all of its skill roots: any realpath reached by
|
|
@@ -308,6 +348,14 @@ export function printReport(report) {
|
|
|
308
348
|
console.log(` - ${finding.message}`);
|
|
309
349
|
}
|
|
310
350
|
}
|
|
351
|
+
if (report.oauth && report.oauth.length > 0) {
|
|
352
|
+
console.log("\nOAuth credentials:");
|
|
353
|
+
for (const entry of report.oauth) {
|
|
354
|
+
const expiry = entry.expiresAt !== undefined ? ` (expires ${new Date(entry.expiresAt).toISOString()})` : "";
|
|
355
|
+
const icon = entry.status === "valid" ? "✅" : "❌";
|
|
356
|
+
console.log(`${icon} ${entry.server} — ${entry.status}${expiry}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
311
359
|
if (crossAgent.length > 0) {
|
|
312
360
|
console.log("\nCross-agent:");
|
|
313
361
|
for (const finding of crossAgent) {
|
package/dist/commands/mcp.d.ts
CHANGED
|
@@ -56,6 +56,12 @@ export interface McpListEntry {
|
|
|
56
56
|
/** Normalized: `oauth` in either the scalar or the object form reports as
|
|
57
57
|
* the scalar here — the listing reports classification, not metadata. */
|
|
58
58
|
auth?: McpAuthMode;
|
|
59
|
+
authOwner?: "agent" | "trellis";
|
|
60
|
+
agentAuthorization?: Array<{
|
|
61
|
+
agent: AgentId;
|
|
62
|
+
status: "unknown";
|
|
63
|
+
instruction: string;
|
|
64
|
+
}>;
|
|
59
65
|
/** Presence of pre-registered client metadata
|
|
60
66
|
* (trellis-mcp-oauth-static-client task 3.2) — `true` when the object form
|
|
61
67
|
* carries a `client_id`. A presence flag, deliberately not the id itself:
|
|
@@ -84,7 +90,7 @@ export interface McpListEntry {
|
|
|
84
90
|
* printed in full. */
|
|
85
91
|
staticEnv?: Record<string, string>;
|
|
86
92
|
}
|
|
87
|
-
export type McpAuthStatus = "not-authorized" | "authorized" | "refreshable" | "expired";
|
|
93
|
+
export type McpAuthStatus = "not-authorized" | "authorized" | "refreshable" | "expired" | "unknown";
|
|
88
94
|
/**
|
|
89
95
|
* Reads only the per-server token store and returns the state, never a
|
|
90
96
|
* token. Classification stays explicit — the caller only asks for servers
|
|
@@ -92,7 +98,7 @@ export type McpAuthStatus = "not-authorized" | "authorized" | "refreshable" | "e
|
|
|
92
98
|
* name that cannot be a token filename, or an unreadable file, simply has no
|
|
93
99
|
* credential: one odd entry must not take down the whole listing.
|
|
94
100
|
*/
|
|
95
|
-
export declare function mcpCredentialState(homeDir: string, serverName: string): {
|
|
101
|
+
export declare function mcpCredentialState(homeDir: string, serverName: string, resourceUrl?: string): {
|
|
96
102
|
authStatus: McpAuthStatus;
|
|
97
103
|
authExpiresAt?: number;
|
|
98
104
|
};
|
|
@@ -156,6 +162,7 @@ export interface McpSetAuthPlan {
|
|
|
156
162
|
* plan collector. */
|
|
157
163
|
export interface McpSetAuthRawArgs {
|
|
158
164
|
auth?: string;
|
|
165
|
+
authOwner?: string;
|
|
159
166
|
/** Public literal by design (design.md D2) — the one field it is fine to
|
|
160
167
|
* pass on a command line. Never echoed back in output or errors. */
|
|
161
168
|
clientId?: string;
|
|
@@ -188,6 +195,10 @@ export declare function runMcpSetAuth(name: string | undefined, raw: McpSetAuthR
|
|
|
188
195
|
}): {
|
|
189
196
|
exitCode: number;
|
|
190
197
|
};
|
|
198
|
+
export declare function applyMcpSetAuthWithSync(plan: McpSetAuthPlan, homeDir: string): Promise<{
|
|
199
|
+
plan: McpSetAuthPlan;
|
|
200
|
+
sync?: McpSyncReport;
|
|
201
|
+
}>;
|
|
191
202
|
export type McpScopeAction = "updated" | "already-set" | "not-found" | "invalid-input";
|
|
192
203
|
export interface McpScopePlan {
|
|
193
204
|
name: string;
|
package/dist/commands/mcp.js
CHANGED
|
@@ -16,7 +16,7 @@ import { homedir } from "node:os";
|
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { loadCanonicalSource, upsertServerYaml, removeServerYaml } from "../core/canonical.js";
|
|
19
|
-
import { ALL_AGENTS, isOAuthAuth, oauthClientMetadata, resolveScope } from "../core/types.js";
|
|
19
|
+
import { ALL_AGENTS, isOAuthAuth, oauthClientMetadata, oauthOwner, resolveScope } from "../core/types.js";
|
|
20
20
|
import { isValidSecretVarName } from "../lib/secretEnv.js";
|
|
21
21
|
import { isExpired, readToken } from "../lib/oauth/store.js";
|
|
22
22
|
import { describeScope, resolveScopeSelection, sameScope } from "../lib/scopeSelection.js";
|
|
@@ -123,7 +123,7 @@ function serversYamlPath(homeDir) {
|
|
|
123
123
|
* name that cannot be a token filename, or an unreadable file, simply has no
|
|
124
124
|
* credential: one odd entry must not take down the whole listing.
|
|
125
125
|
*/
|
|
126
|
-
export function mcpCredentialState(homeDir, serverName) {
|
|
126
|
+
export function mcpCredentialState(homeDir, serverName, resourceUrl) {
|
|
127
127
|
let token;
|
|
128
128
|
try {
|
|
129
129
|
token = readToken(homeDir, serverName);
|
|
@@ -133,6 +133,12 @@ export function mcpCredentialState(homeDir, serverName) {
|
|
|
133
133
|
}
|
|
134
134
|
if (!token?.accessToken)
|
|
135
135
|
return { authStatus: "not-authorized" };
|
|
136
|
+
if (resourceUrl && token.resourceUrl) {
|
|
137
|
+
const resource = new URL(resourceUrl);
|
|
138
|
+
resource.hash = "";
|
|
139
|
+
if (token.resourceUrl !== resource.toString())
|
|
140
|
+
return { authStatus: "not-authorized" };
|
|
141
|
+
}
|
|
136
142
|
const expiry = token.expiresAt !== undefined ? { authExpiresAt: token.expiresAt } : {};
|
|
137
143
|
if (!isExpired(token))
|
|
138
144
|
return { authStatus: "authorized", ...expiry };
|
|
@@ -145,7 +151,16 @@ export function collectMcpListPlan(homeDir = homedir()) {
|
|
|
145
151
|
transport: def.transport,
|
|
146
152
|
auth: isOAuthAuth(def.auth) ? "oauth" : undefined,
|
|
147
153
|
preRegisteredClient: oauthClientMetadata(def.auth).clientId !== undefined ? true : undefined,
|
|
148
|
-
...(isOAuthAuth(def.auth) ?
|
|
154
|
+
...(isOAuthAuth(def.auth) ? {
|
|
155
|
+
authOwner: oauthOwner(def.auth),
|
|
156
|
+
...(oauthOwner(def.auth) === "trellis" ? mcpCredentialState(homeDir, name, def.url) : {
|
|
157
|
+
authStatus: "unknown",
|
|
158
|
+
agentAuthorization: resolveScope(def.agents, canonical.managedAgents).map((agent) => ({
|
|
159
|
+
agent, status: "unknown",
|
|
160
|
+
instruction: agent === "codex" ? `codex mcp login ${name}` : agent === "claude-code" ? "Open Claude Code and use /mcp to authorize this server" : "Authorize this server in the Agent's MCP settings",
|
|
161
|
+
})),
|
|
162
|
+
}),
|
|
163
|
+
} : {}),
|
|
149
164
|
enabled: def.enabled ?? true,
|
|
150
165
|
agents: resolveScope(def.agents, canonical.managedAgents),
|
|
151
166
|
command: def.command,
|
|
@@ -211,6 +226,7 @@ export function parseMcpSetArgs(argv) {
|
|
|
211
226
|
auth: flag("--auth"),
|
|
212
227
|
clientId: flag("--client-id"),
|
|
213
228
|
clientSecretEnv: flag("--client-secret-env"),
|
|
229
|
+
authOwner: flag("--auth-owner"),
|
|
214
230
|
};
|
|
215
231
|
}
|
|
216
232
|
function parseCsv(raw) {
|
|
@@ -314,8 +330,10 @@ export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
|
|
|
314
330
|
const mode = raw.auth === "oauth" || raw.auth === "none" ? raw.auth : undefined;
|
|
315
331
|
if (!name)
|
|
316
332
|
return { name: "(none)", auth: (mode ?? "none"), action: "invalid-input", detail: "usage: trellis mcp set <name> --auth oauth|none [--client-id <id>] [--client-secret-env <NAME>]" };
|
|
317
|
-
const hasClientFlags = raw.clientId !== undefined || raw.clientSecretEnv !== undefined;
|
|
318
|
-
const clientFlagsDetail =
|
|
333
|
+
const hasClientFlags = raw.clientId !== undefined || raw.clientSecretEnv !== undefined || raw.authOwner !== undefined;
|
|
334
|
+
const clientFlagsDetail = raw.authOwner !== undefined
|
|
335
|
+
? "--auth-owner requires an explicit --auth oauth in the same command"
|
|
336
|
+
: "--client-id / --client-secret-env require an explicit --auth oauth in the same command";
|
|
319
337
|
if (mode === undefined) {
|
|
320
338
|
// Naming the missing --auth is the actionable error when that is what
|
|
321
339
|
// the user was reaching for; otherwise report the invalid value itself.
|
|
@@ -329,6 +347,9 @@ export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
|
|
|
329
347
|
if (raw.clientId === "") {
|
|
330
348
|
return { name, auth: mode, action: "invalid-input", detail: "--client-id must not be empty" };
|
|
331
349
|
}
|
|
350
|
+
if (raw.authOwner !== undefined && raw.authOwner !== "agent" && raw.authOwner !== "trellis") {
|
|
351
|
+
return { name, auth: mode, action: "invalid-input", detail: "--auth-owner must be agent or trellis" };
|
|
352
|
+
}
|
|
332
353
|
if (raw.clientSecretEnv !== undefined && !isValidSecretVarName(raw.clientSecretEnv)) {
|
|
333
354
|
// The value is deliberately not echoed: a name-shaped field failing
|
|
334
355
|
// this check is at best a typo and at worst a pasted credential
|
|
@@ -387,6 +408,7 @@ export function collectMcpSetAuthPlan(name, raw, homeDir = homedir()) {
|
|
|
387
408
|
...current,
|
|
388
409
|
auth: {
|
|
389
410
|
kind: "oauth",
|
|
411
|
+
...(raw.authOwner !== undefined ? { owner: raw.authOwner } : typeof current.auth === "object" && current.auth.owner !== undefined ? { owner: current.auth.owner } : {}),
|
|
390
412
|
...(clientId !== undefined ? { clientId } : {}),
|
|
391
413
|
...(clientSecretEnv !== undefined ? { clientSecretEnv } : {}),
|
|
392
414
|
},
|
|
@@ -424,6 +446,21 @@ export function runMcpSetAuth(name, raw, opts = {}) {
|
|
|
424
446
|
}
|
|
425
447
|
return { exitCode: ["invalid-input", "not-found"].includes(plan.action) || Boolean(writeError) ? 1 : 0 };
|
|
426
448
|
}
|
|
449
|
+
export async function applyMcpSetAuthWithSync(plan, homeDir) {
|
|
450
|
+
if (plan.action !== "updated" || !plan.def)
|
|
451
|
+
return { plan };
|
|
452
|
+
const backup = openBackupSession(homeDir, "mcp-auth-owner");
|
|
453
|
+
try {
|
|
454
|
+
const path = serversYamlPath(homeDir);
|
|
455
|
+
const result = upsertServerYaml(path, plan.name, plan.def, backup);
|
|
456
|
+
if (!result.ok)
|
|
457
|
+
throw new Error(result.error);
|
|
458
|
+
return { plan, sync: await collectMcpSyncReport({ homeDir, backupSession: backup }) };
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
backup.finalize();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
427
464
|
/**
|
|
428
465
|
* `mcp scope <name> --agents a,b | --all | --none`
|
|
429
466
|
* (trellis-scope-editing-and-auth-status tasks.md 1.2). Same selector rules as
|
|
@@ -16,6 +16,11 @@ export interface RunMcpAuthOptions {
|
|
|
16
16
|
homeDir?: string;
|
|
17
17
|
/** Test seam — the real one launches the platform browser. */
|
|
18
18
|
openBrowser?: (url: string) => void | Promise<void>;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
onProgress?: (phase: "discovering" | "registering" | "waiting" | "exchanging" | "saving") => void;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
/** Used by the desktop; native grants belong to each Agent. */
|
|
23
|
+
requireHosted?: boolean;
|
|
19
24
|
}
|
|
20
25
|
export interface McpAuthResult {
|
|
21
26
|
server: string;
|
|
@@ -26,3 +31,4 @@ export interface McpAuthResult {
|
|
|
26
31
|
export declare function runMcpAuth(opts: RunMcpAuthOptions): Promise<{
|
|
27
32
|
exitCode: number;
|
|
28
33
|
}>;
|
|
34
|
+
export declare function authorizeMcpServer(opts: RunMcpAuthOptions): Promise<McpAuthResult>;
|
package/dist/commands/mcpAuth.js
CHANGED
|
@@ -11,16 +11,17 @@
|
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
13
13
|
import { discoverAuthorizationServer, parseResourceMetadataUrl } from "../lib/oauth/discovery.js";
|
|
14
|
-
import { authorize } from "../lib/oauth/flow.js";
|
|
14
|
+
import { authorize, abortable } from "../lib/oauth/flow.js";
|
|
15
15
|
import { ensureFreshToken } from "../lib/oauth/refresh.js";
|
|
16
16
|
import { resolveSecretEnv } from "../lib/secretEnv.js";
|
|
17
17
|
import { isExpired, readToken, tokenPath, writeToken } from "../lib/oauth/store.js";
|
|
18
|
-
import {
|
|
18
|
+
import { withServerLock } from "../lib/oauth/lock.js";
|
|
19
|
+
import { isOAuthAuth, oauthOwner, oauthClientMetadata } from "../core/types.js";
|
|
19
20
|
export async function runMcpAuth(opts) {
|
|
20
21
|
const homeDir = opts.homeDir ?? homedir();
|
|
21
22
|
let result;
|
|
22
23
|
try {
|
|
23
|
-
result = await
|
|
24
|
+
result = await authorizeMcpServer(opts);
|
|
24
25
|
}
|
|
25
26
|
catch (err) {
|
|
26
27
|
console.error(err instanceof Error ? err.message : String(err));
|
|
@@ -41,7 +42,9 @@ export async function runMcpAuth(opts) {
|
|
|
41
42
|
}
|
|
42
43
|
return { exitCode: 0 };
|
|
43
44
|
}
|
|
44
|
-
async function
|
|
45
|
+
export async function authorizeMcpServer(opts) {
|
|
46
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
47
|
+
opts.signal?.throwIfAborted();
|
|
45
48
|
const canonical = loadCanonicalSource(homeDir);
|
|
46
49
|
const def = canonical.mcp.servers[opts.serverName];
|
|
47
50
|
if (!def) {
|
|
@@ -54,15 +57,21 @@ async function authorizeServer(opts, homeDir) {
|
|
|
54
57
|
// flow that does not exist.
|
|
55
58
|
throw new Error(`MCP server "${opts.serverName}" is a stdio server — OAuth applies to http/sse servers. Its credentials come from \`env\`/\`env_aliases\` and secrets.policy.yaml.`);
|
|
56
59
|
}
|
|
60
|
+
if (opts.requireHosted && (!isOAuthAuth(def.auth) || oauthOwner(def.auth) !== "trellis" || def.enabled === false)) {
|
|
61
|
+
throw new Error("This connection is authorized by its Agent; choose explicit Trellis ownership before starting hosted authorization");
|
|
62
|
+
}
|
|
57
63
|
const file = tokenPath(homeDir, opts.serverName);
|
|
64
|
+
const resourceUrl = new URL(def.url);
|
|
65
|
+
resourceUrl.hash = "";
|
|
58
66
|
if (!opts.force) {
|
|
59
|
-
const
|
|
67
|
+
const stored = readToken(homeDir, opts.serverName);
|
|
68
|
+
const existing = stored?.resourceUrl && stored.resourceUrl !== resourceUrl.toString() ? undefined : stored;
|
|
60
69
|
if (existing && !isExpired(existing)) {
|
|
61
70
|
// Re-running is a no-op rather than a fresh grant: rotating a
|
|
62
71
|
// perfectly good credential is a way to break a working setup.
|
|
63
72
|
return { server: opts.serverName, status: "already-valid", tokenFile: file, ...(existing.expiresAt ? { expiresAt: existing.expiresAt } : {}) };
|
|
64
73
|
}
|
|
65
|
-
if (existing?.refreshToken) {
|
|
74
|
+
if (existing?.refreshToken && !opts.requireHosted) {
|
|
66
75
|
// Expired but renewable — no browser needed.
|
|
67
76
|
const refreshed = await ensureFreshToken(homeDir, opts.serverName);
|
|
68
77
|
if (refreshed) {
|
|
@@ -70,7 +79,10 @@ async function authorizeServer(opts, homeDir) {
|
|
|
70
79
|
}
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
|
-
|
|
82
|
+
opts.onProgress?.("discovering");
|
|
83
|
+
const fetchImpl = (url, init) => fetch(url, { ...init, signal: opts.signal });
|
|
84
|
+
const metadata = await abortable(discoverAuthorizationServer(def.url, { wwwAuthenticate: await probeWwwAuthenticate(def, opts.signal), fetchImpl }), opts.signal);
|
|
85
|
+
opts.signal?.throwIfAborted();
|
|
74
86
|
if (!metadata) {
|
|
75
87
|
throw new Error(`could not discover OAuth metadata for "${opts.serverName}" (${def.url}) — it may not use OAuth, or may not publish RFC 8414/9728 metadata`);
|
|
76
88
|
}
|
|
@@ -90,12 +102,28 @@ async function authorizeServer(opts, homeDir) {
|
|
|
90
102
|
clientSecret = value;
|
|
91
103
|
}
|
|
92
104
|
const token = await authorize(opts.serverName, metadata, {
|
|
105
|
+
signal: opts.signal,
|
|
106
|
+
resourceUrl: resourceUrl.toString(),
|
|
107
|
+
onProgress: opts.onProgress,
|
|
108
|
+
timeoutMs: opts.timeoutMs,
|
|
93
109
|
...(opts.openBrowser ? { openBrowser: opts.openBrowser } : {}),
|
|
94
110
|
...(metadata.scopesSupported?.length ? { scope: metadata.scopesSupported.join(" ") } : {}),
|
|
95
111
|
...(client.clientId !== undefined ? { clientId: client.clientId } : {}),
|
|
96
112
|
...(clientSecret !== undefined ? { clientSecret } : {}),
|
|
97
113
|
});
|
|
98
|
-
|
|
114
|
+
opts.signal?.throwIfAborted();
|
|
115
|
+
// Serialize the final write with silent refreshes, without holding a lock
|
|
116
|
+
// while the user is in the browser. An older refresh cannot overwrite a new
|
|
117
|
+
// grant after it is committed.
|
|
118
|
+
opts.onProgress?.("saving");
|
|
119
|
+
await withServerLock(homeDir, opts.serverName, async () => {
|
|
120
|
+
opts.signal?.throwIfAborted();
|
|
121
|
+
const current = loadCanonicalSource(homeDir).mcp.servers[opts.serverName];
|
|
122
|
+
if (!current || current.url !== def.url || JSON.stringify(current.auth) !== JSON.stringify(def.auth) || current.enabled === false) {
|
|
123
|
+
throw new Error("The server's endpoint or authorization configuration changed; start authorization again");
|
|
124
|
+
}
|
|
125
|
+
writeToken(homeDir, opts.serverName, token);
|
|
126
|
+
});
|
|
99
127
|
return { server: opts.serverName, status: "authorized", tokenFile: file, ...(token.expiresAt ? { expiresAt: token.expiresAt } : {}) };
|
|
100
128
|
}
|
|
101
129
|
/**
|
|
@@ -113,9 +141,9 @@ function secretSourceLabel(policy) {
|
|
|
113
141
|
* server directly, which beats probing well-known paths. Failure is not
|
|
114
142
|
* an error: discovery falls back to probing.
|
|
115
143
|
*/
|
|
116
|
-
async function probeWwwAuthenticate(def) {
|
|
144
|
+
async function probeWwwAuthenticate(def, signal) {
|
|
117
145
|
try {
|
|
118
|
-
const response = await fetch(def.url, { method: "GET" });
|
|
146
|
+
const response = await fetch(def.url, { method: "GET", signal });
|
|
119
147
|
if (response.status !== 401)
|
|
120
148
|
return null;
|
|
121
149
|
const header = response.headers.get("www-authenticate");
|
|
@@ -46,6 +46,7 @@ export declare function parseMcpGatewayArgs(argv: readonly string[]): {
|
|
|
46
46
|
*/
|
|
47
47
|
export declare function resolveGatewayUpstreams(agentId: AgentId, canonical: Pick<ReturnType<typeof loadCanonicalSource>, "mcp" | "managedAgents" | "secretsPolicy">): {
|
|
48
48
|
upstreams: UpstreamSpec[];
|
|
49
|
+
oauthServers: string[];
|
|
49
50
|
conflicts: string[];
|
|
50
51
|
};
|
|
51
52
|
export declare function runMcpGateway(opts: RunMcpGatewayOptions): Promise<{
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
* an in-process backend without this file, or any agent's config,
|
|
15
15
|
* changing.
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
17
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { loadCanonicalSource } from "../core/canonical.js";
|
|
20
|
-
import { isGatewayAgent,
|
|
20
|
+
import { isGatewayAgent, isAgentOwnedOAuth, resolveMcpPlan } from "../adapters/mcpPlan.js";
|
|
21
21
|
import { LocalBackend } from "../lib/gatewayBackend.js";
|
|
22
22
|
import { DEFAULT_CONNECT_TIMEOUT_MS } from "../lib/mcpConnect.js";
|
|
23
23
|
import { BuiltinRegistry, createRuntimeServer, UpstreamProvider } from "../lib/mcpRuntime.js";
|
|
@@ -27,7 +27,7 @@ import { TaskProvider } from "../lib/taskProvider.js";
|
|
|
27
27
|
import { AgentBridgeProvider } from "../lib/agentBridgeProvider.js";
|
|
28
28
|
import { RuntimeMemoryProvider } from "../lib/memoryProvider.js";
|
|
29
29
|
import { SkillProvider } from "../lib/skillProvider.js";
|
|
30
|
-
import { ALL_AGENTS } from "../core/types.js";
|
|
30
|
+
import { ALL_AGENTS, isOAuthAuth } from "../core/types.js";
|
|
31
31
|
const CLIENT_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
|
|
32
32
|
const SERVER_INFO = { name: "trellis-mcp-gateway", version: "0.0.0" };
|
|
33
33
|
const RUNTIME_CLIENT_INFO = { name: "trellis-mcp-runtime", version: "0.0.0" };
|
|
@@ -60,12 +60,21 @@ export function resolveGatewayUpstreams(agentId, canonical) {
|
|
|
60
60
|
const route = canonical.mcp.routes?.[agentId];
|
|
61
61
|
const hubActive = route ? route.mode === "hub" : Boolean(canonical.mcp.hub) && !isGatewayAgent(agentId, canonical.mcp, canonical.managedAgents);
|
|
62
62
|
if (hubActive) {
|
|
63
|
-
|
|
63
|
+
// Hub mode writes no direct entries at all (mcpPlan's hub branch), so
|
|
64
|
+
// there are no OAuth servers for the status tool to report either.
|
|
65
|
+
return { upstreams: [], oauthServers: [], conflicts: [] };
|
|
64
66
|
}
|
|
65
67
|
const directRoutes = canonical.mcp.routes && route
|
|
66
68
|
? { ...canonical.mcp.routes, [agentId]: { mode: "direct", ...(route.servers ? { servers: route.servers } : {}) } }
|
|
67
69
|
: canonical.mcp.routes;
|
|
68
|
-
|
|
70
|
+
// Trellis-owned OAuth servers stay in the plan as ordinary upstreams
|
|
71
|
+
// (auth stripped here so the direct-mode plan keeps them; the original
|
|
72
|
+
// defs are restored below so withOAuthHeader can attach their tokens at
|
|
73
|
+
// connect). Agent-owned ones are excluded entirely: sync writes those as
|
|
74
|
+
// direct native entries beside the gateway (trellis-desktop-oauth-ownership).
|
|
75
|
+
const ordinaryServers = Object.fromEntries(Object.entries(canonical.mcp.servers)
|
|
76
|
+
.filter(([, def]) => !isAgentOwnedOAuth(def))
|
|
77
|
+
.map(([name, def]) => { const { auth: _auth, ...ordinary } = def; return [name, ordinary]; }));
|
|
69
78
|
const direct = { ...canonical.mcp, servers: ordinaryServers, gateway: undefined, hub: undefined, routes: directRoutes, runtime: undefined };
|
|
70
79
|
// The managed set is passed through unchanged, so an unmanaged agent
|
|
71
80
|
// reaches nothing. Being spawned is NOT treated as consent here, which
|
|
@@ -79,8 +88,20 @@ export function resolveGatewayUpstreams(agentId, canonical) {
|
|
|
79
88
|
// every server, and every secret resolved into it, to the one agent the
|
|
80
89
|
// user just said to stop involving.
|
|
81
90
|
const { desired, conflicts } = resolveMcpPlan(agentId, direct, canonical.managedAgents, canonical.secretsPolicy);
|
|
91
|
+
// Agent-owned OAuth servers bypass the gateway as direct entries, so no
|
|
92
|
+
// connection state exists for them here — but Trellis owns the token
|
|
93
|
+
// store, so `trellis.mcp.status` reports their credential health for
|
|
94
|
+
// exactly this set. It is resolved through a second direct-mode plan
|
|
95
|
+
// over just those servers, so scope, `enabled: false` and route
|
|
96
|
+
// restrictions match what sync writes (the identical planDirectServers
|
|
97
|
+
// checks; trellis-mcp-status-credential-health design.md D4).
|
|
98
|
+
const agentOwnedServers = Object.fromEntries(Object.entries(canonical.mcp.servers).filter(([, def]) => isAgentOwnedOAuth(def)));
|
|
99
|
+
const oauthServers = Object.keys(agentOwnedServers).length === 0
|
|
100
|
+
? []
|
|
101
|
+
: resolveMcpPlan(agentId, { ...direct, servers: agentOwnedServers }, canonical.managedAgents, canonical.secretsPolicy).desired.map(({ name }) => name);
|
|
82
102
|
return {
|
|
83
|
-
upstreams: desired.map(({ name
|
|
103
|
+
upstreams: desired.map(({ name }) => ({ name, def: canonical.mcp.servers[name] })),
|
|
104
|
+
oauthServers,
|
|
84
105
|
conflicts: conflicts.map((conflict) => conflict.message),
|
|
85
106
|
};
|
|
86
107
|
}
|
|
@@ -107,7 +128,7 @@ async function runMcpEdge(opts, clientInfo, serverInfo) {
|
|
|
107
128
|
console.error(`trellis-mcp-gateway: ${err instanceof Error ? err.message : String(err)}`);
|
|
108
129
|
return { exitCode: 1 };
|
|
109
130
|
}
|
|
110
|
-
const { upstreams, conflicts } = resolveGatewayUpstreams(opts.agentId, canonical);
|
|
131
|
+
const { upstreams, oauthServers, conflicts } = resolveGatewayUpstreams(opts.agentId, canonical);
|
|
111
132
|
for (const message of conflicts) {
|
|
112
133
|
console.error(`trellis-mcp-gateway: ${message}`);
|
|
113
134
|
}
|
|
@@ -122,10 +143,17 @@ async function runMcpEdge(opts, clientInfo, serverInfo) {
|
|
|
122
143
|
// process has no terminal to run one in (design.md D9).
|
|
123
144
|
homeDir,
|
|
124
145
|
});
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
146
|
+
// The status tool's oauth section covers both ownership kinds
|
|
147
|
+
// (trellis-desktop-oauth-ownership): trellis-owned servers arrive as
|
|
148
|
+
// upstreams (auth intact) — their Trellis-store credential health is
|
|
149
|
+
// what the gateway itself will rely on at connect; agent-owned servers
|
|
150
|
+
// bypass the gateway as direct entries and are marked agent-managed.
|
|
151
|
+
const oauthReport = [
|
|
152
|
+
...upstreams.filter((u) => isOAuthAuth(u.def.auth)).map((u) => ({ name: u.name, owner: "trellis" })),
|
|
153
|
+
...oauthServers.map((name) => ({ name, owner: "agent" })),
|
|
154
|
+
];
|
|
155
|
+
const registry = new BuiltinRegistry([new SkillProvider(), new RuntimeMemoryProvider(), new RuntimeControlProvider(backend), new McpStatusProvider(backend, { homeDir, oauthServers: oauthReport }), new TaskProvider(), new AgentBridgeProvider(), new UpstreamProvider(backend)]);
|
|
156
|
+
const server = serveStdio(() => createRuntimeServer(serverInfo, { agentId: opts.agentId, homeDir }, registry));
|
|
129
157
|
await waitForShutdown(registry, server);
|
|
130
158
|
return { exitCode: 0 };
|
|
131
159
|
}
|